Compare commits

..

17 Commits

Author SHA1 Message Date
b75b64a39a docs: 说明归档分支的用途与功能落点
冻结 v1.9 之前的一段插件重构历史。16 个提交未以等价形式进入主线,其中
3 个是远端完全没有的(ccquota 的月度额度 UI 与其生命周期内的修复,
随 ccquota 被通用 quota 插件取代而未进主线)。

明确标注不要从本分支继续开发,并给出每项能力现在的落点。
2026-10-05 08:33:11 +08:00
120ba9e957 feat(plugins): 访问控制在插件页配置 + 修复 active 仪表在周期视图显示 0
## 插件管理页(用户要求:权限不要只放 config.yaml)

插件页新增两列(可见性、端点)和一块访问控制编辑器,可直接改:
- 每个插件的整体可见性(跟随插件 / 仅 admin / 仅 user / admin+user / 所有人)
- 每个注册端点的可访问角色与密钥(admin, user, @homeagent, public)

原先这些能力后端齐全但 WebUI 零入口,唯一改法是手改 config.yaml。

## 修掉的三个真缺陷

1. endpointAccess 读错了 override 的键
   SetEndpointAccess 按 plugin#path 存,endpointAccess 却读 plugin 级。
   后果:改了权限后 UI 仍显示插件声明的角色,再保存一次就把限制悄悄撤销。
   鉴权一直是对的,只有显示和往返是坏的 —— 所以之前没被发现。

2. override 只在内存里
   内存 map 让"billing 仅 admin"看着生效,直到下次重启限制凭空消失,
   日志里没有任何线索。现在落盘到 plugin_dir/.access.json,
   插件升级覆盖 .state.json 也不会连带放宽访问。删掉最后一个 override 就删文件,
   "没有覆盖"用文件不存在来诚实表达。

3. 可见性列表泄漏内部键
   端点 override 与插件可见性共用一个 map,UI 因此会看到 "probe#hello"
   并可能把它当 audience 存回去。现在按已注册端点集合过滤。

另外把整体可见性接到同一条 /access 通路(path 用 *),并修了可见性下拉的
集合比较:服务端原样存 "admin, user",而选项是 "user,admin",不相等就会
显示"跟随插件",用户一保存就把访问范围放宽回插件声明 —— 比显示错更糟。

## 活跃请求:三个视图显示 0

active 是实时仪表,没有日历周期可过滤,但周期响应压根没带这个字段。
前端 `st.active || 0` 于是把"字段缺失"变成了一句自信的"0 活跃",
而 all 视图(响应里有该字段)显示真值 —— 三个视图 0、总视图正常。
现在周期响应带上 active,前端去掉 || 0 回退:缺失显示 — 而不是假装是 0。

判据:四个 period 都断言 active 存在且为数字;变异去掉字段即变红。
生产实测 day/week/month/all 均为 4。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 11:49:47 +08:00
c17a851ec1 fix(plugins): quota 页面加载时不再自动探测全部上游
首屏 19.1 秒 → 1.0 秒(生产实测)。

症状是"页面出来了但数据卡在加载"。量出来的原因不是渲染,也不是后端:
所有接口都在 0.4–2ms,而 Network 事件显示页面在头 70 毫秒内并发发出了 21 个
/api/plugins/quota/call/collect_source —— 每个源一次。CPU 采样 98% 空闲,说明
主线程不是在算,是在等。

插件页面的脚本里有 mount(); refresh();,而 refresh 会为每个源发一次探测。
探测要出网,且每次调用都持插件写锁,于是 21 次串行排队;冷启动实测 100 秒,
有缓存后 22 秒。WebUI 的首屏就这么被堵住了。

改动:
- 页面加载时不再自动探测。运维没要求的余额不值得用它换首屏。
- 改为先渲染上次的快照(pluginAPI.fetchState),标题栏标注"快照 HH:MM(点刷新
  取实时)"。点刷新才真探测。这样打开页面立刻有内容,实时数字在后面更新。
- billing 页面同样有 refresh(),但它只读本机 state(1.5ms),无害,保留。

判据:首屏填充时间实测 19.1s → 1.0s,且首屏请求里不再出现 collect_source。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 11:11:16 +08:00
a6c6e6e5c7 feat(plugins): 可见性、state 分区、注册式 HTTP 端点 + 按密钥授权
插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额,
而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤,
所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了
18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自
成本。

## 可见性:插件声明默认值,网关可覆盖

UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或
"all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**:
一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。

覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维
也不该为了改一行去编辑内置 .lua(下次升级就没了)。

过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一
处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。

## state 分区:让"看自己的账单"成为可能

角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、
未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用
state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey:
桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。

不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。
billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让
split 失去意义,而前者不加 split 正是这次要堵的泄露。

## 注册式 HTTP 端点:Go 监听,Lua 处理

有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的
另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条
路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。

插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。
请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质:
status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的
URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。

## 按密钥名授权

roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥
值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且
运维读的是名字。

**keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单
就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写
错误会让唯一能诊断它的人也进不去。

写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里
显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。

## 这个文件里第三次踩同一个坑

readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用
luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各
不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走
luaTableToGoValue。

readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组
永远拿到 nil。

## 变异与判据

去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page");
去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧
归属、admin 豁免、{status=204} 合法、空表报错。

生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 10:51:07 +08:00
098a4a5eb4 feat(plugins): quota 通用套餐余量插件(取代 ccquota)
ccquota asked one vendor one question. The shape that generalizes is two parts:
a small set of probe adapters, and a declarative override for a provider nobody
anticipated.

Three adapters, each verified against a LIVE endpoint rather than assumed:

  - deepseek family: GET /user/balance →
    {is_available, balance_infos:[{currency,total,granted,topped_up}]}.
    Granted vs topped-up is kept separate on purpose: gift credit expiring on a
    schedule is a different operational fact from prepaid money.
  - OpenAI console shape: /v1/dashboard/billing/subscription +
    .../billing/usage. This reports LIMITS and SPEND, not a balance — the live
    provider has hard_limit_usd 1e8. There is deliberately no `remaining`:
    ceiling - spend would read as "$99,999,339 left", a number nobody should
    believe. It renders as spend against a ceiling instead.
  - commandcode: rolling 5h / weekly / monthly windows plus billing period.

A provider with no working adapter is SKIPPED and listed by name at the bottom.
Most of these upstreams are small re-sells whose dashboard paths return an HTML
page rather than JSON; a table full of "unknown" rows teaches the operator to
ignore the page entirely. Failures that are NOT "no endpoint" — a 403 on a key we
hold — stay visible, because those are the ones worth fixing.

## Cost control, and a lock the probe runs under

First load measured 101s for 21 sources: 3 adapters x 2 attempts x 4s, times 21.
Call() holds the plugin's write lock for the whole invocation, so every
__llmsproxy_http round trip inside it blocks request_end hooks — which is how
billing accrues money. A slow upstream is not just a slow card.

  - Timeout cut to 4s (from 10s).
  - A host->adapter memo, persisted in plugin.state, so the dead ends are
    discovered once per process instead of on every page open. Steady state is
    22s.
  - The memo records NEGATIVE results too, which is what actually paid: only
    recording successes left every silent source re-probing forever.
  - Negatives carry a timestamp and expire after 30 minutes. "This upstream has
    no balance endpoint" is a fact that changes — vendors ship dashboards — and a
    permanent negative makes the page quietly stop reporting a source that
    started working.
  - The page calls collect_source concurrently, one call per source, so a single
    502 over 21 sequential probes becomes 21 independent answers.

## Two bugs the page found

- deepseek reports topped_up_balance -0.02 against a positive grant; the `v <= 0
  → "0.00"` branch flattened a real debit to zero. Negative balances now render
  as -¥0.02.
- PLAN_CAPS was declared below the adapter that reads it, so Command Code hit
  "attempt to index global 'PLAN_CAPS' (a nil value)" on every probe. A Lua local
  is nil until its assignment runs.

Verified on production: 5 sources report, 16 skipped by name, 1 visible failure
(tabitoken 403). deepseek ¥0.32 (赠送 0.34 · 充值 -0.02), justwoker $360,865.71
spent against an ∞ ceiling, both Command Code seats with their three windows.

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 09:36:26 +08:00
6bc3ff8bbd feat(plugins): ccquota 显示每月额度
The page showed a monthly BALANCE ($0.08 left) but no monthly ALLOWANCE, so
"how much do I get and how much of it is gone" had no answer — the most useful
number on a plan that is nearly exhausted.

The vendor's credits endpoint reports what is left and never the cap, so a
percentage is not derivable from it. Two things supply the missing half:

  - planId from subscriptions (here individual-goat), mapped to the caps the
    vendor publishes ($10 Go, $70 GOAT/Pro/Max).
  - totalMonthlyCredits from usage/summary, which counts over the BILLING PERIOD
    and so pairs with the monthly allowance rather than with the rolling 5h and
    weekly windows.

Because the cap is a table lookup and not an API fact, it is cross-checked
against monthly_used + balance: if the two disagree by more than 2% + $0.50,
the card says the figure may be off rather than confidently printing a wrong
percentage. A planId absent from the table renders the balance with no bar at
all — inventing a denominator would be worse than admitting there isn't one.

Also surfaces what the endpoints were already returning and the page was
dropping: period_end (with a countdown), cancel_at_period_end, plan name, and
the vendor's own belowThreshold flag, which is distinct from "balance > 0" —
an account can hold $3 and still be under their threshold.

Verified on production: both accounts show 每月 100% ($69.94/$70, red bar),
5 小时 0/14, 每周 0.803/35 on one; plan goat, 周期至 2026-10-17 / 2026-10-19.

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 08:30:36 +08:00
b5f6cac0ef fix(plugins): ccquota 页面首次打开是空的
The plugin page rendered nothing until the operator clicked refresh, and even
the click handler was never wired — while every unit test was green and the
API returned correct numbers. Four separate mistakes stacked here:

1. The script waited on DOMContentLoaded, an event that had already been
   dispatched by the time the kernel injects a plugin mount. Replaced with
   pluginAPI.onTabShown (the kernel's own mechanism, fires with the DOM in
   place) PLUS an immediate call, because onTabShown alone leaves the page
   blank when the tab is already open at boot. The billing page does both; I had
   reduced it to one and broke first paint.

2. The re-entry guard flag lived in a closure variable. The tab's DOM is rebuilt
   on re-entry, so the handler was wired against a node that no longer existed.
   The flag now lives on the NODE (__ccqWired), which is what makes re-wiring
   correct.

3. refresh() swallowed its fetch failure and rendered "no sources", so a broken
   page and an empty account list looked identical. Failures now surface in the
   header line, including when pluginAPI.call is missing (an old kernel).

