1use crate::{
9 FirstTokenDistribution, FirstTokenRequest, HostCapabilities, HostError, ModelHost, OllamaHost,
10 SystemOneHost,
11};
12use async_trait::async_trait;
13use serde::Deserialize;
14use std::collections::HashMap;
15use std::sync::Mutex;
16use std::time::Duration;
17
18#[derive(Deserialize)]
19struct Show {
20 #[serde(default)]
22 capabilities: Vec<String>,
23}
24
25pub struct OllamaRouter {
26 client: reqwest::Client,
27 base_url: String,
28 timeout: Duration,
29 chat: OllamaHost,
30 systemone: SystemOneHost,
31 decision: Mutex<HashMap<String, bool>>,
33}
34
35impl OllamaRouter {
36 pub fn new(base_url: impl Into<String>, timeout: Duration) -> Result<Self, HostError> {
37 let url = crate::base_url(&base_url.into())?;
38 let client = reqwest::Client::builder()
39 .timeout(timeout)
40 .build()
41 .map_err(|e| HostError::Unavailable(e.to_string()))?;
42 Ok(Self {
43 client,
44 chat: OllamaHost::new(url.clone(), timeout)?,
45 systemone: SystemOneHost::new(url.clone(), timeout)?,
46 base_url: url,
47 timeout,
48 decision: Mutex::default(),
49 })
50 }
51
52 async fn is_decision(&self, model: &str) -> Result<bool, HostError> {
58 let known = self
59 .decision
60 .lock()
61 .expect("lock is never poisoned")
62 .get(model)
63 .copied();
64 if let Some(known) = known {
65 return Ok(known);
66 }
67
68 let response = self
69 .client
70 .post(format!("{}/api/show", self.base_url))
71 .json(&serde_json::json!({ "model": model }))
72 .send()
73 .await
74 .map_err(|e| HostError::transport(&e, self.timeout))?;
75
76 let status = response.status().as_u16();
77 if status >= 400 {
78 tracing::debug!(
79 model,
80 status,
81 "could not read the model's capabilities; asking /api/chat"
82 );
83 return Ok(false);
84 }
85
86 let text = response
87 .text()
88 .await
89 .map_err(|e| HostError::transport(&e, self.timeout))?;
90 let show: Show = serde_json::from_str(&text)
91 .map_err(|e| HostError::Protocol(format!("could not read /api/show: {e}")))?;
92 let decision = show.capabilities.iter().any(|c| c == "decision");
93
94 let first = self
96 .decision
97 .lock()
98 .expect("lock is never poisoned")
99 .insert(model.to_string(), decision)
100 .is_none();
101 if decision && first {
102 tracing::info!(model, "decision model; answering through /v1/systemone");
103 }
104 Ok(decision)
105 }
106}
107
108#[async_trait]
109impl ModelHost for OllamaRouter {
110 fn capabilities(&self) -> HostCapabilities {
113 self.chat.capabilities()
114 }
115
116 fn name(&self) -> &str {
117 "ollama"
118 }
119
120 async fn first_token(
121 &self,
122 req: FirstTokenRequest,
123 ) -> Result<FirstTokenDistribution, HostError> {
124 if self.is_decision(&req.model).await? {
125 self.systemone.first_token(req).await
126 } else {
127 self.chat.first_token(req).await
128 }
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use mockito::Matcher;
136 use serde_json::json;
137
138 fn request(model: &str) -> FirstTokenRequest {
139 FirstTokenRequest {
140 model: model.into(),
141 system: None,
142 user: "Which?".into(),
143 top_logprobs: 20,
144 keep_alive: None,
145 offer: crate::test_offer(),
146 }
147 }
148
149 async fn show(
150 server: &mut mockito::Server,
151 model: &str,
152 capabilities: &[&str],
153 ) -> mockito::Mock {
154 server
155 .mock("POST", "/api/show")
156 .match_body(Matcher::PartialJson(json!({ "model": model })))
157 .with_body(json!({ "capabilities": capabilities }).to_string())
158 .expect(1)
159 .create_async()
160 .await
161 }
162
163 #[tokio::test]
164 async fn a_decision_model_is_answered_through_systemone_and_asked_once() {
165 let mut server = mockito::Server::new_async().await;
166 let show = show(&mut server, "nimble", &["decision"]).await;
167 let systemone = server
168 .mock("POST", "/v1/systemone")
169 .with_body(
170 json!({"answers": {"q": {"type": "choice", "probabilities": {"x": 0.8, "y": 0.2}}}})
171 .to_string(),
172 )
173 .expect(2)
174 .create_async()
175 .await;
176 let chat = server
177 .mock("POST", "/api/chat")
178 .expect(0)
179 .create_async()
180 .await;
181 let host = OllamaRouter::new(server.url(), Duration::from_secs(5)).unwrap();
182
183 for _ in 0..2 {
184 let dist = host.first_token(request("nimble")).await.unwrap();
185 assert_eq!(dist.tokens[0].0, "A");
186 assert!((dist.logprob("A").unwrap().exp() - 0.8).abs() < 1e-9);
187 }
188
189 show.assert_async().await;
190 systemone.assert_async().await;
191 chat.assert_async().await;
192 }
193
194 #[tokio::test]
195 async fn any_other_model_gets_the_letter_prompt() {
196 let mut server = mockito::Server::new_async().await;
197 let show = show(&mut server, "gemma4", &["completion", "tools"]).await;
198 let chat = server
199 .mock("POST", "/api/chat")
200 .with_body(
201 json!({
202 "message": {"role": "assistant", "content": "B"},
203 "logprobs": [{"token": "B", "logprob": -0.1, "top_logprobs": [
204 {"token": "B", "logprob": -0.1},
205 {"token": "A", "logprob": -2.4}
206 ]}]
207 })
208 .to_string(),
209 )
210 .expect(1)
211 .create_async()
212 .await;
213 let systemone = server
214 .mock("POST", "/v1/systemone")
215 .expect(0)
216 .create_async()
217 .await;
218 let host = OllamaRouter::new(server.url(), Duration::from_secs(5)).unwrap();
219
220 let dist = host.first_token(request("gemma4")).await.unwrap();
221
222 assert_eq!(dist.tokens[0], ("B".to_string(), -0.1));
223 show.assert_async().await;
224 chat.assert_async().await;
225 systemone.assert_async().await;
226 }
227
228 #[tokio::test]
229 async fn an_unreadable_capability_list_falls_back_to_chat_and_is_asked_again() {
230 let mut server = mockito::Server::new_async().await;
231 let show = server
232 .mock("POST", "/api/show")
233 .with_status(404)
234 .with_body(r#"{"error":"model 'x:latest' not found"}"#)
235 .expect(2)
236 .create_async()
237 .await;
238 let chat = server
239 .mock("POST", "/api/chat")
240 .with_status(404)
241 .with_body(r#"{"error":"model \"x\" not found"}"#)
242 .expect(2)
243 .create_async()
244 .await;
245 let host = OllamaRouter::new(server.url(), Duration::from_secs(5)).unwrap();
246
247 for _ in 0..2 {
248 let err = host.first_token(request("x")).await.unwrap_err();
249 assert!(
250 matches!(err, HostError::Status { status: 404, ref body } if body.contains("not found")),
251 "{err}"
252 );
253 }
254
255 show.assert_async().await;
256 chat.assert_async().await;
257 }
258}