1use crate::types::{ChatMessage, LlmParams};
6
7pub const DEFAULT_CONTEXT_SIZE: u32 = 8192;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Finish {
15 Stop,
16 Length,
17}
18
19impl Finish {
20 pub fn as_str(self) -> &'static str {
21 match self {
22 Self::Stop => "stop",
23 Self::Length => "length",
24 }
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
31#[error(
32 "prompt is {prompt_tokens} tokens but the context holds {n_ctx}; raise contextSize or \
33 shorten the prompt"
34)]
35pub struct ContextOverflow {
36 pub prompt_tokens: usize,
37 pub n_ctx: u32,
38}
39
40pub fn plan_budget(
43 prompt_tokens: usize,
44 max_tokens: u32,
45 n_ctx: u32,
46) -> Result<u32, ContextOverflow> {
47 let room = (n_ctx as usize)
48 .checked_sub(prompt_tokens)
49 .filter(|r| *r > 0);
50 match room {
51 Some(room) => Ok(max_tokens.max(1).min(room.min(u32::MAX as usize) as u32)),
52 None => Err(ContextOverflow {
53 prompt_tokens,
54 n_ctx,
55 }),
56 }
57}
58
59pub fn effective_context(configured: Option<u32>, trained: u32) -> u32 {
62 let wanted = configured.unwrap_or(DEFAULT_CONTEXT_SIZE);
63 if trained > 0 {
64 wanted.min(trained)
65 } else {
66 wanted
67 }
68}
69
70pub fn chat_messages(params: &LlmParams) -> Vec<ChatMessage> {
72 let mut out = Vec::with_capacity(params.messages.len() + 1);
73 if let Some(system) = ¶ms.system {
74 out.push(ChatMessage {
75 role: "system".into(),
76 content: system.clone(),
77 });
78 }
79 out.extend(params.messages.iter().cloned());
80 out
81}
82
83pub fn should_add_bos(model_adds_bos: bool, prompt: &str, bos_text: &str) -> bool {
86 model_adds_bos && (bos_text.is_empty() || !prompt.starts_with(bos_text))
87}
88
89pub fn finish_for(generated: u32, budget: u32, hit_end: bool) -> Finish {
91 if !hit_end && generated >= budget {
92 Finish::Length
93 } else {
94 Finish::Stop
95 }
96}
97
98pub fn split_reasoning(text: &str) -> (String, Option<String>) {
100 match text.split_once("</think>") {
101 Some((thought, answer)) => {
102 let thought = thought.trim().trim_start_matches("<think>").trim();
103 (answer.trim().to_string(), Some(thought.to_string()))
104 }
105 None => (text.trim().to_string(), None),
106 }
107}
108
109pub fn completion_json(
111 model: &str,
112 text: &str,
113 prompt_tokens: usize,
114 completion_tokens: u32,
115 finish: Finish,
116 elapsed_ms: u64,
117) -> serde_json::Value {
118 let (content, reasoning) = split_reasoning(text);
119 let mut message = serde_json::json!({ "role": "assistant", "content": content });
120 if let Some(reasoning) = reasoning {
121 message["reasoning_content"] = reasoning.into();
122 }
123 serde_json::json!({
124 "object": "chat.completion",
125 "model": model,
126 "choices": [{ "index": 0, "message": message, "finish_reason": finish.as_str() }],
127 "usage": {
128 "prompt_tokens": prompt_tokens,
129 "completion_tokens": completion_tokens,
130 "total_tokens": prompt_tokens + completion_tokens as usize,
131 },
132 "elapsed_ms": elapsed_ms,
133 })
134}
135
136#[derive(Debug)]
140pub struct StopHold {
141 stops: Vec<String>,
142 out: String,
143 emitted: usize,
144 stopped: Option<usize>,
145}
146
147impl StopHold {
148 pub fn new(stops: &[String]) -> Self {
149 Self {
150 stops: stops.iter().filter(|s| !s.is_empty()).cloned().collect(),
151 out: String::new(),
152 emitted: 0,
153 stopped: None,
154 }
155 }
156
157 pub fn push(&mut self, piece: &str) -> String {
159 if self.stopped.is_some() {
160 return String::new();
161 }
162 let search_from = self.emitted;
163 self.out.push_str(piece);
164 if let Some(at) = self
165 .stops
166 .iter()
167 .filter_map(|s| {
168 self.out[search_from..]
169 .find(s.as_str())
170 .map(|i| i + search_from)
171 })
172 .min()
173 {
174 self.out.truncate(at);
175 self.stopped = Some(at);
176 return self.release(at);
177 }
178 let held = self.held_suffix();
179 self.release(self.out.len() - held)
180 }
181
182 pub fn finish(&mut self) -> String {
184 self.release(self.out.len())
185 }
186
187 pub fn stopped(&self) -> Option<usize> {
189 self.stopped
190 }
191
192 pub fn text(&self) -> &str {
194 &self.out
195 }
196
197 fn held_suffix(&self) -> usize {
199 let pending = &self.out[self.emitted..];
200 pending
201 .char_indices()
202 .map(|(i, _)| i)
203 .find(|&i| {
204 let tail = &pending[i..];
205 self.stops
206 .iter()
207 .any(|s| s.len() > tail.len() && s.starts_with(tail))
208 })
209 .map_or(0, |i| pending.len() - i)
210 }
211
212 fn release(&mut self, upto: usize) -> String {
213 let upto = upto.max(self.emitted);
214 let text = self.out[self.emitted..upto].to_string();
215 self.emitted = upto;
216 text
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
222pub enum Delta {
223 Reasoning(String),
224 Content(String),
225}
226
227#[derive(Debug, Default, PartialEq, Eq)]
228enum ThinkState {
229 #[default]
231 Undecided,
232 Thinking,
233 Answering,
234}
235
236const THINK_OPEN: &str = "<think>";
237const THINK_CLOSE: &str = "</think>";
238
239#[derive(Debug, Default)]
243pub struct ThinkSplitter {
244 state: ThinkState,
245 buf: String,
246}
247
248impl ThinkSplitter {
249 pub fn push(&mut self, piece: &str) -> Vec<Delta> {
250 self.buf.push_str(piece);
251 let mut out = Vec::new();
252 loop {
253 match self.state {
254 ThinkState::Undecided => {
255 let trimmed = self.buf.trim_start();
256 if let Some(rest) = trimmed.strip_prefix(THINK_OPEN) {
257 self.buf = rest.to_string();
258 self.state = ThinkState::Thinking;
259 } else if THINK_OPEN.starts_with(trimmed) {
260 return out; } else {
262 self.state = ThinkState::Answering;
263 }
264 }
265 ThinkState::Thinking => match self.buf.find(THINK_CLOSE) {
266 Some(at) => {
267 let thought = self.buf[..at].trim().to_string();
268 if !thought.is_empty() {
269 out.push(Delta::Reasoning(thought));
270 }
271 self.buf = self.buf[at + THINK_CLOSE.len()..].trim_start().to_string();
272 self.state = ThinkState::Answering;
273 }
274 None => return out, },
276 ThinkState::Answering => {
277 if !self.buf.is_empty() {
278 out.push(Delta::Content(std::mem::take(&mut self.buf)));
279 }
280 return out;
281 }
282 }
283 }
284 }
285
286 pub fn finish(&mut self) -> Vec<Delta> {
288 let rest = std::mem::take(&mut self.buf);
289 match self.state {
290 ThinkState::Thinking if !rest.trim().is_empty() => {
291 vec![Delta::Reasoning(rest.trim().to_string())]
292 }
293 ThinkState::Undecided | ThinkState::Answering if !rest.is_empty() => {
294 vec![Delta::Content(rest)]
295 }
296 _ => Vec::new(),
297 }
298 }
299}
300
301pub fn chunk_frame(model: &str, delta: &Delta) -> serde_json::Value {
303 let delta = match delta {
304 Delta::Content(t) => serde_json::json!({ "content": t }),
305 Delta::Reasoning(t) => serde_json::json!({ "reasoning_content": t }),
306 };
307 serde_json::json!({
308 "object": "chat.completion.chunk",
309 "model": model,
310 "choices": [{ "index": 0, "delta": delta, "finish_reason": null }],
311 })
312}
313
314pub fn final_frame(
316 model: &str,
317 finish: Finish,
318 prompt_tokens: usize,
319 completion_tokens: u32,
320) -> serde_json::Value {
321 serde_json::json!({
322 "object": "chat.completion.chunk",
323 "model": model,
324 "choices": [{ "index": 0, "delta": {}, "finish_reason": finish.as_str() }],
325 "usage": {
326 "prompt_tokens": prompt_tokens,
327 "completion_tokens": completion_tokens,
328 "total_tokens": prompt_tokens + completion_tokens as usize,
329 },
330 })
331}
332
333pub fn sse_event(value: &serde_json::Value) -> Vec<u8> {
335 format!("data: {value}\n\n").into_bytes()
336}
337
338pub const SSE_DONE: &[u8] = b"data: [DONE]\n\n";
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::types::{ChatMessage, LlmParams};
345
346 #[test]
347 fn budget_is_max_tokens_when_it_fits() {
348 assert_eq!(plan_budget(100, 50, 2048), Ok(50));
349 }
350
351 #[test]
352 fn budget_is_clamped_to_the_room_left_in_the_context() {
353 assert_eq!(plan_budget(2000, 100, 2048), Ok(48));
354 }
355
356 #[test]
357 fn a_prompt_that_fills_the_context_is_refused() {
358 let err = plan_budget(2048, 10, 2048).unwrap_err();
359 assert_eq!(
360 err,
361 ContextOverflow {
362 prompt_tokens: 2048,
363 n_ctx: 2048
364 }
365 );
366 assert_eq!(
367 err.to_string(),
368 "prompt is 2048 tokens but the context holds 2048; raise contextSize or shorten the prompt"
369 );
370 }
371
372 #[test]
373 fn a_zero_budget_still_generates_one_token() {
374 assert_eq!(plan_budget(10, 0, 2048), Ok(1));
375 }
376
377 #[test]
378 fn context_is_the_configured_size_capped_by_training() {
379 assert_eq!(effective_context(Some(32768), 262144), 32768);
380 assert_eq!(effective_context(None, 262144), DEFAULT_CONTEXT_SIZE);
381 assert_eq!(effective_context(Some(32768), 4096), 4096);
382 assert_eq!(
383 effective_context(Some(32768), 0),
384 32768,
385 "unknown training size"
386 );
387 }
388
389 #[test]
390 fn the_system_prompt_leads_the_messages() {
391 let params = LlmParams {
392 system: Some("be brief".into()),
393 messages: vec![ChatMessage {
394 role: "user".into(),
395 content: "hi".into(),
396 }],
397 ..Default::default()
398 };
399 let msgs = chat_messages(¶ms);
400 assert_eq!(msgs[0].role, "system");
401 assert_eq!(msgs[0].content, "be brief");
402 assert_eq!(msgs[1].content, "hi");
403 let bare = LlmParams {
404 messages: params.messages.clone(),
405 ..Default::default()
406 };
407 assert_eq!(chat_messages(&bare).len(), 1);
408 }
409
410 #[test]
411 fn bos_is_added_only_when_the_model_wants_it_and_the_template_did_not() {
412 assert!(should_add_bos(true, "hello", "<s>"));
413 assert!(!should_add_bos(true, "<s>hello", "<s>"));
414 assert!(!should_add_bos(false, "hello", "<s>"));
415 assert!(
416 should_add_bos(true, "hello", ""),
417 "an empty BOS text never matches"
418 );
419 }
420
421 #[test]
422 fn finish_is_length_when_the_budget_ran_out() {
423 assert_eq!(finish_for(50, 50, false), Finish::Length);
424 assert_eq!(finish_for(12, 50, true), Finish::Stop);
425 assert_eq!(Finish::Stop.as_str(), "stop");
426 assert_eq!(Finish::Length.as_str(), "length");
427 }
428
429 #[test]
430 fn reasoning_is_split_from_the_answer() {
431 assert_eq!(
432 split_reasoning("<think>\nhmm\n</think>\n\n{\"a\":1}"),
433 ("{\"a\":1}".to_string(), Some("hmm".to_string()))
434 );
435 assert_eq!(split_reasoning(" plain "), ("plain".to_string(), None));
436 assert_eq!(
437 split_reasoning("still thinking</think>"),
438 (String::new(), Some("still thinking".to_string()))
439 );
440 }
441
442 #[test]
443 fn completion_json_is_openai_shaped_with_real_counts() {
444 let json = completion_json("m", "<think>x</think>answer", 9649, 133, Finish::Stop, 2549);
445 assert_eq!(json["object"], "chat.completion");
446 assert_eq!(json["model"], "m");
447 assert_eq!(json["choices"][0]["message"]["role"], "assistant");
448 assert_eq!(json["choices"][0]["message"]["content"], "answer");
449 assert_eq!(json["choices"][0]["message"]["reasoning_content"], "x");
450 assert_eq!(json["choices"][0]["finish_reason"], "stop");
451 assert_eq!(json["usage"]["prompt_tokens"], 9649);
452 assert_eq!(json["usage"]["completion_tokens"], 133);
453 assert_eq!(json["usage"]["total_tokens"], 9782);
454 assert_eq!(json["elapsed_ms"], 2549);
455 }
456
457 #[test]
458 fn completion_json_omits_absent_reasoning() {
459 let json = completion_json("m", "answer", 1, 1, Finish::Length, 1);
460 assert!(json["choices"][0]["message"]
461 .get("reasoning_content")
462 .is_none());
463 assert_eq!(json["choices"][0]["finish_reason"], "length");
464 }
465
466 fn drain(hold: &mut StopHold, pieces: &[&str]) -> String {
467 let mut out = String::new();
468 for p in pieces {
469 out.push_str(&hold.push(p));
470 }
471 out
472 }
473
474 #[test]
475 fn stop_hold_releases_text_that_cannot_start_a_stop() {
476 let mut hold = StopHold::new(&["</s>".to_string()]);
477 assert_eq!(hold.push("hello "), "hello ");
478 assert_eq!(hold.push("world"), "world");
479 assert_eq!(hold.finish(), "");
480 }
481
482 #[test]
483 fn stop_hold_never_leaks_a_stop_split_across_pieces() {
484 let mut hold = StopHold::new(&["END".to_string()]);
485 let streamed = drain(&mut hold, &["one E", "N", "D two"]);
486 assert_eq!(hold.stopped(), Some("one ".len()));
487 assert_eq!(streamed + &hold.finish(), "one ");
488 }
489
490 #[test]
491 fn stop_hold_without_stops_holds_nothing() {
492 let mut hold = StopHold::new(&[]);
493 assert_eq!(hold.push("a"), "a");
494 assert_eq!(hold.push("é"), "é");
495 assert_eq!(hold.stopped(), None);
496 }
497
498 #[test]
499 fn stop_hold_keeps_multibyte_characters_whole() {
500 let mut hold = StopHold::new(&["xyz".to_string()]);
501 let mut out = hold.push("ééé");
502 out.push_str(&hold.finish());
503 assert_eq!(out, "ééé");
504 }
505
506 fn split(pieces: &[&str]) -> (String, String) {
507 let mut s = ThinkSplitter::default();
508 let (mut reasoning, mut content) = (String::new(), String::new());
509 for p in pieces.iter().copied().map(Some).chain([None]) {
510 let deltas = match p {
511 Some(p) => s.push(p),
512 None => s.finish(),
513 };
514 for d in deltas {
515 match d {
516 Delta::Reasoning(t) => reasoning.push_str(&t),
517 Delta::Content(t) => content.push_str(&t),
518 }
519 }
520 }
521 (reasoning, content)
522 }
523
524 #[test]
525 fn think_splitter_passes_plain_answers_as_content() {
526 assert_eq!(
527 split(&["{\"a\"", ":1}"]),
528 (String::new(), "{\"a\":1}".into())
529 );
530 }
531
532 #[test]
533 fn think_splitter_routes_a_think_block_to_reasoning() {
534 assert_eq!(
535 split(&["<th", "ink>\nhmm", " ok</th", "ink>\n\nanswer"]),
536 ("hmm ok".into(), "answer".into())
537 );
538 }
539
540 #[test]
541 fn think_splitter_treats_text_that_only_looks_like_a_start_as_content() {
542 assert_eq!(split(&["<t", "able>"]), (String::new(), "<table>".into()));
543 assert_eq!(split(&["<th"]), (String::new(), "<th".into()));
544 }
545
546 #[test]
547 fn think_splitter_flushes_an_unfinished_think_as_reasoning() {
548 assert_eq!(split(&["<think>still"]), ("still".into(), String::new()));
549 }
550
551 #[test]
552 fn stream_frames_are_openai_chunks() {
553 let content = chunk_frame("m", &Delta::Content("hi".into()));
554 assert_eq!(
555 content,
556 serde_json::json!({
557 "object": "chat.completion.chunk",
558 "model": "m",
559 "choices": [{ "index": 0, "delta": { "content": "hi" }, "finish_reason": null }],
560 })
561 );
562 let reasoning = chunk_frame("m", &Delta::Reasoning("r".into()));
563 assert_eq!(
564 reasoning["choices"][0]["delta"],
565 serde_json::json!({ "reasoning_content": "r" })
566 );
567 let last = final_frame("m", Finish::Length, 10, 3);
568 assert_eq!(last["choices"][0]["delta"], serde_json::json!({}));
569 assert_eq!(last["choices"][0]["finish_reason"], "length");
570 assert_eq!(last["usage"]["total_tokens"], 13);
571 }
572
573 #[test]
574 fn sse_events_are_data_lines() {
575 assert_eq!(
576 sse_event(&serde_json::json!({ "a": 1 })),
577 b"data: {\"a\":1}\n\n"
578 );
579 assert_eq!(SSE_DONE, b"data: [DONE]\n\n");
580 }
581}