1use crate::error::ApiResult;
5use crate::io::api::AuthenticationScheme;
6use crate::io::database::schema::{ModelRow, Table};
7use crate::io::database::{Database, Operations, Row};
8#[cfg(not(feature = "std"))]
9use crate::io::License;
10#[cfg(feature = "std")]
11use crate::io::License;
12use crate::io::{ModelListFile, Source};
13use crate::prelude::*;
14use crate::prelude::{Error, ErrorKind};
15use crate::schema::hardware::memory::Memory;
16use crate::schema::research_activity::aspect::data::Modality;
17use crate::schema::validate::is_partial_date;
18use crate::schema::OneOrMany;
19use crate::util::constants::app::DEFAULT_HUGGINGFACE_DOMAIN;
20use crate::util::constants::HTTP_URL;
21use crate::util::{strip_suffixes, Label, MarkdownSupport, SemanticVersion, StringInterpolation};
22use bon::Builder;
23use color_eyre::eyre::{eyre, Report};
24use core::{convert::Infallible, fmt, str::from_utf8, str::FromStr};
25use derive_more::Display;
26use owo_colors::OwoColorize;
27use rust_embed::Embed;
28use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30use serde_with::skip_serializing_none;
31use std::collections::HashMap;
32use strum::{EnumIter, IntoEnumIterator};
33use tera::{Context, Tera};
34use tracing::warn;
35use validator::{Validate, ValidationError};
36
37pub mod opencode;
38
39pub(crate) const FALLBACK_MODEL_SUFFIXES: &[&str] = &["-fp8", "-maas"];
40
41#[derive(Clone, Debug, Display, Deserialize, Serialize, JsonSchema)]
43pub enum Harness {
44 #[display("Claude Code")]
46 #[serde(rename = "Claude Code")]
47 ClaudeCode,
48 #[display("Codex")]
50 #[serde(rename = "Codex")]
51 Codex,
52 #[display("Codex CLI")]
54 #[serde(rename = "Codex CLI")]
55 CodexCli,
56 #[display("Cursor CLI")]
58 #[serde(rename = "Cursor CLI")]
59 CursorCli,
60 #[display("Gemini CLI")]
62 #[serde(rename = "Gemini CLI")]
63 GeminiCli,
64 #[display("Mini-SWE-Agent")]
66 #[serde(rename = "Mini-SWE-Agent")]
67 MiniSweAgent,
68 #[display("OpenCode")]
70 #[serde(rename = "OpenCode")]
71 OpenCode,
72 #[display("Terminus-2")]
74 #[serde(rename = "Terminus-2")]
75 Terminus2,
76 #[display("{}", _0)]
78 #[serde(untagged)]
79 Other(String),
80}
81#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
83pub enum Metric {
84 #[serde(rename = "average pass@1")]
86 AveragePassAt1,
87 #[serde(rename = "index")]
89 Index,
90 #[serde(rename = "pass@1")]
92 PassAt1,
93 #[serde(rename = "percent correct")]
95 PercentCorrect,
96 #[serde(rename = "percent resolved")]
98 PercentResolved,
99 #[serde(rename = "resolve rate")]
101 ResolveRate,
102 #[serde(rename = "resolved")]
104 Resolved,
105 #[serde(rename = "score")]
107 Score,
108 #[serde(rename = "success rate")]
110 SuccessRate,
111 #[serde(untagged)]
113 Other(String),
114}
115#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
117pub enum Model {
118 SLM(ModelDetails),
120 LLM(ModelDetails),
122}
123#[derive(Clone, Copy, Debug, Display, Eq, PartialEq)]
125pub enum ModelResolutionReason {
126 #[display("model is not open")]
128 NotOpen,
129 #[display("no open weight sources are declared")]
131 NoOpenWeights,
132 #[display("declared weights do not identify a Hugging Face repository")]
134 NoHuggingFaceRepository,
135 #[display("model has no identifier or name")]
137 MissingIdentifier,
138}
139#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
141pub enum PromptFileAsset {
142 Eli5,
144 ExtractClaim,
146 FindGaps,
148 Summarize,
150 Teach,
152 Translate,
154 Unknown(String),
156}
157#[derive(Clone, Debug, Display, Deserialize, Serialize, JsonSchema)]
159#[serde(rename_all = "lowercase")]
160pub enum Provider {
161 #[display("Alibaba Cloud")]
163 Alibaba,
164 #[display("Amazon Web Services")]
166 Amazon,
167 #[display("Anthropic")]
169 Anthropic,
170 #[display("Azure")]
172 Azure,
173 #[display("Baichuan")]
175 Baichuan,
176 #[display("Baidu")]
178 Baidu,
179 #[display("Cohere")]
181 Cohere,
182 #[display("Databricks")]
184 Databricks,
185 #[display("DeepSeek")]
187 DeepSeek,
188 #[display("Doubao")]
190 Doubao,
191 #[display("Google")]
193 Google,
194 #[display("Groq")]
196 Groq,
197 #[display("IBM")]
199 IBM,
200 #[display("Kimi")]
202 Kimi,
203 #[display("Meta")]
205 Meta,
206 #[display("Minimax")]
208 Minimax,
209 #[display("Mistral")]
211 Mistral,
212 #[display("Moonshot AI")]
214 MoonshotAI,
215 #[display("NVIDIA")]
217 #[serde(alias = "NVIDIA")]
218 Nvidia,
219 #[display("Ollama")]
221 Ollama,
222 #[display("OpenAI")]
224 OpenAI,
225 #[display("Perplexity")]
227 Perplexity,
228 #[display("Qwen")]
230 Qwen,
231 #[display("Salesforce")]
233 Salesforce,
234 #[display("SAP")]
236 SAP,
237 #[display("Sarvam AI")]
239 Sarvam,
240 #[display("Stepfun")]
242 Stepfun,
243 #[display("Tencent")]
245 Tencent,
246 #[display("Together AI")]
248 TogetherAI,
249 #[display("xAI")]
251 XAI,
252 #[display("Xiaomi")]
254 Xiaomi,
255 #[display("Zhipu AI")]
257 ZhipuAI,
258 #[display("{}", _0)]
260 Custom(String),
261}
262#[allow(non_camel_case_types)]
278#[derive(Clone, Debug, Default, Display, EnumIter, PartialEq, Serialize, JsonSchema)]
279pub enum Quantization {
280 #[default]
282 #[display("Q4_K_M")]
283 #[serde(rename = "Q4_K_M")]
284 Q4kM,
285 #[display("Q2_K")]
287 #[serde(rename = "Q2_K")]
288 Q2k,
289 #[display("Q3_K_S")]
291 #[serde(rename = "Q3_K_S")]
292 Q3kS,
293 #[display("Q3_K_M")]
295 #[serde(rename = "Q3_K_M")]
296 Q3kM,
297 #[display("Q3_K_L")]
299 #[serde(rename = "Q3_K_L")]
300 Q3kL,
301 #[display("Q5_K_M")]
303 #[serde(rename = "Q5_K_M")]
304 Q5kM,
305 #[display("Q6_K")]
307 #[serde(rename = "Q6_K")]
308 Q6k,
309 #[display("Q8_0")]
311 #[serde(rename = "Q8_0")]
312 Q8_0,
313 #[display("F8")]
315 #[serde(rename = "F8")]
316 F8,
317 #[display("F16")]
319 #[serde(rename = "F16")]
320 F16,
321 #[display("BF16")]
323 #[serde(rename = "BF16")]
324 BF16,
325 #[display("IQ4_XS")]
327 #[serde(rename = "IQ4_XS")]
328 IQ4_XS,
329 #[display("{}", _0)]
331 #[serde(untagged)]
332 Other(String),
333}
334#[skip_serializing_none]
336#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Validate)]
337pub struct Benchmark {
338 pub name: String,
340 #[validate(range(min = 0.0))]
342 pub score: f64,
343 #[serde(default, deserialize_with = "deserialize_metric")]
345 pub metric: Option<Metric>,
346 #[validate(url)]
348 pub source: String,
349 #[validate(custom(function = "is_partial_date"))]
351 pub date: Option<String>,
352 pub dataset: Option<String>,
354 #[serde(default, deserialize_with = "deserialize_harness")]
356 pub harness: Option<Harness>,
357 pub variant: Option<String>,
359 pub version: Option<String>,
361}
362#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
364pub struct CostDetails {
365 pub input: Option<f64>,
367 pub output: Option<f64>,
369 pub cache_read: Option<f64>,
371 pub cache_write: Option<f64>,
373 pub reasoning: Option<f64>,
375 #[serde(rename = "input_audio")]
377 pub input_audio: Option<f64>,
378 #[serde(rename = "output_audio")]
380 pub output_audio: Option<f64>,
381 pub context_over_200k: Option<Box<CostDetails>>,
383 pub tiers: Option<Vec<CostTier>>,
385}
386#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
388pub struct CostTier {
389 pub input: f64,
391 pub output: f64,
393 pub cache_read: Option<f64>,
395 pub tier: TierInfo,
397}
398#[skip_serializing_none]
402#[derive(Builder, Clone, Debug, Serialize, Deserialize, Validate)]
403#[serde(rename_all = "kebab-case")]
404#[builder(start_fn = init)]
405pub struct FrontMatter {
406 #[builder(default = String::new())]
408 pub name: String,
409 #[builder(default = String::new())]
411 pub description: String,
412 pub config: Option<PromptTemplateConfiguration>,
414 #[validate(nested)]
416 pub license: Option<License>,
417 pub compatibility: Option<String>,
419 pub model: Option<String>,
423 pub metadata: Option<Vec<(String, String)>>,
427 pub allowed_tools: Option<Vec<String>>,
431}
432#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
434pub struct LimitDetails {
435 pub context: u64,
437 pub output: Option<u64>,
439 pub input: Option<u64>,
441}
442#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
444pub struct Modalities {
445 pub input: Vec<Modality>,
447 pub output: Vec<Modality>,
449}
450#[skip_serializing_none]
455#[derive(Builder, Clone, Debug, Default, Deserialize, Serialize, JsonSchema, Validate)]
456#[builder(start_fn = init, on(String, into))]
457#[validate(schema(function = "validate_open_weights", skip_on_field_errors = false))]
458pub struct ModelDetails {
459 pub attachment: Option<bool>,
461 #[serde(default)]
463 pub benchmarks: Option<OneOrMany<Benchmark>>,
464 pub family: Option<String>,
466 pub id: Option<String>,
468 #[validate(custom(function = "is_partial_date"))]
470 pub knowledge: Option<String>,
471 #[validate(custom(function = "is_partial_date"))]
473 pub last_updated: Option<String>,
474 pub limit: Option<LimitDetails>,
476 pub modalities: Option<Modalities>,
478 pub cost: Option<CostDetails>,
480 pub name: Option<String>,
482 pub open_weights: Option<bool>,
484 pub parameters: Option<i64>,
486 pub path: Option<String>,
488 pub reasoning: Option<bool>,
490 #[validate(custom(function = "is_partial_date"))]
492 pub release_date: Option<String>,
493 pub structured_output: Option<bool>,
495 pub temperature: Option<bool>,
497 pub tool_call: Option<bool>,
499 pub fallback: Option<String>,
501 pub variant: Option<String>,
503 pub version: Option<SemanticVersion>,
505 pub weights: Option<Weights>,
507}
508#[derive(Clone, Debug, Eq, Hash, PartialEq)]
510pub struct ModelSelector(String);
511#[derive(Clone, Debug, Default, Eq, PartialEq)]
513pub struct ModelSelectors(Vec<ModelSelector>);
514#[derive(Embed)]
518#[folder = "assets/prompts/"]
519pub struct PromptTemplate;
520#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
522#[serde(rename_all = "kebab-case")]
523#[builder(start_fn = init)]
524pub struct PromptTemplateConfiguration {
525 pub include_analogy: Option<bool>,
527 pub include_examples: Option<bool>,
529 pub include_implicit: Option<bool>,
531 pub include_practice: Option<bool>,
533 pub max_items: Option<u32>,
535 #[builder(default = 300)]
537 pub max_tokens: u32,
538 pub max_words: Option<u32>,
540 pub min_confidence: Option<f32>,
542 #[builder(default = Vec::new())]
544 pub stop_sequences: Vec<String>,
545 pub text: Option<String>,
547 pub language: Option<String>,
549 #[builder(default = 0.1)]
551 pub temperature: f32,
552 #[builder(default = 10)]
554 pub top_k: u32,
555 #[builder(default)]
557 pub version: SemanticVersion,
558}
559#[skip_serializing_none]
561#[derive(Builder, Clone, Debug, Default, Deserialize, Serialize, JsonSchema, Validate)]
562#[builder(start_fn = init, on(String, into))]
563pub struct ProviderDetails {
564 pub authentication: Option<Vec<AuthenticationScheme>>,
566 pub description: Option<String>,
568 #[serde(rename = "doc")]
570 #[validate(url)]
571 pub documentation: Option<String>,
572 #[serde(rename = "api")]
574 #[validate(url)]
575 pub endpoint: Option<String>,
576 pub env: Option<Vec<String>>,
578 #[validate(custom(function = "is_partial_date"))]
580 pub established_date: Option<String>,
581 pub id: Option<String>,
583 #[validate(custom(function = "is_partial_date"))]
585 pub last_updated: Option<String>,
586 #[serde(default, deserialize_with = "deserialize_models")]
588 pub models: Option<Vec<ModelDetails>>,
589 pub name: Option<String>,
591 pub npm: Option<String>,
593 #[validate(url)]
595 pub url: Option<String>,
596}
597#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
599#[serde(rename_all = "camelCase")]
600pub struct TierInfo {
601 #[serde(rename = "type")]
603 pub kind: String,
604 pub size: u64,
606}
607#[skip_serializing_none]
609#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
610pub struct Weight {
611 pub label: String,
613 pub url: String,
615 pub is_open: Option<bool>,
617 pub quantization: Option<Quantization>,
619 pub size: Option<u64>,
621}
622#[derive(Clone, Debug)]
624pub struct WeightGroup {
625 pub quantization: Quantization,
627 pub repository: String,
629 pub revision: String,
631 pub paths: Vec<String>,
633 pub size: Option<u64>,
635}
636#[derive(Clone, Debug, Default)]
638pub struct WeightGroups(pub Vec<WeightGroup>);
639#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
641#[serde(transparent)]
642pub struct Weights(pub Vec<Weight>);
643impl From<&str> for Weight {
644 fn from(value: &str) -> Self {
645 Self {
646 label: "inferred".to_string(),
647 url: value.to_string(),
648 is_open: None,
649 quantization: None,
650 size: None,
651 }
652 }
653}
654impl Weight {
655 pub fn parse(self) -> Option<(String, String, String, Quantization, Option<u64>)> {
657 let prefix = format!("https://{DEFAULT_HUGGINGFACE_DOMAIN}/");
658 self.quantization.and_then(|quantization| {
659 self.url.strip_prefix(&prefix).and_then(|relative| {
660 relative.split_once("/resolve/").and_then(|(repository, remainder)| {
661 remainder
662 .split_once('/')
663 .map(|(revision, path)| (repository.to_string(), revision.to_string(), path.to_string(), quantization, self.size))
664 })
665 })
666 })
667 }
668}
669impl WeightGroups {
670 pub fn select(&self, quantization: &[Quantization], gpu_memory: Option<&Memory>) -> Option<&WeightGroup> {
672 let allowed = match quantization.is_empty() {
673 | true => vec![Quantization::Q4kM],
674 | false => quantization.to_vec(),
675 };
676 let selected = allowed.iter().find_map(|allowed| {
677 self.0
678 .iter()
679 .filter(|group| &group.quantization == allowed)
680 .find(|group| match (gpu_memory, group.size) {
681 | (Some(memory), Some(size)) => memory.can_contain(size).unwrap_or(false),
682 | _ => true,
683 })
684 });
685 match selected {
686 | Some(group) => {
687 if gpu_memory.is_some() && group.size.is_none() {
688 warn!(
689 "=> {} GGUF size metadata is incomplete for '{}'; memory eligibility is unknown and the download will proceed",
690 Label::CAUTION,
691 group.repository
692 );
693 }
694 Some(group)
695 }
696 | None => {
697 let requested = allowed.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
698 if gpu_memory.is_some() && self.0.iter().any(|group| allowed.contains(&group.quantization)) {
699 warn!(
700 "=> {} Persisted GGUF variant [{requested}] exceeds the configured GPU memory",
701 Label::rejected(),
702 );
703 } else {
704 warn!(
705 "=> {} No persisted GGUF variant matched the exact quantization allowlist [{requested}]",
706 Label::rejected()
707 );
708 }
709 None
710 }
711 }
712 }
713}
714impl Weights {
715 pub fn groups(self) -> WeightGroups {
717 WeightGroups(self.0.into_iter().filter_map(Weight::parse).fold(Vec::new(), |groups, parsed| {
718 let (repository, revision, path, quantization, size) = parsed;
719 match groups
720 .iter()
721 .position(|group: &WeightGroup| group.repository == repository && group.revision == revision && group.quantization == quantization)
722 {
723 | Some(index) => groups
724 .into_iter()
725 .enumerate()
726 .map(|(position, group)| {
727 if position == index {
728 WeightGroup {
729 paths: group.paths.into_iter().chain([path.clone()]).collect(),
730 size: match (group.size, size) {
731 | (Some(total), Some(value)) => total.checked_add(value).or(Some(u64::MAX)),
732 | _ => None,
733 },
734 ..group
735 }
736 } else {
737 group
738 }
739 })
740 .collect(),
741 | None => groups
742 .into_iter()
743 .chain([WeightGroup {
744 quantization,
745 repository,
746 revision,
747 paths: vec![path],
748 size,
749 }])
750 .collect(),
751 }
752 }))
753 }
754 pub fn has_file_metadata(&self) -> bool {
756 self.0
757 .iter()
758 .any(|weight| weight.quantization.is_some() && weight.url.contains("/resolve/"))
759 }
760 pub fn infer_quantization(self, model_id: &str) -> Option<Self> {
762 let inferred = model_id
763 .split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
764 .map(Quantization::from)
765 .find(|candidate| {
766 Quantization::iter()
767 .filter(|variant| !matches!(variant, Quantization::Other(_)))
768 .any(|variant| &variant == candidate)
769 })
770 .or_else(|| self.0.iter().find_map(|weight| weight.quantization.clone()));
771 match (self.0.is_empty(), inferred) {
772 | (true, None) => None,
773 | (_, Some(quantization)) if self.0.iter().all(|weight| weight.quantization.is_none()) => Some(Self(
774 [Weight {
775 quantization: Some(quantization),
776 ..Weight::from(model_id)
777 }]
778 .into_iter()
779 .chain(self.0)
780 .collect(),
781 )),
782 | _ => Some(self),
783 }
784 }
785 pub fn persist(self, model_id: &str, database_path: Option<PathBuf>) -> ApiResult<()> {
787 let lookup = ModelRow::init()
788 .model_id(model_id.to_string())
789 .build()
790 .select(database_path.clone(), |row| row.model_id.as_deref() == Some(model_id));
791 match lookup {
792 | Ok(Some(row)) => {
793 let existing = row
794 .weights
795 .as_deref()
796 .and_then(|value| serde_json::from_str::<Weights>(value).ok())
797 .unwrap_or_default();
798 let refreshed = Weights(
799 existing
800 .0
801 .into_iter()
802 .filter(|weight| !(weight.quantization.is_some() && weight.url.contains("/resolve/")))
803 .chain(self.0)
804 .collect(),
805 );
806 refreshed.serialize().and_then(|weights| {
807 ModelRow {
808 weights: Some(weights),
809 ..row
810 }
811 .update_weights(database_path)
812 .map(|_| ())
813 })
814 }
815 | Ok(None) => self.serialize().and_then(|weights| {
816 Database::<Table>::from_path(database_path)
817 .insert(ModelRow::init().model_id(model_id.to_string()).weights(weights).build())
818 .map(|_| ())
819 }),
820 | Err(why) => Err(why),
821 }
822 }
823 pub fn serialize(self) -> ApiResult<String> {
825 serde_json::to_string(&self).map_err(|why| eyre!("Failed to serialize model weights — {why}"))
826 }
827}
828impl Default for FrontMatter {
829 fn default() -> Self {
830 FrontMatter::init().build()
831 }
832}
833impl MarkdownSupport for FrontMatter {
834 fn to_markdown(&self) -> String {
835 self.to_front_matter()
836 .map(|frontmatter| format!("---\n{frontmatter}---"))
837 .unwrap_or_default()
838 }
839}
840impl From<&str> for Harness {
841 fn from(value: &str) -> Self {
842 match value {
843 | "Claude Code" => Self::ClaudeCode,
844 | "Codex" => Self::Codex,
845 | "Codex CLI" => Self::CodexCli,
846 | "Cursor CLI" => Self::CursorCli,
847 | "Gemini CLI" => Self::GeminiCli,
848 | "Mini-SWE-Agent" | "mini-swe-agent" => Self::MiniSweAgent,
849 | "OpenCode" => Self::OpenCode,
850 | "Terminus-2" => Self::Terminus2,
851 | _ => Self::Other(value.to_string()),
852 }
853 }
854}
855impl From<String> for Harness {
856 fn from(value: String) -> Self {
857 Self::Other(value)
858 }
859}
860impl fmt::Display for Metric {
861 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
862 match self {
863 | Self::AveragePassAt1 => write!(f, "average pass@1"),
864 | Self::Index => write!(f, "index"),
865 | Self::PassAt1 => write!(f, "pass@1"),
866 | Self::PercentCorrect => write!(f, "percent correct"),
867 | Self::PercentResolved => write!(f, "percent resolved"),
868 | Self::ResolveRate => write!(f, "resolve rate"),
869 | Self::Resolved => write!(f, "resolved"),
870 | Self::Score => write!(f, "score"),
871 | Self::SuccessRate => write!(f, "success rate"),
872 | Self::Other(value) => write!(f, "{value}"),
873 }
874 }
875}
876impl From<&str> for Metric {
877 fn from(value: &str) -> Self {
878 match value {
879 | "average pass@1" => Self::AveragePassAt1,
880 | "index" => Self::Index,
881 | "pass@1" => Self::PassAt1,
882 | "percent correct" => Self::PercentCorrect,
883 | "percent resolved" => Self::PercentResolved,
884 | "resolve rate" => Self::ResolveRate,
885 | "resolved" => Self::Resolved,
886 | "score" => Self::Score,
887 | "success rate" => Self::SuccessRate,
888 | _ => Self::Other(value.to_string()),
889 }
890 }
891}
892impl From<String> for Metric {
893 fn from(value: String) -> Self {
894 Self::Other(value)
895 }
896}
897impl MarkdownSupport for Benchmark {
898 fn to_markdown(&self) -> String {
899 Some(
900 [
901 Some(format!("- Name: {}", self.name)),
902 Some(format!("- Score: {}", self.score)),
903 self.metric.as_ref().map(|value| format!("- Metric: {value}")),
904 Some(format!("- Source: {}", self.source)),
905 self.date.as_ref().map(|value| format!("- Date: {value}")),
906 self.dataset.as_ref().map(|value| format!("- Dataset: {value}")),
907 self.harness.as_ref().map(|value| format!("- Harness: {value}")),
908 self.variant.as_ref().map(|value| format!("- Variant: {value}")),
909 self.version.as_ref().map(|value| format!("- Version: {value}")),
910 ]
911 .into_iter()
912 .flatten()
913 .map(|value| value.trim_start_matches("- ").to_string())
914 .collect::<Vec<_>>(),
915 )
916 .to_markdown()
917 .trim_start()
918 .to_string()
919 }
920}
921impl MarkdownSupport for OneOrMany<Benchmark> {
922 fn to_markdown(&self) -> String {
923 match self {
924 | Self::One(value) => format!("- Benchmark\n{}", value.to_markdown().with_additional_indent(2)),
925 | Self::Many(values) => values
926 .iter()
927 .map(|value| format!("- Benchmark\n{}", value.to_markdown().with_additional_indent(2)))
928 .collect::<Vec<_>>()
929 .join("\n"),
930 }
931 }
932}
933impl MarkdownSupport for CostDetails {
934 fn to_markdown(&self) -> String {
935 Some(
936 [
937 self.input.map(|value| format!("- Input: {value}")),
938 self.output.map(|value| format!("- Output: {value}")),
939 self.cache_read.map(|value| format!("- Cache Read: {value}")),
940 self.cache_write.map(|value| format!("- Cache Write: {value}")),
941 self.reasoning.map(|value| format!("- Reasoning: {value}")),
942 self.input_audio.map(|value| format!("- Input Audio: {value}")),
943 self.output_audio.map(|value| format!("- Output Audio: {value}")),
944 self.context_over_200k
945 .as_ref()
946 .map(|value| format!("- Context Over 200K\n{}", value.to_markdown().with_additional_indent(2))),
947 self.tiers.as_ref().map(|values| {
948 format!(
949 "- Tiers\n{}",
950 values
951 .iter()
952 .map(|value| format!("- Tier\n{}", value.to_markdown().with_additional_indent(2)))
953 .collect::<Vec<_>>()
954 .join("\n")
955 .with_additional_indent(2)
956 )
957 }),
958 ]
959 .into_iter()
960 .flatten()
961 .map(|value| value.trim_start_matches("- ").to_string())
962 .collect::<Vec<_>>(),
963 )
964 .to_markdown()
965 .trim_start()
966 .to_string()
967 }
968}
969impl MarkdownSupport for CostTier {
970 fn to_markdown(&self) -> String {
971 Some(
972 [
973 format!("- Input: {}", self.input),
974 format!("- Output: {}", self.output),
975 self.cache_read.map(|value| format!("- Cache Read: {value}")).unwrap_or_default(),
976 format!("- Type: {}", self.tier.kind),
977 format!("- Size: {}", self.tier.size),
978 ]
979 .into_iter()
980 .filter(|value| !value.is_empty())
981 .map(|value| value.trim_start_matches("- ").to_string())
982 .collect::<Vec<_>>(),
983 )
984 .to_markdown()
985 .trim_start()
986 .to_string()
987 }
988}
989impl MarkdownSupport for LimitDetails {
990 fn to_markdown(&self) -> String {
991 Some(
992 [
993 Some(format!("- Context: {}", self.context)),
994 self.input.map(|value| format!("- Input: {value}")),
995 self.output.map(|value| format!("- Output: {value}")),
996 ]
997 .into_iter()
998 .flatten()
999 .map(|value| value.trim_start_matches("- ").to_string())
1000 .collect::<Vec<_>>(),
1001 )
1002 .to_markdown()
1003 .trim_start()
1004 .to_string()
1005 }
1006}
1007impl MarkdownSupport for Modalities {
1008 fn to_markdown(&self) -> String {
1009 Some(
1010 [
1011 (!self.input.is_empty()).then(|| format!("- Input: {}", self.input.iter().map(ToString::to_string).collect::<Vec<_>>().join(", "))),
1012 (!self.output.is_empty())
1013 .then(|| format!("- Output: {}", self.output.iter().map(ToString::to_string).collect::<Vec<_>>().join(", "))),
1014 ]
1015 .into_iter()
1016 .flatten()
1017 .map(|value| value.trim_start_matches("- ").to_string())
1018 .collect::<Vec<_>>(),
1019 )
1020 .to_markdown()
1021 .trim_start()
1022 .to_string()
1023 }
1024}
1025impl MarkdownSupport for Weight {
1026 fn to_markdown(&self) -> String {
1027 Some(
1028 [
1029 Some(format!("- Label: {}", self.label)),
1030 Some(format!("- URL: {}", self.url)),
1031 self.is_open.map(|value| format!("- Open: {value}")),
1032 self.quantization.as_ref().map(|value| format!("- Quantization: {value}")),
1033 self.size.map(|value| format!("- Size: {value}")),
1034 ]
1035 .into_iter()
1036 .flatten()
1037 .map(|value| value.trim_start_matches("- ").to_string())
1038 .collect::<Vec<_>>(),
1039 )
1040 .to_markdown()
1041 .trim_start()
1042 .to_string()
1043 }
1044}
1045impl MarkdownSupport for Weights {
1046 fn to_markdown(&self) -> String {
1047 self.0
1048 .iter()
1049 .map(|value| format!("- Weight\n{}", value.to_markdown().with_additional_indent(2)))
1050 .collect::<Vec<_>>()
1051 .join("\n")
1052 }
1053}
1054impl MarkdownSupport for ModelDetails {
1055 fn to_markdown(&self) -> String {
1056 let lines = [
1057 self.attachment.map(|value| format!("- Attachment: {value}")),
1058 self.benchmarks
1059 .as_ref()
1060 .map(|value| format!("- Benchmarks\n{}", value.to_markdown().with_additional_indent(2))),
1061 self.family.as_ref().map(|value| format!("- Family: {value}")),
1062 self.id.as_ref().map(|value| format!("- ID: {value}")),
1063 self.knowledge.as_ref().map(|value| format!("- Knowledge: {value}")),
1064 self.last_updated.as_ref().map(|value| format!("- Last Updated: {value}")),
1065 self.limit
1066 .as_ref()
1067 .map(|value| format!("- Limits\n{}", value.to_markdown().with_additional_indent(2))),
1068 self.modalities
1069 .as_ref()
1070 .map(|value| format!("- Modalities\n{}", value.to_markdown().with_additional_indent(2))),
1071 self.name.as_ref().map(|value| format!("- Name: {value}")),
1072 self.open_weights.map(|value| format!("- Open Weights: {value}")),
1073 self.path.as_ref().map(|value| format!("- Path: {value}")),
1074 self.cost
1075 .as_ref()
1076 .map(|value| format!("- Cost\n{}", value.to_markdown().with_additional_indent(2))),
1077 self.parameters.map(|value| format!("- Parameters: {value}B")),
1078 self.reasoning.map(|value| format!("- Reasoning: {value}")),
1079 self.release_date.as_ref().map(|value| format!("- Release Date: {value}")),
1080 self.structured_output.map(|value| format!("- Structured Output: {value}")),
1081 self.temperature.map(|value| format!("- Temperature: {value}")),
1082 self.tool_call.map(|value| format!("- Tool Call: {value}")),
1083 self.fallback.as_ref().map(|value| format!("- Fallback: {value}")),
1084 self.variant.as_ref().map(|value| format!("- Variant: {value}")),
1085 self.version.as_ref().map(|value| format!("- Version: {value}")),
1086 self.weights
1087 .as_ref()
1088 .map(|value| format!("- Weights\n{}", value.to_markdown().with_additional_indent(2))),
1089 ]
1090 .into_iter()
1091 .flatten()
1092 .collect::<Vec<_>>();
1093 if lines.is_empty() {
1094 String::new()
1095 } else {
1096 lines.join("\n").to_string()
1097 }
1098 }
1099}
1100impl ModelDetails {
1101 pub fn selector(self) -> Result<ModelSelector, ModelResolutionReason> {
1103 let repository = Option::<Source>::from(self.clone())
1104 .map(|source| source.identifier())
1105 .filter(|identifier| !HTTP_URL.is_match(identifier).unwrap_or(false));
1106 let identifier = self
1107 .id
1108 .or(self.name)
1109 .map(|value| value.trim().to_string())
1110 .filter(|value| !value.is_empty());
1111 let selector = match (repository, self.open_weights, self.weights, identifier) {
1112 | (Some(repository), _, _, _) => Ok(repository),
1113 | (None, None | Some(true), None, Some(identifier)) => Ok(identifier),
1114 | (None, Some(false), _, _) => Err(ModelResolutionReason::NotOpen),
1115 | (None, Some(true), None, _) => Err(ModelResolutionReason::NoOpenWeights),
1116 | (None, _, Some(weights), _) if weights.0.is_empty() => Err(ModelResolutionReason::NoOpenWeights),
1117 | (None, _, Some(_), _) => Err(ModelResolutionReason::NoHuggingFaceRepository),
1118 | (None, _, None, None) => Err(ModelResolutionReason::MissingIdentifier),
1119 };
1120 selector.and_then(|value| ModelSelector::new(value).ok_or(ModelResolutionReason::MissingIdentifier))
1121 }
1122 pub fn with_fallback(self, value: &str) -> Self {
1124 Self {
1125 fallback: Some(value.to_string()),
1126 ..self
1127 }
1128 }
1129 pub fn with_id(self, value: &str) -> Self {
1131 Self {
1132 id: Some(value.to_string()),
1133 ..self
1134 }
1135 }
1136}
1137impl ModelSelector {
1138 pub fn new(value: impl Into<String>) -> Option<Self> {
1140 let value = value.into();
1141 let trimmed = value.trim();
1142 (!trimmed.is_empty()).then(|| Self(trimmed.to_string()))
1143 }
1144 pub fn as_str(&self) -> &str {
1146 &self.0
1147 }
1148 pub fn fallback_search_name(&self) -> String {
1150 let name = self.0.rsplit('/').next().unwrap_or_default();
1151 let canonical = strip_suffixes(FALLBACK_MODEL_SUFFIXES, name)
1152 .replace("llama-3.1-", "llama-3_1-")
1153 .replace("llama-3.3-", "llama-3_3-")
1154 .replace("v1.5", "v1_5");
1155 match canonical.as_str() {
1156 | "llama-3_1-nemotron-ultra-253b" => format!("{canonical}-v1"),
1157 | _ => canonical,
1158 }
1159 }
1160}
1161impl AsRef<str> for ModelSelector {
1162 fn as_ref(&self) -> &str {
1163 self.as_str()
1164 }
1165}
1166impl fmt::Display for ModelSelector {
1167 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1168 formatter.write_str(self.as_str())
1169 }
1170}
1171impl From<ModelSelector> for String {
1172 fn from(selector: ModelSelector) -> Self {
1173 selector.0
1174 }
1175}
1176impl From<&[String]> for ModelSelectors {
1177 fn from(values: &[String]) -> Self {
1178 Self(values.iter().filter_map(|value| ModelSelector::new(value.clone())).collect())
1179 }
1180}
1181impl From<Vec<ModelSelector>> for ModelSelectors {
1182 fn from(values: Vec<ModelSelector>) -> Self {
1183 Self(values)
1184 }
1185}
1186impl From<Vec<String>> for ModelSelectors {
1187 fn from(values: Vec<String>) -> Self {
1188 Self(values.into_iter().filter_map(ModelSelector::new).collect())
1189 }
1190}
1191impl TryFrom<String> for ModelSelectors {
1192 type Error = Report;
1193
1194 fn try_from(content: String) -> Result<Self, Self::Error> {
1195 let trimmed = content.trim();
1196 if trimmed.is_empty() {
1197 Err(eyre!("Model list file cannot be empty"))
1198 } else {
1199 let starts_with_collection = trimmed.starts_with(['[', '{']);
1200 let has_structured_line = trimmed.lines().any(|line| {
1201 let line = line.trim_start();
1202 let is_list_item = line.starts_with("- ");
1203 let is_mapping_key = line.ends_with(':');
1204 let is_key_value = !line.contains("://") && line.split_once(':').is_some_and(|(key, _)| !key.trim().is_empty());
1205 is_list_item || is_mapping_key || is_key_value
1206 });
1207 let structured = starts_with_collection || has_structured_line;
1208 match serde_norway::from_str::<ModelListFile>(trimmed) {
1209 | Ok(file) => file.selectors().require_non_empty(),
1210 | Err(why) if structured => Err(eyre!("Failed to parse model list file as JSON or YAML — {why}")),
1211 | Err(_) => Self::from(trimmed.lines().map(str::to_string).collect::<Vec<_>>()).require_non_empty(),
1212 }
1213 }
1214 }
1215}
1216impl ModelSelectors {
1217 pub fn is_empty(&self) -> bool {
1219 self.0.is_empty()
1220 }
1221 pub fn iter(&self) -> impl Iterator<Item = &ModelSelector> {
1223 self.0.iter()
1224 }
1225 pub fn parse(content: String) -> ApiResult<Self> {
1227 Self::try_from(content)
1228 }
1229 fn require_non_empty(self) -> ApiResult<Self> {
1230 match self.is_empty() {
1231 | true => Err(eyre!("Model list file cannot be empty")),
1232 | false => Ok(self),
1233 }
1234 }
1235 pub async fn resolve(self, source: &Option<String>, offline: bool) -> ApiResult<Self> {
1237 match source {
1238 | Some(source) => Source::read(source, offline)
1239 .await
1240 .and_then(Self::parse)
1241 .map(|file| Self(self.0.into_iter().chain(file.0).collect())),
1242 | None => Ok(self),
1243 }
1244 }
1245}
1246impl fmt::Display for PromptFileAsset {
1247 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1248 let value = match self {
1249 | Self::Eli5 => "eli5.prompt",
1250 | Self::ExtractClaim => "extract-claim.prompt",
1251 | Self::FindGaps => "find-gaps.prompt",
1252 | Self::Summarize => "summarize.prompt",
1253 | Self::Teach => "teach.prompt",
1254 | Self::Translate => "translate.prompt",
1255 | Self::Unknown(value) => value,
1256 };
1257
1258 write!(f, "{value}")
1259 }
1260}
1261impl From<&str> for PromptFileAsset {
1262 fn from(value: &str) -> Self {
1263 match value.to_lowercase().as_str() {
1264 | "eli5" | "eli5.prompt" => Self::Eli5,
1265 | "extract-claim" | "extract-claim.prompt" => Self::ExtractClaim,
1266 | "find-gaps" | "find-gaps.prompt" => Self::FindGaps,
1267 | "summarize" | "summarize.prompt" => Self::Summarize,
1268 | "teach" | "teach.prompt" => Self::Teach,
1269 | "translate" | "translate.prompt" => Self::Translate,
1270 | _ => Self::Unknown(value.into()),
1271 }
1272 }
1273}
1274impl From<String> for PromptFileAsset {
1275 fn from(value: String) -> Self {
1276 Self::from(value.as_str())
1277 }
1278}
1279impl Default for PromptTemplateConfiguration {
1280 fn default() -> Self {
1281 PromptTemplateConfiguration::init().build()
1282 }
1283}
1284impl PromptTemplate {
1285 pub fn from_asset(file_name: &str) -> Option<String> {
1287 match Self::get(file_name) {
1288 | Some(value) => from_utf8(value.data.as_ref()).ok().map(String::from),
1289 | None => None,
1290 }
1291 }
1292 pub fn render<T>(asset: T, config: &PromptTemplateConfiguration) -> ApiResult<String>
1302 where
1303 T: Into<PromptFileAsset>,
1304 {
1305 let name = asset.into().to_string();
1306 Self::from_asset(&name)
1307 .ok_or_else(|| Error::new(ErrorKind::NotFound, format!("Prompt template not found — {name}")))
1308 .map_err(Report::from)
1309 .and_then(|template| {
1310 let mut context = Context::new();
1311 context.insert("config", config);
1312 Tera::one_off(&template, &context, false).map_err(Report::from)
1313 })
1314 }
1315}
1316impl From<&str> for Provider {
1317 fn from(value: &str) -> Self {
1318 match value.to_lowercase().as_str() {
1319 | "alibaba" => Self::Alibaba,
1320 | "amazon" => Self::Amazon,
1321 | "anthropic" => Self::Anthropic,
1322 | "azure" => Self::Azure,
1323 | "baichuan" => Self::Baichuan,
1324 | "baidu" => Self::Baidu,
1325 | "cohere" => Self::Cohere,
1326 | "databricks" => Self::Databricks,
1327 | "deepseek" => Self::DeepSeek,
1328 | "doubao" => Self::Doubao,
1329 | "google" => Self::Google,
1330 | "groq" => Self::Groq,
1331 | "ibm" => Self::IBM,
1332 | "kimi" => Self::Kimi,
1333 | "meta" => Self::Meta,
1334 | "minimax" => Self::Minimax,
1335 | "mistral" => Self::Mistral,
1336 | "moonshotai" => Self::MoonshotAI,
1337 | "nvidia" => Self::Nvidia,
1338 | "ollama" => Self::Ollama,
1339 | "openai" => Self::OpenAI,
1340 | "perplexity" => Self::Perplexity,
1341 | "qwen" => Self::Qwen,
1342 | "salesforce" => Self::Salesforce,
1343 | "sap" => Self::SAP,
1344 | "sarvam" => Self::Sarvam,
1345 | "stepfun" => Self::Stepfun,
1346 | "tencent" => Self::Tencent,
1347 | "togetherai" => Self::TogetherAI,
1348 | "xai" => Self::XAI,
1349 | "xiaomi" => Self::Xiaomi,
1350 | "zhipuai" => Self::ZhipuAI,
1351 | _ => Self::Custom(value.into()),
1352 }
1353 }
1354}
1355impl From<&str> for Quantization {
1356 fn from(value: &str) -> Self {
1357 let normalized = value.to_ascii_uppercase();
1358 match normalized.as_str() {
1359 | "Q2_K" | "Q2K" => Self::Q2k,
1360 | "Q3_K_S" | "Q3KS" => Self::Q3kS,
1361 | "Q3_K_M" | "Q3KM" => Self::Q3kM,
1362 | "Q3_K_L" | "Q3KL" => Self::Q3kL,
1363 | "Q4_K_M" | "Q4KM" => Self::Q4kM,
1364 | "Q5_K_M" | "Q5KM" => Self::Q5kM,
1365 | "Q6_K" | "Q6K" => Self::Q6k,
1366 | "Q8_0" | "Q80" => Self::Q8_0,
1367 | "F16" => Self::F16,
1368 | "BF16" => Self::BF16,
1369 | "F8" | "FP8" => Self::F8,
1370 | "IQ4_XS" | "IQ4XS" => Self::IQ4_XS,
1371 | _ => Self::Other(value.to_string()),
1372 }
1373 }
1374}
1375impl From<String> for Quantization {
1376 fn from(value: String) -> Self {
1377 Self::from(value.as_str())
1378 }
1379}
1380impl FromStr for Quantization {
1381 type Err = Infallible;
1382 fn from_str(value: &str) -> Result<Self, Self::Err> {
1383 Ok(Self::from(value))
1384 }
1385}
1386impl<'de> Deserialize<'de> for Quantization {
1387 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1388 where
1389 D: serde::Deserializer<'de>,
1390 {
1391 String::deserialize(deserializer).map(Self::from)
1392 }
1393}
1394impl Quantization {
1395 pub fn from_gguf_filename(filename: &str) -> Option<Self> {
1397 let filename = filename.to_ascii_uppercase();
1398 filename.strip_suffix(".GGUF").and_then(|stem| {
1399 stem.split(['-', '.'])
1400 .find(|part| {
1401 matches!(*part, "F16" | "BF16")
1402 || part
1403 .strip_prefix('Q')
1404 .is_some_and(|value| value.chars().next().is_some_and(|character| character.is_ascii_digit()) && value.contains('_'))
1405 || part
1406 .strip_prefix("IQ")
1407 .is_some_and(|value| value.chars().next().is_some_and(|character| character.is_ascii_digit()) && value.contains('_'))
1408 })
1409 .or_else(|| {
1410 stem.split(['-', '.'])
1411 .rev()
1412 .find(|part| part.contains("FP") && part.chars().any(|character| character.is_ascii_digit()))
1413 })
1414 .map(Self::from)
1415 })
1416 }
1417}
1418impl MarkdownSupport for ProviderDetails {
1419 fn to_markdown(&self) -> String {
1420 let lines = [
1421 self.endpoint.as_ref().map(|value| format!("- API Endpoint: {value}")),
1422 self.authentication
1423 .as_ref()
1424 .map(|value| format!("- Auth Methods: {}", value.iter().map(|m| m.to_string()).collect::<Vec<_>>().join(", "))),
1425 self.description.as_ref().map(|value| format!("- Description: {value}")),
1426 self.documentation.as_ref().map(|value| format!("- Documentation: {value}")),
1427 self.env.as_ref().map(|value| format!("- Env Vars: {}", value.join(", "))),
1428 self.established_date.as_ref().map(|value| format!("- Established: {value}")),
1429 self.id.as_ref().map(|value| format!("- ID: {value}")),
1430 self.last_updated.as_ref().map(|value| format!("- Last Updated: {value}")),
1431 self.name.as_ref().map(|value| format!("- Name: {value}")),
1432 self.npm.as_ref().map(|value| format!("- NPM: {value}")),
1433 self.url.as_ref().map(|value| format!("- URL: {value}")),
1434 ]
1435 .into_iter()
1436 .flatten()
1437 .collect::<Vec<_>>();
1438 if lines.is_empty() {
1439 String::new()
1440 } else {
1441 format!("\n{}", lines.join("\n"))
1442 }
1443 }
1444}
1445fn deserialize_models<'de, D>(deserializer: D) -> Result<Option<Vec<ModelDetails>>, D::Error>
1446where
1447 D: serde::Deserializer<'de>,
1448{
1449 #[derive(Deserialize)]
1450 #[serde(untagged)]
1451 enum Models {
1452 Map(HashMap<String, ModelDetails>),
1453 Vec(Vec<ModelDetails>),
1454 }
1455 match Option::<Models>::deserialize(deserializer)? {
1456 | Some(Models::Map(map)) => Ok(Some(map.into_values().collect())),
1457 | Some(Models::Vec(vec)) => Ok(Some(vec)),
1458 | None => Ok(None),
1459 }
1460}
1461fn deserialize_metric<'de, D>(deserializer: D) -> Result<Option<Metric>, D::Error>
1462where
1463 D: serde::Deserializer<'de>,
1464{
1465 deserialize_optional_typed_value(deserializer)
1466}
1467fn deserialize_harness<'de, D>(deserializer: D) -> Result<Option<Harness>, D::Error>
1468where
1469 D: serde::Deserializer<'de>,
1470{
1471 deserialize_optional_typed_value(deserializer)
1472}
1473fn deserialize_optional_typed_value<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1474where
1475 D: serde::Deserializer<'de>,
1476 T: for<'a> From<&'a str> + From<String>,
1477{
1478 Option::<serde_json::Value>::deserialize(deserializer)
1479 .map(|value| value.filter(|value| !value.is_null()))
1480 .map(|value| value.map(value_to_string_or_other::<T>))
1481}
1482fn value_to_string_or_other<T>(value: serde_json::Value) -> T
1483where
1484 T: for<'a> From<&'a str> + From<String>,
1485{
1486 match value {
1487 | serde_json::Value::String(value) => T::from(value.as_str()),
1488 | other => serde_json::to_string(&other).map_or_else(|_| T::from(other.to_string()), T::from),
1489 }
1490}
1491fn validate_open_weights(details: &ModelDetails) -> Result<(), ValidationError> {
1492 let ModelDetails { open_weights, weights, .. } = details;
1493 let has_open_weight = weights.iter().flat_map(|weights| &weights.0).any(|weight| weight.is_open == Some(true));
1494 if has_open_weight && !open_weights.unwrap_or(false) {
1495 Err(ValidationError::new("open_weights").with_message("open_weights must be true when any weight has is_open: true".into()))
1496 } else {
1497 Ok(())
1498 }
1499}
1500impl ModelDetails {
1501 pub fn report(&self) -> (String, Option<String>) {
1506 let id = self.id.as_deref().unwrap_or("unknown").to_string();
1507 let context = self.fallback.as_ref().map(|fb| format!("{} {fb}", "fallback from".italic()));
1508 (id, context)
1509 }
1510}
1511
1512#[cfg(test)]
1513mod tests;