From 426ec39816fc9f0da4fecf10d6b0492cc1308fe5 Mon Sep 17 00:00:00 2001 From: John Mangel Date: Thu, 2 Jul 2026 22:00:37 +0000 Subject: [PATCH] Fix Bedrock Converse input_tokens double-subtracting cache tokens AWS Bedrock reports inputTokens as already non-cached; cacheReadInputTokens and cacheWriteInputTokens are separate buckets, not folded in. Subtracting them (as inclusive providers like OpenAI/Gemini require) understated input and floored it to zero on cache-heavy turns. Pass inputTokens through, as the Anthropic provider already does for the same Claude models. Updates the converse chat spec: the prior test encoded the subtraction on a payload that can't occur (inputTokens already excludes cache per AWS), plus a regression for the floor-to-zero case. --- lib/ruby_llm/protocols/converse/chat.rb | 9 +++--- spec/ruby_llm/protocols/converse/chat_spec.rb | 30 +++++++++++++++++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/lib/ruby_llm/protocols/converse/chat.rb b/lib/ruby_llm/protocols/converse/chat.rb index c3cdbe493..46708c37e 100644 --- a/lib/ruby_llm/protocols/converse/chat.rb +++ b/lib/ruby_llm/protocols/converse/chat.rb @@ -90,10 +90,11 @@ def parse_completion_body(data, raw:) end def input_tokens(usage) - input_tokens = usage['inputTokens'] - return unless input_tokens - - [input_tokens.to_i - usage['cacheReadInputTokens'].to_i - usage['cacheWriteInputTokens'].to_i, 0].max + # AWS Bedrock reports inputTokens as already non-cached; cacheReadInputTokens and + # cacheWriteInputTokens are separate buckets, not folded into inputTokens. Subtracting + # them (as inclusive providers require) understates input and floors to zero on cache + # hits. See https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html + usage['inputTokens'] end def reasoning_tokens(usage) diff --git a/spec/ruby_llm/protocols/converse/chat_spec.rb b/spec/ruby_llm/protocols/converse/chat_spec.rb index 7e98a1810..6d845073d 100644 --- a/spec/ruby_llm/protocols/converse/chat_spec.rb +++ b/spec/ruby_llm/protocols/converse/chat_spec.rb @@ -4,7 +4,9 @@ RSpec.describe RubyLLM::Protocols::Converse::Chat do describe '.parse_completion_response' do - it 'normalizes cache read and write tokens out of input tokens' do + it 'exposes AWS inputTokens as-is (already non-cached) and keeps cache buckets separate' do + # Per AWS, inputTokens already excludes cache; a real payload sends the non-cached count + # directly, with cache read/write reported separately. response_body = { 'modelId' => 'anthropic.claude-sonnet-4-5-20250929-v1:0', 'output' => { @@ -13,7 +15,7 @@ } }, 'usage' => { - 'inputTokens' => 100, + 'inputTokens' => 50, 'outputTokens' => 5, 'cacheReadInputTokens' => 40, 'cacheWriteInputTokens' => 10 @@ -29,6 +31,30 @@ expect(message.cache_creation_tokens).to eq(10) end + it 'does not subtract cache buckets or floor to zero when the cached prefix exceeds fresh input' do + response_body = { + 'modelId' => 'anthropic.claude-sonnet-4-5-20250929-v1:0', + 'output' => { + 'message' => { + 'content' => [{ 'text' => 'Hi!' }] + } + }, + 'usage' => { + 'inputTokens' => 3, + 'outputTokens' => 5, + 'cacheReadInputTokens' => 7714, + 'cacheWriteInputTokens' => 327 + } + } + + response = instance_double(Faraday::Response, body: response_body) + message = described_class.parse_completion_response(response) + + expect(message.input_tokens).to eq(3) + expect(message.cached_tokens).to eq(7714) + expect(message.cache_creation_tokens).to eq(327) + end + it 'preserves raw stopReason as finish_reason' do response_body = { 'modelId' => 'amazon.nova-lite-v1:0',