goose-provider-types 0.1.0-alpha.13

An AI agent
Documentation
use super::{
    canonical_name, CanonicalModel, CanonicalModelRegistry, Limit, Modalities, Modality, Pricing,
    PricingTier, ThinkingMode,
};
use anyhow::{Context, Result};
use serde_json::Value;
use std::collections::BTreeMap;

const DEFAULT_CONTEXT_LIMIT: usize = 128_000;

fn normalize_provider_name(provider: &str) -> &str {
    match provider {
        "llama" => "meta-llama",
        "xai" => "x-ai",
        "mistral" => "mistralai",
        _ => provider,
    }
}

fn get_string(value: &Value, field: &str) -> Option<String> {
    value.get(field).and_then(|v| v.as_str()).map(String::from)
}

fn get_thinking_mode(canonical_id: &str, value: &Value) -> Option<ThinkingMode> {
    value
        .get("thinking_mode")
        .and_then(|v| v.as_str())
        .and_then(|mode| serde_json::from_value(Value::String(mode.to_string())).ok())
        .or_else(|| inferred_thinking_mode(canonical_id))
}

fn inferred_thinking_mode(canonical_id: &str) -> Option<ThinkingMode> {
    match canonical_id {
        "anthropic/claude-fable-5" => Some(ThinkingMode::AlwaysOnAdaptive),
        "anthropic/claude-fable-5.1" => Some(ThinkingMode::AlwaysOnAdaptive),
        "anthropic/claude-opus-5" => Some(ThinkingMode::Adaptive),
        "anthropic/claude-opus-5.5" => Some(ThinkingMode::AlwaysOnAdaptive),
        "anthropic/claude-opus-4.6" => Some(ThinkingMode::Adaptive),
        "anthropic/claude-opus-4.7" => Some(ThinkingMode::Adaptive),
        "anthropic/claude-opus-4.8" => Some(ThinkingMode::Adaptive),
        "anthropic/claude-sonnet-4.6" => Some(ThinkingMode::Adaptive),
        "anthropic/claude-sonnet-5" => Some(ThinkingMode::Adaptive),
        "anthropic/claude-sonnet-5.5" => Some(ThinkingMode::AlwaysOnAdaptive),
        "anthropic/claude-haiku-5.5" => Some(ThinkingMode::AlwaysOnAdaptive),
        _ => None,
    }
}

fn parse_modalities(model_data: &Value, field: &str) -> Vec<Modality> {
    model_data
        .get("modalities")
        .and_then(|m| m.get(field))
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str())
                .filter_map(|s| {
                    serde_json::from_value(serde_json::Value::String(s.to_string())).ok()
                })
                .collect()
        })
        .unwrap_or_else(|| vec![Modality::Text])
}

fn parse_context_tiers(cost: &Value) -> Vec<PricingTier> {
    let Some(tiers) = cost.get("tiers").and_then(|v| v.as_array()) else {
        return Vec::new();
    };
    tiers
        .iter()
        .filter(|t| t["tier"]["type"] == "context")
        .filter_map(|t| {
            Some(PricingTier {
                above_input_tokens: t["tier"]["size"].as_u64()?,
                input: t.get("input").and_then(|v| v.as_f64()),
                output: t.get("output").and_then(|v| v.as_f64()),
                cache_read: t.get("cache_read").and_then(|v| v.as_f64()),
                cache_write: t.get("cache_write").and_then(|v| v.as_f64()),
            })
        })
        .collect()
}

fn process_model(
    model_id: &str,
    model_data: &Value,
    normalized_provider: &str,
) -> Result<(String, CanonicalModel)> {
    let name = model_data["name"]
        .as_str()
        .with_context(|| format!("Model {} missing name", model_id))?;

    let canonical_id = canonical_name(normalized_provider, model_id);

    let modalities = Modalities {
        input: parse_modalities(model_data, "input"),
        output: parse_modalities(model_data, "output"),
    };

    let cost = match model_data.get("cost") {
        Some(c) if !c.is_null() => Pricing {
            input: c.get("input").and_then(|v| v.as_f64()),
            output: c.get("output").and_then(|v| v.as_f64()),
            cache_read: c.get("cache_read").and_then(|v| v.as_f64()),
            cache_write: c.get("cache_write").and_then(|v| v.as_f64()),
            tiers: parse_context_tiers(c),
        },
        _ => Pricing::default(),
    };

    let limit = Limit {
        context: model_data
            .get("limit")
            .and_then(|l| l.get("context"))
            .and_then(|v| v.as_u64())
            .unwrap_or(DEFAULT_CONTEXT_LIMIT as u64) as usize,
        output: model_data
            .get("limit")
            .and_then(|l| l.get("output"))
            .and_then(|v| v.as_u64())
            .map(|v| v as usize),
    };

    let canonical_model = CanonicalModel {
        id: canonical_id.clone(),
        name: name.to_string(),
        family: get_string(model_data, "family"),
        attachment: model_data.get("attachment").and_then(|v| v.as_bool()),
        reasoning: model_data.get("reasoning").and_then(|v| v.as_bool()),
        reasoning_efforts: model_data
            .get("reasoning_options")
            .and_then(|v| v.as_array())
            .and_then(|options| {
                options
                    .iter()
                    .find(|option| option.get("type").and_then(Value::as_str) == Some("effort"))
            })
            .and_then(|option| option.get("values"))
            .and_then(Value::as_array)
            .map(|values| {
                values
                    .iter()
                    .filter_map(Value::as_str)
                    .map(str::to_owned)
                    .collect()
            }),
        thinking_mode: get_thinking_mode(&canonical_id, model_data),
        tool_call: model_data
            .get("tool_call")
            .and_then(|v| v.as_bool())
            .unwrap_or(false),
        temperature: model_data.get("temperature").and_then(|v| v.as_bool()),
        knowledge: get_string(model_data, "knowledge"),
        release_date: get_string(model_data, "release_date"),
        last_updated: get_string(model_data, "last_updated"),
        modalities,
        open_weights: model_data.get("open_weights").and_then(|v| v.as_bool()),
        cost,
        limit,
    };

    let model_name = canonical_id
        .strip_prefix(&format!("{}/", normalized_provider))
        .unwrap_or(model_id)
        .to_string();

    Ok((model_name, canonical_model))
}

