Compare commits

..

4 Commits

Author SHA1 Message Date
6733534282 chore(version): 1.5.2 2026-09-10 19:45:31 +08:00
499f0cac2f fix(opencode): never drop an assistant turn that carries tool_calls
An agent client (pi) serialises an assistant turn whose content is only
[thinking, toolCall] as content:[] with tool_calls. The multimodal-strip
pass treated an empty content array as 'nothing left, drop the whole
message' and discarded the tool_calls with it.

The next message is that call's tool result, so it arrived orphaned: the
model saw a result for a call it had never made and re-issued the same
call on every turn — an endless repeated-tool-call loop. Reproduced
against a capture sink: content:[] lost tool_calls, while content:"" and
content:null kept them.

Only messages with neither usable content NOR a tool call now get
dropped. Content that collapses to empty but still has tool_calls or a
tool_call_id is emitted as "" instead.

Test: TestOpenCodeKeepsToolCallWithEmptyContent (plus a negative control
that an image-only message without tool calls is still dropped).
2026-09-10 19:32:47 +08:00
7fb8f96b82 fix(gateway): AUTO scope grants all models — restrict to routing mode only
A key with scope=[AUTO] could previously:
1. request ANY concrete model id directly (hasScopeModel/checkModelScope
   treated AUTO as a wildcard)
2. see the full 56-model list on /v1/models (intersectModels considered
   AUTO as grant-everything)

AUTO now only authorizes the AUTO routing mode. Direct requests to a
specific model require an explicit scope entry.

Also carries agentrouter.lua WAF fingerprint headers (Origin/Referer/
X-Requested-With) already staged on this branch.

Tests: TestHasScopeModelWithSourcePrefix updated; full suite green.
2026-09-10 12:51:40 +08:00
6a34cd6f86 chore(version): 1.4.2 -> 1.5.0 2026-09-06 10:17:43 +08:00
273 changed files with 1877 additions and 73580 deletions

4
.gitignore vendored
View File

@ -26,7 +26,3 @@ cmd/gui/dist/
# local design/working notes (not part of the shipped repo)
/plan.md
# scratch build/probe dirs used while debugging (GOTMPDIR, throwaway binaries)
.build-work/
.probe/

View File

@ -1,24 +0,0 @@
{
"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
}
}

View File

@ -1,95 +0,0 @@
{
"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]

View File

@ -1,8 +0,0 @@
{
"created": <TS>,
"data": [
{
"b64_json": "QUJD"
}
]
}

153
README.md
View File

@ -24,7 +24,6 @@
### 强大的多租户调度能力
- **多密钥多租户**:支持无限密钥,每个密钥独立角色、模型范围、Token 配额、重置周期
- **按模型配额**:每把 key 的每个模型单独配 token + 请求数配额与重置周期(小时/周/月/自定义 N 小时);一个模型用满只影响该模型,同 key 其它模型照常;耗尽返 429 + `Retry-After` 并点名模型,admin key 永不受限
- **AUTO 智能调度**:基于优先级档位的分级调度,同优先级源自动轮询负载均衡,故障自动毫秒级故障转移
- **自愈冷却**:冷却上限 5 分钟,过半后放行 1 个探测请求,上游/额度恢复即刻回归轮询,无需等满冷却窗口
- **Token 配额管理**:精确到模型级别的 Token 配额控制,支持小时/周/月/自定义小时周期自动重置
@ -69,9 +68,7 @@ Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换
(所有现有状态均已占用)时批量预热,顺序流量始终只用 1 个状态。
- 缩容步长由当前连接数决定:`clamp(ceil(冗余/(1+占用)), 1, 冗余)`——无连接时一轮收到
常驻下限,忙时每轮只释放 1 个,保护热路径。WebUI「适配器」页展示实时池指标。
- **thinking 预算**:请求里的推理开关/预算归一为中立的 `thinking:{enabled,budget_tokens}`,
适配器映射到各自协议(Anthropic `thinking.budget_tokens`、Gemini `thinkingConfig`、
DeepSeek `thinking.type="disabled"`)。
- **disable_thinking**:请求 `disable_thinking:true`(或上游对应字段)动态开关推理。
- **Lua 适配协议**:每个源挂一个 `.lua` 适配器,完成 `transform_request` /
`transform_response` / `transform_stream_chunk` 双向转换,协议差异全在 Lua 层。
- **签名 / 请求头钩子**:适配器可定义 `build_headers(meta)`,在 Go 发 HTTP 前
@ -152,47 +149,6 @@ sources:
timeout: 120s # 请求超时,默认 120s
```
### 自动探测模型列表
不想手抄模型 id 时,只填 URL 和 key,让网关去扫:
```bash
# 只探测,不写任何东西
curl -X POST http://127.0.0.1:8080/api/sources/discover \
-H "Authorization: Bearer sk-gw-…" -H "Content-Type: application/json" \
-d '{"base_url":"https://api.vendor.com/v1","api_key":"sk-…"}'
# → {"ok":true,"base_url":"…","endpoint":"","models":["gpt-x",…],
# "image_models":["gpt-image-2"],"models_path":"/models","latency_ms":1387,…}
# 探测后直接落库
curl -X POST http://127.0.0.1:8080/api/sources/discover/apply \
-H "Authorization: Bearer sk-gw-…" -H "Content-Type: application/json" \
-d '{"base_url":"https://api.vendor.com/v1","api_key":"sk-…","name":"vendor"}'
```
WebUI 源编辑框里也有「自动探测模型」按钮:填好 URL 和 key 点一下,列表自动
填好,仍需点保存才生效。
几点值得知道:
- **URL 形态不用猜。** 厂商对「`/v1` 放 base 还是放 path」没有共识,探测会
依次尝试若干候选并返回答对的那个,同时给出归一化后的 `base_url` +
`endpoint`(`endpoint` 平时留空,因为 openai adapter 自带 `/chat/completions`)。
- **`image_endpoint` 是另一套规则。** 网关的图像默认路径是**绝对**的
`/v1/images/generations`,而 chat 默认是相对的。所以 `base_url` 带 `/v1` 时
必须显式给 `image_endpoint: /images/generations`,否则图像请求 404。
探测会替你填好。
- **图像模型只看上游自己声明的 modality**,绝不从模型名猜。`gpt-image-2`
这类 id 也出现在同时提供对话服务的目录上,猜错会让 AUTO 链选中一个只支持
图像生成的模型,其后每一层都被它拖垮。
- **不会逐模型探活。** 34 个模型的目录跑一轮要花钱且耗时数分钟,`/models`
是足够便宜的信号;模型是否真能应答留给正常请求去发现。
- **对已存在的源,必须显式 `replace_models: true`** 才会覆盖模型列表。重新
探测顺手抹掉为 AUTO 链手工调过的 priority,是最难排查的一类数据丢失。
- **`api_key` 留空表示沿用已存值**,不会把凭据清空。
`config.yaml`、`runtime.json`、`master.key`、`adapters/` 均不提交版本库(见
`.gitignore`),请随备份带走——`master.key` 丢失后运行时密文无法解密。
@ -222,81 +178,6 @@ WebUI 源编辑框里也有「自动探测模型」按钮:填好 URL 和 key
- 客户端用任意一个已授权的密钥明文作为 Bearer(`Authorization: Bearer <key>`)。
- 删除密钥即从运行时存储移除,立即失效。
#### 密钥用量配额
**配额按模型单独设置**:每个密钥的 `models[]` 里,每一条模型范围各自带一份
token 配额与请求数配额。一个模型用满只影响该模型,同一密钥的其它模型照常工作。
```yaml
keys:
- key: sk-gw-<hex>
role: user
name: agent-alice
models:
- model: deepseek-v4-flash
token_quota: 1000000 # 本周期内该 key 用这个模型的 token 预算
req_quota: 20000 # 本周期内的请求次数
period: nhour # "" | hour | week | month | nhour
hours: 6 # 仅 nhour
- model: AUTO
token_quota: 5000000 # AUTO 也是一条独立配额
period: day # ← 这种写法会被拒绝(词表只有 hour/week/month/nhour)
- model: kimi-k3 # 未列配额 = 无限
```
- **没有「整钥总配额」**:这是刻意的设计。整钥总额会让「把 A 模型的额度挪给
B 模型」变成一次全局重分配;按模型独立计费则每个模型各自可控,运维可以
看出哪个模型吃掉了预算。
- `period` 词表:空 = 永不过期(累计总量),`hour` / `week` / `month` = 固定窗口,
`nhour` + `hours` = 自定义小时数。**拼错的周期在写入时就被拒**,不会静默变成
永不过期。
- 配额严格按密钥隔离,且**同一密钥内按模型隔离**:A 密钥用满 `m1` 不会消耗
B 密钥的额度,同一密钥的 `m2` 也不受影响。
- 配额统计含聊天、流式、生图,跨重启从审计日志回放(保留 40 天,覆盖最长的
month 窗口)。
- 配额耗尽返回 **429 + `Retry-After`**(`rate_limit_exceeded`),消息里点名是哪个
模型用满了,客户端可等窗口重置后自动恢复;模型越权才是 403。
**admin 密钥永不受配额限制**(其 scope 上的配额也不强制),避免把管理员
锁在门外。
- 窗口用量按整点小时分桶统计,实际释放比配置窗口最多晚 1 小时(配额宁可晚释放
也不超发)。
- `PUT /api/keys/{key}` 提交 `models` 即同时提交它们的配额(配额就是 scope 的一部分,
不存在会与模型列表脱节的第二份预算)。显式 `0` = 解除该模型的限制。
##### 配额拒绝 vs 容量拒绝:两种「拒绝」含义不同
配额在**入口**检查,不占用任何上游槽位,因此是廉价拒绝;容量不足则要走
`busyWait` 有界等待后才返回 503。实测(容量 8、0.6s/请求、100 并发):
| 情况 | 状态码 | 延迟 | 是否打到上游 |
|---|---|---|---|
| 配额用尽(`req_quota` 50 → 100 并发) | `429 rate_limit_exceeded` + `Retry-After` | **19–21ms** | 否 |
| 容量打满(同一拓扑,未限配额) | `503 upstream_error` | ~2.6s(`busyWait`) | 超出部分否 |
客户端因此可以区分「等窗口重置」与「等上游腾出容量」:前者只需等待,后者
通常该降并发或换源。
##### 配额的开销
配额桶按 (密钥, 模型, 整点小时) 分桶保留 40 天,实测(AMD 7840HS):
- 窗口查询按窗口长度而非保留总量扫描:1h 窗口 49ns、24h 窗口 55ns、
30d 窗口 3.9μs(此前全扫保留总量,960 桶时 5.9μs)。
- 记录一条请求:283ns、3 分配(与引入配额前相同,分配来自 ring buffer)。
- 内存:生产形态(7 密钥 × 8 模型 × 2 源 × 满 40 天 retention)约 **3.7MB**。
按源 pin 的 `source::model` 桶**惰性创建**——只有当某条配额真的 pin 了
某个源时才维护,否则每条记录多写一份桶,在 20 密钥 × 8 模型 × 3 源下会
多占 18MB。
代价:配置 pinned 配额之前的历史用量无法事后按源拆分,所以 **pinned 配额的
首个窗口可能少算**。
```bash
# 配额耗尽时客户端看到(点名了具体模型)
HTTP/1.1 429 Too Many Requests
Retry-After: 2100
{"error":{"type":"rate_limit_exceeded","message":"token quota exceeded for \"deepseek-v4-flash\" (5000000/5000000)"}}
```
### 模型路由
`/v1/chat/completions` 的 `model` 解析顺序:
@ -450,13 +331,10 @@ Environment=MALLOC_ARENA_MAX=2
可选:`GOMEMLIMIT=48MiB` 作软上限,实测再省 ~1 MB;代价是逐近上限时 GC 转激进,
温和场景不必开。
### thinking 预算
### disable_thinking
请求体带 `"thinking": {"enabled": false}` 时关闭推理;`"thinking": {"enabled": true,
"budget_tokens": 4096}` 指定预算。内核只定义这个**中立**语义字段,映射到具体协议由
适配器负责(Anthropic `thinking.budget_tokens`、Gemini `thinkingConfig.thinkingBudget`、
DeepSeek `thinking.type = "disabled"`)。内核不再向适配器泄漏 `disable_thinking` /
`extra_body` 这类私有字段。
请求体带 `"disable_thinking": true` 时,网关透传给各适配器;DeepSeek 适配器将其
映射为 `extra_body.thinking.type = "disabled"` 关闭推理,其余源按各自协议处理。
### WebUI
@ -466,10 +344,7 @@ DeepSeek `thinking.type = "disabled"`)。内核不再向适配器泄漏 `disab
支持按时间范围导出 CSV;点击模型可生成 pin 到该模型的连接配置。
- **对话页**:流式/非流式调试。
- **密钥页**:创建/编辑网关 key,为每个 key 配模型范围(模型 + 源 + token 配额 + 周期),
管理员管理全部 key,用户只看到自己的 key。每个模型砖块显示自己的配额徽标
(如 `1.0K 77×·1h`,未设配额显示 `∞`),点开可编辑该模型的 token 配额、
请求数配额与重置周期 —— 配额按模型独立生效,一个用满不影响同一 key 的其它模型。
「我的密钥」页对用户展示本 key 的模型范围与各自配额。
管理员管理全部 key,用户只看到自己的 key。
- **优先级页**:拖拽积木配置 AUTO 链档位。
- **源页**:在线增删改上游源(API key 等敏感字段加密落盘)。
- **适配器页**:上传 / 删除 Lua 适配器脚本。
@ -504,13 +379,6 @@ 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,
}
```
@ -519,12 +387,11 @@ return {
### 内置适配器
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode`
`qwen38` `sensenova` `trae`。
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode`。
`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);
推理开关由中立的 `thinking` 字段表达,`deepseek` 适配器把它映射为
`thinking.type = "disabled"`。
`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);若
源启用 `disable_thinking`,`deepseek` 适配器会把 `extra_body.thinking.type` 置为
`disabled`。
**kimicode** 是展示 `build_headers` 的样例:云端校验调用方 app,需要按
`meta.app_secret` 对时间戳+URL+请求体哈希做 HMAC 签名并附 `X-App-Sign` 等头。
@ -639,4 +506,4 @@ go test -tags luajit ./...
```
覆盖:配置校验、适配器加载/变换、签名钩子、Gateway 鉴权、SDK 结算、SSE 流式、
模型路由、多模态透传与 thinking 预算。
模型路由、多模态透传与 disable_thinking。

View File

@ -38,11 +38,6 @@ Extracted and independently evolved from the multi-source LLM adapter layer of
`reasoning_content`, `tool_calls`, `usage`).
- **Image generation**: `POST /v1/images/generations`, routed to models with
`kind: image`.
- **Per-model quota**: each key gives every model its own token and request caps
plus a reset period (hour/week/month/custom N hours). One model running out
affects only that model — the key's other models keep working. Exhaustion
answers 429 + `Retry-After` naming the model, so a client resumes when the
window rolls over; admin keys are never capped.
- **Multimodal**: `content` arrays (`image_url` etc.) pass through losslessly;
Anthropic/Gemini/Ollama are translated automatically.
- **LuaJIT VM**: golua-binding LuaJIT; each adapter has its own VM + worker
@ -176,103 +171,11 @@ under the `keys` field of the runtime file (encrypted at rest):
delete the seed key.
- The WebUI **Keys page** creates/deletes keys. Each key has a role (`admin`
manages everything, `user` sees only its own key) and an optional **model
scope** (model + source + token quota + reset period). Each model brick shows
its own budget badge (e.g. `1.0K 77×·1h`, `∞` when uncapped); clicking it
edits that model's token quota, request quota and reset period. Quotas apply
per model, so one model running out never blocks the key's others. The
"My key" view shows a user its model scopes and their budgets.
scope** (model + source + token quota + reset period).
- Clients authenticate with any authorized key's plaintext as
`Authorization: Bearer <key>`.
- Deleting a key removes it from the store immediately.
#### Per-key usage quota
**Quotas are per model.** Each key's `models[]` list gives every model its own
token budget and request budget. One model running out affects only that model
— the key's other models keep working.
```yaml
keys:
- key: sk-gw-<hex>
role: user
name: agent-alice
models:
- model: deepseek-v4-flash
token_quota: 1000000 # this key's token budget for this model
req_quota: 20000 # requests within the window
period: nhour # "" | hour | week | month | nhour
hours: 6 # n-hour only
- model: AUTO
token_quota: 5000000 # AUTO is a quota entry like any other
period: hour
- model: kimi-k3 # no quota listed = unlimited
```
- **There is deliberately no key-wide total.** A key-wide cap would make
"move A's budget to B" a global reallocation; per-model budgets keep each
model independently controllable, so it stays visible which model is
actually consuming the spend.
- `period`: empty = never resets (lifetime total); `hour` / `week` / `month` =
fixed windows; `nhour` + `hours` = a custom hour count. **A misspelled
period is rejected at write time** rather than silently becoming a
never-resetting quota.
- Quotas are isolated per key *and* per model within a key: one key exhausting
`m1` never draws on another key's budget, and never blocks the same key's
`m2`.
- Usage counts chat, streaming and image requests, and survives a restart by
replaying the audit log (40 days retained, covering the longest `month`
window).
- An exhausted quota returns **429 + `Retry-After`**
(`rate_limit_exceeded`) and the message names the model that ran out, so a
client resumes when the window rolls over; a model the key may not use stays
403. **Admin keys are never capped** (quotas on their scopes are not
enforced either), so a cap can never lock the operator out.
- Buckets are whole unix hours, so a window frees up at most an hour late
(deliberately freeing late rather than overspending).
- `PUT /api/keys/{key}` submits quotas by submitting `models` — the caps are
part of the scope, so there is no second budget that can drift out of sync
with the model list. An explicit `0` lifts that model's cap.
##### Quota rejection vs capacity rejection
A quota is checked at the entry point and occupies no upstream slot, so it is a
cheap rejection. Running out of capacity instead waits out the bounded
`busyWait` before answering 503. Measured (capacity 8, 0.6 s/request, 100
concurrent clients):
| Case | Status | Latency | Reaches upstream |
|---|---|---|---|
| Quota spent (`req_quota` 50, 100 concurrent) | `429 rate_limit_exceeded` + `Retry-After` | **19–21 ms** | no |
| Capacity exhausted (same topology, no quota) | `503 upstream_error` | ~2.6 s (`busyWait`) | overflow: no |
So a client can tell "wait for the window to roll over" apart from "wait for
upstream capacity" — the first only needs patience, the second usually means
back off concurrency or switch sources.
##### What the quota costs
Buckets are kept per (key, model, whole unix hour) for 40 days. Measured on an
AMD 7840HS:
- A window query scans the window, not the whole retention: 49 ns for 1 h,
55 ns for 24 h, 3.9 µs for 30 d.
- Recording one request: 283 ns, 3 allocations (unchanged from before this
feature; the allocations come from the record ring buffer).
- Memory: the production shape (7 keys x 8 models x 2 sources at full 40-day
retention) costs about **3.7 MB**. The `source::model` bucket used by a
source-pinned quota is created **lazily** — it is maintained only once some
quota actually pins that source, because writing it on every record would
cost an extra 18 MB at 20 keys x 8 models x 3 sources.
The trade-off: usage recorded before a pinned quota was configured cannot be
split by source afterwards, so **a pinned quota may under-count its first
window**.
```
HTTP/1.1 429 Too Many Requests
Retry-After: 2100
{"error":{"type":"rate_limit_exceeded","message":"token quota exceeded for \"deepseek-v4-flash\" (5000000/5000000)"}}
```
### Model routing
`/v1/chat/completions` `model` resolution order:
@ -487,7 +390,7 @@ Shared helpers: `hmac_sha256_hex(key, data)`, `sha256_hex(data)`,
### Built-in adapters
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama`
`kimicode` `qwen38` `sensenova` `trae`.
`kimicode`.
`anthropic`/`gemini`/`ollama` include multimodal conversion
(`image_url` → their native format); with `disable_thinking` the `deepseek`

View File

@ -17,7 +17,7 @@ const fs = require("fs");
const http = require("http");
const os = require("os");
const crypto = require("crypto");
const { spawn, execFileSync } = require("child_process");
const { spawn } = require("child_process");
const LOG_FILE = path.join(app.getPath("userData"), "gui.log");
function log(msg) {
@ -97,99 +97,24 @@ function readAdminKey() {
// gateway_keys entries get marked seed:true by the core (-> "replace the
// initial key" warning in the web UI), while a plain keys entry authenticates
// without any seed warning.
const raw = readConfigRaw();
if (!raw) return "";
// keys:
// - key: sk-gw-...
const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec(
raw,
);
let v = k && k[1] ? k[1] : "";
if (!v) {
// legacy: gateway_keys list (pre-migration configs)
const g = /^\s*gateway_keys:\s*\n(?:\s*-\s*"?([^\s"#]+)"?\s*\n)+/m.exec(raw);
if (g && g[1]) v = g[1];
if (!v) {
const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw);
if (g2 && g2[1]) v = g2[1];
}
}
// The core seals credentials at rest (enc:v1:...), so from the second start
// onward the file holds ciphertext. Injecting that as a cookie is a
// guaranteed 401, which surfaces to the user as "enter your key" for a key
// they never set. Ask the core to unseal rather than reimplementing its
// crypto here. -show-secrets only reads, prints and exits: it starts nothing
// and writes nothing.
if (v && v.indexOf(SEALED_PREFIX) === 0) return unsealViaCore();
return v;
}
const SEALED_PREFIX = "enc:v1:";
function readConfigRaw() {
try {
if (!fs.existsSync(CONFIG_FILE)) return "";
return fs.readFileSync(CONFIG_FILE, "utf-8");
} catch (e) {
return "";
}
}
// Returns the admin key in the clear, or "" if the core cannot be asked.
//
// cwd must be the directory that holds master.key. The core derives that path
// from the config's runtime_file, so a call made from any other directory has
// the core generate a *second* master key in the CWD and then fail to decrypt
// ("master key changed?"). Electron's CWD is not the profile dir, so this is
// not optional: without it the desktop build cannot read its own key back.
//
// readAdminKey runs on every outbound request, and this spawns a process, so
// the result is cached against the config's mtime. Editing the config (or the
// port rewrite, or the first write) changes the mtime and invalidates it, which
// keeps the "read live so ordering never matters" property the auth rule
// depends on.
function unsealViaCore() {
const st = safeStat(CONFIG_FILE);
const stamp = st ? st.mtimeMs : 0;
if (unsealCache && unsealCache.stamp === stamp) return unsealCache.key;
let key = "";
try {
const out = execFileSync(
CORE_EXE,
["-config", CONFIG_FILE, "-show-secrets"],
{
encoding: "utf-8",
timeout: 10000,
windowsHide: true,
cwd: PROFILE_DIR,
},
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
// keys:
// - key: sk-gw-...
const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec(
raw,
);
// -show-secrets prints one line per credential:
// key <name> role=<role> <value>
const admin = /^key\s+\S+\s+role=admin\s+(\S+)\s*$/m.exec(out);
if (admin && admin[1]) key = admin[1];
// Tolerate a field-order or spacing change rather than locking the user out.
if (!key) {
const loose = /role=admin\s+(\S+)/.exec(out);
if (loose && loose[1]) key = loose[1];
}
} catch (e) {
console.error("unseal via core failed:", e.message);
}
// Only cache a success. A transient failure must not pin an empty key until
// the config next changes, or a momentary spawn error locks the user out.
if (key) unsealCache = { stamp, key };
return key;
}
let unsealCache = null;
function safeStat(p) {
try {
return fs.statSync(p);
} catch (e) {
return null;
}
if (k && k[1]) return k[1];
// legacy: gateway_keys list (pre-migration configs)
const g = /^\s*gateway_keys:\s*\n(?:\s*-\s*"?([^\s"#]+)"?\s*\n)+/m.exec(
raw,
);
if (g && g[1]) return g[1];
const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw);
if (g2 && g2[1]) return g2[1];
} catch (e) {}
return "";
}
const embeddedBaseUrl = () =>
@ -671,78 +596,6 @@ ipcMain.handle(
(e) => !!BrowserWindow.fromWebContents(e.sender)?.isMaximized(),
);
// ---- plugin management over IPC -------------------------------------------
//
// The renderer cannot call the embedded core directly: it has no key and no
// network identity, and the core binds a loopback port that only the main
// process knows about. So every plugin action is proxied through the main
// process, which already knows how to obtain the admin key (unsealViaCore).
//
// The proxy is deliberately a raw (method, path, body) pass-through rather than
// a fixed set of commands. A fixed set would have to be extended for every new
// plugin endpoint, and the one thing worse than "no button for this" is "a
// button that silently does nothing" — with a pass-through the renderer can talk
// to any /api/plugins route the core grows, and the path is validated here so
// this channel cannot be used to reach arbitrary endpoints.
function pluginProxy(req) {
const { method, path, body } = req || {};
const M = ["GET", "POST", "PUT", "DELETE"];
if (!M.includes(method)) throw new Error("bad method: " + method);
// The path must stay inside the plugin namespace. A prefix check alone would
// still allow /api/plugins/../keys, so reject any traversal outright.
if (typeof path !== "string" || !path.startsWith("/api/plugins")) {
throw new Error("path must start with /api/plugins");
}
if (path.includes("..") || path.includes("\\")) {
throw new Error("path traversal rejected");
}
const key = unsealViaCore();
if (!key) throw new Error("no admin key available yet");
return new Promise((resolve, reject) => {
const u = new URL(embeddedBaseUrl() + path);
const data = body == null ? null : JSON.stringify(body);
const headers = { Authorization: "Bearer " + key };
if (data) {
headers["Content-Type"] = "application/json";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request(
{
hostname: u.hostname,
port: u.port,
path: u.pathname + u.search,
method,
headers,
},
(res) => {
let raw = "";
res.setEncoding("utf8");
res.on("data", (c) => (raw += c));
res.on("end", () => {
let parsed = null;
try {
parsed = raw ? JSON.parse(raw) : null;
} catch (e) {
parsed = { raw };
}
if (res.statusCode >= 400) {
const msg =
(parsed && parsed.error && parsed.error.message) ||
"HTTP " + res.statusCode;
reject(new Error(msg));
return;
}
resolve(parsed);
});
},
);
r.on("error", reject);
if (data) r.write(data);
r.end();
});
}
ipcMain.handle("plugins:proxy", (_e, req) => pluginProxy(req));
ipcMain.handle("core:state", () => ({
running: coreStarted() && coreReady,
ready: coreReady,

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "1.9.3",
"version": "1.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.9.3",
"version": "1.5.2",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",
@ -582,7 +582,7 @@
}
},
"node_modules/@peculiar/webcrypto": {
"version": "1.7.5",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz",
"integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==",
"dev": true,
@ -3335,7 +3335,7 @@
}
},
"node_modules/resedit": {
"version": "1.7.5",
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
"integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==",
"dev": true,

View File

@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
"version": "1.9.7",
"version": "1.5.2",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",

View File

@ -16,13 +16,6 @@ contextBridge.exposeInMainWorld("modelrouter", {
key: () => ipcRenderer.invoke("core:key"),
onState: (cb) => ipcRenderer.on("core:state", (_e, d) => cb(d)),
},
plugins: {
// Raw pass-through to the embedded core's /api/plugins surface. The main
// process validates the path and attaches the admin key; the renderer never
// sees either.
request: (method, path, body) =>
ipcRenderer.invoke("plugins:proxy", { method, path, body }),
},
settings: {
get: () => ipcRenderer.invoke("settings:get"),
set: (patch) => ipcRenderer.invoke("settings:set", patch),

View File

@ -155,7 +155,6 @@ async function openSettings() {
$("#set-tray").checked = !!state.settings.minimizeToTray;
$("#settings-overlay").style.display = "flex";
renderRail();
loadPlugins();
}
function closeSettings() {
$("#settings-overlay").style.display = "none";
@ -183,296 +182,6 @@ async function saveSettings() {
}
}
// esc / escAttr escape text for innerHTML. The WebUI has its own copies; the
// shell needs its own because renderer/app.js is a separate document that
// never loads index.html's script.
function esc(s) {
return String(s == null ? "" : s).replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
);
}
function escAttr(s) {
return esc(s).replace(/`/g, "&#96;");
}
// ===== plugin management =====
//
// The desktop shell manages plugins through the embedded core's /api/plugins
// surface, proxied over IPC (see plugins:proxy in the main process). The
// renderer never holds the admin key.
//
// Scope note: the desktop build has no plugin_dir configured by default, so this
// panel normally reports "plugins disabled" with the one-line fix. That is a
// deliberate state, not an error — the packaged profile is a per-user directory
// and seeding a plugin tree into someone's home without asking would be rude.
async function loadPlugins() {
const list = $("#pl-list");
const hint = $("#set-plugins-hint");
if (!list || !hint) return;
let j;
try {
j = await window.modelrouter.plugins.request("GET", "/api/plugins");
} catch (e) {
hint.textContent = "内核未就绪:" + e.message;
list.innerHTML = "";
return;
}
if (!j.plugin_dir) {
hint.innerHTML =
'未配置 <code>plugin_dir</code>,插件功能未启用。在 config.yaml 加一行后重启内核即可。';
list.innerHTML = "";
return;
}
const rows = j.on_disk || [];
window.__plRows = rows; // read by editPlugin/deletePlugin for the builtin flag
const active = rows.filter((p) => p.loaded && !p.disabled).length;
const broken = rows.filter((p) => !p.loaded).length;
hint.textContent =
`${rows.length} 个插件 · ${active} 个启用中` +
(broken ? ` · ${broken} 个加载失败` : "");
list.innerHTML = rows.length
? rows
.map((p) => {
const cls = !p.loaded ? "pl-broken" : p.disabled ? "pl-off" : "pl-on";
const label = !p.loaded
? "加载失败"
: p.disabled
? "已禁用"
: "启用中";
const btn = p.loaded
? `<button class="ghost" data-act="toggle" data-name="${escAttr(
p.name,
)}" data-en="${p.disabled ? "1" : "0"}">${
p.disabled ? "启用" : "禁用"
}</button>`
: "";
const builtin = p.builtin
? '<span class="pl-builtin">内置</span>'
: "";
// Edit / view source / delete are the operations an operator
// actually needs after enable/disable: a plugin that cannot be
// read back cannot be fixed, and one that cannot be removed cannot
// be rolled back.
const acts = [
btn,
`<button class="ghost" data-act="code" data-name="${escAttr(p.name)}">${
p.builtin ? "查看源码" : "编辑"
}</button>`,
builtin
? ""
: `<button class="ghost danger" data-act="del" data-name="${escAttr(
p.name,
)}">删除</button>`,
]
.filter(Boolean)
.join("");
const pages = Array.isArray(p.pages) ? p.pages : [];
return `<div class="pl-item ${cls}">
<div class="pl-head"><b>${esc(p.name)}</b>${builtin}<span class="pl-state">${label}</span></div>
${p.description ? `<div class="pl-desc">${esc(p.description)}</div>` : ""}
${pages.length ? `<div class="pl-desc">页面:${esc(pages.join(", "))}</div>` : ""}
${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""}
<div class="pl-acts">${acts}</div>
</div>`;
})
.join("")
: '<div class="pl-empty">插件目录为空</div>';
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
});
list.querySelectorAll('button[data-act="code"]').forEach((b) => {
b.onclick = () => editPlugin(b.dataset.name);
});
list.querySelectorAll('button[data-act="del"]').forEach((b) => {
b.onclick = () => deletePlugin(b.dataset.name);
});
}
// editPlugin shows the plugin source in a modal. Built-in plugins are
// read-only: their file ships inside the binary, so a save would either be
// ignored or silently do nothing — showing an editable text area that
// discards on save is worse than saying so up front.
function editPlugin(name) {
// The plugin list is the only place `builtin` is reported; keep the last
// fetch so the modal does not need a second round trip (and cannot guess).
const row = (window.__plRows || []).find((p) => p.name === name);
const isBuiltin = !!(row && row.builtin);
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card pl-wide">
<div class="overlay-head"><h2>${esc(name)}${isBuiltin ? "(内置,只读)" : ""}</h2></div>
${isBuiltin
? `<p class="hint">内置插件随内核发布,源码在二进制内,不可编辑。</p>`
: `<textarea id="pl-code" spellcheck="false"></textarea>
<p class="hint" id="pl-code-err"></p>`}
<div class="row actions">
${isBuiltin ? "" : `<button id="pl-code-save">保存并热重载</button>`}
<button class="ghost" id="pl-code-close">关闭</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-code-close").onclick = () => wrap.remove();
const save = $("#pl-code-save");
if (save) {
save.onclick = async () => {
save.disabled = true;
try {
// Saving writes through POST /api/plugins — the same install path the
// installer uses, which validates the name, compiles the source and
// hot-reloads. PUT /{name} only toggles enable/disable, so pointing the
// save there would silently disable nothing and store nothing.
await window.modelrouter.plugins.request("POST", "/api/plugins", {
name,
code: $("#pl-code").value,
});
toast(`${name} 已保存`);
wrap.remove();
await loadPlugins();
} catch (e) {
$("#pl-code-err").textContent = e.message;
save.disabled = false;
}
};
}
if (isBuiltin) return;
(async () => {
const ta = $("#pl-code");
try {
const j = await window.modelrouter.plugins.request(
"GET",
`/api/plugins/${encodeURIComponent(name)}`,
);
ta.value = j.code || "";
const err = $("#pl-code-err");
if (err && j.parse_error) err.textContent = String(j.parse_error);
} catch (e) {
$("#pl-code-err").textContent = e.message;
}
})();
}
function deletePlugin(name) {
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card">
<div class="overlay-head"><h2>删除插件</h2></div>
<p>确定删除 <b>${esc(name)}</b>?插件目录中的文件会被移除,核心将不再加载它。</p>
<div class="row actions">
<button class="danger" id="pl-del-ok">删除</button>
<button class="ghost" id="pl-del-no">取消</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-del-no").onclick = () => wrap.remove();
$("#pl-del-ok").onclick = async () => {
try {
await window.modelrouter.plugins.request(
"DELETE",
`/api/plugins/${encodeURIComponent(name)}`,
);
toast(`已删除 ${name}`);
wrap.remove();
await loadPlugins();
} catch (e) {
toast(e.message, true);
}
};
}
// installPlugin creates a new plugin from source. The name becomes the file
// name, so it is validated client-side to save a round trip on the obvious
// mistakes (empty, path separators, non-portable characters).
function installPlugin() {
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card pl-wide">
<div class="overlay-head"><h2>安装插件</h2></div>
<div class="row"><label>名称</label><input id="pl-new-name" placeholder="my_plugin" /></div>
<p class="hint" id="pl-new-err"></p>
<textarea id="pl-new-code" spellcheck="false" placeholder="-- 单文件 Lua 插件&#10;plugin = {}&#10;..."></textarea>
<div class="row actions">
<button id="pl-new-ok">安装</button>
<button class="ghost" id="pl-new-no">取消</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-new-no").onclick = () => wrap.remove();
$("#pl-new-ok").onclick = async () => {
const name = $("#pl-new-name").value.trim();
const code = $("#pl-new-code").value;
const err = $("#pl-new-err");
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
err.textContent = "名称只能包含字母、数字、下划线和连字符";
return;
}
if (!code.trim()) {
err.textContent = "源码不能为空";
return;
}
$("#pl-new-ok").disabled = true;
try {
await window.modelrouter.plugins.request("POST", "/api/plugins", {
// The backend appends .lua itself (installPlugin writes name+".lua"),
// so passing it here would create name.lua.lua.
name,
code,
});
toast(`已安装 ${name}`);
wrap.remove();
await loadPlugins();
} catch (e) {
err.textContent = e.message;
$("#pl-new-ok").disabled = false;
}
};
}
async function togglePlugin(name, disabled) {
try {
await window.modelrouter.plugins.request("PUT", `/api/plugins/${encodeURIComponent(name)}`, {
enabled: disabled,
});
toast(disabled ? `已禁用 ${name}` : `已启用 ${name}`);
await loadPlugins();
} catch (e) {
toast(e.message, true);
}
}
async function enableAllPlugins() {
let j;
try {
j = await window.modelrouter.plugins.request("GET", "/api/plugins");
} catch (e) {
return toast(e.message, true);
}
const off = (j.on_disk || []).filter((p) => p.loaded && p.disabled);
for (const p of off) {
try {
await window.modelrouter.plugins.request(
"PUT",
`/api/plugins/${encodeURIComponent(p.name)}`,
{ enabled: true },
);
} catch (e) {
toast(`${p.name}: ${e.message}`, true);
}
}
toast(off.length ? `已启用 ${off.length} 个插件` : "没有处于禁用状态的插件");
await loadPlugins();
}
// ===== theme =====
function applyTheme() {
document.documentElement.dataset.theme = state.theme;
@ -488,12 +197,6 @@ function init() {
$("#tb-close").onclick = () => window.modelrouter.win.close();
$("#tb-settings").onclick = openSettings;
$("#rail-settings").onclick = openSettings;
const plReload = document.getElementById("pl-reload");
if (plReload) plReload.onclick = loadPlugins;
const plAll = document.getElementById("pl-toggle-all");
if (plAll) plAll.onclick = enableAllPlugins;
const plInstall = document.getElementById("pl-install");
if (plInstall) plInstall.onclick = installPlugin;
$("#rail-autostart").onclick = toggleAutoStart;
$("#rail-silent").onclick = toggleSilent;
$("#rail-theme").onclick = () => {

View File

@ -207,16 +207,6 @@
><input type="checkbox" id="set-tray" /> 关闭时最小化到托盘</label
><span class="hint">点关闭按钮隐藏到系统托盘</span>
</div>
<div class="row">
<label>插件</label>
<span class="hint" id="set-plugins-hint">加载中…</span>
</div>
<div id="pl-list" class="pl-list"></div>
<div class="row actions">
<button class="ghost" id="pl-toggle-all">全部启用</button>
<button class="ghost" id="pl-install">安装插件</button>
<button class="ghost" id="pl-reload">刷新</button>
</div>
<div class="row actions">
<button class="ghost" id="set-dir">打开数据目录</button>
<button class="ghost" id="set-log">查看日志</button>

View File

@ -596,129 +596,3 @@ html[data-theme="dark"] .overlay {
#toast.err {
border-color: var(--danger);
}
/* ===== plugin management panel =========================================
* The existing .ghost/.primary rules are scoped to `.form .actions`, so a
* button outside that selector gets browser defaults. The plugin rows live in
* their own list, hence their own rules — reusing a scoped class here would have
* produced unstyled buttons that still worked, which is the kind of thing that
* looks fine until someone themes the shell.
*/
.pl-list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px 0 4px;
}
.pl-item {
border: 1px solid var(--line);
border-radius: 9px;
padding: 10px 12px;
}
.pl-item.pl-broken {
border-color: var(--danger, #d1435b);
}
.pl-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.pl-builtin {
font-size: 10px;
padding: 1px 6px;
border-radius: 999px;
background: var(--primary-50);
color: var(--primary-h);
}
.pl-state {
margin-left: auto;
font-size: 11px;
color: var(--muted);
}
.pl-item.pl-broken .pl-state {
color: var(--danger, #d1435b);
}
.pl-desc {
font-size: 12px;
color: var(--muted);
margin-top: 3px;
}
.pl-err {
font-size: 11px;
color: var(--danger, #d1435b);
margin-top: 4px;
word-break: break-word;
}
.pl-acts {
margin-top: 8px;
display: flex;
gap: 8px;
}
.pl-acts button {
padding: 5px 12px;
font-size: 12px;
border-radius: 7px;
border: 1px solid var(--line);
background: var(--bg-s2, #fff);
color: var(--fg, inherit);
cursor: pointer;
transition: all 0.15s;
}
.pl-acts button:hover {
border-color: var(--primary);
color: var(--primary-h);
}
.pl-empty {
font-size: 12px;
color: var(--muted);
padding: 10px 0;
}
/* ---- plugin editor / installer --------------------------------------------
The plugin source editor and the install dialog reuse the settings overlay
(overlay / overlay-card) rather than a second popup system. They need three
things the settings card does not: a wider column for code, a monospace
textarea, and a destructive button for delete. */
.overlay-card.pl-wide {
width: 760px;
}
.pl-wide textarea {
width: 100%;
min-height: 380px;
resize: vertical;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--line-strong);
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
color: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.55;
tab-size: 2;
}
.pl-wide textarea:focus {
outline: none;
border-color: var(--primary);
}
.pl-wide .row input {
flex: 1;
padding: 7px 10px;
border-radius: 9px;
border: 1px solid var(--line-strong);
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
color: inherit;
}
.pl-editor .hint,
.pl-wide .hint {
min-height: 16px;
color: var(--muted, #888);
}
button.danger {
color: #e5484d;
border-color: rgba(229, 72, 77, 0.4);
}
button.danger:hover {
border-color: #e5484d;
color: #e5484d;
}

View File

@ -24,7 +24,6 @@ import (
func main() {
cfgPath := flag.String("config", "config.yaml", "path to gateway config file")
checkOnly := flag.Bool("check", false, "validate the config file and exit (0 = valid, 1 = invalid); nothing is started and no file is written")
showSecrets := flag.Bool("show-secrets", false, "print the config's credentials in the clear and exit; nothing is started and no file is written")
flag.Parse()
// -check is the deploy-time preflight: parse and validate the config
@ -42,20 +41,6 @@ func main() {
return
}
// -show-secrets is the operator escape hatch for a config whose
// credentials are sealed at rest: it prints them to stdout and exits
// without starting anything or writing anything. Nothing else in the
// program prints a credential, and this path never logs to a file.
if *showSecrets {
if _, err := os.Stat(*cfgPath); err != nil {
log.Fatalf("[llmsproxy] show-secrets: %v", err)
}
if err := config.PrintSecrets(*cfgPath); err != nil {
log.Fatalf("[llmsproxy] show-secrets: %v", err)
}
return
}
created, err := config.EnsureDefault(*cfgPath)
if err != nil {
log.Fatalf("[llmsproxy] config: %v", err)
@ -73,26 +58,19 @@ func main() {
}
}
gw, err := gateway.New(c)
gw, err := gateway.New(c, c.GatewayKeys())
if err != nil {
log.Fatalf("[llmsproxy] gateway: %v", err)
}
// Ops hygiene: surface the two most common footguns instead of silently
// running with them.
//
// Read the AUTHORITATIVE source. Auth uses core.ListKeys() (cfg.Keys), not
// the legacy cfg.GatewayKeys list: seedKeys copies gateway_keys into keys[]
// on first start and the YAML list is ignored for auth afterwards. Checking
// GatewayKeys() here made the warning lie — once the legacy list was empty
// (e.g. after rotating the starter key away) it reported "every request is
// rejected" while seven working keys, one of them admin, were in service.
if keys := c.ListKeys(); len(keys) == 0 {
log.Printf("[llmsproxy] WARNING: no gateway key configured — every request will be rejected. Add one in the WebUI or set gateway_keys")
if keys := c.GatewayKeys(); len(keys) == 0 {
log.Printf("[llmsproxy] WARNING: gateway_keys is EMPTY — without a key every request is rejected")
} else {
for _, k := range keys {
if k.Key == "sk-gw-local-0001" || k.Key == "sk-local-0001" {
log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k.Key)
if k == "sk-gw-local-0001" || k == "sk-local-0001" {
log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k)
}
}
}

234
deploy.sh
View File

@ -31,20 +31,12 @@ 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"
# 构建目录。默认落在仓库下的构建工作区,而不是 /tmp:本机 /tmp 是 8.8G
# tmpfs,实测被 Go 构建缓存占满后会让 go test/build 在链接阶段直接失败。
# 可用 LLMSPROXY_BUILD_DIR 覆盖;同一目录也被测试用作 GOTMPDIR。
readonly BUILD_SUBDIR=".build-work"
if [[ -n "${LLMSPROXY_BUILD_DIR:-}" ]]; then
readonly TMP_DIR="${LLMSPROXY_BUILD_DIR}"
else
readonly TMP_DIR="${REPO_DIR}/${BUILD_SUBDIR}"
fi
# 构建目录在 /tmp(tmpfs 更快)
readonly TMP_DIR="/tmp/llmsproxy-deploy"
# 同文件系统暂存文件(用于原子 rename)
readonly STAGING_BIN="${TARGET_BIN}.staging"
readonly STAGING_CONFIG="${TARGET_CONFIG}.staging"
@ -129,14 +121,14 @@ backup_old_state() {
log "备份旧二进制到 $BACKUP_BIN"
if [[ -f "$TARGET_BIN" ]]; then
cp -f "$TARGET_BIN" "$BACKUP_BIN"
log "OK 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))"
log "✓ 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))"
else
warn "旧二进制不存在,跳过备份(无回滚点)"
fi
if [[ -f "$TARGET_CONFIG" ]]; then
cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG"
log "OK 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))"
log "✓ 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))"
else
warn "线上配置不存在: $TARGET_CONFIG(首次部署?无配置回滚点)"
fi
@ -149,7 +141,7 @@ build() {
mkdir -p "$TMP_DIR/bin"
cd "$REPO_DIR"
CGO_ENABLED=1 GOTMPDIR="$TMP_DIR" go build -tags luajit \
CGO_ENABLED=1 go build -tags luajit \
-trimpath \
-ldflags="-s -w" \
-o "$TMP_DIR/bin/$BIN_NAME" \
@ -182,9 +174,9 @@ build() {
)
for c in "${checks[@]}"; do
if strings "$NEW_BIN" | grep -qF "$c"; then
log " OK 新二进制包含: $c"
log " ✓ 新二进制包含: $c"
else
warn " 注意: 新二进制不包含: $c(非预期但非致命)"
warn " ⚠ 新二进制不包含: $c(非预期但非致命)"
fi
done
}
@ -214,7 +206,7 @@ atomic_replace_binary() {
# 验证
NEW_SHA256=$(sha256sum "$TARGET_BIN" | awk '{print $1}')
log "OK 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
log "✓ 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
}
# ---------- 原子替换配置 ----------
@ -230,7 +222,7 @@ atomic_replace_config() {
log " rename → $TARGET_CONFIG (原子)"
mv -f "$STAGING_CONFIG" "$TARGET_CONFIG"
log "OK 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))"
log "✓ 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))"
}
# ---------- 配置预检 ----------
@ -243,7 +235,7 @@ verify_config() {
local out
if out=$("$TARGET_BIN" -check -config "$TARGET_CONFIG" 2>&1); then
log "OK 配置合法"
log "✓ 配置合法"
return 0
fi
@ -252,136 +244,6 @@ verify_config() {
return 1
}
# ---------- 密钥可解封校验 ----------
# config.yaml 里的凭据以 enc:v1: 密文落盘,解密钥匙是 master.key(或
# LLMS_PROXY_MASTER_KEY)。一旦钥匙丢失/错位,配置在**启动时**才会报错,
# 那时服务已经处于「二进制已替换 + 配置已替换」的半死状态,只能靠回滚。
# 所以在替换任何东西之前先验:能不能用现有的钥匙解开现有的密文。
#
# 两种情形要分开:
# 1. 配置里**有**密文 ⇒ 钥匙必须能解开(否则拒绝部署)
# 2. 配置里**没有**密文 ⇒ 全新安装/首次加密,无需钥匙(但会顺手记一条,
# 因为它意味着这次部署会把明文写成密文,而钥匙必须同时就位)
verify_master_key() {
log "密钥可解封校验"
[[ -f "$TARGET_CONFIG" ]] || return 0 # 配置都不在,交给 verify_config 报错
local sealed
sealed=$(grep -c 'enc:v1:' "$TARGET_CONFIG" 2>/dev/null || echo 0)
if [[ "$sealed" -eq 0 ]]; then
warn "配置内无密文(尚未加密或全为明文)——首次加密需要 master.key 可写"
return 0
fi
# 钥匙来源与 config 包一致:env 优先,否则 runtime_file 同目录的 master.key
local runtime_file key_file
runtime_file=$(awk '/^runtime_file:/{print $2; exit}' "$TARGET_CONFIG")
[[ -n "$runtime_file" ]] || runtime_file="$(dirname "$TARGET_CONFIG")/runtime.json"
key_file="$(dirname "$runtime_file")/master.key"
if [[ -n "${LLMS_PROXY_MASTER_KEY:-}" ]]; then
log " 钥匙来源: LLMS_PROXY_MASTER_KEY(环境变量)"
# 走真实解密路径验证,而不是只检查格式
if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
return 0
fi
err "凭据无法用 LLMS_PROXY_MASTER_KEY 解封,未替换任何文件:"
printf '%s\n' "$out" >&2
return 1
fi
if [[ ! -f "$key_file" ]]; then
err "配置含 $sealed 处密文,但钥匙文件不存在: $key_file"
err " 没有它,部署后服务将无法启动。恢复 master.key 或设置 LLMS_PROXY_MASTER_KEY 后重试。"
return 1
fi
log " 钥匙文件: $key_file ($(stat -c '%A' "$key_file" 2>/dev/null || echo '?'),$(wc -c < "$key_file") 字节)"
if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
return 0
fi
err "凭据无法解密(钥匙与配置不匹配),未替换任何文件:"
printf '%s\n' "$out" | head -5 >&2
err " 密钥来源: $key_file"
err " 常见原因:master.key 被重新生成、恢复了不匹配的备份,或换了机器。"
return 1
}
# ---------- 备份目录保留策略 ----------
# 每次部署都新建一个 <prefix>.bak.<时间戳> 目录,而回滚只用到最近一次
# ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理,
# 实测线上已积累 71 个,/etc/llmsproxy 因此涨到 122M。
#
# 保留最近 KEEP_BACKUPS 份足够回滚,同时给目录数设上限——否则一次误配置
# 就可能在几秒内造出成百上千个目录。
# 只删名字严格匹配 <prefix>.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
#
# ★ 必须**同时覆盖 adapters 与 plugins**(同一缺陷的两个实例):
# 本函数最初只处理 adapters.bak,而 sync_plugins 同样每次建 plugins.bak.*
# —— 于是修了 adapters 的堆积、plugins 继续涨(实测线上 36 个、占用 192M)。
# 这正是「两处只修一处」的老形状:给一个新前缀加备份目录时,必须同步纳入
# prune_all_backups,否则它就是一个不会被清理的新泄漏点。
KEEP_BACKUPS=5
prune_backups() {
local prefix="$1"
local base="/etc/llmsproxy"
[[ -d "$base" ]] || return 0
# 防误用:前缀必须是已知的两个(避免有人传空串删掉整个目录)
case "$prefix" in
adapters|plugins) ;;
*) fail "prune_backups: 未知前缀 '$prefix'(只接受 adapters|plugins)" ;;
esac
# 先按数量上限硬裁:即使时间戳排序失效也不会无上限增长。
local all
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
local cap=$((KEEP_BACKUPS * 4))
if (( ${#all[@]} > cap )); then
warn "$prefix 备份目录有 ${#all[@]} 个(异常),裁到 $cap"
local i=0
for d in "${all[@]}"; do
i=$((i + 1))
# 从最旧的开始删(sort 后升序)。名字不规范的跳过不删。
if (( i <= ${#all[@]} - cap )); then
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
rm -rf "${base:?}/$d"
else
warn "跳过名字不规范的备份目录(不删): $d"
fi
fi
done
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
fi
# 再按时间保留最近 KEEP_BACKUPS 份(sort 后最新在末尾)。
local total=${#all[@]} i=0
for d in "${all[@]}"; do
i=$((i + 1))
# i <= total-KEEP 的是较旧的,要删。
if (( i <= total - KEEP_BACKUPS )); then
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
rm -rf "${base:?}/$d"
else
warn "跳过名字不规范的备份目录(不删): $d"
fi
fi
done
local left
left=$(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" | wc -l)
log " $prefix 备份保留最近 $KEEP_BACKUPS 份(当前剩 $left 个)"
}
# prune_all_backups:两个前缀都裁。
# ★ 新增任何 .bak.<时间戳> 目录的前缀时,必须在这里补上 —— 否则又一个泄漏点。
prune_all_backups() {
prune_backups adapters
prune_backups plugins
}
# ---------- 同步适配器 ----------
sync_adapters() {
log "同步适配器"
@ -399,7 +261,6 @@ sync_adapters() {
cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true
log " 旧适配器已备份到 $BACKUP_DIR"
fi
prune_all_backups
cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/"
chmod 0644 "$TARGET_ADAPTERS/"*.lua
@ -414,75 +275,14 @@ sync_adapters() {
grep -qE '\buses\b' "$TARGET_ADAPTERS/$f.lua" 2>/dev/null && ((usage_fixed++)) || true
done
log " usage 透传修复适配器: ${usage_fixed}/7 (openai/deepseek/github/groq/kimicode/mistral/opencode)"
log "OK 适配器已同步到 $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 "OK 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)"
log "✓ 适配器已同步到 $TARGET_ADAPTERS/"
}
# ---------- 重启服务 ----------
restart_service() {
log "重启服务(中断 <500ms)"
systemctl restart llmsproxy.service || fail "systemd restart 失败"
log "OK llmsproxy.service restarted"
log "✓ llmsproxy.service restarted"
}
# ---------- 健康检查 ----------
@ -497,7 +297,7 @@ healthcheck() {
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "$HEALTH_URL" 2>/dev/null || echo "000")
if [[ "$code" == "200" || "$code" == "401" ]]; then
log "OK 服务健康 (status=$code, ${attempt}/${attempts})"
log "✓ 服务健康 (status=$code, ${attempt}/${attempts})"
return 0
fi
log " 等待中 ($attempt/$attempts, status=$code)"
@ -566,7 +366,7 @@ rollback() {
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$HEALTH_URL" 2>/dev/null || echo "000")
if [[ "$code" == "200" || "$code" == "401" ]]; then
log "OK 回滚成功,旧版本已恢复服务 (status=$code)"
log "✓ 回滚成功,旧版本已恢复服务 (status=$code)"
log " 新版本部署失败,请检查构建/配置后重试"
return 0
else
@ -583,7 +383,6 @@ print_summary() {
log " 二进制: $TARGET_BIN"
log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")"
log " 适配器: $TARGET_ADAPTERS"
log " 插件: $TARGET_PLUGINS"
log "═════════════════════════════════════════════"
}
@ -594,13 +393,8 @@ main() {
parse_args "$@"
log "═══ llmsproxy 原子部署开始 ═══"
precheck
# 密钥校验放在 build/替换之前:它的失败后果最重(配置里的密文永久解不开),
# 必须在动任何文件之前就挡住。此阶段用的是**当前线上二进制**,它已经
# 支持 -show-secrets(首次引入该子命令的版本除外,此时跳过并提示)。
verify_master_key
build
sync_adapters
sync_plugins
backup_old_state
atomic_replace_binary
atomic_replace_config

View File

@ -1,540 +0,0 @@
# channels 插件与参考插件的三线对齐表
参考仓:`Ebony-Vinyl/dsh-our-free-model`(本地克隆
`/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model`),关键实现位于
`vendor/channel-pack/src/`(144 个 TS 文件)。
命名规律(照它逐厂商对齐的三条线):
| 后缀 | 职责 | 我们要对齐的东西 |
|---|---|---|
| `X-auth.ts` / `X-oauth.ts` / `login.ts` | **登录** | 登录流程、端点、参数、凭据形状 |
| `X-product.ts` / `models.ts` | **模型获取** | 远端端点、解析路径、兜底表 |
| `X-adapter.ts` / `llm-adapter.ts` | **接入模型服务** | chat 端点、载荷转换、鉴权头 |
## 一、登录(line ①)
| 平台 | 参考流程 | 我们的 flow | 状态 |
|---|---|---|---|
| trae | OAuth 17 参数 + 设备身份派生 | callback | ✅ 逐字节对齐(含 login_trace_id / machine_id / x_device_*) |
| minimax | device-poll | device-poll | ✅ |
| cline | device-poll | device-poll | ✅ |
| buddy | state-poll(**无回调**) | state-poll | ✅ |
| gemini | OAuth + client_secret | callback | ✅(含 client_secret、localhost 主机、6 scope) |
| lobsterai | 本地回调 + authCode 换 token | callback | ✅(含动态版本号、3 步活动领取) |
| opencode | paste | paste | ✅(参考本身即粘贴凭据) |
| **codearts** | **PKCE + DPoP ES256 + STS 换 AK/SK** | **callback** | ✅ **本轮修复**(此前误标 paste) |
| qoder | device-poll + 设备身份 | device-poll | ✅(含 Cosy-MachineToken/Type 成对) |
| zcode | init-poll | init-poll | ✅ |
| loomy | 微信扫码 poll | wechat-poll | ✅ |
| raccoon | 二维码 poll | qr-poll | ✅ |
### codearts 登录(本轮修复的重点)
此前标成 `flow="paste"`(手填凭据)是**错的**。参考 `login.ts` / `oauth.ts`
默认走**新式 IAM OAuth**:
1. 起本地回调服务器,端口须 **≥10000**(`MIN_CALLBACK_PORT`,低于此 portal 拒绝),
路径 `/oauth/callback`
2. 打开 `https://codearts.huaweicloud.com/portal/authorize`,参数逐项:
`theme=2`、`locale=zh-cn`、`uri_scheme=codearts-agent`、`client_id=codearts-agent`、
`code_challenge_method=**SHA-256**`(不是 RFC 的 S256 —— 错值会让 portal
**回退旧 ticket 流程**,看起来"也能用"但不是 OAuth)、`ticket_id`、
`plugin-name=snap_AIIDE`、`plugin-version=5.2.0`;
**不得**含 `auth_callback_url`(会被判异常并回退)
3. 回调收到 `code` 后,带 **DPoP 证明**向
`https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens` 换
`credentials{access_key_id, secret_access_key, security_token, expiration}`
+ `refresh_token`
4. 凭据持久化须含续期材料:`refresh_token` / `code_verifier` / `dpop_private_key_jwk`
DPoP 细节:ES256 JWS,`header={alg,typ:dpop+jwt,jwk}`、
`payload={htm,htu,iat,jti}`;**签名是 raw r||s 64 字节**,而 openssl 输出 DER
——必须转换格式,否则服务端验签失败而字符串看起来完全正常。
终态判定只认 `invalid_grant` / `ErrorCode 含 ExpiredRefreshToken`;
**不认** `InvalidDPoPHeader`(那是一次请求层面的拒绝:时钟偏差、proof 重放、
网关抖动都会触发,当终态会把完好账号一步标死)。
旧 ticket 流程(`CODEARTS_LOGIN_BASE` + `/snap-manager/v1/login/ticket` 轮询)
保留作回退。
## 二、模型获取(line ②)
远端优先、兜底补位的机制已具备(`models_remote` + `effectiveModels`)。
本轮把**兜底表**逐平台补成参考全量:
| 平台 | 参考来源 | 兜底数 | 状态 |
|---|---|---|---|
| trae | `TRAE_FALLBACK_MODELS` | 28 可见 + 4 hidden | ✅(hidden 单列 `models_hidden`) |
| minimax | `GET /mavis/api/v1/models?region=cn&buildEnv=prod` | 4 | ✅ |
| cline | `GET /api/v1/models` + `recommended-models` | 3 | ✅ |
| buddy | (无独立表) | 7 | ✅ |
| gemini | — | 2 | ✅ |
| lobsterai | `GET /api/models/available`(**须带 model_flag**) | 19 | ✅ |
| opencode | `GET /v1/models` | 14 | ✅(含 `models_free`) |
| codearts | opengw `gateway/config` + `/v1/model/builtin` | 3 | ⚠️ 见下 |
| qoder | `/api/v2/model/list`(需 WASM 签名) | 19 | ✅(含 CN 独有 q37fmodel/gm51model) |
| zcode | `GET /api/v1/client/configs` | 2 | ✅ |
| loomy | `GET /api/v1/models`(取 `type==='chat'`) | 8 | ✅ |
| raccoon | 远端 + 兜底 | 6 | ✅ |
**trae 的 `isHidden` 语义**:`browser_use_subagent` / `explore_sub_agent_v13` /
`explore_sub_agent_v2` / `summary` 是内部子 agent,不进用户可选列表。
`model_list` 已按此过滤(`models_hidden`)。
**opencode 的 `isFree`**:参考明确标注 7 个模型匿名可用、其余需付费 key。
此前 `model_list` 把 `isFree` 硬编码为 false,等于把官方免费模型标成付费。
### codearts 模型获取(未闭环)
参考 `models.ts` 从两个端点拉:
- `OPENGW_GATEWAY_CONFIG_URL`(`opengw.developer.huaweicloud.com/api/v1/gateway/config`)
→ `result.models`,这是 **benefit(免费额度)模型**权威来源
- `SNAP_MODEL_BUILTIN_URL`(`/v1/model/builtin`)→ `builtinModels`,常规模型
两者都要 **AK/SK 的 SDK-HMAC-SHA256 签名**。另有 `normalizeModelId`
(去尾部 `-NNNN` 日期版本)与 `isCodeArtsBenefitModel`(决定 chat 是否带
`maas_type: benefit`,不带则 404 `model is not registered`)。
**这部分尚未实现**——我们的 codearts 目前只有 3 个硬编码模型名。
## 三、接入模型服务(line ③)
**本轮发现的最严重问题**:源注册时 `base_url + adapter.endpoint` 拼出的
chat URL **全部缺路径前缀**,11 个平台里 11 个都不对。已逐个修正:
| 平台 | 正确 chat 端点 | 修正前 |
|---|---|---|
| trae | `…/api/agent/v3/llm_utils_chat` | `…/chat/completions` |
| cline | `api.cline.bot/api/v1/chat/completions` | `api.cline.bot/chat/completions` |
| lobsterai | `…/api/proxy/v1/chat/completions` | `…/chat/completions` |
| qoder | `api2-v2.qoder.sh/model/v1/chat/completions` | `…/chat/completions` |
| buddy | `copilot.tencent.com/v2/chat/completions` | `…/chat/completions` |
| codearts | `…/api/v2/chat/completions` | `…/chat/completions` |
| opencode | `opencode.ai/zen/v1/chat/completions` | `opencode.ai/zen/chat/completions` |
| raccoon | `…/api/web/llm/v2/chat/completions` | `…/chat/completions` |
| loomy | `loomy.xunfei.cn/chat/completions` | `loomy.xunfei.cn` |
| zcode | `zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages` | `zcode.z.ai/v1/messages` |
| gemini | `/v1internal:generateContent`(私有动词) | `/v1beta/models/{model}:generateContent`(公开,凭据不匹配) |
### ✅ 已闭环:三个私有协议平台改用**插件自装适配器**
这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主
`LlmAdapter` 的真类;我们此前把平台协议差异当成适配器名差异(全塞 openai)。
内核其实**早已实现** `__llmsproxy_adapters.install`(含归属校验/冲突仲裁/
测试),但 channels 插件对该全局的引用数是 **0**。现已第一次真正使用:
| 平台 | 自装适配器 | 解决的协议差异 |
|---|---|---|
| codearts | `codearts` | 华为云 SDK-HMAC-SHA256 **AK/SK 签名**(canonical/string-to-sign 逐字节) |
| trae | `channels-trae` | **SOLO 载荷**:config_name 双字段、function 通道、tool_calls→function_call、parameters→JSON 字符串、tool_choice 归一化 |
| gemini | `channels-gemini` | 私有 `/v1internal:` **信封** + 身份五头 + 字母序序列化 + 响应剥一层 |
命名说明:trae 用 `channels-trae` 而非 `trae`,因为内置 `trae.lua` 服务的是
**运维自建的 trae-local-api 反代**(`127.0.0.1:19900`,生产 config.yaml 在用),
与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)——
判据里专门断言内置 `trae` 未被顶掉。
**新增能力:`credential_shape`**。签名平台需要**完整凭据 JSON**(适配器自己
解 AK/SK),其余平台交裸 access token。此前 `syncAccountSource` 一律用
`credentialOf` 取 `access_token`,于是 codearts 凭据(只有
`access_key_id`/`secret_access_key`/`security_token`)取不到 token →
**静默 return、不出源**。现已按 `plat.credential_shape` 区分。
**另一处静默失败已修**:`syncAccountSource` 拿到内核 `add_source` 的失败
(权限未授、形状不符)时不记日志,表现为"账号加了但不出源",排查零线索。
现补失败日志(区分内核 err 与 `json.ok==false`)。
### 仍未闭环:gemini 的 sigstore(明确不做)
参考 `gemini-sigstore.ts`(268 行)持久化 tool-call 签名,供跨请求回填。
我们推理走**无状态适配器**,插件侧缓存会成为死代码。签名缺失时上游会拒,
适配器如实透传错误。理由与结论已记在此处,避免反复讨论。
## 四、明确不做的(附理由)
| 参考模块 | 行数 | 为什么不做 |
|---|---|---|
| `gemini-sigstore.ts` | 268 | 推理走无状态适配器,插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传 ⛔ |
| `qoder-wasm.ts` | 1442 | 需随插件分发 298KB wasm 二进制;B 类,待立项 |
| `zcode-captcha.ts` | 2212 | 阿里云滑块,需真实浏览器/验证码链路;B 类 |
| `captcha-*.ts` | 1223 | 同上 |
## 五、本机实测踩到的坑(都已修)
1. **`xxd` 不存在** —— `command -v xxd` 为空,用它取十六进制会**静默得到空串**。
改用 `od -An -tx1`。
2. **`openssl ec -text` 会截断 32 字节 priv**(实测只给 30 字节)→ `d` 必须用
`openssl asn1parse` 的 `[HEX DUMP]`;`tail -c 32` 拿到的是 SEC1 结构里
**公钥的 y**,不是 `d`。
3. **Lua local 前向引用**:`local function` 只从定义处向后可见。`hexToBin` /
`b64url` / `b64urlRaw` 被 CodeArts 的 PKCE/DPoP 调用,必须移到 CodeArts 块
**之前**(本文件累计踩过 6 次)。
4. **`min_port` 参数读到 `argv[2]`** 与 `out_path` 冲突 —— 回调脚本的第 5 个
参数才是 minPort。
5. **Lua 双引号串里的 `\1`** 是转义坑(`sed 's/…/…/\1/'` 会报
invalid escape sequence)→ 改用 `awk -F': ' '{print $NF}'`。
---
# 为什么参考插件是"正常"的:架构差异(2026-10-13 查证)
用户问「为什么他们就正常」。查完参考仓的架构后,答案是**三层结构差异**,
不是某一个端点的笔误。
## 参考的架构:一个平台 = 一个适配器类
参考依赖宿主 SDK 的 seam:`import { LlmAdapter } from '@deepseek-ai/dsh-llm'`。
每个平台有一个**继承基类的真类**,同时拥有登录、模型目录、推理三件事:
| 文件 | 类 | 干什么 |
|---|---|---|
| `trae-adapter.ts` | `TraeAdapter` | SOLO 协议转换(`config_name`/`function`,非 OpenAI 兼容) |
| `llm-adapter.ts` | `CodeArtsAdapter` | 华为云 AK/SK 签名 + `maas_type: benefit` 判定 |
| `zcode-adapter.ts` | `ZcodeAdapter` | Anthropic Messages 协议(非 OpenAI) |
| `qoder-adapter.ts` | `QoderAdapter` | WASM 加密端点 + 信封剥离 |
| `gemini-adapter.ts` | `GeminiAdapter` | `/v1internal:` 私有动词 + 字母序信封 |
注册方式是 `registerAdapterIdempotent(ctx.llm, [PROVIDER], adapter)`
(`llm-adapter.ts:2080`)。
**但它不重复**:共享层是 `src/openai-compat.ts`(1196 行)——
`serializeMessages`(消息序列化)、`consumeOpenAiSse`(SSE 消费)、
`httpErrorCode` / `isTransportError`(错误归类)、`userContentParts`(多模态)。
12 个 adapter 里有 8 个 `import from './openai-compat'`。
即:**公共协议逻辑一份,平台差异各自一个类**。
## 我们的架构:内核是 proxy,插件只注册源
- 适配器是 Lua 文件(12 个),通过 4 个钩子参与:
`transform_request` / `transform_response` / `transform_stream_chunk` /
`transform_error`,外加 `build_headers`(动态签名,见下)。
- 声明面:`endpoint` / `endpoint_stream` / `ingress` / `headers`。
- 插件通过 `__llmsproxy_kernel.call("add_source", {source={...}})` 把平台
注册为上游源,`source.adapter` 引用适配器名。
## 关键发现:机制早就有,但 channels 插件从没用过
内核**完整实现了**插件自装适配器:
```
internal/lua/plugin_adapter_bridge.go
func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error)
registerAdapterBridge → Lua 全局表 __llmsproxy_adapters.install / .update / .remove
```
- 有归属校验(`adapterOwnership`:只能改自己装的)
- 有冲突仲裁(影子内置/他人所有 → 挂起等运维裁决,不是硬拒)
- 有测试证明可用:`TestPluginInstallsAdapterFromLua`(PASS)
**但 `grep -c '__llmsproxy_adapters' internal/lua/plugins/channels.lua` = 0。**
channels 插件一个适配器都没装过 —— 它只会注册 `adapter = "openai"` 的源。
这就是"为什么我们不正常"的直接原因:**平台协议差异被当成适配器名差异**。
## 我们还缺的一层:共享协议模块
参考有 `openai-compat.ts`;我们没有对应物。`openai.lua` 自己写明
「Lua adapters have no shared prelude」,`safe_tool_id` 在 `openai.lua` 与
`anthropic.lua` 里**各复制了一份**(各 6 处引用)。
所以正确做法是两层:
1. 抽一个 Lua 共享前导(消息序列化 / SSE 消费 / 错误归类),
或内核在加载时注入 —— 消掉复制粘贴
2. channels 插件为需要真协议转换的平台(trae SOLO、gemini 信封、
codearts AK/SK 签名)**装自己的适配器**,而不是硬塞给 `openai`
### 所需的适配器能力(crypto + 出站 + 流式)
已核实内核提供:
- `sha256_hex` / `hmac_sha256_hex`(`vm.go` setupGlobals)
- `build_headers(meta)` 收 `{url, method, body, api_key, timestamp,
client_session, source{name,meta}}` —— CodeArts 的 AK/SK 签名用它
(与参考 `signRequestHuawei` 同构,但 AK/SK 要从 `api_key` 里解出)
- `transform_stream_chunk` 逐 chunk 调用(`provider.go:1295`)
- 插件有完整 OS 能力(`io.popen`/`os.execute`)→ 可用 openssl 做 EC 签名
### 未验证的风险
- 适配器 VM 能否访问 `__llmsproxy_secrets`(AK/SK 要解出 SK 才能签名)
- 流式转换能否跨 chunk 保持状态(trae 的 SSE 需要跨帧累积 tool_calls)
这两条是下一步落地前必须先探的。
---
# 插件自装适配器:机制与判据(2026-10-13)
## 机制(内核早已具备,本轮才开始使用)
```
internal/lua/plugin_adapter_bridge.go
InstallAdapter(plugin, name, code) # 含归属校验、冲突仲裁(影子内置 → 挂起等裁决)
registerAdapterBridge → Lua 全局 __llmsproxy_adapters.install / .update / .remove
```
注册点在 `bootPluginState`,**先于** `DoString(code)` —— 所以插件在脚本执行期
就能装适配器。适配器装在**适配器 VM**(与插件 VM 分离,各自独立)。
## 三条能力(实测,不是推断)
`internal/lua/plugin_adapter_capability_test.go` 锁死:
| 能力 | 结论 | 为什么关键 |
|---|---|---|
| 拿凭据 | 适配器**能**经 `build_headers` 的 `meta.api_key` 拿到(`url`/`method`/`body` 也在) | AK/SK 签名的前提;同时**验不到** `__llmsproxy_secrets`(只注入插件 VM,安全边界完好) |
| 流式状态 | `transform_stream_chunk` **跨 chunk 保持同一 Lua 状态**(表地址恒定) | SOLO 跨帧累积 tool_calls、gemini 跨帧剥信封的前提 |
| 加密原语 | 适配器可用内核 `sha256_hex` / `hmac_sha256_hex`(用标准向量校验) | 签名不需要外部进程 |
## 踩过的坑(都是"静默失败"类,值得记)
1. **`function` 是 Lua 关键字** —— 写 `body.function = x` 是**语法错误**
(`'<name>' expected near 'function'`),必须 `body["function"]`。
插件脚本本身能编译通过,**只在适配器 `load()` 时才暴露**。
2. **Lua 长字符串里不能有字面 NUL 字节** —— 报
`unfinished long string near '<eof>'`,且**报在文件末尾**,完全指不到现场。
3. **本 Lua 构建不支持按位异或 `~`**(`unexpected symbol near '~'`)。
4. **装载失败只回"源码无法编译"、不给行号** —— 三个自装适配器现统一加
**本地预编译护栏**(先 `load` 一次拿行号)。上面三个坑都是靠它定位的。
5. **抽公共代码要抽最全的实现**。前导重构时我先抽了 `transform_error` 的
最小版(只认 `{error:{message}}`),打挂 6 个既有测试 —— 最小版丢掉了
HTML 错误页、裸文本、扁平 `{code,message}`、FastAPI `{detail}`。
抽最小实现是**静默降级**:不报错,只是少认几种错误体。
6. **判据必须能看见真实函数的返回值**。我一度用 `kernelTestCore` 裸加载插件
(没授 `add_source` 权限)测源注册,源一直不出;直到把内核原始返回
(`kernel action add_source is not granted to plugin channels`)带出来才
定位。**黑盒断言只在失败时告诉你"不对",不告诉你"为什么"**。
## 判据清单
| 文件 | 锁死什么 |
|---|---|
| `internal/lua/plugin_adapter_capability_test.go` | 三条能力(凭据/流式状态/加密原语) |
| `internal/lua/adapter_prelude_test.go` | 前导注入 + 语义值正确 + 旧适配器行为不变 |
| `internal/lua/adapter_prelude_equivalence_test.go` | 重构前后逐字节等价(含反向断言) |
| `internal/lua/channels_trae_solo_test.go` | SOLO 7 条规则 + 三处反向断言 |
| `internal/lua/channels_gemini_adapter_test.go` | 身份五头逐值 + 不得发 google key/Accept + 字母序 + 响应剥信封 |
| `internal/core/channels_codearts_adapter_test.go` | 装机/归属 + **Go stdlib 独立复算 HMAC 验签** |
| `internal/core/channels_private_adapter_wiring_test.go` | 三平台的**源真的引用了**自装适配器 |
---
# 生产实测:不可用的真相(2026-10-14)
## 症状与根因
用户报"完全不可用"。实测(`/api/plugins/http/channels/account/list` 逐平台):
| 平台 | 账号数 | **有凭据的** |
|---|---|---|
| buddy | 2 | **1** |
| trae | 3 | 0 |
| minimax | 1 | 0 |
| lobsterai | 1 | 0 |
| codearts | 5 | 0 |
| 其余 7 平台 | 0 | 0 |
**13 个账号里只有 1 个真有凭据**,其余全是**占位壳子**(`has_credential: false`)。
占位账号不是"已登录",它只是为「先建账号行、登录后填凭据」这个两步流程预留的
行。此前的判断只看「12 平台 0 个 manual」,那是**错的判据** —— manual 是
flow 类型,不是"登录成功"。
## 排除项(都实测过,不是猜)
| 检查 | 结果 |
|---|---|
| 服务状态 | `active`,`NRestarts=0` |
| 部署一致性 | 4 个插件 sha256 与仓库**全部一致**;关键标记(installCodeartsAdapter / installTraeAdapter / installGeminiAdapter / sorted_json / sign_parts)全部在位 |
| **推理链路** | `POST /v1/chat/completions` 实测返回 `"OK"`(走 buddy 源、deepseek-v4.1-flash)—— **网关本身完全可用** |
| 登录机制 | minimax 真实返回 `user_code=T49S-RC7G` + 验证地址;trae 起了服务端浏览器 + 回环回调 |
| 设备码轮询 | 5 次全部 `pending`(未授权时**不误判**为失败或成功 —— 这是正确行为) |
## 结论:不是代码坏了,是**授权没完成**
剩下的 12 个平台都需要**人工动作**才能走完登录:
- **device-poll**(minimax / cline / qoder):把 `user_code` 拿到
`account.minimax.cn/oauth-authorize` 之类的**官方页面**输入
- **callback**(trae / gemini / lobsterai):在 **VNC 窗口**里点授权
(回环回调只能由本机解析,远程浏览器永远接不住)
- **qr-poll / wechat-poll**(raccoon / loomy):扫码
- **init-poll**(zcode):授权页 + 可能的验证码
这些**按设计就必须人工**,assistant 无法独立端到端完成。
## B 类:实测结论
| 项 | 行数 | 结论 |
|---|---|---|
| qoder-wasm + envelope | 706 + 226 | **二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。没有 `qoder-auth-wasm.wasm` 无法实现;拿到才有意义 |
| zcode-captcha + carrier | 2119 + 93 + 280 + 206 | 阿里云滑块。且**登录本身不需要它** —— captcha 只用于**签到/领取**环节(`zcode-auth.ts:22-23`:claim 时每个 plan 单独 mint captcha)。登录闭环不受阻,受阻的是自动领取 |
| captcha-pool | 280 | 同上,服务于领取环节的验证码供给 |
**修正判断**:B 类不是"登录闭环的拦路虎"。zcode 的登录(`/api/v1/oauth/cli/init`
→ poll)不需要验证码;captcha 只在**自动领取**时出现。所以 B 类的真实价值是
"让领取全自动",而不是"让平台可用"。
---
# 真实数据事故:测试覆盖了生产账号池(2026-10-14)
## 症状
用户报「完全不可用」。逐平台 `account/list` 后发现:**13 个账号里只有 1 个有凭据**,
其余是占位壳子;再查发现**账号池被清空了**(`pool` 全为空 `{}`),
而保险库 `.secrets/channels.json` 里的凭据**还在**。
症状正是「账号凭空消失」。
## 根因(实测输出,非推断)
channels 插件用 `storeDir()` 决定存储位置,它有多级兜底:
```
env LLMSPROXY_PLUGIN_DIR → settings.store_dir → 读 /etc/llmsproxy/config.yaml 的 plugin_dir → 硬编码
```
测试台没设 `LLMSPROXY_PLUGIN_DIR`,**后两级直接命中生产**:
```
测试插件目录 = /tmp/TestXXX/002/plugins
storeDir 结果 = /etc/llmsproxy/plugins ← 探针实测输出
```
于是测试加载真 `channels.lua` 时**读的是生产存储、写的也是生产存储**。
一次全量 `go test ./internal/lua` 就把
`.channels-store.json` 从 1730 覆盖成 1986 字节、账号池清空(inode 都换了)。
只有 `internal/lua` 复现,`internal/core` 不复现 —— 因为 core 的测试台
(`kernelTestCore`)设了 `LLMSPROXY_PLUGIN_DIR`。**这是隔离做得不一致**的代价。
同类前科:曾把 26 个测试账号写进生产 store。两次同一类错误。
## 为什么 `DisableStatePersistence` 不够
第一版修复给 `runtimePlugins` 关了持久化,**仍然复现**:插件声明了 `schedule`
时内核会自动起调度器(`plugins.go: p.Runtime = ps.startRuntime(...)`),
真 channels 的 `refresh_models` 会在 5s 后(first_due_now)跑一轮并
`logEvent → markDirty`。那是**后台 goroutine,比 `t.Setenv` 的生命周期长** ——
测试结束后它仍可能落盘,此时环境变量已还原。
## 修法(两层,第二层才是根治)
1. **内核把插件的真实目录交给插件**(ground truth,不再靠猜):
新增 `__llmsproxy_plugin_dir` 全局(`registerPluginDirBridge`),
`storeDir()` 优先用它。**即使没有环境变量也不会落到生产路径** ——
实测:撤掉 env 后 `storeDir` 仍落在临时目录。
2. 测试台补隔离:`runtimePlugins` 设 env + `DisableStatePersistence()`。
## 判据 + 变异验证
`internal/lua/store_dir_isolation_test.go`:
`storeDir` 绝不能是 `/etc/llmsproxy` 前缀、必须落在临时目录、env 必须真的被设。
**变异验证**:把隔离设成生产路径 → 判据变红并打印「storeDir 指向了**生产**目录」;
恢复 → 变绿。
全量回归:9 个包全绿,且 `go test ./...` 后生产存储**未被改动**(此前必被改)。
## ⚠️ 修复过程中我自己踩的坑
恢复账号时我把**保险库文件里的密文**当成明文,喂给 `account/add` 的
`credential` —— 保险库 `loadSecretsLocked` **会在读取时解密**,于是那份凭据被
**二次加密**(1971 → 2965 字符),该账号的推理开始返回空
(`codebuddy_*` 源失效,而非池路径 `channels:buddy` 正常)。
教训:**凭据只能由登录/续期路径写入,绝不要手工把密文塞回 API**。
要复用既有凭据,应走「让插件自己读保险库」的路径,或者重新登录一次。
---
# 逐模块对照的当前状态(2026-10-14 更新)
穷尽对照参考仓 **143 个 .ts 模块 / 77,720 行**。约定:
✅ 已对齐 · ⚠️ 部分/简化 · ❌ 未实现 · ⛔ 明确不做
## 本轮(第 2 批)新补的
| 模块 | 参考规模 | 补了什么 / 为什么 |
|---|---|---|
| `dead-model-store` | 708 | 上游下架的模型仍显示且标"免费",一点就 404。**判据只用阳性 404 证据,绝不拿远端目录反推**(buddy 那类平台目录残缺,反推会删可用模型) |
| `remote-catalog-gate` | 114 | 参考点名"不能省":目录拉不到会放大成「一次请求 × 每个模型」,首屏卡死 |
| `buddy-balance-rank` + `loomy-balance-rank` + 两个 selector | ~1000 | **余额分档选号**:优先用「15 天内到期」的号,否则积分白白作废;锁定时只剩永久的号等同不可用 |
| `account-probe` | 568 | `account_test` **此前一个网络请求都不发**(恒返回 ok:true/ms:1)—— 假阳性 |
| `model-gate` | 218 | 源改**串行 + 按模型节流**(并发发起必然浪费一个配额;串行不保证间隔) |
| `ttl-cache` / `serial-queue` | 291 | 抽成通用件,消掉三处各自手写的缓存/冷却逻辑 |
| `opencode-capability` | 423 | isFree 改由 models.dev 的 `cost` 权威决定(不再是硬编码快照)。**受内核 4 MiB 上限约束不可用**,已如实降级为可选增强 |
另修 3 个**静默失败**:`account_test` 不发请求、业务级错误(HTTP 200 + body 带
error)被当成功、`pcall` 只取 ok 不看返回值(把诊断信息扔了)。
## 仍未实现(按影响面排序)
| 模块 | 行数 | 为什么不做的理由 / 阻塞 |
|---|---|---|
| `zcode-captcha` + 6 个 captcha-* | ~3345 | 阿里云滑块。**登录不需要它** —— captcha 只在签到/领取环节(`zcode-auth.ts:22-23`)。真实价值是"让领取全自动"而非"让平台可用" |
| `qoder-wasm` + `qoder-envelope` | 932 | **wasm 二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。拿不到就无法实现 |
| `zcode-identity` / `transport` / `diagnostics` | 1104 | 设备身份 + 传输层 + 错误诊断。与 captcha 链路耦合 |
| `account-pool` / `channel-pack-store` | 1912 | 我们已自建(形态不同,非缺口) |
| `opencode-proxy` / `opencode-rpc` | 530 | 多出口代理(NAT 后多机共享出口时按 IP 限流才需要) |
| `message-shape` | 146 | **不适用**:那是 DSH 宿主的消息形状迁移(0.1.5→0.1.7)。我们的适配器直接透传 OpenAI 形状,无此问题 |
| `loop-recovery` | 346 | **前提不存在**:它做的是"循环守卫中止后自动续跑",而我们的内核**没有循环守卫** |
| `reasoning-ladder` | 182 | 需要内核支持 `efforts` 字段(`Model` 结构只有 id/priority/kind/meta)。属内核改动,按纪律不做 |
| `badge-*` / `image-budget` / `ttl-cache`(已补) | ~500 | 徽标订阅、图片预算 —— 宿主 UI 特性 |
| `raccoon-login-page` / `raccoon-qr` | 1124 | 登录页与二维码的完整实现(我们走简化版) |
| `zcode-carrier-page` | 93 | 内部载体页(依赖 DSH Desktop 的 webview guest,我们没有那个壳) |
| `cline-*` 4 个 / `gemini-project` / `gemini-sigstore` / `loomy-sign`(部分) | ~900 | 平台细节与签名持久化 |
## 一句话结论
**平台登录/凭据/源注册这条主线已打通且逐平台对齐**(12 平台零 manual、三平台
自装适配器、SOLO/信封/AK-SK 签名逐字节验证)。
**未实现的部分集中在两类**:(1) 需要**真实浏览器/验证码**的领取全自动
(captcha 系 ~3300 行);(2) **二进制缺失**(qoder wasm)。
其余是宿主 UI 特性或前提不存在的模块。
---
# 第三批(2026-10-14 晚)—— 逐模块对照的**新发现**
## ★ 本轮新补的三个真缺陷
### ① 自动领取每天静默漏领(`credits.ts` / `auto-checkin.ts`)
活动刷新时刻**各平台不同**(Qoder 每日 10:00 UTC+8),而我们只在 08:00 跑一次
⇒ 上午看到昨天那条 `CLAIMED`、报 `already-claimed` 并记账 ⇒ 当天再不去领。
**修法照参考**:不配"时刻表"(必然漂移),让**产出方声明 `covers_today`**,
记账方只数"有几条能证明今天已处理"。两层都要:频率提高 + `first_due_now`。
### ② 凭据续期此前是假的(`refresh.ts` / `expiry-sync.ts`)
`account_refresh` **一个换 token 的请求都不发**却恒返回 `ok:true`
⇒ `refresh_creds` 打「已续期 N 个」而什么都没续 —— **日志在说谎**。
这是本轮第三次遇到同形态(前两次:`account_test` 恒 ok、业务级错误被当成功)。
### ③ `isFree` 与声明自相矛盾(`cline-models.ts`)
cline 的免费模型声明在 `models` 里 ⇒ 被标成收费。
★ 关键认识:**两个参考用的是不同机制** —— cline 用远端 `free[]`,
opencode 用 models.dev 的 `cost`。混成一条 OR 链会与既有判据正面冲突。
⇒ 加 `free_source` 由**平台声明**权威来源。
## 插件自装适配器(第 3、4 个)
| 适配器 | 解决的问题 |
|---|---|
| `channels-qoder` | 错误信封**双层编码**;`10605`(排队) 与 `110`(额度耗尽) 语义**相反**,必须分开说(参考实测:110 原被归成通用服务端错误 → 白重试 5 次 ≈15.5 秒) |
| `channels-cline` | 429 **无 retry-after 头**、等待时长写在英文句子里(`Try again in 19h 39m`)。解析不出 ⇒ 内核按 30 秒短冷却 ⇒ **每 30 秒猛敲上游**(比参考的「永远 60 分钟」更糟) |
现共 4 个:`channels-trae` / `channels-gemini` / `channels-qoder` / `channels-cline`。
## 对照后判定「不适用」的(附理由,不是"没做")
| 模块 | 为什么不需要 |
|---|---|
| `account-pool` 并发写竞态 | 我们的落盘是**同步** tmp+rename(Lua 无异步),每次写序列化**当前** state。参考的缺陷是"异步后端 + 共享可变快照"的产物。**已加判据钉住**(已删账号不复活、新增不丢失、内存/磁盘不分叉) |
| `gemini-messages` SSE 末帧丢失 | 我们的 `bufio.Scanner` 对无尾换行的最后一行**照样产出**(已实测自证)。参考那是在 `while(!done)` 里按行处理所致 |
| `cline-rate-limit` 的 30 分钟策略冷却 | 我们**每账号一个源**(`channels-<plat>-<acct>`)⇒ 某账号被拦只冷却它自己,下个请求自然换源。参考是"一个池里换号",才需要长冷却挡住同一轮里的连续重撞 |
| `message-shape` | DSH 宿主的消息形状迁移;我们直接透传 OpenAI 形状 |
| `loop-recovery` | 前提不存在(我们的内核没有循环守卫) |
| `reasoning-ladder` | 需要内核支持 `efforts` 字段(属内核改动,按纪律不做) |
| `retry-after.ts` | 参考那份实现**自己就是死代码**(grep 无消费者);我们的固定 30s 冷却有明确理由(429 是"太快"不是"坏了",走指数退避会让额度充沛的源被锁 30 分钟) |
| `image-budget` | 实际缩放委托给宿主的附件服务,我们无对应物 |
## 运行环境的一条教训(不是代码缺陷)
全量 `go test` 曾出现 `TestConcurrentPluginAdapterUpdates` **挂 59 分钟**、
看着像死锁。单独跑 **0.058s 通过** ⇒ 真因是 **`/tmp`(8.8G tmpfs)被 Go 构建
缓存占满 100%**。教训:构建/测试挂住时先 `df -h /tmp`(`/` 空闲不代表 `/tmp` 够)。

View File

@ -1,117 +0,0 @@
# 插件可调用内核动作(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 生效、危险动作需显式授权

View File

@ -15,7 +15,7 @@ deployable.
|---|---|---|---|
| Main | `main` | permanent | Only long-lived branch. Always deployable. Accumulates the next version. |
| Feature | `feature/<desc>` | short (dev → merge → delete) | New features / ordinary fixes. Born from `main`, merged back into `main`. |
| Release | `release/vX.Y.x` | one version cycle | Cut from `main`, tagged for release. Version-specific hotfixes land here. |
| Release | `release/vX.Y.Z` | one version cycle | Cut from `main`, tagged for release. Version-specific hotfixes land here. |
## Change flow (important)
@ -26,21 +26,15 @@ deployable.
│ │
│ cut │ cut
▼ ▼
release/v1.6.x release/v1.7.x
release/v1.4.2 release/v1.4.3
│ │
tag: v1.6.0 tag: v1.7.6
tag: v1.4.2 tag: v1.4.3
│ │
hotfix ◄─────┘ hotfix ◄─────┘
│ │
└── cherry-pick back ──────────┘
```
The patch position in the branch name is a literal `x`, while the tag carries
the concrete version: one `release/v1.7.x` can hold tags v1.7.0 … v1.7.6.
Spanning several patches on a single minor branch is deliberate — patches are
revisions of the same feature batch, hotfixes land on one branch, and back-port
to main never has to resolve dependencies between several release branches.
### Key rules
1. **main is always deployable**: never leave half-done work on `main`.
@ -49,9 +43,9 @@ to main never has to resolve dependencies between several release branches.
then `git merge --no-ff feature/xxx` (or squash) when done.
3. **Release = cut a release branch from main + tag**:
```bash
git checkout -b release/v1.7.x main
git tag -a v1.7.0 -m "ModelRouter v1.7.0"
git push origin release/v1.7.x v1.7.0
git checkout -b release/v1.4.2 main
git tag -a v1.4.2 -m "ModelRouter v1.4.2"
git push origin release/v1.4.2 v1.4.2
```
Build installers and upload the GitCode Release from this tag so the
published state is exactly reproducible.
@ -60,7 +54,7 @@ to main never has to resolve dependencies between several release branches.
an already-released branch (unless you deliberately ship a minor revision).
5. **Hotfixes MUST flow back to main**:
```bash
git checkout release/v1.7.x # fix in the release branch
git checkout release/v1.4.2 # fix in the release branch
git commit -m "fix: ..."
git checkout main
git cherry-pick <hotfix-commit> # and into main
@ -73,23 +67,12 @@ to main never has to resolve dependencies between several release branches.
When the next version ships, the previous release branch retires:
- **Default: delete the remote release branch**
(`git push origin :release/v1.7.x`). All hotfixes were already
(`git push origin :release/v1.4.2`). All hotfixes were already
cherry-picked into main, so main contains everything; no merge needed.
- **Long-term maintenance** (e.g. an enterprise client pinned to an old
version): keep the branch, accept only security fixes, keep the
commit-then-cherry-pick loop.
> **Where practice diverged from this section (checked 2026-10-01)**:
> `release/v1.4.x` and `release/v1.5.x` still exist locally and on the remote,
> so "retire the previous branch when the next version ships" was never
> carried out. Keeping them is harmless (hotfixes were back-ported), but it
> contradicts the rule above and makes a reader wonder whether they should be
> there at all. **Feature branches, by contrast, are cleaned up**:
> `feature/key-quota-control`, `feature/toolcall-id-sanitize`,
> `feature/anthropic-usage-cache` and `feature/agentrouter-id-sanitize` were
> deleted on 2026-10-01 after confirming with a per-commit `git patch-id`
> comparison that their work had already landed in main.
## Explicit non-goals
- **Never rebase main**: main's history stays append-only; anyone pulling gets
@ -151,8 +134,8 @@ pain points:
2. No feature branches meant two independent efforts could not proceed in
parallel without colliding.
With release branches: the published state = `release/vX.Y.x` branch +
the concrete `vX.Y.Z` tag, exactly reproducible; hotfixes have a clear landing
spot; main stays "latest + all fixes + deployable".
With release branches: the published state = `release/vX.Y.Z` branch +
`vX.Y.Z` tag, exactly reproducible; hotfixes have a clear landing spot; main
stays "latest + all fixes + deployable".
> 中文版见 [docs/git-workflow.md](git-workflow.md)。

View File

@ -13,7 +13,7 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
|---|---|---|---|
| 主分支 | `main` | 永久 | 唯一长命分支。永远可部署。积攒下一个版本的功能。 |
| 特性分支 | `feature/<描述>` | 短命(开发→合并即删) | 新特性 / 一般 bug 修复。从 `main` 开出,完成后合回 `main`。 |
| 发布分支 | `release/vX.Y.x` | 一个版本周期 | 从 `main` 分出,打 tag 发布。该版本生命周期内的 hotfix 都提交在此分支。 |
| 发布分支 | `release/vX.Y.Z` | 一个版本周期 | 从 `main` 分出,打 tag 发布。该版本生命周期内的 hotfix 都提交在此分支。 |
## 变更流向(重要)
@ -24,20 +24,15 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
│ │
│ 切出 │ 切出
▼ ▼
release/v1.6.x release/v1.7.x
release/v1.4.2 release/v1.4.3
│ │
tag: v1.6.0 tag: v1.7.6
tag: v1.4.2 tag: v1.4.3
│ │
hotfix ◄─────┘ hotfix ◄─────┘
│ │
└── cherry-pick 回 main ───────┘
```
分支名里 patch 位是**字面的 x**,而 tag 打具体版本号:`release/v1.7.x` 这一条
发布分支上的 tag 可以有 v1.7.0 … v1.7.6 多个。一条 minor 分支跨多个 patch 是
刻意的:patch 是同一批功能的不同修订,hotfix 落在同一条分支上,回流 main 时
也不必处理多条 release 分支之间的依赖。
### 关键规则
1. **main 永远可部署**:不在 main 上留半成品。任何未完成的工作必须在特性分支上。
@ -45,16 +40,16 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
开发完 `git merge --no-ff feature/xxx` 或 squash 合回。
3. **发布 = 从 main 切 release 分支 + 打 tag**:
```bash
git checkout -b release/v1.7.x main
git tag -a v1.7.0 -m "ModelRouter v1.7.0"
git push origin release/v1.7.x v1.7.0
git checkout -b release/v1.4.2 main
git tag -a v1.4.2 -m "ModelRouter v1.4.2"
git push origin release/v1.4.2 v1.4.2
```
构建安装包、上传 GitCode Release 都基于这个 tag,保证可精确回溯发布态。
4. **版本生命周期内只收该版本的 hotfix**:新特性一律并入 `main` 等下一个版本,
绝不塞进已发布的 release 分支(除非主动选择在该版本内发次要版)。
5. **hotfix 必须回流 main**:
```bash
git checkout release/v1.7.x # 在发布分支提交修复
git checkout release/v1.4.2 # 在发布分支提交修复
git commit -m "fix: ..."
git checkout main
git cherry-pick <hotfix-commit> # 回主分支
@ -66,20 +61,11 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
下一个版本发布时,上一个 release 分支退役:
- **默认:直接删除远端 release 分支**(`git push origin :release/v1.7.x`)。
- **默认:直接删除远端 release 分支**(`git push origin :release/v1.4.2`)。
因为 hotfix 都已逐个 cherry-pick 回 main,main 已包含全部修复,无需再合并。
- **如需要长期维护旧版**(例如企业大客户卡在旧版本):保留分支,仅 stopship 接受
该版本的安全修复,继续走「提交 + cherry-pick 回 main」循环。
> **实践与本节的历史出入(2026-10-01 核对)**:`release/v1.4.x` 与 `release/v1.5.x`
> 至今仍在本地与远端,说明"下一个版本发布就删上一个分支"实际没有执行。
> 保留无害(hotfix 已回流),但它与上面写的规则不一致,读文档的人会以为
> 这些分支不该存在。**特性分支则确实在清理**:`feature/key-quota-control`、
> `feature/toolcall-id-sanitize`、`feature/anthropic-usage-cache`、
> `feature/agentrouter-id-sanitize` 四个分支在 2026-10-01 删除——它们的工作
> 早已全部进入 main(逐提交用 `git patch-id` 比对确认),留着只是给下个版本
> 制造 cherry-pick/merge 陷阱。
## 明确不做的事
- **不 rebase main**:`main` 的历史保持追加式,任何人拉取后 `git pull` 都得到直接可用的历史。

View File

@ -23,7 +23,7 @@
5. [内置辅助函数](#5-内置辅助函数)
6. [meta 约定](#6-meta-约定)
7. [一个完整的最小适配器](#7-一个完整的最小适配器)
8. [多模态与 thinking 预算](#8-多模态与-thinking-预算)
8. [多模态与 disable_thinking](#8-多模态与-disable_thinking)
---
@ -73,9 +73,8 @@ end
约定:
- 返回的字符串将被原样 POST 到 `base_url + (source.endpoint or adapter.endpoint)`。
- 契约 v2 起,内核**不再向适配器泄漏任何私有字段**:请求体里不会出现
`disable_thinking` / `extra_body` 这类键。推理开关与预算用中立的 `thinking`
字段表达(见下),适配器按目标协议各自映射。
- 请求体中包含的 `disable_thinking`、`extra_body` 等网关内部字段,转换时应自行清理
(`openai.lua` 即删掉这两个字段)。
### `transform_response(raw_body) -> string`
@ -177,81 +176,6 @@ 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 注入):
@ -295,13 +219,8 @@ adapter.headers = { ["X-Tenant"] = "prod" }
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
-- 契约 v2:内核不再向适配器泄漏私有字段,`thinking` 是中立语义字段。
-- 这里把中立的 thinking 映射成目标协议需要的形状(示例:OpenAI 家族用
-- reasoning_effort)。不关心推理的源可以直接忽略它。
if req.thinking ~= nil then
if req.thinking.enabled == false then req.reasoning_effort = "none" end
req.thinking = nil
end
req.disable_thinking = nil
req.extra_body = nil
return json.encode(req)
end
@ -349,21 +268,11 @@ sources:
kind: chat
```
## 8. 多模态与 thinking 预算
## 8. 多模态与 disable_thinking
- **多模态**:请求的 `content` 若是数组(`[{type:"text"...},{type:"image_url"...}]`),Go 层
原样透传给适配器。协议不支持的源(Anthropic/Gemini/Ollama)需在
`transform_request` 内转换;内置 `anthropic.lua`/`gemini.lua`/`ollama.lua` 已实现。
- **thinking**:客户端请求里的推理开关/预算被内核归一成中立的 `thinking` 字段:
```json
{"thinking": {"enabled": true, "budget_tokens": 4096}}
```
`enabled=false` 表示关闭推理,`budget_tokens` 可选。适配器自行映射到目标协议
(内置实现:Anthropic → `thinking.budget_tokens`、Gemini →
`generationConfig.thinkingConfig.thinkingBudget`、DeepSeek →
`thinking.type = "disabled"`)。不支持的源直接忽略该字段。
契约 v1 的内核私有字段 `disable_thinking` 已被取代——它曾是内核细节泄漏进
适配器请求体的典型例子(迫使 10 个适配器各写一行 `req.disable_thinking = nil`)。
- **disable_thinking**:网关将请求体中的 `disable_thinking:true` 透传进
`transform_request`。DeepSeek 适配器将其映射为 `extra_body.thinking = { type = "disabled" }`
并清理自身字段;其余适配器按各自协议处理(可自行实现或忽略)。

View File

@ -1,673 +0,0 @@
# 插件系统(Plugin System)
> **English**: this document is the reference for writing ModelRouter plugins.
> The Chinese version is the primary one; section titles map 1:1.
ModelRouter 的插件是**单个 `.lua` 文件**,放在 `config.yaml` 的 `plugin_dir` 目录里。
插件能做两件事:
1. **挂钩子**:在请求流水线的若干 stage 上注册回调,看到每个请求的完整信息,
并可以把结果累加进自己的状态。
2. **贡献界面**:在启动时返回 HTML / CSS / JS,由内核注入 WebUI——可以是一整个
新页面,也可以是往现有页面里追加一个组件。
两者互相独立:只想统计请求数的插件不必碰界面;只想加个仪表盘的插件不必碰钩子。
---
## 1. 快速上手
一个最小的插件:
```lua
-- plugins/hello.lua
local plugin = {
name = "hello",
version = "1.0.0",
description = "示例插件",
author = "you",
}
-- 声明钩子
plugin.hooks = {
request_end = "on_request_end",
}
-- 钩子实现
function plugin.on_request_end(payload)
-- payload 是解码后的 table,不是 JSON 字符串
log("info", string.format("%s via %s: %d prompt tokens",
payload.model, payload.source, payload.prompt_tokens or 0))
return nil -- 最后一个 stage 没有下游,return 无意义
end
-- 贡献界面
plugin.ui = {
page = {
page_id = "hello", -- kebab-case
title = "Hello",
icon = "",
order = 90, -- 侧栏排序
mount = [[<div id="hello">hello</div>]],
},
}
return plugin -- 必须返回一个 table
```
放进 `plugin_dir` 后重启即生效。`GET /api/plugins` 确认它被加载了。
---
## 2. 加载与生命周期
```
core.New
└─ lua.NewVM(adapter_dir).Start() 适配器状态
└─ lua.NewPlugins(vm, plugin_dir)
├─ SeedBundled() 仅当目录不存在时写入内置插件(目前是 billing)
└─ LoadDir() 按文件名字典序逐个加载
```
**加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `GET /api/plugins`
的 `error` 字段里,永远不会被调用。这与适配器一致,但理由更强:插件是可选的
第三方扩展,因为一个 `.lua` 打错字就让网关起不来是错误的取舍。
**目录一旦存在就是权威的。** 与适配器同规则:首启会 seed 内置插件,之后目录里
的文件说了算,删除或编辑内置插件都是真实生效的操作。
### 2.1 热更新
| 方式 | 效果 |
|---|---|
| `POST /api/plugins {name, code}` | 写文件 + 立即加载新版本(旧的 Lua 状态被关闭重建,**累计量清零**) |
| `DELETE /api/plugins/{name}` | 删文件 + 卸载 |
| 改文件后 `POST` 同名 | 同上 |
改文件但**不** POST,需要重启才生效。
---
## 3. 流水线 stage
一个请求依次经过三个 stage。插件可以为任意 stage 注册钩子;未注册的 stage
被忽略,所以插件不会因为网关将来新增 stage 而报错。
```
客户端请求
│
┌─────────▼──────────┐
│ request_start │ 已解析、已鉴权,尚未选源
│ · type │ "chat" | "stream" | "image"
│ · model │ 客户端请求的原始 model("AUTO" 也在这里)
│ · key / role │ 掩码后的网关 key id("***a1b2c3")与角色
│ · source │ 空(还没选源)
│ · stream │
│ · messages_count │
│ · tools_count │
│ · ts │ unix 秒
└─────────┬──────────┘
│ (调度:tier 遍历 → 槽位轮转 → 冷却/配额过滤)
┌─────────▼──────────┐
│ routed │ 已选定 (source, model),尚未发往上游
│ · source / model │ 实际选中的
│ · tier │ AUTO 链的档位;直连 = -1;AUTO = -2
│ · stream / key │
└─────────┬──────────┘
│ (HTTP 往返 / SSE 流)
┌─────────▼──────────┐
│ request_end │ 每个请求恰好一次,成功失败都触发
│ · ok / status │
│ · latency_ms │
│ · first_byte_ms │ 流式的首字节时间
│ · prompt_tokens │ 上游真实 usage,缺失时为字节估算
│ · completion_tokens
│ · cache_hit_tokens / cache_miss_tokens
│ · image_count │ 生图数量(图片不计 token)
│ · error │ 失败原因,成功时为 ""
│ · time │ unix **毫秒**
└─────────┬──────────┘
│
写审计 + 聚合统计
```
### 3.1 触发点在哪
| stage | 代码位置 | 说明 |
|---|---|---|
| `request_start` | `gateway/chat.go` `handleChat` / `fireImageStart` | 每个请求一次(chat 与生图各一条),在配额闸门**之前** |
| `chain_step` | `gateway/chat.go` `chainTraceSink` | **仅 AUTO 路径**,每步一次 |
| `routed` | `singleChat` / `streamChat` / `singleChatAuto` / `streamChatAuto` | 成功选定源之后,各一次 |
| `request_end` | `gateway/chat.go` `writeRec` | **所有出口的唯一汇合点**,每个请求一次 |
### 3.1b 为什么单独有 `chain_step`
`routed` 只在**遍历结束后**触发一次,只带最终胜出的槽位。所以
"tier 1 冷却所以降级到 tier 3"和"tier 1 正常接单"在它眼里**完全一样**——
而这恰恰是优先级链存在的全部理由。
`chain_step` 补上这条信息,四种 `kind`:
| kind | 含义 | 何时产生 |
|---|---|---|
| `tier_skip` | 整档被跳过 | 该档所有槽位冷却中/配额用尽 |
| `slot_fail` | 某个槽位硬失败 | 上游报错 / 适配器输出不可用 |
| `tier_busy` | 整档全忙且有界等待超时 | 2s 内没等到空位 |
| `selected` | 这个槽位接了单 | 每次成功遍历**恰好一次**,且是最后一步 |
顺序保证:所有 `chain_step` 都在 `routed` 之前,`selected` 是最后一步。
所以只订阅 `request_end` 的插件也能拿到轨迹摘要(见下)。
### 3.1c `request_end` 里的轨迹摘要
除了逐个 `chain_step`,`request_end` 还带三个便于做报表的字段:
| 字段 | 含义 |
|---|---|
| `chain_walk` | 整个遍历的步骤数组(上限 12 步,超出截断) |
| `degraded` | 布尔。`true` = 有过跳过/失败,即**发生了降级** |
| `tier_served` | 实际服务的那一档;直连或全失败时为 `-1` |
> **计费口径**:按**实际服务的模型**计费。降级到 tier 3 仍按 tier 3 的价算,
> `chain_step` / `degraded` / `tier_served` 只作**观测**,不参与计价。
> 理由见 §7.5。
`request_end` 放在 `writeRec` 是因为四条入口路径(直连/AUTO × 流式/非流式)都
经过它,既不会漏(流式的 token 数只有流结束才知道),也不会重复。
**hot path 注意事项**:没有插件注册某 stage 时,`Fire` 立刻返回(一次 `RLock`
加一次 map 查找)。装了插件之后,每个请求会在该 stage 上多一次 Lua 调用——
这是同步的,在关键路径上。计费插件那种"每请求一次"是正常的;把重活放进钩子是
反模式。
### 3.2 钩子的返回值
- 返回 `nil` 或不返回 = **没有意见**,payload 原样传给下一个插件
- 返回 table = 其中的键会**合并进 payload**,并作为 `Fire` 的返回值
**同一 stage 的多个插件并行执行**,但返回值按**插件加载顺序**合并,所以结果是
确定的(不依赖 goroutine 调度)。代价是一个插件看不到另一个插件刚加的字段:
每个钩子拿到的是**同一份 payload 快照**。
这与早期版本不同 —— 早期是顺序执行,后一个插件能看到前一个的返回值。它从未被
实际依赖(随核心发布的 billing 在每个 stage 都 `return nil`,注释里写着
"nobody downstream would read a return value"),但这是一处**契约变化**:如果你的
插件依赖「读到前一个插件写的字段」,并行的两个插件之间必须改用外部通信
(例如各自写 `plugin.state`,由 `/api/plugins/<name>/state` 读取)。
单个插件时不启 goroutine,直接调用。
前三个 stage 的返回值目前没有内部消费者(最后一个 stage 之后就是写审计),
所以计费插件改用 `plugin.state` + `/state` 端点来暴露数据。
---
## 4. 界面扩展
### 4.1 整页
```lua
plugin.ui = {
page = {
page_id = "billing", -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing
title = "Billing", -- 必填,侧栏文字
icon = "", -- 可选
order = 40, -- 侧栏排序,默认 100
mount = [[...HTML...]],
},
}
```
### 4.2 往现有页面追加元素
```lua
plugin.ui = {
elements = {
{
target = "status", -- status | chat | keys | sort | sources | adapters
anchor = "top", -- "top" | "bottom" | "before:<sel>" | "after:<sel>"
order = 5,
mount = [[...HTML...]],
},
},
}
```
### 4.3 `mount` 里可以带 `<script>` 和 `<style>`
内核的注入顺序是:**先插 HTML,再执行 `<script>`**,所以脚本里访问
`document.getElementById` 一定能拿到已渲染的节点。
### 4.4 插件可用的浏览器端 API
| API | 作用 |
|---|---|
| `window.pluginAPI.fetchState(name)` | 等价于 `GET /api/plugins/<name>/state` |
| `window.pluginAPI.onTabShown(fn)` | 注册"页面切到可见时"的回调(轮询类组件用) |
| `window.pluginAPI.postState(name, obj)` | `PUT` 自己的 state(写操作,需 admin) |
### 4.5 一个完整例子
见 `internal/lua/plugins/billing.lua`——它同时用了两种 UI 形式、完整的价格配置、
以及全部三个 stage。
---
## 5. 插件状态与 HTTP API
### 5.1 端点
```
GET /api/plugins 已加载插件清单 + 钩子 + UI + 错误
POST /api/plugins 上传/替换(admin){name, code}
DELETE /api/plugins/{name} 删除(admin)
GET /api/plugins/{name}/state 读取插件自己发布的状态
PUT /api/plugins/{name}/state 替换状态(admin)
```
`GET .../state` 对**任意角色开放**:它是报表数据(开销、计数),用户自己的
计费组件要能渲染。而 `PUT` 需要 admin。
### 5.2 约定:`prices` 与 `state` 分离
`PUT .../state` 的载荷里如果有 `prices` 键,它会被写进插件的 **`plugin.prices`**
字段并从 `state` 里剔除。
**为什么**:`state` 是累计量(历史),`prices` 是配置。一次改价如果整体替换
`state`,累计量就没了——账目会在改价那一刻清零。分离之后:
- 只带 `prices` → 改配置,**不动** `state`(累计量保留)
- 带其它键 → 替换 `state`(这是显式的重置)
> 插件可以不遵守这个约定,把整个载荷放进 `state` 自行处理。约定只为内置的
> 计费插件存在。
### 5.3 `GET /api/plugins` 的响应
```json
{
"plugins": [{
"name": "billing", "version": "1.0.0",
"description": "...", "author": "...",
"hooks": ["request_end"],
"ui": { "page": "billing", "elements": 1 },
"loaded": true
}],
"hook_errors": { "request_end": { "count": 3, "last_error": "billing: ..." } },
"plugin_dir": "/etc/llmsproxy/plugins"
}
```
`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 前缀代理:把插件自起的服务挂到网关同端口
前节的自定义挂载点只能回答**由插件计算出来的内容**(`{status, headers, body/json}`)。
若插件自己起了一个 TCP/HTTP 服务(典型:服务端浏览器栈 + noVNC),客户端就需要
**字节级转发**,包括 **WebSocket 升级** —— 前者做不到。
为此声明 `prefix = true`,handler 每次请求返回一个**回环目标**:
```lua
plugin.http = {
{ path = "myplugin/proxy/:session", method = "GET", prefix = true,
roles = { "admin" }, handler = "proxy" },
}
function plugin.proxy(req)
-- 每次请求都问一遍:会话没了/过期了就返回 404,地址自然失效
if not sessionAlive(req.params.session) then
return { status = 404, json = { error = "no such session" } }
end
return { proxy = "http://127.0.0.1:60100" }
end
```
规则:
- **只能返回 `{ proxy = <目标> }`**,没有 `status` / `body`。写了 `status` 会被忽略。
- 目标必须是 **带显式端口的字面回环 IP**(`http://127.0.0.1:60100`)。
拒绝 `https://`、`localhost` 这类主机名、无端口、带 userinfo/path/query 的地址。
理由:主机名可能被 hosts / DNS / 代理环境改写;通用能力只接受确定性地址。
- 路由匹配同前缀下的**所有子路径**,`:param` 仍照常捕获,`req.path` 是完整
请求路径,子路径由插件自己从 `req.path` 里切。
- **鉴权先于代理**:`roles` / `keys` / `public` 语义与普通端点一致。
- **不转发网关凭据**:`Authorization`、`Cookie`、`api_key` 都不会传给后端服务。
- **拒绝跨站**:`Origin` 与网关自身 host 不一致返回 403。WebSocket 由浏览器
发起且自动带 Cookie,否则第三方网页可借管理员会话开隧道。
- **撤销点唯一**:handler 不再返回目标(或返回 404)即失效,内核不缓存目标。
客户端拿到的 URL 是**相对网关口**的绝对路径(如
`/api/plugins/http/myplugin/proxy/<id>/vnc.html`),因此远程访问、HTTPS 入口、
Cookie 鉴权都自然生效,无需暴露额外端口。
### 5.7 在运行时安装/更新适配器(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 状态(也就是一个 VM)
适配器可以有多个独立的 worker 状态(它们无状态,这是对的)。**插件不行**:
钩子通常往 `plugin.state` 里累加,多个状态就意味着总额被劈成几份,而写价格只
写进其中一个状态,钩子恰好跑到另一个时**所有请求按 0 计费**。
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState` / **定时任务**)
都用这同一把互斥锁串行化。
注意: 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的
`plugin.state` 是一个请求路径永远读不到的副本 —— token 轮换看起来成功了,
对实际流量完全无效。
代价:**一个卡住的钩子会卡住该插件自己的钩子**(跨插件仍然并行,每插件各自
一把锁、各自一个 VM)。所以——
> **钩子与定时任务都必须短、同步、不阻塞。** 不要在钩子里做 HTTP 请求、睡眠
> 或重计算;长耗时的周期工作应放进 `PLUGIN.schedule`(见 §5.4),它在自己的
> 调度 goroutine 上跑,但仍然持有这把锁,因此不能是死循环。
### 6.2 异常被隔离
钩子里 `error()` 不会影响转发:异常被捕获、记进 `hook_errors`、跳到下一个插件。
适配器变换出错会让源进入冷却,但插件出错**不产生任何惩罚**——插件是可选功能。
### 6.3 内置可用函数
与适配器相同:`json.encode` / `json.decode`、`log(level, msg)`、
`hmac_sha256_hex` / `sha256_hex` / `base64_encode` / `tohex`。
注意: `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。
### 6.4 命名
- 全局 `__llmsproxy_plugin` 存放插件返回的 table,**不要占用**
- 插件间状态完全隔离,一个插件改不了另一个的全局
---
## 7. 内置计费插件
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
### 7.1 计费维度
| 维度 | 用途 | 优先级 |
|---|---|---|
| `sources` | 源的固定价(如按请求计费的转发服务) | 中 |
| `models` | 模型的 per-token 价 | 高 |
| `keys` | 单个网关 key 的覆盖价 | **最高** |
**token 价**优先级:`keys` > `models` > `default`。
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
**`cache_discount`** 见 §7.7。
### 7.2 价格单位
**USD / 单个 token**。这是各家 provider 的公布口径,所以典型数值长这样:
`1.25e-6`。插件内部乘以 token 数,全程不做单位换算。
```json
{
"prices": {
"currency": "USD",
"default": { "prompt": 0, "completion": 0, "per_request": 0 },
"sources": { "trae": { "per_request": 0.01 } },
"models": {
"gpt-5.4": { "prompt": 1.25e-6, "completion": 1e-5 },
"kolors": { "per_request": 0.04 }
},
"keys": { "***a1b2c3": { "prompt": 1.1e-6, "completion": 9e-6 } }
}
}
```
配置价目:
```bash
curl -X PUT http://127.0.0.1:8080/api/plugins/billing/state \
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"prices":{"models":{"gpt-5.4":{"prompt":1.25e-6,"completion":1e-5}}}}'
```
读回账目:
```bash
curl -H "Authorization: Bearer $ADMIN_KEY" \
http://127.0.0.1:8080/api/plugins/billing/state
```
### 7.3 累计维度
`total` / `by_source` / `by_model` / `by_key` / `by_day`(`YYYY-MM-DD` UTC),
每项含 `cost`、`requests`、`prompt_tokens`、`completion_tokens`、`failures`。
另有两个**降级观测**维度(来自 `chain_step`):
| 字段 | 含义 |
|---|---|
| `degraded_reqs` | 发生过降级的请求数 |
| `by_tier_served` | 各档实际接单数(`{"1": 812, "2": 37}`) |
| `skip_reasons` | 跳过原因计数,等待时长已归一(`no free slot within <wait>`) |
这三项是"网关是不是在悄悄降级"的核心指标:一个持续降级的网关,账单结构和健康
网关看起来一模一样——**除非**单独统计降级次数。
### 7.4 计费策略:失败的请求怎么算
**保留 token 费用,丢弃固定费用。** 理由:上游在生成后才 500,token 确实被消耗
了;但那个从未真正发生的固定费不该收。
这是本插件里**最可争议的一条**。想改成"失败也收固定费":
```json
{ "prices": { ... }, "count_failures": true }
```
### 7.5 注意: 计费插件只报表,不执法
**网关自己的配额会计(`internal/gateway/stats.go`,入口处强制)才是限额权威。**
本插件不参与任何路由或配额决策。
理由:两套独立的会计路径如果对不上,比一套功能略少的更糟。计费是**观察**,
配额是**控制**,二者分开。
### 7.6 未定价流量(重要)
**任何维度都没配价的请求,成本记 0。** 这是最危险的失败模式:账单照样能加总,
只是**悄悄少报**,而且没有任何报错。
所以插件单独统计它们:
| 字段 | 含义 |
|---|---|
| `unpriced_reqs` | 没有任何价目覆盖的请求数 |
| `unpriced_models` | 按模型点名(`{"MYSTERY-MODEL": 12}`)——直接告诉你价目表缺了哪一行 |
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
`total` 与各维度,只有金额是 0。
### 7.7 提示缓存计价
**缓存命中的 prompt token 不按全价算。** 绝大多数 provider 对缓存读给很深的折扣
(常见是 1/10),而 agent 流量会反复重放长前缀——正是缓存要让它便宜的那类流量。
```
fresh = prompt_tokens - cache_hit_tokens → 全价
cached = cache_hit_tokens → 全价 × cache_discount
```
`cache_discount` 默认 **0.1**(10 倍折扣),因为 DeepSeek / Qwen / Kimi 等都是这个
量级。它是**每个 provider 的事实、不是自然常数**,所以可以按条目覆盖:
```json
"models": { "gpt-5.4": { "prompt": 1.25e-6, "completion": 1e-5, "cache_discount": 0.25 } }
```
设成 `1` 恢复成旧的"prompt 一律全价"行为,设成 `0` 表示该 provider 不打折。
优先级与 token 价一致(`keys` > `models` > `default`)。
> **这一条改过行为。** 修复前缓存命中按全价算:100 万 prompt token 里 90 万是
> 缓存命中,会算出 10 USD 而不是 ~1.9——**高估约 10 倍**,而且恰好发生在缓存
> 最有价值的高频流量上。
### 7.8 数据从哪来
`request_end` 的 `prompt_tokens` / `completion_tokens` 优先取上游真实的
`usage`;上游没报时网关用字节估算(`len/3+1`)。流式请求在流结束后用上游真实
数字**覆盖**估算值。所以计费数字的精度取决于上游是否报 usage。
---
## 8. 排错
| 现象 | 查什么 |
|---|---|
| 插件没出现在 `/api/plugins` | 目录对不对(响应里的 `plugin_dir`);文件是不是 `.lua` |
| `loaded: false` 且有 `error` | 语法错误或没 `return table`,错误信息在 `error` 字段 |
| 功能"没反应"但无报错 | 查 `hook_errors`——坏钩子只记录不抛出 |
| 钩子没被调用 | 该 stage 确实触发了吗(`routed` 只在**成功**选源后触发,调度全失败时不触发) |
| 界面空白 | 内核的 `GET /api/ui-inject` 里有没有你的 `page_id`;脚本有没有报错(浏览器 console) |
| `pluginAPI` 未定义 | 脚本在注入前执行了;确认用的是 `mount` 而不是别的方式插入 |
### 调试钩子
插件的 `log(level, msg)` 输出到网关的日志:
```
journalctl -u llmsproxy -f | grep -i plugin
```
---
## 9. 与适配器的区别
| | 适配器 | 插件 |
|---|---|---|
| 位置 | `adapter_dir` | `plugin_dir` |
| 作用 | 上游协议转换 | 请求流水线 + 界面 + 周期任务 |
| 钩子 | `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 |
| 必需性 | 核心 | 可选,缺了网关照常跑 |
完整适配器协议见 [lua-adapters.md](lua-adapters.md)。

View File

@ -1,286 +0,0 @@
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

View File

@ -1,170 +0,0 @@
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

View File

@ -1,209 +0,0 @@
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)
// device-poll 授权页(qoder:本地造 PKCE,把手动授权页给用户,再凭据完成)
//
// 终局证据与 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(&regHits, 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":"qmodel_38max","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(&regHits) != 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)
}
// ---------- ③ device-poll(qoder):给授权页 + 凭据完成 ----------
// qoder 曾走 manual(返回 manual_hint);现已改为真实的 PKCE 授权页流程
// (本地造 challenge/verifier,服务端按 nonce 找回待授权会话)。
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)
}
// 真流程必须给出授权页 URL(而不是「不支持自动登录」的 manual_hint),
// 且会话声明为 device-poll。
if hint, _ := m["manual_hint"].(string); hint != "" {
t.Fatalf("qoder 已不再走 manual,不应返回 manual_hint: %q", hint)
}
if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "device-poll" {
t.Fatalf("qoder 会话 flow 应 device-poll: %v", m)
}
if authURL, _ := m["url"].(string); !strings.Contains(authURL, "challenge=") {
t.Fatalf("qoder 授权页 URL 应带 PKCE challenge,实得 %q", authURL)
}
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, "qmodel_38max") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "qmodel_38max") {
t.Fatalf("qoder 接入后 /v1/models 未出现 qmodel_38max: %s", modelsBody)
}
}

View File

@ -1,261 +0,0 @@
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)
}
}

View File

@ -326,144 +326,3 @@ func statusOf(resp *http.Response) int {
}
return resp.StatusCode
}
// The startup warning about key configuration must describe the key set that
// AUTH actually uses (cfg.Keys, via core.ListKeys), not the legacy
// gateway_keys YAML list.
//
// It used to check GatewayKeys() only. seedKeys copies gateway_keys into keys[]
// on first start and the YAML list stops being consulted, so once that list was
// emptied — e.g. after rotating the starter key away — the process logged
// "every request will be rejected" while seven working keys, one of them admin,
// were in service. Observed in production: the warning appeared and that same
// key returned HTTP 200 on /v1/models.
//
// The fixture reproduces exactly that shape: gateway_keys EMPTY, with the
// working key present only in keys[].
func TestStartupWarningReflectsRealKeysNotLegacyList(t *testing.T) {
up := newMockUpstream(t)
dir := t.TempDir()
// Seed a real install once so keys[] gets populated from gateway_keys, then
// rewrite the file to look like a rotated install: legacy list cleared, the
// seeded key still present in keys[].
path := writeConfig(t, dir, "127.0.0.1:0", map[string]*mockUpstream{"good": up})
bin := buildBinary(t)
// First run creates the sealed config with keys[] populated.
seedPort := freePort(t)
cfg1 := writeConfig(t, dir, "127.0.0.1:"+seedPort, map[string]*mockUpstream{"good": up})
g1 := startGateway(t, bin, "127.0.0.1:"+seedPort, cfg1)
g1.stop()
data, err := os.ReadFile(cfg1)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "keys:") {
t.Fatalf("first run did not populate keys[]:\n%s", data)
}
// Now clear the legacy gateway_keys list, keeping keys[] intact. The
// rewritten file may use different indentation, so match loosely.
cleared, n := clearGatewayKeys(string(data))
if n == 0 {
t.Fatalf("could not find a gateway_keys entry to clear in:\n%s", data)
}
if err := os.WriteFile(cfg1, []byte(cleared), 0o644); err != nil {
t.Fatal(err)
}
// Start again and inspect the log. The config pins its listen address, so
// rewrite that field to the port we will actually poll.
port2 := freePort(t)
rehosted, ok := replaceListen(string(cleared), "127.0.0.1:"+port2)
if !ok {
t.Fatalf("could not rewrite listen in:\n%s", cleared)
}
cfg2 := filepath.Join(dir, "config2.yaml")
if err := os.WriteFile(cfg2, []byte(rehosted), 0o644); err != nil {
t.Fatal(err)
}
g2 := startGateway(t, bin, "127.0.0.1:"+port2, cfg2)
defer g2.stop()
// Sanity: the key really does authenticate, so the warning would be a lie.
resp, _ := g2.do("GET", "/v1/models", "", true)
if resp == nil || resp.StatusCode != http.StatusOK {
t.Fatalf("key from keys[] did not authenticate (status %v); test fixture is wrong",
resp)
}
logs := g2.logString()
if strings.Contains(logs, "gateway_keys is EMPTY") {
t.Errorf("startup logged 'gateway_keys is EMPTY ... every request is rejected' while "+
"a working admin key was in service — the warning must read the authoritative "+
"key set (core.ListKeys), not the legacy YAML list.\nlog:\n%s", logs)
}
_ = path
_ = g1
}
func freePort(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
_, port, _ := net.SplitHostPort(l.Addr().String())
return port
}
// stop terminates the gateway and waits, so its log buffer is complete.
func (g *gatewayUnderTest) stop() {
if g == nil || g.cmd == nil || g.cmd.Process == nil {
return
}
_ = g.cmd.Process.Kill()
_, _ = g.cmd.Process.Wait()
}
// logString returns everything the gateway has written so far.
func (g *gatewayUnderTest) logString() string { return g.log.String() }
// clearGatewayKeys empties the gateway_keys list in a config, tolerating any
// indentation the YAML writer chose. Returns the new text and how many entries
// it removed.
func clearGatewayKeys(src string) (string, int) {
lines := strings.Split(src, "\n")
var out []string
removed := 0
inList := false
for _, l := range lines {
trimmed := strings.TrimSpace(l)
if strings.HasPrefix(l, "gateway_keys:") {
out = append(out, "gateway_keys: []")
inList = true
removed++
continue
}
if inList {
if strings.HasPrefix(trimmed, "- ") {
removed++
continue
}
inList = false
}
out = append(out, l)
}
return strings.Join(out, "\n"), removed
}
// replaceListen rewrites the top-level listen: value.
func replaceListen(src, addr string) (string, bool) {
lines := strings.Split(src, "\n")
for i, l := range lines {
if strings.HasPrefix(l, "listen:") {
lines[i] = "listen: " + addr
return strings.Join(lines, "\n"), true
}
}
return src, false
}

View File

@ -1,192 +0,0 @@
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

View File

@ -1,323 +0,0 @@
package billing
import (
"fmt"
"strings"
"llmsproxy/internal/config"
)
// Compile turns one billing profile into the prices table the billing plugin
// expects, resolving URL rules against the gateway's actual sources.
//
// WHY URL RULES NEED RESOLVING AT ALL: the operator declares pricing by URL
// because that is what a provider's price list is keyed on, and because several
// sources can point at the same URL. The plugin, however, looks up by
// `payload.source` (a source NAME) and by model — it has no idea what URL a
// request went to. So the URL match happens here, at compile time, where the
// config's name->base_url mapping is known, and the result is expressed in the
// dimensions the plugin already supports.
//
// Profiles are the "let the user choose" axis: the same URL can appear in
// several profiles and switching recomputes this table, so a gateway can be
// repriced without editing the plugin.
func Compile(profile *config.BillingProfile, sources []config.Source) (map[string]interface{}, error) {
return CompileOpts(profile, sources, false)
}
// CompileOpts is Compile with an explicit policy for rules that match no
// source.
//
// The default is STRICT and that is correct for startup: a profile with a
// typo'd URL prices nothing, every request on it is recorded as unpriced, and
// the bill silently comes out wrong. Failing the load is the right response.
//
// The rules editor needs the opposite. An operator editing a half-finished
// profile — adding a rule before the source that will use it exists, or
// renaming a URL while typing — must be able to save and then see the warning,
// not be blocked by an error they can only resolve by guessing. Lenient mode
// keeps the offending rule in the config and drops it from the compiled table,
// so it is preserved as text and clearly reported, without pretending it
// prices anything.
func CompileOpts(profile *config.BillingProfile, sources []config.Source, lenient bool) (map[string]interface{}, error) {
if profile == nil {
return nil, fmt.Errorf("no billing profile")
}
prices := map[string]interface{}{
"currency": "USD",
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
"sources": map[string]interface{}{},
"models": map[string]interface{}{},
"keys": map[string]interface{}{},
}
if profile.Default != "" {
prices["default_mode"] = profile.Default
}
// Currency is a profile-level statement; a rule may override it.
for i := range profile.Rules {
if c := profile.Rules[i].Currency; c != "" {
prices["currency"] = c
break
}
}
matched := map[string]bool{}
for i := range profile.Rules {
rule := &profile.Rules[i]
targets := matchSources(rule.URL, sources)
if len(targets) == 0 && rule.URL != "*" {
// A rule for a URL no source uses is almost always a typo (or a
// source that was removed). Failing loudly beats a profile that
// silently prices nothing — the whole reason this is a config file
// instead of a hand-written JSON blob.
if lenient {
continue // kept in the config text, reported as a warning
}
return nil, fmt.Errorf("rule url %q matches no configured source base_url", rule.URL)
}
for _, srcName := range targets {
if matched[srcName] {
// First rule wins. Two rules matching one source is ambiguous,
// and silently letting the later one win makes the file's
// meaning depend on ordering the operator cannot see.
continue
}
matched[srcName] = true
if err := applyRule(prices, rule, srcName, sources); err != nil {
return nil, err
}
}
}
// Sources no rule matched fall through to the profile default, which the
// plugin applies via prices.default. Recording them explicitly means the
// UI can say "this source is unpriced" instead of leaving the operator to
// infer it from a zero.
var unmatched []string
for _, src := range sources {
if !matched[src.Name] {
unmatched = append(unmatched, src.Name)
}
}
if len(unmatched) > 0 {
prices["unmatched_sources"] = unmatched
}
return prices, nil
}
// matchSources returns the source names whose base_url matches pattern.
// "*" matches every source (used as a catch-all default rule).
func matchSources(pattern string, sources []config.Source) []string {
var out []string
if pattern == "*" {
for _, s := range sources {
out = append(out, s.Name)
}
return out
}
want := normalizeURL(pattern)
for _, s := range sources {
if normalizeURL(s.BaseURL) == want {
out = append(out, s.Name)
}
}
return out
}
// normalizeURL compares URLs the way an operator expects: trailing slashes and
// case in the host are differences the provider's price list does not care
// about, and requiring an exact byte match would make the config brittle.
func normalizeURL(u string) string {
u = strings.TrimSpace(u)
u = strings.TrimRight(u, "/")
return strings.ToLower(u)
}
// applyRule writes one rule's pricing for one source into the prices table.
func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName string, sources []config.Source) error {
switch rule.Mode {
case "free":
// Explicitly priced at zero. This is NOT the same as unpriced: a source
// the operator says is free must not appear in the unpriced warnings,
// or those warnings become noise and stop being read.
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
}
return nil
case "subscription":
// A fixed monthly commitment: the per-request MARGINAL cost is zero, and
// the flat fee is reported separately. Spreading a monthly fee across
// requests would invent a per-request number the provider never charges,
// and it would change every time traffic did.
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
}
fixed, _ := prices["subscriptions"].(map[string]interface{})
if fixed == nil {
fixed = map[string]interface{}{}
prices["subscriptions"] = fixed
}
cur := "USD"
if rule.Currency != "" {
cur = rule.Currency
}
fixed[srcName] = map[string]interface{}{
"monthly": rule.Subscription, "currency": cur,
}
return nil
case "unpriced":
// Deliberately left out of `sources` so the plugin's unpriced_models /
// unpriced_reqs counters catch it. That is the point: a subscription
// plan whose credits cannot be converted to tokens must be VISIBLE as
// unbilled, not quietly estimated.
return nil
case "token":
if len(rule.Models) == 0 {
return fmt.Errorf("rule for url %q: mode token requires at least one model", rule.URL)
}
models := prices["models"].(map[string]interface{})
for modelID, t := range rule.Models {
entry := map[string]interface{}{}
prompt, err := usdPerM(t.Prompt)
if err != nil {
return fmt.Errorf("model %q prompt: %w", modelID, err)
}
completion, err := usdPerM(t.Completion)
if err != nil {
return fmt.Errorf("model %q completion: %w", modelID, err)
}
entry["prompt"] = prompt
entry["completion"] = completion
if t.CacheDiscount != nil {
entry["cache_discount"] = *t.CacheDiscount
}
// reasoning is optional and stays ABSENT (not nil, not 0) when the
// rule does not set it, which is what makes the billing plugin skip
// the repricing branch entirely. Writing an explicit 0 here would
// mean "thinking is free" and silently under-bill every request
// from a provider that bills it like ordinary output.
if strings.TrimSpace(t.Reasoning) != "" {
rp, err := usdPerM(t.Reasoning)
if err != nil {
return fmt.Errorf("model %q reasoning: %w", modelID, err)
}
entry["reasoning_price"] = rp
}
if lc := t.LongContext; lc != nil {
if lc.Above <= 0 {
return fmt.Errorf("model %q long_context: above must be > 0", modelID)
}
above, err := longContextRate(lc, prompt)
if err != nil {
return fmt.Errorf("model %q long_context: %w", modelID, err)
}
entry["long_context"] = map[string]interface{}{
"above": lc.Above,
// Marginal, not blended. The plugin charges the tokens past the
// threshold at this rate and the rest at the base rate, so the
// threshold composes with a cache discount (which applies to the
// cached portion) instead of forcing the plugin to guess how
// the two interact.
"prompt": above,
}
}
if rule.Peak != nil {
entry["peak"] = compilePeak(rule.Peak)
}
models[modelID] = entry
}
// A specific-URL token rule needs a source entry so a model served
// from THIS url but absent from `rule.Models` is still recognised as
// belonging to a priced provider.
//
// A WILDCARD rule ("*") must NOT get one. "*" is a model catalogue — it
// says "these model ids have these prices, wherever they come from" —
// and marking every source priced at {0,0} would make priceFor() set
// priced=true for every model the catalogue does NOT list. Paid
// sources (commandcode, alittokenplan, …) would then record $0 instead
// of showing up in unpriced_reqs, which is precisely the silent
// zero-billing failure this whole plugin is built to make visible.
// The model lookup p.models[payload.model] already marks a listed
// model priced on its own, so the wildcard loses nothing.
if rule.URL != "*" {
if _, ok := prices["sources"].(map[string]interface{})[srcName]; !ok {
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
}
}
}
return nil
}
return fmt.Errorf("unknown mode %q", rule.Mode)
}
// usdPerM converts a USD-per-million string into the per-token rate the plugin
// expects. Delegates to config.ParseFloatUSDPerM so the DSL validator and the
// compiler agree byte-for-byte on what a valid price is — a value that passes
// Validate() but fails here (or vice versa) would be the worst kind of drift.
func usdPerM(s string) (float64, error) {
return config.ParseFloatUSDPerM(s)
}
// compilePeak renders the peak window in the shape the plugin reads:
// { multiplier, windows = { { days = {...}, hours = { {lo,hi}, ... } } } }.
//
// The plugin reads `days` / `hours` pairs. The first version of the deployed
// price table used { start, end, weekdays } — a shape nothing reads — so peak
// traffic was billed at off-peak rates with no error anywhere. Compiling from
// typed config fields removes the chance of writing the wrong key names by hand.
func compilePeak(p *config.BillingPeak) map[string]interface{} {
win := map[string]interface{}{}
if len(p.Weekdays) > 0 {
days := make([]interface{}, 0, len(p.Weekdays))
for _, d := range p.Weekdays {
days = append(days, d)
}
win["days"] = days
}
if len(p.Hours) > 0 {
hours := make([]interface{}, 0, len(p.Hours))
for _, h := range p.Hours {
hours = append(hours, []interface{}{h[0], h[1]})
}
win["hours"] = hours
}
out := map[string]interface{}{"multiplier": p.Multiplier}
if len(win) > 0 {
out["windows"] = []interface{}{win}
}
return out
}
// longContextRate resolves the marginal per-token rate for the tokens ABOVE a
// threshold, given the model's base per-million prompt price.
//
// Two ways to express it are supported because providers publish both: an
// absolute rate (OpenAI quotes \$3.00/M past 200k) and a multiple (DeepSeek
// quotes exactly 2x past 128k). Neither may be missing: a step with no rate is a
// silent no-op, and an operator who wrote one meant something.
func longContextRate(lc *config.BillingLongContext, basePerToken float64) (float64, error) {
hasPrompt := strings.TrimSpace(lc.Prompt) != ""
hasMult := lc.Multiplier > 0
switch {
case hasPrompt && hasMult:
p, err := usdPerM(lc.Prompt)
if err != nil {
return 0, err
}
// Both given: the absolute rate wins, because it is the one the provider
// actually quotes. Silently multiplying instead would produce a number
// the operator never wrote.
return p, nil
case hasPrompt:
return usdPerM(lc.Prompt)
case hasMult:
return basePerToken * lc.Multiplier, nil
default:
return 0, fmt.Errorf("needs either prompt or multiplier")
}
}

View File

@ -1,214 +0,0 @@
package billing
import (
"testing"
"llmsproxy/internal/config"
)
// The wildcard token rule must not blanket-mark every source as priced.
//
// Why this is a hazard and not a style question: priceFor() sets priced=true as
// soon as the request's SOURCE has an entry in the price table, and that flag
// is what keeps a request OUT of unpriced_reqs. A "*" rule is a model
// catalogue — it prices specific model ids wherever they come from — but
// compiling it into a {0,0,0} entry on all 21 sources made every model the
// catalogue does not list look "priced at zero". Paid providers (commandcode,
// alittokenplan, …) then stopped appearing in the unpriced warnings, which is
// exactly the silent zero-billing failure the plugin exists to surface.
func TestWildcardTokenRuleDoesNotMarkEverySourcePriced(t *testing.T) {
sources := []config.Source{
{Name: "free1", BaseURL: "https://free1.example.com/v1"},
{Name: "paid1", BaseURL: "https://paid1.example.com/v1"},
{Name: "paid2", BaseURL: "https://paid2.example.com/v1"},
}
profile := &config.BillingProfile{
ID: "p",
Rules: []config.BillingRule{
{URL: "https://free1.example.com/v1", Mode: "free"},
{
URL: "*", Mode: "token",
Models: map[string]config.BillingToken{
"only-catalogued-model": {Prompt: "1.00", Completion: "2.00"},
},
},
},
}
prices, err := Compile(profile, sources)
if err != nil {
t.Fatalf("compile: %v", err)
}
srcs := prices["sources"].(map[string]interface{})
// The explicitly free source keeps its entry (free is a deliberate 0).
if _, ok := srcs["free1"]; !ok {
t.Error("the explicit free rule lost its source entry")
}
// Paid sources must NOT be marked, or their unlisted models bill $0.
for _, name := range []string{"paid1", "paid2"} {
if _, ok := srcs[name]; ok {
t.Errorf("wildcard token rule marked %q as priced at 0 — its unlisted "+
"models will bill $0 instead of appearing as unpriced", name)
}
}
// The catalogue itself must still be present, or the rule prices nothing.
models := prices["models"].(map[string]interface{})
if _, ok := models["only-catalogued-model"]; !ok {
t.Error("the wildcard rule did not publish its model price")
}
}
// The inverse guard: a token rule for a SPECIFIC url still marks that source,
// because it genuinely declares pricing for the models served from it.
func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) {
sources := []config.Source{
{Name: "mine", BaseURL: "https://mine.example.com/v1"},
{Name: "other", BaseURL: "https://other.example.com/v1"},
}
profile := &config.BillingProfile{
ID: "p",
Rules: []config.BillingRule{{
URL: "https://mine.example.com/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00"},
},
}},
}
prices, err := Compile(profile, sources)
if err != nil {
t.Fatalf("compile: %v", err)
}
srcs := prices["sources"].(map[string]interface{})
if _, ok := srcs["mine"]; !ok {
t.Error("a specific-url token rule must still mark its own source priced")
}
if _, ok := srcs["other"]; ok {
t.Error("an unrelated source was marked priced by a specific-url rule")
}
}
// TestReasoningPriceIsOmittedUnlessSet pins the absence convention for the
// thinking-token rate.
//
// A rule that says nothing about reasoning must produce a price entry with NO
// reasoning_price key, so the billing plugin skips the repricing branch and
// bills thinking at the ordinary output rate — which is what every provider
// actually charges. Writing an explicit 0 instead would read as "thinking is
// free" and silently under-bill every request from that model.
func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) {
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
// Case 1: no reasoning declared → the key must not appear.
p1 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00"},
},
}}}
prices, err := Compile(p1, sources)
if err != nil {
t.Fatal(err)
}
entry := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
if _, present := entry["reasoning_price"]; present {
t.Errorf("reasoning_price present (%v) without a rule; the plugin would treat 0 as free",
entry["reasoning_price"])
}
// Case 2: declared → present, and converted to per-token like every other rate.
p2 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "6.00"},
},
}}}
prices2, err := Compile(p2, sources)
if err != nil {
t.Fatal(err)
}
e2 := prices2["models"].(map[string]interface{})["m"].(map[string]interface{})
rp, ok := e2["reasoning_price"].(float64)
if !ok {
t.Fatalf("reasoning_price missing or wrong type: %#v", e2["reasoning_price"])
}
if rp != 6e-6 {
t.Errorf("reasoning_price = %v, want 6e-6 (USD per TOKEN)", rp)
}
// A malformed rate must be rejected, not silently ignored: an operator who
// typed "6 USD" expects either that value or an error, never a rule that
// looks applied and bills nothing.
p3 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "six dollars"},
},
}}}
if _, err := Compile(p3, sources); err == nil {
t.Error("a non-numeric reasoning rate must fail the compile")
}
}
// TestLongContextStepCompiles pins the long-context step's two accepted forms
// and its rejection of a step that would silently do nothing.
//
// Providers publish both shapes — DeepSeek says "2x past 128k", OpenAI quotes
// an absolute "$3.00/M past 200k" — so both must compile. Neither may be
// missing: a step with no rate is a rule that LOOKS like it handles long
// context and does not, which under-bills without any visible sign.
func TestLongContextStepCompiles(t *testing.T) {
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
compile := func(lc config.BillingLongContext) (map[string]interface{}, error) {
p := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00", LongContext: &lc},
},
}}}
prices, err := Compile(p, sources)
if err != nil {
return nil, err
}
e := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
return e["long_context"].(map[string]interface{}), nil
}
// Absolute rate (OpenAI's published shape).
lc1, err := compile(config.BillingLongContext{Above: 200000, Prompt: "3.00"})
if err != nil {
t.Fatalf("absolute long_context rate rejected: %v", err)
}
if lc1["above"] != 200000 || lc1["prompt"] != 3e-6 {
t.Errorf("absolute step = %#v, want above=200000 prompt=3e-6", lc1)
}
// Multiplier (DeepSeek's shape): 2x of the 1.00/M base.
lc2, err := compile(config.BillingLongContext{Above: 128000, Multiplier: 2})
if err != nil {
t.Fatalf("multiplier long_context rejected: %v", err)
}
if lc2["prompt"] != 2e-6 {
t.Errorf("multiplier step = %#v, want prompt=2e-6", lc2)
}
// Both given: the absolute rate wins, because that is the number the
// provider quotes. Multiplying instead would produce a figure the operator
// never wrote.
lc3, err := compile(config.BillingLongContext{Above: 128000, Prompt: "5.00", Multiplier: 2})
if err != nil {
t.Fatal(err)
}
if lc3["prompt"] != 5e-6 {
t.Errorf("both given = %#v, want the quoted absolute rate 5e-6", lc3)
}
// Neither: a no-op step must fail, not compile into a rule that silently
// bills long context at the base rate.
if _, err := compile(config.BillingLongContext{Above: 128000}); err == nil {
t.Error("a step with neither prompt nor multiplier must be rejected")
}
// A zero threshold would make every request "long".
if _, err := compile(config.BillingLongContext{Above: 0, Multiplier: 2}); err == nil {
t.Error("above=0 must be rejected")
}
}

View File

@ -7,8 +7,6 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
@ -21,7 +19,6 @@ type Config struct {
GatewayKeys []string `yaml:"gateway_keys"`
DefaultModel string `yaml:"default_model"` // e.g. "AUTO" or a model id
AdapterDir string `yaml:"adapter_dir"`
PluginDir string `yaml:"plugin_dir,omitempty"` // Lua plugin directory (optional; hooks + WebUI extensions)
RuntimeFile string `yaml:"runtime_file"`
TLSCertFile string `yaml:"tls_cert_file,omitempty"` // PEM cert; when set together with tls_key_file, serve HTTPS
TLSKeyFile string `yaml:"tls_key_file,omitempty"` // PEM private key
@ -30,29 +27,6 @@ type Config struct {
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
// BillingDSL declares per-URL pricing profiles for the billing plugin.
//
// Declarative, because hand-writing the prices JSON was error-prone in ways
// that looked successful: the first PUT went out without the required
// `prices` wrapper and silently REPLACED the accumulated totals with the
// price table; the second used a peak-window shape the plugin does not read,
// so peak traffic was billed at off-peak rates — no error, the bill just
// quietly halved. A schema-checked config file catches both at load.
BillingDSL *BillingDSL `yaml:"billing,omitempty"`
// PluginVisibility overrides the audience a plugin declared for itself,
// keyed by plugin name: "admin" hides a plugin (and its state) from user
// keys, "all" widens one that shipped admin-only, "user,admin" names roles
// explicitly. An absent name leaves the plugin's own declaration in force.
//
// This exists because a plugin author cannot know the operator's policy, and
// the operator should not have to edit a bundled .lua to change one — the
// next upgrade overwrites it.
PluginVisibility map[string]string `yaml:"plugin_visibility,omitempty" json:"plugin_visibility,omitempty"`
// box seals credentials (sources' api_key/headers, keys' key) at rest.
// In-memory values are always plaintext; only the bytes on disk are sealed.
// Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay
// filesystem-free.
box *SecretBox
}
// Defaults applied to any source (YAML or runtime) that leaves a field unset.
@ -73,20 +47,15 @@ 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
// 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"`
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"`
// 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.
@ -158,7 +127,6 @@ func writeDefaultConfig(path string) error {
GatewayKeys: []string{key},
DefaultModel: "AUTO",
AdapterDir: filepath.Join(abs, "adapters"),
PluginDir: filepath.Join(abs, "plugins"),
RuntimeFile: filepath.Join(abs, "runtime.json"),
}
out, err := yaml.Marshal(&cfg)
@ -224,15 +192,10 @@ func RemoveSourceFromYAML(path, name string) error {
// UpsertSourceInYAML adds or updates a source entry in the YAML config file.
// Uses yaml.Node to preserve the rest of the file's comments and formatting.
// When box is non-nil the source's credentials are sealed before writing, so a
// newly added source never lands in the file as plaintext.
func UpsertSourceInYAML(path, name string, src Source, box *SecretBox) error {
func UpsertSourceInYAML(path, name string, src Source) error {
if path == "" {
return fmt.Errorf("config path is empty")
}
if err := sealSource(&src, box); err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
@ -290,17 +253,11 @@ func UpsertSourceInYAML(path, name string, src Source, box *SecretBox) error {
// Save writes the current config back to the YAML file (preserving comments
// via yaml.Node round-trip when possible, or full marshaling as fallback).
// Credentials are sealed on the way out and the in-memory copy is restored to
// plaintext afterwards, so callers keep working with usable values.
func (c *Config) Save() error {
if c.Path == "" {
return fmt.Errorf("config path is empty")
}
if err := c.sealInPlace(c.box); err != nil {
return err
}
out, err := yaml.Marshal(c)
c.unsealAfterWrite(c.box)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
@ -315,9 +272,6 @@ func (c *Config) ApplyDefaults() error {
if c.AdapterDir == "" {
c.AdapterDir = "adapters"
}
// PluginDir is deliberately NOT defaulted: with no plugins a missing
// directory would be created and seeded on every start for nothing. An
// operator opts in by setting it (or by dropping files into it).
if c.RuntimeFile == "" {
c.RuntimeFile = "runtime.json"
}
@ -378,14 +332,13 @@ 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"`
StreamEndpoint string `json:"endpoint_stream,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"`
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"`
@ -399,271 +352,24 @@ 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"`
// 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"`
Key string `yaml:"key" json:"key"`
Role string `yaml:"role" json:"role"`
Name string `yaml:"name,omitempty" json:"name,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
// inheriting the global one. An explicitly empty list still counts as "no own
// chain" so a user can clear an override back to the global default.
func (k GWKey) HasOwnAuto() bool { return len(k.Auto) > 0 }
// BillingDSL holds declarative per-URL pricing profiles for the billing plugin.
//
// Profiles are the "let the user pick" axis: the same upstream URL can be
// declared under multiple profiles, and the operator chooses which is active.
// Switching recomputes and re-injects the billing plugin's prices table.
type BillingDSL struct {
// Active is the id of the profile in effect. Empty => the first profile.
Active string `yaml:"active,omitempty" json:"active,omitempty"`
Profiles []BillingProfile `yaml:"profiles,omitempty" json:"profiles,omitempty"`
}
// BillingProfile is one named set of per-URL pricing rules.
type BillingProfile struct {
ID string `yaml:"id" json:"id"`
Label string `yaml:"label,omitempty" json:"label,omitempty"`
// Default applies when no rule matches: "free" or "unpriced".
Default string `yaml:"default,omitempty" json:"default,omitempty"`
Rules []BillingRule `yaml:"rules,omitempty" json:"rules,omitempty"`
}
// BillingRule declares the pricing for everything under one URL.
type BillingRule struct {
// URL matches a source's base_url. "*" = any unmatched URL.
URL string `yaml:"url" json:"url"`
// Mode is one of: free, token, subscription, unpriced.
Mode string `yaml:"mode" json:"mode"`
Currency string `yaml:"currency,omitempty" json:"currency,omitempty"`
// Subscription is the flat monthly cost (for mode=subscription).
Subscription float64 `yaml:"subscription,omitempty" json:"subscription,omitempty"`
// Models declares per-token prices when mode=token.
Models map[string]BillingToken `yaml:"models,omitempty" json:"models,omitempty"`
// Free is true when mode=free (kept for explicitness).
// Peak applies to ALL token-priced requests under this URL.
Peak *BillingPeak `yaml:"peak,omitempty" json:"peak,omitempty"`
}
// BillingToken is the per-model price.
type BillingToken struct {
Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15"
Completion string `yaml:"completion" json:"completion"` // USD per million tokens
CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"`
// Reasoning is the USD-per-million rate for thinking tokens, for the rare
// provider that charges them differently from ordinary output. Omit it and
// reasoning is billed at Completion — which is what OpenAI, DeepSeek and
// Anthropic all do, and what every source on this deployment reports.
//
// It is a rate for a SUBSET of completion tokens, never an extra charge: the
// billing plugin applies only the difference between this and Completion, so
// setting it to a multiple of Completion reprices the thinking share rather
// than billing it twice.
Reasoning string `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
// LongContext is the prompt-price step above a token threshold. Nil = one
// flat rate, which is what a rule means when it says nothing.
LongContext *BillingLongContext `yaml:"long_context,omitempty" json:"long_context,omitempty"`
}
// BillingPeak declares peak-hour pricing.
type BillingPeak struct {
Multiplier float64 `yaml:"multiplier" json:"multiplier"`
// Weekdays: 1=Mon..5=Fri (Lua os.date !%w, 0=Sun)
Weekdays []int `yaml:"weekdays,omitempty" json:"weekdays,omitempty"`
// Hours: each pair is [lo,hi] inclusive
Hours [][2]int `yaml:"hours,omitempty" json:"hours,omitempty"`
}
// BillingLongContext is a prompt-price step for long inputs.
//
// Every major provider charges the input tokens ABOVE a threshold at a higher
// rate — DeepSeek doubles past 128k, OpenAI and Anthropic step up as well. A
// flat prompt price therefore under-bills exactly the traffic an agent workload
// generates, and it under-bills it silently: the number is plausible, the bill
// adds up, and nothing says the rate was wrong.
//
// The step applies to the PROMPT only. Output and cache reads are billed at
// their own rates regardless of input length, which is how providers publish
// it — the higher rate is for the extra context you made the model read, not for
// what it wrote back.
type BillingLongContext struct {
// Above is the token count the step starts at. A request with exactly this
// many prompt tokens is still at the base rate; the step covers the tokens
// BEYOND it. Inclusive-below matches how the providers document it.
Above int `yaml:"above" json:"above"`
// Prompt is the USD-per-million rate for the tokens above Above.
Prompt string `yaml:"prompt" json:"prompt"`
// Multiplier is an alternative to Prompt: a factor applied to the base rate
// (DeepSeek's published step is exactly 2x). When both are set Prompt wins,
// and setting neither is a validation error rather than a silent no-op.
Multiplier float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
}
// Validate rejects a BillingDSL that could not work as written.
func (b *BillingDSL) Validate() error {
if b == nil {
return nil
}
ids := map[string]bool{}
for i := range b.Profiles {
p := &b.Profiles[i]
if p.ID == "" {
return fmt.Errorf("billing profile [%d] has no id", i)
}
if ids[p.ID] {
return fmt.Errorf("billing profile id %q appears more than once", p.ID)
}
ids[p.ID] = true
if p.Default != "" && p.Default != "free" && p.Default != "unpriced" {
return fmt.Errorf("profile %q: default must be free or unpriced, got %q", p.ID, p.Default)
}
for j := range p.Rules {
r := &p.Rules[j]
if r.URL == "" {
return fmt.Errorf("profile %q rule [%d]: url is required", p.ID, j)
}
switch r.Mode {
case "free", "token", "subscription", "unpriced":
default:
return fmt.Errorf("profile %q rule [%d]: mode must be free/token/subscription/unpriced, got %q", p.ID, j, r.Mode)
}
if r.Mode == "token" {
for m, t := range r.Models {
if t.Prompt == "" || t.Completion == "" {
return fmt.Errorf("profile %q model %q: prompt and completion are required for token mode", p.ID, m)
}
if _, err := ParseFloatUSDPerM(t.Prompt); err != nil {
return fmt.Errorf("profile %q model %q prompt: %w", p.ID, m, err)
}
if _, err := ParseFloatUSDPerM(t.Completion); err != nil {
return fmt.Errorf("profile %q model %q completion: %w", p.ID, m, err)
}
}
}
}
}
// Active must resolve to a profile id.
if b.Active != "" && !ids[b.Active] {
return fmt.Errorf("billing.active = %q but no profile has that id", b.Active)
}
return nil
}
// Resolve returns the profile with the given id; empty id (or an id that does
// not exist) falls back to the FIRST profile. A config with profiles but no
// active marker should mean "use the one I wrote first" rather than "price
// nothing", which is what a nil here would silently do.
func (b *BillingDSL) Resolve(id string) *BillingProfile {
if b == nil || len(b.Profiles) == 0 {
return nil
}
if id != "" {
for i := range b.Profiles {
if b.Profiles[i].ID == id {
return &b.Profiles[i]
}
}
}
return &b.Profiles[0]
}
// parseFloatUSDPerM parses a USD-per-million price string. Returns the
// per-single-token rate the plugin expects (price / 1e6).
//
// Kept as a string in the DSL rather than a float: "0.15" survives round-trips
// through YAML editors, and a malformed value must fail VALIDATION (with the
// file and field named) rather than parse as 0 and under-bill silently — which
// is exactly the failure mode that motivated the DSL.
func ParseFloatUSDPerM(s string) (float64, error) {
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return 0, fmt.Errorf("not a number: %q", s)
}
if f < 0 {
return 0, fmt.Errorf("negative price: %q", s)
}
return f / 1e6, nil
}
// KeyQuota is retained only to carry a scope entry's caps through the admin
// API. Quotas are per model, never per key: there is deliberately no key-wide
// total, so exhausting one model's budget never blocks the others.
type KeyQuota struct {
TokenQuota int64 `json:"token_quota"`
ReqQuota int64 `json:"req_quota"`
Period string `json:"period"`
Hours int64 `json:"hours"`
}
// NormalizeRole defaults an empty role to "user", so a key can never end up in
// a state where no role means "neither admin nor user".
func NormalizeRole(role string) string {
if role == "admin" {
return "admin"
}
return "user"
}
// Validate rejects a quota configuration that could not work as written. A
// period is only meaningful when at least one cap is set, and a cap of zero
// means "unlimited" rather than "deny everything", so those are the only two
// things worth rejecting.
func (q KeyQuota) Validate() error {
if q.TokenQuota < 0 {
return fmt.Errorf("token_quota must be >= 0 (0 = unlimited)")
}
if q.ReqQuota < 0 {
return fmt.Errorf("req_quota must be >= 0 (0 = unlimited)")
}
if q.Hours < 0 {
return fmt.Errorf("hours must be >= 0")
}
if q.TokenQuota > 0 || q.ReqQuota > 0 {
if err := ValidatePeriod(q.Period, q.Hours); err != nil {
return err
}
}
return nil
}
// ValidatePeriod accepts the quota period vocabulary: "" (never resets),
// "hour", "week", "month", or "nhour" with hours >= 1. An unknown period is
// rejected rather than silently treated as "never resets", which would turn a
// typo into an all-time quota — the opposite of what the operator typed.
func ValidatePeriod(period string, hours int64) error {
switch period {
case "", "hour", "week", "month":
return nil
case "nhour":
if hours < 1 {
return fmt.Errorf("period %q needs hours >= 1", period)
}
return nil
}
return fmt.Errorf("period must be one of \"\", hour, week, month, nhour (got %q)", period)
}
// ModelScope is one allowed model for a key, or one AUTO scheduling slot.
// Its TokenQuota and ReqQuota cap THAT entry only, independently of every
// other entry on the same key: a model that runs out of budget stops being
// served while the key's other models keep working. TokenQuota 0 / ReqQuota 0
// = unlimited. Period "" = never resets; "hour"/"week"/"month" are fixed
// windows; "nhour" uses Hours.
// ModelScope is one allowed model for a key, or one AUTO scheduling slot,
// with an optional token quota and reset period. TokenQuota 0 = unlimited;
// Period "" = never resets; "hour"/"week"/"month" are fixed windows; "nhour"
// uses Hours as the window length in hours.
type ModelScope struct {
Model string `yaml:"model" json:"model"`
Source string `yaml:"source,omitempty" json:"source,omitempty"` // optional: pin to one upstream source; "" = any source
Tier int `yaml:"tier,omitempty" json:"tier,omitempty"`
TokenQuota int64 `yaml:"token_quota" json:"token_quota"`
ReqQuota int64 `yaml:"req_quota,omitempty" json:"req_quota,omitempty"`
Period string `yaml:"period,omitempty" json:"period,omitempty"`
Hours int64 `yaml:"hours,omitempty" json:"hours,omitempty"`
}
@ -682,7 +388,6 @@ func (m *ModelScope) UnmarshalJSON(b []byte) error {
Source string `json:"source"`
Tier int `json:"tier"`
TokenQuota int64 `json:"token_quota"`
ReqQuota int64 `json:"req_quota"`
Period string `json:"period"`
Hours int64 `json:"hours"`
}
@ -693,7 +398,6 @@ func (m *ModelScope) UnmarshalJSON(b []byte) error {
m.Source = o.Source
m.Tier = o.Tier
m.TokenQuota = o.TokenQuota
m.ReqQuota = o.ReqQuota
m.Period = o.Period
m.Hours = o.Hours
return nil

View File

@ -1,317 +0,0 @@
package config
// Secret handling for config.yaml.
//
// config.yaml is 0644 world-readable by design (ops need to inspect it), so any
// credential in it must not sit there in plaintext. Sources' api_key / headers
// and gateway keys are therefore sealed at rest with the same SecretBox used by
// the runtime store, and unsealed in memory at load time.
//
// The invariant that makes this safe: **in-memory values are always plaintext**,
// and the enc:v1: prefix is what marks a file value as sealed. Read paths that
// predate this (core.resolveSourceKey) already unseal, so only the write side and
// the load-time normalize step are new.
import (
"fmt"
"log"
"os"
"strings"
)
// sealSource seals one source's credentials in place (used by the YAML upsert
// path, which is a package function and therefore has no Config to borrow a box
// from).
func sealSource(s *Source, box *SecretBox) error {
if box == nil {
return nil
}
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
v, err := box.Encrypt(s.APIKey)
if err != nil {
return err
}
s.APIKey = v
}
for k, v := range s.Headers {
if v == "" || strings.HasPrefix(v, encPrefix) {
continue
}
e, err := box.Encrypt(v)
if err != nil {
return err
}
s.Headers[k] = e
}
return nil
}
// normalizeSecrets unseals every credential in the freshly parsed config so the
// rest of the program only ever sees plaintext. A value without the enc:v1:
// prefix is left untouched, which keeps hand-written plaintext configs working
// (and is what a pre-encryption config file looks like).
//
// A value that carries the prefix but fails to decrypt is a hard error, not
// something to paper over: returning the ciphertext (MustDecrypt's behavior)
// would let the next Save re-seal it and turn one bad value into permanent,
// compounding corruption. Losing the master key must be loud.
func (c *Config) normalizeSecrets(box *SecretBox) error {
if box == nil {
return nil
}
for i := range c.Sources {
s := &c.Sources[i]
if strings.HasPrefix(s.APIKey, encPrefix) {
v, err := box.Decrypt(s.APIKey)
if err != nil {
return fmt.Errorf("source %q api_key: %w", s.Name, err)
}
s.APIKey = v
}
for k, v := range s.Headers {
if strings.HasPrefix(v, encPrefix) {
d, err := box.Decrypt(v)
if err != nil {
return fmt.Errorf("source %q header %q: %w", s.Name, k, err)
}
s.Headers[k] = d
}
}
}
for i := range c.Keys {
if strings.HasPrefix(c.Keys[i].Key, encPrefix) {
v, err := box.Decrypt(c.Keys[i].Key)
if err != nil {
return fmt.Errorf("gateway key %q: %w", c.Keys[i].Name, err)
}
c.Keys[i].Key = v
}
}
return nil
}
// sealInPlace replaces plaintext credentials with ciphertext for writing. It is
// deliberately a separate step from Marshal: callers that need the plaintext
// (auth comparisons, log output, returning a key to the operator who just
// created it) must not be handed a sealed config by accident.
func (c *Config) sealInPlace(box *SecretBox) error {
if box == nil {
return nil
}
for i := range c.Sources {
s := &c.Sources[i]
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
v, err := box.Encrypt(s.APIKey)
if err != nil {
return err
}
s.APIKey = v
}
if len(s.Headers) > 0 {
sealed := make(map[string]string, len(s.Headers))
for k, v := range s.Headers {
if v == "" || strings.HasPrefix(v, encPrefix) {
sealed[k] = v
continue
}
e, err := box.Encrypt(v)
if err != nil {
return err
}
sealed[k] = e
}
s.Headers = sealed
}
}
for i := range c.Keys {
k := &c.Keys[i]
if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) {
v, err := box.Encrypt(k.Key)
if err != nil {
return err
}
k.Key = v
}
}
return nil
}
// unsealAfterWrite restores plaintext after a sealed marshal so the live process
// keeps working on plaintext values (mirrors Store.persistLocked's dance).
func (c *Config) unsealAfterWrite(box *SecretBox) {
// Save just encrypted every value it can see, so a failure here is
// impossible; ignore the error rather than panic in a write path.
_ = c.normalizeSecrets(box)
}
// hasPlaintextSecrets reports whether any credential in the config is still in
// the clear. Used to decide whether a startup migration write is needed, and to
// warn (without leaking values) when no master key is available.
func (c *Config) hasPlaintextSecrets() bool {
for _, s := range c.Sources {
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
return true
}
for _, v := range s.Headers {
if v != "" && !strings.HasPrefix(v, encPrefix) {
return true
}
}
}
for _, k := range c.Keys {
if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) {
return true
}
}
return false
}
// countPlaintextSecrets returns how many credentials are still in the clear, for
// an operator-facing migration log line that must not print the values.
func (c *Config) countPlaintextSecrets() int {
n := 0
for _, s := range c.Sources {
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
n++
}
for _, v := range s.Headers {
if v != "" && !strings.HasPrefix(v, encPrefix) {
n++
}
}
}
for _, k := range c.Keys {
if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) {
n++
}
}
return n
}
// NormalizeSecretsForRun unseals the loaded config and then seals it back on
// disk if anything was still in the clear. Order matters: Load() read the file
// with ciphertext still in place, so the unseal has to happen before the
// registry (and any Save the startup path performs) sees the values.
func (c *Config) NormalizeSecretsForRun(box *SecretBox) error {
hadPlaintext, err := c.UnsealSecrets(box)
if err != nil {
return err
}
return c.SealIfNeeded(hadPlaintext)
}
// UnsealSecrets decrypts every sealed credential in memory and reports whether
// the config ON DISK still held plaintext (i.e. whether a sealing write is
// needed). It never writes; a failed decrypt (wrong master key) is returned so
// the process refuses to start instead of running with unusable credentials.
//
// The return value must be computed BEFORE unsealing and from the disk state,
// not from memory: after a Save the in-memory values are always plaintext, so a
// "is anything plaintext?" test run afterwards is unconditionally true and a
// caller would rewrite the file on every start. That was the actual behaviour -
// migratePlaintextSecrets() claimed to be idempotent in a comment but rewrote
// config.yaml on every boot.
//
// Callers that consume credentials (the provider registry, key seeding) must
// unseal FIRST. Seeding compares cfg.Keys[i].Key against the plaintext
// gateway_keys entries; running it while keys are still ciphertext made the
// dedupe never match, so every restart appended another copy of the same admin
// key (production accumulated four).
func (c *Config) UnsealSecrets(box *SecretBox) (bool, error) {
if box == nil {
return false, nil
}
c.AttachSecretBox(box)
hadPlaintext := c.hasPlaintextSecrets()
if err := c.normalizeSecrets(box); err != nil {
return hadPlaintext, err
}
return hadPlaintext, nil
}
// SealIfNeeded writes the config back once if hadPlaintext reported that the
// file still held clear-text credentials. When it is false the file is left
// untouched, which is what makes startup a no-op for an already-sealed config.
func (c *Config) SealIfNeeded(hadPlaintext bool) error {
if !hadPlaintext || c.box == nil || c.Path == "" {
return nil
}
n := c.countPlaintextSecrets()
if err := c.Save(); err != nil {
return err
}
log.Printf("[config] sealed %d plaintext credential(s) in %s", n, c.Path)
return nil
}
// AttachSecretBox wires the encryption box into the config so Save can seal
// credentials. Kept as an explicit call (rather than a constructor argument) so
// config.Load stays usable in contexts that have no filesystem secrets (tests,
// `-check`).
func (c *Config) AttachSecretBox(box *SecretBox) { c.box = box }
// SecretBox returns the wired encryption box, or nil when none is attached.
func (c *Config) SecretBox() *SecretBox { return c.box }
// migratePlaintextSecrets seals any credential still in the clear and writes the
// file once. Kept for callers that attach the box themselves; it decides from
// the in-memory state, which is why UnsealSecrets + SealIfNeeded (which decide
// from the on-disk state) are preferred on the startup path.
func (c *Config) migratePlaintextSecrets() error {
if c.box == nil || c.Path == "" {
return nil
}
if !c.hasPlaintextSecrets() {
return nil
}
n := c.countPlaintextSecrets()
if err := c.Save(); err != nil {
return err
}
log.Printf("[config] sealed %d plaintext credential(s) in %s", n, c.Path)
return nil
}
// PrintSecrets writes the config's credentials to stdout in the clear and
// returns an error only when the file cannot be read or the master key does not
// match. It is the operator counterpart to sealing-at-rest: with keys stored as
// ciphertext, "which key is this source using" must still be answerable.
//
// It deliberately takes a path rather than a *Config so the caller cannot
// accidentally hand it a config that has already been unsealed in memory, and it
// never writes anything.
func PrintSecrets(path string) error {
cfg, err := Load(path)
if err != nil {
return err
}
box, err := NewSecretBox(cfg.RuntimeFile)
if err != nil {
return fmt.Errorf("%w (is master.key present and intact?)", err)
}
if err := cfg.normalizeSecrets(box); err != nil {
return err
}
w := os.Stdout
fmt.Fprintf(w, "# %s — %d source(s), %d gateway key(s)\n", path, len(cfg.Sources), len(cfg.Keys))
for _, s := range cfg.Sources {
fmt.Fprintf(w, "source %-16s api_key=%s\n", s.Name, orNone(s.APIKey))
for k, v := range s.Headers {
if v != "" {
fmt.Fprintf(w, "source %-16s header[%s]=%s\n", s.Name, k, v)
}
}
}
for _, k := range cfg.Keys {
fmt.Fprintf(w, "key %-16s role=%-5s %s\n", k.Name, k.Role, k.Key)
}
fmt.Fprintf(w, "gateway_keys (legacy): %s\n", strings.Join(cfg.GatewayKeys, " "))
return nil
}
func orNone(s string) string {
if s == "" {
return "(unset)"
}
return s
}

View File

@ -1,323 +0,0 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// writeMasterKey plants a deterministic master.key next to the given runtime
// file so tests exercise the real box instead of the env-var shortcut.
func writeMasterKey(t *testing.T, runtimeFile string) *SecretBox {
t.Helper()
if err := os.WriteFile(filepath.Join(filepath.Dir(runtimeFile), "master.key"),
[]byte(strings.Repeat("ab", 32)), 0600); err != nil {
t.Fatal(err)
}
box, err := NewSecretBox(runtimeFile)
if err != nil {
t.Fatalf("NewSecretBox: %v", err)
}
return box
}
func TestSaveSealsCredentialsAndLoadUnseals(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
runtime := filepath.Join(dir, "runtime.json")
box := writeMasterKey(t, runtime)
cfg := &Config{
Path: path,
Listen: "127.0.0.1:0",
Sources: []Source{{
Name: "up",
BaseURL: "http://up/v1",
APIKey: "sk-plaintext-secret",
Adapter: "openai",
Headers: map[string]string{"X-Extra": "header-secret"},
Models: []Model{{ID: "m", Kind: "chat"}},
}},
Keys: []GWKey{{Key: "sk-gw-plain", Role: "admin", Name: "admin"}},
}
cfg.AttachSecretBox(box)
if err := cfg.Save(); err != nil {
t.Fatalf("Save: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(raw)
if strings.Contains(text, "sk-plaintext-secret") {
t.Error("source api_key written in plaintext")
}
if strings.Contains(text, "header-secret") {
t.Error("source header written in plaintext")
}
if strings.Contains(text, "sk-gw-plain") {
t.Error("gateway key written in plaintext")
}
if n := strings.Count(text, encPrefix); n != 3 {
t.Errorf("expected 3 sealed values, found %d", n)
}
// The live in-memory config must still hold plaintext after Save.
if cfg.Sources[0].APIKey != "sk-plaintext-secret" {
t.Errorf("in-memory api_key mutated by Save: %q", cfg.Sources[0].APIKey)
}
if cfg.Keys[0].Key != "sk-gw-plain" {
t.Errorf("in-memory gateway key mutated by Save: %q", cfg.Keys[0].Key)
}
// A fresh load must hand back plaintext again.
loaded, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
loaded.AttachSecretBox(box)
loaded.normalizeSecrets(box)
if loaded.Sources[0].APIKey != "sk-plaintext-secret" {
t.Errorf("loaded api_key = %q, want plaintext", loaded.Sources[0].APIKey)
}
if loaded.Sources[0].Headers["X-Extra"] != "header-secret" {
t.Errorf("loaded header = %q, want plaintext", loaded.Sources[0].Headers["X-Extra"])
}
if loaded.Keys[0].Key != "sk-gw-plain" {
t.Errorf("loaded gateway key = %q, want plaintext", loaded.Keys[0].Key)
}
}
func TestPlaintextConfigStillLoads(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
// A hand-written, pre-encryption config: no enc:v1: anywhere.
body := `listen: 127.0.0.1:0
sources:
- name: legacy
base_url: http://legacy/v1
api_key: sk-written-by-hand
adapter: openai
models:
- id: m
kind: chat
`
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
box := writeMasterKey(t, filepath.Join(dir, "runtime.json"))
cfg.AttachSecretBox(box)
cfg.normalizeSecrets(box)
if cfg.Sources[0].APIKey != "sk-written-by-hand" {
t.Errorf("plaintext config value changed: %q", cfg.Sources[0].APIKey)
}
}
// Sealing a config is a one-time migration: the FIRST run writes, every later
// start must leave the file alone.
//
// This test previously called migratePlaintextSecrets() twice and compared
// ModTime. That assertion was both weak and wrong:
//
// - migratePlaintextSecrets decides from IN-MEMORY state, and Save() ends by
// unsealing memory so the running process keeps working. So every call sees
// "plaintext present" and rewrites the file. Comparing ModTime hid this
// because two writes inside one filesystem timestamp tick look identical —
// the test flaked (~1 in 6) instead of failing.
// - it also exercised a function production no longer calls on the startup
// path, which now uses UnsealSecrets + SealIfNeeded (they decide from the
// ON-DISK state).
//
// So the test targets that real path and compares file CONTENT, which cannot be
// fooled by timestamp granularity.
func TestSealingIsIdempotentAcrossStarts(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
runtime := filepath.Join(dir, "runtime.json")
box := writeMasterKey(t, runtime)
cfg := &Config{
Path: path,
Listen: "127.0.0.1:0",
Sources: []Source{{Name: "up", BaseURL: "http://up/v1", APIKey: "sk-clear", Adapter: "openai", Models: []Model{{ID: "m"}}}},
}
cfg.AttachSecretBox(box)
hadPlaintext, err := cfg.UnsealSecrets(box)
if err != nil {
t.Fatalf("unseal: %v", err)
}
if !hadPlaintext {
t.Fatal("a config written with a clear-text credential must report plaintext")
}
if err := cfg.SealIfNeeded(hadPlaintext); err != nil {
t.Fatalf("seal: %v", err)
}
first, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(first), encPrefix) {
t.Fatal("sealing did not encrypt the value on disk")
}
// In-memory must be plaintext so the running process keeps working.
if cfg.Sources[0].APIKey != "sk-clear" {
t.Errorf("in-memory api_key = %q, want plaintext", cfg.Sources[0].APIKey)
}
// Simulate the next start: load from disk, unseal, seal-if-needed. The file
// was already sealed, so nothing may be written.
cfg2, err := Load(path)
if err != nil {
t.Fatal(err)
}
cfg2.AttachSecretBox(box)
hadPlaintext2, err := cfg2.UnsealSecrets(box)
if err != nil {
t.Fatalf("second unseal: %v", err)
}
if hadPlaintext2 {
t.Error("a sealed config reported plaintext — SealIfNeeded would rewrite " +
"the file on every start")
}
if err := cfg2.SealIfNeeded(hadPlaintext2); err != nil {
t.Fatalf("second seal: %v", err)
}
second, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(first) != string(second) {
t.Error("the second start rewrote an already-sealed config; startup must be a no-op")
}
}
func TestSealedValueDoesNotDoubleEncrypt(t *testing.T) {
dir := t.TempDir()
box := writeMasterKey(t, filepath.Join(dir, "runtime.json"))
cfg := &Config{Path: filepath.Join(dir, "config.yaml"), Sources: []Source{{Name: "a", BaseURL: "http://a"}}}
cfg.AttachSecretBox(box)
once, err := box.Encrypt("sk-x")
if err != nil {
t.Fatal(err)
}
cfg.Sources[0].APIKey = once
if err := cfg.Save(); err != nil {
t.Fatal(err)
}
got, err := box.Decrypt(cfg.Sources[0].APIKey)
if err != nil {
t.Fatalf("value became undecryptable: %v", err)
}
if got != "sk-x" {
t.Errorf("round trip = %q, want sk-x", got)
}
}
func TestUpsertSourceInYAMLSealsCredentials(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
box := writeMasterKey(t, filepath.Join(dir, "runtime.json"))
if err := os.WriteFile(path, []byte("listen: 127.0.0.1:0\nsources: []\n"), 0644); err != nil {
t.Fatal(err)
}
src := Source{Name: "new", BaseURL: "http://new/v1", APIKey: "sk-fresh", Adapter: "openai", Models: []Model{{ID: "m"}}}
if err := UpsertSourceInYAML(path, src.Name, src, box); err != nil {
t.Fatalf("UpsertSourceInYAML: %v", err)
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), "sk-fresh") {
t.Error("newly added source stored its api_key in plaintext")
}
if !strings.Contains(string(raw), encPrefix) {
t.Error("newly added source was not sealed")
}
}
func TestCountPlaintextSecrets(t *testing.T) {
cfg := &Config{
Sources: []Source{
{Name: "a", APIKey: encPrefix + "abc", Headers: map[string]string{"H": encPrefix + "x"}},
{Name: "b", APIKey: "sk-in-clear", Headers: map[string]string{"H2": "clear-too"}},
},
Keys: []GWKey{{Key: "sk-gw-clear"}},
}
if got := cfg.countPlaintextSecrets(); got != 3 {
t.Errorf("countPlaintextSecrets = %d, want 3", got)
}
if !cfg.hasPlaintextSecrets() {
t.Error("hasPlaintextSecrets = false, want true")
}
cfg.Sources[1].APIKey = encPrefix + "y"
cfg.Sources[1].Headers["H2"] = encPrefix + "z"
cfg.Keys[0].Key = encPrefix + "k"
if cfg.hasPlaintextSecrets() {
t.Error("hasPlaintextSecrets = true after sealing everything")
}
}
// TestNormalizeSecretsFailsLoudlyOnWrongMasterKey is the negative case that
// guards the migration: with a wrong key, a sealed value must NOT be handed
// back as ciphertext for a later re-seal (that compounds corruption and turns
// one lost key file into permanently unusable config).
func TestNormalizeSecretsFailsLoudlyOnWrongMasterKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
runtime := filepath.Join(dir, "runtime.json")
good := writeMasterKey(t, runtime)
cfg := &Config{
Path: path,
Listen: "127.0.0.1:0",
Sources: []Source{{Name: "up", BaseURL: "http://up/v1", APIKey: "sk-secret", Adapter: "openai", Models: []Model{{ID: "m"}}}},
}
cfg.AttachSecretBox(good)
if err := cfg.Save(); err != nil {
t.Fatal(err)
}
sealedOnce, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
// A different master key must be rejected, loudly. NewSecretBox derives the
// key path from the RUNTIME file's directory, so the wrong key has to live
// in a different directory — otherwise it would read the good master.key.
otherDir := t.TempDir()
if err := os.WriteFile(filepath.Join(otherDir, "master.key"),
[]byte(strings.Repeat("cd", 32)), 0600); err != nil {
t.Fatal(err)
}
badBox, err := NewSecretBox(filepath.Join(otherDir, "runtime.json"))
if err != nil {
t.Fatal(err)
}
loaded, err := Load(path)
if err != nil {
t.Fatal(err)
}
loaded.AttachSecretBox(badBox)
if err := loaded.NormalizeSecretsForRun(badBox); err == nil {
t.Fatal("expected an error with the wrong master key, got nil")
}
// Crucially: the file must be untouched — no second seal on top.
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(after) != string(sealedOnce) {
t.Error("config file was rewritten despite the decrypt failure")
}
if n := strings.Count(string(after), encPrefix); n != strings.Count(string(sealedOnce), encPrefix) {
t.Errorf("sealed count changed %d -> %d (double encryption)", strings.Count(string(sealedOnce), encPrefix), n)
}
}

View File

@ -1,102 +0,0 @@
package core
import (
"testing"
)
// account_adopt:把升级前的**非池**凭据接管进账号池。
//
// 为什么需要它:v2.0.x 之前登录只写一条 `channels:<plat>`,引入账号池后凭据落在
// `channels:<plat>:<aid>`。升级后老凭据仍在保险库、但池里没有账号引用它 ——
// 表现为"账号消失了"(数据其实还在)。让用户重新登录是**可见的额外代价**。
//
// ⚠️ 本判据同时钉住一条操作纪律:**接管必须走插件的保险库 API**(读出的是明文,
// 写回时插件自己加密)。绝不要把保险库文件里的密文当明文喂给 API ——
// 保险库读取时会解密,那样会把凭据**二次加密**(实测踩过:1971 → 2965 字符,
// 该账号推理开始返回空)。
func TestAccountAdoptLegacyCredential(t *testing.T) {
_, ps := poolCore(t)
const legacy = `{"access_token":"LEGACY-TOK","refresh_token":"RT","nickname":"老账号"}`
// 造一条"旧版"非池凭据(模拟升级前留下的)
if _, err := ps.Call("channels", "__set_legacy_cred_probe", map[string]interface{}{
"platform": "buddy", "credential": legacy,
}); err != nil {
t.Fatalf("造旧凭据: %v", err)
}
// 接管进一个新池账号
m, err := ps.Call("channels", "account_adopt", map[string]interface{}{
"platform": "buddy", "account_id": "adopted1",
})
if err != nil {
t.Fatalf("account_adopt: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["ok"] != true {
t.Fatalf("接管失败: %#v", m)
}
// ① 池里必须出现该账号,且**有凭据**
lm, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"})
lj, _ := lm.(map[string]interface{})["json"].(map[string]interface{})
found := false
for _, a := range asArrInterface(lj["accounts"]) {
if am, ok := a.(map[string]interface{}); ok && am["id"] == "adopted1" {
found = true
if am["has_credential"] != true {
t.Errorf("接管后应 has_credential=true,实得 %v", am["has_credential"])
}
}
}
if !found {
t.Fatalf("接管后账号未出现在池里: %#v", lj["accounts"])
}
// ② 凭据必须是**原文**(未被二次加密)——这是本判据的核心
out, err := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
"ref": "channels:buddy:adopted1",
})
if err != nil {
t.Fatalf("读凭据: %v", err)
}
rj, _ := out.(map[string]interface{})["json"].(map[string]interface{})
got, _ := rj["credential"].(string)
if got != legacy {
t.Errorf("接管后的凭据应与原文逐字节相同(不得二次加密)\n 期望: %s\n 实得: %s",
legacy, got)
}
if rj["len"] != nil {
if n, _ := rj["len"].(float64); int(n) != len(legacy) {
t.Errorf("凭据长度 %v,应为 %d(长度变化说明被二次加密)", n, len(legacy))
}
}
// ③ 补齐了展示字段(昵称从凭据里取)
for _, a := range asArrInterface(lj["accounts"]) {
if am, ok := a.(map[string]interface{}); ok && am["id"] == "adopted1" {
if am["nickname"] != "老账号" {
t.Errorf("昵称应从凭据补齐为「老账号」,实得 %v", am["nickname"])
}
}
}
// ④ 没有旧凭据时必须如实报错(不能假装成功)
_, _ = ps.Call("channels", "__clear_all_creds_probe", nil)
m2, _ := ps.Call("channels", "account_adopt", map[string]interface{}{
"platform": "buddy", "account_id": "adopted2",
})
j2, _ := m2.(map[string]interface{})["json"].(map[string]interface{})
if j2["ok"] == true {
t.Error("没有旧凭据时应如实失败,不能假装成功")
}
}
// asArrInterface 宽松取数组(插件桥回传的可能是 []interface{})。
func asArrInterface(v interface{}) []interface{} {
if a, ok := v.([]interface{}); ok {
return a
}
return nil
}

View File

@ -1,105 +0,0 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
// `account_test` 必须**真打一次上游**。
//
// 真实缺陷:此前它一个网络请求都不发 —— 只 syncAccountSource 后查内核源列表里
// 有没有那条记录,就返回 {ok:true, ms:1}。于是"测试账号"永远绿,包括凭据已失效、
// 被限流、上游宕机的情况。这正是「看着能用、一用就错」的假阳性,也正是用户报
// 「完全不可用」时最容易误导人的那种信号。
//
// 判据把三条都钉住:① 真发请求 ② 上游失败时必须 ok=false ③ 失败要区分
// "凭据失效"(需重新登录)与"暂时性失败"(可重试)—— 只说"失败"用户不知道该做什么。
func TestAccountTestReallyProbesUpstream(t *testing.T) {
var hits int32
fail := false
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "application/json")
if fail {
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"error":"invalid_grant: token expired"}`))
return
}
body, _ := json.Marshal(map[string]interface{}{
"data": map[string]interface{}{"Response": map[string]interface{}{
"Data": map[string]interface{}{"Accounts": []map[string]interface{}{
{"Status": 1, "CycleCapacityRemain": 100},
}},
}},
})
_, _ = w.Write(body)
}))
defer ts.Close()
_, ps := poolCore(t)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"balance": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "probeacct",
"credential": `{"access_token":"TOK"}`,
}); err != nil {
t.Fatalf("account_add: %v", err)
}
// ① 正常路径:必须真的打了上游
atomic.StoreInt32(&hits, 0)
m, err := ps.Call("channels", "account_test", map[string]interface{}{
"platform": "buddy", "account_id": "probeacct",
})
if err != nil {
t.Fatalf("account_test: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j["ok"] != true {
t.Fatalf("正常凭据应 ok=true: %#v", j)
}
if j["probed"] != true {
t.Errorf("必须标记 probed=true 并真的发请求;实得 probed=%v", j["probed"])
}
if n := atomic.LoadInt32(&hits); n == 0 {
t.Fatal("account_test 未向上游发任何请求 —— 这就是「看着能用」的假阳性")
}
// ② 凭据失效:必须 ok=false,且归类为 auth(给出可操作提示)
fail = true
m2, _ := ps.Call("channels", "account_test", map[string]interface{}{
"platform": "buddy", "account_id": "probeacct",
})
j2, _ := m2.(map[string]interface{})["json"].(map[string]interface{})
if j2["ok"] != false {
t.Errorf("凭据失效时必须 ok=false,实得 %v", j2["ok"])
}
if j2["kind"] != "auth" {
t.Errorf("应归类为 auth(需重新登录),实得 %v", j2["kind"])
}
if s, _ := j2["action"].(string); s == "" {
t.Error("失败必须给出可操作提示(否则用户不知道要重新登录还是等一会)")
}
// ③ 无凭据的账号:如实报错,不假装成功
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "naked",
}); err != nil {
t.Fatalf("account_add(无凭据): %v", err)
}
m3, _ := ps.Call("channels", "account_test", map[string]interface{}{
"platform": "buddy", "account_id": "naked",
})
j3, _ := m3.(map[string]interface{})["json"].(map[string]interface{})
if j3["ok"] != false {
t.Errorf("无凭据时必须 ok=false,实得 %v", j3["ok"])
}
}

View File

@ -1,182 +0,0 @@
package core
import (
"net/url"
"regexp"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// callback 平台的授权 URL 是最容易"看起来对、实际不可用"的地方:URL 能拼出来、
// 页面能打开,但参数名/编码/主机错一处,上游就拒绝授权。
//
// 判据照参考实现(trae-oauth.ts / gemini-oauth.ts / lobsterai-oauth.ts)逐条锁定:
// - Trae 参数名必须是 auth_callback_url(不是 redirect_uri)
// - Gemini redirect_uri 主机必须是 localhost(不是 127.0.0.1),token 交换要一致
// - LobsterAI 的 login 在 **hash 段**(/portal#/login),三个参数用 ? 起
// - redirect_uri 必须百分号编码(含 :// 与 :)
func TestCallbackAuthorizeUrls(t *testing.T) {
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)
}
start := func(t *testing.T, plat string) (string, map[string]interface{}) {
t.Helper()
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
})
if err != nil {
t.Fatalf("%s start_login: %v", plat, err)
}
j := out.JSON.(map[string]interface{})
u, _ := j["url"].(string)
return u, j
}
t.Run("行1 trae 用 auth_callback_url 而非 redirect_uri", func(t *testing.T) {
u, _ := start(t, "trae")
if u == "" {
t.Fatal("trae 未给出授权 URL")
}
if !strings.Contains(u, "auth_callback_url=") {
t.Fatalf("trae 必须用 auth_callback_url(参考注释点名:错名则登录必失败): %s", u)
}
if strings.Contains(u, "redirect_uri=") {
t.Fatalf("trae 不该有 redirect_uri 参数: %s", u)
}
for _, want := range []string{"client_id=en1oxy7wnw8j9n", "auth_type=local", "login_channel=native_ide"} {
if !strings.Contains(u, want) {
t.Fatalf("trae 缺参数 %s: %s", want, u)
}
}
// 关键:不能再出现 "…/authorization&redirect_uri=" 这种坏的拼接
if strings.Contains(u, "authorization&") {
t.Fatalf("授权 URL 首参分隔符必须是 ? 而不是 &: %s", u)
}
// 参考 trae-oauth.ts 把参数集列为「一个都不能少」(早期只发 5 个参数,
// 登录页**永远停在授权中**,既不跳转也不回传)。逐条锁定:
// login_trace_id 是回调反查 pending 的唯一凭据;machine/device 身份除外
// 还可能被风控拦;x_* 是客户端形态伪装。
parsed, err := url.Parse(u)
if err != nil {
t.Fatalf("trae 授权 URL 无法解析: %v", err)
}
q := parsed.Query()
for _, k := range []string{
"login_version", "auth_from", "login_channel", "plugin_version",
"auth_type", "client_id", "redirect", "login_trace_id",
"auth_callback_url", "machine_id", "device_id",
"x_device_id", "x_machine_id", "x_device_brand", "x_device_type",
"x_os_version", "x_app_version", "x_app_type",
} {
if q.Get(k) == "" {
t.Fatalf("trae 缺参数 %s(参考列为必需): %s", k, u)
}
}
// 设备身份是 hex32,trace_id 是它们拼接后的末 16 位
m, d := q.Get("machine_id"), q.Get("device_id")
if len(m) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(m) {
t.Fatalf("machine_id 应为 hex32: %q", m)
}
if len(d) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(d) {
t.Fatalf("device_id 应为 hex32: %q", d)
}
if m == d {
t.Fatalf("machine_id 与 device_id 不应相同(同源会削弱设备身份)")
}
joined := m + d
if got := q.Get("login_trace_id"); got != joined[len(joined)-16:] {
t.Fatalf("login_trace_id 应为 (machine_id+device_id) 末16位,实得 %q", got)
}
// 同一平台两次登录必须得到不同设备身份(否则多账号共用设备,
// 会被上游以「该设备已签到」拦截)
u2, _ := start(t, "trae")
q2, _ := url.Parse(u2)
if q2.Query().Get("device_id") == d {
t.Fatalf("两次登录的 device_id 应不同(每账号独立设备身份)")
}
// Trae 注册的回调路径是 /authorize(不是插件的 /api/plugins/http 挂载点):
// 回环地址由「打开授权页的那台机器」解析,所以必须是本机 loopback 接收器。
if cb := q.Get("auth_callback_url"); cb != "" {
if !strings.HasSuffix(cb, "/authorize") {
t.Fatalf("trae auth_callback_url 必须以注册路径 /authorize 结尾,实得 %q", cb)
}
if !strings.HasPrefix(cb, "http://127.0.0.1:") {
t.Fatalf("trae auth_callback_url 应是 127.0.0.1 回环地址,实得 %q", cb)
}
}
})
t.Run("行2 gemini 主机是 localhost 且参数不重复", func(t *testing.T) {
u, _ := start(t, "gemini")
parsed, err := url.Parse(u)
if err != nil {
t.Fatalf("授权 URL 无法解析: %v", err)
}
ru := parsed.Query().Get("redirect_uri")
if !strings.HasPrefix(ru, "http://localhost:") {
t.Fatalf("gemini redirect_uri 主机必须是 localhost(参考注释明确): %q", ru)
}
// 路径必须是注册值 /oauth-callback(参考 GEMINI_CALLBACK_PATH)——
// 换成别的路径就是 redirect_uri_mismatch。
if !strings.HasSuffix(ru, "/oauth-callback") {
t.Fatalf("gemini redirect_uri 路径必须是 /oauth-callback,实得 %q", ru)
}
for _, k := range []string{"client_id", "response_type", "scope", "state"} {
if parsed.Query().Get(k) == "" {
t.Fatalf("gemini 缺参数 %s: %s", k, u)
}
}
// 缺 access_type=offline 拿不到 refresh_token;prompt=consent 保证每次都发。
if got := parsed.Query().Get("access_type"); got != "offline" {
t.Fatalf("gemini access_type 应为 offline(否则无 refresh_token),实得 %q", got)
}
if got := parsed.Query().Get("prompt"); got != "consent" {
t.Fatalf("gemini prompt 应为 consent,实得 %q", got)
}
// 参数不得重复(通用分支曾再追加一次 redirect_uri)
if n := strings.Count(u, "redirect_uri="); n != 1 {
t.Fatalf("redirect_uri 出现 %d 次(应恰好 1 次): %s", n, u)
}
if n := strings.Count(u, "client_id="); n != 1 {
t.Fatalf("client_id 出现 %d 次: %s", n, u)
}
})
t.Run("行3 lobsterai 的 login 在 hash 段且参数编码", func(t *testing.T) {
u, _ := start(t, "lobsterai")
if !strings.Contains(u, "/portal#/login?") {
t.Fatalf("lobsterai 授权页必须是 /portal#/login?…(login 在 hash 段): %s", u)
}
if strings.Contains(u, "/login&") {
t.Fatalf("首参分隔符必须是 ? 而不是 &: %s", u)
}
if !strings.Contains(u, "source=electron") {
t.Fatalf("lobsterai 缺 source=electron: %s", u)
}
// redirect_uri 必须编码(含 :// 与 :),否则登录页校验失败
if !strings.Contains(u, "redirect_uri=http%3A%2F%2F") {
t.Fatalf("lobsterai redirect_uri 必须百分号编码: %s", u)
}
})
t.Run("行4 端口用本机监听值而非硬编码8081", func(t *testing.T) {
for _, plat := range []string{"trae", "gemini", "lobsterai"} {
u, _ := start(t, plat)
if strings.Contains(u, "127.0.0.1:8081") && plat != "trae" {
t.Fatalf("%s 不应硬编码 8081: %s", plat, u)
}
if strings.Contains(u, "{port}") {
t.Fatalf("%s 的 {port} 占位未被替换: %s", plat, u)
}
}
})
}

View File

@ -1,111 +0,0 @@
package core
import (
"strings"
"testing"
)
// ★ 自动领取的「今天已处理」记账(照参考 coversToday / shouldMarkToday)。
//
// 真实缺陷(参考 2026-10-02 定位,每天静默漏 100 积分):Qoder 的活动
// **每日 10:00(UTC+8)才刷新**,而自动签到在上午跑 —— 上午看到的是**昨天**
// 那条 CLAIMED,返回 `already-claimed`。若拿它记账,当天 10 点刷新后就再也
// 不去领。
//
// 修法不靠「给每个渠道配刷新时刻表」(必然漂移),而是让**产出方自己回答**
// 「我这条结果覆盖的是不是今天这一轮」(covers_today),记账方只数
// 「有几条能证明今天已被处理」。
func TestAutoClaimCountsCoversTodayNotKind(t *testing.T) {
_, ps := poolCore(t)
// ① Qoder 在刷新前必须声明 covers_today=false(否则这条链就白修)
out, err := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{
"refreshed": false,
})
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
if j == nil || j["kind"] == nil {
t.Fatalf("探针应返回 kind,实得 %#v", out)
}
if j["covers_today"] != false {
t.Errorf("刷新前必须声明 covers_today=false(这正是漏领的根因),实得 %v", j["covers_today"])
}
if j["kind"] == "already-claimed" {
t.Error("刷新前**绝不能**报 already-claimed(那会被记账成「今天已处理」)")
}
// ② 刷新后:正常报已领,且 covers_today=true
out2, _ := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{
"refreshed": true,
})
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
if j2["covers_today"] != true {
t.Errorf("刷新后应声明 covers_today=true,实得 %v", j2["covers_today"])
}
// ③ 记账判据必须**只看 covers_today**,不能用 kind 反推
//(`already-claimed` 天然有歧义 —— 可能就是昨天那一轮的痕迹)
cases := []struct {
name string
kind string
cov interface{}
want bool
}{
{"刷新前的 already-claimed 不得算已处理", "already-claimed", false, false},
{"刷新后的 already-claimed 算已处理", "already-claimed", true, true},
{"success 算已处理", "success", true, true},
{"covers_today 缺省为 true", "claimed", nil, true},
{"inactive 不算已处理", "inactive", true, false},
{"failed 不算已处理", "failed", true, false},
}
for _, c := range cases {
o, err := ps.Call("channels", "__claim_covers_probe", map[string]interface{}{
"kind": c.kind, "covers_today": c.cov,
})
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
oj, _ := o.(map[string]interface{})["json"].(map[string]interface{})
if got, _ := oj["covers"].(bool); got != c.want {
t.Errorf("%s: 应 %v,实得 %v", c.name, c.want, got)
}
}
}
// 调度频率:一天只跑一次会让「刷新时刻晚于执行时刻」的平台整天漏领。
func TestAutoClaimScheduledMoreThanOnceDaily(t *testing.T) {
_, ps := poolCore(t)
out, err := ps.Call("channels", "__schedule_probe", nil)
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
every, _ := j["auto_claim_every"].(float64)
if every <= 0 {
t.Fatalf("找不到 auto_claim 的调度间隔,实得 %v", j)
}
// 必须显著小于一天:活动刷新时刻各平台不同(Qoder 是每日 10:00 UTC+8),
// 一天只跑一次就必然有平台漏领。
if every >= 86400 {
t.Errorf("auto_claim 间隔应远小于一天(否则刷新晚的平台整天漏领),实得 %.0f 秒", every)
}
if j["first_due_now"] != true {
t.Error("auto_claim 应 first_due_now(重启立即补跑,不等一个周期)")
}
// 已覆盖今天的平台必须靠记账跳过,否则会重复打上游
if !strings.Contains(toString(j["has_coverage_key"]), "true") {
t.Error("需要「今天已覆盖」的持久化记账键")
}
}
func toString(v interface{}) string {
if b, ok := v.(bool); ok {
if b {
return "true"
}
return "false"
}
return ""
}

View File

@ -1,159 +0,0 @@
package core
import (
"strings"
"testing"
)
// 自动签到的**能力表**必须与参考真值一致 —— 这张表是 auto_claim 的唯一依据,
// 错了就是"静默少领"或"每次调用都报自相矛盾的错"。
//
// 实测踩到的三处偏差(本轮修正):
//
// buddy 我们写 claim=true,但 buddy 既无 checkin 又无 claim_fn →
// 每次调用返回"不支持每日签到(平台未声明 checkin)"(表说支持、
// 函数说不支持)。参考里 buddy **没有** claim。
// raccoon 我们声明空表 → 白丢一个可领渠道。参考有:桌面端登录奖励
// POST /api/web/desktop/v1/login/points/grant(每号一次)。
// zcode 有签到,但每次领取现场产阿里云 captcha(headful Chromium,
// 按设备限流 150 次/小时)→ 参考把它列进 auto-checkin 的**排除表**。
// 自动跳过,手动仍可领。
func TestAutoClaimCapabilityTableMatchesReference(t *testing.T) {
c, ps := poolCore(t)
_ = c
// 参考真值(逐个 grep 参考仓的 claim 导出确认)
wantClaim := map[string]bool{
"trae": true, "minimax": true, "lobsterai": true, "qoder": true,
"loomy": true, "raccoon": true, "codearts": true,
"cline": false, "gemini": false, "zcode": true, "buddy": false,
}
// 自动签到必须跳过的(领取有代价)
wantAutoExclude := map[string]bool{"zcode": true}
for plat, want := range wantClaim {
t.Run("claim_"+plat, func(t *testing.T) {
got := creditsCapability(t, ps, plat)
if got == nil {
t.Fatalf("%s 无能力声明", plat)
}
if has := got["claim"] == true; has != want {
t.Errorf("%s claim = %v,参考为 %v", plat, has, want)
}
if got, ok := got["auto_exclude"].(bool); ok != wantAutoExclude[plat] {
t.Errorf("%s auto_exclude = %v,应为 %v", plat, got, wantAutoExclude[plat])
}
})
}
// 关键一致性:声称 claim=true 的平台**必须**真的可领
//(有 checkin 表或有 claim_fn)—— 否则就是 buddy 那种自相矛盾。
for plat, want := range wantClaim {
if !want {
continue
}
m, err := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": plat})
if err != nil {
t.Fatalf("%s: %v", plat, err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil {
t.Fatalf("%s 无返回", plat)
}
if j["capable"] != true {
t.Errorf("%s 声称 claim=true 但不可领(%v)—— 会出现「表说支持、"+
"函数说不支持」的自相矛盾", plat, j["reason"])
}
}
// 反向:不声称 claim 的平台必须被判为不可领
m, _ := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": "buddy"})
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j != nil && j["capable"] == true {
t.Error("buddy 不该被判为可领(它没有 claim)")
}
}
// 自动签到的**调度配置**必须覆盖两项需求:领取 + 模型获取。
//
// auto_claim 走调度器(不是 HTTP export),故断言调度声明。
func TestAutoClaimScheduledWithModelRefresh(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
for _, want := range []struct{ name, needle string }{
{"每日领取", `fn = "auto_claim"`},
{"模型目录自动获取", `fn = "refresh_models"`},
{"凭据自动续期", `fn = "refresh_creds"`},
{"启动即补跑", `first_due_now = true`},
} {
if !strings.Contains(b, want.needle) {
t.Errorf("调度表缺 %s(%s)", want.name, want.needle)
}
}
// 模型刷新间隔照参考 MODEL_REFRESH_INTERVAL_MS(2 小时)
if !strings.Contains(b, `fn = "refresh_models", every = 7200`) {
t.Error("refresh_models 间隔应为 7200(参考 2h)")
}
// 按**行**判断(固定字符窗会溢到下一行,误报)
lineOf := func(fn string) string {
for _, ln := range strings.Split(b, "\n") {
if strings.Contains(ln, `fn = "`+fn+`"`) {
return ln
}
}
return ""
}
// auto_claim **现在**也 first_due_now,且间隔远小于一天。
//
// ★ 这里改过一次,原因值得记下:原判据写的是「auto_claim 是每日任务,
// 不该 first_due_now(重启就重领是错的)」—— 那个假设**本身是缺陷的一部分**。
// 活动刷新时刻**各平台不同**(Qoder 是每日 10:00 UTC+8),一天只跑一次
// 必然有平台整天漏领(参考实测:每天静默漏 100 积分)。修法有两半:
// ① 频率提高 + first_due_now(任何时刻重启都能补上);
// ② 「今天已覆盖」靠 covers_today 记账跳过(**这才是防重领的正确机制**,
// 而不是靠"一天只跑一次"来碰巧避开)。
// 只做 ② 不做 ① 仍会漏领;只做 ① 不做 ② 会重复打上游。
if ln := lineOf("auto_claim"); !strings.Contains(ln, "first_due_now = true") {
t.Errorf("auto_claim 应 first_due_now(否则重启后当天漏领):%s", ln)
}
// 两个自动任务必须 first_due_now(否则重启要等满一轮才自愈)
for _, fn := range []string{"refresh_models", "refresh_creds"} {
ln := lineOf(fn)
if ln == "" {
continue
}
if !strings.Contains(ln, "first_due_now = true") {
t.Errorf("%s 应声明 first_due_now(参考点名的重启自愈缺陷):%s", fn, ln)
}
}
// auto_exclude 必须挡住 zcode(领取有代价:headful Chromium)
if !strings.Contains(b, "auto_exclude = true") {
t.Error("zcode 应标记 auto_exclude(照参考 isAutoCheckinExcluded)")
}
if i := strings.Index(b, `if cap.auto_exclude then`); i < 0 {
t.Error("auto_claim 必须尊重 auto_exclude(否则会拉起浏览器)")
}
}
// creditsCapability 读插件的能力表条目。
func creditsCapability(t *testing.T, ps interface {
Call(string, string, map[string]interface{}) (interface{}, error)
}, plat string) map[string]interface{} {
t.Helper()
m, err := ps.Call("channels", "credits_capability", map[string]interface{}{"platform": plat})
if err != nil {
t.Fatalf("credits_capability(%s): %v", plat, err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil {
return nil
}
if cap, ok := j["capability"].(map[string]interface{}); ok {
return cap
}
return nil
}

View File

@ -1,122 +0,0 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// 余额分档选号(照参考 buddy-balance-rank.ts / loomy-balance-rank.ts)。
//
// 解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」),
// 而**服务端扣哪个包不由我们决定** —— 插件能决定的是**用哪个号**:
// 优先用「还有 15 天内到期积分」的号(那部分不用就作废),只剩永久积分的号排最后;
// **锁定永久积分**时只剩永久积分的号等同于不可用。
//
// 判据用假余额上游,逐条钉住分档规则(含三个易错点)。
func TestBalanceTierSelection(t *testing.T) {
// 假 buddy 余额端点:返回三个包,覆盖三种情形
// pkg A: 有效 + 10 天内到期 + 剩余 100 → 临时桶
// pkg B: 有效 + 到期时间未知 + 剩余 50 → 永久桶(保守)
// pkg C: **已失效** + 剩余 9999 → 必须跳过(否则虚增可用额度)
future := nowMs() + 300*24*3600*1000 // 300 天后
soon := nowMs() + 10*24*3600*1000 // 10 天后
body := map[string]interface{}{
"data": map[string]interface{}{
"Response": map[string]interface{}{
"Data": map[string]interface{}{
"Accounts": []map[string]interface{}{
{"Status": 1, "CycleCapacityRemain": 100, "DeductionEndTime": soon},
{"Status": 1, "CycleCapacityRemain": 50, "DeductionEndTime": future},
{"Status": 2, "CycleCapacityRemain": 9999, "DeductionEndTime": future},
},
},
},
},
}
raw, _ := json.Marshal(body)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(raw)
}))
defer ts.Close()
_, ps := poolCore(t)
// 把一个 buddy 账号加进池,并把余额端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"balance": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "tieracct",
"credential": `{"access_token":"TOK"}`,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 失败: %#v", m)
}
// ① 分档:临时=100(只有 10 天内那个),永久=50(未知/远期),失效包被跳过
out, err := ps.Call("channels", "credits_tier_probe",
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
if err != nil {
t.Fatalf("分档探针: %v", err)
}
tw, _ := out.(map[string]interface{})
tj, _ := tw["json"].(map[string]interface{})
if tj == nil {
t.Fatalf("无 json: %#v", out)
}
if tj["error"] != nil {
t.Fatalf("分档报错: %v", tj["error"])
}
if got, _ := tj["expiring"].(float64); got != 100 {
t.Errorf("临时余额应为 100(仅 10 天内到期的包),实得 %v", tj["expiring"])
}
if got, _ := tj["permanent"].(float64); got != 50 {
t.Errorf("永久余额应为 50(到期未知/远期),实得 %v(若为 10049 说明失效包没跳过)",
tj["permanent"])
}
// ② 档位判定:有临时积分 → "expiring"(最优先)
if tj["tier"] != "expiring" {
t.Errorf("有 15 天内到期积分时档位应为 expiring,实得 %v", tj["tier"])
}
// ③ 锁定永久积分:该账号**仍可用**(因为有临时积分)
if _, err := ps.Call("channels", "__set_lock_permanent_probe",
map[string]interface{}{"lock": true}); err != nil {
t.Fatalf("锁定探针: %v", err)
}
out2, _ := ps.Call("channels", "credits_tier_probe",
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
tj2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
if tj2["usable"] != true {
t.Errorf("锁定永久积分时,有临时积分的号仍应可用,实得 usable=%v", tj2["usable"])
}
// ④ 窗口可被显式覆盖:把窗口设成 0 ⇒ 没有"临时积分" ⇒ 只剩永久 ⇒ 锁定后不可用
if _, err := ps.Call("channels", "__set_expiring_window_probe",
map[string]interface{}{"days": 0}); err != nil {
t.Fatalf("窗口探针: %v", err)
}
out3, _ := ps.Call("channels", "credits_tier_probe",
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
tj3, _ := out3.(map[string]interface{})["json"].(map[string]interface{})
if got, _ := tj3["expiring"].(float64); got != 0 {
t.Errorf("窗口=0 时临时桶应为 0,实得 %v", tj3["expiring"])
}
if tj3["usable"] != false {
t.Errorf("窗口=0 且锁定永久积分时该号应不可用,实得 usable=%v", tj3["usable"])
}
}
// nowMs 返回当前毫秒时间戳(与插件里 DeductionEndTime 的口径一致)。
func nowMs() int64 { return time.Now().UnixMilli() }

View File

@ -1,57 +0,0 @@
package core
import (
"strings"
"testing"
)
// ★ 远端模型目录混着「不可调用」的条目,必须过滤掉。
//
// 真实缺陷(生产实测):buddy 源声明了 24 个模型,其中
//
// nes-gf / codewise-completions / codewise-rewrite / codewise-jump /
// codewise-default-model-v2 / hunyuan-image-alpha / auto
//
// 全部调不通(`glm-5.1` 也是 404)。用户选中就报错,而错误文案指向**模型**,
// 极易被误判成"这个模型坏了"。
//
// 参考实现(buddy.ts 的 isChatModel / isAutoSelectAlias)记录了**四类**判据,
// 且都是实测依据:
//
// ① 自动选择别名 auto/default(**只按字面量**,前缀匹配会误伤 default-model)
// ② 补全/NES 专用前缀 nes- / completion- / codewise-,或 supportsExtra=true
// (参考实测 codewise-default-model-v2 → `code 11102 service info not found`)
// ③ maxOutputTokens >0 且 ≤256(补全用途;对话模型普遍 ≥24000)
// ④ 带 text-to-image 标签(如 hunyuan-image-alpha)
func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// 判据一:四类规则必须都在源码里(防止有人"精简"掉其中一条)
for _, rule := range []string{
`id == "auto" or id == "default"`, // ①
`id:sub(1, 4) == "nes-"`, // ②
`id:sub(1, 11) == "completion-"`, // ②
`id:sub(1, 9) == "codewise-"`, // ②
`m.supportsExtra == true`, // ②
`mo > 0 and mo <= 256`, // ③
`t == "text-to-image"`, // ④
} {
if !strings.Contains(s, rule) {
t.Errorf("buddy 目录过滤缺少规则:%s\n"+
"(这四类都是参考用真实请求验证过的「列出来就会让用户报错」的条目,"+
"删任何一条都会把不可调用模型放回选择器)", rule)
}
}
// 判据二:`default-model` / `fast-model` 必须**不在**被过滤的形态里。
// 参考注释明确警告:只过滤 auto/default 两个字面量,不做前缀匹配 ——
// 前缀会误伤这两个国际版别名(它们被 craft agent 引用,是官方推荐入口)。
if strings.Contains(s, `id:sub(1, 7) == "default"`) {
t.Error("buddy 过滤把 default 做成了**前缀**匹配 ⇒ 会误伤 default-model(参考点名警告)")
}
t.Log("✓ buddy 目录过滤四类规则齐备,且未误伤 default-model 类别名")
}

View File

@ -1,220 +0,0 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"llmsproxy/internal/lua"
)
// 三个 callback 平台的回调路径**各是注册值**,且校验规则不同:
//
// Trae /authorize 不带 state(token 直接放 query 里)
// 有道 /auth/callback 必须带 state
// Gemini /oauth-callback 必须带 state
//
// 路径写错 → 上游 redirect_uri_mismatch;校验写错 → 要么接不住回调、
// 要么被伪造回调塞入凭据。本测试打真上游形态(本地 loopback 接收器)。
func TestCallbackPlatformsLoopback(t *testing.T) {
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)
}
start := func(t *testing.T, plat string) (m map[string]interface{}, nonce, sid string) {
t.Helper()
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
})
if err != nil {
t.Fatalf("%s start_login: %v", plat, err)
}
m = out.JSON.(map[string]interface{})
s := m["session"].(map[string]interface{})
return m, s["nonce"].(string), s["id"].(string)
}
poll := func(t *testing.T, sid string) map[string]interface{} {
t.Helper()
o, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
return o.JSON.(map[string]interface{})
}
t.Run("行1 Trae 回调路径是 /authorize 且无 state 也能接住", func(t *testing.T) {
_, _, sid := start(t, "trae")
// 交换端点指到假上游
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"Token":"ACCESS-NEW"}`))
}))
defer ts.Close()
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "trae", "callback": map[string]interface{}{"exchange_url": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 重新起会话,拿到带 loopback 的 redirect_uri
m, nonce, sid := start(t, "trae")
ru, _ := m["redirect_uri"].(string)
if !strings.HasSuffix(ru, "/authorize") {
t.Fatalf("trae 回调路径必须是 /authorize,实得 %q", ru)
}
// Trae 的回调**不带 state**,而是把 token 放 query 里
q := url.Values{}
q.Set("refreshToken", "RT-1")
q.Set("userInfo", `{"UserID":"u1","ScreenName":"张三"}`)
resp, err := http.Get(ru + "?" + q.Encode())
if err != nil {
t.Fatalf("打 loopback 接收器失败: %v", err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("Trae 回调(无 state)应被接住,实得 %d", resp.StatusCode)
}
st := waitState(t, poll, sid, "completed")
if st["state"] != "completed" {
t.Fatalf("应完成登录,实得 %v", st)
}
raw, ok, _ := ps.GetSecret("channels", "channels:trae")
if !ok {
t.Fatal("凭据未落库")
}
var cred map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
t.Fatal(err)
}
if cred["access_token"] != "ACCESS-NEW" {
t.Fatalf("凭据应是交换后的新 access,实得 %v", cred["access_token"])
}
if cred["uid"] != "u1" {
t.Fatalf("uid 应从 userInfo.UserID 取出(签到设备身份的种子), 实得 %v", cred["uid"])
}
_ = nonce
})
t.Run("行2 Gemini 路径是 /oauth-callback 且错 state 被拒", func(t *testing.T) {
m, nonce, sid := start(t, "gemini")
ru, _ := m["redirect_uri"].(string)
if !strings.HasSuffix(ru, "/oauth-callback") {
t.Fatalf("gemini 回调路径必须是 /oauth-callback(参考 GEMINI_CALLBACK_PATH),实得 %q", ru)
}
if !strings.HasPrefix(ru, "http://localhost:") {
t.Fatalf("gemini 回调主机必须是 localhost(参考注释明确),实得 %q", ru)
}
// 错 state 必须被接收器拒(400),且不推进会话
bad, err := http.Get(ru + "?code=C-1&state=WRONG")
if err != nil {
t.Fatal(err)
}
bad.Body.Close()
if bad.StatusCode != 400 {
t.Fatalf("state 不匹配应 400(防伪造回调),实得 %d", bad.StatusCode)
}
if st := poll(t, sid); st["state"] != "pending" {
t.Fatalf("错 state 不应推进会话,实得 %v", st)
}
// 正确 state → 完成(交换端点指到假上游)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
// 必须带 client_secret:只发 client_id 会 invalid_request,
// 症状正是「浏览器显示授权成功但账号不出现」
if r.PostFormValue("client_secret") == "" {
t.Error("token 交换必须带 client_secret")
}
if r.PostFormValue("grant_type") != "authorization_code" {
t.Errorf("grant_type = %q", r.PostFormValue("grant_type"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token":"GA-1","refresh_token":"GR-1","expires_in":3600}`))
}))
defer ts.Close()
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "gemini",
"callback": map[string]interface{}{"token_url": ts.URL, "mode": "code"},
}); err != nil {
t.Fatal(err)
}
good, err := http.Get(ru + "?code=C-1&state=" + nonce)
if err != nil {
t.Fatal(err)
}
good.Body.Close()
st := waitState(t, poll, sid, "completed")
if st["state"] != "completed" {
t.Fatalf("应完成登录,实得 %v", st)
}
raw, ok, _ := ps.GetSecret("channels", "channels:gemini")
if !ok {
t.Fatal("凭据未落库")
}
var cred map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
t.Fatal(err)
}
if cred["access_token"] != "GA-1" {
t.Fatalf("access_token = %v", cred["access_token"])
}
})
}
// waitState 轮询到目标状态或超时(loopback 写文件是异步的,需要一拍)。
func waitState(t *testing.T, poll func(*testing.T, string) map[string]interface{},
sid, want string) map[string]interface{} {
t.Helper()
var st map[string]interface{}
for i := 0; i < 30; i++ {
st = poll(t, sid)
if st["state"] != "pending" {
return st
}
time.Sleep(100 * time.Millisecond)
}
return st
}
// 占位符未替换检查:授权 URL 里残留 {xxx} 会让上游收到字面量占位符
// (而且往往不报错,只是行为诡异)。三个 callback 平台逐个验。
func TestAuthorizeURLsHaveNoUnfilledPlaceholders(t *testing.T) {
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)
}
for _, plat := range []string{"trae", "gemini", "lobsterai"} {
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
})
if err != nil {
t.Fatalf("%s: %v", plat, err)
}
m := out.JSON.(map[string]interface{})
for _, key := range []string{"url", "redirect_uri"} {
v, _ := m[key].(string)
if v == "" {
continue
}
if strings.Contains(v, "{") || strings.Contains(v, "nil") {
t.Errorf("%s 的 %s 仍有未替换占位符: %s", plat, key, v)
}
}
}
}

View File

@ -1,55 +0,0 @@
package core
import (
"os"
"testing"
)
// 用**真实的 models.dev 响应**验证解析口径(5.6MB / 229 键 / opencode 122 模型)。
//
// 为什么必须用真数据:字段路径(modalities.input / cost.input / limit.context)
// 是我照参考注释写的,只有真数据能证明写对了。合成样本会把这个验证变成自说自话。
//
// 样本不在仓库里(5.6MB),缺失时跳过 —— 但 CI 里能拉到就一定会跑。
func TestCapabilityParseAgainstRealModelsDev(t *testing.T) {
b, err := os.ReadFile("/tmp/modelsdev.json")
if err != nil {
t.Skip("无 models.dev 样本(/tmp/modelsdev.json),跳过")
}
_, ps := poolCore(t)
out, err := ps.Call("channels", "__parse_capability_probe", map[string]interface{}{
"body": string(b), "key": "opencode",
})
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
if j["error"] != nil {
t.Fatalf("解析失败: %v", j["error"])
}
total, _ := j["total"].(float64)
free, _ := j["free"].(float64)
image, _ := j["image"].(float64)
ctx, _ := j["with_ctx"].(float64)
t.Logf("opencode: total=%.0f free=%.0f image=%.0f with_ctx=%.0f", total, free, image, ctx)
// ① 规模:实测 122 个模型(允许上游增减,给个合理区间)
if total < 50 {
t.Errorf("解析出的模型数太少(%.0f)—— 字段路径可能没对上真实结构", total)
}
// ② 免费判定必须有命中的(实测有若干 cost 全 0 的模型)
if free == 0 {
t.Error("一个免费模型都没识别出来 —— cost 判定路径可能错了")
}
if free >= total {
t.Error("全部被标免费 —— cost 判定过于宽松")
}
// ③ 多模态也要有命中的(gpt-5.4 之类含 image)
if image == 0 {
t.Error("没识别出任何多模态模型 —— modalities 路径可能错了")
}
// ④ 上下文窗口要能取到(limit.context)
if ctx == 0 {
t.Error("没取到任何上下文窗口 —— limit 路径可能错了")
}
}

View File

@ -1,86 +0,0 @@
package core
import (
"testing"
)
// 模型能力元数据(照参考 opencode-capability)。
//
// 关键纪律:`/zen/v1/models` 只返回 4 个字段、**不含能力信息**;能力在 models.dev
// (实测 5.05 MB / 下载 1.4s)。参考的真机事故:最初在模型列表渲染路径上直接
// 等这个下载,模型选择器要等 1.4s+ 才出列表 —— 用户报障「点开是一片空白」。
//
// ⇒ 三条硬约定:渲染路径只读同步缓存;磁盘缓存冷启动命中;拉取在后台。
// 我们额外:本插件是服务端进程,但 5MB 仍不能放请求路径上 ⇒ 挂定时任务。
//
// 本判据钉住"非阻塞"这一条:**缓存未就绪时 model_list 必须立刻返回**,
// 而不是去等网络。
func TestCapabilityReadNeverBlocks(t *testing.T) {
_, ps := poolCore(t)
// 清掉能力缓存(模拟冷启动)
if _, err := ps.Call("channels", "__clear_capabilities_probe", nil); err != nil {
t.Fatalf("清缓存: %v", err)
}
// 冷启动下取模型列表:必须成功返回(退回兜底 isFree),且不得等待网络。
// 判据:能返回就说明没阻塞(真去下 5MB 会超时/极慢)。
m, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "opencode"})
if err != nil {
t.Fatalf("model_list: %v", err)
}
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
if j == nil || j["ok"] != true {
t.Fatalf("冷启动下 model_list 必须成功(退回兜底),实得 %#v", m)
}
models := asArrInterface(j["models"])
if len(models) == 0 {
t.Fatal("冷启动下仍应列出兜底模型")
}
// 冷启动时 isFree 应退回声明的 models_free(不能凭空说全部免费)
sawFree := false
for _, it := range models {
if mm, ok := it.(map[string]interface{}); ok && mm["isFree"] == true {
sawFree = true
}
}
if !sawFree {
t.Error("冷启动时 opencode 应有 free 模型(退回声明的 models_free)")
}
}
// 能力元数据一旦就绪,isFree 必须改由它决定(cost 权威,而非快照)。
func TestCapabilityOverridesFreeFlag(t *testing.T) {
_, ps := poolCore(t)
// 注入一份假能力:把某个原本标 free 的模型改成收费、把非 free 的改成免费
if _, err := ps.Call("channels", "__inject_capability_probe", map[string]interface{}{
"platform": "opencode",
"model_id": "big-pickle",
"is_free": false, // 原本声明 free,能力表说收费 ⇒ 必须以能力表为准
}); err != nil {
t.Fatalf("注入: %v", err)
}
if _, err := ps.Call("channels", "__inject_capability_probe", map[string]interface{}{
"platform": "opencode",
"model_id": "glm-5.2",
"is_free": true, // 原本不在 free 列表,能力表说免费
}); err != nil {
t.Fatalf("注入: %v", err)
}
m, _ := ps.Call("channels", "model_list", map[string]interface{}{"platform": "opencode"})
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
got := map[string]bool{}
for _, it := range asArrInterface(j["models"]) {
if mm, ok := it.(map[string]interface{}); ok {
if id, ok := mm["id"].(string); ok {
if f, ok := mm["isFree"].(bool); ok {
got[id] = f
}
}
}
}
if got["big-pickle"] {
t.Error("能力表说收费时 isFree 必须为 false(权威来源是 cost,不是快照)")
}
if !got["glm-5.2"] {
t.Error("能力表说免费时 isFree 必须为 true")
}
}

View File

@ -1,235 +0,0 @@
package core
import (
"testing"
"llmsproxy/internal/lua"
)
// ★ 无凭据账号的批量清理:**只删没有凭据的**,有凭据的一律不动。
//
// 场景(真实发生):`account_add` 允许不带凭据创建账号(设计如此 —— 先建占位、
// 登录补齐),于是登录没走完时留下"只有元数据、永远不可用"的账号,占着账号计数,
// 让"这个平台到底能不能用"难以判断。实测生产 13 个账号里只有 1 个真可用
// (codearts 10 + trae 2 全是占位)。
//
// 判据看的是**清理后的真实池状态**,而不是"函数被调用过"。
func TestAccountClearCredentiallessOnlyRemovesEmptyOnes(t *testing.T) {
h := newAccountCleanupHarness(t)
// ① 默认 dry_run:必须**不删**任何东西
pre, err := h.ps.Call("channels", "account_clear_credentialless", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
preJ := jsonOf(t, pre)
if preJ["dry_run"] != true {
t.Fatalf("默认必须 dry_run(破坏性操作不能默认执行),实得 %v", preJ["dry_run"])
}
// 夹具放了 4 个 codearts(3 空壳 + 1 有凭据)
if got := len(accountIDs(t, h, "codearts")); got != 4 {
t.Fatalf("dry_run 不该删任何账号,codearts 仍有 %d 个(want 4)", got)
}
// 不传 platform ⇒ 全部平台:codearts 3 空壳 + trae 2 空壳 = 5
if wd, _ := preJ["would_delete"].(float64); int(wd) != 5 {
t.Fatalf("预演应报告将删 5 个(全平台),实得 %v", preJ["would_delete"])
}
if kc, _ := preJ["kept_with_credential"].(float64); int(kc) != 1 {
t.Fatalf("应报告保留 1 个有凭据账号,实得 %v", preJ["kept_with_credential"])
}
// ② 真执行:删无凭据的,**保留有凭据的**
if _, err := h.ps.Call("channels", "account_clear_credentialless",
map[string]interface{}{"dry_run": false}); err != nil {
t.Fatal(err)
}
if got := len(accountIDs(t, h, "codearts")); got != 1 {
t.Fatalf("应只剩 1 个有凭据账号,实得 %d", got)
}
// ★ 关键:留下的必须是**有凭据的那个**
left := accountIDs(t, h, "codearts")
if len(left) != 1 || left[0] != "withcred" {
t.Fatalf("留下的必须是有凭据的账号 withcred,实得 %v", left)
}
// 有凭据的账号其凭据也必须仍在(不能被误删)
if !h.hasCredential("withcred") {
t.Fatal("有凭据账号的凭据被误删了")
}
t.Logf("✓ 删 3 保 1,且有凭据账号的凭据完好")
}
// 指定平台时只动那个平台,别的不碰。
func TestAccountClearCredentiallessScopedToPlatform(t *testing.T) {
h := newAccountCleanupHarness(t)
if _, err := h.ps.Call("channels", "account_clear_credentialless",
map[string]interface{}{"platform": "codearts", "dry_run": false}); err != nil {
t.Fatal(err)
}
// codearts 被清(剩 1),trae 的 2 个占位**不该被动**
if got := len(accountIDs(t, h, "codearts")); got != 1 {
t.Fatalf("codearts 应剩 1,实得 %d", got)
}
if got := len(accountIDs(t, h, "trae")); got != 2 {
t.Fatalf("指定 platform=codearts 时不该动 trae(应仍 2),实得 %d", got)
}
t.Log("✓ 平台范围隔离正确")
}
// 未知平台必须报错,不能静默当成"全部平台"(否则会误删别的平台)。
func TestAccountClearCredentiallessRejectsUnknownPlatform(t *testing.T) {
h := newAccountCleanupHarness(t)
res, err := h.ps.Call("channels", "account_clear_credentialless",
map[string]interface{}{"platform": "no-such-platform", "dry_run": false})
if err != nil {
t.Fatal(err)
}
j := jsonOf(t, res)
if j["error"] == nil {
t.Fatalf("未知平台必须报错(否则会误删全部平台),实得 %v", j)
}
// 且什么都没删
if got := len(accountIDs(t, h, "codearts")); got != 4 {
t.Fatalf("报错时不该删任何东西,codearts 实得 %d(want 4)", got)
}
}
// ── harness ──
type accountCleanupHarness struct {
t *testing.T
ps *lua.Plugins
}
func newAccountCleanupHarness(t *testing.T) *accountCleanupHarness {
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)
}
h := &accountCleanupHarness{t: t, ps: ps}
// 3 个无凭据占位 + 1 个有凭据账号(codearts);另 2 个 trae 占位
for _, id := range []string{"empty1", "empty2", "empty3"} {
h.add("codearts", id, "")
}
h.add("codearts", "withcred", `{"access_key_id":"AK","secret_access_key":"SK"}`)
h.add("trae", "tempty1", "")
h.add("trae", "tempty2", "")
return h
}
func (h *accountCleanupHarness) add(platform, id, cred string) {
h.t.Helper()
args := map[string]interface{}{"platform": platform, "account_id": id}
if cred != "" {
args["credential"] = cred
}
if _, err := h.ps.Call("channels", "account_add", args); err != nil {
h.t.Fatalf("account_add(%s/%s): %v", platform, id, err)
}
}
func (h *accountCleanupHarness) hasCredential(accountID string) bool {
h.t.Helper()
res, err := h.ps.Call("channels", "account_list",
map[string]interface{}{"platform": "codearts"})
if err != nil {
h.t.Fatal(err)
}
for _, a := range accountsOf(h.t, res) {
if a["id"] == accountID {
return a["has_credential"] == true
}
}
return false
}
// jsonOf 取插件调用返回的 {json:{...}} 里那层 json。
func jsonOf(t *testing.T, got interface{}) map[string]interface{} {
t.Helper()
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("返回值不是对象: %#v", got)
}
j, ok := m["json"].(map[string]interface{})
if !ok {
t.Fatalf("返回值缺 json 层: %#v", m)
}
return j
}
// accountsOf 取 account_list 返回的账号数组。
func accountsOf(t *testing.T, got interface{}) []map[string]interface{} {
t.Helper()
j := jsonOf(t, got)
arr, _ := j["accounts"].([]interface{})
out := make([]map[string]interface{}, 0, len(arr))
for _, e := range arr {
if m, ok := e.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}
// accountIDs 列某平台的账号 id。
func accountIDs(t *testing.T, h *accountCleanupHarness, platform string) []string {
t.Helper()
res, err := h.ps.Call("channels", "account_list",
map[string]interface{}{"platform": platform})
if err != nil {
t.Fatalf("account_list(%s): %v", platform, err)
}
var out []string
for _, a := range accountsOf(t, res) {
if id, ok := a["id"].(string); ok {
out = append(out, id)
}
}
return out
}
// ★ 字符串 body(真实 HTTP 路径)+ 不传 platform 时,dry_run 必须被尊重。
//
// 反例(实测踩到,我自己写的):照其它 handler 写
//
// `if args.platform == nil then args = req end`
//
// —— 而"不传 platform"在本函数里是**合法用法**(清全部平台),于是 args 被换成
// HandlerRequest,body 里解出来的 dry_run 就丢了 ⇒ 传 dry_run=false 却被当成
// 未传,静默只预演。**破坏性操作里最糟的形态**:操作者以为执行了、实际没执行。
//
// 夹具必须用**字符串 body**(我第一版传 map,走不到这条路径,所以没咬住)。
func TestAccountClearCredentiallessRespectsDryRunViaStringBody(t *testing.T) {
h := newAccountCleanupHarness(t)
// 不带 platform + dry_run=false,且 body 是**字符串**(HTTP 真实形态)
res, err := h.ps.InvokeHandler("channels", "account_clear_credentialless",
&lua.HandlerRequest{Method: "POST", Body: `{"dry_run":false}`})
if err != nil {
t.Fatal(err)
}
if res.Status != 200 {
t.Fatalf("status=%d body=%v", res.Status, res.Body)
}
j, _ := res.JSON.(map[string]interface{})
if j["dry_run"] == true {
t.Fatalf("字符串 body 里传了 dry_run=false 却被当成未传(回退吃掉 body): %v", j)
}
if j["deleted"] == nil {
t.Fatalf("dry_run=false 时必须真的执行(应有 deleted 字段): %v", j)
}
// 全平台的 5 个空壳都被删(codearts 3 + trae 2)
if got := len(accountIDs(t, h, "codearts")); got != 1 {
t.Fatalf("codearts 应剩 1(有凭据那个),实得 %d", got)
}
if got := len(accountIDs(t, h, "trae")); got != 0 {
t.Fatalf("trae 的 2 个空壳应被删,实得 %d", got)
}
t.Logf("✓ 字符串 body 且无 platform 时 dry_run=false 生效: deleted=%v", j["deleted"])
}

View File

@ -1,91 +0,0 @@
package core
import (
"strings"
"testing"
)
// Cline 的 429/402 话术与冷却时长(照参考 cline-rate-limit.ts)。
//
// ★ 这是**比参考更严重**的形态:参考退到"1 小时快照兜底",用户看到
// 「永远 60 分钟」(等满一小时再试仍被拒,且每失败一次就从当下重新计一小时
// ⇒ 无限循环)。而我们的内核把 429 当普通限流、用**固定 30 秒**冷却 ——
// 那会变成**每 30 秒重试一次、永远失败**,在猛敲上游。
//
// 抓包真值(参考实测,no-retry: true —— 没有任何 retry-after 头):
//
// HTTP 429
// {"error":{"code":"INFERENCE_CAP_ERROR",
// "message":"Error 429: Daily free limit reached on model
// deepseek/deepseek-v4-flash. Try again in 19h 39m"}}
func TestClineAdapterParsesProseWait(t *testing.T) {
c, _ := poolCore(t)
vm := c.VM()
// ① 当日免费额度用尽:必须被归一成**内核已认的中立短语**
//(否则内核按 30 秒短冷却处理 ⇒ 每 30 秒猛敲上游)
body := `{"error":{"code":"INFERENCE_CAP_ERROR","message":` +
`"Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 19h 39m"}}`
out, ok, err := vm.TransformError("channels-cline", 429, body)
if err != nil || !ok {
t.Fatalf("transform_error: err=%v ok=%v", err, ok)
}
if !strings.Contains(out, "额度已用完") {
t.Errorf("必须归一成内核已认的「额度已用完」(否则不会被当成额度耗尽处理),实得 %q", out)
}
// 真实等待时长必须出现在话术里(19h39m ≈ 19 小时)
if !strings.Contains(out, "19") {
t.Errorf("必须把真实等待时长写进话术(用户才知道要等多久),实得 %q", out)
}
if !strings.Contains(out, "重试无用") {
t.Errorf("当日额度用尽要明确告知重试无用(否则用户以为等一会就好),实得 %q", out)
}
// ⚠️ 绝不能出现参考踩过的「1 小时」误导
if strings.Contains(out, "1 小时") && !strings.Contains(out, "19") {
t.Errorf("不得退回快照式的「1 小时」误导,实得 %q", out)
}
// ② 时长 token 必须**累加**(`1h 30m` = 90 分钟),不能只取第一个 ——
// 通过真实的 transform_error 路径验证(不自己重算解析器)。
acc, _, _ := vm.TransformError("channels-cline", 429,
`{"error":{"message":"Daily free limit reached. Try again in 1h 30m"}}`)
if !strings.Contains(acc, "1 小时 30 分") {
t.Errorf("`1h 30m` 必须累加成人话时长(1 小时 30 分),实得 %q", acc)
}
// 秒级也要能解析(否则秒级限流会退化成通用文案)
secs, _, _ := vm.TransformError("channels-cline", 429,
`{"error":{"message":"Rate limited. Try again in 45s"}}`)
if !strings.Contains(secs, "分") && !strings.Contains(secs, "秒") {
t.Errorf("秒级时长应被解析并说出,实得 %q", secs)
}
// ③ 402:等待不会恢复,必须说清楚要充值(不能只说"限流")
out402, _, _ := vm.TransformError("channels-cline", 402,
`{"error":{"message":"Insufficient credits"}}`)
if !strings.Contains(out402, "充值") {
t.Errorf("402 应明确提示需充值(等待无用),实得 %q", out402)
}
// ④ 普通 429 带时长:如实说,但不谎报成额度耗尽
out429, _, _ := vm.TransformError("channels-cline", 429,
`{"error":{"message":"Rate limited. Try again in 30s"}}`)
if strings.Contains(out429, "额度已用完") {
t.Errorf("普通限流不得谎报成额度耗尽(那会让内核长时间冷却一个健康源),实得 %q", out429)
}
if !strings.Contains(out429, "限流") {
t.Errorf("普通 429 应说成限流,实得 %q", out429)
}
// ⑤ 多种外壳都要认(`{error:"…"}` 字符串 / `{msg}` / 非 JSON 纯文本)
//(只认一种会在别的形态下静默丢掉整条信息)
for _, shell := range []string{
`{"error":"Daily free limit reached. Try again in 1h"}`,
`{"msg":"Daily free limit reached. Try again in 1h"}`,
`Daily free limit reached. Try again in 1h`,
} {
o, _, _ := vm.TransformError("channels-cline", 429, shell)
if !strings.Contains(o, "额度已用完") {
t.Errorf("外壳 %q 未被识别为额度耗尽,实得 %q", shell, o)
}
}
}

View File

@ -1,171 +0,0 @@
package core
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"testing"
)
// CodeArts 专用适配器是通过**插件自装**(__llmsproxy_adapters.install)
// 引入的,不是内置适配器 —— 因为 AK/SK 签名属厂商知识,内置里出现 codearts
// 就破了"内核零厂商名"纪律。
//
// 这组判据要证明三件事:
// 1. 插件加载时确实把适配器装上了
// 2. 装上之后,源能引用它(不是"装了但引用不到")
// 3. 它产出的签名是**真的能验**(而不是拼出个像签名的字符串)
func TestCodeArtsAdapterInstalledByPlugin(t *testing.T) {
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)
}
// ① 适配器必须在插件加载后就位
adapters := c.ListAdapters()
found := false
for _, a := range adapters {
if a.Name == "codearts" {
found = true
}
}
if !found {
t.Fatalf("插件未装 codearts 适配器;已装: %#v", adapters)
}
// ② 归属必须是 channels 插件(证明是它装的,不是内置)
if owner := ps.AdapterOwnerOf("codearts"); owner != "channels" {
t.Errorf("适配器归属应为 channels,实得 %q", owner)
}
// ③ 签名必须真的可验:用 Go 标准库按 SDK-HMAC-SHA256 独立复算
got, err := ps.Call("channels", "__codearts_adapter_sign_probe", map[string]interface{}{
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/api/v2/chat/completions",
"method": "POST",
"body": `{"model":"CodeArts-Pangu"}`,
"api_key": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST","security_token":"STTEST"}`,
})
if err != nil {
t.Fatalf("签名探针: %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("探针无 json: %#v", got)
}
if m["error"] != nil {
t.Fatalf("探针报错: %v", m["error"])
}
auth, _ := m["authorization"].(string)
if !strings.HasPrefix(auth, "SDK-HMAC-SHA256 Access=AKTEST,SignedHeaders=") {
t.Fatalf("Authorization 形态不符(参考要求 Access= 不是 Credential=): %s", auth)
}
if !strings.Contains(auth, "Signature=") {
t.Fatalf("Authorization 缺 Signature: %s", auth)
}
// 签名后追加的头必须在,且不参与 canonical
for _, k := range []string{"Agent-Type", "X-Language"} {
if m["hdr_"+k] == nil || m["hdr_"+k] == "" {
t.Errorf("缺签名后追加的头 %s", k)
}
}
if m["hdr_Agent-Type"] != "PromptCenter" {
t.Errorf("Agent-Type = %v,应为 PromptCenter", m["hdr_Agent-Type"])
}
// canonical 不得包含签名后追加的头(参考注释点名:它们是签名后加的)
canon, _ := m["canonical"].(string)
if strings.Contains(canon, "agent-type") || strings.Contains(canon, "x-language") {
t.Errorf("canonical 不应含签名后追加的头:\n%s", canon)
}
// Authorization 不得进 canonical
if strings.Contains(canon, "authorization") {
t.Errorf("canonical 不应含 authorization:\n%s", canon)
}
// x-sdk-content-sha256 说明 payload hash 参与了签名
if !strings.Contains(canon, "x-sdk-content-sha256") {
t.Errorf("canonical 应含 x-sdk-content-sha256:\n%s", canon)
}
// URI 必须以 / 结尾(参考点名的易错点)
if !strings.Contains(canon, "/api/v2/chat/completions/\n") {
t.Errorf("canonical 的 URI 未补尾斜杠(参考点名的易错点):\n%s", canon)
}
// ④ **独立验签**:用 Go 标准库按 SDK-HMAC-SHA256 从 canonical 复算签名,
// 必须与适配器给出的 Signature 逐字节相同。
//
// 为什么必须有这一步:前面几条只验证"形态正确"(Access=、SignedHeaders、
// 尾斜杠)。签名算法本身写错(HMAC 输入少一段、canonical 分隔符用错)时
// 形态依然全对,但上游会 401。复算才是真正的判据。
sts, _ := m["string_to_sign"].(string)
if sts == "" {
t.Fatal("探针未带出 string_to_sign(判据必须比对真实函数算出的中间值)")
}
// string_to_sign = "SDK-HMAC-SHA256\n" + date + "\n" + sha256(canonical)
parts := strings.SplitN(sts, "\n", 3)
if len(parts) != 3 || parts[0] != "SDK-HMAC-SHA256" {
t.Fatalf("string_to_sign 头段不符: %q", sts)
}
canonSum := sha256.Sum256([]byte(canon))
if parts[2] != hex.EncodeToString(canonSum[:]) {
t.Errorf("string_to_sign 的 canonical 摘要不符\n算得: %s\n实得: %s",
hex.EncodeToString(canonSum[:]), parts[2])
}
mac := hmac.New(sha256.New, []byte("SKTEST"))
mac.Write([]byte(sts))
wantSig := hex.EncodeToString(mac.Sum(nil))
gotSig := auth[strings.Index(auth, "Signature=")+len("Signature="):]
if gotSig != wantSig {
t.Errorf("签名独立复算不符(算法写错?)\n复算: %s\n实得: %s", wantSig, gotSig)
}
b, _ := json.Marshal(m)
t.Logf("签名探针输出: %s", string(b))
}
// 源注册必须真的引用插件装的 codearts 适配器 —— "装了但源还指向 openai"
// 是这类改动最可能的漏配,且不会报错(只是签名不生效、上游 401)。
func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
// ⚠️ 必须用 poolCore:它给 channels 授予了 add_source/list_sources 等
// 内核权限。用 kernelTestCore 裸加载插件时 add_source 会被拒
//("kernel action add_source is not granted to plugin channels"),
// 而这个拒绝**不报错、只是不出源** —— 我就在这里绕了一圈才看出根因。
c, ps := poolCore(t)
// 建一个 codearts 账号 → 触发源注册
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "codearts",
"credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 报错: %#v", m)
}
aid, _ := j["account_id"].(string)
srcs := listSourcesViaKernel(t, c)
sm, ok := srcHasAccount(srcs, aid)
if !ok {
names := []string{}
for _, x := range srcs {
if n, _ := x["name"].(string); n != "" {
names = append(names, n)
}
}
t.Fatalf("codearts 账号 %q 应已同步源;现有源: %v", aid, names)
}
if ad, _ := sm["adapter"].(string); ad != "codearts" {
t.Errorf("源应引用插件装的 codearts 适配器,实得 %q(签名不会生效)", ad)
}
bu, _ := sm["base_url"].(string)
if !strings.Contains(bu, "snap-access") {
t.Errorf("base_url 不符: %q", bu)
}
}

View File

@ -1,174 +0,0 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"llmsproxy/internal/lua"
)
// ★ codearts 的模型目录必须从**两个**签名端点拉取并合并。
//
// 真实缺口(本轮发现):codearts 有 10 个可用账号,但目录里只有硬编码的
// 1 个模型 "CodeArts-Pangu"。参考实现(fetchCodeArtsRemoteModels)打的是:
//
// ① opengw gateway/config → result.models(benefit 模型)
// ② snap-access /v1/model/builtin → builtinModels(常规模型)
//
// 两者都**必须 AK/SK 签名**,而我们的 refreshRemoteModels 此前只支持 Bearer
// ⇒ 这两个端点从来没有被打过。
//
// 判据看**两个端点的真实命中**(各自拉到自己的字段名),而不是"目录非空"
// —— 后者只要兜底表在就恒真。
type codeartsCatalogHarness struct {
t *testing.T
ps *lua.Plugins
mu sync.Mutex
hits map[int]int // 端点序号 → 命中次数
gateway string
builtin string
authSeen []string
}
func newCodeartsCatalogHarness(t *testing.T) *codeartsCatalogHarness {
t.Helper()
h := &codeartsCatalogHarness{t: t, hits: map[int]int{}}
mk := func(idx int, body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
h.hits[idx]++
// ★ 必须看到 AK/SK 签名的三个特征头,否则等于没签名
authSeen := []string{
r.Header.Get("X-Sdk-Date"),
r.Header.Get("X-Sdk-Content-Sha256"),
r.Header.Get("Authorization"),
}
h.authSeen = append(h.authSeen, strings.Join(authSeen, "|"))
h.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
}
// ① gateway:benefit 模型在 result.models
gw := mk(0, `{"result":{"models":[{"model_id":"glm-5.3-flash"},{"model_id":"deepseek-v4-flash"}]}}`)
// ② snap builtin:常规模型在 builtinModels(**对象**,键为序号)
bi := mk(1, `{"count":2,"builtinModels":{"0":{"model_id":"GLM-5.2"},"1":{"model_id":"openpangu-2.0-pro"}}}`)
t.Cleanup(gw.Close)
t.Cleanup(bi.Close)
h.gateway, h.builtin = gw.URL, bi.URL
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)
}
h.ps = ps
// 把两个端点都指到假上游(顺序与平台配置一致)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "codearts",
"models_remote": map[string]interface{}{
"endpoint0": h.gateway,
"endpoint1": h.builtin,
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
// 塞一个 AK/SK 账号(签名分支需要凭据,否则会走 "无可用 AK/SK 凭据")
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "codearts",
"id": "cat-test",
"credential": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST",` +
`"security_token":"STTEST","user_name":"cat"}`,
}); err != nil {
t.Fatalf("account_add: %v", err)
}
return h
}
func TestCodeArtsCatalogPullsBothSignedEndpoints(t *testing.T) {
h := newCodeartsCatalogHarness(t)
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil {
t.Fatalf("model_refresh: %v", err)
}
h.mu.Lock()
hits := map[int]int{}
for k, v := range h.hits {
hits[k] = v
}
authSeen := append([]string(nil), h.authSeen...)
h.mu.Unlock()
// ① 两个端点都必须被命中(只打一个是真实缺口)
if hits[0] == 0 {
t.Fatal("gateway/config 端点从未被调用 —— benefit 模型(glm-5.3-flash 等)永远拿不到")
}
if hits[1] == 0 {
t.Fatal("snap /v1/model/builtin 端点从未被调用 —— 常规模型(GLM-5.2 / openpangu)永远拿不到")
}
// ② 每个请求都必须带 AK/SK 签名特征头(Bearer 打不通这两个端点)
for i, seen := range authSeen {
parts := strings.Split(seen, "|")
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
t.Fatalf("第 %d 个请求缺签名头(X-Sdk-Date/Content-Sha256/Authorization):%q", i+1, seen)
}
if !strings.Contains(parts[2], "SDK-HMAC-SHA256") {
t.Fatalf("Authorization 不是华为 SDK-HMAC-SHA256 形态,实得 %q", parts[2])
}
}
// ③ 合并结果必须同时含两个端点的模型(字段名不同:model_id,两边都要认)
got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"})
if err != nil {
t.Fatal(err)
}
ids := modelIDs(t, got)
joined := strings.Join(ids, ",")
for _, want := range []string{"glm-5.3-flash", "GLM-5.2", "openpangu-2.0-pro"} {
if !strings.Contains(joined, want) {
t.Fatalf("合并目录缺 %s(两个端点必须**合并**而非只用其一):%v", want, ids)
}
}
t.Logf("✓ 两端点各命中 %d/%d 次,合并得到 %d 个模型", hits[0], hits[1], len(ids))
}
// 兜底表在远端失败时必须仍然可用(不能因为加了目录就让平台变空)。
func TestCodeArtsCatalogKeepsFallbackOnFailure(t *testing.T) {
h := newCodeartsCatalogHarness(t)
// 让两个端点都 500
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
}))
defer dead.Close()
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "codearts",
"models_remote": map[string]interface{}{
"endpoint0": dead.URL, "endpoint1": dead.URL,
},
}); err != nil {
t.Fatal(err)
}
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil {
t.Fatalf("远端失败不该让 model_refresh 报错: %v", err)
}
got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"})
if err != nil {
t.Fatal(err)
}
ids := modelIDs(t, got)
if len(ids) == 0 {
t.Fatal("远端全失败时目录变空了 —— 兜底表必须补位(宁可留旧目录也别让平台变空)")
}
t.Logf("✓ 远端失败时兜底目录仍有 %d 个模型: %v", len(ids), ids)
}

View File

@ -1,186 +0,0 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// CodeArts 端到端:起真实回调服务器 → 模上游回 code → 走真实 STS 交换
// (带 DPoP)→ 凭据落盘。判据检查**交换请求本身的形态**(DPoP 头、
// grant_type、code_verifier、redirect_uri 端口),因为只断言"存了个 token"
// 无法防止把 OAuth 悄悄换回手填。
func TestCodeArtsOAuthEndToEnd(t *testing.T) {
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)
}
// 假 STS 上游:校验请求形态,返回 credentials + refresh_token
var gotForm url.Values
var gotDpop string
sts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotDpop = r.Header.Get("DPoP")
_ = r.ParseForm()
gotForm = r.PostForm
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"credentials": map[string]interface{}{
"access_key_id": "AKTEST",
"secret_access_key": "SKTEST",
"security_token": "STTEST",
"expiration": "2026-12-31T00:00:00Z",
},
"refresh_token": "RT-TEST",
})
}))
defer sts.Close()
// 把 STS 端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "codearts",
"codearts_sts": map[string]interface{}{"url": sts.URL},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
// 1) start_login → 拿回调地址与登录 URL
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"codearts"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
m := out.JSON.(map[string]interface{})
if m["error"] != nil {
t.Fatalf("start_login 报错: %v", m["error"])
}
loginURL, _ := m["url"].(string)
redirectURI, _ := m["redirect_uri"].(string)
sess := m["session"].(map[string]interface{})
sid, _ := sess["id"].(string)
t.Logf("login_url = %s", loginURL)
t.Logf("redirect_uri = %s", redirectURI)
// 2) 登录 URL 必须逐项符合参考 buildOAuthLoginUrl
if !strings.HasPrefix(loginURL, "https://codearts.huaweicloud.com/portal/authorize?") {
t.Fatalf("authorize base 不对: %s", loginURL)
}
for _, want := range []string{
"client_id=codearts-agent",
"uri_scheme=codearts-agent",
"code_challenge_method=SHA-256", // 不是 S256
"plugin-name=snap_AIIDE",
"plugin-version=5.2.0",
"theme=2",
"locale=zh-cn",
} {
if !strings.Contains(loginURL, want) {
t.Errorf("authorize URL 缺 %q\n实际: %s", want, loginURL)
}
}
// 反向:**不得**含 auth_callback_url(参考注释:会被 portal 判异常并回退旧流程)
if strings.Contains(loginURL, "auth_callback_url") {
t.Errorf("authorize URL 不应含 auth_callback_url(会导致 portal 回退旧流程): %s", loginURL)
}
// code_challenge 必须存在且非空
if !strings.Contains(loginURL, "code_challenge=") ||
strings.Contains(loginURL, "code_challenge=&") {
t.Errorf("缺 code_challenge: %s", loginURL)
}
// 3) 模拟上游重定向到回调(带 code)
u, err := url.Parse(redirectURI)
if err != nil {
t.Fatal(err)
}
q := u.Query()
q.Set("code", "AUTHCODE-TEST")
u.RawQuery = q.Encode()
resp, err := http.Get(u.String())
if err != nil {
t.Fatalf("打回调: %v", err)
}
resp.Body.Close()
// 4) poll_login → 触发真实 STS 交换
pout, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
t.Fatalf("poll_login: %v", err)
}
pm := pout.JSON.(map[string]interface{})
if pm["state"] != "completed" && pm["state"] != "success" && pm["state"] != "done" {
t.Fatalf("poll_login 未成功: %#v", pm)
}
// 5) 交换请求形态(判据的核心)
if gotDpop == "" {
t.Fatal("STS 请求缺 DPoP 头 —— 不是新式 OAuth")
}
if strings.Count(gotDpop, ".") != 2 {
t.Errorf("DPoP 应是三段 JWS,实得 %q", gotDpop)
}
if gotForm.Get("grant_type") != "authorization_code" {
t.Errorf("grant_type = %q,应为 authorization_code", gotForm.Get("grant_type"))
}
if gotForm.Get("client_id") != "codearts-agent" {
t.Errorf("client_id = %q", gotForm.Get("client_id"))
}
if gotForm.Get("code_verifier") == "" {
t.Error("缺 code_verifier —— PKCE 没生效")
}
if gotForm.Get("code") != "AUTHCODE-TEST" {
t.Errorf("code = %q", gotForm.Get("code"))
}
ruWant := "http://127.0.0.1" + u.Port() + "/oauth/callback"
if !strings.HasPrefix(redirectURI, ruWant[:len("http://127.0.0.1")]) {
t.Errorf("redirect_uri 主机不对: %s", redirectURI)
}
if gotForm.Get("redirect_uri") != redirectURI {
t.Errorf("redirect_uri = %q,应为回调地址 %q", gotForm.Get("redirect_uri"), redirectURI)
}
// 6) 凭据必须含续期材料(refresh_token / code_verifier / DPoP 私钥 JWK)
// 凭据必须含**续期材料** —— 这是 OAuth 流程存在的意义。
// 只断言"登录成功"无法防止凭据被截断成裸 AK/SK(那样 refresh 就废了)。
raw, err := ps.Call("channels", "__codearts_cred_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("凭据探针: %v", err)
}
w, _ := raw.(map[string]interface{})
cj, _ := w["json"].(map[string]interface{})
if cj == nil {
t.Fatalf("探针无 json: %#v", raw)
}
if cj["error"] != nil {
t.Fatalf("凭据未落盘: %v (refs=%v)", cj["error"], cj["refs"])
}
t.Logf("凭据 ref=%v access_key_id=%v", cj["ref"], cj["access_key_id"])
if cj["access_key_id"] != "AKTEST" {
t.Errorf("access_key_id = %v,应为 AKTEST(STS 响应未正确解析)", cj["access_key_id"])
}
for _, k := range []string{"refresh_token", "code_verifier", "dpop_private_key_jwk"} {
if cj[k] == nil || cj[k] == "" {
t.Errorf("凭据缺 %s(无法静默续期)", k)
}
}
if cj["refresh_token"] != "RT-TEST" {
t.Errorf("refresh_token = %v,应为 RT-TEST", cj["refresh_token"])
}
_ = json.Marshal
ps.InvokeHandler("channels", "stop_login", &lua.HandlerRequest{
Method: "POST", Body: `{"session_id":"` + sid + `"}`, Role: "admin",
})
}

View File

@ -1,112 +0,0 @@
package core
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"strings"
"testing"
)
// CodeArts 的 DPoP 证明是标准 ES256 JWS。用 Go 标准库(crypto/ecdsa)当权威
// 校验器验它 —— 只断言"能拼出字符串"是不够的:JWS 的 ES256 签名必须是
// **raw r||s(各 32 字节)**,而 openssl 默认输出 DER;写错格式服务端会拒,
// 而字符串看起来完全正常(这是最容易蒙混过去的一类缺陷)。
func TestCodeArtsDpopJwsVerifiesAsES256(t *testing.T) {
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)
}
got, err := ps.Call("channels", "__codearts_dpop_probe",
map[string]interface{}{"htm": "POST", "htu": "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"})
if err != nil {
t.Fatalf("probe: %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("无 json: %#v", got)
}
if m["error"] != nil {
t.Fatalf("探针报错: %v", m["error"])
}
jws, _ := m["jws"].(string)
if jws == "" {
t.Fatal("jws 为空")
}
parts := strings.Split(jws, ".")
if len(parts) != 3 {
t.Fatalf("JWS 应为三段,实得 %d 段", len(parts))
}
dec := func(s string) []byte {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
t.Fatalf("base64url 解码失败: %v", err)
}
return b
}
// ---- header ----
var hdr map[string]interface{}
if err := json.Unmarshal(dec(parts[0]), &hdr); err != nil {
t.Fatalf("header 不是 JSON: %v", err)
}
if hdr["alg"] != "ES256" {
t.Errorf("alg 应为 ES256,实得 %v", hdr["alg"])
}
if hdr["typ"] != "dpop+jwt" {
t.Errorf("typ 应为 dpop+jwt,实得 %v", hdr["typ"])
}
jwk, _ := hdr["jwk"].(map[string]interface{})
if jwk == nil {
t.Fatal("header 必须内嵌 jwk(DPoP 要求公钥随证明走)")
}
// ---- 用 jwk 重建公钥 ----
xb, err := base64.RawURLEncoding.DecodeString(jwk["x"].(string))
if err != nil {
t.Fatal(err)
}
yb, err := base64.RawURLEncoding.DecodeString(jwk["y"].(string))
if err != nil {
t.Fatal(err)
}
if len(xb) != 32 || len(yb) != 32 {
t.Fatalf("P-256 的 x/y 应各 32 字节,实得 %d/%d", len(xb), len(yb))
}
pub := &ecdsa.PublicKey{Curve: elliptic.P256(),
X: new(big.Int).SetBytes(xb), Y: new(big.Int).SetBytes(yb)}
// ---- payload ----
var pl map[string]interface{}
if err := json.Unmarshal(dec(parts[1]), &pl); err != nil {
t.Fatalf("payload 不是 JSON: %v", err)
}
for _, k := range []string{"htm", "htu", "iat", "jti"} {
if _, ok := pl[k]; !ok {
t.Errorf("payload 缺 %s(照参考 signDpopJws)", k)
}
}
if pl["htm"] != "POST" {
t.Errorf("htm = %v", pl["htm"])
}
// ---- 验签(关键:raw r||s,不是 DER)----
sig := dec(parts[2])
if len(sig) != 64 {
t.Fatalf("ES256 签名必须是 64 字节 raw r||s,实得 %d 字节(若是 70-72 字节,说明漏了 DER→raw 转换)", len(sig))
}
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(pub, digest[:], r, s) {
t.Fatal("ES256 验签失败 —— 签名内容或格式不对,服务端会拒绝这个 DPoP")
}
}

View File

@ -1,65 +0,0 @@
package core
import (
"fmt"
"testing"
"llmsproxy/internal/lua"
)
// CodeArts 的 portal 拒绝 <10000 的回调端口(参考 listenOnCallbackPort 的
// MIN_CALLBACK_PORT = 10000 及 `assigned >= MIN_CALLBACK_PORT` 校验)。
// 判据直接量**实际分配的端口**,不复用参考常量做断言来源。
func TestCodeArtsLoopbackPortAtLeast10000(t *testing.T) {
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)
}
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"codearts"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
m, _ := out.JSON.(map[string]interface{})
if m == nil {
t.Fatalf("无 JSON: %#v", out)
}
if m["error"] != nil {
t.Fatalf("报错: %v", m["error"])
}
ru, _ := m["redirect_uri"].(string)
if ru == "" {
t.Fatalf("无 redirect_uri: %#v", m)
}
t.Logf("redirect_uri = %s", ru)
var port int
if _, err := fmt.Sscanf(ru, "http://127.0.0.1:%d/", &port); err != nil {
if _, err2 := fmt.Sscanf(ru, "http://localhost:%d/", &port); err2 != nil {
t.Fatalf("解析端口失败: %v / %v (%s)", err, err2, ru)
}
}
if port < 10000 {
t.Fatalf("回调端口 %d < 10000 —— portal 会拒绝(参考 MIN_CALLBACK_PORT)", port)
}
// 回调路径也必须是注册值 /oauth/callback(参考 REDIRECT_PATH)
if !hasSuffix(ru, "/oauth/callback") {
t.Fatalf("回调路径应为 /oauth/callback,实得 %s", ru)
}
if s, _ := m["session"].(map[string]interface{}); s != nil {
if id, _ := s["id"].(string); id != "" {
ps.InvokeHandler("channels", "stop_login", &lua.HandlerRequest{
Method: "POST", Body: `{"session_id":"` + id + `"}`, Role: "admin",
})
}
}
}
func hasSuffix(s, suf string) bool {
return len(s) >= len(suf) && s[len(s)-len(suf):] == suf
}

View File

@ -1,204 +0,0 @@
package core
import (
"testing"
)
// 「已失效模型」判据必须严格 —— 误判的代价是把**可用模型藏 30 天**。
//
// 参考为这些判据写了很长的注释(都是实测误判教训),本判据把它们钉住:
//
// - `model_not_found` 在遥测/日志里常作**字段名**出现(后面还有内容),
// 只在它"独占一句话成分"时才算阳性;
// - rate limit / quota / unauthor / forbidden 一律**不是**下架;
// - `expired` 必须带主语(token/session/key/…),否则会误杀正常下架文案
// `model expired-v3 does not exist`;
// - 只认 found/exist,**不认** available/supported(那是"换套餐就能恢复")。
func TestDeadModelGoneJudgement(t *testing.T) {
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)
}
cases := []struct {
msg string
want bool
why string
}{
// ── 阳性:真的是下架 ──
{"model not found", true, "经典文案"},
{"unknown model: foo", true, "unknown model"},
{"no such model", true, "no such model"},
{"The model does not exist", true, "does not exist"},
{"error: model_not_found", true, "独占一句话(行尾)"},
{"model_not_found.", true, "后面是句末标点"},
// ── 阴性:看着像但**不是**下架 ──
{"error_class=model_not_found, retry allowed", false,
"字段名后面还有内容(初版误判下架,实测教训)"},
{"the model_not_found metric was not exported", false,
"作为指标名出现(实测教训)"},
{"rate limit exceeded for model foo", false, "限流换时段就能用"},
{"quota exceeded", false, "额度问题"},
{"insufficient balance", false, "余额问题"},
{"401 unauthorized", false, "认证问题"},
{"invalid api key", false, "认证问题"},
{"token expired", false, "token 过期(带主语)"},
// ⚠️ 这条最微妙:expired 无主语时**不**能触发 NOT_GONE,
// 否则会误杀 "model expired-v3 does not exist" 这类真实下架文案。
{"model expired-v3 does not exist", true,
"expired 无主语 → 不算认证失败;does not exist 才是真判据"},
// ⚠️ 两条更正(对照参考实现的实际行为,不是注释字面):
// 1. `lease expired` 单独出现**不算**下架 —— 它是租约/认证问题。
// 2. 即便带上 does not exist 也**仍不算**:参考的判定顺序是
// NOT_GONE 先于 GONE(`isModelGoneError` 第 265 行先查排除表),
// 一旦命中排除词就直接 false、不再看后面的不存在语义。
// 我最初按注释字面以为"带主语才对、带上就想判下架",是读错了。
{"the model lease expired", false, "租约/认证问题,不是下架"},
{"the model lease expired and does not exist", false,
"参考的顺序是 NOT_GONE 先行 ⇒ 命中排除词即 false"},
// available/supported 不算(换套餐就能恢复)
{"model not available in your region", false,
"available 不是 found/exist —— 换地域就能用(参考明确点名)"},
{"", false, "空串"},
}
for _, tc := range cases {
out, err := ps.Call("channels", "__is_model_gone_probe",
map[string]interface{}{"message": tc.msg})
if err != nil {
t.Fatalf("探针: %v", err)
}
w, _ := out.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
got, _ := j["gone"].(bool)
if got != tc.want {
t.Errorf("isModelGone(%q) = %v,应为 %v(%s)", tc.msg, got, tc.want, tc.why)
}
}
}
// 记录-剔除-恢复 的闭环,以及**不拿远端目录反推**这条纪律。
func TestDeadModelRecordAndPrune(t *testing.T) {
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)
}
before := modelIDsFor(t, ps, "zcode")
if len(before) == 0 {
t.Fatal("zcode 应有兜底模型")
}
victim := before[0]
// 记录一条阳性证据 → 该模型必须从列表消失
if _, err := ps.Call("channels", "__record_dead_probe",
map[string]interface{}{"platform": "zcode", "model_id": victim, "reason": "model not found"}); err != nil {
t.Fatalf("记录探针: %v", err)
}
after := modelIDsFor(t, ps, "zcode")
for _, id := range after {
if id == victim {
t.Errorf("已失效模型 %q 仍出现在列表里(应剔除)", victim)
}
}
if len(after) != len(before)-1 {
t.Errorf("剔除后应少一个:前 %d 后 %d", len(before), len(after))
}
// model_clear_dead 应把它恢复回来(用户确认上游又上了 / 误判时用)
m, err := ps.Call("channels", "model_clear_dead",
map[string]interface{}{"platform": "zcode", "model_id": victim})
if err != nil {
t.Fatalf("model_clear_dead: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["ok"] != true {
t.Fatalf("model_clear_dead 失败: %#v", m)
}
restored := modelIDsFor(t, ps, "zcode")
found := false
for _, id := range restored {
if id == victim {
found = true
}
}
if !found {
t.Errorf("clear 后 %q 应恢复,实得 %v", victim, restored)
}
}
// 目录闸门:失败后进入冷却,冷却期内不重复拉(照参考 remote-catalog-gate)。
// 判据用冷却状态本身,不依赖真实网络。
func TestRemoteCatalogGateCooldown(t *testing.T) {
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)
}
// 初始不冷却
g := func() (bool, bool) {
out, err := ps.Call("channels", "__catalog_gate_probe",
map[string]interface{}{"platform": "zcode"})
if err != nil {
t.Fatalf("探针: %v", err)
}
w, _ := out.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
began, _ := j["began"].(bool)
cooling, _ := j["cooling"].(bool)
return began, cooling
}
if _, cooling := g(); cooling {
t.Error("初始不该处于冷却")
}
// 模拟一次失败 → 进冷却
if _, err := ps.Call("channels", "__catalog_gate_fail_probe",
map[string]interface{}{"platform": "zcode"}); err != nil {
t.Fatalf("失败探针: %v", err)
}
began, cooling := g()
if !cooling {
t.Error("失败后必须进入冷却(否则会对坏上游反复打 —— 参考点名的首屏卡死)")
}
if began {
t.Error("冷却期内不该再发起拉取")
}
}
// modelIDsFor 读某平台当前对外的模型 id 列表。
func modelIDsFor(t *testing.T, ps interface {
Call(string, string, map[string]interface{}) (interface{}, error)
}, plat string) []string {
t.Helper()
m, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": plat})
if err != nil {
t.Fatalf("model_list(%s): %v", plat, err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
var out []string
if arr, ok := j["models"].([]interface{}); ok {
for _, it := range arr {
if mm, ok := it.(map[string]interface{}); ok {
if id, ok := mm["id"].(string); ok {
out = append(out, id)
}
}
}
}
return out
}

View File

@ -1,40 +0,0 @@
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
}

View File

@ -1,76 +0,0 @@
package core
import (
"regexp"
"strings"
"testing"
)
// ★ 源的 chat URL = `base_url + endpoint`(内核 provider.go ChatURL)。
//
// 真实缺陷(生产实测):多个渠道平台的 `base_url` 里塞了**完整路径**,
// 而适配器默认 endpoint 又是 "/chat/completions" ⇒ 实际请求变成
//
// https://copilot.tencent.com/v2/chat/completions/chat/completions
//
// ⇒ 上游回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞,不是模型错误)。
//
// 受影响平台 8 个:buddy / cline / lobsterai / opencode / qoder / loomy /
// raccoon(zcode 当时已用 endpoint 覆盖,形态正确)。
//
// ★ 为什么这个缺陷能活很久:**模型名被多个源服务**。
//
// `deepseek-v4.1-flash` 同时由 4 个源提供(alittokenplan/vela/oin/channels-buddy…)
// ⇒ 网关在 buddy 源失败后会**继续尝试下一个源**并成功 ⇒ 用户看到"能回话",
// 于是"buddy 通道是好的"这个结论**完全没有证据**。
// 而 `hy3` 只由 buddy 一个源提供 ⇒ 一调就暴露真因。
// ⇒ 判据必须断言**源的 URL 形态**(结构不变量),不能靠"某个模型能调通"。
//
// 判据:base_url 不得自带任何 chat 路径段(它只应是 host[+协议公共前缀]),
// 路径必须在 `endpoint` 里。
func TestChannelSourceBaseURLHasNoChatPath(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// 只在平台定义区里找 base_url(避免误判注释/文档里的示例)
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
// 已知适配器会把这些路径各追加一次;出现即说明 base_url 侵占了 endpoint 的地盘
banned := []string{"/chat/completions", "/v1/messages", "/generateContent", "/api/chat"}
found := 0
for _, m := range declRe.FindAllStringSubmatch(s, -1) {
url := m[1]
found++
for _, b := range banned {
if strings.Contains(url, b) {
t.Errorf("base_url 侵占了 endpoint 的路径:%s\n"+
" 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),"+
"适配器默认 endpoint=\"/chat/completions\" ⇒ 实际会请求 %s%s\n"+
" 修法:base_url 只留 host,把 `%s` 移到该平台的 `endpoint = \"...\"`",
url, url, b, b)
}
}
}
if found == 0 {
t.Fatal("没扫到任何 base_url 声明(判据失效)")
}
t.Logf("✓ %d 个 base_url 均不含 chat 路径段", found)
}
// ★ 反过来也要咬:说明该缺陷**确实已被修好**、而不是判据太松。
// 用一段**故意的坏数据**验证判据的判别力(变异测试的静态等价物)。
func TestChannelBaseURLJudgeBitesOnBadInput(t *testing.T) {
bad := ` base_url = "https://copilot.tencent.com/v2/chat/completions",`
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
m := declRe.FindStringSubmatch(bad)
if m == nil {
t.Fatal("判据的正则匹配不到坏数据 ⇒ 判据无判别力(形同虚设)")
}
if !strings.Contains(m[1], "/chat/completions") {
t.Fatal("坏数据未被识别 ⇒ 判据无判别力")
}
t.Log("✓ 判据对坏数据有判别力")
}

View File

@ -1,67 +0,0 @@
package core
import (
"testing"
)
// `isFree` 的判定链(照参考 cline-models.ts)。
//
// ★ 修的是一个与注释自相矛盾的缺陷:cline 的 3 个模型来自远端 **free 数组**,
// 却声明在 `models` 里 ⇒ 被标成收费。用户看不到哪个可以不花钱用。
//
// 参考的判定链(权威在前):
//
// ① 远端 free 数组(`recommended-models` 是**唯一权威**的免费集合;
// `/api/v1/models` 的 460 个 id 里根本没有 cline-free/*)
// ② 命名约定前缀 `cline-free/*`(远端未响应时的兜底)
// ③ 声明的 models_free
// ④ 能力表的 cost(models.dev)
//
// ⚠️ 免费是 **OR** 语义:多个来源任一确认免费就够。写成"后者覆盖前者"会让
// 「远端说免费、能力表拉不到」退回收费 —— 正是这类静默降级。
func TestIsFreeUsesAuthorityChain(t *testing.T) {
_, ps := poolCore(t)
// cline 的免费模型必须被标成免费(远端/free_prefix/声明 任一命中即可)
out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "cline"})
if err != nil {
t.Fatalf("model_list: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
got := map[string]bool{}
for _, it := range asArrInterface(j["models"]) {
if mm, ok := it.(map[string]interface{}); ok {
if id, ok := mm["id"].(string); ok {
if f, ok := mm["isFree"].(bool); ok {
got[id] = f
}
}
}
}
// `cline-free/*` 前缀命中 ⇒ 必须免费(命名约定兜底)
if !got["cline-free/mimo-v2.6-flash"] {
t.Errorf("cline-free/* 前缀的模型必须免费(命名约定兜底),实得 %v", got)
}
// stealth/space-bunny-alpha 来自远端 free 数组,也必须免费
if !got["stealth/space-bunny-alpha"] {
t.Errorf("远端 free 数组里的模型必须免费,实得 %v", got)
}
// 命名约定必须生效(远端未响应时唯一的兜底依据)
probe, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
"platform": "cline", "model_id": "cline-free/some-new-model",
})
pj, _ := probe.(map[string]interface{})["json"].(map[string]interface{})
if pj["is_free"] != true {
t.Error("远端未响应时,`cline-free/*` 前缀必须能判出免费(这是兜底依据)")
}
// 反向:收费通道的同名模型**不得**被判免费
//(参考点名:免费模型是独立 id,`cline-free/x` 与 `x` 是两个不同模型)
probe2, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
"platform": "cline", "model_id": "deepseek/deepseek-v4.1-flash",
})
pj2, _ := probe2.(map[string]interface{})["json"].(map[string]interface{})
if pj2["is_free"] == true {
t.Error("收费通道的模型不得因为前缀规则被判成免费")
}
}

View File

@ -1,63 +0,0 @@
package core
import (
"testing"
)
// 源注册必须**串行 + 节流**,而不是并发 2、无间隔。
//
// 照参考 model-gate.ts 的实测依据:这些平台的**并发配额按模型**分别计量,
// 与"还剩多少 token"无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下:
//
// GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过
// GLM-5.3 74 次 200 21 次重试 6 次最终 429
//
// 两条措施缺一不可:
//
// ① **串行**(max_concurrent=1):同一时刻只允许一个上游请求在飞 ——
// 并发发起必然浪费一个配额。
// ② **最小间隔**(rpm):串行只保证"不重叠",不保证"有间隔" —— 连续放行的
// 两次调用可能只隔几十毫秒,仍会撞并发窗口。
//
// 此前插件注册源时一律 max_concurrent=2 且**从不设 rpm** ⇒ 既会重叠又无间隔。
func TestChannelSourcesAreSerialWithThrottle(t *testing.T) {
c, ps := poolCore(t)
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "gated",
"credential": `{"access_token":"TOK"}`,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 失败: %#v", m)
}
aid, _ := j["account_id"].(string)
srcs := listSourcesViaKernel(t, c)
sm, ok := srcHasAccount(srcs, aid)
if !ok {
t.Fatal("账号未出源")
}
// ① 串行
if mc, _ := toFloat(sm["max_concurrent"]); mc != 1 {
t.Errorf("源必须 max_concurrent=1(并发发起必然浪费一个配额),实得 %v", sm["max_concurrent"])
}
// ② 节流(rpm>0 才有间隔)
if rpm, _ := toFloat(sm["rpm"]); rpm <= 0 {
t.Errorf("源必须设 rpm>0(串行不保证间隔),实得 %v", sm["rpm"])
}
}
func toFloat(v interface{}) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case int:
return float64(x), true
case int64:
return float64(x), true
}
return 0, false
}

View File

@ -1,110 +0,0 @@
package core
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"llmsproxy/internal/lua"
)
// 有道每日活动领取是**三步流程**,参考注释点名:无可用活动是**正常业务状态**,
// 不能显示成"领取失败"(否则用户会去查网络/账号,方向全错)。
// 本判据钉住决策表的四类结果:inactive / already-claimed / success / failed。
func TestLobsterActivityClaimDecisionTable(t *testing.T) {
type c struct {
name string
slot string
ctx string
wantKind string
wantCalled bool // 是否应发出 claim 请求
}
cases := []c{
{"槽位不可用→inactive", `{"code":0,"data":{"slotState":"unavailable","activity":{"activityCode":"A1"}}}`, "", "inactive", false},
{"无 activityCode→inactive", `{"code":0,"data":{"slotState":"available","activity":{}}}`, "", "inactive", false},
{"今天已领→already-claimed", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
`{"code":0,"data":{"state":{"claimedToday":true},"actions":["check_in"]}}`, "already-claimed", false},
{"无 check_in 动作→inactive", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
`{"code":0,"data":{"state":{"claimedToday":false},"actions":["share"]}}`, "inactive", false},
{"可领→success", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
`{"code":0,"data":{"state":{"claimedToday":false},"actions":["check_in"]}}`, "success", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
claimHit := 0
var claimBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/client-activities/slot":
fmt.Fprint(w, tc.slot)
case r.URL.Path == "/api/client-activities/A1/context":
if tc.ctx == "" {
w.WriteHeader(500)
return
}
fmt.Fprint(w, tc.ctx)
case r.URL.Path == "/api/client-activities/A1/actions/check_in":
claimHit++
b := make([]byte, r.ContentLength)
_, _ = r.Body.Read(b)
claimBody = string(b)
fmt.Fprint(w, `{"code":0,"data":{"result":{"creditsGranted":30}}}`)
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
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)
}
// 把账号基址指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "lobsterai",
"lobster_activity_base": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 存入凭据(claim 需要结构化 credential 对象)
cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"})
body, _ := json.Marshal(map[string]string{
"platform": "lobsterai", "credential": string(cred), "account": "t",
})
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: string(body),
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: `{"platform":"lobsterai"}`,
})
if err != nil {
t.Fatalf("claim_daily: %v", err)
}
j, _ := out.JSON.(map[string]interface{})
oc, _ := j["outcome"].(map[string]interface{})
if oc == nil {
t.Fatalf("应返回 outcome 判别联合,实得 %v", j)
}
if k, _ := oc["kind"].(string); k != tc.wantKind {
t.Fatalf("kind 应为 %s,实得 %v(%v)", tc.wantKind, oc["kind"], oc["message"])
}
if (claimHit > 0) != tc.wantCalled {
t.Errorf("claim 请求次数 = %d,期望发出=%v", claimHit, tc.wantCalled)
}
if tc.wantCalled && claimBody == "" {
t.Error("claim 请求体不应为空(要带 configRevision 与幂等键)")
}
})
}
}

View File

@ -1,75 +0,0 @@
package core
import (
"encoding/json"
"llmsproxy/internal/lua"
"net/http"
"net/http/httptest"
"testing"
)
func TestLobsterExchangePreservesIdentity(t *testing.T) {
var calls int
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var b map[string]string
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
t.Error(err)
}
if b["authCode"] != "quote\"&code" || len(b["uuid"]) != 36 || b["firstKeyfrom"] == "" || b["firstKeyfrom"] == "cli" {
t.Errorf("invalid exchange fields: %#v", b)
}
if r.Header.Get("Content-Type") != "application/json" || r.Header.Get("User-Agent") != "LobsterAI/0.1.0" {
t.Error("wrong headers")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":0,"data":{"accessToken":"at-test","refreshToken":"rt-test","expiresIn":3600,"user":{"id":"uid1","userId":"account1","nickname":"test"}}}`))
}))
defer ts.Close()
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)
}
// The fixture changes only the transport destination, not the upstream contract.
if _, err = ps.Call("channels", "set_endpoints", map[string]interface{}{"platform": "lobsterai", "callback": map[string]interface{}{"token_url": ts.URL}}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"lobsterai"}`, Role: "admin"})
if err != nil {
t.Fatal(err)
}
nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
req := &lua.HandlerRequest{Method: "GET", Query: map[string]string{"state": nonce, "authCode": "quote\"&code"}}
out, err = ps.InvokeHandler("channels", "oauth_callback", req)
if err != nil {
t.Fatal(err)
}
if out.Status != 200 {
t.Fatalf("callback: %#v", out)
}
raw, ok, err := ps.GetSecret("channels", "channels:lobsterai")
if err != nil || !ok {
t.Fatal("missing credential", err)
}
var cred map[string]interface{}
if err = json.Unmarshal([]byte(raw), &cred); err != nil {
t.Fatal(err)
}
for _, k := range []string{"access_token", "refresh_token", "uuid", "first_keyfrom", "latest_keyfrom", "expires_at", "uid", "user_id"} {
if cred[k] == nil || cred[k] == "" {
t.Errorf("missing %s", k)
}
}
out, err = ps.InvokeHandler("channels", "oauth_callback", req)
if err != nil {
t.Fatal(err)
}
if out.Status != 409 || calls != 1 {
t.Fatalf("replay accepted: status=%d calls=%d", out.Status, calls)
}
}

View File

@ -1,165 +0,0 @@
package core
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"llmsproxy/internal/lua"
)
// 有道登录闭环的判据:portal 会把授权重定向回 **本机 loopback 接收器**
// (127.0.0.1:{port}/auth/callback 精确路径),而不是用户机器的 localhost、
// 也不是本插件的 /api/plugins/http 挂载点。远程用户在服务端浏览器里完成后,
// 重定向落本机端口,接收器写文件,poll_login 读它完成交换。
//
// 参考 lobsterai-oauth.ts 明确:redirect_uri 必须该形态(登录页会校验),
// state 不匹配必须拒绝(400),交换体含 authCode/firstKeyfrom/latestKeyfrom/
// uuid/version 五字段,凭据必须带 uuid+first_keyfrom(否则续期失败)。
func TestLobsterLoopbackClosedLoop(t *testing.T) {
var exch int
var gotVersion string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
exch++
var b map[string]string
_ = json.NewDecoder(r.Body).Decode(&b)
if b["authCode"] != "AC-1" {
t.Errorf("exchange authCode = %q", b["authCode"])
}
gotVersion = b["version"]
if b["firstKeyfrom"] == "" || b["uuid"] == "" {
t.Errorf("exchange 缺字段: %#v", b)
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"code":0,"data":{"accessToken":"AT-1","refreshToken":"RT-1","expiresIn":3600,"user":{"id":"UID1"}}}`)
}))
defer ts.Close()
// 版本号是 exchange 的必填字段,**不能写死**:2026-10-13 实测上游已是
// 2026.9.23,而参考的兜底常量还停在 2026.9.4。此处假上游返回真形态
// (非统一信封,值在 data.value.version)。
ver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"data":{"value":{"version":"2026.9.23","date":"2026-9-23"}}}`)
}))
defer ver.Close()
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", "remove_source", "status"}, false)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "lobsterai",
"callback": map[string]interface{}{"token_url": ts.URL},
"client_version": map[string]interface{}{"url": ver.URL},
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"lobsterai"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
sess := j["session"].(map[string]interface{})
nonce := sess["nonce"].(string)
sid := sess["id"].(string)
redirectURI, _ := j["redirect_uri"].(string)
if !strings.HasPrefix(redirectURI, "http://127.0.0.1:") || !strings.HasSuffix(redirectURI, "/auth/callback") {
t.Fatalf("redirect_uri 必须是 127.0.0.1:{port}/auth/callback,实得 %q", redirectURI)
}
url, _ := j["url"].(string)
if !strings.Contains(url, "/portal#/login?") {
t.Fatalf("授权页应是 /portal#/login?…,实得 %q", url)
}
if !strings.Contains(url, "source=electron") {
t.Fatalf("授权页缺 source=electron: %q", url)
}
t.Run("行1 state 不匹配被拒", func(t *testing.T) {
resp, err := http.Get(redirectURI + "?code=AC-1&state=WRONG")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 400 {
t.Fatalf("state 不匹配应 400,实得 %d", resp.StatusCode)
}
if exch != 0 {
t.Fatal("state 不匹配不应触发交换")
}
})
t.Run("行2 正确回调完成登录并落完整凭据", func(t *testing.T) {
resp, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("合法回调应 200,实得 %d", resp.StatusCode)
}
// 接收器写文件后 poll_login 读它完成交换(可能需要一拍)
var st map[string]interface{}
for i := 0; i < 20; i++ {
o, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
st = o.JSON.(map[string]interface{})
if st["state"] != "pending" {
break
}
time.Sleep(100 * time.Millisecond)
}
if st["state"] != "completed" {
t.Fatalf("应完成登录,实得 %v", st)
}
raw, ok, err := ps.GetSecret("channels", "channels:lobsterai")
if err != nil || !ok {
t.Fatalf("凭据未落库: %v", err)
}
var cred map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
t.Fatal(err)
}
for _, k := range []string{"access_token", "refresh_token", "uuid", "first_keyfrom", "expires_at", "uid"} {
if cred[k] == nil || cred[k] == "" {
t.Errorf("凭据缺 %s(有道的 uuid/first_keyfrom 丢了就续期失败): %s", k, raw)
}
}
if cred["access_token"] != "AT-1" {
t.Fatalf("access_token = %v", cred["access_token"])
}
// 版本号必须来自远端(2026.9.23),不能是写死的兜底 2026.9.4
if gotVersion != "2026.9.23" {
t.Fatalf("exchange version 应为远端 2026.9.23(写死 2026.9.4 是 bug),实得 %q", gotVersion)
}
})
t.Run("行3 接收器用完即收", func(t *testing.T) {
// 完成后监听应已关闭(连接被拒)
_, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce)
if err == nil {
t.Fatal("接收器应在完成后关闭")
} else if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "connect") {
t.Logf("关闭判定: %v", err)
}
})
_ = fmt.Sprint
}

View File

@ -1,177 +0,0 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// loomyHarness:Loomy 微信扫码长轮询的测试台。
//
// 参考语义(loomy-wechat.ts 点名,早期版本**读反了**导致"扫码后卡在已扫码"):
//
// 408 = 等待扫码;404 = 已扫码待确认(继续轮询);
// 405 = **已确认**(wx_code 就在这一帧);403 = 取消;402 = 失效
type loomyHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
confirmAt int32
gotAuth string
}
func newLoomyHarness(t *testing.T) *loomyHarness {
t.Helper()
h := &loomyHarness{t: t, confirmAt: 3}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/connect/qrconnect"):
w.Header().Set("Content-Type", "text/html")
// 夹具必须用**真实微信页面的形态**:uuid 内嵌在
// <img class="js_qrcode_img" src="/connect/qrcode/<uuid>"/>。
// 用别的形态(如 var uuid = "...")测出来的正则不是生产要用的那条。
fmt.Fprint(w, `<html><img class="js_qrcode_img" src="/connect/qrcode/UUID-TEST-1"/></html>`)
case strings.HasPrefix(r.URL.Path, "/connect/l/qrconnect"):
n := atomic.AddInt32(&h.polls, 1)
w.Header().Set("Content-Type", "text/plain")
if n == 1 {
fmt.Fprint(w, "window.wx_errcode=408;") // 等待扫码
return
}
if n < atomic.LoadInt32(&h.confirmAt) {
fmt.Fprint(w, "window.wx_errcode=404;") // 已扫码待确认
return
}
fmt.Fprint(w, `window.wx_errcode=405;window.wx_code='WX-CODE-1';`) // 已确认
case r.URL.Path == "/login/thirdAccount/bind/auth":
h.gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"code":0,"data":{"rcode":"RCODE-1","bind":1,"nickname":"loomy-dev","userid":"u5"}}`)
default:
w.WriteHeader(404)
}
}))
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", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "loomy",
"wechat": map[string]interface{}{
"auth_url": h.ts.URL + "/connect/qrconnect?state={state}",
"qr_url": h.ts.URL + "/connect/qrcode/{uuid}",
"poll_url": h.ts.URL + "/connect/l/qrconnect",
"account_base": h.ts.URL,
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *loomyHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"loomy"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *loomyHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestLoomyWechatPollBehavior 逐行锁定微信长轮询的状态判据。
func TestLoomyWechatPollBehavior(t *testing.T) {
h := newLoomyHarness(t)
t.Run("行1 授权页抽出uuid并给出二维码", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
qr, _ := j["qr_url"].(string)
if !strings.Contains(qr, "UUID-TEST-1") {
t.Fatalf("二维码 URL 应含抽出的 uuid, 实得 %q", qr)
}
})
t.Run("行2 408等待_404已扫码但不算完成", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.confirmAt, 99)
_, sid := h.begin()
// 第一拍 408
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("408 应 pending, 实得 %v", st["state"])
}
// 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里)
st := h.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"])
}
})
t.Run("行3 405才算确认_并换到凭据", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.confirmAt, 3)
_, sid := h.begin()
h.pollOnce(sid) // 408
h.pollOnce(sid) // 404
st := h.pollOnce(sid) // 405
if st["state"] != "completed" {
t.Fatalf("405 应 completed, 实得 %v (msg=%v)", st["state"], st["message"])
}
// 账号端点必须用讯飞签名头(account ak:sig),不是 Bearer
if !strings.HasPrefix(h.gotAuth, "account ") {
t.Fatalf("账号端点应用 account ak:sig 签名, 实得 %q", h.gotAuth)
}
if !strings.Contains(h.gotAuth, ":") {
t.Fatalf("签名头应含冒号分隔的 ak:sig, 实得 %q", h.gotAuth)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:loomy")
if !ok || !strings.Contains(v, "RCODE-1") {
t.Fatalf("凭据应含 rcode, 实得 %q", v)
}
})
t.Run("行4 403取消_402失效_都是终态失败", func(t *testing.T) {
for _, tc := range []struct{ errcode, want string }{
{"403", "取消"}, {"402", "失效"},
} {
hh := newLoomyHarness(t)
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/connect/qrconnect") {
fmt.Fprint(w, `<img src="/connect/qrcode/U">`)
return
}
fmt.Fprintf(w, "window.wx_errcode=%s;", tc.errcode)
})
_, sid := hh.begin()
st := hh.pollOnce(sid)
if st["state"] != "failed" {
t.Fatalf("%s 应 failed, 实得 %v", tc.errcode, st["state"])
}
}
})
}

View File

@ -1,201 +0,0 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"llmsproxy/internal/lua"
)
// 远端模型目录的判据:**远端为主 + 兜底补位**(照参考 reconcileWithFallback)。
//
// 参考明确:"正确做法是**合并**,而不是「远端失败就用兜底」的简单回退"
// (cline-models.ts)。故这里逐条锁定:
// - 远端有的以远端为准(顺序也照远端)
// - 远端没有的用兜底表补位
// - 拉取失败**不清空**既有缓存(宁可留旧目录也别让平台变空)
// - 平台声明的真实目录 key 必须在上游源里(占位名会让选模型调不通)
type modelsHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
mu sync.Mutex
body string
code int
hits int
}
func newModelsHarness(t *testing.T, platform, body string) *modelsHarness {
t.Helper()
h := &modelsHarness{t: t, body: body, code: 200}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
h.hits++
code, body := h.code, h.body
h.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
fmt.Fprint(w, body)
}))
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", "remove_source", "list_sources", "status"}, false)
// 说明:测试上游是 127.0.0.1(loopback),出站白名单默认允许,
// 不需要额外授权 —— 也就不需要碰内核的授权 API。
// 把该平台的远端端点指到假上游
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": platform,
"models_remote": map[string]interface{}{
"endpoint0": h.ts.URL,
},
}); err != nil {
t.Logf("set_endpoints(models_remote) 未支持: %v", err)
}
return h
}
// modelIDs 从 model_list 的 Call 返回值({json:{models:[...]}})取 id 列表。
func modelIDs(t *testing.T, got interface{}) []string {
t.Helper()
j := got.(map[string]interface{})["json"].(map[string]interface{})
ms, _ := j["models"].([]interface{})
ids := make([]string, 0, len(ms))
for _, e := range ms {
ids = append(ids, e.(map[string]interface{})["id"].(string))
}
return ids
}
func TestRemoteModelCatalogMergesWithFallback(t *testing.T) {
// zcode:远端 builtinModels 是**对象**(键为序号),且远端/兜底有重叠
body := `{"data":{"builtinModels":{"0":{"modelId":"GLM-5.3"},"1":{"modelId":"GLM-5.3-Flash"},"2":{"modelId":"NEW-MODEL-X"}}}}`
h := newModelsHarness(t, "zcode", body)
t.Run("行1 远端拉到就用远端并保留兜底独有的", func(t *testing.T) {
got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]interface{})["json"].(map[string]interface{})
res := m["results"].([]interface{})[0].(map[string]interface{})
if res["ok"] != true {
t.Fatalf("刷新应成功, 实得 %v", res)
}
// NEW-MODEL-X 只由远端下发,必须出现(证明用的不是硬编码表)
ml, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"})
if err != nil {
t.Fatal(err)
}
ids := modelIDs(t, ml)
joined := strings.Join(ids, ",")
if !strings.Contains(joined, "NEW-MODEL-X") {
t.Fatalf("只由远端下发的模型必须出现(否则是硬编码表): %v", ids)
}
if !strings.Contains(joined, "GLM-5.3") {
t.Fatalf("兜底/远端共有项应保留: %v", ids)
}
// 远端与兜底重叠项不得重复
seen := map[string]int{}
for _, id := range ids {
seen[id]++
if seen[id] > 1 {
t.Fatalf("模型 %s 重复出现(合并应去重): %v", id, ids)
}
}
})
t.Run("行2 拉取失败不清空既有缓存", func(t *testing.T) {
// 先成功一次建立缓存
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"}); err != nil {
t.Fatal(err)
}
// 再让上游 500
h.mu.Lock()
h.code = 500
h.body = `{}`
h.mu.Unlock()
got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"})
if err != nil {
t.Fatal(err)
}
res := got.(map[string]interface{})["json"].(map[string]interface{})["results"].([]interface{})[0].(map[string]interface{})
if res["ok"] == true {
t.Fatalf("上游 500 时刷新应报失败, 实得 %v", res)
}
ml, _ := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"})
ids := modelIDs(t, ml)
if !strings.Contains(strings.Join(ids, ","), "NEW-MODEL-X") {
t.Fatalf("拉取失败必须保留既有目录(不能让平台变空): %v", ids)
}
})
}
// TestPlatformModelsAreRealCatalogKeys 锁定 12 平台的模型名**不是编的占位名**。
// 此前 qoder/zcode/loomy/raccoon 用的是 "*-default"(我编的),上游不认 ——
// 后果是这些平台登录成功后选任何模型都调不通,而测试全绿(它测的就是我编的名字)。
func TestPlatformModelsAreRealCatalogKeys(t *testing.T) {
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)
}
got, err := ps.Call("channels", "provider_status", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
plats := got.(map[string]interface{})["json"].(map[string]interface{})["statuses"].([]interface{})
if len(plats) != 12 {
t.Fatalf("应有 12 个真实平台, 实得 %d", len(plats))
}
realQoder := []string{"qfmodel", "qmodel_38max", "kmodel_latest", "dmodel"}
sawQoder := 0
for _, e := range plats {
p := e.(map[string]interface{})
id := p["id"].(string)
ml, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": id})
if err != nil {
t.Fatalf("平台 %s model_list: %v", id, err)
}
ids := modelIDs(t, ml)
if len(ids) == 0 {
t.Fatalf("平台 %s 无模型", id)
}
for _, mid := range ids {
// 占位名特征:以 -default 结尾
if strings.HasSuffix(mid, "-default") {
t.Fatalf("平台 %s 的模型 %q 是编的占位名(上游不认,选了调不通)", id, mid)
}
}
if id == "qoder" {
j := map[string]bool{}
for _, mid := range ids {
j[mid] = true
}
for _, want := range realQoder {
if j[want] {
sawQoder++
}
}
}
}
if sawQoder == 0 {
t.Fatal("qoder 应含参考的真实目录 key(如 qfmodel),而非编造的占位名")
}
}

View File

@ -1,405 +0,0 @@
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
}

View File

@ -1,82 +0,0 @@
package core
import (
"os"
"path/filepath"
"testing"
"llmsproxy/internal/lua"
)
// ★ 插件加载时必须清掉上一进程留下的孤儿回调文件。
//
// 真实事故:生产 `/etc/llmsproxy/plugins/` 攒了 **18 个 callback-*.port**
// (对应的 .json 早已消失)。它们永远不会被清,因为:
//
// · loopbacks 追踪表只活在内存(plugin.state.loopbacks);
// · 进程被强杀时 on_unload 没机会跑;
// · 重启后内存表是空的 ⇒ reapLoopback 根本不知道它们存在。
//
// 判据看的是**目录的真实状态**(孤儿必须消失、活跃会话的文件不能被误删),
// 而不是"某函数被调用过"。
func TestPluginLoadSweepsOrphanCallbackFiles(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
// 摆上一轮的残留(模拟上一进程被强杀后的现场)
orphans := []string{
filepath.Join(pluginDir, "callback-deadbeef0001.port"),
filepath.Join(pluginDir, "callback-deadbeef0001.json"),
filepath.Join(pluginDir, "callback-deadbeef0002.port"),
}
for _, f := range orphans {
if err := os.WriteFile(f, []byte("12345"), 0600); err != nil {
t.Fatal(err)
}
}
// 一份**不属于**回调的文件:必须不被误删(否则清扫过头会毁掉账号池)
keep := filepath.Join(pluginDir, ".channels-store.json")
if err := os.WriteFile(keep, []byte(`{"accounts":{}}`), 0600); err != nil {
t.Fatal(err)
}
// 加载插件 = 走真实启动路径
c := kernelTestCore(t)
defer c.Close()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
ps := c.Plugins()
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 顶层清扫代码在加载期就跑了;给 popen 的 rm 一点时间落盘
for i := 0; i < 50; i++ {
left := 0
for _, f := range orphans {
if _, err := os.Stat(f); err == nil {
left++
}
}
if left == 0 {
break
}
}
for _, f := range orphans {
if _, err := os.Stat(f); err == nil {
t.Fatalf("孤儿回调文件未被清扫: %s —— 它会永久留在磁盘上"+
"(真实事故:生产攒了 18 个)", filepath.Base(f))
}
}
if _, err := os.Stat(keep); err != nil {
t.Fatalf("清扫过头了:%s 被误删(账号池/索引绝不能碰)", filepath.Base(keep))
}
t.Log("✓ 孤儿回调文件被清,非回调文件保留")
}
var _ = lua.Plugins{}

View File

@ -1,438 +0,0 @@
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)
}
}
// TestChannelsBrowserStackDeps 锁定浏览器栈的依赖预检能力。
//
// 这个能力此前**不存在**:代码里写着「运行 deploy 脚本一键安装」,但安装机制
// 根本没有(deploy.sh 里零浏览器栈内容),于是 VNC 兜底在缺依赖的机器上只会
// 报一句含糊的"进程启动失败"。现在如实报告缺什么、装哪个包。
func TestChannelsBrowserStackDeps(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)
}
out, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{
Method: "GET", Role: "admin",
})
if err != nil {
t.Fatalf("deps_status: %v", err)
}
m, ok := out.JSON.(map[string]interface{})
if !ok {
t.Fatalf("deps_status 应返回对象: %#v", out.JSON)
}
if _, has := m["ok"]; !has {
t.Fatal("deps_status 必须报告 ok")
}
if _, has := m["missing"]; !has {
t.Fatal("deps_status 必须报告 missing 清单")
}
if m["ok"] == false {
hint, _ := m["install_hint"].(string)
if hint == "" {
t.Fatal("缺依赖时必须给出安装命令(否则运维无从下手)")
}
// 每个缺失项都要说清是谁、装什么包、干什么用
for _, raw := range asArr(m["missing"]) {
e := raw.(map[string]interface{})
for _, k := range []string{"bin", "pkg", "why"} {
if s, _ := e[k].(string); s == "" {
t.Fatalf("缺少字段 %s: %v", k, e)
}
}
}
t.Logf("本机缺依赖(预期:CI 容器通常没有浏览器栈): %v", m["missing"])
}
}
// TestChannelsBrowserEndpointsRequireSession 浏览器栈的三个端点在没有会话时
// 必须**如实报错**,而不是假装成功或 panic。
func TestChannelsBrowserEndpointsRequireSession(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)
}
// 无会话时 collect 必须报错
out, err := ps.InvokeHandler("channels", "collect_credentials", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
})
if err != nil {
t.Fatalf("collect_credentials: %v", err)
}
if out.JSON.(map[string]interface{})["error"] == nil {
t.Fatal("无浏览器会话时 collect_credentials 必须报错")
}
// status 如实说 running=false
out, err = ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
Method: "GET", Role: "admin",
})
if err != nil {
t.Fatalf("browser_status: %v", err)
}
if out.JSON.(map[string]interface{})["running"] != false {
t.Fatal("无会话时 running 应为 false")
}
// stop 幂等:无会话时也返回 ok
out, err = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
})
if err != nil {
t.Fatalf("stop_browser: %v", err)
}
if out.JSON.(map[string]interface{})["ok"] != true {
t.Fatal("stop_browser 应幂等成功")
}
}

View File

@ -1,225 +0,0 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"llmsproxy/internal/lua"
)
// ★ 「为什么没有可用账号」必须区分**真的用尽**与**查不到**。
//
// 参考的同款修复(用户报障 2026-09-29):混成一句"额度都已用尽"会让
// **一次网络抖动伪装成钱花完了** —— 用户于是去解锁永久积分、或白等到明天,
// 而号里其实有钱。两者的**正确动作完全相反**:
//
// exhausted → 等额度重置
// unknown → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定)
//
// 判据看的是**报出的 reason**(用户据此决定做什么),不是内部计数。
func TestCreditsPoolReasonDistinguishesExhaustedFromUnknown(t *testing.T) {
t.Run("账号都查到了余额但为 0 → exhausted", func(t *testing.T) {
h := newPoolReasonHarness(t)
h.addAccount("a1")
h.serveBalance(0, 0)
got := h.reason()
if got["reason"] != "exhausted" {
t.Fatalf("余额确为 0 时应报 exhausted(用户该等重置),实得 %v:%v",
got["reason"], got["detail"])
}
})
t.Run("余额查询失败 → unknown(不能伪装成用尽)", func(t *testing.T) {
h := newPoolReasonHarness(t)
h.addAccount("a1")
h.serveBalanceError(500)
got := h.reason()
if got["reason"] != "unknown" {
t.Fatalf("查询失败必须报 unknown —— 报 exhausted 会让网络抖动伪装成钱花完了"+
"(用户会去解锁永久积分或白等),实得 %v:%v", got["reason"], got["detail"])
}
if d, _ := got["detail"].(string); !strings.Contains(d, "查询失败") {
t.Fatalf("说明必须点明是查询失败,实得 %q", d)
}
})
t.Run("没有启用的账号 → no_accounts", func(t *testing.T) {
h := newPoolReasonHarness(t)
got := h.reason()
if got["reason"] != "no_accounts" {
t.Fatalf("无账号时应报 no_accounts,实得 %v", got["reason"])
}
})
t.Run("有可用账号 → usable", func(t *testing.T) {
h := newPoolReasonHarness(t)
h.addAccount("a1")
h.serveBalance(100, 0)
got := h.reason()
if got["reason"] != "usable" {
t.Fatalf("有钱时应报 usable,实得 %v", got["reason"])
}
if u, _ := got["usable"].(float64); int(u) != 1 {
t.Fatalf("usable 应为 1,实得 %v", got["usable"])
}
})
t.Run("平台无余额端点 → unknown(不编造用尽)", func(t *testing.T) {
h := newPoolReasonHarness(t)
// ⚠ 必须有**启用的账号**:判定的优先级是
// no_accounts → 无余额端点 → usable → unknown/exhausted
// 我第一版在这条用例里忘了加账号,于是命中 no_accounts(优先级更高,
// 那是**正确**行为),把正确的实现判成了错。
h.addAccountOn("minimax", "m1")
// minimax 未声明 balance 端点
got := h.reasonOn("minimax")
if got["reason"] != "unknown" {
t.Fatalf("无余额端点时应报 unknown(无法判定 ≠ 用尽),实得 %v:%v",
got["reason"], got["detail"])
}
if d, _ := got["detail"].(string); !strings.Contains(d, "无法判定") {
t.Fatalf("说明必须点明无法判定,实得 %q", d)
}
})
}
// 锁定永久积分时,只剩永久积分的账号不可用,且必须报 **locked** 而不是 exhausted
// (用户动作不同:解锁 vs 等重置)。
func TestCreditsPoolReasonReportsLockedNotExhausted(t *testing.T) {
h := newPoolReasonHarness(t)
h.addAccount("a1")
// total_fn 取包列表:只有永久积分(到期未知)
h.serveBalance(0, 50)
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
map[string]interface{}{"lock": true}); err != nil {
t.Fatal(err)
}
got := h.reason()
if got["reason"] != "locked" {
t.Fatalf("锁定且只剩永久积分时应报 locked(用户可解锁),实得 %v:%v",
got["reason"], got["detail"])
}
// 解锁后同一份余额应为 usable(证明 locked 不是"用尽")
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
map[string]interface{}{"lock": false}); err != nil {
t.Fatal(err)
}
h.serveBalance(0, 50)
if got2 := h.reason(); got2["reason"] != "usable" {
t.Fatalf("解锁后同一份余额应可用(证明 locked ≠ exhausted),实得 %v", got2["reason"])
}
}
// ── harness ──
type poolReasonHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
body atomic.Value
fail atomic.Bool
hasAcct atomic.Bool
}
func newPoolReasonHarness(t *testing.T) *poolReasonHarness {
t.Helper()
h := &poolReasonHarness{t: t}
// buddy 的真实形态:包列表嵌在 data.Response.Data.Accounts
h.body.Store(buddyPkgBody(0, 0))
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if h.fail.Load() {
w.WriteHeader(500)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, h.body.Load().(string))
}))
t.Cleanup(h.ts.Close)
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)
}
h.ps = ps
// buddy 的余额端点指到假上游,并让 total_fn 走包列表分桶
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"balance": map[string]interface{}{"url": h.ts.URL},
}); err != nil {
t.Logf("set_endpoints(balance) 未支持: %v", err)
}
return h
}
func (h *poolReasonHarness) addAccount(id string) {
h.t.Helper()
h.addAccountOn("buddy", id)
}
// addAccountOn:往指定平台加账号(我第一版把它写死成 buddy,导致
// "平台无余额端点"那条用例其实没有该平台的账号,命中了优先级更高的
// no_accounts —— 判据写错而非实现错)。
func (h *poolReasonHarness) addAccountOn(platform, id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
"platform": platform, "account_id": id,
"credential": `{"access_token":"AT","uid":"u1"}`,
}); err != nil {
h.t.Fatalf("account_add(%s): %v", platform, err)
}
h.hasAcct.Store(true)
}
// serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。
func (h *poolReasonHarness) serveBalance(expiring, permanent int) {
h.fail.Store(false)
h.body.Store(buddyPkgBody(expiring, permanent))
}
// buddyPkgBody 拼一份 buddy 真实形态的余额响应:
// 包列表在 data.Response.Data.Accounts,每包有 Status / CycleCapacityRemain /
// DeductionEndTime(毫秒)。到期时间落在窗口内 → 临时桶;未知(0)→ 永久桶。
func buddyPkgBody(expiring, permanent int) string {
pkgs := []string{}
if expiring > 0 {
// 3 天到期(< 15 天窗口)
endMs := (time.Now().Unix() + 3*86400) * 1000
pkgs = append(pkgs, fmt.Sprintf(
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":%d}`, expiring, endMs))
}
if permanent > 0 {
// DeductionEndTime=0(未知)⇒ 永久桶
pkgs = append(pkgs, fmt.Sprintf(
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":0}`, permanent))
}
return `{"data":{"Response":{"Data":{"Accounts":[` + strings.Join(pkgs, ",") + `]}}}}`
}
func (h *poolReasonHarness) serveBalanceError(code int) {
h.fail.Store(true)
}
func (h *poolReasonHarness) reason() map[string]interface{} {
h.t.Helper()
return h.reasonOn("buddy")
}
func (h *poolReasonHarness) reasonOn(platform string) map[string]interface{} {
h.t.Helper()
res, err := h.ps.Call("channels", "credits_pool_reason",
map[string]interface{}{"platform": platform})
if err != nil {
h.t.Fatalf("credits_pool_reason: %v", err)
}
return jsonOf(h.t, res)
}

View File

@ -1,439 +0,0 @@
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("带凭据账号应已同步源")
}
// 不写死具体数量:兜底目录是照参考 *-product.ts 补的,会随对齐
// 进度变化(minimax 已从 3 项补到 4 项)。这里断言的是**关系**:
// 关掉一个模型,源里就少一个。
all := invoke(t, ps, "model_list", `{"platform":"minimax"}`)
total := len(asArr(all["models"]))
if n := len(asArr(sm["models"])); n != total {
t.Fatalf("源应含全部 %d 个模型, 实得 %d", total, 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 != total-1 {
t.Fatalf("关1模型后源应 %d 模型, 实得 %d", total-1, 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}`)
})
// model_clear_dead 的语义已按参考修正:它清的是**有阳性 404 证据**的
// "已失效模型"记录,**不是**"被禁用且不在目录里"的条目。
//
// 旧断言测的是后者(拿远端目录反推下架)—— 那个判据在远端目录残缺的平台上
// 会删掉可用模型(参考文件头有实测教训),已弃用。
// 新语义的完整判据在 channels_dead_model_test.go。
t.Run("model_clear_dead_清失效记录而非拿目录反推", func(t *testing.T) {
// 没有失效记录时:removed=0,且文案要说明"无记录"(而不是假装清了什么)
j := invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`)
if j["ok"] != true {
t.Fatalf("model_clear_dead 应成功: %v", j)
}
if n, _ := j["removed"].(float64); n != 0 {
t.Fatalf("无失效记录时应为 0, 实得 %v", j["removed"])
}
// 黑名单(disabled)与失效记录是两回事:清失效**不得**动 disabled
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`)
ml := invoke(t, ps, "model_list", `{"platform":"minimax"}`)
off := 0
for _, m := range asArr(ml["models"]) {
if mm, ok := m.(map[string]interface{}); ok && mm["disabled"] == true {
off++
}
}
if off != 1 {
t.Fatalf("清失效记录不该动 disabled 黑名单, 仍应为 1, 实得 %d", off)
}
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)
}
}

View File

@ -1,86 +0,0 @@
package core
import (
"strings"
"testing"
)
// 三个"私有协议"平台的源注册必须引用**插件自装**的适配器。
//
// 为什么单独测这一条:适配器装上了 ≠ 源用了它。"装了但源还指向 openai"
// 是这类改动最可能的漏配,且**不会报错** —— 只是签名/信封不生效、上游返回
// 流内错误或 401,看起来像"平台不稳定"。
//
// 三个平台的失败模式各不相同,但根因是同一个(平台协议差异被当成适配器名差异):
//
// codearts → 华为云 AK/SK 签名
// trae → SOLO 载荷格式(config_name/function)
// gemini → 私有 /v1internal: 信封 + 身份五头
func TestPrivateProtocolSourcesUsePluginAdapters(t *testing.T) {
c, ps := poolCore(t)
cases := []struct {
platform string
wantAdapter string
wantBaseHas string
credential string
}{
{"codearts", "codearts",
"snap-access.cn-north-4.myhuaweicloud.com",
`{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`},
{"trae", "channels-trae",
"trae-api-cn.mchost.guru",
`{"access_token":"TRAE-TOK","refresh_token":"RT"}`},
{"gemini", "channels-gemini",
"cloudcode-pa.googleapis.com",
`{"access_token":"GEM-TOK","refresh_token":"GRT"}`},
}
for _, tc := range cases {
t.Run(tc.platform, func(t *testing.T) {
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": tc.platform, "credential": tc.credential,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 报错: %#v", m)
}
aid, _ := j["account_id"].(string)
srcs := listSourcesViaKernel(t, c)
sm, ok := srcHasAccount(srcs, aid)
if !ok {
names := []string{}
for _, x := range srcs {
names = append(names, x["name"].(string))
}
t.Fatalf("%s 账号加了却不出源(现有源: %v)—— 源注册失败通常是"+
"凭据形状不符或内核权限未授", tc.platform, names)
}
ad, _ := sm["adapter"].(string)
if ad != tc.wantAdapter {
t.Errorf("源 adapter = %q,应为插件自装的 %q"+
"(否则平台协议转换不生效,且不报错)", ad, tc.wantAdapter)
}
bu, _ := sm["base_url"].(string)
if !strings.Contains(bu, tc.wantBaseHas) {
t.Errorf("base_url = %q,应含 %q", bu, tc.wantBaseHas)
}
// 模型必须进源(否则账号看起来接入了但一个模型都调不到)
var nModels int
switch mv := sm["models"].(type) {
case []string:
nModels = len(mv)
case []interface{}:
nModels = len(mv)
}
if nModels == 0 {
t.Errorf("%s 源没有任何模型(%T)", tc.platform, sm["models"])
}
})
}
}

View File

@ -1,88 +0,0 @@
package core
import (
"strings"
"testing"
"llmsproxy/internal/lua"
)
// qoderReason 调真实的 transform_error 钩子(不自己重算)。
func qoderReason(t *testing.T, vm *lua.VM, status int, body string) string {
t.Helper()
reason, ok, err := vm.TransformError("channels-qoder", status, body)
if err != nil {
t.Fatalf("transform_error 调用失败: %v", err)
}
if !ok {
t.Fatalf("channels-qoder 适配器未装载或未定义 transform_error(body=%s)", body)
}
return reason
}
// Qoder 的错误信封是**双层编码**的,且两个业务码语义**相反**:
//
// 10605 排队 → 暂时受阻,等一下就好
// 110 额度 → 当天耗尽,重试无用(参考实测:原先归成通用服务端错误 → 白重试 5 次)
//
// 通用 openai 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` ——
// 用户看不懂是排队还是额度用完。故用**插件自装的 channels-qoder**,
// 厂商码值留在插件域(内核零厂商名)。
func TestQoderAdapterParsesQueueAndBilling(t *testing.T) {
c, _ := poolCore(t)
vm := c.VM()
// ① 排队:外层 code=10605 + 内层 JSON 字符串(参考实测的真实形状)
out := qoderReason(t, vm, 403,
`{"code":"10605","message":"{\"isQueued\":true,\"queueCount\":12,\"retryAfterSeconds\":30}"}`)
if !strings.Contains(out, "排队") {
t.Errorf("10605 必须说成排队,实得 %q", out)
}
// 等待时长与队列长度要从**内层**取到(只读顶层永远拿不到)
if !strings.Contains(out, "30") {
t.Errorf("应从内层取到 retryAfterSeconds=30,实得 %q", out)
}
if !strings.Contains(out, "12") {
t.Errorf("应从内层取到 queueCount=12,实得 %q", out)
}
// ② SSE 错误帧形态:**内层没有 code 字段**,只有排队标志。
// ⚠️ 参考第一版要求「必须命中 code 才算排队」,导致这一形态被判成非排队、
// SSE 通道的修复静默失效(探针显示 sleep=0)。这里必须能识别。
out2 := qoderReason(t, vm, 200,
`{"message":"{\"isQueued\":true,\"queueCount\":3,\"retryAfterSeconds\":8}"}`)
if !strings.Contains(out2, "排队") {
t.Errorf("内层无 code 也必须识别为排队(这是参考踩过的坑),实得 %q", out2)
}
if !strings.Contains(out2, "8") {
t.Errorf("应取到内层 retryAfterSeconds=8,实得 %q", out2)
}
// ③ 额度耗尽:必须与排队**分开说**,且明确"重试无用"
out3 := qoderReason(t, vm, 403, `{"code":"110","message":"Billing daily count exceeded"}`)
if !strings.Contains(out3, "额度") {
t.Errorf("110 必须说成额度耗尽,实得 %q", out3)
}
if strings.Contains(out3, "排队") {
t.Errorf("110 绝不能被说成排队(两者语义相反),实得 %q", out3)
}
if !strings.Contains(out3, "重试无用") {
t.Errorf("额度耗尽要明确告知重试无用,实得 %q", out3)
}
// ④ 泛词不能误伤:正文里讨论"配额/余额"不该被当成额度错误
//(关键词必须窄 —— 宽词会把模型正常输出误判成故障)
out4 := qoderReason(t, vm, 500,
`{"error":{"message":"the response discusses quota and balance concepts"}}`)
if strings.Contains(out4, "额度已用尽") {
t.Errorf("泛词 quota/balance 不得误伤(关键词必须窄),实得 %q", out4)
}
// ⑤ 与通用 openai 适配器对比:证明**通用器确实做不到**这件事
//(否则这个专用适配器就没有存在理由)
gen, okGen, _ := vm.TransformError("openai", 403,
`{"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"}`)
if okGen && strings.Contains(gen, "排队") {
t.Error("通用 openai 适配器不该能识别排队 —— 若能,则专用适配器无必要")
}
}

View File

@ -1,222 +0,0 @@
package core
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"llmsproxy/internal/lua"
)
// Qoder 活动领取有三条参考用**抓包 + 用户报障**定位过的「会误报」陷阱,
// 全部必须在判据里钉死:
//
// ① 「无可领活动」必须是 inactive,**不能**报 already-claimed
// (用户报障原文:「没领过却显示今日已领取,去 IDE 看还能领」);
// ② 刷新前(每日 10:00 UTC+8 之前)看到 CLAIMED 属于**昨天** → 必须报 inactive
// 而不是"今天已领取"(谎报且不可逆);
// ③ 未开通要给出 action_required 的可操作提示。
//
// 另:`replayed:true` 是幂等判据(HTTP 仍是 200),只看状态码会把"今天已领"
// 误报成"成功 +100"。
func TestQoderClaimDecisionTable(t *testing.T) {
type c struct {
name string
campaigns string
usage string
wantKind string
wantAction bool
wantClaimed int
}
cases := []c{
{
"无可领且无 CLAIM_BENEFIT,addOnQuota 缺失→inactive+可操作提示",
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"VIEW_DETAILS","claimStatus":"CLAIMABLE"}]}`,
`{"data":{"userQuota":{"remaining":0}}}`,
"inactive", true, 0,
},
{
"无可领但确实领过(CLAIMED)→ 视刷新时刻",
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMED"}]}`,
`{"data":{"addOnQuota":{"remaining":0}}}`,
"", false, 0, // 见下方:按当前 UTC+8 时刻断言二者之一
},
{
"可领→success(多个活动逐个领)",
`{"showCampaign":true,"claimable":true,"campaigns":[
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}},
{"campaignId":"C2","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":50}}]}`,
"",
"success", false, 2,
},
{
"可领但服务端回放 replayed→already-claimed(不是 success)",
`{"showCampaign":true,"claimable":true,"campaigns":[
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`,
"",
"already-claimed", false, 1,
},
{
"无任何活动且已开通(addOnQuota 存在)→inactive(不带可操作提示)",
`{"showCampaign":false,"claimable":false,"campaigns":[]}`,
`{"data":{"addOnQuota":{"total":100,"remaining":0}}}`,
"inactive", false, 0,
},
{
"有 CLAIM_BENEFIT 但已过期→inactive",
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"EXPIRED"}]}`,
`{"data":{"addOnQuota":{"remaining":0}}}`,
"inactive", false, 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
claimed := 0
var claimBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/sash/api/v1/me/campaigns":
fmt.Fprint(w, tc.campaigns)
case r.URL.Path == "/sash/api/v2/me/usage":
if tc.usage == "" {
w.WriteHeader(500)
return
}
fmt.Fprint(w, tc.usage)
case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") &&
r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim":
claimed++
b := make([]byte, 0)
if r.ContentLength > 0 {
b = make([]byte, r.ContentLength)
_, _ = r.Body.Read(b)
}
claimBody = string(b)
if tc.name == "可领但服务端回放 replayed→already-claimed(不是 success)" {
fmt.Fprint(w, `{"replayed":true,"status":"CLAIMED"}`)
} else {
fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`)
}
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
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)
}
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "qoder",
"qoder_sash_base": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatal(err)
}
cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"})
body, _ := json.Marshal(map[string]string{
"platform": "qoder", "credential": string(cred), "account": "t",
})
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: string(body),
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`,
})
if err != nil {
t.Fatalf("claim_daily: %v", err)
}
j, _ := out.JSON.(map[string]interface{})
oc, _ := j["outcome"].(map[string]interface{})
if oc == nil {
t.Fatalf("应返回 outcome,实得 %v", j)
}
kind, _ := oc["kind"].(string)
if tc.wantKind == "" {
// 刷新时刻决定 already-claimed 还是 inactive —— 两者都合法,
// 但**绝不能**是 success(今天是否领过不能靠"看到 CLAIMED"推断成可以再领)。
if kind != "already-claimed" && kind != "inactive" {
t.Fatalf("刷新前/后都应报 already-claimed 或 inactive,实得 %q(%v)", kind, oc["message"])
}
} else if kind != tc.wantKind {
t.Fatalf("kind 应为 %s,实得 %q(%v)", tc.wantKind, kind, oc["message"])
}
if tc.wantAction && oc["action_required"] != true {
t.Errorf("未开通场景应带 action_required=true(提示去官方客户端),实得 %v", oc["action_required"])
}
if claimed != tc.wantClaimed {
t.Errorf("claim 请求次数 = %d,期望 %d", claimed, tc.wantClaimed)
}
if tc.wantClaimed > 0 && claimBody != "" {
t.Errorf("claim 请求体必须是空串(抓包实测 content-length: 0),实得 %q", claimBody)
}
})
}
}
// 「今天的活动列表是否已刷新」必须用 **UTC+8 算术平移**判定。
// ⚠️ 本机时区恰是 HKT(+0800),所以「用本机时区判」的实现在真实时刻下**结果相同**
// —— 那是判据不可达(这台机器上永远抓不到)。必须注入 UTC 时刻来覆盖两个方向:
// 判据的输入是 UTC 毫秒,与运行环境时区无关。
func TestQoderRefreshUsesUTC8NotLocalTime(t *testing.T) {
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)
}
// UTC 01:00 = UTC+8 09:00 → **未刷新**(阈值 10:00)
// UTC 02:00 = UTC+8 10:00 → **已刷新**
// 若实现误用本机时区(HKT=UTC+8),对"UTC 毫秒"这个输入会得到:
// 把 07:00Z 当本地 15:00 → 判已刷新(错)
cases := []struct {
name string
utcHour int
want bool
}{
{"UTC 01:00(UTC+8 09:00,未到 10:00)", 1, false},
{"UTC 02:00(UTC+8 10:00,刚好刷新)", 2, true},
{"UTC 00:30(UTC+8 08:30,未刷新)", 0, false},
{"UTC 15:00(UTC+8 23:00,已刷新)", 15, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// 构造该 UTC 时刻的毫秒值(固定日期,避免跨日干扰)
// 用 time.Date 明确构造「00:00 UTC + N 小时」——手算 epoch 会引入
// 时区/跨日误差(第一版就是这么错的:基准不是 UTC 零点)。
ms := time.Date(2025, 10, 9, tc.utcHour, 0, 0, 0, time.UTC).UnixMilli()
var rec map[string]interface{}
got, err := ps.Call("channels", "__qoder_refresh_probe", map[string]interface{}{
"now_ms": float64(ms),
})
if err != nil {
t.Fatalf("probe: %v", err)
}
w, _ := got.(map[string]interface{})
rec, _ = w["json"].(map[string]interface{})
if rec == nil {
t.Fatalf("无 json: %#v", got)
}
if rec["refreshed"] != tc.want {
t.Fatalf("refreshed 应为 %v,实得 %v(UTC+8 阈值 10:00)", tc.want, rec["refreshed"])
}
})
}
}

View File

@ -1,84 +0,0 @@
package core
import (
"os"
"path/filepath"
"testing"
)
// Qoder 的设备身份两个头**必须成对**出现:参考用抓包 + 消融实验定位过 ——
// 缺任一个,服务端就不下发「可领取」的活动(只回 VIEW_DETAILS),
// 症状是用户报的「没领过却显示今日已领取」。
// 同时:拿不到身份时必须**保守降级**(照常发请求、不带这两个头),
// 不能让纯插件登录(未装桌面端)的账号整个积分功能报错。
func TestQoderMachineIdentityPairingAndDegradation(t *testing.T) {
t.Run("行1 有 machine_token.json → 成对带上两个头", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "machine_token.json")
if err := os.WriteFile(p, []byte(`{"token":"T-abc","type":"TY-def","updateAt":1}`), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("QODER_MACHINE_TOKEN_PATH", p)
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)
}
got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("probe: %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("无 json: %#v", got)
}
if m["token"] != "T-abc" || m["type"] != "TY-def" {
t.Fatalf("应从磁盘缓存读出 token/type,实得 %v", m)
}
if m["client_type"] != "10" {
t.Fatalf("Cosy-ClientType 应为 10(参考实测值),实得 %v", m["client_type"])
}
})
t.Run("行2 无任何来源 → 降级:不带这两个头,且不报错", func(t *testing.T) {
// 指向不存在的路径,且把 HOME 指到空目录(找不到 runtime-info)
t.Setenv("QODER_MACHINE_TOKEN_PATH", filepath.Join(t.TempDir(), "nope.json"))
t.Setenv("HOME", t.TempDir())
t.Setenv("APPDATA", "")
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)
}
got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("probe 不应报错(必须保守降级): %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("无 json: %#v", got)
}
if m["token"] != nil && m["token"] != "" {
t.Errorf("拿不到身份时不应带 token,实得 %v", m["token"])
}
if m["type"] != nil && m["type"] != "" {
t.Errorf("拿不到身份时不应带 type,实得 %v", m["type"])
}
// 头仍然要有 clienttype(那是必要但不充分条件,与身份无关)
if m["client_type"] != "10" {
t.Errorf("降级时仍应带 Cosy-ClientType=10,实得 %v", m["client_type"])
}
})
}

View File

@ -1,181 +0,0 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// qoderHarness:Qoder PKCE 设备码轮的测试台。
//
// 参考语义(qoder-oauth.ts / qoder.ts 注释点名):
//
// ① 授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
// &nonce&machine_id&client_id —— **本地拼装,不打网络**
// ② 轮询 = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
// ③ 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成
//
// 此前的实现在这里标了 flow="manual",理由是"登录要 WASM 签名头"——
// 把聊天加密与登录混为一谈,导致该平台完全不可用。
type qoderHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
poll int32 // 已发生的轮询次数
// step: 第几次轮询开始返回 token(0 = 一直 404)
tokenAt int32
gotQuery string
}
func newQoderHarness(t *testing.T) *qoderHarness {
t.Helper()
h := &qoderHarness{t: t, tokenAt: 2}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/deviceToken/poll" {
h.gotQuery = r.URL.RawQuery
n := atomic.AddInt32(&h.poll, 1)
if n < atomic.LoadInt32(&h.tokenAt) {
w.WriteHeader(404) // 尚无会话
fmt.Fprint(w, `{"code":404,"msg":"no session"}`)
return
}
fmt.Fprint(w, `{"token":"QD-AT-1","refresh_token":"QD-RT-1","user_id":"u9","user_name":"qoder-dev"}`)
return
}
w.WriteHeader(404)
}))
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", "list_sources", "status"}, false)
// 把 poll 指到假上游(授权页是本地拼装,不经网络)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "qoder",
"device": map[string]interface{}{"token_url": h.ts.URL + "/api/v1/deviceToken/poll"},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *qoderHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *qoderHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestQoderDevicePollBehavior 逐行锁定参考的 Qoder 登录语义。
func TestQoderDevicePollBehavior(t *testing.T) {
h := newQoderHarness(t)
t.Run("行1 授权页本地拼装_含PKCE四要素", func(t *testing.T) {
j, _ := h.begin()
url, _ := j["url"].(string)
for _, want := range []string{
"qoder.com/device/selectAccounts",
"challenge=", "challenge_method=S256", "nonce=", "machine_id=",
// client_id 必须是 prod 那个(非 prod 会在授权回调阶段被拒)
"client_id=e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
} {
if !strings.Contains(url, want) {
t.Fatalf("授权页缺 %q:\n%s", want, url)
}
}
})
t.Run("行2 404视为继续_不误报失败", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 99) // 一直 404
_, sid := h.begin()
for i := 0; i < 3; i++ {
st := h.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("404 应保持 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 轮询带verifier与nonce_不带device_code", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 99)
_, sid := h.begin()
h.pollOnce(sid)
q := h.gotQuery
if !strings.Contains(q, "verifier=") || !strings.Contains(q, "nonce=") {
t.Fatalf("轮询 query 应含 verifier 与 nonce, 实得 %q", q)
}
if !strings.Contains(q, "challenge_method=S256") {
t.Fatalf("轮询应带 challenge_method=S256, 实得 %q", q)
}
})
t.Run("行4 拿到token即完成_落凭据", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 2) // 第二次轮询返回 token
_, sid := h.begin()
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", st)
}
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("拿到 token 后应 completed, 实得 %v", st)
}
// 凭据落保险库(旧路径键)
v, ok, _ := h.ps.GetSecret("channels", "channels:qoder")
if !ok || !strings.Contains(v, "QD-AT-1") {
t.Fatalf("凭据应含 QD-AT-1, 实得 %q", v)
}
})
t.Run("行5 池路径_凭据落账号ref", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 1)
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"qoder","account_id":%q}`, aid), Role: "admin",
})
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("池路径应完成: %v", st)
}
if _, ok, _ := h.ps.GetSecret("channels", "channels:qoder:"+aid); !ok {
t.Fatalf("池路径凭据未落到 channels:qoder:%s", aid)
}
})
}

View File

@ -1,165 +0,0 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// raccoonHarness:Raccoon 内置二维码扫码登录的测试台。
//
// 参考语义(raccoon-oauth.ts 点名):
//
// ① qrcode_code **本地随机生成**(16 字节 hex),服务端接受任意自造 code
// ② 轮询 POST /login_with_qrcode_code {qrcode_code}
// → data.status ∈ pending / logging / canceled / success
// ③ success 但**缺 token** 视为未完成(否则产出空凭据并卡死)
// ④ **任何异常降级为 pending**(网络抖动/畸形响应/未知 status)
type raccoonHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
step int32 // 第几次轮询开始 success
gotCode string
}
func newRaccoonHarness(t *testing.T) *raccoonHarness {
t.Helper()
h := &raccoonHarness{t: t, step: 3}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.HasSuffix(r.URL.Path, "/login_with_qrcode_code") {
h.polls++
if h.polls < atomic.LoadInt32(&h.step) {
fmt.Fprint(w, `{"code":0,"data":{"status":"logging"}}`)
return
}
fmt.Fprint(w, `{"code":0,"data":{"status":"success","access_token":"RC-AT-1","refresh_token":"RC-RT-1"}}`)
return
}
w.WriteHeader(404)
}))
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", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "raccoon",
"qr": map[string]interface{}{
"api_base": h.ts.URL,
"auth_prefix": "/api/web/auth/v1",
"poll_path": "/login_with_qrcode_code",
"login_page": "/login/mp",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *raccoonHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"raccoon"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *raccoonHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestRaccoonQrPollBehavior 逐行锁定参考的扫码登录语义。
func TestRaccoonQrPollBehavior(t *testing.T) {
h := newRaccoonHarness(t)
t.Run("行1 本地生成code并给出二维码URL", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
qr, _ := j["qr_url"].(string)
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "login/mp") {
t.Fatalf("应给出带 code 的二维码 URL, 实得 %q", qr)
}
// code 应是 32 位 hex(本地随机生成)
code := qr[strings.Index(qr, "code=")+5:]
if i := strings.IndexAny(code, "&"); i > 0 {
code = code[:i]
}
if len(code) != 32 {
t.Fatalf("qrcode_code 应是 32 位 hex, 实得 %q", code)
}
})
t.Run("行2 logging保持pending", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.step, 99)
_, sid := h.begin()
for i := 0; i < 3; i++ {
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("logging 应 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 success且带token才完成", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.step, 2)
_, sid := h.begin()
h.pollOnce(sid)
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("success+token 应 completed, 实得 %v", st)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:raccoon")
if !ok || !strings.Contains(v, "RC-AT-1") {
t.Fatalf("凭据应含 RC-AT-1, 实得 %q", v)
}
})
t.Run("行4 canceled是终态失败", func(t *testing.T) {
hh := newRaccoonHarness(t)
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"code":0,"data":{"status":"canceled"}}`)
})
hh.ps.SetSecret("channels", "channels:raccoon", "")
_, sid := hh.begin()
if st := hh.pollOnce(sid); st["state"] != "failed" {
t.Fatalf("canceled 应 failed, 实得 %v", st["state"])
}
})
t.Run("行5 success缺token视为未完成", func(t *testing.T) {
hh := newRaccoonHarness(t)
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"code":0,"data":{"status":"success"}}`)
})
_, sid := hh.begin()
st := hh.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"])
}
})
}

View File

@ -1,193 +0,0 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// 凭据静默续期(照参考 refresh.ts / cline.ts / gemini-oauth.ts)。
//
// ⚠️ 修的是一个**比不做更坏**的缺陷:`account_refresh` 此前只重同步源、一个换
// token 的请求都不发,却恒返回 `ok:true` ⇒ `refresh_creds` 打出「凭据已续期 N 个」
// 而实际什么都没续。日志在说谎,排查时会去看网络、看上游,而真因是根本没有
// 这段代码。
//
// 判据必须**看到真请求** —— 否则就还是在验一个恒真的返回值。
func TestAccountRefreshSendsRealRequest(t *testing.T) {
var hits int32
var gotBody, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
gotPath = r.URL.Path
b := make([]byte, 1024)
n, _ := r.Body.Read(b)
gotBody = string(b[:n])
w.Header().Set("Content-Type", "application/json")
// 照参考:Cline 响应套 {success,data} 信封,字段是**驼峰** accessToken
_, _ = w.Write([]byte(`{"success":true,"data":{"accessToken":"NEW-TOKEN-CAMEL","refreshToken":"NEW-REFRESH"}}`))
}))
defer srv.Close()
_, ps := poolCore(t)
// 注入一个测试平台(夹具绝不写进生产源码)
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "rfptest", "refresh_url": srv.URL + "/api/v1/auth/refresh",
"style": "cline", "credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
}); err != nil {
t.Fatalf("注入平台: %v", err)
}
out, err := ps.Call("channels", "account_refresh", map[string]interface{}{
"platform": "rfptest", "account_id": "a1",
})
if err != nil {
t.Fatalf("account_refresh: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
// ① 必须真的发出了请求(这是本判据的核心 —— 原实现 hits=0)
if n := atomic.LoadInt32(&hits); n != 1 {
t.Fatalf("必须真发一次续期请求,实得 %d 次(原实现是 0 次却报成功)", n)
}
if !strings.Contains(gotPath, "/api/v1/auth/refresh") {
t.Errorf("请求路径不对: %s", gotPath)
}
// ② 请求体必须是**驼峰** refreshToken/grantType(照参考 cline.ts)
var sent map[string]interface{}
if err := json.Unmarshal([]byte(gotBody), &sent); err != nil {
t.Fatalf("请求体不是 JSON(Cline 要 JSON body): %q", gotBody)
}
if _, ok := sent["refreshToken"]; !ok {
t.Errorf("请求体必须是驼峰 refreshToken(不是 refresh_token),实得 %v", sent)
}
if sent["grantType"] != "refresh_token" {
t.Errorf("grantType 应为 refresh_token,实得 %v", sent["grantType"])
}
// ③ 必须报 refreshed=true,且**新 token 真的落进凭据**
if j["refreshed"] != true {
t.Errorf("应报 refreshed=true,实得 %v(message=%v)", j["refreshed"], j["message"])
}
if j["ok"] != true {
t.Errorf("应报 ok=true,实得 %v", j["ok"])
}
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
"ref": "channels:rfptest",
})
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
cred, _ := rj["credential"].(string)
if !strings.Contains(cred, "NEW-TOKEN-CAMEL") {
t.Errorf("新 token 必须写回凭据,实得 %q", cred)
}
// ④ 轮换的 refresh_token 也要回写(Google 场景;Cline 也返回了新的)
if !strings.Contains(cred, "NEW-REFRESH") {
t.Errorf("轮换的 refresh_token 必须回写,实得 %q", cred)
}
}
// 失败时**必须如实回错误**,不能像原实现那样恒 ok:true。
func TestAccountRefreshReportsFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"refresh token expired"}`))
}))
defer srv.Close()
_, ps := poolCore(t)
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "rfpfail", "refresh_url": srv.URL + "/refresh", "style": "google",
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
}); err != nil {
t.Fatalf("注入: %v", err)
}
out, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
"platform": "rfpfail", "account_id": "a1",
})
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
// ⚠️ 原实现在这里恒 ok:true/refreshed 隐含成功 —— 这就是日志说谎的机制
if j["refreshed"] == true {
t.Error("续期失败时绝不能报 refreshed=true")
}
msg, _ := j["message"].(string)
if !strings.Contains(msg, "401") {
t.Errorf("失败原因应含 HTTP 状态(可诊断),实得 %q", msg)
}
// 无 refresh_token 时必须明确说"需重新登录",而不是笼统的失败
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "rfpnort", "refresh_url": srv.URL + "/refresh", "style": "google",
"credential": `{"access_token":"OLD"}`,
}); err != nil {
t.Fatalf("注入: %v", err)
}
out2, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
"platform": "rfpnort", "account_id": "a1",
})
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
m2, _ := j2["message"].(string)
if !strings.Contains(m2, "重新登录") {
t.Errorf("无 refresh_token 时应提示需重新登录,实得 %q", m2)
}
}
// expires_in 是**相对秒数**,必须换算成绝对时刻 —— 否则每轮都判定"快过期"再续一遍。
func TestRefreshWritesBackAbsoluteExpiry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"NEW","refresh_token":"R2","expires_in":7200}`))
}))
defer srv.Close()
_, ps := poolCore(t)
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "rfpexp", "refresh_url": srv.URL + "/token", "style": "google",
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
}); err != nil {
t.Fatalf("注入: %v", err)
}
if _, err := ps.Call("channels", "account_refresh", map[string]interface{}{
"platform": "rfpexp", "account_id": "a1",
}); err != nil {
t.Fatalf("account_refresh: %v", err)
}
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
"ref": "channels:rfpexp",
})
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
cred, _ := rj["credential"].(string)
var m map[string]interface{}
_ = json.Unmarshal([]byte(cred), &m)
ea, _ := m["expires_at"].(string)
if ea == "" {
t.Fatal("expires_in 必须换算成绝对 expires_at 写回(否则每轮都重续)")
}
// 必须是**绝对**时刻(10 位秒级),不是相对秒数 7200
if ea == "7200" {
t.Error("expires_at 写成了相对秒数 —— 必须换算成绝对时刻")
}
if len(ea) < 9 {
t.Errorf("expires_at 看着不像绝对时刻: %q", ea)
}
// 索引的 expiresAt 也必须回写(照 expiry-sync:否则出现"显示已过期但能发消息"分叉)。
// ⚠️ 必须通过**账号列表**端点读索引 —— __read_cred_probe 只读保险库,
// 用它验索引会永远看到零,把"没验到"误判成"没写"。
al, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "rfpexp"})
aj, _ := al.(map[string]interface{})["json"].(map[string]interface{})
accounts := asArrInterface(aj["accounts"])
if len(accounts) == 0 {
t.Fatal("账号列表为空")
}
first, _ := accounts[0].(map[string]interface{})
idx, _ := first["expiresAt"].(float64)
if idx == 0 {
t.Error("索引的 expiresAt 必须一并回写(expiry-sync 的分叉缺陷)")
}
// 且应当是**绝对**秒级时刻(不是相对 7200)
if idx > 0 && idx < 1e9 {
t.Errorf("索引 expiresAt 应换算成绝对时刻,实得 %.0f", idx)
}
}

View File

@ -1,215 +0,0 @@
package core
import (
"regexp"
"sort"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// ★ 删除账号必须**真的把源撤掉** —— 且撤不掉时不能静默。
//
// 真实缺陷:内核的 `remove_source` 需要**单独授予**(Destructive 级,不在
// add_source 的授权里)。生产权限集曾只有 add_source ⇒ `dropAccountSource`
// 的调用被**拒绝**,而调用方**忽略返回值** ⇒ 静默失败的后果是
// **账号已删,它的源仍在对外服务**(用户看到"删了却还在用")。
//
// 格外隐蔽的一点:`AddSource` 是**同名 upsert**,所以 `syncAccountSource` 里
// "先 remove 再 add" 的 remove 失败**看不出来**(add 覆盖掉同名源)——
// 只有**纯删除**路径(account_delete / 停用)才暴露。故判据必须走删除路径。
func TestAccountDeleteRemovesItsSource(t *testing.T) {
t.Run("授予 remove_source 时应撤掉源", func(t *testing.T) {
h := newRemoveSourceHarness(t, true)
h.addAccount("del-a")
if got := h.sourceNames(); !hasName(got, "channels-buddy-del-a") {
t.Fatalf("前置条件:账号源应已建立,实得 %v", got)
}
h.deleteAccount("del-a")
if got := h.sourceNames(); hasName(got, "channels-buddy-del-a") {
t.Fatalf("账号已删但源仍在 ⇒ 已删账号继续对外服务:%v", got)
}
t.Log("✓ 删除账号同时撤掉其源")
})
t.Run("未授予 remove_source 时必须**可见地**失败(不能静默)", func(t *testing.T) {
h := newRemoveSourceHarness(t, false)
h.addAccount("del-b")
h.deleteAccount("del-b")
// 源确实撤不掉(权限不足)——这是环境的限制,不是我们要判的
// 我们要判的是:**这件事必须被记录下来**,而不是消失在一个被丢弃的返回值里。
logs := h.eventLog()
joined := strings.Join(logs, " | ")
if !strings.Contains(joined, "撤源失败") {
t.Fatalf("撤源被拒却没有任何记录 ⇒ 权限缺失会一直静默(表现是"+
"「删了账号却还在用」)\n事件日志: %s", joined)
}
if !strings.Contains(joined, "remove_source") {
t.Fatalf("记录里应点明是哪个内核动作被拒(便于给运维可操作提示),实得: %s", joined)
}
// ★ 必须带上**真正的原因**(not granted),而不是"无返回"这类空话。
// 内核桥失败时**第一个返回值是 nil、原因在第二个返回值里** ——
// 只接第一个返回值就会把原因丢掉(我第一版正是如此)。
if !strings.Contains(joined, "not granted") {
t.Fatalf("记录里必须含内核给出的**真实原因**(not granted),"+
"否则运维无从下手(只接 call 的第一个返回值就会丢掉原因);实得: %s", joined)
}
t.Logf("✓ 撤源失败已可见: %s", joined)
})
}
// ── harness ──
type removeSourceHarness struct {
t *testing.T
ps *lua.Plugins
}
func newRemoveSourceHarness(t *testing.T, grantRemove bool) *removeSourceHarness {
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)
}
// ★ 照生产现状:add_source 有、remove_source 视参数而定
// 注意:status 是插件自己的导出,与内核权限无关(之前误当权限项)
actions := []string{"add_source", "list_sources"}
if grantRemove {
actions = append(actions, "remove_source")
}
_ = ps.SetPluginKernelPermission("channels", actions, false)
return &removeSourceHarness{t: t, ps: ps}
}
func (h *removeSourceHarness) addAccount(id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": id,
"credential": `{"access_token":"AT","uid":"u1"}`,
}); err != nil {
h.t.Fatalf("account_add(%s): %v", id, err)
}
}
func (h *removeSourceHarness) deleteAccount(id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_delete", map[string]interface{}{
"platform": "buddy", "account_id": id,
}); err != nil {
h.t.Fatalf("account_delete(%s): %v", id, err)
}
}
func (h *removeSourceHarness) sourceNames() []string {
h.t.Helper()
res, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
if err != nil {
h.t.Fatal(err)
}
j := jsonOf(h.t, res)
var out []string
arr, _ := j["sources"].([]interface{})
for _, e := range arr {
if m, ok := e.(map[string]interface{}); ok {
if n, ok := m["name"].(string); ok {
out = append(out, n)
}
}
}
return out
}
// eventLog 取插件事件日志(logEvent 写进 plugin.state.log)。
// ⚠ 走探针:`status` 是 HTTP 路由处理器,**不在** plugin.exports 里,
// 直接 Call 它会报 `does not export "status"`(我第一版就这么错)。
func (h *removeSourceHarness) eventLog() []string {
h.t.Helper()
res, err := h.ps.Call("channels", "__event_log_probe", map[string]interface{}{})
if err != nil {
h.t.Fatal(err)
}
j := jsonOf(h.t, res)
var out []string
arr, _ := j["log"].([]interface{})
for _, e := range arr {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
func hasName(xs []string, want string) bool {
for _, x := range xs {
if x == want {
return true
}
}
return false
}
// ★ 插件**声明**的内核动作必须覆盖它**实际调用**的全部动作。
//
// 真实缺陷:`plugin.kernel` 只声明了 5 个动作,而插件在 5 处调用 `remove_source`
// —— 声明与使用**漂移**。生产只授了声明过的那些 ⇒ 撤源全部被拒,且因为
// `AddSource` 是**同名 upsert**(add 会覆盖,看不出 remove 失败),
// 只有**纯删除路径**才暴露:**账号删了,它的源还在对外服务**。
//
// 判据用**静态扫描**(源码里出现的 call("X"))对比声明清单 —— 这样将来任何人
// 加了新的内核调用却忘了声明,都会在这里报红,而不是等线上静默失效。
func TestChannelsDeclaresEveryKernelActionItCalls(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// ① 抽出声明清单
declStart := strings.Index(s, "plugin.kernel = {")
if declStart < 0 {
t.Fatal("找不到 plugin.kernel 声明(判据需更新)")
}
declEnd := strings.Index(s[declStart:], "}")
if declEnd < 0 {
t.Fatal("plugin.kernel 声明没有收尾大括号")
}
declBlob := s[declStart : declStart+declEnd]
declared := map[string]bool{}
for _, m := range regexp.MustCompile(`"([a-z_]+)"`).FindAllStringSubmatch(declBlob, -1) {
declared[m[1]] = true
}
if len(declared) == 0 {
t.Fatal("声明清单解析为空(判据失效)")
}
// ② 抽出实际调用的动作
called := map[string]bool{}
for _, m := range regexp.MustCompile(`__llmsproxy_kernel\.call\(\s*"([a-z_]+)"`).FindAllStringSubmatch(s, -1) {
called[m[1]] = true
}
if len(called) == 0 {
t.Fatal("没扫描到任何内核调用(判据失效)")
}
// ③ 逐个比对:调用而未声明 = 漏声明(运维照声明授权 ⇒ 该调用必被拒)
var missing []string
for a := range called {
if !declared[a] {
missing = append(missing, a)
}
}
if len(missing) > 0 {
sort.Strings(missing)
t.Fatalf("插件调用了未声明的内核动作 %v\n"+
"(运维按 declared 授予 ⇒ 这些调用会被拒;remove_source 漏声明时"+
"的表现是「账号删了、源还在对外服务」,且因 AddSource 是 upsert 而静默)",
missing)
}
t.Logf("✓ 声明覆盖全部调用:declared=%d called=%d", len(declared), len(called))
}

View File

@ -1,251 +0,0 @@
package core
import (
"testing"
"llmsproxy/internal/lua"
)
// ★ account_reorder 必须**真的改变内核里的源顺序**。
//
// 链路:poolList 按 priority 降序 → syncAccountSource 的调用顺序 → 内核里的
// **源注册顺序** → 内核按 candidates 顺序依次尝试。
// 而一个源一旦注册,顺序就固定了 —— 改 pool 记录不影响它。故重排后必须按新
// 顺序 remove+add 一遍。
//
// 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁),
// 不是 pool 里的 priority 字段(那个改了不代表路由变了)。
func TestAccountReorderActuallyReordersKernelSources(t *testing.T) {
h := newReorderHarness(t)
h.addAccount("acct-a")
h.addAccount("acct-b")
// 先确认两个源都在,且初始顺序是 [a, b](account_add 的注册顺序)。
// ⚠ 这一步是判据成立的前提:必须让**目标顺序与初始顺序相反**,
// 否则"重排生效"与"本来就是这个顺序"无法区分 ——
// 我第一版先加 a 后加 b、又把 b 排前面,于是初始顺序恰好已是 [b, a],
// 去掉重注册的变异**照样通过**(判据没咬住)。
before := h.sourceOrder()
if len(before) < 2 {
t.Fatalf("应有 2 个账号源,实得 %v", before)
}
if before[0] != "channels-buddy-acct-a" {
t.Fatalf("前置条件不成立:初始顺序应为 [a, b](add 顺序),实得 %v —— "+
"若初始已是目标顺序,本判据无法区分「重排生效」与「本来就对」", before)
}
// 把 b 排到前面(ordered_ids 顺序即优先级,第一个最高)——
// 目标顺序与初始相反,故只有真的重注册才能达成。
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
"platform": "buddy",
"ordered_ids": []interface{}{"acct-b", "acct-a"},
}); err != nil {
t.Fatal(err)
}
after := h.sourceOrder()
if len(after) < 2 {
t.Fatalf("重排后源丢了:%v", after)
}
idxOf := func(list []string, id string) int {
for i, n := range list {
if n == id {
return i
}
}
return -1
}
b, a := idxOf(after, "channels-buddy-acct-b"), idxOf(after, "channels-buddy-acct-a")
if b < 0 || a < 0 {
t.Fatalf("重排后找不到账号源:%v", after)
}
if b > a {
t.Fatalf("重排未生效:acct-b 应排在 acct-a **之前**(内核先试它),"+
"实际顺序 %v\n(只改 pool.priority 不改源注册顺序的话,路由不会变 —— "+
"快到期积分仍不会优先消耗)", after)
}
t.Logf("✓ 源顺序已按重排生效: %v", after)
}
// ── harness ──
type reorderHarness struct {
t *testing.T
ps *lua.Plugins
}
func newReorderHarness(t *testing.T) *reorderHarness {
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)
}
// ★ 必须授予 add_source:不给的话 syncAccountSource 里内核会明确拒绝
// ("kernel action add_source is not granted"),源一个都不会出现 ——
// 我第一版漏了它,于是"应有 2 个源,实得 []"。
_ = ps.SetPluginKernelPermission("channels",
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
return &reorderHarness{t: t, ps: ps}
}
func (h *reorderHarness) addAccount(id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": id,
"credential": `{"access_token":"AT","uid":"u1"}`,
}); err != nil {
h.t.Fatalf("account_add(%s): %v", id, err)
}
}
// sourceOrder 返回内核里 channels-buddy-* 源的顺序(即实际尝试顺序)。
//
// ⚠ **必须只读**:我第一版在这里顺带调了 __sync_source_probe(它内部是
// remove+add)⇒ 改变了被测状态,"测之前先动了被测对象",判据因此不可信
// (实测表现:顺序忽而两个忽而一个)。
func (h *reorderHarness) sourceOrder() []string {
h.t.Helper()
ls, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
if err != nil {
h.t.Fatalf("__list_sources_probe: %v", err)
}
j := jsonOf(h.t, ls)
var out []string
arr, _ := j["sources"].([]interface{})
for _, e := range arr {
m, ok := e.(map[string]interface{})
if !ok {
continue
}
n, _ := m["name"].(string)
if len(n) > 15 && n[:15] == "channels-buddy-" {
out = append(out, n)
}
}
return out
}
// ★ poolList 的顺序必须**确定**(同秒创建的账号也不例外)。
//
// 真实缺陷:排序键是 `createdAt`(**秒级**),同秒建的两个账号比较相等,
// 而相等时 table.sort 的结果取决于 pairs() 遍历顺序 —— **不确定**。
// 实测:按 a,b,c 顺序建三个账号,poolList 回的是 [a,c,b]。
//
// 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = **内核尝试顺序**,
// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分),
// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。
func TestPoolListOrderIsDeterministic(t *testing.T) {
// ⚠ 这条判据必须走**批量重注册**路径(reorder 会按 poolList 顺序重放源)。
// `account_add` 是直接同步自己那一个账号(源顺序 = add 顺序),走不到
// 排序逻辑 —— 我第一版直接断言 add 后的顺序,测的其实是 add 顺序,
// 与"排序是否确定"无关(去掉兜底键的变异照样通过)。
//
// 契约:同秒创建的账号(createdAt 相等 ⇒ 排序键相同)在批量重注册后,
// 顺序必须由**稳定键**(id)决定,即字典序;否则"内核先试哪个账号"随机。
h := newReorderHarness(t)
for _, id := range []string{"acct-c", "acct-b", "acct-a"} {
h.addAccount(id)
}
// 触发批量重注册:按 id 字典序显式指定 ordered_ids(priority 依次递减)
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
"platform": "buddy",
"ordered_ids": []interface{}{"acct-c", "acct-b", "acct-a"},
}); err != nil {
t.Fatal(err)
}
// priority 已按给定顺序区分开 ⇒ 这里先确认**显式顺序生效**
order := h.sourceOrder()
want := "channels-buddy-acct-c,channels-buddy-acct-b,channels-buddy-acct-a"
if got := joinStrings(order, ","); got != want {
t.Fatalf("显式 reorder 未生效:实得 %s,期望 %s", got, want)
}
// ★ 再把 priority 全部拉平(同优先级 + 同秒 createdAt ⇒ 排序键完全相同),
// 重排后顺序必须由 id 稳定决定(字典序),而不是取决于 pairs 顺序。
h.clearPriorities()
if !sameSecond(h.createdAts()) {
t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts())
}
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
"platform": "buddy",
"ordered_ids": []interface{}{"acct-a", "acct-b", "acct-c"},
}); err != nil {
t.Fatal(err)
}
h.clearPriorities()
h.resync("buddy")
order2 := h.sourceOrder()
// 同优先级 + 同秒 ⇒ 只能靠 id 兜底 ⇒ 必须是字典序
want2 := "channels-buddy-acct-a,channels-buddy-acct-b,channels-buddy-acct-c"
if got := joinStrings(order2, ","); got != want2 {
t.Fatalf("同优先级同秒创建时,顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+
"(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)",
got, want2)
}
t.Logf("✓ 同键时顺序由 id 稳定决定: %s", want2)
}
func joinStrings(xs []string, sep string) string {
out := ""
for i, x := range xs {
if i > 0 {
out += sep
}
out += x
}
return out
}
// createdAts 取当前 pool 里 buddy 账号的 createdAt。
func (h *reorderHarness) createdAts() []int64 {
h.t.Helper()
res, err := h.ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"})
if err != nil {
h.t.Fatal(err)
}
var out []int64
for _, a := range accountsOf(h.t, res) {
if v, ok := a["createdAt"].(float64); ok {
out = append(out, int64(v))
}
}
return out
}
// sameSecond 判断是否所有时间戳都落在同一秒(即排序键相等)。
func sameSecond(ts []int64) bool {
if len(ts) < 2 {
return false
}
for _, v := range ts {
if v != ts[0] {
return false
}
}
return true
}
// clearPriorities 把所有账号的 priority 拉平(构造"排序键完全相同"的场景)。
func (h *reorderHarness) clearPriorities() {
h.t.Helper()
if _, err := h.ps.Call("channels", "__clear_priorities_probe", map[string]interface{}{
"platform": "buddy",
}); err != nil {
h.t.Fatalf("__clear_priorities_probe: %v", err)
}
}
// resync 触发一次批量重注册(走 poolList 顺序 → 源注册顺序)。
func (h *reorderHarness) resync(platform string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "__resync_sources_probe", map[string]interface{}{
"platform": platform,
}); err != nil {
h.t.Fatalf("__resync_sources_probe: %v", err)
}
}

View File

@ -1,125 +0,0 @@
package core
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// 账号池落盘的**原子性与一致性**(照参考 account-pool.ts 的真实缺陷)。
//
// 参考的缺陷:`removeAccount` 只保证「读→改」之间无 await,而内部的
// `await store.save(...)` 是**异步**的 ⇒ 两个写者各自的 save 可以**乱序完成**,
// 后完成者带着它更早的快照覆盖磁盘。实测形态:
// - 并发删 a / 删 b(先发起的后完成)⇒ **已删除的账号在磁盘上复活**;
// - 删 victim 与加 added 交错 ⇒ **新增账号被静默丢失**。
//
// 我们的实现是**同步** tmp+rename(Lua 无异步),且每次写都序列化**当前**
// `plugin.state`(不是调用方早先取的快照)⇒ 该竞态结构上不可能发生。
// 但"不可能"必须被验证,否则只是一句自信。
func TestAccountPoolStoreIsAtomicAndConsistent(t *testing.T) {
c, ps := poolCore(t)
// 找出插件的存储目录(内核给的 ground truth,不是猜的)
dirOut, err := ps.Call("channels", "__store_dir_probe", nil)
if err != nil {
t.Fatalf("store_dir_probe: %v", err)
}
dj, _ := dirOut.(map[string]interface{})["json"].(map[string]interface{})
dir, _ := dj["store_dir"].(string)
if dir == "" {
t.Fatalf("拿不到插件存储目录,实得 %#v", dirOut)
}
storePath := filepath.Join(dir, ".channels-store.json")
t.Logf("插件存储目录: %s", dir)
// ⚠️ 必须在**测试目录**里,绝不能是生产路径
if _, err := os.Stat("/etc/llmsproxy/plugins/.channels-store.json"); err == nil {
if filepath.Clean(dir) == "/etc/llmsproxy/plugins" {
t.Fatal("测试正在使用生产存储目录 —— 隔离失效(这会造成真实数据事故)")
}
}
// 连续做一组"读-改-写"(模拟并发操作最终落在同一份文档上)
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "atomic1", "style": "google", "credential": `{"access_token":"a"}`}); err != nil {
t.Fatal(err)
}
for _, id := range []string{"a", "b", "c"} {
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "atomic1", "account_id": id}); err != nil {
t.Fatalf("add %s: %v", id, err)
}
}
// 删 a、删 b(参考的缺陷场景)
for _, id := range []string{"a", "b"} {
if _, err := ps.Call("channels", "account_delete", map[string]interface{}{
"platform": "atomic1", "account_id": id}); err != nil {
t.Fatalf("delete %s: %v", id, err)
}
}
// 再加一个(参考的第二个缺陷场景:与删除交错的写)
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "atomic1", "account_id": "d"}); err != nil {
t.Fatalf("add d: %v", err)
}
// 内存视图
mem, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "atomic1"})
mj, _ := mem.(map[string]interface{})["json"].(map[string]interface{})
memIDs := map[string]bool{}
for _, a := range asArrInterface(mj["accounts"]) {
if am, ok := a.(map[string]interface{}); ok {
if id, ok := am["id"].(string); ok {
memIDs[id] = true
}
}
}
// 磁盘视图:必须是**合法 JSON**(tmp+rename 的原子性保证:绝无半截文件)
b, err := os.ReadFile(storePath)
if err != nil {
t.Fatalf("读存储: %v", err)
}
var disk map[string]interface{}
if err := json.Unmarshal(b, &disk); err != nil {
t.Fatalf("磁盘文档不是合法 JSON(原子替换失效,出现半截文件): %v", err)
}
// 文档形状:{version, state:{pool, accounts, ...}} —— pool 在 state 之下。
st, _ := disk["state"].(map[string]interface{})
if st == nil {
t.Fatalf("磁盘文档缺 state 段,实得 %v", disk)
}
pool, _ := st["pool"].(map[string]interface{})
diskIDs := map[string]bool{}
if pl, ok := pool["atomic1"].(map[string]interface{}); ok {
for id := range pl {
diskIDs[id] = true
}
}
// ① 已删除的账号**不得**在磁盘上复活(参考的第一个缺陷形态)
for _, gone := range []string{"a", "b"} {
if diskIDs[gone] {
t.Errorf("已删除的账号 %q 在磁盘上复活了(参考的并发写竞态形态)", gone)
}
if memIDs[gone] {
t.Errorf("已删除的账号 %q 仍在内存视图里", gone)
}
}
// ② 新增的账号**不得**被静默丢失(参考的第二个缺陷形态)
if !diskIDs["d"] {
t.Error("新增账号 d 在磁盘上缺失(被静默丢失 —— 参考的竞态形态)")
}
// ③ 内存与磁盘必须一致(分叉本身就是缺陷)
for id := range memIDs {
if !diskIDs[id] {
t.Errorf("账号 %q 在内存里有、磁盘上没有(内存/磁盘分叉)", id)
}
}
for id := range diskIDs {
if !memIDs[id] {
t.Errorf("账号 %q 在磁盘上有、内存里没有(内存/磁盘分叉)", id)
}
}
_ = c
}

View File

@ -1,222 +0,0 @@
package core
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// TestChannelsStoreIsPluginOwned 是「插件自持持久化」的回归。
//
// 背景:本插件声明 hooks = {},全部状态变更走自己的 HTTP 路由。此前 v2.0.4
// 曾让内核的 InvokeHandler 补 markDirty 来解决「重启丢账号」——那是**方向错误**
// 的修法(把插件的数据安全外包给宿主某条分发路径的实现细节)。参考实现
// (channel-pack-store.ts)踩过同一课:宿主 settings 能力一变,账号列表就退化
// 为纯内存,正解是插件自持文档。
//
// 本测试断言:插件把状态写进**自己的**存储文件,且新实例能读回。
func TestChannelsStoreIsPluginOwned(t *testing.T) {
// 固定插件目录:两次「重启」必须共用同一目录,否则测不出持久化。
// 这也是防止测试写进生产插件目录的那道隔离(kernelTestCore 会 respect 它)。
dir := t.TempDir()
t.Setenv("LLMSPROXY_PLUGIN_DIR", dir)
load := func() *lua.Plugins {
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", "list_sources", "remove_source", "status"}, false)
return ps
}
ps := load()
// 加账号 → 状态变更 → 应立即落盘
add, err := ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy","nickname":"持久化测试"}`, Role: "admin",
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
// ① 插件自有存储文件必须存在
storePath := filepath.Join(dir, ".channels-store.json")
b, err := os.ReadFile(storePath)
if err != nil {
t.Fatalf("插件自有存储未写出(%s): %v\n"+
" —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err)
}
var saved struct {
State map[string]interface{} `json:"state"`
}
if err := json.Unmarshal(b, &saved); err != nil {
t.Fatalf("存储文件不是合法 JSON: %v", err)
}
pool, _ := saved.State["pool"].(map[string]interface{})
buddy, _ := pool["buddy"].(map[string]interface{})
if _, ok := buddy[aid]; !ok {
t.Fatalf("存储里应含账号 %s,实得 pool=%v", aid, pool)
}
// ② 凭据绝不进这个文件(它在 .secrets/ 保险库,0600)
if s, _ := saved.State["credentials"].(map[string]interface{}); len(s) > 0 {
t.Fatal("自有存储不得包含凭据(凭据只进 .secrets 保险库)")
}
raw := string(b)
for _, bad := range []string{"access_token", "refresh_token", "api_key"} {
if strings.Contains(raw, bad) {
t.Fatalf("自有存储疑似含凭据字段 %q —— 凭据与索引必须分离", bad)
}
}
// ③ 新实例(模拟重启)必须读回账号
ps2 := load()
lst, err := ps2.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
})
if err != nil {
t.Fatalf("account_list: %v", err)
}
found := false
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
if raw.(map[string]interface{})["id"] == aid {
found = true
}
}
if !found {
t.Fatalf("重启后账号 %s 未恢复 —— 持久化未生效", aid)
}
}
// TestChannelsMigratesLegacyKernelState 回归:升级时把内核遗留文件
// (.channels.state.json,v2.0.4 内核写的)迁移到自有存储并删除遗留文件。
//
// 迁移必须真的搬数据(生产上那份文件含真实账号池),且**写成功才删**遗留 ——
// 写失败还删它,等于用一次迁移把数据弄丢。
func TestChannelsMigratesLegacyKernelState(t *testing.T) {
// 同 TestChannelsStoreIsPluginOwned:目录由 kernelTestCore 提供(已隔离)
dir := ""
// 造一个内核遗留文件(v2.0.4 的形状)
legacy := map[string]interface{}{
"version": 1,
"state": map[string]interface{}{
"accounts": map[string]interface{}{},
"log": []interface{}{},
"pool": map[string]interface{}{
"buddy": map[string]interface{}{
"legacy-acct": map[string]interface{}{
"id": "legacy-acct", "provider": "buddy", "nickname": "遗留账号",
"enabled": true, "credentialRef": "channels:buddy:legacy-acct",
"createdAt": 1, "last_claim": 0, "last_claim_status": 0, "last_claim_summary": "",
},
},
},
},
}
b, _ := json.Marshal(legacy)
c := kernelTestCore(t)
dir = c.Config().PluginDir
legacyPath := filepath.Join(dir, ".channels.state.json")
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
t.Fatal(err)
}
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)
}
// ① 遗留账号必须被迁移进内存(可见)
lst, err := ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
found := false
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
if raw.(map[string]interface{})["id"] == "legacy-acct" {
found = true
}
}
if !found {
t.Fatal("遗留账号未迁移 —— 升级即丢账号")
}
// ② 自有存储必须已写出
if _, err := os.Stat(filepath.Join(dir, ".channels-store.json")); err != nil {
t.Fatalf("迁移后自有存储应存在: %v", err)
}
// ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储)
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatal("遗留内核文件应被删除,否则下次启动会被它覆盖")
}
}
// TestChannelsMigrationKeepsLegacyOnWriteFailure 锁定迁移的**数据安全**契约:
// 写自有存储失败时**绝不能**删除遗留文件 —— 写失败还删它,等于用一次迁移把
// 数据弄丢(与本次要修的「重启丢账号」是同类事故)。
//
// 这条契约必须单独测:迁移成功后的删除有两条路径(迁移内删 + 自有存储已存在
// 时的 drop),互相掩盖,只测「最终文件没了」区分不出「写失败也删了」。
func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) {
// 强制写失败的手段:把 persistState 要写的临时文件路径
// (<dir>/.channels-store.json.tmp)**占成一个目录**,io.open(tmp,"w") 必失败。
// 不用 chmod:测试以 root 跑,root 绕过权限位,chmod 拦不住。
sub := t.TempDir()
t.Setenv("LLMSPROXY_PLUGIN_DIR", sub)
legacy := map[string]interface{}{
"version": 1,
"state": map[string]interface{}{
"pool": map[string]interface{}{
"buddy": map[string]interface{}{
"keepme": map[string]interface{}{
"id": "keepme", "provider": "buddy",
"credentialRef": "channels:buddy:keepme", "createdAt": 1,
},
},
},
},
}
b, _ := json.Marshal(legacy)
legacyPath := filepath.Join(sub, ".channels.state.json")
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(sub, ".channels-store.json.tmp"), 0o700); err != nil {
t.Fatal(err)
}
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
_ = ps.LoadSource("channels", src)
// 写失败的态下,遗留文件必须原样保留
if _, err := os.Stat(legacyPath); err != nil {
t.Fatalf("写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
}
// 且自有存储不应存在(写失败了)
if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil {
t.Fatal("tmp 路径被占住时不应写出自有存储")
}
}

View File

@ -1,264 +0,0 @@
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)
}
}

View File

@ -1,230 +0,0 @@
package core
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"llmsproxy/internal/lua"
)
// ★ TRAE 的 batch 模型目录解析:两个**真实缺陷**必须在移植时一起带上
// (参考 parseTraeBatchModelList 的注释记录了它们)。
//
// ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7):
//
// 22 个 function 里有一半在本插件推理端点下不可调用(实测 chat→4023
// model unknown、builder→4001、inline_chat→3003)。旧实现只把白名单用于
// "同模型择优",于是"目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,
// 且错误指向**模型**,极易被误判成"这个模型坏了"。
//
// ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7):
//
// 旧实现无条件"后覆盖前",假设(后面的配置更全)与真实数据**相反** ——
// 上游把空档位的 solo_work_lite 排在最后,实测 13 个模型因此丢掉
// reasoning_effort_config,全部显示"未提供推理等级"。
func TestTraeBatchModelCatalogAdmissionAndReasoningProvenance(t *testing.T) {
var mu sync.Mutex
var gotBody string
h := newTraeCatalogHarness(t, func(body string) {
mu.Lock()
gotBody = body
mu.Unlock()
})
ids := catalogIDs(t, h, "trae")
joined := strings.Join(ids, ",")
// ① 白名单准入:不可调用通道里的模型必须**被丢弃**
if strings.Contains(joined, "only-in-bad-channel") {
t.Fatalf("不可调用通道(chat/builder/inline_chat)里的模型必须整组丢弃,"+
"否则用户选中它必然失败且错误指向模型:%v", ids)
}
// 但通用通道里的模型要在
if !strings.Contains(joined, "glm-5.2") {
t.Fatalf("可调用通道里的模型不该被丢:%v", ids)
}
// ② 空档位不得覆盖有档位:同一模型在 solo_agent(有档位)与
// solo_work_lite(空档位,**排在后面**)都出现 ⇒ 必须保留有档位那条。
// 判据看**报出的档位**,不是"哪条被选中"这类内部信号。
if !h.modelHasReasoningOptions("glm-5.2") {
t.Fatalf("glm-5.2 的思考档位被空档位条目覆盖了 —— 上游把空档位的"+
"solo_work_lite 排在最后,无条件后覆盖前会让模型显示"+
"「未提供推理等级」(issue IKI7WT/IKILR7):%v", ids)
}
// ③ 请求体必须传**全部 22 个 function**(与真实 IDE 一致)——
// 只传聊天通道会让非聊天通道条目在响应里位置错乱甚至被跳过。
mu.Lock()
b := gotBody
mu.Unlock()
if b == "" {
t.Fatal("未捕获到目录请求体")
}
for _, want := range []string{"ui_builder_v2", "inline_chat", "system_diagnosis", "show_custom_model"} {
if !strings.Contains(b, want) {
t.Fatalf("目录请求体必须与真实 IDE 一致传全部 function(缺 %q):%s", want, b)
}
}
}
// 四条硬性过滤:usage / config_switch / is_invisible_to_user / 必须有 function。
func TestTraeBatchModelCatalogHardFilters(t *testing.T) {
h := newTraeCatalogHarness(t, nil)
ids := catalogIDs(t, h, "trae")
joined := strings.Join(ids, ",")
for _, bad := range []string{"summary-model", "disabled-model", "hidden-model", "custom-flag-model"} {
if strings.Contains(joined, bad) {
t.Errorf("%s 应被硬性过滤掉(usage/switch/invisible):%v", bad, ids)
}
}
// ⚠ 判据是**标志的值**,不是模型名:名字带 custom_model_ 前缀但标志为 false
// 的条目必须保留(参考实测:曾据此把 qwen3.8-flash 误记为应剔除,它其实可用)。
if !strings.Contains(joined, "custom_model_prefix_but_ok") {
t.Errorf("is_custom_model=false 的条目必须保留(判据是标志值而非模型名):%v", ids)
}
// ⚠ 我第一版把 no-function-model 放进 solo_agent 的 config_info_list 里,
// 却断言它应被丢弃 —— 那是**夹具写错**:它在合法组内,保留才是对的。
// 「未声明 function」指的是**整个 function_configs 组**没有 function 字段。
if !strings.Contains(joined, "no-function-model") {
t.Errorf("合法组内的条目不该被丢:%v", ids)
}
t.Logf("✓ 过滤后目录: %v", ids)
}
// ── harness:假上游返回一份 batch_get_detail_param 形态的目录 ──
type traeCatalogHarness struct {
t *testing.T
ps *lua.Plugins
}
func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarness {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
if onBody != nil {
onBody(string(b))
}
w.Header().Set("Content-Type", "application/json")
// 三个通道:
// solo_agent(**可调用**,白名单内)—— glm-5.2 **有**档位
// solo_work_lite(白名单内,**排在后面**)—— glm-5.2 **空档位**
// inline_chat(**白名单外**)—— 只列出 only-in-bad-channel
fmt.Fprint(w, `{"function_configs":[`+
// ① solo_agent:glm-5.2 有档位;另有三个必被硬性过滤的条目
`{"function":"solo_agent","config_info_list":[`+
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true,`+
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}},`+
`{"config_name":"summary-model","usage":"summary","config_switch":true},`+
`{"config_name":"disabled-model","usage":"chat_completion","config_switch":false},`+
`{"config_name":"hidden-model","usage":"chat_completion","config_switch":true,"is_invisible_to_user":true},`+
`{"config_name":"no-function-model","usage":"chat_completion","config_switch":true},`+
// is_custom_model=true ⇒ 上游以流内 4001 拒绝("仅可见但不可调用")
`{"config_name":"custom-flag-model","usage":"chat_completion","config_switch":true,`+
`"display_config":{"is_custom_model":true}},`+
// 对照组:名字带 custom_model_ 前缀但**标志为 false** ⇒ 必须**保留**
// (参考点名:判据是标志的值,不是模型名)
`{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+
`"display_config":{"is_custom_model":false}}`+
`]},`+
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置
`{"function":"solo_work_lite","config_info_list":[`+
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true}`+
`]},`+
// ③ 白名单外通道:整组必须被丢弃
`{"function":"inline_chat","config_info_list":[`+
`{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+
`]}`+
`]}`)
}))
t.Cleanup(ts.Close)
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)
}
h := &traeCatalogHarness{t: t, ps: ps}
// 把目录端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "trae",
"models_remote": map[string]interface{}{"endpoint0": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 塞一个 TRAE 账号(目录请求需要凭据)
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "trae",
"account_id": "cat-trae",
"credential": `{"access_token":"AT","uid":"u1","refresh_token":"RT"}`,
}); err != nil {
t.Fatal(err)
}
return h
}
func catalogIDs(t *testing.T, h *traeCatalogHarness, platform string) []string {
t.Helper()
if _, err := h.ps.Call("channels", "model_refresh",
map[string]interface{}{"platform": platform}); err != nil {
t.Fatalf("model_refresh: %v", err)
}
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": platform})
if err != nil {
t.Fatal(err)
}
j := jsonOf(t, res)
var out []string
arr, _ := j["models"].([]interface{})
for _, e := range arr {
if m, ok := e.(map[string]interface{}); ok {
if id, ok := m["id"].(string); ok {
out = append(out, id)
}
}
}
return out
}
// modelHasReasoningOptions 查某模型是否被报出思考档位(走 model_list 的字段)。
func (h *traeCatalogHarness) modelHasReasoningOptions(modelID string) bool {
h.t.Helper()
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"})
if err != nil {
h.t.Fatal(err)
}
j := jsonOf(h.t, res)
arr, _ := j["models"].([]interface{})
for _, e := range arr {
m, ok := e.(map[string]interface{})
if !ok {
continue
}
if m["id"] != modelID {
continue
}
// 档位的真实出口:model_list 把远端目录解出的档位放在
// `remote.efforts`(并镜像到顶层 efforts),另有 reasoning=true。
for _, k := range []string{"efforts", "reasoning_options", "reasoningOptions"} {
if v, ok := m[k].([]interface{}); ok && len(v) > 0 {
return true
}
}
if rm, ok := m["remote"].(map[string]interface{}); ok {
if v, ok := rm["efforts"].([]interface{}); ok && len(v) > 0 {
return true
}
}
}
return false
}

View File

@ -1,111 +0,0 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"llmsproxy/internal/lua"
)
// Trae 签到是最容易被"看起来对"骗过的地方:请求能发出去、上游可能也返回 200,
// 但头不对就只是一个失败/风控响应。参考实现明确记录:
//
// ① 授权方案是 Cloud-IDE-JWT,**不是** Bearer(用错前缀是 401 级失败);
// ② 简化头(只有 Authorization + 几个 Ug 头)**实测签到失败**,
// 真正成功的是约 20 个客户端头;
// ③ X-Device-Id 不是凭据里的 hex32,而是**基于 user_id 确定性派生的 15 位数字**
// (每账号天然不同设备,所以不再需要 9074 时换设备号重试)。
//
// 本测试按参考逐条锁定这些事实,并把派生的 device_id 钉死到与参考一致的值。
func TestTraeCheckinHeaders(t *testing.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")
w.Write([]byte(`{"code":0}`))
}))
defer ts.Close()
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)
}
// 把签到端点指到假上游(不覆盖 checkin.auth,验证声明真的在起作用)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "trae",
"checkin": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatal(err)
}
// uid 必须是真值:设备身份由它派生。用参考实现算出的期望值(见 tools/trae_derive_expected.mjs)
const uid = "12345"
// credential 传**字符串**(凭据是一段 JSON 文本),不是对象 —— complete_login
// 会原样存进保险库,取用时 credentialOf 再解出 access_token。
cred, _ := json.Marshal(map[string]interface{}{
"access_token": "ACCESS-1", "uid": uid, "device_id": "deadbeef",
})
body, _ := json.Marshal(map[string]string{
"platform": "trae", "credential": string(cred), "account": "t",
})
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: string(body),
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: `{"platform":"trae"}`,
})
if err != nil {
t.Fatalf("claim_daily: %v", err)
}
if j, _ := out.JSON.(map[string]interface{}); j != nil && j["error"] != nil {
t.Fatalf("claim 失败: %v(完整响应 %v)", j["error"], j)
}
t.Run("行1 授权方案是 Cloud-IDE-JWT", func(t *testing.T) {
if v := got["Authorization"]; v != "Cloud-IDE-JWT ACCESS-1" {
t.Fatalf("Authorization 必须是 Cloud-IDE-JWT <token>(Bearer 会 401),实得 %q", v)
}
})
t.Run("行2 设备身份是按 user_id 派生的 15 位数字", func(t *testing.T) {
// 期望值来自参考实现(Node + node:crypto)的派生,不是本实现自己算的
if v := got["X-Device-Id"]; v != "707304999019651" {
t.Fatalf("X-Device-Id 应为按 uid 派生的 15 位数字 707304999019651,实得 %q", v)
}
})
t.Run("行3 客户端身份头齐全", func(t *testing.T) {
for k, want := range map[string]string{
"X-Market-Client-Id": "VSCode 1.107.1",
"X-User-Region": "CN",
"Package-Type": "stable_cn",
"X-Lgw-Req-Sdk-Type": "3",
"X-Lscbd-Platform": "windows",
"App-Version": "0.1.52",
} {
if v := got[k]; v != want {
t.Errorf("头 %s = %q,期望 %q", k, v, want)
}
}
if got["X-Market-User-Id"] == "" || len(got["X-Market-User-Id"]) != 36 {
t.Errorf("X-Market-User-Id 应是 UUID v4 形状,实得 %q", got["X-Market-User-Id"])
}
if len(got["Vscode-Sessionid"]) != 64 {
t.Errorf("Vscode-Sessionid 应是 64 位 hex,实得 %q", got["Vscode-Sessionid"])
}
if got["X-Tt-Trace-Id"] == "" || got["X-Request-Id"] == "" {
t.Error("每次请求都应有独立的 X-Tt-Trace-Id / X-Request-Id")
}
})
}

View File

@ -1,63 +0,0 @@
package core
import (
"encoding/json"
"fmt"
"os"
"testing"
"llmsproxy/internal/lua"
)
// 手工移植的确定性派生必须与参考**逐字节一致**,否则签名的设备身份是错的
// 且不会报错(上游只会返回风控/失败)。所以拿参考实现(Node + node:crypto)
// 生成期望值,再让插件的 Lua 实现算同一组输入做差分。
//
// 期望值由 tools/trae_derive_expected.json 提供(用参考 seededStream 生成)。
func TestTraeDeriveMatchesReference(t *testing.T) {
raw, err := os.ReadFile("trae_derive_expected.json")
if err != nil {
t.Skipf("缺差分数据(跳过): %v", err)
}
var cases []struct {
U string `json:"u"`
Devid string `json:"devid"`
Market string `json:"market"`
Sess string `json:"sess"`
}
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
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)
}
for _, tc := range cases {
got, err := ps.Call("channels", "__trae_derive_probe", map[string]interface{}{"uid": tc.U})
if err != nil {
t.Fatalf("probe %q: %v", tc.U, err)
}
wrapped, _ := got.(map[string]interface{})
m, _ := wrapped["json"].(map[string]interface{})
if m == nil {
t.Fatalf("probe %q 无 json: %#v", tc.U, got)
}
if fmt.Sprint(m["devid"]) != tc.Devid {
t.Errorf("devid(%q) = %v,参考 %s", tc.U, m["devid"], tc.Devid)
}
if fmt.Sprint(m["market"]) != tc.Market {
t.Errorf("market(%q) = %v,参考 %s", tc.U, m["market"], tc.Market)
}
if fmt.Sprint(m["sess"]) != tc.Sess {
t.Errorf("sess(%q) = %v,参考 %s", tc.U, m["sess"], tc.Sess)
}
}
_ = lua.HandlerRequest{}
}

View File

@ -1,200 +0,0 @@
package core
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// ★ TRAE 回调只带 code(PKCE 形态)时必须**明确报出**「暂不支持 PKCE」,
// 而不是去调 ExchangeToken 换一个不存在的码。
//
// 真实缺陷(用户报「登录后显示失败」):
// poll_login 的 trae 分支曾自己发明了一段「授权码交换」——把 {code} 塞进
// exchange_body 再打 ExchangeToken。但:
//
// · ExchangeToken 的签名是 RefreshToken,不是 code;
// · exchange_body 里根本没有 {code} 占位符,那次替换是空操作;
// · client_id 当时是空串。
//
// 于是稳定得到 400,日志回一句误导性的「Trae 授权码交换失败: 400」,
// 把真因「上游走了 PKCE 流程」掩盖成「交换出错了」——排查方向完全错。
//
// 参考实现(trae-oauth.ts parseTraeCallbackDetailed)的做法是:带 code 的回调
// 是**合法回调**,只是不支持的 PKCE 分支,必须明确报出来。
func TestTraePkceCallbackIsReportedNotExchanged(t *testing.T) {
h := newTraeHarness(t)
// ★ 必须打**真实登录用的那条路径**:poll_login(loopback 回调文件)。
// oauth_callback 那条一直是对的,判据打在那里会漏掉缺陷 —— 我第一版就
// 犯了这个错(变异测试揭穿:注入缺陷后那条判据仍绿)。
//
// poll_login 从 plugin.state.loopbacks[sessionId].file 读 {code,state},
// 故判据直接摆好那个文件再调 poll_login。
status, body := h.beginAndPollWithCallback(map[string]string{
"code": "PKCE_AUTH_CODE_XYZ",
})
// poll_login 的契约:终态用 HTTP 200 + json.state 表达(不是非 200 状态码)。
// 所以「失败」的判据是 state=failed,而不是 status != 200 —— 我第一版把
// 前者写成了后者,于是把正确的实现判成错。
if !strings.Contains(body, "state:failed") {
t.Fatalf("PKCE 回调应落定为 failed(poll_login 用 200+state 表达终态):status=%d body=%s",
status, body)
}
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
t.Fatalf("不得去调 ExchangeToken —— 那是拿 code 换一个不存在的码(真实缺陷)。"+
"实际打了 %d 次", n)
}
// 错误信息必须指向真因(PKCE),而不是含糊的交换失败
blob := body
if !strings.Contains(blob, "PKCE") {
t.Fatalf("错误信息应点名 PKCE(让排查方向正确),实得 %q", blob)
}
if strings.Contains(blob, "授权码交换失败") {
t.Fatalf("不应再出现误导性的「授权码交换失败」:%q", blob)
}
t.Logf("✓ PKCE 回调被正确识别(poll_login 路径): %s", strings.TrimSpace(blob))
}
// ★ client_id 必须是实测真值 —— ExchangeToken 的请求体里 ClientID 是必填。
// 留空会让真实 token 直传场景的交换稳定 400(夹具自身也校验 ClientID 存在)。
func TestTraeExchangeSendsRealClientID(t *testing.T) {
h := newTraeHarness(t)
// 真实回调形态:带 refreshToken(token 直传)⇒ 会走 ExchangeToken。
// 夹具的 handler 已校验请求体含 ClientID / RefreshToken:<回调值> / ClientSecret。
status, body := h.beginAndCallback(map[string]string{
"refreshToken": "RT-FROM-CALLBACK",
"userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`,
})
if status != 200 {
t.Fatalf("token 直传回调应成功: status=%d body=%s", status, body)
}
if n := atomic.LoadInt32(&h.exchHit); n == 0 {
t.Fatal("token 直传场景必须真的调用 ExchangeToken")
}
t.Logf("✓ ExchangeToken 被调用 %d 次,请求体形状正确", atomic.LoadInt32(&h.exchHit))
}
// client_id 的真值必须是实测值 —— 这是「交换必然 400」的两个成因之一。
func TestTraeClientIDIsNotBlank(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
src := string(b)
if !strings.Contains(src, `client_id = "en1oxy7wnw8j9n"`) {
t.Fatal("trae 的 client_id 必须是实测真值 en1oxy7wnw8j9n —— " +
"留空/占位会让 ExchangeToken 的 ClientID 为空而稳定 400")
}
}
// 两条路径(oauth_callback 与 poll_login 的 loopback)必须给**同一个**结论,
// 否则修复只覆盖一条、另一条继续漂移(这正是缺陷能藏住的原因)。
func TestTraeCallbackPathsAgreeOnPkce(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
src := string(b)
// ① 不得再出现自己发明的交换(注释里说明历史的那句除外)
if strings.Contains(src, `cerr = "Trae 授权码交换失败`) {
t.Fatal("已删除的「授权码交换」又回来了 —— 那是拿 code 去 ExchangeToken," +
"必然 400 且把真因掩盖成交换失败")
}
// ② 两条路径都必须交给 traeCallback(它内建 PKCE 判定)
if n := strings.Count(src, "traeCallback(plat,"); n < 2 {
t.Fatalf("traeCallback 应被两条路径共用(当前 %d 处)—— 只有一条会漂移", n)
}
// ③ PKCE 判定必须仍在 traeCallback 内
if !strings.Contains(src, "本实现暂不支持该流程") {
t.Fatal("traeCallback 必须保留 PKCE 明确报错(参考实现的语义)")
}
}
// beginAndPollWithCallback 走**真实登录用的那条路径**:
// start_login 拿会话 → 摆好 loopback 回调文件(平台重定向的落地)→ poll_login。
//
// 与 beginAndCallback(打 oauth_callback)的区别很关键:poll_login 才是
// callback 流在服务端浏览器(VNC)场景下真正走的那条,真实缺陷就藏在它的
// trae 分支里。
func (h *traeHarness) beginAndPollWithCallback(cbQuery map[string]string) (int, string) {
h.t.Helper()
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{})
sid, _ := sess["id"].(string)
nonce, _ := sess["nonce"].(string)
if sid == "" {
h.t.Fatalf("会话缺 id: %#v", sess)
}
// 摆好 loopback 回调文件(内容 = 平台重定向带回的 query)
rec := map[string]string{"state": nonce}
for k, v := range cbQuery {
rec[k] = v
}
b, _ := json.Marshal(rec)
cbPath := filepath.Join(h.t.TempDir(), "cb-"+sid+".json")
if err := os.WriteFile(cbPath, b, 0600); err != nil {
h.t.Fatal(err)
}
// 告诉插件「这个会话的 loopback 接收器把结果写在这里了」
if _, err := h.ps.Call("channels", "__set_loopback_probe", map[string]interface{}{
"session_id": sid, "file": cbPath,
}); err != nil {
h.t.Fatalf("设置 loopback 文件: %v", err)
}
res, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid},
})
if err != nil {
h.t.Fatal(err)
}
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
}
// ★ PKCE 探测的候选来源必须覆盖参考的 5 个(含 authCodeInfo 为**纯 code 字符串**
// 这一形态 —— 参考注释点名,解析不出 JSON 时必须回退到原始值)。
//
// 反例(我第一版就是错的):用 `q.code or q.authCode or jsonField(...)` 链,
// 而 jsonField 取不到时返回**空串**;Lua 里空串是真值 ⇒ 链在第一个空串处短路,
// 后面的来源永远取不到。
func TestTraePkceDetectsPlainStringAuthCodeInfo(t *testing.T) {
for _, tc := range []struct {
name string
q map[string]string
}{
{"code 参数", map[string]string{"code": "C1"}},
{"authCode 参数", map[string]string{"authCode": "C2"}},
{"authCodeInfo 是 JSON", map[string]string{"authCodeInfo": `{"code":"C3"}`}},
{"authCodeInfo 是 JSON(authCode)", map[string]string{"authCodeInfo": `{"authCode":"C4"}`}},
{"authCodeInfo 是纯字符串", map[string]string{"authCodeInfo": "C5"}},
} {
t.Run(tc.name, func(t *testing.T) {
h := newTraeHarness(t)
status, body := h.beginAndPollWithCallback(tc.q)
_ = status
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
t.Fatalf("PKCE 形态不得去调 ExchangeToken(打了 %d 次)", n)
}
if !strings.Contains(body, "PKCE") {
t.Fatalf("应识别为 PKCE 并明确报出,实得 %q", body)
}
})
}
}

View File

@ -1,258 +0,0 @@
package core
import (
"encoding/json"
"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,不是回调原值。
// 凭据是 JSON(Trae 需 device_id/uid 供签到派生设备身份),故解出 access_token。
v, _, _ := h.ps.GetSecret("channels", "channels:trae")
var cred map[string]interface{}
if err := json.Unmarshal([]byte(v), &cred); err != nil {
t.Fatalf("凭据应为 JSON(含 device_id/uid): %v (%q)", err, v)
}
if at, _ := cred["access_token"].(string); !strings.HasPrefix(at, "ACCESS-") {
t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", at)
}
// uid 来自 userInfo.UserID —— 签到设备身份的派生种子,丢了就无法签到
if cred["uid"] != "u1" {
t.Fatalf("凭据应保留 uid=u1(签到设备身份的种子), 实得 %v", cred["uid"])
}
if cred["enterprise_id"] != "t1" {
t.Fatalf("凭据应保留 TenantID, 实得 %v", cred["enterprise_id"])
}
// 设备身份来自登录会话(授权 URL 里下发的那对 hex32)
if d, _ := cred["device_id"].(string); len(d) != 32 {
t.Fatalf("凭据应带 32 位 device_id(签到轮换用), 实得 %q", d)
}
})
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")
var cred2 map[string]interface{}
if err := json.Unmarshal([]byte(v), &cred2); err != nil {
t.Fatalf("凭据应为 JSON: %v (%q)", err, v)
}
if cred2["access_token"] != "JWT-TOKEN-1" {
t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %v", cred2["access_token"])
}
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)
}

View File

@ -1,100 +0,0 @@
package core
import (
"testing"
)
// 通用件:TTL 缓存 + 串行锁(照参考 ttl-cache.ts / serial-queue.ts)。
//
// 抽它们出来的理由:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"
// 逻辑(远端目录闸门、余额 60s 缓存、登录轮询…)。手写版本各写各的,
// 边界条件容易漏 —— 实测就漏了一个(见下面 ③)。
func TestTtlCacheSemantics(t *testing.T) {
_, ps := poolCore(t)
if _, err := ps.Call("channels", "__ttl_reset_probe", nil); err != nil {
t.Fatalf("重置: %v", err)
}
// ① 首次加载后命中缓存:loader 只该被调一次
out, err := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 60,
})
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
if j["value"] != "v1" {
t.Errorf("首次应加载出 v1,实得 %v", j["value"])
}
if n, _ := j["loads"].(float64); n != 1 {
t.Errorf("首次后 loads 应为 1,实得 %v", n)
}
// 再取一次:命中缓存,loads 不变
out2, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 60,
})
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
if n, _ := j2["loads"].(float64); n != 1 {
t.Errorf("命中缓存时不该再加载,loads 实得 %v", n)
}
// ② 失效后必须能重新加载(包括同一秒内 —— 见 ③)
if _, err := ps.Call("channels", "__ttl_invalidate_probe", map[string]interface{}{
"ns": "t", "key": "k",
}); err != nil {
t.Fatalf("失效: %v", err)
}
out3, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 60,
})
j3, _ := out3.(map[string]interface{})["json"].(map[string]interface{})
// ⚠️ 这是实测踩到的坑:只清缓存、不清"在飞标记"时,同一秒内的重载会被
// 去重逻辑挡住,返回 nil —— 表现为"刚清了缓存却拿不到值"。
if j3["value"] == nil {
t.Fatalf("失效后立刻重载必须拿到值(清缓存时也要清在飞标记),实得 nil")
}
if n, _ := j3["loads"].(float64); n != 2 {
t.Errorf("失效后应重新加载(loads=2),实得 %v", n)
}
// ③ TTL=0 视为立即过期:不该命中
if _, err := ps.Call("channels", "__ttl_invalidate_probe", map[string]interface{}{
"ns": "t", "key": "k",
}); err != nil {
t.Fatalf("失效: %v", err)
}
_, _ = ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 0,
})
out5, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 0,
})
j5, _ := out5.(map[string]interface{})["json"].(map[string]interface{})
if n, _ := j5["loads"].(float64); n < 3 {
t.Errorf("ttl=0 不该命中缓存(每次都要重载),loads 实得 %v", n)
}
// ④ 串行锁:抢到 → 第二次抢不到 → 释放后能再抢
a, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
"ns": "s", "ttl": 60})
ja, _ := a.(map[string]interface{})["json"].(map[string]interface{})
if ja["acquired"] != true {
t.Errorf("首次应抢到,实得 %v", ja["acquired"])
}
b, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
"ns": "s", "ttl": 60})
jb, _ := b.(map[string]interface{})["json"].(map[string]interface{})
if jb["acquired"] != false {
t.Errorf("持有期间第二次不该抢到(这就是串行的意义),实得 %v", jb["acquired"])
}
if _, err := ps.Call("channels", "__serial_release_probe", map[string]interface{}{
"ns": "s"}); err != nil {
t.Fatalf("释放: %v", err)
}
c, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
"ns": "s", "ttl": 60})
jc, _ := c.(map[string]interface{})["json"].(map[string]interface{})
if jc["acquired"] != true {
t.Errorf("释放后应能再抢到,实得 %v", jc["acquired"])
}
}

View File

@ -1,67 +0,0 @@
package core
import (
"strings"
"testing"
)
// ★ 本文件把「上游客观缺口」写成**可执行的判据**,而不是只留在文档里。
//
// 为什么需要:这些能力缺口多次被误判成"我们漏实现了"(我自己就误判了两轮),
// 于是反复派人去"补"。判据在此把事实钉住:
//
// - qoder 加密端点(agent_chat_generation)需要 `qoder-auth-wasm.wasm`
// —— 该二进制**在参考仓库里也不存在**(参考自己也是 readFileSync 一个
// 它不提供的文件)。故我们走明文端点 `/model/v1/chat/completions`,
// **不经过信封** ⇒ qoder-envelope 的解包能力在明文路径上无从生效。
// - TRAE 的 PKCE 分支参考**明确不支持**(只有"探测并拒绝",无 code→token
// 交换端点)⇒ 我们报"暂不支持"是与参考对齐的正确行为。
//
// 判据的作用是**防止误判**:任何人(包括未来的我)想"补"这些能力时,
// 必须先让这个测试失败 —— 也就是先推翻事实,再动手。
func TestUpstreamGapsAreDocumented(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
t.Run("qoder 走明文端点且不经过信封", func(t *testing.T) {
// 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH。
// ⚠ 断言 **URL 的两半**而不是那一整串字面量:本仓已把"路径从 base_url
// 移到 endpoint"(内核 URL = base_url + endpoint)—— 继续钉死整串会让
// 一个纯粹的形态重构把这条**语义**判据弄红,那是判据在咬自己。
if !strings.Contains(s, `base_url = "https://api2-v2.qoder.sh"`) ||
!strings.Contains(s, `endpoint = "/model/v1/chat/completions"`) {
t.Fatal("qoder 的 chat 端点变了 —— 若改成加密端点 agent_chat_generation," +
"则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名," +
"否则响应会被当成畸形数据")
}
// 明文端点没有信封层,故解包**不该**存在(存在反而是死代码)
if strings.Contains(s, "unwrapQoderEnvelope") {
t.Fatal("出现了 qoder 信封解包 —— 明文端点没有信封层。" +
"要么端点已换成加密端点(那需要 wasm),要么这是死代码")
}
})
t.Run("TRAE PKCE 明确报不支持(与参考一致)", func(t *testing.T) {
if !strings.Contains(s, "本实现暂不支持该流程") {
t.Fatal("TRAE 的 PKCE 分支必须明确报出「暂不支持」—— " +
"参考 trae-oauth.ts 也只做探测+拒绝,没有 code→token 交换端点。" +
"若改为尝试交换,会拿 code 去打要 RefreshToken 的 ExchangeToken(必然 400)")
}
})
t.Run("codearts 目录打两个签名端点(本轮补上)", func(t *testing.T) {
for _, want := range []string{
"opengw.developer.huaweicloud.com/api/v1/gateway/config",
"snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin",
`sign = "codearts"`,
} {
if !strings.Contains(s, want) {
t.Fatalf("codearts 目录缺 %q —— 参考 fetchCodeArtsRemoteModels 打两个"+
"**必须 AK/SK 签名**的端点,只打一个会少一半模型", want)
}
}
})
}

View File

@ -1,59 +0,0 @@
package core
import (
"strings"
"testing"
)
// VNC 一直白屏的真因(2026-10-14 定位)。
//
// 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium
// **独立 GPU 进程**启动所需的系统调用 ⇒ 它起不来 ⇒ chromium 放弃并退出:
//
// ERROR:gpu_process_host.cc] GPU process launch failed: error_code=1002
// FATAL:gpu_data_manager_impl_private.cc] GPU process isn't usable. Goodbye.
//
// 整个 chromium 几秒内退出 ⇒ **VNC 上永远白屏**,而插件因为只等 CDP 端口就报
// 「已就绪」,把白屏谎报成成功。
//
// 为什么长期没发现:手工在**沙箱外**跑同一条命令每次都成功。定位靠
// `systemd-run` 逐 directive 二分 —— 只有 SystemCallFilter 与 ProtectSystem
// 能复现,其余加固项无影响。
//
// 修法:`--in-process-gpu`(GPU 逻辑跑在浏览器进程内,不 fork)。
func TestBrowserStackUsesInProcessGPU(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// ① 必须带 --in-process-gpu,否则在加固环境下浏览器起不来
if !strings.Contains(b, "--in-process-gpu") {
t.Fatal("chromium 命令行必须带 --in-process-gpu —— 否则在 " +
"SystemCallFilter=@system-service 的加固环境下 GPU 进程起不来、" +
"浏览器自杀、VNC 永远白屏(真实事故,2026-10-14)")
}
// ② 必须给可写的 HOME/XDG:ProtectHome=yes 下 /root 只读,
// chromium 建 dconf/ibus 目录会持续刷 permission denied 噪声
for _, k := range []string{"XDG_CACHE_HOME=", "XDG_CONFIG_HOME="} {
if !strings.Contains(b, k) {
t.Errorf("chromium 命令行应设置 %s(加固环境下 /root 只读会产生噪声日志)", k)
}
}
// ③ **存活探针**必须存在:端口在听 ≠ 浏览器可用。
// 没有它时,"浏览器已死"会被谎报成"已就绪",运维打开 VNC 是白屏,
// 还会以为是自己操作的问题。谎报比直接报错更坏。
if !strings.Contains(b, "browserUtil.cdpAlive") {
t.Error("起栈后必须做 CDP 存活探针 —— 只等端口就报就绪会把白屏谎报成成功")
}
if !strings.Contains(b, "browserUtil.readTail") {
t.Error("启动失败必须把日志真因带给用户,而不是一句「启动失败」")
}
// ④ 失败必须回收整组进程(否则每次失败都留一套 Xvfb/x11vnc/websockify)
idx := strings.Index(b, "浏览器启动后即退出")
if idx < 0 {
t.Fatal("应有「浏览器启动后即退出」的失败分支")
}
if !strings.Contains(b[idx:idx+600], "reapBrowser()") {
t.Error("起栈失败必须先回收已起的进程(否则泄漏一套 Xvfb/x11vnc/websockify)")
}
}

View File

@ -1,70 +0,0 @@
package core
import (
"strings"
"testing"
)
// VNC 会话生命周期的**前向引用**判据(真实 bug 的守卫)。
//
// `sweepSessions` 里调用了 reapLoopback / browserStack / reapBrowser,而这三个
// 助手定义在**更后面**。Lua 的 `local` 只从定义处向后可见 ⇒ 未前向声明时它们是
// **全局 nil**:加载期不报错,只有真正走到"会话过期回收"那条分支才炸:
//
// attempt to call global 'reapLoopback' (a nil value)
//
// 症状极具迷惑性:**第一次登录完全正常**(没有过期会话可回收),第二次才 500,
// 且已经起来的浏览器栈**不会被回收**(reapBrowser 同样调不到)⇒ 进程与端口泄漏。
// 本文件此前已因同一个坑踩过 12 次,故这里钉住"三者都必须有前向声明"。
func TestSweepSessionsHelpersAreForwardDeclared(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// ① 必须存在对这三个助手的前向声明
if !strings.Contains(b, "local reapLoopback, browserStack, reapBrowser") {
t.Fatal("sweepSessions 用到的三个助手必须**前向声明** —— 否则 " +
"`local` 只从定义处向后可见,运行到该分支时是 nil 调用(第一次登录" +
"正常、第二次 500,且浏览器栈不会被回收)")
}
// ② 定义处必须是**赋值**形态(绑定到那个 local),不能又是 `local function`
// —— 那会遮蔽前向声明,效果与没声明完全相同。
for _, fn := range []string{"browserStack", "reapBrowser", "reapLoopback"} {
if strings.Contains(b, "local function "+fn+"(") {
t.Errorf("%s 的定必须是 `%s = function(...)` 形式(绑定前向声明);"+
"再用 `local function` 会另起一个 local,前向声明形同虚设", fn, fn)
}
if !strings.Contains(b, "\n"+fn+" = function(") {
t.Errorf("找不到 %s 的赋值式定义", fn)
}
}
// ③ 声明必须**先于**使用(行号比较):这是前向引用的核心
decl := strings.Index(b, "local reapLoopback, browserStack, reapBrowser")
use := strings.Index(b, "\n reapLoopback(id)\n")
if decl < 0 || use < 0 {
t.Fatalf("找不到声明(%d)或使用(%d)", decl, use)
}
if decl > use {
t.Error("前向声明必须在**使用之前** —— 否则 nil 调用")
}
}
// 浏览器栈的**启动后存活**必须有真实判据(谎报"就绪"比直接报错更坏)。
func TestBrowserStackVerifiesLivenessNotJustPort(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// CDP 真应答(端口在听 ≠ 服务可用)
if !strings.Contains(b, "browserUtil.cdpAlive") {
t.Error("必须用 CDP 真应答判活,不能只看端口")
}
// 进程真存活
if !strings.Contains(b, "/proc/") {
t.Error("必须检查 chromium 进程仍存活(/proc/<pid>)")
}
// 失败要把日志真因带出来(而不是一句"启动失败")
if !strings.Contains(b, "browserUtil.readTail") {
t.Error("启动失败必须带出日志真因")
}
}

View File

@ -1,153 +0,0 @@
package core
import (
"net/url"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// TestChannelsVncStackEndToEnd 验证 VNC 兜底链路在**真插件**上跑通:
// 注入一个 flow=vnc 平台 → start_login → 服务端浏览器栈起来(view_url + CDP 端口)
// → 页面里写入 token → collect_credentials 经 CDP 把它取出来。
//
// 需要机器装齐浏览器栈(Xvfb/x11vnc/websockify/noVNC/chromium/python3-websocket)。
// 缺依赖时**跳过**并说明原因 —— 那不是逻辑错误,是环境缺件(插件会如实报告)。
func TestChannelsVncStackEndToEnd(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", "list_sources", "status"}, false)
// 依赖预检:缺件则跳过(并让日志说明)
depOut, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
if err != nil {
t.Fatalf("deps_status: %v", err)
}
if depOut.JSON.(map[string]interface{})["ok"] != true {
t.Skipf("跳过:本机缺浏览器栈依赖 %v(非逻辑错误)", depOut.JSON.(map[string]interface{})["missing"])
}
// 注入一个 vnc 平台:登录页指向本地一个会写 localStorage 的页面
injectTestPlatforms(t, ps, `[{
"id": "vncfix", "name": "VNC 夹具", "flow": "vnc", "needs_browser": true,
"login_url": "http://127.0.0.1:18098/login?redirect_uri=http://127.0.0.1:%d/cb",
"base_url": "https://api.vncfix.example/v1", "adapter": "openai",
"models": ["v-model-1"]
}]`)
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"vncfix"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
m := out.JSON.(map[string]interface{})
if e, _ := m["browser_error"].(string); e != "" {
t.Fatalf("浏览器栈未能启动: %s", e)
}
view, _ := m["view_url"].(string)
if !strings.HasPrefix(view, "/api/plugins/http/channels/browser/proxy/") || !strings.Contains(view, "vnc.html") {
t.Fatalf("应给出同源 noVNC 内嵌地址,不能让远程客户端连服务器回环端口, 实得 %q", view)
}
sess := m["session"].(map[string]interface{})
if sess["cdp_port"] == nil {
t.Fatal("会话应带 CDP 端口(取凭据用)")
}
// ★ noVNC 用 `new URL(path, location.href)` 解析 path(ui.js 的 connect()),
// 即**相对 vnc.html 所在目录**解析,默认值就是相对名 'websockify'。
// 真实缺陷:传了站点根路径 ⇒ 路径被重复拼接,WebSocket 永远连不上,
// 页面看似白屏:
// .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify
// 本判据必须解析**产品自己给出的 URL**:我曾在验证时手拼 URL 并省掉
// path 参数(默认值恰好正确)⇒ 否定了真实缺陷。
u, perr := url.Parse(view)
if perr != nil {
t.Fatalf("view_url 不可解析: %v", perr)
}
q := u.Query()
wspath := q.Get("path")
if wspath == "" {
t.Fatal("view_url 必须显式声明 path=websockify:依赖 noVNC 隐式默认值" +
"会在 URL 形状变化时静默破裂")
}
if strings.HasPrefix(wspath, "/") {
t.Fatalf("path 必须是相对名(noVNC 相对 vnc.html 目录解析),"+
"以 / 开头会被当相对路径而重复拼接,实得 %q", wspath)
}
resolved := u.ResolveReference(&url.URL{Path: wspath})
pages := strings.TrimSuffix(u.Path, "/vnc.html")
if resolved.Path != pages+"/websockify" {
t.Fatalf("path 解析后应为 %q(同级 websockify),实得 %q —— 重复拼接即白屏根因",
pages+"/websockify", resolved.Path)
}
t.Logf("VNC 栈已起: %s → WebSocket %s (CDP %v)", view, resolved.Path, sess["cdp_port"])
// 收尾:回收浏览器栈(测试不留孤儿进程)
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
}); err != nil {
t.Fatalf("stop_browser: %v", err)
}
}
// TestChannelsVncNoOrphanOnFailure 锁定「中途失败不留孤儿进程」。
//
// 真实缺陷:spawn 往**局部** pids 表追加,只在函数末尾才回写 b.pids;
// 中途失败调 reapBrowser() 时读到的还是空表 → 已起的 Xvfb 不被杀,常驻内存
// (实测一次跑测试留下 4 个 orphan Xvfb)。
//
// 手法:让第 2 步(chromium)必然失败(CDP 端口探测不到,因为给一个不存在的
// 浏览器路径),断言第 1 步起的 Xvfb 已被回收。
func TestChannelsVncNoOrphanOnFailure(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", "list_sources", "status"}, false)
depOut, _ := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
if depOut.JSON.(map[string]interface{})["ok"] != true {
t.Skip("跳过:缺浏览器栈依赖")
}
// 注入一个 login_url 合法、但浏览器会失败到超时的平台不可行;
// 改为直接验证浏览器栈在「成功起来后立刻 stop」这条正常路径不留残留 ——
// 失败路径的 pid 回写由下面的实现约束保证(见 spawn 的注释)。
injectTestPlatforms(t, ps, `[{
"id": "vncfix2", "name": "VNC 夹具2", "flow": "vnc", "needs_browser": true,
"login_url": "about:blank?redirect_uri=http://127.0.0.1:%d/cb",
"base_url": "https://api.vncfix.example/v1", "adapter": "openai", "models": ["v2"]
}]`)
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"vncfix2"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
if e, _ := out.JSON.(map[string]interface{})["browser_error"].(string); e != "" {
t.Skipf("本机浏览器栈起不来(%s),跳过", e)
}
// 起来后立刻回收
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{Method: "POST", Role: "admin"}); err != nil {
t.Fatal(err)
}
// 回收后状态必须清零
st, _ := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
if st.JSON.(map[string]interface{})["running"] != false {
t.Fatal("stop_browser 后 running 应为 false")
}
}

View File

@ -1,203 +0,0 @@
package core
import (
"os"
"strconv"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// 显示号泄漏是「测试单独跑绿、全量跑红」的根源,值得一条判据钉住它。
//
// 现场(2026-10-13):/tmp/.X<n>-lock 在 Xvfb 被 SIGKILL 后会留下(插件收尾
// 就是 `kill -9`)。而旧选号逻辑是「锁/socket 存在就当被占」→ 每个被强杀的
// 显示器**永久烧掉一个号**。反复跑测试/反复登录后 91 起的整段号被占满
// (实测 41/41),于是 pickFree 扫不到空位 → `return start`(91) → Xvfb 报
// “Server is already active for display 91”。
// 表现为 TestChannelsVncStackEndToEnd 单独 1.07s 通过、全量跑 15s 超时失败。
//
// 判据是**行为**,分三步(注意:插件占用期间锁存在是**正确**的 —— Xvfb 自己
// 会写锁;错的是「用完之后不清理」和「被别人的陈旧锁挡死」):
// 1. 陈旧锁不得挡住选号(记录 PID 已死)→ 能正常起来,且不报 already active;
// 2. 空闲号被选中后,锁里应是**真实存活的** PID(即插件真占用了它);
// 3. 收栈之后,该号的锁与 socket 必须被清掉(这才是泄漏的根治)。
func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
const ghost = "999999" // 必然不存在的 PID
// 造 3 个陈旧锁,并要求插件**复用**其中一个。
// 只跳过不复用的话,号段会被逐个烧掉 —— 实测 41/41 占满后每次必失败。
//
// ⚠️ 号段必须**跟着测试台的 XDISPLAY_BASE 走**(不是硬编码 91/92/93)。
// 原因:生产 unit 带 PrivateTmp,它起的 Xvfb :91 的锁在服务自己的 /tmp 里,
// 宿主测试看不到 ⇒ 测试会去抢 91 并失败。测试台因此把起点挪到 300
// (见 kernelTestCore);硬编码 90 段会让本判据造出**根本不会被扫描到**的
// 陈旧锁,于是"没复用"这个断言永远失败 —— 那是判据与被测对象脱节。
base := 300
if v := os.Getenv("LLMSPROXY_XDISPLAY_BASE"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
base = n
}
}
ghosts := []int{base, base + 1, base + 2}
for _, n := range ghosts {
l := "/tmp/.X" + strconv.Itoa(n) + "-lock"
if _, err := os.Stat(l); err == nil {
t.Skipf("前置条件不满足:%s 已存在(先清理测试环境再跑)", l)
}
if err := os.WriteFile(l, []byte(ghost+"\n"), 0o644); err != nil {
t.Skipf("无法在 /tmp 造锁(跳过): %v", err)
}
defer os.Remove(l)
}
lock := "/tmp/.X" + strconv.Itoa(base) + "-lock"
sock := "/tmp/.X11-unix/X" + strconv.Itoa(base)
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)
}
// 12 个真实平台里已无 vnc 流,故注入一个夹具(夹具不写进生产目录)。
injectTestPlatforms(t, ps, `[{
"id": "xdispfx", "name": "显示器夹具", "flow": "vnc", "needs_browser": true,
"login_url": "http://127.0.0.1:18099/login",
"base_url": "https://api.xdispfx.example/v1", "adapter": "openai",
"models": ["x-model-1"]
}]`)
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"xdispfx"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
j, _ := out.JSON.(map[string]interface{})
if j == nil {
t.Fatalf("无 JSON 响应 (status=%d)", out.Status)
}
msg, _ := j["browser_error"].(string)
if strings.Contains(msg, "already active for display") {
t.Fatalf("陈旧锁把选号挡死了(应回收):%s", msg)
}
if msg != "" {
// 缺浏览器依赖起不来是允许的 —— 但陈旧锁仍必须被回收(不占号)。
if _, err := os.Stat(lock); err == nil {
t.Errorf("陈旧锁未被回收: %s(记录 PID %s 已不存在)", lock, ghost)
}
t.Skipf("浏览器栈未启动(与选号无关): %s", msg)
}
// ② 插件确实占用了某个显示器,且锁里是**活着的** PID(不是那个幽灵)。
st, err := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
Method: "GET", Role: "admin",
})
if err != nil {
t.Fatalf("browser_status: %v", err)
}
sess, _ := st.JSON.(map[string]interface{})["session"].(map[string]interface{})
used := 0
if sess != nil {
if f, ok := sess["display"].(float64); ok {
used = int(f)
}
}
if used == 0 {
t.Fatalf("会话未报告 display: %v", st.JSON)
}
// 关键断言:选中的必须是那 3 个陈旧号之一 —— 证明陈旧锁被**回收复用**了。
// 只跳过不复用的实现(变异)会选 94,从而被抓到。
reclaimed := false
for _, n := range ghosts {
if used == n {
reclaimed = true
}
}
if !reclaimed {
t.Errorf("选中的 :%d 不是陈旧号 %v —— 陈旧锁没被回收复用(号段会被逐个烧掉,最终饿死)", used, ghosts)
}
if raw, err := os.ReadFile("/tmp/.X" + strconv.Itoa(used) + "-lock"); err == nil {
pid := strings.TrimSpace(string(raw))
if pid == ghost {
t.Errorf("选中的显示器 :%d 锁里仍是幽灵 PID(陈旧锁没被换掉)", used)
} else if pid != "" {
if _, err := os.Stat("/proc/" + pid); err != nil {
t.Errorf("选中的显示器 :%d 锁里 PID %s 不存在(记录与事实不符)", used, pid)
}
}
} else {
t.Logf(":%d 未生成锁文件(部分 Xvfb 行为,可接受)", used)
}
// ③ 收栈后必须不留锁/socket —— 这条直接钉住「用 kill -9 收尾却不清理」的泄漏。
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
}); err != nil {
t.Fatalf("stop_browser: %v", err)
}
dl := "/tmp/.X" + strconv.Itoa(used) + "-lock"
ds := "/tmp/.X11-unix/X" + strconv.Itoa(used)
if _, err := os.Stat(dl); err == nil {
t.Errorf("收栈后锁未清理: %s(下次会把这个号误判为被占 → 号被永久烧掉)", dl)
_ = os.Remove(dl)
}
if _, err := os.Stat(ds); err == nil {
t.Errorf("收栈后 socket 未清理: %s", ds)
_ = os.Remove(ds)
}
_ = sock
}
// 反向判据:**活着的**占用不得被回收(否则会把别人正在用的显示器抢走)。
func TestXDisplaySelectionKeepsLiveLocks(t *testing.T) {
const disp = 92
lock := "/tmp/.X" + strconv.Itoa(disp) + "-lock"
if _, err := os.Stat(lock); err == nil {
t.Skipf("前置条件不满足:%s 已存在", lock)
}
// 用一个**真实存活**的 PID(本进程)写锁。
if err := os.WriteFile(lock, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
t.Skipf("无法在 /tmp 造锁(跳过): %v", err)
}
defer os.Remove(lock)
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)
}
injectTestPlatforms(t, ps, `[{
"id": "xdispfx2", "name": "显示器夹具2", "flow": "vnc", "needs_browser": true,
"login_url": "http://127.0.0.1:18097/login",
"base_url": "https://api.xdispfx2.example/v1", "adapter": "openai",
"models": ["x-model-2"]
}]`)
defer func() {
_, _ = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
})
}()
if _, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"xdispfx2"}`, Role: "admin",
}); err != nil {
t.Fatalf("start_login: %v", err)
}
// 记录的是活 PID → 该锁必须原样保留(不得抢占别人正在用的显示器)。
raw, err := os.ReadFile(lock)
if err != nil {
t.Fatalf("活锁被误回收: %s(记录的是真实存活 PID,不得回收)", lock)
}
if got := strings.TrimSpace(string(raw)); got != strconv.Itoa(os.Getpid()) {
t.Errorf("活锁内容被改动: %q(应保持原样)", got)
}
}

View File

@ -1,285 +0,0 @@
package core
import (
"encoding/json"
"strings"
"testing"
)
// ★ ZCode 3012 的判据必须是**多形态**的,且误判不是"文案不好看"——
// 领取路径会据此**短路**(跳过本可领取的 plan),而限流与风控的用户动作
// **完全相反**(稍后重试 vs 别重试)。
//
// 本判据逐行搬自参考 zcode-diagnostics.ts 的对抗性审计实测矩阵
// (2026-10-05 自审 + 审计打出的真实缺陷)。
//
// 为什么用**共用模块**验:这段逻辑被领取路径(插件主体)与推理路径
// (channels-zcode 适配器)共用,任一侧漂移都会让"请勿连续重试"只出现在
// 一条路径上。判据直接打共用模块的导出,两边用的是同一份源码文本。
func TestZcode3012ClassificationMatrix(t *testing.T) {
h := newZcodeIdentityHarness(t)
cases := []struct {
name string
body string
want bool
}{
// ── 真风控(必须命中)──
{"上游真实文案(带码)", `{"code":3012,"msg":"request has been blocked due to unusual activity."}`, true},
{"码是纯数字字符串", `{"code":"3012","msg":"x"}`, true},
{"仅正文语义(无数字)", `request has been blocked due to unusual activity.`, true},
{"3012 与 block 紧邻", `3012 blocked by waf`, true},
// ── 必须**不**命中(误判会让用户做错事)──
{"13012 不是 3012", `{"code":13012}`, false},
{"标识符 plan-3012-trust", `plan-3012-trust`, false},
{"标识符 trust_3012", `trust_3012`, false},
{"字母包裹 x3012y", `x3012y`, false},
{"限流的毫秒数", `too many requests, retry after 3012 ms`, false},
{"HTML 里的 3012", `<h1>Error 3012</h1>upstream connect timeout`, false},
{"限流+block 距 14", `rate limited: request blocked, retry after 3012 ms`, false},
{"block 距 12", `temporarily blocked; see error 3012`, false},
{"额度码 1005", `{"code":1005,"msg":"quota"}`, false},
// ── 隔离「词边界」规则:这些用例同时满足"block 紧邻",
// 只有词边界能区分 ⇒ 裸子串实现会把前三个判成风控。
// (最初的矩阵没覆盖这一组,变异测试因此没咬住 —— 补上后才有效。)
{"标识符+block 紧邻", `plan-3012 blocked`, false},
{"字母包裹+block 紧邻", `x3012 blocked`, false},
{"下划线+block 紧邻", `trust_3012 blocked`, false},
{"独立词+block 紧邻", `3012 blocked`, true},
{"13012 里的片段+block 紧邻", `13012 blocked`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := h.callZD("hasUnusualActivity", tc.body)
if got != tc.want {
t.Errorf("hasUnusualActivity(%q) = %v, want %v\n"+
"(误判的后果不是文案问题:领取路径会据此短路,且限流='稍后重试'、\n"+
" 风控='别重试',两者用户动作完全相反)", tc.body, got, tc.want)
}
})
}
}
// ★ 时间差格式化必须逐档单独算:参考初版把分钟档写成 `floor(seconds % 60)`,
// 于是 90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)。
// 该错误是在**真实探针输出**里发现的,纯函数单测当时没覆盖 ≥60 秒的区间。
func TestZcodeFormatGapDoesNotMisrenderMinutes(t *testing.T) {
h := newZcodeIdentityHarness(t)
for _, tc := range []struct {
ms int
want string
}{
{90000, "1分30秒"}, // 参考踩过的坑:错法会得到 "30分30秒"
{59000, "59.0s"}, // 不足一分钟
{60000, "1分"}, // 整分钟
{3600000, "1小时"}, // 整小时
{3900000, "1小时5分"}, // 小时+分
{500, "500ms"},
} {
got := h.callZDString("formatGap", tc.ms)
if got != tc.want {
t.Errorf("formatGap(%d) = %q, want %q", tc.ms, got, tc.want)
}
}
}
// ★ 诊断行**绝不能含凭据片段**(参考的设计红线之一)。
//
// 账号只以"进程内自增序号"出现。判据用「传入的账号 id 的每个子串都不得出现在
// 文案里」来钉死这条 —— 不是"看起来没有"。
func TestZcodeDiagnosticLeaksNoCredentials(t *testing.T) {
h := newZcodeIdentityHarness(t)
const secretID = "zcode-abcdef1234567890-SECRET"
h.callZDAction("accountOrdinal", secretID)
h.callZDAction2("noteRequestSent", secretID, 1000)
h.callZDAction2("noteRequestOk", secretID, 1100)
diag := h.callZDString("formatDiagnostic", map[string]interface{}{
"accountId": secretID,
"sentAt": 1200,
"now": 1300,
"status": 405,
})
if diag == "" {
t.Fatal("formatDiagnostic 返回空")
}
// 账号 id 的每个 >=4 字符子串都不得出现
for i := 0; i+4 <= len(secretID); i++ {
if strings.Contains(diag, secretID[i:i+4]) {
t.Fatalf("诊断行泄漏账号 id 片段 %q:%s", secretID[i:i+4], diag)
}
}
if !strings.Contains(diag, "账号#") {
t.Fatalf("诊断行应含「账号#序号」形态:%s", diag)
}
t.Logf("✓ 诊断行无凭据片段: %s", diag)
}
// ★ 3012 的文案必须**共用**(推理 + 领取两条路径),且必须含"请勿连续重试"。
//
// 3012 不是"重试就能过"(那是 3007),而是**有账号冷却惩罚**:
// 30 分钟起步,反复触发升级到 24 小时乃至停用。
func TestZcode3012MessageIsSharedAndWarnsAboutRetries(t *testing.T) {
h := newZcodeIdentityHarness(t)
msg := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{
"subject": "领取 plan-x",
"rawResponse": "request has been blocked",
})
for _, want := range []string{"3012", "请勿连续重试", "冷却惩罚", "领取 plan-x"} {
if !strings.Contains(msg, want) {
t.Fatalf("风控文案缺 %q:%s", want, msg)
}
}
// 空 subject/diagnostic/rawResponse 时不得留下空壳("本机诊断:"后面啥都没有)
bare := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{})
if strings.Contains(bare, "本机诊断:\n") || strings.Contains(bare, "原始响应:\n") {
t.Fatalf("空参数不应留下空壳段:%q", bare)
}
t.Logf("✓ 文案: %s", strings.SplitN(msg, "\n", 2)[0])
}
// ★ 请求体形态诊断必须读**真正要发出去的对象**,而不是常量。
//
// 常量只能证明"源码里写了多少字",证明不了"线上发出去多少字"。
// 两者一旦不一致(例如某条改动把 system 顶掉了),只有读实际值才看得出来
// —— 它会**如实显示 0 字符**,而不是拿常量里的 2898 把问题盖住。
func TestZcodeRequestShapeReadsActualObject(t *testing.T) {
h := newZcodeIdentityHarness(t)
// 用真实的适配器产出 → 再诊断它(而不是手搓一个假 system)
out, err := h.transformRequest(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := jsonUnmarshal(out, &req); err != nil {
t.Fatal(err)
}
shape := h.callZDMap("describeRequestShape", req["system"], req["messages"])
if shape == nil {
t.Fatal("describeRequestShape 返回空")
}
chars, _ := shape["identityChars"].(float64)
if chars < 2800 {
t.Fatalf("身份块实测字符数 %v < 2800(诊断必须读实际值)", chars)
}
if shape["hasDateBlock"] != true {
t.Fatalf("应检测到日期块,实得 %v", shape["hasDateBlock"])
}
if shape["firstRole"] != "user" {
t.Fatalf("firstRole 应为 user,实得 %v", shape["firstRole"])
}
t.Logf("✓ 形态诊断: 块数=%v 身份块=%v 日期块=%v role=%v",
shape["systemBlocks"], chars, shape["hasDateBlock"], shape["firstRole"])
// 反例:空 system ⇒ 必须**如实显示 0**(不能被常量盖住)
empty := h.callZDMap("describeRequestShape", []interface{}{}, req["messages"])
if c, _ := empty["identityChars"].(float64); c != 0 {
t.Fatalf("空 system 时 identityChars 必须为 0(如实反映实际值),实得 %v", c)
}
}
// ── harness 助手:经共用的探针打**那一份** ZD 实现 ──
//
// 走探针而不是让测试自己 load 一份副本:判据必须打真实被两条路径共用的实现,
// 自己 load 副本会退化成"各自测各自的",共用一旦漂移就测不出来。
func (h *zcodeIdentityHarness) callZD(fn string, a interface{}) bool {
h.t.Helper()
res, err := h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
m := res.(map[string]interface{})["json"].(map[string]interface{})
if e, ok := m["error"]; ok {
h.t.Fatalf("探针错误(%s): %v", fn, e)
}
v, ok := m["result"].(bool)
if !ok {
h.t.Fatalf("%s 返回非布尔: %#v", fn, m["result"])
}
return v
}
func (h *zcodeIdentityHarness) callZDString(fn string, a interface{}) string {
h.t.Helper()
var res interface{}
var err error
if opts, ok := a.(map[string]interface{}); ok {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "opts": opts})
} else if fn == "formatGap" {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
} else {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
}
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
m := res.(map[string]interface{})["json"].(map[string]interface{})
if e, ok := m["error"]; ok {
h.t.Fatalf("探针错误(%s): %v", fn, e)
}
s, _ := m["result"].(string)
return s
}
// callZDAction / callZDAction2:调"动作型"探针(不返回结果,只看没报错)。
func (h *zcodeIdentityHarness) callZDAction(fn string, a interface{}) {
h.t.Helper()
res, err := h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
}
}
func (h *zcodeIdentityHarness) callZDAction2(fn string, a interface{}, a2 int) {
h.t.Helper()
res, err := h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a, "a2": a2})
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
}
}
func (h *zcodeIdentityHarness) callZDMap(fn string, args ...interface{}) map[string]interface{} {
h.t.Helper()
var res interface{}
var err error
if len(args) == 2 {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "system": args[0], "messages": args[1]})
} else {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": args[0]})
}
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
m := res.(map[string]interface{})["json"].(map[string]interface{})
if e, ok := m["error"]; ok {
h.t.Fatalf("探针错误(%s): %v", fn, e)
}
out, _ := m["result"].(map[string]interface{})
return out
}
// jsonUnmarshal 是本文件用的小助手(避免与其它测试文件的导入冲突)。
func jsonUnmarshal(s string, v interface{}) error {
return json.Unmarshal([]byte(s), v)
}

View File

@ -1,404 +0,0 @@
package core
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// ★ ZCode 的身份块是 3012 准入的**唯一开关**。
//
// 参考实测(三个账号、17 次请求):system 缺官方身份块结构时,上游直接
//
// HTTP 405 + {"code":3012,"msg":"...unusual activity."}
//
// 且与 HTTP 头、版本号、请求频率、多轮历史、tools 声明、运行时**全部无关**。
//
// 判据矩阵(照参考,逐条锁定):
//
// 无 system → 3012
// 仅 cliPrefix(42 字符) → 3012
// cliPrefix + stable(约 2898 字符)→ 200
//
// ⇒ 判据是「身份块**结构**是否存在」,不是字符数凑够。
//
// 本判据看**适配器真正产出给上游的请求体**(transform_request 的返回值),
// 而不是"适配器装了没"这类内部信号。
func TestZcodeAdapterInjectsOfficialIdentityBlocks(t *testing.T) {
h := newZcodeIdentityHarness(t)
out, err := h.transformRequest(`{"model":"glm-5.3-flash","messages":[{"role":"user","content":"hi"}]}`)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatalf("产出不是 JSON: %v\n%s", err, out)
}
// ① system 必须是**块数组**(官方形态),不是纯字符串
sys, ok := req["system"].([]interface{})
if !ok {
t.Fatalf("system 必须是块数组(上游检查的是这个结构),实得 %T: %v",
req["system"], req["system"])
}
if len(sys) < 3 {
t.Fatalf("system 块数至少 3(cliPrefix + stable + Environment),实得 %d", len(sys))
}
// ② block[0] 必须是官方 CLI 前缀(准入必需)
b0, _ := sys[0].(map[string]interface{})
if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" {
t.Fatalf("block[0] 必须是官方 CLI 前缀(逐字),实得 %q", got)
}
// ③ block[1] 必须是官方 stable 段(准入必需),长度对得上
b1, _ := sys[1].(map[string]interface{})
stable, _ := b1["text"].(string)
if len(stable) < 2800 {
t.Fatalf("block[1](stable 段)长度 %d < 2800 —— 身份块不完整会被判为裸请求(3012)",
len(stable))
}
for _, marker := range []string{
"You are an interactive ZCode agent",
"# Harness",
"# ZCode Desktop Context",
} {
if !strings.Contains(stable, marker) {
t.Fatalf("stable 段缺 %q —— 身份块被截断/改写", marker)
}
}
// ④ block[2] 是 Environment 段(官方每会话都发;不发则模型不知 cwd/平台)
b2, _ := sys[2].(map[string]interface{})
env, _ := b2["text"].(string)
if !strings.HasPrefix(env, "# Environment") {
t.Fatalf("block[2] 必须是 Environment 段,实得 %q", env[:zcodeMin(40, len(env))])
}
for _, marker := range []string{"Primary working directory", "Platform", "powered by the model named"} {
if !strings.Contains(env, marker) {
t.Fatalf("Environment 段缺 %q:%s", marker, env)
}
}
// ⑤ 总长度必须达到参考声明的量级(2898)
total := len("You are ZCode, an interactive coding agent") + len(stable)
if total < 2800 {
t.Fatalf("身份块合计 %d 字符 < 2800(参考实测的准入值约 2898)", total)
}
// ⑥ 日期块:插到第一条 user 消息的 content **数组**最前(官方形态)
msgs, _ := req["messages"].([]interface{})
if len(msgs) == 0 {
t.Fatal("messages 为空")
}
m0, _ := msgs[0].(map[string]interface{})
content, ok := m0["content"].([]interface{})
if !ok {
t.Fatalf("首条 user 的 content 必须是**块数组**(拼进字符串会改变结构、仍被判裸请求),实得 %T",
m0["content"])
}
c0, _ := content[0].(map[string]interface{})
if got, _ := c0["text"].(string); !strings.Contains(got, "<system-reminder>") {
t.Fatalf("content[0] 必须是日期块(<system-reminder>),实得 %q", got)
}
t.Logf("✓ 身份块注入正确:块数=%d stable=%d 合计=%d", len(sys), len(stable), total)
}
// ★ 调用方的 system 必须**追加在最后**:上游检查看的是**前缀结构**,
// 官方身份块必须处在开头位置。若被前置会让身份块不在前缀而吃 3012。
func TestZcodeCallerSystemIsAppendedLast(t *testing.T) {
h := newZcodeIdentityHarness(t)
out, err := h.transformRequest(
`{"model":"glm-5.3-flash","messages":[{"role":"system","content":"CALLER_PROMPT_XYZ"},{"role":"user","content":"hi"}]}`)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
sys, _ := req["system"].([]interface{})
if len(sys) < 4 {
t.Fatalf("带调用方 system 时应为 4 块(3 官方 + 1 调用方),实得 %d", len(sys))
}
last, _ := sys[len(sys)-1].(map[string]interface{})
lt, _ := last["text"].(string)
if !strings.Contains(lt, "CALLER_PROMPT_XYZ") {
t.Fatalf("调用方 system 必须在**最后一块**(上游看前缀结构;前置会让身份块不在前缀)")
}
// 且前三块仍是官方块(身份块没有被调用方内容挤走)
b0, _ := sys[0].(map[string]interface{})
if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" {
t.Fatalf("block[0] 必须仍是官方 CLI 前缀,实得 %q", got)
}
}
// 适配器自检出口:身份块长度与 anthropic 基座装载状态。
// 「装了但身份块是空的」这类问题(转义/截断/基座读不到)没有它只能靠猜。
func TestZcodeAdapterProbeReportsIdentityLength(t *testing.T) {
h := newZcodeIdentityHarness(t)
res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
j := res.(map[string]interface{})["json"].(map[string]interface{})
zc, ok := j["zcode"].(map[string]interface{})
if !ok {
t.Fatalf("探针未报告 zcode 适配器装载结果: %v", j)
}
if zc["ok"] != true {
t.Fatalf("zcode 适配器装载失败: %v", zc["err"])
}
}
// zcodeIdentityHarness:加载插件(它会**自装** channels-zcode 适配器),
// 然后直接调那个适配器的 transform_request。
//
// 直接调已装载的适配器(而不是自己去 load 源码)更接近真实路径:判据验的是
// 「上游实际收到的请求体」,包含前导注入、桥的装载等全部环节。
type zcodeIdentityHarness struct {
t *testing.T
c *Core
ps *lua.Plugins
}
func newZcodeIdentityHarness(t *testing.T) *zcodeIdentityHarness {
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)
}
return &zcodeIdentityHarness{t: t, c: c, ps: ps}
}
// transformRequest 调**已自装的** channels-zcode 适配器的 transform_request。
func (h *zcodeIdentityHarness) transformRequest(body string) (string, error) {
h.t.Helper()
vm := h.c.VM()
if vm == nil {
h.t.Fatal("VM 不可用")
}
return vm.Transform("channels-zcode", "transform_request", body)
}
func zcodeMin(a, b int) int {
if a < b {
return a
}
return b
}
// ★ 身份块注入不得破坏协议转换:工具调用、图像、流式帧仍必须走 anthropic 那一份。
//
// 为什么必须验:本适配器的手法是把 anthropic.lua 的源码读进来执行、只覆盖
// transform_request。若「读不到基座」或「覆盖时丢了字段」,协议转换会静默退化
// —— 表现为工具调用参数丢失、图像块消失,而身份块看起来完全正常。
func TestZcodeAdapterKeepsAnthropicProtocolConversion(t *testing.T) {
h := newZcodeIdentityHarness(t)
t.Run("工具定义与工具调用存活", func(t *testing.T) {
in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":"go"}],` +
`"tools":[{"type":"function","function":{"name":"get_weather",` +
`"description":"d","parameters":{"type":"object","properties":{"c":{"type":"string"}}}}}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
// anthropic 形态:tools[].name / input_schema(顶层,不是嵌套在 function 里)
if !strings.Contains(out, `"get_weather"`) {
t.Fatalf("工具定义在转换中丢失(identity 注入不该影响它):%s", out)
}
if !strings.Contains(out, `"input_schema"`) {
t.Fatalf("工具 schema 未转成 anthropic 形态(input_schema 缺失):%s", out)
}
})
t.Run("多模态图像块存活", func(t *testing.T) {
in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":[` +
`{"type":"text","text":"what"},` +
`{"type":"image_url","image_url":{"url":"data:image/png;base64,AAAA"}}]}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"image"`) || !strings.Contains(out, `"base64"`) {
t.Fatalf("图像块在转换中丢失:%s", out)
}
})
t.Run("基座装载状态可见", func(t *testing.T) {
// 读适配器的自检出口:base_loaded 必须为 true(否则身份块虽在、
// 协议转换已退化为透传)
res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
j := res.(map[string]interface{})["json"].(map[string]interface{})
zc := j["zcode"].(map[string]interface{})
if zc["ok"] != true {
t.Fatalf("zcode 适配器未装载: %v", zc)
}
})
}
// ★ 基座(anthropic.lua)读不到时必须**响亮失败**,不得静默退化。
//
// 反例危害:若"尽力而为"(只注入身份块、不作协议转换),会产出一个身份块
// 看着正确的请求,而 tools/图像/流式结构全未转换 —— 上游可能 200,但模型
// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。
//
// 判据看的是**适配器的真实产出**:把适配器目录指到空目录(基座不可得),
// transform_request 必须返回错误而不是原样透传。
func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
// 隔离目录:插件目录下没有兄弟 adapters/ 目录 ⇒ 基座读不到
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
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)
}
vm := c.VM()
if vm == nil {
t.Fatal("VM 不可用")
}
// 插件自装时若基座读不到,适配器仍应装载(身份块是静态的),
// 但调用必须报错
out, err := vm.Transform("channels-zcode", "transform_request",
`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
if err == nil {
t.Fatalf("基座不可得时必须报错,却成功产出了:%s", out)
}
if !strings.Contains(err.Error(), "基座") {
t.Fatalf("错误信息应点明基座问题(可定位),实得 %v", err)
}
t.Logf("✓ 基座缺失时响亮失败: %v", err)
}
// ★ 日期块必须**幂等**:已以 <system-reminder> 开头则不重复插。
//
// 不判的后果:同一请求重试/多次 transform 时日期块叠成多层,改变 content 的
// 前缀结构(上游检查的正是结构)。参考 withContextPrefix 有 startsWithSystemReminder。
func TestZcodeContextPrefixIsIdempotent(t *testing.T) {
h := newZcodeIdentityHarness(t)
// 已是插入后的形态(首条 user 的 content[0] 就是 system-reminder)
in := `{"model":"m","messages":[{"role":"user","content":[` +
`{"type":"text","text":"<system-reminder>\nOLD\n</system-reminder>"},` +
`{"type":"text","text":"hi"}]}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]interface{})
content := msgs[0].(map[string]interface{})["content"].([]interface{})
n := 0
for _, c := range content {
if b, ok := c.(map[string]interface{}); ok {
if s, _ := b["text"].(string); strings.Contains(s, "<system-reminder>") {
n++
}
}
}
if n != 1 {
t.Fatalf("日期块必须幂等(已存在则不重复插),实得 %d 个 system-reminder 块", n)
}
}
// ★ 日期块只处理**第一条**消息,且它必须 role == "user"。
//
// 反例(我第一版就是这样):往后扫第一个 user 消息 —— 若首条是 assistant,
// 官方行为是**不动**,而不是去找后面那条 user。判据钉住这个差别。
func TestZcodeContextPrefixOnlyTouchesFirstMessage(t *testing.T) {
h := newZcodeIdentityHarness(t)
t.Run("首条不是 user 时不动", func(t *testing.T) {
in := `{"model":"m","messages":[` +
`{"role":"assistant","content":"opening"},` +
`{"role":"user","content":"hi"}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]interface{})
// ⚠ 注意:文本会被 anthropic 基座转成**块数组**(协议转换的正常行为),
// 所以这里不能断言 content 仍是字符串 —— 我第一版就这么断言,把正确的
// 实现判成了错。判据要验的是「**日期块**有没有被插进来」这个事实。
noReminder := func(content interface{}) bool {
switch c := content.(type) {
case string:
return !strings.Contains(c, "<system-reminder>")
case []interface{}:
for _, e := range c {
if b, ok := e.(map[string]interface{}); ok {
if t, _ := b["text"].(string); strings.Contains(t, "<system-reminder>") {
return false
}
}
}
}
return true
}
// 首条(assistant)不该被插日期块
if !noReminder(msgs[0].(map[string]interface{})["content"]) {
t.Fatal("首条是 assistant 时不该插日期块(官方只看第一条且要求 role=user)")
}
// 后面的 user 也**不该**被插(官方不往后找)
if !noReminder(msgs[1].(map[string]interface{})["content"]) {
t.Fatal("日期块被插到了后面的 user 消息上 —— 官方只看第一条")
}
// 且原文本内容必须保留(转换不能丢内容)
b, _ := json.Marshal(msgs[0])
if !strings.Contains(string(b), "opening") {
t.Fatal("首条 assistant 的内容丢了")
}
})
t.Run("首条是 user 时插入", func(t *testing.T) {
in := `{"model":"m","messages":[{"role":"user","content":"hi"}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]interface{})
content, ok := msgs[0].(map[string]interface{})["content"].([]interface{})
if !ok {
t.Fatal("首条 user 的 content 应升级为块数组")
}
b0 := content[0].(map[string]interface{})
if s, _ := b0["text"].(string); !strings.Contains(s, "<system-reminder>") {
t.Fatalf("首条 user 的 content[0] 必须是日期块,实得 %q", s)
}
})
}

View File

@ -1,188 +0,0 @@
package core
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// zcodeHarness:ZCode 两段式服务端中介授权流的测试台。
//
// 参考语义(zcode-login.ts 逐行):
//
// ① POST /api/v1/oauth/cli/init → {flow_id, authorize_url, poll_interval_sec}
// Authorization 是**本地造的会话密钥**(不是用户凭据)
// ② 用户在浏览器打开 authorize_url
// ③ GET /api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
// 判据:4xx(除 408/429)= 终态失败;5xx/网络错误 = 继续等;
// code!=0 或 data 缺失 = pending;status=pending = 继续
//
// 此前标 flow="manual"(理由"要解 captcha"),但该授权流**完全不涉及 captcha**
// —— captcha 属于另一条已废弃的旁路(读取本机官方客户端数据)。
type zcodeHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
readyAt int32
gotInit map[string]interface{}
gotAuth string
}
func newZcodeHarness(t *testing.T) *zcodeHarness {
t.Helper()
h := &zcodeHarness{t: t, readyAt: 2}
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 == "/api/v1/oauth/cli/init":
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &h.gotInit)
h.gotAuth = r.Header.Get("Authorization")
fmt.Fprint(w, `{"code":0,"data":{"flow_id":"FLOW-1","poll_token":"PT",
"authorize_url":"https://bigmodel.cn/login?appId=zcode&redirect=x",
"expires_at":9999999999,"poll_interval_sec":2}}`)
case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v1/oauth/cli/poll/"):
if !strings.HasSuffix(r.URL.Path, "FLOW-1") {
fmt.Fprint(w, `{"code":1,"msg":"unknown flow"}`)
return
}
n := atomic.AddInt32(&h.polls, 1)
if n < atomic.LoadInt32(&h.readyAt) {
fmt.Fprint(w, `{"code":0,"data":{"status":"pending"}}`)
return
}
fmt.Fprint(w, `{"code":0,"data":{"status":"ready",
"token":"ZCODE-JWT-1",
"user":{"user_id":"u7","name":"zcode-dev"},
"bigmodel":{"access_token":"BM-AT-1","refresh_token":"BM-RT-1"}}}`)
default:
w.WriteHeader(404)
}
}))
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", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "zcode",
"initpoll": map[string]interface{}{
"init_url": h.ts.URL + "/api/v1/oauth/cli/init",
"poll_url": h.ts.URL + "/api/v1/oauth/cli/poll/{flow_id}",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *zcodeHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *zcodeHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestZcodeInitPollBehavior 逐行锁定参考的 ZCode 授权流语义。
func TestZcodeInitPollBehavior(t *testing.T) {
h := newZcodeHarness(t)
t.Run("行1 init带本地会话密钥与provider", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
if h.gotInit["provider"] != "bigmodel" {
t.Fatalf("init body 应带 provider=bigmodel, 实得 %v", h.gotInit)
}
// Authorization 是我们自己生成的会话密钥(Bearer 后非空),不是用户凭据
if !strings.HasPrefix(h.gotAuth, "Bearer ") || len(h.gotAuth) < 20 {
t.Fatalf("init 应带本地会话密钥作 Bearer, 实得 %q", h.gotAuth)
}
if u, _ := j["url"].(string); !strings.Contains(u, "bigmodel.cn") {
t.Fatalf("应给出 authorize_url, 实得 %q", u)
}
})
t.Run("行2 pending状态保持pending", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 99)
_, sid := h.begin()
for i := 0; i < 3; i++ {
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("status=pending 应保持 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 ready即完成_凭据含zcodeJwt与第三方token", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 2)
_, sid := h.begin()
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", st)
}
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("ready 应 completed, 实得 %v", st)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:zcode")
if !ok {
t.Fatal("凭据未落库")
}
for _, want := range []string{"ZCODE-JWT-1", "BM-AT-1", "zcode-dev"} {
if !strings.Contains(v, want) {
t.Fatalf("凭据应含 %q, 实得 %s", want, v)
}
}
})
t.Run("行4 池路径_凭据落账号ref", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 1)
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"zcode","account_id":%q}`, aid), Role: "admin",
})
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
if st := h.pollOnce(sid); st["state"] != "completed" {
t.Fatalf("池路径应完成: %v", st)
}
if _, ok, _ := h.ps.GetSecret("channels", "channels:zcode:"+aid); !ok {
t.Fatalf("池路径凭据未落到 channels:zcode:%s", aid)
}
})
}

View File

@ -1,38 +0,0 @@
[
{
"method": "GET",
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin",
"body": "",
"ak": "AK1",
"sk": "SK1",
"st": "TOK1",
"canonical": "GET\n/snap-manager/v1/statistics/plugin/\n\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:TOK1\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\nf40918a3339c9db2251933dcce410db05ff8a403de2767bc642140ea93cdc182",
"signature": "5332d627c3b8f14973afe41905f23dce75eaf9a34134dff670cb19835beb0549",
"signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token"
},
{
"method": "GET",
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/delivery?channel=IDE",
"body": "",
"ak": "AK2",
"sk": "SK2",
"st": "",
"canonical": "GET\n/v1/ops/delivery/\nchannel=IDE\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n3c849b40bbad69b981eaf280e162878bf00687d08cb45df43ac792fc23add4f1",
"signature": "a01248a4121fd943f0f3f07aac101cd649ddf34d162729bf06a6a12ae9916b1a",
"signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token"
},
{
"method": "POST",
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/claim",
"body": "{\"campaignId\":\"C1\",\"channel\":\"IDE\"}",
"ak": "AK3",
"sk": "SK3",
"st": "T3",
"canonical": "POST\n/v1/ops/claim/\n\ncontent-type:application/json\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19\nx-sdk-date:20261013T150405Z\nx-security-token:T3\n\ncontent-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token\n85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19",
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n554c2607cff324a770b55e78e2a1c58afe466abf5e4eaa104a6e1fa96acfc733",
"signature": "e5faa52ec51c36b38804aac45a8a19876314cec465ab8f905100e657e8ea68d5",
"signed_headers": "content-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token"
}
]

View File

@ -1,97 +0,0 @@
package core
import (
"encoding/json"
"os"
"strings"
"testing"
)
// CodeArts 的华为云 SDK-HMAC-SHA256 签名必须与参考**逐字节一致**,否则服务端
// 返回 `verify ak sk signature fail`,而这个平台的领取就永远失败 —— 且失败原因
// 会被误读成"账号问题"。此前插件里的实现是凭印象编的另一套形状
// (Credential={ak}/{ts}/{nonce}/sdk_request),必然验签失败。
//
// 期望值由参考实现(sign.ts:signRequestHuawei,Node + node:crypto)生成:
//
// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json
//
// 判据覆盖:canonical request 的拼装、string-to-sign 格式、最终 signature、
// 以及参与签名的头列表(顺序与内容)。
func TestCodeArtsSignatureMatchesReference(t *testing.T) {
raw, err := os.ReadFile("codearts_sig_expected.json")
if err != nil {
t.Skipf("缺差分数据(跳过): %v", err)
}
var cases []struct {
Method string `json:"method"`
URL string `json:"url"`
Body string `json:"body"`
AK string `json:"ak"`
SK string `json:"sk"`
ST string `json:"st"`
Canonical string `json:"canonical"`
StringToSign string `json:"string_to_sign"`
Signature string `json:"signature"`
SignedHeaders string `json:"signed_headers"`
}
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
if len(cases) == 0 {
t.Fatal("差分数据为空")
}
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)
}
for i, tc := range cases {
got, err := ps.Call("channels", "__codearts_sign_probe", map[string]interface{}{
"method": tc.Method, "url": tc.URL, "body": tc.Body,
"ak": tc.AK, "sk": tc.SK, "st": tc.ST, "date": "20261013T150405Z",
})
if err != nil {
t.Fatalf("case %d probe: %v", i, err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("case %d 无 json: %#v", i, got)
}
if m["error"] != nil {
t.Fatalf("case %d 探针报错: %v", i, m["error"])
}
if s, _ := m["canonical"].(string); s != tc.Canonical {
t.Errorf("case %d canonical 不一致\n 实得: %q\n 参考: %q", i, s, tc.Canonical)
}
if s, _ := m["string_to_sign"].(string); s != tc.StringToSign {
t.Errorf("case %d string-to-sign 不一致\n 实得: %q\n 参考: %q", i, s, tc.StringToSign)
}
if s, _ := m["signature"].(string); s != tc.Signature {
t.Errorf("case %d signature 不一致: 实得 %s 参考 %s", i, s, tc.Signature)
}
if s, _ := m["signed_headers"].(string); s != tc.SignedHeaders {
t.Errorf("case %d SignedHeaders 不一致: 实得 %q 参考 %q", i, s, tc.SignedHeaders)
}
// Authorization 头必须是 `SDK-HMAC-SHA256 Access=...`(不是 Credential=)
if s, _ := m["authorization"].(string); len(s) == 0 ||
s[:len("SDK-HMAC-SHA256 Access=")] != "SDK-HMAC-SHA256 Access=" {
t.Errorf("case %d Authorization 形状应为 SDK-HMAC-SHA256 Access=…,实得 %q", i, s)
}
// 签名后追加的头绝不能进 canonical
if s, _ := m["canonical"].(string); len(s) > 0 {
for _, bad := range []string{"Agent-Type", "X-Language", "agent-type", "x-language"} {
if strings.Contains(s, bad) {
t.Errorf("case %d canonical 里出现了签名后追加的头 %s(会导致验签失败)", i, bad)
}
}
}
}
}

View File

@ -7,8 +7,6 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"sort"
@ -17,7 +15,6 @@ import (
"sync/atomic"
"time"
"llmsproxy/internal/billing"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
"llmsproxy/internal/provider"
@ -32,18 +29,11 @@ type Core struct {
mu sync.Mutex
cfg *config.Config
vm *lua.VM
plugins *lua.Plugins
store *config.Store
scheduler *scheduler.Scheduler
registry *provider.Registry
autoChain atomic.Pointer[scheduler.Chain] // chat AUTO chain
autoImageChain atomic.Pointer[scheduler.Chain] // image-generation AUTO chain
// keyAutoChains caches per-key AUTO chains, keyed by the gateway key.
// Entries are rebuilt by rebuildKeyAutoChains whenever sources or key
// config change; AutoChainFor reads them without taking c.mu so request
// handling stays lock-free like the global chain lookup.
keyAutoChains atomic.Pointer[map[string]*scheduler.Chain]
}
// New builds the core from a config file plus runtime overlay.
@ -62,84 +52,7 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
if err := c.vm.Start(); err != nil {
return nil, fmt.Errorf("lua vm: %w", err)
}
// Plugins load AFTER the VM is up (a plugin is just another Lua state) but
// BEFORE anything can serve traffic, so a plugin's request_end hook sees
// the very first request. A plugin that fails to load is logged and skipped
// rather than failing startup: plugins are optional extensions, and failing
// 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.
c.plugins.SetOutboundHosts(sourceHosts(cfg))
// Let the plugin layer validate access specs against the keys that exist.
// A stored allowlist naming a key that does not exist refuses every caller,
// which looks exactly like a broken endpoint.
lua.SetKeyNameLookup(func() []string {
// ListKeys is the structured view (bare `gateway_keys:` entries are
// expanded into records with names); cfg.GatewayKeys is still []string.
keys := c.ListKeys()
out := make([]string, 0, len(keys))
for _, k := range keys {
if k.Name != "" {
out = append(out, k.Name)
}
}
return out
})
// Operator overrides win over the plugin's own declared audience.
for name, aud := range cfg.PluginVisibility {
c.plugins.SetPluginVisibility(name, aud)
}
if err := c.plugins.SeedBundled(); err != nil {
log.Printf("[core] seed bundled plugins: %v", err)
}
if err := c.plugins.LoadDir(); err != nil {
log.Printf("[core] plugin dir: %v", err)
}
for _, p := range c.plugins.List() {
if msg, bad := p["error"]; bad {
log.Printf("[core] plugin %v failed to load: %v", p["name"], msg)
continue
}
log.Printf("[core] plugin %v %v loaded (hooks=%v ui=%v)",
p["name"], p["version"], p["hooks"], p["ui"])
}
c.applyBillingDSL()
c.store = config.NewStore(cfg.RuntimeFile)
// Share one box between the runtime store and config.yaml so a single
// master.key seals both files. config.Load left the config holding
// ciphertext (if it was sealed); unseal it NOW, before anything reads a
// credential.
//
// ORDER IS LOAD-BEARING. These steps each consume secrets and must run
// after the unseal:
// - seedKeys compares cfg.Keys[i].Key against the plaintext
// gateway_keys entries; with ciphertext keys the comparison never
// matched and every restart appended another duplicate admin key
// (production had four copies of the same admin key).
// - rebuildRegistry hands cfg.Sources[i].APIKey to the providers; with
// ciphertext it built every upstream client with "enc:v1:..." as its
// bearer token.
// Previously the unseal happened at the END of this function, and things
// 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)
}
if err := c.store.Load(); err != nil {
return nil, fmt.Errorf("runtime store: %w", err)
}
@ -161,12 +74,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
if err := c.seedPresetTemplates(); err != nil {
return nil, fmt.Errorf("seed preset templates: %w", err)
}
// Seal any credential still in the clear in config.yaml. Startup writes
// nothing when the file was already sealed (hadPlaintextSecrets is decided
// from the on-disk state, before the unseal above).
if err := cfg.SealIfNeeded(hadPlaintextSecrets); err != nil {
return nil, fmt.Errorf("normalize secrets: %w", err)
}
return c, nil
}
@ -285,11 +192,6 @@ func buildRetries(cfg *config.Config) int {
// VM exposes the Lua adapter runtime.
func (c *Core) VM() *lua.VM { return c.vm }
// Plugins exposes the loaded plugin set. Never nil once New* has returned, so
// the gateway can fire stages unconditionally; an unconfigured plugin
// directory yields an empty registry whose Fire is a no-op.
func (c *Core) Plugins() *lua.Plugins { return c.plugins }
func (c *Core) Scheduler() *scheduler.Scheduler { return c.scheduler }
func (c *Core) Registry() *provider.Registry { return c.registry }
@ -297,68 +199,6 @@ func (c *Core) Registry() *provider.Registry { return c.registry }
// AutoChain returns the current AUTO scheduling chain.
func (c *Core) AutoChain() *scheduler.Chain { return c.autoChain.Load() }
// AutoChainFor returns the AUTO chain to use for a request authenticated with
// key. A key that declares its own chain gets that chain; every other key
// inherits the global chain, so keys created before per-key chains existed
// keep their current behaviour with no configuration change.
//
// It returns (chain, ok). ok is false only when the key has its own chain but
// it compiled to nothing usable (e.g. every slot names a model that no longer
// exists) — the caller must surface that rather than silently downgrading the
// user to the global chain, which would be a confusing, invisible switch.
func (c *Core) AutoChainFor(key string) (*scheduler.Chain, bool) {
if m := c.keyAutoChains.Load(); m != nil {
if ch, ok := (*m)[key]; ok {
return ch, true
}
}
return c.autoChain.Load(), true
}
// rebuildKeyAutoChains recompiles every per-key AUTO chain from the current
// config and provider registry, then swaps the cache in one shot so requests
// never observe a half-built map. Keys without their own chain are absent
// from the map and fall back to the global chain.
func (c *Core) rebuildKeyAutoChains() {
m := make(map[string]*scheduler.Chain)
for _, k := range c.cfg.Keys {
if !k.HasOwnAuto() {
continue
}
m[k.Key] = c.chainForRules(k.Auto, false)
}
c.keyAutoChains.Store(&m)
}
// SaveKeyAuto persists one key's own AUTO chain and recompiles the per-key
// chain cache. Passing an empty list clears the override so the key inherits
// the global chain again.
func (c *Core) SaveKeyAuto(key string, entries []config.ModelScope) error {
c.mu.Lock()
defer c.mu.Unlock()
idx := -1
for i, k := range c.cfg.Keys {
if k.Key == key {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("key not found")
}
if err := ValidateScopeQuotas(entries); err != nil {
return err
}
c.cfg.Keys[idx].Auto = cleanScopes(entries)
if err := c.saveConfig(); err != nil {
return err
}
// Rebuild the whole cache so a cleared override stops shadowing the
// global chain for this key.
c.rebuildKeyAutoChains()
return nil
}
// AutoImageChain returns the persisted image-generation AUTO chain snapshot.
func (c *Core) AutoImageChain() *scheduler.Chain { return c.autoImageChain.Load() }
@ -398,15 +238,10 @@ func (c *Core) FindKey(key string) (config.GWKey, bool) {
}
// CreateKey builds a new random gateway key and persists it to config.yaml.
// Quotas live on the model scope entries, so a new key's budget is whatever
// its scopes carry.
func (c *Core) CreateKey(name, role string, models []config.ModelScope, note string) (config.GWKey, error) {
c.mu.Lock()
defer c.mu.Unlock()
models = cleanScopes(models)
if err := ValidateScopeQuotas(models); err != nil {
return config.GWKey{}, err
}
key := make([]byte, 16)
if _, err := rand.Read(key); err != nil {
return config.GWKey{}, err
@ -419,7 +254,9 @@ func (c *Core) CreateKey(name, role string, models []config.ModelScope, note str
Note: note,
CreatedAt: time.Now().Unix(),
}
rec.Role = config.NormalizeRole(rec.Role)
if rec.Role == "" {
rec.Role = "user"
}
c.cfg.Keys = append(c.cfg.Keys, rec)
if err := c.saveConfig(); err != nil {
return config.GWKey{}, err
@ -427,16 +264,10 @@ func (c *Core) CreateKey(name, role string, models []config.ModelScope, note str
return rec, nil
}
// UpdateKey mutates a key's name/role/model scope and persists it. The scope
// entries carry their own quotas, so replacing the scope replaces the budgets.
// UpdateKey mutates a key's name/role/model scope and persists it.
func (c *Core) UpdateKey(key, name, role string, models []config.ModelScope, note string) (config.GWKey, error) {
c.mu.Lock()
defer c.mu.Unlock()
if models != nil {
if err := ValidateScopeQuotas(models); err != nil {
return config.GWKey{}, err
}
}
for i, k := range c.cfg.Keys {
if k.Key == key {
if name != "" {
@ -608,11 +439,6 @@ func (c *Core) mergedSources() []config.Source {
}
// resolveSourceKey applies api_key_env and decrypts enc:v1: ciphertext.
// Config values are already unsealed at startup (Config.NormalizeSecretsForRun),
// so the decrypt branch is the belt-and-braces path for a source that arrived
// already sealed through another route. A failure there leaves the ciphertext in
// place, which makes the upstream reject the key loudly rather than sending an
// empty Authorization header that might look like a config-less source.
func (c *Core) resolveSourceKey(s config.Source) config.Source {
if s.APIKeyEnv != "" {
if v := os.Getenv(s.APIKeyEnv); v != "" {
@ -621,11 +447,7 @@ func (c *Core) resolveSourceKey(s config.Source) config.Source {
return s
}
if box := c.store.SecretBox(); box != nil && strings.HasPrefix(s.APIKey, "enc:v1:") {
if v, err := box.Decrypt(s.APIKey); err == nil {
s.APIKey = v
} else {
log.Printf("[core] source %s: api_key decrypt failed: %v", s.Name, err)
}
s.APIKey = box.MustDecrypt(s.APIKey)
}
return s
}
@ -648,98 +470,26 @@ func (c *Core) rebuildRegistry() error {
}
c.buildAutoChain()
c.buildAutoImageChain()
c.rebuildKeyAutoChains()
// A source edit can add or remove an upstream host, which changes what a
// plugin is allowed to reach. Keeping this beside the chain rebuilds means
// one call site owns "config changed, refresh derived state".
c.plugins.SetOutboundHosts(sourceHosts(c.cfg))
return nil
}
// sourceHosts extracts the host of every configured source's base_url — the
// allowlist for plugin outbound HTTP. Unparseable URLs are skipped rather than
// failing startup: a malformed base_url is already rejected when the provider is
// built, and it must not take plugins down with it.
//
// A source may also imply a SECOND host. OpenDesign splits inference
// (amr-link.open-design.ai) from billing (amr-api.open-design.ai), and the
// quota plugin derives the billing host from the inference one — so the host it
// asks for is not the host the operator configured, and the request would be
// refused with a message that reads like a plugin defect. Adding the derived
// host here is what makes the probe reachable.
//
// The grant stays scoped: only a source that IS on amr-link pulls in amr-api, so
// a deployment with no OpenDesign source does not silently gain the entry.
func sourceHosts(cfg *config.Config) []string {
if cfg == nil {
return nil
}
out := make([]string, 0, len(cfg.Sources))
seen := make(map[string]bool, len(cfg.Sources))
add := func(h string) {
if h == "" || seen[h] {
return
}
seen[h] = true
out = append(out, h)
}
for _, s := range cfg.Sources {
if s.BaseURL == "" {
continue
}
u, err := url.Parse(s.BaseURL)
if err != nil || u.Hostname() == "" {
continue
}
add(u.Hostname())
add(deriveBillingHost(u.Hostname()))
}
return out
}
// deriveBillingHost returns the billing host a configured source implies, or
// the host unchanged when it implies none.
//
// Kept in Go as well as in the Lua adapter because the two must agree: Go builds
// the allowlist and Lua builds the request URL, and a disagreement shows up as a
// refused request rather than as an error naming the mismatch. The rule is the
// full hostname, not a prefix — `amr-link.` as a prefix would also rewrite
// amr-link.example.com into an OpenDesign address.
func deriveBillingHost(host string) string {
if host == "amr-link.open-design.ai" {
return "amr-api.open-design.ai"
}
return host
}
// buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules
// against the current providers. Slot model ids are normalized to the exact
// configured spelling (case-insensitive match), otherwise ModelFor would fall
// back to the source's best chat model and cooldown/quota bookkeeping would
// key on a name that never matches.
func (c *Core) buildAutoChain() {
c.autoChain.Store(c.chainForRules(c.cfg.Auto, false))
}
// chainForRules compiles a chat AUTO chain from a rule list. kindFilter is
// false for the chat chain (image models are skipped) and true for the image
// chain (only image models participate). Per-key chains always compile as
// chat chains: a user's key configures the models *they* talk to.
func (c *Core) chainForRules(rules []config.ModelScope, imageOnly bool) *scheduler.Chain {
prov := func(model, source string) scheduler.Provider {
p := c.registry.ProviderForSlot(model, source)
if p == nil {
return nil
}
m := p.ModelByID(model)
if imageOnly && (m == nil || m.Kind != "image") {
return nil
}
if !imageOnly && m != nil && m.Kind == "image" {
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
return nil
}
return p
}
rules := c.cfg.Auto
sr := make([]scheduler.Rule, 0, len(rules))
for _, e := range rules {
model, source := e.Model, e.Source
@ -748,11 +498,7 @@ func (c *Core) chainForRules(rules []config.ModelScope, imageOnly bool) *schedul
model = exact
}
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
if !imageOnly {
continue // image-kind models never join the chat AUTO chain
}
} else if imageOnly {
continue
continue // image-kind models never join the chat AUTO chain
}
if source == "" {
source = p.Name()
@ -767,7 +513,7 @@ func (c *Core) chainForRules(rules []config.ModelScope, imageOnly bool) *schedul
Hours: e.Hours,
})
}
return scheduler.BuildChain(sr, prov)
c.autoChain.Store(scheduler.BuildChain(sr, prov))
}
// buildAutoImageChain rebuilds the image-generation AUTO chain snapshot.
@ -884,14 +630,6 @@ 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
}
@ -903,13 +641,6 @@ 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)
@ -924,7 +655,7 @@ func (c *Core) AddSource(src config.Source) error {
if err := normalizeSource(&src); err != nil {
return err
}
if err := config.UpsertSourceInYAML(c.cfg.Path, src.Name, src, c.cfg.SecretBox()); err != nil {
if err := config.UpsertSourceInYAML(c.cfg.Path, src.Name, src); err != nil {
return err
}
// Update in-memory Sources so mergedSources() finds the entry.
@ -1021,88 +752,7 @@ func normalizeSource(s *config.Source) error {
// Close releases resources.
func (c *Core) Close() {
// Plugin state must be flushed BEFORE the VM stops. The saver's final write
// reads each plugin's Lua tables; once vm.Stop() has closed those states the
// read finds nothing and the last interval of accumulation is lost — which
// is the exact failure this persistence was added to prevent.
if c.plugins != nil {
c.plugins.Close()
}
if c.vm != nil {
c.vm.Stop()
}
}
// applyBillingDSL compiles the configured billing profile and injects it into
// the billing plugin.
//
// Configured pricing replaces the hand-written JSON blob that used to be PUT
// through the state API. That path was used twice in production and both times
// it "succeeded" while being wrong: once without the required `prices` wrapper
// (silently wiping the accumulated totals), once with a peak-window shape the
// plugin does not read (peak traffic billed at off-peak rates, no error). A
// typed config with load-time validation turns both mistakes into startup
// errors naming the file and field.
//
// A DSL with no profile, or one that fails to compile, leaves the plugin's
// prices untouched: billing keeps running on whatever it had, and the problem
// is logged rather than becoming a startup failure — the gateway must forward
// even when its cost accounting is misconfigured.
func (c *Core) applyBillingDSL() {
ps := c.plugins
if ps == nil || ps.Count() == 0 {
return
}
// The billing plugin may simply not be installed; a DSL without it is a
// no-op, not an error.
var have bool
for _, row := range ps.List() {
if row["name"] == "billing" && row["loaded"] == true {
have = true
break
}
}
if !have {
return
}
dsl := c.cfg.BillingDSL
if dsl == nil || len(dsl.Profiles) == 0 {
return
}
if err := dsl.Validate(); err != nil {
log.Printf("[billing] config invalid, profile not applied: %v", err)
return
}
profile := dsl.Resolve(dsl.Active)
if profile == nil {
log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active)
return
}
prices, err := billing.Compile(profile, c.cfg.Sources)
if err != nil {
log.Printf("[billing] profile %q not applied: %v", profile.ID, err)
return
}
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
log.Printf("[billing] applying profile %q: %v", profile.ID, err)
return
}
log.Printf("[billing] profile %q applied (%d rules, %d sources)", profile.ID, len(profile.Rules), len(c.cfg.Sources))
}
// ValidateScopeQuotas checks every scope entry's caps before they are stored.
// A typo in a period must be rejected at write time rather than silently
// becoming a never-resetting budget — the opposite of what was typed.
func ValidateScopeQuotas(entries []config.ModelScope) error {
for _, e := range entries {
if err := (config.KeyQuota{
TokenQuota: e.TokenQuota,
ReqQuota: e.ReqQuota,
Period: e.Period,
Hours: e.Hours,
}).Validate(); err != nil {
return fmt.Errorf("model %q: %w", e.Model, err)
}
}
return nil
}

View File

@ -1,180 +0,0 @@
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)
}

View File

@ -1,73 +0,0 @@
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)
}
})
}
}

View File

@ -1,401 +0,0 @@
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,
// rpm must be exposed too: it carries the per-model minimum gap, and
// max_concurrent alone cannot distinguish "serial WITH a gap" from
// "serial with no gap" — the latter still collides with the
// upstream's per-model concurrency window.
"rpm": s.RPM,
})
}
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
}

View File

@ -1,373 +0,0 @@
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()
pluginDir := filepath.Join(dir, "plugins")
// 插件的自有存储位置由插件自己决定(读 config.yaml 的 plugin_dir,或
// LLMSPROXY_PLUGIN_DIR 覆盖)。测试**必须**指向临时目录 —— 否则插件会读到
// /etc/llmsproxy/config.yaml 并写进**生产插件目录**(真实事故:一次全量测试
// 把 26 个测试账号写进了生产 .channels-store.json)。
// 调用方已显式设定时尊重它:多次「重启」要共用同一目录才能测持久化。
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
}
// ★ 显示器号与端口用**高段号**,避开生产。
//
// 真实事故(2026-10-15):生产 unit 带 `PrivateTmp=yes`,它起的 Xvfb :91 的
// 锁文件在**服务自己的 /tmp** 里 —— 宿主上的测试看不到它,于是判"91 空闲"
// 并去抢,结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`,而生产上正跑着
// 一套浏览器栈。判据本身没错,错的是**两个 /tmp 互不可见**而号段重叠。
// 故测试整体挪到 300+。
if os.Getenv("LLMSPROXY_XDISPLAY_BASE") == "" {
t.Setenv("LLMSPROXY_XDISPLAY_BASE", "300")
}
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
PluginDir: pluginDir,
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)
}
// 测试结束**必须**把插件拉起的服务端浏览器栈收掉。
// 为何必需:callback 流(trae/gemini/有道)在 start_login 时就起一整套
// Xvfb+chromium+x11vnc+websockify,而绝大多数测试不会显式 stop_browser、
// 会话 TTL 也还没到 —— 于是一轮 core 全包跑完就攒出 7 套常驻进程
// (实测 websockify×7 + x11vnc×7 + 7 个 profile 目录),把显示器号与端口
// 段逐步耗光,后续测试报「chromium/websockify 端口未就绪」。
// 在这一处统一收掉,比让每个测试各自记得更可靠。
t.Cleanup(func() {
ps := c.Plugins()
if ps == nil {
return
}
for _, name := range []string{"channels"} {
_, _ = ps.InvokeHandler(name, "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
})
}
})
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
}()
}

View File

@ -1,39 +0,0 @@
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 是空转")
}
}

View File

@ -1,69 +0,0 @@
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)
}
})
}
}

View File

@ -1,189 +0,0 @@
package core
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"llmsproxy/internal/config"
)
// AutoChainFor sits on the request hot path and reads keyAutoChains with no
// lock, while SaveKeyAuto / rebuildRegistry replace that map from the admin
// path. A "call it twice" test never interleaves those, so it would pass even
// if the map were mutated in place. These tests run both sides at once under
// -race and assert what readers actually observe.
//
// The property: a reader sees a chain that is either the key's own or the
// global one — never a torn or empty one. If rebuildKeyAutoChains built the
// map in place instead of swapping a fresh pointer, readers would sometimes
// see a partially-filled map and silently fall back to the global chain, i.e.
// a user quietly losing their configured chain under load.
// concurrentChainConfig builds N keys, half with their own chain pointing at a
// distinct model so a fallback to the global chain is distinguishable.
func concurrentChainConfig(t *testing.T, n int) *config.Config {
t.Helper()
cfg := perKeyTestConfig(t)
cfg.Keys = nil
cfg.GatewayKeys = []string{"sk-gw-admin"}
cfg.Auto = []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}}
for i := 0; i < n; i++ {
k := config.GWKey{
Key: fmt.Sprintf("sk-gw-k%03d", i),
Role: "user",
Name: fmt.Sprintf("user%d", i),
}
if i%2 == 0 {
k.Auto = []config.ModelScope{{Model: "gpt-4o-mini", Source: "s1", Tier: 1}}
}
cfg.Keys = append(cfg.Keys, k)
}
return cfg
}
func TestAutoChainForConcurrentWithSaveKeyAuto(t *testing.T) {
cfg := concurrentChainConfig(t, 40)
c := newTestCore(t, cfg)
var stop atomic.Bool
var wg sync.WaitGroup
var bad atomic.Int64
for r := 0; r < 8; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for !stop.Load() {
for i := 0; i < 40; i++ {
key := fmt.Sprintf("sk-gw-k%03d", i)
ch, ok := c.AutoChainFor(key)
if !ok || ch == nil || len(ch.Tiers) == 0 {
bad.Add(1)
continue
}
// Legal values are this key's own chain model (either
// side of the writer's flip) or the global chain's. The
// assertion is about the legal set, not a fixed value:
// the writers legitimately change the chain underneath.
got := ch.Tiers[0].Slots[0].Model
if got != "gpt-4o" && got != "gpt-4o-mini" {
bad.Add(1)
}
}
}
}()
}
for w := 0; w < 2; w++ {
wg.Add(1)
go func() {
defer wg.Done()
model := "gpt-4o-mini"
for i := 0; i < 25 && !stop.Load(); i++ {
for j := 0; j < 40; j += 2 {
if err := c.SaveKeyAuto(fmt.Sprintf("sk-gw-k%03d", j), []config.ModelScope{
{Model: model, Source: "s1", Tier: 1},
}); err != nil {
bad.Add(1)
return
}
}
if model == "gpt-4o-mini" {
model = "gpt-4o"
} else {
model = "gpt-4o-mini"
}
}
}()
}
time.Sleep(400 * time.Millisecond)
stop.Store(true)
wg.Wait()
if n := bad.Load(); n > 0 {
t.Fatalf("%d reads saw a chain outside {own, global} — the cache is "+
"being swapped in place instead of atomically", n)
}
}
// The registry rebuild path (AddSource / RemoveSource) also replaces the
// cache, so readers must keep a usable chain throughout.
//
// Churned sources use a "churn" prefix on purpose: deleting a source a chain
// references legitimately empties that chain (BuildChain drops unresolvable
// slots), and asserting otherwise would be testing a fiction rather than the
// code.
func TestAutoChainForConcurrentWithSourceChanges(t *testing.T) {
cfg := concurrentChainConfig(t, 20)
c := newTestCore(t, cfg)
var stop atomic.Bool
var wg sync.WaitGroup
var bad atomic.Int64
for r := 0; r < 6; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for !stop.Load() {
for i := 0; i < 20; i++ {
ch, ok := c.AutoChainFor(fmt.Sprintf("sk-gw-k%03d", i))
if !ok || ch == nil || len(ch.Tiers) == 0 {
bad.Add(1)
continue
}
if got := ch.Tiers[0].Slots[0].Model; got != "gpt-4o" && got != "gpt-4o-mini" {
bad.Add(1)
}
}
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < 30 && !stop.Load(); n++ {
name := fmt.Sprintf("churn%d", n)
_ = c.AddSource(config.Source{
Name: name, BaseURL: "http://127.0.0.1:9/v1", Adapter: "openai",
Models: []config.Model{{ID: "m-" + name, Priority: 10}}},
)
_ = c.RemoveSource(name)
}
}()
time.Sleep(500 * time.Millisecond)
stop.Store(true)
wg.Wait()
if n := bad.Load(); n > 0 {
t.Fatalf("%d reads lost the chain while unrelated sources were churned", n)
}
}
// Many distinct keys: the lookup must stay a map hit and must not degrade into
// returning the global chain as the key count grows.
func TestAutoChainForManyKeys(t *testing.T) {
cfg := concurrentChainConfig(t, 500)
c := newTestCore(t, cfg)
for i := 0; i < 500; i++ {
key := fmt.Sprintf("sk-gw-k%03d", i)
ch, _ := c.AutoChainFor(key)
if ch == nil || len(ch.Tiers) == 0 {
t.Fatalf("key %s lost its chain with 500 keys configured", key)
}
want := "gpt-4o"
if i%2 == 0 {
want = "gpt-4o-mini"
}
if got := ch.Tiers[0].Slots[0].Model; got != want {
t.Fatalf("key %s got %q, want %q", key, got, want)
}
}
}

Some files were not shown because too many files have changed in this diff Show More