providers-kit 0.0.2

Portable enum-dispatch Kit over multiple model provider APIs.
Documentation
use providers_kit::config::{
    AnthropicConfig, ApiKey, GeminiConfig, OllamaConfig, OpenAiConfig, ProviderKind,
    TransportConfig,
};
use providers_kit::embedding::EmbedRequest;
use providers_kit::error::ErrorKind;
use providers_kit::files::FileUploadRequest;
use providers_kit::generation::{
    ContentPart, FunctionCall, GenerateRequest, GenerationConfig, Message, ReasoningConfig,
    ReasoningEffort, Role,
};
use providers_kit::models::ModelId;
use providers_kit::session::SessionConfig;
use providers_kit::{ModelBackend, Provider, ProviderBackend, ProviderConfig};
use serde_json::json;
use std::num::NonZeroU32;
use url::Url;

fn key(value: &str) -> ApiKey {
    ApiKey::new(value).expect("fixture key should validate")
}

fn provider(kind: ProviderKind) -> Provider {
    Provider::from_config(ProviderConfig::new(kind, TransportConfig::default()))
        .expect("fixture provider should construct without network I/O")
}

#[test]
fn file_upload_requests_reject_header_unsafe_values_during_construction_and_deserialization() {
    for (file_name, media_type) in [
        (" padded.txt ", "text/plain"),
        ("unsafe\nname.txt", "text/plain"),
        ("data.txt", " text/plain"),
        ("data.txt", "text/plain/extra"),
        ("data.txt", "text/plain;"),
    ] {
        assert!(
            matches!(
                FileUploadRequest::new(file_name, media_type, vec![1]),
                Err(ref error) if error.kind() == ErrorKind::InvalidInput
            ),
            "unsafe upload values were accepted: {file_name:?}, {media_type:?}"
        );
        assert!(
            serde_json::from_value::<FileUploadRequest>(json!({
                "file_name": file_name,
                "media_type": media_type,
                "data": "AQ=="
            }))
            .is_err()
        );
    }
    FileUploadRequest::new("data.txt", "text/plain; charset=utf-8", vec![1])
        .expect("valid media type parameters should remain accepted");
}

#[tokio::test]
async fn sessionless_models_report_unsupported_without_io() {
    let ollama = provider(ProviderKind::Ollama(
        OllamaConfig::local()
            .with_base_url(Url::parse("http://127.0.0.1:9/api").expect("URL should parse"))
            .expect("exact /api base should validate"),
    ));
    let ollama_model = ollama
        .model(ModelId::new("chat-model").expect("model id should validate"))
        .expect("exact model should construct");
    assert!(matches!(
        ollama_model.open_session(SessionConfig::default()).await,
        Err(ref error) if error.kind() == ErrorKind::Unsupported
    ));

    let anthropic = provider(ProviderKind::Anthropic(
        AnthropicConfig::new(key("anthropic-test"))
            .with_base_url(Url::parse("http://127.0.0.1:9").expect("URL should parse")),
    ));
    let anthropic_model = anthropic
        .model(ModelId::new("claude-test").expect("model id should validate"))
        .expect("exact model should construct");
    let embedding =
        EmbedRequest::new(vec!["hello".to_string()]).expect("embedding should validate");
    assert!(matches!(
        anthropic_model.embed(&embedding).await,
        Err(ref error) if error.kind() == ErrorKind::Unsupported
    ));
    assert!(matches!(
        anthropic_model.open_session(SessionConfig::default()).await,
        Err(ref error) if error.kind() == ErrorKind::Unsupported
    ));

    ollama.shutdown().await.expect("shutdown should succeed");
    anthropic.shutdown().await.expect("shutdown should succeed");
}

#[test]
fn gemini_requires_the_exact_resource_model_id() {
    let gemini = provider(ProviderKind::Gemini(
        GeminiConfig::new(key("gemini-test"))
            .with_base_url(Url::parse("http://127.0.0.1:9").expect("URL should parse")),
    ));
    let error = gemini
        .model(ModelId::new("gemini-2.5-flash").expect("model id should validate"))
        .expect_err("Gemini model ids must retain the models/ resource prefix");
    assert!(matches!(error.kind(), ErrorKind::InvalidInput));

    let model = gemini
        .model(ModelId::new("models/gemini-2.5-flash").expect("model id should validate"))
        .expect("exact Gemini resource id should construct");
    assert_eq!(model.model_id().as_str(), "models/gemini-2.5-flash");
}

#[tokio::test]
async fn model_metadata_rejects_path_injection_before_io() {
    let openai = provider(ProviderKind::OpenAi(
        OpenAiConfig::new(key("openai-test"))
            .with_base_url(Url::parse("http://127.0.0.1:9/v1").expect("URL should parse")),
    ));
    assert!(matches!(
        openai
            .model_info(ModelId::new("gpt-safe?leak=1").expect("model id should validate"))
            .await,
        Err(ref error) if error.kind() == ErrorKind::InvalidInput
    ));

    let anthropic = provider(ProviderKind::Anthropic(
        AnthropicConfig::new(key("anthropic-test"))
            .with_base_url(Url::parse("http://127.0.0.1:9").expect("URL should parse")),
    ));
    assert!(matches!(
        anthropic
            .model_info(ModelId::new("claude-safe/extra").expect("model id should validate"))
            .await,
        Err(ref error) if error.kind() == ErrorKind::InvalidInput
    ));

    let gemini = provider(ProviderKind::Gemini(
        GeminiConfig::new(key("gemini-test"))
            .with_base_url(Url::parse("http://127.0.0.1:9").expect("URL should parse")),
    ));
    assert!(matches!(
        gemini
            .model(ModelId::new("models/gemini-safe#fragment").expect("model id should validate")),
        Err(ref error) if error.kind() == ErrorKind::InvalidInput
    ));
}

