Copilot commented on code in PR #13321:
URL: https://github.com/apache/apisix/pull/13321#discussion_r3161822460
##########
apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua:
##########
@@ -321,32 +683,81 @@ local function openai_to_anthropic_sse(openai_chunk,
state)
role = "assistant",
model = openai_chunk.model,
content = {},
- usage = { input_tokens = 0, output_tokens = 0 }
+ usage = { input_tokens = 0, output_tokens = 0 },
}
setmetatable(message.content, core.json.empty_array_mt)
table.insert(events, make_sse_event("message_start", {
type = "message_start",
message = message,
}))
- push_content_block_start(events, 0, { type = "text", text = "" })
state.is_first = false
- state.content_index = 0
- state.current_open_block = 0
+ state.next_content_index = 0
+ state.current_open_block = nil
+ state.current_block_type = nil
state.tool_call_indices = {}
end
- -- 2. Handle text content delta
+ -- Normalize finish_reason: nil, empty, "null", whitespace → no finish
+ local finish_reason
+ if choice then
+ local fr = choice.finish_reason
+ if type(fr) == "string" and fr ~= "" and fr ~= "null" then
+ -- Strip whitespace
+ local trimmed = fr:match("^%s*(.-)%s*$")
+ if trimmed and trimmed ~= "" then
Review Comment:
In finish_reason normalization, `fr` is checked against "null" before
trimming, so values like " null " (whitespace-padded) will be treated as a real
finish_reason and prematurely stop the stream. Trim first, then treat trimmed
""/"null" as unset.
```suggestion
if type(fr) == "string" then
-- Strip whitespace before checking sentinel values
local trimmed = fr:match("^%s*(.-)%s*$")
if trimmed and trimmed ~= "" and trimmed ~= "null" then
```
##########
apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua:
##########
@@ -84,136 +258,254 @@ function _M.convert_request(request_table, ctx)
return nil, "missing messages"
end
- local openai_body = core.table.clone(request_table)
+ -- Whitelist body construction: only explicitly converted fields are set.
+ local openai_body = {}
- -- 1. Handle System Prompt
- local messages = {}
- if request_table.system then
- local system_content = ""
- if type(request_table.system) == "string" then
- system_content = request_table.system
- elseif type(request_table.system) == "table" then
- for _, block in ipairs(request_table.system) do
- if type(block) == "table" and block.type == "text"
- and type(block.text) == "string" then
- system_content = system_content .. block.text
- end
- end
+ -- Model passthrough
+ if type(request_table.model) == "string" then
+ openai_body.model = request_table.model
+ end
+
+ -- Stream passthrough
+ if request_table.stream ~= nil then
+ openai_body.stream = request_table.stream
+ end
+
+ -- max_tokens → max_completion_tokens (never forward max_tokens)
+ if request_table.max_tokens then
+ openai_body.max_completion_tokens = request_table.max_tokens
+ end
+
+ -- Simple parameter passthrough
+ if request_table.temperature then
+ openai_body.temperature = request_table.temperature
+ end
+ if request_table.top_p then
+ openai_body.top_p = request_table.top_p
+ end
+
+ -- stop_sequences → stop
+ if type(request_table.stop_sequences) == "table" then
+ openai_body.stop = request_table.stop_sequences
+ end
+
+ -- thinking → reasoning_effort
+ if request_table.thinking then
+ local effort = convert_thinking_config(request_table.thinking)
+ if effort then
+ openai_body.reasoning_effort = effort
end
+ end
- if system_content ~= "" then
- table.insert(messages, {
- role = "system",
- content = system_content
- })
+ -- tool_choice conversion
+ if request_table.tool_choice then
+ local converted_tc = convert_tool_choice(request_table.tool_choice)
+ if converted_tc then
+ openai_body.tool_choice = converted_tc
+ end
+ -- disable_parallel_tool_use
+ if type(request_table.tool_choice) == "table"
+ and request_table.tool_choice.disable_parallel_tool_use ==
true then
+ openai_body.parallel_tool_calls = false
+ end
+ end
+
+ -- response_format from output_config or output_format
+ local output_cfg = request_table.output_config or
request_table.output_format
+ if type(output_cfg) == "table" then
+ if output_cfg.type == "json_schema" and output_cfg.json_schema then
+ openai_body.response_format = {
+ type = "json_schema",
+ json_schema = output_cfg.json_schema,
+ }
+ elseif output_cfg.type == "json_object" or output_cfg.type == "json"
then
+ openai_body.response_format = { type = "json_object" }
+ end
+ end
+
+ -- 1. System prompt
+ local messages = {}
+ if request_table.system then
+ local sys_msg = convert_system(request_table.system)
+ if sys_msg then
+ table.insert(messages, sys_msg)
end
- openai_body.system = nil
end
- -- 2. Convert Messages (including tool calls and results)
+ -- 2. Convert messages
for i, msg in ipairs(request_table.messages) do
if type(msg) ~= "table" or type(msg.role) ~= "string" then
return nil, "invalid message at index " .. i
end
- if type(msg.content) ~= "string" and type(msg.content) ~= "table" then
+
+ if type(msg.content) == "string" then
+ table.insert(messages, { role = msg.role, content = msg.content })
+ goto CONTINUE
+ end
+
+ if type(msg.content) ~= "table" then
return nil, "invalid message content at index " .. i
end
- local new_msg = {
- role = msg.role,
- content = ""
- }
- if type(msg.content) == "string" then
- new_msg.content = msg.content
- elseif type(msg.content) == "table" then
- local tool_calls = {}
- local tool_results = {}
-
- for _, block in ipairs(msg.content) do
- if type(block) ~= "table" then
- core.log.warn("unexpected non-table content block in
Anthropic ",
- "request, skipping: ", tostring(block))
- goto CONTINUE_BLOCK
+ -- Process content block array
+ local tool_calls = {}
+ local tool_results = {}
+ local content_parts = {}
+ local has_multimodal = false
+
+ for _, block in ipairs(msg.content) do
+ if type(block) ~= "table" then
+ core.log.warn("unexpected non-table content block in Anthropic
",
+ "request, skipping: ", tostring(block))
+ goto CONTINUE_BLOCK
+ end
+
+ if block.type == "text" and type(block.text) == "string" then
+ local text_part = { type = "text", text = block.text }
+ if block.cache_control then
+ text_part.cache_control = block.cache_control
end
+ table.insert(content_parts, text_part)
- if block.type == "text" and type(block.text) == "string" then
- new_msg.content = (new_msg.content or "") .. block.text
- elseif block.type == "tool_use" then
- if type(block.id) == "string" and type(block.name) ==
"string" then
- table.insert(tool_calls, {
- id = block.id,
- type = "function",
- ["function"] = {
- name = block.name,
- arguments = core.json.encode(block.input or {})
- }
- })
- end
- elseif block.type == "tool_result" then
- if type(block.tool_use_id) == "string" then
- table.insert(tool_results, {
- role = "tool",
- tool_call_id = block.tool_use_id,
- content = type(block.content) == "table"
- and core.json.encode(block.content)
- or tostring(block.content or "")
- })
+ elseif block.type == "image" or block.type == "document" then
+ local media_part = convert_media_block(block)
+ if media_part then
+ table.insert(content_parts, media_part)
+ has_multimodal = true
+ end
+
+ elseif block.type == "tool_use" then
+ if type(block.id) == "string" and type(block.name) == "string"
then
+ table.insert(tool_calls, {
+ id = block.id,
+ type = "function",
+ ["function"] = {
+ name = block.name,
+ arguments = core.json.encode(block.input or {})
+ }
+ })
+ end
+
+ elseif block.type == "tool_result" then
+ if type(block.tool_use_id) == "string" then
+ local tr_content
+ if type(block.content) == "string" then
+ tr_content = block.content
+ elseif type(block.content) == "table" then
+ -- Extract text from content array; images become
image_url
+ local texts = {}
+ local parts = {}
+ local has_media = false
+ for _, sub in ipairs(block.content) do
+ if type(sub) == "table" then
+ if sub.type == "text" and type(sub.text) ==
"string" then
+ table.insert(texts, sub.text)
+ table.insert(parts, { type = "text", text
= sub.text })
+ elseif sub.type == "image" or sub.type ==
"document" then
+ local mp = convert_media_block(sub)
+ if mp then
+ table.insert(parts, mp)
+ has_media = true
+ end
+ end
+ end
+ end
+ if has_media then
+ tr_content = parts
+ else
+ tr_content = table.concat(texts, "")
+ end
+ else
+ tr_content = ""
end
+ table.insert(tool_results, {
+ role = "tool",
+ tool_call_id = block.tool_use_id,
+ content = tr_content,
+ })
end
- ::CONTINUE_BLOCK::
+ elseif block.type == "thinking" or block.type ==
"redacted_thinking" then
+ -- Pass thinking blocks through in content array (for history)
+ table.insert(content_parts, block)
Review Comment:
The request converter currently inserts Anthropic `thinking` /
`redacted_thinking` blocks directly into the OpenAI `message.content` array.
OpenAI Chat Completions content parts are typically limited to supported types
(e.g., `text`, `image_url`), so forwarding `thinking` blocks risks upstream
validation errors. Consider converting these blocks to `text` (or omitting them
from the upstream request while preserving them only for
downstream/client-facing history).
```suggestion
elseif block.type == "thinking" then
if type(block.thinking) == "string" then
local thinking_part = { type = "text", text =
block.thinking }
if block.cache_control then
thinking_part.cache_control = block.cache_control
end
table.insert(content_parts, thinking_part)
else
core.log.warn("unexpected thinking block without string
payload in ",
"Anthropic request, skipping")
end
elseif block.type == "redacted_thinking" then
core.log.warn("omitting unsupported redacted_thinking block
from ",
"OpenAI chat request content")
```
##########
apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua:
##########
@@ -70,9 +71,182 @@ local openai_stop_reason_map = {
length = "max_tokens",
content_filter = "end_turn",
tool_calls = "tool_use",
+ function_call = "tool_use",
}
+-- Convert an Anthropic image/document block to OpenAI image_url format.
+local function convert_media_block(block)
+ if block.type == "image" then
+ local source = block.source
+ if not source then
+ return nil
+ end
+ if source.type == "base64" then
+ return {
+ type = "image_url",
+ image_url = {
+ url = "data:" .. (source.media_type or "image/png")
+ .. ";base64," .. (source.data or ""),
+ },
+ }
+ elseif source.type == "url" and type(source.url) == "string"
+ and source.url ~= "" then
+ return {
+ type = "image_url",
+ image_url = { url = source.url },
+ }
+ end
+ elseif block.type == "document" then
+ local source = block.source
+ if source and source.type == "base64" then
+ return {
+ type = "image_url",
+ image_url = {
+ url = "data:" .. (source.media_type or "application/pdf")
+ .. ";base64," .. (source.data or ""),
+ },
+ }
+ end
+ end
+ return nil
+end
+
+
+-- Convert Anthropic tool_choice to OpenAI format.
+local function convert_tool_choice(tc)
+ if type(tc) ~= "table" then
+ return nil
+ end
+ local t = tc.type
+ if t == "auto" then
+ return "auto"
+ elseif t == "any" then
+ return "required"
+ elseif t == "none" then
+ return "none"
+ elseif t == "tool" and type(tc.name) == "string" then
+ return {
+ type = "function",
+ ["function"] = { name = tc.name },
+ }
+ end
+ return nil
+end
+
+
+-- Convert Anthropic thinking config to OpenAI reasoning_effort.
+local function convert_thinking_config(thinking)
+ if type(thinking) ~= "table" then
+ return nil
+ end
+ if thinking.type == "disabled" then
+ return nil
+ end
+ if thinking.type ~= "enabled" then
+ return nil
+ end
+ local budget = thinking.budget_tokens
+ if type(budget) ~= "number" then
+ return "medium"
+ end
+ if budget < 4096 then
+ return "low"
+ elseif budget < 16384 then
+ return "medium"
+ else
+ return "high"
+ end
+end
+
+
+-- Strip cch= entries from billing header text.
+local function strip_cch_from_billing(text)
+ if type(text) ~= "string" then
+ return text
+ end
+ local prefix = "x-anthropic-billing-header:"
+ if text:sub(1, #prefix):lower() ~= prefix then
+ return text
+ end
+ local value = text:sub(#prefix + 1)
+ -- Remove cch=<value> entries (with optional surrounding semicolons/spaces)
+ value = ngx_re_gsub(value, [[ ?cch=[^;]*;?]], "", "jo")
+ -- Clean up trailing/leading semicolons and spaces
+ value = ngx_re_gsub(value, [[^[; ]+|[; ]+$]], "", "jo")
+ if value == "" then
+ return nil
+ end
+ return prefix .. value
+end
+
+
+-- Convert system prompt to OpenAI messages.
+-- Preserves array structure with cache_control when present.
+local function convert_system(system)
+ if type(system) == "string" then
+ if system == "" then
+ return nil
+ end
+ return { role = "system", content = system }
+ end
+
+ if type(system) ~= "table" then
+ return nil
+ end
+
+ -- Check if any block has cache_control or if there are multiple blocks
+ local has_cache_control = false
+ for _, block in ipairs(system) do
+ if type(block) == "table" and block.cache_control then
+ has_cache_control = true
+ break
+ end
Review Comment:
`convert_system` comment says it checks for cache_control **or multiple
blocks**, but the implementation only checks `block.cache_control`. Either
update the comment or implement the “multiple blocks” behavior (e.g., preserve
array structure when `#system > 1`).
##########
t/plugin/ai-proxy-anthropic.t:
##########
@@ -155,169 +333,1100 @@ passed
-=== TEST 4: test is SSE works as expected
+=== TEST 16: whitelist body - unknown fields are NOT forwarded
+Verify that anthropic-specific fields like metadata, top_k, thinking (raw),
+output_config do NOT appear in the converted request.
--- config
location /t {
content_by_lua_block {
- local http = require("resty.http")
- local httpc = http.new()
local core = require("apisix.core")
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
- local ok, err = httpc:connect({
- scheme = "http",
- host = "localhost",
- port = ngx.var.server_port,
- })
+ local request = {
+ model = "claude-sonnet-4-20250514",
+ max_tokens = 1024,
+ metadata = { user_id = "test" },
+ top_k = 5,
+ thinking = { type = "enabled", budget_tokens = 8000 },
+ unknown_field = "should not appear",
+ messages = {
+ { role = "user", content = "Hello" }
+ }
+ }
- if not ok then
- ngx.status = 500
- ngx.say(err)
+ local ctx = { var = {} }
+ local result, err = converter.convert_request(request, ctx)
+ if not result then
+ ngx.say("ERROR: " .. (err or "nil"))
return
end
- local params = {
- method = "POST",
- headers = {
- ["Content-Type"] = "application/json",
- },
- path = "/anything",
- body = [[{
- "messages": [
- { "role": "system", "content": "some content" }
- ],
- "stream": true
- }]],
- }
+ -- These fields should NOT be present
+ local leaked = {}
+ for _, field in ipairs({"metadata", "top_k", "unknown_field"}) do
+ if result[field] ~= nil then
+ table.insert(leaked, field)
+ end
+ end
+ if #leaked > 0 then
+ ngx.say("LEAKED: " .. table.concat(leaked, ", "))
+ return
+ end
- local res, err = httpc:request(params)
- if not res then
- ngx.status = 500
- ngx.say(err)
+ -- thinking should be converted to reasoning_effort, not passed raw
+ if result.thinking ~= nil then
+ ngx.say("LEAKED: thinking (raw)")
+ return
+ end
+ if result.reasoning_effort ~= "medium" then
+ ngx.say("reasoning_effort wrong: " ..
tostring(result.reasoning_effort))
return
end
- local final_res = {}
- while true do
- local chunk, err = res.body_reader() -- will read chunk by
chunk
- if err then
- core.log.error("failed to read response chunk: ", err)
- break
- end
- if not chunk then
- break
- end
- core.table.insert_tail(final_res, chunk)
+ -- max_tokens should become max_completion_tokens
+ if result.max_tokens ~= nil then
+ ngx.say("LEAKED: max_tokens")
+ return
+ end
+ if result.max_completion_tokens ~= 1024 then
+ ngx.say("max_completion_tokens wrong: " ..
tostring(result.max_completion_tokens))
+ return
end
- ngx.print(#final_res .. final_res[6])
+ ngx.say("OK")
}
}
---- response_body_like eval
-qr/6data: \[DONE\]\n\n/
+--- response_body
+OK
+--- no_error_log
+[error]
-=== TEST 5: set route for Anthropic null-field tests
+=== TEST 17: tool_choice conversion (auto, any, tool, none)
--- config
location /t {
content_by_lua_block {
- local t = require("lib.test_admin").test
- local code, body = t('/apisix/admin/routes/1',
- ngx.HTTP_PUT,
- [[{
- "uri": "/v1/messages",
- "plugins": {
- "ai-proxy-multi": {
- "instances": [
- {
- "name": "openai-compat",
- "provider": "openai-compatible",
- "weight": 1,
- "auth": {
- "header": {
- "Authorization": "Bearer token"
- }
- },
- "options": {
- "model": "test-model"
- },
- "override": {
- "endpoint": "http://localhost:1980"
- }
- }
- ],
- "ssl_verify": false
- }
- }
- }]]
- )
+ local core = require("apisix.core")
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
- if code >= 300 then
- ngx.status = code
- end
- ngx.say(body)
- }
- }
---- response_body
-passed
+ -- auto
+ local r = converter.convert_request({
+ model = "claude-sonnet-4-20250514", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ tools = {{ name = "f", input_schema = {} }},
+ tool_choice = { type = "auto" },
+ }, ctx)
+ assert(r.tool_choice == "auto", "auto failed: " ..
tostring(r.tool_choice))
+ -- any → required
+ r = converter.convert_request({
+ model = "claude-sonnet-4-20250514", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ tools = {{ name = "f", input_schema = {} }},
+ tool_choice = { type = "any" },
+ }, ctx)
+ assert(r.tool_choice == "required", "any failed: " ..
tostring(r.tool_choice))
+ -- none
+ r = converter.convert_request({
+ model = "claude-sonnet-4-20250514", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ tools = {{ name = "f", input_schema = {} }},
+ tool_choice = { type = "none" },
+ }, ctx)
+ assert(r.tool_choice == "none", "none failed: " ..
tostring(r.tool_choice))
-=== TEST 6: Anthropic conversion handles null prompt_tokens_details
-Test that cjson.null (from JSON null) does not crash the converter.
---- request
-POST /v1/messages
-{"model":"test-model","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}
---- more_headers
-Content-Type: application/json
-X-AI-Fixture: openai/null-details.json
---- error_code: 200
---- response_body_like eval
-qr/(?s)(?=.*"input_tokens":10)(?=.*"output_tokens":5)/
+ -- tool → {type:"function", function:{name:"X"}}
+ r = converter.convert_request({
+ model = "claude-sonnet-4-20250514", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ tools = {{ name = "search", input_schema = {} }},
+ tool_choice = { type = "tool", name = "search" },
+ }, ctx)
+ assert(type(r.tool_choice) == "table", "tool failed")
+ assert(r.tool_choice.type == "function", "tool type")
+ assert(r.tool_choice["function"].name == "search", "tool name")
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
--- no_error_log
[error]
-=== TEST 7: Anthropic conversion handles null usage object
---- request
-POST /v1/messages
-{"model":"test-model","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}
---- more_headers
-Content-Type: application/json
-X-AI-Fixture: openai/null-usage.json
---- error_code: 200
---- response_body_like eval
-qr/"input_tokens":0/
+=== TEST 18: disable_parallel_tool_use → parallel_tool_calls=false
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ local r = converter.convert_request({
+ model = "claude-sonnet-4-20250514", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ tools = {{ name = "f", input_schema = {} }},
+ tool_choice = { type = "auto", disable_parallel_tool_use =
true },
+ }, ctx)
+ assert(r.parallel_tool_calls == false, "parallel_tool_calls not
false")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
--- no_error_log
[error]
-=== TEST 8: Anthropic conversion handles null message fields
---- request
-POST /v1/messages
-{"model":"test-model","max_tokens":100,"messages":[{"role":"user","content":"test"}]}
---- more_headers
-Content-Type: application/json
-X-AI-Fixture: openai/null-message.json
---- error_code: 200
---- response_body_like eval
-qr/"type":"text"/
---- no_error_log
-[error]
+=== TEST 19: thinking config budget thresholds
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+ -- low: < 4096
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ thinking = { type = "enabled", budget_tokens = 2000 },
+ }, ctx)
+ assert(r.reasoning_effort == "low", "low: " ..
tostring(r.reasoning_effort))
+ -- medium: 4096 <= x < 16384
+ r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ thinking = { type = "enabled", budget_tokens = 8000 },
+ }, ctx)
+ assert(r.reasoning_effort == "medium", "medium: " ..
tostring(r.reasoning_effort))
-=== TEST 9: Anthropic conversion handles null function in tool_calls
---- request
-POST /v1/messages
-{"model":"test-model","max_tokens":100,"messages":[{"role":"user","content":"call
tool"}]}
---- more_headers
-Content-Type: application/json
-X-AI-Fixture: openai/null-function.json
---- error_code: 200
---- response_body_like eval
-qr/"type":"tool_use"/
+ -- high: >= 16384
+ r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ thinking = { type = "enabled", budget_tokens = 32000 },
+ }, ctx)
+ assert(r.reasoning_effort == "high", "high: " ..
tostring(r.reasoning_effort))
+
+ -- disabled: no reasoning_effort
+ r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ thinking = { type = "disabled" },
+ }, ctx)
+ assert(r.reasoning_effort == nil, "disabled should be nil")
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 20: image content block conversion
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{
+ role = "user",
+ content = {
+ { type = "text", text = "What is this?" },
+ { type = "image", source = {
+ type = "base64",
+ media_type = "image/jpeg",
+ data = "abc123"
+ }},
+ }
+ }},
+ }, ctx)
+
+ -- Should be content array (multimodal)
+ local msg = r.messages[1]
+ assert(type(msg.content) == "table", "should be array")
+ assert(msg.content[1].type == "text", "first is text")
+ assert(msg.content[2].type == "image_url", "second is image_url")
+ assert(msg.content[2].image_url.url ==
"data:image/jpeg;base64,abc123",
+ "url mismatch: " .. msg.content[2].image_url.url)
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 21: document (PDF) content block conversion
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{
+ role = "user",
+ content = {
+ { type = "text", text = "Summarize this PDF" },
+ { type = "document", source = {
+ type = "base64",
+ media_type = "application/pdf",
+ data = "JVBER"
+ }},
+ }
+ }},
+ }, ctx)
+
+ local msg = r.messages[1]
+ assert(type(msg.content) == "table", "should be array")
+ assert(msg.content[2].type == "image_url", "second is image_url")
+ assert(msg.content[2].image_url.url ==
"data:application/pdf;base64,JVBER",
+ "url: " .. msg.content[2].image_url.url)
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 22: tool_result with array content (text + image)
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{
+ role = "user",
+ content = {
+ { type = "tool_result", tool_use_id = "call_1",
content = {
+ { type = "text", text = "Screenshot taken" },
+ { type = "image", source = {
+ type = "base64", media_type = "image/png",
data = "img"
+ }},
+ }},
+ }
+ }},
+ }, ctx)
+
+ -- tool_result with image → content array with image_url
+ local tool_msg = r.messages[1]
+ assert(tool_msg.role == "tool", "role: " .. tool_msg.role)
+ assert(tool_msg.tool_call_id == "call_1", "id mismatch")
+ assert(type(tool_msg.content) == "table", "content should be
array")
+ assert(tool_msg.content[1].type == "text", "first text")
+ assert(tool_msg.content[2].type == "image_url", "second image_url")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 23: empty tools array does NOT produce tools field (Bug 1 fix)
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ tools = {},
+ }, ctx)
+
+ assert(r.tools == nil, "empty tools should not produce tools
field")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 24: response_format from output_config (json_schema)
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ output_config = {
+ type = "json_schema",
+ json_schema = { name = "response", schema = { type =
"object" } },
+ },
+ }, ctx)
+
+ assert(r.response_format ~= nil, "response_format missing")
+ assert(r.response_format.type == "json_schema", "type: " ..
r.response_format.type)
+ assert(r.response_format.json_schema.name == "response", "schema
name")
+ -- output_config should NOT leak
+ assert(r.output_config == nil, "output_config leaked")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 25: response_format from output_format (json_object)
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ output_format = { type = "json_object" },
+ }, ctx)
+
+ assert(r.response_format ~= nil, "response_format missing")
+ assert(r.response_format.type == "json_object", "type")
+ assert(r.output_format == nil, "output_format leaked")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 26: cache_control preserved on tool definitions
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ tools = {{
+ name = "search",
+ description = "Search the web",
+ input_schema = { type = "object" },
+ cache_control = { type = "ephemeral" },
+ }},
+ }, ctx)
+
+ assert(r.tools[1].cache_control ~= nil, "cache_control missing on
tool")
+ assert(r.tools[1].cache_control.type == "ephemeral", "type
mismatch")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 27: tool_use with empty input (no arguments)
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{
+ role = "assistant",
+ content = {{
+ type = "tool_use",
+ id = "call_empty",
+ name = "get_time",
+ input = {},
+ }},
+ }},
+ }, ctx)
+
+ local msg = r.messages[1]
+ assert(msg.tool_calls ~= nil, "tool_calls missing")
+ assert(msg.tool_calls[1]["function"].arguments == "{}",
+ "args: " .. msg.tool_calls[1]["function"].arguments)
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 28: header conversion (x-api-key → Authorization, remove anthropic-*)
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+
+ local headers = {
+ ["x-api-key"] = "sk-ant-123",
+ ["anthropic-version"] = "2023-06-01",
+ ["anthropic-beta"] = "messages-2024",
+ ["x-stainless-arch"] = "x86_64",
+ ["x-stainless-os"] = "linux",
+ ["content-type"] = "application/json",
+ }
+
+ converter.convert_headers(headers)
+
+ assert(headers["authorization"] == "Bearer sk-ant-123",
+ "auth: " .. tostring(headers["authorization"]))
+ assert(headers["x-api-key"] == nil, "x-api-key not removed")
+ assert(headers["anthropic-version"] == nil, "anthropic-version not
removed")
+ assert(headers["anthropic-beta"] == nil, "anthropic-beta not
removed")
+ assert(headers["x-stainless-arch"] == nil, "x-stainless-arch not
removed")
+ assert(headers["x-stainless-os"] == nil, "x-stainless-os not
removed")
+ assert(headers["content-type"] == "application/json",
"content-type preserved")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 29: header conversion does not overwrite existing Authorization
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+
+ local headers = {
+ ["x-api-key"] = "sk-ant-123",
+ ["authorization"] = "Bearer existing-token",
+ }
+
+ converter.convert_headers(headers)
+
+ assert(headers["authorization"] == "Bearer existing-token",
+ "should not overwrite existing auth")
+ assert(headers["x-api-key"] == nil, "x-api-key should still be
removed")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 30: billing header cch= stripping
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ -- cch at end
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ system = {{
+ type = "text",
+ text = "x-anthropic-billing-header:abc=123;cch=456",
+ }},
+ messages = {{ role = "user", content = "hi" }},
+ }, ctx)
+ local sys = r.messages[1]
+ assert(sys.role == "system", "role")
+ -- cch should be stripped
+ assert(not sys.content:find("cch="), "cch not stripped: " ..
sys.content)
+ assert(sys.content:find("abc=123"), "abc preserved: " ..
sys.content)
+
+ -- no cch - unchanged
+ r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ system = {{
+ type = "text",
+ text = "x-anthropic-billing-header:abc=123;def=789",
+ }},
+ messages = {{ role = "user", content = "hi" }},
+ }, ctx)
+ sys = r.messages[1]
+ assert(sys.content:find("abc=123"), "no cch - abc: " ..
sys.content)
+ assert(sys.content:find("def=789"), "no cch - def: " ..
sys.content)
+
+ -- non billing header - left alone
+ r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ system = {{ type = "text", text = "Just a normal system
prompt" }},
+ messages = {{ role = "user", content = "hi" }},
+ }, ctx)
+ sys = r.messages[1]
+ assert(sys.content == "Just a normal system prompt", "normal
prompt")
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 31: set route for SSE streaming tests
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/3',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/v1/messages/stream",
+ "plugins": {
+ "ai-proxy-multi": {
+ "instances": [
+ {
+ "name": "streaming-backend",
+ "provider": "openai-compatible",
+ "weight": 1,
+ "auth": {
+ "header": {
+ "Authorization": "Bearer tok"
+ }
+ },
+ "options": {
+ "model": "gpt-4o",
+ "stream": true
+ },
+ "override": {
+ "endpoint":
"http://localhost:7737/v1/chat/completions"
+ }
+ }
+ ],
+ "ssl_verify": false
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 32: streaming - reasoning_content delta → thinking block events
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+
+ local state = { is_first = true }
+
+ -- First chunk with reasoning
+ local events = converter.convert_sse_events({
+ type = "data",
+ data = {
+ id = "chatcmpl-1",
+ model = "o1",
+ choices = {{ delta = { reasoning_content = "Let me " } }},
+ },
+ }, {}, state)
+
+ assert(#events >= 2, "need message_start + content_block_start +
delta")
+ -- First event should be message_start
+ local msg_start = core.json.decode(events[1].data)
+ assert(msg_start.type == "message_start", "first is message_start")
+ -- Second should be content_block_start (thinking)
+ local block_start = core.json.decode(events[2].data)
+ assert(block_start.type == "content_block_start", "second is
block_start")
+ assert(block_start.content_block.type == "thinking", "block type
is thinking")
+ -- Third should be thinking_delta
+ local delta = core.json.decode(events[3].data)
+ assert(delta.type == "content_block_delta", "third is delta")
+ assert(delta.delta.type == "thinking_delta", "delta type: " ..
delta.delta.type)
+ assert(delta.delta.thinking == "Let me ", "thinking text")
+
+ -- Continue reasoning
+ events = converter.convert_sse_events({
+ type = "data",
+ data = {
+ choices = {{ delta = { reasoning_content = "think..." } }},
+ },
+ }, {}, state)
+ assert(#events == 1, "just a delta")
+ delta = core.json.decode(events[1].data)
+ assert(delta.delta.thinking == "think...", "continued thinking")
+
+ -- Transition to text
+ events = converter.convert_sse_events({
+ type = "data",
+ data = {
+ choices = {{ delta = { content = "The answer" } }},
+ },
+ }, {}, state)
+ -- Should close thinking block and start text block
+ assert(#events >= 3, "stop + start + delta, got " .. #events)
+ local stop = core.json.decode(events[1].data)
+ assert(stop.type == "content_block_stop", "close thinking")
+ local text_start = core.json.decode(events[2].data)
+ assert(text_start.content_block.type == "text", "text block start")
+ local text_delta = core.json.decode(events[3].data)
+ assert(text_delta.delta.text == "The answer", "text content")
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 33: streaming - null/empty finish_reason does NOT stop stream
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+
+ local state = { is_first = true }
+
+ -- Init
+ converter.convert_sse_events({
+ type = "data",
+ data = { id = "x", model = "m", choices = {{ delta = { content
= "hi" } }} },
+ }, {}, state)
+
+ -- Chunk with null finish_reason (like cjson.null being nil after
decode)
+ local events = converter.convert_sse_events({
+ type = "data",
+ data = { choices = {{ delta = { content = " there" },
finish_reason = nil }} },
+ }, {}, state)
+ -- Should NOT trigger message_stop
+ assert(not state.is_done, "nil finish_reason should not stop")
+
+ -- Chunk with empty string finish_reason
+ events = converter.convert_sse_events({
+ type = "data",
+ data = { choices = {{ delta = { content = "!" }, finish_reason
= "" }} },
+ }, {}, state)
+ assert(not state.is_done, "empty finish_reason should not stop")
+
+ -- Chunk with "null" string
+ events = converter.convert_sse_events({
+ type = "data",
+ data = { choices = {{ delta = {}, finish_reason = "null" }} },
+ }, {}, state)
+ assert(not state.is_done, "\"null\" string should not stop")
+
Review Comment:
This test covers `finish_reason = "null"`, but it doesn’t cover
whitespace-padded variants (e.g. " null ") which would currently be treated as
a real finish_reason after trimming and stop the stream. Adding a case here
would prevent regressions in finish_reason normalization.
```suggestion
-- Chunk with whitespace-padded "null" string
events = converter.convert_sse_events({
type = "data",
data = { choices = {{ delta = {}, finish_reason = " null "
}} },
}, {}, state)
assert(not state.is_done, "\" null \" string should not stop")
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]