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 a28d9bb50 fix(ai-proxy): correct Anthropic Messages to OpenAI Chat
request conversion (#13674)
a28d9bb50 is described below
commit a28d9bb50d78b37bbda83a8b37eb17df5dc16193
Author: Nic <[email protected]>
AuthorDate: Thu Jul 9 12:24:47 2026 +0800
fix(ai-proxy): correct Anthropic Messages to OpenAI Chat request conversion
(#13674)
---
.../anthropic-messages-to-openai-chat.lua | 380 ++++++++-----
t/plugin/ai-proxy-anthropic.t | 627 +++++++++++++++++++--
2 files changed, 813 insertions(+), 194 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 2eaf44459..6657db881 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
@@ -25,14 +25,16 @@
-- fields never reach the upstream provider.
local core = require("apisix.core")
+local resty_sha256 = require("resty.sha256")
+local to_hex = require("resty.string").to_hex
local table = table
local type = type
+local next = next
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
@@ -50,15 +52,52 @@ local BUILTIN_TOOL_PREFIXES = {
-- OpenAI tool name constraints: max 64 chars, only [a-zA-Z0-9_-]
local TOOL_NAME_MAX_LEN = 64
+local TOOL_NAME_HASH_LEN = 8
+local TOOL_NAME_PREFIX_LEN = TOOL_NAME_MAX_LEN - TOOL_NAME_HASH_LEN - 1
-local function sanitize_tool_name(name)
- -- Replace invalid characters with underscore
+
+-- Anthropic built-in tools have a type but no input_schema; OpenAI can't
+-- handle them, so they never reach the upstream.
+local function is_builtin_tool(tool)
+ if type(tool.type) ~= "string" then
+ return false
+ end
+ for _, prefix in ipairs(BUILTIN_TOOL_PREFIXES) do
+ if string_sub(tool.type, 1, string_len(prefix)) == prefix then
+ return true
+ end
+ end
+ return false
+end
+
+
+local function tool_name_hash(name)
+ local sha256 = resty_sha256:new()
+ sha256:update(name)
+ return string_sub(to_hex(sha256:final()), 1, TOOL_NAME_HASH_LEN)
+end
+
+
+-- Map an Anthropic tool name to the name used on the OpenAI side. Characters
+-- outside [a-zA-Z0-9_-] become underscores, and any name that had to change is
+-- rewritten as <55-char prefix>_<8 hex chars of sha256(original)>. Two
rewritten
+-- names therefore never collide, the same way LiteLLM's adapter avoids it.
+-- (A tool named after another tool's hash still collides -- LiteLLM has the
same
+-- hole -- but nothing short of rewriting every name closes that.)
+-- The mapping is a pure function of the name, which keeps the `tools` array
and
+-- the `tool_use` blocks in the conversation history in sync. `reverse` records
+-- openai → original for the renamed tools, letting the response converter
+-- restore the original name.
+local function openai_tool_name(name, reverse)
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)
+ if sanitized == name and string_len(name) <= TOOL_NAME_MAX_LEN then
+ return name
end
- return sanitized
+
+ local renamed = string_sub(sanitized, 1, TOOL_NAME_PREFIX_LEN) .. "_"
+ .. tool_name_hash(name)
+ reverse[renamed] = name
+ return renamed
end
@@ -143,6 +182,66 @@ local function convert_media_block(block)
end
+local function concat_text_parts(parts)
+ local text = ""
+ for _, part in ipairs(parts) do
+ if part.type == "text" then
+ text = text .. (part.text or "")
+ end
+ end
+ return text
+end
+
+
+-- JSON schema keywords holding a list of sub-schemas, and keywords holding a
+-- map of named sub-schemas. Both have to be walked by normalize_strict_schema.
+local SCHEMA_LIST_KEYWORDS = { "anyOf", "oneOf", "allOf" }
+local SCHEMA_MAP_KEYWORDS = { "$defs", "definitions" }
+
+
+-- Recursively make a JSON schema comply with OpenAI's strict mode, which
+-- requires `additionalProperties: false` on every object and every property
+-- listed in `required`. Mirrors LiteLLM's Anthropic adapter.
+local function normalize_strict_schema(schema)
+ if type(schema) ~= "table" then
+ return
+ end
+
+ if schema.type == "object" and type(schema.properties) == "table" then
+ schema.additionalProperties = false
+ -- `required` must reach the upstream as a JSON array, including when
the
+ -- object declares no properties at all
+ local required = setmetatable({}, core.json.array_mt)
+ for name, prop in pairs(schema.properties) do
+ table.insert(required, name)
+ normalize_strict_schema(prop)
+ end
+ -- `properties` is a map, so the names come out unordered: sort them to
+ -- keep the outgoing body stable
+ table.sort(required)
+ schema.required = required
+ end
+
+ normalize_strict_schema(schema.items)
+
+ for _, key in ipairs(SCHEMA_LIST_KEYWORDS) do
+ if type(schema[key]) == "table" then
+ for _, sub in ipairs(schema[key]) do
+ normalize_strict_schema(sub)
+ end
+ end
+ end
+
+ for _, key in ipairs(SCHEMA_MAP_KEYWORDS) do
+ if type(schema[key]) == "table" then
+ for _, def in pairs(schema[key]) do
+ normalize_strict_schema(def)
+ end
+ end
+ end
+end
+
+
-- Convert Anthropic tool_choice to OpenAI format.
local function convert_tool_choice(tc)
if type(tc) ~= "table" then
@@ -165,27 +264,50 @@ local function convert_tool_choice(tc)
end
+-- With adaptive thinking the depth comes from output_config.effort. When that
+-- is absent we fall back to "medium", the same default LiteLLM's Anthropic
+-- adapter uses.
+local ADAPTIVE_DEFAULT_EFFORT = "medium"
+
+-- thinking.budget_tokens buckets, matching LiteLLM's shared thresholds
+local EFFORT_LOW_BUDGET = 1024
+local EFFORT_MEDIUM_BUDGET = 2048
+local EFFORT_HIGH_BUDGET = 4096
+
+
-- Convert Anthropic thinking config to OpenAI reasoning_effort.
-local function convert_thinking_config(thinking)
+-- `thinking.type` is one of "enabled", "disabled" or "adaptive". With
+-- "adaptive" the depth is driven by output_config.effort instead of
+-- budget_tokens, and the level is forwarded verbatim: Chat Completions takes
+-- the same labels (none/minimal/low/medium/high/xhigh).
+local function convert_thinking_config(thinking, output_config)
if type(thinking) ~= "table" then
return nil
end
- if thinking.type == "disabled" then
- return nil
+
+ if thinking.type == "adaptive" then
+ if type(output_config) == "table" and type(output_config.effort) ==
"string"
+ and output_config.effort ~= "" then
+ return output_config.effort
+ end
+ return ADAPTIVE_DEFAULT_EFFORT
end
+
if thinking.type ~= "enabled" then
return nil
end
local budget = thinking.budget_tokens
if type(budget) ~= "number" then
- return "medium"
+ budget = 0
end
- if budget < 4096 then
- return "low"
- elseif budget < 16384 then
+ if budget >= EFFORT_HIGH_BUDGET then
+ return "high"
+ elseif budget >= EFFORT_MEDIUM_BUDGET then
return "medium"
+ elseif budget >= EFFORT_LOW_BUDGET then
+ return "low"
else
- return "high"
+ return "minimal"
end
end
@@ -291,7 +413,8 @@ function _M.convert_request(request_table, ctx)
-- thinking → reasoning_effort
if request_table.thinking then
- local effort = convert_thinking_config(request_table.thinking)
+ local effort = convert_thinking_config(request_table.thinking,
+ request_table.output_config)
if effort then
openai_body.reasoning_effort = effort
end
@@ -310,18 +433,30 @@ function _M.convert_request(request_table, ctx)
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" }
+ -- Structured outputs → response_format. The schema lives in the top-level
+ -- `output_format` (beta) or in `output_config.format` (GA), both shaped as
+ -- { type = "json_schema", schema = <json schema> }. `output_format` wins
+ -- when both are present, matching LiteLLM's Anthropic adapter.
+ local output_format = request_table.output_format
+ if type(output_format) ~= "table" or next(output_format) == nil then
+ if type(request_table.output_config) == "table" then
+ output_format = request_table.output_config.format
end
end
+ if type(output_format) == "table" and output_format.type == "json_schema"
+ and type(output_format.schema) == "table" and
next(output_format.schema) then
+ -- Copy before normalizing: the schema belongs to the client's body
+ local schema = core.table.deepcopy(output_format.schema)
+ normalize_strict_schema(schema)
+ openai_body.response_format = {
+ type = "json_schema",
+ json_schema = {
+ name = "structured_output",
+ schema = schema,
+ strict = true,
+ },
+ }
+ end
-- metadata.user_id → user
if type(request_table.metadata) == "table"
@@ -334,7 +469,55 @@ function _M.convert_request(request_table, ctx)
openai_body.service_tier = request_table.service_tier
end
- -- 1. System prompt
+ -- 1. Convert tools (only when non-empty)
+ local tool_name_map = {}
+ if type(request_table.tools) == "table" and #request_table.tools > 0 then
+ local openai_tools = {}
+ local declared_names = {}
+ for _, tool in ipairs(request_table.tools) do
+ if type(tool) ~= "table" then
+ goto CONTINUE_TOOL
+ end
+
+ if is_builtin_tool(tool) then
+ core.log.debug("dropping Anthropic built-in tool '", tool.type,
+ "': not supported by OpenAI upstream")
+ goto CONTINUE_TOOL
+ end
+
+ if type(tool.name) ~= "string" or tool.name == "" then
+ goto CONTINUE_TOOL
+ end
+
+ local oai_name = openai_tool_name(tool.name, tool_name_map)
+ declared_names[tool.name] = oai_name
+ local oai_tool = {
+ type = "function",
+ ["function"] = {
+ name = oai_name,
+ description = tool.description,
+ parameters = tool.input_schema,
+ },
+ }
+ table.insert(openai_tools, oai_tool)
+ ::CONTINUE_TOOL::
+ end
+ if #openai_tools > 0 then
+ openai_body.tools = openai_tools
+ end
+ -- Point tool_choice at the name the tool was declared with. A name
that
+ -- matches no declared tool is left alone: renaming it would only
invent
+ -- a mapping for a tool the upstream never saw.
+ 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
+ tc_func.name = declared_names[tc_func.name] or tc_func.name
+ end
+ end
+ end
+
+ -- 2. System prompt
local messages = {}
if request_table.system then
local sys_msg = convert_system(request_table.system)
@@ -343,7 +526,7 @@ function _M.convert_request(request_table, ctx)
end
end
- -- 2. Convert messages
+ -- 3. 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
@@ -362,7 +545,6 @@ function _M.convert_request(request_table, ctx)
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
@@ -379,7 +561,6 @@ function _M.convert_request(request_table, ctx)
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
@@ -388,7 +569,7 @@ function _M.convert_request(request_table, ctx)
id = block.id,
type = "function",
["function"] = {
- name = block.name,
+ name = openai_tool_name(block.name, tool_name_map),
arguments = core.json.encode(block.input or {})
}
})
@@ -441,23 +622,18 @@ function _M.convert_request(request_table, ctx)
::CONTINUE_BLOCK::
end
- -- Emit tool_results as separate messages
+ -- Emit tool_results as separate messages. OpenAI requires every `tool`
+ -- message to immediately follow the assistant message that carries the
+ -- matching tool_calls, so anything else in this Anthropic message has
+ -- to be emitted after them, not before.
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
- if text_content ~= "" then
- table.insert(messages, { role = msg.role, content =
text_content })
- end
- end
for _, tr in ipairs(tool_results) do
table.insert(messages, tr)
end
+
+ if #content_parts > 0 then
+ table.insert(messages, { role = msg.role, content =
content_parts })
+ end
goto CONTINUE
end
@@ -467,23 +643,16 @@ function _M.convert_request(request_table, ctx)
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
+ local text = concat_text_parts(content_parts)
+ new_msg.content = text ~= "" and text or nil
+ elseif #content_parts == 0 then
new_msg.content = ""
+ elseif msg.role == "assistant" then
+ -- An assistant turn only ever carries text; OpenAI takes it as a
string
+ new_msg.content = concat_text_parts(content_parts)
+ else
+ -- A user turn can mix text with media, so it stays a content array
+ new_msg.content = content_parts
end
table.insert(messages, new_msg)
@@ -491,92 +660,9 @@ function _M.convert_request(request_table, ctx)
end
openai_body.messages = messages
- -- 3. Convert tools (only when non-empty)
- if type(request_table.tools) == "table" and #request_table.tools > 0 then
- local openai_tools = {}
- 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
-
- local oai_tool = {
- type = "function",
- ["function"] = {
- name = oai_name,
- description = tool.description,
- 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
+ -- Store tool name mapping in ctx for response restoration
+ if next(tool_name_map) then
+ ctx.anthropic_tool_name_map = tool_name_map
end
-- tool_choice and parallel_tool_calls are only valid alongside a non-empty
@@ -780,7 +866,7 @@ local function openai_to_anthropic_sse(openai_chunk, state,
tool_name_map)
content = {},
usage = { input_tokens = 0, output_tokens = 0 },
}
- setmetatable(message.content, core.json.empty_array_mt)
+ setmetatable(message.content, core.json.array_mt)
table.insert(events, make_sse_event("message_start", {
type = "message_start",
diff --git a/t/plugin/ai-proxy-anthropic.t b/t/plugin/ai-proxy-anthropic.t
index 4ed54a426..ade9dad62 100644
--- a/t/plugin/ai-proxy-anthropic.t
+++ b/t/plugin/ai-proxy-anthropic.t
@@ -331,7 +331,7 @@ output_config do NOT appear in the converted request.
ngx.say("LEAKED: thinking (raw)")
return
end
- if result.reasoning_effort ~= "medium" then
+ if result.reasoning_effort ~= "high" then
ngx.say("reasoning_effort wrong: " ..
tostring(result.reasoning_effort))
return
end
@@ -443,37 +443,32 @@ OK
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))
+ local function effort_of(thinking)
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ thinking = thinking,
+ }, ctx)
+ return r.reasoning_effort
+ end
- -- 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))
+ -- budget buckets: < 1024 minimal, < 2048 low, < 4096 medium, else
high
+ local cases = {
+ { 0, "minimal" }, { 1023, "minimal" },
+ { 1024, "low" }, { 2047, "low" },
+ { 2048, "medium" }, { 4095, "medium" },
+ { 4096, "high" }, { 32000, "high" },
+ }
+ for _, c in ipairs(cases) do
+ local got = effort_of({ type = "enabled", budget_tokens = c[1]
})
+ assert(got == c[2], c[1] .. " => " .. tostring(got))
+ end
- -- 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))
+ -- enabled without budget_tokens is treated as a zero budget
+ assert(effort_of({ type = "enabled" }) == "minimal", "no budget")
-- 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")
+ assert(effort_of({ type = "disabled" }) == nil, "disabled should
be nil")
ngx.say("OK")
}
@@ -626,7 +621,7 @@ OK
-=== TEST 23: response_format from output_config (json_schema)
+=== TEST 23: response_format from output_config.format (json_schema)
--- config
location /t {
content_by_lua_block {
@@ -634,20 +629,68 @@ OK
local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = {} }
+ local schema = {
+ type = "object",
+ properties = { a = { type = "string" }, b = { type = "string"
} },
+ required = { "a" },
+ }
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" } },
+ effort = "high",
+ format = { type = "json_schema", schema = schema },
},
}, 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")
+ assert(r.response_format.json_schema.name == "structured_output",
"schema name")
+ assert(r.response_format.json_schema.strict == true, "strict")
+
+ -- strict mode: additionalProperties false, every property required
+ local out = r.response_format.json_schema.schema
+ assert(out.additionalProperties == false, "additionalProperties
false")
+ assert(#out.required == 2, "all properties required, got " ..
#out.required)
+
+ -- the client's schema must not be mutated in place
+ assert(schema.additionalProperties == nil, "input schema mutated")
+ assert(#schema.required == 1, "input required mutated")
+
-- output_config should NOT leak
assert(r.output_config == nil, "output_config leaked")
+
+ -- an object with no properties still needs `required` to be a JSON
+ -- array, and every nested sub-schema has to be normalized too
+ local r2 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ output_format = { type = "json_schema", schema = {
+ type = "object",
+ properties = {
+ empty = { type = "object", properties = {} },
+ list = { type = "array", items = {
+ type = "object", properties = { k = { type =
"string" } },
+ }},
+ choice = { anyOf = {
+ { type = "object", properties = { m = { type =
"string" } } },
+ }},
+ ref = { ["$ref"] = "#/$defs/Inner" },
+ },
+ ["$defs"] = {
+ Inner = { type = "object", properties = { z = { type =
"boolean" } } },
+ },
+ }},
+ }, ctx)
+ local out2 = r2.response_format.json_schema.schema
+ local encoded = core.json.encode(out2)
+ assert(encoded:find('"required":%[%]'), "empty required must
encode as []: " .. encoded)
+ assert(out2.properties.empty.additionalProperties == false,
"nested empty object")
+ assert(out2.properties.list.items.additionalProperties == false,
"array items")
+ assert(out2.properties.choice.anyOf[1].additionalProperties ==
false, "anyOf branch")
+ assert(out2["$defs"].Inner.additionalProperties == false, "$defs
entry")
+ assert(out2["$defs"].Inner.required[1] == "z", "$defs required")
+
ngx.say("OK")
}
}
@@ -658,22 +701,38 @@ OK
-=== TEST 24: response_format from output_format (json_object)
+=== TEST 24: only json_schema output formats become response_format
--- config
location /t {
content_by_lua_block {
local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = {} }
+ -- `json_object` is not an Anthropic output format, so nothing is
emitted
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.response_format == nil, "json_object should not map to
response_format")
assert(r.output_format == nil, "output_format leaked")
+
+ -- json_schema without a schema is incomplete, so nothing is
emitted either
+ r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ output_format = { type = "json_schema" },
+ }, ctx)
+ assert(r.response_format == nil, "schema-less json_schema")
+
+ -- an empty schema carries nothing to enforce
+ r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ output_format = { type = "json_schema", schema = {} },
+ }, ctx)
+ assert(r.response_format == nil, "empty json_schema")
+
ngx.say("OK")
}
}
@@ -1176,7 +1235,7 @@ OK
-=== TEST 36: text alongside tool_results → text message + tool messages
+=== TEST 36: text alongside tool_results → tool messages first, then text
message
--- config
location /t {
content_by_lua_block {
@@ -1195,12 +1254,12 @@ OK
}},
}, ctx)
- -- text message first, then tool message
+ -- tool message first, then text 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")
+ assert(r.messages[1].role == "tool", "msg 1 role")
+ assert(r.messages[1].tool_call_id == "call_1", "msg 1 id")
+ assert(r.messages[2].role == "user", "msg 2 role")
+ assert(r.messages[2].content[1].text == "Here are the results:",
"msg 2 text")
ngx.say("OK")
}
}
@@ -1314,7 +1373,8 @@ OK
}, ctx)
msg = r.messages[1]
-- Only text should remain (image skipped)
- assert(msg.content == "Describe this", "empty url skipped: " ..
tostring(msg.content))
+ assert(#msg.content == 1, "empty url skipped: " .. #msg.content)
+ assert(msg.content[1].text == "Describe this", "text kept")
-- nil URL source - should be skipped
r = converter.convert_request({
@@ -1328,7 +1388,8 @@ OK
}},
}, ctx)
msg = r.messages[1]
- assert(msg.content == "Test", "nil url skipped: " ..
tostring(msg.content))
+ assert(#msg.content == 1, "nil url skipped: " .. #msg.content)
+ assert(msg.content[1].text == "Test", "text kept")
ngx.say("OK")
}
@@ -1404,8 +1465,9 @@ OK
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")
+ -- User message: content array without cache_control
+ assert(r.messages[2].content[1].text == "Hello", "user text kept")
+ assert(r.messages[2].content[1].cache_control == nil, "no
cache_control in message")
-- Tool: no cache_control field
local encoded = core.json.encode(r.tools[1])
@@ -1530,6 +1592,16 @@ OK
local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = { llm_model = "gpt-4o" } }
+ -- A name that already fits is forwarded untouched, with no mapping
+ local ctx0 = { var = {} }
+ local r0 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "Hi" }},
+ tools = {{ name = string.rep("a", 64), input_schema = { type =
"object" } }},
+ }, ctx0)
+ assert(r0.tools[1]["function"].name == string.rep("a", 64), "64
chars is fine")
+ assert(ctx0.anthropic_tool_name_map == nil, "no map when nothing
is renamed")
+
-- Tool name with 70 chars (exceeds 64 limit)
local long_name = string.rep("a", 70)
local r = converter.convert_request({
@@ -1542,8 +1614,10 @@ OK
}},
}, ctx)
- -- Should be truncated to 64 chars
+ -- <55-char prefix>_<8 hex chars of sha256(name)>, byte-for-byte
what
+ -- LiteLLM's truncate_tool_name() produces for the same input
local oai_name = r.tools[1]["function"].name
+ assert(oai_name == string.rep("a", 55) .. "_6bd5e503", "hashed: "
.. oai_name)
assert(#oai_name == 64, "truncated to 64: " .. #oai_name)
-- Mapping stored in ctx
@@ -1946,3 +2020,462 @@ the empty-object fallback and log "not a JSON object".
OK
--- error_log
not a JSON object
+
+
+
+=== TEST 56: tool_results precede the trailing text message (parallel
tool_calls)
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ -- assistant emits 2 parallel tool_use blocks, the next user
message
+ -- carries both tool_results plus extra text (what Claude Code
sends
+ -- when a system-reminder or a queued prompt rides along).
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {
+ { role = "user", content = "start" },
+ { role = "assistant", content = {
+ { type = "tool_use", id = "call_a", name = "get_a",
input = {} },
+ { type = "tool_use", id = "call_b", name = "get_b",
input = {} },
+ }},
+ { role = "user", content = {
+ { type = "tool_result", tool_use_id = "call_a",
content = "a" },
+ { type = "tool_result", tool_use_id = "call_b",
content = "b" },
+ { type = "text", text = "also explain briefly" },
+ }},
+ },
+ }, ctx)
+
+ -- every tool message must immediately follow the assistant
tool_calls
+ assert(#r.messages == 5, "expected 5 messages, got " ..
#r.messages)
+ assert(r.messages[2].role == "assistant", "assistant")
+ assert(#r.messages[2].tool_calls == 2, "2 tool_calls")
+ assert(r.messages[3].role == "tool", "msg 3 role: " ..
r.messages[3].role)
+ assert(r.messages[3].tool_call_id == "call_a", "msg 3 id")
+ assert(r.messages[4].role == "tool", "msg 4 role: " ..
r.messages[4].role)
+ assert(r.messages[4].tool_call_id == "call_b", "msg 4 id")
+ assert(r.messages[5].role == "user", "msg 5 role")
+ assert(r.messages[5].content[1].text == "also explain briefly",
"msg 5 text")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 57: media alongside tool_results is preserved after the tool messages
+--- 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 = "tool_result", tool_use_id = "call_1",
content = "done" },
+ { type = "text", text = "look at this" },
+ { type = "image", source = {
+ type = "base64", media_type = "image/png", data =
"img",
+ }},
+ }
+ }},
+ }, ctx)
+
+ assert(#r.messages == 2, "expected 2 messages, got " ..
#r.messages)
+ assert(r.messages[1].role == "tool", "tool message first")
+ local content = r.messages[2].content
+ assert(r.messages[2].role == "user", "user message second")
+ assert(type(content) == "table", "multimodal content kept as
array")
+ assert(content[1].type == "text", "text part")
+ assert(content[2].type == "image_url", "image part kept")
+ assert(content[2].image_url.url == "data:image/png;base64,img",
"image url")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 58: tool_result only (no extra content) emits no trailing message
+--- 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 = "tool_result", tool_use_id = "call_1",
content = "done" },
+ }
+ }},
+ }, ctx)
+
+ assert(#r.messages == 1, "expected 1 message, got " .. #r.messages)
+ assert(r.messages[1].role == "tool", "tool message only")
+
+ -- an empty text block is still a block: it becomes a trailing
message
+ r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{
+ role = "user",
+ content = {
+ { type = "tool_result", tool_use_id = "call_1",
content = "done" },
+ { type = "text", text = "" },
+ }
+ }},
+ }, ctx)
+ assert(#r.messages == 2, "expected 2 messages, got " ..
#r.messages)
+ assert(r.messages[2].content[1].text == "", "empty text block
kept")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 59: adaptive thinking → reasoning_effort from output_config.effort
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+
+ local function effort_of(thinking, output_config)
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ thinking = thinking,
+ output_config = output_config,
+ }, { var = {} })
+ return r.reasoning_effort
+ end
+
+ -- output_config.effort is forwarded verbatim: Chat Completions
takes
+ -- the same labels (none/minimal/low/medium/high/xhigh)
+ local adaptive = { type = "adaptive" }
+ assert(effort_of(adaptive, { effort = "low" }) == "low", "low")
+ assert(effort_of(adaptive, { effort = "high" }) == "high", "high")
+ assert(effort_of(adaptive, { effort = "xhigh" }) == "xhigh",
"xhigh")
+ assert(effort_of(adaptive, { effort = "max" }) == "max", "max")
+ -- adaptive without an explicit effort falls back to medium
+ assert(effort_of(adaptive, nil) == "medium", "default medium")
+ assert(effort_of(adaptive, {}) == "medium", "empty output_config
-> medium")
+ assert(effort_of(adaptive, { effort = "" }) == "medium", "empty
effort -> medium")
+ -- output_config.effort alone does not enable reasoning
+ assert(effort_of(nil, { effort = "high" }) == nil, "no thinking,
no effort")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 60: response_format from output_format (json_schema, beta shape)
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = {} }
+
+ local schema = { type = "object", additionalProperties = false }
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ output_format = { type = "json_schema", schema = schema },
+ }, ctx)
+
+ assert(r.response_format.type == "json_schema", "type")
+ assert(r.response_format.json_schema.strict == true, "strict")
+ assert(r.response_format.json_schema.schema.type == "object",
"schema forwarded")
+ assert(r.output_format == nil, "output_format leaked")
+
+ -- an absent/empty output_format falls back to output_config.format
+ local r2 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ output_config = { format = { type = "json_schema", schema = {
type = "object" } } },
+ }, { var = {} })
+ assert(r2.response_format ~= nil, "output_config.format used")
+ assert(r2.response_format.json_schema.strict == true, "strict")
+
+ -- the top-level output_format wins when both carry a schema
+ local r3 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ output_format = { type = "json_schema", schema = { type =
"object", title = "beta" } },
+ output_config = { format = { type = "json_schema", schema = {
type = "object", title = "ga" } } },
+ }, { var = {} })
+ assert(r3.response_format.json_schema.schema.title == "beta",
"output_format wins")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 61: tool_use in history uses the same sanitized name as the tool
definition
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = { llm_model = "gpt-4o" } }
+
+ local long_name = string.rep("a", 70)
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ tools = {
+ { name = long_name, description = "Long", input_schema = {
type = "object" } },
+ { name = "my tool!x", description = "Invalid chars",
input_schema = { type = "object" } },
+ },
+ messages = {
+ { role = "user", content = "hi" },
+ { role = "assistant", content = {
+ { type = "tool_use", id = "call_1", name = long_name,
input = {} },
+ { type = "tool_use", id = "call_2", name = "my
tool!x", input = {} },
+ }},
+ { role = "user", content = {
+ { type = "tool_result", tool_use_id = "call_1",
content = "ok" },
+ { type = "tool_result", tool_use_id = "call_2",
content = "ok" },
+ }},
+ },
+ }, ctx)
+
+ local declared_1 = r.tools[1]["function"].name
+ local declared_2 = r.tools[2]["function"].name
+ local called_1 = r.messages[2].tool_calls[1]["function"].name
+ local called_2 = r.messages[2].tool_calls[2]["function"].name
+ assert(called_1 == declared_1, "call 1: " .. called_1 .. " vs " ..
declared_1)
+ assert(called_2 == declared_2, "call 2: " .. called_2 .. " vs " ..
declared_2)
+ assert(not called_2:find("[^a-zA-Z0-9_%-]"), "valid chars: " ..
called_2)
+
+ -- a tool_use whose definition is absent still gets a valid name
and
+ -- is restored on the response
+ local ctx2 = { var = { llm_model = "gpt-4o" } }
+ local r2 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "assistant", content = {
+ { type = "tool_use", id = "call_1", name = "orphan tool!",
input = {} },
+ }}},
+ }, ctx2)
+ local orphan = r2.messages[1].tool_calls[1]["function"].name
+ assert(not orphan:find("[^a-zA-Z0-9_%-]"), "orphan sanitized: " ..
orphan)
+ assert(ctx2.anthropic_tool_name_map[orphan] == "orphan tool!",
"orphan mapped")
+
+ local res = converter.convert_response({
+ id = "msg_1",
+ choices = {{ message = { tool_calls = {{
+ id = "call_1", type = "function",
+ ["function"] = { name = orphan, arguments = "{}" },
+ }}}, finish_reason = "tool_calls" }},
+ usage = { prompt_tokens = 10, completion_tokens = 5 },
+ }, ctx2)
+ assert(res.content[1].name == "orphan tool!", "restored: " ..
res.content[1].name)
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 62: a rewritten tool name does not take a name another tool already
owns
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = { llm_model = "gpt-4o" } }
+
+ -- "get weather" sanitizes to "get_weather", which is also a
literal
+ -- tool name here; the tool that owns it verbatim must keep it
+ local r = converter.convert_request({
+ model = "m", max_tokens = 100,
+ tools = {
+ { name = "get weather", description = "A", input_schema =
{ type = "object" } },
+ { name = "get_weather", description = "B", input_schema =
{ type = "object" } },
+ },
+ messages = {
+ { role = "user", content = "hi" },
+ { role = "assistant", content = {
+ { type = "tool_use", id = "c1", name = "get weather",
input = {} },
+ { type = "tool_use", id = "c2", name = "get_weather",
input = {} },
+ }},
+ },
+ }, ctx)
+
+ local n1 = r.tools[1]["function"].name
+ local n2 = r.tools[2]["function"].name
+ assert(n1 ~= n2, "tool names must be unique: " .. n1 .. " vs " ..
n2)
+ assert(n2 == "get_weather", "valid name kept: " .. n2)
+ -- history tool_use follows the same mapping
+ assert(r.messages[2].tool_calls[1]["function"].name == n1, "call
1")
+ assert(r.messages[2].tool_calls[2]["function"].name == n2, "call
2")
+
+ -- each openai name restores to the right original
+ local function restore(oai_name)
+ local res = converter.convert_response({
+ id = "msg_1",
+ choices = {{ message = { tool_calls = {{
+ id = "c", type = "function",
+ ["function"] = { name = oai_name, arguments = "{}" },
+ }}}, finish_reason = "tool_calls" }},
+ usage = { prompt_tokens = 1, completion_tokens = 1 },
+ }, ctx)
+ return res.content[1].name
+ end
+ assert(restore(n1) == "get weather", "restore n1: " .. restore(n1))
+ assert(restore(n2) == "get_weather", "restore n2: " .. restore(n2))
+
+ -- a 70-char name truncates onto a 64-char name owned by another
tool
+ local ctx2 = { var = { llm_model = "gpt-4o" } }
+ local name64 = string.rep("a", 64)
+ local name70 = string.rep("a", 70)
+ local r2 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ tools = {
+ { name = name64, description = "A", input_schema = { type
= "object" } },
+ { name = name70, description = "B", input_schema = { type
= "object" } },
+ },
+ messages = {{ role = "user", content = "hi" }},
+ }, ctx2)
+ local m1 = r2.tools[1]["function"].name
+ local m2 = r2.tools[2]["function"].name
+ assert(m1 == name64, "64-char name kept as is")
+ assert(m2 ~= m1, "truncated name must not collide: " .. m2)
+ assert(#m2 <= 64, "still within the limit: " .. #m2)
+ assert(ctx2.anthropic_tool_name_map[m2] == name70, "truncated name
maps back")
+ assert(ctx2.anthropic_tool_name_map[m1] == nil, "untouched name
needs no mapping")
+
+ -- two long names that share their first 64 chars: the hash suffix
is
+ -- what keeps them apart
+ local ctx3 = { var = { llm_model = "gpt-4o" } }
+ local prefix = "mcp__server__" .. string.rep("a", 55)
+ local r3 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ tools = {
+ { name = prefix .. "_one", input_schema = { type =
"object" } },
+ { name = prefix .. "_two", input_schema = { type =
"object" } },
+ },
+ messages = {{ role = "user", content = "hi" }},
+ }, ctx3)
+ local p1 = r3.tools[1]["function"].name
+ local p2 = r3.tools[2]["function"].name
+ assert(p1 ~= p2, "shared prefix must not collide: " .. p1)
+ assert(ctx3.anthropic_tool_name_map[p1] == prefix .. "_one",
"prefix map 1")
+ assert(ctx3.anthropic_tool_name_map[p2] == prefix .. "_two",
"prefix map 2")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 63: content block shaping depends on the role
+--- 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 = {
+ -- a user turn keeps its blocks as an array, even a single
one
+ { role = "user", content = {{ type = "text", text = "a" }}
},
+ -- an assistant turn only carries text, so it becomes a
string
+ { role = "assistant", content = {
+ { type = "text", text = "x" },
+ { type = "text", text = "y" },
+ }},
+ { role = "user", content = {
+ { type = "text", text = "b" },
+ { type = "text", text = "c" },
+ }},
+ -- a plain string is forwarded as is, whatever the role
+ { role = "assistant", content = "z" },
+ },
+ }, ctx)
+
+ assert(type(r.messages[1].content) == "table", "user single block
is an array")
+ assert(#r.messages[1].content == 1, "one part")
+ assert(r.messages[1].content[1].type == "text", "part type")
+ assert(r.messages[1].content[1].text == "a", "part text")
+
+ assert(r.messages[2].content == "xy", "assistant blocks
concatenated: "
+ .. tostring(r.messages[2].content))
+
+ assert(#r.messages[3].content == 2, "user keeps both blocks")
+ assert(r.messages[4].content == "z", "string content untouched")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 64: tool_choice follows the declared tool name
+--- 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 = {{ name = "my tool!x", input_schema = { type =
"object" } }},
+ tool_choice = { type = "tool", name = "my tool!x" },
+ }, ctx)
+ local declared = r.tools[1]["function"].name
+ assert(r.tool_choice["function"].name == declared,
+ "tool_choice must name the declared tool: " ..
r.tool_choice["function"].name)
+ assert(ctx.anthropic_tool_name_map[declared] == "my tool!x",
"declared tool mapped")
+
+ -- a tool_choice naming a tool that was never declared is left
alone,
+ -- and must not invent a mapping the upstream can never produce
+ local ctx2 = { var = {} }
+ local r2 = converter.convert_request({
+ model = "m", max_tokens = 100,
+ messages = {{ role = "user", content = "hi" }},
+ tools = {{ name = "real_tool", input_schema = { type =
"object" } }},
+ tool_choice = { type = "tool", name = "ghost tool" },
+ }, ctx2)
+ assert(r2.tool_choice["function"].name == "ghost tool",
"undeclared name untouched")
+ assert(ctx2.anthropic_tool_name_map == nil, "no mapping for an
undeclared tool")
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- no_error_log
+[error]