mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +00:00
feat(adapters): 入向端点移交适配器 —— adapter.ingress + deserialize_request,内核主动让出
plan.md 的 R2 目标里有三行钩子契约,出向两条(serialize_response/serialize_chunk)
Phase 2 已落地,第三条 parse_request(protocol_body) -> unified 一直空着:
/v1/messages 的入向解析硬编码在内核 Go 里(anthropicRequest.toUnified),
Anthropic 改协议就得改内核。本次补齐这条对偶。
## 机制(与出向序列化对称)
- 适配器声明 adapter.ingress = "/v1/messages" 认领入站路径,并实现
deserialize_request(raw_body) -> unified_json。
- 内核 handleMessages 经新增的 parseViaIngress 判定:
- 有适配器认领该路径且实现了钩子 → 让出,解析由适配器完成;
- 无适配器认领 → 内核内置解析运行(R5 兜底,未配置部署仍可服务)。
- 认领了但钩子执行失败 → 硬错误 400,不静默换用另一种协议解析
(客户端说的正是该适配器声称支持的协议,悄悄换解析器只会错误调度)。
- 这是内核里唯一的新判定点,与 serializerFor 对称:出向选序列化器,入向选解析器。
## 内核改动
- internal/lua/vm.go:staticInfo 增 ingress 字段;inspectScript 读取;
新增 VM.AdapterForIngress(path)(确定性选择:同名多认领取字典序最小)。
- internal/gateway/messages.go:handleMessages 改为先读 body → parseViaIngress
(让出)→ 未认领时回落内置 anthropicRequest.toUnified。
内置解析与 anthropicRequest 类型保留——它们是兜底,不是死代码。
## 适配器改动
- internal/lua/adapters/anthropic.lua:声明 adapter.ingress = "/v1/messages",
新增 deserialize_request,把内核原 Go 解析逻辑逐字段移植(system 两种拼写、
content 原样透传、缺省 thinking={enabled:false}、缺 messages 报错)。
内置 anthropic 默认随包播种,因此真实部署里内核总是让出给它,
Go 侧 anthropicRequest.toUnified 退化为纯兜底。
## 测试
- gateway:TestMessagesIngressYieldsToBundledAnthropic(让出后仍 200)、
TestAdapterForIngressClaim(声明可被找到、未声明路径为空、钩子存在)、
TestMessagesKernelFallbackWhenNoAdapterClaims(移除认领适配器后内核兜底仍 200)。
- lua:TestAnthropicDeserializeRequestShape 逐字段核对 Lua 与 Go 产出一致
(system 拼接、content、max_tokens/stream/temperature/top_p/top_k/stop_sequences、
缺省 thinking),TestAnthropicDeserializeRejectsMissingMessages。
- 全量回归:gateway/lua/core/config/types/provider/scheduler/e2e 全绿。
- 文档:docs/lua-adapters.md 补「入向让出钩子」章节 + plan.md 台账更新。
This commit is contained in:
@ -5,6 +5,14 @@ adapter.endpoint = "/v1/messages"
|
||||
adapter.headers = {
|
||||
["anthropic-version"] = "2023-06-01",
|
||||
}
|
||||
-- INGRESS CLAIM (inbound dual of serialize_*): this adapter owns parsing of the
|
||||
-- client-facing POST /v1/messages request body. The kernel keeps a built-in
|
||||
-- Anthropic parser as its fallback (R5) — that is what an unconfigured
|
||||
-- deployment uses — but once this adapter is loaded the kernel YIELDS, and the
|
||||
-- deserialize_request hook below is the single place Anthropic's REQUEST shape
|
||||
-- is understood. Iterating on that shape therefore means editing this file,
|
||||
-- never the kernel.
|
||||
adapter.ingress = "/v1/messages"
|
||||
|
||||
-- ============================================================
|
||||
-- helpers
|
||||
@ -132,6 +140,79 @@ local function append_user(msgs, blocks)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- deserialize_request (Anthropic → unified) [INGRESS]
|
||||
-- ============================================================
|
||||
-- The inbound dual of serialize_response. The kernel hands over the raw client
|
||||
-- body and receives the unified request; from there scheduling is identical to
|
||||
-- /v1/chat/completions. Ported field-for-field from the kernel's former
|
||||
-- built-in anthropicRequest.toUnified — which remains as the kernel fallback
|
||||
-- for an unconfigured deployment.
|
||||
function adapter.deserialize_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok or type(req) ~= "table" then
|
||||
error("invalid json")
|
||||
end
|
||||
|
||||
-- system accepts Anthropic's two spellings: a plain string, or an array of
|
||||
-- { type = "text", text = ... } blocks. Only text blocks contribute.
|
||||
local system = ""
|
||||
local sys = req.system
|
||||
if type(sys) == "string" then
|
||||
system = sys
|
||||
elseif type(sys) == "table" then
|
||||
for _, b in ipairs(sys) do
|
||||
if type(b) == "table" and b.type == "text" and type(b.text) == "string" then
|
||||
system = system .. b.text
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local msgs = {}
|
||||
local in_msgs = req.messages
|
||||
if type(in_msgs) ~= "table" or #in_msgs == 0 then
|
||||
error("messages is required")
|
||||
end
|
||||
for i, m in ipairs(in_msgs) do
|
||||
if type(m) ~= "table" or type(m.role) ~= "string" or m.role == "" then
|
||||
error("messages[" .. (i - 1) .. "].role is required")
|
||||
end
|
||||
-- Content is carried VERBATIM: a string stays a string, a block array
|
||||
-- stays an array. The unified format keeps multimodal/tool blocks
|
||||
-- untouched because only an adapter knows which block types the upstream
|
||||
-- accepts. An absent/null content becomes the empty string.
|
||||
local content = m.content
|
||||
if content == nil or content == "" then
|
||||
content = ""
|
||||
end
|
||||
table.insert(msgs, { role = m.role, content = content })
|
||||
end
|
||||
|
||||
-- Anthropic's default is thinking OFF; emit that explicitly so the unified
|
||||
-- request carries the client's intent rather than leaving it absent.
|
||||
local thinking = req.thinking
|
||||
if thinking == nil then
|
||||
thinking = { enabled = false }
|
||||
end
|
||||
|
||||
local out = {
|
||||
model = req.model or "",
|
||||
messages = msgs,
|
||||
system = system,
|
||||
max_tokens = req.max_tokens or 0,
|
||||
stream = req.stream == true,
|
||||
temperature = req.temperature,
|
||||
top_p = req.top_p,
|
||||
top_k = req.top_k or 0,
|
||||
stop_sequences = req.stop_sequences,
|
||||
tools = req.tools,
|
||||
tool_choice = req.tool_choice,
|
||||
thinking = thinking,
|
||||
extra_body = {},
|
||||
}
|
||||
return json.encode(out)
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- transform_request (OpenAI → Anthropic)
|
||||
-- ============================================================
|
||||
|
||||
Reference in New Issue
Block a user