From c4822bb0648dbd8f992306b8270b88f7e8c3d503 Mon Sep 17 00:00:00 2001 From: Georgel Preput Date: Tue, 16 Jun 2026 22:43:14 +0200 Subject: [PATCH] feat(ai-proxy): support OpenAI Chat Completions clients with native Anthropic Messages API upstreams ai-proxy/ai-proxy-multi could translate Anthropic-format clients to OpenAI-compatible upstreams, but not the reverse. There was no way to accept an OpenAI Chat Completions request and forward it to an upstream that only speaks the native Anthropic Messages API (/v1/messages), such as Azure AI Foundry Claude, which exposes no OpenAI-compatible endpoint. Add the missing direction (non-streaming): - New `anthropic-compatible` provider exposing only the `anthropic-messages` capability. The existing `anthropic` provider declares both `openai-chat` and `anthropic-messages`, so OpenAI clients always hit native passthrough and never reach a converter; a provider with only the Messages capability lets the converter run. This mirrors how `openai-compatible` is the vendor-neutral counterpart used as a conversion target. - New `openai-chat` -> `anthropic-messages` converter: request (system role to top-level `system`, tools, tool_choice, tool messages to user tool_result blocks, images, reasoning_effort to thinking, max_tokens), non-streaming response (content/tool_use/thinking to choices, stop reason and usage mapping including cache tokens), and headers. - Streaming (stream=true) is rejected with a clear error for now; it is left as a follow-up since it requires the Anthropic Messages SSE adapter to pass raw event data through to the converter. - Tests (t/plugin/ai-proxy-openai-to-anthropic.t) and docs. Ref: #13566 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Georgel Preput --- .../plugins/ai-protocols/converters/init.lua | 3 + .../openai-chat-to-anthropic-messages.lua | 528 ++++++++++++++++++ .../ai-providers/anthropic-compatible.lua | 44 ++ apisix/plugins/ai-providers/schema.lua | 2 +- docs/en/latest/plugins/ai-proxy-multi.md | 2 +- docs/en/latest/plugins/ai-proxy.md | 3 +- docs/zh/latest/plugins/ai-proxy-multi.md | 2 +- docs/zh/latest/plugins/ai-proxy.md | 3 +- t/fixtures/anthropic/messages-tool-use.json | 24 + t/lib/server.lua | 48 ++ t/plugin/ai-proxy-openai-to-anthropic.t | 421 ++++++++++++++ 11 files changed, 1075 insertions(+), 5 deletions(-) create mode 100644 apisix/plugins/ai-protocols/converters/openai-chat-to-anthropic-messages.lua create mode 100644 apisix/plugins/ai-providers/anthropic-compatible.lua create mode 100644 t/fixtures/anthropic/messages-tool-use.json create mode 100644 t/plugin/ai-proxy-openai-to-anthropic.t diff --git a/apisix/plugins/ai-protocols/converters/init.lua b/apisix/plugins/ai-protocols/converters/init.lua index cbe0e226193e..c8a03ed65dd7 100644 --- a/apisix/plugins/ai-protocols/converters/init.lua +++ b/apisix/plugins/ai-protocols/converters/init.lua @@ -68,5 +68,8 @@ register(require( register(require( "apisix.plugins.ai-protocols.converters.openai-embeddings-to-vertex-predict")) +register(require( + "apisix.plugins.ai-protocols.converters.openai-chat-to-anthropic-messages")) + return _M diff --git a/apisix/plugins/ai-protocols/converters/openai-chat-to-anthropic-messages.lua b/apisix/plugins/ai-protocols/converters/openai-chat-to-anthropic-messages.lua new file mode 100644 index 000000000000..5619ff064166 --- /dev/null +++ b/apisix/plugins/ai-protocols/converters/openai-chat-to-anthropic-messages.lua @@ -0,0 +1,528 @@ +-- +-- 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. +-- + +--- Converter: OpenAI Chat Completions → Anthropic Messages. +-- Converts client requests from OpenAI Chat Completions format to the native +-- Anthropic Messages API format, and converts provider responses back from +-- Anthropic to OpenAI format. The inverse of +-- ai-protocols/converters/anthropic-messages-to-openai-chat.lua. +-- +-- Uses whitelist body construction: the outgoing Anthropic body is built from +-- scratch with only explicitly converted fields. Unknown OpenAI fields never +-- reach the upstream provider. +-- +-- Streaming (stream=true) is not yet supported in this direction and is +-- rejected in convert_request; see the tracking issue. + +local core = require("apisix.core") +local table = table +local type = type +local pairs = pairs +local ipairs = ipairs +local tostring = tostring + +-- Anthropic Messages requires max_tokens; OpenAI Chat makes it optional, so +-- supply a default when the client omits it. Route override.llm_options.max_tokens +-- still force-overrides this afterward via the provider capability rewrite. +local DEFAULT_MAX_TOKENS = 4096 + +local _M = { + from = "openai-chat", + to = "anthropic-messages", +} + + +local anthropic_stop_reason_map = { + end_turn = "stop", + stop_sequence = "stop", + max_tokens = "length", + tool_use = "tool_calls", + pause_turn = "stop", + refusal = "stop", +} + + +-- Convert OpenAI reasoning_effort to an Anthropic thinking config. +-- Mirrors the budget thresholds used by the reverse converter. +local function convert_reasoning_effort(effort) + if effort == "low" then + return { type = "enabled", budget_tokens = 1024 } + elseif effort == "medium" then + return { type = "enabled", budget_tokens = 8192 } + elseif effort == "high" then + return { type = "enabled", budget_tokens = 24576 } + end + return nil +end + + +-- Convert OpenAI tool_choice to Anthropic format. +local function convert_tool_choice(tc) + if tc == "auto" then + return { type = "auto" } + elseif tc == "required" then + return { type = "any" } + elseif tc == "none" then + return { type = "none" } + elseif type(tc) == "table" and tc.type == "function" + and type(tc["function"]) == "table" + and type(tc["function"].name) == "string" then + return { type = "tool", name = tc["function"].name } + end + return nil +end + + +-- Convert an OpenAI image_url content part to an Anthropic image block. +-- Handles both base64 data URLs and remote URLs. +local function convert_image_part(part) + local image_url = part.image_url + local url + if type(image_url) == "table" then + url = image_url.url + elseif type(image_url) == "string" then + url = image_url + end + if type(url) ~= "string" or url == "" then + return nil + end + + -- data:;base64, + local media_type, data = url:match("^data:([^;]+);base64,(.+)$") + if media_type and data then + return { + type = "image", + source = { + type = "base64", + media_type = media_type, + data = data, + }, + } + end + + return { + type = "image", + source = { + type = "url", + url = url, + }, + } +end + + +-- Convert an OpenAI message content (string or content-part array) to a plain +-- string or an array of Anthropic content blocks. Returns a string when the +-- content is purely text, otherwise an array of blocks. +local function convert_content(content) + if type(content) == "string" then + return content + end + if type(content) ~= "table" then + return "" + end + + local blocks = {} + local has_non_text = false + for _, part in ipairs(content) do + if type(part) == "table" then + if part.type == "text" and type(part.text) == "string" then + table.insert(blocks, { type = "text", text = part.text }) + elseif part.type == "image_url" then + local img = convert_image_part(part) + if img then + table.insert(blocks, img) + has_non_text = true + end + else + core.log.warn("dropping unsupported OpenAI content part type '", + tostring(part.type), "' in openai-chat to ", + "anthropic-messages conversion") + end + end + end + + -- Flatten a single text block back to a plain string. + if not has_non_text and #blocks == 1 and blocks[1].type == "text" then + return blocks[1].text + end + if #blocks == 0 then + return "" + end + return blocks +end + + +-- Append a tool_result block to the trailing user message, coalescing +-- consecutive OpenAI `tool` messages into a single Anthropic user message +-- (Anthropic carries tool results as user-role tool_result blocks). +local function append_tool_result(messages, tool_call_id, content) + local block = { + type = "tool_result", + tool_use_id = tool_call_id, + content = type(content) == "string" and content or "", + } + local last = messages[#messages] + if last and last.role == "user" and type(last.content) == "table" + and last._tool_result_group then + table.insert(last.content, block) + else + table.insert(messages, { + role = "user", + content = { block }, + _tool_result_group = true, + }) + end +end + + +--- Convert an incoming OpenAI Chat request to Anthropic Messages format. +function _M.convert_request(request_table, ctx) + if type(request_table) ~= "table" then + return nil, "request body must be a table" + end + + if request_table.stream == true then + return nil, "streaming is not yet supported for openai-chat to " + .. "anthropic-messages conversion" + end + + if type(request_table.messages) ~= "table" or + #request_table.messages == 0 then + return nil, "missing messages" + end + + -- Whitelist body construction: only explicitly converted fields are set. + local anthropic_body = {} + + -- Model passthrough + if type(request_table.model) == "string" then + anthropic_body.model = request_table.model + end + + -- max_tokens (required by Anthropic). Accept either OpenAI field. + anthropic_body.max_tokens = request_table.max_tokens + or request_table.max_completion_tokens + or DEFAULT_MAX_TOKENS + + -- Simple parameter passthrough + if request_table.temperature then + anthropic_body.temperature = request_table.temperature + end + if request_table.top_p then + anthropic_body.top_p = request_table.top_p + end + + -- stop → stop_sequences (string or array) + if type(request_table.stop) == "string" then + anthropic_body.stop_sequences = { request_table.stop } + elseif type(request_table.stop) == "table" then + anthropic_body.stop_sequences = request_table.stop + end + + -- reasoning_effort → thinking + if type(request_table.reasoning_effort) == "string" then + local thinking = convert_reasoning_effort(request_table.reasoning_effort) + if thinking then + anthropic_body.thinking = thinking + end + end + + -- user / safety_identifier → metadata.user_id + local user_id = request_table.safety_identifier or request_table.user + if type(user_id) == "string" then + anthropic_body.metadata = { user_id = user_id } + end + + -- tool_choice conversion + if request_table.tool_choice ~= nil then + local tc = convert_tool_choice(request_table.tool_choice) + if tc then + if tc.type == "tool" or tc.type == "any" or tc.type == "auto" then + if request_table.parallel_tool_calls == false then + tc.disable_parallel_tool_use = true + end + end + anthropic_body.tool_choice = tc + end + end + + -- tools conversion (OpenAI function tools → Anthropic tools) + if type(request_table.tools) == "table" and #request_table.tools > 0 then + local anthropic_tools = {} + for _, tool in ipairs(request_table.tools) do + if type(tool) == "table" and tool.type == "function" + and type(tool["function"]) == "table" + and type(tool["function"].name) == "string" then + local fn = tool["function"] + table.insert(anthropic_tools, { + name = fn.name, + description = fn.description, + input_schema = fn.parameters or { type = "object" }, + }) + end + end + if #anthropic_tools > 0 then + anthropic_body.tools = anthropic_tools + end + end + + -- Messages: split system role out to top-level `system`, convert the rest. + local system_parts = {} + local 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 msg.role == "system" or msg.role == "developer" then + local text = msg.content + if type(text) == "table" then + -- Concatenate text parts of a structured system message. + local parts = {} + for _, part in ipairs(text) do + if type(part) == "table" and part.type == "text" + and type(part.text) == "string" then + table.insert(parts, part.text) + end + end + text = table.concat(parts, "") + end + if type(text) == "string" and text ~= "" then + table.insert(system_parts, text) + end + goto CONTINUE + end + + if msg.role == "tool" then + if type(msg.tool_call_id) == "string" then + append_tool_result(messages, msg.tool_call_id, + convert_content(msg.content)) + end + goto CONTINUE + end + + -- user / assistant + local new_msg = { role = msg.role } + + if msg.role == "assistant" and type(msg.tool_calls) == "table" + and #msg.tool_calls > 0 then + local blocks = {} + -- Preserve any assistant text alongside the tool calls. + local text = convert_content(msg.content) + if type(text) == "string" and text ~= "" then + table.insert(blocks, { type = "text", text = text }) + elseif type(text) == "table" then + for _, b in ipairs(text) do + table.insert(blocks, b) + end + end + for _, tc in ipairs(msg.tool_calls) do + if type(tc) == "table" and tc.type == "function" + and type(tc["function"]) == "table" then + local input = {} + local args = tc["function"].arguments + if type(args) == "string" and args ~= "" then + local decoded, err = core.json.decode(args) + if decoded == nil then + return nil, "invalid tool_calls arguments at message " + .. i .. ": " .. (err or "decode error") + end + input = decoded + end + table.insert(blocks, { + type = "tool_use", + id = tc.id or "", + name = (tc["function"].name) or "", + input = input, + }) + end + end + new_msg.content = blocks + else + new_msg.content = convert_content(msg.content) + end + + table.insert(messages, new_msg) + ::CONTINUE:: + end + + -- Strip the internal grouping marker before emitting. + for _, m in ipairs(messages) do + m._tool_result_group = nil + end + + anthropic_body.messages = messages + + if #system_parts > 0 then + anthropic_body.system = table.concat(system_parts, "\n\n") + end + + return anthropic_body +end + + +--- Convert an Anthropic Messages response back to OpenAI Chat format. +function _M.convert_response(res_body, ctx) + if type(res_body) ~= "table" then + return nil, "response body must be a table" + end + + -- Error passthrough: convert upstream Anthropic errors to OpenAI error format + if res_body.type == "error" or res_body.error then + local err_obj = res_body.error + local err_type = "api_error" + local err_msg = "" + if type(err_obj) == "table" then + if type(err_obj.type) == "string" then + err_type = err_obj.type + end + if type(err_obj.message) == "string" then + err_msg = err_obj.message + end + elseif type(err_obj) == "string" then + err_msg = err_obj + end + return { + error = { + message = err_msg, + type = err_type, + code = err_type, + }, + } + end + + local model = ctx.var.llm_model or res_body.model + + local text_parts = {} + local reasoning_parts = {} + local tool_calls = {} + + if type(res_body.content) == "table" then + for _, block in ipairs(res_body.content) do + if type(block) == "table" then + if block.type == "text" and type(block.text) == "string" then + table.insert(text_parts, block.text) + elseif block.type == "thinking" and type(block.thinking) == "string" then + table.insert(reasoning_parts, block.thinking) + elseif block.type == "tool_use" then + table.insert(tool_calls, { + id = block.id or "", + type = "function", + ["function"] = { + name = block.name or "", + arguments = core.json.encode(block.input or {}), + }, + }) + end + end + end + end + + local message = { role = "assistant" } + message.content = #text_parts > 0 and table.concat(text_parts, "") or core.json.null + if #reasoning_parts > 0 then + message.reasoning_content = table.concat(reasoning_parts, "") + end + if #tool_calls > 0 then + message.tool_calls = tool_calls + end + + local finish_reason = anthropic_stop_reason_map[res_body.stop_reason] or "stop" + if #tool_calls > 0 and res_body.stop_reason == nil then + finish_reason = "tool_calls" + end + + -- Usage: Anthropic input/output tokens → OpenAI prompt/completion tokens. + local usage = { prompt_tokens = 0, completion_tokens = 0, total_tokens = 0 } + if type(res_body.usage) == "table" then + local u = res_body.usage + local input_tokens = u.input_tokens or 0 + local cache_read = u.cache_read_input_tokens or 0 + local cache_creation = u.cache_creation_input_tokens or 0 + -- Anthropic input_tokens excludes cached tokens; OpenAI prompt_tokens + -- is the total, so add cached tokens back in. + local prompt_tokens = input_tokens + cache_read + cache_creation + local completion_tokens = u.output_tokens or 0 + usage.prompt_tokens = prompt_tokens + usage.completion_tokens = completion_tokens + usage.total_tokens = prompt_tokens + completion_tokens + if cache_read > 0 or cache_creation > 0 then + usage.prompt_tokens_details = { + cached_tokens = cache_read, + } + end + end + + local openai_res = { + id = res_body.id, + object = "chat.completion", + model = model, + choices = { + { + index = 0, + message = message, + finish_reason = finish_reason, + }, + }, + usage = usage, + } + + return openai_res +end + + +--- Convert headers for the upstream request. +-- Transforms OpenAI-style auth/telemetry headers to Anthropic-compatible form. +function _M.convert_headers(headers) + if type(headers) ~= "table" then + return + end + + -- Convert Authorization: Bearer to x-api-key, unless the route's + -- auth config already supplied an x-api-key. + if not headers["x-api-key"] then + local authz = headers["authorization"] + if type(authz) == "string" then + local key = authz:match("^[Bb]earer%s+(.+)$") + if key and key ~= "" then + headers["x-api-key"] = key + end + end + end + headers["authorization"] = nil + + -- Anthropic requires an API version header; supply a default if absent. + if not headers["anthropic-version"] then + headers["anthropic-version"] = "2023-06-01" + end + + -- Remove OpenAI-specific and SDK telemetry headers. + local to_remove = {} + for k in pairs(headers) do + if type(k) == "string" then + if k:sub(1, 7) == "openai-" 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/anthropic-compatible.lua b/apisix/plugins/ai-providers/anthropic-compatible.lua new file mode 100644 index 000000000000..a98466f1a209 --- /dev/null +++ b/apisix/plugins/ai-providers/anthropic-compatible.lua @@ -0,0 +1,44 @@ +-- +-- 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. +-- + +--- Provider for upstreams that speak only the native Anthropic Messages API +-- (POST /v1/messages), such as Azure AI Foundry Claude. Unlike the `anthropic` +-- provider, it exposes ONLY the anthropic-messages capability, so OpenAI Chat +-- Completions clients are routed through the openai-chat → anthropic-messages +-- converter instead of being passed through unchanged. The vendor-neutral +-- counterpart of `openai-compatible`; the upstream is supplied via +-- override.endpoint. + +local function rewrite_messages_request_body(body, override, force) + if override.max_tokens then + if force or body.max_tokens == nil then + body.max_tokens = override.max_tokens + end + end +end + +return require("apisix.plugins.ai-providers.base").new( + { + port = 443, + capabilities = { + ["anthropic-messages"] = { + path = "/v1/messages", + rewrite_request_body = rewrite_messages_request_body, + }, + }, + } +) diff --git a/apisix/plugins/ai-providers/schema.lua b/apisix/plugins/ai-providers/schema.lua index d8be7b6ebb0d..11cc2d6f47ba 100644 --- a/apisix/plugins/ai-providers/schema.lua +++ b/apisix/plugins/ai-providers/schema.lua @@ -21,7 +21,7 @@ local _M = {} _M.providers = { "openai", "deepseek", "aimlapi", "anthropic", "openai-compatible", "azure-openai", "openrouter", - "gemini", "vertex-ai", "bedrock", + "gemini", "vertex-ai", "bedrock", "anthropic-compatible", } return _M diff --git a/docs/en/latest/plugins/ai-proxy-multi.md b/docs/en/latest/plugins/ai-proxy-multi.md index 0368c5467404..089993c288c3 100644 --- a/docs/en/latest/plugins/ai-proxy-multi.md +++ b/docs/en/latest/plugins/ai-proxy-multi.md @@ -76,7 +76,7 @@ When an instance's `provider` is set to `bedrock`, the Plugin expects requests i | balancer.key | string | False | | | Used when `type` is `chash`. When `hash_on` is set to `header` or `cookie`, `key` is required. When `hash_on` is set to `consumer`, `key` is not required as the consumer name will be used as the key automatically. | | instances | array[object] | True | | | LLM instance configurations. | | instances.name | string | True | | | Name of the LLM service instance. | -| instances.provider | string | True | | [openai, deepseek, azure-openai, aimlapi, anthropic, openrouter, gemini, vertex-ai, bedrock, openai-compatible] | LLM service provider. When set to `openai`, the Plugin will proxy the request to `api.openai.com`. When set to `deepseek`, the Plugin will proxy the request to `api.deepseek.com`. When set to `aimlapi`, the Plugin uses the OpenAI-compatible driver and proxies the request to `api.aimlapi.com` by default. When set to `anthropic`, the Plugin will proxy the request to `api.anthropic.com` by default. When set to `openrouter`, the Plugin uses the OpenAI-compatible driver and proxies the request to `openrouter.ai` by default. When set to `gemini`, the Plugin uses the OpenAI-compatible driver and proxies the request to `generativelanguage.googleapis.com` by default. When set to `vertex-ai`, the Plugin will proxy the request to `aiplatform.googleapis.com` by default and requires `provider_conf` or `override`. When set to `bedrock`, the Plugin proxies the request to Amazon Bedrock's Converse API at `bedrock-runtime.{region}.amazonaws.com` and signs the request with AWS SigV4. Requires `provider_conf.region` and `auth.aws`. When set to `openai-compatible`, the Plugin will proxy the request to the custom endpoint configured in `override`. | +| instances.provider | string | True | | [openai, deepseek, azure-openai, aimlapi, anthropic, openrouter, gemini, vertex-ai, bedrock, openai-compatible, anthropic-compatible] | LLM service provider. When set to `openai`, the Plugin will proxy the request to `api.openai.com`. When set to `deepseek`, the Plugin will proxy the request to `api.deepseek.com`. When set to `aimlapi`, the Plugin uses the OpenAI-compatible driver and proxies the request to `api.aimlapi.com` by default. When set to `anthropic`, the Plugin will proxy the request to `api.anthropic.com` by default. When set to `openrouter`, the Plugin uses the OpenAI-compatible driver and proxies the request to `openrouter.ai` by default. When set to `gemini`, the Plugin uses the OpenAI-compatible driver and proxies the request to `generativelanguage.googleapis.com` by default. When set to `vertex-ai`, the Plugin will proxy the request to `aiplatform.googleapis.com` by default and requires `provider_conf` or `override`. When set to `bedrock`, the Plugin proxies the request to Amazon Bedrock's Converse API at `bedrock-runtime.{region}.amazonaws.com` and signs the request with AWS SigV4. Requires `provider_conf.region` and `auth.aws`. When set to `openai-compatible`, the Plugin will proxy the request to the custom endpoint configured in `override`. When set to `anthropic-compatible`, the Plugin proxies the request to a native Anthropic Messages API (`/v1/messages`) endpoint configured in `override` (such as Azure AI Foundry Claude), transparently converting OpenAI Chat Completions requests and responses to and from the Anthropic Messages format (non-streaming only at present). | | instances.provider_conf | object | False | | | Configuration for the specific provider. Required when `provider` is set to `vertex-ai` and `override` is not configured. Required when `provider` is set to `bedrock`. | | instances.provider_conf.project_id | string | True | | | Google Cloud Project ID. | | instances.provider_conf.region | string | True (depending on provider) | | minLength = 1 (for Bedrock) | When `provider` is `vertex-ai`, this is the Google Cloud Region. When `provider` is `bedrock`, this is the AWS region used to construct the Bedrock endpoint and to sign the request with SigV4 (required, must be non-empty). | diff --git a/docs/en/latest/plugins/ai-proxy.md b/docs/en/latest/plugins/ai-proxy.md index 642652e81692..87e9946d5ed0 100644 --- a/docs/en/latest/plugins/ai-proxy.md +++ b/docs/en/latest/plugins/ai-proxy.md @@ -67,7 +67,7 @@ When `provider` is set to `bedrock`, the Plugin expects requests in the [Bedrock | Name | Type | Required | Default | Valid values | Description | |--------------------|--------|----------|---------|------------------------------------------|-------------| -| provider | string | True | | [openai, deepseek, azure-openai, aimlapi, anthropic, openrouter, gemini, vertex-ai, bedrock, openai-compatible] | LLM service provider. When set to `openai`, the Plugin will proxy the request to `https://api.openai.com/chat/completions`. When set to `deepseek`, the Plugin will proxy the request to `https://api.deepseek.com/chat/completions`. When set to `aimlapi`, the Plugin uses the OpenAI-compatible driver and proxies the request to `https://api.aimlapi.com/v1/chat/completions` by default. When set to `anthropic`, the Plugin will proxy the request to `https://api.anthropic.com/v1/chat/completions` by default. When set to `openrouter`, the Plugin uses the OpenAI-compatible driver and proxies the request to `https://openrouter.ai/api/v1/chat/completions` by default. When set to `gemini`, the Plugin uses the OpenAI-compatible driver and proxies the request to `https://generativelanguage.googleapis.com/v1beta/openai/chat/completions` by default. When set to `vertex-ai`, the Plugin will proxy the request to `https://aiplatform.googleapis.com` by default and requires `provider_conf` or `override`. When set to `bedrock`, the Plugin will proxy the request to the AWS Bedrock Converse API (`https://bedrock-runtime..amazonaws.com`) and signs the request with AWS SigV4. When set to `openai-compatible`, the Plugin will proxy the request to the custom endpoint configured in `override`. | +| provider | string | True | | [openai, deepseek, azure-openai, aimlapi, anthropic, openrouter, gemini, vertex-ai, bedrock, openai-compatible, anthropic-compatible] | LLM service provider. When set to `openai`, the Plugin will proxy the request to `https://api.openai.com/chat/completions`. When set to `deepseek`, the Plugin will proxy the request to `https://api.deepseek.com/chat/completions`. When set to `aimlapi`, the Plugin uses the OpenAI-compatible driver and proxies the request to `https://api.aimlapi.com/v1/chat/completions` by default. When set to `anthropic`, the Plugin will proxy the request to `https://api.anthropic.com/v1/chat/completions` by default. When set to `openrouter`, the Plugin uses the OpenAI-compatible driver and proxies the request to `https://openrouter.ai/api/v1/chat/completions` by default. When set to `gemini`, the Plugin uses the OpenAI-compatible driver and proxies the request to `https://generativelanguage.googleapis.com/v1beta/openai/chat/completions` by default. When set to `vertex-ai`, the Plugin will proxy the request to `https://aiplatform.googleapis.com` by default and requires `provider_conf` or `override`. When set to `bedrock`, the Plugin will proxy the request to the AWS Bedrock Converse API (`https://bedrock-runtime..amazonaws.com`) and signs the request with AWS SigV4. When set to `openai-compatible`, the Plugin will proxy the request to the custom endpoint configured in `override`. When set to `anthropic-compatible`, the Plugin proxies the request to a native Anthropic Messages API (`/v1/messages`) endpoint configured in `override` (such as Azure AI Foundry Claude), transparently converting OpenAI Chat Completions requests and responses to and from the Anthropic Messages format (non-streaming only at present). | | provider_conf | object | False | | | Configuration for the specific provider. Required when `provider` is set to `vertex-ai` and `override` is not configured. Required when `provider` is set to `bedrock`. | | provider_conf.project_id | string | True | | | Google Cloud Project ID. | | provider_conf.region | string | True (depending on provider) | | minLength = 1 (for Bedrock) | When `provider` is `vertex-ai`, this is the Google Cloud Region. When `provider` is `bedrock`, this is the AWS region used to construct the Bedrock endpoint and to sign the request with SigV4 (required, must be non-empty). | @@ -120,6 +120,7 @@ The table below shows, for each `provider` and target API endpoint, the upstream | `gemini` | `max_completion_tokens` | — | — | | `vertex-ai` | `max_completion_tokens` | — | — | | `anthropic` | `max_tokens` | — | `max_tokens` | +| `anthropic-compatible` | — | — | `max_tokens` | ¹ When `provider` is `openai` and the target is the Chat Completions endpoint, APISIX always rewrites to `max_completion_tokens` and removes any `max_tokens` field from the request body — `max_tokens` has been deprecated in favor of `max_completion_tokens` by OpenAI. diff --git a/docs/zh/latest/plugins/ai-proxy-multi.md b/docs/zh/latest/plugins/ai-proxy-multi.md index 1a6bd5ee6269..e2fa9a712305 100644 --- a/docs/zh/latest/plugins/ai-proxy-multi.md +++ b/docs/zh/latest/plugins/ai-proxy-multi.md @@ -76,7 +76,7 @@ import TabItem from '@theme/TabItem'; | balancer.key | string | 否 | | | 当 `type` 为 `chash` 时使用。当 `hash_on` 设置为 `header` 或 `cookie` 时,需要 `key`。当 `hash_on` 设置为 `consumer` 时,不需要 `key`,因为消费者名称将自动用作键。 | | instances | array[object] | 是 | | | LLM 实例配置。 | | instances.name | string | 是 | | | LLM 服务实例的名称。 | -| instances.provider | string | 是 | | [openai, deepseek, azure-openai, aimlapi, anthropic, openrouter, gemini, vertex-ai, bedrock, openai-compatible] | LLM 服务提供商。设置为 `openai` 时,插件将代理请求到 `api.openai.com`。设置为 `deepseek` 时,插件将代理请求到 `api.deepseek.com`。设置为 `aimlapi` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `api.aimlapi.com`。设置为 `anthropic` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `api.anthropic.com`。设置为 `openrouter` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `openrouter.ai`。设置为 `gemini` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `generativelanguage.googleapis.com`。设置为 `vertex-ai` 时,插件默认将请求代理到 `aiplatform.googleapis.com`,且需要配置 `provider_conf` 或 `override`。设置为 `bedrock` 时,插件将代理请求到 AWS Bedrock Converse API(`bedrock-runtime..amazonaws.com`),并使用 AWS SigV4 对请求进行签名。设置为 `openai-compatible` 时,插件将代理请求到在 `override` 中配置的自定义端点。 | +| instances.provider | string | 是 | | [openai, deepseek, azure-openai, aimlapi, anthropic, openrouter, gemini, vertex-ai, bedrock, openai-compatible, anthropic-compatible] | LLM 服务提供商。设置为 `openai` 时,插件将代理请求到 `api.openai.com`。设置为 `deepseek` 时,插件将代理请求到 `api.deepseek.com`。设置为 `aimlapi` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `api.aimlapi.com`。设置为 `anthropic` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `api.anthropic.com`。设置为 `openrouter` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `openrouter.ai`。设置为 `gemini` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `generativelanguage.googleapis.com`。设置为 `vertex-ai` 时,插件默认将请求代理到 `aiplatform.googleapis.com`,且需要配置 `provider_conf` 或 `override`。设置为 `bedrock` 时,插件将代理请求到 AWS Bedrock Converse API(`bedrock-runtime..amazonaws.com`),并使用 AWS SigV4 对请求进行签名。设置为 `openai-compatible` 时,插件将代理请求到在 `override` 中配置的自定义端点。设置为 `anthropic-compatible` 时,插件将请求代理到在 `override` 中配置的原生 Anthropic Messages API(`/v1/messages`)端点(例如 Azure AI Foundry Claude),并在 OpenAI Chat Completions 格式与 Anthropic Messages 格式之间透明地转换请求和响应(目前仅支持非流式)。 | | instances.provider_conf | object | 否 | | | 特定提供商的配置。当 `provider` 设置为 `vertex-ai` 且未配置 `override` 时必填。当 `provider` 设置为 `bedrock` 时必填。 | | instances.provider_conf.project_id | string | 是 | | | Google Cloud 项目 ID。 | | instances.provider_conf.region | string | 视提供商而定 | | minLength = 1(Bedrock 时) | 当 `provider` 为 `vertex-ai` 时,此项为 Google Cloud 区域。当 `provider` 为 `bedrock` 时,此项为用于构造 Bedrock 端点并使用 SigV4 对请求进行签名的 AWS 区域(必填,不能为空)。 | diff --git a/docs/zh/latest/plugins/ai-proxy.md b/docs/zh/latest/plugins/ai-proxy.md index 2bbe940dd9be..68389b826db4 100644 --- a/docs/zh/latest/plugins/ai-proxy.md +++ b/docs/zh/latest/plugins/ai-proxy.md @@ -67,7 +67,7 @@ import TabItem from '@theme/TabItem'; | 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 | |--------------------|--------|----------|---------|------------------------------------------|-------------| -| provider | string | 是 | | [openai, deepseek, azure-openai, aimlapi, anthropic, openrouter, gemini, vertex-ai, bedrock, openai-compatible] | LLM 服务提供商。当设置为 `openai` 时,插件将代理请求到 `https://api.openai.com/chat/completions`。当设置为 `deepseek` 时,插件将代理请求到 `https://api.deepseek.com/chat/completions`。当设置为 `aimlapi` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `https://api.aimlapi.com/v1/chat/completions`。当设置为 `anthropic` 时,插件将代理请求到 `https://api.anthropic.com/v1/chat/completions`。当设置为 `openrouter` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `https://openrouter.ai/api/v1/chat/completions`。当设置为 `gemini` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `https://generativelanguage.googleapis.com/v1beta/openai/chat/completions`。当设置为 `vertex-ai` 时,插件默认将请求代理到 `https://aiplatform.googleapis.com`,需要配置 `provider_conf` 或 `override`。当设置为 `bedrock` 时,插件将代理请求到 AWS Bedrock Converse API(`https://bedrock-runtime..amazonaws.com`),并使用 AWS SigV4 对请求进行签名。当设置为 `openai-compatible` 时,插件将代理请求到在 `override` 中配置的自定义端点。当设置为 `azure-openai` 时,插件同样将请求代理到 `override` 中配置的自定义端点,并会额外移除用户请求中的 `model` 参数。 | +| provider | string | 是 | | [openai, deepseek, azure-openai, aimlapi, anthropic, openrouter, gemini, vertex-ai, bedrock, openai-compatible, anthropic-compatible] | LLM 服务提供商。当设置为 `openai` 时,插件将代理请求到 `https://api.openai.com/chat/completions`。当设置为 `deepseek` 时,插件将代理请求到 `https://api.deepseek.com/chat/completions`。当设置为 `aimlapi` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `https://api.aimlapi.com/v1/chat/completions`。当设置为 `anthropic` 时,插件将代理请求到 `https://api.anthropic.com/v1/chat/completions`。当设置为 `openrouter` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `https://openrouter.ai/api/v1/chat/completions`。当设置为 `gemini` 时,插件使用 OpenAI 兼容驱动程序,默认将请求代理到 `https://generativelanguage.googleapis.com/v1beta/openai/chat/completions`。当设置为 `vertex-ai` 时,插件默认将请求代理到 `https://aiplatform.googleapis.com`,需要配置 `provider_conf` 或 `override`。当设置为 `bedrock` 时,插件将代理请求到 AWS Bedrock Converse API(`https://bedrock-runtime..amazonaws.com`),并使用 AWS SigV4 对请求进行签名。当设置为 `openai-compatible` 时,插件将代理请求到在 `override` 中配置的自定义端点。当设置为 `azure-openai` 时,插件同样将请求代理到 `override` 中配置的自定义端点,并会额外移除用户请求中的 `model` 参数。当设置为 `anthropic-compatible` 时,插件将请求代理到在 `override` 中配置的原生 Anthropic Messages API(`/v1/messages`)端点(例如 Azure AI Foundry Claude),并在 OpenAI Chat Completions 格式与 Anthropic Messages 格式之间透明地转换请求和响应(目前仅支持非流式)。 | | provider_conf | object | 否 | | | 特定提供商的配置。当 `provider` 设置为 `vertex-ai` 且未配置 `override` 时必填。当 `provider` 设置为 `bedrock` 时必填。 | | provider_conf.project_id | string | 是 | | | Google Cloud 项目 ID。 | | provider_conf.region | string | 视提供商而定 | | minLength = 1(Bedrock 时) | 当 `provider` 为 `vertex-ai` 时,此项为 Google Cloud 区域。当 `provider` 为 `bedrock` 时,此项为用于构造 Bedrock 端点并使用 SigV4 对请求进行签名的 AWS 区域(必填,不能为空)。 | @@ -119,6 +119,7 @@ import TabItem from '@theme/TabItem'; | `gemini` | `max_completion_tokens` | — | — | | `vertex-ai` | `max_completion_tokens` | — | — | | `anthropic` | `max_tokens` | — | `max_tokens` | +| `anthropic-compatible` | — | — | `max_tokens` | ¹ 当 `provider` 为 `openai` 且目标为 Chat Completions 端点时,APISIX 始终改写为 `max_completion_tokens`,并删除请求体中已有的 `max_tokens` 字段——OpenAI 已弃用 `max_tokens`,改用 `max_completion_tokens`。 diff --git a/t/fixtures/anthropic/messages-tool-use.json b/t/fixtures/anthropic/messages-tool-use.json new file mode 100644 index 000000000000..9f43f83cc3c4 --- /dev/null +++ b/t/fixtures/anthropic/messages-tool-use.json @@ -0,0 +1,24 @@ +{ + "id": "msg_tool123", + "type": "message", + "role": "assistant", + "model": "claude-3-5-sonnet-20241022", + "content": [ + { + "type": "text", + "text": "Let me check the weather." + }, + { + "type": "tool_use", + "id": "toolu_abc123", + "name": "get_weather", + "input": { "location": "San Francisco" } + } + ], + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 15, + "output_tokens": 25 + } +} diff --git a/t/lib/server.lua b/t/lib/server.lua index 88b8e603efdb..72877387bd05 100644 --- a/t/lib/server.lua +++ b/t/lib/server.lua @@ -872,6 +872,54 @@ function _M.v1_chat_completions() end function _M.v1_messages() + local json = require("cjson.safe") + local test_type = ngx.req.get_headers()["test-type"] + if test_type then + ngx.req.read_body() + local body = json.decode(ngx.req.get_body_data() or "") + + if test_type == "anthropic-system" then + -- The OpenAI system-role message must become a top-level `system` + -- field, and no system role may remain inside messages[]. + if not body or type(body.system) ~= "string" then + ngx.status = 400 + ngx.say([[{"error":"system not converted to top-level field"}]]) + return + end + for _, msg in ipairs(body.messages or {}) do + if msg.role == "system" then + ngx.status = 400 + ngx.say([[{"error":"system role still present in messages"}]]) + return + end + end + elseif test_type == "anthropic-tools" then + local tool = body and body.tools and body.tools[1] + if not tool or tool.name ~= "get_weather" or not tool.input_schema then + ngx.status = 400 + ngx.say([[{"error":"tool not converted to anthropic format"}]]) + return + end + elseif test_type == "anthropic-tool-result" then + -- OpenAI tool-role messages must become user-role tool_result blocks. + local found + for _, msg in ipairs(body and body.messages or {}) do + if msg.role == "user" and type(msg.content) == "table" then + for _, block in ipairs(msg.content) do + if type(block) == "table" and block.type == "tool_result" then + found = true + end + end + end + end + if not found then + ngx.status = 400 + ngx.say([[{"error":"tool result not converted to tool_result block"}]]) + return + end + end + end + ai_fixture_dispatch() end diff --git a/t/plugin/ai-proxy-openai-to-anthropic.t b/t/plugin/ai-proxy-openai-to-anthropic.t new file mode 100644 index 000000000000..a153de48dee3 --- /dev/null +++ b/t/plugin/ai-proxy-openai-to-anthropic.t @@ -0,0 +1,421 @@ +# +# 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"); + } +}); + +run_tests(); + +__DATA__ + +=== TEST 1: convert_request – system role becomes top-level system, max_tokens defaulted +--- config + location /t { + content_by_lua_block { + local c = require("apisix.plugins.ai-protocols.converters" .. + ".openai-chat-to-anthropic-messages") + local body = { + model = "claude-3-5-sonnet", + messages = { + { role = "system", content = "You are a mathematician" }, + { role = "user", content = "What is 1+1?" }, + }, + } + local out, err = c.convert_request(body, { var = {} }) + assert(out, err) + assert(out.system == "You are a mathematician", "system: " .. tostring(out.system)) + assert(#out.messages == 1, "messages count: " .. #out.messages) + assert(out.messages[1].role == "user") + assert(out.max_tokens == 4096, "max_tokens: " .. tostring(out.max_tokens)) + ngx.say("ok") + } + } +--- response_body +ok + + + +=== TEST 2: convert_request – max_completion_tokens maps to max_tokens +--- config + location /t { + content_by_lua_block { + local c = require("apisix.plugins.ai-protocols.converters" .. + ".openai-chat-to-anthropic-messages") + local out = c.convert_request({ + messages = {{ role = "user", content = "hi" }}, + max_completion_tokens = 256, + }, { var = {} }) + assert(out.max_tokens == 256, "max_tokens: " .. tostring(out.max_tokens)) + ngx.say("ok") + } + } +--- response_body +ok + + + +=== TEST 3: convert_request – streaming is rejected +--- config + location /t { + content_by_lua_block { + local c = require("apisix.plugins.ai-protocols.converters" .. + ".openai-chat-to-anthropic-messages") + local out, err = c.convert_request({ + messages = {{ role = "user", content = "hi" }}, + stream = true, + }, { var = {} }) + assert(out == nil, "expected nil") + ngx.say(err) + } + } +--- response_body +streaming is not yet supported for openai-chat to anthropic-messages conversion + + + +=== TEST 4: convert_request – missing messages returns error +--- config + location /t { + content_by_lua_block { + local c = require("apisix.plugins.ai-protocols.converters" .. + ".openai-chat-to-anthropic-messages") + local out, err = c.convert_request({ model = "x" }, { var = {} }) + assert(out == nil, "expected nil") + ngx.say(err) + } + } +--- response_body +missing messages + + + +=== TEST 5: convert_request – tools and tool_choice converted to Anthropic format +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local c = require("apisix.plugins.ai-protocols.converters" .. + ".openai-chat-to-anthropic-messages") + local out = c.convert_request({ + messages = {{ role = "user", content = "weather?" }}, + tools = {{ + type = "function", + ["function"] = { + name = "get_weather", + description = "Get weather", + parameters = { type = "object", properties = {} }, + }, + }}, + tool_choice = "required", + }, { var = {} }) + assert(out.tools[1].name == "get_weather", "tool name") + assert(out.tools[1].input_schema, "input_schema present") + assert(out.tools[1]["function"] == nil, "no function wrapper") + assert(out.tool_choice.type == "any", "tool_choice: " .. core.json.encode(out.tool_choice)) + ngx.say("ok") + } + } +--- response_body +ok + + + +=== TEST 6: convert_request – assistant tool_calls and tool result conversion +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local c = require("apisix.plugins.ai-protocols.converters" .. + ".openai-chat-to-anthropic-messages") + local out = c.convert_request({ + messages = { + { role = "user", content = "weather in SF?" }, + { + role = "assistant", + tool_calls = {{ + id = "call_1", + type = "function", + ["function"] = { + name = "get_weather", + arguments = '{"location":"SF"}', + }, + }}, + }, + { role = "tool", tool_call_id = "call_1", content = "sunny" }, + }, + }, { var = {} }) + -- assistant message becomes content array with a tool_use block + local asst = out.messages[2] + assert(asst.role == "assistant", "asst role") + assert(asst.content[1].type == "tool_use", "tool_use block") + assert(asst.content[1].input.location == "SF", "decoded input") + -- tool message becomes user message with a tool_result block + local tool_msg = out.messages[3] + assert(tool_msg.role == "user", "tool result role") + assert(tool_msg.content[1].type == "tool_result", "tool_result block") + assert(tool_msg.content[1].tool_use_id == "call_1", "tool_use_id") + -- internal grouping marker must not leak into the body + assert(tool_msg._tool_result_group == nil, "marker leaked") + ngx.say("ok") + } + } +--- response_body +ok + + + +=== TEST 7: convert_response – Anthropic message converted to OpenAI chat completion +--- config + location /t { + content_by_lua_block { + local c = require("apisix.plugins.ai-protocols.converters" .. + ".openai-chat-to-anthropic-messages") + local out = c.convert_response({ + id = "msg_1", + type = "message", + role = "assistant", + model = "claude-3-5-sonnet", + content = {{ type = "text", text = "Hello!" }}, + stop_reason = "end_turn", + usage = { input_tokens = 10, output_tokens = 5 }, + }, { var = { llm_model = "claude-3-5-sonnet" } }) + assert(out.object == "chat.completion", "object") + assert(out.choices[1].message.content == "Hello!", "content") + assert(out.choices[1].finish_reason == "stop", "finish_reason") + assert(out.usage.prompt_tokens == 10, "prompt_tokens") + assert(out.usage.completion_tokens == 5, "completion_tokens") + assert(out.usage.total_tokens == 15, "total_tokens") + ngx.say("ok") + } + } +--- response_body +ok + + + +=== TEST 8: convert_response – tool_use becomes OpenAI tool_calls +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local c = require("apisix.plugins.ai-protocols.converters" .. + ".openai-chat-to-anthropic-messages") + local out = c.convert_response({ + id = "msg_2", + content = {{ + type = "tool_use", + id = "toolu_1", + name = "get_weather", + input = { location = "SF" }, + }}, + stop_reason = "tool_use", + usage = { input_tokens = 8, output_tokens = 12 }, + }, { var = {} }) + local tc = out.choices[1].message.tool_calls[1] + assert(tc.id == "toolu_1", "id") + assert(tc.type == "function", "type") + assert(tc["function"].name == "get_weather", "name") + local args = core.json.decode(tc["function"].arguments) + assert(args.location == "SF", "arguments") + assert(out.choices[1].finish_reason == "tool_calls", "finish_reason") + ngx.say("ok") + } + } +--- response_body +ok + + + +=== TEST 9: convert_response – Anthropic error mapped to OpenAI error +--- config + location /t { + content_by_lua_block { + local c = require("apisix.plugins.ai-protocols.converters" .. + ".openai-chat-to-anthropic-messages") + local out = c.convert_response({ + type = "error", + error = { type = "invalid_request_error", message = "bad" }, + }, { var = {} }) + assert(out.error, "error present") + assert(out.error.message == "bad", "message") + assert(out.error.type == "invalid_request_error", "type") + ngx.say("ok") + } + } +--- response_body +ok + + + +=== TEST 10: convert_headers – Authorization Bearer becomes x-api-key +--- config + location /t { + content_by_lua_block { + local c = require("apisix.plugins.ai-protocols.converters" .. + ".openai-chat-to-anthropic-messages") + local headers = { + ["authorization"] = "Bearer sk-abc", + ["x-stainless-lang"] = "js", + ["openai-organization"] = "org", + ["content-type"] = "application/json", + } + c.convert_headers(headers) + assert(headers["x-api-key"] == "sk-abc", "x-api-key") + assert(headers["authorization"] == nil, "authorization stripped") + assert(headers["anthropic-version"] == "2023-06-01", "anthropic-version") + assert(headers["x-stainless-lang"] == nil, "x-stainless stripped") + assert(headers["openai-organization"] == nil, "openai- stripped") + ngx.say("ok") + } + } +--- response_body +ok + + + +=== TEST 11: convert_headers – existing x-api-key from route auth is preserved +--- config + location /t { + content_by_lua_block { + local c = require("apisix.plugins.ai-protocols.converters" .. + ".openai-chat-to-anthropic-messages") + local headers = { + ["authorization"] = "Bearer client-key", + ["x-api-key"] = "route-key", + } + c.convert_headers(headers) + assert(headers["x-api-key"] == "route-key", "route key preserved") + ngx.say("ok") + } + } +--- response_body +ok + + + +=== TEST 12: Set up route – anthropic-compatible provider, OpenAI client body +--- 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": { + "provider": "anthropic-compatible", + "auth": { + "header": { + "x-api-key": "test-key" + } + }, + "override": { + "endpoint": "http://127.0.0.1:1980" + } + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 13: OpenAI request to native Anthropic upstream – response converted to OpenAI shape +--- request +POST /anything +{ "model": "claude-3-5-sonnet-20241022", "messages": [ { "role": "system", "content": "You are a mathematician" }, { "role": "user", "content": "What is 1+1?" } ] } +--- more_headers +X-AI-Fixture: anthropic/messages-basic.json +--- error_code: 200 +--- response_body eval +qr/(?=.*"object":"chat\.completion")(?=.*"content":"Hello! How can I help you\?")(?=.*"finish_reason":"stop")/s + + + +=== TEST 14: Converted request reaches upstream as native Anthropic format (system top-level) +--- request +POST /anything +{ "model": "claude-3-5-sonnet-20241022", "messages": [ { "role": "system", "content": "You are a mathematician" }, { "role": "user", "content": "What is 1+1?" } ] } +--- more_headers +X-AI-Fixture: anthropic/messages-basic.json +test-type: anthropic-system +--- error_code: 200 +--- response_body eval +qr/"content":"Hello! How can I help you\?"/ + + + +=== TEST 15: Tool definitions are converted to native Anthropic format on the wire +--- request +POST /anything +{ "model": "claude-3-5-sonnet-20241022", "messages": [ { "role": "user", "content": "weather?" } ], "tools": [ { "type": "function", "function": { "name": "get_weather", "description": "Get weather", "parameters": { "type": "object" } } } ] } +--- more_headers +X-AI-Fixture: anthropic/messages-tool-use.json +test-type: anthropic-tools +--- error_code: 200 +--- response_body eval +qr/(?=.*"tool_calls")(?=.*"name":"get_weather")/s + + + +=== TEST 16: Anthropic tool_use response surfaces as OpenAI tool_calls +--- request +POST /anything +{ "model": "claude-3-5-sonnet-20241022", "messages": [ { "role": "user", "content": "weather in SF?" } ] } +--- more_headers +X-AI-Fixture: anthropic/messages-tool-use.json +--- error_code: 200 +--- response_body eval +qr/(?=.*"tool_calls")(?=.*"id":"toolu_abc123")(?=.*"name":"get_weather")(?=.*"finish_reason":"tool_calls")/s + + + +=== TEST 17: Streaming request is rejected with 400 +--- request +POST /anything +{ "model": "claude-3-5-sonnet-20241022", "messages": [ { "role": "user", "content": "hi" } ], "stream": true } +--- more_headers +X-AI-Fixture: anthropic/messages-basic.json +--- error_code: 400 +--- response_body eval +qr/streaming is not yet supported/