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 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 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 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 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 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 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 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 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}