From eaf1610a29e050cb3eb6f5c71f0b30671d25c909 Mon Sep 17 00:00:00 2001 From: Nic Date: Wed, 8 Jul 2026 17:21:34 +0800 Subject: [PATCH 1/7] fix(ai-proxy): correct Anthropic Messages to OpenAI Chat request conversion When a user message carried both `tool_result` blocks and other content (text or media), the converter emitted the text before the `tool` messages. OpenAI-compatible providers require every `tool` message to immediately follow the assistant message holding the matching `tool_calls`, so the upstream rejected the request with "An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'". This shape is legal in the Anthropic Messages API and is what Claude Code sends whenever a system reminder or a queued prompt rides along with tool results, so once such a turn entered the history every subsequent request failed. Emit the `tool` messages first and the remaining content as a following message. Media blocks alongside `tool_result` were dropped entirely; they are now kept in that trailing message. Also fixes three other conversion gaps found while auditing the same path: - `tool_use` blocks in the conversation history kept their original name while the `tools` array was sanitized, so a tool could be declared as `my_tool_x` and called as `my tool!x` in the same request. Tool name mapping now runs before the messages and is shared by both. - `thinking: {"type": "adaptive"}` produced no `reasoning_effort`. It now maps through `output_config.effort`, clamping `xhigh`/`max` to `high` since Chat Completions has no such level. - Structured outputs were read from `output_config.json_schema` / `output_format.json_schema`, neither of which exists. The schema lives in `output_config.format.schema` (GA) or `output_format.schema` (beta), and the OpenAI side needs it wrapped in a named `json_schema` object. Signed-off-by: Nic --- .../anthropic-messages-to-openai-chat.lua | 263 ++++++++++------- t/plugin/ai-proxy-anthropic.t | 269 +++++++++++++++++- 2 files changed, 413 insertions(+), 119 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 2eaf4445932a..67a320f3f8d5 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 @@ -27,6 +27,7 @@ local core = require("apisix.core") local table = table local type = type +local next = next local pairs = pairs local ipairs = ipairs local tostring = tostring @@ -62,6 +63,42 @@ local function sanitize_tool_name(name) end +-- Map an Anthropic tool name to the name used on the OpenAI side, recording +-- the mapping so the response converter can restore the original name. +-- `maps.forward` is original → openai, `maps.reverse` is openai → original. +-- Both the `tools` array and the `tool_use` blocks in the conversation history +-- go through here, so a tool always carries the same name in both places. +local function openai_tool_name(name, maps) + local mapped = maps.forward[name] + if mapped then + return mapped + end + + if string_len(name) <= TOOL_NAME_MAX_LEN + and not ngx_re_find(name, "[^a-zA-Z0-9_-]", "jo") then + return name + end + + local sanitized = sanitize_tool_name(name) + -- Disambiguate collisions by appending a numeric suffix + if maps.reverse[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 maps.reverse[candidate] + sanitized = candidate + end + + maps.reverse[sanitized] = name + maps.forward[name] = sanitized + return sanitized +end + + -- SSE event helpers local function make_sse_event(event_type, data) return { type = event_type, data = core.json.encode(data) } @@ -165,14 +202,33 @@ local function convert_tool_choice(tc) end +-- Anthropic output_config.effort → OpenAI reasoning_effort. +-- OpenAI Chat Completions has no "xhigh"/"max" level, so both clamp to "high". +local effort_map = { + low = "low", + medium = "medium", + high = "high", + xhigh = "high", + max = "high", +} + + -- 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. +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" then + return effort_map[output_config.effort] or "medium" + end + return "medium" end + if thinking.type ~= "enabled" then return nil end @@ -291,7 +347,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,15 +367,24 @@ 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 + -- Structured outputs → response_format. The schema lives in + -- `output_config.format` (GA) or in the top-level `output_format` (beta), + -- both shaped as { type = "json_schema", schema = }. + local output_format = request_table.output_format + if type(output_format) ~= "table" and type(request_table.output_config) == "table" then + output_format = request_table.output_config.format + end + if type(output_format) == "table" then + if output_format.type == "json_schema" and type(output_format.schema) == "table" then openai_body.response_format = { type = "json_schema", - json_schema = output_cfg.json_schema, + json_schema = { + name = "structured_output", + schema = output_format.schema, + strict = true, + }, } - elseif output_cfg.type == "json_object" or output_cfg.type == "json" then + elseif output_format.type == "json_object" then openai_body.response_format = { type = "json_object" } end end @@ -334,7 +400,62 @@ 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). This runs before the messages so + -- that the `tool_use` blocks in the conversation history are renamed with + -- the same mapping as the tool definitions. + local tool_name_maps = { forward = {}, reverse = {} } + if type(request_table.tools) == "table" and #request_table.tools > 0 then + local openai_tools = {} + 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 + + local oai_tool = { + type = "function", + ["function"] = { + name = openai_tool_name(tool.name, tool_name_maps), + 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 + -- Fix tool_choice to use the 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 + tc_func.name = tool_name_maps.forward[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 +464,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 @@ -388,7 +509,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_maps), arguments = core.json.encode(block.input or {}) } }) @@ -441,23 +562,30 @@ 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 + for _, tr in ipairs(tool_results) do + table.insert(messages, tr) + end + if #content_parts > 0 then - local text_content = "" - for _, p in ipairs(content_parts) do - if p.type == "text" then + local content + if has_multimodal then + content = content_parts + else + local text_content = "" + for _, p in ipairs(content_parts) do text_content = text_content .. (p.text or "") end + content = text_content ~= "" and text_content or nil end - if text_content ~= "" then - table.insert(messages, { role = msg.role, content = text_content }) + if content then + table.insert(messages, { role = msg.role, content = content }) end end - for _, tr in ipairs(tool_results) do - table.insert(messages, tr) - end goto CONTINUE end @@ -491,92 +619,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_maps.reverse) then + ctx.anthropic_tool_name_map = tool_name_maps.reverse end -- tool_choice and parallel_tool_calls are only valid alongside a non-empty diff --git a/t/plugin/ai-proxy-anthropic.t b/t/plugin/ai-proxy-anthropic.t index 4ed54a426dfd..ab02f9def177 100644 --- a/t/plugin/ai-proxy-anthropic.t +++ b/t/plugin/ai-proxy-anthropic.t @@ -626,7 +626,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,18 +634,21 @@ OK 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_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.schema == schema, "schema forwarded") + assert(r.response_format.json_schema.name == "structured_output", "schema name") + assert(r.response_format.json_schema.strict == true, "strict") -- output_config should NOT leak assert(r.output_config == nil, "output_config leaked") ngx.say("OK") @@ -1176,7 +1179,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 +1198,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 == "Here are the results:", "msg 2 text") ngx.say("OK") } } @@ -1946,3 +1949,249 @@ 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 == "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" }, + { type = "text", text = "" }, + } + }}, + }, ctx) + + assert(#r.messages == 1, "expected 1 message, got " .. #r.messages) + assert(r.messages[1].role == "tool", "tool message only") + 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 + + local adaptive = { type = "adaptive" } + assert(effort_of(adaptive, { effort = "low" }) == "low", "low") + assert(effort_of(adaptive, { effort = "high" }) == "high", "high") + -- OpenAI Chat Completions has no xhigh/max level + assert(effort_of(adaptive, { effort = "xhigh" }) == "high", "xhigh clamped") + assert(effort_of(adaptive, { effort = "max" }) == "high", "max clamped") + -- adaptive without output_config falls back to medium + assert(effort_of(adaptive, nil) == "medium", "default 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.schema == schema, "schema forwarded") + assert(r.response_format.json_schema.strict == true, "strict") + assert(r.output_format == nil, "output_format leaked") + 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] From a0c05ed6ac91b6116322cdd66f39f6ef2885779b Mon Sep 17 00:00:00 2001 From: Nic Date: Wed, 8 Jul 2026 17:34:02 +0800 Subject: [PATCH 2/7] fix(ai-proxy): keep OpenAI tool names unique when sanitizing A tool whose name needs sanitizing could take a name that another tool already owns verbatim (e.g. "fo o" sanitizes to "fo_o", which is also a literal tool name). Both were then declared to the upstream under the same function name, and the response converter restored that shared name to the wrong original tool. Reserve the already-valid tool names before sanitizing, and run the collision suffix against every name handed to the upstream rather than only against the sanitized ones. Signed-off-by: Nic --- .../anthropic-messages-to-openai-chat.lua | 53 +++++++++++++----- t/plugin/ai-proxy-anthropic.t | 56 +++++++++++++++++++ 2 files changed, 94 insertions(+), 15 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 67a320f3f8d5..e02a0bd3d4f3 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 @@ -63,9 +63,29 @@ local function sanitize_tool_name(name) end +local function tool_name_is_valid(name) + return string_len(name) <= TOOL_NAME_MAX_LEN + and not ngx_re_find(name, "[^a-zA-Z0-9_-]", "jo") +end + + +-- Claim the names that already satisfy OpenAI's constraints, so that a name +-- produced by sanitizing some other tool can never steal one of them. +local function reserve_tool_names(tools, maps) + for _, tool in ipairs(tools) do + if type(tool) == "table" and type(tool.name) == "string" and tool.name ~= "" + and tool_name_is_valid(tool.name) then + maps.used[tool.name] = true + maps.forward[tool.name] = tool.name + end + end +end + + -- Map an Anthropic tool name to the name used on the OpenAI side, recording -- the mapping so the response converter can restore the original name. --- `maps.forward` is original → openai, `maps.reverse` is openai → original. +-- `maps.forward` is original → openai, `maps.reverse` is openai → original +-- (renamed tools only), `maps.used` holds every name handed to the upstream. -- Both the `tools` array and the `tool_use` blocks in the conversation history -- go through here, so a tool always carries the same name in both places. local function openai_tool_name(name, maps) @@ -74,28 +94,30 @@ local function openai_tool_name(name, maps) return mapped end - if string_len(name) <= TOOL_NAME_MAX_LEN - and not ngx_re_find(name, "[^a-zA-Z0-9_-]", "jo") then - return name + local candidate = name + if not tool_name_is_valid(name) then + candidate = sanitize_tool_name(name) end - local sanitized = sanitize_tool_name(name) - -- Disambiguate collisions by appending a numeric suffix - if maps.reverse[sanitized] then + -- Another tool already owns this name: disambiguate with a numeric suffix + if maps.used[candidate] then local suffix = 2 - local candidate + local unique 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 + unique = string_sub(candidate, 1, max_base) .. suffix_str suffix = suffix + 1 - until not maps.reverse[candidate] - sanitized = candidate + until not maps.used[unique] + candidate = unique end - maps.reverse[sanitized] = name - maps.forward[name] = sanitized - return sanitized + maps.used[candidate] = true + maps.forward[name] = candidate + if candidate ~= name then + maps.reverse[candidate] = name + end + return candidate end @@ -403,9 +425,10 @@ function _M.convert_request(request_table, ctx) -- 1. Convert tools (only when non-empty). This runs before the messages so -- that the `tool_use` blocks in the conversation history are renamed with -- the same mapping as the tool definitions. - local tool_name_maps = { forward = {}, reverse = {} } + local tool_name_maps = { forward = {}, reverse = {}, used = {} } if type(request_table.tools) == "table" and #request_table.tools > 0 then local openai_tools = {} + reserve_tool_names(request_table.tools, tool_name_maps) for _, tool in ipairs(request_table.tools) do if type(tool) ~= "table" then goto CONTINUE_TOOL diff --git a/t/plugin/ai-proxy-anthropic.t b/t/plugin/ai-proxy-anthropic.t index ab02f9def177..99ecfd03ccc7 100644 --- a/t/plugin/ai-proxy-anthropic.t +++ b/t/plugin/ai-proxy-anthropic.t @@ -2195,3 +2195,59 @@ OK OK --- no_error_log [error] + + + +=== TEST 62: a sanitized tool name never steals 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" } } + + -- "fo o" sanitizes to "fo_o", which is also a literal tool name here + local r = converter.convert_request({ + model = "m", max_tokens = 100, + tools = { + { name = "fo o", description = "A", input_schema = { type = "object" } }, + { name = "fo_o", description = "B", input_schema = { type = "object" } }, + }, + messages = { + { role = "user", content = "hi" }, + { role = "assistant", content = { + { type = "tool_use", id = "c1", name = "fo o", input = {} }, + { type = "tool_use", id = "c2", name = "fo_o", 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) + -- the already-valid name stays with the tool that owns it + assert(n2 == "fo_o", "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) == "fo o", "restore n1: " .. restore(n1)) + assert(restore(n2) == "fo_o", "restore n2: " .. restore(n2)) + ngx.say("OK") + } + } +--- response_body +OK +--- no_error_log +[error] From a05c7dc519a09b119d24584f22cb778e51f7e3d2 Mon Sep 17 00:00:00 2001 From: Nic Date: Wed, 8 Jul 2026 17:43:15 +0800 Subject: [PATCH 3/7] fix(ai-proxy): follow the Anthropic spec for effort and structured outputs - `output_config.effort` defaults to "high" on the Anthropic side ("equivalent to not setting the parameter"), so adaptive thinking without an explicit effort must map to "high", not "medium". An unrecognised level falls back to the same default instead of silently downgrading. - Stop forcing `strict = true` on the generated `response_format`. Anthropic accepts optional properties and keywords such as `minLength` and `format`, all of which OpenAI rejects under strict schema adherence, so a schema that is valid for Anthropic would have been rejected by the upstream. - `output_config.format` (GA) now takes precedence over the beta `output_format`, and an `output_format` of an unrelated shape no longer shadows it. - Do not reserve OpenAI names for Anthropic built-in tools, which are dropped before they reach the upstream. - Restore the `p.type == "text"` guard when concatenating the content that trails a tool_result, matching the tool_calls branch. Signed-off-by: Nic --- .../anthropic-messages-to-openai-chat.lua | 57 ++++++++++------- t/plugin/ai-proxy-anthropic.t | 62 +++++++++++++++---- 2 files changed, 85 insertions(+), 34 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 e02a0bd3d4f3..6aeaccf48d01 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 @@ -69,12 +69,27 @@ local function tool_name_is_valid(name) end +-- 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 + + -- Claim the names that already satisfy OpenAI's constraints, so that a name -- produced by sanitizing some other tool can never steal one of them. local function reserve_tool_names(tools, maps) for _, tool in ipairs(tools) do if type(tool) == "table" and type(tool.name) == "string" and tool.name ~= "" - and tool_name_is_valid(tool.name) then + and not is_builtin_tool(tool) and tool_name_is_valid(tool.name) then maps.used[tool.name] = true maps.forward[tool.name] = tool.name end @@ -234,6 +249,9 @@ local effort_map = { max = "high", } +-- Omitting output_config.effort is equivalent to "high" on the Anthropic side. +local DEFAULT_EFFORT = "high" + -- Convert Anthropic thinking config to OpenAI reasoning_effort. -- `thinking.type` is one of "enabled", "disabled" or "adaptive". With @@ -246,9 +264,9 @@ local function convert_thinking_config(thinking, output_config) if thinking.type == "adaptive" then if type(output_config) == "table" and type(output_config.effort) == "string" then - return effort_map[output_config.effort] or "medium" + return effort_map[output_config.effort] or DEFAULT_EFFORT end - return "medium" + return DEFAULT_EFFORT end if thinking.type ~= "enabled" then @@ -392,10 +410,16 @@ function _M.convert_request(request_table, ctx) -- Structured outputs → response_format. The schema lives in -- `output_config.format` (GA) or in the top-level `output_format` (beta), -- both shaped as { type = "json_schema", schema = }. - local output_format = request_table.output_format - if type(output_format) ~= "table" and type(request_table.output_config) == "table" then + -- `strict` is deliberately left unset: Anthropic accepts optional + -- properties and keywords such as `minLength` or `format`, all of which + -- OpenAI rejects under strict schema adherence. + local output_format + if type(request_table.output_config) == "table" then output_format = request_table.output_config.format end + if type(output_format) ~= "table" then + output_format = request_table.output_format + end if type(output_format) == "table" then if output_format.type == "json_schema" and type(output_format.schema) == "table" then openai_body.response_format = { @@ -403,7 +427,6 @@ function _M.convert_request(request_table, ctx) json_schema = { name = "structured_output", schema = output_format.schema, - strict = true, }, } elseif output_format.type == "json_object" then @@ -434,20 +457,10 @@ function _M.convert_request(request_table, ctx) 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 + 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 @@ -601,7 +614,9 @@ function _M.convert_request(request_table, ctx) else local text_content = "" for _, p in ipairs(content_parts) do - text_content = text_content .. (p.text or "") + if p.type == "text" then + text_content = text_content .. (p.text or "") + end end content = text_content ~= "" and text_content or nil end diff --git a/t/plugin/ai-proxy-anthropic.t b/t/plugin/ai-proxy-anthropic.t index 99ecfd03ccc7..cd9d64260806 100644 --- a/t/plugin/ai-proxy-anthropic.t +++ b/t/plugin/ai-proxy-anthropic.t @@ -648,7 +648,8 @@ OK assert(r.response_format.type == "json_schema", "type: " .. r.response_format.type) assert(r.response_format.json_schema.schema == schema, "schema forwarded") assert(r.response_format.json_schema.name == "structured_output", "schema name") - assert(r.response_format.json_schema.strict == true, "strict") + -- strict is not forced: Anthropic allows schema features OpenAI rejects there + assert(r.response_format.json_schema.strict == nil, "strict not forced") -- output_config should NOT leak assert(r.output_config == nil, "output_config leaked") ngx.say("OK") @@ -2089,8 +2090,11 @@ OK -- OpenAI Chat Completions has no xhigh/max level assert(effort_of(adaptive, { effort = "xhigh" }) == "high", "xhigh clamped") assert(effort_of(adaptive, { effort = "max" }) == "high", "max clamped") - -- adaptive without output_config falls back to medium - assert(effort_of(adaptive, nil) == "medium", "default medium") + -- omitting effort is equivalent to "high" on the Anthropic side + assert(effort_of(adaptive, nil) == "high", "default high") + assert(effort_of(adaptive, {}) == "high", "empty output_config -> high") + -- an unrecognised level falls back to the Anthropic default too + assert(effort_of(adaptive, { effort = "unknown" }) == "high", "unknown -> high") -- output_config.effort alone does not enable reasoning assert(effort_of(nil, { effort = "high" }) == nil, "no thinking, no effort") ngx.say("OK") @@ -2119,8 +2123,20 @@ OK assert(r.response_format.type == "json_schema", "type") assert(r.response_format.json_schema.schema == schema, "schema forwarded") - assert(r.response_format.json_schema.strict == true, "strict") + assert(r.response_format.json_schema.strict == nil, "strict not forced") assert(r.output_format == nil, "output_format leaked") + + -- the GA output_config.format wins over the beta output_format, and + -- an output_format of another shape must not shadow it + local ga_schema = { type = "object", additionalProperties = false } + local r2 = converter.convert_request({ + model = "m", max_tokens = 100, + messages = {{ role = "user", content = "hi" }}, + output_format = { type = "text" }, + output_config = { format = { type = "json_schema", schema = ga_schema } }, + }, { var = {} }) + assert(r2.response_format ~= nil, "GA schema must not be shadowed") + assert(r2.response_format.json_schema.schema == ga_schema, "GA schema wins") ngx.say("OK") } } @@ -2205,18 +2221,19 @@ OK local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat") local ctx = { var = { llm_model = "gpt-4o" } } - -- "fo o" sanitizes to "fo_o", which is also a literal tool name here + -- "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 = "fo o", description = "A", input_schema = { type = "object" } }, - { name = "fo_o", description = "B", input_schema = { type = "object" } }, + { 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 = "fo o", input = {} }, - { type = "tool_use", id = "c2", name = "fo_o", input = {} }, + { type = "tool_use", id = "c1", name = "get weather", input = {} }, + { type = "tool_use", id = "c2", name = "get_weather", input = {} }, }}, }, }, ctx) @@ -2224,8 +2241,7 @@ OK 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) - -- the already-valid name stays with the tool that owns it - assert(n2 == "fo_o", "valid name kept: " .. 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") @@ -2242,8 +2258,28 @@ OK }, ctx) return res.content[1].name end - assert(restore(n1) == "fo o", "restore n1: " .. restore(n1)) - assert(restore(n2) == "fo_o", "restore n2: " .. restore(n2)) + 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") ngx.say("OK") } } From 15b91f9625e91573612db59c198ee339f4885c9c Mon Sep 17 00:00:00 2001 From: Nic Date: Wed, 8 Jul 2026 17:53:52 +0800 Subject: [PATCH 4/7] fix(ai-proxy): map Anthropic xhigh effort to OpenAI xhigh Chat Completions accepts none/minimal/low/medium/high/xhigh for reasoning_effort, so `xhigh` has a direct counterpart and must not be downgraded to `high`. Only Anthropic's `max` lacks an equivalent; it now clamps to `xhigh`, the highest level OpenAI accepts. Signed-off-by: Nic --- .../converters/anthropic-messages-to-openai-chat.lua | 9 +++++---- t/plugin/ai-proxy-anthropic.t | 6 +++--- 2 files changed, 8 insertions(+), 7 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 6aeaccf48d01..fc4e21b692a9 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 @@ -239,14 +239,15 @@ local function convert_tool_choice(tc) end --- Anthropic output_config.effort → OpenAI reasoning_effort. --- OpenAI Chat Completions has no "xhigh"/"max" level, so both clamp to "high". +-- Anthropic output_config.effort → OpenAI reasoning_effort. Chat Completions +-- accepts none/minimal/low/medium/high/xhigh; Anthropic's "max" has no +-- counterpart, so it clamps to the highest level OpenAI does accept. local effort_map = { low = "low", medium = "medium", high = "high", - xhigh = "high", - max = "high", + xhigh = "xhigh", + max = "xhigh", } -- Omitting output_config.effort is equivalent to "high" on the Anthropic side. diff --git a/t/plugin/ai-proxy-anthropic.t b/t/plugin/ai-proxy-anthropic.t index cd9d64260806..d0b925402fef 100644 --- a/t/plugin/ai-proxy-anthropic.t +++ b/t/plugin/ai-proxy-anthropic.t @@ -2087,9 +2087,9 @@ OK local adaptive = { type = "adaptive" } assert(effort_of(adaptive, { effort = "low" }) == "low", "low") assert(effort_of(adaptive, { effort = "high" }) == "high", "high") - -- OpenAI Chat Completions has no xhigh/max level - assert(effort_of(adaptive, { effort = "xhigh" }) == "high", "xhigh clamped") - assert(effort_of(adaptive, { effort = "max" }) == "high", "max clamped") + assert(effort_of(adaptive, { effort = "xhigh" }) == "xhigh", "xhigh") + -- OpenAI Chat Completions has no "max" level + assert(effort_of(adaptive, { effort = "max" }) == "xhigh", "max clamped to xhigh") -- omitting effort is equivalent to "high" on the Anthropic side assert(effort_of(adaptive, nil) == "high", "default high") assert(effort_of(adaptive, {}) == "high", "empty output_config -> high") From 4d8a5598fe27c515ba0a6f4ec1b9574d491f359a Mon Sep 17 00:00:00 2001 From: Nic Date: Wed, 8 Jul 2026 20:33:19 +0800 Subject: [PATCH 5/7] fix(ai-proxy): align effort and structured outputs with the reference adapter An earlier revision of this branch second-guessed the Anthropic adapter that Anthropic-protocol clients are actually built against, and drifted from it. Restore that behavior: - `output_config.effort` is forwarded verbatim. Chat Completions accepts the same labels (none/minimal/low/medium/high/xhigh), so mapping them onto a smaller set only threw information away. - Adaptive thinking with no explicit effort falls back to `medium`. - `response_format.json_schema` carries `strict = true` again. Strict mode is what makes the schema binding, and it is safe because the schema is first normalized the way strict mode demands: `additionalProperties: false` on every object and every property listed in `required`, recursing through `items`, `anyOf`/`oneOf`/`allOf` and `$defs`/`definitions`. The client's schema is deep-copied first so the incoming body is never mutated. - The top-level `output_format` takes precedence over `output_config.format` again, falling back to it only when absent or empty. Signed-off-by: Nic --- .../anthropic-messages-to-openai-chat.lua | 95 +++++++++++++------ t/plugin/ai-proxy-anthropic.t | 57 +++++++---- 2 files changed, 103 insertions(+), 49 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 fc4e21b692a9..d686000eb062 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 @@ -217,6 +217,50 @@ local function convert_media_block(block) end +-- 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 + local required = {} + for name, prop in pairs(schema.properties) do + table.insert(required, name) + normalize_strict_schema(prop) + end + if #required == 0 then + setmetatable(required, core.json.empty_array_mt) + else + -- `required` is a set; sort it so the outgoing body is stable + table.sort(required) + end + schema.required = required + end + + normalize_strict_schema(schema.items) + + for _, key in ipairs({ "anyOf", "oneOf", "allOf" }) 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({ "$defs", "definitions" }) 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 @@ -239,25 +283,17 @@ local function convert_tool_choice(tc) end --- Anthropic output_config.effort → OpenAI reasoning_effort. Chat Completions --- accepts none/minimal/low/medium/high/xhigh; Anthropic's "max" has no --- counterpart, so it clamps to the highest level OpenAI does accept. -local effort_map = { - low = "low", - medium = "medium", - high = "high", - xhigh = "xhigh", - max = "xhigh", -} - --- Omitting output_config.effort is equivalent to "high" on the Anthropic side. -local DEFAULT_EFFORT = "high" +-- 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" -- Convert Anthropic thinking config to OpenAI reasoning_effort. -- `thinking.type` is one of "enabled", "disabled" or "adaptive". With -- "adaptive" the depth is driven by output_config.effort instead of --- budget_tokens. +-- 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 @@ -265,9 +301,9 @@ local function convert_thinking_config(thinking, output_config) if thinking.type == "adaptive" then if type(output_config) == "table" and type(output_config.effort) == "string" then - return effort_map[output_config.effort] or DEFAULT_EFFORT + return output_config.effort end - return DEFAULT_EFFORT + return ADAPTIVE_DEFAULT_EFFORT end if thinking.type ~= "enabled" then @@ -408,26 +444,27 @@ function _M.convert_request(request_table, ctx) end end - -- Structured outputs → response_format. The schema lives in - -- `output_config.format` (GA) or in the top-level `output_format` (beta), - -- both shaped as { type = "json_schema", schema = }. - -- `strict` is deliberately left unset: Anthropic accepts optional - -- properties and keywords such as `minLength` or `format`, all of which - -- OpenAI rejects under strict schema adherence. - local output_format - if type(request_table.output_config) == "table" then - output_format = request_table.output_config.format - end - if type(output_format) ~= "table" then - output_format = request_table.output_format + -- 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 = }. `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" then if output_format.type == "json_schema" and type(output_format.schema) == "table" 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 = output_format.schema, + schema = schema, + strict = true, }, } elseif output_format.type == "json_object" then diff --git a/t/plugin/ai-proxy-anthropic.t b/t/plugin/ai-proxy-anthropic.t index d0b925402fef..4b6991a1aeb1 100644 --- a/t/plugin/ai-proxy-anthropic.t +++ b/t/plugin/ai-proxy-anthropic.t @@ -634,7 +634,11 @@ OK local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat") local ctx = { var = {} } - local schema = { type = "object", additionalProperties = false } + 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" }}, @@ -646,10 +650,18 @@ OK 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.schema == schema, "schema forwarded") assert(r.response_format.json_schema.name == "structured_output", "schema name") - -- strict is not forced: Anthropic allows schema features OpenAI rejects there - assert(r.response_format.json_schema.strict == nil, "strict not forced") + 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") ngx.say("OK") @@ -2084,17 +2096,16 @@ OK 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") - -- OpenAI Chat Completions has no "max" level - assert(effort_of(adaptive, { effort = "max" }) == "xhigh", "max clamped to xhigh") - -- omitting effort is equivalent to "high" on the Anthropic side - assert(effort_of(adaptive, nil) == "high", "default high") - assert(effort_of(adaptive, {}) == "high", "empty output_config -> high") - -- an unrecognised level falls back to the Anthropic default too - assert(effort_of(adaptive, { effort = "unknown" }) == "high", "unknown -> high") + 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") -- output_config.effort alone does not enable reasoning assert(effort_of(nil, { effort = "high" }) == nil, "no thinking, no effort") ngx.say("OK") @@ -2122,21 +2133,27 @@ OK }, ctx) assert(r.response_format.type == "json_schema", "type") - assert(r.response_format.json_schema.schema == schema, "schema forwarded") - assert(r.response_format.json_schema.strict == nil, "strict not forced") + 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") - -- the GA output_config.format wins over the beta output_format, and - -- an output_format of another shape must not shadow it - local ga_schema = { type = "object", additionalProperties = false } + -- 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_format = { type = "text" }, - output_config = { format = { type = "json_schema", schema = ga_schema } }, + 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(r2.response_format ~= nil, "GA schema must not be shadowed") - assert(r2.response_format.json_schema.schema == ga_schema, "GA schema wins") + assert(r3.response_format.json_schema.schema.title == "beta", "output_format wins") ngx.say("OK") } } From 6f12b1e9d5310c494b9a4d1732b82501d397af55 Mon Sep 17 00:00:00 2001 From: Nic Date: Wed, 8 Jul 2026 22:00:29 +0800 Subject: [PATCH 6/7] fix(ai-proxy): align tool naming, effort buckets and message shaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic → OpenAI converter drifted from the reference adapter in several places. Bring the remaining ones back in line: - Tool names longer than 64 chars are now rewritten as <55-char prefix>_<8 hex chars of sha256(name)> instead of being cut at 64. Truncation alone made two long names that share a prefix collide; the previous fix for that -- a numeric suffix handed out from a table of already-used names -- is no longer needed and is removed. Names carrying characters outside [a-zA-Z0-9_-] still get those replaced, and now also get the hash suffix, so a sanitized name can never take a name that another tool owns verbatim. - thinking.budget_tokens buckets into reasoning_effort at 1024 / 2048 / 4096 (minimal / low / medium / high). A missing budget_tokens counts as zero rather than defaulting to medium. - A user message keeps its content blocks as an array, even when there is a single text block; an assistant message concatenates its text blocks into a string. Both previously depended on the block count. - output_format = {type = "json_object"} no longer maps to a response_format. It is not an Anthropic output format, so a client can never send it, and json_schema is the only shape that carries a schema. Signed-off-by: Nic --- .../anthropic-messages-to-openai-chat.lua | 201 +++++++----------- t/plugin/ai-proxy-anthropic.t | 175 +++++++++++---- 2 files changed, 211 insertions(+), 165 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 d686000eb062..3225e41d705e 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,6 +25,8 @@ -- 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 @@ -33,7 +35,6 @@ 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 @@ -51,22 +52,8 @@ local BUILTIN_TOOL_PREFIXES = { -- 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 - - -local function tool_name_is_valid(name) - return string_len(name) <= TOOL_NAME_MAX_LEN - and not ngx_re_find(name, "[^a-zA-Z0-9_-]", "jo") -end +local TOOL_NAME_HASH_LEN = 8 +local TOOL_NAME_PREFIX_LEN = TOOL_NAME_MAX_LEN - TOOL_NAME_HASH_LEN - 1 -- Anthropic built-in tools have a type but no input_schema; OpenAI can't @@ -84,55 +71,30 @@ local function is_builtin_tool(tool) end --- Claim the names that already satisfy OpenAI's constraints, so that a name --- produced by sanitizing some other tool can never steal one of them. -local function reserve_tool_names(tools, maps) - for _, tool in ipairs(tools) do - if type(tool) == "table" and type(tool.name) == "string" and tool.name ~= "" - and not is_builtin_tool(tool) and tool_name_is_valid(tool.name) then - maps.used[tool.name] = true - maps.forward[tool.name] = tool.name - end - 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, recording --- the mapping so the response converter can restore the original name. --- `maps.forward` is original → openai, `maps.reverse` is openai → original --- (renamed tools only), `maps.used` holds every name handed to the upstream. --- Both the `tools` array and the `tool_use` blocks in the conversation history --- go through here, so a tool always carries the same name in both places. -local function openai_tool_name(name, maps) - local mapped = maps.forward[name] - if mapped then - return mapped - end - - local candidate = name - if not tool_name_is_valid(name) then - candidate = sanitize_tool_name(name) - end - - -- Another tool already owns this name: disambiguate with a numeric suffix - if maps.used[candidate] then - local suffix = 2 - local unique - repeat - local suffix_str = "_" .. suffix - local max_base = TOOL_NAME_MAX_LEN - string_len(suffix_str) - unique = string_sub(candidate, 1, max_base) .. suffix_str - suffix = suffix + 1 - until not maps.used[unique] - candidate = unique +-- 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)>, so two tools +-- can never collide on the same upstream name. 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") + if sanitized == name and string_len(name) <= TOOL_NAME_MAX_LEN then + return name end - maps.used[candidate] = true - maps.forward[name] = candidate - if candidate ~= name then - maps.reverse[candidate] = name - end - return candidate + local renamed = string_sub(sanitized, 1, TOOL_NAME_PREFIX_LEN) .. "_" + .. tool_name_hash(name) + reverse[renamed] = name + return renamed end @@ -217,6 +179,17 @@ 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 + + -- 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. @@ -288,6 +261,11 @@ end -- 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. -- `thinking.type` is one of "enabled", "disabled" or "adaptive". With @@ -311,14 +289,16 @@ local function convert_thinking_config(thinking, output_config) 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 @@ -454,22 +434,19 @@ function _M.convert_request(request_table, ctx) output_format = request_table.output_config.format end end - if type(output_format) == "table" then - if output_format.type == "json_schema" and type(output_format.schema) == "table" 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, - }, - } - elseif output_format.type == "json_object" then - openai_body.response_format = { type = "json_object" } - end + if type(output_format) == "table" and output_format.type == "json_schema" + and type(output_format.schema) == "table" 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 @@ -483,13 +460,10 @@ function _M.convert_request(request_table, ctx) openai_body.service_tier = request_table.service_tier end - -- 1. Convert tools (only when non-empty). This runs before the messages so - -- that the `tool_use` blocks in the conversation history are renamed with - -- the same mapping as the tool definitions. - local tool_name_maps = { forward = {}, reverse = {}, used = {} } + -- 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 = {} - reserve_tool_names(request_table.tools, tool_name_maps) for _, tool in ipairs(request_table.tools) do if type(tool) ~= "table" then goto CONTINUE_TOOL @@ -508,7 +482,7 @@ function _M.convert_request(request_table, ctx) local oai_tool = { type = "function", ["function"] = { - name = openai_tool_name(tool.name, tool_name_maps), + name = openai_tool_name(tool.name, tool_name_map), description = tool.description, parameters = tool.input_schema, }, @@ -524,7 +498,7 @@ function _M.convert_request(request_table, ctx) 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 = tool_name_maps.forward[tc_func.name] or tc_func.name + tc_func.name = openai_tool_name(tc_func.name, tool_name_map) end end end @@ -557,7 +531,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 @@ -574,7 +547,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 @@ -583,7 +555,7 @@ function _M.convert_request(request_table, ctx) id = block.id, type = "function", ["function"] = { - name = openai_tool_name(block.name, tool_name_maps), + name = openai_tool_name(block.name, tool_name_map), arguments = core.json.encode(block.input or {}) } }) @@ -646,21 +618,7 @@ function _M.convert_request(request_table, ctx) end if #content_parts > 0 then - local content - if has_multimodal then - content = content_parts - else - local text_content = "" - for _, p in ipairs(content_parts) do - if p.type == "text" then - text_content = text_content .. (p.text or "") - end - end - content = text_content ~= "" and text_content or nil - end - if content then - table.insert(messages, { role = msg.role, content = content }) - end + table.insert(messages, { role = msg.role, content = content_parts }) end goto CONTINUE end @@ -671,23 +629,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) @@ -696,8 +647,8 @@ function _M.convert_request(request_table, ctx) openai_body.messages = messages -- Store tool name mapping in ctx for response restoration - if next(tool_name_maps.reverse) then - ctx.anthropic_tool_name_map = tool_name_maps.reverse + 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 diff --git a/t/plugin/ai-proxy-anthropic.t b/t/plugin/ai-proxy-anthropic.t index 4b6991a1aeb1..7b24af0911ff 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") } @@ -674,22 +669,30 @@ 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") + ngx.say("OK") } } @@ -1216,7 +1219,7 @@ OK 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 == "Here are the results:", "msg 2 text") + assert(r.messages[2].content[1].text == "Here are the results:", "msg 2 text") ngx.say("OK") } } @@ -1330,7 +1333,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({ @@ -1344,7 +1348,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") } @@ -1420,8 +1425,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]) @@ -1546,6 +1552,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({ @@ -1558,8 +1574,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 @@ -2000,7 +2018,7 @@ not a JSON object 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 == "also explain briefly", "msg 5 text") + assert(r.messages[5].content[1].text == "also explain briefly", "msg 5 text") ngx.say("OK") } } @@ -2063,13 +2081,26 @@ OK role = "user", content = { { type = "tool_result", tool_use_id = "call_1", content = "done" }, - { type = "text", text = "" }, } }}, }, 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") } } @@ -2297,6 +2328,70 @@ OK 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") } } From f97b8e1a3794c95b87c0c932895a7cf8a11ce9ae Mon Sep 17 00:00:00 2001 From: Nic Date: Wed, 8 Jul 2026 22:31:55 +0800 Subject: [PATCH 7/7] fix(ai-proxy): emit an empty `required` as a JSON array `core.json` has no `empty_array_mt`, so `setmetatable(t, core.json.empty_array_mt)` was `setmetatable(t, nil)` and an empty Lua table encoded as `{}`. A strict schema whose object declares no properties therefore reached the upstream as `"required": {}`, which is not an array and is rejected. `core.json.array_mt` is the metatable that exists. Both sites take it: the `required` array added by the strict-mode normalizer, and `message_start`'s empty `content` array in the response converter, which shipped `"content": {}` for as long as it has been there. While in the same code: - An adaptive `thinking` block with `output_config.effort = ""` forwarded the empty string as `reasoning_effort`; it now falls back to `medium`. - `output_format = {type = "json_schema", schema = {}}` produced a `response_format` carrying a schema with nothing in it; an empty schema now means no `response_format`, as with a missing one. - `tool_choice` naming a tool that is not declared no longer records a name mapping for a tool the upstream cannot call. - The two schema-keyword lists are hoisted out of the recursion. Signed-off-by: Nic --- .../anthropic-messages-to-openai-chat.lua | 54 +++++++----- t/plugin/ai-proxy-anthropic.t | 82 ++++++++++++++++++- 2 files changed, 115 insertions(+), 21 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 3225e41d705e..6657db881e36 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 @@ -80,11 +80,14 @@ 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)>, so two tools --- can never collide on the same upstream name. 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. +-- 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") if sanitized == name and string_len(name) <= TOOL_NAME_MAX_LEN then @@ -190,6 +193,12 @@ local function concat_text_parts(parts) 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. @@ -200,23 +209,22 @@ local function normalize_strict_schema(schema) if schema.type == "object" and type(schema.properties) == "table" then schema.additionalProperties = false - local required = {} + -- `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 - if #required == 0 then - setmetatable(required, core.json.empty_array_mt) - else - -- `required` is a set; sort it so the outgoing body is stable - table.sort(required) - 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({ "anyOf", "oneOf", "allOf" }) do + 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) @@ -224,7 +232,7 @@ local function normalize_strict_schema(schema) end end - for _, key in ipairs({ "$defs", "definitions" }) do + 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) @@ -278,7 +286,8 @@ local function convert_thinking_config(thinking, output_config) end if thinking.type == "adaptive" then - if type(output_config) == "table" and type(output_config.effort) == "string" 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 @@ -435,7 +444,7 @@ function _M.convert_request(request_table, ctx) end end if type(output_format) == "table" and output_format.type == "json_schema" - and type(output_format.schema) == "table" then + 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) @@ -464,6 +473,7 @@ function _M.convert_request(request_table, ctx) 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 @@ -479,10 +489,12 @@ function _M.convert_request(request_table, ctx) 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 = openai_tool_name(tool.name, tool_name_map), + name = oai_name, description = tool.description, parameters = tool.input_schema, }, @@ -493,12 +505,14 @@ function _M.convert_request(request_table, ctx) if #openai_tools > 0 then openai_body.tools = openai_tools end - -- Fix tool_choice to use the sanitized name if applicable + -- 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 = openai_tool_name(tc_func.name, tool_name_map) + tc_func.name = declared_names[tc_func.name] or tc_func.name end end end @@ -852,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 7b24af0911ff..ade9dad62f18 100644 --- a/t/plugin/ai-proxy-anthropic.t +++ b/t/plugin/ai-proxy-anthropic.t @@ -659,6 +659,38 @@ OK -- 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") } } @@ -693,6 +725,14 @@ OK }, 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") } } @@ -2137,6 +2177,7 @@ OK -- 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") @@ -2262,7 +2303,7 @@ OK -=== TEST 62: a sanitized tool name never steals a name another tool already owns +=== TEST 62: a rewritten tool name does not take a name another tool already owns --- config location /t { content_by_lua_block { @@ -2399,3 +2440,42 @@ OK 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]