Skip to main content

acorn/schema/agent/
mod.rs

1//! Structured model for working with prompt files (body text and front-matter metadata)
2//!
3//! Aims to support Google [dotprompt](https://github.com/google/dotprompt) and [agent skills](https://agentskills.io/specification) specificaitons
4use 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/// Benchmark evaluation harness or framework
42#[derive(Clone, Debug, Display, Deserialize, Serialize, JsonSchema)]
43pub enum Harness {
44    /// Anthropic's Claude Code agent
45    #[display("Claude Code")]
46    #[serde(rename = "Claude Code")]
47    ClaudeCode,
48    /// OpenAI Codex
49    #[display("Codex")]
50    #[serde(rename = "Codex")]
51    Codex,
52    /// OpenAI Codex CLI
53    #[display("Codex CLI")]
54    #[serde(rename = "Codex CLI")]
55    CodexCli,
56    /// Cursor CLI agent
57    #[display("Cursor CLI")]
58    #[serde(rename = "Cursor CLI")]
59    CursorCli,
60    /// Google Gemini CLI agent
61    #[display("Gemini CLI")]
62    #[serde(rename = "Gemini CLI")]
63    GeminiCli,
64    /// Mini-SWE-Agent framework
65    #[display("Mini-SWE-Agent")]
66    #[serde(rename = "Mini-SWE-Agent")]
67    MiniSweAgent,
68    /// OpenCode open source harness
69    #[display("OpenCode")]
70    #[serde(rename = "OpenCode")]
71    OpenCode,
72    /// Terminus-2 evaluation harness
73    #[display("Terminus-2")]
74    #[serde(rename = "Terminus-2")]
75    Terminus2,
76    /// Catch-all for unknown harness names added by upstream catalogs
77    #[display("{}", _0)]
78    #[serde(untagged)]
79    Other(String),
80}
81/// Metric type for benchmark evaluation results
82#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
83pub enum Metric {
84    /// Average pass@1 across multiple coding tasks
85    #[serde(rename = "average pass@1")]
86    AveragePassAt1,
87    /// Index score
88    #[serde(rename = "index")]
89    Index,
90    /// Pass@1 score
91    #[serde(rename = "pass@1")]
92    PassAt1,
93    /// Percentage of correct answers
94    #[serde(rename = "percent correct")]
95    PercentCorrect,
96    /// Percentage of tasks resolved
97    #[serde(rename = "percent resolved")]
98    PercentResolved,
99    /// Rate at which issues are resolved
100    #[serde(rename = "resolve rate")]
101    ResolveRate,
102    /// Percentage of tasks resolved
103    #[serde(rename = "resolved")]
104    Resolved,
105    /// Numeric score
106    #[serde(rename = "score")]
107    Score,
108    /// Rate of successful task completions
109    #[serde(rename = "success rate")]
110    SuccessRate,
111    /// Catch-all for unknown metric types added by upstream catalogs
112    #[serde(untagged)]
113    Other(String),
114}
115/// Opaque data artifact that is consumed by a given technology
116#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
117pub enum Model {
118    /// Small language model useful for embedding, classification, etc.
119    SLM(ModelDetails),
120    /// Large language model useful for natural language processing, generative AI, etc.
121    LLM(ModelDetails),
122}
123/// Reason model metadata cannot resolve to a Hugging Face repository.
124#[derive(Clone, Copy, Debug, Display, Eq, PartialEq)]
125pub enum ModelResolutionReason {
126    /// The model explicitly declares that its weights are not open.
127    #[display("model is not open")]
128    NotOpen,
129    /// The model declares open weights but provides no weight sources.
130    #[display("no open weight sources are declared")]
131    NoOpenWeights,
132    /// The declared weight sources do not identify a Hugging Face repository.
133    #[display("declared weights do not identify a Hugging Face repository")]
134    NoHuggingFaceRepository,
135    /// The model has no usable identifier or name.
136    #[display("model has no identifier or name")]
137    MissingIdentifier,
138}
139/// Prompt file assets embedded in this crate
140#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
141pub enum PromptFileAsset {
142    /// "Explain like I'm five" prompt template for generating simple explanations of complex topics
143    Eli5,
144    /// Prompt template for extracting claims from text
145    ExtractClaim,
146    /// Prompt template for identifying gaps in knowledge or arguments
147    FindGaps,
148    /// Prompt template for generating concise summaries of text
149    Summarize,
150    /// Prompt template for teaching concepts
151    Teach,
152    /// Prompt template for translating text
153    Translate,
154    /// Fallback for unknown file names.
155    Unknown(String),
156}
157/// Technology that provides access to AI models across cloud and on-prem environments
158#[derive(Clone, Debug, Display, Deserialize, Serialize, JsonSchema)]
159#[serde(rename_all = "lowercase")]
160pub enum Provider {
161    /// Alibaba Cloud
162    #[display("Alibaba Cloud")]
163    Alibaba,
164    /// Amazon Web Services
165    #[display("Amazon Web Services")]
166    Amazon,
167    /// Anthropic
168    #[display("Anthropic")]
169    Anthropic,
170    /// Azure
171    #[display("Azure")]
172    Azure,
173    /// Baichuan
174    #[display("Baichuan")]
175    Baichuan,
176    /// Baidu
177    #[display("Baidu")]
178    Baidu,
179    /// Cohere
180    #[display("Cohere")]
181    Cohere,
182    /// Databricks
183    #[display("Databricks")]
184    Databricks,
185    /// DeepSeek
186    #[display("DeepSeek")]
187    DeepSeek,
188    /// Doubao
189    #[display("Doubao")]
190    Doubao,
191    /// Google
192    #[display("Google")]
193    Google,
194    /// Groq
195    #[display("Groq")]
196    Groq,
197    /// IBM
198    #[display("IBM")]
199    IBM,
200    /// Kimi (Moonshot AI)
201    #[display("Kimi")]
202    Kimi,
203    /// Meta
204    #[display("Meta")]
205    Meta,
206    /// Minimax
207    #[display("Minimax")]
208    Minimax,
209    /// Mistral
210    #[display("Mistral")]
211    Mistral,
212    /// Moonshot AI
213    #[display("Moonshot AI")]
214    MoonshotAI,
215    /// NVIDIA
216    #[display("NVIDIA")]
217    #[serde(alias = "NVIDIA")]
218    Nvidia,
219    /// Ollama
220    #[display("Ollama")]
221    Ollama,
222    /// OpenAI
223    #[display("OpenAI")]
224    OpenAI,
225    /// Perplexity
226    #[display("Perplexity")]
227    Perplexity,
228    /// Qwen (Alibaba)
229    #[display("Qwen")]
230    Qwen,
231    /// Salesforce
232    #[display("Salesforce")]
233    Salesforce,
234    /// SAP
235    #[display("SAP")]
236    SAP,
237    /// Sarvam AI
238    #[display("Sarvam AI")]
239    Sarvam,
240    /// Stepfun
241    #[display("Stepfun")]
242    Stepfun,
243    /// Tencent
244    #[display("Tencent")]
245    Tencent,
246    /// Together AI
247    #[display("Together AI")]
248    TogetherAI,
249    /// xAI
250    #[display("xAI")]
251    XAI,
252    /// Xiaomi
253    #[display("Xiaomi")]
254    Xiaomi,
255    /// Zhipu AI
256    #[display("Zhipu AI")]
257    ZhipuAI,
258    /// Unknown provider
259    #[display("{}", _0)]
260    Custom(String),
261}
262/// Quantization level for a model weight file.
263///
264/// Suffixes follow common GGUF naming:
265/// - `K` marks the newer k-quants family
266/// - `L` means large, higher-quality size within that family
267/// - `M` means medium quality within that family
268/// - `S` means small, lower-quality size within that family
269///
270/// `Q4_K_M` is a GGUF quantization format for LLMs used in `llama.cpp`.
271/// It is often considered the sweet spot between model size and quality:
272/// - `Q4` means 4-bit precision per weight
273/// - `K` means k-quant, a group-wise quantization scheme
274/// - `M` means medium, where sensitive tensors are selectively raised to 5-6 bits
275///
276/// IQ is a separate I-quant family that uses an importance matrix for reconstruction.
277#[allow(non_camel_case_types)]
278#[derive(Clone, Debug, Default, Display, EnumIter, PartialEq, Serialize, JsonSchema)]
279pub enum Quantization {
280    /// Good default balance of quality and size (`Q4_K_M`), often the sweet spot for GGUF LLMs
281    #[default]
282    #[display("Q4_K_M")]
283    #[serde(rename = "Q4_K_M")]
284    Q4kM,
285    /// Lower quality, smallest file size (`Q2_K`)
286    #[display("Q2_K")]
287    #[serde(rename = "Q2_K")]
288    Q2k,
289    /// Smaller, lower quality (`Q3_K_S`)
290    #[display("Q3_K_S")]
291    #[serde(rename = "Q3_K_S")]
292    Q3kS,
293    /// Smaller, lower quality (`Q3_K_M`)
294    #[display("Q3_K_M")]
295    #[serde(rename = "Q3_K_M")]
296    Q3kM,
297    /// Smaller, lower quality, larger than `Q3_K_M` (`Q3_K_L`)
298    #[display("Q3_K_L")]
299    #[serde(rename = "Q3_K_L")]
300    Q3kL,
301    /// Better quality, larger file (`Q5_K_M`)
302    #[display("Q5_K_M")]
303    #[serde(rename = "Q5_K_M")]
304    Q5kM,
305    /// Higher quality, much larger file (`Q6_K`)
306    #[display("Q6_K")]
307    #[serde(rename = "Q6_K")]
308    Q6k,
309    /// Near full quality, very large file (`Q8_0`)
310    #[display("Q8_0")]
311    #[serde(rename = "Q8_0")]
312    Q8_0,
313    /// 8-bit floating-point weights
314    #[display("F8")]
315    #[serde(rename = "F8")]
316    F8,
317    /// Full-ish precision, huge file (`F16`)
318    #[display("F16")]
319    #[serde(rename = "F16")]
320    F16,
321    /// Full-ish precision, huge file (`BF16`)
322    #[display("BF16")]
323    #[serde(rename = "BF16")]
324    BF16,
325    /// Importance-aware 4-bit quantization with extra-small size (`IQ4_XS`)
326    #[display("IQ4_XS")]
327    #[serde(rename = "IQ4_XS")]
328    IQ4_XS,
329    /// Catch-all for quantization tags added by upstream catalogs
330    #[display("{}", _0)]
331    #[serde(untagged)]
332    Other(String),
333}
334/// Benchmark evaluation result
335#[skip_serializing_none]
336#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Validate)]
337pub struct Benchmark {
338    /// Name of the benchmark
339    pub name: String,
340    /// Numeric score achieved
341    #[validate(range(min = 0.0))]
342    pub score: f64,
343    /// Metric type for the score
344    #[serde(default, deserialize_with = "deserialize_metric")]
345    pub metric: Option<Metric>,
346    /// Source URL for the benchmark result
347    #[validate(url)]
348    pub source: String,
349    /// Date of the benchmark result
350    #[validate(custom(function = "is_partial_date"))]
351    pub date: Option<String>,
352    /// Dataset used for evaluation
353    pub dataset: Option<String>,
354    /// Harness or framework used for evaluation
355    #[serde(default, deserialize_with = "deserialize_harness")]
356    pub harness: Option<Harness>,
357    /// Variant of the harness configuration
358    pub variant: Option<String>,
359    /// Version of the benchmark or harness
360    pub version: Option<String>,
361}
362/// Pricing details for a model
363#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
364pub struct CostDetails {
365    /// Cost per million input tokens
366    pub input: Option<f64>,
367    /// Cost per million output tokens
368    pub output: Option<f64>,
369    /// Cost per million cached input tokens
370    pub cache_read: Option<f64>,
371    /// Cost per million cached write tokens
372    pub cache_write: Option<f64>,
373    /// Extended reasoning/computation cost per million tokens
374    pub reasoning: Option<f64>,
375    /// Cost per million input audio tokens
376    #[serde(rename = "input_audio")]
377    pub input_audio: Option<f64>,
378    /// Cost per million output audio tokens
379    #[serde(rename = "output_audio")]
380    pub output_audio: Option<f64>,
381    /// Pricing for context windows exceeding 200K tokens
382    pub context_over_200k: Option<Box<CostDetails>>,
383    /// Pricing tiers for different context sizes
384    pub tiers: Option<Vec<CostTier>>,
385}
386/// Pricing tier for bulk or context-based pricing
387#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
388pub struct CostTier {
389    /// Cost per million input tokens at this tier
390    pub input: f64,
391    /// Cost per million output tokens at this tier
392    pub output: f64,
393    /// Cost per million cached input tokens at this tier
394    pub cache_read: Option<f64>,
395    /// Tier boundary information
396    pub tier: TierInfo,
397}
398/// YAML compliant prompt file front matter
399/// ### Notes
400/// - Opencode only supports `name`, `description`, `license`, `compatibility`, and `metadata`
401#[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    /// Name of the prompt
407    #[builder(default = String::new())]
408    pub name: String,
409    /// Short description of the prompt
410    #[builder(default = String::new())]
411    pub description: String,
412    /// Prompt configuration
413    pub config: Option<PromptTemplateConfiguration>,
414    /// SPDX license identifier for the prompt (could also specify path to LICENSE file)
415    #[validate(nested)]
416    pub license: Option<License>,
417    /// Intended product, system, packages, network access, etc.
418    pub compatibility: Option<String>,
419    /// Model used to generate prompt
420    ///
421    /// Ideally, will be created from a `LargeLanguageModel` struct
422    pub model: Option<String>,
423    /// Additional metadata
424    /// ### Note
425    /// Value is serialized as a key-value map
426    pub metadata: Option<Vec<(String, String)>>,
427    /// List of tools that are pre-approved to run
428    /// ### Note
429    /// Value is serialized as a space-delimited string
430    pub allowed_tools: Option<Vec<String>>,
431}
432/// Token limits for context and output
433#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
434pub struct LimitDetails {
435    /// Maximum context window size in tokens
436    pub context: u64,
437    /// Maximum output token count
438    pub output: Option<u64>,
439    /// Maximum input token count (if different from context)
440    pub input: Option<u64>,
441}
442/// Input/output modalities supported by the model
443#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
444pub struct Modalities {
445    /// Input modalities the model accepts
446    pub input: Vec<Modality>,
447    /// Output modalities the model produces
448    pub output: Vec<Modality>,
449}
450/// Describe a language model (LM)
451///
452/// This struct strives to accommodate the wide myriad varieties of language models in the wild.
453/// As such, version is a string instead of a `SemanticVersion` because versions are attached to models inconsistently.
454#[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    /// Whether the model supports file attachment
460    pub attachment: Option<bool>,
461    /// Benchmark evaluation results
462    #[serde(default)]
463    pub benchmarks: Option<OneOrMany<Benchmark>>,
464    /// Model family that generally describes model architecture (e.g., llama, gemma, qwen, etc.)
465    pub family: Option<String>,
466    /// Unique identifier for the model
467    pub id: Option<String>,
468    /// Knowledge cutoff date for the model
469    #[validate(custom(function = "is_partial_date"))]
470    pub knowledge: Option<String>,
471    /// Date the model was last updated
472    #[validate(custom(function = "is_partial_date"))]
473    pub last_updated: Option<String>,
474    /// Token limits for the model
475    pub limit: Option<LimitDetails>,
476    /// Input/output modalities
477    pub modalities: Option<Modalities>,
478    /// Pricing information for the model
479    pub cost: Option<CostDetails>,
480    /// String value to override full model string descriptor in cases of ambiguity and inconsistency
481    pub name: Option<String>,
482    /// Indicates whether the model weights are openly available
483    pub open_weights: Option<bool>,
484    /// Number of parameters (in billions) (e.g., 14 for "14B")
485    pub parameters: Option<i64>,
486    /// Resolved local path to model weights
487    pub path: Option<String>,
488    /// Whether the model supports extended reasoning/thinking
489    pub reasoning: Option<bool>,
490    /// Release date of the model
491    #[validate(custom(function = "is_partial_date"))]
492    pub release_date: Option<String>,
493    /// Whether the model supports structured output
494    pub structured_output: Option<bool>,
495    /// Whether the model supports temperature configuration
496    pub temperature: Option<bool>,
497    /// Whether the model supports tool calling
498    pub tool_call: Option<bool>,
499    /// Original requested repository identifier when `id` was resolved through GGUF fallback
500    pub fallback: Option<String>,
501    /// Value that describes niche application of a given model family (e.g., "coder" in "qwen-coder")
502    pub variant: Option<String>,
503    /// Version of the model
504    pub version: Option<SemanticVersion>,
505    /// Download sources for model weights
506    pub weights: Option<Weights>,
507}
508/// A normalized model lookup value supplied by a user or model catalog.
509#[derive(Clone, Debug, Eq, Hash, PartialEq)]
510pub struct ModelSelector(String);
511/// A normalized collection of model lookup values.
512#[derive(Clone, Debug, Default, Eq, PartialEq)]
513pub struct ModelSelectors(Vec<ModelSelector>);
514/// Struct for using and sharing prompt templates
515///
516/// See <https://git.sr.ht/~pyrossh/rust-embed>
517#[derive(Embed)]
518#[folder = "assets/prompts/"]
519pub struct PromptTemplate;
520/// Prompt configuration
521#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
522#[serde(rename_all = "kebab-case")]
523#[builder(start_fn = init)]
524pub struct PromptTemplateConfiguration {
525    /// Include analogy or example in explanation prompts
526    pub include_analogy: Option<bool>,
527    /// Include examples in teaching prompts
528    pub include_examples: Option<bool>,
529    /// Include implicit claims in extraction
530    pub include_implicit: Option<bool>,
531    /// Include practice questions in teaching prompts
532    pub include_practice: Option<bool>,
533    /// Generic limit for item counts (e.g., claims, bullets)
534    pub max_items: Option<u32>,
535    /// Maximum number of tokens to allow in context
536    #[builder(default = 300)]
537    pub max_tokens: u32,
538    /// Word budget for summaries and explanations
539    pub max_words: Option<u32>,
540    /// Minimum confidence threshold for extracted claims
541    pub min_confidence: Option<f32>,
542    /// Specific strings that signal the model to halt generation
543    #[builder(default = Vec::new())]
544    pub stop_sequences: Vec<String>,
545    /// Source text to process
546    pub text: Option<String>,
547    /// Target language for translation tasks
548    pub language: Option<String>,
549    /// Hyperparameter that controls the randomness and creativity of the model output
550    #[builder(default = 0.1)]
551    pub temperature: f32,
552    /// Sampling parameter that limits token selection to the K most probable
553    #[builder(default = 10)]
554    pub top_k: u32,
555    /// Prompt version
556    #[builder(default)]
557    pub version: SemanticVersion,
558}
559/// Details about an model provider
560#[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    /// Supported authentication methods
565    pub authentication: Option<Vec<AuthenticationScheme>>,
566    /// Provider description
567    pub description: Option<String>,
568    /// Documentation URL
569    #[serde(rename = "doc")]
570    #[validate(url)]
571    pub documentation: Option<String>,
572    /// API endpoint base URL
573    #[serde(rename = "api")]
574    #[validate(url)]
575    pub endpoint: Option<String>,
576    /// Environment variables required for API authentication
577    pub env: Option<Vec<String>>,
578    /// Date the provider was established
579    #[validate(custom(function = "is_partial_date"))]
580    pub established_date: Option<String>,
581    /// Provider identifier
582    pub id: Option<String>,
583    /// Date the provider details were last updated
584    #[validate(custom(function = "is_partial_date"))]
585    pub last_updated: Option<String>,
586    /// Models offered by this provider
587    #[serde(default, deserialize_with = "deserialize_models")]
588    pub models: Option<Vec<ModelDetails>>,
589    /// Provider name
590    pub name: Option<String>,
591    /// npm package name for the provider's SDK
592    pub npm: Option<String>,
593    /// Provider website URL
594    #[validate(url)]
595    pub url: Option<String>,
596}
597/// Information about a pricing tier boundary
598#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
599#[serde(rename_all = "camelCase")]
600pub struct TierInfo {
601    /// Type of tier boundary (e.g., "context")
602    #[serde(rename = "type")]
603    pub kind: String,
604    /// Size threshold for the tier in tokens
605    pub size: u64,
606}
607/// Source for downloading model weights
608#[skip_serializing_none]
609#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
610pub struct Weight {
611    /// Display label for the weight source
612    pub label: String,
613    /// URL to download the weights
614    pub url: String,
615    /// Whether the weights are openly available
616    pub is_open: Option<bool>,
617    /// Quantization format used for the weights
618    pub quantization: Option<Quantization>,
619    /// Weight file size in bytes
620    pub size: Option<u64>,
621}
622/// A complete GGUF variant, including all split weight shards.
623#[derive(Clone, Debug)]
624pub struct WeightGroup {
625    /// Exact GGUF quantization.
626    pub quantization: Quantization,
627    /// Hugging Face repository containing the files.
628    pub repository: String,
629    /// Repository revision containing the files.
630    pub revision: String,
631    /// Required GGUF file paths.
632    pub paths: Vec<String>,
633    /// Aggregate byte size, or `None` when any shard size is unknown.
634    pub size: Option<u64>,
635}
636/// Grouped GGUF variants parsed from persisted weight metadata.
637#[derive(Clone, Debug, Default)]
638pub struct WeightGroups(pub Vec<WeightGroup>);
639/// Collection of model weight sources
640#[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    /// Parse persisted Hugging Face file metadata into grouping fields.
656    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    /// Select the first allowed GGUF variant that satisfies the memory constraint.
671    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    /// Group Hugging Face GGUF files by repository, revision, and quantization.
716    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    /// Determine whether the collection contains persisted GGUF file metadata.
755    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    /// Add an inferred weight source when the model identifier declares a quantization.
761    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    /// Refresh file-level weights for a model while preserving repository-level sources.
786    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    /// Serialize model weight metadata for database persistence.
824    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    /// Return a Hugging Face repository selector or explain why one cannot be resolved.
1102    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    /// Set the original model identifier for a resolved fallback
1123    pub fn with_fallback(self, value: &str) -> Self {
1124        Self {
1125            fallback: Some(value.to_string()),
1126            ..self
1127        }
1128    }
1129    /// Set the identifier
1130    pub fn with_id(self, value: &str) -> Self {
1131        Self {
1132            id: Some(value.to_string()),
1133            ..self
1134        }
1135    }
1136}
1137impl ModelSelector {
1138    /// Create a selector from a non-empty string after trimming whitespace.
1139    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    /// Return the normalized selector value.
1145    pub fn as_str(&self) -> &str {
1146        &self.0
1147    }
1148    /// Return the model name used for GGUF fallback repository discovery.
1149    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    /// Return whether the collection contains no selectors.
1218    pub fn is_empty(&self) -> bool {
1219        self.0.is_empty()
1220    }
1221    /// Iterate over normalized selectors.
1222    pub fn iter(&self) -> impl Iterator<Item = &ModelSelector> {
1223        self.0.iter()
1224    }
1225    /// Parse selectors from plain text, JSON, or YAML model-list content.
1226    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    /// Merge selectors read from an optional local or remote model-list source.
1236    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    /// Reads a file from the asset folder and returns its contents as a UTF-8 string.
1286    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    /// Render a prompt template with the given configuration
1293    /// ### Example
1294    /// ```ignore
1295    /// let config = Configuration::init()
1296    ///     .text("Some prompt to process")
1297    ///     .max_words(160)
1298    ///     .build();
1299    /// let rendered = PromptTemplate::render(PromptFileAsset::Summarize, &config);
1300    /// ```
1301    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    /// Detect a quantization tag in a GGUF filename.
1396    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    /// Return display parts for human-readable summary output
1502    ///
1503    /// Returns `(primary, optional_context)` where primary is the model identifier
1504    /// and context is a fallback annotation when applicable.
1505    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;