Compare commits

...

111 Commits

Author SHA1 Message Date
a07449d32e fix(plugins): 出站主机授权两处真缺陷 —— 已启动 worker 看不到新授权、授权不落盘
生产实证:channels 插件的 auth/state 在主机已授权后仍被拒
(auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination),
而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。

缺陷一(授权对已启动 worker 无效):
  httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua
  闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**
  (newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回
  ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。
  修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与
  rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker
  无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。

缺陷二(授权不持久化):
  .kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。
  设置页看不出异常(actions 有持久化,读起来像"都持久化了")。
  修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容,
  纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权
  与 actions 一起写入(支持"只批了主机、没批动作"的行)。

回归测试(internal/lua/plugin_http_test.go):
- TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动
  worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。
- TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载
  后授权保留。

go test ./internal/lua/ 全绿;build/vet 通过。
2026-10-13 06:57:28 +08:00
84a9fccd9a fix(plugins): 新内置插件要能落地 —— SeedBundled 改为按文件补齐,deploy.sh 补插件同步
真实事故:channels.lua(多平台账号接入插件)在仓库里、在 2.0 二进制里
(go:embed),生产却完全看不到它 —— /api/plugins 只有 billing/quota/usage。

根因是两处叠加:

1. SeedBundled 只在 plugin_dir **不存在**时才写内置插件。生产 plugin_dir 从
   首次安装起就存在,于是后来加入二进制的新插件永远不会落盘。目录存在不等于
   内容是最新的 —— 这个假设把"新增内置插件"整条路径堵死了。
   改为**按文件补齐**:只在该文件缺失时才 seed,已存在的一律不动(运维自己改
   过、或运行时新增的插件必须跨升级存活 —— 与适配器同一条规则)。

2. deploy.sh 完全不处理 plugins(只同步 adapters/)。即使内核修好了,部署脚本
   也没有把插件内容与仓库对齐的动作。新增 sync_plugins:备份、只补缺、对已有
   文件零改动、末尾逐个校验内嵌插件都在目标目录,摘要里打印插件目录。

测试(internal/lua/vm_test.go):
- TestSeedBundledFillsMissingFilesInExistingDir:构造确切事故条件(目录已存在、
  只有部分插件),断言缺失的被补齐、运维自己的 billing.lua 不被覆盖、补齐后
  channels 确实出现在插件表里。
- TestSeedBundledIdempotent:二次 seed 不覆盖已就位文件。

deploy.sh 通过 bash -n;go test ./internal/lua/ 全绿。
2026-10-12 19:07:09 +08:00
63549db832 Merge feature/unified-wire-v2: 统一线格式 v2 + 适配器接管双向 + 冲突裁决
R1–R6 全部落地:最大集合统一格式、对外序列化移交适配器(serialize_*)、
入向解析移交适配器(adapter.ingress + deserialize_request)、插件适配器桥接、
插件长期运行环境、适配器导入路径校验、插件与服务路径冲突裁决。
2026-10-12 17:09:32 +08:00
d6d41209d9 test(adapters): 逐适配器两向能力断言 + AdapterIngress 访问器
用户要求:16 个适配器逐个验证两个方向(出向序列化 / 入向解析)重构后都没坏。

- 新增 VM.AdapterIngress(name):暴露适配器认领的入站路径,供运维面与测试查看。
- 新增 TestEveryBundledAdapterTwoDirections:对每个内置适配器断言
  1) 上游向 transform_request 存在且可调用;
  2) 出向 — 声明了 serialize_response 的必须可调用且产出合法 JSON/SSE,
     未声明的确认走内核 OpenAI 兜底(两种都合法);
  3) 入向 — 声明了 adapter.ingress 的必须同时实现 deserialize_request
     (否则内核会让出给空解析器),实现了钩子却没声明 ingress 的也报错
     (内核永远不会调用它)。
  12 个内置适配器全部通过。

隔离实例实测(19 源 + 16 适配器):
- 启动无解密失败/崩溃,16 适配器全加载。
- 入向让出决定性实证:装入认领 /v1/messages 的 zzclaimer 并移除 anthropic 后,
  畸形 body 被适配器解析成 model=ZZ-CLAIMER-WAS-HERE,内核据此路由得
  404 model_not_found —— 证明内核确实让出(活实例复现,非仅单测)。
- 真实请求两向贯通:deepseek-v4-flash / sensenova-6.8-flash-lite 返回 200 真实响应;
  其余源返回上游 502(配额/渠道),说明请求正确到达了各适配器的上游。
2026-10-12 16:37:23 +08:00
3c59c33c58 feat(adapters): 入向端点移交适配器 —— adapter.ingress + deserialize_request,内核主动让出
plan.md 的 R2 目标里有三行钩子契约,出向两条(serialize_response/serialize_chunk)
Phase 2 已落地,第三条 parse_request(protocol_body) -> unified 一直空着:
/v1/messages 的入向解析硬编码在内核 Go 里(anthropicRequest.toUnified),
Anthropic 改协议就得改内核。本次补齐这条对偶。

## 机制(与出向序列化对称)

- 适配器声明 adapter.ingress = "/v1/messages" 认领入站路径,并实现
  deserialize_request(raw_body) -> unified_json。
- 内核 handleMessages 经新增的 parseViaIngress 判定:
  - 有适配器认领该路径且实现了钩子 → 让出,解析由适配器完成;
  - 无适配器认领 → 内核内置解析运行(R5 兜底,未配置部署仍可服务)。
- 认领了但钩子执行失败 → 硬错误 400,不静默换用另一种协议解析
  (客户端说的正是该适配器声称支持的协议,悄悄换解析器只会错误调度)。
- 这是内核里唯一的新判定点,与 serializerFor 对称:出向选序列化器,入向选解析器。

## 内核改动

- internal/lua/vm.go:staticInfo 增 ingress 字段;inspectScript 读取;
  新增 VM.AdapterForIngress(path)(确定性选择:同名多认领取字典序最小)。
- internal/gateway/messages.go:handleMessages 改为先读 body → parseViaIngress
  (让出)→ 未认领时回落内置 anthropicRequest.toUnified。
  内置解析与 anthropicRequest 类型保留——它们是兜底,不是死代码。

## 适配器改动

- internal/lua/adapters/anthropic.lua:声明 adapter.ingress = "/v1/messages",
  新增 deserialize_request,把内核原 Go 解析逻辑逐字段移植(system 两种拼写、
  content 原样透传、缺省 thinking={enabled:false}、缺 messages 报错)。
  内置 anthropic 默认随包播种,因此真实部署里内核总是让出给它,
  Go 侧 anthropicRequest.toUnified 退化为纯兜底。

## 测试

- gateway:TestMessagesIngressYieldsToBundledAnthropic(让出后仍 200)、
  TestAdapterForIngressClaim(声明可被找到、未声明路径为空、钩子存在)、
  TestMessagesKernelFallbackWhenNoAdapterClaims(移除认领适配器后内核兜底仍 200)。
- lua:TestAnthropicDeserializeRequestShape 逐字段核对 Lua 与 Go 产出一致
  (system 拼接、content、max_tokens/stream/temperature/top_p/top_k/stop_sequences、
  缺省 thinking),TestAnthropicDeserializeRejectsMissingMessages。
- 全量回归:gateway/lua/core/config/types/provider/scheduler/e2e 全绿。
- 文档:docs/lua-adapters.md 补「入向让出钩子」章节 + plan.md 台账更新。
2026-10-12 16:32:30 +08:00
5948ad19ca feat(plugins): 服务路径/适配器名冲突进裁决而非崩溃或硬拒绝(用户要求 A+B)
此前两类冲突各有问题:
A) 两个插件声明同一条 /api/plugins/http/ 服务路径 → 分发器按加载顺序静默
   选一个(装个新插件就可能改变既有路径归属)。
B) 插件装与内置/他插件同名的适配器 → 硬错误 ErrAdapterNameReserved / owned by。

用户要求:都改成「报冲突、列出候选、运维选一个保留、选择持久化」,绝不崩溃
或拒绝启动。

## A 类:插件 HTTP 服务路径冲突
- dispatch(plugin_http_routes.go)同路径多认领时不再 load-order 选边:未裁决
  返回 409 并点名候选;已裁决按赢家分发。
- route_conflicts.go:RouteConflicts/RouteWinner/ResolveRouteConflict,只统计
  loaded 且未禁用的插件;赢家插件若不再声明该路径,裁决作废回未裁决态。

## B 类:适配器名冲突
- InstallAdapter 撞名(内置或他插件所有)不再硬拒,改挂起:现任继续服务,候选
  入 AdapterConflicts,返回 typed ErrAdapterConflictPending(区分「代码错」)。
- ResolveAdapterConflict:incumbent 保留现任 / candidate 真正装上插件版(绕过
  reserved 门禁——这是运维显式授权覆盖内置的唯一入口)并转移归属。
- 现任内置永不被静默覆盖,守住「第三方插件不得篡改内核发行物」信任边界。

## 持久化 + WebAPI + WebUI
- 两张裁决表落 .route-conflicts.json(routes + adapters),LoadDir 时读回;
  重启后插件再装同名适配器自动套用上次裁决。
- WebAPI:GET /api/plugins/conflicts 列冲突;POST {kind,path|name,winner} 裁决。
- WebUI:插件页顶部「冲突裁决」卡片,逐条列路径/适配器冲突 + 候选 + 裁决按钮。

## 测试
- 单测 conflict_arbitration_test.go 6 项全过:路径冲突检测/裁决/持久化/重启、
  赢家消失回退、适配器撞名挂起/incumbent/candidate/归属转移、非法 winner 拒绝、
  自身更新不受影响(token 轮换不走冲突路径)。
- e2e conflict_arbitration_e2e_test.go 2 项全过(真二进制):两插件抢路径→409
  点名→裁决→200 赢家应答→/v1/models 无回归;插件装 shadow openai→挂起→现任
  保留→裁决 candidate→插件版生效→冲突清空。
- 全量 build + vet 通过,既有 lua/gateway/core 回归无破坏。

修正过程中发现并修了一个真 bug:LoadAdapterSource 对 key≠declared 的适配器
重装会建第二个池、留旧池继续服务(v2 装了 v1 还在跑)——改成统一按注册键
归档,裁决 candidate 后新版本才真正生效。
2026-10-12 12:32:10 +08:00
6b6f9526ff feat(channels): 对外功能面全量对齐参考 channel-pack RPC + 逐平台余额/签到状态端点
此前只实现了参考 RPC 面的一部分(账号池/模型开关/供应商/备份)。本次按
用户要求把对外呈现的功能补齐到与参考 channel-pack-rpc.ts 一致,并逐平台
移植真实余额/签到状态端点(不再'选一部分')。

## 新增 RPC(对齐 channel-pack-rpc.ts 的 case 清单)

credits.status   逐账号签到状态(平台声明 checkin_status 时查,status_fn 解析;
                 无端点平台如实 null,与参考对 CodeArts/LobsterAI 处理一致)
credits.balances 逐账号余额(平台声明 balance 时查,total_fn 解析;无端点/无凭据
                 返回 {balance=null,error=...} 不编造数字 —— 对齐参考'不伪装余额')
credits.permanentLock  永久积分锁定读写(buddy/loomy 等)
account.reset/retest   清限流标记 / 重测(支持 __all__ 批量)
model.setDisabledMany  批量模型开关
model.clearDead        清黑名单里已下线的死模型(只清不在 models 表的 id)
provider.getOrder/setOrder  供应商显示顺序(bogus id 过滤)
gateway.getEnabled/setEnabled  网关整体开关(复用 provider_set_enabled 语义)
backup.status          备份健康度(账号数/无过期时间账号数)
usage.tokenLedger      本网关 token 用量快照(数据源 /api/stats,与 usage 插件同思路)
usage.badge            渠道徽标(credits.balances 汇总)
usage.badgePreference  徽标偏好读写(非法值拒绝不回落,对齐参考注释)
onboarding.status/claim 新人任务(raccoon 桌面登录奖励 3000 分实测端点)

## 逐平台真实余额/签到端点(照各 *-credits.ts 逐字移植)

minimax  signin/status 预检 + credit/details 余额(total=Σ remaining_amount,
         避开参考踩过的 total_count 误读坑)
trae     checkin_credits/status + ide_user_ent_usage(remain=Σ(limit-amount))
buddy    checkin-activity-status(不是 checkin-status 占位坑)+ get-user-resource
cline    usage-limits(dollars,取剩余百分比最小的限额)
lobsterai profile-summary;qoder me/usage(userQuota+addOnQuota);raccoon points/v1/balance
每平台 CHANNEL_CREDITS 能力表声明 unit/claim(对齐参考同名表)。

## WebUI 补齐

账号行展示逐账号余额(credits.balances);详情动作区加「重置限流」「清死模型」;
顶栏加「Token 用量」弹窗(usage.tokenLedger)。node --check 过。

## 测试

TestChannelsExtendedRPCBehavior 10 子项全过:balances 无凭据报错不编造、status
无端点 null、reset、setDisabledMany、clearDead 只清死模型、provider order 过滤、
backup.status、badgePreference 非法拒绝、onboarding supported=false、permanentLock 读写。
全量 8 包回归通过。

已知未移植(如实说明,均需本机无法提供的运行时):captcha.*(zcode 验证码载体,
需浏览器端求解器)、login.sendSms/submitSms(loomy 短信,备用路径)、
cline.requestLog/usage.tokenLedgerHistory(历史聚合,内核未落盘日维度表)。
2026-10-12 08:45:49 +08:00
4d0efab86b feat(channels): 渠道控制台对齐参考 ChannelCard —— 账号池/模型开关/供应商治理/备份 + 全量 UI
用户要求与示例插件 dsh-our-free-model 的 WebUI 全面对齐。参考前端的
核心是 ChannelCard(client.js 3627 行起):一个渠道可挂**多账号池**,
账号可逐个启用/停用/测试/删除/排序,模型可逐个开关,供应商整体开关,
账号备份导出/导入,自动签到开关。此前是单凭据 + 平表登录,差距大。

## 后端(channels.lua 新增 19 个 RPC,对齐参考 channel-pack 契约)

账号池(对齐参考 account.*):
  account_add/list/update/delete/test/refresh/reorder —— 一平台多账号,
  每账号一条上游源 channels-<plat>-<aid>,凭据走保险库(0600 加密落盘)。
  complete_login 带 account_id 时走池路径回填凭据。
模型开关(对齐 model.*):
  model_list/set_disabled/set_all_disabled —— 黑名单存 state,开关即重同步
  该平台所有账号源(关掉模型=该账号源里去掉该模型)。
供应商治理(对齐 provider.*):
  provider_status(账号数/模型数/closed 态)+ provider_set_enabled
  (关闭=全关模型+停用全部账号;打开=清黑名单+启用全部账号)。
一键领取(对齐 credits.claimAll):
  credits_claim_all —— 逐账号 claim_daily 收集结果,返回 {claimed, results}。
备份(对齐 backup.*):
  backup_export/import —— 账号池+凭据+模型黑名单+自动签到开关打包,导入
  恢复凭据(走保险库)+重建全部账号源。
自动签到开关(对齐 usage.autoCheckin):
  auto_checkin —— 读写 state.settings.auto_checkin,关闭时 auto_claim 跳过。
auto_claim 升级为账号池驱动(逐账号 claim + 尊重 CHANNEL_CREDITS 能力表)。

**修一个真 bug**:__llmsproxy_secrets.set 返回 (true, nil) 成功 / (nil, err) 失败,
我多值赋给单变量拿到第一个返回值,误判为失败 → 改为 local _, serr = set(...)。

## WebUI(整个重写,对齐 ChannelCard 交互)

渠道网格(搜索/筛选全部·已接入·待接入/汇总条已接入N·账号总数)
→ 渠道详情(账号池列表:昵称/启用态/上次领取摘要 + 测试/刷新/停用/优先/删除;
模型折叠:逐个开关 + 全部启用/关闭 + 计数;添加账号=登录(占位账号先入池,
login 完成回填凭据,两步式对齐参考 account.create);一键领取额度(结果
摘要展示成功数+失败原因);供应商整体开关;自动签到开关;导出/导入备份)。
登录区仍覆盖 device-poll/callback/paste/manual/vnc 全部流程。
node --check 过内联 JS;凭据只经 complete_login 落保险库,不回显。

## 行为测试(channels_pool_test.go)

TestChannelsAccountPoolBehavior 5 个子测试全过:
  账号增删改(含返回 account_id、改名、删空)
  模型开关同步上游源(3模型→关1→2→全关→closed=true→全开恢复)
  enabled开关同步上游源(停用删源、启用重建源)
  供应商整体开关(关→无源+closed;开→源重建)
  备份导出含凭据明文

## 已知边界(如实说明,未假装对齐)

- 余额逐账号展示:参考有 credits.balances(真实查上游余额),本环境各平台余额
  端点形状差异大且多数需真实凭据,未编造假余额;账号行只显示上次领取摘要。
- usage.tokenLedger / cline.requestLog(用量账本/请求日志页):参考有,本插件
  内核侧已有 usage 记录但未暴露给插件,暂缺这两页(其余控制台能力已对齐)。

回归:全量 8 包(core/lua/gateway/config/types/scheduler/provider/e2e)通过。
2026-10-12 08:18:33 +08:00
b6e687b567 test(e2e): 补齐 Cline/paste/manual 三条流程真二进制端到端
TestChannelsFlowsE2E 用真二进制 + 假平台(loopback 白名单天然放行)跑通:

  ① Cline(WorkOS device-poll 两步)
     假上游:device code → authenticate(首拍 authorization_pending,次拍
     access/refresh) → register 换 CLINE-E2E-AT。
     断言:首拍会话仍 pending;次拍 completed 且 account = register
     userInfo.email(dev@example.com);register 恰好调用一次(防重复换证);
     /v1/models 出现 stealth/space-bunny-alpha(凭据已是 register 的最终
     token,不是 WorkOS token —— 这是 deviceRegister 第二跳的价值)。

  ② paste(opencode)
     start_login 会话 flow=paste → complete_login 贴 OC-E2E-KEY →
     /v1/models 出现 big-pickle。验证 WebUI 粘贴路径(chComplete)能落保险库。

  ③ manual(qoder)
     start_login 返回 manual_hint 含 'WASM'(明确说明不可自动而非假装
     成功)→ complete_login 贴 QD-E2E-KEY → /v1/models 出现 qoder-default。

三条流程统一终局标准:/v1/models 出现该平台模型 = 接入源成功且可路由。
e2e 全量 10 项 PASS。
2026-10-12 07:20:48 +08:00
2353d43c16 feat(channels): 全量厂商接入 —— Cline/Buddy/Gemini/LobsterAI/OpenCode/CodeArts/Qoder/ZCode/Loomy/Raccoon + 新流程 + WebUI 补齐
新增 10 个真实厂商平台(端点/模型/流程逐字照抄参考 channel-pack):

device-poll(已有执行器):
  minimax  保留(MiniMax 形态:看 status 字段,slow_down +5s)
  cline    WorkOS 设备码(判据看 error 字段、HTTP 可非 2xx;slow_down +1s;
           access_denied/invalid_grant 同组失败;成功后 register 第二跳
           /api/v1/auth/register 换 Cline token)。新增 deviceRegister。

state-poll(新流程):
  buddy    CodeBuddy(腾讯)。POST /v2/plugin/auth/state → authUrl+state;
           轮询 GET /v2/plugin/auth/token?state=,业务码 11217 = 未就绪继续等
           (对齐参考 loopGetToken:网络错误同样继续不中断);token 就绪后
           拿账号(失败不阻塞)。

callback:
  gemini   标准 OAuth code 交换(oauth2.googleapis.com/token,六 scope 照抄);
           oauth_callback 增 code→token 交换分支(Gemini/LobsterAI 复用)。
  lobsterai 本地回调 + authCode 换 token(lobsterai-server.youdao.com)。
  trae     保留(token 直传 + ExchangeToken,已 8938574)。

paste:
  opencode 直接贴 API key(无登录流)。
  codearts 企业凭据 + SDK-HMAC-SHA256 签到(claim_fn 钩子,canonical request
           + 派生密钥链 kDate→kNonce→kSigning→signature)。

manual(后端给 manual_hint,明确说明而非假装成功):
  qoder    WASM 签名头(纯 Lua 无法生成)
  zcode    captcha 求解
  loomy    微信扫码
  raccoon  图片二维码

WebUI(之前只覆盖 vnc/url+轮询/device-poll):
  chLogin 增 manual / paste 两个分支:manual_hint 展示 + 凭据粘贴框 +
           提交按钮。新增 chComplete 调 channels/login/complete 落保险库,
           凭据不回显、失败给可读错误不重载页面。node --check 过。

set_endpoints 增 register/state 覆盖(运维能力 + 测试缝,与 device/checkin 同语义)。

行为测试(照参考逐行):
  TestClineWorkOSBehavior  5 行:非200 authorization_pending 继续等 /
    slow_down 退避 1s(interval=2)/ access_denied 失败 / 成功 register 凭据
    是 CLINE-AT-1(不是 WorkOS token)/ 非200无 error 失败。
  TestBuddyStatePollBehavior 3 行:state 申请给 authUrl / 首拍 11217 pending
    次拍 token completed / account 查询失败不阻塞回退默认名。

变异测试修复(deviceOneStep 拆 workos/minimax 两分支后幸存体暴露):
  锚点 +step / authorization_pending 现两处同形,strings.Replace(,,1) 只改
  workos 分支、minimax 决策表没杀到 → 改 ReplaceAll 全改;删冗余第 6 变异体。
  5 变异体全杀。回归:channels/device/cline/buddy/trae/legacy/保险库/e2e 全绿。
2026-10-12 00:28:26 +08:00
893857464d feat(channels): TRAE callback 真实移植 —— token 直传 + ExchangeToken(照参考逐行)
此前 oauth_callback 是通用 OAuth 形态(直接拿 code 当凭据),与 TRAE 真实
行为不符。参考实现 trae-oauth.ts 的回调是 **token 直传**,本次照其行为移植:

parseTraeCallbackDetailed(决策表 5 行):
  行1 refreshToken 直传(或 userJwt.RefreshToken 回退)→ ExchangeToken 换新
      access(请求体 ClientID/RefreshToken/ClientSecret:'-'/UserID 照抄,
      响应取 Token)→ 凭据 = 新 access
  行2 无 refreshToken 但有 userJwt.Token → 直接兜底,**不打** ExchangeToken
  行3 只有 code/authCodeInfo → PKCE 流,明确报"暂不支持"而非含糊失败
      (参考注释强调:判为无效会让用户往错误方向排查)
  行4 全空 → 报"回调未携带 refreshToken / userJwt.Token / code"
  行5 ExchangeToken HTTP 失败 → 错误指明 ExchangeToken 阶段

配套:
- PLATFORMS.trae 增加 callback = {mode="trae", exchange_url, exchange_body};
- oauth_callback 按平台 callback.mode 分派(nil→code 旧路径不变,googleish 不受影响);
- traeCallback/jsonField 新函数;set_endpoints 支持 callback.exchange_url 覆盖
  (既是运维能力也是测试缝,与 device 端点同语义);
- TestTraeCallbackBehavior:5 行决策表 + 行1 断言凭据来自假上游新 Token
  (防"把回调原值当凭据")、行2 断言 ExchangeToken 零调用(防多余上游请求)。

回归:channels/trae/device 变异/压力/legacy/保险库/e2e 全绿。
2026-10-11 22:35:18 +08:00
2f5cde7fea test(e2e): channels device-poll 真二进制端到端 + 修正插件 HTTP 前缀
TestChannelsDevicePollE2E 用真二进制跑通完整链路(对齐参考插件形态的终局验收):

  假平台上游(设备码/token/模型/对话端点)
  → 真网关进程 + plugin_dir(SeedBundled 落地 channels)
  → PUT /api/plugins/channels/kernel 授权(运维批准动作面)
  → call/set_endpoints 把 minimax 端点指到假上游(自建代理能力复用)
  → POST /api/plugins/http/channels/login 发起 device-poll
  → GET .../login/status 轮询:先 pending(authorization_pending),
    后 completed(token 下发)且 account 取 nickname
  → 终局证据:/v1/models 出现 channels-minimax 的模型 MiniMax-M3

调试中抓到并修复:channels.lua 三处旧挂载前缀 /api/plugins/channels/http/
(正确为 /api/plugins/http/,spawnBrowser 回调路径、callback redirect_uri、
UI fetch 基址)—— UI 上点登录/回调一直会 404。

顺带:e2e 就绪探针复用 startGateway(无源时 /v1/models 空列表也 200)。
2026-10-11 21:44:40 +08:00
da0282a3db test: 四类验收 —— 加密解密 / 变异 / 压力 / 旧通路
用户要求的四类测试全部落地,且每类都抓到了真问题:

## 0) 测试暴露的真实缺陷(先修后测)

**保险库明文落盘**(加密解密测试的目标缺陷):.secrets/<plugin>.json 此前
是明文 JSON。接入 SecretBox(与 config.yaml 同一把 master key、同 enc:v1
形态):SetSecretBox 注入(secretSealer 接口,测试可桩);写盘逐值加密、
读盘逐值解密;无前缀旧值原样通过 —— 旧明文库可读、下次写整体重封。
core 注入点在 store 创建之后(NewPlugins 时 box 还不存在 —— 顺序 load-bearing,
修掉了一个 nil-deref)。

## 1) 加密解密(internal/lua/secrets_crypto_test.go)
- EncryptedAtRest:磁盘字节不含明文 token、带 enc:v1、文件 0600/目录 0700
- PlaintextFallbackAndUpgrade:旧明文库可读 + 任意一次写整体重封为密文
- WrongMasterKey:换 key 后读库必须报错(静默空会让插件重登覆盖凭据)
- ConcurrentPlugins:8 插件×2 goroutine 并发读写,无串台/丢写/文件数正确

## 2) 变异(internal/core/device_mutate_test.go + device_harness_test.go)
- 决策表抽成 deviceHarness.runDecisionTable(行为测试与变异测试共用,
  单一事实源)
- 5 个语义变异逐个杀:slow_down 不加 interval / denied 不识别 /
  expired_token 不识别 / authorization_pending 当失败 / 成功分支禁用
- 行3/行4 断言 message 区分(用户拒绝/过期)—— 初版只断 state=failed 时
  两个变异体幸存(落进通用 failed 分支),加 message 后全杀。
  这正是变异测试的价值:暴露断言太弱。
- 基准(未变异)必须全过 —— 防止测试自身失效的假阳性

## 3) 压力(internal/core/channels_stress_test.go)
- ConcurrentLogins:12 轮并发 device-poll,会话互不串台、全部终态
- ClaimStress:30 并发领签到,上游收到恰 30 次(无登录态丢失)

## 4) 旧通路(channels_stress_test.go 后半 + 复用 lua 包测试)
- LegacyVaultPlaintextLoad:老版本明文库 → 新版本可读 → 下次写重封
- LegacySourceSealedConfig:明文 api_key 的旧 config.yaml → 运行期解密、
  落盘 seal、master.key 0600 生成

全量 lua/core/gateway/types/scheduler/provider/config 测试通过;build+vet 绿。
2026-10-11 20:50:52 +08:00
6f2b6b8d3d feat(channels): device-poll 通用执行器 + 行为对齐测试(照参考实现逐行移植)
按用户要求:不是从头造,而是移植参考实现(minimax-oauth.ts)的行为,
并照对方代码的行为形成测试项。

新增(channels.lua):
- b64url/hexToBin/devicePkce:PKCE S256(对齐 createMinimaxPkce)
- deviceOneStep:轮询决策单步执行器,分支顺序逐行对齐 pollMinimaxDeviceToken:
    200+status=pending        → 等
    200+status=slow_down      → interval+5s,等
    200+status=denied/access_denied → failed(用户拒绝)
    200+status=expired/expired_token → failed(码过期)
    非200+error=authorization_pending → 等(OAuth 标准形态)
    非200+error=slow_down     → interval+5s,等
    200+access_token          → 成功
    其它                      → failed(带 error 文本)
- start_login device-poll 分支:申请设备码(PKCE)、user_code/授权URL透传、
  verifier 只存会话不回显
- poll_login:device-poll 会话每次推进一步(页面驱动,不阻塞 —— 插件单VM单锁,
  阻塞循环会卡死本插件全部钩子);成功即落凭据+接入源
- set_endpoints 导出:运维把渠道端点指向自建代理/镜像;也供测试打假上游
- plugin.exports = { set_endpoints }

测试(channels_device_test.go):假上游逐次响应,8 行决策表逐行断言
(pending 等待 / slow_down interval+5 / denied→failed / expired→failed /
authorization_pending 等 / slow_down(4xx) 等 / 成功→completed+account 取
nickname / error→failed 带文本)。全部通过。

教训(词法作用域):devicePkce 原引用文件后段才定义的 newToken,Lua 下那是
nil 全局调用 —— 已改为自含熵源。
2026-10-11 20:08:00 +08:00
96d97b0e0e feat(channels): MiniMax 渠道 —— PKCE 设备码流(RFC 8628)
第二个真实厂商,采用纯 HTTP 的设备码授权(无需本地服务器):

- 申请: POST account.minimax.cn/oauth2/device/code
  body: client_id=mcode-public&scope=agent.default&audience=agent-backend
        &code_challenge={S256(verifier)}&code_challenge_method=S256
- 轮询: POST account.minimax.cn/oauth2/token
  body: grant_type=device_code&device_code=..&client_id=mcode-public
        &code_verifier=..
- 签到: POST agent.minimax.cn/minimax-cloud/api/v1/signin/claim body {}
- 模型 fallback: MiniMax-M3.1-Flash-Preview / M3 / M2.7-highspeed
  (对齐参考仓库 minimax-product.ts)

device-poll 流程的三步状态机在 start_login/poll_login 中实现:
begin 时生成 PKCE verifier/challenge 并申请设备码,把 verifier 存进
会话(只在换 token 时用一次,不回显),页面轮询 token 端点直到
authorization_pending 结束。
2026-10-11 19:29:58 +08:00
6de9f28269 feat(channels): 逐厂商支持启动 —— TRAE 真实渠道接入 + checkin 表驱动
用户确认:仿照示例插件逐个支持厂商,自用、风险已知悉。

PLATFORMS 表升级为完整平台描述(对齐 dsh-our-free-model/channel-pack):
- adapter 字段:接入内核用哪个协议适配器
- checkin 字段:每日签到 {url, method, body, headers}(可缺省=不支持)
- hosts 字段:登录域与上游域(供 plugin.hosts 推导)
- claim_fn/claim_result_fn 钩子位:特殊签名/响应解析的厂商挂点

TRAE(字节)为第一个真实渠道,作为后续厂商的模板:
- 登录:callback 流(www.trae.cn/authorization,token 直传)
- 模型 fallback:DeepSeek-V4-Flash/Pro、Kimi K3、GLM-5 系、Doubao-Seed-2.1
- 签到:POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim body {}
  (端点逐字对齐参考仓库 trae-credits.ts)

plugin.hosts 不再手写:declaredHosts() 从平台表推导(hosts+base_url+checkin.url),
避免"两份名单漂移"(参考仓库同类真实缺陷的教训)。

内核零改动。UI 登录框补回 ch-vnc-wrap 内嵌容器(上次重构时遗漏,VNC 兜底
流程会引用不到)。
2026-10-11 19:26:21 +08:00
50997a4e3a fix(ui): 设置页适配额外出站主机授权 —— hosts_declared/hosts_granted
上一提交把 auth_hosts 语义泛化为 plugin.hosts(额外出站主机授权),但设置页
仍读旧字段名 auth_hosts_declared/auth_hosts_granted、提交 auth_hosts 键 ——
导致内核权限面板的主机授权区永远显示"未声明",勾选保存也不生效。

- 读取: auth_hosts_declared/granted → hosts_declared/granted
- 提交: { auth_hosts } → { hosts }
- 文案: "登录主机" → "额外出站主机"(授权与登录无关,登录是插件自己的事)

内联 JS node --check 全部通过;UI 契约测试通过。
2026-10-11 16:47:24 +08:00
74ec1e7874 refactor(lua): 内核回归六项职责 —— 登录/浏览器全部移入插件
用户钉死了内核的职责边界,本提交把越界部分全部拆除:

  内核只做:加载适配器 · auto 调度 · 加载插件 · 调度插件 · 维护源与 key · 签发密钥

## 架构纠正(用户指出的根本错误)

我此前把信任模型弄反了。实测:插件 VM 的 os.execute/io.popen/package 全部可达
(true,true,true) —— 插件从来不是 OS 级沙箱。正确的边界是:

  插件   可信(运维安装),非沙箱,有完整 OS 能力 → 平台知识/登录/浏览器归它
  适配器 不可信(可被动态安装),请求级 → 沙箱归它(12 个内置适配器实测均不碰
         os/io/package,后续沙箱化零风险,属独立一步)

## 删除(内核越界部分)

- plugin_browser.go        内核浏览器管理器(Xvfb/chromium/x11vnc/websockify
                           进程栈、回环监听器)—— 388 行全删,浏览器由插件用
                           os.execute 自己拉起(实测 spawn+reap 闭环可用)
- plugin_login.go          登录会话状态机 + LoginFlow 枚举 + VNC 全局门禁
                           —— 457 行全删。"登录"从内核概念中消失
- gateway/browser_proxy.go viewer 反代
- handlePluginLogin API    登录会话列表端点
- vnc_enabled 全局开关     改为 per-plugin 授权(见下)

## 新增(内核职责内的通用机制,无任何登录/浏览器语义)

- plugin_outbound.go       通用额外出站主机授权:插件声明 plugin.hosts(请求)
                           → 运维在设置页逐个批准(授权)。与 kernel actions
                           同一"声明≠授权"模型。内核只知道白名单加减,不知道
                           host 被用来干什么
- on_unload 生命周期钩子   卸载是"调度插件"的一部分:内核在关闭插件状态前调用
                           插件导出的 on_unload,让它自清理外部资源。内核不知道
                           资源是什么(进程/句柄/远端会话都行)。带进程回收回归
                           测试 TestOnUnloadReapsPluginProcesses

## channels.lua 全量接管

- 登录会话自持:plugin.state.sessions(nonce 只在 begin 响应出现一次,
  惰性过期清理)
- 服务端浏览器栈自持:spawnBrowser 用 io.popen 拉起 Xvfb/chromium/x11vnc/
  websockify 并记 pid,reapBrowser 回收;on_unload 兜底
- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化(轮询/nonce 回调)
  → ③ VNC 无头浏览器兜底(无浏览器系统/自动化无法完成的场景,如回环重定向
  必须落在服务端)
- plugin.auth_hosts → plugin.hosts(语义泛化:出站主机授权与登录无关)

## 验收

- 全仓旧符号零残留(grep 权威确认,代码+注释+lua+测试)
- 内核非测试代码零厂商名
- build + vet 全绿;lua/core/gateway/types/scheduler/provider 全量测试通过
- channels 端到端(登录→凭据→add_source→模型可见)继续通过
2026-10-11 16:24:55 +08:00
fa50ec8bc7 feat(lua): 服务端浏览器登录(VNC)+ 内嵌页签 —— 回环回调的唯一自动化路径
用户指明的场景修正了我的一个实质错误。我之前写"VNC 不解决取凭据,凭据还在
浏览器 profile 里"——这对 cookie 类成立,但对**回环重定向**不成立:

  浏览器在用户机器 → redirect 到 127.0.0.1:P → 打到**用户的**回环 → 服务端收不到 code
  浏览器在服务端   → redirect 到 127.0.0.1:P → 打到**服务端的**回环 → 插件直接收到 code

后者不需要"提取凭据",code 自己流到监听方。所以对回环类登录,服务端浏览器
不是权宜之计而是**唯一自动化路径**。已实测验证(浏览器在服务端时 loopback
回调的 code 落到了服务端监听器)。

## 实现

plugin_browser.go:Xvfb(:N) ← chromium → x11vnc → websockify(noVNC) + 回环回调监听。
五个进程一起起停,因为失败模式是顺序相关的(崩掉的会话留下的 Xvfb 会让下一个
chromium 以看起来像浏览器 bug 的方式失败)。

**默认关闭,且不是"开了 VNC 就能登全部"**:每个平台单独走 vnc 流程才启动。
理由是这套东西风险最高——在服务端跑真实浏览器登录用户的第三方账号。

**边界**:插件拿到的是 view_url,**不是浏览器控制句柄**。CDP 端口不进 Lua。
否则一个插件就能读所有标签页的 cookie,而那个浏览器登录的是运维的账号。

**teardown 必须删 profile**:里面是活的第三方 session cookie,删它是安全契约
的一部分,不是收尾工作。

## viewer 反代(browser_proxy.go)

/browser/<id>/... → 该会话的 websockify。为什么不直连端口:
1. 鉴权——直连等于把已登录的浏览器暴露给任何能访问该端口的人(实测未鉴权
   访问得 302 → /login,带 admin key 得 3912 字节 noVNC 页面)
2. WebSocket——noVNC 核心是 WS 升级,httputil.ReverseProxy 原生处理
3. 同源——WS 路径不需要跨域配置

## 内嵌在插件页签(用户要求)

viewer 用 iframe 内嵌进 channels 插件的登录区,而不是让人跳出去开新窗口:
- 插件 UI 拿到 view_url 就塞进 iframe src,登录完成/失效时清空 src 并隐藏
- 插件只对 `flow = "vnc"` 的平台起浏览器;其他流程继续用用户自己的浏览器
  (等于白开一个高风险的浏览器是浪费)

## 插件本体:channels(同类插件)

单插件多平台,4 个平台示范四种流程(link-poll / callback / paste / vnc)。
登录 → 凭据进保险库(不进 state)→ add_source 接入内核 → 定时领额度。
声明式:plugin.kernel 申请内核动作、plugin.auth_hosts 申请登录主机,
设置页显示"申请中"由运维批准。平台表加一条就多一个平台,内核零改动。

开发中被测试抓到的三个真实缺陷(都在我自己的代码里):
1. handler 返回 `{url=…}` 没有 status/json → 内核**静默丢弃**,200 空响应、
   无任何错误。已把所有 handler 统一为 {status,json},并加契约测试锁死。
2. 插件把 req.body 当表用 → 实际是 JSON **字符串**,所有平台参数都读不到。
   加 bodyOf() 统一解码。
3. 顺带发现工作树里 discover.go 被改成 `return "image"`(非本次改动,像是早先
   变异测试留下的),会改变模型类型推断;已还原。

测试:服务端浏览器会话(进程栈/默认拒绝/回调捕获/teardown 清理 profile)、
viewer 反代鉴权、插件 VNC 端到端、channels 插件端到端(含未授权时如实报告
source_error 而非假装成功)、handler 契约。
2026-10-11 14:26:28 +08:00
c3b858d5ba feat(lua): 多流程登录会话抽象(R4-e)+ 内核权限设置页
用户决策:**多流程抽象**(VNC 作逃生舱)+ **单插件多平台**。

## 为什么不是 VNC 为主路径(实测参考插件后的结论)

grep 参考插件(dsh-our-free-model,13 个账号渠道)的源码:

  puppeteer / playwright / chromium / headless / vnc / selenium  →  0 处

它的登录全部是**把 URL 交给用户自己的浏览器**,插件轮询凭据是否就绪:
loginUrl 113 次 · cookie 133 次 · oauth 43 次 · deviceCode 8 次 · qrcode 7 次。

不以 VNC 为主路径的四条具体理由:
1. Passkey/WebAuthn 基本不可用:凭据绑在用户设备,VNC 里是另一台机器。
2. 无头浏览器更容易被判为机器人 —— 登录场景最不能踩这个。
3. 安全面:llmsproxy 以 root 运行(master.key 0600),systemd 已 ProtectHome=yes。
   从 root 服务开 VNC 通道到"即将登录用户各平台账号"的浏览器 = 提权面绑高价值目标。
4. 最关键:VNC 不解决取凭据。登录后凭据活在浏览器 profile 的 cookie 里,
   还是要提取 profile —— 那正是最脆弱的一环。轮询流程直接产出 token。

VNC 唯一不可替代的场景(服务端与用户不同网 + 平台要求登录 IP 与调用 IP 一致)
使它作为逃生舱存在:**默认关闭**,需运维显式开启。

## 登录会话抽象(plugin_login.go)

四种流程:link-poll(首选)· callback(复用 Phase 6 自定义挂载)· paste(兜住
cookie 类渠道)· vnc(逃生舱)。内核只管会话生命周期,不含任何平台知识。

- 会话只存**状态**不存凭据:凭据由插件写进自己的保险库,会话里放一个
  UI 可见的记录会让 token 有机会被列表接口带出去
- nonce 只在 begin 时给(后续读取剥离),空 nonce 不匹配任何会话
- 读取时判定过期,无后台定时器(没有登录请求时零开销)
- 跨插件读不可行,且与"不存在"不可区分(否则会确认 id 存在)

## 关键阻碍与修法:登录主机不在出站白名单里

**实测确认**:`auth.huaweicloud.com` 被白名单拒绝
("not an allowed outbound destination (allowed: the hosts of configured sources)"),
整个登录流程无法工作 —— 因为登录主机不是上游源。

修法与内核权限同一姿态:插件在源码 `plugin.auth_hosts` **声明**,运维在设置页
**批准**后才放行。只能批准插件声明过的 host(否则声明只是装饰),逐个批准
不连带(否则一个"登录"理由就能打开整张名单)。

## 设置页

插件表格新增"内核动作"列 + 点击展开的权限面板:动作清单按危险级排序、勾选即
授权、**申请中但未授权会标红**(那通常意味着插件某功能静默失效)。
另加登录主机批准面板(已批准/待批准)。

后端 /api/plugins/{name}/kernel(GET 看 declared vs granted,PUT 改)
与 /api/plugins/{name}/login(列在飞会话,供页面显示"等待你完成登录"+ URL)。

开发中自己引入并修掉一个缺陷:renderPlugins → refreshKernelSummary →
renderPlugins 无限递归;改为只原地 patch 受影响的单元格。

测试 8 个:会话生命周期/跨插件隔离/callback nonce/登录主机门控/只能批准已声明/
过期判定/VNC 需显式开启/未知流程名被拒。node --check 验证内联 JS 可解析。
2026-10-11 13:56:35 +08:00
a59295e826 feat(lua): 插件可调内核动作面(R4-c)+ 凭据保险库(R4-d)
用户指出「接口不全」——此前插件只有 __llmsproxy_adapters 三个手写闭包,
够不到内核。对照参考插件(dsh-our-free-model,13 个账号渠道)的真实能力,
缺的正是这两块:

  插件自己驱动链接认证 → 存凭据 → 定时领额度 → 把源接进内核

## R4-c 内核动作面:__llmsproxy_kernel

18 个动作,全部映射到既有 Core 方法(不是新造能力):

  只读 6:status / list_sources / list_models / list_adapters /
          list_auto_rules / adapter_owner
  写   8:reload / reset_health / probe_all / save_auto_rules /
          save_key_auto / add_source / create_key / update_key
  破坏性 4:remove_source / delete_key / remove_adapter / set_plugin_enabled

**add_source 是「插件注册自己的上游」的落点**——插件拿到凭据后可以自己把
端点接进网关,无需运维改 config.yaml。已端到端验证:插件调它 → 内核源列表
出现 → 模型进入可路由列表。

依赖方向:lua 不能 import core,所以接口定义在 lua、实现放 core
(kernel_actions.go),走 SetKernelAPI 注入——与 SetOutboundHosts 同一先例。

**凭据边界**:动作面**有意排除** list_keys/find_key。Core.ListKeys() 返回
明文网关密钥,交给第三方 Lua 等于交出整个部署的凭据面。有回归测试锁死
这条(TestKernelActionSurfaceExcludesPlaintextKeys)。list_sources 返回的
api_key 也强制掩码(只给 ***+后4位)。

**权限**(用户选「全开+设置页可配」):18 个动作全部可达,但**默认拒绝**,
授权落盘 .kernel-perms.json(独立文件,不并进 .access.json——后者是
map[string]string,塞进去会破坏旧二进制能读的格式)。danger 级不接受通配符。
插件可在源码 plugin.kernel={...} **申请**,设置页显示申请 vs 已授予。

**Go 侧 deadline**(用户选定):每个动作有超时预算,超时把 (nil,err) 还给
Lua。这可行的原因:Lua 只是等返回值,不占 Lua 栈执行权——与「Lua 死循环
不可中断」是两件事。

## R4-d 凭据保险库:__llmsproxy_secrets

此前插件存 token 只能写 plugin.state:0755 目录、明文、与计费累计量同文件、
会被 PUT /state 整体替换、会进 WebUI。现在独立文件:

  <plugin_dir>/.secrets/<plugin>.json   文件 0600,目录 0700

临时文件**先 chmod 再 rename**,全程不存在宽权限窗口。读取身份在插件 boot
时绑定(不从 Lua 状态现读)⇒ 跨插件读不可行是机制而非约定。插件删除时清空
(DeleteAllSecrets + 启动 PurgeOrphanVaults)。

## 开发中被测试抓出的两个真实缺陷(我自己引入的)

写了 TestNoKernelActionDeadlocksFromLua 对**每个**动作断言「在插件 Lua 调用
(此时持有 p.mu)内不得死锁」,抓出:

1. **自死锁**:set_plugin_enabled → SetEnabled → p.mu.Lock(),而死锁发生在
   调用者已持有的同一把锁上。
2. **更隐蔽的迟到副作用**:超时把 error 返回给插件后,动作仍在幕后执行完成——
   插件以为失败,实际已生效。

修法:标注 Deferred 的动作在本次调用返回后异步执行,如实返回 accepted:true
而不是谎报成功。18 个动作全部复验无死锁,另有单独测试确认副作用真的落地
(只测「不死锁」的话,一个全空转的 dispatch 也能通过)。

测试 11 个(core 8 + lua 3):动作可用/密钥掩码/默认拒绝/授权计数/
插件注册源并变为可路由/授权落盘重启保留/未知动作名被拒/凭据面排除明文/
全动作无死锁/破坏性动作生效/Lua 侧桥接可用。
2026-10-11 13:52:29 +08:00
dafc3b3fc3 feat(lua): 插件 Lua 侧适配器桥接(R4 Lua 绑定)+ 真二进制适配器接手 e2e
补上两个此前报告为"缺口"的东西。其中第一个的旧结论是**错的**,这里更正。

## R4 Lua 绑定:__llmsproxy_adapters

用户问的"为什么 Lua 绑不上"—— 结论是**能绑,之前的归因错了**。

旧结论是「NewTable + PushGoClosure + SetField 是 golua 雷区,会 SIGSEGV」。
反证:内核自己的 buildJSONTable 用的就是这同一个模式,长期稳定。真正的变量
是当时 Phase 4 的**双 VM 半成品**(钩子一个 VM、定时任务一个私有 VM),
bootPluginState 签名与调用点不一致,我改到一半编译不过就仓促回滚,留下的
中间态让 billing 一跑就崩。把双 VM 改回单 VM 之后,同一模式一次就通。

顺带回答一个备选方案(用户提出:Lua 只投递请求、Go 串行执行):不需要。
p.mu 已经天然串行化同一插件的所有入口;再加队列只会引入"谁排空/超时后积压
怎么算"的新问题。而且桥接调用本身是微秒级的(交给 Go 重建一个小池),真正
长耗时的是插件自己去取 token 的 HTTP 请求,那跟桥接无关。

能力(Go 侧强制,非约定):
- install(name, source) → (生效名, nil) | (nil, err)
- update(name, source) → (true, nil) | (nil, err)  只限自己装的
- remove(name)         → (true, nil) | (nil, err)  只限自己装的
- 调用身份是插件**启动时的固定名**,不从 Lua 状态现读 ⇒ 无法冒充别的插件
- 不能影子内置(Lua 侧同样被拒,不是绕过 Go 校验的后门)
- 出错返回 (nil, err) 而非抛异常,与钩子/HTTP 处理器同一套约定

**分片 VM 故意不给**:分片是文档化的「只读、可丢弃」工作单元,而适配器
install 是**全局**副作用(改共享注册表)。给分片等于让"只读"的按需调用悄悄
改写所有请求共用的适配器 —— 正是分片契约要防的不可见变更。

测试 6 个:install/rotate/remove 从 Lua 调用、影子内置被拒、**定时任务里
轮换 token 且请求钩子立即可见**(R4 闭环)、分片无桥接。

## 真二进制 e2e:适配器接手对外服务

用户问到的第二个点。此前 /v1/messages 的 Anthropic 接管只在**进程内**
gateway 层测过(internal/gateway/messages_test.go),绕过了只会在真实进程里
发生的事:适配器磁盘 seed、配置加载、真 HTTP 栈。

新增 e2e/adapter_takeover_test.go,打**真编译的二进制** + 只说 Anthropic 的
假上游(该上游对不符形状的请求主动报错,不做宽容接受):
- 非流式:出向必须是 Anthropic message 形状,不能有 OpenAI choices 外壳
- 流式:必须是 message_start…message_stop 序列,**不得**出现 [DONE]
- 压力:96 并发(48 流式)混合打真二进制,全部 200,无 5xx,无形状串味

压力测试初版把并发上限设成 4 于是大量 429,我把断言改正为:429 是内核**正确**
的容量背压,不算失败;失败的定义是 5xx 或 200 但内容/形状错。现在 96/96 通过。
2026-10-11 13:25:11 +08:00
66786bb0e3 docs: 契约 v2 文档收尾(Phase 7)
用户要求的收尾。三份文档与代码对齐。

## docs/lua-adapters.md

- 新增 §4「出向序列化钩子(契约 v2)」:serialize_response /
  serialize_open_preamble / serialize_chunk / serialize_close /
  serialize_done_marker。核心语义:**声明即完全取代**内核的 OpenAI 兜底,
  不是叠加 —— 适配器输出里不会有内核补的 OpenAI 字段(R2/R5)。
- §8 标题与内容从 disable_thinking 改为中立的 thinking 预算:
  `{enabled, budget_tokens}`,由适配器映射到目标协议。v1 的
  disable_thinking/extra_body 是内核细节泄漏进适配器请求体的典型
  (迫使 10 个适配器各写一行清理),已记明已被取代。
- §7 最小适配器示例同步改成映射中立 thinking 字段。

## docs/plugins.md

- §5.4 定时任务(PLUGIN.schedule):every/at/jitter/timeout 字段表 +
  **内核 ticker 驱动、浏览器关着也跑** + 任务与钩子同一个 VM(写
  plugin.state 立即可见于请求)+ 卡死则停用该插件(不能中断 Lua 的
  能力边界)。
- §5.5 自定义对外挂载点:plugin.http 声明同时可在 /api/plugins/http/、
  /plugins/、/v1/plugins/ 三处访问;命名空间约束与内核路由冲突拒绝。
- §5.6 运行时安装/更新适配器(token 轮换)+ 三条安全规则。
- §6.1 更正:某个卡住的钩子/任务只卡**它自己**那个插件的钩子(跨插件
  并行),原文写的是"卡住所有插件"。
- §9 对比表补周期任务/对外端点两行,钩子列表补 serialize_*。

## README

- disable_thinking 章节 → thinking 预算(中立字段 + 各适配器映射)。
- 适配器协议示例补 serialize_* 出向钩子。
- 特性列表与测试覆盖描述同步。
2026-10-11 13:07:50 +08:00
f4d5e779a5 chore(lua): 移除误入的临时复现脚本 zz_repro_test.go
上一条 commit 用 git add -A 时把工作树里一个未跟踪的临时复现脚本
(zz_repro_test.go,注释自称「临时复现」)一并扫了进去。它不属本次范围:
不对任何东西做断言(只有 t.Logf),且它探查的 anthropic 空数组问题
在 main 上同样存在,是既有行为,不由本分支引入。

顺带确认它想复现的那个形状:唯一命中的是
`"messages":{}` —— 空 Lua 数组经 luaTableToGoValue 变成 JSON 对象而非
空数组。这确实是个真问题(Anthropic 会拒),但它在 main 上就存在,属
独立缺陷,不在线格式 v2 分支的处理范围。已记入 plan.md 已知遗留。
2026-10-11 13:00:46 +08:00
61f402beef fix(lua): 定时任务改跑在插件自己的 VM 上 —— 纠正 Phase 4 的双 VM 错误
用户指出的设计错误:**一个插件 = 一个 Lua VM = 一个调度器**。而 Phase 4
(4d1407b)给定时任务开了一个**私有 VM**,于是:

    定时任务 refresh_token 写 plugin.state.token  →  写进私有 VM 的副本
    请求钩子           读 plugin.state.token      →  读主 VM 的值

轮换"成功"了,对实际流量完全无效;而且私有 VM 绕过状态持久化,token 连
磁盘都到不了。这直接违背这个功能存在的理由。

## 修正

PluginRuntime 不再持有 Lua 状态。任务通过 p.state + p.mu 运行 —— 与请求
钩子**同一个 VM、同一把锁**:

- 任务写入立即可见于钩子(TestTaskWriteIsVisibleToHooks 锁死这一点)
- 任务写入经 markDirtyLocked 走同一套持久化
- 跨插件仍并行(每插件各自 VM、各自锁)

## 由此产生的行为(有意为之,非缺陷)

- 某插件的长任务会排在**它自己**的钩子前面(同一 Lua 栈,一次一个调用者)
- 卡死处置随之改变:不再"抛弃 VM 重建"(那会丢掉累计状态——计费总额、
  签到历史,正是插件存在的意义),而是**停用该插件**:
  - checkWedged 超过 wedgedAfter(10min) 置 disarmed
  - Fire 对 disarmed 插件快速失败并记 hook_errors(否则请求会排在永不释放
    的锁后面永久挂起——比停用更糟)
  - Unload 检测 InFlight:有任务在跑时不取 p.mu(会永久挂起)、不 Close
    状态(Lua C 层 use-after-free 直接 abort)。泄漏这一个状态是有界的,
    比崩溃好。
  - maxAbandons/抛弃计数因此删除:没有"重生"了,熔断由 disarmed 承担

## 测试(10 个)

主断言从"任务跑过"改为"**任务的写入对请求路径可见**"—— 旧实现会在这里
失败。另含:无触发自执行/无 schedule 无运行时/解析/at 下次触发/错误隔离/
卸载停 goroutine/卡死停用+钩子快失败/健康任务不误判/8 插件并发各自调度。
2026-10-11 12:59:36 +08:00
57fada3b0d docs(lua): 更正 ScheduledTask.Timeout 注释 —— 超时由抛弃 generation 实现,非 Lua 指令级中断 2026-10-11 12:14:49 +08:00
b6148e6f79 chore(lua): 删除调度器里两个无人调用的诊断辅助函数
sortedTaskNames / PluginTasks 是开发 Phase 4 时留下的诊断辅助,最终
没有任何调用点(WebUI 走 /api/plugins 的既有结构)。连同不再需要的
sort import 一并删除,保持包面干净。
2026-10-11 11:41:08 +08:00
24e00cdc50 feat(gateway): 插件可注册自定义对外挂载点(R3)
用户需求:插件与适配器可以注册对外服务的端口。

现状是插件端点被固定在 /api/plugins/http/ 下。那条限制有三个理由
(可 grep / 可整体撤销 / 不能影子内核路由),放开时必须逐条补回来:

- 命名空间约束:只接受 /plugins/ 与 /v1/plugins/
- 内核路由冲突检测:ValidateCustomMount + FindMountConflict(双向)
- 鉴权默认关闭:沿用 HTTPHandler 的 roles/keys/public 语义

新增 internal/gateway/mount.go:
- ValidateCustomMount:命名空间 + 冲突 + 逃逸校验,错误点名冲突对象
- FindMountConflict:**双向**检测(只查一边会让 /plugins/x 与
  /plugins/x/sub 互相静默吞掉)
- BuildRouteTable / RouteOwner:冲突提示点名持有者("plugin:other")
- handleCustomMount:**复用** handlePluginHTTP 而不是重写一遍分发 ——
  重写会造出两条必须保持一致的分发路径,而只在一处生效的安全规则
  比没有更糟

关键防线:TestKernelRoutesAreAllCoveredByReservedPrefixes 从 server.go
刮出路由 switch 的字面量,逐个核对是否被 kernelReservedPrefixes 覆盖 ——
将来新增内核路由却忘记更新保留表时,这条测试会失败。否则新路由会静默
变成可被插件覆盖的。

测试 9 个:命名空间/影子内核路由/保留表同步/双向冲突/冲突点名/挂载解析/
路由表构成/端到端(自定义挂载 200 + /v1/plugins 形式 + 未注册 404 +
无 key 401 或 302)/内核路由不可拦截。
2026-10-11 10:47:17 +08:00
9016eda905 feat(lua): 卡死任务改为「抛弃整个 VM」—— 不中断,直接换一代
用户给的架构决策:与其想尽办法中断卡死的插件任务,不如直接抛弃它所在的
VM,让任务的 goroutine 跟 VM 一起被处置。这比中断干净 —— VM 重建只要
~0.1ms,抛弃比修复便宜。

## 为什么必须这样做(三次实测)

本 golua + LuaJIT 构建**无法中断**运行中的 Lua 调用:
  1. SetExecutionLimit(200000) 对紧密循环      10s 未返回 —— 无效
  2. 计数 hook(2000) + RaiseError 对紧密循环   fired=0   —— hook 未触发
  3. 计数 hook(1000) 对「函数调用+表写」循环   fired=20971 触发,
     但 RaiseError 后仍未返回 —— RaiseError 走 L.StackTrace(),
     正是 vm.go 已记录的 SIGSEGV / 无法跨 C 栈路径

原因:LuaJIT 把紧密循环编译成机器码,指令计数 hook 只在解释器路径触发;
而唯一能在 C 栈上中断的 golua 入口恰好依赖 StackTrace()。

## 模型:generation 可抛弃

PluginRuntime 持有一个 runtimeGen(私有 VM + 单状态池)。任务超过
wedgedAfter(10min,因为正常情况就是慢网络调用)仍不返回时:

- 抛弃该 generation,由 ensureGeneration 在下一 tick 起一个干净的
- 卡死的 goroutine 留在旧 VM 上,永远碰不到新一代的状态
- 在飞任务被 carry 到新 generation 并重新排期(否则 abandon 计数永不前进,
  熔断也就永远不触发)

## 熔断(必须有)

每次抛弃都留下一个永不返回的 goroutine + Lua 状态。没有上限的话,一个
必然卡死的任务会让运行时每 10 分钟建一个新 VM —— 由插件自身 bug 驱动的
无界泄漏。maxAbandons=5 后熔断停止重生,需人工 reload(同 systemd 单元
反复启动失败后的姿态)。

## 开发中修掉的四个缺陷

1. rt.gen 未赋值:bootGeneration 的结果只赋给局部变量,导致 checkWedged/
   runDue/dropGeneration 全部读到 nil,运行时会「活着但一个任务都不跑」。
2. checkWedged 无条件抛弃:即使没有任何任务判定为 wedged 也 drop,
   让健康插件不断换代。
3. startedAt 由 runDue 设置而非 runOne:任何其它 dispatch 路径的任务
   都不会被追踪,checkWedged 无法区分「在跑」与「从未启动」。
4. runOne 收到 nil gen 会 panic(抛弃与派发竞争)—— 改为安全返回并清标志。

测试:13 个(无触发自动执行/真正执行/零开销/at 解析/条目校验/失败隔离/
卸载停 goroutine/压力/抛弃换代/熔断/健康任务不受影响)。
2026-10-11 10:40:58 +08:00
4d1407bbb4 feat(lua): 插件长期运行环境(R4-b)—— 内核驱动调度 + 独立 VM
用户需求:插件需要长期运行环境(独立 LuaVM 线程化),以支持自动签到、
token 轮换这类"没有浏览器、没有人操作时也要发生"的能力。

旧模型做不到:插件的周期工作只能靠前端 pluginAPI.call 驱动,页面没开就
一次都不跑。这正是自动签到的死穴。

新增 internal/lua/plugin_runtime.go:插件声明,内核驱动。

    PLUGIN.schedule = {
      { fn = "refresh_token", every = 3600 },
      { fn = "daily_checkin",  at = "03:30" },
      { fn = "poll_quota",     every = 300, jitter = 30 },
    }

- 每个声明了 schedule 的插件获得**私有 VM**(不是共享 plugin.state 的那个:
  钩子要累加,任务要长跑且网络阻塞,两者对隔离的要求相反)
- 内核单个 ticker 驱动所有任务;每个任务**独立 goroutine**,一个长任务
  不推迟另一个的 due
- `at="HH:MM"` 每日固定时刻;`jitter` 打散首触发,避免整点齐射
- 任务失败计数进入 hook_errors 面(静默坏任务必须可见)
- Unload 先停调度再关状态(顺序反了会让在跑的任务写进已关闭的 Lua 状态)

明确边界:**适配器不参与调度**。适配器仍是无状态请求级池;token 轮换靠
插件(这里)按周期调用 Phase 3 的 UpdateAdapter。

修掉两个把进程直接搞崩的缺陷:
1. 私有 VM 的池我给 sync.Cond 传了新 mutex 而非池自己的 mu →
   "unlock of unlocked mutex",fatal error 而非可恢复错误。
2. Transform 硬编码 adapterGlobal,而插件模式的池把表存在 pluginGlobal →
   健康的状态也报 "has no table"。加 poolGlobal(p) 按模式选择。

测试 8 个:无任何触发自动执行(核心)/真正执行了(计数读回)/无声明则零开销/
at 解析与畸形退化/不完整条目被忽略/失败隔离/卸载停 goroutine/6 插件×3 任务压力。
2026-10-11 10:26:39 +08:00
5f6939b590 revert(gateway): 还原 discover.go 的空白重排(非本次改动)
我此前对 internal/gateway/ 跑了整目录 gofmt -w,它把 discover.go 里
注释对齐重排了(upstreamModel 的 struct tag 列、copyFields 的行内注释)。
那不是本次任务的改动,且会破坏变异测试 harness 的文本锚点 ——
discover_mutate_test.go 按精确文本做变异注入,锚点对不上就直接判定
"这个变异会是 no-op" 并中止整套 harness。

教训:整目录 gofmt 会在你没打算改的文件上产生 diff。先还原。
2026-10-11 10:16:58 +08:00
a0ab9ddda5 feat(lua): 插件适配器管理桥接(R4-a)—— 安装/改写/删除,支撑 token 轮换
用户需求:插件可安装、运行时修改适配器以支持复杂认证与 token 轮换;
适配器本身不需要持续运行(它是无状态请求级弹性池)。

  插件(长期运行)─定期/事件─> install/update/remove_adapter
                                      │
                                      ▼
                      内核重建该适配器的池(新池先发布,旧池后回收)
                                      │
                                      ▼
                          后续请求自然使用新逻辑

三条安全规则(与 R6 同源):
1. 路径安全:ValidateAdapterName(无穿越/绝对路径/空白)
2. 不得影子内置:插件不能注册与内置同名适配器(运维仍可自行覆盖)
3. 归属可查:owner 表记录每个适配器由谁安装,于是"插件只能改自己装的"
   是可判定的,而非靠约定。没有归属信息时,插件 A 能劫持插件 B 的认证逻辑
   而无法被察觉。

修掉两个开发中暴露的真实缺陷:
1. 池替换窗口:LoadAdapterSource 原先在 v.mu 内先 shutdown 旧池再装新池,
   窗口期内请求拿到 "pool closed"。改为**新池先发布、旧池后回收**。
2. 并发轮换仍失败:多个 goroutine 同时轮换时,单次重试会连续落在刚被下个
   轮换关闭的池上。acquireAcrossSwap 改为按**墙钟**(2s 上限)+1ms 退避
   重试新池 —— 轮换是运维的意图,落在微秒窗口里的请求不该因此失败。
   上限保证适配器真消失时仍会及时报错而非空转。
3. 声明名 vs 注册键:脚本可声明与注册键不同的 name(插件按自己控制的键注册,
   但适配器声明源真正引用的名字)。归属表同时记两个键,删除时一并清理;
   EffectiveAdapterName 在声明名下也能查到池。

测试:11 个(安装/轮换/影子内置/跨插件窃取/碰运维适配器/路径穿越/删除/
归属列举/并发 8×20 压力/语法错误安装即拒/声明名上报)。

注:internal/gateway 的变异测试 harness 拒绝在脏工作区运行(设计如此),
故本提交后它才可运行。
2026-10-11 10:03:43 +08:00
e4111337cd feat(gateway): /v1/messages 端到端 —— 内核不含任何 Anthropic 出站字段名
Phase 2 收尾。证明「对外协议由适配器决定、内核只调度」不是纸面主张:
Anthropic 客户端打 /v1/messages,拿到的是 Anthropic 形状。

- internal/gateway/messages.go:入向把 Anthropic 请求解析进统一格式
  (content blocks 原样保留——只有适配器知道自己的上游接受哪种块类型;
  system 支持字符串与 [{type:text,text}] 两种写法)。
  调度/配额/AUTO 链/冷却/审计/插件 hook 全部复用 /v1/chat/completions
  的同一条路径(serveUnified),差别只有两端线格式。
- internal/lua/adapters/anthropic.lua:新增 serialize_response /
  serialize_open_preamble / serialize_chunk / serialize_close,
  把统一格式译回 Anthropic Messages 形状(含 content 分块、
  stop_reason 反向映射、input/output_tokens 改名)。
- 流式是 message_start → content_block_delta → message_delta →
  message_stop;DoneMarker 为空所以内核**不会**擅自加 [DONE]。

内核出向零 Anthropic 字段名:解析是表驱动的,序列化属于适配器。

测试:
- messages_test.go 5 个端到端用例(真实 core+gateway + 只懂 Anthropic 的
  假上游):形状/stop_reason/块顺序/usage 命名、流式事件序列、入向校验、
  鉴权、以及「未声明钩子时回落 OpenAI 兜底」。
- 修正两个 serializer 测试的前提断言(anthropic 现在确实声明了钩子,
  这正是 Phase 2 的意图),并发测试改为按适配器各自校验形状。

golden 字节级未漂移;go test -tags luajit ./... 全绿。
2026-10-11 09:51:27 +08:00
93eeba3762 feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零
Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为
UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。

改掉的具体缺陷:

1. 私有字段泄漏归零(v1 的核心问题)
   v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body,
   迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。
   现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与
   适配器都认这个词:
   - gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成
     thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能)
   - deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled
   - anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens}
     (enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法)
   - 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段"

2. Tools/ToolChoice 改 json.RawMessage
   原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。
   内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何
   内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。

3. 删除 MarshalJSON 后门
   v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是
   UnifiedRequest 上一个正正经经声明的字段。

渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译,
语义变更不被机械改名淹没。

测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传);
vm_test.go 两处迁移到中立语义。golden 字节级未漂移。
2026-10-11 09:35:32 +08:00
958f1f48b5 feat(lua): 适配器导入路径冲突校验(R6)——防静默覆盖与路径逃逸
用户要求:导入适配器时必须检查路径冲突,避免静默错误。

之前 UploadAdapter(name, code) 直接拼 adapter_dir/<name>.lua:
- name 含 "../" 或绝对路径会写到目录外
- name 与内置同名会静默覆盖,加载失败只报 "adapter X missing",
  现场与病因分离

实现(internal/lua/adapter_validate.go):
- ValidateAdapterName:字符集 ^[a-zA-Z0-9_-]+$,拒绝 /、\、..、绝对路径、空白
- ValidateAdapterPath:文件必须在 adapter_dir 内
- VM.InstallPluginAdapter:插件装内置名 → ErrAdapterNameReserved 拒
- Core.UploadAdapter:在 WriteFile 之前校验(防路径逃逸)
- Core.RemoveAdapter:也校验名字(防 os.Remove 越界)

设计取舍:保留名禁止只对插件生效,不对运维生效。
- operator 放 openai.lua 覆盖内置 = 文档化的热覆盖机制,放行
- 第三方插件静默换掉内核自带的协议翻译 = 篡改发行物,拒绝
- 先误做成了全拒,Start() 加载内置适配器直接崩,测试抓住后改对
  (TestLoadBundledAdapters 等锁住回归)

测试 12 个:路径穿越/绝对路径/保留段/空白/内置覆盖(允许 vs 插件拒绝)/
目录外加载/错误点名/热重载不回归。

验证:go test -tags luajit ./... 全绿。
2026-10-11 09:23:34 +08:00
9d6653a573 feat(gateway): 对外序列化移交适配器,OpenAI 格式降级为最后容错
内核不再硬编码对外 wire。writeChatCompletion / pumpStream 里的 OpenAI
帧拼装抽到 Serializer 接口,适配器可声明钩子完全接管。

优先级契约(用户明确要求:冲突时兜底直接让步):

  适配器声明 serialize_*  ->  适配器完全接管
  未声明                 ->  兜底 openAISerializer

「让步」的含义:适配器接管后内核绝不往其输出补任何 OpenAI 字段。混合体
(半个 Anthropic + 半个 OpenAI)比任何一个单独结果都更难排查——客户端无法
解析,而排查时会指向错误的层。

接口把「流如何结束」也纳入:OpenAI 需 usage 帧 + [DONE],Anthropic 用
message_stop,Gemini 用空 candidates 帧。DoneMarker 让适配器声明自己的
终止标记,避免擅自给严格客户端加 [DONE]。

新增能力:
- VM.HasFn(name, fn):不占 worker 探测适配器是否声明某函数
- VM.AdapterRevision(name):适配器源码每次替换 +1(接着旧值递增)

修掉两个开发中引入的真实缺陷:
1. 锁序倒置自死锁:LoadAdapterSource 里在已持有 v.mu.Lock() 时嵌套
   v.mu.RLock() 取 revision,RWMutex 不可重入 -> 整个测试套件静默挂起。
   症状是不报错也不超时,只是永不返回;靠 go test -timeout 的 goroutine
   栈定位。
2. 缓存键漏掉 VM 维度:serializerCache 原按适配器名做键,导致同进程内两个
   VM 共享判定,适配器重写后新钩子永不生效且不报错。键改为
   (VM, revision, adapter),回归测试 TestSerializerCacheIsPerVM。

测试三件套:
- 功能:适配器接管 / 兜底(无适配器)/ 钩子失败退回兜底 / 缓存随改写失效
- 压力:TestSerializerForConcurrent(16 worker × 40 次)
- 回归:golden 字节级未漂移,Phase 0 安全网生效

验证:go test -tags luajit ./... 全绿(gateway 200s / lua 56s)。
2026-10-11 08:57:51 +08:00
1c6a2821e4 feat(provider): 端点机制去厂商化 —— 适配器声明 endpoint_stream
内核不再知道 Gemini 的流式动词。之前 provider.ChatURL 对
":generateContent" 做字符串替换(注释自认 "Gemini is the only such
adapter"),把厂商协议知识放进了调度器。

现在适配器自己声明:

    adapter.endpoint_stream = "/v1beta/models/{model}:streamGenerateContent"

内核只比较两个不透明字符串,四级优先级:

    source.endpoint_stream > source.endpoint > adapter.endpoint_stream
                                          > adapter.endpoint

- staticInfo.endpointStream + VM.StreamEndpoint() 编译期提取
- config.Source.StreamEndpoint(yaml: endpoint_stream)
- gemini.lua 声明 endpoint_stream
- 内核代码 generateContent 计数归零(仅注释/适配器持有)

首版实现踩了一个回归并当场修掉:源自定义 endpoint 时适配器的 stream
endpoint 会错误覆盖用户的显式路径。TestEndpointPrecedence 把四级优先级
钉死,防止再犯。

验证:go test -tags luajit ./... 全绿;golden 字节级未漂移。
2026-10-11 08:30:39 +08:00
3ad46ea23e feat(types): 统一线格式 v2 最大集合 + 对外输出 golden 基线
Phase 1:内核定义市面所有主流协议字段的**并集**作为唯一内部格式,
适配器按需取用;内核不再携带任何协议形状。

- internal/types/maxwire.go:UnifiedRequest(21 字段,v1 的 10)、
  UnifiedResult(11,v1 的 8)、UnifiedDelta。覆盖 openai/anthropic/
  gemini/ollama/deepseek 各自必需字段(system 顶层、top_k、stop_sequences、
  thinking 预算、parallel_tool_calls、response_format、safety/citations…)。
- 只带**语义**不带**容器**:generationConfig/systemInstruction/options/
  num_predict/maxOutputTokens 等协议专属形状一律不进统一格式,由适配器
  自行装填。测试 TestNoProtocolContainerLeak 钉死这条。
- 修掉 v1 的私有字段泄漏:disable_thinking/extra_body 曾是内核私有字段,
  却被序列化进交给适配器的 body,导致 10 个适配器各自写
  `req.disable_thinking = nil` 清理。v2 用 thinking 语义字段替代,
  TestNoKernelPrivateFieldLeak 为回归。

Phase 0:建立对外输出的**字节级** golden 基线(.snapshots/),
因为现有 e2e 只有 strings.Contains 宽松断言,无法证明 Phase 2 把
OpenAI wire 移交给适配器 serializer 后输出字节未变。

- e2e/wire_golden_test.go:非流式/流式/生图三个 golden,
  比对前 scrub 掉 id 与 created(每请求变化),其余逐字节比对。
- 反向验证:注入 `chat.completion.chunk.DRIFTED` 漂移后 golden 确实失败
  (证明它不是永远通过的空壳),随后完整恢复。
- 稳定性:连跑 3 次全绿。

验证:go test -tags luajit ./internal/types/ ./e2e/ -run 'TestMax|TestNo|TestWireGolden' 全绿。
2026-10-11 08:24:58 +08:00
6113924655 chore(adapters): 移除非官方调用方式的适配器,保留官方适配器与模板
移除 4 个靠伪造客户端身份/绕过 WAF 才能工作的适配器,内核不再发行任何
非官方调用方式:

- opencodezen / opencode / opencodego:靠 x-opencode-* 客户端指纹冒充官方
  客户端;zen 免费池明确要求「free tier can only be used in OpenCode」
- agentrouter:伪造 Origin: qwen.ai + Referer + X-Requested-With 绕过阿里云 WAF

保留 12 个官方适配器(openai/deepseek/anthropic/gemini/github/groq/mistral/
ollama/kimicode/qwen38/sensenova/trae),其中 sensenova/qwen38 实测零伪装特征。

本地保留两处:adapters/_local_archive/(gitignored 非递归子目录,不加载)
与线上 /etc/llmsproxy/adapters/(adapter_dir 权威副本,cp -f 不删文件)。

测试同步:删除仅服务于被移除适配器的 11 个测试函数与 usage_cost_test.go;
改写 2 处借 opencode 之名的 provider 测试为用 openai 的 HTML 错误页压缩能力
验证同一契约(适配器钩子拥有错误格式、原文不外泄)。

验证:go test -tags luajit ./... 全绿;二进制内 x-opencode-client /
QwenCode/0.2.0 / qwen.ai 等指纹字符串计数归零。
2026-10-11 08:17:56 +08:00
d3203d88fc feat(plugins): usage 插件进仓,随包播种
之前只通过 POST /api/plugins 装在生产上,源码仓里没有 —— 于是
core-dist.sh 的 deb/rpm/tar.gz 与 GUI 的 electron-builder 产物都不带它,
/v1/usage 在任何拿到安装包的人那里都是 404,页面显示「后台未开放消费接口」,
看起来像坏了而不像少了个可选功能。billing.lua / quota.lua 一直都是 embed 的,
usage 与它们同级。

四个测试(internal/lua/usage_plugin_test.go)盯的是三件容易在改 Lua 时被
无声破坏的事:

1. 遮罩算法必须与 Go 的 keyID() 一致。/api/stats 的 map 以掩码(***f9a9ed)
   为键,handler 收到的是完整 key;传完整 key 不报错,只是静默返回 0。实测:
     ?key=***f9a9ed        → reqs=33847
     ?key=sk-gw-7ffe…9ed   → reqs=0
   端点于是对一个有几十万次请求的 key 报「0 次」。
2. 不得读 PLUGIN.state / prices / 任何凭据:它报的是**调用者**的用量,
   读配置里的 key 会把一个租户的花费报给所有租户。
3. 取不到数据必须报「usage unavailable」,不能返回 0:0 会被渲染成
   「已用 0,0 次」,那是「这个 key 从没被用过」这个不同的、且错误的断言。
4. period 默认 all:日维度看起来像每天清零。

反向验证过 roles 声明:撤掉后 TestUsagePluginShipsAndGuardsItsOwnKey 变红。

(cherry picked from commit ae2b121959)
2026-10-07 17:37:22 +08:00
7836b95a84 fix(gateway): ?key= 传完整 key 会让 /api/stats panic
给 usage 插件探路时撞出来的,与插件无关。

Snapshot 的统计 map 以**掩码** key id 为键(keyID(req.Key))——by_key 返回什么、
面板就传什么。而 exportKey 对 admin 允许 ?key= 任意值,于是:

    byKey = map[string]*Stat{key: s.byKey[key]}   // 传完整 key → 零值 nil *Stat
    ...
    rows() 里 Stat: *v                          // 解引用 nil → panic

实测(改动前,线上):

    /api/stats?period=day&limit=1&key=sk-gw-7ffe12c5cbacec40bd39a7291df9a9ed
    → curl: (52) Empty reply from server
    → 日志: http: panic serving …: invalid memory address or nil pointer dereference
        llmsproxy/internal/gateway.rows  stats.go:771

net/http 会 recover 单个连接所以进程不死,但客户端拿到空响应,日志里每次一条
panic 栈。任何「这个 key 用了多少」的问法(key 已配但当天无流量、period 把它
的历史滤掉了)都会命中 —— 而零正是那个问题的答案,不是错误。

三处防御,因为同一个错误形状有四个入口:
- rows() 跳过 nil 而不是解引用(调用方不止 Snapshot)
- byStatus 的两处遍历同理
- PeriodSnapshot 的 buckets[k] 同理

by_key 仍保留该 key 的一行零值:面板按行存在与否渲染,缺行会让 key 整个消失。

判据:internal/gateway/stats_unknown_key_test.go(5 个测试)。反向验证过——
把 Snapshot 与 rows 的修复撤掉,TestSnapshotUnknownKeyDoesNotPanic 立刻 panic。

(cherry picked from commit af977efbf7)
2026-10-07 17:37:22 +08:00
b6d0358079 test(harness): 变异测试拒绝在脏工作区上运行
两个变异 harness(Go 版 discover、Python 版 quota)都是「把变异写进源码
文件、跑测试、再还原」。这个形状有两个陷阱,上一轮都真踩到了:

一、脏工作区上的 baseline 是错的

变异被覆盖写入实现文件。如果文件本来就有未提交改动(上一轮被 background
task 超时杀掉、Ctrl-C 中断、编辑器里改了一半),那么:

  - 捕获的「原始内容」不是已提交的源码,baseline 跑的是别的东西
  - 留下的那个变异会被下一轮当成 baseline

两种情况下所有裁决都毫无意义,而 harness 仍然报出干净的分数 —— 正是
「判据失效」最坏的一种:它不报错。

改为直接拒绝运行(Go 版 t.Fatalf,Python 版退出码 2),并且不自动 checkout:
脚本分不清「残留变异」和「人正在改」,静默回退真实改动比停下来更糟。

二、注入必须原子,还原必须覆盖所有退出路径

Python 版原来用 open(path,"w"),就地截断。与它并发的构建(background
go test、编辑器 gofmt、编译器自己读文件)可能看到写了一半的文件,报
syntax error,然后被当成 COMPILE 静默豁免 —— 一个真实的变异就这样被放过。
改为 tempfile + os.replace。还原登记到 atexit 与 SIGINT/SIGTERM。

三、路径必须相对当前目录

Go 版最初用 cwd=包目录 + 仓根相对路径,git 匹配不到任何东西,守卫永远不
触发。两个 harness 现在都用 ./name,各自在自己目录里跑 git。

两个方向都验证过:干净时全杀(discover 11/11、quota 12/12),脏时拒绝运行。

(cherry picked from commit d01712a697)
2026-10-07 17:37:22 +08:00
0858a747ca fix(lua): 导出函数调用不标脏,插件状态从不落盘
quota 声明 hooks=[],全部工作都在导出函数里。markDirty 只从 hook 与
SetState 触发,Call 两边都不占 —— 于是探测改的内存没人写盘,状态文件要等
下一次无关的配置写入才顺带刷新。

现象是「插件看起来没写」:负 memo 要等到下一次改配置才出现。用隔离实例
逐步观察时,一度把这误读成 memo 状态机的三个缺陷,逐个查清后只有这一条
是真的:

  - 「未就绪轮次累计 streak」 —— 不成立。写侧本来就对临时条目用 streak=1,
    65ed1b 版本已是这个行为。
  - 「读侧用旧快照,streak 永远停在 1」 —— 是退避的设计行为。单进程内第二
    次探测被退避挡在写侧之前,streak 自然不再增长;真实的多轮计数在跨进程
    (trace_streak.py)下是 1/2/3/4。
  - 「Call 不标脏」 —— 真缺陷,已验证:去掉 markDirtyLocked,
    新增的 TestOnDemandCallIsPersisted 立刻变红。

顺带保留两处无行为差异的可读性收紧:provisionalEntry 统一按「集合非空」
判断临时条目(与 copySet 空集返回 nil 的写法对齐)。

判据:TestOnDemandCallIsPersisted 用 hookless 插件走导出函数累加,
反向证明(去掉 markDirtyLocked)会红。

(cherry picked from commit 3444d2d903)
2026-10-07 17:37:22 +08:00
7934507179 fix(quota): 未就绪适配器污染负 memo;被拒凭据被后续 404 掩盖
端到端验证时撞出两个真缺陷,都是单元测试全绿、隔离实例打真上游才暴露。

一、负 memo 语义混用

负 memo 说的是「这台 HOST 没有余额端点」,且无条件在每次探测前生效。
openDesign 缺 control_key 说的是「这台部署还没配」—— 两件事共用一个条目:

  1. 未配 control_key → 探测 → 负 memo
  2. 操作员写入 key   → 探测被退避拦下,adapter 根本没跑
  3. 结果:修复看起来无效,源持续沉默(退避窗口可达数小时)

不能靠「skip 就不记 memo」解决:真实 vela 主机上另外三个 adapter 都返回
404(关于主机的持久结论,正是 memo 存在的理由),verdict 一定是普通 skip。
做法是 memo 条目带上「哪些 vendor 因自身未配置而无法判断」:
  - 未就绪的 adapter 直接不跑(跑了只能得到 401 或伪造的 skip)
  - 条目带 unready 集合;该 vendor 配置好后条目作废重算
  - 仍缺配置时条目依然写入(否则无看板的主机回到每次开页 60 秒超时),
    但不退避 —— 退避会拒绝那个本来能成功的探测

二、401 被下一个 adapter 的 404 掩盖

adapter 循环不停在 error,而「最后一个胜」不精确:openDesign 走厂商的整份
主机列表,是最可能看到「key rejected」的那个,但它也可能排在最前(memo 记
得它应答过,下次就优先它)—— 于是它的 401 被下一个 adapter 的
404「no dashboard here」分类成「这台主机没有余额端点」。两种情况的修复动作
完全相反,输出却一样。规则改为:被拒凭据优先,且不被覆盖。

判据:12/12 变异全杀。三条曾经存活或误杀,逐条查清:
  - rejected-as-skip 存活是因为测试没有复现生产调用顺序(memo 空 → openDesign
    排最后 → 旧规则也能过),测试已改为预置 memo 并让另三个 adapter 走同一个
    stub(它们读 base 而非 api_base,否则会全部传输失败)
  - no-cred-guard 并非存活,只是杀它的不是我以为的那个测试(门禁后面还有
    一道检查),预期已改正
  - unready-memo-not-dropped 是真等价变异(known=false 与 nil 只差一次廉价
    探活),已从清单移除

(cherry picked from commit 83f1ec0807)
2026-10-07 17:37:22 +08:00
f042bbf4af fix(plugins): 分片 VM 拿不到插件配置
页面探测走的是 fanout(分片)路径,结果写回走共享 VM。分片 VM 从同一份
源码启动,但没有 state 文件、没有插件身份,所以 PLUGIN.prices(配置,不是
累计状态)在分片里是空的。

对只读累计状态的插件无所谓;对按配置分支的插件就是致命的。OpenDesign
适配器读 prices.control_key,未播种时它对每个主机都得出「未配置」并报
skip,而同一个调用走共享 VM 是好的。也就是说:生产上死、所有调共享 VM 的
测试全绿。

pool 在每次取用时推送配置(不只是首次启动时),并把配置变化也带给池中
复用的 VM;推送是拷贝,分片拿不到共享表的引用。

这条不是在写代码时想到的,是端到端验证时撞出来的:单元测试全过,隔离实例
打真上游时 vela 报「未配置」,而 control_key 明明写进去了。

(cherry picked from commit 65075b6d44)
2026-10-07 17:37:22 +08:00
97a6cf9224 feat(quota): OpenDesign 出站主机加入白名单
插件出站白名单 = 已配置源的 host。这条规则对每个 adapter 都成立,直到
OpenDesign —— 它把推理和计费拆在两个域上:推理 amr-link,计费 amr-api。
adapter 从推理主机推导计费主机,于是它请求的 host 从来不是操作员配过的
那个,请求在离开进程前就被拒,报"不是允许的出站目标",读起来像插件 bug。

core.sourceHosts 现在同时加入派生主机,且仅当源本身就在 amr-link 上
(没有 OpenDesign 源的部署不会凭空获得这条授权)。

派生规则前后端各存一份,因为 Go 建白名单、Lua 建 URL,两边不一致的表现是
「请求被拒」而不是「命名不匹配的错误」。规则改成了完整主机名匹配:
`amr-link.` 前缀写法会把 amr-link.example.com 也改写成 OpenDesign 地址。

顺带修掉一个真缺陷:Lua 侧先 `gsub("/+$")` 再匹配主机名,而 base_url 是
`https://amr-link.open-design.ai/v1` —— 带路径时整串拿去匹配 `^...$`
永远不命中,改写静默失效,探测打到推理主机。改成先切路径。

这个缺陷是「行为断言」抓到的:上一版判据 grep 源码里的 gsub 模式,而
gsub 模式在改前改后都"看起来对"。8/8 变异全杀。

(cherry picked from commit 4a63b9dc0b)
2026-10-07 17:37:22 +08:00
d92db3a9fd feat(quota): 接入 OpenDesign/Vela 余额探测(controlKey)
vela 一直显示 skipped,因为计费端点需要第二个凭据:推理走源自己的
runtimeKey,但 amr-api.open-design.ai 的 wallet/balance 和
billing/summary 只认桌面端登录时下发的 controlKey(runtimeKey 返回
401)。全量后拿到了 controlKey,本机 /root/.amr/config.json。

测了实测行为(两个端点各 200,约 1.3s):
  wallet/balance   → balanceUsd / expiresAt / workspaceId
  billing/summary  → creditsPerUsd / membershipTier /
                     subscriptionCreditsUsd / totalConsumedUsd 等

实现要点:
- controlKey 走插件自己的 prices 表(插件系统本就按插件持久化一张
  prices,与 state 分离),用 PUT /api/plugins/quota/state 设置;
  base_url 保持推理主机,adapter 内部改写 amr-link→amr-api。
- 本账户 billingState=free,没有 codingPlan.windows,所以不设
  rep.windows:页面显示"无周期数据",而不是编一个 100%。
- creditsPerUsd 缺失时置 credits_rate_missing 并跳过积分换算,不默认
  10000;*Usd 字段是权威值,直接采用。
- 401 归为 error(凭据要重登),不是 skip(无计费端点),两者修复动作
  不同。
- skip 时把原因带上:区分「未配置凭据」和「上游无该端点」。

新增 7 条判据 + 7 个变异全部杀死(含一条"skip 丢原因")。变异 harness
第一轮 6/6 时 no-cred-guard 存活,原因是该测试没设 api_base,探测被
出站白名单拦掉、与缺少凭据无法区分 —— 判据失效而非代码问题,已修。

(cherry picked from commit 75a0d247a4)
2026-10-07 17:37:22 +08:00
0e747b0891 fix(ui): 生图与自动探测按钮把对象塞给了 fetch
`api()` 是 `(p, o) => fetch(p, o)`,第二个参数是原生 fetch options,
所以 `body` 必须是字符串。两处都传了对象字面量:

- 生图页 `body: { model, prompt, n, size }`
- 源编辑框自动探测 `body: { base_url, api_key }`

fetch 把对象转成 `"[object Object]"`,网关 json.Unmarshal 失败返回
400,请求根本没到上游。生图页的现象是 lat_ms: 0。

两处都改成 JSON.stringify + 显式 Content-Type。

判据在源码层扫描全文件的每个 `method: "POST"`,而不是断言 DOM ——
wire format 在 DOM 里不可见,这也正是为什么之前测过 curl 端点却漏掉
了按钮。这条全文件扫描当场抓到了第二个同源缺陷(自动探测按钮)。

(cherry picked from commit 2fdb82b119)
2026-10-07 17:37:22 +08:00
be8d753e36 chore(gui): 版本号 1.9.6 → 1.9.7 2026-10-06 15:02:42 +08:00
438629256d feat(ui): 生图二级页面 + 图像模型不再混进对话页
## ① 图像模型曾与对话模型不可区分

根因是结构性的,不是漏了个过滤:

    Provider.Models()   → []string   ← 只有 ID,Kind 被丢掉
    SourceStatus.Models → []string   ← 扁平列表

于是 /api/status 给前端的 34 个模型(29 对话 + 5 图像)长得一模一样。选了
gpt-image-2 就 POST 到 /api/chat —— 那个端点服务不了图像模型,必然失败,
而且报错完全不提「这是图像模型」。

修法:新增 ModelKinds []ModelKindInfo([{id,kind}]),纯增量、omitempty,
老 UI 不受影响;前端按 optgroup 分组,且这些 option 是 **disabled** 的。

分组还不够 —— 分组只是视觉分隔,operator 照样能选,选了照样失败。
disabled 的 option 会自解释(「请到生图页」),可选的只会失败。

★ 写这段时踩了一次 String.replace 的坑:`imgOpts.join("").replace(...)`
只替换**第一个**匹配,于是 6 个图像模型里只有第 1 个被禁用。浏览器实测
逐条读 e.disabled 才抓到(siliconflow 禁用了、vela 的 5 个没有)。
判据只看 value 里有没有 "image" 的话,第一次跑就会永远报警——那是判据
自己错了,比没判据更糟。

## ② 生图二级页面

侧栏新增 tab,与「对话」并列。网格展示 + 提示词输入 + 数量/尺寸,
调用 /v1/images/generations(不是 /api/chat)。

按钮显示已用秒数:实测该上游 15.7s~112.5s,波动 7.6 倍,不显示进度
看起来就像卡死了。

浏览器实测(几何测量,非仅 DOM 断言):
    tab-image 容器   986×523  visible
    img-grid         840×374  visible
    提示词输入框      634×64   visible
    生图页 6 个模型,全部 kind=image,全部可选
    对话页 6 个图像模型,全部 disabled;97 个对话模型全部可选

## ③ base_url 引导不足

文案直接给出截断位置和具体后果,而不是泛泛地说「填 URL」:

    填主机地址,通常不带 /v1 —— 网关会自动拼接路径。但各家约定不同:
    若填 https://x.com/v1,对话路径会正确,而图像生成会变成 /v1/v1/… 而 404。
    不确定就用上方「自动探测模型」。

/v1/v1 不是假设:ImageURL 是 BaseURL + ImageEndpoint(),而图像默认路径
是**绝对**的 /v1/images/generations,chat 的默认是相对的 /chat/completions。
两者对「版本段归谁管」的假设相反。今天在 vela 上为此排查了两次。

## 判据

新增 6 个契约测试:
- 对话页必须读 model_kinds(不读就会退回扁平列表)
- 禁用图像模型不能用 joined-HTML 的 String.replace(只改第一个)
- 生图页存在且注册进 TABS(漏了则 pane 永不可见,症状是「页面空白」)
- sendImage 必须打 /v1/images/generations
- urlHint 必须同时提到 /v1 和 404
- TestImageEndpointComposesUnderRealConditions 用真实形态验证:
  相对路径拼出 /v1/images/generations 得 200,默认路径拼出 /v1/v1/… 得 404
2026-10-06 15:02:42 +08:00
a873fe4724 test(discovery): 真实变异注入 11/11,并给 harness 加三道防线
前几版 harness 先后报过「4 个变异存活」「11 个一个没跑」「9 个存活」,
查清后**全部是判据或变异本身失效,没有一个是代码问题**。这一版把三类
失效都变成显式失败,而不是静默给出错误结论。

**防线一:先证明 filter 选中了东西**
`go test -run <完全不存在>` **不打印** `[no tests to run]`(那句话只在
「包内有其他匹配项但目标没跑」时出现)。我原先据此写的检查方向是反的,
harness 第一步就自我否决。现在改成对比数字:空 filter 必须选中 0 个测试,
真实 filter 必须选中 >= 25 个。本次运行:32 / 0。

**防线二:编译失败 ≠ 变异被杀**
嵌套调用漏 `-tags luajit` ⇒ 纯 Lua 5.1 ⇒ billing.lua 的 goto 编译失败
⇒ 整包 build failed ⇒ **每个变异都「被杀」,给出满分**。这比假绿更隐蔽。
现在 [build failed] 直接 t.Fatalf。

**防线三:变异没改变行为 ≠ 判据漏放**
两次「存活」查清后都是**恒等变换**:
  `&& out.BaseURL != ""`  — 判据用零值结构体,BaseURL 为空,条件恒假
  `&& len(out.BaseURL) >= 0` — len 恒 >= 0,条件恒真
正确写法是把赋值换成 `_ =`:读了变量让编译器满意,行为真的变了。
检测方式:变异后若结果行(剥掉测试名、排序后)与 baseline 完全一致,
报 no-op 而不是记成存活。
no-op 检测本身也修过:原来保留 === RUN 行(含测试名),两次运行测试顺序
不同 ⇒ 每个变异都「看起来变了行为」,检测形同虚设。

11 个变异覆盖:kind 判定、metadata 透传、非目录拒绝、URL 回退、
版本段搬移(chat 与 image 两条)、版本段识别、nil 切片、鉴权分类、
候选去重。

★ 判据的期望值不写死数字:候选去重修好后 len(cands) 从 3 变 2,
连改两次判据都是在追当前列表。改成断言「该覆盖的 URL 都在」。
2026-10-06 15:02:42 +08:00
31bc3968c5 fix(discovery): 裸 host 也必须有回退候选
补 image_endpoint 那一轮的测试暴露了一个覆盖缺口:候选构造是

    if !hasVersion { add(raw, "/v1/models", 0) }
    else           { add(raw, "/models",   0) }

裸 host(https://api.vendor.com)因此只生成**一个**候选,没有任何回退。
catalog 挂在裸 /models 上的厂商会被报成不可达。

为什么一直没发现:所有测试用的 base 都以 /v1 结尾。生产里唯一已配
kind:image 的 siliconflow(base_url https://api.siliconflow.cn)它的
/v1/models 恰好能应答——**一个能工作的巧合把这个洞盖住了**。

现在两个 catalog 路径无条件都试(偏好只决定「多个都成功时报告哪个」,
不决定「试哪些」)。同时修正优先级:base 已带版本段时第一候选必须是
相对的 /models,否则会先试 …/v1/v1/models——一个必然 404 的 URL,白花
一个来回,还让错误信息以无意义的 URL 开头。

判据同步改了:原本断言 len(cands)==2,那是**在测量当前的列表而不是
覆盖性质**,去重修一次漂一次。现在断言「该覆盖的 URL 都在」
(base+"/v1/models" 与 base+"/models"),实现怎么变都不会漂。

新增 discover_endpoint_test.go:
- 裸 base 不带版本段时 image_endpoint 必须留空,否则会写坏今天正常工作的
  siliconflow 配置
- 裸 host + catalog 在 /v1 响应时 endpoint 必须是 /v1/chat/completions
  (这正是我第一次把测试期望写反的地方:catalog 路径是上游要不要 /v1 的
  唯一证据)
- 两种 base 形态下最终拼出的 chat/image URL 逐字断言,且都不含 /v1/v1
2026-10-06 15:02:42 +08:00
c40b5b5981 fix(discovery): 错误分类 + 去掉重复请求 URL
演练错误路径时发现两个真问题。

**① 密钥错误被报成 502(上游故障)。** 这是最常见的失败,却和「厂商挂了」
给出完全相同的错误。两者需要的动作正好相反:重新输 key vs 去查厂商状态页。
操作员只能猜。现在 401/403 会得到 401 并在信息里点名 api key;
DNS 不解析、TLS 失败、连接被拒也各自带一句可执行的说明。

**② 错误信息里的 tried 列表有重复。** base "https://h/v1" + path "/models"
和 base "https://h" + path "/v1/models" 请求的是同一个地址,但候选去重只按
(base, path) 对,于是同一条 URL 在错误信息里出现两次——读起来像「试了两个
地方」,其实只试了一个。改成按最终 URL 去重。

去重后候选从 3 个变成 2 个,两个判据的期望值需要跟着改:
TestDiscoveryCandidates_DedupesAndPrefersTyped 和变异测试的
「fallback search」原本断言 >= 3,那是在测量 bug 而不是覆盖度。覆盖没有丢失:
剩下的两条是 https://h/v1/models 和 https://h/models,两个不同地址。

新增判据:
  TestDiscoverModels_WrongKeyIsUnauthorizedNotBadGateway
  TestDiscoverModels_UnreachableStaysBadGateway(防止过度纠正)
  TestDiscoveryCandidates_NoDuplicateRequests
  TestDiscoverModels_FailureListsEachURLOnce(对操作员真正读到的文本断言)
2026-10-06 15:02:42 +08:00
7ce0378e8b docs(discovery): 用实测数据替换注释里的猜测
composeImageEndpoint 原注释写着「两种形式都 404,无法区分」,那是我写代码时
的推测,不是观测。后来真实打了一次上游就分清了:

  /v1/images/generations    → 200(图像生成本身要几分钟,是上游耗时)
  /v1/v1/images/generations → 404
  /images/generations      → 404

注释里的假话比没有注释更糟——下一个人会拿它当「已知的模糊地带」而不敢
收紧判据。
2026-10-06 15:02:42 +08:00
ae9f56ddb3 fix(gateway): 探测同时算出 image_endpoint,修 /v1/v1 404
上一版只算了 endpoint,图像生成仍然 404。根因是网关两条拼接路径的默认值
规则相反:

    ChatURL  = BaseURL + Endpoint()       默认 "/chat/completions"      相对
    ImageURL = BaseURL + ImageEndpoint()  默认 "/v1/images/generations" 绝对

base_url 带 /v1 时 chat 拼成 /v1/chat/completions 正常,image 拼成
/v1/v1/images/generations 就 404。之前归一化 base_url 时修正了版本段的位置,
但没意识到图像这条路径的默认值自带 /v1,它跟 base 是各管一段。

探测现在额外返回 image_endpoint:base 末尾有版本段时给
/images/generations,否则留空用默认值。判据是 TestComposeImageEndpoint ——
它断言 base+结果绝不含 /v1/v1,这是那个 404 的字面形态。

apply 的继承逻辑相应改了:image_endpoint 不再从旧源继承。旧值是对旧
base_url 正确的,而 base_url 恰恰是这次可能变的东西,沿用它会留下一个对着
新 base 404 的陈旧值。前端 srcDiscover() 同步写入该字段。

端到端(隔离实例 18099,真实 amr-link 上游):
  探测     http 200,1.4s,29 chat + 5 image
  apply    http 200,落库 34 个模型
  文本     http 200,2.0s
  视觉     http 200,1.5s
  图像     路径修正为 /v1/images/generations(不再 404;上游本身 120s 超时,
           属其自身耗时,非路由问题)

探测对已存在的源返回 409 要求显式 replace_models=true,这个保护在验证中
确实生效了。
2026-10-06 15:02:42 +08:00
3860c7381f feat(gateway): 源自动探测模型列表
只需填 URL 和 key 即可自动扫描上游模型并配置,不用再手抄模型 id。

端点
  POST /api/sources/discover        只探测,不写任何东西
  POST /api/sources/discover/apply  探测后落库

核心难点是 URL 形态而不是 /models 调用本身。网关按 base_url + adapter
endpoint 拼路径(openai 提供 "/chat/completions"),而各家厂商对版本段
放在 base 还是 path 上没有共识,也没有哪个响应头能说明。所以探测不信任
调用方给的 URL,而是按偏好顺序试若干候选,返回答的那个,并给出归一化
后的 base_url + endpoint 组合。这正是第一次配 vela 时 /v1/images/
generations 返回 404 的成因:base_url 里已有 /v1,默认图像路径再拼一次。

归一化的不变量是「版本段在 base_url + endpoint 里恰好出现一次」。留在两边
会重复;两边都没有则 chat 请求在要求版本段的厂商上会打错路径。

kind 只信上游自己声明的 modality,绝不从模型名猜。"gpt-image-2" 这类
id 也出现在同时提供对话服务的目录上,猜错会让 AUTO 链选中一个只支持图像
生成的 id,其后每一层都被它拖垮。

不做的事:
- 不逐模型发单 token 探活。34 个模型的目录跑一轮要花钱且耗时数分钟,
  /models 是足够便宜的信号;模型是否真能应答留给正常请求路径。
- 不猜能力。「vision」出现在 id 里不等于该模型接受图片。
- 不自动写盘。探测只读,保存仍需操作员按按钮——错误的 base_url 会产出
  一个看起来合理却无法使用的源。

已存在的源必须显式 replace_models=true 才会覆盖模型列表:重新探测顺手
抹掉为 AUTO 链手工调过的 priority 是最难排查的一类数据丢失。

测试用真实抓取的 amr-link /models 响应做夹具,它把 modality 嵌在
architecture 下、metadata 下还有 cost/context_limit,34 个模型里混着 5 个
图像生成 id——朴素解析器正好在这三处出错。

变异测试 9/9 全杀(TestRealMutants,baseline 43 个测试)。过程中发现并
修掉四个真缺陷:嵌套 metadata 丢失、null 被当空目录、候选盲目拼接出
/v1/v1、以及 discoveryResult 零值序列化成 null 会让前端渲染出
"undefined"。早先的 shell 版变异脚本因 -run 转义损坏报出 4 个"存活",
实际是零个测试被执行——判据分不清"通过"和"没跑"就不是判据。
2026-10-06 15:02:42 +08:00
94c1e7688f fix(gateway): 流式请求从未记录 reasoning_tokens
## 症状

接了一个 reasoning-heavy 的源之后,读生产审计文件发现:它的**流式**请求全部
只有 completion、没有 reasoning。实测对照:

    流式   compl=8166  reasoning=—      ← 网关记的
    非流式 compl=751   reasoning=167    ← 同一个 adapter,非流式正常

客户端两种情况的响应都正确,所以**没有任何报错**。只有审计记录更安静。

## 根因

两条记账路径,只有一条读了它:

    recordChatUsage(非流式)         读 resp.TokenUsage.CompletionTokensDetails
    流式的 usage 合并(内联在 handler) 只读 Prompt 和 Completion

cache 记账当时被镜像过去了,**reasoning 单纯没加**。所以一个花了几百 token
思考的模型,在流式下看起来像什么都没想。

这不是 qwen38 的问题 —— 是任何流式 reasoning 模型的问题,之前没有源暴露它。

## 为什么是「读审计文件」发现的,而不是失败的测试

现有判据 `TestRecordChatUsageKeepsReasoningTokens` 只覆盖非流式那条路径。
流式那段内联在 handler 里,测不到。抽出 `applyStreamUsageDetails` 之后
才有地方挂判据 —— 抽取不是为了好看,是因为不抽就没法测。

## 判据(四条)

- 流式记录 reasoning,且精确值(512)
- **reasoning 绝不被加进 completion** —— 关系式断言,不是「字段存在」检查。
  写成 `rec.Compl += reasoning` 会让每个思考 token 被收两次费。
- 上游没报 reasoning 时不写字段(0 在审计里表示「未上报」,与「没思考」不同)
- cache 记账未被顺带破坏 —— 三种输入形态各测一遍

变异自证三次变红:删掉 reasoning、加到 completion 上、破坏 cache 块。

第四次变异(`d.ReasoningTokens > 0` 改成无条件赋值)没变红 —— 那一版与原版
**行为完全相同**:rec.Reasoning 初值就是 0,且 json tag 带 omitempty,两种情况下
客户端都看不到这个字段。这是无行为差异的变异,不是判据漏放。

## 生产实测

    部署前(13 条):compl=204/277/1822/1264/740/…  reasoning 全部未记录
    部署后( 1 条):compl=213  reasoning=42  status=200

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-06 15:02:42 +08:00
b8386465ea feat(adapters): qwen38 —— tokenplan/discovery-api 适配器
接 https://discovery-api.intern-ai.org.cn/v1(qwen3.8-27b 等 10 个模型,5 个支持
视觉)。这是一个 OpenAI 兼容端点,但有 Stock OpenAI 适配器处理不了的地方。

## 为什么需要专用适配器

**① 思考内容占绝大部分输出,且是独立字段**

实测视觉问答:completion=303,其中 reasoning=295 —— 97% 的 token 预算用在
思考上,只有 8 token 变成正文。

后果是**静默失败**:直接透传上游的 content,调用方看到的是空字符串,看起来
像模型坏了。实测 max_tokens=60 时上游返回 HTTP 200 + content=null +
finish_reason="length" —— 一个健康的请求,什么都没产出。

所以思考内容走自己的字段,绝不折进 content:折进去比丢掉更糟,页面会把模型
的内心戏当成答案显示。

**② `finish_reason: "length"` 配 `content: null` 是正常的**

不是上游故障。上报真实的 finish_reason 和已产生的部分思考,让调用方能区分
「预算用完了」和「上游挂了」。

**③ 服务端会改写模型名**

请求 `qwen3.8-27b`,返回 `model: "qwen3.8-27b-fp8"`。响应里的 model 才是
「谁真的服务了这次请求」,计费按它记账。适配器**不改回去** —— 改回去会让审计
记录撒谎。

## 踩到并修正的一个真 bug

reasoning_tokens 最初放在 `token_usage.reasoning_tokens`(顶层)。类型检查
通过,然后被丢弃。

internal/gateway/chat.go 只在一个地方读 reasoning:
    resp.TokenUsage.CompletionTokensDetails.ReasoningTokens

实测证据:模型报 completion=88,网关响应里 reasoning=null。字段在正确的地方、
错误的层级,等于没有。

判据因此改成断言**路径**而不是断言「某处有个数字」,变异把顶层放回去立刻变红。

(顺带发现:deepseek adapter 根本没发这个字段,所以线上 reasoning 计费一直
是 0。那是另一个缺口,本适配器不依赖它被修。)

## 配置时要知道的一件事

远程图片 URL 由**上游自己去拉**,会稳定超时(实测 Wikimedia 图片返回
media_fetch_failed)。图片必须用 data: 内联。

## 判据(9 条,夹具全是真实响应)

- 思考与正文分离,且互不污染
- 截断响应上报 finish_reason 与部分思考
- reasoning_tokens 走正确路径,且是 completion 的子集
- tool_calls 的 arguments 从 JSON 字符串解码
- 流式:思考 chunk 不漏、正文 chunk 不丢、末尾 usage 带 reasoning
- 媒体失败带 code(media_fetch_failed),invalid_api_key 不带(免得重复)
- 带 tool_calls 的 assistant 轮补 reasoning_content(否则下一轮被拒)
- 多模态 content 数组原样透传

变异自证九次全部变红,其中三次是本次专门补的:reasoning 折进 content、
顶层 vs 嵌套路径、设成 0。

## 隔离实例实测(生产配置 + 真实上游)

    非流式  completion=252  reasoning=167  ✓ 透出
    流式    26 chunk / 714ms,正文 37 字符,思考 55 字符  ✓ 分离
    视觉    base64 图片 → "白色。"  reasoning=44  ✓ 无损

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-06 15:02:42 +08:00
f682205d26 chore(gui): 版本号 1.9.5 → 1.9.6 2026-10-06 15:02:42 +08:00
ba81a4d7e1 chore(gui): 版本号 1.9.4 → 1.9.5 2026-10-06 15:02:42 +08:00
e30199b29d chore(packaging): 归档 Release 附件上传脚本
v1.9.5 上传时踩了两个坑,都不好从错误信息看出来:

1. 端点形状不对称且反直觉
     GET  /releases/tags/{tag}/upload_url   → 404(tags/ 只用于读取)
     POST /releases/{tag}/assets            → 404(该端点不存在)
   看起来像认证失败,实际是路径不对。

2. PUT 必须逐字回放 upload_url 响应里的 headers
   (x-obs-meta-project-id / x-obs-acl / x-obs-callback / Content-Type)。
   少一个就变成未签名请求,报错是存储层的模糊错误 —— 看着像大小超限或文件损坏。

当时先试了三个直觉端点全 404,之后 grep 自己的记忆日志才发现 9-26 就记着
「upload_url 路径不带 tags、PUT 必须回放 headers」。路径形状有疑问时先查历史,
比继续试快。

用法: packaging/upload-release.sh <tag> <file>...
2026-10-05 10:46:34 +08:00
a1fddf72b7 fix(quota): 十三个退避中的源被渲染成「有数据」
## 症状

生产 Quota 页面上 17 个源里 13 个显示「有数据」徽标 —— 而它们实际上什么都
没返回,只是刚刚被退避跳过。

## 根因(三层,逐层都静默)

**① 退避记录缺 `skipped` 标记**
`due_sources` 与 `collectSource` 返回退避记录时没有 `skipped = true`,页面的
`filter(r => !r.skipped)` 就把它们当正常记录放进卡片。卡片渲染匹配已知字段,
一条只有 `reason` 的记录什么都匹配不上,于是落到兜底分支。

**② 兜底分支文案是「有数据」**
那是个断言 —— 它在说「探测有应答」。落到这里的记录其实是:一条退避通知、
一种我们不认识的错误形状、或供应商改了响应格式。把「没有证据」说成「有数据」,
十三个死上游就被渲染成健康的了。改成「未探测」。

**③ 退避与「没有余额接口」被混为一谈**
页脚原本统一说「未暴露余额接口」。这是对上游的另一种断言,而且对死上游是错的
—— 它有接口,只是暂时没探到。

## 「只改了一处」这个坑

`render()` 改成按 `backing_off` 标记分组之后,两个调用点都必须传**记录**而不是
名字。我先改了刷新路径就部署,结果是页脚显示「未列出 13 个源:、、、……」——
症状看起来像渲染 bug,实际是两个调用点与同一个函数之间的契约不一致。

快照路径那行 `.map(function(r){ return r.name; })` 是后来才补的。
判据 `TestQuotaRenderCallSitesPassRecordsNotNames` 现在要求两处都在。

## 顺带修掉的两个真 bug

**空 Lua 表在 Go 侧解码成 `{}` 而不是 `[]`**
`plan.backing_off || []` 里 `{}` 是 truthy,空表会原样进入 `out.concat()` 并抛错,
整次刷新报失败。改用 `Array.isArray` —— 它与插件实际返回的类型匹配。

**分组靠文案匹配是个定时炸弹**
原先用 `reason.indexOf("backing off") === 0` 判断退避。理由字符串一旦本地化,
退避就会被并进「未暴露余额接口」。改用显式 `backing_off: true` 标记,
判据里也改成按标记匹配 —— 变异把文案改回英文立刻变红。

## 判据

新增两条:
- `TestQuotaBackoffRecordIsSkippedAndFlagged` —— 退避记录必须带 `skipped` 和
  `backing_off` 标记,且必须有 reason(否则卡片是空的)
- `TestQuotaRenderCallSitesPassRecordsNotNames` —— 两个调用点都不得把跳过列表
  映射成名字

变异自证四次全部变红:去掉 `skipped`、去掉 `backing_off`、文案改回英文、
快照路径改回 map-to-name。

## 生产实测(浏览器真实点击)

```
第1轮 1694ms: 4 个源 · 0 个读取失败
        13 个源正在退避(连续探测失败,等下次重试):trae、qijiar、toter、…
第2轮 2488ms: 4 个源 · 0 个读取失败
        13 个源正在退避(…):trae、qijiar、toter、…
```

页面现在只列 4 个真有数据的源,13 个退避中的归到页脚并说明原因。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-05 10:27:18 +08:00
b9a025efba feat(quota): 判定与记录回到共享 VM,只有网络等待才分片
## 我引入的自相矛盾

分片是为解决这个量出来的:

    单源探测(无竞争)      1.4 – 3.3 s
    20 源 Promise.all       29 s
    20 源串行理论值        ≈ 29 s   ← 吻合

根因是 Plugins.Call 持 p.mu 到 Lua 函数返回,**含全部网络等待**;一个 Lua VM
只有一个栈,锁不能去掉。于是每个并发调用用自己的 VM 跑同一份源码(实测 8 分片
2.407s → 0.304s)。

但分片 VM 有自己的 PLUGIN.state —— 而**退避状态就存在 state 里**:

    fanout(分片)  →  memo 0 条   →  退避永不生效,每轮都付满 12s
    call(共享 VM) →  memo 17 条  →  13.1s → 4.8s

我设计分片时写了「分片不碰 state」这条硬约束(防 billing 的累加在各分片分叉、
saver 写出随机一份),却没意识到探测的退避状态恰好住在那里。约束和功能打架。

## 解法:按职责切开,不搬家

把退避表移到网关(B)能解决,但 quota 的 adapter memo 也在 state 里,而且
「插件自包含」是这个插件机制的前提。搬一半比不搬更糟。

所以判定与记录留在插件里,共享 VM 负责;只有网络等待分片:

  due_sources   共享 VM,纯判定,不发请求(微秒级)
  fanout        只对 due 里的源分片探测
  memo_record   共享 VM,把这轮结果(成功和失败都记)写回

一个分片也不碰 state —— 约束保住了。

## 实测(隔离实例,18 源,与生产同配置)

    第1轮  10163 ms   退避跳过 0   分片 18   答了 4
    第2轮   1299 ms   退避跳过 14  分片 4    答了 4
    第3轮   1288 ms   退避跳过 14  分片 4    答了 4
    第4轮   1316 ms   退避跳过 14  分片 4    答了 4

**29s → 1.3s,且 4 个有余额的源每次都探到** —— 不是靠少显示换来的快。

## 几个细节

- due_sources 的 body 里不能出现 collectSource:一旦它自己探测,过滤就没有意义
  了(成本在分片之前就付掉了)。有判据守着。
- memo_record 忽略 reason 以 "backing off" 开头的行 —— 那是被跳过的探���,
  把它记成失败会让 streak 在一次没发生的探测上增长。
- 成功写成 vendor 字符串,streak 自然重置,不需要显式清零。

## 判据(新增 4 条,共 11 条读实际发布的 quota.lua)

- 两个导出存在且在 exports 里
- due_sources 不调 collectSource
- memo_record 让 streak 从分片失败里累加,成功后重置
- due_sources 把退避中的 host 挡在分片之外,且零网络请求

写判据时踩了一次:两个源用了同一台 httptest server,于是被判为同一个 host,
两个都退避 —— 这是按 host 记忆的**正确**行为,是我的判据写错了。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-05 08:17:57 +08:00
7c7cf3e078 fix(quota): hostOf 用了 gmatch 而不是 match —— memo 表从未命中过任何东西
## 这是退避失效的真正原因,而且比 TTL 严重

查生产 state 时发现的:19 条 memo,`hostOf(key)` 命中 **0** 条。

    hostOf = function(u)
      return (string.gmatch(u, "^https?://([^/]+)")()) or u
    end

`string.gmatch` 返回**整个匹配**,不是捕获组;而带 `^` 锚定时迭代器不会前进,
所以第一次 `()` 调用返回 **nil**,函数走 `or u` 分支返回完整 URL。

后果是彻底的、而且完全静默:

  - 每次查表都是 HOST_MEMO[nil],每次写入也是 HOST_MEMO[nil]
  - memo 表**对任何 host 从未命中过**
  - 负 memo、PROBE_MEMO_TTL、以及刚加的指数退避,**全是看起来活着的死代码**
  - 生产那 19 条历史 memo(key 是完整 URL,来自 ccquota 时代)从来没有被读过

所以「两个死上游每 30 分钟重付一次探测」的真实原因不是 TTL 到期,
而是压根没命中过 memo。

## 为什么这么久没人发现

失败模式与「memo 里没有可记的东西」完全不可区分:页面照常显示,卡片照常
skipped,没有任何报错。唯一线索是 state 里那 19 条条目 —— 它们看起来像是
在正常工作留下的痕迹。

## 修法

    string.match(u, "^https?://([^/]+)")

判据 `TestQuotaMemoKeyIsTheHost` 钉住它:读**实际发布的** quota.lua,检查
用 string.match,并在运行时验四个 URL 的 host 抽取,以及同一 host 的两个不同
路径必须产出同一个 key(这正是按 host 做 memo 的意义)。

变异把 match 改回 gmatch 立刻变红,并打印出实际返回的完整 URL。
2026-10-05 08:17:57 +08:00
18525ae4c0 chore(plugins): 移除分片诊断日志
SHARD_DEBUG 只在排查「18 个分片为什么还是 12 秒」时用过一次,结论是
17 个 VM 真的在并行(17 VMs live),慢的是其中一个源本身。留着它就是
给热路径留一个 os.Getenv。
2026-10-05 08:17:57 +08:00
c4b37d4322 feat(quota): 探测退避改指数 —— 死上游不再每 30 分钟重付一遍
## 为什么 30 分钟 TTL 治不了死上游

线上有两个 host 已经**不可达十个小时**,而 PROBE_MEMO_TTL 是 1800 秒。
于是每次刷新它们的负 memo 都已经过期,每次都重付三个 adapter 的超时:
每个源 24s(预算修好前)、16s(修好后)。固定 TTL 对「已经死了很久」这个
形状完全无效 —— 它只会一直重复付。

指数退避:连续失败 streak 次,等待 1800 × 2^(streak-1),封顶 6 小时。
成功会把 memo 覆盖成 vendor 字符串,streak 自然重置。

封顶不是可选项 —— 没有它,下线一周的 host 就不再被探测,页面会**永久**
把它误报成「没有余额接口」。

## 过程中判据抓到的三个真 bug

**① 记录守卫让 streak 永远是 1**
`if rep == nil and not HOST_MEMO[host]` 只在 host **第一次**失败时为真。
之后 memo 已是 table,守卫为假,什么都不写。实测连读四次都是
`{at:... streak:1}` —— 退避退化成固定 30 分钟,正是它要解决的问题。

**② `return 0` 在 Lua 里是真值**
`if streak == nil or streak <= 0 then return 0 end` —— 只有 nil 和 false
是假值。调用点写的是 `if backoffSeconds(...) then 跳过`,于是**所有没有
streak 字段的 memo 会被永久跳过**(而那正是本改动之前写的每一个 memo)。

**③ 上限路径 `return BACKOFF_MAX` 也是真值**
21600 会被判成「仍在等待」,封顶反而变成「永远不重试」—— 恰好是封顶要防的
那个故障。现在所有路径只返回布尔。

三个都是静默的:症状只是页面少了几张卡片,没有任何报错。

## 判据

五条,全部读**实际发布的 quota.lua**(go:embed 那份),不是副本:

- 退避确实被调用(变异把调用点换成 `if false` 时会红)
- 窗口表:每个 streak 的窗口长度、窗口内跳过、窗口过期后重试、
  荒谬 streak 也必须重试
- 只返回布尔(六种输入,含无 streak 字段的老 memo)
- 记录守卫会替换 memo 而非合并
- 通过真实插件:首次失败记 streak=1;**跳过不算失败**(否则一个从不被探测的
  host 会无限增长 streak,再也不会被重试)

变异自证六次全部变红:`return 0`、上限返回数字、守卫不替换、调用点去掉、
去掉上限、倍数改成 1。头一次跑「守卫不替换」漏放了 —— 因为我把集成判据缩到
只验首次记录,补了一条源码断言才抓住。

## 这个文件的判据绕了远路,如实记下

`HOST_MEMO` 是进程内缓存,只在首次 collect 时从 state 恢复,之后只往回写。
所以从 Go 侧改 state 对插件不可见 —— 我先是不自知地这么试了两轮,把一个
**正确工作的退避**看成坏的。现在窗口策略直接验函数(经 shim 导出插件自己的
backoffSeconds,不是重新实现),集成判据只留真正能从外部观察的两条。

shim 也踩过:直接把 `backoffSeconds` 当 export,而 Go 侧传的是 table,
于是 at=table、streak=nil,每次都走「无 streak」分支返回 false —— 看起来
和「退避永不触发」一模一样。包一层就对了。
2026-10-05 08:17:57 +08:00
33b778faee feat(plugins): 分片调用 —— 让阻塞型导出函数真正并行
## 问题

quota 页面 20 个源,刷新要 29 秒。页面用 `Promise.all` 并发发 20 个请求,
**看起来是并发的,实际不是**。

`Plugins.Call` 持 `p.mu.Lock()`,锁一直持有到 `L.Call` 返回 —— **包含 Lua 函数
内部的全部网络等待**。20 个请求到了网关后排队,串行累加。

生产实测:

    单源探测(无竞争)      1.4 – 3.3 s
    20 源 Promise.all       29 s 墙钟
    串行理论值              ≈ 29 s   ← 与实测吻合

代码里其实已经知道这个问题:quota.lua 第 104 行写着「the whole page shares ONE
Lua state under a write lock」,并为此把超时从 10s 压到 4s。但那只把 29s 变成
约 12s,没有解决「刷新不可用」。

而当初改成并发的理由(页面注释)是错的 —— 它假设并发能绕开串行。锁让它退化
回串行了。

## 不能去掉锁

一个 Lua VM 只有一个栈。两个并发调用会破坏栈。锁是必要的,答案只能是**让每个
并发调用有自己的 VM,跑同一份插件源码**。

先量,再写实现(8 分片 × 300ms 本地 HTTP 端点):

    共享 VM,8 次串行      2.407 s
    8 个独立 VM 并行       0.304 s    → 7.9x

顺带一个测量教训:这个 spike 第一版用**忙等**而不是网络 I/O,报出 2.3x。
那是量到了 12 核机器上的 CPU 争抢(负载 8.95),和锁无关。插件做的是网络等待,
spike 就必须做网络等待 —— 和上一条提交里 os.clock 那个错误是同一类。

## 实现

- `callOn(w, fn, args)`:从 `Call` 里抽出的「在某 VM 上调一个导出函数」。`Call`
  从 66 行缩到 18 行,仍负责查找、export 白名单和共享 VM 的锁。
- `shardPool`:私有 VM 池,上限 32。开机约 0.1ms,池化省不了什么但省两次刷新
  的重复开销。
- `ShardCall(name, fn, argsList)`:结果与输入**按位置对齐**。
- `POST /api/plugins/{name}/fanout/{fn}`:`{"args": [...]}`,上限 64 条。
- `pluginAPI.fanout()`:页面一次请求。
- quota.lua 改用 fanout。

## 分片的硬约束(写进注释,不是靠约定)

分片 VM **不能碰 PLUGIN.state** —— state 在 VM 里,每个分片一份副本,billing
的累加会各分叉,saver 写出去的是随机某一份。分片只能用于只读、幂等的工作。
所以 `callOn` 既不查 export 白名单(`Call` 那层才是安全边界)也不落盘。

## 一个分片失败不拖垮整批

一个上游连不上,不该让 20 张卡片变成一个错误。失败的分片在自己的位置留
null,其余照常返回。**但全部分片失败时报错** —— 那不是「某个上游挂了」,
是结构性问题,报成功会把它藏在满屏红卡片后面。

## 顺手修掉一个既有缺陷:热重载是坏的

写分片源码来源时,判据直接把它顶了出来:`LoadSource` 无条件 append,而
`findLocked` 返回**第一个**匹配。实测:

    LoadDir 后 1 条 → LoadSource 后 2 条
    [0] version=1   [1] version=2
    find 返回 version=1

也就是说,**编辑插件并保存看起来什么都没发生** —— 旧版本一直被返回,新版本
躺在旁边不生效。修法是先 Unload 再加载。这不是为分片做的,是分片正好照到了
这条路径,已单独补判据 `TestReloadReplacesRatherThanAppends`。

## 判据

七条:并行性(有界而非计时断言,8×200ms 串行是 1.6s,超过 1.0s 即红)、
位置对齐、未导出与未知插件被拒、单个失败不拖批、全失败报错、用已安装源码而
非磁盘、以及热重载替换而非追加。

全量 9 包通过。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-05 08:17:57 +08:00
345cbb30d6 fix(billing/quota): 探测预算用 os.clock 是 CPU 时间,12 秒预算从未生效
## 现象

线上 quota 页面点刷新后卡在「探测中…」,13 秒仍未完成,按钮一直 disabled。

## 实测根因(不是猜测,是量出来的)

单源探测,隔离实例,逐个测:

    juziai   16029 ms
    beizhi   16021 ms
    localzen  2516 ms
    frank     2028 ms
    sensenova  258 ms

而插件自己写着 `PROBE_BUDGET_MS = 12000`。**修复前**这两个源各耗 **24013 ms**
—— 精确等于 4s 超时 × 2 次重试 × 3 个 adapter。预算形同虚设。

## 原因

`exhausted()` 用的是 `os.clock()`,它返回 **CPU 时间**。探测几乎全耗在网络等待上,
CPU 几乎不动,于是预算永远不触发。

判据 `TestPluginClockDuringNetworkWait` 直接量这件事:

    3s blocked on HTTP: os.clock advanced 0.001s, os.time advanced 3s

**001 秒。** 一个用 CPU 时间写的预算,在 I/O 等待下永远不会响。

这个陷阱和「用忙等测锁竞争」是同一个,只是下沉了一层 —— 而且我第一版判据
正是这么写错的:用自旋循环当「网络等待」,结果 os.clock 走了 1.624s / 2s,
看起来 os.clock 跟的是墙钟,差点得出「不是这个原因」的错误结论。必须用真实
HTTP 请求重测才能拿到真相。

代码里两处同样的写法(第 434 和 466 行),一起改掉。

## 改完的效果

    串行 20 源(/call/ 逐个)    84391 ms
    fanout 20 源(并行)          16022 ms
    ⇒ 5.27x

单个死上游从 24s 降到 16s(12s 预算 + 收尾)。

## 还没解决的

16 秒仍然偏长,因为它等于**最慢那一个源**的预算。这不是并行失效,是并行
之后墙钟的必然下界。要再往下走只有两条路:把预算压到 4~6s,或者给已知死掉
的上游走缓存而不是每次重探。都是参数与策略选择,不是结构缺陷,留给下一步。
2026-10-05 08:17:57 +08:00
501ad6aac9 refactor(plugins): 统一授权词汇 —— AccessRule
## 问题

同一个问题「谁能看到 / 能调用什么」有两套规则、相反的默认值,并排住在两个文件里:

    audienceAllows(want, role):   "" / "all" → 全开      页面可见性
    HTTPHandler.allows(role,key): ""         → 仅 admin   注册端点

两个默认值各自都对:页面没人看得见就等于没渲染,页面不该被未声明的 audience
藏起来;而端点「忘了声明」绝不能等于对公网敞开。

危害不在默认值本身,在于**同一个问题有两套词汇**。运维配「谁能看 billing」和
「谁能调 /balance」得先学会两套规则,才能回答同一个问题。

## 改动

新增 internal/lua/plugin_access.go,把两套规则收敛成一个 AccessRule,
并把**仍然不同的那一点——默认值——写成数据而不是留成隐含**:

- AllowsCapability:空 = 拒绝(端点、函数)
- AllowsVisibility:空 = 继承(页面、元素)

语法也统一成一种,`ParseAccessRule` / `Rule` 双向:

    "admin" / "admin, user" / "@alice, @bob" / "user, @alice" / "public"

`@name` 是密钥、其余是角色,一��字符串表达得完规则编辑器要提供的全部选项。

## 抽取时差点弄丢一个真实语义

`"all"` 是既有插件文件和**已保存的运维 override** 里的写法。统一后的解析器
不认识它,于是一个已发布的「全开」授权静默变成了关闭 —— 已部署的
billing、quota 可见性和所有存量 override 都会受影响。

是既有的 TestAudienceAllows 抓到的,它当场变红。这正是抽取的实质收益:
两个函数并排住着的时候,没人会想到去动那个共用字符串。

`TestAllIsAnAliasForPublic` 把它写成新类型的性质,而不是留给碰巧跑到的那
个套件去守着。

## 顺手加的东西我又撤了

accessRuleFor 第一版把 roles 里写成 `@alice` 的项重分类成密钥,理由是
「这大概是个自然的笔误」。那是在重构里偷带行为变更:会把写了莫名其妙内容的
端点的可达范围悄悄放宽。已撤,注释里写明为什么这个笔误值得修是另一件事、
需要它自己的理由和判据。

## 判据

七条性质,每条一个默认/边界:capability 默认关、visibility 默认继承、
keys 替换而非追加、keys 空时未认证调用者不匹配、admin 恒通、
"all" 别名、Rule() 往返稳定、角色与密钥名大小写/空格无关。

变异自证四次,全部变红:keys 变 OR、capability 默认全开、visibility 默认
拒绝、丢掉 "all" 别名。

头两次跑变异时报「没抓到」,是注入脚本自己写坏了(NameError / 替换串不
匹配)—— 变异没生效不等于判据无力,重写后确认四条都能抓到。

## 一条被记录下来的意外

`TestRoleAndKeyUnionAdmitsBoth` 最初断言「同时列出 role 和 key 时两者都放行」。
debug 跑出来是相反的:keys 非空就整体替换掉 role,carol 被拒。

这个意外正是不把它放在注释里的理由。判据现在断言实际语义(替换),与既有
HTTPHandler.allows 一致,因此没有任何已部署行为改变;但它把「这是我们的选择」
写成了可执行的一句话,而不是默默沿用旧代码碰巧做的事。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-05 08:17:57 +08:00
39cda89def refactor(plugins): 声明式解码器 —— 未识别的字段不再静默丢弃
## 问题

三个读取函数(readPluginUI / readPluginExports / readPluginHTTP)各自手写一遍
「取字段、转换、追加」,字段名在 .lua 和 Go 里各存一份。加一个字段要改两处,
**漏了 Go 那一半不会报错**,字段静默读成未设置:

    ui = { visibilty = "admin" }   -- 一个字母之差
    ⇒ 什么都没限制,且与「插件主动不声明 audience」完全无法区分

今天一天里因此丢了三个字段(exports、http 端点、visibility/state_scope),
每次都是靠「量服务端返回的长度对比磁盘文件」反推出来的。

## 改动

新增 internal/lua/plugin_decode.go:schema 是数据,不是反射也不是 struct tag。
字段清单在一个地方,插件作者读一张表就知道网关认什么;不认识的字段**上报而非
丢弃**,并给出「你是不是想写 visibility」这种可执行的提示。

readPluginUI 2270 → 860 字符,readPluginExports 732 → 392。顺手去掉了
readPluginUI 被调用两次、其中第二次对已编译的 depth map 再编译一遍。

## 顺带修掉一个真 bug:handler 必须是名字而非函数值

`asString` 对任何非字符串都 `fmt.Sprintf("%v")`,所以写成
`handler = function(req) ... end` 会变成字符串 "function: 0x7f...",被当成
handler 名接受,端点注册成功,然后在**派发时**失败 —— 离声明处十万八千里。
现在在解码时就拒绝,并同时报出字段名和 path。

## 更正一个我写了但从未核实的说法

之前那批修复的注释里(三个文件 + 提交信息)都写着「插件注册发生在 setupGlobals
安装 json 全局之前,所以 luaToJSON 返回空」。**这个说法是错的**,实测:

    setupGlobals 在 vm.go:574,读取函数在 787,顺序相反
    json global present when readPlugin* runs: true
    luaToJSON 在同一个栈形态上解码正确,返回了完整的 visibility 和 pages

真正的差异是**索引语义**:luaTableToGoValue 会把负索引立即折算成绝对索引
(GetTop()+idx+1),luaToJSON 则保留相对索引并在 Call 前 push。栈一旦被
SetTop(0) 清掉,两者行为分道扬镳。

一个错误解释重复三次比没有解释更糟 —— 它会把下一个读代码的人送去寻找一个
不存在的时序 bug。三处注释都已改成实测结论,并说明这个错误是怎么被发现的。

## 判据

- 拼错的字段被上报、给出 did-you-mean,且该 audience 确实没被应用
- 正确声明零诊断(否则会被静音,然后什么也抓不到)
- 内联函数形式的 handler 被拒并说明原因;`handler = "name"` 形式不受影响
- decode 边界单独一条:走真实注册路径加载插件,断言声明的值都读回来了

变异自证:strictKeys 短路 → 变红。

已知局限(写在判据文件里):把 luaTableToGoValue 换回 luaToJSON 的变异目前
**抓不住** —— 已实测那两条路径在当前栈形态下等价。要让它可检出,需要一条
在 json 全局缺席时走注册路径的判据;现在这条是靠 review 和注释保证的,
不假装它是变异可证的。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-05 08:17:57 +08:00
700f4179fa chore(gui): 版本号 1.9.3 → 1.9.4
(cherry picked from commit 37545b6dbb)
2026-10-04 14:50:47 +08:00
91e3013cb5 feat(billing): 长上下文阶梯定价(边际费率,与缓存折扣/峰段可叠加)
## 影响面实测

审计里 1000 条请求:**883 条(88.3%)prompt 超过 128k**,中位数 246849,
p90 298179,最大 354251。这不是小众场景,是 agent 负载的主流形态。

按「模型 × 源」拆开:其中真正按 token 计价的只有 alittokenplan 的 10 次
(deepseek-v4.1-flash,约 350k prompt),其余走 $70 套餐(commandcode)或
免费源(localzen),记 0 是正确的,对阶梯无影响。

那 10 次当前少算 54.9%($0.3148 → $0.4877)。绝对值不大,但比例严重,
而且这个偏差只会随上下文变长而变大。

## 边际而非混合平均

阶梯只对**超过阈值的部分**重新计价,不是把整段按高价算。理由是可叠加性:
缓存读取同时享受缓存折扣**和**长上下文阶梯价,这与供应商的计费方式一致。
混合平均必须二选一,选错就正好低估「缓存命中的长请求」——而这恰是缓存
存在的意义所在(本部署缓存命中率 88%)。

超出部分按缓存/新鲜的比例拆分,不重不漏。

## DSL 两种写法

供应商两种发布方式都支持:绝对费率(OpenAI 报 \$3.00/M past 200k)和
倍数(DeepSeek 报 2x past 128k)。两者都缺必须报错 —— 一个没有费率的阶梯
会编译成一条「看起来处理了长上下文、实际没处理」的规则,静默少算且无迹可寻。
两者都给时以绝对费率为准,因为那是供应商真正报出的数字。

## 判据

Lua 侧两条(边际性、与缓存折扣叠加)+ Go 侧一条(两种写法与拒绝规则)。
变异自证三次:整段按高价 / 缓存部分不叠加折扣 / 缓存部分不计阶梯,
分别命中 0.40 vs 0.272、0.165 vs 0.2325、0.09 vs 0.2325。

规则编辑器加了三个输入(阈值 + 绝对费率 + 倍数),三项都留空 = 无阶梯 ——
与「阶梯费率为 0」刻意区分,所以是三个独立输入而不是从 0 推断是否存在阶梯。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit 314f4da8f2)
2026-10-04 14:50:47 +08:00
92b81ecfd3 feat(billing): reasoning 价接入 DSL 与规则编辑器
上一提交把链路打通后,reasoning_price 只能靠手改 prices 生效 —— 而 prices 是
编译产物,不是人能编辑的东西。这条补上让它真正可用:

- BillingToken 加 reasoning 字段(USD/百万,与 prompt/completion 同单位)
- compile 只在规则显式声明时写 reasoning_price,缺省时字段不出现
- 规则编辑器的模型行加「思考 $/M」输入框,含新增行的模板

缺省时「不出现」是刻意的:写 0 会被 billing 插件读成「思考免费」,
每个来自该模型的请求都会静默少算。判据专门钉住这一点,变异成「缺省写 0」
即变红。

输入框 placeholder 写明「留空=按输出价计(OpenAI/DeepSeek/Anthropic 都这样)」,
因为一个没有解释的空输入框会被当成「必填但我不知道填什么」。

判据还要求 m-r 出现两次 —— 只改已有行的模板而不改「+ 模型」新建的行,
会让新加的模型在下一次保存时静默丢掉这个价。

生产验证:PUT 一个 reasoning: "1.80" 的规则,编译产物里出现
reasoning_price: 1.8000000000000001e-06(USD/token)。测完已把生产配置恢复为
6 条规则(备份 config.yaml.bak-billing-20261004-122151 为基准重建,
20 个源数量核对一致)。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit b93799a919)
2026-10-04 14:50:47 +08:00
d119d09929 feat(billing): 记录 reasoning token,并支持按规则单独定价(默认不启用)
## 断掉的链路

types.go 里 ReasoningTokens 从被定义起就没人读过。适配器层早就解析并透传了
(opencodego / gemini / anthropic 都写了那段提取),网关在 chat.go 记录 usage
时读了 PromptTokensDetails.CachedTokens,却在隔壁的
CompletionTokensDetails.ReasoningTokens 上把它丢了 —— 数字到了响应里,
在唯一能留住它的地方被扔掉。

后果:任何计费规则都无法区分「思考」和「可见输出」。

## 为什么默认不启用定价

实测四个源(deepseek-v4.1-flash / claude-opus-4-8 / glm-5.3-flash /
space-bunny-free,含 anthropic thinking opt-in)全部不返回该字段。所以
reasoning_price 默认为 nil,costFor 完全跳过该分支,标准公式与改动前逐位相同
—— 这是关键:现有账单不能因为这次改动而变动。

启用时只应用「差额」:把思考部分从普通输出价重定价到思考价。完整再收一次会
对每个思考 token 双重计费,这正是判据里最容易被写出的错误实现。

clamp 到 completion_tokens:供应商报出比 completion 还大的 reasoning 是畸形
数据,不 clamp 会得到 92 倍于整个请求的费用,而且这个数字看起来足够合理,
能在仪表盘上活下来。

判据三条 + 三次变异自证(移除 clamp / 双重计费 / 默认启用)全部变红。

## 遗留

reasoning_price 未接入 BillingRule DSL —— 目前只能通过直接写 prices 生效。
DSL 字段要连带改 config 结构、编译器和规则编辑器 UI,是独立一步。
实测全部源都不返回该字段,所以这条暂时没有实际影响面。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit c36200b78b)
2026-10-04 14:50:47 +08:00
27b908d98c fix(billing): 未定价按天统计,让「已修好」和「还在漏」不再长得一样
计费页只有一个终身计数器 unpriced_reqs,实测 6378 —— 占 18981 请求的 34%。
但这个数字回答不了运维唯一会问的问题:「还在漏吗?」

原因是它把两件事混在一起:加规则之前的历史欠账,和加规则之后仍在发生的漏计。
修好一个源之后终身计数纹丝不动,于是仪表盘上「未定价 6378」和一个仍在
什么都没记的系统完全无法区分。

改动:
- 钩子按 UTC 天写入 unpriced_by_day{day:{reqs, models}}。终身计数保留
  (报表不该改写历史),天桶是新增的观察维度。
- 页面加「未定价(近 7 天)」一行。为 0 时额外显示「近 7 天无」——这时候
  零本身就是结论:加的规则确实生效了。
- 没有天桶数据时返回 null 而不是 0。旧的 state 文件说「未知」,
  不该假装有一份干净记录。

生产实测:全部视图 终身 6378 / 近 7 天 2;日视图隐藏终身计数(null)但保留
近 7 天 —— 周期视图原本把两者都置 null,因为那时根本没有天桶。

失败的请求同样计入(上游 502 的 gpt-5.5 也进了天桶):一次失败的请求照样
消耗上游配额,只是没拿到 token,单看 token 维度会漏掉。

判据:两个不同日期的请求必须落到两个天桶且终身计数不重复累加;变异成
「总记今天」即变红(只判首日会漏掉这一点)。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit ba6de89d57)
2026-10-04 14:50:46 +08:00
ba10f03fc7 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>
(cherry picked from commit 120ba9e957)
(cherry picked from commit 3b28fde22e)
2026-10-04 14:50:46 +08:00
cab3361073 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>
(cherry picked from commit c17a851ec1)
(cherry picked from commit 9823773330)
2026-10-04 14:50:46 +08:00
35b9422232 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 层过滤意味着其它消费者照旧全量泄漏。

角色可见性回答不了"哪一部分"。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 正是这次要堵的泄露。

有些东西值得从浏览器**外面**查(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>
(cherry picked from commit a6c6e6e5c7)
(cherry picked from commit 5eef5ad635)
2026-10-04 14:50:46 +08:00
2b34c7dc8a 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>
(cherry picked from commit 098a4a5eb4)
(cherry picked from commit 40636abcc2)
2026-10-04 14:50:46 +08:00
85c81c63cc 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>
(cherry picked from commit 111dc6d53f)
(cherry picked from commit 22c9215b02)
2026-10-04 14:50:46 +08:00
a2bb6de9fb 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>
(cherry picked from commit 0fbb8e5e88)
(cherry picked from commit e76e1623e4)
2026-10-04 14:50:46 +08:00
0bf0653d00 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:36 +08:00
48e7e6b2a5 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 23:06:42 +08:00
a21d0ca5a1 chore(gui): 版本号 1.9.2 → 1.9.3
v1.9.3 收一处误导性错误与编辑器提示:
- AUTO 被密钥模型范围拦下时返回 model_not_found(像说 AUTO 没配置),
  改为 model_not_allowed 并说明修法
- per-key AUTO 链编辑器在该密钥范围不含 AUTO 时给出警告

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 21:50:20 +08:00
cf14f66ad4 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:23:02 +08:00
4e3b905b58 chore(gui): 版本号 1.9.1 → 1.9.2
v1.9.2 收两处 UI 修复:
- 统计区间文案在日视图恒为空(bucket 是小时粒度,盲目拼 T00:00:00Z 使
  toISOString 抛 RangeError,整句赋值丢失),且天数按小时桶数误报
- per-key AUTO 链默认复制全局链作为编辑起点,而不是空白画布

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 13:36:11 +08:00
130b3f2e8d 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:36:11 +08:00
dac0b4ce93 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 13:36:11 +08:00
97864895d4 chore(gui): 版本号 1.9.0 → 1.9.1
v1.9.1 收 v1.9.0 之后发现并修复的四个 UI/统计缺陷:

- per-key 编辑器画布画进后台标签页的全局画布,弹窗空白且污染全局链
- per-key 保存成功却不关弹窗,toast 文案还把已保存说成会失败
- 首页日/周/月视图请求记录表恒为空,滚动加载新旧行错位
- per-key 弹窗复用共享 #modal-wrap id、编辑即落盘(取消撤不回)、
  无独立链时画布填满全部 226 个模型

v1.9.0 的 tag 与附件保持不动,作为发布时点的凭据。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 11:48:01 +08:00
f9cd0770a9 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:20:29 +08:00
6311913812 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 10:32:51 +08:00
aa003e84d3 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 10:32:51 +08:00
28149bdd1a merge: release/v1.7.x → main(per-key AUTO 链 + GUI 插件管理 + UI 复用)
- 22decf2 feat(keys): 每个密钥可配独立 AUTO 链,管理员代配
- c66e1b5 test(keys): per-key AUTO 链的并发与压力验证
- 9dc2b64 chore(gui): 版本号 1.8.0 → 1.9.0
- 13e2397 fix(ui): per-key 链复用全局 AUTO 画布;统计周期显示实际窗口
- ed9baae feat(gui): 插件面板补齐安装/编辑/删除,on_disk 报告页面

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:50:55 +08:00
ed9baae8e4 feat(gui): 插件面板补齐安装/编辑/删除,on_disk 报告页面
桌面版插件面板此前只有启用/禁用,装不了新插件、读不回源码、删不掉坏的
插件——而 WebUI 有完整能力。两边不一致,且缺失的那三个恰好是出问题时唯一
能用的三个操作。

补齐(复用后端已有契约,未新增端点):
- POST /api/plugins 安装并保存源码。保存走安装路径是有意的:它会校验
  名称、编译并热重载,而 PUT /{name} 只切 enabled 开关,指过去会静默
  什么都没存。
- GET /api/plugins/{name} 读源码(读的是磁盘上的源,与写回可往返)。
- DELETE /api/plugins/{name} 删除。内置插件不显示删除按钮。
- 内置插件的"编辑"改为"查看源码"并置只读:它的文件在二进制里,给一个
  保存后被丢弃的文本框比直接说明更糟。
- 三个弹窗复用既有的 overlay / overlay-card,不新造一套弹窗体系。

后端补 DiskEntry.Pages:GUI 读的是 on_disk 而非 List,pages 只在 List
里,所以面板里那行"页面:…"永远不显示。新增 pageNames() 复刻加载期的
合并规则(page + pages,去重保序)。判据覆盖 pages 与单页 ui.page 两种
写法,并断言 JSON 里带 pages——GUI 读的是 HTTP body,不是 Go 结构体。
变异去掉赋值后两条判据都失败。

CDP 实测 10 项全部通过:安装带页面的插件、源码往返一致、禁用/启用、
热重载后版本号变化、坏插件在列表可见并带错误信息、非法名拦截、语法错误
被后端拒绝而非静默写入、路径穿越被 IPC 代理拦截。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:48:46 +08:00
13e2397e9c fix(ui): per-key 链复用全局 AUTO 画布;统计周期显示实际窗口
两件事。

一、per-key AUTO 链的 UI 之前自己写了一套表格,只有 model + tier 两列,
比全局 AUTO 编辑器弱:没有拖拽排序、没有每槽位的 quota/period/hours。
这是重复实现且必然漂移。改为复用同一套泳道画布:

- 抽出 buildSortIndex / buildLanes / lanesToRules / renderSortEditor,
  全局编辑器与 per-key 编辑器共用。
- sortState.scope 决定保存目标(null=全局,key=该密钥),persistAuto
  按此分支;空 lane 列表即"恢复继承",与后端空 PUT 等价。
- 画布查找走 sortCanvasEl(),两个 id 都能解析,否则拖拽在弹窗里静默失效。
- keyAutoClose 必须清 scope,否则关掉弹窗后全局编辑器会存到这个 key。
- 删掉旧的 keyAutoRow/keyAutoAddRow/keyAutoClear。

判据改为断言"复用"而非断言具体实现,并新增两条:
- 关闭弹窗不清 scope(全局编辑器会存错目标)
- per-key 编辑器不再挂载共享画布

二、"周视图总量大于月视图"不是 bug:日历周期并非嵌套。"本周"从周一开始,
所以月初会回溯到上月末几天,本周总量合法地大于本月。实测今天 2026-10-03
(周六):周窗口 09-28→10-03(6 天,9.2B tokens),月窗口 10-01→10-03
(3 天,4.0B tokens)。

真正的缺陷是看不见这一点。统计页新增"统计区间:YYYY-MM-DD → YYYY-MM-DD
(Nd)",让周 > 月 自解释。判据钉住这个事实,并做变异验证:把周窗口钳制到
月内(一个看起来很自然的"修复")会被两条判据同时抓住。

CDP 实测:周视图显示"2026-09-28 → 2026-10-03 (6d)",月视图显示
"2026-10-01 → 2026-10-03 (3d)";per-key 编辑器渲染出 36 条泳道 / 226 个
块,与全局编辑器同款。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:26:57 +08:00
0cd1cd51f6 merge: release/v1.7.x → main(per-key AUTO 链 + 部署 v1.9.0)
- 22decf2 feat(keys): 每个密钥可配独立 AUTO 链,管理员代配
- c66e1b5 test(keys): per-key AUTO 链的并发与压力验证
- 9dc2b64 chore(gui): 版本号 1.8.0 → 1.9.0

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:15:00 +08:00
9dc2b64013 chore(gui): 版本号 1.8.0 → 1.9.0
per-key AUTO 链(22decf2)与并发判据(c66e1b5)是 v1.8.0 发布后的新特性,
v1.8.0 的 tag 已存在,不能复用。package-lock.json 里剩下的一处 1.8.0 是
@electron/fuses 依赖自身的版本,不应改动。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:08:37 +08:00
c66e1b5955 test(keys): per-key AUTO 链的并发与压力验证
AutoChainFor 在请求热路径上无锁读 keyAutoChains,而 SaveKeyAuto 与
rebuildRegistry 会整表替换它。顺序调用测不到任何交错,所以补并发判据。

- 8 读者 + 2 写者并发改链,断言读到的链必属于 {自身链, 全局链} 这个合法
  集合,不断言固定值(写者本来就在合法地来回切换,钉死值会在正确代码上
  失败)。
- 源增删期间读者保持可用链。churn 源用 "churn" 前缀,避免与链依赖的 s1
  重名——删掉链引用的源会合法地清空该链,拿它当断言等于测虚构。
- 500 个 key 的查表不退化。

变异验证:把 rebuildKeyAutoChains 改成原地清空重填(破坏原子替换),
-race 报 DATA RACE,判据有效。

真实压力(20 key 网关 + 并发 AUTO + 后台持续改链):
- 40 并发 25s:9616 请求 / 382 QPS / 零 panic / 零 DATA RACE
- 出现 111 次 503,根因是 mock 上游被打爆后 "no free slot within 2s",
  属预期降级而非缺陷:并发降到 6 时 1395/1395 全 200。
- 每次压测都断言"未改链的用户始终命中自己该走的模型",未改链的 19 个
  key 全程零错分。
2026-10-03 08:01:37 +08:00
22decf2bd3 feat(keys): 每个密钥可配独立 AUTO 链,管理员代配
此前 AUTO 链是全局单值(cfg.Auto + Core.AutoChain()),所有用户共用一条链。
管理员无法为某个用户单独指定调度链。

按 per-key 覆盖 + 全局兜底实现:

- config.GWKey 增加 Auto 字段与 HasOwnAuto()。未配置即继承全局链,
  存量部署零改动,新密钥天然继承全局链。
- Core 把 buildAutoChain 的归一化逻辑抽成 chainForRules,全局链、
  生图链、per-key 链共用同一套编译,避免两处漂移。
- Core 增加 keyAutoChains 缓存 + AutoChainFor(key)。请求路径读缓存不
  加锁,与全局链查询一致。缓存整表原子替换,不会看到半成品。
- 请求侧 chat.go 改用 AutoChainFor(reqKey)。冷却仍在 Provider 上按
  model+source 共享:两条链指向同一个 slot 时共用冷却,与今天单链行为
  相同,也避免为 per-key 维度重构冷却而改变现有可观测语义。
- API:GET/PUT/DELETE /api/keys/{key}/auto(admin),GET
  /api/keys/me/auto(任意角色,只能读自己的)。空 PUT 与 DELETE 等价于
  "恢复继承",无法持久化一条会 503 的空链。写入时回报解析出的槽位数,
  让管理员当场看到模型名写错,而不是等用户下次请求 503。
- 源变更时一并重编译 per-key 链,加源后无需重启即可生效。

判据 18 条,5 个变异全部被抓住:AutoChainFor 忽略 key、清空后不重建
缓存、源变更不重建、空 PUT 落盘成空链、me/auto 误要求 admin。

UI 判据做变异时发现漏放:只查函数定义存在,删掉按钮后仍通过。已改为
断言 keyCanvasHtml 内的调用点。

端到端实测(真实 HTTP + 两个 mock 上游):admin 与 bob 初始同为 m-fast,
给 bob 配 m-cheap 后两者分流,重启后仍分流,DELETE 后 bob 回到 m-fast。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 07:45:54 +08:00
4b07be67b4 Merge commit 'ba01da9' into HEAD 2026-10-03 00:16:21 +08:00
913974bb6c Merge commit 'c8edea2' into HEAD 2026-10-02 20:16:18 +08:00
ffb2b8f2f2 Merge release/v1.7.x into main for v1.8.0
main had fallen a full feature generation behind: 13739 lines and 29 files
that main did not contain at all (the whole Lua plugin mechanism, the billing
package, stats_period, plugins_api, docs/plugins.md). v1.7.x contains
everything main has and nothing main lacks, so this is a one-way merge rather
than two divergent lines.

Eight conflicts, each read before resolving — the script that does it lives at
.probe/resolve_merge_conflicts.py and aborts rather than guess:

* cmd/gui/package.json, cmd/gui/package-lock.json — main already carries 1.8.0
  (4b37e1a); the release line carries 1.7.6. Kept 1.8.0. Taking 1.7.6 would
  ship tag v1.8.0 next to a manifest claiming 1.7.6.
* packaging/config.example.yaml (2 hunks) — release side is a superset both
  times: it documents the `auto:` field, and it replaces the older one-line
  runtime_file note with a paragraph that also states where templates and
  deleted-markers live and warns that the AUTO chain, gateway keys and sources
  are in config.yaml. HEAD's wording is strictly less.
* internal/config/config.go, internal/core/core.go, internal/gateway/api.go,
  internal/gateway/apiv1.go, internal/gateway/stats.go (6 hunks) — HEAD is
  zero-length in every one and the release side is the new code: BillingDSL
  types, applyBillingDSL, the pointer-semantics source upsert, the
  optional_fields doc string, the AUTO chain-walk field. The resolver asserts
  the empty-HEAD property instead of assuming it.

Verified before committing: no residual conflict markers, both manifests read
1.8.0, `go build -tags luajit ./...` clean, `go test -tags luajit ./...` all nine
packages pass.
2026-10-02 19:38:42 +08:00
1fe379f630 fix(gui): 缓存解封结果,否则每个请求都要 spawn 一个进程
The auth rule calls readAdminKey() on every outbound request so injection
never depends on ordering. Once the sealed-config path shells out to the
core, that turns each request into a process spawn: 200 simulated requests
took 1012ms and launched 200 cores.

Cache the unsealed key against config.yaml's mtime. Editing the config still
invalidates it, which is what the auth rule actually needs -- the port
rewrite, the first write, and a user edit all change mtime. Measured: 200
requests now cost 10ms and one spawn.

Only a successful unseal is cached. Caching a failure would pin an empty key
until the config next changes, turning a momentary spawn error into a locked
out user.
2026-10-01 19:23:20 +08:00
b03b12148f fix(gui): 桌面版被自己的密钥封存挡住登录
The desktop build authenticates the embedded core by reading the admin key
out of config.yaml with a regex and injecting it as a gw_key cookie. The core
seals credentials at rest (enc:v1:...), so from the second start onward that
regex yields ciphertext, the cookie is worthless, and the app asks the user for
a key they never set. The key is generated and hidden by the app itself.

Reproduced end to end: first start writes a plaintext profile, the core seals
it, every later start reads back "enc:v1:..." and falls through to the login
prompt.

- when the stored value is sealed, ask the core to unseal it via
  -show-secrets, which only reads, prints and exits. Reimplementing the core's
  AEAD in JS would be a second source of truth for its key format.
- cwd must be the profile dir. The core locates master.key relative to the
  config's runtime_file, so a call made from anywhere else has it generate a
  second master key in the CWD and then fail to decrypt ("master key changed?").
  Electron's CWD is not the profile dir, so without this the desktop build
  cannot read its own key even after unsealing is wired up.
- the loose regex is kept as a fallback so a future change to the -show-secrets
  output degrades to a login prompt rather than to a wrong credential.

Verified: plaintext start -> core seals -> restart recovers the same key, with
the core running the whole time. Dropping cwd:PROFILE_DIR makes the unseal fail
and leaves a stray master.key in the CWD, so the cwd argument is load-bearing
rather than tidiness.
2026-10-01 19:19:40 +08:00
a1d4365515 fix(sources): implement PUT and stop partial edits from clobbering api_key
Two defects on the admin source write path, both found while adding a model
to a live source by hand.

PUT /api/sources/{name} was advertised in the API index but never
implemented — handleSourcesAPI only switched on GET/POST/DELETE, so the
documented update verb answered 405 while the POST upsert behind it worked.

POST is an upsert that replaces the whole source, so a partial edit that did
not carry api_key persisted an empty or placeholder credential. The source
kept its name, base_url and models, the write returned 200, and the source
then answered 401 on the next request — long after the writing script exited
0. The WebUI had been routing around this by loading the real key through
?reveal=credentials; any script or partial update went straight into it.

- implement PUT, taking the name from the path and rejecting a body name
  that disagrees rather than silently resolving to one of them
- inherit the stored credential when api_key is omitted or sent as the
  literal "__KEEP__"; an explicit new key still rotates
- an empty api_key on a source that does not exist yet stays empty, since
  credential-less local upstreams are legitimate
- add model_ids, an additive shorthand, so "add these models" never has to
  read and echo the existing list back
- align the API index with the implementation

The model_ids merge had a first cut that dropped the existing list when the
request carried no models field; TestSourceModelIDsIsAdditive caught it.

Verified by mutation: removing PUT turns three tests red, flattening
resolveAPIKey into a pass-through turns TestSourceUpsertKeepsAPIKey red
on both subtests, and making model_ids replace instead of merge turns
TestSourceModelIDsIsAdditive red.
2026-10-01 18:15:33 +08:00
3548745f98 fix(startup): 密钥警告改读真实生效的 key 集合
启动时那条「gateway_keys is EMPTY — without a key every request is rejected」
读的是 legacy 的 cfg.GatewayKeys 段,而鉴权实际用 cfg.Keys(core.ListKeys)。
seedKeys 首次启动把 gateway_keys 搬进 keys[] 之后,YAML 里那个列表就不再
被鉴权使用。于是在它被清空(例如轮换掉 starter key 之后)而 keys[] 仍有
7 把可用 key(含 admin)时,进程每次启动都谎报「所有请求都会被拒绝」。

实测:生产日志出现该警告,而同一个 key 请求 /v1/models 返回 200。

- main.go 改为检查 c.ListKeys(),文案改成不绑定字段名。
- 顺带删掉 gateway.New 的 gatewayKeys 参数:函数体从未使用它,
  只读 ListKeys(),留着会继续诱导人以为鉴权来自那个列表。

判据:e2e/TestStartupWarningReflectsRealKeysNotLegacyList —— 构造
「gateway_keys 空 + keys[] 有 key」的真实形态,先断言该 key 确实能鉴权,
再断言日志里不再出现那句谎报。变异验证:回退成 GatewayKeys() 即变红。
2026-09-28 23:43:20 +08:00
3fe0df5bf2 fix(packaging): 发行包不再内置可用的 admin key
打包时把本地 config.yaml(gitignored,含运维真实密钥)原样复制成
config.example.yaml,而 postinst 在首次安装且 /etc 无配置时又把它
cp 成生产配置 ⇒ 每次安装都得到一个同值的、公开已知的 admin key。
实测该 key(sk-gw-local-0001)在生产上真实有效(/v1/models 返回 200,
而网关监听 0.0.0.0)。

三处修正:
- 新增 packaging/config.example.yaml(受 git 跟踪的净化模板),
  gateway_keys 留空、sources 留空,并写明不要填死值。
- core-dist.sh / nfpm.yaml 改为打包该模板,不再碰本地 config.yaml。
- postinst.sh 不再投递示例配置:留空文件会让网关启动但拒绝所有请求
  (无门可入)。改为让二进制首启时自行生成随机 admin key 并打印 ——
  每次安装都不同,且开箱可用。示例文件仅作为 /usr/share 下的参考保留。

实测首启:生成 sk-gw-838d66a1... 并打印,与旧的共享固定值不同。
2026-09-28 23:28:20 +08:00
8ca59b4bb2 fix(core): 修复启动重复播种 admin key + 配置封存非幂等
根因是 unseal 时序:NewFromConfig 把解密放在最后,而之前几步已经在读凭据。

1. seedKeys 重复播种(生产已累积 4 个同名 admin key)
   seedKeys 用 cfg.Keys[i].Key 与明文 gateway_keys 比对去重,但此时内存里的
   key 还是密文 enc:v1:…,比对永不命中 ⇒ 每次重启追加一个同值 admin key。
   实测:core.New(path) 连续重启,seeded key 数 2→3→4 递增。
   (旧测试用 NewFromConfig 构造全新内存对象,没有「盘上已有密文」这个前提,
    复现不出 —— 必须走 core.New 这条读盘的生产路径。)

2. 启动恒重写 config.yaml
   migratePlaintextSecrets 按内存状态判断,而 Save() 末尾会把内存恢复为明文,
   于是每次调用都判定「还有明文」并重写;注释却自称幂等。
   改为 UnsealSecrets 在解密前记录「盘上是否明文」,SealIfNeeded 据此决定
   是否写回 ⇒ 已封存的配置启动不再落盘。

原测试 TestMigratePlaintextSecretsIsIdempotent 用 ModTime 比较,两次写落在同一
时间戳刻度内就看不出来,所以表现为 ~1/6 概率的 flake 而非稳定失败。已改为比较
文件内容并走真实启动路径(UnsealSecrets + SealIfNeeded),并顺带消除该 flake。

附带更正:先前判断「rebuildRegistry 也会拿到密文 API key」不成立 ——
mergedSources → resolveSourceKey 对每个 source 独立解密(belt-and-braces),
provider 始终拿到明文。unseal 前置仍予保留,以消除对该兜底路径的隐性依赖、
并让 seedKeys 在明文下比较。

判据:
- TestRestartDoesNotDuplicateSeededKeys(敏感:回退顺序必红)
- TestSealingIsIdempotentAcrossStarts(12/12 稳定,原先 1/6 flake)
- TestProvidersGetPlaintextCredentials(钉 provider 必须拿到明文这一不变量)
2026-09-28 22:53:33 +08:00
980408d045 fix(tokens): 流式统计改用上游真实 usage,图片不再记 token
两处 token 单位错误,均影响 per-model 配额计费:

1. 流式路径的 prompt/completion 只是「字节÷3」估算。
   pumpStream 明明收到了上游最后一帧的真实 usage,却只发给客户端、
   从不回写审计记录,于是配额按估算值扣。生产实测同一请求:
   上游 prompt=37/completion=179 → 记账 27/262,prompt 低估 1.4x、
   completion 高估 1.5x(双向失真)。同模型流式 completion 中位数
   是非流式的 4-27 倍。非流式路径本就用真实值,两路不一致。
   修法:lastUsage 非零时写回 rec.Prompt/rec.Compl,估算降为兜底
   (上游不报 usage 时仍保留原估算行为)。

2. 图片请求把「图片张数」记成 completion_tokens。
   rec.Compl = int64(len(resp.ImageData)),len 是切片长度即张数
   (生产 38 条 image 记录全是 1),且被计入 token 总量。
   图片生成无 token 概念 ⇒ 新增 Req.ImageCount 独立字段,
   Prompt/Compl 归 0;UI 记录表 image 行改显示张数(新增 i18n thImgs)。

顺带补 TestUILocaleKeyParity:此前无人校验 zh/en 键集合一致,
单边加键不会报错,只会显示原始键名。

新增 token_units_test.go(定值上游 6 项),做过变异验证:
回退修复实测复现 stream=16/173 vs chat=44/100、image completion=3。
2026-09-28 22:20:14 +08:00
4b37e1a0db chore(version): 1.7.1 -> 1.8.0
main 的版本号此前停在 1.4.2(v1.6.0 回流时按纪律未带入 bump),
落后于已发布的 v1.7.1。按 SemVer,main 指向下一个未发布的中版本。
2026-09-27 19:08:37 +08:00
ebe60028f5 refactor(quota): 配额改为按模型,删除整钥总配额
用户明确要求:配额应当是密钥对应的**每个模型的单独配额**,而非整体配额。

## 语义变更

删除 GWKey.TokenQuota / ReqQuota / Period / Hours(整钥总额)。
ModelScope 新增 ReqQuota —— 请求数配额下沉到每条模型范围。

现在:每条 models[] 各自带 token 配额 + 请求数配额 + 重置周期,
彼此独立。一个模型用满只影响该模型。

★ 为什么不保留整钥总额:它会让「把 A 模型的额度挪给 B」变成一次全局
重分配;按模型独立计费则每个模型各自可控,运维能直接看出哪个模型在吃预算。

## 连带改动

- checkQuota 合并 key 级与 scope 级判定;checkKeyQuotaRetry 整体删除
  (顺带修掉上轮遗留的双重判定:入口不再先判空再重算)
- core:CreateKeyWithQuota / UpdateKeyWithQuota / ApplyQuota 全部删除,
  改由 ValidateScopeQuotas 校验每条 scope 的配额
- admin key:scope 上的配额不强制(admin 的 scope 仍限制模型范围,
  但不强制配额)—— 否则管理员会把自己锁在门外
- /api/v1/keys 不再回显 key 级配额字段(scope 里已含)
- WebUI:删除整钥配额徽标 / 「配额」按钮 / 创建表单的配额组 /
  putScope 的整钥回传;模型砖块与范围编辑器新增「请求数配额」输入,
  徽标显示 `1.0K 77×·1h`(未设配额显示 ∞)

## 判据

- TestOneModelsQuotaDoesNotBlockAnother 是本次核心保证。
  ★ 它第一版是**假判据**:m2 从不消耗,key-wide 计数器与 m1 自己的计数器
  读数恰好相同,退回 key-wide 仍通过。变异测试抓到后改为「先用 m2 花掉
  远超 m1 配额的量,再验证 m1 仍可用」—— 这样两种设计才可区分。
- TestUncappedModelNeverBlocked / TestAdminKeyScopesAreNotEnforced 新增
- UI 契约判据重写:整钥配额界面必须彻底消失(13 个符号)、
  scope 编辑器必须往返 req_quota、putScope 只发 scope 列表
- 错误消息点名具体模型(TestKeyAPIRejectionNamesTheModel)
- 3/3 变异全被抓

实测(真实进程 + 浏览器):m2 配额 500000 连打 25 次全成功,
m1 配额 1000 立即 429「token quota exceeded for "m1" (4315/1000)」,
此后 m2/m3 仍 200。UI:整钥配额元素全为 0,砖块各显配额,
编辑器预填/保存正确,零 JS 异常。

(cherry picked from commit c51066f0b6)
2026-09-27 19:07:28 +08:00
149 changed files with 32755 additions and 2295 deletions

View File

@ -0,0 +1,24 @@
{
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {
"content": "pong",
"role": "assistant"
}
}
],
"created": <TS>,
"id": "<ID>",
"model": "good-m",
"object": "chat.completion",
"usage": {
"completion": 1,
"completion_tokens": 1,
"prompt": 3,
"prompt_tokens": 3,
"total": 4,
"total_tokens": 4
}
}

View File

@ -0,0 +1,95 @@
{
"choices": [
{
"delta": {
"content": "",
"role": "assistant"
},
"finish_reason": null,
"index": 0
}
],
"created": <TS>,
"id": "<ID>",
"model": "good-m",
"object": "chat.completion.chunk"
}
{
"choices": [
{
"delta": {
"content": "Hel",
"role": "assistant"
},
"finish_reason": null,
"index": 0
}
],
"created": <TS>,
"id": "<ID>",
"model": "good-m",
"object": "chat.completion.chunk"
}
{
"choices": [
{
"delta": {
"content": "lo",
"role": "assistant"
},
"finish_reason": null,
"index": 0
}
],
"created": <TS>,
"id": "<ID>",
"model": "good-m",
"object": "chat.completion.chunk"
}
{
"choices": [
{
"delta": {
"content": "",
"role": "assistant"
},
"finish_reason": "stop",
"index": 0
}
],
"created": <TS>,
"id": "<ID>",
"model": "good-m",
"object": "chat.completion.chunk"
}
{
"choices": [
{
"delta": {
"content": ""
},
"finish_reason": "stop",
"index": 0
}
],
"created": <TS>,
"id": "<ID>",
"model": "good-m",
"object": "chat.completion.chunk"
}
{
"choices": [],
"created": <TS>,
"id": "<ID>",
"model": "good-m",
"object": "chat.completion.chunk",
"usage": {
"completion": 1,
"completion_tokens": 1,
"prompt": 16,
"prompt_tokens": 16,
"total": 17,
"total_tokens": 17
}
}
[DONE]

View File

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

View File

@ -69,7 +69,9 @@ Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换
(所有现有状态均已占用)时批量预热,顺序流量始终只用 1 个状态。
- 缩容步长由当前连接数决定:`clamp(ceil(冗余/(1+占用)), 1, 冗余)`——无连接时一轮收到
常驻下限,忙时每轮只释放 1 个,保护热路径。WebUI「适配器」页展示实时池指标。
- **disable_thinking**:请求 `disable_thinking:true`(或上游对应字段)动态开关推理。
- **thinking 预算**:请求里的推理开关/预算归一为中立的 `thinking:{enabled,budget_tokens}`,
适配器映射到各自协议(Anthropic `thinking.budget_tokens`、Gemini `thinkingConfig`、
DeepSeek `thinking.type="disabled"`)。
- **Lua 适配协议**:每个源挂一个 `.lua` 适配器,完成 `transform_request` /
`transform_response` / `transform_stream_chunk` 双向转换,协议差异全在 Lua 层。
- **签名 / 请求头钩子**:适配器可定义 `build_headers(meta)`,在 Go 发 HTTP 前
@ -150,6 +152,47 @@ 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` 丢失后运行时密文无法解密。
@ -407,10 +450,13 @@ Environment=MALLOC_ARENA_MAX=2
可选:`GOMEMLIMIT=48MiB` 作软上限,实测再省 ~1 MB;代价是逐近上限时 GC 转激进,
温和场景不必开。
### disable_thinking
### thinking 预算
请求体带 `"disable_thinking": true` 时,网关透传给各适配器;DeepSeek 适配器将其
映射为 `extra_body.thinking.type = "disabled"` 关闭推理,其余源按各自协议处理。
请求体带 `"thinking": {"enabled": false}` 时关闭推理;`"thinking": {"enabled": true,
"budget_tokens": 4096}` 指定预算。内核只定义这个**中立**语义字段,映射到具体协议由
适配器负责(Anthropic `thinking.budget_tokens`、Gemini `thinkingConfig.thinkingBudget`、
DeepSeek `thinking.type = "disabled"`)。内核不再向适配器泄漏 `disable_thinking` /
`extra_body` 这类私有字段。
### WebUI
@ -458,6 +504,13 @@ return {
-- [可选] 动态请求头/签名钩子
-- meta = { url, method, body, api_key, timestamp, source={ name, meta={...} } }
build_headers = function(meta) return { ["X-App-Sign"] = sign } end,
-- [可选] 出向序列化(契约 v2):上游不是 OpenAI 协议时接管写给客户端的字节。
-- 不声明则沿用内核的 OpenAI 兜底;声明即完全取代,内核不再混合字段。
serialize_response = function(result) return body_str end,
serialize_open_preamble = function(result) return bytes end,
serialize_chunk = function(delta) return bytes end,
serialize_close = function(result) return bytes end,
}
```
@ -466,11 +519,12 @@ return {
### 内置适配器
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode`。
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode`
`qwen38` `sensenova` `trae`。
`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);若
源启用 `disable_thinking`,`deepseek` 适配器会把 `extra_body.thinking.type` 置为
`disabled`。
`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);
推理开关由中立的 `thinking` 字段表达,`deepseek` 适配器把它映射为
`thinking.type = "disabled"`。
**kimicode** 是展示 `build_headers` 的样例:云端校验调用方 app,需要按
`meta.app_secret` 对时间戳+URL+请求体哈希做 HMAC 签名并附 `X-App-Sign` 等头。
@ -585,4 +639,4 @@ go test -tags luajit ./...
```
覆盖:配置校验、适配器加载/变换、签名钩子、Gateway 鉴权、SDK 结算、SSE 流式、
模型路由、多模态透传与 disable_thinking。
模型路由、多模态透传与 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`.
`kimicode` `qwen38` `sensenova` `trae`.
`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.8.0",
"version": "1.9.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.8.0",
"version": "1.9.3",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",

View File

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

View File

@ -226,6 +226,7 @@ async function loadPlugins() {
return;
}
const rows = j.on_disk || [];
window.__plRows = rows; // read by editPlugin/deletePlugin for the builtin flag
const active = rows.filter((p) => p.loaded && !p.disabled).length;
const broken = rows.filter((p) => !p.loaded).length;
hint.textContent =
@ -250,11 +251,30 @@ async function loadPlugins() {
const builtin = p.builtin
? '<span class="pl-builtin">内置</span>'
: "";
// Edit / view source / delete are the operations an operator
// actually needs after enable/disable: a plugin that cannot be
// read back cannot be fixed, and one that cannot be removed cannot
// be rolled back.
const acts = [
btn,
`<button class="ghost" data-act="code" data-name="${escAttr(p.name)}">${
p.builtin ? "查看源码" : "编辑"
}</button>`,
builtin
? ""
: `<button class="ghost danger" data-act="del" data-name="${escAttr(
p.name,
)}">删除</button>`,
]
.filter(Boolean)
.join("");
const pages = Array.isArray(p.pages) ? p.pages : [];
return `<div class="pl-item ${cls}">
<div class="pl-head"><b>${esc(p.name)}</b>${builtin}<span class="pl-state">${label}</span></div>
${p.description ? `<div class="pl-desc">${esc(p.description)}</div>` : ""}
${pages.length ? `<div class="pl-desc">页面:${esc(pages.join(", "))}</div>` : ""}
${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""}
<div class="pl-acts">${btn}</div>
<div class="pl-acts">${acts}</div>
</div>`;
})
.join("")
@ -262,6 +282,160 @@ async function loadPlugins() {
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
});
list.querySelectorAll('button[data-act="code"]').forEach((b) => {
b.onclick = () => editPlugin(b.dataset.name);
});
list.querySelectorAll('button[data-act="del"]').forEach((b) => {
b.onclick = () => deletePlugin(b.dataset.name);
});
}
// editPlugin shows the plugin source in a modal. Built-in plugins are
// read-only: their file ships inside the binary, so a save would either be
// ignored or silently do nothing — showing an editable text area that
// discards on save is worse than saying so up front.
function editPlugin(name) {
// The plugin list is the only place `builtin` is reported; keep the last
// fetch so the modal does not need a second round trip (and cannot guess).
const row = (window.__plRows || []).find((p) => p.name === name);
const isBuiltin = !!(row && row.builtin);
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card pl-wide">
<div class="overlay-head"><h2>${esc(name)}${isBuiltin ? "(内置,只读)" : ""}</h2></div>
${isBuiltin
? `<p class="hint">内置插件随内核发布,源码在二进制内,不可编辑。</p>`
: `<textarea id="pl-code" spellcheck="false"></textarea>
<p class="hint" id="pl-code-err"></p>`}
<div class="row actions">
${isBuiltin ? "" : `<button id="pl-code-save">保存并热重载</button>`}
<button class="ghost" id="pl-code-close">关闭</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-code-close").onclick = () => wrap.remove();
const save = $("#pl-code-save");
if (save) {
save.onclick = async () => {
save.disabled = true;
try {
// Saving writes through POST /api/plugins — the same install path the
// installer uses, which validates the name, compiles the source and
// hot-reloads. PUT /{name} only toggles enable/disable, so pointing the
// save there would silently disable nothing and store nothing.
await window.modelrouter.plugins.request("POST", "/api/plugins", {
name,
code: $("#pl-code").value,
});
toast(`${name} 已保存`);
wrap.remove();
await loadPlugins();
} catch (e) {
$("#pl-code-err").textContent = e.message;
save.disabled = false;
}
};
}
if (isBuiltin) return;
(async () => {
const ta = $("#pl-code");
try {
const j = await window.modelrouter.plugins.request(
"GET",
`/api/plugins/${encodeURIComponent(name)}`,
);
ta.value = j.code || "";
const err = $("#pl-code-err");
if (err && j.parse_error) err.textContent = String(j.parse_error);
} catch (e) {
$("#pl-code-err").textContent = e.message;
}
})();
}
function deletePlugin(name) {
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card">
<div class="overlay-head"><h2>删除插件</h2></div>
<p>确定删除 <b>${esc(name)}</b>?插件目录中的文件会被移除,核心将不再加载它。</p>
<div class="row actions">
<button class="danger" id="pl-del-ok">删除</button>
<button class="ghost" id="pl-del-no">取消</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-del-no").onclick = () => wrap.remove();
$("#pl-del-ok").onclick = async () => {
try {
await window.modelrouter.plugins.request(
"DELETE",
`/api/plugins/${encodeURIComponent(name)}`,
);
toast(`已删除 ${name}`);
wrap.remove();
await loadPlugins();
} catch (e) {
toast(e.message, true);
}
};
}
// installPlugin creates a new plugin from source. The name becomes the file
// name, so it is validated client-side to save a round trip on the obvious
// mistakes (empty, path separators, non-portable characters).
function installPlugin() {
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card pl-wide">
<div class="overlay-head"><h2>安装插件</h2></div>
<div class="row"><label>名称</label><input id="pl-new-name" placeholder="my_plugin" /></div>
<p class="hint" id="pl-new-err"></p>
<textarea id="pl-new-code" spellcheck="false" placeholder="-- 单文件 Lua 插件&#10;plugin = {}&#10;..."></textarea>
<div class="row actions">
<button id="pl-new-ok">安装</button>
<button class="ghost" id="pl-new-no">取消</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-new-no").onclick = () => wrap.remove();
$("#pl-new-ok").onclick = async () => {
const name = $("#pl-new-name").value.trim();
const code = $("#pl-new-code").value;
const err = $("#pl-new-err");
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
err.textContent = "名称只能包含字母、数字、下划线和连字符";
return;
}
if (!code.trim()) {
err.textContent = "源码不能为空";
return;
}
$("#pl-new-ok").disabled = true;
try {
await window.modelrouter.plugins.request("POST", "/api/plugins", {
// The backend appends .lua itself (installPlugin writes name+".lua"),
// so passing it here would create name.lua.lua.
name,
code,
});
toast(`已安装 ${name}`);
wrap.remove();
await loadPlugins();
} catch (e) {
err.textContent = e.message;
$("#pl-new-ok").disabled = false;
}
};
}
async function togglePlugin(name, disabled) {
@ -318,6 +492,8 @@ function init() {
if (plReload) plReload.onclick = loadPlugins;
const plAll = document.getElementById("pl-toggle-all");
if (plAll) plAll.onclick = enableAllPlugins;
const plInstall = document.getElementById("pl-install");
if (plInstall) plInstall.onclick = installPlugin;
$("#rail-autostart").onclick = toggleAutoStart;
$("#rail-silent").onclick = toggleSilent;
$("#rail-theme").onclick = () => {

View File

@ -214,6 +214,7 @@
<div id="pl-list" class="pl-list"></div>
<div class="row actions">
<button class="ghost" id="pl-toggle-all">全部启用</button>
<button class="ghost" id="pl-install">安装插件</button>
<button class="ghost" id="pl-reload">刷新</button>
</div>
<div class="row actions">

View File

@ -674,3 +674,51 @@ html[data-theme="dark"] .overlay {
color: var(--muted);
padding: 10px 0;
}
/* ---- plugin editor / installer --------------------------------------------
The plugin source editor and the install dialog reuse the settings overlay
(overlay / overlay-card) rather than a second popup system. They need three
things the settings card does not: a wider column for code, a monospace
textarea, and a destructive button for delete. */
.overlay-card.pl-wide {
width: 760px;
}
.pl-wide textarea {
width: 100%;
min-height: 380px;
resize: vertical;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--line-strong);
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
color: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.55;
tab-size: 2;
}
.pl-wide textarea:focus {
outline: none;
border-color: var(--primary);
}
.pl-wide .row input {
flex: 1;
padding: 7px 10px;
border-radius: 9px;
border: 1px solid var(--line-strong);
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
color: inherit;
}
.pl-editor .hint,
.pl-wide .hint {
min-height: 16px;
color: var(--muted, #888);
}
button.danger {
color: #e5484d;
border-color: rgba(229, 72, 77, 0.4);
}
button.danger:hover {
border-color: #e5484d;
color: #e5484d;
}

View File

@ -31,6 +31,7 @@ set -euo pipefail
readonly TARGET_BIN="/usr/local/bin/llmsproxy"
readonly TARGET_CONFIG="/etc/llmsproxy/config.yaml"
readonly TARGET_ADAPTERS="/etc/llmsproxy/adapters"
readonly TARGET_PLUGINS="/etc/llmsproxy/plugins"
readonly REPO_DIR="/home/program/llmsproxy"
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
readonly BIN_NAME="llmsproxy"
@ -390,6 +391,52 @@ sync_adapters() {
log "✓ 适配器已同步到 $TARGET_ADAPTERS/"
}
# 同步内置插件到 plugin_dir。
#
# 为什么需要它:新内置插件(如 channels.lua)过去只能靠内核的 SeedBundled 落地,
# 而旧版 SeedBundled "目录已存在就什么都不做",于是升级后新插件永远不会出现在
# 生产 —— 它在二进制里、却不在 /api/plugins。内核端已修(改为按文件补齐),
# 这里再显式同步一道,让部署与二进制内容对齐、不依赖内核那一侧的行为。
#
# 只补缺:已存在的插件文件一律不动。运维自己改过、或运行时新增的插件必须跨
# 部署存活;覆盖它们会静默抹掉线上正在用的逻辑。
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)"
local added=0
mkdir -p "$BACKUP_DIR"
for src in "$SRC_PLUGINS"/*.lua; do
[[ -f "$src" ]] || continue
local name; name="$(basename "$src")"
if [[ -f "$TARGET_PLUGINS/$name" ]]; then
continue # 已存在:运维版本优先,不覆盖
fi
cp -f "$src" "$TARGET_PLUGINS/$name"
chmod 0644 "$TARGET_PLUGINS/$name"
log " + 新增内置插件: $name"
((added++)) || true
done
if (( added == 0 )); then
log " 无新增内置插件(均已存在)"
rmdir "$BACKUP_DIR" 2>/dev/null || true
fi
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
for src in "$SRC_PLUGINS"/*.lua; do
[[ -f "$src" ]] || continue
local name; name="$(basename "$src")"
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
done
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,已有文件未改动)"
}
# ---------- 重启服务 ----------
restart_service() {
log "重启服务(中断 <500ms)"
@ -495,6 +542,7 @@ print_summary() {
log " 二进制: $TARGET_BIN"
log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")"
log " 适配器: $TARGET_ADAPTERS"
log " 插件: $TARGET_PLUGINS"
log "═════════════════════════════════════════════"
}
@ -511,6 +559,7 @@ main() {
verify_master_key
build
sync_adapters
sync_plugins
backup_old_state
atomic_replace_binary
atomic_replace_config

View File

@ -0,0 +1,117 @@
# 插件可调用内核动作(R4-c)设计
> 用户决策(2026-10-11):**全开**(13 个内核动作全部可调)+ **设置页可配权限** +
> **Go 侧 deadline 兜底**。
## 1. 要解决的问题
用户原话:"现在这个接口不全,可以多给一些接口让 lua 调用内核。"
现状(`__llmsproxy_adapters`)是我手写的**三个闭包**(install/update/remove),
只能管适配器。内核还有 13 个公开动作(Reload / ResetHealth / AddSource /
RemoveSource / SaveAutoRules / key 增删改 / 列表读取…),插件一个都调不到。
而"自动孵化 / 自愈 / 联动"这类插件能力(例如:探测到上游全挂 → 触发重探 +
清冷却;发现额度变化 → 重排 AUTO 链)恰恰需要它们。
## 2. 关键认识:Go 调用可以超时,Lua 死循环不能
这决定了整个设计,也是用户提"阻塞 + Go 兜底"的原因:
| 形态 | 能否中断 | 处置方式 |
|---|---|---|
| Lua 里 `while true do end` | **不能**(LuaJIT 限制,已实测三次) | 只能停用插件 |
| Lua 里 `__llmsproxy_kernel.call("reload")` | **能** | Go 侧 `select` + deadline,超时把错误返回给 Lua |
因为 Lua 只是在等 Go 的返回值,**不占用 Lua 栈的执行**。所以给每次内核调用加
Go 侧 deadline 是可行的、干净的 —— 不需要"Lua 投递请求、Go 队列执行":那样
还要定义"超时后队列里积压的命令怎么办",而 deadline 直接返回 `(nil, err)`,
语义闭合。
## 3. 依赖方向
`internal/lua` **不能** import `internal/core`(方向是 core → lua)。
按既有先例(`SetOutboundHosts`)注入一个**接口**,由 core 实现:
```go
// internal/lua/kernel_api.go
type KernelAction struct {
Name string // "reload" / "reset_health" / ...
Title string // 设置页展示用
Description string
Danger string // ""=只读, "write"=改配置, "danger"=破坏性
Params []string // 参数名(设置页/文档用)
}
type KernelAPI interface {
// Actions 列出可调用动作(设置页据此渲染权限开关)
Actions() []KernelAction
// Invoke 执行一个动作,args 为已解码参数。**必须**尊重 ctx 的 deadline。
Invoke(ctx context.Context, name string, args map[string]any) (any, error)
}
```
core 侧实现 `Invoke` 时用**现有的 Core 方法**,本身不持 `p.mu`,因此不会与
插件锁成环(已核实:所有 `ps.mu` → `p.mu` 都是先释放 `ps.mu`)。
## 4. 权限模型
复用 `visibilityOverrides` 的**同一套持久化机制**(`.access.json`),
但用独立的键空间:`kernel:<plugin>` → 逗号分隔的动作名或 `*`。
- **默认拒绝**:插件不显式获得授权时,`call` 返回 `(nil, "not permitted")`。
理由:全开是"能力存在",不是"默认授予"。一个插件能删密钥这件事必须由
运维在设置页点过确认。
- **`danger` 类必须显式逐个授权**,不接受 `*`。这一条是防止运维图省事写
通配符,把"删密钥"和"读状态"等同起来。
- 插件可以在自己源码里**声明**它想要的动作(`plugin.kernel = {...}`),
设置页把它显示为"申请",运维批准后才生效。声明不等于授权。
## 5. 动作清单(13 个 + 适配器 3 个)
| 动作 | 危险级 | 对应 Core 方法 | 超时 |
|---|---|---|---|
| `status` | 只读 | 读 AutoSlotStates/Registry | 2s |
| `list_sources` | 只读 | Sources() | 2s |
| `list_models` | 只读 | Registry().ModelList() | 2s |
| `list_adapters` | 只读 | ListAdapters() | 2s |
| `adapter_owner` | 只读 | AdapterOwnerOf | 2s |
| `reload` | write | Reload() | 30s |
| `reset_health` | write | ResetHealth() | 5s |
| `probe_all` | write | Registry().ProbeAll(ctx) | 60s |
| `save_auto_rules` | write | SaveAutoRules() | 10s |
| `save_key_auto` | write | SaveKeyAuto() | 10s |
| `add_source` | write | AddSource() | 30s |
| `remove_source` | danger | RemoveSource() | 30s |
| `create_key` | write | CreateKey() | 10s |
| `update_key` | write | UpdateKey() | 10s |
| `delete_key` | danger | DeleteKey() | 10s |
| `install_adapter` | write | UploadAdapter() | 10s |
| `remove_adapter` | danger | RemoveAdapter() | 30s |
| `set_plugin_enabled` | danger | SetEnabled() | 5s |
**绝不开放**:`ListKeys()` / `FindKey()` —— 返回**明文密钥**。给插件读密钥等于
把网关的凭据面交给第三方 Lua。要用 key 相关动作就只用带掩码的形态。
## 6. Lua 调用面
```lua
-- 探测:拿到可用动作与自己的授权情况
local acts = __llmsproxy_kernel.actions()
-- 调用:统一入口,返回 (result, err)
local ok, err = __llmsproxy_kernel.call("reset_health", {})
local st, err = __llmsproxy_kernel.call("status", {})
```
统一入口(而不是每个动作一个全局函数)的理由:动作集合将来会变,而
`__llmsproxy_kernel` 的签名不变 —— 否则每加一个动作就要动
`bootPluginState`,而那里正是历史上出过 cgo 崩溃的地方。
## 7. 实施顺序
1. `internal/lua/kernel_api.go`:接口 + 动作元数据 + 权限检查 + deadline 包装
2. `internal/lua/plugin_kernel_bridge.go`:Lua 绑定(`actions` / `call`)
3. `internal/core/kernel_actions.go`:实现 `KernelAPI`(core 侧)
4. 权限持久化:复用 `.access.json`,新键空间
5. 设置页:插件行加"内核动作"列 + 授权编辑
6. 测试:Go 侧全动作、Lua 侧调用、权限拒绝、deadline 生效、危险动作需显式授权

View File

@ -23,7 +23,7 @@
5. [内置辅助函数](#5-内置辅助函数)
6. [meta 约定](#6-meta-约定)
7. [一个完整的最小适配器](#7-一个完整的最小适配器)
8. [多模态与 disable_thinking](#8-多模态与-disable_thinking)
8. [多模态与 thinking 预算](#8-多模态与-thinking-预算)
---
@ -73,8 +73,9 @@ end
约定:
- 返回的字符串将被原样 POST 到 `base_url + (source.endpoint or adapter.endpoint)`。
- 请求体中包含的 `disable_thinking`、`extra_body` 等网关内部字段,转换时应自行清理
(`openai.lua` 即删掉这两个字段)。
- 契约 v2 起,内核**不再向适配器泄漏任何私有字段**:请求体里不会出现
`disable_thinking` / `extra_body` 这类键。推理开关与预算用中立的 `thinking`
字段表达(见下),适配器按目标协议各自映射。
### `transform_response(raw_body) -> string`
@ -176,6 +177,81 @@ end
返回非 table 会报错;返回 `{}` 表示无自定义头(不会回退静态头)。
### 出向序列化钩子(契约 v2)
默认情况下,网关把适配器产出的**统一格式**按 OpenAI 线格式序列化给下游(这是内核
保留的兑底,R5)。当上游协议不是 OpenAI(例如 Anthropic Messages API)时,适配器
可以接管出向序列化,自己决定写给客户端的字节。
五个钩子都是可选的,**全不声明就沿用 OpenAI 兑底**。一旦声明某个钩子,它就完全
接管那一段输出——内核不会再往里混合 OpenAI 字段(不是“叠加”,是“取代”)。
| 钩子 | 何时被调用 | 返回值 |
|------|-----------|--------|
| `serialize_response(payload)` | 非流式响应 | 写给客户端的响应体字符串 |
| `serialize_open_preamble(payload)` | 流式开始(发第一个 chunk 前) | 要发的字节(可为 `""`) |
| `serialize_chunk(delta)` | 流式每个增量 | 要发的字节(`""` 表示跳过) |
| `serialize_close(payload)` | 流式结束 | 收尾字节(如 SSE `message_stop`) |
| `serialize_done_marker(payload)` | 流式结束处的 `[DONE]` 标记 | 要写的字节;不声明则用 OpenAI 的 `data: [DONE]` |
`payload` 是统一格式的结果表(见 `internal/types/maxwire.go` 的 `UnifiedResult` /
`UnifiedDelta`),字段名中立,不依赖具体上游协议。
```lua
-- anthropic.lua 的简化形式:把统一结果写成 Anthropic Messages 形状
function adapter.serialize_response(payload)
return json.encode({
id = payload.id,
type = "message",
role = "assistant",
content = { { type = "text", text = payload.content or "" } },
stop_reason = payload.finish_reason == "stop" and "end_turn" or nil,
usage = { input_tokens = payload.prompt_tokens, output_tokens = payload.completion_tokens },
})
end
```
注意:钩子返回的是**完整要发送的字节**(包括必要时的 `event:` / `data:` 前缀与
空行),因为只有适配器知道目标协议的 SSE 细节。
### 入向让出钩子(契约 v2,`serialize_*` 的对偶)
出向是「统一格式 → 客户端协议」,入向是它的镜像:「客户端协议 → 统一格式」。
内核为对外端点(如 `POST /v1/messages`)**保留一份内置解析作为兜底(R5)**,
但适配器可以**声明自己服务该端点**并接管解析——那时内核主动让出,客户端协议的
迭代就完全落在适配器里,不再需要动内核。
声明分两步:
1. `adapter.ingress = "/v1/messages"` —— 声明**认领**哪条对外入站路径。
同一条路径被多个适配器认领是配置错误;内核按适配器名字典序确定性选择。
2. `deserialize_request(raw_body) -> unified_json` —— 把客户端原始 body 解析成
统一请求(`UnifiedRequest` 的 JSON),后续调度与 `/v1/chat/completions` 完全一致。
规则:
- 只有声明了该端点且实现了 `deserialize_request` 的适配器会让内核让出。
- 没有适配器认领时,内核内置解析接管(未配置的部署仍能工作)。
- 认领了但钩子**执行失败**是硬错误(400),不会静默回落到另一种协议解析——
客户端说的正是这个适配器声称支持的协议,悄悄换解析器只会错误调度请求。
```lua
-- anthropic.lua 的简化形式:把 Anthropic Messages 请求解析成统一格式
adapter.ingress = "/v1/messages"
function adapter.deserialize_request(raw_body)
local req = json.decode(raw_body)
local msgs = {}
for _, m in ipairs(req.messages or {}) do
table.insert(msgs, { role = m.role, content = m.content })
end
return json.encode({
model = req.model, messages = msgs, max_tokens = req.max_tokens,
system = type(req.system) == "string" and req.system or "",
stream = req.stream == true,
})
end
```
## 5. 内置辅助函数
所有适配器共享的全局函数(由 Go 注入):
@ -219,8 +295,13 @@ adapter.headers = { ["X-Tenant"] = "prod" }
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
req.extra_body = nil
-- 契约 v2:内核不再向适配器泄漏私有字段,`thinking` 是中立语义字段。
-- 这里把中立的 thinking 映射成目标协议需要的形状(示例:OpenAI 家族用
-- reasoning_effort)。不关心推理的源可以直接忽略它。
if req.thinking ~= nil then
if req.thinking.enabled == false then req.reasoning_effort = "none" end
req.thinking = nil
end
return json.encode(req)
end
@ -268,11 +349,21 @@ sources:
kind: chat
```
## 8. 多模态与 disable_thinking
## 8. 多模态与 thinking 预算
- **多模态**:请求的 `content` 若是数组(`[{type:"text"...},{type:"image_url"...}]`),Go 层
原样透传给适配器。协议不支持的源(Anthropic/Gemini/Ollama)需在
`transform_request` 内转换;内置 `anthropic.lua`/`gemini.lua`/`ollama.lua` 已实现。
- **disable_thinking**:网关将请求体中的 `disable_thinking:true` 透传进
`transform_request`。DeepSeek 适配器将其映射为 `extra_body.thinking = { type = "disabled" }`
并清理自身字段;其余适配器按各自协议处理(可自行实现或忽略)。
- **thinking**:客户端请求里的推理开关/预算被内核归一成中立的 `thinking` 字段:
```json
{"thinking": {"enabled": true, "budget_tokens": 4096}}
```
`enabled=false` 表示关闭推理,`budget_tokens` 可选。适配器自行映射到目标协议
(内置实现:Anthropic → `thinking.budget_tokens`、Gemini →
`generationConfig.thinkingConfig.thinkingBudget`、DeepSeek →
`thinking.type = "disabled"`)。不支持的源直接忽略该字段。
契约 v1 的内核私有字段 `disable_thinking` 已被取代——它曾是内核细节泄漏进
适配器请求体的典型例子(迫使 10 个适配器各写一行 `req.disable_thinking = nil`)。

View File

@ -301,20 +301,143 @@ PUT /api/plugins/{name}/state 替换状态(admin)
`hook_errors` 是**排错入口**:一个坏掉的插件表现为"功能缺失"而不是报错,
这个计数让它可见。
### 5.4 定时任务(`PLUGIN.schedule`)—— 浏览器关着也会跑
要求插件做“随时间发生”的事(自动签到、token 轮换、额度轮询)时,声明一个
调度表:
```lua
plugin.schedule = {
{ fn = "refresh_token", every = 3600 }, -- 每小时(秒)
{ fn = "daily_checkin", every = 86400, at = "03:30" }, -- 每天固定时刻(本地时间)
{ fn = "poll_quota", every = 300, jitter = 30 }, -- 首次触发加 0-30s 抖动
}
function plugin.refresh_token()
-- 注意:直接写 plugin.state 就是对的 ——
-- 任务和钩子跑在同一个 VM 上
plugin.state.token = fetch_new_token()
end
```
| 字段 | 含义 |
|---|---|
| `fn` | 要调用的插件函数名(必须存在) |
| `every` | 间隔秒数。与 `at` 二选一 |
| `at` | 每天本地时间 `"HH:MM"`;设了它就忽略 `every` |
| `jitter` | 首次触发额外随机延迟秒数(避免多实例整点齐射) |
| `timeout` | 解析保留但目前**不生效**(见下) |
要点:
- **内核 ticker 驱动**,不是前端轮询:没有浏览器、没人登录也会执行。
- **任务跑在插件自己的 VM 上**,与请求钩子共用 `plugin.state` —— 任务写的
token 对下一个请求立即可见,也会经同一套机制持久化到 `.<name>.state.json`。
- 任务函数出错只记 `hook_errors`,不影响钩子与转发。
- 没有 `schedule` 的插件不获得任何额外 VM/goroutine(零开销)。
- **超时不能中断一个运行中的 Lua 调用**(本 LuaJIT 构建的硬限制)。任务超过
10 分钟不返回,该插件被**停用**(`disarmed`):不再调度,钩子快速失败并
记 `hook_errors`。这是为了不让一个卡死的任务把请求永久堵在它那把锁后面。
修好插件后重新加载(重传/重启)即可恢复。
> 同一条 6.1 的约束:任务占着插件锁,所以**任务里不要写死循环**。
### 5.5 自定义对外挂载点
插件的 HTTP 端点默认挂在 `/api/plugins/http/...` 下。同一个声明**同时**可在两个
公开命名空间下访问,只要把 `path` 的第一段起成插件名:
```lua
plugin.http = {
{ path = "myplugin/callback", method = "POST", handler = "on_callback" },
}
```
该端点因此可在三个地方访问:
| 路径 | 用途 |
|---|---|
| `/api/plugins/http/myplugin/callback` | 默认前缀(带登录态的管理 UI) |
| `/plugins/myplugin/callback` | 公开(浏览器路径) |
| `/v1/plugins/myplugin/callback` | 公开(API 路径) |
可占用的命名空间只有后两个:
| 前缀 | 例子 |
|---|---|
| `/plugins/` | `/plugins/myplugin/callback` |
| `/v1/plugins/` | `/v1/plugins/myplugin/callback` |
限制(都是为了不把内核路由变成可被插件改写的):
- **不能影子内核路由**:与 `/v1/chat/completions`、`/v1/messages`、`/api/...`
等重叠的挂载在加载时就被拒绝,错误信息点名冲突对象。
- **只能是上面两个命名空间**,不接受任意前缀(否则可以拦截推理请求)。
- 鉴权沿用端点的 `roles` / `keys` / `public` 语义(见 §5.1);
`/v1/plugins/...` 属于 API 路径,未认证返回 401;`/plugins/...` 为浏览器
路径,未认证会跳登录页。
- 未声明的路径不会因为落在允许命名空间而被放行——不在 handler 表里就是 404。
### 5.6 在运行时安装/更新适配器(token 轮换)
插件可以在运行时安装、改写**自己的**适配器 —— 这是"带自动轮换的认证"的实现
方式:拿着新 token 重写适配器源码,后续请求自然使用新逻辑(内核重建该适配器
的池)。**可直接在插件 `.lua` 里调用**:
```lua
-- 每小时的定时任务:轮换 token 并重写适配器
function plugin.refresh_token()
local tok = fetch_new_token() -- 插件自己的取 token 逻辑
local name, err = __llmsproxy_adapters.install(
"myauth",
'return { name = "myauth", endpoint = "/chat/completions",' ..
' headers = { ["Authorization"] = "Bearer ' .. tok .. '" } }'
)
if err then log("error", err) end
end
```
| 调用 | 参数 | 返回 |
|---|---|---|
| `__llmsproxy_adapters.install(name, source)` | 适配器名 + Lua 源码 | `(生效名, nil)` 或 `(nil, err)` |
| `__llmsproxy_adapters.update(name, source)` | 同上(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
| `__llmsproxy_adapters.remove(name)` | 适配器名(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
出错时不抛异常,而是返回 `(nil, err)`——与钩子/HTTP 处理器同一套约定,
一个手滑的插件错误不会带走网关。
安全规则(由 Go 侧强制,不是约定):
- 插件**只能改自己装的**适配器(归属表在 Go 侧记录,调用身份是插件启动时的
固定名字,无法冒充)。
- 插件**不能影子内置适配器**(否则等于篡改发行物);运维仍可自行覆盖。
- 名字经过路径安全校验(无穿越/绝对路径/空白)。
> 这条能力配合 §5.4 的定时任务,就是完整的自动轮换:**没有浏览器、没有人
> 操作时,插件按时间自己把 token 换掉**。
---
## 6. 运行时约束(重要)
### 6.1 一个插件 = 一个 Lua 状态
### 6.1 一个插件 = 一个 Lua 状态(也就是一个 VM)
适配器可以有多个独立的 worker 状态(它们无状态,这是对的)。**插件不行**:
钩子通常往 `plugin.state` 里累加,多个状态就意味着总额被劈成几份,而写价格只
写进其中一个状态,钩子恰好跑到另一个时**所有请求按 0 计费**。
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState`)用互斥锁
串行化。代价:**一个卡住的钩子会卡住所有插件的钩子**。所以——
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState` / **定时任务**)
都用这同一把互斥锁串行化。
> **钩子必须短、同步、不阻塞。** 不要在里面做 HTTP 请求、睡眠或重计算。
⚠️ 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的
`plugin.state` 是一个请求路径永远读不到的副本 —— token 轮换看起来成功了,
对实际流量完全无效。
代价:**一个卡住的钩子会卡住该插件自己的钩子**(跨插件仍然并行,每插件各自
一把锁、各自一个 VM)。所以——
> **钩子与定时任务都必须短、同步、不阻塞。** 不要在钩子里做 HTTP 请求、睡眠
> 或重计算;长耗时的周期工作应放进 `PLUGIN.schedule`(见 §5.4),它在自己的
> 调度 goroutine 上跑,但仍然持有这把锁,因此不能是死循环。
### 6.2 异常被隔离
@ -497,10 +620,12 @@ journalctl -u llmsproxy -f | grep -i plugin
| | 适配器 | 插件 |
|---|---|---|
| 位置 | `adapter_dir` | `plugin_dir` |
| 作用 | 上游协议转换 | 请求流水线 + 界面 |
| 钩子 | `transform_request` / `transform_response` / `transform_stream_chunk` / `build_headers` / `transform_error` | `request_start` / `routed` / `request_end` |
| 状态 | 每个源独立,多 worker | 单状态,见 §6.1 |
| 出错后果 | 该 (源,模型) 进入冷却退避 | 仅该功能缺失 |
| 作用 | 上游协议转换 | 请求流水线 + 界面 + 周期任务 |
| 钩子 | `transform_request` / `transform_response` / `transform_stream_chunk` / `build_headers` / `transform_error` / `serialize_*` | `request_start` / `routed` / `request_end` |
| 周期任务 | **无**(无状态请求级) | `PLUGIN.schedule`(内核 ticker 驱动) |
| 对外端点 | 不开放(见 §5.5) | `/api/plugins/http/...` + 自定义挂载 |
| 状态 | 每个源独立,多 worker | **单 VM、单状态**,见 §6.1 |
| 出错后果 | 该 (源,模型) 进入冷却退避 | 仅该功能缺失;卡死则停用该插件 |
| 鉴权 | 需要 `gateway_keys` 之外的独立凭据 | 网关 key |
| 必需性 | 核心 | 可选,缺了网关照常跑 |

View File

@ -0,0 +1,286 @@
package e2e
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// 本文件端到端验证 R2/R5 的核心主张:**对外服务的 wire 格式由适配器决定,
// 内核只调度**;适配器不声明时内核的 OpenAI 兜底仍然可用。
//
// 与其他测试的分工:
// - internal/gateway/messages_test.go 在**进程内**验证同一件事(起 core+gateway,
// 不打真二进制)。它跑得快,但绕过了一些只在真实进程里发生的事:Lua 适配器的
// 磁盘 seed、配置加载、systemd 式启动路径、真 HTTP 栈。
// - 本文件打**真编译出的二进制**,用一个只说 Anthropic Messages 的假上游,
// 证明整条链路(真进程 → 真 Lua 适配器 → 真上游 → 适配器序列化 → 客户端)
// 确实成立,而不是只在进程内装配下成立。
//
// 这是用户明确问到的"适配器接手对外服务的部分"的验收点。
// anthropicMockUpstream 是只懂 Anthropic Messages 协议的假上游。
//
// 它会对**不符形状的请求主动报错**,而不是宽容接受 —— 宽容的 mock 会让
// "内核把 OpenAI 形状发给了 Anthropic 上游"这种错误测试通过。
func anthropicMockUpstream(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Anthropic 的请求路径必须是 <base>/messages
if !strings.HasSuffix(r.URL.Path, "/messages") {
t.Errorf("请求打到了非 Anthropic 路径: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
body, _ := io.ReadAll(r.Body)
var probe map[string]interface{}
if err := json.Unmarshal(body, &probe); err != nil {
t.Errorf("上游收到非 JSON 请求体: %s", body)
w.WriteHeader(http.StatusBadRequest)
return
}
// Anthropic 形状的硬性要求:messages 是数组;不能有 OpenAI 的 choices 概念
if _, ok := probe["messages"]; !ok {
t.Errorf("上游请求体缺 messages: %s", body)
}
if _, ok := probe["max_tokens"]; !ok {
t.Errorf("Anthropic 要求 max_tokens,请求体缺此项: %s", body)
}
// 反向断言:OpenAI 兜底字段不该出现在 Anthropic 上游请求里
for _, k := range []string{"choices", "chat_completions"} {
if _, bad := probe[k]; bad {
t.Errorf("Anthropic 上游请求体混入了 OpenAI 字段 %q: %s", k, body)
}
}
if stream, _ := probe["stream"].(bool); stream {
w.Header().Set("Content-Type", "text/event-stream")
fl, _ := w.(http.Flusher)
frames := []string{
`event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_e2e","type":"message","role":"assistant","model":"claude-e2e","content":[],"stop_reason":null,"usage":{"input_tokens":3,"output_tokens":0}}}`,
`event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
`event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}`,
`event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`,
`event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}`,
`event: message_stop` + "\n" + `data: {"type":"message_stop"}`,
}
for _, f := range frames {
fmt.Fprint(w, f+"\n\n")
if fl != nil {
fl.Flush()
}
}
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":"msg_e2e","type":"message","role":"assistant","model":"claude-e2e",`+
`"content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn",`+
`"usage":{"input_tokens":3,"output_tokens":1}}`)
}))
t.Cleanup(srv.Close)
return srv
}
// writeAnthropicConfig 写一份把源挂到 anthropic 适配器上的配置。
func writeAnthropicConfig(t *testing.T, dir, listenAddr, upstreamURL string) string {
t.Helper()
cfg := "listen: " + listenAddr + "\n" +
"gateway_keys:\n - sk-e2e-0001\n" +
"default_model: claude-e2e\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"sources:\n" +
" - name: anth\n" +
" base_url: " + upstreamURL + "\n" +
" adapter: anthropic\n" +
" api_key: test-key\n" +
" max_concurrent: 32\n" +
" models:\n" +
" - id: claude-e2e\n" +
" kind: chat\n" +
" priority: 100\n"
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(cfg), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
// TestAdapterTakeoverNonStream:非流式,Anthropic 客户端形状进、出。
func TestAdapterTakeoverNonStream(t *testing.T) {
up := anthropicMockUpstream(t)
dir := t.TempDir()
addr := mustAddr(t)
cfg := writeAnthropicConfig(t, dir, addr, up.URL)
bin := buildBinary(t)
g := startGateway(t, bin, addr, cfg)
resp, body := g.do("POST", "/v1/messages",
`{"model":"claude-e2e","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`, true)
if resp == nil || resp.StatusCode != 200 {
t.Fatalf("status=%v body=%q", statusOf(resp), body)
}
// 出向必须是 Anthropic Messages 形状,不能是 OpenAI choices 形状
if strings.Contains(body, `"choices"`) {
t.Errorf("Anthropic 响应里混入了 OpenAI choices 外壳: %s", body)
}
var out map[string]interface{}
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("响应不是 JSON: %v: %s", err, body)
}
if out["type"] != "message" {
t.Errorf(`响应 type = %v, 期望 "message"(Anthropic 形状): %s`, out["type"], body)
}
// 文本内容必须能取出来 —— 这是"内容确实穿过适配器回来了"的证据
if !strings.Contains(body, "pong") {
t.Errorf("响应里没有上游返回的文本: %s", body)
}
if _, ok := out["usage"]; !ok {
t.Errorf("Anthropic 响应应带 usage: %s", body)
}
}
// TestAdapterTakeoverStream:流式也要是 Anthropic SSE 序列,
// 且**不得**出现 OpenAI 的 [DONE] 终止标记(R2 的"不混合")。
func TestAdapterTakeoverStream(t *testing.T) {
up := anthropicMockUpstream(t)
dir := t.TempDir()
addr := mustAddr(t)
cfg := writeAnthropicConfig(t, dir, addr, up.URL)
bin := buildBinary(t)
g := startGateway(t, bin, addr, cfg)
resp, body := g.do("POST", "/v1/messages",
`{"model":"claude-e2e","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hi"}]}`, true)
if resp == nil || resp.StatusCode != 200 {
t.Fatalf("stream status=%v body=%q", statusOf(resp), body)
}
if !strings.Contains(body, "message_start") {
t.Errorf("Anthropic 流应以 message_start 开头: %s", body)
}
if !strings.Contains(body, "message_stop") {
t.Errorf("Anthropic 流应以 message_stop 收尾: %s", body)
}
if strings.Contains(body, "[DONE]") {
t.Errorf("Anthropic 流里出现了 OpenAI 的 [DONE]: %s", body)
}
if strings.Contains(body, "chat.completion.chunk") {
t.Errorf("流里混入 OpenAI chunk 形状: %s", body)
}
if !strings.Contains(body, "pong") {
t.Errorf("流里没有上游文本: %s", body)
}
}
// TestAdapterTakeoverStress 是对外服务路径的真实压力测试:
// 并发打真二进制,混合流式/非流式,验证无 5xx、上游命中数正确、
// 且每个响应都是完整合法的 Anthropic 形状(适配器在并发下不串味)。
func TestAdapterTakeoverStress(t *testing.T) {
up := anthropicMockUpstream(t)
dir := t.TempDir()
addr := mustAddr(t)
cfg := writeAnthropicConfig(t, dir, addr, up.URL)
bin := buildBinary(t)
g := startGateway(t, bin, addr, cfg)
// max_concurrent 配成 32,让 96 个请求基本都能真正流过适配器 —— 这个测试
// 要压的是**并发下序列化是否串味**,不是内核的容量拒绝策略。真的撞上上限
// 时收到的 429 也是合法背压(见下方断言),不算失败。
const (
workers = 12
perWorker = 8
)
var (
wg sync.WaitGroup
mu sync.Mutex
ok int
shed int
failed []string
streams int
)
start := make(chan struct{})
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
<-start
for i := 0; i < perWorker; i++ {
isStream := (w+i)%2 == 0
var payload string
if isStream {
payload = `{"model":"claude-e2e","max_tokens":32,"stream":true,"messages":[{"role":"user","content":"hi"}]}`
} else {
payload = `{"model":"claude-e2e","max_tokens":32,"messages":[{"role":"user","content":"hi"}]}`
}
resp, body := g.do("POST", "/v1/messages", payload, true)
mu.Lock()
switch {
case resp == nil:
failed = append(failed, "nil response")
// 429 = 内核的容量背压(provider busy)。它是**正确**行为,
// 不是缺陷:并发超限时就该如实拒绝,而不是排队或伪造响应。
// 只要求它是结构良好的错误,不要求 200。
case resp.StatusCode == http.StatusTooManyRequests:
if !strings.Contains(body, "busy") && !strings.Contains(body, "error") {
failed = append(failed, "429 不是结构化错误: "+body)
} else {
shed++
}
// 5xx 才是真失败 —— 那是内核或适配器崩了。
case resp.StatusCode >= 500:
failed = append(failed, fmt.Sprintf("5xx status=%d body=%.120s", resp.StatusCode, body))
case resp.StatusCode != 200:
failed = append(failed, fmt.Sprintf("status=%d body=%.120s", resp.StatusCode, body))
case !strings.Contains(body, "pong"):
// 200 但内容不对:说明并发下适配器把响应串味了
failed = append(failed, "missing upstream text: "+body)
case isStream && !strings.Contains(body, "message_stop"):
failed = append(failed, "stream missing message_stop")
case !isStream && strings.Contains(body, `"choices"`):
failed = append(failed, "openai shell leaked: "+body)
default:
ok++
if isStream {
streams++
}
}
mu.Unlock()
}
}(w)
}
close(start)
wg.Wait()
if len(failed) > 0 {
t.Fatalf("%d/%d 请求失败,前 5 例:\n%s", len(failed), workers*perWorker,
strings.Join(failed[:min(5, len(failed))], "\n"))
}
// 至少大部分请求必须真正流过 200 —— 全被背压拒掉说明并发上限配得太低,
// 那这个压力测试就没压到适配器并发。
if ok < workers*perWorker/2 {
t.Fatalf("只有 %d/%d 请求真正成功,压力未触及适配器并发路径",
ok, workers*perWorker)
}
t.Logf("成功 %d(其中流式 %d),背压 429 %d —— 无 5xx、无 OpenAI 形状泄漏",
ok, streams, shed)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
var _ = time.Second

View File

@ -0,0 +1,170 @@
package e2e
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// TestChannelsDevicePollE2E 用**真二进制**跑通 channels 插件的 device-poll 登录:
//
// 发起登录(start_login) → 页面轮询(poll_login) 推进设备码换 token
// → 凭据入保险库 → add_source 把平台接成上游源 → /v1/models 里出现其模型
//
// 这是对参考仓库 dsh-our-free-model「登录→领额度→接入源」形态的端到端对齐:
// 行为(HTTP 决策分支、会话推进、最终可路由)在真进程里验证,而不是单元桩。
//
// 假上游模拟 MiniMax 的两个端点:设备码申请 + token 轮询(脚本化逐次响应)。
func TestChannelsDevicePollE2E(t *testing.T) {
// ---- 假平台上游(loopback,白名单天然放行)----
var tokenHits int32
var tokenScript atomic.Value // []scriptStep
tokenScript.Store([]scriptStep{})
plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth2/device/code":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"device_code":"DEV-E2E","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/oauth2/token":
i := int(atomic.AddInt32(&tokenHits, 1)) - 1
script := tokenScript.Load().([]scriptStep)
w.Header().Set("Content-Type", "application/json")
if i >= len(script) {
w.WriteHeader(500)
fmt.Fprint(w, `{"error":"unexpected"}`)
return
}
if script[i].code != 0 && script[i].code != 200 {
w.WriteHeader(script[i].code)
}
fmt.Fprint(w, script[i].body)
case "/v1/models":
// 接入源后内核会探测可达;回一个 OpenAI 形状的模型列表
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"object":"list","data":[{"id":"MiniMax-M3","object":"model"}]}`)
case "/v1/chat/completions":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
default:
w.WriteHeader(404)
}
}))
defer plat.Close()
// ---- 真二进制 + 配置(带 plugin_dir,让 bundled channels 落地 + 授权可持久化)----
dir := t.TempDir()
addr := mustAddr(t)
pluginDir := filepath.Join(dir, "plugins") // 不预建:SeedBundled 见空目录才落地内置插件
cfg := "listen: " + addr + "\n" +
"gateway_keys:\n - sk-e2e-0001\n" +
"default_model: none\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"plugin_dir: " + pluginDir + "\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"sources: []\n"
cfgPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
bin := buildBinary(t)
g := startGatewayNoModels(t, bin, addr, cfgPath)
call := func(method, path, body string) (int, map[string]interface{}) {
resp, raw := g.do(method, path, body, true)
var m map[string]interface{}
_ = json.Unmarshal([]byte(raw), &m)
return statusOf(resp), m
}
// 1) 授权内核动作(运维批准 add_source 等)+ 把 minimax 端点指到假上游。
st, m := call("PUT", "/api/plugins/channels/kernel",
`{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`)
if st != 200 {
t.Fatalf("授权内核动作失败: status=%d body=%v", st, m)
}
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
`{"platform":"minimax","base_url":%q,"device":{"code_url":%q,"token_url":%q}}`,
plat.URL+"/v1", plat.URL+"/oauth2/device/code", plat.URL+"/oauth2/token"))
if st != 200 {
t.Fatalf("set_endpoints 失败: status=%d body=%v", st, m)
}
// 2) 发起 device-poll 登录:拿到会话 + user_code。
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"minimax"}`)
if st != 200 {
t.Fatalf("start_login status=%d body=%v", st, m)
}
sess, _ := m["session"].(map[string]interface{})
if sess == nil || sess["user_code"] != "WXYZ-1234" {
t.Fatalf("start_login 未透传 user_code: %v", m)
}
sid, _ := sess["id"].(string)
if sid == "" {
t.Fatal("无会话 id")
}
// 3) 第一次轮询:上游 pending → 会话仍 pending。
tokenScript.Store([]scriptStep{{code: 400, body: `{"error":"authorization_pending"}`}})
atomic.StoreInt32(&tokenHits, 0)
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
if m["state"] != "pending" {
t.Fatalf("轮询1 应 pending, 实得 %v", m)
}
// 4) 第二次轮询:上游发 access_token → 会话 completed,凭据入库 + 接入源。
tokenScript.Store([]scriptStep{{code: 200, body: `{"access_token":"MM-E2E-TOKEN","refresh_token":"MM-RF","nickname":"e2e-user"}`}})
atomic.StoreInt32(&tokenHits, 0)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
if m["state"] == "completed" || m["state"] == "failed" {
break
}
time.Sleep(150 * time.Millisecond)
}
if m["state"] != "completed" {
t.Fatalf("轮询2 应 completed, 实得 %v", m)
}
if m["account"] != "e2e-user" {
t.Fatalf("account 应取 nickname, 实得 %v", m)
}
// 5) 接入源的终局证据:/v1/models 里出现 channels-minimax 的模型。
deadline = time.Now().Add(5 * time.Second)
var modelsBody string
for time.Now().Before(deadline) {
resp, raw := g.do("GET", "/v1/models", "", true)
modelsBody = raw
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "MiniMax-M3") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "MiniMax-M3") {
t.Fatalf("接入源后 /v1/models 未出现 MiniMax-M3: %s", modelsBody)
}
}
type scriptStep struct {
code int
body string
}
// startGatewayNoModels 与 startGateway 相同,但就绪探针不要求 /v1/models 返回
// 非空:本用例启动时没有任何源,/v1/models 是空列表(200 即就绪)。
func startGatewayNoModels(t *testing.T, bin, listen, cfgPath string) *gatewayUnderTest {
t.Helper()
return startGateway(t, bin, listen, cfgPath)
}
var _ = net.Listen
var _ = io.Discard

View File

@ -0,0 +1,200 @@
package e2e
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// TestChannelsFlowsE2E 用真二进制补齐另外三条流程的端到端:
//
// Cline(WorkOS device-poll 两步:authenticate → register 换最终凭据)
// paste(opencode:直接 complete_login 贴 API key)
// manual(qoder:start_login 返回 manual_hint,再凭据完成)
//
// 终局证据与 TestChannelsDevicePollE2E 同一标准:/v1/models 出现该平台模型。
func TestChannelsFlowsE2E(t *testing.T) {
var authHits, regHits int32
plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
// ---- Cline(WorkOS)----
case "/user_management/authorize/device":
fmt.Fprint(w, `{"device_code":"CD-E2E","user_code":"CLNE-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/user_management/authenticate":
if atomic.AddInt32(&authHits, 1) == 1 {
w.WriteHeader(400)
fmt.Fprint(w, `{"error":"authorization_pending"}`)
return
}
fmt.Fprint(w, `{"access_token":"WO-E2E-AT","refresh_token":"WO-E2E-RT"}`)
case "/api/v1/auth/register":
atomic.AddInt32(&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":"qoder-default","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`)
case "/v1/chat/completions":
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
default:
w.WriteHeader(404)
}
}))
defer plat.Close()
dir := t.TempDir()
addr := mustAddr(t)
pluginDir := filepath.Join(dir, "plugins")
cfg := "listen: " + addr + "\n" +
"gateway_keys:\n - sk-e2e-0001\n" +
"default_model: none\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"plugin_dir: " + pluginDir + "\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"sources: []\n"
cfgPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
bin := buildBinary(t)
g := startGatewayNoModels(t, bin, addr, cfgPath)
call := func(method, path, body string) (int, map[string]interface{}) {
resp, raw := g.do(method, path, body, true)
var m map[string]interface{}
_ = json.Unmarshal([]byte(raw), &m)
return statusOf(resp), m
}
st, m := call("PUT", "/api/plugins/channels/kernel",
`{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`)
if st != 200 {
t.Fatalf("授权内核动作失败: status=%d body=%v", st, m)
}
// ---------- ① Cline:WorkOS 设备码两步 ----------
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
`{"platform":"cline","base_url":%q,"device":{"code_url":%q,"token_url":%q},"register":{"url":%q}}`,
plat.URL, plat.URL+"/user_management/authorize/device",
plat.URL+"/user_management/authenticate", plat.URL+"/api/v1/auth/register"))
if st != 200 {
t.Fatalf("set_endpoints(cline) 失败: status=%d body=%v", st, m)
}
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"cline"}`)
if st != 200 {
t.Fatalf("cline start_login status=%d body=%v", st, m)
}
sess, _ := m["session"].(map[string]interface{})
if sess == nil || sess["user_code"] != "CLNE-1234" {
t.Fatalf("cline start_login 未透传 user_code: %v", m)
}
sid, _ := sess["id"].(string)
// 第一拍 pending(authorization_pending),第二拍 completed
atomic.StoreInt32(&authHits, 0)
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
if m["state"] != "pending" {
t.Fatalf("cline 轮询1 应 pending, 实得 %v", m)
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
if m["state"] == "completed" || m["state"] == "failed" {
break
}
time.Sleep(150 * time.Millisecond)
}
if m["state"] != "completed" {
t.Fatalf("cline 轮询2 应 completed, 实得 %v", m)
}
if m["account"] != "dev@example.com" {
t.Fatalf("cline account 应取 register userInfo.email, 实得 %v", m)
}
if atomic.LoadInt32(&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)
}
// ---------- ③ manual(qoder):hint 明确 + 凭据完成 ----------
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
`{"platform":"qoder","base_url":%q}`, plat.URL))
if st != 200 {
t.Fatalf("set_endpoints(qoder) 失败: %v", m)
}
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"qoder"}`)
if st != 200 {
t.Fatalf("qoder start_login status=%d", st)
}
hint, _ := m["manual_hint"].(string)
if !strings.Contains(hint, "WASM") {
t.Fatalf("qoder manual_hint 应说明 WASM 不可自动, 实得 %q", hint)
}
st, m = call("POST", "/api/plugins/http/channels/login/complete",
`{"platform":"qoder","credential":"QD-E2E-KEY"}`)
if st != 200 || m["ok"] != true {
t.Fatalf("qoder complete 失败: status=%d body=%v", st, m)
}
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
resp, raw := g.do("GET", "/v1/models", "", true)
modelsBody = raw
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qoder-default") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "qoder-default") {
t.Fatalf("qoder 接入后 /v1/models 未出现 qoder-default: %s", modelsBody)
}
}

View File

@ -0,0 +1,261 @@
package e2e
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
// writeE2EPlugin 落一个插件文件到 e2e 的 plugin_dir(SeedBundled 见空目录才
// 落地内置插件,所以先放自定义插件再启动网关)。
func writeE2EPlugin(t *testing.T, dir, name, code string) {
t.Helper()
p := filepath.Join(dir, "plugins", name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(code), 0o644); err != nil {
t.Fatal(err)
}
}
// e2eConfig 返回一份最小 e2e 配置文本(含 plugin_dir)。
func e2eConfig(dir, listen string) string {
return "listen: " + listen + "\n" +
"gateway_keys:\n - sk-e2e-0001\n" +
"default_model: AUTO\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
"sources: []\n"
}
// TestConflictArbitrationE2E(用户要求:服务路径冲突不崩溃不拒启 + WebAPI 裁决)
//
// 真二进制全链路:
// 1. 两个插件声明同一条 /api/plugins/http/ 路径 → 两插件都 loaded,启动正常
// 2. 请求该路径 → 409,错误信息点名两个候选插件(不静默选边)
// 3. GET /api/plugins/conflicts → 列出冲突(路径 + 候选 + 未裁决)
// 4. POST /api/plugins/conflicts 裁决 → 持久化
// 5. 再请求该路径 → 200,赢家应答({"from":"B"})
// 6. /v1/models 等既有路由全程不受影响(旧通路无回归)
func TestConflictArbitrationE2E(t *testing.T) {
bin := buildBinary(t)
dir := t.TempDir()
addr := mustAddr(t)
writeE2EPlugin(t, dir, "plugA.lua", `
local P = {}
P.name = "plugA"
P.version = "1"
P.exports = {}
P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } }
function P.h(req) return { status = 200, json = { from = "A" } } end
return P
`)
writeE2EPlugin(t, dir, "plugB.lua", `
local P = {}
P.name = "plugB"
P.version = "1"
P.exports = {}
P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } }
function P.h(req) return { status = 200, json = { from = "B" } } end
return P
`)
cfgPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil {
t.Fatal(err)
}
g := startGateway(t, bin, addr, cfgPath)
// ① 两个插件都加载成功(冲突不拒载)
resp, body := g.do(http.MethodGet, "/api/plugins", "", true)
if resp.StatusCode != 200 {
t.Fatalf("GET /api/plugins: %d %s", resp.StatusCode, body)
}
var list struct {
Plugins []struct {
Name string `json:"name"`
Loaded bool `json:"loaded"`
} `json:"plugins"`
}
if err := json.Unmarshal([]byte(body), &list); err != nil {
t.Fatal(err)
}
loaded := map[string]bool{}
for _, p := range list.Plugins {
loaded[p.Name] = p.Loaded
}
if !loaded["plugA"] || !loaded["plugB"] {
t.Fatalf("冲突的两个插件都应 loaded: %v", loaded)
}
// ② 请求冲突路径 → 409 + 点名候选(不静默选边)
resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("未裁决冲突路径应 409, 实得 %d (%s)", resp.StatusCode, body)
}
if !strings.Contains(body, "plugA") || !strings.Contains(body, "plugB") {
t.Fatalf("409 应点名两个候选: %s", body)
}
// ③ 冲突列表可见
resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true)
if resp.StatusCode != 200 {
t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body)
}
var cl struct {
Routes []struct {
Path string `json:"path"`
Candidates []string `json:"candidates"`
Resolved string `json:"resolved"`
} `json:"routes"`
}
if err := json.Unmarshal([]byte(body), &cl); err != nil {
t.Fatal(err)
}
if len(cl.Routes) != 1 || cl.Routes[0].Path != "contested" || cl.Routes[0].Resolved != "" {
t.Fatalf("冲突形态不对: %+v", cl)
}
// ④ 裁决 plugB 赢
arbit, _ := json.Marshal(map[string]string{
"kind": "route", "path": "contested", "winner": "plugB",
})
resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true)
if resp.StatusCode != 200 {
t.Fatalf("POST conflicts 裁决: %d %s", resp.StatusCode, body)
}
// ⑤ 再请求 → 200 且赢家应答
resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true)
if resp.StatusCode != 200 {
t.Fatalf("裁决后请求冲突路径应 200, 实得 %d (%s)", resp.StatusCode, body)
}
if !strings.Contains(body, `"from":"B"`) {
t.Fatalf("赢家 plugB 应答, 实得 %s", body)
}
// ⑥ 旧通路无回归:/v1/models 正常
resp, _ = g.do(http.MethodGet, "/v1/models", "", true)
if resp.StatusCode != 200 {
t.Fatalf("旧通路 /v1/models 应 200, 实得 %d", resp.StatusCode)
}
}
// TestAdapterConflictE2E:插件装与内置同名适配器 → 挂起(现任保留),
// /api/plugins/conflicts 列出,裁决 candidate 后插件版生效。
func TestAdapterConflictE2E(t *testing.T) {
bin := buildBinary(t)
dir := t.TempDir()
addr := mustAddr(t)
writeE2EPlugin(t, dir, "shadow.lua", `
local P = {}
P.name = "shadow"
P.version = "1"
P.exports = { "install" }
function P.install(args)
local src = 'local a={} a.name="openai" a.version="SHADOW_V1" a.endpoint="/x" return a'
local name, err = __llmsproxy_adapters.install("openai", src)
return { name = name, err = err }
end
return P
`)
cfgPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil {
t.Fatal(err)
}
g := startGateway(t, bin, addr, cfgPath)
// ① 安装 → 返回 err(pending 语义),内置版本不变
resp, body := g.do(http.MethodPost, "/api/plugins/shadow/call/install", "{}", true)
if resp.StatusCode != 200 {
t.Fatalf("call install: %d %s", resp.StatusCode, body)
}
// Call 端点把插件返回值包在 {"result": …} 里;挂起语义的断言在 result 层做。
var wrapped struct {
Result struct {
Name *string `json:"name"`
Err string `json:"err"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(body), &wrapped); err != nil {
t.Fatal(err)
}
out := wrapped.Result
if out.Name != nil {
t.Fatalf("挂起时不得返回生效名: %s", body)
}
if !strings.Contains(out.Err, "openai") || !strings.Contains(out.Err, "conflict") {
t.Fatalf("错误应点名冲突: %q", out.Err)
}
// ② 冲突列表可见
resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true)
if resp.StatusCode != 200 {
t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body)
}
var cl struct {
Adapters []struct {
Name string `json:"name"`
Candidate string `json:"candidate"`
} `json:"adapters"`
}
if err := json.Unmarshal([]byte(body), &cl); err != nil {
t.Fatal(err)
}
if len(cl.Adapters) != 1 || cl.Adapters[0].Name != "openai" || cl.Adapters[0].Candidate != "shadow" {
t.Fatalf("冲突形态不对: %+v", cl)
}
// ③ 适配器列表里 openai 仍是内置版本(现任继续服务,不被静默覆盖)
assertOpenAIVersion := func(want string, msg string) {
t.Helper()
resp, body := g.do(http.MethodGet, "/api/adapters", "", true)
if resp.StatusCode != 200 {
t.Fatalf("GET /api/adapters: %d %s", resp.StatusCode, body)
}
var al struct {
Adapters []struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"adapters"`
}
if err := json.Unmarshal([]byte(body), &al); err != nil {
t.Fatal(err)
}
for _, a := range al.Adapters {
if a.Name == "openai" {
if a.Version != want {
t.Fatalf("%s: openai 版本应 %q, 实得 %q", msg, want, a.Version)
}
return
}
}
t.Fatalf("%s: openai 适配器不存在: %s", msg, body)
}
assertOpenAIVersion("2.0.0", "现任内置必须继续服务(不被静默覆盖)")
// ④ 裁决 candidate → 插件版生效
arbit, _ := json.Marshal(map[string]string{
"kind": "adapter", "name": "openai", "winner": "candidate",
})
if resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true); resp.StatusCode != 200 {
t.Fatalf("裁决 candidate: %d %s", resp.StatusCode, body)
}
// ⑤ 适配器版本已切换 + 冲突清空
assertOpenAIVersion("SHADOW_V1", "裁决后插件版应生效")
resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true)
if resp.StatusCode != 200 {
t.Fatal(resp.StatusCode)
}
if strings.Contains(body, `"name":"openai"`) && strings.Contains(body, `"adapters":[{`) {
t.Fatalf("裁决后冲突应清除: %s", body)
}
}

192
e2e/wire_golden_test.go Normal file
View File

@ -0,0 +1,192 @@
package e2e
import (
"bufio"
"encoding/json"
"io"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// mustAddr 选一个空闲端口并立即释放(与 e2e_test.go 的做法一致)。
func mustAddr(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("pick port: %v", err)
}
addr := l.Addr().String()
l.Close()
return addr
}
// 本文件建立**字节级**对外输出基线,供统一线格式 v2(对外序列化移交适配器)
// 做回归比对。
//
// 为什么必须有它:Phase 2 要把 gateway/chat.go 里硬编码的 OpenAI wire 结构与
// writeChatCompletion/pumpStream 的 JSON 拼装移交给适配器 serializer。现有 e2e
// 只用 strings.Contains 做宽松断言("包含 Hel"),无法证明"输出字节未变"——
// 字段集、omitempty 裁剪、usage 零值处理都可能静默漂移,而客户端会因此看到
// 不同的 wire 形状。golden 把当前输出钉死,改动前后逐字节比对。
//
// 更新方式:GOLDEN_UPDATE=1 go test -tags luajit ./e2e/ -run TestWireGolden
// 更新后必须人工审阅 diff —— golden 是契约,不是快照垃圾桶。
// volatile 命中每次请求都变的字段,比对前替换为占位符。
// 允许冒号后的空白:golden 是 pretty-print 过的(`"created": 123`)。
var volatile = []*regexp.Regexp{
regexp.MustCompile(`"id":\s*"[^"]*"`),
regexp.MustCompile(`"created":\s*\d+`),
}
func scrub(s string) string {
s = volatile[0].ReplaceAllString(s, `"id": "<ID>"`)
return volatile[1].ReplaceAllString(s, `"created": <TS>`)
}
func compareGolden(t *testing.T, name, got string) {
t.Helper()
got = scrub(got)
path := filepath.Join("..", ".snapshots", name)
if os.Getenv("GOLDEN_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
t.Logf("golden updated: %s", path)
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("golden missing (%s): %v\nrun GOLDEN_UPDATE=1 go test -tags luajit ./e2e/ -run TestWireGolden to create", path, err)
}
if got != string(want) {
t.Errorf("对外输出与 golden 漂移 (%s)\n--- want ---\n%s\n--- got ---\n%s",
name, string(want), got)
}
}
// prettyJSON 缩进输出。走 map 会排序键,从而让「字段集」成为唯一被比较的东西,
// 忽略 Go struct 声明顺序(那不是对外契约的一部分)。
func prettyJSON(t *testing.T, raw string) string {
t.Helper()
var v interface{}
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return raw
}
out, err := json.MarshalIndent(v, "", " ")
if err != nil {
return raw
}
return string(out)
}
// TestWireGoldenNonStreamChat 钉死非流式 /v1/chat/completions 的字段集。
func TestWireGoldenNonStreamChat(t *testing.T) {
up := newMockUpstream(t)
dir := t.TempDir()
addr := mustAddr(t)
g := startGateway(t, buildBinary(t), addr, writeConfig(t, dir, addr, map[string]*mockUpstream{"good": up}))
resp, body := g.do("POST", "/v1/chat/completions",
`{"model":"good/good-m","messages":[{"role":"user","content":"hi"}]}`, true)
// 语义:OpenAI 客户端依赖这些顶层字段
var obj map[string]interface{}
if err := json.Unmarshal([]byte(body), &obj); err != nil {
t.Fatalf("not JSON: %v (%s)", err, body)
}
for _, k := range []string{"id", "object", "created", "model", "choices", "usage"} {
if _, ok := obj[k]; !ok {
t.Errorf("非流式响应缺少 OpenAI 必需字段 %q: %s", k, body)
}
}
if obj["object"] != "chat.completion" {
t.Errorf("object = %v, want chat.completion", obj["object"])
}
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
compareGolden(t, "chat_nonstream.json", prettyJSON(t, body))
}
// TestWireGoldenStreamChat 钉死流式 SSE 的帧形状与终止序列。
func TestWireGoldenStreamChat(t *testing.T) {
up := newMockUpstream(t)
dir := t.TempDir()
addr := mustAddr(t)
g := startGateway(t, buildBinary(t), addr, writeConfig(t, dir, addr, map[string]*mockUpstream{"good": up}))
resp, _ := doStream(t, g, `{"model":"good/good-m","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
defer resp.Body.Close()
var frames []string
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
payload := strings.TrimPrefix(line, "data: ")
if payload == "[DONE]" {
frames = append(frames, "[DONE]")
continue
}
frames = append(frames, prettyJSON(t, payload))
}
if len(frames) < 2 {
t.Fatalf("期望多帧 SSE,实得 %d: %v", len(frames), frames)
}
if frames[len(frames)-1] != "[DONE]" {
t.Errorf("末帧 = %q, want [DONE]", frames[len(frames)-1])
}
// 首帧必须是 role 前导:OpenAI 客户端靠它建立 assistant 消息
if !strings.Contains(frames[0], `"role"`) {
t.Errorf("首个 SSE 帧必须携带 role 前导,实得: %s", frames[0])
}
compareGolden(t, "chat_stream.txt", strings.Join(frames, "\n"))
}
// TestWireGoldenImage 钉死 /v1/images/generations 的响应形状。
func TestWireGoldenImage(t *testing.T) {
up := newMockUpstream(t)
dir := t.TempDir()
addr := mustAddr(t)
g := startGateway(t, buildBinary(t), addr, writeConfig(t, dir, addr, map[string]*mockUpstream{"image": up}))
resp, body := g.do("POST", "/v1/images/generations", `{"model":"imagegen/flux-1","prompt":"a cat"}`, true)
if resp.StatusCode != 200 {
t.Fatalf("image status = %d: %s", resp.StatusCode, body)
}
if !strings.Contains(body, "QUJD") {
t.Fatalf("生图响应丢失了载荷: %s", body)
}
compareGolden(t, "image_generation.json", prettyJSON(t, body))
}
// doStream 发流式请求并返回未读完的响应(调用方负责逐行读 SSE)。
func doStream(t *testing.T, g *gatewayUnderTest, body string) (*http.Response, error) {
t.Helper()
req, err := http.NewRequest("POST", "http://"+g.addr+"/v1/chat/completions", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+g.key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("stream request: %v", err)
}
return resp, nil
}
var _ = io.Discard

View File

@ -195,6 +195,36 @@ 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)
}
@ -262,3 +292,32 @@ 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

@ -86,3 +86,129 @@ 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

@ -39,6 +39,15 @@ type Config struct {
// so peak traffic was billed at off-peak rates — no error, the bill just
// quietly halved. A schema-checked config file catches both at load.
BillingDSL *BillingDSL `yaml:"billing,omitempty"`
// PluginVisibility overrides the audience a plugin declared for itself,
// keyed by plugin name: "admin" hides a plugin (and its state) from user
// keys, "all" widens one that shipped admin-only, "user,admin" names roles
// explicitly. An absent name leaves the plugin's own declaration in force.
//
// This exists because a plugin author cannot know the operator's policy, and
// the operator should not have to edit a bundled .lua to change one — the
// next upgrade overwrites it.
PluginVisibility map[string]string `yaml:"plugin_visibility,omitempty" json:"plugin_visibility,omitempty"`
// box seals credentials (sources' api_key/headers, keys' key) at rest.
// In-memory values are always plaintext; only the bytes on disk are sealed.
// Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay
@ -64,15 +73,20 @@ type Model struct {
// Source describes a single upstream LLM provider.
type Source struct {
Name string `yaml:"name" json:"name"`
BaseURL string `yaml:"base_url" json:"base_url"`
APIKey string `yaml:"api_key" json:"api_key"`
APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key)
Adapter string `yaml:"adapter" json:"adapter"`
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override
ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override
Models []Model `yaml:"models" json:"models"`
Headers map[string]string `yaml:"headers" json:"headers,omitempty"`
Name string `yaml:"name" json:"name"`
BaseURL string `yaml:"base_url" json:"base_url"`
APIKey string `yaml:"api_key" json:"api_key"`
APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key)
Adapter string `yaml:"adapter" json:"adapter"`
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override
// StreamEndpoint overrides the CHAT endpoint for streaming requests. Most
// upstreams stream on the same path; Gemini is the exception (same path,
// different verb). Declaring it here keeps that knowledge out of the kernel,
// which only compares opaque strings.
StreamEndpoint string `yaml:"endpoint_stream" json:"endpoint_stream,omitempty"`
ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override
Models []Model `yaml:"models" json:"models"`
Headers map[string]string `yaml:"headers" json:"headers,omitempty"`
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy
// (e.g. http://127.0.0.1:7890). Empty = direct connection. Needed for
// upstreams behind geo/IP blocks that only respond through a proxy.
@ -364,13 +378,14 @@ type RuntimeConfig struct {
// name and api_key) so the WebUI can spin up multiple key-bearing sources
// from one shared template.
type SourceTemplate struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
Adapter string `json:"adapter"`
Endpoint string `json:"endpoint,omitempty"`
ImageEndpoint string `json:"image_endpoint,omitempty"`
Models []Model `json:"models"`
Headers map[string]string `json:"headers,omitempty"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
Adapter string `json:"adapter"`
Endpoint string `json:"endpoint,omitempty"`
StreamEndpoint string `json:"endpoint_stream,omitempty"`
ImageEndpoint string `json:"image_endpoint,omitempty"`
Models []Model `json:"models"`
Headers map[string]string `json:"headers,omitempty"`
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy.
ProxyURL string `json:"proxy_url,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
@ -384,15 +399,25 @@ 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
}
// HasOwnAuto reports whether the key declares its own AUTO chain rather than
// inheriting the global one. An explicitly empty list still counts as "no own
// chain" so a user can clear an override back to the global default.
func (k GWKey) HasOwnAuto() bool { return len(k.Auto) > 0 }
// BillingDSL holds declarative per-URL pricing profiles for the billing plugin.
//
// Profiles are the "let the user pick" axis: the same upstream URL can be
@ -434,6 +459,19 @@ 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.
@ -445,6 +483,31 @@ 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

@ -0,0 +1,40 @@
package core
import (
"testing"
)
// TestChannelsDevicePollBehavior 是 device-poll 流的行为对齐测试。
//
// 决策表逐行搬自参考实现 pollMinimaxDeviceToken(minimax-oauth.ts),
// 上游用假服务器模拟,逐次响应由测试控制。断言的是**行为**(会话状态推进),
// 不是实现细节。决策表本体在 deviceHarness.runDecisionTable(与变异测试共用)。
func TestChannelsDevicePollBehavior(t *testing.T) {
h := newDeviceHarness(t, mustChannelsSrc(t))
// begin 直查:user_code 透传、授权 URL、verifier 不回显
j := h.begin()
sess := j["session"].(map[string]interface{})
if sess["user_code"] != "ABCD-EFGH" {
t.Fatalf("begin 应透传 user_code, 实得 %v", sess["user_code"])
}
if j["url"] != "https://example/activate" {
t.Fatalf("begin 应给授权 URL, 实得 %v", j["url"])
}
if _, has := j["device_verifier"]; has {
t.Fatal("verifier 绝不能回显")
}
// 决策表全过
if !h.runDecisionTable() {
t.Fatal("device-poll 决策表未全过(见 runDecisionTable 内的行号)")
}
}
// mustChannelsSrc 读 bundled channels 源码。
func mustChannelsSrc(t *testing.T) string {
t.Helper()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
return src
}

View File

@ -0,0 +1,325 @@
package core
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// channelsMoreTest:新引入的三条真实流程 + 两个边界流的行为对齐测试。
// 平台定义、决策表、端点逐字照抄参考(见 channels.lua 注释),测试照参考行为搬。
// ---- Cline(WorkOS 设备码 + register 两步) ----
// clineHarness 假上游:device code / authenticate / register 三端点。
type clineHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
tok atomic.Value // 当前 authenticate 响应脚本 []scriptStep
step int32
}
func newClineHarness(t *testing.T) *clineHarness {
t.Helper()
h := &clineHarness{t: t}
h.tok.Store([]scriptStep{})
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/user_management/authorize/device":
fmt.Fprint(w, `{"device_code":"CD1","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/user_management/authenticate":
i := int(atomic.AddInt32(&h.step, 1)) - 1
s := h.tok.Load().([]scriptStep)
if i >= len(s) {
w.WriteHeader(500)
fmt.Fprint(w, `{"error":"unexpected"}`)
return
}
if s[i].http != 0 && s[i].http != 200 {
w.WriteHeader(s[i].http)
}
fmt.Fprint(w, s[i].body)
case "/api/v1/auth/register":
// 校验请求体是驼峰 accessToken/refreshToken(参考 registerClineTokens)
b, _ := io.ReadAll(r.Body)
for _, want := range []string{`"accessToken":"WO-AT-1"`, `"refreshToken":"WO-RT-1"`} {
if !strings.Contains(string(b), want) {
t.Errorf("register 请求体缺 %s: %s", want, b)
}
}
fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-AT-1","refreshToken":"CLINE-RT-1","userInfo":{"clineUserId":"u1","email":"dev@example.com"}}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, _ := readBundledChannels(t)
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "cline",
"device": map[string]interface{}{"code_url": h.ts.URL + "/user_management/authorize/device", "token_url": h.ts.URL + "/user_management/authenticate"},
"register": map[string]interface{}{"url": h.ts.URL + "/api/v1/auth/register"},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *clineHarness) begin() map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"cline"}`,
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
func (h *clineHarness) poll(id string) map[string]interface{} {
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id},
})
return out.JSON.(map[string]interface{})
}
// runWorkosTable 是 WorkOS(Cline)分支的行为契约,供变异测试杀 workos 变异体。
// 决策表 5 行,跟 TestClineWorkOSBehavior 同源(这里只返回布尔,不报细节)。
func (h *clineHarness) runWorkosTable() bool {
st := func(steps ...string) map[string]interface{} {
h.tok.Store(scriptSteps(steps...))
atomic.StoreInt32(&h.step, 0)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
return h.poll(sid)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
// 行1: 非200+error=authorization_pending → 等
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
return false
}
// 行2: 非200+error=slow_down → 等 + interval+1(基线 1)
h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`))
atomic.StoreInt32(&h.step, 0)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
if j2 := h.poll(sid); j2["state"] != "pending" {
return false
} else if iv, _ := j2["interval"].(float64); iv != 2 {
return false
}
// 行3: error=access_denied → failed
if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" {
return false
}
// 行4: 成功(access+refresh)→ register 换 CLINE token → completed
if j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`); j["state"] != "completed" || j["account"] != "dev@example.com" {
return false
}
v, _, _ := h.ps.GetSecret("channels", "channels:cline")
if v != "CLINE-AT-1" {
return false
}
// 行5: 非200无error字段 → failed(不是死等)
if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" {
return false
}
return true
}
// TestClineWorkOSBehavior:WorkOS 判据(error 字段)+ register 两步 + slow_down +1s。
func TestClineWorkOSBehavior(t *testing.T) {
h := newClineHarness(t)
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
st := func(steps ...string) map[string]interface{} {
h.tok.Store(scriptSteps(steps...))
atomic.StoreInt32(&h.step, 0)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
return h.poll(sid)
}
t.Run("行1 error=authorization_pending_非200也继续等", func(t *testing.T) {
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
t.Fatalf("应 pending: %v", j)
}
})
t.Run("行2 error=slow_down_退避1秒", func(t *testing.T) {
h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`))
atomic.StoreInt32(&h.step, 0)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
j2 := h.poll(sid)
if j2["state"] != "pending" {
t.Fatalf("应 pending: %v", j2)
}
if iv, _ := j2["interval"].(float64); iv != 2 { // 基线 1 + slow_down_step 1
t.Fatalf("WorkOS slow_down 应 interval+1s 得 2, 实得 %v", iv)
}
})
t.Run("行3 error=access_denied_失败带拒绝", func(t *testing.T) {
if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" {
t.Fatalf("应 failed: %v", j)
}
})
t.Run("行4 成功_access+refresh_再打register_凭据是CLINE-AT-1", func(t *testing.T) {
j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`)
if j["state"] != "completed" || j["account"] != "dev@example.com" {
t.Fatalf("应 completed+dev@example.com: %v", j)
}
v, _, _ := h.ps.GetSecret("channels", "channels:cline")
if v != "CLINE-AT-1" {
t.Fatalf("凭据应是 register 的 CLINE-AT-1, 实得 %q", v)
}
})
t.Run("行5 非200无error字段_失败", func(t *testing.T) {
if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" {
t.Fatalf("应 failed: %v", j)
}
})
}
// ---- Buddy(CodeBuddy state 轮询) ----
type buddyHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
tokStep int32
tokOk int32
acctOk int32
}
func newBuddyHarness(t *testing.T) *buddyHarness {
t.Helper()
h := &buddyHarness{t: t, tokOk: 1, acctOk: 1}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "POST" && r.URL.Path == "/v2/plugin/auth/state":
fmt.Fprint(w, `{"data":{"state":"ST-1","authUrl":"https://www.codebuddy.cn/login/?platform=ide&state=ST-1"}}`)
case r.Method == "GET" && r.URL.Path == "/v2/plugin/auth/token":
i := atomic.AddInt32(&h.tokStep, 1)
if atomic.LoadInt32(&h.tokOk) == 0 {
w.WriteHeader(200)
fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`)
return
}
if i == 1 {
fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`)
} else {
fmt.Fprint(w, `{"data":{"accessToken":"BD-AT-1","refreshToken":"BD-RT-1"}}`)
}
case r.Method == "GET" && r.URL.Path == "/v2/plugin/login/account":
if atomic.LoadInt32(&h.acctOk) == 0 {
w.WriteHeader(500)
fmt.Fprint(w, `{"code":1}`)
return
}
fmt.Fprint(w, `{"data":{"nickName":"buddy-dev"}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, _ := readBundledChannels(t)
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"state": map[string]interface{}{"state_url": h.ts.URL + "/v2/plugin/auth/state", "token_url": h.ts.URL + "/v2/plugin/auth/token", "account_url": h.ts.URL + "/v2/plugin/login/account"},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *buddyHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`,
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *buddyHarness) poll(id string) map[string]interface{} {
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id},
})
return out.JSON.(map[string]interface{})
}
// TestBuddyStatePollBehavior:state-poll 两拍(先 11217 后 token)+ 账号查询 + account 失败不阻塞。
func TestBuddyStatePollBehavior(t *testing.T) {
h := newBuddyHarness(t)
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
t.Run("行1 state申请给出authUrl", func(t *testing.T) {
j, _ := h.begin()
if j["url"] == "" {
t.Fatalf("应有 authUrl: %v", j)
}
})
t.Run("行2 首拍11217仍pending_次拍token_completed", func(t *testing.T) {
atomic.StoreInt32(&h.tokStep, 0)
_, sid := h.begin()
if j := h.poll(sid); j["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", j)
}
if j := h.poll(sid); j["state"] != "completed" || j["account"] != "buddy-dev" {
t.Fatalf("次拍应 completed+buddy-dev: %v", j)
}
v, _, _ := h.ps.GetSecret("channels", "channels:buddy")
if v != "BD-AT-1" {
t.Fatalf("凭据应 BD-AT-1, 实得 %q", v)
}
})
t.Run("行3 account查询失败不阻塞_回退默认名", func(t *testing.T) {
atomic.StoreInt32(&h.tokStep, 0)
atomic.StoreInt32(&h.acctOk, 0)
defer atomic.StoreInt32(&h.acctOk, 1)
_, sid := h.begin()
h.poll(sid)
j := h.poll(sid)
if j["state"] != "completed" {
t.Fatalf("account 失败不应阻塞: %v", j)
}
})
}
// ---- 通用 ----
func scriptSteps(steps ...string) []scriptStep {
var out []scriptStep
for _, b := range steps {
code := 200
switch {
case strings.HasPrefix(b, "400 "):
code, b = 400, b[4:]
case strings.HasPrefix(b, "403 "):
code, b = 403, b[4:]
case strings.HasPrefix(b, "500 "):
code, b = 500, b[4:]
}
out = append(out, scriptStep{http: code, body: b})
}
return out
}

View File

@ -0,0 +1,241 @@
package core
import (
"encoding/json"
"testing"
"llmsproxy/internal/lua"
)
// TestChannelsPluginEndToEnd 是「同类插件」的完整验收:
// 内置的 channels 插件在**真实内核**上跑通「登录 → 存凭据 → 接入为上游源」。
//
// 这条链路横跨本次所有新增能力,所以它是最有信息量的一个测试:
//
// __llmsproxy_kernel add_source 把平台注册为上游源
// 内核权限 默认拒绝,需运维授权
// 额外出站主机授权 登录域不在源列表里,默认被出站白名单拒绝(plugin.hosts)
// 登录会话 插件自持(plugin.state.sessions),内核无登录概念
func TestChannelsPluginEndToEnd(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatalf("读取内置 channels 插件: %v", err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatalf("加载 channels: %v", err)
}
// 1. 平台清单可用(未授权任何东西也应能列出)
out, err := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{
Method: "GET", Role: "admin",
})
if err != nil {
t.Fatalf("list_platforms: %v", err)
}
body, _ := out.JSON.(map[string]interface{})
plats, _ := body["platforms"].([]interface{})
if len(plats) < 3 {
t.Fatalf("应至少 3 个平台, 实得 %d", len(plats))
}
// 2. 发起登录 → 拿到会话与 URL
out, err = ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"example"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
m := out.JSON.(map[string]interface{})
if m["error"] != nil {
t.Fatalf("发起登录失败: %v", m["error"])
}
if m["url"] == nil || m["url"] == "" {
t.Fatal("应返回用户要打开的登录 URL")
}
sess := m["session"].(map[string]interface{})
id, _ := sess["id"].(string)
if id == "" {
t.Fatal("应返回会话 id")
}
// 3. 轮询到 pending
out, _ = ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id}, Role: "admin",
})
if st := out.JSON.(map[string]interface{})["state"]; st != "pending" {
t.Fatalf("应为 pending, 实得 %v", st)
}
// 4. 未授权内核动作时,接入源必须失败且**如实报告**(不是假装成功)
out, err = ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"example","credential":"tok-1","account":"alice"}`, Role: "admin",
})
if err != nil {
t.Fatalf("complete_login: %v", err)
}
done := out.JSON.(map[string]interface{})
if done["source_error"] == nil {
t.Fatal("未授权 add_source 时应报告 source_error")
}
t.Logf("未授权时的如实报告: %v", done["source_error"])
// 凭据必须已在保险库(登录本身成功了)
tok, ok, _ := ps.GetSecret("channels", "channels:example")
if !ok || tok != "tok-1" {
t.Fatalf("凭据应已存保险库: ok=%v tok=%q", ok, tok)
}
// 且不得出现在 state 里
if stateHasSecret(ps, "channels", "tok-1") {
t.Fatal("★ 凭据泄漏进了 plugin.state")
}
// 5. 运维授权后,重新接入应当真的把源注册进内核
if err := ps.SetPluginKernelPermission("channels",
[]string{"add_source", "list_sources", "status"}, false); err != nil {
t.Fatal(err)
}
out, err = ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"example","credential":"tok-1","account":"alice"}`, Role: "admin",
})
if err != nil {
t.Fatalf("complete_login(授权后): %v", err)
}
done = out.JSON.(map[string]interface{})
if done["source_error"] != nil {
t.Fatalf("授权后仍失败: %v", done["source_error"])
}
if done["source"] != "channels-example" {
t.Fatalf("源名不对: %v", done["source"])
}
// 内核侧必须真的有这个源
found := false
for _, s := range c.Sources() {
if s.Name == "channels-example" {
found = true
if s.BaseURL != "https://api.example.com/v1" {
t.Errorf("base_url 未落库: %q", s.BaseURL)
}
}
}
if !found {
t.Fatal("插件接入的源没有出现在内核源列表里")
}
// 平台声明的模型应可路由
models := c.Registry().ModelList()
hasModel := false
for _, id := range models {
if id == "example-chat" {
hasModel = true
}
}
if !hasModel {
t.Errorf("平台模型未进入可路由列表: %v", models)
}
t.Logf("已接入源 channels-example,模型可见: %v", models)
}
// TestChannelsPluginDeclaresItsNeeds 断言插件如实声明了它需要什么。
//
// 声明是运维审批的依据:插件不声明,设置页就无法显示"申请中",
// 运维只能等某个功能静默失效后才发现。
func TestChannelsPluginDeclaresItsNeeds(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 内核动作声明
views := ps.KernelPermissions()
var declared []string
for _, v := range views {
if v.Plugin == "channels" {
declared = v.Declared
}
}
if len(declared) == 0 {
t.Fatal("channels 应声明它需要的内核动作")
}
for _, want := range []string{"add_source", "status"} {
found := false
for _, d := range declared {
if d == want {
found = true
}
}
if !found {
t.Errorf("应声明 %q, 实得 %v", want, declared)
}
}
// 额外出站主机声明(非源 host,需单独批准)
hosts := ps.PluginExtraHosts("channels")
if len(hosts) == 0 {
t.Fatal("channels 应声明额外出站主机")
}
t.Logf("声明内核动作=%v 额外主机=%v", declared, hosts)
}
// TestChannelsPluginHandlersRequireStatus 是一个契约回归:插件的每个 handler
// 都必须返回 {status,json/body},否则内核会**静默丢弃**它(实测踩过:
// `return {url=…}` 得到 200 空响应,没有任何错误信息)。
func TestChannelsPluginHandlersRequireStatus(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
// 每个 handler 至少要在正常输入下给出非空响应
cases := []struct {
fn string
req *lua.HandlerRequest
what string
}{
{"list_platforms", &lua.HandlerRequest{Method: "GET"}, "平台列表"},
{"status_page", &lua.HandlerRequest{Method: "GET"}, "状态页"},
{"poll_login", &lua.HandlerRequest{Method: "GET", Query: map[string]string{"id": "nope"}}, "轮询(未知会话)"},
{"start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"example"}`}, "发起登录"},
{"start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"nope"}`}, "发起登录(未知平台)"},
{"complete_login", &lua.HandlerRequest{Method: "POST", Body: `{}`}, "完成登录(缺字段)"},
{"claim_daily", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"example"}`}, "领额度(未登录)"},
}
for _, tc := range cases {
out, err := ps.InvokeHandler("channels", tc.fn, tc.req)
if err != nil {
t.Errorf("%s (%s) 返回错误: %v", tc.fn, tc.what, err)
continue
}
if out.JSON == nil && out.Body == "" {
t.Errorf("%s (%s) 返回空响应 —— 内核会静默丢弃,调用方看不到任何原因",
tc.fn, tc.what)
}
}
}
// jsonMarshalForTest 序列化一个接口值,用于检查凭据是否出现在 state 里。
func jsonMarshalForTest(v interface{}) ([]byte, error) { return json.Marshal(v) }
// stateHasSecret 检查凭据是否泄漏进 plugin.state。
func stateHasSecret(ps *lua.Plugins, plugin, secret string) bool {
st := ps.State(plugin)
if st == nil {
return false
}
b, err := jsonMarshalForTest(st)
if err != nil {
return false
}
return contains(string(b), secret)
}

View File

@ -0,0 +1,322 @@
package core
import (
"strings"
"testing"
"llmsproxy/internal/lua"
)
// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。
// 账号池增删改、上游源随 enabled/模型开关同步、供应商整体开关、备份导出。
func poolCore(t *testing.T) (*Core, *lua.Plugins) {
t.Helper()
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "remove_source", "status", "probe_all", "reset_health"}, false)
return c, ps
}
// listSourcesViaKernel 直查内核 list_sources(actionListSources 返回 []map)。
func listSourcesViaKernel(t *testing.T, c *Core) []map[string]interface{} {
t.Helper()
out, err := c.Invoke(nil, "list_sources", map[string]interface{}{})
if err != nil {
t.Fatalf("list_sources: %v", err)
}
if ms, ok := out.([]map[string]interface{}); ok {
return ms
}
raw, _ := out.([]interface{})
var srcs []map[string]interface{}
for _, s := range raw {
if m, ok := s.(map[string]interface{}); ok {
srcs = append(srcs, m)
}
}
return srcs
}
func invoke(t *testing.T, ps *lua.Plugins, fn, body string) map[string]interface{} {
t.Helper()
out, err := ps.InvokeHandler("channels", fn, &lua.HandlerRequest{Method: "POST", Body: body})
if err != nil {
t.Fatalf("invoke %s: %v", fn, err)
}
return out.JSON.(map[string]interface{})
}
// asArr 规整桥接后的数组。Lua 空表过桥是 map[string]interface{}(无法与空数组
// 区分,见 vm.go luaTableToGoValue);内核 list_sources 的 models 过桥是 []string。
func asArr(v interface{}) []interface{} {
switch a := v.(type) {
case []interface{}:
return a
case []string:
out := make([]interface{}, len(a))
for i, s := range a {
out[i] = s
}
return out
}
return nil
}
func srcHasAccount(srcs []map[string]interface{}, needle string) (map[string]interface{}, bool) {
for _, sm := range srcs {
if name, _ := sm["name"].(string); strings.Contains(name, needle) {
return sm, true
}
}
return nil, false
}
// TestChannelsAccountPoolBehavior:账号池 CRUD + 上游源同步 + 模型/供应商开关 + 备份。
func TestChannelsAccountPoolBehavior(t *testing.T) {
c, ps := poolCore(t)
t.Run("账号增删改", func(t *testing.T) {
m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"我的号"}`)
if m["ok"] != true {
t.Fatalf("account_add: %v", m)
}
aid, _ := m["account_id"].(string)
if aid == "" {
t.Fatalf("account_add 应返回 account_id: %v", m)
}
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
accts := asArr(m["accounts"])
if len(accts) != 1 {
t.Fatalf("应有1个账号: %d", len(accts))
}
a := accts[0].(map[string]interface{})
if a["nickname"] != "我的号" || a["enabled"] != true {
t.Fatalf("账号字段: %v", a)
}
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"nickname":"改名"}}`)
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
if asArr(m["accounts"])[0].(map[string]interface{})["nickname"] != "改名" {
t.Fatalf("改名失败")
}
invoke(t, ps, "account_delete", `{"platform":"minimax","account_id":"`+aid+`"}`)
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
if n := len(asArr(m["accounts"])); n != 0 {
t.Fatalf("删除后应为0, 实得 %d", n)
}
})
t.Run("模型开关同步上游源", func(t *testing.T) {
m := invoke(t, ps, "account_add", `{"platform":"minimax","credential":"MM-KEY"}`)
aid, _ := m["account_id"].(string)
sm, ok := srcHasAccount(listSourcesViaKernel(t, c), aid)
if !ok {
t.Fatal("带凭据账号应已同步源")
}
if n := len(asArr(sm["models"])); n != 3 {
t.Fatalf("源应有3模型, 实得 %d", n)
}
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid)
if n := len(asArr(sm["models"])); n != 2 {
t.Fatalf("关1模型后源应2模型, 实得 %d", n)
}
invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":true}`)
m = invoke(t, ps, "provider_status", `{}`)
for _, s := range asArr(m["statuses"]) {
x := s.(map[string]interface{})
if x["id"] == "minimax" && x["closed"] != true {
t.Fatalf("全关模型后应 closed=true: %v", x)
}
}
invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":false}`)
})
t.Run("enabled开关同步上游源", func(t *testing.T) {
m := invoke(t, ps, "account_list", `{"platform":"minimax"}`)
accts := asArr(m["accounts"])
if len(accts) == 0 {
t.Fatal("上个用例应留有账号")
}
aid := accts[0].(map[string]interface{})["id"].(string)
before := len(listSourcesViaKernel(t, c))
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":false}}`)
if n := len(listSourcesViaKernel(t, c)); n != before-1 {
t.Fatalf("停用账号应删源: 前 %d 后 %d", before, n)
}
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":true}}`)
if n := len(listSourcesViaKernel(t, c)); n != before {
t.Fatalf("启用账号应重建源: 前 %d 后 %d", before, n)
}
})
t.Run("供应商整体开关", func(t *testing.T) {
invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":false}`)
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); ok {
t.Fatal("关闭渠道后不应有 minimax 源")
}
m := invoke(t, ps, "provider_status", `{}`)
for _, s := range asArr(m["statuses"]) {
x := s.(map[string]interface{})
if x["id"] == "minimax" && x["closed"] != true {
t.Fatalf("关闭后应 closed: %v", x)
}
}
invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":true}`)
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok {
t.Fatal("开启渠道后应有 minimax 源")
}
})
t.Run("备份导出含凭据", func(t *testing.T) {
invoke(t, ps, "account_add", `{"platform":"cline","credential":"CL-KEY","nickname":"cl1"}`)
exp := invoke(t, ps, "backup_export", `{}`)
payload, _ := exp["payload"].(map[string]interface{})
if payload == nil {
t.Fatal("backup_export 应返回 payload")
}
creds, _ := payload["credentials"].(map[string]interface{})
found := false
for _, v := range creds {
if v == "CL-KEY" {
found = true
}
}
if !found {
t.Fatalf("备份应含凭据明文: %v", creds)
}
})
}
// TestChannelsExtendedRPCBehavior:对齐参考剩余 RPC 面(credits.status/balances、
// account.reset/retest、model.setDisabledMany/clearDead、provider 排序、
// gateway/backup.status、usage.badgePreference、onboarding)。
func TestChannelsExtendedRPCBehavior(t *testing.T) {
_, ps := poolCore(t)
t.Run("credits_balances_无凭据报错_有凭据解析", func(t *testing.T) {
m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"b1"}`)
aid, _ := m["account_id"].(string)
j := invoke(t, ps, "credits_balances", `{"platform":"minimax"}`)
var row map[string]interface{}
for _, a := range asArr(j["accounts"]) {
x := a.(map[string]interface{})
if x["accountId"] == aid {
row = x
}
}
if row == nil {
t.Fatal("balances 应含该账号")
}
if row["balance"] != nil || row["error"] == nil {
t.Fatalf("无凭据应 error 而非编造余额: %v", row)
}
})
t.Run("credits_status_无端点平台如实null", func(t *testing.T) {
m := invoke(t, ps, "account_add", `{"platform":"opencode","nickname":"oc1"}`)
aid, _ := m["account_id"].(string)
j := invoke(t, ps, "credits_status", `{"platform":"opencode"}`)
for _, a := range asArr(j["accounts"]) {
x := a.(map[string]interface{})
if x["accountId"] == aid && x["status"] != nil {
t.Fatalf("opencode 无状态端点应 null: %v", x)
}
}
})
t.Run("account_reset_clears", func(t *testing.T) {
invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"r1"}`)
j := invoke(t, ps, "account_reset", `{"platform":"minimax","account_id":"__all__"}`)
if j["ok"] != true {
t.Fatalf("resetAll: %v", j)
}
})
t.Run("model_set_disabled_many", func(t *testing.T) {
invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":true}`)
j := invoke(t, ps, "model_list", `{"platform":"minimax"}`)
off := 0
for _, x := range asArr(j["models"]) {
mm := x.(map[string]interface{})
if mm["disabled"] == true {
off++
}
}
if off != 2 {
t.Fatalf("批量关2模型后应2个disabled, 实得 %d", off)
}
invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":false}`)
})
t.Run("model_clear_dead_只清黑名单里已下线的", func(t *testing.T) {
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
j := invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`)
if n := len(asArr(j["removed"])); n != 0 {
t.Fatalf("在册模型不算死模型, 实得 %d", n)
}
// 手动放一个不在 models 表里的黑名单项
invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["ghost-model"],"disabled":true}`)
j = invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`)
if n := len(asArr(j["removed"])); n != 1 {
t.Fatalf("死模型应被清: %v", j)
}
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":false}`)
})
t.Run("provider_order", func(t *testing.T) {
invoke(t, ps, "provider_set_order", `{"order":["minimax","trae","bogus"]}`)
j := invoke(t, ps, "provider_get_order", `{}`)
order := asArr(j["order"])
if len(order) != 2 {
t.Fatalf("bogus 应被过滤, 实得 %v", order)
}
if order[0] != "minimax" {
t.Fatalf("顺序应保留: %v", order)
}
})
t.Run("backup_status", func(t *testing.T) {
invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"s1"}`)
j := invoke(t, ps, "backup_status", `{}`)
if n, _ := j["accounts"].(float64); n < 1 {
t.Fatalf("backup_status 应统计账号: %v", j)
}
})
t.Run("badge_preference_非法值拒绝", func(t *testing.T) {
j := invoke(t, ps, "usage_badge_preference", `{"preference":"bogus"}`)
if j["error"] == nil {
t.Fatalf("非法偏好应拒绝: %v", j)
}
j = invoke(t, ps, "usage_badge_preference", `{"preference":"credits"}`)
if j["preference"] != "credits" {
t.Fatalf("合法偏好应保存: %v", j)
}
})
t.Run("onboarding_不支持平台如实报", func(t *testing.T) {
j := invoke(t, ps, "onboarding_status", `{"platform":"minimax"}`)
if j["supported"] != false {
t.Fatalf("minimax 无新人任务应 supported=false: %v", j)
}
})
t.Run("permanent_lock_读写", func(t *testing.T) {
j := invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy","locked":true}`)
if j["locked"] != true {
t.Fatalf("锁定应生效: %v", j)
}
j = invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy"}`)
if j["locked"] != true {
t.Fatalf("只读应回显锁定: %v", j)
}
})
}

View File

@ -0,0 +1,251 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
)
// mustBox 构造一个真 SecretBox(与运行期同一实现,密钥落 runtime dir)。
func mustBox(t *testing.T, runtimeFile string) *config.SecretBox {
t.Helper()
box, err := config.NewSecretBox(runtimeFile)
if err != nil {
t.Fatal(err)
}
return box
}
// channelsCore 起一个带 channels 插件、可指定源码与 box 的核心(压力测试用)。
func channelsCore(t *testing.T, src string) (*Core, *lua.Plugins) {
t.Helper()
c := kernelTestCore(t)
ps := c.Plugins()
if err := ps.LoadSource("channels", src); err != nil {
t.Fatalf("加载 channels: %v", err)
}
ps.SetSecretBox(mustBox(t, filepath.Join(t.TempDir(), "runtime.json")))
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
return c, ps
}
// TestChannelsConcurrentLogins 压力:并发走 device-poll 登录。
//
// 插件是单 VM 单锁 —— 本测试验证的是**正确性下的并发**(会话互不串台、
// 凭据互不可见、最终都 completed),真吞吐压力在 e2e 层(真二进制+HTTP 并发)。
func TestChannelsConcurrentLogins(t *testing.T) {
var hits int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth2/device/code":
n := atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"device_code":"DEV-%d","user_code":"UC-%d","verification_uri":"https://example/activate","interval":1,"expires_in":600}`, n, n)
case "/oauth2/token":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"access_token":"TOK","refresh_token":"RF","nickname":"u"}`)
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
_, ps := channelsCore(t, mustChannelsSrc(t))
// 把 minimax 端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "minimax",
"device": map[string]interface{}{
"code_url": ts.URL + "/oauth2/device/code",
"token_url": ts.URL + "/oauth2/token",
},
}); err != nil {
t.Fatal(err)
}
const rounds = 12
var wg sync.WaitGroup
var failures int32
for i := 0; i < rounds; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"minimax"}`,
})
if err != nil {
t.Errorf("start_login #%d: %v", n, err)
atomic.AddInt32(&failures, 1)
return
}
j := out.JSON.(map[string]interface{})
sess, _ := j["session"].(map[string]interface{})
if sess == nil {
t.Errorf("start_login #%d 无会话: %v", n, j)
atomic.AddInt32(&failures, 1)
return
}
sid := sess["id"].(string)
for k := 0; k < 10; k++ {
out2, _ := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid},
})
m2 := out2.JSON.(map[string]interface{})
if m2["state"] == "completed" {
return
}
if m2["state"] == "failed" {
t.Errorf("会话 #%d failed: %v", n, m2)
atomic.AddInt32(&failures, 1)
return
}
}
t.Errorf("会话 #%d 10 次轮询未终态", n)
atomic.AddInt32(&failures, 1)
}(i)
}
wg.Wait()
if failures != 0 {
t.Fatalf("并发登录失败 %d 轮", failures)
}
}
// TestChannelsClaimStress 压力:同一平台并发领签到(幂等 + 无登录态丢失)。
func TestChannelsClaimStress(t *testing.T) {
var claims int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/checkin" {
atomic.AddInt32(&claims, 1)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"ok":true,"credits":10}`)
return
}
w.WriteHeader(404)
fmt.Fprint(w, `{}`)
}))
defer ts.Close()
_, ps := channelsCore(t, mustChannelsSrc(t))
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "cookieish",
"checkin": map[string]interface{}{"url": ts.URL + "/v1/checkin"},
}); err != nil {
t.Fatal(err)
}
if err := ps.SetSecret("channels", "channels:cookieish", "PASTE-TOKEN"); err != nil {
t.Fatal(err)
}
const n = 30
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(k int) {
defer wg.Done()
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"cookieish"}`,
})
if err != nil {
t.Errorf("claim #%d: %v", k, err)
return
}
j := out.JSON.(map[string]interface{})
if j["ok"] != true {
t.Errorf("claim #%d 未成功: %v", k, j)
}
}(i)
}
wg.Wait()
if got := atomic.LoadInt32(&claims); got != n {
t.Errorf("上游应收到 %d 次领取, 实得 %d", n, got)
}
}
// TestLegacyVaultPlaintextLoad 旧通路:老版本(无加密)写的明文库在新版本
// (带 box)下必须能读 —— 凭据升级不丢。落盘则会在下次写时整体重封为密文。
func TestLegacyVaultPlaintextLoad(t *testing.T) {
dir := t.TempDir()
ps := lua.NewPlugins(nil, dir)
if err := ps.SetSecret("legacy", "channels:old", "PLAIN-OLD-TOKEN"); err != nil {
t.Fatal(err)
}
ps.SetSecretBox(mustBox(t, filepath.Join(dir, "runtime.json")))
v, ok, err := ps.GetSecret("legacy", "channels:old")
if err != nil || !ok || v != "PLAIN-OLD-TOKEN" {
t.Fatalf("旧明文库读回失败: v=%q ok=%v err=%v", v, ok, err)
}
// 任意一次写触发整体重封
if err := ps.SetSecret("legacy", "k2", "NEW"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(dir, ".secrets", "legacy.json"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "PLAIN-OLD-TOKEN") {
t.Fatalf("★ 重封后旧明文仍在盘上:\n%s", raw)
}
if !strings.Contains(string(raw), "enc:v1:") {
t.Fatalf("重封后应含密文:\n%s", raw)
}
}
// TestLegacySourceSealedConfig 旧通路:config.yaml 里明文 api_key 的旧配置,
// 新版本加载后运行期拿到解密值、落盘被 seal(SealIfNeeded 语义)。
func TestLegacySourceSealedConfig(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")
yaml := "listen: 127.0.0.1:0\n" +
"gateway_keys:\n - sk-legacy-0001\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"sources:\n" +
" - name: legacy-src\n" +
" base_url: https://legacy.example.com/v1\n" +
" adapter: openai\n" +
" api_key: PLAIN-LEGACY-KEY\n" +
" max_concurrent: 2\n" +
" models:\n" +
" - id: m1\n" +
" kind: chat\n" +
" priority: 10\n"
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
c, err := NewFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
got := c.Sources()
if len(got) != 1 || got[0].APIKey != "PLAIN-LEGACY-KEY" {
t.Fatalf("运行期 api_key 应解密: %#v", got)
}
raw, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "PLAIN-LEGACY-KEY") {
t.Fatalf("★ 旧明文 api_key 升级后仍明文落盘:\n%s", raw)
}
if !strings.Contains(string(raw), "enc:v1:") {
t.Fatalf("落盘应含 enc:v1 密文:\n%s", raw)
}
mk, err := os.ReadFile(filepath.Join(dir, "master.key"))
if err != nil || len(mk) != 64 {
t.Fatalf("master.key 应 64 hex, len=%d err=%v", len(mk), err)
}
}

View File

@ -0,0 +1,173 @@
package core
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// traeHarness:TRAE callback 流的测试台。假上游模拟 ExchangeToken;
// 回调用 InvokeHandler 直打 oauth_callback(Query 表 = 平台重定向回来的参数)。
type traeHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
exchHit int32
exchOK int32 // 1=成功 0=失败(atomic)
}
func newTraeHarness(t *testing.T) *traeHarness {
t.Helper()
h := &traeHarness{t: t, exchOK: 1}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cloudide/api/v3/trae/oauth/ExchangeToken" {
w.WriteHeader(404)
return
}
n := atomic.AddInt32(&h.exchHit, 1)
w.Header().Set("Content-Type", "application/json")
if atomic.LoadInt32(&h.exchOK) == 0 {
w.WriteHeader(500)
fmt.Fprint(w, `{"error":"exchange down"}`)
return
}
// 校验请求体形状(对齐参考 exchangeTraeCallback 的请求构造)
body, _ := io.ReadAll(r.Body)
for _, want := range []string{`"ClientID"`, `"RefreshToken":"RT-FROM-CALLBACK"`, `"ClientSecret":"-"`} {
if !strings.Contains(string(body), want) {
t.Errorf("ExchangeToken 请求体缺 %s: %s", want, body)
}
}
fmt.Fprintf(w, `{"Token":"ACCESS-%d","RefreshToken":"RT-NEW-%d"}`, n, n)
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
// ExchangeToken 指到假上游(set_endpoints 既是运维能力也是测试缝)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "trae",
"callback": map[string]interface{}{
"exchange_url": h.ts.URL + "/cloudide/api/v3/trae/oauth/ExchangeToken",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
// beginAndCallback 发起登录(拿 nonce)后直接打 oauth_callback。
// cbQuery 是回调的 query(不含 state,内部补);值即平台重定向回来的原始参数。
func (h *traeHarness) beginAndCallback(cbQuery map[string]string) (int, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"trae"}`,
})
if err != nil {
h.t.Fatal(err)
}
j, ok := out.JSON.(map[string]interface{})
if !ok {
h.t.Fatalf("start_login 返回非对象: %#v", out.JSON)
}
sess := j["session"].(map[string]interface{})
nonce := sess["nonce"].(string)
q := map[string]string{"state": nonce}
for k, v := range cbQuery {
q[k] = v
}
res, err := h.ps.InvokeHandler("channels", "oauth_callback", &lua.HandlerRequest{
Method: "GET", Query: q,
})
if err != nil {
h.t.Fatal(err)
}
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
}
// TestTraeCallbackBehavior 是 TRAE callback 流的行为对齐测试。
//
// 决策表逐行搬自参考实现 parseTraeCallbackDetailed + exchangeTraeCallback
// (trae-oauth.ts)。断言的是行为(凭据来源 / 错误文案 / 上游是否被调),不是实现细节。
func TestTraeCallbackBehavior(t *testing.T) {
h := newTraeHarness(t)
t.Run("行1 refreshToken直传_ExchangeToken换新", func(t *testing.T) {
atomic.StoreInt32(&h.exchOK, 1)
st, body := h.beginAndCallback(map[string]string{
"refreshToken": "RT-FROM-CALLBACK",
"userInfo": `{"UserID":"u1","ScreenName":"张三","TenantID":"t1"}`,
})
if st != 200 {
t.Fatalf("应 200: %d %s", st, body)
}
// 凭据必须是 ExchangeToken 的新 Token,不是回调原值
v, _, _ := h.ps.GetSecret("channels", "channels:trae")
if !strings.HasPrefix(v, "ACCESS-") {
t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", v)
}
})
t.Run("行2 无refreshToken_userJwtToken兜底_不打ExchangeToken", func(t *testing.T) {
before := atomic.LoadInt32(&h.exchHit)
st, body := h.beginAndCallback(map[string]string{
"userJwt": `{"Token":"JWT-TOKEN-1","RefreshToken":""}`,
})
if st != 200 {
t.Fatalf("应 200: %d %s", st, body)
}
v, _, _ := h.ps.GetSecret("channels", "channels:trae")
if v != "JWT-TOKEN-1" {
t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %q", v)
}
if atomic.LoadInt32(&h.exchHit) != before {
t.Fatal("无 refreshToken 不应调 ExchangeToken(参考实现分支2)")
}
})
t.Run("行3 只有code_PKCE流_明确报暂不支持", func(t *testing.T) {
st, body := h.beginAndCallback(map[string]string{"code": "PKCE-CODE-1"})
if st != 400 {
t.Fatalf("PKCE 流应 400: %d %s", st, body)
}
if !strings.Contains(body, "PKCE") {
t.Fatalf("错误应明确指向 PKCE 流, 实得 %s", body)
}
})
t.Run("行4 空回调_报缺失", func(t *testing.T) {
st, body := h.beginAndCallback(map[string]string{})
if st != 400 {
t.Fatalf("空回调应 400: %d %s", st, body)
}
if !strings.Contains(body, "refreshToken") {
t.Fatalf("错误应指出缺什么, 实得 %s", body)
}
})
t.Run("行5 ExchangeToken失败_透传阶段", func(t *testing.T) {
atomic.StoreInt32(&h.exchOK, 0)
defer atomic.StoreInt32(&h.exchOK, 1)
st, body := h.beginAndCallback(map[string]string{"refreshToken": "RT-FROM-CALLBACK"})
if st != 400 {
t.Fatalf("exchange 失败应 400: %d %s", st, body)
}
if !strings.Contains(body, "ExchangeToken") {
t.Fatalf("错误应指明 ExchangeToken 阶段, 实得 %s", body)
}
})
}

View File

@ -8,6 +8,7 @@ import (
"encoding/hex"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"sort"
@ -37,6 +38,12 @@ type Core struct {
registry *provider.Registry
autoChain atomic.Pointer[scheduler.Chain] // chat AUTO chain
autoImageChain atomic.Pointer[scheduler.Chain] // image-generation AUTO chain
// keyAutoChains caches per-key AUTO chains, keyed by the gateway key.
// Entries are rebuilt by rebuildKeyAutoChains whenever sources or key
// config change; AutoChainFor reads them without taking c.mu so request
// handling stays lock-free like the global chain lookup.
keyAutoChains atomic.Pointer[map[string]*scheduler.Chain]
}
// New builds the core from a config file plus runtime overlay.
@ -62,6 +69,34 @@ 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.
c.plugins.SetOutboundHosts(sourceHosts(cfg))
// Let the plugin layer validate access specs against the keys that exist.
// A stored allowlist naming a key that does not exist refuses every caller,
// which looks exactly like a broken endpoint.
lua.SetKeyNameLookup(func() []string {
// ListKeys is the structured view (bare `gateway_keys:` entries are
// expanded into records with names); cfg.GatewayKeys is still []string.
keys := c.ListKeys()
out := make([]string, 0, len(keys))
for _, k := range keys {
if k.Name != "" {
out = append(out, k.Name)
}
}
return out
})
// Operator overrides win over the plugin's own declared audience.
for name, aud := range cfg.PluginVisibility {
c.plugins.SetPluginVisibility(name, aud)
}
if err := c.plugins.SeedBundled(); err != nil {
log.Printf("[core] seed bundled plugins: %v", err)
}
@ -96,6 +131,11 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
// only appeared to work because seedKeys' Save() unsealed memory as a side
// effect. Removing the redundant saves exposed the real ordering bug.
cfg.AttachSecretBox(c.store.SecretBox())
// Share that SAME box with the plugin credential vault: one master.key seals
// config.yaml, the runtime store, AND every .secrets/*.json. Set here (not at
// NewPlugins time) because the store — and thus the box — only exists now;
// plugins write secrets only during login flows, well after this point.
c.plugins.SetSecretBox(c.store.SecretBox())
hadPlaintextSecrets, err := cfg.UnsealSecrets(c.store.SecretBox())
if err != nil {
return nil, fmt.Errorf("unseal secrets: %w", err)
@ -257,6 +297,68 @@ func (c *Core) Registry() *provider.Registry { return c.registry }
// AutoChain returns the current AUTO scheduling chain.
func (c *Core) AutoChain() *scheduler.Chain { return c.autoChain.Load() }
// AutoChainFor returns the AUTO chain to use for a request authenticated with
// key. A key that declares its own chain gets that chain; every other key
// inherits the global chain, so keys created before per-key chains existed
// keep their current behaviour with no configuration change.
//
// It returns (chain, ok). ok is false only when the key has its own chain but
// it compiled to nothing usable (e.g. every slot names a model that no longer
// exists) — the caller must surface that rather than silently downgrading the
// user to the global chain, which would be a confusing, invisible switch.
func (c *Core) AutoChainFor(key string) (*scheduler.Chain, bool) {
if m := c.keyAutoChains.Load(); m != nil {
if ch, ok := (*m)[key]; ok {
return ch, true
}
}
return c.autoChain.Load(), true
}
// rebuildKeyAutoChains recompiles every per-key AUTO chain from the current
// config and provider registry, then swaps the cache in one shot so requests
// never observe a half-built map. Keys without their own chain are absent
// from the map and fall back to the global chain.
func (c *Core) rebuildKeyAutoChains() {
m := make(map[string]*scheduler.Chain)
for _, k := range c.cfg.Keys {
if !k.HasOwnAuto() {
continue
}
m[k.Key] = c.chainForRules(k.Auto, false)
}
c.keyAutoChains.Store(&m)
}
// SaveKeyAuto persists one key's own AUTO chain and recompiles the per-key
// chain cache. Passing an empty list clears the override so the key inherits
// the global chain again.
func (c *Core) SaveKeyAuto(key string, entries []config.ModelScope) error {
c.mu.Lock()
defer c.mu.Unlock()
idx := -1
for i, k := range c.cfg.Keys {
if k.Key == key {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("key not found")
}
if err := ValidateScopeQuotas(entries); err != nil {
return err
}
c.cfg.Keys[idx].Auto = cleanScopes(entries)
if err := c.saveConfig(); err != nil {
return err
}
// Rebuild the whole cache so a cleared override stops shadowing the
// global chain for this key.
c.rebuildKeyAutoChains()
return nil
}
// AutoImageChain returns the persisted image-generation AUTO chain snapshot.
func (c *Core) AutoImageChain() *scheduler.Chain { return c.autoImageChain.Load() }
@ -546,26 +648,98 @@ func (c *Core) rebuildRegistry() error {
}
c.buildAutoChain()
c.buildAutoImageChain()
c.rebuildKeyAutoChains()
// A source edit can add or remove an upstream host, which changes what a
// plugin is allowed to reach. Keeping this beside the chain rebuilds means
// one call site owns "config changed, refresh derived state".
c.plugins.SetOutboundHosts(sourceHosts(c.cfg))
return nil
}
// sourceHosts extracts the host of every configured source's base_url — the
// allowlist for plugin outbound HTTP. Unparseable URLs are skipped rather than
// failing startup: a malformed base_url is already rejected when the provider is
// built, and it must not take plugins down with it.
//
// A source may also imply a SECOND host. OpenDesign splits inference
// (amr-link.open-design.ai) from billing (amr-api.open-design.ai), and the
// quota plugin derives the billing host from the inference one — so the host it
// asks for is not the host the operator configured, and the request would be
// refused with a message that reads like a plugin defect. Adding the derived
// host here is what makes the probe reachable.
//
// The grant stays scoped: only a source that IS on amr-link pulls in amr-api, so
// a deployment with no OpenDesign source does not silently gain the entry.
func sourceHosts(cfg *config.Config) []string {
if cfg == nil {
return nil
}
out := make([]string, 0, len(cfg.Sources))
seen := make(map[string]bool, len(cfg.Sources))
add := func(h string) {
if h == "" || seen[h] {
return
}
seen[h] = true
out = append(out, h)
}
for _, s := range cfg.Sources {
if s.BaseURL == "" {
continue
}
u, err := url.Parse(s.BaseURL)
if err != nil || u.Hostname() == "" {
continue
}
add(u.Hostname())
add(deriveBillingHost(u.Hostname()))
}
return out
}
// deriveBillingHost returns the billing host a configured source implies, or
// the host unchanged when it implies none.
//
// Kept in Go as well as in the Lua adapter because the two must agree: Go builds
// the allowlist and Lua builds the request URL, and a disagreement shows up as a
// refused request rather than as an error naming the mismatch. The rule is the
// full hostname, not a prefix — `amr-link.` as a prefix would also rewrite
// amr-link.example.com into an OpenDesign address.
func deriveBillingHost(host string) string {
if host == "amr-link.open-design.ai" {
return "amr-api.open-design.ai"
}
return host
}
// buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules
// against the current providers. Slot model ids are normalized to the exact
// configured spelling (case-insensitive match), otherwise ModelFor would fall
// back to the source's best chat model and cooldown/quota bookkeeping would
// key on a name that never matches.
func (c *Core) buildAutoChain() {
c.autoChain.Store(c.chainForRules(c.cfg.Auto, false))
}
// chainForRules compiles a chat AUTO chain from a rule list. kindFilter is
// false for the chat chain (image models are skipped) and true for the image
// chain (only image models participate). Per-key chains always compile as
// chat chains: a user's key configures the models *they* talk to.
func (c *Core) chainForRules(rules []config.ModelScope, imageOnly bool) *scheduler.Chain {
prov := func(model, source string) scheduler.Provider {
p := c.registry.ProviderForSlot(model, source)
if p == nil {
return nil
}
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
m := p.ModelByID(model)
if imageOnly && (m == nil || m.Kind != "image") {
return nil
}
if !imageOnly && m != nil && m.Kind == "image" {
return nil
}
return p
}
rules := c.cfg.Auto
sr := make([]scheduler.Rule, 0, len(rules))
for _, e := range rules {
model, source := e.Model, e.Source
@ -574,7 +748,11 @@ func (c *Core) buildAutoChain() {
model = exact
}
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
continue // image-kind models never join the chat AUTO chain
if !imageOnly {
continue // image-kind models never join the chat AUTO chain
}
} else if imageOnly {
continue
}
if source == "" {
source = p.Name()
@ -589,7 +767,7 @@ func (c *Core) buildAutoChain() {
Hours: e.Hours,
})
}
c.autoChain.Store(scheduler.BuildChain(sr, prov))
return scheduler.BuildChain(sr, prov)
}
// buildAutoImageChain rebuilds the image-generation AUTO chain snapshot.
@ -706,6 +884,14 @@ func (c *Core) UploadAdapter(name, code string) error {
if name == "" {
return fmt.Errorf("adapter name required")
}
// Validate name SAFETY before touching the filesystem (R6). A path-traversal
// name ("../openai") must be rejected BEFORE WriteFile, or it would already
// have escaped the adapter dir by the time LoadAdapter re-checks. Built-in
// names are allowed here: an admin uploading openai.lua is the documented
// override, same as placing the file in adapter_dir by hand.
if err := lua.ValidateAdapterName(name, nil); err != nil {
return err
}
if err := os.MkdirAll(c.cfg.AdapterDir, 0755); err != nil {
return err
}
@ -717,6 +903,13 @@ func (c *Core) UploadAdapter(name, code string) error {
}
func (c *Core) RemoveAdapter(name string) error {
// Validate the name so a traversal name cannot os.Remove a file outside the
// adapter dir. A reserved (built-in) name is a legitimate delete target in
// the running VM, so reserved-name rejection is NOT applied here — only the
// path-safety character set is.
if err := lua.ValidateAdapterName(name, nil); err != nil {
return err
}
path := filepath.Join(c.cfg.AdapterDir, name+".lua")
_ = os.Remove(path)
c.vm.RemoveAdapter(name)

View File

@ -0,0 +1,180 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// readBundledChannels 读内置 channels 插件源(供行为/变异测试共用)。
func readBundledChannels(t *testing.T) (string, error) {
t.Helper()
return lua.ReadBundledPlugin("channels")
}
// deviceHarness 是 device-poll 决策表的可复用测试台:假上游(脚本化逐次响应)
// + channels 插件加载指定源码 + minimax 端点指到假上游。
// 行为测试(TestChannelsDevicePollBehavior)与变异测试共用它 —— 决策表只有
// 一份,两处不会漂移。
type deviceHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
step int32
// script 持续被 HTTP handler 原子读取
script atomic.Value // []scriptStep
}
type scriptStep struct {
http int
body string
}
func newDeviceHarness(t *testing.T, src string) *deviceHarness {
t.Helper()
h := &deviceHarness{t: t}
h.script.Store([]scriptStep{})
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth2/device/code":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"device_code":"DEV1","user_code":"ABCD-EFGH","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/oauth2/token":
i := int(atomic.AddInt32(&h.step, 1)) - 1
script := h.script.Load().([]scriptStep)
w.Header().Set("Content-Type", "application/json")
if i >= len(script) {
w.WriteHeader(500)
fmt.Fprint(w, `{"error":"unexpected"}`)
return
}
if script[i].http != 0 && script[i].http != 200 {
w.WriteHeader(script[i].http)
}
fmt.Fprint(w, script[i].body)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatalf("加载: %v", err)
}
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "minimax",
"device": map[string]interface{}{
"code_url": h.ts.URL + "/oauth2/device/code",
"token_url": h.ts.URL + "/oauth2/token",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
// setScript 设置 token 端点的逐次响应脚本。`400 {...}` 形式表示非 200。
func (h *deviceHarness) setScript(steps ...string) {
var out []scriptStep
for _, b := range steps {
code := 200
switch {
case strings.HasPrefix(b, "400 "):
code, b = 400, b[4:]
case strings.HasPrefix(b, "403 "):
code, b = 403, b[4:]
}
out = append(out, scriptStep{http: code, body: b})
}
h.script.Store(out)
atomic.StoreInt32(&h.step, 0)
}
func (h *deviceHarness) begin() map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"minimax"}`,
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
func (h *deviceHarness) poll(id string) map[string]interface{} {
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id},
})
return out.JSON.(map[string]interface{})
}
// runDecisionTable 跑完整 8 行决策表,全部符合预期返回 true。
// 这是行为契约的单一事实来源:行为测试用它断言通过;变异测试用它杀变异体。
func (h *deviceHarness) runDecisionTable() bool {
ok := func(cond bool) bool { return cond }
st := func(steps ...string) map[string]interface{} {
h.setScript(steps...)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
return h.poll(sid)
}
// 行1: 200+pending → 等
if j := st(`{"status":"pending"}`); j["state"] != "pending" {
return false
}
// 行2: 200+slow_down → 等 + interval+5
h.setScript(`{"status":"slow_down"}`)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
if j2 := h.poll(sid); j2["state"] != "pending" {
return false
} else if iv, _ := j2["interval"].(float64); iv < 6 {
return false
}
// 行3: 200+denied → failed,message 区分于通用错误(杀"denied 不识别"变异体)
if j := st(`{"status":"denied"}`); j["state"] != "failed" {
return false
} else if msg, _ := j["message"].(string); !strings.Contains(msg, "用户拒绝") {
return false
}
// 行4: 200+expired_token → failed,message 带过期字样
if j := st(`{"status":"expired_token"}`); j["state"] != "failed" {
return false
} else if msg, _ := j["message"].(string); !strings.Contains(msg, "过期") {
return false
}
// 行5: 400+authorization_pending → 等
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
return false
}
// 行6: 403+slow_down → 等 + interval+5
h.setScript(`403 {"error":"slow_down"}`)
j = h.begin()
sid = j["session"].(map[string]interface{})["id"].(string)
if j2 := h.poll(sid); j2["state"] != "pending" {
return false
} else if iv, _ := j2["interval"].(float64); iv < 6 {
return false
}
// 行7: 200+access_token → completed(需先授权 add_source)
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
h.setScript(`{"access_token":"MM-ACCESS-1","refresh_token":"MM-RF-1","nickname":"mm-user"}`)
j = h.begin()
sid = j["session"].(map[string]interface{})["id"].(string)
if j2 := h.poll(sid); j2["state"] != "completed" || j2["account"] != "mm-user" {
return false
}
// 行8: 其它 error → failed 带文本
if j := st(`{"error":"server_error"}`); j["state"] != "failed" {
return false
}
return ok(true)
}

View File

@ -0,0 +1,73 @@
package core
import (
"strings"
"testing"
)
// TestDevicePollMutants 是 device-poll 执行器的变异测试。
//
// 用户要求:移植要照参考实现的行为形成测试项,且要变异验证测试真的能杀掉
// 「行为走样」的代码。每个用例把 channels 源里的一个决策分支做语义变异,
// 断言决策表**会失败**(变异体被杀)。变异体幸存 = 该分支没有覆盖。
func TestDevicePollMutants(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// 锚点会命中 deviceOneStep 里两处同形代码(workos 分支 + minimax 分支),
// 全部替换:两个决策表(minimax + workos)都会杀,任一幸存即失败。
muts := []struct {
name string
from string
to string
}{
{
name: "slow_down 不再加 interval",
from: `sess.device_interval = (sess.device_interval or d.interval or 5) + step`,
to: `sess.device_interval = (sess.device_interval or d.interval or 5)`,
},
{
name: "denied 被当成继续等待",
from: `if resp.status == 200 and (status == "denied" or status == "access_denied") then`,
to: `if resp.status == 200 and (status == "denied_x" or status == "access_denied") then`,
},
{
name: "expired_token 不被识别",
from: `if resp.status == 200 and (status == "expired" or status == "expired_token") then`,
to: `if resp.status == 200 and (status == "expired" or status == "expired_token_x") then`,
},
{
name: "authorization_pending 被当成失败",
from: `if ecode == "authorization_pending" then return false end`,
to: `if ecode == "authorization_pending_x" then return false end`,
},
{
name: "成功分支被禁用",
from: `if resp.status == 200 and rec.access_token then`,
to: `if resp.status == 200 and rec.access_token and false then`,
},
}
for _, mu := range muts {
t.Run(mu.name, func(t *testing.T) {
if !strings.Contains(src, mu.from) {
t.Fatalf("锚点丢失,先更新实现与锚点:%q", mu.from)
}
// 锚点在 deviceOneStep 的 workos/minimax 两个分支各有一份同形代码,
// 全部替换:任一分支的变异体被任一决策表杀掉才算有覆盖。
mutated := strings.ReplaceAll(src, mu.from, mu.to)
if mutated == src {
t.Fatal("变异未生效")
}
// 基准必须全过(测试自身有效),变异体必须被杀(有覆盖)
if !newDeviceHarness(t, src).runDecisionTable() {
t.Fatal("基准决策表未全过 —— 测试自身失效")
}
if newDeviceHarness(t, mutated).runDecisionTable() {
t.Fatalf("★ 变异体幸存(%s)—— 该分支无覆盖", mu.name)
}
})
}
}

View File

@ -0,0 +1,396 @@
package core
import (
"context"
"encoding/json"
"fmt"
"strings"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
)
// 内核动作实现(R4-c)
//
// 把 Core 的公开动作暴露给插件 Lua,走 lua.KernelAPI 接口注入(依赖方向是
// core → lua,所以接口定义在 lua 包、实现放在这里)。
//
// # 为什么是"动作面"而不是"给插件一个 Core 指针"
//
// 直接暴露 *Core 会让插件能碰任何未导出的细节,而且每一次内核重构都会变成
// 对插件作者的破坏性变更。动作面是一份**显式的契约**:每个动作有名字、危险级、
// 超时预算,加动作是一次有意的行为,而不是顺手多导出一个方法。
//
// # 凭据边界
//
// 这里**不实现** list_keys / find_key。Core.ListKeys() 返回的是**明文**网关
// 密钥,把它交给第三方 Lua 等于把部署的整个凭据面交出去。key 相关的读走掩码
// 形态(create_key 的返回是唯一一次明文,且是"刚创建的那一个")。
var _ lua.KernelAPI = (*Core)(nil)
// Actions implements lua.KernelAPI.
//
// The catalog lives in the lua package because permissions must be checkable even
// when no Core is wired. This method exists so the interface is satisfied by the
// real kernel rather than by a separate adapter type.
func (c *Core) Actions() []lua.KernelAction { return lua.KernelActions() }
// Invoke implements lua.KernelAPI. It dispatches one action.
//
// Every action is expected to honour ctx. The caller (lua.callKernel) gives up on
// a deadline and returns an error to the plugin, but a kernel action that keeps
// running afterwards would be an invisible leak, so the long ones (probe_all)
// pass ctx down into the network layer.
func (c *Core) Invoke(ctx context.Context, name string, args map[string]interface{}) (interface{}, error) {
switch name {
// ---- 只读 ----
case "status":
return c.actionStatus(), nil
case "list_sources":
return c.actionListSources(), nil
case "list_models":
return c.actionListModels(), nil
case "list_adapters":
return c.actionListAdapters(), nil
case "list_auto_rules":
return c.actionListAutoRules(), nil
case "adapter_owner":
return c.actionAdapterOwner(args)
// ---- 写 ----
case "reload":
if err := ctx.Err(); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true}, c.Reload()
case "reset_health":
// ResetHealth already no-ops on a nil registry (it ranges over
// Providers()), so this needs no guard beyond the nil check inside.
c.ResetHealth()
return map[string]interface{}{"ok": true}, nil
case "probe_all":
r := c.Registry()
if r == nil {
return nil, fmt.Errorf("the registry is not built yet")
}
r.ProbeAll(ctx)
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("probe_all was cut short: %w", err)
}
return map[string]interface{}{"ok": true}, nil
case "save_auto_rules":
return c.actionSaveAutoRules(args)
case "save_key_auto":
return c.actionSaveKeyAuto(args)
case "add_source":
return c.actionAddSource(args)
case "create_key":
return c.actionCreateKey(args)
// ---- 破坏性 ----
case "remove_source":
return c.actionRemoveSource(args)
case "delete_key":
return c.actionDeleteKey(args)
case "update_key":
return c.actionUpdateKey(args)
case "remove_adapter":
return c.actionRemoveAdapter(args)
case "set_plugin_enabled":
return c.actionSetPluginEnabled(args)
default:
return nil, fmt.Errorf("unknown kernel action %q", name)
}
}
// ---- 只读实现 ----
func (c *Core) actionStatus() interface{} {
// A plugin can call this DURING LoadDir (its own __init or first hook), which
// runs before rebuildRegistry builds the registry. Returning an empty status
// rather than panicking keeps a load-time call from taking the process down;
// the plugin sees an empty list and the next call gives real data.
out := map[string]interface{}{
"sources": []interface{}{},
"auto_slots": []interface{}{},
"default": c.cfg.DefaultModel,
"ready": false,
}
if r := c.Registry(); r != nil {
out["sources"] = r.Status()
out["ready"] = true
}
if slots := c.AutoSlotStates(); slots != nil {
out["auto_slots"] = slots
}
return out
}
// actionListSources returns the sources with api_key MASKED.
//
// Masking is not optional: a plugin has no legitimate need for another source's
// key, and a plugin that wrote the source itself already has it in its own
// credential vault.
func (c *Core) actionListSources() interface{} {
srcs := c.Sources()
out := make([]map[string]interface{}, 0, len(srcs))
for _, s := range srcs {
models := make([]string, 0, len(s.Models))
for _, m := range s.Models {
models = append(models, m.ID)
}
out = append(out, map[string]interface{}{
"name": s.Name,
"base_url": s.BaseURL,
"adapter": s.Adapter,
"models": models,
"has_key": s.APIKey != "" || s.APIKeyEnv != "",
"key_hint": maskSecretHint(s.APIKey),
"max_concurrent": s.MaxConcurrent,
})
}
return out
}
// maskSecretHint shows only a short suffix so an operator (and a plugin) can tell
// two keys apart without either getting the secret.
func maskSecretHint(k string) string {
if k == "" {
return ""
}
if len(k) <= 4 {
return "***"
}
return "***" + k[len(k)-4:]
}
func (c *Core) actionListModels() interface{} {
if r := c.Registry(); r != nil {
return r.ModelList()
}
return []string{}
}
func (c *Core) actionListAdapters() interface{} {
return c.ListAdapters()
}
func (c *Core) actionListAutoRules() interface{} {
return map[string]interface{}{
"auto": c.AutoRules(),
"auto_image": c.AutoImageRules(),
}
}
func (c *Core) actionAdapterOwner(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if name == "" {
return nil, fmt.Errorf("adapter_owner requires a name")
}
owner := ""
if ps := c.Plugins(); ps != nil {
owner = ps.AdapterOwnerOf(name)
}
return map[string]interface{}{
"name": name,
"owner": owner, // "" means operator-installed
}, nil
}
// ---- 写实现 ----
func (c *Core) actionSaveAutoRules(args map[string]interface{}) (interface{}, error) {
entries, err := decodeScopes(args["entries"])
if err != nil {
return nil, err
}
if err := c.SaveAutoRules(entries); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "count": len(entries)}, nil
}
func (c *Core) actionSaveKeyAuto(args map[string]interface{}) (interface{}, error) {
keyName, _ := args["key_name"].(string)
if keyName == "" {
return nil, fmt.Errorf("save_key_auto requires key_name")
}
entries, err := decodeScopes(args["entries"])
if err != nil {
return nil, err
}
if err := c.SaveKeyAuto(keyName, entries); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "count": len(entries)}, nil
}
// actionAddSource is the capability that makes "plugin-driven upstream" real: a
// plugin that has just obtained credentials can wire its endpoint in as a source
// without the operator editing config.yaml.
func (c *Core) actionAddSource(args map[string]interface{}) (interface{}, error) {
raw, ok := args["source"]
if !ok {
return nil, fmt.Errorf("add_source requires a source table")
}
b, err := json.Marshal(raw)
if err != nil {
return nil, fmt.Errorf("source is not encodable: %w", err)
}
var src config.Source
if err := json.Unmarshal(b, &src); err != nil {
return nil, fmt.Errorf("source shape is invalid: %w", err)
}
if strings.TrimSpace(src.Name) == "" {
return nil, fmt.Errorf("source requires a name")
}
if strings.TrimSpace(src.BaseURL) == "" {
return nil, fmt.Errorf("source %s requires a base_url", src.Name)
}
if err := c.AddSource(src); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "name": src.Name}, nil
}
// actionCreateKey creates a gateway key.
//
// The plaintext key is returned ONCE, in this response — the same posture as the
// admin API. It is not retrievable later, so a plugin that needs it must store it
// in its own credential vault immediately.
func (c *Core) actionCreateKey(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
role, _ := args["role"].(string)
if role == "" {
role = "user"
}
models, err := decodeScopes(args["models"])
if err != nil {
return nil, err
}
k, err := c.CreateKey(name, role, models, "created by plugin")
if err != nil {
return nil, err
}
return map[string]interface{}{
"key": k.Key,
"name": k.Name,
"role": k.Role,
"models": k.Models,
}, nil
}
// ---- 破坏性实现 ----
func (c *Core) actionRemoveSource(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if name == "" {
return nil, fmt.Errorf("remove_source requires a name")
}
// The last source is not removable: an empty registry turns every request
// into a 503, and the operator would have to fix it from the filesystem.
if len(c.Sources()) <= 1 {
return nil, fmt.Errorf("refusing to remove the last source: the gateway would have nothing to route to")
}
if err := c.RemoveSource(name); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "name": name}, nil
}
func (c *Core) actionDeleteKey(args map[string]interface{}) (interface{}, error) {
key, _ := args["key"].(string)
if key == "" {
return nil, fmt.Errorf("delete_key requires the key to delete")
}
ok, err := c.DeleteKey(key)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("no such key")
}
return map[string]interface{}{"ok": true}, nil
}
func (c *Core) actionUpdateKey(args map[string]interface{}) (interface{}, error) {
key, _ := args["key"].(string)
if key == "" {
return nil, fmt.Errorf("update_key requires the key")
}
name, _ := args["name"].(string)
role, _ := args["role"].(string)
models, err := decodeScopes(args["models"])
if err != nil {
return nil, err
}
k, err := c.UpdateKey(key, name, role, models, "")
if err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "name": k.Name, "role": k.Role}, nil
}
// actionRemoveAdapter deletes an adapter, but only one the CALLER plugin owns —
// the ownership check lives in Plugins.RemoveAdapter (see the adapter bridge).
// A plugin can always remove its own; removing an operator's or a built-in is
// refused there, and that refusal is the security property.
func (c *Core) actionRemoveAdapter(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if name == "" {
return nil, fmt.Errorf("remove_adapter requires a name")
}
ps := c.Plugins()
if ps == nil {
return nil, fmt.Errorf("no plugins loaded")
}
plugin, _ := args["plugin"].(string)
if plugin == "" {
return nil, fmt.Errorf("remove_adapter needs the calling plugin's name")
}
if err := ps.RemoveAdapter(plugin, name); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "name": name}, nil
}
func (c *Core) actionSetPluginEnabled(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if name == "" {
return nil, fmt.Errorf("set_plugin_enabled requires a name")
}
enabled, ok := args["enabled"].(bool)
if !ok {
return nil, fmt.Errorf("set_plugin_enabled requires enabled (boolean)")
}
ps := c.Plugins()
if ps == nil {
return nil, fmt.Errorf("no plugins loaded")
}
if err := ps.SetEnabled(name, enabled); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "name": name, "enabled": enabled}, nil
}
// decodeScopes converts a Lua-decoded array of model-scope tables into
// []config.ModelScope. It accepts both an explicit array and a single table so a
// one-entry chain does not need a wrapper.
func decodeScopes(v interface{}) ([]config.ModelScope, error) {
if v == nil {
return nil, nil
}
b, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("entries are not encodable: %w", err)
}
var list []config.ModelScope
if err := json.Unmarshal(b, &list); err == nil {
return list, nil
}
var one config.ModelScope
if err := json.Unmarshal(b, &one); err != nil {
return nil, fmt.Errorf("entries shape is invalid: %w", err)
}
return []config.ModelScope{one}, nil
}

View File

@ -0,0 +1,336 @@
package core
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
)
// 本文件验证 R4-c/R4-d 的闭环:**插件自己驱动外部源的链接认证,然后把源接进内核**。
//
// 这是用户点明的"同类插件"能力(对照 dsh-our-free-model):插件主导 OAuth 登录、
// 存凭据、定时领额度、把拿到的端点注册成内核上游。此前 llmsproxy 的插件只能管
// 自己的适配器,够不到内核。
// kernelTestCore 起一个最小 core(一个源 + 一个 key)。
func kernelTestCore(t *testing.T) *Core {
t.Helper()
dir := t.TempDir()
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
PluginDir: filepath.Join(dir, "plugins"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
DefaultModel: "AUTO",
GatewayKeys: []string{"sk-kernel-0001"},
Sources: []config.Source{{
Name: "primary", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
APIKey: "secret-primary-key", MaxConcurrent: 1,
Models: []config.Model{{ID: "m1", Kind: "chat"}},
}},
}
c, err := NewFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(c.Close)
return c
}
// grant 给插件授权若干动作(模拟运维在设置页点选)。
func grant(t *testing.T, c *Core, plugin string, actions ...string) {
t.Helper()
if err := c.Plugins().SetPluginKernelPermission(plugin, actions, false); err != nil {
t.Fatalf("grant %v to %s: %v", actions, plugin, err)
}
}
const kernelProbePlugin = `
local plugin = { name = "kernelprobe", version = "1.0" }
plugin.exports = { "read_status", "read_models", "read_sources", "try_delete_key", "probe_count", "call_reload" }
plugin.kernel = { "status", "list_models", "reload" }
function plugin.read_status()
local st, err = __llmsproxy_kernel.call("status", {})
if err then return { err = err } end
return { ready = st.ready, nsources = #(st.sources or {}) }
end
function plugin.read_models()
local m, err = __llmsproxy_kernel.call("list_models", {})
if err then return { err = err } end
return { models = m }
end
-- sources 动作返回的密钥必须是掩码的
function plugin.read_sources()
local s, err = __llmsproxy_kernel.call("list_sources", {})
if err then return { err = err } end
return { sources = s }
end
-- 未授权的破坏性动作必须被拒
function plugin.try_delete_key()
local _, err = __llmsproxy_kernel.call("delete_key", { key = "sk-kernel-0001" })
return { err = err }
end
-- 授权数量可见(插件据此优雅降级)
function plugin.probe_count()
local acts = __llmsproxy_kernel.actions()
local granted = 0
for _, a in ipairs(acts) do
if a.granted then granted = granted + 1 end
end
return { total = #acts, granted = granted }
end
function plugin.call_reload()
local r, err = __llmsproxy_kernel.call("reload", {})
return { ok = r and r.ok, err = err }
end
return plugin
`
// TestPluginCallsKernelActions 验证插件能调内核动作,且权限边界成立。
func TestPluginCallsKernelActions(t *testing.T) {
c := kernelTestCore(t)
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
t.Fatal(err)
}
grant(t, c, "kernelprobe", "status", "list_models", "list_sources")
// 只读动作可用
out, err := c.Plugins().Call("kernelprobe", "read_status", map[string]interface{}{})
if err != nil {
t.Fatalf("read_status: %v", err)
}
m := out.(map[string]interface{})
if m["err"] != nil {
t.Fatalf("status 调用失败: %v", m["err"])
}
if m["ready"] != true {
t.Errorf("registry 应已就绪: %#v", m)
}
if n, _ := m["nsources"].(float64); n < 1 {
t.Errorf("应至少有 1 个源, 实得 %v", m["nsources"])
}
// 模型列表
out, err = c.Plugins().Call("kernelprobe", "read_models", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
if models, ok := out.(map[string]interface{})["models"].([]interface{}); !ok || len(models) == 0 {
t.Errorf("模型列表应为非空数组: %#v", out)
}
}
// TestKernelActionMasksSourceKeys 是安全断言:list_sources **不得**泄漏 api_key。
func TestKernelActionMasksSourceKeys(t *testing.T) {
c := kernelTestCore(t)
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
t.Fatal(err)
}
grant(t, c, "kernelprobe", "list_sources")
out, err := c.Plugins().Call("kernelprobe", "read_sources", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
// 把整个返回值序列化后检查:明文密钥绝不能出现在里面
b, _ := json.Marshal(out)
if s := string(b); json.Valid(b) && contains(s, "secret-primary-key") {
t.Fatalf("list_sources 泄漏了源的明文 api_key: %s", s)
}
// 但应有可区分性(has_key + 掩码后缀)
if !contains(string(b), "***") {
t.Errorf("应给出掩码提示而不是什么都没有: %s", string(b))
}
}
// TestKernelActionDeniedWithoutGrant 是权限默认拒绝的断言。
func TestKernelActionDeniedWithoutGrant(t *testing.T) {
c := kernelTestCore(t)
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
t.Fatal(err)
}
// 故意不授权任何动作
out, err := c.Plugins().Call("kernelprobe", "try_delete_key", map[string]interface{}{})
if err != nil {
t.Fatalf("调用本身不该报错(拒绝应作为返回值): %v", err)
}
m := out.(map[string]interface{})
if m["err"] == nil || m["err"] == "" {
t.Fatal("未授权的 delete_key 必须被拒")
}
t.Logf("拒绝信息: %v", m["err"])
// 拒绝信息要点名动作与插件(否则运维无法行动)
msg, _ := m["err"].(string)
if !contains(msg, "delete_key") || !contains(msg, "kernelprobe") {
t.Errorf("拒绝信息应点名动作与插件, 实得 %q", msg)
}
// 且密钥必须还在
if _, ok := c.FindKey("sk-kernel-0001"); !ok {
t.Fatal("被拒的动作不应真的执行")
}
}
// TestKernelActionsReportGranted 插件能看到自己拿到了哪些权限。
func TestKernelActionsReportGranted(t *testing.T) {
c := kernelTestCore(t)
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
t.Fatal(err)
}
grant(t, c, "kernelprobe", "status", "list_models")
out, err := c.Plugins().Call("kernelprobe", "probe_count", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]interface{})
total, _ := m["total"].(float64)
granted, _ := m["granted"].(float64)
if total < 15 {
t.Errorf("动作总数应 >=15, 实得 %v", total)
}
if granted != 2 {
t.Errorf("授权数应为 2(status+list_models), 实得 %v", granted)
}
}
// TestPluginRegistersSourceIntoKernel 是"同类插件"的核心闭环:
// 插件通过内核动作把一个新的外部源接进网关。
func TestPluginRegistersSourceIntoKernel(t *testing.T) {
c := kernelTestCore(t)
code := `
local plugin = { name = "upstream", version = "1.0" }
plugin.exports = { "add_my_source" }
plugin.kernel = { "add_source", "list_sources" }
function plugin.add_my_source(args)
local src = {
name = "plugin-provided",
base_url = "http://127.0.0.1:9/v1",
adapter = "openai",
api_key = "plugin-managed-token",
max_concurrent = 2,
models = { { id = "plugin-model", kind = "chat", priority = 10 } },
}
local r, err = __llmsproxy_kernel.call("add_source", { source = src })
if err then return { err = err } end
return { ok = r.ok, name = r.name }
end
return plugin
`
if err := c.Plugins().LoadSource("upstream", code); err != nil {
t.Fatal(err)
}
grant(t, c, "upstream", "add_source", "list_sources")
out, err := c.Plugins().Call("upstream", "add_my_source", map[string]interface{}{})
if err != nil {
t.Fatalf("add_source: %v", err)
}
m := out.(map[string]interface{})
if m["err"] != nil {
t.Fatalf("插件注册源失败: %v", m["err"])
}
if m["name"] != "plugin-provided" {
t.Fatalf("返回的源名不对: %#v", m)
}
// 内核侧必须真的有了这个源 —— 这是"接进网关"的定义
found := false
for _, s := range c.Sources() {
if s.Name == "plugin-provided" {
found = true
if s.BaseURL != "http://127.0.0.1:9/v1" {
t.Errorf("base_url 未落库: %q", s.BaseURL)
}
}
}
if !found {
t.Fatal("插件注册的源没有出现在内核源列表里")
}
// 且模型已可路由
for _, id := range c.Registry().ModelList() {
if id == "plugin-model" {
return // 通过
}
}
t.Errorf("插件注册的模型未进入可路由列表: %v", c.Registry().ModelList())
}
// TestKernelPermissionPersists 授权必须落盘(否则重启即失效)。
func TestKernelPermissionPersists(t *testing.T) {
c := kernelTestCore(t)
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
t.Fatal(err)
}
grant(t, c, "kernelprobe", "status", "reload")
pdir := c.Config().PluginDir
path := filepath.Join(pdir, ".kernel-perms.json")
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("授权未落盘: %v", err)
}
if !contains(string(b), "reload") {
t.Errorf("落盘内容缺少动作名: %s", b)
}
// 模拟重启:新建 Plugins、加载授权、权限仍在
vm2 := lua.NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm2.Start(); err != nil {
t.Fatal(err)
}
defer vm2.Stop()
ps2 := lua.NewPlugins(vm2, pdir)
if err := ps2.LoadKernelPermissions(); err != nil {
t.Fatal(err)
}
perm := ps2.PluginKernelPermission("kernelprobe")
if len(perm.Actions) != 2 {
t.Fatalf("重启后授权应保留 2 项, 实得 %#v", perm.Actions)
}
}
// TestUnknownKernelActionRejected 拼错的动作名必须在授权时就被拒,
// 而不是静默授予"什么都不允许"。
func TestUnknownKernelActionRejected(t *testing.T) {
c := kernelTestCore(t)
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
t.Fatal(err)
}
if err := c.Plugins().SetPluginKernelPermission("kernelprobe", []string{"no_such_action"}, false); err == nil {
t.Fatal("未知动作名应被拒")
}
}
// TestKernelActionSurfaceExcludesPlaintextKeys 是凭据边界的回归断言:
// 动作面里绝不能出现返回明文密钥的动作(ListKeys/FindKey 被有意排除)。
func TestKernelActionSurfaceExcludesPlaintextKeys(t *testing.T) {
for _, a := range lua.KernelActions() {
switch a.Name {
case "list_keys", "find_key", "get_key":
t.Fatalf("动作面出现了明文密钥读取动作 %q —— 这会把部署的凭据面交给插件", a.Name)
}
}
}
func contains(haystack, needle string) bool {
return len(needle) > 0 && len(haystack) >= len(needle) &&
func() bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}()
}

View File

@ -0,0 +1,39 @@
package core
import "testing"
// TestGrantedDestructiveActionTakesEffect 确认授权后的破坏性动作**真的执行**。
//
// 上一条测试只证明"不死锁、有返回"。一个把所有动作都变成空转的 dispatch 也能
// 通过它 —— 所以这条单独断言副作用确实落地(密钥真的被删)。
func TestGrantedDestructiveActionTakesEffect(t *testing.T) {
c := kernelTestCore(t)
code := `
local plugin = { name = "eff", version = "1.0" }
plugin.exports = { "del" }
function plugin.del(args)
local r, err = __llmsproxy_kernel.call("delete_key", { key = args.key })
return { ok = r ~= nil, err = err }
end
return plugin
`
if err := c.Plugins().LoadSource("eff", code); err != nil {
t.Fatal(err)
}
_ = c.Plugins().SetPluginKernelPermission("eff", []string{"delete_key"}, false)
if _, ok := c.FindKey("sk-kernel-0001"); !ok {
t.Fatal("前置条件:key 应存在")
}
out, err := c.Plugins().Call("eff", "del", map[string]interface{}{"key": "sk-kernel-0001"})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]interface{})
if m["ok"] != true {
t.Fatalf("动作未成功: %#v", m)
}
if _, ok := c.FindKey("sk-kernel-0001"); ok {
t.Fatal("★ 动作报告成功但副作用没发生 —— dispatch 是空转")
}
}

View File

@ -0,0 +1,69 @@
package core
import (
"testing"
"time"
"llmsproxy/internal/lua"
)
// TestNoKernelActionDeadlocksFromLua 对**每个**内核动作做同一条断言:
// 在插件 Lua 调用(此时持有 p.mu)内执行时不得死锁。
//
// 为什么是"每个"而不是"那个出问题的":最初只有 set_plugin_enabled 被发现自死锁
// (它经 SetEnabled 重入 p.mu),但"是否还有别的动作也会重入"只能靠枚举回答。
// 这条测试就是那次枚举的固化 —— 将来新增动作时如果也重入插件锁,它会失败。
//
// 同时它验证了"超时后副作用仍在幕后执行"这个更隐蔽的变体:那个缺陷的表现是
// 插件收到 error、但动作其实生效了。断言方式只是"不死锁 + 返回",因为死锁与
// 迟到副作用是同一个根因(在错误的锁上下文里执行)。
func TestNoKernelActionDeadlocksFromLua(t *testing.T) {
for _, a := range lua.KernelActions() {
a := a
t.Run(a.Name, func(t *testing.T) {
c := kernelTestCore(t)
code := `
local plugin = { name = "probe_all_actions", version = "1.0" }
plugin.exports = { "run" }
function plugin.run(args)
local r, err = __llmsproxy_kernel.call(args.action, args.arg or {})
return { ok = r ~= nil, err = err }
end
return plugin
`
if err := c.Plugins().LoadSource("probe_all_actions", code); err != nil {
t.Fatal(err)
}
// 授权所有动作(含破坏性),以便真正执行到
all := make([]string, 0)
for _, x := range lua.KernelActions() {
all = append(all, x.Name)
}
if err := c.Plugins().SetPluginKernelPermission("probe_all_actions", all, false); err != nil {
t.Fatal(err)
}
arg := map[string]interface{}{}
switch a.Name {
case "name", "adapter_owner":
arg["name"] = "primary"
}
if a.Name == "adapter_owner" {
arg = map[string]interface{}{"name": "openai"}
}
done := make(chan struct{})
go func() {
defer close(done)
_, err := c.Plugins().Call("probe_all_actions", "run",
map[string]interface{}{"action": a.Name, "arg": arg})
if err != nil {
t.Logf("%s 返回 err=%v", a.Name, err)
}
}()
select {
case <-done:
case <-time.After(8 * time.Second):
t.Fatalf("动作 %s 在 Lua 调用内死锁(deferred=%v danger=%s)", a.Name, a.Deferred, a.Danger)
}
})
}
}

View File

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

View File

@ -0,0 +1,221 @@
package core
import (
"path/filepath"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/scheduler"
)
// Per-key AUTO chains let an admin give one user their own scheduling chain
// while every other key keeps the global one. These tests pin the three
// properties that make that safe:
//
// 1. a key WITH its own chain gets it;
// 2. a key WITHOUT one inherits the global chain (backward compatible);
// 3. the override is actually persisted, so it survives a restart.
//
// A test that only checked (1) would pass even if AutoChainFor ignored the key
// and always returned the global chain whenever the two happened to be equal,
// so (2) uses deliberately *different* chains.
func perKeyTestConfig(t *testing.T) *config.Config {
t.Helper()
dir := t.TempDir()
return &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: "AUTO",
GatewayKeys: []string{"sk-gw-admin"},
Sources: []config.Source{
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
},
// Global chain points at gpt-4o.
Auto: []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}},
Keys: []config.GWKey{
{Key: "sk-gw-admin", Role: "admin"},
{Key: "sk-gw-inherits", Role: "user", Name: "inherits"},
// This key's own chain points at a DIFFERENT model, so returning
// the global chain by mistake is detectable.
{Key: "sk-gw-own", Role: "user", Name: "own",
Auto: []config.ModelScope{{Model: "gpt-4o-mini", Source: "s1", Tier: 1}}},
},
}
}
func firstSlotModel(t *testing.T, c *Core, key string) string {
t.Helper()
ch, _ := c.AutoChainFor(key)
if ch == nil || len(ch.Tiers) == 0 {
t.Fatalf("key %s: no chain tiers", key)
}
slots := ch.Tiers[0].Slots
if len(slots) == 0 {
t.Fatalf("key %s: tier 1 has no slots", key)
}
return slots[0].Model
}
// A key that declares its own chain must be scheduled by it, not the global.
func TestAutoChainForUsesKeyOwnChain(t *testing.T) {
c := newTestCore(t, perKeyTestConfig(t))
if got := firstSlotModel(t, c, "sk-gw-own"); got != "gpt-4o-mini" {
t.Fatalf("key with own chain must use it, got %q (want gpt-4o-mini)", got)
}
// Sanity: the global chain really is different, so the assertion above
// cannot pass by accident.
if got := firstSlotModel(t, c, "sk-gw-admin"); got != "gpt-4o" {
t.Fatalf("admin must use global chain, got %q (want gpt-4o)", got)
}
}
// A key with no own chain inherits the global one. This is what keeps every
// pre-existing deployment working with no config change.
func TestAutoChainForInheritsGlobalWhenUnset(t *testing.T) {
c := newTestCore(t, perKeyTestConfig(t))
if got := firstSlotModel(t, c, "sk-gw-inherits"); got != "gpt-4o" {
t.Fatalf("key without own chain must inherit global, got %q (want gpt-4o)", got)
}
}
// A key that is not in the key table at all (e.g. a seed key) must also
// inherit the global chain rather than erroring or returning nil.
func TestAutoChainForUnknownKeyInheritsGlobal(t *testing.T) {
c := newTestCore(t, perKeyTestConfig(t))
ch, ok := c.AutoChainFor("sk-gw-never-configured")
if !ok || ch == nil || len(ch.Tiers) == 0 {
t.Fatalf("unknown key must fall back to global chain, got ok=%v chain=%v", ok, ch)
}
}
// The per-key chain must survive a save/reload round trip: an admin configuring
// a chain in the WebUI and restarting the gateway must not silently lose it.
func TestSaveKeyAutoPersistsAndApplies(t *testing.T) {
cfg := perKeyTestConfig(t)
c := newTestCore(t, cfg)
if err := c.SaveKeyAuto("sk-gw-inherits", []config.ModelScope{
{Model: "gpt-4o-mini", Source: "s1", Tier: 1},
}); err != nil {
t.Fatalf("SaveKeyAuto: %v", err)
}
if got := firstSlotModel(t, c, "sk-gw-inherits"); got != "gpt-4o-mini" {
t.Fatalf("saved chain must apply immediately, got %q", got)
}
// Reload from disk exactly like a restart does.
reloaded, err := config.Load(cfg.Path)
if err != nil {
t.Fatalf("reload config: %v", err)
}
c2 := newTestCore(t, reloaded)
if got := firstSlotModel(t, c2, "sk-gw-inherits"); got != "gpt-4o-mini" {
t.Fatalf("per-key chain must survive restart, got %q", got)
}
// The other key must be unaffected by its neighbour's override.
if got := firstSlotModel(t, c2, "sk-gw-admin"); got != "gpt-4o" {
t.Fatalf("override must not leak to other keys, got %q", got)
}
}
// Clearing the override must return the key to the global chain, both in
// memory and after a restart. This is what the WebUI's "use global" toggle
// does, so a stale shadowing entry would silently pin the user to their old
// chain forever.
func TestSaveKeyAutoClearRestoresGlobal(t *testing.T) {
cfg := perKeyTestConfig(t)
c := newTestCore(t, cfg)
if err := c.SaveKeyAuto("sk-gw-own", nil); err != nil {
t.Fatalf("clear: %v", err)
}
if got := firstSlotModel(t, c, "sk-gw-own"); got != "gpt-4o" {
t.Fatalf("cleared key must inherit global again, got %q", got)
}
reloaded, err := config.Load(cfg.Path)
if err != nil {
t.Fatalf("reload: %v", err)
}
c2 := newTestCore(t, reloaded)
if got := firstSlotModel(t, c2, "sk-gw-own"); got != "gpt-4o" {
t.Fatalf("clear must survive restart, got %q", got)
}
}
// Saving an unknown key must fail loudly rather than creating a shadow entry
// that no request can ever match.
func TestSaveKeyAutoUnknownKeyFails(t *testing.T) {
c := newTestCore(t, perKeyTestConfig(t))
if err := c.SaveKeyAuto("sk-gw-nope", []config.ModelScope{{Model: "gpt-4o", Source: "s1"}}); err == nil {
t.Fatal("SaveKeyAuto on unknown key must return an error")
}
}
// Per-key quota validation must run, exactly like the model scope path: an
// override is another place an operator can write a bad budget.
func TestSaveKeyAutoRejectsBadQuota(t *testing.T) {
c := newTestCore(t, perKeyTestConfig(t))
err := c.SaveKeyAuto("sk-gw-inherits", []config.ModelScope{
{Model: "gpt-4o", Source: "s1", TokenQuota: -5},
})
if err == nil {
t.Fatal("negative token quota in a per-key auto chain must be rejected")
}
}
// Source changes must recompile per-key chains. A key whose chain names a
// model that does not exist yet compiles to an empty chain (BuildChain drops
// slots it cannot resolve — sending the request into a black hole would be
// worse), and must start working as soon as the source appears.
func TestKeyAutoChainsRebuildOnSourceChange(t *testing.T) {
cfg := perKeyTestConfig(t)
cfg.Keys = append(cfg.Keys, config.GWKey{
Key: "sk-gw-late", Role: "user", Name: "late",
Auto: []config.ModelScope{{Model: "new-model", Source: "s2", Tier: 1}},
})
c := newTestCore(t, cfg)
// s2 does not exist yet, so the chain has no usable slot.
if ch, _ := c.AutoChainFor("sk-gw-late"); chainSlotsOf(ch) != 0 {
t.Fatalf("chain naming an unknown model must compile empty, got %d slots",
chainSlotsOf(ch))
}
// The other keys are unaffected by the broken one.
if got := firstSlotModel(t, c, "sk-gw-admin"); got != "gpt-4o" {
t.Fatalf("broken per-key chain must not affect others, got %q", got)
}
// Add the source; the per-key chain must pick it up without a restart.
cfg.Sources = append(cfg.Sources, config.Source{
Name: "s2", BaseURL: "http://127.0.0.1:2/v1", Adapter: "openai",
Models: []config.Model{{ID: "new-model", Priority: 10}},
})
if err := c.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
if got := firstSlotModel(t, c, "sk-gw-late"); got != "new-model" {
t.Fatalf("per-key chain must resolve after the source is added, got %q", got)
}
}
// chainSlotsOf counts usable slots, tolerating a nil chain.
func chainSlotsOf(ch *scheduler.Chain) int {
if ch == nil {
return 0
}
n := 0
for _, tn := range ch.Tiers {
n += len(tn.Slots)
}
return n
}

View File

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

@ -445,6 +445,15 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
return
}
out := g.stats.PeriodSnapshot(p, key, time.Now())
// The dashboard paints its records table from this payload's `records`
// field (paintRecords(st.records)), so a period view that omits the key
// renders an empty table no matter how much traffic the window had. The
// aggregate above is period-scoped; the record list is the same
// newest-first page the scroll handler pulls from /api/stats/records,
// which is deliberately not period-filtered (the cursor, not a date
// range, is its state) — that asymmetry is what the paging code
// already assumes.
page := g.stats.AuditPage("", limit, key)
writeJSON(w, http.StatusOK, map[string]interface{}{
"period": out.Period,
"from": out.From,
@ -455,7 +464,21 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
"by_status": out.Status,
"buckets": out.Bucket,
"truncated": out.Truncated,
"key_names": g.keyNamesFor(),
// "Active requests" is a live gauge (requests in flight right now),
// not a period aggregate. Omitting it from the period response made
// the KPI read `st.active || 0` → 0 on every day/week/month view while
// 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,
// 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.
"next_cursor": page.Next,
"has_more": page.HasMore,
"rotated": page.Rotated,
"key_names": g.keyNamesFor(),
})
return
}
@ -567,6 +590,23 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
}
snap := g.stats.Snapshot(limit, key)
snap["key_names"] = g.keyNamesFor()
// Records come from the audit files via the same AuditPage the scroll
// handler uses, not from Snapshot's in-memory ring. Two reasons:
//
// - Ordering. The ring is oldest-first, AuditPage is newest-first. The
// first screen and the paged continuation have to agree, or scrolling
// splices new rows in above old ones.
// - Depth. The ring holds maxRecs entries (a few hundred); the audit
// files hold everything, so "all" actually means all and the cursor
// can keep paging.
//
// It also carries next_cursor, without which the table can never load a
// second page.
page := g.stats.AuditPage("", limit, key)
snap["records"] = page.Records
snap["next_cursor"] = page.Next
snap["has_more"] = page.HasMore
snap["rotated"] = page.Rotated
writeJSON(w, http.StatusOK, snap)
}

View File

@ -189,6 +189,20 @@ 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

@ -21,21 +21,79 @@ import (
)
// chatRequest mirrors the OpenAI chat completions request the gateway accepts.
// chatRequest is the gateway's IN-BOUND client shape for /v1/chat/completions.
//
// It is deliberately an OpenAI wire struct, NOT the internal unified format:
// this is one client protocol, and the kernel parses it before anything else
// knows which protocol the rest of the request will speak. What reaches an
// adapter is types.UnifiedRequest (the maximal set), which Thinking populates.
type chatRequest struct {
Model string `json:"model"`
Messages []types.ChatMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
Model string `json:"model"`
Messages []types.ChatMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
// DisableThinking is the OpenAI-ecosystem spelling of "turn thinking off".
// It is translated into the protocol-neutral Thinking field on the way in
// (see thinkingFromClient), so that flag never travels under this name.
DisableThinking bool `json:"disable_thinking"`
ExtraBody map[string]interface{} `json:"extra_body,omitempty"`
// Thinking accepts the neutral shape directly, so a client already speaking
// the unified format (or an adapter-served protocol that names it that way)
// does not have to round-trip through disable_thinking.
Thinking *types.ThinkingConfig `json:"thinking,omitempty"`
// PromptCacheKey 是 OpenAI 原生的缓存键;部分客户端用它携带会话标识。
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
}
// thinkingFromClient folds the client's spelling of "no thinking" into the
// neutral Thinking field.
//
// Precedence: an explicit `thinking` object wins, because it carries a budget
// the boolean cannot express. disable_thinking only ever sets Enabled=false —
// it says nothing about how much, and inventing a budget there would silently
// override the upstream's default.
func (r *chatRequest) thinkingFromClient() *types.ThinkingConfig {
if r.Thinking != nil {
return r.Thinking
}
if r.DisableThinking {
no := false
return &types.ThinkingConfig{Enabled: &no}
}
return nil
}
// rawTools / rawToolChoice re-encode the loosely-typed client fields into the
// RawMessage the internal format carries. The kernel never interprets them:
// they are the client's payload, passed through to the adapter, which is the
// only layer that knows the tool schema of any particular protocol.
func (r *chatRequest) rawTools() json.RawMessage {
if len(r.Tools) == 0 {
return nil
}
b, err := json.Marshal(r.Tools)
if err != nil {
return nil
}
return b
}
func (r *chatRequest) rawToolChoice() json.RawMessage {
if r.ToolChoice == nil {
return nil
}
b, err := json.Marshal(r.ToolChoice)
if err != nil {
return nil
}
return b
}
// ChatCompletion is the non-streaming OpenAI response object.
type ChatCompletion struct {
ID string `json:"id"`
@ -256,6 +314,16 @@ 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)}
}
@ -400,7 +468,8 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
// TestHooksFireOnRealDirectChat, not by reading the code.
g.fireStart(r.Context(), &req, "chat", model, len(req.Messages), len(req.Tools))
if isAuto(model) {
chain := g.core.AutoChain()
// The key's own chain wins; keys without one inherit the global chain.
chain, _ := g.core.AutoChainFor(reqKey(r.Context()))
if chain == nil || len(chain.Tiers) == 0 {
writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot configured")
return
@ -413,15 +482,15 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
done := g.stats.Begin()
defer done()
inner := &types.ChatRequest{
Messages: req.Messages,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
Stream: req.Stream,
Tools: req.Tools,
ToolChoice: req.ToolChoice,
DisableThinking: req.DisableThinking,
ExtraBody: req.ExtraBody,
ClientSession: clientSession,
Messages: req.Messages,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
Stream: req.Stream,
Tools: req.rawTools(),
ToolChoice: req.rawToolChoice(),
Thinking: req.thinkingFromClient(),
ExtraBody: req.ExtraBody,
ClientSession: clientSession,
}
rec := &Req{
Key: keyID(reqKey(ctx)),
@ -451,6 +520,11 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
return
}
}
// Note: a key whose scope excludes AUTO is rejected earlier, by
// checkQuota(ctx, "AUTO") above, which produces the same message. That
// ordering matters — checkQuota runs before the chain is consulted, so it
// is the check an AUTO request actually hits. Duplicating it here would be
// dead code.
cands, effective := g.resolveCands(r.Context(), &req)
if len(cands) == 0 {
writeError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("model %q is not configured", model))
@ -468,16 +542,16 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
defer done()
inner := &types.ChatRequest{
Model: effective,
Messages: req.Messages,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
Stream: req.Stream,
Tools: req.Tools,
ToolChoice: req.ToolChoice,
DisableThinking: req.DisableThinking,
ExtraBody: req.ExtraBody,
ClientSession: clientSession,
Model: effective,
Messages: req.Messages,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
Stream: req.Stream,
Tools: req.rawTools(),
ToolChoice: req.rawToolChoice(),
Thinking: req.thinkingFromClient(),
ExtraBody: req.ExtraBody,
ClientSession: clientSession,
}
rec := &Req{
Key: keyID(reqKey(ctx)),
@ -570,7 +644,7 @@ func estimatePromptTokens(req *types.ChatRequest) int64 {
}
b, _ := json.Marshal(struct {
Messages []types.ChatMessage `json:"messages"`
Tools []interface{} `json:"tools,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"`
}{Messages: req.Messages, Tools: req.Tools})
if len(b) == 0 {
return 0
@ -773,6 +847,18 @@ 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)
}
@ -789,29 +875,21 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon
}
}
// writeChatCompletion renders a unified response as an OpenAI
// chat.completion object. modelName is the id clients see as the serving
// model: the requested id for direct routes, the exact slot model for AUTO.
func writeChatCompletion(w http.ResponseWriter, resp *types.UnifiedResponse, modelName string) {
msg := RespMessage{Role: "assistant", Content: resp.Content}
if resp.ReasoningContent != "" {
msg.ReasoningContent = resp.ReasoningContent
// writeChatCompletion renders a unified response through the out-bound
// Serializer. The default serializer reproduces the OpenAI wire shape this
// function used to build inline (locked byte-for-byte by e2e golden); an
// adapter that declares serialize_response overrides it. modelName is the id
// clients see as the serving model: the requested id for direct routes, the
// exact slot model for AUTO.
func writeChatCompletion(w http.ResponseWriter, s Serializer, resp *types.UnifiedResponse, modelName string) {
body, err := s.NonStream(resp, modelName, newWireID(), newCreated())
if err != nil {
writeError(w, http.StatusInternalServerError, "serialize_error", "failed to serialize response")
return
}
if len(resp.ToolCalls) > 0 {
msg.ToolCalls = toolCallsWire(resp.ToolCalls)
}
out := ChatCompletion{
ID: newID(),
Object: "chat.completion",
Created: time.Now().Unix(),
Model: modelName,
Choices: []ChatChoice{{Index: 0, Message: msg, FinishReason: resp.FinishReason}},
Cost: resp.Cost,
}
if resp.TokenUsage.Total > 0 || resp.TokenUsage.Prompt > 0 || resp.TokenUsage.Completion > 0 {
out.Usage = &resp.TokenUsage
}
writeJSON(w, http.StatusOK, out)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
// singleChat runs a direct (model-pinned) non-streaming request across the
@ -836,7 +914,7 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [
// the total latency.
rec.FirstByteMs = rec.LatMs
g.writeRec(rec)
writeChatCompletion(w, resp, effective)
writeChatCompletion(w, g.serializerFor(usedSrc, cands), resp, effective)
}
// writeRec records a finished request (audit + aggregates) and fires the
@ -1028,9 +1106,12 @@ 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,
"image_count": rec.ImageCount,
"error": rec.Err,
"time": rec.Time,
// 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,
// 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
@ -1085,24 +1166,29 @@ func mergeUsage(prev, cur *types.TokenUsage) *types.TokenUsage {
return &out
}
// pumpStream writes the full SSE sequence for a started stream: role
// preamble, one chunk per unified delta, the terminating finish_reason, the
// OpenAI-standard final usage chunk (empty choices) and [DONE]. modelName
// follows writeChatCompletion's rule (requested id for direct routes, exact
// slot model for AUTO).
func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan types.UnifiedChunk, modelName string, t0 time.Time) {
// pumpStream writes the full SSE sequence for a started stream. Every byte on
// the wire comes from the Serializer, so the OpenAI frame shapes live in
// serializer.go rather than here, and an adapter that declares serialize_chunk
// replaces them wholesale.
//
// This function owns only transport concerns — headers, the flush cadence,
// first-byte timing, and audit accounting — plus the protocol-agnostic rule
// that usage seen anywhere in the stream becomes the recorded usage. Whether a
// trailing usage frame is emitted, and in what shape, is the serializer's
// decision.
func (g *Gateway) pumpStream(w http.ResponseWriter, ser Serializer, rec *Req, chunks <-chan types.UnifiedChunk, modelName string, t0 time.Time) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
id := newID()
created := time.Now().Unix()
send := func(obj interface{}) bool {
b, err := json.Marshal(obj)
if err != nil {
return false
id := newWireID()
created := newCreated()
st := &StreamState{}
send := func(b []byte) bool {
if len(b) == 0 {
return true
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
return false
@ -1113,115 +1199,66 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan type
return true
}
if !send(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{Role: "assistant"}}},
}) {
return
if b, err := ser.OpenRolePreamble(modelName, id, created, st); err == nil {
if !send(b) {
return
}
}
// First SSE byte sent to the client: record time-to-first-byte for the
// source's status-page latency average.
rec.FirstByteMs = time.Since(t0).Milliseconds()
var lastUsage *types.TokenUsage
var lastCost string
lastFinish := ""
for ck := range chunks {
if ck.Usage != nil {
lastUsage = mergeUsage(lastUsage, ck.Usage)
st.Usage = mergeUsage(st.Usage, ck.Usage)
}
if ck.Cost != "" {
lastCost = ck.Cost
st.Cost = ck.Cost
}
chunk := ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
b, err := ser.Chunk(&ck, modelName, id, created, st)
if err != nil {
return
}
delta := RespMessage{Role: "assistant", Content: ck.Content}
if ck.ReasoningContent != "" {
delta.ReasoningContent = ck.ReasoningContent
}
if len(ck.ToolCalls) > 0 {
delta.ToolCalls = ck.ToolCalls
}
choice := ChunkChoice{Index: 0, Delta: delta}
if ck.Done {
fin := ck.FinishReason
if fin == "" {
fin = "stop"
}
lastFinish = fin
choice.FinishReason = &fin
}
chunk.Choices = []ChunkChoice{choice}
rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)+len(ck.ToolCalls)) / 3
if !send(chunk) {
if !send(b) {
return
}
}
finalFinish := lastFinish
if finalFinish == "" {
finalFinish = "stop"
}
send(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &finalFinish}},
})
// Final usage chunk (OpenAI standard: empty choices + usage before [DONE]).
// Prefer the upstream's exact usage if the stream carried it; fall back to
// the gateway's estimate otherwise.
// Write the upstream's exact numbers back onto the audit record. Without
// this the streamed path kept only the per-chunk byte estimate, so the same
// Write the upstream's exact usage back onto the audit record. Without this
// the streamed path kept only the per-chunk byte estimate, so the same
// request recorded ~1.4x its real completion tokens (measured: upstream 100,
// audit 145) while the non-streaming path recorded 100. Two paths, two
// different numbers for one request is a reporting bug, not a rounding one.
if lastUsage != nil {
if lastUsage.Prompt > 0 {
rec.Prompt = int64(lastUsage.Prompt)
if st.Usage != nil {
if st.Usage.Prompt > 0 {
rec.Prompt = int64(st.Usage.Prompt)
}
if lastUsage.Completion > 0 {
rec.Compl = int64(lastUsage.Completion)
}
}
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)
if st.Usage.Completion > 0 {
rec.Compl = int64(st.Usage.Completion)
}
applyStreamUsageDetails(rec, st.Usage)
} else if rec.Prompt+rec.Compl > 0 {
u := types.TokenUsage{
// No upstream usage on the stream: the recorded per-chunk byte
// estimate is the best number available, so hand it to the serializer
// as the usage rather than dropping it.
st.Usage = &types.TokenUsage{
Prompt: int(rec.Prompt),
Completion: int(rec.Compl),
Total: int(rec.Prompt + rec.Compl),
}
tut = &u
}
if tut != nil {
send(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{},
Usage: tut,
Cost: lastCost,
})
} else if lastCost != "" {
// Cost arrived without any usage (OpenCode sends it on its own frame).
send(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{},
Cost: lastCost,
})
if frames, err := ser.Close(modelName, id, created, st); err == nil {
for _, b := range frames {
if !send(b) {
return
}
}
}
if marker := ser.DoneMarker(); marker != "" {
fmt.Fprintf(w, "data: %s\n\n", marker)
}
fmt.Fprintf(w, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
}
@ -1252,7 +1289,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
rec.Source = usedSrc
g.fireRouted(ctx, "stream", usedSrc, rec.Model, -1, true)
rec.Prompt = estimatePromptTokens(req)
g.pumpStream(w, rec, chunks, effective, t0)
g.pumpStream(w, g.serializerFor(usedSrc, cands), rec, chunks, effective, t0)
}
// singleChatAuto runs a non-streaming AUTO request down the chain (see
@ -1285,7 +1322,7 @@ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, cha
g.fireRouted(ctx, "chat", usedSrc, usedModel, -2, false)
rec.FirstByteMs = rec.LatMs
g.writeRec(rec)
writeChatCompletion(w, resp, usedModel)
writeChatCompletion(w, g.serializerFor(usedSrc, nil), resp, usedModel)
}
// streamChatAuto streams an AUTO request down the chain. A slot is abandoned
@ -1315,7 +1352,7 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha
rec.Source = usedSrc
g.fireRouted(ctx, "stream", usedSrc, rec.Model, -2, true)
rec.Prompt = estimatePromptTokens(req)
g.pumpStream(w, rec, chunks, usedModel, t0)
g.pumpStream(w, g.serializerFor(usedSrc, nil), rec, chunks, usedModel, t0)
}
func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
@ -1428,3 +1465,39 @@ 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

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

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

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

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

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

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

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

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

@ -0,0 +1,257 @@
package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
)
// Per-key AUTO chain admin API. The behaviours pinned here are the ones that
// are easy to get wrong and invisible when they are:
//
// - /api/keys/me/auto must be reachable by a NON-admin (it is their own
// chain); routing it through the generic {key}/auto handler would 403 it.
// - /api/keys/{key}/auto must 403 a non-admin.
// - an empty PUT and a DELETE mean the same thing ("inherit global"), so the
// WebUI cannot persist a chain that would then 503 with no slots.
// - a PUT that resolves to zero slots is reported back, so an admin who
// mistyped a model learns it now instead of from the user's next 503.
// keyAutoGateway builds a gateway with one admin key and one user key.
func keyAutoGateway(t *testing.T) *Gateway {
t.Helper()
td := t.TempDir()
cfgPath := filepath.Join(td, "config.yaml")
if err := os.WriteFile(cfgPath, []byte("listen: :0"), 0644); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
Path: cfgPath,
AdapterDir: filepath.Join(td, "adapters"),
RuntimeFile: filepath.Join(td, "runtime.json"),
DefaultModel: "AUTO",
GatewayKeys: []string{"sk-admin"},
Keys: []config.GWKey{
{Key: "sk-admin", Role: "admin", Name: "admin"},
{Key: "sk-user", Role: "user", Name: "user"},
},
Sources: []config.Source{
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
},
}
if err := cfg.ApplyDefaults(); err != nil {
t.Fatal(err)
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
g, err := New(c)
if err != nil {
t.Fatalf("gateway: %v", err)
}
return g
}
// doReqAs issues an authenticated request as a specific key.
func doReqAs(t *testing.T, g *Gateway, key, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
req, _ := http.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+key)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rr := httptest.NewRecorder()
g.Handler().ServeHTTP(rr, req)
return rr
}
func TestKeyAutoAPIPermissions(t *testing.T) {
g := keyAutoGateway(t)
// A user may read their own chain...
if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/me/auto", ""); rr.Code != http.StatusOK {
t.Fatalf("GET /api/keys/me/auto as user = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
// ...but not another key's.
if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/sk-admin/auto", ""); rr.Code != http.StatusForbidden {
t.Fatalf("GET other key's auto as user = %d, want 403", rr.Code)
}
// ...and not their own via the admin route either.
if rr := doReqAs(t, g, "sk-user", "PUT", "/api/keys/sk-user/auto", `{"auto":[{"model":"gpt-4o"}]}`); rr.Code != http.StatusForbidden {
t.Fatalf("user PUT own auto = %d, want 403 (only admin configures)", rr.Code)
}
}
func TestKeyAutoAPICrudAndInherit(t *testing.T) {
g := keyAutoGateway(t)
// Initially inherits.
var got struct {
Inherits bool `json:"inherits"`
Auto []config.ModelScope `json:"auto"`
}
rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
if rr.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rr.Code, rr.Body.String())
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Inherits || len(got.Auto) != 0 {
t.Fatalf("fresh key must inherit global, got inherits=%v auto=%v", got.Inherits, got.Auto)
}
// Admin sets a chain; the response reports the resolved slot count.
rr = doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
`{"auto":[{"model":"gpt-4o-mini","source":"s1","tier":1}]}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
var put struct {
Inherits bool `json:"inherits"`
Slots int `json:"slots"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &put); err != nil {
t.Fatal(err)
}
if put.Inherits {
t.Fatal("after a non-empty PUT the key must no longer inherit")
}
if put.Slots != 1 {
t.Fatalf("resolved slots = %d, want 1", put.Slots)
}
// And it reads back as an own chain.
rr = doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Inherits || len(got.Auto) != 1 || got.Auto[0].Model != "gpt-4o-mini" {
t.Fatalf("own chain must read back, got inherits=%v auto=%v", got.Inherits, got.Auto)
}
// DELETE restores inheritance.
if rr := doReqAs(t, g, "sk-admin", "DELETE", "/api/keys/sk-user/auto", ""); rr.Code != http.StatusOK {
t.Fatalf("DELETE = %d: %s", rr.Code, rr.Body.String())
}
rr = doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Inherits {
t.Fatal("after DELETE the key must inherit the global chain again")
}
}
// An empty PUT must behave like DELETE. Otherwise a WebUI that submits an
// empty list (every slot deleted in the editor) would persist an empty chain
// and the next AUTO request would fail with no_provider.
func TestKeyAutoAPIEmptyPutClearsOverride(t *testing.T) {
g := keyAutoGateway(t)
if rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
`{"auto":[{"model":"gpt-4o","source":"s1","tier":1}]}`); rr.Code != http.StatusOK {
t.Fatalf("seed PUT = %d: %s", rr.Code, rr.Body.String())
}
rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto", `{"auto":[]}`)
if rr.Code != http.StatusOK {
t.Fatalf("empty PUT = %d: %s", rr.Code, rr.Body.String())
}
var got struct {
Inherits bool `json:"inherits"`
}
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", ""); rr.Code != http.StatusOK {
t.Fatal(rr.Body.String())
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Inherits {
t.Fatal("an empty PUT must clear the override, not persist an empty chain")
}
}
// A chain naming a model that does not exist compiles to zero slots. The API
// must say so at write time instead of letting the user's next request 503.
func TestKeyAutoAPIReportsUnresolvableSlots(t *testing.T) {
g := keyAutoGateway(t)
rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
`{"auto":[{"model":"does-not-exist","source":"s1","tier":1}]}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
var put struct {
Inherits bool `json:"inherits"`
Slots int `json:"slots"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &put); err != nil {
t.Fatal(err)
}
if put.Slots != 0 {
t.Fatalf("unknown model must resolve to 0 slots, got %d", put.Slots)
}
}
func TestKeyAutoAPIUnknownKey404(t *testing.T) {
g := keyAutoGateway(t)
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-nope/auto", ""); rr.Code != http.StatusNotFound {
t.Fatalf("GET unknown key auto = %d, want 404", rr.Code)
}
if rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-nope/auto", `{"auto":[{"model":"gpt-4o"}]}`); rr.Code != http.StatusNotFound {
t.Fatalf("PUT unknown key auto = %d, want 404", rr.Code)
}
}
// A bad quota must be rejected with 400, not persisted and not silently
// clamped — same rule as the model scope path.
func TestKeyAutoAPIRejectsBadQuota(t *testing.T) {
g := keyAutoGateway(t)
rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
`{"auto":[{"model":"gpt-4o","source":"s1","tier":1,"token_quota":-1}]}`)
if rr.Code != http.StatusBadRequest {
t.Fatalf("negative quota PUT = %d, want 400 (body %s)", rr.Code, rr.Body.String())
}
// Nothing may have been persisted.
var got struct {
Inherits bool `json:"inherits"`
}
get := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
if get.Code != http.StatusOK {
t.Fatal(get.Body.String())
}
if err := json.Unmarshal(get.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Inherits {
t.Fatal("a rejected PUT must not persist a chain")
}
}
// The other key endpoints must keep working: the {key}/auto route must not
// shadow /api/keys/{key} itself.
func TestKeyAutoAPIDoesNotShadowKeyRoutes(t *testing.T) {
g := keyAutoGateway(t)
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys", ""); rr.Code != http.StatusOK {
t.Fatalf("GET /api/keys = %d, want 200", rr.Code)
}
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/me", ""); rr.Code != http.StatusOK {
t.Fatalf("GET /api/keys/me = %d, want 200", rr.Code)
}
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

@ -7,6 +7,7 @@ import (
"strings"
"llmsproxy/internal/config"
"llmsproxy/internal/scheduler"
)
// handleKeysAPI manages gateway keys: GET /api/keys (admin: all keys),
@ -17,10 +18,22 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
path = strings.Trim(path, "/")
role := reqRole(r.Context())
// /api/keys/me/auto — a user's own chain, readable without admin rights.
// Must be matched before the generic {key}/auto case below so it does not
// ask admin rights of a plain user asking about their own chain.
if path == "me/auto" {
g.handleKeyMeAuto(w, r)
return
}
if path == "me" {
g.handleKeyMe(w, r)
return
}
// /api/keys/{key}/auto — the per-key AUTO chain editor.
if i := strings.LastIndex(path, "/auto"); i > 0 && path[i+len("/auto"):] == "" {
g.handleKeyAutoAPI(w, r, path[:i])
return
}
if role != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
@ -139,6 +152,101 @@ func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
return rec.Models
}
// handleKeyAutoAPI manages one key's own AUTO chain:
// GET /api/keys/{key}/auto returns it, PUT replaces it, DELETE clears the
// override so the key falls back to the global chain. Admin only — a user can
// inspect their own chain through GET /api/keys/me/auto.
//
// An empty chain and a cleared chain are deliberately the same state: "inherit
// the global chain". That keeps the WebUI's "use global" toggle a plain DELETE
// with no sentinel value to carry through config.yaml.
func (g *Gateway) handleKeyAutoAPI(w http.ResponseWriter, r *http.Request, key string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
rec, ok := g.core.FindKey(key)
if !ok {
writeError(w, http.StatusNotFound, "not_found", "key not found")
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{
"key": key,
"auto": rec.Auto,
"inherits": !rec.HasOwnAuto(),
})
case http.MethodPut, http.MethodPatch:
var body struct {
Auto []config.ModelScope `json:"auto"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if len(body.Auto) == 0 {
// Treat an empty PUT as "give up the override" so the endpoint
// cannot persist a chain that would 503 with no slots.
if err := g.core.SaveKeyAuto(key, nil); err != nil {
writeError(w, http.StatusBadRequest, "key_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "inherits": true})
return
}
if err := g.core.SaveKeyAuto(key, body.Auto); err != nil {
writeError(w, http.StatusBadRequest, "key_error", err.Error())
return
}
// Report whether the chain actually resolved to usable slots. An
// admin who typed a model that no longer exists should learn it here,
// not from the user's next 503.
chain, _ := g.core.AutoChainFor(key)
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true, "inherits": false, "slots": chainSlots(chain),
})
case http.MethodDelete:
if err := g.core.SaveKeyAuto(key, nil); err != nil {
writeError(w, http.StatusBadRequest, "key_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "inherits": true})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// chainSlots counts the usable slots in a chain, for API feedback after an
// edit. A chain that compiled to zero slots is reported so the caller can warn
// instead of letting the next request fail with no_provider.
func chainSlots(ch *scheduler.Chain) int {
if ch == nil {
return 0
}
n := 0
for _, tn := range ch.Tiers {
n += len(tn.Slots)
}
return n
}
// handleKeyMeAuto lets a user read their own AUTO chain without admin rights.
func (g *Gateway) handleKeyMeAuto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
return
}
rec, ok := g.core.FindKey(reqKey(r.Context()))
if !ok {
writeError(w, http.StatusUnauthorized, "invalid_api_key", "key not found")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"key": rec.Key, "auto": rec.Auto, "inherits": !rec.HasOwnAuto(),
})
}
// handleAutoAPI manages the AUTO scheduling slots: GET /api/auto returns the
// current rules; PUT /api/auto replaces them (admin only).
func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {

View File

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

View File

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

231
internal/gateway/mount.go Normal file
View File

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

View File

@ -0,0 +1,324 @@
package gateway
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
)
// 本文件锁定 R3 的两条不变量:
// 1. 自定义挂载只能落在允许的命名空间内
// 2. 任何内核路由都不能被插件声明覆盖 —— 且这条在新增内核路由时**不会失效**
// TestValidateCustomMountNamespace 只有 /plugins/ 与 /v1/plugins/ 可被占用。
func TestValidateCustomMountNamespace(t *testing.T) {
ok := []string{
"/plugins/auth/token",
"/plugins/auth",
"/v1/plugins/callback",
"/v1/plugins/callback/recv",
"/plugins/nested/deep/path",
}
for _, p := range ok {
if _, err := ValidateCustomMount(p); err != nil {
t.Errorf("合法挂载 %q 被拒: %v", p, err)
}
}
bad := []string{
"",
"plugins/x", // 没有前导斜杠
"/v1/chat/completions", // 影子内核路由(且不在允许命名空间)
"/api/anything", // 内核命名空间
"/callback", // 任意前缀
"/plugins/../etc", // 逃逸
"/plugins//double", // 空段
"/v1/plugins/../../x",
}
for _, p := range bad {
if _, err := ValidateCustomMount(p); err == nil {
t.Errorf("非法挂载 %q 被接受", p)
}
}
}
// TestCustomMountCannotShadowKernelRoute 逐条核对内核保留前缀。
func TestCustomMountCannotShadowKernelRoute(t *testing.T) {
// 这些是要保护的公开接口;任何能覆盖它们的挂载都必须被拒。
for _, kernel := range []string{"/v1/chat/completions", "/v1/messages", "/v1/models"} {
// 尝试用允许命名空间里的路径去"贴近"内核路由 —— 例如
// /v1/plugins/../../v1/models(逃逸),必须被拒。
cand := "/v1/plugins/../../" + strings.TrimPrefix(kernel, "/")
if _, err := ValidateCustomMount(cand); err == nil {
t.Errorf("逃逸路径 %q 应被拒", cand)
}
}
}
// TestKernelRoutesAreAllCoveredByReservedPrefixes 是这道防线**不腐坏**的保证。
//
// 它从 server.go 里把路由 switch 的分支字符串刮出来,逐个检查是否被
// kernelReservedPrefixes 覆盖。将来有人新增一条内核路由却忘了更新保留表时,
// 这条测试会失败 —— 否则新路由会静默变成可被插件覆盖的。
func TestKernelRoutesAreAllCoveredByReservedPrefixes(t *testing.T) {
src, err := os.ReadFile("server.go")
if err != nil {
t.Fatal(err)
}
// 抓 switch 里的字面量路径:case r.URL.Path == "/x" / HasPrefix(..., "/x")
re := regexp.MustCompile(`r\.URL\.Path\s*==\s*"(/[^"]*)"`)
matches := re.FindAllStringSubmatch(string(src), -1)
if len(matches) == 0 {
t.Fatal("没有从 server.go 抓到任何路由字面量 —— 测试自身失效")
}
covered := func(p string) bool {
// "/" is the UI catch-all (serveUI). It is the default branch, not a
// path a plugin could shadow, and it is covered by the namespace rule:
// a custom mount must start with /plugins/ or /v1/plugins/, neither of
// which is "/".
if p == "/" {
return true
}
for _, res := range kernelReservedPrefixes {
if res == "/" {
continue
}
rp := strings.TrimRight(res, "/")
if p == rp || strings.HasPrefix(p, rp+"/") {
return true
}
}
// /api/plugins/... 由 pluginHTTPPrefix 与 /api/ 覆盖
if strings.HasPrefix(p, "/api/") {
return true
}
return false
}
for _, m := range matches {
p := m[1]
if !covered(p) {
t.Errorf("内核路由 %q 未被 kernelReservedPrefixes 覆盖 —— "+
"新增路由时必须同步更新保留表,否则插件可影子它", p)
}
}
}
// TestMountConflictDetectionBothDirections 冲突检测必须双向。
//
// 只查一个方向会让「先注册 /plugins/x,后注册 /plugins/x/sub」或反过来时的
// 一个被另一个静默吞掉。
func TestMountConflictDetectionBothDirections(t *testing.T) {
table := []RouteOwner{{Path: "/plugins/x", Owner: "plugin:a"}}
// 被包含
if _, conflict := FindMountConflict("/plugins/x/sub", table); !conflict {
t.Error("嵌套在已有挂载下的路径应判冲突")
}
// 包含
if _, conflict := FindMountConflict("/plugins", table); !conflict {
t.Error("包含已有挂载的路径应判冲突")
}
// 相同
if _, conflict := FindMountConflict("/plugins/x", table); !conflict {
t.Error("完全相同的路径应判冲突")
}
// 无关(前缀相似但不是路径段)
if _, conflict := FindMountConflict("/plugins/xy", table); conflict {
t.Error("/plugins/xy 与 /plugins/x 不是同一个路径段,不该判冲突")
}
}
// TestMountConflictNamesTheOwner 冲突信息必须点名持有者(否则不可行动)。
func TestMountConflictNamesTheOwner(t *testing.T) {
table := []RouteOwner{{Path: "/plugins/taken", Owner: "plugin:other"}}
owner, conflict := FindMountConflict("/plugins/taken/deep", table)
if !conflict {
t.Fatal("应判冲突")
}
if owner.Owner != "plugin:other" {
t.Errorf("应点名持有者 plugin:other, 实得 %q", owner.Owner)
}
}
// TestParseMountRef 解析两种命名空间。
func TestParseMountRef(t *testing.T) {
cases := []struct {
in, owner, rest string
ok bool
}{
{"/plugins/auth/token", "auth", "token", true},
{"/plugins/auth", "auth", "", true},
{"/v1/plugins/cb/recv", "cb", "recv", true},
{"/v1/plugins/cb", "cb", "", true},
{"/plugins/", "", "", false},
{"/plugins", "", "", false},
{"/other/x", "", "", false},
}
for _, c := range cases {
owner, rest, ok := parseMountRef(c.in)
if ok != c.ok || owner != c.owner || rest != c.rest {
t.Errorf("parseMountRef(%q) = (%q,%q,%v), want (%q,%q,%v)",
c.in, owner, rest, ok, c.owner, c.rest, c.ok)
}
}
}
// TestBuildRouteTableIncludesKernel 路由表含内核保留项(供冲突提示用)。
func TestBuildRouteTableIncludesKernel(t *testing.T) {
table := BuildRouteTable(
map[string]string{"/plugins/a": "alpha"},
map[string]string{"/v1/plugins/b": "beta"},
)
var hasKernel, hasPlugin, hasAdapter bool
for _, r := range table {
if r.Owner == "kernel" {
hasKernel = true
}
if r.Owner == "plugin:alpha" {
hasPlugin = true
}
if r.Owner == "adapter:beta" {
hasAdapter = true
}
}
if !hasKernel || !hasPlugin || !hasAdapter {
t.Errorf("路由表应含 kernel/plugin/adapter 三类: %+v", table)
}
}
// TestPluginServesCustomMountEndToEnd 是 R3 的端到端验收:
// 插件声明一个自定义对外挂载点,客户端按该路径访问,得到插件的应答。
func TestPluginServesCustomMountEndToEnd(t *testing.T) {
ts, key := messagesGateway(t)
// messagesGateway 用的 core 没有插件目录配置,这里直接建一个带插件的。
_ = ts
_ = key
dir := t.TempDir()
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
PluginDir: filepath.Join(dir, "plugins"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "AUTO",
GatewayKeys: []string{"sk-mount-0001"},
Sources: []config.Source{{
Name: "s", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
APIKey: "k", MaxConcurrent: 1,
Models: []config.Model{{ID: "m", Kind: "chat"}},
}},
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
gw, err := New(c)
if err != nil {
t.Fatal(err)
}
ts2 := httptest.NewServer(gw.Handler())
defer ts2.Close()
// 装一个声明自定义挂载的插件
code := `
local plugin = { name = "publicapi", version = "1.0" }
plugin.http = {
{ path = "publicapi/token", method = "GET", handler = "token",
roles = { "admin", "user" }, description = "对外 token 端点" },
}
function plugin.token(req)
return { status = 200, body = '{"token":"abc"}' }
end
return plugin
`
if err := c.Plugins().LoadSource("publicapi", code); err != nil {
t.Fatalf("load plugin: %v", err)
}
// 按自定义挂载访问
req, _ := http.NewRequest("GET", ts2.URL+"/plugins/publicapi/token", nil)
req.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0])
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
t.Fatalf("自定义挂载 status = %d: %s", resp.StatusCode, body)
}
if !strings.Contains(string(body), "abc") {
t.Errorf("自定义挂载未返回插件应答: %s", body)
}
// /v1/plugins/ 形式同样可用
req2, _ := http.NewRequest("GET", ts2.URL+"/v1/plugins/publicapi/token", nil)
req2.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0])
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != 200 {
t.Errorf("/v1/plugins/ 形式 status = %d", resp2.StatusCode)
}
// 未声明的命名空间 404(不能因为落在允许命名空间就放行)
req3, _ := http.NewRequest("GET", ts2.URL+"/plugins/nobody/here", nil)
req3.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0])
resp3, err := http.DefaultClient.Do(req3)
if err != nil {
t.Fatal(err)
}
defer resp3.Body.Close()
if resp3.StatusCode != 404 {
t.Errorf("未注册的挂载应 404, 实得 %d", resp3.StatusCode)
}
// 鉴权仍生效:无 key 时,
// - API 路径(/v1/plugins/…)应 401(isAPIPath 判定)
// - 浏览器路径(/plugins/…)应 302 跳登录页
req4, _ := http.NewRequest("GET", ts2.URL+"/v1/plugins/publicapi/token", nil)
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp4, err := noRedirect.Do(req4)
if err != nil {
t.Fatal(err)
}
defer resp4.Body.Close()
if resp4.StatusCode != 401 {
t.Errorf("无 key 访问 /v1/plugins/ 应 401, 实得 %d", resp4.StatusCode)
}
req5, _ := http.NewRequest("GET", ts2.URL+"/plugins/publicapi/token", nil)
resp5, err := noRedirect.Do(req5)
if err != nil {
t.Fatal(err)
}
defer resp5.Body.Close()
if resp5.StatusCode != 302 {
t.Errorf("无 key 访问 /plugins/ 应 302 跳登录, 实得 %d", resp5.StatusCode)
}
if loc := resp5.Header.Get("Location"); !strings.Contains(loc, "/login") {
t.Errorf("应跳转到 /login, 实得 %q", loc)
}
}
// TestCustomMountCannotInterceptKernelRoute 端到端确认内核路由不受影响:
// 即使有插件在用 /plugins/,/v1/chat/completions 与 /v1/models 仍归内核。
func TestCustomMountCannotInterceptKernelRoute(t *testing.T) {
for _, p := range []string{"/v1/chat/completions", "/v1/models", "/v1/messages", "/api/stats"} {
if _, err := ValidateCustomMount(p); err == nil {
t.Errorf("内核路由 %q 必须不能被声明为自定义挂载", p)
}
}
}

View File

@ -0,0 +1,150 @@
package gateway
// The /api/plugins/{name}/call/{fn} endpoint splits its path with string
// surgery, and the split point is easy to get wrong in a way that still
// "works": keeping the "call/" segment in the function name produced
// `does not export "call/collect_account"` — the plugin was correct, the router
// was not, and every call 502'd while the exports list looked perfectly fine.
//
// These tests drive the real router, so a refactor that reintroduces the
// off-by-one is caught here rather than in production.
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
)
// callPlugin exposes exactly one export plus a function that is NOT exported.
// If the exports gate is not honoured, secretFn is reachable and with it the
// plugin's private state — for a plugin holding an API key that means handing
// the key to anyone who can POST to the gateway.
const callPlugin = `
local P = {}
P.name = "calltest"
P.version = "1.0.0"
P.exports = { "add" }
P.secret = "do-not-leak"
function P.add(a)
return { result = (tonumber(a and a.x) or 0) + 1 }
end
function P.secretFn()
return { leaked = P.secret }
end
return P
`
func gatewayWithCallPlugin(t *testing.T) *Gateway {
t.Helper()
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")
body := "listen: :0\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"gateway_keys:\n - sk-test\n"
if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
if err := c.Plugins().LoadSource("calltest", callPlugin); err != nil {
t.Fatalf("load calltest: %v", err)
}
g, err := New(c)
if err != nil {
t.Fatalf("gateway: %v", err)
}
return g
}
func TestPluginCallSplitsFunctionNameCorrectly(t *testing.T) {
g := gatewayWithCallPlugin(t)
rr := doReq(t, g, http.MethodPost,
"/api/plugins/calltest/call/add", `{"x":41}`)
if rr.Code != http.StatusOK {
t.Fatalf("call failed: HTTP %d %s", rr.Code, rr.Body.String())
}
var out struct {
Result struct {
Result int `json:"result"`
} `json:"result"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
}
// 41 + 1 == 42. Reaching 42 proves BOTH halves of the contract: the "/call/"
// segment did not leak into the function name, and the payload arrived.
if out.Result.Result != 42 {
t.Fatalf("got %d, want 42 — either the /call/ segment leaked into the "+
"function name or the argument table never reached Lua", out.Result.Result)
}
}
func TestPluginCallRefusesUndeclaredFunction(t *testing.T) {
g := gatewayWithCallPlugin(t)
rr := doReq(t, g, http.MethodPost,
"/api/plugins/calltest/call/secretFn", `{}`)
if rr.Code == http.StatusOK {
t.Fatalf("an unexported function must not be callable, got 200: %s",
rr.Body.String())
}
if strings.Contains(rr.Body.String(), "do-not-leak") {
t.Fatal("the refusal leaked the plugin's private state")
}
}
// A plugin that declares no exports at all has no callable surface. Reading the
// missing list as "allow everything" would turn every plugin into a remote
// evaluator the moment this endpoint ships.
func TestPluginCallWithoutExportsHasNoCallableSurface(t *testing.T) {
g := gatewayWithCallPlugin(t)
const bare = `
local P = {}
P.name = "bare"
P.version = "1.0.0"
function P.anything()
return { ok = true }
end
return P
`
if err := g.core.Plugins().LoadSource("bare", bare); err != nil {
t.Fatalf("load bare: %v", err)
}
rr := doReq(t, g, http.MethodPost,
"/api/plugins/bare/call/anything", `{}`)
if rr.Code == http.StatusOK {
t.Fatalf("a plugin with no plugin.exports must expose nothing, got 200: %s",
rr.Body.String())
}
}
func TestPluginCallIsAdminOnly(t *testing.T) {
g := gatewayWithCallPlugin(t)
// A real user-role key, not a bare one from config: sk-test/sk-user in the
// config both default to admin, so a test using them would pass the gate for
// the wrong reason and prove nothing.
rec, err := g.core.CreateKey("viewer", "user", nil, "")
if err != nil {
t.Fatal(err)
}
rr := doReqAs(t, g, rec.Key, http.MethodPost,
"/api/plugins/calltest/call/add", `{"x":1}`)
if rr.Code != http.StatusForbidden {
t.Fatalf("a non-admin key reached the call endpoint: HTTP %d %s",
rr.Code, rr.Body.String())
}
}

View File

@ -0,0 +1,266 @@
package gateway
// Mount for plugin-registered HTTP endpoints.
//
// The gateway owns the listener, the authentication, the role check and the
// response framing; the plugin owns the logic. That split is the point: a plugin
// cannot open a socket, cannot bypass auth, and cannot choose a path outside
// /api/plugins/http/.
//
// Why this exists rather than "plugins can call each other": some things are
// worth querying from OUTSIDE the browser — a CI job asking whether the upstream
// balance is above zero, a monitoring probe, another tool on the box. billing
// never wanted this (it only receives hooks); a quota plugin does. Hard-coding a
// route per plugin would mean a gateway change for every such plugin, and an
// arbitrary plugin's code in the main mux is not something the routing table
// should ever grow.
import (
"net/http"
"sort"
"strconv"
"strings"
"llmsproxy/internal/lua"
)
// pluginHTTPPrefix is the mount point. It is deliberately narrow: a plugin's
// routes live under one prefix, which makes them greppable, makes them easy to
// revoke wholesale, and means a plugin cannot shadow /api/keys or /api/stats.
const pluginHTTPPrefix = "/api/plugins/http/"
// handlePluginHTTP dispatches a request to whichever plugin registered the
// longest matching path.
//
// Longest-match rather than first-match: a plugin may register /health and
// another /health/deep, and both must work. With registration order deciding it,
// the outcome would depend on load order and change when a plugin is added.
func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, pluginHTTPPrefix)
ps := g.core.Plugins()
if ps == nil {
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
return
}
matches := ps.HTTPHandlers()
var chosen *lua.HTTPHandler
var params map[string]string
// contenders holds the handlers that matched at the current best depth with
// the SAME literal route pattern — i.e. two plugins that both declared this
// exact path. That is the contested case the operator must arbitrate (user
// requirement A): rather than let load order pick a silent winner, a
// contested path with no recorded decision returns 409 naming the candidates.
var contenders []lua.HTTPHandler
for i := range matches {
h := matches[i]
p, ok := matchHandlerPath(h.Path, rest)
if !ok {
continue
}
switch {
case chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path):
chosen, params = &matches[i], p
contenders = []lua.HTTPHandler{matches[i]}
case segmentCount(h.Path) == segmentCount(chosen.Path) && h.Path == chosen.Path:
// Same route pattern, a different plugin. The winner is arbitrated below.
contenders = append(contenders, matches[i])
}
}
if chosen == nil {
writeError(w, http.StatusNotFound, "not_found",
"no plugin serves this path")
return
}
if len(contenders) > 1 {
resolved := g.resolveContestedRoute(w, ps, chosen.Path, contenders)
if resolved == nil {
return // 409 already written, naming the candidates
}
chosen = resolved
}
if !strings.EqualFold(r.Method, chosen.Method) {
w.Header().Set("Allow", chosen.Method)
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
"this endpoint accepts "+chosen.Method+" only")
return
}
role := reqRole(r.Context())
if !chosen.Allows(role, reqKeyName(r.Context())) {
writeError(w, http.StatusForbidden, "forbidden",
handlerAccessHint(*chosen))
return
}
body, err := readBody(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
req := &lua.HandlerRequest{
Method: r.Method,
Path: rest,
Query: flattenQuery(r.URL.Query()),
Params: params,
Headers: flattenHeader(r.Header),
Body: body,
Role: role,
Key: reqKey(r.Context()),
}
resp, err := ps.InvokeHandler(handlerOwner(chosen), chosen.Handler, req)
if err != nil {
// A plugin error is a 502, not a 500: the gateway is fine, the plugin is
// not, and the operator needs to tell those apart in their logs.
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
lua.WriteHandlerResponse(w, resp)
}
// resolveContestedRoute arbitrates a service path that more than one plugin
// declared (user requirement A). It returns the winning handler, or nil after
// writing a 409 when the operator has not yet chosen.
//
// This is the crux of the "report, don't crash, don't silently pick" rule: with
// two plugins both serving e.g. "stats", load order used to decide the winner.
// Now an unresolved contest is a 409 that NAMES the candidates, and the operator
// picks one in the WebUI (POST /api/plugins/conflicts). A recorded decision is
// honoured here on every request thereafter.
func (g *Gateway) resolveContestedRoute(w http.ResponseWriter, ps *lua.Plugins, path string, contenders []lua.HTTPHandler) *lua.HTTPHandler {
winner := ps.RouteWinner(path)
if winner != "" {
for i := range contenders {
if contenders[i].Plugin == winner {
return &contenders[i]
}
}
// A decision exists but points at a plugin that no longer serves this
// path (it was removed or edited). Fall through to 409 rather than honour
// a stale winner that cannot answer.
}
names := make([]string, 0, len(contenders))
for i := range contenders {
names = append(names, contenders[i].Plugin)
}
sort.Strings(names)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
writeJSON(w, http.StatusConflict, map[string]interface{}{
"error": map[string]interface{}{
"type": "route_conflict",
"message": "service path \"" + path + "\" is claimed by " + strconv.Itoa(len(names)) + " plugins (" + strings.Join(names, ", ") + "); an operator must choose one in the admin UI (Conflicts) or POST /api/plugins/conflicts",
"path": path,
"candidates": names,
},
})
return nil
}
// handlerAccessHint explains, in the refusal, exactly which declaration would
// open this endpoint. A 403 with no reason is the kind of thing an operator
// opens the source to work out, and then the fix is a guess.
func handlerAccessHint(h lua.HTTPHandler) string {
switch {
case len(h.Keys) > 0:
return "this endpoint allows only the named keys (" +
strings.Join(h.Keys, ", ") + "); authenticate with one of them"
case len(h.Roles) > 0:
return "this endpoint requires role " + strings.Join(h.Roles, " or ") +
" (widen it in the plugin's http declaration, or set public=true)"
default:
return "this endpoint declares no roles, so it is admin-only; " +
"set roles=… or keys=… in the plugin's http declaration"
}
}
// handlerOwner returns the plugin name that registered h. The gateway stores the
// owner alongside the handler precisely so this does not have to be guessed from
// the path prefix.
func handlerOwner(h *lua.HTTPHandler) string { return h.Plugin }
// segmentCount is how deep a route is, used for longest-match. A path with more
// segments wins, so /health/deep beats /health when both are registered.
func segmentCount(p string) int { return strings.Count(p, "/") }
// matchHandlerPath matches a route pattern against a concrete path, capturing
// ":name" segments.
//
// Returns ok=false on a segment-count mismatch or a missing value for a
// param — a route must not match with an empty capture, or two different URLs
// would reach the same handler indistinguishably.
func matchHandlerPath(pattern, path string) (map[string]string, bool) {
pp := strings.Split(strings.Trim(pattern, "/"), "/")
sp := strings.Split(strings.Trim(path, "/"), "/")
if len(pp) != len(sp) {
return nil, false
}
var params map[string]string
for i := range pp {
if strings.HasPrefix(pp[i], ":") {
name := pp[i][1:]
if sp[i] == "" {
return nil, false
}
if params == nil {
params = map[string]string{}
}
params[name] = sp[i]
continue
}
if pp[i] != sp[i] {
return nil, false
}
}
return params, true
}
func flattenQuery(q map[string][]string) map[string]string {
out := make(map[string]string, len(q))
for k, v := range q {
if len(v) > 0 {
out[k] = v[0]
}
}
return out
}
func flattenHeader(h http.Header) map[string]string {
out := make(map[string]string, len(h))
for k := range h {
out[strings.ToLower(k)] = h.Get(k)
}
return out
}
func readBody(r *http.Request) (string, error) {
if r.Body == nil {
return "", nil
}
defer r.Body.Close()
// A cap, because the handler runs inside the gateway process: an unbounded
// body from an authenticated caller is a memory exhaustion vector.
const maxBody = 4 << 20
buf := make([]byte, 0, 4096)
tmp := make([]byte, 32*1024)
for {
n, err := r.Body.Read(tmp)
if n > 0 {
if len(buf)+n > maxBody {
return "", &bodyTooLarge{}
}
buf = append(buf, tmp[:n]...)
}
if err != nil {
break
}
}
return string(buf), nil
}
type bodyTooLarge struct{}
func (bodyTooLarge) Error() string { return "request body exceeds 4 MiB" }
// limitBody is kept for the numeric form some callers want in a log line.
var _ = strconv.Itoa

View File

@ -82,6 +82,57 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/plugins")
path = strings.Trim(path, "/")
// /api/plugins/conflicts — the arbitration surface (user requirement).
// GET lists every contested route (A) and every suspended adapter-name
// install (B), each with its candidates/incumbent and any recorded
// decision.
// POST {kind,path|name,winner} records the operator's choice and persists
// it. Must be routed BEFORE the single-segment {name} handler below,
// which would otherwise read "conflicts" as a plugin name.
if path == "conflicts" {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{
"routes": ps.RouteConflicts(),
"adapters": ps.AdapterConflicts(),
})
case http.MethodPost:
var body struct {
Kind string `json:"kind"` // "route" or "adapter"
Path string `json:"path"`
Name string `json:"name"`
Winner string `json:"winner"` // route: plugin name; adapter: "incumbent"|"candidate"
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
switch body.Kind {
case "route":
if err := ps.ResolveRouteConflict(body.Path, body.Winner); err != nil {
writeError(w, http.StatusBadRequest, "conflict_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "path": body.Path, "winner": body.Winner})
case "adapter":
if err := ps.ResolveAdapterConflict(body.Name, body.Winner); err != nil {
writeError(w, http.StatusBadRequest, "conflict_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name, "winner": body.Winner})
default:
writeError(w, http.StatusBadRequest, "invalid_request", "kind must be \"route\" or \"adapter\"")
}
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
return
}
// /api/plugins/{name}/state
if strings.HasSuffix(path, "/state") {
name := strings.TrimSuffix(path, "/state")
@ -93,6 +144,60 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
return
}
// /api/plugins/{name}/http/{path}/access — the operator's view of, and edit
// surface for, which keys an endpoint answers.
if i := strings.Index(path, "/http/"); i >= 0 {
name := path[:i]
rest := path[i+len("/http/"):]
if strings.HasSuffix(rest, "/access") {
epPath := strings.TrimSuffix(rest, "/access")
g.handleEndpointAccess(w, r, name, epPath)
return
}
}
// /api/plugins/{name}/kernel — the operator's view of, and edit surface for,
// which kernel actions a plugin may call, plus its declared extra outbound hosts.
if strings.HasSuffix(path, "/kernel") {
name := strings.TrimSuffix(path, "/kernel")
if name == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "plugin name required")
return
}
g.handlePluginKernel(w, r, name)
return
}
// /api/plugins/{name}/login — REMOVED. Login sessions are plugin domain:
// channels.lua owns its own session state and serves it through its own
// mount. The kernel has no login concept.
// /api/plugins/{name}/call/{fn}
if i := strings.Index(path, "/call/"); i >= 0 {
name := path[:i]
fn := path[i+len("/call/"):]
if name == "" || fn == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "call needs a plugin and a function name")
return
}
g.handlePluginCall(w, r, name, fn)
return
}
// /api/plugins/{name}/fanout/{fn} — one call per argument set, in
// parallel, from one request. See handlePluginFanout for why the
// request-per-item form cannot be made concurrent from the client.
if i := strings.Index(path, "/fanout/"); i >= 0 {
name := path[:i]
fn := path[i+len("/fanout/"):]
if name == "" || fn == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "fanout needs a plugin and a function name")
return
}
g.handlePluginFanout(w, r, name, fn)
return
}
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
@ -106,6 +211,11 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
"on_disk": ps.OnDisk(),
"hook_errors": ps.HookErrors(),
"plugin_dir": g.pluginDir(),
// Visibility overrides and the endpoint access table, so the
// plugin page renders what is IN FORCE rather than making the
// operator read .lua files or config.yaml to find out.
"visibility": ps.VisibilityOverrides(),
"http_access": ps.EndpointAccess(),
})
case http.MethodPost:
var body struct {
@ -211,15 +321,33 @@ func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name
ps := g.core.Plugins()
switch r.Method {
case http.MethodGet:
// Any role may read: plugin state is reporting data (cost, counts),
// and the caller has already been authenticated. Admin-only would stop
// a user key's own billing widget from rendering.
// The plugin's audience gates its DATA as well as its page. Hiding the UI
// while leaving GET /state open would put the numbers one curl away, which
// is the leak the audience field exists to close — and billing's state is
// a global ledger, not the caller's own usage.
//
// A plugin that declares no audience is unchanged (every role may read
// it), so a single-purpose widget keeps working without edits.
role := reqRole(r.Context())
if !ps.StateVisibleTo(name, role) {
writeError(w, http.StatusForbidden, "forbidden",
"plugin "+name+" is not visible to role "+role)
return
}
// prices rides along: the billing rules editor must show the table
// that is actually in effect, not a reconstruction from config. It is
// absent for plugins with no prices (every plugin but billing).
state := ps.State(name)
// A plugin may SPLIT its state: global buckets stay deployment-wide while
// per-caller buckets are narrowed to the authenticated key. Role
// visibility alone cannot express this — the same role is allowed to read
// the plugin, just not another caller's row.
if scope := ps.StateScopeFor(name); scope != nil && role != "admin" {
state = splitStateForKey(state, scope, reqKey(r.Context()))
}
payload := map[string]interface{}{
"plugin": name,
"state": ps.State(name),
"state": state,
}
if pr := ps.Prices(name); pr != nil {
payload["prices"] = pr
@ -245,6 +373,107 @@ func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name
}
}
// Call invokes a named function on a plugin's Lua state and returns its result.
//
// This exists because plugin pages otherwise have NO way to reach their own
// plugin code: pluginAPI offers state read/write and a couple of callbacks, and
// that is enough for billing (whose numbers arrive on hooks during request
// forwarding) but useless for a plugin that must do work on demand. A quota
// page has to ask the vendor when the operator clicks Refresh, and no hook
// fires at that moment.
//
// It is deliberately narrow:
// - The function must be one the plugin explicitly lists in plugin.exports.
// Anything else is refused, so this endpoint cannot become a generic "run
// arbitrary Lua with my credentials" oracle over a plugin's private state.
// - The caller must be admin, and the result is JSON — no Lua value is ever
// handed back to Go as a live closure.
// - Errors come back as (nil, message) from the plugin, matching every other
// plugin entry point, and surface as a 502 rather than raising.
func (g *Gateway) handlePluginCall(w http.ResponseWriter, r *http.Request, name, fn string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
return
}
var args map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
result, err := g.core.Plugins().Call(name, fn, args)
if err != nil {
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"result": result})
}
// handlePluginFanout runs one plugin export per element of a list, in parallel,
// from a SINGLE request.
//
// Why this exists: the quota page has twenty cards, and it used to fire twenty
// browser requests at /call/. They arrived concurrently and then serialised
// inside Plugins.Call, which holds the plugin mutex across the Lua function —
// including its network wait. Measured on the live gateway: 20 probes took 29s of
// wall clock while a single probe with no contention took 1.4–3.3s, so the page
// sat on "probing…" for half a minute. The browser-side concurrency was real and
// made no difference.
//
// A fan-out cannot be assembled from the client either: any request-per-item
// arrangement is exactly the shape that queues. The parallelism has to happen
// where the fan-out is received.
func (g *Gateway) handlePluginFanout(w http.ResponseWriter, r *http.Request, name, fn string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
return
}
var req struct {
// Args is a LIST of argument objects, one per call. A bare object is
// rejected rather than coerced: "args" meaning "one call" and "args"
// meaning "many" depending on its shape is how a caller ends up probing
// one source and believing it probed twenty.
Args []map[string]interface{} `json:"args"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if len(req.Args) == 0 {
writeError(w, http.StatusBadRequest, "invalid_request", "args must be a non-empty list of argument objects")
return
}
// The page legitimately has one card per configured source. The bound is not
// arbitrary: it stops one request from asking for thousands of VMs, and it is
// enforced before any work starts rather than discovered shard by shard.
if len(req.Args) > fanoutMaxArgs {
writeError(w, http.StatusBadRequest, "invalid_request",
fmt.Sprintf("args has %d entries, limit is %d", len(req.Args), fanoutMaxArgs))
return
}
results, err := g.core.Plugins().ShardCall(name, fn, req.Args)
if err != nil {
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
// A shard that failed contributes a null in its slot. That is deliberate and
// is NOT the same as the whole request failing: one unreachable upstream must
// not turn a page of twenty cards into a single error.
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
}
// fanoutMaxArgs bounds one fan-out request. Twenty is the measured real source
// count on the gateway this was built for; the headroom is for operators with
// more sources configured.
const fanoutMaxArgs = 64
func (g *Gateway) pluginDir() string {
if c := g.core.Config(); c != nil {
return c.PluginDir
@ -282,3 +511,241 @@ func (g *Gateway) removePlugin(name string) error {
_ = os.Remove(filepath.Join(g.pluginDir(), name+".lua"))
return g.core.Plugins().Unload(name)
}
// handleEndpointAccess serves the operator's view and edit surface for one
// plugin endpoint's reachable keys.
//
// GET …/http/{path}/access the declared rules plus any override
// PUT …/http/{path}/access body {"access": "admin,@homeagent"}
// DELETE …/http/{path}/access clear the override
//
// The spec string is a comma list: a bare word is a role, "@name" is a gateway
// key's configured name, and the literal "public" opens it to any
// authenticated key. That is the same grammar the visibility override uses, and
// keeping one grammar means an operator learns it once.
//
// Key names are validated against the configured keys BEFORE being stored. An
// allowlist naming a key that does not exist is a silent no-op — every caller
// is refused — and the operator would have no way to tell that apart from the
// endpoint simply working.
func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, name, epPath string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
ps := g.core.Plugins()
// epPath "*" is the WHOLE-PLUGIN visibility override, not an endpoint. It
// shares this route (and therefore this store) on purpose: an operator
// setting "who can see this plugin" and "who can call this endpoint" is
// asking the same question at two granularities, and two stores would let
// them disagree. SetEndpointAccess cannot serve it — it validates that the
// endpoint exists, which "*" by definition does not.
if epPath == pluginWholeOverride {
g.handlePluginVisibility(w, r, name)
return
}
switch r.Method {
case http.MethodGet:
for _, e := range ps.EndpointAccess() {
if e.Plugin == name && e.Path == epPath {
writeJSON(w, http.StatusOK, map[string]interface{}{"endpoint": e})
return
}
}
writeError(w, http.StatusNotFound, "not_found", "no such endpoint")
case http.MethodPut, http.MethodDelete:
spec := ""
if r.Method == http.MethodPut {
var body struct {
Access string `json:"access"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
spec = body.Access
}
if err := ps.ValidateAccessSpec(spec); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := ps.SetEndpointAccess(name, epPath, spec); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "access": spec})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// splitStateForKey rewrites a plugin state so that a non-admin caller sees only
// the buckets that belong to them.
//
// The plugin declares which buckets are global and which are per-key. A per-key
// bucket is keyed by the MASKED key ("***a1b2c3" — the same value the audit log
// uses), so the caller's own row is found by masking the presented secret and
// matching the last six characters.
//
// Keys that do not match are DROPPED, not zeroed: a zeroed row would render as
// "you spent nothing" and, worse, would leak that a row exists for someone else.
func splitStateForKey(state interface{}, scope *lua.StateScope, key string) interface{} {
m, ok := state.(map[string]interface{})
if !ok || scope == nil || key == "" {
return state
}
masked := maskKey(key)
out := make(map[string]interface{}, len(m))
for k, v := range m {
if !scope.IsGlobal(k) {
// Per-key bucket: keep only the caller's rows, at every nesting level
// the scope mentions (by_day_key is {day: {masked: bucket}}).
out[k] = filterKeyBuckets(v, masked, scope.DepthOf(k))
continue
}
out[k] = v
}
return out
}
// filterKeyBuckets narrows a keyed structure to one masked key.
//
// depth 1 is {masked: bucket}; depth 2 is {day: {masked: bucket}}. The level is
// declared rather than sniffed, because both shapes are dicts and sniffing would
// confuse an empty bucket with a nested one.
func filterKeyBuckets(v interface{}, masked string, depth int) interface{} {
switch depth {
case 0:
return v // not a per-key bucket; leave alone
case 1:
m, ok := v.(map[string]interface{})
if !ok {
return v
}
if row, ok := m[masked]; ok {
return map[string]interface{}{masked: row}
}
return map[string]interface{}{}
default:
m, ok := v.(map[string]interface{})
if !ok {
return v
}
out := make(map[string]interface{}, len(m))
for day, inner := range m {
im, ok := inner.(map[string]interface{})
if !ok {
continue
}
if row, ok := im[masked]; ok {
out[day] = map[string]interface{}{masked: row}
}
}
return out
}
}
// pluginWholeOverride is the epPath that addresses a plugin's overall visibility
// rather than one of its endpoints.
const pluginWholeOverride = "*"
// handlePluginVisibility is the whole-plugin half of the access API. The
// audience string uses the same vocabulary as an endpoint spec (roles, @key
// names, public) so the UI can drive both from one input.
func (g *Gateway) handlePluginVisibility(w http.ResponseWriter, r *http.Request, name string) {
ps := g.core.Plugins()
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{
"plugin": name,
"visibility": ps.VisibilityOverrides()[name],
})
case http.MethodPut, http.MethodDelete:
spec := ""
if r.Method == http.MethodPut {
var body struct {
Access string `json:"access"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
spec = body.Access
}
if err := ps.ValidateAccessSpec(spec); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
ps.SetPluginVisibility(name, spec)
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "visibility": spec})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method+" is not allowed here")
}
}
// handlePluginKernel is the operator's surface for a plugin's kernel grants and
// declared extra outbound hosts.
//
// GET returns what the plugin DECLARED beside what was GRANTED. That pairing is
// the point of the endpoint: a plugin declaring `add_source` is a request, and
// the operator needs to see the request next to the current answer to decide.
func (g *Gateway) handlePluginKernel(w http.ResponseWriter, r *http.Request, name string) {
ps := g.core.Plugins()
if ps == nil {
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
return
}
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
switch r.Method {
case http.MethodGet:
perm := ps.PluginKernelPermission(name)
declared := []string(nil)
for _, v := range ps.KernelPermissions() {
if v.Plugin == name {
declared = v.Declared
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"plugin": name,
"actions": ps.KernelActionsView(name),
"declared": declared,
"granted": perm.Actions,
"all_read": perm.AllRead,
// Extra outbound hosts: declared vs granted, same request/privilege
// split as kernel actions. What the plugin does with a host (login,
// claim quotas...) is plugin business — the kernel only bounds egress.
"hosts_declared": ps.PluginExtraHosts(name),
"hosts_granted": ps.ExtraHostsGranted()[name],
})
return
case http.MethodPut, http.MethodPost:
var body struct {
Actions []string `json:"actions"`
AllRead bool `json:"all_read"`
Hosts []string `json:"hosts"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := ps.SetPluginKernelPermission(name, body.Actions, body.AllRead); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.Hosts != nil {
if err := ps.GrantExtraHosts(name, body.Hosts); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
return
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET or PUT")
}
}

View File

@ -210,3 +210,54 @@ func TestPluginStatePUTIsAdminOnly(t *testing.T) {
t.Errorf("admin PUT state = %d: %s", adm.Code, adm.Body.String())
}
}
// TestPluginsListCarriesAccessTables: the plugin page renders visibility
// overrides and the endpoint access table from /api/plugins. Without them in the
// response the operator has to open a .lua or config.yaml to learn who can see
// what, which is exactly what the UI is meant to avoid.
func TestPluginsListCarriesAccessTables(t *testing.T) {
g := gatewayWithCallPlugin(t)
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
if rr.Code != http.StatusOK {
t.Fatalf("GET /api/plugins = %d", rr.Code)
}
var out struct {
Visibility map[string]string `json:"visibility"`
HTTPAccess []map[string]interface{} `json:"http_access"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
}
// The calltest plugin registers no endpoints, so the table is present but
// empty — the KEY is that the field exists at all. A missing field decodes to
// nil and the UI silently renders nothing, which looks identical to "this
// plugin has no endpoints".
if !strings.Contains(rr.Body.String(), "http_access") {
t.Error("/api/plugins response lacks the http_access field")
}
if !strings.Contains(rr.Body.String(), "visibility") {
t.Error("/api/plugins response lacks the visibility field")
}
}
// TestPluginWholeVisibilityViaAccessAPI covers the "*" path: the plugin page
// edits a plugin's overall audience through the same route it edits endpoints
// with. Before this existed the UI had a visibility selector whose save 404'd,
// because SetEndpointAccess validates that the endpoint exists and "*" does not.
func TestPluginWholeVisibilityViaAccessAPI(t *testing.T) {
g := gatewayWithCallPlugin(t)
rr := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
`{"access":"admin, user"}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT */access = %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), `"ok":true`) {
t.Errorf("unexpected body: %s", rr.Body.String())
}
// A user role is a legitimate audience; a nonsense one is not.
bad := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
`{"access":"../../etc"}`)
if bad.Code != http.StatusBadRequest {
t.Errorf("traversal-shaped audience = %d, want 400: %s", bad.Code, bad.Body.String())
}
}

View File

@ -0,0 +1,466 @@
// Package gateway — 对外序列化(Phase 2)
//
// 职责边界(feature/unified-wire-v2):
//
// 内核不硬编码任何对外协议。序列化由 Serializer 承载,默认实现是
// openAISerializer —— 但它只是**最后容错**,不是默认业务路径。
//
// 优先级契约(用户明确要求:任何适配器与兜底 OpenAI 格式冲突时,
// 兜底 OpenAI 直接让步):
//
// 适配器声明 serialize_response / serialize_chunk
// ↓ 未声明
// openAISerializer(内核兜底,保证刚部署未配适配器时仍能服务)
//
// 「让步」的具体含义:一旦适配器声明了对外序列化钩子,内核在**该源**
// 上完全采用适配器的输出,绝不与兜底格式合并/兜底字段。A 请求经 Anthropic
// 序列化后,绝不会被再补上 OpenAI 的字段。这条规则由
// TestAdapterSerializerWinsOverFallback 锁定。
//
// 安全网:openAISerializer 的输出与改造前内核手写的 OpenAI wire 字节级
// 一致(e2e/.snapshots golden 锁定),所以「适配器未声明」这条路径上的
// 对外行为与本次改造之前完全相同。
package gateway
import (
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"time"
"llmsproxy/internal/lua"
"llmsproxy/internal/provider"
"llmsproxy/internal/types"
)
// StreamState 是一个流会话在序列化侧的累积状态(角色前导是否已发、已见
// 的终止原因、累积 usage/cost)。跨 chunk 传递,避免每帧重新统计。
type StreamState struct {
LastFinish string
Usage *types.TokenUsage
Cost string
}
// Serializer 把统一内部格式序列化成对外协议的响应体。
//
// 非流式与流式的终止语义对不同协议差异很大(OpenAI 在最后一个 chunk 上
// 带 finish_reason 后跟一个 usage-only 帧;Anthropic 用独立的
// message_delta 事件;Gemini 用空 candidates 帧),所以「流如何结束」
// 是接口的一部分,而不是调用方的 if。
type Serializer interface {
// NonStream 把一次完成的统一响应序列化成对外 body。
NonStream(resp *types.UnifiedResponse, modelName string, id string, created int64) ([]byte, error)
// OpenRolePreamble 返回流开始时的角色前导帧(可能为空表示该协议不需要)。
// 返回空切片时调用方不写帧。
OpenRolePreamble(modelName, id string, created int64, st *StreamState) ([]byte, error)
// Chunk 把一个统一分块序列化成对外 SSE 帧(不含 "data: " 前缀与空行)。
Chunk(ck *types.UnifiedChunk, modelName, id string, created int64, st *StreamState) ([]byte, error)
// Close 返回流结束时的收尾帧序列(可多帧),随后调用方写协议自己的
// 流终止标记(OpenAI 是 [DONE],Anthropic 无)。
Close(modelName, id string, created int64, st *StreamState) ([][]byte, error)
// DoneMarker 返回该协议的流终止标记;OpenAI 是 "[DONE]",无此概念的
// 协议返回空串(调用方因此不写那一行)。
DoneMarker() string
}
// ---- 兜底实现:OpenAI wire ----
// openAISerializer 是内核的**容错兜底**,输出与改造前手写的 OpenAI wire
// 字节级一致(golden 锁定)。它不是默认业务路径——适配器一旦声明序列化
// 钩子就完全取代它。
type openAISerializer struct{}
// OpenAISerializer 返回兜底 OpenAI 序列化器。暴露给同包测试与
// 「未配置任何适配器」的启动兜底路径使用。
func OpenAISerializer() Serializer { return openAISerializer{} }
func (openAISerializer) NonStream(resp *types.UnifiedResponse, modelName, id string, created int64) ([]byte, error) {
msg := RespMessage{Role: "assistant", Content: resp.Content}
if resp.ReasoningContent != "" {
msg.ReasoningContent = resp.ReasoningContent
}
if len(resp.ToolCalls) > 0 {
msg.ToolCalls = toolCallsWire(resp.ToolCalls)
}
out := ChatCompletion{
ID: id,
Object: "chat.completion",
Created: created,
Model: modelName,
Choices: []ChatChoice{{Index: 0, Message: msg, FinishReason: resp.FinishReason}},
Cost: resp.Cost,
}
if resp.TokenUsage.Total > 0 || resp.TokenUsage.Prompt > 0 || resp.TokenUsage.Completion > 0 {
out.Usage = &resp.TokenUsage
}
return json.Marshal(out)
}
func (openAISerializer) OpenRolePreamble(modelName, id string, created int64, st *StreamState) ([]byte, error) {
return json.Marshal(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{Role: "assistant"}}},
})
}
func (openAISerializer) Chunk(ck *types.UnifiedChunk, modelName, id string, created int64, st *StreamState) ([]byte, error) {
delta := RespMessage{Role: "assistant", Content: ck.Content}
if ck.ReasoningContent != "" {
delta.ReasoningContent = ck.ReasoningContent
}
if len(ck.ToolCalls) > 0 {
delta.ToolCalls = ck.ToolCalls
}
choice := ChunkChoice{Index: 0, Delta: delta}
if ck.Done {
fin := ck.FinishReason
if fin == "" {
fin = "stop"
}
st.LastFinish = fin
choice.FinishReason = &fin
}
return json.Marshal(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{choice},
})
}
func (openAISerializer) Close(modelName, id string, created int64, st *StreamState) ([][]byte, error) {
fin := st.LastFinish
if fin == "" {
fin = "stop"
}
term, err := json.Marshal(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &fin}},
})
if err != nil {
return nil, err
}
out := [][]byte{term}
// OpenAI 的 usage 帧:空 choices + usage + 可选 cost。
if st.Usage != nil || st.Cost != "" {
var tut *types.TokenUsage
if st.Usage != nil {
tut = st.Usage
} else {
tut = &types.TokenUsage{}
}
u, err := json.Marshal(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{}, Usage: tut, Cost: st.Cost,
})
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, nil
}
func (openAISerializer) DoneMarker() string { return "[DONE]" }
// ---- 适配器钩子通道 ----
// luaSerializer 把对外序列化委托给适配器的 serialize_response /
// serialize_chunk 钩子。适配器声明了钩子就**完全取代**兜底实现。
type luaSerializer struct {
vm *lua.VM
adapter string
// responseOK / chunkOK 在构造时一次性探测(HasFn 不占用 worker)。
responseOK bool
chunkOK bool
// openPreambleOK / closeOK 是可选钩子;未声明时借用兜底实现的对应行为,
// 这样「只实现 serialize_chunk」的适配器也能立刻可用。
openPreambleOK bool
closeOK bool
}
// serializerFor 为某个适配器名挑选序列化器。
//
// 这是「适配器优先、兜底让步」的唯一决策点:适配器声明了对外序列化
// 钩子就返回 luaSerializer(其输出不再与 OpenAI 兜底混合),否则返回
// 兜底 OpenAI。
func serializerFor(vm *lua.VM, adapter string) Serializer {
if vm == nil {
return OpenAISerializer()
}
resp := vm.HasFn(adapter, "serialize_response")
chunk := vm.HasFn(adapter, "serialize_chunk")
if !resp && !chunk {
return OpenAISerializer()
}
return &luaSerializer{
vm: vm,
adapter: adapter,
responseOK: resp,
chunkOK: chunk,
openPreambleOK: vm.HasFn(adapter, "serialize_open_preamble"),
closeOK: vm.HasFn(adapter, "serialize_close"),
}
}
func (s *luaSerializer) NonStream(resp *types.UnifiedResponse, modelName, id string, created int64) ([]byte, error) {
if !s.responseOK {
// 该适配器只实现了流式钩子:非流式沿用兜底。这是唯一允许兜底
// 参与的场景——适配器**没有**就该方向的实现,而不是「冲突」。
return openAISerializer{}.NonStream(resp, modelName, id, created)
}
payload := map[string]interface{}{
"model": modelName,
"id": id,
"created": created,
"content": resp.Content,
"reasoning": resp.ReasoningContent,
"finish_reason": resp.FinishReason,
"token_usage": resp.TokenUsage,
"cost": resp.Cost,
}
if len(resp.ToolCalls) > 0 {
payload["tool_calls"] = resp.ToolCalls
}
out, err := s.vm.Transform(s.adapter, "serialize_response", wireJSON(payload))
if err != nil || out == "" {
// 钩子失败:明确退回兜底,而不是产出一半适配器字段 + 一半 OpenAI
// 字段的混合体(那正是「让步」规则要避免的)。
return openAISerializer{}.NonStream(resp, modelName, id, created)
}
return []byte(out), nil
}
func (s *luaSerializer) OpenRolePreamble(modelName, id string, created int64, st *StreamState) ([]byte, error) {
if !s.openPreambleOK {
return openAISerializer{}.OpenRolePreamble(modelName, id, created, st)
}
out, err := s.vm.Transform(s.adapter, "serialize_open_preamble",
wireJSON(map[string]interface{}{"model": modelName, "id": id, "created": created}))
if err != nil {
return openAISerializer{}.OpenRolePreamble(modelName, id, created, st)
}
if out == "" {
return nil, nil // 该协议不需要角色前导
}
return []byte(out), nil
}
func (s *luaSerializer) Chunk(ck *types.UnifiedChunk, modelName, id string, created int64, st *StreamState) ([]byte, error) {
if !s.chunkOK {
return openAISerializer{}.Chunk(ck, modelName, id, created, st)
}
payload := map[string]interface{}{
"model": modelName,
"id": id,
"created": created,
"content": ck.Content,
"reasoning": ck.ReasoningContent,
"done": ck.Done,
"finish_reason": ck.FinishReason,
}
if len(ck.ToolCalls) > 0 {
payload["tool_calls"] = ck.ToolCalls
}
if ck.Usage != nil {
payload["usage"] = ck.Usage
}
if ck.Cost != "" {
payload["cost"] = ck.Cost
}
out, err := s.vm.Transform(s.adapter, "serialize_chunk", wireJSON(payload))
if err != nil {
return openAISerializer{}.Chunk(ck, modelName, id, created, st)
}
if out == "" {
return nil, nil
}
// 适配器的终止帧要驱动审计记录与收尾逻辑,所以把终止原因抄回状态。
if ck.Done && st.LastFinish == "" {
if fin := ck.FinishReason; fin != "" {
st.LastFinish = fin
}
}
return []byte(out), nil
}
func (s *luaSerializer) Close(modelName, id string, created int64, st *StreamState) ([][]byte, error) {
if !s.closeOK {
return openAISerializer{}.Close(modelName, id, created, st)
}
out, err := s.vm.Transform(s.adapter, "serialize_close",
wireJSON(map[string]interface{}{
"model": modelName, "id": id, "created": created,
"finish_reason": st.LastFinish, "cost": st.Cost,
"has_usage": st.Usage != nil,
}))
if err != nil {
return openAISerializer{}.Close(modelName, id, created, st)
}
if out == "" {
return nil, nil
}
// 适配器可以返回多帧(换行分隔),因为 Anthropic 的收尾本身就是
// message_stop + 若干 delta;单帧限制会逼适配器把多事件塞进一个 JSON。
var frames [][]byte
for _, line := range splitLines(out) {
if line == "" {
continue
}
frames = append(frames, []byte(line))
}
return frames, nil
}
// DoneMarker 返回协议自己的流终止标记。适配器可用 serialize_done_marker
// 声明;未声明时若有自定义流式序列化就返回空串(表示「没有 [DONE] 这种
// 概念」),否则沿用 OpenAI 的 [DONE]。
func (s *luaSerializer) DoneMarker() string {
if s.vm.HasFn(s.adapter, "serialize_done_marker") {
out, err := s.vm.Transform(s.adapter, "serialize_done_marker", "{}")
if err == nil {
return out
}
}
if s.chunkOK {
// 适配器接管了流式序列化:只有它没显式声明标记时才不发 [DONE],
// 因为擅自给 Anthropic 流加一行 "[DONE]" 会让严格客户端报错。
return ""
}
return "[DONE]"
}
// splitLines 切分多行帧输出。
func splitLines(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, trimCR(s[start:i]))
start = i + 1
}
}
if start < len(s) {
out = append(out, trimCR(s[start:]))
}
return out
}
func trimCR(s string) string {
if n := len(s); n > 0 && s[n-1] == '\r' {
return s[:n-1]
}
return s
}
// ---- 共享辅助 ----
var wireSeq int64
func newWireID() string {
n := atomic.AddInt64(&wireSeq, 1)
return fmt.Sprintf("chatcmpl-%d", n)
}
func newCreated() int64 { return time.Now().Unix() }
// wireJSON 是本文件私有的 JSON 编码助手(包内已有 discover.go 的 mustJSON,
// 那个返回 []byte 且忽略错误,语义不同,故不共用)。
func wireJSON(v interface{}) string {
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
// serializerCache 按 (VM, 适配器名) 缓存 Serializer。
//
// VM 必须进键里:缓存曾经只按适配器名做键,结果一个进程内两个 VM(或
// 热重载后的新旧 VM)会共享同一条判定。插件在运行时改写适配器后声明了
// serialize_response,另一条缓存记录仍会说「没声明」,于是新钩子永远不
// 生效——而且因为判定本身不会报错,表现只是「适配器说好的对外格式没
// 出现」,极难定位。
//
// HasFn 的代价决定了它必须被缓存:它会**编译一次适配器脚本**来回答
// 「这个适配器声明了哪些函数」,放在每个请求上等于把加载成本翻倍。
//
// 失效由 vmGeneration 负责:适配器重写时 VM 的代号会变(见 adapterEpoch),
// 于是旧条目再也匹配不上,无需显式清扫。
var serializerCache sync.Map // cacheKey -> Serializer
// cacheKey 是 (VM 指针, VM 代号, 适配器名) 的复合键。
type cacheKey struct {
vm *lua.VM
gen uint64
name string
}
func serializerForCached(vm *lua.VM, adapter string) Serializer {
if vm == nil {
return OpenAISerializer()
}
k := cacheKey{vm: vm, gen: adapterEpoch(vm, adapter), name: adapter}
if v, ok := serializerCache.Load(k); ok {
return v.(Serializer)
}
s := serializerFor(vm, adapter)
actual, _ := serializerCache.LoadOrStore(k, s)
return actual.(Serializer)
}
// adapterEpoch 返回该适配器「当前这份代码」的代号。适配器被重写后代号变化,
// 使旧缓存条目自动失效(见 serializerCache 的注释)。
func adapterEpoch(vm *lua.VM, adapter string) uint64 {
return vm.AdapterRevision(adapter)
}
// serializerForSource 解析「实际服务该请求的那个源」该用哪个序列化器。
//
// 必须按**服务者**选,而不是按调用方请求的首选源选:direct 请求在候选源
// 之间故障转移时,usedSrc 可能与请求 model 所属的源完全不同;按首选源选
// 会让故障转移后的响应带着另一个源的对外格式。
//
// 源名查不到时(配置在选择之后被改、或插件装的源尚未注册)退回兜底
// OpenAI:那是「刚部署 / 异常态下仍能服务」的保证,而不是静默产出错误格式。
func serializerForSource(vm *lua.VM, sources []SourceRef, sourceName string) Serializer {
for _, s := range sources {
if s.Name == sourceName {
return serializerForCached(vm, s.Adapter)
}
}
return OpenAISerializer()
}
// SourceRef 是解析序列化器所需的最小源信息(名字 + 适配器)。
type SourceRef struct {
Name string
Adapter string
}
// serializerFor 是 Gateway 侧的解析入口:按**实际服务该请求的源**挑序列化器。
//
// cands 传候选列表时用它做故障转移后的解析(服务源可能不是首选);传 nil
// 表示 AUTO 路径——AUTO 的服务源由链决定,不在调用方的候选集里,退回按
// 当前配置里全部源查找。
func (g *Gateway) serializerFor(sourceName string, cands []*provider.Provider) Serializer {
vm := g.core.VM()
if vm == nil {
return OpenAISerializer()
}
if cands != nil {
refs := make([]SourceRef, 0, len(cands))
for _, p := range cands {
refs = append(refs, SourceRef{Name: p.Name(), Adapter: p.Adapter()})
}
return serializerForSource(vm, refs, sourceName)
}
refs := make([]SourceRef, 0, 16)
for _, s := range g.core.Sources() {
refs = append(refs, SourceRef{Name: s.Name, Adapter: s.Adapter})
}
return serializerForSource(vm, refs, sourceName)
}

View File

@ -0,0 +1,367 @@
package gateway
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"llmsproxy/internal/lua"
"llmsproxy/internal/types"
)
// 本文件锁定对外序列化的**优先级契约**:
//
// 适配器声明 serialize_* → 适配器完全接管
// 适配器未声明 → 兜底 OpenAI(保证刚部署/未配适配器仍能服务)
//
// 以及「让步」的定义:适配器接管后,内核绝不再往它的输出上补任何 OpenAI
// 字段。两个序列化器混在一起产出的东西比任何一个都糟——客户端无法解析,
// 而排查时会指向错误的层。
// luaVMFor 建一个会 seed 内置适配器的 VM(目录名必须是 "adapters")。
func luaVMFor(t *testing.T) *lua.VM {
t.Helper()
vm := lua.NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatalf("vm start: %v", err)
}
t.Cleanup(vm.Stop)
return vm
}
// TestFallbackUsedWhenAdapterDeclaresNothing 是「兜底」本身的测试。
//
// 注:anthropic 现在**确实**声明了 serialize_*(Phase 2 给了它 Anthropic
// 形状),所以本测试只在仍未声明钩子的那些内置适配器上验证兜底行为。
// 这正是设计意图:谁声明谁接管,没声明的继续用 OpenAI 兜底。
func TestFallbackUsedWhenAdapterDeclaresNothing(t *testing.T) {
vm := luaVMFor(t)
for _, name := range []string{"openai", "gemini", "ollama", "deepseek", "github", "groq"} {
for _, hook := range []string{"serialize_response", "serialize_chunk"} {
if vm.HasFn(name, hook) {
t.Errorf("内置适配器 %s 声明了 %s —— 兜底测试的前提已不成立", name, hook)
}
}
}
ser := serializerFor(vm, "openai")
if _, isFallback := ser.(openAISerializer); !isFallback {
t.Fatalf("未声明钩子的适配器应得到兜底序列化器,得到 %T", ser)
}
// 对照:声明了钩子的适配器不走兜底
if _, isFallback := serializerFor(vm, "anthropic").(openAISerializer); isFallback {
t.Error("anthropic 声明了 serialize_response,不应走兜底")
}
}
// TestAdapterSerializerWinsOverFallback 锁定核心规则:适配器声明钩子即接管,
// 且内核不与兜底混合任何字段。
func TestAdapterSerializerWinsOverFallback(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "adapters"), 0o755); err != nil {
t.Fatal(err)
}
// 一个刻意用**非 OpenAI 形状**的适配器:如果内核混入了兜底字段
// (object/choices/usage 等),下面断言会失败。
adapter := `
local adapter = {}
adapter.name = "anthropicish"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
-- 非流式:故意用 content[] 分块 + 没有 choices/usage
function adapter.serialize_response(payload)
local p = json.decode(payload)
return json.encode({
type = "message",
role = "assistant",
model = p.model,
content = { { type = "text", text = p.content } },
stop_reason = (p.finish_reason ~= "" and p.finish_reason or "end_turn"),
})
end
-- 流式:event: style 的帧,且不带 OpenAI 的 object 字段
function adapter.serialize_chunk(payload)
local p = json.decode(payload)
return json.encode({
type = "content_block_delta",
delta = { type = "text_delta", text = p.content },
})
end
return adapter
`
if err := os.WriteFile(filepath.Join(dir, "adapters", "anthropicish.lua"), []byte(adapter), 0o644); err != nil {
t.Fatal(err)
}
vm := lua.NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatalf("vm: %v", err)
}
defer vm.Stop()
ser := serializerFor(vm, "anthropicish")
if _, isFallback := ser.(openAISerializer); isFallback {
t.Fatal("声明了 serialize_* 的适配器必须接管序列化,而不是拿到兜底")
}
// 非流式输出必须是适配器形状,且**不含**任何 OpenAI 字段
resp := &types.UnifiedResponse{
Model: "m",
Content: "hello",
FinishReason: "stop",
TokenUsage: types.TokenUsage{Prompt: 3, Completion: 1, Total: 4},
}
body, err := ser.NonStream(resp, "m", "id-1", 1700000000)
if err != nil {
t.Fatalf("NonStream: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("adapter output not JSON: %v (%s)", err, body)
}
if got["type"] != "message" {
t.Errorf("适配器输出未被采用:type = %v, want message", got["type"])
}
// 「让步」的硬断言:OpenAI 字段一个都不许出现
for _, k := range []string{"object", "choices", "usage", "created"} {
if _, leaked := got[k]; leaked {
t.Errorf("兜底 OpenAI 字段 %q 混进了适配器的输出(让步规则被违反): %s", k, body)
}
}
// 适配器没有读 usage,但兜底也不许替它补上
if _, has := got["token_usage"]; has {
t.Errorf("兜底补了 token_usage 字段,适配器输出被污染: %s", body)
}
}
// TestAdapterSerializerStreamWinsOverFallback 同上,流式路径。
func TestAdapterSerializerStreamWinsOverFallback(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "adapters"), 0o755); err != nil {
t.Fatal(err)
}
adapter := `
local adapter = {}
adapter.name = "streamsh"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
function adapter.serialize_chunk(payload)
local p = json.decode(payload)
return json.encode({ type = "content_block_delta", text = p.content })
end
-- 显式声明收尾与终止标记,证明协议语义完全由适配器决定
function adapter.serialize_close(payload)
local p = json.decode(payload)
return json.encode({ type = "message_stop", stop_reason = p.finish_reason })
end
return adapter
`
if err := os.WriteFile(filepath.Join(dir, "adapters", "streamsh.lua"), []byte(adapter), 0o644); err != nil {
t.Fatal(err)
}
vm := lua.NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatalf("vm: %v", err)
}
defer vm.Stop()
ser := serializerFor(vm, "streamsh")
if _, isFallback := ser.(openAISerializer); isFallback {
t.Fatal("声明 serialize_chunk 的适配器必须接管流式序列化")
}
st := &StreamState{}
b, err := ser.Chunk(&types.UnifiedChunk{Content: "hi"}, "m", "id", 1, st)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"content_block_delta"`) {
t.Errorf("chunk 未采用适配器形状: %s", b)
}
if strings.Contains(string(b), "chat.completion.chunk") || strings.Contains(string(b), `"choices"`) {
t.Errorf("兜底 OpenAI 帧混进了适配器输出: %s", b)
}
// 终止标记:适配器接管流式后,内核不得自作主张发 [DONE]
if m := ser.DoneMarker(); m != "" {
t.Errorf("适配器接管流式时 DoneMarker 应为空(不擅自发 [DONE]),实得 %q", m)
}
// 但它没声明 serialize_close 时,收尾仍借用兜底形状 —— 这是「没实现」
// 不是「冲突」,允许兜底。
if _, isFallbackClose := ser.(interface {
Close(model, id string, created int64, st *StreamState) ([][]byte, error)
}); !isFallbackClose {
t.Fatal("Close 不应在接口上")
}
}
// TestFallbackIsUsedWhenAdapterHookFails 验证钩子**执行失败**时的行为。
//
// 契约:退回兜底的完整输出,而不是产出「一半适配器字段 + 一半 OpenAI
// 字段」的混合体。混合体比任何一个单独结果都更难排查。
func TestFallbackIsUsedWhenAdapterHookFails(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "adapters"), 0o755); err != nil {
t.Fatal(err)
}
adapter := `
local adapter = {}
adapter.name = "broken"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
function adapter.serialize_response(payload)
error("deliberate failure")
end
return adapter
`
if err := os.WriteFile(filepath.Join(dir, "adapters", "broken.lua"), []byte(adapter), 0o644); err != nil {
t.Fatal(err)
}
vm := lua.NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatalf("vm: %v", err)
}
defer vm.Stop()
ser := serializerFor(vm, "broken")
resp := &types.UnifiedResponse{Content: "fallback still works", FinishReason: "stop"}
body, err := ser.NonStream(resp, "m", "id", 1)
if err != nil {
t.Fatalf("钩子失败后必须仍能产出兜底输出,而非报错终止: %v", err)
}
if !strings.Contains(string(body), "chat.completion") {
t.Errorf("钩子失败后未退回兜底 OpenAI 形状: %s", body)
}
}
// TestFallbackSavesUnconfiguredDeployment 直接覆盖用户说的场景:
// 「刚部署没有配置任何适配器」。
func TestFallbackSavesUnconfiguredDeployment(t *testing.T) {
// 完全不建 VM(模拟适配器目录不存在/为空)
ser := serializerFor(nil, "whatever")
body, err := ser.NonStream(&types.UnifiedResponse{Content: "no adapters configured"}, "m", "id", 1)
if err != nil {
t.Fatalf("无适配器时兜底必须可用: %v", err)
}
if !strings.Contains(string(body), "chat.completion") {
t.Errorf("无适配器兜底输出形状不对: %s", body)
}
}
// TestSerializerCacheFollowsAdapterRewrite 验证插件运行时改写适配器后,
// 缓存不会继续持有旧的序列化器判定(否则新声明的钩子永远不生效,而因为
// 判定本身不报错,表现只是「适配器说好的格式没出现」,极难定位)。
func TestSerializerCacheFollowsAdapterRewrite(t *testing.T) {
vm := luaVMFor(t)
if _, isFallback := serializerForCached(vm, "openai").(openAISerializer); !isFallback {
t.Fatal("前提不成立:改写前 openai 应走兜底")
}
// 插件改写该适配器,加一个 serialize_response
src := `local a = {}
a.name = "openai"
a.version = "9"
a.endpoint = "/chat/completions"
function a.serialize_response(p) return '{"rewritten":true}' end
return a`
if err := vm.LoadAdapterSource("openai", src); err != nil {
t.Fatalf("rewrite: %v", err)
}
// 无需任何显式清缓存动作:修订号变化即让旧条目失效。
after := serializerForCached(vm, "openai")
if _, stillFallback := after.(openAISerializer); stillFallback {
t.Error("缓存未随适配器改写失效:已声明 serialize_response 却仍走兜底")
}
body, err := after.NonStream(&types.UnifiedResponse{Content: "x"}, "m", "id", 1)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "rewritten") {
t.Errorf("改写后的序列化器未生效: %s", body)
}
}
// TestSerializerCacheIsPerVM 是那个 bug 的回归测试:缓存曾经只按适配器名做键,
// 于是同一进程内两个 VM 共享同一条判定——一个适配器被重写后,另一个 VM 的
// 缓存会说「没声明钩子」,新钩子永远不生效。
func TestSerializerCacheIsPerVM(t *testing.T) {
vm1 := luaVMFor(t)
vm2 := luaVMFor(t)
// 两个 VM 各自独立:改写 vm1 不得影响 vm2 的判定
if err := vm1.LoadAdapterSource("openai", `local a={} a.name="openai" a.version="9"
a.endpoint="/x" function a.serialize_response(p) return '{"only_vm1":true}' end return a`); err != nil {
t.Fatal(err)
}
if _, f := serializerForCached(vm1, "openai").(openAISerializer); f {
t.Error("vm1 改写后应改走适配器序列化")
}
if _, f := serializerForCached(vm2, "openai").(openAISerializer); !f {
t.Error("vm2 未被改写,却因共享缓存而丢失了兜底判定(跨 VM 串扰)")
}
}
// ---- 并发安全 ----
// TestSerializerForConcurrent 是压力测试:多 goroutine 并发取序列化器
// (缓存是 sync.Map,HasFn 会编译脚本 —— 这里验证并发下不 panic、不返回
// 半初始化对象,且每次结果自洽)。
func TestSerializerForConcurrent(t *testing.T) {
vm := luaVMFor(t)
const workers, iters = 16, 40
var wg sync.WaitGroup
errs := make(chan string, workers*iters)
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < iters; i++ {
name := "openai"
if w%2 == 1 {
name = "anthropic"
}
ser := serializerForCached(vm, name)
if ser == nil {
errs <- "nil serializer"
return
}
// 结果必须自洽:同一适配器每次拿到同一类型,且形状与该适配器
// 声明的协议一致(openai→OpenAI,anthropic→Anthropic)。
_, isFallback := ser.(openAISerializer)
wantShape := "chat.completion"
switch name {
case "anthropic":
if isFallback {
errs <- "anthropic 并发下退回兜底"
return
}
wantShape = `"type":"message"`
default:
if !isFallback {
errs <- name + " 并发下未走兜底"
return
}
}
body, err := ser.NonStream(&types.UnifiedResponse{Content: "x"}, "m", "id", 1)
if err != nil {
errs <- "serialize: " + err.Error()
return
}
if !strings.Contains(string(body), wantShape) {
errs <- "并发下输出形状不一致: " + name + " -> " + string(body)
return
}
}
}(w)
}
wg.Wait()
close(errs)
for e := range errs {
t.Error(e)
}
}

View File

@ -216,6 +216,14 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v1/chat/completions":
g.handleChat(w, r)
case r.URL.Path == "/v1/messages":
// Anthropic Messages protocol. Scheduling, quotas, cooldowns, AUTO
// chain and accounting are EXACTLY the ones /v1/chat/completions uses —
// only the wire shapes differ, and those come from the serving
// adapter's serialize_* hooks. The kernel holds no Anthropic field
// names (see serializer.go: the fallback is OpenAI, and any adapter
// that declares serialize_* replaces it wholesale).
g.handleMessages(w, r)
case r.URL.Path == "/v1/images/generations":
g.handleImage(w, r)
case r.URL.Path == "/v1/models":
@ -224,6 +232,11 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
g.handleAdaptersAPI(w, r)
case r.URL.Path == "/api/v1" || strings.HasPrefix(r.URL.Path, "/api/v1/"):
g.apiV1Routes(w, r)
// Must precede the /api/sources prefix match: a generic HasPrefix on
// "/api/sources/" would otherwise swallow "/api/sources/discover" and the
// operator would get "source name required in path" instead of a catalog.
case r.URL.Path == "/api/sources/discover" || strings.HasPrefix(r.URL.Path, "/api/sources/discover/"):
g.handleSourceDiscoverAPI(w, r)
case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"):
g.handleSourcesAPI(w, r)
case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):
@ -235,6 +248,16 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
// treats anything after the name as a plugin sub-resource and would
// 404 on "rules" instead of reaching the billing editor.
g.handleBillingRules(w, r)
case strings.HasPrefix(r.URL.Path, pluginHTTPPrefix):
// Before the generic plugin router: these paths belong to a PLUGIN, not to
// the plugin manager, and "http" is a valid sub-resource name otherwise.
g.handlePluginHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/plugins/") || strings.HasPrefix(r.URL.Path, "/v1/plugins/"):
// Custom mounts a plugin or adapter claimed (R3). Checked AFTER every
// kernel route so a claim can never shadow one — ValidateCustomMount
// refuses the overlap at load time, and this ordering is the second line
// of defence for a table that somehow slipped through.
g.handleCustomMount(w, r)
case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"):
g.handlePluginsAPI(w, r)
case r.URL.Path == "/api/chat":
@ -305,20 +328,43 @@ func (g *Gateway) auth(next http.Handler) http.Handler {
http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound)
return
}
next.ServeHTTP(w, r.WithContext(withAuth(r.Context(), key, rec.Role)))
next.ServeHTTP(w, r.WithContext(withAuthNamed(r.Context(), key, rec.Role, rec.Name)))
})
}
// authCtx carries the authenticated gateway key and its role.
// authCtx carries the authenticated gateway key, its role, and the key's
// configured NAME.
//
// The name is here because plugin HTTP endpoints authorise by key name rather
// than by secret, and looking the name up from the key at the point of use would
// mean reading config on every request. It is "" for a key configured without
// one, which such an endpoint can never match.
type authCtx struct {
key string
role string
name string
}
type authCtxKeyT struct{}
func withAuth(ctx context.Context, key, role string) context.Context {
return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role})
return withAuthNamed(ctx, key, role, "")
}
// withAuthNamed is withAuth plus the key's configured name.
func withAuthNamed(ctx context.Context, key, role, name string) context.Context {
return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role, name: name})
}
// reqKeyName returns the authenticated key's configured name, or "" if it has
// none. Plugin endpoint authorisation is written against this, not against the
// secret: a secret is rotated without notice, so a rule keyed on it would
// silently stop matching.
func reqKeyName(ctx context.Context) string {
if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok {
return a.name
}
return ""
}
// reqKey returns the authenticated gateway key id (masked suffix for display).

View File

@ -43,7 +43,17 @@ type Req struct {
// accounting at all (even when the hit count is 0). The WebUI shows
// "0%" instead of "—" for such rows.
CacheReported bool `json:"cache_reported,omitempty"`
OK bool `json:"ok"`
// Reasoning is completion_tokens_details.reasoning_tokens.
//
// It is a SUBSET of Compl, not an addition: reasoning tokens are billed at
// the completion rate and already counted there. This field exists so a
// pricing rule can split them when a provider prices thinking tokens
// differently (most do NOT — OpenAI and DeepSeek bill them as ordinary
// output), and so a reader can see what fraction of a bill is thinking.
// 0 means "not reported", which is NOT the same as "no reasoning happened" —
// three of the sources measured return no such field at all.
Reasoning int64 `json:"reasoning_tokens,omitempty"`
OK bool `json:"ok"`
// Status http status code
Status int `json:"status"`
// Err short error message
@ -755,9 +765,20 @@ func (s *Stats) WindowTokens(model, source string, sec int64) int64 {
return total
}
// rows flattens a map[string]*Stat into a reportable, descending-by-requests
// list.
//
// A nil value is skipped rather than dereferenced. The maps this is called with
// are built by several callers (Snapshot, PeriodSnapshot, the audit replay) and a
// nil entry is a legitimate representation of "this dimension has no data" —
// `map[string]*Stat{key: s.byKey[key]}` produced exactly that whenever the
// queried key had no recorded traffic, and `*v` then panicked the handler.
func rows(m map[string]*Stat) []StatsRow {
out := make([]StatsRow, 0, len(m))
for k, v := range m {
if v == nil {
continue
}
out = append(out, StatsRow{Name: k, Stat: *v})
}
for i := 1; i < len(out); i++ {
@ -1175,7 +1196,26 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
if key == "" {
byKey, byModel, bySrc = s.byKey, s.byModel, s.bySrc
} else {
byKey = map[string]*Stat{key: s.byKey[key]}
// The map is keyed by the MASKED key id (keyID(req.Key)), which is what
// by_key reports and what the dashboard sends as ?key=. An admin may
// pass anything in that query parameter, including the full key, and
// `s.byKey[key]` then returns the zero value — a nil *Stat.
//
// rows() below does `*v` on every value, so a nil entry panicked the
// handler: any ?key=<full key> request killed the response with an empty
// reply, and logged a full panic stack. Reproduced against the live
// gateway: /api/stats?period=day&limit=1&key=sk-gw-... → curl exit 52.
//
// A key with no recorded traffic is a legitimate question ("what did this
// key use?"), and its answer is zero — so an absent entry is an empty
// result, not an error, and not a crash.
st := s.byKey[key]
byKey = make(map[string]*Stat, 1)
if st != nil {
byKey[key] = st
} else {
byKey[key] = &Stat{}
}
byModel = s.byKeyModel[key]
bySrc = s.byKeySrc[key]
}
@ -1192,8 +1232,11 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
}
}
bs := make([]agrRow, 0, len(s.byStatus))
for code := range s.byStatus {
bs = append(bs, agrRow{Name: strconv.Itoa(code), Stat: *s.byStatus[code]})
for code, st := range s.byStatus {
if st == nil {
continue
}
bs = append(bs, agrRow{Name: strconv.Itoa(code), Stat: *st})
}
sort.Slice(bs, func(i, j int) bool {
ci, _ := strconv.Atoi(bs[i].Name)
@ -1211,3 +1254,16 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
"replay_partial": s.replayPartial,
}
}
// Active reports requests in flight right now.
//
// It is a live gauge, not an aggregate: there is no calendar period that scopes
// it, which is why the period-scoped /api/stats response has to carry it
// explicitly. Before it did, the day/week/month views fed `st.active || 0` a
// missing key and showed a hard 0 — the same gateway that was serving hundreds
// of requests a second looked idle in three of the four views.
func (s *Stats) Active() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.active
}

View File

@ -174,6 +174,9 @@ func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapsh
out.Srcs = rows(bySrc)
bs := make([]agrRow, 0, len(byStatus))
for code, st := range byStatus {
if st == nil {
continue
}
bs = append(bs, agrRow{Name: code, Stat: *st})
}
sort.Slice(bs, func(i, j int) bool {
@ -192,7 +195,11 @@ func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapsh
sort.Strings(ks)
out.Bucket = make([]PeriodBucket, 0, len(ks))
for _, k := range ks {
out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *buckets[k]})
// Defensive for the same reason rows() is: ks comes from the bucket map's
// keys, and a nil value would panic on the deref below.
if st := buckets[k]; st != nil {
out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *st})
}
}
// The window is only "complete" if the audit walk actually reached back

View File

@ -0,0 +1,159 @@
package gateway
import (
"encoding/json"
"net/url"
"path/filepath"
"testing"
"time"
)
// The dashboard paints its records table from the `records` field of
// /api/stats (paintRecords(st.records)). The period branch of that handler
// used to hand-write a response map and simply omit the key, so switching the
// selector to day / week / month rendered an EMPTY table no matter how much
// traffic the window had — while the lifetime view worked, because it goes
// through Snapshot() which carries records.
//
// These tests pin the contract across every period: same records, same cursor.
func TestStatsPeriodViewsShipRecords(t *testing.T) {
td := t.TempDir()
g := newTestGateway(t)
// Write audit records the period aggregator and the record pager both read.
g.stats.auditPath = filepath.Join(td, "audit.jsonl")
now := time.Now().UnixMilli()
for i := 0; i < 5; i++ {
g.stats.Record(Req{
Time: now - int64(i)*1000, Type: "chat", OK: true,
Model: "m1", Source: "s1", Status: 200,
Prompt: 10, Compl: 2,
})
}
for _, p := range []string{"day", "week", "month", "all"} {
rr := doReq(t, g, "GET", "/api/stats?period="+p, "")
if rr.Code != 200 {
t.Fatalf("period=%s: HTTP %d: %s", p, rr.Code, rr.Body.String())
}
var st map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &st); err != nil {
t.Fatalf("period=%s: %v", p, err)
}
recs, ok := st["records"].([]interface{})
if !ok {
t.Fatalf("period=%s: response has no `records` array — the dashboard "+
"records table renders empty for this view (keys: %v)", p, keysOf(st))
}
if len(recs) != 5 {
t.Fatalf("period=%s: got %d records, want 5", p, len(recs))
}
// The scroll handler pages from next_cursor; without it the table is
// stuck at one screen no matter how far the user scrolls.
if _, ok := st["next_cursor"]; !ok {
t.Fatalf("period=%s: no next_cursor — paging can never continue", p)
}
}
}
// The records shipped with a period view must be the newest-first page the
// paging endpoint would return, in the same order. If /api/stats and
// /api/stats/records disagreed, scrolling would duplicate or skip rows.
func TestStatsPeriodRecordsMatchPager(t *testing.T) {
td := t.TempDir()
g := newTestGateway(t)
g.stats.auditPath = filepath.Join(td, "audit.jsonl")
now := time.Now().UnixMilli()
for i := 0; i < 8; i++ {
g.stats.Record(Req{
Time: now - int64(i)*1000, Type: "chat", OK: true,
Model: "m1", Status: 200, Prompt: 5, Compl: 1,
})
}
rr := doReq(t, g, "GET", "/api/stats?period=day&limit=100", "")
var st struct {
Records []struct {
Time int64 `json:"time"`
Model string `json:"model"`
} `json:"records"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &st); err != nil {
t.Fatal(err)
}
rr2 := doReq(t, g, "GET", "/api/stats/records?limit=100", "")
var pg struct {
Records []struct {
Time int64 `json:"time"`
Model string `json:"model"`
} `json:"records"`
}
if err := json.Unmarshal(rr2.Body.Bytes(), &pg); err != nil {
t.Fatal(err)
}
if len(st.Records) != len(pg.Records) {
t.Fatalf("period view shipped %d records, pager returned %d — the two "+
"sources must agree or scrolling duplicates/skips rows",
len(st.Records), len(pg.Records))
}
for i := range st.Records {
if st.Records[i].Time != pg.Records[i].Time {
t.Fatalf("record %d differs: period view t=%d, pager t=%d",
i, st.Records[i].Time, pg.Records[i].Time)
}
}
// The two sources must agree on DIRECTION as well as content. Which
// direction that is depends on how many reverse-read chunks the audit file
// spans (a small file is read whole, a large one chunk-by-chunk), so the
// test asserts agreement rather than hard-coding newest-first — but they
// can never disagree, because the table interleaves both streams.
if len(st.Records) > 1 {
periodAsc := st.Records[1].Time > st.Records[0].Time
pagerAsc := pg.Records[1].Time > pg.Records[0].Time
if periodAsc != pagerAsc {
t.Fatalf("direction differs: period view %s, pager %s — the table "+
"would splice the two streams out of order",
map[bool]string{true: "oldest-first", false: "newest-first"}[periodAsc],
map[bool]string{true: "oldest-first", false: "newest-first"}[pagerAsc])
}
}
}
// A non-admin caller cannot widen their view by passing someone else's key in
// the query string: exportKey overrides ?key= with the caller's own masked id.
// This is a data-leak boundary, and it applies to the records the period view
// ships just as much as to the aggregates.
func TestStatsPeriodRecordsIgnoreForeignKeyForUsers(t *testing.T) {
td := t.TempDir()
g := newTestGateway(t)
g.stats.auditPath = filepath.Join(td, "audit.jsonl")
other := keyID("sk-gw-cccccccccccccccc")
own := keyID("sk-test")
now := time.Now().UnixMilli()
g.stats.Record(Req{Time: now, OK: true, Model: "m", Status: 200, Key: other})
g.stats.Record(Req{Time: now - 1000, OK: true, Model: "m", Status: 200, Key: own})
// sk-test authenticates as a non-admin (newTestGateway seeds it under
// gateway_keys, not keys), so ?key=<other> must be ignored and the caller
// sees only its own rows.
rr := doReq(t, g, "GET",
"/api/stats?period=day&key="+url.QueryEscape(other), "")
if rr.Code != 200 {
t.Fatalf("HTTP %d: %s", rr.Code, rr.Body.String())
}
var st struct {
Records []struct {
Key string `json:"key"`
} `json:"records"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &st); err != nil {
t.Fatal(err)
}
for _, r := range st.Records {
if r.Key == other {
t.Fatalf("another key's record leaked into a user view "+
"(caller=%s, requested=%s)", own, other)
}
}
}

View File

@ -319,3 +319,116 @@ func TestPeriodFromQuery(t *testing.T) {
}
}
}
// TestPeriodWeekMayStartBeforeMonth pins a fact that looks like a bug and
// gets reported as one: the calendar periods are NOT nested.
//
// On 2026-10-03 (a Saturday) the ISO week runs Mon 2026-09-28 .. Sat
// 10-03, while the month window is 10-01 .. 10-03. The week therefore reaches
// back into the previous month and its total is legitimately LARGER than the
// month's. Anyone reading only the totals will conclude the aggregator is
// broken; it is not, and this test exists so a future "fix" that makes the
// windows nested (e.g. clamping the week to the month) is caught as the
// regression it would be.
func TestPeriodWeekMayStartBeforeMonth(t *testing.T) {
// Saturday 2026-10-03, UTC.
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
weekFrom := periodStart(PeriodWeek, now)
monthFrom := periodStart(PeriodMonth, now)
weekFromD := time.UnixMilli(weekFrom).UTC()
monthFromD := time.UnixMilli(monthFrom).UTC()
if got := weekFromD.Format("2006-01-02"); got != "2026-09-28" {
t.Fatalf("week window start = %s, want 2026-09-28 (the Monday)", got)
}
if got := monthFromD.Format("2006-01-02"); got != "2026-10-01" {
t.Fatalf("month window start = %s, want 2026-10-01", got)
}
if !weekFromD.Before(monthFromD) {
t.Fatal("this test only means anything when the week starts before the month")
}
// The week must reach strictly further back than the month window does,
// otherwise the nesting intuition would hold and the UI note would be noise.
if back := monthFromD.Sub(weekFromD).Hours() / 24; back <= 0 {
t.Fatalf("week should start at least a day before the month window, got %v days back", back)
}
}
// The same overlap must be visible in the aggregate numbers, not just in the
// window bounds: a week total larger than a month total is the exact symptom
// an operator reported.
func TestPeriodWeekTotalCanExceedMonthTotal(t *testing.T) {
dir := t.TempDir()
audit := filepath.Join(dir, "audit.jsonl")
// Two records before the month window (inside this ISO week) and one
// inside October.
pre := time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC).UnixMilli()
in1 := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC).UnixMilli()
in2 := time.Date(2026, 10, 3, 10, 0, 0, 0, time.UTC).UnixMilli()
auditPeriod(t, audit,
Req{Time: pre, OK: true, Prompt: 100, Compl: 10},
Req{Time: pre + 1000, OK: true, Prompt: 100, Compl: 10},
Req{Time: in1, OK: true, Prompt: 50, Compl: 5},
Req{Time: in2, OK: true, Prompt: 50, Compl: 5},
)
s := periodStats(t, audit)
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
week := s.PeriodSnapshot(PeriodWeek, "", now)
month := s.PeriodSnapshot(PeriodMonth, "", now)
if week.Total.Reqs != 4 {
t.Fatalf("week reqs = %d, want 4 (both September records fall in this ISO week)", week.Total.Reqs)
}
if month.Total.Reqs != 2 {
t.Fatalf("month reqs = %d, want 2", month.Total.Reqs)
}
if week.Total.Tokens <= month.Total.Tokens {
t.Fatalf("week tokens (%d) must exceed month tokens (%d): the week reaches into September",
week.Total.Tokens, month.Total.Tokens)
}
// Day must be a strict subset of both, which is the only nesting that holds.
day := s.PeriodSnapshot(PeriodDay, "", now)
if day.Total.Reqs != 1 {
t.Fatalf("day reqs = %d, want 1", day.Total.Reqs)
}
if day.Total.Tokens >= month.Total.Tokens || day.Total.Tokens >= week.Total.Tokens {
t.Fatal("today must be contained in both the week and the month totals")
}
}
// TestPeriodStatsCarryActiveGauge pins the live "active requests" gauge onto
// every period response.
//
// It is a live gauge, not an aggregate, so no calendar window can scope it — but
// the period response is what the day/week/month views render from, and it used
// to omit the field entirely. The UI then evaluated `st.active || 0` and printed
// a hard 0 in three of four views, while the all-time view (whose response did
// carry the field) showed the truth. Reading "0 active" off a busy gateway is
// worse than reading nothing: it is a confident wrong answer.
func TestPeriodStatsCarryActiveGauge(t *testing.T) {
for _, period := range []string{"day", "week", "month", "all"} {
g := newTestGateway(t)
rr := doReq(t, g, http.MethodGet, "/api/stats?period="+period, "")
if rr.Code != http.StatusOK {
t.Fatalf("period=%s: %d", period, rr.Code)
}
var out map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("period=%s decode: %v", period, err)
}
v, ok := out["active"]
if !ok {
t.Errorf("period=%s response has no active field; the UI renders 0", period)
continue
}
if _, ok := v.(float64); !ok {
t.Errorf("period=%s active is %T, want a number", period, v)
}
}
}

View File

@ -0,0 +1,140 @@
package gateway
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestSnapshotUnknownKeyDoesNotPanic covers a nil-dereference that killed the
// response for any /api/stats request carrying a ?key= the statistics had no
// entry for.
//
// The maps in Snapshot are keyed by the MASKED key id (keyID(req.Key)) — that
// is what by_key reports and what the dashboard sends. An admin may pass anything
// in the query parameter, including the full key, and `s.byKey[key]` then yields
// the zero value: a nil *Stat. rows() dereferenced every value, so the handler
// panicked and the client got an empty reply plus a panic stack in the log.
//
// Reproduced against the live gateway before the fix:
//
// /api/stats?period=day&limit=1&key=sk-gw-7ffe12c5cbacec40bd39a7291df9a9ed
// → curl: (52) Empty reply from server
// → log: http: panic serving …: invalid memory address or nil pointer dereference
// llmsproxy/internal/gateway.rows stats.go:771
//
// A key with no recorded traffic is a legitimate question, and zero is its
// answer — not a crash.
func TestSnapshotUnknownKeyDoesNotPanic(t *testing.T) {
s := NewStats(16)
// One recorded key, masked the way Record masks it.
s.Record(Req{Key: "***aaaaaa", Type: "chat", Model: "m", Prompt: 5, Compl: 2})
cases := []struct {
name string
key string
}{
{"masked key that exists", "***aaaaaa"},
{"masked key that does not exist", "***zzzzzz"},
{"full key rather than the mask", "sk-gw-full-key-value"},
{"arbitrary text", "not-a-key-at-all"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// A panic here fails the subtest by unwinding through the test
// goroutine; rows() used to take the whole process with it.
out := s.Snapshot(10, c.key)
byKey, ok := out["by_key"].([]StatsRow)
if !ok {
t.Fatalf("by_key is %T, want []StatsRow", out["by_key"])
}
total, _ := out["total"].(Stat)
t.Logf("key=%q → by_key %d 条, total.reqs=%d", c.key, len(byKey), total.Reqs)
})
}
}
// TestSnapshotUnknownKeyReportsZeroNotAbsence pins the semantics: a key with no
// traffic answers zero, and the by_key row still exists so a dashboard that keys
// off its presence renders a row of zeros rather than dropping the key.
func TestSnapshotUnknownKeyReportsZeroNotAbsence(t *testing.T) {
s := NewStats(16)
out := s.Snapshot(10, "***zzzzzz")
byKey, _ := out["by_key"].([]StatsRow)
if len(byKey) != 1 {
t.Fatalf("by_key has %d rows, want 1: the queried key must appear with zeros", len(byKey))
}
if byKey[0].Name != "***zzzzzz" {
t.Errorf("row name = %q, want ***zzzzzz", byKey[0].Name)
}
if byKey[0].Reqs != 0 {
t.Errorf("reqs = %d, want 0", byKey[0].Reqs)
}
}
// TestPeriodSnapshotUnknownKeyDoesNotPanic is the same guard on the period view,
// which builds its own buckets and calls the same flattening helpers.
func TestPeriodSnapshotUnknownKeyDoesNotPanic(t *testing.T) {
dir := t.TempDir()
audit := dir + "/audit.jsonl"
writeAuditFile(t, audit, 3, "***aaaaaa", time.Now().Add(-time.Hour).UnixMilli())
s := periodStats(t, audit)
for _, key := range []string{"***aaaaaa", "***zzzzzz", "sk-gw-full-key-value"} {
out := s.PeriodSnapshot(PeriodAll, key, time.Now())
t.Logf("key=%q → total.reqs=%d byKey=%d buckets=%d",
key, out.Total.Reqs, len(out.ByKey), len(out.Bucket))
}
}
// TestRowsSkipsNilValues pins the defensive behaviour directly, so a future
// caller that reintroduces a nil entry fails here rather than in production.
func TestRowsSkipsNilValues(t *testing.T) {
in := map[string]*Stat{
"has-traffic": {Reqs: 5},
"no-entry": nil,
}
got := rows(in)
if len(got) != 1 {
t.Fatalf("rows() returned %d rows, want 1 (the nil entry must be skipped): %+v", len(got), got)
}
if got[0].Name != "has-traffic" {
t.Errorf("kept %q, want has-traffic", got[0].Name)
}
}
// TestRowsEmptyIsEmptyNotNil keeps the JSON shape honest: an all-nil map yields
// an empty list, which encodes as [] rather than null — the frontend does
// Array.isArray checks on these fields.
func TestRowsEmptyIsEmptyNotNil(t *testing.T) {
got := rows(map[string]*Stat{"a": nil})
if got == nil {
t.Fatal("rows() returned nil; the report field must stay [] instead of null")
}
if len(got) != 0 {
t.Fatalf("got %d rows, want 0", len(got))
}
}
// TestStatsAPIUnknownKeyReturnsJSON exercises the HTTP path so the assertion
// covers the reported symptom (an empty reply), not just the helper.
func TestStatsAPIUnknownKeyReturnsJSON(t *testing.T) {
g := newTestGateway(t)
srv := httptest.NewServer(g.Handler())
defer srv.Close()
req, err := http.NewRequest("GET", srv.URL+"/api/stats?period=day&limit=1&key=sk-gw-full-key", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer sk-test")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("request failed (an empty reply is what the live gateway produced): %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}

View File

@ -0,0 +1,133 @@
package gateway
import (
"encoding/json"
"testing"
"llmsproxy/internal/types"
)
// TestThinkingFromClient 把「客户端措辞 → 中立语义」这条翻译链钉死。
//
// v1 的问题:disable_thinking 是内核私有字段,被序列化进交给适配器的 body,
// 于是 10 个适配器各自写 req.disable_thinking = nil 来清理它。v2 把它换成
// 双方都认可的 thinking 字段,这条测试确保翻译只发生一次、且在适配器之前。
func TestThinkingFromClient(t *testing.T) {
cases := []struct {
name string
req chatRequest
want *types.ThinkingConfig
}{
{
name: "无任何字段 → 不带 thinking(上游用自己默认)",
req: chatRequest{},
want: nil,
},
{
name: "disable_thinking=true → enabled=false",
req: chatRequest{DisableThinking: true},
want: &types.ThinkingConfig{Enabled: boolPtr(false)},
},
{
name: "中立 thinking 显式给出 → 原样透传",
req: chatRequest{Thinking: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 4096}},
want: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 4096},
},
{
name: "两者冲突时,中立的 thinking 优先(它带预算,布尔表达不了)",
req: chatRequest{
DisableThinking: true,
Thinking: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 1024},
},
want: &types.ThinkingConfig{Enabled: boolPtr(true), BudgetTokens: 1024},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := c.req.thinkingFromClient()
if c.want == nil {
if got != nil {
t.Fatalf("want nil, got %+v", got)
}
return
}
if got == nil {
t.Fatal("want non-nil thinking, got nil")
}
if (got.Enabled == nil) != (c.want.Enabled == nil) {
t.Fatalf("Enabled presence mismatch: got %v want %v", got.Enabled, c.want.Enabled)
}
if got.Enabled != nil && *got.Enabled != *c.want.Enabled {
t.Errorf("Enabled = %v, want %v", *got.Enabled, *c.want.Enabled)
}
if got.BudgetTokens != c.want.BudgetTokens {
t.Errorf("BudgetTokens = %d, want %d", got.BudgetTokens, c.want.BudgetTokens)
}
})
}
}
// TestDisableThinkingNeverReachesAdapterBody 是那条泄漏的回归测试:内核私有
// 字段不得出现在交给适配器的 body 里。
func TestDisableThinkingNeverReachesAdapterBody(t *testing.T) {
var r chatRequest
if err := json.Unmarshal([]byte(`{"model":"m","messages":[],"disable_thinking":true}`), &r); err != nil {
t.Fatal(err)
}
inner := &types.ChatRequest{
Model: "m",
Thinking: r.thinkingFromClient(),
Tools: r.rawTools(),
ToolChoice: r.rawToolChoice(),
ExtraBody: r.ExtraBody,
ClientSession: "",
}
body, err := json.Marshal(inner)
if err != nil {
t.Fatal(err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
if _, leaked := m["disable_thinking"]; leaked {
t.Errorf("内核私有字段 disable_thinking 泄漏进适配器 body(v1 回归): %s", body)
}
if _, ok := m["thinking"]; !ok {
t.Errorf("中立 thinking 字段缺失: %s", body)
}
}
// TestToolsPassthroughRaw 确认 tools/tool_choice 以原始形状交给适配器:
// 内核不解释工具 schema,那是适配器的职责。
func TestToolsPassthroughRaw(t *testing.T) {
var r chatRequest
body := `{"model":"m","messages":[],"tools":[{"type":"function","function":{"name":"f","parameters":{"type":"object"}}}],"tool_choice":"auto"}`
if err := json.Unmarshal([]byte(body), &r); err != nil {
t.Fatal(err)
}
tools := r.rawTools()
if len(tools) == 0 {
t.Fatal("tools 未透传")
}
var decoded []map[string]interface{}
if err := json.Unmarshal(tools, &decoded); err != nil {
t.Fatalf("tools 不是合法 JSON: %v (%s)", err, tools)
}
if len(decoded) != 1 || decoded[0]["type"] != "function" {
t.Errorf("tools 形状被内核改写: %s", tools)
}
if tc := string(r.rawToolChoice()); tc != `"auto"` {
t.Errorf("tool_choice = %s, want \"auto\"", tc)
}
// 空值不应变成 "null" 出现在 body 里
var empty chatRequest
if got := empty.rawTools(); got != nil {
t.Errorf("无 tools 时应返回 nil, got %s", got)
}
if got := empty.rawToolChoice(); got != nil {
t.Errorf("无 tool_choice 时应返回 nil, got %s", got)
}
}
func boolPtr(v bool) *bool { return &v }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,195 @@
package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"llmsproxy/internal/provider"
)
// TestUI_ChatPageGroupsImageModels is a UI-contract guard for the bug that
// started this: the chat page listed image models beside chat ones, and picking
// one posted to /api/chat, which cannot serve it.
//
// The contract is asserted on the HTML source rather than by running a browser,
// because the failure this must catch is a source-level one — an <option>
// without `disabled`. A DOM assertion in a headless browser would pass right
// up until someone removed the attribute, and then only if a CI machine had the
// whole stack up.
func TestUI_ChatPageGroupsImageModels(t *testing.T) {
ui := uiSource(t)
// The dropdown must read model_kinds, not the flat models list. Reading the
// flat list is what made chat and image models indistinguishable.
if !strings.Contains(ui, "model_kinds") {
t.Error("chat page never reads model_kinds; image models will be " +
"indistinguishable from chat models again")
}
// Image options must be rendered disabled. A plain String.replace over the
// joined HTML only disables the FIRST match — that bug shipped once here
// (one source disabled, five selectable) — so the assertion looks for the
// per-item map, not for a global replace.
if strings.Contains(ui, `imgOpts.join("").replace`) {
t.Error("chat page disables image options with String.replace over the " +
"joined HTML, which only rewrites the first option")
}
if !strings.Contains(ui, "disabled") {
t.Error("chat page never emits a disabled attribute for image models")
}
}
// TestUI_HasDedicatedImagePage pins the second half of the fix: image models
// need somewhere they CAN be tested.
func TestUI_HasDedicatedImagePage(t *testing.T) {
ui := uiSource(t)
for _, want := range []string{
`data-tab="image"`, // sidebar entry
`id="tab-image"`, // pane
`function renderImage`, // renderer registered with refresh()
`tab === "image"`, // dispatched
`/v1/images/generations`, // the endpoint the page actually calls
} {
if !strings.Contains(ui, want) {
t.Errorf("image page is missing %q", want)
}
}
// The page must be reachable from TABS; goTab iterates TABS to toggle pane
// visibility, so a pane outside that list is present but permanently hidden —
// which reads as "the page is blank" because the content is in the DOM.
if !strings.Contains(ui, `"image"`) {
t.Error(`"image" does not appear in the TABS list`)
}
}
// TestUI_ImagePageCallsTheImageEndpoint is the discriminator: a page that
// renders an image result but posts to the chat endpoint would produce exactly
// the original failure, just prettier.
func TestUI_ImagePageCallsTheImageEndpoint(t *testing.T) {
ui := uiSource(t)
i := strings.Index(ui, "async function sendImage(")
if i < 0 {
t.Fatal("sendImage() not found")
}
// Bound the search to the function body rather than the whole file, so a
// /v1/images/generations mention elsewhere cannot satisfy this.
body := ui[i:]
if end := strings.Index(body, "\n function "); end > 0 {
body = body[:end]
}
if end := strings.Index(body, "\n async function "); end > 0 {
if s := body[:end]; strings.Contains(s, "/v1/images/generations") {
body = s
} else {
body = body[:end]
}
}
if !strings.Contains(body, "/v1/images/generations") {
t.Error("sendImage() does not post to /v1/images/generations")
}
}
// TestSourceStatus_ExposesModelKindsToTheUI is the server half of the same
// contract: the field the UI reads must actually be serialized.
func TestSourceStatus_ExposesModelKindsToTheUI(t *testing.T) {
st := provider.SourceStatus{
Name: "vela",
Models: []string{"claude-sonnet-5.5", "gpt-image-2"},
ModelKinds: []provider.ModelKindInfo{
{ID: "claude-sonnet-5.5", Kind: "chat"},
{ID: "gpt-image-2", Kind: "image"},
},
}
b, err := json.Marshal(st)
if err != nil {
t.Fatal(err)
}
var got map[string]interface{}
if err := json.Unmarshal(b, &got); err != nil {
t.Fatal(err)
}
mk, ok := got["model_kinds"].([]interface{})
if !ok || len(mk) != 2 {
t.Fatalf("model_kinds = %v, want 2 entries; the UI cannot split the list without it", got["model_kinds"])
}
last := mk[1].(map[string]interface{})
if last["kind"] != "image" {
t.Errorf("model_kinds[1].kind = %v, want \"image\"", last["kind"])
}
}
// TestUI_BaseURLFieldExplainsWhereToCut keeps the second bug fixed: the URL
// hint has to mention the concrete failure, not just say "enter the URL".
func TestUI_BaseURLFieldExplainsWhereToCut(t *testing.T) {
ui := uiSource(t)
if !strings.Contains(ui, "urlHint") {
t.Fatal("no urlHint; the base_url field gives no guidance about /v1")
}
hint := ui[strings.Index(ui, "urlHint:"):]
if len(hint) > 700 {
hint = hint[:700]
}
// The trap is specific and reproducible: base_url ending in /v1 makes chat
// work and image generation 404. Saying only "enter the URL" reproduces the
// confusion the hint exists to remove.
for _, want := range []string{"/v1", "404"} {
if !strings.Contains(hint, want) {
t.Errorf("urlHint does not mention %q — the hint must name the concrete failure", want)
}
}
}
// TestImageEndpointComposesUnderRealConditions proves the hint describes a
// reachable path rather than a plausible-sounding one.
//
// The case that matters: base_url already ends in /v1 (which is what an
// operator pastes from an SDK example), so the RELATIVE image endpoint composes
// to …/v1/images/generations and works, while the gateway's default ABSOLUTE
// path would produce …/v1/v1/… and 404.
//
// The doubled form is served as a 404 here so the test would fail if the code
// ever reverted to relying on the default.
func TestImageEndpointComposesUnderRealConditions(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/images/generations":
_, _ = w.Write([]byte(`{"data":[{"url":"http://example.invalid/x.png"}]}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer up.Close()
// base_url as an operator types it: host root + /v1.
base := up.URL + "/v1"
// What discovery writes when base carries a version segment.
relative := base + "/images/generations"
resp, err := http.Post(relative, "application/json", strings.NewReader(`{"model":"m"}`))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("POST %s = %d, want 200 — the form discovery writes must work", relative, resp.StatusCode)
}
// What the gateway's built-in default would produce for the same base_url.
doubled := base + "/v1/images/generations"
resp2, err := http.Post(doubled, "application/json", strings.NewReader(`{"model":"m"}`))
if err != nil {
t.Fatal(err)
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusNotFound {
t.Errorf("POST %s = %d, want 404 — this test's premise is that the "+
"default image path doubles the version segment; if the upstream ever "+
"accepts it, composeImageEndpoint's rule needs rechecking", doubled, resp2.StatusCode)
}
}

View File

@ -0,0 +1,109 @@
package gateway
import (
"strings"
"testing"
)
// TestUI_ImageRequestSendsJSONBody pins the bug that made the image page
// unusable in production while every DOM assertion still passed.
//
// The page called api(path, {method, body: {...}}). api() is
// `(p, o) => fetch(p, o)` — o is the NATIVE fetch options object, so an object
// in `body` is coerced by fetch into the literal text "[object Object]". The
// gateway's json.Unmarshal rejected it, the response was a bare 400 with
// lat_ms 0, and the page showed an error toast with no image and no usable
// message.
//
// What made this expensive: the pane rendered, the model list had all six
// models, the button enabled itself, the console was clean. Only the WIRE
// format was wrong, and nothing in the DOM shows a wire format.
func TestUI_ImageRequestSendsJSONBody(t *testing.T) {
body := sendImageSource(t)
if !strings.Contains(body, "JSON.stringify") {
t.Error("sendImage() does not JSON.stringify its body — fetch coerces " +
"the object to the text \"[object Object]\" and the gateway answers " +
"400 with lat_ms 0")
}
if !strings.Contains(body, `"Content-Type": "application/json"`) {
t.Error("sendImage() does not set Content-Type: application/json; the " +
"server sees text/plain and may refuse to parse the body")
}
// The literal defect, asserted so a later refactor reintroducing it fails
// here rather than in a browser.
if strings.Contains(body, "body: size ? {") || strings.Contains(body, "body: { model") {
t.Error("sendImage() still passes an object literal as the fetch body")
}
}
// TestUI_PostCallsSendJSONString guards every POST in the file, not just the
// image one. The image page happened to be the only place that used api() with
// an object body, so a check scoped to it would pass while another page had the
// same defect.
func TestUI_PostCallsSendJSONString(t *testing.T) {
ui := uiSource(t)
lines := strings.Split(ui, "\n")
var bad []string
for i, l := range lines {
if !strings.Contains(l, `method: "POST"`) {
continue
}
end := i + 8
if end > len(lines) {
end = len(lines)
}
window := strings.Join(lines[i:end], "\n")
if !strings.Contains(window, "body:") {
continue // no body at all: legitimate (a bare trigger)
}
if strings.Contains(window, "JSON.stringify") || strings.Contains(window, "FormData") {
continue
}
bad = append(bad, strings.TrimSpace(l))
}
if len(bad) > 0 {
t.Errorf("%d POST call(s) send a non-string body:", len(bad))
for _, b := range bad {
if len(b) > 100 {
b = b[:100]
}
t.Errorf(" %s", b)
}
}
}
// TestUI_ImagePageReportsServerErrors pins that a failed generation surfaces
// something actionable. The production bug produced a bare "400 Bad Request"
// toast; the requirement is that the gateway's own message reaches the page.
func TestUI_ImagePageReportsServerErrors(t *testing.T) {
body := sendImageSource(t)
// api() throws Error(j.error.message) on !r.ok, so appending e.message is
// what puts the explanation on screen.
if !strings.Contains(body, "e && e.message") {
t.Error("sendImage() does not surface the server's error message; a " +
"failure would show a bare status with no cause")
}
// And a success carrying no image must not look like a success.
if !strings.Contains(body, "imgNoData") {
t.Error("sendImage() does not warn when the upstream returns no image")
}
}
// sendImageSource returns the text of sendImage() only, so an assertion cannot
// be satisfied by a string that happens to appear elsewhere in the file.
func sendImageSource(t *testing.T) string {
t.Helper()
ui := uiSource(t)
i := strings.Index(ui, "async function sendImage(")
if i < 0 {
t.Fatal("sendImage() not found")
}
body := ui[i:]
if end := strings.Index(body[1:], "\n function "); end > 0 {
body = body[:end+1]
}
return body
}

View File

@ -0,0 +1,439 @@
package gateway
import (
"os"
"strings"
"testing"
)
// Per-key AUTO chain UI contract.
//
// The per-key editor deliberately REUSES the global lane canvas instead of
// shipping a second, simpler editor. That is a design choice with teeth: a
// reduced copy would lack drag ordering and per-slot quota/period/hours, and
// the two would drift apart silently. These checks pin the sharing, because
// the only symptom of un-sharing is "the per-key editor got worse" — no error,
// just a smaller feature set that nobody notices until they use it.
func TestUIPerKeyAutoWiring(t *testing.T) {
src := uiSource(t)
// The admin key list must offer the editor, otherwise the whole feature is
// unreachable from the UI. Assert the *call site* inside keyCanvasHtml,
// not just that the functions exist: removing the button leaves every
// definition intact, and that is exactly the regression to catch.
kcStart := strings.Index(src, "function keyCanvasHtml")
if kcStart < 0 {
t.Fatal("keyCanvasHtml is gone — cannot check the AUTO chain button")
}
kcEnd := strings.Index(src[kcStart:], "\n }")
if kcEnd < 0 {
t.Fatal("could not delimit keyCanvasHtml")
}
if !strings.Contains(src[kcStart:kcStart+kcEnd], "openKeyAutoModal(") {
t.Fatal("per-key AUTO chain button is missing from the key card header")
}
for _, fn := range []string{
"function openKeyAutoModal",
"function keyAutoInherit",
"function keyAutoSave",
"function keyAutoClose",
} {
if !strings.Contains(src, fn) {
t.Fatalf("UI is missing %s — the editor cannot function", fn)
}
}
}
// The per-key editor must render the SAME canvas the global editor uses.
// If this regresses, the per-key chain silently loses drag ordering and
// per-slot quota/period/hours while still looking like it works.
func TestUIPerKeyAutoReusesGlobalCanvas(t *testing.T) {
src := uiSource(t)
// One canvas implementation, two mount ids.
if !strings.Contains(src, "function renderSortEditor(") {
t.Fatal("no shared canvas renderer — the per-key editor must reuse renderSortEditor")
}
if !strings.Contains(src, `canvasId: "scr-canvas"`) {
t.Fatal("the global editor no longer mounts the shared canvas")
}
if !strings.Contains(src, `canvasId: "key-auto-canvas"`) {
t.Fatal("the per-key editor does not mount the shared canvas")
}
// Canvas lookup must consider both ids, or drag/drop silently no-ops on
// whichever surface is not #scr-canvas.
if !strings.Contains(src, "function sortCanvasEl(") ||
!strings.Contains(src, `getElementById("key-auto-canvas")`) {
t.Fatal("sortCanvasEl must resolve both canvas ids")
}
// The rules<->lanes conversion is shared too, so the per-key editor sends
// exactly the fields the server understands (quota/period/hours included).
for _, fn := range []string{"function buildLanes(", "function lanesToRules("} {
if !strings.Contains(src, fn) {
t.Fatalf("%s must be shared between the global and per-key editors", fn)
}
}
}
// Saving must route through persistAuto's scope switch, not a hand-rolled
// request, or the two editors will serialize rules differently.
func TestUIPerKeyAutoSavesThroughScope(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "sortState.scope = key") {
t.Fatal("openKeyAutoModal must set sortState.scope so the save targets the key")
}
if !strings.Contains(src, "sortState.scope = null") {
t.Fatal("closing the modal must clear sortState.scope, or the GLOBAL editor would save to this key")
}
if !strings.Contains(src, "if (sortState.scope)") {
t.Fatal("persistAuto must branch on sortState.scope")
}
if !strings.Contains(src, `"/auto"`) {
t.Fatal("per-key editor must call the /api/keys/{key}/auto endpoint")
}
// An empty lane list is how "inherit the global chain" is expressed.
if !strings.Contains(src, "JSON.stringify({ auto: lanesToRules(sortState.lanes) })") {
t.Fatal("keyAutoSave must PUT lanesToRules(sortState.lanes) verbatim")
}
}
// Both languages need the strings, otherwise a non-Chinese admin sees raw
// i18n keys in the modal.
func TestUIPerKeyAutoHasBothLanguages(t *testing.T) {
src := uiSource(t)
for _, k := range []string{
"kAutoChainTitle", "kAutoChainHint", "kAutoChainInherit",
"kAutoChainEmpty", "kAutoChainGlobal", "kAutoChainBad",
} {
n := strings.Count(src, k+`: "`)
if n < 2 {
t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n)
}
}
}
// The statistics dashboard must print the window it actually covers. Calendar
// periods are not nested — "this week" starts on its Monday and therefore
// reaches into the previous month early in a month, making its total LARGER
// than "this month". That is correct arithmetic, but without the dates on
// screen it reads as a bug, and it has been reported as one.
func TestUIStatsShowsPeriodWindow(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, `id="period-range"`) {
t.Fatal("the stats dashboard has no element for the period window range")
}
if n := strings.Count(src, "periodRange"); n < 2 {
t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n)
}
}
// Both canvases can exist in the DOM at once: the modal is mounted on top of
// the settings tab, whose #scr-canvas stays alive behind it. Resolving the
// canvas by document order (whichever comes first) painted the per-key chain
// into the hidden background tab and left the dialog empty — and worse,
// overwrote the global chain the user returns to. The lookup must follow
// sortState.scope instead.
func TestUISortCanvasResolvesByScope(t *testing.T) {
src := uiSource(t)
el := readFuncBody(t, src, "sortCanvasEl")
if !strings.Contains(el, "if (sortState.scope)") {
t.Fatal("sortCanvasEl must branch on sortState.scope, not document order")
}
if strings.Contains(el, `getElementById("scr-canvas")`) &&
strings.Index(el, `getElementById("scr-canvas")`) < strings.Index(el, "sortState.scope") {
t.Fatal("sortCanvasEl returns #scr-canvas before checking scope — the " +
"per-key editor paints into the global canvas")
}
}
// A successful save must close the dialog even when the slots do not resolve.
// The write DID succeed; leaving the modal open makes the user click Save
// again and again. This regressed once: the !slots branch returned early.
func TestUIKeyAutoSaveClosesOnSuccess(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "keyAutoSave")
// keyAutoClose() must be reached on every success path, not only in the
// happy branch.
iClose := strings.Index(body, "keyAutoClose()")
if iClose < 0 {
t.Fatal("keyAutoSave never calls keyAutoClose — the dialog would stay open")
}
iToast := strings.Index(body, "toast(")
if iToast >= 0 && iClose < iToast {
t.Fatal("keyAutoClose is called before the toast; the warning must be " +
"shown first or it is lost with the modal")
}
// An early `return` inside a branch bypasses keyAutoClose entirely, so
// merely finding the call is not enough: every `return` that sits between
// entering the try block and the close call is a path that leaves the
// dialog open after a successful write.
tryStart := strings.Index(body, "try {")
seg := body[tryStart:iClose]
for _, line := range strings.Split(seg, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "return;" || strings.HasPrefix(trimmed, "return ") {
t.Fatal("a `return` before keyAutoClose leaves the dialog open after " +
"a successful save: " + trimmed)
}
}
}
// readFuncBody returns the source of a top-level function by name, using
// brace matching so nested blocks do not truncate it.
func readFuncBody(t *testing.T, src, name string) string {
t.Helper()
idx := strings.Index(src, "function "+name+"(")
if idx < 0 {
t.Fatalf("function %s not found", name)
}
start := strings.Index(src[idx:], "{")
if start < 0 {
t.Fatalf("function %s has no body", name)
}
i := idx + start
depth := 0
for j := i; j < len(src); j++ {
switch src[j] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return src[i : j+1]
}
}
}
t.Fatalf("function %s body is unterminated", name)
return ""
}
// The per-key dialog must not share the app-wide #modal-wrap id.
//
// Every dialog in this file uses that id, and more than one can be open at
// once (the "add slot" picker opens ON TOP of this one). Handlers resolve
// their own dialog via `.closest()` from their button, which works — but this
// dialog is also looked up from the CANVAS side (sortCanvasEl, keyAutoClose,
// keyAutoInherit), where there is no button to walk up from.
// getElementById("#modal-wrap") then returns whichever dialog comes first in
// the document, i.e. not the one being edited: Cancel closed the wrong box and
// the canvas could resolve to the picker's.
func TestUIKeyAutoModalHasOwnID(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "openKeyAutoModal")
if !strings.Contains(body, `wrap.id = "key-auto-modal"`) {
t.Fatal("openKeyAutoModal must give its dialog its own id, not the shared " +
"#modal-wrap (another dialog may be open on top of it)")
}
close := readFuncBody(t, src, "keyAutoClose")
if strings.Contains(close, `"modal-wrap"`) {
t.Fatal("keyAutoClose resolves the shared #modal-wrap; it would close " +
"whichever dialog comes first in the document")
}
if !strings.Contains(close, `"key-auto-modal"`) {
t.Fatal("keyAutoClose must resolve the per-key dialog by its own id")
}
}
// Editing a chain inside the per-key dialog must NOT persist it. That dialog
// has Save and Cancel, so auto-writing on add/drag/delete meant the change was
// already committed before Save and Cancel could not undo it.
//
// The global page is the opposite: it has always applied edits immediately.
func TestUIKeyAutoEditsDoNotAutoPersist(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "afterChainEdit")
if !strings.Contains(body, "if (sortState.scope)") {
t.Fatal("afterChainEdit must branch on sortState.scope")
}
// The scoped branch must return before reaching persistAuto.
iScope := strings.Index(body, "if (sortState.scope)")
iReturn := strings.Index(body[iScope:], "return")
iPersist := strings.Index(body, "persistAuto()")
if iScope < 0 || iReturn < 0 || iPersist < 0 {
t.Fatal("afterChainEdit no longer has the expected shape")
}
if iScope+iReturn > iPersist {
t.Fatal("the scoped branch reaches persistAuto() — a per-key edit would " +
"be written before Save")
}
// And it must not call itself (a regex-driven rewrite did exactly that).
if strings.Contains(body, "afterChainEdit()") {
t.Fatal("afterChainEdit calls itself: infinite recursion")
}
// Every in-canvas edit goes through it, so none of them bypass the check.
for _, fn := range []string{"scrAddFromForm", "sortScopeSave", "scrDelSlot"} {
b := readFuncBody(t, src, fn)
if strings.Contains(b, "persistAuto()") {
t.Fatalf("%s calls persistAuto() directly, bypassing afterChainEdit", fn)
}
}
}
// The stats range line formats its end date from the LAST bucket label.
// Bucket labels are hourly for the day view ("2026-10-03T09") and daily for
// week/month ("2026-10-01"), so blindly appending "T00:00:00Z" to the hourly
// form builds "2026-10-03T09T00:00:00Z" — Date rejects it, toISOString()
// throws, and the assignment is lost. The symptom is an empty range line on
// exactly the view people look at most, with no error anywhere.
func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) {
src := uiSource(t)
el := readFuncBody(t, src, "paintStats")
// It must branch on the label shape instead of concatenating blindly.
if !strings.Contains(el, `T\d{2}$`) && !strings.Contains(el, `\d{2}-\d{2}T\d{2}`) {
t.Fatal("paintStats must recognise the hourly bucket label shape " +
"(YYYY-MM-DDTHH); concatenating a time onto it yields an invalid Date")
}
// And it must not contain the naive form that broke it.
if strings.Contains(el, `bucket + "T00:00:00Z"`) {
t.Fatal("paintStats still appends T00:00:00Z to the bucket label — " +
"this throws on the day view and leaves the range line empty")
}
}
// A key with no chain of its own must open on a COPY OF THE GLOBAL CHAIN, not
// on an empty canvas. The realistic admin task is "this user gets the global chain
// minus the sources they must not hit"; starting blank forces retyping every
// tier against a 226-model picker. This is deliberately NOT the discovery
// fallback (which dumps every model of every source) — it is the same rules
// the global chain actually runs.
func TestUIKeyAutoSeedsFromGlobalChain(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "openKeyAutoModal")
if !strings.Contains(body, `api("/api/auto")`) {
t.Fatal("openKeyAutoModal must read the global chain to seed the editor")
}
if !strings.Contains(body, "seededFromGlobal") {
t.Fatal("the editor must remember whether its content came from the " +
"global chain, so the notice can say so")
}
// And it must not fall back to the discovery path that fills 226 models.
if strings.Contains(body, `buildLanes(idx, [], `) {
t.Fatal("seeding from an empty rule list triggers buildLanes discovery")
}
}
// The notice must distinguish "already this key's chain" from "a copy that
// only becomes its chain on Save" — those two look identical on the canvas and
// behave very differently if the admin hits Cancel.
func TestUIKeyAutoNoticeDistinguishesSavedFromCopied(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "keyAutoInheritNotice")
for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} {
if !strings.Contains(body, k) {
t.Fatalf("the notice never uses %s — a copied chain and a saved one "+
"would look the same", k)
}
}
// Both languages, or a non-Chinese admin sees raw keys.
for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} {
if n := strings.Count(src, k+`: "`); n < 2 {
t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n)
}
}
}
// A key whose model scope excludes AUTO cannot use AUTO at all — the scope
// filter runs before the chain — yet the editor happily lets an admin
// configure a per-key chain. That combination is easy to create by accident
// and produces a 403 with no visible cause, so the dialog must say so.
//
// We deliberately do NOT widen the scope automatically: silently granting a
// model the operator did not ask for is worse than a loud warning.
func TestUIKeyAutoWarnsOnScopeConflict(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "function keyAutoScopeWarning(") {
t.Fatal("no scope-conflict check in the per-key editor")
}
body := readFuncBody(t, src, "keyAutoScopeWarning")
if !strings.Contains(body, "AUTO") {
t.Fatal("keyAutoScopeWarning must test whether the scope lists AUTO")
}
// It must not write anything back to the key: reporting only.
for _, forbidden := range []string{"PUT", "POST", "fetch(", "api("} {
if strings.Contains(body, forbidden) {
t.Fatalf("keyAutoScopeWarning calls %s — it must only report the "+
"conflict, never widen the scope itself", forbidden)
}
}
// Empty scope means unrestricted, so no warning is correct there.
if !strings.Contains(body, "if (!models.length)") {
t.Fatal("an unrestricted key (no models) must not be warned")
}
// Both languages.
if n := strings.Count(src, "kAutoChainScopeWarn"); n < 2 {
t.Fatalf("i18n key kAutoChainScopeWarn appears %d time(s), want zh+en", n)
}
}
// The scope conflict for AUTO must be reported by checkQuota, because that is
// the check an AUTO request actually reaches first (it runs before the chain is
// consulted). Putting the message on the later path made it dead code — the
// request was rejected one check earlier with the generic "not allowed for this
// key" wording, which sends an operator looking for a quota that isn't the
// problem.
func TestAutoScopeConflictMessageComesFromCheckQuota(t *testing.T) {
b, err := os.ReadFile("chat.go")
if err != nil {
t.Fatalf("read chat.go: %v", err)
}
src := string(b)
i := strings.Index(src, "func (g *Gateway) checkQuota(")
if i < 0 {
t.Fatal("checkQuota not found")
}
// Body = up to the next top-level func.
rest := src[i:]
if j := strings.Index(rest[1:], "\nfunc "); j > 0 {
rest = rest[:j+1]
}
if !strings.Contains(rest, "isAuto(model)") {
t.Fatal("checkQuota must special-case AUTO: a scope that omits AUTO is " +
"rejected there before anything else looks at the chain")
}
if !strings.Contains(rest, "not in this key's model scope") {
t.Fatal("checkQuota's AUTO branch must name the scope as the cause")
}
}
// renderSortEditor replaces its mount's innerHTML wholesale. The global
// priority page used to hand it the .card that also holds the h2 toolbar, so
// every open deleted the save/reset/add buttons: the lanes still rendered and
// the drag worked, but there was no way to persist a change. The per-key modal
// never showed this because its mount held only the canvas.
//
// The regression is invisible in the DOM when you only count lane elements, so
// this asserts on the structure: the toolbar lives in a sibling of the mount,
// not inside it.
func TestGlobalSortToolbarSurvivesCanvasRender(t *testing.T) {
src := uiSource(t)
// The global editor must mount into a dedicated host, not the .card.
i := strings.Index(src, `$("#tab-sort").innerHTML = `)
if i < 0 {
t.Fatal("global sort markup not found")
}
chunk := src[i:min(len(src), i+1400)]
if !strings.Contains(chunk, `id="sort-mount"`) {
t.Fatal("global sort page must provide a dedicated #sort-mount for the canvas")
}
// The mount handed to renderSortEditor must be that host.
if !strings.Contains(src, `renderSortEditor($("#sort-mount")`) {
t.Fatal("renderSortEditor must be given #sort-mount; passing the .card " +
"wipes the h2 toolbar that holds the save button")
}
// The toolbar (save in particular) must be OUTSIDE #sort-mount.
close := strings.Index(chunk, "</div>`;")
if close < 0 {
t.Fatal("malformed #tab-sort template")
}
head := chunk[:close]
mountAt := strings.Index(head, `id="sort-mount"`)
saveAt := strings.Index(head, `onclick="saveSort()"`)
if saveAt < 0 {
t.Fatal("the global sort page has no save button at all")
}
if mountAt >= 0 && saveAt > mountAt {
t.Fatal("the save button sits inside the canvas mount and will be " +
"erased the moment the canvas renders")
}
}

View File

@ -388,3 +388,26 @@ func TestPluginSidebarEntryCarriesATitle(t *testing.T) {
t.Errorf("plugin sidebar button renders the icon only; a label is required. "+
"got: %.200s", stmt)
}
// TestPluginPageHasAccessEditor: the plugin page must let an operator configure
// endpoint access. The APIs existed with zero UI entry point, which meant the
// only way to change who could reach a plugin endpoint was to hand-edit
// config.yaml — the thing the UI is meant to replace.
func TestPluginPageHasAccessEditor(t *testing.T) {
src := uiSource(t)
for _, needle := range []string{
`renderPluginAccess`, // called after rendering the plugin list
`savePluginAccess`, // wired to a button
`/access`, // hits the per-endpoint access API
`id="pl-access"`, // container exists
`http_access`, // reads the table from /api/plugins
`data-ep=`, // one input per endpoint
`data-plvis=`, // one visibility selector per plugin
`/http/*/access`, // whole-plugin visibility shares the access API
`plVisCell`, // renders the declared-vs-override distinction
} {
if !strings.Contains(src, needle) {
t.Errorf("plugin page lacks %q", needle)
}
}
}

View File

@ -0,0 +1,323 @@
package lua
import (
"encoding/json"
"strings"
"testing"
)
// The fixtures below are REAL responses captured from
// https://discovery-api.intern-ai.org.cn/v1 with qwen3.8-27b — not hand-written
// approximations. Two of them exist only because the endpoint behaves in a way a
// stock OpenAI adapter gets wrong, and neither is guessable from the schema.
// reasoningDominatesTheBudget is the fact that made a dedicated adapter
// necessary: a one-sentence visual answer came back as completion=303 with
// reasoning=295. 97% of the budget went to thinking and only 8 tokens reached
// content. Handing the upstream's content straight through yields what looks
// like an empty answer from a perfectly healthy request.
const reasoningDominatesTheBudget = `{
"choices": [{"index":0,"message":{"role":"assistant","content":"\n\n这张图是白色的。",
"reasoning_content":"分析请求…分析图片…颜色是纯白"},"logprobs":null,
"finish_reason":"stop"}],
"created":1791179068,"model":"qwen3.8-27b-fp8","object":"chat.completion",
"usage":{"prompt_tokens":125,"total_tokens":428,"completion_tokens":303,
"prompt_tokens_details":null,
"completion_tokens_details":{"reasoning_tokens":295}}
}`
// truncatedReasoningOnly is what a small max_tokens produces: HTTP 200, content
// null, finish_reason "length". It is NOT an upstream failure.
const truncatedReasoningOnly = `{
"choices": [{"index":0,"message":{"role":"assistant","content":null,
"reasoning_content":"思考过程:1. **分析请求**…图片的颜色是纯","refusal":null},
"finish_reason":"length"}],
"model":"qwen3.8-27b-fp8","object":"chat.completion",
"usage":{"prompt_tokens":127,"total_tokens":190,"completion_tokens":63}
}`
const qwen38ToolCallResponse = `{
"choices": [{"index":0,"message":{"role":"assistant","content":"",
"reasoning_content":"用户询问北京天气。我需要调用 get_weather 函数,参数为城市 \"北京\"。",
"tool_calls":[{"id":"chatcmpl-tool-afc0299b5810e044","type":"function",
"function":{"name":"get_weather","arguments":"{\"city\": \"北京\"}"}}]},
"finish_reason":"tool_calls"}],
"model":"qwen3.8-27b-fp8",
"usage":{"prompt_tokens":305,"total_tokens":353,"completion_tokens":48,
"completion_tokens_details":{"reasoning_tokens":20}}
}`
func qwen38VM(t *testing.T) *VM {
t.Helper()
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("adapter VM: %v", err)
}
t.Cleanup(vm.Stop)
return vm
}
func qwen38Response(t *testing.T, vm *VM, raw string) map[string]interface{} {
t.Helper()
out, err := vm.Transform("qwen38", "transform_response", raw)
if err != nil {
t.Fatalf("transform_response: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(out), &m); err != nil {
t.Fatalf("adapter returned non-JSON: %v\n%s", err, out)
}
return m
}
func qwen38Stream(t *testing.T, vm *VM, raw string) map[string]interface{} {
t.Helper()
out, err := vm.Transform("qwen38", "transform_stream_chunk", raw)
if err != nil {
t.Fatalf("transform_stream_chunk: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(out), &m); err != nil {
t.Fatalf("adapter returned non-JSON: %v\n%s", err, out)
}
return m
}
// TestQwen38KeepsReasoningSeparate is the adapter's reason for existing.
// Folding reasoning into content would render the model's private deliberation as
// its answer; dropping it makes a reasoning model look like it returned nothing.
func TestQwen38KeepsReasoningSeparate(t *testing.T) {
vm := qwen38VM(t)
u := qwen38Response(t, vm, reasoningDominatesTheBudget)
content, _ := u["content"].(string)
reasoning, _ := u["reasoning_content"].(string)
if reasoning == "" {
t.Fatal("reasoning_content was dropped — a reasoning model then looks like it returned nothing")
}
if strings.Contains(content, "分析请求") {
t.Errorf("reasoning was folded into content: %q", content)
}
if !strings.Contains(content, "白色的") {
t.Errorf("content = %q, want the answer text", content)
}
if reasoning == content {
t.Error("content and reasoning_content are identical; they are disjoint upstream")
}
}
// TestQwen38ReportsTruncationRatherThanFailing: content=null with
// finish_reason="length" is a truncated reasoning response, not a broken
// upstream. Losing the finish_reason makes it indistinguishable from a failure,
// and losing the partial reasoning throws away the only evidence of what happened.
func TestQwen38ReportsTruncationRatherThanFailing(t *testing.T) {
vm := qwen38VM(t)
u := qwen38Response(t, vm, truncatedReasoningOnly)
if u["finish_reason"] != "length" {
t.Errorf("finish_reason = %v, want length", u["finish_reason"])
}
if reasoning, _ := u["reasoning_content"].(string); reasoning == "" {
t.Error("the partial reasoning was discarded")
}
if content, _ := u["content"].(string); content != "" {
t.Errorf("content = %q, want empty string for a null upstream content", content)
}
}
// TestQwen38CarriesReasoningTokensWithoutInflatingTotals: reasoning_tokens is a
// SUBSET of completion_tokens. Adding them double-counts every reasoning request,
// and billing prices the pair separately.
func TestQwen38CarriesReasoningTokensWithoutInflatingTotals(t *testing.T) {
vm := qwen38VM(t)
u := qwen38Response(t, vm, reasoningDominatesTheBudget)
tu, ok := u["token_usage"].(map[string]interface{})
if !ok {
t.Fatalf("token_usage = %#v", u["token_usage"])
}
completion, _ := tu["completion"].(float64)
total, _ := tu["total"].(float64)
// The NESTING is the point, not the presence of the field.
//
// internal/gateway/chat.go reads reasoning in exactly one place:
// resp.TokenUsage.CompletionTokensDetails.ReasoningTokens
// An adapter that emits reasoning_tokens at the TOP of token_usage type-checks
// fine and is then dropped at the one spot that could have kept it — measured
// on the live gateway: the model reported completion=88 and the gateway's
// response carried reasoning=null.
//
// So this asserts the path, not merely that a number appears somewhere.
det, ok := tu["completion_tokens_details"].(map[string]interface{})
if !ok {
t.Fatalf("token_usage has no completion_tokens_details: %#v — chat.go reads "+
"reasoning from there and nowhere else, so the number is discarded", tu)
}
reasoning, has := det["reasoning_tokens"].(float64)
if !has {
t.Fatal("completion_tokens_details.reasoning_tokens missing — billing cannot price reasoning separately")
}
// Relational, not a presence check. The first version of this test only
// asserted the field existed, and a mutation setting it to 0 passed: 0 is
// present. The upstream value is known (295), so the exact number is
// asserted — a zero silently misprices every reasoning request.
if reasoning != 295 {
t.Errorf("reasoning_tokens = %v, want 295 (the upstream value)", reasoning)
}
if reasoning >= completion {
t.Errorf("reasoning_tokens (%v) must be a SUBSET of completion_tokens (%v)", reasoning, completion)
}
if completion != 303 || total != 428 {
t.Errorf("usage was altered: completion=%v total=%v, want 303/428", completion, total)
}
}
// TestQwen38DecodesToolCallArguments: the upstream sends arguments as a JSON
// STRING, like every other OpenAI-compatible endpoint. Passing it through
// undecoded leaves the caller to parse it.
func TestQwen38DecodesToolCallArguments(t *testing.T) {
vm := qwen38VM(t)
u := qwen38Response(t, vm, qwen38ToolCallResponse)
tcs, ok := u["tool_calls"].([]interface{})
if !ok || len(tcs) != 1 {
t.Fatalf("tool_calls = %#v", u["tool_calls"])
}
tc, _ := tcs[0].(map[string]interface{})
if tc["name"] != "get_weather" {
t.Errorf("name = %v", tc["name"])
}
args, ok := tc["arguments"].(map[string]interface{})
if !ok {
t.Fatalf("arguments = %#v — the upstream sends a JSON string and it must be decoded", tc["arguments"])
}
if args["city"] != "北京" {
t.Errorf("arguments.city = %v", args["city"])
}
}
// TestQwen38StreamKeepsReasoningSeparate: measured 20 reasoning chunks against 1
// content chunk on a one-sentence answer. Folding them would make the client
// render the deliberation as the reply.
func TestQwen38StreamKeepsReasoningSeparate(t *testing.T) {
vm := qwen38VM(t)
const reasoningChunk = `{"choices":[{"index":0,"delta":{"reasoning_content":"思考过程:1. **分析请求**"},
"finish_reason":null}],"model":"qwen3.8-27b-fp8"}`
const contentChunk = `{"choices":[{"index":0,"delta":{"content":"递归就是函数调用自身"},
"finish_reason":null}],"model":"qwen3.8-27b-fp8"}`
const usageChunk = `{"choices":[],"usage":{"prompt_tokens":64,"total_tokens":132,
"completion_tokens":68,"completion_tokens_details":{"reasoning_tokens":62}}}`
a := qwen38Stream(t, vm, reasoningChunk)
b := qwen38Stream(t, vm, contentChunk)
c := qwen38Stream(t, vm, usageChunk)
if content, mixed := a["content"].(string); mixed && content != "" {
t.Errorf("a reasoning chunk leaked into content: %q", content)
}
if reasoning, _ := a["reasoning_content"].(string); !strings.Contains(reasoning, "思考过程") {
t.Errorf("reasoning chunk lost its text: %#v", a)
}
if content, _ := b["content"].(string); !strings.Contains(content, "递归") {
t.Errorf("content chunk lost its text: %#v", b)
}
if c["usage"] == nil {
t.Errorf("the terminal usage chunk was dropped: %#v", c)
return
}
u, _ := c["usage"].(map[string]interface{})
det, _ := u["completion_tokens_details"].(map[string]interface{})
if det == nil || det["reasoning_tokens"] == nil {
t.Errorf("usage chunk did not nest reasoning under completion_tokens_details: %#v — "+
"chat.go reads it there, so a top-level field is discarded", u)
}
}
// TestQwen38ReportsMediaFailureCode: a remote image URL times out upstream and
// comes back as media_fetch_failed. Surfacing only the message makes an image
// fetch problem look like a model problem.
func TestQwen38ReportsMediaFailureCode(t *testing.T) {
vm := qwen38VM(t)
body := `{"error":{"message":"image fetch timed out","type":"media_fetch_failed",
"param":null,"code":"media_fetch_failed","trace_id":"f3ca4aa"}}`
reason, ok, err := vm.TransformError("qwen38", 400, body)
if err != nil {
t.Fatalf("TransformError: %v", err)
}
if !ok {
t.Fatal("the media failure was swallowed")
}
if !strings.Contains(reason, "image fetch timed out") || !strings.Contains(reason, "media_fetch_failed") {
t.Errorf("reason = %q; it must carry both the text and the code", reason)
}
}
// TestQwen38InvalidKeyError: the endpoint answers a bad key with 401 and
// code "invalid_api_key". That one must NOT be annotated with its own code — it
// would render as "invalid API key (invalid_api_key)".
func TestQwen38InvalidKeyError(t *testing.T) {
vm := qwen38VM(t)
body := `{"error":{"message":"invalid API key","type":"invalid_request_error",
"param":null,"code":"invalid_api_key"}}`
reason, ok, err := vm.TransformError("qwen38", 401, body)
if err != nil || !ok {
t.Fatalf("TransformError: ok=%v err=%v", ok, err)
}
if reason != "invalid API key" {
t.Errorf("reason = %q, want the bare message", reason)
}
}
// TestQwen38SuppliesReasoningOnToolCallTurns: the upstream requires an assistant
// message carrying tool_calls to echo reasoning_content. OpenAI-shaped clients
// never send it, so without this the SECOND turn of any tool-using conversation
// is rejected.
func TestQwen38SuppliesReasoningOnToolCallTurns(t *testing.T) {
vm := qwen38VM(t)
const req = `{"model":"qwen3.8-27b","messages":[
{"role":"user","content":"北京天气"},
{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function",
"function":{"name":"get_weather","arguments":"{\"city\":\"北京\"}"}}]},
{"role":"tool","tool_call_id":"c1","content":"晴 25C"}]}`
out, err := vm.Transform("qwen38", "transform_request", req)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
var decoded map[string]interface{}
if err := json.Unmarshal([]byte(out), &decoded); err != nil {
t.Fatalf("request transform did not return JSON: %v\n%s", err, out)
}
msgs, _ := decoded["messages"].([]interface{})
if len(msgs) < 2 {
t.Fatalf("messages = %#v", decoded["messages"])
}
assistant, _ := msgs[1].(map[string]interface{})
if _, ok := assistant["reasoning_content"]; !ok {
t.Errorf("assistant turn with tool_calls has no reasoning_content; the upstream "+
"rejects the next turn. Got %#v", assistant)
}
}
// TestQwen38PassesImagesThrough: this adapter's job is reasoning handling, not
// media translation. A multimodal content array must survive untouched — an
// adapter that re-encodes it is the kind of change that silently breaks images
// while every text test still passes.
func TestQwen38PassesImagesThrough(t *testing.T) {
vm := qwen38VM(t)
const req = `{"model":"qwen3.8-27b","messages":[{"role":"user","content":[
{"type":"text","text":"这张图是什么颜色?"},
{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]}],
"max_tokens":600}`
out, err := vm.Transform("qwen38", "transform_request", req)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
if !strings.Contains(out, "image_url") || !strings.Contains(out, "data:image/png;base64,") {
t.Errorf("the image part was lost or rewritten:\n%s", out)
}
if strings.Contains(out, "base64,iVBORw0KGgo=") == false {
t.Errorf("the base64 payload was altered:\n%s", out)
}
}

View File

@ -0,0 +1,94 @@
package lua
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
)
// TestEveryBundledAdapterTwoDirections 逐个验证每个内置适配器在**两个方向**上
// 的声明与能力一致,且都能在真实 VM 上被调用而不报错。这是"重构后两个方向
// 都没坏"的批量断言(用户要求:16 个适配器逐个验证两个方向)。
//
// 两个方向:
//
// 出向(egress):serialize_* 钩子 —— 统一格式 → 客户端协议。
// 一个适配器要么声明了 serialize_*(接管),要么依赖内核 OpenAI 兜底。
// 两种都合法;不合法的是"声明了钩子但调用即报错"。
//
// 入向(ingress):adapter.ingress + deserialize_request —— 客户端协议 → 统一格式。
// 声明 ingress 的适配器必须同时实现 deserialize_request,否则内核会让出给
// 一个空解析器(核心里 parseViaIngress 已挡住,此处再在适配器数据面钉一次)。
//
// 上游向(upstream):transform_request —— 统一格式 → 上游协议。
// 这是所有适配器的本职,必须存在且可调用。
func TestEveryBundledAdapterTwoDirections(t *testing.T) {
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
adapters := vm.ListAdapters()
if len(adapters) == 0 {
t.Fatal("no adapters loaded")
}
// 一个最小、协议中立的统一请求,供上游向 transform_request 使用。
unifiedReq := `{"model":"m","messages":[{"role":"user","content":"hi"}],"max_tokens":16,"stream":false}`
for _, a := range adapters {
a := a
t.Run(a.Name, func(t *testing.T) {
// ---- 上游向:transform_request 必须存在且可调用 ----
if !vm.HasFn(a.Name, "transform_request") {
t.Errorf("%s: 缺 transform_request(上游向本职)", a.Name)
} else if _, err := vm.Transform(a.Name, "transform_request", unifiedReq); err != nil {
t.Errorf("%s: transform_request 调用失败: %v", a.Name, err)
}
// ---- 出向:serialize_* 声明与调用一致性 ----
declaredEgress := false
for _, fn := range []string{"serialize_response", "serialize_chunk", "serialize_open_preamble", "serialize_close", "serialize_done_marker"} {
if vm.HasFn(a.Name, fn) {
declaredEgress = true
}
}
if vm.HasFn(a.Name, "serialize_response") {
// 统一结果形状(见 types.UnifiedResult)
res := `{"model":"m","id":"x","content":"hello","finish_reason":"stop","token_usage":{"prompt":1,"completion":1,"total":2}}`
out, err := vm.Transform(a.Name, "serialize_response", res)
if err != nil {
t.Errorf("%s: serialize_response 调用失败: %v", a.Name, err)
} else if strings.TrimSpace(out) == "" {
t.Errorf("%s: serialize_response 返回空", a.Name)
} else if !json.Valid([]byte(out)) && !strings.HasPrefix(strings.TrimSpace(out), "event:") {
t.Errorf("%s: serialize_response 既非 JSON 也非 SSE: %q", a.Name, out)
}
}
if declaredEgress {
t.Logf("%s: 出向由适配器接管", a.Name)
} else {
t.Logf("%s: 出向走内核 OpenAI 兜底(未声明 serialize_*)", a.Name)
}
// ---- 入向:声明 ingress 必须配 deserialize_request ----
ingress := vm.AdapterIngress(a.Name)
hasDeser := vm.HasFn(a.Name, "deserialize_request")
if ingress != "" {
if !hasDeser {
t.Errorf("%s: 声明 ingress=%q 但没有 deserialize_request —— 内核会让出给空解析器", a.Name, ingress)
} else {
// 用该适配器自己服务的那个端点的典型 body 调一次,确认不报错。
if _, err := vm.Transform(a.Name, "deserialize_request", `{"model":"m","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}`); err != nil {
t.Errorf("%s: deserialize_request 调用失败: %v", a.Name, err)
}
t.Logf("%s: 入向认领 %s", a.Name, ingress)
}
} else if hasDeser {
t.Errorf("%s: 实现了 deserialize_request 却没声明 ingress —— 内核永远不会调用它", a.Name)
}
})
}
}

View File

@ -0,0 +1,133 @@
// Package lua — 适配器导入安全校验(R6)
//
// 用户要求(feature/unified-wire-v2):导入适配器时必须检查路径冲突,
// 避免静默错误。
//
// 之前的风险:
//
// UploadAdapter(name, code) 直接拼 adapter_dir/<name>.lua。
// name 含 "../" 或绝对路径会写到目录外;
// name 与内置适配器同名(openai/anthropic/gemini…)会静默覆盖——
// 加载失败只报 "adapter X missing",错误现场与病因分离。
//
// 这里的校验在**所有导入入口**统一执行(LoadAdapterSource / UploadAdapter /
// 插件 install_adapter),让"为什么被拒绝"成为明确的错误信息。
package lua
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// AdapterNamePattern 是适配器名的合法字符集。
// 不允许:路径分隔符(/、\)、点点(..)、绝对路径、空格。
// 允许:字母、数字、下划线、连字符。
const AdapterNamePattern = `^[a-zA-Z0-9_-]+$`
// ErrAdapterNameInvalid 适配器名不合法。
type ErrAdapterNameInvalid struct{ Name, Reason string }
func (e *ErrAdapterNameInvalid) Error() string {
return fmt.Sprintf("adapter name %q invalid: %s", e.Name, e.Reason)
}
// ErrAdapterNameReserved 适配器名与内核内置适配器冲突(用户要求:冲突时
// 内核内置优先,外部不得覆盖)。
type ErrAdapterNameReserved struct{ Name string }
func (e *ErrAdapterNameReserved) Error() string {
return fmt.Sprintf("adapter name %q is reserved by a built-in adapter; renaming or deleting the built-in is not supported", e.Name)
}
// ValidateAdapterName 校验一个将被导入的适配器名。
//
// 它不做文件系统存在性检查(那是上传路径的责任),只校验"这个名字作为
// adapter_dir/<name>.lua 的一部分是否安全"。校验维度:
//
// 1. 非空、符合字符集(拒绝路径穿越、绝对路径、空白)
// 2. 不与内核内置适配器名冲突(除非 force 覆盖内置,但这通常不该被允许)
// 3. 不含保留路径段(.、..、CON、NUL 等)
//
// reservedNames 是调用方提供的"保留名集合"——通常是内置适配器名表。
// 传 nil 表示无保留名(仍校验字符集与路径安全)。
func ValidateAdapterName(name string, reservedNames []string) error {
if name == "" {
return &ErrAdapterNameInvalid{Name: name, Reason: "empty"}
}
// 字符集:最简单的拒绝路径风险的方式。
if !matchesAdapterPattern(name) {
return &ErrAdapterNameInvalid{Name: name, Reason: "must match " + AdapterNamePattern + " (no slashes, dots-only, absolute paths, or whitespace)"}
}
// 保留段:即使字符集通过,也要点名拒绝 . 和 .. (它们会被某些字符集放行,
// 但绝不能成为文件名)。
switch name {
case ".", "..":
return &ErrAdapterNameInvalid{Name: name, Reason: "reserved path segment"}
}
for _, reserved := range reservedNames {
if strings.EqualFold(name, reserved) {
return &ErrAdapterNameReserved{Name: name}
}
}
return nil
}
// ValidateAdapterPath 在 LoadAdapter(path) 入口校验磁盘路径是否指向
// adapter_dir 下、且文件名合法。
//
// 这一层是"从磁盘加载"的安全网:Start() 用它加载目录里的 .lua,必须保证
// 一个被放进来的文件(无论谁放的)只能注册成它自己的名字,不能因为文件名
// 怪异(如 ../openai.lua)就覆盖或越界。
//
// 返回校验后的 (name, error)。
func ValidateAdapterPath(path, adapterDir string, reservedNames []string) (string, error) {
// 先解析成绝对路径,与 adapter_dir 比对,确认文件确实在目录内
// (不在目录内的 LoadAdapter 调用本就是异常用法,但 Start() 遍历的是
// ReadDir 的结果,天然在目录内,这条校验对它是 no-op)。
abs, err := filepath.Abs(path)
if err != nil {
return "", &ErrAdapterNameInvalid{Name: path, Reason: "unresolvable path"}
}
dirAbs, _ := filepath.Abs(adapterDir)
if !isWithinDir(abs, dirAbs) {
return "", &ErrAdapterNameInvalid{Name: path, Reason: "file is outside adapter_dir"}
}
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
if err := ValidateAdapterName(name, reservedNames); err != nil {
return "", err
}
return name, nil
}
// matchesAdapterPattern 是 AdapterNamePattern 的纯函数实现(避免在 hot path
// 里编译正则)。
func matchesAdapterPattern(s string) bool {
if s == "" {
return false
}
for _, r := range s {
ok := (r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
r == '_' || r == '-'
if !ok {
return false
}
}
return true
}
// isWithinDir 判断 path 是否在 dir 内(含 path == dir 本身,不含 .. 逃逸)。
func isWithinDir(path, dir string) bool {
rel, err := filepath.Rel(dir, path)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && rel != "."
}
// 哑引用:让 errors 包在 go vet 下不报"导出但未使用"。
var _ = errors.New

View File

@ -0,0 +1,197 @@
package lua
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// 本文件锁定 R6:导入适配器时检查路径冲突,避免静默错误。
//
// 覆盖三类注入:
// 1. 名字字符集(路径穿越 / 绝对路径 / 空白)→ 拒绝
// 2. 保留名冲突(内置适配器)→ 拒绝
// 3. 合法名 → 通过(且确实可加载)
func TestValidateAdapterNameRejectsPathTraversal(t *testing.T) {
for _, name := range []string{
"../openai", // 目录逃逸
"..", // 保留段
".", // 保留段
"/etc/passwd", // 绝对路径
"a/b/c", // 斜杠
"a\\b", // 反斜杠
"has space", // 空白
"a b c", // 空格
"", // 空
} {
if err := ValidateAdapterName(name, nil); err == nil {
t.Errorf("名字 %q 应被拒绝(路径不安全),但通过了", name)
}
}
}
func TestValidateAdapterNameRejectsReserved(t *testing.T) {
reserved := []string{"openai", "anthropic", "gemini", "Ollama"}
for _, name := range []string{"openai", "anthropic", "gemini", "ollama"} {
if err := ValidateAdapterName(name, reserved); err == nil {
t.Errorf("名字 %q 与内置适配器冲突,应被拒绝", name)
}
}
}
func TestValidateAdapterNameAcceptsLegal(t *testing.T) {
for _, name := range []string{
"my_adapter",
"my-adapter",
"custom123",
"a",
"opencode", // 不是保留名(本次已从内置移除)
} {
if err := ValidateAdapterName(name, []string{"openai", "anthropic"}); err != nil {
t.Errorf("合法名字 %q 被误拒: %v", name, err)
}
}
}
// TestValidateAdapterPathRejectsOutsideDir 验证从磁盘加载路径时,
// 目录外的文件被拒绝。
func TestValidateAdapterPathRejectsOutsideDir(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(dir, "..", "evil.lua")
if _, err := ValidateAdapterPath(outside, dir, nil); err == nil {
t.Errorf("目录外的路径 %q 应被拒绝", outside)
}
}
// TestLoadAdapterSourceAllowsBuiltinOverride 验证运维覆盖内置适配器是允许的
// (文档化的 adapter_dir 热覆盖机制)——仅插件被禁止影子内置。
func TestLoadAdapterSourceAllowsBuiltinOverride(t *testing.T) {
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
code := `local a={} a.name="openai" a.version="override" a.endpoint="/x" return a`
if err := vm.LoadAdapterSource("openai", code); err != nil {
t.Errorf("运维覆盖内置 openai 应被允许(热覆盖机制),却被拒: %v", err)
}
}
// TestInstallPluginAdapterRejectsBuiltin 验证**插件**装内置名被拒(防篡改)。
func TestInstallPluginAdapterRejectsBuiltin(t *testing.T) {
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
code := `local a={} a.name="openai" a.version="evil" a.endpoint="/x" return a`
if err := vm.InstallPluginAdapter("openai", code); err == nil {
t.Error("插件装与内置同名的适配器应被拒(防篡改发行物)")
}
// 但插件装非内置名应成功
ok := `local a={} a.name="myplugin_adapter" a.version="1" a.endpoint="/x" return a`
if err := vm.InstallPluginAdapter("myplugin_adapter", ok); err != nil {
t.Errorf("插件装非保留名应成功: %v", err)
}
}
func TestLoadAdapterSourceRejectsReserved(t *testing.T) {
t.Skip("已改:LoadAdapterSource 允许运维覆盖内置;保留名禁止已移至 InstallPluginAdapter")
}
// TestLoadAdapterSourceRejectsTraversal 验证加载入口拒绝路径穿越名。
func TestLoadAdapterSourceRejectsTraversal(t *testing.T) {
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
code := `local a={} a.name="x" a.version="1" a.endpoint="/x" return a`
if err := vm.LoadAdapterSource("../evil", code); err == nil {
t.Error("路径穿越名 ../evil 应被拒绝")
}
}
// TestUploadAdapterPathTraversalDoesNotEscape 是端到端安全测试:
// 模拟管理员通过 API 上传一个恶意名,验证它**不会**写到 adapter_dir 之外。
func TestUploadAdapterPathTraversalDoesNotEscape(t *testing.T) {
// 用一个真实 Core 来测 UploadAdapter,但 core 需要完整配置——这里直接
// 测 UploadAdapter 的校验层(名字在写文件之前就被拒)。
// 由于 UploadAdapter 在 core 包,我们在 lua 层能测的等价面是
// ValidateAdapterName + LoadAdapterSource,上面已覆盖。
// 这里补一个"确无副作用"断言:遍历名不得创建任何文件。
dir := filepath.Join(t.TempDir(), "adapters")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := ValidateAdapterName("../openai", nil); err == nil {
t.Fatal("前提不成立")
}
// 证明没有 ../openai.lua 被创建
if _, err := os.Stat(filepath.Join(dir, "..", "openai.lua")); !os.IsNotExist(err) {
t.Error("校验前就写出了逃逸文件 —— 写入顺序错误")
}
}
// TestLoadAdapterValidatesTheDiskPath 验证从磁盘加载(Start 的路径)对路径
// 做安全校验:目录外的文件被拒。(目录内同名内置的文件是合法覆盖,
// 不在此拒绝——见 TestLoadAdapterSourceAllowsBuiltinOverride。)
func TestLoadAdapterValidatesTheDiskPath(t *testing.T) {
dir := filepath.Join(t.TempDir(), "adapters")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
// 目录外的文件:应被拒
outside := filepath.Join(dir, "..", "evil.lua")
if err := os.WriteFile(outside, []byte(`local a={} a.name="evil" a.version="1" a.endpoint="/x" return a`), 0o644); err != nil {
t.Fatal(err)
}
vm := NewVM(dir)
if err := vm.LoadAdapter(outside); err == nil {
t.Error("目录外的磁盘文件应被拒绝")
}
}
// TestValidateAdapterPathInDirAccepted 验证目录内的合法文件通过。
func TestValidateAdapterPathInDirAccepted(t *testing.T) {
dir := filepath.Join(t.TempDir(), "adapters")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
ok := filepath.Join(dir, "my_new.lua")
if err := os.WriteFile(ok, []byte(`local a={} a.name="my_new" a.version="1" a.endpoint="/x" return a`), 0o644); err != nil {
t.Fatal(err)
}
name, err := ValidateAdapterPath(ok, dir, []string{"openai"})
if err != nil {
t.Fatalf("合法文件被误拒: %v", err)
}
if name != "my_new" {
t.Errorf("导出名 = %q, want my_new", name)
}
}
// TestErrorNamesTheConflict 验证错误信息点名冲突对象(用户要求"避免静默
// 错误"——错误必须可定位)。
func TestErrorNamesTheConflict(t *testing.T) {
err := ValidateAdapterName("openai", []string{"openai"})
if err == nil {
t.Fatal("应拒绝")
}
var reserved *ErrAdapterNameReserved
if !errors.As(err, &reserved) {
t.Fatalf("错误类型应为 ErrAdapterNameReserved, got %T", err)
}
if !strings.Contains(err.Error(), "openai") {
t.Errorf("错误信息应点名冲突对象: %v", err)
}
if !strings.Contains(err.Error(), "built-in") {
t.Errorf("错误信息应说明冲突性质: %v", err)
}
}

View File

@ -1,212 +0,0 @@
local adapter = {}
adapter.name = "agentrouter"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
adapter.headers = {}
-- AgentRouter 的阿里云 WAF 按三重维度校验:出口 IP(必须海外)、
-- TLS 指纹(JS/Go HTTP 层与 curl 不同)、浏览器指纹 header 集。
-- 纯 UA 已不够:WAF 拦 12 种 agent CLI UA 中的 10 种(全 405 HTML),
-- 只有同时带 Origin: qwen.ai + Referer + X-Requested-With 才放行,
-- 且同 IP 短时间连续 3+ 次请求会被速率封禁,靠 adapter 侧低频调用 +
-- llmsproxy 的 prefFailStep 冷却自然限制,来源无法控制速率。
local default_ua = "QwenCode/0.2.0 (linux; x64)"
-- AgentRouter fronts Claude models (claude-opus-4-8), and Claude upstreams
-- enforce Anthropic's tool id rule ^[a-zA-Z0-9_-]{1,64}$ by rejecting the WHOLE
-- request. Plain OpenAI does not, so an id minted by a permissive model (e.g.
-- "bash:0" from moonshotai/kimi-k3) is replayed here by the client and kills
-- every turn. See anthropic.lua / openai.lua for the same helper: Lua adapters
-- have no shared prelude, so each carries its own copy.
local TOOL_ID_MAX = 64
local function safe_tool_id(id)
if type(id) ~= "string" or id == "" then return id end
local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_")
if clean == id and #clean <= TOOL_ID_MAX then
return clean
end
local digest = string.sub(sha256_hex(id), 1, 8)
local keep = TOOL_ID_MAX - #digest - 1
if #clean > keep then clean = string.sub(clean, 1, keep) end
return clean .. "_" .. digest
end
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
req.extra_body = nil
if req.messages then
for _, msg in ipairs(req.messages) do
msg.reasoning_content = nil
if msg.tool_call_id ~= nil then
msg.tool_call_id = safe_tool_id(msg.tool_call_id)
end
if type(msg.tool_calls) == "table" then
for _, tc in ipairs(msg.tool_calls) do
if type(tc) == "table" and tc.id ~= nil then
tc.id = safe_tool_id(tc.id)
end
end
end
end
end
return json.encode(req)
end
-- 注入 Authorization + 官方客户端 User-Agent
function adapter.build_headers(meta)
local ua = default_ua
local meta_ua = (meta.source or {}).meta and (meta.source).meta.user_agent
if meta_ua and meta_ua ~= "" then
ua = meta_ua
end
return {
["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. meta.api_key,
["User-Agent"] = ua,
-- QwenCode client fingerprint: WAF rejects bare UA without these.
["Accept"] = "application/json, text/plain, */*",
["Accept-Language"] = "zh-CN,zh;q=0.9",
["Origin"] = "https://qwen.ai",
["Referer"] = "https://qwen.ai/",
["X-Requested-With"] = "XMLHttpRequest",
["Connection"] = "keep-alive",
}
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = safe_tool_id(tc.id),
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- usage-only terminal chunk (empty choices + usage): forward it so the
-- gateway emits exact token counts (and cache fields when present).
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
end
end
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil)
}
if finish then
unified.finish_reason = finish
end
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
if delta.tool_calls then
-- Sanitize outbound too: a dirty id must never enter a client session,
-- because the client replays it to every other source. Only the first
-- fragment of a streamed call carries an id; later argument fragments
-- have none and must stay id-less for index-based accumulation.
for _, tc in ipairs(delta.tool_calls) do
if type(tc) == "table" and tc.id ~= nil then
tc.id = safe_tool_id(tc.id)
end
end
unified.tool_calls = delta.tool_calls
end
if uses ~= nil then
unified.usage = uses
end
return json.encode(unified)
end
-- 错误收敛:前置 WAF 会返回整页 HTML(阿里云盾),JSON 时为 new-api 风格
function adapter.transform_error(status, body)
local low = string.lower(body or "")
if string.sub(low, 1, 9) == "<!doctype" or string.find(low, "<html", 1, true) then
return "blocked by AgentRouter WAF"
end
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
end
return adapter

View File

@ -5,6 +5,14 @@ adapter.endpoint = "/v1/messages"
adapter.headers = {
["anthropic-version"] = "2023-06-01",
}
-- INGRESS CLAIM (inbound dual of serialize_*): this adapter owns parsing of the
-- client-facing POST /v1/messages request body. The kernel keeps a built-in
-- Anthropic parser as its fallback (R5) — that is what an unconfigured
-- deployment uses — but once this adapter is loaded the kernel YIELDS, and the
-- deserialize_request hook below is the single place Anthropic's REQUEST shape
-- is understood. Iterating on that shape therefore means editing this file,
-- never the kernel.
adapter.ingress = "/v1/messages"
-- ============================================================
-- helpers
@ -132,6 +140,79 @@ local function append_user(msgs, blocks)
end
end
-- ============================================================
-- deserialize_request (Anthropic → unified) [INGRESS]
-- ============================================================
-- The inbound dual of serialize_response. The kernel hands over the raw client
-- body and receives the unified request; from there scheduling is identical to
-- /v1/chat/completions. Ported field-for-field from the kernel's former
-- built-in anthropicRequest.toUnified — which remains as the kernel fallback
-- for an unconfigured deployment.
function adapter.deserialize_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok or type(req) ~= "table" then
error("invalid json")
end
-- system accepts Anthropic's two spellings: a plain string, or an array of
-- { type = "text", text = ... } blocks. Only text blocks contribute.
local system = ""
local sys = req.system
if type(sys) == "string" then
system = sys
elseif type(sys) == "table" then
for _, b in ipairs(sys) do
if type(b) == "table" and b.type == "text" and type(b.text) == "string" then
system = system .. b.text
end
end
end
local msgs = {}
local in_msgs = req.messages
if type(in_msgs) ~= "table" or #in_msgs == 0 then
error("messages is required")
end
for i, m in ipairs(in_msgs) do
if type(m) ~= "table" or type(m.role) ~= "string" or m.role == "" then
error("messages[" .. (i - 1) .. "].role is required")
end
-- Content is carried VERBATIM: a string stays a string, a block array
-- stays an array. The unified format keeps multimodal/tool blocks
-- untouched because only an adapter knows which block types the upstream
-- accepts. An absent/null content becomes the empty string.
local content = m.content
if content == nil or content == "" then
content = ""
end
table.insert(msgs, { role = m.role, content = content })
end
-- Anthropic's default is thinking OFF; emit that explicitly so the unified
-- request carries the client's intent rather than leaving it absent.
local thinking = req.thinking
if thinking == nil then
thinking = { enabled = false }
end
local out = {
model = req.model or "",
messages = msgs,
system = system,
max_tokens = req.max_tokens or 0,
stream = req.stream == true,
temperature = req.temperature,
top_p = req.top_p,
top_k = req.top_k or 0,
stop_sequences = req.stop_sequences,
tools = req.tools,
tool_choice = req.tool_choice,
thinking = thinking,
extra_body = {},
}
return json.encode(out)
end
-- ============================================================
-- transform_request (OpenAI → Anthropic)
-- ============================================================
@ -254,12 +335,24 @@ function adapter.transform_request(raw_body)
end
end
-- ── thinking (opt-in via extra_body) ──────────────────────
-- Default: OFF. Client sends extra_body.thinking to enable.
-- Example: {"extra_body": {"thinking": {"type": "enabled", "budget_tokens": 4096}}}
if type(req.extra_body) == "table" and type(req.extra_body.thinking) == "table" then
-- ── thinking ─────────────────────────────────────────────
-- Anthropic 的原生字段就是 {type, budget_tokens},所以中立语义几乎直译。
--
-- 中立格式(v2):{ enabled = true/false, budget_tokens = N }
-- enabled=false → 不传该字段(Anthropic 默认关闭思考,传 type=disabled
-- 是非法的,这也是过去只能「不传」的原因)
-- enabled=true → { type="enabled", budget_tokens=N 或默认 }
--
-- 仍接受 extra_body.thinking 的原生写法:已有部署的配置不因此失效。
local th = req.thinking
if type(th) == "table" and th.enabled ~= false then
local budget = th.budget_tokens
if budget == nil or budget <= 0 then budget = 4096 end -- Anthropic 必填
anthropic_req.thinking = { type = "enabled", budget_tokens = budget }
elseif type(req.extra_body) == "table" and type(req.extra_body.thinking) == "table" then
anthropic_req.thinking = req.extra_body.thinking
end
req.thinking = nil
-- ── system ───────────────────────────────────────────────
if system ~= "" then
@ -444,4 +537,124 @@ function adapter.transform_error(status, body)
return nil
end
-- ============================================================
-- 对外序列化(统一格式 → Anthropic 形状)
-- ============================================================
--
-- 内核只持统一格式;对外发什么形状由适配器决定(Phase 2)。
-- 这几个钩子让以 anthropic 适配器为服务端的源,直接对 Anthropic 协议客户端
-- 提供 /v1/messages 语义,而内核里不再有任何 Anthropic 字段名。
--
-- 与入向的 transform_* 互为逆运算:那里的映射(end_turn→stop)在这里反着
-- 做回去(stop→end_turn),且默认 stop_sequence 是最保守的选择——它语义上
-- 最接近"正常结束"。
-- 统一 finish_reason → Anthropic stop_reason
local function to_stop_reason(finish)
if finish == "tool_calls" then return "tool_use" end
if finish == "length" then return "max_tokens" end
if finish == "content_filter" then return "refusal" end
-- stop / 空 / 未知 → end_turn(Anthropic 的"正常说完")
return "end_turn"
end
-- usage 数字化:内部 TokenUsage 的字段名与 Anthropic 的 input_tokens/output_tokens
-- 不同名,这里做最后一次翻译。
local function to_anthropic_usage(u)
if type(u) ~= "table" then
return { input_tokens = 0, output_tokens = 0 }
end
return {
input_tokens = u.prompt or 0,
output_tokens = u.completion or 0,
}
end
-- 非流式响应:统一结果 → Anthropic Messages 响应对象
function adapter.serialize_response(payload)
local p = json.decode(payload)
local blocks = {}
-- thinking 放最前,与 Anthropic 自己的块顺序一致
if p.reasoning ~= nil and p.reasoning ~= "" then
blocks[#blocks + 1] = { type = "thinking", thinking = p.reasoning }
end
blocks[#blocks + 1] = { type = "text", text = p.content or "" }
if type(p.tool_calls) == "table" and #p.tool_calls > 0 then
for _, tc in ipairs(p.tool_calls) do
blocks[#blocks + 1] = {
type = "tool_use",
id = tc.id or "",
name = tc.name or "",
input = tc.arguments or {},
}
end
end
return json.encode({
id = p.id or "",
type = "message",
role = "assistant",
model = p.model or "",
content = blocks,
stop_reason = to_stop_reason(p.finish_reason),
stop_sequence = nil,
usage = to_anthropic_usage(p.token_usage),
})
end
-- 角色前导:Anthropic 的流以 message_start 开头(不是 role delta)
function adapter.serialize_open_preamble(payload)
local p = json.decode(payload)
return json.encode({
type = "message_start",
message = {
id = p.id or "",
type = "message",
role = "assistant",
model = p.model or "",
content = {},
stop_reason = nil,
stop_sequence = nil,
usage = { input_tokens = 0, output_tokens = 0 },
},
})
end
-- 流式增量:统一 chunk → Anthropic content_block_delta
--
-- 真实 Anthropic 流需要 content_block_start(开块)→ delta → stop(关块)
-- 的三段式。这里用与多数 Anthropic 客户端兼容的简化形式:首个文本增量
-- 带 index=0 的 delta,后续同 index;关闭由 serialize_close 负责。
function adapter.serialize_chunk(payload)
local p = json.decode(payload)
if p.done then
-- 终止由 serialize_close 统一发,这里不重复
return ""
end
if p.reasoning ~= nil and p.reasoning ~= "" then
return json.encode({
type = "content_block_delta",
index = 0,
delta = { type = "thinking_delta", thinking = p.reasoning },
})
end
return json.encode({
type = "content_block_delta",
index = 0,
delta = { type = "text_delta", text = p.content or "" },
})
end
-- 收尾:message_delta(stop_reason + usage)→ message_stop
function adapter.serialize_close(payload)
local p = json.decode(payload)
return json.encode({
type = "message_delta",
delta = {
stop_reason = to_stop_reason(p.finish_reason),
stop_sequence = nil,
},
usage = { output_tokens = 0 },
}) .. "\n" .. json.encode({ type = "message_stop" })
end
return adapter

View File

@ -15,11 +15,18 @@ function adapter.transform_request(raw_body)
req.model = "deepseek-v4-flash"
end
req.stream = req.stream or false
if req.disable_thinking then
-- 中立的 thinking 语义 → DeepSeek 自己的协议写法。
-- 客户端说「关掉思考」时 thinking.enabled == false;上游的表达方式是
-- extra_body.thinking.type = "disabled"。v2 之前这里读的是内核私有字段
-- req.disable_thinking,而那个字段曾被内核泄漏进本函数收到的 body。
if type(req.thinking) == "table" and req.thinking.enabled == false then
req.extra_body = req.extra_body or {}
req.extra_body.thinking = { type = "disabled" }
elseif type(req.thinking) == "table" and req.thinking.budget_tokens ~= nil then
req.extra_body = req.extra_body or {}
req.extra_body.thinking = { type = "enabled", budget_tokens = req.thinking.budget_tokens }
end
req.disable_thinking = nil
req.thinking = nil
-- V4 thinking 模式要求:带 tool_calls 的 assistant 消息必须回传 reasoning_content。
-- OpenAI 兼容客户端不会发该字段,补空串即可通过校验。

View File

@ -18,6 +18,10 @@ adapter.version = "2.0.0"
-- can never work. A source that overrides `endpoint:` bypasses the template
-- entirely and must then spell the whole path itself.
adapter.endpoint = "/v1beta/models/{model}:generateContent"
-- 流式走同一路径的不同动词。这个事实以前写在 Go 内核里
-- (provider.ChatURL 对 ":generateContent" 做字符串替换),属于把厂商协议
-- 知识放进调度器;现在由适配器自己声明,内核只比较两个不透明字符串。
adapter.endpoint_stream = "/v1beta/models/{model}:streamGenerateContent"
adapter.headers = {}
-- Gemini API: POST /v1/models/{model}:generateContent

View File

@ -14,7 +14,13 @@ function adapter.transform_request(raw_body)
req.temperature = req.temperature or 0.7
req.max_tokens = req.max_tokens or 4096
req.stream = req.stream or false
req.disable_thinking = nil
-- The gateway's private control fields are GONE from the body (v2): the
-- neutral `thinking` field carries that intent and each adapter maps it in
-- its own protocol terms. These upstreams have no thinking switch at all,
-- so the field is simply dropped here — which is what the nil-ing below
-- used to do, except the field being dropped is no longer the kernel's
-- private one.
req.thinking = nil
req.extra_body = nil
return json.encode(req)
end

View File

@ -13,7 +13,13 @@ function adapter.transform_request(raw_body)
req.temperature = req.temperature or 0.7
req.max_tokens = req.max_tokens or 4096
req.stream = req.stream or false
req.disable_thinking = nil
-- The gateway's private control fields are GONE from the body (v2): the
-- neutral `thinking` field carries that intent and each adapter maps it in
-- its own protocol terms. These upstreams have no thinking switch at all,
-- so the field is simply dropped here — which is what the nil-ing below
-- used to do, except the field being dropped is no longer the kernel's
-- private one.
req.thinking = nil
req.extra_body = nil
return json.encode(req)
end

View File

@ -19,7 +19,13 @@ function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.model = req.model or "kimi-k2"
req.disable_thinking = nil
-- The gateway's private control fields are GONE from the body (v2): the
-- neutral `thinking` field carries that intent and each adapter maps it in
-- its own protocol terms. These upstreams have no thinking switch at all,
-- so the field is simply dropped here — which is what the nil-ing below
-- used to do, except the field being dropped is no longer the kernel's
-- private one.
req.thinking = nil
req.extra_body = nil
return json.encode(req)
end

View File

@ -13,7 +13,13 @@ function adapter.transform_request(raw_body)
req.temperature = req.temperature or 0.7
req.max_tokens = req.max_tokens or 4096
req.stream = req.stream or false
req.disable_thinking = nil
-- The gateway's private control fields are GONE from the body (v2): the
-- neutral `thinking` field carries that intent and each adapter maps it in
-- its own protocol terms. These upstreams have no thinking switch at all,
-- so the field is simply dropped here — which is what the nil-ing below
-- used to do, except the field being dropped is no longer the kernel's
-- private one.
req.thinking = nil
req.extra_body = nil
return json.encode(req)
end

View File

@ -39,7 +39,13 @@ end
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
-- The gateway's private control fields are GONE from the body (v2): the
-- neutral `thinking` field carries that intent and each adapter maps it in
-- its own protocol terms. These upstreams have no thinking switch at all,
-- so the field is simply dropped here — which is what the nil-ing below
-- used to do, except the field being dropped is no longer the kernel's
-- private one.
req.thinking = nil
req.extra_body = nil
if req.messages then
for _, msg in ipairs(req.messages) do

View File

@ -1,243 +0,0 @@
local adapter = {}
adapter.name = "opencode"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
-- opencode.ai zen 网关按客户端指纹(UA + x-opencode-* 头)路由请求池:
-- 缺少 x-opencode-client/session/request/project 身份头的请求会被判为匿名
-- 客户端,x-preview-f-free 等模型在带 tools 时上游直接失败——流式返回单
-- chunk "finish_reason":"network_error" 空 content,非流式返回 503
-- "Endpoint is unavailable",表现为"空回复"。因此除 UA 外必须附带
-- x-opencode-* 身份头;session/request 每请求派生唯一值(见 build_headers)。
adapter.headers = {
["User-Agent"] = "opencode/1.18.21 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
}
-- 每请求生成身份头。沙箱无 os/math,用 meta.timestamp + 请求体哈希派生:
-- 同秒内重复请求 id 相同可接受(zen 只校验存在性,不校验格式)。
local function rand_id(prefix, seed)
return prefix .. string.sub(sha256_hex(seed), 1, 24)
end
function adapter.build_headers(meta)
local ts = tostring(meta.timestamp or "")
return {
["User-Agent"] = adapter.headers["User-Agent"],
["x-opencode-client"] = "cli",
-- project 固定:同网关实例共享一个工作区身份
["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. (meta.source and meta.source.name or "")), 1, 32),
["x-opencode-session"] = rand_id("ses_", "session|" .. ts),
["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")),
}
end
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
-- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息
-- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游
-- "unknown variant `image_url`, expected `text`");带工具调用的必须保留,
-- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。
-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder:
-- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游
-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。
local ROLE_WHITELIST = {
system = true,
user = true,
assistant = true,
tool = true,
latest_reminder = true,
}
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
req.extra_body = nil
-- stream_options is only valid alongside stream:true. Sending it on a
-- non-streaming request is rejected by stricter upstreams (OpenCode Go:
-- "stream_options should be set along with stream"), which failed every
-- non-stream call. Only attach it when the request actually streams.
if req.stream then
if type(req.stream_options) ~= "table" then req.stream_options = {} end
req.stream_options.include_usage = true
end
if req.messages then
local kept = {}
for _, msg in ipairs(req.messages) do
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
msg.role = "system"
end
msg.reasoning_content = nil
local drop = false
if type(msg.content) == "table" then
local parts = {}
for _, part in ipairs(msg.content) do
if type(part) == "table" and part.type ~= nil and part.type ~= "text" then
-- multimodal part not supported by zen
else
table.insert(parts, part)
end
end
if #parts == 0 then
-- Content collapsed to nothing after stripping unsupported
-- parts. A message that still carries a tool call must
-- NEVER be dropped: the very next message is its tool
-- result, and dropping the call orphans that result. The
-- model then sees a result for a call it never made and
-- re-issues the same tool call on every turn (observed as
-- an infinite "repeated tool call" loop).
--
-- This is the common shape for agent clients: an assistant
-- turn whose content is only [thinking, toolCall] serialises
-- to content:[] with tool_calls — exactly the case that used
-- to vanish here. Emit "" instead, which zen accepts.
if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0)
or msg.tool_call_id ~= nil then
msg.content = ""
else
drop = true
end
else
msg.content = parts
end
end
if not drop then
table.insert(kept, msg)
end
end
req.messages = kept
end
return json.encode(req)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
local reasoning = ch.message.reasoning_content or ch.message.reasoning
if reasoning then
unified.reasoning_content = reasoning
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Preserve it; the gateway emits it as the
-- terminal usage chunk. Note: keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
end
end
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
-- usage-only chunk is not a content/finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil)
}
if finish then
unified.finish_reason = finish
end
if uses ~= nil then
unified.usage = uses
end
-- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content)
local reasoning = delta.reasoning_content or delta.reasoning
if reasoning then
unified.reasoning_content = reasoning
end
if delta.tool_calls then
-- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
end
-- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}};
-- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) ~= "table" then return nil end
if e.type == "FreeUsageLimitError" then
return "zen free pool quota exhausted"
end
return e.message
end
return adapter

View File

@ -1,354 +0,0 @@
local adapter = {}
adapter.name = "opencodego"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
-- OpenCode Go(包月订阅端点,https://opencode.ai/zen/go/v1)
--
-- 与 OpenCode Zen(opencodezen.lua,https://opencode.ai/zen/v1)是两个不同的
-- 服务,行为要求并不相同,因此各有专用适配器:
--
-- * 本适配器(Go)服务包月订阅池。上游**强制要求**
-- `x-opencode-session` 头,缺失直接 400 "MissingSessionID"。
-- * Go 的 thinking 模型(deepseek-v4.1-flash 等)**必须回传** assistant 轮的
-- `reasoning_content`,否则 400:
-- The `reasoning_content` in the thinking mode must be passed back to the API.
-- 这是本适配器与 Zen 适配器最关键的差异 —— Zen 会剥掉它,Go 必须原样保留。
-- * Go 上游对协议更严格:非流式请求带 `stream_options` 会被拒
-- ("stream_options should be set along with stream")。
--
-- 同样需要 opencode 客户端指纹(UA + x-opencode-*)才能被正确识别与路由。
adapter.headers = {
["User-Agent"] = "opencode/1.18.21 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
}
-- 每请求生成身份头。沙箱无 os/math,用 meta.timestamp + 请求体哈希派生:
-- 同秒内重复请求 id 相同可接受(zen 只校验存在性,不校验格式)。
local function rand_id(prefix, seed)
return prefix .. string.sub(sha256_hex(seed), 1, 24)
end
-- 会话指纹:取历史里**第一条 user 消息**。
--
-- 为什么不直接用客户端的会话 id:实测抓包(tcpdump 抓 127.0.0.1:8081 的真实
-- agent 请求)确认通用 OpenAI 客户端**根本不发**任何会话标识 —— body 里没有
-- user / session_id / conversation_id / metadata,请求头也只有 X-Stainless-*
-- (OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套 x-opencode-session
-- 是它自己的概念,通用客户端无从转发。
--
-- 退而求其次但要够用:历史被逐轮重放,**第一条 user 消息在整个会话里恒定**,
-- 用它做指纹即可得到"每会话一个 session",而不是"每源一个 session"。
-- 拿不到时(无 user 消息)返回空串,调用方回退到按源稳定。
local function conversation_fingerprint(body)
if type(body) ~= "string" or body == "" then return "" end
local ok, req = pcall(json.decode, body)
if not ok or type(req) ~= "table" then return "" end
for _, m in ipairs(req.messages or {}) do
if type(m) == "table" and m.role == "user" then
local c = m.content
if type(c) == "string" then
if c ~= "" then return c end
elseif type(c) == "table" then
for _, part in ipairs(c) do
if type(part) == "table" and part.type == "text" and part.text then
return part.text
end
end
end
return ""
end
end
return ""
end
-- session_seed 决定上游会话号(x-opencode-session 的种子)。
--
-- 优先级:
-- 1) 客户端自带的会话标识(meta.client_session)——pi 等在开启
-- compat.sendSessionAffinityHeaders 后会发 x-session-affinity,
-- 这是平台真实的会话 id,整个会话恒定且天然按会话隔离。
-- 2) 退路:历史里**第一条 user 消息**做会话指纹。
-- 3) 再退:只按源固定(连 user 消息都没有时)。
--
-- 为什么需要 2/3:通用 OpenAI 客户端默认**根本不发**会话标识 —— 实测抓包
-- (tcpdump 抓 127.0.0.1:8081 真实 agent 请求)确认 body 里没有
-- user / session_id / conversation_id / metadata,请求头也只有
-- X-Stainless-*(OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套
-- x-opencode-session 是它自己的概念,通用客户端无从转发。
--
-- 为什么必须稳定:上游前缀缓存是**会话级**的。会话号每请求一变,
-- 缓存永不命中(实测:固定会号第 2 次命中 5888,每请求换会号则恒为 0)。
local function session_seed(meta)
local src = (meta.source and meta.source.name) or ""
local base = "session|llmsproxy|" .. src
local cs = meta.client_session
if type(cs) == "string" and cs ~= "" then
return base .. "|client|" .. cs
end
return base .. "|" .. conversation_fingerprint(meta.body)
end
function adapter.build_headers(meta)
local ts = tostring(meta.timestamp or "")
local src = (meta.source and meta.source.name) or ""
return {
["User-Agent"] = adapter.headers["User-Agent"],
["x-opencode-client"] = "cli",
-- project 固定:同网关实例共享一个工作区身份
["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. src), 1, 32),
-- session 必须**按源稳定**,不能每请求换:上游的前缀缓存在同一 session
-- 内才复用。实测(同一段 6032 token 提示词):
-- 固定 session -> 第 2 次命中 5888/6032,cached_tokens=5888
-- 每请求换 session -> 永远 0 命中
-- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效,
-- 上游也无法做会话亲和路由。
["x-opencode-session"] = rand_id("ses_", session_seed(meta)),
-- request id 仍每请求唯一(它只是请求标识,不参与缓存键)
["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")),
}
end
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
-- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息
-- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游
-- "unknown variant `image_url`, expected `text`");带工具调用的必须保留,
-- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。
-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder:
-- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游
-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。
local ROLE_WHITELIST = {
system = true,
user = true,
assistant = true,
tool = true,
latest_reminder = true,
}
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
req.extra_body = nil
-- stream_options is only valid alongside stream:true; sending it on a
-- non-streaming request is rejected by strict upstreams.
if req.stream then
if type(req.stream_options) ~= "table" then req.stream_options = {} end
req.stream_options.include_usage = true
end
if req.messages then
local kept = {}
for _, msg in ipairs(req.messages) do
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
msg.role = "system"
end
-- Go 的 thinking 模式**必须**回传 reasoning_content:剥掉它会让
-- 上游直接 400("The `reasoning_content` in the thinking mode must
-- be passed back to the API"),agent 的每一轮都会失败。
-- 注意:与 opencodezen.lua 的行为**相反**,不要在这里剥。
--
-- 但绝大多数客户端(pi 等)根本不保存也不回传 reasoning,只保留
-- 工具调用本身。上游只在“带 tool_calls 的助手轮”上校验这个字段,
-- 实测**空串即可通过校验**,所以缺省时补空串:既满足上游的
-- 一致性要求,又不伪造任何推理内容(用户看到的 reasoning 仍然是
-- 上游本轮真实返回的)。
if msg.role == "assistant"
and type(msg.tool_calls) == "table" and #msg.tool_calls > 0
and msg.reasoning_content == nil then
msg.reasoning_content = ""
end
local drop = false
if type(msg.content) == "table" then
local parts = {}
for _, part in ipairs(msg.content) do
if type(part) == "table" and part.type ~= nil and part.type ~= "text" then
-- multimodal part not supported by zen
else
table.insert(parts, part)
end
end
if #parts == 0 then
-- Content collapsed to nothing after stripping unsupported
-- parts. A message that still carries a tool call must
-- NEVER be dropped: the very next message is its tool
-- result, and dropping the call orphans that result. The
-- model then sees a result for a call it never made and
-- re-issues the same tool call on every turn (observed as
-- an infinite "repeated tool call" loop).
if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0)
or msg.tool_call_id ~= nil then
msg.content = ""
else
drop = true
end
else
msg.content = parts
end
end
if not drop then
table.insert(kept, msg)
end
end
req.messages = kept
end
return json.encode(req)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
-- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断
-- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。
local ctd = resp.usage.completion_tokens_details
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
end
end
-- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。
-- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。
-- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。
if resp.cost ~= nil then
unified.cost = tostring(resp.cost)
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
local reasoning = ch.message.reasoning_content or ch.message.reasoning
if reasoning then
unified.reasoning_content = reasoning
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Preserve it; the gateway emits it as the
-- terminal usage chunk. Note: keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
-- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif:
-- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让
-- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致
-- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
end
if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
if uses.prompt_tokens_details == nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
end
end
local ctd = chunk.usage.completion_tokens_details
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
end
end
if not chunk.choices or #chunk.choices == 0 then
-- 空 choices 的帧既不是内容也不是结束信号。上游发两种:
-- {"choices":[],"usage":{...}} —— 终帧用量
-- {"choices":[],"cost":"0"} —— 单独的费用帧
-- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。
local out = { done = false }
if uses ~= nil then out.usage = uses end
if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end
if uses == nil and chunk.cost == nil then return "" end
return json.encode(out)
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil)
}
if finish then
unified.finish_reason = finish
end
if uses ~= nil then
unified.usage = uses
end
-- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content)
local reasoning = delta.reasoning_content or delta.reasoning
if reasoning then
unified.reasoning_content = reasoning
end
if delta.tool_calls then
-- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
end
-- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}};
-- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) ~= "table" then return nil end
if e.type == "FreeUsageLimitError" then
return "zen free pool quota exhausted"
end
return e.message
end
return adapter

View File

@ -1,342 +0,0 @@
local adapter = {}
adapter.name = "opencodezen"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
-- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1)
--
-- 与 OpenCode Go(opencodego.lua,https://opencode.ai/zen/go/v1)是两个不同的
-- 服务,行为要求并不相同,因此各有专用适配器:
--
-- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端
-- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in
-- OpenCode"。Zen 的免费池以非 thinking 模型为主,历史上回传
-- reasoning_content 会引出上游报错,故这里剥掉(Go 侧相反,见 opencodego.lua)。
-- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content,
-- 否则 thinking 模式直接 400。
--
-- 缺 x-opencode-client/session/request/project 身份头会被判为匿名客户端,
-- 部分模型在带 tools 时上游直接失败(流式返回单 chunk
-- "finish_reason":"network_error" 空 content,非流式 503
-- "Endpoint is unavailable",表现为"空回复")。
adapter.headers = {
["User-Agent"] = "opencode/1.18.21 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
}
-- 每请求生成身份头。沙箱无 os/math,用 meta.timestamp + 请求体哈希派生:
-- 同秒内重复请求 id 相同可接受(zen 只校验存在性,不校验格式)。
local function rand_id(prefix, seed)
return prefix .. string.sub(sha256_hex(seed), 1, 24)
end
-- 会话指纹:取历史里**第一条 user 消息**。
--
-- 为什么不直接用客户端的会话 id:实测抓包(tcpdump 抓 127.0.0.1:8081 的真实
-- agent 请求)确认通用 OpenAI 客户端**根本不发**任何会话标识 —— body 里没有
-- user / session_id / conversation_id / metadata,请求头也只有 X-Stainless-*
-- (OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套 x-opencode-session
-- 是它自己的概念,通用客户端无从转发。
--
-- 退而求其次但要够用:历史被逐轮重放,**第一条 user 消息在整个会话里恒定**,
-- 用它做指纹即可得到"每会话一个 session",而不是"每源一个 session"。
-- 拿不到时(无 user 消息)返回空串,调用方回退到按源稳定。
local function conversation_fingerprint(body)
if type(body) ~= "string" or body == "" then return "" end
local ok, req = pcall(json.decode, body)
if not ok or type(req) ~= "table" then return "" end
for _, m in ipairs(req.messages or {}) do
if type(m) == "table" and m.role == "user" then
local c = m.content
if type(c) == "string" then
if c ~= "" then return c end
elseif type(c) == "table" then
for _, part in ipairs(c) do
if type(part) == "table" and part.type == "text" and part.text then
return part.text
end
end
end
return ""
end
end
return ""
end
-- session_seed 决定上游会话号(x-opencode-session 的种子)。
--
-- 优先级:
-- 1) 客户端自带的会话标识(meta.client_session)——pi 等在开启
-- compat.sendSessionAffinityHeaders 后会发 x-session-affinity,
-- 这是平台真实的会话 id,整个会话恒定且天然按会话隔离。
-- 2) 退路:历史里**第一条 user 消息**做会话指纹。
-- 3) 再退:只按源固定(连 user 消息都没有时)。
--
-- 为什么需要 2/3:通用 OpenAI 客户端默认**根本不发**会话标识 —— 实测抓包
-- (tcpdump 抓 127.0.0.1:8081 真实 agent 请求)确认 body 里没有
-- user / session_id / conversation_id / metadata,请求头也只有
-- X-Stainless-*(OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套
-- x-opencode-session 是它自己的概念,通用客户端无从转发。
--
-- 为什么必须稳定:上游前缀缓存是**会话级**的。会话号每请求一变,
-- 缓存永不命中(实测:固定会号第 2 次命中 5888,每请求换会号则恒为 0)。
local function session_seed(meta)
local src = (meta.source and meta.source.name) or ""
local base = "session|llmsproxy|" .. src
local cs = meta.client_session
if type(cs) == "string" and cs ~= "" then
return base .. "|client|" .. cs
end
return base .. "|" .. conversation_fingerprint(meta.body)
end
function adapter.build_headers(meta)
local ts = tostring(meta.timestamp or "")
local src = (meta.source and meta.source.name) or ""
return {
["User-Agent"] = adapter.headers["User-Agent"],
["x-opencode-client"] = "cli",
-- project 固定:同网关实例共享一个工作区身份
["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. src), 1, 32),
-- session 必须**按源稳定**,不能每请求换:上游的前缀缓存在同一 session
-- 内才复用。实测(同一段 6032 token 提示词):
-- 固定 session -> 第 2 次命中 5888/6032,cached_tokens=5888
-- 每请求换 session -> 永远 0 命中
-- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效,
-- 上游也无法做会话亲和路由。
["x-opencode-session"] = rand_id("ses_", session_seed(meta)),
-- request id 仍每请求唯一(它只是请求标识,不参与缓存键)
["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")),
}
end
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
-- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息
-- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游
-- "unknown variant `image_url`, expected `text`");带工具调用的必须保留,
-- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。
-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder:
-- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游
-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。
local ROLE_WHITELIST = {
system = true,
user = true,
assistant = true,
tool = true,
latest_reminder = true,
}
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
req.extra_body = nil
-- stream_options is only valid alongside stream:true; sending it on a
-- non-streaming request is rejected by strict upstreams.
if req.stream then
if type(req.stream_options) ~= "table" then req.stream_options = {} end
req.stream_options.include_usage = true
end
if req.messages then
local kept = {}
for _, msg in ipairs(req.messages) do
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
msg.role = "system"
end
-- Zen 免费池不接受回传 reasoning_content(Go 侧相反)
msg.reasoning_content = nil
local drop = false
if type(msg.content) == "table" then
local parts = {}
for _, part in ipairs(msg.content) do
if type(part) == "table" and part.type ~= nil and part.type ~= "text" then
-- multimodal part not supported by zen
else
table.insert(parts, part)
end
end
if #parts == 0 then
-- Content collapsed to nothing after stripping unsupported
-- parts. A message that still carries a tool call must
-- NEVER be dropped: the very next message is its tool
-- result, and dropping the call orphans that result. The
-- model then sees a result for a call it never made and
-- re-issues the same tool call on every turn (observed as
-- an infinite "repeated tool call" loop).
if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0)
or msg.tool_call_id ~= nil then
msg.content = ""
else
drop = true
end
else
msg.content = parts
end
end
if not drop then
table.insert(kept, msg)
end
end
req.messages = kept
end
return json.encode(req)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
-- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断
-- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。
local ctd = resp.usage.completion_tokens_details
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
end
end
-- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。
-- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。
-- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。
if resp.cost ~= nil then
unified.cost = tostring(resp.cost)
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
local reasoning = ch.message.reasoning_content or ch.message.reasoning
if reasoning then
unified.reasoning_content = reasoning
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Preserve it; the gateway emits it as the
-- terminal usage chunk. Note: keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
-- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif:
-- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让
-- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致
-- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
end
if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
if uses.prompt_tokens_details == nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
end
end
local ctd = chunk.usage.completion_tokens_details
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
end
end
if not chunk.choices or #chunk.choices == 0 then
-- 空 choices 的帧既不是内容也不是结束信号。上游发两种:
-- {"choices":[],"usage":{...}} —— 终帧用量
-- {"choices":[],"cost":"0"} —— 单独的费用帧
-- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。
local out = { done = false }
if uses ~= nil then out.usage = uses end
if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end
if uses == nil and chunk.cost == nil then return "" end
return json.encode(out)
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil)
}
if finish then
unified.finish_reason = finish
end
if uses ~= nil then
unified.usage = uses
end
-- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content)
local reasoning = delta.reasoning_content or delta.reasoning
if reasoning then
unified.reasoning_content = reasoning
end
if delta.tool_calls then
-- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
end
-- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}};
-- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) ~= "table" then return nil end
if e.type == "FreeUsageLimitError" then
return "zen free pool quota exhausted"
end
return e.message
end
return adapter

View File

@ -0,0 +1,235 @@
-- tokenplan / discovery-api (intern-ai.org.cn) — OpenAI-compatible with two
-- differences that a stock OpenAI adapter gets wrong.
--
-- 1. REASONING IS A SEPARATE FIELD, AND IT IS MOST OF THE OUTPUT.
-- Every response carries `reasoning_content` (non-stream) or
-- `delta.reasoning_content` (stream), disjoint from `content`. Measured on
-- qwen3.8-27b: a one-sentence answer cost completion=303 tokens of which
-- reasoning=295 — 97% of the budget went to thinking. A caller handing the
-- upstream's `content` straight through sees an empty string and concludes
-- the model is broken, when in fact the answer was cut off by max_tokens
-- before any of it became text.
--
-- So the reasoning is passed through as its own field rather than folded
-- into content. Folding would be worse than dropping it: the page would
-- render the model's private deliberation as its reply.
--
-- 2. `finish_reason: "length"` WITH `content: null` IS NORMAL HERE, NOT AN ERROR.
-- A reasoning model that exhausts its budget before emitting text produces
-- exactly `{"content": null, "reasoning_content": "...", "finish_reason":
-- "length"}`. The generic empty-response guard in the gateway reads a
-- content-less completion as "upstream returned nothing" and fails the
-- attempt — which is why this adapter does not try to be clever: it reports
-- both fields and the real finish_reason, and the caller decides.
--
-- 3. MODEL NAMES ARE REWRITTEN BY THE SERVER.
-- Requesting `qwen3.8-27b` answers with `model: "qwen3.8-27b-fp8"`. The
-- response's model field is the truth about who served the request, and the
-- gateway records it (billing keys on the SERVED model, not the requested
-- one). Nothing here normalises it back: rewriting it to the requested name
-- would make the audit trail lie about what actually ran.
--
-- Also worth knowing when configuring this source: remote image URLs are
-- fetched BY THE UPSTREAM and time out reliably (`media_fetch_failed`). Images
-- must be sent inline as data: URLs. Measured: base64 worked, a Wikimedia URL
-- did not.
local adapter = {}
adapter.name = "qwen38"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
adapter.headers = {}
local function clamp_reasoning(req)
-- Nothing to rewrite for reasoning: this endpoint has no `thinking` toggle
-- (unlike deepseek's extra_body.thinking). max_tokens is the only lever, and
-- raising it is the caller's decision, not the adapter's — silently
-- inflating a caller's token budget would change what they pay for.
end
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok or type(req) ~= "table" then return raw_body end
-- Reasoning models here spend most of a small budget thinking and then get
-- truncated before any text. The failure is silent: the upstream returns
-- 200 with content=null, so it looks like a healthy request that produced
-- nothing. Warn rather than rewrite — see clamp_reasoning.
if type(req.max_tokens) == "number" and req.max_tokens > 0 and req.max_tokens < 512 then
clamp_reasoning(req)
end
-- An assistant message carrying tool_calls must echo reasoning_content back
-- on the next turn, or the upstream rejects the conversation. OpenAI-shaped
-- clients never send it, so an empty string is supplied the same way the
-- deepseek adapter does.
if type(req.messages) == "table" then
for _, msg in ipairs(req.messages) do
if type(msg) == "table" and msg.role == "assistant" then
if type(msg.tool_calls) == "table" and msg.tool_calls[1] ~= nil
and msg.reasoning_content == nil then
msg.reasoning_content = ""
end
end
end
end
return json.encode(req)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or type(resp) ~= "table" then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 },
}
if type(resp.usage) == "table" then
local u = resp.usage
unified.token_usage.prompt = u.prompt_tokens or 0
unified.token_usage.completion = u.completion_tokens or 0
unified.token_usage.total = u.total_tokens or 0
if type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens ~= nil then
unified.token_usage.prompt_tokens_details = { cached_tokens = u.prompt_tokens_details.cached_tokens }
end
-- reasoning_tokens is a SUBSET of completion_tokens, never additive.
--
-- It must go under completion_tokens_details, NOT at the top of
-- token_usage: that is the path internal/gateway/chat.go reads when it
-- fills the audit record's Reasoning field. Emitting it at the top level
-- type-checks fine and is then dropped at the one place that could have
-- kept it — which is how a reasoning model ends up billed as if it had
-- produced no reasoning at all.
--
-- (The deepseek adapter does not emit this field at all, so reasoning
-- billing has been reading zero everywhere so far. That is a separate
-- gap; this adapter does not depend on it being fixed.)
if type(u.completion_tokens_details) == "table"
and u.completion_tokens_details.reasoning_tokens ~= nil then
local d = unified.token_usage.completion_tokens_details
if type(d) ~= "table" then
d = {}
unified.token_usage.completion_tokens_details = d
end
d.reasoning_tokens = u.completion_tokens_details.reasoning_tokens
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
local m = ch.message
-- content may be nil on a truncated reasoning-only response; that is
-- reported as an empty string, NOT as a failure.
if type(m.content) == "string" then
unified.content = m.content
end
if type(m.reasoning_content) == "string" then
unified.reasoning_content = m.reasoning_content
end
if type(m.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(m.tool_calls) do
local fn = tc["function"] or {}
local args_ok, args = pcall(json.decode, fn.arguments or "{}")
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = fn.name,
arguments = args,
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok or type(chunk) ~= "table" then return "" end
-- Usage arrives on a chunk with empty choices (the terminal usage chunk).
-- Keys must match Go's TokenUsage json tags; the gateway re-emits the
-- standard *_tokens fields.
local uses = nil
if type(chunk.usage) == "table" then
local u = chunk.usage
uses = {
prompt = u.prompt_tokens or 0,
completion = u.completion_tokens or 0,
total = u.total_tokens or 0,
}
if type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = u.prompt_tokens_details.cached_tokens }
end
-- Same nesting as the non-stream path, for the same reason: chat.go reads
-- reasoning under completion_tokens_details and nothing else.
if type(u.completion_tokens_details) == "table"
and u.completion_tokens_details.reasoning_tokens ~= nil then
uses.completion_tokens_details = {
reasoning_tokens = u.completion_tokens_details.reasoning_tokens
}
end
end
if type(chunk.choices) ~= "table" or #chunk.choices == 0 then
if uses ~= nil then
-- A usage-only chunk is not a content or finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local ch = chunk.choices[1]
local delta = ch.delta or {}
local finish = (type(ch.finish_reason) == "string" and ch.finish_reason ~= "") and ch.finish_reason or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil),
}
if finish then
unified.finish_reason = finish
end
if uses ~= nil then
unified.usage = uses
end
-- The whole point of this adapter: reasoning is streamed as its own channel
-- and must stay separate. Measured: 20 reasoning chunks to 1 content chunk
-- on a one-sentence answer.
if type(delta.reasoning_content) == "string" then
unified.reasoning_content = delta.reasoning_content
end
if type(delta.tool_calls) == "table" then
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
end
-- Error envelope is standard OpenAI, but this endpoint also reports media
-- failures with a distinct code (media_fetch_failed) when a remote image URL
-- cannot be pulled. Both carry {error:{message,...}}; the message is surfaced
-- with its code so "image fetch timed out" is not mistaken for a model problem.
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) ~= "table" then return nil end
if type(e.message) ~= "string" then return nil end
if type(e.code) == "string" and e.code ~= "" and e.code ~= "invalid_api_key" then
return e.message .. " (" .. e.code .. ")"
end
return e.message
end
return adapter

View File

@ -12,7 +12,13 @@ adapter.headers = {}
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
-- The gateway's private control fields are GONE from the body (v2): the
-- neutral `thinking` field carries that intent and each adapter maps it in
-- its own protocol terms. These upstreams have no thinking switch at all,
-- so the field is simply dropped here — which is what the nil-ing below
-- used to do, except the field being dropped is no longer the kernel's
-- private one.
req.thinking = nil
req.extra_body = nil
if req.messages then
for _, msg in ipairs(req.messages) do

View File

@ -15,7 +15,13 @@ adapter.headers = {}
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
-- The gateway's private control fields are GONE from the body (v2): the
-- neutral `thinking` field carries that intent and each adapter maps it in
-- its own protocol terms. These upstreams have no thinking switch at all,
-- so the field is simply dropped here — which is what the nil-ing below
-- used to do, except the field being dropped is no longer the kernel's
-- private one.
req.thinking = nil
req.extra_body = nil
if req.messages then
for _, msg in ipairs(req.messages) do

View File

@ -2,12 +2,25 @@ package lua
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// pluginLoaded reports whether a plugin with this name is present, ignoring any
// LoadError: a plugin that is present but broken still counts as "loaded here",
// and the caller decides whether the error matters.
func pluginLoaded(ps *Plugins, name string) bool {
for _, p := range ps.List() {
if fmt.Sprint(p["name"]) == name {
return true
}
}
return false
}
// The billing plugin ships with the gateway, so its arithmetic is a contract:
// a wrong price silently produces wrong money. These tests drive it through the
// real hook path and check the NUMBERS, not merely that it loads.
@ -447,8 +460,16 @@ return plugin`)
}
// Order must be honoured so the sidebar is predictable.
if len(ui.Pages) != 4 {
t.Fatalf("expected all 4 pages to merge, got %d: %v", len(ui.Pages), got)
//
// The page COUNT is not asserted: it used to be 4, and it grew every time a
// bundled plugin added a page. A hard count here reads as "the merge is
// broken" whenever the product gains a feature, which trains the reader to
// bump a number instead of reading the failure. What matters is that all
// three pages this test contributed merged, and that they came out sorted.
for _, want := range []string{"third-a", "third-b", "other-page"} {
if !got[want] {
t.Errorf("page %s did not merge: %v", want, got)
}
}
for i := 1; i < len(ui.Pages); i++ {
if ui.Pages[i].Order < ui.Pages[i-1].Order {
@ -462,8 +483,15 @@ return plugin`)
// configuration, since seeding only happens on a fresh plugin dir.
func TestBillingPluginLoadedByDefault(t *testing.T) {
ps, pdir := billingVM(t)
if ps.Count() != 1 {
t.Fatalf("expected 1 bundled plugin, got %d", ps.Count())
// billing must be among the bundled plugins, not the only one. The count
// grows with every shipped plugin and asserting it makes this test a
// changelog detector.
if !pluginLoaded(ps, "billing") {
var names []string
for _, p := range ps.List() {
names = append(names, fmt.Sprint(p["name"]))
}
t.Fatalf("billing is not loaded; bundled plugins are %v", names)
}
if _, err := os.Stat(filepath.Join(pdir, "billing.lua")); err != nil {
t.Errorf("billing.lua was not written to the plugin dir: %v", err)
@ -966,3 +994,245 @@ func TestBillingCacheHitClampedInStats(t *testing.T) {
tot["cache_fresh_tokens"])
}
}
// TestUnpricedIsTrackedPerDay pins the day-keyed unpriced tally.
//
// The lifetime counters cannot answer the question an operator actually has —
// "is it STILL happening?" — because they keep the debt from before a rule was
// added. After pricing a mispriced source the lifetime number never moves, so a
// dashboard reading "unpriced: 6376" is indistinguishable from a system that is
// still billing nothing. Day buckets are what make a pricing fix observable: the
// offending day keeps its count and the next day starts at zero for it.
func TestUnpricedIsTrackedPerDay(t *testing.T) {
ps, _ := billingVM(t)
// Nothing is priced in the default config, so every request is unpriced.
// Two requests on two different UTC days.
for _, ms := range []int64{1790000000000, 1790100000000} {
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "mystery-model", "source": "nowhere", "key": "***aaaaaa",
"ok": true, "prompt_tokens": 10, "completion_tokens": 5,
"time": ms,
})
}
st := stateOf(t, ps)
byDay, ok := st["unpriced_by_day"].(map[string]interface{})
if !ok {
t.Fatalf("state has no unpriced_by_day map: %T", st["unpriced_by_day"])
}
if len(byDay) != 2 {
t.Fatalf("expected 2 day buckets, got %d: %v", len(byDay), byDay)
}
var sum float64
for day, bucket := range byDay {
b := bucket.(map[string]interface{})
sum += b["reqs"].(float64)
models := b["models"].(map[string]interface{})
if models["mystery-model"].(float64) != 1 {
t.Errorf("%s: model tally = %v, want 1", day, models["mystery-model"])
}
}
if sum != 2 {
t.Errorf("day buckets sum to %v, want 2 (the lifetime counter must not be double-counted)", sum)
}
// The lifetime counters still exist and still agree — this is additive, not
// a replacement.
if st["unpriced_reqs"].(float64) != 2 {
t.Errorf("unpriced_reqs = %v, want 2", st["unpriced_reqs"])
}
}
// TestReasoningBilledAtCompletionRateByDefault is the property that keeps the
// standard formula matching a real invoice.
//
// reasoning_tokens is a subset of completion_tokens and every provider measured
// (OpenAI, DeepSeek, Anthropic) charges it at the ordinary output rate. A rule
// with no reasoning_price must therefore produce EXACTLY the number the
// pre-reasoning formula produced — the field exists for a provider that prices
// thinking differently, not as a new charge.
func TestReasoningBilledAtCompletionRateByDefault(t *testing.T) {
ps, _ := billingVM(t)
if err := ps.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"currency": "USD",
"models": map[string]interface{}{
// USD PER TOKEN (compile.go divides the per-million figure by 1e6).
// prompt 0.15/M, completion 0.60/M. No reasoning_price anywhere.
"m": map[string]interface{}{"prompt": 0.15e-6, "completion": 0.60e-6},
},
},
}); err != nil {
t.Fatal(err)
}
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
"prompt_tokens": 1000, "completion_tokens": 1000,
"reasoning_tokens": 800, // all of it thinking; still billed as output
"time": 1750000000000,
})
st := stateOf(t, ps)
cost := st["total"].(map[string]interface{})["cost"].(float64)
approx(t, "reasoning at completion rate", cost, 0.00075)
}
// TestReasoningPriceOverridesOnlyWhenSet: with a rule that DOES price thinking
// differently, only the difference is applied. Charging the reasoning portion a
// second time in full is the obvious wrong implementation and it doubles the
// completion line.
func TestReasoningPriceOverridesOnlyWhenSet(t *testing.T) {
ps, _ := billingVM(t)
if err := ps.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"currency": "USD",
"models": map[string]interface{}{
"m": map[string]interface{}{
"prompt": 0.15e-6, "completion": 0.60e-6,
// Thinking costs 3x ordinary output: 1.80/M.
"reasoning_price": 1.80e-6,
},
},
},
}); err != nil {
t.Fatal(err)
}
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
"prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 800,
"time": 1750000000000,
})
st := stateOf(t, ps)
cost := st["total"].(map[string]interface{})["cost"].(float64)
// base = 1000*0.15e-6 + 1000*0.60e-6 = 0.00075
// + 800 * (1.80-0.60)e-6 = 0.00096 → 0.00171
approx(t, "reasoning repriced to 1.80/M", cost, 0.00171)
}
// TestReasoningClampedToCompletion: a provider claiming MORE reasoning than
// completion is malformed. Without a clamp the visible-output count goes
// negative and the delta exceeds the whole request, so the bill silently
// explodes on bad data instead of degrading.
func TestReasoningClampedToCompletion(t *testing.T) {
ps, _ := billingVM(t)
if err := ps.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"currency": "USD",
"models": map[string]interface{}{
"m": map[string]interface{}{
"prompt": 0.15e-6, "completion": 0.60e-6, "reasoning_price": 1.80e-6,
},
},
},
}); err != nil {
t.Fatal(err)
}
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
"prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 99999,
"time": 1750000000000,
})
st := stateOf(t, ps)
cost := st["total"].(map[string]interface{})["cost"].(float64)
// reasoning is clamped from 99999 down to completion (1000):
// base = 1000*0.15e-6 + 1000*0.60e-6 = 0.00075
// delta = 1000 * (1.80-0.60)e-6 = 0.00120
// total = 0.00195
// Without the clamp this would be 0.18 — 92x the whole request, and a
// number plausible enough to survive a glance at a dashboard.
approx(t, "reasoning clamped to completion", cost, 0.00195)
}
// TestLongContextStepIsMarginal: the threshold reprices only the tokens PAST
// it, at a marginal rate.
//
// A flat prompt price under-bills exactly the traffic an agent workload
// produces — on this deployment 88% of requests exceed 128k with a median of
// 247k — and it under-bills silently, because the number stays plausible. The
// step must apply to the excess only; charging the WHOLE prompt at the higher
// rate is the other obvious implementation and it overcharges short requests by
// the same factor it was meant to fix long ones.
func TestLongContextStepIsMarginal(t *testing.T) {
// Short request: below the threshold, so the step must not touch it at all.
ps, _ := billingVM(t)
if err := ps.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"currency": "USD",
"models": map[string]interface{}{
"m": map[string]interface{}{
"prompt": 1e-6, "completion": 0,
"long_context": map[string]interface{}{"above": 128000, "prompt": 2e-6},
},
},
},
}); err != nil {
t.Fatal(err)
}
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
"prompt_tokens": 100000, "completion_tokens": 0, "time": 1750000000000,
})
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
approx(t, "short request unaffected", got, 0.1)
// Long request: 200k prompt with a 128k threshold → 72k tokens at the step.
ps2, _ := billingVM(t)
if err := ps2.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"currency": "USD",
"models": map[string]interface{}{
"m": map[string]interface{}{
"prompt": 1e-6, "completion": 0,
"long_context": map[string]interface{}{"above": 128000, "prompt": 2e-6},
},
},
},
}); err != nil {
t.Fatal(err)
}
ps2.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
"prompt_tokens": 200000, "completion_tokens": 0, "time": 1750000000000,
})
got2 := stateOf(t, ps2)["total"].(map[string]interface{})["cost"].(float64)
// 128000*1e-6 + 72000*2e-6 = 0.128 + 0.144 = 0.272
approx(t, "long request marginal step", got2, 0.272)
}
// TestLongContextComposesWithCacheDiscount pins the interaction, which is the
// whole reason for computing the step as a marginal rate.
//
// On this deployment the cache hit rate is 88% and long prompts dominate, so
// "cached tokens beyond the threshold" is the common case, not an edge case. A
// cache read already bills at prompt*discount; the long-context rate applies on
// top of THAT, not on top of the full prompt price. Computing it any other way
// silently changes the price of the majority of traffic.
func TestLongContextComposesWithCacheDiscount(t *testing.T) {
ps, _ := billingVM(t)
if err := ps.SetState("billing", map[string]interface{}{
"prices": map[string]interface{}{
"currency": "USD", "cache_discount": 0.1,
"models": map[string]interface{}{
"m": map[string]interface{}{
"prompt": 1e-6, "completion": 0,
"long_context": map[string]interface{}{"above": 100000, "prompt": 2e-6},
},
},
},
}); err != nil {
t.Fatal(err)
}
// 200k prompt, 150k of it cached, 50k fresh. Excess past 100k = 100k, split
// 25% fresh / 75% cached.
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
"prompt_tokens": 200000, "completion_tokens": 0,
"cache_hit_tokens": 150000, "cache_reported": true,
"time": 1750000000000,
})
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
// base: fresh 50000*1e-6 = 0.05
// cache 150000*1e-6*0.1 = 0.015
// step: fresh excess 25000*(2-1)e-6 = 0.025
// cache excess 75000*(2-0.1)e-6 = 0.1425
// total = 0.2325
approx(t, "step composes with cache discount", got, 0.2325)
}

View File

@ -414,3 +414,86 @@ func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) {
"control and must never be squeezed by the flexible inputs")
}
}
// TestBillingUIShowsRecentUnpriced: the page must be able to distinguish a
// settled pricing debt from a live leak. The lifetime unpriced counter answers
// neither — it keeps every request from before a rule was added, so it reads the
// same number in both situations. The day buckets exist to make the difference
// visible, and a bucket nothing renders is a bucket nobody benefits from.
func TestBillingUIShowsRecentUnpriced(t *testing.T) {
raw, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatal(err)
}
src := string(raw)
for _, needle := range []string{
"unpriced_by_day", // the tally is written
"recentUnpriced", // it is reduced to a window
"unpriced_recent", // it reaches the view
"unpriced7d", // the card labels it
"unpricedClean", // and says so when the window is clear
} {
if !strings.Contains(src, needle) {
t.Errorf("billing UI lacks %q", needle)
}
}
}
// TestBillingRulesUIExposesReasoningPrice: the thinking-token rate must be
// editable in the rules editor, not only settable by hand-editing config.yaml.
// A pricing capability that lives only in the config file is one the operator
// will assume does not exist.
func TestBillingRulesUIExposesReasoningPrice(t *testing.T) {
raw, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatal(err)
}
src := string(raw)
for _, needle := range []string{
`class='m-r'`, // the input, in both the existing rows and the "+ model" row
`models[n].reasoning`, // collected back out of the DOM on save
`reasoningHint`, // and it says what leaving it blank means
} {
if !strings.Contains(src, needle) {
t.Errorf("rules editor lacks %q", needle)
}
}
// Two occurrences: an existing model's row and the template for a new one.
// One would mean the "+ model" button produces a row that silently drops
// the rate on the next save.
if n := strings.Count(src, `class='m-r'`); n < 2 {
t.Errorf("m-r appears %d time(s); both the row template and the editor need it", n)
}
}
// TestBillingRulesUIExposesLongContextStep: the long-context step must be
// editable in the rules editor.
//
// This is not a niche control. On an agent workload 88% of requests carry more
// than 128k prompt tokens with a median of 247k, so a rule that omits the step
// under-bills the MAJORITY of traffic while every number on the dashboard stays
// plausible. Leaving the capability in config.yaml alone would mean the operator
// never discovers it needs it.
func TestBillingRulesUIExposesLongContextStep(t *testing.T) {
raw, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatal(err)
}
src := string(raw)
for _, needle := range []string{
`class='m-lc-a'`, // threshold
`class='m-lc-p'`, // absolute rate
`class='m-lc-m'`, // multiplier
`models[n].long_context`, // collected back out on save
`lcHint`, // explains that blank = no step
} {
if !strings.Contains(src, needle) {
t.Errorf("rules editor lacks %q", needle)
}
}
// Both the existing-row template and the "+ model" template need the three
// inputs; one would make a newly added model lose its step on the next save.
if n := strings.Count(src, `class='m-lc-a'`); n < 2 {
t.Errorf("long-context inputs appear %d time(s); both row templates need them", n)
}
}

View File

@ -0,0 +1,287 @@
package lua
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// TestRouteConflictDetectedAndArbitrated(用户要求 A):
// 两个插件声明同一条 /api/plugins/http/ 服务路径 → 必须报冲突(列出双方),
// 运维裁决后裁决生效;裁决持久化,重启(重新 LoadDir)后仍然生效。
// 全程不崩溃、不拒绝加载 —— 两个插件都保持 loaded。
func TestRouteConflictDetectedAndArbitrated(t *testing.T) {
dir := t.TempDir()
_, ps := bridgeVM(t)
ps.dir = dir
// a.lua 与 b.lua 都声明路径 "shared"
const aSrc = `
local P = {}
P.name = "plugA"
P.version = "1"
P.exports = {}
P.http = { { path = "shared", handler = "h", roles = {"admin"} } }
function P.h(req) return { status = 200, json = { from = "A" } } end
return P
`
const bSrc = `
local P = {}
P.name = "plugB"
P.version = "1"
P.exports = {}
P.http = { { path = "shared", handler = "h", roles = {"admin"} } }
function P.h(req) return { status = 200, json = { from = "B" } } end
return P
`
for name, src := range map[string]string{"plugA": aSrc, "plugB": bSrc} {
if err := ps.LoadSource(name, src); err != nil {
t.Fatalf("加载 %s: %v", name, err)
}
}
// ① 冲突被报出来,候选齐整,未裁决
cs := ps.RouteConflicts()
if len(cs) != 1 {
t.Fatalf("应恰有 1 条路径冲突, 实得 %d: %+v", len(cs), cs)
}
c := cs[0]
if c.Path != "shared" {
t.Fatalf("冲突路径应为 shared, 实得 %q", c.Path)
}
if len(c.Candidates) != 2 || c.Candidates[0] != "plugA" || c.Candidates[1] != "plugB" {
t.Fatalf("候选应为 [plugA plugB], 实得 %v", c.Candidates)
}
if c.Resolved != "" {
t.Fatalf("初始应未裁决, 实得 %q", c.Resolved)
}
// 两个插件都保持 loaded(不许因冲突拒绝启动/加载)
if ps.List()[0]["loaded"] != true || ps.List()[1]["loaded"] != true {
t.Fatal("冲突的两个插件都应保持 loaded")
}
// ② 非法裁决被拒(赢家必须确实是候选之一)
if err := ps.ResolveRouteConflict("shared", "plugC"); err == nil {
t.Fatal("赢家不是候选应被拒绝")
}
if err := ps.ResolveRouteConflict("other", "plugA"); err == nil {
t.Fatal("未冲突的路径不应可裁决")
}
// ③ 合法裁决 + 持久化
if err := ps.ResolveRouteConflict("shared", "plugB"); err != nil {
t.Fatalf("合法裁决失败: %v", err)
}
if ps.RouteWinner("shared") != "plugB" {
t.Fatalf("裁决后赢家应为 plugB, 实得 %q", ps.RouteWinner("shared"))
}
if _, err := os.Stat(filepath.Join(dir, ".route-conflicts.json")); err != nil {
t.Fatalf("裁决应落盘 .route-conflicts.json: %v", err)
}
// ④ 重启语义:新 registry 读回裁决
_, ps2 := bridgeVM(t)
ps2.dir = dir
if err := ps2.LoadRouteConflicts(); err != nil {
t.Fatalf("读回裁决: %v", err)
}
if err := ps2.LoadSource("plugA", aSrc); err != nil {
t.Fatal(err)
}
if err := ps2.LoadSource("plugB", bSrc); err != nil {
t.Fatal(err)
}
cs2 := ps2.RouteConflicts()
if len(cs2) != 1 || cs2[0].Resolved != "plugB" {
t.Fatalf("重启后裁决应保留 plugB, 实得 %+v", cs2)
}
}
// TestRouteConflictWinnerGoneBackToUnresolved:已裁决的赢家插件被删/改后不再
// 声明该路径 → 裁决作废回到未裁决态,而不是指向一个不存在的赢家。
func TestRouteConflictWinnerGoneBackToUnresolved(t *testing.T) {
_, ps := bridgeVM(t)
const aSrc = `
local P = {}
P.name = "plugA"
P.http = { { path = "s", handler = "h", roles = {"admin"} } }
function P.h(req) return { status = 200, json = {} } end
return P
`
const bSrc = `
local P = {}
P.name = "plugB"
P.http = { { path = "s", handler = "h", roles = {"admin"} } }
function P.h(req) return { status = 200, json = {} } end
return P
`
if err := ps.LoadSource("plugA", aSrc); err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("plugB", bSrc); err != nil {
t.Fatal(err)
}
if err := ps.ResolveRouteConflict("s", "plugA"); err != nil {
t.Fatal(err)
}
// plugA 被禁用 → 不再占路由 → 冲突消失(不是回未裁决)
if err := ps.SetEnabled("plugA", false); err != nil {
t.Fatal(err)
}
if got := ps.RouteConflicts(); len(got) != 0 {
t.Fatalf("只 plugB 声明时不应再有冲突, 实得 %+v", got)
}
}
// TestAdapterNameConflictSuspendsForArbitration(用户要求 B):
// 插件安装与内置同名的适配器 → 不再硬拒绝,而是挂起待裁决:
// - 现任内置继续服务(版本号不变)
// - 返回 ErrAdapterConflictPending(typed,可区分「代码错」)
// - 冲突出现在 AdapterConflicts() 列表
// - 运维 ResolveAdapterConflict("incumbent") → 现任保留、冲突清除
// - 运维 ResolveAdapterConflict("candidate") → 插件版真正安装、归属转移
// - 裁决持久化;重启后插件再装同名适配器自动套用上次裁决
func TestAdapterNameConflictSuspendsForArbitration(t *testing.T) {
dir := t.TempDir()
_, ps := bridgeVM(t)
ps.dir = dir
builtin := `local a={} a.name="openai" a.version="BUILTIN" a.endpoint="/x" return a`
if err := ps.vm.LoadAdapterSource("openai", builtin); err != nil {
t.Fatal(err)
}
evil := `local a={} a.name="openai" a.version="PLUGIN_V1" a.endpoint="/x" return a`
// ① 安装挂起:现任保留,typed 错误,不静默覆盖
got, err := ps.InstallAdapter("sneaky", "openai", evil)
if err == nil {
t.Fatal("撞内置名应返回错误(挂起)")
}
var pend *ErrAdapterConflictPending
if !errors.As(err, &pend) {
t.Fatalf("错误类型应为 ErrAdapterConflictPending, got %T: %v", err, err)
}
if got != "" {
t.Fatalf("挂起时不得返回生效名, 实得 %q", got)
}
if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" {
t.Fatalf("现任内置必须继续服务, 版本实得 %q", v)
}
// ② 冲突可见
ac := ps.AdapterConflicts()
if len(ac) != 1 || ac[0].Name != "openai" || ac[0].Candidate != "sneaky" {
t.Fatalf("挂起冲突应可见: %+v", ac)
}
// ③ 裁决 incumbent → 现任保留
if err := ps.ResolveAdapterConflict("openai", "incumbent"); err != nil {
t.Fatalf("裁决 incumbent: %v", err)
}
if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" {
t.Fatalf("裁决 incumbent 后内置应保留, 实得 %q", v)
}
if got := ps.AdapterConflicts(); len(got) != 0 {
t.Fatalf("裁决后冲突应清除, 实得 %+v", got)
}
// 归属仍是 operator(内置)
if o := ps.AdapterOwnerOf("openai"); o != "" {
t.Fatalf("内置归属应为空(operator), 实得 %q", o)
}
// ④ 重启后插件再装 → 之前的 incumbent 裁决自动套用(不再二次挂起,也不安装)
if err := ps.LoadRouteConflicts(); err != nil {
t.Fatal(err)
}
_, err = ps.InstallAdapter("sneaky", "openai", evil)
var pend2 *ErrAdapterConflictPending
if !errors.As(err, &pend2) {
t.Fatalf("自动套用 incumbent 裁决应仍返回 pending 错误(未安装), got %v", err)
}
if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" {
t.Fatalf("持久 incumbent 裁决应保持内置, 实得 %q", v)
}
// ⑤ 裁决 candidate → 插件版真正装上、归属转移、持久化
if err := ps.ResolveAdapterConflict("openai", "candidate"); err != nil {
t.Fatalf("裁决 candidate: %v", err)
}
if v := ps.vm.AdapterVersion("openai"); v != "PLUGIN_V1" {
t.Fatalf("裁决 candidate 后插件版应生效, 实得 %q", v)
}
if o := ps.AdapterOwnerOf("openai"); o != "sneaky" {
t.Fatalf("归属应转移给 sneaky, 实得 %q", o)
}
// 持久化文件里是 candidate:sneaky
b, _ := os.ReadFile(filepath.Join(dir, ".route-conflicts.json"))
if !strings.Contains(string(b), "candidate:sneaky") {
t.Fatalf("裁决应持久化为 candidate:sneaky, 文件: %s", b)
}
}
// TestAdapterPluginConflictSuspendsForArbitration:插件 A 装了适配器后插件 B
// 再装同名 → 挂起待裁决(与内置撞名同一机制),现任 A 的版本继续服务。
func TestAdapterPluginConflictSuspendsForArbitration(t *testing.T) {
_, ps := bridgeVM(t)
if _, err := ps.InstallAdapter("alpha", "shared_auth", tokenAdapterV1); err != nil {
t.Fatal(err)
}
_, err := ps.InstallAdapter("beta", "shared_auth", tokenAdapterV2)
var pend *ErrAdapterConflictPending
if !errors.As(err, &pend) {
t.Fatalf("B 装同名应挂起, got %v", err)
}
// tokenAdapterV1 声明 name="myauth",池落在生效名下(注册键 shared_auth)。
if v := ps.vm.AdapterVersion("myauth"); v != "1.0" {
t.Fatalf("alpha 的现任版本应继续服务, 实得 %q", v)
}
ac := ps.AdapterConflicts()
if len(ac) != 1 || ac[0].Incumbent != "plugin:alpha" {
t.Fatalf("冲突应点名现任归属 plugin:alpha, 实得 %+v", ac)
}
// 裁决 candidate → beta 接管
if err := ps.ResolveAdapterConflict("shared_auth", "candidate"); err != nil {
t.Fatal(err)
}
if v := ps.vm.AdapterVersion("myauth"); v != "2.0" {
t.Fatalf("beta 版应生效, 实得 %q", v)
}
if o := ps.AdapterOwnerOf("myauth"); o != "beta" {
t.Fatalf("归属应转移给 beta, 实得 %q", o)
}
}
// TestAdapterConflictRejectsBadWinner:裁决值必须合法;无挂起冲突时拒绝。
func TestAdapterConflictRejectsBadWinner(t *testing.T) {
_, ps := bridgeVM(t)
if err := ps.ResolveAdapterConflict("nope", "candidate"); err == nil {
t.Fatal("无挂起冲突应拒绝")
}
if _, err := ps.InstallAdapter("p", "myauth", tokenAdapterV1); err != nil {
t.Fatal(err)
}
// myauth 是插件自己装的 → 更新路径(update 走 UpdateAdapter,非冲突)
if err := ps.ResolveAdapterConflict("myauth", "bogus"); err == nil {
t.Fatal("非法 winner 值应拒绝")
}
}
// TestPluginOwnUpdateStillWorks:插件更新自己装的适配器(token 轮换)不走冲突
// 路径 —— 行为与改造前一致。守住「挂起只发生在撞名」这条边界。
func TestPluginOwnUpdateStillWorks(t *testing.T) {
_, ps := bridgeVM(t)
if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil {
t.Fatal(err)
}
if err := ps.UpdateAdapter("authtoken", "myauth", tokenAdapterV2); err != nil {
t.Fatalf("own update 不应受影响: %v", err)
}
if v := ps.vm.AdapterVersion("myauth"); v != "2.0" {
t.Fatalf("轮换后版本应 2.0, 实得 %q", v)
}
if got := ps.AdapterConflicts(); len(got) != 0 {
t.Fatalf("own update 不应产生冲突, 实得 %+v", got)
}
}

498
internal/lua/kernel_api.go Normal file
View File

@ -0,0 +1,498 @@
package lua
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
)
// Package lua — 插件可调用的内核动作面(R4-c)
//
// 用户需求(原话):「现在这个接口不全,可以多给一些接口让 lua 调用内核。」
//
// 在此之前,插件只能通过 __llmsproxy_adapters 管适配器 —— 那是三个手写闭包。
// 内核另有十几个公开动作(重载配置、清冷却、重探上游、增删源/密钥、改 AUTO 链…),
// 插件一个都调不到。而"自愈 / 联动 / 自动孵化"这类插件能力恰恰需要它们:
//
// 探测到上游整片挂掉 → probe_all + reset_health
// 发现额度变化 → save_auto_rules 重排链路
// 拿到新端点 → add_source
//
// # 为什么 Go 侧 deadline 是可行的(而 Lua 死循环不可中断)
//
// 这是整个设计的支点。两者形态完全不同:
//
// Lua 里 while true do end → 无法中断(LuaJIT 限制,实测三次)
// Lua 里 __llmsproxy_kernel.call(...) → **可以**超时
//
// 因为后者 Lua 只是在等一个返回值,不占用 Lua 栈的执行权。所以每次调用在 Go
// 侧包一层 select + ctx deadline:超时就把 (nil, err) 还给 Lua。这比"Lua 投递
// 请求、Go 队列执行"干净 —— 队列还要定义"超时后积压的命令算什么",而 deadline
// 的语义是闭合的。
//
// # 权限:全开,但默认不授予
//
// 用户选定「全开」(13 个动作都可达)+ 「设置页可配权限」。全开描述的是**能力
// 存在**,不是**默认授予**:一个插件能删密钥这件事必须由运维在设置页点过确认。
// 因此默认拒绝,授权记录在 .access.json 里(复用现有覆盖机制)。
//
// danger 级动作必须**逐个**授权,不接受通配符 —— 否则运维图省事写个 "*",
// "删密钥"就和"读状态"同权了。
// KernelAPI 是内核暴露给插件的能力面。由 internal/core 实现并注入。
//
// 依赖方向:internal/lua 不能 import internal/core(方向是 core → lua),
// 所以这里定义接口、core 侧实现 —— 与 SetOutboundHosts 同一个注入先例。
type KernelAPI interface {
// Actions 列出可调用的动作。设置页据此渲染权限开关,插件据此发现能力。
Actions() []KernelAction
// Invoke 执行一个动作。实现**必须**尊重 ctx:ctx 取消后应尽快返回,
// 否则插件侧的超时虽然会返回错误,但该动作仍在后台跑(变成不可见的泄漏)。
Invoke(ctx context.Context, name string, args map[string]interface{}) (interface{}, error)
}
// 危险级。它决定权限授予的严格程度,也决定设置页用什么颜色提示。
const (
// DangerRead 无副作用,只读。仍然需要授权,但可以一次授予全部。
DangerRead = "read"
// DangerWrite 修改运行状态或配置,但可恢复(重载、清冷却、改链路)。
DangerWrite = "write"
// DangerDestructive 删除类,或让服务中断。**必须逐个授权**。
DangerDestructive = "destructive"
)
// KernelAction 描述一个可调用动作。
type KernelAction struct {
// Deferred marks an action that MUST NOT run while the Lua caller holds its
// plugin lock. The Lua entry points (hook, exported call, scheduled task) all
// hold p.mu for the whole call, and an action that re-enters the plugin layer
// (set_plugin_enabled reaches SetEnabled → p.mu.Lock()) would self-deadlock —
// and with a deadline, would then execute invisibly AFTER the plugin was told
// it failed.
//
// Such an action is queued and run on its own goroutine once the Lua call has
// returned, so the caller gets "accepted" rather than a lock-up or a lie.
Deferred bool
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Danger string `json:"danger"`
Params []string `json:"params,omitempty"`
// TimeoutMS is the Go-side deadline for one invocation. Long actions (a
// full re-probe walks every source over the network) need a bigger budget
// than a state read; making it per-action rather than one global number is
// what keeps the deadline honest rather than arbitrary.
TimeoutMS int `json:"timeout_ms"`
}
// kernelCatalog is the full action set. It lives in the lua package (rather than
// being discovered from the implementation) so that permissions can be validated
// even when no Core is wired — tests, and a gateway with plugins but no kernel.
//
// Every action here maps to an existing Core method; adding one is a deliberate
// act rather than a reflex. ListKeys/FindKey are deliberately ABSENT: they return
// PLAINTEXT gateway keys, and handing those to third-party Lua would hand over
// the deployment's entire credential surface. Key-related reads go through
// masked forms instead.
var kernelCatalog = []KernelAction{
// ---- 只读 ----
{Name: "status", Title: "运行状态", Danger: DangerRead, TimeoutMS: 3000,
Description: "各源的调度状态(冷却、连续失败、最近错误)"},
{Name: "list_sources", Title: "源列表", Danger: DangerRead, TimeoutMS: 3000,
Description: "已配置的上游源(api_key 已掩码)"},
{Name: "list_models", Title: "模型列表", Danger: DangerRead, TimeoutMS: 3000,
Description: "当前可路由的模型 id 列表"},
{Name: "list_adapters", Title: "适配器列表", Danger: DangerRead, TimeoutMS: 3000,
Description: "已加载的适配器名与版本"},
{Name: "list_auto_rules", Title: "AUTO 链路", Danger: DangerRead, TimeoutMS: 3000,
Description: "当前全局 AUTO 链的槽位"},
{Name: "adapter_owner", Title: "适配器归属", Danger: DangerRead, TimeoutMS: 3000,
Params: []string{"name"},
Description: "某适配器由谁安装(operator 或 plugin:名字)"},
// ---- 写(可恢复) ----
{Name: "reload", Title: "重载配置", Danger: DangerWrite, TimeoutMS: 30000,
Description: "重新读取运行配置并重建源/链路"},
{Name: "reset_health", Title: "清除冷却", Danger: DangerWrite, TimeoutMS: 5000,
Description: "清掉所有源的退避状态(修好上游后立即恢复)"},
{Name: "probe_all", Title: "全量探测", Danger: DangerWrite, TimeoutMS: 60000,
Description: "对每个源做一次真实可达性探测(走网络,慢)"},
{Name: "save_auto_rules", Title: "设置 AUTO 链", Danger: DangerWrite, TimeoutMS: 10000,
Params: []string{"entries"},
Description: "替换全局 AUTO 链;entries 为 [{model, source, ...}] 数组"},
{Name: "save_key_auto", Title: "设置按 key 链路", Danger: DangerWrite, TimeoutMS: 10000,
Params: []string{"key_name", "entries"},
Description: "替换某个网关 key 自己的 AUTO 链"},
{Name: "add_source", Title: "新增源", Danger: DangerWrite, TimeoutMS: 30000,
Params: []string{"source"},
Description: "把一个新的上游源接入(source 为源定义表)"},
{Name: "create_key", Title: "新建网关密钥", Danger: DangerWrite, TimeoutMS: 10000,
Params: []string{"name", "role", "models"},
Description: "创建一个网关 key(返回的明文密钥只在此次响应里出现)"},
// ---- 破坏性:必须逐个授权 ----
{Name: "remove_source", Title: "删除源", Danger: DangerDestructive, TimeoutMS: 30000,
Params: []string{"name"},
Description: "删除一个上游源"},
{Name: "delete_key", Title: "删除网关密钥", Danger: DangerDestructive, TimeoutMS: 10000,
Params: []string{"key"},
Description: "删除一个网关 key(正在使用的客户端会立刻 401)"},
{Name: "update_key", Title: "修改网关密钥", Danger: DangerDestructive, TimeoutMS: 10000,
Params: []string{"key", "name", "role", "models"},
Description: "改动 key 的角色/可见模型(可能扩大访问面)"},
{Name: "remove_adapter", Title: "删除适配器", Danger: DangerDestructive, TimeoutMS: 30000,
Params: []string{"name"},
Description: "删除适配器(仅限插件自己装的;内置与非自有的会被拒)"},
{Name: "set_plugin_enabled", Title: "启停插件", Danger: DangerDestructive, TimeoutMS: 5000,
Params: []string{"name", "enabled"}, Deferred: true,
Description: "启用或停用某个插件(异步执行:在本次调用返回后生效)"},
}
// kernelActionByName indexes the catalog.
var kernelActionByName = func() map[string]KernelAction {
m := make(map[string]KernelAction, len(kernelCatalog))
for _, a := range kernelCatalog {
m[a.Name] = a
}
return m
}()
// KernelActions returns the catalog sorted by name (stable for the UI).
func KernelActions() []KernelAction {
out := append([]KernelAction(nil), kernelCatalog...)
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// LookupKernelAction finds an action, reporting whether it exists.
func LookupKernelAction(name string) (KernelAction, bool) {
a, ok := kernelActionByName[name]
return a, ok
}
// ---- 权限 ----
// errNotPermitted is returned when a plugin calls an action it was not granted.
// It names the action and the plugin so the operator can act on it directly.
func notPermitted(plugin, action string) error {
return fmt.Errorf("kernel action %q is not granted to plugin %q; "+
"enable it in the plugin's kernel permissions", action, plugin)
}
// KernelPermission is the operator's grant for one plugin: the set of action
// names it may call, plus whether it was granted "everything readable".
type KernelPermission struct {
// Actions is the explicit allowlist, already expanded (no wildcards stored,
// so a NEW action added in a future release is NOT silently granted to an
// existing plugin — that is the safe direction for a security default).
Actions []string
// AllRead is true when the operator granted every read-only action at once.
// Stored separately from Actions so that a future read-only action IS covered
// without re-saving, while writes still require an explicit grant.
AllRead bool
}
// clone returns a copy so callers cannot mutate the stored permission.
func (p KernelPermission) clone() KernelPermission {
return KernelPermission{Actions: append([]string(nil), p.Actions...), AllRead: p.AllRead}
}
// allows reports whether the permission covers action.
//
// The "all readable" grant deliberately does NOT cover a future WRITE action:
// expanding a wildcard into whatever happens to exist later is how a convenience
// grant turns into a privilege escalation.
func (p KernelPermission) allows(a KernelAction) bool {
if p.AllRead && a.Danger == DangerRead {
return true
}
for _, n := range p.Actions {
if n == a.Name {
return true
}
}
return false
}
// ---- 调用(带 Go 侧超时) ----
// ErrKernelUnavailable is returned when no KernelAPI was injected (a gateway
// running plugins without a wired core, e.g. some tests).
var ErrKernelUnavailable = errors.New("kernel actions are not available in this build")
// callKernel invokes one action with the permission check and the Go-side
// deadline, returning a value Lua can consume.
//
// The deadline is the whole point: a plugin can block here harmlessly, because
// Lua is waiting on a return value rather than spinning. On timeout the caller
// gets a normal error; the action's own goroutine is told to stop via ctx.
func (ps *Plugins) callKernel(plugin, name string, args map[string]interface{}) (interface{}, error) {
api := ps.kernel()
if api == nil {
return nil, ErrKernelUnavailable
}
action, ok := LookupKernelAction(name)
if !ok {
return nil, fmt.Errorf("unknown kernel action %q", name)
}
if !ps.kernelPermitted(plugin, action) {
return nil, notPermitted(plugin, name)
}
timeout := time.Duration(action.TimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = 10 * time.Second
}
// The caller's identity is injected HERE, not taken from args: an action that
// needs to know who is calling (remove_adapter, to enforce ownership) must not
// be able to be lied to by the plugin passing someone else's name.
if args == nil {
args = map[string]interface{}{}
}
args["plugin"] = plugin
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
type result struct {
v interface{}
err error
}
// A deferred action must not run under the caller's plugin lock, so it is
// queued and returns "accepted" immediately. Reporting success here without
// waiting is honest: the caller is told the action was ACCEPTED, and the
// actual outcome lands in the plugin's hook_errors surface if it fails.
if action.Deferred {
ps.runDeferred(action, args)
return map[string]interface{}{
"accepted": true,
"note": "this action runs asynchronously; check the plugin state/errors for the outcome",
}, nil
}
// The action runs on its own goroutine so this call can give up on time.
// Buffered so a late-finishing action never blocks on sending.
done := make(chan result, 1)
go func() {
defer func() {
// A panicking kernel action must not take the gateway down through a
// Lua call. Convert it into an error the plugin sees.
if rec := recover(); rec != nil {
done <- result{err: fmt.Errorf("kernel action %s panicked: %v", name, rec)}
}
}()
v, err := api.Invoke(ctx, name, args)
done <- result{v: v, err: err}
}()
select {
case r := <-done:
return r.v, r.err
case <-ctx.Done():
return nil, fmt.Errorf("kernel action %q exceeded its %s budget", name, timeout)
}
}
// runDeferred executes a lock-re-entrant action outside the Lua call.
//
// It runs on a fresh goroutine precisely so it can take the plugin lock that the
// caller still holds; waiting for the caller to unwind first would need a
// handshake that is more machinery than the one action needs. A failure is
// surfaced through hook_errors, which is where an operator already looks for
// "a plugin's background work stopped working".
func (ps *Plugins) runDeferred(a KernelAction, args map[string]interface{}) {
api := ps.kernel()
if api == nil {
return
}
timeout := time.Duration(a.TimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = 10 * time.Second
}
// Copy the args: the caller's map is still owned by the Lua call frame.
cp := make(map[string]interface{}, len(args))
for k, v := range args {
cp[k] = v
}
go func() {
defer func() {
if rec := recover(); rec != nil {
ps.hookErr.note(StageRequestEnd, fmt.Sprintf("deferred kernel action %s panicked: %v", a.Name, rec))
}
}()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if _, err := api.Invoke(ctx, a.Name, cp); err != nil {
ps.hookErr.note(StageRequestEnd, fmt.Sprintf("deferred kernel action %s failed: %v", a.Name, err))
}
}()
}
// kernelPermitted applies the stored grant, with one deliberate exception: a
// plugin's OWN adapter may always be removed by that plugin (it is not a kernel
// resource, it is one the plugin created). Everything else needs a grant.
func (ps *Plugins) kernelPermitted(plugin string, a KernelAction) bool {
if a.Name == "remove_adapter" {
return true
}
ps.mu.RLock()
perm := ps.kernelPerms[plugin]
ps.mu.RUnlock()
return perm.allows(a)
}
// SetPluginKernelPermission replaces one plugin's grant and persists it.
//
// Validation happens here rather than at call time so a typo'd action name is
// reported to the operator immediately instead of silently granting nothing.
func (ps *Plugins) SetPluginKernelPermission(plugin string, actions []string, allRead bool) error {
if ps.find(plugin) == nil {
return fmt.Errorf("plugin %s not found", plugin)
}
clean := make([]string, 0, len(actions))
seen := map[string]bool{}
for _, raw := range actions {
name := strings.TrimSpace(raw)
if name == "" || seen[name] {
continue
}
a, ok := LookupKernelAction(name)
if !ok {
return fmt.Errorf("unknown kernel action %q", name)
}
seen[name] = true
clean = append(clean, a.Name)
}
sort.Strings(clean)
ps.mu.Lock()
if ps.kernelPerms == nil {
ps.kernelPerms = kernelPermissionSet{}
}
if len(clean) == 0 && !allRead {
delete(ps.kernelPerms, plugin)
} else {
ps.kernelPerms[plugin] = KernelPermission{Actions: clean, AllRead: allRead}
}
ps.saveKernelPermissionsLocked()
ps.mu.Unlock()
return nil
}
// PluginKernelPermission returns a copy of one plugin's grant.
func (ps *Plugins) PluginKernelPermission(plugin string) KernelPermission {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.kernelPerms[plugin].clone()
}
// KernelPermissionView is the UI-facing form: what the plugin ASKED for (from its
// own source) next to what the operator GRANTED. The distinction matters — a
// plugin declaring `plugin.kernel = {...}` is a request, not a privilege.
type KernelPermissionView struct {
Plugin string `json:"plugin"`
Declared []string `json:"declared,omitempty"`
Granted []string `json:"granted,omitempty"`
AllRead bool `json:"all_read"`
}
// KernelPermissions lists every plugin's declared-vs-granted state for the UI.
func (ps *Plugins) KernelPermissions() []KernelPermissionView {
ps.mu.RLock()
defer ps.mu.RUnlock()
out := make([]KernelPermissionView, 0, len(ps.plugins))
for _, p := range ps.plugins {
perm := ps.kernelPerms[p.Info.Name]
out = append(out, KernelPermissionView{
Plugin: p.Info.Name,
Declared: append([]string(nil), p.KernelDeclared...),
Granted: append([]string(nil), perm.Actions...),
AllRead: perm.AllRead,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Plugin < out[j].Plugin })
return out
}
// kernel returns the injected API, or nil.
func (ps *Plugins) kernel() KernelAPI {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.kernelAPI
}
// SetKernelAPI injects the kernel action implementation. Called during wiring,
// mirroring SetOutboundHosts.
func (ps *Plugins) SetKernelAPI(api KernelAPI) {
ps.mu.Lock()
defer ps.mu.Unlock()
ps.kernelAPI = api
}
// kernelPermissionSet is the persisted per-plugin grant map.
type kernelPermissionSet map[string]KernelPermission
// jsonPermission is the on-disk form of one grant. Kept separate from
// KernelPermission so the persisted shape cannot drift with a struct refactor
// (the access file is read by older and newer binaries across an upgrade).
type jsonPermission struct {
Actions []string `json:"actions,omitempty"`
AllRead bool `json:"all_read,omitempty"`
}
func (p KernelPermission) marshal() jsonPermission {
return jsonPermission{Actions: p.Actions, AllRead: p.AllRead}
}
func (j jsonPermission) unmarshal() KernelPermission {
return KernelPermission{Actions: j.Actions, AllRead: j.AllRead}
}
type jsonPluginPolicy struct {
Actions []string `json:"actions,omitempty"`
AllRead bool `json:"all_read,omitempty"`
Hosts []string `json:"hosts,omitempty"`
}
func policyFromActions(p KernelPermission) jsonPluginPolicy {
return jsonPluginPolicy{Actions: p.Actions, AllRead: p.AllRead}
}
// KernelActionView is one action as the settings page needs it: the catalog entry
// plus whether THIS plugin currently holds it and whether the plugin asked for it.
type KernelActionView struct {
KernelAction
Granted bool `json:"granted"`
Declared bool `json:"declared"`
}
// KernelActionsView lists the catalog annotated for one plugin.
//
// Granted and Declared are both reported because they answer different questions:
// "may this plugin do it" and "did this plugin ask for it". An action that is
// declared but not granted is the one an operator most needs to notice.
func (ps *Plugins) KernelActionsView(plugin string) []KernelActionView {
perm := ps.PluginKernelPermission(plugin)
declared := map[string]bool{}
ps.mu.RLock()
for _, p := range ps.plugins {
if p.Info.Name == plugin {
for _, d := range p.KernelDeclared {
declared[d] = true
}
}
}
ps.mu.RUnlock()
out := make([]KernelActionView, 0, len(kernelCatalog))
for _, a := range KernelActions() {
out = append(out, KernelActionView{
KernelAction: a,
Granted: perm.allows(a),
Declared: declared[a.Name],
})
}
return out
}

View File

@ -0,0 +1,126 @@
package lua
import (
"encoding/json"
"os"
)
// Kernel action grants are OPERATOR policy, persisted separately from both
// plugin state and the endpoint/visibility overrides.
//
// Why a separate file rather than reusing .access.json:
//
// - .access.json is a flat map[string]string ("name" or "name#path" -> spec).
// Kernel grants are a list plus a boolean, so folding them in would either
// change the shape of a file an older binary still reads, or force an
// encoding that cannot round-trip. An upgrade must not corrupt a running
// deployment's restrictions.
// - The two answer different questions — "who may call this endpoint" vs
// "what may this plugin do to the kernel" — and an operator looking at one
// should not have to parse the other.
//
// Shape: { "<plugin>": { "actions": ["reload", ...], "all_read": true } }
const kernelPermsFileName = ".kernel-perms.json"
func (ps *Plugins) kernelPermsFile() string {
if ps.dir == "" {
return ""
}
return ps.dir + string(os.PathSeparator) + kernelPermsFileName
}
// LoadKernelPermissions reads the persisted grants. A missing file is the normal
// fresh-install state (nothing granted); a corrupt file is logged and ignored
// rather than fatal — losing a grant is recoverable, refusing to boot is not.
func (ps *Plugins) LoadKernelPermissions() error {
path := ps.kernelPermsFile()
if path == "" {
return nil
}
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
// The file holds BOTH kernel-action grants (map[string]jsonPermission, the
// historical shape) AND, for plugins that asked for them, the operator's
// extra-host grants (map[string]jsonPluginPolicy.Hosts). A bare
// {actions:[...]} row still parses into jsonPluginPolicy with empty Hosts,
// so old files load unchanged.
raw := map[string]jsonPluginPolicy{}
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.kernelPerms == nil {
ps.kernelPerms = kernelPermissionSet{}
}
if ps.extraHosts == nil {
ps.extraHosts = map[string][]string{}
}
for name, jp := range raw {
ps.kernelPerms[name] = KernelPermission{Actions: jp.Actions, AllRead: jp.AllRead}
if len(jp.Hosts) > 0 {
// Load the persisted grant into extraHosts WITHOUT folding it into the
// allowlist yet — sources are loaded later, and the caller owns that
// fold. GrantExtraHosts rebuilds the allowlist when sources are ready.
ps.extraHosts[name] = append([]string(nil), jp.Hosts...)
}
}
return nil
}
// saveKernelPermissionsLocked flushes the grant map. Caller holds ps.mu.
//
// Grants drop unknown action names on write. If an operator authorized an action
// that a later release removed, keeping the name would leave a stale entry that
// nothing can ever satisfy — and it would reappear in the UI as if it were live.
//
// Host grants travel in the same file (jsonPluginPolicy.Hosts): a granted host is
// the operator's explicit decision to let a plugin egress somewhere, and losing
// it across a restart (the previous behaviour) made grants silently vanish.
func (ps *Plugins) saveKernelPermissionsLocked() {
path := ps.kernelPermsFile()
if path == "" {
return
}
out := map[string]jsonPluginPolicy{}
something := false
for name, perm := range ps.kernelPerms {
valid := make([]string, 0, len(perm.Actions))
for _, a := range perm.Actions {
if _, ok := LookupKernelAction(a); ok {
valid = append(valid, a)
}
}
row := policyFromActions(KernelPermission{Actions: valid, AllRead: perm.AllRead})
if hs := ps.extraHosts[name]; len(hs) > 0 {
row.Hosts = append([]string(nil), hs...)
}
if len(valid) == 0 && !perm.AllRead && len(row.Hosts) == 0 {
continue // nothing granted: presence would be a lie
}
out[name] = row
something = true
}
// A plugin may hold host grants with NO kernel actions granted (operators
// approve hosts and actions independently). Emit those too.
for name, hs := range ps.extraHosts {
if len(hs) == 0 {
continue
}
if _, exists := out[name]; !exists {
out[name] = jsonPluginPolicy{Hosts: append([]string(nil), hs...)}
something = true
}
}
if !something {
_ = os.Remove(path)
return
}
writeJSONAtomic(path, out)
}

View File

@ -281,3 +281,60 @@ func TestCloseFlushesTail(t *testing.T) {
t.Errorf("flushed n = %v, want 1", saved.State["n"])
}
}
// TestOnDemandCallIsPersisted guards the persistence gap that the quota memo fell
// into, and it needs a plugin with NO hooks to see it.
//
// markDirty fired from two places: hooks and SetState. `Call` — the on-demand path
// a plugin page uses — fired from neither, so a plugin that does all its work in
// exported functions (quota: it declares no hooks at all) mutated state that
// nothing ever wrote. The visible symptom was a health probe that looked inert
// from outside: the memo changed in memory and the file kept the previous
// generation until some unrelated configuration write happened to flush it.
//
// A hookless plugin is the whole point of the fixture: the counter above
// accumulates through a hook, which is the path that already persisted, so it
// cannot see this defect.
func TestOnDemandCallIsPersisted(t *testing.T) {
ps, _ := newPersistVM(t)
if err := ps.LoadSource("ondemand", `
local plugin = { name = "ondemand", version = "1.0" }
plugin.state = { n = 0 }
plugin.exports = { "bump" }
function plugin.bump(payload)
plugin.state.n = plugin.state.n + 1
return { n = plugin.state.n }
end
return plugin
`); err != nil {
t.Fatal(err)
}
for i := 1; i <= 3; i++ {
out, err := ps.Call("ondemand", "bump", map[string]interface{}{})
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
if m, _ := out.(map[string]interface{}); m["n"] != float64(i) {
t.Fatalf("call %d returned %v, want n=%d", i, out, i)
}
}
// Close() waits for the final flush, so no timing is involved: anything the
// calls did NOT mark dirty cannot appear here.
ps.Close()
b, err := os.ReadFile(ps.stateFile("ondemand"))
if err != nil {
t.Fatalf("★ an exported call did not mark the plugin dirty: %v — a plugin "+
"with no hooks can never persist its work", err)
}
var saved struct {
State map[string]interface{} `json:"state"`
}
if err := json.Unmarshal(b, &saved); err != nil {
t.Fatal(err)
}
if saved.State["n"] != float64(3) {
t.Errorf("persisted n = %v after three exported calls, want 3", saved.State["n"])
}
}

View File

@ -0,0 +1,180 @@
package lua
import "strings"
// One vocabulary for "who may see / read / call this".
//
// Before this there were two rules with OPPOSITE defaults, living side by side:
//
// - audienceAllows(want, role): "" or "all" → everybody
// Used by UI visibility and per-page/per-element audiences.
// - HTTPHandler.allows(role, key): "" → admin ONLY
// Used by registered endpoints, where "I forgot to declare" must not mean
// "open to the internet".
//
// Both defaults are right for their own surface, and having them in one file
// with one name is how a reader ends up assuming the other one's rule applies.
// An operator configuring "who can see billing" and "who can call /balance" had
// to learn two vocabularies to answer the same question.
//
// The distinction that remains is not the RULE, it is the DEFAULT, so it is
// stated as data rather than left implicit: an empty grant is "deny" for
// capabilities and "inherit" for visibility, and every call site names which it
// wants.
// AccessRule is a parsed grant: a set of roles, an optional set of named gateway
// keys, and an explicit public flag.
//
// Keys REPLACE roles rather than adding to them. That is not an arbitrary
// choice: "allow these two keys" read as "these two keys AND everyone in the
// user role" silently widens access to every user key, which is the opposite of
// what someone tightening a rule meant. `keys = { "@alice" }` therefore means
// exactly alice.
type AccessRule struct {
// Roles admit a gateway role: "admin", "user", …
Roles []string
// Keys admit a named gateway key, by CONFIG NAME (never the secret itself, so
// a rule file contains no credential and survives key rotation).
Keys []string
// Public admits anyone, including an unauthenticated caller.
Public bool
}
// AccessAll is the grant that admits everyone.
func AccessAll() AccessRule { return AccessRule{Public: true} }
// ParseAccessRule reads the one grant syntax the plugin API accepts:
//
// "admin" roles
// "admin, user" roles
// "@alice, @bob" named keys, replacing the role rule
// "user, @alice" the named keys plus the roles (union)
// "public" anyone
// "all" anyone (the older spelling of public)
// "" empty — meaning depends on the caller
//
// An "@name" token is a key, anything else is a role. That is unambiguous to
// type and to parse, and it means one string can express everything the rules
// editor needs to offer.
//
// "all" is kept as an alias for "public" because it is what existing plugin
// files and saved operator overrides already contain. Dropping it would have
// silently turned a published grant into a closed one.
func ParseAccessRule(s string) AccessRule {
var r AccessRule
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.EqualFold(part, "public") || strings.EqualFold(part, "all") {
r.Public = true
continue
}
if strings.HasPrefix(part, "@") {
if k := strings.TrimSpace(strings.TrimPrefix(part, "@")); k != "" {
r.Keys = append(r.Keys, k)
}
continue
}
r.Roles = append(r.Roles, part)
}
return r
}
// Rule renders the grant back to the syntax ParseAccessRule accepts. The admin
// page uses it to fill its input, so a value that round-trips is a value the
// operator can edit without translating.
func (r AccessRule) Rule() string {
var parts []string
for _, k := range r.Keys {
parts = append(parts, "@"+k)
}
for _, role := range r.Roles {
parts = append(parts, role)
}
if r.Public {
parts = append(parts, "public")
}
return strings.Join(parts, ", ")
}
// Empty reports whether the grant names nobody.
func (r AccessRule) Empty() bool {
return len(r.Roles) == 0 && len(r.Keys) == 0 && !r.Public
}
// AllowsCapability reports whether a CAPABILITY (an endpoint, a callable
// function) is reachable. Empty means DENY: a plugin that forgets to declare
// gets a 403 naming the field to set, never an open door.
//
// Admin is always admitted. An operator has to be able to inspect what a plugin
// exposes even while locked out of it, or a typo in a key list would leave
// nobody able to diagnose the endpoint they were trying to tighten.
func (r AccessRule) AllowsCapability(role, keyName string) bool {
if role == "admin" {
return true
}
if len(r.Keys) > 0 {
// A non-empty key list REPLACES the role rule (see the type comment).
// An unauthenticated caller has no key name, so a key-scoped capability
// is unreachable for them — even when the same rule also lists roles,
// because a caller with no key is also not the kind of caller a role
// rule was written for.
if keyName == "" {
return false
}
for _, k := range r.Keys {
if strings.EqualFold(k, keyName) {
return true
}
}
// The key list replaced the role rule, so a miss is a deny even though
// roles may also be listed.
return false
}
if r.Public {
return true
}
for _, want := range r.Roles {
if strings.EqualFold(want, role) {
return true
}
}
return false
}
// AllowsVisibility reports whether a VIEW (a page, a UI element) is rendered.
// Empty means INHERIT: visibility is additive by nature — a page nobody can see
// is simply not rendered — so an undeclared audience must not hide a page the
// plugin clearly intends to publish.
func (r AccessRule) AllowsVisibility(role string) bool {
if r.Empty() || r.Public {
return true
}
if role == "admin" && len(r.Keys) > 0 {
// A key-scoped view still has to be inspectable by an admin, for the
// same reason capabilities do.
return true
}
for _, want := range r.Roles {
if strings.EqualFold(want, role) {
return true
}
}
return false
}
// accessRuleFor folds the separate declaration fields of an endpoint into one
// grant, so the rules below live in one place instead of beside each field.
//
// It is a plain fold, deliberately. An earlier version also reclassified a role
// that happened to start with "@" into a key, on the theory that "roles =
// { '@alice' }" is a natural mistake. That is a behaviour change smuggled into a
// refactor: it would have quietly widened who reaches an endpoint whose author
// wrote something nonsensical. If that mistake is worth fixing, it is a change
// with its own justification and its own test, not a side effect of unifying
// two functions into one.
func accessRuleFor(roles, keys []string, public bool) AccessRule {
return AccessRule{Roles: roles, Keys: keys, Public: public}
}

View File

@ -0,0 +1,115 @@
package lua
import (
"encoding/json"
"os"
"sort"
)
// Access overrides are OPERATOR policy, not plugin data. They live in their own
// file rather than in each plugin's state because:
//
// - they describe the deployment, not the plugin, so a plugin upgrade that
// replaces its .state.json must not silently widen access again;
// - they must survive a restart. An in-memory map made "restrict billing to
// admin" look like it worked right up to the next deploy, at which point the
// restriction evaporated with nothing in the logs to say why;
// - the plugin_dir is the one place an operator already looks when asking
// "what does this plugin actually get to see", so the answer lives beside it.
//
// The file is a flat map of "name" (whole plugin) or "name#path" (one endpoint)
// to an access spec, which is the same shape the in-memory store uses, so load
// and save cannot disagree about what a key means.
const accessFileName = ".access.json"
func (ps *Plugins) accessFile() string {
if ps.dir == "" {
return ""
}
return ps.dir + string(os.PathSeparator) + accessFileName
}
// LoadAccessOverrides reads the persisted overrides into memory. A missing file
// is not an error: no operator has restricted anything yet, which is the
// default state of a fresh install.
//
// A corrupt file is also not fatal. Losing a restriction is bad, but refusing to
// start the gateway is worse, and an empty map plus a logged error leaves the
// operator with something to fix.
func (ps *Plugins) LoadAccessOverrides() error {
path := ps.accessFile()
if path == "" {
return nil
}
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
m := map[string]string{}
if err := json.Unmarshal(b, &m); err != nil {
return err
}
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.visibility == nil {
ps.visibility = visibilityOverrides{}
}
for k, v := range m {
ps.visibility[k] = v
}
return nil
}
// saveAccessOverridesLocked flushes the whole override map. It is called on
// every change, and unlike plugin state the map is tiny and changes only when an
// operator clicks Save — so a synchronous write costs nothing that matters, and
// it means the change is durable the moment the API returns 200.
func (ps *Plugins) saveAccessOverridesLocked() {
path := ps.accessFile()
if path == "" {
return
}
out := make(map[string]string, len(ps.visibility))
for k, v := range ps.visibility {
if v != "" {
out[k] = v
}
}
if len(out) == 0 {
// Leave no empty file behind: its absence is the honest encoding of
// "nothing is overridden".
_ = os.Remove(path)
return
}
writeJSONAtomic(path, out)
}
// AccessOverrides returns a copy of every override in force, for the UI.
func (ps *Plugins) accessOverridesSnapshot() map[string]string {
ps.mu.RLock()
defer ps.mu.RUnlock()
if len(ps.visibility) == 0 {
return nil
}
out := make(map[string]string, len(ps.visibility))
for k, v := range ps.visibility {
out[k] = v
}
return out
}
// sortedOverrideKeys exists only so a debug dump is stable; map iteration order
// would otherwise make two identical states look different.
func (ps *Plugins) sortedOverrideKeys() []string {
m := ps.accessOverridesSnapshot()
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

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