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    models: &ModelConfig,
428    model_override: Option<String>,
429    effort: Option<String>,
430) -> Result<Box<dyn crate::provider::ModelProvider>, crate::provider::ProviderError> {
431    use crate::provider::ProviderError;
432
433    // The whole manifest, not just this declaration. A declared provider used
434    // to be built without one, which made `[[model.catalogue]]` invisible to
435    // the endpoint it was most likely written for: the operator's own. The
436    // vendor is the name they gave it, because that is the name
437    // `model exact "<name>/…"` already uses and a manifest should not spell one
438    // provider two ways.
439    let catalogue = models.clone();
440    let vendor = Some(config.name.clone());
441
442    let key = match &config.api_key_env {
443        Some(variable) => Some(crate::http::key_from_env(variable)?),
444        None => None,
445    };
446
447    match config.kind {
448        ProviderKind::Openai => {
449            #[cfg(feature = "openai")]
450            {
451                let provider = match key {
452                    Some(key) => crate::openai::OpenAiProvider::with_key(key),
453                    None => crate::openai::OpenAiProvider::without_key(),
454                };
455                Ok(Box::new(
456                    provider
457                        .with_base_url(config.base_url.clone())
458                        .with_model(model_override)
459                        .with_effort(effort)
460                        .with_catalogue(catalogue)
461                        .with_vendor(vendor),
462                ))
463            }
464            #[cfg(not(feature = "openai"))]
465            {
466                Err(ProviderError::Configuration(format!(
467                    "model provider `{}` needs the `openai` protocol, which this build does not \
468                     include; rebuild with `--features openai`",
469                    config.name
470                )))
471            }
472        }
473        ProviderKind::Anthropic => {
474            #[cfg(feature = "anthropic")]
475            {
476                let Some(key) = key else {
477                    return Err(ProviderError::Configuration(format!(
478                        "model provider `{}` speaks the Anthropic protocol, which authenticates \
479                         every request; give it an `api-key-env`",
480                        config.name
481                    )));
482                };
483                Ok(Box::new(
484                    crate::anthropic::AnthropicProvider::with_key(key)
485                        .with_base_url(config.base_url.clone())
486                        .with_model(model_override)
487                        .with_effort(effort)
488                        .with_catalogue(catalogue)
489                        .with_vendor(vendor),
490                ))
491            }
492            #[cfg(not(feature = "anthropic"))]
493            {
494                Err(ProviderError::Configuration(format!(
495                    "model provider `{}` needs the `anthropic` protocol, which this build does \
496                     not include; rebuild with `--features anthropic`",
497                    config.name
498                )))
499            }
500        }
501        ProviderKind::Google => {
502            #[cfg(feature = "google")]
503            {
504                let Some(key) = key else {
505                    return Err(ProviderError::Configuration(format!(
506                        "model provider `{}` speaks the Gemini protocol, which authenticates \
507                         every request; give it an `api-key-env`",
508                        config.name
509                    )));
510                };
511                Ok(Box::new(
512                    crate::google::GoogleProvider::with_key(key)
513                        .with_base_url(config.base_url.clone())
514                        .with_model(model_override)
515                        .with_effort(effort)
516                        .with_catalogue(catalogue)
517                        .with_vendor(vendor),
518                ))
519            }
520            #[cfg(not(feature = "google"))]
521            {
522                Err(ProviderError::Configuration(format!(
523                    "model provider `{}` needs the `google` protocol, which this build does not \
524                     include; rebuild with `--features google`",
525                    config.name
526                )))
527            }
528        }
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    const BUILT_IN: &[&str] = &["anthropic", "google", "openai"];
537
538    fn provider(name: &str) -> ProviderConfig {
539        ProviderConfig {
540            name: name.to_string(),
541            kind: ProviderKind::Openai,
542            base_url: "http://localhost:11434/v1/chat/completions".to_string(),
543            api_key_env: None,
544        }
545    }
546
547    #[test]
548    fn an_absent_section_declares_nothing() {
549        let config = ModelConfig::default();
550        assert!(config.is_empty());
551        assert!(config.validate(BUILT_IN).is_ok());
552    }
553
554    #[test]
555    fn a_local_server_needs_no_key() {
556        // The common case for Ollama, llama.cpp or LM Studio: no auth at all.
557        let config = ModelConfig {
558            prices: Vec::new(),
559            catalogue: Vec::new(),
560            providers: vec![provider("local")],
561            ..ModelConfig::default()
562        };
563        assert!(config.validate(BUILT_IN).is_ok());
564        assert!(config.providers[0].api_key_env.is_none());
565    }
566
567    #[test]
568    fn duplicate_names_are_refused() {
569        let config = ModelConfig {
570            prices: Vec::new(),
571            catalogue: Vec::new(),
572            providers: vec![provider("local"), provider("local")],
573            ..ModelConfig::default()
574        };
575        let error = config.validate(BUILT_IN).unwrap_err();
576        assert!(error.contains("unique"), "{error}");
577    }
578
579    #[test]
580    fn a_name_containing_a_slash_is_refused_because_that_is_the_separator() {
581        let config = ModelConfig {
582            prices: Vec::new(),
583            catalogue: Vec::new(),
584            providers: vec![provider("my/llm")],
585            ..ModelConfig::default()
586        };
587        let error = config.validate(BUILT_IN).unwrap_err();
588        assert!(error.contains("separates the vendor"), "{error}");
589    }
590
591    #[test]
592    fn an_empty_endpoint_is_refused_before_a_request_is_built() {
593        let mut bare = provider("local");
594        bare.base_url = "  ".to_string();
595        let config = ModelConfig {
596            prices: Vec::new(),
597            catalogue: Vec::new(),
598            providers: vec![bare],
599            ..ModelConfig::default()
600        };
601        assert!(config.validate(BUILT_IN).is_err());
602    }
603
604    #[test]
605    fn an_empty_key_variable_is_refused_rather_than_read_as_no_auth() {
606        // Omitting the field means no auth; setting it to "" is a mistake.
607        let mut confused = provider("local");
608        confused.api_key_env = Some(String::new());
609        let config = ModelConfig {
610            prices: Vec::new(),
611            catalogue: Vec::new(),
612            providers: vec![confused],
613            ..ModelConfig::default()
614        };
615        let error = config.validate(BUILT_IN).unwrap_err();
616        assert!(error.contains("omit it entirely"), "{error}");
617    }
618
619    fn entry(model: &str, context: Option<i64>, capabilities: &[&str]) -> ModelEntry {
620        ModelEntry {
621            model: model.to_string(),
622            context,
623            capabilities: capabilities.iter().map(|c| c.to_string()).collect(),
624        }
625    }
626
627    #[test]
628    fn a_model_with_a_wide_enough_window_and_the_right_capabilities_satisfies() {
629        let model = entry("openai/gpt-x", Some(400_000), &["tool_calling", "vision"]);
630        assert!(model.satisfies(&["vision".to_string()], Some(128_000)));
631        assert!(model.satisfies(&[], None));
632    }
633
634    #[test]
635    fn an_unknown_context_window_does_not_satisfy_a_requirement() {
636        // Guessing would turn a refusal the operator can fix into a provider
637        // error at the first long prompt, a long way from the cause.
638        let model = entry("openai/gpt-x", None, &["vision"]);
639        assert!(!model.satisfies(&[], Some(1)));
640        assert!(model.satisfies(&["vision".to_string()], None));
641        assert!(model
642            .shortfall(&[], Some(1))
643            .contains("declares no context window"));
644    }
645
646    #[test]
647    fn a_shortfall_names_both_halves_of_what_is_missing() {
648        let model = entry("openai/gpt-x", Some(8_000), &["tool_calling"]);
649        let text = model.shortfall(&["vision".to_string()], Some(128_000));
650        assert!(text.contains("8000"), "{text}");
651        assert!(text.contains("128000"), "{text}");
652        assert!(text.contains("vision"), "{text}");
653    }
654
655    #[test]
656    fn an_operators_entry_replaces_a_built_in_of_the_same_name() {
657        // Overriding a built-in means declaring it, not editing the binary --
658        // and it replaces rather than merges, because a half-overridden model
659        // is a set of facts from two places that matches neither.
660        let config = ModelConfig {
661            catalogue: vec![entry(
662                "anthropic/claude-opus-5",
663                Some(2_000_000),
664                &["vision"],
665            )],
666            ..ModelConfig::default()
667        };
668        let known = config.known_models();
669        let found: Vec<&ModelEntry> = known
670            .iter()
671            .filter(|e| e.model == "anthropic/claude-opus-5")
672            .collect();
673        assert_eq!(found.len(), 1, "one entry per model");
674        assert_eq!(found[0].context, Some(2_000_000));
675        assert_eq!(found[0].capabilities, vec!["vision".to_string()]);
676    }
677
678    #[test]
679    fn the_operators_models_are_preferred_to_the_built_in_ones() {
680        let config = ModelConfig {
681            catalogue: vec![entry(
682                "anthropic/mine",
683                Some(1_000_000),
684                &["structured_output"],
685            )],
686            ..ModelConfig::default()
687        };
688        let chosen = config
689            .resolve_capabilities("anthropic", &["structured_output".to_string()], None)
690            .expect("mine satisfies it");
691        assert_eq!(chosen, "mine");
692    }
693
694    #[test]
695    fn a_vendor_with_nothing_in_the_catalogue_says_what_to_declare() {
696        // What OpenAI and Google used to say unconditionally, now said only
697        // when it is true.
698        let error = ModelConfig::default()
699            .resolve_capabilities("openai", &["vision".to_string()], None)
700            .unwrap_err();
701        assert!(error.contains("openai"), "{error}");
702        assert!(error.contains("[[model.catalogue]]"), "{error}");
703    }
704
705    #[test]
706    fn a_vendor_whose_models_all_fall_short_says_how_each_one_does() {
707        let config = ModelConfig {
708            catalogue: vec![
709                entry("openai/small", Some(8_000), &["vision"]),
710                entry("openai/blind", Some(400_000), &["tool_calling"]),
711            ],
712            ..ModelConfig::default()
713        };
714        let error = config
715            .resolve_capabilities("openai", &["vision".to_string()], Some(128_000))
716            .unwrap_err();
717        assert!(error.contains("openai/small"), "{error}");
718        assert!(error.contains("openai/blind"), "{error}");
719        assert!(error.contains("8000"), "{error}");
720        assert!(error.contains("vision"), "{error}");
721    }
722
723    #[test]
724    fn a_model_is_named_without_its_vendor_on_the_wire() {
725        let model = entry("anthropic/claude-opus-5", None, &[]);
726        assert_eq!(model.vendor(), "anthropic");
727        assert_eq!(model.name(), "claude-opus-5");
728    }
729
730    #[test]
731    fn a_default_may_name_a_built_in_without_redeclaring_it() {
732        let config = ModelConfig {
733            prices: Vec::new(),
734            catalogue: Vec::new(),
735            default: Some("anthropic".to_string()),
736            providers: Vec::new(),
737        };
738        assert!(config.validate(BUILT_IN).is_ok());
739    }
740
741    #[test]
742    fn a_default_naming_nothing_lists_what_there_is() {
743        let config = ModelConfig {
744            prices: Vec::new(),
745            catalogue: Vec::new(),
746            default: Some("mistral".to_string()),
747            providers: vec![provider("local")],
748        };
749        let error = config.validate(BUILT_IN).unwrap_err();
750        assert!(error.contains("mistral"), "{error}");
751        assert!(
752            error.contains("anthropic, google, local, openai"),
753            "{error}"
754        );
755    }
756
757    #[test]
758    fn kind_names_a_protocol_and_accepts_the_longer_spelling() {
759        let config: ModelConfig = toml::from_str(
760            r#"
761            [[provider]]
762            name = "local"
763            kind = "openai-compatible"
764            base-url = "http://localhost:8000/v1/chat/completions"
765            "#,
766        )
767        .expect("must parse");
768        assert_eq!(config.providers[0].kind, ProviderKind::Openai);
769    }
770
771    #[test]
772    fn an_unknown_key_is_refused_rather_than_ignored() {
773        // A typo that is silently dropped is how someone ends up believing a
774        // key was forwarded when it was not.
775        let error = toml::from_str::<ModelConfig>(
776            r#"
777            [[provider]]
778            name = "local"
779            kind = "openai"
780            base-url = "http://localhost:8000/v1"
781            api_key = "sk-secret"
782            "#,
783        )
784        .expect_err("an unknown key must fail");
785        assert!(error.to_string().contains("api_key"), "{error}");
786    }
787}