use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProviderKind {
#[serde(alias = "openai-compatible")]
Openai,
Anthropic,
#[serde(alias = "gemini")]
Google,
}
impl ProviderKind {
pub fn as_str(self) -> &'static str {
match self {
ProviderKind::Openai => "openai",
ProviderKind::Anthropic => "anthropic",
ProviderKind::Google => "google",
}
}
pub fn requires_authentication(self) -> bool {
match self {
ProviderKind::Openai => false,
ProviderKind::Anthropic | ProviderKind::Google => true,
}
}
pub fn base_url_is_an_endpoint(self) -> bool {
match self {
ProviderKind::Openai | ProviderKind::Anthropic => true,
ProviderKind::Google => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct ProviderConfig {
pub name: String,
pub kind: ProviderKind,
pub base_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct ModelConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(default, rename = "provider", skip_serializing_if = "Vec::is_empty")]
pub providers: Vec<ProviderConfig>,
#[serde(default, rename = "price", skip_serializing_if = "Vec::is_empty")]
pub prices: Vec<crate::price::ModelPrice>,
#[serde(default, rename = "catalogue", skip_serializing_if = "Vec::is_empty")]
pub catalogue: Vec<ModelEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct ModelEntry {
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<String>,
}
impl ModelEntry {
pub fn vendor(&self) -> &str {
self.model.split_once('/').map(|(v, _)| v).unwrap_or("")
}
pub fn name(&self) -> &str {
self.model
.split_once('/')
.map(|(_, name)| name)
.unwrap_or(&self.model)
}
pub fn satisfies(&self, capabilities: &[String], min_context: Option<i64>) -> bool {
if let Some(required) = min_context {
match self.context {
Some(window) if window >= required => {}
_ => return false,
}
}
capabilities
.iter()
.all(|wanted| self.capabilities.iter().any(|has| has == wanted))
}
pub fn shortfall(&self, capabilities: &[String], min_context: Option<i64>) -> String {
let mut reasons = Vec::new();
if let Some(required) = min_context {
match self.context {
Some(window) if window < required => {
reasons.push(format!("provides {window} context tokens, not {required}"))
}
None => reasons.push("declares no context window".to_string()),
_ => {}
}
}
let missing: Vec<&str> = capabilities
.iter()
.filter(|wanted| !self.capabilities.iter().any(|has| &has == wanted))
.map(String::as_str)
.collect();
if !missing.is_empty() {
reasons.push(format!("lacks {}", missing.join(", ")));
}
format!("{}: {}", self.model, reasons.join("; "))
}
}
pub const BUILT_IN_CATALOGUE: &[(&str, i64, &[&str])] = &[(
"anthropic/claude-opus-5",
1_000_000,
&[
"tool_calling",
"structured_output",
"streaming",
"vision",
"reasoning",
"parallel_tool_calls",
],
)];
impl ModelConfig {
pub fn is_empty(&self) -> bool {
self.providers.is_empty()
&& self.default.is_none()
&& self.prices.is_empty()
&& self.catalogue.is_empty()
}
pub fn known_models(&self) -> Vec<ModelEntry> {
let mut models = self.catalogue.clone();
for (model, context, capabilities) in BUILT_IN_CATALOGUE {
if models.iter().any(|entry| entry.model == *model) {
continue;
}
models.push(ModelEntry {
model: (*model).to_string(),
context: Some(*context),
capabilities: capabilities
.iter()
.map(|name| (*name).to_string())
.collect(),
});
}
models
}
pub fn resolve_capabilities(
&self,
vendor: &str,
capabilities: &[String],
min_context: Option<i64>,
) -> Result<String, String> {
let known: Vec<ModelEntry> = self
.known_models()
.into_iter()
.filter(|entry| entry.vendor() == vendor)
.collect();
if let Some(entry) = known
.iter()
.find(|entry| entry.satisfies(capabilities, min_context))
{
return Ok(entry.name().to_string());
}
let wanted = {
let mut parts = Vec::new();
if let Some(context) = min_context {
parts.push(format!("context >= {context}"));
}
parts.extend(capabilities.iter().cloned());
if parts.is_empty() {
"no requirements".to_string()
} else {
parts.join(", ")
}
};
if known.is_empty() {
return Err(format!(
"this artifact requires {wanted} rather than naming a model, and no model of \
`{vendor}` is in the catalogue\n \
declare one with `[[model.catalogue]]` in ingot.toml, or pin a model with \
`model exact {vendor}/<model>` or --model"
));
}
Err(format!(
"this artifact requires {wanted}, and no `{vendor}` model in the catalogue \
provides it\n {}\n \
add or correct an entry with `[[model.catalogue]]`, or pin a model with --model",
known
.iter()
.map(|entry| entry.shortfall(capabilities, min_context))
.collect::<Vec<_>>()
.join("\n ")
))
}
pub fn pricing(&self) -> crate::price::Pricing {
crate::price::Pricing::new(self.prices.clone())
}
pub fn validate(&self, built_in: &[&str]) -> Result<(), String> {
let mut seen: BTreeSet<&str> = BTreeSet::new();
for provider in &self.providers {
let name = provider.name.trim();
if name.is_empty() {
return Err("an [[model.provider]] has an empty `name`".to_string());
}
if name.contains('/') {
return Err(format!(
"the provider name `{name}` contains `/`, which separates the vendor from \
the model in `model exact \"vendor/model\"`"
));
}
if provider.base_url.trim().is_empty() {
return Err(format!("model provider `{name}` has an empty `base-url`"));
}
if !seen.insert(name) {
return Err(format!(
"two [[model.provider]] entries are both named `{name}`; names must be unique"
));
}
if let Some(variable) = &provider.api_key_env {
if variable.trim().is_empty() {
return Err(format!(
"model provider `{name}` has an empty `api-key-env`; omit it entirely to \
send no authentication"
));
}
}
}
if let Some(default) = &self.default {
let known = seen.contains(default.as_str()) || built_in.contains(&default.as_str());
if !known {
let mut all: Vec<&str> = seen
.iter()
.copied()
.chain(built_in.iter().copied())
.collect();
all.sort_unstable();
all.dedup();
return Err(format!(
"`default = \"{default}\"` names no provider\n declared or built in: {}",
all.join(", ")
));
}
}
Ok(())
}
}
#[cfg(feature = "http")]
pub fn build(
config: &ProviderConfig,
model_override: Option<String>,
effort: Option<String>,
) -> Result<Box<dyn crate::provider::ModelProvider>, crate::provider::ProviderError> {
use crate::provider::ProviderError;
let key = match &config.api_key_env {
Some(variable) => Some(crate::http::key_from_env(variable)?),
None => None,
};
match config.kind {
ProviderKind::Openai => {
#[cfg(feature = "openai")]
{
let provider = match key {
Some(key) => crate::openai::OpenAiProvider::with_key(key),
None => crate::openai::OpenAiProvider::without_key(),
};
Ok(Box::new(
provider
.with_base_url(config.base_url.clone())
.with_model(model_override)
.with_effort(effort),
))
}
#[cfg(not(feature = "openai"))]
{
Err(ProviderError::Configuration(format!(
"model provider `{}` needs the `openai` protocol, which this build does not \
include; rebuild with `--features openai`",
config.name
)))
}
}
ProviderKind::Anthropic => {
#[cfg(feature = "anthropic")]
{
let Some(key) = key else {
return Err(ProviderError::Configuration(format!(
"model provider `{}` speaks the Anthropic protocol, which authenticates \
every request; give it an `api-key-env`",
config.name
)));
};
Ok(Box::new(
crate::anthropic::AnthropicProvider::with_key(key)
.with_base_url(config.base_url.clone())
.with_model(model_override)
.with_effort(effort),
))
}
#[cfg(not(feature = "anthropic"))]
{
Err(ProviderError::Configuration(format!(
"model provider `{}` needs the `anthropic` protocol, which this build does \
not include; rebuild with `--features anthropic`",
config.name
)))
}
}
ProviderKind::Google => {
#[cfg(feature = "google")]
{
let Some(key) = key else {
return Err(ProviderError::Configuration(format!(
"model provider `{}` speaks the Gemini protocol, which authenticates \
every request; give it an `api-key-env`",
config.name
)));
};
Ok(Box::new(
crate::google::GoogleProvider::with_key(key)
.with_base_url(config.base_url.clone())
.with_model(model_override)
.with_effort(effort),
))
}
#[cfg(not(feature = "google"))]
{
Err(ProviderError::Configuration(format!(
"model provider `{}` needs the `google` protocol, which this build does not \
include; rebuild with `--features google`",
config.name
)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const BUILT_IN: &[&str] = &["anthropic", "google", "openai"];
fn provider(name: &str) -> ProviderConfig {
ProviderConfig {
name: name.to_string(),
kind: ProviderKind::Openai,
base_url: "http://localhost:11434/v1/chat/completions".to_string(),
api_key_env: None,
}
}
#[test]
fn an_absent_section_declares_nothing() {
let config = ModelConfig::default();
assert!(config.is_empty());
assert!(config.validate(BUILT_IN).is_ok());
}
#[test]
fn a_local_server_needs_no_key() {
let config = ModelConfig {
prices: Vec::new(),
catalogue: Vec::new(),
providers: vec![provider("local")],
..ModelConfig::default()
};
assert!(config.validate(BUILT_IN).is_ok());
assert!(config.providers[0].api_key_env.is_none());
}
#[test]
fn duplicate_names_are_refused() {
let config = ModelConfig {
prices: Vec::new(),
catalogue: Vec::new(),
providers: vec![provider("local"), provider("local")],
..ModelConfig::default()
};
let error = config.validate(BUILT_IN).unwrap_err();
assert!(error.contains("unique"), "{error}");
}
#[test]
fn a_name_containing_a_slash_is_refused_because_that_is_the_separator() {
let config = ModelConfig {
prices: Vec::new(),
catalogue: Vec::new(),
providers: vec![provider("my/llm")],
..ModelConfig::default()
};
let error = config.validate(BUILT_IN).unwrap_err();
assert!(error.contains("separates the vendor"), "{error}");
}
#[test]
fn an_empty_endpoint_is_refused_before_a_request_is_built() {
let mut bare = provider("local");
bare.base_url = " ".to_string();
let config = ModelConfig {
prices: Vec::new(),
catalogue: Vec::new(),
providers: vec![bare],
..ModelConfig::default()
};
assert!(config.validate(BUILT_IN).is_err());
}
#[test]
fn an_empty_key_variable_is_refused_rather_than_read_as_no_auth() {
let mut confused = provider("local");
confused.api_key_env = Some(String::new());
let config = ModelConfig {
prices: Vec::new(),
catalogue: Vec::new(),
providers: vec![confused],
..ModelConfig::default()
};
let error = config.validate(BUILT_IN).unwrap_err();
assert!(error.contains("omit it entirely"), "{error}");
}
fn entry(model: &str, context: Option<i64>, capabilities: &[&str]) -> ModelEntry {
ModelEntry {
model: model.to_string(),
context,
capabilities: capabilities.iter().map(|c| c.to_string()).collect(),
}
}
#[test]
fn a_model_with_a_wide_enough_window_and_the_right_capabilities_satisfies() {
let model = entry("openai/gpt-x", Some(400_000), &["tool_calling", "vision"]);
assert!(model.satisfies(&["vision".to_string()], Some(128_000)));
assert!(model.satisfies(&[], None));
}
#[test]
fn an_unknown_context_window_does_not_satisfy_a_requirement() {
let model = entry("openai/gpt-x", None, &["vision"]);
assert!(!model.satisfies(&[], Some(1)));
assert!(model.satisfies(&["vision".to_string()], None));
assert!(model
.shortfall(&[], Some(1))
.contains("declares no context window"));
}
#[test]
fn a_shortfall_names_both_halves_of_what_is_missing() {
let model = entry("openai/gpt-x", Some(8_000), &["tool_calling"]);
let text = model.shortfall(&["vision".to_string()], Some(128_000));
assert!(text.contains("8000"), "{text}");
assert!(text.contains("128000"), "{text}");
assert!(text.contains("vision"), "{text}");
}
#[test]
fn an_operators_entry_replaces_a_built_in_of_the_same_name() {
let config = ModelConfig {
catalogue: vec![entry(
"anthropic/claude-opus-5",
Some(2_000_000),
&["vision"],
)],
..ModelConfig::default()
};
let known = config.known_models();
let found: Vec<&ModelEntry> = known
.iter()
.filter(|e| e.model == "anthropic/claude-opus-5")
.collect();
assert_eq!(found.len(), 1, "one entry per model");
assert_eq!(found[0].context, Some(2_000_000));
assert_eq!(found[0].capabilities, vec!["vision".to_string()]);
}
#[test]
fn the_operators_models_are_preferred_to_the_built_in_ones() {
let config = ModelConfig {
catalogue: vec![entry(
"anthropic/mine",
Some(1_000_000),
&["structured_output"],
)],
..ModelConfig::default()
};
let chosen = config
.resolve_capabilities("anthropic", &["structured_output".to_string()], None)
.expect("mine satisfies it");
assert_eq!(chosen, "mine");
}
#[test]
fn a_vendor_with_nothing_in_the_catalogue_says_what_to_declare() {
let error = ModelConfig::default()
.resolve_capabilities("openai", &["vision".to_string()], None)
.unwrap_err();
assert!(error.contains("openai"), "{error}");
assert!(error.contains("[[model.catalogue]]"), "{error}");
}
#[test]
fn a_vendor_whose_models_all_fall_short_says_how_each_one_does() {
let config = ModelConfig {
catalogue: vec![
entry("openai/small", Some(8_000), &["vision"]),
entry("openai/blind", Some(400_000), &["tool_calling"]),
],
..ModelConfig::default()
};
let error = config
.resolve_capabilities("openai", &["vision".to_string()], Some(128_000))
.unwrap_err();
assert!(error.contains("openai/small"), "{error}");
assert!(error.contains("openai/blind"), "{error}");
assert!(error.contains("8000"), "{error}");
assert!(error.contains("vision"), "{error}");
}
#[test]
fn a_model_is_named_without_its_vendor_on_the_wire() {
let model = entry("anthropic/claude-opus-5", None, &[]);
assert_eq!(model.vendor(), "anthropic");
assert_eq!(model.name(), "claude-opus-5");
}
#[test]
fn a_default_may_name_a_built_in_without_redeclaring_it() {
let config = ModelConfig {
prices: Vec::new(),
catalogue: Vec::new(),
default: Some("anthropic".to_string()),
providers: Vec::new(),
};
assert!(config.validate(BUILT_IN).is_ok());
}
#[test]
fn a_default_naming_nothing_lists_what_there_is() {
let config = ModelConfig {
prices: Vec::new(),
catalogue: Vec::new(),
default: Some("mistral".to_string()),
providers: vec![provider("local")],
};
let error = config.validate(BUILT_IN).unwrap_err();
assert!(error.contains("mistral"), "{error}");
assert!(
error.contains("anthropic, google, local, openai"),
"{error}"
);
}
#[test]
fn kind_names_a_protocol_and_accepts_the_longer_spelling() {
let config: ModelConfig = toml::from_str(
r#"
[[provider]]
name = "local"
kind = "openai-compatible"
base-url = "http://localhost:8000/v1/chat/completions"
"#,
)
.expect("must parse");
assert_eq!(config.providers[0].kind, ProviderKind::Openai);
}
#[test]
fn an_unknown_key_is_refused_rather_than_ignored() {
let error = toml::from_str::<ModelConfig>(
r#"
[[provider]]
name = "local"
kind = "openai"
base-url = "http://localhost:8000/v1"
api_key = "sk-secret"
"#,
)
.expect_err("an unknown key must fail");
assert!(error.to_string().contains("api_key"), "{error}");
}
}