ai-dispatch 10.38.0

Multi-AI CLI team orchestrator
// Built-in substring model pricing lookup (USD per million tokens).
// Exports: for_model_lower, is_free_named
// Deps: super::ModelPricing

use super::ModelPricing;

/// Provider naming convention: an id ending in `-free`, `/free`, or `:free`
/// is self-declared free. This encodes a suffix convention, not known pricing.
pub(super) fn is_free_named(model: &str) -> bool {
    let lower = model.to_lowercase();
    lower.ends_with("-free") || lower.ends_with("/free") || lower.ends_with(":free")
}

/// Match `lower` (`model.to_lowercase()`) against known tiers.
pub(super) fn for_model_lower(m: &str) -> Option<ModelPricing> {
    let p = if m.contains("gpt-4.1") && !m.contains("mini") && !m.contains("nano") {
        ModelPricing {
            input_per_m: 2.0,
            output_per_m: 8.0,
        }
    } else if m.contains("gpt-5.6-luna")
        || ((m.contains("gpt-5.4") || m.contains("gpt-5.5") || m.contains("gpt-5.6"))
            && m.contains("mini"))
    {
        ModelPricing {
            input_per_m: 0.4,
            output_per_m: 1.6,
        }
    } else if (m.contains("gpt-5.4") || m.contains("gpt-5.5") || m.contains("gpt-5.6"))
        && !m.contains("mini")
        && !m.contains("nano")
    {
        ModelPricing {
            input_per_m: 2.5,
            output_per_m: 15.0,
        }
    } else if m.contains("gpt-5") && m.contains("mini") {
        ModelPricing {
            input_per_m: 0.25,
            output_per_m: 2.0,
        }
    } else if m.contains("gpt-5") && m.contains("nano") {
        ModelPricing {
            input_per_m: 0.05,
            output_per_m: 0.40,
        }
    } else if m.contains("gpt-5") {
        ModelPricing {
            input_per_m: 1.25,
            output_per_m: 10.0,
        }
    } else if m.contains("gpt-4.1-mini") {
        ModelPricing {
            input_per_m: 0.4,
            output_per_m: 1.6,
        }
    } else if m.contains("gpt-4.1-nano")
        || (m.contains("gemini-3") && m.contains("flash") && m.contains("lite"))
    {
        ModelPricing {
            input_per_m: 0.10,
            output_per_m: 0.40,
        }
    } else if m.contains("gemini-3") && m.contains("flash") {
        ModelPricing {
            input_per_m: 0.30,
            output_per_m: 2.50,
        }
    } else if m.contains("gemini-3") && m.contains("-pro") {
        ModelPricing {
            input_per_m: 1.25,
            output_per_m: 10.0,
        }
    } else if m.contains("gemini-2.5-flash") {
        ModelPricing {
            input_per_m: 0.30,
            output_per_m: 2.50,
        }
    } else if m.contains("gemini-2.5-pro") {
        ModelPricing {
            input_per_m: 1.25,
            output_per_m: 10.0,
        }
    } else if m.contains("coder-model") {
        ModelPricing {
            input_per_m: 0.0,
            output_per_m: 0.0,
        }
    } else if m.contains("qwen") {
        ModelPricing {
            input_per_m: 0.30,
            output_per_m: 2.50,
        }
    } else if m == "haiku" || m.contains("claude-haiku") {
        ModelPricing {
            input_per_m: 0.8,
            output_per_m: 4.0,
        }
    } else if m == "sonnet" || m.contains("claude-sonnet-4") || m.contains("claude-4-sonnet") {
        ModelPricing {
            input_per_m: 3.0,
            output_per_m: 15.0,
        }
    } else if m == "opus" || m.contains("claude-opus-4") || m.contains("claude-opus-4-6") {
        ModelPricing {
            input_per_m: 15.0,
            output_per_m: 75.0,
        }
    } else if m.contains("o3-mini") || m.contains("o4-mini") {
        ModelPricing {
            input_per_m: 1.10,
            output_per_m: 4.40,
        }
    // grok intentionally absent: price is unknown (catalog tier "unknown").
    // A plausible rate here would make estimate_cost invent $figures for
    // finished grok tasks that lack agent-reported total_cost_usd.
    } else if is_free_named(m) || m.starts_with("mimo/") {
        return Some(ModelPricing {
            input_per_m: 0.0,
            output_per_m: 0.0,
        });
    } else if m.contains("glm-4") || m.contains("glm-5") || m.contains("kimi-k2") {
        ModelPricing {
            input_per_m: 0.42,
            output_per_m: 2.10,
        }
    } else if m.contains("composer-2") {
        ModelPricing {
            input_per_m: 0.50,
            output_per_m: 2.50,
        }
    } else {
        return None;
    };
    Some(p)
}

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

    fn is_free(m: &str) -> bool {
        matches!(for_model_lower(m), Some(p) if p.input_per_m == 0.0 && p.output_per_m == 0.0)
    }

    #[test]
    fn mimocode_native_provider_is_free() {
        assert!(is_free("mimo/mimo-auto"));
    }

    #[test]
    fn explicit_free_mimo_model_stays_free() {
        assert!(is_free("opencode/mimo-v2-flash-free"));
    }

    #[test]
    fn paid_mimo_named_model_is_not_zero_costed() {
        // The old `m.contains("mimo")` substring wrongly zero-costed this.
        assert!(!is_free("opencode/mimo-v2-flash"));
        assert!(!is_free("xiaomi/mimo-v2.5-pro"));
    }

    #[test]
    fn free_suffix_is_uniform_not_a_family_allowlist() {
        assert!(is_free_named("opencode-go/hy3-free"));
        assert!(is_free_named("opencode/laguna-s-2.1-free"));
        assert!(is_free("opencode-go/hy3-free"));
        assert!(is_free("opencode/laguna-s-2.1-free"));
    }

    #[test]
    fn free_in_the_middle_of_a_name_is_not_a_free_suffix() {
        assert!(!is_free_named("opencode/laguna-free-v2"));
        assert!(!is_free_named("acme/freedom-pro"));
        assert!(!is_free("opencode/laguna-free-v2"));
        assert!(!is_free("acme/freedom-pro"));
    }
}