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 518aba8d49be..7d0b1b40625b 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= 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 253e048f649a..b1bdb55c9581 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 000000000000..328874092704 --- /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 000000000000..b8b0141e2197 --- /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 000000000000..86bc03880bfa --- /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 000000000000..b65fcb21a655 --- /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 f1cd43687c0c..e5912d192499 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]