Merge remote-tracking branch 'origin/pr/584'

This commit is contained in:
fawney19
2026-05-28 12:16:51 +08:00
9 changed files with 274 additions and 64 deletions
@@ -994,6 +994,7 @@ impl GrokClientStreamEmitter {
fn grok_canonical_usage(usage: GrokUsageEstimate) -> StreamingCanonicalUsage {
StreamingCanonicalUsage {
input_tokens: usage.input_tokens,
input_tokens_include_cache: false,
output_tokens: usage.output_tokens,
total_tokens: usage.input_tokens.saturating_add(usage.output_tokens),
cache_creation_tokens: 0,
@@ -928,6 +928,7 @@ impl ClaudeClientEmitter {
}
fn merge_claude_usage(mut current: CanonicalUsage, next: CanonicalUsage) -> CanonicalUsage {
current.input_tokens_include_cache |= next.input_tokens_include_cache;
if next.input_tokens > 0 {
current.input_tokens = next.input_tokens;
}
@@ -949,11 +950,15 @@ fn merge_claude_usage(mut current: CanonicalUsage, next: CanonicalUsage) -> Cano
if next.reasoning_tokens > 0 {
current.reasoning_tokens = next.reasoning_tokens;
}
current.total_tokens = current
.input_tokens
.saturating_add(current.output_tokens)
.saturating_add(current.cache_creation_tokens)
let cache_input_tokens = current
.cache_creation_tokens
.saturating_add(current.cache_read_tokens);
let input_tokens = if current.input_tokens_include_cache {
current.input_tokens
} else {
current.input_tokens.saturating_add(cache_input_tokens)
};
current.total_tokens = input_tokens.saturating_add(current.output_tokens);
current
}
@@ -1204,6 +1209,23 @@ mod tests {
)));
}
#[test]
fn merge_claude_usage_preserves_inclusive_input_semantics() {
let merged = super::merge_claude_usage(
CanonicalUsage::default(),
CanonicalUsage {
input_tokens: 110_161,
input_tokens_include_cache: true,
output_tokens: 691,
cache_read_tokens: 107_008,
..CanonicalUsage::default()
},
);
assert!(merged.input_tokens_include_cache);
assert_eq!(merged.total_tokens, 110_852);
}
#[test]
fn claude_client_emitter_preserves_tool_identity_and_emits_thinking_blocks() {
let mut emitter = ClaudeClientEmitter::default();
@@ -1394,10 +1416,39 @@ mod tests {
.expect("finish should encode");
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"input_tokens\":10"));
assert!(sse.contains("\"cache_creation_input_tokens\":5"));
assert!(sse.contains("\"cache_read_input_tokens\":4"));
}
#[test]
fn claude_client_emitter_subtracts_cache_when_input_tokens_include_cache() {
let mut emitter = ClaudeClientEmitter::default();
let bytes = emitter
.emit(CanonicalStreamFrame {
id: "msg_cache".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("stop".to_string()),
usage: Some(CanonicalUsage {
input_tokens: 110_161,
input_tokens_include_cache: true,
output_tokens: 691,
total_tokens: 110_852,
cache_read_tokens: 107_008,
reasoning_tokens: 516,
..CanonicalUsage::default()
}),
},
})
.expect("finish should encode");
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"input_tokens\":3153"));
assert!(sse.contains("\"cache_read_input_tokens\":107008"));
assert!(sse.contains("\"output_tokens\":691"));
}
#[test]
fn claude_client_emitter_emits_image_blocks_for_media_parts() {
let mut emitter = ClaudeClientEmitter::default();
@@ -3,10 +3,11 @@ use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
protocol::canonical::{
canonical_extension_object_mut, gemini_extensions, gemini_part_to_canonical_block,
gemini_stop_reason_to_canonical, gemini_usage_to_canonical, CanonicalContentBlock,
CanonicalResponse, CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
CanonicalUsage,
canonical_extension_object_mut, canonical_usage_total_input_tokens,
canonical_usage_total_tokens_for_inclusive_input, gemini_extensions,
gemini_part_to_canonical_block, gemini_stop_reason_to_canonical, gemini_usage_to_canonical,
CanonicalContentBlock, CanonicalResponse, CanonicalResponseOutput, CanonicalRole,
CanonicalStopReason, CanonicalUsage,
},
};
@@ -359,19 +360,26 @@ fn canonical_stop_reason_to_gemini(reason: Option<&CanonicalStopReason>) -> Valu
}
fn canonical_usage_to_gemini_usage_metadata(usage: &CanonicalUsage) -> Value {
let input_tokens = canonical_usage_total_input_tokens(usage);
let mut out = Map::new();
out.insert(
"promptTokenCount".to_string(),
Value::from(usage.input_tokens),
);
out.insert("promptTokenCount".to_string(), Value::from(input_tokens));
out.insert(
"candidatesTokenCount".to_string(),
Value::from(usage.output_tokens.saturating_sub(usage.reasoning_tokens)),
);
out.insert(
"totalTokenCount".to_string(),
Value::from(usage.total_tokens),
Value::from(canonical_usage_total_tokens_for_inclusive_input(
usage,
input_tokens,
)),
);
if usage.cache_read_tokens > 0 {
out.insert(
"cachedContentTokenCount".to_string(),
Value::from(usage.cache_read_tokens),
);
}
if usage.reasoning_tokens > 0 {
out.insert(
"thoughtsTokenCount".to_string(),
@@ -873,10 +873,11 @@ mod tests {
}
}],
"usageMetadata": {
"promptTokenCount": 1,
"promptTokenCount": 5,
"cachedContentTokenCount": 4,
"candidatesTokenCount": 2,
"thoughtsTokenCount": 4,
"totalTokenCount": 7
"totalTokenCount": 11
}
})),
)
@@ -904,10 +905,12 @@ mod tests {
frame.event,
CanonicalStreamEvent::Finish {
usage: Some(CanonicalUsage {
input_tokens: 1,
input_tokens: 5,
input_tokens_include_cache: true,
output_tokens: 6,
cache_read_tokens: 4,
reasoning_tokens: 4,
total_tokens: 7,
total_tokens: 11,
..
}),
..
@@ -994,9 +997,11 @@ mod tests {
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"thought\":true"));
assert!(sse.contains("\"thoughtSignature\":\"sig_123\""));
assert!(sse.contains("\"promptTokenCount\":6"));
assert!(sse.contains("\"thoughtsTokenCount\":1"));
assert!(sse.contains("\"candidatesTokenCount\":2"));
assert!(sse.contains("\"cachedContentTokenCount\":5"));
assert!(sse.contains("\"totalTokenCount\":9"));
assert!(sse.contains("\"finishReason\":\"STOP\""));
}
@@ -2824,6 +2824,7 @@ mod tests {
.expect("usage should parse");
assert_eq!(usage.input_tokens, 20_435);
assert!(usage.input_tokens_include_cache);
assert_eq!(usage.output_tokens, 177);
assert_eq!(usage.cache_read_tokens, 19_840);
assert_eq!(usage.reasoning_tokens, 7);
@@ -3734,6 +3735,7 @@ mod tests {
finish_reason: Some("stop".to_string()),
usage: Some(CanonicalUsage {
input_tokens: 1,
input_tokens_include_cache: true,
output_tokens: 2,
total_tokens: 3,
cache_creation_tokens: 5,
@@ -99,6 +99,7 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
}
Some(CanonicalUsage {
input_tokens,
input_tokens_include_cache: cache_read_tokens > 0 || cache_creation_tokens > 0,
output_tokens,
total_tokens,
cache_creation_tokens,
@@ -247,6 +248,7 @@ pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<Canoni
.unwrap_or(0);
Some(CanonicalUsage {
input_tokens,
input_tokens_include_cache: false,
output_tokens,
total_tokens: input_tokens
.saturating_add(output_tokens)
@@ -314,22 +316,27 @@ pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<Canoni
let usage = value?.as_object()?;
let input_tokens = usage
.get("promptTokenCount")
.or_else(|| usage.get("prompt_token_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.get("candidatesTokenCount")
.or_else(|| usage.get("candidates_token_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
let reasoning_tokens = usage
.get("thoughtsTokenCount")
.or_else(|| usage.get("thoughts_token_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
let cache_read_tokens = usage
.get("cachedContentTokenCount")
.or_else(|| usage.get("cached_content_token_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.get("totalTokenCount")
.or_else(|| usage.get("total_token_count"))
.and_then(Value::as_u64)
.unwrap_or(
input_tokens
@@ -338,6 +345,7 @@ pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<Canoni
);
Some(CanonicalUsage {
input_tokens,
input_tokens_include_cache: cache_read_tokens > 0,
output_tokens: output_tokens.saturating_add(reasoning_tokens),
total_tokens,
cache_read_tokens,
@@ -490,12 +498,13 @@ pub fn build_openai_chat_usage_chunk_from_usage(
model: &str,
usage: &CanonicalUsage,
) -> Value {
let input_tokens = inclusive_input_tokens_from_usage(usage);
build_openai_chat_usage_chunk_with_cache(
id,
model,
usage.input_tokens,
input_tokens,
usage.output_tokens,
usage.total_tokens,
inclusive_total_tokens_from_usage(usage, input_tokens),
usage.reasoning_tokens,
cache_creation_tokens_for_usage(usage),
usage.cache_read_tokens,
@@ -504,12 +513,16 @@ pub fn build_openai_chat_usage_chunk_from_usage(
pub fn openai_responses_usage_from_usage(usage: &CanonicalUsage) -> Value {
let mut output = Map::new();
output.insert("input_tokens".to_string(), Value::from(usage.input_tokens));
let input_tokens = inclusive_input_tokens_from_usage(usage);
output.insert("input_tokens".to_string(), Value::from(input_tokens));
output.insert(
"output_tokens".to_string(),
Value::from(usage.output_tokens),
);
output.insert("total_tokens".to_string(), Value::from(usage.total_tokens));
output.insert(
"total_tokens".to_string(),
Value::from(inclusive_total_tokens_from_usage(usage, input_tokens)),
);
if usage.reasoning_tokens > 0 {
output.insert(
"output_tokens_details".to_string(),
@@ -527,7 +540,10 @@ pub fn openai_responses_usage_from_usage(usage: &CanonicalUsage) -> Value {
pub fn claude_usage_from_usage(usage: &CanonicalUsage) -> Value {
let mut output = Map::new();
output.insert("input_tokens".to_string(), Value::from(usage.input_tokens));
output.insert(
"input_tokens".to_string(),
Value::from(claude_input_tokens_from_usage(usage)),
);
output.insert(
"output_tokens".to_string(),
Value::from(usage.output_tokens),
@@ -560,18 +576,16 @@ pub fn claude_usage_from_usage(usage: &CanonicalUsage) -> Value {
pub fn gemini_usage_metadata_from_usage(usage: &CanonicalUsage) -> Value {
let visible_output_tokens = usage.output_tokens.saturating_sub(usage.reasoning_tokens);
let input_tokens = inclusive_input_tokens_from_usage(usage);
let mut output = Map::new();
output.insert(
"promptTokenCount".to_string(),
Value::from(usage.input_tokens),
);
output.insert("promptTokenCount".to_string(), Value::from(input_tokens));
output.insert(
"candidatesTokenCount".to_string(),
Value::from(visible_output_tokens),
);
output.insert(
"totalTokenCount".to_string(),
Value::from(usage.total_tokens),
Value::from(inclusive_total_tokens_from_usage(usage, input_tokens)),
);
if usage.reasoning_tokens > 0 {
output.insert(
@@ -649,3 +663,39 @@ fn cache_creation_tokens_for_usage(usage: &CanonicalUsage) -> u64 {
.saturating_add(usage.cache_creation_ephemeral_1h_tokens)
}
}
fn cache_input_tokens_for_usage(usage: &CanonicalUsage) -> u64 {
usage
.cache_read_tokens
.saturating_add(cache_creation_tokens_for_usage(usage))
}
fn claude_input_tokens_from_usage(usage: &CanonicalUsage) -> u64 {
if usage.input_tokens_include_cache {
usage
.input_tokens
.saturating_sub(cache_input_tokens_for_usage(usage))
} else {
usage.input_tokens
}
}
fn inclusive_input_tokens_from_usage(usage: &CanonicalUsage) -> u64 {
if usage.input_tokens_include_cache {
usage.input_tokens
} else {
usage
.input_tokens
.saturating_add(cache_input_tokens_for_usage(usage))
}
}
fn inclusive_total_tokens_from_usage(usage: &CanonicalUsage, input_tokens: u64) -> u64 {
if usage.total_tokens > 0
&& (usage.input_tokens_include_cache || cache_input_tokens_for_usage(usage) == 0)
{
usage.total_tokens
} else {
input_tokens.saturating_add(usage.output_tokens)
}
}
@@ -26,8 +26,9 @@ use crate::formats::shared::response::{
sanitize_claude_read_tool_inputs,
};
use crate::formats::shared::stream_core::common::{
content_part_from_openai_image_generation_item, map_openai_finish_reason_to_gemini,
parse_json_arguments_value, CanonicalContentPart, CanonicalStreamEvent, CanonicalUsage,
content_part_from_openai_image_generation_item, gemini_usage_metadata_from_usage,
map_openai_finish_reason_to_gemini, parse_json_arguments_value, CanonicalContentPart,
CanonicalStreamEvent, CanonicalUsage,
};
#[derive(Clone, Debug, PartialEq)]
@@ -3044,26 +3045,7 @@ fn gemini_sync_part_from_canonical_content_part(part: CanonicalContentPart) -> V
}
fn gemini_usage_metadata_from_canonical(usage: CanonicalUsage) -> Value {
let mut usage_metadata = Map::new();
usage_metadata.insert(
"promptTokenCount".to_string(),
Value::from(usage.input_tokens),
);
usage_metadata.insert(
"candidatesTokenCount".to_string(),
Value::from(usage.output_tokens.saturating_sub(usage.reasoning_tokens)),
);
usage_metadata.insert(
"totalTokenCount".to_string(),
Value::from(usage.total_tokens),
);
if usage.reasoning_tokens > 0 {
usage_metadata.insert(
"thoughtsTokenCount".to_string(),
Value::from(usage.reasoning_tokens),
);
}
Value::Object(usage_metadata)
gemini_usage_metadata_from_usage(&usage)
}
fn parse_data_url(value: &str) -> Option<(String, String)> {
@@ -4213,6 +4195,9 @@ mod tests {
"prompt_tokens": 2,
"completion_tokens": 4,
"total_tokens": 6,
"prompt_tokens_details": {
"cached_tokens": 1
},
"completion_tokens_details": {
"reasoning_tokens": 1
}
@@ -4230,6 +4215,9 @@ mod tests {
)
.expect("canonical openai chat -> claude");
assert_eq!(converted_claude, legacy_claude);
assert_eq!(converted_claude["usage"]["input_tokens"], 1);
assert_eq!(converted_claude["usage"]["cache_read_input_tokens"], 1);
assert_eq!(converted_claude["usage"]["output_tokens"], 4);
let legacy_gemini =
convert_openai_chat_response_to_gemini_chat(&provider_body_json, &report_context)
@@ -4341,6 +4329,9 @@ mod tests {
converted_claude["content"][3]["content"],
json!({"ok": true})
);
assert_eq!(converted_claude["usage"]["input_tokens"], 1);
assert_eq!(converted_claude["usage"]["cache_read_input_tokens"], 2);
assert_eq!(converted_claude["usage"]["output_tokens"], 5);
let converted_gemini = convert_standard_chat_response(
&provider_body_json,
@@ -209,6 +209,10 @@ pub struct CanonicalResponseFormat {
pub struct CanonicalUsage {
#[serde(default)]
pub input_tokens: u64,
/// True when `input_tokens` already includes cache read and cache creation
/// input tokens. Claude-style usage leaves cached input tokens separate.
#[serde(default, skip_serializing_if = "is_false")]
pub input_tokens_include_cache: bool,
#[serde(default)]
pub output_tokens: u64,
#[serde(default)]
@@ -227,6 +231,10 @@ pub struct CanonicalUsage {
pub extensions: BTreeMap<String, Value>,
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CanonicalEmbeddingInput {
@@ -4378,11 +4386,12 @@ pub(crate) fn openai_usage_to_canonical(value: Option<&Value>) -> Option<Canonic
.unwrap_or(0);
Some(CanonicalUsage {
input_tokens,
input_tokens_include_cache: cache_read_tokens > 0 || cache_write_tokens > 0,
output_tokens,
total_tokens: usage
.get("total_tokens")
.and_then(Value::as_u64)
.unwrap_or(input_tokens + output_tokens),
.unwrap_or(input_tokens.saturating_add(output_tokens)),
cache_read_tokens,
cache_write_tokens,
reasoning_tokens,
@@ -4414,8 +4423,9 @@ pub(crate) fn claude_usage_to_canonical(value: Option<&Value>) -> Option<Canonic
.unwrap_or(0);
Some(CanonicalUsage {
input_tokens,
input_tokens_include_cache: false,
output_tokens,
total_tokens: input_tokens + output_tokens,
total_tokens: input_tokens.saturating_add(output_tokens),
cache_read_tokens,
cache_write_tokens,
cache_creation_ephemeral_5m_tokens: usage
@@ -4461,21 +4471,30 @@ pub(crate) fn gemini_usage_to_canonical(value: Option<&Value>) -> Option<Canonic
.or_else(|| usage.get("thoughts_token_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
let cache_read_tokens = usage
.get("cachedContentTokenCount")
.or_else(|| usage.get("cached_content_token_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = visible_output_tokens + reasoning_tokens;
Some(CanonicalUsage {
input_tokens,
input_tokens_include_cache: cache_read_tokens > 0,
output_tokens,
total_tokens: usage
.get("totalTokenCount")
.or_else(|| usage.get("total_token_count"))
.and_then(Value::as_u64)
.unwrap_or(input_tokens + output_tokens),
cache_read_tokens,
reasoning_tokens,
extensions: gemini_extensions(
usage,
&[
"promptTokenCount",
"prompt_token_count",
"cachedContentTokenCount",
"cached_content_token_count",
"candidatesTokenCount",
"candidates_token_count",
"thoughtsTokenCount",
@@ -4489,14 +4508,12 @@ pub(crate) fn gemini_usage_to_canonical(value: Option<&Value>) -> Option<Canonic
}
pub(crate) fn canonical_usage_to_openai(value: &CanonicalUsage) -> Value {
let input_tokens = canonical_usage_total_input_tokens(value);
let total_tokens = canonical_usage_total_tokens_for_inclusive_input(value, input_tokens);
let mut output = json!({
"prompt_tokens": value.input_tokens,
"prompt_tokens": input_tokens,
"completion_tokens": value.output_tokens,
"total_tokens": if value.total_tokens > 0 {
value.total_tokens
} else {
value.input_tokens + value.output_tokens
},
"total_tokens": total_tokens,
});
if value.reasoning_tokens > 0 {
output["completion_tokens_details"] = json!({
@@ -4519,14 +4536,12 @@ pub(crate) fn canonical_usage_to_openai(value: &CanonicalUsage) -> Value {
}
pub(crate) fn canonical_usage_to_openai_responses_usage(value: &CanonicalUsage) -> Value {
let input_tokens = canonical_usage_total_input_tokens(value);
let total_tokens = canonical_usage_total_tokens_for_inclusive_input(value, input_tokens);
let mut output = json!({
"input_tokens": value.input_tokens,
"input_tokens": input_tokens,
"output_tokens": value.output_tokens,
"total_tokens": if value.total_tokens > 0 {
value.total_tokens
} else {
value.input_tokens + value.output_tokens
},
"total_tokens": total_tokens,
});
if value.reasoning_tokens > 0 {
output["output_tokens_details"] = json!({
@@ -4550,7 +4565,7 @@ pub(crate) fn canonical_usage_to_openai_responses_usage(value: &CanonicalUsage)
pub(crate) fn canonical_usage_to_claude(value: &CanonicalUsage) -> Value {
let mut output = json!({
"input_tokens": value.input_tokens,
"input_tokens": canonical_usage_uncached_input_tokens(value),
"output_tokens": value.output_tokens,
});
if value.cache_read_tokens > 0 {
@@ -4569,6 +4584,55 @@ pub(crate) fn canonical_usage_to_claude(value: &CanonicalUsage) -> Value {
output
}
fn canonical_usage_cache_creation_tokens(value: &CanonicalUsage) -> u64 {
if value.cache_write_tokens > 0 {
value.cache_write_tokens
} else {
value
.cache_creation_ephemeral_5m_tokens
.saturating_add(value.cache_creation_ephemeral_1h_tokens)
}
}
fn canonical_usage_cache_input_tokens(value: &CanonicalUsage) -> u64 {
value
.cache_read_tokens
.saturating_add(canonical_usage_cache_creation_tokens(value))
}
fn canonical_usage_uncached_input_tokens(value: &CanonicalUsage) -> u64 {
if value.input_tokens_include_cache {
value
.input_tokens
.saturating_sub(canonical_usage_cache_input_tokens(value))
} else {
value.input_tokens
}
}
pub(crate) fn canonical_usage_total_input_tokens(value: &CanonicalUsage) -> u64 {
if value.input_tokens_include_cache {
value.input_tokens
} else {
value
.input_tokens
.saturating_add(canonical_usage_cache_input_tokens(value))
}
}
pub(crate) fn canonical_usage_total_tokens_for_inclusive_input(
value: &CanonicalUsage,
input_tokens: u64,
) -> u64 {
if value.total_tokens > 0
&& (value.input_tokens_include_cache || canonical_usage_cache_input_tokens(value) == 0)
{
value.total_tokens
} else {
input_tokens.saturating_add(value.output_tokens)
}
}
pub(crate) fn openai_finish_reason_to_canonical(
value: Option<&str>,
) -> Option<CanonicalStopReason> {
@@ -5996,6 +6060,26 @@ mod tests {
assert_eq!(rebuilt["stop_reason"], "tool_use");
assert_eq!(rebuilt["usage"]["cache_read_input_tokens"], 3);
assert_eq!(rebuilt["usage"]["cache_creation_input_tokens"], 2);
let rebuilt_openai = canonical_to_openai_responses_response(&canonical, &json!({}));
assert_eq!(rebuilt_openai["usage"]["input_tokens"], 16);
assert_eq!(
rebuilt_openai["usage"]["input_tokens_details"]["cached_tokens"],
3
);
assert_eq!(
rebuilt_openai["usage"]["input_tokens_details"]["cached_creation_tokens"],
2
);
assert_eq!(rebuilt_openai["usage"]["total_tokens"], 23);
let rebuilt_gemini = canonical_to_gemini_response(&canonical, &json!({})).expect("gemini");
assert_eq!(rebuilt_gemini["usageMetadata"]["promptTokenCount"], 16);
assert_eq!(
rebuilt_gemini["usageMetadata"]["cachedContentTokenCount"],
3
);
assert_eq!(rebuilt_gemini["usageMetadata"]["totalTokenCount"], 23);
}
#[test]
@@ -6388,6 +6472,7 @@ mod tests {
}],
"usageMetadata": {
"promptTokenCount": 10,
"cachedContentTokenCount": 4,
"candidatesTokenCount": 5,
"thoughtsTokenCount": 2,
"totalTokenCount": 17
@@ -6417,9 +6502,15 @@ mod tests {
} if tool_use_id == "call_123" && name == "lookup" && output == &json!({"ok": true}))
));
assert_eq!(canonical.usage.as_ref().unwrap().input_tokens, 10);
assert!(canonical.usage.as_ref().unwrap().input_tokens_include_cache);
assert_eq!(canonical.usage.as_ref().unwrap().cache_read_tokens, 4);
assert_eq!(canonical.usage.as_ref().unwrap().output_tokens, 7);
assert_eq!(canonical.usage.as_ref().unwrap().reasoning_tokens, 2);
let claude = canonical_to_claude_response(&canonical);
assert_eq!(claude["usage"]["input_tokens"], 6);
assert_eq!(claude["usage"]["cache_read_input_tokens"], 4);
let rebuilt = canonical_to_gemini_response(&canonical, &json!({})).expect("gemini");
assert_eq!(
rebuilt["candidates"][0]["content"]["parts"][0]["thoughtSignature"],
@@ -6433,6 +6524,9 @@ mod tests {
rebuilt["candidates"][0]["content"]["parts"][3]["functionResponse"]["response"],
json!({"ok": true})
);
assert_eq!(rebuilt["usageMetadata"]["promptTokenCount"], 10);
assert_eq!(rebuilt["usageMetadata"]["cachedContentTokenCount"], 4);
assert_eq!(rebuilt["usageMetadata"]["totalTokenCount"], 17);
assert_eq!(rebuilt["usageMetadata"]["thoughtsTokenCount"], 2);
}
@@ -4,6 +4,10 @@ use serde_json::Value;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CanonicalUsage {
pub input_tokens: u64,
/// True when `input_tokens` already includes cache read and cache creation
/// input tokens. Claude-style usage leaves cached input tokens separate.
#[serde(default, skip_serializing_if = "is_false")]
pub input_tokens_include_cache: bool,
pub output_tokens: u64,
pub total_tokens: u64,
pub cache_creation_tokens: u64,
@@ -13,6 +17,10 @@ pub struct CanonicalUsage {
pub reasoning_tokens: u64,
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CanonicalContentPart {