Skip to main content

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}