use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Default, Serialize)]
pub struct Usage {
pub uncached_input_tokens: u32,
pub cache_read_tokens: u32,
pub cache_write_tokens: u32,
pub completion_tokens: u32,
pub cost: Option<f64>,
pub upstream_inference_cost: Option<f64>,
pub reasoning_tokens: Option<u32>,
}
impl Usage {
pub fn prompt_tokens(&self) -> u32 {
self.uncached_input_tokens + self.cache_read_tokens + self.cache_write_tokens
}
pub fn total_tokens(&self) -> u32 {
self.prompt_tokens() + self.completion_tokens
}
pub(crate) fn merge_from(&mut self, other: &Usage) {
if other.uncached_input_tokens != 0 {
self.uncached_input_tokens = other.uncached_input_tokens;
}
if other.cache_read_tokens != 0 {
self.cache_read_tokens = other.cache_read_tokens;
}
if other.cache_write_tokens != 0 {
self.cache_write_tokens = other.cache_write_tokens;
}
if other.completion_tokens != 0 {
self.completion_tokens = other.completion_tokens;
}
self.cost = other.cost.or(self.cost);
self.upstream_inference_cost = other
.upstream_inference_cost
.or(self.upstream_inference_cost);
self.reasoning_tokens = other.reasoning_tokens.or(self.reasoning_tokens);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum CostResolution {
#[default]
Resolved,
Unpriced,
Unknown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CostInfo {
pub cost: f64,
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
pub cache_read_tokens: u32,
pub cache_write_tokens: u32,
pub reasoning_tokens: Option<u32>,
pub model: String,
pub response_id: String,
pub resolution: CostResolution,
}
pub type CostCallback = Arc<dyn Fn(CostInfo) + Send + Sync>;
#[derive(Debug, Clone, Serialize)]
pub struct CompletionResponse {
pub id: String,
pub model: String,
pub content: String,
pub finish_reason: Option<String>,
pub usage: Option<Usage>,
pub tool_calls: Option<Vec<serde_json::Value>>,
#[serde(skip)]
pub raw_response: Option<serde_json::Value>,
}
impl CompletionResponse {
pub fn new(
id: impl Into<String>,
model: impl Into<String>,
content: impl Into<String>,
) -> Self {
Self {
id: id.into(),
model: model.into(),
content: content.into(),
finish_reason: None,
usage: None,
tool_calls: None,
raw_response: None,
}
}
pub fn is_complete(&self) -> bool {
self.finish_reason.as_deref() == Some("stop")
}
pub fn is_truncated(&self) -> bool {
self.finish_reason.as_deref() == Some("length")
}
pub fn has_tool_calls(&self) -> bool {
self.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty())
}
}
#[derive(Debug, Clone, Default)]
pub struct StreamChunk {
pub id: Option<String>,
pub delta: String,
pub finish_reason: Option<String>,
pub usage: Option<Usage>,
pub tool_calls: Option<Vec<serde_json::Value>>,
}
impl StreamChunk {
pub fn content(delta: impl Into<String>) -> Self {
Self {
delta: delta.into(),
..Default::default()
}
}
pub fn finished(finish_reason: impl Into<String>) -> Self {
Self {
finish_reason: Some(finish_reason.into()),
..Default::default()
}
}
pub fn is_final(&self) -> bool {
self.finish_reason.is_some()
}
}
pub(crate) fn preview_str(body: &str) -> String {
const MAX: usize = 200;
match body.char_indices().nth(MAX) {
Some((cut, _)) => format!("{}…", &body[..cut]),
None => body.to_string(),
}
}
fn error_object(raw: &serde_json::Value) -> Option<&serde_json::Value> {
raw.get("error")
.filter(|e| e.as_object().is_some_and(|o| !o.is_empty()))
}
fn openai_error_in(raw: &serde_json::Value) -> Option<crate::error::MiniLLMError> {
let error = error_object(raw)?;
let message = error["message"]
.as_str()
.map(String::from)
.unwrap_or_else(|| preview_str(&error.to_string()));
let status = error["code"]
.as_u64()
.filter(|&c| (100..=599).contains(&c))
.map(|c| c as u16)
.unwrap_or(502);
Some(crate::error::MiniLLMError::Api { status, message })
}
fn anthropic_error_in(raw: &serde_json::Value) -> Option<crate::error::MiniLLMError> {
let error = error_object(raw)?;
let message = error["message"]
.as_str()
.map(String::from)
.unwrap_or_else(|| preview_str(&error.to_string()));
Some(crate::error::MiniLLMError::Api {
status: 502,
message,
})
}
pub fn parse_openai_response<P: super::Provider + ?Sized>(
raw: serde_json::Value,
provider: &P,
) -> crate::error::Result<CompletionResponse> {
if let Some(err) = openai_error_in(&raw) {
return Err(err);
}
let id = raw["id"].as_str().unwrap_or("").to_string();
let model = raw["model"].as_str().unwrap_or("").to_string();
let choice = raw["choices"]
.get(0)
.filter(|c| c.get("message").is_some())
.ok_or_else(|| {
crate::error::MiniLLMError::MalformedResponse(preview_str(&raw.to_string()))
})?;
let message = &choice["message"];
let content = message["content"].as_str().unwrap_or("").to_string();
let tool_calls = message["tool_calls"].as_array().cloned();
let finish_reason = choice["finish_reason"].as_str().map(String::from);
let usage = provider.parse_usage(&raw);
Ok(CompletionResponse {
id,
model,
content,
finish_reason,
usage,
tool_calls,
raw_response: Some(raw),
})
}
pub fn accumulate_tool_call_deltas(acc: &mut Vec<serde_json::Value>, deltas: &[serde_json::Value]) {
for delta in deltas {
let Some(index) = delta["index"].as_u64().map(|i| i as usize) else {
tracing::warn!("tool_call delta missing numeric index, skipping");
continue;
};
if index > acc.len() {
tracing::warn!(
index,
len = acc.len(),
"tool_call delta index out of order, skipping"
);
continue;
}
if index == acc.len() {
acc.push(serde_json::json!({}));
}
let slot = &mut acc[index];
for key in ["id", "type"] {
if let Some(v) = delta.get(key).filter(|v| !v.is_null()) {
slot[key] = v.clone();
}
}
if let Some(func) = delta.get("function") {
if !slot["function"].is_object() {
slot["function"] = serde_json::json!({});
}
let slot_func = &mut slot["function"];
if let Some(name) = func.get("name").filter(|v| !v.is_null()) {
slot_func["name"] = name.clone();
}
if let Some(frag) = func.get("arguments").and_then(|v| v.as_str()) {
let existing = slot_func["arguments"].as_str().unwrap_or("").to_string();
slot_func["arguments"] = serde_json::json!(format!("{}{}", existing, frag));
}
}
}
}
pub fn parse_openai_chunk<P: super::Provider + ?Sized>(
data: &str,
provider: &P,
) -> Option<crate::error::Result<StreamChunk>> {
if data.trim() == "[DONE]" {
return Some(Ok(StreamChunk::finished("stop")));
}
let json: serde_json::Value = serde_json::from_str(data).ok()?;
if let Some(err) = openai_error_in(&json) {
return Some(Err(err));
}
let id = json["id"]
.as_str()
.filter(|s| !s.is_empty())
.map(String::from);
let usage = provider.parse_usage(&json);
let choice = json["choices"].get(0);
let delta = choice
.and_then(|c| c["delta"]["content"].as_str())
.unwrap_or("")
.to_string();
let finish_reason = choice
.and_then(|c| c["finish_reason"].as_str())
.filter(|s| !s.is_empty())
.map(String::from);
let tool_calls = choice.and_then(|c| c["delta"]["tool_calls"].as_array().cloned());
if delta.is_empty() && finish_reason.is_none() && usage.is_none() && tool_calls.is_none() {
return None;
}
Some(Ok(StreamChunk {
id,
delta,
finish_reason,
usage,
tool_calls,
}))
}
fn parse_anthropic_usage(u: &serde_json::Value) -> Option<Usage> {
if u.is_null() {
return None;
}
Some(Usage {
uncached_input_tokens: u["input_tokens"].as_u64().unwrap_or(0) as u32,
cache_read_tokens: u["cache_read_input_tokens"].as_u64().unwrap_or(0) as u32,
cache_write_tokens: u["cache_creation_input_tokens"].as_u64().unwrap_or(0) as u32,
completion_tokens: u["output_tokens"].as_u64().unwrap_or(0) as u32,
cost: None,
upstream_inference_cost: None,
reasoning_tokens: None,
})
}
pub fn parse_anthropic_response(
raw: serde_json::Value,
) -> crate::error::Result<CompletionResponse> {
if let Some(err) = anthropic_error_in(&raw) {
return Err(err);
}
let content_blocks = raw["content"].as_array().ok_or_else(|| {
crate::error::MiniLLMError::MalformedResponse(preview_str(&raw.to_string()))
})?;
let mut text = String::new();
let mut tool_calls: Vec<serde_json::Value> = Vec::new();
for block in content_blocks {
match block["type"].as_str() {
Some("text") => text.push_str(block["text"].as_str().unwrap_or("")),
Some("tool_use") => tool_calls.push(serde_json::json!({
"id": block["id"],
"type": "function",
"function": {
"name": block["name"],
"arguments": block["input"].to_string(),
},
})),
_ => {}
}
}
Ok(CompletionResponse {
id: raw["id"].as_str().unwrap_or("").to_string(),
model: raw["model"].as_str().unwrap_or("").to_string(),
content: text,
finish_reason: raw["stop_reason"].as_str().map(String::from),
usage: parse_anthropic_usage(&raw["usage"]),
tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
raw_response: Some(raw),
})
}
pub fn parse_anthropic_chunk(data: &str) -> Option<crate::error::Result<StreamChunk>> {
let json: serde_json::Value = serde_json::from_str(data).ok()?;
match json["type"].as_str()? {
"error" => Some(Err(anthropic_error_in(&json).unwrap_or_else(|| {
crate::error::MiniLLMError::Api {
status: 502,
message: preview_str(&json.to_string()),
}
}))),
"message_start" => {
let msg = &json["message"];
let id = msg["id"]
.as_str()
.filter(|s| !s.is_empty())
.map(String::from);
let usage = parse_anthropic_usage(&msg["usage"]);
(id.is_some() || usage.is_some()).then(|| {
Ok(StreamChunk {
id,
usage,
..Default::default()
})
})
}
"content_block_delta" => {
let delta = json["delta"]["text"].as_str().unwrap_or("").to_string();
(!delta.is_empty()).then(|| {
Ok(StreamChunk {
delta,
..Default::default()
})
})
}
"message_delta" => {
let finish_reason = json["delta"]["stop_reason"].as_str().map(String::from);
let usage = parse_anthropic_usage(&json["usage"]);
(finish_reason.is_some() || usage.is_some()).then(|| {
Ok(StreamChunk {
finish_reason,
usage,
..Default::default()
})
})
}
"message_stop" => Some(Ok(StreamChunk::finished("stop"))),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::{OpenRouterProvider, Provider, TokenPrice};
fn acct() -> OpenRouterProvider {
OpenRouterProvider
}
#[test]
fn parse_response_threads_tool_calls_and_finish_reason() {
let raw = serde_json::json!({
"id": "gen-1",
"model": "test-model",
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": null,
"tool_calls": [{"id": "call_1", "type": "function",
"function": {"name": "get_weather", "arguments": "{}"}}]
}
}]
});
let resp = acct().parse_response(raw).unwrap();
assert_eq!(resp.id, "gen-1");
assert_eq!(resp.content, "");
assert_eq!(resp.finish_reason.as_deref(), Some("tool_calls"));
let tc = resp.tool_calls.expect("tool_calls threaded through");
assert_eq!(tc[0]["function"]["name"], "get_weather");
}
#[test]
fn parse_response_surfaces_200_error_body_loudly() {
let raw = serde_json::json!({
"error": {"message": "model overloaded", "code": 503}
});
let err = acct().parse_response(raw).unwrap_err();
match err {
crate::error::MiniLLMError::Api { status, message } => {
assert_eq!(status, 503);
assert_eq!(message, "model overloaded");
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn parse_response_error_with_string_code_defaults_to_retryable_502() {
let raw = serde_json::json!({
"error": {"message": "slow down", "code": "rate_limit_exceeded"}
});
match acct().parse_response(raw).unwrap_err() {
crate::error::MiniLLMError::Api { status, .. } => assert_eq!(status, 502),
other => panic!("expected Api error, got {other:?}"),
}
let raw = serde_json::json!({ "error": {"message": "x", "code": 999_999} });
match acct().parse_response(raw).unwrap_err() {
crate::error::MiniLLMError::Api { status, .. } => assert_eq!(status, 502),
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn parse_response_rejects_malformed_missing_choices() {
let raw = serde_json::json!({ "id": "gen-1", "model": "m" });
assert!(acct().parse_response(raw).is_err());
}
#[test]
fn openrouter_parses_usage_and_aggregates_byok_cost() {
let raw = serde_json::json!({
"usage": {
"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15,
"cost": 0.001,
"cost_details": {"upstream_inference_cost": 0.009},
"prompt_tokens_details": {"cached_tokens": 4},
"completion_tokens_details": {"reasoning_tokens": 2}
}
});
let usage = acct().parse_usage(&raw).expect("usage parsed");
assert_eq!(usage.prompt_tokens(), 10, "total input = sum of buckets");
assert_eq!(
usage.cache_read_tokens, 4,
"cached_tokens → cache_read bucket"
);
assert_eq!(
usage.uncached_input_tokens, 6,
"10 total − 4 cached = 6 uncached"
);
assert_eq!(usage.upstream_inference_cost, Some(0.009));
assert_eq!(usage.reasoning_tokens, Some(2));
let outcome = acct().cost_of(usage, None);
assert_eq!(outcome.resolution, CostResolution::Resolved);
assert!((outcome.usd - 0.010).abs() < 1e-9);
}
#[test]
fn openai_wire_splits_cache_read_as_subset_and_cache_write_as_additive() {
let raw = serde_json::json!({
"usage": {
"prompt_tokens": 10000,
"completion_tokens": 100,
"prompt_tokens_details": {
"cached_tokens": 2000,
"cache_write_tokens": 5000
}
}
});
let usage = acct().parse_usage(&raw).expect("usage parsed");
assert_eq!(usage.cache_read_tokens, 2000);
assert_eq!(
usage.cache_write_tokens, 5000,
"write read from cache_write_tokens"
);
assert_eq!(
usage.uncached_input_tokens, 8000,
"subtract only the cache-read subset (10000 − 2000), NOT the write"
);
assert_eq!(
usage.prompt_tokens(),
15000,
"writes are additive, so total input exceeds prompt_tokens"
);
let price = TokenPrice::new(3.0, 15.0).with_cache_rates(0.3, 3.75);
let usd = price.cost_of(&usage);
assert!((usd - 0.04485).abs() < 1e-9, "got {usd}");
}
#[test]
fn openai_wire_cached_exceeding_prompt_reports_unknown_not_a_fabricated_split() {
let raw = serde_json::json!({
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"prompt_tokens_details": {"cached_tokens": 15}
}
});
assert!(
acct().parse_usage(&raw).is_none(),
"cached > prompt must yield no usage (Unknown cost), not a clamped split"
);
let raw = serde_json::json!({
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"prompt_tokens_details": {"cached_tokens": 10}
}
});
let usage = acct().parse_usage(&raw).expect("cached == prompt is valid");
assert_eq!(usage.uncached_input_tokens, 0);
assert_eq!(usage.cache_read_tokens, 10);
}
#[test]
fn error_object_ignores_benign_falsy_error_fields() {
for benign in [
serde_json::json!({"error": null}),
serde_json::json!({"error": {}}),
serde_json::json!({"error": false}),
serde_json::json!({"error": 0}),
serde_json::json!({"error": ""}),
serde_json::json!({"error": "some string"}),
serde_json::json!({"error": ["a", "b"]}),
serde_json::json!({"id": "gen-1"}),
] {
assert!(
openai_error_in(&benign).is_none(),
"benign error field must not be an error: {benign}"
);
assert!(anthropic_error_in(&benign).is_none());
}
let real = serde_json::json!({"error": {"message": "boom"}});
assert!(openai_error_in(&real).is_some());
assert!(anthropic_error_in(&real).is_some());
}
#[test]
fn accumulate_tool_call_deltas_merges_by_index() {
let mut acc = Vec::new();
accumulate_tool_call_deltas(
&mut acc,
&[serde_json::json!({
"index": 0, "id": "call_1", "type": "function",
"function": {"name": "search", "arguments": "{\"q\":"}
})],
);
accumulate_tool_call_deltas(
&mut acc,
&[serde_json::json!({
"index": 0, "function": {"arguments": "\"rust\"}"}
})],
);
assert_eq!(acc.len(), 1);
assert_eq!(acc[0]["id"], "call_1");
assert_eq!(acc[0]["function"]["name"], "search");
assert_eq!(acc[0]["function"]["arguments"], "{\"q\":\"rust\"}");
}
#[test]
fn accumulate_tool_call_deltas_appends_two_distinct_calls() {
let mut acc = Vec::new();
accumulate_tool_call_deltas(
&mut acc,
&[
serde_json::json!({"index": 0, "id": "c0", "function": {"name": "a"}}),
serde_json::json!({"index": 1, "id": "c1", "function": {"name": "b"}}),
],
);
assert_eq!(acc.len(), 2);
assert_eq!(acc[0]["id"], "c0");
assert_eq!(acc[1]["id"], "c1");
}
#[test]
fn accumulate_tool_call_deltas_rejects_out_of_range_and_missing_index() {
let mut acc = Vec::new();
accumulate_tool_call_deltas(
&mut acc,
&[serde_json::json!({"index": 4_000_000_000u64, "id": "x"})],
);
assert!(acc.is_empty(), "out-of-order high index must be skipped");
accumulate_tool_call_deltas(&mut acc, &[serde_json::json!({"id": "y"})]);
assert!(
acc.is_empty(),
"missing index must be skipped, not coerced to 0"
);
}
#[test]
fn parse_stream_chunk_done_marker() {
let chunk = acct().parse_chunk("[DONE]").unwrap().unwrap();
assert_eq!(chunk.finish_reason.as_deref(), Some("stop"));
}
#[test]
fn parse_stream_chunk_extracts_real_generation_id() {
let chunk = acct()
.parse_chunk(r#"{"id":"gen-abc","choices":[{"delta":{"content":"hi"}}]}"#)
.unwrap()
.unwrap();
assert_eq!(chunk.id.as_deref(), Some("gen-abc"));
assert_eq!(chunk.delta, "hi");
}
#[test]
fn openai_in_band_error_chunk_surfaces_as_err() {
let out = acct()
.parse_chunk(r#"{"error":{"message":"overloaded","code":503}}"#)
.expect("error frame must produce Some(Err), not None");
match out {
Err(crate::error::MiniLLMError::Api { status, message }) => {
assert_eq!(status, 503);
assert_eq!(message, "overloaded");
}
other => panic!("expected Some(Err(Api)), got {other:?}"),
}
}
#[test]
fn anthropic_response_joins_text_blocks_and_parses_usage() {
let raw = serde_json::json!({
"id": "msg_1",
"model": "claude-haiku-4-5",
"content": [{"type": "text", "text": "Hello "}, {"type": "text", "text": "world"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 9, "output_tokens": 4, "cache_read_input_tokens": 2}
});
let resp = parse_anthropic_response(raw).unwrap();
assert_eq!(resp.id, "msg_1");
assert_eq!(resp.content, "Hello world");
assert_eq!(resp.finish_reason.as_deref(), Some("end_turn"));
let u = resp.usage.expect("usage parsed");
assert_eq!(u.uncached_input_tokens, 9);
assert_eq!(u.cache_read_tokens, 2);
assert_eq!(u.cache_write_tokens, 0);
assert_eq!(
u.prompt_tokens(),
11,
"total input = 9 uncached + 2 cache-read"
);
assert_eq!(u.completion_tokens, 4);
assert_eq!(u.total_tokens(), 15);
assert!(u.cost.is_none(), "Anthropic never returns a dollar cost");
}
#[test]
fn anthropic_response_threads_tool_use_blocks() {
let raw = serde_json::json!({
"id": "msg_2", "model": "m",
"content": [
{"type": "text", "text": "calling"},
{"type": "tool_use", "id": "tu_1", "name": "get_weather",
"input": {"city": "Paris"}}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 5, "output_tokens": 2}
});
let resp = parse_anthropic_response(raw).unwrap();
assert_eq!(resp.content, "calling");
let tc = resp.tool_calls.expect("tool_use threaded");
assert_eq!(tc[0]["function"]["name"], "get_weather");
assert_eq!(tc[0]["function"]["arguments"], r#"{"city":"Paris"}"#);
}
#[test]
fn anthropic_response_surfaces_error_body_loudly() {
let raw = serde_json::json!({"type": "error",
"error": {"type": "overloaded_error", "message": "overloaded"}});
match parse_anthropic_response(raw).unwrap_err() {
crate::error::MiniLLMError::Api { message, .. } => assert_eq!(message, "overloaded"),
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn anthropic_response_rejects_missing_content() {
let raw = serde_json::json!({"id": "x", "model": "m"});
assert!(parse_anthropic_response(raw).is_err());
}
#[test]
fn anthropic_chunk_message_start_carries_id_and_input_usage() {
let c = parse_anthropic_chunk(
r#"{"type":"message_start","message":{"id":"msg_9","usage":{"input_tokens":15,"output_tokens":1}}}"#,
)
.unwrap()
.unwrap();
assert_eq!(c.id.as_deref(), Some("msg_9"));
assert_eq!(c.usage.as_ref().unwrap().uncached_input_tokens, 15);
}
#[test]
fn anthropic_chunk_content_delta_carries_text() {
let c = parse_anthropic_chunk(
r#"{"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}"#,
)
.unwrap()
.unwrap();
assert_eq!(c.delta, "hi");
assert!(parse_anthropic_chunk(r#"{"type":"content_block_start"}"#).is_none());
assert!(parse_anthropic_chunk(r#"{"type":"ping"}"#).is_none());
}
#[test]
fn anthropic_chunk_message_delta_carries_stop_and_output_usage() {
let c = parse_anthropic_chunk(
r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":9}}"#,
)
.unwrap()
.unwrap();
assert_eq!(c.finish_reason.as_deref(), Some("end_turn"));
assert_eq!(c.usage.as_ref().unwrap().completion_tokens, 9);
let stop = parse_anthropic_chunk(r#"{"type":"message_stop"}"#)
.unwrap()
.unwrap();
assert_eq!(stop.finish_reason.as_deref(), Some("stop"));
}
#[test]
fn anthropic_in_band_error_event_surfaces_as_err() {
let out = parse_anthropic_chunk(
r#"{"type":"error","error":{"type":"overloaded_error","message":"overloaded"}}"#,
)
.expect("error event must produce Some(Err), not None");
match out {
Err(crate::error::MiniLLMError::Api { message, .. }) => {
assert_eq!(message, "overloaded")
}
other => panic!("expected Some(Err(Api)), got {other:?}"),
}
}
#[test]
fn usage_merge_accumulates_split_input_and_output() {
let mut acc = Usage {
uncached_input_tokens: 15,
completion_tokens: 1,
..Default::default()
};
let delta = Usage {
uncached_input_tokens: 0,
completion_tokens: 9,
..Default::default()
};
acc.merge_from(&delta);
assert_eq!(
acc.uncached_input_tokens, 15,
"input from message_start preserved"
);
assert_eq!(
acc.completion_tokens, 9,
"output from message_delta applied"
);
assert_eq!(
acc.total_tokens(),
24,
"total recomputed from merged buckets"
);
}
}