fn pick_winning_variant(variants: &[(String, CanonicalModel)]) -> usize {
    variants
        .iter()
        .enumerate()
        .min_by(|(_, (id_a, a)), (_, (id_b, b))| {
            id_a.len()
                .cmp(&id_b.len())
                .then_with(|| b.last_updated.cmp(&a.last_updated))
                .then_with(|| b.release_date.cmp(&a.release_date))
                .then_with(|| id_a.cmp(id_b))
        })
        .map(|(idx, _)| idx)
        .unwrap_or(0)
}

pub fn from_models_dev(content: &str) -> Result<CanonicalModelRegistry> {
    let json: Value = serde_json::from_str(content)?;
    let providers = json
        .as_object()
        .context("Expected object in models.dev response")?;
    let mut registry = CanonicalModelRegistry::new();
    for (provider_key, provider_data) in providers {
        let models = match provider_data.get("models").and_then(Value::as_object) {
            Some(models) => models,
            None => continue,
        };
        let provider = normalize_provider_name(provider_key);
        let mut candidates: BTreeMap<String, Vec<(String, CanonicalModel)>> = BTreeMap::new();
        for (model_id, model_data) in models {
            let (name, model) = process_model(model_id, model_data, provider)?;
            candidates
                .entry(name)
                .or_default()
                .push((model_id.clone(), model));
        }
        for (name, variants) in candidates {
            let winner = pick_winning_variant(&variants);
            registry.register(provider, &name, variants[winner].1.clone());
        }
    }
    if registry.count() == 0 {
        anyhow::bail!("models.dev catalog is empty");
    }
    Ok(registry)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn variant(id: &str, release: Option<&str>, updated: Option<&str>) -> (String, CanonicalModel) {
        (
            id.to_string(),
            CanonicalModel {
                id: format!("openai/{id}"),
                name: id.to_string(),
                family: None,
                attachment: None,
                reasoning: None,
                reasoning_efforts: None,
                thinking_mode: None,
                tool_call: false,
                temperature: None,
                knowledge: None,
                release_date: release.map(String::from),
                last_updated: updated.map(String::from),
                modalities: Modalities::default(),
                open_weights: None,
                cost: Pricing::default(),
                limit: Limit::default(),
            },
        )
    }

    #[test]
    fn shortest_variant_wins() {
        let variants = vec![
            variant("gpt-4o-2024-08-06", Some("2024-08-06"), Some("2024-08-06")),
            variant("gpt-4o", Some("2024-05-13"), Some("2024-08-06")),
            variant("gpt-4o-2024-11-20", Some("2024-11-20"), Some("2024-11-20")),
            variant("gpt-4o-2024-05-13", Some("2024-05-13"), Some("2024-05-13")),
        ];
        assert_eq!(variants[pick_winning_variant(&variants)].0, "gpt-4o");

        let variants = vec![
            variant(
                "claude-haiku-4-5-20251001",
                Some("2025-10-16"),
                Some("2025-10-16"),
            ),
            variant("claude-haiku-4-5", Some("2025-10-16"), Some("2025-10-16")),
        ];
        assert_eq!(
            variants[pick_winning_variant(&variants)].0,
            "claude-haiku-4-5"
        );
    }

    #[test]
    fn parses_effort_options_without_confusing_other_reasoning_options() {
        let json = r#"{"openai":{"models":{"future":{"name":"Future","reasoning":true,"reasoning_options":[{"type":"budget","values":[100]},{"type":"effort","values":["low","max"]}]}}}}"#;
        let registry = from_models_dev(json).unwrap();
        assert_eq!(
            registry.get("openai", "future").unwrap().reasoning_efforts,
            Some(vec!["low".to_string(), "max".to_string()])
        );
    }

    #[test]
    fn converts_provider_models_and_rejects_empty_catalog() {
        let json = r#"{"openai":{"models":{"gpt-4o":{"name":"GPT-4o","tool_call":true,"limit":{"context":128000,"output":4096},"cost":{"input":2.5}}}}}"#;
        let registry = from_models_dev(json).unwrap();
        let model = registry.get("openai", "gpt-4o").unwrap();
        assert_eq!(model.id, "openai/gpt-4o");
        assert_eq!(model.limit.context, 128000);
        assert_eq!(model.cost.input, Some(2.5));
        assert!(from_models_dev("{}").is_err());
    }
}