-
Notifications
You must be signed in to change notification settings - Fork 2.8k
feat(ai-proxy): add native support for Anthropic provider #12924
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
Hi @uniprewan, could you explain in detail the background of your PR submission? Is the existing anthropic provider not working? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR attempts to add native Anthropic protocol support to the ai-proxy plugin by introducing a common base driver pattern. However, the implementation has critical flaws that prevent it from working.
Changes:
- Introduced a new base driver (
ai-driver-base.lua) intended to share common logic across AI providers - Refactored
openai-base.luato use the new base driver pattern - Implemented a new
anthropic.luadriver with protocol translation for Anthropic's Messages API - Moved Anthropic from the middle to the end of the provider list in schema.lua
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 29 comments.
| File | Description |
|---|---|
| apisix/plugins/ai-drivers/schema.lua | Reordered provider list, moving Anthropic to the end |
| apisix/plugins/ai-drivers/ai-driver-base.lua | New base driver with critical implementation flaws |
| apisix/plugins/ai-drivers/openai-base.lua | Refactored to use base driver but breaks existing functionality |
| apisix/plugins/ai-drivers/anthropic.lua | New Anthropic driver with protocol translation issues |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| local res, err = httpc:request({ | ||
| method = "POST", | ||
| url = upstream_url, | ||
| headers = headers, | ||
| body = core.json.encode(request_table ), | ||
| ssl_verify = false, -- 生产环境应为 true | ||
| timeout = conf.timeout or 60000, |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The timeout is only used in the request() call but not in the HTTP client setup. The original implementation calls httpc:set_timeout(conf.timeout) before making the connection. The resty.http library's request() method timeout parameter may not work as expected without proper client initialization.
| local res, err = httpc:request({ | |
| method = "POST", | |
| url = upstream_url, | |
| headers = headers, | |
| body = core.json.encode(request_table ), | |
| ssl_verify = false, -- 生产环境应为 true | |
| timeout = conf.timeout or 60000, | |
| httpc:set_timeout(conf.timeout or 60000) | |
| local res, err = httpc:request({ | |
| method = "POST", | |
| url = upstream_url, | |
| headers = headers, | |
| body = core.json.encode(request_table ), | |
| ssl_verify = false, -- 生产环境应为 true |
| -- 4. 处理响应 | ||
| local is_stream = request_table.stream | ||
| local content_type = res.headers["Content-Type"] | ||
|
|
||
| if is_stream and core.string.find(content_type, "text/event-stream") then | ||
| -- 流式响应处理 | ||
| return self:handle_stream_response(ctx, res, conf) | ||
| else | ||
| -- 非流式响应处理 | ||
| return self:handle_non_stream_response(ctx, res, conf) | ||
| end |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The request method doesn't handle retry scenarios for 429 (rate limit) or 5xx errors. The original openai-base.lua implementation returns the status code for these errors to allow the ai-proxy base.lua to retry with another instance. Without this, the retry mechanism in ai-proxy-multi won't work properly.
| function _M.request(self, ctx, conf, request_table, extra_opts) | ||
| -- 1. 协议转换(如果驱动提供了 transform_request) | ||
| if self.transform_request then | ||
| request_table = self.transform_request(request_table) | ||
| end | ||
|
|
||
| -- 2. 构造上游请求 | ||
| local upstream_url = self.scheme .. "://" .. self.host .. ":" .. self.port .. self.path | ||
| local headers = { | ||
| ["Host"] = self.host, | ||
| ["Content-Type"] = "application/json", | ||
| -- 认证头由具体驱动在 transform_request 中添加或在 conf 中获取 | ||
| } | ||
|
|
||
| -- 3. 发送请求 | ||
| local httpc = http.new( ) | ||
| local res, err = httpc:request({ | ||
| method = "POST", | ||
| url = upstream_url, | ||
| headers = headers, | ||
| body = core.json.encode(request_table ), | ||
| ssl_verify = false, -- 生产环境应为 true | ||
| timeout = conf.timeout or 60000, | ||
| }) | ||
|
|
||
| if not res then | ||
| core.log.error("failed to send request to LLM server: ", err) | ||
| return self:handle_error(err) | ||
| end | ||
|
|
||
| -- 4. 处理响应 | ||
| local is_stream = request_table.stream | ||
| local content_type = res.headers["Content-Type"] | ||
|
|
||
| if is_stream and core.string.find(content_type, "text/event-stream") then | ||
| -- 流式响应处理 | ||
| return self:handle_stream_response(ctx, res, conf) | ||
| else | ||
| -- 非流式响应处理 | ||
| return self:handle_non_stream_response(ctx, res, conf) | ||
| end | ||
| end | ||
|
|
||
| -- 处理非流式响应 | ||
| function _M.handle_non_stream_response(self, ctx, res, conf) | ||
| local raw_res_body = res:read_body() | ||
| if not raw_res_body then | ||
| core.log.warn("failed to read response body: ", res.err) | ||
| return self:handle_error(res.err) | ||
| end | ||
|
|
||
| -- 协议转换(如果驱动提供了 transform_response) | ||
| if self.transform_response then | ||
| raw_res_body = self.transform_response(raw_res_body) | ||
| end | ||
|
|
||
| -- 设置响应头和状态码 | ||
| core.response.set_header(ctx, "Content-Type", "application/json") | ||
| core.response.set_status(ctx, res.status) | ||
| core.response.set_body(ctx, raw_res_body) | ||
| core.response.send_response(ctx) | ||
| end | ||
|
|
||
| -- 处理流式响应 | ||
| function _M.handle_stream_response(self, ctx, res, conf) | ||
| core.response.set_header(ctx, "Content-Type", "text/event-stream") | ||
| core.response.set_status(ctx, res.status) | ||
| core.response.send_http_header(ctx ) | ||
|
|
||
| local body_reader = res.body_reader | ||
| local chunk | ||
| while true do | ||
| chunk, err = body_reader() | ||
| if not chunk then | ||
| break | ||
| end | ||
|
|
||
| -- 协议转换(如果驱动提供了 process_sse_chunk) | ||
| if self.process_sse_chunk then | ||
| chunk = self.process_sse_chunk(chunk) | ||
| end | ||
|
|
||
| core.response.write(ctx, chunk) | ||
| end | ||
|
|
||
| if err then | ||
| core.log.error("failed to read stream body: ", err) | ||
| end | ||
|
|
||
| core.response.close(ctx) | ||
| end |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ai-driver-base.lua doesn't set any of the context variables that are used for metrics and logging, such as ctx.llm_request_start_time, ctx.var.llm_time_to_first_token, ctx.var.apisix_upstream_response_time, ctx.ai_token_usage, etc. These are essential for the logging and prometheus metrics functionality. The original implementation properly tracks all these metrics.
|
|
||
| local core = require("apisix.core") | ||
| local plugin = require("apisix.plugin") | ||
| local http = require("resty.http" ) |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is an extra space before the closing parenthesis in the require statement. This should be 'require("resty.http")' without the extra space.
| local http = require("resty.http" ) | |
| local http = require("resty.http") |
| local http = require("resty.http" ) | ||
| local url = require("socket.url") | ||
| -- 假设 sse 模块存在于 apisix.plugins.ai-drivers.sse | ||
| local sse = require("apisix.plugins.ai-drivers.sse") |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is an extra space before the closing parenthesis. This is inconsistent with the rest of the codebase.
| local http = require("resty.http" ) | |
| local url = require("socket.url") | |
| -- 假设 sse 模块存在于 apisix.plugins.ai-drivers.sse | |
| local sse = require("apisix.plugins.ai-drivers.sse") | |
| local http = require("resty.http") | |
| local url = require("socket.url") | |
| -- 假设 sse 模块存在于 apisix.plugins.ai-drivers.sse | |
| local sse = require("apisix.plugins.ai-drivers.sse") |
| process_sse_chunk = _M.process_sse_chunk, | ||
| parse_token_usage = _M.parse_token_usage, | ||
| -- transform_request 和 transform_response 在 OpenAI 兼容层中通常不需要 | ||
| } ) |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is an extra space before the closing parenthesis. This is inconsistent with the rest of the codebase.
| } ) | |
| }) |
|
|
||
|
|
||
| return _M | ||
| return _M.new({}) |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The refactored openai-base.lua returns _M.new({}) at the end, which means every OpenAI-compatible driver that requires this module will get the same singleton instance with empty opts. This breaks the pattern where each driver (openai.lua, deepseek.lua, etc.) calls .new() with their specific host/port/path configuration. This line should return just _M, not _M.new({}).
| return _M.new({}) | |
| return _M |
| return driver_base.new({ | ||
| host = "api.anthropic.com", | ||
| port = 443, | ||
| path = "/v1/messages", | ||
| transform_request = _M.transform_request, | ||
| transform_response = _M.transform_response, | ||
| process_sse_chunk = _M.process_sse_chunk | ||
| }) |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Anthropic driver is missing the validate_request method which is called by ai-proxy/base.lua. This will cause a runtime error when the Anthropic provider is used. The validate_request method needs to be implemented or the driver needs to properly inherit it from the base driver.
| method = "POST", | ||
| url = upstream_url, | ||
| headers = headers, | ||
| body = core.json.encode(request_table ), |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is an extra space before the closing parenthesis. This is inconsistent with the rest of the codebase.
| function _M.process_sse_chunk(chunk) | ||
| local events = sse.decode(chunk) | ||
| local out = {} | ||
|
|
||
| for _, e in ipairs(events) do | ||
| if e.type == "message" then | ||
| local d = core.json.decode(e.data) | ||
| if d.type == "content_block_delta" then | ||
| -- 转换为 OpenAI 兼容的流式格式 | ||
| table.insert(out, "data: " .. core.json.encode({ | ||
| choices = { | ||
| { | ||
| delta = { | ||
| content = d.delta.text | ||
| } | ||
| } | ||
| } | ||
| }) .. "\n") | ||
| elseif d.type == "message_stop" then | ||
| table.insert(out, "data: [DONE]\n") | ||
| end | ||
| end |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The process_sse_chunk function doesn't handle JSON decode errors properly. If core.json.decode(e.data) fails at line 54, the variable 'd' will be nil but the code continues to access d.type without checking, which will cause a runtime error.
Hi @Baoyuantop, |
|
Hi @uniprewan, the current code introduces many breaking changes that may cause existing AI providers to become inoperable. Please maintain the existing implementation or refactor it in a fully backward-compatible manner, ensuring that all CI operations pass after the code changes. |
788517d to
332d024
Compare
|
Background
Currently, the APISIX AI Proxy plugin offers OpenAI compatibility. However, native support for the Anthropic Messages API is incomplete, often resulting in simple request forwarding without leveraging Anthropic's specific protocol features (like the structured Messages API and dedicated system prompt handling).
This feature is crucial for users who want to:
What I Did
This PR introduces native support for the Anthropic provider by implementing a refactored architecture with abstraction.
ai-driver-base.lua) to provide shared functionalities for all AI drivers.anthropic.luadriver that inherits from the base class.openai-base.luato inherit from the new base driver for consistency.Checklist