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 台账更新。
15 KiB
Lua 适配器 API 文档
English: lua-adapters-en.md
每个上游源(source)挂载一个 .lua 适配器,负责把统一 OpenAI 格式请求转换为上游原生
格式,并把上游响应/流式分块转换回统一格式。Go 层只负责调度、并发与透传——因此新增
适配器、适配新协议,无需重编译 Go。
适配器放两份(同构):
- 内置:
internal/lua/adapters/<name>.lua(编译期 embed) - 可覆盖:配置
adapter_dir目录下的同名脚本(优先级更高)
加载时机:启动时由
lua.NewVM(adapter_dir)一次性加载全部适配器;通过 WebUI 上传的适配器(Core.UploadAdapter→vm.LoadAdapter)与在线编辑的源配置即时生效, 无需重启。直接修改adapter_dir下的.lua文件需要重启进程才会重新加载。
目录
1. 脚本结构
脚本是一个返回 table 的 Lua 文件,必须 return 一个表:
local adapter = {}
adapter.name = "mysrc"
adapter.version = "1.0.0"
-- ... 字段与函数 ...
return adapter
脚本由 LuaJIT 解析(golua 绑定,cgo)。每个适配器拥有独立 VM + worker 池,适配器之间
互不干扰;脚本中不允许跨 worker 共享可变状态(log 到 stdout 是唯一副作用)。
2. 静态字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
name |
string | 是 | 适配器名,用于 WebUI 展示 |
version |
string | 否 | 版本号,用于 WebUI 展示 |
endpoint |
string | 否 | 上游请求路径,默认 /chat/completions;可被 source.endpoint / source.image_endpoint 覆盖 |
headers |
table | 否 | 静态默认请求头;若未定义 build_headers 钩子则作为请求头回退 |
transform_error(status, body) |
function | 否 | 错误响应收敛:返回一行短原因;返回 nil / 未定义时客户端统一收到 unknown error(原始响应体只进服务端日志) |
这些字段在加载时静态提取(compile-once),之后读它们不会占用池内 worker。
3. 转换钩子
transform_request(raw_body) -> string
入参:Go 层构造的统一 OpenAI 格式 JSON 字符串(/v1/chat/completions 请求体)。
返回:发送给上游的请求体字符串。
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.model = "upstream-model-name" -- 改写模型名
req.stream = req.stream or false
return json.encode(req)
end
约定:
- 返回的字符串将被原样 POST 到
base_url + (source.endpoint or adapter.endpoint)。 - 契约 v2 起,内核不再向适配器泄漏任何私有字段:请求体里不会出现
disable_thinking/extra_body这类键。推理开关与预算用中立的thinking字段表达(见下),适配器按目标协议各自映射。
transform_response(raw_body) -> string
入参:上游非流式响应的原始 JSON 字符串。返回:统一格式 JSON 字符串。
统一格式字段:
| 字段 | 类型 | 说明 |
|---|---|---|
content |
string | 回复正文 |
finish_reason |
string | stop / length / tool_calls 等 |
reasoning_content |
string(可选) | 推理内容(DeepSeek 等) |
token_usage |
table | { prompt, completion, total }(tokens) |
tool_calls |
array(可选) | 工具调用:{ id, type, name, arguments },arguments 为已解码的 table |
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
unified.content = ch.message.content or ""
unified.finish_reason = ch.finish_reason or ""
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
end
return json.encode(unified)
end
transform_stream_chunk(raw_chunk) -> string
入参:SSE 流中每条 data: 的原始 JSON 字符串(不含 data: 前缀)。
返回:统一分块 JSON;返回 "" 表示跳过该 chunk。
统一分块格式:
| 字段 | 类型 | 说明 |
|---|---|---|
content |
string | 本次增量文本(可为 "") |
done |
boolean(可选) | true 表示流结束(对应 finish_reason 出现) |
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
if not chunk.choices or #chunk.choices == 0 then return "" end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
return json.encode({
content = delta.content or "",
done = (fr ~= nil)
})
end
4. 可选钩子
transform_error(status, body) -> string | nil
把该源特有的错误响应收敛成一行短原因。每个上游的错误格式不同——这是适配器层
的职责:内置各适配器均实现了自己的信封解析(如 zen 的
{error={type,message}}、Anthropic 的 {type="error",error={...}}、Gemini 的
{error={code,message,status}}、Ollama 的字符串 {error="..."} 等)。
未定义本钩子或返回 nil 时,核心不猜测格式,客户端统一收到
api error <status>: unknown error,原始响应体仅记录在服务端日志。
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
if resp.error and resp.error.type == "FreeUsageLimitError" then
return "zen free pool quota exhausted"
end
return resp.error and resp.error.message or nil
end
build_headers(meta) -> table<string,string>
动态生成/签名请求头(如 KimiCode 的 HMAC 签名)。若脚本未定义此函数,Go 层回退使用静态
adapter.headers(或 source.headers)。
function adapter.build_headers(meta)
local msg = meta.method .. meta.url .. meta.body
return {
["X-App-Sign"] = hmac_sha256_hex(meta.source.meta.app_secret, msg),
["X-Timestamp"] = meta.timestamp,
}
end
返回非 table 会报错;返回 {} 表示无自定义头(不会回退静态头)。
出向序列化钩子(契约 v2)
默认情况下,网关把适配器产出的统一格式按 OpenAI 线格式序列化给下游(这是内核 保留的兑底,R5)。当上游协议不是 OpenAI(例如 Anthropic Messages API)时,适配器 可以接管出向序列化,自己决定写给客户端的字节。
五个钩子都是可选的,全不声明就沿用 OpenAI 兑底。一旦声明某个钩子,它就完全 接管那一段输出——内核不会再往里混合 OpenAI 字段(不是“叠加”,是“取代”)。
| 钩子 | 何时被调用 | 返回值 |
|---|---|---|
serialize_response(payload) |
非流式响应 | 写给客户端的响应体字符串 |
serialize_open_preamble(payload) |
流式开始(发第一个 chunk 前) | 要发的字节(可为 "") |
serialize_chunk(delta) |
流式每个增量 | 要发的字节("" 表示跳过) |
serialize_close(payload) |
流式结束 | 收尾字节(如 SSE message_stop) |
serialize_done_marker(payload) |
流式结束处的 [DONE] 标记 |
要写的字节;不声明则用 OpenAI 的 data: [DONE] |
payload 是统一格式的结果表(见 internal/types/maxwire.go 的 UnifiedResult /
UnifiedDelta),字段名中立,不依赖具体上游协议。
-- anthropic.lua 的简化形式:把统一结果写成 Anthropic Messages 形状
function adapter.serialize_response(payload)
return json.encode({
id = payload.id,
type = "message",
role = "assistant",
content = { { type = "text", text = payload.content or "" } },
stop_reason = payload.finish_reason == "stop" and "end_turn" or nil,
usage = { input_tokens = payload.prompt_tokens, output_tokens = payload.completion_tokens },
})
end
注意:钩子返回的是完整要发送的字节(包括必要时的 event: / data: 前缀与
空行),因为只有适配器知道目标协议的 SSE 细节。
入向让出钩子(契约 v2,serialize_* 的对偶)
出向是「统一格式 → 客户端协议」,入向是它的镜像:「客户端协议 → 统一格式」。
内核为对外端点(如 POST /v1/messages)保留一份内置解析作为兜底(R5),
但适配器可以声明自己服务该端点并接管解析——那时内核主动让出,客户端协议的
迭代就完全落在适配器里,不再需要动内核。
声明分两步:
adapter.ingress = "/v1/messages"—— 声明认领哪条对外入站路径。 同一条路径被多个适配器认领是配置错误;内核按适配器名字典序确定性选择。deserialize_request(raw_body) -> unified_json—— 把客户端原始 body 解析成 统一请求(UnifiedRequest的 JSON),后续调度与/v1/chat/completions完全一致。
规则:
- 只有声明了该端点且实现了
deserialize_request的适配器会让内核让出。 - 没有适配器认领时,内核内置解析接管(未配置的部署仍能工作)。
- 认领了但钩子执行失败是硬错误(400),不会静默回落到另一种协议解析—— 客户端说的正是这个适配器声称支持的协议,悄悄换解析器只会错误调度请求。
-- anthropic.lua 的简化形式:把 Anthropic Messages 请求解析成统一格式
adapter.ingress = "/v1/messages"
function adapter.deserialize_request(raw_body)
local req = json.decode(raw_body)
local msgs = {}
for _, m in ipairs(req.messages or {}) do
table.insert(msgs, { role = m.role, content = m.content })
end
return json.encode({
model = req.model, messages = msgs, max_tokens = req.max_tokens,
system = type(req.system) == "string" and req.system or "",
stream = req.stream == true,
})
end
5. 内置辅助函数
所有适配器共享的全局函数(由 Go 注入):
| 函数 | 说明 |
|---|---|
json.encode(v) |
Lua 值 → JSON 字符串;失败返回 "null" |
json.decode(s) |
JSON 字符串 → Lua 值;失败返回 nil |
hmac_sha256_hex(key, data) |
HMAC-SHA256,十六进制小写字符串 |
sha256_hex(data) |
SHA-256,十六进制小写字符串 |
base64_encode(s) |
标准 Base64 编码 |
tohex(s) |
字节 → 十六进制小写字符串 |
log(level, msg) |
打印 [adapter/<name>] <msg> 到 stdout |
另注:脚本由 LuaJIT 执行,标准库(string/table/pcall 等)完整可用,os/io 未暴露
(保持沙箱语义)。
6. meta 约定
build_headers(meta) 与请求转换共享的 meta 结构:
| 字段 | 说明 |
|---|---|
meta.url |
完整请求 URL |
meta.method |
HTTP 方法(通常 POST) |
meta.body |
请求体字符串 |
meta.api_key |
该源的 api_key |
meta.timestamp |
请求时间戳 |
meta.source |
table:{ name, meta = { ... } },即 source 的 meta 字段(如 app_secret) |
7. 一个完整的最小适配器
local adapter = {}
adapter.name = "mysrc"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
adapter.headers = { ["X-Tenant"] = "prod" }
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
-- 契约 v2:内核不再向适配器泄漏私有字段,`thinking` 是中立语义字段。
-- 这里把中立的 thinking 映射成目标协议需要的形状(示例:OpenAI 家族用
-- reasoning_effort)。不关心推理的源可以直接忽略它。
if req.thinking ~= nil then
if req.thinking.enabled == false then req.reasoning_effort = "none" end
req.thinking = nil
end
return json.encode(req)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
unified.content = ch.message.content or ""
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
if not chunk.choices or #chunk.choices == 0 then return "" end
return json.encode({
content = (chunk.choices[1].delta or {}).content or "",
done = (chunk.choices[1].finish_reason ~= nil)
})
end
return adapter
对应的 config.yaml source:
sources:
- name: mysrc
base_url: https://upstream.example.com
api_key: sk-xxx
adapter: mysrc # 或省略 = 内置同名适配器
endpoint: /chat/completions
models:
- id: my-model
priority: 50
kind: chat
8. 多模态与 thinking 预算
-
多模态:请求的
content若是数组([{type:"text"...},{type:"image_url"...}]),Go 层 原样透传给适配器。协议不支持的源(Anthropic/Gemini/Ollama)需在transform_request内转换;内置anthropic.lua/gemini.lua/ollama.lua已实现。 -
thinking:客户端请求里的推理开关/预算被内核归一成中立的
thinking字段:{"thinking": {"enabled": true, "budget_tokens": 4096}}enabled=false表示关闭推理,budget_tokens可选。适配器自行映射到目标协议 (内置实现:Anthropic →thinking.budget_tokens、Gemini →generationConfig.thinkingConfig.thinkingBudget、DeepSeek →thinking.type = "disabled")。不支持的源直接忽略该字段。契约 v1 的内核私有字段
disable_thinking已被取代——它曾是内核细节泄漏进 适配器请求体的典型例子(迫使 10 个适配器各写一行req.disable_thinking = nil)。