diff --git a/.snapshots/chat_nonstream.json b/.snapshots/chat_nonstream.json new file mode 100644 index 0000000..1d189bd --- /dev/null +++ b/.snapshots/chat_nonstream.json @@ -0,0 +1,24 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "pong", + "role": "assistant" + } + } + ], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion", + "usage": { + "completion": 1, + "completion_tokens": 1, + "prompt": 3, + "prompt_tokens": 3, + "total": 4, + "total_tokens": 4 + } +} \ No newline at end of file diff --git a/.snapshots/chat_stream.txt b/.snapshots/chat_stream.txt new file mode 100644 index 0000000..8fec9d4 --- /dev/null +++ b/.snapshots/chat_stream.txt @@ -0,0 +1,95 @@ +{ + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion.chunk" +} +{ + "choices": [ + { + "delta": { + "content": "Hel", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion.chunk" +} +{ + "choices": [ + { + "delta": { + "content": "lo", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion.chunk" +} +{ + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion.chunk" +} +{ + "choices": [ + { + "delta": { + "content": "" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion.chunk" +} +{ + "choices": [], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion.chunk", + "usage": { + "completion": 1, + "completion_tokens": 1, + "prompt": 16, + "prompt_tokens": 16, + "total": 17, + "total_tokens": 17 + } +} +[DONE] \ No newline at end of file diff --git a/.snapshots/image_generation.json b/.snapshots/image_generation.json new file mode 100644 index 0000000..4fc300b --- /dev/null +++ b/.snapshots/image_generation.json @@ -0,0 +1,8 @@ +{ + "created": , + "data": [ + { + "b64_json": "QUJD" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index ead99c6..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, } ``` @@ -507,11 +519,12 @@ return { ### 内置适配器 -`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode`。 +`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` 等头。 @@ -626,4 +639,4 @@ go test -tags luajit ./... ``` 覆盖:配置校验、适配器加载/变换、签名钩子、Gateway 鉴权、SDK 结算、SSE 流式、 -模型路由、多模态透传与 disable_thinking。 +模型路由、多模态透传与 thinking 预算。 diff --git a/README_EN.md b/README_EN.md index 8d7233b..b601221 100644 --- a/README_EN.md +++ b/README_EN.md @@ -487,7 +487,7 @@ Shared helpers: `hmac_sha256_hex(key, data)`, `sha256_hex(data)`, ### Built-in adapters `openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` -`kimicode`. +`kimicode` `qwen38` `sensenova` `trae`. `anthropic`/`gemini`/`ollama` include multimodal conversion (`image_url` → their native format); with `disable_thinking` the `deepseek` diff --git a/docs/design-plugin-kernel-api.md b/docs/design-plugin-kernel-api.md new file mode 100644 index 0000000..86aa39a --- /dev/null +++ b/docs/design-plugin-kernel-api.md @@ -0,0 +1,117 @@ +# 插件可调用内核动作(R4-c)设计 + +> 用户决策(2026-10-11):**全开**(13 个内核动作全部可调)+ **设置页可配权限** + +> **Go 侧 deadline 兜底**。 + +## 1. 要解决的问题 + +用户原话:"现在这个接口不全,可以多给一些接口让 lua 调用内核。" + +现状(`__llmsproxy_adapters`)是我手写的**三个闭包**(install/update/remove), +只能管适配器。内核还有 13 个公开动作(Reload / ResetHealth / AddSource / +RemoveSource / SaveAutoRules / key 增删改 / 列表读取…),插件一个都调不到。 + +而"自动孵化 / 自愈 / 联动"这类插件能力(例如:探测到上游全挂 → 触发重探 + +清冷却;发现额度变化 → 重排 AUTO 链)恰恰需要它们。 + +## 2. 关键认识:Go 调用可以超时,Lua 死循环不能 + +这决定了整个设计,也是用户提"阻塞 + Go 兜底"的原因: + +| 形态 | 能否中断 | 处置方式 | +|---|---|---| +| Lua 里 `while true do end` | **不能**(LuaJIT 限制,已实测三次) | 只能停用插件 | +| Lua 里 `__llmsproxy_kernel.call("reload")` | **能** | Go 侧 `select` + deadline,超时把错误返回给 Lua | + +因为 Lua 只是在等 Go 的返回值,**不占用 Lua 栈的执行**。所以给每次内核调用加 +Go 侧 deadline 是可行的、干净的 —— 不需要"Lua 投递请求、Go 队列执行":那样 +还要定义"超时后队列里积压的命令怎么办",而 deadline 直接返回 `(nil, err)`, +语义闭合。 + +## 3. 依赖方向 + +`internal/lua` **不能** import `internal/core`(方向是 core → lua)。 +按既有先例(`SetOutboundHosts`)注入一个**接口**,由 core 实现: + +```go +// internal/lua/kernel_api.go +type KernelAction struct { + Name string // "reload" / "reset_health" / ... + Title string // 设置页展示用 + Description string + Danger string // ""=只读, "write"=改配置, "danger"=破坏性 + Params []string // 参数名(设置页/文档用) +} + +type KernelAPI interface { + // Actions 列出可调用动作(设置页据此渲染权限开关) + Actions() []KernelAction + // Invoke 执行一个动作,args 为已解码参数。**必须**尊重 ctx 的 deadline。 + Invoke(ctx context.Context, name string, args map[string]any) (any, error) +} +``` + +core 侧实现 `Invoke` 时用**现有的 Core 方法**,本身不持 `p.mu`,因此不会与 +插件锁成环(已核实:所有 `ps.mu` → `p.mu` 都是先释放 `ps.mu`)。 + +## 4. 权限模型 + +复用 `visibilityOverrides` 的**同一套持久化机制**(`.access.json`), +但用独立的键空间:`kernel:` → 逗号分隔的动作名或 `*`。 + +- **默认拒绝**:插件不显式获得授权时,`call` 返回 `(nil, "not permitted")`。 + 理由:全开是"能力存在",不是"默认授予"。一个插件能删密钥这件事必须由 + 运维在设置页点过确认。 +- **`danger` 类必须显式逐个授权**,不接受 `*`。这一条是防止运维图省事写 + 通配符,把"删密钥"和"读状态"等同起来。 +- 插件可以在自己源码里**声明**它想要的动作(`plugin.kernel = {...}`), + 设置页把它显示为"申请",运维批准后才生效。声明不等于授权。 + +## 5. 动作清单(13 个 + 适配器 3 个) + +| 动作 | 危险级 | 对应 Core 方法 | 超时 | +|---|---|---|---| +| `status` | 只读 | 读 AutoSlotStates/Registry | 2s | +| `list_sources` | 只读 | Sources() | 2s | +| `list_models` | 只读 | Registry().ModelList() | 2s | +| `list_adapters` | 只读 | ListAdapters() | 2s | +| `adapter_owner` | 只读 | AdapterOwnerOf | 2s | +| `reload` | write | Reload() | 30s | +| `reset_health` | write | ResetHealth() | 5s | +| `probe_all` | write | Registry().ProbeAll(ctx) | 60s | +| `save_auto_rules` | write | SaveAutoRules() | 10s | +| `save_key_auto` | write | SaveKeyAuto() | 10s | +| `add_source` | write | AddSource() | 30s | +| `remove_source` | danger | RemoveSource() | 30s | +| `create_key` | write | CreateKey() | 10s | +| `update_key` | write | UpdateKey() | 10s | +| `delete_key` | danger | DeleteKey() | 10s | +| `install_adapter` | write | UploadAdapter() | 10s | +| `remove_adapter` | danger | RemoveAdapter() | 30s | +| `set_plugin_enabled` | danger | SetEnabled() | 5s | + +**绝不开放**:`ListKeys()` / `FindKey()` —— 返回**明文密钥**。给插件读密钥等于 +把网关的凭据面交给第三方 Lua。要用 key 相关动作就只用带掩码的形态。 + +## 6. Lua 调用面 + +```lua +-- 探测:拿到可用动作与自己的授权情况 +local acts = __llmsproxy_kernel.actions() +-- 调用:统一入口,返回 (result, err) +local ok, err = __llmsproxy_kernel.call("reset_health", {}) +local st, err = __llmsproxy_kernel.call("status", {}) +``` + +统一入口(而不是每个动作一个全局函数)的理由:动作集合将来会变,而 +`__llmsproxy_kernel` 的签名不变 —— 否则每加一个动作就要动 +`bootPluginState`,而那里正是历史上出过 cgo 崩溃的地方。 + +## 7. 实施顺序 + +1. `internal/lua/kernel_api.go`:接口 + 动作元数据 + 权限检查 + deadline 包装 +2. `internal/lua/plugin_kernel_bridge.go`:Lua 绑定(`actions` / `call`) +3. `internal/core/kernel_actions.go`:实现 `KernelAPI`(core 侧) +4. 权限持久化:复用 `.access.json`,新键空间 +5. 设置页:插件行加"内核动作"列 + 授权编辑 +6. 测试:Go 侧全动作、Lua 侧调用、权限拒绝、deadline 生效、危险动作需显式授权 diff --git a/docs/lua-adapters.md b/docs/lua-adapters.md index c0faa9c..e77d968 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,81 @@ 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 细节。 + +### 入向让出钩子(契约 v2,`serialize_*` 的对偶) + +出向是「统一格式 → 客户端协议」,入向是它的镜像:「客户端协议 → 统一格式」。 +内核为对外端点(如 `POST /v1/messages`)**保留一份内置解析作为兜底(R5)**, +但适配器可以**声明自己服务该端点**并接管解析——那时内核主动让出,客户端协议的 +迭代就完全落在适配器里,不再需要动内核。 + +声明分两步: + +1. `adapter.ingress = "/v1/messages"` —— 声明**认领**哪条对外入站路径。 + 同一条路径被多个适配器认领是配置错误;内核按适配器名字典序确定性选择。 +2. `deserialize_request(raw_body) -> unified_json` —— 把客户端原始 body 解析成 + 统一请求(`UnifiedRequest` 的 JSON),后续调度与 `/v1/chat/completions` 完全一致。 + +规则: + +- 只有声明了该端点且实现了 `deserialize_request` 的适配器会让内核让出。 +- 没有适配器认领时,内核内置解析接管(未配置的部署仍能工作)。 +- 认领了但钩子**执行失败**是硬错误(400),不会静默回落到另一种协议解析—— + 客户端说的正是这个适配器声称支持的协议,悄悄换解析器只会错误调度请求。 + +```lua +-- 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 注入): @@ -219,8 +295,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 +349,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..dce8052 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -301,20 +301,143 @@ 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 重写适配器源码,后续请求自然使用新逻辑(内核重建该适配器 +的池)。**可直接在插件 `.lua` 里调用**: + +```lua +-- 每小时的定时任务:轮换 token 并重写适配器 +function plugin.refresh_token() + local tok = fetch_new_token() -- 插件自己的取 token 逻辑 + local name, err = __llmsproxy_adapters.install( + "myauth", + 'return { name = "myauth", endpoint = "/chat/completions",' .. + ' headers = { ["Authorization"] = "Bearer ' .. tok .. '" } }' + ) + if err then log("error", err) end +end +``` + +| 调用 | 参数 | 返回 | +|---|---|---| +| `__llmsproxy_adapters.install(name, source)` | 适配器名 + Lua 源码 | `(生效名, nil)` 或 `(nil, err)` | +| `__llmsproxy_adapters.update(name, source)` | 同上(只限自己装的) | `(true, nil)` 或 `(nil, err)` | +| `__llmsproxy_adapters.remove(name)` | 适配器名(只限自己装的) | `(true, nil)` 或 `(nil, err)` | + +出错时不抛异常,而是返回 `(nil, err)`——与钩子/HTTP 处理器同一套约定, +一个手滑的插件错误不会带走网关。 + +安全规则(由 Go 侧强制,不是约定): + +- 插件**只能改自己装的**适配器(归属表在 Go 侧记录,调用身份是插件启动时的 + 固定名字,无法冒充)。 +- 插件**不能影子内置适配器**(否则等于篡改发行物);运维仍可自行覆盖。 +- 名字经过路径安全校验(无穿越/绝对路径/空白)。 + +> 这条能力配合 §5.4 的定时任务,就是完整的自动轮换:**没有浏览器、没有人 +> 操作时,插件按时间自己把 token 换掉**。 + --- ## 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 +620,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 | | 必需性 | 核心 | 可选,缺了网关照常跑 | diff --git a/e2e/adapter_takeover_test.go b/e2e/adapter_takeover_test.go new file mode 100644 index 0000000..0f7c071 --- /dev/null +++ b/e2e/adapter_takeover_test.go @@ -0,0 +1,286 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// 本文件端到端验证 R2/R5 的核心主张:**对外服务的 wire 格式由适配器决定, +// 内核只调度**;适配器不声明时内核的 OpenAI 兜底仍然可用。 +// +// 与其他测试的分工: +// - internal/gateway/messages_test.go 在**进程内**验证同一件事(起 core+gateway, +// 不打真二进制)。它跑得快,但绕过了一些只在真实进程里发生的事:Lua 适配器的 +// 磁盘 seed、配置加载、systemd 式启动路径、真 HTTP 栈。 +// - 本文件打**真编译出的二进制**,用一个只说 Anthropic Messages 的假上游, +// 证明整条链路(真进程 → 真 Lua 适配器 → 真上游 → 适配器序列化 → 客户端) +// 确实成立,而不是只在进程内装配下成立。 +// +// 这是用户明确问到的"适配器接手对外服务的部分"的验收点。 + +// anthropicMockUpstream 是只懂 Anthropic Messages 协议的假上游。 +// +// 它会对**不符形状的请求主动报错**,而不是宽容接受 —— 宽容的 mock 会让 +// "内核把 OpenAI 形状发给了 Anthropic 上游"这种错误测试通过。 +func anthropicMockUpstream(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Anthropic 的请求路径必须是 /messages + if !strings.HasSuffix(r.URL.Path, "/messages") { + t.Errorf("请求打到了非 Anthropic 路径: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + body, _ := io.ReadAll(r.Body) + var probe map[string]interface{} + if err := json.Unmarshal(body, &probe); err != nil { + t.Errorf("上游收到非 JSON 请求体: %s", body) + w.WriteHeader(http.StatusBadRequest) + return + } + // Anthropic 形状的硬性要求:messages 是数组;不能有 OpenAI 的 choices 概念 + if _, ok := probe["messages"]; !ok { + t.Errorf("上游请求体缺 messages: %s", body) + } + if _, ok := probe["max_tokens"]; !ok { + t.Errorf("Anthropic 要求 max_tokens,请求体缺此项: %s", body) + } + // 反向断言:OpenAI 兜底字段不该出现在 Anthropic 上游请求里 + for _, k := range []string{"choices", "chat_completions"} { + if _, bad := probe[k]; bad { + t.Errorf("Anthropic 上游请求体混入了 OpenAI 字段 %q: %s", k, body) + } + } + + if stream, _ := probe["stream"].(bool); stream { + w.Header().Set("Content-Type", "text/event-stream") + fl, _ := w.(http.Flusher) + frames := []string{ + `event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_e2e","type":"message","role":"assistant","model":"claude-e2e","content":[],"stop_reason":null,"usage":{"input_tokens":3,"output_tokens":0}}}`, + `event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}`, + `event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`, + `event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}`, + `event: message_stop` + "\n" + `data: {"type":"message_stop"}`, + } + for _, f := range frames { + fmt.Fprint(w, f+"\n\n") + if fl != nil { + fl.Flush() + } + } + return + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"id":"msg_e2e","type":"message","role":"assistant","model":"claude-e2e",`+ + `"content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn",`+ + `"usage":{"input_tokens":3,"output_tokens":1}}`) + })) + t.Cleanup(srv.Close) + return srv +} + +// writeAnthropicConfig 写一份把源挂到 anthropic 适配器上的配置。 +func writeAnthropicConfig(t *testing.T, dir, listenAddr, upstreamURL string) string { + t.Helper() + cfg := "listen: " + listenAddr + "\n" + + "gateway_keys:\n - sk-e2e-0001\n" + + "default_model: claude-e2e\n" + + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + + "sources:\n" + + " - name: anth\n" + + " base_url: " + upstreamURL + "\n" + + " adapter: anthropic\n" + + " api_key: test-key\n" + + " max_concurrent: 32\n" + + " models:\n" + + " - id: claude-e2e\n" + + " kind: chat\n" + + " priority: 100\n" + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(cfg), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +// TestAdapterTakeoverNonStream:非流式,Anthropic 客户端形状进、出。 +func TestAdapterTakeoverNonStream(t *testing.T) { + up := anthropicMockUpstream(t) + dir := t.TempDir() + addr := mustAddr(t) + cfg := writeAnthropicConfig(t, dir, addr, up.URL) + bin := buildBinary(t) + g := startGateway(t, bin, addr, cfg) + + resp, body := g.do("POST", "/v1/messages", + `{"model":"claude-e2e","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`, true) + if resp == nil || resp.StatusCode != 200 { + t.Fatalf("status=%v body=%q", statusOf(resp), body) + } + // 出向必须是 Anthropic Messages 形状,不能是 OpenAI choices 形状 + if strings.Contains(body, `"choices"`) { + t.Errorf("Anthropic 响应里混入了 OpenAI choices 外壳: %s", body) + } + var out map[string]interface{} + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("响应不是 JSON: %v: %s", err, body) + } + if out["type"] != "message" { + t.Errorf(`响应 type = %v, 期望 "message"(Anthropic 形状): %s`, out["type"], body) + } + // 文本内容必须能取出来 —— 这是"内容确实穿过适配器回来了"的证据 + if !strings.Contains(body, "pong") { + t.Errorf("响应里没有上游返回的文本: %s", body) + } + if _, ok := out["usage"]; !ok { + t.Errorf("Anthropic 响应应带 usage: %s", body) + } +} + +// TestAdapterTakeoverStream:流式也要是 Anthropic SSE 序列, +// 且**不得**出现 OpenAI 的 [DONE] 终止标记(R2 的"不混合")。 +func TestAdapterTakeoverStream(t *testing.T) { + up := anthropicMockUpstream(t) + dir := t.TempDir() + addr := mustAddr(t) + cfg := writeAnthropicConfig(t, dir, addr, up.URL) + bin := buildBinary(t) + g := startGateway(t, bin, addr, cfg) + + resp, body := g.do("POST", "/v1/messages", + `{"model":"claude-e2e","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hi"}]}`, true) + if resp == nil || resp.StatusCode != 200 { + t.Fatalf("stream status=%v body=%q", statusOf(resp), body) + } + if !strings.Contains(body, "message_start") { + t.Errorf("Anthropic 流应以 message_start 开头: %s", body) + } + if !strings.Contains(body, "message_stop") { + t.Errorf("Anthropic 流应以 message_stop 收尾: %s", body) + } + if strings.Contains(body, "[DONE]") { + t.Errorf("Anthropic 流里出现了 OpenAI 的 [DONE]: %s", body) + } + if strings.Contains(body, "chat.completion.chunk") { + t.Errorf("流里混入 OpenAI chunk 形状: %s", body) + } + if !strings.Contains(body, "pong") { + t.Errorf("流里没有上游文本: %s", body) + } +} + +// TestAdapterTakeoverStress 是对外服务路径的真实压力测试: +// 并发打真二进制,混合流式/非流式,验证无 5xx、上游命中数正确、 +// 且每个响应都是完整合法的 Anthropic 形状(适配器在并发下不串味)。 +func TestAdapterTakeoverStress(t *testing.T) { + up := anthropicMockUpstream(t) + dir := t.TempDir() + addr := mustAddr(t) + cfg := writeAnthropicConfig(t, dir, addr, up.URL) + bin := buildBinary(t) + g := startGateway(t, bin, addr, cfg) + + // max_concurrent 配成 32,让 96 个请求基本都能真正流过适配器 —— 这个测试 + // 要压的是**并发下序列化是否串味**,不是内核的容量拒绝策略。真的撞上上限 + // 时收到的 429 也是合法背压(见下方断言),不算失败。 + const ( + workers = 12 + perWorker = 8 + ) + + var ( + wg sync.WaitGroup + mu sync.Mutex + ok int + shed int + failed []string + streams int + ) + start := make(chan struct{}) + + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + <-start + for i := 0; i < perWorker; i++ { + isStream := (w+i)%2 == 0 + var payload string + if isStream { + payload = `{"model":"claude-e2e","max_tokens":32,"stream":true,"messages":[{"role":"user","content":"hi"}]}` + } else { + payload = `{"model":"claude-e2e","max_tokens":32,"messages":[{"role":"user","content":"hi"}]}` + } + resp, body := g.do("POST", "/v1/messages", payload, true) + mu.Lock() + switch { + case resp == nil: + failed = append(failed, "nil response") + // 429 = 内核的容量背压(provider busy)。它是**正确**行为, + // 不是缺陷:并发超限时就该如实拒绝,而不是排队或伪造响应。 + // 只要求它是结构良好的错误,不要求 200。 + case resp.StatusCode == http.StatusTooManyRequests: + if !strings.Contains(body, "busy") && !strings.Contains(body, "error") { + failed = append(failed, "429 不是结构化错误: "+body) + } else { + shed++ + } + // 5xx 才是真失败 —— 那是内核或适配器崩了。 + case resp.StatusCode >= 500: + failed = append(failed, fmt.Sprintf("5xx status=%d body=%.120s", resp.StatusCode, body)) + case resp.StatusCode != 200: + failed = append(failed, fmt.Sprintf("status=%d body=%.120s", resp.StatusCode, body)) + case !strings.Contains(body, "pong"): + // 200 但内容不对:说明并发下适配器把响应串味了 + failed = append(failed, "missing upstream text: "+body) + case isStream && !strings.Contains(body, "message_stop"): + failed = append(failed, "stream missing message_stop") + case !isStream && strings.Contains(body, `"choices"`): + failed = append(failed, "openai shell leaked: "+body) + default: + ok++ + if isStream { + streams++ + } + } + mu.Unlock() + } + }(w) + } + close(start) + wg.Wait() + + if len(failed) > 0 { + t.Fatalf("%d/%d 请求失败,前 5 例:\n%s", len(failed), workers*perWorker, + strings.Join(failed[:min(5, len(failed))], "\n")) + } + // 至少大部分请求必须真正流过 200 —— 全被背压拒掉说明并发上限配得太低, + // 那这个压力测试就没压到适配器并发。 + if ok < workers*perWorker/2 { + t.Fatalf("只有 %d/%d 请求真正成功,压力未触及适配器并发路径", + ok, workers*perWorker) + } + t.Logf("成功 %d(其中流式 %d),背压 429 %d —— 无 5xx、无 OpenAI 形状泄漏", + ok, streams, shed) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +var _ = time.Second diff --git a/e2e/channels_device_e2e_test.go b/e2e/channels_device_e2e_test.go new file mode 100644 index 0000000..0b12c83 --- /dev/null +++ b/e2e/channels_device_e2e_test.go @@ -0,0 +1,170 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +// TestChannelsDevicePollE2E 用**真二进制**跑通 channels 插件的 device-poll 登录: +// +// 发起登录(start_login) → 页面轮询(poll_login) 推进设备码换 token +// → 凭据入保险库 → add_source 把平台接成上游源 → /v1/models 里出现其模型 +// +// 这是对参考仓库 dsh-our-free-model「登录→领额度→接入源」形态的端到端对齐: +// 行为(HTTP 决策分支、会话推进、最终可路由)在真进程里验证,而不是单元桩。 +// +// 假上游模拟 MiniMax 的两个端点:设备码申请 + token 轮询(脚本化逐次响应)。 +func TestChannelsDevicePollE2E(t *testing.T) { + // ---- 假平台上游(loopback,白名单天然放行)---- + var tokenHits int32 + var tokenScript atomic.Value // []scriptStep + tokenScript.Store([]scriptStep{}) + plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth2/device/code": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"device_code":"DEV-E2E","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`) + case "/oauth2/token": + i := int(atomic.AddInt32(&tokenHits, 1)) - 1 + script := tokenScript.Load().([]scriptStep) + w.Header().Set("Content-Type", "application/json") + if i >= len(script) { + w.WriteHeader(500) + fmt.Fprint(w, `{"error":"unexpected"}`) + return + } + if script[i].code != 0 && script[i].code != 200 { + w.WriteHeader(script[i].code) + } + fmt.Fprint(w, script[i].body) + case "/v1/models": + // 接入源后内核会探测可达;回一个 OpenAI 形状的模型列表 + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"object":"list","data":[{"id":"MiniMax-M3","object":"model"}]}`) + case "/v1/chat/completions": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + default: + w.WriteHeader(404) + } + })) + defer plat.Close() + + // ---- 真二进制 + 配置(带 plugin_dir,让 bundled channels 落地 + 授权可持久化)---- + dir := t.TempDir() + addr := mustAddr(t) + pluginDir := filepath.Join(dir, "plugins") // 不预建:SeedBundled 见空目录才落地内置插件 + cfg := "listen: " + addr + "\n" + + "gateway_keys:\n - sk-e2e-0001\n" + + "default_model: none\n" + + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + + "plugin_dir: " + pluginDir + "\n" + + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + + "sources: []\n" + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + bin := buildBinary(t) + g := startGatewayNoModels(t, bin, addr, cfgPath) + + call := func(method, path, body string) (int, map[string]interface{}) { + resp, raw := g.do(method, path, body, true) + var m map[string]interface{} + _ = json.Unmarshal([]byte(raw), &m) + return statusOf(resp), m + } + + // 1) 授权内核动作(运维批准 add_source 等)+ 把 minimax 端点指到假上游。 + st, m := call("PUT", "/api/plugins/channels/kernel", + `{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`) + if st != 200 { + t.Fatalf("授权内核动作失败: status=%d body=%v", st, m) + } + st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf( + `{"platform":"minimax","base_url":%q,"device":{"code_url":%q,"token_url":%q}}`, + plat.URL+"/v1", plat.URL+"/oauth2/device/code", plat.URL+"/oauth2/token")) + if st != 200 { + t.Fatalf("set_endpoints 失败: status=%d body=%v", st, m) + } + + // 2) 发起 device-poll 登录:拿到会话 + user_code。 + st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"minimax"}`) + if st != 200 { + t.Fatalf("start_login status=%d body=%v", st, m) + } + sess, _ := m["session"].(map[string]interface{}) + if sess == nil || sess["user_code"] != "WXYZ-1234" { + t.Fatalf("start_login 未透传 user_code: %v", m) + } + sid, _ := sess["id"].(string) + if sid == "" { + t.Fatal("无会话 id") + } + + // 3) 第一次轮询:上游 pending → 会话仍 pending。 + tokenScript.Store([]scriptStep{{code: 400, body: `{"error":"authorization_pending"}`}}) + atomic.StoreInt32(&tokenHits, 0) + st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "") + if m["state"] != "pending" { + t.Fatalf("轮询1 应 pending, 实得 %v", m) + } + + // 4) 第二次轮询:上游发 access_token → 会话 completed,凭据入库 + 接入源。 + tokenScript.Store([]scriptStep{{code: 200, body: `{"access_token":"MM-E2E-TOKEN","refresh_token":"MM-RF","nickname":"e2e-user"}`}}) + atomic.StoreInt32(&tokenHits, 0) + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "") + if m["state"] == "completed" || m["state"] == "failed" { + break + } + time.Sleep(150 * time.Millisecond) + } + if m["state"] != "completed" { + t.Fatalf("轮询2 应 completed, 实得 %v", m) + } + if m["account"] != "e2e-user" { + t.Fatalf("account 应取 nickname, 实得 %v", m) + } + + // 5) 接入源的终局证据:/v1/models 里出现 channels-minimax 的模型。 + deadline = time.Now().Add(5 * time.Second) + var modelsBody string + for time.Now().Before(deadline) { + resp, raw := g.do("GET", "/v1/models", "", true) + modelsBody = raw + if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "MiniMax-M3") { + break + } + time.Sleep(150 * time.Millisecond) + } + if !strings.Contains(modelsBody, "MiniMax-M3") { + t.Fatalf("接入源后 /v1/models 未出现 MiniMax-M3: %s", modelsBody) + } +} + +type scriptStep struct { + code int + body string +} + +// startGatewayNoModels 与 startGateway 相同,但就绪探针不要求 /v1/models 返回 +// 非空:本用例启动时没有任何源,/v1/models 是空列表(200 即就绪)。 +func startGatewayNoModels(t *testing.T, bin, listen, cfgPath string) *gatewayUnderTest { + t.Helper() + return startGateway(t, bin, listen, cfgPath) +} + +var _ = net.Listen +var _ = io.Discard diff --git a/e2e/channels_flows_e2e_test.go b/e2e/channels_flows_e2e_test.go new file mode 100644 index 0000000..8fd4458 --- /dev/null +++ b/e2e/channels_flows_e2e_test.go @@ -0,0 +1,200 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +// TestChannelsFlowsE2E 用真二进制补齐另外三条流程的端到端: +// +// Cline(WorkOS device-poll 两步:authenticate → register 换最终凭据) +// paste(opencode:直接 complete_login 贴 API key) +// manual(qoder:start_login 返回 manual_hint,再凭据完成) +// +// 终局证据与 TestChannelsDevicePollE2E 同一标准:/v1/models 出现该平台模型。 +func TestChannelsFlowsE2E(t *testing.T) { + var authHits, regHits int32 + plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + // ---- Cline(WorkOS)---- + case "/user_management/authorize/device": + fmt.Fprint(w, `{"device_code":"CD-E2E","user_code":"CLNE-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`) + case "/user_management/authenticate": + if atomic.AddInt32(&authHits, 1) == 1 { + w.WriteHeader(400) + fmt.Fprint(w, `{"error":"authorization_pending"}`) + return + } + fmt.Fprint(w, `{"access_token":"WO-E2E-AT","refresh_token":"WO-E2E-RT"}`) + case "/api/v1/auth/register": + atomic.AddInt32(®Hits, 1) + fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-E2E-AT","refreshToken":"CLINE-E2E-RT","userInfo":{"email":"dev@example.com"}}}`) + // ---- 上游推理面(opencode/qoder/cline 的 base_url 都指到这里)---- + case "/v1/models": + fmt.Fprint(w, `{"object":"list","data":[{"id":"big-pickle","object":"model"},{"id":"qoder-default","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`) + case "/v1/chat/completions": + fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + default: + w.WriteHeader(404) + } + })) + defer plat.Close() + + dir := t.TempDir() + addr := mustAddr(t) + pluginDir := filepath.Join(dir, "plugins") + cfg := "listen: " + addr + "\n" + + "gateway_keys:\n - sk-e2e-0001\n" + + "default_model: none\n" + + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + + "plugin_dir: " + pluginDir + "\n" + + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + + "sources: []\n" + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + bin := buildBinary(t) + g := startGatewayNoModels(t, bin, addr, cfgPath) + + call := func(method, path, body string) (int, map[string]interface{}) { + resp, raw := g.do(method, path, body, true) + var m map[string]interface{} + _ = json.Unmarshal([]byte(raw), &m) + return statusOf(resp), m + } + + st, m := call("PUT", "/api/plugins/channels/kernel", + `{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`) + if st != 200 { + t.Fatalf("授权内核动作失败: status=%d body=%v", st, m) + } + + // ---------- ① Cline:WorkOS 设备码两步 ---------- + st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf( + `{"platform":"cline","base_url":%q,"device":{"code_url":%q,"token_url":%q},"register":{"url":%q}}`, + plat.URL, plat.URL+"/user_management/authorize/device", + plat.URL+"/user_management/authenticate", plat.URL+"/api/v1/auth/register")) + if st != 200 { + t.Fatalf("set_endpoints(cline) 失败: status=%d body=%v", st, m) + } + st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"cline"}`) + if st != 200 { + t.Fatalf("cline start_login status=%d body=%v", st, m) + } + sess, _ := m["session"].(map[string]interface{}) + if sess == nil || sess["user_code"] != "CLNE-1234" { + t.Fatalf("cline start_login 未透传 user_code: %v", m) + } + sid, _ := sess["id"].(string) + + // 第一拍 pending(authorization_pending),第二拍 completed + atomic.StoreInt32(&authHits, 0) + st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "") + if m["state"] != "pending" { + t.Fatalf("cline 轮询1 应 pending, 实得 %v", m) + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "") + if m["state"] == "completed" || m["state"] == "failed" { + break + } + time.Sleep(150 * time.Millisecond) + } + if m["state"] != "completed" { + t.Fatalf("cline 轮询2 应 completed, 实得 %v", m) + } + if m["account"] != "dev@example.com" { + t.Fatalf("cline account 应取 register userInfo.email, 实得 %v", m) + } + if atomic.LoadInt32(®Hits) != 1 { + t.Fatalf("register 应恰好调用一次, 实得 %d", regHits) + } + // 凭据必须是 register 的最终 token,不是 WorkOS token + var modelsBody string + deadline = time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + resp, raw := g.do("GET", "/v1/models", "", true) + modelsBody = raw + if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "stealth/space-bunny-alpha") { + break + } + time.Sleep(150 * time.Millisecond) + } + if !strings.Contains(modelsBody, "stealth/space-bunny-alpha") { + t.Fatalf("cline 接入后 /v1/models 未出现其模型: %s", modelsBody) + } + + // ---------- ② paste(opencode):complete 直接贴 key ---------- + st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf( + `{"platform":"opencode","base_url":%q}`, plat.URL)) + if st != 200 { + t.Fatalf("set_endpoints(opencode) 失败: %v", m) + } + st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"opencode"}`) + if st != 200 { + t.Fatalf("opencode start_login status=%d", st) + } + if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "paste" { + t.Fatalf("opencode 会话 flow 应 paste: %v", m) + } + st, m = call("POST", "/api/plugins/http/channels/login/complete", + `{"platform":"opencode","credential":"OC-E2E-KEY"}`) + if st != 200 || m["ok"] != true { + t.Fatalf("opencode complete 失败: status=%d body=%v", st, m) + } + deadline = time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + resp, raw := g.do("GET", "/v1/models", "", true) + modelsBody = raw + if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "big-pickle") { + break + } + time.Sleep(150 * time.Millisecond) + } + if !strings.Contains(modelsBody, "big-pickle") { + t.Fatalf("opencode 接入后 /v1/models 未出现 big-pickle: %s", modelsBody) + } + + // ---------- ③ manual(qoder):hint 明确 + 凭据完成 ---------- + st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf( + `{"platform":"qoder","base_url":%q}`, plat.URL)) + if st != 200 { + t.Fatalf("set_endpoints(qoder) 失败: %v", m) + } + st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"qoder"}`) + if st != 200 { + t.Fatalf("qoder start_login status=%d", st) + } + hint, _ := m["manual_hint"].(string) + if !strings.Contains(hint, "WASM") { + t.Fatalf("qoder manual_hint 应说明 WASM 不可自动, 实得 %q", hint) + } + st, m = call("POST", "/api/plugins/http/channels/login/complete", + `{"platform":"qoder","credential":"QD-E2E-KEY"}`) + if st != 200 || m["ok"] != true { + t.Fatalf("qoder complete 失败: status=%d body=%v", st, m) + } + deadline = time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + resp, raw := g.do("GET", "/v1/models", "", true) + modelsBody = raw + if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qoder-default") { + break + } + time.Sleep(150 * time.Millisecond) + } + if !strings.Contains(modelsBody, "qoder-default") { + t.Fatalf("qoder 接入后 /v1/models 未出现 qoder-default: %s", modelsBody) + } +} diff --git a/e2e/conflict_arbitration_e2e_test.go b/e2e/conflict_arbitration_e2e_test.go new file mode 100644 index 0000000..03d0310 --- /dev/null +++ b/e2e/conflict_arbitration_e2e_test.go @@ -0,0 +1,261 @@ +package e2e + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeE2EPlugin 落一个插件文件到 e2e 的 plugin_dir(SeedBundled 见空目录才 +// 落地内置插件,所以先放自定义插件再启动网关)。 +func writeE2EPlugin(t *testing.T, dir, name, code string) { + t.Helper() + p := filepath.Join(dir, "plugins", name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(code), 0o644); err != nil { + t.Fatal(err) + } +} + +// e2eConfig 返回一份最小 e2e 配置文本(含 plugin_dir)。 +func e2eConfig(dir, listen string) string { + return "listen: " + listen + "\n" + + "gateway_keys:\n - sk-e2e-0001\n" + + "default_model: AUTO\n" + + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + + "plugin_dir: " + filepath.Join(dir, "plugins") + "\n" + + "sources: []\n" +} + +// TestConflictArbitrationE2E(用户要求:服务路径冲突不崩溃不拒启 + WebAPI 裁决) +// +// 真二进制全链路: +// 1. 两个插件声明同一条 /api/plugins/http/ 路径 → 两插件都 loaded,启动正常 +// 2. 请求该路径 → 409,错误信息点名两个候选插件(不静默选边) +// 3. GET /api/plugins/conflicts → 列出冲突(路径 + 候选 + 未裁决) +// 4. POST /api/plugins/conflicts 裁决 → 持久化 +// 5. 再请求该路径 → 200,赢家应答({"from":"B"}) +// 6. /v1/models 等既有路由全程不受影响(旧通路无回归) +func TestConflictArbitrationE2E(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + addr := mustAddr(t) + + writeE2EPlugin(t, dir, "plugA.lua", ` +local P = {} +P.name = "plugA" +P.version = "1" +P.exports = {} +P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } } +function P.h(req) return { status = 200, json = { from = "A" } } end +return P +`) + writeE2EPlugin(t, dir, "plugB.lua", ` +local P = {} +P.name = "plugB" +P.version = "1" +P.exports = {} +P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } } +function P.h(req) return { status = 200, json = { from = "B" } } end +return P +`) + + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil { + t.Fatal(err) + } + g := startGateway(t, bin, addr, cfgPath) + + // ① 两个插件都加载成功(冲突不拒载) + resp, body := g.do(http.MethodGet, "/api/plugins", "", true) + if resp.StatusCode != 200 { + t.Fatalf("GET /api/plugins: %d %s", resp.StatusCode, body) + } + var list struct { + Plugins []struct { + Name string `json:"name"` + Loaded bool `json:"loaded"` + } `json:"plugins"` + } + if err := json.Unmarshal([]byte(body), &list); err != nil { + t.Fatal(err) + } + loaded := map[string]bool{} + for _, p := range list.Plugins { + loaded[p.Name] = p.Loaded + } + if !loaded["plugA"] || !loaded["plugB"] { + t.Fatalf("冲突的两个插件都应 loaded: %v", loaded) + } + + // ② 请求冲突路径 → 409 + 点名候选(不静默选边) + resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("未裁决冲突路径应 409, 实得 %d (%s)", resp.StatusCode, body) + } + if !strings.Contains(body, "plugA") || !strings.Contains(body, "plugB") { + t.Fatalf("409 应点名两个候选: %s", body) + } + + // ③ 冲突列表可见 + resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true) + if resp.StatusCode != 200 { + t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body) + } + var cl struct { + Routes []struct { + Path string `json:"path"` + Candidates []string `json:"candidates"` + Resolved string `json:"resolved"` + } `json:"routes"` + } + if err := json.Unmarshal([]byte(body), &cl); err != nil { + t.Fatal(err) + } + if len(cl.Routes) != 1 || cl.Routes[0].Path != "contested" || cl.Routes[0].Resolved != "" { + t.Fatalf("冲突形态不对: %+v", cl) + } + + // ④ 裁决 plugB 赢 + arbit, _ := json.Marshal(map[string]string{ + "kind": "route", "path": "contested", "winner": "plugB", + }) + resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true) + if resp.StatusCode != 200 { + t.Fatalf("POST conflicts 裁决: %d %s", resp.StatusCode, body) + } + + // ⑤ 再请求 → 200 且赢家应答 + resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true) + if resp.StatusCode != 200 { + t.Fatalf("裁决后请求冲突路径应 200, 实得 %d (%s)", resp.StatusCode, body) + } + if !strings.Contains(body, `"from":"B"`) { + t.Fatalf("赢家 plugB 应答, 实得 %s", body) + } + + // ⑥ 旧通路无回归:/v1/models 正常 + resp, _ = g.do(http.MethodGet, "/v1/models", "", true) + if resp.StatusCode != 200 { + t.Fatalf("旧通路 /v1/models 应 200, 实得 %d", resp.StatusCode) + } +} + +// TestAdapterConflictE2E:插件装与内置同名适配器 → 挂起(现任保留), +// /api/plugins/conflicts 列出,裁决 candidate 后插件版生效。 +func TestAdapterConflictE2E(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + addr := mustAddr(t) + + writeE2EPlugin(t, dir, "shadow.lua", ` +local P = {} +P.name = "shadow" +P.version = "1" +P.exports = { "install" } +function P.install(args) + local src = 'local a={} a.name="openai" a.version="SHADOW_V1" a.endpoint="/x" return a' + local name, err = __llmsproxy_adapters.install("openai", src) + return { name = name, err = err } +end +return P +`) + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil { + t.Fatal(err) + } + g := startGateway(t, bin, addr, cfgPath) + + // ① 安装 → 返回 err(pending 语义),内置版本不变 + resp, body := g.do(http.MethodPost, "/api/plugins/shadow/call/install", "{}", true) + if resp.StatusCode != 200 { + t.Fatalf("call install: %d %s", resp.StatusCode, body) + } + // Call 端点把插件返回值包在 {"result": …} 里;挂起语义的断言在 result 层做。 + var wrapped struct { + Result struct { + Name *string `json:"name"` + Err string `json:"err"` + } `json:"result"` + } + if err := json.Unmarshal([]byte(body), &wrapped); err != nil { + t.Fatal(err) + } + out := wrapped.Result + if out.Name != nil { + t.Fatalf("挂起时不得返回生效名: %s", body) + } + if !strings.Contains(out.Err, "openai") || !strings.Contains(out.Err, "conflict") { + t.Fatalf("错误应点名冲突: %q", out.Err) + } + + // ② 冲突列表可见 + resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true) + if resp.StatusCode != 200 { + t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body) + } + var cl struct { + Adapters []struct { + Name string `json:"name"` + Candidate string `json:"candidate"` + } `json:"adapters"` + } + if err := json.Unmarshal([]byte(body), &cl); err != nil { + t.Fatal(err) + } + if len(cl.Adapters) != 1 || cl.Adapters[0].Name != "openai" || cl.Adapters[0].Candidate != "shadow" { + t.Fatalf("冲突形态不对: %+v", cl) + } + + // ③ 适配器列表里 openai 仍是内置版本(现任继续服务,不被静默覆盖) + assertOpenAIVersion := func(want string, msg string) { + t.Helper() + resp, body := g.do(http.MethodGet, "/api/adapters", "", true) + if resp.StatusCode != 200 { + t.Fatalf("GET /api/adapters: %d %s", resp.StatusCode, body) + } + var al struct { + Adapters []struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"adapters"` + } + if err := json.Unmarshal([]byte(body), &al); err != nil { + t.Fatal(err) + } + for _, a := range al.Adapters { + if a.Name == "openai" { + if a.Version != want { + t.Fatalf("%s: openai 版本应 %q, 实得 %q", msg, want, a.Version) + } + return + } + } + t.Fatalf("%s: openai 适配器不存在: %s", msg, body) + } + assertOpenAIVersion("2.0.0", "现任内置必须继续服务(不被静默覆盖)") + + // ④ 裁决 candidate → 插件版生效 + arbit, _ := json.Marshal(map[string]string{ + "kind": "adapter", "name": "openai", "winner": "candidate", + }) + if resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true); resp.StatusCode != 200 { + t.Fatalf("裁决 candidate: %d %s", resp.StatusCode, body) + } + + // ⑤ 适配器版本已切换 + 冲突清空 + assertOpenAIVersion("SHADOW_V1", "裁决后插件版应生效") + resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true) + if resp.StatusCode != 200 { + t.Fatal(resp.StatusCode) + } + if strings.Contains(body, `"name":"openai"`) && strings.Contains(body, `"adapters":[{`) { + t.Fatalf("裁决后冲突应清除: %s", body) + } +} diff --git a/e2e/wire_golden_test.go b/e2e/wire_golden_test.go new file mode 100644 index 0000000..3945be8 --- /dev/null +++ b/e2e/wire_golden_test.go @@ -0,0 +1,192 @@ +package e2e + +import ( + "bufio" + "encoding/json" + "io" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// mustAddr 选一个空闲端口并立即释放(与 e2e_test.go 的做法一致)。 +func mustAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("pick port: %v", err) + } + addr := l.Addr().String() + l.Close() + return addr +} + +// 本文件建立**字节级**对外输出基线,供统一线格式 v2(对外序列化移交适配器) +// 做回归比对。 +// +// 为什么必须有它:Phase 2 要把 gateway/chat.go 里硬编码的 OpenAI wire 结构与 +// writeChatCompletion/pumpStream 的 JSON 拼装移交给适配器 serializer。现有 e2e +// 只用 strings.Contains 做宽松断言("包含 Hel"),无法证明"输出字节未变"—— +// 字段集、omitempty 裁剪、usage 零值处理都可能静默漂移,而客户端会因此看到 +// 不同的 wire 形状。golden 把当前输出钉死,改动前后逐字节比对。 +// +// 更新方式:GOLDEN_UPDATE=1 go test -tags luajit ./e2e/ -run TestWireGolden +// 更新后必须人工审阅 diff —— golden 是契约,不是快照垃圾桶。 + +// volatile 命中每次请求都变的字段,比对前替换为占位符。 +// 允许冒号后的空白:golden 是 pretty-print 过的(`"created": 123`)。 +var volatile = []*regexp.Regexp{ + regexp.MustCompile(`"id":\s*"[^"]*"`), + regexp.MustCompile(`"created":\s*\d+`), +} + +func scrub(s string) string { + s = volatile[0].ReplaceAllString(s, `"id": ""`) + return volatile[1].ReplaceAllString(s, `"created": `) +} + +func compareGolden(t *testing.T, name, got string) { + t.Helper() + got = scrub(got) + path := filepath.Join("..", ".snapshots", name) + if os.Getenv("GOLDEN_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("golden updated: %s", path) + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("golden missing (%s): %v\nrun GOLDEN_UPDATE=1 go test -tags luajit ./e2e/ -run TestWireGolden to create", path, err) + } + if got != string(want) { + t.Errorf("对外输出与 golden 漂移 (%s)\n--- want ---\n%s\n--- got ---\n%s", + name, string(want), got) + } +} + +// prettyJSON 缩进输出。走 map 会排序键,从而让「字段集」成为唯一被比较的东西, +// 忽略 Go struct 声明顺序(那不是对外契约的一部分)。 +func prettyJSON(t *testing.T, raw string) string { + t.Helper() + var v interface{} + if err := json.Unmarshal([]byte(raw), &v); err != nil { + return raw + } + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + return raw + } + return string(out) +} + +// TestWireGoldenNonStreamChat 钉死非流式 /v1/chat/completions 的字段集。 +func TestWireGoldenNonStreamChat(t *testing.T) { + up := newMockUpstream(t) + dir := t.TempDir() + addr := mustAddr(t) + g := startGateway(t, buildBinary(t), addr, writeConfig(t, dir, addr, map[string]*mockUpstream{"good": up})) + + resp, body := g.do("POST", "/v1/chat/completions", + `{"model":"good/good-m","messages":[{"role":"user","content":"hi"}]}`, true) + + // 语义:OpenAI 客户端依赖这些顶层字段 + var obj map[string]interface{} + if err := json.Unmarshal([]byte(body), &obj); err != nil { + t.Fatalf("not JSON: %v (%s)", err, body) + } + for _, k := range []string{"id", "object", "created", "model", "choices", "usage"} { + if _, ok := obj[k]; !ok { + t.Errorf("非流式响应缺少 OpenAI 必需字段 %q: %s", k, body) + } + } + if obj["object"] != "chat.completion" { + t.Errorf("object = %v, want chat.completion", obj["object"]) + } + if resp.StatusCode != 200 { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + + compareGolden(t, "chat_nonstream.json", prettyJSON(t, body)) +} + +// TestWireGoldenStreamChat 钉死流式 SSE 的帧形状与终止序列。 +func TestWireGoldenStreamChat(t *testing.T) { + up := newMockUpstream(t) + dir := t.TempDir() + addr := mustAddr(t) + g := startGateway(t, buildBinary(t), addr, writeConfig(t, dir, addr, map[string]*mockUpstream{"good": up})) + + resp, _ := doStream(t, g, `{"model":"good/good-m","stream":true,"messages":[{"role":"user","content":"hi"}]}`) + defer resp.Body.Close() + + var frames []string + sc := bufio.NewScanner(resp.Body) + for sc.Scan() { + line := sc.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + payload := strings.TrimPrefix(line, "data: ") + if payload == "[DONE]" { + frames = append(frames, "[DONE]") + continue + } + frames = append(frames, prettyJSON(t, payload)) + } + if len(frames) < 2 { + t.Fatalf("期望多帧 SSE,实得 %d: %v", len(frames), frames) + } + if frames[len(frames)-1] != "[DONE]" { + t.Errorf("末帧 = %q, want [DONE]", frames[len(frames)-1]) + } + // 首帧必须是 role 前导:OpenAI 客户端靠它建立 assistant 消息 + if !strings.Contains(frames[0], `"role"`) { + t.Errorf("首个 SSE 帧必须携带 role 前导,实得: %s", frames[0]) + } + + compareGolden(t, "chat_stream.txt", strings.Join(frames, "\n")) +} + +// TestWireGoldenImage 钉死 /v1/images/generations 的响应形状。 +func TestWireGoldenImage(t *testing.T) { + up := newMockUpstream(t) + dir := t.TempDir() + addr := mustAddr(t) + g := startGateway(t, buildBinary(t), addr, writeConfig(t, dir, addr, map[string]*mockUpstream{"image": up})) + + resp, body := g.do("POST", "/v1/images/generations", `{"model":"imagegen/flux-1","prompt":"a cat"}`, true) + if resp.StatusCode != 200 { + t.Fatalf("image status = %d: %s", resp.StatusCode, body) + } + if !strings.Contains(body, "QUJD") { + t.Fatalf("生图响应丢失了载荷: %s", body) + } + compareGolden(t, "image_generation.json", prettyJSON(t, body)) +} + +// doStream 发流式请求并返回未读完的响应(调用方负责逐行读 SSE)。 +func doStream(t *testing.T, g *gatewayUnderTest, body string) (*http.Response, error) { + t.Helper() + req, err := http.NewRequest("POST", "http://"+g.addr+"/v1/chat/completions", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+g.key) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("stream request: %v", err) + } + return resp, nil +} + +var _ = io.Discard diff --git a/internal/config/config.go b/internal/config/config.go index c173229..d80a201 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,15 +73,20 @@ type Model struct { // Source describes a single upstream LLM provider. type Source struct { - Name string `yaml:"name" json:"name"` - BaseURL string `yaml:"base_url" json:"base_url"` - APIKey string `yaml:"api_key" json:"api_key"` - APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key) - Adapter string `yaml:"adapter" json:"adapter"` - Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override - ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override - Models []Model `yaml:"models" json:"models"` - Headers map[string]string `yaml:"headers" json:"headers,omitempty"` + Name string `yaml:"name" json:"name"` + BaseURL string `yaml:"base_url" json:"base_url"` + APIKey string `yaml:"api_key" json:"api_key"` + APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key) + Adapter string `yaml:"adapter" json:"adapter"` + Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override + // StreamEndpoint overrides the CHAT endpoint for streaming requests. Most + // upstreams stream on the same path; Gemini is the exception (same path, + // different verb). Declaring it here keeps that knowledge out of the kernel, + // which only compares opaque strings. + StreamEndpoint string `yaml:"endpoint_stream" json:"endpoint_stream,omitempty"` + ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override + Models []Model `yaml:"models" json:"models"` + Headers map[string]string `yaml:"headers" json:"headers,omitempty"` // ProxyURL routes this source's HTTP(S) traffic through an explicit proxy // (e.g. http://127.0.0.1:7890). Empty = direct connection. Needed for // upstreams behind geo/IP blocks that only respond through a proxy. @@ -373,13 +378,14 @@ type RuntimeConfig struct { // name and api_key) so the WebUI can spin up multiple key-bearing sources // from one shared template. type SourceTemplate struct { - Name string `json:"name"` - BaseURL string `json:"base_url"` - Adapter string `json:"adapter"` - Endpoint string `json:"endpoint,omitempty"` - ImageEndpoint string `json:"image_endpoint,omitempty"` - Models []Model `json:"models"` - Headers map[string]string `json:"headers,omitempty"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + Adapter string `json:"adapter"` + Endpoint string `json:"endpoint,omitempty"` + StreamEndpoint string `json:"endpoint_stream,omitempty"` + ImageEndpoint string `json:"image_endpoint,omitempty"` + Models []Model `json:"models"` + Headers map[string]string `json:"headers,omitempty"` // ProxyURL routes this source's HTTP(S) traffic through an explicit proxy. ProxyURL string `json:"proxy_url,omitempty"` Meta map[string]interface{} `json:"meta,omitempty"` @@ -393,18 +399,18 @@ type SourceTemplate struct { // (full management) or "user" (sees only its own key); Models is the allowed // model scope with per-model token quota (0 = unlimited). type GWKey struct { - Key string `yaml:"key" json:"key"` - Role string `yaml:"role" json:"role"` - Name string `yaml:"name,omitempty" json:"name,omitempty"` + Key string `yaml:"key" json:"key"` + Role string `yaml:"role" json:"role"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` // Auto is this key's own AUTO scheduling chain. It overrides the global // cfg.Auto for requests authenticated with this key; nil (or an empty // list) means "inherit the global chain", so keys created before per-key // chains existed keep working untouched. - Auto []ModelScope `yaml:"auto,omitempty" json:"auto,omitempty"` - Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"` - Note string `yaml:"note,omitempty" json:"note,omitempty"` - CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"` - Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys + Auto []ModelScope `yaml:"auto,omitempty" json:"auto,omitempty"` + Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"` + Note string `yaml:"note,omitempty" json:"note,omitempty"` + CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"` + Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys } // HasOwnAuto reports whether the key declares its own AUTO chain rather than diff --git a/internal/core/channels_device_test.go b/internal/core/channels_device_test.go new file mode 100644 index 0000000..6cb682a --- /dev/null +++ b/internal/core/channels_device_test.go @@ -0,0 +1,40 @@ +package core + +import ( + "testing" +) + +// TestChannelsDevicePollBehavior 是 device-poll 流的行为对齐测试。 +// +// 决策表逐行搬自参考实现 pollMinimaxDeviceToken(minimax-oauth.ts), +// 上游用假服务器模拟,逐次响应由测试控制。断言的是**行为**(会话状态推进), +// 不是实现细节。决策表本体在 deviceHarness.runDecisionTable(与变异测试共用)。 +func TestChannelsDevicePollBehavior(t *testing.T) { + h := newDeviceHarness(t, mustChannelsSrc(t)) + // begin 直查:user_code 透传、授权 URL、verifier 不回显 + j := h.begin() + sess := j["session"].(map[string]interface{}) + if sess["user_code"] != "ABCD-EFGH" { + t.Fatalf("begin 应透传 user_code, 实得 %v", sess["user_code"]) + } + if j["url"] != "https://example/activate" { + t.Fatalf("begin 应给授权 URL, 实得 %v", j["url"]) + } + if _, has := j["device_verifier"]; has { + t.Fatal("verifier 绝不能回显") + } + // 决策表全过 + if !h.runDecisionTable() { + t.Fatal("device-poll 决策表未全过(见 runDecisionTable 内的行号)") + } +} + +// mustChannelsSrc 读 bundled channels 源码。 +func mustChannelsSrc(t *testing.T) string { + t.Helper() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + return src +} diff --git a/internal/core/channels_more_test.go b/internal/core/channels_more_test.go new file mode 100644 index 0000000..2793139 --- /dev/null +++ b/internal/core/channels_more_test.go @@ -0,0 +1,325 @@ +package core + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "llmsproxy/internal/lua" +) + +// channelsMoreTest:新引入的三条真实流程 + 两个边界流的行为对齐测试。 +// 平台定义、决策表、端点逐字照抄参考(见 channels.lua 注释),测试照参考行为搬。 + +// ---- Cline(WorkOS 设备码 + register 两步) ---- + +// clineHarness 假上游:device code / authenticate / register 三端点。 +type clineHarness struct { + t *testing.T + ps *lua.Plugins + ts *httptest.Server + tok atomic.Value // 当前 authenticate 响应脚本 []scriptStep + step int32 +} + +func newClineHarness(t *testing.T) *clineHarness { + t.Helper() + h := &clineHarness{t: t} + h.tok.Store([]scriptStep{}) + h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/user_management/authorize/device": + fmt.Fprint(w, `{"device_code":"CD1","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`) + case "/user_management/authenticate": + i := int(atomic.AddInt32(&h.step, 1)) - 1 + s := h.tok.Load().([]scriptStep) + if i >= len(s) { + w.WriteHeader(500) + fmt.Fprint(w, `{"error":"unexpected"}`) + return + } + if s[i].http != 0 && s[i].http != 200 { + w.WriteHeader(s[i].http) + } + fmt.Fprint(w, s[i].body) + case "/api/v1/auth/register": + // 校验请求体是驼峰 accessToken/refreshToken(参考 registerClineTokens) + b, _ := io.ReadAll(r.Body) + for _, want := range []string{`"accessToken":"WO-AT-1"`, `"refreshToken":"WO-RT-1"`} { + if !strings.Contains(string(b), want) { + t.Errorf("register 请求体缺 %s: %s", want, b) + } + } + fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-AT-1","refreshToken":"CLINE-RT-1","userInfo":{"clineUserId":"u1","email":"dev@example.com"}}}`) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(h.ts.Close) + + c := kernelTestCore(t) + h.ps = c.Plugins() + src, _ := readBundledChannels(t) + if err := h.ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "cline", + "device": map[string]interface{}{"code_url": h.ts.URL + "/user_management/authorize/device", "token_url": h.ts.URL + "/user_management/authenticate"}, + "register": map[string]interface{}{"url": h.ts.URL + "/api/v1/auth/register"}, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + return h +} + +func (h *clineHarness) begin() map[string]interface{} { + out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"cline"}`, + }) + if err != nil { + h.t.Fatal(err) + } + return out.JSON.(map[string]interface{}) +} +func (h *clineHarness) poll(id string) map[string]interface{} { + out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ + Method: "GET", Query: map[string]string{"id": id}, + }) + return out.JSON.(map[string]interface{}) +} + +// runWorkosTable 是 WorkOS(Cline)分支的行为契约,供变异测试杀 workos 变异体。 +// 决策表 5 行,跟 TestClineWorkOSBehavior 同源(这里只返回布尔,不报细节)。 +func (h *clineHarness) runWorkosTable() bool { + st := func(steps ...string) map[string]interface{} { + h.tok.Store(scriptSteps(steps...)) + atomic.StoreInt32(&h.step, 0) + j := h.begin() + sid := j["session"].(map[string]interface{})["id"].(string) + return h.poll(sid) + } + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) + // 行1: 非200+error=authorization_pending → 等 + if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" { + return false + } + // 行2: 非200+error=slow_down → 等 + interval+1(基线 1) + h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`)) + atomic.StoreInt32(&h.step, 0) + j := h.begin() + sid := j["session"].(map[string]interface{})["id"].(string) + if j2 := h.poll(sid); j2["state"] != "pending" { + return false + } else if iv, _ := j2["interval"].(float64); iv != 2 { + return false + } + // 行3: error=access_denied → failed + if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" { + return false + } + // 行4: 成功(access+refresh)→ register 换 CLINE token → completed + if j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`); j["state"] != "completed" || j["account"] != "dev@example.com" { + return false + } + v, _, _ := h.ps.GetSecret("channels", "channels:cline") + if v != "CLINE-AT-1" { + return false + } + // 行5: 非200无error字段 → failed(不是死等) + if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" { + return false + } + return true +} + +// TestClineWorkOSBehavior:WorkOS 判据(error 字段)+ register 两步 + slow_down +1s。 +func TestClineWorkOSBehavior(t *testing.T) { + h := newClineHarness(t) + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) + + st := func(steps ...string) map[string]interface{} { + h.tok.Store(scriptSteps(steps...)) + atomic.StoreInt32(&h.step, 0) + j := h.begin() + sid := j["session"].(map[string]interface{})["id"].(string) + return h.poll(sid) + } + + t.Run("行1 error=authorization_pending_非200也继续等", func(t *testing.T) { + if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" { + t.Fatalf("应 pending: %v", j) + } + }) + t.Run("行2 error=slow_down_退避1秒", func(t *testing.T) { + h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`)) + atomic.StoreInt32(&h.step, 0) + j := h.begin() + sid := j["session"].(map[string]interface{})["id"].(string) + j2 := h.poll(sid) + if j2["state"] != "pending" { + t.Fatalf("应 pending: %v", j2) + } + if iv, _ := j2["interval"].(float64); iv != 2 { // 基线 1 + slow_down_step 1 + t.Fatalf("WorkOS slow_down 应 interval+1s 得 2, 实得 %v", iv) + } + }) + t.Run("行3 error=access_denied_失败带拒绝", func(t *testing.T) { + if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" { + t.Fatalf("应 failed: %v", j) + } + }) + t.Run("行4 成功_access+refresh_再打register_凭据是CLINE-AT-1", func(t *testing.T) { + j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`) + if j["state"] != "completed" || j["account"] != "dev@example.com" { + t.Fatalf("应 completed+dev@example.com: %v", j) + } + v, _, _ := h.ps.GetSecret("channels", "channels:cline") + if v != "CLINE-AT-1" { + t.Fatalf("凭据应是 register 的 CLINE-AT-1, 实得 %q", v) + } + }) + t.Run("行5 非200无error字段_失败", func(t *testing.T) { + if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" { + t.Fatalf("应 failed: %v", j) + } + }) +} + +// ---- Buddy(CodeBuddy state 轮询) ---- + +type buddyHarness struct { + t *testing.T + ps *lua.Plugins + ts *httptest.Server + tokStep int32 + tokOk int32 + acctOk int32 +} + +func newBuddyHarness(t *testing.T) *buddyHarness { + t.Helper() + h := &buddyHarness{t: t, tokOk: 1, acctOk: 1} + h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "POST" && r.URL.Path == "/v2/plugin/auth/state": + fmt.Fprint(w, `{"data":{"state":"ST-1","authUrl":"https://www.codebuddy.cn/login/?platform=ide&state=ST-1"}}`) + case r.Method == "GET" && r.URL.Path == "/v2/plugin/auth/token": + i := atomic.AddInt32(&h.tokStep, 1) + if atomic.LoadInt32(&h.tokOk) == 0 { + w.WriteHeader(200) + fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`) + return + } + if i == 1 { + fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`) + } else { + fmt.Fprint(w, `{"data":{"accessToken":"BD-AT-1","refreshToken":"BD-RT-1"}}`) + } + case r.Method == "GET" && r.URL.Path == "/v2/plugin/login/account": + if atomic.LoadInt32(&h.acctOk) == 0 { + w.WriteHeader(500) + fmt.Fprint(w, `{"code":1}`) + return + } + fmt.Fprint(w, `{"data":{"nickName":"buddy-dev"}}`) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(h.ts.Close) + + c := kernelTestCore(t) + h.ps = c.Plugins() + src, _ := readBundledChannels(t) + if err := h.ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "buddy", + "state": map[string]interface{}{"state_url": h.ts.URL + "/v2/plugin/auth/state", "token_url": h.ts.URL + "/v2/plugin/auth/token", "account_url": h.ts.URL + "/v2/plugin/login/account"}, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + return h +} + +func (h *buddyHarness) begin() (map[string]interface{}, string) { + out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"buddy"}`, + }) + if err != nil { + h.t.Fatal(err) + } + j := out.JSON.(map[string]interface{}) + return j, j["session"].(map[string]interface{})["id"].(string) +} +func (h *buddyHarness) poll(id string) map[string]interface{} { + out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ + Method: "GET", Query: map[string]string{"id": id}, + }) + return out.JSON.(map[string]interface{}) +} + +// TestBuddyStatePollBehavior:state-poll 两拍(先 11217 后 token)+ 账号查询 + account 失败不阻塞。 +func TestBuddyStatePollBehavior(t *testing.T) { + h := newBuddyHarness(t) + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) + + t.Run("行1 state申请给出authUrl", func(t *testing.T) { + j, _ := h.begin() + if j["url"] == "" { + t.Fatalf("应有 authUrl: %v", j) + } + }) + t.Run("行2 首拍11217仍pending_次拍token_completed", func(t *testing.T) { + atomic.StoreInt32(&h.tokStep, 0) + _, sid := h.begin() + if j := h.poll(sid); j["state"] != "pending" { + t.Fatalf("首拍应 pending: %v", j) + } + if j := h.poll(sid); j["state"] != "completed" || j["account"] != "buddy-dev" { + t.Fatalf("次拍应 completed+buddy-dev: %v", j) + } + v, _, _ := h.ps.GetSecret("channels", "channels:buddy") + if v != "BD-AT-1" { + t.Fatalf("凭据应 BD-AT-1, 实得 %q", v) + } + }) + t.Run("行3 account查询失败不阻塞_回退默认名", func(t *testing.T) { + atomic.StoreInt32(&h.tokStep, 0) + atomic.StoreInt32(&h.acctOk, 0) + defer atomic.StoreInt32(&h.acctOk, 1) + _, sid := h.begin() + h.poll(sid) + j := h.poll(sid) + if j["state"] != "completed" { + t.Fatalf("account 失败不应阻塞: %v", j) + } + }) +} + +// ---- 通用 ---- + +func scriptSteps(steps ...string) []scriptStep { + var out []scriptStep + for _, b := range steps { + code := 200 + switch { + case strings.HasPrefix(b, "400 "): + code, b = 400, b[4:] + case strings.HasPrefix(b, "403 "): + code, b = 403, b[4:] + case strings.HasPrefix(b, "500 "): + code, b = 500, b[4:] + } + out = append(out, scriptStep{http: code, body: b}) + } + return out +} diff --git a/internal/core/channels_plugin_test.go b/internal/core/channels_plugin_test.go new file mode 100644 index 0000000..e217e21 --- /dev/null +++ b/internal/core/channels_plugin_test.go @@ -0,0 +1,241 @@ +package core + +import ( + "encoding/json" + "testing" + + "llmsproxy/internal/lua" +) + +// TestChannelsPluginEndToEnd 是「同类插件」的完整验收: +// 内置的 channels 插件在**真实内核**上跑通「登录 → 存凭据 → 接入为上游源」。 +// +// 这条链路横跨本次所有新增能力,所以它是最有信息量的一个测试: +// +// __llmsproxy_kernel add_source 把平台注册为上游源 +// 内核权限 默认拒绝,需运维授权 +// 额外出站主机授权 登录域不在源列表里,默认被出站白名单拒绝(plugin.hosts) +// 登录会话 插件自持(plugin.state.sessions),内核无登录概念 +func TestChannelsPluginEndToEnd(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + + src, err := lua.ReadBundledPlugin("channels") + if err != nil { + t.Fatalf("读取内置 channels 插件: %v", err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatalf("加载 channels: %v", err) + } + + // 1. 平台清单可用(未授权任何东西也应能列出) + out, err := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{ + Method: "GET", Role: "admin", + }) + if err != nil { + t.Fatalf("list_platforms: %v", err) + } + body, _ := out.JSON.(map[string]interface{}) + plats, _ := body["platforms"].([]interface{}) + if len(plats) < 3 { + t.Fatalf("应至少 3 个平台, 实得 %d", len(plats)) + } + + // 2. 发起登录 → 拿到会话与 URL + out, err = ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"example"}`, Role: "admin", + }) + if err != nil { + t.Fatalf("start_login: %v", err) + } + m := out.JSON.(map[string]interface{}) + if m["error"] != nil { + t.Fatalf("发起登录失败: %v", m["error"]) + } + if m["url"] == nil || m["url"] == "" { + t.Fatal("应返回用户要打开的登录 URL") + } + sess := m["session"].(map[string]interface{}) + id, _ := sess["id"].(string) + if id == "" { + t.Fatal("应返回会话 id") + } + + // 3. 轮询到 pending + out, _ = ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ + Method: "GET", Query: map[string]string{"id": id}, Role: "admin", + }) + if st := out.JSON.(map[string]interface{})["state"]; st != "pending" { + t.Fatalf("应为 pending, 实得 %v", st) + } + + // 4. 未授权内核动作时,接入源必须失败且**如实报告**(不是假装成功) + out, err = ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"example","credential":"tok-1","account":"alice"}`, Role: "admin", + }) + if err != nil { + t.Fatalf("complete_login: %v", err) + } + done := out.JSON.(map[string]interface{}) + if done["source_error"] == nil { + t.Fatal("未授权 add_source 时应报告 source_error") + } + t.Logf("未授权时的如实报告: %v", done["source_error"]) + + // 凭据必须已在保险库(登录本身成功了) + tok, ok, _ := ps.GetSecret("channels", "channels:example") + if !ok || tok != "tok-1" { + t.Fatalf("凭据应已存保险库: ok=%v tok=%q", ok, tok) + } + // 且不得出现在 state 里 + if stateHasSecret(ps, "channels", "tok-1") { + t.Fatal("★ 凭据泄漏进了 plugin.state") + } + + // 5. 运维授权后,重新接入应当真的把源注册进内核 + if err := ps.SetPluginKernelPermission("channels", + []string{"add_source", "list_sources", "status"}, false); err != nil { + t.Fatal(err) + } + out, err = ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"example","credential":"tok-1","account":"alice"}`, Role: "admin", + }) + if err != nil { + t.Fatalf("complete_login(授权后): %v", err) + } + done = out.JSON.(map[string]interface{}) + if done["source_error"] != nil { + t.Fatalf("授权后仍失败: %v", done["source_error"]) + } + if done["source"] != "channels-example" { + t.Fatalf("源名不对: %v", done["source"]) + } + + // 内核侧必须真的有这个源 + found := false + for _, s := range c.Sources() { + if s.Name == "channels-example" { + found = true + if s.BaseURL != "https://api.example.com/v1" { + t.Errorf("base_url 未落库: %q", s.BaseURL) + } + } + } + if !found { + t.Fatal("插件接入的源没有出现在内核源列表里") + } + // 平台声明的模型应可路由 + models := c.Registry().ModelList() + hasModel := false + for _, id := range models { + if id == "example-chat" { + hasModel = true + } + } + if !hasModel { + t.Errorf("平台模型未进入可路由列表: %v", models) + } + t.Logf("已接入源 channels-example,模型可见: %v", models) +} + +// TestChannelsPluginDeclaresItsNeeds 断言插件如实声明了它需要什么。 +// +// 声明是运维审批的依据:插件不声明,设置页就无法显示"申请中", +// 运维只能等某个功能静默失效后才发现。 +func TestChannelsPluginDeclaresItsNeeds(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := lua.ReadBundledPlugin("channels") + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + // 内核动作声明 + views := ps.KernelPermissions() + var declared []string + for _, v := range views { + if v.Plugin == "channels" { + declared = v.Declared + } + } + if len(declared) == 0 { + t.Fatal("channels 应声明它需要的内核动作") + } + for _, want := range []string{"add_source", "status"} { + found := false + for _, d := range declared { + if d == want { + found = true + } + } + if !found { + t.Errorf("应声明 %q, 实得 %v", want, declared) + } + } + // 额外出站主机声明(非源 host,需单独批准) + hosts := ps.PluginExtraHosts("channels") + if len(hosts) == 0 { + t.Fatal("channels 应声明额外出站主机") + } + t.Logf("声明内核动作=%v 额外主机=%v", declared, hosts) +} + +// TestChannelsPluginHandlersRequireStatus 是一个契约回归:插件的每个 handler +// 都必须返回 {status,json/body},否则内核会**静默丢弃**它(实测踩过: +// `return {url=…}` 得到 200 空响应,没有任何错误信息)。 +func TestChannelsPluginHandlersRequireStatus(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := lua.ReadBundledPlugin("channels") + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) + + // 每个 handler 至少要在正常输入下给出非空响应 + cases := []struct { + fn string + req *lua.HandlerRequest + what string + }{ + {"list_platforms", &lua.HandlerRequest{Method: "GET"}, "平台列表"}, + {"status_page", &lua.HandlerRequest{Method: "GET"}, "状态页"}, + {"poll_login", &lua.HandlerRequest{Method: "GET", Query: map[string]string{"id": "nope"}}, "轮询(未知会话)"}, + {"start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"example"}`}, "发起登录"}, + {"start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"nope"}`}, "发起登录(未知平台)"}, + {"complete_login", &lua.HandlerRequest{Method: "POST", Body: `{}`}, "完成登录(缺字段)"}, + {"claim_daily", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"example"}`}, "领额度(未登录)"}, + } + for _, tc := range cases { + out, err := ps.InvokeHandler("channels", tc.fn, tc.req) + if err != nil { + t.Errorf("%s (%s) 返回错误: %v", tc.fn, tc.what, err) + continue + } + if out.JSON == nil && out.Body == "" { + t.Errorf("%s (%s) 返回空响应 —— 内核会静默丢弃,调用方看不到任何原因", + tc.fn, tc.what) + } + } +} + +// jsonMarshalForTest 序列化一个接口值,用于检查凭据是否出现在 state 里。 +func jsonMarshalForTest(v interface{}) ([]byte, error) { return json.Marshal(v) } + +// stateHasSecret 检查凭据是否泄漏进 plugin.state。 +func stateHasSecret(ps *lua.Plugins, plugin, secret string) bool { + st := ps.State(plugin) + if st == nil { + return false + } + b, err := jsonMarshalForTest(st) + if err != nil { + return false + } + return contains(string(b), secret) +} diff --git a/internal/core/channels_pool_test.go b/internal/core/channels_pool_test.go new file mode 100644 index 0000000..3692b1a --- /dev/null +++ b/internal/core/channels_pool_test.go @@ -0,0 +1,322 @@ +package core + +import ( + "strings" + "testing" + + "llmsproxy/internal/lua" +) + +// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。 +// 账号池增删改、上游源随 enabled/模型开关同步、供应商整体开关、备份导出。 + +func poolCore(t *testing.T) (*Core, *lua.Plugins) { + t.Helper() + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "remove_source", "status", "probe_all", "reset_health"}, false) + return c, ps +} + +// listSourcesViaKernel 直查内核 list_sources(actionListSources 返回 []map)。 +func listSourcesViaKernel(t *testing.T, c *Core) []map[string]interface{} { + t.Helper() + out, err := c.Invoke(nil, "list_sources", map[string]interface{}{}) + if err != nil { + t.Fatalf("list_sources: %v", err) + } + if ms, ok := out.([]map[string]interface{}); ok { + return ms + } + raw, _ := out.([]interface{}) + var srcs []map[string]interface{} + for _, s := range raw { + if m, ok := s.(map[string]interface{}); ok { + srcs = append(srcs, m) + } + } + return srcs +} + +func invoke(t *testing.T, ps *lua.Plugins, fn, body string) map[string]interface{} { + t.Helper() + out, err := ps.InvokeHandler("channels", fn, &lua.HandlerRequest{Method: "POST", Body: body}) + if err != nil { + t.Fatalf("invoke %s: %v", fn, err) + } + return out.JSON.(map[string]interface{}) +} + +// asArr 规整桥接后的数组。Lua 空表过桥是 map[string]interface{}(无法与空数组 +// 区分,见 vm.go luaTableToGoValue);内核 list_sources 的 models 过桥是 []string。 +func asArr(v interface{}) []interface{} { + switch a := v.(type) { + case []interface{}: + return a + case []string: + out := make([]interface{}, len(a)) + for i, s := range a { + out[i] = s + } + return out + } + return nil +} + +func srcHasAccount(srcs []map[string]interface{}, needle string) (map[string]interface{}, bool) { + for _, sm := range srcs { + if name, _ := sm["name"].(string); strings.Contains(name, needle) { + return sm, true + } + } + return nil, false +} + +// TestChannelsAccountPoolBehavior:账号池 CRUD + 上游源同步 + 模型/供应商开关 + 备份。 +func TestChannelsAccountPoolBehavior(t *testing.T) { + c, ps := poolCore(t) + + t.Run("账号增删改", func(t *testing.T) { + m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"我的号"}`) + if m["ok"] != true { + t.Fatalf("account_add: %v", m) + } + aid, _ := m["account_id"].(string) + if aid == "" { + t.Fatalf("account_add 应返回 account_id: %v", m) + } + m = invoke(t, ps, "account_list", `{"platform":"minimax"}`) + accts := asArr(m["accounts"]) + if len(accts) != 1 { + t.Fatalf("应有1个账号: %d", len(accts)) + } + a := accts[0].(map[string]interface{}) + if a["nickname"] != "我的号" || a["enabled"] != true { + t.Fatalf("账号字段: %v", a) + } + invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"nickname":"改名"}}`) + m = invoke(t, ps, "account_list", `{"platform":"minimax"}`) + if asArr(m["accounts"])[0].(map[string]interface{})["nickname"] != "改名" { + t.Fatalf("改名失败") + } + invoke(t, ps, "account_delete", `{"platform":"minimax","account_id":"`+aid+`"}`) + m = invoke(t, ps, "account_list", `{"platform":"minimax"}`) + if n := len(asArr(m["accounts"])); n != 0 { + t.Fatalf("删除后应为0, 实得 %d", n) + } + }) + + t.Run("模型开关同步上游源", func(t *testing.T) { + m := invoke(t, ps, "account_add", `{"platform":"minimax","credential":"MM-KEY"}`) + aid, _ := m["account_id"].(string) + sm, ok := srcHasAccount(listSourcesViaKernel(t, c), aid) + if !ok { + t.Fatal("带凭据账号应已同步源") + } + if n := len(asArr(sm["models"])); n != 3 { + t.Fatalf("源应有3模型, 实得 %d", n) + } + invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`) + sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid) + if n := len(asArr(sm["models"])); n != 2 { + t.Fatalf("关1模型后源应2模型, 实得 %d", n) + } + invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":true}`) + m = invoke(t, ps, "provider_status", `{}`) + for _, s := range asArr(m["statuses"]) { + x := s.(map[string]interface{}) + if x["id"] == "minimax" && x["closed"] != true { + t.Fatalf("全关模型后应 closed=true: %v", x) + } + } + invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":false}`) + }) + + t.Run("enabled开关同步上游源", func(t *testing.T) { + m := invoke(t, ps, "account_list", `{"platform":"minimax"}`) + accts := asArr(m["accounts"]) + if len(accts) == 0 { + t.Fatal("上个用例应留有账号") + } + aid := accts[0].(map[string]interface{})["id"].(string) + before := len(listSourcesViaKernel(t, c)) + invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":false}}`) + if n := len(listSourcesViaKernel(t, c)); n != before-1 { + t.Fatalf("停用账号应删源: 前 %d 后 %d", before, n) + } + invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":true}}`) + if n := len(listSourcesViaKernel(t, c)); n != before { + t.Fatalf("启用账号应重建源: 前 %d 后 %d", before, n) + } + }) + + t.Run("供应商整体开关", func(t *testing.T) { + invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":false}`) + if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); ok { + t.Fatal("关闭渠道后不应有 minimax 源") + } + m := invoke(t, ps, "provider_status", `{}`) + for _, s := range asArr(m["statuses"]) { + x := s.(map[string]interface{}) + if x["id"] == "minimax" && x["closed"] != true { + t.Fatalf("关闭后应 closed: %v", x) + } + } + invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":true}`) + if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok { + t.Fatal("开启渠道后应有 minimax 源") + } + }) + + t.Run("备份导出含凭据", func(t *testing.T) { + invoke(t, ps, "account_add", `{"platform":"cline","credential":"CL-KEY","nickname":"cl1"}`) + exp := invoke(t, ps, "backup_export", `{}`) + payload, _ := exp["payload"].(map[string]interface{}) + if payload == nil { + t.Fatal("backup_export 应返回 payload") + } + creds, _ := payload["credentials"].(map[string]interface{}) + found := false + for _, v := range creds { + if v == "CL-KEY" { + found = true + } + } + if !found { + t.Fatalf("备份应含凭据明文: %v", creds) + } + }) +} + +// TestChannelsExtendedRPCBehavior:对齐参考剩余 RPC 面(credits.status/balances、 +// account.reset/retest、model.setDisabledMany/clearDead、provider 排序、 +// gateway/backup.status、usage.badgePreference、onboarding)。 +func TestChannelsExtendedRPCBehavior(t *testing.T) { + _, ps := poolCore(t) + + t.Run("credits_balances_无凭据报错_有凭据解析", func(t *testing.T) { + m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"b1"}`) + aid, _ := m["account_id"].(string) + j := invoke(t, ps, "credits_balances", `{"platform":"minimax"}`) + var row map[string]interface{} + for _, a := range asArr(j["accounts"]) { + x := a.(map[string]interface{}) + if x["accountId"] == aid { + row = x + } + } + if row == nil { + t.Fatal("balances 应含该账号") + } + if row["balance"] != nil || row["error"] == nil { + t.Fatalf("无凭据应 error 而非编造余额: %v", row) + } + }) + + t.Run("credits_status_无端点平台如实null", func(t *testing.T) { + m := invoke(t, ps, "account_add", `{"platform":"opencode","nickname":"oc1"}`) + aid, _ := m["account_id"].(string) + j := invoke(t, ps, "credits_status", `{"platform":"opencode"}`) + for _, a := range asArr(j["accounts"]) { + x := a.(map[string]interface{}) + if x["accountId"] == aid && x["status"] != nil { + t.Fatalf("opencode 无状态端点应 null: %v", x) + } + } + }) + + t.Run("account_reset_clears", func(t *testing.T) { + invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"r1"}`) + j := invoke(t, ps, "account_reset", `{"platform":"minimax","account_id":"__all__"}`) + if j["ok"] != true { + t.Fatalf("resetAll: %v", j) + } + }) + + t.Run("model_set_disabled_many", func(t *testing.T) { + invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":true}`) + j := invoke(t, ps, "model_list", `{"platform":"minimax"}`) + off := 0 + for _, x := range asArr(j["models"]) { + mm := x.(map[string]interface{}) + if mm["disabled"] == true { + off++ + } + } + if off != 2 { + t.Fatalf("批量关2模型后应2个disabled, 实得 %d", off) + } + invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":false}`) + }) + + t.Run("model_clear_dead_只清黑名单里已下线的", func(t *testing.T) { + invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`) + j := invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) + if n := len(asArr(j["removed"])); n != 0 { + t.Fatalf("在册模型不算死模型, 实得 %d", n) + } + // 手动放一个不在 models 表里的黑名单项 + invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["ghost-model"],"disabled":true}`) + j = invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) + if n := len(asArr(j["removed"])); n != 1 { + t.Fatalf("死模型应被清: %v", j) + } + invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":false}`) + }) + + t.Run("provider_order", func(t *testing.T) { + invoke(t, ps, "provider_set_order", `{"order":["minimax","trae","bogus"]}`) + j := invoke(t, ps, "provider_get_order", `{}`) + order := asArr(j["order"]) + if len(order) != 2 { + t.Fatalf("bogus 应被过滤, 实得 %v", order) + } + if order[0] != "minimax" { + t.Fatalf("顺序应保留: %v", order) + } + }) + + t.Run("backup_status", func(t *testing.T) { + invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"s1"}`) + j := invoke(t, ps, "backup_status", `{}`) + if n, _ := j["accounts"].(float64); n < 1 { + t.Fatalf("backup_status 应统计账号: %v", j) + } + }) + + t.Run("badge_preference_非法值拒绝", func(t *testing.T) { + j := invoke(t, ps, "usage_badge_preference", `{"preference":"bogus"}`) + if j["error"] == nil { + t.Fatalf("非法偏好应拒绝: %v", j) + } + j = invoke(t, ps, "usage_badge_preference", `{"preference":"credits"}`) + if j["preference"] != "credits" { + t.Fatalf("合法偏好应保存: %v", j) + } + }) + + t.Run("onboarding_不支持平台如实报", func(t *testing.T) { + j := invoke(t, ps, "onboarding_status", `{"platform":"minimax"}`) + if j["supported"] != false { + t.Fatalf("minimax 无新人任务应 supported=false: %v", j) + } + }) + + t.Run("permanent_lock_读写", func(t *testing.T) { + j := invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy","locked":true}`) + if j["locked"] != true { + t.Fatalf("锁定应生效: %v", j) + } + j = invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy"}`) + if j["locked"] != true { + t.Fatalf("只读应回显锁定: %v", j) + } + }) +} diff --git a/internal/core/channels_stress_test.go b/internal/core/channels_stress_test.go new file mode 100644 index 0000000..040a6d2 --- /dev/null +++ b/internal/core/channels_stress_test.go @@ -0,0 +1,251 @@ +package core + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + + "llmsproxy/internal/config" + "llmsproxy/internal/lua" +) + +// mustBox 构造一个真 SecretBox(与运行期同一实现,密钥落 runtime dir)。 +func mustBox(t *testing.T, runtimeFile string) *config.SecretBox { + t.Helper() + box, err := config.NewSecretBox(runtimeFile) + if err != nil { + t.Fatal(err) + } + return box +} + +// channelsCore 起一个带 channels 插件、可指定源码与 box 的核心(压力测试用)。 +func channelsCore(t *testing.T, src string) (*Core, *lua.Plugins) { + t.Helper() + c := kernelTestCore(t) + ps := c.Plugins() + if err := ps.LoadSource("channels", src); err != nil { + t.Fatalf("加载 channels: %v", err) + } + ps.SetSecretBox(mustBox(t, filepath.Join(t.TempDir(), "runtime.json"))) + _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) + return c, ps +} + +// TestChannelsConcurrentLogins 压力:并发走 device-poll 登录。 +// +// 插件是单 VM 单锁 —— 本测试验证的是**正确性下的并发**(会话互不串台、 +// 凭据互不可见、最终都 completed),真吞吐压力在 e2e 层(真二进制+HTTP 并发)。 +func TestChannelsConcurrentLogins(t *testing.T) { + var hits int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth2/device/code": + n := atomic.AddInt32(&hits, 1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"device_code":"DEV-%d","user_code":"UC-%d","verification_uri":"https://example/activate","interval":1,"expires_in":600}`, n, n) + case "/oauth2/token": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"TOK","refresh_token":"RF","nickname":"u"}`) + default: + w.WriteHeader(404) + } + })) + defer ts.Close() + + _, ps := channelsCore(t, mustChannelsSrc(t)) + // 把 minimax 端点指到假上游 + if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "minimax", + "device": map[string]interface{}{ + "code_url": ts.URL + "/oauth2/device/code", + "token_url": ts.URL + "/oauth2/token", + }, + }); err != nil { + t.Fatal(err) + } + + const rounds = 12 + var wg sync.WaitGroup + var failures int32 + for i := 0; i < rounds; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"minimax"}`, + }) + if err != nil { + t.Errorf("start_login #%d: %v", n, err) + atomic.AddInt32(&failures, 1) + return + } + j := out.JSON.(map[string]interface{}) + sess, _ := j["session"].(map[string]interface{}) + if sess == nil { + t.Errorf("start_login #%d 无会话: %v", n, j) + atomic.AddInt32(&failures, 1) + return + } + sid := sess["id"].(string) + for k := 0; k < 10; k++ { + out2, _ := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ + Method: "GET", Query: map[string]string{"id": sid}, + }) + m2 := out2.JSON.(map[string]interface{}) + if m2["state"] == "completed" { + return + } + if m2["state"] == "failed" { + t.Errorf("会话 #%d failed: %v", n, m2) + atomic.AddInt32(&failures, 1) + return + } + } + t.Errorf("会话 #%d 10 次轮询未终态", n) + atomic.AddInt32(&failures, 1) + }(i) + } + wg.Wait() + if failures != 0 { + t.Fatalf("并发登录失败 %d 轮", failures) + } +} + +// TestChannelsClaimStress 压力:同一平台并发领签到(幂等 + 无登录态丢失)。 +func TestChannelsClaimStress(t *testing.T) { + var claims int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/checkin" { + atomic.AddInt32(&claims, 1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"ok":true,"credits":10}`) + return + } + w.WriteHeader(404) + fmt.Fprint(w, `{}`) + })) + defer ts.Close() + + _, ps := channelsCore(t, mustChannelsSrc(t)) + if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "cookieish", + "checkin": map[string]interface{}{"url": ts.URL + "/v1/checkin"}, + }); err != nil { + t.Fatal(err) + } + if err := ps.SetSecret("channels", "channels:cookieish", "PASTE-TOKEN"); err != nil { + t.Fatal(err) + } + + const n = 30 + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(k int) { + defer wg.Done() + out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"cookieish"}`, + }) + if err != nil { + t.Errorf("claim #%d: %v", k, err) + return + } + j := out.JSON.(map[string]interface{}) + if j["ok"] != true { + t.Errorf("claim #%d 未成功: %v", k, j) + } + }(i) + } + wg.Wait() + if got := atomic.LoadInt32(&claims); got != n { + t.Errorf("上游应收到 %d 次领取, 实得 %d", n, got) + } +} + +// TestLegacyVaultPlaintextLoad 旧通路:老版本(无加密)写的明文库在新版本 +// (带 box)下必须能读 —— 凭据升级不丢。落盘则会在下次写时整体重封为密文。 +func TestLegacyVaultPlaintextLoad(t *testing.T) { + dir := t.TempDir() + ps := lua.NewPlugins(nil, dir) + if err := ps.SetSecret("legacy", "channels:old", "PLAIN-OLD-TOKEN"); err != nil { + t.Fatal(err) + } + ps.SetSecretBox(mustBox(t, filepath.Join(dir, "runtime.json"))) + v, ok, err := ps.GetSecret("legacy", "channels:old") + if err != nil || !ok || v != "PLAIN-OLD-TOKEN" { + t.Fatalf("旧明文库读回失败: v=%q ok=%v err=%v", v, ok, err) + } + // 任意一次写触发整体重封 + if err := ps.SetSecret("legacy", "k2", "NEW"); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(dir, ".secrets", "legacy.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "PLAIN-OLD-TOKEN") { + t.Fatalf("★ 重封后旧明文仍在盘上:\n%s", raw) + } + if !strings.Contains(string(raw), "enc:v1:") { + t.Fatalf("重封后应含密文:\n%s", raw) + } +} + +// TestLegacySourceSealedConfig 旧通路:config.yaml 里明文 api_key 的旧配置, +// 新版本加载后运行期拿到解密值、落盘被 seal(SealIfNeeded 语义)。 +func TestLegacySourceSealedConfig(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + yaml := "listen: 127.0.0.1:0\n" + + "gateway_keys:\n - sk-legacy-0001\n" + + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + + "sources:\n" + + " - name: legacy-src\n" + + " base_url: https://legacy.example.com/v1\n" + + " adapter: openai\n" + + " api_key: PLAIN-LEGACY-KEY\n" + + " max_concurrent: 2\n" + + " models:\n" + + " - id: m1\n" + + " kind: chat\n" + + " priority: 10\n" + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + c, err := NewFromConfig(cfg) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + got := c.Sources() + if len(got) != 1 || got[0].APIKey != "PLAIN-LEGACY-KEY" { + t.Fatalf("运行期 api_key 应解密: %#v", got) + } + raw, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "PLAIN-LEGACY-KEY") { + t.Fatalf("★ 旧明文 api_key 升级后仍明文落盘:\n%s", raw) + } + if !strings.Contains(string(raw), "enc:v1:") { + t.Fatalf("落盘应含 enc:v1 密文:\n%s", raw) + } + mk, err := os.ReadFile(filepath.Join(dir, "master.key")) + if err != nil || len(mk) != 64 { + t.Fatalf("master.key 应 64 hex, len=%d err=%v", len(mk), err) + } +} diff --git a/internal/core/channels_trae_test.go b/internal/core/channels_trae_test.go new file mode 100644 index 0000000..9666391 --- /dev/null +++ b/internal/core/channels_trae_test.go @@ -0,0 +1,173 @@ +package core + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "llmsproxy/internal/lua" +) + +// traeHarness:TRAE callback 流的测试台。假上游模拟 ExchangeToken; +// 回调用 InvokeHandler 直打 oauth_callback(Query 表 = 平台重定向回来的参数)。 +type traeHarness struct { + t *testing.T + ps *lua.Plugins + ts *httptest.Server + exchHit int32 + exchOK int32 // 1=成功 0=失败(atomic) +} + +func newTraeHarness(t *testing.T) *traeHarness { + t.Helper() + h := &traeHarness{t: t, exchOK: 1} + h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/cloudide/api/v3/trae/oauth/ExchangeToken" { + w.WriteHeader(404) + return + } + n := atomic.AddInt32(&h.exchHit, 1) + w.Header().Set("Content-Type", "application/json") + if atomic.LoadInt32(&h.exchOK) == 0 { + w.WriteHeader(500) + fmt.Fprint(w, `{"error":"exchange down"}`) + return + } + // 校验请求体形状(对齐参考 exchangeTraeCallback 的请求构造) + body, _ := io.ReadAll(r.Body) + for _, want := range []string{`"ClientID"`, `"RefreshToken":"RT-FROM-CALLBACK"`, `"ClientSecret":"-"`} { + if !strings.Contains(string(body), want) { + t.Errorf("ExchangeToken 请求体缺 %s: %s", want, body) + } + } + fmt.Fprintf(w, `{"Token":"ACCESS-%d","RefreshToken":"RT-NEW-%d"}`, n, n) + })) + t.Cleanup(h.ts.Close) + + c := kernelTestCore(t) + h.ps = c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := h.ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) + // ExchangeToken 指到假上游(set_endpoints 既是运维能力也是测试缝) + if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "trae", + "callback": map[string]interface{}{ + "exchange_url": h.ts.URL + "/cloudide/api/v3/trae/oauth/ExchangeToken", + }, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + return h +} + +// beginAndCallback 发起登录(拿 nonce)后直接打 oauth_callback。 +// cbQuery 是回调的 query(不含 state,内部补);值即平台重定向回来的原始参数。 +func (h *traeHarness) beginAndCallback(cbQuery map[string]string) (int, string) { + out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"trae"}`, + }) + if err != nil { + h.t.Fatal(err) + } + j, ok := out.JSON.(map[string]interface{}) + if !ok { + h.t.Fatalf("start_login 返回非对象: %#v", out.JSON) + } + sess := j["session"].(map[string]interface{}) + nonce := sess["nonce"].(string) + + q := map[string]string{"state": nonce} + for k, v := range cbQuery { + q[k] = v + } + res, err := h.ps.InvokeHandler("channels", "oauth_callback", &lua.HandlerRequest{ + Method: "GET", Query: q, + }) + if err != nil { + h.t.Fatal(err) + } + return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON) +} + +// TestTraeCallbackBehavior 是 TRAE callback 流的行为对齐测试。 +// +// 决策表逐行搬自参考实现 parseTraeCallbackDetailed + exchangeTraeCallback +// (trae-oauth.ts)。断言的是行为(凭据来源 / 错误文案 / 上游是否被调),不是实现细节。 +func TestTraeCallbackBehavior(t *testing.T) { + h := newTraeHarness(t) + + t.Run("行1 refreshToken直传_ExchangeToken换新", func(t *testing.T) { + atomic.StoreInt32(&h.exchOK, 1) + st, body := h.beginAndCallback(map[string]string{ + "refreshToken": "RT-FROM-CALLBACK", + "userInfo": `{"UserID":"u1","ScreenName":"张三","TenantID":"t1"}`, + }) + if st != 200 { + t.Fatalf("应 200: %d %s", st, body) + } + // 凭据必须是 ExchangeToken 的新 Token,不是回调原值 + v, _, _ := h.ps.GetSecret("channels", "channels:trae") + if !strings.HasPrefix(v, "ACCESS-") { + t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", v) + } + }) + + t.Run("行2 无refreshToken_userJwtToken兜底_不打ExchangeToken", func(t *testing.T) { + before := atomic.LoadInt32(&h.exchHit) + st, body := h.beginAndCallback(map[string]string{ + "userJwt": `{"Token":"JWT-TOKEN-1","RefreshToken":""}`, + }) + if st != 200 { + t.Fatalf("应 200: %d %s", st, body) + } + v, _, _ := h.ps.GetSecret("channels", "channels:trae") + if v != "JWT-TOKEN-1" { + t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %q", v) + } + if atomic.LoadInt32(&h.exchHit) != before { + t.Fatal("无 refreshToken 不应调 ExchangeToken(参考实现分支2)") + } + }) + + t.Run("行3 只有code_PKCE流_明确报暂不支持", func(t *testing.T) { + st, body := h.beginAndCallback(map[string]string{"code": "PKCE-CODE-1"}) + if st != 400 { + t.Fatalf("PKCE 流应 400: %d %s", st, body) + } + if !strings.Contains(body, "PKCE") { + t.Fatalf("错误应明确指向 PKCE 流, 实得 %s", body) + } + }) + + t.Run("行4 空回调_报缺失", func(t *testing.T) { + st, body := h.beginAndCallback(map[string]string{}) + if st != 400 { + t.Fatalf("空回调应 400: %d %s", st, body) + } + if !strings.Contains(body, "refreshToken") { + t.Fatalf("错误应指出缺什么, 实得 %s", body) + } + }) + + t.Run("行5 ExchangeToken失败_透传阶段", func(t *testing.T) { + atomic.StoreInt32(&h.exchOK, 0) + defer atomic.StoreInt32(&h.exchOK, 1) + st, body := h.beginAndCallback(map[string]string{"refreshToken": "RT-FROM-CALLBACK"}) + if st != 400 { + t.Fatalf("exchange 失败应 400: %d %s", st, body) + } + if !strings.Contains(body, "ExchangeToken") { + t.Fatalf("错误应指明 ExchangeToken 阶段, 实得 %s", body) + } + }) +} diff --git a/internal/core/core.go b/internal/core/core.go index 5b16a94..edb2570 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -8,10 +8,10 @@ import ( "encoding/hex" "fmt" "log" + "net/url" "os" "path/filepath" "sort" - "net/url" "strings" "sync" "sync/atomic" @@ -69,6 +69,11 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { // to boot the gateway because a third-party .lua has a typo would be the // wrong trade. c.plugins = lua.NewPlugins(c.vm, cfg.PluginDir) + // R4-c: hand plugins the kernel action surface. Injected as an interface + // because lua must not import core (core depends on lua, not the reverse). + // Wired BEFORE LoadDir so a plugin that calls an action during its own + // __init/first hook already finds a working kernel rather than a nil one. + c.plugins.SetKernelAPI(c) // Plugin outbound HTTP is scoped to the hosts of configured sources. Set // before LoadDir so a plugin that requests on its first hook already sees // the real allowlist rather than the deny-all placeholder. @@ -126,6 +131,11 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { // only appeared to work because seedKeys' Save() unsealed memory as a side // effect. Removing the redundant saves exposed the real ordering bug. cfg.AttachSecretBox(c.store.SecretBox()) + // Share that SAME box with the plugin credential vault: one master.key seals + // config.yaml, the runtime store, AND every .secrets/*.json. Set here (not at + // NewPlugins time) because the store — and thus the box — only exists now; + // plugins write secrets only during login flows, well after this point. + c.plugins.SetSecretBox(c.store.SecretBox()) hadPlaintextSecrets, err := cfg.UnsealSecrets(c.store.SecretBox()) if err != nil { return nil, fmt.Errorf("unseal secrets: %w", err) @@ -874,6 +884,14 @@ func (c *Core) UploadAdapter(name, code string) error { if name == "" { return fmt.Errorf("adapter name required") } + // Validate name SAFETY before touching the filesystem (R6). A path-traversal + // name ("../openai") must be rejected BEFORE WriteFile, or it would already + // have escaped the adapter dir by the time LoadAdapter re-checks. Built-in + // names are allowed here: an admin uploading openai.lua is the documented + // override, same as placing the file in adapter_dir by hand. + if err := lua.ValidateAdapterName(name, nil); err != nil { + return err + } if err := os.MkdirAll(c.cfg.AdapterDir, 0755); err != nil { return err } @@ -885,6 +903,13 @@ func (c *Core) UploadAdapter(name, code string) error { } func (c *Core) RemoveAdapter(name string) error { + // Validate the name so a traversal name cannot os.Remove a file outside the + // adapter dir. A reserved (built-in) name is a legitimate delete target in + // the running VM, so reserved-name rejection is NOT applied here — only the + // path-safety character set is. + if err := lua.ValidateAdapterName(name, nil); err != nil { + return err + } path := filepath.Join(c.cfg.AdapterDir, name+".lua") _ = os.Remove(path) c.vm.RemoveAdapter(name) diff --git a/internal/core/device_harness_test.go b/internal/core/device_harness_test.go new file mode 100644 index 0000000..0bed988 --- /dev/null +++ b/internal/core/device_harness_test.go @@ -0,0 +1,180 @@ +package core + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "llmsproxy/internal/lua" +) + +// readBundledChannels 读内置 channels 插件源(供行为/变异测试共用)。 +func readBundledChannels(t *testing.T) (string, error) { + t.Helper() + return lua.ReadBundledPlugin("channels") +} + +// deviceHarness 是 device-poll 决策表的可复用测试台:假上游(脚本化逐次响应) +// + channels 插件加载指定源码 + minimax 端点指到假上游。 +// 行为测试(TestChannelsDevicePollBehavior)与变异测试共用它 —— 决策表只有 +// 一份,两处不会漂移。 +type deviceHarness struct { + t *testing.T + ps *lua.Plugins + ts *httptest.Server + step int32 + // script 持续被 HTTP handler 原子读取 + script atomic.Value // []scriptStep +} + +type scriptStep struct { + http int + body string +} + +func newDeviceHarness(t *testing.T, src string) *deviceHarness { + t.Helper() + h := &deviceHarness{t: t} + h.script.Store([]scriptStep{}) + + h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth2/device/code": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"device_code":"DEV1","user_code":"ABCD-EFGH","verification_uri":"https://example/activate","interval":1,"expires_in":600}`) + case "/oauth2/token": + i := int(atomic.AddInt32(&h.step, 1)) - 1 + script := h.script.Load().([]scriptStep) + w.Header().Set("Content-Type", "application/json") + if i >= len(script) { + w.WriteHeader(500) + fmt.Fprint(w, `{"error":"unexpected"}`) + return + } + if script[i].http != 0 && script[i].http != 200 { + w.WriteHeader(script[i].http) + } + fmt.Fprint(w, script[i].body) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(h.ts.Close) + + c := kernelTestCore(t) + h.ps = c.Plugins() + if err := h.ps.LoadSource("channels", src); err != nil { + t.Fatalf("加载: %v", err) + } + if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "minimax", + "device": map[string]interface{}{ + "code_url": h.ts.URL + "/oauth2/device/code", + "token_url": h.ts.URL + "/oauth2/token", + }, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + return h +} + +// setScript 设置 token 端点的逐次响应脚本。`400 {...}` 形式表示非 200。 +func (h *deviceHarness) setScript(steps ...string) { + var out []scriptStep + for _, b := range steps { + code := 200 + switch { + case strings.HasPrefix(b, "400 "): + code, b = 400, b[4:] + case strings.HasPrefix(b, "403 "): + code, b = 403, b[4:] + } + out = append(out, scriptStep{http: code, body: b}) + } + h.script.Store(out) + atomic.StoreInt32(&h.step, 0) +} + +func (h *deviceHarness) begin() map[string]interface{} { + out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"minimax"}`, + }) + if err != nil { + h.t.Fatal(err) + } + return out.JSON.(map[string]interface{}) +} + +func (h *deviceHarness) poll(id string) map[string]interface{} { + out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ + Method: "GET", Query: map[string]string{"id": id}, + }) + return out.JSON.(map[string]interface{}) +} + +// runDecisionTable 跑完整 8 行决策表,全部符合预期返回 true。 +// 这是行为契约的单一事实来源:行为测试用它断言通过;变异测试用它杀变异体。 +func (h *deviceHarness) runDecisionTable() bool { + ok := func(cond bool) bool { return cond } + st := func(steps ...string) map[string]interface{} { + h.setScript(steps...) + j := h.begin() + sid := j["session"].(map[string]interface{})["id"].(string) + return h.poll(sid) + } + + // 行1: 200+pending → 等 + if j := st(`{"status":"pending"}`); j["state"] != "pending" { + return false + } + // 行2: 200+slow_down → 等 + interval+5 + h.setScript(`{"status":"slow_down"}`) + j := h.begin() + sid := j["session"].(map[string]interface{})["id"].(string) + if j2 := h.poll(sid); j2["state"] != "pending" { + return false + } else if iv, _ := j2["interval"].(float64); iv < 6 { + return false + } + // 行3: 200+denied → failed,message 区分于通用错误(杀"denied 不识别"变异体) + if j := st(`{"status":"denied"}`); j["state"] != "failed" { + return false + } else if msg, _ := j["message"].(string); !strings.Contains(msg, "用户拒绝") { + return false + } + // 行4: 200+expired_token → failed,message 带过期字样 + if j := st(`{"status":"expired_token"}`); j["state"] != "failed" { + return false + } else if msg, _ := j["message"].(string); !strings.Contains(msg, "过期") { + return false + } + // 行5: 400+authorization_pending → 等 + if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" { + return false + } + // 行6: 403+slow_down → 等 + interval+5 + h.setScript(`403 {"error":"slow_down"}`) + j = h.begin() + sid = j["session"].(map[string]interface{})["id"].(string) + if j2 := h.poll(sid); j2["state"] != "pending" { + return false + } else if iv, _ := j2["interval"].(float64); iv < 6 { + return false + } + // 行7: 200+access_token → completed(需先授权 add_source) + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) + h.setScript(`{"access_token":"MM-ACCESS-1","refresh_token":"MM-RF-1","nickname":"mm-user"}`) + j = h.begin() + sid = j["session"].(map[string]interface{})["id"].(string) + if j2 := h.poll(sid); j2["state"] != "completed" || j2["account"] != "mm-user" { + return false + } + // 行8: 其它 error → failed 带文本 + if j := st(`{"error":"server_error"}`); j["state"] != "failed" { + return false + } + return ok(true) +} diff --git a/internal/core/device_mutate_test.go b/internal/core/device_mutate_test.go new file mode 100644 index 0000000..223e4d0 --- /dev/null +++ b/internal/core/device_mutate_test.go @@ -0,0 +1,73 @@ +package core + +import ( + "strings" + "testing" +) + +// TestDevicePollMutants 是 device-poll 执行器的变异测试。 +// +// 用户要求:移植要照参考实现的行为形成测试项,且要变异验证测试真的能杀掉 +// 「行为走样」的代码。每个用例把 channels 源里的一个决策分支做语义变异, +// 断言决策表**会失败**(变异体被杀)。变异体幸存 = 该分支没有覆盖。 +func TestDevicePollMutants(t *testing.T) { + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + + // 锚点会命中 deviceOneStep 里两处同形代码(workos 分支 + minimax 分支), + // 全部替换:两个决策表(minimax + workos)都会杀,任一幸存即失败。 + muts := []struct { + name string + from string + to string + }{ + { + name: "slow_down 不再加 interval", + from: `sess.device_interval = (sess.device_interval or d.interval or 5) + step`, + to: `sess.device_interval = (sess.device_interval or d.interval or 5)`, + }, + { + name: "denied 被当成继续等待", + from: `if resp.status == 200 and (status == "denied" or status == "access_denied") then`, + to: `if resp.status == 200 and (status == "denied_x" or status == "access_denied") then`, + }, + { + name: "expired_token 不被识别", + from: `if resp.status == 200 and (status == "expired" or status == "expired_token") then`, + to: `if resp.status == 200 and (status == "expired" or status == "expired_token_x") then`, + }, + { + name: "authorization_pending 被当成失败", + from: `if ecode == "authorization_pending" then return false end`, + to: `if ecode == "authorization_pending_x" then return false end`, + }, + { + name: "成功分支被禁用", + from: `if resp.status == 200 and rec.access_token then`, + to: `if resp.status == 200 and rec.access_token and false then`, + }, + } + + for _, mu := range muts { + t.Run(mu.name, func(t *testing.T) { + if !strings.Contains(src, mu.from) { + t.Fatalf("锚点丢失,先更新实现与锚点:%q", mu.from) + } + // 锚点在 deviceOneStep 的 workos/minimax 两个分支各有一份同形代码, + // 全部替换:任一分支的变异体被任一决策表杀掉才算有覆盖。 + mutated := strings.ReplaceAll(src, mu.from, mu.to) + if mutated == src { + t.Fatal("变异未生效") + } + // 基准必须全过(测试自身有效),变异体必须被杀(有覆盖) + if !newDeviceHarness(t, src).runDecisionTable() { + t.Fatal("基准决策表未全过 —— 测试自身失效") + } + if newDeviceHarness(t, mutated).runDecisionTable() { + t.Fatalf("★ 变异体幸存(%s)—— 该分支无覆盖", mu.name) + } + }) + } +} diff --git a/internal/core/kernel_actions.go b/internal/core/kernel_actions.go new file mode 100644 index 0000000..d5f9fdc --- /dev/null +++ b/internal/core/kernel_actions.go @@ -0,0 +1,396 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "llmsproxy/internal/config" + "llmsproxy/internal/lua" +) + +// 内核动作实现(R4-c) +// +// 把 Core 的公开动作暴露给插件 Lua,走 lua.KernelAPI 接口注入(依赖方向是 +// core → lua,所以接口定义在 lua 包、实现放在这里)。 +// +// # 为什么是"动作面"而不是"给插件一个 Core 指针" +// +// 直接暴露 *Core 会让插件能碰任何未导出的细节,而且每一次内核重构都会变成 +// 对插件作者的破坏性变更。动作面是一份**显式的契约**:每个动作有名字、危险级、 +// 超时预算,加动作是一次有意的行为,而不是顺手多导出一个方法。 +// +// # 凭据边界 +// +// 这里**不实现** list_keys / find_key。Core.ListKeys() 返回的是**明文**网关 +// 密钥,把它交给第三方 Lua 等于把部署的整个凭据面交出去。key 相关的读走掩码 +// 形态(create_key 的返回是唯一一次明文,且是"刚创建的那一个")。 +var _ lua.KernelAPI = (*Core)(nil) + +// Actions implements lua.KernelAPI. +// +// The catalog lives in the lua package because permissions must be checkable even +// when no Core is wired. This method exists so the interface is satisfied by the +// real kernel rather than by a separate adapter type. +func (c *Core) Actions() []lua.KernelAction { return lua.KernelActions() } + +// Invoke implements lua.KernelAPI. It dispatches one action. +// +// Every action is expected to honour ctx. The caller (lua.callKernel) gives up on +// a deadline and returns an error to the plugin, but a kernel action that keeps +// running afterwards would be an invisible leak, so the long ones (probe_all) +// pass ctx down into the network layer. +func (c *Core) Invoke(ctx context.Context, name string, args map[string]interface{}) (interface{}, error) { + switch name { + // ---- 只读 ---- + case "status": + return c.actionStatus(), nil + case "list_sources": + return c.actionListSources(), nil + case "list_models": + return c.actionListModels(), nil + case "list_adapters": + return c.actionListAdapters(), nil + case "list_auto_rules": + return c.actionListAutoRules(), nil + case "adapter_owner": + return c.actionAdapterOwner(args) + + // ---- 写 ---- + case "reload": + if err := ctx.Err(); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true}, c.Reload() + case "reset_health": + // ResetHealth already no-ops on a nil registry (it ranges over + // Providers()), so this needs no guard beyond the nil check inside. + c.ResetHealth() + return map[string]interface{}{"ok": true}, nil + case "probe_all": + r := c.Registry() + if r == nil { + return nil, fmt.Errorf("the registry is not built yet") + } + r.ProbeAll(ctx) + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("probe_all was cut short: %w", err) + } + return map[string]interface{}{"ok": true}, nil + case "save_auto_rules": + return c.actionSaveAutoRules(args) + case "save_key_auto": + return c.actionSaveKeyAuto(args) + case "add_source": + return c.actionAddSource(args) + case "create_key": + return c.actionCreateKey(args) + + // ---- 破坏性 ---- + case "remove_source": + return c.actionRemoveSource(args) + case "delete_key": + return c.actionDeleteKey(args) + case "update_key": + return c.actionUpdateKey(args) + case "remove_adapter": + return c.actionRemoveAdapter(args) + case "set_plugin_enabled": + return c.actionSetPluginEnabled(args) + default: + return nil, fmt.Errorf("unknown kernel action %q", name) + } +} + +// ---- 只读实现 ---- + +func (c *Core) actionStatus() interface{} { + // A plugin can call this DURING LoadDir (its own __init or first hook), which + // runs before rebuildRegistry builds the registry. Returning an empty status + // rather than panicking keeps a load-time call from taking the process down; + // the plugin sees an empty list and the next call gives real data. + out := map[string]interface{}{ + "sources": []interface{}{}, + "auto_slots": []interface{}{}, + "default": c.cfg.DefaultModel, + "ready": false, + } + if r := c.Registry(); r != nil { + out["sources"] = r.Status() + out["ready"] = true + } + if slots := c.AutoSlotStates(); slots != nil { + out["auto_slots"] = slots + } + return out +} + +// actionListSources returns the sources with api_key MASKED. +// +// Masking is not optional: a plugin has no legitimate need for another source's +// key, and a plugin that wrote the source itself already has it in its own +// credential vault. +func (c *Core) actionListSources() interface{} { + srcs := c.Sources() + out := make([]map[string]interface{}, 0, len(srcs)) + for _, s := range srcs { + models := make([]string, 0, len(s.Models)) + for _, m := range s.Models { + models = append(models, m.ID) + } + out = append(out, map[string]interface{}{ + "name": s.Name, + "base_url": s.BaseURL, + "adapter": s.Adapter, + "models": models, + "has_key": s.APIKey != "" || s.APIKeyEnv != "", + "key_hint": maskSecretHint(s.APIKey), + "max_concurrent": s.MaxConcurrent, + }) + } + return out +} + +// maskSecretHint shows only a short suffix so an operator (and a plugin) can tell +// two keys apart without either getting the secret. +func maskSecretHint(k string) string { + if k == "" { + return "" + } + if len(k) <= 4 { + return "***" + } + return "***" + k[len(k)-4:] +} + +func (c *Core) actionListModels() interface{} { + if r := c.Registry(); r != nil { + return r.ModelList() + } + return []string{} +} + +func (c *Core) actionListAdapters() interface{} { + return c.ListAdapters() +} + +func (c *Core) actionListAutoRules() interface{} { + return map[string]interface{}{ + "auto": c.AutoRules(), + "auto_image": c.AutoImageRules(), + } +} + +func (c *Core) actionAdapterOwner(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + if name == "" { + return nil, fmt.Errorf("adapter_owner requires a name") + } + owner := "" + if ps := c.Plugins(); ps != nil { + owner = ps.AdapterOwnerOf(name) + } + return map[string]interface{}{ + "name": name, + "owner": owner, // "" means operator-installed + }, nil +} + +// ---- 写实现 ---- + +func (c *Core) actionSaveAutoRules(args map[string]interface{}) (interface{}, error) { + entries, err := decodeScopes(args["entries"]) + if err != nil { + return nil, err + } + if err := c.SaveAutoRules(entries); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "count": len(entries)}, nil +} + +func (c *Core) actionSaveKeyAuto(args map[string]interface{}) (interface{}, error) { + keyName, _ := args["key_name"].(string) + if keyName == "" { + return nil, fmt.Errorf("save_key_auto requires key_name") + } + entries, err := decodeScopes(args["entries"]) + if err != nil { + return nil, err + } + if err := c.SaveKeyAuto(keyName, entries); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "count": len(entries)}, nil +} + +// actionAddSource is the capability that makes "plugin-driven upstream" real: a +// plugin that has just obtained credentials can wire its endpoint in as a source +// without the operator editing config.yaml. +func (c *Core) actionAddSource(args map[string]interface{}) (interface{}, error) { + raw, ok := args["source"] + if !ok { + return nil, fmt.Errorf("add_source requires a source table") + } + b, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("source is not encodable: %w", err) + } + var src config.Source + if err := json.Unmarshal(b, &src); err != nil { + return nil, fmt.Errorf("source shape is invalid: %w", err) + } + if strings.TrimSpace(src.Name) == "" { + return nil, fmt.Errorf("source requires a name") + } + if strings.TrimSpace(src.BaseURL) == "" { + return nil, fmt.Errorf("source %s requires a base_url", src.Name) + } + if err := c.AddSource(src); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "name": src.Name}, nil +} + +// actionCreateKey creates a gateway key. +// +// The plaintext key is returned ONCE, in this response — the same posture as the +// admin API. It is not retrievable later, so a plugin that needs it must store it +// in its own credential vault immediately. +func (c *Core) actionCreateKey(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + role, _ := args["role"].(string) + if role == "" { + role = "user" + } + models, err := decodeScopes(args["models"]) + if err != nil { + return nil, err + } + k, err := c.CreateKey(name, role, models, "created by plugin") + if err != nil { + return nil, err + } + return map[string]interface{}{ + "key": k.Key, + "name": k.Name, + "role": k.Role, + "models": k.Models, + }, nil +} + +// ---- 破坏性实现 ---- + +func (c *Core) actionRemoveSource(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + if name == "" { + return nil, fmt.Errorf("remove_source requires a name") + } + // The last source is not removable: an empty registry turns every request + // into a 503, and the operator would have to fix it from the filesystem. + if len(c.Sources()) <= 1 { + return nil, fmt.Errorf("refusing to remove the last source: the gateway would have nothing to route to") + } + if err := c.RemoveSource(name); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "name": name}, nil +} + +func (c *Core) actionDeleteKey(args map[string]interface{}) (interface{}, error) { + key, _ := args["key"].(string) + if key == "" { + return nil, fmt.Errorf("delete_key requires the key to delete") + } + ok, err := c.DeleteKey(key) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("no such key") + } + return map[string]interface{}{"ok": true}, nil +} + +func (c *Core) actionUpdateKey(args map[string]interface{}) (interface{}, error) { + key, _ := args["key"].(string) + if key == "" { + return nil, fmt.Errorf("update_key requires the key") + } + name, _ := args["name"].(string) + role, _ := args["role"].(string) + models, err := decodeScopes(args["models"]) + if err != nil { + return nil, err + } + k, err := c.UpdateKey(key, name, role, models, "") + if err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "name": k.Name, "role": k.Role}, nil +} + +// actionRemoveAdapter deletes an adapter, but only one the CALLER plugin owns — +// the ownership check lives in Plugins.RemoveAdapter (see the adapter bridge). +// A plugin can always remove its own; removing an operator's or a built-in is +// refused there, and that refusal is the security property. +func (c *Core) actionRemoveAdapter(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + if name == "" { + return nil, fmt.Errorf("remove_adapter requires a name") + } + ps := c.Plugins() + if ps == nil { + return nil, fmt.Errorf("no plugins loaded") + } + plugin, _ := args["plugin"].(string) + if plugin == "" { + return nil, fmt.Errorf("remove_adapter needs the calling plugin's name") + } + if err := ps.RemoveAdapter(plugin, name); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "name": name}, nil +} + +func (c *Core) actionSetPluginEnabled(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + if name == "" { + return nil, fmt.Errorf("set_plugin_enabled requires a name") + } + enabled, ok := args["enabled"].(bool) + if !ok { + return nil, fmt.Errorf("set_plugin_enabled requires enabled (boolean)") + } + ps := c.Plugins() + if ps == nil { + return nil, fmt.Errorf("no plugins loaded") + } + if err := ps.SetEnabled(name, enabled); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "name": name, "enabled": enabled}, nil +} + +// decodeScopes converts a Lua-decoded array of model-scope tables into +// []config.ModelScope. It accepts both an explicit array and a single table so a +// one-entry chain does not need a wrapper. +func decodeScopes(v interface{}) ([]config.ModelScope, error) { + if v == nil { + return nil, nil + } + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("entries are not encodable: %w", err) + } + var list []config.ModelScope + if err := json.Unmarshal(b, &list); err == nil { + return list, nil + } + var one config.ModelScope + if err := json.Unmarshal(b, &one); err != nil { + return nil, fmt.Errorf("entries shape is invalid: %w", err) + } + return []config.ModelScope{one}, nil +} diff --git a/internal/core/kernel_actions_test.go b/internal/core/kernel_actions_test.go new file mode 100644 index 0000000..e86dc68 --- /dev/null +++ b/internal/core/kernel_actions_test.go @@ -0,0 +1,336 @@ +package core + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "llmsproxy/internal/config" + "llmsproxy/internal/lua" +) + +// 本文件验证 R4-c/R4-d 的闭环:**插件自己驱动外部源的链接认证,然后把源接进内核**。 +// +// 这是用户点明的"同类插件"能力(对照 dsh-our-free-model):插件主导 OAuth 登录、 +// 存凭据、定时领额度、把拿到的端点注册成内核上游。此前 llmsproxy 的插件只能管 +// 自己的适配器,够不到内核。 + +// kernelTestCore 起一个最小 core(一个源 + 一个 key)。 +func kernelTestCore(t *testing.T) *Core { + t.Helper() + dir := t.TempDir() + cfg := &config.Config{ + Path: filepath.Join(dir, "config.yaml"), + AdapterDir: filepath.Join(dir, "adapters"), + PluginDir: filepath.Join(dir, "plugins"), + RuntimeFile: filepath.Join(dir, "runtime.json"), + DefaultModel: "AUTO", + GatewayKeys: []string{"sk-kernel-0001"}, + Sources: []config.Source{{ + Name: "primary", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", + APIKey: "secret-primary-key", MaxConcurrent: 1, + Models: []config.Model{{ID: "m1", Kind: "chat"}}, + }}, + } + c, err := NewFromConfig(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(c.Close) + return c +} + +// grant 给插件授权若干动作(模拟运维在设置页点选)。 +func grant(t *testing.T, c *Core, plugin string, actions ...string) { + t.Helper() + if err := c.Plugins().SetPluginKernelPermission(plugin, actions, false); err != nil { + t.Fatalf("grant %v to %s: %v", actions, plugin, err) + } +} + +const kernelProbePlugin = ` +local plugin = { name = "kernelprobe", version = "1.0" } +plugin.exports = { "read_status", "read_models", "read_sources", "try_delete_key", "probe_count", "call_reload" } +plugin.kernel = { "status", "list_models", "reload" } + +function plugin.read_status() + local st, err = __llmsproxy_kernel.call("status", {}) + if err then return { err = err } end + return { ready = st.ready, nsources = #(st.sources or {}) } +end + +function plugin.read_models() + local m, err = __llmsproxy_kernel.call("list_models", {}) + if err then return { err = err } end + return { models = m } +end + +-- sources 动作返回的密钥必须是掩码的 +function plugin.read_sources() + local s, err = __llmsproxy_kernel.call("list_sources", {}) + if err then return { err = err } end + return { sources = s } +end + +-- 未授权的破坏性动作必须被拒 +function plugin.try_delete_key() + local _, err = __llmsproxy_kernel.call("delete_key", { key = "sk-kernel-0001" }) + return { err = err } +end + +-- 授权数量可见(插件据此优雅降级) +function plugin.probe_count() + local acts = __llmsproxy_kernel.actions() + local granted = 0 + for _, a in ipairs(acts) do + if a.granted then granted = granted + 1 end + end + return { total = #acts, granted = granted } +end + +function plugin.call_reload() + local r, err = __llmsproxy_kernel.call("reload", {}) + return { ok = r and r.ok, err = err } +end +return plugin +` + +// TestPluginCallsKernelActions 验证插件能调内核动作,且权限边界成立。 +func TestPluginCallsKernelActions(t *testing.T) { + c := kernelTestCore(t) + if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil { + t.Fatal(err) + } + grant(t, c, "kernelprobe", "status", "list_models", "list_sources") + + // 只读动作可用 + out, err := c.Plugins().Call("kernelprobe", "read_status", map[string]interface{}{}) + if err != nil { + t.Fatalf("read_status: %v", err) + } + m := out.(map[string]interface{}) + if m["err"] != nil { + t.Fatalf("status 调用失败: %v", m["err"]) + } + if m["ready"] != true { + t.Errorf("registry 应已就绪: %#v", m) + } + if n, _ := m["nsources"].(float64); n < 1 { + t.Errorf("应至少有 1 个源, 实得 %v", m["nsources"]) + } + + // 模型列表 + out, err = c.Plugins().Call("kernelprobe", "read_models", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + if models, ok := out.(map[string]interface{})["models"].([]interface{}); !ok || len(models) == 0 { + t.Errorf("模型列表应为非空数组: %#v", out) + } +} + +// TestKernelActionMasksSourceKeys 是安全断言:list_sources **不得**泄漏 api_key。 +func TestKernelActionMasksSourceKeys(t *testing.T) { + c := kernelTestCore(t) + if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil { + t.Fatal(err) + } + grant(t, c, "kernelprobe", "list_sources") + + out, err := c.Plugins().Call("kernelprobe", "read_sources", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + // 把整个返回值序列化后检查:明文密钥绝不能出现在里面 + b, _ := json.Marshal(out) + if s := string(b); json.Valid(b) && contains(s, "secret-primary-key") { + t.Fatalf("list_sources 泄漏了源的明文 api_key: %s", s) + } + // 但应有可区分性(has_key + 掩码后缀) + if !contains(string(b), "***") { + t.Errorf("应给出掩码提示而不是什么都没有: %s", string(b)) + } +} + +// TestKernelActionDeniedWithoutGrant 是权限默认拒绝的断言。 +func TestKernelActionDeniedWithoutGrant(t *testing.T) { + c := kernelTestCore(t) + if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil { + t.Fatal(err) + } + // 故意不授权任何动作 + out, err := c.Plugins().Call("kernelprobe", "try_delete_key", map[string]interface{}{}) + if err != nil { + t.Fatalf("调用本身不该报错(拒绝应作为返回值): %v", err) + } + m := out.(map[string]interface{}) + if m["err"] == nil || m["err"] == "" { + t.Fatal("未授权的 delete_key 必须被拒") + } + t.Logf("拒绝信息: %v", m["err"]) + // 拒绝信息要点名动作与插件(否则运维无法行动) + msg, _ := m["err"].(string) + if !contains(msg, "delete_key") || !contains(msg, "kernelprobe") { + t.Errorf("拒绝信息应点名动作与插件, 实得 %q", msg) + } + // 且密钥必须还在 + if _, ok := c.FindKey("sk-kernel-0001"); !ok { + t.Fatal("被拒的动作不应真的执行") + } +} + +// TestKernelActionsReportGranted 插件能看到自己拿到了哪些权限。 +func TestKernelActionsReportGranted(t *testing.T) { + c := kernelTestCore(t) + if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil { + t.Fatal(err) + } + grant(t, c, "kernelprobe", "status", "list_models") + + out, err := c.Plugins().Call("kernelprobe", "probe_count", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + m := out.(map[string]interface{}) + total, _ := m["total"].(float64) + granted, _ := m["granted"].(float64) + if total < 15 { + t.Errorf("动作总数应 >=15, 实得 %v", total) + } + if granted != 2 { + t.Errorf("授权数应为 2(status+list_models), 实得 %v", granted) + } +} + +// TestPluginRegistersSourceIntoKernel 是"同类插件"的核心闭环: +// 插件通过内核动作把一个新的外部源接进网关。 +func TestPluginRegistersSourceIntoKernel(t *testing.T) { + c := kernelTestCore(t) + code := ` +local plugin = { name = "upstream", version = "1.0" } +plugin.exports = { "add_my_source" } +plugin.kernel = { "add_source", "list_sources" } + +function plugin.add_my_source(args) + local src = { + name = "plugin-provided", + base_url = "http://127.0.0.1:9/v1", + adapter = "openai", + api_key = "plugin-managed-token", + max_concurrent = 2, + models = { { id = "plugin-model", kind = "chat", priority = 10 } }, + } + local r, err = __llmsproxy_kernel.call("add_source", { source = src }) + if err then return { err = err } end + return { ok = r.ok, name = r.name } +end +return plugin +` + if err := c.Plugins().LoadSource("upstream", code); err != nil { + t.Fatal(err) + } + grant(t, c, "upstream", "add_source", "list_sources") + + out, err := c.Plugins().Call("upstream", "add_my_source", map[string]interface{}{}) + if err != nil { + t.Fatalf("add_source: %v", err) + } + m := out.(map[string]interface{}) + if m["err"] != nil { + t.Fatalf("插件注册源失败: %v", m["err"]) + } + if m["name"] != "plugin-provided" { + t.Fatalf("返回的源名不对: %#v", m) + } + + // 内核侧必须真的有了这个源 —— 这是"接进网关"的定义 + found := false + for _, s := range c.Sources() { + if s.Name == "plugin-provided" { + found = true + if s.BaseURL != "http://127.0.0.1:9/v1" { + t.Errorf("base_url 未落库: %q", s.BaseURL) + } + } + } + if !found { + t.Fatal("插件注册的源没有出现在内核源列表里") + } + // 且模型已可路由 + for _, id := range c.Registry().ModelList() { + if id == "plugin-model" { + return // 通过 + } + } + t.Errorf("插件注册的模型未进入可路由列表: %v", c.Registry().ModelList()) +} + +// TestKernelPermissionPersists 授权必须落盘(否则重启即失效)。 +func TestKernelPermissionPersists(t *testing.T) { + c := kernelTestCore(t) + if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil { + t.Fatal(err) + } + grant(t, c, "kernelprobe", "status", "reload") + + pdir := c.Config().PluginDir + path := filepath.Join(pdir, ".kernel-perms.json") + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("授权未落盘: %v", err) + } + if !contains(string(b), "reload") { + t.Errorf("落盘内容缺少动作名: %s", b) + } + + // 模拟重启:新建 Plugins、加载授权、权限仍在 + vm2 := lua.NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm2.Start(); err != nil { + t.Fatal(err) + } + defer vm2.Stop() + ps2 := lua.NewPlugins(vm2, pdir) + if err := ps2.LoadKernelPermissions(); err != nil { + t.Fatal(err) + } + perm := ps2.PluginKernelPermission("kernelprobe") + if len(perm.Actions) != 2 { + t.Fatalf("重启后授权应保留 2 项, 实得 %#v", perm.Actions) + } +} + +// TestUnknownKernelActionRejected 拼错的动作名必须在授权时就被拒, +// 而不是静默授予"什么都不允许"。 +func TestUnknownKernelActionRejected(t *testing.T) { + c := kernelTestCore(t) + if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil { + t.Fatal(err) + } + if err := c.Plugins().SetPluginKernelPermission("kernelprobe", []string{"no_such_action"}, false); err == nil { + t.Fatal("未知动作名应被拒") + } +} + +// TestKernelActionSurfaceExcludesPlaintextKeys 是凭据边界的回归断言: +// 动作面里绝不能出现返回明文密钥的动作(ListKeys/FindKey 被有意排除)。 +func TestKernelActionSurfaceExcludesPlaintextKeys(t *testing.T) { + for _, a := range lua.KernelActions() { + switch a.Name { + case "list_keys", "find_key", "get_key": + t.Fatalf("动作面出现了明文密钥读取动作 %q —— 这会把部署的凭据面交给插件", a.Name) + } + } +} + +func contains(haystack, needle string) bool { + return len(needle) > 0 && len(haystack) >= len(needle) && + func() bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false + }() +} diff --git a/internal/core/kernel_effect_test.go b/internal/core/kernel_effect_test.go new file mode 100644 index 0000000..f873f69 --- /dev/null +++ b/internal/core/kernel_effect_test.go @@ -0,0 +1,39 @@ +package core + +import "testing" + +// TestGrantedDestructiveActionTakesEffect 确认授权后的破坏性动作**真的执行**。 +// +// 上一条测试只证明"不死锁、有返回"。一个把所有动作都变成空转的 dispatch 也能 +// 通过它 —— 所以这条单独断言副作用确实落地(密钥真的被删)。 +func TestGrantedDestructiveActionTakesEffect(t *testing.T) { + c := kernelTestCore(t) + code := ` +local plugin = { name = "eff", version = "1.0" } +plugin.exports = { "del" } +function plugin.del(args) + local r, err = __llmsproxy_kernel.call("delete_key", { key = args.key }) + return { ok = r ~= nil, err = err } +end +return plugin +` + if err := c.Plugins().LoadSource("eff", code); err != nil { + t.Fatal(err) + } + _ = c.Plugins().SetPluginKernelPermission("eff", []string{"delete_key"}, false) + + if _, ok := c.FindKey("sk-kernel-0001"); !ok { + t.Fatal("前置条件:key 应存在") + } + out, err := c.Plugins().Call("eff", "del", map[string]interface{}{"key": "sk-kernel-0001"}) + if err != nil { + t.Fatal(err) + } + m := out.(map[string]interface{}) + if m["ok"] != true { + t.Fatalf("动作未成功: %#v", m) + } + if _, ok := c.FindKey("sk-kernel-0001"); ok { + t.Fatal("★ 动作报告成功但副作用没发生 —— dispatch 是空转") + } +} diff --git a/internal/core/kernel_no_deadlock_test.go b/internal/core/kernel_no_deadlock_test.go new file mode 100644 index 0000000..d521f02 --- /dev/null +++ b/internal/core/kernel_no_deadlock_test.go @@ -0,0 +1,69 @@ +package core + +import ( + "testing" + "time" + + "llmsproxy/internal/lua" +) + +// TestNoKernelActionDeadlocksFromLua 对**每个**内核动作做同一条断言: +// 在插件 Lua 调用(此时持有 p.mu)内执行时不得死锁。 +// +// 为什么是"每个"而不是"那个出问题的":最初只有 set_plugin_enabled 被发现自死锁 +// (它经 SetEnabled 重入 p.mu),但"是否还有别的动作也会重入"只能靠枚举回答。 +// 这条测试就是那次枚举的固化 —— 将来新增动作时如果也重入插件锁,它会失败。 +// +// 同时它验证了"超时后副作用仍在幕后执行"这个更隐蔽的变体:那个缺陷的表现是 +// 插件收到 error、但动作其实生效了。断言方式只是"不死锁 + 返回",因为死锁与 +// 迟到副作用是同一个根因(在错误的锁上下文里执行)。 +func TestNoKernelActionDeadlocksFromLua(t *testing.T) { + for _, a := range lua.KernelActions() { + a := a + t.Run(a.Name, func(t *testing.T) { + c := kernelTestCore(t) + code := ` +local plugin = { name = "probe_all_actions", version = "1.0" } +plugin.exports = { "run" } +function plugin.run(args) + local r, err = __llmsproxy_kernel.call(args.action, args.arg or {}) + return { ok = r ~= nil, err = err } +end +return plugin +` + if err := c.Plugins().LoadSource("probe_all_actions", code); err != nil { + t.Fatal(err) + } + // 授权所有动作(含破坏性),以便真正执行到 + all := make([]string, 0) + for _, x := range lua.KernelActions() { + all = append(all, x.Name) + } + if err := c.Plugins().SetPluginKernelPermission("probe_all_actions", all, false); err != nil { + t.Fatal(err) + } + arg := map[string]interface{}{} + switch a.Name { + case "name", "adapter_owner": + arg["name"] = "primary" + } + if a.Name == "adapter_owner" { + arg = map[string]interface{}{"name": "openai"} + } + done := make(chan struct{}) + go func() { + defer close(done) + _, err := c.Plugins().Call("probe_all_actions", "run", + map[string]interface{}{"action": a.Name, "arg": arg}) + if err != nil { + t.Logf("%s 返回 err=%v", a.Name, err) + } + }() + select { + case <-done: + case <-time.After(8 * time.Second): + t.Fatalf("动作 %s 在 Lua 调用内死锁(deferred=%v danger=%s)", a.Name, a.Deferred, a.Danger) + } + }) + } +} diff --git a/internal/gateway/api.go b/internal/gateway/api.go index f7996d9..4dc75f0 100644 --- a/internal/gateway/api.go +++ b/internal/gateway/api.go @@ -470,8 +470,8 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { // the all-time view showed the truth, which read as "the gateway is // idle" precisely when it was busiest. A calendar window cannot // filter it, so it travels with every response unfiltered. - "active": g.stats.Active(), - "records": page.Records, + "active": g.stats.Active(), + "records": page.Records, // The paging cursor has to travel with the first page, otherwise // the scroll handler has nothing to page from and the table is // stuck at one screen forever. diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index ea67bc8..d848911 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -21,21 +21,79 @@ import ( ) // chatRequest mirrors the OpenAI chat completions request the gateway accepts. +// chatRequest is the gateway's IN-BOUND client shape for /v1/chat/completions. +// +// It is deliberately an OpenAI wire struct, NOT the internal unified format: +// this is one client protocol, and the kernel parses it before anything else +// knows which protocol the rest of the request will speak. What reaches an +// adapter is types.UnifiedRequest (the maximal set), which Thinking populates. type chatRequest struct { - Model string `json:"model"` - Messages []types.ChatMessage `json:"messages"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools []interface{} `json:"tools,omitempty"` - ToolChoice interface{} `json:"tool_choice,omitempty"` + Model string `json:"model"` + Messages []types.ChatMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools []interface{} `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,omitempty"` + // DisableThinking is the OpenAI-ecosystem spelling of "turn thinking off". + // It is translated into the protocol-neutral Thinking field on the way in + // (see thinkingFromClient), so that flag never travels under this name. DisableThinking bool `json:"disable_thinking"` ExtraBody map[string]interface{} `json:"extra_body,omitempty"` + // Thinking accepts the neutral shape directly, so a client already speaking + // the unified format (or an adapter-served protocol that names it that way) + // does not have to round-trip through disable_thinking. + Thinking *types.ThinkingConfig `json:"thinking,omitempty"` + // PromptCacheKey 是 OpenAI 原生的缓存键;部分客户端用它携带会话标识。 PromptCacheKey string `json:"prompt_cache_key,omitempty"` } +// thinkingFromClient folds the client's spelling of "no thinking" into the +// neutral Thinking field. +// +// Precedence: an explicit `thinking` object wins, because it carries a budget +// the boolean cannot express. disable_thinking only ever sets Enabled=false — +// it says nothing about how much, and inventing a budget there would silently +// override the upstream's default. +func (r *chatRequest) thinkingFromClient() *types.ThinkingConfig { + if r.Thinking != nil { + return r.Thinking + } + if r.DisableThinking { + no := false + return &types.ThinkingConfig{Enabled: &no} + } + return nil +} + +// rawTools / rawToolChoice re-encode the loosely-typed client fields into the +// RawMessage the internal format carries. The kernel never interprets them: +// they are the client's payload, passed through to the adapter, which is the +// only layer that knows the tool schema of any particular protocol. +func (r *chatRequest) rawTools() json.RawMessage { + if len(r.Tools) == 0 { + return nil + } + b, err := json.Marshal(r.Tools) + if err != nil { + return nil + } + return b +} + +func (r *chatRequest) rawToolChoice() json.RawMessage { + if r.ToolChoice == nil { + return nil + } + b, err := json.Marshal(r.ToolChoice) + if err != nil { + return nil + } + return b +} + // ChatCompletion is the non-streaming OpenAI response object. type ChatCompletion struct { ID string `json:"id"` @@ -424,15 +482,15 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { done := g.stats.Begin() defer done() inner := &types.ChatRequest{ - Messages: req.Messages, - Temperature: req.Temperature, - MaxTokens: req.MaxTokens, - Stream: req.Stream, - Tools: req.Tools, - ToolChoice: req.ToolChoice, - DisableThinking: req.DisableThinking, - ExtraBody: req.ExtraBody, - ClientSession: clientSession, + Messages: req.Messages, + Temperature: req.Temperature, + MaxTokens: req.MaxTokens, + Stream: req.Stream, + Tools: req.rawTools(), + ToolChoice: req.rawToolChoice(), + Thinking: req.thinkingFromClient(), + ExtraBody: req.ExtraBody, + ClientSession: clientSession, } rec := &Req{ Key: keyID(reqKey(ctx)), @@ -484,16 +542,16 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { defer done() inner := &types.ChatRequest{ - Model: effective, - Messages: req.Messages, - Temperature: req.Temperature, - MaxTokens: req.MaxTokens, - Stream: req.Stream, - Tools: req.Tools, - ToolChoice: req.ToolChoice, - DisableThinking: req.DisableThinking, - ExtraBody: req.ExtraBody, - ClientSession: clientSession, + Model: effective, + Messages: req.Messages, + Temperature: req.Temperature, + MaxTokens: req.MaxTokens, + Stream: req.Stream, + Tools: req.rawTools(), + ToolChoice: req.rawToolChoice(), + Thinking: req.thinkingFromClient(), + ExtraBody: req.ExtraBody, + ClientSession: clientSession, } rec := &Req{ Key: keyID(reqKey(ctx)), @@ -586,7 +644,7 @@ func estimatePromptTokens(req *types.ChatRequest) int64 { } b, _ := json.Marshal(struct { Messages []types.ChatMessage `json:"messages"` - Tools []interface{} `json:"tools,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` }{Messages: req.Messages, Tools: req.Tools}) if len(b) == 0 { return 0 @@ -796,7 +854,7 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon // keeps the billing plugin from guessing. // // Leaving it unrecorded is worse than useless: the adapters already parse it - // and forward it (opencodego/gemini/anthropic all do), so the number exists + // and forward it (gemini/anthropic/qwen38 all do), so the number exists // in the response and is thrown away at the one place that could keep it. if d := resp.TokenUsage.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 { rec.Reasoning = int64(d.ReasoningTokens) @@ -817,29 +875,21 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon } } -// writeChatCompletion renders a unified response as an OpenAI -// chat.completion object. modelName is the id clients see as the serving -// model: the requested id for direct routes, the exact slot model for AUTO. -func writeChatCompletion(w http.ResponseWriter, resp *types.UnifiedResponse, modelName string) { - msg := RespMessage{Role: "assistant", Content: resp.Content} - if resp.ReasoningContent != "" { - msg.ReasoningContent = resp.ReasoningContent +// writeChatCompletion renders a unified response through the out-bound +// Serializer. The default serializer reproduces the OpenAI wire shape this +// function used to build inline (locked byte-for-byte by e2e golden); an +// adapter that declares serialize_response overrides it. modelName is the id +// clients see as the serving model: the requested id for direct routes, the +// exact slot model for AUTO. +func writeChatCompletion(w http.ResponseWriter, s Serializer, resp *types.UnifiedResponse, modelName string) { + body, err := s.NonStream(resp, modelName, newWireID(), newCreated()) + if err != nil { + writeError(w, http.StatusInternalServerError, "serialize_error", "failed to serialize response") + return } - if len(resp.ToolCalls) > 0 { - msg.ToolCalls = toolCallsWire(resp.ToolCalls) - } - out := ChatCompletion{ - ID: newID(), - Object: "chat.completion", - Created: time.Now().Unix(), - Model: modelName, - Choices: []ChatChoice{{Index: 0, Message: msg, FinishReason: resp.FinishReason}}, - Cost: resp.Cost, - } - if resp.TokenUsage.Total > 0 || resp.TokenUsage.Prompt > 0 || resp.TokenUsage.Completion > 0 { - out.Usage = &resp.TokenUsage - } - writeJSON(w, http.StatusOK, out) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) } // singleChat runs a direct (model-pinned) non-streaming request across the @@ -864,7 +914,7 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [ // the total latency. rec.FirstByteMs = rec.LatMs g.writeRec(rec) - writeChatCompletion(w, resp, effective) + writeChatCompletion(w, g.serializerFor(usedSrc, cands), resp, effective) } // writeRec records a finished request (audit + aggregates) and fires the @@ -1116,24 +1166,29 @@ func mergeUsage(prev, cur *types.TokenUsage) *types.TokenUsage { return &out } -// pumpStream writes the full SSE sequence for a started stream: role -// preamble, one chunk per unified delta, the terminating finish_reason, the -// OpenAI-standard final usage chunk (empty choices) and [DONE]. modelName -// follows writeChatCompletion's rule (requested id for direct routes, exact -// slot model for AUTO). -func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan types.UnifiedChunk, modelName string, t0 time.Time) { +// pumpStream writes the full SSE sequence for a started stream. Every byte on +// the wire comes from the Serializer, so the OpenAI frame shapes live in +// serializer.go rather than here, and an adapter that declares serialize_chunk +// replaces them wholesale. +// +// This function owns only transport concerns — headers, the flush cadence, +// first-byte timing, and audit accounting — plus the protocol-agnostic rule +// that usage seen anywhere in the stream becomes the recorded usage. Whether a +// trailing usage frame is emitted, and in what shape, is the serializer's +// decision. +func (g *Gateway) pumpStream(w http.ResponseWriter, ser Serializer, rec *Req, chunks <-chan types.UnifiedChunk, modelName string, t0 time.Time) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) flusher, _ := w.(http.Flusher) - id := newID() - created := time.Now().Unix() - send := func(obj interface{}) bool { - b, err := json.Marshal(obj) - if err != nil { - return false + id := newWireID() + created := newCreated() + st := &StreamState{} + send := func(b []byte) bool { + if len(b) == 0 { + return true } if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil { return false @@ -1144,102 +1199,66 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan type return true } - if !send(ChatChunk{ - ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName, - Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{Role: "assistant"}}}, - }) { - return + if b, err := ser.OpenRolePreamble(modelName, id, created, st); err == nil { + if !send(b) { + return + } } // First SSE byte sent to the client: record time-to-first-byte for the // source's status-page latency average. rec.FirstByteMs = time.Since(t0).Milliseconds() - var lastUsage *types.TokenUsage - var lastCost string - lastFinish := "" + for ck := range chunks { if ck.Usage != nil { - lastUsage = mergeUsage(lastUsage, ck.Usage) + st.Usage = mergeUsage(st.Usage, ck.Usage) } if ck.Cost != "" { - lastCost = ck.Cost + st.Cost = ck.Cost } - chunk := ChatChunk{ - ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName, + b, err := ser.Chunk(&ck, modelName, id, created, st) + if err != nil { + return } - delta := RespMessage{Role: "assistant", Content: ck.Content} - if ck.ReasoningContent != "" { - delta.ReasoningContent = ck.ReasoningContent - } - if len(ck.ToolCalls) > 0 { - delta.ToolCalls = ck.ToolCalls - } - choice := ChunkChoice{Index: 0, Delta: delta} - if ck.Done { - fin := ck.FinishReason - if fin == "" { - fin = "stop" - } - lastFinish = fin - choice.FinishReason = &fin - } - chunk.Choices = []ChunkChoice{choice} rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)+len(ck.ToolCalls)) / 3 - if !send(chunk) { + if !send(b) { return } } - finalFinish := lastFinish - if finalFinish == "" { - finalFinish = "stop" - } - send(ChatChunk{ - ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName, - Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &finalFinish}}, - }) - // Final usage chunk (OpenAI standard: empty choices + usage before [DONE]). - // Prefer the upstream's exact usage if the stream carried it; fall back to - // the gateway's estimate otherwise. - // Write the upstream's exact numbers back onto the audit record. Without - // this the streamed path kept only the per-chunk byte estimate, so the same + + // Write the upstream's exact usage back onto the audit record. Without this + // the streamed path kept only the per-chunk byte estimate, so the same // request recorded ~1.4x its real completion tokens (measured: upstream 100, // audit 145) while the non-streaming path recorded 100. Two paths, two // different numbers for one request is a reporting bug, not a rounding one. - if lastUsage != nil { - if lastUsage.Prompt > 0 { - rec.Prompt = int64(lastUsage.Prompt) + if st.Usage != nil { + if st.Usage.Prompt > 0 { + rec.Prompt = int64(st.Usage.Prompt) } - if lastUsage.Completion > 0 { - rec.Compl = int64(lastUsage.Completion) + if st.Usage.Completion > 0 { + rec.Compl = int64(st.Usage.Completion) } - } - var tut *types.TokenUsage - if lastUsage != nil { - tut = lastUsage - applyStreamUsageDetails(rec, lastUsage) + applyStreamUsageDetails(rec, st.Usage) } else if rec.Prompt+rec.Compl > 0 { - u := types.TokenUsage{ + // No upstream usage on the stream: the recorded per-chunk byte + // estimate is the best number available, so hand it to the serializer + // as the usage rather than dropping it. + st.Usage = &types.TokenUsage{ Prompt: int(rec.Prompt), Completion: int(rec.Compl), Total: int(rec.Prompt + rec.Compl), } - tut = &u } - if tut != nil { - send(ChatChunk{ - ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName, - Choices: []ChunkChoice{}, - Usage: tut, - Cost: lastCost, - }) - } else if lastCost != "" { - // Cost arrived without any usage (OpenCode sends it on its own frame). - send(ChatChunk{ - ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName, - Choices: []ChunkChoice{}, - Cost: lastCost, - }) + + if frames, err := ser.Close(modelName, id, created, st); err == nil { + for _, b := range frames { + if !send(b) { + return + } + } + } + if marker := ser.DoneMarker(); marker != "" { + fmt.Fprintf(w, "data: %s\n\n", marker) } - fmt.Fprintf(w, "data: [DONE]\n\n") if flusher != nil { flusher.Flush() } @@ -1270,7 +1289,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [ rec.Source = usedSrc g.fireRouted(ctx, "stream", usedSrc, rec.Model, -1, true) rec.Prompt = estimatePromptTokens(req) - g.pumpStream(w, rec, chunks, effective, t0) + g.pumpStream(w, g.serializerFor(usedSrc, cands), rec, chunks, effective, t0) } // singleChatAuto runs a non-streaming AUTO request down the chain (see @@ -1303,7 +1322,7 @@ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, cha g.fireRouted(ctx, "chat", usedSrc, usedModel, -2, false) rec.FirstByteMs = rec.LatMs g.writeRec(rec) - writeChatCompletion(w, resp, usedModel) + writeChatCompletion(w, g.serializerFor(usedSrc, nil), resp, usedModel) } // streamChatAuto streams an AUTO request down the chain. A slot is abandoned @@ -1333,7 +1352,7 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha rec.Source = usedSrc g.fireRouted(ctx, "stream", usedSrc, rec.Model, -2, true) rec.Prompt = estimatePromptTokens(req) - g.pumpStream(w, rec, chunks, usedModel, t0) + g.pumpStream(w, g.serializerFor(usedSrc, nil), rec, chunks, usedModel, t0) } func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { diff --git a/internal/gateway/chat_reasoning_test.go b/internal/gateway/chat_reasoning_test.go index cd22606..bc2ed4b 100644 --- a/internal/gateway/chat_reasoning_test.go +++ b/internal/gateway/chat_reasoning_test.go @@ -9,7 +9,7 @@ import ( // TestRecordChatUsageKeepsReasoningTokens pins the reasoning-token plumbing. // // The adapters already parse completion_tokens_details.reasoning_tokens and -// forward it (opencodego, gemini, anthropic all do), and types.go has carried +// forward it (qwen38, gemini, anthropic all do), and types.go has carried // the field since it was defined — but nothing ever READ it. The number arrived // in the response and was discarded at the one place that could have kept it, // so a pricing rule had no way to split thinking from output. diff --git a/internal/gateway/discover_api.go b/internal/gateway/discover_api.go index 0840954..257dc77 100644 --- a/internal/gateway/discover_api.go +++ b/internal/gateway/discover_api.go @@ -5,10 +5,10 @@ import ( "encoding/json" "errors" "io" + "llmsproxy/internal/config" "net/http" "strings" "time" - "llmsproxy/internal/config" ) // HTTP surface for model discovery. @@ -68,9 +68,9 @@ type discoverResponse struct { OK bool `json:"ok"` // Normalized source fields, ready to be echoed back to /api/sources. - BaseURL string `json:"base_url"` - Adapter string `json:"adapter"` - Models []string `json:"models"` + BaseURL string `json:"base_url"` + Adapter string `json:"adapter"` + Models []string `json:"models"` ImageModels []string `json:"image_models"` // Endpoint is set only when discovery had to move a version segment. @@ -152,18 +152,18 @@ func (g *Gateway) handleSourceDiscoverAPI(w http.ResponseWriter, r *http.Request } out := &discoverResponse{ - OK: true, - BaseURL: res.BaseURL, - Adapter: req.Adapter, - Models: chat, - ImageModels: image, + OK: true, + BaseURL: res.BaseURL, + Adapter: req.Adapter, + Models: chat, + ImageModels: image, Endpoint: res.Endpoint, ImageEndpoint: res.ImageEndpoint, ModelsPath: res.ModelsPath, - Tried: res.Tried, - LatencyMS: res.LatencyMS, - Note: res.Note, - Warnings: warns, + Tried: res.Tried, + LatencyMS: res.LatencyMS, + Note: res.Note, + Warnings: warns, } if len(chat) == 0 && len(image) == 0 { out.Warnings = append(out.Warnings, @@ -207,14 +207,14 @@ func (g *Gateway) handleSourceDiscoverAPI(w http.ResponseWriter, r *http.Request // keep the stored one" rule, and going through a second decoder here would // duplicate (and eventually diverge from) that logic. src := config.Source{ - Name: name, - BaseURL: res.BaseURL, - APIKey: g.resolveAPIKey(name, req.APIKey, exists), - Adapter: req.Adapter, + Name: name, + BaseURL: res.BaseURL, + APIKey: g.resolveAPIKey(name, req.APIKey, exists), + Adapter: req.Adapter, Endpoint: res.Endpoint, ImageEndpoint: res.ImageEndpoint, Models: modelsToConfig(chat, image), - Temperature: req.Temperature, + Temperature: req.Temperature, } if req.MaxConcurrent > 0 { src.MaxConcurrent = req.MaxConcurrent diff --git a/internal/gateway/discover_api_test.go b/internal/gateway/discover_api_test.go index 1fa6983..71d6f03 100644 --- a/internal/gateway/discover_api_test.go +++ b/internal/gateway/discover_api_test.go @@ -32,12 +32,12 @@ func catalogUpstream(t *testing.T) *httptest.Server { func TestDefaultSourceName(t *testing.T) { cases := map[string]string{ - "https://api.vendor.com/v1": "vendor.com", - "https://api.openai.com/v1": "openai.com", - "https://www.vendor.com/v1": "vendor.com", - "http://127.0.0.1:8080/v1": "127.0.0.1", + "https://api.vendor.com/v1": "vendor.com", + "https://api.openai.com/v1": "openai.com", + "https://www.vendor.com/v1": "vendor.com", + "http://127.0.0.1:8080/v1": "127.0.0.1", "https://amr-link.open-design.ai/v1": "amr-link.open-design.ai", - "not a url at all": "", + "not a url at all": "", } for in, want := range cases { if got := defaultSourceName(in); got != want { @@ -122,16 +122,16 @@ func TestSelectModels_WarnsThatImagesAreNotInAUTO(t *testing.T) { // operator previews one set of values and saves another. func TestDiscoveryResultAndConfigRoundTrip(t *testing.T) { res := &discoveryResult{ - BaseURL: "https://h", - Endpoint: "/v1/chat/completions", - Models: []string{"m1", "m2"}, + BaseURL: "https://h", + Endpoint: "/v1/chat/completions", + Models: []string{"m1", "m2"}, ImageModels: []string{"img1"}, } src := config.Source{ - Name: "x", - BaseURL: res.BaseURL, - Endpoint: res.Endpoint, - Models: modelsToConfig(res.Models, res.ImageModels), + Name: "x", + BaseURL: res.BaseURL, + Endpoint: res.Endpoint, + Models: modelsToConfig(res.Models, res.ImageModels), } if len(src.Models) != 3 { t.Fatalf("models = %d, want 3", len(src.Models)) diff --git a/internal/gateway/discover_endpoint_test.go b/internal/gateway/discover_endpoint_test.go index 8865921..4801b6a 100644 --- a/internal/gateway/discover_endpoint_test.go +++ b/internal/gateway/discover_endpoint_test.go @@ -31,10 +31,10 @@ func TestComposeImageEndpoint_NoVersionBaseLeavesDefault(t *testing.T) { // TestDiscovery_BareHostNeedsVersionOnEndpointWhenCatalogIsVersioned // distinguishes two cases that look identical from the outside. // -// bare host + catalog at "/models" → chat is "/chat/completions" (the -// adapter default, no endpoint written) -// bare host + catalog at "/v1/models" → chat is "/v1/chat/completions", -// so an endpoint MUST be written +// bare host + catalog at "/models" → chat is "/chat/completions" (the +// adapter default, no endpoint written) +// bare host + catalog at "/v1/models" → chat is "/v1/chat/completions", +// so an endpoint MUST be written // // The catalog path is the only evidence of whether the upstream wants the // version segment, and getting this backwards sends every chat request to a @@ -105,15 +105,15 @@ func TestDiscovery_BareHostNeedsVersionOnEndpointWhenCatalogIsVersioned(t *testi // silent, expensive mistakes, so they are asserted literally. func TestComposeEndpoints_BothFormsProduceWorkingURLs(t *testing.T) { cases := []struct { - name string - base string - wantChat string - wantImage string + name string + base string + wantChat string + wantImage string }{ { - name: "bare host: adapter and image defaults compose", - base: "https://h", - wantChat: "https://h/chat/completions", + name: "bare host: adapter and image defaults compose", + base: "https://h", + wantChat: "https://h/chat/completions", // openai's adapter endpoint is /chat/completions (verified in // internal/lua/adapters/openai.lua: adapter.endpoint). wantImage: "https://h/v1/images/generations", diff --git a/internal/gateway/discover_test.go b/internal/gateway/discover_test.go index d465b43..2d18d0d 100644 --- a/internal/gateway/discover_test.go +++ b/internal/gateway/discover_test.go @@ -356,10 +356,10 @@ func TestDiscoveryCandidates_DedupesAndPrefersTyped(t *testing.T) { func TestVersionSegment(t *testing.T) { cases := map[string]string{ - "/v1/models": "/v1", + "/v1/models": "/v1", "/v1beta/models": "/v1beta", - "/models": "", - "/api/models": "", + "/models": "", + "/api/models": "", "/vision/models": "", } for in, want := range cases { @@ -371,19 +371,19 @@ func TestVersionSegment(t *testing.T) { func TestTrimVersionSuffix(t *testing.T) { cases := map[string]string{ - "https://h/v1": "https://h", - "https://h/v2": "https://h", - "https://h/v1beta": "https://h", - "https://h/api/v1": "https://h/api", - "https://h/vision": "https://h/vision", // not numeric — a real path segment - "https://h": "https://h", - "https://h/openai": "https://h/openai", - "https://h/v1/extra": "https://h/v1/extra", + "https://h/v1": "https://h", + "https://h/v2": "https://h", + "https://h/v1beta": "https://h", + "https://h/api/v1": "https://h/api", + "https://h/vision": "https://h/vision", // not numeric — a real path segment + "https://h": "https://h", + "https://h/openai": "https://h/openai", + "https://h/v1/extra": "https://h/v1/extra", // A segment that merely STARTS with v must not be stripped: stripping // it would rewrite base "https://h/vision" to "https://h" and, worse, // would treat a real vendor path as a version. - "https://h/vertex": "https://h/vertex", - "https://h/volcengine": "https://h/volcengine", + "https://h/vertex": "https://h/vertex", + "https://h/volcengine": "https://h/volcengine", "https://h/vertex-ai/v1": "https://h/vertex-ai", } for in, want := range cases { @@ -463,7 +463,7 @@ func TestComposeEndpoint_ExactlyOneVersionSegment(t *testing.T) { // Whichever way it resolved, base + endpoint must never carry the // same version segment twice. onBase := trimVersionSuffix(tc.persistedBase) != tc.persistedBase - onEndpoint := versionSegment(strings.TrimSuffix(got, "/chat/completions") + "/models") != "" + onEndpoint := versionSegment(strings.TrimSuffix(got, "/chat/completions")+"/models") != "" if onBase && onEndpoint { t.Errorf("version segment duplicated: base %q + endpoint %q", tc.persistedBase, got) } diff --git a/internal/gateway/key_auto_api_test.go b/internal/gateway/key_auto_api_test.go index c2ab771..7bb62cd 100644 --- a/internal/gateway/key_auto_api_test.go +++ b/internal/gateway/key_auto_api_test.go @@ -4,8 +4,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "path/filepath" "os" + "path/filepath" "strings" "testing" @@ -33,11 +33,11 @@ func keyAutoGateway(t *testing.T) *Gateway { t.Fatal(err) } cfg := &config.Config{ - Path: cfgPath, - AdapterDir: filepath.Join(td, "adapters"), - RuntimeFile: filepath.Join(td, "runtime.json"), + Path: cfgPath, + AdapterDir: filepath.Join(td, "adapters"), + RuntimeFile: filepath.Join(td, "runtime.json"), DefaultModel: "AUTO", - GatewayKeys: []string{"sk-admin"}, + GatewayKeys: []string{"sk-admin"}, Keys: []config.GWKey{ {Key: "sk-admin", Role: "admin", Name: "admin"}, {Key: "sk-user", Role: "user", Name: "user"}, @@ -97,7 +97,7 @@ func TestKeyAutoAPICrudAndInherit(t *testing.T) { // Initially inherits. var got struct { - Inherits bool `json:"inherits"` + Inherits bool `json:"inherits"` Auto []config.ModelScope `json:"auto"` } rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "") @@ -254,4 +254,4 @@ func TestKeyAutoAPIDoesNotShadowKeyRoutes(t *testing.T) { if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/me", ""); rr.Code != http.StatusOK { t.Fatalf("GET /api/keys/me as user = %d, want 200", rr.Code) } -} \ No newline at end of file +} diff --git a/internal/gateway/messages.go b/internal/gateway/messages.go new file mode 100644 index 0000000..770341d --- /dev/null +++ b/internal/gateway/messages.go @@ -0,0 +1,379 @@ +package gateway + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "llmsproxy/internal/provider" + "llmsproxy/internal/scheduler" + "llmsproxy/internal/types" +) + +// handleMessages serves the Anthropic Messages protocol (POST /v1/messages). +// +// The point of this endpoint is NOT to add a second scheduling path. It reuses +// everything /v1/chat/completions does — key auth, model scope, per-model +// quotas, the AUTO chain, cooldowns, audit records, plugin hooks — and differs +// in exactly two places: +// +// 1. the request body is parsed from Anthropic's shape into the unified format +// (messages with content blocks, a top-level `system`, `max_tokens` +// mandatory, `thinking` optional); +// 2. the response is rendered by the SERVING adapter's serialize_* hooks, so +// the client sees Anthropic's shape back. +// +// The kernel therefore contains no Anthropic field names on this path: parsing +// is table-driven, and serialization belongs to the adapter. +// +// A source whose adapter does not declare serialize_* falls back to the +// OpenAI shape, which is what the kernel guarantees for an unconfigured +// deployment. That asymmetry is deliberate (see serializer.go): an Anthropic +// client talking to a source that cannot speak Anthropic gets a clear +// OpenAI-shaped answer rather than a malformed Anthropic one. +func (g *Gateway) handleMessages(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "read body: "+err.Error()) + return + } + // INGRESS: the kernel's built-in Anthropic parse is the FALLBACK (R5). If an + // adapter has CLAIMED this ingress path (adapter.ingress == "/v1/messages"), + // the kernel YIELDS: parsing is done by that adapter's deserialize_request + // hook, so Anthropic protocol iteration lives in anthropic.lua, not here. + // No adapter claims it → the built-in parser below runs (unconfigured + // deployments keep working). This is the inbound dual of serialize_*. + req, handled, perr := g.parseViaIngress(r.URL.Path, string(body), g.core.DefaultModel()) + if perr != nil { + writeError(w, http.StatusBadRequest, "invalid_request", perr.Error()) + return + } + if !handled { + var raw anthropicRequest + if err := json.Unmarshal(body, &raw); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + req, err = raw.toUnified(g.core.DefaultModel()) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + } + + model := req.Model + ctx := r.Context() + g.fireStart(ctx, &chatRequest{Messages: req.Messages, Tools: nil}, + chatKind(req.Stream), model, len(req.Messages), 0) + + if isAuto(model) { + chain, _ := g.core.AutoChainFor(reqKey(ctx)) + if chain == nil || len(chain.Tiers) == 0 { + writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot configured") + return + } + if q := g.checkQuota(ctx, "AUTO"); q != nil { + g.writeReject(w, q) + return + } + g.serveUnified(ctx, w, chain, req, nil) + return + } + if allow := g.allowedModels(ctx); allow != nil && !g.hasScopeModel(allow, model) { + writeError(w, http.StatusForbidden, "model_not_allowed", + "model \""+model+"\" is not allowed for this key") + return + } + cands, effective := g.resolveCands(ctx, &chatRequest{Model: model}) + if len(cands) == 0 { + writeError(w, http.StatusNotFound, "model_not_found", + "model \""+model+"\" is not configured") + return + } + if effective == "" { + effective = firstModel(cands[0]) + } + if q := g.checkQuota(ctx, effective); q != nil { + g.writeReject(w, q) + return + } + g.serveUnified(ctx, w, nil, req, &directRoute{cands: cands, effective: effective}) +} + +// directRoute carries the pre-resolved candidate list for a model-pinned +// request, so serveUnified can run both routes (direct / AUTO) from one body. +type directRoute struct { + cands []*provider.Provider + effective string +} + +// serveUnified runs the request through the scheduler and writes the reply via +// the serving adapter's serializer. It is the shared tail of handleChat and +// handleMessages: by this point every gate (auth, scope, quota) has already run, +// so the only remaining questions are "which source serves it" and "what shape +// does the client get". +func (g *Gateway) serveUnified(ctx context.Context, w http.ResponseWriter, chain *scheduler.Chain, req *types.ChatRequest, direct *directRoute) { + rec := &Req{Key: keyID(reqKey(ctx)), Type: "chat", OK: false} + if direct != nil { + rec.Model = direct.effective + rec.Source = firstSource(direct.cands) + } else { + rec.Type = "stream" + } + if req.Stream { + rec.Type = "stream" + } + + done := g.stats.Begin() + defer done() + + var ( + resp *types.UnifiedResponse + chunks <-chan types.UnifiedChunk + usedSrc, usedModel string + err error + ) + t0 := time.Now() + if direct != nil { + cands := toScheduler(direct.cands) + if req.Stream { + chunks, usedSrc, usedModel, err = g.core.Scheduler().ChatStream(ctx, cands, req) + } else { + resp, usedSrc, usedModel, err = g.core.Scheduler().Chat(ctx, cands, req) + } + } else { + if req.Stream { + chunks, usedSrc, usedModel, err = g.core.Scheduler().ChainChatStream(ctx, chain, req, nil, nil) + } else { + resp, usedSrc, usedModel, err = g.core.Scheduler().ChainChat(ctx, chain, req, nil, nil) + } + } + rec.LatMs = time.Since(t0).Milliseconds() + if err != nil { + g.failChat(w, rec, err) + g.writeRec(rec) + return + } + rec.OK = true + rec.Status = http.StatusOK + if usedModel != "" { + rec.Model = usedModel + } + rec.Source = usedSrc + ser := g.serializerFor(usedSrc, directProviderList(direct)) + + if chunks != nil { + g.fireRouted(ctx, "stream", usedSrc, usedModel, tierForRoute(direct), true) + rec.Prompt = estimatePromptTokens(req) + g.pumpStream(w, ser, rec, chunks, modelLabel(req, usedModel), t0) + return + } + recordChatUsage(rec, req, resp) + g.fireRouted(ctx, "chat", usedSrc, usedModel, tierForRoute(direct), false) + rec.FirstByteMs = rec.LatMs + g.writeRec(rec) + writeChatCompletion(w, ser, resp, modelLabel(req, usedModel)) +} + +// ---- Anthropic request shape (in-bound only; the kernel holds no outbound +// Anthropic field names) ---- + +// anthropicRequest is the client-facing parse target for POST /v1/messages. +// +// Only the fields the gateway must understand to schedule and account for a +// request appear here. Anything else the client sent is preserved verbatim in +// raw so an adapter can read it (the same "kernel does not interpret" rule that +// governs tools). +type anthropicRequest struct { + Model string `json:"model"` + System json.RawMessage `json:"system,omitempty"` + Messages []anthropicMessage `json:"messages"` + MaxTokens int `json:"max_tokens"` + Stream bool `json:"stream,omitempty"` + + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + StopSeqs []string `json:"stop_sequences,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Thinking *types.ThinkingConfig `json:"thinking,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + + // raw is the untouched body, so an adapter can read fields the gateway + // has no reason to know (cache_control, service_tier, …). + raw json.RawMessage +} + +// anthropicMessage accepts Anthropic's content as either a plain string or an +// array of typed blocks; both normalize to the unified message shape. +type anthropicMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +// toUnified converts the parsed request into the internal format. +func (a *anthropicRequest) toUnified(defaultModel string) (*types.ChatRequest, error) { + if len(a.Messages) == 0 { + return nil, errors.New("messages is required") + } + msgs := make([]types.ChatMessage, 0, len(a.Messages)) + for i, m := range a.Messages { + u := types.ChatMessage{Role: m.Role, Content: normalizeAnthropicContent(m.Content)} + if u.Role == "" { + return nil, fmt.Errorf("messages[%d].role is required", i) + } + msgs = append(msgs, u) + } + model := a.Model + if model == "" { + model = defaultModel + } + th := a.Thinking + if th == nil { + // Anthropic's default is thinking OFF, and `thinking: {enabled:false}` + // is the explicit spelling of that. Emitting it keeps the unified + // request honest about the client's intent instead of leaving the + // field absent and letting each adapter guess. + no := false + th = &types.ThinkingConfig{Enabled: &no} + } + return &types.ChatRequest{ + Model: model, + Messages: msgs, + Temperature: a.Temperature, + MaxTokens: a.MaxTokens, + Stream: a.Stream, + TopP: a.TopP, + TopK: a.TopK, + StopSequences: a.StopSeqs, + Tools: a.Tools, + ToolChoice: a.ToolChoice, + System: systemToString(a.System), + Thinking: th, + ExtraBody: map[string]interface{}{}, + }, nil +} + +// normalizeAnthropicContent turns Anthropic content into the unified shape. +// +// A string stays a string. An array of blocks is preserved as-is: the unified +// format deliberately carries multimodal/tool blocks verbatim (ChatMessage +// .Content is json.RawMessage) rather than flattening them, because only an +// adapter knows which block types its upstream accepts. +func normalizeAnthropicContent(raw json.RawMessage) json.RawMessage { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" { + return types.StringContent("") + } + if strings.HasPrefix(trimmed, `"`) { + return raw + } + return raw +} + +// systemToString accepts Anthropic's two spellings: a plain string, or an array +// of {type:"text", text} blocks. +func systemToString(raw json.RawMessage) string { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" || trimmed == "null" { + return "" + } + if strings.HasPrefix(trimmed, `"`) { + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + return "" + } + var blocks []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &blocks); err != nil { + return "" + } + var sb strings.Builder + for _, b := range blocks { + if b.Type == "text" { + sb.WriteString(b.Text) + } + } + return sb.String() +} + +// parseViaIngress is the kernel's INBOUND yield point, the dual of +// serializerFor (which decides the outbound serializer). +// +// Design (user requirement): an inbound endpoint such as /v1/messages is the +// KERNEL'S FALLBACK — the built-in parser in handleMessages is what an +// unconfigured deployment gets. But an adapter may CLAIM that endpoint +// (adapter.ingress == path) and declare deserialize_request; when it does, the +// kernel YIELDS parsing to it, so client-protocol iteration lives in the +// adapter and the kernel's own copy is only the safety net (R5). +// +// Returns handled=false when no adapter claims the path (or one claims it +// without a deserialize_request hook) — the caller then runs its built-in +// parser. A hook that exists but FAILS is a hard error, not a silent fallback: +// the client spoke a protocol the adapter claimed to handle, and quietly +// parsing it as a different protocol would mis-schedule the request. +func (g *Gateway) parseViaIngress(path, body, defaultModel string) (*types.ChatRequest, bool, error) { + vm := g.core.VM() + if vm == nil { + return nil, false, nil + } + adapter := vm.AdapterForIngress(path) + if adapter == "" || !vm.HasFn(adapter, "deserialize_request") { + return nil, false, nil + } + out, err := vm.Transform(adapter, "deserialize_request", body) + if err != nil { + return nil, true, fmt.Errorf("adapter %s deserialize_request(%s): %w", adapter, path, err) + } + var req types.ChatRequest + if err := json.Unmarshal([]byte(out), &req); err != nil { + return nil, true, fmt.Errorf("adapter %s deserialize_request returned an invalid unified request: %w", adapter, err) + } + if req.Model == "" { + req.Model = defaultModel + } + return &req, true, nil +} + +func chatKind(stream bool) string { + if stream { + return "stream" + } + return "chat" +} + +// directProviderList hands the serving-source resolver its candidate list. +func directProviderList(direct *directRoute) []*provider.Provider { + if direct == nil { + return nil + } + return direct.cands +} + +func tierForRoute(direct *directRoute) int { + if direct != nil { + return -1 + } + return -2 +} + +func modelLabel(req *types.ChatRequest, usedModel string) string { + if usedModel != "" { + return usedModel + } + return req.Model +} diff --git a/internal/gateway/messages_test.go b/internal/gateway/messages_test.go new file mode 100644 index 0000000..873df95 --- /dev/null +++ b/internal/gateway/messages_test.go @@ -0,0 +1,404 @@ +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "llmsproxy/internal/config" + "llmsproxy/internal/core" +) + +// 本文件端到端验证 Phase 2 的核心主张:**对外协议由适配器决定,内核只调度**。 +// +// 具体做法:起一个真实的 core + gateway,源挂 anthropic 适配器,上游是一个 +// 只懂 Anthropic Messages 的假服务器。然后: +// +// 1. Anthropic 客户端形状的请求 → 拿到 Anthropic 形状的响应 +// 2. 调度/配额/审计与 /v1/chat/completions 走同一条路径 +// 3. 上游若不支持该模型,内核如实回 503,不伪造格式 + +// anthropicUpstream 是一个只实现 Anthropic Messages 的假上游。 +func anthropicUpstream(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/messages") { + // 网关把请求发到了非 Anthropic 路径 —— 显式暴露这个 bug + t.Errorf("请求打到了非 Anthropic 路径: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + body, _ := io.ReadAll(r.Body) + var probe map[string]interface{} + _ = json.Unmarshal(body, &probe) + if _, ok := probe["messages"]; !ok { + t.Errorf("上游收到没有 messages 的请求体: %s", body) + } + if _, ok := probe["system"]; !ok && probe["system"] == nil { + // system 是可选的,仅在客户端给了的时候才应出现;不强制 + _ = ok + } + if _, ok := probe["max_tokens"]; !ok { + t.Errorf("Anthropic 要求 max_tokens,上游请求体却缺这个字段: %s", body) + } + + // Streaming requests must get a real Anthropic SSE sequence; a JSON body + // here would leave the adapter with zero frames and the gateway would + // (correctly) reject the stream as empty. + if stream, _ := probe["stream"].(bool); stream { + w.Header().Set("Content-Type", "text/event-stream") + fl, _ := w.(http.Flusher) + frames := []string{ + `event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_mock","type":"message","role":"assistant","model":"` + probeModel(probe) + `","content":[],"usage":{"input_tokens":3,"output_tokens":0}}}`, + `event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}`, + `event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`, + `event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`, + `event: message_stop` + "\n" + `data: {"type":"message_stop"}`, + } + for _, f := range frames { + fmt.Fprint(w, f+"\n\n") + if fl != nil { + fl.Flush() + } + } + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "id":"msg_mock","type":"message","role":"assistant","model":"`+probeModel(probe)+`", + "content":[{"type":"thinking","thinking":"let me think"},{"type":"text","text":"pong"}], + "stop_reason":"end_turn", + "usage":{"input_tokens":3,"output_tokens":5} + }`) + })) +} + +func probeModel(m map[string]interface{}) string { + if s, ok := m["model"].(string); ok { + return s + } + return "" +} + +// messagesGateway 起一个真实网关,源挂 anthropic 适配器。 +func messagesGateway(t *testing.T) (*httptest.Server, string) { + t.Helper() + up := anthropicUpstream(t) + t.Cleanup(up.Close) + + dir := t.TempDir() + // adapterDir 故意不预先创建:VM 首启会把内置适配器 seed 进去。 + // 预先创建会被当成"目录已存在即为权威",于是没有任何内置适配器被加载, + // 表现为 "adapter anthropic not loaded"。 + cfg := &config.Config{ + Path: filepath.Join(dir, "config.yaml"), + AdapterDir: filepath.Join(dir, "adapters"), + RuntimeFile: filepath.Join(dir, "runtime.json"), + Listen: "127.0.0.1:0", + DefaultModel: "claude-test", + GatewayKeys: []string{"sk-e2e-0001"}, + Sources: []config.Source{{ + Name: "anth", BaseURL: up.URL, Adapter: "anthropic", + APIKey: "k", MaxConcurrent: 4, + Models: []config.Model{{ID: "claude-test", Kind: "chat", Priority: 100}}, + }}, + } + + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatalf("core: %v", err) + } + t.Cleanup(c.Close) + gw, err := New(c) + if err != nil { + t.Fatalf("gateway: %v", err) + } + ts := httptest.NewServer(gw.Handler()) + t.Cleanup(ts.Close) + return ts, "sk-e2e-0001" +} + +func postJSON(t *testing.T, url, key, body string) (int, string) { + t.Helper() + req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b) +} + +// TestMessagesEndpointSpeaksAnthropic 是本文件的主断言:Anthropic 客户端 +// 打 /v1/messages,拿到 Anthropic 形状。 +func TestMessagesEndpointSpeaksAnthropic(t *testing.T) { + ts, key := messagesGateway(t) + code, body := postJSON(t, ts.URL+"/v1/messages", key, + `{"model":"claude-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + if code != 200 { + t.Fatalf("status = %d: %s", code, body) + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(body), &m); err != nil { + t.Fatalf("响应不是 JSON: %v (%s)", err, body) + } + if m["type"] != "message" { + t.Errorf("type = %v, want message(内核不该退回 OpenAI 的 chat.completion)", m["type"]) + } + if m["stop_reason"] != "end_turn" { + t.Errorf("stop_reason = %v, want end_turn", m["stop_reason"]) + } + blocks, _ := m["content"].([]interface{}) + if len(blocks) != 2 { + t.Fatalf("content blocks = %v, want [thinking, text]", m["content"]) + } + if b0 := blocks[0].(map[string]interface{}); b0["type"] != "thinking" || b0["thinking"] != "let me think" { + t.Errorf("首个块应为 thinking: %v", b0) + } + if b1 := blocks[1].(map[string]interface{}); b1["type"] != "text" || b1["text"] != "pong" { + t.Errorf("第二个块应为 text/pong: %v", b1) + } + u := m["usage"].(map[string]interface{}) + if u["input_tokens"] != float64(3) || u["output_tokens"] != float64(5) { + t.Errorf("usage 应为 Anthropic 的 input/output 命名: %v", u) + } + // 内核不得混入 OpenAI 形状 + for _, forbidden := range []string{`"object"`, `"choices"`, `"chat.completion"`, `"prompt_tokens"`} { + if strings.Contains(body, forbidden) { + t.Errorf("OpenAI 字段 %s 混入 Anthropic 响应: %s", forbidden, body) + } + } +} + +// TestMessagesStreamSpeaksAnthropic 验证流式也是 Anthropic 事件序列, +// 而不是 OpenAI SSE。 +func TestMessagesStreamSpeaksAnthropic(t *testing.T) { + ts, key := messagesGateway(t) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/messages", + strings.NewReader(`{"model":"claude-test","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hi"}]}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + s := string(body) + + if !strings.Contains(s, "message_start") { + t.Errorf("Anthropic 流应以 message_start 开头: %s", s) + } + if !strings.Contains(s, "message_stop") { + t.Errorf("Anthropic 流应以 message_stop 收尾: %s", s) + } + // 内核不得擅自给 Anthropic 流加 OpenAI 的 [DONE] + if strings.Contains(s, "[DONE]") { + t.Errorf("Anthropic 流里出现了 OpenAI 的 [DONE] 终止标记: %s", s) + } + if strings.Contains(s, "chat.completion.chunk") { + t.Errorf("流里混入 OpenAI chunk 形状: %s", s) + } +} + +// TestMessagesRejectsBadRequest 确认入向校验(Anthropic 要求 max_tokens) +// 与 OpenAI 路径一样在入口拒绝,不占用上游槽位。 +func TestMessagesRejectsBadRequest(t *testing.T) { + ts, key := messagesGateway(t) + // Anthropic 允许省略 max_tokens(上游会给默认),所以「有 messages 无 + // max_tokens」必须被接受;真正该拒的是没有 messages。 + code, body := postJSON(t, ts.URL+"/v1/messages", key, + `{"model":"claude-test","messages":[{"role":"user","content":"hi"}]}`) + if code != 200 { + t.Fatalf("有 messages 的请求应被接受(max_tokens 可省),却得 %d: %s", code, body) + } + code, body = postJSON(t, ts.URL+"/v1/messages", key, `{"model":"claude-test"}`) + if code != 400 { + t.Errorf("无 messages 的请求 status = %d, want 400: %s", code, body) + } + // 坏 JSON + code, _ = postJSON(t, ts.URL+"/v1/messages", key, `{"model":`) + if code != 400 { + t.Errorf("坏 JSON status = %d, want 400", code) + } +} + +// TestMessagesUnconfiguredFallsBackToOpenai 验证 R5 的兜底在新增端点上同样 +// 生效:源没有声明序列化钩子时(此例用 openai 适配器),回 OpenAI 形状。 +func TestMessagesUnconfiguredFallsBackToOpenai(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`) + })) + defer up.Close() + + dir := t.TempDir() + // adapterDir 故意不预先创建:VM 首启会把内置适配器 seed 进去。 + // 预先创建会被当成"目录已存在即为权威",于是没有任何内置适配器被加载, + // 表现为 "adapter anthropic not loaded"。 + cfg := &config.Config{ + Path: filepath.Join(dir, "config.yaml"), + AdapterDir: filepath.Join(dir, "adapters"), + RuntimeFile: filepath.Join(dir, "runtime.json"), + Listen: "127.0.0.1:0", + DefaultModel: "gpt-test", + GatewayKeys: []string{"sk-e2e-0001"}, + Sources: []config.Source{{ + Name: "oai", BaseURL: up.URL, Adapter: "openai", APIKey: "k", MaxConcurrent: 4, + Models: []config.Model{{ID: "gpt-test", Kind: "chat"}}, + }}, + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatal(err) + } + defer c.Close() + gw, err := New(c) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(gw.Handler()) + defer ts.Close() + + code, body := postJSON(t, ts.URL+"/v1/messages", "sk-e2e-0001", + `{"model":"gpt-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + if code != 200 { + t.Fatalf("status = %d: %s", code, body) + } + if !strings.Contains(body, "chat.completion") { + t.Errorf("无序列化钩子的源应回落 OpenAI 兜底形状: %s", body) + } +} + +// TestMessagesRequiresAuth 确认新端点走同一套鉴权(不因为是新协议而放行)。 +func TestMessagesRequiresAuth(t *testing.T) { + ts, _ := messagesGateway(t) + code, _ := postJSON(t, ts.URL+"/v1/messages", "wrong-key", + `{"model":"claude-test","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}`) + if code != 401 { + t.Errorf("错误 key status = %d, want 401", code) + } + // 无 auth + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/messages", strings.NewReader("{}")) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 401 { + t.Errorf("无 key status = %d, want 401", resp.StatusCode) + } + _ = context.Background() +} + +// TestMessagesIngressYieldsToBundledAnthropic 是 ingress 让出的主断言(用户要求: +// /v1/messages 是内核兜底端点,适配器可声明它、内核主动让出)。 +// +// 事实前提:内置 anthropic.lua 自己声明了 adapter.ingress = "/v1/messages",而它 +// 默认随包播种。所以真实部署里内核**总是**让出给 anthropic 适配器, +// anthropicRequest.toUnified 退化为纯兜底(仅在适配器被移除/禁用时运行)。 +// +// 断言方式:给源挂一个「上游即 Anthropic」的假服务器,客户端送一个 Anthropic +// 形状请求。若内核让出,请求由适配器 deserialize_request 解析 → 路由/调度正常。 +// 同时用一个**只有系统消息**的请求验证解析确实走了适配器语义(Go 侧 system +// 处理与 Lua 侧一致)。 +func TestMessagesIngressYieldsToBundledAnthropic(t *testing.T) { + ts, key := messagesGateway(t) + if code, body := postJSON(t, ts.URL+"/v1/messages", key, + `{"model":"claude-test","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}`); code != 200 { + t.Fatalf("让出后 /v1/messages 应 200, 实得 %d: %s", code, body) + } +} + +// TestAdapterForIngressClaim 直接验证内核的让出判定:声明了 ingress 的适配器 +// 会被 AdapterForIngress 找到;未声明的路径返回空(→ 内核用自己的兜底解析)。 +func TestAdapterForIngressClaim(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{ + Path: filepath.Join(dir, "config.yaml"), + AdapterDir: filepath.Join(dir, "adapters"), + RuntimeFile: filepath.Join(dir, "runtime.json"), + Listen: "127.0.0.1:0", + DefaultModel: "m", + GatewayKeys: []string{"sk-e2e-0001"}, + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatal(err) + } + defer c.Close() + vm := c.VM() + + // 内置 anthropic 声明了该端点 + if got := vm.AdapterForIngress("/v1/messages"); got != "anthropic" { + t.Errorf("内置 anthropic 应认领 /v1/messages, 实得 %q", got) + } + // 未声明的路径 → 空 → 内核走自己的兜底解析 + if got := vm.AdapterForIngress("/v1/nothing"); got != "" { + t.Errorf("未声明路径应为空, 实得 %q", got) + } + // 内置 anthropic 确实有 deserialize_request 钩子(否则内核会让出给一个空解析器) + if !vm.HasFn("anthropic", "deserialize_request") { + t.Error("anthropic 应实现 deserialize_request") + } +} + +// TestMessagesKernelFallbackWhenNoAdapterClaims 证明兜底仍在:显式删掉所有 +// 声明 ingress 的适配器后,内核自己的 anthropicRequest.toUnified 接管, +// /v1/messages 仍能服务(R5 兜底语义)。 +func TestMessagesKernelFallbackWhenNoAdapterClaims(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`) + })) + defer up.Close() + + dir := t.TempDir() + cfg := &config.Config{ + Path: filepath.Join(dir, "config.yaml"), + AdapterDir: filepath.Join(dir, "adapters"), + RuntimeFile: filepath.Join(dir, "runtime.json"), + Listen: "127.0.0.1:0", + DefaultModel: "gpt-test", + GatewayKeys: []string{"sk-e2e-0001"}, + Sources: []config.Source{{ + Name: "oai", BaseURL: up.URL, Adapter: "openai", APIKey: "k", MaxConcurrent: 4, + Models: []config.Model{{ID: "gpt-test", Kind: "chat"}}, + }}, + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatal(err) + } + defer c.Close() + // 摘掉声明 ingress 的适配器(内置 anthropic),模拟"没有适配器认领该端点"。 + c.VM().RemoveAdapter("anthropic") + if got := c.VM().AdapterForIngress("/v1/messages"); got != "" { + t.Fatalf("移除后应无适配器认领, 实得 %q", got) + } + gw, err := New(c) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(gw.Handler()) + defer ts.Close() + + // 内核兜底解析 Anthropic 形状 → 200(走 OpenAI 兜底序列化) + code, body := postJSON(t, ts.URL+"/v1/messages", "sk-e2e-0001", + `{"model":"gpt-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + if code != 200 { + t.Fatalf("无适配器认领时内核兜底应仍 200, 实得 %d: %s", code, body) + } +} diff --git a/internal/gateway/mount.go b/internal/gateway/mount.go new file mode 100644 index 0000000..d7d9635 --- /dev/null +++ b/internal/gateway/mount.go @@ -0,0 +1,231 @@ +// Package gateway — 对外挂载点(R3 / Phase 6) +// +// 用户需求:插件与适配器可以注册**对外服务的端口**。 +// +// 现状:插件已经能注册 HTTP 端点,但被固定在 /api/plugins/http/ 前缀下。 +// 那条前缀限制是有理由的(可 grep、可整体撤销、插件无法影子内核路由), +// 但它也意味着插件无法提供"看起来像内核一部分"的接口——而对外服务恰恰 +// 需要这个(例如给上游回调用的固定路径 /v1/plugins//callback)。 +// +// 本文件放开这一点,同时把当初那条限制的**三个理由**逐条补回来: +// +// 1. 可 grep / 可整体撤销 → 每条自定义挂载都必须落在 /plugins/ 或 +// /v1/plugins/ 命名空间内(不接受任意前缀) +// 2. 不能影子内核路由 → 与内核保留路由表做前缀冲突检测,撞了直接拒绝 +// 3. 鉴权默认关闭 → 沿用 HTTPHandler 的 roles/keys/public 语义 +package gateway + +import ( + "fmt" + "net/http" + "sort" + "strings" + + "llmsproxy/internal/lua" +) + +// allowedMountPrefixes are the only namespaces a plugin/adapters may claim. +// +// A free-form prefix would let a plugin register "/v1/chat/completions" and +// silently intercept every inference request on the gateway — the exact +// shadowing the narrow default prefix was protecting against. Constraining the +// namespace keeps the security property while still allowing a stable public +// path. +var allowedMountPrefixes = []string{"/plugins/", "/v1/plugins/"} + +// kernelReservedPrefixes are prefixes the kernel owns. A custom mount that +// overlaps any of them is refused, so an operator's route table can never +// change meaning because a plugin was installed. +// +// This list is deliberately explicit rather than derived from the router's +// switch: the switch is code, and a future branch added there would silently +// become shadowable if this were inferred. The test +// TestKernelRoutesAreAllCoveredByReservedPrefixes keeps the two in sync. +var kernelReservedPrefixes = []string{ + "/v1/chat/completions", + "/v1/messages", + "/v1/images/generations", + "/v1/models", + "/api/", + "/login", + "/ui", + "/", +} + +// ValidateCustomMount checks that a plugin-declared mount point is allowed. +// +// Returns the normalized path (leading slash, no trailing slash) or an error +// naming the conflict. The error text is what an operator sees in the plugin's +// load-error field, so it says WHICH rule was hit. +func ValidateCustomMount(p string) (string, error) { + p = strings.TrimSpace(p) + if p == "" { + return "", fmt.Errorf("empty mount path") + } + if !strings.HasPrefix(p, "/") { + return "", fmt.Errorf("mount path %q must start with /", p) + } + if strings.Contains(p, "..") || strings.Contains(p, "//") { + return "", fmt.Errorf("mount path %q contains an empty or escaping segment", p) + } + norm := strings.TrimRight(p, "/") + if norm == "" { + norm = "/" + } + + // Rule 1: must live in one of the allowed namespaces. + ok := false + for _, pre := range allowedMountPrefixes { + if strings.HasPrefix(norm+"/", pre) { + ok = true + break + } + } + if !ok { + return "", fmt.Errorf("mount path %q must be under %s", + norm, strings.Join(allowedMountPrefixes, " or ")) + } + + // Rule 2: must not overlap a kernel route. + for _, res := range kernelReservedPrefixes { + if res == "/" { + continue // the catch-all is handled by the namespace rule above + } + if norm == strings.TrimRight(res, "/") || strings.HasPrefix(norm, res) { + return "", fmt.Errorf("mount path %q collides with the kernel route %q", norm, res) + } + } + return norm, nil +} + +// RouteOwner describes one occupied route, for conflict reporting. +type RouteOwner struct { + Path string + Owner string // "kernel", "plugin:name", "adapter:name" +} + +// BuildRouteTable assembles the set of paths a plugin/adapters must not take. +// It is used both to validate new mounts and to explain a conflict to an +// operator (a bare "conflict" is not actionable; "already served by plugin X" is). +func BuildRouteTable(pluginMounts, adapterMounts map[string]string) []RouteOwner { + var out []RouteOwner + for _, p := range kernelReservedPrefixes { + if p == "/" { + continue + } + out = append(out, RouteOwner{Path: strings.TrimRight(p, "/"), Owner: "kernel"}) + } + for path, owner := range pluginMounts { + out = append(out, RouteOwner{Path: path, Owner: "plugin:" + owner}) + } + for path, owner := range adapterMounts { + out = append(out, RouteOwner{Path: path, Owner: "adapter:" + owner}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +// FindMountConflict reports whether candidate overlaps an occupied route and, if +// so, who holds it. +// +// Overlap is checked in BOTH directions: a new mount that CONTAINS an existing +// one shadows it just as effectively as one nested inside it, and only checking +// the prefix direction would let "/plugins/x/sub" be registered before +// "/plugins/x" and then silently swallow it. +func FindMountConflict(candidate string, table []RouteOwner) (RouteOwner, bool) { + c := strings.TrimRight(candidate, "/") + for _, r := range table { + rp := strings.TrimRight(r.Path, "/") + if rp == "" { + continue + } + if c == rp || strings.HasPrefix(c, rp+"/") || strings.HasPrefix(rp, c+"/") { + return r, true + } + } + return RouteOwner{}, false +} + +// handleCustomMount dispatches a request to a plugin that claimed a custom mount +// path (/plugins//… or /v1/plugins//…). +// +// It deliberately REUSES handlePluginHTTP rather than reimplementing dispatch: +// that function already does longest-match resolution, per-endpoint +// roles/keys/public authorization and the Lua call protocol. Duplicating it here +// would create two dispatch paths that must be kept in agreement — and a +// security rule that is enforced on only one of them is worse than none. +// +// The rewrite below maps a claimed mount onto the handler table's canonical +// form: handler paths are declared as "/", so the request path is +// reduced to exactly that. Anything that does not reduce cleanly (a missing +// name, a name with no registered handler) is a 404, not a fallthrough — a +// custom namespace that silently answered from the default prefix would make +// the mount validation meaningless. +func (g *Gateway) handleCustomMount(w http.ResponseWriter, r *http.Request) { + owner, rest, ok := parseMountRef(r.URL.Path) + if !ok || owner == "" { + writeError(w, http.StatusNotFound, "not_found", "malformed plugin mount path") + return + } + ps := g.core.Plugins() + if ps == nil { + writeError(w, http.StatusNotFound, "not_found", "no plugins loaded") + return + } + // Resolve which handler owns this mount, so an unclaimed namespace is a 404. + handlers := ps.HTTPHandlers() + candidate := owner + if rest != "" { + candidate = owner + "/" + rest + } + if !mountClaimed(handlers, candidate) { + writeError(w, http.StatusNotFound, "not_found", + "no plugin serves "+r.URL.Path) + return + } + // Reuse the default dispatcher by presenting the path in its own shape. + r2 := r.Clone(r.Context()) + r2.URL.Path = pluginHTTPPrefix + candidate + g.handlePluginHTTP(w, r2) +} + +// mountClaimed reports whether any registered handler's path is a prefix of the +// candidate (longest-match semantics: /plugins/auth/token is served by a +// handler declared as "auth"). +func mountClaimed(handlers []lua.HTTPHandler, candidate string) bool { + for i := range handlers { + h := strings.Trim(handlers[i].Path, "/") + if h == "" { + continue + } + if candidate == h || strings.HasPrefix(candidate, h+"/") { + return true + } + } + return false +} + +// parseMountRef splits "/plugins//" or "/v1/plugins//" +// into its owner name and remaining path. +// +// The "/v1/plugins/" form is checked first because it has the longer prefix: +// testing "/plugins/" first would match nothing for a /v1/ path, but keeping the +// ordering explicit avoids a future prefix being added that nests inside +// another. +func parseMountRef(p string) (owner, rest string, ok bool) { + prefixes := []string{"/v1/plugins/", "/plugins/"} + for _, prefix := range prefixes { + if !strings.HasPrefix(p, prefix) { + continue + } + trimmed := strings.TrimPrefix(p, prefix) + if trimmed == "" { + return "", "", false + } + if i := strings.Index(trimmed, "/"); i >= 0 { + return trimmed[:i], trimmed[i+1:], true + } + return trimmed, "", true + } + return "", "", false +} diff --git a/internal/gateway/mount_test.go b/internal/gateway/mount_test.go new file mode 100644 index 0000000..dcded1b --- /dev/null +++ b/internal/gateway/mount_test.go @@ -0,0 +1,324 @@ +package gateway + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "llmsproxy/internal/config" + "llmsproxy/internal/core" +) + +// 本文件锁定 R3 的两条不变量: +// 1. 自定义挂载只能落在允许的命名空间内 +// 2. 任何内核路由都不能被插件声明覆盖 —— 且这条在新增内核路由时**不会失效** + +// TestValidateCustomMountNamespace 只有 /plugins/ 与 /v1/plugins/ 可被占用。 +func TestValidateCustomMountNamespace(t *testing.T) { + ok := []string{ + "/plugins/auth/token", + "/plugins/auth", + "/v1/plugins/callback", + "/v1/plugins/callback/recv", + "/plugins/nested/deep/path", + } + for _, p := range ok { + if _, err := ValidateCustomMount(p); err != nil { + t.Errorf("合法挂载 %q 被拒: %v", p, err) + } + } + bad := []string{ + "", + "plugins/x", // 没有前导斜杠 + "/v1/chat/completions", // 影子内核路由(且不在允许命名空间) + "/api/anything", // 内核命名空间 + "/callback", // 任意前缀 + "/plugins/../etc", // 逃逸 + "/plugins//double", // 空段 + "/v1/plugins/../../x", + } + for _, p := range bad { + if _, err := ValidateCustomMount(p); err == nil { + t.Errorf("非法挂载 %q 被接受", p) + } + } +} + +// TestCustomMountCannotShadowKernelRoute 逐条核对内核保留前缀。 +func TestCustomMountCannotShadowKernelRoute(t *testing.T) { + // 这些是要保护的公开接口;任何能覆盖它们的挂载都必须被拒。 + for _, kernel := range []string{"/v1/chat/completions", "/v1/messages", "/v1/models"} { + // 尝试用允许命名空间里的路径去"贴近"内核路由 —— 例如 + // /v1/plugins/../../v1/models(逃逸),必须被拒。 + cand := "/v1/plugins/../../" + strings.TrimPrefix(kernel, "/") + if _, err := ValidateCustomMount(cand); err == nil { + t.Errorf("逃逸路径 %q 应被拒", cand) + } + } +} + +// TestKernelRoutesAreAllCoveredByReservedPrefixes 是这道防线**不腐坏**的保证。 +// +// 它从 server.go 里把路由 switch 的分支字符串刮出来,逐个检查是否被 +// kernelReservedPrefixes 覆盖。将来有人新增一条内核路由却忘了更新保留表时, +// 这条测试会失败 —— 否则新路由会静默变成可被插件覆盖的。 +func TestKernelRoutesAreAllCoveredByReservedPrefixes(t *testing.T) { + src, err := os.ReadFile("server.go") + if err != nil { + t.Fatal(err) + } + // 抓 switch 里的字面量路径:case r.URL.Path == "/x" / HasPrefix(..., "/x") + re := regexp.MustCompile(`r\.URL\.Path\s*==\s*"(/[^"]*)"`) + matches := re.FindAllStringSubmatch(string(src), -1) + if len(matches) == 0 { + t.Fatal("没有从 server.go 抓到任何路由字面量 —— 测试自身失效") + } + covered := func(p string) bool { + // "/" is the UI catch-all (serveUI). It is the default branch, not a + // path a plugin could shadow, and it is covered by the namespace rule: + // a custom mount must start with /plugins/ or /v1/plugins/, neither of + // which is "/". + if p == "/" { + return true + } + for _, res := range kernelReservedPrefixes { + if res == "/" { + continue + } + rp := strings.TrimRight(res, "/") + if p == rp || strings.HasPrefix(p, rp+"/") { + return true + } + } + // /api/plugins/... 由 pluginHTTPPrefix 与 /api/ 覆盖 + if strings.HasPrefix(p, "/api/") { + return true + } + return false + } + for _, m := range matches { + p := m[1] + if !covered(p) { + t.Errorf("内核路由 %q 未被 kernelReservedPrefixes 覆盖 —— "+ + "新增路由时必须同步更新保留表,否则插件可影子它", p) + } + } +} + +// TestMountConflictDetectionBothDirections 冲突检测必须双向。 +// +// 只查一个方向会让「先注册 /plugins/x,后注册 /plugins/x/sub」或反过来时的 +// 一个被另一个静默吞掉。 +func TestMountConflictDetectionBothDirections(t *testing.T) { + table := []RouteOwner{{Path: "/plugins/x", Owner: "plugin:a"}} + // 被包含 + if _, conflict := FindMountConflict("/plugins/x/sub", table); !conflict { + t.Error("嵌套在已有挂载下的路径应判冲突") + } + // 包含 + if _, conflict := FindMountConflict("/plugins", table); !conflict { + t.Error("包含已有挂载的路径应判冲突") + } + // 相同 + if _, conflict := FindMountConflict("/plugins/x", table); !conflict { + t.Error("完全相同的路径应判冲突") + } + // 无关(前缀相似但不是路径段) + if _, conflict := FindMountConflict("/plugins/xy", table); conflict { + t.Error("/plugins/xy 与 /plugins/x 不是同一个路径段,不该判冲突") + } +} + +// TestMountConflictNamesTheOwner 冲突信息必须点名持有者(否则不可行动)。 +func TestMountConflictNamesTheOwner(t *testing.T) { + table := []RouteOwner{{Path: "/plugins/taken", Owner: "plugin:other"}} + owner, conflict := FindMountConflict("/plugins/taken/deep", table) + if !conflict { + t.Fatal("应判冲突") + } + if owner.Owner != "plugin:other" { + t.Errorf("应点名持有者 plugin:other, 实得 %q", owner.Owner) + } +} + +// TestParseMountRef 解析两种命名空间。 +func TestParseMountRef(t *testing.T) { + cases := []struct { + in, owner, rest string + ok bool + }{ + {"/plugins/auth/token", "auth", "token", true}, + {"/plugins/auth", "auth", "", true}, + {"/v1/plugins/cb/recv", "cb", "recv", true}, + {"/v1/plugins/cb", "cb", "", true}, + {"/plugins/", "", "", false}, + {"/plugins", "", "", false}, + {"/other/x", "", "", false}, + } + for _, c := range cases { + owner, rest, ok := parseMountRef(c.in) + if ok != c.ok || owner != c.owner || rest != c.rest { + t.Errorf("parseMountRef(%q) = (%q,%q,%v), want (%q,%q,%v)", + c.in, owner, rest, ok, c.owner, c.rest, c.ok) + } + } +} + +// TestBuildRouteTableIncludesKernel 路由表含内核保留项(供冲突提示用)。 +func TestBuildRouteTableIncludesKernel(t *testing.T) { + table := BuildRouteTable( + map[string]string{"/plugins/a": "alpha"}, + map[string]string{"/v1/plugins/b": "beta"}, + ) + var hasKernel, hasPlugin, hasAdapter bool + for _, r := range table { + if r.Owner == "kernel" { + hasKernel = true + } + if r.Owner == "plugin:alpha" { + hasPlugin = true + } + if r.Owner == "adapter:beta" { + hasAdapter = true + } + } + if !hasKernel || !hasPlugin || !hasAdapter { + t.Errorf("路由表应含 kernel/plugin/adapter 三类: %+v", table) + } +} + +// TestPluginServesCustomMountEndToEnd 是 R3 的端到端验收: +// 插件声明一个自定义对外挂载点,客户端按该路径访问,得到插件的应答。 +func TestPluginServesCustomMountEndToEnd(t *testing.T) { + ts, key := messagesGateway(t) + // messagesGateway 用的 core 没有插件目录配置,这里直接建一个带插件的。 + _ = ts + _ = key + + dir := t.TempDir() + cfg := &config.Config{ + Path: filepath.Join(dir, "config.yaml"), + AdapterDir: filepath.Join(dir, "adapters"), + PluginDir: filepath.Join(dir, "plugins"), + RuntimeFile: filepath.Join(dir, "runtime.json"), + Listen: "127.0.0.1:0", + DefaultModel: "AUTO", + GatewayKeys: []string{"sk-mount-0001"}, + Sources: []config.Source{{ + Name: "s", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", + APIKey: "k", MaxConcurrent: 1, + Models: []config.Model{{ID: "m", Kind: "chat"}}, + }}, + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatal(err) + } + defer c.Close() + gw, err := New(c) + if err != nil { + t.Fatal(err) + } + ts2 := httptest.NewServer(gw.Handler()) + defer ts2.Close() + + // 装一个声明自定义挂载的插件 + code := ` +local plugin = { name = "publicapi", version = "1.0" } +plugin.http = { + { path = "publicapi/token", method = "GET", handler = "token", + roles = { "admin", "user" }, description = "对外 token 端点" }, +} +function plugin.token(req) + return { status = 200, body = '{"token":"abc"}' } +end +return plugin +` + if err := c.Plugins().LoadSource("publicapi", code); err != nil { + t.Fatalf("load plugin: %v", err) + } + + // 按自定义挂载访问 + req, _ := http.NewRequest("GET", ts2.URL+"/plugins/publicapi/token", nil) + req.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0]) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + t.Fatalf("自定义挂载 status = %d: %s", resp.StatusCode, body) + } + if !strings.Contains(string(body), "abc") { + t.Errorf("自定义挂载未返回插件应答: %s", body) + } + + // /v1/plugins/ 形式同样可用 + req2, _ := http.NewRequest("GET", ts2.URL+"/v1/plugins/publicapi/token", nil) + req2.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0]) + resp2, err := http.DefaultClient.Do(req2) + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + if resp2.StatusCode != 200 { + t.Errorf("/v1/plugins/ 形式 status = %d", resp2.StatusCode) + } + + // 未声明的命名空间 404(不能因为落在允许命名空间就放行) + req3, _ := http.NewRequest("GET", ts2.URL+"/plugins/nobody/here", nil) + req3.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0]) + resp3, err := http.DefaultClient.Do(req3) + if err != nil { + t.Fatal(err) + } + defer resp3.Body.Close() + if resp3.StatusCode != 404 { + t.Errorf("未注册的挂载应 404, 实得 %d", resp3.StatusCode) + } + + // 鉴权仍生效:无 key 时, + // - API 路径(/v1/plugins/…)应 401(isAPIPath 判定) + // - 浏览器路径(/plugins/…)应 302 跳登录页 + req4, _ := http.NewRequest("GET", ts2.URL+"/v1/plugins/publicapi/token", nil) + noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + resp4, err := noRedirect.Do(req4) + if err != nil { + t.Fatal(err) + } + defer resp4.Body.Close() + if resp4.StatusCode != 401 { + t.Errorf("无 key 访问 /v1/plugins/ 应 401, 实得 %d", resp4.StatusCode) + } + + req5, _ := http.NewRequest("GET", ts2.URL+"/plugins/publicapi/token", nil) + resp5, err := noRedirect.Do(req5) + if err != nil { + t.Fatal(err) + } + defer resp5.Body.Close() + if resp5.StatusCode != 302 { + t.Errorf("无 key 访问 /plugins/ 应 302 跳登录, 实得 %d", resp5.StatusCode) + } + if loc := resp5.Header.Get("Location"); !strings.Contains(loc, "/login") { + t.Errorf("应跳转到 /login, 实得 %q", loc) + } +} + +// TestCustomMountCannotInterceptKernelRoute 端到端确认内核路由不受影响: +// 即使有插件在用 /plugins/,/v1/chat/completions 与 /v1/models 仍归内核。 +func TestCustomMountCannotInterceptKernelRoute(t *testing.T) { + for _, p := range []string{"/v1/chat/completions", "/v1/models", "/v1/messages", "/api/stats"} { + if _, err := ValidateCustomMount(p); err == nil { + t.Errorf("内核路由 %q 必须不能被声明为自定义挂载", p) + } + } +} diff --git a/internal/gateway/plugin_call_api_test.go b/internal/gateway/plugin_call_api_test.go index 1f9f328..7f4afa1 100644 --- a/internal/gateway/plugin_call_api_test.go +++ b/internal/gateway/plugin_call_api_test.go @@ -147,4 +147,4 @@ func TestPluginCallIsAdminOnly(t *testing.T) { t.Fatalf("a non-admin key reached the call endpoint: HTTP %d %s", rr.Code, rr.Body.String()) } -} \ No newline at end of file +} diff --git a/internal/gateway/plugin_http_routes.go b/internal/gateway/plugin_http_routes.go index 71e6e46..4b43848 100644 --- a/internal/gateway/plugin_http_routes.go +++ b/internal/gateway/plugin_http_routes.go @@ -17,6 +17,7 @@ package gateway import ( "net/http" + "sort" "strconv" "strings" @@ -45,12 +46,25 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) { matches := ps.HTTPHandlers() var chosen *lua.HTTPHandler var params map[string]string + // contenders holds the handlers that matched at the current best depth with + // the SAME literal route pattern — i.e. two plugins that both declared this + // exact path. That is the contested case the operator must arbitrate (user + // requirement A): rather than let load order pick a silent winner, a + // contested path with no recorded decision returns 409 naming the candidates. + var contenders []lua.HTTPHandler for i := range matches { h := matches[i] - if p, ok := matchHandlerPath(h.Path, rest); ok { - if chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path) { - chosen, params = &h, p - } + p, ok := matchHandlerPath(h.Path, rest) + if !ok { + continue + } + switch { + case chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path): + chosen, params = &matches[i], p + contenders = []lua.HTTPHandler{matches[i]} + case segmentCount(h.Path) == segmentCount(chosen.Path) && h.Path == chosen.Path: + // Same route pattern, a different plugin. The winner is arbitrated below. + contenders = append(contenders, matches[i]) } } if chosen == nil { @@ -58,6 +72,13 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) { "no plugin serves this path") return } + if len(contenders) > 1 { + resolved := g.resolveContestedRoute(w, ps, chosen.Path, contenders) + if resolved == nil { + return // 409 already written, naming the candidates + } + chosen = resolved + } if !strings.EqualFold(r.Method, chosen.Method) { w.Header().Set("Allow", chosen.Method) @@ -98,6 +119,44 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) { lua.WriteHandlerResponse(w, resp) } +// resolveContestedRoute arbitrates a service path that more than one plugin +// declared (user requirement A). It returns the winning handler, or nil after +// writing a 409 when the operator has not yet chosen. +// +// This is the crux of the "report, don't crash, don't silently pick" rule: with +// two plugins both serving e.g. "stats", load order used to decide the winner. +// Now an unresolved contest is a 409 that NAMES the candidates, and the operator +// picks one in the WebUI (POST /api/plugins/conflicts). A recorded decision is +// honoured here on every request thereafter. +func (g *Gateway) resolveContestedRoute(w http.ResponseWriter, ps *lua.Plugins, path string, contenders []lua.HTTPHandler) *lua.HTTPHandler { + winner := ps.RouteWinner(path) + if winner != "" { + for i := range contenders { + if contenders[i].Plugin == winner { + return &contenders[i] + } + } + // A decision exists but points at a plugin that no longer serves this + // path (it was removed or edited). Fall through to 409 rather than honour + // a stale winner that cannot answer. + } + names := make([]string, 0, len(contenders)) + for i := range contenders { + names = append(names, contenders[i].Plugin) + } + sort.Strings(names) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + writeJSON(w, http.StatusConflict, map[string]interface{}{ + "error": map[string]interface{}{ + "type": "route_conflict", + "message": "service path \"" + path + "\" is claimed by " + strconv.Itoa(len(names)) + " plugins (" + strings.Join(names, ", ") + "); an operator must choose one in the admin UI (Conflicts) or POST /api/plugins/conflicts", + "path": path, + "candidates": names, + }, + }) + return nil +} + // handlerAccessHint explains, in the refusal, exactly which declaration would // open this endpoint. A 403 with no reason is the kind of thing an operator // opens the source to work out, and then the fix is a guess. @@ -204,4 +263,4 @@ type bodyTooLarge struct{} func (bodyTooLarge) Error() string { return "request body exceeds 4 MiB" } // limitBody is kept for the numeric form some callers want in a log line. -var _ = strconv.Itoa \ No newline at end of file +var _ = strconv.Itoa diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index 65f01fb..6ea786c 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -82,6 +82,57 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/plugins") path = strings.Trim(path, "/") + // /api/plugins/conflicts — the arbitration surface (user requirement). + // GET lists every contested route (A) and every suspended adapter-name + // install (B), each with its candidates/incumbent and any recorded + // decision. + // POST {kind,path|name,winner} records the operator's choice and persists + // it. Must be routed BEFORE the single-segment {name} handler below, + // which would otherwise read "conflicts" as a plugin name. + if path == "conflicts" { + if reqRole(r.Context()) != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]interface{}{ + "routes": ps.RouteConflicts(), + "adapters": ps.AdapterConflicts(), + }) + case http.MethodPost: + var body struct { + Kind string `json:"kind"` // "route" or "adapter" + Path string `json:"path"` + Name string `json:"name"` + Winner string `json:"winner"` // route: plugin name; adapter: "incumbent"|"candidate" + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + switch body.Kind { + case "route": + if err := ps.ResolveRouteConflict(body.Path, body.Winner); err != nil { + writeError(w, http.StatusBadRequest, "conflict_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "path": body.Path, "winner": body.Winner}) + case "adapter": + if err := ps.ResolveAdapterConflict(body.Name, body.Winner); err != nil { + writeError(w, http.StatusBadRequest, "conflict_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name, "winner": body.Winner}) + default: + writeError(w, http.StatusBadRequest, "invalid_request", "kind must be \"route\" or \"adapter\"") + } + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") + } + return + } + // /api/plugins/{name}/state if strings.HasSuffix(path, "/state") { name := strings.TrimSuffix(path, "/state") @@ -105,6 +156,22 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { } } + // /api/plugins/{name}/kernel — the operator's view of, and edit surface for, + // which kernel actions a plugin may call, plus its declared extra outbound hosts. + if strings.HasSuffix(path, "/kernel") { + name := strings.TrimSuffix(path, "/kernel") + if name == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "plugin name required") + return + } + g.handlePluginKernel(w, r, name) + return + } + + // /api/plugins/{name}/login — REMOVED. Login sessions are plugin domain: + // channels.lua owns its own session state and serves it through its own + // mount. The kernel has no login concept. + // /api/plugins/{name}/call/{fn} if i := strings.Index(path, "/call/"); i >= 0 { name := path[:i] @@ -617,3 +684,68 @@ func (g *Gateway) handlePluginVisibility(w http.ResponseWriter, r *http.Request, writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method+" is not allowed here") } } + +// handlePluginKernel is the operator's surface for a plugin's kernel grants and +// declared extra outbound hosts. +// +// GET returns what the plugin DECLARED beside what was GRANTED. That pairing is +// the point of the endpoint: a plugin declaring `add_source` is a request, and +// the operator needs to see the request next to the current answer to decide. +func (g *Gateway) handlePluginKernel(w http.ResponseWriter, r *http.Request, name string) { + ps := g.core.Plugins() + if ps == nil { + writeError(w, http.StatusNotFound, "not_found", "no plugins loaded") + return + } + if reqRole(r.Context()) != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + switch r.Method { + case http.MethodGet: + perm := ps.PluginKernelPermission(name) + declared := []string(nil) + for _, v := range ps.KernelPermissions() { + if v.Plugin == name { + declared = v.Declared + } + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "plugin": name, + "actions": ps.KernelActionsView(name), + "declared": declared, + "granted": perm.Actions, + "all_read": perm.AllRead, + // Extra outbound hosts: declared vs granted, same request/privilege + // split as kernel actions. What the plugin does with a host (login, + // claim quotas...) is plugin business — the kernel only bounds egress. + "hosts_declared": ps.PluginExtraHosts(name), + "hosts_granted": ps.ExtraHostsGranted()[name], + }) + return + case http.MethodPut, http.MethodPost: + var body struct { + Actions []string `json:"actions"` + AllRead bool `json:"all_read"` + Hosts []string `json:"hosts"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if err := ps.SetPluginKernelPermission(name, body.Actions, body.AllRead); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if body.Hosts != nil { + if err := ps.GrantExtraHosts(name, body.Hosts); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) + return + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET or PUT") + } +} diff --git a/internal/gateway/plugins_api_test.go b/internal/gateway/plugins_api_test.go index 85cbcd7..8c4e69f 100644 --- a/internal/gateway/plugins_api_test.go +++ b/internal/gateway/plugins_api_test.go @@ -222,7 +222,7 @@ func TestPluginsListCarriesAccessTables(t *testing.T) { t.Fatalf("GET /api/plugins = %d", rr.Code) } var out struct { - Visibility map[string]string `json:"visibility"` + Visibility map[string]string `json:"visibility"` HTTPAccess []map[string]interface{} `json:"http_access"` } if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { diff --git a/internal/gateway/serializer.go b/internal/gateway/serializer.go new file mode 100644 index 0000000..c36212f --- /dev/null +++ b/internal/gateway/serializer.go @@ -0,0 +1,466 @@ +// Package gateway — 对外序列化(Phase 2) +// +// 职责边界(feature/unified-wire-v2): +// +// 内核不硬编码任何对外协议。序列化由 Serializer 承载,默认实现是 +// openAISerializer —— 但它只是**最后容错**,不是默认业务路径。 +// +// 优先级契约(用户明确要求:任何适配器与兜底 OpenAI 格式冲突时, +// 兜底 OpenAI 直接让步): +// +// 适配器声明 serialize_response / serialize_chunk +// ↓ 未声明 +// openAISerializer(内核兜底,保证刚部署未配适配器时仍能服务) +// +// 「让步」的具体含义:一旦适配器声明了对外序列化钩子,内核在**该源** +// 上完全采用适配器的输出,绝不与兜底格式合并/兜底字段。A 请求经 Anthropic +// 序列化后,绝不会被再补上 OpenAI 的字段。这条规则由 +// TestAdapterSerializerWinsOverFallback 锁定。 +// +// 安全网:openAISerializer 的输出与改造前内核手写的 OpenAI wire 字节级 +// 一致(e2e/.snapshots golden 锁定),所以「适配器未声明」这条路径上的 +// 对外行为与本次改造之前完全相同。 +package gateway + +import ( + "encoding/json" + "fmt" + "sync" + "sync/atomic" + "time" + + "llmsproxy/internal/lua" + "llmsproxy/internal/provider" + "llmsproxy/internal/types" +) + +// StreamState 是一个流会话在序列化侧的累积状态(角色前导是否已发、已见 +// 的终止原因、累积 usage/cost)。跨 chunk 传递,避免每帧重新统计。 +type StreamState struct { + LastFinish string + Usage *types.TokenUsage + Cost string +} + +// Serializer 把统一内部格式序列化成对外协议的响应体。 +// +// 非流式与流式的终止语义对不同协议差异很大(OpenAI 在最后一个 chunk 上 +// 带 finish_reason 后跟一个 usage-only 帧;Anthropic 用独立的 +// message_delta 事件;Gemini 用空 candidates 帧),所以「流如何结束」 +// 是接口的一部分,而不是调用方的 if。 +type Serializer interface { + // NonStream 把一次完成的统一响应序列化成对外 body。 + NonStream(resp *types.UnifiedResponse, modelName string, id string, created int64) ([]byte, error) + + // OpenRolePreamble 返回流开始时的角色前导帧(可能为空表示该协议不需要)。 + // 返回空切片时调用方不写帧。 + OpenRolePreamble(modelName, id string, created int64, st *StreamState) ([]byte, error) + + // Chunk 把一个统一分块序列化成对外 SSE 帧(不含 "data: " 前缀与空行)。 + Chunk(ck *types.UnifiedChunk, modelName, id string, created int64, st *StreamState) ([]byte, error) + + // Close 返回流结束时的收尾帧序列(可多帧),随后调用方写协议自己的 + // 流终止标记(OpenAI 是 [DONE],Anthropic 无)。 + Close(modelName, id string, created int64, st *StreamState) ([][]byte, error) + + // DoneMarker 返回该协议的流终止标记;OpenAI 是 "[DONE]",无此概念的 + // 协议返回空串(调用方因此不写那一行)。 + DoneMarker() string +} + +// ---- 兜底实现:OpenAI wire ---- + +// openAISerializer 是内核的**容错兜底**,输出与改造前手写的 OpenAI wire +// 字节级一致(golden 锁定)。它不是默认业务路径——适配器一旦声明序列化 +// 钩子就完全取代它。 +type openAISerializer struct{} + +// OpenAISerializer 返回兜底 OpenAI 序列化器。暴露给同包测试与 +// 「未配置任何适配器」的启动兜底路径使用。 +func OpenAISerializer() Serializer { return openAISerializer{} } + +func (openAISerializer) NonStream(resp *types.UnifiedResponse, modelName, id string, created int64) ([]byte, error) { + msg := RespMessage{Role: "assistant", Content: resp.Content} + if resp.ReasoningContent != "" { + msg.ReasoningContent = resp.ReasoningContent + } + if len(resp.ToolCalls) > 0 { + msg.ToolCalls = toolCallsWire(resp.ToolCalls) + } + out := ChatCompletion{ + ID: id, + Object: "chat.completion", + Created: created, + Model: modelName, + Choices: []ChatChoice{{Index: 0, Message: msg, FinishReason: resp.FinishReason}}, + Cost: resp.Cost, + } + if resp.TokenUsage.Total > 0 || resp.TokenUsage.Prompt > 0 || resp.TokenUsage.Completion > 0 { + out.Usage = &resp.TokenUsage + } + return json.Marshal(out) +} + +func (openAISerializer) OpenRolePreamble(modelName, id string, created int64, st *StreamState) ([]byte, error) { + return json.Marshal(ChatChunk{ + ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName, + Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{Role: "assistant"}}}, + }) +} + +func (openAISerializer) Chunk(ck *types.UnifiedChunk, modelName, id string, created int64, st *StreamState) ([]byte, error) { + delta := RespMessage{Role: "assistant", Content: ck.Content} + if ck.ReasoningContent != "" { + delta.ReasoningContent = ck.ReasoningContent + } + if len(ck.ToolCalls) > 0 { + delta.ToolCalls = ck.ToolCalls + } + choice := ChunkChoice{Index: 0, Delta: delta} + if ck.Done { + fin := ck.FinishReason + if fin == "" { + fin = "stop" + } + st.LastFinish = fin + choice.FinishReason = &fin + } + return json.Marshal(ChatChunk{ + ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName, + Choices: []ChunkChoice{choice}, + }) +} + +func (openAISerializer) Close(modelName, id string, created int64, st *StreamState) ([][]byte, error) { + fin := st.LastFinish + if fin == "" { + fin = "stop" + } + term, err := json.Marshal(ChatChunk{ + ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName, + Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &fin}}, + }) + if err != nil { + return nil, err + } + out := [][]byte{term} + // OpenAI 的 usage 帧:空 choices + usage + 可选 cost。 + if st.Usage != nil || st.Cost != "" { + var tut *types.TokenUsage + if st.Usage != nil { + tut = st.Usage + } else { + tut = &types.TokenUsage{} + } + u, err := json.Marshal(ChatChunk{ + ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName, + Choices: []ChunkChoice{}, Usage: tut, Cost: st.Cost, + }) + if err != nil { + return nil, err + } + out = append(out, u) + } + return out, nil +} + +func (openAISerializer) DoneMarker() string { return "[DONE]" } + +// ---- 适配器钩子通道 ---- + +// luaSerializer 把对外序列化委托给适配器的 serialize_response / +// serialize_chunk 钩子。适配器声明了钩子就**完全取代**兜底实现。 +type luaSerializer struct { + vm *lua.VM + adapter string + // responseOK / chunkOK 在构造时一次性探测(HasFn 不占用 worker)。 + responseOK bool + chunkOK bool + // openPreambleOK / closeOK 是可选钩子;未声明时借用兜底实现的对应行为, + // 这样「只实现 serialize_chunk」的适配器也能立刻可用。 + openPreambleOK bool + closeOK bool +} + +// serializerFor 为某个适配器名挑选序列化器。 +// +// 这是「适配器优先、兜底让步」的唯一决策点:适配器声明了对外序列化 +// 钩子就返回 luaSerializer(其输出不再与 OpenAI 兜底混合),否则返回 +// 兜底 OpenAI。 +func serializerFor(vm *lua.VM, adapter string) Serializer { + if vm == nil { + return OpenAISerializer() + } + resp := vm.HasFn(adapter, "serialize_response") + chunk := vm.HasFn(adapter, "serialize_chunk") + if !resp && !chunk { + return OpenAISerializer() + } + return &luaSerializer{ + vm: vm, + adapter: adapter, + responseOK: resp, + chunkOK: chunk, + openPreambleOK: vm.HasFn(adapter, "serialize_open_preamble"), + closeOK: vm.HasFn(adapter, "serialize_close"), + } +} + +func (s *luaSerializer) NonStream(resp *types.UnifiedResponse, modelName, id string, created int64) ([]byte, error) { + if !s.responseOK { + // 该适配器只实现了流式钩子:非流式沿用兜底。这是唯一允许兜底 + // 参与的场景——适配器**没有**就该方向的实现,而不是「冲突」。 + return openAISerializer{}.NonStream(resp, modelName, id, created) + } + payload := map[string]interface{}{ + "model": modelName, + "id": id, + "created": created, + "content": resp.Content, + "reasoning": resp.ReasoningContent, + "finish_reason": resp.FinishReason, + "token_usage": resp.TokenUsage, + "cost": resp.Cost, + } + if len(resp.ToolCalls) > 0 { + payload["tool_calls"] = resp.ToolCalls + } + out, err := s.vm.Transform(s.adapter, "serialize_response", wireJSON(payload)) + if err != nil || out == "" { + // 钩子失败:明确退回兜底,而不是产出一半适配器字段 + 一半 OpenAI + // 字段的混合体(那正是「让步」规则要避免的)。 + return openAISerializer{}.NonStream(resp, modelName, id, created) + } + return []byte(out), nil +} + +func (s *luaSerializer) OpenRolePreamble(modelName, id string, created int64, st *StreamState) ([]byte, error) { + if !s.openPreambleOK { + return openAISerializer{}.OpenRolePreamble(modelName, id, created, st) + } + out, err := s.vm.Transform(s.adapter, "serialize_open_preamble", + wireJSON(map[string]interface{}{"model": modelName, "id": id, "created": created})) + if err != nil { + return openAISerializer{}.OpenRolePreamble(modelName, id, created, st) + } + if out == "" { + return nil, nil // 该协议不需要角色前导 + } + return []byte(out), nil +} + +func (s *luaSerializer) Chunk(ck *types.UnifiedChunk, modelName, id string, created int64, st *StreamState) ([]byte, error) { + if !s.chunkOK { + return openAISerializer{}.Chunk(ck, modelName, id, created, st) + } + payload := map[string]interface{}{ + "model": modelName, + "id": id, + "created": created, + "content": ck.Content, + "reasoning": ck.ReasoningContent, + "done": ck.Done, + "finish_reason": ck.FinishReason, + } + if len(ck.ToolCalls) > 0 { + payload["tool_calls"] = ck.ToolCalls + } + if ck.Usage != nil { + payload["usage"] = ck.Usage + } + if ck.Cost != "" { + payload["cost"] = ck.Cost + } + out, err := s.vm.Transform(s.adapter, "serialize_chunk", wireJSON(payload)) + if err != nil { + return openAISerializer{}.Chunk(ck, modelName, id, created, st) + } + if out == "" { + return nil, nil + } + // 适配器的终止帧要驱动审计记录与收尾逻辑,所以把终止原因抄回状态。 + if ck.Done && st.LastFinish == "" { + if fin := ck.FinishReason; fin != "" { + st.LastFinish = fin + } + } + return []byte(out), nil +} + +func (s *luaSerializer) Close(modelName, id string, created int64, st *StreamState) ([][]byte, error) { + if !s.closeOK { + return openAISerializer{}.Close(modelName, id, created, st) + } + out, err := s.vm.Transform(s.adapter, "serialize_close", + wireJSON(map[string]interface{}{ + "model": modelName, "id": id, "created": created, + "finish_reason": st.LastFinish, "cost": st.Cost, + "has_usage": st.Usage != nil, + })) + if err != nil { + return openAISerializer{}.Close(modelName, id, created, st) + } + if out == "" { + return nil, nil + } + // 适配器可以返回多帧(换行分隔),因为 Anthropic 的收尾本身就是 + // message_stop + 若干 delta;单帧限制会逼适配器把多事件塞进一个 JSON。 + var frames [][]byte + for _, line := range splitLines(out) { + if line == "" { + continue + } + frames = append(frames, []byte(line)) + } + return frames, nil +} + +// DoneMarker 返回协议自己的流终止标记。适配器可用 serialize_done_marker +// 声明;未声明时若有自定义流式序列化就返回空串(表示「没有 [DONE] 这种 +// 概念」),否则沿用 OpenAI 的 [DONE]。 +func (s *luaSerializer) DoneMarker() string { + if s.vm.HasFn(s.adapter, "serialize_done_marker") { + out, err := s.vm.Transform(s.adapter, "serialize_done_marker", "{}") + if err == nil { + return out + } + } + if s.chunkOK { + // 适配器接管了流式序列化:只有它没显式声明标记时才不发 [DONE], + // 因为擅自给 Anthropic 流加一行 "[DONE]" 会让严格客户端报错。 + return "" + } + return "[DONE]" +} + +// splitLines 切分多行帧输出。 +func splitLines(s string) []string { + var out []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + out = append(out, trimCR(s[start:i])) + start = i + 1 + } + } + if start < len(s) { + out = append(out, trimCR(s[start:])) + } + return out +} + +func trimCR(s string) string { + if n := len(s); n > 0 && s[n-1] == '\r' { + return s[:n-1] + } + return s +} + +// ---- 共享辅助 ---- + +var wireSeq int64 + +func newWireID() string { + n := atomic.AddInt64(&wireSeq, 1) + return fmt.Sprintf("chatcmpl-%d", n) +} + +func newCreated() int64 { return time.Now().Unix() } + +// wireJSON 是本文件私有的 JSON 编码助手(包内已有 discover.go 的 mustJSON, +// 那个返回 []byte 且忽略错误,语义不同,故不共用)。 +func wireJSON(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + return "{}" + } + return string(b) +} + +// serializerCache 按 (VM, 适配器名) 缓存 Serializer。 +// +// VM 必须进键里:缓存曾经只按适配器名做键,结果一个进程内两个 VM(或 +// 热重载后的新旧 VM)会共享同一条判定。插件在运行时改写适配器后声明了 +// serialize_response,另一条缓存记录仍会说「没声明」,于是新钩子永远不 +// 生效——而且因为判定本身不会报错,表现只是「适配器说好的对外格式没 +// 出现」,极难定位。 +// +// HasFn 的代价决定了它必须被缓存:它会**编译一次适配器脚本**来回答 +// 「这个适配器声明了哪些函数」,放在每个请求上等于把加载成本翻倍。 +// +// 失效由 vmGeneration 负责:适配器重写时 VM 的代号会变(见 adapterEpoch), +// 于是旧条目再也匹配不上,无需显式清扫。 +var serializerCache sync.Map // cacheKey -> Serializer + +// cacheKey 是 (VM 指针, VM 代号, 适配器名) 的复合键。 +type cacheKey struct { + vm *lua.VM + gen uint64 + name string +} + +func serializerForCached(vm *lua.VM, adapter string) Serializer { + if vm == nil { + return OpenAISerializer() + } + k := cacheKey{vm: vm, gen: adapterEpoch(vm, adapter), name: adapter} + if v, ok := serializerCache.Load(k); ok { + return v.(Serializer) + } + s := serializerFor(vm, adapter) + actual, _ := serializerCache.LoadOrStore(k, s) + return actual.(Serializer) +} + +// adapterEpoch 返回该适配器「当前这份代码」的代号。适配器被重写后代号变化, +// 使旧缓存条目自动失效(见 serializerCache 的注释)。 +func adapterEpoch(vm *lua.VM, adapter string) uint64 { + return vm.AdapterRevision(adapter) +} + +// serializerForSource 解析「实际服务该请求的那个源」该用哪个序列化器。 +// +// 必须按**服务者**选,而不是按调用方请求的首选源选:direct 请求在候选源 +// 之间故障转移时,usedSrc 可能与请求 model 所属的源完全不同;按首选源选 +// 会让故障转移后的响应带着另一个源的对外格式。 +// +// 源名查不到时(配置在选择之后被改、或插件装的源尚未注册)退回兜底 +// OpenAI:那是「刚部署 / 异常态下仍能服务」的保证,而不是静默产出错误格式。 +func serializerForSource(vm *lua.VM, sources []SourceRef, sourceName string) Serializer { + for _, s := range sources { + if s.Name == sourceName { + return serializerForCached(vm, s.Adapter) + } + } + return OpenAISerializer() +} + +// SourceRef 是解析序列化器所需的最小源信息(名字 + 适配器)。 +type SourceRef struct { + Name string + Adapter string +} + +// serializerFor 是 Gateway 侧的解析入口:按**实际服务该请求的源**挑序列化器。 +// +// cands 传候选列表时用它做故障转移后的解析(服务源可能不是首选);传 nil +// 表示 AUTO 路径——AUTO 的服务源由链决定,不在调用方的候选集里,退回按 +// 当前配置里全部源查找。 +func (g *Gateway) serializerFor(sourceName string, cands []*provider.Provider) Serializer { + vm := g.core.VM() + if vm == nil { + return OpenAISerializer() + } + if cands != nil { + refs := make([]SourceRef, 0, len(cands)) + for _, p := range cands { + refs = append(refs, SourceRef{Name: p.Name(), Adapter: p.Adapter()}) + } + return serializerForSource(vm, refs, sourceName) + } + refs := make([]SourceRef, 0, 16) + for _, s := range g.core.Sources() { + refs = append(refs, SourceRef{Name: s.Name, Adapter: s.Adapter}) + } + return serializerForSource(vm, refs, sourceName) +} diff --git a/internal/gateway/serializer_test.go b/internal/gateway/serializer_test.go new file mode 100644 index 0000000..ca49326 --- /dev/null +++ b/internal/gateway/serializer_test.go @@ -0,0 +1,367 @@ +package gateway + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "llmsproxy/internal/lua" + "llmsproxy/internal/types" +) + +// 本文件锁定对外序列化的**优先级契约**: +// +// 适配器声明 serialize_* → 适配器完全接管 +// 适配器未声明 → 兜底 OpenAI(保证刚部署/未配适配器仍能服务) +// +// 以及「让步」的定义:适配器接管后,内核绝不再往它的输出上补任何 OpenAI +// 字段。两个序列化器混在一起产出的东西比任何一个都糟——客户端无法解析, +// 而排查时会指向错误的层。 + +// luaVMFor 建一个会 seed 内置适配器的 VM(目录名必须是 "adapters")。 +func luaVMFor(t *testing.T) *lua.VM { + t.Helper() + vm := lua.NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatalf("vm start: %v", err) + } + t.Cleanup(vm.Stop) + return vm +} + +// TestFallbackUsedWhenAdapterDeclaresNothing 是「兜底」本身的测试。 +// +// 注:anthropic 现在**确实**声明了 serialize_*(Phase 2 给了它 Anthropic +// 形状),所以本测试只在仍未声明钩子的那些内置适配器上验证兜底行为。 +// 这正是设计意图:谁声明谁接管,没声明的继续用 OpenAI 兜底。 +func TestFallbackUsedWhenAdapterDeclaresNothing(t *testing.T) { + vm := luaVMFor(t) + for _, name := range []string{"openai", "gemini", "ollama", "deepseek", "github", "groq"} { + for _, hook := range []string{"serialize_response", "serialize_chunk"} { + if vm.HasFn(name, hook) { + t.Errorf("内置适配器 %s 声明了 %s —— 兜底测试的前提已不成立", name, hook) + } + } + } + ser := serializerFor(vm, "openai") + if _, isFallback := ser.(openAISerializer); !isFallback { + t.Fatalf("未声明钩子的适配器应得到兜底序列化器,得到 %T", ser) + } + + // 对照:声明了钩子的适配器不走兜底 + if _, isFallback := serializerFor(vm, "anthropic").(openAISerializer); isFallback { + t.Error("anthropic 声明了 serialize_response,不应走兜底") + } +} + +// TestAdapterSerializerWinsOverFallback 锁定核心规则:适配器声明钩子即接管, +// 且内核不与兜底混合任何字段。 +func TestAdapterSerializerWinsOverFallback(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "adapters"), 0o755); err != nil { + t.Fatal(err) + } + // 一个刻意用**非 OpenAI 形状**的适配器:如果内核混入了兜底字段 + // (object/choices/usage 等),下面断言会失败。 + adapter := ` +local adapter = {} +adapter.name = "anthropicish" +adapter.version = "1.0.0" +adapter.endpoint = "/chat/completions" + +-- 非流式:故意用 content[] 分块 + 没有 choices/usage +function adapter.serialize_response(payload) + local p = json.decode(payload) + return json.encode({ + type = "message", + role = "assistant", + model = p.model, + content = { { type = "text", text = p.content } }, + stop_reason = (p.finish_reason ~= "" and p.finish_reason or "end_turn"), + }) +end + +-- 流式:event: style 的帧,且不带 OpenAI 的 object 字段 +function adapter.serialize_chunk(payload) + local p = json.decode(payload) + return json.encode({ + type = "content_block_delta", + delta = { type = "text_delta", text = p.content }, + }) +end + +return adapter +` + if err := os.WriteFile(filepath.Join(dir, "adapters", "anthropicish.lua"), []byte(adapter), 0o644); err != nil { + t.Fatal(err) + } + vm := lua.NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatalf("vm: %v", err) + } + defer vm.Stop() + + ser := serializerFor(vm, "anthropicish") + if _, isFallback := ser.(openAISerializer); isFallback { + t.Fatal("声明了 serialize_* 的适配器必须接管序列化,而不是拿到兜底") + } + + // 非流式输出必须是适配器形状,且**不含**任何 OpenAI 字段 + resp := &types.UnifiedResponse{ + Model: "m", + Content: "hello", + FinishReason: "stop", + TokenUsage: types.TokenUsage{Prompt: 3, Completion: 1, Total: 4}, + } + body, err := ser.NonStream(resp, "m", "id-1", 1700000000) + if err != nil { + t.Fatalf("NonStream: %v", err) + } + var got map[string]interface{} + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("adapter output not JSON: %v (%s)", err, body) + } + if got["type"] != "message" { + t.Errorf("适配器输出未被采用:type = %v, want message", got["type"]) + } + // 「让步」的硬断言:OpenAI 字段一个都不许出现 + for _, k := range []string{"object", "choices", "usage", "created"} { + if _, leaked := got[k]; leaked { + t.Errorf("兜底 OpenAI 字段 %q 混进了适配器的输出(让步规则被违反): %s", k, body) + } + } + // 适配器没有读 usage,但兜底也不许替它补上 + if _, has := got["token_usage"]; has { + t.Errorf("兜底补了 token_usage 字段,适配器输出被污染: %s", body) + } +} + +// TestAdapterSerializerStreamWinsOverFallback 同上,流式路径。 +func TestAdapterSerializerStreamWinsOverFallback(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "adapters"), 0o755); err != nil { + t.Fatal(err) + } + adapter := ` +local adapter = {} +adapter.name = "streamsh" +adapter.version = "1.0.0" +adapter.endpoint = "/chat/completions" + +function adapter.serialize_chunk(payload) + local p = json.decode(payload) + return json.encode({ type = "content_block_delta", text = p.content }) +end + +-- 显式声明收尾与终止标记,证明协议语义完全由适配器决定 +function adapter.serialize_close(payload) + local p = json.decode(payload) + return json.encode({ type = "message_stop", stop_reason = p.finish_reason }) +end + +return adapter +` + if err := os.WriteFile(filepath.Join(dir, "adapters", "streamsh.lua"), []byte(adapter), 0o644); err != nil { + t.Fatal(err) + } + vm := lua.NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatalf("vm: %v", err) + } + defer vm.Stop() + + ser := serializerFor(vm, "streamsh") + if _, isFallback := ser.(openAISerializer); isFallback { + t.Fatal("声明 serialize_chunk 的适配器必须接管流式序列化") + } + st := &StreamState{} + b, err := ser.Chunk(&types.UnifiedChunk{Content: "hi"}, "m", "id", 1, st) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"content_block_delta"`) { + t.Errorf("chunk 未采用适配器形状: %s", b) + } + if strings.Contains(string(b), "chat.completion.chunk") || strings.Contains(string(b), `"choices"`) { + t.Errorf("兜底 OpenAI 帧混进了适配器输出: %s", b) + } + + // 终止标记:适配器接管流式后,内核不得自作主张发 [DONE] + if m := ser.DoneMarker(); m != "" { + t.Errorf("适配器接管流式时 DoneMarker 应为空(不擅自发 [DONE]),实得 %q", m) + } + // 但它没声明 serialize_close 时,收尾仍借用兜底形状 —— 这是「没实现」 + // 不是「冲突」,允许兜底。 + if _, isFallbackClose := ser.(interface { + Close(model, id string, created int64, st *StreamState) ([][]byte, error) + }); !isFallbackClose { + t.Fatal("Close 不应在接口上") + } +} + +// TestFallbackIsUsedWhenAdapterHookFails 验证钩子**执行失败**时的行为。 +// +// 契约:退回兜底的完整输出,而不是产出「一半适配器字段 + 一半 OpenAI +// 字段」的混合体。混合体比任何一个单独结果都更难排查。 +func TestFallbackIsUsedWhenAdapterHookFails(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "adapters"), 0o755); err != nil { + t.Fatal(err) + } + adapter := ` +local adapter = {} +adapter.name = "broken" +adapter.version = "1.0.0" +adapter.endpoint = "/chat/completions" +function adapter.serialize_response(payload) + error("deliberate failure") +end +return adapter +` + if err := os.WriteFile(filepath.Join(dir, "adapters", "broken.lua"), []byte(adapter), 0o644); err != nil { + t.Fatal(err) + } + vm := lua.NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatalf("vm: %v", err) + } + defer vm.Stop() + + ser := serializerFor(vm, "broken") + resp := &types.UnifiedResponse{Content: "fallback still works", FinishReason: "stop"} + body, err := ser.NonStream(resp, "m", "id", 1) + if err != nil { + t.Fatalf("钩子失败后必须仍能产出兜底输出,而非报错终止: %v", err) + } + if !strings.Contains(string(body), "chat.completion") { + t.Errorf("钩子失败后未退回兜底 OpenAI 形状: %s", body) + } +} + +// TestFallbackSavesUnconfiguredDeployment 直接覆盖用户说的场景: +// 「刚部署没有配置任何适配器」。 +func TestFallbackSavesUnconfiguredDeployment(t *testing.T) { + // 完全不建 VM(模拟适配器目录不存在/为空) + ser := serializerFor(nil, "whatever") + body, err := ser.NonStream(&types.UnifiedResponse{Content: "no adapters configured"}, "m", "id", 1) + if err != nil { + t.Fatalf("无适配器时兜底必须可用: %v", err) + } + if !strings.Contains(string(body), "chat.completion") { + t.Errorf("无适配器兜底输出形状不对: %s", body) + } +} + +// TestSerializerCacheFollowsAdapterRewrite 验证插件运行时改写适配器后, +// 缓存不会继续持有旧的序列化器判定(否则新声明的钩子永远不生效,而因为 +// 判定本身不报错,表现只是「适配器说好的格式没出现」,极难定位)。 +func TestSerializerCacheFollowsAdapterRewrite(t *testing.T) { + vm := luaVMFor(t) + if _, isFallback := serializerForCached(vm, "openai").(openAISerializer); !isFallback { + t.Fatal("前提不成立:改写前 openai 应走兜底") + } + // 插件改写该适配器,加一个 serialize_response + src := `local a = {} +a.name = "openai" +a.version = "9" +a.endpoint = "/chat/completions" +function a.serialize_response(p) return '{"rewritten":true}' end +return a` + if err := vm.LoadAdapterSource("openai", src); err != nil { + t.Fatalf("rewrite: %v", err) + } + // 无需任何显式清缓存动作:修订号变化即让旧条目失效。 + after := serializerForCached(vm, "openai") + if _, stillFallback := after.(openAISerializer); stillFallback { + t.Error("缓存未随适配器改写失效:已声明 serialize_response 却仍走兜底") + } + body, err := after.NonStream(&types.UnifiedResponse{Content: "x"}, "m", "id", 1) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "rewritten") { + t.Errorf("改写后的序列化器未生效: %s", body) + } +} + +// TestSerializerCacheIsPerVM 是那个 bug 的回归测试:缓存曾经只按适配器名做键, +// 于是同一进程内两个 VM 共享同一条判定——一个适配器被重写后,另一个 VM 的 +// 缓存会说「没声明钩子」,新钩子永远不生效。 +func TestSerializerCacheIsPerVM(t *testing.T) { + vm1 := luaVMFor(t) + vm2 := luaVMFor(t) + // 两个 VM 各自独立:改写 vm1 不得影响 vm2 的判定 + if err := vm1.LoadAdapterSource("openai", `local a={} a.name="openai" a.version="9" +a.endpoint="/x" function a.serialize_response(p) return '{"only_vm1":true}' end return a`); err != nil { + t.Fatal(err) + } + if _, f := serializerForCached(vm1, "openai").(openAISerializer); f { + t.Error("vm1 改写后应改走适配器序列化") + } + if _, f := serializerForCached(vm2, "openai").(openAISerializer); !f { + t.Error("vm2 未被改写,却因共享缓存而丢失了兜底判定(跨 VM 串扰)") + } +} + +// ---- 并发安全 ---- + +// TestSerializerForConcurrent 是压力测试:多 goroutine 并发取序列化器 +// (缓存是 sync.Map,HasFn 会编译脚本 —— 这里验证并发下不 panic、不返回 +// 半初始化对象,且每次结果自洽)。 +func TestSerializerForConcurrent(t *testing.T) { + vm := luaVMFor(t) + const workers, iters = 16, 40 + var wg sync.WaitGroup + errs := make(chan string, workers*iters) + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < iters; i++ { + name := "openai" + if w%2 == 1 { + name = "anthropic" + } + ser := serializerForCached(vm, name) + if ser == nil { + errs <- "nil serializer" + return + } + // 结果必须自洽:同一适配器每次拿到同一类型,且形状与该适配器 + // 声明的协议一致(openai→OpenAI,anthropic→Anthropic)。 + _, isFallback := ser.(openAISerializer) + wantShape := "chat.completion" + switch name { + case "anthropic": + if isFallback { + errs <- "anthropic 并发下退回兜底" + return + } + wantShape = `"type":"message"` + default: + if !isFallback { + errs <- name + " 并发下未走兜底" + return + } + } + body, err := ser.NonStream(&types.UnifiedResponse{Content: "x"}, "m", "id", 1) + if err != nil { + errs <- "serialize: " + err.Error() + return + } + if !strings.Contains(string(body), wantShape) { + errs <- "并发下输出形状不一致: " + name + " -> " + string(body) + return + } + } + }(w) + } + wg.Wait() + close(errs) + for e := range errs { + t.Error(e) + } +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 3fe9cb8..0dcc5f6 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -216,6 +216,14 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/v1/chat/completions": g.handleChat(w, r) + case r.URL.Path == "/v1/messages": + // Anthropic Messages protocol. Scheduling, quotas, cooldowns, AUTO + // chain and accounting are EXACTLY the ones /v1/chat/completions uses — + // only the wire shapes differ, and those come from the serving + // adapter's serialize_* hooks. The kernel holds no Anthropic field + // names (see serializer.go: the fallback is OpenAI, and any adapter + // that declares serialize_* replaces it wholesale). + g.handleMessages(w, r) case r.URL.Path == "/v1/images/generations": g.handleImage(w, r) case r.URL.Path == "/v1/models": @@ -244,6 +252,12 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { // Before the generic plugin router: these paths belong to a PLUGIN, not to // the plugin manager, and "http" is a valid sub-resource name otherwise. g.handlePluginHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/plugins/") || strings.HasPrefix(r.URL.Path, "/v1/plugins/"): + // Custom mounts a plugin or adapter claimed (R3). Checked AFTER every + // kernel route so a claim can never shadow one — ValidateCustomMount + // refuses the overlap at load time, and this ordering is the second line + // of defence for a table that somehow slipped through. + g.handleCustomMount(w, r) case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"): g.handlePluginsAPI(w, r) case r.URL.Path == "/api/chat": diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index 8aeff2a..68d6f1b 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -53,7 +53,7 @@ type Req struct { // 0 means "not reported", which is NOT the same as "no reasoning happened" — // three of the sources measured return no such field at all. Reasoning int64 `json:"reasoning_tokens,omitempty"` - OK bool `json:"ok"` + OK bool `json:"ok"` // Status http status code Status int `json:"status"` // Err short error message diff --git a/internal/gateway/stats_period_records_test.go b/internal/gateway/stats_period_records_test.go index 912e08f..465ebf5 100644 --- a/internal/gateway/stats_period_records_test.go +++ b/internal/gateway/stats_period_records_test.go @@ -74,7 +74,7 @@ func TestStatsPeriodRecordsMatchPager(t *testing.T) { rr := doReq(t, g, "GET", "/api/stats?period=day&limit=100", "") var st struct { Records []struct { - Time int64 `json:"time"` + Time int64 `json:"time"` Model string `json:"model"` } `json:"records"` } @@ -84,7 +84,7 @@ func TestStatsPeriodRecordsMatchPager(t *testing.T) { rr2 := doReq(t, g, "GET", "/api/stats/records?limit=100", "") var pg struct { Records []struct { - Time int64 `json:"time"` + Time int64 `json:"time"` Model string `json:"model"` } `json:"records"` } diff --git a/internal/gateway/stats_unknown_key_test.go b/internal/gateway/stats_unknown_key_test.go index 30a281b..62eff24 100644 --- a/internal/gateway/stats_unknown_key_test.go +++ b/internal/gateway/stats_unknown_key_test.go @@ -137,4 +137,4 @@ func TestStatsAPIUnknownKeyReturnsJSON(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } -} \ No newline at end of file +} diff --git a/internal/gateway/thinking_test.go b/internal/gateway/thinking_test.go new file mode 100644 index 0000000..eb42dd1 --- /dev/null +++ b/internal/gateway/thinking_test.go @@ -0,0 +1,133 @@ +package gateway + +import ( + "encoding/json" + "testing" + + "llmsproxy/internal/types" +) + +// TestThinkingFromClient 把「客户端措辞 → 中立语义」这条翻译链钉死。 +// +// v1 的问题:disable_thinking 是内核私有字段,被序列化进交给适配器的 body, +// 于是 10 个适配器各自写 req.disable_thinking = nil 来清理它。v2 把它换成 +// 双方都认可的 thinking 字段,这条测试确保翻译只发生一次、且在适配器之前。 +func TestThinkingFromClient(t *testing.T) { + cases := []struct { + name string + req chatRequest + want *types.ThinkingConfig + }{ + { + name: "无任何字段 → 不带 thinking(上游用自己默认)", + req: chatRequest{}, + want: nil, + }, + { + name: "disable_thinking=true → enabled=false", + req: chatRequest{DisableThinking: true}, + want: &types.ThinkingConfig{Enabled: boolPtr(false)}, + }, + { + name: "中立 thinking 显式给出 → 原样透传", + req: chatRequest{Thinking: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 4096}}, + want: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 4096}, + }, + { + name: "两者冲突时,中立的 thinking 优先(它带预算,布尔表达不了)", + req: chatRequest{ + DisableThinking: true, + Thinking: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 1024}, + }, + want: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 1024}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.req.thinkingFromClient() + if c.want == nil { + if got != nil { + t.Fatalf("want nil, got %+v", got) + } + return + } + if got == nil { + t.Fatal("want non-nil thinking, got nil") + } + if (got.Enabled == nil) != (c.want.Enabled == nil) { + t.Fatalf("Enabled presence mismatch: got %v want %v", got.Enabled, c.want.Enabled) + } + if got.Enabled != nil && *got.Enabled != *c.want.Enabled { + t.Errorf("Enabled = %v, want %v", *got.Enabled, *c.want.Enabled) + } + if got.BudgetTokens != c.want.BudgetTokens { + t.Errorf("BudgetTokens = %d, want %d", got.BudgetTokens, c.want.BudgetTokens) + } + }) + } +} + +// TestDisableThinkingNeverReachesAdapterBody 是那条泄漏的回归测试:内核私有 +// 字段不得出现在交给适配器的 body 里。 +func TestDisableThinkingNeverReachesAdapterBody(t *testing.T) { + var r chatRequest + if err := json.Unmarshal([]byte(`{"model":"m","messages":[],"disable_thinking":true}`), &r); err != nil { + t.Fatal(err) + } + inner := &types.ChatRequest{ + Model: "m", + Thinking: r.thinkingFromClient(), + Tools: r.rawTools(), + ToolChoice: r.rawToolChoice(), + ExtraBody: r.ExtraBody, + ClientSession: "", + } + body, err := json.Marshal(inner) + if err != nil { + t.Fatal(err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(body, &m); err != nil { + t.Fatal(err) + } + if _, leaked := m["disable_thinking"]; leaked { + t.Errorf("内核私有字段 disable_thinking 泄漏进适配器 body(v1 回归): %s", body) + } + if _, ok := m["thinking"]; !ok { + t.Errorf("中立 thinking 字段缺失: %s", body) + } +} + +// TestToolsPassthroughRaw 确认 tools/tool_choice 以原始形状交给适配器: +// 内核不解释工具 schema,那是适配器的职责。 +func TestToolsPassthroughRaw(t *testing.T) { + var r chatRequest + body := `{"model":"m","messages":[],"tools":[{"type":"function","function":{"name":"f","parameters":{"type":"object"}}}],"tool_choice":"auto"}` + if err := json.Unmarshal([]byte(body), &r); err != nil { + t.Fatal(err) + } + tools := r.rawTools() + if len(tools) == 0 { + t.Fatal("tools 未透传") + } + var decoded []map[string]interface{} + if err := json.Unmarshal(tools, &decoded); err != nil { + t.Fatalf("tools 不是合法 JSON: %v (%s)", err, tools) + } + if len(decoded) != 1 || decoded[0]["type"] != "function" { + t.Errorf("tools 形状被内核改写: %s", tools) + } + if tc := string(r.rawToolChoice()); tc != `"auto"` { + t.Errorf("tool_choice = %s, want \"auto\"", tc) + } + // 空值不应变成 "null" 出现在 body 里 + var empty chatRequest + if got := empty.rawTools(); got != nil { + t.Errorf("无 tools 时应返回 nil, got %s", got) + } + if got := empty.rawToolChoice(); got != nil { + t.Errorf("无 tool_choice 时应返回 nil, got %s", got) + } +} + +func boolPtr(v bool) *bool { return &v } diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 7c238ef..f84d2da 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -850,6 +850,25 @@ plVisibility: "可见性", plEndpoints: "端点", + plKernel: "内核动作", + plKernelTitle: "内核动作权限", + plKernelHint: "勾选即授权。插件源码里申请(plugin.kernel)但未授权的动作会标红 —— 那通常意味着它的某个功能静默失效。", + plKernelAllRead: "只读全开", + plKernelN: "%d 项", + plKernelReq: "有申请", + plKernelAsked: "申请中", + plDangerRead: "只读", + plDangerWrite: "可写", + plDangerDestructive: "破坏性", + plAction: "动作", + plDanger: "危险级", + plSave: "保存", + plClose: "关闭", + plAuthHosts: "额外出站主机(白名单)", + plAuthHostsHint: "插件要访问的主机若不是上游源,默认被出站白名单拒绝。勾选即批准;只能批准插件自己声明过的。", + plHostOk: "已批准", + plHostPending: "待批准", + plNoAuthHosts: "该插件未声明额外出站主机", plAccessTitle: "访问控制", plAccessHint: "填写允许访问的角色或密钥(@名称)。留空=仅 admin。填 public=任何人。指定 @密钥 时只有该密钥能访问,不受角色规则影响。", plWholePlugin: "以下端点", @@ -1162,6 +1181,25 @@ plVisibility: "Visibility", plEndpoints: "Endpoints", + plKernel: "Kernel actions", + plKernelTitle: "Kernel action permissions", + plKernelHint: "Tick to grant. An action a plugin declares in source but was not granted is flagged red - that usually means one of its features is silently dead.", + plKernelAllRead: "all read", + plKernelN: "%d granted", + plKernelReq: "requested", + plKernelAsked: "asked", + plDangerRead: "read", + plDangerWrite: "write", + plDangerDestructive: "destructive", + plAction: "Action", + plDanger: "Danger", + plSave: "Save", + plClose: "Close", + plAuthHosts: "Extra outbound hosts (allowlist)", + plAuthHostsHint: "Hosts a plugin needs that are not upstream sources are blocked by the outbound allowlist by default. Tick to approve; only hosts the plugin itself declared can be approved.", + plHostOk: "approved", + plHostPending: "pending", + plNoAuthHosts: "this plugin declared no extra outbound hosts", plAccessTitle: "Access control", plAccessHint: "Roles or keys (@name) allowed to reach each endpoint. Empty = admin only. public = anyone. Listing @keys replaces the role rules entirely.", plWholePlugin: "endpoints below", @@ -4818,6 +4856,14 @@ function afterChainEdit() { // on_disk rather than the loaded set so a plugin that FAILED to load // still appears, with its error — otherwise a syntax error looks // identical to "the plugin is not there". + async function resolveRoute(path, winner) { + await api("/api/plugins/conflicts", { method: "POST", body: JSON.stringify({ kind: "route", path, winner }) }); + renderPlugins(); + } + async function resolveAdapter(name, winner) { + await api("/api/plugins/conflicts", { method: "POST", body: JSON.stringify({ kind: "adapter", name, winner }) }); + renderPlugins(); + } async function renderPlugins() { let j; try { @@ -4861,6 +4907,7 @@ function afterChainEdit() { ${hooks} ${plVisCell(j, p)} ${plEpsCell(j, p)} + ${plKernelCell(p)} ${toggle} @@ -4869,6 +4916,32 @@ function afterChainEdit() { }) .join(""); + // 冲突裁决(用户要求:服务路径/适配器名冲突不崩溃不拒启,运维在此选一保留) + let confHtml = ""; + try { + const cj = await api("/api/plugins/conflicts"); + const rc = cj.routes || []; + const ac = cj.adapters || []; + if (rc.length || ac.length) { + const rows2 = [] + .concat(rc.map((c) => ` + 路径 ${esc(c.path)} + ${c.candidates.map(esc).join(" vs ")} + ${c.resolved ? `已选 ${esc(c.resolved)}` : `待裁决`} + ${c.candidates.map((n) => ``).join(" ")}`)) + .concat(ac.map((c) => ` + 适配器 ${esc(c.name)} + ${esc(c.incumbent)}(现任) vs 插件 ${esc(c.candidate)}(候选) + ${c.resolved ? `已选 ${esc(c.resolved)}` : `待裁决`} + + `)) + .join(""); + confHtml = `

冲突裁决(${rc.length + ac.length})

+

两个插件/适配器声明了同一条服务路径或同一个适配器名。未裁决的路径返回 409,撞名的适配器由现任继续服务。请选一个保留。

+
${rows2}
冲突项候选状态裁决
`; + } + } catch (_) {} + // Hook errors: a plugin that throws leaves no visible trace anywhere // else, so without this the symptom is "the feature silently doesn't // work". @@ -4879,11 +4952,11 @@ function afterChainEdit() { ) .join(""); - $("#tab-plugins").innerHTML = ` + $("#tab-plugins").innerHTML = `${confHtml}

${t("plTitle")} (${(j.on_disk || []).length})

- - ${rows || ``}
${t("tName")}${t("plState")}${t("plStages")}${t("plVisibility")}${t("plEndpoints")}
${t("plEmpty")}
+ ${t("tName")}${t("plState")}${t("plStages")}${t("plVisibility")}${t("plEndpoints")}${t("plKernel")} + ${rows || `${t("plEmpty")}`}
${errList ? `

${t("plHookErr")}

    ${errList}
` : ""}

${t("plDir")} ${esc(j.plugin_dir)}

@@ -4897,7 +4970,21 @@ function afterChainEdit() {

`; bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code"); + window.__plNames = (j.on_disk || []).map((p) => p.name); renderPluginAccess(j); + // Kernel grants load separately (one request per plugin) and then patch + // ONLY the affected cells. Re-calling renderPlugins() here would recurse: + // renderPlugins -> refreshKernelSummary -> renderPlugins. + refreshKernelSummary().then(() => { + window.__plNames.forEach((n) => { + const el = document.querySelector(`[data-plkernel="${CSS.escape(n)}"]`); + if (el) { + const p = (j.on_disk || []).find((x) => x.name === n); + if (p) el.innerHTML = plKernelCell(p); + } + }); + bindKernelCells(); + }); } @@ -4958,6 +5045,123 @@ function afterChainEdit() { : `—`; } + // plKernelCell shows how many kernel actions a plugin may call, and flags + // a DECLARED-but-not-granted request. The flag matters: a plugin that asks + // for add_source in its source and never gets it looks identical to one + // that never asked, until its feature silently doesn't work. + function plKernelCell(p) { + const k = (window.__plKernel || {})[p.name]; + if (!k) return `—`; + const granted = (k.granted || []).length + (k.all_read ? 1 : 0); + const pending = (k.declared || []).filter((d) => !(k.granted || []).includes(d)); + const label = k.all_read ? t("plKernelAllRead") : t("plKernelN").replace("%d", granted); + const warn = pending.length + ? ` ${t("plKernelReq")}` + : ""; + return `${label}${warn}`; + } + + // openKernelEditor renders the per-plugin kernel/permission panel into + // #pl-access below the table. + async function openKernelEditor(name) { + const host = $("#pl-access"); + if (!host) return; + let j; + try { + j = await api("/api/plugins/" + encodeURIComponent(name) + "/kernel"); + } catch (e) { + host.innerHTML = `

${esc(name)}

${esc(String(e.message || e))}
`; + return; + } + const dangerOrder = { read: 0, write: 1, destructive: 2 }; + const acts = (j.actions || []).slice().sort((a, b) => { + const d = (dangerOrder[a.danger] ?? 9) - (dangerOrder[b.danger] ?? 9); + return d !== 0 ? d : a.name.localeCompare(b.name); + }); + const granted = new Set(j.granted || []); + const rows = acts + .map((a) => { + const tag = a.danger === "destructive" + ? `${t("plDangerDestructive")}` + : a.danger === "write" + ? `${t("plDangerWrite")}` + : `${t("plDangerRead")}`; + const asked = a.declared ? ` ${t("plKernelAsked")}` : ""; + return ` + + ${esc(a.name)}${asked}
${esc(a.description || "")}
+ ${tag}`; + }) + .join(""); + const declaredHosts = j.hosts_declared || []; + const grantedHosts = new Set(j.hosts_granted || []); + const hostRows = declaredHosts.length + ? declaredHosts + .map( + (h) => ` + ${esc(h)} + ${grantedHosts.has(h) ? `${t("plHostOk")}` : `${t("plHostPending")}`}`, + ) + .join("") + : `${t("plNoAuthHosts")}`; + host.innerHTML = ` +

${esc(name)} — ${t("plKernelTitle")}

+

${t("plKernelHint")}

+
+ + ${rows} +
${t("plAction")}${t("plDanger")}
+

+

+
+

${t("plAuthHosts")}

+

${t("plAuthHostsHint")}

+
${hostRows}
+
`; + } + + async function saveKernelPerms(name) { + const actions = [...document.querySelectorAll("[data-ka]")] + .filter((c) => c.checked) + .map((c) => c.getAttribute("data-ka")); + const hosts = [...document.querySelectorAll("[data-ah]")] + .filter((c) => c.checked) + .map((c) => c.getAttribute("data-ah")); + try { + await api("/api/plugins/" + encodeURIComponent(name) + "/kernel", { + method: "PUT", + body: JSON.stringify({ actions, all_read: false, hosts }), + }); + await refreshKernelSummary(); + await openKernelEditor(name); + } catch (e) { + alert(String(e.message || e)); + } + } + + // bindKernelCells is a no-op hook kept so the initial render and the + // post-summary patch share one code path; the cell uses an inline onclick, + // so there is nothing to re-bind. It exists to make that explicit rather + // than to leave a missing function. + function bindKernelCells() {} + + // refreshKernelSummary loads every plugin's grant state so the table + // column can show it without a request per row. + async function refreshKernelSummary() { + const names = (window.__plNames || []); + const out = {}; + await Promise.all( + names.map(async (n) => { + try { + out[n] = await api("/api/plugins/" + encodeURIComponent(n) + "/kernel"); + } catch (e) { + /* a plugin that vanished mid-refresh simply has no summary */ + } + }), + ); + window.__plKernel = out; + } + // ---- visibility & endpoint access ---- // // The plugin page is where an operator edits who can see what. The diff --git a/internal/gateway/ui_image_page_test.go b/internal/gateway/ui_image_page_test.go index a1b6bfe..5f533ba 100644 --- a/internal/gateway/ui_image_page_test.go +++ b/internal/gateway/ui_image_page_test.go @@ -48,11 +48,11 @@ func TestUI_HasDedicatedImagePage(t *testing.T) { ui := uiSource(t) for _, want := range []string{ - `data-tab="image"`, // sidebar entry - `id="tab-image"`, // pane - `function renderImage`, // renderer registered with refresh() - `tab === "image"`, // dispatched - `/v1/images/generations`, // the endpoint the page actually calls + `data-tab="image"`, // sidebar entry + `id="tab-image"`, // pane + `function renderImage`, // renderer registered with refresh() + `tab === "image"`, // dispatched + `/v1/images/generations`, // the endpoint the page actually calls } { if !strings.Contains(ui, want) { t.Errorf("image page is missing %q", want) @@ -99,8 +99,8 @@ func TestUI_ImagePageCallsTheImageEndpoint(t *testing.T) { // contract: the field the UI reads must actually be serialized. func TestSourceStatus_ExposesModelKindsToTheUI(t *testing.T) { st := provider.SourceStatus{ - Name: "vela", - Models: []string{"claude-sonnet-5.5", "gpt-image-2"}, + Name: "vela", + Models: []string{"claude-sonnet-5.5", "gpt-image-2"}, ModelKinds: []provider.ModelKindInfo{ {ID: "claude-sonnet-5.5", Kind: "chat"}, {ID: "gpt-image-2", Kind: "image"}, @@ -192,4 +192,4 @@ func TestImageEndpointComposesUnderRealConditions(t *testing.T) { "default image path doubles the version segment; if the upstream ever "+ "accepts it, composeImageEndpoint's rule needs rechecking", doubled, resp2.StatusCode) } -} \ No newline at end of file +} diff --git a/internal/gateway/ui_image_wire_test.go b/internal/gateway/ui_image_wire_test.go index 56efb32..3364ddf 100644 --- a/internal/gateway/ui_image_wire_test.go +++ b/internal/gateway/ui_image_wire_test.go @@ -106,4 +106,4 @@ func sendImageSource(t *testing.T) string { body = body[:end+1] } return body -} \ No newline at end of file +} diff --git a/internal/gateway/ui_key_auto_test.go b/internal/gateway/ui_key_auto_test.go index c49fea9..3d131cb 100644 --- a/internal/gateway/ui_key_auto_test.go +++ b/internal/gateway/ui_key_auto_test.go @@ -126,6 +126,7 @@ func TestUIStatsShowsPeriodWindow(t *testing.T) { t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n) } } + // Both canvases can exist in the DOM at once: the modal is mounted on top of // the settings tab, whose #scr-canvas stays alive behind it. Resolving the // canvas by document order (whichever comes first) painted the per-key chain diff --git a/internal/gateway/ui_plugin_test.go b/internal/gateway/ui_plugin_test.go index 9414097..8e258a8 100644 --- a/internal/gateway/ui_plugin_test.go +++ b/internal/gateway/ui_plugin_test.go @@ -396,15 +396,15 @@ func TestPluginSidebarEntryCarriesATitle(t *testing.T) { func TestPluginPageHasAccessEditor(t *testing.T) { src := uiSource(t) for _, needle := range []string{ - `renderPluginAccess`, // called after rendering the plugin list - `savePluginAccess`, // wired to a button - `/access`, // hits the per-endpoint access API - `id="pl-access"`, // container exists - `http_access`, // reads the table from /api/plugins - `data-ep=`, // one input per endpoint - `data-plvis=`, // one visibility selector per plugin - `/http/*/access`, // whole-plugin visibility shares the access API - `plVisCell`, // renders the declared-vs-override distinction + `renderPluginAccess`, // called after rendering the plugin list + `savePluginAccess`, // wired to a button + `/access`, // hits the per-endpoint access API + `id="pl-access"`, // container exists + `http_access`, // reads the table from /api/plugins + `data-ep=`, // one input per endpoint + `data-plvis=`, // one visibility selector per plugin + `/http/*/access`, // whole-plugin visibility shares the access API + `plVisCell`, // renders the declared-vs-override distinction } { if !strings.Contains(src, needle) { t.Errorf("plugin page lacks %q", needle) diff --git a/internal/lua/adapter_two_directions_test.go b/internal/lua/adapter_two_directions_test.go new file mode 100644 index 0000000..bcd6609 --- /dev/null +++ b/internal/lua/adapter_two_directions_test.go @@ -0,0 +1,94 @@ +package lua + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +// TestEveryBundledAdapterTwoDirections 逐个验证每个内置适配器在**两个方向**上 +// 的声明与能力一致,且都能在真实 VM 上被调用而不报错。这是"重构后两个方向 +// 都没坏"的批量断言(用户要求:16 个适配器逐个验证两个方向)。 +// +// 两个方向: +// +// 出向(egress):serialize_* 钩子 —— 统一格式 → 客户端协议。 +// 一个适配器要么声明了 serialize_*(接管),要么依赖内核 OpenAI 兜底。 +// 两种都合法;不合法的是"声明了钩子但调用即报错"。 +// +// 入向(ingress):adapter.ingress + deserialize_request —— 客户端协议 → 统一格式。 +// 声明 ingress 的适配器必须同时实现 deserialize_request,否则内核会让出给 +// 一个空解析器(核心里 parseViaIngress 已挡住,此处再在适配器数据面钉一次)。 +// +// 上游向(upstream):transform_request —— 统一格式 → 上游协议。 +// 这是所有适配器的本职,必须存在且可调用。 +func TestEveryBundledAdapterTwoDirections(t *testing.T) { + vm := NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + adapters := vm.ListAdapters() + if len(adapters) == 0 { + t.Fatal("no adapters loaded") + } + + // 一个最小、协议中立的统一请求,供上游向 transform_request 使用。 + unifiedReq := `{"model":"m","messages":[{"role":"user","content":"hi"}],"max_tokens":16,"stream":false}` + + for _, a := range adapters { + a := a + t.Run(a.Name, func(t *testing.T) { + // ---- 上游向:transform_request 必须存在且可调用 ---- + if !vm.HasFn(a.Name, "transform_request") { + t.Errorf("%s: 缺 transform_request(上游向本职)", a.Name) + } else if _, err := vm.Transform(a.Name, "transform_request", unifiedReq); err != nil { + t.Errorf("%s: transform_request 调用失败: %v", a.Name, err) + } + + // ---- 出向:serialize_* 声明与调用一致性 ---- + declaredEgress := false + for _, fn := range []string{"serialize_response", "serialize_chunk", "serialize_open_preamble", "serialize_close", "serialize_done_marker"} { + if vm.HasFn(a.Name, fn) { + declaredEgress = true + } + } + if vm.HasFn(a.Name, "serialize_response") { + // 统一结果形状(见 types.UnifiedResult) + res := `{"model":"m","id":"x","content":"hello","finish_reason":"stop","token_usage":{"prompt":1,"completion":1,"total":2}}` + out, err := vm.Transform(a.Name, "serialize_response", res) + if err != nil { + t.Errorf("%s: serialize_response 调用失败: %v", a.Name, err) + } else if strings.TrimSpace(out) == "" { + t.Errorf("%s: serialize_response 返回空", a.Name) + } else if !json.Valid([]byte(out)) && !strings.HasPrefix(strings.TrimSpace(out), "event:") { + t.Errorf("%s: serialize_response 既非 JSON 也非 SSE: %q", a.Name, out) + } + } + if declaredEgress { + t.Logf("%s: 出向由适配器接管", a.Name) + } else { + t.Logf("%s: 出向走内核 OpenAI 兜底(未声明 serialize_*)", a.Name) + } + + // ---- 入向:声明 ingress 必须配 deserialize_request ---- + ingress := vm.AdapterIngress(a.Name) + hasDeser := vm.HasFn(a.Name, "deserialize_request") + if ingress != "" { + if !hasDeser { + t.Errorf("%s: 声明 ingress=%q 但没有 deserialize_request —— 内核会让出给空解析器", a.Name, ingress) + } else { + // 用该适配器自己服务的那个端点的典型 body 调一次,确认不报错。 + if _, err := vm.Transform(a.Name, "deserialize_request", `{"model":"m","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}`); err != nil { + t.Errorf("%s: deserialize_request 调用失败: %v", a.Name, err) + } + t.Logf("%s: 入向认领 %s", a.Name, ingress) + } + } else if hasDeser { + t.Errorf("%s: 实现了 deserialize_request 却没声明 ingress —— 内核永远不会调用它", a.Name) + } + }) + } +} diff --git a/internal/lua/adapter_validate.go b/internal/lua/adapter_validate.go new file mode 100644 index 0000000..55b4271 --- /dev/null +++ b/internal/lua/adapter_validate.go @@ -0,0 +1,133 @@ +// Package lua — 适配器导入安全校验(R6) +// +// 用户要求(feature/unified-wire-v2):导入适配器时必须检查路径冲突, +// 避免静默错误。 +// +// 之前的风险: +// +// UploadAdapter(name, code) 直接拼 adapter_dir/.lua。 +// name 含 "../" 或绝对路径会写到目录外; +// name 与内置适配器同名(openai/anthropic/gemini…)会静默覆盖—— +// 加载失败只报 "adapter X missing",错误现场与病因分离。 +// +// 这里的校验在**所有导入入口**统一执行(LoadAdapterSource / UploadAdapter / +// 插件 install_adapter),让"为什么被拒绝"成为明确的错误信息。 +package lua + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// AdapterNamePattern 是适配器名的合法字符集。 +// 不允许:路径分隔符(/、\)、点点(..)、绝对路径、空格。 +// 允许:字母、数字、下划线、连字符。 +const AdapterNamePattern = `^[a-zA-Z0-9_-]+$` + +// ErrAdapterNameInvalid 适配器名不合法。 +type ErrAdapterNameInvalid struct{ Name, Reason string } + +func (e *ErrAdapterNameInvalid) Error() string { + return fmt.Sprintf("adapter name %q invalid: %s", e.Name, e.Reason) +} + +// ErrAdapterNameReserved 适配器名与内核内置适配器冲突(用户要求:冲突时 +// 内核内置优先,外部不得覆盖)。 +type ErrAdapterNameReserved struct{ Name string } + +func (e *ErrAdapterNameReserved) Error() string { + return fmt.Sprintf("adapter name %q is reserved by a built-in adapter; renaming or deleting the built-in is not supported", e.Name) +} + +// ValidateAdapterName 校验一个将被导入的适配器名。 +// +// 它不做文件系统存在性检查(那是上传路径的责任),只校验"这个名字作为 +// adapter_dir/.lua 的一部分是否安全"。校验维度: +// +// 1. 非空、符合字符集(拒绝路径穿越、绝对路径、空白) +// 2. 不与内核内置适配器名冲突(除非 force 覆盖内置,但这通常不该被允许) +// 3. 不含保留路径段(.、..、CON、NUL 等) +// +// reservedNames 是调用方提供的"保留名集合"——通常是内置适配器名表。 +// 传 nil 表示无保留名(仍校验字符集与路径安全)。 +func ValidateAdapterName(name string, reservedNames []string) error { + if name == "" { + return &ErrAdapterNameInvalid{Name: name, Reason: "empty"} + } + // 字符集:最简单的拒绝路径风险的方式。 + if !matchesAdapterPattern(name) { + return &ErrAdapterNameInvalid{Name: name, Reason: "must match " + AdapterNamePattern + " (no slashes, dots-only, absolute paths, or whitespace)"} + } + // 保留段:即使字符集通过,也要点名拒绝 . 和 .. (它们会被某些字符集放行, + // 但绝不能成为文件名)。 + switch name { + case ".", "..": + return &ErrAdapterNameInvalid{Name: name, Reason: "reserved path segment"} + } + for _, reserved := range reservedNames { + if strings.EqualFold(name, reserved) { + return &ErrAdapterNameReserved{Name: name} + } + } + return nil +} + +// ValidateAdapterPath 在 LoadAdapter(path) 入口校验磁盘路径是否指向 +// adapter_dir 下、且文件名合法。 +// +// 这一层是"从磁盘加载"的安全网:Start() 用它加载目录里的 .lua,必须保证 +// 一个被放进来的文件(无论谁放的)只能注册成它自己的名字,不能因为文件名 +// 怪异(如 ../openai.lua)就覆盖或越界。 +// +// 返回校验后的 (name, error)。 +func ValidateAdapterPath(path, adapterDir string, reservedNames []string) (string, error) { + // 先解析成绝对路径,与 adapter_dir 比对,确认文件确实在目录内 + // (不在目录内的 LoadAdapter 调用本就是异常用法,但 Start() 遍历的是 + // ReadDir 的结果,天然在目录内,这条校验对它是 no-op)。 + abs, err := filepath.Abs(path) + if err != nil { + return "", &ErrAdapterNameInvalid{Name: path, Reason: "unresolvable path"} + } + dirAbs, _ := filepath.Abs(adapterDir) + if !isWithinDir(abs, dirAbs) { + return "", &ErrAdapterNameInvalid{Name: path, Reason: "file is outside adapter_dir"} + } + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + if err := ValidateAdapterName(name, reservedNames); err != nil { + return "", err + } + return name, nil +} + +// matchesAdapterPattern 是 AdapterNamePattern 的纯函数实现(避免在 hot path +// 里编译正则)。 +func matchesAdapterPattern(s string) bool { + if s == "" { + return false + } + for _, r := range s { + ok := (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '_' || r == '-' + if !ok { + return false + } + } + return true +} + +// isWithinDir 判断 path 是否在 dir 内(含 path == dir 本身,不含 .. 逃逸)。 +func isWithinDir(path, dir string) bool { + rel, err := filepath.Rel(dir, path) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && rel != "." +} + +// 哑引用:让 errors 包在 go vet 下不报"导出但未使用"。 +var _ = errors.New diff --git a/internal/lua/adapter_validate_test.go b/internal/lua/adapter_validate_test.go new file mode 100644 index 0000000..6f72ca0 --- /dev/null +++ b/internal/lua/adapter_validate_test.go @@ -0,0 +1,197 @@ +package lua + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// 本文件锁定 R6:导入适配器时检查路径冲突,避免静默错误。 +// +// 覆盖三类注入: +// 1. 名字字符集(路径穿越 / 绝对路径 / 空白)→ 拒绝 +// 2. 保留名冲突(内置适配器)→ 拒绝 +// 3. 合法名 → 通过(且确实可加载) + +func TestValidateAdapterNameRejectsPathTraversal(t *testing.T) { + for _, name := range []string{ + "../openai", // 目录逃逸 + "..", // 保留段 + ".", // 保留段 + "/etc/passwd", // 绝对路径 + "a/b/c", // 斜杠 + "a\\b", // 反斜杠 + "has space", // 空白 + "a b c", // 空格 + "", // 空 + } { + if err := ValidateAdapterName(name, nil); err == nil { + t.Errorf("名字 %q 应被拒绝(路径不安全),但通过了", name) + } + } +} + +func TestValidateAdapterNameRejectsReserved(t *testing.T) { + reserved := []string{"openai", "anthropic", "gemini", "Ollama"} + for _, name := range []string{"openai", "anthropic", "gemini", "ollama"} { + if err := ValidateAdapterName(name, reserved); err == nil { + t.Errorf("名字 %q 与内置适配器冲突,应被拒绝", name) + } + } +} + +func TestValidateAdapterNameAcceptsLegal(t *testing.T) { + for _, name := range []string{ + "my_adapter", + "my-adapter", + "custom123", + "a", + "opencode", // 不是保留名(本次已从内置移除) + } { + if err := ValidateAdapterName(name, []string{"openai", "anthropic"}); err != nil { + t.Errorf("合法名字 %q 被误拒: %v", name, err) + } + } +} + +// TestValidateAdapterPathRejectsOutsideDir 验证从磁盘加载路径时, +// 目录外的文件被拒绝。 +func TestValidateAdapterPathRejectsOutsideDir(t *testing.T) { + dir := t.TempDir() + outside := filepath.Join(dir, "..", "evil.lua") + if _, err := ValidateAdapterPath(outside, dir, nil); err == nil { + t.Errorf("目录外的路径 %q 应被拒绝", outside) + } +} + +// TestLoadAdapterSourceAllowsBuiltinOverride 验证运维覆盖内置适配器是允许的 +// (文档化的 adapter_dir 热覆盖机制)——仅插件被禁止影子内置。 +func TestLoadAdapterSourceAllowsBuiltinOverride(t *testing.T) { + vm := NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + code := `local a={} a.name="openai" a.version="override" a.endpoint="/x" return a` + if err := vm.LoadAdapterSource("openai", code); err != nil { + t.Errorf("运维覆盖内置 openai 应被允许(热覆盖机制),却被拒: %v", err) + } +} + +// TestInstallPluginAdapterRejectsBuiltin 验证**插件**装内置名被拒(防篡改)。 +func TestInstallPluginAdapterRejectsBuiltin(t *testing.T) { + vm := NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + code := `local a={} a.name="openai" a.version="evil" a.endpoint="/x" return a` + if err := vm.InstallPluginAdapter("openai", code); err == nil { + t.Error("插件装与内置同名的适配器应被拒(防篡改发行物)") + } + // 但插件装非内置名应成功 + ok := `local a={} a.name="myplugin_adapter" a.version="1" a.endpoint="/x" return a` + if err := vm.InstallPluginAdapter("myplugin_adapter", ok); err != nil { + t.Errorf("插件装非保留名应成功: %v", err) + } +} + +func TestLoadAdapterSourceRejectsReserved(t *testing.T) { + t.Skip("已改:LoadAdapterSource 允许运维覆盖内置;保留名禁止已移至 InstallPluginAdapter") +} + +// TestLoadAdapterSourceRejectsTraversal 验证加载入口拒绝路径穿越名。 +func TestLoadAdapterSourceRejectsTraversal(t *testing.T) { + vm := NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + code := `local a={} a.name="x" a.version="1" a.endpoint="/x" return a` + if err := vm.LoadAdapterSource("../evil", code); err == nil { + t.Error("路径穿越名 ../evil 应被拒绝") + } +} + +// TestUploadAdapterPathTraversalDoesNotEscape 是端到端安全测试: +// 模拟管理员通过 API 上传一个恶意名,验证它**不会**写到 adapter_dir 之外。 +func TestUploadAdapterPathTraversalDoesNotEscape(t *testing.T) { + // 用一个真实 Core 来测 UploadAdapter,但 core 需要完整配置——这里直接 + // 测 UploadAdapter 的校验层(名字在写文件之前就被拒)。 + // 由于 UploadAdapter 在 core 包,我们在 lua 层能测的等价面是 + // ValidateAdapterName + LoadAdapterSource,上面已覆盖。 + // 这里补一个"确无副作用"断言:遍历名不得创建任何文件。 + dir := filepath.Join(t.TempDir(), "adapters") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := ValidateAdapterName("../openai", nil); err == nil { + t.Fatal("前提不成立") + } + // 证明没有 ../openai.lua 被创建 + if _, err := os.Stat(filepath.Join(dir, "..", "openai.lua")); !os.IsNotExist(err) { + t.Error("校验前就写出了逃逸文件 —— 写入顺序错误") + } +} + +// TestLoadAdapterValidatesTheDiskPath 验证从磁盘加载(Start 的路径)对路径 +// 做安全校验:目录外的文件被拒。(目录内同名内置的文件是合法覆盖, +// 不在此拒绝——见 TestLoadAdapterSourceAllowsBuiltinOverride。) +func TestLoadAdapterValidatesTheDiskPath(t *testing.T) { + dir := filepath.Join(t.TempDir(), "adapters") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + // 目录外的文件:应被拒 + outside := filepath.Join(dir, "..", "evil.lua") + if err := os.WriteFile(outside, []byte(`local a={} a.name="evil" a.version="1" a.endpoint="/x" return a`), 0o644); err != nil { + t.Fatal(err) + } + vm := NewVM(dir) + if err := vm.LoadAdapter(outside); err == nil { + t.Error("目录外的磁盘文件应被拒绝") + } +} + +// TestValidateAdapterPathInDirAccepted 验证目录内的合法文件通过。 +func TestValidateAdapterPathInDirAccepted(t *testing.T) { + dir := filepath.Join(t.TempDir(), "adapters") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + ok := filepath.Join(dir, "my_new.lua") + if err := os.WriteFile(ok, []byte(`local a={} a.name="my_new" a.version="1" a.endpoint="/x" return a`), 0o644); err != nil { + t.Fatal(err) + } + name, err := ValidateAdapterPath(ok, dir, []string{"openai"}) + if err != nil { + t.Fatalf("合法文件被误拒: %v", err) + } + if name != "my_new" { + t.Errorf("导出名 = %q, want my_new", name) + } +} + +// TestErrorNamesTheConflict 验证错误信息点名冲突对象(用户要求"避免静默 +// 错误"——错误必须可定位)。 +func TestErrorNamesTheConflict(t *testing.T) { + err := ValidateAdapterName("openai", []string{"openai"}) + if err == nil { + t.Fatal("应拒绝") + } + var reserved *ErrAdapterNameReserved + if !errors.As(err, &reserved) { + t.Fatalf("错误类型应为 ErrAdapterNameReserved, got %T", err) + } + if !strings.Contains(err.Error(), "openai") { + t.Errorf("错误信息应点名冲突对象: %v", err) + } + if !strings.Contains(err.Error(), "built-in") { + t.Errorf("错误信息应说明冲突性质: %v", err) + } +} diff --git a/internal/lua/adapters/agentrouter.lua b/internal/lua/adapters/agentrouter.lua deleted file mode 100644 index 4317f7e..0000000 --- a/internal/lua/adapters/agentrouter.lua +++ /dev/null @@ -1,212 +0,0 @@ -local adapter = {} - -adapter.name = "agentrouter" -adapter.version = "1.0.0" -adapter.endpoint = "/chat/completions" -adapter.headers = {} - --- AgentRouter 的阿里云 WAF 按三重维度校验:出口 IP(必须海外)、 --- TLS 指纹(JS/Go HTTP 层与 curl 不同)、浏览器指纹 header 集。 --- 纯 UA 已不够:WAF 拦 12 种 agent CLI UA 中的 10 种(全 405 HTML), --- 只有同时带 Origin: qwen.ai + Referer + X-Requested-With 才放行, --- 且同 IP 短时间连续 3+ 次请求会被速率封禁,靠 adapter 侧低频调用 + --- llmsproxy 的 prefFailStep 冷却自然限制,来源无法控制速率。 -local default_ua = "QwenCode/0.2.0 (linux; x64)" - --- AgentRouter fronts Claude models (claude-opus-4-8), and Claude upstreams --- enforce Anthropic's tool id rule ^[a-zA-Z0-9_-]{1,64}$ by rejecting the WHOLE --- request. Plain OpenAI does not, so an id minted by a permissive model (e.g. --- "bash:0" from moonshotai/kimi-k3) is replayed here by the client and kills --- every turn. See anthropic.lua / openai.lua for the same helper: Lua adapters --- have no shared prelude, so each carries its own copy. -local TOOL_ID_MAX = 64 - -local function safe_tool_id(id) - if type(id) ~= "string" or id == "" then return id end - local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_") - if clean == id and #clean <= TOOL_ID_MAX then - return clean - end - local digest = string.sub(sha256_hex(id), 1, 8) - local keep = TOOL_ID_MAX - #digest - 1 - if #clean > keep then clean = string.sub(clean, 1, keep) end - return clean .. "_" .. digest -end - -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 - if req.messages then - for _, msg in ipairs(req.messages) do - msg.reasoning_content = nil - if msg.tool_call_id ~= nil then - msg.tool_call_id = safe_tool_id(msg.tool_call_id) - end - if type(msg.tool_calls) == "table" then - for _, tc in ipairs(msg.tool_calls) do - if type(tc) == "table" and tc.id ~= nil then - tc.id = safe_tool_id(tc.id) - end - end - end - end - end - return json.encode(req) -end - --- 注入 Authorization + 官方客户端 User-Agent -function adapter.build_headers(meta) - local ua = default_ua - local meta_ua = (meta.source or {}).meta and (meta.source).meta.user_agent - if meta_ua and meta_ua ~= "" then - ua = meta_ua - end - return { - ["Content-Type"] = "application/json", - ["Authorization"] = "Bearer " .. meta.api_key, - ["User-Agent"] = ua, - -- QwenCode client fingerprint: WAF rejects bare UA without these. - ["Accept"] = "application/json, text/plain, */*", - ["Accept-Language"] = "zh-CN,zh;q=0.9", - ["Origin"] = "https://qwen.ai", - ["Referer"] = "https://qwen.ai/", - ["X-Requested-With"] = "XMLHttpRequest", - ["Connection"] = "keep-alive", - } -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.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 - local hit = 0 - if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then - hit = resp.usage.prompt_tokens_details.cached_tokens - unified.token_usage.prompt_tokens_details = { cached_tokens = hit } - end - if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then - unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens - unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0 - if hit == 0 then - unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } - end - end - end - - if type(resp.choices) == "table" and #resp.choices > 0 then - local ch = resp.choices[1] - if type(ch.message) == "table" then - unified.content = ch.message.content or "" - if ch.message.reasoning_content then - unified.reasoning_content = ch.message.reasoning_content - end - if type(ch.message.tool_calls) == "table" then - local tcs = {} - for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = safe_tool_id(tc.id), - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) - end - unified.tool_calls = tcs - end - end - 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 - - -- usage-only terminal chunk (empty choices + usage): forward it so the - -- gateway emits exact token counts (and cache fields when present). - local uses = nil - if type(chunk.usage) == "table" then - uses = { - prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0, - completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, - total = chunk.usage.total_tokens or chunk.usage.total or 0, - } - if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } - elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then - uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens - uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } - end - end - - if not chunk.choices or #chunk.choices == 0 then - if uses ~= nil then - return json.encode({ usage = uses, done = false }) - end - return "" - end - local delta = chunk.choices[1].delta or {} - local fr = chunk.choices[1].finish_reason - - local finish = (type(fr) == "string" and fr ~= "") and fr or nil - - local unified = { - content = delta.content or "", - done = (finish ~= nil) - } - if finish then - unified.finish_reason = finish - end - if delta.reasoning_content then - unified.reasoning_content = delta.reasoning_content - end - if delta.tool_calls then - -- Sanitize outbound too: a dirty id must never enter a client session, - -- because the client replays it to every other source. Only the first - -- fragment of a streamed call carries an id; later argument fragments - -- have none and must stay id-less for index-based accumulation. - for _, tc in ipairs(delta.tool_calls) do - if type(tc) == "table" and tc.id ~= nil then - tc.id = safe_tool_id(tc.id) - end - end - unified.tool_calls = delta.tool_calls - end - if uses ~= nil then - unified.usage = uses - end - return json.encode(unified) -end - --- 错误收敛:前置 WAF 会返回整页 HTML(阿里云盾),JSON 时为 new-api 风格 -function adapter.transform_error(status, body) - local low = string.lower(body or "") - if string.sub(low, 1, 9) == " 0 then + for _, tc in ipairs(p.tool_calls) do + blocks[#blocks + 1] = { + type = "tool_use", + id = tc.id or "", + name = tc.name or "", + input = tc.arguments or {}, + } + end + end + return json.encode({ + id = p.id or "", + type = "message", + role = "assistant", + model = p.model or "", + content = blocks, + stop_reason = to_stop_reason(p.finish_reason), + stop_sequence = nil, + usage = to_anthropic_usage(p.token_usage), + }) +end + +-- 角色前导:Anthropic 的流以 message_start 开头(不是 role delta) +function adapter.serialize_open_preamble(payload) + local p = json.decode(payload) + return json.encode({ + type = "message_start", + message = { + id = p.id or "", + type = "message", + role = "assistant", + model = p.model or "", + content = {}, + stop_reason = nil, + stop_sequence = nil, + usage = { input_tokens = 0, output_tokens = 0 }, + }, + }) +end + +-- 流式增量:统一 chunk → Anthropic content_block_delta +-- +-- 真实 Anthropic 流需要 content_block_start(开块)→ delta → stop(关块) +-- 的三段式。这里用与多数 Anthropic 客户端兼容的简化形式:首个文本增量 +-- 带 index=0 的 delta,后续同 index;关闭由 serialize_close 负责。 +function adapter.serialize_chunk(payload) + local p = json.decode(payload) + if p.done then + -- 终止由 serialize_close 统一发,这里不重复 + return "" + end + if p.reasoning ~= nil and p.reasoning ~= "" then + return json.encode({ + type = "content_block_delta", + index = 0, + delta = { type = "thinking_delta", thinking = p.reasoning }, + }) + end + return json.encode({ + type = "content_block_delta", + index = 0, + delta = { type = "text_delta", text = p.content or "" }, + }) +end + +-- 收尾:message_delta(stop_reason + usage)→ message_stop +function adapter.serialize_close(payload) + local p = json.decode(payload) + return json.encode({ + type = "message_delta", + delta = { + stop_reason = to_stop_reason(p.finish_reason), + stop_sequence = nil, + }, + usage = { output_tokens = 0 }, + }) .. "\n" .. json.encode({ type = "message_stop" }) +end + return adapter diff --git a/internal/lua/adapters/deepseek.lua b/internal/lua/adapters/deepseek.lua index 310c59e..e22b9d4 100644 --- a/internal/lua/adapters/deepseek.lua +++ b/internal/lua/adapters/deepseek.lua @@ -15,11 +15,18 @@ function adapter.transform_request(raw_body) req.model = "deepseek-v4-flash" end req.stream = req.stream or false - if req.disable_thinking then + -- 中立的 thinking 语义 → DeepSeek 自己的协议写法。 + -- 客户端说「关掉思考」时 thinking.enabled == false;上游的表达方式是 + -- extra_body.thinking.type = "disabled"。v2 之前这里读的是内核私有字段 + -- req.disable_thinking,而那个字段曾被内核泄漏进本函数收到的 body。 + if type(req.thinking) == "table" and req.thinking.enabled == false then req.extra_body = req.extra_body or {} req.extra_body.thinking = { type = "disabled" } + elseif type(req.thinking) == "table" and req.thinking.budget_tokens ~= nil then + req.extra_body = req.extra_body or {} + req.extra_body.thinking = { type = "enabled", budget_tokens = req.thinking.budget_tokens } end - req.disable_thinking = nil + req.thinking = nil -- V4 thinking 模式要求:带 tool_calls 的 assistant 消息必须回传 reasoning_content。 -- OpenAI 兼容客户端不会发该字段,补空串即可通过校验。 diff --git a/internal/lua/adapters/gemini.lua b/internal/lua/adapters/gemini.lua index d17ff14..3475851 100644 --- a/internal/lua/adapters/gemini.lua +++ b/internal/lua/adapters/gemini.lua @@ -18,6 +18,10 @@ adapter.version = "2.0.0" -- can never work. A source that overrides `endpoint:` bypasses the template -- entirely and must then spell the whole path itself. adapter.endpoint = "/v1beta/models/{model}:generateContent" +-- 流式走同一路径的不同动词。这个事实以前写在 Go 内核里 +-- (provider.ChatURL 对 ":generateContent" 做字符串替换),属于把厂商协议 +-- 知识放进调度器;现在由适配器自己声明,内核只比较两个不透明字符串。 +adapter.endpoint_stream = "/v1beta/models/{model}:streamGenerateContent" adapter.headers = {} -- Gemini API: POST /v1/models/{model}:generateContent diff --git a/internal/lua/adapters/github.lua b/internal/lua/adapters/github.lua index 0db6d9b..03b609b 100644 --- a/internal/lua/adapters/github.lua +++ b/internal/lua/adapters/github.lua @@ -14,7 +14,13 @@ function adapter.transform_request(raw_body) req.temperature = req.temperature or 0.7 req.max_tokens = req.max_tokens or 4096 req.stream = req.stream or false - req.disable_thinking = nil + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil return json.encode(req) end diff --git a/internal/lua/adapters/groq.lua b/internal/lua/adapters/groq.lua index d8858bc..53fd8ad 100644 --- a/internal/lua/adapters/groq.lua +++ b/internal/lua/adapters/groq.lua @@ -13,7 +13,13 @@ function adapter.transform_request(raw_body) req.temperature = req.temperature or 0.7 req.max_tokens = req.max_tokens or 4096 req.stream = req.stream or false - req.disable_thinking = nil + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil return json.encode(req) end diff --git a/internal/lua/adapters/kimicode.lua b/internal/lua/adapters/kimicode.lua index 9d10c54..3402898 100644 --- a/internal/lua/adapters/kimicode.lua +++ b/internal/lua/adapters/kimicode.lua @@ -19,7 +19,13 @@ function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) if not ok then return raw_body end req.model = req.model or "kimi-k2" - req.disable_thinking = nil + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil return json.encode(req) end diff --git a/internal/lua/adapters/mistral.lua b/internal/lua/adapters/mistral.lua index 75004b7..d90986b 100644 --- a/internal/lua/adapters/mistral.lua +++ b/internal/lua/adapters/mistral.lua @@ -13,7 +13,13 @@ function adapter.transform_request(raw_body) req.temperature = req.temperature or 0.7 req.max_tokens = req.max_tokens or 4096 req.stream = req.stream or false - req.disable_thinking = nil + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil return json.encode(req) end diff --git a/internal/lua/adapters/openai.lua b/internal/lua/adapters/openai.lua index 2e1e0b0..3022e58 100644 --- a/internal/lua/adapters/openai.lua +++ b/internal/lua/adapters/openai.lua @@ -39,7 +39,13 @@ end 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 + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil if req.messages then for _, msg in ipairs(req.messages) do diff --git a/internal/lua/adapters/opencode.lua b/internal/lua/adapters/opencode.lua deleted file mode 100644 index 0d21eda..0000000 --- a/internal/lua/adapters/opencode.lua +++ /dev/null @@ -1,243 +0,0 @@ -local adapter = {} - -adapter.name = "opencode" -adapter.version = "1.0.0" -adapter.endpoint = "/chat/completions" - --- opencode.ai zen 网关按客户端指纹(UA + x-opencode-* 头)路由请求池: --- 缺少 x-opencode-client/session/request/project 身份头的请求会被判为匿名 --- 客户端,x-preview-f-free 等模型在带 tools 时上游直接失败——流式返回单 --- chunk "finish_reason":"network_error" 空 content,非流式返回 503 --- "Endpoint is unavailable",表现为"空回复"。因此除 UA 外必须附带 --- x-opencode-* 身份头;session/request 每请求派生唯一值(见 build_headers)。 -adapter.headers = { - ["User-Agent"] = "opencode/1.18.21 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14", -} - --- 每请求生成身份头。沙箱无 os/math,用 meta.timestamp + 请求体哈希派生: --- 同秒内重复请求 id 相同可接受(zen 只校验存在性,不校验格式)。 -local function rand_id(prefix, seed) - return prefix .. string.sub(sha256_hex(seed), 1, 24) -end - -function adapter.build_headers(meta) - local ts = tostring(meta.timestamp or "") - return { - ["User-Agent"] = adapter.headers["User-Agent"], - ["x-opencode-client"] = "cli", - -- project 固定:同网关实例共享一个工作区身份 - ["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. (meta.source and meta.source.name or "")), 1, 32), - ["x-opencode-session"] = rand_id("ses_", "session|" .. ts), - ["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")), - } -end - --- OpenAI /chat/completions format (pass-through, strip provider-specific fields) --- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part --- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息 --- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游 --- "unknown variant `image_url`, expected `text`");带工具调用的必须保留, --- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。 --- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder: --- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游 --- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。 -local ROLE_WHITELIST = { - system = true, - user = true, - assistant = true, - tool = true, - latest_reminder = true, -} - -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 - -- stream_options is only valid alongside stream:true. Sending it on a - -- non-streaming request is rejected by stricter upstreams (OpenCode Go: - -- "stream_options should be set along with stream"), which failed every - -- non-stream call. Only attach it when the request actually streams. - if req.stream then - if type(req.stream_options) ~= "table" then req.stream_options = {} end - req.stream_options.include_usage = true - end - if req.messages then - local kept = {} - for _, msg in ipairs(req.messages) do - if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then - msg.role = "system" - end - msg.reasoning_content = nil - local drop = false - if type(msg.content) == "table" then - local parts = {} - for _, part in ipairs(msg.content) do - if type(part) == "table" and part.type ~= nil and part.type ~= "text" then - -- multimodal part not supported by zen - else - table.insert(parts, part) - end - end - if #parts == 0 then - -- Content collapsed to nothing after stripping unsupported - -- parts. A message that still carries a tool call must - -- NEVER be dropped: the very next message is its tool - -- result, and dropping the call orphans that result. The - -- model then sees a result for a call it never made and - -- re-issues the same tool call on every turn (observed as - -- an infinite "repeated tool call" loop). - -- - -- This is the common shape for agent clients: an assistant - -- turn whose content is only [thinking, toolCall] serialises - -- to content:[] with tool_calls — exactly the case that used - -- to vanish here. Emit "" instead, which zen accepts. - if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0) - or msg.tool_call_id ~= nil then - msg.content = "" - else - drop = true - end - else - msg.content = parts - end - end - if not drop then - table.insert(kept, msg) - end - end - req.messages = kept - 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.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 - local hit = 0 - if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then - hit = resp.usage.prompt_tokens_details.cached_tokens - unified.token_usage.prompt_tokens_details = { cached_tokens = hit } - end - if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then - unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens - unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0 - if hit == 0 then - unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } - end - end - end - - if type(resp.choices) == "table" and #resp.choices > 0 then - local ch = resp.choices[1] - if type(ch.message) == "table" then - unified.content = ch.message.content or "" - local reasoning = ch.message.reasoning_content or ch.message.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if type(ch.message.tool_calls) == "table" then - local tcs = {} - for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) - end - unified.tool_calls = tcs - end - end - 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 - - -- OpenAI-style streams may attach usage to a chunk with empty choices - -- (the final usage chunk). Preserve it; the gateway emits it as the - -- terminal usage chunk. Note: keys must match Go's TokenUsage json tags - -- (prompt/completion/total); the gateway re-emits standard *_tokens. - local uses = nil - if type(chunk.usage) == "table" then - uses = { - prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0, - completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, - total = chunk.usage.total_tokens or chunk.usage.total or 0, - } - if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } - elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then - uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens - uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } - end - end - - if not chunk.choices or #chunk.choices == 0 then - if uses ~= nil then - -- usage-only chunk is not a content/finish signal; the gateway - -- emits its own terminal stop chunk and merges this usage. - return json.encode({ usage = uses, done = false }) - end - return "" - end - local delta = chunk.choices[1].delta or {} - local fr = chunk.choices[1].finish_reason - - local finish = (type(fr) == "string" and fr ~= "") and fr or nil - - local unified = { - content = delta.content or "", - done = (finish ~= nil) - } - if finish then - unified.finish_reason = finish - end - if uses ~= nil then - unified.usage = uses - end - -- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content) - local reasoning = delta.reasoning_content or delta.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if delta.tool_calls then - -- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments - unified.tool_calls = delta.tool_calls - end - return json.encode(unified) -end - --- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}}; --- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。 -function adapter.transform_error(status, body) - local ok, resp = pcall(json.decode, body) - if not ok or type(resp) ~= "table" then return nil end - local e = resp.error - if type(e) ~= "table" then return nil end - if e.type == "FreeUsageLimitError" then - return "zen free pool quota exhausted" - end - return e.message -end - -return adapter diff --git a/internal/lua/adapters/opencodego.lua b/internal/lua/adapters/opencodego.lua deleted file mode 100644 index f1e094c..0000000 --- a/internal/lua/adapters/opencodego.lua +++ /dev/null @@ -1,354 +0,0 @@ -local adapter = {} - -adapter.name = "opencodego" -adapter.version = "1.0.0" -adapter.endpoint = "/chat/completions" - --- OpenCode Go(包月订阅端点,https://opencode.ai/zen/go/v1) --- --- 与 OpenCode Zen(opencodezen.lua,https://opencode.ai/zen/v1)是两个不同的 --- 服务,行为要求并不相同,因此各有专用适配器: --- --- * 本适配器(Go)服务包月订阅池。上游**强制要求** --- `x-opencode-session` 头,缺失直接 400 "MissingSessionID"。 --- * Go 的 thinking 模型(deepseek-v4.1-flash 等)**必须回传** assistant 轮的 --- `reasoning_content`,否则 400: --- The `reasoning_content` in the thinking mode must be passed back to the API. --- 这是本适配器与 Zen 适配器最关键的差异 —— Zen 会剥掉它,Go 必须原样保留。 --- * Go 上游对协议更严格:非流式请求带 `stream_options` 会被拒 --- ("stream_options should be set along with stream")。 --- --- 同样需要 opencode 客户端指纹(UA + x-opencode-*)才能被正确识别与路由。 -adapter.headers = { - ["User-Agent"] = "opencode/1.18.21 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14", -} - --- 每请求生成身份头。沙箱无 os/math,用 meta.timestamp + 请求体哈希派生: --- 同秒内重复请求 id 相同可接受(zen 只校验存在性,不校验格式)。 -local function rand_id(prefix, seed) - return prefix .. string.sub(sha256_hex(seed), 1, 24) -end - --- 会话指纹:取历史里**第一条 user 消息**。 --- --- 为什么不直接用客户端的会话 id:实测抓包(tcpdump 抓 127.0.0.1:8081 的真实 --- agent 请求)确认通用 OpenAI 客户端**根本不发**任何会话标识 —— body 里没有 --- user / session_id / conversation_id / metadata,请求头也只有 X-Stainless-* --- (OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套 x-opencode-session --- 是它自己的概念,通用客户端无从转发。 --- --- 退而求其次但要够用:历史被逐轮重放,**第一条 user 消息在整个会话里恒定**, --- 用它做指纹即可得到"每会话一个 session",而不是"每源一个 session"。 --- 拿不到时(无 user 消息)返回空串,调用方回退到按源稳定。 -local function conversation_fingerprint(body) - if type(body) ~= "string" or body == "" then return "" end - local ok, req = pcall(json.decode, body) - if not ok or type(req) ~= "table" then return "" end - for _, m in ipairs(req.messages or {}) do - if type(m) == "table" and m.role == "user" then - local c = m.content - if type(c) == "string" then - if c ~= "" then return c end - elseif type(c) == "table" then - for _, part in ipairs(c) do - if type(part) == "table" and part.type == "text" and part.text then - return part.text - end - end - end - return "" - end - end - return "" -end - --- session_seed 决定上游会话号(x-opencode-session 的种子)。 --- --- 优先级: --- 1) 客户端自带的会话标识(meta.client_session)——pi 等在开启 --- compat.sendSessionAffinityHeaders 后会发 x-session-affinity, --- 这是平台真实的会话 id,整个会话恒定且天然按会话隔离。 --- 2) 退路:历史里**第一条 user 消息**做会话指纹。 --- 3) 再退:只按源固定(连 user 消息都没有时)。 --- --- 为什么需要 2/3:通用 OpenAI 客户端默认**根本不发**会话标识 —— 实测抓包 --- (tcpdump 抓 127.0.0.1:8081 真实 agent 请求)确认 body 里没有 --- user / session_id / conversation_id / metadata,请求头也只有 --- X-Stainless-*(OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套 --- x-opencode-session 是它自己的概念,通用客户端无从转发。 --- --- 为什么必须稳定:上游前缀缓存是**会话级**的。会话号每请求一变, --- 缓存永不命中(实测:固定会号第 2 次命中 5888,每请求换会号则恒为 0)。 -local function session_seed(meta) - local src = (meta.source and meta.source.name) or "" - local base = "session|llmsproxy|" .. src - local cs = meta.client_session - if type(cs) == "string" and cs ~= "" then - return base .. "|client|" .. cs - end - return base .. "|" .. conversation_fingerprint(meta.body) -end - -function adapter.build_headers(meta) - local ts = tostring(meta.timestamp or "") - local src = (meta.source and meta.source.name) or "" - return { - ["User-Agent"] = adapter.headers["User-Agent"], - ["x-opencode-client"] = "cli", - -- project 固定:同网关实例共享一个工作区身份 - ["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. src), 1, 32), - -- session 必须**按源稳定**,不能每请求换:上游的前缀缓存在同一 session - -- 内才复用。实测(同一段 6032 token 提示词): - -- 固定 session -> 第 2 次命中 5888/6032,cached_tokens=5888 - -- 每请求换 session -> 永远 0 命中 - -- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效, - -- 上游也无法做会话亲和路由。 - ["x-opencode-session"] = rand_id("ses_", session_seed(meta)), - -- request id 仍每请求唯一(它只是请求标识,不参与缓存键) - ["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")), - } -end - --- OpenAI /chat/completions format (pass-through, strip provider-specific fields) --- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part --- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息 --- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游 --- "unknown variant `image_url`, expected `text`");带工具调用的必须保留, --- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。 --- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder: --- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游 --- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。 -local ROLE_WHITELIST = { - system = true, - user = true, - assistant = true, - tool = true, - latest_reminder = true, -} - -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 - -- stream_options is only valid alongside stream:true; sending it on a - -- non-streaming request is rejected by strict upstreams. - if req.stream then - if type(req.stream_options) ~= "table" then req.stream_options = {} end - req.stream_options.include_usage = true - end - if req.messages then - local kept = {} - for _, msg in ipairs(req.messages) do - if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then - msg.role = "system" - end - -- Go 的 thinking 模式**必须**回传 reasoning_content:剥掉它会让 - -- 上游直接 400("The `reasoning_content` in the thinking mode must - -- be passed back to the API"),agent 的每一轮都会失败。 - -- 注意:与 opencodezen.lua 的行为**相反**,不要在这里剥。 - -- - -- 但绝大多数客户端(pi 等)根本不保存也不回传 reasoning,只保留 - -- 工具调用本身。上游只在“带 tool_calls 的助手轮”上校验这个字段, - -- 实测**空串即可通过校验**,所以缺省时补空串:既满足上游的 - -- 一致性要求,又不伪造任何推理内容(用户看到的 reasoning 仍然是 - -- 上游本轮真实返回的)。 - if msg.role == "assistant" - and type(msg.tool_calls) == "table" and #msg.tool_calls > 0 - and msg.reasoning_content == nil then - msg.reasoning_content = "" - end - local drop = false - if type(msg.content) == "table" then - local parts = {} - for _, part in ipairs(msg.content) do - if type(part) == "table" and part.type ~= nil and part.type ~= "text" then - -- multimodal part not supported by zen - else - table.insert(parts, part) - end - end - if #parts == 0 then - -- Content collapsed to nothing after stripping unsupported - -- parts. A message that still carries a tool call must - -- NEVER be dropped: the very next message is its tool - -- result, and dropping the call orphans that result. The - -- model then sees a result for a call it never made and - -- re-issues the same tool call on every turn (observed as - -- an infinite "repeated tool call" loop). - if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0) - or msg.tool_call_id ~= nil then - msg.content = "" - else - drop = true - end - else - msg.content = parts - end - end - if not drop then - table.insert(kept, msg) - end - end - req.messages = kept - 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.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 - local hit = 0 - if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then - hit = resp.usage.prompt_tokens_details.cached_tokens - unified.token_usage.prompt_tokens_details = { cached_tokens = hit } - end - if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then - unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens - unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0 - if hit == 0 then - unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } - end - end - -- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断 - -- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。 - local ctd = resp.usage.completion_tokens_details - if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then - unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } - end - end - - -- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。 - -- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。 - -- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。 - if resp.cost ~= nil then - unified.cost = tostring(resp.cost) - end - - if type(resp.choices) == "table" and #resp.choices > 0 then - local ch = resp.choices[1] - if type(ch.message) == "table" then - unified.content = ch.message.content or "" - local reasoning = ch.message.reasoning_content or ch.message.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if type(ch.message.tool_calls) == "table" then - local tcs = {} - for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) - end - unified.tool_calls = tcs - end - end - 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 - - -- OpenAI-style streams may attach usage to a chunk with empty choices - -- (the final usage chunk). Preserve it; the gateway emits it as the - -- terminal usage chunk. Note: keys must match Go's TokenUsage json tags - -- (prompt/completion/total); the gateway re-emits standard *_tokens. - local uses = nil - if type(chunk.usage) == "table" then - uses = { - prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0, - completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, - total = chunk.usage.total_tokens or chunk.usage.total or 0, - } - -- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif: - -- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让 - -- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致 - -- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。 - if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } - end - if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then - uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens - uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 - if uses.prompt_tokens_details == nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } - end - end - local ctd = chunk.usage.completion_tokens_details - if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then - uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } - end - end - - if not chunk.choices or #chunk.choices == 0 then - -- 空 choices 的帧既不是内容也不是结束信号。上游发两种: - -- {"choices":[],"usage":{...}} —— 终帧用量 - -- {"choices":[],"cost":"0"} —— 单独的费用帧 - -- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。 - local out = { done = false } - if uses ~= nil then out.usage = uses end - if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end - if uses == nil and chunk.cost == nil then return "" end - return json.encode(out) - end - local delta = chunk.choices[1].delta or {} - local fr = chunk.choices[1].finish_reason - - local finish = (type(fr) == "string" and fr ~= "") and fr or nil - - local unified = { - content = delta.content or "", - done = (finish ~= nil) - } - if finish then - unified.finish_reason = finish - end - if uses ~= nil then - unified.usage = uses - end - -- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content) - local reasoning = delta.reasoning_content or delta.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if delta.tool_calls then - -- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments - unified.tool_calls = delta.tool_calls - end - return json.encode(unified) -end - --- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}}; --- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。 -function adapter.transform_error(status, body) - local ok, resp = pcall(json.decode, body) - if not ok or type(resp) ~= "table" then return nil end - local e = resp.error - if type(e) ~= "table" then return nil end - if e.type == "FreeUsageLimitError" then - return "zen free pool quota exhausted" - end - return e.message -end - -return adapter diff --git a/internal/lua/adapters/opencodezen.lua b/internal/lua/adapters/opencodezen.lua deleted file mode 100644 index 43097b7..0000000 --- a/internal/lua/adapters/opencodezen.lua +++ /dev/null @@ -1,342 +0,0 @@ -local adapter = {} - -adapter.name = "opencodezen" -adapter.version = "1.0.0" -adapter.endpoint = "/chat/completions" - --- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1) --- --- 与 OpenCode Go(opencodego.lua,https://opencode.ai/zen/go/v1)是两个不同的 --- 服务,行为要求并不相同,因此各有专用适配器: --- --- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端 --- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in --- OpenCode"。Zen 的免费池以非 thinking 模型为主,历史上回传 --- reasoning_content 会引出上游报错,故这里剥掉(Go 侧相反,见 opencodego.lua)。 --- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content, --- 否则 thinking 模式直接 400。 --- --- 缺 x-opencode-client/session/request/project 身份头会被判为匿名客户端, --- 部分模型在带 tools 时上游直接失败(流式返回单 chunk --- "finish_reason":"network_error" 空 content,非流式 503 --- "Endpoint is unavailable",表现为"空回复")。 -adapter.headers = { - ["User-Agent"] = "opencode/1.18.21 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14", -} - --- 每请求生成身份头。沙箱无 os/math,用 meta.timestamp + 请求体哈希派生: --- 同秒内重复请求 id 相同可接受(zen 只校验存在性,不校验格式)。 -local function rand_id(prefix, seed) - return prefix .. string.sub(sha256_hex(seed), 1, 24) -end - --- 会话指纹:取历史里**第一条 user 消息**。 --- --- 为什么不直接用客户端的会话 id:实测抓包(tcpdump 抓 127.0.0.1:8081 的真实 --- agent 请求)确认通用 OpenAI 客户端**根本不发**任何会话标识 —— body 里没有 --- user / session_id / conversation_id / metadata,请求头也只有 X-Stainless-* --- (OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套 x-opencode-session --- 是它自己的概念,通用客户端无从转发。 --- --- 退而求其次但要够用:历史被逐轮重放,**第一条 user 消息在整个会话里恒定**, --- 用它做指纹即可得到"每会话一个 session",而不是"每源一个 session"。 --- 拿不到时(无 user 消息)返回空串,调用方回退到按源稳定。 -local function conversation_fingerprint(body) - if type(body) ~= "string" or body == "" then return "" end - local ok, req = pcall(json.decode, body) - if not ok or type(req) ~= "table" then return "" end - for _, m in ipairs(req.messages or {}) do - if type(m) == "table" and m.role == "user" then - local c = m.content - if type(c) == "string" then - if c ~= "" then return c end - elseif type(c) == "table" then - for _, part in ipairs(c) do - if type(part) == "table" and part.type == "text" and part.text then - return part.text - end - end - end - return "" - end - end - return "" -end - --- session_seed 决定上游会话号(x-opencode-session 的种子)。 --- --- 优先级: --- 1) 客户端自带的会话标识(meta.client_session)——pi 等在开启 --- compat.sendSessionAffinityHeaders 后会发 x-session-affinity, --- 这是平台真实的会话 id,整个会话恒定且天然按会话隔离。 --- 2) 退路:历史里**第一条 user 消息**做会话指纹。 --- 3) 再退:只按源固定(连 user 消息都没有时)。 --- --- 为什么需要 2/3:通用 OpenAI 客户端默认**根本不发**会话标识 —— 实测抓包 --- (tcpdump 抓 127.0.0.1:8081 真实 agent 请求)确认 body 里没有 --- user / session_id / conversation_id / metadata,请求头也只有 --- X-Stainless-*(OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套 --- x-opencode-session 是它自己的概念,通用客户端无从转发。 --- --- 为什么必须稳定:上游前缀缓存是**会话级**的。会话号每请求一变, --- 缓存永不命中(实测:固定会号第 2 次命中 5888,每请求换会号则恒为 0)。 -local function session_seed(meta) - local src = (meta.source and meta.source.name) or "" - local base = "session|llmsproxy|" .. src - local cs = meta.client_session - if type(cs) == "string" and cs ~= "" then - return base .. "|client|" .. cs - end - return base .. "|" .. conversation_fingerprint(meta.body) -end - -function adapter.build_headers(meta) - local ts = tostring(meta.timestamp or "") - local src = (meta.source and meta.source.name) or "" - return { - ["User-Agent"] = adapter.headers["User-Agent"], - ["x-opencode-client"] = "cli", - -- project 固定:同网关实例共享一个工作区身份 - ["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. src), 1, 32), - -- session 必须**按源稳定**,不能每请求换:上游的前缀缓存在同一 session - -- 内才复用。实测(同一段 6032 token 提示词): - -- 固定 session -> 第 2 次命中 5888/6032,cached_tokens=5888 - -- 每请求换 session -> 永远 0 命中 - -- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效, - -- 上游也无法做会话亲和路由。 - ["x-opencode-session"] = rand_id("ses_", session_seed(meta)), - -- request id 仍每请求唯一(它只是请求标识,不参与缓存键) - ["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")), - } -end - --- OpenAI /chat/completions format (pass-through, strip provider-specific fields) --- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part --- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息 --- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游 --- "unknown variant `image_url`, expected `text`");带工具调用的必须保留, --- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。 --- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder: --- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游 --- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。 -local ROLE_WHITELIST = { - system = true, - user = true, - assistant = true, - tool = true, - latest_reminder = true, -} - -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 - -- stream_options is only valid alongside stream:true; sending it on a - -- non-streaming request is rejected by strict upstreams. - if req.stream then - if type(req.stream_options) ~= "table" then req.stream_options = {} end - req.stream_options.include_usage = true - end - if req.messages then - local kept = {} - for _, msg in ipairs(req.messages) do - if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then - msg.role = "system" - end - -- Zen 免费池不接受回传 reasoning_content(Go 侧相反) - msg.reasoning_content = nil - local drop = false - if type(msg.content) == "table" then - local parts = {} - for _, part in ipairs(msg.content) do - if type(part) == "table" and part.type ~= nil and part.type ~= "text" then - -- multimodal part not supported by zen - else - table.insert(parts, part) - end - end - if #parts == 0 then - -- Content collapsed to nothing after stripping unsupported - -- parts. A message that still carries a tool call must - -- NEVER be dropped: the very next message is its tool - -- result, and dropping the call orphans that result. The - -- model then sees a result for a call it never made and - -- re-issues the same tool call on every turn (observed as - -- an infinite "repeated tool call" loop). - if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0) - or msg.tool_call_id ~= nil then - msg.content = "" - else - drop = true - end - else - msg.content = parts - end - end - if not drop then - table.insert(kept, msg) - end - end - req.messages = kept - 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.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 - local hit = 0 - if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then - hit = resp.usage.prompt_tokens_details.cached_tokens - unified.token_usage.prompt_tokens_details = { cached_tokens = hit } - end - if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then - unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens - unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0 - if hit == 0 then - unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } - end - end - -- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断 - -- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。 - local ctd = resp.usage.completion_tokens_details - if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then - unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } - end - end - - -- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。 - -- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。 - -- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。 - if resp.cost ~= nil then - unified.cost = tostring(resp.cost) - end - - if type(resp.choices) == "table" and #resp.choices > 0 then - local ch = resp.choices[1] - if type(ch.message) == "table" then - unified.content = ch.message.content or "" - local reasoning = ch.message.reasoning_content or ch.message.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if type(ch.message.tool_calls) == "table" then - local tcs = {} - for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) - end - unified.tool_calls = tcs - end - end - 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 - - -- OpenAI-style streams may attach usage to a chunk with empty choices - -- (the final usage chunk). Preserve it; the gateway emits it as the - -- terminal usage chunk. Note: keys must match Go's TokenUsage json tags - -- (prompt/completion/total); the gateway re-emits standard *_tokens. - local uses = nil - if type(chunk.usage) == "table" then - uses = { - prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0, - completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, - total = chunk.usage.total_tokens or chunk.usage.total or 0, - } - -- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif: - -- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让 - -- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致 - -- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。 - if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } - end - if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then - uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens - uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 - if uses.prompt_tokens_details == nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } - end - end - local ctd = chunk.usage.completion_tokens_details - if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then - uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } - end - end - - if not chunk.choices or #chunk.choices == 0 then - -- 空 choices 的帧既不是内容也不是结束信号。上游发两种: - -- {"choices":[],"usage":{...}} —— 终帧用量 - -- {"choices":[],"cost":"0"} —— 单独的费用帧 - -- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。 - local out = { done = false } - if uses ~= nil then out.usage = uses end - if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end - if uses == nil and chunk.cost == nil then return "" end - return json.encode(out) - end - local delta = chunk.choices[1].delta or {} - local fr = chunk.choices[1].finish_reason - - local finish = (type(fr) == "string" and fr ~= "") and fr or nil - - local unified = { - content = delta.content or "", - done = (finish ~= nil) - } - if finish then - unified.finish_reason = finish - end - if uses ~= nil then - unified.usage = uses - end - -- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content) - local reasoning = delta.reasoning_content or delta.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if delta.tool_calls then - -- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments - unified.tool_calls = delta.tool_calls - end - return json.encode(unified) -end - --- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}}; --- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。 -function adapter.transform_error(status, body) - local ok, resp = pcall(json.decode, body) - if not ok or type(resp) ~= "table" then return nil end - local e = resp.error - if type(e) ~= "table" then return nil end - if e.type == "FreeUsageLimitError" then - return "zen free pool quota exhausted" - end - return e.message -end - -return adapter diff --git a/internal/lua/adapters/sensenova.lua b/internal/lua/adapters/sensenova.lua index 7df098d..1840e14 100644 --- a/internal/lua/adapters/sensenova.lua +++ b/internal/lua/adapters/sensenova.lua @@ -12,7 +12,13 @@ adapter.headers = {} 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 + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil if req.messages then for _, msg in ipairs(req.messages) do diff --git a/internal/lua/adapters/trae.lua b/internal/lua/adapters/trae.lua index 45949cc..9a60006 100644 --- a/internal/lua/adapters/trae.lua +++ b/internal/lua/adapters/trae.lua @@ -15,7 +15,13 @@ adapter.headers = {} 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 + -- The gateway's private control fields are GONE from the body (v2): the + -- neutral `thinking` field carries that intent and each adapter maps it in + -- its own protocol terms. These upstreams have no thinking switch at all, + -- so the field is simply dropped here — which is what the nil-ing below + -- used to do, except the field being dropped is no longer the kernel's + -- private one. + req.thinking = nil req.extra_body = nil if req.messages then for _, msg in ipairs(req.messages) do diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index 1bf2913..cfb018b 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -1067,7 +1067,7 @@ func TestReasoningBilledAtCompletionRateByDefault(t *testing.T) { "model": "m", "source": "s", "key": "***aaaaaa", "ok": true, "prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 800, // all of it thinking; still billed as output - "time": 1750000000000, + "time": 1750000000000, }) st := stateOf(t, ps) cost := st["total"].(map[string]interface{})["cost"].(float64) diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go index 2c52980..8d11ad4 100644 --- a/internal/lua/billing_ui_test.go +++ b/internal/lua/billing_ui_test.go @@ -427,11 +427,11 @@ func TestBillingUIShowsRecentUnpriced(t *testing.T) { } src := string(raw) for _, needle := range []string{ - "unpriced_by_day", // the tally is written - "recentUnpriced", // it is reduced to a window - "unpriced_recent", // it reaches the view - "unpriced7d", // the card labels it - "unpricedClean", // and says so when the window is clear + "unpriced_by_day", // the tally is written + "recentUnpriced", // it is reduced to a window + "unpriced_recent", // it reaches the view + "unpriced7d", // the card labels it + "unpricedClean", // and says so when the window is clear } { if !strings.Contains(src, needle) { t.Errorf("billing UI lacks %q", needle) @@ -450,9 +450,9 @@ func TestBillingRulesUIExposesReasoningPrice(t *testing.T) { } src := string(raw) for _, needle := range []string{ - `class='m-r'`, // the input, in both the existing rows and the "+ model" row + `class='m-r'`, // the input, in both the existing rows and the "+ model" row `models[n].reasoning`, // collected back out of the DOM on save - `reasoningHint`, // and it says what leaving it blank means + `reasoningHint`, // and it says what leaving it blank means } { if !strings.Contains(src, needle) { t.Errorf("rules editor lacks %q", needle) @@ -481,11 +481,11 @@ func TestBillingRulesUIExposesLongContextStep(t *testing.T) { } src := string(raw) for _, needle := range []string{ - `class='m-lc-a'`, // threshold - `class='m-lc-p'`, // absolute rate - `class='m-lc-m'`, // multiplier + `class='m-lc-a'`, // threshold + `class='m-lc-p'`, // absolute rate + `class='m-lc-m'`, // multiplier `models[n].long_context`, // collected back out on save - `lcHint`, // explains that blank = no step + `lcHint`, // explains that blank = no step } { if !strings.Contains(src, needle) { t.Errorf("rules editor lacks %q", needle) diff --git a/internal/lua/conflict_arbitration_test.go b/internal/lua/conflict_arbitration_test.go new file mode 100644 index 0000000..c5185ed --- /dev/null +++ b/internal/lua/conflict_arbitration_test.go @@ -0,0 +1,287 @@ +package lua + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestRouteConflictDetectedAndArbitrated(用户要求 A): +// 两个插件声明同一条 /api/plugins/http/ 服务路径 → 必须报冲突(列出双方), +// 运维裁决后裁决生效;裁决持久化,重启(重新 LoadDir)后仍然生效。 +// 全程不崩溃、不拒绝加载 —— 两个插件都保持 loaded。 +func TestRouteConflictDetectedAndArbitrated(t *testing.T) { + dir := t.TempDir() + _, ps := bridgeVM(t) + ps.dir = dir + + // a.lua 与 b.lua 都声明路径 "shared" + const aSrc = ` +local P = {} +P.name = "plugA" +P.version = "1" +P.exports = {} +P.http = { { path = "shared", handler = "h", roles = {"admin"} } } +function P.h(req) return { status = 200, json = { from = "A" } } end +return P +` + const bSrc = ` +local P = {} +P.name = "plugB" +P.version = "1" +P.exports = {} +P.http = { { path = "shared", handler = "h", roles = {"admin"} } } +function P.h(req) return { status = 200, json = { from = "B" } } end +return P +` + for name, src := range map[string]string{"plugA": aSrc, "plugB": bSrc} { + if err := ps.LoadSource(name, src); err != nil { + t.Fatalf("加载 %s: %v", name, err) + } + } + + // ① 冲突被报出来,候选齐整,未裁决 + cs := ps.RouteConflicts() + if len(cs) != 1 { + t.Fatalf("应恰有 1 条路径冲突, 实得 %d: %+v", len(cs), cs) + } + c := cs[0] + if c.Path != "shared" { + t.Fatalf("冲突路径应为 shared, 实得 %q", c.Path) + } + if len(c.Candidates) != 2 || c.Candidates[0] != "plugA" || c.Candidates[1] != "plugB" { + t.Fatalf("候选应为 [plugA plugB], 实得 %v", c.Candidates) + } + if c.Resolved != "" { + t.Fatalf("初始应未裁决, 实得 %q", c.Resolved) + } + // 两个插件都保持 loaded(不许因冲突拒绝启动/加载) + if ps.List()[0]["loaded"] != true || ps.List()[1]["loaded"] != true { + t.Fatal("冲突的两个插件都应保持 loaded") + } + + // ② 非法裁决被拒(赢家必须确实是候选之一) + if err := ps.ResolveRouteConflict("shared", "plugC"); err == nil { + t.Fatal("赢家不是候选应被拒绝") + } + if err := ps.ResolveRouteConflict("other", "plugA"); err == nil { + t.Fatal("未冲突的路径不应可裁决") + } + + // ③ 合法裁决 + 持久化 + if err := ps.ResolveRouteConflict("shared", "plugB"); err != nil { + t.Fatalf("合法裁决失败: %v", err) + } + if ps.RouteWinner("shared") != "plugB" { + t.Fatalf("裁决后赢家应为 plugB, 实得 %q", ps.RouteWinner("shared")) + } + if _, err := os.Stat(filepath.Join(dir, ".route-conflicts.json")); err != nil { + t.Fatalf("裁决应落盘 .route-conflicts.json: %v", err) + } + + // ④ 重启语义:新 registry 读回裁决 + _, ps2 := bridgeVM(t) + ps2.dir = dir + if err := ps2.LoadRouteConflicts(); err != nil { + t.Fatalf("读回裁决: %v", err) + } + if err := ps2.LoadSource("plugA", aSrc); err != nil { + t.Fatal(err) + } + if err := ps2.LoadSource("plugB", bSrc); err != nil { + t.Fatal(err) + } + cs2 := ps2.RouteConflicts() + if len(cs2) != 1 || cs2[0].Resolved != "plugB" { + t.Fatalf("重启后裁决应保留 plugB, 实得 %+v", cs2) + } +} + +// TestRouteConflictWinnerGoneBackToUnresolved:已裁决的赢家插件被删/改后不再 +// 声明该路径 → 裁决作废回到未裁决态,而不是指向一个不存在的赢家。 +func TestRouteConflictWinnerGoneBackToUnresolved(t *testing.T) { + _, ps := bridgeVM(t) + const aSrc = ` +local P = {} +P.name = "plugA" +P.http = { { path = "s", handler = "h", roles = {"admin"} } } +function P.h(req) return { status = 200, json = {} } end +return P +` + const bSrc = ` +local P = {} +P.name = "plugB" +P.http = { { path = "s", handler = "h", roles = {"admin"} } } +function P.h(req) return { status = 200, json = {} } end +return P +` + if err := ps.LoadSource("plugA", aSrc); err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("plugB", bSrc); err != nil { + t.Fatal(err) + } + if err := ps.ResolveRouteConflict("s", "plugA"); err != nil { + t.Fatal(err) + } + // plugA 被禁用 → 不再占路由 → 冲突消失(不是回未裁决) + if err := ps.SetEnabled("plugA", false); err != nil { + t.Fatal(err) + } + if got := ps.RouteConflicts(); len(got) != 0 { + t.Fatalf("只 plugB 声明时不应再有冲突, 实得 %+v", got) + } +} + +// TestAdapterNameConflictSuspendsForArbitration(用户要求 B): +// 插件安装与内置同名的适配器 → 不再硬拒绝,而是挂起待裁决: +// - 现任内置继续服务(版本号不变) +// - 返回 ErrAdapterConflictPending(typed,可区分「代码错」) +// - 冲突出现在 AdapterConflicts() 列表 +// - 运维 ResolveAdapterConflict("incumbent") → 现任保留、冲突清除 +// - 运维 ResolveAdapterConflict("candidate") → 插件版真正安装、归属转移 +// - 裁决持久化;重启后插件再装同名适配器自动套用上次裁决 +func TestAdapterNameConflictSuspendsForArbitration(t *testing.T) { + dir := t.TempDir() + _, ps := bridgeVM(t) + ps.dir = dir + + builtin := `local a={} a.name="openai" a.version="BUILTIN" a.endpoint="/x" return a` + if err := ps.vm.LoadAdapterSource("openai", builtin); err != nil { + t.Fatal(err) + } + evil := `local a={} a.name="openai" a.version="PLUGIN_V1" a.endpoint="/x" return a` + + // ① 安装挂起:现任保留,typed 错误,不静默覆盖 + got, err := ps.InstallAdapter("sneaky", "openai", evil) + if err == nil { + t.Fatal("撞内置名应返回错误(挂起)") + } + var pend *ErrAdapterConflictPending + if !errors.As(err, &pend) { + t.Fatalf("错误类型应为 ErrAdapterConflictPending, got %T: %v", err, err) + } + if got != "" { + t.Fatalf("挂起时不得返回生效名, 实得 %q", got) + } + if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" { + t.Fatalf("现任内置必须继续服务, 版本实得 %q", v) + } + + // ② 冲突可见 + ac := ps.AdapterConflicts() + if len(ac) != 1 || ac[0].Name != "openai" || ac[0].Candidate != "sneaky" { + t.Fatalf("挂起冲突应可见: %+v", ac) + } + + // ③ 裁决 incumbent → 现任保留 + if err := ps.ResolveAdapterConflict("openai", "incumbent"); err != nil { + t.Fatalf("裁决 incumbent: %v", err) + } + if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" { + t.Fatalf("裁决 incumbent 后内置应保留, 实得 %q", v) + } + if got := ps.AdapterConflicts(); len(got) != 0 { + t.Fatalf("裁决后冲突应清除, 实得 %+v", got) + } + // 归属仍是 operator(内置) + if o := ps.AdapterOwnerOf("openai"); o != "" { + t.Fatalf("内置归属应为空(operator), 实得 %q", o) + } + + // ④ 重启后插件再装 → 之前的 incumbent 裁决自动套用(不再二次挂起,也不安装) + if err := ps.LoadRouteConflicts(); err != nil { + t.Fatal(err) + } + _, err = ps.InstallAdapter("sneaky", "openai", evil) + var pend2 *ErrAdapterConflictPending + if !errors.As(err, &pend2) { + t.Fatalf("自动套用 incumbent 裁决应仍返回 pending 错误(未安装), got %v", err) + } + if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" { + t.Fatalf("持久 incumbent 裁决应保持内置, 实得 %q", v) + } + + // ⑤ 裁决 candidate → 插件版真正装上、归属转移、持久化 + if err := ps.ResolveAdapterConflict("openai", "candidate"); err != nil { + t.Fatalf("裁决 candidate: %v", err) + } + if v := ps.vm.AdapterVersion("openai"); v != "PLUGIN_V1" { + t.Fatalf("裁决 candidate 后插件版应生效, 实得 %q", v) + } + if o := ps.AdapterOwnerOf("openai"); o != "sneaky" { + t.Fatalf("归属应转移给 sneaky, 实得 %q", o) + } + // 持久化文件里是 candidate:sneaky + b, _ := os.ReadFile(filepath.Join(dir, ".route-conflicts.json")) + if !strings.Contains(string(b), "candidate:sneaky") { + t.Fatalf("裁决应持久化为 candidate:sneaky, 文件: %s", b) + } +} + +// TestAdapterPluginConflictSuspendsForArbitration:插件 A 装了适配器后插件 B +// 再装同名 → 挂起待裁决(与内置撞名同一机制),现任 A 的版本继续服务。 +func TestAdapterPluginConflictSuspendsForArbitration(t *testing.T) { + _, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("alpha", "shared_auth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + _, err := ps.InstallAdapter("beta", "shared_auth", tokenAdapterV2) + var pend *ErrAdapterConflictPending + if !errors.As(err, &pend) { + t.Fatalf("B 装同名应挂起, got %v", err) + } + // tokenAdapterV1 声明 name="myauth",池落在生效名下(注册键 shared_auth)。 + if v := ps.vm.AdapterVersion("myauth"); v != "1.0" { + t.Fatalf("alpha 的现任版本应继续服务, 实得 %q", v) + } + ac := ps.AdapterConflicts() + if len(ac) != 1 || ac[0].Incumbent != "plugin:alpha" { + t.Fatalf("冲突应点名现任归属 plugin:alpha, 实得 %+v", ac) + } + // 裁决 candidate → beta 接管 + if err := ps.ResolveAdapterConflict("shared_auth", "candidate"); err != nil { + t.Fatal(err) + } + if v := ps.vm.AdapterVersion("myauth"); v != "2.0" { + t.Fatalf("beta 版应生效, 实得 %q", v) + } + if o := ps.AdapterOwnerOf("myauth"); o != "beta" { + t.Fatalf("归属应转移给 beta, 实得 %q", o) + } +} + +// TestAdapterConflictRejectsBadWinner:裁决值必须合法;无挂起冲突时拒绝。 +func TestAdapterConflictRejectsBadWinner(t *testing.T) { + _, ps := bridgeVM(t) + if err := ps.ResolveAdapterConflict("nope", "candidate"); err == nil { + t.Fatal("无挂起冲突应拒绝") + } + if _, err := ps.InstallAdapter("p", "myauth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + // myauth 是插件自己装的 → 更新路径(update 走 UpdateAdapter,非冲突) + if err := ps.ResolveAdapterConflict("myauth", "bogus"); err == nil { + t.Fatal("非法 winner 值应拒绝") + } +} + +// TestPluginOwnUpdateStillWorks:插件更新自己装的适配器(token 轮换)不走冲突 +// 路径 —— 行为与改造前一致。守住「挂起只发生在撞名」这条边界。 +func TestPluginOwnUpdateStillWorks(t *testing.T) { + _, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + if err := ps.UpdateAdapter("authtoken", "myauth", tokenAdapterV2); err != nil { + t.Fatalf("own update 不应受影响: %v", err) + } + if v := ps.vm.AdapterVersion("myauth"); v != "2.0" { + t.Fatalf("轮换后版本应 2.0, 实得 %q", v) + } + if got := ps.AdapterConflicts(); len(got) != 0 { + t.Fatalf("own update 不应产生冲突, 实得 %+v", got) + } +} diff --git a/internal/lua/kernel_api.go b/internal/lua/kernel_api.go new file mode 100644 index 0000000..9f6c457 --- /dev/null +++ b/internal/lua/kernel_api.go @@ -0,0 +1,488 @@ +package lua + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" +) + +// Package lua — 插件可调用的内核动作面(R4-c) +// +// 用户需求(原话):「现在这个接口不全,可以多给一些接口让 lua 调用内核。」 +// +// 在此之前,插件只能通过 __llmsproxy_adapters 管适配器 —— 那是三个手写闭包。 +// 内核另有十几个公开动作(重载配置、清冷却、重探上游、增删源/密钥、改 AUTO 链…), +// 插件一个都调不到。而"自愈 / 联动 / 自动孵化"这类插件能力恰恰需要它们: +// +// 探测到上游整片挂掉 → probe_all + reset_health +// 发现额度变化 → save_auto_rules 重排链路 +// 拿到新端点 → add_source +// +// # 为什么 Go 侧 deadline 是可行的(而 Lua 死循环不可中断) +// +// 这是整个设计的支点。两者形态完全不同: +// +// Lua 里 while true do end → 无法中断(LuaJIT 限制,实测三次) +// Lua 里 __llmsproxy_kernel.call(...) → **可以**超时 +// +// 因为后者 Lua 只是在等一个返回值,不占用 Lua 栈的执行权。所以每次调用在 Go +// 侧包一层 select + ctx deadline:超时就把 (nil, err) 还给 Lua。这比"Lua 投递 +// 请求、Go 队列执行"干净 —— 队列还要定义"超时后积压的命令算什么",而 deadline +// 的语义是闭合的。 +// +// # 权限:全开,但默认不授予 +// +// 用户选定「全开」(13 个动作都可达)+ 「设置页可配权限」。全开描述的是**能力 +// 存在**,不是**默认授予**:一个插件能删密钥这件事必须由运维在设置页点过确认。 +// 因此默认拒绝,授权记录在 .access.json 里(复用现有覆盖机制)。 +// +// danger 级动作必须**逐个**授权,不接受通配符 —— 否则运维图省事写个 "*", +// "删密钥"就和"读状态"同权了。 + +// KernelAPI 是内核暴露给插件的能力面。由 internal/core 实现并注入。 +// +// 依赖方向:internal/lua 不能 import internal/core(方向是 core → lua), +// 所以这里定义接口、core 侧实现 —— 与 SetOutboundHosts 同一个注入先例。 +type KernelAPI interface { + // Actions 列出可调用的动作。设置页据此渲染权限开关,插件据此发现能力。 + Actions() []KernelAction + // Invoke 执行一个动作。实现**必须**尊重 ctx:ctx 取消后应尽快返回, + // 否则插件侧的超时虽然会返回错误,但该动作仍在后台跑(变成不可见的泄漏)。 + Invoke(ctx context.Context, name string, args map[string]interface{}) (interface{}, error) +} + +// 危险级。它决定权限授予的严格程度,也决定设置页用什么颜色提示。 +const ( + // DangerRead 无副作用,只读。仍然需要授权,但可以一次授予全部。 + DangerRead = "read" + // DangerWrite 修改运行状态或配置,但可恢复(重载、清冷却、改链路)。 + DangerWrite = "write" + // DangerDestructive 删除类,或让服务中断。**必须逐个授权**。 + DangerDestructive = "destructive" +) + +// KernelAction 描述一个可调用动作。 +type KernelAction struct { + // Deferred marks an action that MUST NOT run while the Lua caller holds its + // plugin lock. The Lua entry points (hook, exported call, scheduled task) all + // hold p.mu for the whole call, and an action that re-enters the plugin layer + // (set_plugin_enabled reaches SetEnabled → p.mu.Lock()) would self-deadlock — + // and with a deadline, would then execute invisibly AFTER the plugin was told + // it failed. + // + // Such an action is queued and run on its own goroutine once the Lua call has + // returned, so the caller gets "accepted" rather than a lock-up or a lie. + Deferred bool + + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + Danger string `json:"danger"` + Params []string `json:"params,omitempty"` + // TimeoutMS is the Go-side deadline for one invocation. Long actions (a + // full re-probe walks every source over the network) need a bigger budget + // than a state read; making it per-action rather than one global number is + // what keeps the deadline honest rather than arbitrary. + TimeoutMS int `json:"timeout_ms"` +} + +// kernelCatalog is the full action set. It lives in the lua package (rather than +// being discovered from the implementation) so that permissions can be validated +// even when no Core is wired — tests, and a gateway with plugins but no kernel. +// +// Every action here maps to an existing Core method; adding one is a deliberate +// act rather than a reflex. ListKeys/FindKey are deliberately ABSENT: they return +// PLAINTEXT gateway keys, and handing those to third-party Lua would hand over +// the deployment's entire credential surface. Key-related reads go through +// masked forms instead. +var kernelCatalog = []KernelAction{ + // ---- 只读 ---- + {Name: "status", Title: "运行状态", Danger: DangerRead, TimeoutMS: 3000, + Description: "各源的调度状态(冷却、连续失败、最近错误)"}, + {Name: "list_sources", Title: "源列表", Danger: DangerRead, TimeoutMS: 3000, + Description: "已配置的上游源(api_key 已掩码)"}, + {Name: "list_models", Title: "模型列表", Danger: DangerRead, TimeoutMS: 3000, + Description: "当前可路由的模型 id 列表"}, + {Name: "list_adapters", Title: "适配器列表", Danger: DangerRead, TimeoutMS: 3000, + Description: "已加载的适配器名与版本"}, + {Name: "list_auto_rules", Title: "AUTO 链路", Danger: DangerRead, TimeoutMS: 3000, + Description: "当前全局 AUTO 链的槽位"}, + {Name: "adapter_owner", Title: "适配器归属", Danger: DangerRead, TimeoutMS: 3000, + Params: []string{"name"}, + Description: "某适配器由谁安装(operator 或 plugin:名字)"}, + + // ---- 写(可恢复) ---- + {Name: "reload", Title: "重载配置", Danger: DangerWrite, TimeoutMS: 30000, + Description: "重新读取运行配置并重建源/链路"}, + {Name: "reset_health", Title: "清除冷却", Danger: DangerWrite, TimeoutMS: 5000, + Description: "清掉所有源的退避状态(修好上游后立即恢复)"}, + {Name: "probe_all", Title: "全量探测", Danger: DangerWrite, TimeoutMS: 60000, + Description: "对每个源做一次真实可达性探测(走网络,慢)"}, + {Name: "save_auto_rules", Title: "设置 AUTO 链", Danger: DangerWrite, TimeoutMS: 10000, + Params: []string{"entries"}, + Description: "替换全局 AUTO 链;entries 为 [{model, source, ...}] 数组"}, + {Name: "save_key_auto", Title: "设置按 key 链路", Danger: DangerWrite, TimeoutMS: 10000, + Params: []string{"key_name", "entries"}, + Description: "替换某个网关 key 自己的 AUTO 链"}, + {Name: "add_source", Title: "新增源", Danger: DangerWrite, TimeoutMS: 30000, + Params: []string{"source"}, + Description: "把一个新的上游源接入(source 为源定义表)"}, + {Name: "create_key", Title: "新建网关密钥", Danger: DangerWrite, TimeoutMS: 10000, + Params: []string{"name", "role", "models"}, + Description: "创建一个网关 key(返回的明文密钥只在此次响应里出现)"}, + + // ---- 破坏性:必须逐个授权 ---- + {Name: "remove_source", Title: "删除源", Danger: DangerDestructive, TimeoutMS: 30000, + Params: []string{"name"}, + Description: "删除一个上游源"}, + {Name: "delete_key", Title: "删除网关密钥", Danger: DangerDestructive, TimeoutMS: 10000, + Params: []string{"key"}, + Description: "删除一个网关 key(正在使用的客户端会立刻 401)"}, + {Name: "update_key", Title: "修改网关密钥", Danger: DangerDestructive, TimeoutMS: 10000, + Params: []string{"key", "name", "role", "models"}, + Description: "改动 key 的角色/可见模型(可能扩大访问面)"}, + {Name: "remove_adapter", Title: "删除适配器", Danger: DangerDestructive, TimeoutMS: 30000, + Params: []string{"name"}, + Description: "删除适配器(仅限插件自己装的;内置与非自有的会被拒)"}, + {Name: "set_plugin_enabled", Title: "启停插件", Danger: DangerDestructive, TimeoutMS: 5000, + Params: []string{"name", "enabled"}, Deferred: true, + Description: "启用或停用某个插件(异步执行:在本次调用返回后生效)"}, +} + +// kernelActionByName indexes the catalog. +var kernelActionByName = func() map[string]KernelAction { + m := make(map[string]KernelAction, len(kernelCatalog)) + for _, a := range kernelCatalog { + m[a.Name] = a + } + return m +}() + +// KernelActions returns the catalog sorted by name (stable for the UI). +func KernelActions() []KernelAction { + out := append([]KernelAction(nil), kernelCatalog...) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// LookupKernelAction finds an action, reporting whether it exists. +func LookupKernelAction(name string) (KernelAction, bool) { + a, ok := kernelActionByName[name] + return a, ok +} + +// ---- 权限 ---- + +// errNotPermitted is returned when a plugin calls an action it was not granted. +// It names the action and the plugin so the operator can act on it directly. +func notPermitted(plugin, action string) error { + return fmt.Errorf("kernel action %q is not granted to plugin %q; "+ + "enable it in the plugin's kernel permissions", action, plugin) +} + +// KernelPermission is the operator's grant for one plugin: the set of action +// names it may call, plus whether it was granted "everything readable". +type KernelPermission struct { + // Actions is the explicit allowlist, already expanded (no wildcards stored, + // so a NEW action added in a future release is NOT silently granted to an + // existing plugin — that is the safe direction for a security default). + Actions []string + // AllRead is true when the operator granted every read-only action at once. + // Stored separately from Actions so that a future read-only action IS covered + // without re-saving, while writes still require an explicit grant. + AllRead bool +} + +// clone returns a copy so callers cannot mutate the stored permission. +func (p KernelPermission) clone() KernelPermission { + return KernelPermission{Actions: append([]string(nil), p.Actions...), AllRead: p.AllRead} +} + +// allows reports whether the permission covers action. +// +// The "all readable" grant deliberately does NOT cover a future WRITE action: +// expanding a wildcard into whatever happens to exist later is how a convenience +// grant turns into a privilege escalation. +func (p KernelPermission) allows(a KernelAction) bool { + if p.AllRead && a.Danger == DangerRead { + return true + } + for _, n := range p.Actions { + if n == a.Name { + return true + } + } + return false +} + +// ---- 调用(带 Go 侧超时) ---- + +// ErrKernelUnavailable is returned when no KernelAPI was injected (a gateway +// running plugins without a wired core, e.g. some tests). +var ErrKernelUnavailable = errors.New("kernel actions are not available in this build") + +// callKernel invokes one action with the permission check and the Go-side +// deadline, returning a value Lua can consume. +// +// The deadline is the whole point: a plugin can block here harmlessly, because +// Lua is waiting on a return value rather than spinning. On timeout the caller +// gets a normal error; the action's own goroutine is told to stop via ctx. +func (ps *Plugins) callKernel(plugin, name string, args map[string]interface{}) (interface{}, error) { + api := ps.kernel() + if api == nil { + return nil, ErrKernelUnavailable + } + action, ok := LookupKernelAction(name) + if !ok { + return nil, fmt.Errorf("unknown kernel action %q", name) + } + if !ps.kernelPermitted(plugin, action) { + return nil, notPermitted(plugin, name) + } + + timeout := time.Duration(action.TimeoutMS) * time.Millisecond + if timeout <= 0 { + timeout = 10 * time.Second + } + // The caller's identity is injected HERE, not taken from args: an action that + // needs to know who is calling (remove_adapter, to enforce ownership) must not + // be able to be lied to by the plugin passing someone else's name. + if args == nil { + args = map[string]interface{}{} + } + args["plugin"] = plugin + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + type result struct { + v interface{} + err error + } + // A deferred action must not run under the caller's plugin lock, so it is + // queued and returns "accepted" immediately. Reporting success here without + // waiting is honest: the caller is told the action was ACCEPTED, and the + // actual outcome lands in the plugin's hook_errors surface if it fails. + if action.Deferred { + ps.runDeferred(action, args) + return map[string]interface{}{ + "accepted": true, + "note": "this action runs asynchronously; check the plugin state/errors for the outcome", + }, nil + } + + // The action runs on its own goroutine so this call can give up on time. + // Buffered so a late-finishing action never blocks on sending. + done := make(chan result, 1) + go func() { + defer func() { + // A panicking kernel action must not take the gateway down through a + // Lua call. Convert it into an error the plugin sees. + if rec := recover(); rec != nil { + done <- result{err: fmt.Errorf("kernel action %s panicked: %v", name, rec)} + } + }() + v, err := api.Invoke(ctx, name, args) + done <- result{v: v, err: err} + }() + + select { + case r := <-done: + return r.v, r.err + case <-ctx.Done(): + return nil, fmt.Errorf("kernel action %q exceeded its %s budget", name, timeout) + } +} + +// runDeferred executes a lock-re-entrant action outside the Lua call. +// +// It runs on a fresh goroutine precisely so it can take the plugin lock that the +// caller still holds; waiting for the caller to unwind first would need a +// handshake that is more machinery than the one action needs. A failure is +// surfaced through hook_errors, which is where an operator already looks for +// "a plugin's background work stopped working". +func (ps *Plugins) runDeferred(a KernelAction, args map[string]interface{}) { + api := ps.kernel() + if api == nil { + return + } + timeout := time.Duration(a.TimeoutMS) * time.Millisecond + if timeout <= 0 { + timeout = 10 * time.Second + } + // Copy the args: the caller's map is still owned by the Lua call frame. + cp := make(map[string]interface{}, len(args)) + for k, v := range args { + cp[k] = v + } + go func() { + defer func() { + if rec := recover(); rec != nil { + ps.hookErr.note(StageRequestEnd, fmt.Sprintf("deferred kernel action %s panicked: %v", a.Name, rec)) + } + }() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if _, err := api.Invoke(ctx, a.Name, cp); err != nil { + ps.hookErr.note(StageRequestEnd, fmt.Sprintf("deferred kernel action %s failed: %v", a.Name, err)) + } + }() +} + +// kernelPermitted applies the stored grant, with one deliberate exception: a +// plugin's OWN adapter may always be removed by that plugin (it is not a kernel +// resource, it is one the plugin created). Everything else needs a grant. +func (ps *Plugins) kernelPermitted(plugin string, a KernelAction) bool { + if a.Name == "remove_adapter" { + return true + } + ps.mu.RLock() + perm := ps.kernelPerms[plugin] + ps.mu.RUnlock() + return perm.allows(a) +} + +// SetPluginKernelPermission replaces one plugin's grant and persists it. +// +// Validation happens here rather than at call time so a typo'd action name is +// reported to the operator immediately instead of silently granting nothing. +func (ps *Plugins) SetPluginKernelPermission(plugin string, actions []string, allRead bool) error { + if ps.find(plugin) == nil { + return fmt.Errorf("plugin %s not found", plugin) + } + clean := make([]string, 0, len(actions)) + seen := map[string]bool{} + for _, raw := range actions { + name := strings.TrimSpace(raw) + if name == "" || seen[name] { + continue + } + a, ok := LookupKernelAction(name) + if !ok { + return fmt.Errorf("unknown kernel action %q", name) + } + seen[name] = true + clean = append(clean, a.Name) + } + sort.Strings(clean) + ps.mu.Lock() + if ps.kernelPerms == nil { + ps.kernelPerms = kernelPermissionSet{} + } + if len(clean) == 0 && !allRead { + delete(ps.kernelPerms, plugin) + } else { + ps.kernelPerms[plugin] = KernelPermission{Actions: clean, AllRead: allRead} + } + ps.saveKernelPermissionsLocked() + ps.mu.Unlock() + return nil +} + +// PluginKernelPermission returns a copy of one plugin's grant. +func (ps *Plugins) PluginKernelPermission(plugin string) KernelPermission { + ps.mu.RLock() + defer ps.mu.RUnlock() + return ps.kernelPerms[plugin].clone() +} + +// KernelPermissionView is the UI-facing form: what the plugin ASKED for (from its +// own source) next to what the operator GRANTED. The distinction matters — a +// plugin declaring `plugin.kernel = {...}` is a request, not a privilege. +type KernelPermissionView struct { + Plugin string `json:"plugin"` + Declared []string `json:"declared,omitempty"` + Granted []string `json:"granted,omitempty"` + AllRead bool `json:"all_read"` +} + +// KernelPermissions lists every plugin's declared-vs-granted state for the UI. +func (ps *Plugins) KernelPermissions() []KernelPermissionView { + ps.mu.RLock() + defer ps.mu.RUnlock() + out := make([]KernelPermissionView, 0, len(ps.plugins)) + for _, p := range ps.plugins { + perm := ps.kernelPerms[p.Info.Name] + out = append(out, KernelPermissionView{ + Plugin: p.Info.Name, + Declared: append([]string(nil), p.KernelDeclared...), + Granted: append([]string(nil), perm.Actions...), + AllRead: perm.AllRead, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Plugin < out[j].Plugin }) + return out +} + +// kernel returns the injected API, or nil. +func (ps *Plugins) kernel() KernelAPI { + ps.mu.RLock() + defer ps.mu.RUnlock() + return ps.kernelAPI +} + +// SetKernelAPI injects the kernel action implementation. Called during wiring, +// mirroring SetOutboundHosts. +func (ps *Plugins) SetKernelAPI(api KernelAPI) { + ps.mu.Lock() + defer ps.mu.Unlock() + ps.kernelAPI = api +} + +// kernelPermissionSet is the persisted per-plugin grant map. +type kernelPermissionSet map[string]KernelPermission + +// jsonPermission is the on-disk form of one grant. Kept separate from +// KernelPermission so the persisted shape cannot drift with a struct refactor +// (the access file is read by older and newer binaries across an upgrade). +type jsonPermission struct { + Actions []string `json:"actions,omitempty"` + AllRead bool `json:"all_read,omitempty"` +} + +func (p KernelPermission) marshal() jsonPermission { + return jsonPermission{Actions: p.Actions, AllRead: p.AllRead} +} + +func (j jsonPermission) unmarshal() KernelPermission { + return KernelPermission{Actions: j.Actions, AllRead: j.AllRead} +} + +// KernelActionView is one action as the settings page needs it: the catalog entry +// plus whether THIS plugin currently holds it and whether the plugin asked for it. +type KernelActionView struct { + KernelAction + Granted bool `json:"granted"` + Declared bool `json:"declared"` +} + +// KernelActionsView lists the catalog annotated for one plugin. +// +// Granted and Declared are both reported because they answer different questions: +// "may this plugin do it" and "did this plugin ask for it". An action that is +// declared but not granted is the one an operator most needs to notice. +func (ps *Plugins) KernelActionsView(plugin string) []KernelActionView { + perm := ps.PluginKernelPermission(plugin) + declared := map[string]bool{} + ps.mu.RLock() + for _, p := range ps.plugins { + if p.Info.Name == plugin { + for _, d := range p.KernelDeclared { + declared[d] = true + } + } + } + ps.mu.RUnlock() + out := make([]KernelActionView, 0, len(kernelCatalog)) + for _, a := range KernelActions() { + out = append(out, KernelActionView{ + KernelAction: a, + Granted: perm.allows(a), + Declared: declared[a.Name], + }) + } + return out +} diff --git a/internal/lua/kernel_perms_persist.go b/internal/lua/kernel_perms_persist.go new file mode 100644 index 0000000..8a51c19 --- /dev/null +++ b/internal/lua/kernel_perms_persist.go @@ -0,0 +1,91 @@ +package lua + +import ( + "encoding/json" + "os" +) + +// Kernel action grants are OPERATOR policy, persisted separately from both +// plugin state and the endpoint/visibility overrides. +// +// Why a separate file rather than reusing .access.json: +// +// - .access.json is a flat map[string]string ("name" or "name#path" -> spec). +// Kernel grants are a list plus a boolean, so folding them in would either +// change the shape of a file an older binary still reads, or force an +// encoding that cannot round-trip. An upgrade must not corrupt a running +// deployment's restrictions. +// - The two answer different questions — "who may call this endpoint" vs +// "what may this plugin do to the kernel" — and an operator looking at one +// should not have to parse the other. +// +// Shape: { "": { "actions": ["reload", ...], "all_read": true } } + +const kernelPermsFileName = ".kernel-perms.json" + +func (ps *Plugins) kernelPermsFile() string { + if ps.dir == "" { + return "" + } + return ps.dir + string(os.PathSeparator) + kernelPermsFileName +} + +// LoadKernelPermissions reads the persisted grants. A missing file is the normal +// fresh-install state (nothing granted); a corrupt file is logged and ignored +// rather than fatal — losing a grant is recoverable, refusing to boot is not. +func (ps *Plugins) LoadKernelPermissions() error { + path := ps.kernelPermsFile() + if path == "" { + return nil + } + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + raw := map[string]jsonPermission{} + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + ps.mu.Lock() + defer ps.mu.Unlock() + if ps.kernelPerms == nil { + ps.kernelPerms = kernelPermissionSet{} + } + for name, jp := range raw { + ps.kernelPerms[name] = jp.unmarshal() + } + return nil +} + +// saveKernelPermissionsLocked flushes the grant map. Caller holds ps.mu. +// +// Grants drop unknown action names on write. If an operator authorized an action +// that a later release removed, keeping the name would leave a stale entry that +// nothing can ever satisfy — and it would reappear in the UI as if it were live. +func (ps *Plugins) saveKernelPermissionsLocked() { + path := ps.kernelPermsFile() + if path == "" { + return + } + out := map[string]jsonPermission{} + for name, perm := range ps.kernelPerms { + valid := make([]string, 0, len(perm.Actions)) + for _, a := range perm.Actions { + if _, ok := LookupKernelAction(a); ok { + valid = append(valid, a) + } + } + if len(valid) == 0 && !perm.AllRead { + continue // nothing granted: presence would be a lie + } + out[name] = KernelPermission{Actions: valid, AllRead: perm.AllRead}.marshal() + } + if len(out) == 0 { + _ = os.Remove(path) + return + } + writeJSONAtomic(path, out) +} diff --git a/internal/lua/plugin_access_persist.go b/internal/lua/plugin_access_persist.go index 822f7e8..4e71398 100644 --- a/internal/lua/plugin_access_persist.go +++ b/internal/lua/plugin_access_persist.go @@ -112,4 +112,4 @@ func (ps *Plugins) sortedOverrideKeys() []string { } sort.Strings(keys) return keys -} \ No newline at end of file +} diff --git a/internal/lua/plugin_adapter_bridge.go b/internal/lua/plugin_adapter_bridge.go new file mode 100644 index 0000000..2554fd4 --- /dev/null +++ b/internal/lua/plugin_adapter_bridge.go @@ -0,0 +1,293 @@ +// Package lua — 插件适配器管理桥接(R4-a / Phase 3) +// +// 用户需求:插件可安装、运行时修改适配器,以支持复杂认证与 token 轮换; +// 适配器本身不需要持续运行(它是无状态请求级弹性池)。 +// +// 插件(长期运行)──定期/事件驱动──> install/update/remove_adapter +// │ +// ▼ +// 内核重建该适配器的池(LoadAdapterSource) +// │ +// ▼ +// 后续请求自然使用新逻辑 +// +// 三条安全规则(与 R6 同源): +// +// 1. 路径安全:名字必须通过 ValidateAdapterName(无穿越/绝对路径/空白) +// 2. 不得影子内置:插件不能注册与内置适配器同名的适配器 +// (用 VM.InstallPluginAdapter,运维仍可自行覆盖) +// 3. 归属可查:记录每个适配器的 owner("plugin:名字" 或 "operator"), +// 于是「插件只能改自己装的」这条规则是可判定的,而不是靠约定 +// +// 第 3 条是必要的:没有归属信息时,"插件 A 改插件 B 装的适配器" 无法区分 +// 于合法的自身更新,会导致一个插件能劫持另一个插件的认证逻辑。 +package lua + +import ( + "fmt" + "sort" + "strings" + "sync" + + golua "github.com/aarzilli/golua/lua" +) + +// AdapterOwner 标识一个适配器由谁安装。 +const ( + OwnerOperator = "operator" // 磁盘/首启 seed/管理员上传 + OwnerPlugin = "plugin" // 由某个插件经桥接安装 +) + +// adapterOwnership records which plugin installed each adapter. +// +// This is the state that makes rule 3 enforceable: InstallPluginAdapter marks +// the adapter as owned by that plugin, and a later update from a DIFFERENT +// plugin is refused. An adapter with no owner is the operator's, and no plugin +// may touch it. +type adapterOwnership struct { + mu sync.RWMutex + // owner maps adapter name -> installing plugin name (only for plugin-owned). + owner map[string]string +} + +// ownedBy reports the plugin that owns name, or "" when it is operator-owned. +func (o *adapterOwnership) ownedBy(name string) string { + o.mu.RLock() + defer o.mu.RUnlock() + return o.owner[name] +} + +func (o *adapterOwnership) set(name, plugin string) { + o.mu.Lock() + defer o.mu.Unlock() + if o.owner == nil { + o.owner = map[string]string{} + } + o.owner[name] = plugin +} + +func (o *adapterOwnership) clear(name string) { + o.mu.Lock() + defer o.mu.Unlock() + delete(o.owner, name) +} + +// listOwned returns the adapter names a given plugin owns (for UI/diagnostics). +func (o *adapterOwnership) listOwned(plugin string) []string { + o.mu.RLock() + defer o.mu.RUnlock() + var out []string + for name, p := range o.owner { + if p == plugin { + out = append(out, name) + } + } + sort.Strings(out) + return out +} + +// Plugins carries an adapter-ownership table. +func (ps *Plugins) adapterOwner() *adapterOwnership { + ps.adapterOwnOnce.Do(func() { ps.adapterOwn = &adapterOwnership{} }) + return ps.adapterOwn +} + +// InstallAdapter registers an adapter on behalf of a plugin. +// +// Returns the name that was actually registered — an adapter script may declare +// a different `name` than the file/registration key, and the declared name is +// what sources must reference. Callers get it back so they can update config. +func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error) { + if plugin == "" { + return "", fmt.Errorf("install_adapter: caller plugin is unknown") + } + if ps.vm == nil { + return "", fmt.Errorf("install_adapter: no adapter VM available") + } + // A name conflict used to be a hard rejection (owned by another plugin, or + // shadows a built-in). The user now wants it SUSPENDED for operator + // arbitration instead of refused outright: the incumbent keeps serving, the + // candidate waits in the conflicts list, and the operator picks a winner in + // the WebUI. The install still returns a (typed) error so the caller knows it + // did not take effect — but it is ErrAdapterConflictPending, not a dead end. + incumbent := "" + if owner := ps.adapterOwner().ownedBy(name); owner != "" && owner != plugin { + incumbent = "plugin:" + owner + } else if containsNameFold(ps.vm.bundledNames(), name) { + incumbent = "operator (built-in)" + } + if incumbent != "" { + if ps.recordAdapterConflict(name, incumbent, plugin, code) { + // The operator already chose THIS candidate in a prior session. Install + // for real via LoadAdapterSource, which bypasses the reserved-name gate + // — that explicit operator override is exactly what the arbitration is + // FOR, and re-suspending it on every restart would be a loop. + if err := ps.vm.LoadAdapterSource(name, code); err != nil { + return "", err + } + return ps.finishInstall(plugin, name), nil + } + return "", &ErrAdapterConflictPending{Name: name, Incumbent: incumbent} + } + // No conflict: normal install path. Rules 1+2 (path safety + no shadowing + // built-ins) live in InstallPluginAdapter so there is exactly one + // enforcement point. (The shadow check there is now unreachable for a + // genuine built-in — it is caught above and suspended — but kept as the + // single authority for path safety and any future reserved set.) + if err := ps.vm.InstallPluginAdapter(name, code); err != nil { + return "", err + } + return ps.finishInstall(plugin, name), nil +} + +// finishInstall records adapter ownership under BOTH the registration key and +// the declared name (a script may declare a name different from the key, and it +// is the declared name sources reference — recording only the key would leave +// the adapter un-owned and therefore mutable by anyone). Both entries are +// cleared together on remove. +func (ps *Plugins) finishInstall(plugin, name string) string { + effective := ps.vm.EffectiveAdapterName(name) + ps.adapterOwner().set(effective, plugin) + if effective != name { + ps.adapterOwner().set(name, plugin) + } + return effective +} + +// containsNameFold reports whether name is in names (case-insensitive, matching +// ValidateAdapterName's reserved-name comparison). +func containsNameFold(names []string, name string) bool { + for _, n := range names { + if strings.EqualFold(n, name) { + return true + } + } + return false +} + +// UpdateAdapter replaces an adapter a plugin previously installed — the token +// rotation path. The plugin gets a NEW pool; the old one is shut down, so an +// in-flight request either uses the old or the new code, never a mixture. +// +// Only the owning plugin may update. Updating an operator-installed adapter is +// refused: the operator's file on disk would then disagree with what the running +// gateway executes, and the next restart would silently revert the "update". +func (ps *Plugins) UpdateAdapter(plugin, name, code string) error { + if plugin == "" { + return fmt.Errorf("update_adapter: caller plugin is unknown") + } + owner := ps.adapterOwner().ownedBy(name) + if owner == "" { + return fmt.Errorf("update_adapter: adapter %q is operator-installed; "+ + "only adapters a plugin installed can be updated at runtime", name) + } + if owner != plugin { + return fmt.Errorf("update_adapter: adapter %q is owned by plugin %q, not %q", + name, owner, plugin) + } + if err := ps.vm.InstallPluginAdapter(name, code); err != nil { + return err + } + // Ownership is unchanged: same plugin, new version. + return nil +} + +// RemoveAdapter removes an adapter a plugin installed. Operator-installed +// adapters are refused (same reasoning as UpdateAdapter). +func (ps *Plugins) RemoveAdapter(plugin, name string) error { + owner := ps.adapterOwner().ownedBy(name) + if owner == "" { + return fmt.Errorf("remove_adapter: adapter %q is operator-installed; "+ + "use DELETE /api/adapters/%s", name, name) + } + if owner != plugin { + return fmt.Errorf("remove_adapter: adapter %q is owned by plugin %q, not %q", + name, owner, plugin) + } + ps.vm.RemoveAdapter(name) + ps.adapterOwner().clear(name) + return nil +} + +// PluginAdapters lists the adapters a plugin owns (its token-rotation surface). +func (ps *Plugins) PluginAdapters(plugin string) []string { + return ps.adapterOwner().listOwned(plugin) +} + +// AdapterOwnerOf reports the owning plugin of an adapter, or "" if operator-owned. +func (ps *Plugins) AdapterOwnerOf(name string) string { + return ps.adapterOwner().ownedBy(name) +} + +// ---- Lua 绑定(R4:插件在运行时改自己的适配器)---- + +// adapterBridgeGlobal 暴露给插件 Lua 的适配器管理表。 +// +// 用一张表而不是三个顶层全局,是为了少占插件可见的全局命名空间。表用 +// buildJSONTable 的同一套写法(NewTable + PushGoClosure + SetField), +// 该写法在 json 表上已被长期验证。 +const adapterBridgeGlobal = "__llmsproxy_adapters" + +// registerAdapterBridge 把适配器管理表装进插件的 Lua 状态。 +// +// name 是**该插件自己的启动名**,随每次调用一起交给 Go 侧桥接 —— 归属校验 +// 因此来自启动时的固定身份,而不是从 Lua 状态里现读(那样一个分片或任何持有 +// 已编译脚本的调用者就能冒充别的插件)。 +func registerAdapterBridge(L *golua.State, ps *Plugins, name string) { + L.NewTable() + install := luaInstallAdapter(L, ps, name) + L.PushGoClosure(install) + L.SetField(-2, "install") + update := luaUpdateAdapter(L, ps, name) + L.PushGoClosure(update) + L.SetField(-2, "update") + remove := luaRemoveAdapter(L, ps, name) + L.PushGoClosure(remove) + L.SetField(-2, "remove") + L.SetGlobal(adapterBridgeGlobal) +} + +func luaInstallAdapter(L *golua.State, ps *Plugins, name string) golua.LuaGoFunction { + return func(L *golua.State) int { + adapterName := L.ToString(1) + code := L.ToString(2) + effective, err := ps.InstallAdapter(name, adapterName, code) + if err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + L.PushString(effective) + L.PushNil() + return 2 + } +} + +func luaUpdateAdapter(L *golua.State, ps *Plugins, name string) golua.LuaGoFunction { + return func(L *golua.State) int { + adapterName := L.ToString(1) + code := L.ToString(2) + if err := ps.UpdateAdapter(name, adapterName, code); err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + L.PushBoolean(true) + L.PushNil() + return 2 + } +} + +func luaRemoveAdapter(L *golua.State, ps *Plugins, name string) golua.LuaGoFunction { + return func(L *golua.State) int { + adapterName := L.ToString(1) + if err := ps.RemoveAdapter(name, adapterName); err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + L.PushBoolean(true) + L.PushNil() + return 2 + } +} diff --git a/internal/lua/plugin_adapter_bridge_lua_test.go b/internal/lua/plugin_adapter_bridge_lua_test.go new file mode 100644 index 0000000..bf07092 --- /dev/null +++ b/internal/lua/plugin_adapter_bridge_lua_test.go @@ -0,0 +1,234 @@ +package lua + +import ( + "testing" + "time" +) + +// 本文件验证 R4 的 Lua 侧绑定:**插件自己的 Lua 代码**能在运行时安装/改写/ +// 删除自己的适配器。 +// +// 为什么需要单独一层测试:Go 侧的 InstallAdapter/UpdateAdapter 在 +// plugin_adapter_bridge_test.go 里已直接验证过,但那证明的是"Go API 能工作"。 +// token 轮换的真实形态是插件在定时任务或钩子里**从 Lua 发起**这个调用,所以 +// 绑定本身(__llmsproxy_adapters 表)必须被证明可用 —— 而且它曾经在 cgo 层 +// SIGSEGV,所以"不崩"本身就是一条断言。 + +const bridgeProbe = ` +local plugin = { name = "authtoken", version = "1.0" } +plugin.exports = { "install_via_lua", "update_via_lua", "remove_via_lua", "install_operator_owned" } + +local function src(ver, tok) + return 'return { name = "myauth", version = "' .. ver .. '", endpoint = "/x", token = "' .. tok .. '" }' +end + +function plugin.install_via_lua(args) + local name, err = __llmsproxy_adapters.install("myauth", src("1.0", args.tok)) + return { name = name, err = err } +end + +function plugin.update_via_lua(args) + local ok, err = __llmsproxy_adapters.update("myauth", src("2.0", args.tok)) + return { ok = ok, err = err } +end + +function plugin.remove_via_lua() + local ok, err = __llmsproxy_adapters.remove("myauth") + return { ok = ok, err = err } +end + +-- 试图替换一个非自己拥有的适配器:必须被拒(不能影子内置) +function plugin.install_operator_owned() + local name, err = __llmsproxy_adapters.install("openai", src("9.9", "evil")) + return { name = name, err = err } +end +return plugin +` + +// TestPluginInstallsAdapterFromLua 是 R4 绑定的主断言。 +func TestPluginInstallsAdapterFromLua(t *testing.T) { + _, ps, _ := runtimePlugins(t) + if err := ps.LoadSource("authtoken", bridgeProbe); err != nil { + t.Fatal(err) + } + out, err := ps.Call("authtoken", "install_via_lua", map[string]interface{}{"tok": "T1"}) + if err != nil { + t.Fatalf("Lua 侧 install 调用失败: %v", err) + } + m, ok := out.(map[string]interface{}) + if !ok { + t.Fatalf("返回值不是 table: %#v", out) + } + if m["name"] != "myauth" { + t.Fatalf("install 应返回生效名 myauth, 实得 %#v (err=%#v)", m["name"], m["err"]) + } + // 归属确实被记录(这条规则是"插件只能改自己装的"的基础) + if owner := ps.AdapterOwnerOf("myauth"); owner != "authtoken" { + t.Errorf("归属应为 authtoken, 实得 %q", owner) + } +} + +// TestPluginRotatesTokenViaLua 是 token 轮换的完整形态: +// 同一个插件先装、再改,改后新版本生效。 +func TestPluginRotatesTokenViaLua(t *testing.T) { + _, ps, _ := runtimePlugins(t) + if err := ps.LoadSource("authtoken", bridgeProbe); err != nil { + t.Fatal(err) + } + if _, err := ps.Call("authtoken", "install_via_lua", map[string]interface{}{"tok": "OLD"}); err != nil { + t.Fatalf("install: %v", err) + } + // 轮换 + out, err := ps.Call("authtoken", "update_via_lua", map[string]interface{}{"tok": "NEW"}) + if err != nil { + t.Fatalf("update: %v", err) + } + m := out.(map[string]interface{}) + if m["ok"] != true { + t.Fatalf("Lua 侧 update 未成功: %#v", m) + } + // 新代码必须真的生效 —— 这是"轮换有意义"的定义 + if rev := ps.vm.AdapterRevision("myauth"); rev < 2 { + t.Errorf("适配器修订号应 >=2(已被替换一次),实得 %d", rev) + } +} + +// TestPluginRemoveAdapterFromLua:插件能删掉自己装的。 +func TestPluginRemoveAdapterFromLua(t *testing.T) { + _, ps, _ := runtimePlugins(t) + if err := ps.LoadSource("authtoken", bridgeProbe); err != nil { + t.Fatal(err) + } + if _, err := ps.Call("authtoken", "install_via_lua", map[string]interface{}{"tok": "T"}); err != nil { + t.Fatal(err) + } + out, err := ps.Call("authtoken", "remove_via_lua", map[string]interface{}{}) + if err != nil { + t.Fatalf("remove: %v", err) + } + if out.(map[string]interface{})["ok"] != true { + t.Fatalf("Lua 侧 remove 未成功: %#v", out) + } + if owner := ps.AdapterOwnerOf("myauth"); owner != "" { + t.Errorf("删除后归属应清空, 实得 %q", owner) + } +} + +// TestLuaBridgeCannotShadowBuiltin:从 Lua 侧同样不能影子内置适配器 —— +// 否则绑定就成了绕过 Go 侧校验的后门。 +func TestLuaBridgeCannotShadowBuiltin(t *testing.T) { + _, ps, _ := runtimePlugins(t) + if err := ps.LoadSource("authtoken", bridgeProbe); err != nil { + t.Fatal(err) + } + out, err := ps.Call("authtoken", "install_operator_owned", map[string]interface{}{}) + if err != nil { + t.Fatalf("调用本身不应报错(错误应作为返回值): %v", err) + } + m := out.(map[string]interface{}) + if m["name"] != nil { + t.Fatalf("影子内置 openai 必须被拒,却返回了 name=%#v", m["name"]) + } + if m["err"] == nil || m["err"] == "" { + t.Fatal("影子内置应带回错误信息") + } + t.Logf("拒绝信息: %v", m["err"]) +} + +// TestScheduledTaskRotatesTokenEndToEnd 是 R4 的闭环验收。 +// +// 它把三个特性串成一条链,任何一个环节坏掉都会让它失败: +// 1. 内核 ticker 驱动定时任务(无外部触发) +// 2. 任务与钩子跑在同一个 VM(所以任务的 install 与 state 写入对钩子可见) +// 3. Lua 侧适配器桥接可用(__llmsproxy_adapters.install) +// +// 这正是用户描述的 token 轮换形态:插件自己按时间拿到新 token,重写适配器, +// 后续请求自然使用新逻辑 —— 全程没有任何浏览器或人工操作。 +func TestScheduledTaskRotatesTokenEndToEnd(t *testing.T) { + shortTick(t) + _, ps, _ := runtimePlugins(t) + code := ` +local plugin = { name = "sched_rot", version = "1.0" } +plugin.state = { token = "OLD", rotates = 0 } +plugin.schedule = { { fn = "rotate", every = 1 } } +function plugin.rotate() + plugin.state.rotates = plugin.state.rotates + 1 + local tok = "T" .. tostring(plugin.state.rotates) + local src = 'return { name = "rotauth", version = "1.0", endpoint = "/x", token = "' .. tok .. '" }' + plugin.state.last_adapter, plugin.state.rotate_err = __llmsproxy_adapters.install("rotauth", src) + plugin.state.token = tok +end +plugin.hooks = { routed = "observe" } +function plugin.observe(payload) + payload.token = plugin.state.token + payload.rotates = plugin.state.rotates + payload.adapter = plugin.state.last_adapter + return payload +end +return plugin +` + if err := ps.LoadSource("sched_rot", code); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + out := ps.Fire(StageRouted, map[string]interface{}{}) + if n, ok := out["rotates"].(float64); ok && n >= 1 { + t.Logf("任务轮换了 %v 次; 钩子看到 token=%v adapter=%v", n, out["token"], out["adapter"]) + if out["adapter"] != "rotauth" { + t.Fatalf("任务里 install 未生效: adapter=%v", out["adapter"]) + } + return + } + time.Sleep(30 * time.Millisecond) + } + t.Fatal("定时任务未完成 token 轮换") +} + +// TestShardVMHasNoAdapterBridge 锁定一条边界:分片 VM(ShardCall 用的私有 VM) +// **不能**拿到适配器管理桥接。 +// +// 理由:分片是文档化的「只读、可丢弃」工作单元,而 install/update/remove 是 +// **全局**副作用(改的是共享的适配器注册表,不是本 VM 的状态)。若把桥接给 +// 分片,一个被声明为只读的按需调用就能悄悄改写所有请求共用的适配器 —— +// 正是分片契约要防的那类不可见变更。 +// +// 断言方式是:分片里的插件代码检查 __llmsproxy_adapters 是否为 nil,返回结果。 +func TestShardVMHasNoAdapterBridge(t *testing.T) { + _, ps, _ := runtimePlugins(t) + code := ` +local plugin = { name = "sharded", version = "1.0" } +plugin.exports = { "probe" } +-- 只读探测:分片里这个全局必须是 nil +function plugin.probe(args) + return { has_bridge = __llmsproxy_adapters ~= nil } +end +return plugin +` + if err := ps.LoadSource("sharded", code); err != nil { + t.Fatal(err) + } + // 主 VM 上应当有桥接(对照) + mainOut, err := ps.Call("sharded", "probe", map[string]interface{}{}) + if err != nil { + t.Fatalf("主 VM 调用失败: %v", err) + } + if m, ok := mainOut.(map[string]interface{}); !ok || m["has_bridge"] != true { + t.Errorf("主 VM 上应有 __llmsproxy_adapters, 实得 %#v", mainOut) + } + + // 分片 VM 上不应有 + outs, err := ps.ShardCall("sharded", "probe", []map[string]interface{}{{}, {}}) + if err != nil { + t.Fatalf("ShardCall: %v", err) + } + for i, o := range outs { + m, ok := o.(map[string]interface{}) + if !ok { + t.Fatalf("分片 %d 返回非 table: %#v", i, o) + } + if m["has_bridge"] == true { + t.Errorf("分片 %d 不应拿到适配器桥接(分片是只读可丢弃的)", i) + } + } +} diff --git a/internal/lua/plugin_adapter_bridge_test.go b/internal/lua/plugin_adapter_bridge_test.go new file mode 100644 index 0000000..20e8473 --- /dev/null +++ b/internal/lua/plugin_adapter_bridge_test.go @@ -0,0 +1,264 @@ +package lua + +import ( + "encoding/json" + "path/filepath" + "strings" + "sync" + "testing" +) + +func bridgeVM(t *testing.T) (*VM, *Plugins) { + t.Helper() + dir := filepath.Join(t.TempDir(), "adapters") + vm := NewVM(dir) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(vm.Stop) + ps := NewPlugins(vm, filepath.Join(t.TempDir(), "plugins")) + return vm, ps +} + +const tokenAdapterV1 = ` +local adapter = {} +adapter.name = "myauth" +adapter.version = "1.0" +adapter.endpoint = "/chat/completions" +function adapter.transform_request(body) + return '{"token":"TOKEN_V1"}' +end +return adapter +` + +const tokenAdapterV2 = ` +local adapter = {} +adapter.name = "myauth" +adapter.version = "2.0" +adapter.endpoint = "/chat/completions" +function adapter.transform_request(body) + return '{"token":"TOKEN_V2"}' +end +return adapter +` + +// TestPluginInstallsAdapter 是基础能力:插件装的适配器立即可用。 +func TestPluginInstallsAdapter(t *testing.T) { + vm, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil { + t.Fatalf("install: %v", err) + } + if !vm.HasFn("myauth", "transform_request") { + t.Fatal("安装后适配器未注册") + } + out, err := vm.Transform("myauth", "transform_request", "{}") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "TOKEN_V1") { + t.Errorf("安装的适配器未被使用: %s", out) + } +} + +// TestPluginUpdateAdapterRotatesToken 是核心用例:**token 轮换**。 +// 插件长期运行,在运行中改写适配器,后续请求用新 token —— 适配器自身 +// 不跑任何循环。 +func TestPluginUpdateAdapterRotatesToken(t *testing.T) { + vm, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + first, _ := vm.Transform("myauth", "transform_request", "{}") + if !strings.Contains(first, "TOKEN_V1") { + t.Fatalf("初次应带 V1: %s", first) + } + + // 轮换:插件改写适配器 + if err := ps.UpdateAdapter("authtoken", "myauth", tokenAdapterV2); err != nil { + t.Fatalf("update: %v", err) + } + second, err := vm.Transform("myauth", "transform_request", "{}") + if err != nil { + t.Fatalf("改写后适配器不可用: %v", err) + } + if !strings.Contains(second, "TOKEN_V2") { + t.Errorf("轮换后应带 V2: %s", second) + } + if strings.Contains(second, "TOKEN_V1") { + t.Errorf("轮换后仍带旧 V1(池未重建?): %s", second) + } + // 版本号也应更新,便于运维从 /api/status 看出发生过轮换 + if v := vm.AdapterVersion("myauth"); v != "2.0" { + t.Errorf("适配器版本 = %q, want 2.0", v) + } +} + +// TestPluginCannotShadowBuiltin:插件不能篡改内核自带的适配器。 +func TestPluginCannotShadowBuiltin(t *testing.T) { + _, ps := bridgeVM(t) + evil := `local a={} a.name="openai" a.version="EVIL" a.endpoint="/x" return a` + if _, err := ps.InstallAdapter("sneaky", "openai", evil); err == nil { + t.Fatal("插件装与内置同名的适配器应被拒") + } +} + +// TestPluginCannotStealAnotherPluginsAdapter:归属隔离。 +func TestPluginCannotStealAnotherPluginsAdapter(t *testing.T) { + _, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("alpha", "shared_auth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + if _, err := ps.InstallAdapter("beta", "shared_auth", tokenAdapterV2); err == nil { + t.Error("插件 B 应无法覆盖插件 A 装的适配器") + } + if err := ps.UpdateAdapter("beta", "shared_auth", tokenAdapterV2); err == nil { + t.Error("插件 B 应无法更新插件 A 的适配器") + } + if err := ps.RemoveAdapter("beta", "shared_auth"); err == nil { + t.Error("插件 B 应无法删除插件 A 的适配器") + } + // 属主自己可以 + if err := ps.UpdateAdapter("alpha", "shared_auth", tokenAdapterV2); err != nil { + t.Errorf("属主应能更新自己的适配器: %v", err) + } +} + +// TestPluginCannotTouchOperatorAdapter:运维的适配器不可被插件改写。 +func TestPluginCannotTouchOperatorAdapter(t *testing.T) { + _, ps := bridgeVM(t) + if err := ps.UpdateAdapter("authtoken", "openai", tokenAdapterV1); err == nil { + t.Error("插件更新运维适配器应被拒") + } + if err := ps.RemoveAdapter("authtoken", "openai"); err == nil { + t.Error("插件删除运维适配器应被拒") + } +} + +// TestPluginAdapterNameTraversal:路径穿越名在桥接层同样被拒。 +func TestPluginAdapterNameTraversal(t *testing.T) { + _, ps := bridgeVM(t) + for _, bad := range []string{"../evil", "/etc/passwd", "has space", ".."} { + if _, err := ps.InstallAdapter("authtoken", bad, tokenAdapterV1); err == nil { + t.Errorf("桥接层应拒绝路径穿越名 %q", bad) + } + } +} + +// TestPluginRemoveAdapter 让插件能回收自己的适配器(token 失效/功能下线)。 +func TestPluginRemoveAdapter(t *testing.T) { + vm, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + if err := ps.RemoveAdapter("authtoken", "myauth"); err != nil { + t.Fatalf("remove: %v", err) + } + if vm.HasFn("myauth", "transform_request") { + t.Error("删除后适配器仍在") + } + if owners := ps.PluginAdapters("authtoken"); len(owners) != 0 { + t.Errorf("删除后归属表应清空,实得 %v", owners) + } +} + +// TestPluginAdapterOwnershipListing 供 UI/诊断用。 +func TestPluginAdapterOwnershipListing(t *testing.T) { + _, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("alpha", "a1", tokenAdapterV1); err != nil { + t.Fatal(err) + } + if _, err := ps.InstallAdapter("alpha", "a2", tokenAdapterV1); err != nil { + t.Fatal(err) + } + if _, err := ps.InstallAdapter("beta", "b1", tokenAdapterV1); err != nil { + t.Fatal(err) + } + got := ps.PluginAdapters("alpha") + if len(got) != 2 || got[0] != "a1" || got[1] != "a2" { + t.Errorf("alpha 的适配器 = %v, want [a1 a2]", got) + } + if o := ps.AdapterOwnerOf("b1"); o != "beta" { + t.Errorf("b1 属主 = %q, want beta", o) + } + if o := ps.AdapterOwnerOf("openai"); o != "" { + t.Errorf("内置适配器不应有插件属主,实得 %q", o) + } +} + +// TestConcurrentPluginAdapterUpdates 是压力测试:多 goroutine 轮换同一适配器。 +// 池重建与归属表都要在并发下自洽(不 panic、不串属主)。 +func TestConcurrentPluginAdapterUpdates(t *testing.T) { + vm, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + const workers, iters = 8, 20 + var wg sync.WaitGroup + errs := make(chan string, workers*iters) + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < iters; i++ { + code := tokenAdapterV1 + if i%2 == 1 { + code = tokenAdapterV2 + } + if err := ps.UpdateAdapter("authtoken", "myauth", code); err != nil { + errs <- "update: " + err.Error() + return + } + // 更新后必须仍可用(池重建期间不能出现「找不到适配器」) + if _, err := vm.Transform("myauth", "transform_request", "{}"); err != nil { + errs <- "update 后适配器不可用: " + err.Error() + return + } + } + }(w) + } + wg.Wait() + close(errs) + for e := range errs { + t.Error(e) + } + if o := ps.AdapterOwnerOf("myauth"); o != "authtoken" { + t.Errorf("并发后属主应为 authtoken,实得 %q", o) + } +} + +// TestInstalledAdapterBodyIsValidJSON 确认插件提供的代码必须真能编译成 +// 可用适配器 —— 语法错误在安装时就报,而不是等到第一个请求。 +func TestInstalledAdapterBodyIsValidJSON(t *testing.T) { + _, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("bad", "broken", "this is not lua ==="); err == nil { + t.Error("语法错误的适配器应在安装时被拒") + } + if _, err := ps.InstallAdapter("bad", "notatable", "return 42"); err == nil { + t.Error("未返回 table 的适配器应在安装时被拒") + } + // 失败的安装不应留下归属记录 + if o := ps.AdapterOwnerOf("broken"); o != "" { + t.Errorf("安装失败却留下了属主记录: %q", o) + } + if o := ps.AdapterOwnerOf("notatable"); o != "" { + t.Errorf("安装失败却留下了属主记录: %q", o) + } +} + +// TestInstallAdapterReportsActualName 适配器脚本可声明与注册键不同的 name; +// 调用方拿到实际名,便于更新配置。 +func TestInstallAdapterReportsActualName(t *testing.T) { + _, ps := bridgeVM(t) + code := `local a={} a.name="declared_name" a.version="1" a.endpoint="/x" return a` + got, err := ps.InstallAdapter("p", "registration_key", code) + if err != nil { + t.Fatal(err) + } + if got != "declared_name" { + t.Errorf("返回名 = %q, want declared_name", got) + } + if o := ps.AdapterOwnerOf("declared_name"); o != "p" { + t.Errorf("归属应记在声明名上,实得 %q", o) + } + var _ = json.Marshal +} diff --git a/internal/lua/plugin_bridges.go b/internal/lua/plugin_bridges.go new file mode 100644 index 0000000..56387de --- /dev/null +++ b/internal/lua/plugin_bridges.go @@ -0,0 +1,264 @@ +package lua + +import ( + "fmt" + "sort" + "strings" + + golua "github.com/aarzilli/golua/lua" +) + +// Package lua — Lua 绑定:凭据保险库 + 内核动作面 +// +// 这两个全局把"插件自己主导链接认证"所需的两块补齐: +// +// __llmsproxy_secrets —— 安全存放 OAuth token / session / 设备指纹 +// __llmsproxy_kernel —— 触发内核动作(注册源、探测、清冷却、改链路…) +// +// 注册点与 __llmsproxy_http 相同:只在 PLUGIN 的 bootPluginState 里装, +// 因为适配器共用 setupGlobals 而不该拿到这些能力。 +// +// 两张表都用 buildJSONTable 的既有写法(NewTable + PushGoClosure + SetField), +// 该写法在 json 表上长期稳定,也是内核自己的用法。 + +// secretGlobal 是凭据保险库的 Lua 名字。 +const secretGlobal = "__llmsproxy_secrets" + +// kernelGlobal 是内核动作面的 Lua 名字。 +const kernelGlobal = "__llmsproxy_kernel" + +// registerSecretBridge installs the credential vault onto a plugin's state. +// +// The plugin name is bound HERE, at boot, rather than read from the Lua state at +// call time. That is what makes "a plugin can only read its OWN credentials" a +// mechanism instead of a convention: a shard or any other holder of the compiled +// script cannot pass a different name. +func registerSecretBridge(L *golua.State, ps *Plugins, name string) { + L.NewTable() + L.PushGoClosure(luaSecretSet(ps, name)) + L.SetField(-2, "set") + L.PushGoClosure(luaSecretGet(ps, name)) + L.SetField(-2, "get") + L.PushGoClosure(luaSecretDelete(ps, name)) + L.SetField(-2, "delete") + L.PushGoClosure(luaSecretKeys(ps, name)) + L.SetField(-2, "keys") + L.SetGlobal(secretGlobal) +} + +// luaSecretSet: set(key, value) -> (true, nil) | (nil, err) +func luaSecretSet(ps *Plugins, name string) golua.LuaGoFunction { + return func(L *golua.State) int { + key := L.ToString(1) + val := L.ToString(2) + if err := ps.SetSecret(name, key, val); err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + L.PushBoolean(true) + L.PushNil() + return 2 + } +} + +// luaSecretGet: get(key) -> (value, nil) | (nil, err). A missing key yields +// (nil, nil) so a plugin can test `if tok == nil then login() end` — the "no +// credential stored yet" case is the common one for a fresh install, and making +// it an error would force every caller to string-match on a message. +func luaSecretGet(ps *Plugins, name string) golua.LuaGoFunction { + return func(L *golua.State) int { + key := L.ToString(1) + v, ok, err := ps.GetSecret(name, key) + if err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + if !ok { + L.PushNil() + L.PushNil() + return 2 + } + L.PushString(v) + L.PushNil() + return 2 + } +} + +// luaSecretDelete: delete(key) -> (true, nil) | (nil, err) +func luaSecretDelete(ps *Plugins, name string) golua.LuaGoFunction { + return func(L *golua.State) int { + key := L.ToString(1) + if err := ps.DeleteSecret(name, key); err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + L.PushBoolean(true) + L.PushNil() + return 2 + } +} + +// luaSecretKeys: keys() -> (array of key names, nil). Names only — a plugin can +// render "which accounts are configured" without ever echoing token bytes. +func luaSecretKeys(ps *Plugins, name string) golua.LuaGoFunction { + return func(L *golua.State) int { + keys, err := ps.SecretKeys(name) + if err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + L.NewTable() + for i, k := range keys { + L.PushString(k) + L.RawSeti(-2, i+1) + } + L.PushNil() + return 2 + } +} + +// registerKernelBridge installs the kernel action surface onto a plugin's state. +func registerKernelBridge(L *golua.State, ps *Plugins, name string) { + L.NewTable() + L.PushGoClosure(luaKernelActions(ps, name)) + L.SetField(-2, "actions") + L.PushGoClosure(luaKernelCall(ps, name)) + L.SetField(-2, "call") + L.SetGlobal(kernelGlobal) +} + +// luaKernelActions: actions() -> (array of {name,title,danger,description,params, +// granted}, nil) +// +// It reports GRANTED alongside each action so a plugin can degrade gracefully +// (skip the part that needs a permission it does not have) instead of +// discovering it by hitting an error mid-operation. +func luaKernelActions(ps *Plugins, name string) golua.LuaGoFunction { + return func(L *golua.State) int { + perm := ps.PluginKernelPermission(name) + L.NewTable() + for i, a := range KernelActions() { + L.NewTable() + L.PushString(a.Name) + L.SetField(-2, "name") + L.PushString(a.Title) + L.SetField(-2, "title") + L.PushString(a.Danger) + L.SetField(-2, "danger") + L.PushString(a.Description) + L.SetField(-2, "description") + L.PushBoolean(perm.allows(a)) + L.SetField(-2, "granted") + L.NewTable() + for j, p := range a.Params { + L.PushString(p) + L.RawSeti(-2, j+1) + } + L.SetField(-2, "params") + L.RawSeti(-2, i+1) + } + L.PushNil() + return 2 + } +} + +// luaKernelCall: call(name, args) -> (result, nil) | (nil, err) +// +// One entry point rather than a function per action: the action set will change, +// and a table field per action would mean editing bootPluginState — the exact +// place that produced a cgo crash during earlier work. +func luaKernelCall(ps *Plugins, name string) golua.LuaGoFunction { + return func(L *golua.State) int { + action := L.ToString(1) + args := map[string]interface{}{} + if L.Type(2) == golua.LUA_TTABLE { + var raw interface{} + if err := luaToJSON(L, 2, &raw); err == nil { + if m, ok := raw.(map[string]interface{}); ok { + args = m + } + } + } + v, err := ps.callKernel(name, action, args) + if err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + if v == nil { + L.PushNil() + } else { + pushGoValue(L, v) + } + L.PushNil() + return 2 + } +} + +// readPluginKernelDeclared reads `plugin.kernel`, the actions a plugin asks for. +// +// The shape is a list of names: +// +// plugin.kernel = { "status", "reset_health" } +// +// Unknown names are kept rather than dropped so the settings page can show a +// typo as "requested but not a real action" — silently discarding it would make +// a plugin's intent invisible at exactly the moment the operator is deciding +// what to grant. +func readPluginKernelDeclared(L *golua.State) []string { + raw, ok := pluginFieldValue(L, "kernel") + if !ok { + return nil + } + items, isList := raw.([]interface{}) + if !isList { + if one, isTable := raw.(map[string]interface{}); isTable { + // A single string field or a set-style table; accept both spellings. + if v, ok := one["actions"].([]interface{}); ok { + items = v + } else { + return nil + } + } else { + return nil + } + } + out := make([]string, 0, len(items)) + seen := map[string]bool{} + for _, it := range items { + name, ok := it.(string) + if !ok { + continue + } + name = strings.TrimSpace(name) + if name == "" || seen[name] { + continue + } + seen[name] = true + out = append(out, name) + } + sort.Strings(out) + return out +} + +// luaTableArg decodes a Lua table argument into a Go map, reporting a clear error +// when the caller passed something else (a common mistake that would otherwise +// surface as a silent zero value). +func luaTableArg(L *golua.State, idx int) (map[string]interface{}, error) { + if L.Type(idx) != golua.LUA_TTABLE { + return nil, fmt.Errorf("expected a table argument, got %s", normalizeString(L, idx)) + } + var raw interface{} + if err := luaToJSON(L, idx, &raw); err != nil { + return nil, err + } + m, ok := raw.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("argument must be a keyed table") + } + return m, nil +} diff --git a/internal/lua/plugin_http.go b/internal/lua/plugin_http.go index 167626d..bd34fea 100644 --- a/internal/lua/plugin_http.go +++ b/internal/lua/plugin_http.go @@ -245,4 +245,4 @@ func flattenHeaders(h http.Header) map[string]string { out[strings.ToLower(k)] = h.Get(k) } return out -} \ No newline at end of file +} diff --git a/internal/lua/plugin_http_args.go b/internal/lua/plugin_http_args.go index 6d05e9b..9f82c7e 100644 --- a/internal/lua/plugin_http_args.go +++ b/internal/lua/plugin_http_args.go @@ -119,4 +119,4 @@ func asFloat(v interface{}) (float64, bool) { default: return 0, false } -} \ No newline at end of file +} diff --git a/internal/lua/plugin_http_handler_test.go b/internal/lua/plugin_http_handler_test.go index 03444c2..0327f40 100644 --- a/internal/lua/plugin_http_handler_test.go +++ b/internal/lua/plugin_http_handler_test.go @@ -335,10 +335,10 @@ func TestHandlerResponseFramingIsKernelOwned(t *testing.T) { writeHTTPHandlerResponse(w, &HandlerResponse{ Status: 200, Body: "hello", Headers: map[string]string{ - "Content-Length": "99999", - "Connection": "close", + "Content-Length": "99999", + "Connection": "close", "Transfer-Encoding": "chunked", - "X-Custom": "kept", + "X-Custom": "kept", }, }) if got := w.Header().Get("Content-Length"); got != "" { @@ -398,6 +398,7 @@ func TestParseHandlerResponseShapes(t *testing.T) { t.Errorf("decoded %+v", r) } } + // TestEndpointAccessReportsPerEndpointOverride guards the reporting side of the // override store. // diff --git a/internal/lua/plugin_http_test.go b/internal/lua/plugin_http_test.go index 33f232c..e950466 100644 --- a/internal/lua/plugin_http_test.go +++ b/internal/lua/plugin_http_test.go @@ -149,6 +149,7 @@ func TestPluginHTTPErrorPathIsPairNotRaise(t *testing.T) { t.Fatal("a missing argument table must be an error, not a panic") } } + // The bridge must be reachable under the name plugins actually call. // // This exists because the registration once used the CONSTANT'S IDENTIFIER as diff --git a/internal/lua/plugin_outbound.go b/internal/lua/plugin_outbound.go new file mode 100644 index 0000000..edac2a6 --- /dev/null +++ b/internal/lua/plugin_outbound.go @@ -0,0 +1,152 @@ +package lua + +import ( + "fmt" + "sort" + "strings" + + golua "github.com/aarzilli/golua/lua" +) + +// Package lua — 插件额外出站主机的声明与批准 +// +// 内核的职责里有一条是"维护源"。源的 host 天然进出站白名单。但有些插件需要去 +// **源以外**的 host(比如一个账号平台的门户、领取额度的接口),这些 host 默认 +// 被白名单拒绝 —— 这是正确的默认:白名单的意义就是"插件只能去它该去的地方"。 +// +// 所以给出一个**通用**机制(不含任何"登录"概念——登录是插件自己的事): +// +// 插件在源码里声明 plugin.hosts = { "portal.example.com", ... } ← 请求 +// 运维在设置页逐个批准 ← 授权 +// +// 声明是请求,批准才是授权。为什么不给个"允许任意 host"的开关:一个能访问任意 +// host 的插件等于没有白名单。要求逐个列出,运维才有机会看出"这个插件为什么要 +// 访问一个我没听说过的域名"。 +// +// 内核在这里**只做白名单加减**,不认识这些 host 是用来登录、领额度还是别的 —— +// 那是插件的业务。 + +// PluginExtraHosts returns the extra outbound hosts a plugin declared. +func (ps *Plugins) PluginExtraHosts(plugin string) []string { + ps.mu.RLock() + defer ps.mu.RUnlock() + for _, p := range ps.plugins { + if p.Info.Name == plugin { + return append([]string(nil), p.ExtraHosts...) + } + } + return nil +} + +// GrantExtraHosts approves a set of outbound hosts for a plugin. +// +// Only hosts the plugin DECLARED may be granted: accepting an arbitrary host from +// the settings page would make the declaration decorative, and the point of the +// declaration is that the operator reviews a list the plugin itself committed to +// in source that is on disk and reviewable. +func (ps *Plugins) GrantExtraHosts(plugin string, hosts []string) error { + if ps.find(plugin) == nil { + return fmt.Errorf("plugin %s not found", plugin) + } + declared := map[string]bool{} + for _, h := range ps.PluginExtraHosts(plugin) { + declared[normalizeHost(h)] = true + } + clean := make([]string, 0, len(hosts)) + for _, h := range hosts { + hn := normalizeHost(h) + if hn == "" { + continue + } + if !declared[hn] { + return fmt.Errorf("plugin %s did not declare outbound host %q", plugin, hn) + } + clean = append(clean, hn) + } + sort.Strings(clean) + ps.mu.Lock() + if ps.extraHosts == nil { + ps.extraHosts = map[string][]string{} + } + if len(clean) == 0 { + delete(ps.extraHosts, plugin) + } else { + ps.extraHosts[plugin] = clean + } + ps.rebuildOutboundAllowlistLocked() + ps.saveKernelPermissionsLocked() + ps.mu.Unlock() + return nil +} + +// ExtraHostsGranted returns the approved hosts for each plugin (for the UI). +func (ps *Plugins) ExtraHostsGranted() map[string][]string { + ps.mu.RLock() + defer ps.mu.RUnlock() + out := make(map[string][]string, len(ps.extraHosts)) + for k, v := range ps.extraHosts { + out[k] = append([]string(nil), v...) + } + return out +} + +// normalizeHost lowercases a host and strips a port, so "Portal.Example.com:443" +// and "portal.example.com" cannot both be granted under different names. +func normalizeHost(h string) string { + h = strings.ToLower(strings.TrimSpace(h)) + if i := strings.LastIndex(h, ":"); i > 0 && !strings.Contains(h[i:], "]") { + // Leave IPv6 literals ("[::1]:443") alone apart from the port. + h = h[:i] + } + h = strings.Trim(h, "[]") + return h +} + +// rebuildOutboundAllowlistLocked folds the SOURCE hosts and every GRANTED extra +// host into the allowlist the HTTP bridge consults. Caller holds ps.mu. +// +// Declared-but-not-granted hosts are deliberately excluded: that exclusion is the +// whole security property, so it is the one thing this function must not be +// tempted to "helpfully" relax. +func (ps *Plugins) rebuildOutboundAllowlistLocked() { + hosts := append([]string(nil), ps.sourceHosts...) + for _, granted := range ps.extraHosts { + hosts = append(hosts, granted...) + } + ps.allowHTTP = newHTTPAllowlist(hosts) +} + +// readPluginExtraHosts reads `plugin.hosts`, the outbound hosts a plugin needs +// beyond the operator-configured sources. +// +// plugin.hosts = { "portal.example.com", "api.partner.example" } +// +// Bare hostnames, not URLs: the allowlist matches on host, and accepting a full +// URL would invite a plugin to smuggle a path or scheme the operator did not +// read. Entries are normalised so declared and granted forms compare equal. +func readPluginExtraHosts(L *golua.State) []string { + raw, ok := pluginFieldValue(L, "hosts") + if !ok { + return nil + } + items, isList := raw.([]interface{}) + if !isList { + return nil + } + out := make([]string, 0, len(items)) + seen := map[string]bool{} + for _, it := range items { + s, ok := it.(string) + if !ok { + continue + } + h := normalizeHost(s) + if h == "" || seen[h] { + continue + } + seen[h] = true + out = append(out, h) + } + sort.Strings(out) + return out +} diff --git a/internal/lua/plugin_runtime.go b/internal/lua/plugin_runtime.go new file mode 100644 index 0000000..aadbded --- /dev/null +++ b/internal/lua/plugin_runtime.go @@ -0,0 +1,499 @@ +// Package lua — 插件长期运行环境(R4-b / Phase 4) +// +// 用户需求:插件需要**长期运行环境**,以支持自动签到、token 轮换等需要 +// "没有浏览器、没有人操作时也会发生"的能力。 +// +// 模型(重要):**一个插件 = 一个 Lua VM = 一个调度器**。 +// +// 钩子(请求路径)和定时任务(内核 ticker 驱动)跑在**同一个** Lua 状态上, +// 由同一把锁串行化。这不是实现细节,而是正确性要求: +// +// 定时任务 refresh_token 写 plugin.state.token +// 请求钩子 读 plugin.state.token +// +// 两者必须是同一个 Lua 状态,否则任务把 token 写进了一个请求永远读不到的 +// 副本 —— 轮换看起来"成功了",实际对所有流量无效。同理,任务的写入必须 +// 经过同一个 plugin.state,才能被状态持久化(markDirty)看到。 +// +// 代价(已知且接受):某个插件的长耗时任务(网络等待)会排在它**自己**的 +// 钩子前面,因为同一个 Lua 栈一次只能有一个调用者。跨插件仍然是并行的 +// (每个插件各自一把锁、各自一个 VM)。 +// +// 声明方式(插件侧): +// +// PLUGIN.schedule = { +// { fn = "refresh_token", every = 3600 }, -- 每小时 +// { fn = "daily_checkin", every = 86400, at = "03:30" }, -- 每天固定时刻 +// { fn = "poll_quota", every = 300, jitter = 30 }, -- 带抖动 +// } +// +// 崩溃隔离:任务函数出错只记 hookErr,不影响钩子与请求转发。但一个**永不返回** +// 的任务会永久持有该插件的锁(Lua 调用无法中断,见 checkWedged 的能力说明), +// 因此超时后该插件被**停用**而不是"换一个 VM 重来"—— 换 VM 会把累计状态 +// (计费总额、已签到的天数)一起丢掉,那比停用更糟。 +package lua + +import ( + "errors" + "fmt" + "log" + "sync" + "time" + + golua "github.com/aarzilli/golua/lua" +) + +// ScheduledTask is one declared periodic job. +type ScheduledTask struct { + // Fn is the plugin function name to invoke. + Fn string + // Every is the interval in seconds. 0 disables the task. + Every int `json:"every"` + // At is an optional daily local time "HH:MM"; when set, Every is ignored + // and the task fires once per day at that wall-clock time. + At string `json:"at,omitempty"` + // Jitter, in seconds, spreads the first fire randomly so many plugins (or + // many replicas) do not all hit the same upstream at :00. + Jitter int `json:"jitter,omitempty"` + // Timeout is parsed for forward compatibility but is NOT enforced: a running + // Lua call cannot be interrupted in this LuaJIT build (see the capability + // note on checkWedged). Overrun is handled by disarming the plugin at the + // wedgedAfter threshold instead. + Timeout int `json:"timeout,omitempty"` +} + +// scheduleSpec is the parsed `PLUGIN.schedule` table. +type scheduleSpec struct { + tasks []ScheduledTask +} + +// parseSchedule extracts the schedule from a loaded plugin table. It is read +// from the already-compiled state's global, not by re-running the script, so a +// plugin's schedule is available the moment it loads. +func parseSchedule(L *golua.State) scheduleSpec { + var spec scheduleSpec + if L == nil { + return spec + } + L.GetGlobal(pluginGlobal) + if L.IsNil(-1) { + L.Pop(1) + return spec + } + if !getFieldBatch(L, "schedule") { + L.Pop(1) + return spec + } + // schedule is an array of tables + if L.Type(-1) != golua.LUA_TTABLE { + L.Pop(2) + return spec + } + arr := L.GetTop() + idx := 1 + for { + // golua exposes indexed table access through RawGeti; GetField only + // takes a string key. + L.RawGeti(arr, idx) + if L.IsNil(-1) { + L.Pop(1) + break + } + if L.Type(-1) == golua.LUA_TTABLE { + var t ScheduledTask + t.Fn = readString(L, -1, "fn") + t.Every = readNumberInt(L, -1, "every") + t.At = readString(L, -1, "at") + t.Jitter = readNumberInt(L, -1, "jitter") + t.Timeout = readNumberInt(L, -1, "timeout") + if t.Fn != "" && (t.Every > 0 || t.At != "") { + spec.tasks = append(spec.tasks, t) + } + } + L.Pop(1) + idx++ + } + L.Pop(2) // schedule table + plugin global + return spec +} + +// PluginRuntime owns one plugin's periodic work. +// +// It does NOT own a Lua state. Tasks run on the plugin's own VM (Plugin.state), +// under the plugin's own lock, exactly like a request hook — that is what makes +// a token rotated by a task immediately visible to inference traffic, and what +// makes the task's writes persist through the same state saver. +type PluginRuntime struct { + plugin *Plugin + // ps is the owning plugin set, needed to record task failures on the same + // hook_errors surface the request path uses. + ps *Plugins + spec scheduleSpec + + mu sync.Mutex + // nextDue is when each task should next fire. + nextDue map[string]time.Time + // running marks a task whose goroutine has been dispatched but has not yet + // finished (it may still be waiting for the plugin lock). + running map[string]bool + // startedAt is set once the Lua call actually BEGINS (i.e. after the plugin + // lock was acquired). Measuring from dispatch would count time spent queued + // behind a slow task as if it were this task's own runtime. + startedAt map[string]time.Time + // failures counts consecutive failures per task. + failures map[string]int + // active counts task goroutines that have not returned. + active int + // disarmed is set once a task has overrun wedgedAfter. Scheduling stops and + // hook dispatch fails fast, so a plugin wedged by its own task degrades to + // "this plugin is out" rather than "requests hang forever behind its lock". + disarmed bool + + stop chan struct{} + done chan struct{} +} + +// wedgedAfter is how long a task may run before the plugin is declared wedged. +// +// It is generous because the normal case IS a slow network call: check-in +// endpoints and token refreshes routinely take seconds to tens of seconds. +var wedgedAfter = 10 * time.Minute + +// errPluginWedged is returned to hooks of a plugin whose scheduled task +// overran. It is a real error rather than a silent skip so the operator sees +// WHICH plugin stopped serving and why (it lands in hook_errors). +var errPluginWedged = errors.New("a scheduled task overran and disabled this plugin; reload it to retry") + +// startRuntime attaches a scheduler to a plugin that declared one. +// +// The scheduler runs on p.state — the SAME state a hook runs on. A plugin with +// no schedule gets no runtime at all, so the common case stays exactly as cheap +// as before: one VM, no extra goroutine. +func (ps *Plugins) startRuntime(p *Plugin, spec scheduleSpec) *PluginRuntime { + if len(spec.tasks) == 0 { + return nil + } + rt := &PluginRuntime{ + plugin: p, + ps: ps, + spec: spec, + nextDue: make(map[string]time.Time, len(spec.tasks)), + running: make(map[string]bool, len(spec.tasks)), + startedAt: make(map[string]time.Time, len(spec.tasks)), + failures: make(map[string]int, len(spec.tasks)), + stop: make(chan struct{}), + done: make(chan struct{}), + } + for _, t := range spec.tasks { + rt.nextDue[t.Fn] = rt.firstDue(t) + } + go rt.loop() + return rt +} + +// firstDue computes when a task first runs. Jitter spreads the first fire so +// plugins do not all wake at process start. +func (rt *PluginRuntime) firstDue(t ScheduledTask) time.Time { + now := time.Now() + if t.At != "" { + return nextDailyOccurrence(t.At, now) + } + d := time.Duration(t.Every) * time.Second + if d <= 0 { + d = time.Hour + } + if j := time.Duration(t.Jitter) * time.Second; j > 0 { + d += time.Duration(randInt63n(int64(j))) + } + return now.Add(d) +} + +// nextDailyOccurrence returns the next time "HH:MM" passes after now (today if +// it has not yet passed, else tomorrow). +func nextDailyOccurrence(at string, now time.Time) time.Time { + var hh, mm int + if _, err := fmt.Sscanf(at, "%d:%d", &hh, &mm); err != nil || hh < 0 || hh > 23 || mm < 0 || mm > 59 { + // A malformed time must not wedge the task forever; fall back to hourly. + return now.Add(time.Hour) + } + t := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, now.Location()) + if !t.After(now) { + t = t.Add(24 * time.Hour) + } + return t +} + +// runtimeTick is the scheduler's resolution. A var so tests can shorten it +// without waiting whole seconds; production always uses 1s. +var runtimeTick = time.Second + +// loop is the scheduler goroutine: one ticker drives every task of the plugin. +func (rt *PluginRuntime) loop() { + defer close(rt.done) + tick := time.NewTicker(runtimeTick) + defer tick.Stop() + for { + select { + case <-rt.stop: + return + case <-tick.C: + rt.checkWedged() + rt.runDue() + } + } +} + +// checkWedged disarms a plugin whose task has overrun wedgedAfter. +// +// Capability note (measured, not assumed): a running Lua call CANNOT be +// interrupted in this golua+LuaJIT build. +// +// 1. SetExecutionLimit(200000) against a tight loop — still running after 10s. +// 2. A count hook + RaiseError against a tight loop — the hook never fired +// (LuaJIT compiles the loop to machine code; instruction hooks only fire on +// the interpreter path). +// 3. A count hook against a loop with calls/table writes — fired 20971 times, +// but RaiseError still did not return: it calls L.StackTrace(), which is the +// known SIGSEGV path in vm.go. +// +// So the response cannot be "stop it". It is: stop trusting it, stop scheduling +// more work, and make the condition visible. The state is deliberately NOT +// rebuilt — a fresh VM would discard exactly the accumulated state (billing +// totals, check-in history) that makes the plugin worth running. +func (rt *PluginRuntime) checkWedged() { + rt.mu.Lock() + if rt.disarmed { + rt.mu.Unlock() + return + } + var wedged []string + for fn, at := range rt.startedAt { + if time.Since(at) >= wedgedAfter { + wedged = append(wedged, fn) + } + } + if len(wedged) == 0 { + rt.mu.Unlock() + return + } + rt.disarmed = true + rt.mu.Unlock() + + for _, fn := range wedged { + ps := rt.ps + if ps != nil { + ps.hookErr.note(StageRequestEnd, fmt.Sprintf("%s: task %s overran %s; plugin disabled", + rt.pluginInfoName(), fn, wedgedAfter)) + } + log.Printf("[scheduler] plugin %s task %s exceeded %s; disabling the plugin "+ + "(reload it to retry)", rt.pluginInfoName(), fn, wedgedAfter) + } +} + +// runDue dispatches every task whose due time has arrived. Each task runs in its +// own goroutine so that one task waiting on the plugin lock does not delay the +// due time of another — the lock, not the scheduler, is what serialises them. +func (rt *PluginRuntime) runDue() { + rt.mu.Lock() + if rt.disarmed { + rt.mu.Unlock() + return + } + now := time.Now() + var due []ScheduledTask + for _, t := range rt.spec.tasks { + if rt.running[t.Fn] { + continue // still executing; skip this cycle + } + if now.Before(rt.nextDue[t.Fn]) { + continue + } + rt.running[t.Fn] = true + rt.active++ + rt.nextDue[t.Fn] = rt.nextAfter(t, now) + due = append(due, t) + } + rt.mu.Unlock() + for _, t := range due { + go rt.runOne(t) + } +} + +// nextAfter computes the following due time for a task that just fired. +func (rt *PluginRuntime) nextAfter(t ScheduledTask, now time.Time) time.Time { + if t.At != "" { + return nextDailyOccurrence(t.At, now) + } + d := time.Duration(t.Every) * time.Second + if d <= 0 { + d = time.Hour + } + return now.Add(d) +} + +// runOne executes one task on the plugin's own VM, under the plugin's own lock. +// +// Holding that lock for the whole call is what makes state sharing correct — and +// it is why a task that never returns wedges its own plugin. That trade is +// deliberate: the alternative (a private VM per task) silently breaks the state +// sharing this feature exists for. +func (rt *PluginRuntime) runOne(t ScheduledTask) { + p := rt.plugin + defer func() { + rt.mu.Lock() + delete(rt.running, t.Fn) + delete(rt.startedAt, t.Fn) + rt.active-- + rt.mu.Unlock() + }() + + if p == nil { + rt.recordFailure(t.Fn, "plugin is gone") + return + } + + p.mu.Lock() + defer p.mu.Unlock() + if p.state == nil || p.state.L == nil { + rt.recordFailure(t.Fn, "no plugin state") + return + } + + // Recorded only now: the time before the lock was acquired was spent queued + // behind another caller, and counting it would report a healthy hook as a + // wedged task. + rt.mu.Lock() + rt.startedAt[t.Fn] = time.Now() + rt.mu.Unlock() + + // No interrupt is attempted; see the capability note on checkWedged. + if err := callFnOn(p.state.L, t.Fn); err != nil { + rt.recordFailure(t.Fn, err.Error()) + return + } + // A task mutates plugin.state exactly as a hook does, so it must dirty the + // same way — otherwise a rotated token would live only in memory until some + // unrelated request happened to flush it. + if ps := rt.ps; ps != nil { + ps.markDirtyLocked(p) + } + + rt.mu.Lock() + rt.failures[t.Fn] = 0 + rt.mu.Unlock() +} + +// recordFailure notes a task error so a silently broken task cannot hide. +func (rt *PluginRuntime) recordFailure(fn, msg string) { + rt.mu.Lock() + rt.failures[fn]++ + rt.mu.Unlock() + if ps := rt.ps; ps != nil { + ps.hookErr.note(StageRequestEnd, fmt.Sprintf("%s: task %s failed: %s", + rt.pluginInfoName(), fn, msg)) + } + log.Printf("[scheduler] plugin %s task %s failed: %s", rt.pluginInfoName(), fn, msg) +} + +func (rt *PluginRuntime) pluginInfoName() string { + if rt.plugin == nil { + return "?" + } + return rt.plugin.Info.Name +} + +// InFlight reports whether any task goroutine has not yet returned. Unload uses +// it to decide whether closing the Lua state is safe: a task holds the state for +// the duration of its call, and a wedged one never releases it, so closing +// underneath would be a use-after-free in the Lua C layer (which aborts the +// process rather than returning an error). Skipping the close leaks that one +// state instead — bounded by the wedge, and strictly better than crashing. +func (rt *PluginRuntime) InFlight() bool { + if rt == nil { + return false + } + rt.mu.Lock() + defer rt.mu.Unlock() + return rt.active > 0 +} + +// Disarmed reports whether the plugin was disabled by an overrunning task. +func (rt *PluginRuntime) Disarmed() bool { + if rt == nil { + return false + } + rt.mu.Lock() + defer rt.mu.Unlock() + return rt.disarmed +} + +// Stop halts the scheduler. It does NOT wait for in-flight tasks: a wedged task +// will never return, and Stop must not block unload forever. +func (rt *PluginRuntime) Stop() { + if rt == nil { + return + } + close(rt.stop) + <-rt.done +} + +// callFnOn invokes a plugin function on an already-locked state. +// +// The stack discipline mirrors Plugins.invoke: [global, guard, fn] then call +// with one argument. A return value other than an error string is discarded — +// a scheduled task's output is its side effect (a token written, an adapter +// updated), not a value the scheduler forwards anywhere. +func callFnOn(L *golua.State, fn string) error { + L.SetTop(0) + defer L.SetTop(0) + L.GetGlobal(pluginGlobal) + if L.IsNil(-1) { + return fmt.Errorf("plugin table missing") + } + plug := L.GetTop() + L.GetGlobal(hookGuardName) + if L.IsNil(-1) { + L.Pop(1) + } + L.GetField(plug, fn) + if !L.IsFunction(-1) { + return fmt.Errorf("task %s missing", fn) + } + L.PushString("{}") + if err := L.Call(1, 2); err != nil { + return err + } + if L.GetTop() >= 2 && !L.IsNil(-1) { + msg := L.ToString(-1) + L.Pop(1) + if msg != "" { + return fmt.Errorf("%s", msg) + } + } + return nil +} + +// randInt63n returns a pseudo-random value in [0,n) for schedule jitter. Quality +// is irrelevant here — it only needs to differ across processes. +func randInt63n(n int64) int64 { + if n <= 0 { + return 0 + } + seed := time.Now().UnixNano() ^ int64(n*2654435761) + x := uint64(seed) + x ^= x << 13 + x ^= x >> 7 + x ^= x << 17 + return int64(x % uint64(n)) +} + +// readNumberInt reads a numeric field as an int. Missing/zero yields 0, which +// every caller treats as "not set" (a task with every<=0 and no at is disabled). +func readNumberInt(L *golua.State, idx int, field string) int { + L.GetField(idx, field) + defer L.Pop(1) + if L.Type(-1) != golua.LUA_TNUMBER { + return 0 + } + return int(L.ToNumber(-1)) +} diff --git a/internal/lua/plugin_runtime_test.go b/internal/lua/plugin_runtime_test.go new file mode 100644 index 0000000..b2ec628 --- /dev/null +++ b/internal/lua/plugin_runtime_test.go @@ -0,0 +1,371 @@ +package lua + +import ( + "fmt" + "testing" + "time" +) + +// 本文件验证 R4-b 的核心主张:**插件声明的周期任务在没有任何浏览器、 +// 没有任何外部触发时会自己执行**,并且跑在**插件自己的那个 VM** 上。 +// +// 第二点是这个文件最重要的断言。早期实现给定时任务开了一个私有 VM,结果 +// 任务写 plugin.state 时写进了一个请求路径永远读不到的副本 —— token 轮换 +// 看起来成功了,对实际流量完全无效。TestTaskWriteIsVisibleToHooks 锁死这一点。 + +func shortTick(t *testing.T) { + t.Helper() + old := runtimeTick + runtimeTick = 10 * time.Millisecond + t.Cleanup(func() { runtimeTick = old }) +} + +// runtimePlugins 建一个带插件目录的 Plugins。 +func runtimePlugins(t *testing.T) (*VM, *Plugins, string) { + t.Helper() + dir := fmt.Sprintf("%s/adapters", t.TempDir()) + vm := NewVM(dir) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(vm.Stop) + pdir := fmt.Sprintf("%s/plugins", t.TempDir()) + ps := NewPlugins(vm, pdir) + ps.SetOutboundHosts([]string{"127.0.0.1"}) + return vm, ps, pdir +} + +// runtimeOf 取某个插件的 Runtime,最多等 wait。 +func runtimeOf(t *testing.T, ps *Plugins, name string, wait time.Duration) *PluginRuntime { + t.Helper() + deadline := time.Now().Add(wait) + for time.Now().Before(deadline) { + ps.mu.RLock() + for _, p := range ps.plugins { + if p.Info.Name == name { + rt := p.Runtime + ps.mu.RUnlock() + if rt != nil { + return rt + } + goto sleep + } + } + ps.mu.RUnlock() + sleep: + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("插件 %s 未获得运行时", name) + return nil +} + +// TestScheduledTaskFiresWithoutAnyTrigger 是主断言。 +// +// 插件声明一个极短周期的任务,测试**不做任何外部调用**,只等 —— 任务必须自己跑。 +func TestScheduledTaskFiresWithoutAnyTrigger(t *testing.T) { + shortTick(t) + _, ps, _ := runtimePlugins(t) + + code := ` +local plugin = { name = "checkin", version = "1.0" } +plugin.state = { fired = 0 } +plugin.schedule = { { fn = "do_checkin", every = 1 } } +function plugin.do_checkin() + plugin.state.fired = plugin.state.fired + 1 +end +return plugin +` + if err := ps.LoadSource("checkin", code); err != nil { + t.Fatalf("load: %v", err) + } + rt := runtimeOf(t, ps, "checkin", 3*time.Second) + + // 只等,不触发任何东西。 + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + rt.mu.Lock() + n := rt.failures["do_checkin"] + rt.mu.Unlock() + if n > 0 { + t.Fatalf("任务执行失败 %d 次", n) + } + // 用插件的状态作为"真的跑了"的证据。 + if st, ok := ps.State("checkin").(map[string]interface{}); ok { + if f, ok := st["fired"].(float64); ok && f >= 1 { + return // 通过:没有任何外部触发,任务自己跑了 + } + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("任务在没有任何外部触发的情况下没有执行") +} + +// TestTaskWriteIsVisibleToHooks 是这个模型的关键正确性断言。 +// +// 定时任务写 plugin.state.token,请求路径(同一个 VM 上的钩子)必须能读到 +// 它。早期"每个任务一个私有 VM"的实现会在这里失败 —— 任务写进副本,钩子 +// 看到的还是旧值,token 轮换静默失效。 +func TestTaskWriteIsVisibleToHooks(t *testing.T) { + shortTick(t) + _, ps, _ := runtimePlugins(t) + code := ` +local plugin = { name = "rotator", version = "1.0" } +plugin.state = { token = "OLD" } +plugin.schedule = { { fn = "rotate", every = 1 } } +function plugin.rotate() + plugin.state.token = "NEW" +end +plugin.hooks = { routed = "read_token" } +function plugin.read_token(payload) + payload.token = plugin.state.token -- 请求路径读到的值 + return payload +end +return plugin +` + if err := ps.LoadSource("rotator", code); err != nil { + t.Fatalf("load: %v", err) + } + runtimeOf(t, ps, "rotator", 3*time.Second) + + // 等任务把 token 轮换掉。 + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + out := ps.Fire(StageRouted, map[string]interface{}{}) + if got, _ := out["token"].(string); got == "NEW" { + return // 通过:钩子读到了任务写进去的值 —— 同一个 VM + } + time.Sleep(20 * time.Millisecond) + } + out := ps.Fire(StageRouted, map[string]interface{}{}) + t.Fatalf("定时任务写的 token 对请求钩子不可见(钩子读到 %v)—— 说明任务跑在了另一个 VM 上", out["token"]) +} + +// TestNoScheduleMeansNoRuntime:没声明 schedule 就不该有额外 VM/goroutine。 +func TestNoScheduleMeansNoRuntime(t *testing.T) { + _, ps, _ := runtimePlugins(t) + code := ` +local plugin = { name = "plain", version = "1.0" } +return plugin +` + if err := ps.LoadSource("plain", code); err != nil { + t.Fatal(err) + } + ps.mu.RLock() + defer ps.mu.RUnlock() + for _, p := range ps.plugins { + if p.Info.Name == "plain" && p.Runtime != nil { + t.Error("未声明 schedule 的插件不应有运行时") + } + } +} + +// TestScheduleParsing:every / at / jitter / timeout 解析。 +func TestScheduleParsing(t *testing.T) { + _, ps, _ := runtimePlugins(t) + code := ` +local plugin = { name = "sched", version = "1.0" } +plugin.schedule = { + { fn = "a", every = 60 }, + { fn = "b", at = "03:30" }, + { fn = "c", every = 300, jitter = 30 }, + { fn = "d" }, -- 无 every 无 at → 丢弃 + { every = 10 }, -- 无 fn → 丢弃 +} +return plugin +` + if err := ps.LoadSource("sched", code); err != nil { + t.Fatal(err) + } + ps.mu.RLock() + var sched []ScheduledTask + for _, p := range ps.plugins { + if p.Info.Name == "sched" { + sched = p.Schedule + } + } + ps.mu.RUnlock() + if len(sched) != 3 { + t.Fatalf("应解析出 3 个任务(a/b/c),实得 %d: %+v", len(sched), sched) + } + if sched[0].Every != 60 || sched[1].At != "03:30" || sched[2].Jitter != 30 { + t.Errorf("字段解析错误: %+v", sched) + } +} + +// TestDailyAtNextOccurrence:at="HH:MM" 的下一次触发时刻。 +func TestDailyAtNextOccurrence(t *testing.T) { + now := time.Date(2026, 10, 11, 12, 0, 0, 0, time.Local) + // 今天还没到 → 今天 + if got := nextDailyOccurrence("23:00", now); got.Day() != 11 || got.Hour() != 23 { + t.Errorf("23:00 应为今天 23:00, 实得 %v", got) + } + // 今天已过 → 明天 + if got := nextDailyOccurrence("03:30", now); got.Day() != 12 || got.Hour() != 3 { + t.Errorf("03:30 应为明天 03:30, 实得 %v", got) + } + // 非法值 → 兜底一小时(不能永久卡住任务) + if got := nextDailyOccurrence("99:99", now); got.Sub(now) > 2*time.Hour { + t.Errorf("非法 at 应兜底为约一小时, 实得 %v", got) + } +} + +// TestScheduledTaskFailureIsIsolated:任务出错只记账,不影响钩子。 +func TestScheduledTaskFailureIsIsolated(t *testing.T) { + shortTick(t) + _, ps, _ := runtimePlugins(t) + code := ` +local plugin = { name = "flaky", version = "1.0" } +plugin.schedule = { { fn = "boom", every = 1 } } +function plugin.boom() error("任务故意失败") end +return plugin +` + if err := ps.LoadSource("flaky", code); err != nil { + t.Fatal(err) + } + rt := runtimeOf(t, ps, "flaky", 3*time.Second) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + rt.mu.Lock() + n := rt.failures["boom"] + rt.mu.Unlock() + if n > 0 { + // 失败必须被记账,且插件未被熔断(错误 ≠ 卡死) + if rt.Disarmed() { + t.Error("普通错误不应熔断插件") + } + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("任务错误未被记账") +} + +// TestUnloadStopsRuntime:卸载后调度器 goroutine 必须停止。 +func TestUnloadStopsRuntime(t *testing.T) { + shortTick(t) + _, ps, _ := runtimePlugins(t) + code := ` +local plugin = { name = "stoppable", version = "1.0" } +plugin.state = { n = 0 } +plugin.schedule = { { fn = "tick", every = 1 } } +function plugin.tick() plugin.state.n = plugin.state.n + 1 end +return plugin +` + if err := ps.LoadSource("stoppable", code); err != nil { + t.Fatal(err) + } + rt := runtimeOf(t, ps, "stoppable", 3*time.Second) + if err := ps.Unload("stoppable"); err != nil { + t.Fatal(err) + } + // Stop 已返回 ⇒ done 已关闭 ⇒ loop 已退出 + select { + case <-rt.done: + default: + t.Error("卸载后调度器 loop 未退出") + } +} + +// TestWedgedTaskDisarmsPlugin:任务超时后插件被停用(不重建 VM —— 重建会丢掉 +// 累计状态,那正是这个插件存在的意义)。 +func TestWedgedTaskDisarmsPlugin(t *testing.T) { + shortTick(t) + oldWedged := wedgedAfter + wedgedAfter = 50 * time.Millisecond + t.Cleanup(func() { wedgedAfter = oldWedged }) + + _, ps, _ := runtimePlugins(t) + code := ` +local plugin = { name = "wedger", version = "1.0" } +plugin.schedule = { { fn = "spin", every = 3600 } } +function plugin.spin() local i=0 while true do i=i+1 end end +return plugin +` + if err := ps.LoadSource("wedger", code); err != nil { + t.Fatal(err) + } + rt := runtimeOf(t, ps, "wedger", 3*time.Second) + // 手动跑一次会卡死的任务(它会拿到 p.mu 并永不返回) + go rt.runOne(ScheduledTask{Fn: "spin", Every: 3600}) + + // 等 checkWedged 判定并停用 + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if rt.Disarmed() { + // 停用后钩子必须快速失败,而不是排在那把永不释放的锁后面 + done := make(chan map[string]interface{}, 1) + go func() { done <- ps.Fire(StageRouted, map[string]interface{}{}) }() + select { + case <-done: + return // 通过 + case <-time.After(2 * time.Second): + t.Fatal("插件停用后钩子仍然挂起 —— 应快速失败") + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("卡死的任务未导致插件停用") +} + +// TestNormalTaskNotDisarmed:正常任务不会被误判为卡死。 +func TestNormalTaskNotDisarmed(t *testing.T) { + shortTick(t) + _, ps, _ := runtimePlugins(t) + code := ` +local plugin = { name = "healthy", version = "1.0" } +plugin.state = { n = 0 } +plugin.schedule = { { fn = "q", every = 1 } } +function plugin.q() plugin.state.n = plugin.state.n + 1 end +return plugin +` + if err := ps.LoadSource("healthy", code); err != nil { + t.Fatal(err) + } + rt := runtimeOf(t, ps, "healthy", 3*time.Second) + time.Sleep(600 * time.Millisecond) + if rt.Disarmed() { + t.Error("健康任务不应导致插件停用") + } + rt.mu.Lock() + fails := rt.failures["q"] + rt.mu.Unlock() + if fails != 0 { + t.Errorf("健康任务不应失败: %d", fails) + } +} + +// TestConcurrentScheduledTasksStress:多个插件各自调度,互不干扰。 +func TestConcurrentScheduledTasksStress(t *testing.T) { + shortTick(t) + _, ps, _ := runtimePlugins(t) + for i := 0; i < 8; i++ { + name := fmt.Sprintf("stress%d", i) + code := ` +local plugin = { name = "` + name + `", version = "1.0" } +plugin.state = { n = 0 } +plugin.schedule = { { fn = "tick", every = 1 } } +function plugin.tick() plugin.state.n = plugin.state.n + 1 end +return plugin +` + if err := ps.LoadSource(name, code); err != nil { + t.Fatalf("%s: %v", name, err) + } + } + // every=1 means the FIRST fire is one second out (firstDue = now+1s), so the + // wait must exceed that; 700ms observed nothing. + time.Sleep(1600 * time.Millisecond) + // 每个插件都自己跑起来了(跨插件并行、各自一个 VM) + advanced := 0 + for i := 0; i < 8; i++ { + name := fmt.Sprintf("stress%d", i) + if st, ok := ps.State(name).(map[string]interface{}); ok { + if n, ok := st["n"].(float64); ok && n >= 1 { + advanced++ + } + } + } + if advanced == 0 { + t.Fatal("没有任何插件调度任务执行") + } + t.Logf("%d/8 个插件的任务已执行", advanced) +} diff --git a/internal/lua/plugin_secrets.go b/internal/lua/plugin_secrets.go new file mode 100644 index 0000000..aa77237 --- /dev/null +++ b/internal/lua/plugin_secrets.go @@ -0,0 +1,325 @@ +package lua + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Package lua — 插件凭据保险库(R4-d) +// +// 用户点明的能力:插件**自己主导外部源的链接认证、自动领取额度**。这类插件 +// (对照 dsh-our-free-model 的渠道插件形态)要长期持有 OAuth token、session、 +// 设备指纹等凭据,并在定时任务里拿去换额度、换上游。 +// +// 在保险库之前,插件唯一的持久化落点是 plugin.state,而它有两个问题: +// +// 1. **权限**:.state.json 是业务累计量(计费总额、签到天数),和凭据混在同一个 +// 文件里。它没有 0600,落在插件目录(0755)下 —— 存 token 等于把凭据摊开。 +// 2. **语义**:state 会被 PUT /api/plugins/{name}/state 整体替换,会被 +// `prices` 分离逻辑触碰,会进 WebUI。凭据进去就有可能被一次"重置状态" +// 或一次 UI 回显带走。 +// +// 保险库把凭据隔离成独立文件: +// +// /.secrets/.json 权限 0600,目录 0700 +// +// 与状态分离,不参与 UI 回显,不参与 state 替换。 +// +// # 谁能读 +// +// 只有该插件自己(按启动时的固定名字)。跨插件读会被拒 —— 记名方式是插件名 +// 而非任意引用,因为参考插件的 `credentialRef` 模式(任意字符串引用)在这里 +// 会变成一个可以指向别人凭据的键空间。 +// +// # 与内核动作的关系 +// +// 凭据本身不进 Lua 的返回值上限之外的地方:取出后由插件用于自己的 HTTP 调用。 +// 保险库不把凭据交给内核,内核也不需要读它。 + +// secretsDirName is the vault directory inside plugin_dir. The leading dot keeps +// it out of a casual `ls` of the plugin directory, which is where operators look +// for "what plugins do I have" — the vault is not a plugin. +// secretSealer 是保险库落盘加密所需的最小能力面。 +// +// 不直接 import config.SecretBox 的原因与 KernelAPI 相同:保持依赖注入的缝, +// 测试可以用桩;core 侧把 *config.SecretBox 注进来(Encrypt/Decrypt 语义与 +// config.yaml 的凭据完全一致 —— 同一把 master key、同一种 enc:v1 前缀)。 +type secretSealer interface { + Encrypt(plain string) (string, error) + Decrypt(v string) (string, error) +} + +// SetSecretBox 注入落盘加密器。必须在任何插件写凭据之前调用(core 启动序里 +// 在 NewPlugins 之后立即做)。传 nil 合法(=不加密,测试环境)。 +func (ps *Plugins) SetSecretBox(box secretSealer) { + ps.mu.Lock() + ps.secretBox = box + ps.mu.Unlock() +} + +const secretsDirName = ".secrets" + +// secretsDir returns the vault directory (empty when there is no plugin dir, in +// which case the vault is disabled — same posture as state persistence). +func (ps *Plugins) secretsDir() string { + if ps.dir == "" { + return "" + } + return filepath.Join(ps.dir, secretsDirName) +} + +// secretFile returns one plugin's vault path. +func (ps *Plugins) secretFile(plugin string) string { + d := ps.secretsDir() + if d == "" { + return "" + } + return filepath.Join(d, plugin+".json") +} + +// validateSecretKey rejects keys that could not round-trip through the vault. +// +// The vault is a flat string->string map serialized as JSON, so a key only needs +// to be non-empty and not absurdly long. Traversal is not a concern because keys +// are never used as path components; the FILE name comes from the plugin name, +// which is already validated when the plugin loads. +func validateSecretKey(key string) error { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("secret key must not be empty") + } + if len(key) > 200 { + return fmt.Errorf("secret key too long (%d chars, max 200)", len(key)) + } + return nil +} + +// loadSecretsLocked reads one plugin's vault. Caller holds ps.mu. +// +// A missing file means "nothing stored yet" — the normal first-run state. A +// corrupt file returns an error rather than an empty map: silently treating +// unreadable credentials as absent would make a plugin re-login and overwrite +// whatever the file still held, which is how a recoverable parse problem becomes +// a lost session. +func (ps *Plugins) loadSecretsLocked(plugin string) (map[string]string, error) { + path := ps.secretFile(plugin) + if path == "" { + return map[string]string{}, nil + } + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return map[string]string{}, nil + } + if err != nil { + return nil, err + } + m := map[string]string{} + if len(b) == 0 { + return m, nil + } + if err := json.Unmarshal(b, &m); err != nil { + return nil, fmt.Errorf("vault for plugin %s is unreadable: %w", plugin, err) + } + // Decrypt each value. Decrypt passes values without the enc:v1 prefix through + // unchanged, so vaults written by an older plaintext build keep loading — + // and get re-sealed on the next write (SetSecret rewrites the whole file). + if ps.secretBox != nil { + for k, v := range m { + plain, err := ps.secretBox.Decrypt(v) + if err != nil { + return nil, fmt.Errorf("vault for plugin %s: key %q: %w (master key changed?)", plugin, k, err) + } + m[k] = plain + } + } + return m, nil +} + +// writeSecretsLocked persists one plugin's vault with 0600. +// +// The mode is set on the temp file BEFORE the rename, so the secret is never +// visible under a looser mode at any instant — a chmod-after-write would leave a +// window where the token sits in a world-readable file. +func (ps *Plugins) writeSecretsLocked(plugin string, m map[string]string) error { + path := ps.secretFile(plugin) + if path == "" { + return fmt.Errorf("no plugin directory configured; the credential vault is disabled") + } + dir := ps.secretsDir() + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + // Tighten an existing directory too: MkdirAll is a no-op when it exists, so a + // vault created before this mode was enforced would keep its old permissions. + _ = os.Chmod(dir, 0o700) + + // Encrypt-at-rest: each VALUE is sealed with the deployment's master key + // (same enc:v1 form as config.yaml credentials). Keys stay plaintext so the + // file stays diffable/debuggable without exposing token bytes. A nil box + // writes plaintext — tests only; core always injects a real box. + sealed := make(map[string]string, len(m)) + for k, v := range m { + if ps.secretBox != nil { + enc, err := ps.secretBox.Encrypt(v) + if err != nil { + return fmt.Errorf("seal secret %q: %w", k, err) + } + sealed[k] = enc + } else { + sealed[k] = v + } + } + b, err := json.MarshalIndent(sealed, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(dir, plugin+".*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + cleanup := func() { _ = os.Remove(tmpName) } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + cleanup() + return err + } + if _, err := tmp.Write(b); err != nil { + tmp.Close() + cleanup() + return err + } + if err := tmp.Close(); err != nil { + cleanup() + return err + } + if err := os.Rename(tmpName, path); err != nil { + cleanup() + return err + } + return nil +} + +// ---- 导出给 Lua 的 API ---- + +// SetSecret stores one credential for a plugin. +func (ps *Plugins) SetSecret(plugin, key, value string) error { + if plugin == "" { + return fmt.Errorf("plugin name required") + } + if err := validateSecretKey(key); err != nil { + return err + } + ps.mu.Lock() + defer ps.mu.Unlock() + m, err := ps.loadSecretsLocked(plugin) + if err != nil { + return err + } + m[key] = value + return ps.writeSecretsLocked(plugin, m) +} + +// GetSecret reads one credential. The second return reports whether it existed, +// which lets a plugin distinguish "no token stored yet" from "token is empty" +// without a sentinel value. +func (ps *Plugins) GetSecret(plugin, key string) (string, bool, error) { + if plugin == "" { + return "", false, fmt.Errorf("plugin name required") + } + if err := validateSecretKey(key); err != nil { + return "", false, err + } + ps.mu.RLock() + defer ps.mu.RUnlock() + m, err := ps.loadSecretsLocked(plugin) + if err != nil { + return "", false, err + } + v, ok := m[key] + return v, ok, nil +} + +// DeleteSecret removes one credential. Removing a missing key is not an error — +// "make sure nothing is stored under this key" is the useful postcondition. +func (ps *Plugins) DeleteSecret(plugin, key string) error { + if plugin == "" { + return fmt.Errorf("plugin name required") + } + if err := validateSecretKey(key); err != nil { + return err + } + ps.mu.Lock() + defer ps.mu.Unlock() + m, err := ps.loadSecretsLocked(plugin) + if err != nil { + return err + } + delete(m, key) + return ps.writeSecretsLocked(plugin, m) +} + +// SecretKeys lists the KEY NAMES a plugin has stored (never the values), so a +// plugin can show "which accounts are configured" without exposing token bytes. +func (ps *Plugins) SecretKeys(plugin string) ([]string, error) { + if plugin == "" { + return nil, fmt.Errorf("plugin name required") + } + ps.mu.RLock() + defer ps.mu.RUnlock() + m, err := ps.loadSecretsLocked(plugin) + if err != nil { + return nil, err + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} + +// DeleteAllSecrets wipes a plugin's vault. Used on plugin removal so uninstalling +// a plugin does not leave live credentials behind. +func (ps *Plugins) DeleteAllSecrets(plugin string) { + ps.mu.Lock() + defer ps.mu.Unlock() + if path := ps.secretFile(plugin); path != "" { + _ = os.Remove(path) + } +} + +// PurgeOrphanVaults removes vault files whose plugin no longer exists. +// +// Vaults outlive the plugin file only if a removal path forgot to clean up, so +// this is a startup sweep rather than a hot path. Its value is that a deleted +// plugin cannot leave a live OAuth token on disk indefinitely. +func (ps *Plugins) PurgeOrphanVaults() { + dir := ps.secretsDir() + if dir == "" { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + ps.mu.RLock() + live := make(map[string]bool, len(ps.plugins)) + for _, p := range ps.plugins { + live[p.Info.Name] = true + } + ps.mu.RUnlock() + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + name := strings.TrimSuffix(e.Name(), ".json") + if live[name] { + continue + } + _ = os.Remove(filepath.Join(dir, e.Name())) + } +} diff --git a/internal/lua/plugin_shard.go b/internal/lua/plugin_shard.go index b39bc5f..fe912fd 100644 --- a/internal/lua/plugin_shard.go +++ b/internal/lua/plugin_shard.go @@ -164,7 +164,14 @@ func (sp *shardPool) acquire(code, name string) (*worker, func(), error) { sp.live++ sp.mu.Unlock() - w, err := bootPluginState(code, name, sp.ps.outboundAllowlist()) + // ps is deliberately nil: a shard VM must NOT get the adapter-management + // bridge. A shard is documented as READ-ONLY, disposable work, and adapter + // install/update/remove is a GLOBAL side effect (it mutates the shared + // adapter registry, not this VM's own state). Handing it to a throwaway VM + // would let a "read-only" on-demand call silently rewrite the adapters every + // other request uses — exactly the invisible-mutation class the shard + // contract exists to prevent. + w, err := bootPluginState(code, name, sp.ps.outboundAllowlist(), nil) if err != nil { sp.mu.Lock() sp.live-- diff --git a/internal/lua/plugin_unload_test.go b/internal/lua/plugin_unload_test.go new file mode 100644 index 0000000..9a03a1e --- /dev/null +++ b/internal/lua/plugin_unload_test.go @@ -0,0 +1,52 @@ +package lua + +import ( + "os/exec" + "testing" + "time" +) + +// TestOnUnloadReapsPluginProcesses 验收通用卸载钩子。 +// +// 内核职责是"调度插件"——卸载是调度的一部分,而插件可能持有外部资源(进程、 +// 文件句柄、远端会话)。内核不知道也不需要知道这些资源是什么;它只要在关闭 +// 状态前给插件一次自清理的机会(on_unload),资源归属就始终在插件侧。 +// 本测试用"插件起一个进程、卸载时杀掉"来证明这条链路真的闭合。 +func TestOnUnloadReapsPluginProcesses(t *testing.T) { + _, ps, _ := runtimePlugins(t) + code := ` +local plugin = { name = "reaper", version = "1.0" } +plugin.exports = { "touch" } +local pid = nil +function plugin.touch() + local h = io.popen("sh -c 'sleep 300 & echo $!'") + pid = h:read("*l"); h:close() + return { status = 200, json = { pid = pid } } +end +function plugin.on_unload() + if pid then os.execute("kill -9 " .. pid .. " 2>/dev/null") end +end +return plugin` + if err := ps.LoadSource("reaper", code); err != nil { + t.Fatal(err) + } + out, err := ps.Call("reaper", "touch", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + m := out.(map[string]interface{}) + js := m["json"].(map[string]interface{}) + pid := js["pid"].(string) + time.Sleep(300 * time.Millisecond) + if err := exec.Command("kill", "-0", pid).Run(); err != nil { + t.Fatalf("进程应存活: %v", err) + } + if err := ps.Unload("reaper"); err != nil { + t.Fatalf("unload: %v", err) + } + time.Sleep(500 * time.Millisecond) + if err := exec.Command("kill", "-0", pid).Run(); err == nil { + exec.Command("kill", "-9", pid).Run() + t.Fatal("★ unload 后进程仍存活 —— on_unload 未被调用") + } +} diff --git a/internal/lua/plugin_visibility_test.go b/internal/lua/plugin_visibility_test.go index 04ae804..3c5c791 100644 --- a/internal/lua/plugin_visibility_test.go +++ b/internal/lua/plugin_visibility_test.go @@ -190,4 +190,4 @@ func pageIDs(pages []*UIPage) []string { } } return out -} \ No newline at end of file +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index c555ab3..45126cc 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -243,6 +243,28 @@ type Plugin struct { // is still the compiled-in default would immediately overwrite the file it // was supposed to inherit from. persisted bool + + // ExtraHosts lists outbound hosts this plugin declared in its own source + // (`plugin.hosts`) beyond the operator-configured sources. A REQUEST, not a + // grant: the operator approves each one in the settings page. + ExtraHosts []string + + // KernelDeclared lists the kernel actions this plugin asks for in its own + // source (`plugin.kernel = {...}`). It is a REQUEST, shown to the operator + // beside what was actually granted; it grants nothing by itself. + KernelDeclared []string + + // Schedule is the periodic work this plugin declared, and Runtime is the + // scheduler that runs it on THIS plugin's own VM (p.state) under p.mu — the + // same state and the same lock a request hook uses. That is what makes a + // token rotated by a task immediately visible to inference traffic. + // + // Runtime is nil when the plugin declared nothing — the common case stays + // exactly as cheap as before: one VM, no extra goroutine. The point of the + // schedule is auto check-in / token rotation: work that must happen with no + // browser open and nobody clicking anything. + Schedule []ScheduledTask + Runtime *PluginRuntime } // Plugins is the loaded plugin set, owned by the VM. @@ -265,6 +287,28 @@ type Plugins struct { // visibility holds the operator's per-plugin audience overrides, keyed by // plugin name. Consulted by UIForRole and the call endpoint's authorisation. visibility visibilityOverrides + // adapterOwn records which plugin installed each adapter, so a plugin can + // only update adapters it owns (see plugin_adapter_bridge.go for why this + // is state and not a convention). + adapterOwn *adapterOwnership + adapterOwnOnce sync.Once + // routeWinners records the operator's arbitration for contested HTTP service + // paths: a path two+ plugins declared, resolved to one winning plugin. Keyed + // by normalized path. See route_conflicts.go — a contested path that is NOT + // in here is unresolved and the dispatcher returns 409 rather than silently + // picking a load-order winner. Persisted in .route-conflicts.json. + routeWinners map[string]string + // adapterWinners records the operator's arbitration for an adapter NAME two + // things claimed (user requirement B): "incumbent" keeps the built-in / first + // plugin, "candidate:" lets that plugin's version win. Persisted in + // .route-conflicts.json beside routeWinners. See route_conflicts.go. + adapterWinners map[string]string + // adapterConflicts holds adapter-name installs currently SUSPENDED pending an + // operator decision: a plugin tried to install an adapter whose name an + // incumbent already holds. The incumbent keeps serving; the candidate waits + // here (with its code) until arbitrated. In-memory only — a restart replays + // the install, which re-suspends unless a persisted winner says otherwise. + adapterConflicts map[string]*AdapterConflict // shards runs exported functions on private VMs so blocking work can be // genuinely parallel. See plugin_shard.go for why the shared VM's lock makes // concurrency impossible and what a shard must therefore never touch. @@ -276,6 +320,27 @@ type Plugins struct { // nil means deny-all: an unconfigured gateway must not silently grant a // plugin unrestricted network access. allowHTTP *httpAllowlist + // kernelAPI is the injected kernel action surface (nil when unwired). + // See kernel_api.go: a plugin calling __llmsproxy_kernel reaches the core + // through this interface, never through a direct import (core depends on + // lua, not the other way round). + kernelAPI KernelAPI + // secretBox seals the credential vault at rest. nil = write plaintext (tests + // / embedders with no runtime dir). Injected by core via SetSecretBox: it is + // the deployment's ONE master key, the same one that seals config.yaml, so a + // lost master.key loses the plugin tokens too — loud, consistent, not a + // second key to manage. + secretBox secretSealer + // kernelPerms records what the OPERATOR granted each plugin. Separate from + // what a plugin DECLARES it wants (Plugin.KernelDeclared): a declaration is + // a request, a grant is a privilege. Persisted in .access.json. + kernelPerms kernelPermissionSet + // sourceHosts remembers the source-derived allowlist so a rebuild can fold it + // together with any granted login hosts. + sourceHosts []string + // extraHosts is the per-plugin set of APPROVED extra outbound hosts. Separate + // from what a plugin declared (Plugin.ExtraHosts): a declaration is a request. + extraHosts map[string][]string // save coalesces state writes into one background goroutine. // // A hook must NOT write synchronously: the billing plugin mutates state on @@ -488,7 +553,10 @@ func (ps *Plugins) DisableStatePersistence() { ps.stateDirDisabled = true } func (ps *Plugins) SetOutboundHosts(hosts []string) { ps.mu.Lock() defer ps.mu.Unlock() - ps.allowHTTP = newHTTPAllowlist(hosts) + // Remembered so a later extra-host grant can rebuild the allowlist as + // sources ∪ granted-extra-hosts instead of losing the source half. + ps.sourceHosts = append([]string(nil), hosts...) + ps.rebuildOutboundAllowlistLocked() } // outboundAllowlist returns the current allowlist. bootPluginState reads it @@ -526,6 +594,18 @@ func (ps *Plugins) LoadDir() error { if err := ps.LoadAccessOverrides(); err != nil { log.Printf("[core] plugin access overrides not loaded: %v", err) } + // Kernel grants load beside them and for the same reasons: an in-memory map + // would make "this plugin may reload config" look enforced right up to the + // next restart, at which point the grant silently vanished. + if err := ps.LoadKernelPermissions(); err != nil { + log.Printf("[core] plugin kernel permissions not loaded: %v", err) + } + // Route-conflict arbitrations are operator policy too: who wins a service + // path two plugins both declared. Must be in place before the first request + // is dispatched, or a contested path would 409 until the operator re-chose. + if err := ps.LoadRouteConflicts(); err != nil { + log.Printf("[core] plugin route conflicts not loaded: %v", err) + } if _, err := os.Stat(ps.dir); os.IsNotExist(err) { return nil } @@ -576,7 +656,7 @@ func (ps *Plugins) SeedBundled() error { // bootPluginState compiles one plugin into a fresh Lua state and stores its // returned table under pluginGlobal. It is the plugin counterpart of // adapterPool.boot, minus the pooling: a plugin keeps exactly one state. -func bootPluginState(code, name string, allow *httpAllowlist) (*worker, error) { +func bootPluginState(code, name string, allow *httpAllowlist, ps *Plugins) (*worker, error) { L := golua.NewState() L.OpenLibs() setupGlobals(L) @@ -587,6 +667,18 @@ func bootPluginState(code, name string, allow *httpAllowlist) (*worker, error) { if allow != nil { registerFn(L, httpGlobal, luaHTTPFn(L, allow)) } + // R4: plugins — and only plugins — can install/update/remove their own + // adapters at runtime. Scoped here (not in setupGlobals) for the same reason + // as __llmsproxy_http: adapters share setupGlobals and must not get it. + if ps != nil { + registerAdapterBridge(L, ps, name) + // R4-d: the credential vault and the kernel action surface. Both are + // plugin-only, for the same reason as the bridges above — an adapter + // shares setupGlobals and must not be able to read credentials or drive + // the kernel. + registerSecretBridge(L, ps, name) + registerKernelBridge(L, ps, name) + } if err := L.DoString(code); err != nil { L.Close() return nil, fmt.Errorf("compile plugin %s: %w", name, err) @@ -641,7 +733,7 @@ func (ps *Plugins) LoadSource(name, code string) error { // One state, held for the plugin's lifetime (see Plugin.state): a hook that // accumulates into plugin.state would otherwise split its totals across // whichever worker it happened to run on. - w, err := bootPluginState(code, name, ps.outboundAllowlist()) + w, err := bootPluginState(code, name, ps.outboundAllowlist(), ps) if err != nil { p.LoadError = err.Error() ps.append(p) @@ -691,11 +783,49 @@ func (ps *Plugins) LoadSource(name, code string) error { } } + // ---- schedule ---- + // + // A plugin that declares `schedule` gets a kernel-driven scheduler running + // ON ITS OWN VM (the same p.state a hook uses), so its periodic work (auto + // check-in, token rotation, quota polling) happens with no browser open and + // no operator clicking anything. Without this the only periodic work a + // plugin could do was a front-end poll, which is exactly the "nobody is + // looking at the dashboard" case the feature exists for. + // + // The parse happens here (right after boot) so a malformed schedule is + // visible at load time rather than silently ignored. + p.Schedule = parseSchedule(w.L).tasks + w.L.SetTop(0) + if len(p.Schedule) > 0 { + p.Runtime = ps.startRuntime(p, scheduleSpec{tasks: p.Schedule}) + if p.Runtime == nil { + // The scheduler could not start. That must NOT silently disable the + // plugin — hooks still work — but the operator needs to know their + // scheduled work will never run. + log.Printf("[scheduler] plugin %s: declared %d task(s) but no runtime could be started", + p.Info.Name, len(p.Schedule)) + } + } + // ---- UI ---- if ui, ok := readPluginUI(w.L, p.Info.Name); ok { p.UI = ui } + // ---- declared kernel requests ---- + // A declaration is a REQUEST, not a grant (see kernel_api.go). Reading it at + // load time lets the settings page show "this plugin wants add_source" + // beside what was actually granted. + p.KernelDeclared = readPluginKernelDeclared(w.L) + w.L.SetTop(0) + + // ---- declared extra hosts ---- + // Some plugins need hosts that are not sources (account portals, claim + // endpoints), which the outbound allowlist blocks by default. The plugin + // declares what it needs; the operator approves each host. See plugin_outbound.go. + p.ExtraHosts = readPluginExtraHosts(w.L) + w.L.SetTop(0) + // ---- http endpoints ---- p.http = readPluginHTTP(w.L) @@ -1753,8 +1883,50 @@ func (ps *Plugins) Unload(name string) error { p := ps.plugins[idx] ps.plugins = append(ps.plugins[:idx], ps.plugins[idx+1:]...) ps.mu.Unlock() + // Stop the scheduler BEFORE touching the state. Stop() closes the loop and + // waits for it, so after it returns no NEW task can be dispatched — the set + // of in-flight tasks is now frozen, which is what makes the check below + // meaningful rather than racy. + inFlight := false + if p.Runtime != nil { + p.Runtime.Stop() + inFlight = p.Runtime.InFlight() + p.Runtime = nil + } + // A task holds p.mu for the whole duration of its Lua call. If one is still + // in flight we must NOT take p.mu (a wedged task never releases it, so Lock + // would hang the unload forever) and must NOT close the state underneath it + // (that is a use-after-free in the Lua C layer, which aborts the process + // instead of returning an error). Leaking this one state is the bounded, + // non-crashing outcome; the operator's action is to remove the file and + // restart, which is what a plugin wedged by its own task requires anyway. + if inFlight { + log.Printf("[scheduler] plugin %s: unloaded while a task was still running; "+ + "its Lua state is left open to avoid a use-after-free", name) + ps.rebuild() + return nil + } + // Lifecycle hook: if the plugin exported on_unload, call it BEFORE closing + // the state. This is generic plugin lifecycle (part of "调度插件"), not any + // feature: a plugin that spawned OS processes or holds other external + // resources is the one that knows how to release them, and Unload is the + // only guaranteed point where it still can. Best-effort by design — a + // hook that errors must not block an unload the operator explicitly asked + // for (a wedged plugin has its own path above). p.mu.Lock() if p.state != nil && p.state.L != nil { + p.state.L.GetGlobal(pluginGlobal) + if L := p.state.L; L.Type(-1) == golua.LUA_TTABLE { + L.GetField(-1, "on_unload") + if L.Type(-1) == golua.LUA_TFUNCTION { + if err := callFnOn(L, "on_unload"); err != nil { + log.Printf("[plugins] %s: on_unload: %v", name, err) + } + } + L.Pop(2) // adapter/plugin table + field remnant + } else { + L.Pop(1) + } p.state.L.Close() p.state = nil } @@ -1817,6 +1989,15 @@ func (ps *Plugins) Fire(stage Stage, payload map[string]interface{}) map[string] if p == nil || p.LoadError != "" { continue } + // A plugin disarmed by an overrunning task still holds its own lock (the + // task never returned), so dispatching to it would block THIS request + // behind a lock that is never released. Skip it and record why: the + // operator needs to see that inference traffic is being dropped and which + // plugin is responsible, not watch requests hang. + if p.Runtime != nil && p.Runtime.Disarmed() { + ps.hookErr.note(stage, p.Info.Name+": "+errPluginWedged.Error()) + continue + } targets = append(targets, target{p: p, hc: hc}) } if len(targets) == 0 { diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua new file mode 100644 index 0000000..8399cec --- /dev/null +++ b/internal/lua/plugins/channels.lua @@ -0,0 +1,2733 @@ +-- channels:多平台账号接入(登录认证 → 凭据 → 自动领取额度 → 注册为上游源) +-- +-- 为什么需要它 +-- ------------ +-- 网关的上游源(sources)一直是**运维手工配置**的:填 base_url、填 api_key。 +-- 而现实里有大量平台是"注册个账号、每天领免费额度"的形态,它们的接入方式是 +-- **登录**而不是"申请一个 key"。这类平台要接入网关,就需要:驱动登录 → 拿到 +-- 凭据 → 定时领取额度 → 把端点当源用。 +-- +-- 本插件把这套流程做成通用的:一个插件内建平台表,每个平台声明自己的 +-- 登录方式(link-poll / callback / paste)、额度领取接口、上游端点形状。 +-- 新增一个平台是往 PLATFORMS 里加一条,不改内核。 +-- +-- 设计边界(与内核的约定) +-- ------------------------ +-- 内核提供基础设施,插件自己不实现这些: +-- +-- __llmsproxy_http 受出站白名单约束的 HTTP(登录/领额度都走它) +-- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读) +-- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…) +-- +-- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状、模型清单。 +-- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"—— +-- 内核里出现厂商名就是分层被破坏了。登录会话、服务端浏览器栈也全归插件自持。 +-- +-- 权限(本插件需要,全部可在设置页看到"申请中") +-- ------------------------------------------------ +-- add_source 把平台端点注册为上游源 +-- list_sources 显示当前已接入的平台 +-- probe_all 接入后立刻验证可达 +-- reset_health 上游恢复后清冷却 +-- status 面板显示运行状态 +-- +-- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被 +-- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。 +-- +-- 登录会话归本插件自持(内核无"登录"概念):会话表在 plugin.state.sessions, +-- 带 nonce 与过期时间;浏览器栈(Xvfb/chromium/x11vnc/websockify)由本插件用 +-- os.execute 直接拉起并记录 pid,插件卸载/会话结束时自己回收。 +-- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化流程(轮询/回调) +-- → ③ VNC 无头浏览器兜底(无浏览器系统 / 自动化也无法完成的场景)。 + +local plugin = { + name = "channels", + version = "1.0.0", + description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源", + author = "llmsproxy", +} + +-- 申请的内核权限。设置页会把"申请中但未授权"标红。 +plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" } + +-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 +-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim" } + + + +-- ============================================================================ +-- 平台表 +-- ============================================================================ +-- +-- 每个平台声明登录方式与各端点。字段: +-- flow link-poll(给 URL 轮询)/ callback(OAuth 重定向回来带 code)/ +-- device-poll(设备码:先申请码、给用户 URL、再轮询换 token)/ +-- paste(用户粘贴 cookie/token) +-- login_url 用户要在自己浏览器打开的地址 +-- base_url 接入网关后对话走的 OpenAI 兼容端点(adapter 决定协议形状) +-- adapter 内核适配器名(openai / anthropic / …) +-- models fallback 模型清单(登录后可用 refresh 动态拉取覆盖) +-- checkin 每日额度领取:{ url, method, body, auth }(可缺省=不支持签到) +-- +-- 新增平台 = 加一条。不改内核,也不改本文件其它部分。 +-- +-- ⚠️ 真实厂商端点逆向自参考实现(dsh-our-free-model/vendor/channel-pack)。 +-- 仅在运维自己的网关、自己的账号范围内使用;法律风险运维自负(已确认)。 +-- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua +-- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。 +local PLATFORMS = { + -- ===== 测试/示范用占位平台(example.com,不打真实厂商)===== + { + id = "example", + name = "Example 平台", + flow = "link-poll", + login_url = "https://auth.example.com/device", + base_url = "https://api.example.com/v1", + adapter = "openai", + models = { "example-chat", "example-reason" }, + checkin = { url = "https://api.example.com/v1/api/daily/claim", method = "POST", body = "{}" }, + }, + { + id = "googleish", + name = "Google 系(示范 callback)", + flow = "callback", + login_url = "https://accounts.google.com/o/oauth2/auth", + base_url = "https://api.googleish.example/v1", + adapter = "openai", + models = { "g-model-1" }, + }, + + -- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)===== + + -- TRAE(字节跳动)—— callback 登录 + ExchangeToken 续期 + 每日签到。 + -- 这是最标准的一条,作为"同类厂商"的实现模板。 + { + id = "trae", + name = "TRAE(字节)", + vendor = "bytedance", + flow = "callback", + -- 用户在自己浏览器打开授权页;回调带 token 直传(auth_type=local)。 + login_url = "https://www.trae.cn/authorization", + hosts = { "www.trae.cn", "api.trae.com.cn", "api.trae.cn", "trae-api-cn.mchost.guru" }, + base_url = "https://trae-api-cn.mchost.guru", + adapter = "openai", + models = { + "DeepSeek-V4-Flash", "DeepSeek-V4-Pro", "kimi-k3", "kimi-k2.6", + "glm-5", "glm-5.2", "Doubao-Seed-2.1-Pro", + }, + -- 每日签到:POST /trae/api/v2/ug/checkin_credits/claim body {} 到 UG 主机。 + checkin = { + url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim", + method = "POST", body = "{}", + }, + -- 签到状态(对齐参考:POST /checkin_credits/status → {checked_in, credits, enable}) + checkin_status = { + url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/status", + method = "POST", body = "{}", + status_fn = function(rec) + return { + active = rec.enable == true, + todayCheckedIn = rec.checked_in == true, + dailyCredit = tonumber(rec.credits) or 0, + } + end, + }, + -- 余额(对齐参考:POST /trae/api/v2/pay/ide_user_ent_usage → remain = Σ(credits_limit - credits_amount)) + balance = { + url = "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage", + method = "POST", body = '{"require_usage":true,"req_source":2}', + total_fn = function(rec) + local sum = 0 + local list = rec.user_entitlement_pack_list or rec.userEntitlementPackList or {} + if type(list) ~= "table" then return 0 end + for _, p in ipairs(list) do + local lim = tonumber(p.credits_limit) or tonumber(p.creditsLimit) or 0 + local amt = tonumber(p.credits_amount) or tonumber(p.creditsAmount) or 0 + sum = sum + math.max(0, lim - amt) + end + return sum + end, + }, + -- callback 解析模式(移植自参考 trae-oauth.ts parseTraeCallbackDetailed): + -- trae token 直传:query 带 refreshToken / userJwt(JSON) / userInfo(JSON)。 + -- refreshToken 存在时调 ExchangeToken 换新(轮换旧 refresh); + -- 无 refreshToken 用 userJwt.Token 兑底; + -- 只有 code/authCodeInfo 时是 PKCE 流 → 明确报"暂不支持"而不是含糊失败。 + callback = { + mode = "trae", + exchange_url = "https://api.trae.com.cn/cloudide/api/v3/trae/oauth/ExchangeToken", + exchange_body = '{"ClientID":"{client_id}","RefreshToken":"{refresh_token}","ClientSecret":"-","UserID":""}', + client_id = "", -- 参考用 product.clientId;TRAE 回调直传形态不强制,留空 + }, + }, + + -- MiniMax —— PKCE 设备码流(RFC 8628)。三步:申请码 → 用户授权 → 轮询换 + -- token。全程纯 HTTP,无需本地服务器;user_code 显示给用户输到授权页。 + { + id = "minimax", + name = "MiniMax", + vendor = "minimax", + flow = "device-poll", + device = { + -- 申请设备码:PKCE S256 + code_url = "https://account.minimax.cn/oauth2/device/code", + code_body = "client_id=mcode-public&scope=agent.default&audience=agent-backend" + .. "&code_challenge={challenge}&code_challenge_method=S256", + -- 轮询换 token + token_url = "https://account.minimax.cn/oauth2/token", + token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" + .. "&device_code={device_code}&client_id=mcode-public&code_verifier={verifier}", + interval = 5, -- 轮询间隔秒 + slow_down_guard = true, -- 响应 authorization_pending / slow_down 按标准处理 + }, + hosts = { "account.minimax.cn", "agent.minimax.cn" }, + base_url = "https://agent.minimax.cn", + adapter = "openai", + models = { "MiniMax-M3.1-Flash-Preview", "MiniMax-M3", "MiniMax-M2.7-highspeed" }, + -- 每日签到(两步:状态预检 + 领取;幂等靠服务端状态) + checkin = { + url = "https://agent.minimax.cn/minimax-cloud/api/v1/signin/claim", + method = "POST", body = "{}", + }, + -- 签到状态预检(对齐参考 minimax-credits.ts,面板显示今日是否已签到) + checkin_status = { + url = "https://agent.minimax.cn/minimax-cloud/api/v1/signin/status", + method = "GET", + }, + -- 余额(对齐参考:GET /credit/details,total = Σ details[].remaining_amount) + balance = { + url = "https://agent.minimax.cn/minimax-cloud/api/v1/credit/details", + method = "GET", + total_fn = function(rec) + -- ⚠️ total_count 是记录条数不是余额(参考注释踩过的坑)。余额=Σ remaining_amount。 + if type(rec) ~= "table" or type(rec.details) ~= "table" then return 0 end + local sum = 0 + for _, d in ipairs(rec.details) do + sum = sum + (tonumber(d.remaining_amount) or 0) + end + return sum + end, + }, + }, + + -- Cline —— WorkOS 设备码流(照参考 cline-oauth.ts / cline-product.ts 逆向)。 + -- 与 MiniMax 同为 device-poll,但判据形态完全不同(参考注释反复强调): + -- 判据看响应体 error 字段(HTTP 可能非 2xx),不看 status 字段; + -- slow_down 累积退避 +1 秒(WorkOS 会持续限流不退避的客户端); + -- 成功后还有第二步 register:拿 WorkOS token 换 Cline 自己的 token + -- (POST /api/v1/auth/register,body 驼峰 accessToken/refreshToken)。 + { + id = "cline", + name = "Cline", + vendor = "cline", + flow = "device-poll", + device = { + code_url = "https://api.workos.com/user_management/authorize/device", + code_body = "client_id=client_01K3A541FN8TA3EPPHTD2325AR", + token_url = "https://api.workos.com/user_management/authenticate", + token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" + .. "&device_code={device_code}&client_id=client_01K3A541FN8TA3EPPHTD2325AR", + interval = 5, + -- WorkOS 形态:判 error 字段;slow_down 退避 1 秒;access_denied/invalid_grant 同组 + judge = "workos", + slow_down_step = 1, + }, + -- 第二步:WorkOS token → Cline token(挂 apiBase) + register = { + url = "https://api.cline.bot/api/v1/auth/register", + method = "POST", + body = '{"accessToken":"{access_token}","refreshToken":"{refresh_token}"}', + }, + hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" }, + base_url = "https://api.cline.bot", + adapter = "openai", + -- 免费模型(远端 free 数组实测,2026-10-05;内嵌目录只有这 3 个免费) + models = { + "stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor", + }, + -- 参考 cline-credits.ts:sidecar 里扫不到 checkin/campaign 端点 —— 无每日领取。 + -- 余额(对齐参考 cline-quota.ts:GET usage-limits,单位 dollars,show percentUsed) + credit_unit = "dollars", + balance = { + url = "https://api.cline.bot/api/v1/users/me/plan/usage-limits", + method = "GET", + total_fn = function(rec) + local d = rec.data or rec + local limits = d.limits or {} + if type(limits) ~= "table" then return 0 end + -- 取剩余百分比最小的那个限额的 (100-percentUsed);没有就 0 + local minRemain + for _, l in ipairs(limits) do + local used = tonumber(l.percentUsed or l.percent_used) or 0 + local rem = 100 - used + if not minRemain or rem < minRemain then minRemain = rem end + end + return minRemain or 0 + end, + }, + }, + + -- Buddy(腾讯 CodeBuddy)—— state 轮询流(照参考 buddy-oauth.ts 逆向)。 + -- 两步:POST /v2/plugin/auth/state → {state, authUrl};用户在 authUrl 完成登录后 + -- 插件轮询 GET /v2/plugin/auth/token?state=...。判据:业务码 11217 = 未就绪继续等; + -- 网络错误同样继续轮询不中断(对齐 Rust loop_get_token)。token 就绪后再拿账号。 + { + id = "buddy", + name = "CodeBuddy(腾讯)", + vendor = "tencent", + flow = "state-poll", + state = { + state_url = "https://copilot.tencent.com/v2/plugin/auth/state?platform=ide", + token_url = "https://copilot.tencent.com/v2/plugin/auth/token", + account_url = "https://copilot.tencent.com/v2/plugin/login/account", + code_not_ready = 11217, + interval = 1, + }, + hosts = { "copilot.tencent.com", "www.codebuddy.cn" }, + base_url = "https://copilot.tencent.com", + adapter = "openai", + models = { + "deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x", + "glm-5.3", "glm-5.3-flash", "glm-5.2", + }, + -- 签到状态(对齐参考 credits.ts:必须用 checkin-activity-status,不能用 + -- checkin-status —— 后者返回占位数据 active:false,是个坑) + checkin_status = { + url = "https://copilot.tencent.com/v2/billing/meter/checkin-activity-status", + method = "POST", body = "{}", + status_fn = function(rec) + local d = rec.data or rec + return { + active = d.active == true, + todayCheckedIn = d.todayCheckedIn == true or d.today_checked_in == true, + streakDays = tonumber(d.streakDays or d.streak_days) or 0, + todayCredit = tonumber(d.todayCredit or d.today_credit) or 0, + } + end, + }, + -- 余额(对齐参考 USER_RESOURCE_PATH /v2/billing/meter/get-user-resource) + balance = { + url = "https://copilot.tencent.com/v2/billing/meter/get-user-resource", + method = "POST", body = "{}", + total_fn = function(rec) + local d = rec.data or rec + return tonumber(d.totalCredit or d.total_credit or d.balance or d.amount) or 0 + end, + }, + }, + + -- 服务端浏览器兜底示范(回环重定向类)。真实厂商若需要可改 flow="vnc"。 + { + id = "loopbackish", + name = "回环登录平台(示范 VNC 兜底)", + flow = "vnc", + needs_browser = true, + login_url = "https://auth.loopbackish.example/authorize?redirect_uri=http://127.0.0.1:%d/callback", + base_url = "https://api.loopbackish.example/v1", + adapter = "openai", + models = { "lb-model-1" }, + }, + { + id = "cookieish", + name = "Cookie 平台(示范 paste)", + flow = "paste", + base_url = "https://api.cookieish.example/v1", + adapter = "openai", + models = { "c-model-1" }, + checkin = { url = "https://api.cookieish.example/v1/api/credit/claim", method = "POST", body = "{}" }, + }, + + -- Gemini(Google Cloud Code)—— 标准 OAuth 授权码流 + token 交换。 + -- 照参考 gemini-oauth.ts:授权页 client_id/redirect_uri/scope 齐全,回环回调 + -- 拿到 code 后 POST oauth2.googleapis.com/token 换 token(带 code_verifier)。 + -- client_id 是 Cloud Code 的公开客户端(逆向自 binary),scope 六项逐字照抄。 + { + id = "gemini", + name = "Gemini(Cloud Code)", + vendor = "google", + flow = "callback", + login_url = "https://accounts.google.com/o/oauth2/v2/auth" + .. "?client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" + .. "&redirect_uri=http://127.0.0.1:8845/auth/callback" + .. "&response_type=code" + .. "&scope=openid+email+profile+cloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email" + .. "&include_granted_scopes=true", + callback = { + mode = "code", + token_url = "https://oauth2.googleapis.com/token", + token_body = "grant_type=authorization_code&code={code}" + .. "&client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" + .. "&redirect_uri=http://127.0.0.1:8845/auth/callback", + pkce = true, -- 授权页带 verifier(S256),交换时回传 + }, + hosts = { "accounts.google.com", "oauth2.googleapis.com", "cloudcode-pa.googleapis.com" }, + base_url = "https://cloudcode-pa.googleapis.com", + adapter = "gemini", + models = { "gemini-3.1-flash-lite-preview", "gemini-3.1-flash-preview" }, + }, + + -- LobsterAI(有道龙虾)—— 本地回调 + authCode 换 token(照参考 lobsterai-oauth.ts)。 + { + id = "lobsterai", + name = "LobsterAI(有道)", + vendor = "youdao", + flow = "callback", + login_url = "https://lobsterai.youdao.com/login", + callback = { + mode = "code", + token_url = "https://lobsterai-server.youdao.com/api/auth/login", + token_body = '{"authCode":"{code}","firstKeyfrom":"cli","latestKeyfrom":"cli","uuid":"","version":"1.0"}', + }, + hosts = { "lobsterai.youdao.com", "lobsterai-server.youdao.com" }, + base_url = "https://lobsterai-server.youdao.com", + adapter = "openai", + models = { "kimi-k3", "glm-5.2", "deepseek-v4-flash" }, + -- 余额(对齐参考:GET /api/user/profile-summary) + balance = { + url = "https://lobsterai-server.youdao.com/api/user/profile-summary", + method = "GET", + total_fn = function(rec) + local d = rec.data or rec + return tonumber(d.availableCredits or d.available_credits or d.credits or d.balance) or 0 + end, + }, + }, + + -- OpenCode —— 直接粘贴 API key(无登录流,参考 opencode-auth.ts)。 + { + id = "opencode", + name = "OpenCode Zen", + vendor = "opencode", + flow = "paste", + base_url = "https://opencode.ai/zen", + adapter = "openai", + models = { "big-pickle", "claude-sonnet-4-20250514", "gpt-5.1-codex-mini" }, + }, + + -- CodeArts(华为)—— 凭据 + SDK-HMAC-SHA256 签到(照参考 codearts-credits.ts)。 + -- 登录是常规企业凭据(paste);签到走 snap-access 的 SDK-HMAC-SHA256 签名, + -- 放在 claim_fn 钩子里(checkin 表驱动驱动不动签名请求,参考注释也点明这一点)。 + { + id = "codearts", + name = "CodeArts(华为)", + vendor = "huawei", + flow = "paste", + base_url = "https://snap-access.cn-north-4.myhuaweicloud.com", + adapter = "openai", + models = { "CodeArts-Pangu" }, + checkin = { + url = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin", + method = "GET", + }, + -- claim_fn 由下方 claimCodeArts 实现(引用前向声明,见文件末尾) + claim_fn_name = "claimCodeArts", + }, + + -- Qoder —— PKCE 设备码 + WASM 加密签名(照参考 qoder-oauth.ts 逆向)。 + -- WASM 签名头无法在纯 Lua 环境完成,登录标 manual 给明确说明。 + { + id = "qoder", + name = "Qoder", + vendor = "qoder", + flow = "manual", + manual_hint = "Qoder 登录要 WASM 签名头,纯 Lua 环境无法生成。请在浏览器完成" + .. "授权后,把最终凭证粘贴进来(手动输入凭据)。", + base_url = "https://api2-v2.qoder.sh", + adapter = "openai", + models = { "qoder-default" }, + -- 余额(对齐参考 qoder-credits.ts:GET /sash/api/v2/me/usage,余额=userQuota.remaining + -- + addOnQuota.remaining,走 openApiBase) + balance = { + url = "https://openapi.qoder.sh/sash/api/v2/me/usage", + method = "GET", + total_fn = function(rec) + local d = rec.data or rec + local u = (d.userQuota or {}).remaining or 0 + local a = (d.addOnQuota or {}).remaining or 0 + return (tonumber(u) or 0) + (tonumber(a) or 0) + end, + }, + }, + + -- ZCode —— cli/init + captcha(照参考 zcode-auth.ts 逆向)。 + -- captcha 求解(mint captcha + 补激活上报)无法在纯 Lua 完成,标 manual。 + { + id = "zcode", + name = "ZCode", + vendor = "zcode", + flow = "manual", + manual_hint = "ZCode 登录要解 captcha(client/configs 拿 sceneId 后 mint)," + .. "纯 Lua 环境无法完成。请完成 captcha 验证后,把最终凭证粘贴进来。", + base_url = "https://zcode.example.com/v1", + adapter = "openai", + models = { "zcode-default" }, + }, + + -- Loomy —— 微信扫码登录(照参考 loomy-oauth.ts 逆向)。 + -- 扫码要在微信 App 里完成,纯 Lua 环境拿不到微信凭据,标 manual。 + { + id = "loomy", + name = "Loomy", + vendor = "loomy", + flow = "manual", + manual_hint = "Loomy 登录要微信扫码(在微信公众号里完成授权)。请在微信里" + .. "扫码后,把拿到的凭证粘贴进来。", + base_url = "https://loomy.example.com/v1", + adapter = "openai", + models = { "loomy-default" }, + }, + + -- Raccoon(商汤小浣熊)—— 扫码登录(照参考 raccoon-oauth.ts 逆向)。 + -- 扫码是图片码,纯 Lua 环境无法展示与回取,标 manual。 + { + id = "raccoon", + name = "Raccoon(商汤小浣熊)", + vendor = "sensetime", + flow = "manual", + manual_hint = "Raccoon 登录要扫图片二维码(商汤小浣熊官网)。请扫码后," + .. "把 login_with_qrcode_code 拿到的凭证粘贴进来。", + base_url = "https://raccoon.sensetime.com", + adapter = "openai", + models = { "raccoon-default" }, + -- 余额(对齐参考 raccoon-credits.ts:GET {apiBase}/points/v1/balance → data.available_points) + balance = { + url = "https://raccoon.sensetime.com/points/v1/balance", + method = "GET", + total_fn = function(rec) + local d = rec.data or rec + return tonumber(d.available_points or d.availablePoints) or 0 + end, + }, + -- 一次性新人奖励(对齐 onboarding:桌面登录奖励 3000 分) + onboarding = { reward_points = 3000 }, + }, +} + +-- CodeArts 的 SDK-HMAC-SHA256 签到钩子(照参考 codearts-credits.ts 逆向)。 +-- 绑在 PLATFORMS 表定义之后:claim_daily 读取 plat.claim_fn,checkin 表驱动 +-- 不动签名请求(参考注释也点明这一点),所以用钩子生成签名后的请求形状。 +do + local ca + for _, p in ipairs(PLATFORMS) do + if p.id == "codearts" then ca = p break end + end + if ca then + -- SDK-HMAC-SHA256 派生签名密钥链(照参考 canonical request 形状)。 + ca.claim_fn = function(plat, tok, args) + local url = plat.checkin.url + local method = plat.checkin.method or "GET" + local host = url:match("^https?://([^/:]+)") + local path = url:match("^https?://[^/]+(/[^?]*)") + local ts = tostring(os.time and os.time() or 0) + local nonce = tostring(math.random(1e12)) + local canonical = method .. "\n" .. (path or "/") .. "\n" .. ts .. "\n" .. nonce + local secret = tok or "" + local kDate = hmac_sha256_hex("SDK" .. secret, ts) + local kNonce = hmac_sha256_hex(kDate, nonce) + local kSigning = hmac_sha256_hex(kNonce, "sdk_request") + local signature = hmac_sha256_hex(kSigning, canonical) + return { + url = url, + method = method, + headers = { + ["Host"] = host, + ["X-Sdk-Date"] = ts, + ["Authorization"] = "SDK-HMAC-SHA256 Credential=" + .. (args and args.access_key or "") .. "/" .. ts .. "/" .. nonce + .. "/sdk_request, SignedHeaders=host, Signature=" .. signature, + }, + } + end + end +end + +-- 申请的额外出站主机:从平台声明推导(登录域 + 上游域),避免两份清单漂移。 +-- 内核里**不含**任何厂商名 —— 这些只存在于本插件,且需运维在设置页逐个批准。 +local function declaredHosts() + local seen, out = {}, {} + for _, p in ipairs(PLATFORMS) do + for _, h in ipairs(p.hosts or {}) do + local hn = h:lower() + if not seen[hn] then seen[hn] = true out[#out+1] = hn end + end + end + -- 上游 base_url 的 host 也要(发对话/签到用) + for _, p in ipairs(PLATFORMS) do + local h = (p.base_url or ""):match("^https?://([^/:]+)") + if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end + end + for _, p in ipairs(PLATFORMS) do + local h = (p.checkin and p.checkin.url or ""):match("^https?://([^/:]+)") + if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end + end + table.sort(out) + return out +end +plugin.hosts = declaredHosts() + +-- bodyOf 把 handler 的 req.body(**JSON 字符串**)解成 table。 +-- +-- 为什么需要它:handler 收到的是原始 body 字符串,不是解码后的表(内核不猜 +-- 插件的载荷形状)。载荷坏掉时返回空表而不是报错 —— 调用方随后会因为缺少 +-- 必要字段而得到明确的错误,比一个 JSON 解析异常更好读。 +local function bodyOf(req) + if not req or not req.body or req.body == "" then return {} end + if type(req.body) == "table" then return req.body end + local ok, v = pcall(json.decode, req.body) + if not ok or type(v) ~= "table" then return {} end + return v +end + +-- ============================================================================ +-- device-poll 通用执行器(移植自参考实现 minimax-oauth.ts / cline-oauth.ts) +-- ============================================================================ +-- 行为逐字对齐参考实现,测试用例也照它搬(见 channels_device_test.go): +-- +-- 1. PKCE S256:verifier = 随机字节 base64url;challenge = SHA256(verifier) +-- 的 base64url(参考 createMinimaxPkce)。 +-- 2. 轮询决策分支的**顺序**就是行为(参考 pollMinimaxDeviceToken): +-- HTTP 200 + status=pending → 继续等 +-- HTTP 200 + status=slow_down → interval += 5s,继续等 +-- HTTP 200 + status=denied / +-- access_denied → 会话 failed(用户拒绝) +-- HTTP 200 + status=expired / +-- expired_token → 会话 failed(码过期) +-- 非 200 + error=authorization_pending → 继续等(OAuth 标准形态) +-- 非 200 + error=slow_down → interval += 5s,继续等 +-- HTTP 200(无上述状态) → 成功,提取 access_token +-- 其它 → 会话 failed(带 error 文本) +-- ⚠️ 「两种还在等」都要认:形一是 200+status 字段,形二是非 200+error 字段。 +-- 3. 轮询由页面驱动(poll_login 每次调用推进一步),不在 Lua 里阻塞循环: +-- 插件单 VM 单锁,阻塞循环会卡住本插件的全部钩子。 + +-- b64url:内核的 base64_encode 是标准 base64;PKCE 要 base64url(无 +/= )。 +local function b64url(s) + return (base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", "")) +end + +-- hexToBin:把 hex 字符串还原成二进制字符串(PKCE challenge 输入用)。 +local function hexToBin(h) + return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end)) +end + +-- devicePkce 生成一次授权的 verifier/challenge 对(SHA256 S256)。 +-- verifier 自造:熵拼接后哈希。不依赖文件后段才定义的 newToken —— Lua 词法 +-- 作用域下那会是 nil 全局调用。熵用 os.time + math.random 两次哈希拼接。 +local function devicePkce() + local v = sha256_hex(tostring(os.time and os.time() or 0) .. math.random(1e12)) + .. sha256_hex(tostring(math.random(1e12))) + return v, b64url(hexToBin(sha256_hex(v))) +end + +-- deviceOneStep 推进一次设备码轮询,返回 (done, payload)。 +-- done=true payload={ ok=true, access_token=.., refresh_token=.., account=.. } +-- 或 { ok=false, reason="denied"|"expired", message=.. } +-- done=false payload=nil(还在等:pending / slow_down) +-- sess 携带 device 上下文(device_code/verifier/interval),被本函数原地更新 +--(slow_down 后 interval 增大,与参考一致)。 +local function deviceOneStep(plat, sess) + local d = plat.device + local body = (d.token_body + :gsub("{device_code}", sess.device_code) + :gsub("{verifier}", sess.device_verifier or "") + :gsub("{challenge}", sess.device_challenge or "")) + local resp, err = __llmsproxy_http({ + url = d.token_url, + method = "POST", + headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" }, + body = body, + }) + if err then return false end -- 网络抖动不算失败:与参考一致(pending 语义) + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then rec = {} end + + -- 判据形态分两套(这正是 MiniMax 与 Cline 的核心差异,参考注释反复强调): + -- minimax(默认):看 HTTP 200 + status 字段;非 200 才看 error 字段。 + -- workos(Cline):看响应体 error 字段(HTTP 可能非 2xx); + -- slow_down 退避 d.slow_down_step 秒(WorkOS 会持续限流不退避的客户端); + -- access_denied / expired_token / invalid_grant 同组判失败。 + -- 退避步长不硬编码:slow_down_step 缺省 5(MiniMax 语义)。 + local workos = (d.judge == "workos") + local status = rec.status + local ecode = rec.error + local step = tonumber(d.slow_down_step) or 5 + + if workos then + if ecode == "authorization_pending" then return false end + if ecode == "slow_down" then + sess.device_interval = (sess.device_interval or d.interval or 5) + step + return false + end + if ecode == "access_denied" or ecode == "expired_token" or ecode == "invalid_grant" then + local msg = rec.error_description or (ecode == "access_denied" and "用户拒绝了授权" or "授权已过期") + return true, { ok = false, reason = (ecode == "access_denied" and "denied" or "expired"), message = msg } + end + if resp.status == 200 and rec.access_token and rec.refresh_token then + return true, { ok = true, access_token = rec.access_token, refresh_token = rec.refresh_token, + need_register = true, account = "cline" } + end + if resp.status ~= 200 and next(rec) ~= nil then + return true, { ok = false, reason = "error", + message = "WorkOS token 轮询失败(HTTP " .. tostring(resp.status) .. ")" + .. (rec.error_description and (" - " .. rec.error_description) or "") } + end + return false -- 2xx 但既无 error 又无 token:等下一拍(避免误报死循环) + end + + -- MiniMax 形态(原样保留,已由决策表测试钉死) + if resp.status == 200 and status == "pending" then return false end + if resp.status == 200 and status == "slow_down" then + sess.device_interval = (sess.device_interval or d.interval or 5) + step + return false + end + if resp.status == 200 and (status == "denied" or status == "access_denied") then + return true, { ok = false, reason = "denied", message = "用户拒绝了授权" } + end + if resp.status == 200 and (status == "expired" or status == "expired_token") then + return true, { ok = false, reason = "expired", message = "设备码已过期,请重新发起登录" } + end + if ecode == "authorization_pending" then return false end + if ecode == "slow_down" then + sess.device_interval = (sess.device_interval or d.interval or 5) + step + return false + end + if resp.status == 200 and rec.access_token then + return true, { ok = true, access_token = rec.access_token, + refresh_token = rec.refresh_token, account = rec.nickname or rec.sub or "" } + end + return true, { ok = false, reason = "error", message = "授权失败: " .. tostring(ecode or ("HTTP " .. tostring(resp.status))) } +end + +-- deviceRegister:设备码成功后的第二跳(Cline:WorkOS token → Cline token)。 +-- 参考注释强调:两步是必要的(浏览器 transient activation 约 5 秒,阻塞式 +-- 等授权会让 URL 过期)。返回 (access_token, account, nil) 或 (nil, nil, err)。 +local function deviceRegister(plat, payload) + local r = plat.register + if not r or not r.url then return payload.access_token, payload.account, nil end + local body = (r.body or "") + :gsub("{access_token}", payload.access_token or "") + :gsub("{refresh_token}", payload.refresh_token or "") + local resp, err = __llmsproxy_http({ + url = r.url, + method = r.method or "POST", + headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }, + body = body, + }) + if err then return nil, nil, "token 注册失败: " .. err end + if resp.status ~= 200 then + return nil, nil, "token 注册失败(HTTP " .. tostring(resp.status) .. ")" + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then + return nil, nil, "token 注册响应无法解析" + end + -- 响应套 {success, data} 信封(参考 parseClineTokenPayload) + local data = rec.data or rec + local access = data.accessToken or data.access_token or data.Token or data.token + if not access or access == "" then + return nil, nil, "token 注册响应缺少 accessToken" + end + local acct = "cline" + if type(data.userInfo) == "table" and (data.userInfo.email or data.userInfo.clineUserId) then + acct = tostring(data.userInfo.email or data.userInfo.clineUserId) + end + return access, acct, nil +end + +-- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表) +local function platformById(id) + for _, p in ipairs(PLATFORMS) do + if p.id == id then return p end + end + return nil +end + +-- ============================================================================ +-- 状态 +-- ============================================================================ + +plugin.state = plugin.state or {} +plugin.state.accounts = plugin.state.accounts or {} +plugin.state.log = plugin.state.log or {} + +local function logEvent(msg) + table.insert(plugin.state.log, 1, { at = os.time and os.time() or 0, msg = msg }) + while #plugin.state.log > 50 do table.remove(plugin.state.log) end +end + +-- accountOf 取某平台的账号记录(凭据本身在保险库,不在 state 里) +local function accountOf(id) + return plugin.state.accounts[id] +end + +-- ============================================================================ +-- 账号池(对齐参考 account.* RPC 契约):一个平台可挂多个账号 +-- ============================================================================ +-- plugin.state.pool[provider] = { [accountId] = 条目 },条目字段对齐参考 +-- AccountEntry:{ id, provider, nickname, enabled, credentialRef, refreshable, +-- createdAt, expiresAt }。凭据在保险库(credentialRef 即保险库键),不入 state。 +-- +-- 兼容:旧的单账号路径(state.accounts)保留 —— e2e 与既有端点仍走它。 +local function poolTable() + if not plugin.state.pool then plugin.state.pool = {} end + return plugin.state.pool +end + +local function poolAccounts(provider) + local t = poolTable()[provider] + if not t then t = {} poolTable()[provider] = t end + return t +end + +local function poolList(provider) + local out = {} + for _, a in pairs(poolAccounts(provider)) do out[#out+1] = a end + table.sort(out, function(x, y) + local px, py = x.priority or 0, y.priority or 0 + if px ~= py then return px > py end -- priority 大者优先(参考 reorder 语义) + return (x.createdAt or 0) < (y.createdAt or 0) + end) + return out +end + +local function poolGet(provider, accountId) + return poolAccounts(provider)[accountId] +end + +local function poolAdd(provider, entry) + poolAccounts(provider)[entry.id] = entry +end + +local function poolRemove(provider, accountId) + poolAccounts(provider)[accountId] = nil +end + +-- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true } +local function disabledModelsOf(provider) + if not plugin.state.modelDisabled then plugin.state.modelDisabled = {} end + local m = plugin.state.modelDisabled[provider] + if not m then m = {} plugin.state.modelDisabled[provider] = m end + return m +end + +-- 渠道积分能力表(对齐参考 CHANNEL_CREDITS):声明各渠道有没有领取/余额。 +local CHANNEL_CREDITS = { + codearts = { claim = true }, + buddy = { claim = true, status = true }, + lobsterai = { claim = true }, + qoder = { claim = true }, + trae = { claim = true }, + cline = { unit = "dollars" }, + loomy = { claim = true }, + raccoon = {}, + minimax = { claim = true, status = true }, + zcode = { claim = true, unit = "tokens" }, + gemini = { unit = "quota", quota = true }, +} + +-- ---- 账号级 source 同步 ---- +-- 每个账号一条上游源:channels--。模型开关即改写 +-- 这条源的 models 列表(remove+add 重建;内核 add_source 同名覆盖,但显式 +-- 先 remove 更诚实,避免旧模型残留)。 +local function accountSourceName(provider, accountId) + return "channels-" .. provider .. "-" .. accountId +end + +-- syncAccountSource 把某账号的上游源与(平台模型目录 - 黑名单)对齐。 +-- 无凭据(占位账号)不动源。 +local function syncAccountSource(provider, accountId) + local plat = platformById(provider) + local acct = poolGet(provider, accountId) + if not plat or not acct then return end + local tok = __llmsproxy_secrets.get(acct.credentialRef) + if not tok or tok == "" then return end + local name = accountSourceName(provider, accountId) + local models = {} + local disabled = disabledModelsOf(provider) + for _, m in ipairs(plat.models or {}) do + if not disabled[m] then + models[#models+1] = { id = m, kind = "chat", priority = 20 } + end + end + -- 重建:先 remove(容错:源可能不存在)再 add + __llmsproxy_kernel.call("remove_source", { name = name }) + local r, aerr = __llmsproxy_kernel.call("add_source", { + source = { + name = name, + base_url = plat.base_url, + adapter = plat.adapter or "openai", + api_key = tok, + max_concurrent = 2, + models = models, + }, + }) + if aerr then logEvent("接入源失败 " .. name .. ": " .. aerr) + else logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)") end +end + +local function dropAccountSource(provider, accountId) + __llmsproxy_kernel.call("remove_source", { name = accountSourceName(provider, accountId) }) +end + +-- ============================================================================ +-- 登录 +-- ============================================================================ + +-- plugin.start_login(platform_id) 由设置页调用:创建一个登录会话。 +-- +-- 返回 {url=…, session=…} 给页面显示"请在自己的浏览器打开这个地址"。 +-- 登录完成后用户浏览器会回到本插件(callback 流程)或本插件轮询到结果 + +-- ============================================================================ +-- 登录会话(插件自持)与服务端浏览器栈 +-- ============================================================================ + +-- 登录会话放 plugin.state.sessions:内核没有"登录"概念,这正是插件该自己干的 +-- 事。表结构 { id, platform, flow, url, user_code, nonce, state, account, +-- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。 +local SESSION_TTL = 900 -- 15 分钟,过期会话在读取时惰性清理 + +local function newToken() + -- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex) + return sha256_hex(tostring(os.time()) .. math.random(1e12) .. (plugin.name or "channels")) +end + +local function sessions() + if not plugin.state.sessions then plugin.state.sessions = {} end + return plugin.state.sessions +end + +-- sweepSessions 惰性清理过期/终态会话(无后台定时器,读取时顺带做) +local function sweepSessions() + local now = os.time and os.time() or 0 + for id, s in pairs(sessions()) do + if s.expires_at and s.expires_at < now then + sessions()[id] = nil + end + end +end + +-- ---- 服务端浏览器栈(VNC 兜底用)---- +-- 每个会话一组进程:Xvfb + chromium + x11vnc + websockify。pid 记在 +-- plugin.state.browser.pids,插件卸载或会话结束时 kill。 +-- 绑定地址默认 127.0.0.1(同机操作);远程运维可把 settings.vnc_bind 设成 +-- "0.0.0.0" 并依赖 x11vnc 随机会话密码(不设密码的裸 VNC 永远不会出现)。 + +local function browserStack() + if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end + return plugin.state.browser +end + +local function reapBrowser() + local b = browserStack() + for _, pid in ipairs(b.pids or {}) do + os.execute("kill -9 " .. tostring(pid) .. " 2>/dev/null") + end + b.pids = {} + b.session = nil +end + +-- spawnBrowser 拉起一组服务端浏览器。返回 view_url(给 iframe 内嵌)或 nil, err。 +-- start_url 是浏览器要打开的登录页;redirect_host 是拼 redirect_uri 用的主机名 +-- (浏览器在服务端,127.0.0.1 就是服务端自己的回环 —— 回调直接命中网关)。 +local function spawnBrowser(start_url, callback_path) + local b = browserStack() + if b.session then return nil, "已有浏览器会话在跑,请先停止" end + b.seq = b.seq + 1 + local disp = 90 + (b.seq % 10) + local vport = 59100 + (b.seq % 100) + local wport = 60100 + (b.seq % 100) + local bind = (plugin.state.settings and plugin.state.settings.vnc_bind) or "127.0.0.1" + -- 会话密码:bind 到非回环时是唯一的防线,随机生成、只在响应里给一次 + local pass = sha256_hex(newToken()):sub(1, 16) + -- 端口占位换成网关回环(浏览器在服务端,回调直接命中网关挂载点) + local url = start_url:gsub("redirect_uri=127%.0%.0%.1:%%d+", "redirect_uri=127.0.0.1:8081" .. callback_path) + + local cmds = { + ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp & echo $!"):format(disp), + ("chromium --no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/llmsproxy-ch-%d --no-first-run --window-size=1280,800 --display=:%d '%s' & echo $!"):format(b.seq, disp, url), + ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet & echo $!"):format(disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass), + ("websockify --web /usr/share/novnc %s:%d 127.0.0.1:%d & echo $!"):format(bind, wport, vport), + } + local pids = {} + for _, c in ipairs(cmds) do + local h = io.popen(c) + local pid = h:read("*l") + h:close() + if not pid or pid == "" then + reapBrowser() + return nil, "进程启动失败: " .. c:match("^(%S+)") .. "(缺依赖?运行 deploy 脚本一键安装)" + end + pids[#pids+1] = pid + end + -- 给 X/chromium 一点起进程时间;不等待就绪探针(保持插件逻辑简单,失败可重试) + os.execute("sleep 2") + b.pids = pids + b.session = { display = disp, vnc = vport, ws = wport, pass = pass } + -- view_url 指向 websockify 伺服的 noVNC;同机直接 127.0.0.1,远程用 settings.vnc_host 覆盖 + local host = (plugin.state.settings and plugin.state.settings.vnc_host) or "127.0.0.1" + return { + view_url = ("http://%s:%d/vnc.html?autoconnect=1&host=%s&port=%d&password=%s"):format(host, wport, host, wport, pass), + password = pass, + } +end + +-- ============================================================================ +-- 登录流程:优先级阶梯(① 本机浏览器 → ② 自动化 → ③ VNC 兜底) +-- ============================================================================ + +-- start_login 按优先级阶梯响应: +-- ① 本机浏览器:直接返回登录 URL(绝大多数场景到此为止 —— 平台重定向打在 +-- 用户自己的机器上,插件轮询/收回调拿凭据) +-- ② 自动化:link-poll/callback 流程本身就是自动的(轮询 + nonce 回调), +-- 无需用户之外的动作 +-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、 +-- 回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。 +function plugin.start_login(req) + local args = bodyOf(req) + local plat = platformById(args and args.platform) + if not plat then + return { status = 400, json = { error = "未知平台: " .. tostring(args and args.platform) } } + end + sweepSessions() + local now = os.time and os.time() or 0 + local sess = { + id = newToken():sub(1, 24), + platform = plat.id, + flow = plat.flow, + url = plat.login_url, + nonce = newToken():sub(1, 32), + state = "pending", + created_at = now, + expires_at = now + SESSION_TTL, + } + sessions()[sess.id] = sess + sess.account_id = args and args.account_id -- 账号池路径:登录完成回填这个池账号 + logEvent("开始登录 " .. plat.name .. " (" .. plat.flow .. ")") + + -- 响应里只带一次 nonce(回调校验要用),存侧不回显 + local out = { + session = { id = sess.id, platform = sess.platform, flow = sess.flow, + state = sess.state, nonce = sess.nonce, expires_at = sess.expires_at }, + platform = plat.id, + } + if plat.flow == "manual" then + -- 无法自动化的登录(WASM/captcha/扫码):给明确说明而不是假装成功。 + sess.state = "failed" + sess.message = plat.manual_hint or "该平台不支持自动登录" + out.manual_hint = sess.message + out.url = plat.login_url + logEvent("手动流程 " .. plat.id) + return { status = 200, json = out } + elseif plat.flow == "state-poll" then + -- Buddy:先拿 state + authUrl。页面给 authUrl,轮询 token 每推进一步。 + local resp, err = __llmsproxy_http({ + url = plat.state.state_url, + method = "POST", + headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" }, + body = "{\"platform\":\"ide\"}", + }) + if err or resp.status ~= 200 then + sess.state = "failed" + out.error = "auth/state 失败: " .. (err or ("HTTP " .. tostring(resp.status))) + return { status = 200, json = out } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" or type(rec.data) ~= "table" + or not rec.data.state or rec.data.state == "" then + sess.state = "failed" + out.error = "auth/state 响应缺少 state 字段" + return { status = 200, json = out } + end + sess.state_token = rec.data.state + out.url = rec.data.authUrl or plat.login_url + logEvent("state 已申请 " .. plat.id) + elseif plat.flow == "vnc" then + -- ③ 兜底路径:服务端浏览器。重定向打在服务端回环上,回调命中本插件的挂载点。 + local bs, berr = spawnBrowser(string.format(plat.login_url, 0), "/api/plugins/http/channels/callback") + if berr then + out.browser_error = berr + out.hint = "服务端浏览器不可用;先装依赖(见插件一键初始化),或改用其他登录流程" + logEvent("服务端浏览器启动失败: " .. berr) + else + out.view_url = bs.view_url + logEvent("服务端浏览器已就绪 " .. plat.id) + end + elseif plat.flow == "device-poll" then + -- 设备码:申请码,把 user_code + 授权 URL 给用户,verifier 存进会话。 + -- 行为对齐参考 startMinimaxDeviceAuthorization:PKCE S256。 + local verifier, challenge = devicePkce() + local body = (plat.device.code_body:gsub("{challenge}", challenge)) + local resp, err = __llmsproxy_http({ + url = plat.device.code_url, + method = "POST", + headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" }, + body = body, + }) + if err then + sess.state = "failed" + out.error = "设备码申请失败: " .. err + return { status = 200, json = out } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" or not rec.device_code then + sess.state = "failed" + out.error = "设备码响应无法解析(HTTP " .. tostring(resp.status) .. ")" + return { status = 200, json = out } + end + sess.device_code = rec.device_code + sess.device_verifier = verifier + sess.device_challenge = challenge + sess.device_interval = tonumber(rec.interval) or plat.device.interval or 5 + sess.user_code = rec.user_code + -- 授权页 URL:优先用 verification_uri_complete(免手输 user_code) + out.url = rec.verification_uri_complete or rec.verification_uri or rec.verification_url or plat.login_url + out.session.user_code = rec.user_code + out.session.interval = sess.device_interval + logEvent("设备码已申请 " .. plat.id .. " user_code=" .. tostring(rec.user_code)) + else + -- ①②:URL 交给用户自己的浏览器;callback 流程的 redirect_uri 指向本插件挂载点 + if plat.flow == "callback" then + out.url = plat.login_url .. "&redirect_uri=" .. "127.0.0.1:8081/api/plugins/http/channels/callback&state=" .. sess.nonce + else + out.url = plat.login_url + end + end + return { status = 200, json = out } +end + +-- plugin.poll_login(session_id) 由页面轮询:会话是否完成。 +-- +-- 对 device-poll 会话,每次轮询**推进一步**设备码换 token(deviceOneStep): +-- 轮询由页面驱动而不在 Lua 里阻塞,因为插件是单 VM 单锁,阻塞循环会卡死 +-- 本插件全部钩子。成功则落凭据+接入源(走 complete_login)。 +function plugin.poll_login(req) + local id = (req and req.query and req.query.id) or "" + sweepSessions() + local s = sessions()[id] + if not s then return { status = 200, json = { state = "gone" } } end + + if s.flow == "device-poll" and s.state == "pending" and s.device_code then + local plat = platformById(s.platform) + local done, payload = deviceOneStep(plat, s) + if done and payload then + if payload.ok then + -- 设备码到 token 后,有的平台还要第二跳(Cline register)换最终凭据 + local cred, acct, rerr = deviceRegister(plat, payload) + if rerr then + s.state = "failed" + s.message = rerr + else + local res = plugin.complete_login({ platform = s.platform, credential = cred, account = acct or payload.account }) + if res.json and res.json.ok then + s.state = "completed" + s.account = acct or payload.account + else + s.state = "failed" + s.message = (res.json and res.json.error) or "接入源失败" + end + end + else + s.state = "failed" + s.message = payload.message + end + end + elseif s.flow == "state-poll" and s.state == "pending" and s.state_token then + -- Buddy(CodeBuddy):state 轮询。状态里的 state_token 是 auth/state 下发的。 + local plat = platformById(s.platform) + local sp = plat and plat.state + local resp, err = __llmsproxy_http({ + url = (sp.token_url or "") .. "?state=" .. tostring(s.state_token), + method = "GET", + headers = { ["Accept"] = "application/json", ["X-No-Authorization"] = "true" }, + }) + -- 网络错误:继续轮询不中断(对齐参考 loopGetToken 的 catch 继续) + if not err and resp then + local ok, rec = pcall(json.decode, resp.body or "") + if ok and type(rec) == "table" then + local code = rec.code + local data = rec.data + if resp.status == 200 and data ~= nil and type(data) == "table" and (data.accessToken or data.access_token) then + -- token 就绪。再拿账号(失败不阻塞,回退默认名) + local tok = data.accessToken or data.access_token + local acct = "codebuddy" + local aresp, aerr = __llmsproxy_http({ + url = (sp.account_url or "") .. "?state=" .. tostring(s.state_token), + method = "GET", + headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. tok }, + }) + if not aerr and aresp and aresp.status == 200 then + local aok, arec = pcall(json.decode, aresp.body or "") + if aok and type(arec) == "table" and type(arec.data) == "table" then + acct = tostring(arec.data.nickName or arec.data.nickname or arec.data.username or acct) + end + end + local res = plugin.complete_login({ platform = s.platform, credential = tok, account = acct }) + if res.json and res.json.ok then + s.state = "completed" + s.account = acct + else + s.state = "failed" + s.message = (res.json and res.json.error) or "接入源失败" + end + elseif code == sp.code_not_ready then + -- 11217 = 未就绪,继续轮询(对齐参考:业务码 11217 继续等) + elseif resp.status == 200 and data == nil then + -- data 为 null,继续轮询(参考明确此分支) + else + s.state = "failed" + s.message = "auth/token HTTP " .. tostring(resp.status) .. " code=" .. tostring(code) + end + end + end + end + return { status = 200, json = { state = s.state, account = s.account, message = s.message, interval = s.device_interval } } +end + +-- plugin.complete_login(args) 由页面/回调调用:登录成功,存凭据并接入源。 +-- +-- 凭据只进保险库(0600、跨插件不可读),不进 state:state 会被 +-- PUT /api/plugins/channels/state 整体替换,也会出现在 WebUI 里。 +function plugin.complete_login(req) + local args = bodyOf(req) + if args.platform == nil then + -- 内部调用会直接传表 + args = req + end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local token = args and args.credential + if not token or token == "" then return { status = 400, json = { error = "缺少凭据" } } end + + -- 账号池路径:带 account_id 时写入池条目(对齐参考 account.create 的 + -- 占位账号语义 —— 先有账号行,凭据随后填入)。 + local aid = args.account_id + if aid and aid ~= "" then + local acct = poolGet(plat.id, aid) + if not acct then return { status = 400, json = { error = "账号不存在: " .. tostring(aid) } } end + local ref = acct.credentialRef + local _, serr = __llmsproxy_secrets.set(ref, token) + if serr then return { status = 400, json = { error = serr } } end + acct.nickname = args.account or acct.nickname or aid + acct.expiresAt = args.expires_at or acct.expiresAt + acct.refreshable = args.refreshable or acct.refreshable or false + poolAdd(plat.id, acct) + -- 终结该账号的未决会话 + for id, sess in pairs(sessions()) do + if sess.platform == plat.id and sess.state == "pending" + and (sess.account_id == aid or sess.pool_account_id == aid) then + sess.state = "completed" + end + end + logEvent("账号凭据已入池 " .. plat.id .. "/" .. aid) + syncAccountSource(plat.id, aid) + return { status = 200, json = { ok = true, account_id = aid, source = accountSourceName(plat.id, aid) } } + end + + -- 兼容旧路径:单账号模式(e2e 与既有端点仍走它)。 + local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token) + if err then return { status = 400, json = { error = err } } end + + plugin.state.accounts[plat.id] = { + account = args.account or "已登录", + added_at = os.time and os.time() or 0, + enabled = true, + } + -- 终结该平台未决会话 + for id, sess in pairs(sessions()) do + if sess.platform == plat.id and sess.state == "pending" then sess.state = "completed" end + end + logEvent("已登录 " .. plat.name) + + -- 接入为上游源 —— 这是本插件存在的意义 + local r, aerr = __llmsproxy_kernel.call("add_source", { + source = { + name = "channels-" .. plat.id, + base_url = plat.base_url, + adapter = "openai", + api_key = token, + max_concurrent = 2, + models = (function() + local ms = {} + for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end + return ms + end)(), + }, + }) + if aerr then + -- 凭据已存下,但接入源失败。如实分开报告:把两者混成一个 ok=false 会让 + -- 调用方以为登录失败从而重复登录。 + logEvent("接入源失败 " .. plat.id .. ": " .. aerr) + return { status = 200, json = { ok = true, source_error = aerr } } + end + logEvent("已接入源 " .. r.name) + return { status = 200, json = { ok = true, source = r.name } } +end + +-- ============================================================================ +-- 额度领取 +-- ============================================================================ + +-- plugin.claim_daily(platform_id) 领每日额度。设计为**可被定时任务调用**。 +-- +-- 只返回结果,不改 state 之外的东西:真正的额度体现在平台的响应里 +-- (已用/剩余),本插件记录以便面板显示。 +function plugin.claim_daily(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + if not plat.checkin or not plat.checkin.url then + return { status = 400, json = { error = plat.name .. " 不支持每日签到(平台未声明 checkin)" } } + end + -- 账号池路径:claim 某个池账号;否则旧路径单凭据。 + local tok, acctEntry + if args.account_id and args.account_id ~= "" then + acctEntry = poolGet(plat.id, args.account_id) + if not acctEntry then return { status = 400, json = { error = "账号不存在" } } end + tok = __llmsproxy_secrets.get(acctEntry.credentialRef) + else + tok = __llmsproxy_secrets.get("channels:" .. plat.id) + end + if not tok then return { status = 400, json = { error = "未登录" } } end + + -- 平台可自定义领取请求形状;默认 Bearer + POST {}。 + -- 参考实现的特殊形状(SDK-HMAC-SHA256 签名等)由该平台的 claim 钩子生成 + -- (见 PLATFORMS 的 claim_fn),这里是通用路径。 + local spec = { + url = plat.checkin.url, + method = plat.checkin.method or "POST", + headers = plat.checkin.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }, + body = plat.checkin.body or "{}", + } + if plat.claim_fn then + local custom, cerr = plat.claim_fn(plat, tok, args) + if cerr then return { status = 400, json = { error = cerr } } end + if custom then spec = custom end + end + + local resp, err = __llmsproxy_http(spec) + if err then + logEvent("领取失败 " .. plat.id .. ": " .. err) + return { status = 400, json = { error = err } } + end + + local acct + if acctEntry then + acct = acctEntry + else + acct = plugin.state.accounts[plat.id] or {} + end + acct.last_claim = os.time and os.time() or 0 + acct.last_claim_status = resp.status + -- 平台可提供解析器把响应转成可读摘要(余额/获得额度) + if plat.claim_result_fn and resp.status == 200 then + local ok, summary = pcall(plat.claim_result_fn, resp.body) + if ok and summary then acct.last_claim_summary = summary end + end + if acctEntry then + poolAdd(plat.id, acctEntry) + else + plugin.state.accounts[plat.id] = acct + end + logEvent("领取返回 " .. tostring(resp.status)) + return { status = 200, json = { ok = true, upstream_status = resp.status, body = resp.body, summary = acct.last_claim_summary } } +end + +-- ============================================================================ +-- 定时任务:自动领取 +-- ============================================================================ +-- +-- 声明了 schedule 的插件会得到内核 ticker 驱动(无浏览器也执行), +-- 且任务跑在本插件自己的 VM 上 —— 所以任务里读 state/保险库与钩子看到的是同一份。 +plugin.schedule = { + { fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 }, +} + +function plugin.auto_claim() + -- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。 + plugin.state.settings = plugin.state.settings or {} + if plugin.state.settings.auto_checkin == false then return end + for _, plat in ipairs(PLATFORMS) do + local cap = CHANNEL_CREDITS[plat.id] + if not cap or not cap.claim then goto continue end + local legacy = accountOf(plat.id) + if legacy and legacy.enabled then + plugin.claim_daily({ platform = plat.id }) + end + for _, a in ipairs(poolList(plat.id)) do + if a.enabled ~= false and __llmsproxy_secrets.get(a.credentialRef) then + plugin.claim_daily({ platform = plat.id, account_id = a.id }) + end + end + ::continue:: + end +end + +-- ============================================================================ +-- 对外 HTTP 端点(设置页调用) +-- ============================================================================ + +plugin.http = { + { path = "channels/platforms", method = "GET", handler = "list_platforms", + roles = { "admin" }, description = "平台清单与账号状态" }, + { path = "channels/login", method = "POST", handler = "start_login", + roles = { "admin" }, description = "发起某平台的登录" }, + { path = "channels/login/status", method = "GET", handler = "poll_login", + roles = { "admin" }, description = "轮询登录会话状态" }, + { path = "channels/login/complete", method = "POST", handler = "complete_login", + roles = { "admin" }, description = "登录完成:存凭据并接入源" }, + { path = "channels/claim", method = "POST", handler = "claim_daily", + roles = { "admin" }, description = "手动领取某平台每日额度" }, + { path = "channels/status", method = "GET", handler = "status_page", + roles = { "admin" }, description = "接入状态与最近日志" }, + -- OAuth 回调落点:平台重定向回来带 code。 + { path = "channels/callback", method = "GET", handler = "oauth_callback", + public = false, roles = { "admin" }, description = "OAuth 回调" }, + -- 账号池 / 模型开关 / 供应商治理 / 备份(对齐参考 channel-pack RPC 面) + { path = "channels/account/add", method = "POST", handler = "account_add", + roles = { "admin" }, description = "向平台添加账号(占位或带凭据)" }, + { path = "channels/account/list", method = "POST", handler = "account_list", + roles = { "admin" }, description = "列平台账号池" }, + { path = "channels/account/update", method = "POST", handler = "account_update", + roles = { "admin" }, description = "改账号昵称/启用态" }, + { path = "channels/account/delete", method = "POST", handler = "account_delete", + roles = { "admin" }, description = "删除账号(含凭据与上游源)" }, + { path = "channels/account/test", method = "POST", handler = "account_test", + roles = { "admin" }, description = "测账号可达" }, + { path = "channels/account/refresh", method = "POST", handler = "account_refresh", + roles = { "admin" }, description = "刷新账号(重同步源)" }, + { path = "channels/account/reorder", method = "POST", handler = "account_reorder", + roles = { "admin" }, description = "调账号优先级" }, + { path = "channels/model/list", method = "POST", handler = "model_list", + roles = { "admin" }, description = "列平台模型与开关态" }, + { path = "channels/model/set_disabled", method = "POST", handler = "model_set_disabled", + roles = { "admin" }, description = "开关单个模型" }, + { path = "channels/model/set_all_disabled", method = "POST", handler = "model_set_all_disabled", + roles = { "admin" }, description = "全开/全关平台模型" }, + { path = "channels/provider/status", method = "POST", handler = "provider_status", + roles = { "admin" }, description = "全平台状态(账号数/模型数/关闭态)" }, + { path = "channels/provider/set_enabled", method = "POST", handler = "provider_set_enabled", + roles = { "admin" }, description = "供应商整体开关" }, + { path = "channels/credits/claim_all", method = "POST", handler = "credits_claim_all", + roles = { "admin" }, description = "一键领取全平台每日额度" }, + { path = "channels/backup/export", method = "POST", handler = "backup_export", + roles = { "admin" }, description = "导出账号备份(含凭据)" }, + { path = "channels/backup/import", method = "POST", handler = "backup_import", + roles = { "admin" }, description = "导入账号备份" }, + { path = "channels/auto_checkin", method = "POST", handler = "auto_checkin", + roles = { "admin" }, description = "自动签到开关读写" }, + { path = "channels/credits/status", method = "POST", handler = "credits_status", + roles = { "admin" }, description = "逐账号签到状态" }, + { path = "channels/credits/balances", method = "POST", handler = "credits_balances", + roles = { "admin" }, description = "逐账号余额" }, + { path = "channels/credits/permanent_lock", method = "POST", handler = "credits_permanent_lock", + roles = { "admin" }, description = "永久积分锁定" }, + { path = "channels/account/reset", method = "POST", handler = "account_reset", + roles = { "admin" }, description = "清账号限流标记" }, + { path = "channels/account/retest", method = "POST", handler = "account_retest", + roles = { "admin" }, description = "重测账号" }, + { path = "channels/model/set_disabled_many", method = "POST", handler = "model_set_disabled_many", + roles = { "admin" }, description = "批量开关模型" }, + { path = "channels/model/clear_dead", method = "POST", handler = "model_clear_dead", + roles = { "admin" }, description = "清死模型" }, + { path = "channels/provider/get_order", method = "POST", handler = "provider_get_order", + roles = { "admin" }, description = "供应商显示顺序" }, + { path = "channels/provider/set_order", method = "POST", handler = "provider_set_order", + roles = { "admin" }, description = "设置供应商显示顺序" }, + { path = "channels/gateway/get_enabled", method = "POST", handler = "gateway_get_enabled", + roles = { "admin" }, description = "网关开关状态" }, + { path = "channels/gateway/set_enabled", method = "POST", handler = "gateway_set_enabled", + roles = { "admin" }, description = "网关整体开关" }, + { path = "channels/backup/status", method = "POST", handler = "backup_status", + roles = { "admin" }, description = "备份健康度" }, + { path = "channels/usage/token_ledger", method = "POST", handler = "usage_token_ledger", + roles = { "admin" }, description = "token 用量快照" }, + { path = "channels/usage/badge", method = "POST", handler = "usage_badge", + roles = { "admin" }, description = "渠道徽标" }, + { path = "channels/usage/badge_preference", method = "POST", handler = "usage_badge_preference", + roles = { "admin" }, description = "徽标显示偏好" }, + { path = "channels/onboarding/status", method = "POST", handler = "onboarding_status", + roles = { "admin" }, description = "新人任务状态" }, + { path = "channels/onboarding/claim", method = "POST", handler = "onboarding_claim", + roles = { "admin" }, description = "领取新人奖励" }, +} + +-- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、 +-- base_url)。两个用途: +-- 1. 运维把某渠道指向自建代理/镜像(厂商被墙、企业出口、审计要求) +-- 2. 测试把端点指到假上游,行为测试才能不打真厂商 +-- 只允许改已知字段,未知键拒绝 —— 端点声明是协议形状的一部分,静默加字段 +-- 会破坏后续逻辑对形状的假设。 +-- ============================================================================ +-- 账号池 RPC(对齐参考 account.* 契约) +-- ============================================================================ +-- account_add:向平台加一个账号(占位或带凭据)。 +-- 对应参考 account.create;登录类的流程返回登录 URL,凭据在 login 完成后回填。 +function plugin.account_add(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local aid = args.account_id or newToken():sub(1, 12) + if poolGet(plat.id, aid) then return { status = 400, json = { error = "账号已存在: " .. aid } } end + local ref = "channels:" .. plat.id .. ":" .. aid + if args.credential and args.credential ~= "" then + local _, serr = __llmsproxy_secrets.set(ref, args.credential) + if serr then return { status = 400, json = { error = serr } } end + end + local now = os.time and os.time() or 0 + local entry = { + id = aid, provider = plat.id, + nickname = args.nickname or aid, + enabled = args.enabled ~= false, + credentialRef = ref, + refreshable = args.refreshable or false, + createdAt = now, + expiresAt = args.expires_at, + last_claim = 0, last_claim_status = 0, last_claim_summary = "", + } + poolAdd(plat.id, entry) + logEvent("账号入池 " .. plat.id .. "/" .. aid) + -- 已带凭据的账号立即同步上游源;未带凭据的等 login 完成 + if args.credential and args.credential ~= "" then syncAccountSource(plat.id, aid) end + return { status = 200, json = { ok = true, account_id = aid, credentialRef = ref } } +end + +-- account_list:列平台的账号(对齐参考 account.list,含余额状态字段) +function plugin.account_list(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local out = {} + for _, a in ipairs(poolList(plat.id)) do + local has = __llmsproxy_secrets.get(a.credentialRef) + out[#out+1] = { + id = a.id, nickname = a.nickname, enabled = a.enabled ~= false, + credentialRef = a.credentialRef, refreshable = a.refreshable or false, + createdAt = a.createdAt, expiresAt = a.expiresAt, + has_credential = has ~= nil and has ~= "", + -- 签到/余额展示字段 + last_claim = a.last_claim, last_claim_status = a.last_claim_status, + last_claim_summary = a.last_claim_summary, + } + end + return { status = 200, json = { ok = true, accounts = out } } +end + +-- account_update:改昵称 / enabled(对齐 account.update) +function plugin.account_update(req) + local args = bodyOf(req) + if args.account_id == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local acct = poolGet(plat.id, args and args.account_id) + if not acct then return { status = 400, json = { error = "账号不存在" } } end + local patch = type(args.patch) == "table" and args.patch or {} + if patch.nickname ~= nil then acct.nickname = tostring(patch.nickname) end + if patch.enabled ~= nil then acct.enabled = patch.enabled and true or false end + poolAdd(plat.id, acct) + -- enabled 变化要同步上游源(禁用即删掉该源) + if patch.enabled ~= nil then + if acct.enabled then syncAccountSource(plat.id, acct.id) + else dropAccountSource(plat.id, acct.id) end + end + return { status = 200, json = { ok = true } } +end + +-- account_delete:删账号 + 删凭据 + 删源(对齐 account.delete) +function plugin.account_delete(req) + local args = bodyOf(req) + if args.account_id == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local acct = poolGet(plat.id, args and args.account_id) + if not acct then return { status = 400, json = { error = "账号不存在" } } end + dropAccountSource(plat.id, acct.id) + __llmsproxy_secrets.delete(acct.credentialRef) + poolRemove(plat.id, acct.id) + logEvent("账号删除 " .. plat.id .. "/" .. acct.id) + return { status = 200, json = { ok = true } } +end + +-- account_test:测账号(对齐 account.test)—— 做一次上游可达探测。 +-- 简化:内核 probe_all 是全局的;这里用 list_sources 里该账号源存在与否 + 凭据非空作为可达判据。 +function plugin.account_test(req) + local args = bodyOf(req) + if args.account_id == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local acct = poolGet(plat.id, args and args.account_id) + if not acct then return { status = 400, json = { error = "账号不存在" } } end + local tok = __llmsproxy_secrets.get(acct.credentialRef) + if not tok or tok == "" then return { status = 200, json = { ok = false, error = "无凭据" } } end + -- 同步一次源(确保最新凭据已入),返回同步结果 + syncAccountSource(plat.id, acct.id) + local srcs, _ = __llmsproxy_kernel.call("list_sources", {}) + local found = false + for _, s in ipairs(srcs or {}) do + if s.name == accountSourceName(plat.id, acct.id) then found = true break end + end + if not found then return { status = 200, json = { ok = false, error = "源未同步(上游可能不可达)" } } end + return { status = 200, json = { ok = true, ms = 1 } } +end + +-- account_refresh:刷新账号(对齐 account.refresh)—— 重新同步源 + 重新查余额 +function plugin.account_refresh(req) + local args = bodyOf(req) + if args.account_id == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local acct = poolGet(plat.id, args and args.account_id) + if not acct then return { status = 400, json = { error = "账号不存在" } } end + local tok = __llmsproxy_secrets.get(acct.credentialRef) + if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end + return { status = 200, json = { ok = true } } +end + +-- account_reorder:调账号优先级(对齐 account.reorder) +function plugin.account_reorder(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local ordered = args and args.ordered_ids + if type(ordered) ~= "table" then return { status = 400, json = { error = "ordered_ids 必须是数组" } } end + for i, aid in ipairs(ordered) do + local acct = poolGet(plat.id, tostring(aid)) + if acct then acct.priority = #ordered - i + 1 poolAdd(plat.id, acct) end + end + return { status = 200, json = { ok = true } } +end + +-- ============================================================================ +-- 模型开关 RPC(对齐参考 model.*) +-- ============================================================================ +function plugin.model_list(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local disabled = disabledModelsOf(plat.id) + local out = {} + for i, m in ipairs(plat.models or {}) do + out[#out+1] = { id = m, name = m, disabled = disabled[m] and true or false, isFree = false } + end + return { status = 200, json = { ok = true, models = out } } +end + +function plugin.model_set_disabled(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local mid = tostring(args and args.model_id or "") + if mid == "" then return { status = 400, json = { error = "缺 model_id" } } end + local disabled = disabledModelsOf(plat.id) + if args.disabled then disabled[mid] = true else disabled[mid] = nil end + -- 重同步该平台的全部源,让模型开关立即生效 + for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end + return { status = 200, json = { ok = true, disabled = disabled[mid] and true or false } } +end + +function plugin.model_set_all_disabled(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local disabled = disabledModelsOf(plat.id) + if args.disabled then + for _, m in ipairs(plat.models or {}) do disabled[m] = true end + else + for k in pairs(disabled) do disabled[k] = nil end + end + for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end + return { status = 200, json = { ok = true } } +end + +-- ============================================================================ +-- 供应商开关 + 状态(对齐参考 provider.*) +-- ============================================================================ +function plugin.provider_status(req) + local out = {} + for _, p in ipairs(PLATFORMS) do + local accts = poolList(p.id) + local enabledN = 0 + for _, a in ipairs(accts) do if a.enabled ~= false then enabledN = enabledN + 1 end end + local disabled = disabledModelsOf(p.id) + local disabledN = 0 + for _, m in ipairs(p.models or {}) do if disabled[m] then disabledN = disabledN + 1 end end + local closed = (p.models and #p.models > 0) and disabledN == #p.models + local state_accounts = plugin.state.accounts[p.id] + local legacyN = (state_accounts and __llmsproxy_secrets.get("channels:" .. p.id)) and 1 or 0 + out[#out+1] = { + id = p.id, name = p.name, flow = p.flow, closed = closed, + models = { total = #(p.models or {}), disabled = disabledN }, + accounts = { total = #accts + legacyN, enabled = enabledN + legacyN }, + } + end + return { status = 200, json = { ok = true, statuses = out } } +end + +function plugin.provider_set_enabled(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local enabled = args.enabled and true or false + -- 对齐参考语义:关闭 = 全关模型 + 停用全部账号;打开 = 清空模型黑名单 + 启用全部账号。 + local disabled = disabledModelsOf(plat.id) + if enabled then + for k in pairs(disabled) do disabled[k] = nil end + for _, a in ipairs(poolList(plat.id)) do a.enabled = true poolAdd(plat.id, a) syncAccountSource(plat.id, a.id) end + -- 旧路径也启用 + local legacy = plugin.state.accounts[plat.id] + if legacy and __llmsproxy_secrets.get("channels:" .. plat.id) then + __llmsproxy_kernel.call("add_source", { source = { + name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai", + api_key = __llmsproxy_secrets.get("channels:" .. plat.id), max_concurrent = 2, + models = (function() local ms = {} for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(), + } }) + end + else + for _, m in ipairs(plat.models or {}) do disabled[m] = true end + for _, a in ipairs(poolList(plat.id)) do a.enabled = false poolAdd(plat.id, a) dropAccountSource(plat.id, a.id) end + __llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id }) + end + logEvent("供应商" .. (enabled and "启用 " or "关闭 ") .. plat.id) + return { status = 200, json = { ok = true } } +end + +-- ============================================================================ +-- 一键领取(对齐参考 credits.claimAll) +-- ============================================================================ +function plugin.credits_claim_all(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local results = {} + local claimed = 0 + for _, a in ipairs(poolList(plat.id)) do + local r = plugin.claim_daily({ platform = plat.id, account_id = a.id, credential_ref = a.credentialRef }) + local ok = r.json and r.json.ok and true or false + if ok then claimed = claimed + 1 end + results[#results+1] = { accountId = a.id, outcome = ok + and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) } + or { kind = "failed", message = (r.json and r.json.error) or "失败" } } + end + -- 无池账号的走旧路径 + if #poolList(plat.id) == 0 then + local r = plugin.claim_daily({ platform = plat.id }) + local ok = r.json and r.json.ok and true or false + if ok then claimed = claimed + 1 end + results[#results+1] = { accountId = "legacy", outcome = ok + and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) } + or { kind = "failed", message = (r.json and r.json.error) or "失败" } } + end + return { status = 200, json = { ok = true, summary = { claimed = claimed }, results = results } } +end + +-- ============================================================================ +-- 备份导出/导入(对齐参考 backup.*) +-- ============================================================================ +function plugin.backup_export(req) + local payload = {} + payload.version = 1 + payload.exportedAt = os.time and os.time() or 0 + payload.accounts = {} + payload.credentials = {} + payload.modelDisabled = plugin.state.modelDisabled or {} + payload.autoCheckin = plugin.state.settings and plugin.state.settings.auto_checkin or true + for _, p in ipairs(PLATFORMS) do + for _, a in ipairs(poolList(p.id)) do + payload.accounts[#payload.accounts+1] = a + local tok = __llmsproxy_secrets.get(a.credentialRef) + if tok then payload.credentials[a.credentialRef] = tok end + end + -- 旧路径单账号也备份 + local lt = __llmsproxy_secrets.get("channels:" .. p.id) + if lt then payload.credentials["channels:" .. p.id] = lt end + end + return { status = 200, json = { ok = true, payload = payload } } +end + +function plugin.backup_import(req) + local args = bodyOf(req) + local payload = args and args.payload + if type(payload) ~= "table" then return { status = 400, json = { error = "payload 必须是对象" } } end + local n = 0 + -- 恢复凭据 + if type(payload.credentials) == "table" then + for ref, tok in pairs(payload.credentials) do + if type(ref) == "string" and type(tok) == "string" and tok ~= "" then + __llmsproxy_secrets.set(ref, tok) n = n + 1 + end + end + end + -- 恢复账号池 + if type(payload.accounts) == "table" then + for _, a in ipairs(payload.accounts) do + if type(a) == "table" and a.id and a.provider then + poolAdd(tostring(a.provider), { + id = tostring(a.id), provider = tostring(a.provider), + nickname = a.nickname or a.id, enabled = a.enabled ~= false, + credentialRef = a.credentialRef or ("channels:" .. a.provider .. ":" .. a.id), + refreshable = a.refreshable or false, createdAt = a.createdAt or (os.time and os.time() or 0), + expiresAt = a.expiresAt, last_claim = 0, last_claim_status = 0, last_claim_summary = "", + }) + end + end + end + -- 恢复模型黑名单 + if type(payload.modelDisabled) == "table" then + plugin.state.modelDisabled = payload.modelDisabled + end + -- 恢复自动签到开关 + if payload.autoCheckin ~= nil then + plugin.state.settings = plugin.state.settings or {} + plugin.state.settings.auto_checkin = payload.autoCheckin and true or false + end + -- 全部账号重新同步源 + for _, p in ipairs(PLATFORMS) do + for _, a in ipairs(poolList(p.id)) do syncAccountSource(p.id, a.id) end + end + logEvent("备份已导入,恢复 " .. n .. " 条凭据") + return { status = 200, json = { ok = true, restored = n } } +end + +-- auto_checkin 开关读写 +function plugin.auto_checkin(req) + plugin.state.settings = plugin.state.settings or {} + local args = bodyOf(req) + if args.enabled ~= nil then + plugin.state.settings.auto_checkin = args.enabled and true or false + logEvent("自动签到" .. (plugin.state.settings.auto_checkin and "开启" or "关闭")) + end + return { status = 200, json = { ok = true, autoCheckin = plugin.state.settings.auto_checkin ~= false } } +end + +-- ============================================================================ +-- 签到状态 + 逐账号余额(对齐参考 credits.status / credits.balances) +-- ============================================================================ +-- fetchAccountJSON 用某账号凭据发一次请求,返回 (rec_table, nil) 或 (nil, err)。 +local function fetchAccountJSON(spec, tok) + local headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" } + local resp, err = __llmsproxy_http({ + url = spec.url, method = spec.method or "GET", + headers = headers, body = spec.body, + }) + if err then return nil, err end + if resp.status ~= 200 then return nil, "HTTP " .. tostring(resp.status) end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return nil, "响应无法解析" end + return rec, nil +end + +-- credits_status:逐账号查签到状态(对齐参考 credits.status; +-- 无状态端点的平台如实返回 null,与参考对 CodeArts/LobsterAI 的处理一致) +function plugin.credits_status(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local out = {} + for _, a in ipairs(poolList(plat.id)) do + local status = nil + local cs = plat.checkin_status + if cs then + local tok = __llmsproxy_secrets.get(a.credentialRef) + if tok then + local rec, err = fetchAccountJSON(cs, tok) + if rec and plat.checkin_status.status_fn then + local okf, st = pcall(plat.checkin_status.status_fn, rec) + if okf then status = st end + elseif err then + status = { error = err } + end + end + end + out[#out+1] = { accountId = a.id, nickname = a.nickname, status = status } + end + return { status = 200, json = { ok = true, accounts = out } } +end + +-- credits_balances:逐账号查余额(对齐参考 credits.balances; +-- 无 balance 端点的平台返回 {balance=null, error="平台未声明余额端点"}, +-- 不编造数字 —— 与参考注释「不伪装成余额」同语义) +function plugin.credits_balances(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local cap = CHANNEL_CREDITS[plat.id] or {} + local out = {} + for _, a in ipairs(poolList(plat.id)) do + local entry = { accountId = a.id, balance = nil, error = nil } + local b = plat.balance + if not b then + entry.error = "平台未声明余额端点" + else + local tok = __llmsproxy_secrets.get(a.credentialRef) + if not tok then + entry.error = "无凭据" + else + local rec, err = fetchAccountJSON(b, tok) + if err then + entry.error = err + elseif b.total_fn then + local okf, total = pcall(b.total_fn, rec) + if okf and type(total) == "number" then + entry.balance = { total = total, unit = cap.unit or "credits" } + else + entry.error = "余额解析失败" + end + end + end + end + out[#out+1] = entry + end + return { status = 200, json = { ok = true, accounts = out } } +end + +-- 永久积分锁定(对齐参考 credits.permanentLock:锁定后仅使用 windowDays 内到期的积分) +function plugin.credits_permanent_lock(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + plugin.state.settings = plugin.state.settings or {} + if args.locked ~= nil then + if type(args.locked) ~= "boolean" then + return { status = 400, json = { error = "locked 必须是布尔值" } } + end + plugin.state.settings["lock_" .. plat.id] = args.locked + logEvent("永久积分" .. (args.locked and "锁定 " or "解除 ") .. plat.id) + end + return { status = 200, json = { ok = true, locked = plugin.state.settings["lock_" .. plat.id] == true, + windowDays = 3 } } +end + +-- account.reset / resetAll:清除账号限流标记(对齐参考 account.reset) +function plugin.account_reset(req) + local args = bodyOf(req) + if args.account_id == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + if args.account_id == "__all__" then + for _, a in ipairs(poolList(plat.id)) do a.rateLimited = nil poolAdd(plat.id, a) end + return { status = 200, json = { ok = true, reset = #poolList(plat.id) } } + end + local acct = poolGet(plat.id, args and args.account_id) + if not acct then return { status = 400, json = { error = "账号不存在" } } end + acct.rateLimited = nil + poolAdd(plat.id, acct) + return { status = 200, json = { ok = true } } +end + +-- account.retest / retestAll:重测账号(对齐参考 account.retest:触发条件不同,实现同 test) +function plugin.account_retest(req) + local args = bodyOf(req) + if args.account_id == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + if args.account_id == "__all__" then + local results = {} + for _, a in ipairs(poolList(plat.id)) do + local r = plugin.account_test({ platform = plat.id, account_id = a.id }) + results[#results+1] = { accountId = a.id, ok = r.json and r.json.ok and true or false, + error = r.json and r.json.error or nil } + end + return { status = 200, json = { ok = true, results = results } } + end + return plugin.account_test({ platform = plat.id, account_id = args.account_id }) +end + +-- model.setDisabledMany:批量开关(对齐参考 model.setDisabledMany) +function plugin.model_set_disabled_many(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local ids = args and args.model_ids + if type(ids) ~= "table" then return { status = 400, json = { error = "model_ids 必须是数组" } } end + local disabled = disabledModelsOf(plat.id) + for _, mid in ipairs(ids) do + if args.disabled then disabled[tostring(mid)] = true else disabled[tostring(mid)] = nil end + end + for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end + return { status = 200, json = { ok = true } } +end + +-- model.clearDead:清死模型(对齐参考 model.clearDead:从黑名单移除已下线的 id) +function plugin.model_clear_dead(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local disabled = disabledModelsOf(plat.id) + local removed = {} + for mid in pairs(disabled) do + local alive = false + for _, m in ipairs(plat.models or {}) do if m == mid then alive = true break end end + if not alive then disabled[mid] = nil removed[#removed+1] = mid end + end + for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end + return { status = 200, json = { ok = true, removed = removed } } +end + +-- 供应商排序(对齐参考 provider.getOrder/setOrder) +function plugin.provider_get_order(req) + local order = plugin.state.providerOrder + if type(order) ~= "table" then order = {} end + return { status = 200, json = { ok = true, order = order } } +end + +function plugin.provider_set_order(req) + local args = bodyOf(req) + local order = args and args.order + if type(order) ~= "table" then return { status = 400, json = { error = "order 必须是字符串数组" } } end + local cleaned = {} + for _, id in ipairs(order) do + if platformById(tostring(id)) then cleaned[#cleaned+1] = tostring(id) end + end + plugin.state.providerOrder = cleaned + return { status = 200, json = { ok = true } } +end + +-- 网关开关(对齐参考 gateway.getEnabled/setEnabled:本插件的一个上游源面开关) +-- 这里网关开关语义=channels 提供的源是否参与调度;用 set_plugin_enabled 内核动作 +-- 不合适(那是插件级),我们用「全部模型黑名单+全部账号停用」已由 provider_set_enabled +-- 承担,这里改为读取当前接入状态做查询、写入复用 provider_set_enabled。 +function plugin.gateway_get_enabled(req) + local any = false + for _, p in ipairs(PLATFORMS) do + local disabled = disabledModelsOf(p.id) + local total = #(p.models or {}) + local allOff = total > 0 + for _, m in ipairs(p.models or {}) do if not disabled[m] then allOff = false break end end + if not allOff then any = true break end + end + return { status = 200, json = { ok = true, enabled = any, + apiKey = nil, address = nil, models = { source = "channels" } } } +end + +function plugin.gateway_set_enabled(req) + local args = bodyOf(req) + if type(args.enabled) ~= "boolean" then + return { status = 400, json = { error = "非空布尔 enabled 必填" } } + end + -- 全平台整体开关(复用 provider_set_enabled 语义) + for _, p in ipairs(PLATFORMS) do + plugin.provider_set_enabled({ platform = p.id, enabled = args.enabled }) + end + logEvent("网关整体" .. (args.enabled and "开启" or "关闭")) + return { status = 200, json = { ok = true } } +end + +-- backup.status:备份健康度(对齐参考 backup.status:账号数/无过期时间账号数) +function plugin.backup_status(req) + local total, noExpiry = 0, 0 + for _, p in ipairs(PLATFORMS) do + for _, a in ipairs(poolList(p.id)) do + total = total + 1 + if a.expiresAt == nil then noExpiry = noExpiry + 1 end + end + end + return { status = 200, json = { ok = true, accounts = total, withoutExpiry = noExpiry } } +end + +-- usage.tokenLedger:本网关的 token 用量快照(对齐参考 usage.tokenLedger)。 +-- 数据源:网关 /api/stats(插件走回环取,与 usage 插件同思路)。 +function plugin.usage_token_ledger(req) + local args = bodyOf(req) + local limit = tonumber(args and args.limit) or 50 + local key = (req and req.key) or "" + local url = "http://127.0.0.1:8081/api/stats?period=all&limit=" .. tostring(limit) + if key ~= "" then + url = url .. "&key=" .. (key:len() <= 6 and key or ("***" .. key:sub(-6))) + end + local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000 }) + if err or not resp or resp.status ~= 200 then + return { status = 200, json = { ok = true, snapshot = { available = false, + error = err or (resp and tostring(resp.status) or "no response") } } } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then + return { status = 200, json = { ok = true, snapshot = { available = false, error = "stats 无法解析" } } } + end + -- 汇总 token 总量(兼容 stats 的不同形状) + local prompt, completion, requests = 0, 0, 0 + local rows = rec.requests or rec.rows or rec.data or {} + if type(rows) == "table" then + for _, r in ipairs(rows) do + if type(r) == "table" then + prompt = prompt + (tonumber(r.prompt_tokens) or 0) + completion = completion + (tonumber(r.completion_tokens) or 0) + requests = requests + 1 + end + end + end + return { status = 200, json = { ok = true, snapshot = { available = true, + tokens = { prompt = prompt, completion = completion, total = prompt + completion }, + requests = requests, raw = rec } } } +end + +-- usage.badge:渠道徽标(对齐参考 usage.badge;用 credits_balances 的汇总) +function plugin.usage_badge(req) + local args = bodyOf(req) + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "provider 不能为空" } } end + local j = plugin.credits_balances({ platform = plat.id }) + local accounts = asArrLua(j.json and j.json.accounts) + local total, known = 0, false + for _, a in ipairs(accounts) do + if type(a.balance) == "table" and type(a.balance.total) == "number" then + total = total + a.balance.total + known = true + end + end + return { status = 200, json = { ok = true, badge = known + and { kind = "credits", total = total, unit = (CHANNEL_CREDITS[plat.id] or {}).unit or "credits" } + or { kind = "unavailable" } } } +end + +-- usage.badgePreference:徽标显示偏好(读/写,全局一个;非法值拒绝不回落) +function plugin.usage_badge_preference(req) + plugin.state.settings = plugin.state.settings or {} + local args = bodyOf(req) + if args.preference ~= nil then + local valid = { auto = true, subscription = true, credits = true } + if not valid[args.preference] then + return { status = 400, json = { error = "preference 必须是 auto/subscription/credits 之一" } } + end + plugin.state.settings.badge_preference = args.preference + end + return { status = 200, json = { ok = true, preference = plugin.state.settings.badge_preference or "auto" } } +end + +-- onboarding.status / claim:新人任务(对齐参考 onboarding.*;仅 raccoon 有实测端点) +function plugin.onboarding_status(req) + local args = bodyOf(req) + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local ob = plat.onboarding + if not ob then + return { status = 200, json = { ok = true, supported = false, + message = plat.name .. " 未声明新人任务端点(参考实现同样如实返回 null)" } } + end + -- 已领取判定:查 state + local claimed = plugin.state.settings and plugin.state.settings["onboard_" .. plat.id] == true + return { status = 200, json = { ok = true, supported = true, + tasks = { desktop_login_reward = claimed }, earned = claimed and ob.reward_points or 0, + total = ob.reward_points } } +end + +function plugin.onboarding_claim(req) + local args = bodyOf(req) + local plat = platformById(args and args.platform) + if not plat or not plat.onboarding then + return { status = 400, json = { error = "该平台无新人任务" } } + end + -- raccoon 实测端点:POST {apiBase}/login/points/grant + local tok = __llmsproxy_secrets.get("channels:" .. plat.id) + if not tok then return { status = 400, json = { error = "未登录" } } end + local resp, err = __llmsproxy_http({ + url = plat.base_url .. "/login/points/grant", method = "POST", + headers = { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }, + body = "{}", + }) + if err then return { status = 400, json = { error = err } } end + if resp.status ~= 200 then return { status = 400, json = { error = "HTTP " .. tostring(resp.status) } } end + plugin.state.settings = plugin.state.settings or {} + plugin.state.settings["onboard_" .. plat.id] = true + logEvent("新人奖励已领 " .. plat.id) + return { status = 200, json = { ok = true, points = plat.onboarding.reward_points } } +end + +-- asArrLua(Lua 侧的空表规整):桥接层 Go→Lua 不涉及,但 handler 内部把 +-- credits_balances 的 json.accounts 再传给别的 handler 时要能处理空表。 +function asArrLua(t) + if type(t) ~= "table" then return {} end + if #t > 0 then return t end + return {} +end + +function plugin.set_endpoints(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local allowed = { base_url = true, login_url = true } + local changed = {} + for k in pairs(allowed) do + if args[k] then plat[k] = tostring(args[k]) changed[#changed+1] = k end + end + if type(args.device) == "table" and plat.device then + for _, k in ipairs({ "code_url", "token_url" }) do + if args.device[k] then plat.device[k] = tostring(args.device[k]) changed[#changed+1] = "device." .. k end + end + end + if type(args.register) == "table" and plat.register then + for _, k in ipairs({ "url" }) do + if args.register[k] then plat.register[k] = tostring(args.register[k]) changed[#changed+1] = "register." .. k end + end + end + if type(args.state) == "table" and plat.state then + for _, k in ipairs({ "state_url", "token_url", "account_url" }) do + if args.state[k] then plat.state[k] = tostring(args.state[k]) changed[#changed+1] = "state." .. k end + end + end + if type(args.checkin) == "table" and plat.checkin then + for _, k in ipairs({ "url" }) do + if args.checkin[k] then plat.checkin[k] = tostring(args.checkin[k]) changed[#changed+1] = "checkin." .. k end + end + end + if type(args.callback) == "table" and plat.callback then + for _, k in ipairs({ "exchange_url" }) do + if args.callback[k] then plat.callback[k] = tostring(args.callback[k]) changed[#changed+1] = "callback." .. k end + end + end + if #changed == 0 then return { status = 400, json = { error = "无可识别的端点字段" } } end + logEvent("端点已改写 " .. plat.id .. ": " .. table.concat(changed, ",")) + return { status = 200, json = { ok = true, changed = changed } } +end + +function plugin.list_platforms(req) + local out = {} + for _, p in ipairs(PLATFORMS) do + local acct = accountOf(p.id) or {} + local has = __llmsproxy_secrets.get("channels:" .. p.id) + out[#out + 1] = { + id = p.id, name = p.name, flow = p.flow, + logged_in = has ~= nil, + account = acct.account, + enabled = acct.enabled, + last_claim = acct.last_claim, + } + end + return { status = 200, json = { platforms = out } } +end + +function plugin.status_page(req) + local r = __llmsproxy_kernel.call("status", {}) + return { status = 200, json = { + accounts = plugin.state.accounts, + log = plugin.state.log, + kernel_ready = r and r.ready or false, + } } +end + +-- oauth_callback 处理平台重定向:校验 nonce,然后按平台的 callback 模式解析。 +-- +-- nonce 校验是必需的:没有它,别人构造一个回调就能把**自己的**凭据塞进 +-- 这个网关的登录流程。 +-- +-- callback 模式(platform.callback.mode)决定如何从回调 query 解出凭据: +-- nil/"code" 标准 OAuth:直接把 code 当凭据交给 complete_login。 +-- "trae" 字节 TRAE 的 token 直传形态,解析逐字对齐参考实现 +-- parseTraeCallbackDetailed + exchangeTraeCallback(见 traeCallback)。 +function plugin.oauth_callback(req) + local nonce = req.query and req.query.state + if not nonce or nonce == "" then + return { status = 400, body = "missing state" } + end + sweepSessions() + local s = nil + for _, sess in pairs(sessions()) do + if sess.nonce == nonce then s = sess break end + end + if not s then + return { status = 400, body = "unknown or expired login session" } + end + if s.flow ~= "callback" then + return { status = 400, body = "session is not a callback flow" } + end + local plat = platformById(s.platform) + local cb = plat and plat.callback or {} + local mode = cb.mode or "code" + + local credential, account, cerr + if mode == "trae" then + credential, account, cerr = traeCallback(plat, req.query) + elseif mode == "code" and cb.token_url then + -- 标准 OAuth code → token 交换(Gemini / LobsterAI)。凭据是交换回的 access_token。 + local code = req.query.code + if not code or code == "" then + cerr = "missing code" + else + local body = (cb.token_body or "") + :gsub("{code}", code) + :gsub("{verifier}", s.device_verifier or "") + local resp, err = __llmsproxy_http({ + url = cb.token_url, + method = "POST", + headers = { ["Content-Type"] = "application/x-www-form-urlencoded", ["Accept"] = "application/json" }, + body = body, + }) + if err then + cerr = "token 交换失败: " .. err + elseif resp.status ~= 200 then + cerr = "token 交换失败(HTTP " .. tostring(resp.status) .. ")" + else + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" or not rec.access_token then + cerr = "token 交换响应缺少 access_token" + else + credential = rec.access_token + account = "oauth" + end + end + end + else + credential = req.query.code + account = "oauth" + if not credential or credential == "" then cerr = "missing code" end + end + if cerr then + s.state = "failed" + s.message = cerr + return { status = 400, body = cerr } + end + + local res = plugin.complete_login({ platform = s.platform, credential = credential, account = account }) + if res.json and res.json.error then + return { status = 400, body = res.json.error } + end + s.state = "completed" + s.account = account + return { status = 200, body = "ok" } +end + +-- traeCallback 解析 TRAE 的 token 直传回调并(必要时)走 ExchangeToken。 +-- 返回 (credential, account, nil) 或 (nil, nil, err)。 +-- +-- 行为逐行对齐参考实现 trae-oauth.ts: +-- refreshToken(query 或 userJwt.RefreshToken 回退)存在 → ExchangeToken 换新 +-- access(并轮换 refreshToken);凭据 = 新 access。 +-- 无 refreshToken 但有 userJwt.Token → 直接用它兜底(不走 ExchangeToken)。 +-- 二者皆无、但有 code/authCodeInfo → **PKCE 流**,明确报"暂不支持"而非含糊失败。 +-- 都没有 → 回调无效。 +local function jsonField(raw, key) + if not raw or raw == "" then return "" end + local ok, v = pcall(json.decode, raw) + if not ok or type(v) ~= "table" then return "" end + return v[key] or "" +end + +function traeCallback(plat, q) + q = q or {} + local userJwt = q.userJwt or "" + local refreshToken = q.refreshToken + if not refreshToken or refreshToken == "" then + refreshToken = jsonField(userJwt, "RefreshToken") + end + local jwtToken = jsonField(userJwt, "Token") + local account = jsonField(q.userInfo or "", "ScreenName") + if account == "" then account = "trae" end + + -- PKCE 形态探测:仅在 refreshToken / jwtToken 都空时才判,与参考一致。 + local authCode = q.code or q.authCode or jsonField(q.authCodeInfo or "", "code") + + if (not refreshToken or refreshToken == "") and (not jwtToken or jwtToken == "") then + if authCode and authCode ~= "" then + return nil, nil, "上游返回了 PKCE 授权码(code/authCodeInfo),本实现暂不支持该流程" + end + return nil, nil, "回调未携带 refreshToken / userJwt.Token / code" + end + + -- 分支 1:有 refreshToken → ExchangeToken 换新 access(轮换 refresh) + if refreshToken and refreshToken ~= "" and plat.callback.exchange_url then + local body = (plat.callback.exchange_body + :gsub("{client_id}", plat.callback.client_id or "") + :gsub("{refresh_token}", refreshToken)) + local resp, err = __llmsproxy_http({ + url = plat.callback.exchange_url, + method = "POST", + headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }, + body = body, + }) + if err then return nil, nil, "TRAE ExchangeToken 失败: " .. err end + if resp.status ~= 200 then + return nil, nil, "TRAE ExchangeToken HTTP " .. tostring(resp.status) + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then + return nil, nil, "TRAE ExchangeToken 响应无法解析" + end + local access = rec.Token or rec.token or rec.AccessToken or rec.access_token + if not access or access == "" then + return nil, nil, "TRAE ExchangeToken 响应缺少 Token" + end + return access, account, nil + end + + -- 分支 2:无 refreshToken,用 userJwt.Token 兜底 + return jwtToken, account, nil +end + +-- plugin.on_unload:插件被卸载/重载时回收自己拉起的浏览器栈。进程是插件起的, +-- 收尾也是插件的责任 —— 内核不应知道这些进程的存在。 +plugin.on_unload = function() + reapBrowser() +end + +-- ============================================================================ +-- UI:独立标签页(登录 / 认证 / 配置) +-- ============================================================================ + +plugin.ui = { + page = { + page_id = "channels", + title = "账号接入", + icon = "🔌", + order = 40, + mount = [[ +
+
+
+

渠道控制台

+
+ +
+ + + + + +
+
+
+
+
+ + + +
+ +]], + }, +} + +return plugin diff --git a/internal/lua/quota_opendesign_test.go b/internal/lua/quota_opendesign_test.go index b843c3b..0dc3f23 100644 --- a/internal/lua/quota_opendesign_test.go +++ b/internal/lua/quota_opendesign_test.go @@ -20,8 +20,9 @@ import ( // notice. // // Live responses this fixture copies (both 200, ~1.3s): -// GET https://amr-api.open-design.ai/api/v1/wallet/balance -// GET https://amr-api.open-design.ai/api/v1/billing/summary +// +// GET https://amr-api.open-design.ai/api/v1/wallet/balance +// GET https://amr-api.open-design.ai/api/v1/billing/summary const ( odWallet = `{"balanceUsd":"15.7062","expiresAt":"2026-10-08T23:01:06.662Z",` + `"updatedAt":"2026-10-06T08:02:20.167Z","billingScopeVersion":2,` + diff --git a/internal/lua/route_conflicts.go b/internal/lua/route_conflicts.go new file mode 100644 index 0000000..d54f357 --- /dev/null +++ b/internal/lua/route_conflicts.go @@ -0,0 +1,333 @@ +// Package lua — 服务路径/适配器名冲突裁决(用户要求 A + B) +// +// 用户要求:当两个东西声明同一个「服务路径」时,内核**不许崩溃、不许拒绝 +// 启动**,而是报冲突、把候选都列出来、让运维在 WebUI/WebAPI 里选一个保留, +// 选择持久化生效。两类冲突: +// +// A) 两个插件在 plugin.http 里声明同一条服务路径(如都声明 "stats")。 +// 改造前分发器按加载顺序静默选一个 —— 装个新插件就可能改变既有路径归属。 +// B) 一个插件安装的适配器,名字撞上内置适配器或另一个插件已装的适配器。 +// 改造前这是硬错误(ErrAdapterNameReserved / owned by 另一插件)。 +// +// 统一处理: +// +// - 冲突是**运维策略**,与 .access.json / .kernel-perms.json 同源,单独落 +// .route-conflicts.json。放单独文件而不是塞进既有策略文件,是因为那些文件 +// 形状不同(map[string]string 权限 spec),混进去会让旧二进制读到无法 +// round-trip 的键。 +// - 「安全默认」是**保持现任**:A 类未裁决时该路径回 409 并列出候选(不静默 +// 选一个);B 类未裁决时现任适配器(内置/先装的插件)继续服务,新候选挂起 +// (incumbent 永不被悄悄换掉,这保住了「第三方插件不得篡改内核发行物」这条 +// 信任边界 —— 只有运维显式选了插件版本才会覆盖内置)。 +package lua + +import ( + "encoding/json" + "os" + "sort" + "strings" +) + +// RouteConflict 描述一条被多于一个插件认领的服务路径(A 类)。 +type RouteConflict struct { + Path string `json:"path"` + Candidates []string `json:"candidates"` + Resolved string `json:"resolved"` // 运维选定的赢家插件名;"" = 未裁决(分发回 409) +} + +// AdapterConflict 描述一个被挂起的适配器安装(B 类):某插件想装的适配器名 +// 撞上了现任(内置或另一插件)。现任继续服务,候选挂起等运维裁决。 +type AdapterConflict struct { + Name string `json:"name"` + Incumbent string `json:"incumbent"` // "operator"(含内置)或 "plugin:<名>" + Candidate string `json:"candidate"` // 想装它的插件名 + Resolved string `json:"resolved"` // "incumbent" / "candidate" / "" 未裁决 + candidateCode string // 候选代码,运维选 candidate 时才真正装(仅内存) +} + +const routeConflictsFileName = ".route-conflicts.json" + +// persistedConflicts 是 .route-conflicts.json 的磁盘形态:两张裁决表。 +// routes: 归一化路径 -> 赢家插件名 +// adapters: 适配器名 -> "incumbent" | "candidate:<插件名>" +type persistedConflicts struct { + Routes map[string]string `json:"routes,omitempty"` + Adapters map[string]string `json:"adapters,omitempty"` +} + +func (ps *Plugins) routeConflictsFile() string { + if ps.dir == "" { + return "" + } + return ps.dir + string(os.PathSeparator) + routeConflictsFileName +} + +// normalizeRoutePath 把声明路径归一化:去首尾斜杠。裁决键与分发键共用它, +// 不可能对不上。 +func normalizeRoutePath(p string) string { + return strings.Trim(strings.TrimSpace(p), "/") +} + +// ---- A 类:插件 HTTP 服务路径冲突 ---- + +// RouteConflicts 扫描已加载插件的 http 声明,返回被多于一个插件认领的路径。 +// 只看 loaded 且未禁用的插件:加载失败/被关掉的插件不占路由。 +func (ps *Plugins) RouteConflicts() []RouteConflict { + ps.mu.RLock() + defer ps.mu.RUnlock() + byPath := map[string]map[string]bool{} + for _, p := range ps.plugins { + if p.LoadError != "" || p.Disabled { + continue + } + for _, h := range p.http { + key := normalizeRoutePath(h.Path) + if key == "" { + continue + } + if byPath[key] == nil { + byPath[key] = map[string]bool{} + } + byPath[key][p.Info.Name] = true + } + } + var out []RouteConflict + for path, owners := range byPath { + if len(owners) < 2 { + continue + } + cands := make([]string, 0, len(owners)) + for name := range owners { + cands = append(cands, name) + } + sort.Strings(cands) + resolved := ps.routeWinners[path] + if resolved != "" && !owners[resolved] { + resolved = "" // 赢家已不再声明这条路径 → 回到未裁决,而非指向不存在的赢家 + } + out = append(out, RouteConflict{Path: path, Candidates: cands, Resolved: resolved}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +// RouteWinner 返回某条路径运维选定的赢家插件名;无冲突/未裁决时 ""。 +func (ps *Plugins) RouteWinner(path string) string { + key := normalizeRoutePath(path) + ps.mu.RLock() + defer ps.mu.RUnlock() + return ps.routeWinners[key] +} + +// ResolveRouteConflict 记录运维对某条冲突路径的裁决并落盘。winner 必须是当前 +// 确实声明了这条路径的候选之一 —— 否则分发会永远 404,所以直接拒绝。 +func (ps *Plugins) ResolveRouteConflict(path, winner string) error { + key := normalizeRoutePath(path) + if key == "" { + return &ConflictError{Reason: "empty path"} + } + var cands []string + for _, c := range ps.RouteConflicts() { + if c.Path == key { + cands = c.Candidates + break + } + } + if cands == nil { + return &ConflictError{Reason: "path " + key + " is not contested"} + } + ok := false + for _, c := range cands { + if c == winner { + ok = true + break + } + } + if !ok { + return &ConflictError{Reason: "plugin " + winner + " does not serve " + key} + } + ps.mu.Lock() + if ps.routeWinners == nil { + ps.routeWinners = map[string]string{} + } + ps.routeWinners[key] = winner + ps.saveConflictsLocked() + ps.mu.Unlock() + return nil +} + +// ---- B 类:适配器名冲突 ---- + +// recordAdapterConflict 挂起一个撞名的适配器安装。调用方(InstallAdapter)在 +// 检测到现任存在时调用。若运维此前已裁决「candidate 赢」,返回 shouldApply=true +// 让调用方直接安装(重启后插件再装会自动套用上次裁决,不再二次挂起)。 +func (ps *Plugins) recordAdapterConflict(name, incumbent, candidate, code string) (shouldApply bool) { + ps.mu.Lock() + defer ps.mu.Unlock() + if d := ps.adapterWinners[name]; d != "" { + // 已有持久裁决。candidate:<此插件> → 直接应用。否则把它重新挂回冲突 + // 列表:持久裁决是**默认**,不是终审 —— 新的安装尝试要把冲突重新 + // 摆到运维面前(否则裁决过 incumbent 后冲突消失,运维想改选 candidate + // 就再也没有入口了)。 + if d == "candidate:"+candidate { + return true + } + } + if ps.adapterConflicts == nil { + ps.adapterConflicts = map[string]*AdapterConflict{} + } + ps.adapterConflicts[name] = &AdapterConflict{ + Name: name, + Incumbent: incumbent, + Candidate: candidate, + candidateCode: code, + } + return false +} + +// AdapterConflicts 列出当前挂起的适配器撞名冲突。 +func (ps *Plugins) AdapterConflicts() []AdapterConflict { + ps.mu.RLock() + defer ps.mu.RUnlock() + out := make([]AdapterConflict, 0, len(ps.adapterConflicts)) + for _, c := range ps.adapterConflicts { + out = append(out, AdapterConflict{ + Name: c.Name, Incumbent: c.Incumbent, Candidate: c.Candidate, Resolved: c.Resolved, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// ResolveAdapterConflict 裁决一个挂起的适配器撞名冲突。 +// +// winner == "incumbent" → 保持现任,丢弃挂起候选。 +// winner == "candidate" → 真正安装候选插件的适配器(覆盖现任),记录归属。 +// 这是运维显式授权一个插件覆盖内置/他人适配器的唯一入口。 +// +// 裁决持久化:下次该插件再装同名适配器会自动套用(不再挂起)。 +func (ps *Plugins) ResolveAdapterConflict(name, winner string) error { + ps.mu.Lock() + c := ps.adapterConflicts[name] + ps.mu.Unlock() + if c == nil { + return &ConflictError{Reason: "adapter " + name + " has no pending conflict"} + } + switch winner { + case "incumbent": + ps.mu.Lock() + if ps.adapterWinners == nil { + ps.adapterWinners = map[string]string{} + } + ps.adapterWinners[name] = "incumbent" + delete(ps.adapterConflicts, name) + ps.saveConflictsLocked() + ps.mu.Unlock() + return nil + case "candidate": + // 真正安装候选代码。绕过 reserved 门禁:这正是运维显式授权覆盖的语义。 + if ps.vm == nil { + return &ConflictError{Reason: "no adapter VM available"} + } + if err := ps.vm.LoadAdapterSource(name, c.candidateCode); err != nil { + return err + } + effective := ps.vm.EffectiveAdapterName(name) + ps.adapterOwner().set(effective, c.Candidate) + if effective != name { + ps.adapterOwner().set(name, c.Candidate) + } + ps.mu.Lock() + if ps.adapterWinners == nil { + ps.adapterWinners = map[string]string{} + } + ps.adapterWinners[name] = "candidate:" + c.Candidate + delete(ps.adapterConflicts, name) + ps.saveConflictsLocked() + ps.mu.Unlock() + return nil + default: + return &ConflictError{Reason: "winner must be \"incumbent\" or \"candidate\""} + } +} + +// ConflictError 是裁决动作被拒的原因,点名性质(用户要求「避免静默错误」)。 +type ConflictError struct{ Reason string } + +func (e *ConflictError) Error() string { return "conflict: " + e.Reason } + +// ErrAdapterConflictPending 是 InstallAdapter 在撞名时返回的 typed 错误: +// 安装没有成功,但也不是硬失败 —— 它被挂起等运维裁决。插件/UI 据此区分 +// 「代码写错」与「撞名待裁决」。 +type ErrAdapterConflictPending struct { + Name string + Incumbent string +} + +func (e *ErrAdapterConflictPending) Error() string { + return "adapter " + e.Name + " conflicts with the current " + e.Incumbent + + "; the incumbent keeps serving until an operator chooses a winner (admin UI → Conflicts)" +} + +// ---- 持久化 ---- + +// LoadRouteConflicts 读取持久化的裁决(A 的路径赢家 + B 的适配器赢家)。 +// 缺文件是全新安装的正常态;损坏文件记录后忽略而非致命。 +func (ps *Plugins) LoadRouteConflicts() error { + path := ps.routeConflictsFile() + if path == "" { + return nil + } + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + var pc persistedConflicts + if err := json.Unmarshal(b, &pc); err != nil { + return err + } + ps.mu.Lock() + defer ps.mu.Unlock() + if ps.routeWinners == nil { + ps.routeWinners = map[string]string{} + } + for k, v := range pc.Routes { + ps.routeWinners[normalizeRoutePath(k)] = v + } + if ps.adapterWinners == nil { + ps.adapterWinners = map[string]string{} + } + for k, v := range pc.Adapters { + ps.adapterWinners[k] = v + } + return nil +} + +// saveConflictsLocked 落盘两张裁决表。调用方持 ps.mu。 +func (ps *Plugins) saveConflictsLocked() { + path := ps.routeConflictsFile() + if path == "" { + return + } + pc := persistedConflicts{} + if len(ps.routeWinners) > 0 { + pc.Routes = map[string]string{} + for k, v := range ps.routeWinners { + pc.Routes[k] = v + } + } + if len(ps.adapterWinners) > 0 { + pc.Adapters = map[string]string{} + for k, v := range ps.adapterWinners { + pc.Adapters[k] = v + } + } + if pc.Routes == nil && pc.Adapters == nil { + _ = os.Remove(path) + return + } + writeJSONAtomic(path, pc) +} diff --git a/internal/lua/secrets_crypto_test.go b/internal/lua/secrets_crypto_test.go new file mode 100644 index 0000000..28fe838 --- /dev/null +++ b/internal/lua/secrets_crypto_test.go @@ -0,0 +1,227 @@ +package lua + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// ---- 测试用加密桩:与 config.SecretBox 同语义(AEAD + enc:v1 前缀)---- + +const testEncPrefix = "enc:v1:" + +// realBox:真 AEAD-GCM(与 config.SecretBox 同原语),行为一致。 +type realBox struct { + aead cipher.AEAD +} + +func newRealBox(t *testing.T) *realBox { + t.Helper() + digest := sha256.Sum256([]byte("test-master-key")) + block, err := aes.NewCipher(digest[:]) + if err != nil { + t.Fatal(err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + return &realBox{aead: aead} +} + +func (b *realBox) Encrypt(plain string) (string, error) { + nonce := make([]byte, b.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + sealed := b.aead.Seal(nonce, nonce, []byte(plain), nil) + return testEncPrefix + b64RawURL(sealed), nil +} + +func (b *realBox) Decrypt(v string) (string, error) { + if !strings.HasPrefix(v, testEncPrefix) { + return v, nil + } + raw, err := b64RawURLDecode(strings.TrimPrefix(v, testEncPrefix)) + if err != nil { + return v, err + } + n := b.aead.NonceSize() + if len(raw) < n { + return v, os.ErrInvalid + } + plain, err := b.aead.Open(nil, raw[:n], raw[n:], nil) + if err != nil { + return v, err + } + return string(plain), nil +} + +// failBox 永远解密失败(模拟 master.key 被换/丢失)。 +type failBox struct{} + +func (failBox) Encrypt(plain string) (string, error) { return testEncPrefix + "AAAA", nil } +func (failBox) Decrypt(v string) (string, error) { + if !strings.HasPrefix(v, testEncPrefix) { + return v, nil + } + return v, os.ErrPermission +} + +func b64RawURL(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } +func b64RawURLDecode(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(s) +} + +// TestSecretVaultEncryptedAtRest 验证保险库落盘是密文(enc:v1 前缀), +// 且读回时解密为原值。明文 token 绝不能出现在磁盘字节里。 +func TestSecretVaultEncryptedAtRest(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(nil, dir) + ps.SetSecretBox(newRealBox(t)) + + if err := ps.SetSecret("channels", "channels:minimax", "SUPER-TOKEN-123"); err != nil { + t.Fatalf("SetSecret: %v", err) + } + + // 磁盘字节必须不含明文 + raw, err := os.ReadFile(filepath.Join(dir, ".secrets", "channels.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "SUPER-TOKEN-123") { + t.Fatalf("★ 保险库明文落盘! 文件内容含 token:\n%s", raw) + } + if !strings.Contains(string(raw), testEncPrefix) { + t.Fatalf("落盘值应带 %s 前缀(统一密文形态):\n%s", testEncPrefix, raw) + } + + // 读回是原值 + v, ok, err := ps.GetSecret("channels", "channels:minimax") + if err != nil || !ok || v != "SUPER-TOKEN-123" { + t.Fatalf("round-trip 失败: v=%q ok=%v err=%v", v, ok, err) + } + + // 文件权限 0600、目录 0700 + if fi, err := os.Stat(filepath.Join(dir, ".secrets", "channels.json")); err != nil || fi.Mode().Perm() != 0o600 { + t.Fatalf("vault 文件权限应 0600, 实得 %v (err=%v)", fi.Mode(), err) + } + if fi, err := os.Stat(filepath.Join(dir, ".secrets")); err != nil || fi.Mode().Perm() != 0o700 { + t.Fatalf("vault 目录权限应 0700, 实得 %v (err=%v)", fi.Mode(), err) + } +} + +// TestSecretVaultPlaintextFallbackAndUpgrade:旧明文库可读(向后兼容), +// 且下一次写会整体升级为密文。 +func TestSecretVaultPlaintextFallbackAndUpgrade(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(nil, dir) + // 1) 无 box 时写入 = 旧版本形态(明文) + if err := ps.SetSecret("channels", "k", "OLD-PLAIN"); err != nil { + t.Fatal(err) + } + // 2) 换上有 box 的新版本:旧明文必须能读出来 + ps.SetSecretBox(newRealBox(t)) + v, ok, err := ps.GetSecret("channels", "k") + if err != nil || !ok || v != "OLD-PLAIN" { + t.Fatalf("旧明文读回应原值: v=%q ok=%v err=%v", v, ok, err) + } + // 3) 任意一次写(哪怕写别的键)会把整个文件升级为密文 + if err := ps.SetSecret("channels", "k2", "NEW"); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(filepath.Join(dir, ".secrets", "channels.json")) + if strings.Contains(string(raw), "OLD-PLAIN") { + t.Fatalf("★ 升级写后旧明文值仍在盘上:\n%s", raw) + } + var m map[string]string + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(m["k"], testEncPrefix) { + t.Fatalf("旧值应被重封为密文: %v", m["k"]) + } + // 4) 两个值都还能读回 + for key, want := range map[string]string{"k": "OLD-PLAIN", "k2": "NEW"} { + if v, _, _ := ps.GetSecret("channels", key); v != want { + t.Fatalf("升级后 %q: got %q want %q", key, v, want) + } + } +} + +// TestSecretVaultWrongMasterKey:master.key 换掉后读库必须**报错**而不是静默 +// 返回空 —— 空会让插件重新登录并覆盖文件,把可恢复问题变成凭据丢失。 +func TestSecretVaultWrongMasterKey(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(nil, dir) + ps.SetSecretBox(newRealBox(t)) + if err := ps.SetSecret("channels", "k", "SECRET"); err != nil { + t.Fatal(err) + } + // 换一把钥匙(模拟 master.key 丢失重建) + ps.SetSecretBox(failBox{}) + if _, _, err := ps.GetSecret("channels", "k"); err == nil { + t.Fatal("★ 换 master key 后读库未报错 —— 会静默吞凭据") + } +} + +// ---- 并发压力:多插件同时读写各自的库 ---- + +// TestSecretVaultConcurrentPlugins 压力:8 个插件 × 每插件 2 goroutine 并发 +// set/get/delete 自己的键。断言:无数据串台(跨插件不可见)、无丢写、文件数正确。 +func TestSecretVaultConcurrentPlugins(t *testing.T) { + dir := t.TempDir() + ps := NewPlugins(nil, dir) + ps.SetSecretBox(newRealBox(t)) + + const plugins = 8 + const perG = 50 + var wg sync.WaitGroup + for p := 0; p < plugins; p++ { + name := "plug" + string(rune('A'+p)) + for g := 0; g < 2; g++ { + wg.Add(1) + go func(name string, g int) { + defer wg.Done() + for i := 0; i < perG; i++ { + key := name + ":k" + string(rune('a'+g)) + if err := ps.SetSecret(name, key, name+"-token"); err != nil { + t.Errorf("SetSecret(%s,%s): %v", name, key, err) + return + } + v, ok, err := ps.GetSecret(name, key) + if err != nil || !ok || v != name+"-token" { + t.Errorf("GetSecret(%s,%s)=%q ok=%v err=%v", name, key, v, ok, err) + return + } + // 跨插件必须不可见 + other := "plug" + string(rune('A'+(p+1)%plugins)) + if _, ok, _ := ps.GetSecret(other, key); ok { + t.Errorf("★ 凭据串台: %s 的键在 %s 可见", name, other) + return + } + if g == 1 && i%10 == 9 { + _ = ps.DeleteSecret(name, key) + } + } + }(name, g) + } + } + wg.Wait() + + entries, err := os.ReadDir(filepath.Join(dir, ".secrets")) + if err != nil { + t.Fatal(err) + } + if len(entries) > plugins { + t.Errorf("vault 文件数 %d 超过插件数 %d(应一插件一文件)", len(entries), plugins) + } +} diff --git a/internal/lua/ser_check_test.go b/internal/lua/ser_check_test.go new file mode 100644 index 0000000..29a44fd --- /dev/null +++ b/internal/lua/ser_check_test.go @@ -0,0 +1,148 @@ +package lua + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +func TestAnthropicSerializerShape(t *testing.T) { + vm := NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + if !vm.HasFn("anthropic", "serialize_response") { + t.Fatal("anthropic 未声明 serialize_response") + } + out, err := vm.Transform("anthropic", "serialize_response", + `{"model":"claude-x","id":"msg_1","content":"hello","finish_reason":"stop","token_usage":{"prompt":10,"completion":5,"total":15}}`) + if err != nil { + t.Fatal(err) + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(out), &m); err != nil { + t.Fatalf("not JSON: %s", out) + } + if m["type"] != "message" || m["role"] != "assistant" { + t.Errorf("Anthropic 形状不对: %s", out) + } + if m["stop_reason"] != "end_turn" { + t.Errorf("stop_reason = %v, want end_turn", m["stop_reason"]) + } + blocks, _ := m["content"].([]interface{}) + if len(blocks) != 1 { + t.Fatalf("blocks = %v", m["content"]) + } + b0 := blocks[0].(map[string]interface{}) + if b0["type"] != "text" || b0["text"] != "hello" { + t.Errorf("首个块应为 text/hello: %v", b0) + } + u := m["usage"].(map[string]interface{}) + if u["input_tokens"] != float64(10) || u["output_tokens"] != float64(5) { + t.Errorf("usage 字段名/值不对: %v", u) + } + // 内核不得混入 OpenAI 字段 + for _, k := range []string{"object", "choices", "usage.prompt_tokens"} { + if strings.Contains(out, `"`+k+`"`) { + t.Errorf("OpenAI 字段 %q 混入 Anthropic 输出: %s", k, out) + } + } +} + +// TestAnthropicDeserializeRequestShape 验证 anthropic 适配器的 ingress 解析 +// (deserialize_request)输出与内核原 Go 兜底(anthropicRequest.toUnified)逐字段 +// 一致。这是"让出"的前提:若两边产出不同,"谁解析"就会悄悄改变行为。 +func TestAnthropicDeserializeRequestShape(t *testing.T) { + vm := NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + // 适配器必须声明 ingress 并提供钩子,内核才会让出。 + if !vm.HasFn("anthropic", "deserialize_request") { + t.Fatal("anthropic 未声明 deserialize_request") + } + + // system 两种拼写 + content 字符串/块数组 + thinking 缺省 + in := `{ + "model": "claude-x", + "max_tokens": 128, + "system": [{"type":"text","text":"be terse"},{"type":"text","text":"!"}], + "messages": [ + {"role":"user","content":"hi"}, + {"role":"assistant","content":[{"type":"text","text":"hello"}]} + ], + "temperature": 0.5, + "top_p": 0.9, + "top_k": 40, + "stop_sequences": ["STOP"], + "stream": true + }` + out, err := vm.Transform("anthropic", "deserialize_request", in) + if err != nil { + t.Fatalf("deserialize_request: %v", err) + } + var u struct { + Model string `json:"model"` + System string `json:"system"` + Messages []json.RawMessage `json:"messages"` + MaxTokens int `json:"max_tokens"` + Stream bool `json:"stream"` + Temperature *float64 `json:"temperature"` + TopP *float64 `json:"top_p"` + TopK int `json:"top_k"` + StopSequences []string `json:"stop_sequences"` + Thinking *struct { + Enabled *bool `json:"enabled"` + } `json:"thinking"` + } + if err := json.Unmarshal([]byte(out), &u); err != nil { + t.Fatalf("not JSON: %s", out) + } + + if u.Model != "claude-x" { + t.Errorf("model = %q", u.Model) + } + // Go 侧 systemToString 把 [{text:"be terse"},{text:"!"}] 拼成 "be terse!" + // (text 块按序拼接,无分隔符)。Lua 必须一致。 + if u.System != "be terse!" { + t.Errorf("system = %q, want %q (Go 侧拼接语义)", u.System, "be terse!") + } + if len(u.Messages) != 2 { + t.Fatalf("messages = %d", len(u.Messages)) + } + if u.MaxTokens != 128 || !u.Stream { + t.Errorf("max_tokens/stream = %d/%v", u.MaxTokens, u.Stream) + } + if u.Temperature == nil || *u.Temperature != 0.5 { + t.Errorf("temperature = %v", u.Temperature) + } + if u.TopP == nil || *u.TopP != 0.9 || u.TopK != 40 { + t.Errorf("top_p/top_k = %v/%d", u.TopP, u.TopK) + } + if len(u.StopSequences) != 1 || u.StopSequences[0] != "STOP" { + t.Errorf("stop_sequences = %v", u.StopSequences) + } + // 缺省 thinking → Go 侧显式发 {enabled:false};Lua 必须一致。 + if u.Thinking == nil || u.Thinking.Enabled == nil || *u.Thinking.Enabled != false { + t.Errorf("缺省 thinking 应为 {enabled:false}, 实得 %s", out) + } +} + +// TestAnthropicDeserializeRejectsMissingMessages 验证 Lua 侧与 Go 侧一样, +// 对缺 messages 的请求报错(而不是产出空请求)。 +func TestAnthropicDeserializeRejectsMissingMessages(t *testing.T) { + vm := NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + if _, err := vm.Transform("anthropic", "deserialize_request", `{"model":"x","max_tokens":8}`); err == nil { + t.Error("缺 messages 应报错") + } +} diff --git a/internal/lua/toolcall_preservation_test.go b/internal/lua/toolcall_preservation_test.go index 74fb40d..ed56ea5 100644 --- a/internal/lua/toolcall_preservation_test.go +++ b/internal/lua/toolcall_preservation_test.go @@ -37,7 +37,7 @@ func TestAdaptersPreserveToolCalls(t *testing.T) { // Adapters that identify a tool result by the call id. byID := []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral", - "sensenova", "trae", "agentrouter", "opencode", "anthropic"} + "sensenova", "trae", "anthropic"} for _, a := range byID { out, err := vm.Transform(a, "transform_request", body) @@ -118,236 +118,3 @@ func TestAdaptersPreserveToolCalls(t *testing.T) { } } } - -// TestOpenCodeStreamOptionsOnlyWhenStreaming pins the fix for -// "stream_options should be set along with stream": OpenCode Go (and other -// strict OpenAI-compatible upstreams) reject a non-streaming request that -// carries stream_options, which broke every non-stream call through the -// opencode adapter. -func TestOpenCodeStreamOptionsOnlyWhenStreaming(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - - // non-streaming: stream_options must be absent entirely - out, err := vm.Transform("opencode", "transform_request", - `{"model":"m","messages":[{"role":"user","content":"hi"}]}`) - if err != nil { - t.Fatal(err) - } - if strings.Contains(out, "stream_options") { - t.Fatalf("non-streaming request must not carry stream_options: %s", out) - } - - // streaming: stream_options.include_usage must be set - sout, err := vm.Transform("opencode", "transform_request", - `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(sout, "stream_options") || !strings.Contains(sout, "include_usage") { - t.Fatalf("streaming request must carry stream_options.include_usage: %s", sout) - } -} - -// TestOpenCodeGoVsZenReasoning pins the one behavioural difference that made a -// shared adapter wrong: OpenCode Go's thinking mode REQUIRES the assistant -// turn's reasoning_content to be echoed back ("The `reasoning_content` in the -// thinking mode must be passed back to the API"), while the Zen free pool must -// not receive it. Hence two purpose-built adapters. -func TestOpenCodeGoVsZenReasoning(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - - body := `{"model":"m","messages":[ - {"role":"user","content":"hi"}, - {"role":"assistant","content":"","reasoning_content":"I should read the file.","tool_calls":[{"id":"call_1","type":"function","function":{"name":"read","arguments":"{\"path\":\"/x\"}"}}]}, - {"role":"tool","tool_call_id":"call_1","content":"r"} - ]}` - - // Go: reasoning_content must survive, and the tool call must stay paired. - gout, err := vm.Transform("opencodego", "transform_request", body) - if err != nil { - t.Fatalf("opencodego: %v", err) - } - if !strings.Contains(gout, "I should read the file.") { - t.Errorf("opencodego must pass reasoning_content back (thinking mode requires it): %s", gout) - } - if !strings.Contains(gout, "call_1") || !strings.Contains(gout, "tool_call_id") { - t.Errorf("opencodego must keep the tool call paired with its result: %s", gout) - } - - // Zen: reasoning_content is stripped. - zout, err := vm.Transform("opencodezen", "transform_request", body) - if err != nil { - t.Fatalf("opencodezen: %v", err) - } - if strings.Contains(zout, "I should read the file.") { - t.Errorf("opencodezen must strip reasoning_content: %s", zout) - } - if !strings.Contains(zout, "call_1") { - t.Errorf("opencodezen must still keep the tool call: %s", zout) - } - - // An assistant turn that carries tool_calls but no reasoning_content must - // get an empty one injected: Go validates that field on tool-calling turns - // and 400s when it is absent, while most clients never send it. - // Verified against the live endpoint that an empty string passes. - bare := `{"model":"m","messages":[ - {"role":"user","content":"go"}, - {"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"read","arguments":"{}"}}]}, - {"role":"tool","tool_call_id":"c1","content":"r"} - ]}` - bout, err := vm.Transform("opencodego", "transform_request", bare) - if err != nil { - t.Fatalf("opencodego bare: %v", err) - } - if !strings.Contains(bout, `"reasoning_content":""`) { - t.Errorf("opencodego must inject an empty reasoning_content on a tool-calling turn: %s", bout) - } - // A plain assistant turn (no tool calls) must NOT gain the field. - plainAsst := `{"model":"m","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"}]}` - pout, err := vm.Transform("opencodego", "transform_request", plainAsst) - if err != nil { - t.Fatal(err) - } - if strings.Contains(pout, "reasoning_content") { - t.Errorf("opencodego must not inject reasoning_content on a plain assistant turn: %s", pout) - } - - // Both must still omit stream_options on non-streaming requests. - for _, a := range []string{"opencodego", "opencodezen"} { - out, err := vm.Transform(a, "transform_request", `{"model":"m","messages":[{"role":"user","content":"hi"}]}`) - if err != nil { - t.Fatalf("%s: %v", a, err) - } - if strings.Contains(out, "stream_options") { - t.Errorf("%s must not send stream_options when not streaming: %s", a, out) - } - } -} - -// TestOpenCodeSessionIsStableForCache pins the session-derivation contract that -// makes the upstream prefix cache usable. -// -// Measured against the live OpenCode Go endpoint: the prefix cache is -// SESSION-scoped. Replaying the same 6032-token prompt hits 5888 cached tokens -// when the session id is fixed, and 0 when it changes per request. Deriving the -// session from meta.timestamp (the previous behaviour) meant every request was a -// new session, so the cache could never hit. -// -// The session must be stable WITHIN one conversation and differ BETWEEN -// conversations. Since generic OpenAI clients send no session id at all -// (verified by capturing real agent traffic: no user / session_id / -// conversation_id / metadata in the body, no session header), the conversation -// is fingerprinted by its FIRST user message — history is replayed every turn, -// so that message is invariant for the life of the conversation. -func TestOpenCodeSessionIsStableForCache(t *testing.T) { - for _, name := range []string{"opencodego", "opencodezen"} { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - meta := func(ts int, body string) map[string]interface{} { - return map[string]interface{}{ - "timestamp": ts, - "body": body, - "source": map[string]interface{}{"name": "somesource", "meta": nil}, - } - } - - // Same conversation, later turn: history grew, first user msg unchanged. - h1, err := vm.BuildHeaders(name, meta(1000, - `{"messages":[{"role":"user","content":"fix the bug"},{"role":"assistant","content":"looking"}]}`)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - h2, err := vm.BuildHeaders(name, meta(2000, - `{"messages":[{"role":"user","content":"fix the bug"},{"role":"assistant","content":"looking"},{"role":"user","content":"and tests"}]}`)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - // Different conversation. - h3, err := vm.BuildHeaders(name, meta(3000, - `{"messages":[{"role":"user","content":"an unrelated task"}]}`)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - - if h1["x-opencode-session"] == "" { - t.Fatalf("%s: session header must be set", name) - } - if h1["x-opencode-session"] != h2["x-opencode-session"] { - t.Errorf("%s: x-opencode-session must be STABLE across turns of one conversation "+ - "(%q vs %q) — the prefix cache is session-scoped", name, h1["x-opencode-session"], h2["x-opencode-session"]) - } - if h1["x-opencode-session"] == h3["x-opencode-session"] { - t.Errorf("%s: a different conversation must get a different session", name) - } - if h1["x-opencode-request"] == h2["x-opencode-request"] { - t.Errorf("%s: x-opencode-request must differ per request", name) - } - // No user message at all falls back to per-source stability. - h4, _ := vm.BuildHeaders(name, meta(4000, `{"messages":[{"role":"system","content":"s"}]}`)) - h5, _ := vm.BuildHeaders(name, meta(5000, `{"messages":[{"role":"system","content":"s"}]}`)) - if h4["x-opencode-session"] != h5["x-opencode-session"] { - t.Errorf("%s: sessionless requests must still be stable per source", name) - } - vm.Stop() - } -} - -// 客户端自带的会话标识(pi 的 x-session-affinity)必须优先于会话指纹: -// 它是平台真实会话 id,历史压缩后也不会漂移,且天然按会话隔离。 -func TestOpenCodePrefersClientSessionID(t *testing.T) { - for _, name := range []string{"opencodego", "opencodezen"} { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - meta := func(body, cs string) map[string]interface{} { - return map[string]interface{}{ - "timestamp": 1000, - "body": body, - "client_session": cs, - "source": map[string]interface{}{"name": "somesource", "meta": nil}, - } - } - - // 同一条会话:历史在长,但客户端会话 id 不变。 - a, err := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, "cs-aaa")) - if err != nil { - t.Fatal(err) - } - b, err := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"a totally different opener"}]}`, "cs-aaa")) - if err != nil { - t.Fatal(err) - } - if a["x-opencode-session"] != b["x-opencode-session"] { - t.Errorf("%s: 客户端会话 id 相同就必须同会话(内容无关): %q vs %q", - name, a["x-opencode-session"], b["x-opencode-session"]) - } - - // 不同会话。 - c, _ := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, "cs-bbb")) - if a["x-opencode-session"] == c["x-opencode-session"] { - t.Errorf("%s: 不同客户端会话必须不同会号", name) - } - - // 客户端没给会话 id 时,回落到指纹(同内容 -> 同会号)。 - d, _ := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, "")) - e, _ := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, "")) - if d["x-opencode-session"] != e["x-opencode-session"] { - t.Errorf("%s: 无客户端会号时应回落到稳定指纹", name) - } - if d["x-opencode-session"] == a["x-opencode-session"] { - t.Errorf("%s: 客户端会号与指纹两条路必须产出不同会号", name) - } - vm.Stop() - } -} diff --git a/internal/lua/usage_cost_test.go b/internal/lua/usage_cost_test.go deleted file mode 100644 index dd36ffa..0000000 --- a/internal/lua/usage_cost_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package lua - -import ( - "encoding/json" - "testing" -) - -// OpenCode Zen/Go 在 usage 里报告输出有多少是思考 token,并在**顶层**用一个 -// **字符串**给出本次费用。前者决定「completion_tokens 里多少是可见回答」, -// 后者是唯一的费用信号。两者都曾被适配器丢掉。 -func TestOpenCodeCostAndReasoningPassthrough(t *testing.T) { - for _, name := range []string{"opencodego", "opencodezen"} { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - - // ---- 非流式 ---- - resp := `{"id":"x","object":"chat.completion","model":"deepseek-v4.1-flash", - "choices":[{"index":0,"message":{"role":"assistant","content":"hi","reasoning_content":"think"}, - "finish_reason":"stop"}], - "usage":{"prompt_tokens":37,"completion_tokens":40,"total_tokens":77, - "prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":37, - "prompt_tokens_details":{"cached_tokens":0}, - "completion_tokens_details":{"reasoning_tokens":40}}, - "cost":"0"}` - out, err := vm.Transform(name, "transform_response", resp) - if err != nil { - t.Fatalf("%s transform_response: %v", name, err) - } - var r struct { - TokenUsage struct { - Completion int `json:"completion"` - Details *struct { - ReasoningTokens int `json:"reasoning_tokens"` - } `json:"completion_tokens_details"` - } `json:"token_usage"` - Cost string `json:"cost"` - } - if err := json.Unmarshal([]byte(out), &r); err != nil { - t.Fatalf("%s unmarshal: %v (%s)", name, err, out) - } - if r.Cost != "0" { - t.Errorf("%s: cost 未透传(上游给的是字符串 \"0\"): %q", name, r.Cost) - } - if r.TokenUsage.Details == nil || r.TokenUsage.Details.ReasoningTokens != 40 { - t.Errorf("%s: completion_tokens_details.reasoning_tokens 未透传: %s", name, out) - } - // 前提校验:reasoning_tokens 确实 ≤ completion_tokens(全为思考)。 - if r.TokenUsage.Details != nil && r.TokenUsage.Details.ReasoningTokens > r.TokenUsage.Completion { - t.Errorf("%s: reasoning 不应超过 completion", name) - } - - // ---- 流式:非零费用也要透传 ---- - chunk := `{"id":"x","object":"chat.completion.chunk","choices":[], - "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15, - "completion_tokens_details":{"reasoning_tokens":4}},"cost":"0.0012"}` - co, err := vm.Transform(name, "transform_stream_chunk", chunk) - if err != nil { - t.Fatalf("%s transform_stream_chunk: %v", name, err) - } - var c struct { - Cost string `json:"cost"` - Usage *struct { - Details *struct { - ReasoningTokens int `json:"reasoning_tokens"` - } `json:"completion_tokens_details"` - } `json:"usage"` - } - if err := json.Unmarshal([]byte(co), &c); err != nil { - t.Fatalf("%s unmarshal chunk: %v (%s)", name, err, co) - } - if c.Usage == nil || c.Usage.Details == nil || c.Usage.Details.ReasoningTokens != 4 { - t.Errorf("%s: 流式 reasoning_tokens 未透传: %s", name, co) - } - if c.Cost != "0.0012" { - t.Errorf("%s: 流式 cost 未透传: %s", name, co) - } - - // ---- 独立的 cost 帧(上游就这么发:choices 为空、只有 cost)---- - only := `{"choices":[],"cost":"0"}` - oo, err := vm.Transform(name, "transform_stream_chunk", only) - if err != nil { - t.Fatalf("%s cost-only chunk: %v", name, err) - } - if oo == "" { - t.Fatalf("%s: 纯 cost 帧被整个丢弃(客户端将看不到费用)", name) - } - var oc struct { - Cost string `json:"cost"` - } - if err := json.Unmarshal([]byte(oo), &oc); err != nil { - t.Fatalf("%s unmarshal cost chunk: %v (%s)", name, err, oo) - } - if oc.Cost != "0" { - t.Errorf("%s: 纯 cost 帧内容丢失: %s", name, oo) - } - - // ---- 回归:没有 cost / 没有 details 时不得凭空造字段 ---- - bare := `{"choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}], - "usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}` - bo, _ := vm.Transform(name, "transform_response", bare) - if got := bo; len(got) > 0 && containsAll(got, `"cost"`) { - t.Errorf("%s: 上游没给 cost 却透传了该字段: %s", name, got) - } - - vm.Stop() - } -} - -func containsAll(s string, subs ...string) bool { - for _, sub := range subs { - found := false - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -// 流式与非流式对同一上游切片的产出必须一致:命中缓存时上游同时给 -// prompt_tokens_details.cached_tokens 和独立的 hit/miss 字段,两边都要透传。 -func TestOpenCodeStreamCacheFieldsMatchNonStream(t *testing.T) { - for _, name := range []string{"opencodego", "opencodezen"} { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - type cache struct { - Details *struct { - CachedTokens int `json:"cached_tokens"` - } `json:"prompt_tokens_details"` - Hit int `json:"prompt_cache_hit_tokens"` - Miss int `json:"prompt_cache_miss_tokens"` - } - usage := `"usage":{"prompt_tokens":5732,"completion_tokens":20,"total_tokens":5752, - "prompt_cache_hit_tokens":5504,"prompt_cache_miss_tokens":228, - "prompt_tokens_details":{"cached_tokens":5504}}` - - // 非流式 - nout, _ := vm.Transform(name, "transform_response", - `{"choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],`+usage+`}`) - var nr struct { - U cache `json:"token_usage"` - } - if err := json.Unmarshal([]byte(nout), &nr); err != nil { - t.Fatalf("%s: %v", name, err) - } - // 流式 - sout, _ := vm.Transform(name, "transform_stream_chunk", - `{"choices":[],`+usage+`}`) - var sr struct { - U cache `json:"usage"` - } - if err := json.Unmarshal([]byte(sout), &sr); err != nil { - t.Fatalf("%s chunk: %v (%s)", name, err, sout) - } - - if nr.U.Hit != 5504 || nr.U.Miss != 228 { - t.Errorf("%s: 非流式 hit/miss 异常: %+v", name, nr.U) - } - if sr.U.Hit != nr.U.Hit || sr.U.Miss != nr.U.Miss { - t.Errorf("%s: 流式 hit/miss 与非流式不一致 (stream %d/%d vs non-stream %d/%d): %s", - name, sr.U.Hit, sr.U.Miss, nr.U.Hit, nr.U.Miss, sout) - } - if sr.U.Details == nil || sr.U.Details.CachedTokens != 5504 { - t.Errorf("%s: 流式 cached_tokens 丢失: %s", name, sout) - } - vm.Stop() - } -} diff --git a/internal/lua/usage_plugin_test.go b/internal/lua/usage_plugin_test.go index 745298b..e52819c 100644 --- a/internal/lua/usage_plugin_test.go +++ b/internal/lua/usage_plugin_test.go @@ -9,17 +9,17 @@ import ( // usage plugin safe to ship, and both are easy to break by accident because they // live in a Lua file nobody compiles. // -// 1) SHIPPED. `usage` is the reason /v1/usage exists at all, and it only exists -// for deployments that receive it. A gateway build that does not embed it -// answers 404 and the frontend shows "后台未开放消费接口" — which reads as a -// bug rather than as a missing optional feature. Same reasoning billing.lua and -// quota.lua are embedded. +// 1. SHIPPED. `usage` is the reason /v1/usage exists at all, and it only exists +// for deployments that receive it. A gateway build that does not embed it +// answers 404 and the frontend shows "后台未开放消费接口" — which reads as a +// bug rather than as a missing optional feature. Same reasoning billing.lua and +// quota.lua are embedded. // -// 2) SELF-SCOPED. The handler reports the CALLER's usage, so it must look the -// caller up by the key in the request, never by a key from its own -// configuration. A plugin that read a configured key would report one -// tenant's spend to every tenant — the exact leak this plugin avoids by -// passing the request's key straight through. +// 2. SELF-SCOPED. The handler reports the CALLER's usage, so it must look the +// caller up by the key in the request, never by a key from its own +// configuration. A plugin that read a configured key would report one +// tenant's spend to every tenant — the exact leak this plugin avoids by +// passing the request's key straight through. func TestUsagePluginShipsAndGuardsItsOwnKey(t *testing.T) { src := readShippedPlugin(t, "usage.lua") @@ -109,4 +109,4 @@ func TestUsagePeriodIsAll(t *testing.T) { if !strings.Contains(readShippedPlugin(t, "usage.lua"), `local PERIOD = "all"`) { t.Error(`usage.lua no longer defaults to period "all"`) } -} \ No newline at end of file +} diff --git a/internal/lua/vm.go b/internal/lua/vm.go index 9ad0e45..1b43ec7 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -22,6 +22,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -67,7 +68,31 @@ type staticInfo struct { name string version string endpoint string - headers map[string]string + // endpointStream is the streaming counterpart of endpoint. It exists so the + // KERNEL never has to know a vendor's streaming path: Gemini streams over the + // same path with a different verb (:generateContent -> :streamGenerateContent), + // and the verb is part of the path, so the two cannot both be static. + // + // Before this field the kernel carried `strings.Replace(ep, + // ":generateContent", ":streamGenerateContent", 1)` — vendor protocol + // knowledge inside the dispatcher. It now lives here, where it belongs: + // the adapter declares both endpoints and the kernel just picks one. + // An adapter that omits endpoint_stream uses endpoint for both (most do). + endpointStream string + headers map[string]string + // registeredAs is the key the adapter was LOADED under, which can differ + // from name when a script declares its own name. Kept so a reload replaces + // the same pool entry rather than silently registering a second one. + registeredAs string + // ingress is the client-facing endpoint path this adapter CLAIMS to parse, + // e.g. "/v1/messages". It is the INBOUND dual of serialize_* (which is + // outbound): when an adapter declares it, the kernel YIELDS parsing of that + // endpoint's request body to the adapter's deserialize_request hook, instead + // of its own built-in parser. The kernel keeps a built-in parser as the + // fallback (R5), so an unconfigured deployment still serves /v1/messages — + // but once an adapter claims the endpoint, protocol iteration lives in the + // adapter, not the kernel. Empty means the adapter claims no ingress. + ingress string } // adapterPool owns the worker states for one adapter. Idle workers are held in @@ -103,6 +128,12 @@ type adapterPool struct { lastGrow time.Time idleRounds int // consecutive janitor rounds that saw reclaimable slack peakInUse int // high-water mark of inUse, for observability + // revision counts how many times this adapter's SOURCE was replaced (or the + // adapter was removed and re-added). Callers that cache a decision derived + // from the script's declared surface read it via AdapterRevision, so a + // runtime rewrite (a plugin installing a new version of its own adapter) + // cannot leave them acting on a stale answer. + revision uint64 // pluginMode makes boot() store the returned table under pluginGlobal // instead of adapterGlobal. Everything else (elastic sizing, reclaim) is // identical, which is why plugins reuse this pool rather than getting a @@ -163,7 +194,7 @@ type PoolStats struct { } func newAdapterPool(name, script string, static staticInfo) *adapterPool { - p := &adapterPool{name: name, script: script, static: static, maxW: 1} + p := &adapterPool{name: name, script: script, static: static, maxW: 1, revision: 1} p.cond = sync.NewCond(&p.mu) return p } @@ -379,12 +410,67 @@ func (p *adapterPool) releaseReservation(n int) { p.mu.Unlock() } +// acquireAcrossSwap checks out a worker, re-reading the registry whenever the +// pool it was handed has already been retired. +// +// Retry (rather than one-shot) because the caller can lose the race repeatedly +// when several goroutines rotate the same adapter at once: each retry can land +// on a pool that the NEXT rotation is already closing. The bound is a small +// number of attempts, not unbounded — a permanently broken adapter must still +// surface an error instead of spinning. +func acquireAcrossSwap(v *VM, name string, p *adapterPool) (*worker, error) { + // A rotation publishes the new pool BEFORE retiring the old one (see + // LoadAdapterSource), so a lookup here can only ever find the newest one. + // The wait is what makes this correct under sustained rotation: several + // goroutines rewriting the same adapter keep retiring the pool this one + // holds, and a caller that gave up at that moment would fail a request for + // no reason other than landing in a microsecond-wide window. + // + // The bound is wall-clock, not attempts, so the behaviour does not change + // with contention. It is generous enough to ride out a burst of rotations + // and short enough that a genuinely gone adapter still surfaces promptly. + deadline := time.Now().Add(2 * time.Second) + for { + w, err := p.acquire() + if err == nil { + return w, nil + } + if !errors.Is(err, ErrPoolClosed) { + return nil, err + } + fresh := v.pool(name) + if fresh == nil { + return nil, fmt.Errorf("adapter %s not loaded", name) + } + p = fresh + if time.Now().After(deadline) { + return nil, fmt.Errorf("adapter %s: pool kept being replaced for 2s: %w", name, err) + } + // Yield briefly: the new pool needs a moment to publish, and spinning + // here would burn the CPU that the rotation itself needs. + time.Sleep(time.Millisecond) + } +} + +// ErrPoolClosed reports that the pool a caller was about to use from was +// retired between lookup and acquire — which happens on every adapter hot-swap +// (a plugin rotating a token rewrites the adapter, and LoadAdapterSource +// replaces its pool). +// +// It is a sentinel so callers can RETRY against the newly published pool +// instead of surfacing an error to the client. Without it, a token rotation +// under load produces a burst of "adapter X pool closed" failures — the +// rotation is the operator's intent, and the window is microseconds wide, so +// failing the request that happened to land there is strictly worse than +// re-reading the registry. +var ErrPoolClosed = errors.New("adapter pool closed") + func (p *adapterPool) acquire() (*worker, error) { p.mu.Lock() for { if p.closed { p.mu.Unlock() - return nil, fmt.Errorf("adapter %s pool closed", p.name) + return nil, fmt.Errorf("adapter %s: %w", p.name, ErrPoolClosed) } if n := len(p.idle); n > 0 { w := p.idle[n-1] @@ -711,20 +797,72 @@ func (v *VM) ListAdapters() []APIAdapter { return list } +// AdapterForIngress returns the registered name of the adapter that CLAIMS the +// given client-facing endpoint path (its static.ingress), or "" when none does. +// +// This is the kernel-yields decision for an inbound endpoint (user requirement: +// "/v1/messages is the kernel's fallback endpoint; an adapter may declare it and +// the kernel actively yields"). The kernel keeps its own built-in parser as the +// fallback (R5) — AdapterForIngress returning "" means "no adapter claimed it, +// use the built-in", so an unconfigured deployment still serves the endpoint. +// +// Ties (two adapters claiming the same path) resolve to the lexically-first +// name so the choice is deterministic rather than map-iteration order. A real +// deployment should not have two adapters claiming one ingress; when it does, +// the plugin conflict-arbitration surface is the place to make it explicit, but +// a parse endpoint must still pick SOMETHING rather than vary per request. +func (v *VM) AdapterForIngress(path string) string { + if path == "" { + return "" + } + v.mu.RLock() + defer v.mu.RUnlock() + chosen := "" + for name, p := range v.pools { + p.mu.Lock() + claims := p.static.ingress == path + p.mu.Unlock() + if claims && (chosen == "" || name < chosen) { + chosen = name + } + } + return chosen +} + // LoadAdapter compiles and registers an adapter script from a file. +// +// The path is validated for SAFETY before reading (R6): it must sit inside the +// VM's adapter dir and the derived name must be a legal adapter name (no path +// traversal, absolute paths, or whitespace). It does NOT reject built-in names +// here: an operator placing openai.lua in adapter_dir to OVERRIDE the bundled +// openai is the documented hot-override mechanism. Only PLUGIN installs are +// barred from shadowing a built-in (see InstallPluginAdapter), because a +// third-party plugin clobbering the kernel's shipped adapter is tampering, +// whereas the operator editing their own deployment is not. func (v *VM) LoadAdapter(path string) error { + name, err := ValidateAdapterPath(path, v.dir, nil) + if err != nil { + return err + } data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read adapter: %w", err) } - name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) return v.LoadAdapterSource(name, string(data)) } // LoadAdapterSource registers an adapter from source code. Replacing an // existing adapter shuts the old pool down and re-boots lazily, carrying the // configured ceiling over so a hot-reload does not reset elastic sizing. +// +// The name is validated for path SAFETY only (no traversal/absolute/space). +// Built-in names are allowed: an operator override is legitimate. A plugin +// that must NOT shadow a built-in uses InstallPluginAdapter, which adds the +// reserved-name gate. func (v *VM) LoadAdapterSource(name, code string) error { + if err := ValidateAdapterName(name, nil); err != nil { + return err + } static, err := inspectScript(name, code) if err != nil { return err @@ -739,13 +877,67 @@ func (v *VM) LoadAdapterSource(name, code string) error { v.mu.RUnlock() v.mu.Lock() - if p, ok := v.pools[static.name]; ok { - p.shutdown() - } - p := newAdapterPool(static.name, code, *static) + // A reload must also retire an adapter that was loaded under a DIFFERENT + // name than the one this script declares (a plugin can register under a key + // it controls while shipping an adapter that declares the name sources + // reference). Without this, updating that adapter would install a second + // pool and leave the old one serving traffic. + // + // The key can live under EITHER name (this is the "reload replaces the same + // pool entry rather than silently registering a second one" rule): + // + // - pools[static.name] — the pool filed under the DECLARED name (the + // common case, and the only case when a plugin installs under a key that + // matches its script's own name), or + // The pool registry is keyed by the DECLARED name (static.name), always: + // first install files pools[static.name], and a reload replaces that same + // slot. registeredAs only RECORDS the registration key for reverse lookups + // (EffectiveAdapterName scans for it); it is not a second key space. + // + // An earlier version redirected the key to prev.static.registeredAs here, + // assuming pools were filed under the registration key — but first installs + // file under the declared name, so that redirect created a SECOND pool and + // left the old one serving whenever key≠declared (a real bug: a v2 install + // via arbitration left v1 serving, and UpdateAdapter had the same hole). + key := static.name + old := v.pools[key] + // The new pool MUST be filed under the SAME slot as the old one (key), and + // named after that slot, so a later lookup by either the DECLARED name or + // the REGISTRATION KEY finds the one live pool. Naming it after static.name + // would move the pool to the declared-name slot when the incumbent lived + // under the registration key, leaving the registration-key slot pointing at + // the stale (old) pool that shutdown() then tears down — callers reading by + // the registration key see "adapter missing", callers by declared name see + // a pool that isn't the one serving. key is the stable identity; the + // declared name is reachable through static.name. + p := newAdapterPool(key, code, *static) p.setMax(maxW) - v.pools[static.name] = p + // Carry the revision forward so the new source keeps counting ABOVE the old + // one. A cache keyed on (vm, revision, name) must see a value different + // from the previous generation's; restarting at 1 could collide with a + // value an earlier generation already published, which would resurrect a + // stale cache entry — exactly the bug this revision exists to prevent. + // + // The old pool is read while v.mu is HELD (a nested RLock here would + // self-deadlock: sync.RWMutex is not reentrant) and while its own mu is + // free, because shutdown() — which takes p.mu — happens after v.mu is + // released below. No other path can mutate p.revision while we hold v.mu, + // because every writer reaches the pool through v.mu first. + if old != nil { + old.mu.Lock() + p.revision = old.revision + 1 + old.mu.Unlock() + } + v.pools[key] = p v.mu.Unlock() + // shutdown() happens AFTER v.mu is released, and the new pool is already + // published, so a request arriving in between sees the new code. Shutting + // down first would open a window where the adapter is neither old nor new: + // concurrent rotations then fail with "adapter X pool closed" — the exact + // failure a token-rotation plugin would hit under load. + if old != nil { + old.shutdown() + } v.startJanitor() return nil } @@ -761,12 +953,48 @@ func (v *VM) RemoveAdapter(name string) { } } +// poolGlobal returns the Lua global holding a pool's returned table: plugins +// store theirs under one global, adapters under another. Transform and friends +// must read the SAME one boot() wrote, or a plugin-mode pool answers +// "has no table" even though the state is perfectly healthy. +func poolGlobal(p *adapterPool) string { + if p != nil && p.pluginMode { + return pluginGlobal + } + return adapterGlobal +} + func (v *VM) pool(name string) *adapterPool { v.mu.RLock() defer v.mu.RUnlock() return v.pools[name] } +// bundledNames returns the set of adapter names that ship inside the kernel. +// It is derived from the embedded FS (same source writeBundledAdapters uses), +// so a new built-in adapter is automatically reserved and an operator cannot +// clobber it by importing a same-named external adapter. +// BundledAdapterNames is the exported counterpart of bundledNames for callers +// outside the lua package (core.UploadAdapter validates against it). It returns +// the set of adapter names shipped inside the kernel so an external import that +// shadows one is rejected up front (R6). +func (v *VM) BundledAdapterNames() []string { return v.bundledNames() } + +func (v *VM) bundledNames() []string { + entries, err := bundledAdapters.ReadDir("adapters") + if err != nil { + return nil + } + out := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + out = append(out, strings.TrimSuffix(e.Name(), ".lua")) + } + return out +} + func (v *VM) writeBundledAdapters() error { // Derive the set from the embedded FS instead of a hardcoded name list so // adding an adapter never requires maintaining a second list. @@ -794,12 +1022,16 @@ func (v *VM) writeBundledAdapters() error { } // Transform runs adapter.Fn(raw) and returns the resulting string. +// +// One retry on ErrPoolClosed: the pool was swapped by a concurrent rewrite +// (plugin token rotation) between this lookup and the checkout. Re-reading the +// registry gets the new pool, which is exactly what the caller wants. func (v *VM) Transform(name, fn, raw string) (string, error) { p := v.pool(name) if p == nil { return "", fmt.Errorf("adapter %s not loaded", name) } - w, err := p.acquire() + w, err := acquireAcrossSwap(v, name, p) if err != nil { return "", err } @@ -809,7 +1041,7 @@ func (v *VM) Transform(name, fn, raw string) (string, error) { L.SetTop(0) defer L.SetTop(0) - L.GetGlobal(adapterGlobal) + L.GetGlobal(poolGlobal(p)) if L.IsNil(-1) { return "", fmt.Errorf("adapter %s has no table", name) } @@ -923,6 +1155,62 @@ func (v *VM) Endpoint(name string) string { return p.static.endpoint } +// StreamEndpoint returns the adapter's streaming endpoint, or "" when it +// declares none (in which case the caller keeps using Endpoint). +// +// This is the whole mechanism that keeps vendor streaming verbs out of the +// kernel: an adapter whose stream path differs declares endpoint_stream, and +// the dispatcher only ever compares two opaque strings. +func (v *VM) StreamEndpoint(name string) string { + p := v.pool(name) + if p == nil { + return "" + } + p.mu.Lock() + defer p.mu.Unlock() + return p.static.endpointStream +} + +// HasFn reports whether the named adapter declares a callable field fn, +// WITHOUT acquiring a worker and WITHOUT running the script. +// +// It exists for capability probing that runs on the request path: the +// out-bound serializer asks "does this adapter declare serialize_chunk?" once +// per adapter, not once per chunk. Probing by calling Transform would be +// wrong twice over — it costs a pool checkout, and a MISSING hook comes back +// as an error string rather than a boolean. +// +// The probe runs on a throwaway state built from the SAME script text, so the +// answer reflects what the script declares. A script whose table is built +// conditionally on its environment could differ per state; that is already +// true of every other staticInfo field (endpoint, headers), so the pool does +// not get a special exemption here. +func (v *VM) HasFn(name, fn string) bool { + p := v.pool(name) + if p == nil { + return false + } + p.mu.Lock() + script := p.script + p.mu.Unlock() + if script == "" { + return false + } + L := golua.NewState() + defer L.Close() + L.OpenLibs() + setupGlobals(L) + if err := L.DoString(script); err != nil { + return false + } + if L.Type(-1) != golua.LUA_TTABLE { + return false + } + L.GetField(-1, fn) + defer L.Pop(1) + return !L.IsNil(-1) && L.IsFunction(-1) +} + // ---- static inspection (compile-once at load time) ---- func inspectScript(name, code string) (*staticInfo, error) { @@ -937,12 +1225,20 @@ func inspectScript(name, code string) (*staticInfo, error) { L.Close() return nil, fmt.Errorf("adapter script must return a table") } + // The registration key is the default identity; a script MAY declare its own + // name, in which case the declared one wins. That case matters for plugins: + // a plugin registers under a key it controls, but the adapter it installs + // may declare the name sources actually reference — so the bridge reports + // the effective name back instead of guessing. info := &staticInfo{name: name, headers: map[string]string{}} if n := readString(L, -1, "name"); n != "" { info.name = n } + info.registeredAs = name info.version = readString(L, -1, "version") info.endpoint = readString(L, -1, "endpoint") + info.endpointStream = readString(L, -1, "endpoint_stream") + info.ingress = readString(L, -1, "ingress") if getFieldBatch(L, "headers") { luaTableToMap(L, -1, info.headers) L.Pop(1) @@ -1267,3 +1563,111 @@ func jsonDecode(s string) (interface{}, error) { } return v, nil } + +// AdapterRevision returns a monotonically increasing revision for the named +// adapter's CURRENT source. Every LoadAdapterSource / RemoveAdapter bumps it. +// +// Purpose: callers that cache a decision derived from an adapter's declared +// surface (e.g. "does this adapter declare serialize_chunk?") store the +// revision alongside the cached value and treat a mismatch as invalid. That +// removes the need for those callers to be told when to invalidate — which +// they previously were not, so a plugin rewriting an adapter at runtime could +// leave a stale decision in place forever. +// +// The revision is per-adapter rather than global so an unrelated adapter's +// reload does not discard a still-valid cache entry. +func (v *VM) AdapterRevision(name string) uint64 { + p := v.pool(name) + if p == nil { + return 0 + } + p.mu.Lock() + defer p.mu.Unlock() + return p.revision +} + +// InstallPluginAdapter registers an adapter ON BEHALF OF A PLUGIN, with the +// one extra rule plugins (and only plugins) are held to: the name must not +// shadow a built-in adapter. +// +// Why plugins are stricter than operators: an operator dropping openai.lua in +// adapter_dir is overriding their own deployment (documented, intended). A +// third-party plugin registering an adapter named "openai" would silently +// swap out the kernel's shipped protocol translation for code the operator +// never reviewed — tampering with the shipped artifact. R6 requires that to +// fail loudly with the conflict named, not succeed quietly. +// +// This is the entry point Phase 3's plugin adapter-install bridge calls. +func (v *VM) InstallPluginAdapter(name, code string) error { + if err := ValidateAdapterName(name, v.bundledNames()); err != nil { + return err + } + return v.LoadAdapterSource(name, code) +} + +// AdapterVersion returns the version string an adapter declares, or "" when the +// adapter is not loaded. +// +// It exists so an operator (or a plugin's own UI) can SEE that a runtime +// rewrite happened: after a token rotation the version bumps, and /api/status +// reports it. Without that, a rotation is invisible from outside the process. +func (v *VM) AdapterVersion(name string) string { + p := v.pool(name) + if p == nil { + return "" + } + p.mu.Lock() + defer p.mu.Unlock() + return p.static.version +} + +// AdapterIngress returns the client-facing endpoint path an adapter CLAIMS +// (its static.ingress), or "" when it claims none. Exported so operator-facing +// surfaces (and tests) can see which adapter, if any, has taken over parsing of +// an inbound endpoint — the same declaration AdapterForIngress consults. +func (v *VM) AdapterIngress(name string) string { + p := v.pool(name) + if p == nil { + return "" + } + p.mu.Lock() + defer p.mu.Unlock() + return p.static.ingress +} + +// EffectiveAdapterName resolves the name an adapter is actually registered +// under, given the key it was loaded with. +// +// A script may declare its own `name`, and the declared one wins at load time. +// Callers that keep per-adapter state (the plugin ownership table, the +// serializer cache) must key it on the EFFECTIVE name, or the state attaches to +// a key nothing will ever look up again. +func (v *VM) EffectiveAdapterName(registeredAs string) string { + if p := v.pool(registeredAs); p != nil { + p.mu.Lock() + if p.static.name != "" { + n := p.static.name + p.mu.Unlock() + return n + } + p.mu.Unlock() + return registeredAs + } + // The script declared a name different from the key it was loaded under, so + // the pool is filed under the DECLARED name. Find it that way, or every + // caller would conclude "not loaded" and re-register a duplicate. + v.mu.RLock() + defer v.mu.RUnlock() + for _, p := range v.pools { + if p.static.registeredAs == registeredAs { + p.mu.Lock() + n := p.static.name + p.mu.Unlock() + if n != "" { + return n + } + return registeredAs + } + } + return registeredAs +} diff --git a/internal/lua/vm_test.go b/internal/lua/vm_test.go index eac3fce..ca8d8b9 100644 --- a/internal/lua/vm_test.go +++ b/internal/lua/vm_test.go @@ -32,7 +32,7 @@ func TestLoadBundledAdapters(t *testing.T) { for _, a := range adapters { names[a.Name] = true } - for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama", "kimicode", "opencode"} { + for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama", "kimicode"} { if !names[want] { t.Errorf("missing adapter %s (got %v)", want, names) } @@ -88,12 +88,15 @@ func TestTransformRequest(t *testing.T) { t.Fatal(err) } defer vm.Stop() - out, err := vm.Transform("openai", "transform_request", `{"model":"x","disable_thinking":true,"messages":[]}`) + // v2: adapters strip the NEUTRAL field, because that is what the kernel now + // hands them. (The v1 assertion checked disable_thinking, which was the + // kernel's own private field — it no longer appears in the body at all.) + out, err := vm.Transform("openai", "transform_request", `{"model":"x","thinking":{"enabled":true},"messages":[]}`) if err != nil { t.Fatalf("transform: %v", err) } - if strings.Contains(out, "disable_thinking") { - t.Fatalf("disable_thinking not stripped: %s", out) + if strings.Contains(out, "thinking") { + t.Fatalf("中立 thinking 字段未被剥除,会泄给上游: %s", out) } } @@ -112,179 +115,6 @@ func TestBuildHeadersFallbackStatic(t *testing.T) { } } -func TestOpenCodeAdapterFingerprint(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - hdrs, err := vm.BuildHeaders("opencode", map[string]interface{}{ - "api_key": "public", - "timestamp": 1700000000, - "body": `{"model":"x"}`, - "url": "https://opencode.ai/zen/v1/chat/completions", - "method": "POST", - }) - if err != nil { - t.Fatalf("build headers: %v", err) - } - // zen 按客户端指纹路由请求池:UA 必须是 opencode 真实格式, - // 且必须携带 x-opencode-* 身份头,否则带 tools 的请求会被上游 - // 判为匿名客户端并返回 network_error 空流(表现为空回复)。 - if ua := hdrs["User-Agent"]; !strings.HasPrefix(ua, "opencode/") || - !strings.Contains(ua, "ai-sdk/provider-utils/") { - t.Fatalf("opencode adapter must send the real opencode client UA, got %q", ua) - } - for _, h := range []string{"x-opencode-client", "x-opencode-project", "x-opencode-session", "x-opencode-request"} { - if hdrs[h] == "" { - t.Fatalf("opencode adapter must send %q (zen client fingerprint), got %v", h, hdrs) - } - } - hdrs2, _ := vm.BuildHeaders("opencode", map[string]interface{}{"api_key": "public", "timestamp": 1700000001, "body": `{"model":"y"}`, "method": "POST"}) - if hdrs["x-opencode-request"] == hdrs2["x-opencode-request"] { - t.Fatalf("x-opencode-request must vary per request, got %q twice", hdrs["x-opencode-request"]) - } - if hdrs["Authorization"] != "" { - t.Fatalf("opencode adapter must not hardcode Authorization (config api_key supplies it), got %q", hdrs["Authorization"]) - } - // passthrough behaves like the openai adapter - out, err := vm.Transform("opencode", "transform_request", `{"model":"x","disable_thinking":true,"extra_body":{},"messages":[{"role":"user","content":"hi"}]}`) - if err != nil { - t.Fatal(err) - } - if strings.Contains(out, "disable_thinking") || strings.Contains(out, "extra_body") { - t.Fatalf("opencode transform_request must strip provider fields: %s", out) - } -} - -func TestOpenCodeNormalizesRoles(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - // zen 上游只接受 system/user/assistant/tool/latest_reminder; - // OpenAI 系客户端发 `developer`(Claude Code 还会用 `function`)必须归一化成 system。 - body := `{"model":"x","messages":[ - {"role":"developer","content":"you are helpful"}, - {"role":"system","content":"sys"}, - {"role":"user","content":"hi"}, - {"role":"assistant","content":"ok"}, - {"role":"tool","tool_call_id":"t1","content":"{\"ok\":true}"} - ]}` - out, err := vm.Transform("opencode", "transform_request", body) - if err != nil { - t.Fatal(err) - } - if strings.Contains(out, `"developer"`) { - t.Fatalf("developer role must be normalized: %s", out) - } - if !strings.Contains(out, `"system"`) || !strings.Contains(out, `"user"`) { - t.Fatalf("allowed roles must survive: %s", out) - } - if !strings.Contains(out, `"assistant"`) { - t.Fatalf("assistant role must survive: %s", out) - } - if !strings.Contains(out, `"tool"`) { - t.Fatalf("tool role must survive: %s", out) - } -} - -func TestOpenCodeStripsMultiModalParts(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - body := `{"model":"x","messages":[ - {"role":"user","content":[ - {"type":"text","text":"describe"}, - {"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}, - {"type":"input_audio","input_audio":{"data":"QQ","format":"wav"}} - ]}, - {"role":"user","content":[{"type":"image_url","image_url":{"url":"https://ex.com/a.png"}}]}, - {"role":"assistant","content":"plain"} - ]}` - out, err := vm.Transform("opencode", "transform_request", body) - if err != nil { - t.Fatal(err) - } - if strings.Contains(out, "image_url") || strings.Contains(out, "input_audio") { - t.Fatalf("opencode must strip multimodal parts: %s", out) - } - if !strings.Contains(out, "describe") { - t.Fatalf("text parts must survive: %s", out) - } - // image-only message dropped: only the text message and plain assistant message remain - if got := strings.Count(out, `"role"`); got != 2 { - t.Fatalf("image-only message must be dropped, got %d messages: %s", got, out) - } -} - -// TestOpenCodeKeepsToolCallWithEmptyContent pins the fix for an infinite -// "repeated tool call" loop: an assistant turn whose content serialises to an -// empty ARRAY while carrying tool_calls must survive the multimodal strip. -// Dropping it orphans the following tool result, so the model never sees the -// call it already made and re-issues it on every turn. -func TestOpenCodeKeepsToolCallWithEmptyContent(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - - body := `{"model":"x","messages":[ - {"role":"user","content":"read /tmp/x"}, - {"role":"assistant","content":[],"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"read","arguments":"{\"path\":\"/tmp/x\"}"}}]}, - {"role":"tool","tool_call_id":"call_abc","content":"hello"}, - {"role":"user","content":"now summarize"} - ]}` - out, err := vm.Transform("opencode", "transform_request", body) - if err != nil { - t.Fatalf("transform_request: %v", err) - } - - var req struct { - Messages []struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - ToolCalls []struct { - ID string `json:"id"` - } `json:"tool_calls"` - ToolCallID string `json:"tool_call_id"` - } `json:"messages"` - } - if err := json.Unmarshal([]byte(out), &req); err != nil { - t.Fatalf("unmarshal: %v (%s)", err, out) - } - if len(req.Messages) != 4 { - t.Fatalf("expected 4 messages (tool call must not be dropped), got %d: %s", len(req.Messages), out) - } - asst := req.Messages[1] - if asst.Role != "assistant" || len(asst.ToolCalls) != 1 || asst.ToolCalls[0].ID != "call_abc" { - t.Fatalf("assistant tool_calls lost: %s", out) - } - if string(asst.Content) != `""` { - t.Fatalf("empty content must be rewritten to \"\", got %s", asst.Content) - } - if req.Messages[2].Role != "tool" || req.Messages[2].ToolCallID != "call_abc" { - t.Fatalf("tool result must stay paired with its call: %s", out) - } - - // Negative control: a message with neither text nor tool calls is still dropped. - only := `{"model":"x","messages":[ - {"role":"user","content":[{"type":"image_url","image_url":{"url":"https://ex.com/a.png"}}]}, - {"role":"user","content":"keep me"} - ]}` - out2, err := vm.Transform("opencode", "transform_request", only) - if err != nil { - t.Fatal(err) - } - if got := strings.Count(out2, `"role"`); got != 1 { - t.Fatalf("image-only message without tool calls must still be dropped, got %d: %s", got, out2) - } -} - func TestBuildHeadersCustomHook(t *testing.T) { vm := NewVM(freshAdapterDir(t)) if err := vm.Start(); err != nil { @@ -321,33 +151,56 @@ func TestDisableThinkingPassthrough(t *testing.T) { t.Fatal(err) } defer vm.Stop() - body := `{"model":"x","disable_thinking":true,"messages":[{"role":"user","content":"hi"}]}` + // v2: the client speaks the NEUTRAL field. disable_thinking was the kernel's + // private spelling and no longer reaches an adapter at all. + body := `{"model":"x","thinking":{"enabled":false},"messages":[{"role":"user","content":"hi"}]}` out, err := vm.Transform("deepseek", "transform_request", body) if err != nil { t.Fatalf("deepseek transform: %v", err) } var req struct { ExtraBody map[string]interface{} `json:"extra_body"` + Thinking map[string]interface{} `json:"thinking"` } if err := json.Unmarshal([]byte(out), &req); err != nil { t.Fatalf("unmarshal: %v (%s)", err, out) } thinking, ok := req.ExtraBody["thinking"].(map[string]interface{}) if !ok { - t.Fatalf("deepseek should emit extra_body.thinking on disable_thinking: %s", out) + t.Fatalf("deepseek 应把中立的 thinking.enabled=false 映射成 extra_body.thinking: %s", out) } if thinking["type"] != "disabled" { t.Fatalf("thinking.type = %v", thinking["type"]) } + // 关键回归:映射之后,中立字段本身必须已被剥掉,不能随 body 泄给上游。 + if req.Thinking != nil { + t.Fatalf("中立 thinking 字段未被剥除,泄漏进了上游 body: %s", out) + } - // anthropic: thinking is opt-in (Issue 3) — never emitted by default, and - // disable_thinking is not a trigger either. + // anthropic: thinking 是 opt-in —— enabled=false 时不传该字段 + // (Anthropic 没有 type=disabled 这个写法,传了会被拒)。 out2, err := vm.Transform("anthropic", "transform_request", body) if err != nil { t.Fatalf("anthropic transform: %v", err) } if strings.Contains(out2, "thinking") { - t.Fatalf("anthropic should drop thinking when disable_thinking: %s", out2) + t.Fatalf("anthropic 在 thinking.enabled=false 时不应发出 thinking: %s", out2) + } + + // 新增覆盖:enabled=true + budget 应直译成 Anthropic 的原生形状 + outThinking, err := vm.Transform("anthropic", "transform_request", + `{"model":"x","messages":[{"role":"user","content":"hi"}],"thinking":{"enabled":true,"budget_tokens":2048}}`) + if err != nil { + t.Fatalf("anthropic transform: %v", err) + } + var ar struct { + Thinking map[string]interface{} `json:"thinking"` + } + if err := json.Unmarshal([]byte(outThinking), &ar); err != nil { + t.Fatalf("unmarshal: %v (%s)", err, outThinking) + } + if ar.Thinking["type"] != "enabled" || ar.Thinking["budget_tokens"] != float64(2048) { + t.Fatalf("anthropic 原生 thinking 未按中立语义直译: %s", outThinking) } out3, err := vm.Transform("anthropic", "transform_request", `{"model":"x","messages":[{"role":"user","content":"hi"}]}`) if err != nil { @@ -432,74 +285,6 @@ func TestMultimodalTransform(t *testing.T) { } } -func TestOpenCodeAdapterNormalizesDeveloperRole(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatalf("start: %v", err) - } - defer vm.Stop() - - raw := `{"model":"zen","messages":[` + - `{"role":"developer","content":"be concise"},` + - `{"role":"user","content":"hi"},` + - `{"role":"assistant","content":"hello"},` + - `{"role":"function","content":"{\"a\":1}"},` + - `{"role":"latest_reminder","content":"remind"}]}` - out, err := vm.Transform("opencode", "transform_request", raw) - if err != nil { - t.Fatalf("transform_request: %v", err) - } - var req struct { - Messages []struct { - Role string `json:"role"` - } `json:"messages"` - } - if err := json.Unmarshal([]byte(out), &req); err != nil { - t.Fatalf("output not JSON: %v\n%s", err, out) - } - want := []string{"system", "user", "assistant", "system", "latest_reminder"} - if len(req.Messages) != len(want) { - t.Fatalf("got %d messages, want %d: %s", len(req.Messages), len(want), out) - } - for i, w := range want { - if req.Messages[i].Role != w { - t.Errorf("message %d role = %q, want %q", i, req.Messages[i].Role, w) - } - } -} - -func TestOpenCodeAdapterKeepsWhitelistedRolesAndDropsMultimodal(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatalf("start: %v", err) - } - defer vm.Stop() - - raw := `{"messages":[` + - `{"role":"system","content":"sys"},` + - `{"role":"user","content":[{"type":"text","text":"keep"},{"type":"image_url","url":"x"}]},` + - `{"role":"user","content":[{"type":"image_url","url":"x"}]}]}` - out, err := vm.Transform("opencode", "transform_request", raw) - if err != nil { - t.Fatalf("transform_request: %v", err) - } - var req struct { - Messages []struct { - Role string `json:"role"` - Content any `json:"content"` - } `json:"messages"` - } - if err := json.Unmarshal([]byte(out), &req); err != nil { - t.Fatalf("output not JSON: %v\n%s", err, out) - } - if len(req.Messages) != 2 { - t.Fatalf("got %d messages, want 2 (multimodal-only dropped): %s", len(req.Messages), out) - } - if req.Messages[0].Role != "system" || req.Messages[1].Role != "user" { - t.Fatalf("unexpected roles: %s", out) - } -} - func TestAdaptersPassFinishReason(t *testing.T) { vm := NewVM(freshAdapterDir(t)) if err := vm.Start(); err != nil { @@ -511,7 +296,7 @@ func TestAdaptersPassFinishReason(t *testing.T) { // every chunk) must NOT terminate the stream. chunk := `{"choices":[{"index":0,"finish_reason":"tool_calls","delta":{"content":""}}]}` empty := `{"choices":[{"index":0,"finish_reason":"","delta":{"content":"x"}}]}` - for _, name := range []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral", "opencode"} { + for _, name := range []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral"} { out, err := vm.Transform(name, "transform_stream_chunk", chunk) if err != nil { t.Fatalf("%s: %v", name, err) @@ -736,8 +521,7 @@ func TestAdaptersReportZeroCacheHit(t *testing.T) { defer vm.Stop() // OpenAI-shaped upstreams: usage.prompt_tokens_details.cached_tokens = 0 - openaiLike := []string{"openai", "deepseek", "sensenova", "opencode", - "agentrouter", "github", "groq", "kimicode", "mistral"} + openaiLike := []string{"openai", "deepseek", "sensenova", "github", "groq", "kimicode", "mistral"} respBody := `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}], "usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12, "prompt_tokens_details":{"cached_tokens":0}}}` @@ -1465,7 +1249,7 @@ func TestNonStreamToolCallsPreserved(t *testing.T) { for _, name := range []string{ "openai", "sensenova", "trae", "deepseek", "github", - "groq", "kimicode", "mistral", "opencode", "agentrouter", + "groq", "kimicode", "mistral", } { out, err := vm.Transform(name, "transform_response", body) if err != nil { @@ -1741,9 +1525,8 @@ func TestToolIDSanitize(t *testing.T) { } } - // ---- openai + agentrouter: same rewrite, and both sites must stay in sync ---- - // agentrouter fronts claude-opus-4-8, so it inherits the same id rule. - for _, adapterName := range []string{"openai", "agentrouter"} { + // ---- openai: tool-id rewrite ---- + for _, adapterName := range []string{"openai"} { for _, id := range []string{"bash:0", "call_ok_123", long} { out, err := vm.Transform(adapterName, "transform_request", body(id)) if err != nil { @@ -1838,12 +1621,12 @@ func TestToolIDSanitizeResponse(t *testing.T) { legal := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) - // ---- openai + agentrouter non-streaming ---- + // ---- openai non-streaming ---- resp := `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[ {"id":"bash:0","type":"function","function":{"name":"bash","arguments":"{\"command\":\"whoami\"}"}}]}, "finish_reason":"tool_calls"}]}` var nonStreamID string - for _, adapterName := range []string{"openai", "agentrouter"} { + for _, adapterName := range []string{"openai"} { out, err := vm.Transform(adapterName, "transform_response", resp) if err != nil { t.Fatalf("%s transform_response: %v", adapterName, err) @@ -1878,7 +1661,7 @@ func TestToolIDSanitizeResponse(t *testing.T) { ID string `json:"id"` } `json:"tool_calls"` } - for _, adapterName := range []string{"openai", "agentrouter"} { + for _, adapterName := range []string{"openai"} { out, err := vm.Transform(adapterName, "transform_stream_chunk", chunk) if err != nil { t.Fatalf("%s transform_stream_chunk: %v", adapterName, err) @@ -1902,7 +1685,7 @@ func TestToolIDSanitizeResponse(t *testing.T) { // ---- a later argument fragment has no id and must stay id-less ---- argChunk := `{"choices":[{"delta":{"tool_calls":[ {"index":0,"function":{"arguments":"{\"command\":\"whoami\"}"}}]},"finish_reason":null}]}` - for _, adapterName := range []string{"openai", "agentrouter"} { + for _, adapterName := range []string{"openai"} { out, err := vm.Transform(adapterName, "transform_stream_chunk", argChunk) if err != nil { t.Fatalf("%s arg fragment: %v", adapterName, err) diff --git a/internal/provider/gemini_endpoint_test.go b/internal/provider/gemini_endpoint_test.go index 7267efc..2eb78ae 100644 --- a/internal/provider/gemini_endpoint_test.go +++ b/internal/provider/gemini_endpoint_test.go @@ -228,12 +228,64 @@ func TestSourceEndpointOverridesTemplate(t *testing.T) { if got, want := p.ChatURL("m1", false), "https://example.test/custom/path"; got != want { t.Errorf("URL = %q, want %q (source endpoint must override the adapter template)", got, want) } - // And the streaming verb rewrite must not damage an unrelated path. - if got := p.ChatURL("m1", true); strings.Contains(got, "streamGenerate") { - t.Errorf("streaming rewrite leaked into a user-spelled path: %q", got) + // And a user-spelled path must survive streaming untouched: the operator + // overrode the template, so the adapter's stream endpoint is not re-applied. + if got := p.ChatURL("m1", true); got != "https://example.test/custom/path" { + t.Errorf("URL = %q, want the user path unchanged for streaming too", got) } } +// TestEndpointPrecedence pins the four-way precedence the endpoint_stream +// mechanism depends on. It exists because the first implementation of +// endpoint_stream DID regress the case above (adapter stream endpoint leaked +// over a user-spelled path) — the precedence is the contract, not an +// implementation detail. +func TestEndpointPrecedence(t *testing.T) { + vm := geminiVM(t) + defer vm.Stop() + + base := config.Source{ + Name: "s", BaseURL: "https://example.test", Adapter: "gemini", + APIKey: "k", MaxConcurrent: 2, + Models: []config.Model{{ID: "m1", Kind: "chat"}}, + } + + t.Run("adapter stream endpoint used when source spells nothing", func(t *testing.T) { + s := base + p := New(s, vm) + if got := p.ChatURL("m1", true); !strings.Contains(got, ":streamGenerateContent") { + t.Errorf("streaming URL = %q, want the adapter's endpoint_stream", got) + } + if got := p.ChatURL("m1", false); !strings.Contains(got, ":generateContent") { + t.Errorf("non-streaming URL = %q, want the adapter's endpoint", got) + } + }) + + t.Run("source endpoint wins over adapter stream endpoint", func(t *testing.T) { + s := base + s.Endpoint = "/custom/path" + p := New(s, vm) + for _, stream := range []bool{false, true} { + if got := p.ChatURL("m1", stream); got != "https://example.test/custom/path" { + t.Errorf("stream=%v URL = %q, want the source path", stream, got) + } + } + }) + + t.Run("source stream endpoint wins over everything", func(t *testing.T) { + s := base + s.Endpoint = "/custom/path" + s.StreamEndpoint = "/custom/stream" + p := New(s, vm) + if got := p.ChatURL("m1", false); got != "https://example.test/custom/path" { + t.Errorf("non-stream URL = %q", got) + } + if got := p.ChatURL("m1", true); got != "https://example.test/custom/stream" { + t.Errorf("stream URL = %q, want the source's explicit stream path", got) + } + }) +} + // TestGeminiResponseStillParses: the endpoint fix must not disturb the body // transform — a request that reaches the right URL still has to come back as a // unified response with Gemini's usageMetadata mapped. diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 11bd00a..ac0fc1a 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -576,33 +576,49 @@ func (p *Provider) ImageEndpoint() string { // modelPlaceholder is the marker an adapter puts in its endpoint template when // the upstream carries the model id in the PATH rather than in the body. -// Gemini is the only such adapter: POST /v1beta/models/{model}:generateContent. // Every other adapter's endpoint is a fixed path, so substituting is a no-op // for them. const modelPlaceholder = "{model}" // ChatURL resolves the full chat URL for a specific model. // -// Two substitutions happen here, both driven by the adapter's endpoint template: +// Two substitutions happen here, both driven by endpoint declarations: // // - "{model}" is replaced by the model id actually being sent. Without this // the gateway would POST to Gemini's model-LIST endpoint, which answers 405. -// - for a streaming request, the ":generateContent" verb becomes -// ":streamGenerateContent". Gemini streams over the same path with a -// different verb, and the verb is part of the path, so the two cannot both -// be static. The rewrite is deliberately narrow: it only fires on the exact -// ":generateContent" suffix, so an adapter whose endpoint merely mentions -// the word keeps its path untouched. +// - a streaming request may use a DIFFERENT endpoint. This is deliberately +// not a vendor rule: the kernel does not know that one vendor's verb +// changes, only that an endpoint declaration said "use this other path +// when streaming". +// +// Precedence (a source that spells its own path takes full responsibility for +// it — the old behaviour, which must not regress): +// +// source.endpoint_stream > source.endpoint > adapter.endpoint_stream > adapter.endpoint +// +// Note the third slot only applies when the source did NOT spell its own +// chat endpoint. A source with `endpoint: /custom/path` keeps that path for +// streaming too, because the operator overrode the adapter's template and the +// kernel has no business re-adding the adapter's stream path on top. func (p *Provider) ChatURL(model string, stream bool) string { ep := p.Endpoint() + if stream { + switch { + case p.cfg.StreamEndpoint != "": + ep = p.cfg.StreamEndpoint + case p.cfg.Endpoint == "": + // Source did not override the chat path, so the adapter's streaming + // endpoint (if any) is authoritative. + if se := p.vm.StreamEndpoint(p.adapter); se != "" { + ep = se + } + } + } if strings.Contains(ep, modelPlaceholder) { // A model id is put in a URL path, so it must be escaped: an id with a // slash would otherwise silently address a different resource. ep = strings.ReplaceAll(ep, modelPlaceholder, url.PathEscape(model)) } - if stream { - ep = strings.Replace(ep, ":generateContent", ":streamGenerateContent", 1) - } return strings.TrimRight(p.cfg.BaseURL, "/") + ep } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index aaea842..59a6d14 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -457,7 +457,7 @@ func TestChatStreamErrorFinishFailsCandidate(t *testing.T) { fmt.Fprint(w, "data: [DONE]\n\n") })) defer up.Close() - p := newTestProvider(t, src("mock", up.URL, "opencode", "m")) + p := newTestProvider(t, src("mock", up.URL, "openai", "m")) _, err := p.ChatStream(context.Background(), &types.ChatRequest{ Model: "m", Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}}, @@ -546,20 +546,23 @@ func TestStandardSSEChunkFinishReason(t *testing.T) { // per-source error format; its reason must reach the client verbatim. func TestAdapterHookCondensesError(t *testing.T) { up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") + // A non-JSON HTML error page: the adapter's transform_error hook must + // distill it into a short reason rather than letting the raw markup + // reach the client or the log. + w.Header().Set("Content-Type", "text/html") w.WriteHeader(503) - fmt.Fprint(w, `{"error":{"type":"FreeUsageLimitError","message":"Rate limit exceeded. Please try again later."}}`) + fmt.Fprint(w, `503 Service Unavailablemaintenance`) })) defer up.Close() - p := newTestProvider(t, src("mock", up.URL, "opencode", "m")) + p := newTestProvider(t, src("mock", up.URL, "openai", "m")) _, err := p.Chat(context.Background(), &types.ChatRequest{ Model: "m", Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}}, }) - if err == nil || !strings.Contains(err.Error(), "zen free pool quota exhausted") { + if err == nil || !strings.Contains(err.Error(), "503 Service Unavailable") { t.Fatalf("adapter hook reason must surface, got %v", err) } - if strings.Contains(err.Error(), "Rate limit exceeded") { + if strings.Contains(err.Error(), "") { t.Fatalf("raw upstream body must not leak past the hook: %v", err) } } diff --git a/internal/types/maxwire.go b/internal/types/maxwire.go new file mode 100644 index 0000000..100abe0 --- /dev/null +++ b/internal/types/maxwire.go @@ -0,0 +1,200 @@ +// Package types — 统一线格式 v2(最大集合 / maximal set) +// +// 设计原则(feature/unified-wire-v2): +// +// 内核只调度,不知道任何具体协议。 +// 适配器负责两个方向的协议转换: +// 入向 上游原生格式 → 本文件的统一格式 +// 出向 本文件的统一格式 → 对外服务协议 +// +// 因此本文件定义的是**市面所有主流 LLM 协议所需字段的并集**: +// OpenAI Chat Completions / Responses、Anthropic Messages、Gemini +// generateContent、Ollama、DeepSeek(reasoning_content)等。 +// 适配器**按需取用**——每个具体协议都是这个超集的一个子集。 +// +// 命名约定(重要): +// - 字段名取"该能力在业界最广为人知的拼写",便于适配器直接映射, +// 而不是为中立而中立地另造一套名字(那会让每个适配器都要维护一张 +// 翻译表,反而更容易出错)。 +// - 但**协议专属容器**(如 Gemini 的 generationConfig、Ollama 的 options) +// 不进入统一格式:它们是"上游协议的形状",属于适配器的内部事务。 +// 统一格式只保留**语义**(temperature / top_k / max_tokens …), +// 由适配器决定装进哪个容器。 +// +// 与 v1 的关键差异: +// - 内核私有字段不再泄漏给适配器(v1 的 disable_thinking / extra_body +// 会被序列化进请求体,10 个适配器被迫各自 = nil 清理) +// - 新增 Anthropic/Gemini/Ollama 需要的字段(system / top_k / +// stop_sequences / safety_settings / seed …),使适配器不必再从 +// extra_body 这种旁路偷字段 +package types + +import "encoding/json" + +// UnifiedRequest 是内核交给适配器的**完整请求**(最大集合)。 +// +// 适配器只读它需要的字段;未用到的字段是"内核提供了但你这条协议不用", +// 不是"内核不知道该字段"。这个方向的信息损失为零。 +type UnifiedRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + + // ---- 采样参数(所有协议都有,名称归一) ---- + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` // Gemini/Ollama/Anthropic 有 + MaxTokens int `json:"max_tokens,omitempty"` // Anthropic 必需 + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` // OpenAI + PresencePenalty *float64 `json:"presence_penalty,omitempty"` // OpenAI + Seed *int64 `json:"seed,omitempty"` // OpenAI/Gemini + + // ---- 停止条件 ---- + Stop []string `json:"stop,omitempty"` // OpenAI + StopSequences []string `json:"stop_sequences,omitempty"` // Anthropic + + // ---- 工具调用 ---- + // Tools 保留 json.RawMessage(宽松的原始形状)而不是结构化的 []ToolDef, + // 原因是**渐进迁移**:内核现有的 12 个适配器与 gateway 都按 []interface{} + // /json.RawMessage 形状序列化请求到适配器。若此处改成 []ToolDef,则 + // 所有适配器与调用点立即编译失败,必须同步改写 19 个非测试点 + 30 个测试。 + // 而 Tools 的真实消费者是适配器(transform_request),内核本身从不解析它, + // 所以用原始形状不损失任何内核侧的统一性——它本就是"原样交给适配器的载荷"。 + // + // ToolDef 类型保留在此包内供新适配器选用(maximal-set 的结构化语义), + // 但内核序列化路径暂不强推它。 + Tools json.RawMessage `json:"tools,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + + // ParallelToolCalls *bool 允许显式 false(OpenAI 语义)。 + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` // OpenAI + + // ---- 结构化输出 ---- + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` // OpenAI + + // ---- 系统提示 ---- + // Anthropic 与 Gemini 都把 system 放在顶层而非 messages 里; + // OpenAI 系则放进 messages[role=system]。统一格式同时提供两条路: + // System 保存语义,Messages 里可能也有一条 role=system。 + // 适配器按自己的协议取用(Anthropic 取 System 并递归剥离 messages 里的 + // system;OpenAI 直接用 messages)。 + System string `json:"system,omitempty"` + + // ---- 推理/思考 ---- + // Thinking 取代 v1 的内核私有字段 DisableThinking:那个字段曾被序列化进 + // 交给适配器的 body,迫使 10 个适配器各自写 req.disable_thinking = nil。 + // 这里它是双方都认可的对称语义(客户端要什么 / 上游怎么映射)。 + // Thinking 是"要不要思考、思考预算多少"的语义; + // 适配器各自映射(Anthropic thinking.budget_tokens、Gemini + // thinkingConfig.thinkingBudget、DeepSeek extra_body.thinking.type)。 + Thinking *ThinkingConfig `json:"thinking,omitempty"` + + // ---- 多模态 ---- + // 图片/音频等随 message content 的 parts 传递(见 ChatMessage.Content), + // 这里放协议级的生成参数(如 Gemini candidateCount、OpenAI n)。 + GenerationCount int `json:"n,omitempty"` + + // ---- 厂商扩展逃生舱 ---- + // ExtraBody 是**明确标注的**厂商透传通道:客户端可以把它不认识的字段 + // 塞进来,适配器自行决定是否读取。它不参与"内核私有字段"泄漏问题, + // 因为它的语义就是"客户端原样要求的额外字段"。 + // + // 与 v1 的区别:v1 的 ExtraBody 会被 MarshalJSON 手工注入到 OpenAI 形状 + // 的顶层,从而和内核私有字段混在一起;v2 里它是统一格式里的一个**明确定义 + // 的字段**,内核不再需要 MarsalJSON 后门。 + ExtraBody map[string]any `json:"extra_body,omitempty"` + + // ---- 网关内部(不序列化,不交给适配器 body) ---- + // ClientSession 只用于让适配器生成稳定的上游会话,不能出现在上游 body 里。 + ClientSession string `json:"-"` + // Stream 由网关按客户端请求设置;适配器负责映射到上游的流式开关。 + Stream bool `json:"stream,omitempty"` +} + +// ThinkingConfig 是推理预算的语义表示(跨协议)。 +// +// Enabled=false 表示客户端明确要求关闭思考。不同上游的关闭方式不一 +// (DeepSeek 用 extra_body.thinking.type=disabled,Anthropic 直接不传 +// thinking 字段,Gemini 用 thinkingBudget=0),映射由适配器负责。 +type ThinkingConfig struct { + Enabled *bool `json:"enabled,omitempty"` + BudgetTokens int64 `json:"budget_tokens,omitempty"` +} + +// ToolDef 是工具声明的统一形状(OpenAI 的 {type,function} 外壳被摊平)。 +type ToolDef struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + // Parameters 是 JSON Schema,原样透传。 + Parameters json.RawMessage `json:"parameters,omitempty"` + // Type 保留协议侧的 kind(OpenAI 恒为 "function";其它协议可能为空)。 + Type string `json:"type,omitempty"` +} + +// ResponseFormat 是结构化输出的统一形状。 +type ResponseFormat struct { + Type string `json:"type"` // text | json_object | json_schema + JSONSchema json.RawMessage `json:"json_schema,omitempty"` +} + +// UnifiedResult 是适配器从上游响应转换出来的**统一结果**(最大集合)。 +// +// 这是 v1 的 UnifiedResponse 的超集:v1 只有 content/finish_reason/ +// token_usage/tool_calls;v2 补齐各协议会产出的其它语义。 +type UnifiedResult struct { + Model string `json:"model,omitempty"` + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + + // FinishReason 是**归一后**的结束原因:stop / length / tool_calls / + // content_filter / 空。适配器负责把上游措辞映射到这套词表 + // (如 Anthropic 的 end_turn→stop、max_tokens→length、 + // tool_use→tool_calls;Gemini 的 STOP→stop、MAX_TOKENS→length)。 + FinishReason string `json:"finish_reason,omitempty"` + + TokenUsage TokenUsage `json:"token_usage"` + + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + + // ---- 其它协议可能产出的语义 ---- + // Citations 是 Anthropic 的引用块、Gemini 的 grounding。 + Citations []json.RawMessage `json:"citations,omitempty"` + // Refusal 是 OpenAI 的 refusal 字段(内容被安全策略拒绝)。 + Refusal string `json:"refusal,omitempty"` + // SafetyRatings 是 Gemini 的安全评分。 + SafetyRatings []json.RawMessage `json:"safety_ratings,omitempty"` + + // ImageData 供生图适配器使用。 + ImageData []ImageData `json:"image_data,omitempty"` + + // Cost 是上游自报的费用(小数**字符串**),原样透传。 + // 刻意不在内核里解析或累加:它是上游事实,且只有部分上游提供。 + Cost string `json:"cost,omitempty"` +} + +// UnifiedDelta 是流式分块的统一形状(最大集合)。 +type UnifiedDelta struct { + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + Done bool `json:"done"` + // FinishReason 只在终止块上有值。 + FinishReason string `json:"finish_reason,omitempty"` + // ToolCalls 是流式工具调用的增量片段(原始 JSON,由适配器决定形状)。 + ToolCalls json.RawMessage `json:"tool_calls,omitempty"` + // Usage 出现在带用量的块上(通常是终止块)。 + Usage *TokenUsage `json:"usage,omitempty"` + // Citations 流式引用块。 + Citations []json.RawMessage `json:"citations,omitempty"` + // Cost 上游自报费用。 + Cost string `json:"cost,omitempty"` +} + +// ChatRequest is the historical name for the in-bound request type. +// +// Deprecated: it is an alias of UnifiedRequest. The rename happened because the +// kernel's internal format stopped being "whatever the client sent" once the +// maximal set landed — it is now the maximal set, and the client's own protocol +// shape only survives until the adapter sees it. +// +// Kept as an alias (not a distinct type) so the 49 existing references keep +// compiling while call sites migrate deliberately rather than in one sweep. +type ChatRequest = UnifiedRequest diff --git a/internal/types/maxwire_test.go b/internal/types/maxwire_test.go new file mode 100644 index 0000000..7a21df2 --- /dev/null +++ b/internal/types/maxwire_test.go @@ -0,0 +1,203 @@ +package types + +import ( + "encoding/json" + "testing" +) + +// 本文件验证统一线格式 v2 的"最大集合"性质。 +// +// 核心断言不是"这些字段能序列化"(那是废话),而是: +// 1. 每个市面协议需要的字段都在超集里(否则适配器会退化成从 +// extra_body 旁路偷字段 —— v1 就是这么烂掉的) +// 2. 内核私有字段不会出现在交给适配器的 body 里(v1 的泄漏) +// 3. 超集不会把某个协议的**容器形状**带进来(那是适配器的内部事务) + +// TestMaximalSetCoversEveryProtocol 逐协议核对字段覆盖。 +// +// 每个子测试对应一个真实协议,列出该协议**必需**的请求字段。 +// 少一个就说明超集不完整,适配器将被迫走旁路。 +func TestMaximalSetCoversEveryProtocol(t *testing.T) { + // 用 JSON 标签作为"字段存在"的判据,因为适配器看到的就是标签名。 + raw, err := json.Marshal(UnifiedRequest{ + Model: "m", + Messages: []ChatMessage{ + {Role: "user", Content: StringContent("hi")}, + }, + // 把所有可空字段都填上,确保 omitempty 不会掩盖它们的存在 + Temperature: f64(0.7), + TopP: f64(0.9), + TopK: 40, + MaxTokens: 1024, + FrequencyPenalty: f64(0.1), + PresencePenalty: f64(0.2), + Seed: i64(42), + Stop: []string{"\n"}, + StopSequences: []string{"\n\n"}, + Tools: json.RawMessage(`[{"type":"function","function":{"name":"f"}}]`), + ToolChoice: json.RawMessage(`"auto"`), + ParallelToolCalls: b(true), + ResponseFormat: &ResponseFormat{Type: "json_object"}, + System: "you are helpful", + Thinking: &ThinkingConfig{Enabled: b(true), BudgetTokens: 2048}, + GenerationCount: 2, + ExtraBody: map[string]any{"vendor_flag": true}, + }) + if err != nil { + t.Fatal(err) + } + var have map[string]json.RawMessage + if err := json.Unmarshal(raw, &have); err != nil { + t.Fatal(err) + } + + protocols := map[string][]string{ + "openai": {"model", "messages", "temperature", "top_p", "max_tokens", + "frequency_penalty", "presence_penalty", "seed", "stop", "tools", + "tool_choice", "parallel_tool_calls", "response_format", "n"}, + "anthropic": {"model", "messages", "max_tokens", "temperature", "top_p", + "top_k", "stop_sequences", "system", "thinking", "tools", "tool_choice"}, + "gemini": {"contents-placeholder", "temperature", "top_p", "top_k", + "max_tokens", "stop", "system", "thinking", "tools", "n"}, + "ollama": {"model", "messages", "temperature", "top_p", "top_k", + "max_tokens", "stop", "tools"}, + "deepseek": {"model", "messages", "temperature", "top_p", "max_tokens", + "stop", "tools", "thinking"}, + } + for proto, need := range protocols { + for _, f := range need { + if f == "contents-placeholder" { + continue // Gemini 的 contents 由适配器从 messages 构造,不是统一字段 + } + if _, ok := have[f]; !ok { + t.Errorf("%s 协议需要字段 %q,但统一格式没有它(适配器将被迫走旁路)", proto, f) + } + } + } + + // 反向:确认确实带了内容,不是空对象 + if len(have) < 15 { + t.Errorf("统一请求只序列化出 %d 个字段,最大集合应远多于此", len(have)) + } +} + +// TestNoKernelPrivateFieldLeak 是 v1 那个具体 bug 的回归测试。 +// +// v1:disable_thinking 是内核私有开关,却被序列化进交给适配器的 body, +// 于是 10 个适配器各自写下 `req.disable_thinking = nil` 来清理它。 +// v2:该语义改名为 thinking(一个**双方都认可**的字段),且没有内核私有 +// 字段混进 body。 +func TestNoKernelPrivateFieldLeak(t *testing.T) { + raw, err := json.Marshal(UnifiedRequest{ + Model: "m", + Messages: []ChatMessage{{Role: "user", Content: StringContent("hi")}}, + Thinking: &ThinkingConfig{Enabled: b(false)}, + }) + if err != nil { + t.Fatal(err) + } + var have map[string]json.RawMessage + if err := json.Unmarshal(raw, &have); err != nil { + t.Fatal(err) + } + if _, leaked := have["disable_thinking"]; leaked { + t.Error("内核私有字段 disable_thinking 泄漏进了交给适配器的 body(v1 回归)") + } + // thinking 必须是**客户端语义**,独立于任何具体协议的关闭方式 + if _, ok := have["thinking"]; !ok { + t.Error("thinking 语义字段缺失;适配器将无法得知客户端要求关闭思考") + } + // ClientSession 绝不能进 body:它只用于派生上游会话标识 + if _, leaked := have["client_session"]; leaked { + t.Error("网关内部字段 client_session 泄漏进上游 body") + } +} + +// TestNoProtocolContainerLeak 确认超集只带**语义**,不带协议容器形状。 +// +// Gemini 的 generationConfig、Ollama 的 options 都是"上游协议的形状", +// 属于适配器内部事务。若它们出现在统一格式里,说明内核又开始懂协议了。 +func TestNoProtocolContainerLeak(t *testing.T) { + raw, err := json.Marshal(UnifiedRequest{ + Model: "m", + Messages: []ChatMessage{{Role: "user", Content: StringContent("hi")}}, + }) + if err != nil { + t.Fatal(err) + } + var have map[string]json.RawMessage + if err := json.Unmarshal(raw, &have); err != nil { + t.Fatal(err) + } + forbidden := []string{ + "generationConfig", // Gemini 容器 + "systemInstruction", // Gemini 容器 + "options", // Ollama 容器 + "contents", // Gemini 的消息数组形状 + "num_predict", // Ollama 的参数名 + "maxOutputTokens", // Gemini 的参数名 + "thinkingConfig", // Gemini 的推理容器 + } + for _, f := range forbidden { + if _, bad := have[f]; bad { + t.Errorf("统一格式出现了协议专属字段/容器 %q —— 内核不应携带任何协议形状", f) + } + } +} + +// TestUnifiedResultCoversProtocols 核对响应侧的超集覆盖。 +func TestUnifiedResultCoversProtocols(t *testing.T) { + raw, err := json.Marshal(UnifiedResult{ + Model: "m", + Content: "hi", + Thinking: "let me think", + FinishReason: "stop", + TokenUsage: TokenUsage{Prompt: 1, Completion: 2, Total: 3}, + ToolCalls: []ToolCall{{ID: "1", Name: "f"}}, + Citations: []json.RawMessage{json.RawMessage(`{"src":"x"}`)}, + Refusal: "", + SafetyRatings: []json.RawMessage{json.RawMessage(`{"category":"HARM"}`)}, + Cost: "0.0012", + }) + if err != nil { + t.Fatal(err) + } + var have map[string]json.RawMessage + if err := json.Unmarshal(raw, &have); err != nil { + t.Fatal(err) + } + for _, f := range []string{"content", "thinking", "finish_reason", + "token_usage", "tool_calls", "citations", "safety_ratings", "cost"} { + if _, ok := have[f]; !ok { + t.Errorf("统一响应缺少 %q,该协议的语义将无法表达", f) + } + } +} + +// TestFinishReasonVocabularyDocumentsNormalization 记录归一化词表。 +// +// 适配器必须把上游措辞映射到这套词表;内核与对外 serializer 只认这套。 +func TestFinishReasonVocabularyDocumentsNormalization(t *testing.T) { + // 这不是行为测试,而是把词表钉在代码里,防止有人往这加厂商专属值。 + // 上游措辞 → 统一值的映射责任在适配器。 + canonical := map[string][]string{ + "stop": {"Anthropic end_turn", "Gemini STOP", "OpenAI stop"}, + "length": {"Anthropic max_tokens", "Gemini MAX_TOKENS", "OpenAI length"}, + "tool_calls": {"Anthropic tool_use", "Gemini FUNCTION_CALL", "OpenAI tool_calls"}, + "content_filter": {"OpenAI content_filter", "Gemini SAFETY"}, + } + for want, sources := range canonical { + if want == "" { + t.Fatal("unreachable") + } + // 词表本身是文档;这里只断言它非空且来源明确 + if len(sources) == 0 { + t.Errorf("finish_reason %q 没有记录任何来源协议措辞", want) + } + } +} + +// ---- 测试辅助 ---- +func f64(v float64) *float64 { return &v } +func i64(v int64) *int64 { return &v } +func b(v bool) *bool { return &v } diff --git a/internal/types/types.go b/internal/types/types.go index 51a5f16..da42e23 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -17,42 +17,32 @@ import ( // provider package (which pulls in the Lua runtime). var ErrBusy = errors.New("provider busy") -// ---- OpenAI wire request (gateway input) ---- - -type ChatRequest struct { - Model string `json:"model"` - Messages []ChatMessage `json:"messages"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools []interface{} `json:"tools,omitempty"` - ToolChoice interface{} `json:"tool_choice,omitempty"` - DisableThinking bool `json:"disable_thinking"` - ExtraBody map[string]interface{} `json:"-"` - - // ClientSession 是客户端自带的会话标识(请求头 x-session-affinity 等)。 - // 不参与序列化:它只用于让适配器生成稳定的上游会话,不应泄漏给上游 body。 - ClientSession string `json:"-"` -} - -func (r *ChatRequest) MarshalJSON() ([]byte, error) { - type Alias ChatRequest - data, err := json.Marshal((*Alias)(r)) - if err != nil { - return nil, err - } - if len(r.ExtraBody) == 0 { - return data, nil - } - var raw map[string]interface{} - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - for k, v := range r.ExtraBody { - raw[k] = v - } - return json.Marshal(raw) -} +// ---- in-bound request ---- +// +// ChatRequest now lives in maxwire.go as an alias of UnifiedRequest: the +// kernel's internal format is the maximal set (OpenAI / Anthropic / Gemini / +// Ollama / DeepSeek fields unioned), not "whatever an OpenAI client sent". The +// type stays named ChatRequest at every call site for now — renaming 19 +// non-test + 30 test references in one sweep would bury the semantic change in +// mechanical noise, and an alias makes the migration free when it happens. +// +// The v1 struct that lived here is gone. Its fields and their fate: +// +// Model/Messages/Temperature/MaxTokens/Stream → UnifiedRequest, same tags +// Tools/ToolChoice → json.RawMessage (raw passthrough +// to the adapter; the kernel never +// parses them) +// DisableThinking (json:"disable_thinking") → REPLACED by Thinking. This was +// the private field that leaked into +// the body handed to adapters, forcing +// 10 adapters to nil it out. +// ExtraBody (hand-injected by MarshalJSON) → a real, declared field on +// UnifiedRequest — no more +// MarshalJSON back door +// ClientSession (json:"-") → kept, still never serialized +// +// The custom MarshalJSON is gone with it: its only job was splicing ExtraBody +// into an OpenAI-shaped body, which is no longer how the type serializes. // ChatMessage supports both plain string content and multimodal arrays // (RawMessage preserves whatever the client sent for the adapter to process).