mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-08 10:17:21 +00:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 652a035d47 | |||
| 6bec260513 | |||
| ed954b77bd | |||
| cb3ea25586 | |||
| 0232b9cd24 | |||
| a07449d32e | |||
| 84a9fccd9a | |||
| 63549db832 | |||
| d6d41209d9 | |||
| 3c59c33c58 | |||
| 5948ad19ca | |||
| 6b6f9526ff | |||
| 4d0efab86b | |||
| b6e687b567 | |||
| 2353d43c16 | |||
| 893857464d | |||
| 2f5cde7fea | |||
| da0282a3db | |||
| 6f2b6b8d3d | |||
| 96d97b0e0e | |||
| 6de9f28269 | |||
| 50997a4e3a | |||
| 74ec1e7874 | |||
| fa50ec8bc7 | |||
| c3b858d5ba | |||
| a59295e826 | |||
| dafc3b3fc3 | |||
| 66786bb0e3 | |||
| f4d5e779a5 | |||
| 61f402beef | |||
| 57fada3b0d | |||
| b6148e6f79 | |||
| 24e00cdc50 | |||
| 9016eda905 | |||
| 4d1407bbb4 | |||
| 5f6939b590 | |||
| a0ab9ddda5 | |||
| e4111337cd | |||
| 93eeba3762 | |||
| 958f1f48b5 | |||
| 9d6653a573 | |||
| 1c6a2821e4 | |||
| 3ad46ea23e | |||
| 6113924655 | |||
| d3203d88fc | |||
| 7836b95a84 | |||
| b6d0358079 | |||
| 0858a747ca | |||
| 7934507179 | |||
| f042bbf4af | |||
| 97a6cf9224 | |||
| d92db3a9fd | |||
| 0e747b0891 | |||
| be8d753e36 | |||
| 438629256d | |||
| a873fe4724 | |||
| 31bc3968c5 | |||
| c40b5b5981 | |||
| 7ce0378e8b | |||
| ae9f56ddb3 | |||
| 3860c7381f | |||
| 94c1e7688f | |||
| b8386465ea | |||
| f682205d26 | |||
| ba81a4d7e1 | |||
| e30199b29d | |||
| a1fddf72b7 | |||
| b9a025efba | |||
| 7c7cf3e078 | |||
| 18525ae4c0 | |||
| c4b37d4322 | |||
| 33b778faee | |||
| 345cbb30d6 | |||
| 501ad6aac9 | |||
| 39cda89def |
24
.snapshots/chat_nonstream.json
Normal file
24
.snapshots/chat_nonstream.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": "pong",
|
||||
"role": "assistant"
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion",
|
||||
"usage": {
|
||||
"completion": 1,
|
||||
"completion_tokens": 1,
|
||||
"prompt": 3,
|
||||
"prompt_tokens": 3,
|
||||
"total": 4,
|
||||
"total_tokens": 4
|
||||
}
|
||||
}
|
||||
95
.snapshots/chat_stream.txt
Normal file
95
.snapshots/chat_stream.txt
Normal file
@ -0,0 +1,95 @@
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "",
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "Hel",
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "lo",
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "",
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": ""
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
{
|
||||
"choices": [],
|
||||
"created": <TS>,
|
||||
"id": "<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]
|
||||
8
.snapshots/image_generation.json
Normal file
8
.snapshots/image_generation.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"created": <TS>,
|
||||
"data": [
|
||||
{
|
||||
"b64_json": "QUJD"
|
||||
}
|
||||
]
|
||||
}
|
||||
31
README.md
31
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 预算。
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.7",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
64
deploy.sh
64
deploy.sh
@ -31,6 +31,7 @@ set -euo pipefail
|
||||
readonly TARGET_BIN="/usr/local/bin/llmsproxy"
|
||||
readonly TARGET_CONFIG="/etc/llmsproxy/config.yaml"
|
||||
readonly TARGET_ADAPTERS="/etc/llmsproxy/adapters"
|
||||
readonly TARGET_PLUGINS="/etc/llmsproxy/plugins"
|
||||
readonly REPO_DIR="/home/program/llmsproxy"
|
||||
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
|
||||
readonly BIN_NAME="llmsproxy"
|
||||
@ -390,6 +391,67 @@ sync_adapters() {
|
||||
log "✓ 适配器已同步到 $TARGET_ADAPTERS/"
|
||||
}
|
||||
|
||||
# 同步内置插件到 plugin_dir。
|
||||
#
|
||||
# 内置插件是**内核发行物的一部分**(go:embed 进二进制,随版本演进),与内置适配器
|
||||
# 同性质。因此这里的纪律与 sync_adapters 完全一致:**覆盖内置同名文件**(带备份),
|
||||
# 保留目录里运行时新增/运维自加的 .lua。
|
||||
#
|
||||
# 早期实现是「只补缺,已存在一律不动」,看着安全,实际造成一个功能性缺口:
|
||||
# 内置插件本身的**修复无法下发**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
|
||||
# 全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版,
|
||||
# 生产行为毫无变化。内置源码的正确性由仓库保证,部署必须与二进制内容对齐。
|
||||
#
|
||||
# 「只补缺」原本想保护的是「运维改过 / 运行时新增的插件」,但那些都是**非内置
|
||||
# 名字**:内置名一律以发行物为准,其余名字一律不动。两者不是一回事。
|
||||
sync_plugins() {
|
||||
log "同步内置插件"
|
||||
|
||||
SRC_PLUGINS="$REPO_DIR/internal/lua/plugins"
|
||||
[[ -d "$SRC_PLUGINS" ]] || fail "内嵌插件目录不存在: $SRC_PLUGINS"
|
||||
|
||||
mkdir -p "$TARGET_PLUGINS"
|
||||
|
||||
# 备份将被覆盖/新增的内置插件(非内置名字不进入循环,天然不动)
|
||||
BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local added=0 updated=0 same=0
|
||||
for src in "$SRC_PLUGINS"/*.lua; do
|
||||
[[ -f "$src" ]] || continue
|
||||
local name; name="$(basename "$src")"
|
||||
if [[ -f "$TARGET_PLUGINS/$name" ]]; then
|
||||
if cmp -s "$src" "$TARGET_PLUGINS/$name"; then
|
||||
((same++)) || true
|
||||
continue
|
||||
fi
|
||||
cp -f "$TARGET_PLUGINS/$name" "$BACKUP_DIR/$name.bak" 2>/dev/null || true
|
||||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||||
log " ~ 更新内置插件: $name"
|
||||
((updated++)) || true
|
||||
else
|
||||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||||
log " + 新增内置插件: $name"
|
||||
((added++)) || true
|
||||
fi
|
||||
done
|
||||
# 没有任何改动时不必留空备份目录
|
||||
if (( updated == 0 && added == 0 )); then
|
||||
rmdir "$BACKUP_DIR" 2>/dev/null || true
|
||||
else
|
||||
log " 旧插件已备份到 $BACKUP_DIR"
|
||||
fi
|
||||
|
||||
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
|
||||
for src in "$SRC_PLUGINS"/*.lua; do
|
||||
[[ -f "$src" ]] || continue
|
||||
local name; name="$(basename "$src")"
|
||||
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
|
||||
done
|
||||
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)"
|
||||
}
|
||||
|
||||
# ---------- 重启服务 ----------
|
||||
restart_service() {
|
||||
log "重启服务(中断 <500ms)"
|
||||
@ -495,6 +557,7 @@ print_summary() {
|
||||
log " 二进制: $TARGET_BIN"
|
||||
log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")"
|
||||
log " 适配器: $TARGET_ADAPTERS"
|
||||
log " 插件: $TARGET_PLUGINS"
|
||||
log "═════════════════════════════════════════════"
|
||||
}
|
||||
|
||||
@ -511,6 +574,7 @@ main() {
|
||||
verify_master_key
|
||||
build
|
||||
sync_adapters
|
||||
sync_plugins
|
||||
backup_old_state
|
||||
atomic_replace_binary
|
||||
atomic_replace_config
|
||||
|
||||
117
docs/design-plugin-kernel-api.md
Normal file
117
docs/design-plugin-kernel-api.md
Normal file
@ -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:<plugin>` → 逗号分隔的动作名或 `*`。
|
||||
|
||||
- **默认拒绝**:插件不显式获得授权时,`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 生效、危险动作需显式授权
|
||||
@ -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`)。
|
||||
|
||||
141
docs/plugins.md
141
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 对下一个请求立即可见,也会经同一套机制持久化到 `.<name>.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 |
|
||||
| 必需性 | 核心 | 可选,缺了网关照常跑 |
|
||||
|
||||
|
||||
286
e2e/adapter_takeover_test.go
Normal file
286
e2e/adapter_takeover_test.go
Normal file
@ -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 的请求路径必须是 <base>/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
|
||||
170
e2e/channels_device_e2e_test.go
Normal file
170
e2e/channels_device_e2e_test.go
Normal file
@ -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
|
||||
200
e2e/channels_flows_e2e_test.go
Normal file
200
e2e/channels_flows_e2e_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
261
e2e/conflict_arbitration_e2e_test.go
Normal file
261
e2e/conflict_arbitration_e2e_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
192
e2e/wire_golden_test.go
Normal file
192
e2e/wire_golden_test.go
Normal file
@ -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": "<ID>"`)
|
||||
return volatile[1].ReplaceAllString(s, `"created": <TS>`)
|
||||
}
|
||||
|
||||
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
|
||||
@ -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
|
||||
|
||||
40
internal/core/channels_device_test.go
Normal file
40
internal/core/channels_device_test.go
Normal file
@ -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
|
||||
}
|
||||
405
internal/core/channels_more_test.go
Normal file
405
internal/core/channels_more_test.go
Normal file
@ -0,0 +1,405 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// beginPool 走 UI 的真实流程:先 account/add 建占位账号,再带 account_id 登录。
|
||||
// 这正是用户报障("登录了但无法获取账号信息")的那条路径。
|
||||
func (h *buddyHarness) beginPool() (map[string]interface{}, string, string) {
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"buddy","account_id":%q}`, aid),
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string), aid
|
||||
}
|
||||
|
||||
func (h *buddyHarness) accountList() []interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
accts, _ := m["accounts"].([]interface{})
|
||||
return accts
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 凭据是**完整凭据 JSON**(对齐参考 buildCredential),不再是裸 token:
|
||||
// 必须带 refresh_token,否则 refreshable=false、无法静默续期。
|
||||
v, _, _ := h.ps.GetSecret("channels", "channels:buddy")
|
||||
if !strings.Contains(v, `"access_token":"BD-AT-1"`) {
|
||||
t.Fatalf("凭据应含 access_token, 实得 %q", v)
|
||||
}
|
||||
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
|
||||
t.Fatalf("凭据必须含 refresh_token(否则无法续期), 实得 %q", v)
|
||||
}
|
||||
if !strings.Contains(v, "buddy-dev") {
|
||||
t.Fatalf("凭据应回填昵称, 实得 %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)
|
||||
}
|
||||
})
|
||||
|
||||
// 行4 回归:UI 真实流程(account/add 建占位账号 → 带 account_id 登录)。
|
||||
// 缺陷复现:poll_login 完成时不传 account_id → 凭据落到 `channels:buddy`,
|
||||
// 而池账号 credentialRef 是 `channels:buddy:<aid>` → account_list 报
|
||||
// has_credential=false、credits 报「无凭据」——即用户报障的
|
||||
// 「登录了但无法获取账号信息」。
|
||||
t.Run("行4 池路径:登录后账号必须真的拿到凭据", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.tokStep, 0)
|
||||
_, sid, aid := h.beginPool()
|
||||
h.poll(sid) // 首拍 11217
|
||||
j := h.poll(sid)
|
||||
if j["state"] != "completed" {
|
||||
t.Fatalf("池路径登录应完成: %v", j)
|
||||
}
|
||||
// ① 凭据必须落在池账号的 ref 上
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:buddy:"+aid)
|
||||
if !ok || v == "" {
|
||||
t.Fatalf("凭据未落到池账号 ref(用户报障的根因); aid=%s ok=%v", aid, ok)
|
||||
}
|
||||
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
|
||||
t.Fatalf("池凭据应是完整 JSON: %q", v)
|
||||
}
|
||||
// ② account_list 必须报 has_credential=true(UI 读的就是这个字段)
|
||||
found := false
|
||||
for _, raw := range h.accountList() {
|
||||
a, _ := raw.(map[string]interface{})
|
||||
if a["id"] == aid {
|
||||
found = true
|
||||
if a["has_credential"] != true {
|
||||
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
|
||||
}
|
||||
if a["refreshable"] != true {
|
||||
t.Fatalf("refreshable 应为 true(有 refresh_token), 实得 %v", a["refreshable"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("池账号 %s 未出现在 account_list", aid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 通用 ----
|
||||
|
||||
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
|
||||
}
|
||||
342
internal/core/channels_plugin_test.go
Normal file
342
internal/core/channels_plugin_test.go
Normal file
@ -0,0 +1,342 @@
|
||||
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)
|
||||
}
|
||||
// 测试夹具经运行时注入:生产目录只含真实厂商,夹具不再内联进源码
|
||||
// (否则会出现在生产 UI 的平台列表里)。
|
||||
injectTestPlatforms(t, ps, examplePlatformFixture)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// examplePlatformFixture 是端到端测试用的占位平台(对应此前内联在生产源码里
|
||||
// 的 "example" 平台)。它经 inject_platforms 注入,且**只在测试进程内存在** ——
|
||||
// 生产插件的 PLATFORMS 只含真实厂商。
|
||||
const examplePlatformFixture = `[
|
||||
{
|
||||
"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": "{}"}
|
||||
}
|
||||
]`
|
||||
|
||||
// injectTestPlatforms 把测试夹具注入插件(运行时,不落盘)。
|
||||
func injectTestPlatforms(t *testing.T, ps *lua.Plugins, fixtureJSON string) {
|
||||
t.Helper()
|
||||
out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platforms":` + fixtureJSON + `}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("inject_platforms: %v", err)
|
||||
}
|
||||
m, _ := out.JSON.(map[string]interface{})
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("注入测试平台失败: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectPlatformsRejectsBadInput 锁定注入机制的三条边界:
|
||||
// 1. 不得覆盖内建平台(注入不能劫持真实厂商)
|
||||
// 2. 重复 id 拒绝
|
||||
// 3. 空数组 = 清空注入(夹具不跨测试残留)
|
||||
//
|
||||
// 这个机制存在的理由:夹具内联进生产源码会让它们出现在生产 UI 的平台列表里
|
||||
// (真实踩过 —— 16 个平台里有 4 个是夹具)。
|
||||
func TestInjectPlatformsRejectsBadInput(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)
|
||||
}
|
||||
|
||||
call := func(body string) map[string]interface{} {
|
||||
out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
|
||||
Method: "POST", Body: body, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("inject_platforms(%s): %v", body, err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
platforms := func() []interface{} {
|
||||
out, _ := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
return out.JSON.(map[string]interface{})["platforms"].([]interface{})
|
||||
}
|
||||
|
||||
base := len(platforms())
|
||||
if base == 0 {
|
||||
t.Fatal("生产目录应有真实厂商平台")
|
||||
}
|
||||
|
||||
// 注入一个合法夹具 → 列表 +1
|
||||
if m := call(`{"platforms":[{"id":"fixture-a","name":"A","flow":"paste","base_url":"https://a.example/v1"}]}`); m["ok"] != true {
|
||||
t.Fatalf("合法注入应成功: %v", m)
|
||||
}
|
||||
if got := len(platforms()); got != base+1 {
|
||||
t.Fatalf("注入后平台数 = %d, 期望 %d", got, base+1)
|
||||
}
|
||||
|
||||
// 1. 覆盖内建平台 → 拒绝
|
||||
if m := call(`{"platforms":[{"id":"buddy","name":"劫持"}]}`); m["error"] == nil {
|
||||
t.Fatal("覆盖内建平台必须被拒绝")
|
||||
}
|
||||
// 2. 重复 id → 拒绝
|
||||
if m := call(`{"platforms":[{"id":"fixture-b"},{"id":"fixture-b"}]}`); m["error"] == nil {
|
||||
t.Fatal("重复 id 必须被拒绝")
|
||||
}
|
||||
// 无 id → 拒绝
|
||||
if m := call(`{"platforms":[{"name":"no-id"}]}`); m["error"] == nil {
|
||||
t.Fatal("缺 id 必须被拒绝")
|
||||
}
|
||||
|
||||
// 3. 空数组 = 清空注入,回到基线
|
||||
if m := call(`{"platforms":[]}`); m["ok"] != true {
|
||||
t.Fatalf("清空应成功: %v", m)
|
||||
}
|
||||
if got := len(platforms()); got != base {
|
||||
t.Fatalf("清空后平台数 = %d, 期望回到基线 %d", got, base)
|
||||
}
|
||||
}
|
||||
418
internal/core/channels_pool_test.go
Normal file
418
internal/core/channels_pool_test.go
Normal file
@ -0,0 +1,418 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuddyBillingHeadersMatchReference 锁定 buddy 计费请求的头(逐行移植参考
|
||||
// checkinHeaders)。真实缺陷:实现只发 Authorization,上游直接 401 —— 修好后
|
||||
// 账号能读到凭据但余额仍失败,运维看到的仍是「拿不到账号信息」。
|
||||
func TestBuddyBillingHeadersMatchReference(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
var got map[string]string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = map[string]string{}
|
||||
for k := range r.Header {
|
||||
got[k] = r.Header.Get(k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":{"totalCredit":42}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// 把 buddy 的余额/签到端点指到本地假上游
|
||||
invoke(t, ps, "set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
|
||||
m := invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("account_add: %v", m)
|
||||
}
|
||||
invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("余额请求未发出")
|
||||
}
|
||||
// 参考 checkinHeaders 的必备身份头(缺一个上游就 401)
|
||||
want := map[string]string{
|
||||
"Authorization": "Bearer BD-AT",
|
||||
"X-Domain": "copilot.tencent.com",
|
||||
"X-Product": "SaaS",
|
||||
"X-Product-Code": "codebuddy",
|
||||
"User-Agent": "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("头 %s = %q, 期望 %q(buddy 计费端点缺它会被上游 401)", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuddyBalanceParseDoubleNested 逐行锁定参考 fetchCreditBalance 的解析口径:
|
||||
// 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 data 不同,
|
||||
// 这是该接口最容易错的地方),且只累加**有效包**的 CycleCapacityRemain ——
|
||||
// 失效包服务端仍返回,并进总额会让数字虚高(参考实测 155.67 → 655.67)。
|
||||
func TestBuddyBalanceParseDoubleNested(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
// 两个有效包 + 一个失效包(ExpiredTime 在 2020 年)
|
||||
body := `{"code":0,"msg":"OK","data":{"Response":{"Data":{"Accounts":[
|
||||
{"PackageName":"A","Status":1,"CycleCapacityRemain":100.5,"ExpiredTime":"2099-01-01 00:00:00"},
|
||||
{"PackageName":"B","Status":1,"CycleCapacityRemain":55.17000031,"ExpiredTime":"2099-01-01 00:00:00"},
|
||||
{"PackageName":"C","Status":1,"CycleCapacityRemain":500,"ExpiredTime":"2020-01-01 00:00:00"}
|
||||
]}}}}`
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
invoke(t, ps, "set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
|
||||
invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
|
||||
|
||||
j := invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
var row map[string]interface{}
|
||||
for _, a := range asArr(j["accounts"]) {
|
||||
row = a.(map[string]interface{})
|
||||
}
|
||||
bal, _ := row["balance"].(map[string]interface{})
|
||||
if bal == nil {
|
||||
t.Fatalf("应解析出余额, 实得 error=%v", row["error"])
|
||||
}
|
||||
// 100.5 + 55.17 = 155.67(失效包的 500 不计入)
|
||||
if got, _ := bal["total"].(float64); got != 155.67 {
|
||||
t.Fatalf("余额 = %v, 期望 155.67(只累加有效包、两位小数)", bal["total"])
|
||||
}
|
||||
|
||||
// 单层 data(缺 Response 层)必须判为解析失败,不能当 0
|
||||
body = `{"code":0,"data":{"TotalDosage":609}}`
|
||||
j = invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
for _, a := range asArr(j["accounts"]) {
|
||||
row = a.(map[string]interface{})
|
||||
}
|
||||
if row["balance"] != nil {
|
||||
t.Fatalf("双层缺失时应报错而非当 0(参考语义:区分「查不到」与「余额为 0」): %v", row)
|
||||
}
|
||||
}
|
||||
264
internal/core/channels_stress_test.go
Normal file
264
internal/core/channels_stress_test.go
Normal file
@ -0,0 +1,264 @@
|
||||
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
|
||||
}
|
||||
|
||||
// cookieishPlatformFixture 是压力测试用的占位平台(对应此前内联在生产源码里的
|
||||
// "cookieish")。夹具经运行时注入,生产目录只含真实厂商。
|
||||
const cookieishPlatformFixture = `[{
|
||||
"id": "cookieish",
|
||||
"name": "Cookie 平台(测试夹具)",
|
||||
"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": "{}"}
|
||||
}]`
|
||||
|
||||
// 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))
|
||||
injectTestPlatforms(t, ps, cookieishPlatformFixture)
|
||||
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)
|
||||
}
|
||||
}
|
||||
237
internal/core/channels_trae_test.go
Normal file
237
internal/core/channels_trae_test.go
Normal file
@ -0,0 +1,237 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTraeCallbackPoolPath 回归:callback 流的**池路径**(UI 真实流程)。
|
||||
//
|
||||
// 与 buddy poll_login 的池路径同一缺陷:complete_login 不带 account_id 时,
|
||||
// 凭据落到与占位账号无关的 `channels:<plat>` 键,池账号永远 has_credential=false。
|
||||
// 此前只有 device-poll / state-poll 分支修了,callback 分支是漏网。
|
||||
func TestTraeCallbackPoolPath(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
atomic.StoreInt32(&h.exchOK, 1)
|
||||
|
||||
// UI 流程:先建占位账号,再带 account_id 发起登录
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"trae","account_id":%q}`, aid),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
|
||||
|
||||
st, body := h.invokeCallback(map[string]string{
|
||||
"state": nonce, "refreshToken": "RT-FROM-CALLBACK", "userInfo": `{"UserID":"u9","ScreenName":"池用户"}`,
|
||||
})
|
||||
if st != 200 {
|
||||
t.Fatalf("回调应 200: %d %s", st, body)
|
||||
}
|
||||
|
||||
// ① 凭据必须落在池账号的 ref
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:trae:"+aid)
|
||||
if !ok || v == "" {
|
||||
t.Fatalf("★ callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid)
|
||||
}
|
||||
// ② account_list 必须报 has_credential=true
|
||||
lst, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range lst.JSON.(map[string]interface{})["accounts"].([]interface{}) {
|
||||
a := raw.(map[string]interface{})
|
||||
if a["id"] == aid && a["has_credential"] != true {
|
||||
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// invokeCallback 直打 oauth_callback(Query 已含 state)。
|
||||
func (h *traeHarness) invokeCallback(q map[string]string) (int, string) {
|
||||
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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
180
internal/core/device_harness_test.go
Normal file
180
internal/core/device_harness_test.go
Normal file
@ -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)
|
||||
}
|
||||
73
internal/core/device_mutate_test.go
Normal file
73
internal/core/device_mutate_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
396
internal/core/kernel_actions.go
Normal file
396
internal/core/kernel_actions.go
Normal file
@ -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
|
||||
}
|
||||
336
internal/core/kernel_actions_test.go
Normal file
336
internal/core/kernel_actions_test.go
Normal file
@ -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
|
||||
}()
|
||||
}
|
||||
39
internal/core/kernel_effect_test.go
Normal file
39
internal/core/kernel_effect_test.go
Normal file
@ -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 是空转")
|
||||
}
|
||||
}
|
||||
69
internal/core/kernel_no_deadlock_test.go
Normal file
69
internal/core/kernel_no_deadlock_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
379
internal/gateway/messages.go
Normal file
379
internal/gateway/messages.go
Normal file
@ -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
|
||||
}
|
||||
404
internal/gateway/messages_test.go
Normal file
404
internal/gateway/messages_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
231
internal/gateway/mount.go
Normal file
231
internal/gateway/mount.go
Normal file
@ -0,0 +1,231 @@
|
||||
// Package gateway — 对外挂载点(R3 / Phase 6)
|
||||
//
|
||||
// 用户需求:插件与适配器可以注册**对外服务的端口**。
|
||||
//
|
||||
// 现状:插件已经能注册 HTTP 端点,但被固定在 /api/plugins/http/ 前缀下。
|
||||
// 那条前缀限制是有理由的(可 grep、可整体撤销、插件无法影子内核路由),
|
||||
// 但它也意味着插件无法提供"看起来像内核一部分"的接口——而对外服务恰恰
|
||||
// 需要这个(例如给上游回调用的固定路径 /v1/plugins/<name>/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/<name>/… or /v1/plugins/<name>/…).
|
||||
//
|
||||
// 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 "<name>/<rest>", 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/<name>/<rest>" or "/v1/plugins/<name>/<rest>"
|
||||
// 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
|
||||
}
|
||||
324
internal/gateway/mount_test.go
Normal file
324
internal/gateway/mount_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
var _ = strconv.Itoa
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
466
internal/gateway/serializer.go
Normal file
466
internal/gateway/serializer.go
Normal file
@ -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)
|
||||
}
|
||||
367
internal/gateway/serializer_test.go
Normal file
367
internal/gateway/serializer_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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":
|
||||
|
||||
@ -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
|
||||
@ -765,9 +765,20 @@ func (s *Stats) WindowTokens(model, source string, sec int64) int64 {
|
||||
return total
|
||||
}
|
||||
|
||||
// rows flattens a map[string]*Stat into a reportable, descending-by-requests
|
||||
// list.
|
||||
//
|
||||
// A nil value is skipped rather than dereferenced. The maps this is called with
|
||||
// are built by several callers (Snapshot, PeriodSnapshot, the audit replay) and a
|
||||
// nil entry is a legitimate representation of "this dimension has no data" —
|
||||
// `map[string]*Stat{key: s.byKey[key]}` produced exactly that whenever the
|
||||
// queried key had no recorded traffic, and `*v` then panicked the handler.
|
||||
func rows(m map[string]*Stat) []StatsRow {
|
||||
out := make([]StatsRow, 0, len(m))
|
||||
for k, v := range m {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, StatsRow{Name: k, Stat: *v})
|
||||
}
|
||||
for i := 1; i < len(out); i++ {
|
||||
@ -1185,7 +1196,26 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||
if key == "" {
|
||||
byKey, byModel, bySrc = s.byKey, s.byModel, s.bySrc
|
||||
} else {
|
||||
byKey = map[string]*Stat{key: s.byKey[key]}
|
||||
// The map is keyed by the MASKED key id (keyID(req.Key)), which is what
|
||||
// by_key reports and what the dashboard sends as ?key=. An admin may
|
||||
// pass anything in that query parameter, including the full key, and
|
||||
// `s.byKey[key]` then returns the zero value — a nil *Stat.
|
||||
//
|
||||
// rows() below does `*v` on every value, so a nil entry panicked the
|
||||
// handler: any ?key=<full key> request killed the response with an empty
|
||||
// reply, and logged a full panic stack. Reproduced against the live
|
||||
// gateway: /api/stats?period=day&limit=1&key=sk-gw-... → curl exit 52.
|
||||
//
|
||||
// A key with no recorded traffic is a legitimate question ("what did this
|
||||
// key use?"), and its answer is zero — so an absent entry is an empty
|
||||
// result, not an error, and not a crash.
|
||||
st := s.byKey[key]
|
||||
byKey = make(map[string]*Stat, 1)
|
||||
if st != nil {
|
||||
byKey[key] = st
|
||||
} else {
|
||||
byKey[key] = &Stat{}
|
||||
}
|
||||
byModel = s.byKeyModel[key]
|
||||
bySrc = s.byKeySrc[key]
|
||||
}
|
||||
@ -1202,8 +1232,11 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
bs := make([]agrRow, 0, len(s.byStatus))
|
||||
for code := range s.byStatus {
|
||||
bs = append(bs, agrRow{Name: strconv.Itoa(code), Stat: *s.byStatus[code]})
|
||||
for code, st := range s.byStatus {
|
||||
if st == nil {
|
||||
continue
|
||||
}
|
||||
bs = append(bs, agrRow{Name: strconv.Itoa(code), Stat: *st})
|
||||
}
|
||||
sort.Slice(bs, func(i, j int) bool {
|
||||
ci, _ := strconv.Atoi(bs[i].Name)
|
||||
|
||||
@ -174,6 +174,9 @@ func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapsh
|
||||
out.Srcs = rows(bySrc)
|
||||
bs := make([]agrRow, 0, len(byStatus))
|
||||
for code, st := range byStatus {
|
||||
if st == nil {
|
||||
continue
|
||||
}
|
||||
bs = append(bs, agrRow{Name: code, Stat: *st})
|
||||
}
|
||||
sort.Slice(bs, func(i, j int) bool {
|
||||
@ -192,7 +195,11 @@ func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapsh
|
||||
sort.Strings(ks)
|
||||
out.Bucket = make([]PeriodBucket, 0, len(ks))
|
||||
for _, k := range ks {
|
||||
out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *buckets[k]})
|
||||
// Defensive for the same reason rows() is: ks comes from the bucket map's
|
||||
// keys, and a nil value would panic on the deref below.
|
||||
if st := buckets[k]; st != nil {
|
||||
out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *st})
|
||||
}
|
||||
}
|
||||
|
||||
// The window is only "complete" if the audit walk actually reached back
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
140
internal/gateway/stats_unknown_key_test.go
Normal file
140
internal/gateway/stats_unknown_key_test.go
Normal file
@ -0,0 +1,140 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestSnapshotUnknownKeyDoesNotPanic covers a nil-dereference that killed the
|
||||
// response for any /api/stats request carrying a ?key= the statistics had no
|
||||
// entry for.
|
||||
//
|
||||
// The maps in Snapshot are keyed by the MASKED key id (keyID(req.Key)) — that
|
||||
// is what by_key reports and what the dashboard sends. An admin may pass anything
|
||||
// in the query parameter, including the full key, and `s.byKey[key]` then yields
|
||||
// the zero value: a nil *Stat. rows() dereferenced every value, so the handler
|
||||
// panicked and the client got an empty reply plus a panic stack in the log.
|
||||
//
|
||||
// Reproduced against the live gateway before the fix:
|
||||
//
|
||||
// /api/stats?period=day&limit=1&key=sk-gw-7ffe12c5cbacec40bd39a7291df9a9ed
|
||||
// → curl: (52) Empty reply from server
|
||||
// → log: http: panic serving …: invalid memory address or nil pointer dereference
|
||||
// llmsproxy/internal/gateway.rows stats.go:771
|
||||
//
|
||||
// A key with no recorded traffic is a legitimate question, and zero is its
|
||||
// answer — not a crash.
|
||||
func TestSnapshotUnknownKeyDoesNotPanic(t *testing.T) {
|
||||
s := NewStats(16)
|
||||
// One recorded key, masked the way Record masks it.
|
||||
s.Record(Req{Key: "***aaaaaa", Type: "chat", Model: "m", Prompt: 5, Compl: 2})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{"masked key that exists", "***aaaaaa"},
|
||||
{"masked key that does not exist", "***zzzzzz"},
|
||||
{"full key rather than the mask", "sk-gw-full-key-value"},
|
||||
{"arbitrary text", "not-a-key-at-all"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
// A panic here fails the subtest by unwinding through the test
|
||||
// goroutine; rows() used to take the whole process with it.
|
||||
out := s.Snapshot(10, c.key)
|
||||
byKey, ok := out["by_key"].([]StatsRow)
|
||||
if !ok {
|
||||
t.Fatalf("by_key is %T, want []StatsRow", out["by_key"])
|
||||
}
|
||||
total, _ := out["total"].(Stat)
|
||||
t.Logf("key=%q → by_key %d 条, total.reqs=%d", c.key, len(byKey), total.Reqs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotUnknownKeyReportsZeroNotAbsence pins the semantics: a key with no
|
||||
// traffic answers zero, and the by_key row still exists so a dashboard that keys
|
||||
// off its presence renders a row of zeros rather than dropping the key.
|
||||
func TestSnapshotUnknownKeyReportsZeroNotAbsence(t *testing.T) {
|
||||
s := NewStats(16)
|
||||
out := s.Snapshot(10, "***zzzzzz")
|
||||
byKey, _ := out["by_key"].([]StatsRow)
|
||||
if len(byKey) != 1 {
|
||||
t.Fatalf("by_key has %d rows, want 1: the queried key must appear with zeros", len(byKey))
|
||||
}
|
||||
if byKey[0].Name != "***zzzzzz" {
|
||||
t.Errorf("row name = %q, want ***zzzzzz", byKey[0].Name)
|
||||
}
|
||||
if byKey[0].Reqs != 0 {
|
||||
t.Errorf("reqs = %d, want 0", byKey[0].Reqs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeriodSnapshotUnknownKeyDoesNotPanic is the same guard on the period view,
|
||||
// which builds its own buckets and calls the same flattening helpers.
|
||||
func TestPeriodSnapshotUnknownKeyDoesNotPanic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
audit := dir + "/audit.jsonl"
|
||||
writeAuditFile(t, audit, 3, "***aaaaaa", time.Now().Add(-time.Hour).UnixMilli())
|
||||
s := periodStats(t, audit)
|
||||
|
||||
for _, key := range []string{"***aaaaaa", "***zzzzzz", "sk-gw-full-key-value"} {
|
||||
out := s.PeriodSnapshot(PeriodAll, key, time.Now())
|
||||
t.Logf("key=%q → total.reqs=%d byKey=%d buckets=%d",
|
||||
key, out.Total.Reqs, len(out.ByKey), len(out.Bucket))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRowsSkipsNilValues pins the defensive behaviour directly, so a future
|
||||
// caller that reintroduces a nil entry fails here rather than in production.
|
||||
func TestRowsSkipsNilValues(t *testing.T) {
|
||||
in := map[string]*Stat{
|
||||
"has-traffic": {Reqs: 5},
|
||||
"no-entry": nil,
|
||||
}
|
||||
got := rows(in)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("rows() returned %d rows, want 1 (the nil entry must be skipped): %+v", len(got), got)
|
||||
}
|
||||
if got[0].Name != "has-traffic" {
|
||||
t.Errorf("kept %q, want has-traffic", got[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRowsEmptyIsEmptyNotNil keeps the JSON shape honest: an all-nil map yields
|
||||
// an empty list, which encodes as [] rather than null — the frontend does
|
||||
// Array.isArray checks on these fields.
|
||||
func TestRowsEmptyIsEmptyNotNil(t *testing.T) {
|
||||
got := rows(map[string]*Stat{"a": nil})
|
||||
if got == nil {
|
||||
t.Fatal("rows() returned nil; the report field must stay [] instead of null")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("got %d rows, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatsAPIUnknownKeyReturnsJSON exercises the HTTP path so the assertion
|
||||
// covers the reported symptom (an empty reply), not just the helper.
|
||||
func TestStatsAPIUnknownKeyReturnsJSON(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
srv := httptest.NewServer(g.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest("GET", srv.URL+"/api/stats?period=day&limit=1&key=sk-gw-full-key", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer sk-test")
|
||||
resp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed (an empty reply is what the live gateway produced): %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
133
internal/gateway/thinking_test.go
Normal file
133
internal/gateway/thinking_test.go
Normal file
@ -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 }
|
||||
@ -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() {
|
||||
<td>${hooks}</td>
|
||||
<td>${plVisCell(j, p)}</td>
|
||||
<td>${plEpsCell(j, p)}</td>
|
||||
<td data-plkernel="${escAttr(p.name)}">${plKernelCell(p)}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="small" onclick="editPlugin('${escAttr(p.name)}')">${t("plEdit")}</button>
|
||||
${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) => `
|
||||
<tr><td><span class="tag tag-red">路径</span> <code>${esc(c.path)}</code></td>
|
||||
<td>${c.candidates.map(esc).join(" vs ")}</td>
|
||||
<td>${c.resolved ? `<span class="tag tag-green">已选 ${esc(c.resolved)}</span>` : `<span class="tag tag-red">待裁决</span>`}</td>
|
||||
<td>${c.candidates.map((n) => `<button class="small" onclick="resolveRoute('${escAttr(c.path)}','${escAttr(n)}')">用 ${esc(n)}</button>`).join(" ")}</td></tr>`))
|
||||
.concat(ac.map((c) => `
|
||||
<tr><td><span class="tag">适配器</span> <code>${esc(c.name)}</code></td>
|
||||
<td>${esc(c.incumbent)}(现任) vs 插件 ${esc(c.candidate)}(候选)</td>
|
||||
<td>${c.resolved ? `<span class="tag tag-green">已选 ${esc(c.resolved)}</span>` : `<span class="tag tag-red">待裁决</span>`}</td>
|
||||
<td><button class="small" onclick="resolveAdapter('${escAttr(c.name)}','incumbent')">保留现任</button>
|
||||
<button class="small" onclick="resolveAdapter('${escAttr(c.name)}','candidate')">用插件版</button></td></tr>`))
|
||||
.join("");
|
||||
confHtml = `<div class="card" style="border-color:var(--danger)"><h2>冲突裁决(${rc.length + ac.length})</h2>
|
||||
<p class="muted">两个插件/适配器声明了同一条服务路径或同一个适配器名。未裁决的路径返回 409,撞名的适配器由现任继续服务。请选一个保留。</p>
|
||||
<div class="tbl-wrap"><table><tr><th>冲突项</th><th>候选</th><th>状态</th><th>裁决</th></tr>${rows2}</table></div></div>`;
|
||||
}
|
||||
} 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}
|
||||
<div class="card"><h2>${t("plTitle")} (${(j.on_disk || []).length})</h2>
|
||||
<div class="tbl-wrap"><table><tr>
|
||||
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th>${t("plVisibility")}</th><th>${t("plEndpoints")}</th><th></th>
|
||||
</tr>${rows || `<tr><td colspan="6" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
|
||||
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th>${t("plVisibility")}</th><th>${t("plEndpoints")}</th><th>${t("plKernel")}</th><th></th>
|
||||
</tr>${rows || `<tr><td colspan="7" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
|
||||
<div id="pl-access"></div>
|
||||
${errList ? `<p class="muted" style="color:var(--danger)">${t("plHookErr")}</p><ul style="font-size:12px">${errList}</ul>` : ""}
|
||||
<p class="muted">${t("plDir")} <code>${esc(j.plugin_dir)}</code></p>
|
||||
@ -4897,7 +4970,21 @@ function afterChainEdit() {
|
||||
<p><button onclick="installPlugin()">${t("plInstallBtn")}</button></p>
|
||||
</div>`;
|
||||
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() {
|
||||
: `<span class="muted">—</span>`;
|
||||
}
|
||||
|
||||
// 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 `<span class="muted">—</span>`;
|
||||
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
|
||||
? ` <span class="tag tag-red" title="${escAttr(pending.join(", "))}">${t("plKernelReq")}</span>`
|
||||
: "";
|
||||
return `<a href="#" onclick="openKernelEditor('${escAttr(p.name)}');return false">${label}</a>${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 = `<div class="card"><h2>${esc(name)}</h2><div class="muted">${esc(String(e.message || e))}</div></div>`;
|
||||
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"
|
||||
? `<span class="tag tag-red">${t("plDangerDestructive")}</span>`
|
||||
: a.danger === "write"
|
||||
? `<span class="tag">${t("plDangerWrite")}</span>`
|
||||
: `<span class="tag tag-green">${t("plDangerRead")}</span>`;
|
||||
const asked = a.declared ? ` <span class="tag tag-blue">${t("plKernelAsked")}</span>` : "";
|
||||
return `<tr>
|
||||
<td><input type="checkbox" data-ka="${escAttr(a.name)}" ${granted.has(a.name) ? "checked" : ""}></td>
|
||||
<td><b>${esc(a.name)}</b>${asked}<div class="muted">${esc(a.description || "")}</div></td>
|
||||
<td>${tag}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
const declaredHosts = j.hosts_declared || [];
|
||||
const grantedHosts = new Set(j.hosts_granted || []);
|
||||
const hostRows = declaredHosts.length
|
||||
? declaredHosts
|
||||
.map(
|
||||
(h) => `<tr><td><input type="checkbox" data-ah="${escAttr(h)}" ${grantedHosts.has(h) ? "checked" : ""}></td>
|
||||
<td><code>${esc(h)}</code></td>
|
||||
<td>${grantedHosts.has(h) ? `<span class="tag tag-green">${t("plHostOk")}</span>` : `<span class="tag">${t("plHostPending")}</span>`}</td></tr>`,
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="3" class="empty">${t("plNoAuthHosts")}</td></tr>`;
|
||||
host.innerHTML = `
|
||||
<div class="card"><h2>${esc(name)} — ${t("plKernelTitle")}</h2>
|
||||
<p class="muted">${t("plKernelHint")}</p>
|
||||
<div class="tbl-wrap"><table>
|
||||
<tr><th></th><th>${t("plAction")}</th><th>${t("plDanger")}</th></tr>
|
||||
${rows}
|
||||
</table></div>
|
||||
<p><button onclick="saveKernelPerms('${escAttr(name)}')">${t("plSave")}</button>
|
||||
<button onclick="document.getElementById('pl-access').innerHTML=''">${t("plClose")}</button></p>
|
||||
</div>
|
||||
<div class="card"><h2>${t("plAuthHosts")}</h2>
|
||||
<p class="muted">${t("plAuthHostsHint")}</p>
|
||||
<div class="tbl-wrap"><table>${hostRows}</table></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,4 +106,4 @@ func sendImageSource(t *testing.T) string {
|
||||
body = body[:end+1]
|
||||
}
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
94
internal/lua/adapter_two_directions_test.go
Normal file
94
internal/lua/adapter_two_directions_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
133
internal/lua/adapter_validate.go
Normal file
133
internal/lua/adapter_validate.go
Normal file
@ -0,0 +1,133 @@
|
||||
// Package lua — 适配器导入安全校验(R6)
|
||||
//
|
||||
// 用户要求(feature/unified-wire-v2):导入适配器时必须检查路径冲突,
|
||||
// 避免静默错误。
|
||||
//
|
||||
// 之前的风险:
|
||||
//
|
||||
// UploadAdapter(name, code) 直接拼 adapter_dir/<name>.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/<name>.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
|
||||
197
internal/lua/adapter_validate_test.go
Normal file
197
internal/lua/adapter_validate_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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) == "<!doctype" or string.find(low, "<html", 1, true) then
|
||||
return "blocked by AgentRouter WAF"
|
||||
end
|
||||
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" and type(e.message) == "string" then
|
||||
return e.message
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -5,6 +5,14 @@ adapter.endpoint = "/v1/messages"
|
||||
adapter.headers = {
|
||||
["anthropic-version"] = "2023-06-01",
|
||||
}
|
||||
-- INGRESS CLAIM (inbound dual of serialize_*): this adapter owns parsing of the
|
||||
-- client-facing POST /v1/messages request body. The kernel keeps a built-in
|
||||
-- Anthropic parser as its fallback (R5) — that is what an unconfigured
|
||||
-- deployment uses — but once this adapter is loaded the kernel YIELDS, and the
|
||||
-- deserialize_request hook below is the single place Anthropic's REQUEST shape
|
||||
-- is understood. Iterating on that shape therefore means editing this file,
|
||||
-- never the kernel.
|
||||
adapter.ingress = "/v1/messages"
|
||||
|
||||
-- ============================================================
|
||||
-- helpers
|
||||
@ -132,6 +140,79 @@ local function append_user(msgs, blocks)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- deserialize_request (Anthropic → unified) [INGRESS]
|
||||
-- ============================================================
|
||||
-- The inbound dual of serialize_response. The kernel hands over the raw client
|
||||
-- body and receives the unified request; from there scheduling is identical to
|
||||
-- /v1/chat/completions. Ported field-for-field from the kernel's former
|
||||
-- built-in anthropicRequest.toUnified — which remains as the kernel fallback
|
||||
-- for an unconfigured deployment.
|
||||
function adapter.deserialize_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok or type(req) ~= "table" then
|
||||
error("invalid json")
|
||||
end
|
||||
|
||||
-- system accepts Anthropic's two spellings: a plain string, or an array of
|
||||
-- { type = "text", text = ... } blocks. Only text blocks contribute.
|
||||
local system = ""
|
||||
local sys = req.system
|
||||
if type(sys) == "string" then
|
||||
system = sys
|
||||
elseif type(sys) == "table" then
|
||||
for _, b in ipairs(sys) do
|
||||
if type(b) == "table" and b.type == "text" and type(b.text) == "string" then
|
||||
system = system .. b.text
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local msgs = {}
|
||||
local in_msgs = req.messages
|
||||
if type(in_msgs) ~= "table" or #in_msgs == 0 then
|
||||
error("messages is required")
|
||||
end
|
||||
for i, m in ipairs(in_msgs) do
|
||||
if type(m) ~= "table" or type(m.role) ~= "string" or m.role == "" then
|
||||
error("messages[" .. (i - 1) .. "].role is required")
|
||||
end
|
||||
-- Content is carried VERBATIM: a string stays a string, a block array
|
||||
-- stays an array. The unified format keeps multimodal/tool blocks
|
||||
-- untouched because only an adapter knows which block types the upstream
|
||||
-- accepts. An absent/null content becomes the empty string.
|
||||
local content = m.content
|
||||
if content == nil or content == "" then
|
||||
content = ""
|
||||
end
|
||||
table.insert(msgs, { role = m.role, content = content })
|
||||
end
|
||||
|
||||
-- Anthropic's default is thinking OFF; emit that explicitly so the unified
|
||||
-- request carries the client's intent rather than leaving it absent.
|
||||
local thinking = req.thinking
|
||||
if thinking == nil then
|
||||
thinking = { enabled = false }
|
||||
end
|
||||
|
||||
local out = {
|
||||
model = req.model or "",
|
||||
messages = msgs,
|
||||
system = system,
|
||||
max_tokens = req.max_tokens or 0,
|
||||
stream = req.stream == true,
|
||||
temperature = req.temperature,
|
||||
top_p = req.top_p,
|
||||
top_k = req.top_k or 0,
|
||||
stop_sequences = req.stop_sequences,
|
||||
tools = req.tools,
|
||||
tool_choice = req.tool_choice,
|
||||
thinking = thinking,
|
||||
extra_body = {},
|
||||
}
|
||||
return json.encode(out)
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- transform_request (OpenAI → Anthropic)
|
||||
-- ============================================================
|
||||
@ -254,12 +335,24 @@ function adapter.transform_request(raw_body)
|
||||
end
|
||||
end
|
||||
|
||||
-- ── thinking (opt-in via extra_body) ──────────────────────
|
||||
-- Default: OFF. Client sends extra_body.thinking to enable.
|
||||
-- Example: {"extra_body": {"thinking": {"type": "enabled", "budget_tokens": 4096}}}
|
||||
if type(req.extra_body) == "table" and type(req.extra_body.thinking) == "table" then
|
||||
-- ── thinking ─────────────────────────────────────────────
|
||||
-- Anthropic 的原生字段就是 {type, budget_tokens},所以中立语义几乎直译。
|
||||
--
|
||||
-- 中立格式(v2):{ enabled = true/false, budget_tokens = N }
|
||||
-- enabled=false → 不传该字段(Anthropic 默认关闭思考,传 type=disabled
|
||||
-- 是非法的,这也是过去只能「不传」的原因)
|
||||
-- enabled=true → { type="enabled", budget_tokens=N 或默认 }
|
||||
--
|
||||
-- 仍接受 extra_body.thinking 的原生写法:已有部署的配置不因此失效。
|
||||
local th = req.thinking
|
||||
if type(th) == "table" and th.enabled ~= false then
|
||||
local budget = th.budget_tokens
|
||||
if budget == nil or budget <= 0 then budget = 4096 end -- Anthropic 必填
|
||||
anthropic_req.thinking = { type = "enabled", budget_tokens = budget }
|
||||
elseif type(req.extra_body) == "table" and type(req.extra_body.thinking) == "table" then
|
||||
anthropic_req.thinking = req.extra_body.thinking
|
||||
end
|
||||
req.thinking = nil
|
||||
|
||||
-- ── system ───────────────────────────────────────────────
|
||||
if system ~= "" then
|
||||
@ -444,4 +537,124 @@ function adapter.transform_error(status, body)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- 对外序列化(统一格式 → Anthropic 形状)
|
||||
-- ============================================================
|
||||
--
|
||||
-- 内核只持统一格式;对外发什么形状由适配器决定(Phase 2)。
|
||||
-- 这几个钩子让以 anthropic 适配器为服务端的源,直接对 Anthropic 协议客户端
|
||||
-- 提供 /v1/messages 语义,而内核里不再有任何 Anthropic 字段名。
|
||||
--
|
||||
-- 与入向的 transform_* 互为逆运算:那里的映射(end_turn→stop)在这里反着
|
||||
-- 做回去(stop→end_turn),且默认 stop_sequence 是最保守的选择——它语义上
|
||||
-- 最接近"正常结束"。
|
||||
|
||||
-- 统一 finish_reason → Anthropic stop_reason
|
||||
local function to_stop_reason(finish)
|
||||
if finish == "tool_calls" then return "tool_use" end
|
||||
if finish == "length" then return "max_tokens" end
|
||||
if finish == "content_filter" then return "refusal" end
|
||||
-- stop / 空 / 未知 → end_turn(Anthropic 的"正常说完")
|
||||
return "end_turn"
|
||||
end
|
||||
|
||||
-- usage 数字化:内部 TokenUsage 的字段名与 Anthropic 的 input_tokens/output_tokens
|
||||
-- 不同名,这里做最后一次翻译。
|
||||
local function to_anthropic_usage(u)
|
||||
if type(u) ~= "table" then
|
||||
return { input_tokens = 0, output_tokens = 0 }
|
||||
end
|
||||
return {
|
||||
input_tokens = u.prompt or 0,
|
||||
output_tokens = u.completion or 0,
|
||||
}
|
||||
end
|
||||
|
||||
-- 非流式响应:统一结果 → Anthropic Messages 响应对象
|
||||
function adapter.serialize_response(payload)
|
||||
local p = json.decode(payload)
|
||||
local blocks = {}
|
||||
-- thinking 放最前,与 Anthropic 自己的块顺序一致
|
||||
if p.reasoning ~= nil and p.reasoning ~= "" then
|
||||
blocks[#blocks + 1] = { type = "thinking", thinking = p.reasoning }
|
||||
end
|
||||
blocks[#blocks + 1] = { type = "text", text = p.content or "" }
|
||||
if type(p.tool_calls) == "table" and #p.tool_calls > 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
|
||||
|
||||
@ -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 兼容客户端不会发该字段,补空串即可通过校验。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
287
internal/lua/conflict_arbitration_test.go
Normal file
287
internal/lua/conflict_arbitration_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
498
internal/lua/kernel_api.go
Normal file
498
internal/lua/kernel_api.go
Normal file
@ -0,0 +1,498 @@
|
||||
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}
|
||||
}
|
||||
|
||||
type jsonPluginPolicy struct {
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
AllRead bool `json:"all_read,omitempty"`
|
||||
Hosts []string `json:"hosts,omitempty"`
|
||||
}
|
||||
|
||||
func policyFromActions(p KernelPermission) jsonPluginPolicy {
|
||||
return jsonPluginPolicy{Actions: p.Actions, AllRead: p.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
|
||||
}
|
||||
126
internal/lua/kernel_perms_persist.go
Normal file
126
internal/lua/kernel_perms_persist.go
Normal file
@ -0,0 +1,126 @@
|
||||
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: { "<plugin>": { "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
|
||||
}
|
||||
// The file holds BOTH kernel-action grants (map[string]jsonPermission, the
|
||||
// historical shape) AND, for plugins that asked for them, the operator's
|
||||
// extra-host grants (map[string]jsonPluginPolicy.Hosts). A bare
|
||||
// {actions:[...]} row still parses into jsonPluginPolicy with empty Hosts,
|
||||
// so old files load unchanged.
|
||||
raw := map[string]jsonPluginPolicy{}
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
if ps.kernelPerms == nil {
|
||||
ps.kernelPerms = kernelPermissionSet{}
|
||||
}
|
||||
if ps.extraHosts == nil {
|
||||
ps.extraHosts = map[string][]string{}
|
||||
}
|
||||
for name, jp := range raw {
|
||||
ps.kernelPerms[name] = KernelPermission{Actions: jp.Actions, AllRead: jp.AllRead}
|
||||
if len(jp.Hosts) > 0 {
|
||||
// Load the persisted grant into extraHosts WITHOUT folding it into the
|
||||
// allowlist yet — sources are loaded later, and the caller owns that
|
||||
// fold. GrantExtraHosts rebuilds the allowlist when sources are ready.
|
||||
ps.extraHosts[name] = append([]string(nil), jp.Hosts...)
|
||||
}
|
||||
}
|
||||
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.
|
||||
//
|
||||
// Host grants travel in the same file (jsonPluginPolicy.Hosts): a granted host is
|
||||
// the operator's explicit decision to let a plugin egress somewhere, and losing
|
||||
// it across a restart (the previous behaviour) made grants silently vanish.
|
||||
func (ps *Plugins) saveKernelPermissionsLocked() {
|
||||
path := ps.kernelPermsFile()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
out := map[string]jsonPluginPolicy{}
|
||||
something := false
|
||||
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)
|
||||
}
|
||||
}
|
||||
row := policyFromActions(KernelPermission{Actions: valid, AllRead: perm.AllRead})
|
||||
if hs := ps.extraHosts[name]; len(hs) > 0 {
|
||||
row.Hosts = append([]string(nil), hs...)
|
||||
}
|
||||
if len(valid) == 0 && !perm.AllRead && len(row.Hosts) == 0 {
|
||||
continue // nothing granted: presence would be a lie
|
||||
}
|
||||
out[name] = row
|
||||
something = true
|
||||
}
|
||||
// A plugin may hold host grants with NO kernel actions granted (operators
|
||||
// approve hosts and actions independently). Emit those too.
|
||||
for name, hs := range ps.extraHosts {
|
||||
if len(hs) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := out[name]; !exists {
|
||||
out[name] = jsonPluginPolicy{Hosts: append([]string(nil), hs...)}
|
||||
something = true
|
||||
}
|
||||
}
|
||||
if !something {
|
||||
_ = os.Remove(path)
|
||||
return
|
||||
}
|
||||
writeJSONAtomic(path, out)
|
||||
}
|
||||
@ -338,3 +338,67 @@ return plugin
|
||||
t.Errorf("persisted n = %v after three exported calls, want 3", saved.State["n"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeHandlerIsPersisted 是真实事故的回归:channels 插件声明 hooks=[],
|
||||
// 一切状态变更(账号入池、凭据引用、模型黑名单、事件日志)都经过自己的 HTTP
|
||||
// handler。而 InvokeHandler 是唯一一条**从不 markDirty** 的分发路径 —— hook 路径
|
||||
// 有、Call 路径有,只有它漏了。后果:
|
||||
//
|
||||
// .channels.state.json 从未被写出 → 重启后账号池静默清空。
|
||||
// 运维在升级前的重启里丢掉了刚登录的账号,界面显示"已登录"但列表为空。
|
||||
//
|
||||
// billing/quota 侥幸没踩到,只因为它们有 hooks(billing)或用 Call(quota)。
|
||||
// 本测试用**无 hooks、只走 handler** 的插件复现该形态。
|
||||
func TestInvokeHandlerIsPersisted(t *testing.T) {
|
||||
ps, _ := newPersistVM(t)
|
||||
if err := ps.LoadSource("handlersonly", `
|
||||
local plugin = { name = "handlersonly", version = "1.0" }
|
||||
plugin.state = { accounts = {} }
|
||||
-- 不声明任何 hooks:状态只能经 HTTP handler 变更(channels 的形态)
|
||||
plugin.hooks = {}
|
||||
plugin.http = {
|
||||
{ path = "add", method = "POST", handler = "add" },
|
||||
}
|
||||
function plugin.add(req)
|
||||
local body = req.body or ""
|
||||
local id = body:match('"id":"([^"]+)"') or "unknown"
|
||||
plugin.state.accounts[id] = { id = id, enabled = true }
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
return plugin
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, id := range []string{"acct-1", "acct-2"} {
|
||||
resp, err := ps.InvokeHandler("handlersonly", "add", &handlerRequest{
|
||||
Method: "POST", Body: `{"id":"` + id + `"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("handler returned nil response")
|
||||
}
|
||||
}
|
||||
// Close() waits for the final flush, so no timing is involved: anything the
|
||||
// handlers did NOT mark dirty cannot appear here.
|
||||
ps.Close()
|
||||
|
||||
b, err := os.ReadFile(ps.stateFile("handlersonly"))
|
||||
if err != nil {
|
||||
t.Fatalf("★ 无 hooks 的插件经 HTTP handler 改状态后未落盘: %v\n"+
|
||||
" —— InvokeHandler 必须像 Call / hook 路径一样 markDirty,\n"+
|
||||
" 否则 channels 的账号池会在每次重启后静默清空", err)
|
||||
}
|
||||
var saved struct {
|
||||
State map[string]interface{} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accts, _ := saved.State["accounts"].(map[string]interface{})
|
||||
if len(accts) != 2 {
|
||||
t.Fatalf("落盘的 accounts = %v,应为 2 个账号", saved.State["accounts"])
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,4 +112,4 @@ func (ps *Plugins) sortedOverrideKeys() []string {
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
}
|
||||
|
||||
293
internal/lua/plugin_adapter_bridge.go
Normal file
293
internal/lua/plugin_adapter_bridge.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
234
internal/lua/plugin_adapter_bridge_lua_test.go
Normal file
234
internal/lua/plugin_adapter_bridge_lua_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
264
internal/lua/plugin_adapter_bridge_test.go
Normal file
264
internal/lua/plugin_adapter_bridge_test.go
Normal file
@ -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
|
||||
}
|
||||
264
internal/lua/plugin_bridges.go
Normal file
264
internal/lua/plugin_bridges.go
Normal file
@ -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
|
||||
}
|
||||
@ -43,6 +43,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
@ -80,19 +81,39 @@ var httpClient = &http.Client{
|
||||
const httpGlobal = "__llmsproxy_http"
|
||||
|
||||
// httpAllowlist is the set of hosts plugins may contact: the host of every
|
||||
// configured source's base_url.
|
||||
// configured source's base_url, plus every operator-granted extra host.
|
||||
//
|
||||
// It is a LIVE, shared object, not a snapshot. This matters: bootPluginState
|
||||
// binds one *httpAllowlist into every Lua closure at state-boot time, and those
|
||||
// closures live for the plugin's lifetime. Granting an extra host after a
|
||||
// worker booted must therefore MUTATE the same object the closures hold —
|
||||
// replacing ps.allowHTTP with a fresh instance would leave every already-booted
|
||||
// worker checking the STALE list, and a grant would look accepted (the API
|
||||
// answers ok, the UI shows it) while requests kept getting refused with
|
||||
// "not an allowed outbound destination". That exact failure shipped once.
|
||||
//
|
||||
// Mutations are rare (operator approves a host); lookups are per request.
|
||||
type httpAllowlist struct {
|
||||
mu sync.RWMutex
|
||||
hosts map[string]bool
|
||||
}
|
||||
|
||||
func newHTTPAllowlist(hosts []string) *httpAllowlist {
|
||||
m := make(map[string]bool, len(hosts))
|
||||
a := &httpAllowlist{hosts: make(map[string]bool, len(hosts))}
|
||||
a.add(hosts)
|
||||
return a
|
||||
}
|
||||
|
||||
// add merges hosts into the live allowlist. Called by rebuild (source hosts
|
||||
// change on hot reload) and by GrantExtraHosts (operator approves a host).
|
||||
func (a *httpAllowlist) add(hosts []string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, h := range hosts {
|
||||
if h != "" {
|
||||
m[strings.ToLower(h)] = true
|
||||
a.hosts[strings.ToLower(h)] = true
|
||||
}
|
||||
}
|
||||
return &httpAllowlist{hosts: m}
|
||||
}
|
||||
|
||||
// allows reports whether host:port may be contacted. Ports 80/443 are implied
|
||||
@ -103,6 +124,8 @@ func (a *httpAllowlist) allows(host string, port string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.hosts[host] {
|
||||
return true
|
||||
}
|
||||
@ -245,4 +268,4 @@ func flattenHeaders(h http.Header) map[string]string {
|
||||
out[strings.ToLower(k)] = h.Get(k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,4 +119,4 @@ func asFloat(v interface{}) (float64, bool) {
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -264,6 +264,10 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
|
||||
L.Pop(1)
|
||||
}
|
||||
if errMsg != "" {
|
||||
// A handler that raised partway may already have mutated plugin.state
|
||||
// (an account added before a later step failed), so mark it dirty before
|
||||
// bailing: the mutation it managed to make is still real.
|
||||
ps.markDirtyLocked(p)
|
||||
return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg)
|
||||
}
|
||||
if L.GetTop() < 1 || L.IsNil(-1) {
|
||||
@ -273,6 +277,18 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
|
||||
if err := luaToJSON(L, -1, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A handler mutates plugin.state just as a hook or an exported Call does, and
|
||||
// this was the one dispatch path that never marked the plugin dirty.
|
||||
//
|
||||
// Consequence (real defect): a plugin with NO hooks — channels is exactly
|
||||
// that, it declares hooks=[] and drives everything through its own HTTP
|
||||
// routes — accumulated accounts, credential refs, model blacklists and its
|
||||
// event log entirely in memory. Nothing ever marked it dirty, so
|
||||
// .channels.state.json was never written and a restart silently wiped the
|
||||
// account pool: the operator saw "logged in", restarted for an upgrade, and
|
||||
// the account was gone. billing/quota escape this only because they declare
|
||||
// hooks and/or use Call, both of which already mark dirty.
|
||||
ps.markDirtyLocked(p)
|
||||
return parseHandlerResponse(raw)
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
//
|
||||
|
||||
@ -14,6 +14,8 @@ package lua
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@ -149,6 +151,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
|
||||
@ -215,3 +218,139 @@ return P
|
||||
t.Fatalf("body=%q, want the server's JSON", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantExtraHostsVisibleToAlreadyBootedWorker 是真实事故的回归测试。
|
||||
//
|
||||
// 事故:allowlist 是不可变快照。bootPluginState 在 worker 启动时把 *httpAllowlist
|
||||
// 指针绑进 Lua 闭包;GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**,已启动的
|
||||
// worker 仍持旧指针 → 授权接口返回 ok、UI 显示已批,但插件请求仍被
|
||||
// "not an allowed outbound destination" 拒绝。channels 插件的 auth/state 就是这样
|
||||
// 在生产被拦的(copilot.tencent.com 已批仍报错)。
|
||||
//
|
||||
// 修复:allowlist 改为带锁的**可变共享对象**(原地 add),所有闭包立即可见。
|
||||
// 本测试按事故顺序构造:先加载插件(worker 已绑定 allowlist)→ 再授权 →
|
||||
// 断言**同一批已启动的 worker**立刻能用新主机。
|
||||
func TestGrantExtraHostsVisibleToAlreadyBootedWorker(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
ps.SetOutboundHosts(nil) // 起始只允许 loopback
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
// srv.URL 是 127.0.0.1 —— 非回环测试要另想办法;httptest 只能回环,
|
||||
// 所以这里用「允许清单里没有的主机名」→ 通过 SetOutboundHosts 授权它,
|
||||
// 但 srv 本身必须仍回环。做法:插件请求的 URL 用 srv.URL(回环,恒允许)?
|
||||
// 不行——那测不出 allowlist 更新。改用 checkURL 直接断言新主机可见。
|
||||
//
|
||||
// 真实事故的关键路径:worker 已启动 → 授权 → 同一 worker 立即看到。
|
||||
// Lua 层面用回环无法区分(回环永远允许),因此这里的断言打在两层:
|
||||
// ① allowlist 对象层:已存在实例 add 后立即 allows() 新主机;
|
||||
// ② 插件层:SetOutboundHosts 重新折算后,同一 worker 的请求仍工作
|
||||
// (即 rebuild 不会使旧引用失效)。
|
||||
host := "portal.example.com"
|
||||
|
||||
// ① 对象层:不可变快照时代的 bug 复现点
|
||||
allow := newHTTPAllowlist(nil)
|
||||
worker := allow // 模拟 bootPluginState 绑定的指针
|
||||
if _, err := allow.checkURL("https://" + host + "/x"); err == nil {
|
||||
t.Fatal("前提不成立:未授权主机不应允许")
|
||||
}
|
||||
allow.add([]string{host}) // GrantExtraHosts 的实际路径(原地 add)
|
||||
if _, err := worker.checkURL("https://" + host + "/x"); err != nil {
|
||||
t.Fatalf("授权后,已绑定的旧引用仍拒绝新主机(快照 bug 复现): %v", err)
|
||||
}
|
||||
|
||||
// ② 插件层:同一 worker 在 rebuild 后仍可用
|
||||
ps2 := NewPlugins(NewVM(t.TempDir()), t.TempDir())
|
||||
ps2.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
probe := `
|
||||
local P = {}
|
||||
P.name = "grantprobe"
|
||||
P.version = "1.0"
|
||||
P.exports = { "probe" }
|
||||
P.hosts = { "portal.example.com" }
|
||||
function P.probe(arg)
|
||||
local res, err = ` + httpGlobal + `({ url = arg.url, method = "GET" })
|
||||
if res == nil then return { error = tostring(err) } end
|
||||
return { status = res.status }
|
||||
end
|
||||
return P
|
||||
`
|
||||
if err := ps2.LoadSource("grantprobe", probe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// worker 已启动。现在授权(grant → rebuild → 原地 add)
|
||||
if err := ps2.GrantExtraHosts("grantprobe", []string{"portal.example.com"}); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
// 同一批 worker:一次调用成功即证明 allowlist 引用未被弃用
|
||||
got, err := ps2.Call("grantprobe", "probe", map[string]interface{}{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := got.(map[string]interface{})
|
||||
if m == nil || m["error"] != nil {
|
||||
t.Fatalf("授权后同一 worker 调用失败(引用被弃用): %v", got)
|
||||
}
|
||||
if s, _ := m["status"].(float64); int(s) != 200 {
|
||||
t.Fatalf("status=%v, want 200", m["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostGrantsPersistAcrossRestart 是第二个真实缺陷的回归:host 授权此前只
|
||||
// 写进运行态(extraHosts map),从不落盘 —— 重启后授权消失,插件出站全部被拦,
|
||||
// 而设置页看不出任何异常(actions 有持久化、hosts 没有,读起来像"都持久化了")。
|
||||
func TestHostGrantsPersistAcrossRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(filepath.Join(dir, "adapters")), dir)
|
||||
if err := ps.LoadSource("grantprobe", `
|
||||
local P = {}
|
||||
P.name = "grantprobe"
|
||||
P.version = "1.0"
|
||||
P.exports = {}
|
||||
P.hosts = { "portal.example.com", "api.partner.example" }
|
||||
return P
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.GrantExtraHosts("grantprobe", []string{"portal.example.com", "api.partner.example"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 落盘验证
|
||||
b, err := os.ReadFile(filepath.Join(dir, ".kernel-perms.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("授权未落盘: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(b), "portal.example.com") {
|
||||
t.Fatalf("host 授权应写入 .kernel-perms.json,文件内容: %s", b)
|
||||
}
|
||||
|
||||
// 重启语义:新 registry 读回授权
|
||||
ps2 := NewPlugins(NewVM(filepath.Join(dir, "adapters")), dir)
|
||||
if err := ps2.LoadKernelPermissions(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ps2.ExtraHostsGranted()["grantprobe"]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("重启后 host 授权应恢复 2 条, 实得 %v", got)
|
||||
}
|
||||
// 插件再加载后,授权可直接使用(GrantExtraHosts 只接受"声明过"的主机,
|
||||
// 所以重载插件不会丢授权这件事本身就是恢复生效的证据)
|
||||
if err := ps2.LoadSource("grantprobe", `
|
||||
local P = {}
|
||||
P.name = "grantprobe"
|
||||
P.version = "1.0"
|
||||
P.exports = {}
|
||||
P.hosts = { "portal.example.com", "api.partner.example" }
|
||||
return P
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = ps2.ExtraHostsGranted()["grantprobe"]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("插件重载后授权应保留, 实得 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
164
internal/lua/plugin_outbound.go
Normal file
164
internal/lua/plugin_outbound.go
Normal file
@ -0,0 +1,164 @@
|
||||
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.
|
||||
//
|
||||
// The allowlist object is mutated IN PLACE (allowHTTP.add) rather than replaced:
|
||||
// already-booted plugin workers hold the ORIGINAL pointer in their Lua closures
|
||||
// (bootPluginState captured it at boot), so replacing the field would strand
|
||||
// them on a stale copy — a grant would be recorded and displayed while requests
|
||||
// kept being refused. Mutating the shared object makes every closure see the
|
||||
// new hosts immediately.
|
||||
//
|
||||
// Note this FOLDS sources + grants additively: a host that loses its grant
|
||||
// cannot be un-allowed this way, which is acceptable because grants are
|
||||
// append-only operator approvals (revocation is a restart away — the persisted
|
||||
// file is the authority on boot).
|
||||
//
|
||||
// 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.add(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
|
||||
}
|
||||
499
internal/lua/plugin_runtime.go
Normal file
499
internal/lua/plugin_runtime.go
Normal file
@ -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))
|
||||
}
|
||||
371
internal/lua/plugin_runtime_test.go
Normal file
371
internal/lua/plugin_runtime_test.go
Normal file
@ -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)
|
||||
}
|
||||
325
internal/lua/plugin_secrets.go
Normal file
325
internal/lua/plugin_secrets.go
Normal file
@ -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 回显带走。
|
||||
//
|
||||
// 保险库把凭据隔离成独立文件:
|
||||
//
|
||||
// <plugin_dir>/.secrets/<plugin>.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()))
|
||||
}
|
||||
}
|
||||
@ -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--
|
||||
|
||||
52
internal/lua/plugin_unload_test.go
Normal file
52
internal/lua/plugin_unload_test.go
Normal file
@ -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 未被调用")
|
||||
}
|
||||
}
|
||||
@ -190,4 +190,4 @@ func pageIDs(pages []*UIPage) []string {
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:<plugin>" 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
|
||||
}
|
||||
@ -561,22 +641,30 @@ func (ps *Plugins) LoadDir() error {
|
||||
// directory does not exist yet, mirroring the adapter seeding rule: once the
|
||||
// directory exists it is authoritative, so deleting or editing a shipped plugin
|
||||
// is a real action that survives restarts.
|
||||
// SeedBundled writes the plugins shipped with the gateway into dir when they are
|
||||
// MISSING.
|
||||
//
|
||||
// This used to be "write everything only when the directory does not exist",
|
||||
// which silently stopped delivering plugins added to the binary LATER: the
|
||||
// directory had existed since the first install, so a newly embedded plugin
|
||||
// (e.g. channels.lua) never landed on an upgraded deployment — it was in the
|
||||
// binary and completely invisible in /api/plugins. The directory's mere
|
||||
// existence is not evidence that its contents are current.
|
||||
//
|
||||
// The rule is now per-file: seed a bundled plugin only when a file of that name
|
||||
// is absent. An OPERATOR'S file always wins, so customising or replacing a
|
||||
// bundled plugin survives an upgrade — the same rule adapters already use.
|
||||
func (ps *Plugins) SeedBundled() error {
|
||||
if ps.dir == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(ps.dir); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return writeBundledPlugins(ps.dir)
|
||||
}
|
||||
|
||||
// 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 +675,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 +741,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 +791,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 +1891,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 +1997,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 {
|
||||
|
||||
3090
internal/lua/plugins/channels.lua
Normal file
3090
internal/lua/plugins/channels.lua
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user