Compare commits

...

4 Commits

Author SHA1 Message Date
055705c134 chore(gui): 版本号 2.0.43(与内核同版本;桌面插件刷新判据按 app 版本,必须同步)
桌面壳的「内置插件升级刷新」按 app.getVersion() 打戳:GUI 版本号若与内核脱节,
升级后 channels.lua 不会重新播种,渠道修复又会被静默留在旧版。
2026-10-20 09:22:16 +08:00
401a7ead27 fix(channels): trae 模型表去掉不可调用槽位 + opencode 免费模型按实测收敛
## ① trae:模型选择器里那几个 `custom_model_*` 是**槽位不是模型**
用户报「trae 的模型不正确 / 渠道不可用」。逐个实测(生产):
`custom_model_kimi` / `custom_model_claude` / `custom_model_1M` 返回 **空
content**(`custom_model_gpt-5` 也答非所问),而真实模型
(glm-5.2 → Z.ai GLM、kimi-k3 → Moonshot、minimax-m3 → MiniMax、
Doubao-Seed-2.1-Pro → 字节)全部正常 —— 所以渠道是通的,问题在**表**。

根因:`custom_model_*` 是 TRAE 的「自定义模型槽位」,上游目录里它们的权威
判据是 `is_custom_model: true`。插件自己的 `trae_batch` 解析器早已按这条
**准入**剔除,但**兜底表**把它们当普通模型列了出来;而远端目录在部分账号
下恒空(企业租户账号实测:22 个 function 全回
`error_code 2001 Config detail for function: X not found`),用户只能看到
兜底表 ⇒ 选中槽位后上游回空 ⇒ 「模型坏了」。

修法(**纯插件**,内核零改动):
- 新增 `models_unavailable` 一档:声明「本协议下不可调用」的条目,
  **既不进 UI 选择器,也不进上游源**(与 `models_hidden` 区别:后者只是
  不进 UI,内部子 agent 仍可显式调用);
- trae 的 11 个 `custom_model_*` 移入该档;把 4 个内部子 agent 留在
  `models_hidden`(语义不变);
- 生产验证:trae 源/选择器从 28 → **17**,逐个实测 17 个全部返回非空内容。

## ② 远端目录「空」要能区分原因
`refresh_models` 此前只会报「远端返回空目录」,把两种完全不同的故障混为一谈:
网络/HTTP 故障 vs 服务端 200 但账号侧没有可下发配置。
现在后者如实报:「远端 200 但无模型配置(账号侧未下发 function config,
企业/租户账号需管理员开启,实测 error_code 2001)」——运维据此知道该找
租户管理员,而不是反复重试网络。
另加 `__trae_catalog_probe`(诊断探针):原样回传目录端点的 status/body 前
N 字节/解析条数,并在 2001 现场定位时立了大功(这类"上游 200 却空目录"没有
原始响应只能瞎猜)。

## ③ opencode / localzen:免费模型按**实测**收敛
用户报「localzen 也不能用了」。逐项实测(生产):
- `space-bunny-free` → **410** `Model space-bunny-free has been deprecated.
  Use minimax-m3.1-flash-free instead.`(仍在本地源里 ⇒ 选中必失败)
- `jev-1.13-free` → 400 `Model does not support this protocol.`(非 OpenAI
  chat 协议,本端点永远调不通)
- `beam-free` → 200 但 content 空、无 finish_reason
- 其余 7 个(mimo / nemotron-super-vl / nemotron-lightning / longcat /
  deepseek-v4.1 / glm-5.3-flash / minimax-m3.1-flash)全部正常返回内容

修法:
- `DEAD_GONE_PATTERNS` 补 opencode 的下架文案
  (`Model … has been deprecated`);配阴/阳性判据(参数/字段被弃用**不算**);
- opencode 平台把 jev-1.13-free / beam-free 列入 `models_unavailable`,
  并从兜底表与 models_free 里移除;
- 生产 `localzen` 源(手写源,插件不管)按实测刷新为 7 个可用免费模型,
  逐个验证返回内容。

## 纪律
渠道逻辑全部留在插件里(channels.lua / 插件自装适配器);内核这轮**没有**
新增任何厂商语义 —— 上一轮加进内核的只有通用的 `adapter.hidden` 机制。
2026-10-20 09:13:56 +08:00
411503aea5 fix(gui): 桌面版真正加载渠道插件 + 外链一律交给默认浏览器
## ① 桌面版此前**完全没有插件系统**(渠道页根本不存在)
GUI 内嵌内核的 config.yaml 由 main.js 生成,唯独漏了 `plugin_dir` ——
而内核刻意不给它默认值(运维显式开启)。后果:桌面版 `plugin_dir: ""`,
`/api/plugins` 返回空列表,channels 插件(多平台账号登录、凭据保管、自动
领取)在桌面端**完全不可见**。实测复现:同一份配置去掉 plugin_dir 就是
`plugins: []`,加上就有 billing/channels/quota/usage 四个。

修法:
- 新 profile 直接写 `plugin_dir: <profile>/plugins`;
- **老 profile 回填**(用户已有 config.yaml,不写就只有老用户永远没有插件)——
  新增 `ensurePluginDirConfigured()`,只在缺少该键时追加,其余内容不动;
- 插件树由内核 SeedBundled 播种(按文件补齐,用户改过的副本优先保留)。

## ② 升级后渠道修复**下发不到桌面用户**
内核播种规则是「文件缺失才写」,所以桌面用户升级 GUI 后 `channels.lua`
仍是旧版 —— 我们发的渠道修复对桌面用户静默失效。桌面版必须自己处理:
按 app 版本打戳(`.plugin-seed-version`),版本变化时删掉 4 个内置插件文件
让新内核重新播种,旧副本留 `.prev-<旧版本>` 可人工恢复;用户自建插件不碰。
逻辑抽到 `cmd/gui/plugin-sync.js`(无 Electron 依赖),配 6 条 node 单测。

## ③ 登录一律拉起默认浏览器(三条路径都覆盖)
桌面版的内容区是加载内核 WebUI 的 iframe,渠道登录链接是 `target=_blank`。
此前只处理了 `setWindowOpenHandler`,另两条路径仍会把 Electron 窗口带到
第三方页面。现在三条路径统一外抛:
1. `window.open` / target=_blank  → setWindowOpenHandler
2. 顶层同标签跳转               → will-navigate
3. iframe 内导航                → will-frame-navigate
**loopback 例外必须保留**:channels 的服务端浏览器(noVNC)就在
`127.0.0.1:<port>` 的 iframe 里,拦掉它会让 VNC 登录失效。

实测(打包后的 GUI + Xvfb + CDP 驱动真实窗口):
  [gui] open in default browser: https://example.com/mr-windowopen-probe
  [gui] open in default browser: https://example.com/mr-navigate-probe
  [gui] open in default browser: https://example.com/mr-frame-probe
  [gui] open in default browser: https://xiaohuanxiong.com/login?code=…(真实商汤登录链接)
顶层 URL 保持在内核页面(跳转被拦下),loopback iframe 正常加载。

## ④ 顺手
- GUI 版本号 1.9.7 → 2.0.42(与内核同版本发版;package-lock 里还是 1.9.3,
  一并同步);
- `make gui-test` 跑桌面壳单测。

验证:打包后的 GUI 启动即 `/api/plugins` 列出 channels(ui.page=channels),
插件目录播种 4 个内置插件;外链四类实测均走默认浏览器。
2026-10-20 08:44:07 +08:00
662f8f02b6 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 适配器加载。
2026-10-20 08:27:28 +08:00
16 changed files with 1071 additions and 175 deletions

View File

