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::io::api::AuthenticationScheme;
5use crate::io::ApiResult;
6#[cfg(not(feature = "std"))]
7use crate::io::License;
8#[cfg(feature = "std")]
9use crate::io::License;
10use crate::prelude::*;
11use crate::prelude::{Error, ErrorKind};
12use crate::schema::research_activity::aspect::data::Modality;
13use crate::schema::validate::is_partial_date;
14use crate::util::{SemanticVersion, ToMarkdown};
15use bon::Builder;
16use color_eyre::eyre::Report;
17use core::{fmt, str::from_utf8};
18use derive_more::Display;
19use rust_embed::Embed;
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use serde_with::skip_serializing_none;
23use std::collections::HashMap;
24use tera::{Context, Tera};
25use validator::{Validate, ValidationError};
26
27pub mod opencode;
28
29/// Benchmark evaluation harness or framework
30#[derive(Clone, Debug, Display, Deserialize, Serialize, JsonSchema)]
31pub enum Harness {
32    /// Anthropic's Claude Code agent
33    #[display("Claude Code")]
34    #[serde(rename = "Claude Code")]
35    ClaudeCode,
36    /// OpenAI Codex
37    #[display("Codex")]
38    #[serde(rename = "Codex")]
39    Codex,
40    /// OpenAI Codex CLI
41    #[display("Codex CLI")]
42    #[serde(rename = "Codex CLI")]
43    CodexCli,
44    /// Cursor CLI agent
45    #[display("Cursor CLI")]
46    #[serde(rename = "Cursor CLI")]
47    CursorCli,
48    /// Google Gemini CLI agent
49    #[display("Gemini CLI")]
50    #[serde(rename = "Gemini CLI")]
51    GeminiCli,
52    /// Mini-SWE-Agent framework
53    #[display("Mini-SWE-Agent")]
54    #[serde(rename = "Mini-SWE-Agent")]
55    MiniSweAgent,
56    /// OpenCode open source harness
57    #[display("OpenCode")]
58    #[serde(rename = "OpenCode")]
59    OpenCode,
60    /// Terminus-2 evaluation harness
61    #[display("Terminus-2")]
62    #[serde(rename = "Terminus-2")]
63    Terminus2,
64}
65/// Metric type for benchmark evaluation results
66#[derive(Clone, Debug, Display, Deserialize, Serialize, JsonSchema)]
67pub enum Metric {
68    /// Average pass@1 across multiple coding tasks
69    #[display("average pass@1")]
70    #[serde(rename = "average pass@1")]
71    AveragePassAt1,
72    /// Index score
73    #[display("index")]
74    #[serde(rename = "index")]
75    Index,
76    /// Pass@1 score
77    #[display("pass@1")]
78    #[serde(rename = "pass@1")]
79    PassAt1,
80    /// Percentage of correct answers
81    #[display("percent correct")]
82    #[serde(rename = "percent correct")]
83    PercentCorrect,
84    /// Rate at which issues are resolved
85    #[display("resolve rate")]
86    #[serde(rename = "resolve rate")]
87    ResolveRate,
88    /// Percentage of tasks resolved
89    #[display("resolved")]
90    #[serde(rename = "resolved")]
91    Resolved,
92    /// Numeric score
93    #[display("score")]
94    #[serde(rename = "score")]
95    Score,
96    /// Rate of successful task completions
97    #[display("success rate")]
98    #[serde(rename = "success rate")]
99    SuccessRate,
100}
101/// Opaque data artifact that is consumed by a given technology
102#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
103pub enum Model {
104    /// Small language model useful for embedding, classification, etc.
105    SLM(ModelDetails),
106    /// Large language model useful for natural language processing, generative AI, etc.
107    LLM(ModelDetails),
108}
109/// Prompt file assets embedded in this crate
110#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
111pub enum PromptFileAsset {
112    /// "Explain like I'm five" prompt template for generating simple explanations of complex topics
113    Eli5,
114    /// Prompt template for extracting claims from text
115    ExtractClaim,
116    /// Prompt template for identifying gaps in knowledge or arguments
117    FindGaps,
118    /// Prompt template for generating concise summaries of text
119    Summarize,
120    /// Prompt template for teaching concepts
121    Teach,
122    /// Prompt template for translating text
123    Translate,
124    /// Fallback for unknown file names.
125    Unknown(String),
126}
127/// Technology that provides access to AI models across cloud and on-prem environments
128#[derive(Clone, Debug, Display, Deserialize, Serialize, JsonSchema)]
129#[serde(rename_all = "lowercase")]
130pub enum Provider {
131    /// Alibaba Cloud
132    #[display("Alibaba Cloud")]
133    Alibaba,
134    /// Amazon Web Services
135    #[display("Amazon Web Services")]
136    Amazon,
137    /// Anthropic
138    #[display("Anthropic")]
139    Anthropic,
140    /// Azure
141    #[display("Azure")]
142    Azure,
143    /// Baichuan
144    #[display("Baichuan")]
145    Baichuan,
146    /// Baidu
147    #[display("Baidu")]
148    Baidu,
149    /// Cohere
150    #[display("Cohere")]
151    Cohere,
152    /// Databricks
153    #[display("Databricks")]
154    Databricks,
155    /// DeepSeek
156    #[display("DeepSeek")]
157    DeepSeek,
158    /// Doubao
159    #[display("Doubao")]
160    Doubao,
161    /// Google
162    #[display("Google")]
163    Google,
164    /// Groq
165    #[display("Groq")]
166    Groq,
167    /// IBM
168    #[display("IBM")]
169    IBM,
170    /// Kimi (Moonshot AI)
171    #[display("Kimi")]
172    Kimi,
173    /// Meta
174    #[display("Meta")]
175    Meta,
176    /// Minimax
177    #[display("Minimax")]
178    Minimax,
179    /// Mistral
180    #[display("Mistral")]
181    Mistral,
182    /// Moonshot AI
183    #[display("Moonshot AI")]
184    MoonshotAI,
185    /// NVIDIA
186    #[display("NVIDIA")]
187    #[serde(alias = "NVIDIA")]
188    Nvidia,
189    /// Ollama
190    #[display("Ollama")]
191    Ollama,
192    /// OpenAI
193    #[display("OpenAI")]
194    OpenAI,
195    /// Perplexity
196    #[display("Perplexity")]
197    Perplexity,
198    /// Qwen (Alibaba)
199    #[display("Qwen")]
200    Qwen,
201    /// Salesforce
202    #[display("Salesforce")]
203    Salesforce,
204    /// SAP
205    #[display("SAP")]
206    SAP,
207    /// Sarvam AI
208    #[display("Sarvam AI")]
209    Sarvam,
210    /// Stepfun
211    #[display("Stepfun")]
212    Stepfun,
213    /// Tencent
214    #[display("Tencent")]
215    Tencent,
216    /// Together AI
217    #[display("Together AI")]
218    TogetherAI,
219    /// xAI
220    #[display("xAI")]
221    XAI,
222    /// Xiaomi
223    #[display("Xiaomi")]
224    Xiaomi,
225    /// Zhipu AI
226    #[display("Zhipu AI")]
227    ZhipuAI,
228    /// Unknown provider
229    #[display("{}", _0)]
230    Custom(String),
231}
232/// Benchmark evaluation result
233#[skip_serializing_none]
234#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Validate)]
235pub struct Benchmark {
236    /// Name of the benchmark
237    pub name: String,
238    /// Numeric score achieved
239    #[validate(range(min = 0.0))]
240    pub score: f64,
241    /// Metric type for the score
242    pub metric: Metric,
243    /// Source URL for the benchmark result
244    #[validate(url)]
245    pub source: String,
246    /// Date of the benchmark result
247    #[validate(custom(function = "is_partial_date"))]
248    pub date: Option<String>,
249    /// Dataset used for evaluation
250    pub dataset: Option<String>,
251    /// Harness or framework used for evaluation
252    pub harness: Option<Harness>,
253    /// Variant of the harness configuration
254    pub variant: Option<String>,
255    /// Version of the benchmark or harness
256    pub version: Option<String>,
257}
258/// Pricing details for a model
259#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
260pub struct CostDetails {
261    /// Cost per million input tokens
262    pub input: Option<f64>,
263    /// Cost per million output tokens
264    pub output: Option<f64>,
265    /// Cost per million cached input tokens
266    pub cache_read: Option<f64>,
267    /// Cost per million cached write tokens
268    pub cache_write: Option<f64>,
269    /// Extended reasoning/computation cost per million tokens
270    pub reasoning: Option<f64>,
271    /// Cost per million input audio tokens
272    #[serde(rename = "input_audio")]
273    pub input_audio: Option<f64>,
274    /// Cost per million output audio tokens
275    #[serde(rename = "output_audio")]
276    pub output_audio: Option<f64>,
277    /// Pricing for context windows exceeding 200K tokens
278    pub context_over_200k: Option<Box<CostDetails>>,
279    /// Pricing tiers for different context sizes
280    pub tiers: Option<Vec<CostTier>>,
281}
282/// Pricing tier for bulk or context-based pricing
283#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
284pub struct CostTier {
285    /// Cost per million input tokens at this tier
286    pub input: f64,
287    /// Cost per million output tokens at this tier
288    pub output: f64,
289    /// Cost per million cached input tokens at this tier
290    pub cache_read: Option<f64>,
291    /// Tier boundary information
292    pub tier: TierInfo,
293}
294/// YAML compliant prompt file front matter
295/// ### Notes
296/// - Opencode only supports `name`, `description`, `license`, `compatibility`, and `metadata`
297#[skip_serializing_none]
298#[derive(Builder, Clone, Debug, Serialize, Deserialize, Validate)]
299#[serde(rename_all = "kebab-case")]
300#[builder(start_fn = init)]
301pub struct FrontMatter {
302    /// Name of the prompt
303    #[builder(default = String::new())]
304    pub name: String,
305    /// Short description of the prompt
306    #[builder(default = String::new())]
307    pub description: String,
308    /// Prompt configuration
309    pub config: Option<PromptTemplateConfiguration>,
310    /// SPDX license identifier for the prompt (could also specify path to LICENSE file)
311    #[validate(nested)]
312    pub license: Option<License>,
313    /// Intended product, system, packages, network access, etc.
314    pub compatibility: Option<String>,
315    /// Model used to generate prompt
316    ///
317    /// Ideally, will be created from a `LargeLanguageModel` struct
318    pub model: Option<String>,
319    /// Additional metadata
320    /// ### Note
321    /// Value is serialized as a key-value map
322    pub metadata: Option<Vec<(String, String)>>,
323    /// List of tools that are pre-approved to run
324    /// ### Note
325    /// Value is serialized as a space-delimited string
326    pub allowed_tools: Option<Vec<String>>,
327}
328/// Token limits for context and output
329#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
330pub struct LimitDetails {
331    /// Maximum context window size in tokens
332    pub context: u64,
333    /// Maximum output token count
334    pub output: u64,
335    /// Maximum input token count (if different from context)
336    pub input: Option<u64>,
337}
338/// Input/output modalities supported by the model
339#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
340pub struct Modalities {
341    /// Input modalities the model accepts
342    pub input: Vec<Modality>,
343    /// Output modalities the model produces
344    pub output: Vec<Modality>,
345}
346/// Describe a language model (LM)
347///
348/// This struct strives to accommodate the wide myriad varieties of language models in the wild.
349/// As such, version is a string instead of a `SemanticVersion` because versions are attached to models inconsistently.
350#[skip_serializing_none]
351#[derive(Builder, Clone, Debug, Default, Deserialize, Serialize, JsonSchema, Validate)]
352#[builder(start_fn = init, on(String, into))]
353#[validate(schema(function = "validate_open_weights", skip_on_field_errors = false))]
354pub struct ModelDetails {
355    /// Whether the model supports file attachment
356    pub attachment: Option<bool>,
357    /// Benchmark evaluation results
358    pub benchmarks: Option<Vec<Benchmark>>,
359    /// Model family that generally describes model architecture (e.g., llama, gemma, qwen, etc.)
360    pub family: Option<String>,
361    /// Unique identifier for the model
362    pub id: Option<String>,
363    /// Knowledge cutoff date for the model
364    #[validate(custom(function = "is_partial_date"))]
365    pub knowledge: Option<String>,
366    /// Date the model was last updated
367    #[validate(custom(function = "is_partial_date"))]
368    pub last_updated: Option<String>,
369    /// Token limits for the model
370    pub limit: Option<LimitDetails>,
371    /// Input/output modalities
372    pub modalities: Option<Modalities>,
373    /// Pricing information for the model
374    pub cost: Option<CostDetails>,
375    /// String value to override full model string descriptor in cases of ambiguity and inconsistency
376    pub name: Option<String>,
377    /// Indicates whether the model weights are openly available
378    pub open_weights: Option<bool>,
379    /// Number of parameters (in billions) (e.g., 14 for "14B")
380    pub parameters: Option<i64>,
381    /// Whether the model supports extended reasoning/thinking
382    pub reasoning: Option<bool>,
383    /// Release date of the model
384    #[validate(custom(function = "is_partial_date"))]
385    pub release_date: Option<String>,
386    /// Whether the model supports structured output
387    pub structured_output: Option<bool>,
388    /// Whether the model supports temperature configuration
389    pub temperature: Option<bool>,
390    /// Whether the model supports tool calling
391    pub tool_call: Option<bool>,
392    /// Value that describes niche application of a given model family (e.g., "coder" in "qwen-coder")
393    pub variant: Option<String>,
394    /// Version of the model
395    pub version: Option<SemanticVersion>,
396    /// Download sources for model weights
397    pub weights: Option<Vec<Weight>>,
398}
399/// Struct for using and sharing prompt templates
400///
401/// See <https://git.sr.ht/~pyrossh/rust-embed>
402#[derive(Embed)]
403#[folder = "assets/prompts/"]
404pub struct PromptTemplate;
405/// Prompt configuration
406#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
407#[serde(rename_all = "kebab-case")]
408#[builder(start_fn = init)]
409pub struct PromptTemplateConfiguration {
410    /// Include analogy or example in explanation prompts
411    pub include_analogy: Option<bool>,
412    /// Include examples in teaching prompts
413    pub include_examples: Option<bool>,
414    /// Include implicit claims in extraction
415    pub include_implicit: Option<bool>,
416    /// Include practice questions in teaching prompts
417    pub include_practice: Option<bool>,
418    /// Generic limit for item counts (e.g., claims, bullets)
419    pub max_items: Option<u32>,
420    /// Maximum number of tokens to allow in context
421    #[builder(default = 300)]
422    pub max_tokens: u32,
423    /// Word budget for summaries and explanations
424    pub max_words: Option<u32>,
425    /// Minimum confidence threshold for extracted claims
426    pub min_confidence: Option<f32>,
427    /// Specific strings that signal the model to halt generation
428    #[builder(default = Vec::new())]
429    pub stop_sequences: Vec<String>,
430    /// Source text to process
431    pub text: Option<String>,
432    /// Target language for translation tasks
433    pub language: Option<String>,
434    /// Hyperparameter that controls the randomness and creativity of the model output
435    #[builder(default = 0.1)]
436    pub temperature: f32,
437    /// Sampling parameter that limits token selection to the K most probable
438    #[builder(default = 10)]
439    pub top_k: u32,
440    /// Prompt version
441    #[builder(default)]
442    pub version: SemanticVersion,
443}
444/// Details about an model provider
445#[skip_serializing_none]
446#[derive(Builder, Clone, Debug, Default, Deserialize, Serialize, JsonSchema, Validate)]
447#[builder(start_fn = init, on(String, into))]
448pub struct ProviderDetails {
449    /// Supported authentication methods
450    pub authentication: Option<Vec<AuthenticationScheme>>,
451    /// Provider description
452    pub description: Option<String>,
453    /// Documentation URL
454    #[serde(rename = "doc")]
455    #[validate(url)]
456    pub documentation: Option<String>,
457    /// API endpoint base URL
458    #[serde(rename = "api")]
459    #[validate(url)]
460    pub endpoint: Option<String>,
461    /// Environment variables required for API authentication
462    pub env: Option<Vec<String>>,
463    /// Date the provider was established
464    #[validate(custom(function = "is_partial_date"))]
465    pub established_date: Option<String>,
466    /// Provider identifier
467    pub id: Option<String>,
468    /// Date the provider details were last updated
469    #[validate(custom(function = "is_partial_date"))]
470    pub last_updated: Option<String>,
471    /// Models offered by this provider
472    #[serde(default, deserialize_with = "deserialize_models")]
473    pub models: Option<Vec<ModelDetails>>,
474    /// Provider name
475    pub name: Option<String>,
476    /// npm package name for the provider's SDK
477    pub npm: Option<String>,
478    /// Provider website URL
479    #[validate(url)]
480    pub url: Option<String>,
481}
482/// Information about a pricing tier boundary
483#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
484#[serde(rename_all = "camelCase")]
485pub struct TierInfo {
486    /// Type of tier boundary (e.g., "context")
487    #[serde(rename = "type")]
488    pub kind: String,
489    /// Size threshold for the tier in tokens
490    pub size: u64,
491}
492/// Source for downloading model weights
493#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
494pub struct Weight {
495    /// Display label for the weight source
496    pub label: String,
497    /// URL to download the weights
498    pub url: String,
499    /// Whether the weights are openly available
500    pub is_open: Option<bool>,
501}
502impl Default for FrontMatter {
503    fn default() -> Self {
504        FrontMatter::init().build()
505    }
506}
507impl ToMarkdown for ModelDetails {
508    fn to_markdown(&self) -> String {
509        let lines = [
510            self.attachment.map(|value| format!("- Attachment: {value}")),
511            self.family.as_ref().map(|value| format!("- Family: {value}")),
512            self.id.as_ref().map(|value| format!("- ID: {value}")),
513            self.knowledge.as_ref().map(|value| format!("- Knowledge: {value}")),
514            self.last_updated.as_ref().map(|value| format!("- Last Updated: {value}")),
515            self.name.as_ref().map(|value| format!("- Name: {value}")),
516            self.open_weights.map(|value| format!("- Open Weights: {value}")),
517            self.cost.as_ref().map(|c| {
518                let parts = [
519                    c.input.map(|v| format!("input=${v}")),
520                    c.output.map(|v| format!("output=${v}")),
521                    c.cache_read.map(|v| format!("cache_read=${v}")),
522                ]
523                .into_iter()
524                .flatten()
525                .collect::<Vec<_>>();
526                format!("- Cost: {}", parts.join(", "))
527            }),
528            self.parameters.map(|value| format!("- Parameters: {value}B")),
529            self.reasoning.map(|value| format!("- Reasoning: {value}")),
530            self.release_date.as_ref().map(|value| format!("- Release Date: {value}")),
531            self.structured_output.map(|value| format!("- Structured Output: {value}")),
532            self.temperature.map(|value| format!("- Temperature: {value}")),
533            self.tool_call.map(|value| format!("- Tool Call: {value}")),
534            self.variant.as_ref().map(|value| format!("- Variant: {value}")),
535            self.version.as_ref().map(|value| format!("- Version: {value}")),
536        ]
537        .into_iter()
538        .flatten()
539        .collect::<Vec<_>>();
540        if lines.is_empty() {
541            String::new()
542        } else {
543            lines.join("\n").to_string()
544        }
545    }
546}
547impl fmt::Display for PromptFileAsset {
548    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
549        let value = match self {
550            | Self::Eli5 => "eli5.prompt",
551            | Self::ExtractClaim => "extract-claim.prompt",
552            | Self::FindGaps => "find-gaps.prompt",
553            | Self::Summarize => "summarize.prompt",
554            | Self::Teach => "teach.prompt",
555            | Self::Translate => "translate.prompt",
556            | Self::Unknown(value) => value,
557        };
558
559        write!(f, "{value}")
560    }
561}
562impl From<&str> for PromptFileAsset {
563    fn from(value: &str) -> Self {
564        match value.to_lowercase().as_str() {
565            | "eli5" | "eli5.prompt" => Self::Eli5,
566            | "extract-claim" | "extract-claim.prompt" => Self::ExtractClaim,
567            | "find-gaps" | "find-gaps.prompt" => Self::FindGaps,
568            | "summarize" | "summarize.prompt" => Self::Summarize,
569            | "teach" | "teach.prompt" => Self::Teach,
570            | "translate" | "translate.prompt" => Self::Translate,
571            | _ => Self::Unknown(value.into()),
572        }
573    }
574}
575impl From<String> for PromptFileAsset {
576    fn from(value: String) -> Self {
577        Self::from(value.as_str())
578    }
579}
580impl Default for PromptTemplateConfiguration {
581    fn default() -> Self {
582        PromptTemplateConfiguration::init().build()
583    }
584}
585impl PromptTemplate {
586    /// Reads a file from the asset folder and returns its contents as a UTF-8 string.
587    pub fn from_asset(file_name: &str) -> Option<String> {
588        match Self::get(file_name) {
589            | Some(value) => from_utf8(value.data.as_ref()).ok().map(String::from),
590            | None => None,
591        }
592    }
593    /// Render a prompt template with the given configuration
594    /// ### Example
595    /// ```ignore
596    /// let config = Configuration::init()
597    ///     .text("Some prompt to process")
598    ///     .max_words(160)
599    ///     .build();
600    /// let rendered = PromptTemplate::render(PromptFileAsset::Summarize, &config);
601    /// ```
602    pub fn render<T>(asset: T, config: &PromptTemplateConfiguration) -> ApiResult<String>
603    where
604        T: Into<PromptFileAsset>,
605    {
606        let name = asset.into().to_string();
607        Self::from_asset(&name)
608            .ok_or_else(|| Error::new(ErrorKind::NotFound, format!("Prompt template not found — {name}")))
609            .map_err(Report::from)
610            .and_then(|template| {
611                Context::from_serialize(config)
612                    .map_err(Report::from)
613                    .and_then(|context| Tera::one_off(&template, &context, false).map_err(Report::from))
614            })
615    }
616}
617impl From<&str> for Provider {
618    fn from(value: &str) -> Self {
619        match value.to_lowercase().as_str() {
620            | "alibaba" => Self::Alibaba,
621            | "amazon" => Self::Amazon,
622            | "anthropic" => Self::Anthropic,
623            | "azure" => Self::Azure,
624            | "baichuan" => Self::Baichuan,
625            | "baidu" => Self::Baidu,
626            | "cohere" => Self::Cohere,
627            | "databricks" => Self::Databricks,
628            | "deepseek" => Self::DeepSeek,
629            | "doubao" => Self::Doubao,
630            | "google" => Self::Google,
631            | "groq" => Self::Groq,
632            | "ibm" => Self::IBM,
633            | "kimi" => Self::Kimi,
634            | "meta" => Self::Meta,
635            | "minimax" => Self::Minimax,
636            | "mistral" => Self::Mistral,
637            | "moonshotai" => Self::MoonshotAI,
638            | "nvidia" => Self::Nvidia,
639            | "ollama" => Self::Ollama,
640            | "openai" => Self::OpenAI,
641            | "perplexity" => Self::Perplexity,
642            | "qwen" => Self::Qwen,
643            | "salesforce" => Self::Salesforce,
644            | "sap" => Self::SAP,
645            | "sarvam" => Self::Sarvam,
646            | "stepfun" => Self::Stepfun,
647            | "tencent" => Self::Tencent,
648            | "togetherai" => Self::TogetherAI,
649            | "xai" => Self::XAI,
650            | "xiaomi" => Self::Xiaomi,
651            | "zhipuai" => Self::ZhipuAI,
652            | _ => Self::Custom(value.into()),
653        }
654    }
655}
656impl ToMarkdown for ProviderDetails {
657    fn to_markdown(&self) -> String {
658        let lines = [
659            self.endpoint.as_ref().map(|value| format!("- API Endpoint: {value}")),
660            self.authentication
661                .as_ref()
662                .map(|value| format!("- Auth Methods: {}", value.iter().map(|m| m.to_string()).collect::<Vec<_>>().join(", "))),
663            self.description.as_ref().map(|value| format!("- Description: {value}")),
664            self.documentation.as_ref().map(|value| format!("- Documentation: {value}")),
665            self.env.as_ref().map(|value| format!("- Env Vars: {}", value.join(", "))),
666            self.established_date.as_ref().map(|value| format!("- Established: {value}")),
667            self.id.as_ref().map(|value| format!("- ID: {value}")),
668            self.last_updated.as_ref().map(|value| format!("- Last Updated: {value}")),
669            self.name.as_ref().map(|value| format!("- Name: {value}")),
670            self.npm.as_ref().map(|value| format!("- NPM: {value}")),
671            self.url.as_ref().map(|value| format!("- URL: {value}")),
672        ]
673        .into_iter()
674        .flatten()
675        .collect::<Vec<_>>();
676        if lines.is_empty() {
677            String::new()
678        } else {
679            format!("\n{}", lines.join("\n"))
680        }
681    }
682}
683fn deserialize_models<'de, D>(deserializer: D) -> Result<Option<Vec<ModelDetails>>, D::Error>
684where
685    D: serde::Deserializer<'de>,
686{
687    #[derive(Deserialize)]
688    #[serde(untagged)]
689    enum Models {
690        Map(HashMap<String, ModelDetails>),
691        Vec(Vec<ModelDetails>),
692    }
693    match Option::<Models>::deserialize(deserializer)? {
694        | Some(Models::Map(map)) => Ok(Some(map.into_values().collect())),
695        | Some(Models::Vec(vec)) => Ok(Some(vec)),
696        | None => Ok(None),
697    }
698}
699fn validate_open_weights(details: &ModelDetails) -> Result<(), ValidationError> {
700    let ModelDetails { open_weights, weights, .. } = details;
701    let has_open_weight = weights.iter().flatten().any(|w| w.is_open == Some(true));
702    if has_open_weight && !open_weights.unwrap_or(false) {
703        Err(ValidationError::new("open_weights").with_message("open_weights must be true when any weight has is_open: true".into()))
704    } else {
705        Ok(())
706    }
707}
708
709#[cfg(test)]
710mod tests;