1use 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#[derive(Clone, Debug, Display, Deserialize, Serialize, JsonSchema)]
31pub enum Harness {
32 #[display("Claude Code")]
34 #[serde(rename = "Claude Code")]
35 ClaudeCode,
36 #[display("Codex")]
38 #[serde(rename = "Codex")]
39 Codex,
40 #[display("Codex CLI")]
42 #[serde(rename = "Codex CLI")]
43 CodexCli,
44 #[display("Cursor CLI")]
46 #[serde(rename = "Cursor CLI")]
47 CursorCli,
48 #[display("Gemini CLI")]
50 #[serde(rename = "Gemini CLI")]
51 GeminiCli,
52 #[display("Mini-SWE-Agent")]
54 #[serde(rename = "Mini-SWE-Agent")]
55 MiniSweAgent,
56 #[display("OpenCode")]
58 #[serde(rename = "OpenCode")]
59 OpenCode,
60 #[display("Terminus-2")]
62 #[serde(rename = "Terminus-2")]
63 Terminus2,
64}
65#[derive(Clone, Debug, Display, Deserialize, Serialize, JsonSchema)]
67pub enum Metric {
68 #[display("average pass@1")]
70 #[serde(rename = "average pass@1")]
71 AveragePassAt1,
72 #[display("index")]
74 #[serde(rename = "index")]
75 Index,
76 #[display("pass@1")]
78 #[serde(rename = "pass@1")]
79 PassAt1,
80 #[display("percent correct")]
82 #[serde(rename = "percent correct")]
83 PercentCorrect,
84 #[display("resolve rate")]
86 #[serde(rename = "resolve rate")]
87 ResolveRate,
88 #[display("resolved")]
90 #[serde(rename = "resolved")]
91 Resolved,
92 #[display("score")]
94 #[serde(rename = "score")]
95 Score,
96 #[display("success rate")]
98 #[serde(rename = "success rate")]
99 SuccessRate,
100}
101#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
103pub enum Model {
104 SLM(ModelDetails),
106 LLM(ModelDetails),
108}
109#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
111pub enum PromptFileAsset {
112 Eli5,
114 ExtractClaim,
116 FindGaps,
118 Summarize,
120 Teach,
122 Translate,
124 Unknown(String),
126}
127#[derive(Clone, Debug, Display, Deserialize, Serialize, JsonSchema)]
129#[serde(rename_all = "lowercase")]
130pub enum Provider {
131 #[display("Alibaba Cloud")]
133 Alibaba,
134 #[display("Amazon Web Services")]
136 Amazon,
137 #[display("Anthropic")]
139 Anthropic,
140 #[display("Azure")]
142 Azure,
143 #[display("Baichuan")]
145 Baichuan,
146 #[display("Baidu")]
148 Baidu,
149 #[display("Cohere")]
151 Cohere,
152 #[display("Databricks")]
154 Databricks,
155 #[display("DeepSeek")]
157 DeepSeek,
158 #[display("Doubao")]
160 Doubao,
161 #[display("Google")]
163 Google,
164 #[display("Groq")]
166 Groq,
167 #[display("IBM")]
169 IBM,
170 #[display("Kimi")]
172 Kimi,
173 #[display("Meta")]
175 Meta,
176 #[display("Minimax")]
178 Minimax,
179 #[display("Mistral")]
181 Mistral,
182 #[display("Moonshot AI")]
184 MoonshotAI,
185 #[display("NVIDIA")]
187 #[serde(alias = "NVIDIA")]
188 Nvidia,
189 #[display("Ollama")]
191 Ollama,
192 #[display("OpenAI")]
194 OpenAI,
195 #[display("Perplexity")]
197 Perplexity,
198 #[display("Qwen")]
200 Qwen,
201 #[display("Salesforce")]
203 Salesforce,
204 #[display("SAP")]
206 SAP,
207 #[display("Sarvam AI")]
209 Sarvam,
210 #[display("Stepfun")]
212 Stepfun,
213 #[display("Tencent")]
215 Tencent,
216 #[display("Together AI")]
218 TogetherAI,
219 #[display("xAI")]
221 XAI,
222 #[display("Xiaomi")]
224 Xiaomi,
225 #[display("Zhipu AI")]
227 ZhipuAI,
228 #[display("{}", _0)]
230 Custom(String),
231}
232#[skip_serializing_none]
234#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Validate)]
235pub struct Benchmark {
236 pub name: String,
238 #[validate(range(min = 0.0))]
240 pub score: f64,
241 pub metric: Metric,
243 #[validate(url)]
245 pub source: String,
246 #[validate(custom(function = "is_partial_date"))]
248 pub date: Option<String>,
249 pub dataset: Option<String>,
251 pub harness: Option<Harness>,
253 pub variant: Option<String>,
255 pub version: Option<String>,
257}
258#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
260pub struct CostDetails {
261 pub input: Option<f64>,
263 pub output: Option<f64>,
265 pub cache_read: Option<f64>,
267 pub cache_write: Option<f64>,
269 pub reasoning: Option<f64>,
271 #[serde(rename = "input_audio")]
273 pub input_audio: Option<f64>,
274 #[serde(rename = "output_audio")]
276 pub output_audio: Option<f64>,
277 pub context_over_200k: Option<Box<CostDetails>>,
279 pub tiers: Option<Vec<CostTier>>,
281}
282#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
284pub struct CostTier {
285 pub input: f64,
287 pub output: f64,
289 pub cache_read: Option<f64>,
291 pub tier: TierInfo,
293}
294#[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 #[builder(default = String::new())]
304 pub name: String,
305 #[builder(default = String::new())]
307 pub description: String,
308 pub config: Option<PromptTemplateConfiguration>,
310 #[validate(nested)]
312 pub license: Option<License>,
313 pub compatibility: Option<String>,
315 pub model: Option<String>,
319 pub metadata: Option<Vec<(String, String)>>,
323 pub allowed_tools: Option<Vec<String>>,
327}
328#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
330pub struct LimitDetails {
331 pub context: u64,
333 pub output: u64,
335 pub input: Option<u64>,
337}
338#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
340pub struct Modalities {
341 pub input: Vec<Modality>,
343 pub output: Vec<Modality>,
345}
346#[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 pub attachment: Option<bool>,
357 pub benchmarks: Option<Vec<Benchmark>>,
359 pub family: Option<String>,
361 pub id: Option<String>,
363 #[validate(custom(function = "is_partial_date"))]
365 pub knowledge: Option<String>,
366 #[validate(custom(function = "is_partial_date"))]
368 pub last_updated: Option<String>,
369 pub limit: Option<LimitDetails>,
371 pub modalities: Option<Modalities>,
373 pub cost: Option<CostDetails>,
375 pub name: Option<String>,
377 pub open_weights: Option<bool>,
379 pub parameters: Option<i64>,
381 pub reasoning: Option<bool>,
383 #[validate(custom(function = "is_partial_date"))]
385 pub release_date: Option<String>,
386 pub structured_output: Option<bool>,
388 pub temperature: Option<bool>,
390 pub tool_call: Option<bool>,
392 pub variant: Option<String>,
394 pub version: Option<SemanticVersion>,
396 pub weights: Option<Vec<Weight>>,
398}
399#[derive(Embed)]
403#[folder = "assets/prompts/"]
404pub struct PromptTemplate;
405#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
407#[serde(rename_all = "kebab-case")]
408#[builder(start_fn = init)]
409pub struct PromptTemplateConfiguration {
410 pub include_analogy: Option<bool>,
412 pub include_examples: Option<bool>,
414 pub include_implicit: Option<bool>,
416 pub include_practice: Option<bool>,
418 pub max_items: Option<u32>,
420 #[builder(default = 300)]
422 pub max_tokens: u32,
423 pub max_words: Option<u32>,
425 pub min_confidence: Option<f32>,
427 #[builder(default = Vec::new())]
429 pub stop_sequences: Vec<String>,
430 pub text: Option<String>,
432 pub language: Option<String>,
434 #[builder(default = 0.1)]
436 pub temperature: f32,
437 #[builder(default = 10)]
439 pub top_k: u32,
440 #[builder(default)]
442 pub version: SemanticVersion,
443}
444#[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 pub authentication: Option<Vec<AuthenticationScheme>>,
451 pub description: Option<String>,
453 #[serde(rename = "doc")]
455 #[validate(url)]
456 pub documentation: Option<String>,
457 #[serde(rename = "api")]
459 #[validate(url)]
460 pub endpoint: Option<String>,
461 pub env: Option<Vec<String>>,
463 #[validate(custom(function = "is_partial_date"))]
465 pub established_date: Option<String>,
466 pub id: Option<String>,
468 #[validate(custom(function = "is_partial_date"))]
470 pub last_updated: Option<String>,
471 #[serde(default, deserialize_with = "deserialize_models")]
473 pub models: Option<Vec<ModelDetails>>,
474 pub name: Option<String>,
476 pub npm: Option<String>,
478 #[validate(url)]
480 pub url: Option<String>,
481}
482#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
484#[serde(rename_all = "camelCase")]
485pub struct TierInfo {
486 #[serde(rename = "type")]
488 pub kind: String,
489 pub size: u64,
491}
492#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
494pub struct Weight {
495 pub label: String,
497 pub url: String,
499 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 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 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;