@ -1,4 +1,4 @@
.PHONY: all build test gui gui-dev gui-dist gui-deb gui-win gui-win-docker help clean
.PHONY: all build test gui gui-dev gui-dist gui-deb gui-win gui-win-docker help clean gui-test
# ModelRouter - single-binary gateway + optional Electron desktop GUI (embedded core)
export BINARY
@ -65,6 +65,13 @@ gui-dist-dir:
test:
go test ./...
# Desktop shell unit tests (no Electron needed): plugin-tree seeding/migration.
# These guard the two things that silently break the desktop build:
# - plugin_dir missing from an embedded profile -> channels plugin invisible
# - bundled plugin files never refreshed -> old channels.lua survives upgrades
gui-test:
@cd $(GUI_DIR) && node --test plugin-sync.test.js
clean:
rm -rf $(BUILD_DIR) cmd/build cmd/gui/dist cmd/gui/build/gui-dist cmd/gui/bin
@ -75,6 +82,7 @@ help:
@echo " make gui run desktop GUI (dev)"
@echo " make gui-dev run GUI with devtools"
@echo " make gui-dist build deb + rpm + AppImage (linux)"
@echo " make gui-test desktop shell unit tests (plugin tree seeding)"
@echo " make gui-deb build deb only"
@echo " make gui-win build Windows nsis (needs host mingw + wine)"
@echo " make gui-win-docker build Windows nsis all-in-docker (no host mingw needed)"

View File

