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:
JianFeeeee
2026-10-12 16:32:09 +08:00
parent 5948ad19ca
commit 3c59c33c58
6 changed files with 420 additions and 6 deletions

View File

@ -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)
-- ============================================================

View File

@ -51,3 +51,98 @@ func TestAnthropicSerializerShape(t *testing.T) {
}
}
}
// TestAnthropicDeserializeRequestShape 验证 anthropic 适配器的 ingress 解析
// (deserialize_request)输出与内核原 Go 兜底(anthropicRequest.toUnified)逐字段
// 一致。这是"让出"的前提:若两边产出不同,"谁解析"就会悄悄改变行为。
func TestAnthropicDeserializeRequestShape(t *testing.T) {
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
// 适配器必须声明 ingress 并提供钩子,内核才会让出。
if !vm.HasFn("anthropic", "deserialize_request") {
t.Fatal("anthropic 未声明 deserialize_request")
}
// system 两种拼写 + content 字符串/块数组 + thinking 缺省
in := `{
"model": "claude-x",
"max_tokens": 128,
"system": [{"type":"text","text":"be terse"},{"type":"text","text":"!"}],
"messages": [
{"role":"user","content":"hi"},
{"role":"assistant","content":[{"type":"text","text":"hello"}]}
],
"temperature": 0.5,
"top_p": 0.9,
"top_k": 40,
"stop_sequences": ["STOP"],
"stream": true
}`
out, err := vm.Transform("anthropic", "deserialize_request", in)
if err != nil {
t.Fatalf("deserialize_request: %v", err)
}
var u struct {
Model string `json:"model"`
System string `json:"system"`
Messages []json.RawMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
TopK int `json:"top_k"`
StopSequences []string `json:"stop_sequences"`
Thinking *struct {
Enabled *bool `json:"enabled"`
} `json:"thinking"`
}
if err := json.Unmarshal([]byte(out), &u); err != nil {
t.Fatalf("not JSON: %s", out)
}
if u.Model != "claude-x" {
t.Errorf("model = %q", u.Model)
}
// Go 侧 systemToString 把 [{text:"be terse"},{text:"!"}] 拼成 "be terse!"
// (text 块按序拼接,无分隔符)。Lua 必须一致。
if u.System != "be terse!" {
t.Errorf("system = %q, want %q (Go 侧拼接语义)", u.System, "be terse!")
}
if len(u.Messages) != 2 {
t.Fatalf("messages = %d", len(u.Messages))
}
if u.MaxTokens != 128 || !u.Stream {
t.Errorf("max_tokens/stream = %d/%v", u.MaxTokens, u.Stream)
}
if u.Temperature == nil || *u.Temperature != 0.5 {
t.Errorf("temperature = %v", u.Temperature)
}
if u.TopP == nil || *u.TopP != 0.9 || u.TopK != 40 {
t.Errorf("top_p/top_k = %v/%d", u.TopP, u.TopK)
}
if len(u.StopSequences) != 1 || u.StopSequences[0] != "STOP" {
t.Errorf("stop_sequences = %v", u.StopSequences)
}
// 缺省 thinking → Go 侧显式发 {enabled:false};Lua 必须一致。
if u.Thinking == nil || u.Thinking.Enabled == nil || *u.Thinking.Enabled != false {
t.Errorf("缺省 thinking 应为 {enabled:false}, 实得 %s", out)
}
}
// TestAnthropicDeserializeRejectsMissingMessages 验证 Lua 侧与 Go 侧一样,
// 对缺 messages 的请求报错(而不是产出空请求)。
func TestAnthropicDeserializeRejectsMissingMessages(t *testing.T) {
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
if _, err := vm.Transform("anthropic", "deserialize_request", `{"model":"x","max_tokens":8}`); err == nil {
t.Error("缺 messages 应报错")
}
}

View File

@ -84,6 +84,15 @@ type staticInfo struct {
// from name when a script declares its own name. Kept so a reload replaces
// the same pool entry rather than silently registering a second one.
registeredAs string
// ingress is the client-facing endpoint path this adapter CLAIMS to parse,
// e.g. "/v1/messages". It is the INBOUND dual of serialize_* (which is
// outbound): when an adapter declares it, the kernel YIELDS parsing of that
// endpoint's request body to the adapter's deserialize_request hook, instead
// of its own built-in parser. The kernel keeps a built-in parser as the
// fallback (R5), so an unconfigured deployment still serves /v1/messages —
// but once an adapter claims the endpoint, protocol iteration lives in the
// adapter, not the kernel. Empty means the adapter claims no ingress.
ingress string
}
// adapterPool owns the worker states for one adapter. Idle workers are held in
@ -788,6 +797,38 @@ func (v *VM) ListAdapters() []APIAdapter {
return list
}
// AdapterForIngress returns the registered name of the adapter that CLAIMS the
// given client-facing endpoint path (its static.ingress), or "" when none does.
//
// This is the kernel-yields decision for an inbound endpoint (user requirement:
// "/v1/messages is the kernel's fallback endpoint; an adapter may declare it and
// the kernel actively yields"). The kernel keeps its own built-in parser as the
// fallback (R5) — AdapterForIngress returning "" means "no adapter claimed it,
// use the built-in", so an unconfigured deployment still serves the endpoint.
//
// Ties (two adapters claiming the same path) resolve to the lexically-first
// name so the choice is deterministic rather than map-iteration order. A real
// deployment should not have two adapters claiming one ingress; when it does,
// the plugin conflict-arbitration surface is the place to make it explicit, but
// a parse endpoint must still pick SOMETHING rather than vary per request.
func (v *VM) AdapterForIngress(path string) string {
if path == "" {
return ""
}
v.mu.RLock()
defer v.mu.RUnlock()
chosen := ""
for name, p := range v.pools {
p.mu.Lock()
claims := p.static.ingress == path
p.mu.Unlock()
if claims && (chosen == "" || name < chosen) {
chosen = name
}
}
return chosen
}
// LoadAdapter compiles and registers an adapter script from a file.
//
// The path is validated for SAFETY before reading (R6): it must sit inside the
@ -1197,6 +1238,7 @@ func inspectScript(name, code string) (*staticInfo, error) {
info.version = readString(L, -1, "version")
info.endpoint = readString(L, -1, "endpoint")
info.endpointStream = readString(L, -1, "endpoint_stream")
info.ingress = readString(L, -1, "ingress")
if getFieldBatch(L, "headers") {
luaTableToMap(L, -1, info.headers)
L.Pop(1)