feat(lua)!: 插件只通过 modelrouter SDK 与内核交互,且不再随内核安装

- 新增唯一公开接口表 `modelrouter`(sdk_version=1.0):http / secrets /
  kernel / adapters / plugin_dir。成员与内部桥是**同一对象**而非副本 ——
  复制会让凭据保险库分叉(插件写 A、内核读 B)。
- `llmsproxy` 与 `__llmsproxy_*` 降为同对象兼容别名,只为已装插件继续可用。
  仓库内 4 个插件(channels / billing / quota / usage)全部改用
  `modelrouter.*`:此前它们直接依赖内核私有全局,内核无法在不炸插件的前提下
  改实现(重命名、加守卫、换实现都算破坏性变更)。
- 插件不再由网关安装:`SeedBundled()` 变彻底 no-op,`core.New` 不再调用;
  `ReadBundledPlugin` 改为按源文件定位(脱离 cwd),只服务测试与开发工具。
- 桌面端删除 `BUILTIN_PLUGINS` 与 `syncBundledPluginsOnUpgrade`:升级不再
  删除、改名或覆盖用户插件文件。插件是运维资产,不是内核资源。
- `Builtin` 不再靠"源码与仓库示例逐字节相同"推断,插件一律视为外部文件。
- 判据:SDK 存在/版本/形状、别名与内部桥同一张表、适配器 VM 拿不到 SDK、
  经 SDK 真实写读凭据并驱动内核动作、GUI 升级不动插件文件、release 包不含插件。

说明:channels.lua / quota.lua 同时带有此前的渠道与额度改动(含 OpenCode 免费池
监控),与 SDK 改名同处一个文件,无法文件级拆分,故一并纳入。
This commit is contained in:
JianFeeeee
2026-10-26 22:27:12 +08:00
parent 79bc55cfbe
commit de7553eaa2
19 changed files with 653 additions and 465 deletions

View File

