diff --git a/litellm-rust/crates/token-counter/src/types.rs b/litellm-rust/crates/token-counter/src/types.rs index 16ab1d86f90..7f01219d4c0 100644 --- a/litellm-rust/crates/token-counter/src/types.rs +++ b/litellm-rust/crates/token-counter/src/types.rs @@ -147,15 +147,28 @@ pub(crate) struct ToolCall { pub(crate) function: ToolCallFunction, } +/// Python runs `str(function.get("arguments", ""))`: a missing key counts as +/// empty, but an explicit null counts as the string "None". `serde(default)` +/// keeps a missing key at `None`, and the deserializer turns an explicit null +/// into "None" so the counter sees what Python counts. +fn explicit_null_arguments_as_none<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + match Option::::deserialize(deserializer)? { + Some(arguments) => Ok(Some(arguments)), + None => Ok(Some(String::from("None"))), + } +} + #[derive(Clone, Debug, Deserialize, PartialEq)] pub(crate) struct ToolCallFunction { - #[serde(default)] + #[serde(default, deserialize_with = "explicit_null_arguments_as_none")] pub(crate) arguments: Option, } #[derive(Clone, Debug, Deserialize, PartialEq)] pub(crate) struct LegacyFunctionCall { - #[serde(default)] + #[serde(default, deserialize_with = "explicit_null_arguments_as_none")] pub(crate) arguments: Option, } diff --git a/litellm-rust/crates/token-counter/tests/token_counter.rs b/litellm-rust/crates/token-counter/tests/token_counter.rs index 2161652f0b1..10dd5abbbb0 100644 --- a/litellm-rust/crates/token-counter/tests/token_counter.rs +++ b/litellm-rust/crates/token-counter/tests/token_counter.rs @@ -80,6 +80,17 @@ mod json { const ASSISTANT_CONTENT_WITH_TOOL_CALL: &str = r#"{"model":"claude-sonnet-4-5","messages":[ {"role":"assistant","content":"calling","tool_calls":[{"id":"1","type":"function","function":{"name":"get_weather","arguments":"{\"city\": \"Paris, France\"}"}}]}]}"#; + const NULL_TOOL_CALL_ARGUMENTS: &str = r#"{"model":"claude-sonnet-4-5","messages":[ + {"role":"assistant","tool_calls":[{"id":"1","type":"function","function":{"name":"f","arguments":null}}]}, + {"role":"tool","tool_call_id":"1","content":"x"}]}"#; + + const MISSING_TOOL_CALL_ARGUMENTS: &str = r#"{"model":"claude-sonnet-4-5","messages":[ + {"role":"assistant","tool_calls":[{"id":"1","type":"function","function":{"name":"f"}}]}, + {"role":"tool","tool_call_id":"1","content":"x"}]}"#; + + const LEGACY_NULL_FUNCTION_CALL_ARGUMENTS: &str = r#"{"model":"claude-sonnet-4-5","messages":[ + {"role":"assistant","function_call":{"name":"f","arguments":null}}]}"#; + fn assert_count_request_matches_python_token_counter( load: JsonLoader, body: &str, @@ -167,6 +178,9 @@ mod json { #[case::tool_calls_and_tool_result(super::TOOL_CALLS_AND_TOOL_RESULT, 24)] #[case::legacy_function_call(super::LEGACY_FUNCTION_CALL, 14)] #[case::assistant_content_with_tool_call(super::ASSISTANT_CONTENT_WITH_TOOL_CALL, 16)] + #[case::null_tool_call_arguments_count_as_none(super::NULL_TOOL_CALL_ARGUMENTS, 14)] + #[case::missing_tool_call_arguments_count_as_empty(super::MISSING_TOOL_CALL_ARGUMENTS, 13)] + #[case::legacy_null_function_call_arguments_count_as_none(super::LEGACY_NULL_FUNCTION_CALL_ARGUMENTS, 8)] fn count_request_matches_python_token_counter( #[case] body: &str, #[case] expected: usize,