use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::mpsc;
use crate::message::Message;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamChunk {
Text(String),
Reasoning(String),
}
pub type StreamSender = mpsc::UnboundedSender<StreamChunk>;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextBreakdown {
pub system_prompt: u32,
pub rules: u32,
pub skills: u32,
pub subagents: u32,
pub tools: u32,
pub mcp_dynamic: u32,
pub conversation: u32,
pub overhead: u32,
}
impl ContextBreakdown {
pub fn local_sum(&self) -> u32 {
self.system_prompt
.saturating_add(self.rules)
.saturating_add(self.skills)
.saturating_add(self.subagents)
.saturating_add(self.tools)
.saturating_add(self.mcp_dynamic)
.saturating_add(self.conversation)
}
pub fn total(&self) -> u32 {
self.local_sum().saturating_add(self.overhead)
}
}
pub fn estimate_tokens(text: &str) -> u32 {
let bytes = text.len();
let tokens = (bytes as f64 / 4.0).ceil() as u32;
if tokens == 0 && !text.is_empty() {
1
} else {
tokens
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
pub cache_hit_tokens: u32,
pub cache_miss_tokens: u32,
pub reasoning_tokens: u32,
}
impl TokenUsage {
pub fn accumulate(self, other: TokenUsage) -> TokenUsage {
TokenUsage {
reasoning_tokens: self.reasoning_tokens.saturating_add(other.reasoning_tokens),
prompt_tokens: self.prompt_tokens.saturating_add(other.prompt_tokens),
completion_tokens: self
.completion_tokens
.saturating_add(other.completion_tokens),
total_tokens: self.total_tokens.saturating_add(other.total_tokens),
cache_hit_tokens: self.cache_hit_tokens.saturating_add(other.cache_hit_tokens),
cache_miss_tokens: self
.cache_miss_tokens
.saturating_add(other.cache_miss_tokens),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub parameters: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
pub struct StructuredRequest {
pub messages: Vec<Message>,
pub schema: Value,
pub schema_name: String,
}
#[derive(Debug, Clone, Default)]
pub struct Completion {
pub content: String,
pub tool_calls: Vec<ToolCall>,
pub finish_reason: Option<String>,
pub usage: Option<TokenUsage>,
pub reasoning_content: Option<String>,
}
impl Completion {
pub fn extract_inline_reasoning(&mut self) {
if self
.reasoning_content
.as_deref()
.is_some_and(|r| !r.trim().is_empty())
{
return;
}
let Some((reasoning, cleaned)) = split_think_tags(&self.content) else {
return;
};
if !reasoning.is_empty() {
self.reasoning_content = Some(reasoning);
}
self.content = cleaned;
}
}
fn split_think_tags(content: &str) -> Option<(String, String)> {
const OPEN: &str = "<think>";
const CLOSE: &str = "</think>";
let open_idx = content.find(OPEN)?;
let after_open = open_idx + OPEN.len();
if let Some(rel_close) = content[after_open..].find(CLOSE) {
let close_idx = after_open + rel_close;
let reasoning = content[after_open..close_idx].trim().to_string();
let mut cleaned = String::with_capacity(content.len());
cleaned.push_str(&content[..open_idx]);
cleaned.push_str(&content[close_idx + CLOSE.len()..]);
Some((reasoning, cleaned.trim().to_string()))
} else {
let reasoning = content[after_open..].trim().to_string();
let cleaned = content[..open_idx].trim().to_string();
Some((reasoning, cleaned))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn completion_with_content(content: &str) -> Completion {
Completion {
content: content.to_string(),
..Default::default()
}
}
#[test]
fn estimate_tokens_is_byte_based_documents_cjk_overcount() {
let ascii = "hello world";
let cjk = "你好世界";
assert_eq!(ascii.len(), 11);
assert_eq!(ascii.chars().count(), 11);
assert_eq!(cjk.len(), 12);
assert_eq!(cjk.chars().count(), 4);
assert_eq!(estimate_tokens(ascii), 3);
assert_eq!(estimate_tokens(cjk), 3);
assert_eq!(estimate_tokens(ascii), estimate_tokens(cjk));
}
#[test]
fn extract_inline_reasoning_moves_think_block_to_reasoning() {
let mut c = completion_with_content(
"<think>\nlet me work through this\n</think>\n\nThe answer is 42.",
);
c.extract_inline_reasoning();
assert_eq!(
c.reasoning_content.as_deref(),
Some("let me work through this")
);
assert_eq!(c.content, "The answer is 42.");
}
#[test]
fn extract_inline_reasoning_handles_inline_single_line() {
let mut c = completion_with_content("<think>quick thought</think>answer");
c.extract_inline_reasoning();
assert_eq!(c.reasoning_content.as_deref(), Some("quick thought"));
assert_eq!(c.content, "answer");
}
#[test]
fn extract_inline_reasoning_unclosed_tag_treats_rest_as_reasoning() {
let mut c = completion_with_content("partial answer<think>still thinking");
c.extract_inline_reasoning();
assert_eq!(c.reasoning_content.as_deref(), Some("still thinking"));
assert_eq!(c.content, "partial answer");
}
#[test]
fn extract_inline_reasoning_no_tag_is_noop() {
let mut c = completion_with_content("just a plain answer");
c.extract_inline_reasoning();
assert!(c.reasoning_content.is_none());
assert_eq!(c.content, "just a plain answer");
}
#[test]
fn token_usage_accumulate_sums_all_fields() {
let a = TokenUsage {
reasoning_tokens: 10,
prompt_tokens: 20,
completion_tokens: 30,
total_tokens: 60,
cache_hit_tokens: 5,
cache_miss_tokens: 15,
};
let b = TokenUsage {
reasoning_tokens: 1,
prompt_tokens: 2,
completion_tokens: 3,
total_tokens: 6,
cache_hit_tokens: 4,
cache_miss_tokens: 9,
};
let c = a.accumulate(b);
assert_eq!(c.reasoning_tokens, 11);
assert_eq!(c.prompt_tokens, 22);
assert_eq!(c.completion_tokens, 33);
assert_eq!(c.total_tokens, 66);
assert_eq!(c.cache_hit_tokens, 9);
assert_eq!(c.cache_miss_tokens, 24);
}
#[test]
fn token_usage_accumulate_saturates_on_overflow() {
let big = TokenUsage {
reasoning_tokens: u32::MAX,
prompt_tokens: u32::MAX,
completion_tokens: u32::MAX,
total_tokens: u32::MAX,
cache_hit_tokens: u32::MAX,
cache_miss_tokens: u32::MAX,
};
let one = TokenUsage {
reasoning_tokens: 1,
prompt_tokens: 1,
completion_tokens: 1,
total_tokens: 1,
cache_hit_tokens: 1,
cache_miss_tokens: 1,
};
let result = big.accumulate(one);
assert_eq!(
result.reasoning_tokens,
u32::MAX,
"must saturate, not overflow"
);
assert_eq!(result.prompt_tokens, u32::MAX);
assert_eq!(result.completion_tokens, u32::MAX);
}
#[test]
fn split_think_tags_empty_block_returns_empty_reasoning() {
let result = split_think_tags("<think></think>answer");
assert!(result.is_some(), "empty think block must still parse");
let (reasoning, cleaned) = result.unwrap();
assert!(
reasoning.is_empty(),
"reasoning must be empty for empty tags; got: {reasoning:?}"
);
assert_eq!(cleaned, "answer");
}
#[test]
fn split_think_tags_returns_none_when_no_tag() {
let result = split_think_tags("no think tag here");
assert!(
result.is_none(),
"must return None when no <think> tag present"
);
}
#[test]
fn split_think_tags_preserves_content_before_tag() {
let result = split_think_tags("prefix <think>reason</think>suffix");
assert!(result.is_some());
let (reasoning, cleaned) = result.unwrap();
assert_eq!(reasoning, "reason");
assert!(
cleaned.contains("prefix"),
"content before <think> must be preserved in cleaned: {cleaned:?}"
);
assert!(
cleaned.contains("suffix"),
"content after </think> must be preserved in cleaned: {cleaned:?}"
);
}
#[test]
fn extract_inline_reasoning_preserves_existing_reasoning_field() {
let mut c = Completion {
content: "<think>ignored</think>answer".to_string(),
reasoning_content: Some("real reasoning".to_string()),
..Default::default()
};
c.extract_inline_reasoning();
assert_eq!(c.reasoning_content.as_deref(), Some("real reasoning"));
assert_eq!(c.content, "<think>ignored</think>answer");
}
#[test]
fn estimate_tokens_empty_string_is_zero() {
assert_eq!(estimate_tokens(""), 0);
}
#[test]
fn estimate_tokens_exact_multiple_of_four() {
assert_eq!(estimate_tokens("abcd"), 1);
}
#[test]
fn estimate_tokens_uses_ceil_not_floor() {
assert_eq!(estimate_tokens("abcde"), 2);
assert_eq!(
estimate_tokens("abcdefg"),
2, "7-char string must round up to 2 tokens"
);
}
#[test]
fn estimate_tokens_long_string_scales_linearly() {
let text = "a".repeat(100);
assert_eq!(estimate_tokens(&text), 25);
}
#[test]
fn estimate_tokens_single_nonempty_returns_at_least_one() {
assert_eq!(estimate_tokens("a"), 1);
assert_eq!(estimate_tokens("ab"), 1);
}
#[test]
fn context_breakdown_default_is_all_zeros() {
let b = ContextBreakdown::default();
assert_eq!(b.system_prompt, 0);
assert_eq!(b.rules, 0);
assert_eq!(b.skills, 0);
assert_eq!(b.subagents, 0);
assert_eq!(b.tools, 0);
assert_eq!(b.mcp_dynamic, 0);
assert_eq!(b.conversation, 0);
assert_eq!(b.overhead, 0);
assert_eq!(b.local_sum(), 0);
assert_eq!(b.total(), 0);
}
#[test]
fn context_breakdown_local_sum_excludes_overhead() {
let b = ContextBreakdown {
system_prompt: 10,
rules: 20,
skills: 30,
subagents: 40,
tools: 50,
mcp_dynamic: 60,
conversation: 70,
overhead: 9999,
};
assert_eq!(b.local_sum(), 280);
assert_eq!(b.total(), 280 + 9999);
}
#[test]
fn context_breakdown_serde_round_trip() {
let b = ContextBreakdown {
system_prompt: 11,
rules: 22,
skills: 33,
subagents: 44,
tools: 55,
mcp_dynamic: 66,
conversation: 77,
overhead: 88,
};
let json = serde_json::to_string(&b).unwrap();
let back: ContextBreakdown = serde_json::from_str(&json).unwrap();
assert_eq!(b, back);
}
}