mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
Cloud Code 凭据**不能**打公开的 /v1beta/models/{model}:generateContent。
参考 gemini.ts 点名三件必须保留,我们此前**一件都没有**(gemini.lua 产的是
公开的 contents/parts 裸体、一个身份头都不发,端点还指着公开路径):
1. **身份五头逐字写死、不许随机**,且**不带** x-goog-api-key /
x-goog-api-client(User-Agent/x-client-name/x-client-version/
x-machine-id/x-vscode-sessionid)
2. **流式刻意不带 Accept**(抓包一致)
3. **信封逐层字母序序列化**(上游按字节比对;Go 的 encoding/json 对 map
自动字母序,而我们用 json.encode,故须自己排——实现 sorted_json)
信封形状(私有端点要的不是裸 contents):
{model, project, request:{contents, systemInstruction, tools,
generationConfig, sessionId}, requestId, userAgent}
响应也是信封(candidates 嵌在 response 里)→ 必须**剥一层**,否则下游
拿不到 candidates,表现为"模型没回话"(静默失败)。
另加前导助手 gemini_response / gemini_stream_chunk(candidates/parts/
usageMetadata → 统一信封;STOP→stop 等 finishReason 映射)。
★ 踩到的两个坑:
- **Lua 长字符串里不能有字面 NUL 字节**(Python heredoc 写的 "\0" 变成了真
字节)→ Lua 报 "unfinished long string near '<eof>'",且报在文件末尾,
完全指不到现场。
- **本 Lua 构建不支持按位异或 `~`**(unexpected symbol near '~')→ FNV-1a
的 XOR 改用算术等价形式。参考自己明言 sessionId 的哈希本体未反推出来、
数值不与原版逐字相同,所以这里要的是**确定性 + 依赖维度正确**
(吃 project 与首条文本、不吃 model),不是数值一致。
- 另给三个自装适配器统一加**本地预编译护栏**:内核装载失败只回"源码无法
编译"不给行号;先 load 一次就能拿到行号(上面两个坑都是靠它定位的)。
⛔ 仍不做:gemini-sigstore(签名持久化,268 行)——推理走无状态适配器,
插件侧缓存会成为死代码。理由已记在 docs/channels-parity.md。
判据(internal/lua/channels_gemini_adapter_test.go):走真实路径断言
身份五头逐值、**反向断言** x-goog-api-key/x-goog-api-client/Accept 不得出现、
信封五个顶层字段齐全、顶层键字母序(解析式校验,不是字符串匹配)、
响应剥信封后 content/finish_reason/token_usage 都到位。
回归:internal/{lua,core,provider,gateway} 全绿。
7709 lines
365 KiB
Lua
7709 lines
365 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", "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" }
|
||
|
||
|
||
|
||
-- ============================================================================
|
||
-- 平台表
|
||
-- ============================================================================
|
||
--
|
||
-- 每个平台声明登录方式与各端点。字段:
|
||
-- 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
|
||
|
||
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}"}',
|
||
},
|
||
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",
|
||
},
|
||
adapter = "openai",
|
||
-- 免费模型(远端 free 数组实测,2026-10-05;内嵌目录只有这 3 个免费)
|
||
models = {
|
||
"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 = "{}",
|
||
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。
|
||
},
|
||
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",
|
||
-- 照参考 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",
|
||
adapter = "openai",
|
||
-- 兜底目录(照参考 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
|
||
local CODEARTS_PORTAL_AUTHORIZE = "https://codearts.huaweicloud.com/portal/authorize"
|
||
local CODEARTS_STS_TOKEN = "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"
|
||
local CODEARTS_CLIENT_ID = "codearts-agent"
|
||
local CODEARTS_REDIRECT_PATH = "/oauth/callback"
|
||
local CODEARTS_LOGIN_PLUGIN_NAME = "snap_AIIDE"
|
||
local CODEARTS_LOGIN_PLUGIN_VERSION = "5.2.0"
|
||
local CODEARTS_OAUTH_THEME = "2"
|
||
local CODEARTS_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
|
||
return { done = true, kind = "inactive",
|
||
message = "今天的每日活动尚未刷新(每日 10:00(UTC+8)刷新),"
|
||
.. "当前看到的是昨天那一轮,请稍后再来领取。" }
|
||
end
|
||
return { done = true, kind = "already-claimed", 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
|
||
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
|
||
local s = plugin.state.settings and plugin.state.settings.store_dir
|
||
if type(s) == "string" and s ~= "" then return 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 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",
|
||
}
|
||
|
||
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"
|
||
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。
|
||
local function credentialIsRefreshable(cred)
|
||
return type(cred) == "table" and type(cred.refresh_token) == "string" and cred.refresh_token ~= ""
|
||
end
|
||
|
||
-- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true }
|
||
-- ============================================================================
|
||
-- 远端模型目录:**远端为主 + 兜底补位**(照参考的 reconcileWithFallback)
|
||
-- ============================================================================
|
||
--
|
||
-- 为什么不只用硬编码名单:上游模型目录是**会变**的(新增/下线/改名),
|
||
-- 硬编码必然过期。为什么不只用远端:远端可能拉不到(未登录/网络抖动/端点变更),
|
||
-- 那时平台就"没有模型可用"了。
|
||
--
|
||
-- 故正确形态是**合并**而非"远端失败就用兜底"的简单回退(参考 cline-models.ts
|
||
-- 明确写了这一条):远端有的以远端为准,远端没有的用兜底表补。
|
||
--
|
||
-- 注意:兜底表是**编译期快照**,价格/倍率会变,故兜底条目不携带价格类字段。
|
||
|
||
-- 取该平台**当前有效**的模型目录(远端优先,兜底补位,剔除黑名单)。
|
||
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
|
||
return out
|
||
end
|
||
|
||
-- parseRemoteModels(parse, body):把各平台响应解析成 id 列表。
|
||
-- 每个分支的字段路径都照参考实现,含注释点名的坑。
|
||
local function parseRemoteModels(kind, body)
|
||
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)
|
||
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)) 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, status = true },
|
||
lobsterai = { claim = true },
|
||
qoder = { claim = true },
|
||
trae = { claim = true },
|
||
cline = { unit = "dollars" },
|
||
loomy = { claim = true },
|
||
raccoon = {},
|
||
minimax = { claim = true, status = true },
|
||
zcode = { claim = true, unit = "tokens" },
|
||
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 = 2,
|
||
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
|
||
|
||
-- 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 窗口里完成交互,机器负责把结果取出来。
|
||
|
||
local function browserStack()
|
||
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
|
||
|
||
local function reapBrowser()
|
||
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
|
||
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)
|
||
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(真实踩过的坑)。
|
||
local function reapLoopback(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
|
||
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
|
||
os.execute("rm -rf " .. profileDir .. " 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
|
||
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", ("%s --no-sandbox --disable-dev-shm-usage --user-data-dir=%s "
|
||
.. "--no-first-run --disable-gpu --remote-debugging-port=%d --remote-allow-origins=* "
|
||
.. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%s.log 2>&1 & echo $!"):format(
|
||
chromium, profileDir, cdpPort, disp, 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
|
||
|
||
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,
|
||
max_concurrent = 2,
|
||
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))
|
||
local outcome = { kind = spec.kind, message = spec.message }
|
||
-- 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/保险库与钩子看到的是同一份。
|
||
plugin.schedule = {
|
||
{ fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 },
|
||
}
|
||
|
||
function plugin.auto_claim()
|
||
-- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
if plugin.state.settings.auto_checkin == false then return end
|
||
for _, plat in ipairs(allPlatforms()) do
|
||
local cap = CHANNEL_CREDITS[plat.id]
|
||
if not cap or not cap.claim then goto continue end
|
||
local legacy = accountOf(plat.id)
|
||
if legacy and legacy.enabled then
|
||
plugin.claim_daily({ platform = plat.id })
|
||
end
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
if a.enabled ~= false and credentialOf(a.credentialRef) then
|
||
plugin.claim_daily({ platform = plat.id, account_id = a.id })
|
||
end
|
||
end
|
||
::continue::
|
||
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/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)
|
||
logEvent("账号删除 " .. plat.id .. "/" .. acct.id)
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- account_test:测账号(对齐 account.test)—— 做一次上游可达探测。
|
||
-- 简化:内核 probe_all 是全局的;这里用 list_sources 里该账号源存在与否 + 凭据非空作为可达判据。
|
||
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 = "无凭据" } } end
|
||
-- 同步一次源(确保最新凭据已入),返回同步结果
|
||
syncAccountSource(plat.id, acct.id)
|
||
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
|
||
if not found then return { status = 200, json = { ok = false, error = "源未同步(上游可能不可达)" } } end
|
||
return { status = 200, json = { ok = true, ms = 1 } }
|
||
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
|
||
local tok = credentialOf(acct.credentialRef)
|
||
if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end
|
||
return { status = 200, json = { ok = true } }
|
||
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
|
||
local out = {}
|
||
for i, m in ipairs(effectiveModels(plat.id)) do
|
||
if not hidden[m] then
|
||
out[#out+1] = { id = m, name = m, disabled = disabled[m] and true or false,
|
||
isFree = free[m] and true or false }
|
||
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 = 2,
|
||
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)。
|
||
local function fetchAccountJSON(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
|
||
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)
|
||
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 disabled = disabledModelsOf(plat.id)
|
||
local removed = {}
|
||
for mid in pairs(disabled) do
|
||
local alive = false
|
||
for _, m in ipairs(effectiveModels(plat.id)) do if m == mid then alive = true break end end
|
||
if not alive then disabled[mid] = nil removed[#removed+1] = mid end
|
||
end
|
||
markDirty()
|
||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||
return { status = 200, json = { ok = true, removed = removed } }
|
||
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 resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000 })
|
||
if err or not resp or resp.status ~= 200 then
|
||
return { status = 200, json = { ok = true, snapshot = { available = false,
|
||
error = err or (resp and tostring(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 = 200, json = { ok = true, snapshot = { available = false, error = "stats 无法解析" } } }
|
||
end
|
||
-- 汇总 token 总量(兼容 stats 的不同形状)
|
||
local prompt, completion, requests = 0, 0, 0
|
||
local rows = rec.requests or rec.rows or rec.data or {}
|
||
if type(rows) == "table" then
|
||
for _, r in ipairs(rows) do
|
||
if type(r) == "table" then
|
||
prompt = prompt + (tonumber(r.prompt_tokens) or 0)
|
||
completion = completion + (tonumber(r.completion_tokens) or 0)
|
||
requests = requests + 1
|
||
end
|
||
end
|
||
end
|
||
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
|
||
]==]
|
||
|
||
-- installTraeAdapter():装/更新本插件自己的 TRAE 直连适配器。
|
||
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
|
||
]==]
|
||
|
||
-- 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.__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 = 2, 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
|
||
|
||
-- 装本插件自己的专用适配器。必须放在**文件末尾**(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 }
|
||
if not gok then logEvent("Gemini 适配器装载失败: " .. tostring(gerr)) end
|
||
if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end
|
||
end
|
||
|
||
return plugin
|