mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户要求的收尾。三份文档与代码对齐。
## 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_* 出向钩子。
- 特性列表与测试覆盖描述同步。
332 lines
13 KiB
Markdown
332 lines
13 KiB
Markdown
# 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`)。
|