4. THE ACTUAL REASON the page stayed blank: there were TWO ccquota plugins. The
   embed (go:embed plugins/*.lua) version and the on-disk one both loaded, and
   the UI injected the EMBED copy — 5182 characters, predating every fix — while
   every edit I made and every hot reload went to the disk copy. The running
   binary was serving stale plugin code no amount of reloading could fix. Only a
   rebuild surfaced it, and only measuring the served <script> length (7053 vs
   5182) showed which copy the browser had.

Lesson worth keeping: "the file on disk is what runs" is false for anything
under go:embed. Compare what the SERVER sends before editing the same file
twice and concluding the edit did nothing.

Verified in the browser: 2 account cards, 4 window bars, real balances, no
overflow, no console errors, refresh button 48x29 and functional.

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 08:23:55 +08:00
111dc6d53f feat(plugins): ccquota 多账户额度插件 + 页面→Lua 调用通道
A quota plugin cannot work on billing's model. Billing reports what the gateway
already measured, delivered on hooks during request forwarding. A plan balance
has no such arrival path: it exists only on the vendor's side, and the only way
to learn it is to ask — at the moment the operator clicks Refresh. No hook fires
on a click, and pluginAPI offered only state read/write.

So this adds the missing piece: POST /api/plugins/{name}/call/{fn}, plus
pluginAPI.call on the page side.

## Why the gate is an explicit export list

That endpoint is an HTTP surface into a Lua state. Without a declared allowlist
it would let any caller invoke the plugin's private helpers with crafted
arguments and read whatever it can see — for a plugin holding an API key, that
is handing the key to anyone who can POST. So a function is callable only if the
plugin listed it in plugin.exports. A plugin with no exports has no callable
surface; reading the missing list as "allow all" is exactly the failure mode
worth designing against. The endpoint is also admin-only.

## ccquota

One account per configured source on api.commandcode.ai, keyed by that source's
own key — two keys on one host are two seats, and both are shown. Reads
/alpha/whoami, /alpha/billing/credits, /alpha/billing/subscriptions and
/alpha/usage/summary. Each endpoint degrades independently: a vendor retiring
one route must not blank the others, and a key out of quota still reads credits
fine while inference 401s, so the report records WHICH part failed rather than
rendering a zero that reads as "no quota left".

The 401-is-everything trap: those endpoints need a bearer token plus three
headers. The token lives in the table now, per request. Passing the shared
header table straight through — my first version — yields 401 on every
endpoint, which is indistinguishable from a revoked key and sent me auditing the
account instead of the plugin.

## Also fixed: three real defects found by going live

- The bridge was registered as `httpGlobal` (the Go const's IDENTIFIER) instead
  of `__llmsproxy_http` (its VALUE). Green unit tests, "attempt to call global
  '__llmsproxy_http' (a nil value)" on first real request. Now a test boots a
  real plugin and calls it the way a plugin does.
- The call router kept `call/` in the function name, so every call 502'd with
  `does not export "call/collect_account"` while the exports list looked fine.
- readPluginExports used luaToJSON, which needs the `json` global — not yet
  installed while a plugin is registering — so it silently returned an empty
  list and every export looked undeclared. It uses luaTableToGoValue now.

Two tests hardcoded counts ("1 bundled plugin", "4 pages") and turned red the
moment a second bundled plugin existed. They now assert what they mean: billing
is among the bundled plugins, and the pages this test contributed all merged in
order. A count assertion is a changelog detector.

## Verified on production

  commandcode    2198972886@qq.com  active  $0.0811  5h 0/14  wk 0.803/35  44,921 req
  commmandcode2  jianf3112@gmail.com active  $0.0700  5h 0/14  wk 0/35      43,827 req

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 08:10:11 +08:00
0fbb8e5e88 feat(plugins): 给 Lua 插件加受限出站 HTTP(__llmsproxy_http)
billing 插件从不主动出站——它报告的一切都随网关转发请求时的 hook payload
一起到达。额度插件是第一个必须自己发请求的东西:套餐余量不主动问就拿不到。

问题在于这恰好是插件模型最锋利的边。插件是 WebUI 文本框里装进来的单个
.lua 文件,跑在网关进程里:给它一个通用 HTTP 客户端,等于让每次安装都成为
一件常备的 SSFP 工具,还顺带打开了读环境变量拿凭据的第二扇门。

所以这个桥刻意做窄:

- 目标必须命中某个「已配置源」已经在通信的主机。那些是运维声明过的,
  不是插件声明的——插件无法靠编一个 URL 扩大自己的触及面,而源的主机
  正是本部署已经愿意交付 API key 的那批上游。
- 非 loopback 主机上拒绝明文 http。凭据只该走 HTTPS。
- loopback 免登记:它出不了本机,而 billing 规则页读网关自己的 API 正是
  这么做的。
- 每请求都有超时、响应体有 4MiB 上限,插件无法占死 worker 或吃光内存。
- 绝不从环境变量读凭据。需要 key 的插件必须被显式给(state 或调用方配置),
  这样授权对运维可见,而不是环境里默默存在的东西。
- 不跟随重定向:白名单是逐跳检查的,跟随就等于绕过它,3xx 原样返回。

能力只装在插件运行时(bootPluginState),不装进 setupGlobals——后者被
adapter 池共用,adapter 是运维的配置而非安装的代码。

判据 8 条钉的是拒绝路径而非快乐路径:未登记主机(含 169.254.169.254、
后缀欺骗 api.example.com.evil.test)、明文 http、空名单必须全拒、不得跟随
重定向、超时与响应体被钳制、超大响应宁可报错也不截断(截断会解析成损坏
JSON,插件会报解析错而不是真因)。

测试当场抓到一个真 bug:loopback 被白名单一起拦了,与代码注释里「loopback
免登记」自相矛盾。变异(把主机检查换成 if false)被 3 条判据抓住。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 07:43:56 +08:00
34fcc0b40f fix(ui): 优先级页的保存/重置/添加按钮每次打开都被画布渲染冲掉
renderSortEditor 会整体替换 mount 的 innerHTML。per-key AUTO 弹窗的 mount
只有画布,覆写无害;但全局优先级页把 .card(连同里面装着工具栏的 h2)当
mount 传了进去,于是每次打开页面,画布一渲染就把工具栏删了。

症状很隐蔽:泳道和积木照常渲染、拖拽也正常,只是「保存排序」「重置」
「+ 添加档位」和聊天/生图切换全部消失 —— 优先级变得无从调整,也无从保存。
DOM 里数画布元素一切正常,只有量按钮的几何尺寸才看得见它没了。

修法:工具栏留在 .card 里,另给一个 #sort-mount 专供画布。per-key 弹窗
本来就是这个结构,无需改动。

判据 1 条,断言工具栏必须落在 #sort-mount 之外,且 renderSortEditor 必须
接收 #sort-mount 而不是 .card。变异(mount 改回 .card)判红。

生产 CDP 实测(真实点击,非仅 DOM 断言):
  5 个按钮全部可见,坐标尺寸正常(保存排序 72x29)
  画布 4 泳道 / 10 块
  点击「保存排序」→ toast「排序已保存并热重载 · 链上冷却已复位」
  切走再回来,按钮仍在位

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 07:05:11 +08:00
7b201cbea3 fix(gateway): AUTO 范围冲突的提示要放在 checkQuota,否则是死代码
上一版把消息加在请求路径的范围检查上,实测才发现它永远走不到:
AUTO 请求先经过 checkQuota(ctx, "AUTO")(在链被读取之前),范围不含 AUTO
时它当场返回旧文案「model \"AUTO\" is not allowed for this key」——那句会
把运维引去查配额,而配额并不是原因。

改为在 checkQuota 内对 AUTO 分流,输出说明真实原因与修法。请求路径那处
换成注释说明为何不再重复。

判据 1 条,断言 checkQuota 函数体内必须同时出现 isAuto(model) 与新文案
——放在别处不算数。变异(删掉该分支)导致编译失败,被捕获。

生产实测(临时把某密钥范围改成不含 AUTO 再恢复):
  HTTP 403  model_not_allowed
  model \"AUTO\" is not in this key's model scope, so it cannot use AUTO;
  add \"AUTO\" to the key's models or remove the scope restriction

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 22:53:27 +08:00
d71d235a8f fix(gateway): AUTO 被密钥模型范围拦下时报错误导 + 编辑器给出提示
一个密钥的模型范围不含 AUTO 时,它用不了 AUTO —— 范围过滤发生在链之前。
但这两条路径都不说真话:

1. 请求侧:AUTO 走的是通用分支,返回 model_not_found "model \"AUTO\" is
   not configured",读起来像 AUTO 没配置。非 AUTO 模型早就有 model_not_allowed
   的专门提示,AUTO 漏了。补上,并说明修法(把 AUTO 加进该密钥的 models,
   或去掉范围限制)。

2. 编辑器侧:per-key AUTO 链编辑器可以正常配链、保存也成功,看起来一切正常,
   但该密钥的每个请求都会 403。管理员无从得知。现在弹窗顶部在检测到冲突时
   显示警告,并列出当前范围。

刻意不做的事:不自动把 AUTO 加进该密钥的模型范围。那等于悄悄授予运营
没要求的访问权,比一个显眼的警告更糟。

判据 1 条,除确认提示出现外还断言该函数体内没有 PUT/POST/fetch/api ——
它只报告,不得写回。变异(去掉 AUTO 判断)判红。

CDP 实测四种场景:范围含 AUTO → 无提示;范围不含 → 警告并列出范围;
空范围(不受限)→ 无提示;范围含 AUTO → 无提示。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 21:16:56 +08:00
59802d7697 feat(ui): per-key AUTO 链默认复制全局链作为编辑起点
此前给没有独立链的密钥打开编辑器是空白画布,管理员要从 226 个模型里
一条条重新挑。真实任务是"这个用户拿全局链,但去掉他不该碰的两个源",
所以起点应该是全局链本身。

- 无独立链时读 /api/auto 的 rules 预填(实测 4 泳道 / 10 块,与全局链
  一致),不是 discovery 兜底(那会把每个源的每个模型都堆进来)。
- 提示区分三种状态,因为它们在画布上长得一样、点取消的后果完全不同:
  复制来的起点 / 该密钥已有的独立链 / 清空后保存将恢复跟随全局。
- seeded 标记随关闭清理,避免下次的提示说错来源。

CDP 实测:打开无独立链的密钥 → 画布 4 泳道 10 块 + 提示"已复制全局链
作为编辑起点";删一个块服务端仍 inherits:true;保存后 inherits:false
n=9、弹窗关闭;重开读回 4 泳道 9 块且提示消失(它现在有自己的链了)。
横向滚动可达性一并验证:scrollLeft 172 = scrollWidth-clientWidth,
滚到底后无残留不可达块。

判据 2 条 + 变异(恢复空白起点 / 去掉 seeded 标记均判红)。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 13:09:45 +08:00
ac58f9485b fix(ui): 统计区间文案在日视图恒为空、天数按小时桶误报
v1.9.0 加的「统计区间:起 → 止 (Nd)」正是用来解释"周 > 月"的,但它自己
在日视图上从来不出现在屏幕上。

原因:区间终点从最后一个 bucket 的标签拼出来,而 bucket 粒度随视图变化
—— 日视图按小时("2026-10-03T09"),周/月按天("2026-10-01")。代码无条件
拼 "T00:00:00Z",日视图就成了 "2026-10-03T09T00:00:00Z",Date 拒绝该值,
toISOString() 抛 RangeError,整个赋值语句丢失。页面无任何报错,只是那行
字不见了——恰好是用户最常看的"今日"视图。

第二个问题同源:天数直接用了 st.buckets.length。日视图 5 个桶是 5 个小时,
界面却显示 "(5d)"。改为按窗口起止算天数。

CDP 实测(修复后):
  day   统计区间: 2026-10-03 → 2026-10-03 (1d)   ← 原为空,且曾误报 5d
  week  统计区间: 2026-09-28 → 2026-10-03 (6d)
  month 统计区间: 2026-10-01 → 2026-10-03 (3d)
  all  (空,正确:终身累计没有窗口)

判据一条 + 变异:恢复原来的盲目拼接 → 判红。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 12:48:17 +08:00
0938a45ced fix(ui): per-key 弹窗复用共享弹窗ID、编辑即落盘、空画布显示226个模型
截图看渲染 + 交互实测后发现的四个问题,都出在我自己新写的代码里。

一、弹窗用了全站共享的 #modal-wrap。
这个 id 是 App 内所有弹窗共用的,且允许多个同时存在("添加档位"选择器就
开在本弹窗之上)。既有代码靠 .closest() 或 closeTopModal() 从按钮往上找,
是有效的——但本弹窗还要从画布侧被查(sortCanvasEl / keyAutoClose /
keyAutoInherit),那里没有按钮可走。实测点"添加档位"后 DOM 里同时有
modal-wrap=1 和 key-auto-modal=1,getElementById 会命中文档里的第一个,
也就是用户没在看的那一个:取消关掉错误的框,画布也可能解析到选择器上。
改为独立 id #key-auto-modal。

二、编辑即落盘,保存/取消形同虚设。
scrAddFromForm / sortScopeSave / scrDelSlot 等 6 处编辑后立刻调
persistAuto()。全局页这样是有意的(改动立即生效),但它和自己页面上那句
"点击保存排序后生效"的提示自相矛盾;在 per-key 弹窗里更严重——加一个槽位
就已经写进那个用户的链了,取消也无法撤销。收敛到 afterChainEdit():
有 scope 时只提示、不落盘,保存是唯一写入口。

三、无独立链时画布显示全部 226 个模型。
注释写的是"显示空画布",实现却是 buildLanes(idx, [], 'chat')——空 rules
会走 discovery 兜底,把每个源的模型全填进去。用户打开弹窗看到 226 个色块,
会以为这些就是这个密钥的链。改为空 rules 直接给 []。

四、"使用全局链"提示不随画布变化。
加完槽位后仍显示"该密钥使用全局 AUTO 链",与旁边的色块直接矛盾。改为由
afterChainEdit 触发、按画布是否有内容决定去留。

另:批量替换时正则的缩进假设错了一次,把 afterChainEdit 自己改成了自调用
(无限递归),node --check 抓到后修掉。这类批量替换必须先过语法检查。

判据两条 + 变异:弹窗回到共享 id → 判红;去掉 scope 分支 → 判红。
CDP 实测:弹窗 id 唯一、添加后画布 0→1 块、添加后服务端 inherits=true
(未落盘)、取消后仍 inherits=true、选择器与弹窗并存互不干扰。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 11:08:03 +08:00
57abc25bb9 fix(stats): 日/周/月视图请求记录表空白,滚动加载顺序错乱
首页记录表由 paintRecords(st.records) 渲染,而 /api/stats 的 period 分支
手写了一份响应 map,压根没有 records 字段 —— 只有 all 分支走 Snapshot()
才带得上。实测切到日/周/月,记录表恒为空。

两处数据源也不一致:

| 端点 | 数据源 | 排序 |
|---|---|---|
| /api/stats(all)| 内存ring(几百条)| oldest-first |
| /api/stats/records(翻页)| 审计文件(全部)| newest-first |
| /api/stats(period)| 无 | 无 |

前端靠 paintRecords 里一句 .reverse() 把 all 的 oldest-first 转成 newest-first,
翻页端点则本来就是 newest-first。首屏和翻页方向相反,滚动时新旧行会错位
拼接;且 all 分支不带 next_cursor,表永远停在第一屏。

修法:两个分支都改用 AuditPage(与翻页端点同一份代码路径),记录方向统一
由它决定,前端去掉 .reverse()。附带好处:记录深度不再受 maxRecs 限制,
"全部"视图真的能翻到底。

判据三条,变异验证:period 分支返回 nil records → 前两条判红;all 分支不
覆盖 records → "no next_cursor"判红。变异过程中我自己也犯了两次错:第一版
判据硬编码 newest-first,而AuditPage 在单文件(整块读完)与多文件(分块反向
读)下方向不同,改为断言两处方向一致;第三版想测 admin 过滤,但
newTestGateway 的 sk-test 不是 admin,改为测真正的越权边界(非admin 不能
用 ?key= 看别人的记录)。

CDP 实测:四个视图各 100 行、时间从新到旧,滚动加载 100→200 行且新末行
时间更旧(顺序衔接正确)。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 09:50:38 +08:00
95ac9db67a fix(ui): per-key 编辑器画布画错容器、保存成功却不关弹窗
CDP 实测发现两个真 bug,都是复用画布时引入的。

一、画布画进了后台标签页。sortCanvasEl() 原本按文档顺序找,先命中
#scr-canvas(全局优先级页的画布)。而弹窗是挂在设置页之上的,两个画布
同时存在于 DOM —— 于是 per-key 的 36 条泳道被画进隐藏的全局画布,弹窗
里空空如也,回���全局页还会发现自己的链被覆盖了。

改为按 sortState.scope 解析:有 scope 就用 #key-auto-canvas,否则用
#scr-canvas。

二、保存成功却弹窗不关。!j.slots 分支里我写了 return,跳过了
keyAutoClose。写确实成功了(服务端已存),但用户看到弹窗还开着、按钮
还是禁用状态,会反复点保存。而且那条 toast 文案写成"保存后 AUTO 请求
会失败",读起来像保存失败了 —— 一并改成"已保存,但槽位无法解析…"。

判据两条,变异验证:恢复按文档顺序查找 → 第一条判红;!slots 分支加
return → 第二条判红。第二条最初只查 keyAutoClose() 是否存在,变异加了
return 仍然通过,属于判据漏放,已改为扫描 try 块内 close 调用之前的
所有 return 语句。

CDP 复测(先开全局页制造干扰,再开 per-key 弹窗):
- 弹窗画布 36 泳道,全局画布保持 4 泳道未被污染
- 保存 gpt-5.2@xinjianya 后弹窗关闭、服务端存储正确、scope 清空
- 重开弹窗读回 [["gpt-5.2@xinjianya"]]

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 09:21:02 +08:00
222 changed files with 2835 additions and 44406 deletions

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"
}
]
}

32
ARCHIVE.md Normal file
View File

@ -0,0 +1,32 @@
# archive/pre-v1.9-quota-refactor
**这是一个归档分支,不要从它继续开发。**
它冻结的是 v1.9 之前的一段插件重构历史,保存于 2026-10-05。
## 为什么保留
分支上有 16 个提交没有以等价形式进入主线。逐个 patch-id 核对后,其中 **3 个
是远端完全没有的**:
| commit | 内容 | 为什么没进主线 |
|---|---|---|
| `a6c6e6e` | 可见性、state 分区、注册式 HTTP 端点 | 后继版本 `35b9422` 是它的 rebase 等价版,但这个 hash 上还挂着另一些只存在于此分支的改动 |
| `6bc3ff8` | ccquota 显示每月额度 | ccquota 后来被通用 `quota` 插件取代,这段额度 UI 没进主线 |
| `b5f6cac` | ccquota 页面首次打开是空的 | 同上,是 ccquota 生命周期内的修复 |
其余 13 个已在主线以等价 patch 存在(`35b9422` / `cab3361` / `ba10f03` 等),
它们保留在这里是为了让这段历史可追溯,不是为了再次使用。
## 现在的功能落点
| 当时在这里 | 现在在哪 |
|---|---|
| ccquota 多账户额度插件 | `internal/lua/plugins/quota.lua`(通用套餐余量插件) |
| 可见性 / state 分区 / 按密钥授权 | `internal/lua/plugin_access.go`(`AccessRule`) |
| 注册式 HTTP 端点 | `internal/lua/plugin_http_handler.go` + `AccessRule` |
## 当前发布线
- `main` — 下一个未发布版本
- `release/v1.9.x` — v1.9.x 发布线(最新 tag `v1.9.5`)

View File

@ -69,9 +69,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 +150,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` 丢失后运行时密文无法解密。
@ -450,13 +407,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
@ -504,13 +458,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 +466,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 +585,4 @@ go test -tags luajit ./...
```
覆盖:配置校验、适配器加载/变换、签名钩子、Gateway 鉴权、SDK 结算、SSE 流式、
模型路由、多模态透传与 thinking 预算。
模型路由、多模态透传与 disable_thinking。

View File

@ -487,7 +487,7 @@ Shared helpers: `hmac_sha256_hex(key, data)`, `sha256_hex(data)`,
### Built-in adapters
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama`
`kimicode` `qwen38` `sensenova` `trae`.
`kimicode`.
`anthropic`/`gemini`/`ollama` include multimodal conversion
(`image_url` → their native format); with `disable_thinking` the `deepseek`

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "1.9.3",
"version": "1.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.9.3",
"version": "1.9.0",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",

View File

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

158
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
@ -283,7 +275,7 @@ verify_master_key() {
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) ') 项)"
log "✓ 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
return 0
fi
err "凭据无法用 LLMS_PROXY_MASTER_KEY 解封,未替换任何文件:"
@ -299,7 +291,7 @@ verify_master_key() {
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) ') 项)"
log "✓ 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
return 0
fi
err "凭据无法解密(钥匙与配置不匹配),未替换任何文件:"
@ -309,60 +301,48 @@ verify_master_key() {
return 1
}
# ---------- 备份目录保留策略 ----------
# 每次部署都新建一个 <prefix>.bak.<时间戳> 目录,而回滚只用到最近一次
# ---------- 适配器备份保留策略 ----------
# 每次部署都新建一个 adapters.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
# 保留最近 KEEP_ADAPTER_BACKUPS 份足够回滚,同时给目录数设上限——否则
# 一次误配置(比如 adapter_dir 指错)就可能在几秒内造出成百上千个目录。
# 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
KEEP_ADAPTER_BACKUPS=5
prune_backups() {
local prefix="$1"
prune_adapter_backups() {
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))
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
local cap=$((KEEP_ADAPTER_BACKUPS * 4))
if (( ${#all[@]} > cap )); then
warn "$prefix 备份目录有 ${#all[@]} 个(异常),裁到 $cap"
warn "适配器备份目录有 ${#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
if [[ "$d" =~ ^adapters\.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)
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
fi
# 再按时间保留最近 KEEP_BACKUPS 份(sort 后最新在末尾)。
# 再按时间保留最近 KEEP_ADAPTER_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
if (( i <= total - KEEP_ADAPTER_BACKUPS )); then
if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then
rm -rf "${base:?}/$d"
else
warn "跳过名字不规范的备份目录(不删): $d"
@ -371,15 +351,8 @@ prune_backups() {
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
left=$(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' | wc -l)
log " 适配器备份保留最近 $KEEP_ADAPTER_BACKUPS 份(当前剩 $left 个)"
}
# ---------- 同步适配器 ----------
@ -399,7 +372,7 @@ sync_adapters() {
cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true
log " 旧适配器已备份到 $BACKUP_DIR"
fi
prune_all_backups
prune_adapter_backups
cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/"
chmod 0644 "$TARGET_ADAPTERS/"*.lua
@ -414,75 +387,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 +409,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 +478,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 +495,6 @@ print_summary() {
log " 二进制: $TARGET_BIN"
log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")"
log " 适配器: $TARGET_ADAPTERS"
log " 插件: $TARGET_PLUGINS"
log "═════════════════════════════════════════════"
}
@ -600,7 +511,6 @@ main() {
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

@ -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

@ -46,7 +46,7 @@ plugin.ui = {
page = {
page_id = "hello", -- kebab-case
title = "Hello",
icon = "",
icon = "👋",
order = 90, -- 侧栏排序
mount = [[<div id="hello">hello</div>]],
},
@ -211,7 +211,7 @@ plugin.ui = {
page = {
page_id = "billing", -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing
title = "Billing", -- 必填,侧栏文字
icon = "", -- 可选
icon = "💰", -- 可选
order = 40, -- 侧栏排序,默认 100
mount = [[...HTML...]],
},
@ -301,184 +301,20 @@ PUT /api/plugins/{name}/state 替换状态(admin)
`hook_errors` 是**排错入口**:一个坏掉的插件表现为"功能缺失"而不是报错,
这个计数让它可见。
### 5.4 定时任务(`PLUGIN.schedule`)—— 浏览器关着也会跑
要求插件做“随时间发生”的事(自动签到、token 轮换、额度轮询)时,声明一个
调度表:
```lua
plugin.schedule = {
{ fn = "refresh_token", every = 3600 }, -- 每小时(秒)
{ fn = "daily_checkin", every = 86400, at = "03:30" }, -- 每天固定时刻(本地时间)
{ fn = "poll_quota", every = 300, jitter = 30 }, -- 首次触发加 0-30s 抖动
}
function plugin.refresh_token()
-- 注意:直接写 plugin.state 就是对的 ——
-- 任务和钩子跑在同一个 VM 上
plugin.state.token = fetch_new_token()
end
```
| 字段 | 含义 |
|---|---|
| `fn` | 要调用的插件函数名(必须存在) |
| `every` | 间隔秒数。与 `at` 二选一 |
| `at` | 每天本地时间 `"HH:MM"`;设了它就忽略 `every` |
| `jitter` | 首次触发额外随机延迟秒数(避免多实例整点齐射) |
| `timeout` | 解析保留但目前**不生效**(见下) |
要点:
- **内核 ticker 驱动**,不是前端轮询:没有浏览器、没人登录也会执行。
- **任务跑在插件自己的 VM 上**,与请求钩子共用 `plugin.state` —— 任务写的
token 对下一个请求立即可见,也会经同一套机制持久化到 `.<name>.state.json`。
- 任务函数出错只记 `hook_errors`,不影响钩子与转发。
- 没有 `schedule` 的插件不获得任何额外 VM/goroutine(零开销)。
- **超时不能中断一个运行中的 Lua 调用**(本 LuaJIT 构建的硬限制)。任务超过
10 分钟不返回,该插件被**停用**(`disarmed`):不再调度,钩子快速失败并
记 `hook_errors`。这是为了不让一个卡死的任务把请求永久堵在它那把锁后面。
修好插件后重新加载(重传/重启)即可恢复。
> 同一条 6.1 的约束:任务占着插件锁,所以**任务里不要写死循环**。
### 5.5 自定义对外挂载点
插件的 HTTP 端点默认挂在 `/api/plugins/http/...` 下。同一个声明**同时**可在两个
公开命名空间下访问,只要把 `path` 的第一段起成插件名:
```lua
plugin.http = {
{ path = "myplugin/callback", method = "POST", handler = "on_callback" },
}
```
该端点因此可在三个地方访问:
| 路径 | 用途 |
|---|---|
| `/api/plugins/http/myplugin/callback` | 默认前缀(带登录态的管理 UI) |
| `/plugins/myplugin/callback` | 公开(浏览器路径) |
| `/v1/plugins/myplugin/callback` | 公开(API 路径) |
可占用的命名空间只有后两个:
| 前缀 | 例子 |
|---|---|
| `/plugins/` | `/plugins/myplugin/callback` |
| `/v1/plugins/` | `/v1/plugins/myplugin/callback` |
限制(都是为了不把内核路由变成可被插件改写的):
- **不能影子内核路由**:与 `/v1/chat/completions`、`/v1/messages`、`/api/...`
等重叠的挂载在加载时就被拒绝,错误信息点名冲突对象。
- **只能是上面两个命名空间**,不接受任意前缀(否则可以拦截推理请求)。
- 鉴权沿用端点的 `roles` / `keys` / `public` 语义(见 §5.1);
`/v1/plugins/...` 属于 API 路径,未认证返回 401;`/plugins/...` 为浏览器
路径,未认证会跳登录页。
- 未声明的路径不会因为落在允许命名空间而被放行——不在 handler 表里就是 404。
### 5.6 前缀代理:把插件自起的服务挂到网关同端口
前节的自定义挂载点只能回答**由插件计算出来的内容**(`{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)
### 6.1 一个插件 = 一个 Lua 状态
适配器可以有多个独立的 worker 状态(它们无状态,这是对的)。**插件不行**:
钩子通常往 `plugin.state` 里累加,多个状态就意味着总额被劈成几份,而写价格只
写进其中一个状态,钩子恰好跑到另一个时**所有请求按 0 计费**。
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState` / **定时任务**)
都用这同一把互斥锁串行化。
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState`)用互斥锁
串行化。代价:**一个卡住的钩子会卡住所有插件的钩子**。所以——
注意: 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的
`plugin.state` 是一个请求路径永远读不到的副本 —— token 轮换看起来成功了,
对实际流量完全无效。
代价:**一个卡住的钩子会卡住该插件自己的钩子**(跨插件仍然并行,每插件各自
一把锁、各自一个 VM)。所以——
> **钩子与定时任务都必须短、同步、不阻塞。** 不要在钩子里做 HTTP 请求、睡眠
> 或重计算;长耗时的周期工作应放进 `PLUGIN.schedule`(见 §5.4),它在自己的
> 调度 goroutine 上跑,但仍然持有这把锁,因此不能是死循环。
> **钩子必须短、同步、不阻塞。** 不要在里面做 HTTP 请求、睡眠或重计算。
### 6.2 异常被隔离
@ -490,7 +326,7 @@ end
与适配器相同:`json.encode` / `json.decode`、`log(level, msg)`、
`hmac_sha256_hex` / `sha256_hex` / `base64_encode` / `tohex`。
注意: `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。
⚠️ `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。
### 6.4 命名
@ -577,7 +413,7 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
{ "prices": { ... }, "count_failures": true }
```
### 7.5 注意: 计费插件只报表,不执法
### 7.5 ⚠️ 计费插件只报表,不执法
**网关自己的配额会计(`internal/gateway/stats.go`,入口处强制)才是限额权威。**
本插件不参与任何路由或配额决策。
@ -661,12 +497,10 @@ journalctl -u llmsproxy -f | grep -i plugin
| | 适配器 | 插件 |
|---|---|---|
| 位置 | `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 |
| 出错后果 | 该 (源,模型) 进入冷却退避 | 仅该功能缺失;卡死则停用该插件 |
| 作用 | 上游协议转换 | 请求流水线 + 界面 |
| 钩子 | `transform_request` / `transform_response` / `transform_stream_chunk` / `build_headers` / `transform_error` | `request_start` / `routed` / `request_end` |
| 状态 | 每个源独立,多 worker | 单状态,见 §6.1 |
| 出错后果 | 该 (源,模型) 进入冷却退避 | 仅该功能缺失 |
| 鉴权 | 需要 `gateway_keys` 之外的独立凭据 | 网关 key |
| 必需性 | 核心 | 可选,缺了网关照常跑 |

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

@ -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

@ -195,36 +195,6 @@ func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName
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)
}
@ -292,32 +262,3 @@ func compilePeak(p *config.BillingPeak) map[string]interface{} {
}
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

@ -47,7 +47,7 @@ func TestWildcardTokenRuleDoesNotMarkEverySourcePriced(t *testing.T) {
// 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 "+
t.Errorf("★ wildcard token rule marked %q as priced at 0 — its unlisted "+
"models will bill $0 instead of appearing as unpriced", name)
}
}
@ -86,129 +86,3 @@ func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) {
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

@ -73,20 +73,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.
@ -378,14 +373,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,18 +393,18 @@ type SourceTemplate struct {
// (full management) or "user" (sees only its own key); Models is the allowed
// model scope with per-model token quota (0 = unlimited).
type GWKey struct {
Key string `yaml:"key" json:"key"`
Role string `yaml:"role" json:"role"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Key string `yaml:"key" json:"key"`
Role string `yaml:"role" json:"role"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
// Auto is this key's own AUTO scheduling chain. It overrides the global
// cfg.Auto for requests authenticated with this key; nil (or an empty
// list) means "inherit the global chain", so keys created before per-key
// chains existed keep working untouched.
Auto []ModelScope `yaml:"auto,omitempty" json:"auto,omitempty"`
Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"`
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
Auto []ModelScope `yaml:"auto,omitempty" json:"auto,omitempty"`
Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"`
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
}
// HasOwnAuto reports whether the key declares its own AUTO chain rather than
@ -459,19 +453,6 @@ 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.
@ -483,31 +464,6 @@ type BillingPeak struct {
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 {

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,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,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,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,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,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,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,63 +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
if !strings.Contains(s, "https://api2-v2.qoder.sh/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,296 +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)
}

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

@ -8,10 +8,10 @@ import (
"encoding/hex"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"sort"
"net/url"
"strings"
"sync"
"sync/atomic"
@ -69,11 +69,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
// to boot the gateway because a third-party .lua has a typo would be the
// wrong trade.
c.plugins = lua.NewPlugins(c.vm, cfg.PluginDir)
// R4-c: hand plugins the kernel action surface. Injected as an interface
// because lua must not import core (core depends on lua, not the reverse).
// Wired BEFORE LoadDir so a plugin that calls an action during its own
// __init/first hook already finds a working kernel rather than a nil one.
c.plugins.SetKernelAPI(c)
// Plugin outbound HTTP is scoped to the hosts of configured sources. Set
// before LoadDir so a plugin that requests on its first hook already sees
// the real allowlist rather than the deny-all placeholder.
@ -131,11 +126,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
// only appeared to work because seedKeys' Save() unsealed memory as a side
// effect. Removing the redundant saves exposed the real ordering bug.
cfg.AttachSecretBox(c.store.SecretBox())
// Share that SAME box with the plugin credential vault: one master.key seals
// config.yaml, the runtime store, AND every .secrets/*.json. Set here (not at
// NewPlugins time) because the store — and thus the box — only exists now;
// plugins write secrets only during login flows, well after this point.
c.plugins.SetSecretBox(c.store.SecretBox())
hadPlaintextSecrets, err := cfg.UnsealSecrets(c.store.SecretBox())
if err != nil {
return nil, fmt.Errorf("unseal secrets: %w", err)
@ -660,29 +650,11 @@ func (c *Core) rebuildRegistry() error {
// 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
@ -691,27 +663,11 @@ func sourceHosts(cfg *config.Config) []string {
if err != nil || u.Hostname() == "" {
continue
}
add(u.Hostname())
add(deriveBillingHost(u.Hostname()))
out = append(out, 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
@ -884,14 +840,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 +851,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)

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,22 +0,0 @@
package core
import (
"os"
"testing"
"time"
)
// 把测试进程的时区钉成 **UTC**。
//
// 为什么必需:Qoder 的每日活动按 **UTC+8** 结算,判定必须用算术平移而不是
// 本机时区。但这台机器**本身就是 HKT(+0800)** —— 于是在真实环境里
// 「os.date("!*t")(UTC 平移)」与「os.date("*t")(本机时区)」结果**完全相同**,
// 判据无法区分二者(实测:把实现改成用本机时区,判据照样通过)。
// 钉成 UTC 后两者相差 8 小时,误用本机时区的实现立刻暴露。
//
// 只影响测试进程,不改系统时区。
func TestMain(m *testing.M) {
os.Setenv("TZ", "UTC")
time.Local = time.UTC
os.Exit(m.Run())
}

View File

@ -1,108 +0,0 @@
package core
import (
"net/url"
"testing"
"llmsproxy/internal/config"
)
// The plugin outbound allowlist is built from the hosts of configured sources.
// That rule is correct for every adapter whose credential and endpoint live on
// the same host — which was all of them until OpenDesign.
//
// OpenDesign splits them: inference is amr-link.open-design.ai, billing is
// amr-api.open-design.ai. The quota plugin derives the billing host from the
// inference one, so the host it asks for is NOT the host the operator
// configured. The request is then refused by the allowlist with a message that
// reads like a plugin defect ("host ... is not an allowed outbound destination")
// rather than a missing entry.
//
// This file pins the bridge: when a source looks like OpenDesign, the derived
// billing host joins the allowlist.
func TestSourceHostsIncludesConfiguredHosts(t *testing.T) {
cfg := &config.Config{Sources: []config.Source{
{Name: "a", BaseURL: "https://api.deepseek.com"},
{Name: "b", BaseURL: "https://amr-link.open-design.ai/v1"},
{Name: "empty", BaseURL: ""},
{Name: "bad", BaseURL: "://nonsense"},
}}
got := map[string]bool{}
for _, h := range sourceHosts(cfg) {
got[h] = true
}
for _, want := range []string{"api.deepseek.com", "amr-link.open-design.ai"} {
if !got[want] {
t.Errorf("sourceHosts missing configured host %q (allowlist would refuse "+
"every plugin request to it): %v", want, got)
}
}
// An empty/unparseable base_url must not abort the whole list.
if !got["api.deepseek.com"] {
t.Fatal("an unparseable sibling source dropped a valid host")
}
}
// TestSourceHostsAddsDerivedBillingHost covers the OpenDesign case at the level
// that actually matters: the host the PLUGIN asks for must be in the allowlist.
func TestSourceHostsAddsDerivedBillingHost(t *testing.T) {
cfg := &config.Config{Sources: []config.Source{
{Name: "vela", BaseURL: "https://amr-link.open-design.ai/v1"},
}}
got := map[string]bool{}
for _, h := range sourceHosts(cfg) {
got[h] = true
}
if !got["amr-link.open-design.ai"] {
t.Fatalf("inference host missing: %v", got)
}
if !got["amr-api.open-design.ai"] {
t.Fatalf("the billing host the quota plugin derives is not in the allowlist, "+
"so every OpenDesign balance probe is refused before it leaves the "+
"process: %v", got)
}
}
// A deployment with no OpenDesign source must not silently gain its billing
// host: an allowlist entry is a grant, and widening it for everyone would let an
// unrelated plugin reach a host the operator never configured.
func TestSourceHostsDoesNotGrantUnconfiguredBillingHost(t *testing.T) {
cfg := &config.Config{Sources: []config.Source{
{Name: "ds", BaseURL: "https://api.deepseek.com"},
}}
for _, h := range sourceHosts(cfg) {
if h == "amr-api.open-design.ai" {
t.Fatal("the OpenDesign billing host was granted to a deployment with " +
"no OpenDesign source")
}
}
}
// The derivation has to agree with the Lua side. Keep the two spellings of the
// rule in one place so a change to one is visible when reading the other.
func TestSourceHostsDerivationMatchesPluginRule(t *testing.T) {
// what the Lua adapter does: origin:gsub("^amr%-link%.", "amr-api.")
const in = "amr-link.open-design.ai"
want := "amr-api.open-design.ai"
got := deriveBillingHost(in)
if got != want {
t.Fatalf("deriveBillingHost(%q) = %q, want %q — the Go allowlist and the "+
"Lua probe must agree on the host or the request is refused", in, got, want)
}
// A host that is not amr-link stays untouched, so this cannot accidentally
// rewrite an unrelated provider into an OpenDesign address.
for _, other := range []string{"api.deepseek.com", "amr-link.example.com", "xamr-link.a"} {
if deriveBillingHost(other) != other {
t.Errorf("deriveBillingHost(%q) = %q, want it unchanged", other, deriveBillingHost(other))
}
}
// url.Parse round-trip keeps the helper honest about what it receives.
u, err := url.Parse("https://amr-link.open-design.ai/v1")
if err != nil {
t.Fatal(err)
}
if deriveBillingHost(u.Hostname()) != "amr-api.open-design.ai" {
t.Fatal("derivation failed on a real base_url hostname")
}
}

View File

@ -1,44 +0,0 @@
[
{
"u": "12345",
"devid": "707304999019651",
"market": "5daa819c-8425-4bae-88b7-2bbfec3c25ba",
"sess": "e5b8dfc5830ce0bdb141f7177effb90a7cf8227bb23b930a427a8550bf7357df"
},
{
"u": "UID-abc",
"devid": "579362202930608",
"market": "a5388fc1-5969-4666-bc74-be9a11f69567",
"sess": "a47da2e13fe16d49dc859fbed58d1d99e0faef5d116acafc64a2d26013d17a1f"
},
{
"u": "900000001",
"devid": "764948639142479",
"market": "e449daa5-b0c0-4f35-918e-8bb01b33f179",
"sess": "fdd37c37d1cc950439c25467e6dca75fcc84a2b0d294aa910387a9a965645650"
},
{
"u": "x",
"devid": "268405722745284",
"market": "4f2427c5-0583-4f82-8e18-4e38566dd857",
"sess": "79be40fff0fbaee572d1ba3ab13a47af9d2f4d943115ef512c56e42132e23a66"
},
{
"u": "",
"devid": "170789623621479",
"market": "b32897c1-f9c0-4ad2-b2b4-725455fffb6b",
"sess": "4a61a6b4c4bcbf1040bf0edf3df2b3e7d76a956f3ef81ef8f148eadcc03d25cc"
},
{
"u": "用户1",
"devid": "263026789643405",
"market": "1c1b4e1f-9d7a-4698-9f34-b057d9a74b0b",
"sess": "a13dbde68977eee49818e2e2fb7d3bbb1ffb4c807a6e6f1831168a84adea0dfe"
},
{
"u": "1234567890123456789012345",
"devid": "358669731114854",
"market": "449882ec-9a69-4a7a-b9ab-25aa59b28c50",
"sess": "305eb96fe14a194386c0fd4148d4919870ed803b69cf75296b53ea1f41e4c574"
}
]

View File

@ -470,8 +470,8 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
// the all-time view showed the truth, which read as "the gateway is
// idle" precisely when it was busiest. A calendar window cannot
// filter it, so it travels with every response unfiltered.
"active": g.stats.Active(),
"records": page.Records,
"active": g.stats.Active(),
"records": page.Records,
// The paging cursor has to travel with the first page, otherwise
// the scroll handler has nothing to page from and the table is
// stuck at one screen forever.

View File

@ -189,20 +189,6 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
{Method: "GET", Path: "/api/stats", Auth: "any", Summary: "usage aggregates"},
{Method: "GET", Path: "/api/stats/records", Auth: "any", Summary: "paged request records"},
{Method: "GET", Path: "/api/adapters", Auth: "admin", Summary: "installed Lua adapters"},
{Method: "POST", Path: "/api/sources/discover", Auth: "admin",
Summary: "probe a base_url+api_key for its model catalog",
// Probing is read-only with respect to configuration: it writes
// nothing, which is why it is safe to call repeatedly while the
// operator is still typing a key.
WriteEffect: "none",
},
{Method: "POST", Path: "/api/sources/discover/apply", Auth: "admin",
Summary: "probe and write the source in one step",
// Requires replace_models=true when the source already exists,
// so re-running discovery cannot silently discard a hand-tuned
// model list. An empty api_key keeps the stored credential.
WriteEffect: "creates or replaces a source and its model list",
},
{Method: "GET", Path: "/api/source_templates", Auth: "admin", Summary: "source templates"},
{Method: "POST", Path: "/v1/chat/completions", Auth: "any", Summary: "OpenAI-compatible inference"},
},

View File

@ -67,7 +67,7 @@ func TestBillingRulesCRUD(t *testing.T) {
t.Fatal(err)
}
if n := len(got.DSL.Profiles[0].Rules); n != 2 {
t.Fatalf("re-adding an existing url appended instead of replacing: %d rules", n)
t.Fatalf("★ re-adding an existing url appended instead of replacing: %d rules", n)
}
var mode string
for _, r := range got.DSL.Profiles[0].Rules {
@ -105,7 +105,7 @@ func TestBillingRulesRejectUnknownURLWithWarning(t *testing.T) {
t.Fatal(err)
}
if len(got.Warnings) == 0 {
t.Fatal("a rule matching no configured source was accepted with no warning — " +
t.Fatal("★ a rule matching no configured source was accepted with no warning — " +
"it will price nothing and every request will be recorded as unpriced")
}
if !strings.Contains(got.Warnings[0], "typo.example.com") {
@ -136,7 +136,7 @@ func TestBillingRulesInvalidIsRejectedNotPersisted(t *testing.T) {
t.Fatal(err)
}
if len(got.DSL.Profiles[0].Rules) != 1 {
t.Errorf("a rejected rule was persisted anyway: %d rules", len(got.DSL.Profiles[0].Rules))
t.Errorf("★ a rejected rule was persisted anyway: %d rules", len(got.DSL.Profiles[0].Rules))
}
// An unparseable price must be refused too, not silently priced at zero.
if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p","rule":{"url":"https://y/v1","mode":"token",
@ -175,7 +175,7 @@ func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
t.Fatalf("read config: %v", err)
}
if !strings.Contains(string(raw), "commandcode.ai") {
t.Errorf("the rule is not in config.yaml on disk — an edit that does not "+
t.Errorf("★ the rule is not in config.yaml on disk — an edit that does not "+
"survive a restart:\n%s", raw)
}
reloaded, err := config.Load(cfg.Path)
@ -226,7 +226,7 @@ func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
}
cc, ok := sources["commandcode"]
if !ok {
t.Fatalf("the rule was saved but never injected: prices has %v, "+
t.Fatalf("★ the rule was saved but never injected: prices has %v, "+
"no commandcode entry. Every request on it stays unpriced.", keysOf(sources))
}
if cc == nil {

View File

@ -21,79 +21,21 @@ import (
)
// chatRequest mirrors the OpenAI chat completions request the gateway accepts.
// chatRequest is the gateway's IN-BOUND client shape for /v1/chat/completions.
//
// It is deliberately an OpenAI wire struct, NOT the internal unified format:
// this is one client protocol, and the kernel parses it before anything else
// knows which protocol the rest of the request will speak. What reaches an
// adapter is types.UnifiedRequest (the maximal set), which Thinking populates.
type chatRequest struct {
Model string `json:"model"`
Messages []types.ChatMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
// DisableThinking is the OpenAI-ecosystem spelling of "turn thinking off".
// It is translated into the protocol-neutral Thinking field on the way in
// (see thinkingFromClient), so that flag never travels under this name.
Model string `json:"model"`
Messages []types.ChatMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
DisableThinking bool `json:"disable_thinking"`
ExtraBody map[string]interface{} `json:"extra_body,omitempty"`
// Thinking accepts the neutral shape directly, so a client already speaking
// the unified format (or an adapter-served protocol that names it that way)
// does not have to round-trip through disable_thinking.
Thinking *types.ThinkingConfig `json:"thinking,omitempty"`
// PromptCacheKey 是 OpenAI 原生的缓存键;部分客户端用它携带会话标识。
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
}
// thinkingFromClient folds the client's spelling of "no thinking" into the
// neutral Thinking field.
//
// Precedence: an explicit `thinking` object wins, because it carries a budget
// the boolean cannot express. disable_thinking only ever sets Enabled=false —
// it says nothing about how much, and inventing a budget there would silently
// override the upstream's default.
func (r *chatRequest) thinkingFromClient() *types.ThinkingConfig {
if r.Thinking != nil {
return r.Thinking
}
if r.DisableThinking {
no := false
return &types.ThinkingConfig{Enabled: &no}
}
return nil
}
// rawTools / rawToolChoice re-encode the loosely-typed client fields into the
// RawMessage the internal format carries. The kernel never interprets them:
// they are the client's payload, passed through to the adapter, which is the
// only layer that knows the tool schema of any particular protocol.
func (r *chatRequest) rawTools() json.RawMessage {
if len(r.Tools) == 0 {
return nil
}
b, err := json.Marshal(r.Tools)
if err != nil {
return nil
}
return b
}
func (r *chatRequest) rawToolChoice() json.RawMessage {
if r.ToolChoice == nil {
return nil
}
b, err := json.Marshal(r.ToolChoice)
if err != nil {
return nil
}
return b
}
// ChatCompletion is the non-streaming OpenAI response object.
type ChatCompletion struct {
ID string `json:"id"`
@ -314,17 +256,17 @@ func (g *Gateway) checkQuota(ctx context.Context, model string) *quotaRejection
}
return nil
}
// A scope that simply does not list the model is a different problem from
// a quota being exhausted, and the message has to say which: telling an
// operator "not allowed for this key" when they configured a quota sends
// them to the wrong setting. AUTO is checked here before the chain is even
// consulted, so this is the message that surfaces for AUTO.
if isAuto(model) {
return &quotaRejection{msg: fmt.Sprintf(
"model %q is not in this key's model scope, so it cannot use AUTO; "+
"add %q to the key's models or remove the scope restriction", model, model)}
}
return &quotaRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)}
// A scope that simply does not list the model is a different problem from
// a quota being exhausted, and the message has to say which: telling an
// operator "not allowed for this key" when they configured a quota sends
// them to the wrong setting. AUTO is checked here before the chain is even
// consulted, so this is the message that surfaces for AUTO.
if isAuto(model) {
return &quotaRejection{msg: fmt.Sprintf(
"model %q is not in this key's model scope, so it cannot use AUTO; "+
"add %q to the key's models or remove the scope restriction", model, model)}
}
return &quotaRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)}
}
// quotaWindowSuffix describes a quota's reset window for an error message, so
@ -482,15 +424,15 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
done := g.stats.Begin()
defer done()
inner := &types.ChatRequest{
Messages: req.Messages,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
Stream: req.Stream,
Tools: req.rawTools(),
ToolChoice: req.rawToolChoice(),
Thinking: req.thinkingFromClient(),
ExtraBody: req.ExtraBody,
ClientSession: clientSession,
Messages: req.Messages,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
Stream: req.Stream,
Tools: req.Tools,
ToolChoice: req.ToolChoice,
DisableThinking: req.DisableThinking,
ExtraBody: req.ExtraBody,
ClientSession: clientSession,
}
rec := &Req{
Key: keyID(reqKey(ctx)),
@ -542,16 +484,16 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
defer done()
inner := &types.ChatRequest{
Model: effective,
Messages: req.Messages,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
Stream: req.Stream,
Tools: req.rawTools(),
ToolChoice: req.rawToolChoice(),
Thinking: req.thinkingFromClient(),
ExtraBody: req.ExtraBody,
ClientSession: clientSession,
Model: effective,
Messages: req.Messages,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
Stream: req.Stream,
Tools: req.Tools,
ToolChoice: req.ToolChoice,
DisableThinking: req.DisableThinking,
ExtraBody: req.ExtraBody,
ClientSession: clientSession,
}
rec := &Req{
Key: keyID(reqKey(ctx)),
@ -644,7 +586,7 @@ func estimatePromptTokens(req *types.ChatRequest) int64 {
}
b, _ := json.Marshal(struct {
Messages []types.ChatMessage `json:"messages"`
Tools json.RawMessage `json:"tools,omitempty"`
Tools []interface{} `json:"tools,omitempty"`
}{Messages: req.Messages, Tools: req.Tools})
if len(b) == 0 {
return 0
@ -847,18 +789,6 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon
// Cache accounting: whichever source format the adapter normalized into
// (prompt_tokens_details.cached_tokens or legacy prompt_cache_hit_tokens),
// read it back so the request record carries the hit/miss split.
// reasoning_tokens is a SUBSET of completion_tokens and is billed at the
// completion rate by every provider measured, so it must NOT be added to
// Compl. Recording it separately is what lets a price rule split thinking
// from output for the providers that do price them differently, and what
// keeps the billing plugin from guessing.
//
// Leaving it unrecorded is worse than useless: the adapters already parse it
// and forward it (gemini/anthropic/qwen38 all do), so the number exists
// in the response and is thrown away at the one place that could keep it.
if d := resp.TokenUsage.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 {
rec.Reasoning = int64(d.ReasoningTokens)
}
if d := resp.TokenUsage.PromptTokensDetails; d != nil && d.CachedTokens > 0 {
rec.CacheHit = int64(d.CachedTokens)
}
@ -875,21 +805,29 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon
}
}
// writeChatCompletion renders a unified response through the out-bound
// Serializer. The default serializer reproduces the OpenAI wire shape this
// function used to build inline (locked byte-for-byte by e2e golden); an
// adapter that declares serialize_response overrides it. modelName is the id
// clients see as the serving model: the requested id for direct routes, the
// exact slot model for AUTO.
func writeChatCompletion(w http.ResponseWriter, s Serializer, resp *types.UnifiedResponse, modelName string) {
body, err := s.NonStream(resp, modelName, newWireID(), newCreated())
if err != nil {
writeError(w, http.StatusInternalServerError, "serialize_error", "failed to serialize response")
return
// writeChatCompletion renders a unified response as an OpenAI
// chat.completion object. modelName is the id clients see as the serving
// model: the requested id for direct routes, the exact slot model for AUTO.
func writeChatCompletion(w http.ResponseWriter, resp *types.UnifiedResponse, modelName string) {
msg := RespMessage{Role: "assistant", Content: resp.Content}
if resp.ReasoningContent != "" {
msg.ReasoningContent = resp.ReasoningContent
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
if len(resp.ToolCalls) > 0 {
msg.ToolCalls = toolCallsWire(resp.ToolCalls)
}
out := ChatCompletion{
ID: newID(),
Object: "chat.completion",
Created: time.Now().Unix(),
Model: modelName,
Choices: []ChatChoice{{Index: 0, Message: msg, FinishReason: resp.FinishReason}},
Cost: resp.Cost,
}
if resp.TokenUsage.Total > 0 || resp.TokenUsage.Prompt > 0 || resp.TokenUsage.Completion > 0 {
out.Usage = &resp.TokenUsage
}
writeJSON(w, http.StatusOK, out)
}
// singleChat runs a direct (model-pinned) non-streaming request across the
@ -914,7 +852,7 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [
// the total latency.
rec.FirstByteMs = rec.LatMs
g.writeRec(rec)
writeChatCompletion(w, g.serializerFor(usedSrc, cands), resp, effective)
writeChatCompletion(w, resp, effective)
}
// writeRec records a finished request (audit + aggregates) and fires the
@ -1106,12 +1044,9 @@ func (g *Gateway) fireEnd(rec *Req) {
// carried this (rec.CacheReported); the plugin payload did not, so any
// plugin-level cache accounting had to guess.
"cache_reported": rec.CacheReported,
// A SUBSET of completion_tokens, not an extra. Billing plugins that
// add it to completion would double-charge every thinking token.
"reasoning_tokens": rec.Reasoning,
"image_count": rec.ImageCount,
"error": rec.Err,
"time": rec.Time,
"image_count": rec.ImageCount,
"error": rec.Err,
"time": rec.Time,
// chain_walk: the AUTO tier-by-tier trace, when the request went
// through the chain. Empty for a direct request and for a gateway with
// no plugins loaded. Absent rather than empty so a plugin can tell
@ -1166,29 +1101,24 @@ func mergeUsage(prev, cur *types.TokenUsage) *types.TokenUsage {
return &out
}
// pumpStream writes the full SSE sequence for a started stream. Every byte on
// the wire comes from the Serializer, so the OpenAI frame shapes live in
// serializer.go rather than here, and an adapter that declares serialize_chunk
// replaces them wholesale.
//
// This function owns only transport concerns — headers, the flush cadence,
// first-byte timing, and audit accounting — plus the protocol-agnostic rule
// that usage seen anywhere in the stream becomes the recorded usage. Whether a
// trailing usage frame is emitted, and in what shape, is the serializer's
// decision.
func (g *Gateway) pumpStream(w http.ResponseWriter, ser Serializer, rec *Req, chunks <-chan types.UnifiedChunk, modelName string, t0 time.Time) {
// pumpStream writes the full SSE sequence for a started stream: role
// preamble, one chunk per unified delta, the terminating finish_reason, the
// OpenAI-standard final usage chunk (empty choices) and [DONE]. modelName
// follows writeChatCompletion's rule (requested id for direct routes, exact
// slot model for AUTO).
func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan types.UnifiedChunk, modelName string, t0 time.Time) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
id := newWireID()
created := newCreated()
st := &StreamState{}
send := func(b []byte) bool {
if len(b) == 0 {
return true
id := newID()
created := time.Now().Unix()
send := func(obj interface{}) bool {
b, err := json.Marshal(obj)
if err != nil {
return false
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
return false
@ -1199,66 +1129,115 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, ser Serializer, rec *Req, ch
return true
}
if b, err := ser.OpenRolePreamble(modelName, id, created, st); err == nil {
if !send(b) {
return
}
if !send(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{Role: "assistant"}}},
}) {
return
}
// First SSE byte sent to the client: record time-to-first-byte for the
// source's status-page latency average.
rec.FirstByteMs = time.Since(t0).Milliseconds()
var lastUsage *types.TokenUsage
var lastCost string
lastFinish := ""
for ck := range chunks {
if ck.Usage != nil {
st.Usage = mergeUsage(st.Usage, ck.Usage)
lastUsage = mergeUsage(lastUsage, ck.Usage)
}
if ck.Cost != "" {
st.Cost = ck.Cost
lastCost = ck.Cost
}
b, err := ser.Chunk(&ck, modelName, id, created, st)
if err != nil {
return
chunk := ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
}
delta := RespMessage{Role: "assistant", Content: ck.Content}
if ck.ReasoningContent != "" {
delta.ReasoningContent = ck.ReasoningContent
}
if len(ck.ToolCalls) > 0 {
delta.ToolCalls = ck.ToolCalls
}
choice := ChunkChoice{Index: 0, Delta: delta}
if ck.Done {
fin := ck.FinishReason
if fin == "" {
fin = "stop"
}
lastFinish = fin
choice.FinishReason = &fin
}
chunk.Choices = []ChunkChoice{choice}
rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)+len(ck.ToolCalls)) / 3
if !send(b) {
if !send(chunk) {
return
}
}
// Write the upstream's exact usage back onto the audit record. Without this
// the streamed path kept only the per-chunk byte estimate, so the same
finalFinish := lastFinish
if finalFinish == "" {
finalFinish = "stop"
}
send(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &finalFinish}},
})
// Final usage chunk (OpenAI standard: empty choices + usage before [DONE]).
// Prefer the upstream's exact usage if the stream carried it; fall back to
// the gateway's estimate otherwise.
// Write the upstream's exact numbers back onto the audit record. Without
// this the streamed path kept only the per-chunk byte estimate, so the same
// request recorded ~1.4x its real completion tokens (measured: upstream 100,
// audit 145) while the non-streaming path recorded 100. Two paths, two
// different numbers for one request is a reporting bug, not a rounding one.
if st.Usage != nil {
if st.Usage.Prompt > 0 {
rec.Prompt = int64(st.Usage.Prompt)
if lastUsage != nil {
if lastUsage.Prompt > 0 {
rec.Prompt = int64(lastUsage.Prompt)
}
if st.Usage.Completion > 0 {
rec.Compl = int64(st.Usage.Completion)
if lastUsage.Completion > 0 {
rec.Compl = int64(lastUsage.Completion)
}
}
var tut *types.TokenUsage
if lastUsage != nil {
tut = lastUsage
// Write the upstream's cache accounting back onto the request record
// so the audit trail carries the hit/miss split for streaming too.
if d := tut.PromptTokensDetails; d != nil {
rec.CacheReported = true
if d.CachedTokens > 0 {
rec.CacheHit = int64(d.CachedTokens)
}
} else if tut.PromptCacheHit > 0 {
rec.CacheHit = int64(tut.PromptCacheHit)
rec.CacheReported = true
}
if tut.PromptCacheMiss > 0 {
rec.CacheMiss = int64(tut.PromptCacheMiss)
}
applyStreamUsageDetails(rec, st.Usage)
} else if rec.Prompt+rec.Compl > 0 {
// No upstream usage on the stream: the recorded per-chunk byte
// estimate is the best number available, so hand it to the serializer
// as the usage rather than dropping it.
st.Usage = &types.TokenUsage{
u := types.TokenUsage{
Prompt: int(rec.Prompt),
Completion: int(rec.Compl),
Total: int(rec.Prompt + rec.Compl),
}
tut = &u
}
if frames, err := ser.Close(modelName, id, created, st); err == nil {
for _, b := range frames {
if !send(b) {
return
}
}
}
if marker := ser.DoneMarker(); marker != "" {
fmt.Fprintf(w, "data: %s\n\n", marker)
if tut != nil {
send(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{},
Usage: tut,
Cost: lastCost,
})
} else if lastCost != "" {
// Cost arrived without any usage (OpenCode sends it on its own frame).
send(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{},
Cost: lastCost,
})
}
fmt.Fprintf(w, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
}
@ -1289,7 +1268,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
rec.Source = usedSrc
g.fireRouted(ctx, "stream", usedSrc, rec.Model, -1, true)
rec.Prompt = estimatePromptTokens(req)
g.pumpStream(w, g.serializerFor(usedSrc, cands), rec, chunks, effective, t0)
g.pumpStream(w, rec, chunks, effective, t0)
}
// singleChatAuto runs a non-streaming AUTO request down the chain (see
@ -1322,7 +1301,7 @@ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, cha
g.fireRouted(ctx, "chat", usedSrc, usedModel, -2, false)
rec.FirstByteMs = rec.LatMs
g.writeRec(rec)
writeChatCompletion(w, g.serializerFor(usedSrc, nil), resp, usedModel)
writeChatCompletion(w, resp, usedModel)
}
// streamChatAuto streams an AUTO request down the chain. A slot is abandoned
@ -1352,7 +1331,7 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha
rec.Source = usedSrc
g.fireRouted(ctx, "stream", usedSrc, rec.Model, -2, true)
rec.Prompt = estimatePromptTokens(req)
g.pumpStream(w, g.serializerFor(usedSrc, nil), rec, chunks, usedModel, t0)
g.pumpStream(w, rec, chunks, usedModel, t0)
}
func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
@ -1465,39 +1444,3 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
Data: resp.ImageData,
})
}
// applyStreamUsageDetails mirrors the upstream's reasoning and cache numbers
// onto a STREAMED request's audit record.
//
// It exists as a named function because this was inline in the streaming handler,
// where it was untestable — and untested it stayed wrong. The non-streaming
// recorder read completion_tokens_details.reasoning_tokens; this one did not. So
// a source's streamed requests recorded completion and no reasoning at all while
// its non-streamed calls recorded both. Nothing failed and no test was red: the
// audit trail was simply quieter than it should have been.
//
// Found by reading the production audit file after wiring a reasoning-heavy
// source: completion=8166 with reasoning absent, on every streamed request.
//
// Reasoning is a SUBSET of completion, never an addition to it. See
// recordChatUsage for why that matters to billing.
func applyStreamUsageDetails(rec *Req, u *types.TokenUsage) {
if d := u.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 {
rec.Reasoning = int64(d.ReasoningTokens)
}
// Cache accounting is here for the same reason it is kept apart: "zero hits
// because nothing was cached" and "this provider never reports caching" are
// the same number and mean opposite things.
if d := u.PromptTokensDetails; d != nil {
rec.CacheReported = true
if d.CachedTokens > 0 {
rec.CacheHit = int64(d.CachedTokens)
}
} else if u.PromptCacheHit > 0 {
rec.CacheHit = int64(u.PromptCacheHit)
rec.CacheReported = true
}
if u.PromptCacheMiss > 0 {
rec.CacheMiss = int64(u.PromptCacheMiss)
}
}

View File

@ -1,160 +0,0 @@
package gateway
import (
"testing"
"llmsproxy/internal/types"
)
// TestRecordChatUsageKeepsReasoningTokens pins the reasoning-token plumbing.
//
// The adapters already parse completion_tokens_details.reasoning_tokens and
// forward it (qwen38, gemini, anthropic all do), and types.go has carried
// the field since it was defined — but nothing ever READ it. The number arrived
// in the response and was discarded at the one place that could have kept it,
// so a pricing rule had no way to split thinking from output.
//
// The property that matters most: reasoning is a SUBSET of completion. Every
// provider measured bills it at the completion rate, so a billing plugin that
// added it to completion_tokens would double-charge every thinking token. The
// test asserts completion is left alone.
func TestRecordChatUsageKeepsReasoningTokens(t *testing.T) {
rec := &Req{}
resp := &types.UnifiedResponse{
TokenUsage: types.TokenUsage{
Prompt: 100,
Completion: 500,
CompletionTokensDetails: &types.CompletionTokensDetails{
ReasoningTokens: 320,
},
},
}
recordChatUsage(rec, &types.ChatRequest{}, resp)
if rec.Reasoning != 320 {
t.Errorf("Reasoning = %d, want 320", rec.Reasoning)
}
// The subset must not be folded into completion — it is already counted there.
if rec.Compl != 500 {
t.Errorf("Compl = %d, want 500 (reasoning is a subset, never additive)", rec.Compl)
}
if rec.Prompt != 100 {
t.Errorf("Prompt = %d, want 100", rec.Prompt)
}
}
// TestRecordChatUsageReasoningAbsentStaysZero: "not reported" and "reported as
// zero" must both leave the field at 0, and neither may invent a number. A
// plugin reading Reasoning cannot tell them apart, which is why the field is
// documented as "0 means not reported" rather than "0 means no thinking".
func TestRecordChatUsageReasoningAbsentStaysZero(t *testing.T) {
for _, tc := range []struct {
name string
u types.TokenUsage
}{
{"no details object", types.TokenUsage{Prompt: 10, Completion: 20}},
{"details without reasoning", types.TokenUsage{
Prompt: 10,
Completion: 20,
CompletionTokensDetails: &types.CompletionTokensDetails{},
}},
} {
rec := &Req{}
recordChatUsage(rec, &types.ChatRequest{}, &types.UnifiedResponse{TokenUsage: tc.u})
if rec.Reasoning != 0 {
t.Errorf("%s: Reasoning = %d, want 0", tc.name, rec.Reasoning)
}
if rec.Compl != 20 {
t.Errorf("%s: Compl = %d, want 20", tc.name, rec.Compl)
}
}
}
// TestStreamedReasoningIsRecordedOnTheRequest is the streaming counterpart.
//
// TestRecordChatUsageKeepsReasoningTokens covers the non-streaming path only,
// and for a long time the streamed path dropped the number: the non-streaming
// recorder read TokenUsage.CompletionTokensDetails, while the streaming one read
// only Prompt and Completion from the terminal usage chunk. (Cache accounting was
// mirrored; reasoning simply never was.)
//
// The symptom is invisible in the response — the client sees a correct answer
// either way. It only shows in the audit trail, where a reasoning-heavy source's
// streamed requests recorded completion=8166 and no reasoning at all. Found by
// reading the production audit file, not by a failing test, which is exactly why
// the property is pinned here.
func TestStreamedReasoningIsRecordedOnTheRequest(t *testing.T) {
rec := &Req{}
applyStreamUsageDetails(rec, &types.TokenUsage{
Prompt: 159579,
Completion: 751,
Total: 160330,
CompletionTokensDetails: &types.CompletionTokensDetails{
ReasoningTokens: 512,
},
})
if rec.Reasoning != 512 {
t.Errorf("streamed Reasoning = %d, want 512 — a streamed request records no "+
"reasoning, so a pricing rule that splits thinking from output silently "+
"bills it as ordinary output", rec.Reasoning)
}
}
// TestStreamedReasoningIsNeverAddedToCompletion pins the SUBSET relation in the
// streaming path specifically. A presence check would pass if someone wrote
// rec.Compl += reasoning, and billing would then charge every thinking token
// twice.
func TestStreamedReasoningIsNeverAddedToCompletion(t *testing.T) {
rec := &Req{}
rec.Compl = 751 // set upstream of the call, as the streaming handler does
applyStreamUsageDetails(rec, &types.TokenUsage{
Completion: 751,
CompletionTokensDetails: &types.CompletionTokensDetails{ReasoningTokens: 512},
})
if rec.Compl != 751 {
t.Errorf("Compl = %d, want 751 unchanged — reasoning is a subset, not an addition", rec.Compl)
}
if rec.Reasoning >= rec.Compl {
t.Errorf("Reasoning (%d) must be a SUBSET of Compl (%d)", rec.Reasoning, rec.Compl)
}
}
// TestStreamedUsageWithoutReasoningLeavesItUnset: an upstream that reports no
// reasoning must not be turned into a reported zero. In the audit trail 0 means
// "not reported", and a plugin cannot tell that from "no reasoning happened".
func TestStreamedUsageWithoutReasoningLeavesItUnset(t *testing.T) {
rec := &Req{}
applyStreamUsageDetails(rec, &types.TokenUsage{Prompt: 10, Completion: 4, Total: 14})
if rec.Reasoning != 0 {
t.Errorf("Reasoning = %d for an upstream that reported none", rec.Reasoning)
}
}
// TestStreamedCacheAccountingStillWorks is a regression guard for the block the
// reasoning lines were added to. They share a function, so a future edit that
// short-circuits one would break the other — silently, since a missing cache
// number reads as "nothing was cached".
func TestStreamedCacheAccountingStillWorks(t *testing.T) {
for _, c := range []struct {
name string
u types.TokenUsage
hit int64
miss int64
rep bool
}{
{"details object", types.TokenUsage{
PromptTokensDetails: &types.PromptTokensDetails{CachedTokens: 4096},
}, 4096, 0, true},
{"legacy hit/miss", types.TokenUsage{
PromptCacheHit: 2048, PromptCacheMiss: 512,
}, 2048, 512, true},
{"nothing reported", types.TokenUsage{}, 0, 0, false},
} {
rec := &Req{}
applyStreamUsageDetails(rec, &c.u)
if rec.CacheHit != c.hit || rec.CacheMiss != c.miss || rec.CacheReported != c.rep {
t.Errorf("%s: hit=%d miss=%d reported=%v; want %d/%d/%v",
c.name, rec.CacheHit, rec.CacheMiss, rec.CacheReported, c.hit, c.miss, c.rep)
}
}
}

View File

@ -33,13 +33,13 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
t.Fatalf("read deploy.sh: %v", err)
}
src := string(fn)
i := strings.Index(src, "prune_backups()")
i := strings.Index(src, "prune_adapter_backups()")
if i < 0 {
t.Fatal("prune_backups not found in deploy.sh")
t.Fatal("prune_adapter_backups not found in deploy.sh")
}
j := strings.Index(src[i:], "\n}\n")
if j < 0 {
t.Fatal("prune_backups has no closing brace")
t.Fatal("prune_adapter_backups has no closing brace")
}
body := src[i : i+j+3]
@ -47,20 +47,20 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
body = strings.Replace(body,
`local base="/etc/llmsproxy"`,
`local base="`+base+`"`, 1)
// KEEP_BACKUPS must come from deploy.sh itself, NOT from a literal
// KEEP_ADAPTER_BACKUPS must come from deploy.sh itself, NOT from a literal
// here: hardcoding 5 in the shim overrode whatever the script configured, so
// a mutation that set KEEP_BACKUPS=0 in deploy.sh still passed. The
// a mutation that set KEEP_ADAPTER_BACKUPS=0 in deploy.sh still passed. The
// value is part of what is under test.
var keepRe = regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
var keepRe = regexp.MustCompile(`(?m)^KEEP_ADAPTER_BACKUPS=(\d+)`)
keepM := keepRe.FindStringSubmatch(src)
if keepM == nil {
t.Fatal("deploy.sh no longer sets KEEP_BACKUPS; the pruning " +
t.Fatal("deploy.sh no longer sets KEEP_ADAPTER_BACKUPS; the pruning " +
"policy would be undefined")
}
keep := keepM[1]
script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" + keep + "\n" +
body + "\nprune_backups adapters\n"
script := "warn() { :; }\nlog() { :; }\nKEEP_ADAPTER_BACKUPS=" + keep + "\n" +
body + "\nprune_adapter_backups\n"
scriptPath := filepath.Join(base, "fn.sh")
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
t.Fatalf("write script: %v", err)
@ -88,7 +88,7 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
}
if out, err := exec.Command("bash", scriptPath).CombinedOutput(); err != nil {
t.Fatalf("prune_backups failed: %v\n%s", err, out)
t.Fatalf("prune_adapter_backups failed: %v\n%s", err, out)
}
entries, err := os.ReadDir(base)
@ -102,11 +102,11 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
}
}
// 12 well-formed backups with KEEP_BACKUPS=5 → the newest FIVE
// 12 well-formed backups with KEEP_ADAPTER_BACKUPS=5 → the newest FIVE
// survive (…08 …09 …10 …11 …12) and the older seven are deleted. Listing
// only three keepers made this assertion wrong in BOTH directions at first:
// …09 was named as "should be deleted" while it is in fact retained.
// KEEP_BACKUPS=5 is a cap on the TOTAL number of backups, and
// KEEP_ADAPTER_BACKUPS=5 is a cap on the TOTAL number of backups, and
// irregular names are never deleted — so they consume slots rather than
// being ignored. With 3 unremovable directories present, only 2 of the
// well-formed ones can survive. That is the function's actual (and safe)
@ -202,123 +202,3 @@ func TestDeployBacksUpTheLiveFileBeforeOverwriting(t *testing.T) {
"captures the new file and rollback restores nothing")
}
}
// ★ 备份清理必须覆盖 plugins 前缀(真实缺陷)。
//
// 事实:backup 清理最初只处理 `adapters.bak.*`,而 `sync_plugins` 同样每次部署
// 建一个 `plugins.bak.<时间戳>` —— 于是 adapters 的堆积被修好,plugins 继续涨。
// 实测线上 plugins.bak 积累到 **36 个**、/etc/llmsproxy 涨到 192M。
//
// 这是「两处只修一处」的老形状。判据钉住:prune_all_backups 必须同时裁两个前缀,
// 且新增任何 .bak.<时间戳> 前缀时都要在这里补上(否则又是一个泄漏点)。
func TestDeployPruneCoversBothBackupPrefixes(t *testing.T) {
if _, err := os.Stat("../../deploy.sh"); err != nil {
t.Skip("deploy.sh not present (packaging-only checkout)")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
fn, err := os.ReadFile("../../deploy.sh")
if err != nil {
t.Fatal(err)
}
src := string(fn)
// ① prune_all_backups 必须存在且两个前缀都裁
i := strings.Index(src, "prune_all_backups()")
if i < 0 {
t.Fatal("prune_all_backups 不存在 —— 调用点若仍只裁 adapters," +
"plugins.bak 会继续无上限堆积(实测 36 个 / 192M)")
}
j := strings.Index(src[i:], "\n}\n")
if j < 0 {
t.Fatal("prune_all_backups 没有收尾大括号")
}
body := src[i : i+j+3]
for _, want := range []string{"prune_backups adapters", "prune_backups plugins"} {
if !strings.Contains(body, want) {
t.Fatalf("prune_all_backups 缺 %q —— plugins.bak 会无上限堆积", want)
}
}
// ② ★ 覆盖完整性:脚本里出现的每个 `.bak.<时间戳>` 前缀都必须在裁剪范围内。
// 这条比逐字检查两个名字更强 —— 将来有人新增第三种备份目录(比如
// runtime.bak.*),这里会立刻报红,而不是等它涨到几百兆。
prefixRe := regexp.MustCompile(`([a-z_]+)\.bak\.\$\(date`)
found := map[string]bool{}
for _, m := range prefixRe.FindAllStringSubmatch(src, -1) {
found[m[1]] = true
}
if len(found) == 0 {
t.Fatal("deploy.sh 里没找到任何 .bak.$(date …) 备份目录创建点(判据需更新)")
}
for prefix := range found {
if !strings.Contains(body, "prune_backups "+prefix) {
t.Errorf("deploy.sh 会创建 %s.bak.<时间戳> 目录,但 prune_all_backups 不裁它 "+
"—— 它就是一个无上限增长的备份泄漏点(实测 plugins.bak 涨到 36 个)", prefix)
}
}
// ③ 行为验证:用真函数裁 plugins 前缀,最新 5 份必须留下、更旧的删掉
base := t.TempDir()
k := strings.Index(src, "prune_backups()")
kj := strings.Index(src[k:], "\n}\n")
fnBody := src[k : k+kj+3]
fnBody = strings.Replace(fnBody, `local base="/etc/llmsproxy"`, `local base="`+base+`"`, 1)
keepRe := regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
km := keepRe.FindStringSubmatch(src)
if km == nil {
t.Fatal("deploy.sh 不再设置 KEEP_BACKUPS")
}
script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" +
km[1] + "\n" + fnBody + "\nprune_backups plugins\n"
sp := filepath.Join(base, "fn.sh")
if err := os.WriteFile(sp, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
for _, ts := range []string{
"20260101120000", "20260201120000", "20260301120000", "20260401120000",
"20260501120000", "20260601120000", "20260701120000", "20260801120000",
} {
if err := os.MkdirAll(filepath.Join(base, "plugins.bak."+ts), 0o755); err != nil {
t.Fatal(err)
}
}
// adapters 前缀**不该被动**(防止误删别的前缀)
if err := os.MkdirAll(filepath.Join(base, "adapters.bak.20260101120000"), 0o755); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("bash", sp).CombinedOutput(); err != nil {
t.Fatalf("prune_backups plugins failed: %v\n%s", err, out)
}
entries, err := os.ReadDir(base)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, e := range entries {
if e.IsDir() {
got[e.Name()] = true
}
}
if !got["plugins.bak.20260801120000"] {
t.Error("最新的 plugins 备份被删了;裁剪必须从最旧的一端开始")
}
if got["plugins.bak.20260101120000"] {
t.Error("最旧的 plugins 备份存活;裁剪没生效")
}
if !got["adapters.bak.20260101120000"] {
t.Error("裁 plugins 时误删了 adapters 备份 —— 前缀参数必须严格限定范围")
}
// ④ 未知前缀必须响亮失败(防误用:传空串会删掉整个目录)
bad := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" +
km[1] + "\n" + fnBody + "\nprune_backups \"\"\n"
bp := filepath.Join(base, "bad.sh")
if err := os.WriteFile(bp, []byte(bad), 0o600); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("bash", bp).CombinedOutput(); err == nil {
t.Fatalf("prune_backups 接受空前缀(危险:会删整个目录),输出: %s", out)
}
}

View File

@ -1,749 +0,0 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"llmsproxy/internal/config"
)
// Model discovery: turn "here is a URL and a key" into a configured source.
//
// The hard part is not the GET /models call — it is guessing which URL the
// upstream actually serves. This gateway builds request paths as
// base_url + adapter endpoint (openai contributes "/chat/completions"), so a
// base_url that already ends in "/v1" yields "/v1/chat/completions" while a
// base_url of "/v1" plus our default image path yields the broken
// "/v1/v1/images/generations". Vendors disagree on whether the version segment
// belongs to the base or to the path, and there is no header that says which.
//
// So discovery does not trust the URL it is given. It tries a small, ordered
// set of candidates, records which one answered, and reports that back as the
// normalized base_url + adapter endpoint pair. A caller who typed the wrong
// form gets a working source anyway, plus the correction needed to make it
// permanent.
//
// What is deliberately NOT here:
//
// - No guessing of model capabilities from the name. "vision" in an id does
// not make a model accept images, and marking an image-only endpoint as
// kind:"chat" makes every AUTO request fail on it. Kind stays "chat" unless
// the upstream's own metadata says otherwise (see discoveredModelKind).
// - No live probe per model. A single-token completion per model would cost
// real money across a 34-model catalog and takes minutes. /models is the
// cheap, authoritative-enough signal for "this id is served here"; whether
// it actually answers stays for the normal request path to discover.
//
// The one thing that IS verified live is reachability, because a source that
// 404s is not worth writing to disk.
const (
// discoveryTimeout bounds the whole probe. Vendors are routinely slow on
// the first request to an uncached endpoint; 20s is enough for a healthy
// one and short enough that the UI is not left hanging.
discoveryTimeout = 20 * time.Second
// discoveryModelsPath is the OpenAI-compatible listing endpoint. Nearly
// every vendor proxies it verbatim, including the ones whose chat path
// differs.
discoveryModelsPath = "/v1/models"
// discoveryMaxBody caps the listing response. A catalog with thousands of
// models is still far under this; anything larger is a captive portal or
// an error page, not a model list.
discoveryMaxBody = 8 << 20
)
// discoveredModel is one entry of a parsed /models response, kept in the
// upstream's own terms. The upstream is the only authority on what it serves.
type discoveredModel struct {
ID string
Kind string // "chat" | "image"
Meta map[string]interface{}
}
// discoveryResult reports what a probe found, including the case where nothing
// worked — the failure text is what the operator needs, so it must name every
// URL that was tried rather than just the last one.
type discoveryResult struct {
BaseURL string `json:"base_url"`
Endpoint string `json:"endpoint"`
// ImageEndpoint is reported separately because the gateway composes it
// differently: Provider.ImageURL is BaseURL + ImageEndpoint(), and the
// default ImageEndpoint is the ABSOLUTE path "/v1/images/generations".
// Chat's default is the relative "/chat/completions". So a base_url that
// already ends in /v1 produces a working chat URL and a broken image URL
// ("/v1/v1/images/generations") — which is exactly the 404 seen on the
// first vela configuration, and which discovery has to correct.
ImageEndpoint string `json:"image_endpoint"`
Adapter string `json:"adapter"`
ModelsPath string `json:"models_path"`
Models []string `json:"models"`
ImageModels []string `json:"image_models"`
Tried []string `json:"tried"`
LatencyMS int64 `json:"latency_ms"`
Note string `json:"note,omitempty"`
}
// MarshalJSON guarantees the two list fields are always JSON arrays, never
// null. The WebUI concatenates them directly, and "null" renders as the string
// "undefined" in every row — a failure that only shows up in a browser and not
// in a JSON schema check.
//
// Relying on the constructor to initialize the slices is not enough: the zero
// value of this struct is a legitimate thing for a caller to build, and a
// future code path that forgets the initializer would ship a UI bug silently.
func (r discoveryResult) MarshalJSON() ([]byte, error) {
type alias discoveryResult // avoid recursion
out := alias(r)
if out.Models == nil {
out.Models = []string{}
}
if out.ImageModels == nil {
out.ImageModels = []string{}
}
if out.Tried == nil {
out.Tried = []string{}
}
return json.Marshal(out)
}
// discoveryCandidate is one (base_url, models-path) guess. Order matters: the
// first candidate is the one reported as normalized when several work, because
// it is the one that keeps base_url free of a version segment and therefore
// composes correctly with the adapter's chat path.
type discoveryCandidate struct {
baseURL string
modelsPath string
// prefer ranks candidates that are more likely to be the vendor's real
// path. A 200 from a lower-preference candidate is still accepted, but
// only after every higher-preference one failed — see DiscoverModels.
prefer int
}
// discoveryFailure carries the HTTP status the caller should answer with.
//
// The status is part of the error rather than left to the handler because the
// whole point of classifying a failure is that the operator must not have to
// guess whether the key, the endpoint, or the vendor is at fault.
type discoveryFailure struct {
msg string
status int
}
func (e *discoveryFailure) Error() string { return e.msg }
// StatusOrDefault reports the intended HTTP status.
func (e *discoveryFailure) StatusOrDefault() int {
if e.status == 0 {
return http.StatusBadGateway
}
return e.status
}
// discoveryCandidates enumerates the URL forms worth trying for a raw input.
//
// The ranking encodes a real observation rather than a guess: the amr-link
// endpoint serves its catalog at "/v1/models" (the form an SDK example shows)
// from a base the operator pasted WITH the "/v1", while several other vendors
// mount it at the host root. Both are probed; the one that answers is reported
// back as a normalized base/endpoint pair.
//
// What we are really deciding is the base_url to persist. The safest one keeps
// the version segment on the ENDPOINT, because openai's adapter endpoint is
// "/chat/completions" and the two are concatenated: base "https://h" +
// endpoint "/v1/chat/completions" is correct, while leaving "/v1" on the base
// doubles the default image path into "/v1/v1/images/generations" — which is
// exactly the 404 observed on the first vela configuration attempt.
func discoveryCandidates(raw string) []discoveryCandidate {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return nil
}
// Deduplicate on the URL that will actually be requested, not on the
// (base, path) pair. base "https://h/v1" + path "/models" and base "https://h"
// + path "/v1/models" both request the same address, and listing it twice in
// an error message makes the operator think two things were tried.
seenURL := map[string]bool{}
seenPair := map[string]bool{}
var out []discoveryCandidate
add := func(base, path string, prefer int) {
pair := base + "|" + path
if seenPair[pair] || seenURL[base+path] {
return
}
seenPair[pair] = true
seenURL[base+path] = true
out = append(out, discoveryCandidate{baseURL: base, modelsPath: path, prefer: prefer})
}
stripped := trimVersionSuffix(raw)
hasVersion := stripped != raw
// Both catalog paths are always tried, whichever form the operator typed.
//
// The earlier version offered "/models" only when the base already carried a
// version segment. That left a bare host ("https://api.vendor.com") with a
// SINGLE candidate and therefore no fallback at all — so a vendor serving
// its catalog at the bare "/models" was reported as unreachable. Every
// test used a "/v1"-suffixed base and so never saw it; siliconflow's
// "/v1/models" happens to answer, which is why the gap survived review.
//
// Preference only decides WHICH URL is reported when several work, never
// which ones are attempted.
//
// Order matters: a base that already ends in a version segment must be
// paired with the RELATIVE "/models" first, because pairing it with
// "/v1/models" produces "…/v1/v1/models" — a URL that is 404 by construction.
// Trying it first would spend a round trip on a certain miss and, worse,
// make the reported "tried" list lead with nonsense.
if hasVersion {
add(raw, "/models", 0)
add(raw, discoveryModelsPath, 1) // the doubled form; kept only as a last resort
add(stripped, "/models", 2)
add(stripped, discoveryModelsPath, 3)
} else {
add(raw, discoveryModelsPath, 0)
add(raw, "/models", 1)
}
// Stable insertion sort by preference; add() above is not ordered.
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j].prefer < out[j-1].prefer; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
// trimVersionSuffix removes a trailing "/v1" (or /v2, /v1beta, ...) so the
// host root can be probed on its own.
func trimVersionSuffix(base string) string {
i := strings.LastIndex(base, "/")
if i < 0 {
return base
}
if !isVersionSegment(base[i+1:]) {
return base
}
return base[:i]
}
// versionSegment returns the leading path segment of a models path when that
// segment is a version marker ("/v1/models" → "/v1", "/models" → ""). It is the
// piece that must move between base_url and endpoint so the two compose.
func versionSegment(modelsPath string) string {
parts := strings.Split(strings.Trim(modelsPath, "/"), "/")
if len(parts) != 2 || !isVersionSegment(parts[0]) {
return ""
}
return "/" + parts[0]
}
// isVersionSegment reports whether a path segment looks like "v1", "v2",
// "v1beta" — and not like a real path name such as "vision" or a wildcard
// path such as "v1x".
//
// The rule is: a leading "v", then digits, then an OPTIONAL suffix drawn from
// the prerelease markers vendors actually use. A free-form alphabetic tail was
// tried and rejected — it classifies "v1x" as a version, and that is how a
// templated path segment would get stripped off a real base URL.
func isVersionSegment(seg string) bool {
if len(seg) < 2 || seg[0] != 'v' {
return false
}
i := 1
digits := 0
for ; i < len(seg); i++ {
if seg[i] < '0' || seg[i] > '9' {
break
}
digits++
}
if digits == 0 {
return false
}
suffix := strings.ToLower(seg[i:])
if suffix == "" {
return true
}
for _, marker := range []string{"alpha", "beta", "preview", "dev", "rc"} {
if suffix == marker || strings.HasPrefix(suffix, "-"+marker) {
return true
}
}
return false
}
// composeEndpoint derives the chat endpoint to persist, given the models path
// that actually answered, the base_url being persisted, and the base the
// operator typed.
//
// The rule is a single invariant: exactly one version segment must end up in
// base_url + endpoint. The adapter contributes "/chat/completions" and the
// default image path contributes "/v1/images/generations", so a version left on
// BOTH breaks one of them — and one left on NEITHER sends chat to the wrong
// path on vendors that require the segment.
func composeEndpoint(modelsPath, persistedBase, typedBase string) string {
// The version segment currently present on the persisted base.
onBase := ""
if b := trimVersionSuffix(persistedBase); b != persistedBase {
onBase = persistedBase[len(b):]
}
// The version segment present in the models path that answered.
inPath := versionSegment(modelsPath)
// The version segment actually WORKED: prefer the models path's, then the
// one the operator typed.
version := inPath
if version == "" && onBase == "" {
if b := trimVersionSuffix(typedBase); b != typedBase && typedBase != "" {
version = typedBase[len(b):]
}
}
if version == "" {
return "" // adapter default already composes
}
return version + "/chat/completions"
}
// composeImageEndpoint derives the image endpoint to persist.
//
// Provider.ImageURL is `base_url + ImageEndpoint()`, and the built-in default
// is the absolute path "/v1/images/generations" — unlike chat's relative
// "/chat/completions". So the two defaults do NOT agree about who owns the
// version segment, which is the trap this function exists to close:
//
// base "https://h" + "/v1/images/generations" → correct
// base "https://h/v1" + "/v1/images/generations" → "/v1/v1/…" 404
// base "https://h/v1" + "/images/generations" → "/v1/images/generations" correct
//
// The second row is not a theoretical guess. Measured against the real
// amr-link endpoint with base_url "https://amr-link.open-design.ai/v1":
//
// …/v1/images/generations → 200 (the image is then generated; that call
// takes minutes, which is the upstream's own
// latency and not a routing problem)
// …/v1/v1/images/generations → 404
// …/images/generations → 404
//
// Leaving the default in place when the base already ends in a version is
// therefore always wrong for such an upstream, and the version-relative form
// is the one that composes.
func composeImageEndpoint(persistedBase string) string {
if b := trimVersionSuffix(persistedBase); b != persistedBase {
return "/images/generations"
}
return ""
}
// DiscoverModels probes rawURL for a model catalog. apiKey is sent as a bearer
// token; some gateways accept an unauthenticated listing, so an empty key is
// allowed and simply omits the header.
//
// It returns on the first candidate that yields a parseable catalog, so the
// reported latency is that of the working path, not the sum of the failures.
func DiscoverModels(ctx context.Context, rawURL, apiKey string) (*discoveryResult, error) {
cands := discoveryCandidates(rawURL)
if len(cands) == 0 {
return nil, fmt.Errorf("base url required")
}
client := &http.Client{Timeout: discoveryTimeout}
var tried []string
var firstErr string
var authErr string
typedBase := strings.TrimRight(strings.TrimSpace(rawURL), "/")
var lastErr string
for _, c := range cands {
full := c.baseURL + c.modelsPath
tried = append(tried, full)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
firstErr = err.Error()
continue
}
req.Header.Set("Accept", "application/json")
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
lastErr = classifyTransport(err)
if lastErr == "" {
lastErr = err.Error()
}
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, discoveryMaxBody))
resp.Body.Close()
took := time.Since(start)
if readErr != nil {
firstErr = readErr.Error()
continue
}
if resp.StatusCode != http.StatusOK {
// An auth rejection is the operator's key being wrong, not the
// upstream being down. Keep it: it is the single most common
// failure and 502 would send them to the wrong place.
if af := classifyAuthStatus(resp.StatusCode, body); af != "" {
authErr = af
continue
}
firstErr = fmt.Sprintf("%s returned %d", full, resp.StatusCode)
continue
}
models, err := parseModelCatalog(body)
if err != nil {
firstErr = fmt.Sprintf("%s: %v", full, err)
continue
}
res := &discoveryResult{
BaseURL: c.baseURL,
ModelsPath: c.modelsPath,
Adapter: "openai",
Models: []string{},
ImageModels: []string{},
Tried: append([]string{}, tried...),
LatencyMS: took.Milliseconds(),
}
// Persist a base/endpoint pair that composes correctly. The adapter's
// own endpoint is "/chat/completions", so any version segment has to be
// carried on the endpoint rather than left on the base (where it doubles
// the default image path into "/v1/v1/images/generations" — the exact
// 404 seen on the first vela configuration attempt).
res.Endpoint = composeEndpoint(c.modelsPath, c.baseURL, typedBase)
res.ImageEndpoint = composeImageEndpoint(c.baseURL)
// If the operator typed a version segment and we are persisting a base
// that no longer has it, the endpoint above is what restores it. If the
// base kept the segment, the adapter default is already correct.
seen := map[string]bool{}
for _, m := range models {
if m.ID == "" || seen[m.ID] {
continue
}
seen[m.ID] = true
if m.Kind == "image" {
res.ImageModels = append(res.ImageModels, m.ID)
} else {
res.Models = append(res.Models, m.ID)
}
}
sort.Strings(res.Models)
sort.Strings(res.ImageModels)
if len(res.Models) == 0 && len(res.ImageModels) == 0 {
firstErr = fmt.Sprintf("%s: catalog contained no usable model ids", full)
continue
}
if c.baseURL != typedBase {
res.Note = fmt.Sprintf("base url normalized from %s to %s; endpoint set to %q so it composes with the adapter",
typedBase, c.baseURL, res.Endpoint)
}
return res, nil
}
return nil, buildDiscoveryError(tried, authErr, firstErr, lastErr)
}
// classifyAuthStatus turns a 401/403 from the catalog endpoint into a message
// that names the credential as the cause.
//
// This distinction is the whole point: a wrong key and an unreachable vendor
// are the two most common discovery failures, and they need opposite actions
// (re-type the key vs. go check the vendor). Reporting both as "upstream
// error" makes the operator guess.
func classifyAuthStatus(status int, body []byte) string {
if status != http.StatusUnauthorized && status != http.StatusForbidden {
return ""
}
snippet := strings.TrimSpace(string(body))
// Vendors put the reason in wildly different shapes; a compact quote is
// more useful than a parse attempt that fails on most of them.
snippet = strings.Join(strings.Fields(snippet), " ")
if len(snippet) > 200 {
snippet = snippet[:200] + "…"
}
return fmt.Sprintf("upstream rejected the api key (HTTP %d): %s — "+
"check the key, not the endpoint", status, snippet)
}
// classifyTransport turns low-level network failures into something an
// operator can act on. An empty result means "no specific advice", and the
// caller keeps the raw error.
func classifyTransport(err error) string {
if err == nil {
return ""
}
msg := err.Error()
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "no such host"):
return "hostname does not resolve: " + msg
case strings.Contains(lower, "certificate"), strings.Contains(lower, "tls"):
return "TLS verification failed: " + msg
case strings.Contains(lower, "connection refused"):
return "connection refused: " + msg
}
return ""
}
// buildDiscoveryError assembles the final error, preferring the most specific
// diagnosis available. Every URL tried is listed: an operator staring at a
// failed probe needs to know the search actually happened, not just the last
// attempt.
func buildDiscoveryError(tried []string, authErr, firstErr, lastErr string) error {
triedPart := "no model catalog found"
if len(tried) > 0 {
triedPart = "no model catalog found; tried " + strings.Join(tried, ", ")
}
if authErr != "" {
return &discoveryFailure{status: http.StatusUnauthorized, msg: authErr + " (" + triedPart + ")"}
}
detail := firstErr
if detail == "" {
detail = lastErr
}
if detail == "" {
return errors.New(triedPart)
}
if lastErr != "" && lastErr != firstErr {
detail += " (last: " + lastErr + ")"
}
return &discoveryFailure{status: http.StatusBadGateway, msg: triedPart + "; " + detail}
}
// upstreamModel is the subset of a /models entry this code reads. Vendors add
// fields freely and rename the nesting; only id is universal, and the modality
// hints below are read opportunistically.
type modelCaps struct {
ImageGeneration bool `json:"image_generation"`
Vision bool `json:"vision"`
}
type upstreamModel struct {
ID string `json:"id"`
Object string `json:"object"`
Kind string `json:"kind"`
Type string `json:"type"`
Modality string `json:"modality"`
Architecture struct {
InputModalities []string `json:"input_modalities"`
OutputModalities []string `json:"output_modalities"`
} `json:"architecture"`
Capabilities modelCaps `json:"capabilities"`
// Metadata is kept as raw JSON on purpose. The real amr-link catalog nests
// cost/capability/context_limit here, and a typed struct would silently
// discard every key this build does not know about — the operator would
// then lose the context limit their vendor published. Raw also lets
// input_modalities/output_modalities be read without a second type.
Metadata json.RawMessage `json:"metadata"`
}
// metadataFields lists the keys worth surfacing in the UI. They are copied
// through verbatim and nothing is inferred from them; an upstream that sends
// no context limit must leave that absent rather than have this code write 0.
var metadataFields = []string{
"context_length", "context_window", "max_context_length",
"context_limit", "context_budget", "cost", "capability",
}
// parseModelCatalog reads the OpenAI list shape {"data":[...]} and the bare
// array shape some gateways return. A response that is valid JSON but has
// neither shape is an error, not an empty catalog: reporting "0 models" for a
// login page or a rate-limit JSON would silently create an empty source.
func parseModelCatalog(body []byte) ([]discoveredModel, error) {
// "null" unmarshals into a nil slice without error, and an HTML login page
// fails to unmarshal at all. Both must be errors.
trimmed := strings.TrimSpace(string(body))
if trimmed == "" || trimmed == "null" {
return nil, fmt.Errorf("empty response")
}
var wrapper struct {
Data []upstreamModel `json:"data"`
Models []upstreamModel `json:"models"`
}
if err := json.Unmarshal(body, &wrapper); err == nil {
if len(wrapper.Data) > 0 {
return toDiscovered(wrapper.Data), nil
}
if len(wrapper.Models) > 0 {
return toDiscovered(wrapper.Models), nil
}
// A well-formed but empty catalog is a legitimate answer from a key
// that has no models provisioned. Distinguish it from "not a catalog"
// by checking the shape actually parsed.
var probe map[string]json.RawMessage
if json.Unmarshal(body, &probe) == nil {
if _, ok := probe["data"]; ok {
return nil, nil
}
if _, ok := probe["models"]; ok {
return nil, nil
}
}
}
var bare []upstreamModel
if err := json.Unmarshal(body, &bare); err == nil {
return toDiscovered(bare), nil
}
return nil, fmt.Errorf("response is not a model catalog")
}
func toDiscovered(in []upstreamModel) []discoveredModel {
out := make([]discoveredModel, 0, len(in))
for _, m := range in {
out = append(out, discoveredModel{
ID: m.ID,
Kind: discoveredModelKind(m),
Meta: modelMeta(m),
})
}
return out
}
// discoveredModelKind trusts the upstream's own modality fields and nothing
// else. It never infers from the model NAME: ids like "gpt-image-1" or
// "ideogram-v2" appear on catalogs that also serve chat, and a wrong kind
// poisons the AUTO chain (an image-only id selected for a chat request fails
// every tier behind it).
func discoveredModelKind(m upstreamModel) string {
modIn := append(append([]string{}, m.Architecture.InputModalities...),
modalityList(m.Metadata, "input_modalities")...)
modOut := append(append([]string{}, m.Architecture.OutputModalities...),
modalityList(m.Metadata, "output_modalities")...)
has := func(list []string, want string) bool {
for _, v := range list {
if strings.EqualFold(strings.TrimSpace(v), want) {
return true
}
}
return false
}
if m.Capabilities.ImageGeneration || has(modOut, "image") {
return "image"
}
if has(modIn, "image") || has(modIn, "file") || has(modIn, "audio") || m.Capabilities.Vision {
return "chat" // multimodal input still means a chat model
}
// Some catalogs encode modality as a top-level string instead of a list.
// Only the image-ish spellings count: "chat" here means the upstream said
// chat, and treating that as "unknown" would ignore its statement.
switch strings.ToLower(strings.TrimSpace(m.Kind + " " + m.Modality + " " + m.Type)) {
case "image", "text-to-image", "image_generation", "text_to_image":
return "image"
}
return "chat"
}
// modalityList reads one array-of-strings field out of a raw JSON object,
// tolerating both absence and a non-array value.
func modalityList(raw json.RawMessage, field string) []string {
if len(raw) == 0 {
return nil
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return nil
}
v, ok := obj[field]
if !ok {
return nil
}
var out []string
if err := json.Unmarshal(v, &out); err != nil {
return nil
}
return out
}
// modelMeta carries through the fields an operator wants to see in the UI
// without this code inventing them. The upstream nests most of them under
// "metadata" (the real amr-link catalog does) while some put the same keys at
// the top level, so both are read and top level wins on a tie. Only keys the
// upstream actually sent are copied, so an absent context limit stays absent
// instead of becoming 0.
func modelMeta(m upstreamModel) map[string]interface{} {
meta := map[string]interface{}{}
copyFields := func(raw json.RawMessage) {
if len(raw) == 0 {
return
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return
}
for _, k := range metadataFields {
if v, ok := obj[k]; ok {
if dec := rawOrString(v); dec != nil {
meta[k] = dec
}
}
}
}
copyFields(m.Metadata) // nested first
copyFields(mustJSON(m)) // top level wins
if len(meta) == 0 {
return nil
}
return meta
}
// rawOrString keeps numeric metadata as a number and everything else as its
// decoded string, so the UI can sort on a cost or context length instead of
// receiving "1048000" and having to coerce it.
func rawOrString(raw json.RawMessage) interface{} {
var num json.Number
if err := json.Unmarshal(raw, &num); err == nil {
if f, err := num.Float64(); err == nil {
return f
}
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
var any interface{}
if err := json.Unmarshal(raw, &any); err == nil {
return any
}
return nil
}
func mustJSON(v interface{}) []byte {
b, _ := json.Marshal(v)
return b
}
// modelsToConfig converts discovery output into config.Model entries, applying
// the same priority convention the UI uses: chat models the operator is
// likely to want first get a lower number, and image models sort last because
// they are unreachable from /v1/chat/completions.
func modelsToConfig(chat, image []string) []config.Model {
out := make([]config.Model, 0, len(chat)+len(image))
for _, id := range chat {
out = append(out, config.Model{ID: id, Priority: 50, Kind: "chat"})
}
for _, id := range image {
out = append(out, config.Model{ID: id, Priority: 70, Kind: "image"})
}
return out
}

View File

@ -1,403 +0,0 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"io"
"llmsproxy/internal/config"
"net/http"
"strings"
"time"
)
// HTTP surface for model discovery.
//
// Two endpoints, deliberately separate:
//
// POST /api/sources/discover — probe a URL+key, return the catalog.
// Nothing is written.
// POST /api/sources/discover/apply — probe, then write the source.
//
// The split exists because discovery mutates nothing and is therefore safe to
// call repeatedly while the operator is still typing a key, whereas apply
// rewrites credentials and the AUTO chain's inputs. Making them one endpoint
// with a flag would mean a "just check it" click can silently create a source.
//
// Both are admin-only, matching /api/sources: the request body carries an API
// key, and a model catalog reveals which upstreams an operator uses.
// discoverRequest is the body of both endpoints.
type discoverRequest struct {
// BaseURL is what the operator pasted. It may or may not include a
// version segment; discovery works that out rather than making the
// caller know this gateway's path-composition rules.
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
// Name is required for apply, ignored for discover. It defaults from the
// base URL's host when omitted, because "https://api.vendor.com/v1" is a
// better starting name than "new-source".
Name string `json:"name"`
// Adapter is optional. Discovery assumes "openai" because that is what a
// /v1/models catalog implies; a non-OpenAI upstream (Anthropic, Gemini)
// has no such endpoint, so discovery will legitimately fail for those and
// the operator configures them by hand.
Adapter string `json:"adapter"`
// Temperature and MaxConcurrent are carried into the written source.
Temperature float64 `json:"temperature"`
MaxConcurrent int `json:"max_concurrent"`
// ReplaceModels drops the source's existing model list before applying the
// discovered one. It defaults to true for a NEW source and is required to
// be spelled out for an existing one, so a re-discovery cannot silently
// delete hand-tuned priorities.
ReplaceModels *bool `json:"replace_models,omitempty"`
// Select narrows what gets written. Empty means everything discovered.
// Ids that are not in the catalog are an error rather than a silent skip:
// a typo here would produce a source that 404s on every request.
Include []string `json:"include,omitempty"`
Exclude []string `json:"exclude,omitempty"`
}
// discoverResponse is the JSON the WebUI binds to.
type discoverResponse struct {
OK bool `json:"ok"`
// Normalized source fields, ready to be echoed back to /api/sources.
BaseURL string `json:"base_url"`
Adapter string `json:"adapter"`
Models []string `json:"models"`
ImageModels []string `json:"image_models"`
// Endpoint is set only when discovery had to move a version segment.
Endpoint string `json:"endpoint"`
// ImageEndpoint is required whenever base_url carries a version segment:
// the gateway's default image path is the ABSOLUTE "/v1/images/generations"
// while chat's default is the relative "/chat/completions", so the same
// base_url that yields a working chat URL yields a 404 image URL.
ImageEndpoint string `json:"image_endpoint"`
// Diagnostics for the operator.
ModelsPath string `json:"models_path"`
Tried []string `json:"tried"`
LatencyMS int64 `json:"latency_ms"`
Note string `json:"note,omitempty"`
Warnings []string `json:"warnings,omitempty"`
// Set by apply only.
SourceName string `json:"source_name,omitempty"`
Applied bool `json:"applied,omitempty"`
}
func (g *Gateway) handleSourceDiscoverAPI(w http.ResponseWriter, r *http.Request) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
apply := strings.HasSuffix(r.URL.Path, "/apply")
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
"use POST with {\"base_url\":…,\"api_key\":…}")
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "cannot read body")
return
}
var req discoverRequest
if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
req.BaseURL = strings.TrimSpace(req.BaseURL)
if req.BaseURL == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "base_url required")
return
}
if req.Adapter == "" {
req.Adapter = "openai"
}
// Bounded so a hung upstream cannot pin the handler; discovery has its own
// per-request timeout on top.
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
res, err := DiscoverModels(ctx, req.BaseURL, req.APIKey)
if err != nil {
// A rejected key must not be reported as an upstream outage: the two
// need opposite actions from the operator, and 502 sends them to the
// wrong one.
status := http.StatusBadGateway
var f *discoveryFailure
if errors.As(err, &f) {
status = f.StatusOrDefault()
}
writeError(w, status, "discover_failed", err.Error())
return
}
chat, image, warns, err := selectModels(res.Models, res.ImageModels, req.Include, req.Exclude)
if err != nil {
// 400: the operator asked for a model the catalog does not have.
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
out := &discoverResponse{
OK: true,
BaseURL: res.BaseURL,
Adapter: req.Adapter,
Models: chat,
ImageModels: image,
Endpoint: res.Endpoint,
ImageEndpoint: res.ImageEndpoint,
ModelsPath: res.ModelsPath,
Tried: res.Tried,
LatencyMS: res.LatencyMS,
Note: res.Note,
Warnings: warns,
}
if len(chat) == 0 && len(image) == 0 {
out.Warnings = append(out.Warnings,
"every discovered model was excluded; applying this would create a source with no models")
}
if !apply {
writeJSON(w, http.StatusOK, out)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = defaultSourceName(req.BaseURL)
}
if name == "" {
writeError(w, http.StatusBadRequest, "invalid_request",
"name required: cannot derive one from base_url")
return
}
if strings.ContainsAny(name, "/\\ \t") || name == "." || name == ".." {
writeError(w, http.StatusBadRequest, "invalid_request",
"source name must not contain slashes or whitespace")
return
}
exists := g.sourceExists(name)
if exists && req.ReplaceModels == nil {
// Refusing here is deliberate. Re-running discovery on an existing
// source and having it silently replace a hand-tuned model list —
// including priorities chosen for the AUTO chain — is the kind of
// "helpful" behaviour that loses an afternoon.
writeError(w, http.StatusConflict, "conflict",
"source "+name+" already exists; set replace_models=true to overwrite its model list, "+
"or choose a different name")
return
}
// Build the source directly rather than round-tripping through the
// /api/sources payload: resolveAPIKey carries the "an empty api_key means
// keep the stored one" rule, and going through a second decoder here would
// duplicate (and eventually diverge from) that logic.
src := config.Source{
Name: name,
BaseURL: res.BaseURL,
APIKey: g.resolveAPIKey(name, req.APIKey, exists),
Adapter: req.Adapter,
Endpoint: res.Endpoint,
ImageEndpoint: res.ImageEndpoint,
Models: modelsToConfig(chat, image),
Temperature: req.Temperature,
}
if req.MaxConcurrent > 0 {
src.MaxConcurrent = req.MaxConcurrent
}
// An existing source may carry fields discovery does not know about
// (proxy_url, headers, api_key_env, timeout). Rebuilding from scratch would
// drop them, so they are inherited unless the request overrides them.
if exists {
old := g.sourceByName(name)
if old != nil {
src.ProxyURL = old.ProxyURL
src.APIKeyEnv = old.APIKeyEnv
src.Timeout = old.Timeout
src.QueueTimeout = old.QueueTimeout
src.Headers = old.Headers
src.Meta = old.Meta
src.MaxTokens = old.MaxTokens
src.RPM = old.RPM
// image_endpoint is NOT inherited when discovery supplied one:
// the stored value was correct for the OLD base_url, and the base
// is exactly what may have changed. Inheriting it would keep a
// stale value that 404s against the new base.
if res.ImageEndpoint == "" {
src.ImageEndpoint = old.ImageEndpoint
}
if src.Adapter == "" {
src.Adapter = old.Adapter
}
if req.MaxConcurrent <= 0 {
src.MaxConcurrent = old.MaxConcurrent
}
// replace_models=false keeps the curated list. Discovery still
// refreshed base_url/endpoint/credential, which is the point of
// re-running it.
if req.ReplaceModels != nil && !*req.ReplaceModels {
src.Models = old.Models
out.Warnings = append(out.Warnings,
"model list left unchanged (replace_models=false)")
}
}
}
if err := g.core.AddSource(src); err != nil {
writeError(w, http.StatusBadRequest, "source_error", err.Error())
return
}
out.SourceName = name
out.Applied = true
writeJSON(w, http.StatusOK, out)
}
// selectModels applies the include/exclude filters.
//
// An include entry the catalog does not have is an error: silently dropping it
// produces a source that looks configured and 404s at request time, which is
// far harder to diagnose than a rejected apply. An exclude entry that is not
// present is fine — the operator is allowed to be defensive.
func selectModels(chat, image, include, exclude []string) ([]string, []string, []string, error) {
have := map[string]bool{}
for _, m := range chat {
have[m] = true
}
for _, m := range image {
have[m] = true
}
if len(include) > 0 {
var missing []string
for _, want := range include {
if !have[want] {
missing = append(missing, want)
}
}
if len(missing) > 0 {
return nil, nil, nil, &discoverError{
msg: "these models are not in the upstream catalog: " + strings.Join(missing, ", "),
}
}
keep := map[string]bool{}
for _, want := range include {
keep[want] = true
}
var c, i []string
for _, m := range chat {
if keep[m] {
c = append(c, m)
}
}
for _, m := range image {
if keep[m] {
i = append(i, m)
}
}
chat, image = c, i
}
if len(exclude) > 0 {
drop := map[string]bool{}
for _, m := range exclude {
drop[m] = true
}
var c, i []string
for _, m := range chat {
if !drop[m] {
c = append(c, m)
}
}
for _, m := range image {
if !drop[m] {
i = append(i, m)
}
}
chat, image = c, i
}
// chat and image must stay non-nil: the JSON contract forbids null here.
if chat == nil {
chat = []string{}
}
if image == nil {
image = []string{}
}
var warns []string
if len(chat) == 0 && len(image) == 0 {
// Caller can only get here by excluding everything, or from a catalog
// that was already empty. Either way "silence" would read as success.
warns = append(warns,
"no models selected; applying this would create a source that cannot serve any request")
return chat, image, warns, nil
}
if len(image) > 0 && len(chat) == 0 {
warns = append(warns,
"every model is an image model; AUTO chat requests have nothing to select")
}
if len(chat) > 0 && len(image) > 0 {
warns = append(warns,
"image models are only reachable through /v1/images/generations, not AUTO chat")
}
return chat, image, warns, nil
}
// discoverError is a client mistake rather than an upstream failure, which is
// why it gets its own type instead of fmt.Errorf.
type discoverError struct{ msg string }
func (e *discoverError) Error() string { return e.msg }
// defaultSourceName derives a source name from a base URL host, stripping the
// version segment and any "api." prefix. It returns "" for input that is not a
// URL at all — the caller must not fall back to echoing garbage into a source
// name, since the name becomes a config key and an API path segment.
func defaultSourceName(baseURL string) string {
s := strings.TrimSpace(baseURL)
if !strings.Contains(s, "://") {
return ""
}
s = s[strings.Index(s, "://")+3:]
if i := strings.IndexAny(s, "/?#"); i >= 0 {
s = s[:i]
}
if i := strings.LastIndex(s, ":"); i > 0 && !strings.Contains(s[i+1:], "]") {
s = s[:i] // strip port
}
// A host must still look like one after trimming the cosmetic prefixes.
s = strings.TrimPrefix(s, "api.")
s = strings.TrimPrefix(s, "www.")
if s == "" || strings.ContainsAny(s, " \t\"'<>") {
return ""
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case r == '.' || r == '-' || r == '_':
default:
return ""
}
}
if !strings.Contains(s, ".") {
// A bare label is far more likely to be a mistake than a hostname.
return ""
}
return s
}

View File

@ -1,314 +0,0 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"llmsproxy/internal/config"
)
// upstream that answers /v1/models with the real captured catalog
func catalogUpstream(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
w.WriteHeader(http.StatusNotFound)
return
}
if got := r.Header.Get("Authorization"); got != "Bearer sk-secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(realCatalogBody))
}))
t.Cleanup(srv.Close)
return srv
}
func TestDefaultSourceName(t *testing.T) {
cases := map[string]string{
"https://api.vendor.com/v1": "vendor.com",
"https://api.openai.com/v1": "openai.com",
"https://www.vendor.com/v1": "vendor.com",
"http://127.0.0.1:8080/v1": "127.0.0.1",
"https://amr-link.open-design.ai/v1": "amr-link.open-design.ai",
"not a url at all": "",
}
for in, want := range cases {
if got := defaultSourceName(in); got != want {
t.Errorf("defaultSourceName(%q) = %q, want %q", in, got, want)
}
}
}
func TestSelectModels_IncludeUnknownIsAnError(t *testing.T) {
chat := []string{"a", "b"}
image := []string{"img"}
// Silently dropping a typo'd id would create a source that looks
// configured and 404s on every request — much worse than a rejection.
_, _, _, err := selectModels(chat, image, []string{"a", "typo"}, nil)
if err == nil {
t.Fatal("expected an error for an include id that is not in the catalog")
}
if !strings.Contains(err.Error(), "typo") {
t.Errorf("error should name the offending id, got %q", err)
}
}
func TestSelectModels_IncludeNarrows(t *testing.T) {
chat, image, _, err := selectModels([]string{"a", "b", "c"}, []string{"img1", "img2"}, []string{"b", "img1"}, nil)
if err != nil {
t.Fatal(err)
}
if len(chat) != 1 || chat[0] != "b" {
t.Errorf("chat = %v, want [b]", chat)
}
if len(image) != 1 || image[0] != "img1" {
t.Errorf("image = %v, want [img1]", image)
}
}
func TestSelectModels_ExcludeUnknownIsFine(t *testing.T) {
chat, _, _, err := selectModels([]string{"a"}, nil, nil, []string{"never-existed"})
if err != nil {
t.Errorf("excluding a model that is not present should be allowed: %v", err)
}
if len(chat) != 1 {
t.Errorf("chat = %v, want [a] unchanged", chat)
}
}
func TestSelectModels_NeverReturnsNil(t *testing.T) {
chat, image, warns, err := selectModels(nil, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
if chat == nil || image == nil {
t.Fatal("nil slices would serialize as JSON null and break the UI")
}
if len(warns) == 0 {
t.Error("an empty catalog deserves a warning, not silence")
}
}
func TestSelectModels_WarnsAboutImageOnlyCatalog(t *testing.T) {
_, _, warns, err := selectModels(nil, []string{"gpt-image-2"}, nil, nil)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(warns, " ")
if !strings.Contains(joined, "AUTO") {
t.Errorf("warnings %v should say AUTO has nothing to select", warns)
}
}
func TestSelectModels_WarnsThatImagesAreNotInAUTO(t *testing.T) {
_, _, warns, err := selectModels([]string{"a"}, []string{"img"}, nil, nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(warns, " "), "images/generations") {
t.Errorf("warnings %v should point at the image endpoint", warns)
}
}
// TestDiscoveryResultAndConfigRoundTrip checks the fields the WebUI will echo
// back actually survive into a config.Source — a mismatch here means the
// operator previews one set of values and saves another.
func TestDiscoveryResultAndConfigRoundTrip(t *testing.T) {
res := &discoveryResult{
BaseURL: "https://h",
Endpoint: "/v1/chat/completions",
Models: []string{"m1", "m2"},
ImageModels: []string{"img1"},
}
src := config.Source{
Name: "x",
BaseURL: res.BaseURL,
Endpoint: res.Endpoint,
Models: modelsToConfig(res.Models, res.ImageModels),
}
if len(src.Models) != 3 {
t.Fatalf("models = %d, want 3", len(src.Models))
}
if src.Endpoint != "/v1/chat/completions" {
t.Errorf("endpoint lost: %q", src.Endpoint)
}
var img int
for _, m := range src.Models {
if m.Kind == "image" {
img++
}
}
if img != 1 {
t.Errorf("image models = %d, want 1", img)
}
}
// TestDiscoverModels_SendsAuthorizationHeader pins the only way the feature
// works against a real vendor: without the bearer token every paid /models
// endpoint returns 401.
func TestDiscoverModels_SendsAuthorizationHeader(t *testing.T) {
var seen string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"data":[{"id":"m"}]}`))
}))
defer srv.Close()
if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-secret"); err != nil {
t.Fatal(err)
}
if seen != "Bearer sk-secret" {
t.Errorf("Authorization = %q, want %q", seen, "Bearer sk-secret")
}
}
// TestDiscoverModels_WrongKeyIsUnauthorizedNotBadGateway pins the distinction
// that decides whether the operator knows what to do next.
//
// A rejected key and an unreachable vendor are the two most common discovery
// failures, and they need opposite actions: re-type the key, versus go check the
// vendor's status page. Both used to surface as one generic error, so the
// operator had to guess.
func TestDiscoverModels_WrongKeyIsUnauthorizedNotBadGateway(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"message":"invalid api key","code":"invalid_api_key"}}`))
}))
defer srv.Close()
_, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-wrong")
if err == nil {
t.Fatal("expected a failure against a 401 endpoint")
}
var f *discoveryFailure
if !errors.As(err, &f) {
t.Fatalf("error should be classified, got %T: %v", err, err)
}
if got := f.StatusOrDefault(); got != http.StatusUnauthorized {
t.Errorf("status = %d, want %d — a wrong key must not read as an upstream outage",
got, http.StatusUnauthorized)
}
// The message has to name which credential is at fault, or the status code
// alone still sends the operator to the wrong place.
if !strings.Contains(strings.ToLower(f.Error()), "api key") {
t.Errorf("message %q should name the api key as the cause", f.Error())
}
if !strings.Contains(f.Error(), "/v1/models") {
t.Errorf("message %q should list what was tried", f.Error())
}
}
// TestDiscoverModels_UnreachableStaysBadGateway: the classification must not
// over-correct — an unresolvable host is still the vendor's or the network's
// problem, not the key's.
func TestDiscoverModels_UnreachableStaysBadGateway(t *testing.T) {
_, err := DiscoverModels(context.Background(), "https://no-such-host-xyz.invalid/v1", "k")
if err == nil {
t.Fatal("expected a failure for an unresolvable host")
}
var f *discoveryFailure
if !errors.As(err, &f) {
t.Fatalf("error should be classified, got %T", err)
}
if got := f.StatusOrDefault(); got != http.StatusBadGateway {
t.Errorf("status = %d, want %d", got, http.StatusBadGateway)
}
if !strings.Contains(strings.ToLower(f.Error()), "resolve") {
t.Errorf("message %q should say the hostname does not resolve", f.Error())
}
}
// TestDiscoveryCandidates_NoDuplicateRequests: base "https://h/v1" with path
// "/models" and base "https://h" with path "/v1/models" request the SAME URL.
// Listing it twice in a failure message reads as "two things were tried" when
// only one was.
func TestDiscoveryCandidates_NoDuplicateRequests(t *testing.T) {
seen := map[string]bool{}
for _, c := range discoveryCandidates("https://h/v1") {
url := c.baseURL + c.modelsPath
if seen[url] {
t.Errorf("candidate %q would be requested twice", url)
}
seen[url] = true
}
if len(seen) < 2 {
t.Errorf("only %d distinct URLs; the search collapsed", len(seen))
}
}
// TestDiscoverModels_FailureListsEachURLOnce is the end-to-end form of the same
// rule, on the error text an operator actually reads.
func TestDiscoverModels_FailureListsEachURLOnce(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
_, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
if err == nil {
t.Fatal("expected failure")
}
msg := err.Error()
needle := srv.URL + "/v1/models"
if n := strings.Count(msg, needle); n > 1 {
t.Errorf("URL %s appears %d times in %q", needle, n, msg)
}
}
func TestDiscoverModels_EmptyKeyOmitsHeader(t *testing.T) {
var present bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, present = r.Header["Authorization"]
_, _ = w.Write([]byte(`{"data":[{"id":"m"}]}`))
}))
defer srv.Close()
if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", ""); err != nil {
t.Fatal(err)
}
if present {
t.Error("an empty key should omit the header, not send an empty bearer")
}
}
// TestDiscoverModels_RealCatalogEndToEnd walks the actual captured response
// through the whole path and asserts the numbers an operator would see.
func TestDiscoverModels_RealCatalogEndToEnd(t *testing.T) {
srv := catalogUpstream(t)
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-secret")
if err != nil {
t.Fatalf("discovery failed: %v", err)
}
if res.BaseURL != srv.URL+"/v1" {
t.Errorf("base = %q, want the typed URL unchanged", res.BaseURL)
}
if res.Endpoint != "" {
t.Errorf("endpoint = %q, want empty (base already carries /v1)", res.Endpoint)
}
if len(res.Models) != 2 || len(res.ImageModels) != 2 {
t.Errorf("chat/image = %d/%d, want 2/2", len(res.Models), len(res.ImageModels))
}
if res.Adapter != "openai" {
t.Errorf("adapter = %q, want openai", res.Adapter)
}
// The response must be JSON the UI can consume directly.
b, _ := json.Marshal(res)
var round map[string]interface{}
if err := json.Unmarshal(b, &round); err != nil {
t.Fatal(err)
}
if round["ok"] != nil {
// discoverResponse adds ok; discoveryResult does not. Both marshal
// fine, so this is only a shape note.
_ = round
}
}

View File

@ -1,153 +0,0 @@
package gateway
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestComposeImageEndpoint_NoVersionBaseLeavesDefault is the counterpart of the
// /v1 case: when the base carries no version segment, discovery must emit "" so
// the gateway's own default "/v1/images/generations" composes.
//
// This is the shape every currently-working source uses — siliconflow, the only
// source in production with kind:image, has base_url
// "https://api.siliconflow.cn" — so writing a non-empty value here would break
// exactly the configuration that is known to work today.
func TestComposeImageEndpoint_NoVersionBaseLeavesDefault(t *testing.T) {
if got := composeImageEndpoint("https://api.siliconflow.cn"); got != "" {
t.Errorf("composeImageEndpoint = %q, want \"\" so the built-in default composes", got)
}
// And the composition it implies must be the vendor's real path.
base := "https://api.siliconflow.cn"
composed := base + "/v1/images/generations"
if !strings.HasSuffix(composed, "/v1/images/generations") {
t.Errorf("composed URL %q lost the image path", composed)
}
}
// TestDiscovery_BareHostNeedsVersionOnEndpointWhenCatalogIsVersioned
// distinguishes two cases that look identical from the outside.
//
// bare host + catalog at "/models" → chat is "/chat/completions" (the
// adapter default, no endpoint written)
// bare host + catalog at "/v1/models" → chat is "/v1/chat/completions",
// so an endpoint MUST be written
//
// The catalog path is the only evidence of whether the upstream wants the
// version segment, and getting this backwards sends every chat request to a
// path the vendor does not serve.
func TestDiscovery_BareHostNeedsVersionOnEndpointWhenCatalogIsVersioned(t *testing.T) {
cases := []struct {
name string
catalogPath string
wantEndpoint string
wantImage string
}{
{
name: "catalog at the bare /models needs no endpoint",
catalogPath: "/models",
wantEndpoint: "",
wantImage: "",
},
{
name: "catalog at /v1/models requires /v1 on the chat endpoint",
catalogPath: "/v1/models",
wantEndpoint: "/v1/chat/completions",
wantImage: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != tc.catalogPath {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
}))
defer srv.Close()
res, err := DiscoverModels(context.Background(), srv.URL, "k")
if err != nil {
t.Fatalf("discovery failed: %v", err)
}
if res.BaseURL != srv.URL {
t.Errorf("base = %q, want %q", res.BaseURL, srv.URL)
}
if res.Endpoint != tc.wantEndpoint {
t.Errorf("endpoint = %q, want %q", res.Endpoint, tc.wantEndpoint)
}
if res.ImageEndpoint != tc.wantImage {
t.Errorf("image_endpoint = %q, want %q", res.ImageEndpoint, tc.wantImage)
}
// Whatever it wrote, the chat URL must reach the same version
// prefix the catalog was served under.
ep := res.Endpoint
if ep == "" {
ep = "/chat/completions"
}
if got := res.BaseURL + ep; strings.Contains(got, "/v1/v1") {
t.Errorf("chat URL %q has a doubled version", got)
}
})
}
}
// TestComposeEndpoints_BothFormsProduceWorkingURLs is the end-to-end
// invariant across the two shapes, stated as the URLs the gateway will build.
//
// A base with a version and no image_endpoint produces "/v1/v1/…" — the exact
// 404 observed before composeImageEndpoint existed. A base without one and a
// non-empty image_endpoint produces a path missing the version. Both are
// silent, expensive mistakes, so they are asserted literally.
func TestComposeEndpoints_BothFormsProduceWorkingURLs(t *testing.T) {
cases := []struct {
name string
base string
wantChat string
wantImage string
}{
{
name: "bare host: adapter and image defaults compose",
base: "https://h",
wantChat: "https://h/chat/completions",
// openai's adapter endpoint is /chat/completions (verified in
// internal/lua/adapters/openai.lua: adapter.endpoint).
wantImage: "https://h/v1/images/generations",
},
{
name: "versioned base: the version moves onto both endpoints",
base: "https://h/v1",
wantChat: "https://h/v1/chat/completions",
wantImage: "https://h/v1/images/generations",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ep := composeEndpoint("/models", tc.base, tc.base)
if ep == "" {
ep = "/chat/completions" // the adapter default
}
iep := composeImageEndpoint(tc.base)
if iep == "" {
iep = "/v1/images/generations" // the gateway default
}
gotChat := tc.base + ep
gotImage := tc.base + iep
if gotChat != tc.wantChat {
t.Errorf("chat URL = %q, want %q", gotChat, tc.wantChat)
}
if gotImage != tc.wantImage {
t.Errorf("image URL = %q, want %q", gotImage, tc.wantImage)
}
for _, u := range []string{gotChat, gotImage} {
if strings.Contains(u, "/v1/v1") {
t.Errorf("%q has a doubled version segment", u)
}
}
})
}
}

View File

@ -1,342 +0,0 @@
package gateway
// Real mutation injection: rewrites the IMPLEMENTATION on disk, re-runs the
// suite, and requires it to go red.
//
// Why this exists and why it is a Go file rather than a shell script:
//
// The first version was a shell script. Its -run filter contained a regex that
// the shell mangled, so every mutant reported "SURVIVED" while go test printed
// "[no tests to run]". Four mutants looked like judgement failures and were
// actually never executed. A judgement that cannot tell "tests passed" from
// "no tests ran" is not a judgement.
//
// This version therefore refuses to report anything unless it has first proved
// the filter selects a non-trivial number of tests (see baseline below), and it
// treats "[no tests to run]" as a hard error rather than a green run.
//
// It also must not be run against a DIRTY tree: the mutant is written over
// discover.go, and a leftover mutant from a killed run would make the baseline
// green against broken code while every verdict came from the wrong source.
// See the guard at the top of TestRealMutants.
//
// Run: go test -tags luajit ./internal/gateway/ -run TestRealMutants -v
import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
// mutationFilter must select every assertion that guards the code below. It is
// deliberately broad: a narrow filter is how mutants get to survive for free.
const mutationFilter = "Test(ParseModelCatalog|DiscoveredModelKind|Discover|TrimVersion|VersionSegment|ComposeEndpoint|ComposeImageEndpoint|ComposeEndpoints|IsVersionSegment|ModelsToConfig|DiscoveryResult|DiscoveryCandidates|Discovery_BareHost)"
type mutation struct {
name string
from string
to string
}
func TestRealMutants(t *testing.T) {
if testing.Short() {
t.Skip("mutation campaign compiles the package repeatedly")
}
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
target := filepath.Join(wd, "discover.go")
orig, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
// Refuse to run against a dirty tree.
//
// The mutant is written OVER discover.go, so on a dirty file the "original"
// captured above is not the committed source — the baseline then runs against
// whatever was there, and a killed run (a background-task timeout, a Ctrl-C)
// leaves a mutant behind that the NEXT run treats as the baseline. Both make
// every verdict meaningless while the harness still reports a clean score.
//
// Refusing rather than restoring: this cannot tell "leftover mutant" apart from
// "the developer is mid-edit", and silently reverting real work is worse than
// stopping.
if dirty, derr := mutationTargetIsDirty(wd, "discover.go"); derr == nil && dirty {
t.Fatalf("%s has uncommitted changes — commit or stash them first. A mutant "+
"left behind by a killed run would otherwise become the baseline, and "+
"every verdict below would be about code nobody wrote.", target)
}
defer func() {
if werr := os.WriteFile(target, orig, 0o644); werr != nil {
t.Fatalf("restore discover.go: %v", werr)
}
}()
// --- prove the harness is armed before trusting any verdict ---
// A filter that selects nothing exits 0, which is indistinguishable from a
// real pass. The only way to tell them apart is to compare against a filter
// KNOWN to be empty: if that empty filter produces the same result as the
// real one, the real one is probably empty too.
//
// Note go test does NOT print "[no tests to run]" for a wholly unmatched
// filter (it prints that only when the package has other matching tests), so
// the check has to compare outputs rather than look for a marker string.
// Getting this backwards made the harness refuse to run at all.
emptyOut := runSuite(wd, "TestZZZNoSuchTestExists")
realOut := runSuite(wd, mutationFilter)
if countTests(emptyOut) != 0 {
t.Fatalf("a filter matching nothing selected %d tests; the harness's own probe is broken",
countTests(emptyOut))
}
// Kept for the no-op check below: a mutant whose output matches the
// baseline never changed behaviour, so it is an invalid mutation rather
// than a gap in the tests.
baseOut := realOut
if strings.Contains(realOut, "[no tests to run]") {
t.Fatalf("baseline selected no tests:\n%s", realOut)
}
baseCount := countTests(realOut)
if baseCount < 25 {
t.Fatalf("baseline ran only %d tests; the filter is too narrow to judge anything", baseCount)
}
if strings.Contains(realOut, "FAIL") {
t.Logf("note: baseline is not green; kill verdicts remain valid")
}
t.Logf("baseline: %d tests selected, empty filter selects %d",
baseCount, countTests(emptyOut))
muts := []mutation{
{
// Written as a condition that still READS modOut, because a
// mutation that leaves a variable unused fails to compile — and a
// build failure is not a judgement (see the harness note above).
name: "image kind ignores upstream output modality",
from: `if m.Capabilities.ImageGeneration || has(modOut, "image") {`,
to: "if m.Capabilities.ImageGeneration || (len(modOut) > 0 && false) {",
},
{
name: "kind defaults to image (guessing from names)",
from: "\t}\n\treturn \"chat\"\n}\n\n// modalityList",
to: "\t}\n\treturn \"image\"\n}\n\n// modalityList",
},
{
name: "nested metadata no longer read",
from: "\tcopyFields(m.Metadata) // nested first",
to: "\tcopyFields(json.RawMessage(nil)) // mutant: nested metadata dropped",
},
{
name: "non-catalog bodies accepted",
from: "\tif trimmed == \"\" || trimmed == \"null\" {\n\t\treturn nil, fmt.Errorf(\"empty response\")\n\t}",
to: "\tif false && (trimmed == \"\" || trimmed == \"null\") {\n\t\treturn nil, fmt.Errorf(\"empty response\")\n\t}",
},
{
// Removing the bare "/models" candidate must be observable: a vendor
// serving its catalog at the bare path would otherwise look
// unreachable. The `len(...) >= 0` is a tautology that only exists to
// keep the expression syntactically valid after the call is dropped.
name: "URL fallback search removed",
from: "\t\tadd(raw, \"/models\", 0)",
to: "\t\tif len(\"/models\") >= 0 {\n\t\t\t_ = hasVersion\n\t\t}",
},
{
name: "version segment never moved onto the chat endpoint",
from: "\tif version == \"\" {\n\t\treturn \"\" // adapter default already composes\n\t}",
to: "\tif version == \"\" || len(version) > 0 {\n\t\treturn \"\"\n\t}",
},
{
name: "image endpoint never corrected (/v1/v1 regression)",
from: "\tif b := trimVersionSuffix(persistedBase); b != persistedBase {\n\t\treturn \"/images/generations\"\n\t}",
to: "\tif b := trimVersionSuffix(persistedBase); b == b && b != b {\n\t\treturn \"/images/generations\"\n\t}",
},
{
name: "version test loosened so 'vision' counts as a version",
from: "\tsuffix := strings.ToLower(seg[i:])",
to: "\tsuffix := strings.ToLower(seg[i:])\n\tif len(suffix) >= 0 {\n\t\tsuffix = \"beta\"\n\t}",
},
{
// The guard must be removable WITHOUT the mutant becoming a
// semantic no-op. Two earlier attempts failed:
//
// `&& out.BaseURL != ""` — the guard this test exercises uses a
// zero-value struct whose BaseURL is empty, so the added
// condition made the mutant behave identically to the original.
// `&& len(out.BaseURL) >= 0` — always true for any string, so the
// mutant was STILL an identity transform.
//
// Reading the variable into a blank assignment keeps the compiler
// happy without changing behaviour, which is what makes the
// mutant observable.
name: "nil slice guard removed from JSON output",
from: "\tif out.ImageModels == nil {\n\t\tout.ImageModels = []string{}\n\t}",
to: "\tif out.ImageModels == nil {\n\t\t_ = out.ImageModels\n\t}",
},
{
name: "auth rejection no longer classified",
from: "\tif status != http.StatusUnauthorized && status != http.StatusForbidden {\n\t\treturn \"\"\n\t}",
to: "\tif status != http.StatusUnauthorized && status != http.StatusForbidden || len(body) >= 0 {\n\t\treturn \"\"\n\t}",
},
{
name: "duplicate candidate URLs no longer collapsed",
from: "\t\tif seenPair[pair] || seenURL[base+path] {",
to: "\t\tif len(pair) > 0 && (seenPair[pair] || seenURL[base+path]) && false {",
},
}
survived := 0
for _, m := range muts {
t.Run(m.name, func(t *testing.T) {
if !strings.Contains(string(orig), m.from) {
t.Fatalf("anchor not found in discover.go — this mutation would be a no-op:\n%q", m.from)
}
mutated := strings.Replace(string(orig), m.from, m.to, 1)
if mutated == string(orig) {
t.Fatal("replacement changed nothing")
}
if werr := os.WriteFile(target, []byte(mutated), 0o644); werr != nil {
t.Fatal(werr)
}
defer os.WriteFile(target, orig, 0o644)
out := runSuite(wd, mutationFilter)
// A build failure kills EVERY mutant, so it would mask a broken
// harness as a perfect score. Distinguish it from a real test
// failure and fail loudly instead.
if strings.Contains(out, "[build failed]") {
t.Fatalf("mutant broke the build — that is not a judgement, it is a broken harness:\n%s", firstLines(out, 20))
}
if countTests(out) == 0 {
t.Fatalf("filter selected nothing — the verdict would be meaningless")
}
if !strings.Contains(out, "FAIL") {
// The mutant compiled and the suite stayed green. Before calling
// this a survivor, check the mutation was not a no-op: an earlier
// version reported four "survivors" that were really invalid
// mutations whose added guard made them behaviourally identical
// to the original. A silent no-op is a broken mutation, and
// conflating the two sends the next reader hunting for a missing
// test that does not exist.
if behaviourOnly(out) == behaviourOnly(baseOut) {
t.Fatalf("mutant is a no-op: the suite output is identical to the "+
"baseline, so this mutation never changed behaviour. Fix the "+
"mutation rather than looking for a missing test.\nmutant: %q", m.to)
}
survived++
t.Errorf("SURVIVED %s — no test noticed this wrong implementation", m.name)
return
}
t.Logf("KILL %s", m.name)
})
}
if survived > 0 {
t.Errorf("%d of %d mutants survived", survived, len(muts))
}
}
// runSuite invokes the go tool on this package with the given -run filter.
//
// The luajit build tag is mandatory: without it the package falls back to plain
// Lua 5.1, billing.lua's `goto` fails to compile, and the whole package fails
// to build — which the harness would misread as "every mutant killed".
func runSuite(wd, filter string) string {
tool := os.Getenv("GO_TEST_GO")
if tool == "" {
tool = "go"
}
cmd := exec.Command(tool, "test",
"-tags", "luajit",
".", "-run", filter, "-count=1", "-v")
cmd.Dir = wd
cmd.Env = os.Environ()
out, _ := cmd.CombinedOutput()
return string(out)
}
// mutationTargetIsDirty reports whether a file under internal/gateway has
// uncommitted changes, so the harness can refuse to run instead of treating a
// leftover mutant as the baseline.
//
// git runs from the package directory with a PATH RELATIVE to it. An earlier
// version of the sibling script passed a repo-relative pathspec while running in
// the package directory; git matched nothing and the guard never fired.
func mutationTargetIsDirty(wd, name string) (bool, error) {
cmd := exec.Command("git", "diff", "--name-only", "--", "./"+name)
cmd.Dir = wd
out, err := cmd.Output()
if err != nil {
return false, err
}
return strings.TrimSpace(string(out)) != "", nil
}
func countTests(out string) int {
n := 0
for _, line := range strings.Split(out, "\n") {
// Subtests are indented; only count top-level ones so a single test
// with a table of cases cannot inflate the count.
if strings.HasPrefix(line, "=== RUN Test") && !strings.Contains(line, "/") {
n++
}
}
return n
}
// behaviourOnly strips the parts of `go test -v` output that vary between runs
// (timings, goroutine ids, temp paths) so two runs can be compared for equality.
// If a mutant produces output identical to the baseline under this projection,
// the mutation did not change observable behaviour and is therefore invalid.
func behaviourOnly(out string) string {
var kept []string
for _, line := range strings.Split(out, "\n") {
trimmed := strings.TrimSpace(line)
switch {
// Keep only the outcome lines. Test NAMES are deliberately dropped:
// two runs of the same suite can legitimately order or cache tests
// differently, and keeping the names made every mutant look like it had
// "changed behaviour" — which defeats the no-op detector entirely.
case strings.HasPrefix(trimmed, "--- PASS"), strings.HasPrefix(trimmed, "--- FAIL"),
strings.HasPrefix(trimmed, "--- SKIP"),
strings.HasPrefix(trimmed, "ok "), trimmed == "FAIL",
strings.Contains(trimmed, ".go:"):
kept = append(kept, trimmed)
}
}
// Counting outcomes is stronger than comparing the sequence: a mutation
// that swaps which test fails still changes the multiset, and one that
// changes nothing cannot.
sort.Strings(kept)
return strings.Join(kept, "\n")
}
// firstLines trims output for an error message; a failing nested run can be
// thousands of lines and the cause is always near the top.
func firstLines(s string, n int) string {
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
// TestMutationFilterCoversEveryGuard keeps the filter honest. Dropping a group
// from it silently disarms that area's mutants.
func TestMutationFilterCoversEveryGuard(t *testing.T) {
for _, group := range []string{
"ParseModelCatalog", // nested metadata + non-catalog rejection
"DiscoveredModelKind", // kind follows upstream metadata
"Discover", // URL fallback, auth classification
"ComposeEndpoint", // version segment exactly once (chat)
"ComposeImageEndpoint",
"ComposeEndpoints", // the composed URLs, both base shapes
"IsVersionSegment", // "v1beta" vs "vision"
"DiscoveryResult", // nil slices are arrays
} {
if !strings.Contains(mutationFilter, group) {
t.Errorf("mutation filter omits %s; mutants there survive silently", group)
}
}
}

View File

@ -1,171 +0,0 @@
package gateway
// Mutation harness for the discovery code. Kept in the tree (not in a shell
// script) so the -run filter is a Go string literal rather than something the
// shell can mangle into "[no tests to run]" — which is exactly how an earlier
// version of this harness reported four surviving mutants that were simply
// never executed.
//
// Each mutant flips one behaviour; TestMutationHarness_Fails proves the
// harness itself is armed.
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// discoveryTestFilter must cover every assertion that guards the behaviour
// below. Narrowing it is how mutants get to "survive" for free.
const discoveryTestFilter = "Test(ParseModelCatalog|DiscoveredModelKind|Discover|TrimVersion|VersionSegment|ComposeEndpoint|IsVersionSegment|ModelsToConfig|DiscoveryResult|DiscoveryCandidates)"
func TestDiscoveryFilterRunsRealTests(t *testing.T) {
if discoveryTestFilter == "" {
t.Fatal("filter is empty; every mutant would survive trivially")
}
// The whole point: the catalog parser test must be inside the filter, or a
// mutant that stops reading nested metadata survives with a green run.
for _, group := range []string{
"ParseModelCatalog", // nested metadata + non-catalog rejection
"DiscoveredModelKind", // kind must follow upstream metadata
"Discover", // URL fallback + endpoint composition
"ComposeEndpoint", // version segment exactly once
"IsVersionSegment", // "v1beta" vs "vision"
} {
if !strings.Contains(discoveryTestFilter, group) {
t.Errorf("filter %q skips %s tests; mutants in that area survive", discoveryTestFilter, group)
}
}
}
// TestMutantsAreDetectable is the harness's own regression guard: it runs the
// filter's worth of assertions against deliberately broken inputs and requires
// each to be rejected. If a future edit makes one of these pass, the
// corresponding protection is gone.
func TestMutantsAreDetectable(t *testing.T) {
t.Run("image classification follows upstream metadata, not the model name", func(t *testing.T) {
// The mutant: drop the output-modality check, so every model is chat.
m := upstreamModel{ID: "gpt-image-2"}
m.Architecture.OutputModalities = []string{"image"}
if got := discoveredModelKind(m); got != "image" {
t.Errorf("mutant survived: got %q, want \"image\"", got)
}
})
t.Run("nested metadata is read", func(t *testing.T) {
var ms []upstreamModel
if err := json.Unmarshal([]byte(realCatalogBody), &ms); err == nil {
t.Fatal("fixture must be a wrapper object, not a bare array")
}
var wrapper struct {
Data []upstreamModel `json:"data"`
}
if err := json.Unmarshal([]byte(realCatalogBody), &wrapper); err != nil {
t.Fatal(err)
}
if len(wrapper.Data) == 0 {
t.Fatal("fixture parsed to zero models")
}
// The mutant: skip copyFields(m.Metadata) entirely.
meta := modelMeta(wrapper.Data[0])
if meta == nil {
t.Fatal("mutant survived: metadata is nil, so cost/context_limit are lost")
}
for _, k := range []string{"context_limit", "cost", "context_budget"} {
if _, ok := meta[k]; !ok {
t.Errorf("mutant survived: %s dropped from nested metadata", k)
}
}
})
t.Run("non-catalog responses are rejected", func(t *testing.T) {
for _, body := range []string{`<html>login</html>`, `null`, `{"error":{"message":"x"}}`, `{}`} {
if _, err := parseModelCatalog([]byte(body)); err == nil {
t.Errorf("mutant survived: %q accepted as a catalog", body)
}
}
})
t.Run("version segments are distinguished from real path segments", func(t *testing.T) {
if !isVersionSegment("v1beta") {
t.Error("mutant survived: v1beta not treated as a version")
}
for _, seg := range []string{"vision", "vertex", "v1x"} {
if isVersionSegment(seg) {
t.Errorf("mutant survived: %q wrongly treated as a version", seg)
}
}
})
t.Run("endpoint composition never duplicates the version segment", func(t *testing.T) {
// The mutant: never move the segment onto the endpoint.
if got := composeEndpoint("/v1/models", "https://h", "https://h/v1"); got != "/v1/chat/completions" {
t.Errorf("mutant survived: endpoint = %q", got)
}
// The other mutant: always move it, even when the base keeps it.
if got := composeEndpoint("/models", "https://h/v1", "https://h/v1"); got != "" {
t.Errorf("mutant survived: endpoint = %q would double /v1", got)
}
})
t.Run("image_models is never nil", func(t *testing.T) {
res := &discoveryResult{Models: []string{"a"}, ImageModels: nil}
b, _ := json.Marshal(res)
if strings.Contains(string(b), `"image_models":null`) {
t.Errorf("mutant survived: %s", b)
}
})
t.Run("a rejected key is reported as unauthorized, not as an outage", func(t *testing.T) {
// The mutant: collapse every failure into one generic error, which is
// what made a typo'd key look like the vendor being down.
if classifyAuthStatus(http.StatusUnauthorized, []byte(`{"error":"invalid api key"}`)) == "" {
t.Error("mutant survived: 401 is not classified")
}
if classifyAuthStatus(http.StatusNotFound, []byte("nope")) != "" {
t.Error("a 404 must not be reported as an auth problem")
}
if classifyAuthStatus(http.StatusOK, nil) != "" {
t.Error("a 200 must not be reported as an auth problem")
}
})
t.Run("duplicate candidate URLs are collapsed", func(t *testing.T) {
seen := map[string]bool{}
for _, c := range discoveryCandidates("https://h/v1") {
u := c.baseURL + c.modelsPath
if seen[u] {
t.Errorf("mutant survived: %s requested twice", u)
}
seen[u] = true
}
})
t.Run("discovery falls back across URL forms", func(t *testing.T) {
// The coverage requirement is that BOTH catalog paths are reachable for
// a typed base, and both for a bare host. Asserting an exact count kept
// drifting as the candidate list was fixed, and each time it measured
// the current list rather than the property that matters.
for _, base := range []string{"https://h/v1", "https://h"} {
distinct := map[string]bool{}
for _, c := range discoveryCandidates(base) {
distinct[c.baseURL+c.modelsPath] = true
}
for _, want := range []string{base + "/v1/models", base + "/models"} {
if !distinct[want] {
t.Errorf("mutant survived: %s never requests %s", base, want)
}
}
}
})
t.Run("only the catalog is fetched, never each model", func(t *testing.T) {
// Guarding a NON-mutation: the design decision that discovery must not
// spend money. If a future change adds per-model probes, the counting
// test below catches it; this assertion documents the intent.
if discoveryModelsPath != "/v1/models" {
t.Errorf("catalog path changed to %q", discoveryModelsPath)
}
})
}

View File

@ -1,648 +0,0 @@
package gateway
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// realCatalogBody is the actual /models response captured from the amr-link
// endpoint, not a hand-written fixture. Two properties of it are the reason
// this test exists at all:
//
// - it nests modalities under "architecture", which the naive parser misses;
// - it lists five image-generation ids among 34 chat ids, so a parser that
// returns everything as "chat" silently poisons the AUTO chain.
const realCatalogBody = `{"data":[{"id":"claude-fable-5","object":"model","created":1781081344,"owned_by":"vela","architecture":{"input_modalities":["text","image","file"],"output_modalities":["text"]},"metadata":{"cost":"very_high","capability":"best_quality","context_limit":1000000,"context_budget":232000},"default":false,"enabled":true},{"id":"gpt-image-2","object":"model","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]},"metadata":{"capability":"best_quality"},"enabled":true},{"id":"deepseek-v4-flash","object":"model","architecture":{"input_modalities":["text"],"output_modalities":["text"]},"metadata":{"cost":"medium","capability":"standard","context_limit":1048000},"enabled":true},{"id":"seedream-5.0-pro","object":"model","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]},"metadata":{"capability":"best_quality"},"enabled":true}]}`
func TestParseModelCatalog_RealUpstreamShape(t *testing.T) {
models, err := parseModelCatalog([]byte(realCatalogBody))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(models) != 4 {
t.Fatalf("want 4 models, got %d", len(models))
}
kind := map[string]string{}
for _, m := range models {
kind[m.ID] = m.Kind
}
// The two image generators must be classified by the upstream's declared
// output modality, not by their names.
for _, id := range []string{"gpt-image-2", "seedream-5.0-pro"} {
if kind[id] != "image" {
t.Errorf("%s: got kind %q, want \"image\"", id, kind[id])
}
}
for _, id := range []string{"claude-fable-5", "deepseek-v4-flash"} {
if kind[id] != "chat" {
t.Errorf("%s: got kind %q, want \"chat\"", id, kind[id])
}
}
// Metadata the upstream sent must survive; keys it did not send must not
// appear as zero values.
if got := models[0].Meta["context_limit"]; got == nil {
t.Error("claude-fable-5: context_limit dropped (it is nested under metadata in the real response)")
} else if f, ok := got.(float64); !ok || f != 1000000 {
t.Errorf("context_limit = %#v, want numeric 1000000 so the UI can sort on it", got)
}
if _, present := models[0].Meta["cost"]; !present {
t.Error("claude-fable-5: cost dropped")
}
// context_budget is present on this entry and must not be dropped either.
if _, present := models[0].Meta["context_budget"]; !present {
t.Error("claude-fable-5: context_budget dropped")
}
// deepseek-v4-flash DOES send metadata; gpt-image-2 sends only capability.
// Neither may lose it.
if models[2].Meta["context_limit"] == nil {
t.Error("deepseek-v4-flash: context_limit dropped")
}
if models[2].Meta["cost"] == nil {
t.Error("deepseek-v4-flash: cost dropped")
}
if _, bogus := models[0].Meta["made_up_field"]; bogus {
t.Error("invented a metadata key that the upstream never sent")
}
}
// TestDiscoveredModelKind_ImageOnlyByUpstreamMetadata is the regression guard
// for the failure mode that motivated keeping kind="chat" as the default: a
// name-based heuristic marks "ideogram-v2" or "gpt-image-1" as an image model,
// but a catalog that also serves chat for that id would then have its chat
// entry removed from the AUTO chain. Only the upstream's declared OUTPUT
// modality may classify a model as image.
func TestDiscoveredModelKind_ImageOnlyByUpstreamMetadata(t *testing.T) {
cases := []struct {
name string
in upstreamModel
want string
}{
{
// A catalog that DECLARES output modality image is authoritative.
// The "kind" string saying chat must not override it.
name: "declared output modality image wins over a chat kind string",
in: func() upstreamModel {
var m upstreamModel
m.Kind = "chat"
m.Architecture.OutputModalities = []string{"image"}
return m
}(),
want: "image",
},
{
name: "bare array modality string",
in: upstreamModel{ID: "x", Modality: "text-to-image"},
want: "image",
},
{
name: "image INPUT is still a chat model",
in: func() upstreamModel {
var m upstreamModel
m.Architecture.InputModalities = []string{"text", "image"}
return m
}(),
want: "chat",
},
{
name: "file input is still a chat model",
in: func() upstreamModel {
var m upstreamModel
m.Architecture.InputModalities = []string{"text", "file"}
return m
}(),
want: "chat",
},
{
name: "vision capability flag is chat",
in: upstreamModel{ID: "y", Capabilities: modelCaps{Vision: true}},
want: "chat",
},
{
name: "capabilities.image_generation is image",
in: upstreamModel{ID: "z", Capabilities: modelCaps{ImageGeneration: true}},
want: "image",
},
{
name: "no modality info at all defaults to chat",
in: upstreamModel{ID: "plain"},
want: "chat",
},
{
// The default must be chat even when the name LOOKS like an image
// model. Guessing from the name is what puts gpt-image-2 into the
// chat AUTO chain, where every request to it fails.
name: "image-looking name without upstream metadata stays chat",
in: upstreamModel{ID: "gpt-image-2"},
want: "chat",
},
{
name: "vision-looking name without upstream metadata stays chat",
in: upstreamModel{ID: "ideogram-v2"},
want: "chat",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := discoveredModelKind(tc.in); got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestParseModelCatalog_RejectsNonCatalog(t *testing.T) {
// A 200 with valid JSON that is not a catalog must NOT be reported as an
// empty result. Reporting zero models would let a login page or a
// rate-limit body create a source with no models in it.
for name, body := range map[string]string{
"login page html": `<html><body>Sign in</body></html>`,
"rate limit json": `{"error":{"message":"slow down","type":"rate_limit"}}`,
"empty object": `{}`,
"null": `null`,
"wrong shape": `{"objects":[{"id":"m"}]}`,
} {
t.Run(name, func(t *testing.T) {
if _, err := parseModelCatalog([]byte(body)); err == nil {
t.Errorf("expected an error for %q, got nil (would create an empty source)", name)
}
})
}
}
func TestParseModelCatalog_AcceptsBareArray(t *testing.T) {
models, err := parseModelCatalog([]byte(`[{"id":"a"},{"id":"b"}]`))
if err != nil {
t.Fatalf("bare array should parse: %v", err)
}
if len(models) != 2 {
t.Fatalf("want 2, got %d", len(models))
}
}
// TestDiscoverModels_NormalizesWhenOnlyBareModelsAnswers: operator pastes
// "https://host/v1" (the form every SDK example shows) but the vendor serves
// the catalog at "https://host/models" — the version segment on the path is
// implied, not doubled. Discovery must strip the segment off the base and
// report it on the endpoint, because openai's adapter endpoint is
// "/chat/completions" and the two are concatenated.
func TestDiscoverModels_NormalizesWhenOnlyBareModelsAnswers(t *testing.T) {
var paths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
if r.URL.Path != "/models" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(realCatalogBody))
}))
defer srv.Close()
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-test")
if err != nil {
t.Fatalf("discovery failed: %v", err)
}
if res.BaseURL != srv.URL {
t.Errorf("base_url = %q, want %q (version segment moved off the base)", res.BaseURL, srv.URL)
}
// "/models" carries no version segment, but the operator typed one — the
// endpoint has to restore it or the chat call would miss /v1.
if res.Endpoint != "/v1/chat/completions" {
t.Errorf("endpoint = %q, want %q", res.Endpoint, "/v1/chat/completions")
}
if res.Note == "" {
t.Error("silently rewriting the operator's base_url is worse than saying so")
}
if len(res.Models) != 2 || len(res.ImageModels) != 2 {
t.Errorf("got %d chat / %d image, want 2 / 2", len(res.Models), len(res.ImageModels))
}
if len(paths) < 2 {
t.Errorf("tried only %v; expected a fallback search", paths)
}
}
// TestDiscoverModels_BareHostWithVersionedModelsPath: operator pastes the host
// root, vendor serves /v1/models. The base needs no rewriting; the endpoint
// does.
func TestDiscoverModels_BareHostWithVersionedModelsPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(`{"data":[{"id":"m1"}]}`))
}))
defer srv.Close()
res, err := DiscoverModels(context.Background(), srv.URL, "")
if err != nil {
t.Fatalf("discovery failed: %v", err)
}
if res.BaseURL != srv.URL {
t.Errorf("base_url = %q, want %q", res.BaseURL, srv.URL)
}
if res.Endpoint != "/v1/chat/completions" {
t.Errorf("endpoint = %q, want %q", res.Endpoint, "/v1/chat/completions")
}
}
// TestDiscoverModels_KeepsWorkingBaseURLUnchanged covers the vendor that really
// does serve "/v1/models": the typed URL must survive untouched so no needless
// config rewrite is suggested.
func TestDiscoverModels_KeepsWorkingBaseURLUnchanged(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
}))
defer srv.Close()
// Base typed WITH "/v1", catalog served at "…/v1/models". That is exactly
// the amr-link shape: first candidate succeeds, nothing is rewritten, and
// the adapter's default endpoint already composes.
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "")
if err != nil {
t.Fatalf("discovery failed: %v", err)
}
if res.BaseURL != srv.URL+"/v1" {
t.Errorf("base_url = %q, want it unchanged", res.BaseURL)
}
if res.Endpoint != "" {
t.Errorf("endpoint = %q, want empty (the typed base already carries /v1)", res.Endpoint)
}
if res.Note != "" {
t.Errorf("note = %q, want empty when nothing was rewritten", res.Note)
}
if len(res.Models) != 2 {
t.Errorf("got %d chat models, want 2", len(res.Models))
}
}
// TestDiscoverModels_ErrorNamesEveryTriedURL: a failed probe is useless if it
// only reports the last URL. The operator needs to see the full search.
func TestDiscoverModels_ErrorNamesEveryTriedURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
_, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
if err == nil {
t.Fatal("expected failure against an all-404 server")
}
msg := err.Error()
for _, want := range []string{"/models", "/v1/models", "404"} {
if !strings.Contains(msg, want) {
t.Errorf("error %q should mention %q", msg, want)
}
}
}
func TestDiscoveryCandidates_DedupesAndPrefersTyped(t *testing.T) {
cands := discoveryCandidates("https://h/v1/")
if len(cands) == 0 {
t.Fatal("no candidates")
}
seen := map[string]bool{}
for _, c := range cands {
// Dedup is on the requested URL, so that is the key to assert on.
k := c.baseURL + c.modelsPath
if seen[k] {
t.Errorf("duplicate request for %s", k)
}
seen[k] = true
}
// The versioned spelling of the typed URL must be reachable: on the real
// amr-link endpoint that is what answers, and it is the preferred candidate.
if !seen["https://h/v1/models"] {
t.Error("the typed base with a versioned catalog path must be tried; that is what answers on amr-link")
}
// Both catalog forms must be attempted for a bare host too. Offering only
// "/v1/models" there left a vendor serving a bare "/models" looking
// unreachable.
if !seen["https://h/models"] {
t.Error("host root with a bare /models path was never tried")
}
// The first candidate is preferred, so its URL is what gets reported when
// several answer.
if cands[0].baseURL+cands[0].modelsPath != "https://h/v1/models" {
t.Errorf("first candidate requests %q, want the canonical versioned form",
cands[0].baseURL+cands[0].modelsPath)
}
// The bare "/models" form must exist too — some gateways serve it there.
hasBare := false
for _, c := range cands {
if c.modelsPath == "/models" {
hasBare = true
}
}
if !hasBare {
t.Error("no /models candidate; vendors serving the catalog at /models would be missed")
}
// Preference must be monotonically non-decreasing so the sort is stable.
for i := 1; i < len(cands); i++ {
if cands[i].prefer < cands[i-1].prefer {
t.Errorf("candidates out of preference order at %d: %d < %d", i, cands[i].prefer, cands[i-1].prefer)
}
}
}
func TestVersionSegment(t *testing.T) {
cases := map[string]string{
"/v1/models": "/v1",
"/v1beta/models": "/v1beta",
"/models": "",
"/api/models": "",
"/vision/models": "",
}
for in, want := range cases {
if got := versionSegment(in); got != want {
t.Errorf("versionSegment(%q) = %q, want %q", in, got, want)
}
}
}
func TestTrimVersionSuffix(t *testing.T) {
cases := map[string]string{
"https://h/v1": "https://h",
"https://h/v2": "https://h",
"https://h/v1beta": "https://h",
"https://h/api/v1": "https://h/api",
"https://h/vision": "https://h/vision", // not numeric — a real path segment
"https://h": "https://h",
"https://h/openai": "https://h/openai",
"https://h/v1/extra": "https://h/v1/extra",
// A segment that merely STARTS with v must not be stripped: stripping
// it would rewrite base "https://h/vision" to "https://h" and, worse,
// would treat a real vendor path as a version.
"https://h/vertex": "https://h/vertex",
"https://h/volcengine": "https://h/volcengine",
"https://h/vertex-ai/v1": "https://h/vertex-ai",
}
for in, want := range cases {
if got := trimVersionSuffix(in); got != want {
t.Errorf("trimVersionSuffix(%q) = %q, want %q", in, got, want)
}
}
}
// TestIsVersionSegment_RequiresDigits pins the distinction the whole endpoint
// derivation rests on: "v1"/"v1beta" are versions, "vision"/"vertex" are path
// segments. Without the digit requirement, stripping turns a vendor's real
// base URL into a different one.
func TestIsVersionSegment_RequiresDigits(t *testing.T) {
yes := []string{"v1", "v2", "v10", "v1beta", "v1alpha", "v1preview"}
no := []string{"vision", "vertex", "volcengine", "openai", "v", "vv", "api", "beta", "v1x"}
for _, s := range yes {
if !isVersionSegment(s) {
t.Errorf("isVersionSegment(%q) = false, want true", s)
}
}
for _, s := range no {
if isVersionSegment(s) {
t.Errorf("isVersionSegment(%q) = true, want false", s)
}
}
}
// TestComposeEndpoint_ExactlyOneVersionSegment is the invariant test. Every row
// below is a URL form that was observed to 404 or to route wrongly when the
// version segment ended up in the wrong place.
func TestComposeEndpoint_ExactlyOneVersionSegment(t *testing.T) {
cases := []struct {
name string
modelsPath string
persistedBase string
typedBase string
want string
}{
{
name: "typed base has /v1 and catalog served there: base keeps it, endpoint empty",
modelsPath: "/models", persistedBase: "https://h/v1", typedBase: "https://h/v1",
want: "",
},
{
name: "catalog served at versioned path, base stripped: endpoint carries it",
modelsPath: "/v1/models", persistedBase: "https://h", typedBase: "https://h/v1",
want: "/v1/chat/completions",
},
{
name: "bare host, bare catalog, nothing typed: adapter default composes",
modelsPath: "/models", persistedBase: "https://h", typedBase: "https://h",
want: "",
},
{
name: "bare host, versioned catalog: endpoint owns the version",
modelsPath: "/v1/models", persistedBase: "https://h", typedBase: "https://h",
want: "/v1/chat/completions",
},
{
name: "typed /v1 but catalog only at bare /models: strip base, restore on endpoint",
modelsPath: "/models", persistedBase: "https://h", typedBase: "https://h/v1",
want: "/v1/chat/completions",
},
{
name: "non-version segment must never be promoted onto the endpoint",
modelsPath: "/models", persistedBase: "https://h/vision", typedBase: "https://h/vision",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := composeEndpoint(tc.modelsPath, tc.persistedBase, tc.typedBase)
if got != tc.want {
t.Errorf("composeEndpoint = %q, want %q", got, tc.want)
}
// Whichever way it resolved, base + endpoint must never carry the
// same version segment twice.
onBase := trimVersionSuffix(tc.persistedBase) != tc.persistedBase
onEndpoint := versionSegment(strings.TrimSuffix(got, "/chat/completions")+"/models") != ""
if onBase && onEndpoint {
t.Errorf("version segment duplicated: base %q + endpoint %q", tc.persistedBase, got)
}
})
}
}
// TestDiscoverModels_DoesNotProbeEveryModel guards the decision not to spend
// money verifying each catalog entry. Only the catalog request is made.
func TestDiscoverModels_DoesNotProbeEveryModel(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
_, _ = w.Write([]byte(`{"data":[{"id":"a"},{"id":"b"},{"id":"c"}]}`))
}))
defer srv.Close()
if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k"); err != nil {
t.Fatalf("discovery failed: %v", err)
}
if calls != 1 {
t.Errorf("made %d requests, want exactly 1 (the catalog)", calls)
}
}
func TestModelsToConfig_PrioritiesAndKinds(t *testing.T) {
got := modelsToConfig([]string{"a", "b"}, []string{"img1"})
if len(got) != 3 {
t.Fatalf("want 3 models, got %d", len(got))
}
if got[2].ID != "img1" || got[2].Kind != "image" {
t.Errorf("image model mis-sorted: %+v", got[2])
}
if got[2].Priority <= got[0].Priority {
t.Errorf("image model priority %d should sort after chat %d", got[2].Priority, got[0].Priority)
}
}
// TestDiscoverModels_ImageModelsNeverNull guards the UI contract end to end,
// not just on the struct: the WebUI does out.concat(image_models), and a JSON
// null there renders "undefined" next to every image model row. This asserts on
// the served bytes rather than the Go field, because that is what the bug was.
func TestDiscoverModels_ImageModelsNeverNull(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only chat models — the case where the slice is never appended to.
_, _ = w.Write([]byte(`{"data":[{"id":"a"},{"id":"b"}]}`))
}))
defer srv.Close()
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
if err != nil {
t.Fatalf("discovery failed: %v", err)
}
if len(res.ImageModels) != 0 {
t.Fatalf("precondition: want zero image models, got %d", len(res.ImageModels))
}
b, err := json.Marshal(res)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"image_models":[]`) {
t.Errorf("served %s\nwant \"image_models\":[] — null breaks concat in the UI", b)
}
}
// TestComposeImageEndpoint pins the rule that fixed a real 404.
//
// Provider.ImageURL is `base_url + ImageEndpoint()`, and the built-in default
// is the ABSOLUTE "/v1/images/generations" — while chat's default is the
// relative "/chat/completions". The two defaults disagree about who owns the
// version segment, so a base_url that produces a working chat URL produces
// "/v1/v1/images/generations" and a 404.
func TestComposeImageEndpoint(t *testing.T) {
cases := []struct {
base string
want string
}{
// base without a version: the absolute default composes correctly.
{"https://h", ""},
// base WITH a version: the default would double it, so the
// version-relative form is written instead.
{"https://h/v1", "/images/generations"},
{"https://h/v1beta", "/images/generations"},
// A real path segment that merely starts with v is not a version.
{"https://h/vision", ""},
}
for _, tc := range cases {
got := composeImageEndpoint(tc.base)
if got != tc.want {
t.Errorf("composeImageEndpoint(%q) = %q, want %q", tc.base, got, tc.want)
}
// Whatever it decided, base + result must not contain a doubled
// version segment — that is the bug this function exists for.
joined := tc.base + got
if strings.Contains(joined, "/v1/v1") {
t.Errorf("composed %q contains a doubled version segment", joined)
}
}
}
// TestDiscoveryReportsImageEndpointWhenBaseCarriesVersion is the end-to-end
// version: a pasted base_url with "/v1" must come back with an image endpoint,
// or the image models it reported will all 404 on use.
func TestDiscoveryReportsImageEndpointWhenBaseCarriesVersion(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(realCatalogBody))
}))
defer srv.Close()
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
if err != nil {
t.Fatal(err)
}
if len(res.ImageModels) == 0 {
t.Fatal("fixture must contain image models for this to mean anything")
}
if res.BaseURL != srv.URL+"/v1" {
t.Fatalf("base = %q, want the typed URL with /v1", res.BaseURL)
}
if res.ImageEndpoint != "/images/generations" {
t.Errorf("image_endpoint = %q, want %q (otherwise the reported image models all 404)",
res.ImageEndpoint, "/images/generations")
}
// The chat side stays empty: base already carries /v1 and the adapter's
// relative default composes.
if res.Endpoint != "" {
t.Errorf("endpoint = %q, want empty", res.Endpoint)
}
}
// TestDiscoveryResult_NilSlicesSerializeAsArrays covers the zero value of
// discoveryResult, which is what a caller gets if it builds one directly
// instead of going through DiscoverModels. DiscoverModels happens to
// initialize the slices, so testing only that path would let the MarshalJSON
// nil guard look redundant — and removing it would ship a latent UI bug the
// first time some other caller builds the struct.
func TestDiscoveryResult_NilSlicesSerializeAsArrays(t *testing.T) {
b, err := json.Marshal(&discoveryResult{BaseURL: "https://h"})
if err != nil {
t.Fatal(err)
}
got := string(b)
for _, want := range []string{`"models":[]`, `"image_models":[]`, `"tried":[]`} {
if !strings.Contains(got, want) {
t.Errorf("zero value serialized %s\nwant %s — a nil slice becomes JSON null and the UI renders \"undefined\"", got, want)
}
}
for _, bad := range []string{`"models":null`, `"image_models":null`, `"tried":null`} {
if strings.Contains(got, bad) {
t.Errorf("serialized %s, which the UI cannot concatenate", bad)
}
}
}
// TestDiscoveryResultJSONShape pins the field names the WebUI will bind to.
func TestDiscoveryResultJSONShape(t *testing.T) {
b, err := json.Marshal(&discoveryResult{
BaseURL: "https://h", Models: []string{"a"}, ImageModels: []string{},
ModelsPath: "/models", Adapter: "openai",
})
if err != nil {
t.Fatal(err)
}
var got map[string]interface{}
if err := json.Unmarshal(b, &got); err != nil {
t.Fatal(err)
}
// image_models must serialize as [] and never null: the UI does
// out.concat(image_models) and null would render "undefined".
if v, ok := got["image_models"]; !ok || v == nil {
t.Errorf("image_models = %v, want an empty array (null breaks concat in the UI)", v)
}
for _, k := range []string{"base_url", "endpoint", "models", "image_models", "models_path", "tried"} {
if _, ok := got[k]; !ok {
t.Errorf("missing field %q in discovery response", k)
}
}
}

View File

@ -4,8 +4,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"os"
"strings"
"testing"
@ -33,11 +33,11 @@ func keyAutoGateway(t *testing.T) *Gateway {
t.Fatal(err)
}
cfg := &config.Config{
Path: cfgPath,
AdapterDir: filepath.Join(td, "adapters"),
RuntimeFile: filepath.Join(td, "runtime.json"),
Path: cfgPath,
AdapterDir: filepath.Join(td, "adapters"),
RuntimeFile: filepath.Join(td, "runtime.json"),
DefaultModel: "AUTO",
GatewayKeys: []string{"sk-admin"},
GatewayKeys: []string{"sk-admin"},
Keys: []config.GWKey{
{Key: "sk-admin", Role: "admin", Name: "admin"},
{Key: "sk-user", Role: "user", Name: "user"},
@ -97,7 +97,7 @@ func TestKeyAutoAPICrudAndInherit(t *testing.T) {
// Initially inherits.
var got struct {
Inherits bool `json:"inherits"`
Inherits bool `json:"inherits"`
Auto []config.ModelScope `json:"auto"`
}
rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
@ -254,4 +254,4 @@ func TestKeyAutoAPIDoesNotShadowKeyRoutes(t *testing.T) {
if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/me", ""); rr.Code != http.StatusOK {
t.Fatalf("GET /api/keys/me as user = %d, want 200", rr.Code)
}
}
}

View File

@ -1,379 +0,0 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"llmsproxy/internal/provider"
"llmsproxy/internal/scheduler"
"llmsproxy/internal/types"
)
// handleMessages serves the Anthropic Messages protocol (POST /v1/messages).
//
// The point of this endpoint is NOT to add a second scheduling path. It reuses
// everything /v1/chat/completions does — key auth, model scope, per-model
// quotas, the AUTO chain, cooldowns, audit records, plugin hooks — and differs
// in exactly two places:
//
// 1. the request body is parsed from Anthropic's shape into the unified format
// (messages with content blocks, a top-level `system`, `max_tokens`
// mandatory, `thinking` optional);
// 2. the response is rendered by the SERVING adapter's serialize_* hooks, so
// the client sees Anthropic's shape back.
//
// The kernel therefore contains no Anthropic field names on this path: parsing
// is table-driven, and serialization belongs to the adapter.
//
// A source whose adapter does not declare serialize_* falls back to the
// OpenAI shape, which is what the kernel guarantees for an unconfigured
// deployment. That asymmetry is deliberate (see serializer.go): an Anthropic
// client talking to a source that cannot speak Anthropic gets a clear
// OpenAI-shaped answer rather than a malformed Anthropic one.
func (g *Gateway) handleMessages(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "read body: "+err.Error())
return
}
// INGRESS: the kernel's built-in Anthropic parse is the FALLBACK (R5). If an
// adapter has CLAIMED this ingress path (adapter.ingress == "/v1/messages"),
// the kernel YIELDS: parsing is done by that adapter's deserialize_request
// hook, so Anthropic protocol iteration lives in anthropic.lua, not here.
// No adapter claims it → the built-in parser below runs (unconfigured
// deployments keep working). This is the inbound dual of serialize_*.
req, handled, perr := g.parseViaIngress(r.URL.Path, string(body), g.core.DefaultModel())
if perr != nil {
writeError(w, http.StatusBadRequest, "invalid_request", perr.Error())
return
}
if !handled {
var raw anthropicRequest
if err := json.Unmarshal(body, &raw); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
req, err = raw.toUnified(g.core.DefaultModel())
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
}
model := req.Model
ctx := r.Context()
g.fireStart(ctx, &chatRequest{Messages: req.Messages, Tools: nil},
chatKind(req.Stream), model, len(req.Messages), 0)
if isAuto(model) {
chain, _ := g.core.AutoChainFor(reqKey(ctx))
if chain == nil || len(chain.Tiers) == 0 {
writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot configured")
return
}
if q := g.checkQuota(ctx, "AUTO"); q != nil {
g.writeReject(w, q)
return
}
g.serveUnified(ctx, w, chain, req, nil)
return
}
if allow := g.allowedModels(ctx); allow != nil && !g.hasScopeModel(allow, model) {
writeError(w, http.StatusForbidden, "model_not_allowed",
"model \""+model+"\" is not allowed for this key")
return
}
cands, effective := g.resolveCands(ctx, &chatRequest{Model: model})
if len(cands) == 0 {
writeError(w, http.StatusNotFound, "model_not_found",
"model \""+model+"\" is not configured")
return
}
if effective == "" {
effective = firstModel(cands[0])
}
if q := g.checkQuota(ctx, effective); q != nil {
g.writeReject(w, q)
return
}
g.serveUnified(ctx, w, nil, req, &directRoute{cands: cands, effective: effective})
}
// directRoute carries the pre-resolved candidate list for a model-pinned
// request, so serveUnified can run both routes (direct / AUTO) from one body.
type directRoute struct {
cands []*provider.Provider
effective string
}
// serveUnified runs the request through the scheduler and writes the reply via
// the serving adapter's serializer. It is the shared tail of handleChat and
// handleMessages: by this point every gate (auth, scope, quota) has already run,
// so the only remaining questions are "which source serves it" and "what shape
// does the client get".
func (g *Gateway) serveUnified(ctx context.Context, w http.ResponseWriter, chain *scheduler.Chain, req *types.ChatRequest, direct *directRoute) {
rec := &Req{Key: keyID(reqKey(ctx)), Type: "chat", OK: false}
if direct != nil {
rec.Model = direct.effective
rec.Source = firstSource(direct.cands)
} else {
rec.Type = "stream"
}
if req.Stream {
rec.Type = "stream"
}
done := g.stats.Begin()
defer done()
var (
resp *types.UnifiedResponse
chunks <-chan types.UnifiedChunk
usedSrc, usedModel string
err error
)
t0 := time.Now()
if direct != nil {
cands := toScheduler(direct.cands)
if req.Stream {
chunks, usedSrc, usedModel, err = g.core.Scheduler().ChatStream(ctx, cands, req)
} else {
resp, usedSrc, usedModel, err = g.core.Scheduler().Chat(ctx, cands, req)
}
} else {
if req.Stream {
chunks, usedSrc, usedModel, err = g.core.Scheduler().ChainChatStream(ctx, chain, req, nil, nil)
} else {
resp, usedSrc, usedModel, err = g.core.Scheduler().ChainChat(ctx, chain, req, nil, nil)
}
}
rec.LatMs = time.Since(t0).Milliseconds()
if err != nil {
g.failChat(w, rec, err)
g.writeRec(rec)
return
}
rec.OK = true
rec.Status = http.StatusOK
if usedModel != "" {
rec.Model = usedModel
}
rec.Source = usedSrc
ser := g.serializerFor(usedSrc, directProviderList(direct))
if chunks != nil {
g.fireRouted(ctx, "stream", usedSrc, usedModel, tierForRoute(direct), true)
rec.Prompt = estimatePromptTokens(req)
g.pumpStream(w, ser, rec, chunks, modelLabel(req, usedModel), t0)
return
}
recordChatUsage(rec, req, resp)
g.fireRouted(ctx, "chat", usedSrc, usedModel, tierForRoute(direct), false)
rec.FirstByteMs = rec.LatMs
g.writeRec(rec)
writeChatCompletion(w, ser, resp, modelLabel(req, usedModel))
}
// ---- Anthropic request shape (in-bound only; the kernel holds no outbound
// Anthropic field names) ----
// anthropicRequest is the client-facing parse target for POST /v1/messages.
//
// Only the fields the gateway must understand to schedule and account for a
// request appear here. Anything else the client sent is preserved verbatim in
// raw so an adapter can read it (the same "kernel does not interpret" rule that
// governs tools).
type anthropicRequest struct {
Model string `json:"model"`
System json.RawMessage `json:"system,omitempty"`
Messages []anthropicMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"`
StopSeqs []string `json:"stop_sequences,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Thinking *types.ThinkingConfig `json:"thinking,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
// raw is the untouched body, so an adapter can read fields the gateway
// has no reason to know (cache_control, service_tier, …).
raw json.RawMessage
}
// anthropicMessage accepts Anthropic's content as either a plain string or an
// array of typed blocks; both normalize to the unified message shape.
type anthropicMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
// toUnified converts the parsed request into the internal format.
func (a *anthropicRequest) toUnified(defaultModel string) (*types.ChatRequest, error) {
if len(a.Messages) == 0 {
return nil, errors.New("messages is required")
}
msgs := make([]types.ChatMessage, 0, len(a.Messages))
for i, m := range a.Messages {
u := types.ChatMessage{Role: m.Role, Content: normalizeAnthropicContent(m.Content)}
if u.Role == "" {
return nil, fmt.Errorf("messages[%d].role is required", i)
}
msgs = append(msgs, u)
}
model := a.Model
if model == "" {
model = defaultModel
}
th := a.Thinking
if th == nil {
// Anthropic's default is thinking OFF, and `thinking: {enabled:false}`
// is the explicit spelling of that. Emitting it keeps the unified
// request honest about the client's intent instead of leaving the
// field absent and letting each adapter guess.
no := false
th = &types.ThinkingConfig{Enabled: &no}
}
return &types.ChatRequest{
Model: model,
Messages: msgs,
Temperature: a.Temperature,
MaxTokens: a.MaxTokens,
Stream: a.Stream,
TopP: a.TopP,
TopK: a.TopK,
StopSequences: a.StopSeqs,
Tools: a.Tools,
ToolChoice: a.ToolChoice,
System: systemToString(a.System),
Thinking: th,
ExtraBody: map[string]interface{}{},
}, nil
}
// normalizeAnthropicContent turns Anthropic content into the unified shape.
//
// A string stays a string. An array of blocks is preserved as-is: the unified
// format deliberately carries multimodal/tool blocks verbatim (ChatMessage
// .Content is json.RawMessage) rather than flattening them, because only an
// adapter knows which block types its upstream accepts.
func normalizeAnthropicContent(raw json.RawMessage) json.RawMessage {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" {
return types.StringContent("")
}
if strings.HasPrefix(trimmed, `"`) {
return raw
}
return raw
}
// systemToString accepts Anthropic's two spellings: a plain string, or an array
// of {type:"text", text} blocks.
func systemToString(raw json.RawMessage) string {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return ""
}
if strings.HasPrefix(trimmed, `"`) {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
return ""
}
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &blocks); err != nil {
return ""
}
var sb strings.Builder
for _, b := range blocks {
if b.Type == "text" {
sb.WriteString(b.Text)
}
}
return sb.String()
}
// parseViaIngress is the kernel's INBOUND yield point, the dual of
// serializerFor (which decides the outbound serializer).
//
// Design (user requirement): an inbound endpoint such as /v1/messages is the
// KERNEL'S FALLBACK — the built-in parser in handleMessages is what an
// unconfigured deployment gets. But an adapter may CLAIM that endpoint
// (adapter.ingress == path) and declare deserialize_request; when it does, the
// kernel YIELDS parsing to it, so client-protocol iteration lives in the
// adapter and the kernel's own copy is only the safety net (R5).
//
// Returns handled=false when no adapter claims the path (or one claims it
// without a deserialize_request hook) — the caller then runs its built-in
// parser. A hook that exists but FAILS is a hard error, not a silent fallback:
// the client spoke a protocol the adapter claimed to handle, and quietly
// parsing it as a different protocol would mis-schedule the request.
func (g *Gateway) parseViaIngress(path, body, defaultModel string) (*types.ChatRequest, bool, error) {
vm := g.core.VM()
if vm == nil {
return nil, false, nil
}
adapter := vm.AdapterForIngress(path)
if adapter == "" || !vm.HasFn(adapter, "deserialize_request") {
return nil, false, nil
}
out, err := vm.Transform(adapter, "deserialize_request", body)
if err != nil {
return nil, true, fmt.Errorf("adapter %s deserialize_request(%s): %w", adapter, path, err)
}
var req types.ChatRequest
if err := json.Unmarshal([]byte(out), &req); err != nil {
return nil, true, fmt.Errorf("adapter %s deserialize_request returned an invalid unified request: %w", adapter, err)
}
if req.Model == "" {
req.Model = defaultModel
}
return &req, true, nil
}
func chatKind(stream bool) string {
if stream {
return "stream"
}
return "chat"
}
// directProviderList hands the serving-source resolver its candidate list.
func directProviderList(direct *directRoute) []*provider.Provider {
if direct == nil {
return nil
}
return direct.cands
}
func tierForRoute(direct *directRoute) int {
if direct != nil {
return -1
}
return -2
}
func modelLabel(req *types.ChatRequest, usedModel string) string {
if usedModel != "" {
return usedModel
}
return req.Model
}

View File

@ -1,404 +0,0 @@
package gateway
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
)
// 本文件端到端验证 Phase 2 的核心主张:**对外协议由适配器决定,内核只调度**。
//
// 具体做法:起一个真实的 core + gateway,源挂 anthropic 适配器,上游是一个
// 只懂 Anthropic Messages 的假服务器。然后:
//
// 1. Anthropic 客户端形状的请求 → 拿到 Anthropic 形状的响应
// 2. 调度/配额/审计与 /v1/chat/completions 走同一条路径
// 3. 上游若不支持该模型,内核如实回 503,不伪造格式
// anthropicUpstream 是一个只实现 Anthropic Messages 的假上游。
func anthropicUpstream(t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/messages") {
// 网关把请求发到了非 Anthropic 路径 —— 显式暴露这个 bug
t.Errorf("请求打到了非 Anthropic 路径: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
body, _ := io.ReadAll(r.Body)
var probe map[string]interface{}
_ = json.Unmarshal(body, &probe)
if _, ok := probe["messages"]; !ok {
t.Errorf("上游收到没有 messages 的请求体: %s", body)
}
if _, ok := probe["system"]; !ok && probe["system"] == nil {
// system 是可选的,仅在客户端给了的时候才应出现;不强制
_ = ok
}
if _, ok := probe["max_tokens"]; !ok {
t.Errorf("Anthropic 要求 max_tokens,上游请求体却缺这个字段: %s", body)
}
// Streaming requests must get a real Anthropic SSE sequence; a JSON body
// here would leave the adapter with zero frames and the gateway would
// (correctly) reject the stream as empty.
if stream, _ := probe["stream"].(bool); stream {
w.Header().Set("Content-Type", "text/event-stream")
fl, _ := w.(http.Flusher)
frames := []string{
`event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_mock","type":"message","role":"assistant","model":"` + probeModel(probe) + `","content":[],"usage":{"input_tokens":3,"output_tokens":0}}}`,
`event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
`event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}`,
`event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`,
`event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`,
`event: message_stop` + "\n" + `data: {"type":"message_stop"}`,
}
for _, f := range frames {
fmt.Fprint(w, f+"\n\n")
if fl != nil {
fl.Flush()
}
}
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{
"id":"msg_mock","type":"message","role":"assistant","model":"`+probeModel(probe)+`",
"content":[{"type":"thinking","thinking":"let me think"},{"type":"text","text":"pong"}],
"stop_reason":"end_turn",
"usage":{"input_tokens":3,"output_tokens":5}
}`)
}))
}
func probeModel(m map[string]interface{}) string {
if s, ok := m["model"].(string); ok {
return s
}
return ""
}
// messagesGateway 起一个真实网关,源挂 anthropic 适配器。
func messagesGateway(t *testing.T) (*httptest.Server, string) {
t.Helper()
up := anthropicUpstream(t)
t.Cleanup(up.Close)
dir := t.TempDir()
// adapterDir 故意不预先创建:VM 首启会把内置适配器 seed 进去。
// 预先创建会被当成"目录已存在即为权威",于是没有任何内置适配器被加载,
// 表现为 "adapter anthropic not loaded"。
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "claude-test",
GatewayKeys: []string{"sk-e2e-0001"},
Sources: []config.Source{{
Name: "anth", BaseURL: up.URL, Adapter: "anthropic",
APIKey: "k", MaxConcurrent: 4,
Models: []config.Model{{ID: "claude-test", Kind: "chat", Priority: 100}},
}},
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
gw, err := New(c)
if err != nil {
t.Fatalf("gateway: %v", err)
}
ts := httptest.NewServer(gw.Handler())
t.Cleanup(ts.Close)
return ts, "sk-e2e-0001"
}
func postJSON(t *testing.T, url, key, body string) (int, string) {
t.Helper()
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
// TestMessagesEndpointSpeaksAnthropic 是本文件的主断言:Anthropic 客户端
// 打 /v1/messages,拿到 Anthropic 形状。
func TestMessagesEndpointSpeaksAnthropic(t *testing.T) {
ts, key := messagesGateway(t)
code, body := postJSON(t, ts.URL+"/v1/messages", key,
`{"model":"claude-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
if code != 200 {
t.Fatalf("status = %d: %s", code, body)
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(body), &m); err != nil {
t.Fatalf("响应不是 JSON: %v (%s)", err, body)
}
if m["type"] != "message" {
t.Errorf("type = %v, want message(内核不该退回 OpenAI 的 chat.completion)", m["type"])
}
if m["stop_reason"] != "end_turn" {
t.Errorf("stop_reason = %v, want end_turn", m["stop_reason"])
}
blocks, _ := m["content"].([]interface{})
if len(blocks) != 2 {
t.Fatalf("content blocks = %v, want [thinking, text]", m["content"])
}
if b0 := blocks[0].(map[string]interface{}); b0["type"] != "thinking" || b0["thinking"] != "let me think" {
t.Errorf("首个块应为 thinking: %v", b0)
}
if b1 := blocks[1].(map[string]interface{}); b1["type"] != "text" || b1["text"] != "pong" {
t.Errorf("第二个块应为 text/pong: %v", b1)
}
u := m["usage"].(map[string]interface{})
if u["input_tokens"] != float64(3) || u["output_tokens"] != float64(5) {
t.Errorf("usage 应为 Anthropic 的 input/output 命名: %v", u)
}
// 内核不得混入 OpenAI 形状
for _, forbidden := range []string{`"object"`, `"choices"`, `"chat.completion"`, `"prompt_tokens"`} {
if strings.Contains(body, forbidden) {
t.Errorf("OpenAI 字段 %s 混入 Anthropic 响应: %s", forbidden, body)
}
}
}
// TestMessagesStreamSpeaksAnthropic 验证流式也是 Anthropic 事件序列,
// 而不是 OpenAI SSE。
func TestMessagesStreamSpeaksAnthropic(t *testing.T) {
ts, key := messagesGateway(t)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/messages",
strings.NewReader(`{"model":"claude-test","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
s := string(body)
if !strings.Contains(s, "message_start") {
t.Errorf("Anthropic 流应以 message_start 开头: %s", s)
}
if !strings.Contains(s, "message_stop") {
t.Errorf("Anthropic 流应以 message_stop 收尾: %s", s)
}
// 内核不得擅自给 Anthropic 流加 OpenAI 的 [DONE]
if strings.Contains(s, "[DONE]") {
t.Errorf("Anthropic 流里出现了 OpenAI 的 [DONE] 终止标记: %s", s)
}
if strings.Contains(s, "chat.completion.chunk") {
t.Errorf("流里混入 OpenAI chunk 形状: %s", s)
}
}
// TestMessagesRejectsBadRequest 确认入向校验(Anthropic 要求 max_tokens)
// 与 OpenAI 路径一样在入口拒绝,不占用上游槽位。
func TestMessagesRejectsBadRequest(t *testing.T) {
ts, key := messagesGateway(t)
// Anthropic 允许省略 max_tokens(上游会给默认),所以「有 messages 无
// max_tokens」必须被接受;真正该拒的是没有 messages。
code, body := postJSON(t, ts.URL+"/v1/messages", key,
`{"model":"claude-test","messages":[{"role":"user","content":"hi"}]}`)
if code != 200 {
t.Fatalf("有 messages 的请求应被接受(max_tokens 可省),却得 %d: %s", code, body)
}
code, body = postJSON(t, ts.URL+"/v1/messages", key, `{"model":"claude-test"}`)
if code != 400 {
t.Errorf("无 messages 的请求 status = %d, want 400: %s", code, body)
}
// 坏 JSON
code, _ = postJSON(t, ts.URL+"/v1/messages", key, `{"model":`)
if code != 400 {
t.Errorf("坏 JSON status = %d, want 400", code)
}
}
// TestMessagesUnconfiguredFallsBackToOpenai 验证 R5 的兜底在新增端点上同样
// 生效:源没有声明序列化钩子时(此例用 openai 适配器),回 OpenAI 形状。
func TestMessagesUnconfiguredFallsBackToOpenai(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
}))
defer up.Close()
dir := t.TempDir()
// adapterDir 故意不预先创建:VM 首启会把内置适配器 seed 进去。
// 预先创建会被当成"目录已存在即为权威",于是没有任何内置适配器被加载,
// 表现为 "adapter anthropic not loaded"。
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "gpt-test",
GatewayKeys: []string{"sk-e2e-0001"},
Sources: []config.Source{{
Name: "oai", BaseURL: up.URL, Adapter: "openai", APIKey: "k", MaxConcurrent: 4,
Models: []config.Model{{ID: "gpt-test", Kind: "chat"}},
}},
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
gw, err := New(c)
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(gw.Handler())
defer ts.Close()
code, body := postJSON(t, ts.URL+"/v1/messages", "sk-e2e-0001",
`{"model":"gpt-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
if code != 200 {
t.Fatalf("status = %d: %s", code, body)
}
if !strings.Contains(body, "chat.completion") {
t.Errorf("无序列化钩子的源应回落 OpenAI 兜底形状: %s", body)
}
}
// TestMessagesRequiresAuth 确认新端点走同一套鉴权(不因为是新协议而放行)。
func TestMessagesRequiresAuth(t *testing.T) {
ts, _ := messagesGateway(t)
code, _ := postJSON(t, ts.URL+"/v1/messages", "wrong-key",
`{"model":"claude-test","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}`)
if code != 401 {
t.Errorf("错误 key status = %d, want 401", code)
}
// 无 auth
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/messages", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 401 {
t.Errorf("无 key status = %d, want 401", resp.StatusCode)
}
_ = context.Background()
}
// TestMessagesIngressYieldsToBundledAnthropic 是 ingress 让出的主断言(用户要求:
// /v1/messages 是内核兜底端点,适配器可声明它、内核主动让出)。
//
// 事实前提:内置 anthropic.lua 自己声明了 adapter.ingress = "/v1/messages",而它
// 默认随包播种。所以真实部署里内核**总是**让出给 anthropic 适配器,
// anthropicRequest.toUnified 退化为纯兜底(仅在适配器被移除/禁用时运行)。
//
// 断言方式:给源挂一个「上游即 Anthropic」的假服务器,客户端送一个 Anthropic
// 形状请求。若内核让出,请求由适配器 deserialize_request 解析 → 路由/调度正常。
// 同时用一个**只有系统消息**的请求验证解析确实走了适配器语义(Go 侧 system
// 处理与 Lua 侧一致)。
func TestMessagesIngressYieldsToBundledAnthropic(t *testing.T) {
ts, key := messagesGateway(t)
if code, body := postJSON(t, ts.URL+"/v1/messages", key,
`{"model":"claude-test","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}`); code != 200 {
t.Fatalf("让出后 /v1/messages 应 200, 实得 %d: %s", code, body)
}
}
// TestAdapterForIngressClaim 直接验证内核的让出判定:声明了 ingress 的适配器
// 会被 AdapterForIngress 找到;未声明的路径返回空(→ 内核用自己的兜底解析)。
func TestAdapterForIngressClaim(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "m",
GatewayKeys: []string{"sk-e2e-0001"},
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
vm := c.VM()
// 内置 anthropic 声明了该端点
if got := vm.AdapterForIngress("/v1/messages"); got != "anthropic" {
t.Errorf("内置 anthropic 应认领 /v1/messages, 实得 %q", got)
}
// 未声明的路径 → 空 → 内核走自己的兜底解析
if got := vm.AdapterForIngress("/v1/nothing"); got != "" {
t.Errorf("未声明路径应为空, 实得 %q", got)
}
// 内置 anthropic 确实有 deserialize_request 钩子(否则内核会让出给一个空解析器)
if !vm.HasFn("anthropic", "deserialize_request") {
t.Error("anthropic 应实现 deserialize_request")
}
}
// TestMessagesKernelFallbackWhenNoAdapterClaims 证明兜底仍在:显式删掉所有
// 声明 ingress 的适配器后,内核自己的 anthropicRequest.toUnified 接管,
// /v1/messages 仍能服务(R5 兜底语义)。
func TestMessagesKernelFallbackWhenNoAdapterClaims(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
}))
defer up.Close()
dir := t.TempDir()
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "gpt-test",
GatewayKeys: []string{"sk-e2e-0001"},
Sources: []config.Source{{
Name: "oai", BaseURL: up.URL, Adapter: "openai", APIKey: "k", MaxConcurrent: 4,
Models: []config.Model{{ID: "gpt-test", Kind: "chat"}},
}},
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
// 摘掉声明 ingress 的适配器(内置 anthropic),模拟"没有适配器认领该端点"。
c.VM().RemoveAdapter("anthropic")
if got := c.VM().AdapterForIngress("/v1/messages"); got != "" {
t.Fatalf("移除后应无适配器认领, 实得 %q", got)
}
gw, err := New(c)
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(gw.Handler())
defer ts.Close()
// 内核兜底解析 Anthropic 形状 → 200(走 OpenAI 兜底序列化)
code, body := postJSON(t, ts.URL+"/v1/messages", "sk-e2e-0001",
`{"model":"gpt-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
if code != 200 {
t.Fatalf("无适配器认领时内核兜底应仍 200, 实得 %d: %s", code, body)
}
}

View File

@ -1,231 +0,0 @@
// Package gateway — 对外挂载点(R3 / Phase 6)
//
// 用户需求:插件与适配器可以注册**对外服务的端口**。
//
// 现状:插件已经能注册 HTTP 端点,但被固定在 /api/plugins/http/ 前缀下。
// 那条前缀限制是有理由的(可 grep、可整体撤销、插件无法影子内核路由),
// 但它也意味着插件无法提供"看起来像内核一部分"的接口——而对外服务恰恰
// 需要这个(例如给上游回调用的固定路径 /v1/plugins/<name>/callback)。
//
// 本文件放开这一点,同时把当初那条限制的**三个理由**逐条补回来:
//
// 1. 可 grep / 可整体撤销 → 每条自定义挂载都必须落在 /plugins/ 或
// /v1/plugins/ 命名空间内(不接受任意前缀)
// 2. 不能影子内核路由 → 与内核保留路由表做前缀冲突检测,撞了直接拒绝
// 3. 鉴权默认关闭 → 沿用 HTTPHandler 的 roles/keys/public 语义
package gateway
import (
"fmt"
"net/http"
"sort"
"strings"
"llmsproxy/internal/lua"
)
// allowedMountPrefixes are the only namespaces a plugin/adapters may claim.
//
// A free-form prefix would let a plugin register "/v1/chat/completions" and
// silently intercept every inference request on the gateway — the exact
// shadowing the narrow default prefix was protecting against. Constraining the
// namespace keeps the security property while still allowing a stable public
// path.
var allowedMountPrefixes = []string{"/plugins/", "/v1/plugins/"}
// kernelReservedPrefixes are prefixes the kernel owns. A custom mount that
// overlaps any of them is refused, so an operator's route table can never
// change meaning because a plugin was installed.
//
// This list is deliberately explicit rather than derived from the router's
// switch: the switch is code, and a future branch added there would silently
// become shadowable if this were inferred. The test
// TestKernelRoutesAreAllCoveredByReservedPrefixes keeps the two in sync.
var kernelReservedPrefixes = []string{
"/v1/chat/completions",
"/v1/messages",
"/v1/images/generations",
"/v1/models",
"/api/",
"/login",
"/ui",
"/",
}
// ValidateCustomMount checks that a plugin-declared mount point is allowed.
//
// Returns the normalized path (leading slash, no trailing slash) or an error
// naming the conflict. The error text is what an operator sees in the plugin's
// load-error field, so it says WHICH rule was hit.
func ValidateCustomMount(p string) (string, error) {
p = strings.TrimSpace(p)
if p == "" {
return "", fmt.Errorf("empty mount path")
}
if !strings.HasPrefix(p, "/") {
return "", fmt.Errorf("mount path %q must start with /", p)
}
if strings.Contains(p, "..") || strings.Contains(p, "//") {
return "", fmt.Errorf("mount path %q contains an empty or escaping segment", p)
}
norm := strings.TrimRight(p, "/")
if norm == "" {
norm = "/"
}
// Rule 1: must live in one of the allowed namespaces.
ok := false
for _, pre := range allowedMountPrefixes {
if strings.HasPrefix(norm+"/", pre) {
ok = true
break
}
}
if !ok {
return "", fmt.Errorf("mount path %q must be under %s",
norm, strings.Join(allowedMountPrefixes, " or "))
}
// Rule 2: must not overlap a kernel route.
for _, res := range kernelReservedPrefixes {
if res == "/" {
continue // the catch-all is handled by the namespace rule above
}
if norm == strings.TrimRight(res, "/") || strings.HasPrefix(norm, res) {
return "", fmt.Errorf("mount path %q collides with the kernel route %q", norm, res)
}
}
return norm, nil
}
// RouteOwner describes one occupied route, for conflict reporting.
type RouteOwner struct {
Path string
Owner string // "kernel", "plugin:name", "adapter:name"
}
// BuildRouteTable assembles the set of paths a plugin/adapters must not take.
// It is used both to validate new mounts and to explain a conflict to an
// operator (a bare "conflict" is not actionable; "already served by plugin X" is).
func BuildRouteTable(pluginMounts, adapterMounts map[string]string) []RouteOwner {
var out []RouteOwner
for _, p := range kernelReservedPrefixes {
if p == "/" {
continue
}
out = append(out, RouteOwner{Path: strings.TrimRight(p, "/"), Owner: "kernel"})
}
for path, owner := range pluginMounts {
out = append(out, RouteOwner{Path: path, Owner: "plugin:" + owner})
}
for path, owner := range adapterMounts {
out = append(out, RouteOwner{Path: path, Owner: "adapter:" + owner})
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
// FindMountConflict reports whether candidate overlaps an occupied route and, if
// so, who holds it.
//
// Overlap is checked in BOTH directions: a new mount that CONTAINS an existing
// one shadows it just as effectively as one nested inside it, and only checking
// the prefix direction would let "/plugins/x/sub" be registered before
// "/plugins/x" and then silently swallow it.
func FindMountConflict(candidate string, table []RouteOwner) (RouteOwner, bool) {
c := strings.TrimRight(candidate, "/")
for _, r := range table {
rp := strings.TrimRight(r.Path, "/")
if rp == "" {
continue
}
if c == rp || strings.HasPrefix(c, rp+"/") || strings.HasPrefix(rp, c+"/") {
return r, true
}
}
return RouteOwner{}, false
}
// handleCustomMount dispatches a request to a plugin that claimed a custom mount
// path (/plugins/<name>/… or /v1/plugins/<name>/…).
//
// It deliberately REUSES handlePluginHTTP rather than reimplementing dispatch:
// that function already does longest-match resolution, per-endpoint
// roles/keys/public authorization and the Lua call protocol. Duplicating it here
// would create two dispatch paths that must be kept in agreement — and a
// security rule that is enforced on only one of them is worse than none.
//
// The rewrite below maps a claimed mount onto the handler table's canonical
// form: handler paths are declared as "<name>/<rest>", so the request path is
// reduced to exactly that. Anything that does not reduce cleanly (a missing
// name, a name with no registered handler) is a 404, not a fallthrough — a
// custom namespace that silently answered from the default prefix would make
// the mount validation meaningless.
func (g *Gateway) handleCustomMount(w http.ResponseWriter, r *http.Request) {
owner, rest, ok := parseMountRef(r.URL.Path)
if !ok || owner == "" {
writeError(w, http.StatusNotFound, "not_found", "malformed plugin mount path")
return
}
ps := g.core.Plugins()
if ps == nil {
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
return
}
// Resolve which handler owns this mount, so an unclaimed namespace is a 404.
handlers := ps.HTTPHandlers()
candidate := owner
if rest != "" {
candidate = owner + "/" + rest
}
if !mountClaimed(handlers, candidate) {
writeError(w, http.StatusNotFound, "not_found",
"no plugin serves "+r.URL.Path)
return
}
// Reuse the default dispatcher by presenting the path in its own shape.
r2 := r.Clone(r.Context())
r2.URL.Path = pluginHTTPPrefix + candidate
g.handlePluginHTTP(w, r2)
}
// mountClaimed reports whether any registered handler's path is a prefix of the
// candidate (longest-match semantics: /plugins/auth/token is served by a
// handler declared as "auth").
func mountClaimed(handlers []lua.HTTPHandler, candidate string) bool {
for i := range handlers {
h := strings.Trim(handlers[i].Path, "/")
if h == "" {
continue
}
if candidate == h || strings.HasPrefix(candidate, h+"/") {
return true
}
}
return false
}
// parseMountRef splits "/plugins/<name>/<rest>" or "/v1/plugins/<name>/<rest>"
// into its owner name and remaining path.
//
// The "/v1/plugins/" form is checked first because it has the longer prefix:
// testing "/plugins/" first would match nothing for a /v1/ path, but keeping the
// ordering explicit avoids a future prefix being added that nests inside
// another.
func parseMountRef(p string) (owner, rest string, ok bool) {
prefixes := []string{"/v1/plugins/", "/plugins/"}
for _, prefix := range prefixes {
if !strings.HasPrefix(p, prefix) {
continue
}
trimmed := strings.TrimPrefix(p, prefix)
if trimmed == "" {
return "", "", false
}
if i := strings.Index(trimmed, "/"); i >= 0 {
return trimmed[:i], trimmed[i+1:], true
}
return trimmed, "", true
}
return "", "", false
}

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