mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 20:50:20 +08:00
Merge remote-tracking branch 'origin/pr/584'
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user