providers-kit 0.0.2

Portable enum-dispatch Kit over multiple model provider APIs.
Documentation
use std::collections::BTreeSet;

use serde_json::{Map, Value, json};

use crate::KitError;
use crate::core::HostedToolMode;
use crate::error::ValidationViolation;
use crate::generation::ProviderToolSpec;
use crate::hosted::HostedTool;
use crate::models::ModelId;

pub(crate) fn render(
    model: &ModelId,
    specs: &[ProviderToolSpec],
    _mode: HostedToolMode,
) -> Result<(Vec<Value>, Map<String, Value>), KitError> {
    let hosted = specs
        .iter()
        .filter_map(ProviderToolSpec::hosted_tool)
        .collect::<Vec<_>>();
    if hosted.is_empty() {
        return Ok((Vec::new(), Map::new()));
    }
    if !supports_hosted(model) {
        return Err(KitError::unsupported(
            ValidationViolation::Unsupported {
                field: "tools.hosted",
                rule: "hosted tools unavailable for selected model",
            },
            format!("Gemini hosted tools for model `{}`", model.as_str()),
        ));
    }
    if hosted.len() > 1
        && hosted
            .iter()
            .any(|tool| matches!(tool, HostedTool::FileSearch { .. }))
    {
        return Err(KitError::invalid_input(
            ValidationViolation::Incompatible {
                field: "tools.file_search",
                other: "tools.hosted",
            },
            "Gemini file search cannot be combined with another hosted tool",
        ));
    }
    let mut tools = Vec::new();
    for tool in hosted {
        tools.push(match tool {
            HostedTool::WebSearch => json!({"google_search": {}}),
            HostedTool::UrlContext => json!({"url_context": {}}),
            HostedTool::CodeExecution => json!({"code_execution": {}}),
            HostedTool::FileSearch { store_ids } => {
                validate_store_ids(store_ids)?;
                json!({"fileSearch": {"file_search_store_names": store_ids}})
            }
        });
    }
    Ok((tools, Map::new()))
}

fn validate_store_ids(values: &[String]) -> Result<(), KitError> {
    let mut unique = BTreeSet::new();
    if values.is_empty()
        || values
            .iter()
            .any(|value| value.trim().is_empty() || !unique.insert(value))
    {
        return Err(KitError::invalid_input(
            ValidationViolation::InvalidValue {
                field: "tools.file_search.store_ids",
                rule: "nonempty collection of unique nonblank IDs",
            },
            "Gemini file search requires unique nonblank store IDs",
        ));
    }
    Ok(())
}

fn supports_hosted(model: &ModelId) -> bool {
    matches!(
        model.as_str(),
        "models/gemini-4-argon"
            | "models/gemini-3.8-flash"
            | "models/gemini-3.1-pro-preview"
            | "models/gemini-3-flash-preview"
            | "models/gemini-2.5-pro"
            | "models/gemini-2.5-flash"
            | "models/gemini-2.5-flash-lite"
    )
}