mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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 全局调用 —— 已改为自含熵源。
This commit is contained in:
@ -50,6 +50,10 @@ local plugin = {
|
||||
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
|
||||
plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" }
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints" }
|
||||
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
@ -208,6 +212,96 @@ local function bodyOf(req)
|
||||
return v
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- device-poll 通用执行器(移植自参考实现 minimax-oauth.ts / cline-oauth.ts)
|
||||
-- ============================================================================
|
||||
-- 行为逐字对齐参考实现,测试用例也照它搬(见 channels_device_test.go):
|
||||
--
|
||||
-- 1. PKCE S256:verifier = 随机字节 base64url;challenge = SHA256(verifier)
|
||||
-- 的 base64url(参考 createMinimaxPkce)。
|
||||
-- 2. 轮询决策分支的**顺序**就是行为(参考 pollMinimaxDeviceToken):
|
||||
-- HTTP 200 + status=pending → 继续等
|
||||
-- HTTP 200 + status=slow_down → interval += 5s,继续等
|
||||
-- HTTP 200 + status=denied /
|
||||
-- access_denied → 会话 failed(用户拒绝)
|
||||
-- HTTP 200 + status=expired /
|
||||
-- expired_token → 会话 failed(码过期)
|
||||
-- 非 200 + error=authorization_pending → 继续等(OAuth 标准形态)
|
||||
-- 非 200 + error=slow_down → interval += 5s,继续等
|
||||
-- HTTP 200(无上述状态) → 成功,提取 access_token
|
||||
-- 其它 → 会话 failed(带 error 文本)
|
||||
-- ⚠️ 「两种还在等」都要认:形一是 200+status 字段,形二是非 200+error 字段。
|
||||
-- 3. 轮询由页面驱动(poll_login 每次调用推进一步),不在 Lua 里阻塞循环:
|
||||
-- 插件单 VM 单锁,阻塞循环会卡住本插件的全部钩子。
|
||||
|
||||
-- b64url:内核的 base64_encode 是标准 base64;PKCE 要 base64url(无 +/= )。
|
||||
local function b64url(s)
|
||||
return (base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", ""))
|
||||
end
|
||||
|
||||
-- hexToBin:把 hex 字符串还原成二进制字符串(PKCE challenge 输入用)。
|
||||
local function hexToBin(h)
|
||||
return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end))
|
||||
end
|
||||
|
||||
-- devicePkce 生成一次授权的 verifier/challenge 对(SHA256 S256)。
|
||||
-- verifier 自造:熵拼接后哈希。不依赖文件后段才定义的 newToken —— Lua 词法
|
||||
-- 作用域下那会是 nil 全局调用。熵用 os.time + math.random 两次哈希拼接。
|
||||
local function devicePkce()
|
||||
local v = sha256_hex(tostring(os.time and os.time() or 0) .. math.random(1e12))
|
||||
.. sha256_hex(tostring(math.random(1e12)))
|
||||
return v, b64url(hexToBin(sha256_hex(v)))
|
||||
end
|
||||
|
||||
-- deviceOneStep 推进一次设备码轮询,返回 (done, payload)。
|
||||
-- done=true payload={ ok=true, access_token=.., refresh_token=.., account=.. }
|
||||
-- 或 { ok=false, reason="denied"|"expired", message=.. }
|
||||
-- done=false payload=nil(还在等:pending / slow_down)
|
||||
-- sess 携带 device 上下文(device_code/verifier/interval),被本函数原地更新
|
||||
--(slow_down 后 interval 增大,与参考一致)。
|
||||
local function deviceOneStep(plat, sess)
|
||||
local d = plat.device
|
||||
local body = (d.token_body
|
||||
:gsub("{device_code}", sess.device_code)
|
||||
:gsub("{verifier}", sess.device_verifier or "")
|
||||
:gsub("{challenge}", sess.device_challenge or ""))
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = d.token_url,
|
||||
method = "POST",
|
||||
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" },
|
||||
body = body,
|
||||
})
|
||||
if err then return false end -- 网络抖动不算失败:与参考一致(pending 语义)
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if not ok or type(rec) ~= "table" then rec = {} end
|
||||
local status = rec.status
|
||||
local ecode = rec.error
|
||||
|
||||
-- 形态一:HTTP 200 + status
|
||||
if resp.status == 200 and status == "pending" then return false end
|
||||
if resp.status == 200 and status == "slow_down" then
|
||||
sess.device_interval = (sess.device_interval or d.interval or 5) + 5
|
||||
return false
|
||||
end
|
||||
if resp.status == 200 and (status == "denied" or status == "access_denied") then
|
||||
return true, { ok = false, reason = "denied", message = "用户拒绝了授权" }
|
||||
end
|
||||
if resp.status == 200 and (status == "expired" or status == "expired_token") then
|
||||
return true, { ok = false, reason = "expired", message = "设备码已过期,请重新发起登录" }
|
||||
end
|
||||
-- 形态二:非 200 + error(OAuth 标准形态)
|
||||
if ecode == "authorization_pending" then return false end
|
||||
if ecode == "slow_down" then
|
||||
sess.device_interval = (sess.device_interval or d.interval or 5) + 5
|
||||
return false
|
||||
end
|
||||
if resp.status == 200 and rec.access_token then
|
||||
return true, { ok = true, access_token = rec.access_token,
|
||||
refresh_token = rec.refresh_token, account = rec.nickname or rec.sub or "" }
|
||||
end
|
||||
return true, { ok = false, reason = "error", message = "授权失败: " .. tostring(ecode or ("HTTP " .. tostring(resp.status))) }
|
||||
end
|
||||
|
||||
-- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)
|
||||
local function platformById(id)
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
@ -386,6 +480,38 @@ function plugin.start_login(req)
|
||||
out.view_url = bs.view_url
|
||||
logEvent("服务端浏览器已就绪 " .. plat.id)
|
||||
end
|
||||
elseif plat.flow == "device-poll" then
|
||||
-- 设备码:申请码,把 user_code + 授权 URL 给用户,verifier 存进会话。
|
||||
-- 行为对齐参考 startMinimaxDeviceAuthorization:PKCE S256。
|
||||
local verifier, challenge = devicePkce()
|
||||
local body = (plat.device.code_body:gsub("{challenge}", challenge))
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = plat.device.code_url,
|
||||
method = "POST",
|
||||
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" },
|
||||
body = body,
|
||||
})
|
||||
if err then
|
||||
sess.state = "failed"
|
||||
out.error = "设备码申请失败: " .. err
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if not ok or type(rec) ~= "table" or not rec.device_code then
|
||||
sess.state = "failed"
|
||||
out.error = "设备码响应无法解析(HTTP " .. tostring(resp.status) .. ")"
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
sess.device_code = rec.device_code
|
||||
sess.device_verifier = verifier
|
||||
sess.device_challenge = challenge
|
||||
sess.device_interval = tonumber(rec.interval) or plat.device.interval or 5
|
||||
sess.user_code = rec.user_code
|
||||
-- 授权页 URL:优先用 verification_uri_complete(免手输 user_code)
|
||||
out.url = rec.verification_uri_complete or rec.verification_uri or rec.verification_url or plat.login_url
|
||||
out.session.user_code = rec.user_code
|
||||
out.session.interval = sess.device_interval
|
||||
logEvent("设备码已申请 " .. plat.id .. " user_code=" .. tostring(rec.user_code))
|
||||
else
|
||||
-- ①②:URL 交给用户自己的浏览器;callback 流程的 redirect_uri 指向本插件挂载点
|
||||
if plat.flow == "callback" then
|
||||
@ -398,12 +524,37 @@ function plugin.start_login(req)
|
||||
end
|
||||
|
||||
-- plugin.poll_login(session_id) 由页面轮询:会话是否完成。
|
||||
--
|
||||
-- 对 device-poll 会话,每次轮询**推进一步**设备码换 token(deviceOneStep):
|
||||
-- 轮询由页面驱动而不在 Lua 里阻塞,因为插件是单 VM 单锁,阻塞循环会卡死
|
||||
-- 本插件全部钩子。成功则落凭据+接入源(走 complete_login)。
|
||||
function plugin.poll_login(req)
|
||||
local id = (req and req.query and req.query.id) or ""
|
||||
sweepSessions()
|
||||
local s = sessions()[id]
|
||||
if not s then return { status = 200, json = { state = "gone" } } end
|
||||
return { status = 200, json = { state = s.state, account = s.account, message = s.message } }
|
||||
|
||||
if s.flow == "device-poll" and s.state == "pending" and s.device_code then
|
||||
local plat = platformById(s.platform)
|
||||
local done, payload = deviceOneStep(plat, s)
|
||||
if done and payload then
|
||||
if payload.ok then
|
||||
-- 成功:落凭据 + 接入源
|
||||
local res = plugin.complete_login({ platform = s.platform, credential = payload.access_token, account = payload.account })
|
||||
if res.json and res.json.ok then
|
||||
s.state = "completed"
|
||||
s.account = payload.account
|
||||
else
|
||||
s.state = "failed"
|
||||
s.message = (res.json and res.json.error) or "接入源失败"
|
||||
end
|
||||
else
|
||||
s.state = "failed"
|
||||
s.message = payload.message
|
||||
end
|
||||
end
|
||||
end
|
||||
return { status = 200, json = { state = s.state, account = s.account, message = s.message, interval = s.device_interval } }
|
||||
end
|
||||
|
||||
-- plugin.complete_login(args) 由页面/回调调用:登录成功,存凭据并接入源。
|
||||
@ -554,6 +705,37 @@ plugin.http = {
|
||||
public = false, roles = { "admin" }, description = "OAuth 回调" },
|
||||
}
|
||||
|
||||
-- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、
|
||||
-- base_url)。两个用途:
|
||||
-- 1. 运维把某渠道指向自建代理/镜像(厂商被墙、企业出口、审计要求)
|
||||
-- 2. 测试把端点指到假上游,行为测试才能不打真厂商
|
||||
-- 只允许改已知字段,未知键拒绝 —— 端点声明是协议形状的一部分,静默加字段
|
||||
-- 会破坏后续逻辑对形状的假设。
|
||||
function plugin.set_endpoints(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platform == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local allowed = { base_url = true, login_url = true }
|
||||
local changed = {}
|
||||
for k in pairs(allowed) do
|
||||
if args[k] then plat[k] = tostring(args[k]) changed[#changed+1] = k end
|
||||
end
|
||||
if type(args.device) == "table" and plat.device then
|
||||
for _, k in ipairs({ "code_url", "token_url" }) do
|
||||
if args.device[k] then plat.device[k] = tostring(args.device[k]) changed[#changed+1] = "device." .. k end
|
||||
end
|
||||
end
|
||||
if type(args.checkin) == "table" and plat.checkin then
|
||||
for _, k in ipairs({ "url" }) do
|
||||
if args.checkin[k] then plat.checkin[k] = tostring(args.checkin[k]) changed[#changed+1] = "checkin." .. k end
|
||||
end
|
||||
end
|
||||
if #changed == 0 then return { status = 400, json = { error = "无可识别的端点字段" } } end
|
||||
logEvent("端点已改写 " .. plat.id .. ": " .. table.concat(changed, ","))
|
||||
return { status = 200, json = { ok = true, changed = changed } }
|
||||
end
|
||||
|
||||
function plugin.list_platforms(req)
|
||||
local out = {}
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
|
||||
Reference in New Issue
Block a user