This is an automated email from the ASF dual-hosted git repository.
AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new 96ba388d50 fix(ai-proxy): account for Bedrock prompt cache tokens
(#14044)
96ba388d50 is described below
commit 96ba388d507ea73129a4ee7848c740b6274c5181
Author: AlinsRan <[email protected]>
AuthorDate: Mon Oct 26 16:46:32 2026 +0800
fix(ai-proxy): account for Bedrock prompt cache tokens (#14044)
---
apisix/plugins/ai-protocols/bedrock-converse.lua | 39 ++--
t/fixtures/bedrock/converse-cache-usage-cases.json | 196 +++++++++++++++++++++
.../bedrock/converse-streaming-with-cache.bin | Bin 0 -> 600 bytes
t/fixtures/bedrock/converse-with-cache.json | 1 +
t/lib/server.lua | 6 +-
t/plugin/ai-proxy-bedrock-single.t | 68 +++++++
6 files changed, 296 insertions(+), 14 deletions(-)
diff --git a/apisix/plugins/ai-protocols/bedrock-converse.lua
b/apisix/plugins/ai-protocols/bedrock-converse.lua
index 1d179d01e8..97929d2590 100644
--- a/apisix/plugins/ai-protocols/bedrock-converse.lua
+++ b/apisix/plugins/ai-protocols/bedrock-converse.lua
@@ -30,6 +30,31 @@ local table = table
local _M = {}
+local function cache_token_count(raw, key, alias)
+ local value = raw[key]
+ if type(value) ~= "number" then
+ value = raw[alias]
+ end
+ return type(value) == "number" and value or 0
+end
+
+
+local function normalize_usage(raw)
+ local cache_read = cache_token_count(raw, "cacheReadInputTokens",
"cacheReadInputTokenCount")
+ local cache_write = cache_token_count(raw, "cacheWriteInputTokens",
"cacheWriteInputTokenCount")
+ -- Bedrock inputTokens excludes cached tokens; normalized prompt usage
includes them.
+ local prompt_tokens = (raw.inputTokens or 0) + cache_read + cache_write
+ local completion_tokens = raw.outputTokens or 0
+ return {
+ prompt_tokens = prompt_tokens,
+ completion_tokens = completion_tokens,
+ total_tokens = raw.totalTokens or prompt_tokens + completion_tokens,
+ cache_read_input_tokens = cache_read,
+ cache_creation_input_tokens = cache_write,
+ }
+end
+
+
--- Detect whether the request matches the Bedrock Converse API format.
-- Uses URI suffix (/converse) and body (valid JSON table with messages).
function _M.matches(body, ctx)
@@ -117,12 +142,7 @@ function _M.parse_sse_event(event, ctx, state)
local raw = data.usage
return {
type = "usage_and_done",
- usage = {
- prompt_tokens = raw.inputTokens or 0,
- completion_tokens = raw.outputTokens or 0,
- total_tokens = raw.totalTokens
- or (raw.inputTokens or 0) + (raw.outputTokens or 0),
- },
+ usage = normalize_usage(raw),
raw_usage = raw,
}
end
@@ -140,12 +160,7 @@ function _M.extract_usage(res_body)
return nil, nil
end
local raw = res_body.usage
- return {
- prompt_tokens = raw.inputTokens or 0,
- completion_tokens = raw.outputTokens or 0,
- total_tokens = raw.totalTokens
- or (raw.inputTokens or 0) + (raw.outputTokens or 0),
- }, raw
+ return normalize_usage(raw), raw
end
diff --git a/t/fixtures/bedrock/converse-cache-usage-cases.json
b/t/fixtures/bedrock/converse-cache-usage-cases.json
new file mode 100644
index 0000000000..6ba210f76d
--- /dev/null
+++ b/t/fixtures/bedrock/converse-cache-usage-cases.json
@@ -0,0 +1,196 @@
+[
+ {
+ "name": "no cache",
+ "raw": {
+ "inputTokens": 10,
+ "outputTokens": 8
+ },
+ "expected": {
+ "prompt_tokens": 10,
+ "completion_tokens": 8,
+ "total_tokens": 18,
+ "cache_read_input_tokens": 0,
+ "cache_creation_input_tokens": 0
+ }
+ },
+ {
+ "name": "read",
+ "raw": {
+ "inputTokens": 10,
+ "outputTokens": 8,
+ "cacheReadInputTokens": 20
+ },
+ "expected": {
+ "prompt_tokens": 30,
+ "completion_tokens": 8,
+ "total_tokens": 38,
+ "cache_read_input_tokens": 20,
+ "cache_creation_input_tokens": 0
+ }
+ },
+ {
+ "name": "write",
+ "raw": {
+ "inputTokens": 10,
+ "outputTokens": 8,
+ "cacheWriteInputTokens": 30
+ },
+ "expected": {
+ "prompt_tokens": 40,
+ "completion_tokens": 8,
+ "total_tokens": 48,
+ "cache_read_input_tokens": 0,
+ "cache_creation_input_tokens": 30
+ }
+ },
+ {
+ "name": "both",
+ "raw": {
+ "inputTokens": 10,
+ "outputTokens": 8,
+ "cacheReadInputTokens": 20,
+ "cacheWriteInputTokens": 30
+ },
+ "expected": {
+ "prompt_tokens": 60,
+ "completion_tokens": 8,
+ "total_tokens": 68,
+ "cache_read_input_tokens": 20,
+ "cache_creation_input_tokens": 30
+ }
+ },
+ {
+ "name": "explicit total",
+ "raw": {
+ "inputTokens": 10,
+ "outputTokens": 8,
+ "cacheReadInputTokens": 20,
+ "cacheWriteInputTokens": 30,
+ "totalTokens": 68
+ },
+ "expected": {
+ "prompt_tokens": 60,
+ "completion_tokens": 8,
+ "total_tokens": 68,
+ "cache_read_input_tokens": 20,
+ "cache_creation_input_tokens": 30
+ }
+ },
+ {
+ "name": "preserve total",
+ "raw": {
+ "inputTokens": 10,
+ "outputTokens": 8,
+ "cacheReadInputTokens": 20,
+ "totalTokens": 99
+ },
+ "expected": {
+ "prompt_tokens": 30,
+ "completion_tokens": 8,
+ "total_tokens": 99,
+ "cache_read_input_tokens": 20,
+ "cache_creation_input_tokens": 0
+ }
+ },
+ {
+ "name": "zero total",
+ "raw": {
+ "inputTokens": 10,
+ "outputTokens": 8,
+ "totalTokens": 0
+ },
+ "expected": {
+ "prompt_tokens": 10,
+ "completion_tokens": 8,
+ "total_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "cache_creation_input_tokens": 0
+ }
+ },
+ {
+ "name": "aliases",
+ "raw": {
+ "inputTokens": 10,
+ "outputTokens": 8,
+ "cacheReadInputTokenCount": 20,
+ "cacheWriteInputTokenCount": 30
+ },
+ "expected": {
+ "prompt_tokens": 60,
+ "completion_tokens": 8,
+ "total_tokens": 68,
+ "cache_read_input_tokens": 20,
+ "cache_creation_input_tokens": 30
+ }
+ },
+ {
+ "name": "canonical precedence",
+ "raw": {
+ "inputTokens": 10,
+ "outputTokens": 8,
+ "cacheReadInputTokens": 4,
+ "cacheReadInputTokenCount": 20,
+ "cacheWriteInputTokens": 6,
+ "cacheWriteInputTokenCount": 30
+ },
+ "expected": {
+ "prompt_tokens": 20,
+ "completion_tokens": 8,
+ "total_tokens": 28,
+ "cache_read_input_tokens": 4,
+ "cache_creation_input_tokens": 6
+ }
+ },
+ {
+ "name": "zero precedence",
+ "raw": {
+ "inputTokens": 10,
+ "outputTokens": 8,
+ "cacheReadInputTokens": 0,
+ "cacheReadInputTokenCount": 20,
+ "cacheWriteInputTokens": 0,
+ "cacheWriteInputTokenCount": 30
+ },
+ "expected": {
+ "prompt_tokens": 10,
+ "completion_tokens": 8,
+ "total_tokens": 18,
+ "cache_read_input_tokens": 0,
+ "cache_creation_input_tokens": 0
+ }
+ },
+ {
+ "name": "null cache",
+ "raw": {
+ "inputTokens": 10,
+ "outputTokens": 8,
+ "cacheReadInputTokens": null,
+ "cacheWriteInputTokens": null
+ },
+ "expected": {
+ "prompt_tokens": 10,
+ "completion_tokens": 8,
+ "total_tokens": 18,
+ "cache_read_input_tokens": 0,
+ "cache_creation_input_tokens": 0
+ }
+ },
+ {
+ "name": "null alias fallback",
+ "raw": {
+ "inputTokens": 10,
+ "outputTokens": 8,
+ "cacheReadInputTokens": null,
+ "cacheReadInputTokenCount": 20,
+ "cacheWriteInputTokens": null,
+ "cacheWriteInputTokenCount": 30
+ },
+ "expected": {
+ "prompt_tokens": 60,
+ "completion_tokens": 8,
+ "total_tokens": 68,
+ "cache_read_input_tokens": 20,
+ "cache_creation_input_tokens": 30
+ }
+ }
+]
diff --git a/t/fixtures/bedrock/converse-streaming-with-cache.bin
b/t/fixtures/bedrock/converse-streaming-with-cache.bin
new file mode 100644
index 0000000000..ee4ec14e3a
Binary files /dev/null and
b/t/fixtures/bedrock/converse-streaming-with-cache.bin differ
diff --git a/t/fixtures/bedrock/converse-with-cache.json
b/t/fixtures/bedrock/converse-with-cache.json
new file mode 100644
index 0000000000..154ecbec21
--- /dev/null
+++ b/t/fixtures/bedrock/converse-with-cache.json
@@ -0,0 +1 @@
+{"output":{"message":{"role":"assistant","content":[{"text":"Hello"}]}},"stopReason":"end_turn","usage":{"inputTokens":10,"outputTokens":8,"totalTokens":68,"cacheReadInputTokens":20,"cacheWriteInputTokens":30}}
diff --git a/t/lib/server.lua b/t/lib/server.lua
index b2f057dc4f..33be399269 100644
--- a/t/lib/server.lua
+++ b/t/lib/server.lua
@@ -1480,7 +1480,8 @@ function _M.bedrock_converse()
end
local fixture_loader = require("lib.fixture_loader")
- local content, ferr = fixture_loader.load("bedrock/converse-basic.json")
+ local content, ferr = fixture_loader.load(
+ ngx.req.get_headers()["X-AI-Fixture"] or "bedrock/converse-basic.json")
if not content then
ngx.status = 500
ngx.say(ferr)
@@ -1594,7 +1595,8 @@ function _M.bedrock_converse_stream()
end
local fixture_loader = require("lib.fixture_loader")
- local content, ferr =
fixture_loader.load("bedrock/bedrock-converse-streaming.bin")
+ local content, ferr = fixture_loader.load(
+ ngx.req.get_headers()["X-AI-Fixture"] or
"bedrock/bedrock-converse-streaming.bin")
if not content then
ngx.status = 500
ngx.say(ferr)
diff --git a/t/plugin/ai-proxy-bedrock-single.t
b/t/plugin/ai-proxy-bedrock-single.t
index fc7e7d3b5a..c5f5dacb8b 100644
--- a/t/plugin/ai-proxy-bedrock-single.t
+++ b/t/plugin/ai-proxy-bedrock-single.t
@@ -497,3 +497,71 @@ POST /single-ai/stream/converse
qr/"text"\s*:\s*"Hello!"/
--- error_log eval
qr{\[test\] received uri:
/model/anthropic\.claude-3-5-sonnet-20241022-v2%3A0/converse(?!-stream)}
+
+
+
+=== TEST 19: Bedrock cache usage matches LiteLLM for both response modes
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local bedrock =
require("apisix.plugins.ai-protocols.bedrock-converse")
+ local loader = require("lib.fixture_loader")
+ -- Expected usage was differentialed against LiteLLM 3d526bfe
transform_usage.
+ local cases = core.json.decode(assert(loader.load(
+ "bedrock/converse-cache-usage-cases.json")), { null_as_nil =
true })
+ for _, case in ipairs(cases) do
+ local usage, raw = bedrock.extract_usage({ usage = case.raw })
+ assert(raw == case.raw, case.name .. " raw usage preserved")
+ local result = bedrock.parse_sse_event({
+ headers = { [":event-type"] = "metadata" },
+ payload = core.json.encode({ usage = case.raw }),
+ }, {}, {})
+ assert(result.type == "usage_and_done", case.name)
+ for key, expected in pairs(case.expected) do
+ assert(usage[key] == expected, case.name .. " non-stream "
.. key)
+ assert(result.usage[key] == expected, case.name .. "
stream " .. key)
+ end
+ assert(core.json.stably_encode(result.raw_usage)
+ == core.json.stably_encode(case.raw), case.name .. "
stream raw usage")
+ end
+ local usage = bedrock.extract_usage({ usage = {} })
+ assert(usage.prompt_tokens == 0 and usage.total_tokens == 0,
"empty usage")
+ assert(bedrock.extract_usage({}) == nil, "missing usage")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 20: non-streaming cache tokens reach the LLM logging variables
+--- request
+POST /single-ai/stream/converse
+{"messages":[{"role":"user","content":[{"text":"Say hi"}]}]}
+--- more_headers
+X-AI-Fixture: bedrock/converse-with-cache.json
+--- response_body_like eval
+qr/"cacheReadInputTokens":20/
+--- access_log eval
+qr/ 60 8 68 false false 0 \S* 20 30 0 /
+--- no_error_log
+[error]
+
+
+
+=== TEST 21: streaming cache tokens reach the LLM logging variables
+--- request
+POST /single-ai/stream/converse
+{"stream":true,"messages":[{"role":"user","content":[{"text":"Say hi"}]}]}
+--- more_headers
+X-AI-Fixture: bedrock/converse-streaming-with-cache.bin
+--- response_body_like eval
+qr/metadata/
+--- access_log eval
+qr/ 60 8 68 true false 0 \S* 20 30 0 /
+--- no_error_log
+[error]