recursive/llm/chat.rs
1//! Chat completion data types.
2//!
3//! These types are the wire contract between the [`ChatProvider`] trait and
4//! its callers. All provider implementations produce and consume these
5//! shapes; nothing in `tools/` or `agent/` depends on provider internals.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use tokio::sync::mpsc;
10
11use crate::message::Message;
12
13/// A single streamed delta emitted by a provider during a streaming LLM call.
14///
15/// Providers that expose an explicit reasoning channel (DeepSeek R1, OpenAI
16/// o1/o3, Anthropic / DeepSeek extended thinking) emit `Reasoning` deltas
17/// *before* the answer's `Text` deltas, so consumers can render the
18/// chain-of-thought live and above the final answer.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum StreamChunk {
21 /// A delta of the visible answer text.
22 Text(String),
23 /// A delta of the model's reasoning / thinking text.
24 Reasoning(String),
25}
26
27/// Channel sender for streaming partial deltas during a streaming LLM call.
28/// Each [`StreamChunk`] is a delta emitted by the provider.
29pub type StreamSender = mpsc::UnboundedSender<StreamChunk>;
30
31/// Locally-estimated per-component token breakdown of the prompt sent to the
32/// provider.
33///
34/// Distinct from [`TokenUsage`] (which is the provider's reported truth):
35/// these are local chars/4 estimates, one bucket per logical segment of the
36/// assembled prompt. The `overhead` bucket absorbs the difference between
37/// the sum of the other buckets and the provider's reported `prompt_tokens`
38/// (chat-template wrapping, tool JSON envelope, message separators) so the
39/// breakdown is honest about its estimation error rather than pretending
40/// to be exact.
41///
42/// Recomputed every step: the static buckets (`system_prompt`, `rules`,
43/// `skills`, `subagents`, `tools`, `mcp_dynamic`) change only on a `/model`
44/// hot-swap or tool-registry change, while `conversation` grows with every
45/// transcript mutation and `overhead` is derived from the provider's
46/// reported `prompt_tokens` after each step.
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ContextBreakdown {
49 /// `default_system_prompt` + the six memory layers + any channel
50 /// `append_system_prompt` suffix.
51 pub system_prompt: u32,
52 /// `# Project context` block: `AGENTS.md` + `CLAUDE.md` from
53 /// `prepend_project_context`.
54 pub rules: u32,
55 /// `skill_index()` output.
56 pub skills: u32,
57 /// `## Coordinator workflow` + `sub_agent` usage note (only when
58 /// `sub_agent_enabled`).
59 pub subagents: u32,
60 /// Eager tool specs that are NOT from MCP and NOT deferred.
61 pub tools: u32,
62 /// Tool specs that ARE MCP-sourced or deferred (the dynamic /
63 /// discovered-tools bucket).
64 pub mcp_dynamic: u32,
65 /// `self.messages` text content (transcript body).
66 pub conversation: u32,
67 /// Provider total - local sum (saturating to 0). Absorbs chat-template
68 /// wrapping, tool JSON envelope, and message separators that the local
69 /// estimate can't reproduce.
70 pub overhead: u32,
71}
72
73impl ContextBreakdown {
74 /// Sum of the seven non-overhead buckets.
75 pub fn local_sum(&self) -> u32 {
76 self.system_prompt
77 .saturating_add(self.rules)
78 .saturating_add(self.skills)
79 .saturating_add(self.subagents)
80 .saturating_add(self.tools)
81 .saturating_add(self.mcp_dynamic)
82 .saturating_add(self.conversation)
83 }
84
85 /// Total of every bucket including overhead.
86 pub fn total(&self) -> u32 {
87 self.local_sum().saturating_add(self.overhead)
88 }
89}
90
91/// Rough token estimate for budget/breakdown display. Uses a bytes/4
92/// heuristic (≈4 bytes/token for English; over-counts CJK ~3× because CJK
93/// chars are 3 UTF-8 bytes each). This is intentionally byte-based for
94/// speed (no char iteration) and is only an estimate — exact tokenization
95/// would require the provider's tokenizer. Do not use for billing.
96///
97/// Used by both the `estimate_tokens` tool and the local `ContextBreakdown`
98/// so the two share the same arithmetic.
99///
100/// The estimator deliberately rounds up (ceil) so a 5-byte string becomes
101/// 2 tokens, not 1; this matches the conservative budgeting the rest of
102/// the codebase applies when comparing local estimates against the
103/// provider's reported truth.
104pub fn estimate_tokens(text: &str) -> u32 {
105 let bytes = text.len();
106 let tokens = (bytes as f64 / 4.0).ceil() as u32;
107 // Defensive: if bytes overflow the divisor (impossible for `str::len()`
108 // on a single allocation but possible in theory), the f64 rounding
109 // path can yield 0. Force a 1-token floor for any non-empty input.
110 if tokens == 0 && !text.is_empty() {
111 1
112 } else {
113 tokens
114 }
115}
116
117/// Token usage data from an LLM response.
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
119pub struct TokenUsage {
120 pub prompt_tokens: u32,
121 pub completion_tokens: u32,
122 pub total_tokens: u32,
123 /// Input tokens served from the provider's prompt cache.
124 ///
125 /// Invariant (normalised across providers):
126 /// `cache_hit_tokens + cache_miss_tokens == total input tokens`.
127 /// A "hit" is a token read from cache; everything else processed for the
128 /// prompt (fresh input *and* tokens written to cache) counts as a "miss".
129 /// This lets consumers compute a cache-hit rate as
130 /// `cache_hit / (cache_hit + cache_miss)` uniformly, regardless of which
131 /// provider reported the usage. DeepSeek already reports the split this
132 /// way (`prompt_tokens = hit + miss`); Anthropic reports `input_tokens`,
133 /// `cache_read_input_tokens` and `cache_creation_input_tokens` separately,
134 /// so its parser folds `input + creation` into `cache_miss_tokens`.
135 pub cache_hit_tokens: u32,
136 pub cache_miss_tokens: u32,
137 /// Reasoning / thinking tokens emitted by models that support
138 /// extended thinking (DeepSeek R1, OpenAI o1, Anthropic
139 /// extended thinking). Adds to the cost total because the
140 /// model spent compute producing them. Default 0 for models
141 /// that don't report this separately.
142 pub reasoning_tokens: u32,
143}
144
145impl TokenUsage {
146 /// Saturating element-wise sum. Used to accumulate across LLM calls.
147 pub fn accumulate(self, other: TokenUsage) -> TokenUsage {
148 TokenUsage {
149 reasoning_tokens: self.reasoning_tokens.saturating_add(other.reasoning_tokens),
150 prompt_tokens: self.prompt_tokens.saturating_add(other.prompt_tokens),
151 completion_tokens: self
152 .completion_tokens
153 .saturating_add(other.completion_tokens),
154 total_tokens: self.total_tokens.saturating_add(other.total_tokens),
155 cache_hit_tokens: self.cache_hit_tokens.saturating_add(other.cache_hit_tokens),
156 cache_miss_tokens: self
157 .cache_miss_tokens
158 .saturating_add(other.cache_miss_tokens),
159 }
160 }
161}
162
163/// JSON-schema description of a tool, sent verbatim to the model.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct ToolSpec {
166 pub name: String,
167 pub description: String,
168 /// JSON Schema object describing the tool's input.
169 pub parameters: Value,
170}
171
172/// A structured request to invoke one of the registered tools.
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174pub struct ToolCall {
175 pub id: String,
176 pub name: String,
177 /// Raw JSON arguments as produced by the model.
178 pub arguments: Value,
179}
180
181/// Request for a structured JSON response conforming to a JSON schema.
182pub struct StructuredRequest {
183 pub messages: Vec<Message>,
184 /// JSON Schema describing the expected response shape.
185 pub schema: Value,
186 /// Name for the schema (sent to the provider as `schema_name`).
187 pub schema_name: String,
188}
189
190/// One step of model output.
191#[derive(Debug, Clone, Default)]
192pub struct Completion {
193 pub content: String,
194 pub tool_calls: Vec<ToolCall>,
195 pub finish_reason: Option<String>,
196 pub usage: Option<TokenUsage>,
197 /// DeepSeek reasoning/thinking content. Stored in the transcript and
198 /// echoed back on subsequent requests to satisfy the API contract.
199 pub reasoning_content: Option<String>,
200}
201
202impl Completion {
203 /// Normalise chain-of-thought that a model emitted *inline* in the
204 /// `content` field wrapped in `<think>…</think>` tags into the
205 /// dedicated [`Completion::reasoning_content`] channel.
206 ///
207 /// Many DeepSeek-R1 style deployments (and OpenAI-compatible proxies)
208 /// don't populate the separate `reasoning_content` SSE field; they
209 /// stream the thinking inline as `<think>…</think>` inside `content`.
210 /// Downstream the TUI markdown renderer parses those tags as an HTML
211 /// block and silently drops the whole section — so the thinking is
212 /// visible while streaming but disappears once the turn finalises and
213 /// the assistant block is re-rendered from `content`.
214 ///
215 /// This moves the inner text into `reasoning_content` (so the existing
216 /// `AgentEvent::Reasoning` → thinking-block pipeline lights up) and
217 /// strips the tags from `content` (so the answer renders cleanly).
218 ///
219 /// No-op when `reasoning_content` is already populated (true reasoner
220 /// models that use the dedicated field) or when no `<think>` tag is
221 /// present.
222 pub fn extract_inline_reasoning(&mut self) {
223 if self
224 .reasoning_content
225 .as_deref()
226 .is_some_and(|r| !r.trim().is_empty())
227 {
228 return;
229 }
230 let Some((reasoning, cleaned)) = split_think_tags(&self.content) else {
231 return;
232 };
233 if !reasoning.is_empty() {
234 self.reasoning_content = Some(reasoning);
235 }
236 self.content = cleaned;
237 }
238}
239
240/// Split a `<think>…</think>` block out of `content`.
241///
242/// Returns `Some((reasoning, cleaned_content))` when an opening `<think>`
243/// tag is present, else `None`. An unclosed `<think>` (e.g. a truncated
244/// response) treats everything after the tag as reasoning. Both halves are
245/// trimmed of surrounding whitespace.
246fn split_think_tags(content: &str) -> Option<(String, String)> {
247 const OPEN: &str = "<think>";
248 const CLOSE: &str = "</think>";
249
250 let open_idx = content.find(OPEN)?;
251 let after_open = open_idx + OPEN.len();
252
253 if let Some(rel_close) = content[after_open..].find(CLOSE) {
254 let close_idx = after_open + rel_close;
255 let reasoning = content[after_open..close_idx].trim().to_string();
256 let mut cleaned = String::with_capacity(content.len());
257 cleaned.push_str(&content[..open_idx]);
258 cleaned.push_str(&content[close_idx + CLOSE.len()..]);
259 Some((reasoning, cleaned.trim().to_string()))
260 } else {
261 // Unclosed tag: treat the remainder as reasoning.
262 let reasoning = content[after_open..].trim().to_string();
263 let cleaned = content[..open_idx].trim().to_string();
264 Some((reasoning, cleaned))
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 fn completion_with_content(content: &str) -> Completion {
273 Completion {
274 content: content.to_string(),
275 ..Default::default()
276 }
277 }
278
279 #[test]
280 fn estimate_tokens_is_byte_based_documents_cjk_overcount() {
281 // "hello world" = 11 bytes / 11 chars; "你好世界" = 12 bytes / 4 chars.
282 let ascii = "hello world";
283 let cjk = "你好世界";
284 // Pin the byte/char counts so the ratio below is self-documenting.
285 assert_eq!(ascii.len(), 11);
286 assert_eq!(ascii.chars().count(), 11);
287 assert_eq!(cjk.len(), 12);
288 assert_eq!(cjk.chars().count(), 4);
289 // Byte-based: ascii=11/4≈3, cjk=12/4=3 — same tokens despite cjk having 1/3 the chars.
290 assert_eq!(estimate_tokens(ascii), 3);
291 assert_eq!(estimate_tokens(cjk), 3);
292 assert_eq!(estimate_tokens(ascii), estimate_tokens(cjk));
293 // This equality IS the known over-count; the docstring explains it.
294 }
295
296 #[test]
297 fn extract_inline_reasoning_moves_think_block_to_reasoning() {
298 let mut c = completion_with_content(
299 "<think>\nlet me work through this\n</think>\n\nThe answer is 42.",
300 );
301 c.extract_inline_reasoning();
302 assert_eq!(
303 c.reasoning_content.as_deref(),
304 Some("let me work through this")
305 );
306 assert_eq!(c.content, "The answer is 42.");
307 }
308
309 #[test]
310 fn extract_inline_reasoning_handles_inline_single_line() {
311 let mut c = completion_with_content("<think>quick thought</think>answer");
312 c.extract_inline_reasoning();
313 assert_eq!(c.reasoning_content.as_deref(), Some("quick thought"));
314 assert_eq!(c.content, "answer");
315 }
316
317 #[test]
318 fn extract_inline_reasoning_unclosed_tag_treats_rest_as_reasoning() {
319 let mut c = completion_with_content("partial answer<think>still thinking");
320 c.extract_inline_reasoning();
321 assert_eq!(c.reasoning_content.as_deref(), Some("still thinking"));
322 assert_eq!(c.content, "partial answer");
323 }
324
325 #[test]
326 fn extract_inline_reasoning_no_tag_is_noop() {
327 let mut c = completion_with_content("just a plain answer");
328 c.extract_inline_reasoning();
329 assert!(c.reasoning_content.is_none());
330 assert_eq!(c.content, "just a plain answer");
331 }
332
333 // ── TokenUsage::accumulate tests ─────────────────────────────────────────
334
335 #[test]
336 fn token_usage_accumulate_sums_all_fields() {
337 // kills function-level replacement and any single-field mutation in accumulate()
338 let a = TokenUsage {
339 reasoning_tokens: 10,
340 prompt_tokens: 20,
341 completion_tokens: 30,
342 total_tokens: 60,
343 cache_hit_tokens: 5,
344 cache_miss_tokens: 15,
345 };
346 let b = TokenUsage {
347 reasoning_tokens: 1,
348 prompt_tokens: 2,
349 completion_tokens: 3,
350 total_tokens: 6,
351 cache_hit_tokens: 4,
352 cache_miss_tokens: 9,
353 };
354 let c = a.accumulate(b);
355 assert_eq!(c.reasoning_tokens, 11);
356 assert_eq!(c.prompt_tokens, 22);
357 assert_eq!(c.completion_tokens, 33);
358 assert_eq!(c.total_tokens, 66);
359 assert_eq!(c.cache_hit_tokens, 9);
360 assert_eq!(c.cache_miss_tokens, 24);
361 }
362
363 #[test]
364 fn token_usage_accumulate_saturates_on_overflow() {
365 // kills `saturating_add` → wrapping_add or `+` mutations
366 let big = TokenUsage {
367 reasoning_tokens: u32::MAX,
368 prompt_tokens: u32::MAX,
369 completion_tokens: u32::MAX,
370 total_tokens: u32::MAX,
371 cache_hit_tokens: u32::MAX,
372 cache_miss_tokens: u32::MAX,
373 };
374 let one = TokenUsage {
375 reasoning_tokens: 1,
376 prompt_tokens: 1,
377 completion_tokens: 1,
378 total_tokens: 1,
379 cache_hit_tokens: 1,
380 cache_miss_tokens: 1,
381 };
382 let result = big.accumulate(one);
383 assert_eq!(
384 result.reasoning_tokens,
385 u32::MAX,
386 "must saturate, not overflow"
387 );
388 assert_eq!(result.prompt_tokens, u32::MAX);
389 assert_eq!(result.completion_tokens, u32::MAX);
390 }
391
392 #[test]
393 fn split_think_tags_empty_block_returns_empty_reasoning() {
394 // kills mutations in the trimming / boundary logic for empty think blocks
395 let result = split_think_tags("<think></think>answer");
396 assert!(result.is_some(), "empty think block must still parse");
397 let (reasoning, cleaned) = result.unwrap();
398 assert!(
399 reasoning.is_empty(),
400 "reasoning must be empty for empty tags; got: {reasoning:?}"
401 );
402 assert_eq!(cleaned, "answer");
403 }
404
405 #[test]
406 fn split_think_tags_returns_none_when_no_tag() {
407 // kills `content.find(OPEN)?` → always-Some mutations
408 let result = split_think_tags("no think tag here");
409 assert!(
410 result.is_none(),
411 "must return None when no <think> tag present"
412 );
413 }
414
415 #[test]
416 fn split_think_tags_preserves_content_before_tag() {
417 // kills mutations to the `content[..open_idx]` slice in split_think_tags
418 let result = split_think_tags("prefix <think>reason</think>suffix");
419 assert!(result.is_some());
420 let (reasoning, cleaned) = result.unwrap();
421 assert_eq!(reasoning, "reason");
422 assert!(
423 cleaned.contains("prefix"),
424 "content before <think> must be preserved in cleaned: {cleaned:?}"
425 );
426 assert!(
427 cleaned.contains("suffix"),
428 "content after </think> must be preserved in cleaned: {cleaned:?}"
429 );
430 }
431
432 #[test]
433 fn extract_inline_reasoning_preserves_existing_reasoning_field() {
434 // True reasoner model: reasoning_content already populated via the
435 // dedicated SSE field. Leave content untouched even if it happens
436 // to contain a stray tag.
437 let mut c = Completion {
438 content: "<think>ignored</think>answer".to_string(),
439 reasoning_content: Some("real reasoning".to_string()),
440 ..Default::default()
441 };
442 c.extract_inline_reasoning();
443 assert_eq!(c.reasoning_content.as_deref(), Some("real reasoning"));
444 assert_eq!(c.content, "<think>ignored</think>answer");
445 }
446
447 // ── estimate_tokens tests ───────────────────────────────────────────
448
449 #[test]
450 fn estimate_tokens_empty_string_is_zero() {
451 // kills `replace / 4.0 with % 4.0` mutation — % would yield 0 / 4 = 0
452 // for "" (correct by accident), but the ceil-cast branch must still
453 // produce 0. The defensive floor only kicks in when ceil is 0 AND
454 // text is non-empty, so empty text → 0 must round-trip cleanly.
455 assert_eq!(estimate_tokens(""), 0);
456 }
457
458 #[test]
459 fn estimate_tokens_exact_multiple_of_four() {
460 // 4 chars / 4 = 1 → ceil = 1 token exactly.
461 // kills `replace ceil with floor` — 4/4 = 1 either way, so we
462 // additionally check that the no-op rounding doesn't drift.
463 assert_eq!(estimate_tokens("abcd"), 1);
464 }
465
466 #[test]
467 fn estimate_tokens_uses_ceil_not_floor() {
468 // 5 chars / 4 = 1.25 → ceil = 2 tokens; floor would give 1.
469 // This is the load-bearing case: a half-token string must round UP
470 // so we never underestimate the local prompt size.
471 assert_eq!(estimate_tokens("abcde"), 2);
472 assert_eq!(
473 estimate_tokens("abcdefg"),
474 2, // 7/4 = 1.75 → 2
475 "7-char string must round up to 2 tokens"
476 );
477 }
478
479 #[test]
480 fn estimate_tokens_long_string_scales_linearly() {
481 // 100 chars / 4 = 25 → ceil = 25.
482 let text = "a".repeat(100);
483 assert_eq!(estimate_tokens(&text), 25);
484 }
485
486 #[test]
487 fn estimate_tokens_single_nonempty_returns_at_least_one() {
488 // Defensive floor: any non-empty input must yield >= 1 token so
489 // a 1-char string (which ceil-divides to 0.25 → 1 anyway) and a
490 // 2-char string (which ceil-divides to 0.5 → 1) are consistent.
491 assert_eq!(estimate_tokens("a"), 1);
492 assert_eq!(estimate_tokens("ab"), 1);
493 }
494
495 // ── ContextBreakdown tests ──────────────────────────────────────────
496
497 #[test]
498 fn context_breakdown_default_is_all_zeros() {
499 // All buckets default to 0 so a freshly-built breakdown serialises
500 // cleanly without per-field Option wrappers.
501 let b = ContextBreakdown::default();
502 assert_eq!(b.system_prompt, 0);
503 assert_eq!(b.rules, 0);
504 assert_eq!(b.skills, 0);
505 assert_eq!(b.subagents, 0);
506 assert_eq!(b.tools, 0);
507 assert_eq!(b.mcp_dynamic, 0);
508 assert_eq!(b.conversation, 0);
509 assert_eq!(b.overhead, 0);
510 assert_eq!(b.local_sum(), 0);
511 assert_eq!(b.total(), 0);
512 }
513
514 #[test]
515 fn context_breakdown_local_sum_excludes_overhead() {
516 // local_sum is the seven user-meaningful buckets. Overhead must be
517 // excluded so it can be subtracted by the breakdown-computation
518 // code without double-counting.
519 let b = ContextBreakdown {
520 system_prompt: 10,
521 rules: 20,
522 skills: 30,
523 subagents: 40,
524 tools: 50,
525 mcp_dynamic: 60,
526 conversation: 70,
527 overhead: 9999,
528 };
529 assert_eq!(b.local_sum(), 280);
530 assert_eq!(b.total(), 280 + 9999);
531 }
532
533 #[test]
534 fn context_breakdown_serde_round_trip() {
535 let b = ContextBreakdown {
536 system_prompt: 11,
537 rules: 22,
538 skills: 33,
539 subagents: 44,
540 tools: 55,
541 mcp_dynamic: 66,
542 conversation: 77,
543 overhead: 88,
544 };
545 let json = serde_json::to_string(&b).unwrap();
546 let back: ContextBreakdown = serde_json::from_str(&json).unwrap();
547 assert_eq!(b, back);
548 }
549}