Files
ModelRouter/docs/lua-adapters.md
JianFeeeee 66786bb0e3 docs: 契约 v2 文档收尾(Phase 7)
用户要求的收尾。三份文档与代码对齐。

## docs/lua-adapters.md

- 新增 §4「出向序列化钩子(契约 v2)」:serialize_response /
  serialize_open_preamble / serialize_chunk / serialize_close /
  serialize_done_marker。核心语义:**声明即完全取代**内核的 OpenAI 兜底,
  不是叠加 —— 适配器输出里不会有内核补的 OpenAI 字段(R2/R5)。
- §8 标题与内容从 disable_thinking 改为中立的 thinking 预算:
  `{enabled, budget_tokens}`,由适配器映射到目标协议。v1 的
  disable_thinking/extra_body 是内核细节泄漏进适配器请求体的典型
  (迫使 10 个适配器各写一行清理),已记明已被取代。
- §7 最小适配器示例同步改成映射中立 thinking 字段。

## docs/plugins.md

- §5.4 定时任务(PLUGIN.schedule):every/at/jitter/timeout 字段表 +
  **内核 ticker 驱动、浏览器关着也跑** + 任务与钩子同一个 VM(写
  plugin.state 立即可见于请求)+ 卡死则停用该插件(不能中断 Lua 的
  能力边界)。
- §5.5 自定义对外挂载点:plugin.http 声明同时可在 /api/plugins/http/、
  /plugins/、/v1/plugins/ 三处访问;命名空间约束与内核路由冲突拒绝。
- §5.6 运行时安装/更新适配器(token 轮换)+ 三条安全规则。
- §6.1 更正:某个卡住的钩子/任务只卡**它自己**那个插件的钩子(跨插件
  并行),原文写的是"卡住所有插件"。
- §9 对比表补周期任务/对外端点两行,钩子列表补 serialize_*。

## README

- disable_thinking 章节 → thinking 预算(中立字段 + 各适配器映射)。
- 适配器协议示例补 serialize_* 出向钩子。
- 特性列表与测试覆盖描述同步。
2026-10-11 13:07:50 +08:00

13 KiB
Raw Blame History

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. 脚本结构
  2. 静态字段
  3. 转换钩子
  4. 可选钩子
  5. 内置辅助函数
  6. meta 约定
  7. 一个完整的最小适配器
  8. 多模态与 thinking 预算

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 细节。

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)。