diff --git a/README.md b/README.md index 178afd6..f7142a3 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,9 @@ Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换 (所有现有状态均已占用)时批量预热,顺序流量始终只用 1 个状态。 - 缩容步长由当前连接数决定:`clamp(ceil(冗余/(1+占用)), 1, 冗余)`——无连接时一轮收到 常驻下限,忙时每轮只释放 1 个,保护热路径。WebUI「适配器」页展示实时池指标。 -- **disable_thinking**:请求 `disable_thinking:true`(或上游对应字段)动态开关推理。 +- **thinking 预算**:请求里的推理开关/预算归一为中立的 `thinking:{enabled,budget_tokens}`, + 适配器映射到各自协议(Anthropic `thinking.budget_tokens`、Gemini `thinkingConfig`、 + DeepSeek `thinking.type="disabled"`)。 - **Lua 适配协议**:每个源挂一个 `.lua` 适配器,完成 `transform_request` / `transform_response` / `transform_stream_chunk` 双向转换,协议差异全在 Lua 层。 - **签名 / 请求头钩子**:适配器可定义 `build_headers(meta)`,在 Go 发 HTTP 前 @@ -448,10 +450,13 @@ Environment=MALLOC_ARENA_MAX=2 可选:`GOMEMLIMIT=48MiB` 作软上限,实测再省 ~1 MB;代价是逐近上限时 GC 转激进, 温和场景不必开。 -### disable_thinking +### thinking 预算 -请求体带 `"disable_thinking": true` 时,网关透传给各适配器;DeepSeek 适配器将其 -映射为 `extra_body.thinking.type = "disabled"` 关闭推理,其余源按各自协议处理。 +请求体带 `"thinking": {"enabled": false}` 时关闭推理;`"thinking": {"enabled": true, +"budget_tokens": 4096}` 指定预算。内核只定义这个**中立**语义字段,映射到具体协议由 +适配器负责(Anthropic `thinking.budget_tokens`、Gemini `thinkingConfig.thinkingBudget`、 +DeepSeek `thinking.type = "disabled"`)。内核不再向适配器泄漏 `disable_thinking` / +`extra_body` 这类私有字段。 ### WebUI @@ -499,6 +504,13 @@ return { -- [可选] 动态请求头/签名钩子 -- meta = { url, method, body, api_key, timestamp, source={ name, meta={...} } } build_headers = function(meta) return { ["X-App-Sign"] = sign } end, + + -- [可选] 出向序列化(契约 v2):上游不是 OpenAI 协议时接管写给客户端的字节。 + -- 不声明则沿用内核的 OpenAI 兜底;声明即完全取代,内核不再混合字段。 + serialize_response = function(result) return body_str end, + serialize_open_preamble = function(result) return bytes end, + serialize_chunk = function(delta) return bytes end, + serialize_close = function(result) return bytes end, } ``` @@ -510,9 +522,9 @@ return { `openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode` `qwen38` `sensenova` `trae`。 -`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);若 -源启用 `disable_thinking`,`deepseek` 适配器会把 `extra_body.thinking.type` 置为 -`disabled`。 +`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式); +推理开关由中立的 `thinking` 字段表达,`deepseek` 适配器把它映射为 +`thinking.type = "disabled"`。 **kimicode** 是展示 `build_headers` 的样例:云端校验调用方 app,需要按 `meta.app_secret` 对时间戳+URL+请求体哈希做 HMAC 签名并附 `X-App-Sign` 等头。 @@ -627,4 +639,4 @@ go test -tags luajit ./... ``` 覆盖:配置校验、适配器加载/变换、签名钩子、Gateway 鉴权、SDK 结算、SSE 流式、 -模型路由、多模态透传与 disable_thinking。 +模型路由、多模态透传与 thinking 预算。 diff --git a/docs/lua-adapters.md b/docs/lua-adapters.md index c0faa9c..c79647c 100644 --- a/docs/lua-adapters.md +++ b/docs/lua-adapters.md @@ -23,7 +23,7 @@ 5. [内置辅助函数](#5-内置辅助函数) 6. [meta 约定](#6-meta-约定) 7. [一个完整的最小适配器](#7-一个完整的最小适配器) -8. [多模态与 disable_thinking](#8-多模态与-disable_thinking) +8. [多模态与 thinking 预算](#8-多模态与-thinking-预算) --- @@ -73,8 +73,9 @@ end 约定: - 返回的字符串将被原样 POST 到 `base_url + (source.endpoint or adapter.endpoint)`。 -- 请求体中包含的 `disable_thinking`、`extra_body` 等网关内部字段,转换时应自行清理 - (`openai.lua` 即删掉这两个字段)。 +- 契约 v2 起,内核**不再向适配器泄漏任何私有字段**:请求体里不会出现 + `disable_thinking` / `extra_body` 这类键。推理开关与预算用中立的 `thinking` + 字段表达(见下),适配器按目标协议各自映射。 ### `transform_response(raw_body) -> string` @@ -176,6 +177,43 @@ 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 注入): @@ -219,8 +257,13 @@ 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 - req.disable_thinking = nil - req.extra_body = nil + -- 契约 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 @@ -268,11 +311,21 @@ sources: kind: chat ``` -## 8. 多模态与 disable_thinking +## 8. 多模态与 thinking 预算 - **多模态**:请求的 `content` 若是数组(`[{type:"text"...},{type:"image_url"...}]`),Go 层 原样透传给适配器。协议不支持的源(Anthropic/Gemini/Ollama)需在 `transform_request` 内转换;内置 `anthropic.lua`/`gemini.lua`/`ollama.lua` 已实现。 -- **disable_thinking**:网关将请求体中的 `disable_thinking:true` 透传进 - `transform_request`。DeepSeek 适配器将其映射为 `extra_body.thinking = { type = "disabled" }` - 并清理自身字段;其余适配器按各自协议处理(可自行实现或忽略)。 +- **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`)。 diff --git a/docs/plugins.md b/docs/plugins.md index a759602..0cdfb13 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -301,20 +301,118 @@ PUT /api/plugins/{name}/state 替换状态(admin) `hook_errors` 是**排错入口**:一个坏掉的插件表现为"功能缺失"而不是报错, 这个计数让它可见。 +### 5.4 定时任务(`PLUGIN.schedule`)—— 浏览器关着也会跑 + +要求插件做“随时间发生”的事(自动签到、token 轮换、额度轮询)时,声明一个 +调度表: + +```lua +plugin.schedule = { + { fn = "refresh_token", every = 3600 }, -- 每小时(秒) + { fn = "daily_checkin", every = 86400, at = "03:30" }, -- 每天固定时刻(本地时间) + { fn = "poll_quota", every = 300, jitter = 30 }, -- 首次触发加 0-30s 抖动 +} +function plugin.refresh_token() + -- 注意:直接写 plugin.state 就是对的 —— + -- 任务和钩子跑在同一个 VM 上 + plugin.state.token = fetch_new_token() +end +``` + +| 字段 | 含义 | +|---|---| +| `fn` | 要调用的插件函数名(必须存在) | +| `every` | 间隔秒数。与 `at` 二选一 | +| `at` | 每天本地时间 `"HH:MM"`;设了它就忽略 `every` | +| `jitter` | 首次触发额外随机延迟秒数(避免多实例整点齐射) | +| `timeout` | 解析保留但目前**不生效**(见下) | + +要点: + +- **内核 ticker 驱动**,不是前端轮询:没有浏览器、没人登录也会执行。 +- **任务跑在插件自己的 VM 上**,与请求钩子共用 `plugin.state` —— 任务写的 + token 对下一个请求立即可见,也会经同一套机制持久化到 `..state.json`。 +- 任务函数出错只记 `hook_errors`,不影响钩子与转发。 +- 没有 `schedule` 的插件不获得任何额外 VM/goroutine(零开销)。 +- **超时不能中断一个运行中的 Lua 调用**(本 LuaJIT 构建的硬限制)。任务超过 + 10 分钟不返回,该插件被**停用**(`disarmed`):不再调度,钩子快速失败并 + 记 `hook_errors`。这是为了不让一个卡死的任务把请求永久堵在它那把锁后面。 + 修好插件后重新加载(重传/重启)即可恢复。 + +> 同一条 6.1 的约束:任务占着插件锁,所以**任务里不要写死循环**。 + +### 5.5 自定义对外挂载点 + +插件的 HTTP 端点默认挂在 `/api/plugins/http/...` 下。同一个声明**同时**可在两个 +公开命名空间下访问,只要把 `path` 的第一段起成插件名: + +```lua +plugin.http = { + { path = "myplugin/callback", method = "POST", handler = "on_callback" }, +} +``` + +该端点因此可在三个地方访问: + +| 路径 | 用途 | +|---|---| +| `/api/plugins/http/myplugin/callback` | 默认前缀(带登录态的管理 UI) | +| `/plugins/myplugin/callback` | 公开(浏览器路径) | +| `/v1/plugins/myplugin/callback` | 公开(API 路径) | + +可占用的命名空间只有后两个: + +| 前缀 | 例子 | +|---|---| +| `/plugins/` | `/plugins/myplugin/callback` | +| `/v1/plugins/` | `/v1/plugins/myplugin/callback` | + +限制(都是为了不把内核路由变成可被插件改写的): + +- **不能影子内核路由**:与 `/v1/chat/completions`、`/v1/messages`、`/api/...` + 等重叠的挂载在加载时就被拒绝,错误信息点名冲突对象。 +- **只能是上面两个命名空间**,不接受任意前缀(否则可以拦截推理请求)。 +- 鉴权沿用端点的 `roles` / `keys` / `public` 语义(见 §5.1); + `/v1/plugins/...` 属于 API 路径,未认证返回 401;`/plugins/...` 为浏览器 + 路径,未认证会跳登录页。 +- 未声明的路径不会因为落在允许命名空间而被放行——不在 handler 表里就是 404。 + +### 5.6 在运行时安装/更新适配器(token 轮换) + +插件可以在运行时安装、改写**自己的**适配器 —— 这是"带自动轮换的认证"的实现 +方式:拿着新 token 重写适配器源码,后续请求自然使用新逻辑(内核重建该适配器 +的池)。这是 Phase 3 的桥接能力,安全规则如下: + +- 插件**只能改自己装的**适配器(归属表在 Go 侧记录,不靠约定)。 +- 插件**不能影子内置适配器**(否则等于篡改发行物);运维仍可自行覆盖。 +- 名字经过路径安全校验(无穿越/绝对路径/空白)。 + +> 注:这层桥接目前以 Go API 形式提供并被测试覆盖;插件 `.lua` 直接调用它的 +> 全局绑定尚未接通(见 plan.md 已知限制)。 + --- ## 6. 运行时约束(重要) -### 6.1 一个插件 = 一个 Lua 状态 +### 6.1 一个插件 = 一个 Lua 状态(也就是一个 VM) 适配器可以有多个独立的 worker 状态(它们无状态,这是对的)。**插件不行**: 钩子通常往 `plugin.state` 里累加,多个状态就意味着总额被劈成几份,而写价格只 写进其中一个状态,钩子恰好跑到另一个时**所有请求按 0 计费**。 -因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState`)用互斥锁 -串行化。代价:**一个卡住的钩子会卡住所有插件的钩子**。所以—— +因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState` / **定时任务**) +都用这同一把互斥锁串行化。 -> **钩子必须短、同步、不阻塞。** 不要在里面做 HTTP 请求、睡眠或重计算。 +⚠️ 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的 +`plugin.state` 是一个请求路径永远读不到的副本 —— token 轮换看起来成功了, +对实际流量完全无效。 + +代价:**一个卡住的钩子会卡住该插件自己的钩子**(跨插件仍然并行,每插件各自 +一把锁、各自一个 VM)。所以—— + +> **钩子与定时任务都必须短、同步、不阻塞。** 不要在钩子里做 HTTP 请求、睡眠 +> 或重计算;长耗时的周期工作应放进 `PLUGIN.schedule`(见 §5.4),它在自己的 +> 调度 goroutine 上跑,但仍然持有这把锁,因此不能是死循环。 ### 6.2 异常被隔离 @@ -497,10 +595,12 @@ journalctl -u llmsproxy -f | grep -i plugin | | 适配器 | 插件 | |---|---|---| | 位置 | `adapter_dir` | `plugin_dir` | -| 作用 | 上游协议转换 | 请求流水线 + 界面 | -| 钩子 | `transform_request` / `transform_response` / `transform_stream_chunk` / `build_headers` / `transform_error` | `request_start` / `routed` / `request_end` | -| 状态 | 每个源独立,多 worker | 单状态,见 §6.1 | -| 出错后果 | 该 (源,模型) 进入冷却退避 | 仅该功能缺失 | +| 作用 | 上游协议转换 | 请求流水线 + 界面 + 周期任务 | +| 钩子 | `transform_request` / `transform_response` / `transform_stream_chunk` / `build_headers` / `transform_error` / `serialize_*` | `request_start` / `routed` / `request_end` | +| 周期任务 | **无**(无状态请求级) | `PLUGIN.schedule`(内核 ticker 驱动) | +| 对外端点 | 不开放(见 §5.5) | `/api/plugins/http/...` + 自定义挂载 | +| 状态 | 每个源独立,多 worker | **单 VM、单状态**,见 §6.1 | +| 出错后果 | 该 (源,模型) 进入冷却退避 | 仅该功能缺失;卡死则停用该插件 | | 鉴权 | 需要 `gateway_keys` 之外的独立凭据 | 网关 key | | 必需性 | 核心 | 可选,缺了网关照常跑 |