blob: ade9dad62f186136e0d8ac143a8986dc639171e4 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
BEGIN {
$ENV{TEST_ENABLE_CONTROL_API_V1} = "0";
}
use t::APISIX 'no_plan';
log_level("info");
repeat_each(1);
no_long_string();
no_root_location();
add_block_preprocessor(sub {
my ($block) = @_;
if (!defined $block->request) {
$block->set_value("request", "GET /t");
}
my $user_yaml_config = <<_EOC_;
plugins:
- ai-proxy
- ai-proxy-multi
_EOC_
$block->set_value("extra_yaml_config", $user_yaml_config);
});
run_tests();
__DATA__
=== 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": "/v1/messages",
"plugins": {
"ai-proxy-multi": {
"instances": [
{
"name": "openai-backend",
"provider": "openai-compatible",
"weight": 1,
"auth": {
"header": {
"Authorization": "Bearer test-token"
}
},
"options": {
"model": "gpt-4o"
},
"override": {
"endpoint": "http://localhost:1980"
}
}
],
"ssl_verify": false
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 2: simple text message conversion
--- request
POST /v1/messages
{"model":"claude-sonnet-4-20250514","max_tokens":1024,"messages":[{"role":"user","content":"Hello"}]}
--- more_headers
Content-Type: application/json
X-AI-Fixture: openai/chat-basic.json
--- error_code: 200
--- response_body_like eval
qr/(?=.*"type":"message")(?=.*"type":"text")(?=.*"stop_reason":"end_turn")/
=== 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]
=== 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 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]
=== 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]
=== 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/(?s)(?=.*"type":"thinking")(?=.*"thinking":"Let me think step by step)(?=.*"signature":"")(?=.*"type":"text")(?=.*The answer is 42)/
--- no_error_log
[error]
=== 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]
=== 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 11: null prompt_tokens_details does not crash
--- request
POST /v1/messages
{"model":"test-model","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}
--- more_headers
Content-Type: application/json
X-AI-Fixture: openai/null-details.json
--- error_code: 200
--- response_body_like eval
qr/(?s)(?=.*"input_tokens":10)(?=.*"output_tokens":5)/
--- no_error_log
[error]
=== TEST 12: null usage object handled gracefully
--- request
POST /v1/messages
{"model":"test-model","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}
--- more_headers
Content-Type: application/json
X-AI-Fixture: openai/null-usage.json
--- error_code: 200
--- response_body_like eval
qr/"input_tokens":0/
--- no_error_log
[error]
=== TEST 13: null message fields handled gracefully
--- request
POST /v1/messages
{"model":"test-model","max_tokens":100,"messages":[{"role":"user","content":"test"}]}
--- more_headers
Content-Type: application/json
X-AI-Fixture: openai/null-message.json
--- error_code: 200
--- response_body_like eval
qr/"type":"text"/
--- no_error_log
[error]
=== 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"}]}
--- more_headers
Content-Type: application/json
X-AI-Fixture: openai/null-function.json
--- error_code: 200
--- response_body_like eval
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 ~= "high" 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 = {} }
local function effort_of(thinking)
local r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
thinking = thinking,
}, ctx)
return r.reasoning_effort
end
-- budget buckets: < 1024 minimal, < 2048 low, < 4096 medium, else high
local cases = {
{ 0, "minimal" }, { 1023, "minimal" },
{ 1024, "low" }, { 2047, "low" },
{ 2048, "medium" }, { 4095, "medium" },
{ 4096, "high" }, { 32000, "high" },
}
for _, c in ipairs(cases) do
local got = effort_of({ type = "enabled", budget_tokens = c[1] })
assert(got == c[2], c[1] .. " => " .. tostring(got))
end
-- enabled without budget_tokens is treated as a zero budget
assert(effort_of({ type = "enabled" }) == "minimal", "no budget")
-- disabled: no reasoning_effort
assert(effort_of({ type = "disabled" }) == 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.format (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 schema = {
type = "object",
properties = { a = { type = "string" }, b = { type = "string" } },
required = { "a" },
}
local r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
output_config = {
effort = "high",
format = { type = "json_schema", schema = schema },
},
}, ctx)
assert(r.response_format ~= nil, "response_format missing")
assert(r.response_format.type == "json_schema", "type: " .. r.response_format.type)
assert(r.response_format.json_schema.name == "structured_output", "schema name")
assert(r.response_format.json_schema.strict == true, "strict")
-- strict mode: additionalProperties false, every property required
local out = r.response_format.json_schema.schema
assert(out.additionalProperties == false, "additionalProperties false")
assert(#out.required == 2, "all properties required, got " .. #out.required)
-- the client's schema must not be mutated in place
assert(schema.additionalProperties == nil, "input schema mutated")
assert(#schema.required == 1, "input required mutated")
-- output_config should NOT leak
assert(r.output_config == nil, "output_config leaked")
-- an object with no properties still needs `required` to be a JSON
-- array, and every nested sub-schema has to be normalized too
local r2 = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
output_format = { type = "json_schema", schema = {
type = "object",
properties = {
empty = { type = "object", properties = {} },
list = { type = "array", items = {
type = "object", properties = { k = { type = "string" } },
}},
choice = { anyOf = {
{ type = "object", properties = { m = { type = "string" } } },
}},
ref = { ["$ref"] = "#/$defs/Inner" },
},
["$defs"] = {
Inner = { type = "object", properties = { z = { type = "boolean" } } },
},
}},
}, ctx)
local out2 = r2.response_format.json_schema.schema
local encoded = core.json.encode(out2)
assert(encoded:find('"required":%[%]'), "empty required must encode as []: " .. encoded)
assert(out2.properties.empty.additionalProperties == false, "nested empty object")
assert(out2.properties.list.items.additionalProperties == false, "array items")
assert(out2.properties.choice.anyOf[1].additionalProperties == false, "anyOf branch")
assert(out2["$defs"].Inner.additionalProperties == false, "$defs entry")
assert(out2["$defs"].Inner.required[1] == "z", "$defs required")
ngx.say("OK")
}
}
--- response_body
OK
--- no_error_log
[error]
=== TEST 24: only json_schema output formats become response_format
--- config
location /t {
content_by_lua_block {
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = {} }
-- `json_object` is not an Anthropic output format, so nothing is emitted
local r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
output_format = { type = "json_object" },
}, ctx)
assert(r.response_format == nil, "json_object should not map to response_format")
assert(r.output_format == nil, "output_format leaked")
-- json_schema without a schema is incomplete, so nothing is emitted either
r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
output_format = { type = "json_schema" },
}, ctx)
assert(r.response_format == nil, "schema-less json_schema")
-- an empty schema carries nothing to enforce
r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
output_format = { type = "json_schema", schema = {} },
}, ctx)
assert(r.response_format == nil, "empty json_schema")
ngx.say("OK")
}
}
--- 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 → tool messages first, then text 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 = "user",
content = {
{ type = "text", text = "Here are the results:" },
{ type = "tool_result", tool_use_id = "call_1", content = "done" },
}
}},
}, ctx)
-- tool message first, then text message
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[2].role == "user", "msg 2 role")
assert(r.messages[2].content[1].text == "Here are the results:", "msg 2 text")
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 == 1, "empty url skipped: " .. #msg.content)
assert(msg.content[1].text == "Describe this", "text kept")
-- nil URL source - should be skipped
r = converter.convert_request({
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 == 1, "nil url skipped: " .. #msg.content)
assert(msg.content[1].text == "Test", "text kept")
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: content array without cache_control
assert(r.messages[2].content[1].text == "Hello", "user text kept")
assert(r.messages[2].content[1].cache_control == nil, "no cache_control in message")
-- Tool: no cache_control field
local encoded = core.json.encode(r.tools[1])
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" } }
-- A name that already fits is forwarded untouched, with no mapping
local ctx0 = { var = {} }
local r0 = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "Hi" }},
tools = {{ name = string.rep("a", 64), input_schema = { type = "object" } }},
}, ctx0)
assert(r0.tools[1]["function"].name == string.rep("a", 64), "64 chars is fine")
assert(ctx0.anthropic_tool_name_map == nil, "no map when nothing is renamed")
-- Tool name with 70 chars (exceeds 64 limit)
local long_name = string.rep("a", 70)
local r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "Hi" }},
tools = {{
name = long_name,
description = "Long tool",
input_schema = { type = "object" },
}},
}, ctx)
-- <55-char prefix>_<8 hex chars of sha256(name)>, byte-for-byte what
-- LiteLLM's truncate_tool_name() produces for the same input
local oai_name = r.tools[1]["function"].name
assert(oai_name == string.rep("a", 55) .. "_6bd5e503", "hashed: " .. oai_name)
assert(#oai_name == 64, "truncated to 64: " .. #oai_name)
-- Mapping stored in ctx
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]
=== TEST 47: set route for native Anthropic protocol built-in var 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": {
"provider": "anthropic",
"auth": {
"header": {
"x-api-key": "test-key"
}
},
"options": {
"model": "claude-3-5-sonnet-20241022"
},
"override": {
"endpoint": "http://127.0.0.1:1980"
},
"ssl_verify": false
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 48: Anthropic streaming accumulates total_tokens from split message_start and message_delta events
--- request
POST /v1/messages
{"messages":[{"role":"user","content":"Hello"}],"model":"claude-3-5-sonnet-20241022","max_tokens":1024,"stream":true}
--- more_headers
X-AI-Fixture: anthropic/messages-streaming.sse
--- error_code: 200
--- access_log eval
qr/127\.0\.0\.1:1980 200 [\d.]+ \"\S+\" claude-3-5-sonnet-20241022 claude-3-5-sonnet-20241022 [\d.]+ 10 8 18 true false 0 /
=== TEST 49: Anthropic streaming detects tool_use in content_block_start as tool call
--- request
POST /v1/messages
{"messages":[{"role":"user","content":"What is the weather?"}],"model":"claude-3-5-sonnet-20241022","max_tokens":1024,"stream":true}
--- more_headers
X-AI-Fixture: anthropic/messages-streaming-with-tool-use.sse
--- error_code: 200
--- access_log eval
qr/127\.0\.0\.1:1980 200 [\d.]+ \"\S+\" claude-3-5-sonnet-20241022 claude-3-5-sonnet-20241022 [\d.]+ 20 5 25 true true 0 /
=== TEST 50: Anthropic non-streaming writes cache tokens to access log
--- request
POST /v1/messages
{"messages":[{"role":"user","content":"Hello"}],"model":"claude-3-5-sonnet-20241022","max_tokens":1024}
--- more_headers
X-AI-Fixture: anthropic/messages-with-cache.json
--- error_code: 200
--- access_log eval
qr/127\.0\.0\.1:1980 200 [\d.]+ \"\S+\" claude-3-5-sonnet-20241022 claude-3-5-sonnet-20241022 [\d.]+ 50 30 80 false false 0 \S* 200 100 0/
=== TEST 51: Anthropic streaming writes cache tokens to access log
--- request
POST /v1/messages
{"messages":[{"role":"user","content":"Hello"}],"model":"claude-3-5-sonnet-20241022","max_tokens":1024,"stream":true}
--- more_headers
X-AI-Fixture: anthropic/messages-streaming-with-cache.sse
--- error_code: 200
--- access_log eval
qr/127\.0\.0\.1:1980 200 [\d.]+ \"\S+\" claude-3-5-sonnet-20241022 claude-3-5-sonnet-20241022 [\d.]+ 50 30 80 true false 0 \S* 200 100 0/
=== TEST 52: tool_choice is dropped when no tools are forwarded to upstream
--- config
location /t {
content_by_lua_block {
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = {} }
-- tool_choice set but no tools field at all
local r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
tool_choice = { type = "auto" },
}, ctx)
assert(r.tools == nil, "tools should be nil")
assert(r.tool_choice == nil, "tool_choice must be dropped without tools")
-- tools present but all are Anthropic built-ins (dropped)
r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
tools = {{ type = "web_search", name = "web_search" }},
tool_choice = { type = "any", disable_parallel_tool_use = true },
}, ctx)
assert(r.tools == nil, "all built-in tools dropped, tools nil")
assert(r.tool_choice == nil, "tool_choice must be dropped when tools empty")
assert(r.parallel_tool_calls == nil, "parallel_tool_calls must be dropped too")
-- sanity: tool_choice preserved when a real tool remains
r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
tools = {{ name = "f", input_schema = {} }},
tool_choice = { type = "auto" },
}, ctx)
assert(r.tool_choice == "auto", "tool_choice kept with a real tool")
ngx.say("OK")
}
}
--- response_body
OK
--- no_error_log
[error]
=== TEST 53: streaming - done after message_start without content block emits message_stop
--- 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 opens the message (message_start) but no content block
local events = converter.convert_sse_events({
type = "data",
data = { id = "x", model = "m", choices = {{ delta = { role = "assistant" } }} },
}, {}, state)
assert(#events >= 1, "expected message_start")
assert(core.json.decode(events[1].data).type == "message_start", "first is message_start")
assert(state.current_open_block == nil, "no content block opened")
-- Upstream ends the stream with [DONE] and no finish_reason chunk
events = converter.convert_sse_events({ type = "done" }, {}, state)
assert(events ~= nil, "done must not return nil after message_start")
local saw_stop = false
for _, e in ipairs(events) do
if core.json.decode(e.data).type == "message_stop" then
saw_stop = true
end
end
assert(saw_stop, "message_stop must be emitted to avoid hanging the client")
assert(state.is_done, "stream marked done")
ngx.say("OK")
}
}
--- response_body
OK
--- no_error_log
[error]
=== TEST 54: malformed tool_call arguments fall back to empty input instead of aborting
An OpenAI-compatible upstream may emit tool_call arguments that are not valid
JSON (or not a JSON object). The converter must not abort the whole response --
which would also drop already-collected text/thinking content -- but fall back
to an empty input object and log a warning.
--- config
location /t {
content_by_lua_block {
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = { llm_model = "gpt-4o" } }
local res, err = converter.convert_response({
id = "msg_1",
choices = {{
message = {
content = "partial answer",
tool_calls = {{
id = "call_1",
type = "function",
["function"] = { name = "do_it", arguments = "{not valid json" },
}},
},
finish_reason = "tool_calls",
}},
usage = { prompt_tokens = 10, completion_tokens = 5 },
}, ctx)
assert(res ~= nil, "conversion must not abort: " .. tostring(err))
local has_text, has_tool = false, false
for _, c in ipairs(res.content) do
if c.type == "text" and c.text == "partial answer" then
has_text = true
end
if c.type == "tool_use" then
has_tool = true
assert(type(c.input) == "table", "input is an object")
assert(next(c.input) == nil, "input is an empty object")
assert(c.name == "do_it", "tool name preserved")
end
end
assert(has_text, "already-collected text content preserved")
assert(has_tool, "tool_use block still emitted")
ngx.say("OK")
}
}
--- response_body
OK
--- error_log
failed to decode tool_call arguments
=== TEST 55: tool_call arguments that decode to non-object fall back to empty input
Valid JSON that is not an object (number, string, boolean) should also trigger
the empty-object fallback and log "not a 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 = { llm_model = "gpt-4o" } }
local res, err = converter.convert_response({
id = "msg_1",
choices = {{
message = {
content = "answer",
tool_calls = {{
id = "call_1",
type = "function",
["function"] = { name = "do_it", arguments = "123" },
}},
},
finish_reason = "tool_calls",
}},
usage = { prompt_tokens = 10, completion_tokens = 5 },
}, ctx)
assert(res ~= nil, "conversion must not abort: " .. tostring(err))
local has_text, has_tool = false, false
for _, c in ipairs(res.content) do
if c.type == "text" and c.text == "answer" then
has_text = true
end
if c.type == "tool_use" then
has_tool = true
assert(type(c.input) == "table", "input is an object")
assert(next(c.input) == nil, "input is an empty object")
end
end
assert(has_text, "already-collected text content preserved")
assert(has_tool, "tool_use block emitted")
ngx.say("OK")
}
}
--- response_body
OK
--- error_log
not a JSON object
=== TEST 56: tool_results precede the trailing text message (parallel tool_calls)
--- config
location /t {
content_by_lua_block {
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = {} }
-- assistant emits 2 parallel tool_use blocks, the next user message
-- carries both tool_results plus extra text (what Claude Code sends
-- when a system-reminder or a queued prompt rides along).
local r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {
{ role = "user", content = "start" },
{ role = "assistant", content = {
{ type = "tool_use", id = "call_a", name = "get_a", input = {} },
{ type = "tool_use", id = "call_b", name = "get_b", input = {} },
}},
{ role = "user", content = {
{ type = "tool_result", tool_use_id = "call_a", content = "a" },
{ type = "tool_result", tool_use_id = "call_b", content = "b" },
{ type = "text", text = "also explain briefly" },
}},
},
}, ctx)
-- every tool message must immediately follow the assistant tool_calls
assert(#r.messages == 5, "expected 5 messages, got " .. #r.messages)
assert(r.messages[2].role == "assistant", "assistant")
assert(#r.messages[2].tool_calls == 2, "2 tool_calls")
assert(r.messages[3].role == "tool", "msg 3 role: " .. r.messages[3].role)
assert(r.messages[3].tool_call_id == "call_a", "msg 3 id")
assert(r.messages[4].role == "tool", "msg 4 role: " .. r.messages[4].role)
assert(r.messages[4].tool_call_id == "call_b", "msg 4 id")
assert(r.messages[5].role == "user", "msg 5 role")
assert(r.messages[5].content[1].text == "also explain briefly", "msg 5 text")
ngx.say("OK")
}
}
--- response_body
OK
--- no_error_log
[error]
=== TEST 57: media alongside tool_results is preserved after the tool messages
--- config
location /t {
content_by_lua_block {
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = {} }
local r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{
role = "user",
content = {
{ type = "tool_result", tool_use_id = "call_1", content = "done" },
{ type = "text", text = "look at this" },
{ type = "image", source = {
type = "base64", media_type = "image/png", data = "img",
}},
}
}},
}, ctx)
assert(#r.messages == 2, "expected 2 messages, got " .. #r.messages)
assert(r.messages[1].role == "tool", "tool message first")
local content = r.messages[2].content
assert(r.messages[2].role == "user", "user message second")
assert(type(content) == "table", "multimodal content kept as array")
assert(content[1].type == "text", "text part")
assert(content[2].type == "image_url", "image part kept")
assert(content[2].image_url.url == "data:image/png;base64,img", "image url")
ngx.say("OK")
}
}
--- response_body
OK
--- no_error_log
[error]
=== TEST 58: tool_result only (no extra content) emits no trailing message
--- config
location /t {
content_by_lua_block {
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = {} }
local r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{
role = "user",
content = {
{ type = "tool_result", tool_use_id = "call_1", content = "done" },
}
}},
}, ctx)
assert(#r.messages == 1, "expected 1 message, got " .. #r.messages)
assert(r.messages[1].role == "tool", "tool message only")
-- an empty text block is still a block: it becomes a trailing message
r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{
role = "user",
content = {
{ type = "tool_result", tool_use_id = "call_1", content = "done" },
{ type = "text", text = "" },
}
}},
}, ctx)
assert(#r.messages == 2, "expected 2 messages, got " .. #r.messages)
assert(r.messages[2].content[1].text == "", "empty text block kept")
ngx.say("OK")
}
}
--- response_body
OK
--- no_error_log
[error]
=== TEST 59: adaptive thinking → reasoning_effort from output_config.effort
--- config
location /t {
content_by_lua_block {
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local function effort_of(thinking, output_config)
local r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
thinking = thinking,
output_config = output_config,
}, { var = {} })
return r.reasoning_effort
end
-- output_config.effort is forwarded verbatim: Chat Completions takes
-- the same labels (none/minimal/low/medium/high/xhigh)
local adaptive = { type = "adaptive" }
assert(effort_of(adaptive, { effort = "low" }) == "low", "low")
assert(effort_of(adaptive, { effort = "high" }) == "high", "high")
assert(effort_of(adaptive, { effort = "xhigh" }) == "xhigh", "xhigh")
assert(effort_of(adaptive, { effort = "max" }) == "max", "max")
-- adaptive without an explicit effort falls back to medium
assert(effort_of(adaptive, nil) == "medium", "default medium")
assert(effort_of(adaptive, {}) == "medium", "empty output_config -> medium")
assert(effort_of(adaptive, { effort = "" }) == "medium", "empty effort -> medium")
-- output_config.effort alone does not enable reasoning
assert(effort_of(nil, { effort = "high" }) == nil, "no thinking, no effort")
ngx.say("OK")
}
}
--- response_body
OK
--- no_error_log
[error]
=== TEST 60: response_format from output_format (json_schema, beta shape)
--- config
location /t {
content_by_lua_block {
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = {} }
local schema = { type = "object", additionalProperties = false }
local r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
output_format = { type = "json_schema", schema = schema },
}, ctx)
assert(r.response_format.type == "json_schema", "type")
assert(r.response_format.json_schema.strict == true, "strict")
assert(r.response_format.json_schema.schema.type == "object", "schema forwarded")
assert(r.output_format == nil, "output_format leaked")
-- an absent/empty output_format falls back to output_config.format
local r2 = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
output_config = { format = { type = "json_schema", schema = { type = "object" } } },
}, { var = {} })
assert(r2.response_format ~= nil, "output_config.format used")
assert(r2.response_format.json_schema.strict == true, "strict")
-- the top-level output_format wins when both carry a schema
local r3 = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
output_format = { type = "json_schema", schema = { type = "object", title = "beta" } },
output_config = { format = { type = "json_schema", schema = { type = "object", title = "ga" } } },
}, { var = {} })
assert(r3.response_format.json_schema.schema.title == "beta", "output_format wins")
ngx.say("OK")
}
}
--- response_body
OK
--- no_error_log
[error]
=== TEST 61: tool_use in history uses the same sanitized name as the tool definition
--- config
location /t {
content_by_lua_block {
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = { llm_model = "gpt-4o" } }
local long_name = string.rep("a", 70)
local r = converter.convert_request({
model = "m", max_tokens = 100,
tools = {
{ name = long_name, description = "Long", input_schema = { type = "object" } },
{ name = "my tool!x", description = "Invalid chars", input_schema = { type = "object" } },
},
messages = {
{ role = "user", content = "hi" },
{ role = "assistant", content = {
{ type = "tool_use", id = "call_1", name = long_name, input = {} },
{ type = "tool_use", id = "call_2", name = "my tool!x", input = {} },
}},
{ role = "user", content = {
{ type = "tool_result", tool_use_id = "call_1", content = "ok" },
{ type = "tool_result", tool_use_id = "call_2", content = "ok" },
}},
},
}, ctx)
local declared_1 = r.tools[1]["function"].name
local declared_2 = r.tools[2]["function"].name
local called_1 = r.messages[2].tool_calls[1]["function"].name
local called_2 = r.messages[2].tool_calls[2]["function"].name
assert(called_1 == declared_1, "call 1: " .. called_1 .. " vs " .. declared_1)
assert(called_2 == declared_2, "call 2: " .. called_2 .. " vs " .. declared_2)
assert(not called_2:find("[^a-zA-Z0-9_%-]"), "valid chars: " .. called_2)
-- a tool_use whose definition is absent still gets a valid name and
-- is restored on the response
local ctx2 = { var = { llm_model = "gpt-4o" } }
local r2 = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "assistant", content = {
{ type = "tool_use", id = "call_1", name = "orphan tool!", input = {} },
}}},
}, ctx2)
local orphan = r2.messages[1].tool_calls[1]["function"].name
assert(not orphan:find("[^a-zA-Z0-9_%-]"), "orphan sanitized: " .. orphan)
assert(ctx2.anthropic_tool_name_map[orphan] == "orphan tool!", "orphan mapped")
local res = converter.convert_response({
id = "msg_1",
choices = {{ message = { tool_calls = {{
id = "call_1", type = "function",
["function"] = { name = orphan, arguments = "{}" },
}}}, finish_reason = "tool_calls" }},
usage = { prompt_tokens = 10, completion_tokens = 5 },
}, ctx2)
assert(res.content[1].name == "orphan tool!", "restored: " .. res.content[1].name)
ngx.say("OK")
}
}
--- response_body
OK
--- no_error_log
[error]
=== TEST 62: a rewritten tool name does not take a name another tool already owns
--- config
location /t {
content_by_lua_block {
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = { llm_model = "gpt-4o" } }
-- "get weather" sanitizes to "get_weather", which is also a literal
-- tool name here; the tool that owns it verbatim must keep it
local r = converter.convert_request({
model = "m", max_tokens = 100,
tools = {
{ name = "get weather", description = "A", input_schema = { type = "object" } },
{ name = "get_weather", description = "B", input_schema = { type = "object" } },
},
messages = {
{ role = "user", content = "hi" },
{ role = "assistant", content = {
{ type = "tool_use", id = "c1", name = "get weather", input = {} },
{ type = "tool_use", id = "c2", name = "get_weather", input = {} },
}},
},
}, ctx)
local n1 = r.tools[1]["function"].name
local n2 = r.tools[2]["function"].name
assert(n1 ~= n2, "tool names must be unique: " .. n1 .. " vs " .. n2)
assert(n2 == "get_weather", "valid name kept: " .. n2)
-- history tool_use follows the same mapping
assert(r.messages[2].tool_calls[1]["function"].name == n1, "call 1")
assert(r.messages[2].tool_calls[2]["function"].name == n2, "call 2")
-- each openai name restores to the right original
local function restore(oai_name)
local res = converter.convert_response({
id = "msg_1",
choices = {{ message = { tool_calls = {{
id = "c", type = "function",
["function"] = { name = oai_name, arguments = "{}" },
}}}, finish_reason = "tool_calls" }},
usage = { prompt_tokens = 1, completion_tokens = 1 },
}, ctx)
return res.content[1].name
end
assert(restore(n1) == "get weather", "restore n1: " .. restore(n1))
assert(restore(n2) == "get_weather", "restore n2: " .. restore(n2))
-- a 70-char name truncates onto a 64-char name owned by another tool
local ctx2 = { var = { llm_model = "gpt-4o" } }
local name64 = string.rep("a", 64)
local name70 = string.rep("a", 70)
local r2 = converter.convert_request({
model = "m", max_tokens = 100,
tools = {
{ name = name64, description = "A", input_schema = { type = "object" } },
{ name = name70, description = "B", input_schema = { type = "object" } },
},
messages = {{ role = "user", content = "hi" }},
}, ctx2)
local m1 = r2.tools[1]["function"].name
local m2 = r2.tools[2]["function"].name
assert(m1 == name64, "64-char name kept as is")
assert(m2 ~= m1, "truncated name must not collide: " .. m2)
assert(#m2 <= 64, "still within the limit: " .. #m2)
assert(ctx2.anthropic_tool_name_map[m2] == name70, "truncated name maps back")
assert(ctx2.anthropic_tool_name_map[m1] == nil, "untouched name needs no mapping")
-- two long names that share their first 64 chars: the hash suffix is
-- what keeps them apart
local ctx3 = { var = { llm_model = "gpt-4o" } }
local prefix = "mcp__server__" .. string.rep("a", 55)
local r3 = converter.convert_request({
model = "m", max_tokens = 100,
tools = {
{ name = prefix .. "_one", input_schema = { type = "object" } },
{ name = prefix .. "_two", input_schema = { type = "object" } },
},
messages = {{ role = "user", content = "hi" }},
}, ctx3)
local p1 = r3.tools[1]["function"].name
local p2 = r3.tools[2]["function"].name
assert(p1 ~= p2, "shared prefix must not collide: " .. p1)
assert(ctx3.anthropic_tool_name_map[p1] == prefix .. "_one", "prefix map 1")
assert(ctx3.anthropic_tool_name_map[p2] == prefix .. "_two", "prefix map 2")
ngx.say("OK")
}
}
--- response_body
OK
--- no_error_log
[error]
=== TEST 63: content block shaping depends on the role
--- config
location /t {
content_by_lua_block {
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = {} }
local r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {
-- a user turn keeps its blocks as an array, even a single one
{ role = "user", content = {{ type = "text", text = "a" }} },
-- an assistant turn only carries text, so it becomes a string
{ role = "assistant", content = {
{ type = "text", text = "x" },
{ type = "text", text = "y" },
}},
{ role = "user", content = {
{ type = "text", text = "b" },
{ type = "text", text = "c" },
}},
-- a plain string is forwarded as is, whatever the role
{ role = "assistant", content = "z" },
},
}, ctx)
assert(type(r.messages[1].content) == "table", "user single block is an array")
assert(#r.messages[1].content == 1, "one part")
assert(r.messages[1].content[1].type == "text", "part type")
assert(r.messages[1].content[1].text == "a", "part text")
assert(r.messages[2].content == "xy", "assistant blocks concatenated: "
.. tostring(r.messages[2].content))
assert(#r.messages[3].content == 2, "user keeps both blocks")
assert(r.messages[4].content == "z", "string content untouched")
ngx.say("OK")
}
}
--- response_body
OK
--- no_error_log
[error]
=== TEST 64: tool_choice follows the declared tool name
--- config
location /t {
content_by_lua_block {
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
local ctx = { var = {} }
local r = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
tools = {{ name = "my tool!x", input_schema = { type = "object" } }},
tool_choice = { type = "tool", name = "my tool!x" },
}, ctx)
local declared = r.tools[1]["function"].name
assert(r.tool_choice["function"].name == declared,
"tool_choice must name the declared tool: " .. r.tool_choice["function"].name)
assert(ctx.anthropic_tool_name_map[declared] == "my tool!x", "declared tool mapped")
-- a tool_choice naming a tool that was never declared is left alone,
-- and must not invent a mapping the upstream can never produce
local ctx2 = { var = {} }
local r2 = converter.convert_request({
model = "m", max_tokens = 100,
messages = {{ role = "user", content = "hi" }},
tools = {{ name = "real_tool", input_schema = { type = "object" } }},
tool_choice = { type = "tool", name = "ghost tool" },
}, ctx2)
assert(r2.tool_choice["function"].name == "ghost tool", "undeclared name untouched")
assert(ctx2.anthropic_tool_name_map == nil, "no mapping for an undeclared tool")
ngx.say("OK")
}
}
--- response_body
OK
--- no_error_log
[error]