This is an automated email from the ASF dual-hosted git repository.

nic-6443 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new a28d9bb50 fix(ai-proxy): correct Anthropic Messages to OpenAI Chat 
request conversion (#13674)
a28d9bb50 is described below

commit a28d9bb50d78b37bbda83a8b37eb17df5dc16193
Author: Nic <[email protected]>
AuthorDate: Thu Jul 9 12:24:47 2026 +0800

    fix(ai-proxy): correct Anthropic Messages to OpenAI Chat request conversion 
(#13674)
---
 .../anthropic-messages-to-openai-chat.lua          | 380 ++++++++-----
 t/plugin/ai-proxy-anthropic.t                      | 627 +++++++++++++++++++--
 2 files changed, 813 insertions(+), 194 deletions(-)

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

Reply via email to