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

332 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Lua 适配器 API 文档
> **English**: [lua-adapters-en.md](./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. [脚本结构](#1-脚本结构)
2. [静态字段](#2-静态字段)
3. [转换钩子](#3-转换钩子)
4. [可选钩子](#4-可选钩子)
5. [内置辅助函数](#5-内置辅助函数)
6. [meta 约定](#6-meta-约定)
7. [一个完整的最小适配器](#7-一个完整的最小适配器)
8. [多模态与 thinking 预算](#8-多模态与-thinking-预算)
---
## 1. 脚本结构
脚本是一个返回 table 的 Lua 文件,必须 `return` 一个表:
```lua
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` 请求体)。
返回:发送给上游的请求体字符串。
```lua
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 |
```lua
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` 出现) |
```lua
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`,原始响应体仅记录在服务端日志。
```lua
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`)。
```lua
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`),字段名中立,不依赖具体上游协议。
```lua
-- 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. 一个完整的最小适配器
```lua
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:
```yaml
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` 字段:
```json
{"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`)。