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..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 @@ -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 = }. `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 4ed54a426dfd..ade9dad62f18 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]