This is an automated email from the ASF dual-hosted git repository.
nic-6443 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 adf0c7b3d feat(ai-proxy): rewrite Anthropic-to-OpenAI converter with
whitelist body construction (#13321)
adf0c7b3d is described below
commit adf0c7b3d7ad54af196d46b75b44cfd5bc7c5d59
Author: Nic <[email protected]>
AuthorDate: Wed May 6 10:32:09 2026 +0800
feat(ai-proxy): rewrite Anthropic-to-OpenAI converter with whitelist body
construction (#13321)
---
.../anthropic-messages-to-openai-chat.lua | 825 ++++++++--
apisix/plugins/ai-providers/base.lua | 5 +
t/fixtures/openai/chat-error.json | 7 +
.../openai/chat-with-multiple-tool-calls.json | 34 +
t/fixtures/openai/chat-with-reasoning.json | 25 +
t/fixtures/openai/chat-with-tool-calls.json | 26 +
t/plugin/ai-proxy-anthropic.t | 1663 ++++++++++++++++++--
7 files changed, 2291 insertions(+), 294 deletions(-)
diff --git
a/apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua
b/apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua
index 518aba8d4..7d0b1b406 100644
---
a/apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua
+++
b/apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua
@@ -20,16 +20,22 @@
-- OpenAI Chat Completions format, and converts provider responses
-- back from OpenAI to Anthropic format.
--
--- Converters work DOWNSTREAM of adapters: the target adapter (openai-chat)
--- parses the provider's response, and this converter transforms the parsed
--- result into the client's format (Anthropic Messages).
+-- Uses whitelist body construction: the outgoing OpenAI body is built
+-- from scratch with only explicitly converted fields. Unknown Anthropic
+-- fields never reach the upstream provider.
local core = require("apisix.core")
local table = table
local type = type
+local pairs = pairs
local ipairs = ipairs
local tostring = tostring
local setmetatable = setmetatable
+local ngx_re_gsub = ngx.re.gsub
+local ngx_re_find = ngx.re.find
+local math_max = math.max
+local string_sub = string.sub
+local string_len = string.len
local _M = {
from = "anthropic-messages",
@@ -37,6 +43,25 @@ local _M = {
}
+-- Anthropic built-in tool type prefixes (no input_schema, OpenAI can't handle
them)
+local BUILTIN_TOOL_PREFIXES = {
+ "computer_", "bash_", "text_editor_", "web_search", "code_execution_"
+}
+
+-- OpenAI tool name constraints: max 64 chars, only [a-zA-Z0-9_-]
+local TOOL_NAME_MAX_LEN = 64
+
+local function sanitize_tool_name(name)
+ -- Replace invalid characters with underscore
+ local sanitized = ngx_re_gsub(name, "[^a-zA-Z0-9_-]", "_", "jo")
+ -- Truncate to max length
+ if string_len(sanitized) > TOOL_NAME_MAX_LEN then
+ sanitized = string_sub(sanitized, 1, TOOL_NAME_MAX_LEN)
+ end
+ return sanitized
+end
+
+
-- SSE event helpers
local function make_sse_event(event_type, data)
return { type = event_type, data = core.json.encode(data) }
@@ -70,9 +95,155 @@ 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
+ if not source.data or source.data == "" then
+ return nil
+ end
+ return {
+ type = "image_url",
+ image_url = {
+ url = "data:" .. (source.media_type or "image/png")
+ .. ";base64," .. source.data,
+ },
+ }
+ 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
+ if not source.data or source.data == "" then
+ return nil
+ end
+ return {
+ type = "image_url",
+ image_url = {
+ url = "data:" .. (source.media_type or "application/pdf")
+ .. ";base64," .. source.data,
+ },
+ }
+ 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.
+-- Always concatenates text blocks into a single string (cache_control is
stripped).
+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
+
+ -- Simple concatenation (cache_control stripped: OpenAI doesn't support it)
+ local parts = {}
+ for _, block in ipairs(system) do
+ if type(block) == "table" and block.type == "text"
+ and type(block.text) == "string" then
+ local cleaned = strip_cch_from_billing(block.text)
+ if cleaned then
+ table.insert(parts, cleaned)
+ end
+ end
+ end
+ local text = table.concat(parts, "")
+ if text == "" then
+ return nil
+ end
+ return { role = "system", content = text }
+end
+
+
--- Convert an incoming Anthropic request to OpenAI Chat format.
function _M.convert_request(request_table, ctx)
if type(request_table) ~= "table" then
@@ -84,100 +255,235 @@ 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
+ if openai_body.stream then
+ openai_body.stream_options = { include_usage = true }
end
+ end
- if system_content ~= "" then
- table.insert(messages, {
- role = "system",
- content = system_content
- })
+ -- 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
- openai_body.system = nil
end
- -- 2. Convert Messages (including tool calls and results)
+ -- 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
+
+ -- metadata.user_id → user
+ if type(request_table.metadata) == "table"
+ and type(request_table.metadata.user_id) == "string" then
+ openai_body.user = request_table.metadata.user_id
+ end
+
+ -- service_tier passthrough
+ if type(request_table.service_tier) == "string" then
+ openai_body.service_tier = request_table.service_tier
+ 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
+ end
+
+ -- 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 }
+ table.insert(content_parts, text_part)
+
+ 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
- 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 == "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::
+ -- thinking/redacted_thinking blocks are dropped: OpenAI Chat
Completions
+ -- has no equivalent semantics for past reasoning content as input.
+ -- This is a protocol limitation, not a bug.
end
- if #tool_calls > 0 then
- new_msg.tool_calls = tool_calls
- new_msg.content = new_msg.content ~= "" and new_msg.content or
nil
- end
+ ::CONTINUE_BLOCK::
+ end
- if #tool_results > 0 then
- if new_msg.content and new_msg.content ~= "" then
- table.insert(messages, { role = msg.role, content =
new_msg.content })
+ -- Emit tool_results as separate messages
+ if #tool_results > 0 then
+ -- If there's text alongside tool_results, emit it first
+ if #content_parts > 0 then
+ local text_content = ""
+ for _, p in ipairs(content_parts) do
+ if p.type == "text" then
+ text_content = text_content .. (p.text or "")
+ end
end
- for _, tr in ipairs(tool_results) do
- table.insert(messages, tr)
+ if text_content ~= "" then
+ table.insert(messages, { role = msg.role, content =
text_content })
end
- goto CONTINUE
end
+ for _, tr in ipairs(tool_results) do
+ table.insert(messages, tr)
+ end
+ goto CONTINUE
+ end
+
+ -- Build the message
+ local new_msg = { role = msg.role }
+
+ if #tool_calls > 0 then
+ new_msg.tool_calls = tool_calls
+ -- Text content alongside tool_calls
+ if #content_parts > 0 then
+ local text = ""
+ for _, p in ipairs(content_parts) do
+ if p.type == "text" then
+ text = text .. (p.text or "")
+ end
+ end
+ new_msg.content = text ~= "" and text or nil
+ end
+ elseif has_multimodal or #content_parts > 1 then
+ -- Multimodal or multi-block: keep as content array
+ new_msg.content = content_parts
+ elseif #content_parts == 1 and content_parts[1].type == "text" then
+ -- Single text block: flatten to string
+ new_msg.content = content_parts[1].text
+ else
+ new_msg.content = ""
end
table.insert(messages, new_msg)
@@ -185,33 +491,92 @@ function _M.convert_request(request_table, ctx)
end
openai_body.messages = messages
- -- 3. Convert Tools Definition
- if type(request_table.tools) == "table" then
+ -- 3. Convert tools (only when non-empty)
+ if type(request_table.tools) == "table" and #request_table.tools > 0 then
local openai_tools = {}
- for i, tool in ipairs(request_table.tools) do
- if type(tool) ~= "table" or type(tool.name) ~= "string" or
tool.name == "" then
- return nil, "invalid tool definition at index " .. i
+ local tool_name_map -- lazily created if truncation needed
+ for _, tool in ipairs(request_table.tools) do
+ if type(tool) ~= "table" then
+ goto CONTINUE_TOOL
+ end
+
+ -- Skip Anthropic built-in tools (they have type but no
input_schema)
+ if type(tool.type) == "string" then
+ local is_builtin = false
+ for _, prefix in ipairs(BUILTIN_TOOL_PREFIXES) do
+ if string_sub(tool.type, 1, string_len(prefix)) == prefix
then
+ is_builtin = true
+ break
+ end
+ end
+ if is_builtin then
+ core.log.debug("dropping Anthropic built-in tool '",
tool.type,
+ "': not supported by OpenAI upstream")
+ goto CONTINUE_TOOL
+ end
+ end
+
+ if type(tool.name) ~= "string" or tool.name == "" then
+ goto CONTINUE_TOOL
+ end
+
+ -- Sanitize tool name for OpenAI compatibility
+ local oai_name = tool.name
+ if string_len(oai_name) > TOOL_NAME_MAX_LEN
+ or ngx_re_find(oai_name, "[^a-zA-Z0-9_-]", "jo") then
+ local sanitized = sanitize_tool_name(oai_name)
+ if sanitized ~= oai_name then
+ if not tool_name_map then
+ tool_name_map = {}
+ end
+ -- Disambiguate collisions by appending numeric suffix
+ if tool_name_map[sanitized] then
+ local suffix = 2
+ local candidate
+ repeat
+ local suffix_str = "_" .. suffix
+ local max_base = TOOL_NAME_MAX_LEN -
string_len(suffix_str)
+ candidate = string_sub(sanitized, 1, max_base) ..
suffix_str
+ suffix = suffix + 1
+ until not tool_name_map[candidate]
+ sanitized = candidate
+ end
+ tool_name_map[sanitized] = oai_name
+ oai_name = sanitized
+ end
end
- table.insert(openai_tools, {
+
+ local oai_tool = {
type = "function",
["function"] = {
- name = tool.name,
+ name = oai_name,
description = tool.description,
- parameters = tool.input_schema
- }
- })
+ parameters = tool.input_schema,
+ },
+ }
+ table.insert(openai_tools, oai_tool)
+ ::CONTINUE_TOOL::
+ end
+ if #openai_tools > 0 then
+ openai_body.tools = openai_tools
+ end
+ -- Store tool name mapping in ctx for response restoration
+ if tool_name_map then
+ ctx.anthropic_tool_name_map = tool_name_map
+ -- Fix tool_choice to use sanitized name if applicable
+ if type(openai_body.tool_choice) == "table"
+ and openai_body.tool_choice.type == "function" then
+ local tc_func = openai_body.tool_choice["function"]
+ if tc_func and type(tc_func.name) == "string" then
+ for sanitized, original in pairs(tool_name_map) do
+ if original == tc_func.name then
+ tc_func.name = sanitized
+ break
+ end
+ end
+ end
+ end
end
- openai_body.tools = openai_tools
- end
-
- -- 4. Map Parameters
- if openai_body.max_tokens then
- openai_body.max_completion_tokens = openai_body.max_tokens
- end
-
- if openai_body.stop_sequences then
- openai_body.stop = openai_body.stop_sequences
- openai_body.stop_sequences = nil
end
return openai_body
@@ -224,6 +589,32 @@ function _M.convert_response(res_body, ctx)
return nil, "response body must be a table"
end
+ -- Error passthrough: convert upstream errors to Anthropic error format
+ if res_body.error then
+ local err_obj = res_body.error
+ local err_type = "api_error"
+ if type(err_obj) == "table" then
+ if err_obj.type then
+ err_type = err_obj.type
+ elseif err_obj.code then
+ err_type = err_obj.code
+ end
+ end
+ local err_msg = ""
+ if type(err_obj) == "table" and type(err_obj.message) == "string" then
+ err_msg = err_obj.message
+ elseif type(err_obj) == "string" then
+ err_msg = err_obj
+ end
+ return {
+ type = "error",
+ error = {
+ type = err_type,
+ message = err_msg,
+ },
+ }
+ end
+
local choice = res_body.choices and res_body.choices[1]
if not choice then
return nil, "no choices in response"
@@ -232,13 +623,30 @@ function _M.convert_response(res_body, ctx)
local model = ctx.var.llm_model
local content = {}
- local text = choice.message and choice.message.content
+
+ -- Extract reasoning/thinking from response
+ local msg = choice.message
+ if msg then
+ local reasoning = msg.reasoning_content or msg.reasoning
+ if type(reasoning) == "string" and reasoning ~= "" then
+ table.insert(content, {
+ type = "thinking",
+ thinking = reasoning,
+ signature = "",
+ })
+ end
+ end
+
+ -- Text content
+ local text = msg and msg.content
if type(text) == "string" and text ~= "" then
table.insert(content, { type = "text", text = text })
end
- if choice.message and type(choice.message.tool_calls) == "table" then
- for _, tc in ipairs(choice.message.tool_calls) do
+ -- Tool calls
+ local tool_name_map = ctx.anthropic_tool_name_map
+ if msg and type(msg.tool_calls) == "table" then
+ for _, tc in ipairs(msg.tool_calls) do
local input = {}
if tc["function"] and type(tc["function"].arguments) == "string"
then
local decoded, err = core.json.decode(tc["function"].arguments)
@@ -247,11 +655,16 @@ function _M.convert_response(res_body, ctx)
end
input = decoded
end
+ local tc_name = (tc["function"] and tc["function"].name) or ""
+ -- Restore original Anthropic tool name if it was sanitized
+ if tool_name_map and tool_name_map[tc_name] then
+ tc_name = tool_name_map[tc_name]
+ end
table.insert(content, {
type = "tool_use",
id = tc.id or "",
- name = (tc["function"] and tc["function"].name) or "",
- input = input
+ name = tc_name,
+ input = input,
})
end
end
@@ -260,6 +673,30 @@ function _M.convert_response(res_body, ctx)
content = {{ type = "text", text = "" }}
end
+ -- Usage with cached_tokens handling
+ local usage = {
+ input_tokens = 0,
+ output_tokens = 0,
+ }
+ if res_body.usage then
+ local prompt_tokens = res_body.usage.prompt_tokens or 0
+ local completion_tokens = res_body.usage.completion_tokens or 0
+ local details = res_body.usage.prompt_tokens_details
+
+ usage.output_tokens = completion_tokens
+
+ if type(details) == "table" then
+ local cached = details.cached_tokens or 0
+ usage.input_tokens = math_max(0, prompt_tokens - cached)
+ usage.cache_read_input_tokens = cached
+ if details.cache_creation_input_tokens then
+ usage.cache_creation_input_tokens =
details.cache_creation_input_tokens
+ end
+ else
+ usage.input_tokens = prompt_tokens
+ end
+ end
+
local anthropic_res = {
id = res_body.id,
type = "message",
@@ -267,24 +704,15 @@ function _M.convert_response(res_body, ctx)
model = model or res_body.model,
content = content,
stop_reason = openai_stop_reason_map[choice.finish_reason] or
"end_turn",
- usage = {
- input_tokens = res_body.usage and res_body.usage.prompt_tokens or
0,
- output_tokens = res_body.usage and
res_body.usage.completion_tokens or 0,
- }
+ usage = usage,
}
- if res_body.usage and res_body.usage.prompt_tokens_details then
- anthropic_res.usage.cache_read_input_tokens =
- res_body.usage.prompt_tokens_details.cached_tokens or 0
- end
-
return anthropic_res
end
--- Convert an OpenAI SSE chunk to Anthropic SSE events.
--- state: table to maintain stream state (is_first, content_index, etc.)
-local function openai_to_anthropic_sse(openai_chunk, state)
+local function openai_to_anthropic_sse(openai_chunk, state, tool_name_map)
if type(openai_chunk) ~= "table" then
return {}
end
@@ -300,10 +728,23 @@ local function openai_to_anthropic_sse(openai_chunk,
state)
if state.pending_stop then
local message_delta = state.pending_message_delta
if type(openai_chunk.usage) == "table" and not message_delta.usage
then
+ local details = openai_chunk.usage.prompt_tokens_details
+ local prompt_tokens = openai_chunk.usage.prompt_tokens or 0
+ local cached = 0
+ if type(details) == "table" then
+ cached = details.cached_tokens or 0
+ end
message_delta.usage = {
- input_tokens = openai_chunk.usage.prompt_tokens or 0,
+ input_tokens = math_max(0, prompt_tokens - cached),
output_tokens = openai_chunk.usage.completion_tokens or 0,
}
+ if cached > 0 then
+ message_delta.usage.cache_read_input_tokens = cached
+ end
+ if type(details) == "table" and
details.cache_creation_input_tokens then
+ message_delta.usage.cache_creation_input_tokens =
+ details.cache_creation_input_tokens
+ end
end
table.insert(events, make_sse_event("message_delta",
message_delta))
table.insert(events, make_sse_event("message_stop", { type =
"message_stop" }))
@@ -321,7 +762,7 @@ 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)
@@ -329,24 +770,72 @@ local function openai_to_anthropic_sse(openai_chunk,
state)
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" then
+ local trimmed = fr:match("^%s*(.-)%s*$")
+ if trimmed and trimmed ~= "" and trimmed ~= "null" then
+ finish_reason = trimmed
+ end
+ end
+ end
+
+ -- 2. Handle reasoning/thinking content delta
+ if choice and choice.delta then
+ local reasoning = choice.delta.reasoning_content or
choice.delta.reasoning
+ if type(reasoning) == "string" and reasoning ~= "" then
+ -- Start thinking block if not already open
+ if state.current_block_type ~= "thinking" then
+ if state.current_open_block ~= nil then
+ push_content_block_stop(events, state.current_open_block)
+ end
+ local idx = state.next_content_index
+ state.next_content_index = idx + 1
+ state.current_open_block = idx
+ state.current_block_type = "thinking"
+ push_content_block_start(events, idx, {
+ type = "thinking",
+ thinking = "",
+ })
+ end
+ push_content_block_delta(events, state.current_open_block, {
+ type = "thinking_delta",
+ thinking = reasoning,
+ })
+ end
+ end
+
+ -- 3. Handle text content delta
if choice and choice.delta and type(choice.delta.content) == "string"
and choice.delta.content ~= "" then
- push_content_block_delta(events, 0, {
+ -- Transition from thinking to text block if needed
+ if state.current_block_type ~= "text" then
+ if state.current_open_block ~= nil then
+ push_content_block_stop(events, state.current_open_block)
+ end
+ local idx = state.next_content_index
+ state.next_content_index = idx + 1
+ state.current_open_block = idx
+ state.current_block_type = "text"
+ push_content_block_start(events, idx, { type = "text", text = "" })
+ end
+ push_content_block_delta(events, state.current_open_block, {
type = "text_delta",
text = choice.delta.content,
})
end
- -- 3. Handle tool_calls deltas
+ -- 4. Handle tool_calls deltas
if choice and choice.delta and type(choice.delta.tool_calls) == "table"
then
for _, tc_delta in ipairs(choice.delta.tool_calls) do
if type(tc_delta) ~= "table" then
@@ -361,15 +850,21 @@ local function openai_to_anthropic_sse(openai_chunk,
state)
if state.current_open_block ~= nil then
push_content_block_stop(events, state.current_open_block)
end
- state.content_index = state.content_index + 1
- state.tool_call_indices[tc_idx] = state.content_index
- state.current_open_block = state.content_index
+ local idx = state.next_content_index
+ state.next_content_index = idx + 1
+ state.tool_call_indices[tc_idx] = idx
+ state.current_open_block = idx
+ state.current_block_type = "tool_use"
local fn = tc_delta["function"] or {}
- push_content_block_start(events, state.content_index, {
+ local tool_name = fn.name or ""
+ if tool_name_map and tool_name_map[tool_name] then
+ tool_name = tool_name_map[tool_name]
+ end
+ push_content_block_start(events, idx, {
type = "tool_use",
id = tc_delta.id or "",
- name = fn.name or "",
+ name = tool_name,
input = {},
})
end
@@ -387,25 +882,35 @@ local function openai_to_anthropic_sse(openai_chunk,
state)
end
end
- -- 4. Handle stream completion
- if choice and type(choice.finish_reason) == "string" then
+ -- 5. Handle stream completion (only when finish_reason is valid)
+ if finish_reason then
if state.current_open_block ~= nil then
push_content_block_stop(events, state.current_open_block)
state.current_open_block = nil
+ state.current_block_type = nil
end
local message_delta = {
type = "message_delta",
delta = {
- stop_reason = openai_stop_reason_map[choice.finish_reason] or
"end_turn",
+ stop_reason = openai_stop_reason_map[finish_reason] or
"end_turn",
},
}
if type(openai_chunk.usage) == "table" then
+ local details = openai_chunk.usage.prompt_tokens_details
+ local prompt_tokens = openai_chunk.usage.prompt_tokens or 0
+ local cached = 0
+ if type(details) == "table" then
+ cached = details.cached_tokens or 0
+ end
message_delta.usage = {
- input_tokens = openai_chunk.usage.prompt_tokens or 0,
+ input_tokens = math_max(0, prompt_tokens - cached),
output_tokens = openai_chunk.usage.completion_tokens or 0,
}
+ if cached > 0 then
+ message_delta.usage.cache_read_input_tokens = cached
+ end
end
state.pending_message_delta = message_delta
@@ -418,30 +923,80 @@ end
--- Convert parsed SSE events (from openai-chat adapter) to Anthropic format.
--- Called with the result of openai_chat_adapter.parse_sse_event().
--- @param parsed table Parsed SSE event from target adapter
--- @param ctx table Request context
--- @param state table Mutable converter state
--- @return table|nil List of Anthropic SSE events to send to client
-function _M.convert_sse_events(parsed, _, state)
+function _M.convert_sse_events(parsed, ctx, state)
if not parsed or parsed.type == "skip" then
return nil
end
+ -- Pass-through ping events to keep long-lived connections alive
+ if parsed.type == "ping" then
+ return { make_sse_event("ping", { type = "ping" }) }
+ end
+
if parsed.type == "done" then
-- Flush any deferred message_stop
if state.pending_stop then
- return openai_to_anthropic_sse({ choices = {} }, state)
+ return openai_to_anthropic_sse({ choices = {} }, state,
+ ctx and ctx.anthropic_tool_name_map)
+ end
+ -- If no pending_stop but stream never finished properly, emit minimal
stop
+ if not state.is_done and state.is_first == false then
+ if state.current_open_block ~= nil then
+ local events = {}
+ push_content_block_stop(events, state.current_open_block)
+ state.current_open_block = nil
+ local message_delta = {
+ type = "message_delta",
+ delta = { stop_reason = "end_turn" },
+ usage = { input_tokens = 0, output_tokens = 0 },
+ }
+ table.insert(events, make_sse_event("message_delta",
message_delta))
+ table.insert(events, make_sse_event("message_stop", { type =
"message_stop" }))
+ state.is_done = true
+ return events
+ end
end
return nil
end
if parsed.data then
- return openai_to_anthropic_sse(parsed.data, state)
+ return openai_to_anthropic_sse(parsed.data, state,
+ ctx and ctx.anthropic_tool_name_map)
end
return nil
end
+--- Convert headers for the upstream request.
+-- Transforms Anthropic-specific headers to OpenAI-compatible format.
+function _M.convert_headers(headers)
+ if type(headers) ~= "table" then
+ return
+ end
+
+ -- Convert x-api-key to Authorization Bearer (if no Authorization already
set)
+ local api_key = headers["x-api-key"]
+ if type(api_key) == "string" and api_key ~= "" then
+ if not headers["authorization"] then
+ headers["authorization"] = "Bearer " .. api_key
+ end
+ headers["x-api-key"] = nil
+ end
+
+ -- Remove Anthropic-specific and SDK telemetry headers
+ local to_remove = {}
+ for k in pairs(headers) do
+ if type(k) == "string" then
+ if k:sub(1, 10) == "anthropic-" or k:sub(1, 12) == "x-stainless-"
then
+ table.insert(to_remove, k)
+ end
+ end
+ end
+ for _, k in ipairs(to_remove) do
+ headers[k] = nil
+ end
+end
+
+
return _M
diff --git a/apisix/plugins/ai-providers/base.lua
b/apisix/plugins/ai-providers/base.lua
index 17d9238da..f16b72bbb 100644
--- a/apisix/plugins/ai-providers/base.lua
+++ b/apisix/plugins/ai-providers/base.lua
@@ -172,6 +172,11 @@ function _M.build_request(self, ctx, conf, request_body,
opts)
headers["authorization"] = "Bearer " .. token
end
+ -- Protocol converter header transformation (e.g. Anthropic → OpenAI
headers)
+ if ctx.ai_converter and ctx.ai_converter.convert_headers then
+ ctx.ai_converter.convert_headers(headers)
+ end
+
local params = {
method = "POST",
scheme = scheme,
diff --git a/t/fixtures/openai/chat-error.json
b/t/fixtures/openai/chat-error.json
new file mode 100644
index 000000000..328874092
--- /dev/null
+++ b/t/fixtures/openai/chat-error.json
@@ -0,0 +1,7 @@
+{
+ "error": {
+ "type": "invalid_request_error",
+ "message": "The model does not exist.",
+ "code": "model_not_found"
+ }
+}
diff --git a/t/fixtures/openai/chat-with-multiple-tool-calls.json
b/t/fixtures/openai/chat-with-multiple-tool-calls.json
new file mode 100644
index 000000000..b8b0141e2
--- /dev/null
+++ b/t/fixtures/openai/chat-with-multiple-tool-calls.json
@@ -0,0 +1,34 @@
+{
+ "id": "chatcmpl-multi-tools",
+ "object": "chat.completion",
+ "model": "gpt-4o",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "Let me check both.",
+ "tool_calls": [
+ {
+ "id": "call_111",
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "arguments": "{\"location\":\"NYC\"}"
+ }
+ },
+ {
+ "id": "call_222",
+ "type": "function",
+ "function": {
+ "name": "get_time",
+ "arguments": "{\"timezone\":\"EST\"}"
+ }
+ }
+ ]
+ },
+ "finish_reason": "tool_calls"
+ }
+ ],
+ "usage": { "prompt_tokens": 60, "completion_tokens": 30, "total_tokens": 90 }
+}
diff --git a/t/fixtures/openai/chat-with-reasoning.json
b/t/fixtures/openai/chat-with-reasoning.json
new file mode 100644
index 000000000..86bc03880
--- /dev/null
+++ b/t/fixtures/openai/chat-with-reasoning.json
@@ -0,0 +1,25 @@
+{
+ "id": "chatcmpl-reason1",
+ "object": "chat.completion",
+ "model": "o1-preview",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "The answer is 42.",
+ "reasoning_content": "Let me think step by step about this problem."
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 30,
+ "completion_tokens": 15,
+ "total_tokens": 45,
+ "prompt_tokens_details": {
+ "cached_tokens": 10,
+ "cache_creation_input_tokens": 5
+ }
+ }
+}
diff --git a/t/fixtures/openai/chat-with-tool-calls.json
b/t/fixtures/openai/chat-with-tool-calls.json
new file mode 100644
index 000000000..b65fcb21a
--- /dev/null
+++ b/t/fixtures/openai/chat-with-tool-calls.json
@@ -0,0 +1,26 @@
+{
+ "id": "chatcmpl-tool1",
+ "object": "chat.completion",
+ "model": "gpt-4o",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [
+ {
+ "id": "call_abc123",
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "arguments": "{\"location\":\"San Francisco\"}"
+ }
+ }
+ ]
+ },
+ "finish_reason": "tool_calls"
+ }
+ ],
+ "usage": { "prompt_tokens": 50, "completion_tokens": 20, "total_tokens": 70 }
+}
diff --git a/t/plugin/ai-proxy-anthropic.t b/t/plugin/ai-proxy-anthropic.t
index f1cd43687..e5912d192 100644
--- a/t/plugin/ai-proxy-anthropic.t
+++ b/t/plugin/ai-proxy-anthropic.t
@@ -45,34 +45,33 @@ run_tests();
__DATA__
-=== TEST 1: set route with right auth header
+=== TEST 1: set route for request conversion tests (capture forwarded body)
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
+ -- Route that echoes the forwarded body (to verify request
conversion)
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
- "uri": "/anything",
+ "uri": "/v1/messages",
"plugins": {
"ai-proxy-multi": {
"instances": [
{
- "name": "anthropic",
- "provider": "anthropic",
+ "name": "openai-backend",
+ "provider": "openai-compatible",
"weight": 1,
"auth": {
"header": {
- "Authorization": "Bearer token"
+ "Authorization": "Bearer
test-token"
}
},
"options": {
- "model": "claude-sonnet-4-20250514",
- "max_tokens": 512,
- "temperature": 1.0
+ "model": "gpt-4o"
},
"override": {
- "endpoint":
"http://127.0.0.1:1980/v1/chat/completions"
+ "endpoint": "http://localhost:1980"
}
}
],
@@ -93,178 +92,140 @@ passed
-=== TEST 2: send request
+=== TEST 2: simple text message conversion
--- request
-POST /anything
-{ "messages": [ { "role": "system", "content": "You are a mathematician" }, {
"role": "user", "content": "What is 1+1?"} ] }
+POST /v1/messages
+{"model":"claude-sonnet-4-20250514","max_tokens":1024,"messages":[{"role":"user","content":"Hello"}]}
--- more_headers
-Authorization: Bearer token
+Content-Type: application/json
X-AI-Fixture: openai/chat-basic.json
--- error_code: 200
---- response_body eval
-qr/\{ "content": "1 \+ 1 = 2\.", "role": "assistant" \}/
+--- response_body_like eval
+qr/(?=.*"type":"message")(?=.*"type":"text")(?=.*"stop_reason":"end_turn")/
-=== TEST 3: set route with stream = true (SSE)
---- 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": "/anything",
- "plugins": {
- "ai-proxy-multi": {
- "instances": [
- {
- "name": "anthropic",
- "provider": "anthropic",
- "weight": 1,
- "auth": {
- "header": {
- "Authorization": "Bearer token"
- }
- },
- "options": {
- "model": "claude-sonnet-4-20250514",
- "max_tokens": 512,
- "temperature": 1.0,
- "stream": true
- },
- "override": {
- "endpoint":
"http://localhost:7737/v1/chat/completions"
- }
- }
- ],
- "ssl_verify": false
- }
- }
- }]]
- )
+=== TEST 3: system prompt as string
+--- request
+POST /v1/messages
+{"model":"claude-sonnet-4-20250514","max_tokens":512,"system":"You are
helpful.","messages":[{"role":"user","content":"Hi"}]}
+--- more_headers
+Content-Type: application/json
+X-AI-Fixture: openai/chat-basic.json
+--- error_code: 200
+--- response_body_like eval
+qr/"type":"message"/
+--- no_error_log
+[error]
- if code >= 300 then
- ngx.status = code
- end
- ngx.say(body)
- }
- }
---- response_body
-passed
+=== TEST 4: system prompt as content blocks array with cache_control
+--- request
+POST /v1/messages
+{"model":"claude-sonnet-4-20250514","max_tokens":512,"system":[{"type":"text","text":"You
are a coding
assistant.","cache_control":{"type":"ephemeral"}},{"type":"text","text":"Always
write tests."}],"messages":[{"role":"user","content":"Hi"}]}
+--- more_headers
+Content-Type: application/json
+X-AI-Fixture: openai/chat-basic.json
+--- error_code: 200
+--- response_body_like eval
+qr/"type":"message"/
+--- no_error_log
+[error]
-=== TEST 4: test is SSE works as expected
---- config
- location /t {
- content_by_lua_block {
- local http = require("resty.http")
- local httpc = http.new()
- local core = require("apisix.core")
- local ok, err = httpc:connect({
- scheme = "http",
- host = "localhost",
- port = ngx.var.server_port,
- })
- if not ok then
- ngx.status = 500
- ngx.say(err)
- return
- end
+=== TEST 5: tool_use in assistant message → tool_calls conversion
+--- request
+POST /v1/messages
+{"model":"claude-sonnet-4-20250514","max_tokens":1024,"messages":[{"role":"user","content":"What
is the
weather?"},{"role":"assistant","content":[{"type":"tool_use","id":"call_abc","name":"get_weather","input":{"location":"SF"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_abc","content":"Sunny,
72F"}]}]}
+--- more_headers
+Content-Type: application/json
+X-AI-Fixture: openai/chat-basic.json
+--- error_code: 200
+--- response_body_like eval
+qr/"type":"message"/
+--- no_error_log
+[error]
- local params = {
- method = "POST",
- headers = {
- ["Content-Type"] = "application/json",
- },
- path = "/anything",
- body = [[{
- "messages": [
- { "role": "system", "content": "some content" }
- ],
- "stream": true
- }]],
- }
- local res, err = httpc:request(params)
- if not res then
- ngx.status = 500
- ngx.say(err)
- 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)
- end
+=== TEST 6: response with tool_calls → Anthropic tool_use blocks
+--- request
+POST /v1/messages
+{"model":"claude-sonnet-4-20250514","max_tokens":1024,"messages":[{"role":"user","content":"Get
the weather"}]}
+--- more_headers
+Content-Type: application/json
+X-AI-Fixture: openai/chat-with-tool-calls.json
+--- error_code: 200
+--- response_body_like eval
+qr/(?s)(?=.*"type":"tool_use")(?=.*"name":"get_weather")(?=.*"id":"call_abc123")(?=.*"stop_reason":"tool_use")/
+--- no_error_log
+[error]
- ngx.print(#final_res .. final_res[6])
- }
- }
+
+
+=== TEST 7: response with reasoning_content → thinking block
+--- request
+POST /v1/messages
+{"model":"claude-sonnet-4-20250514","max_tokens":1024,"messages":[{"role":"user","content":"Think
about this"}]}
+--- more_headers
+Content-Type: application/json
+X-AI-Fixture: openai/chat-with-reasoning.json
+--- error_code: 200
--- response_body_like eval
-qr/6data: \[DONE\]\n\n/
+qr/(?s)(?=.*"type":"thinking")(?=.*"thinking":"Let me think step by
step)(?=.*"signature":"")(?=.*"type":"text")(?=.*The answer is 42)/
+--- no_error_log
+[error]
-=== TEST 5: set route for Anthropic null-field tests
---- 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
- }
- }
- }]]
- )
+=== TEST 8: cached_tokens deducted from input_tokens
+--- request
+POST /v1/messages
+{"model":"claude-sonnet-4-20250514","max_tokens":1024,"messages":[{"role":"user","content":"test"}]}
+--- more_headers
+Content-Type: application/json
+X-AI-Fixture: openai/chat-with-reasoning.json
+--- error_code: 200
+--- response_body_like eval
+qr/(?s)(?=.*"input_tokens":20)(?=.*"cache_read_input_tokens":10)(?=.*"cache_creation_input_tokens":5)/
+--- no_error_log
+[error]
- if code >= 300 then
- ngx.status = code
- end
- ngx.say(body)
- }
- }
---- response_body
-passed
+
+
+=== TEST 9: error response passthrough
+--- request
+POST /v1/messages
+{"model":"nonexistent","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}
+--- more_headers
+Content-Type: application/json
+X-AI-Fixture: openai/chat-error.json
+--- error_code: 200
+--- response_body_like eval
+qr/(?s)(?=.*"type":"error")(?=.*"invalid_request_error")(?=.*model does not
exist)/
+--- no_error_log
+[error]
+
+
+
+=== TEST 10: response with multiple tool_calls + text → text block + tool_use
blocks
+--- request
+POST /v1/messages
+{"model":"claude-sonnet-4-20250514","max_tokens":1024,"messages":[{"role":"user","content":"check
weather and time"}]}
+--- more_headers
+Content-Type: application/json
+X-AI-Fixture: openai/chat-with-multiple-tool-calls.json
+--- error_code: 200
+--- response_body_like eval
+qr/(?s)(?=.*"type":"text")(?=.*Let me check
both)(?=.*"type":"tool_use")(?=.*get_weather)(?=.*get_time)/
+--- no_error_log
+[error]
-=== TEST 6: Anthropic conversion handles null prompt_tokens_details
-Test that cjson.null (from JSON null) does not crash the converter.
+=== TEST 11: null prompt_tokens_details does not crash
--- request
POST /v1/messages
{"model":"test-model","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}
@@ -279,7 +240,7 @@ qr/(?s)(?=.*"input_tokens":10)(?=.*"output_tokens":5)/
-=== TEST 7: Anthropic conversion handles null usage object
+=== TEST 12: null usage object handled gracefully
--- request
POST /v1/messages
{"model":"test-model","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}
@@ -294,7 +255,7 @@ qr/"input_tokens":0/
-=== TEST 8: Anthropic conversion handles null message fields
+=== TEST 13: null message fields handled gracefully
--- request
POST /v1/messages
{"model":"test-model","max_tokens":100,"messages":[{"role":"user","content":"test"}]}
@@ -309,7 +270,7 @@ qr/"type":"text"/
-=== TEST 9: Anthropic conversion handles null function in tool_calls
+=== TEST 14: null function in tool_calls handled gracefully
--- request
POST /v1/messages
{"model":"test-model","max_tokens":100,"messages":[{"role":"user","content":"call
tool"}]}
@@ -321,3 +282,1387 @@ X-AI-Fixture: openai/null-function.json
qr/"type":"tool_use"/
--- no_error_log
[error]
+
+
+
+=== TEST 15: 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 core = require("apisix.core")
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+
+ 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" }
+ }
+ }
+
+ local ctx = { var = {} }
+ local result, err = converter.convert_request(request, ctx)
+ if not result then
+ ngx.say("ERROR: " .. (err or "nil"))
+ return
+ end
+
+ -- 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
+
+ -- 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
+
+ -- 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.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 16: tool_choice conversion (auto, any, tool, none)
+--- 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 = {} }
+
+ -- 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))
+
+ -- 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 17: 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 18: 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))
+
+ -- 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 19: 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 20: 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 21: 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 22: 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 23: 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 24: 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 25: cache_control stripped from 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)
+
+ local encoded = core.json.encode(r.tools[1])
+ assert(not encoded:find("cache_control"), "cache_control should be
stripped: " .. encoded)
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 26: 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 27: 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",
+ ["anthropic-custom-header"] = "should-be-removed",
+ ["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["anthropic-custom-header"] == nil,
"anthropic-custom-header 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 28: 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 29: 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 30: 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 31: 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")
+
+ -- Real finish_reason should stop
+ events = converter.convert_sse_events({
+ type = "data",
+ data = { choices = {{ delta = {}, finish_reason = "stop" }} },
+ }, {}, state)
+ assert(state.is_done, "\"stop\" should stop the stream")
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 32: streaming - usage deferred to final chunk after finish_reason
+--- 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 + text
+ converter.convert_sse_events({
+ type = "data",
+ data = { id = "x", model = "m", choices = {{ delta = { content
= "hi" } }} },
+ }, {}, state)
+
+ -- finish_reason without usage (deferred)
+ converter.convert_sse_events({
+ type = "data",
+ data = { choices = {{ delta = {}, finish_reason = "stop" }} },
+ }, {}, state)
+ assert(state.is_done, "should be done")
+ assert(state.pending_stop, "should have pending stop")
+
+ -- Usage arrives in trailing chunk
+ local events = converter.convert_sse_events({
+ type = "data",
+ data = {
+ choices = {},
+ usage = {
+ prompt_tokens = 100,
+ completion_tokens = 50,
+ prompt_tokens_details = { cached_tokens = 20 },
+ },
+ },
+ }, {}, state)
+
+ -- Should now emit message_delta with usage + message_stop
+ assert(#events == 2, "expect 2 events, got " .. #events)
+ local msg_delta = core.json.decode(events[1].data)
+ assert(msg_delta.type == "message_delta", "first is message_delta")
+ assert(msg_delta.usage.input_tokens == 80, "input: " ..
msg_delta.usage.input_tokens)
+ assert(msg_delta.usage.output_tokens == 50, "output: " ..
msg_delta.usage.output_tokens)
+ assert(msg_delta.usage.cache_read_input_tokens == 20,
+ "cached: " ..
tostring(msg_delta.usage.cache_read_input_tokens))
+ local msg_stop = core.json.decode(events[2].data)
+ assert(msg_stop.type == "message_stop", "second is message_stop")
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 33: streaming - dynamic content_block index (thinking → text → tool)
+--- 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 }
+
+ -- Reasoning → index 0
+ converter.convert_sse_events({
+ type = "data",
+ data = { id = "x", model = "m",
+ choices = {{ delta = { reasoning_content = "hmm" } }}
},
+ }, {}, state)
+ assert(state.next_content_index == 1, "after thinking: idx=" ..
state.next_content_index)
+
+ -- Text → index 1
+ converter.convert_sse_events({
+ type = "data",
+ data = { choices = {{ delta = { content = "answer" } }} },
+ }, {}, state)
+ assert(state.next_content_index == 2, "after text: idx=" ..
state.next_content_index)
+
+ -- Tool call → index 2
+ converter.convert_sse_events({
+ type = "data",
+ data = { choices = {{ delta = {
+ tool_calls = {{ index = 0, id = "call_1",
+ ["function"] = { name = "f", arguments =
"" } }}
+ } }} },
+ }, {}, state)
+ assert(state.next_content_index == 3, "after tool: idx=" ..
state.next_content_index)
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 34: streaming - duplicate chunks after message_stop are ignored
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+
+ local state = { is_first = true }
+
+ -- Init + finish
+ converter.convert_sse_events({
+ type = "data",
+ data = { id = "x", model = "m", choices = {{ delta = { content
= "hi" } }} },
+ }, {}, state)
+ converter.convert_sse_events({
+ type = "data",
+ data = { choices = {{ delta = {}, finish_reason = "stop" }},
+ usage = { prompt_tokens = 10, completion_tokens = 5 }
},
+ }, {}, state)
+
+ -- Flush pending
+ local events = converter.convert_sse_events({
+ type = "done",
+ }, {}, state)
+ assert(#events == 2, "flush: " .. #events)
+
+ -- Another "done" after message_stop → ignored
+ events = converter.convert_sse_events({
+ type = "done",
+ }, {}, state)
+ assert(events == nil, "should be nil after stop")
+
+ -- Another data chunk after done → ignored
+ events = converter.convert_sse_events({
+ type = "data",
+ data = { choices = {{ delta = { content = "extra" } }} },
+ }, {}, state)
+ assert(#events == 0, "should produce no events: " .. #events)
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 35: multiple tool_results in single user message → separate tool
messages
+--- 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 = "result 1" },
+ { type = "tool_result", tool_use_id = "call_2",
content = "result 2" },
+ }
+ }},
+ }, ctx)
+
+ -- Should produce 2 separate tool messages
+ assert(#r.messages == 2, "expected 2 messages, got " ..
#r.messages)
+ assert(r.messages[1].role == "tool", "msg 1 role")
+ assert(r.messages[1].tool_call_id == "call_1", "msg 1 id")
+ assert(r.messages[1].content == "result 1", "msg 1 content")
+ assert(r.messages[2].role == "tool", "msg 2 role")
+ assert(r.messages[2].tool_call_id == "call_2", "msg 2 id")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 36: text alongside tool_results → text message + tool messages
+--- 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 = "Here are the results:" },
+ { type = "tool_result", tool_use_id = "call_1",
content = "done" },
+ }
+ }},
+ }, ctx)
+
+ -- text message first, then tool message
+ assert(#r.messages == 2, "expected 2 messages, got " ..
#r.messages)
+ assert(r.messages[1].role == "user", "msg 1 role")
+ assert(r.messages[1].content == "Here are the results:", "msg 1
text")
+ assert(r.messages[2].role == "tool", "msg 2 role")
+ assert(r.messages[2].tool_call_id == "call_1", "msg 2 id")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 37: mixed text + tool_use in assistant message
+--- 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 = "text", text = "Let me search for that." },
+ { type = "tool_use", id = "call_1", name = "search",
+ input = { query = "test" } },
+ }
+ }},
+ }, ctx)
+
+ local msg = r.messages[1]
+ assert(msg.role == "assistant", "role")
+ assert(msg.content == "Let me search for that.", "text content")
+ assert(msg.tool_calls ~= nil, "tool_calls present")
+ assert(#msg.tool_calls == 1, "one tool call")
+ assert(msg.tool_calls[1]["function"].name == "search", "tool name")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 38: stop_sequences → stop 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 = "hi" }},
+ stop_sequences = { "END", "STOP" },
+ }, ctx)
+
+ assert(type(r.stop) == "table", "stop should be table")
+ assert(r.stop[1] == "END", "first stop")
+ assert(r.stop[2] == "STOP", "second stop")
+ assert(r.stop_sequences == nil, "stop_sequences should not leak")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 39: image with URL source type
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ -- Valid URL source
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{
+ role = "user",
+ content = {
+ { type = "text", text = "Describe this" },
+ { type = "image", source = {
+ type = "url",
+ url = "https://example.com/image.png"
+ }},
+ }
+ }},
+ }, ctx)
+
+ local msg = r.messages[1]
+ assert(type(msg.content) == "table", "should be array")
+ assert(msg.content[2].type == "image_url", "type")
+ assert(msg.content[2].image_url.url ==
"https://example.com/image.png", "url")
+
+ -- Empty URL source - should be skipped
+ r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{
+ role = "user",
+ content = {
+ { type = "text", text = "Describe this" },
+ { type = "image", source = { type = "url", url = "" }},
+ }
+ }},
+ }, ctx)
+ msg = r.messages[1]
+ -- Only text should remain (image skipped)
+ assert(msg.content == "Describe this", "empty url skipped: " ..
tostring(msg.content))
+
+ -- nil URL source - should be skipped
+ r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{
+ role = "user",
+ content = {
+ { type = "text", text = "Test" },
+ { type = "image", source = { type = "url" }},
+ }
+ }},
+ }, ctx)
+ msg = r.messages[1]
+ assert(msg.content == "Test", "nil url skipped: " ..
tostring(msg.content))
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 40: stream=true adds stream_options.include_usage
+--- 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, stream = true,
+ messages = {{ role = "user", content = "Hi" }},
+ }, ctx)
+
+ assert(r.stream == true, "stream")
+ assert(type(r.stream_options) == "table", "stream_options exists")
+ assert(r.stream_options.include_usage == true, "include_usage")
+
+ -- Non-streaming should not have stream_options
+ local r2 = converter.convert_request({
+ model = "m", max_tokens = 100, stream = false,
+ messages = {{ role = "user", content = "Hi" }},
+ }, ctx)
+ assert(r2.stream_options == nil, "no stream_options when not
streaming")
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 41: cache_control stripped from system, messages, and tools
+--- 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,
+ system = {
+ { type = "text", text = "System prompt", cache_control = {
type = "ephemeral" } },
+ },
+ messages = {{
+ role = "user",
+ content = {
+ { type = "text", text = "Hello", cache_control = {
type = "ephemeral" } },
+ }
+ }},
+ tools = {{
+ name = "my_tool",
+ description = "A tool",
+ input_schema = { type = "object" },
+ cache_control = { type = "ephemeral" },
+ }},
+ }, ctx)
+
+ -- System: should be plain string, no cache_control
+ assert(r.messages[1].role == "system", "system role")
+ assert(type(r.messages[1].content) == "string", "system is string:
" .. type(r.messages[1].content))
+
+ -- User message: should be flattened string, no cache_control
+ assert(r.messages[2].content == "Hello", "user content flattened")
+
+ -- Tool: no cache_control field
+ local encoded = core.json.encode(r.tools[1])
+ assert(not encoded:find("cache_control"), "no cache_control in
tool: " .. encoded)
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 42: metadata.user_id → user field
+--- 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,
+ metadata = { user_id = "user-123" },
+ messages = {{ role = "user", content = "Hi" }},
+ }, ctx)
+
+ assert(r.user == "user-123", "user field: " .. tostring(r.user))
+
+ -- No metadata: no user field
+ local r2 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "Hi" }},
+ }, ctx)
+ assert(r2.user == nil, "no user when no metadata")
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 43: Anthropic built-in tools are silently skipped
+--- 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 = {
+ { type = "computer_20241022", name = "computer",
display_width_px = 1024 },
+ { type = "bash_20250124", name = "bash" },
+ { type = "text_editor_20250124", name = "text_editor" },
+ { name = "normal_tool", description = "A normal tool",
input_schema = { type = "object" } },
+ },
+ }, ctx)
+
+ -- Only the normal tool should survive
+ assert(#r.tools == 1, "expected 1 tool, got " .. #r.tools)
+ assert(r.tools[1]["function"].name == "normal_tool", "normal tool
name")
+
+ -- All built-in tools: should produce no tools
+ local r2 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "Hi" }},
+ tools = {
+ { type = "web_search_20260209", name = "web_search" },
+ { type = "code_execution_20250522", name = "code_exec" },
+ },
+ }, ctx)
+ assert(r2.tools == nil, "no tools when all are built-in")
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 44: ping SSE event pass-through
+--- 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 }
+
+ local events = converter.convert_sse_events({ type = "ping" }, {},
state)
+
+ assert(type(events) == "table", "events is table")
+ assert(#events == 1, "one event")
+ local decoded = core.json.decode(events[1].data)
+ assert(decoded.type == "ping", "ping type: " ..
tostring(decoded.type))
+ assert(events[1].type == "ping", "event type: " .. events[1].type)
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 45: tool name truncation and mapping
+--- 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 = { llm_model = "gpt-4o" } }
+
+ -- Tool name with 70 chars (exceeds 64 limit)
+ local long_name = string.rep("a", 70)
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "Hi" }},
+ tools = {{
+ name = long_name,
+ description = "Long tool",
+ input_schema = { type = "object" },
+ }},
+ }, ctx)
+
+ -- Should be truncated to 64 chars
+ local oai_name = r.tools[1]["function"].name
+ assert(#oai_name == 64, "truncated to 64: " .. #oai_name)
+
+ -- Mapping stored in ctx
+ assert(ctx.anthropic_tool_name_map ~= nil, "map exists")
+ assert(ctx.anthropic_tool_name_map[oai_name] == long_name, "map
correct")
+
+ -- Response conversion restores original name
+ local res = converter.convert_response({
+ id = "msg_1",
+ choices = {{ message = { tool_calls = {{
+ id = "call_1",
+ type = "function",
+ ["function"] = { name = oai_name, arguments = "{}" },
+ }}}, finish_reason = "tool_calls" }},
+ usage = { prompt_tokens = 10, completion_tokens = 5 },
+ }, ctx)
+ assert(res.content[1].name == long_name, "restored name: " ..
res.content[1].name)
+
+ -- Tool with invalid chars
+ local ctx2 = { var = { llm_model = "gpt-4o" } }
+ local r2 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "Hi" }},
+ tools = {{
+ name = "my tool.with spaces!",
+ description = "Invalid chars",
+ input_schema = { type = "object" },
+ }},
+ }, ctx2)
+ local sanitized = r2.tools[1]["function"].name
+ -- Should only contain valid chars
+ assert(not sanitized:find("[^a-zA-Z0-9_%-]"), "valid chars only: "
.. sanitized)
+
+ -- Collision disambiguation: two tools that sanitize to the same
name
+ local ctx3 = { var = { llm_model = "gpt-4o" } }
+ local r3 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "Hi" }},
+ tools = {
+ { name = "my tool!foo", description = "A", input_schema =
{ type = "object" } },
+ { name = "my tool@foo", description = "B", input_schema =
{ type = "object" } },
+ },
+ }, ctx3)
+ local n1 = r3.tools[1]["function"].name
+ local n2 = r3.tools[2]["function"].name
+ assert(n1 ~= n2, "no collision: " .. n1 .. " vs " .. n2)
+ -- Both map back to different original names
+ assert(ctx3.anthropic_tool_name_map[n1] == "my tool!foo", "map1: "
.. tostring(ctx3.anthropic_tool_name_map[n1]))
+ assert(ctx3.anthropic_tool_name_map[n2] == "my tool@foo", "map2: "
.. tostring(ctx3.anthropic_tool_name_map[n2]))
+
+ -- Collision with max-length names: suffix must not exceed 64 chars
+ local ctx3b = { var = { llm_model = "gpt-4o" } }
+ local long64_a = string.rep("x", 60) .. "!aaa"
+ local long64_b = string.rep("x", 60) .. "@aaa"
+ local r3b = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "Hi" }},
+ tools = {
+ { name = long64_a, description = "A", input_schema = {
type = "object" } },
+ { name = long64_b, description = "B", input_schema = {
type = "object" } },
+ },
+ }, ctx3b)
+ local nb1 = r3b.tools[1]["function"].name
+ local nb2 = r3b.tools[2]["function"].name
+ assert(nb1 ~= nb2, "long collision distinct: " .. nb1 .. " vs " ..
nb2)
+ assert(#nb1 <= 64, "name1 <= 64: " .. #nb1)
+ assert(#nb2 <= 64, "name2 <= 64: " .. #nb2)
+
+ -- tool_choice name is sanitized consistently with tool definitions
+ local ctx4 = { var = { llm_model = "gpt-4o" } }
+ local r4 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "Hi" }},
+ tools = {{
+ name = long_name,
+ description = "Long tool",
+ input_schema = { type = "object" },
+ }},
+ tool_choice = { type = "tool", name = long_name },
+ }, ctx4)
+ local tc_name = r4.tool_choice["function"].name
+ local tool_fn_name = r4.tools[1]["function"].name
+ assert(tc_name == tool_fn_name, "tool_choice matches tool: " ..
tc_name .. " vs " .. tool_fn_name)
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 46: service_tier passthrough
+--- 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,
+ service_tier = "auto",
+ messages = {{ role = "user", content = "Hi" }},
+ }, ctx)
+
+ assert(r.service_tier == "auto", "service_tier: " ..
tostring(r.service_tier))
+
+ -- No service_tier: not present
+ local r2 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "Hi" }},
+ }, ctx)
+ assert(r2.service_tier == nil, "no service_tier")
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]