mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user