Skip to main content

cairn_models/
lib.rs

1//! Model adapters for Cairn.
2//!
3//! * [`AnthropicModel`] speaks the Messages API natively and preserves
4//!   extended thinking and prompt caching.
5//! * [`OpenAiCompatModel`] speaks Chat Completions, which covers OpenAI,
6//!   Google Gemini (OpenAI-compatible endpoint), Ollama, vLLM, OpenRouter,
7//!   Groq, Together and most self-hosted servers.
8
9mod 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
21/// Build a model from a `provider:model` spec, reading credentials from the
22/// environment.
23///
24/// | spec prefix  | env var             | endpoint                                  |
25/// |--------------|---------------------|-------------------------------------------|
26/// | `anthropic:` | `ANTHROPIC_API_KEY` | api.anthropic.com                         |
27/// | `openai:`    | `OPENAI_API_KEY`    | api.openai.com                            |
28/// | `gemini:`    | `GEMINI_API_KEY`    | Gemini OpenAI-compatible endpoint         |
29/// | `ollama:`    | —                   | `OLLAMA_BASE_URL` or localhost:11434      |
30/// | `openrouter:`| `OPENROUTER_API_KEY`| openrouter.ai                             |
31/// | `compat:<name>:` | `<NAME>_API_KEY` (optional) | `<NAME>_BASE_URL` (required)  |
32///
33/// `compat:` reaches any OpenAI-compatible server: `compat:k2:IFM/K2-Horizon-375B-A23B`
34/// reads `K2_BASE_URL` and `K2_API_KEY`, and its [`Model::id`] is
35/// `k2:IFM/K2-Horizon-375B-A23B`. `<NAME>` is the name upper-cased with
36/// non-alphanumeric characters replaced by `_`.
37pub 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}