#[tokio::test]
async fn interactive_model_and_audio_compatibility_is_checked_before_websocket_io() {
    let openai = provider(ProviderKind::OpenAi(
        OpenAiConfig::new(key("openai-test"))
            .with_base_url(Url::parse("http://127.0.0.1:9/v1").expect("URL should parse")),
    ));
    let openai_stateless = openai
        .model(ModelId::new("gpt-standard").expect("model id should validate"))
        .expect("exact stateless model should construct");
    assert!(matches!(
        openai_stateless
            .open_session(SessionConfig::default())
            .await,
        Err(ref error) if error.kind() == ErrorKind::Unsupported
    ));
    let openai_model = openai
        .model(ModelId::new("gpt-realtime").expect("model id should validate"))
        .expect("exact model should construct");
    let unsupported_openai_audio = SessionConfig::default()
        .with_input_audio("audio/opus", false)
        .expect("portable audio media type should validate");
    assert!(matches!(
        openai_model.open_session(unsupported_openai_audio).await,
        Err(ref error) if error.kind() == ErrorKind::Unsupported
    ));

    let gemini = provider(ProviderKind::Gemini(
        GeminiConfig::new(key("gemini-test"))
            .with_base_url(Url::parse("http://127.0.0.1:9").expect("URL should parse")),
    ));
    let gemini_stateless = gemini
        .model(ModelId::new("models/gemini-2.5-flash").expect("model id should validate"))
        .expect("exact stateless model should construct");
    assert!(matches!(
        gemini_stateless
            .open_session(SessionConfig::default())
            .await,
        Err(ref error) if error.kind() == ErrorKind::Unsupported
    ));
    let gemini_live = gemini
        .model(
            ModelId::new("models/gemini-3.1-flash-live-preview").expect("model id should validate"),
        )
        .expect("exact live model should construct");
    assert!(matches!(
        gemini_live.open_session(SessionConfig::default()).await,
        Err(ref error) if error.kind() == ErrorKind::Unsupported
    ));
    let unsupported_gemini_audio = SessionConfig::default()
        .with_output_audio("audio/opus", false)
        .expect("portable audio media type should validate");
    assert!(matches!(
        gemini_live.open_session(unsupported_gemini_audio).await,
        Err(ref error) if error.kind() == ErrorKind::Unsupported
    ));

    openai.shutdown().await.expect("shutdown should succeed");
    gemini.shutdown().await.expect("shutdown should succeed");
}

#[tokio::test]
async fn anthropic_reasoning_uses_only_exact_known_model_profiles() {
    let anthropic = provider(ProviderKind::Anthropic(
        AnthropicConfig::new(key("anthropic-test"))
            .with_base_url(Url::parse("http://127.0.0.1:9").expect("URL should parse")),
    ));
    let request = |reasoning| {
        GenerateRequest::new(vec![
            Message::user_text("reason").expect("message should validate"),
        ])
        .expect("request should validate")
        .with_generation(
            GenerationConfig::default()
                .with_max_output_tokens(NonZeroU32::new(2048).expect("limit is non-zero")),
        )
        .expect("generation controls should validate")
        .with_reasoning(reasoning)
    };
    let effort = request(ReasoningConfig {
        effort: Some(ReasoningEffort::Maximum),
        ..ReasoningConfig::default()
    });
    let budget = request(ReasoningConfig {
        max_reasoning_tokens: Some(1024),
        ..ReasoningConfig::default()
    });

    for id in [
        "claude-fable-5",
        "claude-opus-5",
        "claude-sonnet-5",
        "claude-opus-4-8",
        "claude-opus-4-7",
    ] {
        let model = anthropic
            .model(ModelId::new(id).expect("model id should validate"))
            .expect("exact model should construct");
        assert!(matches!(
            model.generate(&effort).await,
            Err(ref error) if error.kind() == ErrorKind::Transport
        ));
        assert!(matches!(
            model.generate(&budget).await,
            Err(ref error) if error.kind() == ErrorKind::Unsupported
        ));
    }

    for id in ["claude-opus-4-6", "claude-sonnet-4-6"] {
        let model = anthropic
            .model(ModelId::new(id).expect("model id should validate"))
            .expect("exact model should construct");
        assert!(matches!(
            model.generate(&effort).await,
            Err(ref error) if error.kind() == ErrorKind::Transport
        ));
        assert!(matches!(
            model.generate(&budget).await,
            Err(ref error) if error.kind() == ErrorKind::Transport
        ));
    }

    let haiku = anthropic
        .model(ModelId::new("claude-haiku-4-5-20251001").expect("model id should validate"))
        .expect("exact model should construct");
    assert!(matches!(
        haiku.generate(&budget).await,
        Err(ref error) if error.kind() == ErrorKind::Transport
    ));
    assert!(matches!(
        haiku.generate(&effort).await,
        Err(ref error) if error.kind() == ErrorKind::Unsupported
    ));

    let unknown = anthropic
        .model(ModelId::new("claude-unknown").expect("model id should validate"))
        .expect("exact model should construct");
    assert!(matches!(
        unknown.generate(&effort).await,
        Err(ref error) if error.kind() == ErrorKind::Unsupported
    ));

    anthropic.shutdown().await.expect("shutdown should succeed");
}

#[test]
fn function_calls_are_constructed_on_assistant_messages() {
    let call = FunctionCall::new(None, "lookup", json!({})).expect("function call should validate");
    let message: Message = Message::assistant(
        vec![ContentPart::Text {
            text: "hello".to_string(),
        }],
        vec![call],
    )
    .expect("assistant message with function call should validate");
    assert_eq!(message.role(), Role::Assistant);
    assert_eq!(message.function_calls().len(), 1);
}