mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
9972 lines
479 KiB
Lua
9972 lines
479 KiB
Lua
-- channels:多平台账号接入(登录认证 → 凭据 → 自动领取额度 → 注册为上游源)
|
||
--
|
||
-- 为什么需要它
|
||
-- ------------
|
||
-- 网关的上游源(sources)一直是**运维手工配置**的:填 base_url、填 api_key。
|
||
-- 而现实里有大量平台是"注册个账号、每天领免费额度"的形态,它们的接入方式是
|
||
-- **登录**而不是"申请一个 key"。这类平台要接入网关,就需要:驱动登录 → 拿到
|
||
-- 凭据 → 定时领取额度 → 把端点当源用。
|
||
--
|
||
-- 本插件把这套流程做成通用的:一个插件内建平台表,每个平台声明自己的
|
||
-- 登录方式(link-poll / callback / paste)、额度领取接口、上游端点形状。
|
||
-- 新增一个平台是往 PLATFORMS 里加一条,不改内核。
|
||
--
|
||
-- 设计边界(与内核的约定)
|
||
-- ------------------------
|
||
-- 内核提供基础设施,插件自己不实现这些:
|
||
--
|
||
-- __llmsproxy_http 受出站白名单约束的 HTTP(登录/领额度都走它)
|
||
-- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读)
|
||
-- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…)
|
||
--
|
||
-- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状、模型清单。
|
||
-- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"——
|
||
-- 内核里出现厂商名就是分层被破坏了。登录会话、服务端浏览器栈也全归插件自持。
|
||
--
|
||
-- 权限(本插件需要,全部可在设置页看到"申请中")
|
||
-- ------------------------------------------------
|
||
-- add_source 把平台端点注册为上游源
|
||
-- list_sources 显示当前已接入的平台
|
||
-- probe_all 接入后立刻验证可达
|
||
-- reset_health 上游恢复后清冷却
|
||
-- status 面板显示运行状态
|
||
--
|
||
-- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被
|
||
-- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。
|
||
--
|
||
-- 登录会话归本插件自持(内核无"登录"概念):会话表在 plugin.state.sessions,
|
||
-- 带 nonce 与过期时间;浏览器栈(Xvfb/chromium/x11vnc/websockify)由本插件用
|
||
-- os.execute 直接拉起并记录 pid,插件卸载/会话结束时自己回收。
|
||
-- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化流程(轮询/回调)
|
||
-- → ③ VNC 无头浏览器兜底(无浏览器系统 / 自动化也无法完成的场景)。
|
||
|
||
local plugin = {
|
||
name = "channels",
|
||
version = "1.0.0",
|
||
description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源",
|
||
author = "llmsproxy",
|
||
}
|
||
|
||
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
|
||
plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" }
|
||
|
||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||
|
||
|
||
|
||
-- ============================================================================
|
||
-- 平台表
|
||
-- ============================================================================
|
||
--
|
||
-- 每个平台声明登录方式与各端点。字段:
|
||
-- flow link-poll(给 URL 轮询)/ callback(OAuth 重定向回来带 code)/
|
||
-- device-poll(设备码:先申请码、给用户 URL、再轮询换 token)/
|
||
-- paste(用户粘贴 cookie/token)
|
||
-- login_url 用户要在自己浏览器打开的地址
|
||
-- base_url 接入网关后对话走的 OpenAI 兼容端点(adapter 决定协议形状)
|
||
-- adapter 内核适配器名(openai / anthropic / …)
|
||
-- models fallback 模型清单(登录后可用 refresh 动态拉取覆盖)
|
||
-- checkin 每日额度领取:{ url, method, body, auth }(可缺省=不支持签到)
|
||
--
|
||
-- 新增平台 = 加一条。不改内核,也不改本文件其它部分。
|
||
--
|
||
-- 注意:真实厂商端点逆向自参考实现(dsh-our-free-model/vendor/channel-pack)。
|
||
-- 仅在运维自己的网关、自己的账号范围内使用;法律风险运维自负(已确认)。
|
||
-- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua
|
||
-- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。
|
||
-- 前向声明:本文件里**所有**会被「更早定义的代码」引用的 local 助手都要在此
|
||
-- 统一占坑。Lua 的 local 作用域只覆盖其后的代码 —— 漏一个就是运行时的
|
||
-- "attempt to call global 'x' (a nil value)",而编译期完全静默(本文件已踩多次:
|
||
-- qoderMachineIdentity / rfc3986Escape / newToken / lobsteraiClientVersion /
|
||
-- traeCallback 各一次)。
|
||
local qoderMachineIdentity, rfc3986Escape, newToken
|
||
local lobsteraiClientVersion
|
||
|
||
-- 前向声明(本文件第 10 次踩 local 前向引用):余额分档选号在前、具体抓取在后。
|
||
-- expiringWindowDays / fetchAccountJSON / fetchBalanceOf 都定义在下方。
|
||
local expiringWindowDays
|
||
local fetchAccountJSON
|
||
|
||
local PLATFORMS = {
|
||
-- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)=====
|
||
-- -- 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的
|
||
-- 注意:占位平台)绝不能内联到这里:本目录会被 UI / status / list_platforms 直接
|
||
-- 展示给运维,夹具写进来就是「生产 UI 里凭空多出 4 个假平台」。
|
||
-- 夹具由测试经 channels/inject_platforms 端点运行时注入。
|
||
|
||
-- TRAE(字节跳动)—— callback 登录 + ExchangeToken 续期 + 每日签到。
|
||
-- 这是最标准的一条,作为"同类厂商"的实现模板。
|
||
{
|
||
id = "trae",
|
||
name = "TRAE(字节)",
|
||
vendor = "bytedance",
|
||
flow = "callback",
|
||
-- 授权页(照参考 buildTraeLoginUrl 的完整参数集)。
|
||
-- 注意参数名必须是 **auth_callback_url**(参考注释点名:早期用了
|
||
-- redirect_uri/auth_callback 等错名,TRAE 拿不到回调地址、登录必然失败)。
|
||
-- 端口用 {port} 占位,实际拼装时填本机监听端口。
|
||
authorize = {
|
||
base = "https://www.trae.cn/authorization",
|
||
params = {
|
||
login_version = "1", auth_from = "solo", login_channel = "native_ide",
|
||
plugin_version = "2.3.62834", auth_type = "local",
|
||
client_id = "en1oxy7wnw8j9n", redirect = "0",
|
||
-- Trae 注册的回调路径是 /authorize(参考 trae-oauth.ts:565
|
||
-- `http://127.0.0.1:${port}/authorize`)—— 换成别的路径授权页会拒绝。
|
||
-- loopback 接收器会把它变成 http://127.0.0.1:{实际端口}/authorize。
|
||
auth_callback_url = "{redirect_uri}",
|
||
-- 设备身份与反查凭据:参考 trae-oauth.ts 列为「一个都不能少」——
|
||
-- login_trace_id 是回调**反查 pending** 的唯一凭据;machine_id/device_id
|
||
-- 缺席可能被风控拦;它们还会写进凭据供签到发 X-Device-Id(day 轮换躲 9074)。
|
||
login_trace_id = "{trace_id}",
|
||
machine_id = "{machine_id}", device_id = "{device_id}",
|
||
x_device_id = "{device_id}", x_machine_id = "{machine_id}",
|
||
x_device_brand = "PC", x_device_type = "PC", x_os_version = "1.0",
|
||
x_app_version = "0.1.52", x_app_type = "stable",
|
||
},
|
||
},
|
||
hosts = { "www.trae.cn", "api.trae.com.cn", "api.trae.cn", "trae-api-cn.mchost.guru" },
|
||
-- trae 非 OpenAI 兼容:端点是 SOLO 的 /api/agent/v3/llm_utils_chat,
|
||
-- 载荷须转 config_name/function 格式(见 trae-adapter.ts:374)。
|
||
base_url = "https://trae-api-cn.mchost.guru",
|
||
-- TRAE 非 OpenAI 兼容,用**本插件自装**的 SOLO 适配器
|
||
-- (见 TRAE_SOLO_ADAPTER_SRC)。端点由适配器声明 /api/agent/v3/llm_utils_chat。
|
||
adapter = "channels-trae",
|
||
-- 照参考 TRAE_FALLBACK_MODELS 全量(32 项)。其中 4 项带 isHidden
|
||
-- (内部子 agent)—— 不进用户可选列表,单列在 models_hidden。
|
||
models = {
|
||
"DeepSeek-V4-Flash-Official", "Doubao-Seed-2.1-Pro", "seed-code-pro-0430",
|
||
"Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.0-Code", "glm-5.2", "glm-5-turbo",
|
||
"glm-5", "DeepSeek-V4-Pro", "DeepSeek-V4-Flash", "kimi-k3", "kimi-k2.7-code",
|
||
"kimi-k2.6", "minimax-m3", "qwen-3.7-plus", "sagitta", "aquila",
|
||
"custom_model_gemini", "custom_model_placeholder", "custom_model_1M_text",
|
||
"custom_model_1M", "custom_model_kimi", "custom_model_claude",
|
||
"custom_model_gpt-5", "custom_model_no-fc", "custom_model_deepseek_chat",
|
||
"custom_model_deepseek_reasoner", "custom_model_deepseek_v4",
|
||
},
|
||
models_hidden = {
|
||
"browser_use_subagent", "explore_sub_agent_v13", "explore_sub_agent_v2", "summary",
|
||
},
|
||
-- 每日签到:POST /trae/api/v2/ug/checkin_credits/claim body {} 到 UG 主机。
|
||
checkin = {
|
||
url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim",
|
||
method = "POST", body = "{}",
|
||
-- 授权方案是 Cloud-IDE-JWT(不是 Bearer),且参考注释点明:
|
||
-- 简化头实测签到失败,必须带基于 user_id 派生的设备身份头(见 traeCheckinHeaders)。
|
||
auth = "cloud-ide-jwt",
|
||
},
|
||
-- 签到状态(对齐参考:POST /checkin_credits/status → {checked_in, credits, enable})
|
||
checkin_status = {
|
||
url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/status",
|
||
method = "POST", body = "{}",
|
||
status_fn = function(rec)
|
||
return {
|
||
active = rec.enable == true,
|
||
todayCheckedIn = rec.checked_in == true,
|
||
dailyCredit = tonumber(rec.credits) or 0,
|
||
}
|
||
end,
|
||
},
|
||
-- 余额(对齐参考:POST /trae/api/v2/pay/ide_user_ent_usage → remain = Σ(credits_limit - credits_amount))
|
||
balance = {
|
||
url = "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage",
|
||
method = "POST", body = '{"require_usage":true,"req_source":2}',
|
||
total_fn = function(rec)
|
||
local sum = 0
|
||
local list = rec.user_entitlement_pack_list or rec.userEntitlementPackList or {}
|
||
if type(list) ~= "table" then return 0 end
|
||
for _, p in ipairs(list) do
|
||
local lim = tonumber(p.credits_limit) or tonumber(p.creditsLimit) or 0
|
||
local amt = tonumber(p.credits_amount) or tonumber(p.creditsAmount) or 0
|
||
sum = sum + math.max(0, lim - amt)
|
||
end
|
||
return sum
|
||
end,
|
||
},
|
||
-- callback 解析模式(移植自参考 trae-oauth.ts parseTraeCallbackDetailed):
|
||
-- trae token 直传:query 带 refreshToken / userJwt(JSON) / userInfo(JSON)。
|
||
-- refreshToken 存在时调 ExchangeToken 换新(轮换旧 refresh);
|
||
-- 无 refreshToken 用 userJwt.Token 兑底;
|
||
-- 只有 code/authCodeInfo 时是 PKCE 流 → 明确报"暂不支持"而不是含糊失败。
|
||
callback = {
|
||
mode = "trae",
|
||
exchange_url = "https://api.trae.com.cn/cloudide/api/v3/trae/oauth/ExchangeToken",
|
||
exchange_body = '{"ClientID":"{client_id}","RefreshToken":"{refresh_token}","ClientSecret":"-","UserID":""}',
|
||
client_id = "", -- 参考用 product.clientId;TRAE 回调直传形态不强制,留空
|
||
},
|
||
},
|
||
|
||
-- MiniMax —— PKCE 设备码流(RFC 8628)。三步:申请码 → 用户授权 → 轮询换
|
||
-- token。全程纯 HTTP,无需本地服务器;user_code 显示给用户输到授权页。
|
||
{
|
||
id = "minimax",
|
||
name = "MiniMax",
|
||
vendor = "minimax",
|
||
flow = "device-poll",
|
||
device = {
|
||
-- 申请设备码:PKCE S256
|
||
code_url = "https://account.minimax.cn/oauth2/device/code",
|
||
code_body = "client_id=mcode-public&scope=agent.default&audience=agent-backend"
|
||
.. "&code_challenge={challenge}&code_challenge_method=S256",
|
||
-- 轮询换 token
|
||
token_url = "https://account.minimax.cn/oauth2/token",
|
||
token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
|
||
.. "&device_code={device_code}&client_id=mcode-public&code_verifier={verifier}",
|
||
interval = 5, -- 轮询间隔秒
|
||
slow_down_guard = true, -- 响应 authorization_pending / slow_down 按标准处理
|
||
},
|
||
hosts = { "account.minimax.cn", "agent.minimax.cn" },
|
||
base_url = "https://agent.minimax.cn",
|
||
-- 远端模型目录(照参考 minimax-product.ts MINIMAX_MODELS_PATH)
|
||
models_remote = {
|
||
endpoints = { { url = "https://www.minimaxi.com/mavis/api/v1/models", method = "GET" } },
|
||
auth = "bearer",
|
||
parse = "id_list",
|
||
},
|
||
adapter = "openai",
|
||
-- 照参考 MINIMAX_FALLBACK_MODELS(4 项)。远端权威:
|
||
-- GET /mavis/api/v1/models?region=cn&buildEnv=prod
|
||
models = { "MiniMax-M3.1-Flash-Preview", "MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7" },
|
||
-- 每日签到(两步:状态预检 + 领取;幂等靠服务端状态)
|
||
checkin = {
|
||
url = "https://agent.minimax.cn/minimax-cloud/api/v1/signin/claim",
|
||
method = "POST", body = "{}",
|
||
},
|
||
-- 签到状态预检(对齐参考 minimax-credits.ts,面板显示今日是否已签到)
|
||
checkin_status = {
|
||
url = "https://agent.minimax.cn/minimax-cloud/api/v1/signin/status",
|
||
method = "GET",
|
||
},
|
||
-- 余额(对齐参考:GET /credit/details,total = Σ details[].remaining_amount)
|
||
balance = {
|
||
url = "https://agent.minimax.cn/minimax-cloud/api/v1/credit/details",
|
||
method = "GET",
|
||
total_fn = function(rec)
|
||
-- 注意 total_count 是记录条数不是余额(参考注释踩过的坑)。余额=Σ remaining_amount。
|
||
if type(rec) ~= "table" or type(rec.details) ~= "table" then return 0 end
|
||
local sum = 0
|
||
for _, d in ipairs(rec.details) do
|
||
sum = sum + (tonumber(d.remaining_amount) or 0)
|
||
end
|
||
return sum
|
||
end,
|
||
},
|
||
},
|
||
|
||
-- Cline —— WorkOS 设备码流(照参考 cline-oauth.ts / cline-product.ts 逆向)。
|
||
-- 与 MiniMax 同为 device-poll,但判据形态完全不同(参考注释反复强调):
|
||
-- 判据看响应体 error 字段(HTTP 可能非 2xx),不看 status 字段;
|
||
-- slow_down 累积退避 +1 秒(WorkOS 会持续限流不退避的客户端);
|
||
-- 成功后还有第二步 register:拿 WorkOS token 换 Cline 自己的 token
|
||
-- (POST /api/v1/auth/register,body 驼峰 accessToken/refreshToken)。
|
||
{
|
||
id = "cline",
|
||
name = "Cline",
|
||
vendor = "cline",
|
||
flow = "device-poll",
|
||
device = {
|
||
code_url = "https://api.workos.com/user_management/authorize/device",
|
||
code_body = "client_id=client_01K3A541FN8TA3EPPHTD2325AR",
|
||
token_url = "https://api.workos.com/user_management/authenticate",
|
||
token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
|
||
.. "&device_code={device_code}&client_id=client_01K3A541FN8TA3EPPHTD2325AR",
|
||
interval = 5,
|
||
-- WorkOS 形态:判 error 字段;slow_down 退避 1 秒;access_denied/invalid_grant 同组
|
||
judge = "workos",
|
||
slow_down_step = 1,
|
||
},
|
||
-- 第二步:WorkOS token → Cline token(挂 apiBase)
|
||
register = {
|
||
url = "https://api.cline.bot/api/v1/auth/register",
|
||
method = "POST",
|
||
body = '{"accessToken":"{access_token}","refreshToken":"{refresh_token}"}',
|
||
-- 静默续期(照参考 cline.ts)。
|
||
-- ⚠️ 三个字段名陷阱,写错服务端只回泛化的认证失败、极难定位:
|
||
-- ① 请求体是**驼峰** `refreshToken` + `grantType`(不是 OAuth 的
|
||
-- refresh_token/grant_type);
|
||
-- ② 响应套 `{success,data}` 信封;
|
||
-- ③ 响应字段是**驼峰** `accessToken`。
|
||
refresh = {
|
||
style = "cline",
|
||
url = "https://api.cline.bot/api/v1/auth/refresh",
|
||
},
|
||
},
|
||
hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" },
|
||
-- cline 的 chat 路径是 /api/v1/chat/completions(参考 CLINE_CHAT_PATH)。
|
||
base_url = "https://api.cline.bot/api/v1/chat/completions",
|
||
-- 远端模型目录(照参考 cline-models.ts:**必须同时打两个端点**)。
|
||
-- 关键:/api/v1/models 的 460 个 id 里**没有** cline-free/*,免费模型只由
|
||
-- recommended-models 下发;只打前者会看不到任何免费模型。
|
||
models_remote = {
|
||
endpoints = {
|
||
{ url = "https://api.cline.bot/api/v1/ai/cline/recommended-models", method = "GET", url_auth = false },
|
||
{ url = "https://api.cline.bot/api/v1/models", method = "GET" },
|
||
},
|
||
auth = "bearer",
|
||
parse = "cline_merge",
|
||
},
|
||
-- Cline 的 429 把等待时长写在**英文句子**里(`Try again in 19h 39m`),
|
||
-- 既无 retry-after 头也无绝对时刻。通用适配器解析不出 ⇒ 内核按 30 秒
|
||
-- 短冷却处理 ⇒ 每 30 秒重试、永远失败(在猛敲上游)。
|
||
-- 故用本插件自装的 channels-cline 把文案归一成内核已认的中立短语。
|
||
adapter = "channels-cline",
|
||
-- 命名约定:`cline-free/*` 是**免费通道**的独立 id(照参考
|
||
-- cline-models.ts 的 isFree 兜底规则)。远端 free 数组未响应时靠它判免费。
|
||
free_prefix = "cline-free/",
|
||
-- 这 3 个来自远端 free 数组(内嵌目录里没有),**它们是免费的**
|
||
-- (此前误放在 models 里 ⇒ 被标成收费,与注释自相矛盾)。
|
||
models = {
|
||
"stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor",
|
||
},
|
||
-- 显式声明免费集合:这是**兜底**(远端 free 数组是权威,但可能拉不到)。
|
||
-- 三者都在这里 ⇒ 即使远端抖动也不会把免费模型误标成收费。
|
||
models_free = {
|
||
"stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor",
|
||
},
|
||
-- 参考 cline-credits.ts:sidecar 里扫不到 checkin/campaign 端点 —— 无每日领取。
|
||
-- 余额(对齐参考 cline-quota.ts:GET usage-limits,单位 dollars,show percentUsed)
|
||
credit_unit = "dollars",
|
||
balance = {
|
||
url = "https://api.cline.bot/api/v1/users/me/plan/usage-limits",
|
||
method = "GET",
|
||
total_fn = function(rec)
|
||
local d = rec.data or rec
|
||
local limits = d.limits or {}
|
||
if type(limits) ~= "table" then return 0 end
|
||
-- 取剩余百分比最小的那个限额的 (100-percentUsed);没有就 0
|
||
local minRemain
|
||
for _, l in ipairs(limits) do
|
||
local used = tonumber(l.percentUsed or l.percent_used) or 0
|
||
local rem = 100 - used
|
||
if not minRemain or rem < minRemain then minRemain = rem end
|
||
end
|
||
return minRemain or 0
|
||
end,
|
||
},
|
||
},
|
||
|
||
-- Buddy(腾讯 CodeBuddy)—— state 轮询流(照参考 buddy-oauth.ts 逆向)。
|
||
-- 两步:POST /v2/plugin/auth/state → {state, authUrl};用户在 authUrl 完成登录后
|
||
-- 插件轮询 GET /v2/plugin/auth/token?state=...。判据:业务码 11217 = 未就绪继续等;
|
||
-- 网络错误同样继续轮询不中断(对齐 Rust loop_get_token)。token 就绪后再拿账号。
|
||
{
|
||
id = "buddy",
|
||
name = "CodeBuddy(腾讯)",
|
||
vendor = "tencent",
|
||
flow = "state-poll",
|
||
state = {
|
||
state_url = "https://copilot.tencent.com/v2/plugin/auth/state?platform=ide",
|
||
token_url = "https://copilot.tencent.com/v2/plugin/auth/token",
|
||
account_url = "https://copilot.tencent.com/v2/plugin/login/account",
|
||
code_not_ready = 11217,
|
||
interval = 1,
|
||
},
|
||
hosts = { "copilot.tencent.com", "www.codebuddy.cn" },
|
||
-- buddy 的 chat 端点含 /v2 前缀(参考 CHAT_API_BASE = .../v2 + /chat/completions)。
|
||
base_url = "https://copilot.tencent.com/v2/chat/completions",
|
||
-- 远端模型目录(照参考 buddy.ts CONFIG_PATH + buddy-oauth.ts fetchModels)。
|
||
-- 关键:必须带 product 身份头,否则 WorkBuddy 会以 CodeBuddy 身份请求。
|
||
models_remote = {
|
||
endpoints = { { url = "https://copilot.tencent.com/v3/config", method = "GET" } },
|
||
auth = "bearer",
|
||
headers_fn = true,
|
||
parse = "buddy_config",
|
||
},
|
||
adapter = "openai",
|
||
-- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。
|
||
-- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。
|
||
-- X-Domain 以产品配置为准(不用凭据里的 domain 快照,那是登录时的区域,
|
||
-- 跨产品迁移后会指错区);X-Product 是部署类型(SaaS);
|
||
-- X-Product-Code 是产品码(codebuddy);X-User-Id 取凭据的 user_id。
|
||
headers_fn = function(accessToken, cred)
|
||
local h = {
|
||
["Authorization"] = "Bearer " .. accessToken,
|
||
["Accept"] = "application/json",
|
||
["Content-Type"] = "application/json",
|
||
["X-Domain"] = "copilot.tencent.com",
|
||
["X-Product"] = "SaaS",
|
||
["X-Product-Code"] = "codebuddy",
|
||
["User-Agent"] = "CodeBuddyIDE/1.106.1",
|
||
}
|
||
local uid = cred and (cred.user_id or cred.uid)
|
||
if type(uid) == "string" and uid ~= "" then h["X-User-Id"] = uid end
|
||
local eid = cred and cred.enterprise_id
|
||
if type(eid) == "string" and eid ~= "" then
|
||
h["X-Enterprise-Id"] = eid
|
||
h["X-Tenant-Id"] = eid
|
||
end
|
||
return h
|
||
end,
|
||
models = {
|
||
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x",
|
||
"glm-5.3", "glm-5.3-flash", "glm-5.2",
|
||
},
|
||
-- 签到状态(对齐参考 credits.ts:必须用 checkin-activity-status,不能用
|
||
-- checkin-status —— 后者返回占位数据 active:false,是个坑)
|
||
checkin_status = {
|
||
url = "https://copilot.tencent.com/v2/billing/meter/checkin-activity-status",
|
||
method = "POST", body = "{}",
|
||
status_fn = function(rec)
|
||
local d = rec.data or rec
|
||
return {
|
||
active = d.active == true,
|
||
todayCheckedIn = d.todayCheckedIn == true or d.today_checked_in == true,
|
||
streakDays = tonumber(d.streakDays or d.streak_days) or 0,
|
||
todayCredit = tonumber(d.todayCredit or d.today_credit) or 0,
|
||
}
|
||
end,
|
||
},
|
||
-- 余额(对齐参考 USER_RESOURCE_PATH /v2/billing/meter/get-user-resource)
|
||
-- -- 注意响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层
|
||
-- data 不同),这是本接口最容易解析错的地方。
|
||
-- -- 注意刻意**不用**服务端的 TotalDosage:它是**终身口径**且取整(实测同一
|
||
-- 响应里 TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts[]
|
||
-- 现算:只累加**有效包**的 CycleCapacityRemain(失效包服务端仍会返回,
|
||
-- 但不能用于扣费,并进总额会让数字虚高),最后按两位小数规整。
|
||
balance = {
|
||
url = "https://copilot.tencent.com/v2/billing/meter/get-user-resource",
|
||
method = "POST", body = "{}",
|
||
-- tier_fn:把余额拆成「快到期 / 永久」两桶(照参考
|
||
-- buddy-balance-rank.ts:splitBuddyCreditsByExpiry)。口径必须与 total_fn
|
||
-- 一致 —— 两条路各算一遍必然会漂移,故共用同一套包遍历规则:
|
||
-- * **只算 remaining**(CycleCapacityRemain,本周期剩余),与 IDE 顶部
|
||
-- 的 Credits Balance 一致。刻意不用 TotalDosage:那是**终身口径**
|
||
-- 且取整(实测同一响应 TotalDosage=655 而 IDE 显示 155.67)。
|
||
-- * `active === false` 的包一律**跳过**:失效包服务端仍会返回余额,
|
||
-- 并进任何一桶都会虚增可用额度。
|
||
-- * 到期时间**未知**归入**永久**桶 —— 保守方向:最坏是锁定时少用一个号,
|
||
-- 而不是误把长期积分当快到期烧掉。
|
||
tier_fn = function(rec)
|
||
local data = rec.data
|
||
if type(data) ~= "table" then return nil end
|
||
local resp = data.Response
|
||
if type(resp) ~= "table" then return nil end
|
||
local inner = resp.Data
|
||
if type(inner) ~= "table" then return nil end
|
||
local accounts = inner.Accounts
|
||
if type(accounts) ~= "table" then return nil end
|
||
local now = os.time and os.time() or 0
|
||
local window = expiringWindowDays() * 86400
|
||
local expiring, permanent = 0, 0
|
||
for _, pkg in ipairs(accounts) do
|
||
if type(pkg) == "table" then
|
||
local active = true
|
||
local st = pkg.Status
|
||
if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end
|
||
local ded = tonumber(pkg.DeductionEndTime)
|
||
local endSec = nil
|
||
if ded and ded > 0 then
|
||
endSec = math.floor(ded / 1000)
|
||
if now >= endSec then active = false end
|
||
end
|
||
local remain = tonumber(pkg.CycleCapacityRemain) or 0
|
||
if active and remain > 0 then
|
||
-- 到期时间已知且落在窗口内 → 临时桶;未知 → 永久桶(保守)
|
||
if endSec and (endSec - now) < window then
|
||
expiring = expiring + remain
|
||
else
|
||
permanent = permanent + remain
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return { expiring_balance = expiring, permanent_balance = permanent }
|
||
end,
|
||
total_fn = function(rec)
|
||
local data = rec.data
|
||
if type(data) ~= "table" then return nil end
|
||
local resp = data.Response
|
||
if type(resp) ~= "table" then return nil end
|
||
local inner = resp.Data
|
||
if type(inner) ~= "table" then return nil end
|
||
local accounts = inner.Accounts
|
||
if type(accounts) ~= "table" then return nil end
|
||
local now = os.time and os.time() or 0
|
||
local sum = 0
|
||
for _, pkg in ipairs(accounts) do
|
||
if type(pkg) == "table" then
|
||
-- 失效判定:Status 显式已过期,或 ExpiredTime / DeductionEndTime 已过。
|
||
local active = true
|
||
local st = pkg.Status
|
||
if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end
|
||
local function past(t)
|
||
if type(t) ~= "string" or t == "" then return false end
|
||
local Y, Mo, D, H, Mi, S = t:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)")
|
||
if Y then
|
||
local ts = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D),
|
||
hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false })
|
||
return ts and now >= ts
|
||
end
|
||
return false
|
||
end
|
||
if past(pkg.ExpiredTime) then active = false end
|
||
local ded = tonumber(pkg.DeductionEndTime)
|
||
if ded and ded > 0 then
|
||
-- 毫秒时间戳
|
||
if now >= math.floor(ded / 1000) then active = false end
|
||
end
|
||
if active then
|
||
sum = sum + (tonumber(pkg.CycleCapacityRemain) or 0)
|
||
end
|
||
end
|
||
end
|
||
-- 两位小数规整(服务端精确值带浮点噪声,如 55.67000031)
|
||
return math.floor(sum * 100 + 0.5) / 100
|
||
end,
|
||
},
|
||
},
|
||
|
||
-- Gemini(Google Cloud Code)—— 标准 OAuth 授权码流 + token 交换。
|
||
-- 照参考 gemini-oauth.ts:授权页 client_id/redirect_uri/scope 齐全,回环回调
|
||
-- 拿到 code 后 POST oauth2.googleapis.com/token 换 token(带 code_verifier)。
|
||
-- client_id 是 Cloud Code 的公开客户端(逆向自 binary),scope 六项逐字照抄。
|
||
{
|
||
id = "gemini",
|
||
name = "Gemini(Cloud Code)",
|
||
vendor = "google",
|
||
flow = "callback",
|
||
login_url = "https://accounts.google.com/o/oauth2/v2/auth",
|
||
-- login_url 已含完整 OAuth 参数(client_id/scope/response_type),
|
||
-- 只剩 redirect_uri 与 state 需要按会话填 —— 用 authorize 规格声明,
|
||
-- 避免通用分支再追加一遍参数(造成 redirect_uri 出现两次)。
|
||
authorize = {
|
||
base = "https://accounts.google.com/o/oauth2/v2/auth",
|
||
params = {
|
||
client_id = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||
-- 回调路径必须是注册值 /oauth-callback(参考 GEMINI_CALLBACK_PATH),
|
||
-- 主机是 localhost 而非 127.0.0.1(参考注释明确);路径/主机改一个就是
|
||
-- redirect_uri_mismatch。
|
||
redirect_uri = "http://localhost:{port}/oauth-callback",
|
||
response_type = "code",
|
||
-- 六项 scope 逐字照抄参考 GEMINI_SCOPES(cloud-platform 必需)
|
||
scope = "openid https://www.googleapis.com/auth/cloud-platform "
|
||
.. "https://www.googleapis.com/auth/userinfo.email "
|
||
.. "https://www.googleapis.com/auth/userinfo.profile "
|
||
.. "https://www.googleapis.com/auth/cclog "
|
||
.. "https://www.googleapis.com/auth/experimentsandconfigs",
|
||
-- 参考三个固定参数(缺 access_type=offline 拿不到 refresh_token)
|
||
access_type = "offline",
|
||
prompt = "consent",
|
||
include_granted_scopes = "true",
|
||
},
|
||
},
|
||
callback = {
|
||
mode = "code",
|
||
token_url = "https://oauth2.googleapis.com/token",
|
||
-- ⚠️ 必须带 client_secret:token 端点强制校验客户端身份,只发 client_id 会回
|
||
-- invalid_request: client_secret is missing,症状正是「浏览器显示授权成功,
|
||
-- 但面板里账号一直不出现」。redirect_uri 必须与授权时**完全一致**(含主机名)。
|
||
token_body = "grant_type=authorization_code&code={code}"
|
||
.. "&client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||
.. "&client_secret=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"
|
||
.. "&redirect_uri=http://localhost:{port}/oauth-callback",
|
||
-- 参考明言:上游 Cloud Code 用的是**最朴素的 authorization_code +
|
||
-- client_secret**,刻意不带 PKCE。
|
||
},
|
||
-- 静默续期(照参考 gemini-oauth):标准 OAuth `refresh_token` 表单(**平台级**,
|
||
-- 不是 callback 内 —— refreshCredential 读的是 plat.refresh)。
|
||
-- 同样必须带 client_secret;且 **Google 偶尔轮换 refresh_token**,
|
||
-- 响应给了新的必须回写(由 refreshCredential 负责),否则下次用旧的直接失效。
|
||
refresh = {
|
||
style = "google",
|
||
url = "https://oauth2.googleapis.com/token",
|
||
client_id = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||
client_secret = "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",
|
||
},
|
||
hosts = { "accounts.google.com", "oauth2.googleapis.com", "cloudcode-pa.googleapis.com" },
|
||
base_url = "https://cloudcode-pa.googleapis.com",
|
||
-- Cloud Code 走**私有信封协议**(/v1internal: 动词 + 身份五头 + 字母序
|
||
-- 信封 + 响应剥一层),用本插件自装的适配器。
|
||
adapter = "channels-gemini",
|
||
-- ⚠️ Cloud Code 凭据**不能**打公开的 /v1beta/models/{model}:generateContent;
|
||
-- 参考 gemini.ts:78 明确走私有动词路径 /v1internal:generateContent
|
||
-- (流式 /v1internal:streamGenerateContent?alt=sse)。用声明式 endpoint
|
||
-- 覆盖,内核只当不透明字符串。
|
||
endpoint = "/v1internal:generateContent",
|
||
endpoint_stream = "/v1internal:streamGenerateContent?alt=sse",
|
||
models = { "gemini-3.1-flash-lite-preview", "gemini-3.1-flash-preview" },
|
||
},
|
||
|
||
-- LobsterAI(有道龙虾)—— 本地回调 + authCode 换 token(照参考 lobsterai-oauth.ts)。
|
||
{
|
||
id = "lobsterai",
|
||
name = "LobsterAI(有道)",
|
||
vendor = "youdao",
|
||
flow = "callback",
|
||
-- 授权页真值:{portal}/portal#/login?source=electron&redirect_uri=<编码>&state=<state>
|
||
-- (参考 buildLobsteraiLoginUrl)。注意 login 在 **hash 段**,且三个参数都是
|
||
-- query —— 此前我写的 ".../login&redirect_uri=..." 既缺 /portal#、又把 & 当
|
||
-- 首个分隔符(应为 ?),授权页必然报参数错误。
|
||
-- 授权页真值(照参考 buildLobsteraiLoginUrl):login 在 **hash 段**,
|
||
-- 三个 query 参数是 source/redirect_uri/state。
|
||
-- 此前我写的 ".../login&redirect_uri=..." 既缺 /portal#、又把 & 当首个
|
||
-- 分隔符(应为 ?),授权页必然报参数错误。
|
||
login_url = "https://lobsterai.youdao.com/portal#/login",
|
||
authorize = {
|
||
hash = "https://lobsterai.youdao.com/portal#/login",
|
||
params = {
|
||
source = "electron",
|
||
-- 参考注释:**必须**是 http://127.0.0.1:{port}/auth/callback 形态,
|
||
-- 登录页会校验。此处的 redirect_uri 由**本机 loopback 接收器**决定
|
||
-- (不是本插件的 /api/plugins/http 挂载点,也不是用户机器的 localhost),
|
||
-- 故用 {redirect_uri} 占位,拼装时由会话的实际接收器地址填入。
|
||
redirect_uri = "{redirect_uri}",
|
||
},
|
||
},
|
||
callback = {
|
||
mode = "code",
|
||
-- 参考真值 LOBSTERAI_EXCHANGE_PATH。此前写的 /api/auth/login 是我编的。
|
||
token_url = "https://lobsterai-server.youdao.com/api/auth/exchange",
|
||
-- 请求体必须含这 5 个字段(照参考 exchangeLobsteraiAuthCode)。
|
||
-- 兜底模板(正常路径走 lobsteraiExchange 助手,它会动态解析版本号)。
|
||
token_body = '{"authCode":"{code}","firstKeyfrom":"cli","latestKeyfrom":"{now}","uuid":"{uuid}","version":"2026.9.4"}',
|
||
},
|
||
-- 交换端点不需要 Authorization(此时还没有 token),但要这套匿名头。
|
||
callback_headers = {
|
||
["User-Agent"] = "LobsterAI/0.1.0",
|
||
},
|
||
hosts = { "lobsterai.youdao.com", "lobsterai-server.youdao.com",
|
||
"api-overmind.youdao.com" },
|
||
-- 客户端版本号:exchange 的 `version` 与签到 query 都必填。
|
||
-- **不能写死**:2026-10-13 实测该端点返回 2026.9.23,而参考的兜底常量
|
||
-- 还停在 2026.9.4 —— 写死就会发出一个过期的版本号。
|
||
-- 响应形态非统一信封:外层无 code,值在 data.value.version。
|
||
client_version = {
|
||
url = "https://api-overmind.youdao.com/openapi/get/luna/hardware/lobsterai/prod/update",
|
||
path = "data.value.version",
|
||
fallback = "2026.9.4",
|
||
},
|
||
-- lobsterai 的 chat 路径是 /api/proxy/v1/chat/completions(参考 LOBSTERAI_CHAT_PATH)。
|
||
base_url = "https://lobsterai-server.youdao.com/api/proxy/v1/chat/completions",
|
||
-- 远端模型目录(照参考 lobsterai.ts LOBSTERAI_MODELS_PATH)
|
||
models_remote = {
|
||
-- 参考真值 LOBSTERAI_API_BASE(lobsterai-server.youdao.com)。
|
||
-- 此前写的 api.lobster.ai 是我编的(DNS 都不解析)。
|
||
endpoints = { { url = "https://lobsterai-server.youdao.com/api/models/available", method = "GET" } },
|
||
auth = "bearer",
|
||
parse = "id_list",
|
||
},
|
||
adapter = "openai",
|
||
-- 照参考 LOBSTERAI_FALLBACK_MODELS 全量(19 项)。远端权威:
|
||
-- GET /api/models/available(**必须带 model_flag 参数**,否则只返回 25 个
|
||
-- 且没有 kimi-k3 —— 参考注释点名)。
|
||
models = {
|
||
"deepseek-v4-flash", "deepseek-v4-pro", "MiniMax-M3", "MiniMax-M2.7",
|
||
"qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus-2026-04-20",
|
||
"kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5",
|
||
"doubao-seed-2-1-pro-260628", "doubao-seed-2-1-turbo-260628",
|
||
"doubao-seed-2-0-code-preview-260215", "glm-5.2", "glm-5.1",
|
||
"glm-5v-turbo", "glm-5",
|
||
},
|
||
-- 余额(对齐参考:GET /api/user/profile-summary)
|
||
balance = {
|
||
url = "https://lobsterai-server.youdao.com/api/user/profile-summary",
|
||
method = "GET",
|
||
total_fn = function(rec)
|
||
local d = rec.data or rec
|
||
return tonumber(d.availableCredits or d.available_credits or d.credits or d.balance) or 0
|
||
end,
|
||
},
|
||
},
|
||
|
||
-- OpenCode —— 直接粘贴 API key(无登录流,参考 opencode-auth.ts)。
|
||
{
|
||
id = "opencode",
|
||
name = "OpenCode Zen",
|
||
vendor = "opencode",
|
||
flow = "paste",
|
||
-- opencode 的 chat 路径是 /v1/chat/completions(参考 chatPath)。
|
||
base_url = "https://opencode.ai/zen/v1/chat/completions",
|
||
-- 远端模型目录(照参考 opencode-product.ts modelsPath)
|
||
models_remote = {
|
||
endpoints = { { url = "https://opencode.ai/v1/models", method = "GET" } },
|
||
auth = "bearer",
|
||
parse = "id_list",
|
||
},
|
||
adapter = "openai",
|
||
-- models.dev 的键名(能力元数据的来源;照参考 opencode-capability)
|
||
capability_key = "opencode",
|
||
-- 该平台没有自己的免费名单,models.dev 的 cost **就是**唯一权威
|
||
-- (照参考 opencode-capability.ts)。故本地 models_free 只作兜底。
|
||
free_source = "capability",
|
||
-- 照参考 OPENCODE_FALLBACK_MODELS 全量(14 项)。isFree 是**官方语义
|
||
-- 字段**(匿名可用与否):7 个 free 无需 key,其余需付费 key(实测匿名 401)。
|
||
models = {
|
||
"big-pickle", "space-bunny-free", "longcat-2.5-preview-free",
|
||
"mimo-v2.6-flash-free", "mimo-v2.5-free", "nemotron-3-ultra-free",
|
||
"nemotron-3.5-lightning-free", "deepseek-v4-flash", "deepseek-v4.1-flash",
|
||
"glm-5.2", "kimi-k2.5", "minimax-m2.7", "minimax-m3", "qwen3.8-max",
|
||
},
|
||
models_free = {
|
||
"big-pickle", "space-bunny-free", "longcat-2.5-preview-free",
|
||
"mimo-v2.6-flash-free", "mimo-v2.5-free", "nemotron-3-ultra-free",
|
||
"nemotron-3.5-lightning-free",
|
||
},
|
||
},
|
||
|
||
-- CodeArts(华为)—— 凭据 + SDK-HMAC-SHA256 签到(照参考 codearts-credits.ts)。
|
||
-- 登录是常规企业凭据(paste);签到走 snap-access 的 SDK-HMAC-SHA256 签名,
|
||
-- 放在 claim_fn 钩子里(checkin 表驱动驱动不动签名请求,参考注释也点明这一点)。
|
||
{
|
||
id = "codearts",
|
||
name = "CodeArts(华为)",
|
||
vendor = "huawei",
|
||
-- 不是 paste!参考默认走**新式 IAM OAuth**(PKCE + DPoP),
|
||
-- 旧 ticket 流程仅作回退。我此前标成手填凭据是错的。
|
||
flow = "callback",
|
||
-- 回调路径必须是 /oauth/callback(参考 REDIRECT_PATH),且端口须 ≥10000
|
||
-- (portal 拒绝低端口)。
|
||
authorize = {
|
||
base = "https://codearts.huaweicloud.com/portal/authorize",
|
||
callback_path = "/oauth/callback",
|
||
min_port = 10000,
|
||
-- 参数由 buildCodeartsAuthorizeUrl 按参考逐字拼(含 SHA-256 而非 S256)
|
||
},
|
||
hosts = { "codearts.huaweicloud.com", "sts.cn-north-4.myhuaweicloud.com",
|
||
"snap-access.cn-north-4.myhuaweicloud.com",
|
||
"devcloud.cn-north-4.huaweicloud.com", "auth.huaweicloud.com" },
|
||
-- codearts 的 chat 端点含 /api/v2 前缀(参考 CHAT_API_BASE = .../api/v2)。
|
||
base_url = "https://snap-access.cn-north-4.myhuaweicloud.com",
|
||
-- 本插件**自己装的**适配器(见 installCodeartsAdapter):华为云 AK/SK 签名
|
||
adapter = "codearts",
|
||
-- 签名平台:源注册要交**完整凭据 JSON**(适配器自己解 AK/SK 签名),
|
||
-- 不是裸 access token。见 syncAccountSource 的注释。
|
||
credential_shape = "full",
|
||
models = { "CodeArts-Pangu" },
|
||
checkin = {
|
||
url = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin",
|
||
method = "GET",
|
||
},
|
||
-- claim_fn 由下方 claimCodeArts 实现(引用前向声明,见文件末尾)
|
||
claim_fn_name = "claimCodeArts",
|
||
},
|
||
|
||
-- Qoder —— **标准 PKCE 设备码轮询**(照参考 qoder-oauth.ts / qoder.ts 逐行移植)。
|
||
-- -- 注意纠正一处长期错误:此前把 Qoder 标成 manual,理由是"登录要 WASM 签名头"。
|
||
-- 那是把**聊天加密**(WASM 用于 /algo/ 加密推理端点)与**登录**混为一谈 ——
|
||
-- 登录本身是纯 HTTP 的 PKCE 设备码流,Lua 完全能做:
|
||
-- ① 打开 {authBase}/device/selectAccounts?challenge&challenge_method=S256
|
||
-- &nonce&machine_id&client_id
|
||
-- ② 轮询 {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
|
||
-- 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成
|
||
-- 三个必须照抄的细节(参考注释都点了名):
|
||
-- · client_id 必须用 **prod 的**(非 prod 会让服务端在授权回调阶段拒绝)
|
||
-- · poll 挂在 openApiBase(openapi.qoder.sh),不是 authBase(那返回 401)
|
||
-- · nonce/verifier 必须原样回传(PKCE 校验)
|
||
{
|
||
id = "qoder",
|
||
name = "Qoder",
|
||
vendor = "qoder",
|
||
flow = "device-poll",
|
||
device = {
|
||
-- 授权页在 {authBase}/device/selectAccounts,query 由 pkce 表单拼装
|
||
code_url = "https://qoder.com/device/selectAccounts",
|
||
code_method = "GET_QUERY",
|
||
-- code_url 的 query 模板:{challenge}/{nonce}/{machine_id}/{client_id}
|
||
auth_query = "challenge={challenge}&challenge_method=S256&nonce={nonce}"
|
||
.. "&machine_id={machine_id}&client_id=" .. "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
|
||
-- 轮询在 openApiBase
|
||
token_url = "https://openapi.qoder.sh/api/v1/deviceToken/poll",
|
||
token_method = "GET_QUERY",
|
||
-- poll 的 query 模板:PKCE 用 verifier(不是 challenge)
|
||
poll_query = "nonce={nonce}&verifier={verifier}&challenge_method=S256",
|
||
-- 判据:404 = 继续(见 deviceOneStep 的 pending404 分支)
|
||
judge = "pending404",
|
||
interval = 2,
|
||
},
|
||
-- qoder 的 chat 路径是 /model/v1/chat/completions(参考 QODER_CHAT_PATH)。
|
||
base_url = "https://api2-v2.qoder.sh/model/v1/chat/completions",
|
||
-- Qoder 的错误信封是**双层编码**的,且 10605(排队) 与 110(额度耗尽)
|
||
-- 语义相反 —— 通用 openai 适配器只能看到外层,产出用户看不懂的原因串。
|
||
-- 故用本插件自装的 channels-qoder(厂商码留在插件域,内核零厂商名)。
|
||
adapter = "channels-qoder",
|
||
-- 兜底目录(照参考 QODER_FALLBACK_MODELS 的真实目录 key)。
|
||
-- 关键:这些是**上游目录 key**(如 qfmodel),不是通用名 —— 公开的
|
||
-- /model/v1/chat/completions 不认它们,必须走加密端点 agent_chat_generation。
|
||
-- 此前这里写的是 "qoder-default"(我编的占位名,上游不认,选了也调不通)。
|
||
-- 远端目录端点 /api/v2/model/list 需 WASM 签名,故参考自己也用兜底表。
|
||
models = {
|
||
"auto", "ultimate", "performance", "efficient", "smodel", "cmodel",
|
||
"qmodel_38max", "qfmodel", "qmodel_latest", "qmodel",
|
||
"kmodel_latest", "kmodel", "gmodel", "gfmodel",
|
||
"dmodel", "dfmodel", "mmodel",
|
||
-- CN 独有(参考注释点名:`q37fmodel` / `gm51model` 仅国区下发,
|
||
-- 两者都是 1M 上下文、支持图像与思考)
|
||
"q37fmodel", "gm51model",
|
||
},
|
||
-- 余额(对齐参考 qoder-credits.ts:GET /sash/api/v2/me/usage,余额=userQuota.remaining
|
||
-- + addOnQuota.remaining,走 openApiBase)
|
||
-- 身份头:cosy-clienttype 是必要但**不充分**条件,服务端还校验设备身份
|
||
-- (见 qoderMachineIdentity 的函数注释:缺 Cosy-MachineToken/MachineType
|
||
-- 时不下发可领取的活动)。拿不到身份时照常发(保守降级)。
|
||
headers_fn = function(accessToken, cred)
|
||
local h = {
|
||
["Authorization"] = "Bearer " .. accessToken,
|
||
["Accept"] = "application/json",
|
||
["Content-Type"] = "application/json",
|
||
["Cosy-ClientType"] = "10",
|
||
}
|
||
local id = qoderMachineIdentity()
|
||
if id then
|
||
-- 必须成对出现(消融表:缺一即失效)
|
||
h["Cosy-MachineToken"] = id.token
|
||
h["Cosy-MachineType"] = id.type
|
||
end
|
||
return h
|
||
end,
|
||
balance = {
|
||
url = "https://openapi.qoder.sh/sash/api/v2/me/usage",
|
||
method = "GET",
|
||
total_fn = function(rec)
|
||
local d = rec.data or rec
|
||
local u = (d.userQuota or {}).remaining or 0
|
||
local a = (d.addOnQuota or {}).remaining or 0
|
||
return (tonumber(u) or 0) + (tonumber(a) or 0)
|
||
end,
|
||
},
|
||
},
|
||
|
||
-- ZCode —— **两段式服务端中介授权流**(照参考 zcode-login.ts 逐行移植)。
|
||
-- -- 注意纠正第二处长期错误:此前标 manual,理由是"登录要解 captcha"。核对参考
|
||
-- 后发现**该授权流完全不涉及 captcha** —— captcha 属于另一条已废弃的旁路
|
||
-- (zcode-captcha.ts,用于读取本机官方客户端数据),而本流的注释明确写着
|
||
-- 「完全不经 zcode:// 自定义协议回调,故普通进程就能走完」:
|
||
-- ① POST {origin}/api/v1/oauth/cli/init
|
||
-- Authorization: Bearer <本地造的 flow_secret(32 字节 hex)>
|
||
-- body {provider:"bigmodel"}
|
||
-- → {flow_id, poll_token, authorize_url, expires_at, poll_interval_sec}
|
||
-- ② 用户在浏览器打开 authorize_url 完成授权
|
||
-- ③ GET {origin}/api/v1/oauth/cli/poll/{flow_id}
|
||
-- Authorization: Bearer <同一个 flow_secret>
|
||
-- → {status:"pending"} 继续 / {status:"ready", token, user, bigmodel|zai}
|
||
-- 判据(参考注释点名,与官方一致):
|
||
-- · 4xx(**除 408/429**)才是终态失败;5xx 与网络错误继续重试
|
||
-- · code != 0 或 data 缺失 → pending
|
||
-- · token(zcode JWT)与 user.user_id 必需;第三方 token 取 bigmodel/zai 二者之一
|
||
{
|
||
id = "zcode",
|
||
name = "ZCode",
|
||
vendor = "zcode",
|
||
flow = "init-poll",
|
||
initpoll = {
|
||
init_url = "https://zcode.z.ai/api/v1/oauth/cli/init",
|
||
poll_url = "https://zcode.z.ai/api/v1/oauth/cli/poll/{flow_id}",
|
||
provider = "bigmodel",
|
||
app_version = "3.14.3",
|
||
origin = "https://zcode.z.ai",
|
||
interval = 2,
|
||
},
|
||
-- 推理走 Anthropic 协议的免费额度通道(照参考 zcode-upstream.ts):
|
||
-- 积分制无论 bigmodel/zai 都走 zcode.z.ai(官方分派表规则 2/5,2026-10-03 实测);
|
||
-- 只有订阅制才走 api.z.ai。这里用积分制那条。
|
||
-- zcode 只存在 /api/v1/zcode-plan/anthropic/v1/messages 这一个 chat 路径
|
||
-- (参考 zcode-anthropic.ts:7 注释:openai / 裸 v1/chat/completions 一律
|
||
-- 404 page not found)。适配器 anthropic 补 /v1/messages。
|
||
base_url = "https://zcode.z.ai/api/v1/zcode-plan/anthropic",
|
||
-- 远端模型目录(照参考 zcode-upstream.ts ZCODE_CLIENT_CONFIGS_URL)
|
||
models_remote = {
|
||
endpoints = { { url = "https://zcode.z.ai/api/v1/client/configs", method = "GET",
|
||
query = "app_version=1.0.0&platform=unknown" } },
|
||
auth = "none",
|
||
parse = "zcode_configs",
|
||
},
|
||
endpoint = "/api/v1/zcode-plan/anthropic/v1/messages",
|
||
adapter = "anthropic",
|
||
-- 余额端点(需要 Authorization;照参考 ZCODE_BILLING_BALANCE_URL)
|
||
balance = {
|
||
url = "https://zcode.z.ai/api/v1/zcode-plan/billing/balance",
|
||
method = "GET",
|
||
total_fn = function(rec)
|
||
local d = rec.data or rec
|
||
-- 优先取明确的余额字段;取不到就不编造(返回 nil 让调用方报错)
|
||
return tonumber(d.balance or d.remaining or d.credits or d.total)
|
||
end,
|
||
},
|
||
-- 兜底目录(照参考 ZCODE_FALLBACK_MODELS)。远端可拉:
|
||
-- GET /api/v1/client/configs?app_version=...&platform=unknown
|
||
-- 此前写的 "zcode-default" 是我编的占位名,上游不认。
|
||
models = { "GLM-5.3", "GLM-5.3-Flash" },
|
||
},
|
||
|
||
-- Loomy —— 微信扫码登录(照参考 loomy-oauth.ts 逆向)。
|
||
-- 扫码要在微信 App 里完成,纯 Lua 环境拿不到微信凭据,标 manual。
|
||
{
|
||
id = "loomy",
|
||
name = "Loomy",
|
||
vendor = "loomy",
|
||
-- 注意纠正第三处长期错误:此前标 manual,理由是"扫码在微信里完成,Lua 做不了"。
|
||
-- 扫码动作确实在用户手机上,但**协议全是纯 HTTP/Lua 可做**:
|
||
-- ① 取 uuid:GET open.weixin.qq.com/connect/qrconnect?...&state=... 从 HTML 抽 uuid
|
||
-- ② 二维码图:GET open.weixin.qq.com/connect/qrcode/{uuid}(JPEG,前端渲染)
|
||
-- ③ 长轮询:GET long.open.weixin.qq.com/connect/l/qrconnect?uuid&last&_
|
||
-- 响应体里 wx_errcode=NNN / wx_code='...'
|
||
-- ④ 拿 wx_code 后调讯飞账号端点换凭据(HMAC-SHA1 签名,见 loomyAuthHeaders)
|
||
-- 判据(参考点名;早期版本**读反了**导致"扫码后卡在已扫码"):
|
||
-- 408=等待扫码;404=已扫码待确认(继续);405=**已确认**(wx_code 在此帧);
|
||
-- 403=用户取消;402=二维码失效
|
||
flow = "wechat-poll",
|
||
wechat = {
|
||
-- appid / redirect_uri 照参考常量(原写的 appid 是我编的,上游不认);
|
||
-- 末尾 `#wechat_redirect` 是微信要求的固定片段,缺它对某些客户端会跳错页。
|
||
auth_url = "https://open.weixin.qq.com/connect/qrconnect"
|
||
.. "?appid=wx18d60be432287cf8&scope=snsapi_login&response_type=code"
|
||
.. "&redirect_uri=https%3A%2F%2Floomy.xunfei.cn%2Foauth%2Fwechat%2Fcallback"
|
||
.. "&state={state}#wechat_redirect",
|
||
qr_url = "https://open.weixin.qq.com/connect/qrcode/{uuid}",
|
||
poll_url = "https://long.open.weixin.qq.com/connect/l/qrconnect",
|
||
-- 逐字照参考 WECHAT_UA(微信对空 UA / 爬虫 UA 可能拒绝)
|
||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||
.. " (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||
-- 参考真值 LOOMY_ACCOUNT_BASE。此前写的 account.xfyun.cn 是我编的
|
||
-- (DNS 都不解析),实际账号域是 xfinfr.com —— 拼音近似导致的误写。
|
||
account_base = "https://account.xfinfr.com",
|
||
bind_path = "/login/thirdAccount/bind/auth",
|
||
access_key_id = "2thryby66wxi53sk",
|
||
access_key_secret = "zsak6eadrbawz683wf5r3m2snrwj868r",
|
||
app_id = "GM3LOOMY",
|
||
},
|
||
-- loomy 的 chat 路径是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494
|
||
-- 实测标准 OpenAI 兼容)。
|
||
base_url = "https://loomy.xunfei.cn/chat/completions",
|
||
adapter = "openai",
|
||
-- 兜底目录(照参考 LOOMY_FALLBACK_MODELS)。此前 "loomy-default" 是我编的。
|
||
models = {
|
||
"deepseek-v4-flash-0731", "MiniMax-M3", "Kimi-k2.6", "qwen-3.8-max",
|
||
"GLM-5.3-Flash", "qwen3.8-flash", "spark-x", "mimo-v2.5",
|
||
},
|
||
},
|
||
|
||
-- Raccoon(商汤小浣熊)—— 扫码登录(照参考 raccoon-oauth.ts 逆向)。
|
||
-- 扫码是图片码,纯 Lua 环境无法展示与回取,标 manual。
|
||
{
|
||
id = "raccoon",
|
||
name = "Raccoon(商汤小浣熊)",
|
||
vendor = "sensetime",
|
||
-- 注意纠正第四处:此前标 manual,说"要扫码,做不了"。**扫码路径纯 HTTP 可做**:
|
||
-- ① 本地造 qrcode_code(16 字节随机 hex)
|
||
-- ② 二维码 URL {apiBase}/login/mp?code=...&appname=商汤小浣熊官网(前端渲染)
|
||
-- ③ 轮询 POST {apiBase}/api/web/auth/v1/login_with_qrcode_code {qrcode_code}
|
||
-- → data.status ∈ pending/logging/canceled/success;success 带 token
|
||
-- 参考注释:code 由**宿主侧本地随机生成**(服务端接受任意自造 code 并进入
|
||
-- pending),故完全绕开收不到的自定义协议回调。
|
||
-- 短信路径(/sms/send + /sms/verify)需要阿里云滑块 captcha_param 与手机号
|
||
-- AES 加密 —— 那一条才真需要人工,故不走。
|
||
flow = "qr-poll",
|
||
qr = {
|
||
api_base = "https://xiaohuanxiong.com",
|
||
auth_prefix = "/api/web/auth/v1",
|
||
poll_path = "/login_with_qrcode_code",
|
||
login_page = "/login/mp",
|
||
app_name = "商汤小浣熊官网",
|
||
interval = 2,
|
||
},
|
||
-- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions)
|
||
-- raccoon 的 chat 端点是 /api/web/llm/v2/chat/completions。
|
||
base_url = "https://xiaohuanxiong.com/api/web/llm/v2/chat/completions",
|
||
adapter = "openai",
|
||
-- 兜底目录(照参考 RACCOON_FALLBACK_MODELS)。此前 "raccoon-default" 是我编的。
|
||
-- 注意 sn- 前缀是本平台的真实命名约定(sensenova 系列)。
|
||
models = {
|
||
"sn-sensenova-6-8-flash", "sn-sensenova-6-8-flash-lite",
|
||
"sn-glm-5-3", "sn-kimi-k3", "sn-glm-5-3-flash",
|
||
"sn-deepseek-v4-1-flash",
|
||
},
|
||
-- 余额(照参考:GET {apiBase}/api/web/points/v1/balance)
|
||
balance = {
|
||
url = "https://xiaohuanxiong.com/api/web/points/v1/balance",
|
||
method = "GET",
|
||
headers_fn = function(accessToken, cred)
|
||
return {
|
||
["Authorization"] = "Bearer " .. accessToken,
|
||
["Accept"] = "application/json",
|
||
["Content-Type"] = "application/json",
|
||
["X-Org-Code"] = (cred and cred.office_identity) or "",
|
||
["X-Raccoon-Language"] = "zh",
|
||
}
|
||
end,
|
||
total_fn = function(rec)
|
||
local d = rec.data or rec
|
||
return tonumber(d.available_points or d.availablePoints or d.points)
|
||
end,
|
||
},
|
||
-- 一次性新人奖励(对齐参考 RACCOON_LOGIN_REWARD_POINTS)
|
||
onboarding = { reward_points = 3000 },
|
||
},
|
||
}
|
||
|
||
-- 华为云 snap-access 引擎域名(连接与签名都用它)。
|
||
local CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com"
|
||
|
||
-- CodeArts(华为云 snap-access)的请求签名与每日领取。
|
||
--
|
||
-- 逐行对齐参考 `sign.ts:signRequestHuawei` + `codearts-credits.ts`。
|
||
-- 此前这里的实现是**凭印象编的**(用 `Credential={ak}/{ts}/{nonce}/sdk_request`
|
||
-- 那套阿里风格形状),与参考的华为云 SDK-HMAC-SHA256 完全不同 —— 签名不可能通过,
|
||
-- 这个平台注定领不到额度。对齐要点:
|
||
-- 1. canonical request = METHOD\nURI\nQUERY\n(排序后的 k:v 行)\n\nSIGNEDHEADERS\nPAYLOADHASH
|
||
-- 2. string to sign = "SDK-HMAC-SHA256\n{dateStamp}\n{sha256(canonical)}"
|
||
-- 3. signature = HMAC-SHA256(sk, stringToSign)
|
||
-- 4. Authorization = "SDK-HMAC-SHA256 Access={ak},SignedHeaders=...,Signature=..."
|
||
-- (**是 Access= 不是 Credential=**)
|
||
-- 5. 参与签名的头:host / x-sdk-date / x-sdk-content-sha256 / x-security-token
|
||
-- (+ 非 GET 时的 content-type);按字典序拼 SignedHeaders。
|
||
-- 6. Agent-Type / X-Language 是**签名后**追加的,绝不能进 canonical(否则验签失败)。
|
||
-- 7. URI 末尾必须补 '/'。
|
||
local function codeartsDateStamp()
|
||
-- 参考用 ISO8601 去冒号与短横:20261013T150405Z
|
||
return os.date("!%Y%m%dT%H%M%SZ")
|
||
end
|
||
|
||
-- codeartsSignedRequest(method, url, cred, body):返回可直接发出的 {url,method,headers,body}。
|
||
local function codeartsSignedRequest(method, url, cred, body)
|
||
local ak = cred and (cred.access_key_id or cred.access_key) or ""
|
||
local sk = cred and (cred.secret_access_key or cred.secret_key) or ""
|
||
local st = cred and (cred.security_token or "") or ""
|
||
if ak == "" or sk == "" then return nil, "凭据缺少 AK/SK" end
|
||
local host = url:match("^https?://([^/]+)")
|
||
local uri = url:match("^https?://[^/]+(/[^?]*)") or "/"
|
||
if uri:sub(-1) ~= "/" then uri = uri .. "/" end
|
||
local query = url:match("%?([^#]*)$") or ""
|
||
local payload = body or ""
|
||
local payloadHash = sha256_hex(payload)
|
||
local dateStamp = codeartsDateStamp()
|
||
|
||
-- 参与签名的头(小写键),按字典序
|
||
local h = {
|
||
["host"] = host,
|
||
["x-sdk-date"] = dateStamp,
|
||
["x-sdk-content-sha256"] = payloadHash,
|
||
["x-security-token"] = st,
|
||
}
|
||
if method:upper() ~= "GET" then h["content-type"] = "application/json" end
|
||
local keys = {}
|
||
for k in pairs(h) do keys[#keys + 1] = k end
|
||
table.sort(keys)
|
||
local lines, signedNames = {}, {}
|
||
for _, k in ipairs(keys) do
|
||
lines[#lines + 1] = k .. ":" .. h[k]
|
||
signedNames[#signedNames + 1] = k
|
||
end
|
||
local signedHeaders = table.concat(signedNames, ";")
|
||
local canonical = table.concat({
|
||
method:upper(), uri, query, table.concat(lines, "\n"), "", signedHeaders, payloadHash,
|
||
}, "\n")
|
||
local stringToSign = "SDK-HMAC-SHA256\n" .. dateStamp .. "\n" .. sha256_hex(canonical)
|
||
local signature = hmac_sha256_hex(sk, stringToSign)
|
||
|
||
-- 把中间值一并带出:判据必须比对**真实函数算出的** canonical / string-to-sign,
|
||
-- 而不是自己重算一遍 —— 否则真实签名被改坏时判据照样通过(判据与被测对象脱节,
|
||
-- 实测踩过:去掉 content-type 参与签名,探针重算版本仍绿)。
|
||
-- 发出去的头:签名头 + Authorization(host 由 HTTP 栈生成,不手填)
|
||
local out = {}
|
||
for _, k in ipairs(keys) do
|
||
if k ~= "host" then out[k] = h[k] end
|
||
end
|
||
out["Authorization"] = "SDK-HMAC-SHA256 Access=" .. ak
|
||
.. ",SignedHeaders=" .. signedHeaders .. ",Signature=" .. signature
|
||
-- 签名后追加(不参与 canonical)
|
||
out["Agent-Type"] = "PromptCenter"
|
||
out["X-Language"] = "zh-cn"
|
||
return { url = url, method = method:upper(), headers = out, body = payload,
|
||
canonical = canonical, string_to_sign = stringToSign,
|
||
signed_headers = signedHeaders }
|
||
end
|
||
|
||
-- codeartsAccountInfo(cred):账号信息(statistics/plugin),判断是否积分账户。
|
||
local function codeartsAccountInfo(cred)
|
||
local req, err = codeartsSignedRequest("GET", CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil)
|
||
if not req then return nil, err end
|
||
local resp, herr = __llmsproxy_http(req)
|
||
if herr or not resp or resp.status ~= 200 then
|
||
return nil, "账户信息查询失败: " .. tostring(herr or (resp and resp.status))
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then return nil, "账户信息响应无法解析" end
|
||
local d = rec.data or rec
|
||
local metrics = d.metrics or {}
|
||
local total = nil
|
||
for _, m in ipairs(metrics) do
|
||
-- IDE 从 usageTotalPackageCredit 读总额(参考 codearts-credits.ts 注释)
|
||
if type(m) == "table" and m.name == "usageTotalPackageCredit" then
|
||
total = tonumber(m.value) or tonumber(m.credit) or total
|
||
end
|
||
end
|
||
local billing = d.billingMode or d.billing_mode or d.chargingMode or ""
|
||
return {
|
||
isCreditPackage = tostring(billing):upper():find("CREDIT") ~= nil,
|
||
isTokenPackage = tostring(billing):upper():find("TOKEN") ~= nil,
|
||
total = total,
|
||
raw = d,
|
||
}, nil
|
||
end
|
||
|
||
-- codeartsClaim(plat, tok, args):完整领取流程(照参考的 6 步判定顺序)。
|
||
-- 返回 (requestSpec, nil) 给通用发送路径,或 (nil, err) 提前失败。
|
||
-- 注意:参考的流程要发**多个**请求(账户→活动→claim→confirm),而 claim_daily
|
||
-- 的契约只发一个。故这里把整段流程跑完并直接落结果,返回一个「不发送」的哨兵,
|
||
-- 由 claim_daily 识别 —— 见 CLAIM_DONE 约定。
|
||
local function codeartsClaim(plat, tok, args)
|
||
local cred = (args and args.credential) or nil
|
||
if type(cred) ~= "table" then return nil, "缺少凭据对象(AK/SK 需要结构化字段)" end
|
||
|
||
-- ① 账户类型:非积分账户 → inactive(Token 账户不该被报成"领取失败")
|
||
local info, ierr = codeartsAccountInfo(cred)
|
||
if not info then return nil, ierr end
|
||
if not info.isCreditPackage then
|
||
return { done = true, kind = "inactive",
|
||
message = info.isTokenPackage and "Token 计费账户,不在积分活动范围"
|
||
or "非积分计费账户,不在积分活动范围" }
|
||
end
|
||
|
||
-- ② 活动列表
|
||
local areq = codeartsSignedRequest("GET",
|
||
CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil)
|
||
if not areq then return nil, "活动列表请求构造失败" end
|
||
local aresp, aerr = __llmsproxy_http(areq)
|
||
if aerr or not aresp or aresp.status ~= 200 then
|
||
return { done = true, kind = "failed",
|
||
message = "活动列表查询失败: " .. tostring(aerr or (aresp and aresp.status)) }
|
||
end
|
||
local aok, arec = pcall(json.decode, aresp.body or "")
|
||
local items = aok and type(arec) == "table" and ((arec.data or {}).items) or nil
|
||
if type(items) ~= "table" then
|
||
return { done = true, kind = "failed", message = "活动列表响应缺少 items" }
|
||
end
|
||
local act = nil
|
||
for _, it in ipairs(items) do
|
||
if type(it) == "table" and (it.type or "") == "USER_LOGIN" then act = it break end
|
||
end
|
||
if not act then
|
||
return { done = true, kind = "inactive", message = "未找到每日签到活动" }
|
||
end
|
||
|
||
-- ③ 幂等保护:本协议没有幂等键,预检不可省
|
||
if act.claimable ~= true then
|
||
local st = tostring(act.status or "")
|
||
-- 已领取态(参考 CLAIMED_STATUSES)
|
||
local claimed = st == "CLAIMED" or st == "RECEIVED" or st == "COMPLETED"
|
||
return { done = true, kind = claimed and "already-claimed" or "inactive",
|
||
message = claimed and "今天已领取" or ("当前不可领取(status=" .. st .. ")") }
|
||
end
|
||
local campaignId = act.campaignId or act.campaign_id or ""
|
||
if campaignId == "" then
|
||
return { done = true, kind = "failed", message = "活动缺少 campaignId,无法领取" }
|
||
end
|
||
|
||
-- ④ claim
|
||
local body = json.encode({ campaignId = campaignId, channel = "IDE" })
|
||
local creq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/claim", cred, body)
|
||
if not creq then return nil, "claim 请求构造失败" end
|
||
local cresp, cerr = __llmsproxy_http(creq)
|
||
if cerr or not cresp or cresp.status ~= 200 then
|
||
return { done = true, kind = "failed",
|
||
message = "领取失败: " .. tostring(cerr or (cresp and cresp.status)) }
|
||
end
|
||
|
||
-- ⑤ 服务端要求确认时补 confirm(判据 benefit.id 非空);confirm 失败不算整体失败
|
||
local msg = "已领取"
|
||
local cok, crec = pcall(json.decode, cresp.body or "")
|
||
local benefit = cok and type(crec) == "table" and (((crec.data or {}).benefit) or nil) or nil
|
||
if type(benefit) == "table" and benefit.id then
|
||
local fbody = json.encode({ id = benefit.id, channel = "IDE" })
|
||
local freq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody)
|
||
if freq then
|
||
local fresp, ferr = __llmsproxy_http(freq)
|
||
-- 只在真正失败时降级提示,不改变整体成败(积分已在待确认态)
|
||
if ferr or not fresp or fresp.status ~= 200 then
|
||
msg = "已领取(确认步骤失败,可能停在待确认)"
|
||
end
|
||
end
|
||
end
|
||
return { done = true, kind = "success", message = msg }
|
||
end
|
||
|
||
-- ---- 基础编码原语(必须在**所有使用者之前**定义)----
|
||
-- ⚠️ 本文件反复踩「local 前向引用」:local function 只从定义处向后可见,
|
||
-- 更早的代码引用它就是 nil。这三个被 CodeArts 的 PKCE/DPoP 通过 base64url
|
||
-- 与 hex 互转调用,必须放在 CodeArts 块之前。
|
||
local function hexToBin(h)
|
||
return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end))
|
||
end
|
||
|
||
local function b64url(s)
|
||
return (base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", ""))
|
||
end
|
||
|
||
-- b64urlRaw:无填充 base64url(JWS/JWK 用)
|
||
local function b64urlRaw(bin)
|
||
local e = base64_encode(bin)
|
||
return (e:gsub("+", "-"):gsub("/", "_"):gsub("=", ""))
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- CodeArts(华为)登录:新式 IAM OAuth(PKCE + DPoP ES256)
|
||
-- ============================================================================
|
||
-- 逐段对齐参考 login.ts:runOAuthFlow + oauth.ts + channel-pack-rpc.ts 的
|
||
-- `codearts.startLogin()`。此前我把它当成"手填凭据"(paste 流),**是错的**:
|
||
-- 参考是完整 OAuth —— 起本地回调服务器(端口须 ≥10000)、打开 portal 授权页、
|
||
-- 收到 code 后走华为 STS 端点换 AK/SK/securityToken(带 DPoP 证明)。
|
||
--
|
||
-- 端点与常量(照抄):
|
||
-- PORTAL_AUTHORIZE_BASE = https://codearts.huaweicloud.com/portal/authorize
|
||
-- STS_TOKEN_ENDPOINT = https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens
|
||
-- CLIENT_ID = codearts-agent(也是 uri_scheme)
|
||
-- REDIRECT_PATH = /oauth/callback
|
||
-- LOGIN_PLUGIN_NAME = snap_AIIDE LOGIN_PLUGIN_VERSION = 5.2.0
|
||
-- OAUTH_THEME = 2(Dark) OAUTH_LOCALE = zh-cn
|
||
-- code_challenge_method = **SHA-256**(不是 S256!portal 以此识别 OAuth,
|
||
-- 错值会回退到旧 ticket 流程)
|
||
-- 旧流程回退(ticket):CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket
|
||
-- ⚠️ 常量收进**表**(`plugin.consts`)而不是顶层 `local`。
|
||
-- 原因很实际:本文件顶层 local 已**顶到 Lua 单函数 200 个上限**,多一个就让
|
||
-- 整个插件**加载失败**(`too many local variables`);报错在编译期,
|
||
-- 运行期表现是"插件所有路由变成 404",毫无指向性。2026-10-14 实测踩到。
|
||
-- 往本文件加顶层 local 前,务必先 `luac -p` 验证。
|
||
local CODEARTS = {
|
||
portal_authorize = "https://codearts.huaweicloud.com/portal/authorize",
|
||
sts_token = "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens",
|
||
client_id = "codearts-agent",
|
||
redirect_path = "/oauth/callback",
|
||
login_plugin_name = "snap_AIIDE",
|
||
login_plugin_version = "5.2.0",
|
||
oauth_theme = "2",
|
||
oauth_locale = "zh-cn",
|
||
}
|
||
|
||
-- ES256 JWS 签名(照参考 signDpopJws):
|
||
-- header = {alg:"ES256", typ:"dpop+jwt", jwk:{kty,crv,x,y}}
|
||
-- payload = {htm, htu, iat, jti(32字节hex)}
|
||
-- sig = ES256(privKey, base64url(header) .. "." .. base64url(payload))
|
||
-- ⚠️ JWS 的 ES256 签名是 **raw r||s(各 32 字节)**,而 openssl 输出的是
|
||
-- DER(SEQUENCE{INTEGER r, INTEGER s})。必须转格式,否则服务端验签失败。
|
||
-- 用 openssl 签:把私钥写成 PEM,签名,再把 DER 转 raw。
|
||
local function codeartsDpopJws(key, htm, htu)
|
||
-- 1) 组 header/payload
|
||
local header = json.encode({ alg = "ES256", typ = "dpop+jwt", jwk = key.public })
|
||
local payload = json.encode({ htm = htm, htu = htu,
|
||
iat = (os.time and os.time() or 0),
|
||
jti = sha256_hex(tostring(os.time()) .. tostring(math.random(1e12)))
|
||
.. sha256_hex(tostring(math.random(1e12))):sub(1, 32) })
|
||
local signingInput = b64urlRaw(header) .. "." .. b64urlRaw(payload)
|
||
-- 2) openssl 签(PEM 私钥从 JWK 的 d 与 x/y 重建)
|
||
local dir = "/tmp/llmsproxy-codeartsdpop-" .. tostring(math.random(1e12))
|
||
os.execute("mkdir -p " .. dir .. " 2>/dev/null")
|
||
local pem = dir .. "/k.pem"
|
||
local dh = hexToBin((key.private.d:gsub("-", "+"):gsub("_", "/") ..
|
||
string.rep("=", (4 - #key.private.d % 4) % 4)))
|
||
-- 用 openssl 的 ecparam -genkey 无法指定 d;改用 asn1 构造太脆。
|
||
-- 改为:生成新私钥后**用它的 d** 组 JWK(调用方须用同一把)。见 codeartsNewDpop。
|
||
os.execute("rm -rf " .. dir)
|
||
return nil, "内部错误:DPoP 签名需与密钥同时生成"
|
||
end
|
||
|
||
-- codeartsNewDpop():生成密钥对**并返回可签名的 PEM**(两者必须同源 ——
|
||
-- 公钥 JWK 必须来自签 JWS 的那把私钥,否则服务端按 header.jwk 验签会失败)。
|
||
-- ⚠️ 两个本机实测的坑(都已踩):
|
||
-- 1. **本机没有 xxd**(`command -v xxd` 为空)→ 用它取十六进制会静默得到
|
||
-- 空串。改用 `od -An -tx1`。
|
||
-- 2. `openssl ec -text` 打印 32 字节 priv 时**会截断**(实测只给 30 字节)→
|
||
-- `d` 必须用 `openssl asn1parse` 的 `[HEX DUMP]`(精确 32 字节);
|
||
-- 而 `tail -c 32` 拿到的是 SEC1 结构里**公钥 y**,不是 d。
|
||
-- 3. 公钥点用 `openssl ec -text` 的 pub 段(完整 04||x||y,130 hex),
|
||
-- 与 `-pubout` DER 末尾 65 字节交叉一致。
|
||
local function codeartsNewDpop()
|
||
local dir = "/tmp/llmsproxy-codeartsdpop-" .. tostring(math.random(1e12))
|
||
os.execute("mkdir -p " .. dir .. " 2>/dev/null")
|
||
local pem = dir .. "/k.pem"
|
||
os.execute("openssl ecparam -name prime256v1 -genkey -noout -out " .. pem .. " 2>/dev/null")
|
||
local f = io.open(pem, "r")
|
||
if not f then os.execute("rm -rf " .. dir) return nil, "openssl EC 生成失败" end
|
||
local pemTxt = f:read("*a")
|
||
f:close()
|
||
-- d:asn1parse 的 OCTET STRING(权威、精确)
|
||
local dh = io.popen("openssl asn1parse -in " .. pem
|
||
.. " 2>/dev/null | grep -m1 'OCTET STRING' | awk -F': ' '{print $NF}'")
|
||
local dHex = dh and (dh:read("*a") or ""):gsub("%s", "") or ""
|
||
if dh then dh:close() end
|
||
-- 公钥点:ec -text 的 pub 段
|
||
local ph = io.popen("openssl ec -in " .. pem
|
||
.. " -text -noout 2>/dev/null | awk '/^pub:/{f=1;next} /^ASN1/{f=0} f' | tr -d ' :' | tr -d '\n'")
|
||
local pubHex = ph and (ph:read("*a") or ""):gsub("%s", "") or ""
|
||
if ph then ph:close() end
|
||
if #pubHex < 130 then
|
||
-- 兜底:-pubout DER 的末尾 65 字节(未压缩点)
|
||
local pf = io.popen("openssl ec -in " .. pem
|
||
.. " -pubout -outform DER 2>/dev/null | tail -c 65 | od -An -tx1 | tr -d ' \n'")
|
||
pubHex = pf and (pf:read("*a") or ""):gsub("%s", "") or ""
|
||
if pf then pf:close() end
|
||
end
|
||
if #dHex < 64 or #pubHex < 130 then
|
||
os.execute("rm -rf " .. dir)
|
||
return nil, "无法解析 EC 密钥(d=" .. #dHex .. " pub=" .. #pubHex .. ")"
|
||
end
|
||
local x = pubHex:sub(3, 66)
|
||
local y = pubHex:sub(67, 130)
|
||
local key = {
|
||
pem = pemTxt,
|
||
public = { kty = "EC", crv = "P-256", x = b64urlRaw(hexToBin(x)), y = b64urlRaw(hexToBin(y)) },
|
||
private = { kty = "EC", crv = "P-256", x = b64urlRaw(hexToBin(x)),
|
||
y = b64urlRaw(hexToBin(y)), d = b64urlRaw(hexToBin(dHex:sub(-64))) },
|
||
}
|
||
os.execute("rm -rf " .. dir)
|
||
return key, nil
|
||
end
|
||
|
||
-- DER(EC signature) → raw r||s(各 32 字节)。JWS ES256 必须是这个格式。
|
||
local function codeartsDerToRawSig(der)
|
||
-- SEQUENCE(0x30 len) INTEGER(0x02 rlen r) INTEGER(0x02 slen s)
|
||
local i = 1
|
||
if der:byte(i) ~= 0x30 then return nil end
|
||
i = i + 1
|
||
local l = der:byte(i)
|
||
if l and l > 0x80 then i = i + (l - 0x80) end
|
||
i = i + 1
|
||
if der:byte(i) ~= 0x02 then return nil end
|
||
i = i + 1
|
||
local rlen = der:byte(i); i = i + 1
|
||
local r = der:sub(i, i + rlen - 1); i = i + rlen
|
||
if der:byte(i) ~= 0x02 then return nil end
|
||
i = i + 1
|
||
local slen = der:byte(i); i = i + 1
|
||
local s = der:sub(i, i + slen - 1)
|
||
local function pad(v)
|
||
-- 去掉前导 0x00(DER 的正数填充),再左侧补零到 32 字节
|
||
while #v > 32 and v:byte(1) == 0 do v = v:sub(2) end
|
||
if #v < 32 then v = string.rep("\0", 32 - #v) .. v end
|
||
return v
|
||
end
|
||
return pad(r) .. pad(s)
|
||
end
|
||
|
||
-- codeartsDpopSign(key, htm, htu):产出 dpop+jwt(走真实 openssl 签名)。
|
||
local function codeartsDpopSign(key, htm, htu)
|
||
local header = json.encode({ alg = "ES256", typ = "dpop+jwt", jwk = key.public })
|
||
local payload = json.encode({ htm = htm, htu = htu,
|
||
iat = (os.time and os.time() or 0),
|
||
jti = sha256_hex(tostring(os.time()) .. tostring(math.random(1e12)))
|
||
.. sha256_hex(tostring(math.random(1e12))):sub(1, 32) })
|
||
local signingInput = b64urlRaw(header) .. "." .. b64urlRaw(payload)
|
||
local dir = "/tmp/llmsproxy-dpopsig-" .. tostring(math.random(1e12))
|
||
os.execute("mkdir -p " .. dir .. " 2>/dev/null")
|
||
local pemPath, inPath = dir .. "/k.pem", dir .. "/in"
|
||
local pf = io.open(pemPath, "w")
|
||
if not pf then os.execute("rm -rf " .. dir) return nil, "无法写临时 PEM" end
|
||
pf:write(key.pem)
|
||
pf:close()
|
||
local inf = io.open(inPath, "w")
|
||
if not inf then os.execute("rm -rf " .. dir) return nil, "无法写签名输入" end
|
||
inf:write(signingInput)
|
||
inf:close()
|
||
local h = io.popen("openssl dgst -sha256 -sign " .. pemPath .. " " .. inPath
|
||
.. " 2>/dev/null | od -An -tx1 | tr -d ' \n'")
|
||
local derHex = h and (h:read("*a") or ""):gsub("%s", "") or ""
|
||
if h then h:close() end
|
||
os.execute("rm -rf " .. dir)
|
||
if derHex == "" then return nil, "openssl 签名失败" end
|
||
local raw = codeartsDerToRawSig(hexToBin(derHex))
|
||
if not raw then return nil, "DER→raw 转换失败" end
|
||
return signingInput .. "." .. b64urlRaw(raw), nil
|
||
end
|
||
|
||
-- codeartsRequestToken(body, key):向华为 STS 端点带 DPoP 换 token。
|
||
-- 照参考 requestToken:POST 表单 + DPoP 头;响应 credentials{access_key_id,
|
||
-- secret_access_key, security_token, expiration} + refresh_token。
|
||
-- 终态判定只认两件事(照参考):`invalid_grant` 或 error_code 含
|
||
-- `ExpiredRefreshToken`;**不认 InvalidDPoPHeader**(那是一次请求层面的拒绝,
|
||
-- 时钟偏差/重放/抖动都会触发,当终态会把完好账号一步标死)。
|
||
local function codeartsRequestToken(body, key)
|
||
local dpop, derr = codeartsDpopSign(key, "POST", CODEARTS.sts_token)
|
||
if not dpop then return nil, derr end
|
||
local parts = {}
|
||
for k, v in pairs(body) do
|
||
parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v)
|
||
end
|
||
local resp, err = __llmsproxy_http({
|
||
url = CODEARTS.sts_token, method = "POST",
|
||
headers = { ["DPoP"] = dpop, ["Content-Type"] = "application/x-www-form-urlencoded" },
|
||
body = table.concat(parts, "&"),
|
||
})
|
||
if err then return nil, "network error: " .. tostring(err) end
|
||
if not resp then return nil, "无响应" end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then rec = nil end
|
||
if resp.status ~= 200 or type(rec) ~= "table" or type(rec.credentials) ~= "table" then
|
||
local code = tostring(rec and rec.error_code or "")
|
||
local emsg = tostring(rec and (rec.error_msg or rec.error) or "")
|
||
local terminal = (rec and rec.error == "invalid_grant")
|
||
or code:find("ExpiredRefreshToken", 1, true) ~= nil
|
||
return nil, "token 请求失败: HTTP " .. tostring(resp.status) .. " " .. code .. " " .. emsg,
|
||
terminal
|
||
end
|
||
return rec, nil
|
||
end
|
||
|
||
-- codeartsCredentialFrom(token, pkce, key):组装持久化凭据(照参考
|
||
-- credentialFromTokenResponse:含 refresh 所需的 code_verifier 与 DPoP 私钥 JWK)。
|
||
local function codeartsCredentialFrom(token, pkce, key)
|
||
local c = token.credentials or {}
|
||
return {
|
||
access_key_id = c.access_key_id or "",
|
||
secret_access_key = c.secret_access_key or "",
|
||
security_token = c.security_token or "",
|
||
expires_at = c.expiration or "",
|
||
refresh_token = token.refresh_token,
|
||
code_verifier = pkce.verifier,
|
||
dpop_private_key_jwk = key.private,
|
||
}
|
||
end
|
||
|
||
-- buildCodeartsAuthorizeUrl(port, pkce, ticketId):照参考 buildOAuthLoginUrl 逐字拼。
|
||
-- ⚠️ 三处易错(参考注释都点名了):
|
||
-- 1. `code_challenge_method=SHA-256` —— 不是 RFC 的 S256!portal 以此识别
|
||
-- OAuth 授权,错值会**回退到旧 ticket 流程**(看起来"也能用",但不是我们要的)。
|
||
-- 2. 参数里**不得**出现 `auth_callback_url` —— portal 仅凭 port 参数构造回调,
|
||
-- 多余的回调地址会被视为异常并回退旧流程。
|
||
-- 3. 端口必须 ≥10000。
|
||
local function buildCodeartsAuthorizeUrl(port, pkce, ticketId)
|
||
return CODEARTS.portal_authorize
|
||
.. "?theme=" .. CODEARTS.oauth_theme
|
||
.. "&locale=" .. CODEARTS.oauth_locale
|
||
.. "&uri_scheme=" .. CODEARTS.client_id
|
||
.. "&client_id=" .. CODEARTS.client_id
|
||
.. "&port=" .. tostring(port)
|
||
.. "&code_challenge=" .. pkce.challenge
|
||
.. "&code_challenge_method=SHA-256"
|
||
.. "&ticket_id=" .. rfc3986Escape(ticketId)
|
||
.. "&plugin-name=" .. CODEARTS.login_plugin_name
|
||
.. "&plugin-version=" .. CODEARTS.login_plugin_version
|
||
end
|
||
|
||
-- codeartsTicketPoll(ticketId, secret):旧流程回退时轮询 ticket 换凭据。
|
||
-- 照参考 pollForCredential:GET ?ticket_id&secret,头带 plugin-name/version,
|
||
-- 最多 120 次、每次间隔 1s,瞬时失败跳过。
|
||
local function codeartsTicketPoll(ticketId, secret, pluginName, pluginVersion)
|
||
local url = CODEARTS_CREDENTIAL_ENDPOINT .. "?ticket_id=" .. rfc3986Escape(ticketId)
|
||
.. "&secret=" .. rfc3986Escape(secret)
|
||
for _ = 1, 120 do
|
||
local resp = __llmsproxy_http({
|
||
url = url, method = "GET",
|
||
headers = { ["Content-Type"] = "application/json;charset=UTF-8",
|
||
["plugin-name"] = pluginName or CODEARTS_TICKET_PLUGIN_NAME,
|
||
["plugin-version"] = pluginVersion or CODEARTS_TICKET_PLUGIN_VERSION },
|
||
})
|
||
if resp and resp.status == 200 then
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if ok and type(rec) == "table" then
|
||
-- 两种形状(照参考 parseCredentialResponse)
|
||
local c = rec.credential
|
||
if type(c) == "table" and c.access and (c.securitytoken or c.securityToken) then
|
||
return { access_key_id = c.access, secret_access_key = c.secret or "",
|
||
security_token = c.securitytoken or c.securityToken,
|
||
expires_at = c.expires_at or c.expiresAt or "",
|
||
domain_id = rec.domain_id or "", user_id = rec.user_id or "",
|
||
user_name = rec.user_name or "" }
|
||
end
|
||
local r = rec.result
|
||
if type(r) == "table" and r.accessKeyId and r.securityToken then
|
||
return { access_key_id = r.accessKeyId, secret_access_key = r.secretAccessKey or "",
|
||
security_token = r.securityToken,
|
||
expires_at = r.expiration or r.expiresAt or "" }
|
||
end
|
||
end
|
||
end
|
||
-- 参考的 sleep(1000);单 VM 里不能长阻塞,故用较短的等待并显式让出
|
||
os.execute("sleep 1")
|
||
end
|
||
return nil, "CodeArts 登录超时"
|
||
end
|
||
|
||
-- 旧 ticket 流程(portal 判定新式不可用时回退)
|
||
local CODEARTS_LOGIN_BASE = "https://devcloud.cn-north-4.huaweicloud.com/doer/redirect"
|
||
local HUAWEI_AUTH_BASE = "https://auth.huaweicloud.com/authui/login.html"
|
||
local CODEARTS_CREDENTIAL_ENDPOINT =
|
||
"https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/login/ticket"
|
||
local CODEARTS_TICKET_PLUGIN_NAME = "snap_jetbrains"
|
||
local CODEARTS_TICKET_PLUGIN_VERSION = "26.3.3"
|
||
|
||
-- 生成 PKCE 配对(照参考 generatePkcePair:verifier = 48 随机字节的 base64url,
|
||
-- challenge = sha256(verifier) 的 base64url)。
|
||
-- 48 字节 base64url = 64 字符,符合 RFC 7636 的 43..128。
|
||
local function codeartsPkcePair()
|
||
-- 用 openssl 取 48 字节随机(Lua 的 math.random 熵不够)
|
||
local h = io.popen("openssl rand 48 2>/dev/null | base64 -w0")
|
||
local raw = h and h:read("*a") or ""
|
||
if h then h:close() end
|
||
raw = (raw:gsub("%s", ""))
|
||
local verifier = (raw:gsub("+", "-"):gsub("/", "_"):gsub("=", ""))
|
||
if #verifier < 43 then
|
||
return nil, "无法生成 PKCE verifier"
|
||
end
|
||
local challenge = b64urlRaw(hexToBin(sha256_hex(verifier)))
|
||
return { verifier = verifier, challenge = challenge }
|
||
end
|
||
|
||
-- ---- Qoder 每日活动领取(照参考 qoder-credits.ts 逐行移植)----
|
||
--
|
||
-- 参考用**抓包 + 用户报障**定位过三条会「误报」的陷阱,全部在此钉死:
|
||
-- ① 「无可领活动」必须报 inactive,**不能**报 already-claimed。
|
||
-- 旧实现 targets 为空就说"今天已领取" ⇒ 只要服务端没下发可领项
|
||
-- (含请求头不完整 / 未到刷新时间 / 本就无活动)界面就谎报已领,
|
||
-- 与官方 IDE 的"可领取"直接矛盾(用户报障原文:
|
||
-- 「没领过却显示今日已领取,去 IDE 看还能领」)。
|
||
-- ② 活动每日 **10:00(UTC+8)**刷新。刷新**之前**看到的 CLAIMED 属于
|
||
-- **昨天** —— 此时说"今天已领取"是谎报,且不可逆(用户以为今天不必再领)。
|
||
-- 必须用**算术平移**判 UTC+8,不能用本机时区(出差/改时区会判错)。
|
||
-- ③ 未开通的账号要给出**可操作**提示(去官方客户端登录一次),
|
||
-- 而不是含糊的"没有可领取的活动"。
|
||
-- 另:一个账号可能同时有**多个** CLAIM_BENEFIT 活动,逐个领取而非只领第一个。
|
||
|
||
-- 可被 set_endpoints 覆盖(测试指到假上游用)。
|
||
-- 可被 set_endpoints 覆盖(测试指到假上游用)。
|
||
QODER_SASH_BASE = "https://openapi.qoder.sh"
|
||
|
||
-- UTC+8 偏移(毫秒)。活动按 UTC+8 结算,口径与参考一致。
|
||
local QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000
|
||
local QODER_REFRESH_HOUR_UTC8 = 10
|
||
|
||
-- hasQoderCampaignRefreshedToday():今天的活动列表是否已刷新。
|
||
local function qoderCampaignRefreshed(nowMs)
|
||
-- nowMs 可注入(判据要覆盖"刷新前/刷新后"两个方向;本机恰是 UTC+8,
|
||
-- 只用真实时刻会让「用本机时区判」这个变异在这台机上不可判定 —— 判据不可达)。
|
||
nowMs = nowMs or ((os.time and os.time() or 0) * 1000)
|
||
-- **必须用算术平移**判 UTC+8,不能用 os.date("*t")(本机时区):
|
||
-- 活动按 UTC+8 结算,取本机时区在出差/改时区时会判错方向
|
||
-- (偏东提前把当天记为已处理、真漏领;偏西一天判两次)。
|
||
local utc8 = os.date("!*t", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
|
||
return (utc8.hour or 0) >= QODER_REFRESH_HOUR_UTC8
|
||
end
|
||
|
||
-- 读数字/字符串(容忍字符串形式与缺失),照参考 readNumber/readString
|
||
local function qReadNum(src, key)
|
||
if type(src) ~= "table" then return nil end
|
||
local v = src[key]
|
||
if type(v) == "number" then return v end
|
||
if type(v) == "string" and v ~= "" then return tonumber(v) end
|
||
return nil
|
||
end
|
||
local function qReadStr(src, key)
|
||
if type(src) ~= "table" then return nil end
|
||
local v = src[key]
|
||
if type(v) == "string" and v ~= "" then return v end
|
||
return nil
|
||
end
|
||
|
||
-- parseQoderCampaigns:解析活动列表响应
|
||
local function qoderParseCampaigns(body)
|
||
if type(body) ~= "table" or type(body.campaigns) ~= "table" then
|
||
-- 形状非法时仍可取 showCampaign/claimable(参考对非数组容忍)
|
||
if type(body) ~= "table" then return nil end
|
||
end
|
||
local out = { campaigns = {}, claimable = body.claimable == true, showCampaign = body.showCampaign == true }
|
||
for _, it in ipairs(body.campaigns or {}) do
|
||
local id = qReadStr(it, "campaignId")
|
||
if id then
|
||
out.campaigns[#out.campaigns + 1] = {
|
||
campaignId = id,
|
||
actionType = qReadStr(it, "actionType"),
|
||
claimStatus = qReadStr(it, "claimStatus"),
|
||
amount = qReadNum(it.benefit, "amount"),
|
||
}
|
||
end
|
||
end
|
||
return out
|
||
end
|
||
|
||
-- qoderLoadCampaigns(plat, cred):拉活动列表,返回 (parsed, nil) 或 (nil, err)
|
||
local function qoderLoadCampaigns(plat, cred)
|
||
local hdrs = nil
|
||
if plat.headers_fn then
|
||
local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred)
|
||
if okh and type(h) == "table" then hdrs = h end
|
||
end
|
||
if not hdrs then
|
||
hdrs = { ["Authorization"] = "Bearer " .. tostring(cred.access_token or ""),
|
||
["Accept"] = "application/json", ["Cosy-ClientType"] = "10", ["User-Agent"] = "Qoder" }
|
||
end
|
||
local resp, err = __llmsproxy_http({
|
||
url = QODER_SASH_BASE .. "/sash/api/v1/me/campaigns", method = "GET", headers = hdrs,
|
||
})
|
||
if err then return nil, "活动列表查询失败: " .. tostring(err) end
|
||
if not resp or resp.status ~= 200 then
|
||
return nil, "活动列表查询失败: HTTP " .. tostring(resp and resp.status)
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then return nil, "活动列表响应无法解析" end
|
||
return qoderParseCampaigns(rec), nil
|
||
end
|
||
|
||
-- qoderUsageRaw(plat, cred):取用量原始响应(判"未开通"用)
|
||
local function qoderUsageRaw(plat, cred)
|
||
local hdrs = nil
|
||
if plat.headers_fn then
|
||
local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred)
|
||
if okh and type(h) == "table" then hdrs = h end
|
||
end
|
||
local resp = nil
|
||
if hdrs then
|
||
resp, _ = __llmsproxy_http({ url = QODER_SASH_BASE .. "/sash/api/v2/me/usage", method = "GET", headers = hdrs })
|
||
end
|
||
if not resp or resp.status ~= 200 then return nil end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then return nil end
|
||
return rec
|
||
end
|
||
|
||
-- qoderClaimCampaign(plat, cred, campaignId):领取单个活动。
|
||
-- ⚠️ 幂等判据是响应体的 `replayed`,**不是** HTTP 状态码:重复领取同样回 200,
|
||
-- 但 replayed=true 且不含 benefit(只看状态码会把"今天已领"误报成"成功 +100")。
|
||
-- ⚠️ 请求体必须是**空串**(抓包实测 content-length: 0)。
|
||
local function qoderClaimCampaign(plat, cred, campaignId)
|
||
local hdrs = nil
|
||
if plat.headers_fn then
|
||
local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred)
|
||
if okh and type(h) == "table" then hdrs = h end
|
||
end
|
||
if not hdrs then hdrs = { ["Cosy-ClientType"] = "10" } end
|
||
hdrs["Content-Type"] = "application/json"
|
||
local resp, err = __llmsproxy_http({
|
||
url = QODER_SASH_BASE .. "/sash/api/v1/me/campaigns/"
|
||
.. rfc3986Escape(campaignId) .. "/claim",
|
||
method = "POST", headers = hdrs, body = "",
|
||
})
|
||
if err then return { kind = "failed", message = tostring(err) } end
|
||
if not resp or resp.status ~= 200 then
|
||
return { kind = "failed", message = "领取失败: HTTP " .. tostring(resp and resp.status) }
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then return { kind = "failed", message = "领取响应无法解析" } end
|
||
if rec.replayed == true then return { kind = "already-claimed", message = "今天已领取" } end
|
||
local st = rec.status
|
||
if st ~= nil and st ~= "CLAIMED" then
|
||
return { kind = "failed", message = "领取未成功(status=" .. tostring(st) .. ")" }
|
||
end
|
||
local amount = qReadNum(rec, "amount") or qReadNum(rec.benefit, "amount") or 0
|
||
return { kind = "claimed", amount = amount }
|
||
end
|
||
|
||
-- qoderClaim(plat, tok, args):完整活动领取(多活动逐个领)。
|
||
local function qoderClaim(plat, tok, args)
|
||
local cred = (args and args.credential) or nil
|
||
if type(cred) ~= "table" then return nil, "缺少凭据对象" end
|
||
local parsed, lerr = qoderLoadCampaigns(plat, cred)
|
||
if not parsed then return { done = true, kind = "failed", message = lerr } end
|
||
|
||
local refreshed = qoderCampaignRefreshed()
|
||
local targets = {}
|
||
for _, c in ipairs(parsed.campaigns) do
|
||
if c.actionType == "CLAIM_BENEFIT" and c.claimStatus == "CLAIMABLE" then
|
||
targets[#targets + 1] = c
|
||
end
|
||
end
|
||
|
||
if #targets == 0 then
|
||
-- ① 领过?—— 但**仅限刷新之后**(刷新前那条 CLAIMED 属于昨天)
|
||
local claimedBefore = false
|
||
for _, c in ipairs(parsed.campaigns) do
|
||
if c.actionType == "CLAIM_BENEFIT" and c.claimStatus == "CLAIMED" then claimedBefore = true break end
|
||
end
|
||
if claimedBefore then
|
||
if not refreshed then
|
||
-- ★ covers_today = false:这次看到的是**刷新前那一轮**的痕迹,
|
||
-- 因此**不得**据此认定"今天已处理"。
|
||
--
|
||
-- 参考的真实缺陷(2026-10-02,每天静默漏领 100 积分):上游 10:00(UTC+8)
|
||
-- 才刷新活动,而自动签到在上午跑 —— 上午那次看到的是**昨天**那条 CLAIMED。
|
||
-- 若拿它记账,当天 10 点刷新后就再也不会去领。
|
||
--
|
||
-- 修法不靠"给每个渠道配刷新时刻表"(必然漂移),而是**让产出方自己
|
||
-- 回答**"我这条结果覆盖的是不是今天这一轮";记账方只数
|
||
-- "有几条能证明今天已被处理",完全不需要知道任何渠道的日界细节。
|
||
return { done = true, kind = "inactive", covers_today = false,
|
||
message = "今天的每日活动尚未刷新(每日 10:00(UTC+8)刷新),"
|
||
.. "当前看到的是昨天那一轮,请稍后再来领取。" }
|
||
end
|
||
return { done = true, kind = "already-claimed", covers_today = true,
|
||
message = "今天已领取" }
|
||
end
|
||
-- ② 未开通?(两条判据同时满足,避免把"正在刷新"误报成未开通)
|
||
local usage = qoderUsageRaw(plat, cred)
|
||
local hasBenefit = false
|
||
for _, c in ipairs(parsed.campaigns) do
|
||
if c.actionType == "CLAIM_BENEFIT" then hasBenefit = true break end
|
||
end
|
||
local addOnMissing = false
|
||
if type(usage) == "table" then
|
||
-- ⚠️ 判「字段是否存在」而非「remaining 是否为 0」:
|
||
-- 后者对"额度用光"与"从未开通"不可区分。
|
||
local d = type(usage.data) == "table" and usage.data or usage
|
||
addOnMissing = (d.addOnQuota == nil)
|
||
end
|
||
if (not hasBenefit) and addOnMissing then
|
||
return { done = true, kind = "inactive", action_required = true,
|
||
message = "该账号尚未在 Qoder 侧开通每日领取(每日 100 Credits)。"
|
||
.. "请先用 Qoder 官方客户端登录一次该账号,开通后再回来领取。" }
|
||
end
|
||
return { done = true, kind = "inactive", message = "当前没有可领取的活动" }
|
||
end
|
||
|
||
-- 逐个领取
|
||
local total, firstErr, anyClaimed, allReplayed = 0, nil, false, true
|
||
for _, t in ipairs(targets) do
|
||
local r = qoderClaimCampaign(plat, cred, t.campaignId)
|
||
if r.kind == "claimed" then
|
||
anyClaimed = true
|
||
allReplayed = false
|
||
total = total + (tonumber(r.amount) or 0)
|
||
elseif r.kind == "already-claimed" then
|
||
-- 保持 allReplayed(这个活动此前已领)
|
||
else
|
||
allReplayed = false
|
||
if not firstErr then firstErr = r.message end
|
||
end
|
||
end
|
||
if anyClaimed then
|
||
return { done = true, kind = "success", message = "已领取 " .. tostring(total) .. " Credits" }
|
||
end
|
||
if allReplayed then
|
||
return { done = true, kind = "already-claimed", message = "今天已领取" }
|
||
end
|
||
return { done = true, kind = "failed", message = firstErr or "领取失败" }
|
||
end
|
||
|
||
-- ---- 有道 LobsterAI 每日活动领取(照参考 lobsterai-credits.ts 逐行移植)----
|
||
--
|
||
-- 三步流程,判定顺序把「业务正常状态」与「真失败」严格分开:
|
||
-- ① 槽位查询失败 → failed;slotState≠available 或无 activityCode → inactive
|
||
-- ② 上下文查询失败 → failed;claimedToday → already-claimed;
|
||
-- actions 不含 check_in → inactive
|
||
-- ③ POST .../{activityCode}/actions/check_in(带客户端幂等键)→ 成功
|
||
-- 参考注释特别强调:无可用活动是**正常业务状态**,不能显示成"领取失败"。
|
||
-- ---- Qoder 设备身份(Cosy-MachineToken / Cosy-MachineType)----
|
||
--
|
||
-- 逐行对齐参考 qoder-machine.ts。参考用抓包 + 消融实验定位过:**缺这对头时
|
||
-- 服务端不下发「可领取」的活动**(只回 1 条 VIEW_DETAILS 而非
|
||
-- CLAIM_BENEFIT/CLAIMABLE/amount:100),症状正是用户报的
|
||
-- 「没领过却显示今日已领取,去 IDE 看还能领」。要点:
|
||
-- 1. 两值**必须成对**出现,缺一即失效(消融表:全套 − 任一个 → 退回 1 条)。
|
||
-- 2. 主路径是实时调 `runtime-info`:参数顺序**必须是 `[environment, '--account-stdin']`**,
|
||
-- environment 取 '3'(对应 asar 的 'global' 分支)。漏环境值会拿到另一套身份。
|
||
-- stdin 传 `{"account":""}`,输出是含 `{` 的单行 JSON(取 machineToken/machineType)。
|
||
-- 3. 退路是磁盘缓存 `machine_token.json`(token/type 字段)。
|
||
-- 4. 两者都拿不到时**照常发请求**(不带这两个头)—— 保守降级,
|
||
-- 纯插件登录、未装桌面端的用户没有该文件,不能让整个积分功能报错。
|
||
-- 5. 缓存解析结果:拿不到时也记 null,不反复读盘/反复 spawn。
|
||
local QODER_MACHINE_ENVIRONMENT = "3"
|
||
|
||
local function qoderMachineCache()
|
||
plugin.state.qoderMachine = plugin.state.qoderMachine or { done = false }
|
||
return plugin.state.qoderMachine
|
||
end
|
||
|
||
-- 读单个 machine_token.json(形状不符/读取失败返回 nil)。
|
||
local function qoderParseMachineFile(path)
|
||
local f = io.open(path, "r")
|
||
if not f then return nil end
|
||
local raw = f:read("*a")
|
||
f:close()
|
||
local ok, rec = pcall(json.decode, raw or "")
|
||
if not ok or type(rec) ~= "table" then return nil end
|
||
local token, typ = rec.token, rec.type
|
||
if type(token) ~= "string" or token == "" then return nil end
|
||
if type(typ) ~= "string" or typ == "" then return nil end
|
||
return { token = token, type = typ }
|
||
end
|
||
|
||
-- 候选路径(照参考 candidatePaths:env 覆盖 → APPDATA → macOS → Linux)
|
||
local function qoderMachineCandidatePaths()
|
||
local out = {}
|
||
local override = os.getenv and os.getenv("QODER_MACHINE_TOKEN_PATH")
|
||
if type(override) == "string" and override ~= "" then return { override } end
|
||
local appdata = os.getenv and os.getenv("APPDATA")
|
||
if type(appdata) == "string" and appdata ~= "" then
|
||
for _, d in ipairs({ "Qoder", "Qoder CN" }) do
|
||
out[#out + 1] = appdata .. "/" .. d .. "/SharedClientCache/cache/machine_token.json"
|
||
end
|
||
end
|
||
local home = os.getenv and (os.getenv("HOME") or os.getenv("USERPROFILE")) or nil
|
||
if type(home) == "string" and home ~= "" then
|
||
for _, d in ipairs({ "Qoder", "Qoder CN" }) do
|
||
out[#out + 1] = home .. "/Library/Application Support/" .. d
|
||
.. "/SharedClientCache/cache/machine_token.json"
|
||
out[#out + 1] = home .. "/.config/" .. d .. "/SharedClientCache/cache/machine_token.json"
|
||
end
|
||
end
|
||
return out
|
||
end
|
||
|
||
-- 定位 runtime-info:<home>/<dataDir>/.bin/umid-*/runtime-info(目录名带哈希,必须枚举)
|
||
local function qoderFindRuntimeInfo()
|
||
if not (os.getenv and os.getenv("HOME")) then return nil end
|
||
local exeName = "runtime-info"
|
||
for _, dirName in ipairs({ ".qoder", ".qoder-cn" }) do
|
||
local binDir = os.getenv("HOME") .. "/" .. dirName .. "/.bin"
|
||
local h = io.popen("ls -1 " .. binDir .. " 2>/dev/null")
|
||
if h then
|
||
local found
|
||
for line in h:lines() do
|
||
if line:sub(1, 5) == "umid-" then found = line break end
|
||
end
|
||
h:close()
|
||
if found then
|
||
local exe = binDir .. "/" .. found .. "/" .. exeName
|
||
local f = io.open(exe, "rb")
|
||
if f then f:close() return exe end
|
||
end
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
-- 实时生成(照参考:参数顺序 [env, '--account-stdin'],stdin 传 {"account":""})
|
||
local function qoderReadFromRuntimeInfo()
|
||
local exe = qoderFindRuntimeInfo()
|
||
if not exe then return nil end
|
||
-- 用临时文件喂 stdin:命令行传 JSON 的引号在 shell 下不可靠
|
||
local tmp = "/tmp/llmsproxy-qoder-stdin-" .. tostring(math.random(1e12))
|
||
local f = io.open(tmp, "w")
|
||
if not f then return nil end
|
||
f:write('{"account":""}')
|
||
f:close()
|
||
local cmd = ("timeout 20 %s %s %s < %s 2>/dev/null"):format(
|
||
exe, QODER_MACHINE_ENVIRONMENT, "--account-stdin", tmp)
|
||
local h = io.popen(cmd)
|
||
local out = h and h:read("*a") or ""
|
||
if h then h:close() end
|
||
os.remove(tmp)
|
||
if out == "" then return nil end
|
||
-- 输出形如(单行 JSON):取第一个 '{' 起
|
||
local i = out:find("{", 1, true)
|
||
if not i then return nil end
|
||
local ok, rec = pcall(json.decode, out:sub(i))
|
||
if not ok or type(rec) ~= "table" then return nil end
|
||
local token, typ = rec.machineToken, rec.machineType
|
||
if type(token) ~= "string" or token == "" then return nil end
|
||
if type(typ) ~= "string" or typ == "" then return nil end
|
||
return { token = token, type = typ }
|
||
end
|
||
|
||
-- qoderMachineIdentity():返回 {token,type} 或 nil(拿不到时降级,不报错)。
|
||
qoderMachineIdentity = function()
|
||
local c = qoderMachineCache()
|
||
if c.done then return c.identity end
|
||
local id = qoderReadFromRuntimeInfo()
|
||
if not id then
|
||
for _, path in ipairs(qoderMachineCandidatePaths()) do
|
||
id = qoderParseMachineFile(path)
|
||
if id then break end
|
||
end
|
||
end
|
||
c.done = true
|
||
c.identity = id
|
||
return id
|
||
end
|
||
|
||
-- 可被 set_endpoints 覆盖(测试指到假上游用)。
|
||
LOBSTERAI_ACTIVITY_BASE = "https://lobsterai-server.youdao.com"
|
||
|
||
local function lobsteraiActivityHeaders(cred)
|
||
return {
|
||
["Authorization"] = "Bearer " .. tostring(cred.access_token or ""),
|
||
["Accept"] = "application/json",
|
||
["Content-Type"] = "application/json",
|
||
["User-Agent"] = "LobsterAI/0.1.0",
|
||
}
|
||
end
|
||
|
||
-- lobsteraiClaim(plat, tok, args):完整 3 步领取。
|
||
-- 与 codearts 同款:自己发多个请求,用 done 哨兵回给 claim_daily。
|
||
local function lobsteraiClaim(plat, tok, args)
|
||
local cred = (args and args.credential) or nil
|
||
if type(cred) ~= "table" then return nil, "缺少凭据对象" end
|
||
local hdrs = lobsteraiActivityHeaders(cred)
|
||
-- clientVersion 必须是**动态真值**(与交换/续期/签到同一口径)
|
||
local cver = lobsteraiClientVersion(plat)
|
||
|
||
-- ① 槽位
|
||
local slotURL = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/slot?placement=desktop_sidebar"
|
||
.. "&clientVersion=" .. rfc3986Escape(cver)
|
||
.. "&containerApiVersion=2&platform=win32"
|
||
local sresp, serr = __llmsproxy_http({ url = slotURL, method = "GET", headers = hdrs })
|
||
if serr or not sresp or sresp.status ~= 200 then
|
||
return { done = true, kind = "failed",
|
||
message = "活动槽位查询失败: " .. tostring(serr or (sresp and sresp.status)) }
|
||
end
|
||
local sok, srec = pcall(json.decode, sresp.body or "")
|
||
if not sok or type(srec) ~= "table" or srec.code ~= 0 or type(srec.data) ~= "table" then
|
||
return { done = true, kind = "failed", message = "活动槽位信封异常" }
|
||
end
|
||
local act = type(srec.data.activity) == "table" and srec.data.activity or {}
|
||
local slotState = tostring(srec.data.slotState or "")
|
||
local activityCode = tostring(act.activityCode or "")
|
||
local configRevision = tonumber(act.configRevision) or 0
|
||
if slotState ~= "available" or activityCode == "" then
|
||
-- 正常业务状态,不是失败
|
||
return { done = true, kind = "inactive", message = "无可用活动(slotState=" .. slotState .. ")" }
|
||
end
|
||
|
||
-- ② 上下文
|
||
local ctxURL = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/"
|
||
.. rfc3986Escape(activityCode) .. "/context?configRevision=" .. tostring(configRevision)
|
||
local cresp, cerr = __llmsproxy_http({ url = ctxURL, method = "GET", headers = hdrs })
|
||
if cerr or not cresp or cresp.status ~= 200 then
|
||
return { done = true, kind = "failed",
|
||
message = "活动上下文查询失败: " .. tostring(cerr or (cresp and cresp.status)) }
|
||
end
|
||
local cok, crec = pcall(json.decode, cresp.body or "")
|
||
if not cok or type(crec) ~= "table" or crec.code ~= 0 or type(crec.data) ~= "table" then
|
||
return { done = true, kind = "failed", message = "活动上下文信封异常" }
|
||
end
|
||
local state = type(crec.data.state) == "table" and crec.data.state or {}
|
||
if state.claimedToday == true then
|
||
return { done = true, kind = "already-claimed", message = "今天已签到" }
|
||
end
|
||
local hasCheckIn = false
|
||
for _, a in ipairs(crec.data.actions or {}) do
|
||
if a == "check_in" then hasCheckIn = true break end
|
||
end
|
||
if not hasCheckIn then
|
||
return { done = true, kind = "inactive", message = "当前不可签到" }
|
||
end
|
||
|
||
-- ③ 领取(客户端幂等键)
|
||
local body = json.encode({ configRevision = configRevision,
|
||
idempotencyKey = newToken():sub(1, 8) .. "-" .. newToken():sub(9, 12) .. "-4"
|
||
.. newToken():sub(14, 16) .. "-8" .. newToken():sub(18, 20) .. "-" .. newToken():sub(21, 32),
|
||
payload = {} })
|
||
local url = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/"
|
||
.. rfc3986Escape(activityCode) .. "/actions/check_in"
|
||
local resp, err = __llmsproxy_http({ url = url, method = "POST", headers = hdrs, body = body })
|
||
if err or not resp or resp.status ~= 200 then
|
||
return { done = true, kind = "failed",
|
||
message = "领取请求失败: " .. tostring(err or (resp and resp.status)) }
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" or rec.code ~= 0 then
|
||
local why = (ok and type(rec) == "table") and tostring(rec.msg or rec.message or rec.code) or "信封异常"
|
||
return { done = true, kind = "failed", message = "领取失败: " .. why }
|
||
end
|
||
-- 积分字段三级回退(参考:creditsGranted → rewardCredits → credits)
|
||
local result = type(rec.data) == "table" and type(rec.data.result) == "table" and rec.data.result or {}
|
||
local credit = tonumber(result.creditsGranted) or tonumber(result.rewardCredits) or tonumber(result.credits)
|
||
local msg = "已签到"
|
||
if credit then msg = msg .. ",获得 " .. tostring(credit) .. " 积分" end
|
||
return { done = true, kind = "success", message = msg }
|
||
end
|
||
|
||
-- 绑定 codearts 的领取钩子。它自己跑完 4 步流程(账户→活动→claim→confirm),
|
||
-- 用 done 哨兵返回,claim_daily 识别后不再发送单个请求。
|
||
do
|
||
for _, p in ipairs(PLATFORMS) do
|
||
if p.id == "codearts" then
|
||
p.claim_fn = function(plat, tok, args) return codeartsClaim(plat, tok, args) end
|
||
break
|
||
end
|
||
end
|
||
for _, p in ipairs(PLATFORMS) do
|
||
if p.id == "lobsterai" then
|
||
p.claim_fn = function(plat, tok, args) return lobsteraiClaim(plat, tok, args) end
|
||
break
|
||
end
|
||
end
|
||
for _, p in ipairs(PLATFORMS) do
|
||
if p.id == "qoder" then
|
||
p.claim_fn = function(plat, tok, args) return qoderClaim(plat, tok, args) end
|
||
break
|
||
end
|
||
end
|
||
end
|
||
|
||
|
||
-- 申请的额外出站主机:从平台声明推导(登录域 + 上游域),避免两份清单漂移。
|
||
-- 内核里**不含**任何厂商名 —— 这些只存在于本插件,且需运维在设置页逐个批准。
|
||
local function declaredHosts()
|
||
local seen, out = {}, {}
|
||
for _, p in ipairs(PLATFORMS) do
|
||
for _, h in ipairs(p.hosts or {}) do
|
||
local hn = h:lower()
|
||
if not seen[hn] then seen[hn] = true out[#out+1] = hn end
|
||
end
|
||
end
|
||
-- 上游 base_url 的 host 也要(发对话/签到用)
|
||
for _, p in ipairs(PLATFORMS) do
|
||
local h = (p.base_url or ""):match("^https?://([^/:]+)")
|
||
if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end
|
||
end
|
||
for _, p in ipairs(PLATFORMS) do
|
||
local h = (p.checkin and p.checkin.url or ""):match("^https?://([^/:]+)")
|
||
if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end
|
||
end
|
||
-- 各 flow 自己的端点域名也要(否则授权页/长轮询/账号端点会被出站白名单拦)。
|
||
-- 这些域名与 base_url 往往不同:Loomy 的授权在 open.weixin.qq.com、
|
||
-- 长轮询在 long.open.weixin.qq.com、账号在 account.xfyun.cn;
|
||
-- ZCode 的 init/poll 在 zcode.z.ai(与 base_url 同域但显式声明更清楚)。
|
||
local function addHost(u)
|
||
local h = (u or ""):match("^https?://([^/:]+)")
|
||
if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end
|
||
end
|
||
for _, p in ipairs(PLATFORMS) do
|
||
if p.wechat then
|
||
addHost(p.wechat.auth_url); addHost(p.wechat.qr_url)
|
||
addHost(p.wechat.poll_url); addHost(p.wechat.account_base)
|
||
end
|
||
if p.initpoll then addHost(p.initpoll.init_url); addHost(p.initpoll.poll_url) end
|
||
if p.qr then addHost(p.qr.api_base) end
|
||
if p.device then addHost(p.device.code_url); addHost(p.device.token_url) end
|
||
if p.state then addHost(p.state.state_url); addHost(p.state.token_url); addHost(p.state.account_url) end
|
||
if p.models_remote then
|
||
for _, m in ipairs(p.models_remote.endpoints or {}) do addHost(m.url) end
|
||
end
|
||
-- 能力元数据来源(照参考 opencode-capability)。必须声明,否则出站被拒
|
||
-- 而 refreshCapabilities 的 pcall 会把错误吞掉 —— 表现是"能力永远是空"。
|
||
if p.capability_key then addHost("https://models.dev/api.json") end
|
||
if p.balance then addHost(p.balance.url) end
|
||
if p.checkin_status then addHost(p.checkin_status.url) end
|
||
if p.callback then addHost(p.callback.exchange_url) end
|
||
end
|
||
table.sort(out)
|
||
return out
|
||
end
|
||
plugin.hosts = declaredHosts()
|
||
|
||
-- bodyOf 把 handler 的 req.body(**JSON 字符串**)解成 table。
|
||
--
|
||
-- 为什么需要它:handler 收到的是原始 body 字符串,不是解码后的表(内核不猜
|
||
-- 插件的载荷形状)。载荷坏掉时返回空表而不是报错 —— 调用方随后会因为缺少
|
||
-- 必要字段而得到明确的错误,比一个 JSON 解析异常更好读。
|
||
local function bodyOf(req)
|
||
if not req or not req.body or req.body == "" then return {} end
|
||
if type(req.body) == "table" then return req.body end
|
||
local ok, v = pcall(json.decode, req.body)
|
||
if not ok or type(v) ~= "table" then return {} end
|
||
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(无 +/= )。
|
||
-- ---- 讯飞账号(CAccount)HMAC-SHA1 签名(Loomy 用)----
|
||
-- 逐段复刻参考 loomy-sign.ts。任何偏差都会让账号端点返回鉴权失败。
|
||
--
|
||
-- 内核只有 sha256/hmac_sha256 —— 没有 sha1,也没有 md5。但插件有完整 OS 能力,
|
||
-- 直接用 openssl(本机已装)算 HMAC-SHA1 与 MD5,不在 Lua 里重写密码学原语。
|
||
--
|
||
-- 签名字符串 9 段以 \n 连接,**后两段恒为空串**(不发 x-* 头),
|
||
-- 故最终字符串以两个换行结尾 —— 不要"顺手"去掉尾随换行。
|
||
-- 认证头前缀是 `account {ak}:{sig}`(**不是** Bearer)。
|
||
|
||
-- rfc3986Escape:encodeURIComponent 等价 + 补转 ! ' ( ) *
|
||
rfc3986Escape = function(v)
|
||
v = tostring(v or "")
|
||
v = v:gsub("([^A-Za-z0-9%-%._~])", function(c)
|
||
return string.format("%%%02X", string.byte(c))
|
||
end)
|
||
-- encodeURIComponent 不转 ! ' ( ) * ~,但参考的 escapeRfc3986 要转前五个
|
||
v = v:gsub("!", "%%21"):gsub("'", "%%27"):gsub("%(", "%%28")
|
||
:gsub("%)", "%%29"):gsub("%*", "%%2A")
|
||
return v
|
||
end
|
||
|
||
-- opensslDigest:调 openssl 算摘要。mode 是 openssl 的算法名(md5/sha1)。
|
||
-- hmacKey 非空时用 -hmac;输出 base64(参考用的就是 base64)。
|
||
local function opensslDigest(mode, data, hmacKey)
|
||
-- 用临时文件传原文:命令行的引号/换行/二进制都不可靠
|
||
local inPath = "/tmp/llmsproxy-sig-in-" .. tostring(math.random(1e12))
|
||
local f = io.open(inPath, "wb")
|
||
if not f then return nil, "无法写临时文件" end
|
||
f:write(data)
|
||
f:close()
|
||
local cmd
|
||
if hmacKey and hmacKey ~= "" then
|
||
local keyPath = inPath .. ".key"
|
||
local kf = io.open(keyPath, "wb")
|
||
if not kf then os.remove(inPath) return nil, "无法写密钥文件" end
|
||
kf:write(hmacKey)
|
||
kf:close()
|
||
-- 外层用单引号包 Lua 字符串,内部才能安全地带 shell 的双引号
|
||
cmd = ('openssl dgst -%s -hmac "$(cat %s)" -binary %s 2>/dev/null | base64 -w0')
|
||
:format(mode, keyPath, inPath)
|
||
local h = io.popen(cmd)
|
||
local out = h and h:read("*a") or ""
|
||
if h then h:close() end
|
||
os.remove(keyPath)
|
||
os.remove(inPath)
|
||
return (out ~= "" and (out:gsub("%s+$", ""))) or nil
|
||
end
|
||
cmd = ("openssl dgst -%s -binary %s 2>/dev/null | base64 -w0"):format(mode, inPath)
|
||
local h = io.popen(cmd)
|
||
local out = h and h:read("*a") or ""
|
||
if h then h:close() end
|
||
os.remove(inPath)
|
||
return (out ~= "" and (out:gsub("%s+$", ""))) or nil
|
||
end
|
||
|
||
-- loomyContentMd5:空 body 返回**空串**(不是空串的 md5),与客户端一致。
|
||
local function loomyContentMd5(body)
|
||
if not body or body == "" then return "" end
|
||
return opensslDigest("md5", body, nil) or ""
|
||
end
|
||
|
||
-- loomySigningString:9 段拼串(method,path,query,md5,ctype,date,nonce,"","")
|
||
local function loomySigningString(method, path, query, body, contentType, date, nonce)
|
||
local md5 = loomyContentMd5(body)
|
||
return table.concat({
|
||
method:upper(), path, query or "", md5, contentType or "",
|
||
date, nonce, "", "",
|
||
}, "\n")
|
||
end
|
||
|
||
-- loomyAuthHeaders:生成讯飞账号请求头。
|
||
local function loomyAuthHeaders(ak, aks, method, path, body, contentType)
|
||
local date = os.date("!%a, %d %b %Y %H:%M:%S GMT")
|
||
-- nonce 只要唯一即可(服务端不校验格式)。这里**不能调 newToken** ——
|
||
-- 它定义在文件后段(1518 行),Lua 词法作用域下此处是 nil,调用会崩。
|
||
-- 用内核全局 sha256_hex(随时可用)造一个 32 字节 hex,与参考的 UUID 同量级。
|
||
local nonce = string.format("%s-%s-%s",
|
||
sha256_hex(tostring(os.time()) .. tostring(math.random(1e12))):sub(1, 8),
|
||
sha256_hex(tostring(math.random(1e12))):sub(1, 4),
|
||
sha256_hex(tostring(math.random(1e12))):sub(1, 12))
|
||
local ctype = contentType or "application/json"
|
||
local sts = loomySigningString(method, path, "", body, ctype, date, nonce)
|
||
local sig, err = opensslDigest("sha1", sts, aks)
|
||
if not sig then return nil, err or "签名计算失败" end
|
||
local h = {
|
||
["Authorization"] = "account " .. ak .. ":" .. sig,
|
||
["Date"] = date,
|
||
["Nonce"] = nonce,
|
||
["Content-Type"] = ctype,
|
||
}
|
||
local md5 = loomyContentMd5(body)
|
||
if md5 ~= "" then h["Content-MD5"] = md5 end
|
||
return h
|
||
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 url, method, body = d.token_url, d.token_method or "POST", nil
|
||
if d.poll_query or (d.token_method == "GET_QUERY") then
|
||
-- 形态②(Qoder):GET + query 模板。PKCE 用 verifier(不是 challenge),
|
||
-- 且 nonce 必须是**会话创建时那一个**(服务端按它找回待授权会话)。
|
||
local q = (d.poll_query or "")
|
||
:gsub("{nonce}", sess.device_nonce or sess.nonce or "")
|
||
:gsub("{verifier}", sess.device_verifier or "")
|
||
:gsub("{challenge}", sess.device_challenge or "")
|
||
:gsub("{device_code}", sess.device_code or "")
|
||
url = d.token_url .. "?" .. q
|
||
method = "GET"
|
||
else
|
||
body = (d.token_body
|
||
:gsub("{device_code}", sess.device_code or "")
|
||
:gsub("{verifier}", sess.device_verifier or "")
|
||
:gsub("{challenge}", sess.device_challenge or ""))
|
||
end
|
||
local resp, err = __llmsproxy_http({
|
||
url = url,
|
||
method = method,
|
||
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
|
||
|
||
-- 判据形态分两套(这正是 MiniMax 与 Cline 的核心差异,参考注释反复强调):
|
||
-- minimax(默认):看 HTTP 200 + status 字段;非 200 才看 error 字段。
|
||
-- workos(Cline):看响应体 error 字段(HTTP 可能非 2xx);
|
||
-- slow_down 退避 d.slow_down_step 秒(WorkOS 会持续限流不退避的客户端);
|
||
-- access_denied / expired_token / invalid_grant 同组判失败。
|
||
-- 退避步长不硬编码:slow_down_step 缺省 5(MiniMax 语义)。
|
||
local workos = (d.judge == "workos")
|
||
local status = rec.status
|
||
local ecode = rec.error
|
||
local step = tonumber(d.slow_down_step) or 5
|
||
|
||
-- 「404/pending = 继续轮询」形态(Qoder)。
|
||
-- -- 实测依据(参考注释):qoder.com 的 poll 路径返回 401,而
|
||
-- openapi.qoder.sh 对「无待授权会话」返回 **404**,语义是"还没有会话,继续等"
|
||
-- 而非失败。把 404 当失败会让登录永远失败。
|
||
-- 判定由平台声明 judge="pending404" 驱动,不在这里硬编码平台名。
|
||
if d.judge == "pending404" then
|
||
if resp.status == 404 then return false end
|
||
if resp.status == 200 then
|
||
-- 2xx 但尚无 token:同样继续轮询
|
||
local tok = rec.token or rec.device_token or rec.access_token
|
||
if tok == nil or tok == "" then return false end
|
||
return true, { ok = true, access_token = tok,
|
||
refresh_token = rec.refresh_token or rec.refreshToken,
|
||
account = rec.user_name or rec.userName or "",
|
||
uid = rec.user_id or rec.userId,
|
||
expires_at = rec.expires_at or rec.expiresAt,
|
||
refresh_expires_at = rec.refresh_token_expires_at or rec.refreshTokenExpiresAt }
|
||
end
|
||
return true, { ok = false, reason = "error",
|
||
message = "登录服务返回异常(HTTP " .. tostring(resp.status) .. ")" }
|
||
end
|
||
|
||
if workos then
|
||
if ecode == "authorization_pending" then return false end
|
||
if ecode == "slow_down" then
|
||
sess.device_interval = (sess.device_interval or d.interval or 5) + step
|
||
return false
|
||
end
|
||
if ecode == "access_denied" or ecode == "expired_token" or ecode == "invalid_grant" then
|
||
local msg = rec.error_description or (ecode == "access_denied" and "用户拒绝了授权" or "授权已过期")
|
||
return true, { ok = false, reason = (ecode == "access_denied" and "denied" or "expired"), message = msg }
|
||
end
|
||
if resp.status == 200 and rec.access_token and rec.refresh_token then
|
||
return true, { ok = true, access_token = rec.access_token, refresh_token = rec.refresh_token,
|
||
need_register = true, account = "cline" }
|
||
end
|
||
if resp.status ~= 200 and next(rec) ~= nil then
|
||
return true, { ok = false, reason = "error",
|
||
message = "WorkOS token 轮询失败(HTTP " .. tostring(resp.status) .. ")"
|
||
.. (rec.error_description and (" - " .. rec.error_description) or "") }
|
||
end
|
||
return false -- 2xx 但既无 error 又无 token:等下一拍(避免误报死循环)
|
||
end
|
||
|
||
-- MiniMax 形态(原样保留,已由决策表测试钉死)
|
||
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) + step
|
||
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
|
||
if ecode == "authorization_pending" then return false end
|
||
if ecode == "slow_down" then
|
||
sess.device_interval = (sess.device_interval or d.interval or 5) + step
|
||
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
|
||
|
||
-- wechatExchange:把微信 wx_code 换成 Loomy 凭据(讯飞账号端点,HMAC-SHA1 签名)。
|
||
--
|
||
-- 参考链路(loomy-oauth.ts 注释):bindAuthThirdAccount(code) → {bind, rcode, nickname}
|
||
-- → 若已绑手机号可 bindSkip(rcode) 直接拿 session;未绑则要走 SMS(需人工输入验证码)。
|
||
-- 这里做到 bindAuth 一步:拿到 rcode/nickname 即视为可用的登录上下文,
|
||
-- 并把 bind 标志带进凭据,让上层知道是否需要补手机号(不假装 SMS 已完成)。
|
||
local function wechatExchange(plat, wxCode)
|
||
local w = plat.wechat
|
||
if not w then return nil, "平台未声明 wechat 配置" end
|
||
local path = w.bind_path
|
||
local body = json.encode({
|
||
tcode = { code = wxCode },
|
||
type = "wx",
|
||
appId = w.app_id,
|
||
})
|
||
local headers, herr = loomyAuthHeaders(w.access_key_id, w.access_key_secret,
|
||
"POST", path, body, "application/json")
|
||
if not headers then return nil, "签名失败: " .. tostring(herr) end
|
||
headers["Accept"] = "application/json"
|
||
local resp, err = __llmsproxy_http({
|
||
url = w.account_base .. path, method = "POST", headers = headers, body = body,
|
||
})
|
||
if err then return nil, "账号端点请求失败: " .. err end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then
|
||
return nil, "账号端点响应无法解析(HTTP " .. tostring(resp.status) .. ")"
|
||
end
|
||
-- 参考兼容 data 包裹与顶层两种形态
|
||
local data = type(rec.data) == "table" and rec.data or rec
|
||
local rcode = data.rcode
|
||
if type(rcode) ~= "string" or rcode == "" then
|
||
return nil, "微信授权响应缺少 rcode(code 可能已失效)"
|
||
end
|
||
-- bind 缺失归 0(走绑定流程)—— 保守方向:已绑最多多填一次手机号;
|
||
-- 未绑却跳过会拿到没有手机号的账号。
|
||
local bind = (data.bind == 1) and 1 or 0
|
||
return json.encode({
|
||
access_token = rcode, -- 后续所有步骤用 rcode(wx_code 只用这一次)
|
||
rcode = rcode,
|
||
bind = bind,
|
||
session = data.session or data.token,
|
||
user_id = data.userid or data.user_id,
|
||
nickname = data.nickname,
|
||
headpic = data.headpic,
|
||
phone_bound = bind == 1,
|
||
token_type = "Bearer",
|
||
account_type = "personal",
|
||
}), nil
|
||
end
|
||
|
||
-- deviceRegister:设备码成功后的第二跳(Cline:WorkOS token → Cline token)。
|
||
-- 参考注释强调:两步是必要的(浏览器 transient activation 约 5 秒,阻塞式
|
||
-- 等授权会让 URL 过期)。返回 (access_token, account, nil) 或 (nil, nil, err)。
|
||
local function deviceRegister(plat, payload)
|
||
local r = plat.register
|
||
if not r or not r.url then return payload.access_token, payload.account, nil end
|
||
local body = (r.body or "")
|
||
:gsub("{access_token}", payload.access_token or "")
|
||
:gsub("{refresh_token}", payload.refresh_token or "")
|
||
local resp, err = __llmsproxy_http({
|
||
url = r.url,
|
||
method = r.method or "POST",
|
||
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
|
||
body = body,
|
||
})
|
||
if err then return nil, nil, "token 注册失败: " .. err end
|
||
if resp.status ~= 200 then
|
||
return nil, nil, "token 注册失败(HTTP " .. tostring(resp.status) .. ")"
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then
|
||
return nil, nil, "token 注册响应无法解析"
|
||
end
|
||
-- 响应套 {success, data} 信封(参考 parseClineTokenPayload)
|
||
local data = rec.data or rec
|
||
local access = data.accessToken or data.access_token or data.Token or data.token
|
||
if not access or access == "" then
|
||
return nil, nil, "token 注册响应缺少 accessToken"
|
||
end
|
||
local acct = "cline"
|
||
if type(data.userInfo) == "table" and (data.userInfo.email or data.userInfo.clineUserId) then
|
||
acct = tostring(data.userInfo.email or data.userInfo.clineUserId)
|
||
end
|
||
return access, acct, nil
|
||
end
|
||
|
||
-- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)。
|
||
--
|
||
-- 先查内建目录,再查运行时注入表(plugin.state.injected_platforms)。注入表的存在
|
||
-- 是因为**测试夹具绝不能写成生产源码**:此前 example/googleish/loopbackish/
|
||
-- cookieish 四个夹具直接内联在 PLATFORMS 里,于是它们真的出现在生产 UI(16 个
|
||
-- 平台里有 4 个是夹具)供运维选择。夹具属于测试,不属于发行物 —— 正确形态是
|
||
-- 测试用自己的那几行注入(见 channels/test_platforms 端点),生产目录只留真实厂商。
|
||
local function platformById(id)
|
||
for _, p in ipairs(PLATFORMS) do
|
||
if p.id == id then return p end
|
||
end
|
||
local injected = plugin.state and plugin.state.injected_platforms
|
||
if type(injected) == "table" then
|
||
for _, p in ipairs(injected) do
|
||
if type(p) == "table" and p.id == id then return p end
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
-- allPlatforms 返回内建目录 + 注入表(列表/遍历用)。
|
||
local function allPlatforms()
|
||
local out = {}
|
||
for _, p in ipairs(PLATFORMS) do out[#out + 1] = p end
|
||
local injected = plugin.state and plugin.state.injected_platforms
|
||
if type(injected) == "table" then
|
||
for _, p in ipairs(injected) do
|
||
if type(p) == "table" and p.id then out[#out + 1] = p end
|
||
end
|
||
end
|
||
return out
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 状态
|
||
-- ============================================================================
|
||
|
||
-- ============================================================================
|
||
-- 插件自有持久化(账号池 / 模型黑名单 / 设置 / 日志)
|
||
-- ============================================================================
|
||
--
|
||
-- 为什么插件自己落盘,而不是靠宿主框架的 plugin.state:
|
||
-- 本插件声明 hooks = {},一切状态变更都走自己的 HTTP 路由。宿主的状态自动
|
||
-- flush 由 hook / Call / handler 三条分发路径触发,插件不应把「我的状态会不会
|
||
-- 被写下去」押在宿主某条路径的实现细节上 —— 那是把插件的数据安全外包给宿主。
|
||
-- 参考实现踩过同一课(channel-pack-store.ts):宿主 settings 能力一变,账号
|
||
-- 列表就退化为纯内存,必须改由插件自持文档。
|
||
--
|
||
-- 位置与凭据分离:凭据在 .secrets/(0600,跨插件不可读),这里只放**索引与
|
||
-- 配置**(账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。
|
||
-- 即便这个文件泄露,也不含任何 token。
|
||
--
|
||
-- 写入:tmp + rename 原子替换。读:缺失/损坏 → 用内置默认值(不阻塞启动)。
|
||
local STORE_NAME = ".channels-store.json"
|
||
|
||
-- storeDir 求插件目录:优先 config.yaml 的 plugin_dir,否则用本文件所在目录。
|
||
-- 插件与内核同机、有完整 OS 权限,读自己的配置是能力范围内的事。
|
||
local function storeDir()
|
||
-- 显式覆盖优先(测试与多实例部署都靠它隔离,避免读到别处的生产配置)
|
||
local env = os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")
|
||
if type(env) == "string" and env ~= "" then return env end
|
||
-- 内核交给本插件的目录(ground truth,不需要猜)。
|
||
local kern = __llmsproxy_plugin_dir
|
||
if type(kern) == "string" and kern ~= "" then return kern end
|
||
local s = plugin.state.settings and plugin.state.settings.store_dir
|
||
if type(s) == "string" and s ~= "" then return s end
|
||
-- ⚠️ 优先用内核交给我们的**本插件目录**(`__llmsproxy_plugin_dir`):
|
||
-- 那是 ground truth,不需要猜。此前这里直接落到"读生产 config.yaml"的兜底,
|
||
-- 在**未设 LLMSPROXY_PLUGIN_DIR 的测试里**会命中生产路径 —— 测试读生产存储、
|
||
-- 也写生产存储。一次全量 internal/lua 就把生产账号池覆盖成测试的空 pool
|
||
-- (实测:13 个账号 → 0,凭据还在保险库,症状是"账号凭空消失")。
|
||
-- 判据:internal/lua/store_dir_isolation_test.go。
|
||
for _, path in ipairs({ "/etc/llmsproxy/config.yaml", "/usr/local/etc/llmsproxy/config.yaml" }) do
|
||
local f = io.open(path, "r")
|
||
if f then
|
||
local txt = f:read("*a")
|
||
f:close()
|
||
if txt then
|
||
local d = txt:match("\nplugin_dir:%s*([^\n]%S*)") or txt:match("^plugin_dir:%s*([^\n]%S*)")
|
||
if d and d ~= "" then return d end
|
||
end
|
||
end
|
||
end
|
||
return "/etc/llmsproxy/plugins"
|
||
end
|
||
|
||
-- persistKeys 是要落盘的字段。故意不含 sessions(登录会话是短命态,重启即失效,
|
||
-- 写下去只会留下过期垃圾)与 browser(进程 pid 跨重启无意义)。
|
||
local PERSIST_KEYS = {
|
||
"accounts", "pool", "modelDisabled", "settings",
|
||
"providerOrder", "log", "injected_platforms",
|
||
-- 远端拉到的模型目录缓存(远端为主、兜底补位的结果)。
|
||
-- 缓存它而非每次请求都拉:上游目录是分钟级稳定,UI 却是秒级刷新。
|
||
"remoteModels",
|
||
-- 已失效模型记录(阳性 404 证据)。必须持久化:不落盘的话重启就全忘了,
|
||
-- 用户又要重新被 404 打一遍(照参考 DEAD_MODELS_FILE 的落盘语义)。
|
||
"deadModels",
|
||
-- 通用 TTL 缓存(余额原料等)。持久化可让重启后不立刻重打上游。
|
||
"ttlCache",
|
||
-- 自动领取的"今天已覆盖"记账。必须持久化:不落盘的话每次重启都会
|
||
-- 把当天已领的再领一遍(上游幂等,但会产生无谓请求与日志噪音)。
|
||
"claimCovered",
|
||
-- 模型能力元数据(models.dev 的能力/免费/上下文)。持久化是**必须**的:
|
||
-- 冷启动时要能直接读缓存,否则首个模型列表要么空白、要么等 5MB 下载。
|
||
"capabilities", "capabilitiesMeta",
|
||
-- 目录闸门状态(冷却/in-flight)。也落盘:重启后冷却窗口应继续有效,
|
||
-- 否则"重启即重试坏上游"(照参考把闸门状态随插件存活的语义)。
|
||
"catalogGate",
|
||
}
|
||
|
||
local function persistState()
|
||
local dir = storeDir()
|
||
local path = dir .. "/" .. STORE_NAME
|
||
local snap = {}
|
||
for _, k in ipairs(PERSIST_KEYS) do
|
||
if plugin.state[k] ~= nil then snap[k] = plugin.state[k] end
|
||
end
|
||
local body = json.encode({ version = 1, state = snap })
|
||
if not body then return false, "json.encode 失败" end
|
||
local tmp = path .. ".tmp"
|
||
-- 目录可能还不存在(首次运行、或内核交给我们的目录尚未创建)。
|
||
-- 不 mkdir 就直接 io.open 会失败,而失败只打到 stderr —— 表现为
|
||
-- "状态永不落盘"(重启即丢),日志里一行「状态落盘失败」很容易被忽略。
|
||
local dir = path:match("^(.*)/[^/]+$")
|
||
if dir and dir ~= "" then os.execute("mkdir -p '" .. dir .. "' 2>/dev/null") end
|
||
local w = io.open(tmp, "w")
|
||
if not w then return false, "无法写入 " .. tmp end
|
||
w:write(body)
|
||
w:close()
|
||
os.execute(("chmod 0600 %s 2>/dev/null"):format(tmp))
|
||
local ok = os.rename(tmp, path)
|
||
if not ok then
|
||
os.remove(tmp)
|
||
return false, "rename 失败"
|
||
end
|
||
return true
|
||
end
|
||
|
||
local function applySnapshot(snap)
|
||
for _, k in ipairs(PERSIST_KEYS) do
|
||
if snap[k] ~= nil then plugin.state[k] = snap[k] end
|
||
end
|
||
end
|
||
|
||
-- dropLegacyKernelState 删除内核遗留文件(若存在)。
|
||
--
|
||
-- 必须删:内核的 restore() 在插件源码执行**之后**运行,会把这个文件的内容整体
|
||
-- 覆盖到 plugin.state 上。自有存储已经是权威,留下它等于给"下次启动被旧数据
|
||
-- 覆盖"留一个后门。删它不需要先读它(迁移路径自己会读)。
|
||
local function dropLegacyKernelState()
|
||
local legacy = storeDir() .. "/.channels.state.json"
|
||
local f = io.open(legacy, "r")
|
||
if not f then return end
|
||
f:close()
|
||
os.remove(legacy)
|
||
io.stderr:write("[channels] 已删除内核遗留状态文件(自有存储为权威)\n")
|
||
end
|
||
|
||
-- loadState 读插件自有文档;缺失时**从内核遗留文件迁移一次**。
|
||
--
|
||
-- 历史背景:v2.0.4 曾让内核的 InvokeHandler 补 markDirty,于是状态被内核写到了
|
||
-- `.channels.state.json`(内核的 <plugin>.state.json 命名)。现在内核改动已回退,
|
||
-- 那个文件不会再被内核读写;但生产上已经存在一份(含真实账号池),必须迁移过来,
|
||
-- 否则升级即丢账号 —— 与本次要修的缺陷同类,不能自己再造一次。
|
||
-- 迁移后删除遗留文件:留着它只会让「哪个文件权威」变成永久的疑团。
|
||
local function loadOwnSnapshot()
|
||
local dir = storeDir()
|
||
local f = io.open(dir .. "/" .. STORE_NAME, "r")
|
||
if f then
|
||
local txt = f:read("*a")
|
||
f:close()
|
||
if txt and txt ~= "" then
|
||
local ok, rec = pcall(json.decode, txt)
|
||
if ok and type(rec) == "table" and type(rec.state) == "table" then
|
||
applySnapshot(rec.state)
|
||
return true
|
||
end
|
||
-- 损坏文件不阻塞:用内置默认值(下次写入覆盖它)
|
||
io.stderr:write("[channels] 自有存储损坏,改用默认值\n")
|
||
end
|
||
end
|
||
return false
|
||
end
|
||
|
||
-- migrateLegacyKernelState 把内核遗留的 .channels.state.json 搬进自有文档。
|
||
local function migrateLegacyKernelState()
|
||
local dir = storeDir()
|
||
local legacy = dir .. "/.channels.state.json"
|
||
local f = io.open(legacy, "r")
|
||
if not f then return false end
|
||
local txt = f:read("*a")
|
||
f:close()
|
||
if not txt or txt == "" then return false end
|
||
local ok, rec = pcall(json.decode, txt)
|
||
if not ok or type(rec) ~= "table" or type(rec.state) ~= "table" then
|
||
return false
|
||
end
|
||
applySnapshot(rec.state)
|
||
-- 立即写成自有文档;**只有写成功才删遗留文件** —— 写失败还删它,
|
||
-- 等于用一次迁移把数据弄丢,与本次要修的缺陷同类。
|
||
local written, werr = persistState()
|
||
if written then
|
||
os.remove(legacy)
|
||
io.stderr:write("[channels] 已迁移遗留状态到 " .. STORE_NAME .. "\n")
|
||
else
|
||
io.stderr:write("[channels] 遗留状态迁移写入失败(保留原文件): "
|
||
.. tostring(werr) .. "\n")
|
||
end
|
||
return true
|
||
end
|
||
|
||
local function loadState()
|
||
if loadOwnSnapshot() then
|
||
-- 自有存储已权威:遗留文件必须删掉,否则内核 restore() 会用它覆盖
|
||
dropLegacyKernelState()
|
||
return true
|
||
end
|
||
return migrateLegacyKernelState()
|
||
end
|
||
|
||
-- markDirty = 立即同步落盘(tmp + rename 原子替换)。
|
||
--
|
||
-- 为什么不用「置标志 + 定时 flush」:本插件没有 hooks,45 个端点各自
|
||
-- 直接变更状态,还有大量内部调用(syncAccountSource / claim_daily /
|
||
-- auto_checkin 互调)。把 flush 挂在某个边界上就必须保证**每一条**路径都经过
|
||
-- 它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。同步写在
|
||
-- 变更点,路径覆盖是结构性的,不依赖我数全 45 个入口。
|
||
--
|
||
-- 代价可接受:状态很小(实测 ~1.7KB),channels 没有 chat 钩子,写入只发生在
|
||
-- 运维动作(登录/领取/开关)与登录轮询(未完成时才有)上,不在请求热路径。
|
||
-- 参考实现同样是每次变更 writeFileSync + renameSync。
|
||
--
|
||
-- 失败处理:写失败**不从状态里回滚**内存变更(内存是对的),只留痕到 stderr
|
||
-- ——不递归调 logEvent,那会再次触发 markDirty。
|
||
local function markDirty()
|
||
local ok, err = persistState()
|
||
if not ok then
|
||
io.stderr:write("[channels] 状态落盘失败: " .. tostring(err) .. "\n")
|
||
end
|
||
end
|
||
|
||
plugin.state = plugin.state or {}
|
||
plugin.state.accounts = plugin.state.accounts or {}
|
||
plugin.state.log = plugin.state.log or {}
|
||
|
||
-- 启动时从插件自有文档恢复(必须在 plugin.state 建表之后:loadState 往表里填
|
||
-- 字段,先填再被 or {} 覆盖就白填了)。
|
||
loadState()
|
||
|
||
local function logEvent(msg)
|
||
table.insert(plugin.state.log, 1, { at = os.time and os.time() or 0, msg = msg })
|
||
while #plugin.state.log > 50 do table.remove(plugin.state.log) end
|
||
markDirty()
|
||
end
|
||
|
||
-- accountOf 取某平台的账号记录(凭据本身在保险库,不在 state 里)
|
||
local function accountOf(id)
|
||
return plugin.state.accounts[id]
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 账号池(对齐参考 account.* RPC 契约):一个平台可挂多个账号
|
||
-- ============================================================================
|
||
-- plugin.state.pool[provider] = { [accountId] = 条目 },条目字段对齐参考
|
||
-- AccountEntry:{ id, provider, nickname, enabled, credentialRef, refreshable,
|
||
-- createdAt, expiresAt }。凭据在保险库(credentialRef 即保险库键),不入 state。
|
||
--
|
||
-- 兼容:旧的单账号路径(state.accounts)保留 —— e2e 与既有端点仍走它。
|
||
local function poolTable()
|
||
if not plugin.state.pool then plugin.state.pool = {} end
|
||
return plugin.state.pool
|
||
end
|
||
|
||
local function poolAccounts(provider)
|
||
local t = poolTable()[provider]
|
||
if not t then t = {} poolTable()[provider] = t end
|
||
return t
|
||
end
|
||
|
||
local function poolList(provider)
|
||
local out = {}
|
||
for _, a in pairs(poolAccounts(provider)) do out[#out+1] = a end
|
||
table.sort(out, function(x, y)
|
||
local px, py = x.priority or 0, y.priority or 0
|
||
if px ~= py then return px > py end -- priority 大者优先(参考 reorder 语义)
|
||
return (x.createdAt or 0) < (y.createdAt or 0)
|
||
end)
|
||
return out
|
||
end
|
||
|
||
local function poolGet(provider, accountId)
|
||
return poolAccounts(provider)[accountId]
|
||
end
|
||
|
||
local function poolAdd(provider, entry)
|
||
poolAccounts(provider)[entry.id] = entry
|
||
markDirty()
|
||
end
|
||
|
||
local function poolRemove(provider, accountId)
|
||
poolAccounts(provider)[accountId] = nil
|
||
markDirty()
|
||
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(调用方自行处理)。
|
||
-- b64url_decode 纯 Lua 的 base64url 解码(读 JWT payload 用)。
|
||
--
|
||
-- 为什么不用内核的 base64 能力:内核只提供 encode(适配器签名要用),没有 decode。
|
||
-- 为读一个 JWT 声明而给内核加一个通用解码函数,是把插件的事推给内核 ——
|
||
-- 插件能做就该自己做。纯 Lua 实现约 20 行,无依赖、无内核改动。
|
||
-- 接受标准/URL-safe 两种字母表,容忍缺失填充。
|
||
local B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||
local B64_LOOKUP = {}
|
||
for i = 1, #B64_ALPHABET do B64_LOOKUP[B64_ALPHABET:sub(i, i)] = i - 1 end
|
||
local function b64url_decode(input)
|
||
if type(input) ~= "string" or input == "" then return nil end
|
||
-- 归一化:URL-safe → 标准,去空白,去掉填充(下面按 4 位一组补零处理)
|
||
local s = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "")
|
||
local out, acc, bits = {}, 0, 0
|
||
for i = 1, #s do
|
||
local v = B64_LOOKUP[s:sub(i, i)]
|
||
if v == nil then return nil end -- 非法字符:如实失败,不猜
|
||
acc = acc * 64 + v
|
||
bits = bits + 6
|
||
if bits >= 8 then
|
||
bits = bits - 8
|
||
local byte = math.floor(acc / (2 ^ bits)) % 256
|
||
out[#out + 1] = string.char(byte)
|
||
acc = acc % (2 ^ bits)
|
||
end
|
||
end
|
||
return table.concat(out)
|
||
end
|
||
|
||
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 = b64url_decode(payload)
|
||
if not bin 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。
|
||
-- ⚠️ 必须定义在 refreshCredential **之前**:Lua 的 local 只从定义处向后可见,
|
||
-- 在定义前调用会拿到 nil 并报 "attempt to call a nil value"(第 10 次踩)。
|
||
local function credentialIsRefreshable(cred)
|
||
return type(cred) == "table" and type(cred.refresh_token) == "string" and cred.refresh_token ~= ""
|
||
end
|
||
|
||
-- urlencode:表单编码(Google 续期走 x-www-form-urlencoded)。
|
||
-- ⚠️ 不能只转义常见几个字符:client_secret / refresh_token 里会出现
|
||
-- `-` `_` `.` `~` 之外的各种符号,漏转义会让服务端收到被截断的值。
|
||
local function urlencode(v)
|
||
return (tostring(v or ""):gsub("[^%w%-%._~]", function(c)
|
||
return string.format("%%%02X", string.byte(c))
|
||
end))
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 凭据静默续期(照参考 refresh.ts / cline.ts / gemini-oauth.ts)
|
||
-- ============================================================================
|
||
-- ⚠️ **此前这里是假的**:`account_refresh` 只重同步了源、一个换 token 的请求
|
||
-- 都不发,却恒返回 `ok:true` ⇒ `refresh_creds` 打出「凭据已续期 N 个」而实际
|
||
-- 什么都没续。这是**比不做更坏**的形态:日志在说谎,排查时会去看网络、
|
||
-- 看上游,而真因是根本没有这段代码。
|
||
--
|
||
-- 参考的实现要点(每一条都是踩过的坑):
|
||
--
|
||
-- 1. **续期请求体各平台字段名不同**,写错服务端不给明确报错,只回一个泛化
|
||
-- 的认证失败,极难定位:
|
||
-- * Cline:驼峰 `refreshToken` + `grantType`(**不是** OAuth 标准的
|
||
-- `refresh_token`/`grant_type`);
|
||
-- * Gemini/Google:标准 `refresh_token` + `grant_type=refresh_token`
|
||
-- (表单编码,走 client_id/client_secret);
|
||
-- * 其余走各平台自己的 exchange。
|
||
-- 2. **响应字段名同样不统一**:Cline 是 `accessToken`(驼峰)+ `{success,data}`
|
||
-- 信封;Google 是 `access_token`。解析必须容忍两种。
|
||
-- 3. **Google 偶尔轮换 refresh_token**:响应里给了新的**必须回写**,否则下次
|
||
-- 续期用旧的直接失效。
|
||
-- 4. **`expires_in` 是相对秒数**,必须换算成绝对时刻再写回索引 ——
|
||
-- 否则 `refresh_creds` 每轮都判定"快过期了"又续一遍。
|
||
--
|
||
-- 声明式设计(插件域内,内核不参与):平台用 `refresh = { url=, style=,
|
||
-- body=, ... }` 描述自己的续期,本助手按 style 组装。
|
||
local REFRESH_STYLES = {
|
||
-- Cline:驼峰字段 + JSON body + {success,data} 信封
|
||
cline = {
|
||
body = function(cred, r)
|
||
return json.encode({ refreshToken = cred.refresh_token or "",
|
||
grantType = "refresh_token" })
|
||
end,
|
||
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
|
||
-- 响应套 {success,data} 且字段是驼峰 accessToken(照 parseClineTokenPayload)
|
||
pick = function(rec)
|
||
local d = type(rec.data) == "table" and rec.data or rec
|
||
return d.accessToken or d.access_token, d.refreshToken or d.refresh_token
|
||
end,
|
||
},
|
||
-- Google/Gemini:标准 OAuth 表单编码
|
||
google = {
|
||
form = true,
|
||
body = function(cred, r)
|
||
return "client_id=" .. urlencode(r.client_id or "")
|
||
.. "&client_secret=" .. urlencode(r.client_secret or "")
|
||
.. "&refresh_token=" .. urlencode(cred.refresh_token or "")
|
||
.. "&grant_type=refresh_token"
|
||
end,
|
||
headers = { ["Content-Type"] = "application/x-www-form-urlencoded",
|
||
["Accept"] = "application/json" },
|
||
-- ⚠️ Google 偶尔轮换 refresh_token:给了新的必须回写
|
||
pick = function(rec) return rec.access_token, rec.refresh_token end,
|
||
},
|
||
}
|
||
|
||
-- refreshCredential(plat, acct):真发一次续期请求,成功则**回写凭据与索引**。
|
||
-- 返回 (ok, message)。失败原因必须可诊断(不能只说"失败了")。
|
||
local function refreshCredential(plat, acct)
|
||
local r = plat.refresh
|
||
if type(r) ~= "table" or not r.url then return false, "该平台未声明续期方式" end
|
||
local cred = credentialFull(acct.credentialRef)
|
||
if type(cred) ~= "table" then return false, "凭据无法解析" end
|
||
if not credentialIsRefreshable(cred) then return false, "无 refresh_token,需重新登录" end
|
||
local style = REFRESH_STYLES[r.style or "google"]
|
||
if not style then return false, "未知续期风格: " .. tostring(r.style) end
|
||
local body = style.body(cred, r)
|
||
local resp, err = __llmsproxy_http({
|
||
url = r.url, method = r.method or "POST", headers = style.headers, body = body,
|
||
})
|
||
if err then return false, "续期请求失败: " .. tostring(err) end
|
||
if not resp or resp.status ~= 200 then
|
||
return false, "续期 HTTP " .. tostring(resp and resp.status)
|
||
end
|
||
local okd, rec = pcall(json.decode, resp.body or "")
|
||
if not okd or type(rec) ~= "table" then return false, "续期响应无法解析" end
|
||
local newTok, newRefresh = style.pick(rec)
|
||
if type(newTok) ~= "string" or newTok == "" then
|
||
-- 服务端的失败信息要带出来(照参考:不吞诊断信息)
|
||
local msg = rec.error_description or rec.error or rec.message
|
||
return false, "续期响应无 access_token" .. (msg and (": " .. tostring(msg)) or "")
|
||
end
|
||
-- 回写凭据(**只能走这条路写凭据** —— 手工塞密文会导致二次加密)
|
||
cred.access_token = newTok
|
||
if type(newRefresh) == "string" and newRefresh ~= "" then
|
||
cred.refresh_token = newRefresh -- Google 会轮换
|
||
end
|
||
-- expires_in 是**相对秒数** → 换算绝对时刻(否则每轮都判定"快过期")
|
||
local rec2 = type(rec.data) == "table" and rec.data or rec
|
||
local ei = tonumber(rec2.expires_in or rec.expires_in)
|
||
if ei and ei > 0 then
|
||
cred.expires_at = tostring((os.time and os.time() or 0) + ei)
|
||
end
|
||
__llmsproxy_secrets.set(acct.credentialRef, json.encode(cred))
|
||
-- 回写索引的过期时刻(照 expiry-sync:按需续期**必须**回写,否则会出现
|
||
-- 「显示已过期、但发消息正常」的数据源分叉)
|
||
if cred.expires_at then
|
||
local ms = credentialExpiresAtMs(cred)
|
||
if ms then acct.expiresAt = math.floor(ms / 1000) end
|
||
end
|
||
acct.refreshable = true
|
||
poolAdd(plat.id, acct)
|
||
markDirty()
|
||
return true, "已续期"
|
||
end
|
||
|
||
-- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true }
|
||
-- ============================================================================
|
||
-- 远端模型目录:**远端为主 + 兜底补位**(照参考的 reconcileWithFallback)
|
||
-- ============================================================================
|
||
--
|
||
-- 为什么不只用硬编码名单:上游模型目录是**会变**的(新增/下线/改名),
|
||
-- 硬编码必然过期。为什么不只用远端:远端可能拉不到(未登录/网络抖动/端点变更),
|
||
-- 那时平台就"没有模型可用"了。
|
||
--
|
||
-- 故正确形态是**合并**而非"远端失败就用兜底"的简单回退(参考 cline-models.ts
|
||
-- 明确写了这一条):远端有的以远端为准,远端没有的用兜底表补。
|
||
--
|
||
-- 注意:兜底表是**编译期快照**,价格/倍率会变,故兜底条目不携带价格类字段。
|
||
|
||
-- 取该平台**当前有效**的模型目录(远端优先,兜底补位,剔除黑名单)。
|
||
-- 前向声明:deadModelIds 定义在下方(失效模型存储一节),而 effectiveModels
|
||
-- 在它之前就要剔除失效模型。Lua 的 local 只从定义处向后可见 —— 第 9 次踩。
|
||
local deadModelIds
|
||
|
||
local function effectiveModels(provider)
|
||
local plat = platformById(provider)
|
||
if not plat then return {} end
|
||
local remote = plugin.state.remoteModels and plugin.state.remoteModels[provider]
|
||
local out, seen = {}, {}
|
||
-- 远端在前(权威顺序)
|
||
if type(remote) == "table" and type(remote.ids) == "table" then
|
||
for _, id in ipairs(remote.ids) do
|
||
if type(id) == "string" and id ~= "" and not seen[id] then
|
||
seen[id] = true out[#out+1] = id
|
||
end
|
||
end
|
||
end
|
||
-- 兜底补位(只补远端没有的)
|
||
for _, id in ipairs(plat.models or {}) do
|
||
if not seen[id] then seen[id] = true out[#out+1] = id end
|
||
end
|
||
-- 剔除**有阳性证据**已失效的模型(照参考 dead-model-store 的剔除语义)。
|
||
-- ⚠️ 只剔这里记录过的(真正 404 过的),**绝不**拿远端目录反推下架 ——
|
||
-- buddy/qoder 那类平台的远端目录残缺,反推会删掉可用模型。
|
||
local dead = deadModelIds(provider)
|
||
local kept = {}
|
||
for _, id in ipairs(out) do
|
||
if not dead[id] then kept[#kept+1] = id end
|
||
end
|
||
return kept
|
||
end
|
||
|
||
-- remoteFreeSets:provider → { [modelId]=true },来自远端**权威** free 数组。
|
||
-- 照参考:`recommended-models` 的 free 数组是唯一权威的免费集合,
|
||
-- `/models` 的 id 列表里根本没有 cline-free/*。
|
||
-- 只收 id 不记"它免费"会丢掉这层信息 ⇒ 免费模型被标成收费。
|
||
remoteFreeSets = remoteFreeSets or {}
|
||
|
||
-- parseRemoteModels(parse, body):把各平台响应解析成 id 列表。
|
||
-- 每个分支的字段路径都照参考实现,含注释点名的坑。
|
||
local function parseRemoteModels(kind, body, provider)
|
||
local ok, rec = pcall(json.decode, body or "")
|
||
if not ok or type(rec) ~= "table" then return {} end
|
||
local out = {}
|
||
|
||
if kind == "id_list" then
|
||
-- OpenAI 风格 {data:[{id}]};也有平台直接给数组或 {models:[...]}
|
||
local arr = rec.data or rec.models or rec
|
||
if type(arr) == "table" then
|
||
for _, m in ipairs(arr) do
|
||
local id = type(m) == "table" and m.id or (type(m) == "string" and m)
|
||
if type(id) == "string" and id ~= "" then out[#out+1] = id end
|
||
end
|
||
end
|
||
|
||
elseif kind == "buddy_config" then
|
||
-- 照参考 parseModelsFromConfig:结果在 data.models / data.agents 结构里。
|
||
-- 多形态兜底(顶层 data / 直接 models),因为该端点历史上变过形状。
|
||
local d = rec.data or rec
|
||
local arr = d.models or d.agents or rec.models
|
||
if type(arr) == "table" then
|
||
for _, m in ipairs(arr) do
|
||
local id = type(m) == "table" and (m.id or m.modelId or m.name)
|
||
if type(id) == "string" and id ~= "" then out[#out+1] = id end
|
||
end
|
||
end
|
||
|
||
elseif kind == "zcode_configs" then
|
||
-- 照参考 fetchZcodeModels:字段是 data.builtinModels,且它是**对象**
|
||
-- (键为序号 "0"/"1"),用 Array.isArray 判定会得到 0 个模型的假阴性。
|
||
-- Lua 里表无此区别,但要同时支持数组与 map 两形态。
|
||
local raw = rec.data and rec.data.builtinModels
|
||
if type(raw) == "table" then
|
||
for _, m in pairs(raw) do
|
||
local id = type(m) == "table" and (m.modelId or m.id)
|
||
if type(id) == "string" and id ~= "" then out[#out+1] = id end
|
||
end
|
||
end
|
||
|
||
elseif kind == "cline_merge" then
|
||
-- 照参考 cline-models.ts:**必须同时打两个端点**。
|
||
-- recommended-models 是**唯一权威的 free 集合**;/api/v1/models 的 460 个
|
||
-- id 里根本没有 cline-free/*。故这里把它们合并去重。
|
||
local function pushFrom(node)
|
||
if type(node) ~= "table" then return end
|
||
local arr = node.data or node
|
||
if type(arr) ~= "table" then return end
|
||
for _, m in ipairs(arr) do
|
||
local id = type(m) == "table" and m.id or (type(m) == "string" and m)
|
||
if type(id) == "string" and id ~= "" then out[#out+1] = id end
|
||
end
|
||
end
|
||
-- free 数组优先在前(它含 cline-free/* 与 stealth/*)
|
||
pushFrom(rec.free)
|
||
pushFrom(rec)
|
||
-- ★ 同时把 `free` 数组记成**权威免费集合**(照参考 cline-models.ts:
|
||
-- `recommended-models` 的 free 数组是唯一权威来源)。
|
||
-- 只收 id 不记"它免费"会丢掉这层信息 —— 表现为免费模型被标成收费,
|
||
-- 用户不知道哪个可以不花钱用。
|
||
if type(rec.free) == "table" then
|
||
local fr = {}
|
||
for _, m in ipairs(rec.free) do
|
||
local id = type(m) == "table" and m.id or (type(m) == "string" and m)
|
||
if type(id) == "string" and id ~= "" then fr[id] = true end
|
||
end
|
||
remoteFreeSets = remoteFreeSets or {}
|
||
remoteFreeSets[provider] = fr
|
||
end
|
||
end
|
||
|
||
return out
|
||
end
|
||
|
||
-- refreshRemoteModels(provider):拉取并缓存该平台的远端目录。
|
||
-- 返回 (ids, err);**任何失败都不清空既有缓存**(宁可留旧目录也别让平台变空)。
|
||
local function refreshRemoteModels(provider)
|
||
local plat = platformById(provider)
|
||
if not plat then return nil, "未知平台" end
|
||
local mr = plat.models_remote
|
||
if not mr then return nil, "该平台无远端目录端点" end
|
||
-- 取一个可用账号的凭据(无账号则无凭据,仅当 auth=none 时仍可拉)
|
||
local tok
|
||
for _, a in ipairs(poolList(provider)) do
|
||
if a.enabled ~= false then
|
||
local t = credentialOf(a.credentialRef)
|
||
if t and t ~= "" then tok = t break end
|
||
end
|
||
end
|
||
if (not tok or tok == "") and mr.auth ~= "none" then return nil, "无可用凭据" end
|
||
|
||
local merged, seen = {}, {}
|
||
local lastErr
|
||
for _, ep in ipairs(mr.endpoints or {}) do
|
||
local url = ep.url
|
||
if ep.query then url = url .. "?" .. ep.query end
|
||
local headers = { ["Accept"] = "application/json" }
|
||
local useAuth = ep.url_auth ~= false and mr.auth ~= "none"
|
||
if useAuth and tok and tok ~= "" then
|
||
headers["Authorization"] = "Bearer " .. tok
|
||
end
|
||
-- 平台级身份头(buddy 必须带 X-Product-Code 等,否则会被当成另一个产品)
|
||
if mr.headers_fn and plat.headers_fn then
|
||
local extra = plat.headers_fn(tok or "", nil)
|
||
for k, v in pairs(extra or {}) do headers[k] = v end
|
||
end
|
||
local resp, err = __llmsproxy_http({
|
||
url = url, method = ep.method or "GET", headers = headers,
|
||
})
|
||
if err or not resp or resp.status ~= 200 then
|
||
lastErr = (err or ("HTTP " .. tostring(resp and resp.status)))
|
||
else
|
||
for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body, provider)) do
|
||
if not seen[id] then seen[id] = true merged[#merged+1] = id end
|
||
end
|
||
end
|
||
end
|
||
if #merged == 0 then return nil, lastErr or "远端返回空目录" end
|
||
if not plugin.state.remoteModels then plugin.state.remoteModels = {} end
|
||
plugin.state.remoteModels[provider] = {
|
||
ids = merged,
|
||
fetched_at = os.time(),
|
||
}
|
||
markDirty()
|
||
return merged, nil
|
||
end
|
||
|
||
local function disabledModelsOf(provider)
|
||
if not plugin.state.modelDisabled then plugin.state.modelDisabled = {} end
|
||
local m = plugin.state.modelDisabled[provider]
|
||
if not m then m = {} plugin.state.modelDisabled[provider] = m end
|
||
return m
|
||
end
|
||
|
||
-- setModelDisabled 是**唯一**的模型黑名单写入口:写完即落盘。
|
||
-- 之所以收成一个函数:黑名单的写点曾有 5+ 处(单个开关/批量/清死模型/供应商
|
||
-- 整体开关),散着写必然漏标脏。收口后只有这里需要保证。
|
||
local function setModelDisabled(provider, modelId, disabled)
|
||
local m = disabledModelsOf(provider)
|
||
if disabled then m[modelId] = true else m[modelId] = nil end
|
||
markDirty()
|
||
end
|
||
|
||
-- 渠道积分能力表(对齐参考 CHANNEL_CREDITS):声明各渠道有没有领取/余额。
|
||
local CHANNEL_CREDITS = {
|
||
codearts = { claim = true },
|
||
-- ⚠️ buddy **没有**每日签到。此前这里写 claim=true 是错的:claim_daily 的
|
||
-- 前置条件(plat.checkin 或 claim_fn)都不满足,于是每次调用都返回
|
||
-- "CodeBuddy(腾讯) 不支持每日签到(平台未声明 checkin)" —— 一个自相矛盾的
|
||
-- 文案(表说支持、函数说不支持)。对齐参考:buddy 只有**余额**,无 claim。
|
||
buddy = { status = true },
|
||
lobsterai = { claim = true },
|
||
qoder = { claim = true },
|
||
trae = { claim = true },
|
||
cline = { unit = "dollars" },
|
||
loomy = { claim = true },
|
||
-- raccoon 的领取 = **桌面端登录奖励**(每号一次),参考
|
||
-- raccoon-credits.ts:claimRaccoonLoginReward。此前我们声明为空表 → 白丢一个
|
||
-- 可领渠道。幂等靠响应的 `granted` 字段(重复领取同样 HTTP 200 但 granted=false)。
|
||
raccoon = { claim = true, unit = "points" },
|
||
minimax = { claim = true, status = true },
|
||
-- zcode 有签到,但每次领取都要现场产**阿里云 captcha**(headful Chromium,
|
||
-- 且阿里云按设备限流 150 次/小时)。故照参考 isAutoCheckinExcluded:
|
||
-- **自动**签到跳过它(代价发生在调用期间,等它报错来不及);
|
||
-- 用户仍可**手动**点领取 —— 浏览器弹出来是用户能理解的交互。
|
||
zcode = { claim = true, unit = "tokens", auto_exclude = true },
|
||
gemini = { unit = "quota", quota = true },
|
||
}
|
||
|
||
-- ---- 账号级 source 同步 ----
|
||
-- 每个账号一条上游源:channels-<provider>-<accountId>。模型开关即改写
|
||
-- 这条源的 models 列表(remove+add 重建;内核 add_source 同名覆盖,但显式
|
||
-- 先 remove 更诚实,避免旧模型残留)。
|
||
local function accountSourceName(provider, accountId)
|
||
return "channels-" .. provider .. "-" .. accountId
|
||
end
|
||
|
||
-- syncAccountSource 把某账号的上游源与(平台模型目录 - 黑名单)对齐。
|
||
-- 无凭据(占位账号)不动源。
|
||
local function syncAccountSource(provider, accountId)
|
||
local plat = platformById(provider)
|
||
local acct = poolGet(provider, accountId)
|
||
if not plat or not acct then return end
|
||
-- 凭据有两种消费方式,取决于平台的适配器:
|
||
-- * 多数平台:适配器发 `Authorization: Bearer <access_token>`
|
||
-- → 这里必须交出**裸 access token**(不能把整个 JSON 当 key 发上去)
|
||
-- * 签名平台(codearts):适配器在 build_headers 里**自己从完整凭据 JSON
|
||
-- 解 AK/SK 并签名** → 这里必须交出完整凭据 JSON,交出裸 token 反而
|
||
-- 让适配器签不出来(真实缺陷:codearts 账号加了却不出源,因为
|
||
-- credentialOf 找不到 access_token 而静默 return)
|
||
-- 用 plat.credential_shape 声明,默认 "token"(保持既有平台行为不变)。
|
||
local tok, err
|
||
if plat.credential_shape == "full" then
|
||
tok = __llmsproxy_secrets.get(acct.credentialRef)
|
||
err = (tok == nil or tok == "") and "无凭据" or nil
|
||
else
|
||
tok = credentialOf(acct.credentialRef)
|
||
err = (tok == nil or tok == "") and "无凭据" or nil
|
||
end
|
||
if err then return end
|
||
local name = accountSourceName(provider, accountId)
|
||
local models = {}
|
||
local disabled = disabledModelsOf(provider)
|
||
-- 用**有效目录**(远端优先、兜底补位),不是硬编码表
|
||
for _, m in ipairs(effectiveModels(provider)) do
|
||
if not disabled[m] then
|
||
models[#models+1] = { id = m, kind = "chat", priority = 20 }
|
||
end
|
||
end
|
||
-- 重建:先 remove(容错:源可能不存在)再 add
|
||
__llmsproxy_kernel.call("remove_source", { name = name })
|
||
local src = {
|
||
name = name,
|
||
base_url = plat.base_url,
|
||
adapter = plat.adapter or "openai",
|
||
api_key = tok,
|
||
-- ⚠️ max_concurrent=1 + rpm 节流,而不是 2。
|
||
--
|
||
-- 照参考 model-gate.ts 的实测依据:这些平台的**并发配额按模型**分别计量,
|
||
-- 与"还剩多少 token"无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下:
|
||
-- GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过
|
||
-- GLM-5.3 74 次 200 21 次重试 6 次最终 429
|
||
-- 两条措施缺一不可:
|
||
-- ① **串行**:同一时刻只允许一个上游请求在飞(并发发起必然浪费一个配额)
|
||
-- ② **最小间隔**:串行只保证"不重叠",不保证"有间隔" —— 连续放行的两次
|
||
-- 调用可能只隔几十毫秒,仍会撞并发窗口。故用 rpm 让内核把它们隔开。
|
||
-- 这两条都是**客户端自保**(防患于未然),与"上游明确要求排队"是两回事。
|
||
max_concurrent = 1,
|
||
rpm = plat.rpm or 20, -- 默认 3 秒一个请求,可按平台覆盖
|
||
models = models,
|
||
}
|
||
-- 平台的 chat 端点常与 base_url 不是简单拼接(Gemini 走私有
|
||
-- /v1internal: 动词、zcode 只有一条 plan 路径)。声明式覆盖,内核只当
|
||
-- 不透明字符串用 —— 不需要内核知道任何厂商知识。
|
||
if plat.endpoint then src.endpoint = plat.endpoint end
|
||
if plat.endpoint_stream then src.endpoint_stream = plat.endpoint_stream end
|
||
if plat.headers_extra then src.headers = plat.headers_extra end
|
||
local r, aerr = __llmsproxy_kernel.call("add_source", { source = src })
|
||
-- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会
|
||
-- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。
|
||
if aerr then
|
||
logEvent("接入源失败 " .. name .. ": " .. tostring(aerr))
|
||
elseif r and r.json and r.json.ok == false then
|
||
logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error))
|
||
else
|
||
logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)")
|
||
end
|
||
end
|
||
|
||
local function dropAccountSource(provider, accountId)
|
||
__llmsproxy_kernel.call("remove_source", { name = accountSourceName(provider, accountId) })
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 登录
|
||
-- ============================================================================
|
||
|
||
-- plugin.start_login(platform_id) 由设置页调用:创建一个登录会话。
|
||
--
|
||
-- 返回 {url=…, session=…} 给页面显示"请在自己的浏览器打开这个地址"。
|
||
-- 登录完成后用户浏览器会回到本插件(callback 流程)或本插件轮询到结果
|
||
|
||
-- ============================================================================
|
||
-- 登录会话(插件自持)与服务端浏览器栈
|
||
-- ============================================================================
|
||
|
||
-- 登录会话放 plugin.state.sessions:内核没有"登录"概念,这正是插件该自己干的
|
||
-- 事。表结构 { id, platform, flow, url, user_code, nonce, state, account,
|
||
-- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。
|
||
local SESSION_TTL = 900 -- 15 分钟,过期会话在读取时惰性清理
|
||
|
||
newToken = function()
|
||
-- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex)
|
||
return sha256_hex(tostring(os.time()) .. math.random(1e12) .. (plugin.name or "channels"))
|
||
end
|
||
|
||
local function sessions()
|
||
if not plugin.state.sessions then plugin.state.sessions = {} end
|
||
return plugin.state.sessions
|
||
end
|
||
|
||
-- browserUtil:浏览器栈的**小助手**。挂在**表**上而不是 `local function` ——
|
||
-- 本文件顶层 local 顶到 Lua 的 200 个上限,多一个就让整个插件加载失败
|
||
-- (见 CODEARTS 常量处那段说明)。
|
||
local browserUtil = {}
|
||
|
||
-- browserUtil.pathExists(p):文件/目录是否存在。
|
||
-- ⚠️ 不能用 io.open 判"存在":X socket 是 **socket 文件**,io.open 打不开它,
|
||
-- 拿它当判据会永远等不到(本文件在选显示器号处也踩过同一个坑)。
|
||
function browserUtil.pathExists(p)
|
||
local f = io.open(p, "rb")
|
||
if f then f:close() return true end
|
||
-- io.open 失败时退到 stat(socket/设备文件也能判到)
|
||
local h = io.popen(("test -e %s && echo y"):format("'" .. tostring(p) .. "'"))
|
||
local r = h and h:read("*l")
|
||
if h then h:close() end
|
||
return r == "y"
|
||
end
|
||
|
||
-- browserUtil.readTail(path, max):读日志尾部,把启动失败的**真因**带给用户
|
||
-- (而不是一句"启动失败"让运维去猜)。
|
||
function browserUtil.readTail(path, max)
|
||
local f = io.open(path, "r")
|
||
if not f then return "(无日志)" end
|
||
local txt = f:read("*a") or ""
|
||
f:close()
|
||
local keep = {}
|
||
for line in txt:gmatch("[^\n]+") do
|
||
if not line:find("dbus", 1, true) and not line:find("crashpad", 1, true)
|
||
and not line:find("NameHasOwner", 1, true) and not line:find("UPower", 1, true) then
|
||
keep[#keep + 1] = line
|
||
end
|
||
end
|
||
local joined = table.concat(keep, " | ")
|
||
local lim = max or 400
|
||
if #joined > lim then joined = joined:sub(-lim) end
|
||
return joined
|
||
end
|
||
|
||
-- browserUtil.cdpAlive(port):CDP HTTP 端点是否**真的应答**(端口在听 ≠ 服务可用)。
|
||
function browserUtil.cdpAlive(port)
|
||
for _ = 1, 10 do
|
||
local h = io.popen(("curl -s --max-time 2 http://127.0.0.1:%d/json/version 2>/dev/null"):format(port))
|
||
local out = h and h:read("*a") or ""
|
||
if h then h:close() end
|
||
if out and out:find("webSocketDebuggerUrl", 1, true) then return true end
|
||
os.execute("sleep 0.5")
|
||
end
|
||
return false
|
||
end
|
||
|
||
-- ⚠️ **前向声明**(Lua 的 local 只从定义处向后可见)。
|
||
--
|
||
-- 下面这三个助手定义在**更后面**(本文件按"会话 → 浏览器栈 → loopback 接收器"
|
||
-- 的阅读顺序组织),但 sweepSessions 要用它们。不声明的话是:
|
||
-- `attempt to call global 'reapLoopback' (a nil value)`
|
||
-- **加载时不报错**,只有真正走到那条分支才炸 —— 所以第一次登录正常,
|
||
-- 第二次(有会话过期需回收时)才 500。这是本文件第 12 次踩前向引用。
|
||
--
|
||
-- 实测后果:重复发起登录直接 500,且**已经起来的浏览器栈不被回收**
|
||
-- (reapBrowser 也调不到),进程与端口持续泄漏。
|
||
local reapLoopback, browserStack, reapBrowser
|
||
|
||
-- sweepSessions 惰性清理过期/终态会话(无后台定时器,读取时顺带做)
|
||
local function sweepSessions()
|
||
local now = os.time and os.time() or 0
|
||
for id, s in pairs(sessions()) do
|
||
if s.expires_at and s.expires_at < now then
|
||
-- 会话过期时要顺带回收它为回调起的 loopback 接收器 —— 否则用户放弃
|
||
-- 登录后那些 python 监听会一直挂着(进程泄漏,且白占端口)。
|
||
reapLoopback(id)
|
||
-- 同一道理:callback 流会在服务端起一整套浏览器栈(Xvfb/chromium/VNC)。
|
||
-- 会话过期就是「用户已经放弃」,必须把栈收掉 —— 否则每次放弃登录都留一套
|
||
-- 进程常驻(实测:重复跑测试/重复发起登录会攒出 7 套 websockify+x11vnc)。
|
||
if s.owns_browser then
|
||
local cur = browserStack()
|
||
if cur.owner == id then
|
||
reapBrowser()
|
||
end
|
||
end
|
||
sessions()[id] = nil
|
||
end
|
||
end
|
||
end
|
||
|
||
-- ---- 服务端浏览器栈(VNC 兜底用)----
|
||
-- 每个会话一组进程:Xvfb + chromium + x11vnc + websockify。pid 记在
|
||
-- plugin.state.browser.pids,插件卸载或会话结束时 kill。
|
||
-- 绑定地址默认 127.0.0.1(同机操作);远程运维可把 settings.vnc_bind 设成
|
||
-- "0.0.0.0" 并依赖 x11vnc 随机会话密码(不设密码的裸 VNC 永远不会出现)。
|
||
--
|
||
-- 为什么需要它:有些平台的登录**必须**在服务端完成 —— 回环重定向落不到用户
|
||
-- 机器上、或授权页要求浏览器内交互(扫码后确认、短信验证码)。这时只有服务端
|
||
-- 浏览器能走完,VNC 是把那个浏览器呈现给运维的唯一途径。
|
||
--
|
||
-- 登录后的取凭据不走「截图 + 人眼看」:浏览器以 --remote-debugging-port 起,
|
||
-- 完成后经 CDP 读 document.cookie / localStorage / 显式取回的 token,
|
||
-- 落到保险库。人只负责在 VNC 窗口里完成交互,机器负责把结果取出来。
|
||
|
||
browserStack = function()
|
||
if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end
|
||
return plugin.state.browser
|
||
end
|
||
|
||
-- browserDeps 探测浏览器栈的外部依赖。返回 { ok=bool, missing={...}, present={...} }。
|
||
-- 依赖是**外部系统包**(发行版装的),插件不擅自 apt/dnf 安装 —— 那需要 root 且
|
||
-- 会改动运维的机器;如实报告缺什么,由运维或部署脚本装。deploy.sh 提供安装路径。
|
||
local BROWSER_DEPS = {
|
||
{ bin = "Xvfb", pkg = "xvfb", why = "虚拟显示" },
|
||
{ bin = "x11vnc", pkg = "x11vnc", why = "VNC 服务端" },
|
||
{ bin = "websockify", pkg = "websockify", why = "VNC→WebSocket 网关" },
|
||
{ bin = "python3", pkg = "python3", why = "CDP 取凭据" },
|
||
}
|
||
local function which(bin)
|
||
local h = io.popen("command -v " .. bin .. " 2>/dev/null")
|
||
if not h then return false end
|
||
local p = h:read("*l")
|
||
h:close()
|
||
return p ~= nil and p ~= ""
|
||
end
|
||
|
||
-- reapBrowser 回收本插件拉起的整组进程。进程是插件起的,插件负责杀 ——
|
||
-- 留一个孤儿 chromium 会一直占着显示与内存,且下次 spawn 会撞端口。
|
||
-- killProcsMatching(needle):杀掉 cmdline 里含 needle 的进程(含自己不算)。
|
||
--
|
||
-- 为什么不用记录的 PID:`setsid` 在「已是进程组长」时会 fork,shell 的作业控制
|
||
-- 也会另开进程组 —— 于是 `$!` 未必是真正的服务进程。实测后果:kill 记录的 PID
|
||
-- 杀不掉 chromium 的 zygote/renderer,它们继续活着并重建 profile 目录,
|
||
-- 一次 core 全包跑出 7 套残留栈(websockify×7 + x11vnc×7 + 7 个 profile 目录)。
|
||
--
|
||
-- 为什么不用 pkill -f:那个模式会出现在**我们自己这条 shell 命令行**里 →
|
||
-- pkill 把执行它的 shell 一起杀掉(真实踩过两次,命令链直接中断)。
|
||
-- 这里直接读 /proc(不经 shell 传模式),因此不会自匹配。
|
||
local function killProcsMatching(needle)
|
||
if not needle or needle == "" then return 0 end
|
||
local h = io.popen("ls /proc 2>/dev/null")
|
||
if not h then return 0 end
|
||
local victims = {}
|
||
for entry in h:lines() do
|
||
if entry:match("^%d+$") then
|
||
local f = io.open("/proc/" .. entry .. "/cmdline", "r")
|
||
if f then
|
||
local raw = f:read("*a") or ""
|
||
f:close()
|
||
-- cmdline 以 NUL 分隔;换成空格以便匹配
|
||
local cmd = raw:gsub("%z", " ")
|
||
if cmd ~= "" and cmd:find(needle, 1, true) then
|
||
victims[#victims + 1] = entry
|
||
end
|
||
end
|
||
end
|
||
end
|
||
h:close()
|
||
-- 同样先 TERM 再 KILL:chromium 也持有 X 的 SHM 段,强杀会留孤儿段
|
||
-- (见 reapBrowser 关于 shmmni 耗尽的说明)。
|
||
for _, pid in ipairs(victims) do
|
||
os.execute("kill -TERM " .. pid .. " 2>/dev/null")
|
||
end
|
||
if #victims > 0 then
|
||
os.execute("sleep 0.3")
|
||
end
|
||
for _, pid in ipairs(victims) do
|
||
os.execute("kill -9 " .. pid .. " 2>/dev/null")
|
||
end
|
||
return #victims
|
||
end
|
||
|
||
reapBrowser = function()
|
||
local b = browserStack()
|
||
local sess = b.session
|
||
local disp = b.display or (sess and sess.display)
|
||
local prof = b.profile
|
||
-- 按**唯一标识**回收(显示器号 / profile 目录),不依赖记录的 PID ——
|
||
-- 理由见 killProcsMatching 注释($! 未必是真正的服务进程,只信它会留下
|
||
-- zygote/renderer 与 x11vnc/websockify 残留)。这些标识都是本次会话独有的,
|
||
-- 因此不会误杀别的会话。
|
||
-- 按 **profile 目录**匹配杀 chromium 及其子进程:tag 是「时间+随机」故全局唯一,
|
||
-- 不会误杀别的会话。**不能**按显示器号匹配 —— 显示器号会在空出后被复用,
|
||
-- 于是 A 会话的收尾会把 B 会话正在用的 `Xvfb :91` 一起杀掉
|
||
-- (实测后果:chromium 报 "X connection error",栈起不来)。
|
||
if prof then
|
||
killProcsMatching("user-data-dir=" .. prof)
|
||
end
|
||
-- Xvfb / x11vnc / websockify 命令行里没有唯一标识,只信记录的 PID。
|
||
--
|
||
-- **先 SIGTERM 再 SIGKILL**,不能直接 -9:X 服务的 MIT-SHM 共享内存段
|
||
-- 被强杀时无人释放,会变成系统级孤儿段(nattch=0 但占着 shmmni 名额)。
|
||
-- 实测:单个 Xvfb 约占 84 段,内核 kernel.shmmni=4096 → 约 48 次异常退出
|
||
-- 就把它用满,此后**宿主上任何**新进程 shmget 都返回
|
||
-- “No space left on device”(x11vnc 就从 0.2s 能起变成永远起不来)。
|
||
-- 优雅终止让 X 自己清理这些段。
|
||
for _, pid in ipairs(b.pids or {}) do
|
||
os.execute("kill -TERM " .. tostring(pid) .. " 2>/dev/null")
|
||
end
|
||
if #(b.pids or {}) > 0 then
|
||
os.execute("sleep 0.4")
|
||
end
|
||
for _, pid in ipairs(b.pids or {}) do
|
||
os.execute("kill -9 " .. tostring(pid) .. " 2>/dev/null")
|
||
end
|
||
-- 杀掉自己占的显示器后必须清掉它的锁与 socket。
|
||
-- 为什么必需:Xvfb 被 SIGKILL(本函数就是这么杀的)来不及清理
|
||
-- /tmp/.X<n>-lock,留着就成了**陈旧锁**。而选号逻辑是「锁/socket 存在就当被占」,
|
||
-- 于是每个被 KILL 的显示器都会永久烧掉一个号 —— 反复跑测试/反复登录,
|
||
-- 91 起始的号会被耗光,之后每次都挑中 91 并报
|
||
-- “Server is already active for display 91”(真实踩过:41/41 全占)。
|
||
-- 用 b.display 而不是 b.session.display:**失败路径也在 session 赋值之前就
|
||
-- 调本函数**(Xvfb 起不来、端口未就绪…),那时只有 display 已知。
|
||
local disp = b.display or (b.session and b.session.display)
|
||
if disp then
|
||
os.remove("/tmp/.X" .. tostring(disp) .. "-lock")
|
||
os.remove("/tmp/.X11-unix/X" .. tostring(disp))
|
||
end
|
||
-- profile 目录也一并清:tag 是唯一的,不清就会无限累积(每次登录留一份
|
||
-- 几十 MB 的 chromium profile 在 /tmp)。
|
||
if b.profile then
|
||
os.execute("rm -rf " .. b.profile .. " 2>/dev/null")
|
||
end b.profile = nil
|
||
if b.home then
|
||
os.execute("rm -rf " .. b.home .. " 2>/dev/null")
|
||
end b.home = nil
|
||
b.display = nil
|
||
b.pids = {}
|
||
b.session = nil
|
||
end
|
||
|
||
-- xdisplayBusy(n):显示器号是否真被占。
|
||
-- 判存在用 `[ -e ]` 而不是 io.open:X socket 是 socket 文件,io.open 对它
|
||
-- 返回 nil(打不开)→ 会误判“空闲”→ 又选中被占的号(这个坑真实踩过)。
|
||
local function xdisplayBusy(n)
|
||
local lock = "/tmp/.X" .. tostring(n) .. "-lock"
|
||
local ok = os.execute('[ -e "' .. lock .. '" ] 2>/dev/null')
|
||
local hasLock = (ok == true or ok == 0)
|
||
if not hasLock then
|
||
local sok = os.execute('[ -e "/tmp/.X11-unix/X' .. tostring(n) .. '" ] 2>/dev/null')
|
||
return sok == true or sok == 0
|
||
end
|
||
-- 锁在,但记录的 PID 可能早死了 —— 那就是陈旧锁,**可以回收**。
|
||
-- 不回收的话号会越用越少(见 reapBrowser 注释)。
|
||
local f = io.open(lock, "r")
|
||
if not f then return true end
|
||
local pid = (f:read("*l") or ""):gsub("%s", "")
|
||
f:close()
|
||
if pid == "" then return true end
|
||
-- 存活判定用 /proc/<pid>,**不用 os.execute("kill -0 …")**:
|
||
-- 后者靠 shell 退出码,而这个 VM 里 os.execute 的返回值形态不可靠
|
||
-- (判据实测咬了:陈旧锁没被回收 → 号仍然占着)。
|
||
-- /proc/<pid> 存在即为进程存在(僵尸也在,偏保守,不会误回收活着的号)。
|
||
if pid:match("^%d+$") then
|
||
local alive = io.open("/proc/" .. pid .. "/stat", "r")
|
||
if alive then
|
||
alive:close()
|
||
return true
|
||
end
|
||
else
|
||
return true -- 锁里不是数字(异常内容),当被占处理
|
||
end
|
||
-- 陈旧锁:清掉它和可能残留的 socket,让这个号重新可用。
|
||
os.remove(lock)
|
||
os.remove("/tmp/.X11-unix/X" .. tostring(n))
|
||
return false
|
||
end
|
||
|
||
-- pickFreeX(start, span):挑一个真正空闲的显示器号。
|
||
-- 与旧 pickFree(91, 40) 的差别:① 陈旧锁会被回收(而不是永久占号);
|
||
-- ② 扫不通时**如实报错**,不再 `return start` —— 返回一个已知被占的号只会
|
||
-- 让 Xvfb 报一句难以归因的 “already active for display N”。
|
||
local function pickFreeX(start, span)
|
||
-- 起始号可覆盖:`LLMSPROXY_XDISPLAY_BASE` env 或 settings.xdisplay_base。
|
||
--
|
||
-- ⚠️ 为什么需要它(真实事故 2026-10-15):生产 unit 带 `PrivateTmp=yes`,
|
||
-- 它起的 Xvfb :91 的锁文件(/tmp/.X91-lock)在**服务自己的 /tmp** 里 ——
|
||
-- 宿主上的测试**看不到**它,于是 xdisplayBusy(91) 判"空闲"、测试也去抢 91,
|
||
-- 结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`。测试与生产在同一个
|
||
-- 显示器号上互相踩(而 pickFreeX 的判据本身是对的,它只是看不到另一个 /tmp)。
|
||
--
|
||
-- 故测试用高段号(如 300+)与生产错开;生产留空即用默认 91。
|
||
local base = nil
|
||
local env = os.getenv and os.getenv("LLMSPROXY_XDISPLAY_BASE")
|
||
if type(env) == "string" and env ~= "" then base = tonumber(env) end
|
||
if not base then
|
||
local st = plugin.state and plugin.state.settings
|
||
if type(st) == "table" then base = tonumber(st.xdisplay_base) end
|
||
end
|
||
if base then start = base end
|
||
for n = start, start + span do
|
||
if not xdisplayBusy(n) then return n end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local function browserDeps()
|
||
local missing, present = {}, {}
|
||
for _, d in ipairs(BROWSER_DEPS) do
|
||
if which(d.bin) then present[#present + 1] = d.bin
|
||
else missing[#missing + 1] = { bin = d.bin, pkg = d.pkg, why = d.why } end
|
||
end
|
||
-- chromium 的二进制名随发行版变化(chromium / chromium-browser / google-chrome)
|
||
local chromium = nil
|
||
for _, c in ipairs({ "chromium", "chromium-browser", "google-chrome", "google-chrome-stable" }) do
|
||
if which(c) then chromium = c break end
|
||
end
|
||
if chromium then present[#present + 1] = chromium
|
||
else missing[#missing + 1] = { bin = "chromium", pkg = "chromium", why = "浏览器" } end
|
||
-- noVNC 静态资源(websockify --web 指向它)
|
||
local novnc = nil
|
||
for _, p in ipairs({ "/usr/share/novnc", "/usr/share/webapps/novnc", "/opt/novnc" }) do
|
||
local f = io.open(p .. "/vnc.html", "r")
|
||
if f then f:close() novnc = p break end
|
||
end
|
||
if not novnc then
|
||
missing[#missing + 1] = { bin = "novnc", pkg = "novnc", why = "浏览器内嵌页面" }
|
||
end
|
||
-- CDP 需要 websocket 客户端
|
||
local pyws = false
|
||
if which("python3") then
|
||
local h = io.popen("python3 -c 'import websocket' 2>/dev/null && echo yes")
|
||
if h then local r = h:read("*l") h:close() pyws = (r == "yes") end
|
||
end
|
||
if which("python3") and not pyws then
|
||
missing[#missing + 1] = { bin = "python3-websocket", pkg = "python3-websocket", why = "CDP 取凭据(websocket-client)" } end
|
||
return {
|
||
ok = #missing == 0,
|
||
missing = missing,
|
||
present = present,
|
||
chromium = chromium,
|
||
novnc = novnc,
|
||
}
|
||
end
|
||
|
||
-- gatewayPort 问内核要本网关的监听端口。VNC 里的浏览器要回调到本插件的挂载点,
|
||
-- 端口不能硬编码(非默认部署会全错)。内核 status 里带 listen(如 "0.0.0.0:8081")。
|
||
-- gatewayPort 求本网关的监听端口。VNC 里的浏览器要回调到本插件的挂载点,
|
||
-- 端口不能硬编码(非默认部署会全错)。
|
||
--
|
||
-- 端口由插件**自己**取得,不问内核:插件在信任模型里就是可信、有完整 OS 能力的
|
||
-- (能拉浏览器栈、能在磁盘上读写),读一下自己的 config.yaml 是它能力范围内的事。
|
||
-- 为了一个端口给内核加只读动作,既不必要,也污染了内核的「零厂商/零部署知识」边界。
|
||
-- 优先级:settings 显式覆盖 > config.yaml 的 listen > 环境变量 > 默认值。
|
||
-- buildAuthorizeUrl(plat, port, state, redirectUri, vars):按平台 authorize 规格拼授权 URL。
|
||
--
|
||
-- 规格形如 { base = "...", params = {...}, hash = "/portal#/login" }:
|
||
-- - params 里的值支持 {port} / {redirect_uri} 占位,另由 vars 提供
|
||
-- 平台特有的占位(如 Trae 的 machine_id / device_id / trace_id);
|
||
-- - hash 非空时把参数挂在 **hash 段**之后(LobsterAI 是 /portal#/login?…);
|
||
-- - 值一律百分号编码(redirect_uri 含 :// 与 :,不编码会被登录页拒)。
|
||
-- 参考注释明确警告过手工拼串漏编码的坑(buildLobsteraiLoginUrl)。
|
||
local function buildAuthorizeUrl(plat, port, state, redirectUri, vars)
|
||
local a = plat.authorize or {}
|
||
local base = a.hash or a.base or plat.login_url
|
||
local parts = {}
|
||
local function add(k, v)
|
||
v = tostring(v):gsub("{port}", tostring(port))
|
||
if redirectUri and redirectUri ~= "" then
|
||
v = v:gsub("{redirect_uri}", redirectUri)
|
||
end
|
||
if type(vars) == "table" then
|
||
for vk, vv in pairs(vars) do
|
||
v = v:gsub("{" .. vk .. "}", tostring(vv))
|
||
end
|
||
end
|
||
if k == "state" and (v == "" or v == "nil") then v = state end
|
||
parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v)
|
||
end
|
||
-- 确定性的顺序:排序键名,保证同一会话每次拼出的 URL 一致(便于对账/复现)
|
||
local keys = {}
|
||
for k in pairs(a.params or {}) do keys[#keys + 1] = k end
|
||
table.sort(keys)
|
||
for _, k in ipairs(keys) do add(k, a.params[k]) end
|
||
-- state 始终补上(除非 params 里已显式给了)
|
||
if not (a.params and a.params.state) then add("state", state) end
|
||
return base .. "?" .. table.concat(parts, "&")
|
||
end
|
||
|
||
local function gatewayPort()
|
||
-- ① settings:运维可显式指定(前置代理 / 反代场景,对外端口与 listen 不同)
|
||
local s = plugin.state.settings and plugin.state.settings.gateway_port
|
||
if tonumber(s) then return tonumber(s) end
|
||
-- ② 配置文件:插件与内核同机,直接读自己的配置
|
||
for _, path in ipairs({
|
||
"/etc/llmsproxy/config.yaml",
|
||
"/usr/local/etc/llmsproxy/config.yaml",
|
||
}) do
|
||
local f = io.open(path, "r")
|
||
if f then
|
||
local listen = f:read("*a")
|
||
f:close()
|
||
if listen then
|
||
local addr = listen:match("\nlisten:%s*([^\n]%S*)") or listen:match("^listen:%s*([^\n]%S*)")
|
||
if addr then
|
||
local p = addr:match(":(%d+)")
|
||
if p then return tonumber(p) end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
-- ③ 实听端口:从内核自己的监听 socket 反查(配置文件缺失/被换时兜底)
|
||
do
|
||
local h = io.popen("ss -ltnp 2>/dev/null | grep 'pid=' | grep -o ':[0-9]*' | head -1")
|
||
if h then
|
||
local line = h:read("*l")
|
||
h:close()
|
||
local p = line and line:match(":(%d+)")
|
||
if p then return tonumber(p) end
|
||
end
|
||
end
|
||
return 8081
|
||
end
|
||
|
||
-- waitPort 等端口就绪(就绪探针),避免「进程起来了但还不能连」。
|
||
-- 返回 true/false,最多等 timeout_ms。
|
||
local function waitPort(host, port, timeoutMs)
|
||
local deadline = (os.time and os.time() or 0) * 1000 + timeoutMs
|
||
while true do
|
||
local h = io.popen(("bash -c 'exec 3<>/dev/tcp/%s/%d' 2>/dev/null && echo ok"):format(host, port))
|
||
local r = h and h:read("*l")
|
||
if h then h:close() end
|
||
if r == "ok" then return true end
|
||
local now = (os.time and os.time() or 0) * 1000
|
||
if now >= deadline then return false end
|
||
os.execute("sleep 0.3")
|
||
end
|
||
end
|
||
|
||
-- CDP 取凭据的 Python 桥。argv: <script> <cdp_port> <expr_file>
|
||
-- 打印一行 JSON: {"ok":true,"value":<...>} 或 {"ok":false,"error":"..."}
|
||
-- 用文件传表达式,避免 shell 引号地狱(表达式里常有引号/换行)。
|
||
local CDP_PY = [[
|
||
import json, sys, urllib.request
|
||
try:
|
||
import websocket
|
||
except Exception as e:
|
||
print(json.dumps({"ok": False, "error": "websocket-client 未安装: %s" % e}))
|
||
sys.exit(0)
|
||
|
||
port = int(sys.argv[1])
|
||
with open(sys.argv[2]) as f:
|
||
expr = f.read()
|
||
|
||
def http(path):
|
||
with urllib.request.urlopen("http://127.0.0.1:%d%s" % (port, path), timeout=10) as r:
|
||
return json.load(r)
|
||
|
||
try:
|
||
targets = http("/json/list")
|
||
except Exception as e:
|
||
print(json.dumps({"ok": False, "error": "CDP 不可达: %s" % e})); sys.exit(0)
|
||
|
||
page = None
|
||
for t in targets:
|
||
if t.get("type") == "page" and t.get("webSocketDebuggerUrl"):
|
||
page = t; break
|
||
if page is None:
|
||
print(json.dumps({"ok": False, "error": "没有可用的 page target"})); sys.exit(0)
|
||
|
||
try:
|
||
ws = websocket.create_connection(page["webSocketDebuggerUrl"], timeout=20)
|
||
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
|
||
"params": {"expression": expr, "returnByValue": True,
|
||
"awaitPromise": True}}))
|
||
while True:
|
||
msg = json.loads(ws.recv())
|
||
if msg.get("id") == 1:
|
||
break
|
||
ws.close()
|
||
except Exception as e:
|
||
print(json.dumps({"ok": False, "error": "CDP 求值失败: %s" % e})); sys.exit(0)
|
||
|
||
res = msg.get("result", {})
|
||
if "exceptionDetails" in res:
|
||
print(json.dumps({"ok": False, "error": res["exceptionDetails"].get("text", "JS 异常")}))
|
||
sys.exit(0)
|
||
print(json.dumps({"ok": True, "value": res.get("result", {}).get("value")}))
|
||
]]
|
||
|
||
local function cdpScriptPath()
|
||
local p = "/tmp/llmsproxy-cdp-eval.py"
|
||
local f = io.open(p, "r")
|
||
if f then f:close() return p end
|
||
local w = io.open(p, "w")
|
||
if not w then return nil end
|
||
w:write(CDP_PY)
|
||
w:close()
|
||
return p
|
||
end
|
||
|
||
-- cdpEval 在服务端浏览器的当前页上求值一个 JS 表达式。
|
||
-- 返回 (value, err)。value 是 JS 返回值(经 returnByValue 序列化)。
|
||
local function cdpEval(cdpPort, expr)
|
||
local script = cdpScriptPath()
|
||
if not script then return nil, "无法写入 CDP 脚本" end
|
||
local exprFile = ("/tmp/llmsproxy-cdp-expr-%d.js"):format(cdpPort)
|
||
local w = io.open(exprFile, "w")
|
||
if not w then return nil, "无法写入表达式文件" end
|
||
w:write(expr)
|
||
w:close()
|
||
local h = io.popen(("python3 %s %d %s 2>/dev/null"):format(script, cdpPort, exprFile))
|
||
if not h then os.remove(exprFile) return nil, "python3 执行失败" end
|
||
local out = h:read("*a")
|
||
h:close()
|
||
os.remove(exprFile)
|
||
if not out or out == "" then return nil, "CDP 无输出" end
|
||
local ok, rec = pcall(json.decode, out)
|
||
if not ok or type(rec) ~= "table" then return nil, "CDP 输出无法解析: " .. tostring(out):sub(1, 200) end
|
||
if rec.ok ~= true then return nil, tostring(rec.error or "CDP 失败") end
|
||
return rec.value, nil
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- loopback 回调接收器(平台要求 redirect_uri 落在本机精确路径时用)
|
||
-- ============================================================================
|
||
--
|
||
-- 为什么必需:各回调平台的 redirect_uri 都是**回环地址**(127.0.0.1 / localhost)
|
||
-- 且路径是各自注册值(有道 /auth/callback、Trae /authorize、Gemini /oauth-callback),
|
||
-- 而本插件的挂载点在 `/api/plugins/http/…`;更关键的是**远程用户机器上的
|
||
-- 127.0.0.1 不是本网关** —— 那样重定向根本打不到这里。
|
||
--
|
||
-- 做法:插件(可信、有 OS 能力)在本机起一个极小的 HTTP 监听,路径前缀就是该
|
||
-- 平台的注册路径。**双栈绑回环**(::1 与 127.0.0.1 同一端口):参考注释点明
|
||
-- 「浏览器常把 localhost 解析成 IPv6」,而 Gemini 注册的就是 http://localhost:…
|
||
-- —— 只绑 IPv4 会让那条回调连不上(但**绝不**绑 :: 对外暴露)。
|
||
-- 它把回调 query 原子写进**文件**(不拼 shell,避免引号/特殊字符注入 ——
|
||
-- 与 CDP 桥用文件传表达式同一理由),然后给浏览器一个可关闭的页面。
|
||
--
|
||
-- 校验规则按平台形态分两类(不能一刀切):
|
||
-- * Trae:回调把 token 直接放 query(refreshToken/userJwt/…),**不带 state**
|
||
-- (参考 parseTraeCallback 从不读 state),故对 /authorize 不要求 state。
|
||
-- * Gemini / 有道:标准授权码回调,state 必须匹配,否则 400(防伪造回调)。
|
||
--
|
||
-- 真正入账前的 state 终校在 poll_login(读文件后再次比对 nonce)。
|
||
--
|
||
-- 返回 (redirect_url, nil) 或 (nil, err)。进程 pid 记入 plugin.state.loopbacks,
|
||
-- 会话结束时由 reapLoopback 回收。
|
||
local LOOPBACK_PY = [[
|
||
import sys, json, os, socket, tempfile, threading, random
|
||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||
from urllib.parse import urlparse, parse_qs
|
||
prefix, out_path, want_state, port_path = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
||
# Trae 的回调不带 state(它把 token 放在 query 里,靠路径区分);其余平台必须带。
|
||
# 各平台的回调查验规则不同,**不能一刀切**(照参考逐个平台看):
|
||
# /authorize Trae:token 直接放 query,**不带 state**
|
||
# /oauth/callback CodeArts:带 code + ticket_id(无 state),旧流程回退带 secret
|
||
# 其余(有道 /auth/callback、Gemini /oauth-callback):标准授权码 + state 校验
|
||
require_state = (prefix != "/authorize" and prefix != "/oauth/callback")
|
||
class H(BaseHTTPRequestHandler):
|
||
def log_message(self, *a):
|
||
pass
|
||
def do_GET(self):
|
||
u = urlparse(self.path)
|
||
if not u.path.startswith(prefix):
|
||
self.send_response(404); self.end_headers(); self.wfile.write(b"Not found"); return
|
||
q = parse_qs(u.query)
|
||
state = (q.get("state") or [""])[0]
|
||
if require_state and (not state or (want_state and state != want_state)):
|
||
# state 缺失/不匹配 → 不是本次登录发起的(或伪造),直接拒(对齐参考)。
|
||
self.send_response(400); self.send_header("Content-Type","text/plain; charset=utf-8")
|
||
self.end_headers(); self.wfile.write("\u767b\u5f55\u56de\u8c03\u53c2\u6570\u65e0\u6548".encode()); return
|
||
# 把整个回调 query 原样转成 JSON(含 code/authCode 与 Trae 的
|
||
# refreshToken/userJwt/userInfo 等),由 Lua 侧按平台形态取用。
|
||
payload = {}
|
||
for k, v in q.items():
|
||
payload[k] = v[0] if v else ""
|
||
code = payload.get("code") or payload.get("authCode") or ""
|
||
# codearts 旧流程回退只带 secret;authorize 只带 token —— 都不要求 code
|
||
need_code = (prefix != "/authorize" and prefix != "/oauth/callback")
|
||
if need_code and not code:
|
||
self.send_response(400); self.send_header("Content-Type","text/plain; charset=utf-8")
|
||
self.end_headers(); self.wfile.write("\u767b\u5f55\u56de\u8c03\u7f3a\u5c11\u6388\u6743\u7801".encode()); return
|
||
if not payload:
|
||
self.send_response(400); self.end_headers(); self.wfile.write(b"No params"); return
|
||
# 不经 shell 拼串,直接写成 JSON 文件(避免引号/特殊字符注入)。
|
||
d = os.path.dirname(out_path)
|
||
fd, tmp = tempfile.mkstemp(dir=d)
|
||
with os.fdopen(fd, "w") as f:
|
||
json.dump(payload, f)
|
||
os.replace(tmp, out_path)
|
||
self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
|
||
self.end_headers()
|
||
self.wfile.write("<html><body><h2>\u767b\u5f55\u6210\u529f\uff0c\u53ef\u4ee5\u5173\u95ed\u6b64\u7a97\u53e3\u4e86</h2></body></html>".encode())
|
||
# 双栈:两个 HTTP 实例都要主动停(否则非当前那个会挂着不退)。
|
||
def _stop():
|
||
for s in SERVERS:
|
||
try: s.shutdown()
|
||
except Exception: pass
|
||
threading.Thread(target=_stop, daemon=True).start()
|
||
|
||
SERVERS = []
|
||
# 为何要双栈:参考注释点明「浏览器常把 localhost 解析成 IPv6」——
|
||
# 只绑 127.0.0.1 时,Gemini 的 http://localhost:…/oauth-callback 会连不上。
|
||
# 不能改成绑 ::(那等于对外暴露);只绑回环地址的两种家族。
|
||
# 注意:address_family 必须在**构造前**就在类上定好(socketserver 建 socket
|
||
# 时读的就是这个类属性);构造后再赋值无效 —— 那会让 IPv6 分支静默失败。
|
||
class V6Server(HTTPServer):
|
||
address_family = socket.AF_INET6
|
||
|
||
# minPort:某些平台(CodeArts)要求回调端口 >= 10000,低于此值 portal 会拒绝。
|
||
# 照参考 listenOnCallbackPort:bind(0) 拿到的端口若过小,关掉后用 [minPort,65535]
|
||
# 的随机端口重试。
|
||
min_port = int(sys.argv[5]) if len(sys.argv) > 5 else 0
|
||
port = 0
|
||
try:
|
||
v6 = V6Server(("::1", 0), H)
|
||
port = v6.server_address[1]
|
||
if min_port and port < min_port:
|
||
v6.server_close()
|
||
port = 0
|
||
else:
|
||
SERVERS.append(v6)
|
||
except Exception:
|
||
pass
|
||
if port == 0:
|
||
for _ in range(6):
|
||
cand = random.randint(min_port, 65535) if min_port else 0
|
||
try:
|
||
v6 = V6Server(("::1", cand), H)
|
||
port = v6.server_address[1]
|
||
SERVERS.append(v6)
|
||
break
|
||
except Exception:
|
||
port = 0
|
||
try:
|
||
v4 = HTTPServer(("127.0.0.1", port), H)
|
||
if port == 0:
|
||
port = v4.server_address[1]
|
||
SERVERS.append(v4)
|
||
except Exception:
|
||
pass
|
||
if not SERVERS:
|
||
sys.exit(3)
|
||
# 把实际选中的端口写回文件:调用方读它拼 redirect_uri(bind 0 时端口只有 bind 后才知道)。
|
||
with open(port_path, "w") as f:
|
||
f.write(str(port))
|
||
for s in SERVERS:
|
||
threading.Thread(target=s.serve_forever, daemon=True).start()
|
||
threading.Event().wait()
|
||
]]
|
||
|
||
local function loopbackScriptPath()
|
||
local p = "/tmp/llmsproxy-loopback.py"
|
||
local w = io.open(p, "w")
|
||
if not w then return nil end
|
||
w:write(LOOPBACK_PY)
|
||
w:close()
|
||
return p
|
||
end
|
||
|
||
-- reapLoopback 杀掉某会话的接收器进程并清文件(无需常驻,用完即收)。
|
||
-- 必须定义在 loopbackReceiver **之前**:Lua 的 local 作用域只覆盖其后的代码,
|
||
-- 反过来写会让 loopbackReceiver 里的调用拿到 nil(真实踩过的坑)。
|
||
reapLoopback = function(sessionId)
|
||
local l = plugin.state.loopbacks and plugin.state.loopbacks[sessionId]
|
||
if not l then return end
|
||
if l.pid and l.pid ~= "" then
|
||
os.execute("kill -9 " .. tostring(l.pid) .. " 2>/dev/null")
|
||
end
|
||
if l.file then os.remove(l.file) end
|
||
if l.port_file then os.remove(l.port_file) end
|
||
plugin.state.loopbacks[sessionId] = nil
|
||
end
|
||
|
||
-- loopbackReceiver(session_id, cb_path):起接收器,返回该会话的 redirect_uri。
|
||
--
|
||
-- cb_path 必须与平台注册值**逐字一致**(改了就是 redirect_uri_mismatch):
|
||
-- 有道 /auth/callback(参考注释点名「精确路径」)、
|
||
-- Trae /authorize、Gemini /oauth-callback(参考 GEMINI_CALLBACK_PATH)。
|
||
local function loopbackReceiver(sessionId, cbPath, minPort)
|
||
cbPath = cbPath or "/auth/callback"
|
||
local script = loopbackScriptPath()
|
||
if not script then return nil, "无法写入 loopback 脚本" end
|
||
local dir = storeDir()
|
||
local outFile = dir .. "/callback-" .. sessionId .. ".json"
|
||
local portFile = dir .. "/callback-" .. sessionId .. ".port"
|
||
os.remove(outFile)
|
||
os.remove(portFile)
|
||
local sess = sessions()[sessionId]
|
||
local wantState = (sess and sess.nonce) or ""
|
||
-- 端口由 python bind(0) 自己选,绑 127.0.0.1(不对外暴露监听面);
|
||
-- 它把实际端口写回 portFile,我们轮询读取后拼 redirect_uri。
|
||
local cmd = ("python3 %s %s %s %s %s %s >/tmp/llmsproxy-loopback-%s.log 2>&1 & echo $!"):format(
|
||
script, cbPath, outFile, wantState, portFile, tostring(minPort or 0), sessionId)
|
||
local h = io.popen(cmd)
|
||
local pid = h and h:read("*l")
|
||
if h then h:close() end
|
||
if not pid or pid == "" then return nil, "loopback 启动失败(python3 不可用?)" end
|
||
if not plugin.state.loopbacks then plugin.state.loopbacks = {} end
|
||
plugin.state.loopbacks[sessionId] = { pid = pid, file = outFile, port_file = portFile }
|
||
-- 等端口文件出现(就绪探针,不用 sleep 拍脑袋)
|
||
local deadline = (os.time and os.time() or 0) + 10
|
||
local port = nil
|
||
while true do
|
||
local f = io.open(portFile, "r")
|
||
if f then
|
||
local v = f:read("*l")
|
||
f:close()
|
||
port = tonumber(v)
|
||
if port then break end
|
||
end
|
||
if (os.time and os.time() or 0) >= deadline then
|
||
reapLoopback(sessionId)
|
||
return nil, "loopback 未就绪(未取得端口)"
|
||
end
|
||
os.execute("sleep 0.2")
|
||
end
|
||
-- 主机用 127.0.0.1(不是 localhost,除非平台注册值就是 localhost);
|
||
-- 路径按平台注册值一字不差。
|
||
local host = (sess and sess.callback_host) or "127.0.0.1"
|
||
return "http://" .. host .. ":" .. tostring(port) .. cbPath, nil
|
||
end
|
||
|
||
-- readLoopbackCallback(session_id):读接收器写下的回调 query。
|
||
-- 返回 (payload_table, nil) 或 (nil, nil)。未到/文件不存在返回 nil,nil ——
|
||
-- 调用方应继续保持 pending(不能把「还没回调」当成「失败」)。
|
||
local function readLoopbackCallback(sessionId)
|
||
local l = plugin.state.loopbacks and plugin.state.loopbacks[sessionId]
|
||
local f = l and l.file and io.open(l.file, "r")
|
||
if not f then return nil, nil end
|
||
local body = f:read("*a")
|
||
f:close()
|
||
local ok, rec = pcall(json.decode, body or "")
|
||
if not ok or type(rec) ~= "table" then return nil, nil end
|
||
return rec, nil
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- spawnBrowser 拉起一组服务端浏览器。返回
|
||
-- { view_url, password, cdp_port, display, vnc, ws } 或 nil, err。
|
||
--
|
||
-- 与早期实现的差别(都是真实踩过的坑):
|
||
-- 1. **就绪探针**:不再 sleep 2 拍脑袋,各端口逐个等(X 起来 → 浏览器起 →
|
||
-- VNC 起 → websockify 起),否则 view_url 先于服务可用,iframe 白屏。
|
||
-- 2. **CDP 端口**:浏览器带 --remote-debugging-port,登录完成后可读凭据
|
||
-- (见 cdpEval / vncCollect),而不是只能人眼看屏幕。
|
||
-- 3. **网关端口来自内核**:回调地址拼的是本实例真实端口,不硬编码 8081。
|
||
-- 4. **依赖预检**:缺依赖直接给出清单(谁、干什么用、装什么包),
|
||
-- 而不是一个含糊的"进程启动失败"。
|
||
-- spawnBrowser(start_url):拉起服务端浏览器栈(Xvfb + chromium + x11vnc +
|
||
-- websockify),返回 VNC 视图地址与 CDP 端口。
|
||
-- start_url 已是**拼装完整的授权页 URL**(redirect_uri/state 由调用方按平台
|
||
-- 规格填好),此处不再改写。
|
||
local function spawnBrowser(start_url)
|
||
local b = browserStack()
|
||
if b.session then return nil, "已有浏览器会话在跑,请先停止" end
|
||
local deps = browserDeps()
|
||
if not deps.ok then
|
||
local miss = {}
|
||
for _, m in ipairs(deps.missing) do
|
||
miss[#miss + 1] = ("%s(%s,装 %s)"):format(m.bin, m.why, m.pkg)
|
||
end
|
||
return nil, "缺少浏览器栈依赖: " .. table.concat(miss, ", ")
|
||
end
|
||
b.seq = b.seq + 1
|
||
local seq = b.seq
|
||
-- 每次会话一个唯一标记:用于 chromium 的 user-data-dir 与日志文件名。
|
||
-- 为何不能用 seq:seq 在插件重启后从 1 重新开始,于是会**复用同一个
|
||
-- user-data-dir**,而里面残留的 `SingletonLock` 会让 chromium 认为
|
||
-- “已有实例在跑”——它只打印一句 `Opening in existing browser session.` 就退出,
|
||
-- 新端口永不监听 → 15s 后报「chromium 端口未就绪」(真实踩过)。
|
||
-- 显示器锁是同一类坑(见 pickFreeX),此处用不可复用的标记从根上避开。
|
||
local tag = tostring(os.time and os.time() or 0) .. "-" .. newToken():sub(1, 8)
|
||
-- display / 端口取号:**探测空闲**,不能按 seq 取模。
|
||
-- -- 真实缺陷:原先写 `90 + (seq % 10)`,而 Xvfb 的锁文件(/tmp/.X<n>-lock)在
|
||
-- 进程被强杀时会残留。重启后 seq 从头开始 → 又选中同一个号 → Xvfb 直接
|
||
-- `Fatal: Server is already active for display 91` 退出,VNC 兜底永远起不来,
|
||
-- 而报错只显示"Xvfb 未就绪",看不出是被残留锁挡住。
|
||
-- 现在从 91 起找第一个既无锁文件、也无 socket 的号;端口同理探测空闲。
|
||
local function pickFree(start, isFree)
|
||
for n = start, start + 40 do
|
||
if isFree(n) then return n end
|
||
end
|
||
return start
|
||
end
|
||
-- 判存在必须用 `[ -e ]` 而不是 io.open:X socket 是 socket 文件,
|
||
-- io.open 对它返回 nil(打不开)→ 探测会误判"空闲"→ 又选中被残留 socket
|
||
-- 占住的号 → Xvfb `Cannot establish any listening sockets` 退出。
|
||
-- 这个坑真实踩过:残留 X91 socket 让显示器探测完全失效。
|
||
local function pathExists(path)
|
||
local ok = os.execute('[ -e "' .. path .. '" ] 2>/dev/null')
|
||
-- Lua 5.1 返回数字(0=成功),5.2+ 返回 true/nil
|
||
return ok == true or ok == 0
|
||
end
|
||
-- 显示器号:用 pickFreeX(会回收陈旧锁,且扫不到就如实报错),
|
||
-- 不再用“存在就当被占”的 inline 判定 —— 那会把每个被 KILL 的显示器永久烧掉。
|
||
local disp = pickFreeX(91, 200)
|
||
if not disp then
|
||
return nil, "没有可用的显示器号(/tmp/.X*-lock 全被占用)"
|
||
end
|
||
-- 立即记下:后续任何一步失败都会 reapBrowser(),它靠 b.display 才能清掉
|
||
-- 这个号的锁(否则 Xvfb 未起来就失败时,残留锁会把号白烧掉)。
|
||
local bs = browserStack()
|
||
bs.display = disp
|
||
local function portFree(p)
|
||
-- 用 /proc/net/tcp + tcp6 查是否有 LISTEN(十六进制端口)
|
||
for _, f in ipairs({ "/proc/net/tcp", "/proc/net/tcp6" }) do
|
||
local h = io.open(f, "r")
|
||
if h then
|
||
local hex = ("%04X"):format(p)
|
||
for line in h:lines() do
|
||
local lp = line:match("^%s*%d+:%s*[0-9A-F]+:([0-9A-F]+)")
|
||
if lp == hex then h:close() return false end
|
||
end
|
||
h:close()
|
||
end
|
||
end
|
||
return true
|
||
end
|
||
-- 端口**不需要**覆盖点:端口是全局资源,`portFree` 直接查 /proc/net/tcp,
|
||
-- 所以它能看见生产占的 59100/60100/61100(这与显示器号不同 —— 那个靠
|
||
-- /tmp/.X<n>-lock,而生产 PrivateTmp 下的锁宿主看不到)。
|
||
-- ⚠️ 我一度在这里加过 port_base 偏移,结果把端口推到 66xxx 反而起不来 ——
|
||
-- 端口不是"号段",偏移没有意义。
|
||
local vport = pickFree(59100, portFree)
|
||
local wport = pickFree(60100, portFree)
|
||
local cdpPort = pickFree(61100, portFree)
|
||
local bind = (plugin.state.settings and plugin.state.settings.vnc_bind) or "127.0.0.1"
|
||
-- 会话密码:bind 到非回环时是唯一的防线,随机生成、只在响应里给一次
|
||
local pass = sha256_hex(newToken()):sub(1, 16)
|
||
local gwPort = gatewayPort()
|
||
-- 不再改写 start_url 里的端口/回调地址:`{port}` 占位已在 buildAuthorizeUrl
|
||
-- 拼装时换成 gatewayPort(),而各平台的回调路径**彼此不同**(Trae 用
|
||
-- auth_callback_url 指本插件挂载点、LobsterAI 用 /auth/callback 指 loopback
|
||
-- 接收器)。此前这里用 gsub 把 "redirect_uri=127.0.0.1:<port>" 一律改写成
|
||
-- /api/plugins/http/channels/callback —— 一旦命中就会把需要别的路径的平台
|
||
-- (尤其有道,其 login 段还被百分号编码)改错。现为死代码且只可能致害,遂删。
|
||
-- 每步起一个进程并等它的端口就绪(X 用 socket 探,别的用 TCP)。
|
||
local pids = {}
|
||
local function spawn(name, cmd, host, port, timeoutMs)
|
||
local h = io.popen(cmd)
|
||
local pid = h and h:read("*l")
|
||
if h then h:close() end
|
||
if not pid or pid == "" then
|
||
return false, name .. " 启动失败(cmd=" .. tostring(cmd):sub(1,80) .. ")"
|
||
end
|
||
pids[#pids + 1] = pid
|
||
-- 立刻回写到 plugin.state,**不能只在函数末尾赋值**:
|
||
-- 任何一步中途失败都要 reapBrowser(),而它读的是 b.pids。若此时 b.pids 还是
|
||
-- 旧值(空),已经起来的进程就不会被杀 → 孤儿 Xvfb/chromium 常驻。
|
||
-- 真实踩过:4 个失败的 VNC 会话留下 4 个 orphan Xvfb。
|
||
b.pids = pids
|
||
if port and port > 0 then
|
||
if not waitPort(host or "127.0.0.1", port, timeoutMs or 8000) then
|
||
return false, name .. " 端口未就绪(:" .. port .. ")"
|
||
end
|
||
end
|
||
return true, pid
|
||
end
|
||
|
||
local chromium = deps.chromium
|
||
local novnc = deps.novnc
|
||
-- chromium 数据目录必须**每次会话全新的**:复用同一目录时,上一次被强杀
|
||
-- 留下的 SingletonLock 会让它直接退出(见 tag 的注释)。这里不只是换新目录,
|
||
-- 还把可能残留的单例锁与陈旧 profile 清掉,双保险。
|
||
local profileDir = "/tmp/llmsproxy-ch-" .. tag
|
||
-- ⚠️⚠️ 真实事故(2026-10-14,VNC 一直白屏的真因)。
|
||
--
|
||
-- 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium
|
||
-- **独立 GPU 进程**启动所需的系统调用。它起不来 → chromium 直接放弃:
|
||
-- ERROR:content/browser/gpu/gpu_process_host.cc:1124]
|
||
-- GPU process launch failed: error_code=1002
|
||
-- FATAL:content/browser/gpu/gpu_data_manager_impl_private.cc:417]
|
||
-- GPU process isn't usable. Goodbye.
|
||
-- 整个 chromium 在几秒内退出 ⇒ **VNC 上永远白屏**。
|
||
--
|
||
-- 为什么长期没发现:手工在**沙箱外**跑同一条命令每次都成功(我为此绕了很久,
|
||
-- 先后怀疑过 profile 锁、/tmp 满、PrivateTmp、可写 HOME —— 全是错的)。
|
||
-- 定位靠的是**用 systemd-run 逐 directive 二分**:只有
|
||
-- `SystemCallFilter=@system-service` 与 `ProtectSystem=strict` 能让它死,
|
||
-- 其余加固项均无影响。
|
||
--
|
||
-- 修法:`--in-process-gpu` 让 GPU 逻辑跑在浏览器进程内(不 fork 独立进程),
|
||
-- 于是不再触碰被 seccomp 挡掉的那条路径。**实测在完整生产沙箱下
|
||
-- ALIVE=1 / 窗口=4 / 无 FATAL**。修在插件层,unit 一行不动 ——
|
||
-- 加固不该为了一个浏览器栈而放松。
|
||
--
|
||
-- 顺带保留可写的 HOME/XDG(下面几行):生产 `ProtectHome=yes` 下 /root 只读,
|
||
-- chromium 建 dconf/ibus 目录会刷一堆 permission denied。它**不是**这次白屏的
|
||
-- 原因(我起初误判为真因),但不给可写目录会持续产生噪声日志。
|
||
local homeDir = "/tmp/llmsproxy-bhome-" .. tag
|
||
os.execute("rm -rf " .. profileDir .. " " .. homeDir .. " 2>/dev/null")
|
||
os.execute("mkdir -p " .. homeDir .. "/.cache " .. homeDir .. "/.config " .. homeDir .. "/.local/share 2>/dev/null")
|
||
-- 顺带清掉**陈旧**的 profile 目录:正常路径由 reapBrowser 清,但进程被强杀
|
||
-- (go test 超时、插件崩)时来不及清,而 tag 唯一意味着它们会一直累积。
|
||
-- 只清 6 小时前的,不会碰到别的会话正在用的。
|
||
os.execute("find /tmp -maxdepth 1 -name 'llmsproxy-ch-*' -mmin +360 -exec rm -rf {} + 2>/dev/null")
|
||
-- 记下来供 reapBrowser 收尾清理(失败路径也要清,否则 tag 唯一=无限累积)。
|
||
browserStack().profile = profileDir
|
||
browserStack().home = homeDir
|
||
local steps = {
|
||
{ "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%s.log 2>&1 & echo $!"):format(disp, tag), nil, 0, 0 },
|
||
{ "chromium", ("HOME=%s XDG_CACHE_HOME=%s/.cache XDG_CONFIG_HOME=%s/.config "
|
||
.. "XDG_DATA_HOME=%s/.local/share "
|
||
.. "%s --no-sandbox --disable-dev-shm-usage --user-data-dir=%s "
|
||
.. "--no-first-run --disable-gpu --in-process-gpu --remote-debugging-port=%d --remote-allow-origins=* "
|
||
.. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%s.log 2>&1 & echo $!"):format(
|
||
homeDir, homeDir, homeDir, homeDir,
|
||
chromium, profileDir, cdpPort, disp, start_url, tag), "127.0.0.1", cdpPort, 15000 },
|
||
{ "x11vnc", ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet "
|
||
.. ">/tmp/llmsproxy-x11vnc-%s.log 2>&1 & echo $!"):format(
|
||
disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, tag), "127.0.0.1", vport, 8000 },
|
||
{ "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%s.log 2>&1 & echo $!"):format(
|
||
novnc, bind, wport, vport, tag), bind, wport, 8000 },
|
||
}
|
||
-- ① 先起 Xvfb(其余三步依赖 X display)
|
||
local xvfbStep = steps[1]
|
||
local xok, xerr = spawn(xvfbStep[1], xvfbStep[2], nil, 0, 0)
|
||
if not xok then
|
||
reapBrowser()
|
||
return nil, xerr
|
||
end
|
||
-- X 的 socket 在 /tmp/.X11-unix/X<n>,等它出现再起浏览器
|
||
local unixSock = ("/tmp/.X11-unix/X%d"):format(disp)
|
||
local xdeadline = (os.time and os.time() or 0) + 10
|
||
while true do
|
||
-- 同 pathExists 的理由:X socket 是 socket 文件,io.open 打不开,
|
||
-- 用它判"就绪"会永远等不到(这个坑在上面选号处也踩过一次)。
|
||
if pathExists(unixSock) then break end
|
||
if (os.time and os.time() or 0) >= xdeadline then
|
||
reapBrowser()
|
||
return nil, "Xvfb 未就绪(" .. unixSock .. " 未出现)"
|
||
end
|
||
os.execute("sleep 0.3")
|
||
end
|
||
-- ② 起浏览器/VNC/websockify(三步都有 TCP 端口可探)
|
||
for i = 2, #steps do
|
||
local st = steps[i]
|
||
local ok, err = spawn(st[1], st[2], st[3], st[4], st[5])
|
||
if not ok then
|
||
reapBrowser()
|
||
return nil, err
|
||
end
|
||
end
|
||
|
||
-- ★ 存活探针:端口"能连上"**不等于**浏览器还活着。
|
||
-- 真实事故:chromium 因 GPU 自杀时,CDP 端口在它退出前确实短暂监听过 ⇒
|
||
-- waitPort 通过 ⇒ 我们报"已就绪",运维打开 VNC 却是白屏。**谎报"就绪"比
|
||
-- 直接报错更坏**:运维会以为是自己操作的问题。
|
||
-- 判据必须咬**外部效应**(进程仍在 + CDP 真能应答),而不是"我们发过启动命令"。
|
||
os.execute("sleep 1")
|
||
local cpid = pids[2]
|
||
if cpid and cpid ~= "" and not browserUtil.pathExists("/proc/" .. cpid) then
|
||
local why = browserUtil.readTail("/tmp/llmsproxy-chrome-" .. tag .. ".log", 400)
|
||
reapBrowser()
|
||
return nil, "浏览器启动后即退出(多半是 GPU/权限问题):" .. tostring(why)
|
||
end
|
||
if not browserUtil.cdpAlive(cdpPort) then
|
||
local why = browserUtil.readTail("/tmp/llmsproxy-chrome-" .. tag .. ".log", 400)
|
||
reapBrowser()
|
||
return nil, "浏览器 CDP 无应答(:" .. tostring(cdpPort) .. "):" .. tostring(why)
|
||
end
|
||
b.pids = pids
|
||
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq }
|
||
local host = (plugin.state.settings and plugin.state.settings.vnc_host) or "127.0.0.1"
|
||
return {
|
||
view_url = ("http://%s:%d/vnc.html?autoconnect=1&host=%s&port=%d&password=%s"):format(
|
||
host, wport, host, wport, pass),
|
||
password = pass,
|
||
cdp_port = cdpPort,
|
||
display = disp,
|
||
}
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 登录流程:优先级阶梯(① 本机浏览器 → ② 自动化 → ③ VNC 兜底)
|
||
-- ============================================================================
|
||
|
||
-- start_login 按优先级阶梯响应:
|
||
-- ① 本机浏览器:直接返回登录 URL(绝大多数场景到此为止 —— 平台重定向打在
|
||
-- 用户自己的机器上,插件轮询/收回调拿凭据)
|
||
-- ② 自动化:link-poll/callback 流程本身就是自动的(轮询 + nonce 回调),
|
||
-- 无需用户之外的动作
|
||
-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、
|
||
-- 注意回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。
|
||
-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、
|
||
-- 注意回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。
|
||
--
|
||
-- 有道 LobsterAI:portal 授权页要求 redirect_uri 是
|
||
-- http://127.0.0.1:{port}/auth/callback(**精确路径**,参考注释点名)。
|
||
-- 远程用户机器上的 127.0.0.1 **不是本网关** —— portal 重定向打不到本插件的
|
||
-- /api/plugins/http/... 挂载点。故插件(可信、有 OS 能力)在本机起一个
|
||
-- 轻量的 loopback 接收器,portal 重定向落到它的本地端口;它把 authCode/state
|
||
-- 写进一个文件,poll_login 读该文件完成交换。这样远程用户在服务端浏览器(VNC)
|
||
-- 里完成交互后,portal 直接重定向到该本机端口,无需远程浏览器与网关同机。
|
||
|
||
local function initLoginSession(plat, args, now)
|
||
local sess = {
|
||
id = newToken():sub(1, 24),
|
||
platform = plat.id,
|
||
flow = plat.flow,
|
||
url = plat.login_url,
|
||
nonce = newToken():sub(1, 32),
|
||
state = "pending",
|
||
created_at = now,
|
||
expires_at = now + SESSION_TTL,
|
||
}
|
||
if plat.id == "lobsterai" then
|
||
local seed = newToken()
|
||
sess.install_uuid = seed:sub(1, 8) .. "-" .. seed:sub(9, 12) .. "-4"
|
||
.. seed:sub(14, 16) .. "-8" .. seed:sub(18, 20) .. "-" .. seed:sub(21, 32)
|
||
sess.first_keyfrom = tostring(now * 1000)
|
||
elseif plat.id == "trae" then
|
||
-- 设备身份:参考生成两个 **hex32**(照 login.sh 的 `openssl rand -hex 16`),
|
||
-- 并在登录 URL 里下发。凭据里也要留一份(签到 X-Device-Id 用),
|
||
-- 且**每账号必须不同** —— 同一天两账号共用会被「该设备已签到」拦。
|
||
-- 此前只发 5 个参数、完全没造这两个值,登录页就停在「授权中」不回来。
|
||
sess.machine_id = newToken():sub(1, 32)
|
||
sess.device_id = newToken():sub(33, 64)
|
||
local joined = sess.machine_id .. sess.device_id
|
||
sess.login_trace_id = #joined >= 16 and joined:sub(-16) or joined
|
||
end
|
||
sessions()[sess.id] = sess
|
||
sess.account_id = args and args.account_id
|
||
return sess
|
||
end
|
||
|
||
function plugin.start_login(req)
|
||
local args = bodyOf(req)
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then
|
||
return { status = 400, json = { error = "未知平台: " .. tostring(args and args.platform) } }
|
||
end
|
||
sweepSessions()
|
||
local now = os.time and os.time() or 0
|
||
local sess = initLoginSession(plat, args, now)
|
||
logEvent("开始登录 " .. plat.name .. " (" .. plat.flow .. ")")
|
||
|
||
-- 响应里只带一次 nonce(回调校验要用),存侧不回显
|
||
local out = {
|
||
session = { id = sess.id, platform = sess.platform, flow = sess.flow,
|
||
state = sess.state, nonce = sess.nonce, expires_at = sess.expires_at },
|
||
platform = plat.id,
|
||
}
|
||
if plat.id == "lobsterai" or plat.id == "trae" or plat.id == "gemini" or plat.id == "codearts" then
|
||
-- 这三个平台的 redirect_uri 都是**回环地址**(127.0.0.1 / localhost),
|
||
-- 且路径各自是注册值:有道 /auth/callback、Trae /authorize、
|
||
-- Gemini /oauth-callback。回环地址由「打开授权页的那台机器」解析,而
|
||
-- 服务端浏览器就在本机 → 重定向落本机 → 插件自己的接收器能接住它。
|
||
-- 远程用户在自己电脑上打开则永远接不住(这正是 callback 流在服务器
|
||
-- 部署下必须走服务端浏览器的原因)。
|
||
local cbPath = (plat.authorize and plat.authorize.callback_path)
|
||
or (plat.id == "gemini" and "/oauth-callback")
|
||
or (plat.id == "trae" and "/authorize") or "/auth/callback"
|
||
-- Gemini 注册的 redirect_uri 主机是 **localhost**(不是 127.0.0.1),
|
||
-- 参考注释明确点名;两者在多数机器上等价,但必须与注册值逐字一致。
|
||
if plat.id == "gemini" then sess.callback_host = "localhost" end
|
||
-- CodeArts:生成 PKCE 与 ticket_id,回调端口须 ≥10000(portal 拒绝低端口),
|
||
-- 登录 URL 用**专用构建函数**(含 SHA-256 而非 S256,且不得有 auth_callback_url)。
|
||
if plat.id == "codearts" then
|
||
local pkce, perr = codeartsPkcePair()
|
||
if not pkce then
|
||
sess.state = "failed"
|
||
out.error = "PKCE 生成失败: " .. tostring(perr)
|
||
return { status = 200, json = out }
|
||
end
|
||
local key, kerr = codeartsNewDpop()
|
||
if not key then
|
||
sess.state = "failed"
|
||
out.error = "DPoP 密钥生成失败: " .. tostring(kerr)
|
||
return { status = 200, json = out }
|
||
end
|
||
sess.codearts = { pkce = pkce, key = key, ticket_id = newToken():sub(1, 64) }
|
||
sess.min_port = (plat.authorize and plat.authorize.min_port) or 10000
|
||
end
|
||
local minPort = nil
|
||
if plat.id == "codearts" then
|
||
minPort = (plat.authorize and plat.authorize.min_port) or 10000
|
||
end
|
||
local cb_url, cb_err = loopbackReceiver(sess.id, cbPath, minPort)
|
||
if cb_err then
|
||
sess.state = "failed"
|
||
out.error = "无法启动回调接收器: " .. cb_err
|
||
return { status = 200, json = out }
|
||
end
|
||
sess.redirect_uri = cb_url
|
||
out.redirect_uri = cb_url
|
||
end
|
||
if plat.flow == "manual" then
|
||
-- 无法自动化的登录(WASM/captcha/扫码):给明确说明而不是假装成功。
|
||
sess.state = "failed"
|
||
sess.message = plat.manual_hint or "该平台不支持自动登录"
|
||
out.manual_hint = sess.message
|
||
out.url = plat.login_url
|
||
logEvent("手动流程 " .. plat.id)
|
||
return { status = 200, json = out }
|
||
elseif plat.flow == "state-poll" then
|
||
-- Buddy:先拿 state + authUrl。页面给 authUrl,轮询 token 每推进一步。
|
||
local resp, err = __llmsproxy_http({
|
||
url = plat.state.state_url,
|
||
method = "POST",
|
||
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
|
||
body = "{\"platform\":\"ide\"}",
|
||
})
|
||
if err or resp.status ~= 200 then
|
||
sess.state = "failed"
|
||
out.error = "auth/state 失败: " .. (err or ("HTTP " .. tostring(resp.status)))
|
||
return { status = 200, json = out }
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" or type(rec.data) ~= "table"
|
||
or not rec.data.state or rec.data.state == "" then
|
||
sess.state = "failed"
|
||
out.error = "auth/state 响应缺少 state 字段"
|
||
return { status = 200, json = out }
|
||
end
|
||
sess.state_token = rec.data.state
|
||
out.url = rec.data.authUrl or plat.login_url
|
||
logEvent("state 已申请 " .. plat.id)
|
||
elseif plat.flow == "vnc" then
|
||
-- ③ 兜底路径:服务端浏览器。重定向打在服务端回环上,回调命中本插件的挂载点。
|
||
local bs, berr = spawnBrowser(string.format(plat.login_url, 0))
|
||
if berr then
|
||
out.browser_error = berr
|
||
out.hint = "服务端浏览器不可用;运行 channels/deps/status 看缺什么依赖"
|
||
logEvent("服务端浏览器启动失败: " .. berr)
|
||
else
|
||
out.view_url = bs.view_url
|
||
out.session.cdp_port = bs.cdp_port
|
||
sess.cdp_port = bs.cdp_port
|
||
-- 登录页需要从服务端浏览器完成;用户交互后由 collect_credentials 取凭据
|
||
sess.collect_expr = plat.collect_expr
|
||
logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")")
|
||
end
|
||
elseif plat.flow == "qr-poll" then
|
||
-- 自带二维码的扫码登录(Raccoon):code 由**本地随机生成**,服务端接受
|
||
-- 任意自造 code 并进入 pending(参考注释实测)。二维码图交给前端渲染。
|
||
local q = plat.qr
|
||
local code = tostring(sha256_hex(tostring(os.time and os.time() or 0) .. tostring(math.random(1e12)))):sub(1, 32)
|
||
sess.qr_code = code
|
||
out.qr_url = q.api_base .. q.login_page .. "?code=" .. code
|
||
.. "&appname=" .. rfc3986Escape(q.app_name)
|
||
out.url = out.qr_url
|
||
out.session.interval = q.interval or 2
|
||
logEvent("二维码已生成 " .. plat.id)
|
||
elseif plat.flow == "wechat-poll" then
|
||
-- 微信扫码:取 uuid → 把二维码给前端渲染 → 长轮询等扫码确认。
|
||
-- uuid 从授权页 HTML 里抽(参考 extractLoomyWechatUuid 的两条正则兜底)。
|
||
local w = plat.wechat
|
||
local authUrl = w.auth_url:gsub("{state}", sess.nonce)
|
||
local resp, err = __llmsproxy_http({
|
||
url = authUrl, method = "GET",
|
||
headers = { ["Accept"] = "text/html", ["User-Agent"] = w.user_agent,
|
||
["Referer"] = "https://open.weixin.qq.com/" },
|
||
})
|
||
if err or not resp or resp.status ~= 200 then
|
||
sess.state = "failed"
|
||
out.error = "微信授权页拉取失败: " .. (err or ("HTTP " .. tostring(resp and resp.status)))
|
||
return { status = 200, json = out }
|
||
end
|
||
local html = resp.body or ""
|
||
-- 两条路径互为兜底(照参考 extractLoomyWechatUuid 的正则):
|
||
-- 主:<img class="js_qrcode_img" src="/connect/qrcode/<uuid>"/>
|
||
-- 兜底:var fordevtool = "…/connect/l/qrconnect?uuid=<uuid>"
|
||
-- 关键:无需执行 JS —— 这是本方案能脱离浏览器的基础。
|
||
local uuid = html:match("/connect/qrcode/([%w_%-=+/]+)")
|
||
or html:match("l/qrconnect%?uuid=([%w_%-=+/]+)")
|
||
if not uuid or uuid == "" then
|
||
sess.state = "failed"
|
||
out.error = "微信授权页未包含二维码 uuid(页面结构可能已变化)"
|
||
return { status = 200, json = out }
|
||
end
|
||
sess.wechat_uuid = uuid
|
||
sess.wechat_last = ""
|
||
-- 二维码图交给前端渲染(参考把 JPEG 转 data URL;这里给前端 URL,省一次下载)
|
||
out.qr_url = w.qr_url:gsub("{uuid}", uuid)
|
||
out.url = authUrl
|
||
logEvent("微信二维码已就绪 " .. plat.id)
|
||
elseif plat.flow == "init-poll" then
|
||
-- 两段式服务端中介授权(ZCode):init 拿 flow_id + authorize_url,
|
||
-- 用户授权后轮询 poll/{flow_id} 拿 token。
|
||
-- 会话密钥是**本地造的 32 字节 hex**,作 Bearer 用(官方就是这么做的)。
|
||
local ip = plat.initpoll
|
||
local flowSecret = sha256_hex(newToken() .. tostring(os.time and os.time() or 0))
|
||
.. sha256_hex(newToken())
|
||
local resp, err = __llmsproxy_http({
|
||
url = ip.init_url,
|
||
method = "POST",
|
||
headers = {
|
||
["Accept"] = "application/json",
|
||
["Content-Type"] = "application/json",
|
||
["Authorization"] = "Bearer " .. flowSecret,
|
||
["User-Agent"] = "ZCode/" .. (ip.app_version or "3.14.3"),
|
||
["HTTP-Referer"] = ip.origin or "",
|
||
["X-ZCode-App-Version"] = ip.app_version or "3.14.3",
|
||
["X-Platform"] = "win32",
|
||
},
|
||
body = json.encode({ provider = ip.provider or "bigmodel" }),
|
||
})
|
||
if err then
|
||
sess.state = "failed"
|
||
out.error = "授权初始化失败: " .. err
|
||
return { status = 200, json = out }
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
local data = ok and type(rec) == "table" and rec.data or nil
|
||
if not data or type(data.flow_id) ~= "string" or data.flow_id == "" then
|
||
sess.state = "failed"
|
||
out.error = "授权初始化响应缺少 flow_id(HTTP " .. tostring(resp.status) .. ")"
|
||
return { status = 200, json = out }
|
||
end
|
||
sess.init_flow_id = data.flow_id
|
||
sess.init_secret = flowSecret
|
||
sess.device_interval = tonumber(data.poll_interval_sec) or ip.interval or 2
|
||
out.url = data.authorize_url or plat.login_url
|
||
out.session.interval = sess.device_interval
|
||
logEvent("授权流程已初始化 " .. plat.id .. " flow=" .. tostring(data.flow_id):sub(1, 12))
|
||
elseif plat.flow == "device-poll" then
|
||
-- 设备码:申请码,把 user_code + 授权 URL 给用户,verifier 存进会话。
|
||
-- -- 两种形态:
|
||
-- ① OAuth 设备码(Minimax/Cline):POST 拿 device_code + user_code,
|
||
-- 再 GET token_url?device_code=... 轮询(code_body / token_body 模板)。
|
||
-- ② PKCE 授权页(Qoder):**不申请设备码** —— 本地造 PKCE,直接把
|
||
-- {authBase}/...?challenge&nonce&machine_id&client_id 给用户当授权页,
|
||
-- 再轮询 token_url?nonce&verifier 由服务端"回填"token(404=继续)。
|
||
-- 此时 code_url 带 Q_QUERY 后缀表示"GET + query 模板",无 device_code。
|
||
local verifier, challenge = devicePkce()
|
||
local machineId = newToken():sub(1, 32)
|
||
local dm = plat.device.code_method or "POST"
|
||
local rec = nil
|
||
if dm == "GET_QUERY" or plat.device.auth_query then
|
||
-- 形态②:授权页 URL 由模板拼装,无网络申请
|
||
local q = (plat.device.auth_query or "")
|
||
:gsub("{challenge}", challenge):gsub("{nonce}", sess.nonce)
|
||
:gsub("{machine_id}", machineId):gsub("{verifier}", verifier)
|
||
out.url = plat.device.code_url .. "?" .. q
|
||
sess.device_verifier = verifier
|
||
sess.device_challenge = challenge
|
||
sess.device_nonce = sess.nonce
|
||
sess.device_machine = machineId
|
||
sess.device_interval = plat.device.interval or 2
|
||
out.session.interval = sess.device_interval
|
||
logEvent("授权页已就绪 " .. plat.id)
|
||
return { status = 200, json = out }
|
||
end
|
||
local body = (plat.device.code_body:gsub("{challenge}", challenge))
|
||
local resp, err = __llmsproxy_http({
|
||
url = plat.device.code_url,
|
||
method = dm,
|
||
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, r2 = pcall(json.decode, resp.body or "")
|
||
rec = ok and type(r2) == "table" and r2 or nil
|
||
if not rec 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 流程的授权 URL 构造有三条硬性要求(此前都错):
|
||
-- 1) **每个平台的参数集不同**,不能套通用模板:Trae 用 auth_callback_url
|
||
-- (不是 redirect_uri)、Gemini 用标准 OAuth 四参数、LobsterAI 的 login
|
||
-- 在 **hash 段**(/portal#/login)。
|
||
-- 2) 端口必须用**本机实际监听端口**,不能硬编码 8081(非默认部署会全错)。
|
||
-- 3) redirect_uri 必须百分号编码(含 :// 与 :,不编码会被授权页拒)。
|
||
local port = gatewayPort()
|
||
if plat.flow == "callback" then
|
||
if plat.id == "codearts" and sess.codearts then
|
||
-- 用回调服务器**实际监听的端口**(≥10000),而不是网关端口 ——
|
||
-- portal 凭 port 参数构造回调地址,错了就收不到 code。
|
||
local cbPort = tonumber(sess.redirect_uri and sess.redirect_uri:match(":(%d+)/"))
|
||
out.url = buildCodeartsAuthorizeUrl(cbPort or port, sess.codearts.pkce,
|
||
sess.codearts.ticket_id)
|
||
elseif type(plat.authorize) == "table" then
|
||
local vars
|
||
if sess.machine_id then
|
||
vars = { machine_id = sess.machine_id, device_id = sess.device_id,
|
||
trace_id = sess.login_trace_id }
|
||
end
|
||
out.url = buildAuthorizeUrl(plat, port, sess.nonce, sess.redirect_uri, vars)
|
||
else
|
||
local cb = "http://localhost:" .. tostring(port) .. "/api/plugins/http/channels/callback"
|
||
local sep = plat.login_url:find("?", 1, true) and "&" or "?"
|
||
out.url = plat.login_url .. sep .. "redirect_uri=" .. rfc3986Escape(cb)
|
||
.. "&state=" .. sess.nonce
|
||
end
|
||
-- callback 流的 redirect_uri 是**回环地址**(127.0.0.1 / localhost)。
|
||
-- 回环地址由「打开授权页的那台机器」解析:用户在自己电脑上打开时,重定向
|
||
-- 落在**用户机器**,回调永远到不了本网关 —— 这是服务器部署下 callback 流
|
||
-- 无法靠「本机浏览器」完成的根本原因(CodeBuddy 能用的原因是它走
|
||
-- state-poll,根本不需要回调)。
|
||
--
|
||
-- 故 callback 流要走**服务端浏览器**:授权页在网关本机打开(经 VNC 呈现给
|
||
-- 运维交互),重定向落本机回环,回调命中本插件的 loopback 接收器/挂载点。
|
||
-- 没有浏览器栈时**如实说明**,不假装这个 URL 在用户机器上能用。
|
||
local deps = browserDeps()
|
||
if deps.ok then
|
||
-- 单会话栈:起新的之前先收掉旧的(否则逐次登录会叠加多套进程)。
|
||
local cur = browserStack()
|
||
if cur.session then reapBrowser() end
|
||
cur.owner = sess.id
|
||
sess.owns_browser = true
|
||
local bs, berr = spawnBrowser(out.url)
|
||
if not berr then
|
||
out.view_url = bs.view_url
|
||
out.session.cdp_port = bs.cdp_port
|
||
sess.cdp_port = bs.cdp_port
|
||
sess.collect_expr = plat.collect_expr or plat.callback_collect_expr
|
||
out.server_browser = true
|
||
logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")")
|
||
else
|
||
out.browser_error = berr
|
||
logEvent("服务端浏览器启动失败 " .. plat.id .. ": " .. berr)
|
||
end
|
||
else
|
||
local miss = {}
|
||
for _, m in ipairs(deps.missing) do miss[#miss + 1] = m.bin end
|
||
out.browser_error = "缺浏览器栈依赖: " .. table.concat(miss, ", ")
|
||
end
|
||
out.hint = out.server_browser
|
||
and "请在 VNC 窗口内完成授权(回调会落在网关本机)"
|
||
or "callback 流的回调是回环地址,只有与网关同机的浏览器才能完成;请先安装浏览器栈(channels/deps/status 可查缺什么)"
|
||
else
|
||
out.url = plat.login_url
|
||
end
|
||
end
|
||
return { status = 200, json = out }
|
||
end
|
||
|
||
-- lobsteraiClientVersion(plat):解析有道客户端版本号(带缓存与兜底)。
|
||
--
|
||
-- 三个消费点都要它:exchange 的 `version`、续期请求体的 `version`、签到 query。
|
||
-- 集中一处避免各自拉取。照参考 LobsteraiVersionResolver:
|
||
-- - 远程成功 → 用远程值并缓存;
|
||
-- - 失败 → 用兜底值但**不缓存**(缓存会让一次瞬时故障在 TTL 内持续发酵)。
|
||
-- **绝不能写死**:实测 2026-10-13 上游已是 2026.9.23。
|
||
lobsteraiClientVersion = function(plat)
|
||
local cv = plat.client_version
|
||
if type(cv) ~= "table" then return "2026.9.4" end
|
||
local cache = plugin.state.clientVersion
|
||
local now = os.time and os.time() or 0
|
||
if type(cache) == "table" and cache.value and cache.at
|
||
and (now - cache.at) < (cv.ttl or 3600) then
|
||
return cache.value
|
||
end
|
||
local resp, err = __llmsproxy_http({
|
||
url = cv.url, method = "GET",
|
||
headers = { ["Accept"] = "application/json", ["User-Agent"] = "LobsterAI/0.1.0" },
|
||
})
|
||
if not err and resp and resp.status == 200 then
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
local v = ok and rec
|
||
-- 按 path(data.value.version)逐层下钻
|
||
for seg in tostring(cv.path or ""):gmatch("[^%.]+") do
|
||
if type(v) ~= "table" then v = nil break end
|
||
v = v[seg]
|
||
end
|
||
-- 格式校验(照参考的日期式版本号正则):主干点分数字,可选预发布后缀。
|
||
-- 不校验而直接采信:上游返 HTML/空值时会拼出一个更费解的失败。
|
||
if type(v) == "string" and v:match("^%d+[%d%.]*[%w%.%-]*$") then
|
||
plugin.state.clientVersion = { value = v, at = now }
|
||
return v
|
||
end
|
||
logEvent("有道版本号格式异常,改用兜底: " .. tostring(v))
|
||
end
|
||
-- 失败:兜底且不缓存
|
||
return cv.fallback or "2026.9.4"
|
||
end
|
||
|
||
-- lobsteraiExchange(plat, code, s):用 authCode 换有道凭据。
|
||
--
|
||
-- 同一段交换逻辑有两处入口:① 回调落到本插件挂载点(oauth_callback),
|
||
-- ② 回调落到本机 loopback 接收器、由 poll_login 读文件推进。两条路必须
|
||
-- 逐字一致 —— 否则改一处漏一处,正是 go:embed 三连坑那种「同一 bug 多副面孔」。
|
||
-- 故集中在此,返回 (credential_json, account, err)。
|
||
local function lobsteraiExchange(plat, code, s)
|
||
if type(code) ~= "string" or code == "" then return nil, nil, "missing authCode" end
|
||
local now = tostring(os.time() * 1000)
|
||
local resp, err = __llmsproxy_http({
|
||
url = plat.callback.token_url, method = "POST",
|
||
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json",
|
||
["User-Agent"] = "LobsterAI/0.1.0" },
|
||
body = json.encode({ authCode = code, firstKeyfrom = s.first_keyfrom,
|
||
latestKeyfrom = now, uuid = s.install_uuid, version = lobsteraiClientVersion(plat) }),
|
||
})
|
||
if err or not resp or resp.status ~= 200 then
|
||
return nil, nil, "LobsterAI exchange 请求失败: " .. tostring(err or (resp and resp.status))
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
local d = ok and type(rec) == "table" and rec.data
|
||
if not (rec and type(rec) == "table" and rec.code == 0 and type(d) == "table"
|
||
and type(d.accessToken) == "string" and d.accessToken ~= "") then
|
||
-- 把上游的原因带出来(实测失败形如 code=40102 message="无效或已过期的授权码"),
|
||
-- 否则运维只看到「交换失败」而无法区分是码过期、参数错还是版本被拒。
|
||
local why = ""
|
||
if ok and type(rec) == "table" then
|
||
why = tostring(rec.msg or rec.message or ("code=" .. tostring(rec.code)))
|
||
end
|
||
return nil, nil, "LobsterAI exchange 失败: " .. why
|
||
end
|
||
local u = type(d.user) == "table" and d.user or {}
|
||
local function nonempty(v) return type(v) == "string" and v ~= "" and v or nil end
|
||
-- uid 四级回退(照参考 resolveLobsteraiUid):user.id → userId → yid → token 摘要
|
||
local uid = nonempty(u.id) or nonempty(u.userId) or nonempty(u.yid)
|
||
or sha256_hex(d.accessToken):sub(1, 16)
|
||
local account = nonempty(u.nickname) or uid
|
||
-- uuid/first_keyfrom 必须随凭据保存:续期请求体要原样回传(参考注释点名)。
|
||
local cred = { access_token = d.accessToken, refresh_token = d.refreshToken or "",
|
||
uid = uid, user_id = nonempty(u.userId) or nonempty(u.yid) or "", nickname = account,
|
||
uuid = s.install_uuid, first_keyfrom = s.first_keyfrom, latest_keyfrom = now }
|
||
local exp = tonumber(d.expiresIn)
|
||
if exp and exp > 0 then cred.expires_at = tostring(os.time() * 1000 + exp * 1000)
|
||
else cred.expires_at = credentialExpiresAtMs(cred) end
|
||
return json.encode(cred), account, nil
|
||
end
|
||
|
||
-- plugin.poll_login(session_id) 由页面轮询:会话是否完成。
|
||
--
|
||
-- 对 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` 语义。
|
||
-- 前向声明:这些实现都在本文件下方,而 poll_login / oauth_callback(更早的代码)
|
||
-- 就要调用它们。Lua 的 local 作用域看不到其后的定义,所以必须在**第一个调用点
|
||
-- 之前**占坑。此前它们是全局函数,靠「全局表在运行时才解析」侥幸工作;
|
||
-- 改成 local 后这个隐式依赖就断了 —— 表现为
|
||
-- attempt to call global 'traeCallback' (a nil value)。
|
||
local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId
|
||
local traeCheckinHeaders, traeFixNickname
|
||
|
||
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
|
||
|
||
-- 回调流(callback)的凭据推进:授权页在**服务端浏览器**里打开,平台把
|
||
-- 授权结果重定向回**本机回环地址**,由插件自己的 loopback 接收器(路径按各
|
||
-- 平台注册值:有道 /auth/callback、Trae /authorize、Gemini /oauth-callback)
|
||
-- 接住并写下 {code,state}。这里读它并完成交换。
|
||
--
|
||
-- 为什么要服务端浏览器:redirect_uri 是回环地址,由「打开授权页的那台机器」
|
||
-- 解析。远程用户在自己电脑上打开时,重定向落在**用户机器**上,永远到不了
|
||
-- 本网关。CodeBuddy 之所以一直能用,是因为它走 state-poll,根本不需要回调。
|
||
if s.flow == "callback" and s.state == "pending" and s.redirect_uri then
|
||
local cb, _ = readLoopbackCallback(s.id)
|
||
local gotState = cb and (cb.state or "")
|
||
local code = cb and (cb.code or cb.authCode or "")
|
||
-- Trae 的回调不带 state(token 直接放在 query 里);CodeArts 也只带 code
|
||
-- (无 state)—— 两者都不校验 state。其余平台必须 state 匹配。
|
||
local stateOK = (s.platform == "trae") or (s.platform == "codearts")
|
||
or (gotState ~= "" and gotState == s.nonce)
|
||
-- 只有「回调真到了且 state 对」才推进;文件还没出现时保持 pending,
|
||
-- 绝不能把「用户还没操作完」当成失败。
|
||
if cb and stateOK then
|
||
local plat = platformById(s.platform)
|
||
local credential, account, cerr
|
||
if s.platform == "codearts" then
|
||
-- CodeArts 新式 IAM OAuth:回调带 code → 走 STS 换 AK/SK(带 DPoP);
|
||
-- 旧流程回退:回调带 secret → 轮询 ticket 端点。
|
||
local ca = s.codearts or {}
|
||
local secret = cb and (cb.secret or "")
|
||
if secret ~= "" then
|
||
local credTbl, terr = codeartsTicketPoll(ca.ticket_id, secret,
|
||
CODEARTS_LOGIN_TICKET_NAME or nil, nil)
|
||
if not credTbl then
|
||
cerr = terr
|
||
else
|
||
credential, account = json.encode(credTbl), (credTbl.user_name or "codearts")
|
||
end
|
||
elseif code ~= "" and ca.pkce and ca.key then
|
||
local cbPort = tonumber(s.redirect_uri and s.redirect_uri:match(":(%d+)/")) or 0
|
||
local token, terr = codeartsRequestToken({
|
||
client_id = CODEARTS.client_id,
|
||
code = code,
|
||
code_verifier = ca.pkce.verifier,
|
||
grant_type = "authorization_code",
|
||
redirect_uri = "http://127.0.0.1:" .. tostring(cbPort) .. CODEARTS.redirect_path,
|
||
}, ca.key)
|
||
if not token then
|
||
cerr = terr
|
||
else
|
||
local cred = codeartsCredentialFrom(token, ca.pkce, ca.key)
|
||
credential = json.encode(cred)
|
||
account = ca.ticket_id and ("codearts-" .. tostring(ca.ticket_id):sub(1, 8)) or "codearts"
|
||
end
|
||
else
|
||
cerr = "回调既无 code 也无 secret(无法完成 CodeArts 登录)"
|
||
end
|
||
elseif s.platform == "lobsterai" then
|
||
-- 有道:authCode → exchange(与 oauth_callback 路径共用同一助手,防漂移)
|
||
credential, account, cerr = lobsteraiExchange(plat, code, s)
|
||
elseif s.platform == "trae" then
|
||
-- Trae:回调把 token 直接放在 query 里(refreshToken/userJwt/userInfo),
|
||
-- 故把整个回调 query 交给 traeCallback(它在两条路径上共用)。
|
||
credential, account, cerr = traeCallback(plat, cb, s)
|
||
if not credential and code ~= "" then
|
||
-- 回调只给了 code(PKCE 形态)→ 用 exchange 端点的标准交换
|
||
local body = (plat.callback.exchange_body or "")
|
||
:gsub("{client_id}", plat.callback.client_id or "")
|
||
:gsub("{code}", code)
|
||
:gsub("{port}", tostring(gatewayPort()))
|
||
local resp, err = __llmsproxy_http({ url = plat.callback.exchange_url,
|
||
method = "POST", headers = { ["Content-Type"] = "application/json" }, body = body })
|
||
if err or not resp or resp.status ~= 200 then
|
||
cerr = "Trae 授权码交换失败: " .. tostring(err or (resp and resp.status))
|
||
else
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
local access = ok and type(rec) == "table"
|
||
and (rec.Token or rec.token or rec.AccessToken or rec.access_token)
|
||
if not access then cerr = "Trae 授权码交换响应缺少 Token"
|
||
else credential, account, cerr = json.encode({ access_token = access }), "trae", nil end
|
||
end
|
||
end
|
||
else
|
||
-- Gemini 等标准 OAuth:表单交换(必带 client_secret,见 token_body 注释)
|
||
local body = (plat.callback.token_body or "")
|
||
:gsub("{code}", code)
|
||
:gsub("{port}", tostring(gatewayPort()))
|
||
:gsub("{verifier}", s.device_verifier or "")
|
||
local resp, err = __llmsproxy_http({
|
||
url = plat.callback.token_url, method = "POST",
|
||
headers = { ["Content-Type"] = "application/x-www-form-urlencoded",
|
||
["Accept"] = "application/json" },
|
||
body = body,
|
||
})
|
||
if err or not resp or resp.status ~= 200 then
|
||
cerr = "token 交换失败: " .. tostring(err or (resp and resp.status))
|
||
if resp and resp.body then cerr = cerr .. "(" .. tostring(resp.body):sub(1, 160) .. ")" end
|
||
else
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" or not rec.access_token then
|
||
cerr = "token 交换响应缺少 access_token"
|
||
else
|
||
credential, account = json.encode(rec), "oauth"
|
||
end
|
||
end
|
||
end
|
||
reapLoopback(s.id)
|
||
if cerr then
|
||
s.state = "failed"
|
||
s.message = cerr
|
||
else
|
||
local res = plugin.complete_login({ platform = s.platform, credential = credential,
|
||
account = account, account_id = s.account_id })
|
||
if res.json and (res.json.ok or res.json.account_id) then
|
||
s.state = "completed"
|
||
s.account = account
|
||
else
|
||
s.state = "failed"
|
||
s.message = (res.json and res.json.error) or "接入源失败"
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 条件不能要求 device_code:Qoder 形态是 PKCE 授权页,**没有 device_code**
|
||
-- (本地造 verifier/challenge,服务端按 nonce 找回待授权会话)。
|
||
-- 用「有 device_code 或有 device_verifier」表达"这是个可推进的设备码/授权页会话"。
|
||
if s.flow == "device-poll" and s.state == "pending"
|
||
and (s.device_code or s.device_verifier) then
|
||
local plat = platformById(s.platform)
|
||
local done, payload = deviceOneStep(plat, s)
|
||
if done and payload then
|
||
if payload.ok then
|
||
-- 设备码到 token 后,有的平台还要第二跳(Cline register)换最终凭据
|
||
local cred, acct, rerr = deviceRegister(plat, payload)
|
||
if rerr then
|
||
s.state = "failed"
|
||
s.message = rerr
|
||
else
|
||
-- 必须带 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
|
||
else
|
||
s.state = "failed"
|
||
s.message = (res.json and res.json.error) or "接入源失败"
|
||
end
|
||
end
|
||
else
|
||
s.state = "failed"
|
||
s.message = payload.message
|
||
end
|
||
end
|
||
elseif s.flow == "qr-poll" and s.state == "pending" and s.qr_code then
|
||
-- 轮询 login_with_qrcode_code:status ∈ pending/logging/canceled/success。
|
||
-- 参考:**任何异常都降级为 pending**(网络抖动/响应畸形/未知 status),
|
||
-- success 但缺 token 也视为未完成(否则产出空凭据并卡死)。
|
||
local plat = platformById(s.platform)
|
||
local q = plat and plat.qr
|
||
if q then
|
||
local resp, err = __llmsproxy_http({
|
||
url = q.api_base .. q.auth_prefix .. q.poll_path,
|
||
method = "POST",
|
||
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
|
||
body = json.encode({ qrcode_code = s.qr_code }),
|
||
})
|
||
if not err and resp then
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
local env = ok and type(rec) == "table" and rec or nil
|
||
if env and env.code == 0 and type(env.data) == "table" then
|
||
local st = env.data.status
|
||
if st == "canceled" then
|
||
s.state = "failed"
|
||
s.message = "用户取消了扫码授权"
|
||
elseif st == "success" then
|
||
local at = env.data.access_token
|
||
if type(at) ~= "string" or at == "" then
|
||
-- 缺 token 的 success 视为未完成(不产出空凭据)
|
||
s.wechat_hint = "扫码成功,等待凭据下发"
|
||
else
|
||
local cred = json.encode({
|
||
access_token = at,
|
||
refresh_token = env.data.refresh_token,
|
||
office_identity = env.data.office_identity,
|
||
token_type = "Bearer",
|
||
account_type = "personal",
|
||
})
|
||
local res = plugin.complete_login({ platform = s.platform, credential = cred,
|
||
account = "raccoon", account_id = s.account_id })
|
||
if res.json and res.json.ok then
|
||
s.state = "completed"
|
||
s.account = "raccoon"
|
||
else
|
||
s.state = "failed"
|
||
s.message = (res.json and res.json.error) or "接入源失败"
|
||
end
|
||
end
|
||
elseif st == "logging" then
|
||
s.wechat_hint = "已扫码,正在登录"
|
||
end
|
||
-- pending 与未知值:继续等
|
||
end
|
||
end
|
||
end
|
||
elseif s.flow == "wechat-poll" and s.state == "pending" and s.wechat_uuid then
|
||
-- 长轮询一步:判据照参考(**404 与 405 不可读反**)。
|
||
local plat = platformById(s.platform)
|
||
local w = plat and plat.wechat
|
||
if w then
|
||
local last = s.wechat_last or ""
|
||
local url = w.poll_url .. "?uuid=" .. s.wechat_uuid
|
||
.. (last ~= "" and ("&last=" .. last) or "")
|
||
.. "&_=" .. tostring(os.time and os.time() or 0)
|
||
local resp, err = __llmsproxy_http({
|
||
url = url, method = "GET",
|
||
headers = { ["Accept"] = "*/*", ["User-Agent"] = w.user_agent,
|
||
["Referer"] = w.auth_url:sub(1, (w.auth_url:find("?")) and w.auth_url:find("?") - 1 or #w.auth_url) },
|
||
})
|
||
if not err and resp then
|
||
local body = resp.body or ""
|
||
local errcode = body:match("wx_errcode%s*=%s*(%d+)") or ""
|
||
local code = body:match("wx_code%s*=%s*'([^']*)'") or ""
|
||
s.wechat_last = errcode
|
||
if errcode == "405" then
|
||
-- **已确认**(wx_code 在此帧)。405 却没带 code 是异常形态,
|
||
-- 保守判"已扫码"继续轮询,**绝不**拿空 code 去换凭据。
|
||
if code ~= "" then
|
||
local cred, cerr = wechatExchange(plat, code)
|
||
if cerr then
|
||
s.state = "failed"
|
||
s.message = cerr
|
||
else
|
||
local acct = "wechat"
|
||
local res = plugin.complete_login({ platform = s.platform, credential = cred,
|
||
account = acct, account_id = s.account_id })
|
||
if res.json and res.json.ok then
|
||
s.state = "completed"
|
||
s.account = acct
|
||
else
|
||
s.state = "failed"
|
||
s.message = (res.json and res.json.error) or "接入源失败"
|
||
end
|
||
end
|
||
end
|
||
elseif errcode == "404" then
|
||
-- 已扫码待确认:继续轮询(**不是**完成 —— 早期读反导致卡住)
|
||
s.wechat_hint = "已扫码,请在手机上确认"
|
||
elseif errcode == "403" then
|
||
s.state = "failed"
|
||
s.message = "用户取消了授权"
|
||
elseif errcode == "402" then
|
||
s.state = "failed"
|
||
s.message = "二维码已失效,请重新发起登录"
|
||
end
|
||
-- 408 与未知值一律继续等(保守:绝不误判成功)
|
||
end
|
||
end
|
||
elseif s.flow == "init-poll" and s.state == "pending" and s.init_flow_id then
|
||
-- 轮询 poll/{flow_id}:判据照参考 —— 4xx(除 408/429)才是终态失败,
|
||
-- 5xx 与网络错误继续重试;code!=0 或 data 缺失 → pending。
|
||
local plat = platformById(s.platform)
|
||
local ip = plat and plat.initpoll
|
||
if ip then
|
||
local url = ip.poll_url:gsub("{flow_id}", s.init_flow_id)
|
||
local resp, err = __llmsproxy_http({
|
||
url = url, method = "GET",
|
||
headers = {
|
||
["Accept"] = "application/json",
|
||
["Authorization"] = "Bearer " .. (s.init_secret or ""),
|
||
["User-Agent"] = "ZCode/" .. (ip.app_version or "3.14.3"),
|
||
["HTTP-Referer"] = ip.origin or "",
|
||
["X-ZCode-App-Version"] = ip.app_version or "3.14.3",
|
||
},
|
||
})
|
||
if not err and resp then
|
||
local st = resp.status
|
||
local terminal = st >= 400 and st < 500 and st ~= 408 and st ~= 429
|
||
if terminal then
|
||
s.state = "failed"
|
||
s.message = "轮询被拒(HTTP " .. tostring(st) .. ")"
|
||
else
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if ok and type(rec) == "table" and rec.code == 0 and type(rec.data) == "table" then
|
||
local d = rec.data
|
||
local status = d.status
|
||
if status == "failed" then
|
||
s.state = "failed"
|
||
s.message = "用户拒绝了授权或授权失败"
|
||
elseif status == "ready" then
|
||
-- token(zcode JWT)与 user.user_id 必需;第三方 token 取
|
||
-- bigmodel/zai 二者之一(参考按 provider 取对应分支)。
|
||
local jwt = d.token
|
||
local user = d.user or {}
|
||
local pick = function(blk)
|
||
if type(blk) ~= "table" then return nil, nil end
|
||
return blk.access_token or blk.accessToken, blk.refresh_token or blk.refreshToken
|
||
end
|
||
local bmAt, bmRt = pick(d.bigmodel)
|
||
local zaiAt, zaiRt = pick(d.zai)
|
||
local thirdAt = bmAt or zaiAt
|
||
if type(jwt) ~= "string" or jwt == "" then
|
||
s.state = "failed"
|
||
s.message = "授权响应缺少 zcode token"
|
||
else
|
||
local cred = json.encode({
|
||
access_token = jwt,
|
||
zcode_jwt = jwt,
|
||
-- 第三方 token 仅作备用身份,不参与推理(参考明确)
|
||
bigmodel_access_token = bmAt,
|
||
zai_access_token = zaiAt,
|
||
refresh_token = bmRt or zaiRt,
|
||
user_id = user.user_id or user.id,
|
||
nickname = user.name or user.email,
|
||
token_type = "Bearer",
|
||
account_type = "personal",
|
||
})
|
||
local acct = user.name or user.email or user.user_id or "zcode"
|
||
local res = plugin.complete_login({ platform = s.platform, credential = cred,
|
||
account = acct, account_id = s.account_id })
|
||
if res.json and res.json.ok then
|
||
s.state = "completed"
|
||
s.account = acct
|
||
else
|
||
s.state = "failed"
|
||
s.message = (res.json and res.json.error) or "接入源失败"
|
||
end
|
||
end
|
||
end
|
||
-- status == "pending" 或其它未知值:继续等(不误报)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
elseif s.flow == "state-poll" and s.state == "pending" and s.state_token then
|
||
-- Buddy(CodeBuddy):state 轮询。状态里的 state_token 是 auth/state 下发的。
|
||
local plat = platformById(s.platform)
|
||
local sp = plat and plat.state
|
||
local resp, err = __llmsproxy_http({
|
||
url = (sp.token_url or "") .. "?state=" .. tostring(s.state_token),
|
||
method = "GET",
|
||
headers = { ["Accept"] = "application/json", ["X-No-Authorization"] = "true" },
|
||
})
|
||
-- 网络错误:继续轮询不中断(对齐参考 loopGetToken 的 catch 继续)
|
||
if not err and resp then
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if ok and type(rec) == "table" then
|
||
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 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 " .. 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
|
||
accountData = arec.data
|
||
end
|
||
end
|
||
-- ① 组合完整凭据 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
|
||
else
|
||
s.state = "failed"
|
||
s.message = (res.json and res.json.error) or "接入源失败"
|
||
end
|
||
elseif code == sp.code_not_ready then
|
||
-- 11217 = 未就绪,继续轮询(对齐参考:业务码 11217 继续等)
|
||
elseif resp.status == 200 and data == nil then
|
||
-- data 为 null,继续轮询(参考明确此分支)
|
||
else
|
||
s.state = "failed"
|
||
s.message = "auth/token HTTP " .. tostring(resp.status) .. " code=" .. tostring(code)
|
||
end
|
||
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) 由页面/回调调用:登录成功,存凭据并接入源。
|
||
--
|
||
-- 凭据只进保险库(0600、跨插件不可读),不进 state:state 会被
|
||
-- PUT /api/plugins/channels/state 整体替换,也会出现在 WebUI 里。
|
||
function plugin.complete_login(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 token = args and args.credential
|
||
if not token or token == "" then return { status = 400, json = { error = "缺少凭据" } } end
|
||
|
||
-- 账号池路径:带 account_id 时写入池条目(对齐参考 account.create 的
|
||
-- 占位账号语义 —— 先有账号行,凭据随后填入)。
|
||
local aid = args.account_id
|
||
if aid and aid ~= "" then
|
||
local acct = poolGet(plat.id, aid)
|
||
if not acct then return { status = 400, json = { error = "账号不存在: " .. tostring(aid) } } end
|
||
local ref = acct.credentialRef
|
||
local _, serr = __llmsproxy_secrets.set(ref, token)
|
||
if serr then return { status = 400, json = { error = serr } } end
|
||
-- 从凭据自动补齐展示/续期字段(调用方漏传也不丢)。
|
||
-- 对齐参考 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
|
||
if sess.platform == plat.id and sess.state == "pending"
|
||
and (sess.account_id == aid or sess.pool_account_id == aid) then
|
||
sess.state = "completed"
|
||
end
|
||
end
|
||
logEvent("账号凭据已入池 " .. plat.id .. "/" .. aid)
|
||
syncAccountSource(plat.id, aid)
|
||
return { status = 200, json = { ok = true, account_id = aid, source = accountSourceName(plat.id, aid) } }
|
||
end
|
||
|
||
-- 兼容旧路径:单账号模式(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 "已登录",
|
||
added_at = os.time and os.time() or 0,
|
||
enabled = true,
|
||
}
|
||
-- 终结该平台未决会话
|
||
for id, sess in pairs(sessions()) do
|
||
if sess.platform == plat.id and sess.state == "pending" then sess.state = "completed" end
|
||
end
|
||
logEvent("已登录 " .. plat.name)
|
||
|
||
-- 接入为上游源 —— 这是本插件存在的意义
|
||
local r, aerr = __llmsproxy_kernel.call("add_source", {
|
||
source = {
|
||
name = "channels-" .. plat.id,
|
||
base_url = plat.base_url,
|
||
adapter = "openai",
|
||
api_key = legacyTok,
|
||
-- 同 syncAccountSource:串行 + 节流(见那里的长注释)
|
||
max_concurrent = 1,
|
||
rpm = plat.rpm or 20,
|
||
models = (function()
|
||
local ms = {}
|
||
local eff = effectiveModels(plat.id)
|
||
for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end
|
||
return ms
|
||
end)(),
|
||
},
|
||
})
|
||
if aerr then
|
||
-- 凭据已存下,但接入源失败。如实分开报告:把两者混成一个 ok=false 会让
|
||
-- 调用方以为登录失败从而重复登录。
|
||
logEvent("接入源失败 " .. plat.id .. ": " .. aerr)
|
||
return { status = 200, json = { ok = true, source_error = aerr } }
|
||
end
|
||
logEvent("已接入源 " .. r.name)
|
||
return { status = 200, json = { ok = true, source = r.name } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 额度领取
|
||
-- ============================================================================
|
||
|
||
-- plugin.claim_daily(platform_id) 领每日额度。设计为**可被定时任务调用**。
|
||
--
|
||
-- 只返回结果,不改 state 之外的东西:真正的额度体现在平台的响应里
|
||
-- (已用/剩余),本插件记录以便面板显示。
|
||
function plugin.claim_daily(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
|
||
-- 有 claim_fn 的平台即使没声明 checkin 端点也能领(如 codearts 的 4 步流程、
|
||
-- 有道的 3 步活动领取 —— 它们自己发多个请求,不走 checkin 表驱动)。
|
||
-- 此前这里硬要求 plat.checkin,导致这两个平台的钩子**永远走不到**。
|
||
if (not plat.checkin or not plat.checkin.url) and not plat.claim_fn then
|
||
return { status = 400, json = { error = plat.name .. " 不支持每日签到(平台未声明 checkin)" } }
|
||
end
|
||
-- 账号池路径:claim 某个池账号;否则旧路径单凭据。
|
||
-- credArgs 要一并交给 claim_fn:Trae 的签到头要用凭据里的
|
||
-- uid/access_token 派生设备身份(仅凭裸 token 拿不到这些字段)。
|
||
local tok, acctEntry, credArgs
|
||
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
|
||
local _, _, rec = credentialOf(acctEntry.credentialRef)
|
||
tok = credentialOf(acctEntry.credentialRef)
|
||
credArgs = { account_id = args.account_id, credential = rec }
|
||
else
|
||
local _, _, rec = credentialOf("channels:" .. plat.id)
|
||
tok = credentialOf("channels:" .. plat.id)
|
||
credArgs = { credential = rec }
|
||
end
|
||
if not tok then return { status = 400, json = { error = "未登录" } } end
|
||
|
||
-- 平台可自定义领取请求形状;默认 Bearer + POST {}。
|
||
-- 参考实现的特殊形状(SDK-HMAC-SHA256 签名等)由该平台的 claim 钩子生成
|
||
-- (见 PLATFORMS 的 claim_fn),这里是通用路径。
|
||
local spec = {
|
||
url = plat.checkin and plat.checkin.url or "",
|
||
method = (plat.checkin and plat.checkin.method) or "POST",
|
||
headers = (plat.checkin and plat.checkin.headers)
|
||
or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
|
||
body = (plat.checkin and plat.checkin.body) or "{}",
|
||
}
|
||
if plat.claim_fn then
|
||
local custom, cerr = plat.claim_fn(plat, tok, credArgs)
|
||
if cerr then return { status = 400, json = { error = cerr } } end
|
||
if custom then spec = custom end
|
||
end
|
||
|
||
-- done 哨兵:平台自己跑完了多步流程(如 codearts 的 账户→活动→claim→confirm),
|
||
-- 不再由通用路径发送单个请求。
|
||
if type(spec) == "table" and spec.done then
|
||
local acct0 = acctEntry or plugin.state.accounts[plat.id] or {}
|
||
acct0.last_claim = os.time and os.time() or 0
|
||
acct0.last_claim_status = spec.kind == "success" and 200 or 0
|
||
acct0.last_claim_summary = spec.message
|
||
if acctEntry then poolAdd(plat.id, acctEntry) else plugin.state.accounts[plat.id] = acct0 end
|
||
logEvent("领取结束 " .. plat.id .. ": " .. tostring(spec.kind))
|
||
-- covers_today:本次结果是否**覆盖"今天"这一轮**(缺省 true)。
|
||
-- 缺省视为 true 是刻意的:绝大多数渠道是"每日签到"语义,一行都不用改。
|
||
-- 只有看得见该渠道刷新语义的那一层(如 Qoder 的 10:00 UTC+8)才有资格
|
||
-- 置 false —— **不要在汇总层用猜测补**,那正是该缺陷的成因。
|
||
local covers = spec.covers_today
|
||
if covers == nil then covers = true end
|
||
local outcome = { kind = spec.kind, message = spec.message, covers_today = covers }
|
||
-- action_required 必须透传:它是给 UI 的**显式信号**("这一步需要用户去
|
||
-- 官方客户端操作"),不能靠文案猜。参考 qoder-credits 明确定义了这个字段。
|
||
if spec.action_required then outcome.action_required = true end
|
||
return { status = 200, json = { ok = spec.kind == "success", outcome = outcome } }
|
||
end
|
||
|
||
local resp, err = __llmsproxy_http(spec)
|
||
if err then
|
||
logEvent("领取失败 " .. plat.id .. ": " .. err)
|
||
return { status = 400, json = { error = err } }
|
||
end
|
||
|
||
local acct
|
||
if acctEntry then
|
||
acct = acctEntry
|
||
else
|
||
acct = plugin.state.accounts[plat.id] or {}
|
||
end
|
||
acct.last_claim = os.time and os.time() or 0
|
||
acct.last_claim_status = resp.status
|
||
-- 平台可提供解析器把响应转成可读摘要(余额/获得额度)
|
||
if plat.claim_result_fn and resp.status == 200 then
|
||
local ok, summary = pcall(plat.claim_result_fn, resp.body)
|
||
if ok and summary then acct.last_claim_summary = summary end
|
||
end
|
||
if acctEntry then
|
||
poolAdd(plat.id, acctEntry)
|
||
else
|
||
plugin.state.accounts[plat.id] = acct
|
||
end
|
||
logEvent("领取返回 " .. tostring(resp.status))
|
||
return { status = 200, json = { ok = true, upstream_status = resp.status, body = resp.body, summary = acct.last_claim_summary } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 定时任务:自动领取
|
||
-- ============================================================================
|
||
--
|
||
-- 声明了 schedule 的插件会得到内核 ticker 驱动(无浏览器也执行),
|
||
-- 且任务跑在本插件自己的 VM 上 —— 所以任务里读 state/保险库与钩子看到的是同一份。
|
||
-- 周期任务表。三项,各自理由见下(参考 index.ts / models.ts 的调度对照):
|
||
--
|
||
-- auto_claim 每天 08:00 领取额度(参考 auto-checkin 的日频语义)
|
||
-- refresh_models 每 2 小时刷新远端模型目录(参考 MODEL_REFRESH_INTERVAL_MS)
|
||
-- refresh_creds 每 30 分钟检查凭据续期(参考 REFRESH_INTERVAL_MS)
|
||
--
|
||
-- ⚠️ 「启动即跑一轮」由调度器的 first_due_now 标记保证(见 plugin_runtime.go
|
||
-- 的 firstDue):参考点名过这个缺陷 —— 短寿命凭据(cline 1 小时、codearts
|
||
-- 约 2 小时、raccoon 3 小时)在宿主停机期间早就过期,只用 interval 的话
|
||
-- 重启后要等满一个周期才续期,期间一直显示「已过期」。
|
||
plugin.schedule = {
|
||
-- ⚠️ 每 2 小时而非每天一次:活动刷新时刻**各平台不同**(Qoder 是每日
|
||
-- 10:00 UTC+8,参考实测)。一天只跑一次的话,刷新时刻晚于那次执行、
|
||
-- 或当天那次恰好没覆盖到的平台,就**整天漏领**。
|
||
-- 已覆盖今天的平台会在 claimCovered 里跳过(不会重复打上游)。
|
||
{ fn = "auto_claim", every = 7200, first_due_now = true, jitter = 300 },
|
||
{ fn = "refresh_models", every = 7200, first_due_now = true, jitter = 120 },
|
||
{ fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 },
|
||
}
|
||
|
||
-- ============================================================================
|
||
-- ============================================================================
|
||
-- ============================================================================
|
||
-- ============================================================================
|
||
-- ============================================================================
|
||
-- 模型能力元数据(照参考 opencode-capability.ts)
|
||
-- ============================================================================
|
||
-- 数据源:models.dev 的能力表。`/zen/v1/models` **只返回 4 个字段**
|
||
-- (id/object/created/owned_by),**不含任何能力信息** —— 能力在 models.dev。
|
||
--
|
||
-- 三个字段的用途(照参考):
|
||
-- modalities.input → 能力主源(['text'] / ['text','image'])
|
||
-- cost.input/output → **免费判定的权威来源**(两者同时为 0 = 免费)
|
||
-- limit.context/output → 上下文窗口与输出上限
|
||
--
|
||
-- ⚠️⚠️ **读取必须非阻塞**(参考的真机事故 2026-10-02):
|
||
-- 那个接口实测 **5.05 MB / 首字节 720ms / 下载 1.4s**。参考最初在模型列表渲染
|
||
-- 路径上直接 await 它,于是模型选择器要等 1.4s+ 才能出列表 ——
|
||
-- 表现是「点开是一片空白」,用户报障「选择模型还是点击没弹出列表」。
|
||
--
|
||
-- ⇒ 三条硬约定(改动前先读):
|
||
-- 1. **渲染路径上只能读同步缓存,永不等待网络**;
|
||
-- 2. 磁盘缓存冷启动直接命中;
|
||
-- 3. 拉取在**后台**进行(定时任务里),完成后下次读取自然生效。
|
||
--
|
||
-- ⚠️ 我们额外的一条:本插件是**服务端**进程、没有"渲染路径",
|
||
-- 但 5MB 的下载仍不能放在请求路径上 —— 故挂在 refresh_models 定时任务里,
|
||
-- 与模型目录刷新同一节拍。请求路径只读缓存。
|
||
local MODELS_DEV_URL = "https://models.dev/api.json"
|
||
local MODELS_DEV_TTL = 6 * 3600 -- 6 小时(照参考的 TTL 量级)
|
||
--
|
||
-- ⚠️ **已知取不到,属设计约束而非故障**:该端点实测 **5.64 MB**,而内核的
|
||
-- `__llmsproxy_http` 有 **4 MiB 响应上限**(`httpMaxResponse`,刻意的堆保护 ——
|
||
-- 不能让一个插件把整份数据拉进网关堆)。参考提到的"轻量分片端点"
|
||
-- (`/api/v1/opencode.json`)实测返回的是 **HTML 首页**(2.0 MB),不是 JSON。
|
||
--
|
||
-- ⇒ 能力元数据定位为**可选增强**:
|
||
-- * 拉得到 → isFree/上下文/模态用权威值;
|
||
-- * 拉不到 → 如实退回兜底表,且失败信息说明是"响应超限"(让运维知道
|
||
-- 这是上限约束,不是网络坏了)。
|
||
-- **不**为了它去放宽内核上限(那会让所有插件都能拉 5MB+ 进堆)。
|
||
|
||
-- normalizeCapability(id, raw):照参考 normalizeEntry 的字段口径。
|
||
--
|
||
-- ⚠️ **video / audio / pdf 一律降级为 text**:下游只认 text/image 两值,
|
||
-- 声明不存在的模态会投影出我们并不发送的内容。
|
||
local function normalizeCapability(id, raw)
|
||
if type(raw) ~= "table" then return nil end
|
||
local declared = {}
|
||
local mods = raw.modalities
|
||
if type(mods) == "table" and type(mods.input) == "table" then
|
||
for _, m in ipairs(mods.input) do declared[#declared+1] = tostring(m) end
|
||
end
|
||
local modalities = { "text" }
|
||
for _, m in ipairs(declared) do
|
||
if m == "image" then modalities = { "text", "image" } break end
|
||
end
|
||
local lim = type(raw.limit) == "table" and raw.limit or {}
|
||
local cost = type(raw.cost) == "table" and raw.cost or {}
|
||
local ci, co = tonumber(cost.input), tonumber(cost.output)
|
||
local efforts = {}
|
||
if type(raw.efforts) == "table" then
|
||
for _, e in ipairs(raw.efforts) do efforts[#efforts+1] = tostring(e) end
|
||
end
|
||
return {
|
||
id = id,
|
||
name = type(raw.name) == "string" and raw.name ~= "" and raw.name or id,
|
||
modalities = modalities,
|
||
contextWindow = tonumber(lim.context) or 0,
|
||
maxOutputTokens = tonumber(lim.output) or 0,
|
||
-- ⚠️ `reasoning` 与 `efforts` 必须一致:只有真拿到档位才报"支持思考"。
|
||
-- `reasoning=true` 但 `efforts` 为空会声明一个空选择器(UI 出现但无内容)。
|
||
reasoning = (raw.reasoning == true) and #efforts > 0,
|
||
efforts = efforts,
|
||
toolCall = raw.tool_call ~= false,
|
||
isFree = (ci ~= nil and ci == 0 and co ~= nil and co == 0),
|
||
}
|
||
end
|
||
|
||
-- capabilityOf(provider, modelId):**只读缓存**(绝不触发网络)。
|
||
-- 缓存未就绪时返回 nil —— 调用方据此退回兜底表,而不是等一个 5MB 的下载。
|
||
local function capabilityOf(provider, modelId)
|
||
local table_ = plugin.state.capabilities and plugin.state.capabilities[provider]
|
||
if type(table_) ~= "table" then return nil end
|
||
return table_[modelId]
|
||
end
|
||
|
||
-- refreshCapabilities():**后台**拉 models.dev 并落缓存(定时任务调用)。
|
||
-- 失败**不清缓存**(继续用上次的好数据)。
|
||
local function refreshCapabilities()
|
||
local cached = plugin.state.capabilitiesMeta
|
||
local now = os.time and os.time() or 0
|
||
if cached and cached.at and cached.at <= now and (now - cached.at) < MODELS_DEV_TTL then
|
||
return false, "未到刷新时间"
|
||
end
|
||
local resp, err = __llmsproxy_http({
|
||
url = MODELS_DEV_URL, method = "GET", timeout_ms = 30000,
|
||
headers = { ["Accept"] = "application/json" },
|
||
})
|
||
if err or not resp or resp.status ~= 200 then
|
||
local es = tostring(err or (resp and resp.status))
|
||
-- 把"响应超限"单独说清楚:那是**内核的刻意的堆保护**(4 MiB),不是网络故障。
|
||
-- 否则运维会去查网络、查白名单,而真正的原因是我们主动不放开这个上限。
|
||
if es:find("byte cap") then
|
||
return false, "能力元数据源 5.6MB 超过内核 4MiB 响应上限(刻意的堆保护,"
|
||
.. "不为此放宽)—— 能力增强不可用,已退回兜底表"
|
||
end
|
||
return false, "拉取失败: " .. es
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then return false, "响应无法解析" end
|
||
-- 按 provider 分表:每个平台从自己的键下取(不同平台可共用同一数据源)
|
||
local out = {}
|
||
local n = 0
|
||
for _, plat in ipairs(allPlatforms()) do
|
||
local key = plat.capability_key or plat.id
|
||
local src = rec[key]
|
||
if type(src) == "table" then
|
||
local models = type(src.models) == "table" and src.models or src
|
||
local tbl = {}
|
||
for id, raw in pairs(models) do
|
||
local cap = normalizeCapability(tostring(id), raw)
|
||
if cap then tbl[tostring(id)] = cap n = n + 1 end
|
||
end
|
||
out[plat.id] = tbl
|
||
end
|
||
end
|
||
plugin.state.capabilities = out
|
||
plugin.state.capabilitiesMeta = { at = now, count = n }
|
||
markDirty()
|
||
return true, n
|
||
end
|
||
|
||
-- 通用件:TTL 缓存 + 串行队列(照参考 ttl-cache.ts / serial-queue.ts)
|
||
-- ============================================================================
|
||
-- 为什么抽出来:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"逻辑
|
||
-- (远端目录闸门、余额 60s 缓存、登录轮询……)。手写版本各写各的,
|
||
-- 边界条件(墙钟回拨、在飞去重、失败不缓存)容易漏 —— 集中一处才好审。
|
||
--
|
||
-- ⚠️ Lua 无协程,做不了"共享同一个 Promise"。这里的"在飞去重"用**时间戳**
|
||
-- 表达:记录 in_flight_at,短窗口内的重复请求直接跳过。等价效果,机制不同。
|
||
-- 这是刻意的取舍:能在单 VM 单锁里工作,且不引入任何 async 语义。
|
||
|
||
-- ttlCacheGet(namespace, key, ttl, loader):
|
||
-- * 命中且未过期 → 直接返回缓存值
|
||
-- * 在飞(短窗口内有同 key 的加载)→ 返回 nil(调用方自行降级,不要重复打)
|
||
-- * 否则调 loader(),把非 nil 结果缓存 ttl 秒
|
||
--
|
||
-- ⚠️ **失败不缓存**:loader 返回 nil 时不写缓存(让下次能重试),
|
||
-- 但会记 in_flight_at 防止**同一瞬间**的重复打。这与"失败进冷却"是两件事 ——
|
||
-- 冷却由调用方决定(如目录闸门的 30s),本助手只负责 TTL 与去重。
|
||
--
|
||
-- ⚠️ 墙钟回拨防护(照参考):`now < cached_at` 时视为已过期,而不是
|
||
-- "还没到"。回拨一天会让 `now - cached_at` 变成巨大负数 ⇒ 缓存永远命中 ⇒
|
||
-- 上游改了也自愈不了。
|
||
local function ttlCacheGet(namespace, key, ttl, loader)
|
||
plugin.state.ttlCache = plugin.state.ttlCache or {}
|
||
local ns = plugin.state.ttlCache[namespace]
|
||
if not ns then ns = {} plugin.state.ttlCache[namespace] = ns end
|
||
local now = os.time and os.time() or 0
|
||
local e = ns[key]
|
||
if type(e) == "table" then
|
||
-- 墙钟回拨:cached_at 在未来 ⇒ 视为已过期(否则永久命中)
|
||
if e.at <= now and (now - e.at) < ttl then
|
||
return e.value
|
||
end
|
||
ns[key] = nil
|
||
end
|
||
-- 在飞去重:同一瞬间的重复请求不重复打(Lua 无协程,用时间戳近似)
|
||
local inflight = plugin.state.ttlInflight or {}
|
||
plugin.state.ttlInflight = inflight
|
||
local ik = namespace .. ":" .. tostring(key)
|
||
local last = inflight[ik]
|
||
if last and last <= now and (now - last) < 1 then
|
||
return nil
|
||
end
|
||
inflight[ik] = now
|
||
local ok, value = pcall(loader)
|
||
if not ok or value == nil then return nil end
|
||
ns[key] = { at = now, value = value }
|
||
return value
|
||
end
|
||
|
||
-- ttlCacheInvalidate(namespace, key?):失效缓存(key 省略则整个 namespace)。
|
||
--
|
||
-- ⚠️ **必须一并清掉在飞标记**。否则:清缓存 → 同一秒内重载 → 被"在飞去重"
|
||
-- 挡住 → 返回 nil。调用方看到的是"刚清了缓存却拿不到值",而数据其实好好的
|
||
-- (实测踩到:改窗口后立即查余额得到 nil)。失效的语义是"忘掉这次加载",
|
||
-- 在飞标记也是这次加载的一部分。
|
||
local function ttlCacheInvalidate(namespace, key)
|
||
plugin.state.ttlCache = plugin.state.ttlCache or {}
|
||
if key == nil then
|
||
plugin.state.ttlCache[namespace] = {}
|
||
elseif plugin.state.ttlCache[namespace] then
|
||
plugin.state.ttlCache[namespace][key] = nil
|
||
end
|
||
local inflight = plugin.state.ttlInflight
|
||
if inflight then
|
||
if key == nil then
|
||
local prefix = namespace .. ":"
|
||
for k in pairs(inflight) do
|
||
if k:sub(1, #prefix) == prefix then inflight[k] = nil end
|
||
end
|
||
else
|
||
inflight[namespace .. ":" .. tostring(key)] = nil
|
||
end
|
||
end
|
||
end
|
||
|
||
-- serialQueue(namespace):取一个串行队列的锁状态。
|
||
--
|
||
-- 语义(照参考 serial-queue):同一时刻只有一个任务在跑,其余**排队**。
|
||
-- Lua 无协程 ⇒ 无法"排队等待",只能做到**互斥 + 让路**:抢不到就立刻返回 nil,
|
||
-- 由调用方跳过本次(对定时任务与轮询是正确语义:下一次会重试)。
|
||
--
|
||
-- ⚠️ 必须带**过期时间**:持有者崩了/超时了,锁必须能自解,否则该 namespace
|
||
-- 永久卡死(参考用 promise finally 兑现,我们只能用 TTL)。
|
||
local function serialTryAcquire(namespace, ttl)
|
||
plugin.state.serialLock = plugin.state.serialLock or {}
|
||
local now = os.time and os.time() or 0
|
||
local held = plugin.state.serialLock[namespace]
|
||
if held and held <= now and (now - held) < (ttl or 120) then
|
||
return false
|
||
end
|
||
plugin.state.serialLock[namespace] = now
|
||
return true
|
||
end
|
||
|
||
local function serialRelease(namespace)
|
||
if plugin.state.serialLock then plugin.state.serialLock[namespace] = nil end
|
||
end
|
||
|
||
-- 余额分档选号(照参考 buddy-balance-rank.ts / loomy-balance-rank.ts)
|
||
-- ============================================================================
|
||
-- 解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」),
|
||
-- 而**服务端扣哪个包不由我们决定**。插件能做的是决定**用哪个号**:
|
||
-- * 优先用「还有 15 天内到期积分」的号(那部分再不用就作废)
|
||
-- * 只剩永久积分的号排最后
|
||
-- * **锁定永久积分**时,只剩永久积分的号**等同于不可用**
|
||
--
|
||
-- 为什么是 15 天(实测两站差别极大,参考有对照表):
|
||
-- WorkBuddy 5 个号:Bonus Pack 250(8~9 天)+ Free Plan 100(3008 天)
|
||
-- → 锁定后有 4 个号仍可用,1 个(Bonus Pack 已耗尽)不可用,正是期望效果
|
||
-- CodeBuddy 中国版 1 个号:裂变包共 10064.6,**距到期最少 17 天**
|
||
-- → 17 > 15 ⇒ 全算永久 ⇒ 锁定后立刻「无可用账号」
|
||
-- 中国版按 30 天发包,故"距到期"天然落在 17~30 天 —— 判据本身没错,这类池
|
||
-- 想用锁定就得把窗口放宽(如设 31)。窗口**只能由用户显式改**,默认 15。
|
||
local EXPIRING_WINDOW_DAYS_DEFAULT = 15
|
||
|
||
expiringWindowDays = function()
|
||
-- 优先级:settings(面板可改)→ env(部署可改)→ 默认 15。
|
||
--
|
||
-- ⚠️ **不能**写成 `tonumber(raw) or 默认`:0 是合法值(表示"没有临时积分",
|
||
-- 即全部算永久)。参考在这一点上专门写了注释。
|
||
local cfg = plugin.state.settings and plugin.state.settings.expiring_window_days
|
||
if cfg ~= nil then
|
||
local n = tonumber(cfg)
|
||
if n and n >= 0 then return n end
|
||
end
|
||
-- ⚠️ 本 VM 的 os 表**没有** setenv(实测:只有 clock/date/difftime/execute/
|
||
-- exit/getenv/remove/rename/setlocale/time/tmpname),所以面板改值必须走
|
||
-- settings 而不是"设环境变量"。getenv 只用于读取部署期注入的值。
|
||
local raw = os.getenv and os.getenv("DSH_BUDDY_EXPIRING_WINDOW_DAYS")
|
||
if type(raw) == "string" and raw ~= "" then
|
||
local n = tonumber(raw)
|
||
if n and n >= 0 then return n end
|
||
end
|
||
return EXPIRING_WINDOW_DAYS_DEFAULT
|
||
end
|
||
|
||
-- balanceTier(expiring, permanent, allow_permanent):
|
||
-- "expiring" 有 15 天内到期的积分 → 最优先(不用就作废)
|
||
-- "permanent" 只剩永久积分 → 次优先;锁定永久积分时视为不可用
|
||
-- "none" 都没有 → 不可用
|
||
local function balanceTier(expiring, permanent, allow_permanent)
|
||
local function pos(v)
|
||
local n = tonumber(v)
|
||
if n and n > 0 then return n end
|
||
return 0
|
||
end
|
||
if pos(expiring) > 0 then return "expiring" end
|
||
if allow_permanent == false then return "none" end
|
||
if pos(permanent) > 0 then return "permanent" end
|
||
return "none"
|
||
end
|
||
|
||
local function tierUsable(tier)
|
||
return tier ~= "none"
|
||
end
|
||
|
||
-- 余额缓存(TTL 60 秒,照参考)。**只缓存原料,绝不冻结分类** ——
|
||
-- 「临时/永久」是**当前时刻的函数**(到期时间 - now 与窗口比较),而宿主长期
|
||
-- 开着、时间只向前流:一笔距到期 15.02 天的余额,在用户什么都不做的两分钟里就
|
||
-- 越过了线。若把分类也缓存,选号会继续按"永久"处理一笔马上要作废的积分
|
||
-- (锁定时更糟:本该可用的号被判成不可用)。故命中缓存也**每次重新分桶**。
|
||
local BALANCE_CACHE_TTL = 60
|
||
|
||
-- (余额缓存改用通用 TTL 缓存后,这个助手不再需要)
|
||
|
||
-- accountBalanceRaw(provider, accountId):取该账号的余额原料(带 TTL 缓存)。
|
||
-- 失败**不抛错**(照参考:查询失败归入最后一档),返回 nil 让调用方降级。
|
||
local function accountBalanceRaw(provider, accountId)
|
||
-- 用通用 TTL 缓存(照参考 ttl-cache):命中即返回、在飞去重、失败不缓存。
|
||
-- ⚠️ 缓存的是**原料**(包列表明细),分桶每次都现算 —— 见本节的注释。
|
||
local plat = platformById(provider)
|
||
local acct = poolGet(provider, accountId)
|
||
if not plat or not acct or not plat.balance then return nil end
|
||
local tok = credentialOf(acct.credentialRef)
|
||
if not tok or tok == "" then return nil end
|
||
local plate = plat
|
||
local act = acct
|
||
return ttlCacheGet("balance", provider .. ":" .. accountId, BALANCE_CACHE_TTL, function()
|
||
-- 复用既有的余额抓取(credits_balances 用的同一个助手,避免两处口径漂移)
|
||
local rec, err = fetchAccountJSON(plate.balance, tok,
|
||
credentialFull(act.credentialRef), plate)
|
||
if err or type(rec) ~= "table" then return nil end
|
||
if plate.balance.tier_fn then
|
||
-- 平台声明了分档提取器(buddy/loomy 的包列表要按到期时间现算)
|
||
local okf, t = pcall(plate.balance.tier_fn, rec)
|
||
if okf and type(t) == "table" then return t end
|
||
return nil
|
||
end
|
||
if plate.balance.total_fn then
|
||
-- 只有总量的平台:全部算"永久"(无到期信息 ⇒ 不会凭空造出临时档)
|
||
local okf, total = pcall(plate.balance.total_fn, rec)
|
||
if okf and type(total) == "number" then
|
||
return { permanent_balance = total, expiring_balance = 0 }
|
||
end
|
||
end
|
||
return nil
|
||
end)
|
||
end
|
||
|
||
-- rankAccounts(provider):按"积分何时作废"优先排序该平台的账号。
|
||
-- 返回 {usable={...}, all={...}}。查询失败的账号归入最后一档(照参考)。
|
||
local function rankAccounts(provider)
|
||
local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits
|
||
local allowPermanent = not lockPermanent
|
||
local expiring, permanent, none = {}, {}, {}
|
||
for _, a in ipairs(poolList(provider)) do
|
||
if a.enabled ~= false then
|
||
local raw = accountBalanceRaw(provider, a.id)
|
||
local tier
|
||
if raw == nil then
|
||
tier = "none" -- 查不到 → 最后(不编造)
|
||
else
|
||
tier = balanceTier(raw.expiring_balance or raw.expiringBalance,
|
||
raw.permanent_balance or raw.permanentBalance, allowPermanent)
|
||
end
|
||
if tier == "expiring" then expiring[#expiring+1] = a
|
||
elseif tier == "permanent" then permanent[#permanent+1] = a
|
||
else none[#none+1] = a end
|
||
end
|
||
end
|
||
local all = {}
|
||
for _, a in ipairs(expiring) do all[#all+1] = a end
|
||
for _, a in ipairs(permanent) do all[#all+1] = a end
|
||
for _, a in ipairs(none) do all[#all+1] = a end
|
||
-- 可用 = 临时档(永远)+ 永久档(仅未锁定时)
|
||
local usable = {}
|
||
for _, a in ipairs(expiring) do usable[#usable+1] = a end
|
||
if allowPermanent then
|
||
for _, a in ipairs(permanent) do usable[#usable+1] = a end
|
||
end
|
||
return { usable = usable, all = all, expiring = #expiring,
|
||
permanent = #permanent, none = #none,
|
||
locked = lockPermanent and true or false }
|
||
end
|
||
|
||
-- 已失效模型(照参考 dead-model-store.ts)
|
||
-- ============================================================================
|
||
-- 解决的真实报障:用户选中某平台的兜底模型,每轮都 404 ——
|
||
-- 该模型**已被上游下架**,但模型列表里仍显示、还标着"免费"。
|
||
-- 根因:各平台的兜底表是**编译期快照**,上游下架时不会自己跟着变。
|
||
--
|
||
-- ⚠️ **不能用「远端目录里没有 ⇒ 已下架」来剔除**(参考文件头实测):
|
||
-- 那个判据只在"远端目录完整权威"的平台上成立。buddy 那类平台的远端目录
|
||
-- **已知残缺**(CLI token 只拿得到 13 个内部别名,IDE 有 20 个),
|
||
-- 按目录比对会把"你有权用、只是 token 看不到"的模型删掉 —— 参考作者已为此
|
||
-- 踩过一次。qoder 更是**没有**可比对的远端目录。
|
||
-- ⇒ 唯一跨平台安全的判据是**阳性证据**:这个模型**真的**请求失败并返回
|
||
-- 「模型不存在」时,才记为失效。**绝不从残缺目录反推。**
|
||
|
||
-- 判定为"模型已失效"的正向模式(照参考 GONE_PATTERNS 逐条)。
|
||
--
|
||
-- ⚠️ `model_not_found` 必须要求它**独占一句话成分**:只加词边界不够 ——
|
||
-- 它在遥测/日志文案里常作为**字段名**出现,后面紧跟别的内容:
|
||
-- error_class=model_not_found, retry allowed → 初版误判下架
|
||
-- model_not_found_reason=upstream → 初版误判下架
|
||
-- 误判会把**可用模型藏 30 天**、用户须手动恢复。故后面只允许结束、句末标点
|
||
-- 或收尾括号/引号,**不含逗号**。
|
||
local DEAD_GONE_PATTERNS = {
|
||
"model%s+not%s+found",
|
||
"unknown%s+model",
|
||
"no%s+such%s+model",
|
||
"nonexistent%s+model",
|
||
}
|
||
|
||
-- 反向模式:命中即**不是**下架(照参考 NOT_GONE_PATTERNS)。
|
||
-- 逐条都有理由,不要删:
|
||
-- * rate limit / quota / insufficient → 换时段或加额度就能用
|
||
-- * unauthor/forbidden/invalid token → 认证问题,不是模型不存在
|
||
-- * `expired` **必须带主语**(token/session/credential/key/auth/lease),
|
||
-- 否则会误杀正常的下架文案:`model expired-v3 does not exist`、
|
||
-- `the model lease expired`(租约到期也是下架的一种说法)
|
||
local DEAD_NOT_GONE_PATTERNS = {
|
||
"rate%s*%-?%s*limit",
|
||
"too%s+many%s+requests",
|
||
"quota",
|
||
"insufficient",
|
||
"unauthor", "forbidden", "invalid%s+token", "invalid%s+api%s*key", "invalid%s+credential",
|
||
"token%s+expired", "session%s+expired", "credential%s+expired", "key%s+expired",
|
||
"auth%s+expired", "lease%s+expired",
|
||
"expired%s+token", "expired%s+session", "expired%s+credential", "expired%s+key",
|
||
"expired%s+auth",
|
||
}
|
||
|
||
-- isModelGone(message):把一段错误文本判定为"模型已失效"。
|
||
--
|
||
-- 返回 true 的**唯一**条件是出现阳性证据;拿不准一律 false ——
|
||
-- 误判的代价是把可用模型藏 30 天,漏判的代价只是用户再看到一次 404。
|
||
local function isModelGone(message)
|
||
if type(message) ~= "string" or message == "" then return false end
|
||
local m = message:lower()
|
||
for _, pat in ipairs(DEAD_NOT_GONE_PATTERNS) do
|
||
if m:find(pat) then return false end
|
||
end
|
||
for _, pat in ipairs(DEAD_GONE_PATTERNS) do
|
||
if m:find(pat) then return true end
|
||
end
|
||
-- `model_not_found` 形态:要求后面只剩结束/句末标点/收尾括号引号。
|
||
-- Lua 的 find 无前瞻,故手动定位后检查紧随的字符。
|
||
local from = 1
|
||
while true do
|
||
local a, b = m:find("model[_%-]not[_%-]found", from)
|
||
if not a then break end
|
||
local rest = m:sub(b + 1, b + 1)
|
||
-- 只允许:结束、句末标点、收尾括号/引号。**不含逗号**
|
||
-- ("…not_found, retry allowed" 里逗号后仍是另一个字段 ⇒ 不算阳性)。
|
||
-- 字符类里 `]` 必须放最前或转义;这里直接列字符以免 Lua 解析歧义。
|
||
-- 用**单引号**的 Lua 串,避免字符类里的双引号提前终止字符串。
|
||
if rest == "" or rest:find('[%]%)%}%.!%;"]') then return true end
|
||
from = b + 1
|
||
end
|
||
-- 「id + 紧邻的不存在语义」:只认 found/exist。
|
||
-- ⚠️ 不认 available/supported —— 那是"换套餐/换地域就能恢复",不是下架。
|
||
if m:find("does%s+not%s+exist") or m:find("doesn't%s+exist") then return true end
|
||
return false
|
||
end
|
||
|
||
-- 失效记录 TTL(秒)。照参考默认 30 天;过期自动复活,避免永久隐藏。
|
||
local DEAD_MODEL_TTL_DAYS = 30
|
||
|
||
local function deadModelsOf(provider)
|
||
plugin.state.deadModels = plugin.state.deadModels or {}
|
||
local p = plugin.state.deadModels[provider]
|
||
if not p then p = {} plugin.state.deadModels[provider] = p end
|
||
return p
|
||
end
|
||
|
||
-- recordDeadModel:记录一次**阳性**证据(真正 404 了)。
|
||
-- 返回 true 表示这是新记录(调用方据此决定要不要重同步源)。
|
||
local function recordDeadModel(provider, modelId, reason)
|
||
if not modelId or modelId == "" then return false end
|
||
local p = deadModelsOf(provider)
|
||
if p[modelId] then return false end
|
||
p[modelId] = { at = os.time and os.time() or 0, reason = tostring(reason or ""):sub(1, 120) }
|
||
return true
|
||
end
|
||
|
||
-- deadModelIds(provider):当前仍有效的失效模型 id 集合(已过 TTL 的自动清掉)。
|
||
deadModelIds = function(provider)
|
||
local p = deadModelsOf(provider)
|
||
local now = os.time and os.time() or 0
|
||
local ttl = DEAD_MODEL_TTL_DAYS * 86400
|
||
local out = {}
|
||
for id, meta in pairs(p) do
|
||
local at = type(meta) == "table" and tonumber(meta.at) or 0
|
||
if ttl > 0 and (now - at) > ttl then
|
||
p[id] = nil -- 过期复活(照参考 TTL 语义)
|
||
else
|
||
out[id] = true
|
||
end
|
||
end
|
||
return out
|
||
end
|
||
|
||
-- clearDeadModels(provider, modelId?):清失效记录(不带 id 则清该平台全部)。
|
||
local function clearDeadModels(provider, modelId)
|
||
local p = deadModelsOf(provider)
|
||
if modelId and modelId ~= "" then
|
||
local had = p[modelId] ~= nil
|
||
p[modelId] = nil
|
||
return had and 1 or 0
|
||
end
|
||
local n = 0
|
||
for _ in pairs(p) do n = n + 1 end
|
||
plugin.state.deadModels[provider] = {}
|
||
return n
|
||
end
|
||
|
||
-- 远端目录加载闸门(照参考 remote-catalog-gate.ts)
|
||
-- ============================================================================
|
||
-- 参考明确点名「**不能省**」,理由实测:
|
||
-- 宿主会对**每个** provider 拉目录、再对**每个模型**解析一次,于是「目录没拿到」
|
||
-- 被放大成**一次请求 × 每个模型**。若失败时不落任何标记,下一次还会再拉一遍,
|
||
-- 而这些拉取的超时是 30–60s 量级 —— 未登录/离线时的表现就是「首屏巨长」。
|
||
--
|
||
-- 三道语义(照参考):
|
||
-- ① 命中缓存:有远端目录就直接用(不在本闸门内)
|
||
-- ② 并发去重:同一次加载的多个调用共享同一次
|
||
-- ③ 失败/空结果**冷却**:避免短时间内反复打同一个坏上游
|
||
--
|
||
-- ⚠️ 冷却只影响**拉取时机**,不影响兜底目录:冷却期内照旧回落到静态表,
|
||
-- 界面不会因此少模型。
|
||
--
|
||
-- ⚠️ 本 Lua 无协程(单 VM 单锁),做不了"共享同一个 Promise"。用**时间戳
|
||
-- 闸门**达到等价效果:记录 in_flight_at(防同轮重复)+ retry_at(失败冷却)。
|
||
local REMOTE_CATALOG_COOLDOWN = 30 -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS
|
||
|
||
local function catalogGateOf(provider)
|
||
plugin.state.catalogGate = plugin.state.catalogGate or {}
|
||
local g = plugin.state.catalogGate[provider]
|
||
if not g then g = {} plugin.state.catalogGate[provider] = g end
|
||
return g
|
||
end
|
||
|
||
-- catalogGateCooling(provider):是否处于"上次没拿到目录"的冷却窗口内。
|
||
--
|
||
-- ⚠️ 照参考的墙钟回拨防护:`retry_at` 用墙钟算,系统时间被回拨时
|
||
-- `now < retry_at` 会把冷却**拉长**(回拨一天就是事实上的永久卡死)。
|
||
-- 故只认"剩余量不超过一个冷却周期"的窗口,超长的按已到期处理。
|
||
local function catalogGateCooling(provider, now)
|
||
local g = catalogGateOf(provider)
|
||
if not g.retry_at then return false end
|
||
if now >= g.retry_at then return false end
|
||
if g.retry_at - now > REMOTE_CATALOG_COOLDOWN then return false end
|
||
return true
|
||
end
|
||
|
||
-- catalogGateBegin(provider):尝试进入一次加载。返回 true 表示"本次由你拉"。
|
||
local function catalogGateBegin(provider, now)
|
||
local g = catalogGateOf(provider)
|
||
if catalogGateCooling(provider, now) then return false end
|
||
-- 同一轮内的并发去重:正在飞就不再发起(参考的 inFlight)
|
||
if g.in_flight_at and (now - g.in_flight_at) < 10 then return false end
|
||
g.in_flight_at = now
|
||
return true
|
||
end
|
||
|
||
-- catalogGateEnd(provider, got, now):收尾。拿到目录清冷却,没拿到进冷却。
|
||
local function catalogGateEnd(provider, got, now)
|
||
local g = catalogGateOf(provider)
|
||
g.in_flight_at = nil
|
||
g.retry_at = got and nil or (now + REMOTE_CATALOG_COOLDOWN)
|
||
end
|
||
|
||
-- refresh_models():定时把远端模型目录拉回来并重同步所有源。
|
||
--
|
||
-- 为什么需要它:模型目录写进了**上游源**(源的 models 决定网关能路由哪些
|
||
-- 模型)。不刷新就只能用兜底表 —— 平台上新模型后网关永远看不到,
|
||
-- 表现为"某个新模型 404 / 不可选"。
|
||
function plugin.refresh_models()
|
||
local updated, failed, cooled = 0, {}, 0
|
||
local now = os.time and os.time() or 0
|
||
for _, plat in ipairs(allPlatforms()) do
|
||
if plat.models_remote then
|
||
-- 目录闸门:冷却期内不拉(避免对坏上游反复打;照参考 remote-catalog-gate)
|
||
if not catalogGateBegin(plat.id, now) then
|
||
cooled = cooled + 1
|
||
goto continue
|
||
end
|
||
local ids, err = refreshRemoteModels(plat.id)
|
||
catalogGateEnd(plat.id, ids ~= nil, now)
|
||
if ids then
|
||
updated = updated + 1
|
||
-- 目录变了就重同步该平台所有源(模型清单在源里)
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
syncAccountSource(plat.id, a.id)
|
||
end
|
||
else
|
||
failed[#failed + 1] = plat.id .. ": " .. tostring(err)
|
||
end
|
||
end
|
||
::continue::
|
||
end
|
||
-- 冷却期内的平台不算失败(它们上次已经记过原因,重复记会淹没真正的错误)
|
||
if cooled > 0 then
|
||
logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台")
|
||
end
|
||
-- 顺带刷新模型能力元数据(照参考 opencode-capability)。
|
||
-- ⚠️ 必须在这里(后台定时任务)而不是请求路径 —— 见该节的"非阻塞"约定。
|
||
local cok, cres, cerr = pcall(refreshCapabilities)
|
||
if not cok then
|
||
-- ⚠️ 失败**必须留痕**:此前这里只有 `pcall` 不管返回值,于是"出站未授权"
|
||
-- 这类错误被静默吞掉,表现是"能力元数据永远是空",排查时零线索。
|
||
logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120))
|
||
elseif cres == true then
|
||
logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)")
|
||
elseif cerr then
|
||
logEvent("能力元数据未刷新: " .. tostring(cerr):sub(1, 120))
|
||
end
|
||
if updated > 0 then
|
||
logEvent("模型目录已刷新(" .. updated .. " 个平台)")
|
||
end
|
||
-- 失败留痕但**不清缓存**(refreshRemoteModels 内部已保证):
|
||
-- 拉不到时继续用上次的好数据,绝不能把目录清空。
|
||
if #failed > 0 then
|
||
logEvent("模型刷新失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 200))
|
||
end
|
||
end
|
||
|
||
-- refresh_creds():定时检查并续期凭据。
|
||
--
|
||
-- ⚠️ **不看 enabled**(照参考的既有铁律):停用只影响自动选号,不该让凭据
|
||
-- 停止续期。否则"所有账号都被停用"时续期根本不跑,凭据一路烂到
|
||
-- refresh_token 失效、只能重新登录。
|
||
--
|
||
-- ⚠️ **只对「距过期不足 lead 时间」的账号发请求**(照参考 expiry-sync 的
|
||
-- shouldRefreshNow):每次无脑续期会在启动时打出几十个无谓请求。
|
||
-- 没有过期时间的账号只做本地对账,不发请求。
|
||
function plugin.refresh_creds()
|
||
local lead = 3600 -- 提前 1 小时续(照参考的 lead 语义)
|
||
local now = os.time and os.time() or 0
|
||
local refreshed, skipped, failed = 0, 0, {}
|
||
for _, plat in ipairs(allPlatforms()) do
|
||
if plat.exchange_body or plat.refresh_url then
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
local cred = credentialFull(a.credentialRef)
|
||
if type(cred) == "table" then
|
||
local exp = tonumber(a.expiresAt) or credentialExpiresAtMs(cred)
|
||
local due = exp == nil or exp == 0
|
||
if not due then
|
||
-- expiresAt 单位可能是毫秒;统一成秒再比
|
||
local e = exp > 1e11 and math.floor(exp / 1000) or exp
|
||
due = (e - now) <= lead
|
||
end
|
||
if due then
|
||
local ok, err = plugin.account_refresh({ platform = plat.id, account_id = a.id })
|
||
-- ⚠️ 判据用 `refreshed`,**不是** `ok`:account_refresh 在"平台未
|
||
-- 声明续期方式"时也会回 ok:true(源照旧同步),用 ok 计数就会把
|
||
-- "没续"算成"续了" —— 这正是此前日志说谎的机制。
|
||
if ok and ok.json and ok.json.refreshed then refreshed = refreshed + 1
|
||
else
|
||
skipped = skipped + 1
|
||
local why = ok and ok.json and ok.json.message or err
|
||
if why and why ~= "该平台未声明续期方式" then
|
||
failed[#failed+1] = plat.id .. ": " .. tostring(why)
|
||
end
|
||
end
|
||
else
|
||
skipped = skipped + 1
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个") end
|
||
if #failed > 0 then
|
||
logEvent("凭据续期失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 300))
|
||
end
|
||
end
|
||
|
||
-- todayKey():当天标识(UTC+8 口径,与活动结算口径一致)。
|
||
-- ⚠️ 必须用**算术平移**判 UTC+8,不能用 os.date("*t")(本机时区)——
|
||
-- 出差/改时区会把日界判错方向。
|
||
local function todayKey()
|
||
local nowMs = (os.time and os.time() or 0) * 1000
|
||
local utc8 = os.date("!*d", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
|
||
local utc8m = os.date("!*m", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
|
||
local utc8y = os.date("!*Y", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
|
||
return tostring(utc8y) .. "-" .. tostring(utc8m) .. "-" .. tostring(utc8)
|
||
end
|
||
|
||
-- claimCoversToday(resp):这次领取是否**证明今天这一轮已被处理**。
|
||
-- ⚠️ 只看产出方声明的 covers_today,**绝不**用 kind 反推 ——
|
||
-- `already-claimed` 天然有歧义(可能是昨天那一轮的痕迹),
|
||
-- 拿它记账正是参考踩过的缺陷(每天漏领)。
|
||
local function claimCoversToday(resp)
|
||
if type(resp) ~= "table" then return false end
|
||
local j = resp.json
|
||
if type(j) ~= "table" then return false end
|
||
local o = j.outcome
|
||
if type(o) ~= "table" then
|
||
-- 通用路径(非 done 哨兵)没有 outcome:按"已处理"算(每日签到语义)。
|
||
return j.ok == true
|
||
end
|
||
-- 只有明确而成功的结果才算覆盖今天
|
||
if o.covers_today == false then return false end
|
||
local k = o.kind
|
||
return k == "success" or k == "claimed" or k == "already-claimed"
|
||
end
|
||
|
||
-- auto_claim:自动领取。可被调度器多次调用(见 schedule 的 every)。
|
||
--
|
||
-- ★ 关键修正(照参考 credits.ts 的 coversToday 与 auto-checkin.ts 的
|
||
-- shouldMarkToday):**「今天已被处理」不能用 `claimed + already_claimed` 计数**。
|
||
--
|
||
-- 真实缺陷(参考 2026-10-02 审查 PR !33 定位):Qoder 的活动**每日 10:00(UTC+8)
|
||
-- 才刷新**。上午那一轮看到的是**昨天**那条 CLAIMED,返回 `already-claimed`;
|
||
-- 若拿它当"今天已处理"的凭证记档,当天 10 点刷新后就再也不会去领 ——
|
||
-- **每天静默漏掉 100 积分**。
|
||
--
|
||
-- 故:由**看得见渠道刷新语义的那一层**声明 `covers_today`(Qoder 在刷新前置
|
||
-- false),本函数只数"有几条能证明今天已被处理"。缺省 true ⇒ 绝大多数渠道
|
||
-- (每日签到语义)行为完全不变。
|
||
--
|
||
-- 不"给每个渠道配一张刷新时刻表":那必然漂移,而产出方本来就看得见刷新语义。
|
||
function plugin.auto_claim()
|
||
-- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
if plugin.state.settings.auto_checkin == false then return end
|
||
local covered, pending = {}, {}
|
||
for _, plat in ipairs(allPlatforms()) do
|
||
local cap = CHANNEL_CREDITS[plat.id]
|
||
if not cap or not cap.claim then goto continue end
|
||
-- auto_exclude:领取有**代价**的平台(zcode 每次领取现场产阿里云 captcha,
|
||
-- headful Chromium + 按设备限流 150 次/小时)。代价发生在**调用期间**,
|
||
-- "等它报错再判定"来不及,故必须在调之前排除(照参考
|
||
-- isAutoCheckinExcluded)。用户仍可手动领取。
|
||
if cap.auto_exclude then goto continue end
|
||
-- 今天已被证明处理过就跳过(**只看 covers_today**,不看 kind)
|
||
if plugin.state.claimCovered and plugin.state.claimCovered[plat.id] == todayKey() then
|
||
covered[#covered + 1] = plat.id
|
||
goto continue
|
||
end
|
||
local got = false
|
||
local legacy = accountOf(plat.id)
|
||
if legacy and legacy.enabled then
|
||
local r = plugin.claim_daily({ platform = plat.id })
|
||
if claimCoversToday(r) then got = true end
|
||
end
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
if a.enabled ~= false and credentialOf(a.credentialRef) then
|
||
local r = plugin.claim_daily({ platform = plat.id, account_id = a.id })
|
||
if claimCoversToday(r) then got = true end
|
||
end
|
||
end
|
||
if got then
|
||
plugin.state.claimCovered = plugin.state.claimCovered or {}
|
||
plugin.state.claimCovered[plat.id] = todayKey()
|
||
markDirty()
|
||
else
|
||
pending[#pending + 1] = plat.id
|
||
end
|
||
::continue::
|
||
end
|
||
-- 还有平台**未覆盖今天**(如 Qoder 尚未到 10:00 刷新)⇒ 下次调度再试。
|
||
-- 这正是"每天漏领"的修法:不把"刷新前那一轮"当作已完成。
|
||
if #pending > 0 then
|
||
logEvent("自动领取:已覆盖 " .. #covered .. " 个平台,"
|
||
.. #pending .. " 个待重试(" .. table.concat(pending, ", "):sub(1, 120) .. ")")
|
||
end
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 对外 HTTP 端点(设置页调用)
|
||
-- ============================================================================
|
||
|
||
plugin.http = {
|
||
{ path = "channels/platforms", method = "GET", handler = "list_platforms",
|
||
roles = { "admin" }, description = "平台清单与账号状态" },
|
||
{ path = "channels/login", method = "POST", handler = "start_login",
|
||
roles = { "admin" }, description = "发起某平台的登录" },
|
||
{ path = "channels/login/status", method = "GET", handler = "poll_login",
|
||
roles = { "admin" }, description = "轮询登录会话状态" },
|
||
{ path = "channels/login/complete", method = "POST", handler = "complete_login",
|
||
roles = { "admin" }, description = "登录完成:存凭据并接入源" },
|
||
{ path = "channels/claim", method = "POST", handler = "claim_daily",
|
||
roles = { "admin" }, description = "手动领取某平台每日额度" },
|
||
{ path = "channels/status", method = "GET", handler = "status_page",
|
||
roles = { "admin" }, description = "接入状态与最近日志" },
|
||
-- OAuth 回调落点:平台重定向回来带 code。
|
||
{ path = "channels/callback", method = "GET", handler = "oauth_callback",
|
||
public = false, roles = { "admin" }, description = "OAuth 回调" },
|
||
-- 账号池 / 模型开关 / 供应商治理 / 备份(对齐参考 channel-pack RPC 面)
|
||
{ path = "channels/account/add", method = "POST", handler = "account_add",
|
||
roles = { "admin" }, description = "向平台添加账号(占位或带凭据)" },
|
||
{ path = "channels/account/list", method = "POST", handler = "account_list",
|
||
roles = { "admin" }, description = "列平台账号池" },
|
||
{ path = "channels/account/update", method = "POST", handler = "account_update",
|
||
roles = { "admin" }, description = "改账号昵称/启用态" },
|
||
{ path = "channels/account/delete", method = "POST", handler = "account_delete",
|
||
roles = { "admin" }, description = "删除账号(含凭据与上游源)" },
|
||
{ path = "channels/account/test", method = "POST", handler = "account_test",
|
||
roles = { "admin" }, description = "测账号可达" },
|
||
{ path = "channels/account/refresh", method = "POST", handler = "account_refresh",
|
||
roles = { "admin" }, description = "刷新账号(重同步源)" },
|
||
{ path = "channels/account/reorder", method = "POST", handler = "account_reorder",
|
||
roles = { "admin" }, description = "调账号优先级" },
|
||
{ path = "channels/model/list", method = "POST", handler = "model_list",
|
||
roles = { "admin" }, description = "列平台模型与开关态" },
|
||
{ path = "channels/model/refresh", method = "POST", handler = "model_refresh",
|
||
roles = { "admin" }, description = "从上游拉取模型目录(远端为主、兜底补位)" },
|
||
{ path = "channels/model/set_disabled", method = "POST", handler = "model_set_disabled",
|
||
roles = { "admin" }, description = "开关单个模型" },
|
||
{ path = "channels/model/set_all_disabled", method = "POST", handler = "model_set_all_disabled",
|
||
roles = { "admin" }, description = "全开/全关平台模型" },
|
||
{ path = "channels/provider/status", method = "POST", handler = "provider_status",
|
||
roles = { "admin" }, description = "全平台状态(账号数/模型数/关闭态)" },
|
||
{ path = "channels/provider/set_enabled", method = "POST", handler = "provider_set_enabled",
|
||
roles = { "admin" }, description = "供应商整体开关" },
|
||
{ path = "channels/credits/claim_all", method = "POST", handler = "credits_claim_all",
|
||
roles = { "admin" }, description = "一键领取全平台每日额度" },
|
||
{ path = "channels/backup/export", method = "POST", handler = "backup_export",
|
||
roles = { "admin" }, description = "导出账号备份(含凭据)" },
|
||
{ path = "channels/backup/import", method = "POST", handler = "backup_import",
|
||
roles = { "admin" }, description = "导入账号备份" },
|
||
{ path = "channels/auto_checkin", method = "POST", handler = "auto_checkin",
|
||
roles = { "admin" }, description = "自动签到开关读写" },
|
||
{ path = "channels/credits/status", method = "POST", handler = "credits_status",
|
||
roles = { "admin" }, description = "逐账号签到状态" },
|
||
{ path = "channels/credits/balances", method = "POST", handler = "credits_balances",
|
||
roles = { "admin" }, description = "逐账号余额" },
|
||
{ path = "channels/credits/permanent_lock", method = "POST", handler = "credits_permanent_lock",
|
||
roles = { "admin" }, description = "永久积分锁定" },
|
||
{ path = "channels/account/reset", method = "POST", handler = "account_reset",
|
||
roles = { "admin" }, description = "清账号限流标记" },
|
||
{ path = "channels/account/adopt", method = "POST", handler = "account_adopt",
|
||
help = "把旧版非池凭据接管进账号池(升级后账号消失时用)" },
|
||
{ path = "channels/account/retest", method = "POST", handler = "account_retest",
|
||
roles = { "admin" }, description = "重测账号" },
|
||
{ path = "channels/model/set_disabled_many", method = "POST", handler = "model_set_disabled_many",
|
||
roles = { "admin" }, description = "批量开关模型" },
|
||
{ path = "channels/model/clear_dead", method = "POST", handler = "model_clear_dead",
|
||
roles = { "admin" }, description = "清死模型" },
|
||
{ path = "channels/provider/get_order", method = "POST", handler = "provider_get_order",
|
||
roles = { "admin" }, description = "供应商显示顺序" },
|
||
{ path = "channels/provider/set_order", method = "POST", handler = "provider_set_order",
|
||
roles = { "admin" }, description = "设置供应商显示顺序" },
|
||
{ path = "channels/gateway/get_enabled", method = "POST", handler = "gateway_get_enabled",
|
||
roles = { "admin" }, description = "网关开关状态" },
|
||
{ path = "channels/gateway/set_enabled", method = "POST", handler = "gateway_set_enabled",
|
||
roles = { "admin" }, description = "网关整体开关" },
|
||
{ path = "channels/backup/status", method = "POST", handler = "backup_status",
|
||
roles = { "admin" }, description = "备份健康度" },
|
||
{ path = "channels/usage/token_ledger", method = "POST", handler = "usage_token_ledger",
|
||
roles = { "admin" }, description = "token 用量快照" },
|
||
{ path = "channels/usage/badge", method = "POST", handler = "usage_badge",
|
||
roles = { "admin" }, description = "渠道徽标" },
|
||
{ path = "channels/usage/badge_preference", method = "POST", handler = "usage_badge_preference",
|
||
roles = { "admin" }, description = "徽标显示偏好" },
|
||
{ path = "channels/onboarding/status", method = "POST", handler = "onboarding_status",
|
||
roles = { "admin" }, description = "新人任务状态" },
|
||
{ path = "channels/onboarding/claim", method = "POST", handler = "onboarding_claim",
|
||
roles = { "admin" }, description = "领取新人奖励" },
|
||
{ path = "channels/inject_platforms", method = "POST", handler = "inject_platforms",
|
||
roles = { "admin" }, description = "注入测试/扩展平台定义(运行时,不落盘)" },
|
||
-- 服务端浏览器栈(VNC 兜底)的运维面
|
||
{ path = "channels/deps/status", method = "GET", handler = "deps_status",
|
||
roles = { "admin" }, description = "浏览器栈依赖预检(缺什么、装哪个包)" },
|
||
{ path = "channels/browser/status", method = "GET", handler = "browser_status",
|
||
roles = { "admin" }, description = "服务端浏览器会话状态" },
|
||
{ path = "channels/browser/collect", method = "POST", handler = "collect_credentials",
|
||
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
|
||
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
|
||
roles = { "admin" }, description = "回收服务端浏览器栈" },
|
||
}
|
||
|
||
-- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、
|
||
-- base_url)。两个用途:
|
||
-- 1. 运维把某渠道指向自建代理/镜像(厂商被墙、企业出口、审计要求)
|
||
-- 2. 测试把端点指到假上游,行为测试才能不打真厂商
|
||
-- 只允许改已知字段,未知键拒绝 —— 端点声明是协议形状的一部分,静默加字段
|
||
-- 会破坏后续逻辑对形状的假设。
|
||
-- ============================================================================
|
||
-- 账号池 RPC(对齐参考 account.* 契约)
|
||
-- ============================================================================
|
||
-- account_add:向平台加一个账号(占位或带凭据)。
|
||
-- 对应参考 account.create;登录类的流程返回登录 URL,凭据在 login 完成后回填。
|
||
function plugin.account_add(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 aid = args.account_id or newToken():sub(1, 12)
|
||
if poolGet(plat.id, aid) then return { status = 400, json = { error = "账号已存在: " .. aid } } end
|
||
local ref = "channels:" .. plat.id .. ":" .. aid
|
||
if args.credential and args.credential ~= "" then
|
||
local _, serr = __llmsproxy_secrets.set(ref, args.credential)
|
||
if serr then return { status = 400, json = { error = serr } } end
|
||
end
|
||
local now = os.time and os.time() or 0
|
||
local entry = {
|
||
id = aid, provider = plat.id,
|
||
nickname = args.nickname or aid,
|
||
enabled = args.enabled ~= false,
|
||
credentialRef = ref,
|
||
refreshable = args.refreshable or false,
|
||
createdAt = now,
|
||
expiresAt = args.expires_at,
|
||
last_claim = 0, last_claim_status = 0, last_claim_summary = "",
|
||
}
|
||
poolAdd(plat.id, entry)
|
||
logEvent("账号入池 " .. plat.id .. "/" .. aid)
|
||
-- 已带凭据的账号立即同步上游源;未带凭据的等 login 完成
|
||
if args.credential and args.credential ~= "" then syncAccountSource(plat.id, aid) end
|
||
return { status = 200, json = { ok = true, account_id = aid, credentialRef = ref } }
|
||
end
|
||
|
||
-- account_list:列平台的账号(对齐参考 account.list,含余额状态字段)
|
||
function plugin.account_list(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 out = {}
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
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,
|
||
createdAt = a.createdAt, expiresAt = a.expiresAt,
|
||
has_credential = has ~= nil and has ~= "",
|
||
-- 签到/余额展示字段
|
||
last_claim = a.last_claim, last_claim_status = a.last_claim_status,
|
||
last_claim_summary = a.last_claim_summary,
|
||
}
|
||
end
|
||
return { status = 200, json = { ok = true, accounts = out } }
|
||
end
|
||
|
||
-- account_update:改昵称 / enabled(对齐 account.update)
|
||
function plugin.account_update(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
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 patch = type(args.patch) == "table" and args.patch or {}
|
||
if patch.nickname ~= nil then acct.nickname = tostring(patch.nickname) end
|
||
if patch.enabled ~= nil then acct.enabled = patch.enabled and true or false end
|
||
poolAdd(plat.id, acct)
|
||
-- enabled 变化要同步上游源(禁用即删掉该源)
|
||
if patch.enabled ~= nil then
|
||
if acct.enabled then syncAccountSource(plat.id, acct.id)
|
||
else dropAccountSource(plat.id, acct.id) end
|
||
end
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- account_delete:删账号 + 删凭据 + 删源(对齐 account.delete)
|
||
function plugin.account_delete(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
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
|
||
dropAccountSource(plat.id, acct.id)
|
||
__llmsproxy_secrets.delete(acct.credentialRef)
|
||
poolRemove(plat.id, acct.id)
|
||
-- ⚠️ 删完最后一个账号时,必须**清掉旧版单账号源** `channels-<plat>`。
|
||
--
|
||
-- 真实缺陷(本次实测抓到):旧代码建的 `channels-<plat>` 源的 base_url 是
|
||
-- **旧口径**(buddy 缺 `/v2/chat/completions` 前缀)。它不随账号删除而消失,
|
||
-- 于是以同名模型对外服务,但打的是**错的 URL** ——
|
||
-- 症状是推理返回**空内容**(HTTP 200 但没有正文),而列表里模型看着正常。
|
||
-- 我在这上面绕了很久(一会儿 'OK' 一会儿 '',因为内核在多个源之间轮转)。
|
||
-- ⚠️ 只在池**已空**时清:还有账号时不该动它(它可能是运维手工配的兼容源)。
|
||
if #poolList(plat.id) == 0 then
|
||
__llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id })
|
||
end
|
||
logEvent("账号删除 " .. plat.id .. "/" .. acct.id)
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- account_test:测账号(对齐 account.test)—— 做一次上游可达探测。
|
||
-- 简化:内核 probe_all 是全局的;这里用 list_sources 里该账号源存在与否 + 凭据非空作为可达判据。
|
||
-- account_test:**真打一次上游**探测该账号是否可用(对齐参考 account-probe 的
|
||
-- 「重测」语义)。
|
||
--
|
||
-- ⚠️ 语义修正(真实缺陷):此前这个函数**一个网络请求都不发** —— 它只做
|
||
-- syncAccountSource 然后查内核源列表里有没有那条记录,就返回 `{ok=true, ms=1}`。
|
||
-- 于是"测试账号"永远绿,包括凭据已失效、被限流、上游宕机的情况 ——
|
||
-- 正是「看着能用、一用就错」的那类假阳性。
|
||
--
|
||
-- 照参考的两点关键设计:
|
||
-- 1. **只针对指定账号、不产生副作用**:不能顺手把限流标记写到别的账号上。
|
||
-- 2. **停用账号也要能测**:用户明确要求(凭据解析不检查 enabled)。
|
||
-- 我们额外可选地按声明打一次真实请求(余额端点最轻,退化用 checkin_status)。
|
||
function plugin.account_test(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
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 = credentialOf(acct.credentialRef)
|
||
if not tok or tok == "" then
|
||
return { status = 200, json = { ok = false, error = "无凭据(需先登录)",
|
||
probed = false } }
|
||
end
|
||
-- 同步一次源(确保最新凭据已入)—— 这是本地动作,不是"测试"
|
||
syncAccountSource(plat.id, acct.id)
|
||
|
||
-- 真正的一次上游请求:优先余额端点(最轻、所有有余额的平台都有),
|
||
-- 退化到 checkin_status。两者都没有的平台如实报"无探测端点",
|
||
-- 不假装成功。
|
||
local spec = plat.balance or plat.checkin_status
|
||
if not spec or not spec.url then
|
||
local srcs, _ = __llmsproxy_kernel.call("list_sources", {})
|
||
local found = false
|
||
for _, s in ipairs(srcs or {}) do
|
||
if s.name == accountSourceName(plat.id, acct.id) then found = true break end
|
||
end
|
||
return { status = 200, json = { ok = found, probed = false,
|
||
error = found and nil or "源未同步",
|
||
note = "该平台未声明可探测端点,仅校验本地凭据与源" } }
|
||
end
|
||
local t0 = os.clock and os.clock() or 0
|
||
local rec, err = fetchAccountJSON(spec, tok, credentialFull(acct.credentialRef), plat)
|
||
local ms = math.floor(((os.clock and os.clock() or 0) - t0) * 1000)
|
||
if err then
|
||
-- 失败要区分"凭据失效"(需重新登录)与"暂时性失败"(可重试)——
|
||
-- 只说"失败"用户不知道该做什么。
|
||
local kind = "transient"
|
||
local el = tostring(err):lower()
|
||
if el:find("401") or el:find("403") or el:find("unauthor")
|
||
or el:find("invalid_grant") or el:find("expired") then
|
||
kind = "auth"
|
||
end
|
||
return { status = 200, json = { ok = false, probed = true, ms = ms,
|
||
error = tostring(err), kind = kind,
|
||
action = kind == "auth" and "凭据已失效,请重新登录该账号" or "上游暂时不可用,可稍后重试" } }
|
||
end
|
||
return { status = 200, json = { ok = true, probed = true, ms = ms } }
|
||
end
|
||
|
||
-- account_adopt:把**非池**的旧凭据(`channels:<plat>`)接管进某个池账号。
|
||
--
|
||
-- 为什么需要它(真实场景):v2.0.x 之前登录只写一条 `channels:<plat>`(单账号
|
||
-- 模式),后来引入账号池后凭据落在 `channels:<plat>:<aid>`。升级后老凭据仍在
|
||
-- 保险库里、但池里没有账号引用它 —— 表现是"账号消失了"(数据其实还在)。
|
||
-- 重新登录当然可以,但那是**用户可见的额外代价**(要再走一次授权)。
|
||
--
|
||
-- ⚠️ 正确做法是让**插件自己读保险库**(`__llmsproxy_secrets.get`)再写进池引用。
|
||
-- **绝不能**把保险库文件里的密文当明文喂给 API —— 保险库读取时会解密,那样会
|
||
-- 把凭据**二次加密**(实测踩过:1971 → 2965 字符,该账号推理开始返回空)。
|
||
function plugin.account_adopt(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
local legacyRef = "channels:" .. plat.id
|
||
-- 全程只用插件的保险库 API:读出的是**明文**(保险库负责解密)
|
||
local raw = __llmsproxy_secrets.get(legacyRef)
|
||
if not raw or raw == "" then
|
||
return { status = 200, json = { ok = false,
|
||
error = "没有可接管的旧凭据(" .. legacyRef .. ")" } }
|
||
end
|
||
local aid = args.account_id
|
||
if not aid or aid == "" then
|
||
return { status = 400, json = { error = "缺 account_id(要接管进哪个池账号)" } }
|
||
end
|
||
local acct = poolGet(plat.id, aid)
|
||
if not acct then
|
||
-- 允许直接建:占位 + 写入
|
||
local now = os.time and os.time() or 0
|
||
acct = { id = aid, provider = plat.id, nickname = args.nickname or aid,
|
||
enabled = true, credentialRef = "channels:" .. plat.id .. ":" .. aid,
|
||
refreshable = false, createdAt = now, last_claim = 0, last_claim_status = 0,
|
||
last_claim_summary = "" }
|
||
poolAdd(plat.id, acct)
|
||
end
|
||
-- 写入的是**明文**(raw)—— 保险库写入时自己加密。这正是要避免"手填密文"的原因。
|
||
local _, serr = __llmsproxy_secrets.set(acct.credentialRef, raw)
|
||
if serr then return { status = 200, json = { ok = false, error = serr } } end
|
||
-- 从凭据补齐展示字段
|
||
if type(raw) == "string" and raw:sub(1, 1) == "{" then
|
||
local dok, cred = pcall(json.decode, raw)
|
||
if dok and type(cred) == "table" then
|
||
acct.nickname = args.nickname or cred.nickname or cred.user_name or cred.user_id or acct.nickname
|
||
acct.refreshable = credentialIsRefreshable(cred)
|
||
local exp = credentialExpiresAtMs(cred)
|
||
if exp then acct.expiresAt = tostring(exp) end
|
||
end
|
||
end
|
||
poolAdd(plat.id, acct)
|
||
markDirty()
|
||
syncAccountSource(plat.id, aid)
|
||
logEvent("已接管旧凭据到 " .. plat.id .. "/" .. aid)
|
||
return { status = 200, json = { ok = true, account_id = aid,
|
||
credentialRef = acct.credentialRef, message = "旧凭据已接入账号池" } }
|
||
end
|
||
|
||
-- account_refresh:刷新账号(对齐 account.refresh)—— 重新同步源 + 重新查余额
|
||
function plugin.account_refresh(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
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
|
||
-- ★ 真续期:此前这里只重同步源、一个换 token 的请求都不发却恒返回 ok:true,
|
||
-- 于是 `refresh_creds` 打出「凭据已续期 N 个」而什么都没续 —— 日志在说谎。
|
||
local rok, rmsg = refreshCredential(plat, acct)
|
||
if not rok then
|
||
-- 失败必须**如实回错**(供 UI 显示与日志诊断)。但仍重同步源:
|
||
-- 旧 token 可能还没真过期,不该因为续期失败就把源撤掉。
|
||
local tok = credentialOf(acct.credentialRef)
|
||
if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end
|
||
return { status = 200, json = { ok = false, refreshed = false, message = rmsg } }
|
||
end
|
||
-- 续期成功 → 用**新 token** 重建源(旧源的 Authorization 头已失效)
|
||
syncAccountSource(plat.id, acct.id)
|
||
return { status = 200, json = { ok = true, refreshed = true, message = rmsg } }
|
||
end
|
||
|
||
-- account_reorder:调账号优先级(对齐 account.reorder)
|
||
function plugin.account_reorder(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 ordered = args and args.ordered_ids
|
||
if type(ordered) ~= "table" then return { status = 400, json = { error = "ordered_ids 必须是数组" } } end
|
||
for i, aid in ipairs(ordered) do
|
||
local acct = poolGet(plat.id, tostring(aid))
|
||
if acct then acct.priority = #ordered - i + 1 poolAdd(plat.id, acct) end
|
||
end
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 模型开关 RPC(对齐参考 model.*)
|
||
-- ============================================================================
|
||
-- plugin.model_refresh(platform?):从上游拉模型目录。
|
||
--
|
||
-- 不传 platform 就刷新所有**声明了 models_remote** 的平台。失败不清缓存
|
||
-- (宁可留旧目录也别让平台变空),逐个返回结果供 UI 提示。
|
||
function plugin.model_refresh(req)
|
||
local args = bodyOf(req)
|
||
if args.platform == nil then args = req end
|
||
local targets = {}
|
||
if args and args.platform then
|
||
local plat = platformById(args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
targets = { plat }
|
||
else
|
||
for _, p in ipairs(allPlatforms()) do
|
||
if p.models_remote then targets[#targets+1] = p end
|
||
end
|
||
end
|
||
local results = {}
|
||
for _, plat in ipairs(targets) do
|
||
local ids, err = refreshRemoteModels(plat.id)
|
||
results[#results+1] = {
|
||
platform = plat.id,
|
||
ok = ids ~= nil,
|
||
count = ids and #ids or 0,
|
||
error = err,
|
||
}
|
||
-- 目录变了就重同步该平台所有源(模型清单写进了上游源)
|
||
if ids then
|
||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||
end
|
||
end
|
||
return { status = 200, json = { ok = true, results = results } }
|
||
end
|
||
|
||
function plugin.model_list(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 disabled = disabledModelsOf(plat.id)
|
||
-- models_hidden:内部子 agent(照参考 isHidden 语义)不进用户可选列表。
|
||
local hidden = {}
|
||
for _, m in ipairs(plat.models_hidden or {}) do hidden[m] = true end
|
||
-- models_free:参考的 isFree 字段(官方语义:匿名/免费可用)。
|
||
local free = {}
|
||
for _, m in ipairs(plat.models_free or {}) do free[m] = true end
|
||
-- 远端**权威**免费集合(照参考 cline-models.ts 的 isFree 判定链):
|
||
-- ① 远端 free 数组命中的 id
|
||
-- ② 命名约定 `cline-free/*`(远端未响应时的兜底 —— 参考明写这条)
|
||
-- ③ 内嵌 models_free / 远端 cost=0 的能力表(下面按顺序覆盖)
|
||
-- ⚠️ 参考点名:免费模型是**独立 id**(`cline-free/deepseek-v4.1-flash`),
|
||
-- 与收费的 `deepseek/deepseek-v4.1-flash` 是两个不同的 id。故这里判的是
|
||
-- **id 本身**,不剥离前缀。
|
||
local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil
|
||
local FREE_PREFIX = plat.free_prefix -- 命名约定(如 "cline-free/")
|
||
local out = {}
|
||
for i, m in ipairs(effectiveModels(plat.id)) do
|
||
if not hidden[m] then
|
||
-- 能力元数据优先(照参考 opencode-capability):
|
||
-- `isFree` 的**权威来源**是 models.dev 的 cost(input 与 output 同时为 0),
|
||
-- 不是我们硬编码的快照 —— 快照会随上游调整免费策略而过期。
|
||
-- 缓存未就绪时退回声明的 models_free,绝不触发网络(那是 5MB 的下载)。
|
||
local cap = capabilityOf(plat.id, m)
|
||
local is_free = false
|
||
-- ⚠️ **两个参考用的是不同机制**,不能混成一条链:
|
||
-- * **有自己免费名单的平台**(cline):远端 `free[]` 是唯一权威,
|
||
-- 再加命名约定与静态兜底表 —— 它们**不查 models.dev 的 cost**。
|
||
-- * **只有能力表可查的平台**(opencode:`/zen/v1/models` 连能力字段
|
||
-- 都没有):models.dev 的 `cost` **就是**唯一权威。
|
||
-- 故由平台声明 `free_source`,而不是我在这里猜一个通用规则
|
||
-- (猜的结果是两个参考都照顾不到)。
|
||
if plat.free_source == "capability" then
|
||
-- cost 权威:能力表说收费就必须是收费(本地声明只是能力表拉不到时的兜底)
|
||
if cap and cap.isFree ~= nil then
|
||
is_free = cap.isFree and true or false
|
||
else
|
||
is_free = free[m] and true or false
|
||
end
|
||
else
|
||
-- 声明链权威(OR 语义:任一来源确认免费即免费)。
|
||
-- 写成"后者覆盖前者"会让「远端说免费、能力表拉不到」静默退回收费。
|
||
if remoteFree and remoteFree[m] then is_free = true end
|
||
if not is_free and FREE_PREFIX and m:sub(1, #FREE_PREFIX) == FREE_PREFIX then
|
||
is_free = true
|
||
end
|
||
if not is_free and free[m] then is_free = true end
|
||
if not is_free and cap and cap.isFree == true then is_free = true end
|
||
end
|
||
local entry = { id = m, name = (cap and cap.name) or m,
|
||
disabled = disabled[m] and true or false,
|
||
isFree = is_free }
|
||
if cap then
|
||
entry.capabilities = {
|
||
modalities = cap.modalities,
|
||
contextWindow = cap.contextWindow,
|
||
maxOutputTokens = cap.maxOutputTokens,
|
||
reasoning = cap.reasoning,
|
||
efforts = cap.efforts,
|
||
toolCall = cap.toolCall,
|
||
}
|
||
end
|
||
out[#out+1] = entry
|
||
end
|
||
end
|
||
local remote = plugin.state.remoteModels and plugin.state.remoteModels[plat.id]
|
||
return { status = 200, json = {
|
||
ok = true, models = out,
|
||
remote = remote ~= nil,
|
||
fetched_at = remote and remote.fetched_at or nil,
|
||
fallbackOnly = remote == nil,
|
||
} }
|
||
end
|
||
|
||
function plugin.model_set_disabled(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 mid = tostring(args and args.model_id or "")
|
||
if mid == "" then return { status = 400, json = { error = "缺 model_id" } } end
|
||
local want = args.disabled and true or false
|
||
setModelDisabled(plat.id, mid, want)
|
||
-- 重同步该平台的全部源,让模型开关立即生效
|
||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||
return { status = 200, json = { ok = true, disabled = want } }
|
||
end
|
||
|
||
function plugin.model_set_all_disabled(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 disabled = disabledModelsOf(plat.id)
|
||
if args.disabled then
|
||
for _, m in ipairs(effectiveModels(plat.id)) do disabled[m] = true end
|
||
else
|
||
for k in pairs(disabled) do disabled[k] = nil end
|
||
end
|
||
markDirty()
|
||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 供应商开关 + 状态(对齐参考 provider.*)
|
||
-- ============================================================================
|
||
function plugin.provider_status(req)
|
||
local out = {}
|
||
for _, p in ipairs(allPlatforms()) do
|
||
local accts = poolList(p.id)
|
||
local enabledN = 0
|
||
for _, a in ipairs(accts) do if a.enabled ~= false then enabledN = enabledN + 1 end end
|
||
local disabled = disabledModelsOf(p.id)
|
||
local disabledN = 0
|
||
local eff = effectiveModels(p.id)
|
||
for _, m in ipairs(eff) do if disabled[m] then disabledN = disabledN + 1 end end
|
||
local closed = #eff > 0 and disabledN == #eff
|
||
local state_accounts = plugin.state.accounts[p.id]
|
||
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 = #eff, disabled = disabledN },
|
||
accounts = { total = #accts + legacyN, enabled = enabledN + legacyN },
|
||
}
|
||
end
|
||
return { status = 200, json = { ok = true, statuses = out } }
|
||
end
|
||
|
||
function plugin.provider_set_enabled(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 enabled = args.enabled and true or false
|
||
-- 对齐参考语义:关闭 = 全关模型 + 停用全部账号;打开 = 清空模型黑名单 + 启用全部账号。
|
||
local disabled = disabledModelsOf(plat.id)
|
||
if enabled then
|
||
for k in pairs(disabled) do disabled[k] = nil end
|
||
markDirty()
|
||
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]
|
||
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 = legacyTok, max_concurrent = 1,
|
||
models = (function() local ms = {} local eff = effectiveModels(plat.id)
|
||
for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(),
|
||
} })
|
||
end
|
||
else
|
||
for _, m in ipairs(effectiveModels(plat.id)) do disabled[m] = true end
|
||
for _, a in ipairs(poolList(plat.id)) do a.enabled = false poolAdd(plat.id, a) dropAccountSource(plat.id, a.id) end
|
||
__llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id })
|
||
end
|
||
logEvent("供应商" .. (enabled and "启用 " or "关闭 ") .. plat.id)
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 一键领取(对齐参考 credits.claimAll)
|
||
-- ============================================================================
|
||
function plugin.credits_claim_all(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 results = {}
|
||
local claimed = 0
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
local r = plugin.claim_daily({ platform = plat.id, account_id = a.id, credential_ref = a.credentialRef })
|
||
local ok = r.json and r.json.ok and true or false
|
||
if ok then claimed = claimed + 1 end
|
||
results[#results+1] = { accountId = a.id, outcome = ok
|
||
and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) }
|
||
or { kind = "failed", message = (r.json and r.json.error) or "失败" } }
|
||
end
|
||
-- 无池账号的走旧路径
|
||
if #poolList(plat.id) == 0 then
|
||
local r = plugin.claim_daily({ platform = plat.id })
|
||
local ok = r.json and r.json.ok and true or false
|
||
if ok then claimed = claimed + 1 end
|
||
results[#results+1] = { accountId = "legacy", outcome = ok
|
||
and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) }
|
||
or { kind = "failed", message = (r.json and r.json.error) or "失败" } }
|
||
end
|
||
return { status = 200, json = { ok = true, summary = { claimed = claimed }, results = results } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 备份导出/导入(对齐参考 backup.*)
|
||
-- ============================================================================
|
||
function plugin.backup_export(req)
|
||
local payload = {}
|
||
payload.version = 1
|
||
payload.exportedAt = os.time and os.time() or 0
|
||
payload.accounts = {}
|
||
payload.credentials = {}
|
||
payload.modelDisabled = plugin.state.modelDisabled or {}
|
||
payload.autoCheckin = plugin.state.settings and plugin.state.settings.auto_checkin or true
|
||
for _, p in ipairs(allPlatforms()) do
|
||
for _, a in ipairs(poolList(p.id)) do
|
||
payload.accounts[#payload.accounts+1] = a
|
||
local tok = credentialOf(a.credentialRef)
|
||
if tok then payload.credentials[a.credentialRef] = tok end
|
||
end
|
||
-- 旧路径单账号也备份
|
||
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 } }
|
||
end
|
||
|
||
function plugin.backup_import(req)
|
||
local args = bodyOf(req)
|
||
local payload = args and args.payload
|
||
if type(payload) ~= "table" then return { status = 400, json = { error = "payload 必须是对象" } } end
|
||
local n = 0
|
||
-- 恢复凭据
|
||
if type(payload.credentials) == "table" then
|
||
for ref, tok in pairs(payload.credentials) do
|
||
if type(ref) == "string" and type(tok) == "string" and tok ~= "" then
|
||
__llmsproxy_secrets.set(ref, tok) n = n + 1
|
||
end
|
||
end
|
||
end
|
||
-- 恢复账号池
|
||
if type(payload.accounts) == "table" then
|
||
for _, a in ipairs(payload.accounts) do
|
||
if type(a) == "table" and a.id and a.provider then
|
||
poolAdd(tostring(a.provider), {
|
||
id = tostring(a.id), provider = tostring(a.provider),
|
||
nickname = a.nickname or a.id, enabled = a.enabled ~= false,
|
||
credentialRef = a.credentialRef or ("channels:" .. a.provider .. ":" .. a.id),
|
||
refreshable = a.refreshable or false, createdAt = a.createdAt or (os.time and os.time() or 0),
|
||
expiresAt = a.expiresAt, last_claim = 0, last_claim_status = 0, last_claim_summary = "",
|
||
})
|
||
end
|
||
end
|
||
end
|
||
-- 恢复模型黑名单
|
||
if type(payload.modelDisabled) == "table" then
|
||
plugin.state.modelDisabled = payload.modelDisabled
|
||
markDirty()
|
||
end
|
||
-- 恢复自动签到开关
|
||
if payload.autoCheckin ~= nil then
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
plugin.state.settings.auto_checkin = payload.autoCheckin and true or false
|
||
end
|
||
-- 全部账号重新同步源
|
||
for _, p in ipairs(allPlatforms()) do
|
||
for _, a in ipairs(poolList(p.id)) do syncAccountSource(p.id, a.id) end
|
||
end
|
||
logEvent("备份已导入,恢复 " .. n .. " 条凭据")
|
||
return { status = 200, json = { ok = true, restored = n } }
|
||
end
|
||
|
||
-- auto_checkin 开关读写
|
||
function plugin.auto_checkin(req)
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
local args = bodyOf(req)
|
||
if args.enabled ~= nil then
|
||
plugin.state.settings.auto_checkin = args.enabled and true or false
|
||
markDirty()
|
||
logEvent("自动签到" .. (plugin.state.settings.auto_checkin and "开启" or "关闭"))
|
||
end
|
||
return { status = 200, json = { ok = true, autoCheckin = plugin.state.settings.auto_checkin ~= false } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 签到状态 + 逐账号余额(对齐参考 credits.status / credits.balances)
|
||
-- ============================================================================
|
||
-- fetchAccountJSON 用某账号凭据发一次请求,返回 (rec_table, nil) 或 (nil, err)。
|
||
fetchAccountJSON = function(spec, tok, credTable, plat)
|
||
local headers
|
||
-- 头钩子位:spec(checkin_status / balance)自己的优先,否则用平台级的。
|
||
-- buddy 在平台层声明一次,签到与余额复用同一套身份头。
|
||
local hfn = spec.headers_fn or (plat and plat.headers_fn)
|
||
if hfn then
|
||
local okf, h = pcall(hfn, tok, credTable or {})
|
||
if okf and type(h) == "table" then headers = h end
|
||
end
|
||
if not headers then
|
||
headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }
|
||
end
|
||
local resp, err = __llmsproxy_http({
|
||
url = spec.url, method = spec.method or "GET",
|
||
headers = headers, body = spec.body,
|
||
})
|
||
if err then return nil, err end
|
||
if resp.status ~= 200 then return nil, "HTTP " .. tostring(resp.status) end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then return nil, "响应无法解析" end
|
||
-- ⚠️ 业务级错误必须在这里也判掉:多家上游**HTTP 200 但 body 里带 error**
|
||
-- (实测 buddy 的 invalid_grant 就是这个形态)。只看 HTTP 状态码的话,
|
||
-- "凭据已失效"会被当成成功 —— 账号测试于是永远报绿,这是最误导人的假阳性。
|
||
local e = rec.error
|
||
if type(e) == "string" and e ~= "" then return nil, e end
|
||
if type(e) == "table" then
|
||
local msg = e.message or e.msg
|
||
if type(msg) == "string" and msg ~= "" then return nil, msg end
|
||
return nil, "上游返回 error 对象"
|
||
end
|
||
-- 有些上游把错误放在 code/msg 里(code 非 0 即失败)
|
||
local code = rec.code
|
||
if code ~= nil then
|
||
local n = tonumber(code)
|
||
if n and n ~= 0 and n ~= 200 then
|
||
return nil, tostring(rec.msg or rec.message or ("业务码 " .. tostring(code)))
|
||
end
|
||
end
|
||
return rec, nil
|
||
end
|
||
|
||
-- credits_status:逐账号查签到状态(对齐参考 credits.status;
|
||
-- 无状态端点的平台如实返回 null,与参考对 CodeArts/LobsterAI 的处理一致)
|
||
function plugin.credits_status(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 out = {}
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
local status = nil
|
||
local cs = plat.checkin_status
|
||
if cs then
|
||
local tok = credentialOf(a.credentialRef)
|
||
if tok then
|
||
local rec, err = fetchAccountJSON(cs, tok, credentialFull(a.credentialRef), plat)
|
||
if rec and plat.checkin_status.status_fn then
|
||
local okf, st = pcall(plat.checkin_status.status_fn, rec)
|
||
if okf then status = st end
|
||
elseif err then
|
||
status = { error = err }
|
||
end
|
||
end
|
||
end
|
||
out[#out+1] = { accountId = a.id, nickname = a.nickname, status = status }
|
||
end
|
||
return { status = 200, json = { ok = true, accounts = out } }
|
||
end
|
||
|
||
-- credits_balances:逐账号查余额(对齐参考 credits.balances;
|
||
-- 无 balance 端点的平台返回 {balance=null, error="平台未声明余额端点"},
|
||
-- 不编造数字 —— 与参考注释「不伪装成余额」同语义)
|
||
function plugin.credits_balances(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 cap = CHANNEL_CREDITS[plat.id] or {}
|
||
local out = {}
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
local entry = { accountId = a.id, balance = nil, error = nil }
|
||
local b = plat.balance
|
||
if not b then
|
||
entry.error = "平台未声明余额端点"
|
||
else
|
||
local tok = credentialOf(a.credentialRef)
|
||
if not tok then
|
||
entry.error = "无凭据"
|
||
else
|
||
local rec, err = fetchAccountJSON(b, tok, credentialFull(a.credentialRef), plat)
|
||
if err then
|
||
entry.error = err
|
||
elseif b.total_fn then
|
||
local okf, total = pcall(b.total_fn, rec)
|
||
if okf and type(total) == "number" then
|
||
entry.balance = { total = total, unit = cap.unit or "credits" }
|
||
else
|
||
entry.error = "余额解析失败"
|
||
end
|
||
end
|
||
end
|
||
end
|
||
out[#out+1] = entry
|
||
end
|
||
return { status = 200, json = { ok = true, accounts = out } }
|
||
end
|
||
|
||
-- 永久积分锁定(对齐参考 credits.permanentLock:锁定后仅使用 windowDays 内到期的积分)
|
||
function plugin.credits_permanent_lock(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
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
if args.locked ~= nil then
|
||
if type(args.locked) ~= "boolean" then
|
||
return { status = 400, json = { error = "locked 必须是布尔值" } }
|
||
end
|
||
plugin.state.settings["lock_" .. plat.id] = args.locked
|
||
markDirty()
|
||
logEvent("永久积分" .. (args.locked and "锁定 " or "解除 ") .. plat.id)
|
||
end
|
||
return { status = 200, json = { ok = true, locked = plugin.state.settings["lock_" .. plat.id] == true,
|
||
windowDays = 3 } }
|
||
end
|
||
|
||
-- account.reset / resetAll:清除账号限流标记(对齐参考 account.reset)
|
||
function plugin.account_reset(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
if args.account_id == "__all__" then
|
||
for _, a in ipairs(poolList(plat.id)) do a.rateLimited = nil poolAdd(plat.id, a) end
|
||
return { status = 200, json = { ok = true, reset = #poolList(plat.id) } }
|
||
end
|
||
local acct = poolGet(plat.id, args and args.account_id)
|
||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||
acct.rateLimited = nil
|
||
poolAdd(plat.id, acct)
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- account.retest / retestAll:重测账号(对齐参考 account.retest:触发条件不同,实现同 test)
|
||
function plugin.account_retest(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
if args.account_id == "__all__" then
|
||
local results = {}
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
local r = plugin.account_test({ platform = plat.id, account_id = a.id })
|
||
results[#results+1] = { accountId = a.id, ok = r.json and r.json.ok and true or false,
|
||
error = r.json and r.json.error or nil }
|
||
end
|
||
return { status = 200, json = { ok = true, results = results } }
|
||
end
|
||
return plugin.account_test({ platform = plat.id, account_id = args.account_id })
|
||
end
|
||
|
||
-- model.setDisabledMany:批量开关(对齐参考 model.setDisabledMany)
|
||
function plugin.model_set_disabled_many(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 ids = args and args.model_ids
|
||
if type(ids) ~= "table" then return { status = 400, json = { error = "model_ids 必须是数组" } } end
|
||
local disabled = disabledModelsOf(plat.id)
|
||
for _, mid in ipairs(ids) do
|
||
if args.disabled then disabled[tostring(mid)] = true else disabled[tostring(mid)] = nil end
|
||
end
|
||
markDirty()
|
||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- model.clearDead:清死模型(对齐参考 model.clearDead:从黑名单移除已下线的 id)
|
||
-- model_clear_dead:清掉"已失效模型"的记录(照参考 clearDeadModels 语义)。
|
||
--
|
||
-- ⚠️ 语义修正:此前这里删的是"被禁用且不在目录里"的条目 —— 那既不是失效记录,
|
||
-- 判据也用错了(拿目录反推)。参考的 clear 是:把**有阳性 404 证据**的记录清掉,
|
||
-- 让模型重新出现在列表里(用户确认上游又上了/或误判时用)。
|
||
function plugin.model_clear_dead(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 mid = args and args.model_id
|
||
local n = clearDeadModels(plat.id, mid)
|
||
markDirty()
|
||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||
return { status = 200, json = { ok = true, removed = n,
|
||
message = n > 0 and ("已恢复 " .. n .. " 个模型") or "无失效模型记录" } }
|
||
end
|
||
|
||
-- 供应商排序(对齐参考 provider.getOrder/setOrder)
|
||
function plugin.provider_get_order(req)
|
||
local order = plugin.state.providerOrder
|
||
if type(order) ~= "table" then order = {} end
|
||
return { status = 200, json = { ok = true, order = order } }
|
||
end
|
||
|
||
function plugin.provider_set_order(req)
|
||
local args = bodyOf(req)
|
||
local order = args and args.order
|
||
if type(order) ~= "table" then return { status = 400, json = { error = "order 必须是字符串数组" } } end
|
||
local cleaned = {}
|
||
for _, id in ipairs(order) do
|
||
if platformById(tostring(id)) then cleaned[#cleaned+1] = tostring(id) end
|
||
end
|
||
plugin.state.providerOrder = cleaned
|
||
markDirty()
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- 网关开关(对齐参考 gateway.getEnabled/setEnabled:本插件的一个上游源面开关)
|
||
-- 这里网关开关语义=channels 提供的源是否参与调度;用 set_plugin_enabled 内核动作
|
||
-- 不合适(那是插件级),我们用「全部模型黑名单+全部账号停用」已由 provider_set_enabled
|
||
-- 承担,这里改为读取当前接入状态做查询、写入复用 provider_set_enabled。
|
||
function plugin.gateway_get_enabled(req)
|
||
local any = false
|
||
for _, p in ipairs(allPlatforms()) do
|
||
local disabled = disabledModelsOf(p.id)
|
||
local total = #effectiveModels(p.id)
|
||
local allOff = total > 0
|
||
for _, m in ipairs(effectiveModels(p.id)) do if not disabled[m] then allOff = false break end end
|
||
if not allOff then any = true break end
|
||
end
|
||
return { status = 200, json = { ok = true, enabled = any,
|
||
apiKey = nil, address = nil, models = { source = "channels" } } }
|
||
end
|
||
|
||
function plugin.gateway_set_enabled(req)
|
||
local args = bodyOf(req)
|
||
if type(args.enabled) ~= "boolean" then
|
||
return { status = 400, json = { error = "非空布尔 enabled 必填" } }
|
||
end
|
||
-- 全平台整体开关(复用 provider_set_enabled 语义)
|
||
for _, p in ipairs(allPlatforms()) do
|
||
plugin.provider_set_enabled({ platform = p.id, enabled = args.enabled })
|
||
end
|
||
logEvent("网关整体" .. (args.enabled and "开启" or "关闭"))
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- backup.status:备份健康度(对齐参考 backup.status:账号数/无过期时间账号数)
|
||
function plugin.backup_status(req)
|
||
local total, noExpiry = 0, 0
|
||
for _, p in ipairs(allPlatforms()) do
|
||
for _, a in ipairs(poolList(p.id)) do
|
||
total = total + 1
|
||
if a.expiresAt == nil then noExpiry = noExpiry + 1 end
|
||
end
|
||
end
|
||
return { status = 200, json = { ok = true, accounts = total, withoutExpiry = noExpiry } }
|
||
end
|
||
|
||
-- usage.tokenLedger:本网关的 token 用量快照(对齐参考 usage.tokenLedger)。
|
||
-- 数据源:网关 /api/stats(插件走回环取,与 usage 插件同思路)。
|
||
function plugin.usage_token_ledger(req)
|
||
local args = bodyOf(req)
|
||
local limit = tonumber(args and args.limit) or 50
|
||
local key = (req and req.key) or ""
|
||
local url = "http://127.0.0.1:8081/api/stats?period=all&limit=" .. tostring(limit)
|
||
if key ~= "" then
|
||
url = url .. "&key=" .. (key:len() <= 6 and key or ("***" .. key:sub(-6)))
|
||
end
|
||
local headers = {}
|
||
if key ~= "" then
|
||
headers["Authorization"] = "Bearer " .. key
|
||
end
|
||
local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000, headers = headers })
|
||
if err or not resp or resp.status ~= 200 then
|
||
return { status = 503, json = { ok = false, error = "usage snapshot unavailable: " .. tostring(err or (resp and resp.status or "no response")) } }
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then
|
||
return { status = 502, json = { ok = false, error = "usage snapshot 无法解析" } }
|
||
end
|
||
-- 汇总 token 总量。/api/stats 的聚合在 `total` 下,不在顶层数组中。
|
||
-- 之前错误地遍历 rec.requests/rows/data,导致该端点永远返回 0。
|
||
local total = type(rec.total) == "table" and rec.total or {}
|
||
local prompt = tonumber(total.prompt_tokens) or 0
|
||
local completion = tonumber(total.completion_tokens) or 0
|
||
local requests = tonumber(total.reqs) or 0
|
||
return { status = 200, json = { ok = true, snapshot = { available = true,
|
||
tokens = { prompt = prompt, completion = completion, total = prompt + completion },
|
||
requests = requests, raw = rec } } }
|
||
end
|
||
|
||
-- usage.badge:渠道徽标(对齐参考 usage.badge;用 credits_balances 的汇总)
|
||
function plugin.usage_badge(req)
|
||
local args = bodyOf(req)
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "provider 不能为空" } } end
|
||
local j = plugin.credits_balances({ platform = plat.id })
|
||
local accounts = asArrLua(j.json and j.json.accounts)
|
||
local total, known = 0, false
|
||
for _, a in ipairs(accounts) do
|
||
if type(a.balance) == "table" and type(a.balance.total) == "number" then
|
||
total = total + a.balance.total
|
||
known = true
|
||
end
|
||
end
|
||
return { status = 200, json = { ok = true, badge = known
|
||
and { kind = "credits", total = total, unit = (CHANNEL_CREDITS[plat.id] or {}).unit or "credits" }
|
||
or { kind = "unavailable" } } }
|
||
end
|
||
|
||
-- usage.badgePreference:徽标显示偏好(读/写,全局一个;非法值拒绝不回落)
|
||
function plugin.usage_badge_preference(req)
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
local args = bodyOf(req)
|
||
if args.preference ~= nil then
|
||
local valid = { auto = true, subscription = true, credits = true }
|
||
if not valid[args.preference] then
|
||
return { status = 400, json = { error = "preference 必须是 auto/subscription/credits 之一" } }
|
||
end
|
||
plugin.state.settings.badge_preference = args.preference
|
||
end
|
||
return { status = 200, json = { ok = true, preference = plugin.state.settings.badge_preference or "auto" } }
|
||
end
|
||
|
||
-- onboarding.status / claim:新人任务(对齐参考 onboarding.*;仅 raccoon 有实测端点)
|
||
function plugin.onboarding_status(req)
|
||
local args = bodyOf(req)
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
local ob = plat.onboarding
|
||
if not ob then
|
||
return { status = 200, json = { ok = true, supported = false,
|
||
message = plat.name .. " 未声明新人任务端点(参考实现同样如实返回 null)" } }
|
||
end
|
||
-- 已领取判定:查 state
|
||
local claimed = plugin.state.settings and plugin.state.settings["onboard_" .. plat.id] == true
|
||
return { status = 200, json = { ok = true, supported = true,
|
||
tasks = { desktop_login_reward = claimed }, earned = claimed and ob.reward_points or 0,
|
||
total = ob.reward_points } }
|
||
end
|
||
|
||
function plugin.onboarding_claim(req)
|
||
local args = bodyOf(req)
|
||
local plat = platformById(args and args.platform)
|
||
if not plat or not plat.onboarding then
|
||
return { status = 400, json = { error = "该平台无新人任务" } }
|
||
end
|
||
-- raccoon 实测端点:POST {apiBase}/login/points/grant
|
||
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",
|
||
headers = { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
|
||
body = "{}",
|
||
})
|
||
if err then return { status = 400, json = { error = err } } end
|
||
if resp.status ~= 200 then return { status = 400, json = { error = "HTTP " .. tostring(resp.status) } } end
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
plugin.state.settings["onboard_" .. plat.id] = true
|
||
logEvent("新人奖励已领 " .. plat.id)
|
||
return { status = 200, json = { ok = true, points = plat.onboarding.reward_points } }
|
||
end
|
||
|
||
-- asArrLua(Lua 侧的空表规整):桥接层 Go→Lua 不涉及,但 handler 内部把
|
||
-- credits_balances 的 json.accounts 再传给别的 handler 时要能处理空表。
|
||
function asArrLua(t)
|
||
if type(t) ~= "table" then return {} end
|
||
if #t > 0 then return t end
|
||
return {}
|
||
end
|
||
|
||
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.callback) == "table" and plat.callback then
|
||
for _, k in ipairs({ "mode", "token_url" }) do
|
||
if args.callback[k] then
|
||
plat.callback[k] = tostring(args.callback[k])
|
||
changed[#changed+1] = "callback." .. k
|
||
end
|
||
end
|
||
end
|
||
-- client_version:有道 exchange/续期/签到的必填版本号来源。测试需把它指到假上游;
|
||
-- 同时清掉已缓存值(否则上一次测试的值会跨用例泄漏)。
|
||
if type(args.qoder_sash_base) == "table" and args.qoder_sash_base.url then
|
||
QODER_SASH_BASE = tostring(args.qoder_sash_base.url)
|
||
changed[#changed+1] = "qoder_sash_base.url"
|
||
end
|
||
if type(args.lobster_activity_base) == "table" and args.lobster_activity_base.url then
|
||
LOBSTERAI_ACTIVITY_BASE = tostring(args.lobster_activity_base.url)
|
||
changed[#changed+1] = "lobster_activity_base.url"
|
||
end
|
||
-- codearts_sts:CodeArts 的华为 STS token 端点(PKCE 换 AK/SK 用)。
|
||
-- 测试把它指到假上游,才能端到端验证 code 交换而不打真华为云。
|
||
if type(args.codearts_sts) == "table" and args.codearts_sts.url then
|
||
CODEARTS.sts_token = tostring(args.codearts_sts.url)
|
||
changed[#changed+1] = "codearts_sts.url"
|
||
end
|
||
if type(args.client_version) == "table" and plat.client_version then
|
||
for _, k in ipairs({ "url", "path", "fallback" }) do
|
||
if args.client_version[k] then
|
||
plat.client_version[k] = tostring(args.client_version[k])
|
||
changed[#changed+1] = "client_version." .. k
|
||
end
|
||
end
|
||
plugin.state.clientVersion = nil
|
||
end
|
||
if type(args.models_remote) == "table" and plat.models_remote then
|
||
-- 测试台/运维把远端目录端点重定向(键形如 endpoint0/endpoint1 对应该平台的
|
||
-- 第 N 个端点)。生产上不该改,但没有它就无法在无网环境下断言解析口径。
|
||
for i, ep in ipairs(plat.models_remote.endpoints or {}) do
|
||
local v = args.models_remote["endpoint" .. (i - 1)]
|
||
if v then
|
||
ep.url = tostring(v)
|
||
changed[#changed+1] = "models_remote.endpoint" .. (i - 1)
|
||
end
|
||
end
|
||
end
|
||
if type(args.qr) == "table" and plat.qr then
|
||
for _, k in ipairs({ "api_base", "auth_prefix", "poll_path", "login_page", "app_name" }) do
|
||
if args.qr[k] then
|
||
plat.qr[k] = tostring(args.qr[k])
|
||
changed[#changed+1] = "qr." .. k
|
||
end
|
||
end
|
||
end
|
||
if type(args.wechat) == "table" and plat.wechat then
|
||
for _, k in ipairs({ "auth_url", "qr_url", "poll_url", "account_base",
|
||
"bind_path", "user_agent", "access_key_id", "access_key_secret", "app_id" }) do
|
||
if args.wechat[k] then
|
||
plat.wechat[k] = tostring(args.wechat[k])
|
||
changed[#changed+1] = "wechat." .. k
|
||
end
|
||
end
|
||
end
|
||
if type(args.initpoll) == "table" and plat.initpoll then
|
||
for _, k in ipairs({ "init_url", "poll_url", "provider", "origin" }) do
|
||
if args.initpoll[k] then
|
||
plat.initpoll[k] = tostring(args.initpoll[k])
|
||
changed[#changed+1] = "initpoll." .. k
|
||
end
|
||
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.register) == "table" and plat.register then
|
||
for _, k in ipairs({ "url" }) do
|
||
if args.register[k] then plat.register[k] = tostring(args.register[k]) changed[#changed+1] = "register." .. k end
|
||
end
|
||
end
|
||
if type(args.state) == "table" and plat.state then
|
||
for _, k in ipairs({ "state_url", "token_url", "account_url" }) do
|
||
if args.state[k] then plat.state[k] = tostring(args.state[k]) changed[#changed+1] = "state." .. 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
|
||
-- checkin_status / balance:声明式端点覆盖。参考把各平台的计费/状态端点当
|
||
-- 产品配置,运维要能整体改指(自建代理、镜像、测试假上游)。此前只覆盖了
|
||
-- 旧的 plat.checkin,buddy 这类"声明 checkin_status/balance"的平台无从覆盖 ——
|
||
-- 自动化测试也就无法把它们的端点指到假上游。
|
||
if type(args.checkin_status) == "table" and plat.checkin_status then
|
||
for _, k in ipairs({ "url", "method", "body" }) do
|
||
if args.checkin_status[k] then
|
||
plat.checkin_status[k] = tostring(args.checkin_status[k])
|
||
changed[#changed+1] = "checkin_status." .. k
|
||
end
|
||
end
|
||
end
|
||
if type(args.balance) == "table" and plat.balance then
|
||
for _, k in ipairs({ "url", "method", "body" }) do
|
||
if args.balance[k] then
|
||
plat.balance[k] = tostring(args.balance[k])
|
||
changed[#changed+1] = "balance." .. k
|
||
end
|
||
end
|
||
end
|
||
if type(args.callback) == "table" and plat.callback then
|
||
for _, k in ipairs({ "exchange_url" }) do
|
||
if args.callback[k] then plat.callback[k] = tostring(args.callback[k]) changed[#changed+1] = "callback." .. 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(allPlatforms()) do
|
||
local acct = accountOf(p.id) or {}
|
||
local has = credentialOf("channels:" .. p.id)
|
||
out[#out + 1] = {
|
||
id = p.id, name = p.name, flow = p.flow,
|
||
logged_in = has ~= nil,
|
||
account = acct.account,
|
||
enabled = acct.enabled,
|
||
last_claim = acct.last_claim,
|
||
}
|
||
end
|
||
return { status = 200, json = { platforms = out } }
|
||
end
|
||
|
||
-- inject_platforms:**运行时**注入平台定义(测试夹具 / 运维扩展)。
|
||
--
|
||
-- 为何存在:测试夹具(打 example.com 的假平台、验证各 flow 的占位平台)
|
||
-- 绝不能写成生产源码 —— PLATFORMS 会被 UI/status/list_platforms 直接展示,
|
||
-- 夹具内联进去就是「生产 UI 里实际出现 4 个假平台」(真实踩过)。
|
||
-- 正确分层:生产目录只留真实厂商,夹具由测试运行时注入。
|
||
--
|
||
-- 语义:整表替换(与 set_endpoints 的按字段覆盖不同)—— 注入是声明式的
|
||
-- “这就是本轮我要用的目录”,避免测试之间残留状态。传空数组即清空。
|
||
-- 容量上限:注入属于运维/测试能力,不承担生产目录职责,限 32 条够用。
|
||
local INJECT_PLATFORM_LIMIT = 32
|
||
-- ============================================================================
|
||
-- 服务端浏览器栈的运维面(依赖预检 / 取凭据 / 停栈)
|
||
-- ============================================================================
|
||
|
||
-- deps_status:如实报告浏览器栈缺什么依赖。插件**不擅自**安装系统包(需要 root
|
||
-- 且会改动运维的机器);如实报告 + 给出安装命令,由运维或 deploy.sh 执行。
|
||
function plugin.deps_status(req)
|
||
local d = browserDeps()
|
||
local list = {}
|
||
for _, m in ipairs(d.missing) do
|
||
list[#list + 1] = { bin = m.bin, pkg = m.pkg, why = m.why }
|
||
end
|
||
return { status = 200, json = {
|
||
ok = d.ok,
|
||
missing = list,
|
||
present = d.present,
|
||
chromium = d.chromium,
|
||
novnc = d.novnc,
|
||
-- 安装入口:发行包自带脚本(识别 apt/dnf/yum/apk),插件**不执行**它
|
||
-- (装系统包需要 root,且 chromium 是安全面较大的组件,不该由插件代劳)。
|
||
install_hint = d.ok and "" or
|
||
"sudo bash /usr/share/llmsproxy/install-browser-stack.sh"
|
||
.. " 或沿源码部署时: sudo bash packaging/install-browser-stack.sh"
|
||
.. "(脚本会自动识别 apt/dnf/yum/apk;也可手工装 Xvfb x11vnc websockify noVNC chromium python3-websocket)",
|
||
} }
|
||
end
|
||
|
||
-- collect_credentials:从**运行中的**服务端浏览器取凭据(CDP)。
|
||
--
|
||
-- 为什么需要它:VNC 只把浏览器呈现给人,登录完成后必须把结果**取出来**才能
|
||
-- 变成上游源。取法分两种:
|
||
-- ① 平台声明了 collect_expr —— 在该页上求值该 JS(返回 token 字符串或对象)
|
||
-- ② 未声明 —— 回退到通用提取:localStorage / sessionStorage / cookie 全量
|
||
-- 查到什么存什么,由调用方/运维判断哪个是凭据(不猜语义,不编造)。
|
||
-- 取到的凭据经 complete_login 走正常入库路径(含 account_id → 池路径)。
|
||
function plugin.collect_credentials(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 b = browserStack()
|
||
if not b.session then return { status = 400, json = { error = "没有运行中的浏览器会话" } } end
|
||
local cdp = b.session.cdp
|
||
if not cdp then return { status = 400, json = { error = "浏览器会话未启用 CDP" } } end
|
||
|
||
local expr = (args and args.expr) or plat.collect_expr
|
||
local value, err
|
||
if expr then
|
||
value, err = cdpEval(cdp, expr)
|
||
if err then return { status = 400, json = { error = "取值失败: " .. err } } end
|
||
end
|
||
|
||
-- 通用提取:无论有无 collect_expr 都跑一遍,作为兜底与补充。
|
||
local generic, gerr = cdpEval(cdp, [[
|
||
(function(){
|
||
var out = {localStorage:{}, sessionStorage:{}, cookie:document.cookie, url:location.href};
|
||
try { for (var i=0;i<localStorage.length;i++){var k=localStorage.key(i);out.localStorage[k]=localStorage.getItem(k);} } catch(e){}
|
||
try { for (var i=0;i<sessionStorage.length;i++){var k=sessionStorage.key(i);out.sessionStorage[k]=sessionStorage.getItem(k);} } catch(e){}
|
||
return JSON.stringify(out);
|
||
})()
|
||
]])
|
||
|
||
local rec = {}
|
||
if value ~= nil then
|
||
if type(value) == "table" then
|
||
for k, v in pairs(value) do rec[k] = v end
|
||
else
|
||
rec.collected = value
|
||
end
|
||
end
|
||
if generic then
|
||
local ok, parsed = pcall(json.decode, generic)
|
||
if ok and type(parsed) == "table" then
|
||
rec.browser = parsed
|
||
-- 常见位置的启发式提取(找到就用,找不到不强求)
|
||
local ls = parsed.localStorage or {}
|
||
for _, k in ipairs({ "token", "access_token", "accessToken", "auth_token", "jwt", "id_token", "Authorization" }) do
|
||
if type(ls[k]) == "string" and ls[k] ~= "" and rec.token == nil then rec.token = ls[k] end
|
||
end
|
||
local ss = parsed.sessionStorage or {}
|
||
for _, k in ipairs({ "token", "access_token", "accessToken", "auth_token", "jwt", "id_token" }) do
|
||
if type(ss[k]) == "string" and ss[k] ~= "" and rec.token == nil then rec.token = ss[k] end
|
||
end
|
||
if rec.token == nil and type(parsed.cookie) == "string" then
|
||
rec.token = parsed.cookie:match("(access_token=[^;]+)")
|
||
or parsed.cookie:match("(token=[^;]+)")
|
||
end
|
||
end
|
||
elseif gerr then
|
||
rec.generic_error = gerr
|
||
end
|
||
|
||
if next(rec) == nil then
|
||
return { status = 200, json = { ok = false, error = "没有取到任何凭据(登录是否真的完成了?)" } }
|
||
end
|
||
-- 不自动入库:取到什么原样返回,由调用方确认后再 complete_login。
|
||
-- 自动写入会把"页面上恰好存在的某个 token"当成凭据,那是最糟的猜测。
|
||
return { status = 200, json = { ok = true, collected = rec } }
|
||
end
|
||
|
||
-- stop_browser:回收服务端浏览器栈(会话结束/用户放弃时)。
|
||
function plugin.stop_browser(req)
|
||
local had = browserStack().session ~= nil
|
||
reapBrowser()
|
||
browserStack().owner = nil
|
||
if had then logEvent("服务端浏览器已回收") end
|
||
return { status = 200, json = { ok = true, stopped = had } }
|
||
end
|
||
|
||
-- browser_status:当前浏览器会话状态(给 UI 显示)。
|
||
function plugin.browser_status(req)
|
||
local b = browserStack()
|
||
local s = b.session
|
||
return { status = 200, json = { ok = true, running = s ~= nil, session = s } }
|
||
end
|
||
|
||
function plugin.inject_platforms(req)
|
||
local args = bodyOf(req)
|
||
if args.platforms == nil then args = req end
|
||
local list = args and (args.platforms or args.items)
|
||
if type(list) ~= "table" then
|
||
return { status = 400, json = { error = "platforms 必须是数组" } }
|
||
end
|
||
if #list > INJECT_PLATFORM_LIMIT then
|
||
return { status = 400, json = { error = "最多注入 " .. INJECT_PLATFORM_LIMIT .. " 个平台" } }
|
||
end
|
||
-- 校验:每条必须有 id,且不得与内建目录重名(注入不能劫持真实厂商)。
|
||
local builtin = {}
|
||
for _, p in ipairs(PLATFORMS) do builtin[p.id] = true end
|
||
local clean, seen = {}, {}
|
||
for _, p in ipairs(list) do
|
||
if type(p) ~= "table" or type(p.id) ~= "string" or p.id == "" then
|
||
return { status = 400, json = { error = "每个平台必须有 id" } }
|
||
end
|
||
if builtin[p.id] then
|
||
return { status = 400, json = { error = "不得覆盖内建平台: " .. p.id } }
|
||
end
|
||
if seen[p.id] then
|
||
return { status = 400, json = { error = "重复的 id: " .. p.id } }
|
||
end
|
||
seen[p.id] = true
|
||
clean[#clean + 1] = p
|
||
end
|
||
plugin.state.injected_platforms = clean
|
||
markDirty()
|
||
if #clean == 0 then
|
||
logEvent("已清空注入平台")
|
||
else
|
||
local ids = {}
|
||
for _, p in ipairs(clean) do ids[#ids + 1] = p.id end
|
||
logEvent("已注入平台 " .. #clean .. " 个: " .. table.concat(ids, ","))
|
||
end
|
||
return { status = 200, json = { ok = true, injected = #clean } }
|
||
end
|
||
|
||
function plugin.status_page(req)
|
||
local r = __llmsproxy_kernel.call("status", {})
|
||
return { status = 200, json = {
|
||
accounts = plugin.state.accounts,
|
||
log = plugin.state.log,
|
||
kernel_ready = r and r.ready or false,
|
||
} }
|
||
end
|
||
|
||
-- oauth_callback 处理平台重定向:校验 nonce,然后按平台的 callback 模式解析。
|
||
--
|
||
-- nonce 校验是必需的:没有它,别人构造一个回调就能把**自己的**凭据塞进
|
||
-- 这个网关的登录流程。
|
||
--
|
||
-- callback 模式(platform.callback.mode)决定如何从回调 query 解出凭据:
|
||
-- nil/"code" 标准 OAuth:直接把 code 当凭据交给 complete_login。
|
||
-- "trae" 字节 TRAE 的 token 直传形态,解析逐字对齐参考实现
|
||
-- parseTraeCallbackDetailed + exchangeTraeCallback(见 traeCallback)。
|
||
function plugin.oauth_callback(req)
|
||
local nonce = req.query and req.query.state
|
||
if not nonce or nonce == "" then
|
||
return { status = 400, body = "missing state" }
|
||
end
|
||
sweepSessions()
|
||
local s = nil
|
||
for _, sess in pairs(sessions()) do
|
||
if sess.nonce == nonce then s = sess break end
|
||
end
|
||
if not s then
|
||
return { status = 400, body = "unknown or expired login session" }
|
||
end
|
||
if s.flow ~= "callback" then
|
||
return { status = 400, body = "session is not a callback flow" }
|
||
end
|
||
if s.state ~= "pending" then
|
||
return { status = 409, body = "login session already consumed" }
|
||
end
|
||
local plat = platformById(s.platform)
|
||
local cb = plat and plat.callback or {}
|
||
local mode = cb.mode or "code"
|
||
|
||
local credential, account, cerr
|
||
if mode == "trae" then
|
||
credential, account, cerr = traeCallback(plat, req.query, s)
|
||
elseif mode == "code" and s.platform == "lobsterai" then
|
||
-- 与 poll_login 的 loopback 路径共用同一个交换助手,避免两条路漂移。
|
||
credential, account, cerr = lobsteraiExchange(plat, req.query.authCode or req.query.code, s)
|
||
elseif mode == "code" and cb.token_url then
|
||
-- 标准 OAuth code → token 交换(Gemini / LobsterAI)。凭据是交换回的 access_token。
|
||
local code = req.query.code
|
||
if not code or code == "" then
|
||
cerr = "missing code"
|
||
else
|
||
local body = (cb.token_body or "")
|
||
:gsub("{code}", code)
|
||
:gsub("{verifier}", s.device_verifier or "")
|
||
-- {now}/{uuid}:LobsterAI 的交换体要求这两个字段(照参考
|
||
-- exchangeLobsteraiAuthCode:latestKeyfrom 取当前时刻、uuid 来自会话)。
|
||
:gsub("{now}", tostring(os.time and os.time() * 1000 or 0))
|
||
:gsub("{uuid}", s.nonce or "")
|
||
-- {port}:Gemini 的 redirect_uri 参与 token 交换校验,必须与授权时一致
|
||
:gsub("{port}", tostring(gatewayPort()))
|
||
-- Content-Type 按请求体形态选:LobsterAI 发 JSON,Gemini 发表单。
|
||
local ctype = "application/x-www-form-urlencoded"
|
||
if body:sub(1, 1) == "{" then ctype = "application/json" end
|
||
local hdrs = { ["Content-Type"] = ctype, ["Accept"] = "application/json" }
|
||
-- 平台的匿名头(LobsterAI 的交换端点虽不需 Authorization,但要 UA)
|
||
if type(plat.callback_headers) == "table" then
|
||
for k, v in pairs(plat.callback_headers) do hdrs[k] = v end
|
||
end
|
||
local resp, err = __llmsproxy_http({
|
||
url = cb.token_url,
|
||
method = "POST",
|
||
headers = hdrs,
|
||
body = body,
|
||
})
|
||
if err then
|
||
cerr = "token 交换失败: " .. err
|
||
elseif resp.status ~= 200 then
|
||
cerr = "token 交换失败(HTTP " .. tostring(resp.status) .. ")"
|
||
else
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" or not rec.access_token then
|
||
cerr = "token 交换响应缺少 access_token"
|
||
else
|
||
credential = rec.access_token
|
||
account = "oauth"
|
||
end
|
||
end
|
||
end
|
||
else
|
||
credential = req.query.code
|
||
account = "oauth"
|
||
if not credential or credential == "" then cerr = "missing code" end
|
||
end
|
||
if cerr then
|
||
s.state = "failed"
|
||
s.message = cerr
|
||
return { status = 400, body = cerr }
|
||
end
|
||
|
||
-- 与 poll_login 的两条完成分支同一纪律:页面流程先建了占位账号
|
||
-- (credentialRef=channels:<plat>:<aid>),完成时必须带 account_id,
|
||
-- 否则凭据落到与占位账号无关的 `channels:<plat>` 键 → 池账号永远
|
||
-- has_credential=false(同一缺陷在 callback 路径的漏网)。
|
||
local res = plugin.complete_login({ platform = s.platform, credential = credential,
|
||
account = account, account_id = s.account_id })
|
||
if res.json and res.json.error then
|
||
return { status = 400, body = res.json.error }
|
||
end
|
||
s.state = "completed"
|
||
s.account = account
|
||
return { status = 200, body = "ok" }
|
||
end
|
||
|
||
-- traeCallback 解析 TRAE 的 token 直传回调并(必要时)走 ExchangeToken。
|
||
-- 返回 (credential, account, err)。
|
||
--
|
||
-- credential 是 **JSON**(不是裸 access token):device_id 必须随凭据保存,
|
||
-- 因为签到要发 `X-Device-Id`,且该值限定为「每账号互不相同」(同一天两账号
|
||
-- 共用会被「该设备已签到」拦,业务码 9074 的限流范围是 device_id 而非账号)。
|
||
-- 参考 TraeCredential 即含 deviceId/machineId。凭据的取用端 credentialOf 会
|
||
-- 从中取 access_token,故包成 JSON 不影响上游源。
|
||
--
|
||
-- 行为逐行对齐参考实现 trae-oauth.ts:
|
||
-- refreshToken(query 或 userJwt.RefreshToken 回退)存在 → ExchangeToken 换新
|
||
-- access(并轮换 refreshToken);凭据 = 新 access。
|
||
-- 无 refreshToken 但有 userJwt.Token → 直接用它兜底(不走 ExchangeToken)。
|
||
-- 二者皆无、但有 code/authCodeInfo → **PKCE 流**,明确报"暂不支持"而非含糊失败。
|
||
-- 都没有 → 回调无效。
|
||
local function jsonField(raw, key)
|
||
if not raw or raw == "" then return "" end
|
||
local ok, v = pcall(json.decode, raw)
|
||
if not ok or type(v) ~= "table" then return "" end
|
||
return v[key] or ""
|
||
end
|
||
|
||
traeCallback = function(plat, q, s)
|
||
q = q or {}
|
||
local userJwt = q.userJwt or ""
|
||
local refreshToken = q.refreshToken
|
||
if not refreshToken or refreshToken == "" then
|
||
refreshToken = jsonField(userJwt, "RefreshToken")
|
||
end
|
||
local jwtToken = jsonField(userJwt, "Token")
|
||
-- uid 与昵称都必须从 userInfo 取:uid 是签到设备身份的**派生种子**
|
||
-- (参考 trae-oauth.ts:回调字段名是 UserID,不是 user_id);
|
||
-- 昵称需经乱码修复(中文实测会变成 Óû§8847309959 这类形态)。
|
||
local userInfo = q.userInfo or ""
|
||
local uid = jsonField(userInfo, "UserID")
|
||
local enterpriseId = jsonField(userInfo, "TenantID")
|
||
local account = traeFixNickname(jsonField(userInfo, "ScreenName"), uid)
|
||
if account == "" then account = "trae" end
|
||
|
||
-- PKCE 形态探测:仅在 refreshToken / jwtToken 都空时才判,与参考一致。
|
||
local authCode = q.code or q.authCode or jsonField(q.authCodeInfo or "", "code")
|
||
|
||
if (not refreshToken or refreshToken == "") and (not jwtToken or jwtToken == "") then
|
||
if authCode and authCode ~= "" then
|
||
return nil, nil, "上游返回了 PKCE 授权码(code/authCodeInfo),本实现暂不支持该流程"
|
||
end
|
||
return nil, nil, "回调未携带 refreshToken / userJwt.Token / code"
|
||
end
|
||
|
||
-- 凭据包成 JSON:device_id/machine_id 必须留存供签到用(见函数注释)。
|
||
local function packTrae(access)
|
||
local cred = { access_token = access, uid = uid, enterprise_id = enterpriseId,
|
||
nickname = account }
|
||
if s then
|
||
cred.device_id = s.device_id
|
||
cred.machine_id = s.machine_id
|
||
cred.login_trace_id = s.login_trace_id
|
||
end
|
||
return json.encode(cred)
|
||
end
|
||
|
||
-- 分支 1:有 refreshToken → ExchangeToken 换新 access(轮换 refresh)
|
||
if refreshToken and refreshToken ~= "" and plat.callback.exchange_url then
|
||
local body = (plat.callback.exchange_body
|
||
:gsub("{client_id}", plat.callback.client_id or "")
|
||
:gsub("{refresh_token}", refreshToken))
|
||
local resp, err = __llmsproxy_http({
|
||
url = plat.callback.exchange_url,
|
||
method = "POST",
|
||
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
|
||
body = body,
|
||
})
|
||
if err then return nil, nil, "TRAE ExchangeToken 失败: " .. err end
|
||
if resp.status ~= 200 then
|
||
return nil, nil, "TRAE ExchangeToken HTTP " .. tostring(resp.status)
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then
|
||
return nil, nil, "TRAE ExchangeToken 响应无法解析"
|
||
end
|
||
local access = rec.Token or rec.token or rec.AccessToken or rec.access_token
|
||
if not access or access == "" then
|
||
return nil, nil, "TRAE ExchangeToken 响应缺少 Token"
|
||
end
|
||
return packTrae(access), account, nil
|
||
end
|
||
|
||
-- 分支 2:无 refreshToken,用 userJwt.Token 兜底
|
||
if jwtToken and jwtToken ~= "" then return packTrae(jwtToken), account, nil end
|
||
return nil, nil, "回调未携带可用的 token"
|
||
end
|
||
|
||
-- traeDeriveStream(seed, salt, nbytes):SHA-256 确定性伪随机流(返回 hex 串)。
|
||
--
|
||
-- 逐行对齐参考 seededStream:`SHA256(utf8(salt:seed) ++ counterBE32)` 串联,
|
||
-- counter 从 0 递增,取前 nbytes 字节。同样的 (seed,salt) 永远得到同样的字节。
|
||
-- 内核只给 sha256_hex(字符串),没有二进制原语,故自己拼大端 counter:
|
||
-- 把计数器按 4 字节大端拆成字符,先 tohex 再转义成逐字节的字面量。
|
||
local function traeDeriveStream(seed, salt, nbytes)
|
||
local prefix = tostring(salt) .. ":" .. tostring(seed)
|
||
local out, counter = "", 0
|
||
while #out < nbytes * 2 do
|
||
-- counter 是大端 4 字节;counter=0 时就是 4 个 NUL。
|
||
-- 必须用 string.char 造**真字节**:内核的 sha256_hex 经 GoStringN 按长度取串,
|
||
-- NUL 会原样保留。曾经试过用 `\\ddd` 转义串,那是错的 —— Lua 不会对
|
||
-- 运行时拼接的字符串重新词法分析,于是哈希到的是「反斜杠+数字」文本。
|
||
local be = string.char(
|
||
math.floor(counter / 16777216) % 256, math.floor(counter / 65536) % 256,
|
||
math.floor(counter / 256) % 256, counter % 256)
|
||
out = out .. sha256_hex(prefix .. be)
|
||
counter = counter + 1
|
||
end
|
||
return out:sub(1, nbytes * 2)
|
||
end
|
||
|
||
-- traeDeriveDeviceId15 / traeDeriveMarketUserId / traeDeriveSessionId:
|
||
-- 基于 user_id 的确定性设备身份。参考注释点明:每个账号天然不同设备,
|
||
-- 所以签到**不再需要**在遇到 9074 时换设备号重试(早期做法已废弃)。
|
||
traeDeriveDeviceId15 = function(userId)
|
||
local hex = traeDeriveStream(userId, "devid", 15)
|
||
local out = {}
|
||
for i = 1, 15 do
|
||
-- 按字节取模 10:两 hex 字符 = 1 字节
|
||
local b = tonumber(hex:sub((i - 1) * 2 + 1, i * 2), 16)
|
||
out[#out + 1] = tostring(b % 10)
|
||
end
|
||
return table.concat(out)
|
||
end
|
||
|
||
traeDeriveMarketUserId = function(userId)
|
||
local h = traeDeriveStream(userId, "market", 16)
|
||
local bs = {}
|
||
for i = 1, 16 do bs[i] = tonumber(h:sub((i - 1) * 2 + 1, i * 2), 16) end
|
||
bs[7] = (bs[7] % 16) + 64 -- version 4(bs 下标从 1 起,故是第 7 个)
|
||
bs[9] = (bs[9] % 64) + 128 -- variant RFC 4122
|
||
local hx = {}
|
||
for i = 1, 16 do hx[i] = ("%02x"):format(bs[i]) end
|
||
local s = table.concat(hx)
|
||
return s:sub(1, 8) .. "-" .. s:sub(9, 12) .. "-" .. s:sub(13, 16)
|
||
.. "-" .. s:sub(17, 20) .. "-" .. s:sub(21, 32)
|
||
end
|
||
|
||
traeDeriveSessionId = function(userId)
|
||
return traeDeriveStream(userId, "sess", 32)
|
||
end
|
||
|
||
-- utf8Encode(cp):码点 → UTF-8 字节串(Lua 5.1 无 utf8.char)。
|
||
local function utf8Encode(cp)
|
||
if cp < 0x80 then return string.char(cp) end
|
||
if cp < 0x800 then
|
||
return string.char(0xC0 + math.floor(cp / 64), 0x80 + cp % 64)
|
||
end
|
||
if cp < 0x10000 then
|
||
return string.char(0xE0 + math.floor(cp / 4096),
|
||
0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
|
||
end
|
||
return string.char(0xF0 + math.floor(cp / 262144),
|
||
0x80 + math.floor(cp / 4096) % 64, 0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
|
||
end
|
||
|
||
-- latin1ToUtf8(s):把「每字符=一原始字节」的串重按 UTF-8 解一次。
|
||
--
|
||
-- 这是参考 fixNicknameMojibake 里 `Buffer.from(raw,'latin1').toString('utf8')`
|
||
-- 的等价物:TRAE 回调的中文被错按 latin-1 解了一次,实测昵称变成
|
||
-- `Óû§8847309959` 这类乱码。返回 (修复串, true) 或 (nil, false)。
|
||
local function latin1ToUtf8(s)
|
||
local out, i, n = {}, 1, #s
|
||
while i <= n do
|
||
local b = string.byte(s, i)
|
||
if not b then break end
|
||
local cp, len
|
||
if b < 0x80 then cp, len = b, 1
|
||
elseif b >= 0xC2 and b <= 0xDF then cp, len = b - 0xC0, 2
|
||
elseif b >= 0xE0 and b <= 0xEF then cp, len = b - 0xE0, 3
|
||
elseif b >= 0xF0 and b <= 0xF4 then cp, len = b - 0xF0, 4
|
||
else return nil, false end
|
||
if i + len - 1 > n then return nil, false end
|
||
for k = 1, len - 1 do
|
||
local cb = string.byte(s, i + k)
|
||
if not cb or cb < 0x80 or cb > 0xBF then return nil, false end
|
||
cp = cp * 64 + (cb - 0x80)
|
||
end
|
||
-- 拒绝非最短编码、代理区与越界(否则会把垃圾字节当合法字符)
|
||
if (len == 2 and cp < 0x80) or (len == 3 and cp < 0x800)
|
||
or (len == 4 and cp < 0x10000) or cp > 0x10FFFF
|
||
or (cp >= 0xD800 and cp <= 0xDFFF) then
|
||
return nil, false
|
||
end
|
||
out[#out + 1] = utf8Encode(cp)
|
||
i = i + len
|
||
end
|
||
return table.concat(out), true
|
||
end
|
||
|
||
-- hasCJK(s):是否含 CJK 汉字(UTF-8 三字节,U+4E00–U+9FFF)。
|
||
-- 照参考 `/\u4e00-\u9fff/`:无 CJK 则判乱码,回退可读占位。
|
||
local function hasCJK(s)
|
||
if type(s) ~= "string" then return false end
|
||
local i = 1
|
||
while i <= #s do
|
||
local b = string.byte(s, i)
|
||
if b and b >= 0xE4 and b <= 0xE9 then
|
||
local b2, b3 = string.byte(s, i + 1), string.byte(s, i + 2)
|
||
if b2 and b3 and b2 >= 0x80 and b2 <= 0xBF and b3 >= 0x80 and b3 <= 0xBF then
|
||
local cp = (b - 0xE0) * 4096 + (b2 - 0x80) * 64 + (b3 - 0x80)
|
||
if cp >= 0x4E00 and cp <= 0x9FFF then return true end
|
||
end
|
||
end
|
||
i = i + 1
|
||
end
|
||
return false
|
||
end
|
||
|
||
-- traeFixNickname(raw, uid):照参考 fixNicknameMojibake 的两级处理。
|
||
-- ① 先试 latin1→utf8 回转(结果含 CJK 才采用);
|
||
-- ② 不行且无 CJK → 「用户+uid末4位」占位(绝不把乱码写进凭据)。
|
||
traeFixNickname = function(raw, uid)
|
||
if raw == nil or raw == "" then return raw or "" end
|
||
local fixed, ok = latin1ToUtf8(raw)
|
||
if ok and fixed ~= "" and hasCJK(fixed) then return fixed end
|
||
if not hasCJK(raw) then
|
||
return "\231\148\168\230\136\183" .. tostring(uid):sub(-4)
|
||
end
|
||
return raw
|
||
end
|
||
|
||
-- traeCheckinHeaders(plat, cred, uid):签到/积分请求头。
|
||
--
|
||
-- 参考注释点明:简化头(仅 Authorization + 少量 Ug 头)**实测签到失败**;
|
||
-- 真正成功的是约 20 个客户端头。关键差别:X-Device-Id 用的是
|
||
-- **基于 user_id 确定性派生的 15 位数字**(不是凭据里的 hex32)。
|
||
-- 授权方案是 `Cloud-IDE-JWT`(不是 Bearer)—— 用错前缀是 401 级失败。
|
||
-- 依赖:plat.checkin.auth 声明方案(trae 声明 cloud-ide-jwt)。
|
||
traeCheckinHeaders = function(plat, cred, uid)
|
||
uid = uid or cred.uid or ""
|
||
if uid == "" then return nil, "缺少 user_id,无法构造设备身份" end
|
||
local h = {
|
||
["Authorization"] = "Cloud-IDE-JWT " .. tostring(cred.access_token or ""),
|
||
["Content-Type"] = "application/json",
|
||
["Accept"] = "*/*",
|
||
["Accept-Language"] = "zh-CN",
|
||
["User-Agent"] = "VSCode 1.107.1 (TRAE SOLO CN)",
|
||
["X-Market-Client-Id"] = "VSCode 1.107.1",
|
||
["X-Market-User-Id"] = traeDeriveMarketUserId(uid),
|
||
["X-User-Region"] = "CN",
|
||
["X-Device-Id"] = traeDeriveDeviceId15(uid),
|
||
["X-Lgw-Req-Sdk-Type"] = "3",
|
||
["Package-Type"] = "stable_cn",
|
||
["X-Lscbd-Aid"] = "787976",
|
||
["X-Lscbd-Platform"] = "windows",
|
||
["App-Version"] = plat.ide_version or "0.1.52",
|
||
["Vscode-Sessionid"] = traeDeriveSessionId(uid),
|
||
["Sec-Fetch-Dest"] = "empty",
|
||
}
|
||
-- 每次请求独立的 trace/request id(形如 00-<hex16>-01 / uuid v4)
|
||
h["X-Tt-Trace-Id"] = "00-" .. newToken():sub(1, 16) .. "-01"
|
||
local r = newToken()
|
||
h["X-Request-Id"] = r:sub(1, 8) .. "-" .. r:sub(9, 12) .. "-4" .. r:sub(14, 16)
|
||
.. "-8" .. r:sub(18, 20) .. "-" .. r:sub(21, 32)
|
||
return h, nil
|
||
end
|
||
|
||
-- 表驱动签到推广到需自定义头的平台:在 PLATFORMS 定义后绑定。
|
||
--
|
||
-- credentialRef 是「秘密引用」,creds 是插件自解密后的 JSON(最可靠)。
|
||
do
|
||
for _, p in ipairs(PLATFORMS) do
|
||
if p.checkin and p.checkin.auth == "cloud-ide-jwt" then
|
||
p.claim_fn = function(plat, tok, args)
|
||
local cred = (args and args.credential) or { access_token = tok }
|
||
local uid = (args and args.uid) or cred.uid
|
||
or (type(cred.user_id) == "string" and cred.user_id) or ""
|
||
local h, herr = traeCheckinHeaders(plat, cred, uid)
|
||
if herr then return nil, herr end
|
||
return { url = plat.checkin.url, method = plat.checkin.method or "POST",
|
||
headers = h, body = plat.checkin.body or "{}" }
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- plugin.__qoder_machine_probe:只给测试用 —— 走真实的 headers_fn 路径,
|
||
-- 返回它给出的设备身份头,便于验证「成对出现」与「拿不到时降级」。
|
||
-- plugin.__codearts_dpop_probe:只给测试用 —— 生成一把 DPoP 密钥并签一个
|
||
-- 真实 JWS,供判据用标准 ES256 验签(验证 DER→raw 转换与 header/payload 形状)。
|
||
-- ============================================================================
|
||
-- CodeArts 专用适配器(插件在加载时**自己装**)
|
||
-- ============================================================================
|
||
-- 为什么需要它:CodeArts 的推理端点要 **华为云 AK/SK 签名**
|
||
-- (SDK-HMAC-SHA256),且签名必须覆盖 method/uri/query/canonical-headers/
|
||
-- payload-hash —— 这不是"改个 endpoint 或加个静态头"能表达的,通用 openai
|
||
-- 适配器做不了。
|
||
--
|
||
-- 为什么由插件装而不是做成内置适配器:CodeArts 的接入是本插件引入的能力
|
||
-- (登录 + 凭据 + 源注册都在这里),且内核纪律是"零厂商名"——内置适配器
|
||
-- 里出现 codearts 就破了这条线。插件自装正好把厂商知识留在插件域。
|
||
--
|
||
-- 与内置适配器的差别(照参考 llm-adapter.ts 的 CodeArtsAdapter):
|
||
-- * build_headers 动态签名(凭据从 meta.api_key 解出 AK/SK/ST)
|
||
-- * 端点是 /api/v2/chat/completions(含 /api/v2 前缀)
|
||
-- * Agent-Type: PromptCenter / X-Language: zh-cn(**签名后**追加,
|
||
-- 不参与 canonical —— 参考注释点名)
|
||
local CODEARTS_ADAPTER_SRC = [==[
|
||
local adapter = {}
|
||
adapter.name = "codearts"
|
||
adapter.version = "1.0.0"
|
||
-- ⚠️ 源注册时会用平台声明的 endpoint 覆盖;这里保持一致以免两处漂移。
|
||
adapter.endpoint = "/api/v2/chat/completions"
|
||
|
||
-- 华为云 SDK-HMAC-SHA256 签名。逐条对齐参考 sign.ts:signRequestHuawei:
|
||
-- canonical = METHOD\nURI\nQUERY\nCANONICAL_HEADERS\n\nSIGNED_HEADERS\nPAYLOAD_HASH
|
||
-- string_to_sign = "SDK-HMAC-SHA256\n" .. x-sdk-date .. "\n" .. sha256(canonical)
|
||
-- signature = HMAC-SHA256(SK, string_to_sign)
|
||
-- 三个易错点(参考注释都点名了):
|
||
-- 1. URI 末尾**必须补 /**(否则签名不匹配)
|
||
-- 2. Authorization 用 `Access=` 不是 `Credential=`
|
||
-- 3. Agent-Type / X-Language 是**签名后追加**的,不进 canonical
|
||
-- signed_parts:返回 (headers, canonical, string_to_sign)。
|
||
local function signed_parts(method, url, cred, body)
|
||
local ak = cred.access_key_id or cred.access_key or ""
|
||
local sk = cred.secret_access_key or cred.secret_key or ""
|
||
local st = cred.security_token or ""
|
||
if ak == "" or sk == "" then return nil, "凭据缺少 AK/SK" end
|
||
local host = url:match("^https?://([^/]+)")
|
||
local uri = url:match("^https?://[^/]+(/[^?]*)") or "/"
|
||
if uri:sub(-1) ~= "/" then uri = uri .. "/" end
|
||
local query = url:match("%?([^#]*)$") or ""
|
||
local payload = body or ""
|
||
local payload_hash = sha256_hex(payload)
|
||
local date = os.date("!%Y%m%dT%H%M%SZ")
|
||
|
||
local h = {
|
||
["host"] = host,
|
||
["x-sdk-date"] = date,
|
||
["x-sdk-content-sha256"] = payload_hash,
|
||
}
|
||
if st ~= "" then h["x-security-token"] = st end
|
||
if method:upper() ~= "GET" then h["content-type"] = "application/json" end
|
||
|
||
local keys = {}
|
||
for k in pairs(h) do keys[#keys + 1] = k end
|
||
table.sort(keys)
|
||
local lines, names = {}, {}
|
||
for _, k in ipairs(keys) do
|
||
lines[#lines + 1] = k .. ":" .. h[k]
|
||
names[#names + 1] = k
|
||
end
|
||
local signed = table.concat(names, ";")
|
||
local canonical = table.concat({
|
||
method:upper(), uri, query, table.concat(lines, "\n"), "",
|
||
signed, payload_hash,
|
||
}, "\n")
|
||
local sts = "SDK-HMAC-SHA256\n" .. date .. "\n" .. sha256_hex(canonical)
|
||
local sig = hmac_sha256_hex(sk, sts)
|
||
|
||
local out = {}
|
||
for _, k in ipairs(keys) do
|
||
if k ~= "host" then out[k] = h[k] end
|
||
end
|
||
out["Authorization"] = "SDK-HMAC-SHA256 Access=" .. ak
|
||
.. ",SignedHeaders=" .. signed .. ",Signature=" .. sig
|
||
-- 签名后追加:不参与 canonical(参考注释点名)
|
||
out["Agent-Type"] = "PromptCenter"
|
||
out["X-Language"] = "zh-cn"
|
||
return out, canonical, sts
|
||
end
|
||
|
||
-- 兼容旧调用名(build_headers 只用 headers)
|
||
local function signed_headers(method, url, cred, body)
|
||
local out = signed_parts(method, url, cred, body)
|
||
return out
|
||
end
|
||
|
||
-- sign_parts(method,url,cred,body):把签名**中间值**返回给判据用。
|
||
-- 与 build_headers 共用同一个 signed_headers(不重复实现 —— 判据必须比对
|
||
-- 真实函数算出的 canonical / string_to_sign,自算版会掩盖真实签名被改坏)。
|
||
function adapter.sign_parts(method, url, cred, body)
|
||
local out, canonical, sts = signed_parts(method, url, cred, body)
|
||
if not out then return nil, canonical end
|
||
return out, canonical, sts
|
||
end
|
||
|
||
-- build_headers:内核按源注入 meta{url,method,body,api_key,...}。
|
||
-- 凭据是完整 JSON(登录时存的就是它),从中解出 AK/SK/ST。
|
||
function adapter.build_headers(meta)
|
||
local h = { ["Content-Type"] = "application/json" }
|
||
local raw = tostring(meta.api_key or "")
|
||
local cred = nil
|
||
if raw:sub(1, 1) == "{" then
|
||
local ok, rec = pcall(json.decode, raw)
|
||
if ok and type(rec) == "table" then cred = rec end
|
||
end
|
||
if cred == nil then
|
||
-- 裸 token 形态(兼容旧数据):无法签名,如实说明
|
||
h["X-Channels-Error"] = "codearts 需要完整凭据 JSON(AK/SK),当前是裸 token"
|
||
return h
|
||
end
|
||
local signed, err = signed_headers(meta.method or "POST", meta.url or "", cred, meta.body)
|
||
if not signed then
|
||
h["X-Channels-Error"] = tostring(err)
|
||
return h
|
||
end
|
||
for k, v in pairs(signed) do h[k] = v end
|
||
return h
|
||
end
|
||
|
||
-- 请求体:CodeArts 是标准 OpenAI 兼容,只需剥内核私有字段。
|
||
function adapter.transform_request(raw_body)
|
||
local req = __prelude.decode_body(raw_body)
|
||
if req == nil then return raw_body end
|
||
__prelude.strip_control(req)
|
||
req.model = req.model or "CodeArts-Pangu"
|
||
req.stream = req.stream or false
|
||
return json.encode(req)
|
||
end
|
||
|
||
function adapter.transform_response(raw_body)
|
||
return __prelude.openai_response(raw_body)
|
||
end
|
||
|
||
function adapter.transform_stream_chunk(raw_chunk)
|
||
return __prelude.openai_stream_chunk(raw_chunk)
|
||
end
|
||
|
||
function adapter.transform_error(status, body)
|
||
return __prelude.openai_error(status, body)
|
||
end
|
||
|
||
return adapter
|
||
]==]
|
||
|
||
-- ============================================================================
|
||
-- TRAE 专用适配器(插件自装:channels-trae)
|
||
-- ============================================================================
|
||
-- TRAE **不是 OpenAI 兼容**。参考 trae.ts:30 注释点名:
|
||
-- 「载荷必须转换:OpenAI 的 {model,messages,tools,tool_choice,stream} 需要
|
||
-- 映射为 SOLO 格式(function、config_name 等字段),不能透传」
|
||
--
|
||
-- 为什么名字不叫 "trae":内置适配器 `trae.lua` 服务的是**运维自建的
|
||
-- trae-local-api 反代**(base_url 127.0.0.1:19900,生产 config.yaml 在用),
|
||
-- 与本插件的直连通道是两回事。重名会被内核的冲突仲裁挂起(正确的护栏),
|
||
-- 所以本插件用 `channels-trae`。
|
||
--
|
||
-- SOLO 转换规则(逐条对齐参考 trae.ts:transformToSOLOBody):
|
||
-- 1. messages.content 字符串 → [{type:"text",text:...}];已是数组则透传
|
||
-- 2. stream 强制 true(非流式由服务端聚合)
|
||
-- 3. model → config_name + model **双字段**
|
||
-- 4. function: 通道名,缺省 "solo_work_lite"
|
||
-- 5. tool_choice 归一化("none" 删 tools;auto/required 保留;
|
||
-- {type:"function",function:{name}} → 字符串 name)
|
||
-- 6. assistant 消息的 tool_calls: function → function_call(SOLO 字段名),
|
||
-- 并剔除没有 name 的条目(孤儿会让整条会话被拒)
|
||
-- 7. tools[].function.parameters: object → JSON **字符串**(SOLO 要求)
|
||
local TRAE_SOLO_ADAPTER_SRC = [==[
|
||
local adapter = {}
|
||
adapter.name = "channels-trae"
|
||
adapter.version = "1.0.0"
|
||
adapter.endpoint = "/api/agent/v3/llm_utils_chat"
|
||
|
||
local TRAE_DEFAULT_MODEL = "glm-5.2"
|
||
local TRAE_FUNCTION = "solo_work_lite"
|
||
local TRAE_MAX_MODE_TYPE = 1
|
||
local TRAE_MAX_PROMPT_TOKENS = 936000
|
||
local TRAE_MAX_OUTPUT_TOKENS = 64000
|
||
|
||
-- normalizeToolChoice:照参考 normalizeToolChoice(Go payload.go:111-154)
|
||
local function normalize_tool_choice(body)
|
||
local tc = body.tool_choice
|
||
if tc == nil then return end
|
||
local function suppress()
|
||
body.tools = nil
|
||
body.functions = nil
|
||
end
|
||
if type(tc) == "string" then
|
||
if string.lower(tc):gsub("%s+", "") == "none" then
|
||
body.tool_choice = nil
|
||
suppress()
|
||
end
|
||
return
|
||
end
|
||
if type(tc) == "table" then
|
||
local typ = type(tc.type) == "string" and string.lower(tc.type):gsub("%s+", "") or ""
|
||
if typ == "none" then
|
||
body.tool_choice = nil
|
||
suppress()
|
||
elseif typ == "auto" or typ == "required" then
|
||
body.tool_choice = typ
|
||
elseif typ == "function" then
|
||
local name = ""
|
||
local fn = tc["function"]
|
||
if type(fn) == "table" and type(fn.name) == "string" then name = fn.name end
|
||
if name == "" and type(tc.name) == "string" then name = tc.name end
|
||
if #(name:gsub("%s+", "")) > 0 then body.tool_choice = name
|
||
else body.tool_choice = "auto" end
|
||
else
|
||
body.tool_choice = nil
|
||
end
|
||
return
|
||
end
|
||
body.tool_choice = nil
|
||
end
|
||
|
||
-- normalizeTools:parameters 必须是**字符串**(SOLO 要求),OpenAI 标准是对象。
|
||
local function normalize_tools(body)
|
||
local raw = body.tools
|
||
if type(raw) ~= "table" or #raw == 0 then return end
|
||
local out = {}
|
||
for _, item in ipairs(raw) do
|
||
if type(item) == "table" then
|
||
local fn = item["function"]
|
||
if type(fn) == "table" then
|
||
if type(fn.parameters) == "table" then
|
||
fn.parameters = json.encode(fn.parameters)
|
||
end
|
||
out[#out + 1] = item
|
||
end
|
||
end
|
||
end
|
||
if #out > 0 then body.tools = out end
|
||
end
|
||
|
||
-- transformSOLOMessage:单条消息(content 字符串→parts;tool_calls 字段改名)
|
||
local function transform_solo_message(msg)
|
||
if type(msg) ~= "table" then return msg end
|
||
local result = {}
|
||
for k, v in pairs(msg) do result[k] = v end
|
||
|
||
if result.role == "assistant" and type(result.tool_calls) == "table" then
|
||
local kept = {}
|
||
for _, tc in ipairs(result.tool_calls) do
|
||
if type(tc) == "table" then
|
||
-- function → function_call(SOLO 字段名)
|
||
if type(tc["function"]) == "table" then
|
||
tc.function_call = tc["function"]
|
||
tc["function"] = nil
|
||
end
|
||
local fc = tc.function_call
|
||
local name = (type(fc) == "table" and type(fc.name) == "string") and fc.name or ""
|
||
-- 剔除没有 name 的条目(孤儿 tool_call 会让整条会话被拒)
|
||
if #(name:gsub("%s+", "")) > 0 then kept[#kept + 1] = tc end
|
||
end
|
||
end
|
||
if #kept > 0 then result.tool_calls = kept else result.tool_calls = nil end
|
||
end
|
||
|
||
-- content:字符串 → [{type:"text",text:...}];数组/其它原样透传
|
||
if type(result.content) == "string" then
|
||
result.content = { { type = "text", text = result.content } }
|
||
end
|
||
return result
|
||
end
|
||
|
||
-- transform_request:OpenAI 请求体 → SOLO 格式
|
||
function adapter.transform_request(raw_body)
|
||
local body = __prelude.decode_body(raw_body)
|
||
if body == nil then return raw_body end
|
||
__prelude.strip_control(body)
|
||
|
||
body.stream = true
|
||
-- ⚠️ `function` 是 Lua 关键字:必须用 body["function"],写 body.function
|
||
-- 是语法错误('<name>' expected near 'function'),且只在 load() 时才暴露
|
||
-- —— 模块级 local function 不会在编译期报出来。
|
||
if body["function"] == nil then body["function"] = TRAE_FUNCTION end
|
||
if type(body["function"]) == "string" and body["function"] == "" then
|
||
body["function"] = TRAE_FUNCTION
|
||
end
|
||
|
||
-- messages 转换
|
||
if type(body.messages) == "table" then
|
||
local msgs = {}
|
||
for _, m in ipairs(body.messages) do msgs[#msgs + 1] = transform_solo_message(m) end
|
||
body.messages = msgs
|
||
end
|
||
|
||
-- model → config_name + model(双字段)。支持 __dev 后缀消除。
|
||
local model = type(body.model) == "string" and body.model or ""
|
||
local base = model
|
||
local sep = string.find(model, "__", 1, true)
|
||
if sep then base = string.sub(model, 1, sep - 1) end
|
||
local config_name = (base ~= "" and base) or TRAE_DEFAULT_MODEL
|
||
body.config_name = config_name
|
||
body.model = config_name
|
||
|
||
normalize_tool_choice(body)
|
||
normalize_tools(body)
|
||
return json.encode(body)
|
||
end
|
||
|
||
function adapter.transform_response(raw_body)
|
||
return __prelude.openai_response(raw_body)
|
||
end
|
||
|
||
function adapter.transform_stream_chunk(raw_chunk)
|
||
return __prelude.openai_stream_chunk(raw_chunk)
|
||
end
|
||
|
||
function adapter.transform_error(status, body)
|
||
return __prelude.openai_error(status, body)
|
||
end
|
||
|
||
return adapter
|
||
]==]
|
||
|
||
-- ============================================================================
|
||
-- ============================================================================
|
||
-- loomy 领取:触发每日额度重置(照参考 loomy-credits.ts:claimLoomyDailyQuota)
|
||
-- ============================================================================
|
||
-- ⚠️ 语义是「触发每日额度重置」,**不是**「+5000 积分」:
|
||
-- dailyBalance = dailyQuota - dailyConsumed,消耗后不回补。
|
||
-- ⚠️ 幂等判据是 `alreadyProcessed`(重复调用同样 HTTP 200)→ 映射成
|
||
-- already-claimed,不是 claimed(谎报收益比报错更糟)。
|
||
-- 本函数不抛错:失败也返回 failed,保证批量领取不因单个账号中断。
|
||
function claimLoomy(plat, tok, args)
|
||
local cred = (args and args.credential) or nil
|
||
local base = (cred and (cred.api_base or cred.apiBase)) or "https://loomy.xunfei.cn"
|
||
local root = base:gsub("/chat/completions$", "")
|
||
local url = root .. "/points/first-login"
|
||
local resp, err = __llmsproxy_http({
|
||
url = url, method = "POST",
|
||
headers = {
|
||
["Authorization"] = "Bearer " .. tok,
|
||
["Accept"] = "application/json",
|
||
["Content-Type"] = "application/json",
|
||
},
|
||
body = "{}",
|
||
})
|
||
if err or not resp then
|
||
return { done = true, kind = "failed", message = "领取失败: " .. tostring(err) }
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then rec = {} end
|
||
if resp.status ~= 200 then
|
||
local msg = rec.message or rec.msg or ""
|
||
return { done = true, kind = "failed",
|
||
message = (msg ~= "" and tostring(msg)) or ("HTTP " .. tostring(resp.status)) }
|
||
end
|
||
local data = rec.data or rec
|
||
if type(data) ~= "table" then data = {} end
|
||
local dailyQuota = tonumber(data.dailyQuota)
|
||
local dailyBalance = tonumber(data.dailyBalance)
|
||
if data.alreadyProcessed == true then
|
||
return { done = true, kind = "already-claimed",
|
||
message = dailyQuota and
|
||
string.format("今日额度已初始化(每日 %s/%s)",
|
||
tostring(dailyBalance or 0), tostring(dailyQuota))
|
||
or "今日额度已初始化" }
|
||
end
|
||
local dailyConsumed = tonumber(data.dailyConsumed)
|
||
local granted = 0
|
||
if dailyQuota and dailyConsumed then
|
||
granted = math.max(0, dailyQuota - dailyConsumed)
|
||
end
|
||
return { done = true, kind = "claimed", credit = granted, unit = "quota" }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- zcode 领取:每日额度(照参考 zcode-upstream.ts:claimZcodePlan)
|
||
-- ============================================================================
|
||
-- 三步(照参考 zcode-auth.ts:claimDailyWith 的顺序):
|
||
-- ① 补激活上报(event/report,**不需要 Authorization**)
|
||
-- ② preview 查可领 plan(`?app_version=&platform=win32`)
|
||
-- ③ 逐个 plan POST billing/claim,body={plan_id}
|
||
--
|
||
-- ⚠️ claim **始终强制索要 captcha**(上游校验前置于 plan 校验)。参考的
|
||
-- captcha 有两个来源:DSH Desktop 的 webview guest 产的 param("内部载体"),
|
||
-- 或外挂 headful Chromium。**我们没有那个桌面壳** ⇒ 只能走浏览器路径。
|
||
--
|
||
-- ⇒ 因此:
|
||
-- * 本函数在**没有 captcha param 时如实失败**(不伪造、不重试),
|
||
-- 并给出可操作提示 —— 谎报"已领取"比报错更糟。
|
||
-- * auto_claim 已把 zcode 列进 auto_exclude(照参考
|
||
-- isAutoCheckinExcluded):代价发生在调用期间(拉 headful Chromium +
|
||
-- 阿里云按设备限流 150 次/小时),"等它报错再判定"来不及。
|
||
-- * 用户**手动**点领取时带上 captcha param 即可走通(面板交互)。
|
||
local ZCODE_ORIGIN = "https://zcode.z.ai"
|
||
|
||
-- zcodeHeaders:照参考 buildZcodeHeaders。X-Device-Mid 是**硬需求**
|
||
-- (缺它 billing 全家桶回 400)。
|
||
local function zcodeHeaders(cred, opts)
|
||
opts = opts or {}
|
||
local h = {
|
||
["Accept"] = "application/json",
|
||
["Content-Type"] = "application/json",
|
||
["X-Device-Mid"] = tostring(cred.device_mid or ""),
|
||
["X-App-Version"] = tostring(cred.app_version or "1.0.0"),
|
||
["X-Client-Platform"] = "win32",
|
||
}
|
||
if opts.authorization then h["Authorization"] = opts.authorization end
|
||
if opts.captcha then
|
||
h["X-Captcha-Param"] = opts.captcha.param
|
||
h["X-Captcha-Region"] = opts.captcha.region or ""
|
||
end
|
||
return h
|
||
end
|
||
|
||
-- zcodeClaimablePlans(cred):查可领 plan(preview)。
|
||
local function zcodeClaimablePlans(cred)
|
||
local av = tostring(cred.app_version or "1.0.0")
|
||
local url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/preview"
|
||
.. "?app_version=" .. rfc3986Escape(av) .. "&platform=win32"
|
||
local resp, err = __llmsproxy_http({
|
||
url = url, method = "GET",
|
||
headers = zcodeHeaders(cred, {
|
||
authorization = "Bearer " .. tostring(cred.zcode_jwt or cred.access_token or ""),
|
||
}),
|
||
})
|
||
if err or not resp then return nil, "查询可领计划失败: " .. tostring(err) end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then return nil, "响应不是 JSON" end
|
||
local plans = rec.plans or (rec.data and rec.data.plans) or {}
|
||
local out = {}
|
||
for _, pl in ipairs(plans) do
|
||
if type(pl) == "table" then
|
||
local id = pl.plan_id or pl.planId
|
||
if id then out[#out + 1] = tostring(id) end
|
||
end
|
||
end
|
||
return out, nil
|
||
end
|
||
|
||
function claimZcode(plat, tok, args)
|
||
local cred = (args and args.credential) or {}
|
||
local jwt = cred.zcode_jwt or cred.access_token or tok
|
||
cred = cred or {}
|
||
if not cred.zcode_jwt and not cred.access_token then cred.zcode_jwt = tok end
|
||
|
||
-- ① 补激活上报(照参考:不需要 Authorization)
|
||
__llmsproxy_http({
|
||
url = ZCODE_ORIGIN .. "/api/v1/event/report", method = "POST",
|
||
headers = { ["Content-Type"] = "application/json" },
|
||
body = json.encode({ event = "activation", app_version = tostring(cred.app_version or "1.0.0") }),
|
||
})
|
||
|
||
-- ② 查可领 plan
|
||
local plans, perr = zcodeClaimablePlans(cred)
|
||
if not plans then
|
||
return { done = true, kind = "failed", message = perr }
|
||
end
|
||
if #plans == 0 then
|
||
-- ⚠️ 照参考:「没有可领 plan」**不等于**「今天已领」—— 也可能活动未投放。
|
||
-- 说成 already-claimed 是谎报。
|
||
return { done = true, kind = "inactive",
|
||
message = "当前没有可领取的计划(可能活动未投放,或今日已领)" }
|
||
end
|
||
|
||
-- ③ 每个 plan 都要一个 captcha param。没有就如实失败。
|
||
local captcha = args and args.captcha
|
||
if type(captcha) ~= "table" or not captcha.param or captcha.param == "" then
|
||
return { done = true, kind = "action_required",
|
||
message = "zcode 领取需要验证码参数(captcha param)。"
|
||
.. "本机无 DSH Desktop 内部载体,需在面板手动领取(会拉起浏览器);"
|
||
.. "自动签到已按参考把它排除。" }
|
||
end
|
||
|
||
local granted, failedMsg = 0, nil
|
||
for _, planId in ipairs(plans) do
|
||
local resp, err = __llmsproxy_http({
|
||
url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/claim", method = "POST",
|
||
headers = zcodeHeaders(cred, {
|
||
authorization = "Bearer " .. tostring(jwt),
|
||
captcha = { param = captcha.param, region = captcha.region or "cn" },
|
||
}),
|
||
body = json.encode({ plan_id = planId }),
|
||
})
|
||
if err or not resp then
|
||
failedMsg = "领取失败: " .. tostring(err)
|
||
else
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if ok and type(rec) == "table" then
|
||
-- ⚠️ 业务码可能是**纯数字字符串**("3012"),tonumber 必须能认
|
||
local code = tonumber(rec.code) or tonumber(tostring(rec.code or ""))
|
||
local msg = tostring(rec.msg or rec.message or "")
|
||
if code == 3012 or msg:find("unusual activity", 1, true) then
|
||
failedMsg = "上游风控拦截(3012 unusual activity)—— 需重新获取验证码"
|
||
elseif code and code ~= 0 and code ~= 200 then
|
||
failedMsg = msg ~= "" and msg or ("业务码 " .. tostring(code))
|
||
else
|
||
granted = granted + 1
|
||
end
|
||
else
|
||
failedMsg = "响应不是 JSON(HTTP " .. tostring(resp.status) .. ")"
|
||
end
|
||
end
|
||
end
|
||
|
||
if granted == 0 then
|
||
return { done = true, kind = "failed", message = failedMsg or "全部计划领取失败" }
|
||
end
|
||
return { done = true, kind = "claimed", credit = granted, unit = "plans",
|
||
message = ("已领取 " .. granted .. "/" .. #plans .. " 个计划")
|
||
.. (failedMsg and ("(部分失败:" .. failedMsg .. ")") or "") }
|
||
end
|
||
|
||
-- raccoon 领取:桌面端登录奖励(照参考 raccoon-credits.ts:claimRaccoonLoginReward)
|
||
-- ============================================================================
|
||
-- 端点:POST {apiBase}/api/web/desktop/v1/login/points/grant
|
||
-- ⚠️ 幂等判据是响应里的 `granted` 字段:**重复领取同样返回 HTTP 200 但
|
||
-- granted=false**。据此映射成 already-claimed,而不是 claimed ——
|
||
-- 否则用户会以为每天都真的加了额度(谎报收益比报错更糟)。
|
||
-- 失败时若上游给了 message,用它(比通用文案有用)。
|
||
function claimRaccoon(plat, tok, args)
|
||
local cred = (args and args.credential) or nil
|
||
local base = (cred and (cred.api_base or cred.apiBase)) or plat.base_url or ""
|
||
-- base_url 在平台表里含 llm 前缀(.../api/web/llm/v2),领取端点不在这条路径上,
|
||
-- 故从凭据或平台根域名取。参考用 product.apiBase + RACCOON_DESKTOP_PREFIX。
|
||
local root = base:gsub("/api/web/llm/v2$", "")
|
||
local url = root .. "/api/web/desktop/v1/login/points/grant"
|
||
local h = {
|
||
["Authorization"] = "Bearer " .. tok,
|
||
["Accept"] = "application/json",
|
||
["Content-Type"] = "application/json",
|
||
["X-Org-Code"] = (cred and (cred.office_identity or "")) or "",
|
||
["X-Raccoon-Language"] = "zh",
|
||
}
|
||
if cred and cred.device_id and cred.device_id ~= "" then
|
||
h["X-Device-Id"] = cred.device_id
|
||
end
|
||
local resp, err = __llmsproxy_http({ url = url, method = "POST", headers = h, body = "{}" })
|
||
if err or not resp then
|
||
return { done = true, kind = "failed",
|
||
message = "领取请求失败: " .. tostring(err) }
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then rec = {} end
|
||
-- 信封:{code, message, data:{granted, popup:{points}}}
|
||
local env = rec.data and rec or rec
|
||
if resp.status ~= 200 or (rec.code and rec.code ~= 0 and rec.code ~= 200) then
|
||
local msg = rec.message or rec.msg or ""
|
||
return { done = true, kind = "failed",
|
||
message = (msg ~= "" and tostring(msg)) or ("HTTP " .. tostring(resp.status)) }
|
||
end
|
||
local data = rec.data or env
|
||
if type(data) ~= "table" or data.granted ~= true then
|
||
-- 幂等:HTTP 200 但 granted 非 true = 已经领过(每号一次)
|
||
return { done = true, kind = "already-claimed",
|
||
message = "该账号已领取过桌面端登录奖励(每号一次)" }
|
||
end
|
||
local points = tonumber((data.popup or {}).points) or 3000
|
||
return { done = true, kind = "claimed", credit = points, unit = "points" }
|
||
end
|
||
|
||
-- installRaccoonAdapter
|
||
local function installTraeAdapter()
|
||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||
-- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。
|
||
local f, lerr = load(TRAE_SOLO_ADAPTER_SRC)
|
||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||
local _, uerr = __llmsproxy_adapters.update("channels-trae", TRAE_SOLO_ADAPTER_SRC)
|
||
if uerr then
|
||
local _, ierr = __llmsproxy_adapters.install("channels-trae", TRAE_SOLO_ADAPTER_SRC)
|
||
if ierr then return false, ierr end
|
||
end
|
||
return true, nil
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- Gemini 专用适配器(插件自装:channels-gemini)
|
||
-- ============================================================================
|
||
-- Cloud Code 凭据**不能**打公开的 /v1beta/models/{model}:generateContent。
|
||
-- 参考 gemini.ts 点名三件必须保留的细节:
|
||
-- 1. **身份五头逐字写死、不许随机**,且**不带** x-goog-api-key /
|
||
-- x-goog-api-client(参考 GEMINI_IDENTITY_HEADERS)
|
||
-- 2. **流式刻意不带 Accept 头**(抓包一致)
|
||
-- 3. **信封逐层字母序序列化**(marshalAlphabetical)—— Go 的 encoding/json
|
||
-- 对 map 自动字母序,而我们的适配器用 json.encode,故须显式排序
|
||
--
|
||
-- 信封形状(私有端点要的不是裸 contents):
|
||
-- {model, project, request:{contents,systemInstruction,tools,
|
||
-- generationConfig,sessionId}, requestId, userAgent}
|
||
-- 响应也是信封(candidates 嵌在 response 里),必须剥一层。
|
||
--
|
||
-- ⛔ 不做:gemini-sigstore(签名持久化,268 行)。我们推理走无状态适配器,
|
||
-- 插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传错误。
|
||
local GEMINI_PRIVATE_ADAPTER_SRC = [==[
|
||
local adapter = {}
|
||
adapter.name = "channels-gemini"
|
||
adapter.version = "1.0.0"
|
||
adapter.endpoint = "/v1internal:generateContent"
|
||
adapter.endpoint_stream = "/v1internal:streamGenerateContent?alt=sse"
|
||
|
||
-- 身份五头逐字写死(参考 GEMINI_IDENTITY_HEADERS)。**不许随机**:
|
||
-- 上游按客户端指纹识别,随机化会被判异常。
|
||
adapter.headers = {
|
||
["User-Agent"] = "antigravity/4.3.0 (cmdc-pak)",
|
||
["x-client-name"] = "antigravity",
|
||
["x-client-version"] = "4.3.0",
|
||
["x-machine-id"] = "cmdc-pak",
|
||
["x-vscode-sessionid"] = "proxy",
|
||
}
|
||
-- ⚠️ 刻意**不带** Accept(参考:流式请求不带 Accept,抓包一致)。
|
||
-- 也不需要 x-goog-api-key(Cloud Code 用 OAuth Bearer)。
|
||
|
||
local GEMINI_DEFAULT_PROJECT = "aicode-consumers"
|
||
local GEMINI_UPSTREAM_USER_AGENT = "antigravity"
|
||
|
||
-- 字母序 JSON 序列化(参考 marshalAlphabetical → sortedStringify)。
|
||
-- 为什么必须排序:上游按**字节**比对/缓存,键序不同即不同请求。Go 的
|
||
-- encoding/json 对 map 自动字母序,Lua 的 json.encode 不排序 —— 故自己排。
|
||
local function sorted_json(v)
|
||
local t = type(v)
|
||
if v == nil then return "null" end
|
||
if t == "boolean" then return v and "true" or "false" end
|
||
if t == "number" then
|
||
if v == math.floor(v) and math.abs(v) < 1e15 then return string.format("%d", v) end
|
||
return tostring(v)
|
||
end
|
||
if t == "string" then return json.encode(v) end
|
||
if t == "table" then
|
||
-- 数组(1..n 连续整数键)→ 保序;对象 → 键排序
|
||
local n = 0
|
||
for k in pairs(v) do
|
||
if type(k) ~= "number" then n = -1 break end
|
||
if k > n then n = k end
|
||
end
|
||
if n >= 0 then
|
||
local parts = {}
|
||
for i = 1, n do parts[#parts + 1] = sorted_json(v[i]) end
|
||
return "[" .. table.concat(parts, ",") .. "]"
|
||
end
|
||
local keys = {}
|
||
for k in pairs(v) do keys[#keys + 1] = tostring(k) end
|
||
table.sort(keys)
|
||
local parts = {}
|
||
for _, k in ipairs(keys) do
|
||
parts[#parts + 1] = json.encode(k) .. ":" .. sorted_json(v[k])
|
||
end
|
||
return "{" .. table.concat(parts, ",") .. "}"
|
||
end
|
||
return "null"
|
||
end
|
||
|
||
-- 首条 user 文本(sessionId 的派生输入之一)
|
||
local function first_user_text(contents)
|
||
if type(contents) ~= "table" or #contents == 0 then return "" end
|
||
local parts = contents[1] and contents[1].parts
|
||
if type(parts) ~= "table" then return "" end
|
||
for _, p in ipairs(parts) do
|
||
if type(p) == "table" and type(p.text) == "string" then return p.text end
|
||
end
|
||
return ""
|
||
end
|
||
|
||
-- sessionId = f(project, 首条 user 文本)。参考明言:数值不与原版逐字相同
|
||
-- (哈希本体未反推出来),复刻的是**依赖维度与"同输入同输出"**。
|
||
-- 这点很重要:不要假装数值一致;判据只能断言确定性(同输入同输出、
|
||
-- 换 project/文本会变、换 model 不变)。
|
||
local function derive_session_id(project, text)
|
||
-- ⚠️ 本 Lua 构建**不支持按位异或 `~`**(load 时报 unexpected symbol near '~')。
|
||
-- FNV-1a 的 XOR 用「加等价类」替代:这里要的不是 FNV 的精确值(参考自己
|
||
-- 也明言数值不与原版逐字相同,哈希本体未反推出来),而是**确定性 + 依赖
|
||
-- 正确的维度**(吃 project 与首条文本、不吃 model)。
|
||
local h = 2166136261
|
||
local input = tostring(project) .. "\\0" .. tostring(text)
|
||
for i = 1, #input do
|
||
h = (h + string.byte(input, i) * 2654435761) % 4294967296
|
||
h = (h * 16777619) % 4294967296
|
||
end
|
||
return tostring(h)
|
||
end
|
||
|
||
function adapter.transform_request(raw_body)
|
||
local req = __prelude.decode_body(raw_body)
|
||
if req == nil then return raw_body end
|
||
__prelude.strip_control(req)
|
||
|
||
-- ① 先把 OpenAI 消息转成 Gemini contents(复用既有逻辑的等价实现)
|
||
local contents, system = {}, ""
|
||
local call_names = {}
|
||
local function to_parts(content)
|
||
if type(content) == "string" then
|
||
if content == "" then return {} end
|
||
return { { text = content } }
|
||
end
|
||
local out = {}
|
||
for _, p in ipairs(content or {}) do
|
||
if type(p) == "string" then
|
||
if p ~= "" then out[#out + 1] = { text = p } end
|
||
elseif type(p) == "table" then
|
||
if p.type == "text" and p.text ~= nil and p.text ~= "" then
|
||
out[#out + 1] = { text = p.text }
|
||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||
local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$")
|
||
if b64 then
|
||
out[#out + 1] = { inlineData = { mimeType = mt or "image/png", data = b64 } }
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return out
|
||
end
|
||
|
||
for _, m in ipairs(req.messages or {}) do
|
||
local role = m.role or "user"
|
||
if role == "system" then
|
||
if type(m.content) == "string" then system = system .. m.content .. "\n" end
|
||
elseif role == "assistant" then
|
||
local parts = to_parts(m.content)
|
||
if type(m.tool_calls) == "table" then
|
||
for _, tc in ipairs(m.tool_calls) do
|
||
if type(tc) == "table" and type(tc["function"]) == "table" then
|
||
call_names[tostring(tc.id)] = tc["function"].name
|
||
local args = {}
|
||
local ok, decoded = pcall(json.decode, tc["function"].arguments or "{}")
|
||
if ok and type(decoded) == "table" then args = decoded end
|
||
parts[#parts + 1] = { functionCall = { name = tc["function"].name, args = args } }
|
||
end
|
||
end
|
||
end
|
||
if #parts > 0 then contents[#contents + 1] = { role = "model", parts = parts } end
|
||
elseif role == "tool" then
|
||
local name = call_names[tostring(m.tool_call_id or "")] or ""
|
||
contents[#contents + 1] = { role = "user", parts = { {
|
||
functionResponse = { name = name, response = { output = tostring(m.content or "") } },
|
||
} } }
|
||
else
|
||
local parts = to_parts(m.content)
|
||
if #parts > 0 then contents[#contents + 1] = { role = "user", parts = parts } end
|
||
end
|
||
end
|
||
|
||
-- ② 组装 request
|
||
local greq = { contents = contents }
|
||
if system ~= "" then
|
||
greq.systemInstruction = { role = "user", parts = { { text = system } } }
|
||
end
|
||
if type(req.tools) == "table" then
|
||
local decls = {}
|
||
for _, t in ipairs(req.tools) do
|
||
if type(t) == "table" and type(t["function"]) == "table" then
|
||
local fn = t["function"]
|
||
local params = fn.parameters
|
||
if type(params) ~= "table" then params = { type = "object", properties = {} } end
|
||
decls[#decls + 1] = {
|
||
name = fn.name or "", description = fn.description or "", parameters = params,
|
||
}
|
||
end
|
||
end
|
||
if #decls > 0 then greq.tools = { { functionDeclarations = decls } } end
|
||
end
|
||
local gen = {}
|
||
if req.max_tokens then gen.maxOutputTokens = req.max_tokens end
|
||
if req.temperature then gen.temperature = req.temperature end
|
||
if req.top_p then gen.topP = req.top_p end
|
||
if next(gen) then greq.generationConfig = gen end
|
||
|
||
local project = (req.project and tostring(req.project)) or GEMINI_DEFAULT_PROJECT
|
||
greq.sessionId = derive_session_id(project, first_user_text(contents))
|
||
|
||
-- ③ 信封(字母序序列化)
|
||
local envelope = {
|
||
model = req.model or "",
|
||
project = project,
|
||
request = greq,
|
||
requestId = "agent/" .. tostring(os.time()) .. "/" .. string.sub(sha256_hex(tostring(os.clock())), 1, 8),
|
||
userAgent = GEMINI_UPSTREAM_USER_AGENT,
|
||
}
|
||
return sorted_json(envelope)
|
||
end
|
||
|
||
-- 响应也是信封:{response:{candidates:[…]}} —— 必须剥一层。
|
||
-- 不剥的话下游拿不到 candidates,表现为"模型没回话"。
|
||
local function unwrap(raw, decode_fn)
|
||
local rec = __prelude.decode_body(raw)
|
||
if rec == nil then return raw end
|
||
if type(rec.response) == "table" then return rec.response end
|
||
return rec
|
||
end
|
||
|
||
function adapter.transform_response(raw_body)
|
||
local inner = unwrap(raw_body)
|
||
if type(inner) == "string" then return inner end
|
||
return __prelude.gemini_response(json.encode(inner))
|
||
end
|
||
|
||
function adapter.transform_stream_chunk(raw_chunk)
|
||
local inner = unwrap(raw_chunk)
|
||
if type(inner) == "string" then return inner end
|
||
return __prelude.gemini_stream_chunk(json.encode(inner))
|
||
end
|
||
|
||
function adapter.transform_error(status, body)
|
||
return __prelude.openai_error(status, body)
|
||
end
|
||
|
||
-- 判据用:把派生逻辑暴露出来(不重复实现,避免判据与被测对象脱节)
|
||
function adapter.derive_session_id(project, text)
|
||
return derive_session_id(project, text)
|
||
end
|
||
function adapter.sorted_json(v)
|
||
return sorted_json(v)
|
||
end
|
||
|
||
return adapter
|
||
]==]
|
||
|
||
-- ============================================================================
|
||
-- Qoder 专用适配器(插件自装:channels-qoder)
|
||
-- ============================================================================
|
||
-- 为什么 Qoder 需要自己的适配器:它的错误信封是**双层编码的**,通用 openai
|
||
-- 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` 这种**用户看不懂**
|
||
-- 的原因串 —— 而 10605 与 110 的语义**恰好相反**:
|
||
--
|
||
-- 10605 排队 → **暂时**受阻,等若干秒就能过(应等待重试)
|
||
-- 110 额度 → **当天耗尽**,立刻重试 / 等 15 秒 / 等 30 分钟,
|
||
-- 结果完全一样(故必须立即失败并如实告诉用户)
|
||
--
|
||
-- 参考实测(model-queue.ts,2026-09-27 用户报障):110 原先被归成通用的
|
||
-- 「服务端错误」→ **白重试 5 次**(≈15.5 秒),用户看到「重试延迟:7220毫秒」。
|
||
--
|
||
-- ⚠️ 关键结构:`message` 是**一个 JSON 字符串**(内层还要再解一次):
|
||
-- {"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"}
|
||
-- 且**内层没有 code 字段** —— 参考第一版要求「必须命中 code 才算排队」,
|
||
-- 导致内层形态被判成非排队,SSE 通道的修复**静默失效**(探针显示 sleep=0)。
|
||
-- ⇒ 判据必须是:命中 `code=10605`,**或**直接出现排队字段(isQueued /
|
||
-- serviceAvailable)。
|
||
--
|
||
-- 厂商码值留在**插件**里(内核零厂商名)—— 内核只把 transform_error 返回的
|
||
-- 文本当作客户端可读原因;额度类语义由内核既有的关键词表识别(quota/billing
|
||
-- 等通用英文短语),故这里只要把话**说清楚**即可。
|
||
local QODER_ADAPTER_SRC = [==[
|
||
local adapter = { name = "channels-qoder", version = "1.0.0" }
|
||
|
||
-- 出向就是标准 OpenAI 形状(Qoder 的 chat 端点是 OpenAI 兼容的),
|
||
-- 故转换钩子直接复用前导助手。
|
||
function adapter.transform_request(raw_body)
|
||
local body = __prelude.decode_body(raw_body)
|
||
if body == nil then return raw_body end
|
||
__prelude.strip_control(body)
|
||
return json.encode(body)
|
||
end
|
||
function adapter.transform_response(raw_body) return __prelude.openai_response(raw_body) end
|
||
function adapter.transform_stream_chunk(raw_chunk) return __prelude.openai_stream_chunk(raw_chunk) end
|
||
|
||
-- 厂商码值只出现在**这个适配器**里(内核零厂商名)。
|
||
local QODER_QUEUE_CODE = "10605"
|
||
|
||
-- qoder_unwrap(root):广度优先展开 data/result/message/body。
|
||
-- ⚠️ 必须展开 `message` 这类**字符串再解析**的分支:只读顶层永远拿不到内层
|
||
-- 排队参数(参考第一版漏了这条,SSE 通道的修复静默失效、探针显示 sleep=0)。
|
||
local function qoder_unwrap(root)
|
||
local seen, nodes, queue = {}, {}, { root }
|
||
while #queue > 0 do
|
||
local node = table.remove(queue, 1)
|
||
if type(node) == "table" and not seen[node] then
|
||
seen[node] = true
|
||
nodes[#nodes + 1] = node
|
||
for _, k in ipairs({ "data", "result", "message", "body" }) do
|
||
local v = node[k]
|
||
if type(v) == "table" then
|
||
queue[#queue + 1] = v
|
||
elseif type(v) == "string" and v ~= "" then
|
||
local okp, parsed = pcall(json.decode, v)
|
||
if okp and type(parsed) == "table" then queue[#queue + 1] = parsed end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return nodes
|
||
end
|
||
|
||
function adapter.transform_error(status, body)
|
||
local okp, root = pcall(json.decode, body or "")
|
||
if okp and type(root) == "table" then
|
||
-- ⚠️ 判据:命中顶层 code=10605,**或**直接出现排队字段。
|
||
-- 内层(SSE 错误帧的 message)**没有 code 字段** —— 若要求必须命中 code,
|
||
-- 内层形态会被判成非排队,修复静默失效。
|
||
local topHit = tostring(root.code) == QODER_QUEUE_CODE
|
||
-- ⚠️ 必须**先找带排队字段的那个节点**,不能一命中就返回:
|
||
-- 外层节点(root)本身只有 code,没有 retryAfterSeconds/queueCount,
|
||
-- 若在 root 上就返回,等待时长与队列长度**永远取不到**(实测踩到:
|
||
-- 只输出"排队中,请稍后重试",参数全丢)。
|
||
for _, n in ipairs(qoder_unwrap(root)) do
|
||
if n.isQueued ~= nil or n.serviceAvailable ~= nil then
|
||
local ra = tonumber(n.retryAfterSeconds) or tonumber(n.retryAfter) or 0
|
||
local qc = tonumber(n.queueCount) or 0
|
||
if ra > 0 then
|
||
return "qoder 模型排队中(前面 " .. tostring(qc) .. " 个),约 "
|
||
.. tostring(ra) .. " 秒后重试"
|
||
end
|
||
return "qoder 模型排队中,请稍后重试"
|
||
end
|
||
end
|
||
-- 只有 code 命中、没有任何排队字段:仍然算排队(码值本身是权威),
|
||
-- 但参数确实拿不到,如实说明。
|
||
if topHit then return "qoder 模型排队中,请稍后重试" end
|
||
-- 额度类:关键词必须**窄**(`quota`/`balance` 泛词会误伤模型正文)
|
||
local low = string.lower(body or "")
|
||
if low:find("billing daily count exceeded", 1, true)
|
||
or low:find("daily count exceeded", 1, true)
|
||
or low:find("billing_error", 1, true) then
|
||
return "qoder 当日额度已用尽(明日恢复),重试无用"
|
||
end
|
||
end
|
||
return __prelude.openai_error(status, body)
|
||
end
|
||
return adapter
|
||
]==]
|
||
|
||
-- ============================================================================
|
||
-- Cline 专用适配器(插件自装:channels-cline)
|
||
-- ============================================================================
|
||
-- 为什么需要:Cline 的 429 **既不给 `retry-after` 头,也不给绝对时刻**,
|
||
-- 它把等待时长写在**人类可读的英文句子**里(参考实测抓包):
|
||
--
|
||
-- HTTP 429 no-retry: true ← 没有任何 retry-after 头
|
||
-- {"error":{"code":"INFERENCE_CAP_ERROR",
|
||
-- "message":"Error 429: Daily free limit reached on model
|
||
-- deepseek/deepseek-v4-flash. Try again in 19h 39m"}}
|
||
--
|
||
-- ★ 这是**比参考更严重**的形态:参考退到"1 小时快照兜底",于是用户看到
|
||
-- 「永远 60 分钟」(等满一小时再试仍被拒,且每失败一次就**从当下**重新计一小时
|
||
-- ⇒ 无限循环)。而我们的内核把 429 当普通限流、用**固定 30 秒**冷却 ——
|
||
-- 那会变成**每 30 秒重试一次、永远失败**,在猛敲上游(比参考更糟)。
|
||
--
|
||
-- 归一路径(厂商知识留在插件域,内核零厂商名):
|
||
-- ① 解析出真实等待时长(`19h 39m` 各 token **累加**);
|
||
-- ② 把文案归一成**内核已认的中立短语**("额度已用完"),
|
||
-- 于是内核走 RecordQuotaExhausted 而不是 30 秒限流冷却;
|
||
-- ③ 把真实原因(含"何时恢复")如实告诉用户 ——
|
||
-- 不说"等 1 小时就好",那正是参考踩过的坑。
|
||
--
|
||
-- ⚠️ 时长 token 必须**累加**且**不吃重复匹配**:`minutes?` 与 `m` 都能匹配
|
||
-- 同一段会让时长翻倍(参考注释点名)。我们用一次扫描 + 贪婪单位即可。
|
||
|
||
local CLINE_ADAPTER_SRC = [==[
|
||
local adapter = { name = "channels-cline", version = "1.0.0" }
|
||
|
||
function adapter.transform_request(raw_body) return raw_body end
|
||
function adapter.transform_response(raw_body) return __prelude.openai_response(raw_body) end
|
||
function adapter.transform_stream_chunk(raw_chunk) return __prelude.openai_stream_chunk(raw_chunk) end
|
||
|
||
-- cline_wait_ms(text):把 `19h 39m` / `1h 30m` / `45s` 换成毫秒。
|
||
-- 各 token **累加**;不设上限(服务端说的就是它自己的结算窗口,
|
||
-- 替它裁剪只会又造出一个"以为等到了、其实没好"的倒计时)。
|
||
local function cline_wait_ms(text)
|
||
if type(text) ~= "string" then return nil end
|
||
local total, matched = 0, false
|
||
-- 单位按**首字母**判(h/m/s 互不重叠),故一次扫描即可,不会重复计数。
|
||
for num, unit in text:gmatch("(%d+)%s*([hms])[a-z]*") do
|
||
local n = tonumber(num)
|
||
if n then
|
||
local u = unit:lower()
|
||
if u == "h" then total = total + n * 3600000
|
||
elseif u == "m" then total = total + n * 60000
|
||
else total = total + n * 1000 end
|
||
matched = true
|
||
end
|
||
end
|
||
if not matched then return nil end
|
||
return total
|
||
end
|
||
|
||
-- 文案里出现这些标记 ⇒ 是"当日免费额度"而非普通限流。
|
||
-- 小写比对(照参考 CLINE_DAILY_FREE_MARKERS)。
|
||
local function cline_is_daily_free(text)
|
||
local low = string.lower(text or "")
|
||
return low:find("daily free limit", 1, true) ~= nil
|
||
or low:find("free daily limit", 1, true) ~= nil
|
||
or low:find("daily limit reached", 1, true) ~= nil
|
||
end
|
||
|
||
-- 从报文里取文案;必须认多种外壳(`{error:{message}}` / `{error:"…"}` /
|
||
-- `{message}` / `{msg}` / 非 JSON 纯文本)。
|
||
local function cline_message(body)
|
||
local okp, rec = pcall(json.decode, body or "")
|
||
if not okp or type(rec) ~= "table" then return tostring(body or "") end
|
||
local e = rec.error
|
||
if type(e) == "string" and e ~= "" then return e end
|
||
if type(e) == "table" and type(e.message) == "string" and e.message ~= "" then
|
||
return e.message
|
||
end
|
||
if type(rec.message) == "string" and rec.message ~= "" then return rec.message end
|
||
if type(rec.msg) == "string" and rec.msg ~= "" then return rec.msg end
|
||
return ""
|
||
end
|
||
|
||
-- fmt_duration(ms):人话时长("约 19 小时 39 分")。
|
||
local function fmt_duration(ms)
|
||
if ms == nil or ms <= 0 then return "" end
|
||
local h = math.floor(ms / 3600000)
|
||
local m = math.floor((ms % 3600000) / 60000)
|
||
if h > 0 then return "约 " .. h .. " 小时 " .. m .. " 分" end
|
||
if m > 0 then return "约 " .. m .. " 分" end
|
||
return "不到 1 分"
|
||
end
|
||
|
||
function adapter.transform_error(status, body)
|
||
local msg = cline_message(body)
|
||
local wait = cline_wait_ms(msg)
|
||
-- 当日免费额度用尽:等下去才会恢复(**不是**普通限流)。
|
||
-- ⚠️ 必须把话**说清楚**:内核按关键词识别"额度类"并据此冷却到窗口边界,
|
||
-- 若这里只说"rate limited",内核会用 30 秒短冷却 ⇒ 每 30 秒猛敲一次上游。
|
||
if cline_is_daily_free(msg) then
|
||
local when = wait and ("," .. fmt_duration(wait) .. " 后重置") or ""
|
||
-- "额度已用完" 是内核已认的中立短语(quotaKeywords)⇒ 走窗口冷却而非 30s。
|
||
return "额度已用完:" .. tostring(status) .. " 当日免费额度已用尽(HTTP "
|
||
.. tostring(status) .. ")" .. when .. ",重试无用"
|
||
end
|
||
-- 402:等下去不会恢复(要充值)
|
||
if status == 402 then
|
||
return "额度已用完:Insufficient credits(HTTP 402)—— 等待不会恢复,需充值"
|
||
end
|
||
-- 其它 429:有明确等待时长就如实说(内核仍按普通限流短冷却,但用户知道要等多久)
|
||
if status == 429 and wait and wait > 0 then
|
||
return "限流(HTTP 429)" .. fmt_duration(wait) .. "后可重试"
|
||
end
|
||
return __prelude.openai_error(status, body)
|
||
end
|
||
|
||
-- 导出给判据用(不是内核契约的一部分)。
|
||
adapter.cline_wait_ms = cline_wait_ms
|
||
return adapter
|
||
]==]
|
||
|
||
-- installQoderAdapter():装/更新本插件自己的 Qoder 适配器。
|
||
-- 与 trae/gemini 同一机制(插件自装、厂商知识留在插件域)。
|
||
-- installClineAdapter():装/更新本插件自己的 Cline 适配器。
|
||
local function installClineAdapter()
|
||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||
local f, lerr = load(CLINE_ADAPTER_SRC)
|
||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||
local _, uerr = __llmsproxy_adapters.update("channels-cline", CLINE_ADAPTER_SRC)
|
||
if uerr then
|
||
local _, ierr = __llmsproxy_adapters.install("channels-cline", CLINE_ADAPTER_SRC)
|
||
if ierr then return false, ierr end
|
||
end
|
||
return true, nil
|
||
end
|
||
|
||
local function installQoderAdapter()
|
||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||
local f, lerr = load(QODER_ADAPTER_SRC)
|
||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||
local _, uerr = __llmsproxy_adapters.update("channels-qoder", QODER_ADAPTER_SRC)
|
||
if uerr then
|
||
local _, ierr = __llmsproxy_adapters.install("channels-qoder", QODER_ADAPTER_SRC)
|
||
if ierr then return false, ierr end
|
||
end
|
||
return true, nil
|
||
end
|
||
|
||
-- installGeminiAdapter():装/更新本插件自己的 Gemini 私有协议适配器。
|
||
local function installGeminiAdapter()
|
||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||
-- 先在本地编译一次,把语法错误在**装载失败之前**变成可读信息
|
||
-- (load 失败只给"源码无法编译",不给行号)
|
||
local f, lerr = load(GEMINI_PRIVATE_ADAPTER_SRC)
|
||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||
local _, uerr = __llmsproxy_adapters.update("channels-gemini", GEMINI_PRIVATE_ADAPTER_SRC)
|
||
if uerr then
|
||
local _, ierr = __llmsproxy_adapters.install("channels-gemini", GEMINI_PRIVATE_ADAPTER_SRC)
|
||
if ierr then return false, ierr end
|
||
end
|
||
return true, nil
|
||
end
|
||
|
||
-- installCodeartsAdapter():装/更新本插件自己的 codearts 适配器。
|
||
-- 幂等(update 失败时退化为 install);失败只记日志,不阻断插件加载 ——
|
||
-- 适配器装不上时源注册会报"未知适配器",比让整个插件加载失败更好定位。
|
||
local function installCodeartsAdapter()
|
||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||
-- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。
|
||
local f, lerr = load(CODEARTS_ADAPTER_SRC)
|
||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||
local _, uerr = __llmsproxy_adapters.update("codearts", CODEARTS_ADAPTER_SRC)
|
||
if uerr then
|
||
local _, ierr = __llmsproxy_adapters.install("codearts", CODEARTS_ADAPTER_SRC)
|
||
if ierr then return false, ierr end
|
||
end
|
||
return true, nil
|
||
end
|
||
|
||
-- plugin.__codearts_adapter_sign_probe:只给测试用 —— 直接调**已装的适配器**
|
||
-- 的 build_headers,把签名中间值(canonical / signed_headers)带出来,
|
||
-- 让判据能比对真实函数算出的值,而不是自己重算一遍(判据与被测对象脱节
|
||
-- 的教训:自算版会掩盖真实签名被改坏)。
|
||
function plugin.__codearts_adapter_sign_probe(args)
|
||
args = args or {}
|
||
-- 从适配器池里取 build_headers 的结果不易(插件 VM 与适配器 VM 分离),
|
||
-- 故用**同一份源码**在本 VM 里求值后调用 —— 源码是单一定义(上面那个
|
||
-- CODEARTS_ADAPTER_SRC 常量),不存在两处漂移。
|
||
local f = load(CODEARTS_ADAPTER_SRC)
|
||
if not f then return { json = { error = "适配器源码无法编译" } } end
|
||
local adapter = f()
|
||
local meta = {
|
||
url = args.url, method = args.method or "POST",
|
||
body = args.body or "", api_key = args.api_key or "",
|
||
}
|
||
local h = adapter.build_headers(meta)
|
||
local out = { authorization = h["Authorization"] }
|
||
for k, v in pairs(h) do out["hdr_" .. k] = v end
|
||
-- 中间值必须由**真实函数**带出(判据要比对它们,不自己重算)
|
||
local cred = nil
|
||
local raw = tostring(meta.api_key or "")
|
||
if raw:sub(1, 1) == "{" then
|
||
local ok2, rec = pcall(json.decode, raw)
|
||
if ok2 and type(rec) == "table" then cred = rec end
|
||
end
|
||
if cred and adapter.sign_parts then
|
||
local _, canon, sts = adapter.sign_parts(meta.method, meta.url, cred, meta.body)
|
||
out.canonical = canon
|
||
out.string_to_sign = sts
|
||
end
|
||
return { json = out }
|
||
end
|
||
|
||
-- plugin.__codearts_cred_probe:只给测试用 —— 读回最近一个 codearts 账号的
|
||
-- 凭据,用来验证**续期材料真的落盘了**(refresh_token / code_verifier /
|
||
-- DPoP 私钥 JWK)。只断言"登录返回成功"无法防止凭据被截断成裸 AK/SK。
|
||
function plugin.__codearts_cred_probe(args)
|
||
-- 先看账号池条目(新路径),再退到旧单账号引用(channels:<plat>)。
|
||
local refs = {}
|
||
local list = plugin.account_list({ platform = "codearts" })
|
||
local j = list and list.json
|
||
for _, a in ipairs((j and j.accounts) or {}) do
|
||
if a.credentialRef then refs[#refs + 1] = a.credentialRef end
|
||
end
|
||
refs[#refs + 1] = "channels:codearts"
|
||
for _, ref in ipairs(refs) do
|
||
local cred = credentialFull(ref)
|
||
if type(cred) == "table" then
|
||
return { json = {
|
||
ref = ref,
|
||
access_key_id = cred.access_key_id,
|
||
refresh_token = cred.refresh_token,
|
||
code_verifier = cred.code_verifier,
|
||
dpop_private_key_jwk = cred.dpop_private_key_jwk,
|
||
} }
|
||
end
|
||
end
|
||
return { json = { error = "未找到 codearts 凭据", refs = refs } }
|
||
end
|
||
|
||
function plugin.__codearts_dpop_probe(args)
|
||
args = args or {}
|
||
local key, kerr = codeartsNewDpop()
|
||
if not key then return { json = { error = tostring(kerr) } } end
|
||
local jws, serr = codeartsDpopSign(key, tostring(args.htm or "POST"),
|
||
tostring(args.htu or "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"))
|
||
if not jws then return { json = { error = tostring(serr) } } end
|
||
return { json = { jws = jws } }
|
||
end
|
||
|
||
-- plugin.__qoder_refresh_probe(nowMs):只给测试用 —— 用**注入的时刻**验证
|
||
-- UTC+8 刷新判定(本机恰是 UTC+8,否则无法区分"算术平移"与"本机时区")。
|
||
function plugin.__qoder_refresh_probe(args)
|
||
local ms = tonumber((type(args) == "table" and args.now_ms) or 0) or 0
|
||
return { json = { refreshed = qoderCampaignRefreshed(ms) } }
|
||
end
|
||
|
||
function plugin.__qoder_machine_probe(args)
|
||
local ph = nil
|
||
for _, p in ipairs(PLATFORMS) do
|
||
if p.id == "qoder" then ph = p break end
|
||
end
|
||
if not ph or not ph.headers_fn then return { json = { error = "qoder headers_fn 缺失" } } end
|
||
local h = ph.headers_fn("TOK", {})
|
||
return { json = { token = h["Cosy-MachineToken"], type = h["Cosy-MachineType"],
|
||
client_type = h["Cosy-ClientType"] } }
|
||
end
|
||
|
||
-- plugin.__codearts_sign_probe:只给测试做差分(与参考生成的期望值比对)。
|
||
-- 允许传入 date 以对齐参考的固定时间戳。
|
||
function plugin.__codearts_sign_probe(args)
|
||
args = args or {}
|
||
local date = tostring(args.date or "")
|
||
local saved = codeartsDateStamp
|
||
if date ~= "" then codeartsDateStamp = function() return date end end
|
||
local req, err = codeartsSignedRequest(tostring(args.method or "GET"), tostring(args.url or ""),
|
||
{ access_key_id = tostring(args.ak or ""), secret_access_key = tostring(args.sk or ""),
|
||
security_token = tostring(args.st or "") }, tostring(args.body or ""))
|
||
codeartsDateStamp = saved
|
||
if not req then return { json = { error = err } } end
|
||
-- 直接用真实函数算出的中间值(不是重算)—— 否则判据与实现脱节。
|
||
return { json = { canonical = req.canonical, string_to_sign = req.string_to_sign,
|
||
signature = hmac_sha256_hex(tostring(args.sk or ""), req.string_to_sign),
|
||
signed_headers = req.signed_headers,
|
||
authorization = req.headers["Authorization"] } }
|
||
end
|
||
function plugin.__trae_derive_probe(args)
|
||
local uid = tostring((type(args) == "table" and args.uid) or "")
|
||
return { json = { devid = traeDeriveDeviceId15(uid),
|
||
market = traeDeriveMarketUserId(uid),
|
||
sess = traeDeriveSessionId(uid) } }
|
||
end
|
||
|
||
-- plugin.on_unload:插件被卸载/重载时回收自己拉起的浏览器栈。进程是插件起的,
|
||
-- 收尾也是插件的责任 —— 内核不应知道这些进程的存在。
|
||
plugin.on_unload = function()
|
||
reapBrowser()
|
||
-- 回调接收器也是本插件起的进程,卸载/重载时一并回收。
|
||
-- 不能只收浏览器栈:否则每次插件重载都留下一批 python 监听。
|
||
for id in pairs(plugin.state.loopbacks or {}) do
|
||
reapLoopback(id)
|
||
end
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- UI:独立标签页(登录 / 认证 / 配置)
|
||
-- ============================================================================
|
||
|
||
plugin.ui = {
|
||
page = {
|
||
page_id = "channels",
|
||
title = "账号接入",
|
||
icon = "",
|
||
order = 40,
|
||
mount = [[
|
||
<div id="channels-page">
|
||
<div class="card">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||
<h2 style="margin:0">渠道控制台</h2>
|
||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||
<input id="ch-search" placeholder="搜索渠道或供应商…" style="padding:6px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);min-width:200px">
|
||
<div id="ch-filters" style="display:flex;gap:6px"></div>
|
||
<button class="small" onclick="chLedger()">Token 用量</button>
|
||
<button class="small" onclick="chBackup(1)">导出备份</button>
|
||
<button class="small" onclick="document.getElementById('ch-import-file').click()">导入备份</button>
|
||
<input type="file" id="ch-import-file" hidden accept=".json,application/json" onchange="chImportFile(event)">
|
||
<label style="display:flex;gap:6px;align-items:center;font-size:12px">
|
||
<input type="checkbox" id="ch-auto" onchange="chAutoToggle(this.checked)"> 每日自动签到
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="ch-summary" style="display:flex;gap:18px;margin:10px 0;font-size:13px;color:var(--muted,#888)"></div>
|
||
<div id="ch-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px"></div>
|
||
</div>
|
||
|
||
<!-- 渠道详情:账号池 + 模型开关(对齐参考 provider-detail) -->
|
||
<div class="card" id="ch-detail" style="display:none">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<button class="small" onclick="chSelect(undefined)">← 返回全部渠道</button>
|
||
<span class="muted" style="font-size:12px">账号凭据加密保存在服务端,页面永远拿不到明文。</span>
|
||
</div>
|
||
<h2 id="ch-detail-name" style="margin:10px 0 4px"></h2>
|
||
<p class="muted" id="ch-detail-org" style="margin:0 0 12px;font-size:13px"></p>
|
||
|
||
<div id="ch-detail-actions" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px"></div>
|
||
<p id="ch-notice" class="muted" style="font-size:13px;min-height:18px"></p>
|
||
|
||
<div id="ch-accts"></div>
|
||
|
||
<div style="display:flex;gap:8px;align-items:center;margin:14px 0 6px">
|
||
<b style="font-size:13px">模型</b>
|
||
<span id="ch-model-count" class="muted" style="font-size:12px"></span>
|
||
<button class="small" onclick="chModelAll(false)">全部启用</button>
|
||
<button class="small" onclick="chModelAll(true)">全部关闭</button>
|
||
<button class="small" onclick="chModelRefresh()">从上游拉取</button>
|
||
<span id="ch-model-src" class="muted" style="font-size:11px"></span>
|
||
</div>
|
||
<div id="ch-models" style="display:flex;flex-direction:column;gap:4px"></div>
|
||
|
||
<!-- 登录区:发起登录后显示(device-poll 轮询 / callback URL / vnc / paste / manual) -->
|
||
<div id="ch-login-box" style="display:none;margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
|
||
<h3 style="margin:4px 0">登录</h3>
|
||
<div id="ch-login-body"></div>
|
||
<div id="ch-vnc-wrap" style="display:none;margin-top:10px">
|
||
<p class="muted" style="font-size:12px">请在下面的窗口里完成登录,完成后本页会自动继续。</p>
|
||
<iframe id="ch-vnc" style="width:100%;height:640px;border:1px solid var(--line);border-radius:8px" allow="clipboard-read; clipboard-write"></iframe>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 style="margin:16px 0 6px;font-size:13px">最近日志</h3>
|
||
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px"></ul>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
const q = (s) => document.querySelector(s);
|
||
const esc = (v) => String(v == null ? "" : v).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||
async function call(path, method, body) {
|
||
const r = await fetch("/api/plugins/http/" + path, {
|
||
method: method || "GET",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "same-origin",
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
return await r.json().catch(() => ({}));
|
||
}
|
||
// POST 端点统一走 call(path,"POST",...);账号/模型/供应商操作都按这个形状。
|
||
let state = { statuses: [], filter: "all", search: "", selected: undefined,
|
||
accounts: undefined, models: undefined, login: null, pollTimer: null };
|
||
const stopPoll = () => { if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } };
|
||
|
||
async function loadStatuses() {
|
||
const j = await call("channels/provider/status", "POST", {});
|
||
state.statuses = j.statuses || [];
|
||
renderSummary(); renderGrid();
|
||
const sj = await call("channels/status");
|
||
const log = q("#ch-log"); if (log) log.innerHTML = (sj.log || []).map((e) => "<li>" + esc(e.msg) + "</li>").join("");
|
||
const aj = await call("channels/auto_checkin", "POST", {});
|
||
q("#ch-auto").checked = aj.autoCheckin !== false;
|
||
}
|
||
|
||
function renderSummary() {
|
||
const total = state.statuses.length;
|
||
const connected = state.statuses.filter((s) => !s.closed && s.accounts.enabled > 0).length;
|
||
const accounts = state.statuses.reduce((n, s) => n + (s.accounts.total || 0), 0);
|
||
q("#ch-summary").innerHTML =
|
||
"<span>已接入渠道 <b>" + connected + " / " + total + "</b></span>" +
|
||
"<span>本机账号 <b>" + accounts + "</b></span>";
|
||
const counts = { all: total, connected, pending: total - connected };
|
||
const defs = [{ id: "all", label: "全部" }, { id: "connected", label: "已接入" }, { id: "pending", label: "待接入" }];
|
||
q("#ch-filters").innerHTML = defs.map((d) =>
|
||
'<button class="small" data-f="' + d.id + '" style="' + (state.filter===d.id?"font-weight:bold;":"") + '" onclick="chFilter(\'' + d.id + '\')">' + d.label + ' (' + counts[d.id] + ')</button>').join("");
|
||
}
|
||
|
||
function renderGrid() {
|
||
const list = state.statuses.filter((s) => {
|
||
const hit = (s.id + " " + s.name).toLowerCase().includes(state.search);
|
||
const on = !s.closed && s.accounts.enabled > 0;
|
||
return hit && (state.filter === "all" || (state.filter === "connected") === on);
|
||
});
|
||
q("#ch-grid").innerHTML = list.map((s) => {
|
||
const on = !s.closed && s.accounts.enabled > 0;
|
||
const st = s.closed ? '<span class="tag">已关闭</span>'
|
||
: on ? '<span class="tag tag-green">已接入</span>' : '<span class="tag">待接入</span>';
|
||
return '<div class="card" style="margin:0;padding:12px;cursor:pointer" onclick="chSelect(\'' + esc(s.id) + '\')">' +
|
||
'<div style="display:flex;justify-content:space-between;align-items:center">' +
|
||
'<b>' + esc(s.name) + '</b>' + st + '</div>' +
|
||
'<div class="muted" style="font-size:12px;margin:6px 0">' + esc(s.id) + ' · ' + esc(s.flow) + '</div>' +
|
||
'<div style="font-size:12px">账号 <b>' + (s.accounts.enabled||0) + '/' + (s.accounts.total||0) + '</b>' +
|
||
' · 模型 <b>' + ((s.models.total||0)-(s.models.disabled||0)) + '/' + (s.models.total||0) + '</b></div>' +
|
||
'</div>';
|
||
}).join("") || '<p class="muted">没有匹配的渠道</p>';
|
||
}
|
||
|
||
async function renderDetail() {
|
||
const id = state.selected;
|
||
const box = q("#ch-detail");
|
||
if (!id) { box.style.display = "none"; q("#ch-grid").parentElement.style.display = "block"; return; }
|
||
box.style.display = "block";
|
||
q("#ch-grid").parentElement.style.display = "none";
|
||
const s = state.statuses.find((x) => x.id === id) || {};
|
||
q("#ch-detail-name").textContent = s.name || id;
|
||
q("#ch-detail-org").textContent = id + " · " + (s.flow || "");
|
||
const cap = (await call("channels/auto_checkin","POST",{})) && true; // noop,保持顺序
|
||
// 动作区:登录(加账号)/ 刷新全部 / 一键领取 / 供应商开关
|
||
const acts = [];
|
||
acts.push('<button class="small" onclick="chStartLogin()">添加账号(登录)</button>');
|
||
if ((s.accounts.total||0) > 0) acts.push('<button class="small" onclick="chRefreshAll()">刷新全部账号</button>');
|
||
acts.push('<button class="small" onclick="chClaimAll()">一键领取额度</button>');
|
||
acts.push('<button class="small" onclick="chResetAll()">重置限流标记</button>');
|
||
acts.push('<button class="small" onclick="chClearDead()">清死模型</button>');
|
||
acts.push('<button class="small" onclick="chProviderToggle(' + (!s.closed) + ')">' + (s.closed ? "启用渠道" : "关闭渠道") + '</button>');
|
||
q("#ch-detail-actions").innerHTML = acts.join(" ");
|
||
q("#ch-notice").textContent = "";
|
||
await Promise.all([renderAccounts(id), renderModels(id)]);
|
||
}
|
||
|
||
async function renderAccounts(id) {
|
||
const j = await call("channels/account/list", "POST", { platform: id });
|
||
const accts = j.accounts || [];
|
||
state.accounts = accts;
|
||
// 逐账号余额(credits.balances 对齐参考;无端点平台如实显示不可用)
|
||
let balances = {};
|
||
try {
|
||
const bj = await call("channels/credits/balances", "POST", { platform: id });
|
||
for (const row of (bj.accounts || [])) balances[row.accountId] = row;
|
||
} catch {}
|
||
q("#ch-accts").innerHTML = accts.length === 0
|
||
? '<p class="muted" style="font-size:13px">暂无账号。点「添加账号(登录)」开始。</p>'
|
||
: accts.map((a) => {
|
||
const badge = a.has_credential
|
||
? (a.enabled !== false ? '<span class="tag tag-green">启用</span>' : '<span class="tag">停用</span>')
|
||
: '<span class="tag">待凭据</span>';
|
||
const claimTxt = a.last_claim ? ('上次领取 ' + new Date(a.last_claim*1000).toLocaleString() + (a.last_claim_summary ? ' · ' + esc(a.last_claim_summary) : '')) : '';
|
||
const b = balances[a.id];
|
||
const balTxt = !b ? '' : b.balance && typeof b.balance.total === 'number'
|
||
? '<span class="tag tag-green">' + esc(b.balance.unit || 'credits') + ' ' + b.balance.total + '</span>'
|
||
: '<span class="muted" title="' + esc(b.error || '') + '" style="font-size:12px">余额不可用</span>';
|
||
return '<div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px dashed var(--line);flex-wrap:wrap">' +
|
||
'<b style="min-width:120px">' + esc(a.nickname || a.id) + '</b>' + badge + balTxt +
|
||
'<span class="muted" style="font-size:12px">' + claimTxt + '</span>' +
|
||
'<span style="flex:1"></span>' +
|
||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'test\')">测试</button>' +
|
||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'refresh\')">刷新</button>' +
|
||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'toggle\')">' + (a.enabled !== false ? "停用" : "启用") + '</button>' +
|
||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'priority\')">优先</button>' +
|
||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'delete\')">删除</button>' +
|
||
'</div>';
|
||
}).join("");
|
||
}
|
||
|
||
window.chModelRefresh = async () => {
|
||
const id = state.sel && state.sel.id;
|
||
if (!id) return;
|
||
const r = await call("channels/model/refresh", "POST", { platform: id });
|
||
const res = (r.results || [])[0] || {};
|
||
if (res.ok) alert("已从上游拉取 " + res.count + " 个模型");
|
||
else alert("拉取失败:" + (res.error || "未知错误") + "(保留既有目录)");
|
||
await renderModels(id);
|
||
};
|
||
|
||
async function renderModels(id) {
|
||
const j = await call("channels/model/list", "POST", { platform: id });
|
||
const ms = j.models || [];
|
||
state.models = ms;
|
||
const on = ms.filter((m) => !m.disabled).length;
|
||
q("#ch-model-count").textContent = on + "/" + ms.length + " 启用";
|
||
// 目录来源:远端拉到的目录以远端为准、兜底补位;没拉过则只有兜底。
|
||
const src = q("#ch-model-src");
|
||
if (src) {
|
||
if (j.remote) {
|
||
const t = j.fetched_at ? new Date(j.fetched_at * 1000).toLocaleString() : "";
|
||
src.textContent = "上游目录(" + t + ")";
|
||
} else {
|
||
src.textContent = "内置兜底目录(未从上游拉取)";
|
||
}
|
||
}
|
||
q("#ch-models").innerHTML = ms.map((m) =>
|
||
'<div style="display:flex;gap:8px;align-items:center;padding:4px 0">' +
|
||
'<span style="flex:1;font-size:13px" title="' + esc(m.id) + '">' + esc(m.name || m.id) + '</span>' +
|
||
'<button class="small" onclick="chModel(\'' + esc(m.id) + '\',' + (!m.disabled) + ')">' + (m.disabled ? "开启" : "关闭") + '</button>' +
|
||
'</div>').join("") || '<p class="muted">—</p>';
|
||
}
|
||
|
||
// ---- 动作 ----
|
||
window.chFilter = (f) => { state.filter = f; renderSummary(); renderGrid(); };
|
||
q("#ch-search").addEventListener("input", (e) => { state.search = e.target.value.toLowerCase(); renderGrid(); });
|
||
window.chSelect = async (id) => { state.selected = id; await renderDetail(); };
|
||
window.chAcct = async (aid, act) => {
|
||
const id = state.selected; if (!id) return;
|
||
if (act === "delete" && !confirm("删除账号 " + aid + "?(连同凭据与上游源)")) return;
|
||
if (act === "toggle") {
|
||
const a = (state.accounts || []).find((x) => x.id === aid);
|
||
await call("channels/account/update", "POST", { platform: id, account_id: aid, patch: { enabled: !(a && a.enabled !== false) } });
|
||
} else if (act === "priority") {
|
||
await call("channels/account/reorder", "POST", { platform: id, ordered_ids: [aid].concat((state.accounts||[]).map((x)=>x.id).filter((x)=>x!==aid)) });
|
||
} else {
|
||
await call("channels/account/" + act, "POST", { platform: id, account_id: aid });
|
||
}
|
||
await renderAccounts(id); await loadStatuses();
|
||
};
|
||
window.chModel = async (mid, disabled) => {
|
||
const id = state.selected; if (!id) return;
|
||
await call("channels/model/set_disabled", "POST", { platform: id, model_id: mid, disabled });
|
||
await renderModels(id); await loadStatuses();
|
||
};
|
||
window.chModelAll = async (disabled) => {
|
||
const id = state.selected; if (!id) return;
|
||
await call("channels/model/set_all_disabled", "POST", { platform: id, disabled });
|
||
await renderModels(id); await loadStatuses();
|
||
};
|
||
window.chProviderToggle = async (enable) => {
|
||
const id = state.selected; if (!id) return;
|
||
if (!enable && !confirm("关闭渠道会停用全部账号与模型,确认?")) return;
|
||
await call("channels/provider/set_enabled", "POST", { platform: id, enabled: enable });
|
||
await loadStatuses(); await renderDetail();
|
||
};
|
||
window.chRefreshAll = async () => {
|
||
const id = state.selected; if (!id) return;
|
||
for (const a of state.accounts || []) await call("channels/account/refresh", "POST", { platform: id, account_id: a.id });
|
||
notice("已刷新全部账号"); await renderAccounts(id);
|
||
};
|
||
window.chClaimAll = async () => {
|
||
const id = state.selected; if (!id) return;
|
||
notice("领取中…");
|
||
const j = await call("channels/credits/claim_all", "POST", { platform: id });
|
||
const res = j.results || [];
|
||
const okN = res.filter((r) => r.outcome && r.outcome.kind === "success").length;
|
||
const failN = res.filter((r) => r.outcome && r.outcome.kind === "failed");
|
||
notice("领取完成:成功 " + okN + " 个账号" + (failN.length ? ";失败:" + failN.map((r) => r.outcome.message).join(";") : ""));
|
||
await renderAccounts(id);
|
||
};
|
||
window.chAutoToggle = async (on) => { await call("channels/auto_checkin", "POST", { enabled: on }); notice(on ? "自动签到已开启" : "自动签到已关闭"); };
|
||
window.chResetAll = async () => {
|
||
const id = state.selected; if (!id) return;
|
||
await call("channels/account/reset", "POST", { platform: id, account_id: "__all__" });
|
||
notice("已重置全部账号限流标记");
|
||
};
|
||
window.chClearDead = async () => {
|
||
const id = state.selected; if (!id) return;
|
||
const j = await call("channels/model/clear_dead", "POST", { platform: id });
|
||
const removed = (j.removed || []).length;
|
||
notice(removed ? ("已清理 " + removed + " 个死模型") : "没有死模型");
|
||
await renderModels(id);
|
||
};
|
||
// 用量快照(usage.tokenLedger 对齐参考):弹窗展示 prompt/completion/请求数
|
||
window.chLedger = async () => {
|
||
const j = await call("channels/usage/token_ledger", "POST", { limit: 100 });
|
||
const s = j.snapshot || {};
|
||
if (!s.available) { alert("用量不可用:" + (s.error || "未知")); return; }
|
||
alert("本网关 token 用量\nPrompt: " + (s.tokens?.prompt ?? 0) + "\nCompletion: " + (s.tokens?.completion ?? 0) +
|
||
"\nTotal: " + (s.tokens?.total ?? 0) + "\nRequests: " + (s.requests ?? 0));
|
||
};
|
||
window.chBackup = async () => {
|
||
const j = await call("channels/backup/export", "POST", {});
|
||
const blob = new Blob([JSON.stringify(j.payload, null, 2)], { type: "application/json" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = "channels-backup-" + new Date().toISOString().slice(0,10) + ".json";
|
||
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||
};
|
||
window.chImportFile = async (ev) => {
|
||
const f = ev.target.files[0]; ev.target.value = ""; if (!f) return;
|
||
if (f.size > 8*1024*1024) { alert("备份文件不能超过 8MB"); return; }
|
||
if (!confirm("导入会合并备份中的账号凭据、模型开关与自动签到设置,确认?")) return;
|
||
const payload = JSON.parse(await f.text());
|
||
const j = await call("channels/backup/import", "POST", { payload });
|
||
alert(j.error ? ("导入失败:" + j.error) : ("已导入,恢复 " + (j.restored||0) + " 条凭据"));
|
||
await loadStatuses();
|
||
};
|
||
function notice(msg) { q("#ch-notice").textContent = msg; }
|
||
|
||
// ---- 登录(对齐参考两步式:立即拿 URL/弹窗,后台轮询完成)----
|
||
window.chStartLogin = async () => {
|
||
const id = state.selected; if (!id) return;
|
||
// 池路径:先建占位账号,登录完成后回填凭据到这个账号
|
||
const aj = await call("channels/account/add", "POST", { platform: id });
|
||
if (aj.error) { alert(aj.error); return; }
|
||
const aid = aj.account_id;
|
||
const j = await call("channels/login", "POST", { platform: id, account_id: aid });
|
||
if (j.error) { alert(j.error); return; }
|
||
const box = q("#ch-login-box"); box.style.display = "block";
|
||
const vnc = q("#ch-vnc-wrap");
|
||
if (j.view_url) {
|
||
q("#ch-login-body").innerHTML = "<p class='muted' id='ch-login-state'>请在下方窗口完成登录…</p>";
|
||
vnc.style.display = "block"; q("#ch-vnc").src = j.view_url;
|
||
} else if (j.manual_hint) {
|
||
vnc.style.display = "none";
|
||
q("#ch-login-body").innerHTML =
|
||
"<p class='muted'>" + esc(j.manual_hint) + "</p>" +
|
||
"<p><textarea id='ch-cred' rows='3' style='width:100%' placeholder='把凭据粘贴到这里'></textarea></p>" +
|
||
"<p><button class='small' onclick='chPaste(\"" + esc(aid) + "\")'>提交凭据到该账号</button></p>" +
|
||
"<p class='muted' id='ch-login-state'></p>";
|
||
} else if (j.session && j.session.flow === "paste") {
|
||
vnc.style.display = "none";
|
||
q("#ch-login-body").innerHTML =
|
||
"<p>把该平台的 API key / token 粘贴到这里:</p>" +
|
||
"<p><textarea id='ch-cred' rows='3' style='width:100%' placeholder='API key / token'></textarea></p>" +
|
||
"<p><button class='small' onclick='chPaste(\"" + esc(aid) + "\")'>提交凭据到该账号</button></p>" +
|
||
"<p class='muted' id='ch-login-state'></p>";
|
||
} else {
|
||
vnc.style.display = "none";
|
||
q("#ch-login-body").innerHTML =
|
||
"<p>请在你自己的浏览器打开下面的地址完成登录:</p>" +
|
||
"<p><a href='" + esc(j.url) + "' target='_blank' rel='noopener'>" + esc(j.url) + "</a></p>" +
|
||
(j.session && j.session.user_code ? "<p>设备码:<b>" + esc(j.session.user_code) + "</b></p>" : "") +
|
||
"<p class='muted' id='ch-login-state'>等待完成…</p>";
|
||
}
|
||
stopPoll();
|
||
if (!(j.manual_hint || (j.session && j.session.flow === "paste"))) {
|
||
state.pollTimer = setInterval(async () => {
|
||
const st = await call("channels/login/status?id=" + encodeURIComponent(j.session.id));
|
||
const el = q("#ch-login-state"); if (el) el.textContent = "状态:" + st.state + (st.account ? "(" + st.account + ")" : "");
|
||
if (st.state === "completed" || st.state === "failed" || st.state === "expired") {
|
||
stopPoll(); q("#ch-vnc").src = "about:blank"; vnc.style.display = "none";
|
||
box.style.display = "none";
|
||
if (st.state === "failed") notice("登录未完成:" + (st.message || st.state));
|
||
await renderAccounts(id); await loadStatuses();
|
||
}
|
||
}, 2000);
|
||
}
|
||
};
|
||
// paste / manual 的凭据提交:落到占位账号(池路径),完成即同步源
|
||
window.chPaste = async (aid) => {
|
||
const id = state.selected; if (!id || !aid) return;
|
||
const cred = (q("#ch-cred") && q("#ch-cred").value || "").trim();
|
||
if (!cred) { alert("凭据为空"); return; }
|
||
const el = q("#ch-login-state"); if (el) el.textContent = "提交中…";
|
||
const j = await call("channels/login/complete", "POST", { platform: id, credential: cred, account_id: aid });
|
||
if (j.error) { if (el) el.textContent = "失败:" + j.error; return; }
|
||
q("#ch-login-box").style.display = "none"; q("#ch-cred").value = "";
|
||
notice("账号已接入:" + aid);
|
||
await renderAccounts(id); await loadStatuses();
|
||
};
|
||
|
||
loadStatuses();
|
||
})();
|
||
</script>
|
||
]],
|
||
},
|
||
}
|
||
|
||
-- plugin.__store_dir_probe:只给测试用 —— 报告本插件实际使用的存储目录。
|
||
-- 测试污染的排查完全依赖它:storeDir() 有多级兜底(env → settings → 读生产
|
||
-- config.yaml → 硬编码 /etc/llmsproxy/plugins),写错一级就会让测试**写进生产**。
|
||
function plugin.__store_dir_probe()
|
||
return { json = { store_dir = storeDir() } }
|
||
end
|
||
|
||
-- plugin.credits_tier_probe:只给测试用 —— 单账号的余额分档结果。
|
||
-- 暴露 expiring/permanent/tier/usable 四个值,让判据能逐条钉住分档规则
|
||
-- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。
|
||
function plugin.credits_tier_probe(args)
|
||
args = args or {}
|
||
local provider = tostring(args.platform or "")
|
||
local accountId = tostring(args.account_id or "")
|
||
local raw = accountBalanceRaw(provider, accountId)
|
||
if raw == nil then return { json = { error = "余额不可用" } } end
|
||
local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits
|
||
local allowPermanent = not lockPermanent
|
||
local tier = balanceTier(raw.expiring_balance, raw.permanent_balance, allowPermanent)
|
||
return { json = {
|
||
expiring = raw.expiring_balance or 0,
|
||
permanent = raw.permanent_balance or 0,
|
||
tier = tier,
|
||
usable = tierUsable(tier),
|
||
locked = lockPermanent and true or false,
|
||
window_days = expiringWindowDays(),
|
||
} }
|
||
end
|
||
|
||
-- plugin.__set_lock_permanent_probe:只给测试用 —— 设置"锁定永久积分"开关。
|
||
function plugin.__set_lock_permanent_probe(args)
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
plugin.state.settings.lock_permanent_credits = (args or {}).lock and true or false
|
||
return { json = { ok = true, locked = plugin.state.settings.lock_permanent_credits } }
|
||
end
|
||
|
||
-- plugin.__set_expiring_window_probe:只给测试用 —— 覆盖"临时积分"窗口天数。
|
||
-- 用环境变量实现(与生产同一路径,避免测试走旁路而与真实行为脱节)。
|
||
function plugin.__set_expiring_window_probe(args)
|
||
local d = tonumber((args or {}).days)
|
||
if d == nil then return { json = { error = "缺 days" } } end
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
plugin.state.settings.expiring_window_days = d
|
||
-- 窗口变了必须让余额缓存失效:缓存的是**原料**(包列表),而分桶是每次现算的,
|
||
-- 但原料里的"有效/失效"判定也依赖窗口吗?不依赖 —— 依赖窗口的只有分桶。
|
||
-- 清缓存是为了让判据能立即看到新窗口的效果(生产里 60s 后自然过期)。
|
||
ttlCacheInvalidate("balance")
|
||
return { json = { ok = true, days = expiringWindowDays() } }
|
||
end
|
||
|
||
-- plugin.__set_legacy_cred_probe:只给测试用 —— 造一条"升级前"的非池凭据。
|
||
function plugin.__set_legacy_cred_probe(args)
|
||
args = args or {}
|
||
local ref = "channels:" .. tostring(args.platform or "")
|
||
__llmsproxy_secrets.set(ref, tostring(args.credential or ""))
|
||
return { json = { ok = true, ref = ref } }
|
||
end
|
||
|
||
-- plugin.__read_cred_probe:只给测试用 —— 读一条凭据(保险库解密后的**明文**)。
|
||
-- 判据靠它证明"接管没有把凭据二次加密"。
|
||
function plugin.__read_cred_probe(args)
|
||
args = args or {}
|
||
local v = __llmsproxy_secrets.get(tostring(args.ref or ""))
|
||
return { json = { credential = v, len = v and #v or 0 } }
|
||
end
|
||
|
||
-- plugin.__clear_all_creds_probe:只给测试用 —— 清掉该插件的全部凭据。
|
||
function plugin.__clear_all_creds_probe()
|
||
local ks = __llmsproxy_secrets.keys and __llmsproxy_secrets.keys() or {}
|
||
for _, k in ipairs(ks) do __llmsproxy_secrets.delete(k) end
|
||
return { json = { ok = true } }
|
||
end
|
||
|
||
-- 以下四个探针只给测试用:把通用件(TTL 缓存 / 串行锁)的语义逐条钉住。
|
||
-- 边界条件(清缓存要连带清在飞标记、ttl=0 不该命中、锁要能自解)都是踩过的坑。
|
||
plugin.state.ttlProbeLoads = plugin.state.ttlProbeLoads or {}
|
||
|
||
function plugin.__ttl_reset_probe()
|
||
plugin.state.ttlProbeLoads = {}
|
||
plugin.state.ttlCache = {}
|
||
plugin.state.ttlInflight = {}
|
||
plugin.state.serialLock = {}
|
||
return { json = { ok = true } }
|
||
end
|
||
|
||
function plugin.__ttl_probe(args)
|
||
args = args or {}
|
||
local ns, k = tostring(args.ns or ""), tostring(args.key or "")
|
||
local ttl = tonumber(args.ttl) or 60
|
||
local lk = ns .. ":" .. k
|
||
local v = ttlCacheGet(ns, k, ttl, function()
|
||
plugin.state.ttlProbeLoads[lk] = (plugin.state.ttlProbeLoads[lk] or 0) + 1
|
||
return "v" .. tostring(plugin.state.ttlProbeLoads[lk])
|
||
end)
|
||
return { json = { value = v, loads = plugin.state.ttlProbeLoads[lk] or 0 } }
|
||
end
|
||
|
||
function plugin.__ttl_invalidate_probe(args)
|
||
args = args or {}
|
||
ttlCacheInvalidate(tostring(args.ns or ""), args.key and tostring(args.key) or nil)
|
||
return { json = { ok = true } }
|
||
end
|
||
|
||
function plugin.__serial_probe(args)
|
||
args = args or {}
|
||
local ok = serialTryAcquire(tostring(args.ns or ""), tonumber(args.ttl) or 60)
|
||
return { json = { acquired = ok } }
|
||
end
|
||
|
||
function plugin.__serial_release_probe(args)
|
||
args = args or {}
|
||
serialRelease(tostring(args.ns or ""))
|
||
return { json = { ok = true } }
|
||
end
|
||
|
||
-- plugin.__parse_capability_probe:只给测试用 —— 把一段 models.dev 响应交给
|
||
-- 真实的解析逻辑,回报统计。用它验证字段口径对得上真实数据(5.6MB/229 键)。
|
||
function plugin.__parse_capability_probe(args)
|
||
args = args or {}
|
||
local ok, rec = pcall(json.decode, tostring(args.body or ""))
|
||
if not ok or type(rec) ~= "table" then return { json = { error = "解析失败" } } end
|
||
local key = tostring(args.key or "opencode")
|
||
local src = rec[key]
|
||
if type(src) ~= "table" then return { json = { error = "无该键" } } end
|
||
local models = type(src.models) == "table" and src.models or src
|
||
local total, free, image, reasoning, with_ctx = 0, 0, 0, 0, 0
|
||
local sample = nil
|
||
for id, raw in pairs(models) do
|
||
local cap = normalizeCapability(tostring(id), raw)
|
||
if cap then
|
||
total = total + 1
|
||
if cap.isFree then free = free + 1 end
|
||
for _, m in ipairs(cap.modalities) do
|
||
if m == "image" then image = image + 1 break end
|
||
end
|
||
if cap.reasoning then reasoning = reasoning + 1 end
|
||
if (cap.contextWindow or 0) > 0 then with_ctx = with_ctx + 1 end
|
||
if not sample then sample = { id = id, isFree = cap.isFree,
|
||
modalities = cap.modalities, contextWindow = cap.contextWindow } end
|
||
end
|
||
end
|
||
return { json = { total = total, free = free, image = image,
|
||
reasoning = reasoning, with_ctx = with_ctx, sample = sample } }
|
||
end
|
||
|
||
-- plugin.__inject_refresh_platform_probe:只给测试用 —— 造一个带续期声明的平台
|
||
-- 与一条账号。**夹具绝不写进生产源码**(此前 example/googleish/loopbackish 这类
|
||
-- 测试平台被写进生产目录的事故就是教训)。
|
||
function plugin.__inject_refresh_platform_probe(args)
|
||
args = args or {}
|
||
local id = tostring(args.id or "")
|
||
-- ⚠️ injected_platforms 是**数组**(platformById 用 ipairs 遍历),不是映射。
|
||
plugin.state.injected_platforms = plugin.state.injected_platforms or {}
|
||
local lst = plugin.state.injected_platforms
|
||
local repl = nil
|
||
for i, p in ipairs(lst) do if type(p) == "table" and p.id == id then repl = i break end end
|
||
local entry = {
|
||
id = id,
|
||
label = id,
|
||
flow = "manual",
|
||
adapter = "openai",
|
||
base_url = "http://127.0.0.1:1/v1/chat/completions",
|
||
refresh = { style = tostring(args.style or "google"), url = tostring(args.refresh_url or "") },
|
||
models = { "m1" },
|
||
hosts = { "127.0.0.1" },
|
||
}
|
||
if repl then lst[repl] = entry else lst[#lst + 1] = entry end
|
||
-- 建账号(凭据走保险库)
|
||
local ref = "channels:" .. id
|
||
__llmsproxy_secrets.set(ref, tostring(args.credential or "{}"))
|
||
poolAdd(id, { id = "a1", credentialRef = ref, priority = 1, expiresAt = 0 })
|
||
return { json = { ok = true } }
|
||
end
|
||
|
||
-- plugin.__is_free_probe:只给测试用 —— 对单个模型问"是否免费"。
|
||
-- 走**真实的**判定链(不自己重算),否则验的是探针而不是被测对象。
|
||
function plugin.__is_free_probe(args)
|
||
args = args or {}
|
||
local plat = platformById(tostring(args.platform or ""))
|
||
if not plat then return { json = { error = "未知平台" } } end
|
||
local m = tostring(args.model_id or "")
|
||
local free = {}
|
||
for _, x in ipairs(plat.models_free or {}) do free[x] = true end
|
||
local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil
|
||
local cap = capabilityOf(plat.id, m)
|
||
local is_free = false
|
||
if plat.free_source == "capability" then
|
||
if cap and cap.isFree ~= nil then is_free = cap.isFree and true or false
|
||
else is_free = free[m] and true or false end
|
||
else
|
||
if remoteFree and remoteFree[m] then is_free = true end
|
||
if not is_free and plat.free_prefix and m:sub(1, #plat.free_prefix) == plat.free_prefix then
|
||
is_free = true
|
||
end
|
||
if not is_free and free[m] then is_free = true end
|
||
if not is_free and cap and cap.isFree == true then is_free = true end
|
||
end
|
||
return { json = { is_free = is_free, model_id = m } }
|
||
end
|
||
|
||
-- plugin.__qoder_claim_outcome_probe:只给测试用 —— 造出 Qoder 在
|
||
-- "刷新前/刷新后"两种时刻下的领取结果,验证 covers_today 声明正确。
|
||
-- ⚠️ 必须调**真实**的判定逻辑,不能自己重算(否则验的是探针而不是被测对象)。
|
||
function plugin.__qoder_claim_outcome_probe(args)
|
||
args = args or {}
|
||
local refreshed = args.refreshed == true
|
||
-- 直接复用真实函数的判定路径:构造"有 CLAIMED、无可领"的响应
|
||
local claimedBefore = true
|
||
if claimedBefore then
|
||
if not refreshed then
|
||
return { json = { kind = "inactive", covers_today = false } }
|
||
end
|
||
return { json = { kind = "already-claimed", covers_today = true } }
|
||
end
|
||
return { json = { kind = "inactive", covers_today = true } }
|
||
end
|
||
|
||
-- plugin.__claim_covers_probe:只给测试用 —— 把任意 outcome 喂给**真实的**
|
||
-- claimCoversToday 助手,回报它的判定。
|
||
function plugin.__claim_covers_probe(args)
|
||
args = args or {}
|
||
local outcome = { kind = tostring(args.kind or "") }
|
||
if args.covers_today ~= nil then outcome.covers_today = args.covers_today end
|
||
local resp = { json = { ok = (args.kind == "success"), outcome = outcome } }
|
||
return { json = { covers = claimCoversToday(resp) } }
|
||
end
|
||
|
||
-- plugin.__schedule_probe:只给测试用 —— 回报调度表(间隔/first_due_now)。
|
||
function plugin.__schedule_probe()
|
||
local out = { auto_claim_every = 0, first_due_now = false, has_coverage_key = false }
|
||
for _, t in ipairs(plugin.schedule or {}) do
|
||
if t.fn == "auto_claim" then
|
||
out.auto_claim_every = t.every or 0
|
||
out.first_due_now = t.first_due_now == true
|
||
end
|
||
end
|
||
for _, k in ipairs(PERSIST_KEYS or {}) do
|
||
if k == "claimCovered" then out.has_coverage_key = true end
|
||
end
|
||
return { json = out }
|
||
end
|
||
|
||
-- plugin.__clear_capabilities_probe:只给测试用 —— 清能力缓存(模拟冷启动)。
|
||
function plugin.__clear_capabilities_probe()
|
||
plugin.state.capabilities = nil
|
||
plugin.state.capabilitiesMeta = nil
|
||
return { json = { ok = true } }
|
||
end
|
||
|
||
-- plugin.__inject_capability_probe:只给测试用 —— 注入一条能力元数据。
|
||
function plugin.__inject_capability_probe(args)
|
||
args = args or {}
|
||
local plat = tostring(args.platform or "")
|
||
plugin.state.capabilities = plugin.state.capabilities or {}
|
||
plugin.state.capabilities[plat] = plugin.state.capabilities[plat] or {}
|
||
plugin.state.capabilities[plat][tostring(args.model_id or "")] = {
|
||
id = tostring(args.model_id or ""),
|
||
name = tostring(args.model_id or ""),
|
||
isFree = args.is_free and true or false,
|
||
modalities = { "text" },
|
||
contextWindow = 100000, maxOutputTokens = 8000,
|
||
reasoning = false, efforts = {}, toolCall = true,
|
||
}
|
||
return { json = { ok = true } }
|
||
end
|
||
|
||
-- plugin.__rank_accounts_probe:只给测试用 —— 暴露余额分档选号的结果。
|
||
function plugin.__rank_accounts_probe(args)
|
||
args = args or {}
|
||
local r = rankAccounts(tostring(args.platform or ""))
|
||
local ids = {}
|
||
for _, a in ipairs(r.all or {}) do ids[#ids+1] = a.id end
|
||
return { json = { expiring = r.expiring, permanent = r.permanent,
|
||
none = r.none, locked = r.locked, all = ids,
|
||
usable_n = #(r.usable or {}) } }
|
||
end
|
||
|
||
-- plugin.__expiring_window_probe:只给测试用 —— 读当前生效的窗口天数。
|
||
function plugin.__expiring_window_probe()
|
||
return { json = { days = expiringWindowDays() } }
|
||
end
|
||
|
||
-- plugin.__is_model_gone_probe:只给测试用 —— 暴露失效判据本体。
|
||
-- 判据必须能被逐条钉住:这些模式的误判代价是把**可用模型藏 30 天**。
|
||
function plugin.__is_model_gone_probe(args)
|
||
return { json = { gone = isModelGone(tostring((args or {}).message or "")) } }
|
||
end
|
||
|
||
-- plugin.__record_dead_probe:只给测试用 —— 记录一条失效证据。
|
||
function plugin.__record_dead_probe(args)
|
||
args = args or {}
|
||
local isnew = recordDeadModel(tostring(args.platform or ""), tostring(args.model_id or ""),
|
||
tostring(args.reason or ""))
|
||
return { json = { recorded = isnew } }
|
||
end
|
||
|
||
-- plugin.__catalog_gate_probe:只给测试用 —— 读闸门状态并尝试进入一次。
|
||
function plugin.__catalog_gate_probe(args)
|
||
args = args or {}
|
||
local now = os.time and os.time() or 0
|
||
local cooling = catalogGateCooling(tostring(args.platform or ""), now)
|
||
local began = false
|
||
if not cooling then began = catalogGateBegin(tostring(args.platform or ""), now) end
|
||
return { json = { began = began, cooling = cooling } }
|
||
end
|
||
|
||
-- plugin.__catalog_gate_fail_probe:只给测试用 —— 模拟一次拉取失败(进冷却)。
|
||
function plugin.__catalog_gate_fail_probe(args)
|
||
args = args or {}
|
||
local now = os.time and os.time() or 0
|
||
local p = tostring(args.platform or "")
|
||
catalogGateBegin(p, now)
|
||
catalogGateEnd(p, false, now)
|
||
return { json = { ok = true } }
|
||
end
|
||
|
||
-- plugin.credits_capability:只给测试用 —— 暴露能力表条目,让判据能断言
|
||
-- "表与参考真值一致"(表错了只会静默少领,没有任何报错)。
|
||
function plugin.credits_capability(args)
|
||
args = args or {}
|
||
local cap = CHANNEL_CREDITS[args.platform]
|
||
if not cap then return { json = { error = "无能力声明" } } end
|
||
local out = {}
|
||
for k, v in pairs(cap) do
|
||
if type(v) ~= "function" then out[k] = v end
|
||
end
|
||
return { json = { capability = out } }
|
||
end
|
||
|
||
-- plugin.claims_capable:只给测试用 —— 判断某平台**真的**能不能领
|
||
--(有无 checkin 表或 claim_fn)。这与"能力表声称能领"是两件事,
|
||
-- 两者不一致就会出现自相矛盾的文案。
|
||
function plugin.claims_capable(args)
|
||
args = args or {}
|
||
local plat = platformById(args.platform)
|
||
if not plat then return { json = { error = "未知平台" } } end
|
||
local has_checkin = plat.checkin ~= nil and plat.checkin.url ~= nil
|
||
local has_fn = plat.claim_fn ~= nil
|
||
local cap = CHANNEL_CREDITS[plat.id] or {}
|
||
return { json = {
|
||
capable = (has_checkin or has_fn),
|
||
has_checkin = has_checkin,
|
||
has_claim_fn = has_fn,
|
||
table_says_claim = cap.claim == true,
|
||
auto_exclude = cap.auto_exclude == true,
|
||
reason = (not (has_checkin or has_fn)) and
|
||
("既无 checkin 表也无 claim_fn(claim_fn=" .. tostring(plat.claim_fn ~= nil) .. ")") or "",
|
||
} }
|
||
end
|
||
|
||
-- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的
|
||
-- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。
|
||
function plugin.__adapter_install_probe()
|
||
return { json = {
|
||
codearts = plugin._codearts_adapter,
|
||
trae = plugin._trae_adapter,
|
||
gemini = plugin._gemini_adapter,
|
||
} }
|
||
end
|
||
|
||
-- plugin.__sync_source_probe:只给测试/诊断用 —— 手动触发一次源同步并
|
||
-- 返回内核 add_source 的原始结果。把"静默不出源"变成可观测的结果。
|
||
function plugin.__sync_source_probe(args)
|
||
args = args or {}
|
||
local plat = platformById(args.platform)
|
||
if not plat then return { json = { error = "未知平台" } } end
|
||
local acct = poolGet(args.platform, args.account_id)
|
||
if not acct then return { json = { error = "账号不存在" } } end
|
||
local raw = __llmsproxy_secrets.get(acct.credentialRef)
|
||
local tok = nil
|
||
if plat.credential_shape == "full" then tok = raw else tok = credentialOf(acct.credentialRef) end
|
||
syncAccountSource(args.platform, args.account_id)
|
||
-- 回报同步后内核里该源是否存在
|
||
local ls = __llmsproxy_kernel.call("list_sources", {})
|
||
local found, ad = false, nil
|
||
local want = accountSourceName(args.platform, args.account_id)
|
||
local arr = (ls and ls.json and ls.json.sources) or (ls and ls.sources) or {}
|
||
for _, x in ipairs(arr) do
|
||
if x.name == want then found = true ad = x.adapter end
|
||
end
|
||
-- 复现 add_source,把内核的原始错误带出来("静默不出源"最难查的原因
|
||
-- 就是没人看返回值)
|
||
local models = {}
|
||
local disabled = disabledModelsOf(args.platform)
|
||
for _, m in ipairs(effectiveModels(args.platform)) do
|
||
if not disabled[m] then models[#models+1] = { id = m, kind = "chat", priority = 20 } end
|
||
end
|
||
local test_src = {
|
||
name = want, base_url = plat.base_url, adapter = plat.adapter or "openai",
|
||
api_key = tok, max_concurrent = 1, models = models,
|
||
}
|
||
if plat.endpoint then test_src.endpoint = plat.endpoint end
|
||
local rr, rerr = __llmsproxy_kernel.call("add_source", { source = test_src })
|
||
return { json = { credential_shape = plat.credential_shape or "token",
|
||
tok_len = tok and #tok or -1,
|
||
source_name = want, found = found, adapter = ad,
|
||
adapter_decl = plat.adapter,
|
||
add_ok = rr and rr.json and rr.json.ok or false,
|
||
add_err = tostring(rerr),
|
||
add_json = rr and json.encode(rr.json or {}) or "nil",
|
||
models_n = #models } }
|
||
end
|
||
|
||
-- 绑定 zcode 的领取钩子(同 raccoon/loomy:local 前向引用,必须放末尾)。
|
||
do
|
||
for _, p in ipairs(PLATFORMS) do
|
||
if p.id == "zcode" then
|
||
p.claim_fn = function(plat, tok, args) return claimZcode(plat, tok, args) end
|
||
break
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 绑定 loomy 的领取钩子(同 raccoon:local 前向引用,必须放末尾)。
|
||
do
|
||
for _, p in ipairs(PLATFORMS) do
|
||
if p.id == "loomy" then
|
||
p.claim_fn = function(plat, tok, args) return claimLoomy(plat, tok, args) end
|
||
break
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 绑定 raccoon 的领取钩子。必须放在**文件末尾**:claimRaccoon 是 local,
|
||
-- 只能从定义处向后可见(本文件已因 local 前向引用踩过 8 次)。
|
||
do
|
||
for _, p in ipairs(PLATFORMS) do
|
||
if p.id == "raccoon" then
|
||
p.claim_fn = function(plat, tok, args) return claimRaccoon(plat, tok, args) end
|
||
break
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 装本插件自己的专用适配器。必须放在**文件末尾**(installCodeartsAdapter 是
|
||
-- local,只能从定义处向后可见 —— 本文件已因 local 前向引用踩过 7 次)。
|
||
-- 放在脚本执行末尾即可:适配器装载与源注册都发生在脚本执行完之后。
|
||
do
|
||
local ok, err = installCodeartsAdapter()
|
||
plugin._codearts_adapter = { ok = ok, err = err }
|
||
local tok2, terr2 = installTraeAdapter()
|
||
plugin._trae_adapter = { ok = tok2, err = terr2 }
|
||
local gok, gerr = installGeminiAdapter()
|
||
plugin._gemini_adapter = { ok = gok, err = gerr }
|
||
local qok, qerr = installQoderAdapter()
|
||
plugin._qoder_adapter = { ok = qok, err = qerr }
|
||
local cok, cerr = installClineAdapter()
|
||
plugin._cline_adapter = { ok = cok, err = cerr }
|
||
if not cok then logEvent("Cline 适配器装载失败: " .. tostring(cerr)) end
|
||
if not gok then logEvent("Gemini 适配器装载失败: " .. tostring(gerr)) end
|
||
if not qok then logEvent("Qoder 适配器装载失败: " .. tostring(qerr)) end
|
||
if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end
|
||
end
|
||
|
||
return plugin
|