1mod anthropic;
10mod openai;
11mod sse;
12
13pub use anthropic::AnthropicModel;
14pub use cairn_runtime::{Priced, Pricing};
15pub use openai::OpenAiCompatModel;
16
17use std::sync::Arc;
18
19use cairn_runtime::{Model, ModelError};
20
21pub fn from_spec(spec: &str) -> Result<Arc<dyn Model>, ModelError> {
38 let (provider, model) =
39 spec.split_once(':').ok_or_else(|| ModelError::Other(format!("expected `provider:model`, got `{spec}`")))?;
40 let key = |var: &str| std::env::var(var).map_err(|_| ModelError::Other(format!("{var} is not set")));
41 let model: Arc<dyn Model> = match provider {
42 "anthropic" => Arc::new(AnthropicModel::new(model, key("ANTHROPIC_API_KEY")?)),
43 "openai" => Arc::new(OpenAiCompatModel::openai(model, key("OPENAI_API_KEY")?)),
44 "gemini" => Arc::new(OpenAiCompatModel::gemini(model, key("GEMINI_API_KEY")?)),
45 "openrouter" => Arc::new(OpenAiCompatModel::openrouter(model, key("OPENROUTER_API_KEY")?)),
46 "compat" => {
47 let (name, model) = model
48 .split_once(':')
49 .filter(|(n, m)| !n.is_empty() && !m.is_empty())
50 .ok_or_else(|| ModelError::Other(format!("expected `compat:<name>:<model>`, got `{spec}`")))?;
51 let prefix = env_prefix(name);
52 let base = key(&format!("{prefix}_BASE_URL"))?;
53 let api_key = std::env::var(format!("{prefix}_API_KEY")).ok().filter(|k| !k.is_empty());
54 Arc::new(OpenAiCompatModel::new(name, model, base, api_key))
55 }
56 "ollama" => {
57 let base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434/v1".into());
58 Arc::new(OpenAiCompatModel::new("ollama", model, base, None))
59 }
60 other => return Err(ModelError::Other(format!("unknown provider `{other}`"))),
61 };
62 Ok(model)
63}
64
65fn env_prefix(name: &str) -> String {
66 name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' }).collect()
67}
68
69pub(crate) async fn send_json(
70 request: reqwest::RequestBuilder,
71 body: &serde_json::Value,
72) -> Result<serde_json::Value, ModelError> {
73 let resp = request.json(body).send().await.map_err(|e| ModelError::Transport(e.to_string()))?;
74 let status = resp.status();
75 let text = resp.text().await.map_err(|e| ModelError::Transport(e.to_string()))?;
76 if !status.is_success() {
77 return Err(ModelError::Api { status: status.as_u16(), body: text });
78 }
79 serde_json::from_str(&text).map_err(|e| ModelError::Decode(e.to_string()))
80}
81
82pub(crate) fn merge_options(body: &mut serde_json::Value, options: Option<&serde_json::Value>) {
83 if let (Some(target), Some(serde_json::Value::Object(extra))) = (body.as_object_mut(), options) {
84 for (k, v) in extra {
85 target.insert(k.clone(), v.clone());
86 }
87 }
88}
89
90pub(crate) fn result_string(v: &serde_json::Value) -> String {
91 match v {
92 serde_json::Value::String(s) => s.clone(),
93 other => other.to_string(),
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn env_prefix_normalizes_names() {
103 assert_eq!(env_prefix("k2"), "K2");
104 assert_eq!(env_prefix("my-vllm.prod"), "MY_VLLM_PROD");
105 }
106
107 #[test]
108 fn compat_spec_errors_are_actionable() {
109 let err = from_spec("compat:k2").err().unwrap().to_string();
110 assert!(err.contains("compat:<name>:<model>"), "{err}");
111 let err = from_spec("compat:cairn_unset_zz:m").err().unwrap().to_string();
112 assert!(err.contains("CAIRN_UNSET_ZZ_BASE_URL is not set"), "{err}");
113 }
114}