Skip to main content

ingot_runtime/
catalogue.rs

1//! Which model services this machine can reach.
2//!
3//! Three are built in, because most people want them and none needs
4//! configuring. Everything else is declared by the operator:
5//!
6//! ```toml
7//! [model]
8//! default = "local"
9//!
10//! [[model.provider]]
11//! name = "local"                 # the vendor half of `model exact "local/…"`
12//! kind = "openai"                # the wire protocol it speaks
13//! base-url = "http://localhost:11434/v1/chat/completions"
14//!
15//! [[model.provider]]
16//! name = "azure"
17//! kind = "openai"
18//! base-url = "https://…/chat/completions?api-version=2024-10-21"
19//! api-key-env = "AZURE_OPENAI_KEY"
20//! ```
21//!
22//! `kind` names a **protocol**, not a company. Ingot implements three, and a
23//! service that speaks any of them can be reached by naming it here — which is
24//! why "how many providers does Ingot support" is the wrong question. The
25//! answer to the right one:
26//!
27//! | `kind` | Reaches |
28//! |--------|---------|
29//! | `openai` | OpenAI, Azure OpenAI, Ollama, vLLM, llama.cpp, LM Studio, Groq, Together, OpenRouter, Fireworks, DeepSeek, Mistral's compatible endpoint, and most hosted gateways |
30//! | `anthropic` | Anthropic, and gateways that front the Messages API |
31//! | `google` | Google Gemini |
32//!
33//! A third protocol is here for one reason: Gemini is the vendor that cannot be
34//! reached by pretending to be something else. Anything that already speaks one
35//! of the first two needs no code, only a `base-url`.
36//!
37//! # What a model can do, as opposed to where it is
38//!
39//! `model requires { structured_output, context >= 128k }` has to be matched
40//! against something. That something is also here:
41//!
42//! ```toml
43//! [[model.catalogue]]
44//! model = "openai/gpt-5.1"
45//! context = 400000
46//! capabilities = ["tool_calling", "structured_output", "streaming"]
47//! ```
48//!
49//! These facts used to be `const`s in a provider module, which had two costs. A
50//! model growing a larger context window was a code change and a release. And
51//! only one of the three providers had them at all — the other two refused a
52//! capability requirement outright, saying they had "no catalogue to match them
53//! against". This is that catalogue, and all three now consult it through
54//! [`ModelConfig::resolve_capabilities`], so they cannot answer the same
55//! question differently.
56//!
57//! `base-url` is a complete endpoint for `openai` and `anthropic`. For `google`
58//! it is the API base, because that protocol puts the model and the method in
59//! the path — see [`ProviderKind::base_url_is_an_endpoint`].
60//!
61//! `api-key-env` names an environment variable. There is no way to write a key
62//! into a manifest, for the same reason there is none in `[[mcp.server]]`: a
63//! manifest is committed. A provider with no `api-key-env` sends no
64//! authentication at all, which is what a local server usually wants.
65
66use std::collections::BTreeSet;
67
68use serde::{Deserialize, Serialize};
69
70/// The wire protocols Ingot speaks.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "kebab-case")]
73pub enum ProviderKind {
74    /// OpenAI Chat Completions. Spoken by OpenAI, Azure OpenAI, vLLM,
75    /// llama.cpp, Ollama, LM Studio, and most hosted gateways.
76    #[serde(alias = "openai-compatible")]
77    Openai,
78    /// The Anthropic Messages API. Spoken by Anthropic and by gateways that
79    /// front it.
80    Anthropic,
81    /// Google's Generative Language API (Gemini).
82    ///
83    /// Here because it is the one a service cannot reach by pretending to be
84    /// something else: it is neither of the two above.
85    #[serde(alias = "gemini")]
86    Google,
87}
88
89impl ProviderKind {
90    pub fn as_str(self) -> &'static str {
91        match self {
92            ProviderKind::Openai => "openai",
93            ProviderKind::Anthropic => "anthropic",
94            ProviderKind::Google => "google",
95        }
96    }
97
98    /// Whether every request this protocol makes carries a credential.
99    ///
100    /// False only for `openai`, and only because a server on the same machine
101    /// usually wants no authentication at all. Asserted here rather than
102    /// rediscovered by each caller, so `doctor` and the provider builder cannot
103    /// disagree about whether a declaration is complete.
104    pub fn requires_authentication(self) -> bool {
105        match self {
106            ProviderKind::Openai => false,
107            ProviderKind::Anthropic | ProviderKind::Google => true,
108        }
109    }
110
111    /// Whether `base-url` is a complete endpoint or the base to build one from.
112    ///
113    /// Worth being explicit about rather than leaving to a reader of two
114    /// provider modules: the Gemini protocol puts the model and the method in
115    /// the path, so there is no one endpoint to name.
116    pub fn base_url_is_an_endpoint(self) -> bool {
117        match self {
118            ProviderKind::Openai | ProviderKind::Anthropic => true,
119            ProviderKind::Google => false,
120        }
121    }
122}
123
124/// One service the operator declared.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(deny_unknown_fields, rename_all = "kebab-case")]
127pub struct ProviderConfig {
128    /// The vendor half of a pinned reference: `model exact "<name>/<model>"`.
129    pub name: String,
130    /// Which protocol to speak.
131    pub kind: ProviderKind,
132    /// The endpoint, in full. Not a host: services disagree about the path,
133    /// and guessing it produces a 404 that looks like a missing model.
134    pub base_url: String,
135    /// The **name** of the variable holding the key. Absent means no auth,
136    /// which is what a local server usually wants.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub api_key_env: Option<String>,
139}
140
141/// The `[model]` section of a manifest.
142#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields, rename_all = "kebab-case")]
144pub struct ModelConfig {
145    /// Which provider answers a call the artifact did not pin to a vendor.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub default: Option<String>,
148    #[serde(default, rename = "provider", skip_serializing_if = "Vec::is_empty")]
149    pub providers: Vec<ProviderConfig>,
150    /// What each model costs, so `budget.cost` can be charged.
151    ///
152    /// Deployment configuration rather than part of the program, for the same
153    /// reason `[[mcp.server]]` is: a price is provider- and time-dependent, and
154    /// an artifact carrying one would be stale the moment it was published.
155    #[serde(default, rename = "price", skip_serializing_if = "Vec::is_empty")]
156    pub prices: Vec<crate::price::ModelPrice>,
157    /// What each model can do, so `model requires { ... }` can be matched
158    /// against something.
159    ///
160    /// Deployment configuration for the same reason a price is: a model's
161    /// context window and capabilities change on the vendor's schedule, not on
162    /// this project's release schedule. They used to be `const`s in a provider
163    /// module, which meant a model growing a larger window was a code change
164    /// and a release -- and that two of the three providers refused capability
165    /// requirements outright, because neither had "a catalogue to match them
166    /// against". This is that catalogue.
167    #[serde(default, rename = "catalogue", skip_serializing_if = "Vec::is_empty")]
168    pub catalogue: Vec<ModelEntry>,
169}
170
171/// What one model provides.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(deny_unknown_fields, rename_all = "kebab-case")]
174pub struct ModelEntry {
175    /// `vendor/model`, the same form `model exact` uses.
176    ///
177    /// Qualified because a bare model name would make two vendors' catalogues
178    /// collide, and because matching a requirement means first knowing which
179    /// vendor is answering.
180    pub model: String,
181    /// Context window in tokens, for `context >= N`.
182    ///
183    /// Absent means unknown, and an unknown window **does not satisfy** a
184    /// requirement. Guessing would turn a refusal an operator can fix into a
185    /// provider error at the first long prompt.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub context: Option<i64>,
188    /// Capability names, as `model requires { ... }` spells them.
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub capabilities: Vec<String>,
191}
192
193impl ModelEntry {
194    /// The vendor half of `model`.
195    pub fn vendor(&self) -> &str {
196        self.model.split_once('/').map(|(v, _)| v).unwrap_or("")
197    }
198
199    /// The model half, which is what a provider puts on the wire.
200    pub fn name(&self) -> &str {
201        self.model
202            .split_once('/')
203            .map(|(_, name)| name)
204            .unwrap_or(&self.model)
205    }
206
207    /// Whether this model satisfies a requirement.
208    pub fn satisfies(&self, capabilities: &[String], min_context: Option<i64>) -> bool {
209        if let Some(required) = min_context {
210            match self.context {
211                Some(window) if window >= required => {}
212                // Unknown is not "big enough". See `context`.
213                _ => return false,
214            }
215        }
216        capabilities
217            .iter()
218            .all(|wanted| self.capabilities.iter().any(|has| has == wanted))
219    }
220
221    /// Why it does not, in words an operator can act on.
222    pub fn shortfall(&self, capabilities: &[String], min_context: Option<i64>) -> String {
223        let mut reasons = Vec::new();
224        if let Some(required) = min_context {
225            match self.context {
226                Some(window) if window < required => {
227                    reasons.push(format!("provides {window} context tokens, not {required}"))
228                }
229                None => reasons.push("declares no context window".to_string()),
230                _ => {}
231            }
232        }
233        let missing: Vec<&str> = capabilities
234            .iter()
235            .filter(|wanted| !self.capabilities.iter().any(|has| &has == wanted))
236            .map(String::as_str)
237            .collect();
238        if !missing.is_empty() {
239            reasons.push(format!("lacks {}", missing.join(", ")));
240        }
241        format!("{}: {}", self.model, reasons.join("; "))
242    }
243}
244
245/// The models Ingot knows about without being told.
246///
247/// A short list, and deliberately not a directory of everything on the market:
248/// a built-in catalogue is stale the moment it ships, and the operator's
249/// entries come first so a stale one is always overridable. What is here is
250/// what makes `model requires { … }` work out of the box for the one provider
251/// that has a sensible default at all.
252pub const BUILT_IN_CATALOGUE: &[(&str, i64, &[&str])] = &[(
253    "anthropic/claude-opus-5",
254    1_000_000,
255    &[
256        "tool_calling",
257        "structured_output",
258        "streaming",
259        "vision",
260        "reasoning",
261        "parallel_tool_calls",
262    ],
263)];
264
265impl ModelConfig {
266    pub fn is_empty(&self) -> bool {
267        self.providers.is_empty()
268            && self.default.is_none()
269            && self.prices.is_empty()
270            && self.catalogue.is_empty()
271    }
272
273    /// Every model this deployment knows about, the operator's first.
274    ///
275    /// Order is preference order and it is the operator's: their entries are
276    /// tried before the built-in ones, so overriding a built-in means declaring
277    /// it, not editing this binary. Within each group, declaration order.
278    pub fn known_models(&self) -> Vec<ModelEntry> {
279        let mut models = self.catalogue.clone();
280        for (model, context, capabilities) in BUILT_IN_CATALOGUE {
281            // A declared entry for the same model wins outright, rather than
282            // merging: a half-overridden model is a set of facts that came from
283            // two places and matches neither.
284            if models.iter().any(|entry| entry.model == *model) {
285                continue;
286            }
287            models.push(ModelEntry {
288                model: (*model).to_string(),
289                context: Some(*context),
290                capabilities: capabilities
291                    .iter()
292                    .map(|name| (*name).to_string())
293                    .collect(),
294            });
295        }
296        models
297    }
298
299    /// The first model of `vendor` that satisfies a requirement, or why none
300    /// does.
301    ///
302    /// The one place capability matching happens, so the three providers cannot
303    /// answer the same question differently -- which is what they did before
304    /// this existed: one had a hardcoded default and two refused outright.
305    pub fn resolve_capabilities(
306        &self,
307        vendor: &str,
308        capabilities: &[String],
309        min_context: Option<i64>,
310    ) -> Result<String, String> {
311        let known: Vec<ModelEntry> = self
312            .known_models()
313            .into_iter()
314            .filter(|entry| entry.vendor() == vendor)
315            .collect();
316
317        if let Some(entry) = known
318            .iter()
319            .find(|entry| entry.satisfies(capabilities, min_context))
320        {
321            return Ok(entry.name().to_string());
322        }
323
324        let wanted = {
325            let mut parts = Vec::new();
326            if let Some(context) = min_context {
327                parts.push(format!("context >= {context}"));
328            }
329            parts.extend(capabilities.iter().cloned());
330            if parts.is_empty() {
331                "no requirements".to_string()
332            } else {
333                parts.join(", ")
334            }
335        };
336
337        if known.is_empty() {
338            return Err(format!(
339                "this artifact requires {wanted} rather than naming a model, and no model of \
340                 `{vendor}` is in the catalogue\n  \
341                 declare one with `[[model.catalogue]]` in ingot.toml, or pin a model with \
342                 `model exact {vendor}/<model>` or --model"
343            ));
344        }
345        Err(format!(
346            "this artifact requires {wanted}, and no `{vendor}` model in the catalogue \
347             provides it\n  {}\n  \
348             add or correct an entry with `[[model.catalogue]]`, or pin a model with --model",
349            known
350                .iter()
351                .map(|entry| entry.shortfall(capabilities, min_context))
352                .collect::<Vec<_>>()
353                .join("\n  ")
354        ))
355    }
356
357    /// The prices a run is given, as the interpreter wants them.
358    pub fn pricing(&self) -> crate::price::Pricing {
359        crate::price::Pricing::new(self.prices.clone())
360    }
361
362    /// Reject what cannot work, before a key is read or a request is built.
363    ///
364    /// `built_in` are the names available without being declared, so that
365    /// `default = "anthropic"` is accepted without an `[[model.provider]]`
366    /// entry restating what Ingot already knows.
367    pub fn validate(&self, built_in: &[&str]) -> Result<(), String> {
368        let mut seen: BTreeSet<&str> = BTreeSet::new();
369
370        for provider in &self.providers {
371            let name = provider.name.trim();
372            if name.is_empty() {
373                return Err("an [[model.provider]] has an empty `name`".to_string());
374            }
375            if name.contains('/') {
376                return Err(format!(
377                    "the provider name `{name}` contains `/`, which separates the vendor from \
378                     the model in `model exact \"vendor/model\"`"
379                ));
380            }
381            if provider.base_url.trim().is_empty() {
382                return Err(format!("model provider `{name}` has an empty `base-url`"));
383            }
384            if !seen.insert(name) {
385                return Err(format!(
386                    "two [[model.provider]] entries are both named `{name}`; names must be unique"
387                ));
388            }
389            if let Some(variable) = &provider.api_key_env {
390                if variable.trim().is_empty() {
391                    return Err(format!(
392                        "model provider `{name}` has an empty `api-key-env`; omit it entirely to \
393                         send no authentication"
394                    ));
395                }
396            }
397        }
398
399        if let Some(default) = &self.default {
400            let known = seen.contains(default.as_str()) || built_in.contains(&default.as_str());
401            if !known {
402                let mut all: Vec<&str> = seen
403                    .iter()
404                    .copied()
405                    .chain(built_in.iter().copied())
406                    .collect();
407                all.sort_unstable();
408                all.dedup();
409                return Err(format!(
410                    "`default = \"{default}\"` names no provider\n  declared or built in: {}",
411                    all.join(", ")
412                ));
413            }
414        }
415
416        Ok(())
417    }
418}
419
420/// Build the provider a declaration describes.
421///
422/// Separate from the declaration so that a manifest can be read, validated and
423/// printed on a build with no HTTP support at all.
424#[cfg(feature = "http")]
425pub fn build(
426    config: &ProviderConfig,
427    model_override: Option<String>,
428    effort: Option<String>,
429) -> Result<Box<dyn crate::provider::ModelProvider>, crate::provider::ProviderError> {
430    use crate::provider::ProviderError;
431
432    let key = match &config.api_key_env {
433        Some(variable) => Some(crate::http::key_from_env(variable)?),
434        None => None,
435    };
436
437    match config.kind {
438        ProviderKind::Openai => {
439            #[cfg(feature = "openai")]
440            {
441                let provider = match key {
442                    Some(key) => crate::openai::OpenAiProvider::with_key(key),
443                    None => crate::openai::OpenAiProvider::without_key(),
444                };
445                Ok(Box::new(
446                    provider
447                        .with_base_url(config.base_url.clone())
448                        .with_model(model_override)
449                        .with_effort(effort),
450                ))
451            }
452            #[cfg(not(feature = "openai"))]
453            {
454                Err(ProviderError::Configuration(format!(
455                    "model provider `{}` needs the `openai` protocol, which this build does not \
456                     include; rebuild with `--features openai`",
457                    config.name
458                )))
459            }
460        }
461        ProviderKind::Anthropic => {
462            #[cfg(feature = "anthropic")]
463            {
464                let Some(key) = key else {
465                    return Err(ProviderError::Configuration(format!(
466                        "model provider `{}` speaks the Anthropic protocol, which authenticates \
467                         every request; give it an `api-key-env`",
468                        config.name
469                    )));
470                };
471                Ok(Box::new(
472                    crate::anthropic::AnthropicProvider::with_key(key)
473                        .with_base_url(config.base_url.clone())
474                        .with_model(model_override)
475                        .with_effort(effort),
476                ))
477            }
478            #[cfg(not(feature = "anthropic"))]
479            {
480                Err(ProviderError::Configuration(format!(
481                    "model provider `{}` needs the `anthropic` protocol, which this build does \
482                     not include; rebuild with `--features anthropic`",
483                    config.name
484                )))
485            }
486        }
487        ProviderKind::Google => {
488            #[cfg(feature = "google")]
489            {
490                let Some(key) = key else {
491                    return Err(ProviderError::Configuration(format!(
492                        "model provider `{}` speaks the Gemini protocol, which authenticates \
493                         every request; give it an `api-key-env`",
494                        config.name
495                    )));
496                };
497                Ok(Box::new(
498                    crate::google::GoogleProvider::with_key(key)
499                        .with_base_url(config.base_url.clone())
500                        .with_model(model_override)
501                        .with_effort(effort),
502                ))
503            }
504            #[cfg(not(feature = "google"))]
505            {
506                Err(ProviderError::Configuration(format!(
507                    "model provider `{}` needs the `google` protocol, which this build does not \
508                     include; rebuild with `--features google`",
509                    config.name
510                )))
511            }
512        }
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    const BUILT_IN: &[&str] = &["anthropic", "google", "openai"];
521
522    fn provider(name: &str) -> ProviderConfig {
523        ProviderConfig {
524            name: name.to_string(),
525            kind: ProviderKind::Openai,
526            base_url: "http://localhost:11434/v1/chat/completions".to_string(),
527            api_key_env: None,
528        }
529    }
530
531    #[test]
532    fn an_absent_section_declares_nothing() {
533        let config = ModelConfig::default();
534        assert!(config.is_empty());
535        assert!(config.validate(BUILT_IN).is_ok());
536    }
537
538    #[test]
539    fn a_local_server_needs_no_key() {
540        // The common case for Ollama, llama.cpp or LM Studio: no auth at all.
541        let config = ModelConfig {
542            prices: Vec::new(),
543            catalogue: Vec::new(),
544            providers: vec![provider("local")],
545            ..ModelConfig::default()
546        };
547        assert!(config.validate(BUILT_IN).is_ok());
548        assert!(config.providers[0].api_key_env.is_none());
549    }
550
551    #[test]
552    fn duplicate_names_are_refused() {
553        let config = ModelConfig {
554            prices: Vec::new(),
555            catalogue: Vec::new(),
556            providers: vec![provider("local"), provider("local")],
557            ..ModelConfig::default()
558        };
559        let error = config.validate(BUILT_IN).unwrap_err();
560        assert!(error.contains("unique"), "{error}");
561    }
562
563    #[test]
564    fn a_name_containing_a_slash_is_refused_because_that_is_the_separator() {
565        let config = ModelConfig {
566            prices: Vec::new(),
567            catalogue: Vec::new(),
568            providers: vec![provider("my/llm")],
569            ..ModelConfig::default()
570        };
571        let error = config.validate(BUILT_IN).unwrap_err();
572        assert!(error.contains("separates the vendor"), "{error}");
573    }
574
575    #[test]
576    fn an_empty_endpoint_is_refused_before_a_request_is_built() {
577        let mut bare = provider("local");
578        bare.base_url = "  ".to_string();
579        let config = ModelConfig {
580            prices: Vec::new(),
581            catalogue: Vec::new(),
582            providers: vec![bare],
583            ..ModelConfig::default()
584        };
585        assert!(config.validate(BUILT_IN).is_err());
586    }
587
588    #[test]
589    fn an_empty_key_variable_is_refused_rather_than_read_as_no_auth() {
590        // Omitting the field means no auth; setting it to "" is a mistake.
591        let mut confused = provider("local");
592        confused.api_key_env = Some(String::new());
593        let config = ModelConfig {
594            prices: Vec::new(),
595            catalogue: Vec::new(),
596            providers: vec![confused],
597            ..ModelConfig::default()
598        };
599        let error = config.validate(BUILT_IN).unwrap_err();
600        assert!(error.contains("omit it entirely"), "{error}");
601    }
602
603    fn entry(model: &str, context: Option<i64>, capabilities: &[&str]) -> ModelEntry {
604        ModelEntry {
605            model: model.to_string(),
606            context,
607            capabilities: capabilities.iter().map(|c| c.to_string()).collect(),
608        }
609    }
610
611    #[test]
612    fn a_model_with_a_wide_enough_window_and_the_right_capabilities_satisfies() {
613        let model = entry("openai/gpt-x", Some(400_000), &["tool_calling", "vision"]);
614        assert!(model.satisfies(&["vision".to_string()], Some(128_000)));
615        assert!(model.satisfies(&[], None));
616    }
617
618    #[test]
619    fn an_unknown_context_window_does_not_satisfy_a_requirement() {
620        // Guessing would turn a refusal the operator can fix into a provider
621        // error at the first long prompt, a long way from the cause.
622        let model = entry("openai/gpt-x", None, &["vision"]);
623        assert!(!model.satisfies(&[], Some(1)));
624        assert!(model.satisfies(&["vision".to_string()], None));
625        assert!(model
626            .shortfall(&[], Some(1))
627            .contains("declares no context window"));
628    }
629
630    #[test]
631    fn a_shortfall_names_both_halves_of_what_is_missing() {
632        let model = entry("openai/gpt-x", Some(8_000), &["tool_calling"]);
633        let text = model.shortfall(&["vision".to_string()], Some(128_000));
634        assert!(text.contains("8000"), "{text}");
635        assert!(text.contains("128000"), "{text}");
636        assert!(text.contains("vision"), "{text}");
637    }
638
639    #[test]
640    fn an_operators_entry_replaces_a_built_in_of_the_same_name() {
641        // Overriding a built-in means declaring it, not editing the binary --
642        // and it replaces rather than merges, because a half-overridden model
643        // is a set of facts from two places that matches neither.
644        let config = ModelConfig {
645            catalogue: vec![entry(
646                "anthropic/claude-opus-5",
647                Some(2_000_000),
648                &["vision"],
649            )],
650            ..ModelConfig::default()
651        };
652        let known = config.known_models();
653        let found: Vec<&ModelEntry> = known
654            .iter()
655            .filter(|e| e.model == "anthropic/claude-opus-5")
656            .collect();
657        assert_eq!(found.len(), 1, "one entry per model");
658        assert_eq!(found[0].context, Some(2_000_000));
659        assert_eq!(found[0].capabilities, vec!["vision".to_string()]);
660    }
661
662    #[test]
663    fn the_operators_models_are_preferred_to_the_built_in_ones() {
664        let config = ModelConfig {
665            catalogue: vec![entry(
666                "anthropic/mine",
667                Some(1_000_000),
668                &["structured_output"],
669            )],
670            ..ModelConfig::default()
671        };
672        let chosen = config
673            .resolve_capabilities("anthropic", &["structured_output".to_string()], None)
674            .expect("mine satisfies it");
675        assert_eq!(chosen, "mine");
676    }
677
678    #[test]
679    fn a_vendor_with_nothing_in_the_catalogue_says_what_to_declare() {
680        // What OpenAI and Google used to say unconditionally, now said only
681        // when it is true.
682        let error = ModelConfig::default()
683            .resolve_capabilities("openai", &["vision".to_string()], None)
684            .unwrap_err();
685        assert!(error.contains("openai"), "{error}");
686        assert!(error.contains("[[model.catalogue]]"), "{error}");
687    }
688
689    #[test]
690    fn a_vendor_whose_models_all_fall_short_says_how_each_one_does() {
691        let config = ModelConfig {
692            catalogue: vec![
693                entry("openai/small", Some(8_000), &["vision"]),
694                entry("openai/blind", Some(400_000), &["tool_calling"]),
695            ],
696            ..ModelConfig::default()
697        };
698        let error = config
699            .resolve_capabilities("openai", &["vision".to_string()], Some(128_000))
700            .unwrap_err();
701        assert!(error.contains("openai/small"), "{error}");
702        assert!(error.contains("openai/blind"), "{error}");
703        assert!(error.contains("8000"), "{error}");
704        assert!(error.contains("vision"), "{error}");
705    }
706
707    #[test]
708    fn a_model_is_named_without_its_vendor_on_the_wire() {
709        let model = entry("anthropic/claude-opus-5", None, &[]);
710        assert_eq!(model.vendor(), "anthropic");
711        assert_eq!(model.name(), "claude-opus-5");
712    }
713
714    #[test]
715    fn a_default_may_name_a_built_in_without_redeclaring_it() {
716        let config = ModelConfig {
717            prices: Vec::new(),
718            catalogue: Vec::new(),
719            default: Some("anthropic".to_string()),
720            providers: Vec::new(),
721        };
722        assert!(config.validate(BUILT_IN).is_ok());
723    }
724
725    #[test]
726    fn a_default_naming_nothing_lists_what_there_is() {
727        let config = ModelConfig {
728            prices: Vec::new(),
729            catalogue: Vec::new(),
730            default: Some("mistral".to_string()),
731            providers: vec![provider("local")],
732        };
733        let error = config.validate(BUILT_IN).unwrap_err();
734        assert!(error.contains("mistral"), "{error}");
735        assert!(
736            error.contains("anthropic, google, local, openai"),
737            "{error}"
738        );
739    }
740
741    #[test]
742    fn kind_names_a_protocol_and_accepts_the_longer_spelling() {
743        let config: ModelConfig = toml::from_str(
744            r#"
745            [[provider]]
746            name = "local"
747            kind = "openai-compatible"
748            base-url = "http://localhost:8000/v1/chat/completions"
749            "#,
750        )
751        .expect("must parse");
752        assert_eq!(config.providers[0].kind, ProviderKind::Openai);
753    }
754
755    #[test]
756    fn an_unknown_key_is_refused_rather_than_ignored() {
757        // A typo that is silently dropped is how someone ends up believing a
758        // key was forwarded when it was not.
759        let error = toml::from_str::<ModelConfig>(
760            r#"
761            [[provider]]
762            name = "local"
763            kind = "openai"
764            base-url = "http://localhost:8000/v1"
765            api_key = "sk-secret"
766            "#,
767        )
768        .expect_err("an unknown key must fail");
769        assert!(error.to_string().contains("api_key"), "{error}");
770    }
771}