use crate::tools::{ToolCall, ToolCallDelta};
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<ToolCall>>,
#[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<ToolCallDelta>>,
}
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()
.map(|entries| parse_openai_tool_calls(entries))
.transpose()?;
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),
})
}
fn parse_openai_tool_calls(entries: &[serde_json::Value]) -> crate::error::Result<Vec<ToolCall>> {
entries
.iter()
.map(|entry| {
let id = entry["id"].as_str();
let name = entry["function"]["name"].as_str();
let arguments = entry["function"]["arguments"].as_str();
match (id, name) {
(Some(id), Some(name)) => {
Ok(ToolCall::new(id, name, arguments.unwrap_or_default()))
}
_ => Err(crate::error::MiniLLMError::MalformedResponse(format!(
"tool_calls entry missing id or function.name: {}",
preview_str(&entry.to_string())
))),
}
})
.collect()
}
fn parse_openai_tool_call_deltas(entries: &[serde_json::Value]) -> Vec<ToolCallDelta> {
entries
.iter()
.filter_map(|entry| {
let Some(index) = entry["index"].as_u64() else {
tracing::warn!("tool_call delta missing numeric index, skipping");
return None;
};
Some(ToolCallDelta {
index,
id: entry["id"].as_str().map(String::from),
name: entry["function"]["name"].as_str().map(String::from),
arguments_fragment: entry["function"]["arguments"].as_str().map(String::from),
})
})
.collect()
}
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())
.map(|entries| parse_openai_tool_call_deltas(entries))
.filter(|deltas| !deltas.is_empty());
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<ToolCall> = 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") => {
let (id, name) = (block["id"].as_str(), block["name"].as_str());
let (Some(id), Some(name)) = (id, name) else {
return Err(crate::error::MiniLLMError::MalformedResponse(format!(
"tool_use block missing id or name: {}",
preview_str(&block.to_string())
)));
};
tool_calls.push(ToolCall::new(id, name, 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_start" => {
let block = &json["content_block"];
if block["type"].as_str() != Some("tool_use") {
return None;
}
let index = json["index"].as_u64()?;
Some(Ok(StreamChunk {
tool_calls: Some(vec![ToolCallDelta {
index,
id: block["id"].as_str().map(String::from),
name: block["name"].as_str().map(String::from),
arguments_fragment: None,
}]),
..Default::default()
}))
}
"content_block_delta" => match json["delta"]["type"].as_str() {
Some("input_json_delta") => {
let index = json["index"].as_u64()?;
let frag = json["delta"]["partial_json"].as_str().unwrap_or("");
(!frag.is_empty()).then(|| {
Ok(StreamChunk {
tool_calls: Some(vec![ToolCallDelta {
index,
arguments_fragment: Some(frag.to_string()),
..Default::default()
}]),
..Default::default()
})
})
}
_ => {
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].id, "call_1");
assert_eq!(tc[0].name, "get_weather");
assert_eq!(tc[0].arguments, "{}");
}
#[test]
fn parse_response_rejects_malformed_tool_call_entry() {
let raw = serde_json::json!({
"id": "gen-1", "model": "m",
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": null,
"tool_calls": [{"type": "function", "function": {"arguments": "{}"}}]
}
}]
});
assert!(acct().parse_response(raw).is_err());
}
#[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 parse_stream_chunk_extracts_typed_tool_call_deltas() {
let c = acct()
.parse_chunk(
r#"{"id":"gen-1","choices":[{"delta":{"tool_calls":[
{"index":0,"id":"c0","type":"function",
"function":{"name":"search","arguments":"{\"q\":"}},
{"function":{"arguments":"ignored, no index"}}
]}}]}"#,
)
.unwrap()
.unwrap();
let deltas = c.tool_calls.expect("tool call deltas parsed");
assert_eq!(deltas.len(), 1, "index-less delta skipped");
assert_eq!(deltas[0].index, 0);
assert_eq!(deltas[0].id.as_deref(), Some("c0"));
assert_eq!(deltas[0].name.as_deref(), Some("search"));
assert_eq!(deltas[0].arguments_fragment.as_deref(), Some("{\"q\":"));
}
#[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].id, "tu_1");
assert_eq!(tc[0].name, "get_weather");
assert_eq!(tc[0].arguments, r#"{"city":"Paris"}"#);
}
#[test]
fn anthropic_chunk_tool_use_block_start_and_json_deltas() {
let start = parse_anthropic_chunk(
r#"{"type":"content_block_start","index":1,
"content_block":{"type":"tool_use","id":"tu_1","name":"get_weather","input":{}}}"#,
)
.unwrap()
.unwrap();
let d = start.tool_calls.expect("tool_use start mapped");
assert_eq!(d[0].index, 1);
assert_eq!(d[0].id.as_deref(), Some("tu_1"));
assert_eq!(d[0].name.as_deref(), Some("get_weather"));
let frag = parse_anthropic_chunk(
r#"{"type":"content_block_delta","index":1,
"delta":{"type":"input_json_delta","partial_json":"{\"city\":"}}"#,
)
.unwrap()
.unwrap();
let d = frag.tool_calls.expect("json delta mapped");
assert_eq!(d[0].index, 1);
assert_eq!(d[0].arguments_fragment.as_deref(), Some("{\"city\":"));
assert!(parse_anthropic_chunk(
r#"{"type":"content_block_start","index":0,
"content_block":{"type":"text","text":""}}"#
)
.is_none());
}
#[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"
);
}
}