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/