diff --git a/lib/ruby_llm/protocols/converse/chat.rb b/lib/ruby_llm/protocols/converse/chat.rb index 49bbd5123..7834d537c 100644 --- a/lib/ruby_llm/protocols/converse/chat.rb +++ b/lib/ruby_llm/protocols/converse/chat.rb @@ -93,10 +93,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 4998440e2..4259609f9 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',