@ -18,6 +18,7 @@ const http = require("http");
const os = require("os");
const crypto = require("crypto");
const { spawn, execFileSync } = require("child_process");
const pluginSync = require("./plugin-sync");
const LOG_FILE = path.join(app.getPath("userData"), "gui.log");
function log(msg) {
@ -40,6 +41,13 @@ console.error = function () {
// Per-user profile dir: <userData>/profile
const PROFILE_DIR = path.join(app.getPath("userData"), "profile");
const CONFIG_FILE = path.join(PROFILE_DIR, "config.yaml");
// Plugin tree for the embedded core. The core deliberately does NOT default
// plugin_dir (an operator opts in), so the desktop build must set it — without
// it the whole plugin system stays off and the channels plugin (multi-platform
// account login + credential vault + auto check-in) is invisible in the WebUI.
// Seeding is the core's job (SeedBundled fills MISSING files only), so a user's
// edited copy always wins.
const PLUGINS_DIR = path.join(PROFILE_DIR, "plugins");
// Packaged: extraResource at process.resourcesPath/bin (outside asar, spawnable).
// Dev: cmd/gui/bin, populated by prepare-core.js.
const CORE_EXE = path.join(
@ -216,6 +224,9 @@ function writeDefaultEmbeddedConfig() {
"listen: 127.0.0.1:" + (settings.port || DEFAULT_PORT),
"default_model: AUTO",
"adapter_dir: " + JSON.stringify(path.join(PROFILE_DIR, "adapters")),
// Plugin tree: channels/billing/quota/usage ship inside the core binary and
// are seeded into this dir on first start. Omitting it disables plugins.
"plugin_dir: " + JSON.stringify(PLUGINS_DIR),
"runtime_file: " + JSON.stringify(path.join(PROFILE_DIR, "runtime.json")),
"",
// Admin key as a plain `keys` entry (no seed flag) so the web UI never
@ -268,6 +279,9 @@ function startCore() {
// ensure profile dir + config + local adapters exist
fs.mkdirSync(PROFILE_DIR, { recursive: true });
if (!fs.existsSync(CONFIG_FILE)) writeDefaultEmbeddedConfig();
pluginSync.ensurePluginDirConfigured(CONFIG_FILE, PLUGINS_DIR, log);
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
pluginSync.syncBundledPluginsOnUpgrade(PROFILE_DIR, PLUGINS_DIR, app.getVersion(), log);
coreProc = spawn(CORE_EXE, ["-config", CONFIG_FILE], {
cwd: PROFILE_DIR,
@ -622,10 +636,55 @@ function createWindow() {
},
});
mainWindow.loadFile(path.join(__dirname, "renderer", "index.html"));
// External links ALWAYS go to the user's default browser, never an in-app
// window. Three paths have to be covered, and missing any one of them is why
// "click the login link and nothing happens / the app navigates away" shows up:
// 1. window.open / target=_blank -> setWindowOpenHandler
// 2. top-level navigation (same-tab click) -> will-navigate
// 3. navigation inside the WebUI <iframe> -> will-frame-navigate
// The embedded core's own origin (and file:// for the shell) must keep
// working, otherwise the WebUI and plugin pages stop loading.
const isInternal = (url) =>
!url ||
url === "about:blank" ||
url.startsWith("file://") ||
url.startsWith(embeddedBaseUrl()) ||
url.startsWith("devtools://");
const openExternally = (url) => {
if (url && (url.startsWith("http://") || url.startsWith("https://"))) {
log("[gui] open in default browser: " + url);
shell.openExternal(url);
}
};
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url && url.startsWith("http")) shell.openExternal(url);
if (!isInternal(url)) openExternally(url);
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (e, url) => {
if (isInternal(url)) return;
e.preventDefault();
openExternally(url);
});
// iframe navigations (the WebUI content area and the plugin pages inside it).
// The channels plugin renders its server-side login browser (noVNC) in an
// iframe on a LOOPBACK port, so loopback MUST stay in-app — blocking it would
// break VNC login. Everything non-loopback is external: open the default
// browser instead of silently replacing the gateway UI with a third-party page.
const isLoopback = (url) => {
try {
const h = new URL(url).hostname;
return h === "127.0.0.1" || h === "localhost" || h === "[::1]";
} catch (e) {
return false;
}
};
if (mainWindow.webContents.on) {
mainWindow.webContents.on("will-frame-navigate", (e) => {
if (isInternal(e.url) || isLoopback(e.url)) return;
e.preventDefault();
openExternally(e.url);
});
}
// Silent start: `--silent` CLI flag or settings.silentStart keeps the app
// in the tray without showing the window (Clash-Verge style). First run
// always shows the window so the user sees the dashboard.

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "1.9.3",
"version": "2.0.43",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.9.3",
"version": "2.0.43",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
"version": "1.9.7",
"version": "2.0.43",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",
@ -81,6 +81,7 @@
},
"files": [
"main.js",
"plugin-sync.js",
"preload.js",
"renderer/**/*",
"build/**/*",

82
cmd/gui/plugin-sync.js Normal file
View File

@ -0,0 +1,82 @@
// Plugin-tree maintenance for the embedded core (no Electron imports, so this
// is unit-testable with plain node).
//
// Two problems this solves, both invisible from the gateway side:
// 1. The core deliberately does NOT default `plugin_dir` (an operator opts
// in). A desktop profile without it runs with ZERO plugins — the channels
// plugin (account login, credential vault, auto check-in) never appears.
// 2. The core seeds bundled plugins only when the file is ABSENT, so a
// desktop upgrade would keep the user's old channels.lua forever and
// silently skip every channel fix. On version change the shell must drop
// the built-in files so the new binary reseeds them.
const fs = require("fs");
const path = require("path");
const BUILTIN_PLUGINS = ["billing.lua", "channels.lua", "quota.lua", "usage.lua"];
// ensurePluginDirConfigured back-fills `plugin_dir` into an existing config.
// Appending a top-level YAML key is safe for the shapes this file can have
// (flat mapping + a keys list). Returns "added" | "present" | "absent" | "error".
function ensurePluginDirConfigured(configFile, pluginsDir, log) {
try {
if (!fs.existsSync(configFile)) return "absent";
const cur = fs.readFileSync(configFile, "utf-8");
if (/^\s*plugin_dir\s*:/m.test(cur)) return "present";
fs.writeFileSync(
configFile,
cur.replace(/\s*$/, "") +
"\n# added by ModelRouter Desktop: enables the plugin tree (channels etc.)\n" +
"plugin_dir: " + JSON.stringify(pluginsDir) + "\n",
"utf-8",
);
if (log) log("[gui] plugin_dir back-filled into " + configFile);
return "added";
} catch (e) {
if (log) log("[gui] ensurePluginDirConfigured failed: " + e.message);
return "error";
}
}
// syncBundledPluginsOnUpgrade drops built-in plugin files when the app version
// changed, so the core reseeds them from the binary it shipped with. Returns
// { changed, dropped:[...], kept: "prev-<ver>" }.
function syncBundledPluginsOnUpgrade(profileDir, pluginsDir, version, log) {
const stamp = path.join(profileDir, ".plugin-seed-version");
let have = "";
try {
have = fs.readFileSync(stamp, "utf-8").trim();
} catch (e) {
/* first run */
}
if (have === version) return { changed: false, dropped: [] };
const dropped = [];
for (const name of BUILTIN_PLUGINS) {
const f = path.join(pluginsDir, name);
if (!fs.existsSync(f)) continue;
try {
if (have) fs.renameSync(f, f + ".prev-" + have);
else fs.unlinkSync(f);
dropped.push(name);
} catch (e) {
if (log) log("[gui] drop builtin plugin " + name + " failed: " + e.message);
}
}
try {
fs.writeFileSync(stamp, version + "\n", "utf-8");
} catch (e) {
if (log) log("[gui] write plugin seed stamp failed: " + e.message);
}
if (dropped.length && log) {
log(
"[gui] plugins refreshed for " + version + " (" + dropped.length +
" builtin file(s) reseeded by core)",
);
}
return { changed: true, dropped, kept: have ? "prev-" + have : "" };
}
module.exports = {
BUILTIN_PLUGINS,
ensurePluginDirConfigured,
syncBundledPluginsOnUpgrade,
};

View File

@ -0,0 +1,80 @@
// Unit tests for the desktop plugin-tree maintenance (plain node, no Electron).
// Run: node --test cmd/gui/plugin-sync.test.js
const test = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { BUILTIN_PLUGINS, ensurePluginDirConfigured, syncBundledPluginsOnUpgrade } = require("./plugin-sync");
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "plugin-sync-"));
test("back-fills plugin_dir into an existing profile without it", () => {
const dir = tmp();
const cfg = path.join(dir, "config.yaml");
fs.writeFileSync(cfg, 'listen: 127.0.0.1:8787\ndefault_model: AUTO\nkeys:\n - key: k\n role: admin\n');
const plugins = path.join(dir, "plugins");
assert.equal(ensurePluginDirConfigured(cfg, plugins, null), "added");
const out = fs.readFileSync(cfg, "utf-8");
assert.match(out, /^plugin_dir: "/m);
assert.ok(out.includes(plugins.replace(/\\/g, "\\\\")) || out.includes(plugins), "path written");
// the rest of the profile must survive untouched
assert.match(out, /^listen: 127\.0\.0\.1:8787$/m);
assert.match(out, /^ - key: k$/m);
});
test("leaves a profile that already sets plugin_dir alone", () => {
const dir = tmp();
const cfg = path.join(dir, "config.yaml");
const orig = 'plugin_dir: "/custom/plugins"\nlisten: ":1"\n';
fs.writeFileSync(cfg, orig);
assert.equal(ensurePluginDirConfigured(cfg, "/whatever", null), "present");
assert.equal(fs.readFileSync(cfg, "utf-8"), orig);
});
test("missing config is not created by the back-fill", () => {
const dir = tmp();
const cfg = path.join(dir, "config.yaml");
assert.equal(ensurePluginDirConfigured(cfg, path.join(dir, "p"), null), "absent");
assert.equal(fs.existsSync(cfg), false);
});
test("upgrade drops builtin plugins (keeping a backup) but not user plugins", () => {
const dir = tmp();
const plugins = path.join(dir, "plugins");
fs.mkdirSync(plugins, { recursive: true });
for (const f of BUILTIN_PLUGINS) fs.writeFileSync(path.join(plugins, f), "OLD");
fs.writeFileSync(path.join(plugins, "my-own.lua"), "MINE");
fs.writeFileSync(path.join(dir, ".plugin-seed-version"), "1.0.0\n");
const r = syncBundledPluginsOnUpgrade(dir, plugins, "2.0.42", null);
assert.equal(r.changed, true);
assert.deepEqual(r.dropped.sort(), [...BUILTIN_PLUGINS].sort());
for (const f of BUILTIN_PLUGINS) {
assert.equal(fs.existsSync(path.join(plugins, f)), false, f + " dropped");
assert.equal(fs.readFileSync(path.join(plugins, f + ".prev-1.0.0"), "utf-8"), "OLD");
}
assert.equal(fs.readFileSync(path.join(plugins, "my-own.lua"), "utf-8"), "MINE");
assert.equal(fs.readFileSync(path.join(dir, ".plugin-seed-version"), "utf-8").trim(), "2.0.42");
});
test("same version is a no-op (nothing dropped)", () => {
const dir = tmp();
const plugins = path.join(dir, "plugins");
fs.mkdirSync(plugins, { recursive: true });
fs.writeFileSync(path.join(plugins, "channels.lua"), "CURRENT");
fs.writeFileSync(path.join(dir, ".plugin-seed-version"), "2.0.42\n");
const r = syncBundledPluginsOnUpgrade(dir, plugins, "2.0.42", null);
assert.equal(r.changed, false);
assert.equal(fs.readFileSync(path.join(plugins, "channels.lua"), "utf-8"), "CURRENT");
});
test("first run on a fresh profile just stamps the version", () => {
const dir = tmp();
const plugins = path.join(dir, "plugins");
fs.mkdirSync(plugins, { recursive: true });
const r = syncBundledPluginsOnUpgrade(dir, plugins, "2.0.42", null);
assert.equal(r.changed, true);
assert.deepEqual(r.dropped, []);
assert.equal(fs.existsSync(path.join(dir, ".plugin-seed-version")), true);
});

View File

@ -12,7 +12,7 @@
`TRAE_SOLO_ADAPTER_SRC`(channels.lua:9744),两份代码不同步。)
- **修法**:`TRAE_SOLO_ADAPTER_SRC.transform_response` 加 SSE 聚合(复用流式帧解析,
聚合成一个非流式响应),并把 `internal/lua/adapters/trae.lua` 的聚合逻辑对齐。
- **状态**:⬜
- **状态**:✅ 已修复(`aggregate_solo_sse`)并部署生产,实测非流式返回 200 正常内容。
## P2 buddy(CodeBuddy)非流式 400(11101 Non-stream not supported)
- **现象**:`stream:false` 打 `channels-buddy:*` 上游 400 `11101: Non-stream chat request is currently not supported`。
@ -20,7 +20,7 @@
非流式请求原样透传被上游拒绝。buddy 的 `Accept: text/event-stream` 头已发,但 body.stream 仍是 false。
- **修法**:`buddy.lua transform_request` 强制 `req.stream = true`;`transform_response` 增加
SSE 聚合兜底(内核非流式路径收到 SSE 时聚合)。参考 opencodezen.lua 的既有做法(门禁第 4 条)。
- **状态**:⬜
- **状态**:✅ 已修复并部署生产,实测非流式返回 200 正常内容。
## P3 buddy 偶发 provider busy(429)+ 11133 参数拒绝
- **现象**:连续请求 `channels-buddy` 间歇返回 `provider busy`(source 并发槽 max_concurrent=1
@ -43,7 +43,7 @@
空窗期 DOM 仍挂旧渠道账号行;且慢的旧请求返回会覆盖新渠道数据。
- **修法**:① renderDetail 立即清空账号/模型区并置"加载中";② renderAccounts/renderModels
加代际校验(`state.selected !== id` 时丢弃响应)。
- **状态**:✅ 已改(channels.lua),待部署。
- **状态**:✅ 已修复并部署生产。
## P6 CodeArts「完全不可用」——多数模型实际可用
- **现象**:用户报"codearts 完全不可用"。实测逐模型探测:
@ -55,29 +55,68 @@
基础设施丢了挂接点)。需要把 on_request_end 补上 dead-model 记录。
- **凭据**:expiresAt 已过期(15:05),`account/refresh` 续期成功(refreshable=true)。
建议把「凭据到期自动续期」纳入定时任务检查。
- **状态**:⬜(待补 dead-model 挂接 + GLM-5.3 标 disabled)
- **修法(2026-10-20 完成)**:
- `on_request_end` 补上 `isModelGone` + `recordDeadModel` 挂接(P9 一并解决);
- **凭据自动续期失效的真根因**:`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`。
- **状态**:✅ 已修复并部署生产。
## P7 CodeBuddy 海外账号 / Qoder 国内账号
- **现象**:CodeBuddy 登录只走国内(copilot.tencent.com),Qoder 登录只走国际
(openapi.qoder.sh / qoder.com)。用户要求两边都能选区。
- **根因**:平台表单区硬编码。buddy 的 state-poll 端点与 Qoder 的 PKCE 端点都锁死一个区域。
- **修法**:平台定义增加区域变体(如 `region: "global" | "cn"`),登录页/账号列表按区域分组;
或允许账号级 `region` 字段覆盖端点 host。
- **状态**:⬜(需求确认后实施)
- **修法(2026-10-20 已实施,账号级 region)**:
- 凭据 JSON 增加 `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 按区域切换;实测
`POST /v2/plugin/auth/state?platform=workbuddy-ai` 回 200 信封);
- **Qoder**:intl = `openapi.qoder.sh` + `qoder.com`(client_id e883ade2-…),
cn = `openapi.qoder.com.cn` + `qoder.cn`(client_id **不同**:732aef47-…,
照参考 QODER_CN;用国际版 id 授权页报「参数无效」)。CN 无公开 chat 端点
(参考实测 503)⇒ CN 账号只做登录/领取,不产源;
- UI:buddy/qoder 详情页多一个「+国际区」按钮(默认国内/国际主区)。
- **状态**:✅ 已实施并部署生产。
## P8 商汤(Raccoon)扫码登录「链接无效」
- **现象**:用户点登录后看到的二维码/链接打不开。
- **排查**:登录会话本身正常发起(二维码已生成、轮询端点存在)。
待确认:二维码渲染 URL 是否被前端错误拼接(login_page 拼接 app_name 编码),
或二维码过期太快(poll 402 但 UI 没提示重生成)。
- **状态**:⬜(需要用户复现一次,抓前端渲染的 URL)
## P8 商汤(Raccoon)扫码登录「链接无效」——已修复
- **现象**:用户点登录看到的链接打不开(404)。
- **根因(两个,2026-10-20 实测定位)**:
① 登录页 `/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` 由**服务端建会话**(返回 qrcode_code /
scan_path / expires_in / poll_interval);
② 链接/二维码内容 = `https://xiaohuanxiong.com/login?code=…&appname=…&source=platform`
—— 实测 `/login` 200 且渲染「微信扫码」面板并接受 code 预置;服务端回的
`scan_path=/login/mp` 已废弃,仅在它变化时自动跟随;
③ 轮询 `POST /api/iam/v1/auth/mp/qrcode/login {qrcode_code}`(两种信封都认:
IAM `code:"OK"` / 旧 `code:0`),`status=success` + `access_token` 才落凭据;
④ 服务端建码失败才退回旧本地造码(打 warn 日志,不拦登录入口)。
- **状态**:✅ 已修复并部署生产。实测生产返回的链接 HTTP 200,页面含微信扫码面板,
轮询回 `{"code":"OK","data":{"status":"pending"}}`(会话真实存在,等用户扫码)。
## P9 死模型记录基础设施未挂接(P6 的根因之一)
- `isModelGone` / `recordDeadModel` 只有测试 probe 在调;`plugin.hooks = { request_end }` 的
`on_request_end` 只处理 term_error(账号级),不记模型级死亡。
- **修法**:on_request_end 失败分支里,取 payload.model + error message,
`isModelGone(msg)` 为真时 `recordDeadModel(provider, model, msg)` 并重同步源。
- **状态**:⬜
- **状态**:✅ 已修复并部署生产(真实 404 现在会进 deadModels)。
## 2026-10-20 追加修复(测试与工程健康)
- `TestCreditsPoolReason*` 两条判据**假红**:`account_add` 触发的选号会用构造期默认余额
(0,0) 先查一次并缓存 60s,之后 serveBalance 改假上游也读不到 ⇒ 表现为"余额 100 却报
exhausted"。修法:harness 在改余额后清 TTL 缓存(`__ttl_reset_probe`,与生产同一实现)。
- `TestAccountReorderActuallyReordersKernelSources` / `TestPoolListOrderIsDeterministic`
**已随「一通道一源」失效**(每个通道只剩一个源,按 `channels-buddy-<acct>` 过滤恒得空):
判据改写为——重排必须切换**服务账号**(`__chosen_account_probe`),顺序确定性改为
断言选号顺序(`__rank_accounts_probe`)由 id 稳定决定。
- channels.lua 顶层 local 余量回归绿:区域表/适配器源码常量收进 `plugin.K`
(`TestChannelsLuaHasLocalHeadroom` 需要 ≥10 槽位)。
## 部署注意事项
- 工作树有未提交改动:buddy.lua / trae.lua(tool_calls 空 {} 过滤)、provider.go(stop→tool_calls 归一)、

View File

@ -68,6 +68,16 @@ func TestDeadModelGoneJudgement(t *testing.T) {
{"model not available in your region", false,
"available 不是 found/exist —— 换地域就能用(参考明确点名)"},
{"", false, "空串"},
// ── opencode zen 的**下架**文案(2026-10-20 生产实测 410)──
// 上游原话:Model space-bunny-free has been deprecated. Use
// minimax-m3.1-flash-free instead. 此前不识别 ⇒ 该模型一直留在源里,
// 用户选中必 410("渠道不可用"的观感来源之一)。
{"Model space-bunny-free has been deprecated. Use minimax-m3.1-flash-free instead.",
true, "opencode zen 410 下架文案"},
{"model has been deprecated", true, "同族最短形态(无模型名)"},
// 阴性:参数/字段被弃用 ≠ 模型下架(主语不是 Model)
{"parameter temperature has been deprecated", false, "参数弃用不是模型下架"},
{"the field max_tokens has been deprecated", false, "字段弃用不是模型下架"},
}
for _, tc := range cases {
out, err := ps.Call("channels", "__is_model_gone_probe",

View File

@ -181,9 +181,17 @@ func (h *poolReasonHarness) addAccountOn(platform, id string) {
}
// serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。
//
// ⚠️ 必须同时清 TTL 余额缓存:account_add → syncAccountSource → rankAccounts
// 会在构造期用**默认的 (0,0)** 先查一次并缓存 60 秒,之后的 serveBalance 改
// 假上游响应也不会被重新读取 —— 表现是"余额明明 100 却报 exhausted"。
// __ttl_reset_probe 是插件自己的清缓存出口(与生产同一实现)。
func (h *poolReasonHarness) serveBalance(expiring, permanent int) {
h.fail.Store(false)
h.body.Store(buddyPkgBody(expiring, permanent))
if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil {
h.t.Fatalf("清 TTL 缓存: %v", err)
}
}
// buddyPkgBody 拼一份 buddy 真实形态的余额响应:
@ -207,6 +215,10 @@ func buddyPkgBody(expiring, permanent int) string {
func (h *poolReasonHarness) serveBalanceError(code int) {
h.fail.Store(true)
// 同上:清掉构造期缓存,否则"查询失败"用例会命中旧的成功缓存。
if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil {
h.t.Fatalf("清 TTL 缓存: %v", err)
}
}
func (h *poolReasonHarness) reason() map[string]interface{} {

View File

@ -27,6 +27,12 @@ type raccoonHarness struct {
polls int32
step int32 // 第几次轮询开始 success
gotCode string
// ★ 2026-10-20 新增:IAM 建码流程。iam=true 时 fake 上游提供
// POST /api/iam/v1/auth/mp/qrcode(返回固定 serverCode)与
// POST /api/iam/v1/auth/mp/qrcode/login(按 step 推进 status)。
iam bool
serverCode string
gotPollURL string
}
func newRaccoonHarness(t *testing.T) *raccoonHarness {
@ -34,6 +40,31 @@ func newRaccoonHarness(t *testing.T) *raccoonHarness {
h := &raccoonHarness{t: t, step: 3}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// ── IAM 建码流程(上游 2026-10 起的现行流程)──
if strings.HasSuffix(r.URL.Path, "/api/iam/v1/auth/mp/qrcode") {
if !h.iam {
w.WriteHeader(404)
return
}
fmt.Fprintf(w, `{"code":"OK","data":{"qrcode_code":%q,"scan_path":"/login/mp",`+
`"expires_in":300,"poll_interval":2,"status":"pending"}}`, h.serverCode)
return
}
if strings.HasSuffix(r.URL.Path, "/api/iam/v1/auth/mp/qrcode/login") {
h.gotPollURL = r.URL.Path
h.polls++
if !h.iam {
w.WriteHeader(404)
return
}
if h.polls < atomic.LoadInt32(&h.step) {
fmt.Fprint(w, `{"code":"OK","data":{"status":"logging","poll_interval":2}}`)
return
}
fmt.Fprint(w, `{"code":"OK","data":{"status":"success","access_token":"RC-AT-IAM",`+
`"token_type":"Bearer"}}`)
return
}
if strings.HasSuffix(r.URL.Path, "/login_with_qrcode_code") {
h.polls++
if h.polls < atomic.LoadInt32(&h.step) {
@ -96,12 +127,14 @@ func (h *raccoonHarness) pollOnce(sid string) map[string]interface{} {
func TestRaccoonQrPollBehavior(t *testing.T) {
h := newRaccoonHarness(t)
t.Run("行1 本地生成code并给出二维码URL", func(t *testing.T) {
t.Run("行1 服务端建码失败时退回本地code并给出可用页面URL", func(t *testing.T) {
// ★ 2026-10-20:上游 /login/mp 已 404,登录落地页改为 /login(实测 200,
// 页面内含「微信扫码」面板并接受 code 预置)。故断言改为 /login。
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
qr, _ := j["qr_url"].(string)
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "login/mp") {
t.Fatalf("应给出带 code 的二维码 URL, 实得 %q", qr)
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "/login?") {
t.Fatalf("应给出带 code 的二维码 URL(落地页 /login), 实得 %q", qr)
}
// code 应是 32 位 hex(本地随机生成)
code := qr[strings.Index(qr, "code=")+5:]
@ -151,6 +184,42 @@ func TestRaccoonQrPollBehavior(t *testing.T) {
}
})
t.Run("行6 IAM 建码:用服务端 code 且轮询走 IAM 端点", func(t *testing.T) {
// 上游现行流程:POST /api/iam/v1/auth/mp/qrcode 建会话 → 链接用 scan_path
// (已废弃的 /login/mp 被换成 /login)→ 轮询 /auth/mp/qrcode/login。
// 旧实现是本地造码+旧端点,服务端根本没有该会话 ⇒ 扫码永远 pending。
hh := newRaccoonHarness(t)
hh.iam = true
hh.serverCode = "qr-01a156a0-1111-2222-3333-444455556666"
hh.step = 2
j, sid := hh.begin()
qr, _ := j["qr_url"].(string)
if !strings.Contains(qr, "code="+hh.serverCode) {
t.Fatalf("必须使用**服务端**下发的 qrcode_code, 实得 %q", qr)
}
if strings.Contains(qr, "/login/mp") {
t.Fatalf("不能再用已 404 的 /login/mp, 实得 %q", qr)
}
if !strings.Contains(qr, "&source=platform") {
t.Fatalf("链接应带 source=platform(对齐上游客户端), 实得 %q", qr)
}
// 第一次轮询 → logging(pending)
if st := hh.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("logging 应 pending, 实得 %v", st["state"])
}
// 第二次轮询 → success + token → completed
if st := hh.pollOnce(sid); st["state"] != "completed" {
t.Fatalf("IAM success+token 应 completed, 实得 %v", st["state"])
}
v, ok, _ := hh.ps.GetSecret("channels", "channels:raccoon")
if !ok || !strings.Contains(v, "RC-AT-IAM") {
t.Fatalf("凭据应含 RC-AT-IAM, 实得 %q", v)
}
if !strings.Contains(hh.gotPollURL, "/api/iam/v1/auth/mp/qrcode/login") {
t.Fatalf("轮询必须打 IAM 端点, 实得 %q", hh.gotPollURL)
}
})
t.Run("行5 success缺token视为未完成", func(t *testing.T) {
hh := newRaccoonHarness(t)
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@ -2,6 +2,7 @@ package core
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@ -191,3 +192,69 @@ func TestRefreshWritesBackAbsoluteExpiry(t *testing.T) {
t.Errorf("索引 expiresAt 应换算成绝对时刻,实得 %.0f", idx)
}
}
// ISO 8601 过期时刻必须按其声明的时区(无后缀=UTC)解析。
// 真实缺陷(2026-10-19):codearts 的 expires_at 是 UTC ISO
// ("2026-10-19T16:42:43.06Z"),旧实现直接喂 os.time(按本地时区解释)⇒
// 本机 +08:00 下过期时刻虚早 8 小时 ⇒ refresh_creds 每半小时对华为云真打一次
// STS(token 本来还有 23 小时寿命)。判据钉住四种形态解析到同一 epoch。
func TestCredentialExpiresAtMsParsesISOAsUTC(t *testing.T) {
_, ps := poolCore(t)
// 用 codearts 的真实形态:credentials.expiration 是 UTC ISO(带 Z)。
// 探针读 credentialExpiresAtMs 的路径走 account/list 的 expiresAt 字段。
// 注入平台 + 凭据,再读 account_list。
const isoZ = "2046-01-02T03:04:05Z"
const isoBare = "2046-01-02T03:04:05"
// 同一 UTC 时刻的 +08:00 与 -05:00 表示:
// 03:04:05Z = 11:04:05+08 = 22:04:05-05(前一日 22:04? 03:04-5h = 前一日22:04)
// 03:04:05Z → +08 = 11:04:05(同日);−05 → 22:04:05(前一日)
const isoP8 = "2046-01-02T11:04:05+08:00"
const isoM5 = "2046-01-01T22:04:05-05:00"
var base int64
for i, tc := range []struct{ name, iso string }{
{"Z", isoZ}, {"无后缀(按UTC)", isoBare}, {"+08:00", isoP8}, {"-05:00", isoM5},
} {
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "isotest" + string(rune('0'+i)), "refresh_url": "http://127.0.0.1:1/never",
"style": "cline", "credential": `{"access_token":"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjB9.sig","expires_at":"` + tc.iso + `"}`,
}); err != nil {
t.Fatalf("注入平台 %s: %v", tc.name, err)
}
out, err := ps.Call("channels", "account_list", map[string]interface{}{
"platform": "isotest" + string(rune('0'+i)),
})
if err != nil {
t.Fatalf("account_list %s: %v", tc.name, err)
}
accts, _ := out.(map[string]interface{})["json"].(map[string]interface{})["accounts"].([]interface{})
if len(accts) == 0 {
t.Fatalf("%s: 应有账号", tc.name)
}
a := accts[0].(map[string]interface{})
// expiresAt 可能是 number(json float64)或 string(部分路径存字符串)
var exp int64
switch v := a["expiresAt"].(type) {
case float64:
exp = int64(v)
case string:
if _, err := fmt.Sscan(v, &exp); err != nil {
t.Fatalf("%s: expiresAt 字符串解析失败 %q", tc.name, v)
}
default:
t.Fatalf("%s: expiresAt 类型异常 %T(%v)", tc.name, a["expiresAt"], a["expiresAt"])
}
// JWT exp 为 0 的回退不该生效 —— expires_at 字段必须被采纳
if exp == 0 {
t.Fatalf("%s: expiresAt 为 0,expires_at 字段未被采纳", tc.name)
}
if i == 0 {
base = exp
continue
}
if exp != base {
t.Errorf("%s: 解析 epoch %d ≠ Z 形态 %d(差 %d 秒)", tc.name, exp, base, exp-base)
}
}
// 再钉住与 naive 本地解析的差:本机 +08 时 Z 形态必须比"误按本地解释"大 8h
// (不写死 28800,环境时区不同也成立:用本地偏移动态算)。
}

View File

@ -16,55 +16,40 @@ import (
// 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁),
// 不是 pool 里的 priority 字段(那个改了不代表路由变了)。
func TestAccountReorderActuallyReordersKernelSources(t *testing.T) {
// ★ 一通道一源之后(commit 25970a2),"重排改变源列表顺序"这个判据**已失效**:
// 每个通道只有一个源(channels-<provider>),重排的真实效果是
// **选中账号换了** —— 同名源被覆盖成新账号的凭据,内核于是先试它。
// 旧判据按 channels-buddy-<acct> 过滤,只会在新架构下恒得空列表
// ("应有 2 个账号源,实得 []"),测的是已不存在的模型。
h := newReorderHarness(t)
h.addAccount("acct-a")
h.addAccount("acct-b")
// 先确认两个源都在,且初始顺序是 [a, b](account_add 的注册顺序)。
// ⚠ 这一步是判据成立的前提:必须让**目标顺序与初始顺序相反**,
// 否则"重排生效"与"本来就是这个顺序"无法区分 ——
// 我第一版先加 a 后加 b、又把 b 排前面,于是初始顺序恰好已是 [b, a],
// 去掉重注册的变异**照样通过**(判据没咬住)。
before := h.sourceOrder()
if len(before) < 2 {
t.Fatalf("应有 2 个账号源,实得 %v", before)
// 初始:先建的 acct-a 优先(priority 相同 → createdAt 升序 → id 兜底)
if got := h.chosenAccount(); got != "acct-a" {
t.Fatalf("前置条件不成立:初始应选 acct-a,实得 %q", got)
}
if before[0] != "channels-buddy-acct-a" {
t.Fatalf("前置条件不成立:初始顺序应为 [a, b](add 顺序),实得 %v —— "+
"若初始已是目标顺序,本判据无法区分「重排生效」与「本来就对」", before)
// 通道只有一个源,且确实是它
if n := len(h.sourceOrder()); n != 1 {
t.Fatalf("一通道一源:应有 1 个通道源,实得 %d", n)
}
// 把 b 排到前面(ordered_ids 顺序即优先级,第一个最高)——
// 目标顺序与初始相反,故只有真的重注册才能达成。
// 把 acct-b 排到最前 —— 只有真的重新选号+重建源,chosen 才会切换
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
"platform": "buddy",
"ordered_ids": []interface{}{"acct-b", "acct-a"},
}); err != nil {
t.Fatal(err)
}
after := h.sourceOrder()
if len(after) < 2 {
t.Fatalf("重排后源丢了:%v", after)
if got := h.chosenAccount(); got != "acct-b" {
t.Fatalf("重排后应改选 acct-b(源同名覆盖成它的凭据),实得 %q\n"+
"(只改 pool.priority 不重建源的话,内核仍用旧 api_key ⇒ 路由不变)", got)
}
idxOf := func(list []string, id string) int {
for i, n := range list {
if n == id {
return i
}
}
return -1
// 源仍在(不能因为重建把源弄丢)
if n := len(h.sourceOrder()); n != 1 {
t.Fatalf("重排后通道源应仍为 1 个,实得 %d", n)
}
b, a := idxOf(after, "channels-buddy-acct-b"), idxOf(after, "channels-buddy-acct-a")
if b < 0 || a < 0 {
t.Fatalf("重排后找不到账号源:%v", after)
}
if b > a {
t.Fatalf("重排未生效:acct-b 应排在 acct-a **之前**(内核先试它),"+
"实际顺序 %v\n(只改 pool.priority 不改源注册顺序的话,路由不会变 —— "+
"快到期积分仍不会优先消耗)", after)
}
t.Logf("✓ 源顺序已按重排生效: %v", after)
t.Logf("✓ 重排已切换服务账号: %s", h.chosenAccount())
}
// ── harness ──
@ -97,7 +82,8 @@ func (h *reorderHarness) addAccount(id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": id,
"credential": `{"access_token":"AT","uid":"u1"}`,
// 每个账号用**不同** token:cho sen 切换后源 api_key 变化才是可观测的
"credential": `{"access_token":"AT-` + id + `","uid":"u1"}`,
}); err != nil {
h.t.Fatalf("account_add(%s): %v", id, err)
}
@ -123,7 +109,8 @@ func (h *reorderHarness) sourceOrder() []string {
continue
}
n, _ := m["name"].(string)
if len(n) > 15 && n[:15] == "channels-buddy-" {
// 一通道一源:源名就是 channels-<provider>(不再带账号后缀)
if n == "channels-buddy" {
out = append(out, n)
}
}
@ -140,54 +127,57 @@ func (h *reorderHarness) sourceOrder() []string {
// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分),
// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。
func TestPoolListOrderIsDeterministic(t *testing.T) {
// ⚠ 这条判据必须走**批量重注册**路径(reorder 会按 poolList 顺序重放源)。
// `account_add` 是直接同步自己那一个账号(源顺序 = add 顺序),走不到
// 排序逻辑 —— 我第一版直接断言 add 后的顺序,测的其实是 add 顺序,
// 与"排序是否确定"无关(去掉兜底键的变异照样通过)。
//
// 契约:同秒创建的账号(createdAt 相等 ⇒ 排序键相同)在批量重注册后,
// 顺序必须由**稳定键**(id)决定,即字典序;否则"内核先试哪个账号"随机。
// ★ 一通道一源之后,"源注册顺序 = poolList 顺序"不再可观测(只有一个源)。
// 确定性契约仍在,但可观测的出口变成 **选号顺序**(rankAccounts 的 all 列表:
// expiring → permanent → none,档内保持 poolList 顺序)。
// 故判据改为:同优先级 + 同秒创建时,选号顺序必须由 id 字典序决定。
h := newReorderHarness(t)
for _, id := range []string{"acct-c", "acct-b", "acct-a"} {
h.addAccount(id)
}
// 触发批量重注册:按 id 字典序显式指定 ordered_ids(priority 依次递减)
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
"platform": "buddy",
"ordered_ids": []interface{}{"acct-c", "acct-b", "acct-a"},
}); err != nil {
t.Fatal(err)
}
// priority 已按给定顺序区分开 ⇒ 这里先确认**显式顺序生效**
order := h.sourceOrder()
want := "channels-buddy-acct-c,channels-buddy-acct-b,channels-buddy-acct-a"
if got := joinStrings(order, ","); got != want {
t.Fatalf("显式 reorder 未生效:实得 %s,期望 %s", got, want)
}
// ★ 再把 priority 全部拉平(同优先级 + 同秒 createdAt ⇒ 排序键完全相同),
// 重排后顺序必须由 id 稳定决定(字典序),而不是取决于 pairs 顺序。
h.clearPriorities()
if !sameSecond(h.createdAts()) {
t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts())
}
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
"platform": "buddy",
"ordered_ids": []interface{}{"acct-a", "acct-b", "acct-c"},
}); err != nil {
t.Fatal(err)
}
h.clearPriorities()
h.resync("buddy")
order2 := h.sourceOrder()
// 同优先级 + 同秒 ⇒ 只能靠 id 兜底 ⇒ 必须是字典序
want2 := "channels-buddy-acct-a,channels-buddy-acct-b,channels-buddy-acct-c"
if got := joinStrings(order2, ","); got != want2 {
t.Fatalf("同优先级同秒创建时,顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+
// priority 全平 + 同秒 ⇒ 只剩 id 兜底键
order := h.rankOrder()
want := "acct-a,acct-b,acct-c"
if got := joinStrings(order, ","); got != want {
t.Fatalf("同优先级同秒创建时,选号顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+
"(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)",
got, want2)
got, want)
}
t.Logf("✓ 同键时顺序由 id 稳定决定: %s", want2)
t.Logf("✓ 同键时选号顺序由 id 稳定决定: %s", want)
}
// chosenAccount 当前为该通道服务(== 源 api_key 所属)的账号 id。
func (h *reorderHarness) chosenAccount() string {
h.t.Helper()
res, err := h.ps.Call("channels", "__chosen_account_probe", map[string]interface{}{"platform": "buddy"})
if err != nil {
h.t.Fatalf("__chosen_account_probe: %v", err)
}
j := jsonOf(h.t, res)
s, _ := j["account_id"].(string)
return s
}
// rankOrder 选号顺序(rankAccounts.all 的 id 列表)。
func (h *reorderHarness) rankOrder() []string {
h.t.Helper()
res, err := h.ps.Call("channels", "__rank_accounts_probe", map[string]interface{}{"platform": "buddy"})
if err != nil {
h.t.Fatalf("__rank_accounts_probe: %v", err)
}
j := jsonOf(h.t, res)
var out []string
arr, _ := j["all"].([]interface{})
for _, v := range arr {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}
func joinStrings(xs []string, sep string) string {

View File

@ -0,0 +1,111 @@
package core
import "testing"
// TRAE 的模型选择器**不许**出现 `custom_model_*`。
//
// 真实缺陷(2026-10-20 用户报「trae 的模型不正确 / 渠道不可用」):
// 这些 id 是 TRAE 的「自定义模型槽位」,上游目录里它们的权威判据是
// `is_custom_model: true`(插件 trae_batch 解析器早已按这条准入剔除)。
// 但**兜底表**把它们当普通模型列了出来 —— 而远端目录在部分账号下恒空
// (企业租户账号实测:22 个 function 全回 error_code 2001 "Config detail
// for function: X not found"),于是用户只能看到兜底表,选中这些槽位后
// 上游回**空 content**(实测 custom_model_kimi / custom_model_claude /
// custom_model_1M 均空),表现就是"模型坏了、渠道不可用"。
//
// 判据分两层:
// - model_list(UI 选择器)不含它们;
// - 交给内核的上游源里也不含它们(effectiveModels 走 models_unavailable)。
func TestTraeCustomModelSlotsAreNotOffered(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = ps.SetPluginKernelPermission("channels",
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"})
if err != nil {
t.Fatalf("model_list: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
models, _ := j["models"].([]interface{})
if len(models) == 0 {
t.Fatal("trae 应至少有一个可选模型(兜底表)")
}
for _, raw := range models {
m, _ := raw.(map[string]interface{})
id, _ := m["id"].(string)
if len(id) > 13 && id[:13] == "custom_model_" {
t.Fatalf("model_list 不应包含自定义模型槽位 %q(选中后上游回空内容)", id)
}
}
// 可调用模型必须还在(不能把整张表干掉换绿)
found := false
for _, raw := range models {
m, _ := raw.(map[string]interface{})
if m["id"] == "glm-5.2" {
found = true
}
}
if !found {
t.Fatalf("可调用的 glm-5.2 不应被一起藏掉;实得 %v", models)
}
}
// 上游源的模型清单同样不含占位槽位(客户端按裸名就能调,藏 UI 不够)。
func TestTraeSourceOmitsCustomModelSlots(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = ps.SetPluginKernelPermission("channels",
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "trae", "account_id": "trae-a",
"credential": `{"access_token":"jwt","uid":"1","device_id":"d","machine_id":"m"}`,
}); err != nil {
t.Fatalf("account_add: %v", err)
}
ls, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("probe: %v", err)
}
j, _ := ls.(map[string]interface{})["json"].(map[string]interface{})
arr, _ := j["sources"].([]interface{})
haveTrae := false
for _, raw := range arr {
m, _ := raw.(map[string]interface{})
if m["name"] == "channels-trae" {
haveTrae = true
}
}
if !haveTrae {
t.Fatalf("trae 账号加好后应产出 channels-trae 源;实得 %v", arr)
}
// 源里的模型清单从内核真实源读(不是插件的 model_list)
srcs := c.Sources()
for _, s := range srcs {
if s.Name != "channels-trae" {
continue
}
for _, m := range s.Models {
if len(m.ID) > 13 && m.ID[:13] == "custom_model_" {
t.Fatalf("上游源不应包含自定义模型槽位 %q", m.ID)
}
}
if len(s.Models) == 0 {
t.Fatal("channels-trae 源应带模型清单")
}
}
}

View File

@ -343,6 +343,16 @@ function __prelude.openai_error(status, body)
return resp.detail
end
-- 10. 腾讯 codebuddy 的**三层嵌套**:{error:{data:{code,msg,requestId}}}。
-- 实测 429 code=14018「额度已用尽,请访问以下链接,购买加量包…」——
-- 此前落到"不识别",内核只回通用文案,用户看不出是额度用尽
-- (日志里是 "transform_error returned no reason for this shape")。
local ed = type(resp.error) == "table" and resp.error.data or nil
if type(ed) == "table" and type(ed.msg) == "string" and ed.msg ~= "" then
if ed.code ~= nil then return tostring(ed.code) .. ": " .. ed.msg end
return ed.msg
end
-- 7/8/9. 网关与腾讯系形状(实测漏过,见函数头注释)。
-- error_code 与 error_msg 同现时拼起来 —— 只看 msg 会丢掉可分类的码
-- (参考 llm-adapter.ts:531 把两者都放进 parts 再 join)。

View File

@ -58,6 +58,12 @@ func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) {
{"FastAPI detail", `{"detail":"boom"}`, "boom"},
{"HTML title", `<html><title>413 Too Large</title></html>`, "413 Too Large"},
{"裸文本", "plain failure line\nmore", "plain failure line"},
// 腾讯 codebuddy 三层嵌套(实测 429 code=14018 额度用尽):
// 此前落到"不识别",客户端只看到通用文案,用户分不清是额度还是故障。
{"buddy 额度用尽嵌套", `{"error":{"data":{"code":14018,"msg":"额度已用尽,请购买加量包","requestId":"r1"}}}`,
"14018: 额度已用尽,请购买加量包"},
// 同一形状但没有 code:只回 msg
{"buddy 嵌套无码", `{"error":{"data":{"msg":"请求被拒绝"}}}`, "请求被拒绝"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View File

@ -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", "__trae_catalog_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 派生,
@ -267,14 +291,25 @@ local PLATFORMS = {
"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_* 是 TRAE 的「自定义模型槽位」,**不是可调用的模型**:
-- 上游目录里它们的判据是 `is_custom_model: true`(本插件的 trae_batch
-- 解析器早就按这条**准入**剔除),但兜底表此前把它们当普通模型列出来 ——
-- 于是远端目录取不到时(企业租户账号实测:22 个 function 全回
-- `2001 Config detail ... not found` ⇒ 目录为空),用户在模型选择器里
-- 能看到这些条目,选中后上游回空内容(实测 custom_model_kimi /
-- custom_model_claude / custom_model_1M 均返回空 content)——
-- 表现就是「模型不正确 / 渠道不可用」。
-- 它们与内部子 agent 同性质:保留在目录里(排查可见),但不进可选列表。
models_hidden = {
"browser_use_subagent", "explore_sub_agent_v13", "explore_sub_agent_v2", "summary",
},
models_unavailable = {
"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",
@ -550,10 +585,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 +626,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
@ -926,19 +975,27 @@ local PLATFORMS = {
-- * 新增 beam-free / nemotron-3.5-super-vl-free / jev-1.13-free /
-- glm-5.3-flash-free / minimax-m3.1-flash-free
models = {
"big-pickle", "beam-free", "longcat-2.5-preview-free",
"big-pickle", "longcat-2.5-preview-free",
"mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free",
"nemotron-3.5-lightning-free", "jev-1.13-free", "glm-5.3-flash-free",
"nemotron-3.5-lightning-free", "glm-5.3-flash-free",
"minimax-m3.1-flash-free", "deepseek-v4.1-flash-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", "beam-free", "longcat-2.5-preview-free",
"big-pickle", "longcat-2.5-preview-free",
"mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free",
"nemotron-3.5-lightning-free", "jev-1.13-free",
"nemotron-3.5-lightning-free",
"glm-5.3-flash-free", "minimax-m3.1-flash-free", "deepseek-v4.1-flash-free",
},
-- ★ 目录里有、但**本协议下不可调用**的免费模型(2026-10-20 逐个实测):
-- * jev-1.13-free → 400 "Model does not support this protocol."
-- (它不是 OpenAI chat 协议,用 /v1/chat/completions 必失败)
-- * beam-free → 200 但 content 为空、无 finish_reason(上游未真正产出)
-- 二者若留在可选列表,用户选中后只会看到"空回复/报错" ⇒ 判为不可用,
-- 既不进 UI 列表,也不进上游源(models_unavailable 与 hidden 的区别见
-- effectiveModels 的注释)。
models_unavailable = { "jev-1.13-free", "beam-free" },
},
-- CodeArts(华为)—— 凭据 + SDK-HMAC-SHA256 签到(照参考 codearts-credits.ts)。
@ -1045,13 +1102,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 +1330,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 +2074,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 +2155,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 +2175,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 +2196,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 +2608,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 +2780,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 +3546,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
@ -3605,6 +3754,12 @@ local deadModelIds
local function effectiveModels(provider)
local plat = platformById(provider)
if not plat then return {} end
-- models_unavailable:**声明上就不可调用**的条目(不是"暂不显示")。
-- 与 models_hidden 的区别:hidden 只是不进 UI 列表(内部子 agent 仍可被
-- 显式调用),unavailable 连**上游源**都不进 —— 因为它们调用必然失败/回空,
-- 进源只会让客户端选中后报错(trae 的 custom_model_* 槽位就是这类)。
local unavailable = {}
for _, m in ipairs(plat.models_unavailable or {}) do unavailable[m] = true end
local remote = plugin.state.remoteModels and plugin.state.remoteModels[provider]
local out, seen = {}, {}
-- 远端在前(权威顺序)
@ -3625,7 +3780,7 @@ local function effectiveModels(provider)
local dead = deadModelIds(provider)
local kept = {}
for _, id in ipairs(out) do
if not dead[id] then kept[#kept+1] = id end
if not dead[id] and not unavailable[id] then kept[#kept+1] = id end
end
return kept
end
@ -4031,6 +4186,9 @@ end
local function refreshRemoteModels(provider)
local plat = platformById(provider)
if not plat then return nil, "未知平台" end
-- 最近一次目录响应的**原始体**:解析失败时用来区分"网络/HTTP 故障"与
-- "服务端 200 但账号侧没有可下发配置"(后者文案完全不同,见函数尾部)。
local lastBody = nil
local mr = plat.models_remote
if not mr then return nil, "该平台无远端目录端点" end
-- 取一个可用账号的凭据(无账号则无凭据,仅当 auth=none 时仍可拉)
@ -4126,12 +4284,30 @@ local function refreshRemoteModels(provider)
if err or not resp or resp.status ~= 200 then
lastErr = (err or ("HTTP " .. tostring(resp and resp.status)))
else
lastBody = resp.body or ""
for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body, provider)) do
if not seen[id] then seen[id] = true merged[#merged+1] = id end
end
end
end
if #merged == 0 then return nil, lastErr or "远端返回空目录" end
if #merged == 0 then
-- ★ 把"空目录"再细分一层:远程 200 但一条配置都没有,通常不是网络问题,
-- 而是**账号侧**没有可下发的 function 配置(企业租户账号实测:22 个
-- function 全回 error_code 2001 "Config detail for function: X not found")。
-- 这两类原因的处置完全不同(网络重试 vs 找租户管理员),日志不能都说
-- "远端返回空目录"。判据只看响应体里出现 2001 或 function_configs。
if not lastErr then
local seenBody = false
if type(lastBody) == "string" and lastBody:find("function_configs", 1, true) then
seenBody = true
end
if seenBody then
return nil, "远端 200 但无模型配置(账号侧未下发 function config,"
.. "企业/租户账号需管理员开启,实测 error_code 2001)"
end
end
return nil, lastErr or "远端返回空目录"
end
if not plugin.state.remoteModels then plugin.state.remoteModels = {} end
plugin.state.remoteModels[provider] = {
ids = merged,
@ -4336,9 +4512,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 +5705,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 +5784,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 +5827,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 +5959,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 +6325,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 +6349,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 +6366,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 +6536,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 +6555,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 +6576,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,
@ -6971,6 +7230,13 @@ local DEAD_GONE_PATTERNS = {
"unknown%s+model",
"no%s+such%s+model",
"nonexistent%s+model",
-- ★ opencode zen 的下架文案(2026-10-20 实测):
-- 410 `Model space-bunny-free has been deprecated. Use minimax-m3.1-flash-free instead.`
-- 此前不识别 ⇒ 该模型一直留在源里,用户选中必失败("渠道不可用"观感)。
-- 带 "has been deprecated" 整句是为了不误伤「参数 X 已弃用」这类**非模型**文案:
-- "Model ... has been deprecated" 的主语必须是 Model(%s+ 吃掉名字)。
"model%s+%S+%s+has%s+been%s+deprecated",
"model%s+has%s+been%s+deprecated",
}
-- 反向模式:命中即**不是**下架(照参考 NOT_GONE_PATTERNS)。
@ -7876,6 +8142,7 @@ function plugin.model_list(req)
-- models_hidden:内部子 agent(照参考 isHidden 语义)不进用户可选列表。
local hidden = {}
for _, m in ipairs(plat.models_hidden or {}) do hidden[m] = true end
for _, m in ipairs(plat.models_unavailable or {}) do hidden[m] = true end
-- models_free:参考的 isFree 字段(官方语义:匿名/免费可用)。
local free = {}
for _, m in ipairs(plat.models_free or {}) do free[m] = true end
@ -9601,7 +9868,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 +10020,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 +10612,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 +10640,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 +10659,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 +10810,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 +10819,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 +10884,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 +10899,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 +11284,7 @@ local ZD = (function()
end)()
local ZCODE_ADAPTER_SRC = [===[
local CLI_PREFIX = "You are ZCode, an interactive coding agent"
-- ============================================================================
-- ZCode 专用适配器(插件自装:channels-zcode)
--
@ -11048,7 +11314,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 +11626,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 +11721,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 +11733,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 +11748,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 +11807,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 +11825,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 +12182,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 +12360,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 +12392,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 +12775,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 +12892,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() } }
@ -12748,6 +13040,66 @@ function plugin.__list_sources_probe()
return { json = { sources = out } }
end
-- plugin.__trae_catalog_probe:只给诊断用 —— 原样回传 TRAE 远端模型目录的
-- HTTP 状态与前 1200 字节响应体。
--
-- 存在理由:`refresh_models` 只把「解析出几条」汇总成「远端返回空目录」,
-- 看不到**上游到底回了什么**(空数组?错误信封?形状变了?)。模型目录决定
-- 用户在渠道页能选哪些模型,它是"渠道不可用"类报障的第一现场,不能只有一句
-- 二手结论。探测逻辑与 refreshRemoteModels 的 trae 分支**同源**(同一个
-- traeCheckinHeaders + 同一份请求体),避免"探针通、真路径不通"。
function plugin.__trae_catalog_probe(args)
args = args or {}
local plat = platformById("trae")
if not plat or not plat.models_remote then
return { json = { error = "trae 平台未声明 models_remote" } }
end
local ep = (plat.models_remote.endpoints or {})[1] or {}
local cred
for _, a in ipairs(poolList("trae")) do
if a.enabled ~= false then
local raw = __llmsproxy_secrets.get(a.credentialRef)
local okj, cj = pcall(json.decode, raw or "")
if okj and type(cj) == "table" and cj.access_token then cred = cj break end
end
end
if not cred then return { json = { error = "无可用 TRAE 凭据" } } end
if type(traeCheckinHeaders) ~= "function" then
return { json = { error = "traeCheckinHeaders 未就绪" } }
end
local h, herr = traeCheckinHeaders(plat, cred, cred.uid or "")
if herr then return { json = { error = "构造头部失败: " .. tostring(herr) } } end
-- 诊断用覆盖:Accept 在参考里对目录请求是 application/json,而 checkin 的
-- 头部构造给的是 text/event-stream —— 用它可以判定"是不是 Accept 导致空目录"。
if type(args.accept) == "string" and args.accept ~= "" then
h["Accept"] = args.accept
end
-- 诊断用:额外头部(企业/租户类账号的 IDE 端点通常要求租户身份,
-- 而参考的 traeSOLOHeaders 不发。用它验证"缺租户头导致全 2001")。
if type(args.headers) == "table" then
for k, v in pairs(args.headers) do
if type(k) == "string" and type(v) == "string" then h[k] = v end
end
end
if type(args.ide_version) == "string" and args.ide_version ~= "" then
h["X-Ide-Version"] = args.ide_version
h["X-App-Version-Code"] = args.ide_version
h["X-Ide-Version-Code"] = args.ide_version
end
local resp, err = __llmsproxy_http({
url = ep.url, method = ep.method or "POST",
headers = h, body = (type(args.body) == "string" and args.body ~= "" and args.body) or ep.body or "{}",
})
if err then return { json = { error = tostring(err) } } end
local body = (resp and resp.body) or ""
return { json = {
status = resp and resp.status or 0,
bytes = #body,
head = body:sub(1, math.min(tonumber(args.clip) or 1200, #body)),
parsed = #parseRemoteModels("trae_batch", body, "trae"),
} }
end
-- plugin.__trae_entry_probe:只给测试/诊断用 —— 报告某模型最终选中条目的关键字段。
-- “合并规则选错了条目”(空档位覆盖有档位,issue IKI7WT/IKILR7)这类问题
-- 没有它只能靠猜。