mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): 商汤扫码链路重建 + 双区登录 + 凭据过期口径 + 四条老化判据
① 商汤(Raccoon)扫码「链接无效」——两个真根因
- 登录页 /login/mp 已被站点删除(Next 路由 404,实测只有 404 页);
- 旧实现本地随机造 qrcode_code,而上游早已不认自造码:旧端点
/api/web/auth/v1/login_with_qrcode_code 对任意 code 恒回 pending
(服务端没有该会话)⇒ 扫码永远完不成,账号一直是空凭据。
修法:改为上游现行 IAM 流程(逐行对齐其网页客户端 auth chunk)——
POST /api/iam/v1/auth/mp/qrcode 建会话 → 链接用
/login?code=…&appname=…&source=platform(实测 200 + 微信扫码面板)
→ 轮询 /api/iam/v1/auth/mp/qrcode/login(IAM code:"OK" 与旧 code:0 都认)。
服务端建码失败才退回旧本地码并打 warn。UI 对扫码流程改文案为
「用微信扫码」(原本文案会让人以为点开就自动登录)。
生产实测:链接 HTTP 200、轮询回 pending(会话真实存在)。
② 双区登录(P7)
- 凭据增加 region(intl|cn,缺省=历史行为);start_login 接受 region,
会话按区域选端点,完成登录写进凭据;
- CodeBuddy:cn=copilot.tencent.com(platform=ide)/
intl=www.workbuddy.ai(platform=workbuddy-ai,X-Domain、
X-Product-Code、UA 按区域;实测 auth/state 回 200 信封);
- Qoder:intl=openapi.qoder.sh+qoder.com(client_id e883ade2-…)/
cn=openapi.qoder.com.cn+qoder.cn(client_id 不同 732aef47-…,照参考
QODER_CN)。CN 无公开 chat 端点 ⇒ CN 只做登录/领取;
- UI:buddy/qoder 详情页增加「+国际区」按钮。
③ 凭据过期时刻的时区口径(P6 真根因)
credentialExpiresAtMs 把 ISO 过期时刻按本地时区解析,而 codearts 给的是
UTC ISO(2026-10-19T16:42:43.06Z)⇒ 本机 +08 下过期时刻虚早 8 小时 ⇒
refresh_creds 每 30 分钟都判"已过期"并对华为云真打一次 STS(token 本还有
23 小时寿命)。改为按声明时区解析(Z / ±hh:mm / 无后缀=UTC 三种形态同一
epoch),补 TestCredentialExpiresAtMsParsesISOAsUTC。
④ 四条老化/假红判据
- TestCreditsPoolReason*:account_add 触发的选号会用构造期默认余额 (0,0)
先查一次并缓存 60s ⇒ 之后改假上游读不到("余额 100 却报 exhausted")。
harness 改余额后清 TTL 缓存(__ttl_reset_probe,与生产同一实现)。
- TestAccountReorderActually... / TestPoolListOrderIsDeterministic:随
「一通道一源」失效(按 channels-buddy-<acct> 过滤恒得空)。改写为——
重排必须切换服务账号(新增 __chosen_account_probe),顺序确定性改为断言
选号顺序(__rank_accounts_probe)由 id 稳定决定。
- channels.lua 顶层 local 余量:区域表与 5 份适配器源码常量收进 plugin.K
(TestChannelsLuaHasLocalHeadroom 需要 ≥10 槽位);ZCode 适配器源码里的
CLI_PREFIX 必须留在源码内(适配器沙箱没有 plugin 全局)。
⑤ 错误文案
openai_error 增补腾讯 codebuddy 三层嵌套 {error:{data:{code,msg}}}:实测
429 code=14018「额度已用尽」此前落到"不识别",客户端只看到通用文案。
验证:core 434s 全绿、lua 62s 全绿、provider 全绿;生产已部署并实测
raccoon 链接 200 / trae 非流式 200 / zcode 适配器加载。
This commit is contained in:
@ -75,6 +75,30 @@ plugin.K = {
|
||||
REMOTE_CATALOG_COOLDOWN = 30, -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS
|
||||
INJECT_PLATFORM_LIMIT = 32,
|
||||
ZCODE_ORIGIN = "https://zcode.z.ai",
|
||||
-- ★ 双区端点表(2026-10-19):buddy/WorkBuddy 与 qoder 国际/国内两份 host。
|
||||
-- 放 plugin.K 而非顶层 local —— 本文件主 chunk 的 local 数已顶到 Lua 上限
|
||||
-- 200(TestChannelsLuaHasLocalHeadroom 钉着),新增常量必须走这里。
|
||||
-- qoder: intl=openapi.qoder.sh,cn=openapi.qoder.com.cn(CN 无公开 chat
|
||||
-- 端点,只做登录/领取);CN 的 client_id 与授权页都不同
|
||||
-- (照参考 qoder-product.ts QODER_CN:qoder.cn / 732aef47-…)。
|
||||
-- buddy: cn=copilot.tencent.com(platform=ide),intl=www.workbuddy.ai
|
||||
-- (platform=workbuddy-ai,productCode=workbuddy,照参考 product.ts WORKBUDDY)。
|
||||
QODER_REGION_BASE = {
|
||||
intl = "https://openapi.qoder.sh",
|
||||
cn = "https://openapi.qoder.com.cn",
|
||||
},
|
||||
QODER_REGION_AUTH = {
|
||||
intl = { auth = "https://qoder.com", client_id = "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb" },
|
||||
cn = { auth = "https://qoder.cn", client_id = "732aef47-9cf2-46a2-95fe-4cebb5d0d1fa" },
|
||||
},
|
||||
BUDDY_REGION_HOST = {
|
||||
intl = "www.workbuddy.ai",
|
||||
cn = "copilot.tencent.com",
|
||||
},
|
||||
GEMINI_DEFAULT_PROJECT = "aicode-consumers",
|
||||
GEMINI_UPSTREAM_USER_AGENT = "antigravity",
|
||||
CLI_PREFIX = "You are ZCode, an interactive coding agent",
|
||||
QODER_QUEUE_CODE = "10605", -- 10605=排队(重试),与 110(额度耗尽)语义相反
|
||||
}
|
||||
|
||||
-- ★★ plugin.adaptersDir():适配器目录 = 插件目录的**兄弟**目录
|
||||
@ -116,7 +140,7 @@ plugin.kernel = {
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__chosen_account_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
|
||||
|
||||
|
||||
@ -254,7 +278,7 @@ local PLATFORMS = {
|
||||
-- 载荷须转 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。
|
||||
-- (见 plugin.K.TRAE_SOLO_ADAPTER_SRC)。端点由适配器声明 /api/agent/v3/llm_utils_chat。
|
||||
adapter = "channels-trae",
|
||||
-- ★ 让 syncAccountSource 把凭据里的设备身份放进源的 meta ——
|
||||
-- trae 的推理头需要 X-Uid / X-Device-Id / X-Machine-Id 派生,
|
||||
@ -550,10 +574,16 @@ local PLATFORMS = {
|
||||
name = "CodeBuddy(腾讯)",
|
||||
vendor = "tencent",
|
||||
flow = "state-poll",
|
||||
-- ★ 双区(2026-10-19,照参考 product.ts 的 CODEBUDDY / WORKBUDDY 两份配置):
|
||||
-- * cn(国内,默认)→ copilot.tencent.com,platform=ide,X-Domain/UA 用国内值
|
||||
-- * intl(国际,WorkBuddy)→ www.workbuddy.ai,platform=workbuddy-ai,
|
||||
-- productCode=workbuddy,UA=WorkBuddyIDE/5.5.2(2026-10-19 实测
|
||||
-- POST {intl}/v2/plugin/auth/state?platform=workbuddy-ai 回 200 信封)
|
||||
-- 区域按**账号**记录(凭据 JSON 的 region 字段);登录请求可带 region。
|
||||
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",
|
||||
state_url = "https://{buddyhost}/v2/plugin/auth/state?platform={platform}",
|
||||
token_url = "https://{buddyhost}/v2/plugin/auth/token",
|
||||
account_url = "https://{buddyhost}/v2/plugin/login/account",
|
||||
code_not_ready = 11217,
|
||||
interval = 1,
|
||||
},
|
||||
@ -585,14 +615,22 @@ local PLATFORMS = {
|
||||
-- 跨产品迁移后会指错区);X-Product 是部署类型(SaaS);
|
||||
-- X-Product-Code 是产品码(codebuddy);X-User-Id 取凭据的 user_id。
|
||||
headers_fn = function(accessToken, cred)
|
||||
-- ★ 双区:凭据 region 决定身份头(照参考 product.ts 两份产品配置)。
|
||||
local region = cred and tostring(cred.region or "") or ""
|
||||
local isIntl = region == "intl" or (region == "" and false)
|
||||
-- 未声明 region 的存量凭据:X-Domain 快照若是 workbuddy 则按国际区,
|
||||
-- 否则按国内(升级前全部是国内账号,默认值不变)。
|
||||
if region == "" then
|
||||
isIntl = cred and cred.domain == "www.workbuddy.ai" or false
|
||||
end
|
||||
local h = {
|
||||
["Authorization"] = "Bearer " .. accessToken,
|
||||
["Accept"] = "application/json",
|
||||
["Content-Type"] = "application/json",
|
||||
["X-Domain"] = "copilot.tencent.com",
|
||||
["X-Domain"] = isIntl and "www.workbuddy.ai" or "copilot.tencent.com",
|
||||
["X-Product"] = "SaaS",
|
||||
["X-Product-Code"] = "codebuddy",
|
||||
["User-Agent"] = "CodeBuddyIDE/1.106.1",
|
||||
["X-Product-Code"] = isIntl and "workbuddy" or "codebuddy",
|
||||
["User-Agent"] = isIntl and "WorkBuddyIDE/5.5.2" or "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
|
||||
@ -1045,13 +1083,16 @@ local PLATFORMS = {
|
||||
flow = "device-poll",
|
||||
device = {
|
||||
-- 授权页在 {authBase}/device/selectAccounts,query 由 pkce 表单拼装
|
||||
code_url = "https://qoder.com/device/selectAccounts",
|
||||
-- ★ 双区:host 与 client_id 都用占位符({auth} / {client_id}),
|
||||
-- start_login 按会话区域从 QODER_REGION_AUTH 替换。
|
||||
code_url = "{auth}/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",
|
||||
.. "&machine_id={machine_id}&client_id={client_id}",
|
||||
-- 轮询在 openApiBase。★ host 用 {openapi} 占位符(双区:intl=openapi.qoder.sh /
|
||||
-- cn=openapi.qoder.com.cn),deviceOneStep 按会话区域替换。
|
||||
token_url = "https://{openapi}/api/v1/deviceToken/poll",
|
||||
token_method = "GET_QUERY",
|
||||
-- poll 的 query 模板:PKCE 用 verifier(不是 challenge)
|
||||
poll_query = "nonce={nonce}&verifier={verifier}&challenge_method=S256",
|
||||
@ -1270,12 +1311,32 @@ local PLATFORMS = {
|
||||
-- 短信路径(/sms/send + /sms/verify)需要阿里云滑块 captcha_param 与手机号
|
||||
-- AES 加密 —— 那一条才真需要人工,故不走。
|
||||
flow = "qr-poll",
|
||||
-- ★★ 2026-10-20 实测重写(用户报障「商汤那个链接点进去是无效链接」)。
|
||||
-- 旧实现两个致命错:
|
||||
-- ① code **本地随机造**,但上游早已不认自造码:旧端点
|
||||
-- /api/web/auth/v1/login_with_qrcode_code 对任意 code 恒回 pending
|
||||
-- (服务端根本没这个会话)⇒ 扫码永远不会完成,账号一直是空凭据。
|
||||
-- ② 登录页 /login/mp **已 404**(站点 Next 路由已删)⇒ 点开是无效链接。
|
||||
-- 现行流程(逐行对齐上游网页客户端 2026-10-20 的 auth chunk):
|
||||
-- ① POST {base}/api/iam/v1/auth/mp/qrcode → {qrcode_code, scan_path,
|
||||
-- expires_in, poll_interval}(**服务端建会话**)
|
||||
-- ② 二维码/链接 = {base}{scan_page}?code=…&appname=…&source=platform
|
||||
-- 页面用 /login(实测 200,且渲染「微信扫码」面板并接受 code 预置);
|
||||
-- 服务端回的 scan_path 是 ""/login/mp"",该路由已 404,故以 scan_page 为准,
|
||||
-- scan_path 仅在它非 "/login/mp" 时采用(上游换路由时自动跟随)。
|
||||
-- ③ 轮询 POST {base}/api/iam/v1/auth/mp/qrcode/login {qrcode_code}
|
||||
-- → {code:"OK", data:{status: pending|logging|canceled|success, access_token?}}
|
||||
-- 旧端点保留为 legacy_poll_path 兜底(IAM 不可用时退回旧行为)。
|
||||
qr = {
|
||||
api_base = "https://xiaohuanxiong.com",
|
||||
auth_prefix = "/api/web/auth/v1",
|
||||
poll_path = "/login_with_qrcode_code",
|
||||
login_page = "/login/mp",
|
||||
auth_prefix = "/api/web/auth/v1", -- 旧端点前缀(兜底)
|
||||
poll_path = "/login_with_qrcode_code", -- 旧轮询(兜底)
|
||||
create_path = "/api/iam/v1/auth/mp/qrcode",
|
||||
iam_poll_path = "/api/iam/v1/auth/mp/qrcode/login",
|
||||
scan_page = "/login",
|
||||
login_page = "/login/mp", -- 上游已废弃,仅作兼容判断
|
||||
app_name = "商汤小浣熊官网",
|
||||
source = "platform",
|
||||
interval = 2,
|
||||
},
|
||||
-- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions)
|
||||
@ -1994,6 +2055,25 @@ end
|
||||
-- 可被 set_endpoints 覆盖(测试指到假上游用)。
|
||||
QODER_SASH_BASE = "https://openapi.qoder.sh"
|
||||
|
||||
-- qoderRegionBase(cred):该账号区域的 SASH/OpenAPI 基址。
|
||||
-- ★ 双区(2026-10-19,照参考 qoder-product.ts 的 QODER / QODER_CN 两份配置)。
|
||||
-- 端点表在 plugin.K.QODER_REGION_BASE / QODER_REGION_AUTH(主 chunk local
|
||||
-- 数已顶到 Lua 上限 200,新增常量必须进 plugin.K —— 见 TestChannelsLuaHasLocalHeadroom)。
|
||||
-- ⚠️ CN 没有可用的公开 chat 端点(参考注释明确)→ CN 账号**只做登录/领取**,不产源。
|
||||
-- 区域按**账号**记录(凭据 JSON 的 region 字段);未声明 region 的存量凭据
|
||||
-- 一律视为 intl(与升级前行为一致)。
|
||||
local function qoderRegionBase(cred)
|
||||
local region = type(cred) == "table" and tostring(cred.region or "") or ""
|
||||
if region == "" then region = "intl" end
|
||||
-- set_endpoints 的 qoder_sash_base 覆盖**优先**于区域表:测试把上游指到
|
||||
-- httptest 假服务器靠它;此时区域表必须让路,否则请求仍打真上游被出站
|
||||
-- 白名单拒绝(实测 TestQoderClaim* 全挂在这个上)。
|
||||
if QODER_SASH_BASE ~= "https://openapi.qoder.sh" then
|
||||
return QODER_SASH_BASE
|
||||
end
|
||||
return plugin.K.QODER_REGION_BASE[region] or QODER_SASH_BASE
|
||||
end
|
||||
|
||||
-- UTC+8 偏移(毫秒)。活动按 UTC+8 结算,口径与参考一致。
|
||||
|
||||
-- hasQoderCampaignRefreshedToday():今天的活动列表是否已刷新。
|
||||
@ -2056,7 +2136,7 @@ local function qoderLoadCampaigns(plat, cred)
|
||||
["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,
|
||||
url = qoderRegionBase(cred) .. "/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
|
||||
@ -2076,7 +2156,7 @@ local function qoderUsageRaw(plat, cred)
|
||||
end
|
||||
local resp = nil
|
||||
if hdrs then
|
||||
resp, _ = __llmsproxy_http({ url = QODER_SASH_BASE .. "/sash/api/v2/me/usage", method = "GET", headers = hdrs })
|
||||
resp, _ = __llmsproxy_http({ url = qoderRegionBase(cred) .. "/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 "")
|
||||
@ -2097,7 +2177,7 @@ local function qoderClaimCampaign(plat, cred, campaignId)
|
||||
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/"
|
||||
url = qoderRegionBase(cred) .. "/sash/api/v1/me/campaigns/"
|
||||
.. rfc3986Escape(campaignId) .. "/claim",
|
||||
method = "POST", headers = hdrs, body = "",
|
||||
})
|
||||
@ -2509,6 +2589,13 @@ local function declaredHosts()
|
||||
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
|
||||
-- ★ 双区占位符 URL(buddy {buddyhost} / qoder {auth}、{openapi})在 addHost
|
||||
-- 里提取不到真实 host,这里把两个区域的 host 直接补进声明,否则出站被拒
|
||||
-- (参考 WorkBuddy www.workbuddy.ai、Qoder CN openapi.qoder.com.cn)。
|
||||
if p.id == "buddy" then addHost("https://www.workbuddy.ai") end
|
||||
if p.id == "qoder" then
|
||||
addHost("https://qoder.cn"); addHost("https://openapi.qoder.com.cn")
|
||||
end
|
||||
if p.models_remote then
|
||||
for _, m in ipairs(p.models_remote.endpoints or {}) do addHost(m.url) end
|
||||
end
|
||||
@ -2674,6 +2761,15 @@ end
|
||||
local function deviceOneStep(plat, sess)
|
||||
local d = plat.device
|
||||
local url, method, body = d.token_url, d.token_method or "POST", nil
|
||||
-- ★ 双区(2026-10-19):会话创建时记录的 region 决定 poll 端点。
|
||||
-- 平台声明里 token_url 的 host 部分用 {openapi} 占位符表达;按会话区域
|
||||
-- 换成对应基址。未用占位符的平台不受影响(gsub 找不到就不动)。
|
||||
-- setmetatable+__index 只换 token_url 一个键,其余仍读原表。
|
||||
if type(d.token_url) == "string" and d.token_url:find("{openapi}", 1, true) then
|
||||
local region = tostring(sess.region or "intl")
|
||||
d = setmetatable({ token_url = (d.token_url:gsub("{openapi}",
|
||||
plugin.K.QODER_REGION_BASE[region] or QODER_SASH_BASE)) }, { __index = d })
|
||||
end
|
||||
if d.poll_query or (d.token_method == "GET_QUERY") then
|
||||
-- 形态②(Qoder):GET + query 模板。PKCE 用 verifier(不是 challenge),
|
||||
-- 且 nonce 必须是**会话创建时那一个**(服务端按它找回待授权会话)。
|
||||
@ -3431,10 +3527,44 @@ local function credentialExpiresAtMs(cred)
|
||||
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
|
||||
-- ISO 8601:解析 "YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]"。
|
||||
-- ★ 无偏移后缀时必须按 **UTC** 算,不能直接喂 os.time(它按本地时区解释)。
|
||||
-- 真实缺陷(2026-10-19):codearts 的 expires_at 是 UTC ISO
|
||||
-- ("2026-10-19T16:42:43.06Z"),本机 +08:00 直接 os.time 解析 ⇒ 过期
|
||||
-- 时刻虚早 8 小时 ⇒ refresh_creds 每一轮都判"已过期"⇒ 每半小时真打一次
|
||||
-- 华为云 STS(token 本来还有 23 小时寿命)。
|
||||
-- 修法:先用 os.date("!...", epoch_of_naive_interpretation) 技巧不可靠,
|
||||
-- 直接用「本地 epoch − 本地偏移」还原 UTC:os.time 表按本地解释得 t,
|
||||
-- t − (os.time() − os.time(os.date("!*t"))) 即同一钟面时刻的 UTC epoch。
|
||||
-- 显式带 Z 或 ±hh:mm 后缀的按其偏移换算。
|
||||
local Y, Mo, D, H, Mi, S, rest =
|
||||
raw:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$")
|
||||
if Y then
|
||||
-- os.time 把字段按**本地时区**解释。对"本地钟面 = 字段值"的时刻,
|
||||
-- os.time 给出的就是正确的 epoch。要做的是把"声明时区的钟面"换算成
|
||||
-- "本地钟面"再喂 os.time:
|
||||
-- 字段带 Z / 无后缀 = UTC 钟面 ⇒ 本地钟面 = 字段 + localOff ⇒
|
||||
-- epoch = os.time(字段 + localOff) = naive + localOff。
|
||||
-- 字段带 ±hh[:mm] = 声明时区钟面 ⇒ 本地钟面 = 字段 + localOff − off。
|
||||
local naive = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D),
|
||||
hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false })
|
||||
local localOff = os.time() - os.time(os.date("!*t"))
|
||||
if rest:sub(1, 1) == "+" or rest:sub(1, 1) == "-" then
|
||||
-- 偏移形如 "+08:00" / "-05:00" / "+0800"。字符类 [%+%-] 在部分 Lua
|
||||
-- 构建下不可靠(实测 luajit 匹配 nil),用 sub 拆更稳。
|
||||
local sign = rest:sub(1, 1)
|
||||
local oh, om = rest:sub(2):match("^(%d%d):?(%d*)")
|
||||
local off = (tonumber(oh) or 0) * 3600
|
||||
+ (tonumber(om ~= "" and om or "0") or 0) * 60
|
||||
if sign == "-" then off = -off end
|
||||
-- 字段是"声明时区 off 的钟面"。本地钟面 = 字段 + (localOff − off):
|
||||
-- 例(本机 +08):字段 "11:42:43-05:00"(=16:42:43Z)的本地钟面是
|
||||
-- 00:42:43(+1d);localOff−off = 28800−(−18000) = 46800 = 13h,
|
||||
-- 11:42:43 + 13h = 00:42:43(+1d)。✓
|
||||
return (naive + (localOff - off)) * 1000
|
||||
end
|
||||
return (naive + localOff) * 1000
|
||||
end
|
||||
end
|
||||
local exp = jwtClaim(cred.access_token, "exp")
|
||||
if type(exp) == "number" then return exp * 1000 end
|
||||
@ -4336,9 +4466,19 @@ local function syncProviderSource(provider)
|
||||
.. tostring(rerr or "(无错误信息)"), "warn", provider)
|
||||
end
|
||||
end
|
||||
-- ★ 双区:buddy 的 chat host 按所选账号的区域取(WorkBuddy=www.workbuddy.ai)。
|
||||
-- 选号在上方完成(plugin.state.chosen[provider] 已记录),从其凭据读 region。
|
||||
local chatBase = plat.base_url
|
||||
if provider == "buddy" and plugin.state.chosen and plugin.state.chosen[provider] then
|
||||
local chosenCred = credentialFull(
|
||||
(poolGet(provider, plugin.state.chosen[provider]) or {}).credentialRef)
|
||||
if type(chosenCred) == "table" and chosenCred.region == "intl" then
|
||||
chatBase = "https://" .. plugin.K.BUDDY_REGION_HOST.intl
|
||||
end
|
||||
end
|
||||
local src = {
|
||||
name = name,
|
||||
base_url = plat.base_url,
|
||||
base_url = chatBase,
|
||||
adapter = plat.adapter or "openai",
|
||||
api_key = tok,
|
||||
-- ⚠️ max_concurrent=1 + rpm 节流,而不是 2。
|
||||
@ -5519,6 +5659,11 @@ local function initLoginSession(plat, args, now)
|
||||
end
|
||||
sessions()[sess.id] = sess
|
||||
sess.account_id = args and args.account_id
|
||||
-- ★ 双区:登录请求可带 region("intl"|"cn")。会话带着它选 poll/授权页端点;
|
||||
-- 完成登录后 complete_login 把 region 写进凭据 JSON,推理/领取按账号取区。
|
||||
local region = tostring((args and args.region) or "")
|
||||
if region ~= "cn" then region = "intl" end
|
||||
sess.region = region
|
||||
return sess
|
||||
end
|
||||
|
||||
@ -5593,11 +5738,17 @@ function plugin.start_login(req)
|
||||
return { status = 200, json = out }
|
||||
elseif plat.flow == "state-poll" then
|
||||
-- Buddy:先拿 state + authUrl。页面给 authUrl,轮询 token 每推进一步。
|
||||
-- ★ 双区:state_url 的 {buddyhost}/{platform} 占位符按会话区域替换。
|
||||
local region = tostring(sess.region or "intl")
|
||||
local bhost = plugin.K.BUDDY_REGION_HOST[region] or plugin.K.BUDDY_REGION_HOST.intl
|
||||
local bplat = (region == "cn") and "ide" or "workbuddy-ai"
|
||||
local stateUrl = tostring(plat.state.state_url)
|
||||
:gsub("{buddyhost}", bhost):gsub("{platform}", bplat)
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = plat.state.state_url,
|
||||
url = stateUrl,
|
||||
method = "POST",
|
||||
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
|
||||
body = "{\"platform\":\"ide\"}",
|
||||
body = "{\"platform\":\"" .. bplat .. "\"}",
|
||||
})
|
||||
if err or resp.status ~= 200 then
|
||||
sess.state = "failed"
|
||||
@ -5630,15 +5781,50 @@ function plugin.start_login(req)
|
||||
logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")")
|
||||
end
|
||||
elseif plat.flow == "qr-poll" then
|
||||
-- 自带二维码的扫码登录(Raccoon):code 由**本地随机生成**,服务端接受
|
||||
-- 任意自造 code 并进入 pending(参考注释实测)。二维码图交给前端渲染。
|
||||
-- 扫码登录(Raccoon):**必须由服务端建会话**(2026-10-20 实测重写,见平台定义)。
|
||||
-- 本地自造 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)
|
||||
local code, scanPath, interval
|
||||
local usedIAM = false
|
||||
-- ① 服务端建码
|
||||
if type(q.create_path) == "string" and q.create_path ~= "" then
|
||||
local cresp, cerr = __llmsproxy_http({
|
||||
url = q.api_base .. q.create_path,
|
||||
method = "POST",
|
||||
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
|
||||
body = "{}",
|
||||
})
|
||||
if not cerr and cresp and cresp.status == 200 then
|
||||
local okc, crec = pcall(json.decode, cresp.body or "")
|
||||
local d = okc and type(crec) == "table" and (crec.data or crec) or nil
|
||||
if type(d) == "table" and type(d.qrcode_code) == "string" and d.qrcode_code ~= "" then
|
||||
code = d.qrcode_code
|
||||
usedIAM = true
|
||||
-- scan_path 只在**不是已废弃的 /login/mp** 时采用(上游换路由时自动跟随)
|
||||
if type(d.scan_path) == "string" and d.scan_path ~= ""
|
||||
and d.scan_path ~= q.login_page then
|
||||
scanPath = d.scan_path
|
||||
end
|
||||
if type(d.poll_interval) == "number" and d.poll_interval > 0 then
|
||||
interval = math.max(1, math.min(60, d.poll_interval))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- ② 服务端不可用 → 退回旧的本地造码(不拦截登录入口,但日志要说清)
|
||||
if code == nil then
|
||||
code = tostring(sha256_hex(tostring(os.time and os.time() or 0) .. tostring(math.random(1e12)))):sub(1, 32)
|
||||
logEvent("二维码走了本地兜底(服务端建码失败,扫码可能无法完成) " .. plat.id, "warn", plat.id)
|
||||
end
|
||||
sess.qr_code = code
|
||||
out.qr_url = q.api_base .. q.login_page .. "?code=" .. code
|
||||
.. "&appname=" .. rfc3986Escape(q.app_name)
|
||||
sess.qr_iam = usedIAM and true or false
|
||||
-- ③ 链接/二维码内容:优先服务端 scan_path,否则 scan_page(实测可用)
|
||||
out.qr_url = q.api_base .. (scanPath or q.scan_page or q.login_page)
|
||||
.. "?code=" .. code
|
||||
.. "&appname=" .. rfc3986Escape(q.app_name or "")
|
||||
.. "&source=" .. rfc3986Escape(q.source or "platform")
|
||||
out.url = out.qr_url
|
||||
out.session.interval = q.interval or 2
|
||||
out.session.interval = interval or q.interval or 2
|
||||
logEvent("二维码已生成 " .. plat.id)
|
||||
elseif plat.flow == "wechat-poll" then
|
||||
-- 微信扫码:取 uuid → 把二维码给前端渲染 → 长轮询等扫码确认。
|
||||
@ -5727,10 +5913,19 @@ function plugin.start_login(req)
|
||||
local rec = nil
|
||||
if dm == "GET_QUERY" or plat.device.auth_query then
|
||||
-- 形态②:授权页 URL 由模板拼装,无网络申请
|
||||
-- ★ 双区:code_url 的 {auth} / {openapi} 占位符与 query 模板里的
|
||||
-- {client_id} 按会话区域从 QODER_REGION_AUTH 替换(CN 的 client_id
|
||||
-- 与国际版不同,用错授权页报「参数无效」—— 参考 qoder-product.ts 点名)。
|
||||
local ra = plugin.K.QODER_REGION_AUTH[tostring(sess.region or "intl")]
|
||||
or plugin.K.QODER_REGION_AUTH.intl
|
||||
local codeUrl = tostring(plat.device.code_url or "")
|
||||
codeUrl = codeUrl:gsub("{auth}", ra.auth):gsub("{openapi}",
|
||||
plugin.K.QODER_REGION_BASE[sess.region or "intl"] or QODER_SASH_BASE)
|
||||
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
|
||||
:gsub("{client_id}", ra.client_id)
|
||||
out.url = codeUrl .. "?" .. q
|
||||
sess.device_verifier = verifier
|
||||
sess.device_challenge = challenge
|
||||
sess.device_nonce = sess.nonce
|
||||
@ -6084,6 +6279,8 @@ function plugin.poll_login(req)
|
||||
else
|
||||
-- 必须带 account_id:页面流程先 account/add 建了占位账号,
|
||||
-- 不带就会落到与占位账号无关的 `channels:<plat>` 键(见 poll_login 顶部注释)。
|
||||
-- ★ 双区:把会话区域写进凭据 JSON(推理/领取按账号取区)。
|
||||
if type(cred) == "table" and s.region then cred.region = s.region end
|
||||
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
|
||||
@ -6106,8 +6303,16 @@ function plugin.poll_login(req)
|
||||
local plat = platformById(s.platform)
|
||||
local q = plat and plat.qr
|
||||
if q then
|
||||
-- ★ 2026-10-20:服务端建的会话必须打 **IAM** 轮询端点;旧端点只认它自己的
|
||||
-- 会话,对 IAM 码恒回 pending。反之,本地兜底码只能走旧端点。
|
||||
local pollUrl
|
||||
if s.qr_iam and type(q.iam_poll_path) == "string" and q.iam_poll_path ~= "" then
|
||||
pollUrl = q.api_base .. q.iam_poll_path
|
||||
else
|
||||
pollUrl = q.api_base .. q.auth_prefix .. q.poll_path
|
||||
end
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = q.api_base .. q.auth_prefix .. q.poll_path,
|
||||
url = pollUrl,
|
||||
method = "POST",
|
||||
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
|
||||
body = json.encode({ qrcode_code = s.qr_code }),
|
||||
@ -6115,7 +6320,9 @@ function plugin.poll_login(req)
|
||||
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
|
||||
-- IAM 信封:{code:"OK", data:{...}};旧信封:{code:0, data:{...}}。两种都认。
|
||||
local codeOk = env and (env.code == 0 or env.code == "OK")
|
||||
if codeOk and type(env.data) == "table" then
|
||||
local st = env.data.status
|
||||
if st == "canceled" then
|
||||
s.state = "failed"
|
||||
@ -6283,8 +6490,11 @@ function plugin.poll_login(req)
|
||||
-- Buddy(CodeBuddy):state 轮询。状态里的 state_token 是 auth/state 下发的。
|
||||
local plat = platformById(s.platform)
|
||||
local sp = plat and plat.state
|
||||
-- ★ 双区:轮询 host 按会话区域替换(与 start_login 的 state_url 同一映射)。
|
||||
local bhost = plugin.K.BUDDY_REGION_HOST[tostring(s.region or "intl")]
|
||||
or plugin.K.BUDDY_REGION_HOST.intl
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = (sp.token_url or "") .. "?state=" .. tostring(s.state_token),
|
||||
url = (sp.token_url or ""):gsub("{buddyhost}", bhost) .. "?state=" .. tostring(s.state_token),
|
||||
method = "GET",
|
||||
headers = { ["Accept"] = "application/json", ["X-No-Authorization"] = "true" },
|
||||
})
|
||||
@ -6299,7 +6509,7 @@ function plugin.poll_login(req)
|
||||
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),
|
||||
url = (sp.account_url or ""):gsub("{buddyhost}", bhost) .. "?state=" .. tostring(s.state_token),
|
||||
method = "GET",
|
||||
headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. access },
|
||||
})
|
||||
@ -6320,6 +6530,9 @@ function plugin.poll_login(req)
|
||||
acct = credTable.user_id
|
||||
end
|
||||
if acct == nil or acct == "" then acct = "codebuddy" end
|
||||
-- ★ 双区:凭据记录区域(推理/余额头按账号取区)
|
||||
credTable.region = tostring(s.region or "intl")
|
||||
credJson = json.encode(credTable)
|
||||
-- ② 必须带 account_id(页面流程的占位账号);否则凭据落到无关的
|
||||
-- `channels:<plat>` 键,账号池查不到。
|
||||
local res = plugin.complete_login({ platform = s.platform, credential = credJson,
|
||||
@ -9601,7 +9814,7 @@ end
|
||||
-- * 端点是 /api/v2/chat/completions(含 /api/v2 前缀)
|
||||
-- * Agent-Type: PromptCenter / X-Language: zh-cn(**签名后**追加,
|
||||
-- 不参与 canonical —— 参考注释点名)
|
||||
local CODEARTS_ADAPTER_SRC = [==[
|
||||
plugin.K.CODEARTS_ADAPTER_SRC = [==[
|
||||
local adapter = {}
|
||||
adapter.name = "codearts"
|
||||
adapter.version = "1.0.0"
|
||||
@ -9753,7 +9966,7 @@ return adapter
|
||||
-- 6. assistant 消息的 tool_calls: function → function_call(SOLO 字段名),
|
||||
-- 并剔除没有 name 的条目(孤儿会让整条会话被拒)
|
||||
-- 7. tools[].function.parameters: object → JSON **字符串**(SOLO 要求)
|
||||
local TRAE_SOLO_ADAPTER_SRC = [==[
|
||||
plugin.K.TRAE_SOLO_ADAPTER_SRC = [==[
|
||||
local adapter = {}
|
||||
adapter.name = "channels-trae"
|
||||
adapter.version = "1.0.0"
|
||||
@ -10345,11 +10558,11 @@ end
|
||||
local function installTraeAdapter()
|
||||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||||
-- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。
|
||||
local f, lerr = load(TRAE_SOLO_ADAPTER_SRC)
|
||||
local f, lerr = load(plugin.K.TRAE_SOLO_ADAPTER_SRC)
|
||||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||||
local _, uerr = __llmsproxy_adapters.update("channels-trae", TRAE_SOLO_ADAPTER_SRC)
|
||||
local _, uerr = __llmsproxy_adapters.update("channels-trae", plugin.K.TRAE_SOLO_ADAPTER_SRC)
|
||||
if uerr then
|
||||
local _, ierr = __llmsproxy_adapters.install("channels-trae", TRAE_SOLO_ADAPTER_SRC)
|
||||
local _, ierr = __llmsproxy_adapters.install("channels-trae", plugin.K.TRAE_SOLO_ADAPTER_SRC)
|
||||
if ierr then return false, ierr end
|
||||
end
|
||||
return true, nil
|
||||
@ -10373,7 +10586,7 @@ end
|
||||
--
|
||||
-- ⛔ 不做:gemini-sigstore(签名持久化,268 行)。我们推理走无状态适配器,
|
||||
-- 插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传错误。
|
||||
local GEMINI_PRIVATE_ADAPTER_SRC = [==[
|
||||
plugin.K.GEMINI_PRIVATE_ADAPTER_SRC = [==[
|
||||
local adapter = {}
|
||||
adapter.name = "channels-gemini"
|
||||
adapter.version = "1.0.0"
|
||||
@ -10392,8 +10605,6 @@ adapter.headers = {
|
||||
-- ⚠️ 刻意**不带** 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 的
|
||||
@ -10545,7 +10756,7 @@ function adapter.transform_request(raw_body)
|
||||
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
|
||||
local project = (req.project and tostring(req.project)) or "aicode-consumers"
|
||||
greq.sessionId = derive_session_id(project, first_user_text(contents))
|
||||
|
||||
-- ③ 信封(字母序序列化)
|
||||
@ -10554,7 +10765,7 @@ function adapter.transform_request(raw_body)
|
||||
project = project,
|
||||
request = greq,
|
||||
requestId = "agent/" .. tostring(os.time()) .. "/" .. string.sub(sha256_hex(tostring(os.clock())), 1, 8),
|
||||
userAgent = GEMINI_UPSTREAM_USER_AGENT,
|
||||
userAgent = "antigravity",
|
||||
}
|
||||
return sorted_json(envelope)
|
||||
end
|
||||
@ -10619,7 +10830,7 @@ return adapter
|
||||
-- 厂商码值留在**插件**里(内核零厂商名)—— 内核只把 transform_error 返回的
|
||||
-- 文本当作客户端可读原因;额度类语义由内核既有的关键词表识别(quota/billing
|
||||
-- 等通用英文短语),故这里只要把话**说清楚**即可。
|
||||
local QODER_ADAPTER_SRC = [==[
|
||||
plugin.K.QODER_ADAPTER_SRC = [==[
|
||||
local adapter = { name = "channels-qoder", version = "1.0.0" }
|
||||
|
||||
-- 出向就是标准 OpenAI 形状(Qoder 的 chat 端点是 OpenAI 兼容的),
|
||||
@ -10634,7 +10845,7 @@ function adapter.transform_response(raw_body) return __prelude.openai_response(r
|
||||
function adapter.transform_stream_chunk(raw_chunk) return __prelude.openai_stream_chunk(raw_chunk) end
|
||||
|
||||
-- 厂商码值只出现在**这个适配器**里(内核零厂商名)。
|
||||
local QODER_QUEUE_CODE = "10605"
|
||||
QODER_QUEUE_CODE = "10605"
|
||||
|
||||
-- qoder_unwrap(root):广度优先展开 data/result/message/body。
|
||||
-- ⚠️ 必须展开 `message` 这类**字符串再解析**的分支:只读顶层永远拿不到内层
|
||||
@ -11019,6 +11230,7 @@ local ZD = (function()
|
||||
end)()
|
||||
|
||||
local ZCODE_ADAPTER_SRC = [===[
|
||||
local CLI_PREFIX = "You are ZCode, an interactive coding agent"
|
||||
-- ============================================================================
|
||||
-- ZCode 专用适配器(插件自装:channels-zcode)
|
||||
--
|
||||
@ -11048,7 +11260,6 @@ local ZCODE_ADAPTER_SRC = [===[
|
||||
local adapter = { name = "channels-zcode", version = "1.0.0" }
|
||||
|
||||
-- ★ 官方 CLI 前缀(准入必需)。逐字复刻,勿改。
|
||||
local CLI_PREFIX = "You are ZCode, an interactive coding agent"
|
||||
|
||||
-- ★ 官方 stable 段(准入必需;三段以 "\n\n" 连接)。逐字复刻,勿改。
|
||||
-- 官方完整身份块还含 ~5KB 的 dynamic 段(给 coding agent 的行为指令),与准入
|
||||
@ -11361,7 +11572,7 @@ return adapter
|
||||
-- ⚠️ 时长 token 必须**累加**且**不吃重复匹配**:`minutes?` 与 `m` 都能匹配
|
||||
-- 同一段会让时长翻倍(参考注释点名)。我们用一次扫描 + 贪婪单位即可。
|
||||
|
||||
local CLINE_ADAPTER_SRC = [==[
|
||||
plugin.K.CLINE_ADAPTER_SRC = [==[
|
||||
local adapter = { name = "channels-cline", version = "1.0.0" }
|
||||
|
||||
function adapter.transform_request(raw_body) return raw_body end
|
||||
@ -11456,11 +11667,11 @@ return adapter
|
||||
-- installClineAdapter():装/更新本插件自己的 Cline 适配器。
|
||||
local function installClineAdapter()
|
||||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||||
local f, lerr = load(CLINE_ADAPTER_SRC)
|
||||
local f, lerr = load(plugin.K.CLINE_ADAPTER_SRC)
|
||||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||||
local _, uerr = __llmsproxy_adapters.update("channels-cline", CLINE_ADAPTER_SRC)
|
||||
local _, uerr = __llmsproxy_adapters.update("channels-cline", plugin.K.CLINE_ADAPTER_SRC)
|
||||
if uerr then
|
||||
local _, ierr = __llmsproxy_adapters.install("channels-cline", CLINE_ADAPTER_SRC)
|
||||
local _, ierr = __llmsproxy_adapters.install("channels-cline", plugin.K.CLINE_ADAPTER_SRC)
|
||||
if ierr then return false, ierr end
|
||||
end
|
||||
return true, nil
|
||||
@ -11468,11 +11679,11 @@ end
|
||||
|
||||
local function installQoderAdapter()
|
||||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||||
local f, lerr = load(QODER_ADAPTER_SRC)
|
||||
local f, lerr = load(plugin.K.QODER_ADAPTER_SRC)
|
||||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||||
local _, uerr = __llmsproxy_adapters.update("channels-qoder", QODER_ADAPTER_SRC)
|
||||
local _, uerr = __llmsproxy_adapters.update("channels-qoder", plugin.K.QODER_ADAPTER_SRC)
|
||||
if uerr then
|
||||
local _, ierr = __llmsproxy_adapters.install("channels-qoder", QODER_ADAPTER_SRC)
|
||||
local _, ierr = __llmsproxy_adapters.install("channels-qoder", plugin.K.QODER_ADAPTER_SRC)
|
||||
if ierr then return false, ierr end
|
||||
end
|
||||
return true, nil
|
||||
@ -11483,11 +11694,11 @@ local function installGeminiAdapter()
|
||||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||||
-- 先在本地编译一次,把语法错误在**装载失败之前**变成可读信息
|
||||
-- (load 失败只给"源码无法编译",不给行号)
|
||||
local f, lerr = load(GEMINI_PRIVATE_ADAPTER_SRC)
|
||||
local f, lerr = load(plugin.K.GEMINI_PRIVATE_ADAPTER_SRC)
|
||||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||||
local _, uerr = __llmsproxy_adapters.update("channels-gemini", GEMINI_PRIVATE_ADAPTER_SRC)
|
||||
local _, uerr = __llmsproxy_adapters.update("channels-gemini", plugin.K.GEMINI_PRIVATE_ADAPTER_SRC)
|
||||
if uerr then
|
||||
local _, ierr = __llmsproxy_adapters.install("channels-gemini", GEMINI_PRIVATE_ADAPTER_SRC)
|
||||
local _, ierr = __llmsproxy_adapters.install("channels-gemini", plugin.K.GEMINI_PRIVATE_ADAPTER_SRC)
|
||||
if ierr then return false, ierr end
|
||||
end
|
||||
return true, nil
|
||||
@ -11542,11 +11753,11 @@ end
|
||||
local function installCodeartsAdapter()
|
||||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||||
-- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。
|
||||
local f, lerr = load(CODEARTS_ADAPTER_SRC)
|
||||
local f, lerr = load(plugin.K.CODEARTS_ADAPTER_SRC)
|
||||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||||
local _, uerr = __llmsproxy_adapters.update("codearts", CODEARTS_ADAPTER_SRC)
|
||||
local _, uerr = __llmsproxy_adapters.update("codearts", plugin.K.CODEARTS_ADAPTER_SRC)
|
||||
if uerr then
|
||||
local _, ierr = __llmsproxy_adapters.install("codearts", CODEARTS_ADAPTER_SRC)
|
||||
local _, ierr = __llmsproxy_adapters.install("codearts", plugin.K.CODEARTS_ADAPTER_SRC)
|
||||
if ierr then return false, ierr end
|
||||
end
|
||||
return true, nil
|
||||
@ -11560,8 +11771,8 @@ function plugin.__codearts_adapter_sign_probe(args)
|
||||
args = args or {}
|
||||
-- 从适配器池里取 build_headers 的结果不易(插件 VM 与适配器 VM 分离),
|
||||
-- 故用**同一份源码**在本 VM 里求值后调用 —— 源码是单一定义(上面那个
|
||||
-- CODEARTS_ADAPTER_SRC 常量),不存在两处漂移。
|
||||
local f = load(CODEARTS_ADAPTER_SRC)
|
||||
-- plugin.K.CODEARTS_ADAPTER_SRC 常量),不存在两处漂移。
|
||||
local f = load(plugin.K.CODEARTS_ADAPTER_SRC)
|
||||
if not f then return { json = { error = "适配器源码无法编译" } } end
|
||||
local adapter = f()
|
||||
local meta = {
|
||||
@ -11917,7 +12128,10 @@ plugin.ui = {
|
||||
const cap = (await call("channels/auto_checkin","POST",{})) && true; // noop,保持顺序
|
||||
// 动作区:登录(加账号)/ 刷新全部 / 一键领取 / 供应商开关
|
||||
const acts = [];
|
||||
acts.push('<button class="small" onclick="chStartLogin()">添加账号(登录)</button>');
|
||||
acts.push('<button class="small" onclick="chStartLogin()">添加账号(登录)</button>'
|
||||
+ (["buddy","qoder"].includes(id)
|
||||
? ' <button class="small" title="登录国际区(WorkBuddy / qoder.com)" onclick="chStartLogin(\'intl\')">+国际区</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>');
|
||||
@ -12092,13 +12306,16 @@ plugin.ui = {
|
||||
function notice(msg) { q("#ch-notice").textContent = msg; }
|
||||
|
||||
// ---- 登录(对齐参考两步式:立即拿 URL/弹窗,后台轮询完成)----
|
||||
window.chStartLogin = async () => {
|
||||
window.chStartLogin = async (region) => {
|
||||
const id = state.selected; if (!id) return;
|
||||
// ★ 双区:按钮按住 Shift 点 = 登录国际区(buddy→WorkBuddy / qoder→qoder.com)。
|
||||
// 简单可靠:不需要为双区改版式,一行 title 提示即可。默认国内/国际为主区。
|
||||
const r = (region === "intl") ? "intl" : "cn";
|
||||
// 池路径:先建占位账号,登录完成后回填凭据到这个账号
|
||||
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 });
|
||||
const j = await call("channels/login", "POST", { platform: id, account_id: aid, region: r });
|
||||
if (j.error) { alert(j.error); return; }
|
||||
const box = q("#ch-login-box"); box.style.display = "block";
|
||||
const vnc = q("#ch-vnc-wrap");
|
||||
@ -12121,8 +12338,13 @@ plugin.ui = {
|
||||
"<p class='muted' id='ch-login-state'></p>";
|
||||
} else {
|
||||
vnc.style.display = "none";
|
||||
// 扫码类流程(qr-poll)的地址是「用微信扫」的落地页,直接点开也能拿到
|
||||
// 页面里的二维码,故文案要说清楚,别让用户以为点开就自动登录了。
|
||||
const isQr = !!(j.session && j.session.flow === "qr-poll");
|
||||
q("#ch-login-body").innerHTML =
|
||||
"<p>请在你自己的浏览器打开下面的地址完成登录:</p>" +
|
||||
"<p>" + (isQr
|
||||
? "用<b>微信扫码</b>登录(本机点开下面地址,页面里也有二维码):"
|
||||
: "请在你自己的浏览器打开下面的地址完成登录:") + "</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>";
|
||||
@ -12499,10 +12721,17 @@ function plugin.__inject_refresh_platform_probe(args)
|
||||
hosts = { "127.0.0.1" },
|
||||
}
|
||||
if repl then lst[repl] = entry else lst[#lst + 1] = entry end
|
||||
-- 建账号(凭据走保险库)
|
||||
-- 建账号(凭据走保险库)。expiresAt 从凭据的 expires_at 派生(毫秒→秒),
|
||||
-- 供"账号已过期"类判据与 account_list 展示;解析失败保持 0。
|
||||
local ref = "channels:" .. id
|
||||
__llmsproxy_secrets.set(ref, tostring(args.credential or "{}"))
|
||||
poolAdd(id, { id = "a1", credentialRef = ref, priority = 1, expiresAt = 0 })
|
||||
local expAt = 0
|
||||
local dok, dcred = pcall(json.decode, tostring(args.credential or "{}"))
|
||||
if dok and type(dcred) == "table" then
|
||||
local ms = credentialExpiresAtMs(dcred)
|
||||
if ms then expAt = math.floor(ms / 1000) end
|
||||
end
|
||||
poolAdd(id, { id = "a1", credentialRef = ref, priority = 1, expiresAt = expAt })
|
||||
return { json = { ok = true } }
|
||||
end
|
||||
|
||||
@ -12609,6 +12838,15 @@ function plugin.__rank_accounts_probe(args)
|
||||
usable_n = #(r.usable or {}) } }
|
||||
end
|
||||
|
||||
-- plugin.__chosen_account_probe:只给测试用 —— 当前为该通道服务的账号 id。
|
||||
-- 一通道一源之后,account_reorder 的效果不再是"源列表顺序",而是
|
||||
-- **选中的账号变了**(源同名覆盖,api_key 换成新账号的 token)→ 内核先试它。
|
||||
function plugin.__chosen_account_probe(args)
|
||||
args = args or {}
|
||||
local provider = tostring(args.platform or "")
|
||||
return { json = { account_id = (plugin.state.chosen or {})[provider] or "" } }
|
||||
end
|
||||
|
||||
-- plugin.__expiring_window_probe:只给测试用 —— 读当前生效的窗口天数。
|
||||
function plugin.__expiring_window_probe()
|
||||
return { json = { days = expiringWindowDays() } }
|
||||
|
||||
Reference in New Issue
Block a user