tea-model 0.1.0

Provider-neutral model port for tea-rs
Documentation
use std::str::FromStr;

use tea_model::{
    HostedToolKind, ModelCapabilities, ModelDisplayName, ModelSpec, ModelSpecError, ProviderId,
    ReasoningEffort, ReasoningProfile,
};
use tea_protocol::{ModelId, TokenCount};

fn tokens(value: u64) -> TokenCount {
    TokenCount::new(value).unwrap()
}

#[test]
fn provider_and_display_names_are_bounded() {
    assert_eq!(
        ProviderId::from_str("openai-compatible").unwrap().as_str(),
        "openai-compatible"
    );
    assert!(ProviderId::from_str("").is_err());
    assert!(ProviderId::from_str("OpenAI").is_err());
    assert!(ProviderId::from_str("openai compatible").is_err());
    assert!(ProviderId::from_str(&"p".repeat(129)).is_err());

    assert_eq!(
        ModelDisplayName::from_str("Claude Sonnet 4")
            .unwrap()
            .as_str(),
        "Claude Sonnet 4"
    );
    assert!(ModelDisplayName::from_str("").is_err());
    assert!(ModelDisplayName::from_str("bad\nname").is_err());
    assert!(ModelDisplayName::from_str(&"m".repeat(257)).is_err());
}

#[test]
fn capabilities_are_explicit_and_internally_consistent() {
    let text_only = ModelCapabilities::text();
    assert!(text_only.accepts_text());
    assert!(!text_only.accepts_images());
    assert!(!text_only.supports_reasoning());
    assert!(!text_only.supports_tools());
    assert!(!text_only.supports_parallel_tool_calls());
    assert!(!text_only.supports_hosted_tool(HostedToolKind::WebSearch));
    assert!(!text_only.reports_usage());

    let capable = text_only
        .with_image_input()
        .with_reasoning()
        .with_tools(true)
        .with_hosted_tool(HostedToolKind::WebSearch)
        .with_usage_reporting();
    assert!(capable.accepts_text());
    assert!(capable.accepts_images());
    assert!(capable.supports_reasoning());
    assert!(capable.supports_tools());
    assert!(capable.supports_parallel_tool_calls());
    assert!(capable.supports_hosted_tool(HostedToolKind::WebSearch));
    assert!(capable.reports_usage());

    let serial_tools = ModelCapabilities::text().with_tools(false);
    assert!(serial_tools.supports_tools());
    assert!(!serial_tools.supports_parallel_tool_calls());
}

#[test]
fn model_spec_enforces_context_and_output_limits() {
    let model_id = ModelId::from_str("anthropic/claude-sonnet-4").unwrap();
    let provider_id = ProviderId::from_str("anthropic").unwrap();
    let display_name = ModelDisplayName::from_str("Claude Sonnet 4").unwrap();
    let capabilities = ModelCapabilities::text().with_reasoning().with_tools(true);

    let spec = ModelSpec::new(
        model_id.clone(),
        provider_id.clone(),
        display_name.clone(),
        tokens(200_000),
        tokens(64_000),
        capabilities,
    )
    .unwrap();

    assert_eq!(spec.model_id(), &model_id);
    assert_eq!(spec.provider_id(), &provider_id);
    assert_eq!(spec.display_name(), &display_name);
    assert_eq!(spec.context_window_tokens(), tokens(200_000));
    assert_eq!(spec.max_output_tokens(), tokens(64_000));
    assert_eq!(spec.capabilities(), capabilities);

    assert_eq!(
        ModelSpec::new(
            model_id.clone(),
            provider_id.clone(),
            display_name.clone(),
            tokens(0),
            tokens(1),
            capabilities,
        )
        .unwrap_err(),
        ModelSpecError::EmptyContextWindow
    );
    assert_eq!(
        ModelSpec::new(
            model_id.clone(),
            provider_id.clone(),
            display_name.clone(),
            tokens(100),
            tokens(0),
            capabilities,
        )
        .unwrap_err(),
        ModelSpecError::EmptyOutputLimit
    );
    assert_eq!(
        ModelSpec::new(
            model_id,
            provider_id,
            display_name,
            tokens(100),
            tokens(101),
            capabilities,
        )
        .unwrap_err(),
        ModelSpecError::OutputExceedsContext
    );
}

