1use crate::{
10 FirstTokenDistribution, FirstTokenRequest, HostCapabilities, HostError, ModelHost, Offer, Shape,
11};
12use async_trait::async_trait;
13use serde::ser::SerializeMap;
14use serde::{Deserialize, Serialize, Serializer};
15use std::collections::BTreeMap;
16use std::time::{Duration, Instant};
17
18const SYSTEMONE_MAX_OPTIONS: usize = 26;
21
22#[derive(Serialize)]
23struct Request<'a> {
24 model: &'a str,
25 state: &'a str,
26 questions: BTreeMap<&'a str, Ask<'a>>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 keep_alive: Option<&'a str>,
31}
32
33#[derive(Serialize)]
34#[serde(tag = "type", rename_all = "snake_case")]
35enum Ask<'a> {
36 Noul {
37 instructions: &'a str,
38 },
39 Choice {
40 instructions: &'a str,
41 criteria: NullCriteria<'a>,
42 },
43 Score {
44 instructions: &'a str,
45 criteria: Vec<&'a str>,
47 },
48}
49
50struct NullCriteria<'a>(&'a [(String, String)]);
54
55impl Serialize for NullCriteria<'_> {
56 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
57 let mut map = serializer.serialize_map(Some(self.0.len()))?;
58 for (_, text) in self.0 {
59 map.serialize_entry(text, &())?;
60 }
61 map.end()
62 }
63}
64
65fn body(req: &FirstTokenRequest) -> Request<'_> {
68 let offer = &req.offer;
69 let instructions = |default| offer.question.as_deref().unwrap_or(default);
70 let ask = match offer.shape {
71 Shape::YesNo => Ask::Noul {
72 instructions: instructions("Is this true?"),
73 },
74 Shape::Pick => Ask::Choice {
75 instructions: instructions("Which option applies?"),
76 criteria: NullCriteria(&offer.options),
77 },
78 Shape::Scale => Ask::Score {
79 instructions: instructions("Which level applies?"),
80 criteria: offer.options.iter().map(|(_, t)| t.as_str()).collect(),
81 },
82 };
83 Request {
84 model: &req.model,
85 state: &offer.state,
86 questions: BTreeMap::from([(offer.question_id.as_str(), ask)]),
87 keep_alive: req.keep_alive.as_deref(),
88 }
89}
90
91#[derive(Deserialize)]
92struct Response {
93 answers: BTreeMap<String, Reply>,
94 #[serde(default)]
95 usage: Usage,
96}
97
98#[derive(Default, Deserialize)]
99struct Usage {
100 #[serde(default)]
101 input_tokens: u32,
102}
103
104#[derive(Deserialize)]
108#[serde(tag = "type", rename_all = "snake_case")]
109enum Reply {
110 Noul {
111 noul: f64,
112 },
113 Choice {
114 probabilities: BTreeMap<String, f64>,
115 },
116 Score {
117 probabilities: BTreeMap<String, f64>,
118 },
119}
120
121pub struct SystemOneHost {
122 client: reqwest::Client,
123 base_url: String,
124 timeout: Duration,
125}
126
127impl SystemOneHost {
128 pub fn new(base_url: impl Into<String>, timeout: Duration) -> Result<Self, HostError> {
129 let client = reqwest::Client::builder()
130 .timeout(timeout)
131 .build()
132 .map_err(|e| HostError::Unavailable(e.to_string()))?;
133 Ok(Self {
134 client,
135 base_url: crate::base_url(&base_url.into())?,
136 timeout,
137 })
138 }
139}
140
141#[async_trait]
142impl ModelHost for SystemOneHost {
143 fn capabilities(&self) -> HostCapabilities {
144 HostCapabilities {
145 max_top_logprobs: SYSTEMONE_MAX_OPTIONS,
146 }
147 }
148
149 fn name(&self) -> &str {
150 "systemone"
151 }
152
153 async fn first_token(
154 &self,
155 req: FirstTokenRequest,
156 ) -> Result<FirstTokenDistribution, HostError> {
157 let started = Instant::now();
158
159 let response = self
160 .client
161 .post(format!("{}/v1/systemone", self.base_url))
162 .json(&body(&req))
163 .send()
164 .await
165 .map_err(|e| HostError::transport(&e, self.timeout))?;
166
167 let status = response.status().as_u16();
168 let text = response
169 .text()
170 .await
171 .map_err(|e| HostError::transport(&e, self.timeout))?;
172
173 if status >= 400 {
174 return Err(HostError::status(status, text));
175 }
176
177 parse_answer(&text, &req.offer, started.elapsed())
178 }
179}
180
181fn ln(p: f64) -> f64 {
184 p.max(1e-12).ln()
185}
186
187fn wire_type(shape: Shape) -> &'static str {
188 match shape {
189 Shape::YesNo => "noul",
190 Shape::Pick => "choice",
191 Shape::Scale => "score",
192 }
193}
194
195fn parse_answer(
197 text: &str,
198 offer: &Offer,
199 latency: Duration,
200) -> Result<FirstTokenDistribution, HostError> {
201 let mut response: Response =
202 serde_json::from_str(text).map_err(|e| HostError::Protocol(e.to_string()))?;
203 let reply = response.answers.remove(&offer.question_id).ok_or_else(|| {
204 HostError::Protocol(format!("no answer for question {:?}", offer.question_id))
205 })?;
206
207 let mut tokens: Vec<(String, f64)> = match (offer.shape, reply) {
208 (Shape::YesNo, Reply::Noul { noul }) => vec![
209 (offer.options[0].0.clone(), ln(noul)),
210 (offer.options[1].0.clone(), ln(1.0 - noul)),
211 ],
212 (Shape::Pick, Reply::Choice { probabilities }) => offer
213 .options
214 .iter()
215 .map(|(label, text)| {
216 probabilities
217 .get(text)
218 .map(|&p| (label.clone(), ln(p)))
219 .ok_or_else(|| {
220 HostError::Protocol(format!("no probability for option {text:?}"))
221 })
222 })
223 .collect::<Result<_, _>>()?,
224 (Shape::Scale, Reply::Score { probabilities }) => offer
225 .options
226 .iter()
227 .enumerate()
228 .map(|(k, (label, _))| {
229 probabilities
230 .get(&k.to_string())
231 .map(|&p| (label.clone(), ln(p)))
232 .ok_or_else(|| HostError::Protocol(format!("no probability for level {k}")))
233 })
234 .collect::<Result<_, _>>()?,
235 (shape, reply) => {
236 let got = match reply {
237 Reply::Noul { .. } => "noul",
238 Reply::Choice { .. } => "choice",
239 Reply::Score { .. } => "score",
240 };
241 return Err(HostError::Protocol(format!(
242 "asked a {} question, got a {got} answer",
243 wire_type(shape)
244 )));
245 }
246 };
247
248 tokens.sort_by(|a, b| b.1.total_cmp(&a.1));
249 let floor = tokens.last().map(|(_, lp)| *lp).unwrap_or(ln(0.0));
250
251 Ok(FirstTokenDistribution {
252 tokens,
253 floor,
254 input_tokens: response.usage.input_tokens,
255 latency,
256 })
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use serde_json::json;
263
264 fn offer(shape: Shape, question: Option<&str>, texts: &[&str]) -> Offer {
265 Offer {
266 question_id: "q".into(),
267 state: "s".into(),
268 question: question.map(str::to_string),
269 shape,
270 options: texts
271 .iter()
272 .enumerate()
273 .map(|(i, t)| (((b'A' + i as u8) as char).to_string(), t.to_string()))
274 .collect(),
275 }
276 }
277
278 fn request(offer: Offer, keep_alive: Option<&str>) -> FirstTokenRequest {
279 FirstTokenRequest {
280 model: "nimble".into(),
281 system: Some("ignored".into()),
282 user: "ignored".into(),
283 top_logprobs: 20,
284 keep_alive: keep_alive.map(str::to_string),
285 offer,
286 }
287 }
288
289 fn parse(
290 offer: &Offer,
291 response: serde_json::Value,
292 ) -> Result<FirstTokenDistribution, HostError> {
293 parse_answer(&response.to_string(), offer, Duration::ZERO)
294 }
295
296 #[test]
297 fn a_choice_keeps_the_option_order() {
298 let req = request(
299 offer(
300 Shape::Pick,
301 Some("Wer?"),
302 &["IT", "Personal", "Vertrieb", "Buchhaltung"],
303 ),
304 Some("5m"),
305 );
306
307 let body = serde_json::to_string(&body(&req)).unwrap();
308
309 assert!(
310 body.contains(
311 r#""criteria":{"IT":null,"Personal":null,"Vertrieb":null,"Buchhaltung":null}"#
312 ),
313 "{body}"
314 );
315 }
316
317 #[test]
318 fn each_shape_is_sent_as_its_primitive() {
319 let noul = request(
320 offer(Shape::YesNo, Some("Urgent?"), &["Yes", "No"]),
321 Some("5m"),
322 );
323 let json = serde_json::to_value(body(&noul)).unwrap();
324 assert_eq!(
325 json,
326 json!({
327 "model": "nimble",
328 "state": "s",
329 "questions": {"q": {"type": "noul", "instructions": "Urgent?"}},
330 "keep_alive": "5m"
331 })
332 );
333
334 let scale = request(
335 offer(Shape::Scale, Some("How bad?"), &["1", "2", "3", "4", "5"]),
336 None,
337 );
338 let json = serde_json::to_value(body(&scale)).unwrap();
339 assert_eq!(
340 json["questions"]["q"],
341 json!({"type": "score", "instructions": "How bad?", "criteria": ["1", "2", "3", "4", "5"]})
342 );
343 assert!(json.get("keep_alive").is_none(), "{json}");
344
345 let unasked = request(offer(Shape::Pick, None, &["x", "y"]), None);
346 let json = serde_json::to_value(body(&unasked)).unwrap();
347 assert_eq!(
348 json["questions"]["q"]["instructions"],
349 "Which option applies?"
350 );
351 }
352
353 #[test]
354 fn a_score_is_read_from_zero_based_levels() {
355 let o = offer(Shape::Scale, None, &["1", "2", "3", "4", "5"]);
356 let probabilities = [0.1, 0.2, 0.6, 0.05, 0.05];
357
358 let dist = parse(
359 &o,
360 json!({
361 "answers": {"q": {
362 "type": "score",
363 "score": 1.75,
364 "legend": {"0": "1", "1": "2", "2": "3", "3": "4", "4": "5"},
365 "probabilities": {"0": 0.1, "1": 0.2, "2": 0.6, "3": 0.05, "4": 0.05},
366 "confidence": 0.3
367 }},
368 "usage": {"input_tokens": 145, "output_tokens": 1}
369 }),
370 )
371 .unwrap();
372
373 assert_eq!(dist.tokens[0].0, "C");
374 for (label, p) in ["A", "B", "C", "D", "E"].iter().zip(probabilities) {
375 assert!(
376 (dist.logprob(label).unwrap().exp() - p).abs() < 1e-9,
377 "{label}"
378 );
379 }
380 assert_eq!(dist.input_tokens, 145);
381 }
382
383 #[test]
384 fn a_reported_zero_stays_finite() {
385 let o = offer(Shape::YesNo, Some("?"), &["Yes", "No"]);
386
387 let dist = parse(&o, json!({"answers": {"q": {"type": "noul", "noul": 1.0}}})).unwrap();
388
389 assert_eq!(dist.tokens[0], ("A".to_string(), 0.0));
390 assert_eq!(dist.logprob("B"), Some(1e-12_f64.ln()));
391 assert!(dist.floor.is_finite());
392 }
393
394 #[test]
395 fn an_answer_outside_the_offer_is_a_protocol_error() {
396 let pick = offer(Shape::Pick, None, &["IT", "Personal", "Vertrieb"]);
397 let scale = offer(Shape::Scale, None, &["low", "mid", "high"]);
398
399 let cases = [
400 (
401 &pick,
402 json!({"answers": {"q": {"type": "choice", "choice": "IT",
403 "probabilities": {"IT": 0.4, "Personal": 0.6}}}}),
404 "Vertrieb",
405 ),
406 (
407 &scale,
408 json!({"answers": {"q": {"type": "score",
409 "probabilities": {"0": 0.4, "1": 0.6}}}}),
410 "level 2",
411 ),
412 (
413 &pick,
414 json!({"answers": {"q": {"type": "noul", "noul": 0.9}}}),
415 "asked a choice question, got a noul answer",
416 ),
417 (
418 &pick,
419 json!({"answers": {"other": {"type": "noul", "noul": 0.9}}}),
420 "no answer for question \"q\"",
421 ),
422 ];
423
424 for (offer, response, says) in cases {
425 match parse(offer, response) {
426 Err(HostError::Protocol(message)) => {
427 assert!(message.contains(says), "{message}")
428 }
429 other => panic!("{says}: {:?}", other.map(|d| d.tokens)),
430 }
431 }
432 }
433
434 #[tokio::test]
435 async fn an_unknown_model_is_a_404() {
436 let mut server = mockito::Server::new_async().await;
437 let mock = server
438 .mock("POST", "/v1/systemone")
439 .with_status(404)
440 .with_body(r#"{"error":"model \"does-not-exist\" not found, try pulling it first"}"#)
441 .expect(1)
442 .create_async()
443 .await;
444 let host = SystemOneHost::new(server.url(), Duration::from_secs(5)).unwrap();
445
446 let err = host
447 .first_token(request(
448 offer(Shape::YesNo, Some("?"), &["Yes", "No"]),
449 None,
450 ))
451 .await
452 .unwrap_err();
453
454 assert!(
455 matches!(err, HostError::Status { status: 404, ref body } if body.contains("not found")),
456 "{err}"
457 );
458 mock.assert_async().await;
459 }
460}