1use std::collections::BTreeSet;
67
68use serde::{Deserialize, Serialize};
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "kebab-case")]
73pub enum ProviderKind {
74 #[serde(alias = "openai-compatible")]
77 Openai,
78 Anthropic,
81 #[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 pub fn requires_authentication(self) -> bool {
105 match self {
106 ProviderKind::Openai => false,
107 ProviderKind::Anthropic | ProviderKind::Google => true,
108 }
109 }
110
111 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(deny_unknown_fields, rename_all = "kebab-case")]
127pub struct ProviderConfig {
128 pub name: String,
130 pub kind: ProviderKind,
132 pub base_url: String,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub api_key_env: Option<String>,
139}
140
141#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields, rename_all = "kebab-case")]
144pub struct ModelConfig {
145 #[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 #[serde(default, rename = "price", skip_serializing_if = "Vec::is_empty")]
156 pub prices: Vec<crate::price::ModelPrice>,
157 #[serde(default, rename = "catalogue", skip_serializing_if = "Vec::is_empty")]
168 pub catalogue: Vec<ModelEntry>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(deny_unknown_fields, rename_all = "kebab-case")]
174pub struct ModelEntry {
175 pub model: String,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub context: Option<i64>,
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub capabilities: Vec<String>,
191}
192
193impl ModelEntry {
194 pub fn vendor(&self) -> &str {
196 self.model.split_once('/').map(|(v, _)| v).unwrap_or("")
197 }
198
199 pub fn name(&self) -> &str {
201 self.model
202 .split_once('/')
203 .map(|(_, name)| name)
204 .unwrap_or(&self.model)
205 }
206
207 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 _ => return false,
214 }
215 }
216 capabilities
217 .iter()
218 .all(|wanted| self.capabilities.iter().any(|has| has == wanted))
219 }
220
221 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
245pub 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 pub fn known_models(&self) -> Vec<ModelEntry> {
279 let mut models = self.catalogue.clone();
280 for (model, context, capabilities) in BUILT_IN_CATALOGUE {
281 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 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 pub fn pricing(&self) -> crate::price::Pricing {
359 crate::price::Pricing::new(self.prices.clone())
360 }
361
362 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#[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 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 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 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 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 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 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}