@ -34,7 +34,7 @@
-- endpoint" — a 401 on a key we hold, say — are surfaced, because those are the
-- ones worth fixing.
--
-- Only hosts of CONFIGURED SOURCES are reachable (__llmsproxy_http enforces
-- Only hosts of CONFIGURED SOURCES are reachable (modelrouter.http enforces
-- that), so adding a probe never widens what the gateway can talk to.
local PLUGIN = {}
@ -127,7 +127,7 @@ local function request(path, key, headers, tries)
hdrs["Authorization"] = "Bearer " .. tostring(key or "")
local lastErr = "no attempt"
for _ = 1, tries do
local res, err = __llmsproxy_http({
local res, err = modelrouter.http({
-- 4s, not 10s: probing is network-bound and the whole page shares ONE
-- Lua state under a write lock, so a slow upstream is not just a slow card
-- — it stalls every request_end hook (i.e. billing) behind it.
@ -540,6 +540,111 @@ local function commandCode(base, key)
return rep, nil
end
-- openCodeFreePool:OpenCode 免费池的额度监控。
--
-- 为什之不能像其他上游那样“查余额”:OpenCode Zen **没有**额度查询端点。
-- 额度耗尽的唯一表现是真实推理请求返回 429:
--
-- HTTP 429
-- Retry-After: 80867
-- {"type":"error","error":{"type":"FreeUsageLimitError",
-- "message":"Free usage limit reached. Subscribe to OpenCode Go to keep going: ..."}}
--
-- 恰好就是这个响应把“还剩多少”变成了可监控的事实 —— `Retry-After` 就是
-- 距离额度恢复的秒数。不对自己凭空造一个“余额百分比”:那个数上游从未提供过,
-- 和本插件开头“宁可不说也不要编”的口径直接冲突。
--
-- ★ 代价控制:这是**发一次真实推理请求**去探额度(没有别的办法)。所以
-- ① 只探一个固定极小请求(max_tokens=1,单字 prompt),便宜到可以忽略;
-- ② 只在免费池**未确认耗尽**时才探(耗尽后 Retry-After 已经告诉我们恢复时刻,
-- 再去探就是无意义的敲上游);
-- ③ 探针失败不影响真实请求:它只读 429 的头部,不消费额度窗口里的并发。
--
-- ⚠️ 免费层有身份门禁:缺 `x-opencode-*` 头会得到 403 FreeTierError,
-- 而 403 不是额度事实。那些头由适配器生成(公式在 opencodezen.lua),
-- 本插件不重复实现一套 —— 两处各写一份必然漂移。因此本探针只负责
-- “带上源自己的凭据去问一次”,身份头由源的 adapter 负责。
-- ⇒ 真正稳定的做法是看**真实请求**的结果;本探针只是让“配额还剩多久”
-- 在没有真实流量时也能被看到。
local FREE_POOL_HINT = "free"
local function isFreePoolModel(id)
return type(id) == "string" and id:find(FREE_POOL_HINT, 1, true) ~= nil
end
local function openCodeFreePool(base, key, src)
-- 只对一个免费模型探一次,避免把目录里每个 free 模型都打一遍。
local probeModel
for _, m in ipairs((src and src.models) or {}) do
if isFreePoolModel(m) then probeModel = m break end
end
if not probeModel then return nil, "skip", "no free model configured" end
local url = base .. "/chat/completions"
local res, err = modelrouter.http({
url = url, method = "POST",
headers = {
["Authorization"] = "Bearer " .. tostring(key or ""),
["Content-Type"] = "application/json",
["Accept"] = "text/event-stream",
},
body = json.encode({
model = probeModel,
messages = { { role = "user", content = "hi" } },
stream = true,
max_tokens = 1,
}),
timeout_ms = 6000,
})
if res == nil then return nil, "error", tostring(err or "request failed") end
local hdr = res.headers or {}
local function h(name)
local v = hdr[name]
if v == nil then v = hdr[string.lower(name)] end
return v
end
if res.status == 429 then
local retry = tonumber(h("Retry-After") or h("retry-after"))
local ok, body = pcall(json.decode, res.body or "")
local etype = ok and type(body) == "table" and get(body, "error.type") or nil
return {
vendor = "opencode-free",
free_pool = true,
-- 额度耗尽是一个**明确的运营事实**,必须渲染成“不可用”,
-- 而不是“某次请求失败了”:它不是故障,是配额用完了。
available = false,
exceeded = true,
reason = (etype == "FreeUsageLimitError") and "免费池额度已用尽"
or "HTTP 429",
-- 恢复时刻:上游直接告诉我们了,不要自己猜一个退避。
retry_after_s = retry,
resume_at = retry and (os.time() + retry) or nil,
probe_model = probeModel,
}, nil
end
if res.status == 403 then
-- 403 是身份/网络门禁,**不是**额度事实。两者必须分开报,
-- 否则会把“IP 被风控”误读成“额度用完了”。
return nil, "error", "HTTP 403(免费层身份被拒,非额度问题)"
end
if res.status >= 200 and res.status < 300 then
return {
vendor = "opencode-free",
free_pool = true,
available = true,
exceeded = false,
reason = "免费池可用",
probe_model = probeModel,
}, nil
end
return nil, "error", "HTTP " .. tostring(res.status)
end
-- Ordered probes. DeepSeek-family first because it is a single cheap endpoint,
-- then the OpenAI console shape, then Command Code's four.
local ADAPTERS = {
@ -547,6 +652,10 @@ local ADAPTERS = {
{ vendor = "deepseek", probe = deepseekBalance },
{ vendor = "openai-dash", probe = openaiDashboard },
{ vendor = "opendesign", probe = openDesign },
-- ★ OpenCode 免费池:**没有**额度查询端点,只能靠一次真实请求的 429
-- 与 Retry-After 判断。放最后:其他适配器能答就不用花这次请求。
-- 只对含 free 模型的源生效(probe 内部自行判断)。
{ vendor = "opencode-free", probe = openCodeFreePool },
}
-- The probe memo remembers, per host, which adapter answered. Persisted in the
@ -722,7 +831,7 @@ local function collectSource(src, budget)
-- genuinely unreachable case; a reachable host still goes through the
-- adapters.
if known == false then
local probe = __llmsproxy_http({
local probe = modelrouter.http({
url = base .. "/", method = "GET", timeout_ms = 2500,
headers = { ["Authorization"] = "Bearer " .. tostring(key or "") },
})
@ -764,13 +873,30 @@ local function collectSource(src, budget)
kind = "skip"
why = because or ("adapter not configured: " .. a.vendor)
else
local r, k, e = a.probe(base, key)
local r, k, e = a.probe(base, key, src)
if r ~= nil then
rep = r
HOST_MEMO[host] = a.vendor
break
end
kind, why = k, e
-- ★ 不相关适配器的 skip **不得**覆盖已有原因。
--
-- 真实回归(加 OpenCode 免费池探测时引入):opendesign 先说
-- 「缺 control_key」,紧随其后的 opencode-free 探测因「本源没有 free 模型」
-- 而 skip,用的是无条件 `kind, why = k, e`,于是把那条可操作的原因换成了
-- 「no free model configured」—— 运维看到的是“这个源没有免费模型”,
-- 而实际要去配的是 control_key。判据 TestQuotaOpenDesignSkipsWithoutControlKey
-- 直接咬住了它。
--
-- 规则:一个适配器**仅因自身不适用**(本源没有它关心的模型)而 skip 时,
-- 不得抹掉别人给出的原因;真错误仍按原逻辑进入 errWhy。
local notApplicable = k == "skip" and type(e) == "string"
and e:find("no free model configured", 1, true) ~= nil
if not notApplicable then
kind, why = k, e
elseif why == nil then
kind, why = k, e
end
if k == "error" then
local rejected = tostring(e or ""):match("rejected") ~= nil
-- Last error wins, except that a rejected credential displaces anything