@ -803,6 +803,171 @@ local function poolRemove(provider, accountId)
poolAccounts ( provider ) [ accountId ] = nil
end
-- 凭据取值:保险库里存的可能是**裸 token**(历史/device-poll/paste 路径),
-- 也可能是**完整凭据 JSON**( buddy / workbuddy 等 state-poll 平台,对齐参考
-- buildCredential 的结构: access_token + refresh_token + expires_at + domain + ...)。
--
-- 本助手统一取出可用的 access token( 及 refresh_token) , 使所有 HTTP 消费点
-- ( Bearer 头、HMAC 签名)无需关心存的是哪种形态:
-- 裸 token → 原样返回
-- 完整凭据 JSON 字符串 → 取 .access_token
-- 已经是 table → 取 .access_token
-- 返回 (accessToken, refreshToken, fullTableOrNil)。两者都空时返回 (nil, nil, nil)。
local function credentialOf ( ref )
local raw = ref and __llmsproxy_secrets.get ( ref )
if not raw or raw == " " then return nil , nil , nil end
-- 裸 token 快路:不含 '{' 的字符串直接当 token( 绝大多数历史数据)
if type ( raw ) == " string " and raw : sub ( 1 , 1 ) ~= " { " then return raw , nil , nil end
local ok , rec = pcall ( json.decode , type ( raw ) == " string " and raw or " " )
if ok and type ( rec ) == " table " then
local at = rec.access_token or rec.accessToken or rec.token
local rt = rec.refresh_token or rec.refreshToken
if at and at ~= " " then return at , rt , rec end
-- 是 JSON 但没有 access_token: 回退原样( 可能是别的形状的凭据)
return raw , rt , rec
end
return raw , nil , nil
end
-- credentialFull 取完整凭据(供需要 refresh_token/expires_at 的路径用)。
local function credentialFull ( ref )
local _ , _ , full = credentialOf ( ref )
return full
end
-- jwtClaim 从 JWT 的 payload 读一个声明(不验签,仅用于读取展示/续期元数据)。
-- 对齐参考 buddy.ts 的 jwtNickname / jwtExpiresAtMs / jwtSubject:
-- CodeBuddy 的 login/account 响应**不含昵称**,昵称只在 access_token 的声明里;
-- token 端又只返回 expiresIn( 相对秒数) 而不含 expiresAt。两处都得从 JWT 兜底。
-- 解析失败返回 nil( 调用方自行处理) 。
local function jwtClaim ( token , name )
if type ( token ) ~= " string " or token == " " then return nil end
local payload = token : match ( " ^[^%.]+%.([^%.]+) " )
if not payload then return nil end
local bin , derr = base64_decode ( payload ) -- 内核新增: base64url 兼容
if not bin or derr then return nil end
local ok , rec = pcall ( json.decode , bin )
if not ok or type ( rec ) ~= " table " then return nil end
return rec [ name ]
end
-- stripControl 去掉控制字符(含 CR/LF/Tab) 并把连续空白折叠为单个空格。
-- 对齐参考 stripControlChars: CodeBuddy 的 scope 字段有时返回多行文本,
-- 原样落盘会在序列化时被当作多行标量,破坏后续 JSON 解析。
local function stripControl ( v )
if type ( v ) ~= " string " then return v end
v = v : gsub ( " [%z \1 - \31 \127 ]+ " , " " )
v = v : gsub ( " %s%s+ " , " " )
return ( v : gsub ( " ^%s+ " , " " ) : gsub ( " %s+$ " , " " ) )
end
-- readStr 从响应表安全读字符串字段(兼容后端把数字返为数字)。
local function readStr ( rec , key )
if type ( rec ) ~= " table " then return " " end
local v = rec [ key ]
if type ( v ) == " string " then return stripControl ( v ) end
if type ( v ) == " number " then return tostring ( v ) end
return " "
end
-- absExpiryMs 把绝对字段或相对秒数换算为毫秒时间戳字符串。
-- 对齐参考 absoluteExpiryMs:
-- ① 绝对字段( expiresAt/refreshExpiresAt) 存在 → 归一化为毫秒字符串
-- ② 否则用相对秒数( expiresIn/refreshExpiresIn) + access_token 的 JWT iat 兜底
-- ③ 都没有 → 返回空串(由 credentialExpiresAtMs 再从 JWT exp 兜底)
-- 实测: CodeBuddy 的 /v2/plugin/auth/token 只返回 expiresIn, 没有 expiresAt;
-- 不换算的话凭据的 expires_at 永远是空串, UI 一直显示"有效期未知"。
local function absExpiryMs ( rec , absKey , relKey , accessToken )
local absolute = readStr ( rec , absKey )
if absolute ~= " " then
if absolute : match ( " ^%d+$ " ) then
local n = tonumber ( absolute )
if n then
return tostring ( n > 1e12 and n or n * 1000 )
end
end
return absolute
end
local rel = rec [ relKey ]
local relSec = nil
if type ( rel ) == " number " then relSec = rel
elseif type ( rel ) == " string " and rel : match ( " ^%-?%d+%.?%d*$ " ) then relSec = tonumber ( rel ) end
if relSec == nil then return " " end
-- 基准: access_token 的 iat( 签发时刻) 优先, 缺失时用当前时刻。
local iat = jwtClaim ( accessToken , " iat " )
local baseMs = ( type ( iat ) == " number " and iat * 1000 ) or ( os.time and os.time ( ) * 1000 or 0 )
return tostring ( baseMs + relSec * 1000 )
end
-- buildCredential 把 token 响应 + account 响应组合为可持久化的完整凭据 JSON。
-- 逐字段对齐参考 buddy.ts 的 buildCredential / parseTokenData / parseAccountData:
-- access_token / refresh_token / expires_at / refresh_expires_at /
-- token_type / scope / domain / user_id / nickname / enterprise_id / account_type
-- 昵称回填顺序: account.nickname → JWT.nickname → JWT.preferred_username。
-- expires_at 同理: token.expiresAt 为空时由 absExpiryMs 用 expiresIn 或 JWT exp 兜底。
local function buildCredential ( data , accountData )
local access = readStr ( data , " accessToken " )
if access == " " then access = readStr ( data , " access_token " ) end
local refresh = readStr ( data , " refreshToken " )
if refresh == " " then refresh = readStr ( data , " refresh_token " ) end
local tokenType = readStr ( data , " tokenType " )
if tokenType == " " then tokenType = " Bearer " end
local acct = type ( accountData ) == " table " and accountData or { }
local nickname = readStr ( acct , " nickname " )
if nickname == " " then nickname = readStr ( acct , " nickName " ) end
if nickname == " " then
local jn = jwtClaim ( access , " nickname " )
if type ( jn ) ~= " string " or jn == " " then jn = jwtClaim ( access , " preferred_username " ) end
if type ( jn ) ~= " string " or jn == " " then jn = jwtClaim ( access , " name " ) end
if type ( jn ) == " string " then nickname = stripControl ( jn ) end
end
local uid = readStr ( acct , " uid " )
if uid == " " then
local sub = jwtClaim ( access , " sub " )
if type ( sub ) == " string " then uid = sub end
end
local acctType = readStr ( acct , " type " )
if acctType == " " then acctType = " personal " end
return {
access_token = access ,
refresh_token = refresh ,
expires_at = absExpiryMs ( data , " expiresAt " , " expiresIn " , access ) ,
refresh_expires_at = absExpiryMs ( data , " refreshExpiresAt " , " refreshExpiresIn " , access ) ,
token_type = tokenType ,
scope = readStr ( data , " scope " ) ,
domain = readStr ( data , " domain " ) ,
user_id = uid ,
nickname = nickname ,
enterprise_id = readStr ( acct , " enterpriseId " ) ,
account_type = acctType ,
}
end
-- credentialExpiresAtMs 从凭据表解析毫秒过期时刻(兼容毫秒/秒级时间戳/ISO) 。
-- 对齐参考 credentialExpiresAtMs: expires_at 为空时回退解析 access_token 的 JWT exp。
local function credentialExpiresAtMs ( cred )
if type ( cred ) ~= " table " then return nil end
local raw = cred.expires_at
if type ( raw ) == " string " and raw ~= " " then
if raw : match ( " ^%d+$ " ) then
local n = tonumber ( raw )
if n then return n > 1e12 and n or n * 1000 end
end
-- ISO 8601: 解析 "YYYY-MM-DDThh:mm:ss" 形态(无时区偏移时按 UTC)
local Y , Mo , D , H , Mi , S = raw : match ( " ^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d) " )
if Y then return os.time ( { year = tonumber ( Y ) , month = tonumber ( Mo ) , day = tonumber ( D ) ,
hour = tonumber ( H ) , min = tonumber ( Mi ) , sec = tonumber ( S ) , isdst = false } ) * 1000 end
end
local exp = jwtClaim ( cred.access_token , " exp " )
if type ( exp ) == " number " then return exp * 1000 end
return nil
end
-- credentialIsRefreshable 凭据是否携带可静默续期的 refresh_token。
local function credentialIsRefreshable ( cred )
return type ( cred ) == " table " and type ( cred.refresh_token ) == " string " and cred.refresh_token ~= " "
end
-- 模型黑名单(对齐参考 model.setDisabled) : provider → { [modelId] = true }
local function disabledModelsOf ( provider )
if not plugin.state . modelDisabled then plugin.state . modelDisabled = { } end
@ -840,7 +1005,8 @@ local function syncAccountSource(provider, accountId)
local plat = platformById ( provider )
local acct = poolGet ( provider , accountId )
if not plat or not acct then return end
local tok = __llmsproxy_secrets.get ( acct.credentialRef )
-- 凭据可能是裸 token 或完整凭据 JSON; 上游源只吃 access token。
local tok = credentialOf ( acct.credentialRef )
if not tok or tok == " " then return end
local name = accountSourceName ( provider , accountId )
local models = { }
@ -1102,6 +1268,14 @@ end
-- 对 device-poll 会话,每次轮询**推进一步**设备码换 token( deviceOneStep) :
-- 轮询由页面驱动而不在 Lua 里阻塞,因为插件是单 VM 单锁,阻塞循环会卡死
-- 本插件全部钩子。成功则落凭据+接入源(走 complete_login) 。
--
-- ⚠️ **必须把 session 上的 account_id 传给 complete_login**(真实缺陷):
-- 页面流程是「先 account/add 建占位账号( credentialRef=channels:<plat>:<aid>)
-- → login 带 account_id → 轮询」。若这里不带 account_id, complete_login 会走
-- 兼容旧路径,凭据落到 `channels:<plat>` 这个**与占位账号无关的键**,而账号池
-- 条目的 credentialRef 指向 `channels:<plat>:<aid>` —— 于是 account/list 报
-- has_credential=false、credits/balances 报「无凭据」,表现为「登录成功但无法
-- 获取账号信息」。对齐参考 BuddyAuth.login 的 `flowOptions.accountId` 语义。
function plugin . poll_login ( req )
local id = ( req and req.query and req.query . id ) or " "
sweepSessions ( )
@ -1119,7 +1293,10 @@ function plugin.poll_login(req)
s.state = " failed "
s.message = rerr
else
local res = plugin.complete_login ( { platform = s.platform , credential = cred , account = acct or payload.account } )
-- ⚠️ 必须带 account_id: 页面流程先 account/add 建了占位账号,
-- 不带就会落到与占位账号无关的 `channels:<plat>` 键(见 poll_login 顶部注释)。
local res = plugin.complete_login ( { platform = s.platform , credential = cred ,
account = acct or payload.account , account_id = s.account_id } )
if res.json and res.json . ok then
s.state = " completed "
s.account = acct or payload.account
@ -1149,21 +1326,36 @@ function plugin.poll_login(req)
local code = rec.code
local data = rec.data
if resp.status == 200 and data ~= nil and type ( data ) == " table " and ( data.accessToken or data.access_token ) then
-- token 就绪。再拿账号(失败不阻塞,回退默认名)
local tok = data.accessToken or data.access_token
local acct = " codebuddy "
-- token 就绪。再拿账号(失败不阻塞,回退默认名)。
local access = data.accessToken or data.access_token
local accountData = nil
local aresp , aerr = __llmsproxy_http ( {
url = ( sp.account_url or " " ) .. " ?state= " .. tostring ( s.state_token ) ,
method = " GET " ,
headers = { [ " Accept " ] = " application/json " , [ " Authorization " ] = " Bearer " .. tok } ,
headers = { [ " Accept " ] = " application/json " , [ " Authorization " ] = " Bearer " .. access } ,
} )
if not aerr and aresp and aresp.status == 200 then
local aok , arec = pcall ( json.decode , aresp.body or " " )
if aok and type ( arec ) == " table " and type ( arec.data ) == " table " then
acct = tostring ( arec.data . nickName or arec.data . nickname or arec.data . username or acct )
accountData = arec.data
end
end
local res = plugin.complete_login ( { platform = s.platform , credential = tok , account = acct } )
-- ① 组合完整凭据 JSON( 对齐参考 buildCredential) : 带 refresh_token /
-- expires_at / domain / user_id / nickname, 而非只存裸 access_token。
-- 只存裸 token 会丢掉 refresh_token, 导致 refreshable=false、无法静默
-- 续期,且 expires_at 未知。
local credTable = buildCredential ( data , accountData )
local credJson = json.encode ( credTable )
local acct = credTable.nickname
if acct == nil or acct == " " then
acct = credTable.user_id
end
if acct == nil or acct == " " then acct = " codebuddy " end
-- ② 必须带 account_id( 页面流程的占位账号) ; 否则凭据落到无关的
-- `channels:<plat>` 键,账号池查不到。
local res = plugin.complete_login ( { platform = s.platform , credential = credJson ,
account = acct , account_id = s.account_id ,
expires_at = credTable.expires_at , refreshable = credentialIsRefreshable ( credTable ) } )
if res.json and res.json . ok then
s.state = " completed "
s.account = acct
@ -1209,9 +1401,27 @@ function plugin.complete_login(req)
local ref = acct.credentialRef
local _ , serr = __llmsproxy_secrets.set ( ref , token )
if serr then return { status = 400 , json = { error = serr } } end
acct.nickname = args.account or acct.nickname or aid
acct.expiresAt = args.expires_at or acct.expiresAt
acct.refreshable = args.refreshable or acct.refreshable or false
-- 从凭据自动补齐展示/续期字段(调用方漏传也不丢)。
-- 对齐参考 saveCredential: 昵称取 credential.nickname,
-- expiresAt 由 credentialExpiresAtMs 推断( expires_at 或 JWT exp) ,
-- refreshable 看是否携带 refresh_token。
local credTbl = nil
if type ( token ) == " string " and token : sub ( 1 , 1 ) == " { " then
local dok , drec = pcall ( json.decode , token )
if dok and type ( drec ) == " table " then credTbl = drec end
end
local derivedNick = credTbl and credTbl.nickname
if derivedNick == nil or derivedNick == " " then derivedNick = credTbl and credTbl.user_id end
local derivedExp = credTbl and credentialExpiresAtMs ( credTbl )
acct.nickname = args.account or derivedNick or acct.nickname or aid
acct.expiresAt = args.expires_at or ( derivedExp and tostring ( derivedExp ) ) or acct.expiresAt
if args.refreshable ~= nil then
acct.refreshable = args.refreshable and true or false
elseif credTbl ~= nil then
acct.refreshable = credentialIsRefreshable ( credTbl )
else
acct.refreshable = acct.refreshable or false
end
poolAdd ( plat.id , acct )
-- 终结该账号的未决会话
for id , sess in pairs ( sessions ( ) ) do
@ -1228,6 +1438,9 @@ function plugin.complete_login(req)
-- 兼容旧路径: 单账号模式( e2e 与既有端点仍走它)。
local ok , err = __llmsproxy_secrets.set ( " channels: " .. plat.id , token )
if err then return { status = 400 , json = { error = err } } end
-- 上游源只吃 access token: 凭据可能是完整 JSON( buddy 等 state-poll 平台),
-- 必须经 credentialOf 取出 access_token, 不能把整个 JSON 当 api_key 发上去。
local legacyTok = credentialOf ( " channels: " .. plat.id ) or token
plugin.state . accounts [ plat.id ] = {
account = args.account or " 已登录 " ,
@ -1246,7 +1459,7 @@ function plugin.complete_login(req)
name = " channels- " .. plat.id ,
base_url = plat.base_url ,
adapter = " openai " ,
api_key = token ,
api_key = legacyTok ,
max_concurrent = 2 ,
models = ( function ( )
local ms = { }
@ -1286,9 +1499,9 @@ function plugin.claim_daily(req)
if args.account_id and args.account_id ~= " " then
acctEntry = poolGet ( plat.id , args.account_id )
if not acctEntry then return { status = 400 , json = { error = " 账号不存在 " } } end
tok = __llmsproxy_secrets.get ( acctEntry.credentialRef )
tok = credentialOf ( acctEntry.credentialRef )
else
tok = __llmsproxy_secrets.get ( " channels: " .. plat.id )
tok = credentialOf ( " channels: " .. plat.id )
end
if not tok then return { status = 400 , json = { error = " 未登录 " } } end
@ -1357,7 +1570,7 @@ function plugin.auto_claim()
plugin.claim_daily ( { platform = plat.id } )
end
for _ , a in ipairs ( poolList ( plat.id ) ) do
if a.enabled ~= false and __llmsproxy_secrets.get ( a.credentialRef ) then
if a.enabled ~= false and credentialOf ( a.credentialRef ) then
plugin.claim_daily ( { platform = plat.id , account_id = a.id } )
end
end
@ -1503,7 +1716,7 @@ function plugin.account_list(req)
if not plat then return { status = 400 , json = { error = " 未知平台 " } } end
local out = { }
for _ , a in ipairs ( poolList ( plat.id ) ) do
local has = __llmsproxy_secrets.get ( a.credentialRef )
local has = credentialOf ( a.credentialRef )
out [ # out + 1 ] = {
id = a.id , nickname = a.nickname , enabled = a.enabled ~= false ,
credentialRef = a.credentialRef , refreshable = a.refreshable or false ,
@ -1561,7 +1774,7 @@ function plugin.account_test(req)
if not plat then return { status = 400 , json = { error = " 未知平台 " } } end
local acct = poolGet ( plat.id , args and args.account_id )
if not acct then return { status = 400 , json = { error = " 账号不存在 " } } end
local tok = __llmsproxy_secrets.get ( acct.credentialRef )
local tok = credentialOf ( acct.credentialRef )
if not tok or tok == " " then return { status = 200 , json = { ok = false , error = " 无凭据 " } } end
-- 同步一次源(确保最新凭据已入),返回同步结果
syncAccountSource ( plat.id , acct.id )
@ -1582,7 +1795,7 @@ function plugin.account_refresh(req)
if not plat then return { status = 400 , json = { error = " 未知平台 " } } end
local acct = poolGet ( plat.id , args and args.account_id )
if not acct then return { status = 400 , json = { error = " 账号不存在 " } } end
local tok = __llmsproxy_secrets.get ( acct.credentialRef )
local tok = credentialOf ( acct.credentialRef )
if tok and tok ~= " " then syncAccountSource ( plat.id , acct.id ) end
return { status = 200 , json = { ok = true } }
end
@ -1661,7 +1874,7 @@ function plugin.provider_status(req)
for _ , m in ipairs ( p.models or { } ) do if disabled [ m ] then disabledN = disabledN + 1 end end
local closed = ( p.models and # p.models > 0 ) and disabledN == # p.models
local state_accounts = plugin.state . accounts [ p.id ]
local legacyN = ( state_accounts and __llmsproxy_secrets.get ( " channels: " .. p.id ) ) and 1 or 0
local legacyN = ( state_accounts and credentialOf ( " channels: " .. p.id ) ) and 1 or 0
out [ # out + 1 ] = {
id = p.id , name = p.name , flow = p.flow , closed = closed ,
models = { total = # ( p.models or { } ) , disabled = disabledN } ,
@ -1684,10 +1897,11 @@ function plugin.provider_set_enabled(req)
for _ , a in ipairs ( poolList ( plat.id ) ) do a.enabled = true poolAdd ( plat.id , a ) syncAccountSource ( plat.id , a.id ) end
-- 旧路径也启用
local legacy = plugin.state . accounts [ plat.id ]
if legacy and __llmsproxy_secrets.get ( " channels: " .. plat.id ) then
local legacyTok = credentialOf ( " channels: " .. plat.id )
if legacy and legacyTok then
__llmsproxy_kernel.call ( " add_source " , { source = {
name = " channels- " .. plat.id , base_url = plat.base_url , adapter = plat.adapter or " openai " ,
api_key = __llmsproxy_secrets.get ( " channels: " .. plat.id ) , max_concurrent = 2 ,
api_key = legacyTok , max_concurrent = 2 ,
models = ( function ( ) local ms = { } for i , m in ipairs ( plat.models ) do ms [ i ] = { id = m , kind = " chat " , priority = 20 } end return ms end ) ( ) ,
} } )
end
@ -1744,11 +1958,11 @@ function plugin.backup_export(req)
for _ , p in ipairs ( PLATFORMS ) do
for _ , a in ipairs ( poolList ( p.id ) ) do
payload.accounts [ # payload.accounts + 1 ] = a
local tok = __llmsproxy_secrets.get ( a.credentialRef )
local tok = credentialOf ( a.credentialRef )
if tok then payload.credentials [ a.credentialRef ] = tok end
end
-- 旧路径单账号也备份
local lt = __llmsproxy_secrets.get ( " channels: " .. p.id )
local lt = credentialOf ( " channels: " .. p.id )
if lt then payload.credentials [ " channels: " .. p.id ] = lt end
end
return { status = 200 , json = { ok = true , payload = payload } }
@ -1838,7 +2052,7 @@ function plugin.credits_status(req)
local status = nil
local cs = plat.checkin_status
if cs then
local tok = __llmsproxy_secrets.get ( a.credentialRef )
local tok = credentialOf ( a.credentialRef )
if tok then
local rec , err = fetchAccountJSON ( cs , tok )
if rec and plat.checkin_status . status_fn then
@ -1870,7 +2084,7 @@ function plugin.credits_balances(req)
if not b then
entry.error = " 平台未声明余额端点 "
else
local tok = __llmsproxy_secrets.get ( a.credentialRef )
local tok = credentialOf ( a.credentialRef )
if not tok then
entry.error = " 无凭据 "
else
@ -2132,7 +2346,7 @@ function plugin.onboarding_claim(req)
return { status = 400 , json = { error = " 该平台无新人任务 " } }
end
-- raccoon 实测端点: POST {apiBase}/login/points/grant
local tok = __llmsproxy_secrets.get ( " channels: " .. plat.id )
local tok = credentialOf ( " channels: " .. plat.id )
if not tok then return { status = 400 , json = { error = " 未登录 " } } end
local resp , err = __llmsproxy_http ( {
url = plat.base_url .. " /login/points/grant " , method = " POST " ,
@ -2199,7 +2413,7 @@ function plugin.list_platforms(req)
local out = { }
for _ , p in ipairs ( PLATFORMS ) do
local acct = accountOf ( p.id ) or { }
local has = __llmsproxy_secrets.get ( " channels: " .. p.id )
local has = credentialOf ( " channels: " .. p.id )
out [ # out + 1 ] = {
id = p.id , name = p.name , flow = p.flow ,
logged_in = has ~= nil ,