#[test]
fn model_types_are_send_sync() {
    fn assert_send_sync<T: Send + Sync>() {}
    assert_send_sync::<ModelSpec>();
    assert_send_sync::<ModelCapabilities>();
    assert_send_sync::<ReasoningProfile>();
}

#[test]
fn reasoning_profiles_validate_defaults_and_clamp_upward_first() {
    assert_eq!(
        ReasoningProfile::new(ReasoningEffort::Medium, []).unwrap_err(),
        ModelSpecError::EmptyReasoningEfforts
    );
    assert_eq!(
        ReasoningProfile::new(
            ReasoningEffort::Medium,
            [ReasoningEffort::Low, ReasoningEffort::High],
        )
        .unwrap_err(),
        ModelSpecError::ReasoningDefaultUnsupported
    );
    assert_eq!(
        ReasoningProfile::new(
            ReasoningEffort::Low,
            [ReasoningEffort::Low, ReasoningEffort::Low],
        )
        .unwrap_err(),
        ModelSpecError::DuplicateReasoningEffort
    );

    let profile = ReasoningProfile::new(
        ReasoningEffort::Medium,
        [
            ReasoningEffort::Off,
            ReasoningEffort::Medium,
            ReasoningEffort::Maximum,
        ],
    )
    .unwrap();
    assert_eq!(
        profile.supported_efforts(),
        &[
            ReasoningEffort::Off,
            ReasoningEffort::Medium,
            ReasoningEffort::Maximum,
        ]
    );
    assert_eq!(
        profile.resolve(ReasoningEffort::Low).effective(),
        ReasoningEffort::Medium
    );
    assert!(profile.resolve(ReasoningEffort::Low).was_clamped());
    assert_eq!(
        profile.resolve(ReasoningEffort::High).effective(),
        ReasoningEffort::Maximum
    );
    assert_eq!(
        profile.resolve(ReasoningEffort::Maximum).effective(),
        ReasoningEffort::Maximum
    );
    assert!(!profile.resolve(ReasoningEffort::Maximum).was_clamped());
}

#[test]
fn model_spec_carries_explicit_or_compatible_reasoning_profiles() {
    let explicit = ReasoningProfile::new(
        ReasoningEffort::High,
        [ReasoningEffort::Low, ReasoningEffort::High],
    )
    .unwrap();
    let spec = ModelSpec::new(
        ModelId::from_str("test/reasoning").unwrap(),
        ProviderId::from_str("test").unwrap(),
        ModelDisplayName::from_str("Reasoning").unwrap(),
        tokens(10_000),
        tokens(2_000),
        ModelCapabilities::text(),
    )
    .unwrap()
    .with_reasoning_profile(explicit.clone());
    assert_eq!(spec.reasoning_profile(), Some(&explicit));
    assert!(spec.capabilities().supports_reasoning());

    let compatible = ModelSpec::new(
        ModelId::from_str("test/legacy").unwrap(),
        ProviderId::from_str("test").unwrap(),
        ModelDisplayName::from_str("Legacy").unwrap(),
        tokens(10_000),
        tokens(2_000),
        ModelCapabilities::text().with_reasoning(),
    )
    .unwrap();
    let compatible = compatible.reasoning_profile().unwrap();
    assert_eq!(compatible.default_effort(), ReasoningEffort::Medium);
    assert!(
        !compatible
            .supported_efforts()
            .contains(&ReasoningEffort::ExtraHigh)
    );
    assert!(
        !compatible
            .supported_efforts()
            .contains(&ReasoningEffort::Maximum)
    );

    let plain = ModelSpec::new(
        ModelId::from_str("test/plain").unwrap(),
        ProviderId::from_str("test").unwrap(),
        ModelDisplayName::from_str("Plain").unwrap(),
        tokens(10_000),
        tokens(2_000),
        ModelCapabilities::text(),
    )
    .unwrap();
    assert_eq!(plain.reasoning_profile(), None);
    let disabled = plain
        .resolve_reasoning(Some(ReasoningEffort::High))
        .unwrap();
    assert_eq!(disabled.requested(), ReasoningEffort::High);
    assert_eq!(disabled.effective(), ReasoningEffort::Off);
    assert!(disabled.was_clamped());
    assert_eq!(plain.resolve_reasoning(None), None);
}