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

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


The following commit(s) were added to refs/heads/master by this push:
     new adf0c7b3d feat(ai-proxy): rewrite Anthropic-to-OpenAI converter with 
whitelist body construction (#13321)
adf0c7b3d is described below

commit adf0c7b3d7ad54af196d46b75b44cfd5bc7c5d59
Author: Nic <[email protected]>
AuthorDate: Wed May 6 10:32:09 2026 +0800

    feat(ai-proxy): rewrite Anthropic-to-OpenAI converter with whitelist body 
construction (#13321)
---
 .../anthropic-messages-to-openai-chat.lua          |  825 ++++++++--
 apisix/plugins/ai-providers/base.lua               |    5 +
 t/fixtures/openai/chat-error.json                  |    7 +
 .../openai/chat-with-multiple-tool-calls.json      |   34 +
 t/fixtures/openai/chat-with-reasoning.json         |   25 +
 t/fixtures/openai/chat-with-tool-calls.json        |   26 +
 t/plugin/ai-proxy-anthropic.t                      | 1663 ++++++++++++++++++--
 7 files changed, 2291 insertions(+), 294 deletions(-)

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

Reply via email to