1mod ollama;
16mod openai;
17mod router;
18mod systemone;
19
20pub use ollama::OllamaHost;
21pub use openai::{Flavour, OpenAiCompatHost};
22pub use router::OllamaRouter;
23pub use systemone::SystemOneHost;
24
25use async_trait::async_trait;
26use std::sync::Arc;
27use std::time::Duration;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum HostKind {
32 Ollama,
33 OpenAi,
35 Vllm,
36 LlamaCpp,
37 LmStudio,
38}
39
40impl HostKind {
41 pub fn default_url(self) -> &'static str {
43 match self {
44 HostKind::Ollama => "http://localhost:11434",
45 HostKind::OpenAi => "https://api.openai.com/v1",
46 HostKind::Vllm => "http://localhost:8000/v1",
47 HostKind::LlamaCpp => "http://localhost:8080/v1",
48 HostKind::LmStudio => "http://localhost:1234/v1",
49 }
50 }
51}
52
53#[derive(Debug, thiserror::Error)]
54#[error("unknown host {0:?}; expected one of ollama, openai, vllm, llamacpp, lmstudio")]
55pub struct UnknownHostKind(String);
56
57impl std::str::FromStr for HostKind {
58 type Err = UnknownHostKind;
59
60 fn from_str(s: &str) -> Result<Self, Self::Err> {
61 match s.trim().to_ascii_lowercase().as_str() {
62 "ollama" => Ok(HostKind::Ollama),
63 "openai" => Ok(HostKind::OpenAi),
64 "vllm" => Ok(HostKind::Vllm),
65 "llamacpp" | "llama.cpp" => Ok(HostKind::LlamaCpp),
66 "lmstudio" => Ok(HostKind::LmStudio),
67 _ => Err(UnknownHostKind(s.to_string())),
68 }
69 }
70}
71
72impl std::fmt::Display for HostKind {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.write_str(match self {
75 HostKind::Ollama => "ollama",
76 HostKind::OpenAi => "openai",
77 HostKind::Vllm => "vllm",
78 HostKind::LlamaCpp => "llamacpp",
79 HostKind::LmStudio => "lmstudio",
80 })
81 }
82}
83
84pub fn connect(
86 kind: HostKind,
87 url: &str,
88 api_key: Option<String>,
89 timeout: Duration,
90) -> Result<Arc<dyn ModelHost>, HostError> {
91 let flavour = match kind {
92 HostKind::Ollama => return Ok(Arc::new(OllamaRouter::new(url, timeout)?)),
93 HostKind::OpenAi => Flavour::Generic,
94 HostKind::Vllm => Flavour::Vllm,
95 HostKind::LlamaCpp => Flavour::LlamaCpp,
96 HostKind::LmStudio => Flavour::LmStudio,
97 };
98 Ok(Arc::new(OpenAiCompatHost::new(
99 url, api_key, flavour, timeout,
100 )?))
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct HostCapabilities {
106 pub max_top_logprobs: usize,
111}
112
113#[derive(Debug, Clone)]
115pub struct FirstTokenRequest {
116 pub model: String,
117 pub system: Option<String>,
118 pub user: String,
119 pub top_logprobs: usize,
120 pub keep_alive: Option<String>,
123 pub offer: Offer,
125}
126
127#[derive(Debug, Clone, PartialEq)]
132pub struct Offer {
133 pub question_id: String,
135 pub state: String,
136 pub question: Option<String>,
138 pub shape: Shape,
139 pub options: Vec<(String, String)>,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum Shape {
146 YesNo,
148 Pick,
150 Scale,
152}
153
154#[derive(Debug, Clone)]
156pub struct FirstTokenDistribution {
157 pub tokens: Vec<(String, f64)>,
159 pub floor: f64,
164 pub input_tokens: u32,
165 pub latency: Duration,
166}
167
168impl FirstTokenDistribution {
169 pub fn logprob(&self, token: &str) -> Option<f64> {
171 self.tokens
172 .iter()
173 .find(|(t, _)| t == token)
174 .map(|(_, lp)| *lp)
175 }
176}
177
178#[derive(Debug, thiserror::Error)]
179pub enum HostError {
180 #[error("host unreachable: {0}")]
181 Unavailable(String),
182
183 #[error("host timed out after {0:?}")]
184 Timeout(Duration),
185
186 #[error("host returned {status}: {body}")]
187 Status { status: u16, body: String },
188
189 #[error(
192 "host response carried no logprobs (model {model:?}); \
193 the runtime must support top_logprobs for cerno to work"
194 )]
195 NoLogprobs { model: String },
196
197 #[error("could not parse host response: {0}")]
198 Protocol(String),
199
200 #[error("host URL {url:?} is not usable: {reason}")]
203 InvalidUrl { url: String, reason: String },
204}
205
206fn base_url(raw: &str) -> Result<String, HostError> {
208 let trimmed = raw.trim().trim_end_matches('/');
209 let invalid = |reason: String| HostError::InvalidUrl {
210 url: raw.to_string(),
211 reason,
212 };
213
214 let parsed = reqwest::Url::parse(trimmed).map_err(|e| invalid(e.to_string()))?;
215 if !matches!(parsed.scheme(), "http" | "https") {
216 return Err(invalid("it must start with http:// or https://".into()));
219 }
220 if parsed.host_str().is_none() {
221 return Err(invalid("it names no host".into()));
222 }
223 Ok(trimmed.to_string())
224}
225
226const MAX_ERROR_BODY: usize = 300;
230
231impl HostError {
232 fn status(status: u16, body: String) -> Self {
238 tracing::warn!(status, body = %body, "host answered with an error");
239
240 if matches!(status, 401 | 403) {
241 return Self::Status {
242 status,
243 body: "the host refused cerno's credentials; the service log has its answer".into(),
244 };
245 }
246
247 let body = match body.char_indices().nth(MAX_ERROR_BODY) {
248 Some((cut, _)) => format!("{}…", &body[..cut]),
249 None => body,
250 };
251 Self::Status { status, body }
252 }
253
254 fn transport(error: &reqwest::Error, timeout: Duration) -> Self {
257 if error.is_timeout() {
258 Self::Timeout(timeout)
259 } else {
260 Self::Unavailable(error.to_string())
261 }
262 }
263}
264
265#[async_trait]
266pub trait ModelHost: Send + Sync {
267 fn capabilities(&self) -> HostCapabilities;
268
269 fn name(&self) -> &str;
271
272 async fn first_token(
273 &self,
274 req: FirstTokenRequest,
275 ) -> Result<FirstTokenDistribution, HostError>;
276}
277
278#[cfg(test)]
280pub(crate) fn test_offer() -> Offer {
281 Offer {
282 question_id: "q".into(),
283 state: "s".into(),
284 question: Some("Which?".into()),
285 shape: Shape::Pick,
286 options: vec![("A".into(), "x".into()), ("B".into(), "y".into())],
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn every_host_kind_parses_from_its_own_name() {
296 for kind in [
297 HostKind::Ollama,
298 HostKind::OpenAi,
299 HostKind::Vllm,
300 HostKind::LlamaCpp,
301 HostKind::LmStudio,
302 ] {
303 assert_eq!(kind.to_string().parse::<HostKind>().unwrap(), kind);
304 }
305 assert_eq!(
306 " LLAMA.CPP ".parse::<HostKind>().unwrap(),
307 HostKind::LlamaCpp
308 );
309 }
310
311 #[test]
312 fn an_unknown_host_kind_names_the_choices() {
313 let err = "tgi".parse::<HostKind>().unwrap_err().to_string();
314
315 assert!(err.contains("\"tgi\""), "{err}");
316 assert!(err.contains("lmstudio"), "{err}");
317 }
318
319 #[test]
321 fn default_urls_match_where_each_runtime_listens() {
322 assert_eq!(HostKind::Ollama.default_url(), "http://localhost:11434");
323 assert_eq!(HostKind::Vllm.default_url(), "http://localhost:8000/v1");
324 assert_eq!(HostKind::LlamaCpp.default_url(), "http://localhost:8080/v1");
325 assert_eq!(HostKind::LmStudio.default_url(), "http://localhost:1234/v1");
326 }
327
328 #[test]
329 fn a_long_error_body_is_cut_short() {
330 let HostError::Status { body, .. } = HostError::status(400, "x".repeat(5000)) else {
331 panic!()
332 };
333
334 assert_eq!(
335 body.chars().count(),
336 MAX_ERROR_BODY + 1,
337 "300 characters and an ellipsis"
338 );
339 assert!(body.ends_with('…'));
340 }
341
342 #[test]
343 fn an_authentication_failure_passes_on_none_of_the_body() {
344 for status in [401, 403] {
345 let HostError::Status { body, .. } = HostError::status(
346 status,
347 r#"{"error":{"message":"Incorrect API key provided: sk-proj-********abcd."}}"#
348 .into(),
349 ) else {
350 panic!()
351 };
352
353 assert!(!body.contains("sk-"), "{status}: {body}");
354 assert!(!body.contains("abcd"), "{status}: {body}");
355 }
356 }
357
358 #[test]
359 fn a_short_error_body_is_kept_whole() {
360 let HostError::Status { body, .. } = HostError::status(404, "model not found".into())
361 else {
362 panic!()
363 };
364
365 assert_eq!(body, "model not found");
366 }
367
368 #[test]
369 fn a_base_url_must_be_http_with_a_host() {
370 assert_eq!(
371 base_url(" http://localhost:11434/ ").unwrap(),
372 "http://localhost:11434"
373 );
374 assert_eq!(
375 base_url("https://api.openai.com/v1").unwrap(),
376 "https://api.openai.com/v1"
377 );
378
379 for bad in ["localhost:11434", "ftp://x", "http://", "not a url", ""] {
380 assert!(
381 matches!(base_url(bad), Err(HostError::InvalidUrl { .. })),
382 "{bad:?} was accepted"
383 );
384 }
385 }
386
387 #[test]
388 fn connect_refuses_a_url_without_a_scheme() {
389 let err = connect(
390 HostKind::Ollama,
391 "localhost:11434",
392 None,
393 Duration::from_secs(1),
394 )
395 .err()
396 .expect("refused");
397
398 assert!(err.to_string().contains("http://"), "{err}");
399 }
400
401 #[test]
402 fn connect_names_the_host_it_built() {
403 let timeout = Duration::from_secs(1);
404
405 assert_eq!(
406 connect(HostKind::Ollama, "http://x", None, timeout)
407 .unwrap()
408 .name(),
409 "ollama"
410 );
411 assert_eq!(
412 connect(HostKind::Vllm, "http://x/v1", None, timeout)
413 .unwrap()
414 .name(),
415 "vllm"
416 );
417 }
418}