ai-dispatch 11.1.0

Multi-AI CLI team orchestrator
// Owned model-catalog records, including CLI-discovered models and pricing overrides.
// Exports resolved catalog queries without adding a second discovery cache.
// Deps: static model catalog, served-only rows (model_catalog_served), serde.

use anyhow::Result;
use serde::Deserialize;

use super::model_catalog_served::{served_only_models, SERVED_PROBE_AGENTS};
use super::{static_models_for_agent, AgentModel, AGENT_MODELS};
use crate::types::AgentKind;

/// Where a catalog row came from; leaderboard evidence is resolved separately.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelOrigin {
    Catalog,
    Served,
    PricingOverride,
}

impl ModelOrigin {
    pub fn label(self) -> &'static str {
        match self {
            Self::Catalog => "catalog",
            Self::Served => "served",
            Self::PricingOverride => "pricing_override",
        }
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct PricingFileModel {
    pub agent: String,
    pub model: String,
    pub input_per_m: f64,
    pub output_per_m: f64,
    pub tier: String,
    pub description: String,
    pub updated: String,
}

#[derive(Debug, Clone)]
pub struct ResolvedAgentModel {
    pub agent: AgentKind,
    pub model: String,
    pub tier: String,
    pub description: String,
    pub origin: ModelOrigin,
}

impl From<&AgentModel> for ResolvedAgentModel {
    fn from(model: &AgentModel) -> Self {
        Self {
            agent: model.agent,
            model: model.model.to_string(),
            tier: model.tier.to_string(),
            description: model.description.to_string(),
            origin: ModelOrigin::Catalog,
        }
    }
}

impl ResolvedAgentModel {
    pub fn from_override(agent: AgentKind, model: PricingFileModel) -> Self {
        let PricingFileModel { model, tier, description, .. } = model;
        Self {
            agent,
            model,
            tier,
            description,
            origin: ModelOrigin::PricingOverride,
        }
    }
}

pub fn models_for_agent(agent: &AgentKind) -> Vec<ResolvedAgentModel> {
    let mut models: Vec<_> = static_models_for_agent(agent)
        .into_iter()
        .map(ResolvedAgentModel::from)
        .collect();
    models.extend(served_only_models(*agent));
    models
}

#[derive(Debug, Clone, Deserialize)]
pub struct PricingResponse {
    pub models: Vec<PricingFileModel>,
}

pub fn load_pricing_overrides() -> Result<Vec<PricingFileModel>> {
    let path = crate::paths::pricing_path();
    if !path.exists() {
        return Ok(Vec::new());
    }
    let contents = std::fs::read_to_string(path)?;
    let response: PricingResponse = serde_json::from_str(&contents)?;
    Ok(response.models)
}

pub fn merged_agent_models() -> Result<Vec<ResolvedAgentModel>> {
    let mut merged = Vec::with_capacity(AGENT_MODELS.len());
    for model in AGENT_MODELS {
        merged.push(ResolvedAgentModel::from(model));
    }
    for model in SERVED_PROBE_AGENTS.iter().flat_map(|agent| served_only_models(*agent)) {
        merged.push(model);
    }
    for model in load_pricing_overrides()? {
        let Some(agent) = AgentKind::parse_str(&model.agent) else {
            continue;
        };
        if let Some(listed) = merged.iter_mut()
            .find(|row| row.agent == agent && row.model.eq_ignore_ascii_case(&model.model)) {
            listed.tier = model.tier;
            listed.description = model.description;
        } else {
            merged.push(ResolvedAgentModel::from_override(agent, model));
        }
    }
    Ok(merged)
}