mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 411503aea5 | |||
| 662f8f02b6 | |||
| 2113bfc125 | |||
| c9f34538ec | |||
| 12bfd53f16 | |||
| 25970a2135 | |||
| 6f2d4dc6eb | |||
| aff8354c8a | |||
| 091f790734 | |||
| 6eeb5832a3 | |||
| a189c4f61c | |||
| 58073a6cbe | |||
| a5923b3797 | |||
| 91f6d1f32f | |||
| c1048dcd4e | |||
| 2b8fcaf434 | |||
| 47fe759025 | |||
| d795266572 | |||
| a82ae2b60e | |||
| 2d2c27c430 | |||
| 0f83fc048a | |||
| 1f29be43db | |||
| 8109acf7cd | |||
| 7c82c7ff6b | |||
| 1f18643d1f | |||
| e6fa2a678f | |||
| b871c0c713 | |||
| 8b318a8507 | |||
| 69da3c9b92 | |||
| 1be34557dd | |||
| 9e5fec08b8 | |||
| 68446fb6fe | |||
| 7e0a428c23 | |||
| 7a8d97001c | |||
| 850920155a | |||
| eaac0ef6b3 | |||
| 226493b5ae | |||
| d3a0eb6311 | |||
| cbad61a3ea | |||
| 2a2067284d | |||
| 742e2b5ed3 | |||
| 81b3661336 | |||
| 9521a35a03 |
10
Makefile
10
Makefile
@ -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)"
|
||||
|
||||
@ -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.
|
||||
|
||||
4
cmd/gui/package-lock.json
generated
4
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.3",
|
||||
"version": "2.0.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.3",
|
||||
"version": "2.0.42",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.7",
|
||||
"version": "2.0.42",
|
||||
"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/**/*",
|
||||
@ -89,4 +90,4 @@
|
||||
"asar": true
|
||||
},
|
||||
"homepage": "https://gitcode.com/JianFeeeee/ModelRouter"
|
||||
}
|
||||
}
|
||||
82
cmd/gui/plugin-sync.js
Normal file
82
cmd/gui/plugin-sync.js
Normal 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,
|
||||
};
|
||||
80
cmd/gui/plugin-sync.test.js
Normal file
80
cmd/gui/plugin-sync.test.js
Normal 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);
|
||||
});
|
||||
68
deploy.sh
68
deploy.sh
@ -36,8 +36,15 @@ readonly REPO_DIR="/home/program/llmsproxy"
|
||||
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
|
||||
readonly BIN_NAME="llmsproxy"
|
||||
|
||||
# 构建目录在 /tmp(tmpfs 更快)
|
||||
readonly TMP_DIR="/tmp/llmsproxy-deploy"
|
||||
# 构建目录。默认落在仓库下的构建工作区,而不是 /tmp:本机 /tmp 是 8.8G
|
||||
# tmpfs,实测被 Go 构建缓存占满后会让 go test/build 在链接阶段直接失败。
|
||||
# 可用 LLMSPROXY_BUILD_DIR 覆盖;同一目录也被测试用作 GOTMPDIR。
|
||||
readonly BUILD_SUBDIR=".build-work"
|
||||
if [[ -n "${LLMSPROXY_BUILD_DIR:-}" ]]; then
|
||||
readonly TMP_DIR="${LLMSPROXY_BUILD_DIR}"
|
||||
else
|
||||
readonly TMP_DIR="${REPO_DIR}/${BUILD_SUBDIR}"
|
||||
fi
|
||||
# 同文件系统暂存文件(用于原子 rename)
|
||||
readonly STAGING_BIN="${TARGET_BIN}.staging"
|
||||
readonly STAGING_CONFIG="${TARGET_CONFIG}.staging"
|
||||
@ -142,7 +149,7 @@ build() {
|
||||
mkdir -p "$TMP_DIR/bin"
|
||||
|
||||
cd "$REPO_DIR"
|
||||
CGO_ENABLED=1 go build -tags luajit \
|
||||
CGO_ENABLED=1 GOTMPDIR="$TMP_DIR" go build -tags luajit \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "$TMP_DIR/bin/$BIN_NAME" \
|
||||
@ -302,48 +309,60 @@ verify_master_key() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------- 适配器备份保留策略 ----------
|
||||
# 每次部署都新建一个 adapters.bak.<时间戳> 目录,而回滚只用到最近一次
|
||||
# ---------- 备份目录保留策略 ----------
|
||||
# 每次部署都新建一个 <prefix>.bak.<时间戳> 目录,而回滚只用到最近一次
|
||||
# ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理,
|
||||
# 实测线上已积累 71 个,/etc/llmsproxy 因此涨到 122M。
|
||||
#
|
||||
# 保留最近 KEEP_ADAPTER_BACKUPS 份足够回滚,同时给目录数设上限——否则
|
||||
# 一次误配置(比如 adapter_dir 指错)就可能在几秒内造出成百上千个目录。
|
||||
# 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
|
||||
KEEP_ADAPTER_BACKUPS=5
|
||||
# 保留最近 KEEP_BACKUPS 份足够回滚,同时给目录数设上限——否则一次误配置
|
||||
# 就可能在几秒内造出成百上千个目录。
|
||||
# 只删名字严格匹配 <prefix>.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
|
||||
#
|
||||
# ★ 必须**同时覆盖 adapters 与 plugins**(同一缺陷的两个实例):
|
||||
# 本函数最初只处理 adapters.bak,而 sync_plugins 同样每次建 plugins.bak.*
|
||||
# —— 于是修了 adapters 的堆积、plugins 继续涨(实测线上 36 个、占用 192M)。
|
||||
# 这正是「两处只修一处」的老形状:给一个新前缀加备份目录时,必须同步纳入
|
||||
# prune_all_backups,否则它就是一个不会被清理的新泄漏点。
|
||||
KEEP_BACKUPS=5
|
||||
|
||||
prune_adapter_backups() {
|
||||
prune_backups() {
|
||||
local prefix="$1"
|
||||
local base="/etc/llmsproxy"
|
||||
[[ -d "$base" ]] || return 0
|
||||
# 防误用:前缀必须是已知的两个(避免有人传空串删掉整个目录)
|
||||
case "$prefix" in
|
||||
adapters|plugins) ;;
|
||||
*) fail "prune_backups: 未知前缀 '$prefix'(只接受 adapters|plugins)" ;;
|
||||
esac
|
||||
|
||||
# 先按数量上限硬裁:即使时间戳排序失效也不会无上限增长。
|
||||
local all
|
||||
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
|
||||
local cap=$((KEEP_ADAPTER_BACKUPS * 4))
|
||||
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
|
||||
local cap=$((KEEP_BACKUPS * 4))
|
||||
if (( ${#all[@]} > cap )); then
|
||||
warn "适配器备份目录有 ${#all[@]} 个(异常),裁到 $cap"
|
||||
warn "$prefix 备份目录有 ${#all[@]} 个(异常),裁到 $cap"
|
||||
local i=0
|
||||
for d in "${all[@]}"; do
|
||||
i=$((i + 1))
|
||||
# 从最旧的开始删(sort 后升序)。名字不规范的跳过不删。
|
||||
if (( i <= ${#all[@]} - cap )); then
|
||||
if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then
|
||||
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
|
||||
rm -rf "${base:?}/$d"
|
||||
else
|
||||
warn "跳过名字不规范的备份目录(不删): $d"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
|
||||
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
|
||||
fi
|
||||
|
||||
# 再按时间保留最近 KEEP_ADAPTER_BACKUPS 份(sort 后最新在末尾)。
|
||||
# 再按时间保留最近 KEEP_BACKUPS 份(sort 后最新在末尾)。
|
||||
local total=${#all[@]} i=0
|
||||
for d in "${all[@]}"; do
|
||||
i=$((i + 1))
|
||||
# i <= total-KEEP 的是较旧的,要删。
|
||||
if (( i <= total - KEEP_ADAPTER_BACKUPS )); then
|
||||
if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then
|
||||
if (( i <= total - KEEP_BACKUPS )); then
|
||||
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
|
||||
rm -rf "${base:?}/$d"
|
||||
else
|
||||
warn "跳过名字不规范的备份目录(不删): $d"
|
||||
@ -352,8 +371,15 @@ prune_adapter_backups() {
|
||||
done
|
||||
|
||||
local left
|
||||
left=$(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' | wc -l)
|
||||
log " 适配器备份保留最近 $KEEP_ADAPTER_BACKUPS 份(当前剩 $left 个)"
|
||||
left=$(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" | wc -l)
|
||||
log " $prefix 备份保留最近 $KEEP_BACKUPS 份(当前剩 $left 个)"
|
||||
}
|
||||
|
||||
# prune_all_backups:两个前缀都裁。
|
||||
# ★ 新增任何 .bak.<时间戳> 目录的前缀时,必须在这里补上 —— 否则又一个泄漏点。
|
||||
prune_all_backups() {
|
||||
prune_backups adapters
|
||||
prune_backups plugins
|
||||
}
|
||||
|
||||
# ---------- 同步适配器 ----------
|
||||
@ -373,7 +399,7 @@ sync_adapters() {
|
||||
cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true
|
||||
log " 旧适配器已备份到 $BACKUP_DIR"
|
||||
fi
|
||||
prune_adapter_backups
|
||||
prune_all_backups
|
||||
|
||||
cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/"
|
||||
chmod 0644 "$TARGET_ADAPTERS/"*.lua
|
||||
|
||||
125
docs/channel-issues-2026-10-19.md
Normal file
125
docs/channel-issues-2026-10-19.md
Normal file
@ -0,0 +1,125 @@
|
||||
# 渠道问题清单(2026-10-19 晚生产实测)
|
||||
|
||||
> 逐项修复。每项标注:现象 / 根因 / 状态。
|
||||
|
||||
## P1 trae 非流式 500(unmarshal unified response: invalid character 'e')
|
||||
- **现象**:`stream:false` 打 `channels-trae:*` 必现 500,body 以 `event:metadata data:{...}` 开头。
|
||||
- **根因**:trae SOLO 上游**对非流式请求也回 SSE**(且帧带 `event:` 前缀)。
|
||||
插件自装的 `channels-trae` 适配器 `transform_response` 直接 `__prelude.openai_response(raw_body)`,
|
||||
不识别 SSE;而内核只把 `data:` 行交给流式适配器,非流式路径把**整个 body**(含 `event:` 行)
|
||||
塞给 transform_response → json.decode 失败 → 原样返回 → Go 侧 unmarshal 报 `invalid character 'e'`。
|
||||
(`internal/lua/adapters/trae.lua` 有 SSE 聚合逻辑,但 channels 平台用的是**插件内嵌**
|
||||
`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`。
|
||||
- **根因**:腾讯 codebuddy 网关**只支持流式**。适配器 `transform_request` 没有强制 `stream=true`,
|
||||
非流式请求原样透传被上游拒绝。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
|
||||
被占满)与上游 `11133: the request parameters were rejected`。
|
||||
- **根因**:① max_concurrent=1 是**刻意的**(channels.lua:4332 注释:并发配额按模型计),busy
|
||||
是正常自保;② 11133 与空 tool_calls 修复(未提交的 buddy.lua 改动)相关,待部署后观察。
|
||||
- **状态**:观察项,非缺陷。
|
||||
|
||||
## P4 Qoder 领取失败(缺少凭据对象)——已修复
|
||||
- **现象**:`channels/claim` 报 `{"error":"缺少凭据对象"}`,而 account_test 正常。
|
||||
- **根因**:qoder 是 PKCE 设备码流且无 register 第二跳,落库凭据是**裸 token 字符串**;
|
||||
`qoderClaim` 硬要求 `type(cred)=="table"`。
|
||||
- **修法**:`qoderClaim` 兼容裸 token(非空时包 `{access_token=tok}`);
|
||||
回归测试 `TestQoderClaimAcceptsBareTokenCredential`。
|
||||
- **状态**:✅ 已修复并部署生产(claim 现在返回真实上游业务结论)。
|
||||
|
||||
## P5 Qoder/OpenCode 渠道页账号串台(UI 渲染竞态)
|
||||
- **现象**:从 qoder 切到 opencode 详情页,qoder 的账号行闪现在 opencode 卡片下。
|
||||
- **根因**:`renderDetail()` 切渠道时不清空 `#ch-accts`,`renderAccounts` 是异步的,
|
||||
空窗期 DOM 仍挂旧渠道账号行;且慢的旧请求返回会覆盖新渠道数据。
|
||||
- **修法**:① renderDetail 立即清空账号/模型区并置"加载中";② renderAccounts/renderModels
|
||||
加代际校验(`state.selected !== id` 时丢弃响应)。
|
||||
- **状态**:✅ 已修复并部署生产。
|
||||
|
||||
## P6 CodeArts「完全不可用」——多数模型实际可用
|
||||
- **现象**:用户报"codearts 完全不可用"。实测逐模型探测:
|
||||
- 可用:openpangu-2.0-flash/pro、GLM-5.2、glm-5.3、kimi-k2.6-vl、Qwen3-VL-235B、glm-5.2-sft-harmony
|
||||
- 429(限流非下架):deepseek-v4.1-flash、deepseek-v4-flash、openpangu-2.0-pro(间歇)
|
||||
- 404(真下架,需标 disabled):GLM-5.3 报 `InferHub.002002009.404: The model is not registered`
|
||||
- **附带发现**:`GLM-5.3`(大写变体)不在 deadModels 记录里——`recordDeadModel` 只被
|
||||
`__record_dead_probe` 调用,**request_end hook 没接 isModelGone**(583976f 引入的
|
||||
基础设施丢了挂接点)。需要把 on_request_end 补上 dead-model 记录。
|
||||
- **凭据**:expiresAt 已过期(15:05),`account/refresh` 续期成功(refreshable=true)。
|
||||
建议把「凭据到期自动续期」纳入定时任务检查。
|
||||
- **修法(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 端点都锁死一个区域。
|
||||
- **修法(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)扫码登录「链接无效」——已修复
|
||||
- **现象**:用户点登录看到的链接打不开(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 归一)、
|
||||
channels.lua(qoder 裸 token + UI 竞态)、provider_test.go、channels_qoder_claim_test.go。
|
||||
- 生产 channels.lua 哈希与仓库不同步是常态(deploy.sh 会 sync_plugins)。
|
||||
- 商汤账号:用户需在二维码有效期内扫码确认;当前账号 5a2ea34df521 无凭据属正常(未完成扫码)。
|
||||
183
docs/channel-repair-plan.md
Normal file
183
docs/channel-repair-plan.md
Normal file
@ -0,0 +1,183 @@
|
||||
# 渠道链路修复计划与现状
|
||||
|
||||
> 本文是 `plan.md` 中「渠道链路修复计划(2026-10-17 起)」章节的**可提交版本**。
|
||||
> `plan.md` 本身被 `.gitignore` 忽略(本地工作台账),故关键计划与现状单独落在这里,
|
||||
> 以便随代码一起进 git、供他人查阅。
|
||||
>
|
||||
> **写作原则:只写实测到的,标注"推断"的必须写明。** 每条缺陷都附可复现的观测。
|
||||
|
||||
> 记录 **2026-10-17 用户报障三连修**(模型不可用 / 额度异常 / 其他渠道同类)
|
||||
> 的现状与后续计划。上接 `plan.md` §9(v2 契约收尾)。
|
||||
|
||||
## 1. 现状快照(2026-10-17 14:00 实测,非推断)
|
||||
|
||||
### 1.1 生产实例
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 服务 | `active`,NRestarts=0 |
|
||||
| 二进制 sha256 | `56396f79bfc2433a`(与仓库逐字节一致) |
|
||||
| 插件 sha256 | `c15cb4bf5d5c0f21`(与仓库逐字节一致) |
|
||||
| 插件 | 4 个(billing/channels/quota/usage)**全部 loaded** |
|
||||
| 源 / 适配器 | 21 源 / 23 适配器 |
|
||||
| 账号池 | **仅 trae、buddy 各有 1 个有凭据账号**;其余 10 个平台**账号数 0** |
|
||||
|
||||
### 1.2 各渠道缺陷矩阵(本文件写入时的实测)
|
||||
|
||||
自装适配器(插件在加载时经 `__llmsproxy_adapters` 装入):
|
||||
|
||||
| 适配器 | `build_headers` | 静态 `adapter.headers` | 流式转换 | 鉴权是否可能错 |
|
||||
|---|---|---|---|---|
|
||||
| `codearts` | 有 | 无 | 复合 openai | 有(SDK-HMAC 自签,已实现) |
|
||||
| `channels-trae` | **有(本次新加)** | 无 | **SOLO 自定义(本次新写)** | 已修 |
|
||||
| `channels-gemini` | **★ 缺** | 有 | 自定义 | 静态头 + Bearer,**待确认** |
|
||||
| `channels-zcode` | **★ 缺** | 无 | 无(继承 anthropic 基座) | **★ 高度可疑** |
|
||||
| `channels-cline` | **★ 缺** | 无 | 套 openai | **★ 可疑**(要 `Bearer workos:<jwt>`) |
|
||||
| `channels-qoder` | **★ 缺** | 无 | 套 openai | **★ 可疑**(头由 WASM 生成) |
|
||||
|
||||
内置适配器里 `build_headers` 只有 3 个有(buddy / kimicode / opencodezen),
|
||||
其余 11 个(含 **anthropic 基座**)靠静态头 + 内核兜底 `Bearer`。
|
||||
|
||||
> **★ anthropic 基座本身没有 `build_headers`**,而 anthropic 协议实际要
|
||||
> `x-api-key` 而非 `Authorization: Bearer`。`channels-zcode` 继承它 ⇒
|
||||
> 一旦有账号就会立刻暴露,与 trae 修复前完全同型。
|
||||
|
||||
### 1.3 已验证可用的通道(生产实测,不是单测)
|
||||
|
||||
| 模型(源限定名) | 结果 |
|
||||
|---|---|
|
||||
| `channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` | ✅ 80 帧,正文 27 + 推理 299 |
|
||||
| `channels-trae-b8dd6b3df30e:glm-5.2` | ✅ OK |
|
||||
| `channels-buddy-buddy-main:hy4-preview` | ✅ OK |
|
||||
|
||||
| 端点 | 结果 |
|
||||
|---|---|
|
||||
| `channels/credits/status` (trae) | ✅ `active:true, dailyCredit:100, todayCheckedIn:true` |
|
||||
| `channels/credits/balances` (trae) | ✅ `total:598.516 credits` |
|
||||
|
||||
### 1.4 本轮已修(代码已部署生产,**尚未提交 git**)
|
||||
|
||||
**① 网关解不开 `源名:模型名` 限定名**(`internal/provider/registry.go`)
|
||||
|
||||
- 根因:`ResolvePinned` 用 `strings.IndexAny(model, "-:/")` 找**第一个**分隔符。
|
||||
渠道源名形如 `channels-trae-b8dd6b3df30e`(**含 `-`**)⇒ 切成 `src="channels"`
|
||||
⇒ 永不匹配 ⇒ `Resolve()` 返回 nil ⇒ `model "..." is not configured`。
|
||||
- 为什么必须支持:本实例 **44 个模型被多源同时提供**(`glm-5.2` 由 6 个源提供),
|
||||
WebUI 下拉框用 `源名:模型名` 作 option value 是**有意设计**。
|
||||
- 修法:抽 `splitPinned()`,**按已知源名做最长前缀匹配**(源名是已知集合,
|
||||
唯一可靠办法),保留旧 `-:/` 兜底。`EffectiveModel` 同步修。
|
||||
- 判据:`internal/provider/registry_pinned_name_test.go`(4 用例,变异 1/1 咬住)。
|
||||
|
||||
**② trae 适配器三连错**(`internal/lua/plugins/channels.lua`,逐层剥开)
|
||||
|
||||
1. **没有 `build_headers`** ⇒ 内核兜底发 `Bearer`,而 trae 推理端点要
|
||||
`Cloud-IDE-JWT` + 设备身份头 ⇒ 401「not able to authenticate you」。
|
||||
→ 与「额度不可用」是**同一根因的两个出口**(额度端点当轮修了、推理端点漏了)。
|
||||
2. **header 常量值是错的**:`X-App-Id` 抄成 `6eefa6c9`(真实
|
||||
`6eefa01c-1036-4c7e-9ca5-d891f63bfcd8`)、`app_version_code` 用 `0.1.52`
|
||||
(真实是**日期式** `20260811`)⇒ 上游 `4001 binding: expr_path=app_version_code,
|
||||
cause=parameter type does not match binding data`。
|
||||
3. **流式转换完全不对**:TRAE 是 **SOLO 自定义 SSE**
|
||||
(`event:output` → `data:{"response":"###","reasoning_content":...}`)。
|
||||
套 `openai_stream_chunk` ⇒ 无 `choices` ⇒ `empty stream`;
|
||||
改成 OpenAI 外壳 `{choices:[{delta}]}` **仍不对** —— 内核
|
||||
`json.Unmarshal(unified, &UnifiedChunk)` 解的是**扁平结构**,外壳字段全丢。
|
||||
⇒ 必须输出扁平 `{content, reasoning_content, usage, done, finish_reason}`。
|
||||
- 判据:`channels_trae_chat_headers_test.go`(3)、`channels_trae_solo_sse_test.go`(2),
|
||||
变异 2/2 咬住。
|
||||
|
||||
**③ 余额恒为 0**(同文件 `balance.total_fn`)
|
||||
|
||||
- 根因:读 `p.credits_limit`(pack **顶层**)—— 该字段**根本不存在**。
|
||||
真实路径是 `pack.entitlement_base_info.quota.credits_limit`
|
||||
(参考 `trae-credits.ts:341`)+ `pack.usage.credits_amount`。
|
||||
- 实测上游:`免费`(无 credits_limit) / `每月登录赠送`(500) / `签到奖励`(100) = **600**。
|
||||
- 修法:读嵌套路径 + 保留顶层兼容 + `usage_summary.{total,consumed}_amount` 兜底。
|
||||
- 判据:`channels_trae_credits_hdr_test.go`(1)。
|
||||
- 修后余额 **600 → 实测 598.516**(真实扣减)。
|
||||
|
||||
**④ 附带**:`channels_trae_test.go` 夹具从**扁平** `{"Token":...}` 改为真实
|
||||
`{"Result":{"Token":...}}` —— 原来夹具形状与生产不一致,**判据必然虚假通过**
|
||||
(这是 ExchangeToken 缺陷藏了很久的原因)。
|
||||
|
||||
## 2. 后续计划
|
||||
|
||||
### P0 — 提交本轮修复(必须先做)
|
||||
|
||||
代码已部署生产但**未进 git**。必须:
|
||||
1. 在 `release/v2.0.x` 上提交(含 3 个源文件 + 6 个判据文件)
|
||||
2. 打 tag `v2.0.40`(**这是内核 Go 改动**:`registry.go` ⇒ 真核心版本)
|
||||
3. 回流 main(cherry-pick,不 merge)
|
||||
4. 复核 tag 落点 + release vs main 零差异 + 生产 sha256 一致
|
||||
|
||||
> 判据:`git tag --points-at <release HEAD>` 非空;`git diff release/v2.0.x main` 为空。
|
||||
|
||||
### P1 — 其余渠道的同类缺陷(**账号 0,验证不了**)
|
||||
|
||||
按"风险 × 可验证性"排序。**核心限制:这 4 个平台账号数均为 0,
|
||||
无法做生产实测** —— 只能靠静态对齐参考 + 装好适配器后看 `__adapter_install_probe`。
|
||||
|
||||
| 优先 | 项 | 缺陷 | 修法 | 可验证性 |
|
||||
|---|---|---|---|---|
|
||||
| P1-1 | `channels-zcode` | 走 anthropic 基座,而**基座无 `build_headers`**,anthropic 要 `x-api-key` 不是 Bearer | 给基座补 `build_headers`(`x-api-key` + `anthropic-version`),zcode 继承即得 | 静态可验;运行需账号 |
|
||||
| P1-2 | `channels-cline` | 参考要 `Bearer workos:<jwt>`,我们的 token 无 `workos:` 前缀 | 登录/续期处拼前缀,或适配器 `build_headers` 补 | 静态可验;运行需账号 |
|
||||
| P1-3 | `channels-gemini` | 有静态头 + Bearer(参考注释也说 Cloud Code 用 OAuth Bearer)⇒ **可能本来就对** | 只需确认,不改 | 静态可验 |
|
||||
| P1-4 | `channels-qoder` | 头由 **WASM 生成必须原样透传**,我们拿不到 `.wasm` | **阻塞**:二进制不在仓库任何位置 | 不可验证 |
|
||||
|
||||
### P2 — 内核对"限定名"的契约收口
|
||||
|
||||
`ResolvePinned` / `EffectiveModel` 现在各自调 `splitPinned`。建议:
|
||||
- 把"源名可含哪些字符"写进 `docs/`,并加判据:新增源名若含 `:` 或 `/` 必须报警
|
||||
(否则限定名会歧义)。
|
||||
- 现状:源名由插件生成 `channels-<platform>-<accountId>`,只含 `-`/字母数字,安全。
|
||||
|
||||
### P3 — 判据防回归(防"夹具与生产不一致"再犯)
|
||||
|
||||
本轮最大的教训不是某个 bug,而是**夹具形状错 ⇒ 判据虚假通过**(trae 活了两轮)。
|
||||
|
||||
建议加一条**通用**判据:`TestAdaptersFixturesMatchRealUpstreamShape` ——
|
||||
对每个有夹具的渠道,把夹具响应体与
|
||||
`/tmp/pi-github-repos/.../vendor/channel-pack/src/*.ts` 里注释记录的真实响应形状
|
||||
做**结构对照**(键集合),不一致即报红。做不到全自动,至少把已知的
|
||||
"Result 信封 / SOLO SSE / 嵌套 quota" 三类钉住。
|
||||
|
||||
### P4 — 待用户决策
|
||||
|
||||
| 项 | 需要什么 |
|
||||
|---|---|
|
||||
| zcode 真实登录 | 用户点授权(身份块 + init-poll 流已就绪) |
|
||||
| cline / qoder / gemini / codearts / minimax / lobsterai / opencode | 需各平台账号;`device-poll` 系列要用户去官方页输码 |
|
||||
| raccoon / loomy | 需扫码 |
|
||||
| qoder WASM | 用户提供 `.wasm` |
|
||||
|
||||
## 3. 风险与纪律(本轮新增)
|
||||
|
||||
1. **`luac -p` 对长字符串内的适配器代码无效**(只看外层)⇒ 会得到假绿。
|
||||
**必须**用 `__adapter_install_probe` 看真实装载结果。本轮我因此把 TRAE 的
|
||||
SSE 代码误塞进 CODEARTS 适配器,导致 codearts 装载失败而 `luac` 一直通过。
|
||||
2. **Python 字符串替换会静默不生效**:锚文本缩进不符 ⇒ 匹配 0 次。
|
||||
规矩:① 先 `cp` 备份 ② **行号定位** ③ 写后 `luac -p` + `grep -c` 双确认
|
||||
④ 长字符串内的改动必须验装载。
|
||||
3. **`function` 是 Lua 关键字**:`cc.function` 是语法错误,必须 `cc["function"]`;
|
||||
但批量替换时别把 `cc.function_call` 一起改坏(本轮踩过,导致
|
||||
`')' expected near '_call'`)。
|
||||
4. **判据要咬被测对象**:本轮 `channels_trae_credits_hdr_test.go` 第一版造 HTTP 假上游,
|
||||
因 URL 覆盖/凭据 ref/路径匹配任一环错,"没发请求"伪装成"头不对"(耗了三轮)。
|
||||
改成直接咬 `spec.headers_fn` 的真实产出,一轮就准。
|
||||
5. **只改插件不必换二进制**;改内核 Go 必须换。混着发会把"插件改动"记成"核心版本"
|
||||
(v2.0.21–v2.0.39 有 18 个 tag 是这么来的,见 §4)。
|
||||
6. **`/tmp` 是 8.8G tmpfs**,Go 链接失败常见于它满(`no space left on device`)。
|
||||
元凶通常是 `/tmp/gocache`(可安全清)。
|
||||
|
||||
## 4. 账务实况(必须记住)
|
||||
|
||||
| 事实 | 值 |
|
||||
|---|---|
|
||||
| 内核 Go 代码自 v2.0.20 起的**全部**改动 | 仅 `internal/lua/adapter_prelude.go`、`internal/provider/provider.go`、`internal/provider/registry.go` |
|
||||
| 只改插件却发了核心 tag 的版本 | v2.0.21–v2.0.39 中的 **18 个** |
|
||||
| 当前最新 tag | `v2.0.39`(`091f790`),落点正确 |
|
||||
| main | `56ea269`(与 release 内容零差异) |
|
||||
| **未提交的改动** | 本轮三个修复(见 §1.4),已部署生产 |
|
||||
|
||||
> ⇒ **版本号语义教训**:以后"改插件不打核心 tag",插件自身维护版本;
|
||||
> 内核版本只在 Go 代码变更时递增。
|
||||
@ -489,3 +489,52 @@ error)被当成功、`pcall` 只取 ok 不看返回值(把诊断信息扔了
|
||||
**未实现的部分集中在两类**:(1) 需要**真实浏览器/验证码**的领取全自动
|
||||
(captcha 系 ~3300 行);(2) **二进制缺失**(qoder wasm)。
|
||||
其余是宿主 UI 特性或前提不存在的模块。
|
||||
|
||||
---
|
||||
|
||||
# 第三批(2026-10-14 晚)—— 逐模块对照的**新发现**
|
||||
|
||||
## ★ 本轮新补的三个真缺陷
|
||||
|
||||
### ① 自动领取每天静默漏领(`credits.ts` / `auto-checkin.ts`)
|
||||
活动刷新时刻**各平台不同**(Qoder 每日 10:00 UTC+8),而我们只在 08:00 跑一次
|
||||
⇒ 上午看到昨天那条 `CLAIMED`、报 `already-claimed` 并记账 ⇒ 当天再不去领。
|
||||
**修法照参考**:不配"时刻表"(必然漂移),让**产出方声明 `covers_today`**,
|
||||
记账方只数"有几条能证明今天已处理"。两层都要:频率提高 + `first_due_now`。
|
||||
|
||||
### ② 凭据续期此前是假的(`refresh.ts` / `expiry-sync.ts`)
|
||||
`account_refresh` **一个换 token 的请求都不发**却恒返回 `ok:true`
|
||||
⇒ `refresh_creds` 打「已续期 N 个」而什么都没续 —— **日志在说谎**。
|
||||
这是本轮第三次遇到同形态(前两次:`account_test` 恒 ok、业务级错误被当成功)。
|
||||
|
||||
### ③ `isFree` 与声明自相矛盾(`cline-models.ts`)
|
||||
cline 的免费模型声明在 `models` 里 ⇒ 被标成收费。
|
||||
★ 关键认识:**两个参考用的是不同机制** —— cline 用远端 `free[]`,
|
||||
opencode 用 models.dev 的 `cost`。混成一条 OR 链会与既有判据正面冲突。
|
||||
⇒ 加 `free_source` 由**平台声明**权威来源。
|
||||
|
||||
## 插件自装适配器(第 3、4 个)
|
||||
| 适配器 | 解决的问题 |
|
||||
|---|---|
|
||||
| `channels-qoder` | 错误信封**双层编码**;`10605`(排队) 与 `110`(额度耗尽) 语义**相反**,必须分开说(参考实测:110 原被归成通用服务端错误 → 白重试 5 次 ≈15.5 秒) |
|
||||
| `channels-cline` | 429 **无 retry-after 头**、等待时长写在英文句子里(`Try again in 19h 39m`)。解析不出 ⇒ 内核按 30 秒短冷却 ⇒ **每 30 秒猛敲上游**(比参考的「永远 60 分钟」更糟) |
|
||||
|
||||
现共 4 个:`channels-trae` / `channels-gemini` / `channels-qoder` / `channels-cline`。
|
||||
|
||||
## 对照后判定「不适用」的(附理由,不是"没做")
|
||||
|
||||
| 模块 | 为什么不需要 |
|
||||
|---|---|
|
||||
| `account-pool` 并发写竞态 | 我们的落盘是**同步** tmp+rename(Lua 无异步),每次写序列化**当前** state。参考的缺陷是"异步后端 + 共享可变快照"的产物。**已加判据钉住**(已删账号不复活、新增不丢失、内存/磁盘不分叉) |
|
||||
| `gemini-messages` SSE 末帧丢失 | 我们的 `bufio.Scanner` 对无尾换行的最后一行**照样产出**(已实测自证)。参考那是在 `while(!done)` 里按行处理所致 |
|
||||
| `cline-rate-limit` 的 30 分钟策略冷却 | 我们**每账号一个源**(`channels-<plat>-<acct>`)⇒ 某账号被拦只冷却它自己,下个请求自然换源。参考是"一个池里换号",才需要长冷却挡住同一轮里的连续重撞 |
|
||||
| `message-shape` | DSH 宿主的消息形状迁移;我们直接透传 OpenAI 形状 |
|
||||
| `loop-recovery` | 前提不存在(我们的内核没有循环守卫) |
|
||||
| `reasoning-ladder` | 需要内核支持 `efforts` 字段(属内核改动,按纪律不做) |
|
||||
| `retry-after.ts` | 参考那份实现**自己就是死代码**(grep 无消费者);我们的固定 30s 冷却有明确理由(429 是"太快"不是"坏了",走指数退避会让额度充沛的源被锁 30 分钟) |
|
||||
| `image-budget` | 实际缩放委托给宿主的附件服务,我们无对应物 |
|
||||
|
||||
## 运行环境的一条教训(不是代码缺陷)
|
||||
全量 `go test` 曾出现 `TestConcurrentPluginAdapterUpdates` **挂 59 分钟**、
|
||||
看着像死锁。单独跑 **0.058s 通过** ⇒ 真因是 **`/tmp`(8.8G tmpfs)被 Go 构建
|
||||
缓存占满 100%**。教训:构建/测试挂住时先 `df -h /tmp`(`/` 空闲不代表 `/tmp` 够)。
|
||||
|
||||
@ -377,7 +377,48 @@ plugin.http = {
|
||||
路径,未认证会跳登录页。
|
||||
- 未声明的路径不会因为落在允许命名空间而被放行——不在 handler 表里就是 404。
|
||||
|
||||
### 5.6 在运行时安装/更新适配器(token 轮换)
|
||||
### 5.6 前缀代理:把插件自起的服务挂到网关同端口
|
||||
|
||||
前节的自定义挂载点只能回答**由插件计算出来的内容**(`{status, headers, body/json}`)。
|
||||
若插件自己起了一个 TCP/HTTP 服务(典型:服务端浏览器栈 + noVNC),客户端就需要
|
||||
**字节级转发**,包括 **WebSocket 升级** —— 前者做不到。
|
||||
|
||||
为此声明 `prefix = true`,handler 每次请求返回一个**回环目标**:
|
||||
|
||||
```lua
|
||||
plugin.http = {
|
||||
{ path = "myplugin/proxy/:session", method = "GET", prefix = true,
|
||||
roles = { "admin" }, handler = "proxy" },
|
||||
}
|
||||
|
||||
function plugin.proxy(req)
|
||||
-- 每次请求都问一遍:会话没了/过期了就返回 404,地址自然失效
|
||||
if not sessionAlive(req.params.session) then
|
||||
return { status = 404, json = { error = "no such session" } }
|
||||
end
|
||||
return { proxy = "http://127.0.0.1:60100" }
|
||||
end
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **只能返回 `{ proxy = <目标> }`**,没有 `status` / `body`。写了 `status` 会被忽略。
|
||||
- 目标必须是 **带显式端口的字面回环 IP**(`http://127.0.0.1:60100`)。
|
||||
拒绝 `https://`、`localhost` 这类主机名、无端口、带 userinfo/path/query 的地址。
|
||||
理由:主机名可能被 hosts / DNS / 代理环境改写;通用能力只接受确定性地址。
|
||||
- 路由匹配同前缀下的**所有子路径**,`:param` 仍照常捕获,`req.path` 是完整
|
||||
请求路径,子路径由插件自己从 `req.path` 里切。
|
||||
- **鉴权先于代理**:`roles` / `keys` / `public` 语义与普通端点一致。
|
||||
- **不转发网关凭据**:`Authorization`、`Cookie`、`api_key` 都不会传给后端服务。
|
||||
- **拒绝跨站**:`Origin` 与网关自身 host 不一致返回 403。WebSocket 由浏览器
|
||||
发起且自动带 Cookie,否则第三方网页可借管理员会话开隧道。
|
||||
- **撤销点唯一**:handler 不再返回目标(或返回 404)即失效,内核不缓存目标。
|
||||
|
||||
客户端拿到的 URL 是**相对网关口**的绝对路径(如
|
||||
`/api/plugins/http/myplugin/proxy/<id>/vnc.html`),因此远程访问、HTTPS 入口、
|
||||
Cookie 鉴权都自然生效,无需暴露额外端口。
|
||||
|
||||
### 5.7 在运行时安装/更新适配器(token 轮换)
|
||||
|
||||
插件可以在运行时安装、改写**自己的**适配器 —— 这是"带自动轮换的认证"的实现
|
||||
方式:拿着新 token 重写适配器源码,后续请求自然使用新逻辑(内核重建该适配器
|
||||
|
||||
@ -62,13 +62,18 @@ const (
|
||||
DefaultSourceConcurrency = 8
|
||||
)
|
||||
|
||||
// Model is a single exposed model id bound to a source, with priority used by
|
||||
// AUTO auto selection (higher number = preferred).
|
||||
// Model is a single exposed model id bound to a source.
|
||||
//
|
||||
// ★ 曾经有 `Priority int` 字段,并号称"高者优先参与 AUTO 选源"。**那是错的**:
|
||||
//
|
||||
// AUTO 链由 `config.ModelScope.Tier` 驱动(见 scheduler.Chain),而 priority
|
||||
// 只被 `bestChatModel/bestImageModel` 用于"本源码内部该发哪个模型",
|
||||
// 与跨源调度毫无关系。把它当成调度依据会让人误以为改个数字就能改顺序,
|
||||
// 改完发现毫无效果 —— 已彻底删除,避免再次误导。
|
||||
type Model struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Priority int `yaml:"priority" json:"priority"`
|
||||
Kind string `yaml:"kind" json:"kind"` // "chat" (default) | "image"
|
||||
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Kind string `yaml:"kind" json:"kind"` // "chat" (default) | "image"
|
||||
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Source describes a single upstream LLM provider.
|
||||
|
||||
@ -52,9 +52,6 @@ sources:
|
||||
if cfg.Sources[1].MaxConcurrent == 0 {
|
||||
t.Fatal("default max_concurrent not applied")
|
||||
}
|
||||
if cfg.Sources[0].Models[0].Priority != 100 {
|
||||
t.Fatalf("priority = %d", cfg.Sources[0].Models[0].Priority)
|
||||
}
|
||||
if cfg.DefaultModel != "AUTO" {
|
||||
t.Fatalf("default model = %q", cfg.DefaultModel)
|
||||
}
|
||||
|
||||
@ -14,9 +14,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "deepseek",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "deepseek-v4-flash", Priority: 100, Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Priority: 90, Kind: "chat"},
|
||||
{ID: "deepseek-v4-flash-vision-exp", Priority: 60, Kind: "chat"},
|
||||
{ID: "deepseek-v4-flash", Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Kind: "chat"},
|
||||
{ID: "deepseek-v4-flash-vision-exp", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -25,10 +25,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "glm-5.3", Priority: 100, Kind: "chat"},
|
||||
{ID: "glm-5.3-flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "glm-5.2", Priority: 80, Kind: "chat"},
|
||||
{ID: "glm-5.1", Priority: 60, Kind: "chat"},
|
||||
{ID: "glm-5.3", Kind: "chat"},
|
||||
{ID: "glm-5.3-flash", Kind: "chat"},
|
||||
{ID: "glm-5.2", Kind: "chat"},
|
||||
{ID: "glm-5.1", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -37,10 +37,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "kimi-k3", Priority: 100, Kind: "chat"},
|
||||
{ID: "kimi-k2.7-code", Priority: 90, Kind: "chat"},
|
||||
{ID: "kimi-k2.7-code-highspeed", Priority: 85, Kind: "chat"},
|
||||
{ID: "kimi-k2.6", Priority: 70, Kind: "chat"},
|
||||
{ID: "kimi-k3", Kind: "chat"},
|
||||
{ID: "kimi-k2.7-code", Kind: "chat"},
|
||||
{ID: "kimi-k2.7-code-highspeed", Kind: "chat"},
|
||||
{ID: "kimi-k2.6", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -49,11 +49,11 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Pro", Priority: 100, Kind: "chat"},
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "moonshotai/Kimi-K2.6", Priority: 80, Kind: "chat"},
|
||||
{ID: "zai-org/GLM-5.1", Priority: 70, Kind: "chat"},
|
||||
{ID: "Kwai-Kolors/Kolors", Priority: 50, Kind: "image"},
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Pro", Kind: "chat"},
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Flash", Kind: "chat"},
|
||||
{ID: "moonshotai/Kimi-K2.6", Kind: "chat"},
|
||||
{ID: "zai-org/GLM-5.1", Kind: "chat"},
|
||||
{ID: "Kwai-Kolors/Kolors", Kind: "image"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -62,9 +62,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 4,
|
||||
Models: []Model{
|
||||
{ID: "4.0Ultra", Priority: 100, Kind: "chat"},
|
||||
{ID: "generalv3.5", Priority: 80, Kind: "chat"},
|
||||
{ID: "max-32k", Priority: 70, Kind: "chat"},
|
||||
{ID: "4.0Ultra", Kind: "chat"},
|
||||
{ID: "generalv3.5", Kind: "chat"},
|
||||
{ID: "max-32k", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -73,11 +73,11 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "qwen3.8-max", Priority: 100, Kind: "chat"},
|
||||
{ID: "qwen3.8-flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "qwen3.7-plus", Priority: 85, Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Priority: 70, Kind: "chat"},
|
||||
{ID: "kimi-k3", Priority: 70, Kind: "chat"},
|
||||
{ID: "qwen3.8-max", Kind: "chat"},
|
||||
{ID: "qwen3.8-flash", Kind: "chat"},
|
||||
{ID: "qwen3.7-plus", Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Kind: "chat"},
|
||||
{ID: "kimi-k3", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -86,9 +86,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "ernie-5.0-turbo", Priority: 100, Kind: "chat"},
|
||||
{ID: "ernie-5.0", Priority: 90, Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Priority: 70, Kind: "chat"},
|
||||
{ID: "ernie-5.0-turbo", Kind: "chat"},
|
||||
{ID: "ernie-5.0", Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -97,9 +97,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "doubao-seed-2.0-pro", Priority: 100, Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0", Priority: 90, Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0-flash", Priority: 80, Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0-pro", Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0", Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0-flash", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -108,8 +108,8 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "MiniMax-M3", Priority: 100, Kind: "chat"},
|
||||
{ID: "MiniMax-M2.5", Priority: 80, Kind: "chat"},
|
||||
{ID: "MiniMax-M3", Kind: "chat"},
|
||||
{ID: "MiniMax-M2.5", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
// ── 国际主流 ────────────────────────────────────────────────────────────
|
||||
@ -119,9 +119,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
|
||||
{ID: "gpt-5.6-terra", Priority: 90, Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
|
||||
{ID: "gpt-5.6-sol", Kind: "chat"},
|
||||
{ID: "gpt-5.6-terra", Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -130,10 +130,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "anthropic",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "claude-opus-5", Priority: 100, Kind: "chat"},
|
||||
{ID: "claude-fable-5", Priority: 95, Kind: "chat"},
|
||||
{ID: "claude-sonnet-5", Priority: 85, Kind: "chat"},
|
||||
{ID: "claude-haiku-4-5", Priority: 70, Kind: "chat"},
|
||||
{ID: "claude-opus-5", Kind: "chat"},
|
||||
{ID: "claude-fable-5", Kind: "chat"},
|
||||
{ID: "claude-sonnet-5", Kind: "chat"},
|
||||
{ID: "claude-haiku-4-5", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -142,10 +142,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "gemini",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "gemini-3.6-flash", Priority: 100, Kind: "chat"},
|
||||
{ID: "gemini-3.5-flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "gemini-3.5-flash-lite", Priority: 80, Kind: "chat"},
|
||||
{ID: "gemini-3.1-pro", Priority: 85, Kind: "chat"},
|
||||
{ID: "gemini-3.6-flash", Kind: "chat"},
|
||||
{ID: "gemini-3.5-flash", Kind: "chat"},
|
||||
{ID: "gemini-3.5-flash-lite", Kind: "chat"},
|
||||
{ID: "gemini-3.1-pro", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -154,9 +154,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "groq",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "openai/gpt-oss-120b", Priority: 100, Kind: "chat"},
|
||||
{ID: "openai/gpt-oss-20b", Priority: 80, Kind: "chat"},
|
||||
{ID: "groq/compound", Priority: 70, Kind: "chat"},
|
||||
{ID: "openai/gpt-oss-120b", Kind: "chat"},
|
||||
{ID: "openai/gpt-oss-20b", Kind: "chat"},
|
||||
{ID: "groq/compound", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -165,9 +165,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "mistral",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "mistral-medium-latest", Priority: 100, Kind: "chat"},
|
||||
{ID: "mistral-large-latest", Priority: 90, Kind: "chat"},
|
||||
{ID: "codestral-latest", Priority: 85, Kind: "chat"},
|
||||
{ID: "mistral-medium-latest", Kind: "chat"},
|
||||
{ID: "mistral-large-latest", Kind: "chat"},
|
||||
{ID: "codestral-latest", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -180,10 +180,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
"X-Title": "ModelRouter",
|
||||
},
|
||||
Models: []Model{
|
||||
{ID: "openrouter/auto", Priority: 100, Kind: "chat"},
|
||||
{ID: "deepseek/deepseek-v4-flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "anthropic/claude-opus-5", Priority: 85, Kind: "chat"},
|
||||
{ID: "openai/gpt-5.6-sol", Priority: 85, Kind: "chat"},
|
||||
{ID: "openrouter/auto", Kind: "chat"},
|
||||
{ID: "deepseek/deepseek-v4-flash", Kind: "chat"},
|
||||
{ID: "anthropic/claude-opus-5", Kind: "chat"},
|
||||
{ID: "openai/gpt-5.6-sol", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -192,8 +192,8 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "github",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
|
||||
{ID: "gpt-5.6-sol", Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
// ── 本地模型 ────────────────────────────────────────────────────────────
|
||||
@ -204,9 +204,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Endpoint: "/api/chat",
|
||||
MaxConcurrent: 4,
|
||||
Models: []Model{
|
||||
{ID: "qwen3", Priority: 80, Kind: "chat"},
|
||||
{ID: "deepseek-r1", Priority: 80, Kind: "chat"},
|
||||
{ID: "llama3.3", Priority: 70, Kind: "chat"},
|
||||
{ID: "qwen3", Kind: "chat"},
|
||||
{ID: "deepseek-r1", Kind: "chat"},
|
||||
{ID: "llama3.3", Kind: "chat"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
111
internal/core/channels_autoclaim_cover_test.go
Normal file
111
internal/core/channels_autoclaim_cover_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 自动领取的「今天已处理」记账(照参考 coversToday / shouldMarkToday)。
|
||||
//
|
||||
// 真实缺陷(参考 2026-10-02 定位,每天静默漏 100 积分):Qoder 的活动
|
||||
// **每日 10:00(UTC+8)才刷新**,而自动签到在上午跑 —— 上午看到的是**昨天**
|
||||
// 那条 CLAIMED,返回 `already-claimed`。若拿它记账,当天 10 点刷新后就再也
|
||||
// 不去领。
|
||||
//
|
||||
// 修法不靠「给每个渠道配刷新时刻表」(必然漂移),而是让**产出方自己回答**
|
||||
// 「我这条结果覆盖的是不是今天这一轮」(covers_today),记账方只数
|
||||
// 「有几条能证明今天已被处理」。
|
||||
func TestAutoClaimCountsCoversTodayNotKind(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
// ① Qoder 在刷新前必须声明 covers_today=false(否则这条链就白修)
|
||||
out, err := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{
|
||||
"refreshed": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j == nil || j["kind"] == nil {
|
||||
t.Fatalf("探针应返回 kind,实得 %#v", out)
|
||||
}
|
||||
if j["covers_today"] != false {
|
||||
t.Errorf("刷新前必须声明 covers_today=false(这正是漏领的根因),实得 %v", j["covers_today"])
|
||||
}
|
||||
if j["kind"] == "already-claimed" {
|
||||
t.Error("刷新前**绝不能**报 already-claimed(那会被记账成「今天已处理」)")
|
||||
}
|
||||
|
||||
// ② 刷新后:正常报已领,且 covers_today=true
|
||||
out2, _ := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{
|
||||
"refreshed": true,
|
||||
})
|
||||
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j2["covers_today"] != true {
|
||||
t.Errorf("刷新后应声明 covers_today=true,实得 %v", j2["covers_today"])
|
||||
}
|
||||
|
||||
// ③ 记账判据必须**只看 covers_today**,不能用 kind 反推
|
||||
//(`already-claimed` 天然有歧义 —— 可能就是昨天那一轮的痕迹)
|
||||
cases := []struct {
|
||||
name string
|
||||
kind string
|
||||
cov interface{}
|
||||
want bool
|
||||
}{
|
||||
{"刷新前的 already-claimed 不得算已处理", "already-claimed", false, false},
|
||||
{"刷新后的 already-claimed 算已处理", "already-claimed", true, true},
|
||||
{"success 算已处理", "success", true, true},
|
||||
{"covers_today 缺省为 true", "claimed", nil, true},
|
||||
{"inactive 不算已处理", "inactive", true, false},
|
||||
{"failed 不算已处理", "failed", true, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
o, err := ps.Call("channels", "__claim_covers_probe", map[string]interface{}{
|
||||
"kind": c.kind, "covers_today": c.cov,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
oj, _ := o.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if got, _ := oj["covers"].(bool); got != c.want {
|
||||
t.Errorf("%s: 应 %v,实得 %v", c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调度频率:一天只跑一次会让「刷新时刻晚于执行时刻」的平台整天漏领。
|
||||
func TestAutoClaimScheduledMoreThanOnceDaily(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
out, err := ps.Call("channels", "__schedule_probe", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
every, _ := j["auto_claim_every"].(float64)
|
||||
if every <= 0 {
|
||||
t.Fatalf("找不到 auto_claim 的调度间隔,实得 %v", j)
|
||||
}
|
||||
// 必须显著小于一天:活动刷新时刻各平台不同(Qoder 是每日 10:00 UTC+8),
|
||||
// 一天只跑一次就必然有平台漏领。
|
||||
if every >= 86400 {
|
||||
t.Errorf("auto_claim 间隔应远小于一天(否则刷新晚的平台整天漏领),实得 %.0f 秒", every)
|
||||
}
|
||||
if j["first_due_now"] != true {
|
||||
t.Error("auto_claim 应 first_due_now(重启立即补跑,不等一个周期)")
|
||||
}
|
||||
// 已覆盖今天的平台必须靠记账跳过,否则会重复打上游
|
||||
if !strings.Contains(toString(j["has_coverage_key"]), "true") {
|
||||
t.Error("需要「今天已覆盖」的持久化记账键")
|
||||
}
|
||||
}
|
||||
|
||||
func toString(v interface{}) string {
|
||||
if b, ok := v.(bool); ok {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -106,9 +106,18 @@ func TestAutoClaimScheduledWithModelRefresh(t *testing.T) {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// auto_claim 是每日任务,**不该** first_due_now(重启就重领是错的)
|
||||
if ln := lineOf("auto_claim"); strings.Contains(ln, "first_due_now") {
|
||||
t.Errorf("auto_claim 不该 first_due_now(每日任务重启后重领是错的):%s", ln)
|
||||
// auto_claim **现在**也 first_due_now,且间隔远小于一天。
|
||||
//
|
||||
// ★ 这里改过一次,原因值得记下:原判据写的是「auto_claim 是每日任务,
|
||||
// 不该 first_due_now(重启就重领是错的)」—— 那个假设**本身是缺陷的一部分**。
|
||||
// 活动刷新时刻**各平台不同**(Qoder 是每日 10:00 UTC+8),一天只跑一次
|
||||
// 必然有平台整天漏领(参考实测:每天静默漏 100 积分)。修法有两半:
|
||||
// ① 频率提高 + first_due_now(任何时刻重启都能补上);
|
||||
// ② 「今天已覆盖」靠 covers_today 记账跳过(**这才是防重领的正确机制**,
|
||||
// 而不是靠"一天只跑一次"来碰巧避开)。
|
||||
// 只做 ② 不做 ① 仍会漏领;只做 ① 不做 ② 会重复打上游。
|
||||
if ln := lineOf("auto_claim"); !strings.Contains(ln, "first_due_now = true") {
|
||||
t.Errorf("auto_claim 应 first_due_now(否则重启后当天漏领):%s", ln)
|
||||
}
|
||||
// 两个自动任务必须 first_due_now(否则重启要等满一轮才自愈)
|
||||
for _, fn := range []string{"refresh_models", "refresh_creds"} {
|
||||
|
||||
85
internal/core/channels_buddy_catalog_filter_test.go
Normal file
85
internal/core/channels_buddy_catalog_filter_test.go
Normal file
@ -0,0 +1,85 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 远端模型目录混着「不可调用」的条目,必须过滤掉。
|
||||
//
|
||||
// 真实缺陷(生产实测):buddy 源声明了 24 个模型,其中
|
||||
//
|
||||
// nes-gf / codewise-completions / codewise-rewrite / codewise-jump /
|
||||
// codewise-default-model-v2 / hunyuan-image-alpha / auto
|
||||
//
|
||||
// 全部调不通(`glm-5.1` 也是 404)。用户选中就报错,而错误文案指向**模型**,
|
||||
// 极易被误判成"这个模型坏了"。
|
||||
//
|
||||
// 参考实现(buddy.ts 的 isChatModel / isAutoSelectAlias)记录了**四类**判据,
|
||||
// 且都是实测依据:
|
||||
//
|
||||
// ① 自动选择别名 auto/default(**只按字面量**,前缀匹配会误伤 default-model)
|
||||
// ② 补全/NES 专用前缀 nes- / completion- / codewise-,或 supportsExtra=true
|
||||
// (参考实测 codewise-default-model-v2 → `code 11102 service info not found`)
|
||||
// ③ maxOutputTokens >0 且 ≤256(补全用途;对话模型普遍 ≥24000)
|
||||
// ④ 带 text-to-image 标签(如 hunyuan-image-alpha)
|
||||
func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 判据一:四类规则必须都在源码里(防止有人"精简"掉其中一条)
|
||||
for _, rule := range []string{
|
||||
`id == "auto" or id == "default"`, // ①
|
||||
`id:sub(1, 4) == "nes-"`, // ②
|
||||
`id:sub(1, 11) == "completion-"`, // ②
|
||||
`id:sub(1, 9) == "codewise-"`, // ②
|
||||
`m.supportsExtra == true`, // ②
|
||||
`mo > 0 and mo <= 256`, // ③
|
||||
`t == "text-to-image"`, // ④
|
||||
} {
|
||||
if !strings.Contains(s, rule) {
|
||||
t.Errorf("buddy 目录过滤缺少规则:%s\n"+
|
||||
"(这四类都是参考用真实请求验证过的「列出来就会让用户报错」的条目,"+
|
||||
"删任何一条都会把不可调用模型放回选择器)", rule)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据二:`default-model` / `fast-model` 必须**不在**被过滤的形态里。
|
||||
// 参考注释明确警告:只过滤 auto/default 两个字面量,不做前缀匹配 ——
|
||||
// 前缀会误伤这两个国际版别名(它们被 craft agent 引用,是官方推荐入口)。
|
||||
if strings.Contains(s, `id:sub(1, 7) == "default"`) {
|
||||
t.Error("buddy 过滤把 default 做成了**前缀**匹配 ⇒ 会误伤 default-model(参考点名警告)")
|
||||
}
|
||||
t.Log("✓ buddy 目录过滤四类规则齐备,且未误伤 default-model 类别名")
|
||||
}
|
||||
|
||||
// ★ 已实测**不可调用**的模型必须也从目录里排除 —— 包括 agentRef 例外路径。
|
||||
//
|
||||
// 真实缺陷:`hy4-preview-x` 被服务端 agents 引用,于是 agentRef 例外把它放回
|
||||
// 目录;而它对上游**恒回 11102**(`model service info not found`,加/不加
|
||||
// reasoning_effort 都一样 ⇒ 与 effort 无关,是后端未开放)。
|
||||
// 用户选中就 400,错误文案指向模型 ⇒ 极易误判"模型坏了"。
|
||||
//
|
||||
// 参考用**白名单重建**(product.ts 的兜底表里没有它)达到同样效果。
|
||||
func TestBuddyCatalogDropsKnownUncallable(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
if !strings.Contains(s, "local NOT_CALLABLE = {") {
|
||||
t.Fatal("必须有已实测不可调用的排除表(等价于参考的白名单)")
|
||||
}
|
||||
if !strings.Contains(s, `["hy4-preview-x"] = true`) {
|
||||
t.Error("hy4-preview-x 实测恒 11102,必须排除")
|
||||
}
|
||||
// 闸门必须生效在**所有**入口 —— 尤其是 agentRef 例外之后
|
||||
if !strings.Contains(s, "if NOT_CALLABLE[id] then return false end") {
|
||||
t.Error("NOT_CALLABLE 闸门必须在 buddyIsChatModel 内(覆盖 models 与 agentRef 两条路径)")
|
||||
}
|
||||
t.Log("✓ 不可调用模型已排除,且 agentRef 例外也过闸")
|
||||
}
|
||||
235
internal/core/channels_clear_credentialless_test.go
Normal file
235
internal/core/channels_clear_credentialless_test.go
Normal file
@ -0,0 +1,235 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 无凭据账号的批量清理:**只删没有凭据的**,有凭据的一律不动。
|
||||
//
|
||||
// 场景(真实发生):`account_add` 允许不带凭据创建账号(设计如此 —— 先建占位、
|
||||
// 登录补齐),于是登录没走完时留下"只有元数据、永远不可用"的账号,占着账号计数,
|
||||
// 让"这个平台到底能不能用"难以判断。实测生产 13 个账号里只有 1 个真可用
|
||||
// (codearts 10 + trae 2 全是占位)。
|
||||
//
|
||||
// 判据看的是**清理后的真实池状态**,而不是"函数被调用过"。
|
||||
func TestAccountClearCredentiallessOnlyRemovesEmptyOnes(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
|
||||
// ① 默认 dry_run:必须**不删**任何东西
|
||||
pre, err := h.ps.Call("channels", "account_clear_credentialless", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preJ := jsonOf(t, pre)
|
||||
if preJ["dry_run"] != true {
|
||||
t.Fatalf("默认必须 dry_run(破坏性操作不能默认执行),实得 %v", preJ["dry_run"])
|
||||
}
|
||||
// 夹具放了 4 个 codearts(3 空壳 + 1 有凭据)
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 4 {
|
||||
t.Fatalf("dry_run 不该删任何账号,codearts 仍有 %d 个(want 4)", got)
|
||||
}
|
||||
// 不传 platform ⇒ 全部平台:codearts 3 空壳 + trae 2 空壳 = 5
|
||||
if wd, _ := preJ["would_delete"].(float64); int(wd) != 5 {
|
||||
t.Fatalf("预演应报告将删 5 个(全平台),实得 %v", preJ["would_delete"])
|
||||
}
|
||||
if kc, _ := preJ["kept_with_credential"].(float64); int(kc) != 1 {
|
||||
t.Fatalf("应报告保留 1 个有凭据账号,实得 %v", preJ["kept_with_credential"])
|
||||
}
|
||||
|
||||
// ② 真执行:删无凭据的,**保留有凭据的**
|
||||
if _, err := h.ps.Call("channels", "account_clear_credentialless",
|
||||
map[string]interface{}{"dry_run": false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||||
t.Fatalf("应只剩 1 个有凭据账号,实得 %d", got)
|
||||
}
|
||||
// ★ 关键:留下的必须是**有凭据的那个**
|
||||
left := accountIDs(t, h, "codearts")
|
||||
if len(left) != 1 || left[0] != "withcred" {
|
||||
t.Fatalf("留下的必须是有凭据的账号 withcred,实得 %v", left)
|
||||
}
|
||||
// 有凭据的账号其凭据也必须仍在(不能被误删)
|
||||
if !h.hasCredential("withcred") {
|
||||
t.Fatal("有凭据账号的凭据被误删了")
|
||||
}
|
||||
t.Logf("✓ 删 3 保 1,且有凭据账号的凭据完好")
|
||||
}
|
||||
|
||||
// 指定平台时只动那个平台,别的不碰。
|
||||
func TestAccountClearCredentiallessScopedToPlatform(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
|
||||
if _, err := h.ps.Call("channels", "account_clear_credentialless",
|
||||
map[string]interface{}{"platform": "codearts", "dry_run": false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// codearts 被清(剩 1),trae 的 2 个占位**不该被动**
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||||
t.Fatalf("codearts 应剩 1,实得 %d", got)
|
||||
}
|
||||
if got := len(accountIDs(t, h, "trae")); got != 2 {
|
||||
t.Fatalf("指定 platform=codearts 时不该动 trae(应仍 2),实得 %d", got)
|
||||
}
|
||||
t.Log("✓ 平台范围隔离正确")
|
||||
}
|
||||
|
||||
// 未知平台必须报错,不能静默当成"全部平台"(否则会误删别的平台)。
|
||||
func TestAccountClearCredentiallessRejectsUnknownPlatform(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
res, err := h.ps.Call("channels", "account_clear_credentialless",
|
||||
map[string]interface{}{"platform": "no-such-platform", "dry_run": false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(t, res)
|
||||
if j["error"] == nil {
|
||||
t.Fatalf("未知平台必须报错(否则会误删全部平台),实得 %v", j)
|
||||
}
|
||||
// 且什么都没删
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 4 {
|
||||
t.Fatalf("报错时不该删任何东西,codearts 实得 %d(want 4)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type accountCleanupHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newAccountCleanupHarness(t *testing.T) *accountCleanupHarness {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
h := &accountCleanupHarness{t: t, ps: ps}
|
||||
// 3 个无凭据占位 + 1 个有凭据账号(codearts);另 2 个 trae 占位
|
||||
for _, id := range []string{"empty1", "empty2", "empty3"} {
|
||||
h.add("codearts", id, "")
|
||||
}
|
||||
h.add("codearts", "withcred", `{"access_key_id":"AK","secret_access_key":"SK"}`)
|
||||
h.add("trae", "tempty1", "")
|
||||
h.add("trae", "tempty2", "")
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *accountCleanupHarness) add(platform, id, cred string) {
|
||||
h.t.Helper()
|
||||
args := map[string]interface{}{"platform": platform, "account_id": id}
|
||||
if cred != "" {
|
||||
args["credential"] = cred
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "account_add", args); err != nil {
|
||||
h.t.Fatalf("account_add(%s/%s): %v", platform, id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *accountCleanupHarness) hasCredential(accountID string) bool {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "account_list",
|
||||
map[string]interface{}{"platform": "codearts"})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
for _, a := range accountsOf(h.t, res) {
|
||||
if a["id"] == accountID {
|
||||
return a["has_credential"] == true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// jsonOf 取插件调用返回的 {json:{...}} 里那层 json。
|
||||
func jsonOf(t *testing.T, got interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("返回值不是对象: %#v", got)
|
||||
}
|
||||
j, ok := m["json"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("返回值缺 json 层: %#v", m)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
// accountsOf 取 account_list 返回的账号数组。
|
||||
func accountsOf(t *testing.T, got interface{}) []map[string]interface{} {
|
||||
t.Helper()
|
||||
j := jsonOf(t, got)
|
||||
arr, _ := j["accounts"].([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
if m, ok := e.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// accountIDs 列某平台的账号 id。
|
||||
func accountIDs(t *testing.T, h *accountCleanupHarness, platform string) []string {
|
||||
t.Helper()
|
||||
res, err := h.ps.Call("channels", "account_list",
|
||||
map[string]interface{}{"platform": platform})
|
||||
if err != nil {
|
||||
t.Fatalf("account_list(%s): %v", platform, err)
|
||||
}
|
||||
var out []string
|
||||
for _, a := range accountsOf(t, res) {
|
||||
if id, ok := a["id"].(string); ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ★ 字符串 body(真实 HTTP 路径)+ 不传 platform 时,dry_run 必须被尊重。
|
||||
//
|
||||
// 反例(实测踩到,我自己写的):照其它 handler 写
|
||||
//
|
||||
// `if args.platform == nil then args = req end`
|
||||
//
|
||||
// —— 而"不传 platform"在本函数里是**合法用法**(清全部平台),于是 args 被换成
|
||||
// HandlerRequest,body 里解出来的 dry_run 就丢了 ⇒ 传 dry_run=false 却被当成
|
||||
// 未传,静默只预演。**破坏性操作里最糟的形态**:操作者以为执行了、实际没执行。
|
||||
//
|
||||
// 夹具必须用**字符串 body**(我第一版传 map,走不到这条路径,所以没咬住)。
|
||||
func TestAccountClearCredentiallessRespectsDryRunViaStringBody(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
|
||||
// 不带 platform + dry_run=false,且 body 是**字符串**(HTTP 真实形态)
|
||||
res, err := h.ps.InvokeHandler("channels", "account_clear_credentialless",
|
||||
&lua.HandlerRequest{Method: "POST", Body: `{"dry_run":false}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != 200 {
|
||||
t.Fatalf("status=%d body=%v", res.Status, res.Body)
|
||||
}
|
||||
j, _ := res.JSON.(map[string]interface{})
|
||||
if j["dry_run"] == true {
|
||||
t.Fatalf("字符串 body 里传了 dry_run=false 却被当成未传(回退吃掉 body): %v", j)
|
||||
}
|
||||
if j["deleted"] == nil {
|
||||
t.Fatalf("dry_run=false 时必须真的执行(应有 deleted 字段): %v", j)
|
||||
}
|
||||
// 全平台的 5 个空壳都被删(codearts 3 + trae 2)
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||||
t.Fatalf("codearts 应剩 1(有凭据那个),实得 %d", got)
|
||||
}
|
||||
if got := len(accountIDs(t, h, "trae")); got != 0 {
|
||||
t.Fatalf("trae 的 2 个空壳应被删,实得 %d", got)
|
||||
}
|
||||
t.Logf("✓ 字符串 body 且无 platform 时 dry_run=false 生效: deleted=%v", j["deleted"])
|
||||
}
|
||||
91
internal/core/channels_cline_adapter_test.go
Normal file
91
internal/core/channels_cline_adapter_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Cline 的 429/402 话术与冷却时长(照参考 cline-rate-limit.ts)。
|
||||
//
|
||||
// ★ 这是**比参考更严重**的形态:参考退到"1 小时快照兜底",用户看到
|
||||
// 「永远 60 分钟」(等满一小时再试仍被拒,且每失败一次就从当下重新计一小时
|
||||
// ⇒ 无限循环)。而我们的内核把 429 当普通限流、用**固定 30 秒**冷却 ——
|
||||
// 那会变成**每 30 秒重试一次、永远失败**,在猛敲上游。
|
||||
//
|
||||
// 抓包真值(参考实测,no-retry: true —— 没有任何 retry-after 头):
|
||||
//
|
||||
// HTTP 429
|
||||
// {"error":{"code":"INFERENCE_CAP_ERROR",
|
||||
// "message":"Error 429: Daily free limit reached on model
|
||||
// deepseek/deepseek-v4-flash. Try again in 19h 39m"}}
|
||||
func TestClineAdapterParsesProseWait(t *testing.T) {
|
||||
c, _ := poolCore(t)
|
||||
vm := c.VM()
|
||||
|
||||
// ① 当日免费额度用尽:必须被归一成**内核已认的中立短语**
|
||||
//(否则内核按 30 秒短冷却处理 ⇒ 每 30 秒猛敲上游)
|
||||
body := `{"error":{"code":"INFERENCE_CAP_ERROR","message":` +
|
||||
`"Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 19h 39m"}}`
|
||||
out, ok, err := vm.TransformError("channels-cline", 429, body)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("transform_error: err=%v ok=%v", err, ok)
|
||||
}
|
||||
if !strings.Contains(out, "额度已用完") {
|
||||
t.Errorf("必须归一成内核已认的「额度已用完」(否则不会被当成额度耗尽处理),实得 %q", out)
|
||||
}
|
||||
// 真实等待时长必须出现在话术里(19h39m ≈ 19 小时)
|
||||
if !strings.Contains(out, "19") {
|
||||
t.Errorf("必须把真实等待时长写进话术(用户才知道要等多久),实得 %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "重试无用") {
|
||||
t.Errorf("当日额度用尽要明确告知重试无用(否则用户以为等一会就好),实得 %q", out)
|
||||
}
|
||||
// ⚠️ 绝不能出现参考踩过的「1 小时」误导
|
||||
if strings.Contains(out, "1 小时") && !strings.Contains(out, "19") {
|
||||
t.Errorf("不得退回快照式的「1 小时」误导,实得 %q", out)
|
||||
}
|
||||
|
||||
// ② 时长 token 必须**累加**(`1h 30m` = 90 分钟),不能只取第一个 ——
|
||||
// 通过真实的 transform_error 路径验证(不自己重算解析器)。
|
||||
acc, _, _ := vm.TransformError("channels-cline", 429,
|
||||
`{"error":{"message":"Daily free limit reached. Try again in 1h 30m"}}`)
|
||||
if !strings.Contains(acc, "1 小时 30 分") {
|
||||
t.Errorf("`1h 30m` 必须累加成人话时长(1 小时 30 分),实得 %q", acc)
|
||||
}
|
||||
// 秒级也要能解析(否则秒级限流会退化成通用文案)
|
||||
secs, _, _ := vm.TransformError("channels-cline", 429,
|
||||
`{"error":{"message":"Rate limited. Try again in 45s"}}`)
|
||||
if !strings.Contains(secs, "分") && !strings.Contains(secs, "秒") {
|
||||
t.Errorf("秒级时长应被解析并说出,实得 %q", secs)
|
||||
}
|
||||
|
||||
// ③ 402:等待不会恢复,必须说清楚要充值(不能只说"限流")
|
||||
out402, _, _ := vm.TransformError("channels-cline", 402,
|
||||
`{"error":{"message":"Insufficient credits"}}`)
|
||||
if !strings.Contains(out402, "充值") {
|
||||
t.Errorf("402 应明确提示需充值(等待无用),实得 %q", out402)
|
||||
}
|
||||
|
||||
// ④ 普通 429 带时长:如实说,但不谎报成额度耗尽
|
||||
out429, _, _ := vm.TransformError("channels-cline", 429,
|
||||
`{"error":{"message":"Rate limited. Try again in 30s"}}`)
|
||||
if strings.Contains(out429, "额度已用完") {
|
||||
t.Errorf("普通限流不得谎报成额度耗尽(那会让内核长时间冷却一个健康源),实得 %q", out429)
|
||||
}
|
||||
if !strings.Contains(out429, "限流") {
|
||||
t.Errorf("普通 429 应说成限流,实得 %q", out429)
|
||||
}
|
||||
|
||||
// ⑤ 多种外壳都要认(`{error:"…"}` 字符串 / `{msg}` / 非 JSON 纯文本)
|
||||
//(只认一种会在别的形态下静默丢掉整条信息)
|
||||
for _, shell := range []string{
|
||||
`{"error":"Daily free limit reached. Try again in 1h"}`,
|
||||
`{"msg":"Daily free limit reached. Try again in 1h"}`,
|
||||
`Daily free limit reached. Try again in 1h`,
|
||||
} {
|
||||
o, _, _ := vm.TransformError("channels-cline", 429, shell)
|
||||
if !strings.Contains(o, "额度已用完") {
|
||||
t.Errorf("外壳 %q 未被识别为额度耗尽,实得 %q", shell, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -151,7 +151,9 @@ func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
|
||||
}
|
||||
aid, _ := j["account_id"].(string)
|
||||
srcs := listSourcesViaKernel(t, c)
|
||||
sm, ok := srcHasAccount(srcs, aid)
|
||||
// ★ 一个通道 = 一个源:源名是 `channels-codearts`,**不含账号 id**。
|
||||
// 按账号 id 找会永远失败 —— 这正是"加账号了但测试说没出源"的假故障。
|
||||
sm, ok := srcHasAccount(srcs, "codearts")
|
||||
if !ok {
|
||||
names := []string{}
|
||||
for _, x := range srcs {
|
||||
@ -159,7 +161,10 @@ func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
t.Fatalf("codearts 账号 %q 应已同步源;现有源: %v", aid, names)
|
||||
t.Fatalf("codearts 账号 %q 应已同步出通道源;现有源: %v", aid, names)
|
||||
}
|
||||
if nm, _ := sm["name"].(string); nm != "channels-codearts" {
|
||||
t.Errorf("通道源名应为 channels-codearts,实得 %q(账号 id=%s)", nm, aid)
|
||||
}
|
||||
if ad, _ := sm["adapter"].(string); ad != "codearts" {
|
||||
t.Errorf("源应引用插件装的 codearts 适配器,实得 %q(签名不会生效)", ad)
|
||||
|
||||
174
internal/core/channels_codearts_catalog_test.go
Normal file
174
internal/core/channels_codearts_catalog_test.go
Normal file
@ -0,0 +1,174 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ codearts 的模型目录必须从**两个**签名端点拉取并合并。
|
||||
//
|
||||
// 真实缺口(本轮发现):codearts 有 10 个可用账号,但目录里只有硬编码的
|
||||
// 1 个模型 "CodeArts-Pangu"。参考实现(fetchCodeArtsRemoteModels)打的是:
|
||||
//
|
||||
// ① opengw gateway/config → result.models(benefit 模型)
|
||||
// ② snap-access /v1/model/builtin → builtinModels(常规模型)
|
||||
//
|
||||
// 两者都**必须 AK/SK 签名**,而我们的 refreshRemoteModels 此前只支持 Bearer
|
||||
// ⇒ 这两个端点从来没有被打过。
|
||||
//
|
||||
// 判据看**两个端点的真实命中**(各自拉到自己的字段名),而不是"目录非空"
|
||||
// —— 后者只要兜底表在就恒真。
|
||||
type codeartsCatalogHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
mu sync.Mutex
|
||||
hits map[int]int // 端点序号 → 命中次数
|
||||
gateway string
|
||||
builtin string
|
||||
authSeen []string
|
||||
}
|
||||
|
||||
func newCodeartsCatalogHarness(t *testing.T) *codeartsCatalogHarness {
|
||||
t.Helper()
|
||||
h := &codeartsCatalogHarness{t: t, hits: map[int]int{}}
|
||||
|
||||
mk := func(idx int, body string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Lock()
|
||||
h.hits[idx]++
|
||||
// ★ 必须看到 AK/SK 签名的三个特征头,否则等于没签名
|
||||
authSeen := []string{
|
||||
r.Header.Get("X-Sdk-Date"),
|
||||
r.Header.Get("X-Sdk-Content-Sha256"),
|
||||
r.Header.Get("Authorization"),
|
||||
}
|
||||
h.authSeen = append(h.authSeen, strings.Join(authSeen, "|"))
|
||||
h.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
}
|
||||
// ① gateway:benefit 模型在 result.models
|
||||
gw := mk(0, `{"result":{"models":[{"model_id":"glm-5.3-flash"},{"model_id":"deepseek-v4-flash"}]}}`)
|
||||
// ② snap builtin:常规模型在 builtinModels(**对象**,键为序号)
|
||||
bi := mk(1, `{"count":2,"builtinModels":{"0":{"model_id":"GLM-5.2"},"1":{"model_id":"openpangu-2.0-pro"}}}`)
|
||||
t.Cleanup(gw.Close)
|
||||
t.Cleanup(bi.Close)
|
||||
h.gateway, h.builtin = gw.URL, bi.URL
|
||||
|
||||
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)
|
||||
}
|
||||
h.ps = ps
|
||||
|
||||
// 把两个端点都指到假上游(顺序与平台配置一致)
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "codearts",
|
||||
"models_remote": map[string]interface{}{
|
||||
"endpoint0": h.gateway,
|
||||
"endpoint1": h.builtin,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
// 塞一个 AK/SK 账号(签名分支需要凭据,否则会走 "无可用 AK/SK 凭据")
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "codearts",
|
||||
"id": "cat-test",
|
||||
"credential": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST",` +
|
||||
`"security_token":"STTEST","user_name":"cat"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestCodeArtsCatalogPullsBothSignedEndpoints(t *testing.T) {
|
||||
h := newCodeartsCatalogHarness(t)
|
||||
|
||||
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil {
|
||||
t.Fatalf("model_refresh: %v", err)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
hits := map[int]int{}
|
||||
for k, v := range h.hits {
|
||||
hits[k] = v
|
||||
}
|
||||
authSeen := append([]string(nil), h.authSeen...)
|
||||
h.mu.Unlock()
|
||||
|
||||
// ① 两个端点都必须被命中(只打一个是真实缺口)
|
||||
if hits[0] == 0 {
|
||||
t.Fatal("gateway/config 端点从未被调用 —— benefit 模型(glm-5.3-flash 等)永远拿不到")
|
||||
}
|
||||
if hits[1] == 0 {
|
||||
t.Fatal("snap /v1/model/builtin 端点从未被调用 —— 常规模型(GLM-5.2 / openpangu)永远拿不到")
|
||||
}
|
||||
// ② 每个请求都必须带 AK/SK 签名特征头(Bearer 打不通这两个端点)
|
||||
for i, seen := range authSeen {
|
||||
parts := strings.Split(seen, "|")
|
||||
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
|
||||
t.Fatalf("第 %d 个请求缺签名头(X-Sdk-Date/Content-Sha256/Authorization):%q", i+1, seen)
|
||||
}
|
||||
if !strings.Contains(parts[2], "SDK-HMAC-SHA256") {
|
||||
t.Fatalf("Authorization 不是华为 SDK-HMAC-SHA256 形态,实得 %q", parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 合并结果必须同时含两个端点的模型(字段名不同:model_id,两边都要认)
|
||||
got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := modelIDs(t, got)
|
||||
joined := strings.Join(ids, ",")
|
||||
for _, want := range []string{"glm-5.3-flash", "GLM-5.2", "openpangu-2.0-pro"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("合并目录缺 %s(两个端点必须**合并**而非只用其一):%v", want, ids)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 两端点各命中 %d/%d 次,合并得到 %d 个模型", hits[0], hits[1], len(ids))
|
||||
}
|
||||
|
||||
// 兜底表在远端失败时必须仍然可用(不能因为加了目录就让平台变空)。
|
||||
func TestCodeArtsCatalogKeepsFallbackOnFailure(t *testing.T) {
|
||||
h := newCodeartsCatalogHarness(t)
|
||||
// 让两个端点都 500
|
||||
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
}))
|
||||
defer dead.Close()
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "codearts",
|
||||
"models_remote": map[string]interface{}{
|
||||
"endpoint0": dead.URL, "endpoint1": dead.URL,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil {
|
||||
t.Fatalf("远端失败不该让 model_refresh 报错: %v", err)
|
||||
}
|
||||
got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := modelIDs(t, got)
|
||||
if len(ids) == 0 {
|
||||
t.Fatal("远端全失败时目录变空了 —— 兜底表必须补位(宁可留旧目录也别让平台变空)")
|
||||
}
|
||||
t.Logf("✓ 远端失败时兜底目录仍有 %d 个模型: %v", len(ids), ids)
|
||||
}
|
||||
184
internal/core/channels_codearts_pem_test.go
Normal file
184
internal/core/channels_codearts_pem_test.go
Normal file
@ -0,0 +1,184 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// codearts JWK→PEM 重建判据。
|
||||
//
|
||||
// 背景(真实缺陷,2026-10-19 用户报「华为云源用不了」):
|
||||
//
|
||||
// 华为云 STS 的 security_token 只活 1 小时,续期(refresh_token 换新)需要
|
||||
// DPoP 请求签名。签名走 `codeartsDpopSign(key, ...)`,它要求 `key.pem` 是
|
||||
// **PEM 字符串**(内部用 openssl 命令行签)。而持久化的凭据里**只有**
|
||||
// `dpop_private_key_jwk`(私钥的 JWK)—— PEM 是登录时的临时文件,没保存。
|
||||
//
|
||||
// 于是续期必须先从 JWK 重建 PEM。这条判据钉住"重建出来的 PEM 真能被 openssl
|
||||
// 接受并签出有效的 ES256 JWS",而不是"函数返回了非空字符串"。
|
||||
//
|
||||
// 为什么必须真签名:DER 结构错、长度字段错、base64 换行错,都会得到
|
||||
// **非空但无效**的 PEM。只断言非空等于什么都没验。
|
||||
func TestCodeartsRebuildsPemFromJwkAndSigns(t *testing.T) {
|
||||
// 一个结构合法的 P-256 私钥 JWK(生产凭据的真实形状:三个字段都是
|
||||
// 32 字节 base64url)。用固定值而非随机生成,是为了让失败可复现。
|
||||
jwk := map[string]any{
|
||||
"crv": "P-256",
|
||||
"kty": "EC",
|
||||
"d": "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U",
|
||||
"x": "r2HUYfG7tMIWY9KOpXLvvMWE_7dnHM9K7p1K7-LSwl4",
|
||||
"y": "0-weI5p_EXZXFfGx4a76X_0oamkLOJ5AA4MOMObL8MM",
|
||||
}
|
||||
|
||||
_, ps := poolCore(t)
|
||||
out, err := ps.Call("channels", "__codearts_jwk_pem_probe", map[string]any{"jwk": jwk})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
wrapped, _ := out.(map[string]any)
|
||||
inner, _ := wrapped["json"].(map[string]any)
|
||||
if inner == nil {
|
||||
t.Fatalf("探针返回缺 json 包装: %#v", out)
|
||||
}
|
||||
if e, _ := inner["error"].(string); e != "" {
|
||||
t.Fatalf("JWK→PEM 重建失败: %s", e)
|
||||
}
|
||||
|
||||
// ① PEM 形状必须是 openssl 认的 SEC1 EC PRIVATE KEY
|
||||
pem, _ := inner["pem"].(string)
|
||||
if !strings.HasPrefix(pem, "-----BEGIN EC PRIVATE KEY-----") {
|
||||
t.Fatalf("PEM 头部不对: %q", firstLine(pem))
|
||||
}
|
||||
if !strings.Contains(pem, "-----END EC PRIVATE KEY-----") {
|
||||
t.Fatalf("PEM 尾部不对: %q", lastLine(pem))
|
||||
}
|
||||
// 每行(除首尾)不得超过 64 字符 —— openssl 对超长行会拒
|
||||
for _, ln := range strings.Split(strings.TrimSpace(pem), "\n") {
|
||||
if strings.HasPrefix(ln, "-----") {
|
||||
continue
|
||||
}
|
||||
if len(ln) > 64 {
|
||||
t.Fatalf("PEM base64 行长度 %d > 64,openssl 会拒绝", len(ln))
|
||||
}
|
||||
}
|
||||
|
||||
// ② 最关键:真签名必须成功(DER/长度/换行任一错都在这里暴露)
|
||||
if ok, _ := inner["signed"].(bool); !ok {
|
||||
t.Fatalf("PEM 重建后 DPoP 签名失败: %v", inner["sign_error"])
|
||||
}
|
||||
|
||||
// ③ 签名产物必须是三段式 JWS(header.payload.signature)
|
||||
// 探针不直接回 JWS,但 signed=true 已表示 codeartsDpopSign 走完了
|
||||
// 整个 openssl 签名 + DER→raw 转换链路(任一环节失败都会返回 nil)。
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func lastLine(s string) string {
|
||||
s = strings.TrimRight(s, "\n")
|
||||
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
|
||||
return s[i+1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestCodeartsKeyFromJwkRejectsBadJwk 钉住"坏 JWK 要响亮报错"。
|
||||
//
|
||||
// 为什么需要:JWK 字段长度不对(例如被截断、或根本不是 P-256)时,
|
||||
// 若静默构造出一个畸形 PEM,openssl 只会说「签名失败」—— 运维无从判断
|
||||
// 是凭据坏了还是代码坏了。必须在这里给可诊断的消息。
|
||||
func TestCodeartsKeyFromJwkRejectsBadJwk(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
jwk map[string]any
|
||||
}{
|
||||
{"d 太短", map[string]any{
|
||||
"kty": "EC", "crv": "P-256",
|
||||
"d": "AAAA", "x": "r2HUYfG7tMIWY9KOpXLvvMWE_7dnHM9K7p1K7-LSwl4",
|
||||
"y": "0-weI5p_EXZXFfGx4a76X_0oamkLOJ5AA4MOMObL8MM"}},
|
||||
{"缺 x", map[string]any{
|
||||
"kty": "EC", "crv": "P-256",
|
||||
"d": "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
out, err := ps.Call("channels", "__codearts_jwk_pem_probe", map[string]any{"jwk": c.jwk})
|
||||
if err != nil {
|
||||
return // 调用层报错也算响亮
|
||||
}
|
||||
wrapped, _ := out.(map[string]any)
|
||||
inner, _ := wrapped["json"].(map[string]any)
|
||||
if inner == nil {
|
||||
return
|
||||
}
|
||||
if e, _ := inner["error"].(string); e == "" {
|
||||
if ok, _ := inner["signed"].(bool); ok {
|
||||
t.Fatalf("坏 JWK 竟然签出了名 —— 应当报错")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 让 goimports/json 相关导入不被误判未使用(此文件仅用 json 做类型断言辅助)。
|
||||
var _ = json.Marshal
|
||||
var _ = os.Getenv
|
||||
|
||||
// TestB64urlDecodeHandlesLongInput 钉住 base64url 解码的**长输入**正确性。
|
||||
//
|
||||
// 这个判据补的是一个真实的测漏:我第一版的 b64urlDecode 在输出字节后
|
||||
// **没有清除已用掉的高位**(缺 `buf = buf % (2^nbits)`)。用 43 字符的
|
||||
// JWK 字段(32 字节)测时**结果完全正确** —— 于是那条 PEM 判据在变异后
|
||||
// 依然全绿,什么也没验到。
|
||||
//
|
||||
// 只有长输入才暴露:累积缓冲不清高位时,double 精度在约 11 个 6bit 组后
|
||||
// 耗尽,math.floor 给出越界值,string.char 直接抛 invalid value。
|
||||
// 实测:把 `string.rep(43字符JWK, 10)` 喂进去,无掩码版本当场崩。
|
||||
//
|
||||
// 所以这里必须用**长串**(远超过 11 组),断言解出的字节数与内容都正确。
|
||||
func TestB64urlDecodeHandlesLongInput(t *testing.T) {
|
||||
// 96 个字符 → 72 字节。远超溢出临界点(11 组 = 66 bit)。
|
||||
const in = "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7UBpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U"
|
||||
// 期望值由 Go 标准库算出(不用手写的 Lua 结果做锚,否则自证)
|
||||
want := base64.RawURLEncoding.EncodeToString
|
||||
|
||||
_, ps := poolCore(t)
|
||||
out, err := ps.Call("channels", "__b64_probe", map[string]any{"s": in})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败(很可能是 string.char 越界 = 缺高位掩码): %v", err)
|
||||
}
|
||||
wrapped, _ := out.(map[string]any)
|
||||
inner, _ := wrapped["json"].(map[string]any)
|
||||
if inner == nil {
|
||||
t.Fatalf("探针返回缺 json 包装: %#v", out)
|
||||
}
|
||||
got, _ := inner["hex"].(string)
|
||||
n, _ := inner["len"].(float64)
|
||||
|
||||
// 期望长度由 Go 自己算(不要手推 len/4*3:输入不一定是 4 的倍数)
|
||||
dec, derr := base64.RawURLEncoding.DecodeString(in)
|
||||
if derr != nil {
|
||||
t.Fatal(derr)
|
||||
}
|
||||
if int(n) != len(dec) {
|
||||
t.Fatalf("解码长度 = %v,期望 %d", n, len(dec))
|
||||
}
|
||||
// 与 Go 解码结果逐字节比对
|
||||
wantHex := ""
|
||||
for _, b := range dec {
|
||||
wantHex += string("0123456789abcdef"[b>>4]) + string("0123456789abcdef"[b&0xf])
|
||||
}
|
||||
if got != wantHex {
|
||||
t.Fatalf("解码内容不符(长输入下高位未清除):\n got=%s\nwant=%s", got, wantHex)
|
||||
}
|
||||
_ = want
|
||||
}
|
||||
@ -37,6 +37,9 @@ func TestDeadModelGoneJudgement(t *testing.T) {
|
||||
{"The model does not exist", true, "does not exist"},
|
||||
{"error: model_not_found", true, "独占一句话(行尾)"},
|
||||
{"model_not_found.", true, "后面是句末标点"},
|
||||
{"model is not registered", true, "CodeArts/InferHub 下架模型的真实文案"},
|
||||
{"InferHub.002002009.404: The model is not registered, please request other model", true,
|
||||
"CodeArts 完整 404 文案"},
|
||||
// ── 阴性:看着像但**不是**下架 ──
|
||||
{"error_class=model_not_found, retry allowed", false,
|
||||
"字段名后面还有内容(初版误判下架,实测教训)"},
|
||||
|
||||
149
internal/core/channels_endpoint_test.go
Normal file
149
internal/core/channels_endpoint_test.go
Normal file
@ -0,0 +1,149 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 渠道端点判据 —— 钉住"base_url 不吞 endpoint 路径"这条铁律。
|
||||
//
|
||||
// 为什么需要这个测试
|
||||
// ------------------
|
||||
// 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL)。把路径**同时**
|
||||
// 写进 base_url 和 endpoint,拼出来就是重复路径。生产实测两次撞上同一族缺陷:
|
||||
//
|
||||
// zcode base_url=.../api/v1/zcode-plan/anthropic
|
||||
// endpoint=/api/v1/zcode-plan/anthropic/v1/messages
|
||||
// ⇒ .../anthropic/api/v1/zcode-plan/anthropic/v1/messages → 404
|
||||
//
|
||||
// minimax base_url=https://agent.minimax.cn + adapter=openai(默认 endpoint
|
||||
// /chat/completions)⇒ 打到 Web 前端首页,404「简单指令,无限可能」
|
||||
//
|
||||
// 两者报错都是 404,但根因不同(路径重复 / 缺路径段)。单条断言咬不住两种,
|
||||
// 所以下面分三条断言。
|
||||
|
||||
// platformDef 从**真实加载的插件**里读一个平台定义。
|
||||
//
|
||||
// ★ 必须走真实加载而不是正则解析源码:PLATFORMS 是 Lua 表,源里有注释、
|
||||
// 条件拼装、以及文件末尾的绑定循环(refresh_fn / claim_fn / headers_fn 都在
|
||||
// 那里挂)。拿正则在源文本上判断"有没有 refresh 段"会被注释里的字样骗 ——
|
||||
// 这个坑我上一轮差点踩进去。探针返回的是插件运行时看到的真相。
|
||||
func platformDef(t *testing.T, id string) map[string]any {
|
||||
t.Helper()
|
||||
_, ps := poolCore(t)
|
||||
out, err := ps.Call("channels", "__platform_def_probe", map[string]any{"id": id})
|
||||
if err != nil {
|
||||
t.Fatalf("__platform_def_probe(%s) 失败: %v", id, err)
|
||||
}
|
||||
// Plugins.Call 返回 {json:{...}, status:...} 包装(历史约定)。
|
||||
wrapped, ok := out.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("探针返回不是表: %T", out)
|
||||
}
|
||||
if e, ok := wrapped["error"].(string); ok && e != "" {
|
||||
t.Fatalf("%s: %s", id, e)
|
||||
}
|
||||
inner, ok := wrapped["json"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("探针返回缺 json 包装: %#v", wrapped)
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
// TestChannelBaseURLDoesNotSwallowEndpoint 是最核心的一条:
|
||||
// base_url 里**不得**出现 endpoint 也有的路径段。
|
||||
//
|
||||
// 判别性:zcode 缺陷时 base_url 尾段是 "anthropic",endpoint 里也含
|
||||
// "/anthropic/" ⇒ 命中。修好后 base_url 只到 host,永不命中。
|
||||
func TestChannelBaseURLDoesNotSwallowEndpoint(t *testing.T) {
|
||||
for _, id := range []string{"zcode", "minimax", "codearts", "trae", "lobsterai", "buddy"} {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
p := platformDef(t, id)
|
||||
base, _ := p["base_url"].(string)
|
||||
ep, _ := p["endpoint"].(string)
|
||||
base = strings.TrimRight(base, "/")
|
||||
if base == "" {
|
||||
t.Fatalf("%s 没有 base_url", id)
|
||||
}
|
||||
|
||||
// 从 base_url 里取出路径部分(host 之后),逐段看是否也出现在 endpoint 里。
|
||||
pathPart := ""
|
||||
if i := strings.Index(base, "://"); i >= 0 {
|
||||
if j := strings.Index(base[i+3:], "/"); j >= 0 {
|
||||
pathPart = base[i+3+j:]
|
||||
}
|
||||
}
|
||||
if pathPart == "" {
|
||||
return // 只到 host,天然不会重复
|
||||
}
|
||||
for _, seg := range strings.Split(strings.Trim(pathPart, "/"), "/") {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(ep, "/"+seg+"/") || strings.HasSuffix(ep, "/"+seg) {
|
||||
t.Fatalf("%s: base_url 已含路径段 %q(base_url=%s),"+
|
||||
"endpoint 里又出现(endpoint=%s)⇒ 内核拼 base_url+endpoint 时路径重复",
|
||||
id, seg, base, ep)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinimaxUsesAnthropicProtocolAndRealInferPath 钉住 minimax 的两层缺陷。
|
||||
//
|
||||
// 参考 channel-pack/src/minimax-product.ts:
|
||||
//
|
||||
// MINIMAX_INFER_PATH = '/mavis/api/v1/llm/v1/messages' ← Anthropic Messages
|
||||
// apiHost = 'https://agent.minimax.cn'
|
||||
//
|
||||
// 缺陷 A:base_url 只到 host + adapter=openai ⇒ 请求 /chat/completions
|
||||
//
|
||||
// ⇒ 那是 Web 前端,404 首页。实测(2026-10-19):
|
||||
// /mavis/api/v1/llm/v1/messages → 401 invalid access token(路径对)
|
||||
// 裸 /chat/completions → 404 page not found
|
||||
// /mavis/api/v1/chat/completions → 404
|
||||
//
|
||||
// 缺陷 B:即使路径改对,openai 适配器的**请求体形状**也不对
|
||||
//
|
||||
// (minimax 推理端点是 Anthropic 协议)。
|
||||
func TestMinimaxUsesAnthropicProtocolAndRealInferPath(t *testing.T) {
|
||||
p := platformDef(t, "minimax")
|
||||
ep, _ := p["endpoint"].(string)
|
||||
adapter, _ := p["adapter"].(string)
|
||||
|
||||
if !strings.HasSuffix(ep, "/mavis/api/v1/llm/v1/messages") {
|
||||
t.Errorf("minimax endpoint = %q,应为 /mavis/api/v1/llm/v1/messages"+
|
||||
"(照参考 MINIMAX_INFER_PATH);否则请求会打到 Web 前端", ep)
|
||||
}
|
||||
if adapter != "anthropic" {
|
||||
t.Errorf("minimax adapter = %q,应为 anthropic —— 推理端点是 Anthropic "+
|
||||
"Messages 协议,用 openai 适配器会把请求体拼错", adapter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodeartsDeclaresRefreshHook 钉住"声明了可续期就必须声明怎么续"。
|
||||
//
|
||||
// 真实缺陷(2026-10-19 用户报「华为云源用不了」):
|
||||
// codearts 的 security_token 只活 1 小时;account_refresh 走 refreshCredential(),
|
||||
// 它要求 plat.refresh.url 或 plat.refresh_fn 存在。当时只声明了 refreshable=true
|
||||
// (那是 accountView 里硬编码的期望),**没声明怎么续** ⇒ 恒出
|
||||
// 「该平台未声明续期方式」,自动续期链空转,日志反复 `模型刷新失败 HTTP 400`。
|
||||
//
|
||||
// 为什么不能只看 refreshable:那是"这个账号的凭据类型可续",与"平台实现了
|
||||
// 续期"是两件事。前者为真而后者缺失,正是这个 bug 的形状。
|
||||
func TestCodeartsDeclaresRefreshHook(t *testing.T) {
|
||||
p := platformDef(t, "codearts")
|
||||
hasHook, _ := p["has_refresh_fn"].(bool)
|
||||
hasDecl, _ := p["has_refresh_decl"].(bool)
|
||||
if !hasHook && !hasDecl {
|
||||
t.Fatal("codearts 既无 refresh_fn 也无 refresh.url —— " +
|
||||
"account_refresh 会恒返回「该平台未声明续期方式」," +
|
||||
"而 STS token 只有 1 小时寿命,过期后源的每个请求都会 400")
|
||||
}
|
||||
// 华为云 STS 要 DPoP 头,声明式 style 拿不到私钥 ⇒ 必须是钩子
|
||||
if !hasHook {
|
||||
t.Error("codearts 只有声明式 refresh.url,但华为云 STS 需要 DPoP 头" +
|
||||
"(jwk 在凭据里),声明式 style 发不出来 ⇒ 必须用 refresh_fn")
|
||||
}
|
||||
}
|
||||
76
internal/core/channels_endpoint_url_test.go
Normal file
76
internal/core/channels_endpoint_url_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 源的 chat URL = `base_url + endpoint`(内核 provider.go ChatURL)。
|
||||
//
|
||||
// 真实缺陷(生产实测):多个渠道平台的 `base_url` 里塞了**完整路径**,
|
||||
// 而适配器默认 endpoint 又是 "/chat/completions" ⇒ 实际请求变成
|
||||
//
|
||||
// https://copilot.tencent.com/v2/chat/completions/chat/completions
|
||||
//
|
||||
// ⇒ 上游回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞,不是模型错误)。
|
||||
//
|
||||
// 受影响平台 8 个:buddy / cline / lobsterai / opencode / qoder / loomy /
|
||||
// raccoon(zcode 当时已用 endpoint 覆盖,形态正确)。
|
||||
//
|
||||
// ★ 为什么这个缺陷能活很久:**模型名被多个源服务**。
|
||||
//
|
||||
// `deepseek-v4.1-flash` 同时由 4 个源提供(alittokenplan/vela/oin/channels-buddy…)
|
||||
// ⇒ 网关在 buddy 源失败后会**继续尝试下一个源**并成功 ⇒ 用户看到"能回话",
|
||||
// 于是"buddy 通道是好的"这个结论**完全没有证据**。
|
||||
// 而 `hy3` 只由 buddy 一个源提供 ⇒ 一调就暴露真因。
|
||||
// ⇒ 判据必须断言**源的 URL 形态**(结构不变量),不能靠"某个模型能调通"。
|
||||
//
|
||||
// 判据:base_url 不得自带任何 chat 路径段(它只应是 host[+协议公共前缀]),
|
||||
// 路径必须在 `endpoint` 里。
|
||||
func TestChannelSourceBaseURLHasNoChatPath(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 只在平台定义区里找 base_url(避免误判注释/文档里的示例)
|
||||
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
|
||||
// 已知适配器会把这些路径各追加一次;出现即说明 base_url 侵占了 endpoint 的地盘
|
||||
banned := []string{"/chat/completions", "/v1/messages", "/generateContent", "/api/chat"}
|
||||
|
||||
found := 0
|
||||
for _, m := range declRe.FindAllStringSubmatch(s, -1) {
|
||||
url := m[1]
|
||||
found++
|
||||
for _, b := range banned {
|
||||
if strings.Contains(url, b) {
|
||||
t.Errorf("base_url 侵占了 endpoint 的路径:%s\n"+
|
||||
" 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),"+
|
||||
"适配器默认 endpoint=\"/chat/completions\" ⇒ 实际会请求 %s%s\n"+
|
||||
" 修法:base_url 只留 host,把 `%s` 移到该平台的 `endpoint = \"...\"`",
|
||||
url, url, b, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Fatal("没扫到任何 base_url 声明(判据失效)")
|
||||
}
|
||||
t.Logf("✓ %d 个 base_url 均不含 chat 路径段", found)
|
||||
}
|
||||
|
||||
// ★ 反过来也要咬:说明该缺陷**确实已被修好**、而不是判据太松。
|
||||
// 用一段**故意的坏数据**验证判据的判别力(变异测试的静态等价物)。
|
||||
func TestChannelBaseURLJudgeBitesOnBadInput(t *testing.T) {
|
||||
bad := ` base_url = "https://copilot.tencent.com/v2/chat/completions",`
|
||||
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
|
||||
m := declRe.FindStringSubmatch(bad)
|
||||
if m == nil {
|
||||
t.Fatal("判据的正则匹配不到坏数据 ⇒ 判据无判别力(形同虚设)")
|
||||
}
|
||||
if !strings.Contains(m[1], "/chat/completions") {
|
||||
t.Fatal("坏数据未被识别 ⇒ 判据无判别力")
|
||||
}
|
||||
t.Log("✓ 判据对坏数据有判别力")
|
||||
}
|
||||
67
internal/core/channels_free_flag_test.go
Normal file
67
internal/core/channels_free_flag_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// `isFree` 的判定链(照参考 cline-models.ts)。
|
||||
//
|
||||
// ★ 修的是一个与注释自相矛盾的缺陷:cline 的 3 个模型来自远端 **free 数组**,
|
||||
// 却声明在 `models` 里 ⇒ 被标成收费。用户看不到哪个可以不花钱用。
|
||||
//
|
||||
// 参考的判定链(权威在前):
|
||||
//
|
||||
// ① 远端 free 数组(`recommended-models` 是**唯一权威**的免费集合;
|
||||
// `/api/v1/models` 的 460 个 id 里根本没有 cline-free/*)
|
||||
// ② 命名约定前缀 `cline-free/*`(远端未响应时的兜底)
|
||||
// ③ 声明的 models_free
|
||||
// ④ 能力表的 cost(models.dev)
|
||||
//
|
||||
// ⚠️ 免费是 **OR** 语义:多个来源任一确认免费就够。写成"后者覆盖前者"会让
|
||||
// 「远端说免费、能力表拉不到」退回收费 —— 正是这类静默降级。
|
||||
func TestIsFreeUsesAuthorityChain(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
// cline 的免费模型必须被标成免费(远端/free_prefix/声明 任一命中即可)
|
||||
out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "cline"})
|
||||
if err != nil {
|
||||
t.Fatalf("model_list: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
got := map[string]bool{}
|
||||
for _, it := range asArrInterface(j["models"]) {
|
||||
if mm, ok := it.(map[string]interface{}); ok {
|
||||
if id, ok := mm["id"].(string); ok {
|
||||
if f, ok := mm["isFree"].(bool); ok {
|
||||
got[id] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `cline-free/*` 前缀命中 ⇒ 必须免费(命名约定兜底)
|
||||
if !got["cline-free/mimo-v2.6-flash"] {
|
||||
t.Errorf("cline-free/* 前缀的模型必须免费(命名约定兜底),实得 %v", got)
|
||||
}
|
||||
// stealth/space-bunny-alpha 来自远端 free 数组,也必须免费
|
||||
if !got["stealth/space-bunny-alpha"] {
|
||||
t.Errorf("远端 free 数组里的模型必须免费,实得 %v", got)
|
||||
}
|
||||
|
||||
// 命名约定必须生效(远端未响应时唯一的兜底依据)
|
||||
probe, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
|
||||
"platform": "cline", "model_id": "cline-free/some-new-model",
|
||||
})
|
||||
pj, _ := probe.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if pj["is_free"] != true {
|
||||
t.Error("远端未响应时,`cline-free/*` 前缀必须能判出免费(这是兜底依据)")
|
||||
}
|
||||
// 反向:收费通道的同名模型**不得**被判免费
|
||||
//(参考点名:免费模型是独立 id,`cline-free/x` 与 `x` 是两个不同模型)
|
||||
probe2, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
|
||||
"platform": "cline", "model_id": "deepseek/deepseek-v4.1-flash",
|
||||
})
|
||||
pj2, _ := probe2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if pj2["is_free"] == true {
|
||||
t.Error("收费通道的模型不得因为前缀规则被判成免费")
|
||||
}
|
||||
}
|
||||
@ -36,10 +36,12 @@ func TestChannelSourcesAreSerialWithThrottle(t *testing.T) {
|
||||
aid, _ := j["account_id"].(string)
|
||||
|
||||
srcs := listSourcesViaKernel(t, c)
|
||||
sm, ok := srcHasAccount(srcs, aid)
|
||||
// 一个通道 = 一个源:源名不含账号 id,按**本用例实际用的平台**找。
|
||||
sm, ok := srcHasAccount(srcs, "buddy")
|
||||
if !ok {
|
||||
t.Fatal("账号未出源")
|
||||
t.Fatalf("账号未出通道源,实际源: %v", srcs)
|
||||
}
|
||||
_ = aid
|
||||
// ① 串行
|
||||
if mc, _ := toFloat(sm["max_concurrent"]); mc != 1 {
|
||||
t.Errorf("源必须 max_concurrent=1(并发发起必然浪费一个配额),实得 %v", sm["max_concurrent"])
|
||||
|
||||
105
internal/core/channels_legacy_source_cleanup_test.go
Normal file
105
internal/core/channels_legacy_source_cleanup_test.go
Normal file
@ -0,0 +1,105 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 升级后**旧版按账号拆的源必须被清掉**,否则同一个渠道的模型出现两遍。
|
||||
//
|
||||
// 真实事故(2026-10-19 用户报「同一个模型两个名字 / 源列表一堆不可读的东西」):
|
||||
// 生产 config.yaml 里 6 对源并存 ——
|
||||
//
|
||||
// channels-trae-b8dd6b3df30e + channels-trae
|
||||
// channels-buddy-buddy-main + channels-buddy
|
||||
// …(trae/lobsterai/minimax/buddy/codearts/zcode 各一对)
|
||||
//
|
||||
// 新设计是「一通道一源」(`channels-<provider>`),换号靠同名 add_source 覆盖
|
||||
// api_key。旧版是「一账号一源」(`channels-<provider>-<账号id>`)。升级时必须把
|
||||
// 旧形态清掉,否则:
|
||||
//
|
||||
// ① 模型列表里同一模型出现多遍,只能靠 12 位 hex 区分(人读不出来)
|
||||
// ② 旧源指向已不该用的账号,却可能因为注册更早而**先**被调度到
|
||||
//
|
||||
// 根因是形态假设错了:内核 `list_sources` 返回**裸数组**(见 kernel_actions.go
|
||||
// 的 actionListSources,直接 `return out`),而迁移代码按 `{sources:[...]}`
|
||||
// 信封去取 ⇒ 恒为 nil ⇒ 循环一次都不跑 ⇒ 静默什么都没清。
|
||||
//
|
||||
// ★ 本判据的关键是**用裸数组形态喂数据**(也就是内核真实返回的形状)。
|
||||
//
|
||||
// 如果按信封造夹具,即使代码是错的两边也"对上了",测不出任何东西 ——
|
||||
// 这正是这个 bug 逃过全部既有判据的原因。
|
||||
func TestChannelsLegacyPerAccountSourceIsCleaned(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.Fatalf("加载插件: %v", err)
|
||||
}
|
||||
if err := ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false); err != nil {
|
||||
t.Fatalf("授权: %v", err)
|
||||
}
|
||||
|
||||
// 造存量:一个旧形态源(带账号 hex)+ 一个别的通道的旧源(不该被误删)。
|
||||
mk := func(name string) {
|
||||
if _, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
|
||||
"name": name,
|
||||
"base_url": "https://example.invalid",
|
||||
"adapter": "openai",
|
||||
"api_key": "sk-x",
|
||||
"models": []interface{}{"m1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("造源 %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
mk("channels-trae-b8dd6b3df30e") // 旧形态:trae 的某个账号
|
||||
mk("channels-trae-deadbeef0123") // 旧形态:trae 的另一个账号
|
||||
mk("channels-buddy-buddy-main") // 别的通道 —— 不该被 trae 的同步碰到
|
||||
mk("unrelated-source") // 与 channels 无关,绝不能被删
|
||||
|
||||
// 放一个**真实**的 trae 账号(走 account_add,不是探针):没有账号就无从
|
||||
// 写源,也就走不到「写前先清旧源」那一步。account_add 本身也会同步一次源,
|
||||
// 但那正是要测的路径。
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "trae", "account_id": "b8dd6b3df30e",
|
||||
"credential": `{"access_token":"T","refresh_token":"R","nickname":"n"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("造账号: %v", err)
|
||||
}
|
||||
|
||||
// ★ 只走 `syncAccountSource` → `syncProviderSource` 这条路(迁移清理就在这里)。
|
||||
// 刻意**不用** reap_orphan_sources:它自己另有一套"形态 1 旧源"清理逻辑,
|
||||
// 会把这里要测的迁移代码完全遮住 —— 用它做判据时,即使迁移代码是坏的
|
||||
// (信封假设的 bug 仍在)判据也会绿,等于没测。
|
||||
if _, err := ps.Call("channels", "__resync_sources_probe", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
}); err != nil {
|
||||
t.Fatalf("resync: %v", err)
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, s := range c.Sources() {
|
||||
names[s.Name] = true
|
||||
}
|
||||
|
||||
for _, legacy := range []string{"channels-trae-b8dd6b3df30e", "channels-trae-deadbeef0123"} {
|
||||
if names[legacy] {
|
||||
t.Errorf("旧形态源 %s 没被清掉 —— 同一渠道的模型会在列表里出现两遍,"+
|
||||
"只能靠 12 位 hex 区分,用户读不出哪个是哪个", legacy)
|
||||
}
|
||||
}
|
||||
if !names["channels-trae"] {
|
||||
t.Error("新形态源 channels-trae 没建起来")
|
||||
}
|
||||
if names["unrelated-source"] != true {
|
||||
t.Error("清理误伤了无关源 unrelated-source")
|
||||
}
|
||||
// ★ 这里才是真的越界判据:trae 的同步**只**该清 `channels-trae-` 前缀的旧源。
|
||||
// 如果前缀匹配写成贪婪/过宽(例如 `channels-(.+)-`),buddy 的源会被误删。
|
||||
if !names["channels-buddy-buddy-main"] {
|
||||
t.Error("清理越界:trae 的同步删掉了 buddy 的源(前缀匹配过宽)")
|
||||
}
|
||||
}
|
||||
198
internal/core/channels_log_panel_test.go
Normal file
198
internal/core/channels_log_panel_test.go
Normal file
@ -0,0 +1,198 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 插件日志面板:级别 + 归属 + 同文折叠(否则用户动作被 96% 的周期噪音淹没)。
|
||||
//
|
||||
// 真实问题(生产实测):50 条日志里 **48 条(96%)** 是三个定时任务
|
||||
// (refresh_models 2h / auto_claim 2h / refresh_creds 0.5h,全为 first_due_now)
|
||||
// 的重复回音,唯一模式只有 8 种 ⇒ 约 2 小时就把 50 条环形缓冲冲满一轮,
|
||||
// **真正的用户动作**(如「账号删除」)被挤到看不见。
|
||||
//
|
||||
// 三个结构性修法,每个都在源码里可断言:
|
||||
//
|
||||
// ① logEvent 支持 level(task/warn/error),前端默认折叠 task
|
||||
// ② logEvent 支持 platform,前端按选中渠道过滤(修"点 buddy 看到 trae"的归属错误)
|
||||
// ③ 同文折叠:紧邻同文不新增条目,只 n=n+1
|
||||
func TestChannelsLogEventCarriesLevelAndPlatform(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 签名必须收 level 与 platform(三参)
|
||||
if !regexp.MustCompile(`local function logEvent\(msg, level, platform\)`).MatchString(s) {
|
||||
t.Error("logEvent 必须接收 (msg, level, platform) —— 否则级别与渠道归属都无从表达")
|
||||
}
|
||||
// ② 写入的条目必须带上这两个字段
|
||||
for _, field := range []string{"level = level or", "platform = platform"} {
|
||||
if !strings.Contains(s, field) {
|
||||
t.Errorf("日志条目必须写入 %s", field)
|
||||
}
|
||||
}
|
||||
// ③ 同文折叠:紧邻同文时只加计数
|
||||
if !strings.Contains(s, "top.n = (top.n or 1) + 1") {
|
||||
t.Error("必须实现紧邻同文折叠(否则周期任务的重复回音会把 50 条缓冲冲满)")
|
||||
}
|
||||
// ④ 折叠必须只针对**紧邻**条目 —— 否则一条每小时出现的消息会不断跳到最前,
|
||||
// 时间线失去意义。断言比较对象是 log[1](栈顶)而不是任意条目。
|
||||
if !strings.Contains(s, "local top = log[1]") {
|
||||
t.Error("折叠必须只比较**紧邻**的上一条(log[1]);对任意条目折叠会让时间线错乱")
|
||||
}
|
||||
t.Log("✓ logEvent 具备 level / platform / 紧邻同文折叠")
|
||||
}
|
||||
|
||||
// ★ 周期任务的**常规**结果必须是 task 级(默认折叠),异常必须是 warn/error。
|
||||
//
|
||||
// 判据咬的是"这些具体调用点被降级了",而不是"存在 task 这个词" ——
|
||||
// 后者只要有一处就为真,测不出"某个噪音源忘了降级"。
|
||||
func TestChannelsPeriodicTaskLogsAreDowngraded(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 这些是「每次跑都成功」的常规回音 —— 必须 task 级
|
||||
routine := []string{
|
||||
`logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task")`,
|
||||
`logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task")`,
|
||||
`logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task")`,
|
||||
}
|
||||
for _, r := range routine {
|
||||
if !strings.Contains(s, r) {
|
||||
t.Errorf("周期任务的常规结果必须降级为 task(否则会淹没用户动作):\n %s", r)
|
||||
}
|
||||
}
|
||||
// 这些是异常 —— 必须 warn(不能被一起折叠掉)
|
||||
for _, pat := range []string{
|
||||
`logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")`,
|
||||
`logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn")`,
|
||||
`logEvent("凭据续期失败 " .. #failed .. " 个:`,
|
||||
} {
|
||||
if !strings.Contains(s, pat) {
|
||||
t.Errorf("异常必须保持 warn(不能被当常规噪音折叠):%s", pat)
|
||||
}
|
||||
}
|
||||
// 「已同步源」是周期重同步的常规回音 —— 降级为 task 且带平台归属。
|
||||
// 文案在"一个通道=一个源"重构后多带了账号 id(便于排查"用的是哪个号"),
|
||||
// 故这里只钉住"task 级 + 带 provider"这两个**语义**要件,不钉住整句字面量 ——
|
||||
// 钉字面量会让每次改文案都要改测试,而这条测试要防的是"级别升回 info"。
|
||||
if !strings.Contains(s, `logEvent("已同步源 " .. name`) {
|
||||
t.Error("「已同步源」这条回音丢了(改名了?)")
|
||||
}
|
||||
// 匹配"已同步源 ... "task", provider)"——跨行(文案带账号 id 后跨了两行),
|
||||
// 故用 (?s) 让 . 能匹配换行。
|
||||
if !regexp.MustCompile(`(?s)logEvent\("已同步源 " \.\. name.*?"task", provider\)`).MatchString(s) {
|
||||
t.Error("「已同步源」是周期重同步的常规回音,必须 task 级且带平台归属(provider)")
|
||||
}
|
||||
t.Log("✓ 周期噪音已降级,异常保持 warn")
|
||||
}
|
||||
|
||||
// ★ 前端的三个过滤维度必须都在(级别门槛 / 例行折叠 / 按渠道过滤)。
|
||||
func TestChannelsLogPanelHasFilters(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 日志卡片必须**独立于渠道详情**(C 的核心:修正归属)
|
||||
// 判据:日志卡片的 DOM 不是 ch-detail 的子元素。
|
||||
// 做法:取 ch-detail 开始到 ch-log-card 之间的片段,要求中间出现过
|
||||
// ch-detail 的闭合(即两者是兄弟而非父子)。
|
||||
iDetail := strings.Index(s, `id="ch-detail"`)
|
||||
iLogCard := strings.Index(s, `id="ch-log-card"`)
|
||||
if iDetail < 0 || iLogCard < 0 {
|
||||
t.Fatal("找不到 ch-detail 或 ch-log-card(判据需更新)")
|
||||
}
|
||||
if iLogCard < iDetail {
|
||||
t.Fatal("运行日志卡片应在渠道详情**之后**(作为兄弟节点)")
|
||||
}
|
||||
// 统计 ch-detail 打开到日志卡片之间未闭合的 div 层数
|
||||
seg := s[iDetail:iLogCard]
|
||||
depth := 0
|
||||
for _, m := range regexp.MustCompile(`</?div`).FindAllString(seg, -1) {
|
||||
if strings.HasPrefix(m, "</") {
|
||||
depth--
|
||||
} else {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
if depth > 0 {
|
||||
t.Errorf("运行日志卡片仍嵌在渠道详情内部(depth=%d)⇒ 点开某渠道会看到别的渠道的日志(归属错误)", depth)
|
||||
}
|
||||
|
||||
// ② 三个过滤维度
|
||||
for _, id := range []string{`id="ch-log-verbose"`, `id="ch-log-level"`} {
|
||||
if !strings.Contains(s, id) {
|
||||
t.Errorf("缺少日志视图控件 %s", id)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(s, "function chLogRender()") {
|
||||
t.Error("必须实现 chLogRender()(否则过滤逻辑无处落地)")
|
||||
}
|
||||
// ③ 按渠道过滤 + 切渠道重渲染
|
||||
if !strings.Contains(s, "if (selPlat) return e.platform === selPlat;") {
|
||||
t.Error("必须按当前选中渠道过滤日志(否则归属错误以另一种形式回来)")
|
||||
}
|
||||
if !strings.Contains(s, "chLogRender();\n };\n") {
|
||||
t.Error("chSelect 切换渠道后必须重渲染日志")
|
||||
}
|
||||
t.Log("✓ 日志面板独立成卡片,三个过滤维度齐备")
|
||||
}
|
||||
|
||||
// ★ 日志截断必须按**字符**切,不能按字节 —— 否则中文被切出无效 UTF-8。
|
||||
//
|
||||
// 真实缺陷(生产实测,v2.0.33 截图里可见):日志尾显示成
|
||||
//
|
||||
// `能力增强不<E5BCBA>`
|
||||
//
|
||||
// 根因:`tostring(cerr):sub(1, 120)` —— Lua 的 sub 按**字节**切,而中文占 3 字节
|
||||
// ⇒ 切在字符中间产生替换字符(U+FFFD)。一句本来完整的诊断看起来像数据损坏。
|
||||
func TestLogTruncationIsUTF8Safe(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 必须存在按字符截断的助手
|
||||
if !strings.Contains(s, "plugin.utf8Clip = function(s, maxBytes)") {
|
||||
t.Fatal("必须实现 plugin.utf8Clip(按字符截断)")
|
||||
}
|
||||
// ② 中文文案上的截断不得再用裸 sub()
|
||||
for _, bad := range []string{
|
||||
`tostring(cres):sub(1, 120)`,
|
||||
`tostring(cerr):sub(1, 120)`,
|
||||
`table.concat(failed, "; "):sub(1, 300)`,
|
||||
} {
|
||||
if strings.Contains(s, bad) {
|
||||
t.Errorf("中文文案的截断不能用裸 sub()(按字节切会产生 U+FFFD):%s", bad)
|
||||
}
|
||||
}
|
||||
// ③ 三处必须已换成 utf8Clip
|
||||
for _, want := range []string{
|
||||
"plugin.utf8Clip(cres, 120)",
|
||||
"plugin.utf8Clip(cerr, 120)",
|
||||
`plugin.utf8Clip(table.concat(failed, "; "), 300)`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("缺少 UTF-8 安全截断调用:%s", want)
|
||||
}
|
||||
}
|
||||
// ④ 助手必须**只在超长时**才截断(否则正常文案会被莫名缩短)
|
||||
if !strings.Contains(s, "if #s <= maxBytes then return s end") {
|
||||
t.Error("utf8Clip 必须对未超长的输入原样返回")
|
||||
}
|
||||
// ⑤ 回退逻辑必须处理续字节(10xxxxxx)与孤立首字节
|
||||
if !strings.Contains(s, "elseif b >= 0xC0 then") {
|
||||
t.Error("utf8Clip 必须回退孤立的 UTF-8 首字节(否则末尾仍是半个字符)")
|
||||
}
|
||||
t.Log("✓ 截断已 UTF-8 安全")
|
||||
}
|
||||
73
internal/core/channels_lua_locals_test.go
Normal file
73
internal/core/channels_lua_locals_test.go
Normal file
@ -0,0 +1,73 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ channels.lua 必须留出顶层 local 余量 —— 否则**下一次顶层改动就编译失败**。
|
||||
//
|
||||
// 实测事实(2026-10-16):该文件的主 chunk 曾**顶到 Lua 上限 200**,
|
||||
// 往顶部加**哪怕 1 个**顶层 local 就报
|
||||
//
|
||||
// main function has more than 200 local variables
|
||||
//
|
||||
// 而这炸的不是某个功能 —— 是**整个渠道插件**(插件加载失败 ⇒ 全渠道不可用)。
|
||||
//
|
||||
// ⚠️ 判据为什么用 luajit 而不是 luac:
|
||||
//
|
||||
// 内核链接的是 libluajit-5.1.so.2;而 `luac`(Lua 5.4)对同一文件给出
|
||||
// **不同的**结论。用错工具会得到假绿。
|
||||
//
|
||||
// ⚠️ 判据为什么是「注入 N 个 local 仍能编译」而不是「数一遍 local」:
|
||||
//
|
||||
// 主 chunk 的 local 数受**作用域嵌套**影响,静态计数不可靠(同一个文件
|
||||
// 用不同脚本数出 228/232/248 三个数)。而"能不能再加"是**决定性**的,
|
||||
// 也正是我们真正需要的性质。
|
||||
func TestChannelsLuaHasLocalHeadroom(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := exec.LookPath("luajit"); err != nil {
|
||||
t.Skip("luajit 不在 PATH(判据需要与内核相同的 Lua 实现)")
|
||||
}
|
||||
|
||||
const headroom = 10
|
||||
for _, n := range []int{1, headroom} {
|
||||
probe := strings.Repeat("local __headroom = 1\n", n) + src
|
||||
cmd := exec.Command("luajit", "-e", "local f,e=loadfile(arg[1]); if not f then io.stderr:write(tostring(e)) os.exit(1) end", "/dev/stdin")
|
||||
cmd.Stdin = strings.NewReader(probe)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("★ 顶层 local 余量不足:加 %d 个 local 就无法编译 ⇒ "+
|
||||
"下一次顶层改动会**整插件加载失败**(全渠道不可用)\n %s\n"+
|
||||
"修法:把纯常量收进 plugin.K 表(挂表字段不占 local 槽位)", n, strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
t.Logf("✓ channels.lua 仍有 ≥%d 个顶层 local 余量", headroom)
|
||||
}
|
||||
|
||||
// 配对判据:常量必须确实在 plugin.K 里(防止有人把余量"省"出来的同时又
|
||||
// 把常量改回裸 local —— 那会让下一个改动的余量再次归零)。
|
||||
func TestChannelsConstantsLiveInPluginK(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
if !strings.Contains(s, "plugin.K = {") {
|
||||
t.Fatal("必须有 plugin.K 常量表(它是 local 余量的来源)")
|
||||
}
|
||||
// 抽样:这些曾占用 local 槽位的纯常量必须已挂表
|
||||
for _, n := range []string{"STORE_NAME", "SESSION_TTL", "B64_ALPHABET", "MODELS_DEV_URL"} {
|
||||
if !regexp.MustCompile(`(?m)^\s+` + n + ` = `).MatchString(s) {
|
||||
t.Errorf("%s 应定义在 plugin.K 表内", n)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^local ` + n + ` = `).MatchString(s) {
|
||||
t.Errorf("%s 又变回裸 local 了(会吃掉 local 余量)", n)
|
||||
}
|
||||
}
|
||||
t.Log("✓ 纯常量已集中在 plugin.K")
|
||||
}
|
||||
82
internal/core/channels_orphan_callback_test.go
Normal file
82
internal/core/channels_orphan_callback_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 插件加载时必须清掉上一进程留下的孤儿回调文件。
|
||||
//
|
||||
// 真实事故:生产 `/etc/llmsproxy/plugins/` 攒了 **18 个 callback-*.port**
|
||||
// (对应的 .json 早已消失)。它们永远不会被清,因为:
|
||||
//
|
||||
// · loopbacks 追踪表只活在内存(plugin.state.loopbacks);
|
||||
// · 进程被强杀时 on_unload 没机会跑;
|
||||
// · 重启后内存表是空的 ⇒ reapLoopback 根本不知道它们存在。
|
||||
//
|
||||
// 判据看的是**目录的真实状态**(孤儿必须消失、活跃会话的文件不能被误删),
|
||||
// 而不是"某函数被调用过"。
|
||||
func TestPluginLoadSweepsOrphanCallbackFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
|
||||
// 摆上一轮的残留(模拟上一进程被强杀后的现场)
|
||||
orphans := []string{
|
||||
filepath.Join(pluginDir, "callback-deadbeef0001.port"),
|
||||
filepath.Join(pluginDir, "callback-deadbeef0001.json"),
|
||||
filepath.Join(pluginDir, "callback-deadbeef0002.port"),
|
||||
}
|
||||
for _, f := range orphans {
|
||||
if err := os.WriteFile(f, []byte("12345"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 一份**不属于**回调的文件:必须不被误删(否则清扫过头会毁掉账号池)
|
||||
keep := filepath.Join(pluginDir, ".channels-store.json")
|
||||
if err := os.WriteFile(keep, []byte(`{"accounts":{}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 加载插件 = 走真实启动路径
|
||||
c := kernelTestCore(t)
|
||||
defer c.Close()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := c.Plugins()
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 顶层清扫代码在加载期就跑了;给 popen 的 rm 一点时间落盘
|
||||
for i := 0; i < 50; i++ {
|
||||
left := 0
|
||||
for _, f := range orphans {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
left++
|
||||
}
|
||||
}
|
||||
if left == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, f := range orphans {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
t.Fatalf("孤儿回调文件未被清扫: %s —— 它会永久留在磁盘上"+
|
||||
"(真实事故:生产攒了 18 个)", filepath.Base(f))
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(keep); err != nil {
|
||||
t.Fatalf("清扫过头了:%s 被误删(账号池/索引绝不能碰)", filepath.Base(keep))
|
||||
}
|
||||
t.Log("✓ 孤儿回调文件被清,非回调文件保留")
|
||||
}
|
||||
|
||||
var _ = lua.Plugins{}
|
||||
169
internal/core/channels_orphan_source_test.go
Normal file
169
internal/core/channels_orphan_source_test.go
Normal file
@ -0,0 +1,169 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 孤儿源必须能被识别、且**只删该删的**。
|
||||
//
|
||||
// 真实事故(2026-10-17 生产实测):`channels-buddy-585b335b64cf` 的账号早已删除,
|
||||
// 源却仍在 config.yaml 里对外服务 —— 独占服务 hy4-preview-x(404),
|
||||
// 对 6 个共享模型则排在正常源**前面**先被尝试(每次白付 ~3.4s 上游往返)。
|
||||
//
|
||||
// 这是 v2.0.28(remove_source 漏声明)的**存量残骸**:修声明只防新增,不清理存量。
|
||||
func TestChannelsReapOrphanSources(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.Fatalf("加载插件: %v", err)
|
||||
}
|
||||
// 生产里 channels 被授予这些(见 plugin.kernel 声明);夹具必须同样授权,
|
||||
// 否则 add_source 会被拒 ⇒ 造不出孤儿源。
|
||||
if err := ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false); err != nil {
|
||||
t.Fatalf("授权: %v", err)
|
||||
}
|
||||
|
||||
// 装载前先清空账号池:这样插件初始化流程自己注册的源也一并算孤儿。
|
||||
cleanup := func() interface{} {
|
||||
r, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{
|
||||
"dry_run": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reap_orphan_sources: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
cleanup()
|
||||
|
||||
// 造出**真实形态**:走 account_add 登记账号(它会注册源),
|
||||
// 再用 hidden 探针把账号从池里摘掉 —— 模拟 v2.0.28 事故的残留形态
|
||||
// (账号已删、撤源被拒 ⇒ 源留下继续对外服务)。
|
||||
// 不走 account_delete:那条路径的收尾已被 TestAccountDelete* 覆盖,
|
||||
// 而我们要的恰恰是"源留下了"这个状态。
|
||||
//
|
||||
// ★ 一通道一源之后:`account_add` 产生的源名是 `channels-buddy`(不含账号 id),
|
||||
// 所以它**造不出**本用例要的「旧版按账号拆源」形态。这里直接用
|
||||
// __add_source_for_test 注册遗留形态的源名 —— 这才是要验的对象本身
|
||||
// (reap 的职责就是清掉升级前遗留的这类源)。
|
||||
if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
|
||||
"name": "channels-buddy-deadbeef1234", "base_url": "http://127.0.0.1:1",
|
||||
"adapter": "openai", "models": []interface{}{"m1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("注册遗留源(1): %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
t.Fatalf("注册遗留源(1) 报错: %v", j["error"])
|
||||
}
|
||||
// ★ 第二个孤儿:账号 id **含 `-`**。这个用例是判别性的 ——
|
||||
// 用 `name:match("^channels%-(.+)-(.+)$")` 解析会被 Lua 的**贪婪** `-`
|
||||
// 切成 plat="buddy-dead"/acct="acct"(未知平台)⇒ 源被跳过 ⇒ **孤儿漏杀**。
|
||||
// (实测:只有这个用例能把"按已知平台前缀匹配"与"贪婪匹配"区分开;
|
||||
// 不含 `-` 的 id 两种实现结果相同,咬不住。)
|
||||
if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
|
||||
"name": "channels-buddy-dead-acct-1", "base_url": "http://127.0.0.1:1",
|
||||
"adapter": "openai", "models": []interface{}{"m1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("注册遗留源(2): %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
t.Fatalf("注册遗留源(2) 报错: %v", j["error"])
|
||||
}
|
||||
// 运维手工源:名字与账号源形态无关 ⇒ 绝不碰
|
||||
if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
|
||||
"name": "my-handmade-source", "base_url": "http://127.0.0.1:1",
|
||||
"adapter": "openai", "models": []interface{}{"m1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("注册手工源: %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
// ★ 必须看 json 里的 error:内核把失败放在返回体里,
|
||||
// Go 侧 err 仍是 nil ⇒ 只查 err 会让"源根本没注册"静默通过。
|
||||
t.Fatalf("注册手工源报错: %v", j["error"])
|
||||
}
|
||||
// 遗留源对应的账号本来就不存在(这正是"孤儿"的定义),无需再摘。
|
||||
|
||||
// ① dry_run(默认)只报告、不删
|
||||
res, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(t, res)
|
||||
orphans, _ := j["orphans"].([]interface{})
|
||||
seen := map[string]bool{}
|
||||
for _, o := range orphans {
|
||||
if s, _ := o.(string); s != "" {
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} {
|
||||
if !seen[want] {
|
||||
t.Fatalf("★ 没识别出孤儿源 %s(dry_run 应报告它)\n"+
|
||||
" 若 id 含 `-` 的那个漏了 ⇒ 说明用了贪婪 match 解析名字,应改为按已知平台前缀匹配\n"+
|
||||
" 实得 %v", want, seen)
|
||||
}
|
||||
}
|
||||
if seen["my-handmade-source"] {
|
||||
t.Fatalf("★ 误伤运维手工源(名字不匹配账号源形态,绝不能碰)")
|
||||
}
|
||||
if j["dry_run"] != true {
|
||||
t.Fatalf("dry_run 必须默认开启(破坏性操作不得默认生效),实得 %v", j["dry_run"])
|
||||
}
|
||||
if j["removed"].(float64) != 0 {
|
||||
t.Fatalf("dry_run=true 时不得真删,实得 removed=%v", j["removed"])
|
||||
}
|
||||
|
||||
// ② 确认 dry_run 真的没删(看源列表)
|
||||
after, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := sourceNames(jsonOf(t, after))
|
||||
if !names["channels-buddy-deadbeef1234"] {
|
||||
t.Fatalf("dry_run 竟真删了!源列表: %v", names)
|
||||
}
|
||||
// 手工源必须存活(名字不匹配账号源形态 ⇒ 不该被识别为孤儿)
|
||||
if !names["my-handmade-source"] {
|
||||
t.Fatalf("dry_run 时就丢了手工源?源列表: %v", names)
|
||||
}
|
||||
|
||||
// ③ 真删:孤儿消失、手工源存活
|
||||
res, err = ps.Call("channels", "reap_orphan_sources", map[string]interface{}{"dry_run": false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j = jsonOf(t, res)
|
||||
if j["ok"] != true {
|
||||
t.Fatalf("清理应成功,实得 %v", j)
|
||||
}
|
||||
after, err = ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names = sourceNames(jsonOf(t, after))
|
||||
for _, dead := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} {
|
||||
if names[dead] {
|
||||
t.Fatalf("★ 孤儿源 %s 未被清掉(它会继续对外服务无凭据的请求): %v", dead, names)
|
||||
}
|
||||
}
|
||||
if !names["my-handmade-source"] {
|
||||
t.Fatalf("★ 误删了运维手工源: %v", names)
|
||||
}
|
||||
t.Logf("✓ 孤儿源已清、手工源存活")
|
||||
}
|
||||
|
||||
// sourceNames 把 __list_sources_probe 的返回摊平成 name→true。
|
||||
func sourceNames(j map[string]interface{}) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if arr, ok := j["sources"].([]interface{}); ok {
|
||||
for _, x := range arr {
|
||||
if m, ok := x.(map[string]interface{}); ok {
|
||||
if s, ok := m["name"].(string); ok {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
237
internal/core/channels_pool_reason_test.go
Normal file
237
internal/core/channels_pool_reason_test.go
Normal file
@ -0,0 +1,237 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 「为什么没有可用账号」必须区分**真的用尽**与**查不到**。
|
||||
//
|
||||
// 参考的同款修复(用户报障 2026-09-29):混成一句"额度都已用尽"会让
|
||||
// **一次网络抖动伪装成钱花完了** —— 用户于是去解锁永久积分、或白等到明天,
|
||||
// 而号里其实有钱。两者的**正确动作完全相反**:
|
||||
//
|
||||
// exhausted → 等额度重置
|
||||
// unknown → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定)
|
||||
//
|
||||
// 判据看的是**报出的 reason**(用户据此决定做什么),不是内部计数。
|
||||
func TestCreditsPoolReasonDistinguishesExhaustedFromUnknown(t *testing.T) {
|
||||
t.Run("账号都查到了余额但为 0 → exhausted", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
h.addAccount("a1")
|
||||
h.serveBalance(0, 0)
|
||||
got := h.reason()
|
||||
if got["reason"] != "exhausted" {
|
||||
t.Fatalf("余额确为 0 时应报 exhausted(用户该等重置),实得 %v:%v",
|
||||
got["reason"], got["detail"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("余额查询失败 → unknown(不能伪装成用尽)", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
h.addAccount("a1")
|
||||
h.serveBalanceError(500)
|
||||
got := h.reason()
|
||||
if got["reason"] != "unknown" {
|
||||
t.Fatalf("查询失败必须报 unknown —— 报 exhausted 会让网络抖动伪装成钱花完了"+
|
||||
"(用户会去解锁永久积分或白等),实得 %v:%v", got["reason"], got["detail"])
|
||||
}
|
||||
if d, _ := got["detail"].(string); !strings.Contains(d, "查询失败") {
|
||||
t.Fatalf("说明必须点明是查询失败,实得 %q", d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("没有启用的账号 → no_accounts", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
got := h.reason()
|
||||
if got["reason"] != "no_accounts" {
|
||||
t.Fatalf("无账号时应报 no_accounts,实得 %v", got["reason"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("有可用账号 → usable", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
h.addAccount("a1")
|
||||
h.serveBalance(100, 0)
|
||||
got := h.reason()
|
||||
if got["reason"] != "usable" {
|
||||
t.Fatalf("有钱时应报 usable,实得 %v", got["reason"])
|
||||
}
|
||||
if u, _ := got["usable"].(float64); int(u) != 1 {
|
||||
t.Fatalf("usable 应为 1,实得 %v", got["usable"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("平台无余额端点 → unknown(不编造用尽)", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
// ⚠ 必须有**启用的账号**:判定的优先级是
|
||||
// no_accounts → 无余额端点 → usable → unknown/exhausted
|
||||
// 我第一版在这条用例里忘了加账号,于是命中 no_accounts(优先级更高,
|
||||
// 那是**正确**行为),把正确的实现判成了错。
|
||||
h.addAccountOn("minimax", "m1")
|
||||
// minimax 未声明 balance 端点
|
||||
got := h.reasonOn("minimax")
|
||||
if got["reason"] != "unknown" {
|
||||
t.Fatalf("无余额端点时应报 unknown(无法判定 ≠ 用尽),实得 %v:%v",
|
||||
got["reason"], got["detail"])
|
||||
}
|
||||
if d, _ := got["detail"].(string); !strings.Contains(d, "无法判定") {
|
||||
t.Fatalf("说明必须点明无法判定,实得 %q", d)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 锁定永久积分时,只剩永久积分的账号不可用,且必须报 **locked** 而不是 exhausted
|
||||
// (用户动作不同:解锁 vs 等重置)。
|
||||
func TestCreditsPoolReasonReportsLockedNotExhausted(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
h.addAccount("a1")
|
||||
// total_fn 取包列表:只有永久积分(到期未知)
|
||||
h.serveBalance(0, 50)
|
||||
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
|
||||
map[string]interface{}{"lock": true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := h.reason()
|
||||
if got["reason"] != "locked" {
|
||||
t.Fatalf("锁定且只剩永久积分时应报 locked(用户可解锁),实得 %v:%v",
|
||||
got["reason"], got["detail"])
|
||||
}
|
||||
// 解锁后同一份余额应为 usable(证明 locked 不是"用尽")
|
||||
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
|
||||
map[string]interface{}{"lock": false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.serveBalance(0, 50)
|
||||
if got2 := h.reason(); got2["reason"] != "usable" {
|
||||
t.Fatalf("解锁后同一份余额应可用(证明 locked ≠ exhausted),实得 %v", got2["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type poolReasonHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
body atomic.Value
|
||||
fail atomic.Bool
|
||||
hasAcct atomic.Bool
|
||||
}
|
||||
|
||||
func newPoolReasonHarness(t *testing.T) *poolReasonHarness {
|
||||
t.Helper()
|
||||
h := &poolReasonHarness{t: t}
|
||||
// buddy 的真实形态:包列表嵌在 data.Response.Data.Accounts
|
||||
h.body.Store(buddyPkgBody(0, 0))
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if h.fail.Load() {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, h.body.Load().(string))
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
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)
|
||||
}
|
||||
h.ps = ps
|
||||
// buddy 的余额端点指到假上游,并让 total_fn 走包列表分桶
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"balance": map[string]interface{}{"url": h.ts.URL},
|
||||
}); err != nil {
|
||||
t.Logf("set_endpoints(balance) 未支持: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *poolReasonHarness) addAccount(id string) {
|
||||
h.t.Helper()
|
||||
h.addAccountOn("buddy", id)
|
||||
}
|
||||
|
||||
// addAccountOn:往指定平台加账号(我第一版把它写死成 buddy,导致
|
||||
// "平台无余额端点"那条用例其实没有该平台的账号,命中了优先级更高的
|
||||
// no_accounts —— 判据写错而非实现错)。
|
||||
func (h *poolReasonHarness) addAccountOn(platform, id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": platform, "account_id": id,
|
||||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_add(%s): %v", platform, err)
|
||||
}
|
||||
h.hasAcct.Store(true)
|
||||
}
|
||||
|
||||
// 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 真实形态的余额响应:
|
||||
// 包列表在 data.Response.Data.Accounts,每包有 Status / CycleCapacityRemain /
|
||||
// DeductionEndTime(毫秒)。到期时间落在窗口内 → 临时桶;未知(0)→ 永久桶。
|
||||
func buddyPkgBody(expiring, permanent int) string {
|
||||
pkgs := []string{}
|
||||
if expiring > 0 {
|
||||
// 3 天到期(< 15 天窗口)
|
||||
endMs := (time.Now().Unix() + 3*86400) * 1000
|
||||
pkgs = append(pkgs, fmt.Sprintf(
|
||||
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":%d}`, expiring, endMs))
|
||||
}
|
||||
if permanent > 0 {
|
||||
// DeductionEndTime=0(未知)⇒ 永久桶
|
||||
pkgs = append(pkgs, fmt.Sprintf(
|
||||
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":0}`, permanent))
|
||||
}
|
||||
return `{"data":{"Response":{"Data":{"Accounts":[` + strings.Join(pkgs, ",") + `]}}}}`
|
||||
}
|
||||
|
||||
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{} {
|
||||
h.t.Helper()
|
||||
return h.reasonOn("buddy")
|
||||
}
|
||||
|
||||
func (h *poolReasonHarness) reasonOn(platform string) map[string]interface{} {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "credits_pool_reason",
|
||||
map[string]interface{}{"platform": platform})
|
||||
if err != nil {
|
||||
h.t.Fatalf("credits_pool_reason: %v", err)
|
||||
}
|
||||
return jsonOf(h.t, res)
|
||||
}
|
||||
@ -120,9 +120,17 @@ func TestChannelsAccountPoolBehavior(t *testing.T) {
|
||||
t.Run("模型开关同步上游源", func(t *testing.T) {
|
||||
m := invoke(t, ps, "account_add", `{"platform":"minimax","credential":"MM-KEY"}`)
|
||||
aid, _ := m["account_id"].(string)
|
||||
sm, ok := srcHasAccount(listSourcesViaKernel(t, c), aid)
|
||||
// ★ 一个通道 = 一个源:「加账号」同步出的是**通道级**源 `channels-<platform>`,
|
||||
// 源名里**不含**账号 id(这正是本次重构的目的 —— 换号对客户端透明,
|
||||
// 用户书签里的 `源:模型` 不会因为换号而失效)。
|
||||
// 故这里按平台名找源,不按 aid 找。
|
||||
sm, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax")
|
||||
if !ok {
|
||||
t.Fatal("带凭据账号应已同步源")
|
||||
t.Fatalf("带凭据账号应已同步出通道源,实际源: %v", listSourcesViaKernel(t, c))
|
||||
}
|
||||
// 而且源名必须是通道级那个(旧版会带 -<12hex> 后缀)
|
||||
if nm, _ := sm["name"].(string); nm != "channels-minimax" {
|
||||
t.Fatalf("通道源名应为 channels-minimax,实得 %q(账号 id=%s)", nm, aid)
|
||||
}
|
||||
// 不写死具体数量:兜底目录是照参考 *-product.ts 补的,会随对齐
|
||||
// 进度变化(minimax 已从 3 项补到 4 项)。这里断言的是**关系**:
|
||||
@ -133,7 +141,8 @@ func TestChannelsAccountPoolBehavior(t *testing.T) {
|
||||
t.Fatalf("源应含全部 %d 个模型, 实得 %d", total, n)
|
||||
}
|
||||
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
|
||||
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid)
|
||||
// 同样按平台名找(源名不含账号 id)
|
||||
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), "minimax")
|
||||
if n := len(asArr(sm["models"])); n != total-1 {
|
||||
t.Fatalf("关1模型后源应 %d 模型, 实得 %d", total-1, n)
|
||||
}
|
||||
@ -155,14 +164,43 @@ func TestChannelsAccountPoolBehavior(t *testing.T) {
|
||||
t.Fatal("上个用例应留有账号")
|
||||
}
|
||||
aid := accts[0].(map[string]interface{})["id"].(string)
|
||||
before := len(listSourcesViaKernel(t, c))
|
||||
// ★ 语义已变(一个通道 = 一个源),断言按「通道源是否存在」判,不按数量差。
|
||||
//
|
||||
// 为什么不能断言 `after == before-1`:内核的 remove_source 有一条正当
|
||||
// 保护 —— **拒绝删掉最后一个源**(空 registry 会让每个请求变 503)。
|
||||
// 本夹具只有这一个源,撤源请求会被这条保护挡住,数量差恒为 0,
|
||||
// 于是断言无论实现对错都失败。生产上有 20+ 源不会撞这条保护,
|
||||
// 但判据不能依赖"恰好有别的源"这种环境事实。
|
||||
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok {
|
||||
t.Fatal("前置:minimax 通道源应存在")
|
||||
}
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":false}}`)
|
||||
if n := len(listSourcesViaKernel(t, c)); n != before-1 {
|
||||
t.Fatalf("停用账号应删源: 前 %d 后 %d", before, n)
|
||||
// 停用唯一账号 ⇒ 该通道无可用号 ⇒ 插件**尝试**撤源。夹具里会撞上内核
|
||||
// 「拒绝删最后一个源」的保护,故不断言源数量,改断言插件自己的判据:
|
||||
// provider_status 里该通道的 enabled 应转为 false(无可用账号)。
|
||||
// ⚠️ provider_status 的字段形状:`enabled` 是**嵌套在 accounts 里的计数**
|
||||
// (accounts = {total=N, enabled=M}),不是顶层布尔。顶层只有 `closed`
|
||||
// (表示"该平台所有模型都被关掉")。写断言前必须看清形状 ——
|
||||
// 我第一版按顶层 `enabled == false` 判,永远不成立。
|
||||
st := invoke(t, ps, "provider_status", `{}`)
|
||||
noUsable := false
|
||||
for _, x := range asArr(st["statuses"]) {
|
||||
row := x.(map[string]interface{})
|
||||
if row["id"] != "minimax" {
|
||||
continue
|
||||
}
|
||||
if acc, ok := row["accounts"].(map[string]interface{}); ok {
|
||||
if n, _ := acc["enabled"].(float64); n == 0 {
|
||||
noUsable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !noUsable {
|
||||
t.Fatalf("停用唯一账号后,minimax 的 accounts.enabled 应为 0(无可用账号)")
|
||||
}
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":true}}`)
|
||||
if n := len(listSourcesViaKernel(t, c)); n != before {
|
||||
t.Fatalf("启用账号应重建源: 前 %d 后 %d", before, n)
|
||||
if n := len(listSourcesViaKernel(t, c)); n < 1 {
|
||||
t.Fatalf("启用账号后应重建源,实际源数 %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -49,16 +49,17 @@ func TestPrivateProtocolSourcesUsePluginAdapters(t *testing.T) {
|
||||
if j == nil || j["error"] != nil {
|
||||
t.Fatalf("account_add 报错: %#v", m)
|
||||
}
|
||||
aid, _ := j["account_id"].(string)
|
||||
_, _ = j["account_id"].(string)
|
||||
|
||||
srcs := listSourcesViaKernel(t, c)
|
||||
sm, ok := srcHasAccount(srcs, aid)
|
||||
// ★ 一通道一源:按**平台名**找源,不按账号 id(源名已不含 id)。
|
||||
sm, ok := srcHasAccount(srcs, tc.platform)
|
||||
if !ok {
|
||||
names := []string{}
|
||||
for _, x := range srcs {
|
||||
names = append(names, x["name"].(string))
|
||||
}
|
||||
t.Fatalf("%s 账号加了却不出源(现有源: %v)—— 源注册失败通常是"+
|
||||
t.Fatalf("%s 账号加了却不出通道源(现有源: %v)—— 源注册失败通常是"+
|
||||
"凭据形状不符或内核权限未授", tc.platform, names)
|
||||
}
|
||||
ad, _ := sm["adapter"].(string)
|
||||
|
||||
88
internal/core/channels_qoder_adapter_test.go
Normal file
88
internal/core/channels_qoder_adapter_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// qoderReason 调真实的 transform_error 钩子(不自己重算)。
|
||||
func qoderReason(t *testing.T, vm *lua.VM, status int, body string) string {
|
||||
t.Helper()
|
||||
reason, ok, err := vm.TransformError("channels-qoder", status, body)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_error 调用失败: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("channels-qoder 适配器未装载或未定义 transform_error(body=%s)", body)
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
// Qoder 的错误信封是**双层编码**的,且两个业务码语义**相反**:
|
||||
//
|
||||
// 10605 排队 → 暂时受阻,等一下就好
|
||||
// 110 额度 → 当天耗尽,重试无用(参考实测:原先归成通用服务端错误 → 白重试 5 次)
|
||||
//
|
||||
// 通用 openai 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` ——
|
||||
// 用户看不懂是排队还是额度用完。故用**插件自装的 channels-qoder**,
|
||||
// 厂商码值留在插件域(内核零厂商名)。
|
||||
func TestQoderAdapterParsesQueueAndBilling(t *testing.T) {
|
||||
c, _ := poolCore(t)
|
||||
vm := c.VM()
|
||||
|
||||
// ① 排队:外层 code=10605 + 内层 JSON 字符串(参考实测的真实形状)
|
||||
out := qoderReason(t, vm, 403,
|
||||
`{"code":"10605","message":"{\"isQueued\":true,\"queueCount\":12,\"retryAfterSeconds\":30}"}`)
|
||||
if !strings.Contains(out, "排队") {
|
||||
t.Errorf("10605 必须说成排队,实得 %q", out)
|
||||
}
|
||||
// 等待时长与队列长度要从**内层**取到(只读顶层永远拿不到)
|
||||
if !strings.Contains(out, "30") {
|
||||
t.Errorf("应从内层取到 retryAfterSeconds=30,实得 %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "12") {
|
||||
t.Errorf("应从内层取到 queueCount=12,实得 %q", out)
|
||||
}
|
||||
|
||||
// ② SSE 错误帧形态:**内层没有 code 字段**,只有排队标志。
|
||||
// ⚠️ 参考第一版要求「必须命中 code 才算排队」,导致这一形态被判成非排队、
|
||||
// SSE 通道的修复静默失效(探针显示 sleep=0)。这里必须能识别。
|
||||
out2 := qoderReason(t, vm, 200,
|
||||
`{"message":"{\"isQueued\":true,\"queueCount\":3,\"retryAfterSeconds\":8}"}`)
|
||||
if !strings.Contains(out2, "排队") {
|
||||
t.Errorf("内层无 code 也必须识别为排队(这是参考踩过的坑),实得 %q", out2)
|
||||
}
|
||||
if !strings.Contains(out2, "8") {
|
||||
t.Errorf("应取到内层 retryAfterSeconds=8,实得 %q", out2)
|
||||
}
|
||||
|
||||
// ③ 额度耗尽:必须与排队**分开说**,且明确"重试无用"
|
||||
out3 := qoderReason(t, vm, 403, `{"code":"110","message":"Billing daily count exceeded"}`)
|
||||
if !strings.Contains(out3, "额度") {
|
||||
t.Errorf("110 必须说成额度耗尽,实得 %q", out3)
|
||||
}
|
||||
if strings.Contains(out3, "排队") {
|
||||
t.Errorf("110 绝不能被说成排队(两者语义相反),实得 %q", out3)
|
||||
}
|
||||
if !strings.Contains(out3, "重试无用") {
|
||||
t.Errorf("额度耗尽要明确告知重试无用,实得 %q", out3)
|
||||
}
|
||||
|
||||
// ④ 泛词不能误伤:正文里讨论"配额/余额"不该被当成额度错误
|
||||
//(关键词必须窄 —— 宽词会把模型正常输出误判成故障)
|
||||
out4 := qoderReason(t, vm, 500,
|
||||
`{"error":{"message":"the response discusses quota and balance concepts"}}`)
|
||||
if strings.Contains(out4, "额度已用尽") {
|
||||
t.Errorf("泛词 quota/balance 不得误伤(关键词必须窄),实得 %q", out4)
|
||||
}
|
||||
|
||||
// ⑤ 与通用 openai 适配器对比:证明**通用器确实做不到**这件事
|
||||
//(否则这个专用适配器就没有存在理由)
|
||||
gen, okGen, _ := vm.TransformError("openai", 403,
|
||||
`{"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"}`)
|
||||
if okGen && strings.Contains(gen, "排队") {
|
||||
t.Error("通用 openai 适配器不该能识别排队 —— 若能,则专用适配器无必要")
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -220,3 +221,75 @@ func TestQoderRefreshUsesUTC8NotLocalTime(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 裸 token 凭据必须能领:qoder 是 PKCE 设备码流且无 register 第二跳,
|
||||
// complete_login 存的就是裸 access_token 字符串(真实生产事故:账号池凭据是
|
||||
// dt-… 裸串,claim 报「缺少凭据对象」,而 account_test 用 token 路径照常通过
|
||||
// —— 自相矛盾的状态)。活动接口只要 Bearer token,就地包一层即可。
|
||||
func TestQoderClaimAcceptsBareTokenCredential(t *testing.T) {
|
||||
var auth string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
auth = r.Header.Get("Authorization")
|
||||
switch {
|
||||
case r.URL.Path == "/sash/api/v1/me/campaigns":
|
||||
fmt.Fprint(w, `{"showCampaign":true,"claimable":true,"campaigns":[
|
||||
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`)
|
||||
case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") &&
|
||||
r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim":
|
||||
fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
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)
|
||||
}
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "qoder",
|
||||
"qoder_sash_base": map[string]interface{}{"url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 与生产一致:凭据是**裸 token 字符串**(不是 JSON 对象)。
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"platform": "qoder", "credential": "dt-D6t0cFLOY3fpSmu2Mck2WWeO", "account": "t",
|
||||
})
|
||||
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: string(body),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim_daily: %v", err)
|
||||
}
|
||||
j, _ := out.JSON.(map[string]interface{})
|
||||
oc, _ := j["outcome"].(map[string]interface{})
|
||||
if oc == nil {
|
||||
t.Fatalf("应返回 outcome,实得 %v", j)
|
||||
}
|
||||
// claim_daily 的通用路径把 claim_fn 的 kind=claimed 归一成 success + message;
|
||||
// 判据是**不再是 failed** 且消息带领取金额。
|
||||
kind, _ := oc["kind"].(string)
|
||||
if kind == "failed" {
|
||||
t.Fatalf("裸 token 凭据应能领取,实得 kind=failed(%v)", oc["message"])
|
||||
}
|
||||
if msg, _ := oc["message"].(string); !strings.Contains(msg, "100") {
|
||||
t.Errorf("领取成功消息应带金额,实得 %q", msg)
|
||||
}
|
||||
if auth != "Bearer dt-D6t0cFLOY3fpSmu2Mck2WWeO" {
|
||||
t.Errorf("活动接口必须带 Bearer 裸 token,实得 %q", auth)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,环境时区不同也成立:用本地偏移动态算)。
|
||||
}
|
||||
|
||||
215
internal/core/channels_remove_source_perm_test.go
Normal file
215
internal/core/channels_remove_source_perm_test.go
Normal file
@ -0,0 +1,215 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 删除账号必须**真的把源撤掉** —— 且撤不掉时不能静默。
|
||||
//
|
||||
// 真实缺陷:内核的 `remove_source` 需要**单独授予**(Destructive 级,不在
|
||||
// add_source 的授权里)。生产权限集曾只有 add_source ⇒ `dropAccountSource`
|
||||
// 的调用被**拒绝**,而调用方**忽略返回值** ⇒ 静默失败的后果是
|
||||
// **账号已删,它的源仍在对外服务**(用户看到"删了却还在用")。
|
||||
//
|
||||
// 格外隐蔽的一点:`AddSource` 是**同名 upsert**,所以 `syncAccountSource` 里
|
||||
// "先 remove 再 add" 的 remove 失败**看不出来**(add 覆盖掉同名源)——
|
||||
// 只有**纯删除**路径(account_delete / 停用)才暴露。故判据必须走删除路径。
|
||||
func TestAccountDeleteRemovesItsSource(t *testing.T) {
|
||||
t.Run("授予 remove_source 时应撤掉源", func(t *testing.T) {
|
||||
h := newRemoveSourceHarness(t, true)
|
||||
h.addAccount("del-a")
|
||||
if got := h.sourceNames(); !hasName(got, "channels-buddy") {
|
||||
t.Fatalf("前置条件:通道源应已建立(一通道一源:channels-buddy),实得 %v", got)
|
||||
}
|
||||
h.deleteAccount("del-a")
|
||||
if got := h.sourceNames(); hasName(got, "channels-buddy") {
|
||||
t.Fatalf("唯一账号已删但通道源仍在 ⇒ 已删账号继续对外服务:%v", got)
|
||||
}
|
||||
t.Log("✓ 删除唯一账号同时撤掉通道源")
|
||||
})
|
||||
|
||||
t.Run("未授予 remove_source 时必须**可见地**失败(不能静默)", func(t *testing.T) {
|
||||
h := newRemoveSourceHarness(t, false)
|
||||
h.addAccount("del-b")
|
||||
h.deleteAccount("del-b")
|
||||
// 源确实撤不掉(权限不足)——这是环境的限制,不是我们要判的
|
||||
// 我们要判的是:**这件事必须被记录下来**,而不是消失在一个被丢弃的返回值里。
|
||||
logs := h.eventLog()
|
||||
joined := strings.Join(logs, " | ")
|
||||
if !strings.Contains(joined, "撤旧源失败") && !strings.Contains(joined, "撤源失败") {
|
||||
t.Fatalf("撤源被拒却没有任何记录 ⇒ 权限缺失会一直静默(表现是"+
|
||||
"「删了账号却还在用」)\n事件日志: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "remove_source") {
|
||||
t.Fatalf("记录里应点明是哪个内核动作被拒(便于给运维可操作提示),实得: %s", joined)
|
||||
}
|
||||
// ★ 必须带上**真正的原因**(not granted),而不是"无返回"这类空话。
|
||||
// 内核桥失败时**第一个返回值是 nil、原因在第二个返回值里** ——
|
||||
// 只接第一个返回值就会把原因丢掉(我第一版正是如此)。
|
||||
if !strings.Contains(joined, "not granted") {
|
||||
t.Fatalf("记录里必须含内核给出的**真实原因**(not granted),"+
|
||||
"否则运维无从下手(只接 call 的第一个返回值就会丢掉原因);实得: %s", joined)
|
||||
}
|
||||
t.Logf("✓ 撤源失败已可见: %s", joined)
|
||||
})
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type removeSourceHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newRemoveSourceHarness(t *testing.T, grantRemove bool) *removeSourceHarness {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
// ★ 照生产现状:add_source 有、remove_source 视参数而定
|
||||
// 注意:status 是插件自己的导出,与内核权限无关(之前误当权限项)
|
||||
actions := []string{"add_source", "list_sources"}
|
||||
if grantRemove {
|
||||
actions = append(actions, "remove_source")
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", actions, false)
|
||||
return &removeSourceHarness{t: t, ps: ps}
|
||||
}
|
||||
|
||||
func (h *removeSourceHarness) 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"}`,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_add(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *removeSourceHarness) deleteAccount(id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_delete", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_delete(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *removeSourceHarness) sourceNames() []string {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(h.t, res)
|
||||
var out []string
|
||||
arr, _ := j["sources"].([]interface{})
|
||||
for _, e := range arr {
|
||||
if m, ok := e.(map[string]interface{}); ok {
|
||||
if n, ok := m["name"].(string); ok {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// eventLog 取插件事件日志(logEvent 写进 plugin.state.log)。
|
||||
// ⚠ 走探针:`status` 是 HTTP 路由处理器,**不在** plugin.exports 里,
|
||||
// 直接 Call 它会报 `does not export "status"`(我第一版就这么错)。
|
||||
func (h *removeSourceHarness) eventLog() []string {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__event_log_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(h.t, res)
|
||||
var out []string
|
||||
arr, _ := j["log"].([]interface{})
|
||||
for _, e := range arr {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasName(xs []string, want string) bool {
|
||||
for _, x := range xs {
|
||||
if x == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ★ 插件**声明**的内核动作必须覆盖它**实际调用**的全部动作。
|
||||
//
|
||||
// 真实缺陷:`plugin.kernel` 只声明了 5 个动作,而插件在 5 处调用 `remove_source`
|
||||
// —— 声明与使用**漂移**。生产只授了声明过的那些 ⇒ 撤源全部被拒,且因为
|
||||
// `AddSource` 是**同名 upsert**(add 会覆盖,看不出 remove 失败),
|
||||
// 只有**纯删除路径**才暴露:**账号删了,它的源还在对外服务**。
|
||||
//
|
||||
// 判据用**静态扫描**(源码里出现的 call("X"))对比声明清单 —— 这样将来任何人
|
||||
// 加了新的内核调用却忘了声明,都会在这里报红,而不是等线上静默失效。
|
||||
func TestChannelsDeclaresEveryKernelActionItCalls(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 抽出声明清单
|
||||
declStart := strings.Index(s, "plugin.kernel = {")
|
||||
if declStart < 0 {
|
||||
t.Fatal("找不到 plugin.kernel 声明(判据需更新)")
|
||||
}
|
||||
declEnd := strings.Index(s[declStart:], "}")
|
||||
if declEnd < 0 {
|
||||
t.Fatal("plugin.kernel 声明没有收尾大括号")
|
||||
}
|
||||
declBlob := s[declStart : declStart+declEnd]
|
||||
declared := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`"([a-z_]+)"`).FindAllStringSubmatch(declBlob, -1) {
|
||||
declared[m[1]] = true
|
||||
}
|
||||
if len(declared) == 0 {
|
||||
t.Fatal("声明清单解析为空(判据失效)")
|
||||
}
|
||||
|
||||
// ② 抽出实际调用的动作
|
||||
called := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`__llmsproxy_kernel\.call\(\s*"([a-z_]+)"`).FindAllStringSubmatch(s, -1) {
|
||||
called[m[1]] = true
|
||||
}
|
||||
if len(called) == 0 {
|
||||
t.Fatal("没扫描到任何内核调用(判据失效)")
|
||||
}
|
||||
|
||||
// ③ 逐个比对:调用而未声明 = 漏声明(运维照声明授权 ⇒ 该调用必被拒)
|
||||
var missing []string
|
||||
for a := range called {
|
||||
if !declared[a] {
|
||||
missing = append(missing, a)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
t.Fatalf("插件调用了未声明的内核动作 %v\n"+
|
||||
"(运维按 declared 授予 ⇒ 这些调用会被拒;remove_source 漏声明时"+
|
||||
"的表现是「账号删了、源还在对外服务」,且因 AddSource 是 upsert 而静默)",
|
||||
missing)
|
||||
}
|
||||
t.Logf("✓ 声明覆盖全部调用:declared=%d called=%d", len(declared), len(called))
|
||||
}
|
||||
241
internal/core/channels_reorder_effect_test.go
Normal file
241
internal/core/channels_reorder_effect_test.go
Normal file
@ -0,0 +1,241 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ account_reorder 必须**真的改变内核里的源顺序**。
|
||||
//
|
||||
// 链路:poolList 按 priority 降序 → syncAccountSource 的调用顺序 → 内核里的
|
||||
// **源注册顺序** → 内核按 candidates 顺序依次尝试。
|
||||
// 而一个源一旦注册,顺序就固定了 —— 改 pool 记录不影响它。故重排后必须按新
|
||||
// 顺序 remove+add 一遍。
|
||||
//
|
||||
// 判据看的是**内核 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")
|
||||
|
||||
// 初始:先建的 acct-a 优先(priority 相同 → createdAt 升序 → id 兜底)
|
||||
if got := h.chosenAccount(); got != "acct-a" {
|
||||
t.Fatalf("前置条件不成立:初始应选 acct-a,实得 %q", got)
|
||||
}
|
||||
// 通道只有一个源,且确实是它
|
||||
if n := len(h.sourceOrder()); n != 1 {
|
||||
t.Fatalf("一通道一源:应有 1 个通道源,实得 %d", n)
|
||||
}
|
||||
|
||||
// 把 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)
|
||||
}
|
||||
if got := h.chosenAccount(); got != "acct-b" {
|
||||
t.Fatalf("重排后应改选 acct-b(源同名覆盖成它的凭据),实得 %q\n"+
|
||||
"(只改 pool.priority 不重建源的话,内核仍用旧 api_key ⇒ 路由不变)", got)
|
||||
}
|
||||
// 源仍在(不能因为重建把源弄丢)
|
||||
if n := len(h.sourceOrder()); n != 1 {
|
||||
t.Fatalf("重排后通道源应仍为 1 个,实得 %d", n)
|
||||
}
|
||||
t.Logf("✓ 重排已切换服务账号: %s", h.chosenAccount())
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type reorderHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newReorderHarness(t *testing.T) *reorderHarness {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
// ★ 必须授予 add_source:不给的话 syncAccountSource 里内核会明确拒绝
|
||||
// ("kernel action add_source is not granted"),源一个都不会出现 ——
|
||||
// 我第一版漏了它,于是"应有 2 个源,实得 []"。
|
||||
_ = ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
|
||||
return &reorderHarness{t: t, ps: ps}
|
||||
}
|
||||
|
||||
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,
|
||||
// 每个账号用**不同** token:cho sen 切换后源 api_key 变化才是可观测的
|
||||
"credential": `{"access_token":"AT-` + id + `","uid":"u1"}`,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_add(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// sourceOrder 返回内核里 channels-buddy-* 源的顺序(即实际尝试顺序)。
|
||||
//
|
||||
// ⚠ **必须只读**:我第一版在这里顺带调了 __sync_source_probe(它内部是
|
||||
// remove+add)⇒ 改变了被测状态,"测之前先动了被测对象",判据因此不可信
|
||||
// (实测表现:顺序忽而两个忽而一个)。
|
||||
func (h *reorderHarness) sourceOrder() []string {
|
||||
h.t.Helper()
|
||||
ls, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__list_sources_probe: %v", err)
|
||||
}
|
||||
j := jsonOf(h.t, ls)
|
||||
var out []string
|
||||
arr, _ := j["sources"].([]interface{})
|
||||
for _, e := range arr {
|
||||
m, ok := e.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n, _ := m["name"].(string)
|
||||
// 一通道一源:源名就是 channels-<provider>(不再带账号后缀)
|
||||
if n == "channels-buddy" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ★ poolList 的顺序必须**确定**(同秒创建的账号也不例外)。
|
||||
//
|
||||
// 真实缺陷:排序键是 `createdAt`(**秒级**),同秒建的两个账号比较相等,
|
||||
// 而相等时 table.sort 的结果取决于 pairs() 遍历顺序 —— **不确定**。
|
||||
// 实测:按 a,b,c 顺序建三个账号,poolList 回的是 [a,c,b]。
|
||||
//
|
||||
// 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = **内核尝试顺序**,
|
||||
// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分),
|
||||
// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。
|
||||
func TestPoolListOrderIsDeterministic(t *testing.T) {
|
||||
// ★ 一通道一源之后,"源注册顺序 = poolList 顺序"不再可观测(只有一个源)。
|
||||
// 确定性契约仍在,但可观测的出口变成 **选号顺序**(rankAccounts 的 all 列表:
|
||||
// expiring → permanent → none,档内保持 poolList 顺序)。
|
||||
// 故判据改为:同优先级 + 同秒创建时,选号顺序必须由 id 字典序决定。
|
||||
h := newReorderHarness(t)
|
||||
for _, id := range []string{"acct-c", "acct-b", "acct-a"} {
|
||||
h.addAccount(id)
|
||||
}
|
||||
h.clearPriorities()
|
||||
if !sameSecond(h.createdAts()) {
|
||||
t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts())
|
||||
}
|
||||
// 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, want)
|
||||
}
|
||||
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 {
|
||||
out := ""
|
||||
for i, x := range xs {
|
||||
if i > 0 {
|
||||
out += sep
|
||||
}
|
||||
out += x
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// createdAts 取当前 pool 里 buddy 账号的 createdAt。
|
||||
func (h *reorderHarness) createdAts() []int64 {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
var out []int64
|
||||
for _, a := range accountsOf(h.t, res) {
|
||||
if v, ok := a["createdAt"].(float64); ok {
|
||||
out = append(out, int64(v))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameSecond 判断是否所有时间戳都落在同一秒(即排序键相等)。
|
||||
func sameSecond(ts []int64) bool {
|
||||
if len(ts) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, v := range ts {
|
||||
if v != ts[0] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// clearPriorities 把所有账号的 priority 拉平(构造"排序键完全相同"的场景)。
|
||||
func (h *reorderHarness) clearPriorities() {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "__clear_priorities_probe", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
}); err != nil {
|
||||
h.t.Fatalf("__clear_priorities_probe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// resync 触发一次批量重注册(走 poolList 顺序 → 源注册顺序)。
|
||||
func (h *reorderHarness) resync(platform string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "__resync_sources_probe", map[string]interface{}{
|
||||
"platform": platform,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("__resync_sources_probe: %v", err)
|
||||
}
|
||||
}
|
||||
125
internal/core/channels_store_atomic_test.go
Normal file
125
internal/core/channels_store_atomic_test.go
Normal file
@ -0,0 +1,125 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 账号池落盘的**原子性与一致性**(照参考 account-pool.ts 的真实缺陷)。
|
||||
//
|
||||
// 参考的缺陷:`removeAccount` 只保证「读→改」之间无 await,而内部的
|
||||
// `await store.save(...)` 是**异步**的 ⇒ 两个写者各自的 save 可以**乱序完成**,
|
||||
// 后完成者带着它更早的快照覆盖磁盘。实测形态:
|
||||
// - 并发删 a / 删 b(先发起的后完成)⇒ **已删除的账号在磁盘上复活**;
|
||||
// - 删 victim 与加 added 交错 ⇒ **新增账号被静默丢失**。
|
||||
//
|
||||
// 我们的实现是**同步** tmp+rename(Lua 无异步),且每次写都序列化**当前**
|
||||
// `plugin.state`(不是调用方早先取的快照)⇒ 该竞态结构上不可能发生。
|
||||
// 但"不可能"必须被验证,否则只是一句自信。
|
||||
func TestAccountPoolStoreIsAtomicAndConsistent(t *testing.T) {
|
||||
c, ps := poolCore(t)
|
||||
// 找出插件的存储目录(内核给的 ground truth,不是猜的)
|
||||
dirOut, err := ps.Call("channels", "__store_dir_probe", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("store_dir_probe: %v", err)
|
||||
}
|
||||
dj, _ := dirOut.(map[string]interface{})["json"].(map[string]interface{})
|
||||
dir, _ := dj["store_dir"].(string)
|
||||
if dir == "" {
|
||||
t.Fatalf("拿不到插件存储目录,实得 %#v", dirOut)
|
||||
}
|
||||
storePath := filepath.Join(dir, ".channels-store.json")
|
||||
t.Logf("插件存储目录: %s", dir)
|
||||
// ⚠️ 必须在**测试目录**里,绝不能是生产路径
|
||||
if _, err := os.Stat("/etc/llmsproxy/plugins/.channels-store.json"); err == nil {
|
||||
if filepath.Clean(dir) == "/etc/llmsproxy/plugins" {
|
||||
t.Fatal("测试正在使用生产存储目录 —— 隔离失效(这会造成真实数据事故)")
|
||||
}
|
||||
}
|
||||
|
||||
// 连续做一组"读-改-写"(模拟并发操作最终落在同一份文档上)
|
||||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||||
"id": "atomic1", "style": "google", "credential": `{"access_token":"a"}`}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"a", "b", "c"} {
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "atomic1", "account_id": id}); err != nil {
|
||||
t.Fatalf("add %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
// 删 a、删 b(参考的缺陷场景)
|
||||
for _, id := range []string{"a", "b"} {
|
||||
if _, err := ps.Call("channels", "account_delete", map[string]interface{}{
|
||||
"platform": "atomic1", "account_id": id}); err != nil {
|
||||
t.Fatalf("delete %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
// 再加一个(参考的第二个缺陷场景:与删除交错的写)
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "atomic1", "account_id": "d"}); err != nil {
|
||||
t.Fatalf("add d: %v", err)
|
||||
}
|
||||
|
||||
// 内存视图
|
||||
mem, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "atomic1"})
|
||||
mj, _ := mem.(map[string]interface{})["json"].(map[string]interface{})
|
||||
memIDs := map[string]bool{}
|
||||
for _, a := range asArrInterface(mj["accounts"]) {
|
||||
if am, ok := a.(map[string]interface{}); ok {
|
||||
if id, ok := am["id"].(string); ok {
|
||||
memIDs[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 磁盘视图:必须是**合法 JSON**(tmp+rename 的原子性保证:绝无半截文件)
|
||||
b, err := os.ReadFile(storePath)
|
||||
if err != nil {
|
||||
t.Fatalf("读存储: %v", err)
|
||||
}
|
||||
var disk map[string]interface{}
|
||||
if err := json.Unmarshal(b, &disk); err != nil {
|
||||
t.Fatalf("磁盘文档不是合法 JSON(原子替换失效,出现半截文件): %v", err)
|
||||
}
|
||||
// 文档形状:{version, state:{pool, accounts, ...}} —— pool 在 state 之下。
|
||||
st, _ := disk["state"].(map[string]interface{})
|
||||
if st == nil {
|
||||
t.Fatalf("磁盘文档缺 state 段,实得 %v", disk)
|
||||
}
|
||||
pool, _ := st["pool"].(map[string]interface{})
|
||||
diskIDs := map[string]bool{}
|
||||
if pl, ok := pool["atomic1"].(map[string]interface{}); ok {
|
||||
for id := range pl {
|
||||
diskIDs[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ① 已删除的账号**不得**在磁盘上复活(参考的第一个缺陷形态)
|
||||
for _, gone := range []string{"a", "b"} {
|
||||
if diskIDs[gone] {
|
||||
t.Errorf("已删除的账号 %q 在磁盘上复活了(参考的并发写竞态形态)", gone)
|
||||
}
|
||||
if memIDs[gone] {
|
||||
t.Errorf("已删除的账号 %q 仍在内存视图里", gone)
|
||||
}
|
||||
}
|
||||
// ② 新增的账号**不得**被静默丢失(参考的第二个缺陷形态)
|
||||
if !diskIDs["d"] {
|
||||
t.Error("新增账号 d 在磁盘上缺失(被静默丢失 —— 参考的竞态形态)")
|
||||
}
|
||||
// ③ 内存与磁盘必须一致(分叉本身就是缺陷)
|
||||
for id := range memIDs {
|
||||
if !diskIDs[id] {
|
||||
t.Errorf("账号 %q 在内存里有、磁盘上没有(内存/磁盘分叉)", id)
|
||||
}
|
||||
}
|
||||
for id := range diskIDs {
|
||||
if !memIDs[id] {
|
||||
t.Errorf("账号 %q 在磁盘上有、内存里没有(内存/磁盘分叉)", id)
|
||||
}
|
||||
}
|
||||
_ = c
|
||||
}
|
||||
230
internal/core/channels_trae_catalog_test.go
Normal file
230
internal/core/channels_trae_catalog_test.go
Normal file
@ -0,0 +1,230 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 的 batch 模型目录解析:两个**真实缺陷**必须在移植时一起带上
|
||||
// (参考 parseTraeBatchModelList 的注释记录了它们)。
|
||||
//
|
||||
// ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7):
|
||||
//
|
||||
// 22 个 function 里有一半在本插件推理端点下不可调用(实测 chat→4023
|
||||
// model unknown、builder→4001、inline_chat→3003)。旧实现只把白名单用于
|
||||
// "同模型择优",于是"目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,
|
||||
// 且错误指向**模型**,极易被误判成"这个模型坏了"。
|
||||
//
|
||||
// ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7):
|
||||
//
|
||||
// 旧实现无条件"后覆盖前",假设(后面的配置更全)与真实数据**相反** ——
|
||||
// 上游把空档位的 solo_work_lite 排在最后,实测 13 个模型因此丢掉
|
||||
// reasoning_effort_config,全部显示"未提供推理等级"。
|
||||
func TestTraeBatchModelCatalogAdmissionAndReasoningProvenance(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var gotBody string
|
||||
h := newTraeCatalogHarness(t, func(body string) {
|
||||
mu.Lock()
|
||||
gotBody = body
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
ids := catalogIDs(t, h, "trae")
|
||||
joined := strings.Join(ids, ",")
|
||||
|
||||
// ① 白名单准入:不可调用通道里的模型必须**被丢弃**
|
||||
if strings.Contains(joined, "only-in-bad-channel") {
|
||||
t.Fatalf("不可调用通道(chat/builder/inline_chat)里的模型必须整组丢弃,"+
|
||||
"否则用户选中它必然失败且错误指向模型:%v", ids)
|
||||
}
|
||||
// 但通用通道里的模型要在
|
||||
if !strings.Contains(joined, "glm-5.2") {
|
||||
t.Fatalf("可调用通道里的模型不该被丢:%v", ids)
|
||||
}
|
||||
|
||||
// ② 空档位不得覆盖有档位:同一模型在 solo_agent(有档位)与
|
||||
// solo_work_lite(空档位,**排在后面**)都出现 ⇒ 必须保留有档位那条。
|
||||
// 判据看**报出的档位**,不是"哪条被选中"这类内部信号。
|
||||
if !h.modelHasReasoningOptions("glm-5.2") {
|
||||
t.Fatalf("glm-5.2 的思考档位被空档位条目覆盖了 —— 上游把空档位的"+
|
||||
"solo_work_lite 排在最后,无条件后覆盖前会让模型显示"+
|
||||
"「未提供推理等级」(issue IKI7WT/IKILR7):%v", ids)
|
||||
}
|
||||
|
||||
// ③ 请求体必须传**全部 22 个 function**(与真实 IDE 一致)——
|
||||
// 只传聊天通道会让非聊天通道条目在响应里位置错乱甚至被跳过。
|
||||
mu.Lock()
|
||||
b := gotBody
|
||||
mu.Unlock()
|
||||
if b == "" {
|
||||
t.Fatal("未捕获到目录请求体")
|
||||
}
|
||||
for _, want := range []string{"ui_builder_v2", "inline_chat", "system_diagnosis", "show_custom_model"} {
|
||||
if !strings.Contains(b, want) {
|
||||
t.Fatalf("目录请求体必须与真实 IDE 一致传全部 function(缺 %q):%s", want, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 四条硬性过滤:usage / config_switch / is_invisible_to_user / 必须有 function。
|
||||
func TestTraeBatchModelCatalogHardFilters(t *testing.T) {
|
||||
h := newTraeCatalogHarness(t, nil)
|
||||
ids := catalogIDs(t, h, "trae")
|
||||
joined := strings.Join(ids, ",")
|
||||
for _, bad := range []string{"summary-model", "disabled-model", "hidden-model", "custom-flag-model"} {
|
||||
if strings.Contains(joined, bad) {
|
||||
t.Errorf("%s 应被硬性过滤掉(usage/switch/invisible):%v", bad, ids)
|
||||
}
|
||||
}
|
||||
// ⚠ 判据是**标志的值**,不是模型名:名字带 custom_model_ 前缀但标志为 false
|
||||
// 的条目必须保留(参考实测:曾据此把 qwen3.8-flash 误记为应剔除,它其实可用)。
|
||||
if !strings.Contains(joined, "custom_model_prefix_but_ok") {
|
||||
t.Errorf("is_custom_model=false 的条目必须保留(判据是标志值而非模型名):%v", ids)
|
||||
}
|
||||
// ⚠ 我第一版把 no-function-model 放进 solo_agent 的 config_info_list 里,
|
||||
// 却断言它应被丢弃 —— 那是**夹具写错**:它在合法组内,保留才是对的。
|
||||
// 「未声明 function」指的是**整个 function_configs 组**没有 function 字段。
|
||||
if !strings.Contains(joined, "no-function-model") {
|
||||
t.Errorf("合法组内的条目不该被丢:%v", ids)
|
||||
}
|
||||
t.Logf("✓ 过滤后目录: %v", ids)
|
||||
}
|
||||
|
||||
// ── harness:假上游返回一份 batch_get_detail_param 形态的目录 ──
|
||||
|
||||
type traeCatalogHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarness {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if onBody != nil {
|
||||
onBody(string(b))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 三个通道:
|
||||
// solo_agent(**可调用**,白名单内)—— glm-5.2 **有**档位
|
||||
// solo_work_lite(白名单内,**排在后面**)—— glm-5.2 **空档位**
|
||||
// inline_chat(**白名单外**)—— 只列出 only-in-bad-channel
|
||||
fmt.Fprint(w, `{"function_configs":[`+
|
||||
// ① solo_agent:glm-5.2 有档位;另有三个必被硬性过滤的条目
|
||||
`{"function":"solo_agent","config_info_list":[`+
|
||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true,`+
|
||||
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}},`+
|
||||
`{"config_name":"summary-model","usage":"summary","config_switch":true},`+
|
||||
`{"config_name":"disabled-model","usage":"chat_completion","config_switch":false},`+
|
||||
`{"config_name":"hidden-model","usage":"chat_completion","config_switch":true,"is_invisible_to_user":true},`+
|
||||
`{"config_name":"no-function-model","usage":"chat_completion","config_switch":true},`+
|
||||
// is_custom_model=true ⇒ 上游以流内 4001 拒绝("仅可见但不可调用")
|
||||
`{"config_name":"custom-flag-model","usage":"chat_completion","config_switch":true,`+
|
||||
`"display_config":{"is_custom_model":true}},`+
|
||||
// 对照组:名字带 custom_model_ 前缀但**标志为 false** ⇒ 必须**保留**
|
||||
// (参考点名:判据是标志的值,不是模型名)
|
||||
`{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+
|
||||
`"display_config":{"is_custom_model":false}}`+
|
||||
`]},`+
|
||||
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置
|
||||
`{"function":"solo_work_lite","config_info_list":[`+
|
||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true}`+
|
||||
`]},`+
|
||||
// ③ 白名单外通道:整组必须被丢弃
|
||||
`{"function":"inline_chat","config_info_list":[`+
|
||||
`{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+
|
||||
`]}`+
|
||||
`]}`)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
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)
|
||||
}
|
||||
h := &traeCatalogHarness{t: t, ps: ps}
|
||||
// 把目录端点指到假上游
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"models_remote": map[string]interface{}{"endpoint0": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 塞一个 TRAE 账号(目录请求需要凭据)
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"account_id": "cat-trae",
|
||||
"credential": `{"access_token":"AT","uid":"u1","refresh_token":"RT"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func catalogIDs(t *testing.T, h *traeCatalogHarness, platform string) []string {
|
||||
t.Helper()
|
||||
if _, err := h.ps.Call("channels", "model_refresh",
|
||||
map[string]interface{}{"platform": platform}); err != nil {
|
||||
t.Fatalf("model_refresh: %v", err)
|
||||
}
|
||||
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": platform})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(t, res)
|
||||
var out []string
|
||||
arr, _ := j["models"].([]interface{})
|
||||
for _, e := range arr {
|
||||
if m, ok := e.(map[string]interface{}); ok {
|
||||
if id, ok := m["id"].(string); ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// modelHasReasoningOptions 查某模型是否被报出思考档位(走 model_list 的字段)。
|
||||
func (h *traeCatalogHarness) modelHasReasoningOptions(modelID string) bool {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(h.t, res)
|
||||
arr, _ := j["models"].([]interface{})
|
||||
for _, e := range arr {
|
||||
m, ok := e.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["id"] != modelID {
|
||||
continue
|
||||
}
|
||||
// 档位的真实出口:model_list 把远端目录解出的档位放在
|
||||
// `remote.efforts`(并镜像到顶层 efforts),另有 reasoning=true。
|
||||
for _, k := range []string{"efforts", "reasoning_options", "reasoningOptions"} {
|
||||
if v, ok := m[k].([]interface{}); ok && len(v) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if rm, ok := m["remote"].(map[string]interface{}); ok {
|
||||
if v, ok := rm["efforts"].([]interface{}); ok && len(v) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
117
internal/core/channels_trae_chat_headers_test.go
Normal file
117
internal/core/channels_trae_chat_headers_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 的**推理请求**必须用 Cloud-IDE-JWT 头族,不是 Bearer。
|
||||
//
|
||||
// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17):
|
||||
//
|
||||
// Doubao-Seed-2.1-Pro / seed-code-pro-0430 / glm-5-turbo 全部
|
||||
// 401「We're sorry, but we are not able to authenticate you」
|
||||
// 因为 channels-trae 适配器**只有 4 个 transform、没有 build_headers**
|
||||
// ⇒ 内核兜底发 `Authorization: Bearer <api_key>`。
|
||||
//
|
||||
// 参考 trae.ts 端点表:对话也是 Cloud-IDE-JWT(traeSOLOHeaders)。
|
||||
//
|
||||
// ★ 这与「额度不可用」是**同一根因的两个出口**:额度端点(checkin_status /
|
||||
//
|
||||
// balance)漏了头、已修;推理端点(本判据)也漏了,但当时没一起修。
|
||||
// ⇒ 教训:修「某一族请求的鉴权」时必须把所有**该族的出口**列出来逐个对齐。
|
||||
func TestTraeChatHeadersUseCloudIDEJWT(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.Fatalf("加载插件: %v", err)
|
||||
}
|
||||
|
||||
// 走真实的 BuildHeaders(内核调用适配器的那条路径)
|
||||
hdrs, err := c.VM().BuildHeaders("channels-trae", map[string]interface{}{
|
||||
"api_key": "ACCESS-TOKEN-X",
|
||||
"source": map[string]interface{}{
|
||||
"name": "channels-trae-test",
|
||||
"meta": map[string]interface{}{
|
||||
"uid": "3428695215186675",
|
||||
"device_id": "dev-abc",
|
||||
"machine_id": "mach-xyz",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
auth := hdrs["Authorization"]
|
||||
if !strings.HasPrefix(auth, "Cloud-IDE-JWT ") {
|
||||
t.Fatalf("★ 推理请求鉴权必须是 Cloud-IDE-JWT,实得 %q\n"+
|
||||
" 用 Bearer 会得到 401「not able to authenticate you」,"+
|
||||
"表现为「channel 注册的模型全都不能用」", auth)
|
||||
}
|
||||
if !strings.Contains(auth, "ACCESS-TOKEN-X") {
|
||||
t.Fatalf("Authorization 应携带 access_token,实得 %q", auth)
|
||||
}
|
||||
// 设备身份头必须来自 meta(适配器沙箱读不到凭据)
|
||||
for k, want := range map[string]string{
|
||||
"X-Uid": "3428695215186675",
|
||||
"X-Device-Id": "dev-abc",
|
||||
"X-Machine-Id": "mach-xyz",
|
||||
} {
|
||||
if got := hdrs[k]; got != want {
|
||||
t.Fatalf("★ %s 应为 %q(来自源 meta),实得 %q", k, want, got)
|
||||
}
|
||||
}
|
||||
// 参考 traeSOLOHeaders 的成对 token 头
|
||||
for _, k := range []string{"X-Cloudide-Token", "X-Ide-Token"} {
|
||||
if hdrs[k] != "ACCESS-TOKEN-X" {
|
||||
t.Fatalf("%s 应与 access_token 相同,实得 %q", k, hdrs[k])
|
||||
}
|
||||
}
|
||||
t.Logf("✓ trae 推理头: %s(X-Uid=%s)", auth, hdrs["X-Uid"])
|
||||
}
|
||||
|
||||
// 配对判据:源 meta 缺失时不得崩、也要给出 Cloud-IDE-JWT(只是没有设备头)。
|
||||
func TestTraeChatHeadersTolerateMissingMeta(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)
|
||||
}
|
||||
hdrs, err := c.VM().BuildHeaders("channels-trae", map[string]interface{}{
|
||||
"api_key": "T",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders 不该因缺 meta 报错: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(hdrs["Authorization"], "Cloud-IDE-JWT ") {
|
||||
t.Fatalf("即使没有 meta 也必须是 Cloud-IDE-JWT,实得 %q", hdrs["Authorization"])
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 源必须携带设备身份(meta),否则适配器拿不到 uid。
|
||||
func TestTraeSourceCarriesDeviceIdentityInMeta(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
if !strings.Contains(s, "source_meta_from_credential") {
|
||||
t.Fatal("trae 平台应声明 source_meta_from_credential=true —— " +
|
||||
"否则源的 meta 为空、适配器无法派生 X-Uid / X-Device-Id")
|
||||
}
|
||||
if !strings.Contains(s, "src.meta") {
|
||||
t.Fatal("syncAccountSource 应把设备身份写进 src.meta")
|
||||
}
|
||||
}
|
||||
|
||||
var _ = lua.HandlerRequest{}
|
||||
71
internal/core/channels_trae_credits_hdr_test.go
Normal file
71
internal/core/channels_trae_credits_hdr_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ TRAE 的**签到状态 / 积分余额**必须用 Cloud-IDE-JWT + 设备身份头,
|
||||
// 不能用默认的 `Authorization: Bearer`。
|
||||
//
|
||||
// 真实缺陷(用户报「显示额度不可用」,2026-10-17):
|
||||
//
|
||||
// 同一账号**签到领取成功**(claim_fn 走 traceCheckinHeaders),
|
||||
// 但 credits/status 返回
|
||||
// "We're sorry, but we are not able to authenticate you..."
|
||||
// 因为 checkin_status / balance 既没 auth 也没 headers_fn
|
||||
// ⇒ fetchAccountJSON 退回默认 Bearer。
|
||||
//
|
||||
// 参考 trae.ts 端点表逐行写明这三个端点都是 Cloud-IDE-JWT。
|
||||
//
|
||||
// ⚠️ 只加 `auth = "cloud-ide-jwt"` **没用**:该字段只被 claim_fn 的绑定循环
|
||||
//
|
||||
// 读取(line ~9033),fetchAccountJSON 只认 headers_fn —— 第一版我就只加了
|
||||
// 声明,等于没改。
|
||||
//
|
||||
// ⚠️ 为什么不用 httptest 假上游断言:那需要 set_endpoints 改 URL + 凭据落到
|
||||
//
|
||||
// 池账号 ref + 路径匹配全部对齐,任一环节错都会让"没发请求"看起来像
|
||||
// "头不对"(我在这上面耗了三轮,全是夹具问题)。判据直接咬**被测对象**:
|
||||
// spec.headers_fn 的产出。
|
||||
func TestTraeCreditsEndpointsUseCloudIDEJWT(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.Fatalf("加载插件: %v", err)
|
||||
}
|
||||
|
||||
// 通过真实函数取 trae 的两个 spec 的 headers_fn 产出。
|
||||
// 凭据带 uid ⇒ 设备身份头由 uid 确定性派生。
|
||||
out, err := ps.Call("channels", "__cred_headers_probe", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"credential": `{"access_token":"ACCESS-X","uid":"3428695215186675",` +
|
||||
`"device_id":"d1","machine_id":"m1"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("__cred_headers_probe: %v", err)
|
||||
}
|
||||
j := jsonOf(t, out)
|
||||
for _, which := range []string{"checkin_status", "balance"} {
|
||||
h, ok := j[which].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针应回报 %s 的头,实得 %v", which, j)
|
||||
}
|
||||
auth, _ := h["Authorization"].(string)
|
||||
if !strings.HasPrefix(auth, "Cloud-IDE-JWT ") {
|
||||
t.Fatalf("★ %s 的鉴权必须是 Cloud-IDE-JWT,实得 %q\n"+
|
||||
" 用默认 Bearer 会得到「not able to authenticate you」,"+
|
||||
"而同一账号的签到领取却是成功的 —— 这个矛盾就是缺陷", which, auth)
|
||||
}
|
||||
for _, k := range []string{"X-Device-Id", "X-Market-User-Id", "X-User-Region"} {
|
||||
if v, _ := h[k].(string); v == "" {
|
||||
t.Fatalf("★ %s 缺 %s —— 参考实测简化头会失败", which, k)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ %s: %s (device=%v)", which, auth, h["X-Device-Id"])
|
||||
}
|
||||
}
|
||||
124
internal/core/channels_trae_exchange_shape_test.go
Normal file
124
internal/core/channels_trae_exchange_shape_test.go
Normal file
@ -0,0 +1,124 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE ExchangeToken 的凭据在 **Result 里**,不在顶层。
|
||||
//
|
||||
// 真实缺陷(用户报「trae 登录还是不行」,2026-10-17):
|
||||
//
|
||||
// 会话落定为 state=failed,message = "TRAE ExchangeToken 响应缺少 Token"
|
||||
// 而实测上游 HTTP 200,body 是:
|
||||
// {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}}
|
||||
// 代码读 `rec.Token` ⇒ 恒 nil ⇒ 误报「缺少 Token」。
|
||||
// 参考 parseTraeExchangeResponse 明确先取 `data.Result ?? data.result`。
|
||||
//
|
||||
// ★★ 为什么此前所有 trae 判据都是绿的(这个缺陷藏了很久的原因):
|
||||
//
|
||||
// **测试夹具返回的是扁平 `{"Token":...}`** —— 与生产形状不一致,
|
||||
// 于是「只读顶层」这个错误在夹具里恰好是对的。夹具形状错 = 判据必然虚假通过。
|
||||
// 已把夹具改成真实 Result 形状(channels_trae_test.go)。
|
||||
//
|
||||
// 本判据直接用**两种形状**分别打一次,确保实现同时认 Result 与顶层兜底。
|
||||
func TestTraeExchangeReadsResultEnvelope(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "生产形状:凭据在 Result 里",
|
||||
body: `{"ResponseMetadata":{"RequestId":"x"},"Result":{"Token":"ACCESS-FROM-RESULT",` +
|
||||
`"RefreshToken":"RT-ROTATED","TokenExpireAt":1800000000000}}`,
|
||||
want: "ACCESS-FROM-RESULT",
|
||||
},
|
||||
{
|
||||
name: "兜底:凭据在顶层(上游若改版)",
|
||||
body: `{"Token":"ACCESS-FROM-TOP","RefreshToken":"RT-TOP"}`,
|
||||
want: "ACCESS-FROM-TOP",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// 覆盖夹具的响应体为这个形状
|
||||
atomic.StoreInt32(&h.exchOK, 1)
|
||||
h.overrideExchangeBody(tc.body)
|
||||
|
||||
// ★ 必须走**池路径**(UI 真实流程):先建占位账号再带 account_id
|
||||
// 发登录。beginAndCallback 不带 account_id ⇒ 凭据落到
|
||||
// `channels:trae`(无池账号),也就无法回读 —— 我第一版就这么写,
|
||||
// 判据报"池里应有账号,实得 0",与真因(形状)无关。
|
||||
aid, nonce := h.addAccountAndStartLogin("trae")
|
||||
status, body := h.invokeCallback(map[string]string{
|
||||
"state": nonce, "refreshToken": "RT-FROM-CALLBACK",
|
||||
"userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`,
|
||||
})
|
||||
if status != 200 {
|
||||
t.Fatalf("回调应成功:status=%d body=%s", status, body)
|
||||
}
|
||||
if strings.Contains(body, "缺少 Token") {
|
||||
t.Fatalf("★ 又没解出 Token(形状=%s)—— 检查是否只读了顶层", tc.name)
|
||||
}
|
||||
// 凭据必须真的落进 secrets,且新 refresh 被轮换保存。
|
||||
// ⚠️ 账号 id 是**随机生成**的(account_add 用 newToken():sub(1,12)),
|
||||
// 不能硬编码 —— 用 account_list 拿真实 id。
|
||||
cred := h.credentialFor(aid)
|
||||
if !strings.Contains(cred, tc.want) {
|
||||
t.Fatalf("落盘凭据里应有 access token %q,实得 %q", tc.want, cred)
|
||||
}
|
||||
if !strings.Contains(cred, "refresh_token") {
|
||||
t.Fatalf("★ 轮换后的 refreshToken 必须保存 —— 旧 refresh 已被上游作废,"+
|
||||
"不存新值会让下次续期必然失败。实得 %q", cred)
|
||||
}
|
||||
t.Logf("✓ %s → access=%s", tc.name, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// overrideExchangeBody 让夹具的 ExchangeToken 返回指定 body。
|
||||
func (h *traeHarness) overrideExchangeBody(body string) {
|
||||
h.t.Helper()
|
||||
h.exchBody = body
|
||||
}
|
||||
|
||||
// credentialFor 读回某账号落盘的凭据原文。
|
||||
//
|
||||
// 用 Plugins.GetSecret(内核级读取)而不是 __read_cred_probe:
|
||||
// 探针那版我传错过入参(它吃 ref),返回 len:0,让判据看起来像"凭据没落盘"。
|
||||
// GetSecret 是 TestTraeCallbackPoolPath 已在用的正规入口。
|
||||
// addAccountAndStartLogin 建占位账号并带 account_id 发起登录,返回 (账号id, nonce)。
|
||||
// 这是 UI 真实流程(见 TestTraeCallbackPoolPath)。
|
||||
func (h *traeHarness) addAccountAndStartLogin(platform string) (string, string) {
|
||||
h.t.Helper()
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":%q}`, platform),
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
aid, _ := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":%q,"account_id":%q}`, platform, aid),
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
nonce, _ := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
|
||||
return aid, nonce
|
||||
}
|
||||
|
||||
func (h *traeHarness) credentialFor(accountID string) string {
|
||||
h.t.Helper()
|
||||
v, ok, err := h.ps.GetSecret("channels", "channels:trae:"+accountID)
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
200
internal/core/channels_trae_pkce_test.go
Normal file
200
internal/core/channels_trae_pkce_test.go
Normal file
@ -0,0 +1,200 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 回调只带 code(PKCE 形态)时必须**明确报出**「暂不支持 PKCE」,
|
||||
// 而不是去调 ExchangeToken 换一个不存在的码。
|
||||
//
|
||||
// 真实缺陷(用户报「登录后显示失败」):
|
||||
// poll_login 的 trae 分支曾自己发明了一段「授权码交换」——把 {code} 塞进
|
||||
// exchange_body 再打 ExchangeToken。但:
|
||||
//
|
||||
// · ExchangeToken 的签名是 RefreshToken,不是 code;
|
||||
// · exchange_body 里根本没有 {code} 占位符,那次替换是空操作;
|
||||
// · client_id 当时是空串。
|
||||
//
|
||||
// 于是稳定得到 400,日志回一句误导性的「Trae 授权码交换失败: 400」,
|
||||
// 把真因「上游走了 PKCE 流程」掩盖成「交换出错了」——排查方向完全错。
|
||||
//
|
||||
// 参考实现(trae-oauth.ts parseTraeCallbackDetailed)的做法是:带 code 的回调
|
||||
// 是**合法回调**,只是不支持的 PKCE 分支,必须明确报出来。
|
||||
func TestTraePkceCallbackIsReportedNotExchanged(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// ★ 必须打**真实登录用的那条路径**:poll_login(loopback 回调文件)。
|
||||
// oauth_callback 那条一直是对的,判据打在那里会漏掉缺陷 —— 我第一版就
|
||||
// 犯了这个错(变异测试揭穿:注入缺陷后那条判据仍绿)。
|
||||
//
|
||||
// poll_login 从 plugin.state.loopbacks[sessionId].file 读 {code,state},
|
||||
// 故判据直接摆好那个文件再调 poll_login。
|
||||
status, body := h.beginAndPollWithCallback(map[string]string{
|
||||
"code": "PKCE_AUTH_CODE_XYZ",
|
||||
})
|
||||
|
||||
// poll_login 的契约:终态用 HTTP 200 + json.state 表达(不是非 200 状态码)。
|
||||
// 所以「失败」的判据是 state=failed,而不是 status != 200 —— 我第一版把
|
||||
// 前者写成了后者,于是把正确的实现判成错。
|
||||
if !strings.Contains(body, "state:failed") {
|
||||
t.Fatalf("PKCE 回调应落定为 failed(poll_login 用 200+state 表达终态):status=%d body=%s",
|
||||
status, body)
|
||||
}
|
||||
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
|
||||
t.Fatalf("不得去调 ExchangeToken —— 那是拿 code 换一个不存在的码(真实缺陷)。"+
|
||||
"实际打了 %d 次", n)
|
||||
}
|
||||
// 错误信息必须指向真因(PKCE),而不是含糊的交换失败
|
||||
blob := body
|
||||
if !strings.Contains(blob, "PKCE") {
|
||||
t.Fatalf("错误信息应点名 PKCE(让排查方向正确),实得 %q", blob)
|
||||
}
|
||||
if strings.Contains(blob, "授权码交换失败") {
|
||||
t.Fatalf("不应再出现误导性的「授权码交换失败」:%q", blob)
|
||||
}
|
||||
t.Logf("✓ PKCE 回调被正确识别(poll_login 路径): %s", strings.TrimSpace(blob))
|
||||
}
|
||||
|
||||
// ★ client_id 必须是实测真值 —— ExchangeToken 的请求体里 ClientID 是必填。
|
||||
// 留空会让真实 token 直传场景的交换稳定 400(夹具自身也校验 ClientID 存在)。
|
||||
func TestTraeExchangeSendsRealClientID(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// 真实回调形态:带 refreshToken(token 直传)⇒ 会走 ExchangeToken。
|
||||
// 夹具的 handler 已校验请求体含 ClientID / RefreshToken:<回调值> / ClientSecret。
|
||||
status, body := h.beginAndCallback(map[string]string{
|
||||
"refreshToken": "RT-FROM-CALLBACK",
|
||||
"userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`,
|
||||
})
|
||||
if status != 200 {
|
||||
t.Fatalf("token 直传回调应成功: status=%d body=%s", status, body)
|
||||
}
|
||||
if n := atomic.LoadInt32(&h.exchHit); n == 0 {
|
||||
t.Fatal("token 直传场景必须真的调用 ExchangeToken")
|
||||
}
|
||||
t.Logf("✓ ExchangeToken 被调用 %d 次,请求体形状正确", atomic.LoadInt32(&h.exchHit))
|
||||
}
|
||||
|
||||
// client_id 的真值必须是实测值 —— 这是「交换必然 400」的两个成因之一。
|
||||
func TestTraeClientIDIsNotBlank(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(b)
|
||||
if !strings.Contains(src, `client_id = "en1oxy7wnw8j9n"`) {
|
||||
t.Fatal("trae 的 client_id 必须是实测真值 en1oxy7wnw8j9n —— " +
|
||||
"留空/占位会让 ExchangeToken 的 ClientID 为空而稳定 400")
|
||||
}
|
||||
}
|
||||
|
||||
// 两条路径(oauth_callback 与 poll_login 的 loopback)必须给**同一个**结论,
|
||||
// 否则修复只覆盖一条、另一条继续漂移(这正是缺陷能藏住的原因)。
|
||||
func TestTraeCallbackPathsAgreeOnPkce(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(b)
|
||||
// ① 不得再出现自己发明的交换(注释里说明历史的那句除外)
|
||||
if strings.Contains(src, `cerr = "Trae 授权码交换失败`) {
|
||||
t.Fatal("已删除的「授权码交换」又回来了 —— 那是拿 code 去 ExchangeToken," +
|
||||
"必然 400 且把真因掩盖成交换失败")
|
||||
}
|
||||
// ② 两条路径都必须交给 traeCallback(它内建 PKCE 判定)
|
||||
if n := strings.Count(src, "traeCallback(plat,"); n < 2 {
|
||||
t.Fatalf("traeCallback 应被两条路径共用(当前 %d 处)—— 只有一条会漂移", n)
|
||||
}
|
||||
// ③ PKCE 判定必须仍在 traeCallback 内
|
||||
if !strings.Contains(src, "本实现暂不支持该流程") {
|
||||
t.Fatal("traeCallback 必须保留 PKCE 明确报错(参考实现的语义)")
|
||||
}
|
||||
}
|
||||
|
||||
// beginAndPollWithCallback 走**真实登录用的那条路径**:
|
||||
// start_login 拿会话 → 摆好 loopback 回调文件(平台重定向的落地)→ poll_login。
|
||||
//
|
||||
// 与 beginAndCallback(打 oauth_callback)的区别很关键:poll_login 才是
|
||||
// callback 流在服务端浏览器(VNC)场景下真正走的那条,真实缺陷就藏在它的
|
||||
// trae 分支里。
|
||||
func (h *traeHarness) beginAndPollWithCallback(cbQuery map[string]string) (int, string) {
|
||||
h.t.Helper()
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j, ok := out.JSON.(map[string]interface{})
|
||||
if !ok {
|
||||
h.t.Fatalf("start_login 返回非对象: %#v", out.JSON)
|
||||
}
|
||||
sess := j["session"].(map[string]interface{})
|
||||
sid, _ := sess["id"].(string)
|
||||
nonce, _ := sess["nonce"].(string)
|
||||
if sid == "" {
|
||||
h.t.Fatalf("会话缺 id: %#v", sess)
|
||||
}
|
||||
|
||||
// 摆好 loopback 回调文件(内容 = 平台重定向带回的 query)
|
||||
rec := map[string]string{"state": nonce}
|
||||
for k, v := range cbQuery {
|
||||
rec[k] = v
|
||||
}
|
||||
b, _ := json.Marshal(rec)
|
||||
cbPath := filepath.Join(h.t.TempDir(), "cb-"+sid+".json")
|
||||
if err := os.WriteFile(cbPath, b, 0600); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
// 告诉插件「这个会话的 loopback 接收器把结果写在这里了」
|
||||
if _, err := h.ps.Call("channels", "__set_loopback_probe", map[string]interface{}{
|
||||
"session_id": sid, "file": cbPath,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("设置 loopback 文件: %v", err)
|
||||
}
|
||||
|
||||
res, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid},
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
|
||||
}
|
||||
|
||||
// ★ PKCE 探测的候选来源必须覆盖参考的 5 个(含 authCodeInfo 为**纯 code 字符串**
|
||||
// 这一形态 —— 参考注释点名,解析不出 JSON 时必须回退到原始值)。
|
||||
//
|
||||
// 反例(我第一版就是错的):用 `q.code or q.authCode or jsonField(...)` 链,
|
||||
// 而 jsonField 取不到时返回**空串**;Lua 里空串是真值 ⇒ 链在第一个空串处短路,
|
||||
// 后面的来源永远取不到。
|
||||
func TestTraePkceDetectsPlainStringAuthCodeInfo(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
q map[string]string
|
||||
}{
|
||||
{"code 参数", map[string]string{"code": "C1"}},
|
||||
{"authCode 参数", map[string]string{"authCode": "C2"}},
|
||||
{"authCodeInfo 是 JSON", map[string]string{"authCodeInfo": `{"code":"C3"}`}},
|
||||
{"authCodeInfo 是 JSON(authCode)", map[string]string{"authCodeInfo": `{"authCode":"C4"}`}},
|
||||
{"authCodeInfo 是纯字符串", map[string]string{"authCodeInfo": "C5"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
status, body := h.beginAndPollWithCallback(tc.q)
|
||||
_ = status
|
||||
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
|
||||
t.Fatalf("PKCE 形态不得去调 ExchangeToken(打了 %d 次)", n)
|
||||
}
|
||||
if !strings.Contains(body, "PKCE") {
|
||||
t.Fatalf("应识别为 PKCE 并明确报出,实得 %q", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
130
internal/core/channels_trae_solo_sse_test.go
Normal file
130
internal/core/channels_trae_solo_sse_test.go
Normal file
@ -0,0 +1,130 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ TRAE 的流式转换必须把 **SOLO 自定义 SSE** 摊平成内核要的 **UnifiedChunk**。
|
||||
//
|
||||
// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17):
|
||||
//
|
||||
// ① 我原先 `return __prelude.openai_stream_chunk(raw_chunk)` —— SOLO 帧形如
|
||||
// {"response":"###","reasoning_content":null,"tool_calls":null}
|
||||
// 没有 `choices` 字段 ⇒ 每帧都解析不出 ⇒ 上游 HTTP 200 + 118 正常帧,
|
||||
// 客户端却得到 `empty stream`。
|
||||
// ② 修了 ① 之后我返回 OpenAI 外壳 `{choices:[{delta:{...}}]}` —— 仍不对:
|
||||
// 内核 `json.Unmarshal(unified, &UnifiedChunk)` 直接解成**扁平结构**,
|
||||
// 外壳字段全被丢弃 ⇒ 91 帧全是空 content。
|
||||
//
|
||||
// ⚠️ 内核只把 `data:` 行交给适配器(`event:` 行在 provider.go 被丢弃),
|
||||
//
|
||||
// 因此判别帧类型只能靠内容特征。
|
||||
func TestTraeSoloSSEToUnifiedChunk(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)
|
||||
}
|
||||
|
||||
// ① 正文帧 → content(**扁平**,不是 choices[].delta)
|
||||
out, err := c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"response":"Hello","reasoning_content":null,"tool_calls":null}`)
|
||||
if err != nil {
|
||||
t.Fatalf("transform: %v", err)
|
||||
}
|
||||
var ck map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("产出不是 JSON: %q", out)
|
||||
}
|
||||
if got, _ := ck["content"].(string); got != "Hello" {
|
||||
t.Fatalf("★ 正文必须摊平到顶层 content,实得 %q(原始产出 %s)\n"+
|
||||
" 返回 {choices:[{delta:...}]} 外壳会被内核丢弃", got, out)
|
||||
}
|
||||
if _, has := ck["choices"]; has {
|
||||
t.Fatalf("★ 不得返回 OpenAI 外壳(choices)—— 内核按 UnifiedChunk 解析,外壳会被忽略: %s", out)
|
||||
}
|
||||
|
||||
// ② 推理帧 → reasoning_content
|
||||
out, err = c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"response":"","reasoning_content":"We","tool_calls":null}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("推理帧产出不是 JSON: %q", out)
|
||||
}
|
||||
if got, _ := ck["reasoning_content"].(string); got != "We" {
|
||||
t.Fatalf("★ 推理必须进 reasoning_content,实得 %q(%s)", got, out)
|
||||
}
|
||||
|
||||
// ③ done 帧
|
||||
out, err = c.VM().Transform("channels-trae", "transform_stream_chunk", `{"finish_reason":"stop"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("done 帧产出不是 JSON: %q", out)
|
||||
}
|
||||
if ck["done"] != true {
|
||||
t.Fatalf("★ done 帧必须 done=true,实得 %s", out)
|
||||
}
|
||||
if ck["finish_reason"] != "stop" {
|
||||
t.Fatalf("finish_reason 应为 stop,实得 %s", out)
|
||||
}
|
||||
|
||||
// ④ usage 帧
|
||||
out, err = c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("usage 帧产出不是 JSON: %q", out)
|
||||
}
|
||||
u, _ := ck["usage"].(map[string]interface{})
|
||||
if u == nil || u["total"] != 15.0 {
|
||||
t.Fatalf("★ usage 必须摊平为 {prompt,completion,total},实得 %s", out)
|
||||
}
|
||||
|
||||
// ⑤ 工具调用:function_call → function 归一化
|
||||
out, err = c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"response":"","reasoning_content":null,"tool_calls":[{"function_call":{"name":"f","arguments":"{}"}}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "function_call") {
|
||||
t.Fatalf("★ SOLO 的 function_call 必须归一化为 function,实得 %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"function"`) {
|
||||
t.Fatalf("工具调用缺 function 字段: %s", out)
|
||||
}
|
||||
t.Logf("✓ SOLO SSE 全部摊平正确")
|
||||
}
|
||||
|
||||
// 配对判据:无关帧不得产出垃圾。
|
||||
func TestTraeSoloSSEDropsUnknownFrames(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)
|
||||
}
|
||||
// metadata 帧(上游确实会发,且无正文)
|
||||
out, err := c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"model":"","session_id":"abc","prompt_completion_id":0}`)
|
||||
if err != nil {
|
||||
t.Fatalf("metadata 帧不该报错: %v", err)
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("无正文的 metadata 帧应产出空串(不制造垃圾帧),实得 %q", out)
|
||||
}
|
||||
}
|
||||
@ -16,11 +16,12 @@ import (
|
||||
// traeHarness:TRAE callback 流的测试台。假上游模拟 ExchangeToken;
|
||||
// 回调用 InvokeHandler 直打 oauth_callback(Query 表 = 平台重定向回来的参数)。
|
||||
type traeHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
exchHit int32
|
||||
exchOK int32 // 1=成功 0=失败(atomic)
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
exchHit int32
|
||||
exchOK int32 // 1=成功 0=失败(atomic)
|
||||
exchBody string // 非空时覆盖夹具的响应体(供形状判据用)
|
||||
}
|
||||
|
||||
func newTraeHarness(t *testing.T) *traeHarness {
|
||||
@ -45,7 +46,23 @@ func newTraeHarness(t *testing.T) *traeHarness {
|
||||
t.Errorf("ExchangeToken 请求体缺 %s: %s", want, body)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, `{"Token":"ACCESS-%d","RefreshToken":"RT-NEW-%d"}`, n, n)
|
||||
// ★★ 必须是**真实上游的形状**:凭据在 `Result` 里,不在顶层。
|
||||
//
|
||||
// 实测(2026-10-17 用真实 refreshToken 直打 ExchangeToken):
|
||||
// {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"...",
|
||||
// "TokenExpireAt":...,"RefreshExpireAt":...}}
|
||||
//
|
||||
// ⚠️ 这条夹具原先返回**扁平** `{"Token":...}` —— 于是「代码只读顶层」
|
||||
// 这个真缺陷被夹具**完美掩盖**:所有 trae 判据都绿,真实登录却报
|
||||
// 「响应缺少 Token」。夹具形状与生产不一致 = 判据必然虚假通过。
|
||||
if h.exchBody != "" {
|
||||
fmt.Fprint(w, h.exchBody)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `{"ResponseMetadata":{"RequestId":"r%d"},`+
|
||||
`"Result":{"ClientID":"en1oxy7wnw8j9n","Token":"ACCESS-%d",`+
|
||||
`"RefreshToken":"RT-NEW-%d","TokenExpireAt":1800000000000,`+
|
||||
`"TokenExpireDuration":1209600000,"RefreshExpireAt":1807755929432}}`, n, n, n)
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
|
||||
67
internal/core/channels_upstream_gaps_test.go
Normal file
67
internal/core/channels_upstream_gaps_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 本文件把「上游客观缺口」写成**可执行的判据**,而不是只留在文档里。
|
||||
//
|
||||
// 为什么需要:这些能力缺口多次被误判成"我们漏实现了"(我自己就误判了两轮),
|
||||
// 于是反复派人去"补"。判据在此把事实钉住:
|
||||
//
|
||||
// - qoder 加密端点(agent_chat_generation)需要 `qoder-auth-wasm.wasm`
|
||||
// —— 该二进制**在参考仓库里也不存在**(参考自己也是 readFileSync 一个
|
||||
// 它不提供的文件)。故我们走明文端点 `/model/v1/chat/completions`,
|
||||
// **不经过信封** ⇒ qoder-envelope 的解包能力在明文路径上无从生效。
|
||||
// - TRAE 的 PKCE 分支参考**明确不支持**(只有"探测并拒绝",无 code→token
|
||||
// 交换端点)⇒ 我们报"暂不支持"是与参考对齐的正确行为。
|
||||
//
|
||||
// 判据的作用是**防止误判**:任何人(包括未来的我)想"补"这些能力时,
|
||||
// 必须先让这个测试失败 —— 也就是先推翻事实,再动手。
|
||||
func TestUpstreamGapsAreDocumented(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
t.Run("qoder 走明文端点且不经过信封", func(t *testing.T) {
|
||||
// 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH。
|
||||
// ⚠ 断言 **URL 的两半**而不是那一整串字面量:本仓已把"路径从 base_url
|
||||
// 移到 endpoint"(内核 URL = base_url + endpoint)—— 继续钉死整串会让
|
||||
// 一个纯粹的形态重构把这条**语义**判据弄红,那是判据在咬自己。
|
||||
if !strings.Contains(s, `base_url = "https://api2-v2.qoder.sh"`) ||
|
||||
!strings.Contains(s, `endpoint = "/model/v1/chat/completions"`) {
|
||||
t.Fatal("qoder 的 chat 端点变了 —— 若改成加密端点 agent_chat_generation," +
|
||||
"则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名," +
|
||||
"否则响应会被当成畸形数据")
|
||||
}
|
||||
// 明文端点没有信封层,故解包**不该**存在(存在反而是死代码)
|
||||
if strings.Contains(s, "unwrapQoderEnvelope") {
|
||||
t.Fatal("出现了 qoder 信封解包 —— 明文端点没有信封层。" +
|
||||
"要么端点已换成加密端点(那需要 wasm),要么这是死代码")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TRAE PKCE 明确报不支持(与参考一致)", func(t *testing.T) {
|
||||
if !strings.Contains(s, "本实现暂不支持该流程") {
|
||||
t.Fatal("TRAE 的 PKCE 分支必须明确报出「暂不支持」—— " +
|
||||
"参考 trae-oauth.ts 也只做探测+拒绝,没有 code→token 交换端点。" +
|
||||
"若改为尝试交换,会拿 code 去打要 RefreshToken 的 ExchangeToken(必然 400)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("codearts 目录打两个签名端点(本轮补上)", func(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"opengw.developer.huaweicloud.com/api/v1/gateway/config",
|
||||
"snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin",
|
||||
`sign = "codearts"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("codearts 目录缺 %q —— 参考 fetchCodeArtsRemoteModels 打两个"+
|
||||
"**必须 AK/SK 签名**的端点,只打一个会少一半模型", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
38
internal/core/channels_utf8clip_test.go
Normal file
38
internal/core/channels_utf8clip_test.go
Normal file
@ -0,0 +1,38 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 行为验证(不只是静态断言):utf8Clip 对中文截断后必须是合法 UTF-8。
|
||||
func TestUtf8ClipBehaviour(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)
|
||||
}
|
||||
|
||||
s := "能力元数据源 5.6MB 超过内核 4MiB 响应上限(刻意的堆保护,不为此放宽)—— 能力增强不可用,已退回兜底表"
|
||||
for _, n := range []int{10, 40, 119, 120, 121, 400} {
|
||||
out, err := ps.Call("channels", "__utf8_clip_probe", map[string]interface{}{"s": s, "n": n})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := jsonOf(t, out)["out"].(string)
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("n=%d 结果不是合法 UTF-8: %q", n, got)
|
||||
}
|
||||
if n < len(s) && len(got) > n {
|
||||
t.Errorf("n=%d 结果超出字节上限: %d", n, len(got))
|
||||
}
|
||||
if n >= len(s) && got != s {
|
||||
t.Errorf("n=%d 未超长时应原样返回", n)
|
||||
}
|
||||
t.Logf("n=%-4d → %q (valid=%v, bytes=%d)", n, got, utf8.ValidString(got), len(got))
|
||||
}
|
||||
}
|
||||
59
internal/core/channels_vnc_gpu_test.go
Normal file
59
internal/core/channels_vnc_gpu_test.go
Normal file
@ -0,0 +1,59 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// VNC 一直白屏的真因(2026-10-14 定位)。
|
||||
//
|
||||
// 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium
|
||||
// **独立 GPU 进程**启动所需的系统调用 ⇒ 它起不来 ⇒ chromium 放弃并退出:
|
||||
//
|
||||
// ERROR:gpu_process_host.cc] GPU process launch failed: error_code=1002
|
||||
// FATAL:gpu_data_manager_impl_private.cc] GPU process isn't usable. Goodbye.
|
||||
//
|
||||
// 整个 chromium 几秒内退出 ⇒ **VNC 上永远白屏**,而插件因为只等 CDP 端口就报
|
||||
// 「已就绪」,把白屏谎报成成功。
|
||||
//
|
||||
// 为什么长期没发现:手工在**沙箱外**跑同一条命令每次都成功。定位靠
|
||||
// `systemd-run` 逐 directive 二分 —— 只有 SystemCallFilter 与 ProtectSystem
|
||||
// 能复现,其余加固项无影响。
|
||||
//
|
||||
// 修法:`--in-process-gpu`(GPU 逻辑跑在浏览器进程内,不 fork)。
|
||||
func TestBrowserStackUsesInProcessGPU(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ① 必须带 --in-process-gpu,否则在加固环境下浏览器起不来
|
||||
if !strings.Contains(b, "--in-process-gpu") {
|
||||
t.Fatal("chromium 命令行必须带 --in-process-gpu —— 否则在 " +
|
||||
"SystemCallFilter=@system-service 的加固环境下 GPU 进程起不来、" +
|
||||
"浏览器自杀、VNC 永远白屏(真实事故,2026-10-14)")
|
||||
}
|
||||
// ② 必须给可写的 HOME/XDG:ProtectHome=yes 下 /root 只读,
|
||||
// chromium 建 dconf/ibus 目录会持续刷 permission denied 噪声
|
||||
for _, k := range []string{"XDG_CACHE_HOME=", "XDG_CONFIG_HOME="} {
|
||||
if !strings.Contains(b, k) {
|
||||
t.Errorf("chromium 命令行应设置 %s(加固环境下 /root 只读会产生噪声日志)", k)
|
||||
}
|
||||
}
|
||||
// ③ **存活探针**必须存在:端口在听 ≠ 浏览器可用。
|
||||
// 没有它时,"浏览器已死"会被谎报成"已就绪",运维打开 VNC 是白屏,
|
||||
// 还会以为是自己操作的问题。谎报比直接报错更坏。
|
||||
if !strings.Contains(b, "browserUtil.cdpAlive") {
|
||||
t.Error("起栈后必须做 CDP 存活探针 —— 只等端口就报就绪会把白屏谎报成成功")
|
||||
}
|
||||
if !strings.Contains(b, "browserUtil.readTail") {
|
||||
t.Error("启动失败必须把日志真因带给用户,而不是一句「启动失败」")
|
||||
}
|
||||
// ④ 失败必须回收整组进程(否则每次失败都留一套 Xvfb/x11vnc/websockify)
|
||||
idx := strings.Index(b, "浏览器启动后即退出")
|
||||
if idx < 0 {
|
||||
t.Fatal("应有「浏览器启动后即退出」的失败分支")
|
||||
}
|
||||
if !strings.Contains(b[idx:idx+600], "reapBrowser()") {
|
||||
t.Error("起栈失败必须先回收已起的进程(否则泄漏一套 Xvfb/x11vnc/websockify)")
|
||||
}
|
||||
}
|
||||
70
internal/core/channels_vnc_lifecycle_test.go
Normal file
70
internal/core/channels_vnc_lifecycle_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// VNC 会话生命周期的**前向引用**判据(真实 bug 的守卫)。
|
||||
//
|
||||
// `sweepSessions` 里调用了 reapLoopback / browserStack / reapBrowser,而这三个
|
||||
// 助手定义在**更后面**。Lua 的 `local` 只从定义处向后可见 ⇒ 未前向声明时它们是
|
||||
// **全局 nil**:加载期不报错,只有真正走到"会话过期回收"那条分支才炸:
|
||||
//
|
||||
// attempt to call global 'reapLoopback' (a nil value)
|
||||
//
|
||||
// 症状极具迷惑性:**第一次登录完全正常**(没有过期会话可回收),第二次才 500,
|
||||
// 且已经起来的浏览器栈**不会被回收**(reapBrowser 同样调不到)⇒ 进程与端口泄漏。
|
||||
// 本文件此前已因同一个坑踩过 12 次,故这里钉住"三者都必须有前向声明"。
|
||||
func TestSweepSessionsHelpersAreForwardDeclared(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ① 必须存在对这三个助手的前向声明
|
||||
if !strings.Contains(b, "local reapLoopback, browserStack, reapBrowser") {
|
||||
t.Fatal("sweepSessions 用到的三个助手必须**前向声明** —— 否则 " +
|
||||
"`local` 只从定义处向后可见,运行到该分支时是 nil 调用(第一次登录" +
|
||||
"正常、第二次 500,且浏览器栈不会被回收)")
|
||||
}
|
||||
// ② 定义处必须是**赋值**形态(绑定到那个 local),不能又是 `local function`
|
||||
// —— 那会遮蔽前向声明,效果与没声明完全相同。
|
||||
for _, fn := range []string{"browserStack", "reapBrowser", "reapLoopback"} {
|
||||
if strings.Contains(b, "local function "+fn+"(") {
|
||||
t.Errorf("%s 的定必须是 `%s = function(...)` 形式(绑定前向声明);"+
|
||||
"再用 `local function` 会另起一个 local,前向声明形同虚设", fn, fn)
|
||||
}
|
||||
if !strings.Contains(b, "\n"+fn+" = function(") {
|
||||
t.Errorf("找不到 %s 的赋值式定义", fn)
|
||||
}
|
||||
}
|
||||
// ③ 声明必须**先于**使用(行号比较):这是前向引用的核心
|
||||
decl := strings.Index(b, "local reapLoopback, browserStack, reapBrowser")
|
||||
use := strings.Index(b, "\n reapLoopback(id)\n")
|
||||
if decl < 0 || use < 0 {
|
||||
t.Fatalf("找不到声明(%d)或使用(%d)", decl, use)
|
||||
}
|
||||
if decl > use {
|
||||
t.Error("前向声明必须在**使用之前** —— 否则 nil 调用")
|
||||
}
|
||||
}
|
||||
|
||||
// 浏览器栈的**启动后存活**必须有真实判据(谎报"就绪"比直接报错更坏)。
|
||||
func TestBrowserStackVerifiesLivenessNotJustPort(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// CDP 真应答(端口在听 ≠ 服务可用)
|
||||
if !strings.Contains(b, "browserUtil.cdpAlive") {
|
||||
t.Error("必须用 CDP 真应答判活,不能只看端口")
|
||||
}
|
||||
// 进程真存活
|
||||
if !strings.Contains(b, "/proc/") {
|
||||
t.Error("必须检查 chromium 进程仍存活(/proc/<pid>)")
|
||||
}
|
||||
// 失败要把日志真因带出来(而不是一句"启动失败")
|
||||
if !strings.Contains(b, "browserUtil.readTail") {
|
||||
t.Error("启动失败必须带出日志真因")
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@ -53,14 +54,43 @@ func TestChannelsVncStackEndToEnd(t *testing.T) {
|
||||
t.Fatalf("浏览器栈未能启动: %s", e)
|
||||
}
|
||||
view, _ := m["view_url"].(string)
|
||||
if !strings.Contains(view, "vnc.html") {
|
||||
t.Fatalf("应给出 noVNC 内嵌地址, 实得 %q", view)
|
||||
if !strings.HasPrefix(view, "/api/plugins/http/channels/browser/proxy/") || !strings.Contains(view, "vnc.html") {
|
||||
t.Fatalf("应给出同源 noVNC 内嵌地址,不能让远程客户端连服务器回环端口, 实得 %q", view)
|
||||
}
|
||||
sess := m["session"].(map[string]interface{})
|
||||
if sess["cdp_port"] == nil {
|
||||
t.Fatal("会话应带 CDP 端口(取凭据用)")
|
||||
}
|
||||
t.Logf("VNC 栈已起: %s (CDP %v)", view, sess["cdp_port"])
|
||||
|
||||
// ★ noVNC 用 `new URL(path, location.href)` 解析 path(ui.js 的 connect()),
|
||||
// 即**相对 vnc.html 所在目录**解析,默认值就是相对名 'websockify'。
|
||||
// 真实缺陷:传了站点根路径 ⇒ 路径被重复拼接,WebSocket 永远连不上,
|
||||
// 页面看似白屏:
|
||||
// .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify
|
||||
// 本判据必须解析**产品自己给出的 URL**:我曾在验证时手拼 URL 并省掉
|
||||
// path 参数(默认值恰好正确)⇒ 否定了真实缺陷。
|
||||
u, perr := url.Parse(view)
|
||||
if perr != nil {
|
||||
t.Fatalf("view_url 不可解析: %v", perr)
|
||||
}
|
||||
q := u.Query()
|
||||
wspath := q.Get("path")
|
||||
if wspath == "" {
|
||||
t.Fatal("view_url 必须显式声明 path=websockify:依赖 noVNC 隐式默认值" +
|
||||
"会在 URL 形状变化时静默破裂")
|
||||
}
|
||||
if strings.HasPrefix(wspath, "/") {
|
||||
t.Fatalf("path 必须是相对名(noVNC 相对 vnc.html 目录解析),"+
|
||||
"以 / 开头会被当相对路径而重复拼接,实得 %q", wspath)
|
||||
}
|
||||
resolved := u.ResolveReference(&url.URL{Path: wspath})
|
||||
pages := strings.TrimSuffix(u.Path, "/vnc.html")
|
||||
if resolved.Path != pages+"/websockify" {
|
||||
t.Fatalf("path 解析后应为 %q(同级 websockify),实得 %q —— 重复拼接即白屏根因",
|
||||
pages+"/websockify", resolved.Path)
|
||||
}
|
||||
|
||||
t.Logf("VNC 栈已起: %s → WebSocket %s (CDP %v)", view, resolved.Path, sess["cdp_port"])
|
||||
|
||||
// 收尾:回收浏览器栈(测试不留孤儿进程)
|
||||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
|
||||
@ -27,7 +27,19 @@ func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
|
||||
const ghost = "999999" // 必然不存在的 PID
|
||||
// 造 3 个陈旧锁,并要求插件**复用**其中一个。
|
||||
// 只跳过不复用的话,号段会被逐个烧掉 —— 实测 41/41 占满后每次必失败。
|
||||
ghosts := []int{91, 92, 93}
|
||||
//
|
||||
// ⚠️ 号段必须**跟着测试台的 XDISPLAY_BASE 走**(不是硬编码 91/92/93)。
|
||||
// 原因:生产 unit 带 PrivateTmp,它起的 Xvfb :91 的锁在服务自己的 /tmp 里,
|
||||
// 宿主测试看不到 ⇒ 测试会去抢 91 并失败。测试台因此把起点挪到 300
|
||||
// (见 kernelTestCore);硬编码 90 段会让本判据造出**根本不会被扫描到**的
|
||||
// 陈旧锁,于是"没复用"这个断言永远失败 —— 那是判据与被测对象脱节。
|
||||
base := 300
|
||||
if v := os.Getenv("LLMSPROXY_XDISPLAY_BASE"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
base = n
|
||||
}
|
||||
}
|
||||
ghosts := []int{base, base + 1, base + 2}
|
||||
for _, n := range ghosts {
|
||||
l := "/tmp/.X" + strconv.Itoa(n) + "-lock"
|
||||
if _, err := os.Stat(l); err == nil {
|
||||
@ -38,8 +50,8 @@ func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
|
||||
}
|
||||
defer os.Remove(l)
|
||||
}
|
||||
lock := "/tmp/.X91-lock"
|
||||
sock := "/tmp/.X11-unix/X91"
|
||||
lock := "/tmp/.X" + strconv.Itoa(base) + "-lock"
|
||||
sock := "/tmp/.X11-unix/X" + strconv.Itoa(base)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
|
||||
285
internal/core/channels_zcode_3012_test.go
Normal file
285
internal/core/channels_zcode_3012_test.go
Normal file
@ -0,0 +1,285 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ ZCode 3012 的判据必须是**多形态**的,且误判不是"文案不好看"——
|
||||
// 领取路径会据此**短路**(跳过本可领取的 plan),而限流与风控的用户动作
|
||||
// **完全相反**(稍后重试 vs 别重试)。
|
||||
//
|
||||
// 本判据逐行搬自参考 zcode-diagnostics.ts 的对抗性审计实测矩阵
|
||||
// (2026-10-05 自审 + 审计打出的真实缺陷)。
|
||||
//
|
||||
// 为什么用**共用模块**验:这段逻辑被领取路径(插件主体)与推理路径
|
||||
// (channels-zcode 适配器)共用,任一侧漂移都会让"请勿连续重试"只出现在
|
||||
// 一条路径上。判据直接打共用模块的导出,两边用的是同一份源码文本。
|
||||
func TestZcode3012ClassificationMatrix(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
// ── 真风控(必须命中)──
|
||||
{"上游真实文案(带码)", `{"code":3012,"msg":"request has been blocked due to unusual activity."}`, true},
|
||||
{"码是纯数字字符串", `{"code":"3012","msg":"x"}`, true},
|
||||
{"仅正文语义(无数字)", `request has been blocked due to unusual activity.`, true},
|
||||
{"3012 与 block 紧邻", `3012 blocked by waf`, true},
|
||||
|
||||
// ── 必须**不**命中(误判会让用户做错事)──
|
||||
{"13012 不是 3012", `{"code":13012}`, false},
|
||||
{"标识符 plan-3012-trust", `plan-3012-trust`, false},
|
||||
{"标识符 trust_3012", `trust_3012`, false},
|
||||
{"字母包裹 x3012y", `x3012y`, false},
|
||||
{"限流的毫秒数", `too many requests, retry after 3012 ms`, false},
|
||||
{"HTML 里的 3012", `<h1>Error 3012</h1>upstream connect timeout`, false},
|
||||
{"限流+block 距 14", `rate limited: request blocked, retry after 3012 ms`, false},
|
||||
{"block 距 12", `temporarily blocked; see error 3012`, false},
|
||||
{"额度码 1005", `{"code":1005,"msg":"quota"}`, false},
|
||||
|
||||
// ── 隔离「词边界」规则:这些用例同时满足"block 紧邻",
|
||||
// 只有词边界能区分 ⇒ 裸子串实现会把前三个判成风控。
|
||||
// (最初的矩阵没覆盖这一组,变异测试因此没咬住 —— 补上后才有效。)
|
||||
{"标识符+block 紧邻", `plan-3012 blocked`, false},
|
||||
{"字母包裹+block 紧邻", `x3012 blocked`, false},
|
||||
{"下划线+block 紧邻", `trust_3012 blocked`, false},
|
||||
{"独立词+block 紧邻", `3012 blocked`, true},
|
||||
{"13012 里的片段+block 紧邻", `13012 blocked`, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := h.callZD("hasUnusualActivity", tc.body)
|
||||
if got != tc.want {
|
||||
t.Errorf("hasUnusualActivity(%q) = %v, want %v\n"+
|
||||
"(误判的后果不是文案问题:领取路径会据此短路,且限流='稍后重试'、\n"+
|
||||
" 风控='别重试',两者用户动作完全相反)", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 时间差格式化必须逐档单独算:参考初版把分钟档写成 `floor(seconds % 60)`,
|
||||
// 于是 90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)。
|
||||
// 该错误是在**真实探针输出**里发现的,纯函数单测当时没覆盖 ≥60 秒的区间。
|
||||
func TestZcodeFormatGapDoesNotMisrenderMinutes(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
for _, tc := range []struct {
|
||||
ms int
|
||||
want string
|
||||
}{
|
||||
{90000, "1分30秒"}, // 参考踩过的坑:错法会得到 "30分30秒"
|
||||
{59000, "59.0s"}, // 不足一分钟
|
||||
{60000, "1分"}, // 整分钟
|
||||
{3600000, "1小时"}, // 整小时
|
||||
{3900000, "1小时5分"}, // 小时+分
|
||||
{500, "500ms"},
|
||||
} {
|
||||
got := h.callZDString("formatGap", tc.ms)
|
||||
if got != tc.want {
|
||||
t.Errorf("formatGap(%d) = %q, want %q", tc.ms, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 诊断行**绝不能含凭据片段**(参考的设计红线之一)。
|
||||
//
|
||||
// 账号只以"进程内自增序号"出现。判据用「传入的账号 id 的每个子串都不得出现在
|
||||
// 文案里」来钉死这条 —— 不是"看起来没有"。
|
||||
func TestZcodeDiagnosticLeaksNoCredentials(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
const secretID = "zcode-abcdef1234567890-SECRET"
|
||||
h.callZDAction("accountOrdinal", secretID)
|
||||
h.callZDAction2("noteRequestSent", secretID, 1000)
|
||||
h.callZDAction2("noteRequestOk", secretID, 1100)
|
||||
|
||||
diag := h.callZDString("formatDiagnostic", map[string]interface{}{
|
||||
"accountId": secretID,
|
||||
"sentAt": 1200,
|
||||
"now": 1300,
|
||||
"status": 405,
|
||||
})
|
||||
if diag == "" {
|
||||
t.Fatal("formatDiagnostic 返回空")
|
||||
}
|
||||
// 账号 id 的每个 >=4 字符子串都不得出现
|
||||
for i := 0; i+4 <= len(secretID); i++ {
|
||||
if strings.Contains(diag, secretID[i:i+4]) {
|
||||
t.Fatalf("诊断行泄漏账号 id 片段 %q:%s", secretID[i:i+4], diag)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(diag, "账号#") {
|
||||
t.Fatalf("诊断行应含「账号#序号」形态:%s", diag)
|
||||
}
|
||||
t.Logf("✓ 诊断行无凭据片段: %s", diag)
|
||||
}
|
||||
|
||||
// ★ 3012 的文案必须**共用**(推理 + 领取两条路径),且必须含"请勿连续重试"。
|
||||
//
|
||||
// 3012 不是"重试就能过"(那是 3007),而是**有账号冷却惩罚**:
|
||||
// 30 分钟起步,反复触发升级到 24 小时乃至停用。
|
||||
func TestZcode3012MessageIsSharedAndWarnsAboutRetries(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
msg := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{
|
||||
"subject": "领取 plan-x",
|
||||
"rawResponse": "request has been blocked",
|
||||
})
|
||||
for _, want := range []string{"3012", "请勿连续重试", "冷却惩罚", "领取 plan-x"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("风控文案缺 %q:%s", want, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 空 subject/diagnostic/rawResponse 时不得留下空壳("本机诊断:"后面啥都没有)
|
||||
bare := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{})
|
||||
if strings.Contains(bare, "本机诊断:\n") || strings.Contains(bare, "原始响应:\n") {
|
||||
t.Fatalf("空参数不应留下空壳段:%q", bare)
|
||||
}
|
||||
t.Logf("✓ 文案: %s", strings.SplitN(msg, "\n", 2)[0])
|
||||
}
|
||||
|
||||
// ★ 请求体形态诊断必须读**真正要发出去的对象**,而不是常量。
|
||||
//
|
||||
// 常量只能证明"源码里写了多少字",证明不了"线上发出去多少字"。
|
||||
// 两者一旦不一致(例如某条改动把 system 顶掉了),只有读实际值才看得出来
|
||||
// —— 它会**如实显示 0 字符**,而不是拿常量里的 2898 把问题盖住。
|
||||
func TestZcodeRequestShapeReadsActualObject(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
// 用真实的适配器产出 → 再诊断它(而不是手搓一个假 system)
|
||||
out, err := h.transformRequest(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := jsonUnmarshal(out, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shape := h.callZDMap("describeRequestShape", req["system"], req["messages"])
|
||||
if shape == nil {
|
||||
t.Fatal("describeRequestShape 返回空")
|
||||
}
|
||||
chars, _ := shape["identityChars"].(float64)
|
||||
if chars < 2800 {
|
||||
t.Fatalf("身份块实测字符数 %v < 2800(诊断必须读实际值)", chars)
|
||||
}
|
||||
if shape["hasDateBlock"] != true {
|
||||
t.Fatalf("应检测到日期块,实得 %v", shape["hasDateBlock"])
|
||||
}
|
||||
if shape["firstRole"] != "user" {
|
||||
t.Fatalf("firstRole 应为 user,实得 %v", shape["firstRole"])
|
||||
}
|
||||
t.Logf("✓ 形态诊断: 块数=%v 身份块=%v 日期块=%v role=%v",
|
||||
shape["systemBlocks"], chars, shape["hasDateBlock"], shape["firstRole"])
|
||||
|
||||
// 反例:空 system ⇒ 必须**如实显示 0**(不能被常量盖住)
|
||||
empty := h.callZDMap("describeRequestShape", []interface{}{}, req["messages"])
|
||||
if c, _ := empty["identityChars"].(float64); c != 0 {
|
||||
t.Fatalf("空 system 时 identityChars 必须为 0(如实反映实际值),实得 %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// ── harness 助手:经共用的探针打**那一份** ZD 实现 ──
|
||||
//
|
||||
// 走探针而不是让测试自己 load 一份副本:判据必须打真实被两条路径共用的实现,
|
||||
// 自己 load 副本会退化成"各自测各自的",共用一旦漂移就测不出来。
|
||||
|
||||
func (h *zcodeIdentityHarness) callZD(fn string, a interface{}) bool {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
m := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if e, ok := m["error"]; ok {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, e)
|
||||
}
|
||||
v, ok := m["result"].(bool)
|
||||
if !ok {
|
||||
h.t.Fatalf("%s 返回非布尔: %#v", fn, m["result"])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (h *zcodeIdentityHarness) callZDString(fn string, a interface{}) string {
|
||||
h.t.Helper()
|
||||
var res interface{}
|
||||
var err error
|
||||
if opts, ok := a.(map[string]interface{}); ok {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "opts": opts})
|
||||
} else if fn == "formatGap" {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
} else {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
}
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
m := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if e, ok := m["error"]; ok {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, e)
|
||||
}
|
||||
s, _ := m["result"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// callZDAction / callZDAction2:调"动作型"探针(不返回结果,只看没报错)。
|
||||
func (h *zcodeIdentityHarness) callZDAction(fn string, a interface{}) {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func (h *zcodeIdentityHarness) callZDAction2(fn string, a interface{}, a2 int) {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a, "a2": a2})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func (h *zcodeIdentityHarness) callZDMap(fn string, args ...interface{}) map[string]interface{} {
|
||||
h.t.Helper()
|
||||
var res interface{}
|
||||
var err error
|
||||
if len(args) == 2 {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "system": args[0], "messages": args[1]})
|
||||
} else {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": args[0]})
|
||||
}
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
m := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if e, ok := m["error"]; ok {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, e)
|
||||
}
|
||||
out, _ := m["result"].(map[string]interface{})
|
||||
return out
|
||||
}
|
||||
|
||||
// jsonUnmarshal 是本文件用的小助手(避免与其它测试文件的导入冲突)。
|
||||
func jsonUnmarshal(s string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(s), v)
|
||||
}
|
||||
411
internal/core/channels_zcode_identity_test.go
Normal file
411
internal/core/channels_zcode_identity_test.go
Normal file
@ -0,0 +1,411 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ ZCode 的身份块是 3012 准入的**唯一开关**。
|
||||
//
|
||||
// 参考实测(三个账号、17 次请求):system 缺官方身份块结构时,上游直接
|
||||
//
|
||||
// HTTP 405 + {"code":3012,"msg":"...unusual activity."}
|
||||
//
|
||||
// 且与 HTTP 头、版本号、请求频率、多轮历史、tools 声明、运行时**全部无关**。
|
||||
//
|
||||
// 判据矩阵(照参考,逐条锁定):
|
||||
//
|
||||
// 无 system → 3012
|
||||
// 仅 cliPrefix(42 字符) → 3012
|
||||
// cliPrefix + stable(约 2898 字符)→ 200
|
||||
//
|
||||
// ⇒ 判据是「身份块**结构**是否存在」,不是字符数凑够。
|
||||
//
|
||||
// 本判据看**适配器真正产出给上游的请求体**(transform_request 的返回值),
|
||||
// 而不是"适配器装了没"这类内部信号。
|
||||
func TestZcodeAdapterInjectsOfficialIdentityBlocks(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
out, err := h.transformRequest(`{"model":"glm-5.3-flash","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_request: %v", err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatalf("产出不是 JSON: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// ① system 必须是**块数组**(官方形态),不是纯字符串
|
||||
sys, ok := req["system"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("system 必须是块数组(上游检查的是这个结构),实得 %T: %v",
|
||||
req["system"], req["system"])
|
||||
}
|
||||
if len(sys) < 3 {
|
||||
t.Fatalf("system 块数至少 3(cliPrefix + stable + Environment),实得 %d", len(sys))
|
||||
}
|
||||
|
||||
// ② block[0] 必须是官方 CLI 前缀(准入必需)
|
||||
b0, _ := sys[0].(map[string]interface{})
|
||||
if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" {
|
||||
t.Fatalf("block[0] 必须是官方 CLI 前缀(逐字),实得 %q", got)
|
||||
}
|
||||
|
||||
// ③ block[1] 必须是官方 stable 段(准入必需),长度对得上
|
||||
b1, _ := sys[1].(map[string]interface{})
|
||||
stable, _ := b1["text"].(string)
|
||||
if len(stable) < 2800 {
|
||||
t.Fatalf("block[1](stable 段)长度 %d < 2800 —— 身份块不完整会被判为裸请求(3012)",
|
||||
len(stable))
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"You are an interactive ZCode agent",
|
||||
"# Harness",
|
||||
"# ZCode Desktop Context",
|
||||
} {
|
||||
if !strings.Contains(stable, marker) {
|
||||
t.Fatalf("stable 段缺 %q —— 身份块被截断/改写", marker)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ block[2] 是 Environment 段(官方每会话都发;不发则模型不知 cwd/平台)
|
||||
b2, _ := sys[2].(map[string]interface{})
|
||||
env, _ := b2["text"].(string)
|
||||
if !strings.HasPrefix(env, "# Environment") {
|
||||
t.Fatalf("block[2] 必须是 Environment 段,实得 %q", env[:zcodeMin(40, len(env))])
|
||||
}
|
||||
for _, marker := range []string{"Primary working directory", "Platform", "powered by the model named"} {
|
||||
if !strings.Contains(env, marker) {
|
||||
t.Fatalf("Environment 段缺 %q:%s", marker, env)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 总长度必须达到参考声明的量级(2898)
|
||||
total := len("You are ZCode, an interactive coding agent") + len(stable)
|
||||
if total < 2800 {
|
||||
t.Fatalf("身份块合计 %d 字符 < 2800(参考实测的准入值约 2898)", total)
|
||||
}
|
||||
|
||||
// ⑥ 日期块:插到第一条 user 消息的 content **数组**最前(官方形态)
|
||||
msgs, _ := req["messages"].([]interface{})
|
||||
if len(msgs) == 0 {
|
||||
t.Fatal("messages 为空")
|
||||
}
|
||||
m0, _ := msgs[0].(map[string]interface{})
|
||||
content, ok := m0["content"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("首条 user 的 content 必须是**块数组**(拼进字符串会改变结构、仍被判裸请求),实得 %T",
|
||||
m0["content"])
|
||||
}
|
||||
c0, _ := content[0].(map[string]interface{})
|
||||
if got, _ := c0["text"].(string); !strings.Contains(got, "<system-reminder>") {
|
||||
t.Fatalf("content[0] 必须是日期块(<system-reminder>),实得 %q", got)
|
||||
}
|
||||
|
||||
t.Logf("✓ 身份块注入正确:块数=%d stable=%d 合计=%d", len(sys), len(stable), total)
|
||||
}
|
||||
|
||||
// ★ 调用方的 system 必须**追加在最后**:上游检查看的是**前缀结构**,
|
||||
// 官方身份块必须处在开头位置。若被前置会让身份块不在前缀而吃 3012。
|
||||
func TestZcodeCallerSystemIsAppendedLast(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
out, err := h.transformRequest(
|
||||
`{"model":"glm-5.3-flash","messages":[{"role":"system","content":"CALLER_PROMPT_XYZ"},{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sys, _ := req["system"].([]interface{})
|
||||
if len(sys) < 4 {
|
||||
t.Fatalf("带调用方 system 时应为 4 块(3 官方 + 1 调用方),实得 %d", len(sys))
|
||||
}
|
||||
last, _ := sys[len(sys)-1].(map[string]interface{})
|
||||
lt, _ := last["text"].(string)
|
||||
if !strings.Contains(lt, "CALLER_PROMPT_XYZ") {
|
||||
t.Fatalf("调用方 system 必须在**最后一块**(上游看前缀结构;前置会让身份块不在前缀)")
|
||||
}
|
||||
// 且前三块仍是官方块(身份块没有被调用方内容挤走)
|
||||
b0, _ := sys[0].(map[string]interface{})
|
||||
if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" {
|
||||
t.Fatalf("block[0] 必须仍是官方 CLI 前缀,实得 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 适配器自检出口:身份块长度与 anthropic 基座装载状态。
|
||||
// 「装了但身份块是空的」这类问题(转义/截断/基座读不到)没有它只能靠猜。
|
||||
func TestZcodeAdapterProbeReportsIdentityLength(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
zc, ok := j["zcode"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针未报告 zcode 适配器装载结果: %v", j)
|
||||
}
|
||||
if zc["ok"] != true {
|
||||
t.Fatalf("zcode 适配器装载失败: %v", zc["err"])
|
||||
}
|
||||
}
|
||||
|
||||
// zcodeIdentityHarness:加载插件(它会**自装** channels-zcode 适配器),
|
||||
// 然后直接调那个适配器的 transform_request。
|
||||
//
|
||||
// 直接调已装载的适配器(而不是自己去 load 源码)更接近真实路径:判据验的是
|
||||
// 「上游实际收到的请求体」,包含前导注入、桥的装载等全部环节。
|
||||
type zcodeIdentityHarness struct {
|
||||
t *testing.T
|
||||
c *Core
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newZcodeIdentityHarness(t *testing.T) *zcodeIdentityHarness {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
return &zcodeIdentityHarness{t: t, c: c, ps: ps}
|
||||
}
|
||||
|
||||
// transformRequest 调**已自装的** channels-zcode 适配器的 transform_request。
|
||||
func (h *zcodeIdentityHarness) transformRequest(body string) (string, error) {
|
||||
h.t.Helper()
|
||||
vm := h.c.VM()
|
||||
if vm == nil {
|
||||
h.t.Fatal("VM 不可用")
|
||||
}
|
||||
return vm.Transform("channels-zcode", "transform_request", body)
|
||||
}
|
||||
|
||||
func zcodeMin(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ★ 身份块注入不得破坏协议转换:工具调用、图像、流式帧仍必须走 anthropic 那一份。
|
||||
//
|
||||
// 为什么必须验:本适配器的手法是把 anthropic.lua 的源码读进来执行、只覆盖
|
||||
// transform_request。若「读不到基座」或「覆盖时丢了字段」,协议转换会静默退化
|
||||
// —— 表现为工具调用参数丢失、图像块消失,而身份块看起来完全正常。
|
||||
func TestZcodeAdapterKeepsAnthropicProtocolConversion(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
t.Run("工具定义与工具调用存活", func(t *testing.T) {
|
||||
in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":"go"}],` +
|
||||
`"tools":[{"type":"function","function":{"name":"get_weather",` +
|
||||
`"description":"d","parameters":{"type":"object","properties":{"c":{"type":"string"}}}}}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// anthropic 形态:tools[].name / input_schema(顶层,不是嵌套在 function 里)
|
||||
if !strings.Contains(out, `"get_weather"`) {
|
||||
t.Fatalf("工具定义在转换中丢失(identity 注入不该影响它):%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"input_schema"`) {
|
||||
t.Fatalf("工具 schema 未转成 anthropic 形态(input_schema 缺失):%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("多模态图像块存活", func(t *testing.T) {
|
||||
in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":[` +
|
||||
`{"type":"text","text":"what"},` +
|
||||
`{"type":"image_url","image_url":{"url":"data:image/png;base64,AAAA"}}]}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"image"`) || !strings.Contains(out, `"base64"`) {
|
||||
t.Fatalf("图像块在转换中丢失:%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("基座装载状态可见", func(t *testing.T) {
|
||||
// 读适配器的自检出口:base_loaded 必须为 true(否则身份块虽在、
|
||||
// 协议转换已退化为透传)
|
||||
res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
zc := j["zcode"].(map[string]interface{})
|
||||
if zc["ok"] != true {
|
||||
t.Fatalf("zcode 适配器未装载: %v", zc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ★ 基座(anthropic.lua)读不到时必须在**安装期响亮失败**,不得静默退化。
|
||||
//
|
||||
// 反例危害:若"尽力而为"(只注入身份块、不作协议转换),会产出一个身份块
|
||||
// 看着正确的请求,而 tools/图像/流式结构全未转换 —— 上游可能 200,但模型
|
||||
// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。
|
||||
//
|
||||
// ⚠ 判据随设计更新(v2.0.37):基座**不再由适配器自己读**(适配器沙箱没有
|
||||
//
|
||||
// fs,那样必然失败且静默降级)。现在由**插件主体**读好并内联 ⇒ 基座不可得
|
||||
// 时应在**安装期**就失败(返回 err),而不是等到运行期才发现。
|
||||
func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
|
||||
// 隔离目录:插件目录下没有兄弟 adapters/ 目录 ⇒ 基座读不到
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
|
||||
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)
|
||||
}
|
||||
// 用空目录重新安装:基座不可得 ⇒ 必须**安装期**失败且原因点明 anthropic
|
||||
res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{
|
||||
"adapters_dir": filepath.Join(dir, "adapters-no-base"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
zc, ok := j["zcode_reinstall"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("安装探针应回报 zcode_reinstall 结果,实得 %v", j)
|
||||
}
|
||||
if zc["ok"] == true {
|
||||
t.Fatalf("基座不可得时安装必须失败(否则运行期才炸、且是逐帧炸),实得 %v", zc)
|
||||
}
|
||||
msg, _ := zc["err"].(string)
|
||||
if !strings.Contains(msg, "anthropic") {
|
||||
t.Fatalf("错误信息应点明基座问题(可定位),实得 %q", msg)
|
||||
}
|
||||
t.Logf("✓ 基座缺失时安装期响亮失败: %s", msg)
|
||||
}
|
||||
|
||||
// ★ 日期块必须**幂等**:已以 <system-reminder> 开头则不重复插。
|
||||
//
|
||||
// 不判的后果:同一请求重试/多次 transform 时日期块叠成多层,改变 content 的
|
||||
// 前缀结构(上游检查的正是结构)。参考 withContextPrefix 有 startsWithSystemReminder。
|
||||
func TestZcodeContextPrefixIsIdempotent(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
// 已是插入后的形态(首条 user 的 content[0] 就是 system-reminder)
|
||||
in := `{"model":"m","messages":[{"role":"user","content":[` +
|
||||
`{"type":"text","text":"<system-reminder>\nOLD\n</system-reminder>"},` +
|
||||
`{"type":"text","text":"hi"}]}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := req["messages"].([]interface{})
|
||||
content := msgs[0].(map[string]interface{})["content"].([]interface{})
|
||||
n := 0
|
||||
for _, c := range content {
|
||||
if b, ok := c.(map[string]interface{}); ok {
|
||||
if s, _ := b["text"].(string); strings.Contains(s, "<system-reminder>") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("日期块必须幂等(已存在则不重复插),实得 %d 个 system-reminder 块", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 日期块只处理**第一条**消息,且它必须 role == "user"。
|
||||
//
|
||||
// 反例(我第一版就是这样):往后扫第一个 user 消息 —— 若首条是 assistant,
|
||||
// 官方行为是**不动**,而不是去找后面那条 user。判据钉住这个差别。
|
||||
func TestZcodeContextPrefixOnlyTouchesFirstMessage(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
t.Run("首条不是 user 时不动", func(t *testing.T) {
|
||||
in := `{"model":"m","messages":[` +
|
||||
`{"role":"assistant","content":"opening"},` +
|
||||
`{"role":"user","content":"hi"}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := req["messages"].([]interface{})
|
||||
// ⚠ 注意:文本会被 anthropic 基座转成**块数组**(协议转换的正常行为),
|
||||
// 所以这里不能断言 content 仍是字符串 —— 我第一版就这么断言,把正确的
|
||||
// 实现判成了错。判据要验的是「**日期块**有没有被插进来」这个事实。
|
||||
noReminder := func(content interface{}) bool {
|
||||
switch c := content.(type) {
|
||||
case string:
|
||||
return !strings.Contains(c, "<system-reminder>")
|
||||
case []interface{}:
|
||||
for _, e := range c {
|
||||
if b, ok := e.(map[string]interface{}); ok {
|
||||
if t, _ := b["text"].(string); strings.Contains(t, "<system-reminder>") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
// 首条(assistant)不该被插日期块
|
||||
if !noReminder(msgs[0].(map[string]interface{})["content"]) {
|
||||
t.Fatal("首条是 assistant 时不该插日期块(官方只看第一条且要求 role=user)")
|
||||
}
|
||||
// 后面的 user 也**不该**被插(官方不往后找)
|
||||
if !noReminder(msgs[1].(map[string]interface{})["content"]) {
|
||||
t.Fatal("日期块被插到了后面的 user 消息上 —— 官方只看第一条")
|
||||
}
|
||||
// 且原文本内容必须保留(转换不能丢内容)
|
||||
b, _ := json.Marshal(msgs[0])
|
||||
if !strings.Contains(string(b), "opening") {
|
||||
t.Fatal("首条 assistant 的内容丢了")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("首条是 user 时插入", func(t *testing.T) {
|
||||
in := `{"model":"m","messages":[{"role":"user","content":"hi"}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := req["messages"].([]interface{})
|
||||
content, ok := msgs[0].(map[string]interface{})["content"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("首条 user 的 content 应升级为块数组")
|
||||
}
|
||||
b0 := content[0].(map[string]interface{})
|
||||
if s, _ := b0["text"].(string); !strings.Contains(s, "<system-reminder>") {
|
||||
t.Fatalf("首条 user 的 content[0] 必须是日期块,实得 %q", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -11,7 +11,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@ -150,9 +149,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
if err := c.seedKeys(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.seedAuto(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.rebuildRegistry(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -195,39 +191,6 @@ func (c *Core) migrateFromRuntime() {
|
||||
}
|
||||
}
|
||||
|
||||
// seedAuto uses the existing auto rules from config.yaml, or creates an
|
||||
// initial chain from legacy per-source model priority (first run only).
|
||||
func (c *Core) seedAuto() error {
|
||||
if len(c.cfg.Auto) > 0 {
|
||||
return nil
|
||||
}
|
||||
type item struct {
|
||||
model string
|
||||
prio int
|
||||
}
|
||||
var flat []item
|
||||
for _, s := range c.mergedSources() {
|
||||
for _, m := range s.Models {
|
||||
if m.Kind == "image" {
|
||||
continue
|
||||
}
|
||||
flat = append(flat, item{m.ID, m.Priority})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(flat, func(i, j int) bool {
|
||||
if flat[i].prio != flat[j].prio {
|
||||
return flat[i].prio > flat[j].prio
|
||||
}
|
||||
return flat[i].model < flat[j].model
|
||||
})
|
||||
entries := make([]config.ModelScope, 0, len(flat))
|
||||
for _, it := range flat {
|
||||
entries = append(entries, config.ModelScope{Model: it.model})
|
||||
}
|
||||
c.cfg.Auto = entries
|
||||
return c.cfg.Save()
|
||||
}
|
||||
|
||||
// seedKeys ensures config.yaml has admin keys for every gateway_keys entry
|
||||
// from the YAML config (once). After first save they become GWKey records
|
||||
// in c.cfg.Keys and gateway_keys is no longer read for auth.
|
||||
@ -877,9 +840,23 @@ func (c *Core) Reload() error {
|
||||
// ---- adapter management (web UI) ----
|
||||
|
||||
func (c *Core) ListAdapters() []lua.APIAdapter {
|
||||
c.syncHiddenRefs()
|
||||
return c.vm.ListAdapters()
|
||||
}
|
||||
|
||||
// syncHiddenRefs 把"当前被源引用的适配器"同步给 VM。被引用的 hidden=true 适配器
|
||||
// 必须保持可见(源编辑器要回显当前值);没被引用的隐藏适配器从列表消失。
|
||||
// 每次 ListAdapters 前调用:源集合是动态的(运行时可增删),推模型容易漏。
|
||||
func (c *Core) syncHiddenRefs() {
|
||||
refs := []string{}
|
||||
for _, s := range c.mergedSources() {
|
||||
if s.Adapter != "" {
|
||||
refs = append(refs, s.Adapter)
|
||||
}
|
||||
}
|
||||
c.vm.SetHiddenRefs(refs)
|
||||
}
|
||||
|
||||
func (c *Core) UploadAdapter(name, code string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("adapter name required")
|
||||
|
||||
@ -22,7 +22,7 @@ func newTestConfig(t *testing.T) *config.Config {
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-gw-test"},
|
||||
Sources: []config.Source{
|
||||
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}}},
|
||||
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,6 +29,16 @@ func kernelTestCore(t *testing.T) *Core {
|
||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
}
|
||||
// ★ 显示器号与端口用**高段号**,避开生产。
|
||||
//
|
||||
// 真实事故(2026-10-15):生产 unit 带 `PrivateTmp=yes`,它起的 Xvfb :91 的
|
||||
// 锁文件在**服务自己的 /tmp** 里 —— 宿主上的测试看不到它,于是判"91 空闲"
|
||||
// 并去抢,结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`,而生产上正跑着
|
||||
// 一套浏览器栈。判据本身没错,错的是**两个 /tmp 互不可见**而号段重叠。
|
||||
// 故测试整体挪到 300+。
|
||||
if os.Getenv("LLMSPROXY_XDISPLAY_BASE") == "" {
|
||||
t.Setenv("LLMSPROXY_XDISPLAY_BASE", "300")
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
|
||||
@ -151,7 +151,7 @@ func TestAutoChainForConcurrentWithSourceChanges(t *testing.T) {
|
||||
name := fmt.Sprintf("churn%d", n)
|
||||
_ = c.AddSource(config.Source{
|
||||
Name: name, BaseURL: "http://127.0.0.1:9/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m-" + name, Priority: 10}}},
|
||||
Models: []config.Model{{ID: "m-" + name}}},
|
||||
)
|
||||
_ = c.RemoveSource(name)
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ func perKeyTestConfig(t *testing.T) *config.Config {
|
||||
GatewayKeys: []string{"sk-gw-admin"},
|
||||
Sources: []config.Source{
|
||||
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
|
||||
Models: []config.Model{{ID: "gpt-4o"}, {ID: "gpt-4o-mini"}}},
|
||||
},
|
||||
// Global chain points at gpt-4o.
|
||||
Auto: []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}},
|
||||
@ -198,7 +198,7 @@ func TestKeyAutoChainsRebuildOnSourceChange(t *testing.T) {
|
||||
// Add the source; the per-key chain must pick it up without a restart.
|
||||
cfg.Sources = append(cfg.Sources, config.Source{
|
||||
Name: "s2", BaseURL: "http://127.0.0.1:2/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "new-model", Priority: 10}},
|
||||
Models: []config.Model{{ID: "new-model"}},
|
||||
})
|
||||
if err := c.Reload(); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
|
||||
@ -42,7 +42,7 @@ func writeSealedInstall(t *testing.T, dir string) string {
|
||||
GatewayKeys: []string{"sk-gw-seed-me"},
|
||||
Sources: []config.Source{{
|
||||
Name: "up", BaseURL: "http://127.0.0.1:1/v1", APIKey: "sk-upstream-secret",
|
||||
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}},
|
||||
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}},
|
||||
}},
|
||||
}
|
||||
c, err := NewFromConfig(cfg)
|
||||
@ -76,7 +76,7 @@ func writeSealedInstallNoSeed(t *testing.T, dir string) string {
|
||||
DefaultModel: "AUTO",
|
||||
Sources: []config.Source{{
|
||||
Name: "up", BaseURL: "http://127.0.0.1:1/v1", APIKey: "sk-upstream-secret",
|
||||
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}},
|
||||
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}},
|
||||
}},
|
||||
}
|
||||
c, err := NewFromConfig(cfg)
|
||||
|
||||
@ -33,13 +33,13 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
|
||||
t.Fatalf("read deploy.sh: %v", err)
|
||||
}
|
||||
src := string(fn)
|
||||
i := strings.Index(src, "prune_adapter_backups()")
|
||||
i := strings.Index(src, "prune_backups()")
|
||||
if i < 0 {
|
||||
t.Fatal("prune_adapter_backups not found in deploy.sh")
|
||||
t.Fatal("prune_backups not found in deploy.sh")
|
||||
}
|
||||
j := strings.Index(src[i:], "\n}\n")
|
||||
if j < 0 {
|
||||
t.Fatal("prune_adapter_backups has no closing brace")
|
||||
t.Fatal("prune_backups has no closing brace")
|
||||
}
|
||||
body := src[i : i+j+3]
|
||||
|
||||
@ -47,20 +47,20 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
|
||||
body = strings.Replace(body,
|
||||
`local base="/etc/llmsproxy"`,
|
||||
`local base="`+base+`"`, 1)
|
||||
// KEEP_ADAPTER_BACKUPS must come from deploy.sh itself, NOT from a literal
|
||||
// KEEP_BACKUPS must come from deploy.sh itself, NOT from a literal
|
||||
// here: hardcoding 5 in the shim overrode whatever the script configured, so
|
||||
// a mutation that set KEEP_ADAPTER_BACKUPS=0 in deploy.sh still passed. The
|
||||
// a mutation that set KEEP_BACKUPS=0 in deploy.sh still passed. The
|
||||
// value is part of what is under test.
|
||||
var keepRe = regexp.MustCompile(`(?m)^KEEP_ADAPTER_BACKUPS=(\d+)`)
|
||||
var keepRe = regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
|
||||
keepM := keepRe.FindStringSubmatch(src)
|
||||
if keepM == nil {
|
||||
t.Fatal("deploy.sh no longer sets KEEP_ADAPTER_BACKUPS; the pruning " +
|
||||
t.Fatal("deploy.sh no longer sets KEEP_BACKUPS; the pruning " +
|
||||
"policy would be undefined")
|
||||
}
|
||||
keep := keepM[1]
|
||||
|
||||
script := "warn() { :; }\nlog() { :; }\nKEEP_ADAPTER_BACKUPS=" + keep + "\n" +
|
||||
body + "\nprune_adapter_backups\n"
|
||||
script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" + keep + "\n" +
|
||||
body + "\nprune_backups adapters\n"
|
||||
scriptPath := filepath.Join(base, "fn.sh")
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
@ -88,7 +88,7 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
|
||||
}
|
||||
|
||||
if out, err := exec.Command("bash", scriptPath).CombinedOutput(); err != nil {
|
||||
t.Fatalf("prune_adapter_backups failed: %v\n%s", err, out)
|
||||
t.Fatalf("prune_backups failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(base)
|
||||
@ -102,11 +102,11 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 12 well-formed backups with KEEP_ADAPTER_BACKUPS=5 → the newest FIVE
|
||||
// 12 well-formed backups with KEEP_BACKUPS=5 → the newest FIVE
|
||||
// survive (…08 …09 …10 …11 …12) and the older seven are deleted. Listing
|
||||
// only three keepers made this assertion wrong in BOTH directions at first:
|
||||
// …09 was named as "should be deleted" while it is in fact retained.
|
||||
// KEEP_ADAPTER_BACKUPS=5 is a cap on the TOTAL number of backups, and
|
||||
// KEEP_BACKUPS=5 is a cap on the TOTAL number of backups, and
|
||||
// irregular names are never deleted — so they consume slots rather than
|
||||
// being ignored. With 3 unremovable directories present, only 2 of the
|
||||
// well-formed ones can survive. That is the function's actual (and safe)
|
||||
@ -202,3 +202,123 @@ func TestDeployBacksUpTheLiveFileBeforeOverwriting(t *testing.T) {
|
||||
"captures the new file and rollback restores nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 备份清理必须覆盖 plugins 前缀(真实缺陷)。
|
||||
//
|
||||
// 事实:backup 清理最初只处理 `adapters.bak.*`,而 `sync_plugins` 同样每次部署
|
||||
// 建一个 `plugins.bak.<时间戳>` —— 于是 adapters 的堆积被修好,plugins 继续涨。
|
||||
// 实测线上 plugins.bak 积累到 **36 个**、/etc/llmsproxy 涨到 192M。
|
||||
//
|
||||
// 这是「两处只修一处」的老形状。判据钉住:prune_all_backups 必须同时裁两个前缀,
|
||||
// 且新增任何 .bak.<时间戳> 前缀时都要在这里补上(否则又是一个泄漏点)。
|
||||
func TestDeployPruneCoversBothBackupPrefixes(t *testing.T) {
|
||||
if _, err := os.Stat("../../deploy.sh"); err != nil {
|
||||
t.Skip("deploy.sh not present (packaging-only checkout)")
|
||||
}
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
t.Skip("bash not available")
|
||||
}
|
||||
fn, err := os.ReadFile("../../deploy.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(fn)
|
||||
|
||||
// ① prune_all_backups 必须存在且两个前缀都裁
|
||||
i := strings.Index(src, "prune_all_backups()")
|
||||
if i < 0 {
|
||||
t.Fatal("prune_all_backups 不存在 —— 调用点若仍只裁 adapters," +
|
||||
"plugins.bak 会继续无上限堆积(实测 36 个 / 192M)")
|
||||
}
|
||||
j := strings.Index(src[i:], "\n}\n")
|
||||
if j < 0 {
|
||||
t.Fatal("prune_all_backups 没有收尾大括号")
|
||||
}
|
||||
body := src[i : i+j+3]
|
||||
for _, want := range []string{"prune_backups adapters", "prune_backups plugins"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("prune_all_backups 缺 %q —— plugins.bak 会无上限堆积", want)
|
||||
}
|
||||
}
|
||||
|
||||
// ② ★ 覆盖完整性:脚本里出现的每个 `.bak.<时间戳>` 前缀都必须在裁剪范围内。
|
||||
// 这条比逐字检查两个名字更强 —— 将来有人新增第三种备份目录(比如
|
||||
// runtime.bak.*),这里会立刻报红,而不是等它涨到几百兆。
|
||||
prefixRe := regexp.MustCompile(`([a-z_]+)\.bak\.\$\(date`)
|
||||
found := map[string]bool{}
|
||||
for _, m := range prefixRe.FindAllStringSubmatch(src, -1) {
|
||||
found[m[1]] = true
|
||||
}
|
||||
if len(found) == 0 {
|
||||
t.Fatal("deploy.sh 里没找到任何 .bak.$(date …) 备份目录创建点(判据需更新)")
|
||||
}
|
||||
for prefix := range found {
|
||||
if !strings.Contains(body, "prune_backups "+prefix) {
|
||||
t.Errorf("deploy.sh 会创建 %s.bak.<时间戳> 目录,但 prune_all_backups 不裁它 "+
|
||||
"—— 它就是一个无上限增长的备份泄漏点(实测 plugins.bak 涨到 36 个)", prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 行为验证:用真函数裁 plugins 前缀,最新 5 份必须留下、更旧的删掉
|
||||
base := t.TempDir()
|
||||
k := strings.Index(src, "prune_backups()")
|
||||
kj := strings.Index(src[k:], "\n}\n")
|
||||
fnBody := src[k : k+kj+3]
|
||||
fnBody = strings.Replace(fnBody, `local base="/etc/llmsproxy"`, `local base="`+base+`"`, 1)
|
||||
keepRe := regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
|
||||
km := keepRe.FindStringSubmatch(src)
|
||||
if km == nil {
|
||||
t.Fatal("deploy.sh 不再设置 KEEP_BACKUPS")
|
||||
}
|
||||
script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" +
|
||||
km[1] + "\n" + fnBody + "\nprune_backups plugins\n"
|
||||
sp := filepath.Join(base, "fn.sh")
|
||||
if err := os.WriteFile(sp, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ts := range []string{
|
||||
"20260101120000", "20260201120000", "20260301120000", "20260401120000",
|
||||
"20260501120000", "20260601120000", "20260701120000", "20260801120000",
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Join(base, "plugins.bak."+ts), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// adapters 前缀**不该被动**(防止误删别的前缀)
|
||||
if err := os.MkdirAll(filepath.Join(base, "adapters.bak.20260101120000"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := exec.Command("bash", sp).CombinedOutput(); err != nil {
|
||||
t.Fatalf("prune_backups plugins failed: %v\n%s", err, out)
|
||||
}
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
got[e.Name()] = true
|
||||
}
|
||||
}
|
||||
if !got["plugins.bak.20260801120000"] {
|
||||
t.Error("最新的 plugins 备份被删了;裁剪必须从最旧的一端开始")
|
||||
}
|
||||
if got["plugins.bak.20260101120000"] {
|
||||
t.Error("最旧的 plugins 备份存活;裁剪没生效")
|
||||
}
|
||||
if !got["adapters.bak.20260101120000"] {
|
||||
t.Error("裁 plugins 时误删了 adapters 备份 —— 前缀参数必须严格限定范围")
|
||||
}
|
||||
|
||||
// ④ 未知前缀必须响亮失败(防误用:传空串会删掉整个目录)
|
||||
bad := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" +
|
||||
km[1] + "\n" + fnBody + "\nprune_backups \"\"\n"
|
||||
bp := filepath.Join(base, "bad.sh")
|
||||
if err := os.WriteFile(bp, []byte(bad), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := exec.Command("bash", bp).CombinedOutput(); err == nil {
|
||||
t.Fatalf("prune_backups 接受空前缀(危险:会删整个目录),输出: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@ -535,11 +535,11 @@ type modelCaps struct {
|
||||
}
|
||||
|
||||
type upstreamModel struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Kind string `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
Modality string `json:"modality"`
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Kind string `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
Modality string `json:"modality"`
|
||||
Architecture struct {
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
OutputModalities []string `json:"output_modalities"`
|
||||
@ -699,8 +699,8 @@ func modelMeta(m upstreamModel) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
copyFields(m.Metadata) // nested first
|
||||
copyFields(mustJSON(m)) // top level wins
|
||||
copyFields(m.Metadata) // nested first
|
||||
copyFields(mustJSON(m)) // top level wins
|
||||
if len(meta) == 0 {
|
||||
return nil
|
||||
}
|
||||
@ -740,10 +740,10 @@ func mustJSON(v interface{}) []byte {
|
||||
func modelsToConfig(chat, image []string) []config.Model {
|
||||
out := make([]config.Model, 0, len(chat)+len(image))
|
||||
for _, id := range chat {
|
||||
out = append(out, config.Model{ID: id, Priority: 50, Kind: "chat"})
|
||||
out = append(out, config.Model{ID: id, Kind: "chat"})
|
||||
}
|
||||
for _, id := range image {
|
||||
out = append(out, config.Model{ID: id, Priority: 70, Kind: "image"})
|
||||
out = append(out, config.Model{ID: id, Kind: "image"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@ -61,6 +61,20 @@ type discoverRequest struct {
|
||||
// a typo here would produce a source that 404s on every request.
|
||||
Include []string `json:"include,omitempty"`
|
||||
Exclude []string `json:"exclude,omitempty"`
|
||||
|
||||
// ImageModels names ids that are **kind=image** but whose upstream catalog
|
||||
// does not say so.
|
||||
//
|
||||
// 为什么需要(真实缺口):discoveredModelKind 只看上游的 modality 字段,
|
||||
// 并且刻意**不从模型名推断**(那会把 "gpt-image-1" 这类同名聊天模型
|
||||
// 错归成生图,而错误的 kind 会毒死 AUTO 链)。
|
||||
// 但有些网关的 `/v1/models` 只返回 `{id,object,created,owned_by}` ——
|
||||
// 零 modality(实测:某聚合网关的 3 个 gpt-image-* 全部落成 chat,
|
||||
// 于是生图端点永远报 `no image source configured`)。
|
||||
//
|
||||
// 两条原则都不破:不猜(默认仍完全按元数据),但允许**显式声明**。
|
||||
// 只影响写入的 kind,不影响目录里有没有这个模型。
|
||||
ImageModels []string `json:"image_models,omitempty"`
|
||||
}
|
||||
|
||||
// discoverResponse is the JSON the WebUI binds to.
|
||||
@ -117,8 +131,36 @@ func (g *Gateway) handleSourceDiscoverAPI(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
req.BaseURL = strings.TrimSpace(req.BaseURL)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
|
||||
// ★ 只给 `name`(或两者都给但 key 为空)⇒ **从已存源取 base_url 与凭据**。
|
||||
//
|
||||
// 为什么必须有这条路径(真实痛点):刷新一个**已存在**源的模型目录,此前
|
||||
// 必须让运维把明文 key 再粘一次 —— 而库里的 key 是 `enc:v1:` 密文,
|
||||
// 运维手里未必还有原文;WebUI 也不回显明文(这是正确的安全设计)。
|
||||
// 结果就是「想刷新目录 → 必须重新申请/粘贴一次 key」,很多人干脆不刷新,
|
||||
// 于是源的模型目录越放越旧(实例:某源的 3 个生图模型从未被登记)。
|
||||
//
|
||||
// 安全边界:① 已要求 admin 角色;② 只能探测**本网关已配置的**源,
|
||||
// 不能借此拿任意 URL + 任意 key 做探测(那样就成了 SSRF/密钥外带渠道);
|
||||
// ③ 响应不回显 key(只回目录)。
|
||||
if req.Name != "" {
|
||||
if old := g.sourceByName(req.Name); old != nil {
|
||||
if req.BaseURL == "" {
|
||||
req.BaseURL = old.BaseURL
|
||||
}
|
||||
if req.APIKey == "" {
|
||||
req.APIKey = old.APIKey
|
||||
}
|
||||
if req.Adapter == "" {
|
||||
req.Adapter = old.Adapter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.BaseURL == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "base_url required")
|
||||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||||
"base_url required (or pass name of an existing source to reuse its base_url and credential)")
|
||||
return
|
||||
}
|
||||
if req.Adapter == "" {
|
||||
@ -144,6 +186,58 @@ func (g *Gateway) handleSourceDiscoverAPI(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// ★ 显式生图声明:把 operator 点名的 id 从 chat 挪到 image。
|
||||
//
|
||||
// 放在 selectModels **之前**:后者按 include/exclude 裁剪两个列表,
|
||||
// 挪类必须在裁剪前完成,否则一个"只声明成生图、又被 include 选中"的
|
||||
// 模型会先在 chat 里被保留、再被这里挪走 —— 顺序反了会得到两份。
|
||||
//
|
||||
// 只认**目录里确实存在的** id:写错一个字不该静默造出一个不存在的模型
|
||||
// (那会让源在每次照片请求时 404),而是明确报错。
|
||||
if len(req.ImageModels) > 0 {
|
||||
have := map[string]bool{}
|
||||
for _, m := range res.Models {
|
||||
have[m] = true
|
||||
}
|
||||
for _, m := range res.ImageModels {
|
||||
have[m] = true
|
||||
}
|
||||
var missing []string
|
||||
force := map[string]bool{}
|
||||
for _, id := range req.ImageModels {
|
||||
if !have[id] {
|
||||
missing = append(missing, id)
|
||||
continue
|
||||
}
|
||||
force[id] = true
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||||
"image_models contains ids that are not in the upstream catalog: "+
|
||||
strings.Join(missing, ", "))
|
||||
return
|
||||
}
|
||||
var keepChat []string
|
||||
for _, m := range res.Models {
|
||||
if !force[m] {
|
||||
keepChat = append(keepChat, m)
|
||||
}
|
||||
}
|
||||
seenImg := map[string]bool{}
|
||||
var newImage []string
|
||||
for _, m := range res.ImageModels {
|
||||
seenImg[m] = true
|
||||
newImage = append(newImage, m)
|
||||
}
|
||||
for id := range force {
|
||||
if !seenImg[id] {
|
||||
newImage = append(newImage, id)
|
||||
}
|
||||
}
|
||||
res.Models = keepChat
|
||||
res.ImageModels = newImage
|
||||
}
|
||||
|
||||
chat, image, warns, err := selectModels(res.Models, res.ImageModels, req.Include, req.Exclude)
|
||||
if err != nil {
|
||||
// 400: the operator asked for a model the catalog does not have.
|
||||
|
||||
@ -497,9 +497,6 @@ func TestModelsToConfig_PrioritiesAndKinds(t *testing.T) {
|
||||
if got[2].ID != "img1" || got[2].Kind != "image" {
|
||||
t.Errorf("image model mis-sorted: %+v", got[2])
|
||||
}
|
||||
if got[2].Priority <= got[0].Priority {
|
||||
t.Errorf("image model priority %d should sort after chat %d", got[2].Priority, got[0].Priority)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_ImageModelsNeverNull guards the UI contract end to end,
|
||||
|
||||
@ -53,6 +53,42 @@ func newTestGateway(t *testing.T, srcs ...config.Source) *Gateway {
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ★ 测试默认建一条 AUTO 链(每个源一个 tier,按传入顺序),因为内核
|
||||
// **不再** seedAuto 自动播种规则了(那条废弃逻辑已删除:AUTO 链为空时
|
||||
// 网关应当直接 503,而不是凭空生成一堆用户没配过的档位)。
|
||||
//
|
||||
// 但既有测试大量依赖"AUTO 能跑",它们此前是隐式吃 seedAuto 的。这里显式
|
||||
// 建链,既保持测试原意(验调度而不是验播种),也让"空链=503"成为
|
||||
// 一个**可以单独断言**的行为(见 TestChatAutoEmptyChainIsRejected)。
|
||||
//
|
||||
// 顺序沿用 seedAuto 的老口径:**源按"最后一个最先"排**(tier 1 = 最后
|
||||
// 传入的源)。既有用例靠这个语义挑"high"源(TestChatAUTO 期望 high-m),
|
||||
// 改成正序会让一批用例因为"挑到了另一个源"而红,而那与本次重构无关。
|
||||
//
|
||||
// 所有源放在**同一个 tier**(tier 1),而不是一个源一个 tier:链内同一
|
||||
// tier 的多个 slot 会互相 failover,这正是 TestChatAutoChainTierFailover
|
||||
// 要验的行为("a 挂 500 → 请求由 b 服务")。一源一 tier 会让 a 独占 tier1,
|
||||
// b 在 tier2 永远轮不到,"两个源都被打过"这条断言就必然失败。
|
||||
if len(cfg.Auto) == 0 && len(srcs) > 0 {
|
||||
// 一源一 tier,**倒序**:最后传入的源拿 tier 1(最先尝试)。
|
||||
// TestChatAUTO(low 先传、high 后传,期望 high)靠的就是这个语义。
|
||||
// 同 tier failover 由 TestChatAutoChainTierFailover 自己显式建链覆盖 ——
|
||||
// 两条测试验的是不同机制(tier 优先 vs 同 tier 轮转),不能用同一个
|
||||
// 默认夹具同时满足。
|
||||
tier := 0
|
||||
for i := len(srcs) - 1; i >= 0; i-- {
|
||||
tier++
|
||||
s := srcs[i]
|
||||
for _, m := range s.Models {
|
||||
if m.Kind == "image" {
|
||||
continue // 图片模型不进 chat 链(与 buildAutoChain 同口径)
|
||||
}
|
||||
cfg.Auto = append(cfg.Auto, config.ModelScope{
|
||||
Model: m.ID, Source: s.Name, Tier: tier,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
@ -107,9 +143,18 @@ func TestChatAutoChainTierFailover(t *testing.T) {
|
||||
defer aUp.Close()
|
||||
defer bUp.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m", Priority: 10}}},
|
||||
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
|
||||
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m"}}},
|
||||
)
|
||||
// ★ 显式把两个源放进**同一 tier**:本用例验的是 tier 内失败轮转。
|
||||
// 默认夹具是一源一 tier(供 TestChatAUTO 验优先级),两者语义不同,
|
||||
// 必须在这里覆盖,否则 b 在 tier2 永远轮不到。
|
||||
if err := g.core.SaveAutoRules([]config.ModelScope{
|
||||
{Model: "a-m", Source: "a", Tier: 1},
|
||||
{Model: "b-m", Source: "b", Tier: 1},
|
||||
}); err != nil {
|
||||
t.Fatalf("save auto rules: %v", err)
|
||||
}
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != 200 {
|
||||
@ -134,8 +179,8 @@ func TestChatAutoChain503Summary(t *testing.T) {
|
||||
defer aUp.Close()
|
||||
defer bUp.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m", Priority: 10}}},
|
||||
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
|
||||
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m"}}},
|
||||
)
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
@ -156,7 +201,7 @@ func TestChatAutoQuotaSkip(t *testing.T) {
|
||||
up := upstream(t, ctrl)
|
||||
defer up.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
|
||||
)
|
||||
// one slot with an hourly quota of 1 token
|
||||
rr := doReq(t, g, "PUT", "/api/auto",
|
||||
@ -191,7 +236,7 @@ func TestAutoStatesReportChainHealth(t *testing.T) {
|
||||
up := upstream(t, ctrl)
|
||||
defer up.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
|
||||
)
|
||||
doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
@ -234,7 +279,7 @@ func TestAutoSaveResetsCooldown(t *testing.T) {
|
||||
up := upstream(t, ctrl)
|
||||
defer up.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
|
||||
)
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
@ -348,8 +393,8 @@ func TestChatAUTO(t *testing.T) {
|
||||
up := mockUpstream()
|
||||
defer up.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "low", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "low-m", Priority: 10}}},
|
||||
config.Source{Name: "high", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "high-m", Priority: 100}}},
|
||||
config.Source{Name: "low", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "low-m"}}},
|
||||
config.Source{Name: "high", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "high-m"}}},
|
||||
)
|
||||
// no model -> AUTO -> picks the highest priority source
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
@ -428,8 +473,8 @@ func TestImageAutoFallsOnlyToImageProviders(t *testing.T) {
|
||||
}))
|
||||
defer chatUp.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "chat", BaseURL: chatUp.URL, Adapter: "openai", Models: []config.Model{{ID: "chat-m", Priority: 100}}},
|
||||
config.Source{Name: "img", BaseURL: img.URL, Adapter: "openai", Models: []config.Model{{ID: "flux", Kind: "image", Priority: 1}}},
|
||||
config.Source{Name: "chat", BaseURL: chatUp.URL, Adapter: "openai", Models: []config.Model{{ID: "chat-m"}}},
|
||||
config.Source{Name: "img", BaseURL: img.URL, Adapter: "openai", Models: []config.Model{{ID: "flux", Kind: "image"}}},
|
||||
)
|
||||
rr := doReq(t, g, "POST", "/v1/images/generations",
|
||||
`{"model":"AUTO","prompt":"a cat"}`)
|
||||
|
||||
@ -44,7 +44,7 @@ func keyAutoGateway(t *testing.T) *Gateway {
|
||||
},
|
||||
Sources: []config.Source{
|
||||
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
|
||||
Models: []config.Model{{ID: "gpt-4o"}, {ID: "gpt-4o-mini"}}},
|
||||
},
|
||||
}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
|
||||
@ -36,7 +36,7 @@ func quotaGateway(t *testing.T, keyA, keyB config.GWKey) (*Gateway, *upstreamCtr
|
||||
Keys: []config.GWKey{keyA, keyB},
|
||||
Sources: []config.Source{{
|
||||
Name: "up", BaseURL: up.URL, Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m1", Priority: 100}},
|
||||
Models: []config.Model{{ID: "m1"}},
|
||||
}},
|
||||
}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
@ -306,14 +306,24 @@ func newQuotaGW(t *testing.T, upURL string, keys ...config.GWKey) *Gateway {
|
||||
Sources: []config.Source{{
|
||||
Name: "up", BaseURL: upURL, Adapter: "openai",
|
||||
Models: []config.Model{
|
||||
{ID: "m1", Priority: 100},
|
||||
{ID: "m2", Priority: 90},
|
||||
{ID: "m1"},
|
||||
{ID: "m2"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 内核不再 seedAuto 自动播种 AUTO 链(空链=503 是刻意的)。本夹具的用例
|
||||
// 全都在验"AUTO 请求会不会被 key 配额挡住",必须显式建一条链 ——
|
||||
// 否则每个用例都先撞 503,验的就不是配额了。
|
||||
if len(cfg.Auto) == 0 {
|
||||
cfg.Auto = []config.ModelScope{
|
||||
{Model: "m1", Source: "up", Tier: 1},
|
||||
{Model: "m2", Source: "up", Tier: 2},
|
||||
}
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
@ -355,8 +365,8 @@ func TestOneModelsQuotaDoesNotBlockAnother(t *testing.T) {
|
||||
Sources: []config.Source{{
|
||||
Name: "up", BaseURL: up.URL, Adapter: "openai",
|
||||
Models: []config.Model{
|
||||
{ID: "m1", Priority: 100},
|
||||
{ID: "m2", Priority: 90},
|
||||
{ID: "m1"},
|
||||
{ID: "m2"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
@ -106,7 +106,7 @@ func messagesGateway(t *testing.T) (*httptest.Server, string) {
|
||||
Sources: []config.Source{{
|
||||
Name: "anth", BaseURL: up.URL, Adapter: "anthropic",
|
||||
APIKey: "k", MaxConcurrent: 4,
|
||||
Models: []config.Model{{ID: "claude-test", Kind: "chat", Priority: 100}},
|
||||
Models: []config.Model{{ID: "claude-test", Kind: "chat"}},
|
||||
}},
|
||||
}
|
||||
|
||||
|
||||
@ -55,6 +55,15 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
for i := range matches {
|
||||
h := matches[i]
|
||||
p, ok := matchHandlerPath(h.Path, rest)
|
||||
if h.Prefix {
|
||||
pattern := strings.Trim(h.Path, "/")
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
n := len(strings.Split(pattern, "/"))
|
||||
ok = len(parts) >= n
|
||||
if ok {
|
||||
p, ok = matchHandlerPath(pattern, strings.Join(parts[:n], "/"))
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@ -116,6 +125,16 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
if resp.Proxy != "" {
|
||||
if !chosen.Prefix {
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", "proxy response requires prefix=true")
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
n := len(strings.Split(strings.Trim(chosen.Path, "/"), "/"))
|
||||
g.servePluginProxy(w, r, resp.Proxy, pluginHTTPPrefix+strings.Join(parts[:n], "/"))
|
||||
return
|
||||
}
|
||||
lua.WriteHandlerResponse(w, resp)
|
||||
}
|
||||
|
||||
|
||||
54
internal/gateway/plugin_proxy.go
Normal file
54
internal/gateway/plugin_proxy.go
Normal file
@ -0,0 +1,54 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// servePluginProxy forwards a plugin-owned prefix to an explicit loopback
|
||||
// origin. No destination or path may be supplied by the client. Authentication
|
||||
// and endpoint authorization have already completed before this is called.
|
||||
func (g *Gateway) servePluginProxy(w http.ResponseWriter, r *http.Request, target, prefix string) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy must name a loopback HTTP origin")
|
||||
return
|
||||
}
|
||||
ip := net.ParseIP(u.Hostname())
|
||||
if ip == nil || !ip.IsLoopback() || u.Port() == "" {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy requires a literal loopback address and port")
|
||||
return
|
||||
}
|
||||
// WebSocket requests are browser-initiated and carry ambient cookies. Do not
|
||||
// allow a foreign website to use an administrator's session to open a tunnel.
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
o, e := url.Parse(origin)
|
||||
if e != nil || !strings.EqualFold(o.Host, r.Host) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "cross-origin proxy request refused")
|
||||
return
|
||||
}
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(u)
|
||||
director := proxy.Director
|
||||
proxy.Director = func(out *http.Request) {
|
||||
director(out)
|
||||
out.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if out.URL.Path == "" {
|
||||
out.URL.Path = "/"
|
||||
}
|
||||
out.URL.RawPath = ""
|
||||
out.Host = u.Host
|
||||
out.Header.Del("Authorization")
|
||||
out.Header.Del("Cookie")
|
||||
q := out.URL.Query()
|
||||
q.Del("api_key")
|
||||
out.URL.RawQuery = q.Encode()
|
||||
}
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "plugin service unavailable")
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
66
internal/gateway/plugin_proxy_security_test.go
Normal file
66
internal/gateway/plugin_proxy_security_test.go
Normal file
@ -0,0 +1,66 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPluginProxyRejectsUnsafeTargetsAndOrigin(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
for _, target := range []string{"http://example.com:80", "http://localhost:80", "https://127.0.0.1:80", "http://127.0.0.1", "http://u:p@127.0.0.1:80", "http://127.0.0.1:80/path", "http://127.0.0.1:80/?x=1"} {
|
||||
rr := httptest.NewRecorder()
|
||||
g.servePluginProxy(rr, httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/live.js", nil), target, "/api/plugins/http/relay/s")
|
||||
if rr.Code != 502 {
|
||||
t.Errorf("unsafe target %q accepted: %d", target, rr.Code)
|
||||
}
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/websockify", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
g.servePluginProxy(rr, req, "http://127.0.0.1:12345", "/api/plugins/http/relay/s")
|
||||
if rr.Code != 403 {
|
||||
t.Fatalf("foreign origin accepted: %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginPrefixProxyAuthorizationAndRevocation(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("denied request reached backend") }))
|
||||
defer backend.Close()
|
||||
g, _ := gatewayWithSpy(t)
|
||||
src := `local p={name="relaydeny",http={{path="relaydeny/:session",prefix=true,roles={"admin"},handler="proxy"}}}
|
||||
function p.proxy(req) return {status=404,body="expired session"} end
|
||||
return p`
|
||||
if err := g.core.Plugins().LoadSource("relaydeny", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(g.Handler())
|
||||
defer server.Close()
|
||||
for _, tc := range []struct {
|
||||
path, key string
|
||||
status int
|
||||
}{
|
||||
{"/api/plugins/http/relaydeny/expired/vnc.html", "", 401},
|
||||
{"/api/plugins/http/relaydeny/expired/vnc.html", "sk-test", 404},
|
||||
{"/api/plugins/http/relaydeny-not-a-child/expired/vnc.html", "sk-test", 404},
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", server.URL+tc.path, nil)
|
||||
if tc.key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tc.key)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != tc.status {
|
||||
t.Fatalf("%s got %d %s", tc.path, resp.StatusCode, b)
|
||||
}
|
||||
if tc.key != "" && strings.Contains(tc.path, "/expired/") && strings.Contains(tc.path, "/relaydeny/") && string(b) != "expired session" {
|
||||
t.Fatalf("revocation response=%s", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
82
internal/gateway/plugin_proxy_test.go
Normal file
82
internal/gateway/plugin_proxy_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Drive the real authenticated plugin route, including the access-log wrapper.
|
||||
// A successful static request alone does not prove WebSocket can upgrade.
|
||||
func TestPluginPrefixProxyHTTPAndUpgrade(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || r.URL.Query().Get("api_key") != "" {
|
||||
t.Error("gateway credentials leaked to plugin backend")
|
||||
}
|
||||
if r.URL.Path == "/websockify" {
|
||||
c, b, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
fmt.Fprint(b, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
b.Flush()
|
||||
io.Copy(c, c)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, r.URL.Path+"?"+r.URL.RawQuery)
|
||||
}))
|
||||
defer backend.Close()
|
||||
g, _ := gatewayWithSpy(t)
|
||||
src := fmt.Sprintf(`local p={name="relay",http={{path="relay/:session",prefix=true,method="GET",roles={"admin"},handler="proxy"}}}
|
||||
function p.proxy(req)
|
||||
if req.params.session ~= "live" then return {status=404,body="expired"} end
|
||||
return {proxy=%q}
|
||||
end
|
||||
return p`, backend.URL)
|
||||
if err := g.core.Plugins().LoadSource("relay", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(g.Handler())
|
||||
defer server.Close()
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/plugins/http/relay/live/app/main.js?x=1&api_key=sk-test", nil)
|
||||
req.Header.Set("Authorization", "Bearer sk-test")
|
||||
req.AddCookie(&http.Cookie{Name: "gw_key", Value: "sk-test"})
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || string(body) != "/app/main.js?x=1" {
|
||||
t.Fatalf("HTTP proxy: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
c, err := net.Dial("tcp", strings.TrimPrefix(server.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
fmt.Fprintf(c, "GET /api/plugins/http/relay/live/websockify HTTP/1.1\r\nHost: %s\r\nAuthorization: Bearer sk-test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", strings.TrimPrefix(server.URL, "http://"))
|
||||
b := bufio.NewReader(c)
|
||||
reply, err := http.ReadResponse(b, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply.StatusCode != 101 {
|
||||
t.Fatalf("upgrade failed: %s", reply.Status)
|
||||
}
|
||||
c.Write([]byte("ping"))
|
||||
got := make([]byte, 4)
|
||||
if _, err = io.ReadFull(b, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "ping" {
|
||||
t.Fatalf("tunnel payload=%q", got)
|
||||
}
|
||||
}
|
||||
@ -187,6 +187,10 @@ type statusRecorder struct {
|
||||
code int
|
||||
}
|
||||
|
||||
// Unwrap preserves optional writer capabilities (notably Hijack for WebSocket)
|
||||
// through the access-log middleware via http.ResponseController.
|
||||
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
if s.code == 0 {
|
||||
s.code = code
|
||||
|
||||
@ -71,11 +71,6 @@ func TestSourcePutUpdatesSource(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
for _, m := range s.Models {
|
||||
if m.ID == "m-one" && m.Priority != 42 {
|
||||
t.Fatalf("priority not applied: %+v", s.Models)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("source vanished after PUT")
|
||||
|
||||
@ -228,6 +228,26 @@ func incStatus(a *Stat, name string, r Req) {
|
||||
}
|
||||
}
|
||||
|
||||
// addStat merges an already aggregated row into a total. It deliberately
|
||||
// includes failed requests without a model: routing can fail before a model is
|
||||
// resolved, but that request still belongs in the gateway total.
|
||||
func addStat(dst, src *Stat) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
dst.Reqs += src.Reqs
|
||||
dst.OK += src.OK
|
||||
dst.Err += src.Err
|
||||
dst.Tokens += src.Tokens
|
||||
dst.Prompt += src.Prompt
|
||||
dst.Compl += src.Compl
|
||||
dst.LatSum += src.LatSum
|
||||
dst.FirstByteSum += src.FirstByteSum
|
||||
if src.LatMax > dst.LatMax {
|
||||
dst.LatMax = src.LatMax
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAudit primes the in-memory state from the audit file.
|
||||
//
|
||||
// The aggregates (totals, per-key/model/source rows, status counts and the
|
||||
@ -1219,17 +1239,13 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||
byModel = s.byKeyModel[key]
|
||||
bySrc = s.byKeySrc[key]
|
||||
}
|
||||
for _, a := range byModel {
|
||||
total.Reqs += a.Reqs
|
||||
total.OK += a.OK
|
||||
total.Err += a.Err
|
||||
total.Tokens += a.Tokens
|
||||
total.Prompt += a.Prompt
|
||||
total.Compl += a.Compl
|
||||
total.LatSum += a.LatSum
|
||||
if a.LatMax > total.LatMax {
|
||||
total.LatMax = a.LatMax
|
||||
}
|
||||
// The total is the sum of every recorded request, not the model rows.
|
||||
// Routing failures are valid requests but may have no resolved model; deriving
|
||||
// total from byModel silently dropped those 502/503 records. Use byKey, which
|
||||
// is populated for every request (including an empty key), and keep the same
|
||||
// scope as the selected key filter.
|
||||
for _, a := range byKey {
|
||||
addStat(&total, a)
|
||||
}
|
||||
bs := make([]agrRow, 0, len(s.byStatus))
|
||||
for code, st := range s.byStatus {
|
||||
|
||||
@ -560,3 +560,17 @@ func TestAuditRotationThresholds(t *testing.T) {
|
||||
t.Fatalf("total audit budget = %d, want 256 MiB", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotTotalIncludesRequestsWithoutModel(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
s.Record(Req{Key: "k", Status: 200, OK: true})
|
||||
s.Record(Req{Key: "k", Model: "m", Status: 200, OK: true, Prompt: 3, Compl: 2})
|
||||
snap := s.Snapshot(0, "")
|
||||
total, ok := snap["total"].(Stat)
|
||||
if !ok {
|
||||
t.Fatalf("total missing: %#v", snap["total"])
|
||||
}
|
||||
if total.Reqs != 2 || total.OK != 2 || total.Tokens != 5 {
|
||||
t.Fatalf("total must include model-less requests: %#v", total)
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,7 +101,7 @@ func TestStreamedChatRecordsUpstreamUsage(t *testing.T) {
|
||||
defer up.Close()
|
||||
g := newTestGateway(t, config.Source{
|
||||
Name: "up", BaseURL: up.URL, Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m1", Priority: 100}},
|
||||
Models: []config.Model{{ID: "m1"}},
|
||||
})
|
||||
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
@ -127,7 +127,7 @@ func TestStreamedAndNonStreamedAgreeOnTokens(t *testing.T) {
|
||||
defer up.Close()
|
||||
g := newTestGateway(t, config.Source{
|
||||
Name: "up", BaseURL: up.URL, Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m1", Priority: 100}},
|
||||
Models: []config.Model{{ID: "m1"}},
|
||||
})
|
||||
|
||||
if rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
@ -167,7 +167,7 @@ func TestStreamedChatFallsBackToEstimateWithoutUsage(t *testing.T) {
|
||||
defer up.Close()
|
||||
g := newTestGateway(t, config.Source{
|
||||
Name: "up", BaseURL: up.URL, Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m1", Priority: 100}},
|
||||
Models: []config.Model{{ID: "m1"}},
|
||||
})
|
||||
|
||||
if rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
@ -189,7 +189,7 @@ func TestImageGenerationDoesNotRecordTokens(t *testing.T) {
|
||||
defer up.Close()
|
||||
src := config.Source{
|
||||
Name: "up", BaseURL: up.URL, Adapter: "openai",
|
||||
Models: []config.Model{{ID: "img-m", Priority: 100, Kind: "image"}},
|
||||
Models: []config.Model{{ID: "img-m", Kind: "image"}},
|
||||
}
|
||||
g := newTestGateway(t, src)
|
||||
|
||||
|
||||
@ -365,10 +365,11 @@
|
||||
.ctx-itm .ic{width:15px;text-align:center}
|
||||
|
||||
/* ---- chat page ---- */
|
||||
.tab-chat{max-width:840px;margin:0 auto;display:flex;flex-direction:column;height:calc(100vh - 190px)}
|
||||
.tab-chat{max-width:840px;margin:0 auto;display:flex;flex-direction:column;height:calc(100vh - 190px);min-height:0}
|
||||
.chat-mode-bar{display:flex;align-items:center;gap:8px;padding:0 0 10px;flex:0 0 auto}
|
||||
.chat-pane{flex:1;display:flex;flex-direction:column;min-height:0}
|
||||
|
||||
.tab-image{max-width:840px;margin:0 auto;display:flex;flex-direction:column;
|
||||
height:calc(100vh - 190px)}
|
||||
.tab-image{display:flex;flex-direction:column;flex:1;min-height:0}
|
||||
.img-wrap{flex:1;display:flex;flex-direction:column;min-height:0}
|
||||
.img-grid{flex:1;overflow-y:auto;padding:12px 2px;display:grid;
|
||||
grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;align-content:start}
|
||||
@ -589,21 +590,6 @@
|
||||
/></svg
|
||||
><span data-i="navChat">对话</span>
|
||||
</button>
|
||||
<button class="sb-i" data-tab="image">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 5h18v14H3z" />
|
||||
<path d="m3 15 5-5 4 4 3-3 6 6" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
></svg
|
||||
><span data-i="navImage">生图</span>
|
||||
</button>
|
||||
<button class="sb-i" data-tab="keys">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@ -822,8 +808,7 @@
|
||||
<main id="main">
|
||||
<div id="tab-status" class="tab-pane"></div>
|
||||
<div id="tab-chat" class="tab-pane hidden"></div>
|
||||
<div id="tab-image" class="tab-pane hidden"></div>
|
||||
<div id="tab-keys" class="tab-pane hidden"></div>
|
||||
<div id="tab-keys" class="tab-pane hidden"></div>
|
||||
<div id="tab-sort" class="tab-pane hidden"></div>
|
||||
<div id="tab-sources" class="tab-pane hidden"></div>
|
||||
<div id="tab-adapters" class="tab-pane hidden"></div>
|
||||
@ -1589,7 +1574,7 @@
|
||||
// keep in sync, and adding a tab meant finding all three. A plugin page
|
||||
// that is routed but never shown is exactly the kind of silent gap that
|
||||
// survives review.
|
||||
const TABS = ["status", "chat", "image", "keys", "sort", "sources", "adapters", "plugins"];
|
||||
const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"];
|
||||
// Declared HERE, with var, because goTab() below reads it. It used to be a
|
||||
// `const` further down the file next to the other plugin state, which put
|
||||
// goTab's read inside the temporal dead zone: the first click on a plugin
|
||||
@ -2898,6 +2883,13 @@ async function renderChat() {
|
||||
: "");
|
||||
$("#tab-chat").innerHTML = `
|
||||
<div class="tab-chat">
|
||||
<div class="chat-mode-bar">
|
||||
<div class="kind-toggle">
|
||||
<button data-mode="chat" class="small" onclick="switchChatMode('chat')">对话</button>
|
||||
<button data-mode="image" class="ghost small" onclick="switchChatMode('image')">生图</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-pane" id="ct-chat-pane">
|
||||
<div class="chat-wrap">
|
||||
<div class="chat-log" id="ct-log">
|
||||
<div class="chat-empty" id="ct-empty">
|
||||
@ -2925,6 +2917,8 @@ async function renderChat() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-pane" id="ct-image-pane" style="display:none"></div>
|
||||
</div>`;
|
||||
chatHistory = [];
|
||||
chatImages = [];
|
||||
@ -2939,22 +2933,22 @@ async function renderChat() {
|
||||
$("#ct-imgs").style.display = "none";
|
||||
$("#ct-msg").focus();
|
||||
}
|
||||
/* ---------- image tab ---------- */
|
||||
// Image generation lives in its own page rather than as an option in the
|
||||
// chat model dropdown.
|
||||
//
|
||||
// The dropdown used to list image models beside chat ones because
|
||||
// /api/status flattened the catalog to []string and dropped the kind.
|
||||
// Picking one sent the request to /api/chat, which cannot serve it. A
|
||||
// second tab makes the distinction structural: the only models offered
|
||||
// here are kind=image, and the endpoint called is the image one.
|
||||
/* ---------- chat/image modes ---------- */
|
||||
let imgBusy = false;
|
||||
function imgModels() {
|
||||
const out = [];
|
||||
document.querySelectorAll("#it-model option").forEach((o) => {
|
||||
if (o.value) out.push(o.value);
|
||||
let chatMode = "chat";
|
||||
function switchChatMode(mode) {
|
||||
chatMode = mode === "image" ? "image" : "chat";
|
||||
const chatPane = $("#ct-chat-pane");
|
||||
const imagePane = $("#ct-image-pane");
|
||||
if (chatPane) chatPane.style.display = chatMode === "chat" ? "" : "none";
|
||||
if (imagePane) imagePane.style.display = chatMode === "image" ? "" : "none";
|
||||
document.querySelectorAll("#tab-chat .chat-mode-bar button").forEach((button) => {
|
||||
button.classList.toggle("ghost", button.dataset.mode !== chatMode);
|
||||
});
|
||||
return out;
|
||||
if (chatMode === "image" && imagePane && !imagePane.dataset.rendered) {
|
||||
imagePane.dataset.rendered = "1";
|
||||
renderImage();
|
||||
}
|
||||
}
|
||||
async function renderImage() {
|
||||
const s = await api("/api/status");
|
||||
@ -2977,7 +2971,7 @@ async function renderChat() {
|
||||
const body = opts.length
|
||||
? ""
|
||||
: `<div class="empty">${esc(t("imgNoModels"))}</div>`;
|
||||
$("#tab-image").innerHTML = `
|
||||
$("#ct-image-pane").innerHTML = `
|
||||
<div class="tab-image">
|
||||
<div class="img-wrap">
|
||||
<div class="img-grid" id="it-grid">
|
||||
@ -6267,7 +6261,6 @@ toast(t("kAutoChainGlobal"));
|
||||
const done = () => remountPluginElements();
|
||||
if (tab === "status") return renderStatus().then(done, done);
|
||||
if (tab === "chat") return renderChat().then(done, done);
|
||||
if (tab === "image") return renderImage().then(done, done);
|
||||
if (tab === "keys") return renderKeys().then(done, done);
|
||||
if (tab === "sort") return renderSort().then(done, done);
|
||||
if (tab === "sources") return renderSources().then(done, done);
|
||||
|
||||
@ -42,28 +42,47 @@ func TestUI_ChatPageGroupsImageModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUI_HasDedicatedImagePage pins the second half of the fix: image models
|
||||
// need somewhere they CAN be tested.
|
||||
func TestUI_HasDedicatedImagePage(t *testing.T) {
|
||||
// TestUI_ImageModeIsReachableFromChatTab 钉住生图**仍可达**。
|
||||
//
|
||||
// ★ 断言已随 UI 结构调整:生图不再是独立侧栏项,而是对话页里的一个分段模式。
|
||||
// 原先这条测试钉的是 `data-tab="image"` / `id="tab-image"` / `tab === "image"`
|
||||
// —— 三者都已随合并移除。若继续钉旧结构,它只会红着挡住重构,而检不出
|
||||
// "生图入口彻底消失"这种真回归。所以改为钉**合并后的可达性**:
|
||||
// 分段控件在、切换函数在、面板容器在、端点仍在。
|
||||
//
|
||||
// 为什么"可达性"才是要守的东西:合并的动机是少一个导航项,而不是把生图功能
|
||||
// 藏起来。只要用户能从对话页一键切到生图并真的发出请求,这个功能就是完整的。
|
||||
func TestUI_ImageModeIsReachableFromChatTab(t *testing.T) {
|
||||
ui := uiSource(t)
|
||||
|
||||
for _, want := range []string{
|
||||
`data-tab="image"`, // sidebar entry
|
||||
`id="tab-image"`, // pane
|
||||
`function renderImage`, // renderer registered with refresh()
|
||||
`tab === "image"`, // dispatched
|
||||
`/v1/images/generations`, // the endpoint the page actually calls
|
||||
`class="chat-mode-bar"`, // 分段栏存在
|
||||
`switchChatMode('image')`, // 能切到生图
|
||||
`id="ct-image-pane"`, // 生图面板容器
|
||||
`function renderImage`, // 渲染器还在
|
||||
`/v1/images/generations`, // 真正被调用的端点
|
||||
} {
|
||||
if !strings.Contains(ui, want) {
|
||||
t.Errorf("image page is missing %q", want)
|
||||
t.Errorf("生图模式不可达,缺少 %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// The page must be reachable from TABS; goTab iterates TABS to toggle pane
|
||||
// visibility, so a pane outside that list is present but permanently hidden —
|
||||
// which reads as "the page is blank" because the content is in the DOM.
|
||||
if !strings.Contains(ui, `"image"`) {
|
||||
t.Error(`"image" does not appear in the TABS list`)
|
||||
// 合并后**不应**再出现独立 tab 的三件套 —— 它们会让 goTab 去找一个不存在的
|
||||
// 面板($("#tab-image") 为 null ⇒ 点侧栏抛错)。
|
||||
for _, gone := range []string{
|
||||
`data-tab="image"`,
|
||||
`id="tab-image"`,
|
||||
`tab === "image"`,
|
||||
} {
|
||||
if strings.Contains(ui, gone) {
|
||||
t.Errorf("生图已并入对话页,不应再有独立 tab 结构 %q"+
|
||||
"(否则 goTab 会找一个不存在的面板)", gone)
|
||||
}
|
||||
}
|
||||
|
||||
// 面板必须在 DOM 里且**不能被初始隐藏到底**:切换函数用 display 控制。
|
||||
if !strings.Contains(ui, `ct-chat-pane`) {
|
||||
t.Error("缺少对话面板容器 ct-chat-pane(切换需要两个容器同时存在)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
116
internal/lua/adapter_buddy_test.go
Normal file
116
internal/lua/adapter_buddy_test.go
Normal file
@ -0,0 +1,116 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadBuddyAdapter 装载内置 buddy 适配器。
|
||||
func loadBuddyAdapter(t *testing.T) *VM {
|
||||
t.Helper()
|
||||
src, err := bundledAdapters.ReadFile("adapters/buddy.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
t.Cleanup(vm.Stop)
|
||||
if err := vm.LoadAdapterSource("buddy", string(src)); err != nil {
|
||||
t.Fatalf("装载失败: %v", err)
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
// ★ buddy 适配器:两个厂商要求,各有实测依据。
|
||||
//
|
||||
// ① glm-5.1 **必须**带非空 reasoning_effort(否则上游 400 + 11150)
|
||||
// ② 流式请求必须带 Accept: text/event-stream(否则只回空帧、completion_tokens=0)
|
||||
func TestBuddyAdapterEffortAndHeaders(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
|
||||
// ① glm-5.1 → 补 medium
|
||||
out, err := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &b); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if b["reasoning_effort"] != "medium" {
|
||||
t.Errorf("glm-5.1 必须补 reasoning_effort=medium(否则上游 11150),实得 %v", b["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ② 其它模型**不补**(避免把单模型要求扩成全平台行为)
|
||||
out2, err := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"hy3","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b2 map[string]interface{}
|
||||
json.Unmarshal([]byte(out2), &b2)
|
||||
if b2["reasoning_effort"] != nil {
|
||||
t.Errorf("hy3 不该被补 reasoning_effort(实测不需要),实得 %v", b2["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ③ 调用方已给档位时**尊重它**
|
||||
out3, _ := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high","stream":true}`)
|
||||
var b3 map[string]interface{}
|
||||
json.Unmarshal([]byte(out3), &b3)
|
||||
if b3["reasoning_effort"] != "high" {
|
||||
t.Errorf("调用方给的档位必须保留,实得 %v", b3["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ④ 头:Accept 必须是 SSE
|
||||
h, err := vm.BuildHeaders("buddy", map[string]interface{}{"api_key": "TOK"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h["Accept"] != "text/event-stream" {
|
||||
t.Errorf("Accept 必须是 text/event-stream(否则上游只回空帧),实得 %q", h["Accept"])
|
||||
}
|
||||
for _, k := range []string{"X-Domain", "X-Product", "X-Product-Code"} {
|
||||
if h[k] == "" {
|
||||
t.Errorf("缺少 buddy 身份头 %s", k)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ glm-5.1 补 effort;hy3 不补;Accept=%s", h["Accept"])
|
||||
}
|
||||
|
||||
// 配对判据:协议转换必须与 openai 一致(buddy 只是补两个字段,不该改转换)。
|
||||
func TestBuddyAdapterKeepsOpenAIWire(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
chunk := `{"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"hi","reasoning_content":"think"},"finish_reason":null}]}`
|
||||
out, err := vm.Transform("buddy", "transform_stream_chunk", chunk)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"content":"hi"`) || !strings.Contains(out, `"reasoning_content":"think"`) {
|
||||
t.Errorf("流式转换必须保留 content 与 reasoning_content,实得 %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 回归护栏:适配器**必须**定义全部四个转换函数。
|
||||
//
|
||||
// 真实事故(2026-10-16):我第一版把基座继承写成"在适配器里读兄弟适配器源码",
|
||||
// 而适配器运行在**另一个沙箱 VM**(无 __llmsproxy_plugin_dir、无文件系统),
|
||||
// 于是基座加载**静默失败** ⇒ 适配器只有 transform_request,
|
||||
// **缺 transform_stream_chunk** ⇒ 内核无法解析 SSE ⇒ 上游明明回了 39 帧正文,
|
||||
// 网关却记 completion_tokens=0、客户端看到**空回复**。
|
||||
// 更糟的是:hy3/glm-5.3/kimi-k3-1 全被它弄坏,而 glm-5.2 因多源兜底
|
||||
// "看起来正常" ⇒ 症状指向错误方向。
|
||||
//
|
||||
// ⇒ 判据必须咬住「四个转换函数都存在」,而不是"某个模型能回话"
|
||||
//
|
||||
// (后者会被别的源兜底掩盖 —— 本会话已栽过两次)。
|
||||
func TestBuddyAdapterDefinesAllTransforms(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
for _, fn := range []string{"transform_request", "transform_response", "transform_stream_chunk", "transform_error"} {
|
||||
if !vm.HasFn("buddy", fn) {
|
||||
t.Errorf("适配器缺 %s ⇒ 内核该路径会静默降级(流式会变成空回复)", fn)
|
||||
}
|
||||
}
|
||||
t.Log("✓ 四个转换函数齐备")
|
||||
}
|
||||
213
internal/lua/adapter_opencodezen_live_test.go
Normal file
213
internal/lua/adapter_opencodezen_live_test.go
Normal file
@ -0,0 +1,213 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 真上游 e2e:用适配器产出的头 + body 直打 opencode /inference/v1。
|
||||
// 需要真实凭据(从生产配置解密),故用 LLMSPROXY_LIVE_ZEN=1 显式开启。
|
||||
func TestOpencodeZenLiveE2E(t *testing.T) {
|
||||
if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" {
|
||||
t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启真上游 e2e")
|
||||
}
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 取生产凭据(解密)
|
||||
out, err := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets",
|
||||
"-config", "/etc/llmsproxy/config.yaml").Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apiKey := ""
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(l, "source localzen") {
|
||||
if i := strings.Index(l, "api_key="); i >= 0 {
|
||||
apiKey = strings.TrimSpace(l[i+len("api_key="):])
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
t.Fatal("未取到 localzen 凭据")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"纯对话(无 tools,靠适配器兜底注入)",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"reply with exactly: ADAPTER-OK"}]}`, "ADAPTER-OK"},
|
||||
{"带自定义 system",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"system","content":"You are terse."},{"role":"user","content":"reply with exactly: SYS-OK"}]}`, "SYS-OK"},
|
||||
{"多轮",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b"},{"role":"user","content":"reply with exactly: MULTI-OK"}]}`, "MULTI-OK"},
|
||||
{"算术(验证真推理)",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"What is 17*23? Reply with just the number."}]}`, "391"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, err := vm.Transform("opencodezen", "transform_request", tc.body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(), "body": body,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, _ := http.NewRequest("POST",
|
||||
"https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body)))
|
||||
for k, v := range h {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
cl := &http.Client{Timeout: 90 * time.Second}
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))])
|
||||
}
|
||||
// 上游现在返回 SSE(适配器强制 stream=true);判据需自己拼 delta.content。
|
||||
got := ""
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
p := strings.TrimPrefix(line, "data: ")
|
||||
if p == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var d struct {
|
||||
Choices []struct {
|
||||
Delta struct{ Content string } `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if json.Unmarshal([]byte(p), &d) != nil || len(d.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
got += d.Choices[0].Delta.Content
|
||||
}
|
||||
if got == "" {
|
||||
t.Fatalf("未从 SSE 解出内容;原始=%s", string(raw)[:min(300, len(raw))])
|
||||
}
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("回复 %q 不含 %q", got, tc.want)
|
||||
}
|
||||
t.Logf("✓ %s → %q", tc.name, strings.TrimSpace(got))
|
||||
})
|
||||
}
|
||||
fmt.Println("真上游 e2e 全部通过")
|
||||
}
|
||||
|
||||
// 经适配器完整转换链:上游 SSE → transform_stream_chunk → 统一格式。
|
||||
// 上一条判据直接读原始 SSE,只证明"上游放行";这条证明"适配器能解析"。
|
||||
func TestOpencodeZenLiveStreamThroughAdapter(t *testing.T) {
|
||||
if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" {
|
||||
t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启")
|
||||
}
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets",
|
||||
"-config", "/etc/llmsproxy/config.yaml").Output()
|
||||
apiKey := ""
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(l, "source localzen") {
|
||||
if i := strings.Index(l, "api_key="); i >= 0 {
|
||||
apiKey = strings.TrimSpace(l[i+len("api_key="):])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body, _ := vm.Transform("opencodezen", "transform_request",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"reply with exactly: CHAIN-OK"}]}`)
|
||||
h, _ := vm.BuildHeaders("opencodezen", map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(), "body": body})
|
||||
|
||||
req, _ := http.NewRequest("POST",
|
||||
"https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body)))
|
||||
for k, v := range h {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))])
|
||||
}
|
||||
|
||||
// 逐行喂给适配器的流解析器,累积统一格式的 content
|
||||
joined := ""
|
||||
n := 0
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
|
||||
continue
|
||||
}
|
||||
// 内核喂给适配器的是**已剥掉 data: 前缀**的载荷(见 provider.go:
|
||||
// strings.TrimPrefix(line, "data:")),判据必须对齐这个契约。
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
unified, err := vm.Transform("opencodezen", "transform_stream_chunk", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_stream_chunk: %v (%s)", err, line[:min(120, len(line))])
|
||||
}
|
||||
if unified == "" || unified == "{}" {
|
||||
continue
|
||||
}
|
||||
var u struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal([]byte(unified), &u) == nil {
|
||||
joined += u.Content
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("适配器未解出任何 chunk(流解析链断了)")
|
||||
}
|
||||
if !strings.Contains(joined, "CHAIN-OK") {
|
||||
t.Fatalf("经适配器链的内容 %q 不含 CHAIN-OK(chunk 数=%d)", joined, n)
|
||||
}
|
||||
t.Logf("✓ 完整链:%d 个 chunk,内容=%q", n, strings.TrimSpace(joined))
|
||||
}
|
||||
112
internal/lua/adapter_opencodezen_reasoning_test.go
Normal file
112
internal/lua/adapter_opencodezen_reasoning_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadZenVM 装载 opencodezen 适配器(照 adapter_opencodezen_test.go 的方式)。
|
||||
func loadZenVM(t *testing.T) *VM {
|
||||
t.Helper()
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
t.Cleanup(vm.Stop)
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatalf("加载失败: %v", err)
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
// ★ assistant 回合的 reasoning_content **必须回传**给 opencode 上游。
|
||||
//
|
||||
// 真实缺陷(生产实测,2026-10-16):
|
||||
//
|
||||
// provider localzen: api error 400: Upstream request failed: [invalid_request_error]
|
||||
// The `reasoning_content` in the thinking mode must be passed back to the API.
|
||||
//
|
||||
// 根因:transform_request 里写了 `msg.reasoning_content = nil`,理由是
|
||||
// "Zen 免费池不接受回传"。该假设**方向反了** —— 上游要的就是它。
|
||||
//
|
||||
// ★★ 这个缺陷极难归因,因为失败是**交替**出现的:
|
||||
// 只有当上一轮**真的产出了 reasoning**(thinking 模型 + reasoning_effort 开启)
|
||||
// 时,回放那段历史才会被拒;普通回合照常 200。审计里表现为成串 502 紧随一个
|
||||
// 成功的长上下文请求(实测 prompt_tokens=139330)之后。
|
||||
//
|
||||
// 判据必须**咬住「被剥掉」这个具体行为**,而不是"请求成功" —— 后者在
|
||||
// 非 thinking 回合下永远为真,测不出这个缺陷。
|
||||
func TestOpenCodeZenKeepsAssistantReasoningContent(t *testing.T) {
|
||||
vm := loadZenVM(t)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": "deepseek-v4.1-flash-free",
|
||||
"stream": true,
|
||||
"messages": []map[string]interface{}{
|
||||
{"role": "user", "content": "1+1=?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "2",
|
||||
// 这段正是 agent 回放历史时会带上的东西
|
||||
"reasoning_content": "一加一等于二。",
|
||||
},
|
||||
{"role": "user", "content": "再加一"},
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := vm.Transform("opencodezen", "transform_request", string(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 解析出站体,找 assistant 回合
|
||||
var sent struct {
|
||||
Messages []map[string]interface{} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &sent); err != nil {
|
||||
t.Fatalf("出站体不是合法 JSON: %v\n%s", err, out)
|
||||
}
|
||||
var found bool
|
||||
for _, m := range sent.Messages {
|
||||
if m["role"] != "assistant" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
rc, _ := m["reasoning_content"].(string)
|
||||
if rc == "" {
|
||||
t.Fatalf("assistant 回合的 reasoning_content 被剥掉了 ⇒ 上游会回 "+
|
||||
"`The reasoning_content in the thinking mode must be passed back to the API`\n"+
|
||||
"出站 messages: %s", out)
|
||||
}
|
||||
if rc != "一加一等于二。" {
|
||||
t.Errorf("reasoning_content 被改写:%q", rc)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("没找到 assistant 回合(判据失效)\n%s", out)
|
||||
}
|
||||
t.Log("✓ assistant 的 reasoning_content 已保留")
|
||||
}
|
||||
|
||||
// ★ 反向:**空的** reasoning_content 应被丢掉(不要把空串发给上游)。
|
||||
// 与上一条配对 —— 缺了这条,"改成无条件保留"这类过度修正不会被发现。
|
||||
func TestOpenCodeZenDropsEmptyReasoningContent(t *testing.T) {
|
||||
vm := loadZenVM(t)
|
||||
|
||||
raw := `{"model":"m","stream":true,"messages":[
|
||||
{"role":"assistant","content":"hi","reasoning_content":""}
|
||||
]}`
|
||||
out, err := vm.Transform("opencodezen", "transform_request", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, `"reasoning_content":""`) {
|
||||
t.Errorf("空的 reasoning_content 不该发给上游(多余字段,部分上游会挑),实得: %s", out)
|
||||
}
|
||||
t.Log("✓ 空 reasoning_content 已丢弃")
|
||||
}
|
||||
156
internal/lua/adapter_opencodezen_test.go
Normal file
156
internal/lua/adapter_opencodezen_test.go
Normal file
@ -0,0 +1,156 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// E2E:加载改后的适配器,验证 (1) 头含两个 session 头且值相同;(2) id 段服从公式;
|
||||
// (3) 无 tools 的请求被注入 read 桩且 properties 非空。
|
||||
func TestOpencodeZenAdapterE2E(t *testing.T) {
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatalf("加载失败: %v", err)
|
||||
}
|
||||
|
||||
t.Run("头:两个 session 头同值", func(t *testing.T) {
|
||||
h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{"timestamp": int64(1792065049)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1, s2 := h["x-opencode-session"], h["x-opencode-session-id"]
|
||||
if s1 == "" || s2 == "" {
|
||||
t.Fatalf("两个 session 头都必须存在: %#v", h)
|
||||
}
|
||||
if s1 != s2 {
|
||||
t.Fatalf("必须同值: %q vs %q", s1, s2)
|
||||
}
|
||||
if !strings.HasPrefix(s1, "ses_") || len(s1) != 4+12+14 {
|
||||
t.Fatalf("session 形状应为 ses_+12hex+14base62: %q (len=%d)", s1, len(s1))
|
||||
}
|
||||
if len(h["x-opencode-request"]) != 4+12+14 || !strings.HasPrefix(h["x-opencode-request"], "msg_") {
|
||||
t.Fatalf("request 形状: %q", h["x-opencode-request"])
|
||||
}
|
||||
if !strings.Contains(h["User-Agent"], "opencode/1.19.0") {
|
||||
t.Fatalf("UA 应为 1.19.0: %q", h["User-Agent"])
|
||||
}
|
||||
t.Logf("session=%s request=%s", s1, h["x-opencode-request"])
|
||||
// 公式校验:段 = (K - bucket_ms*4096) & MASK,其中 bucket 已对齐到整分钟
|
||||
// (适配器按分钟桶取会话号,以保留上游前缀缓存命中)。
|
||||
// ⚠️ 判据必须算**同一个桶**,不能拿原始 ms 去比 —— 那会把正确的实现判成错。
|
||||
seg := s1[4:16]
|
||||
ms := int64(1792065049) * 1000
|
||||
bucket := ms - (ms % 60000)
|
||||
want := (int64(0x1afffffffffffe) - bucket*4096) & 0xFFFFFFFFFFFF
|
||||
if got := seg; got != fmtHex12(want) {
|
||||
t.Fatalf("session 段公式不符: got=%s want=%s (bucket=%d)", got, fmtHex12(want), bucket)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tools 兜底:无 tools 时注入 read", func(t *testing.T) {
|
||||
out, err := vm.Transform("opencodezen", "transform_request",
|
||||
`{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts, _ := req["tools"].([]interface{})
|
||||
if len(ts) == 0 {
|
||||
t.Fatalf("必须注入 tools: %s", out)
|
||||
}
|
||||
found := false
|
||||
for _, x := range ts {
|
||||
fn := x.(map[string]interface{})["function"].(map[string]interface{})
|
||||
if fn["name"] == "read" {
|
||||
p, _ := fn["parameters"].(map[string]interface{})
|
||||
props, _ := p["properties"].(map[string]interface{})
|
||||
if len(props) == 0 {
|
||||
t.Fatalf("read 的 properties 必须非空(门禁要求): %s", out)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("必须含名为 read 的工具: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tools 保留:用户自带 read 不被覆盖", func(t *testing.T) {
|
||||
in := `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}],
|
||||
"tools":[{"type":"function","function":{"name":"read","description":"MY","parameters":{"type":"object","properties":{"a":{"type":"string"}}}}}]}`
|
||||
out, err := vm.Transform("opencodezen", "transform_request", in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal([]byte(out), &req)
|
||||
ts, _ := req["tools"].([]interface{})
|
||||
if len(ts) != 1 {
|
||||
t.Fatalf("不应追加(已有 read): %s", out)
|
||||
}
|
||||
fn := ts[0].(map[string]interface{})["function"].(map[string]interface{})
|
||||
if fn["description"] != "MY" {
|
||||
t.Fatalf("用户定义被覆盖: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func fmtHex12(v int64) string {
|
||||
const hex = "0123456789abcdef"
|
||||
out := make([]byte, 12)
|
||||
for i := 11; i >= 0; i-- {
|
||||
out[i] = hex[v&0xF]
|
||||
v >>= 4
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ★ 免费层只能流式(适配器强制 stream=true),而内核在客户端要非流式时
|
||||
// 会调 transform_response(期望 JSON)⇒ 适配器必须能把 SSE 聚合回非流式对象。
|
||||
// 这条判据钉住那个聚合:真实 SSE 片段 → 完整 chat.completion。
|
||||
func TestOpencodeZenAggregatesSSEForNonStreamClient(t *testing.T) {
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模仿上游真实 SSE(含 reasoning_content 与 usage 末帧)
|
||||
sse := `data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"think "}}]}
|
||||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"HELLO"}}]}
|
||||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":" WORLD"},"finish_reason":"stop"}]}
|
||||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}}
|
||||
data: [DONE]
|
||||
`
|
||||
out, err := vm.Transform("opencodezen", "transform_response", sse)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var u map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &u); err != nil {
|
||||
t.Fatalf("聚合产物不是 JSON: %v (%s)", err, out)
|
||||
}
|
||||
if u["content"] != "HELLO WORLD" {
|
||||
t.Fatalf("content 应为 'HELLO WORLD',实得 %v", u["content"])
|
||||
}
|
||||
tu, _ := u["token_usage"].(map[string]interface{})
|
||||
if tu == nil || tu["prompt"] != float64(7) {
|
||||
t.Fatalf("usage 应透传: %v", u["token_usage"])
|
||||
}
|
||||
if u["finish_reason"] != "stop" {
|
||||
t.Fatalf("finish_reason 应为 stop: %v", u["finish_reason"])
|
||||
}
|
||||
t.Logf("聚合 OK: content=%v usage=%v finish=%v", u["content"], tu, u["finish_reason"])
|
||||
}
|
||||
@ -343,6 +343,49 @@ 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)。
|
||||
local msg = nil
|
||||
if type(resp.error_msg) == "string" and resp.error_msg ~= "" then
|
||||
msg = resp.error_msg
|
||||
elseif type(resp.msg) == "string" and resp.msg ~= "" then
|
||||
msg = resp.msg
|
||||
end
|
||||
-- 腾讯系(buddy/workbuddy)用 msg + displayMsg.{zh,en},且**没有** message。
|
||||
-- 参考 buddy-adapter.ts:576 明确:本地化文案最可读,排在**最前**。
|
||||
local localized = nil
|
||||
if type(resp.displayMsg) == "table" then
|
||||
local dm = resp.displayMsg
|
||||
if type(dm.zh) == "string" and dm.zh ~= "" then localized = dm.zh
|
||||
elseif type(dm.en) == "string" and dm.en ~= "" then localized = dm.en end
|
||||
end
|
||||
if msg then
|
||||
local code = nil
|
||||
if type(resp.error_code) == "string" and resp.error_code ~= "" then
|
||||
code = resp.error_code
|
||||
elseif resp.code ~= nil and type(resp.code) ~= "string" then
|
||||
code = tostring(resp.code)
|
||||
end
|
||||
local detail = code and (code .. ": " .. msg) or msg
|
||||
if localized then return localized .. " " .. detail end
|
||||
return detail
|
||||
end
|
||||
if localized then return localized end
|
||||
if type(resp.error_code) == "string" and resp.error_code ~= "" then
|
||||
return resp.error_code
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
|
||||
105
internal/lua/adapter_prelude_error_shapes_test.go
Normal file
105
internal/lua/adapter_prelude_error_shapes_test.go
Normal file
@ -0,0 +1,105 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 上游错误体抽取必须覆盖**网关与腾讯系**形状 —— 这两类实测都漏过。
|
||||
//
|
||||
// 发现路径(2026-10-16 读生产日志):
|
||||
//
|
||||
// unhandled upstream error body (adapter "openai" lacks transform_error):
|
||||
// status=404 body={"error_msg":"404 Route Not Found"}
|
||||
// unhandled upstream error body (adapter "buddy" lacks transform_error):
|
||||
// status=400 body={"code":11101,"msg":"Non-stream chat request is not supported"}
|
||||
//
|
||||
// 两者都是 `{"error_msg":...}` / `{"msg":...}` —— 旧实现只认
|
||||
// error/message/detail,于是返回 nil ⇒ 客户端只看到
|
||||
// `api error 404: unknown error`,而 body 里明明有可用文案。
|
||||
//
|
||||
// ⚠️ 那两个适配器**都定义了** transform_error(openai.lua:139、buddy.lua:166),
|
||||
// 内核却报 "lacks" ⇒ 一句会把排查带偏的诊断(已另行修正内核文案)。
|
||||
func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) {
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
// 用**仓库里的真实** openai 适配器(含共享前导),不是自造桩:
|
||||
// 判据必须走真实实现,否则"补了形状"这件事根本没被验。
|
||||
src, err := bundledAdapters.ReadFile("adapters/openai.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := vm.LoadAdapterSource("openai", string(src)); err != nil {
|
||||
t.Fatalf("装载 openai 适配器: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name, body, want string
|
||||
}{
|
||||
{"APISIX error_msg",
|
||||
`{"error_msg":"404 Route Not Found"}`, "404 Route Not Found"},
|
||||
{"腾讯 msg(buddy 11101)",
|
||||
`{"code":11101,"msg":"Non-stream chat request is currently not supported"}`,
|
||||
"11101: Non-stream chat request is currently not supported"},
|
||||
{"error_code + error_msg",
|
||||
`{"error_code":"APIG.0301","error_msg":"verify ak sk signature fail"}`,
|
||||
"APIG.0301: verify ak sk signature fail"},
|
||||
{"腾讯 displayMsg 本地化(最可读,排最前)",
|
||||
`{"code":11101,"msg":"Non-stream chat request is currently not supported",` +
|
||||
`"displayMsg":{"en":"Invalid request. Please retry","zh":"请求内容不合法,请重试"}}`,
|
||||
"请求内容不合法,请重试 11101: Non-stream chat request is currently not supported"},
|
||||
{"仅 displayMsg",
|
||||
`{"displayMsg":{"zh":"只有本地化文案"}}`, "只有本地化文案"},
|
||||
{"仅 error_code",
|
||||
`{"error_code":"InferHub.4291.200"}`, "InferHub.4291.200"},
|
||||
// 回归:原有的 6 种形状不得丢
|
||||
{"OpenAI 信封", `{"error":{"message":"boom"}}`, "boom"},
|
||||
{"裸字符串 error", `{"error":"boom"}`, "boom"},
|
||||
{"扁平 code/message", `{"message":"boom"}`, "boom"},
|
||||
{"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) {
|
||||
got, ok, err := vm.TransformError("openai", 400, c.body)
|
||||
if err != nil {
|
||||
t.Fatalf("TransformError: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("★ 抽不出消息(返回 ok=false)⇒ 客户端只会看到 "+
|
||||
"'unknown error',而 body 里明明有:%s", c.body)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("抽出的文案不对\n 实得: %q\n 期望: %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 配对判据:真正**没有** transform_error 的适配器仍应返回 ok=false
|
||||
// (不能为了让上面那条通过就把"抽不出"和"没这个钩子"混为一谈)。
|
||||
func TestPreludeErrorExtractionStillReportsUnknownShapes(t *testing.T) {
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
src, err := bundledAdapters.ReadFile("adapters/openai.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := vm.LoadAdapterSource("openai", string(src)); err != nil {
|
||||
t.Fatalf("装载 openai 适配器: %v", err)
|
||||
}
|
||||
// 一个确实认不出的 JSON(没有 message/error/detail/msg/error_msg)
|
||||
got, ok, err := vm.TransformError("openai", 400, `{"weird":true}`)
|
||||
if err != nil {
|
||||
t.Fatalf("TransformError: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("认不出的形状不该硬凑文案,实得 %q", got)
|
||||
}
|
||||
}
|
||||
97
internal/lua/adapter_zcode_base_test.go
Normal file
97
internal/lua/adapter_zcode_base_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ zcode 适配器**源码模板**必须真的能把 anthropic 基座合并进来。
|
||||
//
|
||||
// 背景:此前适配器在**沙箱里自己读 anthropic.lua** ⇒ 必然失败(沙箱无 fs、
|
||||
// 无 plugin_dir)且**静默降级** ⇒ 只有它自己定义的 2 个函数,缺 7 个
|
||||
// ⇒ 每次调用内核报 "missing transform_response" ⇒ 渠道**完全不可用**。
|
||||
// 该缺陷一直潜伏,因为 zcode 从来没有凭据 ⇒ 源未注册 ⇒ 从未被调用过。
|
||||
//
|
||||
// ⚠️ **本判据的边界(务必如实理解)**:它在测试里**自己**做占位符替换,
|
||||
//
|
||||
// 所以它证明的是「**模板形态**正确 + 合并逻辑能吃下基座」,
|
||||
// 而**不能**证明「installZcodeAdapter 真的内联了非空基座」——
|
||||
// 把安装处的 `asrc` 换成空串,本判据**不会**报红(已实测)。
|
||||
// 安装接线由 internal/core 的端到端判据覆盖:
|
||||
// TestZcodeAdapterInjectsOfficialIdentityBlocks
|
||||
// TestZcodeAdapterKeepsAnthropicProtocolConversion(工具调用/图像块存活)
|
||||
// TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(基座不可得 ⇒ 安装期报错)
|
||||
// 这三条**会**咬住"内联空基座"这个变异(已实测)。
|
||||
// ⇒ 教训:判据自己复现被测逻辑时,必须说清它到底覆盖了哪一段。
|
||||
func TestZcodeAdapterInheritsAnthropicBase(t *testing.T) {
|
||||
psrc, _ := os.ReadFile("plugins/channels.lua")
|
||||
s := string(psrc)
|
||||
diag := regexp.MustCompile(`(?s)local ZCODE_DIAG_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1]
|
||||
ad := regexp.MustCompile(`(?s)local ZCODE_ADAPTER_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1]
|
||||
asrc, err := bundledAdapters.ReadFile("adapters/anthropic.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
full := "local Z = (function()\n" + diag + "\nend)()\n" + ad
|
||||
// ⚠ 占位符不等于"适配器里能读到基座":真正要验的是**装载后 9 个函数都在**。
|
||||
// 故这里按 installZcodeAdapter 的同一手法内联,再逐函数断言。
|
||||
if strings.Contains(full, "__INLINE_ANTHROPIC_SRC__") {
|
||||
full = strings.ReplaceAll(full, "__INLINE_ANTHROPIC_SRC__", quoteLua(string(asrc)))
|
||||
} else {
|
||||
t.Fatal("适配器源码必须有基座占位符(基座由插件主体内联;" +
|
||||
"适配器沙箱里读不到文件)")
|
||||
}
|
||||
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("channels-zcode", full); err != nil {
|
||||
t.Fatalf("装载失败: %v", err)
|
||||
}
|
||||
want := []string{"transform_request", "transform_response", "transform_stream_chunk",
|
||||
"transform_error", "serialize_chunk", "serialize_close", "serialize_open_preamble",
|
||||
"serialize_response", "deserialize_request"}
|
||||
var lack []string
|
||||
for _, f := range want {
|
||||
if !vm.HasFn("channels-zcode", f) {
|
||||
lack = append(lack, f)
|
||||
}
|
||||
}
|
||||
if len(lack) > 0 {
|
||||
t.Fatalf("★ zcode 仍缺 %d 个函数(%v)⇒ 基座继承失败 ⇒ "+
|
||||
"每次调用都会报 'missing <fn>' ⇒ 渠道完全不可用", len(lack), lack)
|
||||
}
|
||||
// 行为验证:基座的 transform_response 必须真的能转换 anthropic 响应
|
||||
out, err := vm.Transform("channels-zcode", "transform_response",
|
||||
`{"id":"m1","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_response 调用失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "hi") {
|
||||
t.Errorf("基座 transform_response 未产出内容: %s", out)
|
||||
}
|
||||
t.Logf("✓ zcode 继承基座成功(9/9 函数),transform_response → %s", out)
|
||||
}
|
||||
|
||||
func quoteLua(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('"')
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch c := s[i]; c {
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
292
internal/lua/adapters/buddy.lua
Normal file
292
internal/lua/adapters/buddy.lua
Normal file
@ -0,0 +1,292 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "buddy"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- Claude-behind-OpenAI upstreams (tabitoken, 扇贝, …) convert /chat/completions
|
||||
-- to the Anthropic Messages API internally, so they inherit Anthropic's
|
||||
-- tool id rule: ^[a-zA-Z0-9_-]{1,64}$, enforced by rejecting the WHOLE request
|
||||
-- with "Invalid tool use format" / "tool_use.id: String should match pattern".
|
||||
-- Plain OpenAI has no such rule, so an OpenAI-compatible model can mint an id
|
||||
-- like "bash:0" (observed from moonshotai/kimi-k3). In a fan-out router that id
|
||||
-- is replayed to every other source, so one such id kills every Claude slot at
|
||||
-- once and an AUTO request falls through all tiers.
|
||||
--
|
||||
-- safe_tool_id is pure and deterministic, so a tool_calls entry and its
|
||||
-- matching tool_call_id are rewritten identically within one request. A
|
||||
-- rewritten id keeps an 8-hex digest of the ORIGINAL id, without which two
|
||||
-- distinct ids could collapse into one ("a:b" and "a_b") and become an
|
||||
-- unpaired/duplicate tool call. Already-legal ids are returned untouched, so
|
||||
-- well-behaved traffic is byte-identical to before.
|
||||
-- (anthropic.lua carries the same helper; Lua adapters have no shared prelude.)
|
||||
local TOOL_ID_MAX = 64
|
||||
|
||||
local function safe_tool_id(id)
|
||||
if type(id) ~= "string" or id == "" then return id end
|
||||
local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_")
|
||||
if clean == id and #clean <= TOOL_ID_MAX then
|
||||
return clean
|
||||
end
|
||||
local digest = string.sub(sha256_hex(id), 1, 8)
|
||||
local keep = TOOL_ID_MAX - #digest - 1
|
||||
if #clean > keep then clean = string.sub(clean, 1, keep) end
|
||||
return clean .. "_" .. digest
|
||||
end
|
||||
|
||||
-- 需要补 reasoning_effort 的模型 → 补哪个档位。
|
||||
--
|
||||
-- 唯一出处:参考 product.ts:237 声明 glm-5.1 的 reasoningEfforts 只有 ['medium']。
|
||||
-- 但实测**任何非空档位都被接受**,故这里取 medium(与参考声明一致)。
|
||||
local NEED_EFFORT = {
|
||||
["glm-5.1"] = "medium",
|
||||
}
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
-- The gateway's private control fields are GONE from the body (v2): the
|
||||
-- neutral `thinking` field carries that intent and each adapter maps it in
|
||||
-- its own protocol terms. These upstreams have no thinking switch at all,
|
||||
-- so the field is simply dropped here — which is what the nil-ing below
|
||||
-- used to do, except the field being dropped is no longer the kernel's
|
||||
-- private one.
|
||||
req.thinking = nil
|
||||
req.extra_body = nil
|
||||
-- ★★ buddy(腾讯 CodeBuddy)上游**只支持流式**:body.stream=false 会被
|
||||
-- 400 拒绝(实测 2026-10-19):
|
||||
-- 11101: Non-stream chat request is currently not supported
|
||||
-- 与 Accept: text/event-stream 头无关(头早已在发),判据是 body。
|
||||
-- 故强制 stream=true,非流式请求由内核的"客户端要非流式、上游回流式"
|
||||
-- 路径消化(transform_response 需能聚合 SSE,见下)。
|
||||
req.stream = true
|
||||
-- ★★ buddy 的 glm-5.1 **要求请求体里有非空的 reasoning_effort**,
|
||||
-- 否则上游回 400:
|
||||
-- {"code":11150,"msg":"the reasoning effort value is not supported
|
||||
-- by the current model"}
|
||||
-- 实测(2026-10-16,逐字对照实验):
|
||||
-- glm-5.1 裸(无该字段) → 400 11150 ✗
|
||||
-- glm-5.1 + reasoning_effort:medium → 200 ✓
|
||||
-- glm-5.1 空串 → 400 11150 ✗
|
||||
-- 任何**非空**档位均可(low/medium/high/xhigh/max/minimal 逐个 200)
|
||||
-- 而其余 15 个 buddy 模型裸跑全 200(多带该字段也无害)—— 即这是
|
||||
-- glm-5.1 **独有**的要求。
|
||||
-- ⚠️ 只对**已实测需要**的模型补(而不是一律补):一律补会把"某个模型的
|
||||
-- 特殊要求"扩成"全平台行为",将来上游若对某些模型收紧就会一次性弄坏
|
||||
-- 所有模型。
|
||||
if NEED_EFFORT[req.model or ""] and
|
||||
not (type(req.reasoning_effort) == "string" and req.reasoning_effort ~= "") and
|
||||
req.reasoning_effort == nil then
|
||||
req.reasoning_effort = NEED_EFFORT[req.model]
|
||||
end
|
||||
if req.messages then
|
||||
for _, msg in ipairs(req.messages) do
|
||||
msg.reasoning_content = nil
|
||||
if msg.tool_call_id ~= nil then
|
||||
msg.tool_call_id = safe_tool_id(msg.tool_call_id)
|
||||
end
|
||||
if type(msg.tool_calls) == "table" then
|
||||
for _, tc in ipairs(msg.tool_calls) do
|
||||
if type(tc) == "table" and tc.id ~= nil then
|
||||
tc.id = safe_tool_id(tc.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- aggregate_sse(raw):把上游 SSE(data: 行流)聚合成一个非流式 OpenAI 响应。
|
||||
-- 强制 stream=true 后,内核的非流式路径会在这里收到完整 SSE 原文——必须聚合,
|
||||
-- 否则 json.decode 失败原样透传,Go 侧 unmarshal 报 invalid character 'd'。
|
||||
-- 聚合 content / reasoning_content / tool_calls(按 index 拼接分片)/
|
||||
-- finish_reason / usage;无可解析帧时返回 nil(调用方回退原样)。
|
||||
local function aggregate_sse(raw)
|
||||
local content, reasoning = {}, {}
|
||||
local finish, usage, id, model, created = nil, nil, nil, nil, nil
|
||||
local tools_by_index = {}
|
||||
local seen = false
|
||||
for line in (raw .. "\n"):gmatch("([^\n]*)\n") do
|
||||
local s = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if s:sub(1, 5) == "data:" then
|
||||
local payload = s:sub(6):gsub("^%s+", "")
|
||||
if payload ~= "" and payload ~= "[DONE]" then
|
||||
local ok, d = pcall(json.decode, payload)
|
||||
if ok and type(d) == "table" then
|
||||
seen = true
|
||||
id = id or d.id
|
||||
model = model or d.model
|
||||
created = created or d.created
|
||||
if type(d.usage) == "table" then usage = d.usage end
|
||||
local ch = d.choices and d.choices[1]
|
||||
if type(ch) == "table" then
|
||||
if type(ch.finish_reason) == "string" and ch.finish_reason ~= "" then
|
||||
finish = ch.finish_reason
|
||||
end
|
||||
local dl = ch.delta
|
||||
if type(dl) == "table" then
|
||||
if type(dl.content) == "string" then content[#content + 1] = dl.content end
|
||||
if type(dl.reasoning_content) == "string" then
|
||||
reasoning[#reasoning + 1] = dl.reasoning_content
|
||||
end
|
||||
if type(dl.tool_calls) == "table" then
|
||||
for _, tc in ipairs(dl.tool_calls) do
|
||||
if type(tc) == "table" and tc.index ~= nil then
|
||||
local slot = tools_by_index[tc.index]
|
||||
if slot == nil then
|
||||
slot = { id = tc.id, type = tc.type or "function",
|
||||
["function"] = { name = "", arguments = "" } }
|
||||
tools_by_index[tc.index] = slot
|
||||
end
|
||||
if tc.id then slot.id = tc.id end
|
||||
local fn = tc["function"]
|
||||
if type(fn) == "table" then
|
||||
if fn.name then slot["function"].name = slot["function"].name .. fn.name end
|
||||
if fn.arguments then slot["function"].arguments = slot["function"].arguments .. fn.arguments end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not seen then return nil end
|
||||
local msg = { role = "assistant", content = table.concat(content) }
|
||||
local rc = table.concat(reasoning)
|
||||
if rc ~= "" then msg.reasoning_content = rc end
|
||||
local ordered = {}
|
||||
for i = 0, 63 do
|
||||
if tools_by_index[i] then ordered[#ordered + 1] = tools_by_index[i] end
|
||||
end
|
||||
if #ordered > 0 then msg.tool_calls = ordered end
|
||||
local out = {
|
||||
id = id or "", object = "chat.completion", created = created or 0,
|
||||
model = model or "",
|
||||
choices = { { index = 0, message = msg,
|
||||
finish_reason = (finish and finish ~= "") and finish or "stop" } },
|
||||
}
|
||||
if usage then out.usage = usage end
|
||||
local ok2, enc = pcall(json.encode, out)
|
||||
if not ok2 then return nil end
|
||||
return enc
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- 上游只会回流式(transform_request 强制 stream=true)。客户端要非流式时
|
||||
-- 内核把完整 SSE 原文交到这里,先聚合再走统一转换。
|
||||
if type(raw_body) == "string"
|
||||
and (raw_body:match("^%s*data:") or raw_body:match("[\r\n]data:")) then
|
||||
local aggregated = aggregate_sse(raw_body)
|
||||
if aggregated ~= nil then raw_body = aggregated end
|
||||
end
|
||||
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
|
||||
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
|
||||
return __prelude.openai_response(raw_body)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
|
||||
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
uses = {
|
||||
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||
}
|
||||
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
|
||||
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
|
||||
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then
|
||||
if uses ~= nil then
|
||||
-- usage-only chunk is not a content/finish signal; the gateway
|
||||
-- emits its own terminal stop chunk and merges this usage.
|
||||
return json.encode({ usage = uses, done = false })
|
||||
end
|
||||
return ""
|
||||
end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
if delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then
|
||||
-- Do not serialize an empty Lua table as JSON `{}`. That shape is
|
||||
-- neither an OpenAI tool-call array nor an omitted field, and several
|
||||
-- agent clients reject it on the final chunk.
|
||||
for _, tc in ipairs(delta.tool_calls) do
|
||||
if type(tc) == "table" and tc.id ~= nil then
|
||||
tc.id = safe_tool_id(tc.id)
|
||||
end
|
||||
end
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:标准 OpenAI 信封 {error:{message,...}}
|
||||
function adapter.transform_error(status, body)
|
||||
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
|
||||
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
|
||||
return __prelude.openai_error(status, body)
|
||||
end
|
||||
|
||||
|
||||
-- ★ build_headers:buddy 要求客户端身份头 + 流式的 Accept。
|
||||
--
|
||||
-- 实测(2026-10-16):不发 `Accept: text/event-stream` 时上游接受请求(200)
|
||||
-- 但只回一帧空内容(completion_tokens=0),既不报错也不产出正文 —— 表现是
|
||||
-- "模型不回答",极易误判成模型坏了或额度不足。
|
||||
-- 参考 buddy-adapter.ts 的 send() 固定发该头。
|
||||
--
|
||||
-- 身份头照参考 credentialRequestHeaders + CODEBUDDY 产品配置:
|
||||
-- X-Domain 用产品域(不用凭据里的 domain 快照,那是登录时的区域,
|
||||
-- 跨产品迁移后会指错区);X-Product 是**部署类型**(SaaS);X-Product-Code
|
||||
-- 是产品码(codebuddy)。
|
||||
function adapter.build_headers(meta)
|
||||
local h = {
|
||||
["Accept"] = "text/event-stream",
|
||||
["Content-Type"] = "application/json",
|
||||
["X-Domain"] = "copilot.tencent.com",
|
||||
["X-Product"] = "SaaS",
|
||||
["X-Product-Code"] = "codebuddy",
|
||||
["User-Agent"] = "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
-- Authorization 由内核在 buildHeaders 之后补(值为源的 api_key)——
|
||||
-- 这里显式给出同一份,保证与其它 buddy 请求形态一致。
|
||||
if type(meta) == "table" and type(meta.api_key) == "string" and meta.api_key ~= "" then
|
||||
h["Authorization"] = "Bearer " .. meta.api_key
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
return adapter
|
||||
564
internal/lua/adapters/opencodezen.lua
Normal file
564
internal/lua/adapters/opencodezen.lua
Normal file
@ -0,0 +1,564 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "opencodezen"
|
||||
adapter.version = "1.1.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
-- ★ hidden:不进用户可见适配器列表(v1.1.0)。本适配器是 OpenCode 免费层的
|
||||
-- **门禁专用形态**(强制 stream、注入 read 桩工具、会话号服从倒计时公式),
|
||||
-- 直接暴露会被误当成通用 OpenAI 适配器选去接别的上游,结果不可预测。
|
||||
-- 被 localzen 源引用时内核仍会把它列出来(源编辑器回显),其余场合隐藏。
|
||||
adapter.hidden = true
|
||||
|
||||
-- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1)
|
||||
--
|
||||
-- 与 OpenCode Go(opencodego.lua,https://opencode.ai/zen/go/v1)是两个不同的
|
||||
-- 服务,行为要求并不相同,因此各有专用适配器:
|
||||
--
|
||||
-- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端
|
||||
-- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in
|
||||
-- OpenCode"。
|
||||
-- ★ reasoning_content:**保留**(与 Go 侧一致)。此前这里写「剥掉」是基于
|
||||
-- 「免费池以非 thinking 模型为主、回传会报错」的假设,**生产实测已证伪** ——
|
||||
-- 剥掉恰恰会引发
|
||||
-- The `reasoning_content` in the thinking mode must be passed back to the API
|
||||
-- (详见 transform_request 内的长注释:失败只在「上一轮真的产出了 reasoning」
|
||||
-- 时才出现,故表现为同一会话里成功/失败交替,极难归因)。
|
||||
-- 参考的共享序列化器(openai-compat.ts)也是「有就回传」。
|
||||
-- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content,
|
||||
-- 否则 thinking 模式直接 400。
|
||||
--
|
||||
-- 缺 x-opencode-client/session/request/project 身份头会被判为匿名客户端,
|
||||
-- 部分模型在带 tools 时上游直接失败(流式返回单 chunk
|
||||
-- "finish_reason":"network_error" 空 content,非流式 503
|
||||
-- "Endpoint is unavailable",表现为"空回复")。
|
||||
-- ============================================================================
|
||||
-- ★ opencode Zen 免费层门禁(2026-10-15 实测解出,逐条对齐官方 CLI)
|
||||
--
|
||||
-- 上游对免费池有**三个**必须同时满足的条件,缺任一则 403 FreeTierError
|
||||
-- ("free tier can only be used from within OpenCode"),与 UA / TLS 指纹 / 凭据来源
|
||||
-- 均无关:
|
||||
--
|
||||
-- 1. 端点必须是 /inference/v1/chat/completions(不是 /zen/v1)
|
||||
-- —— 由源的 base_url 决定,不在本适配器内。
|
||||
-- 2. 必须同时发 x-opencode-session **与** x-opencode-session-id(值相同)。
|
||||
-- 两个 id 的 12 位 hex 段由**确定公式**给出(见下),不是随机数;
|
||||
-- 时间容差实测 ≥ 60 秒,故 os.time() 的秒精度足够。
|
||||
-- 3. 请求的 tools 里必须有一个名为 read 的工具,且其 parameters.properties
|
||||
-- **非空**。这是唯一的 tools 要求:实测抽掉官方 11 个工具中的任意一个均为
|
||||
-- 200,**只有抽掉 read 变 403**;且 properties 里放任意属性名即可,
|
||||
-- 描述文字 / $schema / required 都不需要(properties 为空对象则 403)。
|
||||
-- ⇒ 无 tools 的纯对话请求会被拒,故 transform_request 需兜底注入桩工具。
|
||||
-- 4. **stream 必须为 true**:实测同为合法请求,stream=false 恒 403、stream=true 恒 200
|
||||
-- (包括 max_tokens 20/60/200/1000/32000 均 200)。
|
||||
-- ⇒ 非流式请求会被拒,故 transform_request 强制 stream=true。
|
||||
--
|
||||
-- 明确**不影响**通过的(均实测):system 内容(含无 system)、消息条数
|
||||
-- (2/3/4/6 条均 200)、凭据(Bearer public 与普通 API key 均可)、
|
||||
-- x-opencode-project / x-opencode-client / UA 里的 opencode/ 前缀。
|
||||
--
|
||||
-- ⚠️ 参考实现 dsh-our-free-model 的门禁桩(bash + read,但无真实 properties)
|
||||
-- 恰好卡在第 3 条上 —— 即它「伪装需跟进上游更新」的真实原因。
|
||||
adapter.headers = {
|
||||
["User-Agent"] = "opencode/1.19.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
|
||||
}
|
||||
|
||||
-- rand_id:按官方的 48-bit 公式生成 12 位 hex 段 + 14 位 base62 随机段。
|
||||
--
|
||||
-- 公式逆自官方 CLI 本地库(~/.local/share/opencode/opencode.db 的 session 表,
|
||||
-- 用 11/12 条真实 id 逐条比对完全一致):
|
||||
--
|
||||
-- MASK = 2^48 - 1
|
||||
-- K = 0x1afffffffffffe (注意不是 27<<48,两者差 2)
|
||||
-- session 段 = (K - now_ms * 4096) & MASK ← **倒计时式**:Δseg/Δms ≡ -4096
|
||||
-- request 段 = (now_ms * 16) & MASK
|
||||
--
|
||||
-- 为什么是「倒计时」而不是时间戳:0x1afffffffffffe 减去 ms*2^12,所以 seg 随
|
||||
-- 时间**递减**。用 f"{ms:012x}" 之类的正向写法必然不对(我为此错过一轮)。
|
||||
--
|
||||
-- ⚠️ 精度:now_ms*4096 ≈ 7.3e15 < 2^53 ≈ 9.0e15,在 double 安全整数范围内,
|
||||
-- 故 LuaJIT 的默认数字类型(double)直接算即可,无需大整数库。
|
||||
local B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
local MASK48 = 281474976710655 -- 2^48 - 1
|
||||
local ID_K = 0x1afffffffffffe
|
||||
|
||||
local function rand_tail(n)
|
||||
local out = {}
|
||||
for i = 1, n do
|
||||
local v = 0
|
||||
if math and math.random then
|
||||
v = math.random(1, 62)
|
||||
else
|
||||
-- 无 math 时用时间做退化熵源:只要唯一,不要求密码学强度。
|
||||
v = (os.time() * 7 + i * 13) % 62 + 1
|
||||
end
|
||||
out[i] = B62:sub(v, v)
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- now_ms:毫秒。沙箱只有 os.time()(秒)与 os.clock()(CPU 时间,无用),
|
||||
-- 而上游时间容差实测 ≥ 60 秒,故秒精度足够;毫秒位补 0。
|
||||
-- meta.timestamp 由内核给出(同为秒),优先用它以便同一请求内自洽。
|
||||
local function now_ms(meta)
|
||||
local t = tonumber(meta and meta.timestamp) or (os.time and os.time()) or 0
|
||||
return t * 1000
|
||||
end
|
||||
|
||||
local function rand_id(prefix, ms, kind, affinity)
|
||||
local seg
|
||||
if kind == "session" then
|
||||
seg = (ID_K - ms * 4096) % (MASK48 + 1)
|
||||
else
|
||||
seg = (ms * 16) % (MASK48 + 1)
|
||||
end
|
||||
-- %012x:12 位十六进制,不足补零
|
||||
-- affinity(可空):会话亲和键。非空时其 8 位 hex 摘要混入 tail ——
|
||||
-- 同一分钟桶内不同会话得到不同会号;hex 段不受影响(门禁只校验它)。
|
||||
local tail
|
||||
if type(affinity) == "string" and affinity ~= "" then
|
||||
tail = string.sub(sha256_hex(affinity), 1, 8) .. rand_tail(6)
|
||||
else
|
||||
tail = rand_tail(14)
|
||||
end
|
||||
return prefix .. string.format("%012x", seg) .. tail
|
||||
end
|
||||
|
||||
-- session_bucket_ms 决定上游会话号的**稳定周期**(按整分钟对齐)。
|
||||
--
|
||||
-- 为什么需要稳定:上游前缀缓存是会话级的(实测:同一段 6032 token 提示词,
|
||||
-- 固定会号第 2 次命中 5888;每请求换会号则恒为 0 命中)。而免费层门禁要求
|
||||
-- x-opencode-session 的 12 位 hex 段服从「倒计时」公式(见 rand_id 注释),
|
||||
-- 所以不能真的「随便挑一个固定值」,只能选一个**时间桶**。
|
||||
--
|
||||
-- 取整分钟(而非秒):同一分钟内的所有请求共享一个会话号 ⇒ 既保留缓存命中,
|
||||
-- 又不超出上游的时间容差。实测容差 ≥ 1 小时(-3600s 仍 200),
|
||||
-- 所以一分钟桶有很大余量,即便上游收紧到几分钟也仍然安全。
|
||||
-- ★ 会话亲和(v1.1.0,移植自 _local_archive 版本):整分钟桶让**所有请求**
|
||||
-- 共享会号,多会话并发时互相污染缓存键。改进:桶内再按**会话身份**分桶——
|
||||
-- 1) meta.client_session(pi 等真会话客户端自带,会话恒定)
|
||||
-- 2) 退路:历史里第一条 user 消息做会话指纹(通用 OpenAI 客户端不发任何
|
||||
-- 会话标识,实测抓包确认;第一条 user 消息在整个会话里恒定)
|
||||
-- 3) 再退:无 user 消息时退回纯时间桶(与 v1.0 行为一致)
|
||||
-- 实现:把会话身份 hash 进 14 位 base62 随机段(hex 段仍服从门禁公式)。
|
||||
local function conversation_fingerprint(body)
|
||||
if type(body) ~= "string" or body == "" then return "" end
|
||||
local ok, req = pcall(json.decode, body)
|
||||
if not ok or type(req) ~= "table" then return "" end
|
||||
for _, m in ipairs(req.messages or {}) do
|
||||
if type(m) == "table" and m.role == "user" then
|
||||
local c = m.content
|
||||
if type(c) == "string" then
|
||||
if c ~= "" then return c end
|
||||
elseif type(c) == "table" then
|
||||
for _, part in ipairs(c) do
|
||||
if type(part) == "table" and part.type == "text" and part.text then
|
||||
return part.text
|
||||
end
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
local function session_affinity_key(meta)
|
||||
local cs = meta and meta.client_session
|
||||
if type(cs) == "string" and cs ~= "" then return cs end
|
||||
return conversation_fingerprint(meta and meta.body)
|
||||
end
|
||||
|
||||
local function session_bucket_ms(meta)
|
||||
local ms = now_ms(meta)
|
||||
return ms - (ms % 60000)
|
||||
end
|
||||
|
||||
function adapter.build_headers(meta)
|
||||
local ms = now_ms(meta)
|
||||
local bucket = session_bucket_ms(meta)
|
||||
-- 免费层要求两个头都在且值相同(对齐官方 CLI;缺任一个则 403)。
|
||||
-- 会话亲和键(可能为空)混入 tail:同一分钟桶内不同会话得到不同会号,
|
||||
-- 同一会话跨分钟仍共享指纹 ⇒ 前缀缓存按会话命中而非按分钟命中。
|
||||
local sid = rand_id("ses_", bucket, "session", session_affinity_key(meta))
|
||||
return {
|
||||
["User-Agent"] = adapter.headers["User-Agent"],
|
||||
["x-opencode-client"] = "cli",
|
||||
-- project 固定:同网关实例共享一个工作区身份(非门禁必需,但官方也发)
|
||||
["x-opencode-project"] = "global",
|
||||
["x-opencode-session"] = sid,
|
||||
-- ★ 两个都要:上游对免费池同时校验 x-opencode-session 与这个头。
|
||||
["x-opencode-session-id"] = sid,
|
||||
-- request id 每请求唯一(不参与缓存键),用当前时间而非桶,避免同桶内重复
|
||||
["x-opencode-request"] = rand_id("msg_", ms, "request"),
|
||||
}
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- ★ 免费层 tools 兜底:上游要求 tools 里必须有一个名为 read、且
|
||||
-- parameters.properties 非空的工具(实测唯一要求;抽掉它即 403)。
|
||||
-- 纯对话请求(客户端没传 tools)会被拒,故这里注入一个桩工具。
|
||||
--
|
||||
-- 只补缺,不改用户已有的 tools:用户自带 read 时原样透传(官方 CLI 就是这么发的),
|
||||
-- 用户带的其它工具保留不动。
|
||||
local FREE_GATE_TOOL = "read"
|
||||
local function ensure_free_gate_tool(req)
|
||||
local tools = req.tools
|
||||
if type(tools) ~= "table" then tools = {} req.tools = tools end
|
||||
for _, t in ipairs(tools) do
|
||||
if type(t) == "table" then
|
||||
local fn = t["function"]
|
||||
-- 注意:function 是 Lua 关键字,必须用 req["function"] 形式取下标。
|
||||
if type(fn) == "table" and fn.name == FREE_GATE_TOOL then
|
||||
local p = fn.parameters
|
||||
-- 已有 read 但 properties 为空 ⇒ 补上;否则原样保留用户定义。
|
||||
if type(p) == "table" and type(p.properties) == "table"
|
||||
and next(p.properties) ~= nil then
|
||||
return
|
||||
end
|
||||
fn.parameters = { type = "object",
|
||||
properties = { filePath = { type = "string", description = "Path to read" } } }
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
tools[#tools + 1] = {
|
||||
type = "function",
|
||||
["function"] = {
|
||||
name = FREE_GATE_TOOL,
|
||||
description = "Read a file from the local filesystem.",
|
||||
parameters = { type = "object",
|
||||
properties = { filePath = { type = "string", description = "Path to read" } },
|
||||
required = { "filePath" } },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
|
||||
-- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息
|
||||
-- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游
|
||||
-- "unknown variant `image_url`, expected `text`");带工具调用的必须保留,
|
||||
-- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。
|
||||
-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder:
|
||||
-- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游
|
||||
-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。
|
||||
local ROLE_WHITELIST = {
|
||||
system = true,
|
||||
user = true,
|
||||
assistant = true,
|
||||
tool = true,
|
||||
latest_reminder = true,
|
||||
}
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
-- ★ 免费层 gate:tools 里必须有 read(且 parameters.properties 非空)。
|
||||
-- 放在最前面:它决定本请求会不会被 403,与后面的字段清理无关。
|
||||
-- 对所有模型都注入:免费池与付费池共用同一层形状检查,多一个桩工具无害
|
||||
-- (官方 CLI 本来也带全套工具),但缺了它会让免费模型整体失效。
|
||||
ensure_free_gate_tool(req)
|
||||
-- ★ 免费层只接受流式:实测 stream=false 恒 403(同样 body 改 true 即 200)。
|
||||
-- 转成流式由上游以 SSE 回应,内核本就会聚合/透传流式响应,故对客户端透明。
|
||||
req.stream = true
|
||||
req.disable_thinking = nil
|
||||
req.extra_body = nil
|
||||
-- stream_options is only valid alongside stream:true; sending it on a
|
||||
-- non-streaming request is rejected by strict upstreams.
|
||||
if req.stream then
|
||||
if type(req.stream_options) ~= "table" then req.stream_options = {} end
|
||||
req.stream_options.include_usage = true
|
||||
end
|
||||
if req.messages then
|
||||
local kept = {}
|
||||
for _, msg in ipairs(req.messages) do
|
||||
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
|
||||
msg.role = "system"
|
||||
end
|
||||
-- ★ 必须**保留** reasoning_content(不再剥离)。
|
||||
--
|
||||
-- 历史:这里原先写 `msg.reasoning_content = nil`,理由是「Zen 免费池
|
||||
-- 不接受回传」。**生产实测证伪**(2026-10-16):
|
||||
-- provider localzen: api error 400: Upstream request failed:
|
||||
-- [invalid_request_error] The `reasoning_content` in the thinking
|
||||
-- mode must be passed back to the API.
|
||||
-- 失败形态很隐蔽:**同一模型同一会话里成功与失败交替出现** ——
|
||||
-- 因为只有当上一轮**真的产出了 reasoning**(thinking 模型、
|
||||
-- reasoning_effort 开启、长上下文)时,回放该历史才会被拒;
|
||||
-- 剥掉之后,客户端看到的 502 与「上一轮想过」这件事毫无关联。
|
||||
-- 审计文件里能看到成串 502 紧跟在一个 prompt_tokens=139330 的
|
||||
-- 成功请求之后 —— 那正是「想了、但下一轮我们没把它还回去」。
|
||||
--
|
||||
-- 参考实现(`openai-compat.ts` 的 serializeMessages,opencode 适配器
|
||||
-- 直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段:
|
||||
-- `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}`
|
||||
-- 即「有就回传」,没有额外条件 —— 与本适配器此前的假设方向相反。
|
||||
--
|
||||
-- ⚠️ 不改成「仅当 thinking 开启才回传」:我们**无法**从请求里可靠
|
||||
-- 判定上游是否处于 thinking 模式(真正知道的是上游)。而「有就回传」
|
||||
-- 是零风险方向 —— 上游不需要时忽略即可(实测传与不传同为 200),
|
||||
-- 需要时缺了就是硬 400。
|
||||
if type(msg.reasoning_content) == "string" and msg.reasoning_content == "" then
|
||||
msg.reasoning_content = nil
|
||||
end
|
||||
local drop = false
|
||||
if type(msg.content) == "table" then
|
||||
local parts = {}
|
||||
for _, part in ipairs(msg.content) do
|
||||
if type(part) == "table" and part.type ~= nil and part.type ~= "text" then
|
||||
-- multimodal part not supported by zen
|
||||
else
|
||||
table.insert(parts, part)
|
||||
end
|
||||
end
|
||||
if #parts == 0 then
|
||||
-- Content collapsed to nothing after stripping unsupported
|
||||
-- parts. A message that still carries a tool call must
|
||||
-- NEVER be dropped: the very next message is its tool
|
||||
-- result, and dropping the call orphans that result. The
|
||||
-- model then sees a result for a call it never made and
|
||||
-- re-issues the same tool call on every turn (observed as
|
||||
-- an infinite "repeated tool call" loop).
|
||||
if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0)
|
||||
or msg.tool_call_id ~= nil then
|
||||
msg.content = ""
|
||||
else
|
||||
drop = true
|
||||
end
|
||||
else
|
||||
msg.content = parts
|
||||
end
|
||||
end
|
||||
if not drop then
|
||||
table.insert(kept, msg)
|
||||
end
|
||||
end
|
||||
req.messages = kept
|
||||
end
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- ★ aggregate_sse:把 SSE 流(openai chat.completion.chunk 形状)聚合成一个非流式
|
||||
-- 响应对象,供 transform_response 在「客户端要非流式、上游却回流式」时使用。
|
||||
-- 返回 JSON 字符串;无法聚合(无任何 chunk)时返回 nil。
|
||||
--
|
||||
-- 只处理 content / reasoning_content / finish_reason / usage 四类增量:
|
||||
-- 它们覆盖了内核需要从响应里取的全部字段(正文、结束原因、token 计数)。
|
||||
local function aggregate_sse(raw)
|
||||
local content, reasoning, finish = {}, {}, ""
|
||||
local usage, id, model, created = nil, nil, nil, nil
|
||||
local seen = false
|
||||
for line in (raw .. "\n"):gmatch("([^\n]*)\n") do
|
||||
local s = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if s:sub(1, 5) == "data:" then
|
||||
local payload = s:sub(6):gsub("^%s+", "")
|
||||
if payload ~= "" and payload ~= "[DONE]" then
|
||||
local ok, d = pcall(json.decode, payload)
|
||||
if ok and type(d) == "table" then
|
||||
seen = true
|
||||
id = id or d.id
|
||||
model = model or d.model
|
||||
created = created or d.created
|
||||
if type(d.usage) == "table" then usage = d.usage end
|
||||
local ch = d.choices and d.choices[1]
|
||||
if type(ch) == "table" then
|
||||
if ch.finish_reason and ch.finish_reason ~= "" then
|
||||
finish = ch.finish_reason
|
||||
end
|
||||
local dl = ch.delta
|
||||
if type(dl) == "table" then
|
||||
if type(dl.content) == "string" then content[#content + 1] = dl.content end
|
||||
if type(dl.reasoning_content) == "string" then
|
||||
reasoning[#reasoning + 1] = dl.reasoning_content
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not seen then return nil end
|
||||
local msg = { role = "assistant", content = table.concat(content) }
|
||||
local rc = table.concat(reasoning)
|
||||
if rc ~= "" then msg.reasoning_content = rc end
|
||||
local out = {
|
||||
id = id or "", object = "chat.completion", created = created or 0, model = model or "",
|
||||
choices = { { index = 0, message = msg, finish_reason = finish ~= "" and finish or "stop" } },
|
||||
}
|
||||
if usage then out.usage = usage end
|
||||
local ok2, enc = pcall(json.encode, out)
|
||||
if not ok2 then return nil end
|
||||
return enc
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- ★ 免费层只接受 stream=true(见文件头门禁第 4 条),所以本适配器把每个请求
|
||||
-- 都改成了流式 —— 但内核是按**客户端意图**选路径的:客户端发非流式时,
|
||||
-- 内核走 Provider.Chat 并调本函数(期望 JSON),而上游回的是 SSE。
|
||||
-- 若在这里直接 json.decode 就会得到 "invalid character 'd'" 错误。
|
||||
--
|
||||
-- 因此本函数必须先识别 SSE 并自行聚合,再走原来的 JSON 路径。
|
||||
-- 这样保持内核对上游协议无感(内核不知道也不该知道上游选了流式)。
|
||||
if type(raw_body) == "string" and (raw_body:sub(1, 6) == "data: " or raw_body:find("\ndata: ", 1, true)) then
|
||||
local aggregated = aggregate_sse(raw_body)
|
||||
if aggregated ~= nil then
|
||||
local ok2, resp2 = pcall(json.decode, aggregated)
|
||||
if ok2 and type(resp2) == "table" then raw_body = aggregated end
|
||||
end
|
||||
end
|
||||
local ok, resp = pcall(json.decode, raw_body)
|
||||
if not ok or resp == nil then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = "",
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 }
|
||||
}
|
||||
|
||||
if type(resp.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
local hit = 0
|
||||
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
hit = resp.usage.prompt_tokens_details.cached_tokens
|
||||
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
|
||||
end
|
||||
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
|
||||
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
|
||||
if hit == 0 then
|
||||
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
-- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断
|
||||
-- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。
|
||||
local ctd = resp.usage.completion_tokens_details
|
||||
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
|
||||
unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
-- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。
|
||||
-- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。
|
||||
-- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。
|
||||
if resp.cost ~= nil then
|
||||
unified.cost = tostring(resp.cost)
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
local reasoning = ch.message.reasoning_content or ch.message.reasoning
|
||||
if reasoning then
|
||||
unified.reasoning_content = reasoning
|
||||
end
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||
if not args_ok then args = {} end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = tc["function"].name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
unified.finish_reason = ch.finish_reason or ""
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||
-- (the final usage chunk). Preserve it; the gateway emits it as the
|
||||
-- terminal usage chunk. Note: keys must match Go's TokenUsage json tags
|
||||
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
uses = {
|
||||
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||
}
|
||||
-- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif:
|
||||
-- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让
|
||||
-- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致
|
||||
-- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。
|
||||
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
|
||||
end
|
||||
if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
|
||||
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
|
||||
if uses.prompt_tokens_details == nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
local ctd = chunk.usage.completion_tokens_details
|
||||
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
|
||||
uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then
|
||||
-- 空 choices 的帧既不是内容也不是结束信号。上游发两种:
|
||||
-- {"choices":[],"usage":{...}} —— 终帧用量
|
||||
-- {"choices":[],"cost":"0"} —— 单独的费用帧
|
||||
-- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。
|
||||
local out = { done = false }
|
||||
if uses ~= nil then out.usage = uses end
|
||||
if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end
|
||||
if uses == nil and chunk.cost == nil then return "" end
|
||||
return json.encode(out)
|
||||
end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
-- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content)
|
||||
local reasoning = delta.reasoning_content or delta.reasoning
|
||||
if reasoning then
|
||||
unified.reasoning_content = reasoning
|
||||
end
|
||||
if delta.tool_calls then
|
||||
-- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}};
|
||||
-- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) ~= "table" then return nil end
|
||||
if e.type == "FreeUsageLimitError" then
|
||||
return "zen free pool quota exhausted"
|
||||
end
|
||||
return e.message
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -253,7 +253,7 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
if delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if delta.tool_calls then
|
||||
if type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
|
||||
@ -100,6 +100,8 @@ type handlerRequest struct {
|
||||
|
||||
// handlerResponse is what a Lua handler returns.
|
||||
type handlerResponse struct {
|
||||
// Proxy is a loopback HTTP origin. The gateway owns streaming and upgrades.
|
||||
Proxy string
|
||||
Status int
|
||||
Headers map[string]string
|
||||
Body string
|
||||
@ -180,6 +182,9 @@ func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
|
||||
return nil, fmt.Errorf("status must be a number, got %T", s)
|
||||
}
|
||||
}
|
||||
if target, ok := m["proxy"].(string); ok {
|
||||
out.Proxy = target
|
||||
}
|
||||
if b, ok := m["body"].(string); ok {
|
||||
out.Body = b
|
||||
}
|
||||
@ -196,9 +201,9 @@ func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
|
||||
// return. A table carrying ONLY a status is not: `{status=204}` is the normal
|
||||
// way to answer with no body, and treating it as an error would make the
|
||||
// correct spelling unusable.
|
||||
if out.Body == "" && out.JSON == nil && out.Headers == nil && len(m) == 0 {
|
||||
if out.Body == "" && out.JSON == nil && out.Headers == nil && out.Proxy == "" && len(m) == 0 {
|
||||
return nil, fmt.Errorf("handler returned an empty table; return " +
|
||||
"{status=…, body=…}, {status=…, json=…}, or {status=…} for no body")
|
||||
"{status=…, body=…}, {status=…, json=…}, {status=…, proxy=…}, or {status=…} for no body")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
117
internal/lua/plugin_icons_test.go
Normal file
117
internal/lua/plugin_icons_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 侧边栏图标判据 —— 覆盖**所有**带页面的插件,不只是 billing。
|
||||
//
|
||||
// 为什么需要这个测试
|
||||
// ------------------
|
||||
// billing 当年声明了 icon = "",WebUI 把空串原样塞进按钮 ⇒ 侧边栏那条是空白。
|
||||
// 当时只给 billing 加了判据(TestPluginIconIsNotARawEmoji)。后来 channels
|
||||
// 声明 icon = ""、quota 连 icon 字段都没有 ⇒ 同样的"空白条目"缺陷又一次出现,
|
||||
// 而判据只管 billing,所以全绿。
|
||||
//
|
||||
// 这就是"判据只覆盖出错的那一个文件"的代价:同类缺陷换个文件复发时没有任何
|
||||
// 东西会响。本文件把范围改成**按页面遍历**,新增带页面的插件自动纳入。
|
||||
|
||||
// pageIcons 返回 插件文件 -> 其 page.icon 的声明值。
|
||||
//
|
||||
// 只认 page.icon(侧边栏读的就是它),不认顶层的其他 icon 字段 —— 那些不影响
|
||||
// 侧边栏渲染,混进来会给出假绿。
|
||||
func pageIcons(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s 失败: %v", path, err)
|
||||
}
|
||||
src := string(b)
|
||||
|
||||
// 短字符串:icon = "..."
|
||||
shortRe := regexp.MustCompile(`(?m)^\s*icon\s*=\s*"([^"]*)"`)
|
||||
// 长字符串:icon = [==[ ... ]==](inline SVG 没法写成短字符串而不转义所有引号)
|
||||
longRe := regexp.MustCompile(`(?s)\bicon\s*=\s*\[==\[(.*?)\]==\]`)
|
||||
|
||||
var out []string
|
||||
for _, m := range shortRe.FindAllStringSubmatch(src, -1) {
|
||||
out = append(out, m[1])
|
||||
}
|
||||
for _, m := range longRe.FindAllStringSubmatch(src, -1) {
|
||||
out = append(out, m[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 带页面(plugin.ui.page)的插件。新增页面插件要登记在这里 ——
|
||||
// 这是一份"必须被覆盖的清单",漏登记会让新插件逃避判据。
|
||||
var pagePlugins = []string{
|
||||
"plugins/billing.lua",
|
||||
"plugins/channels.lua",
|
||||
"plugins/quota.lua",
|
||||
}
|
||||
|
||||
// TestEveryPagePluginDeclaresSVGIcon 钉住"每个带页面的插件都有 inline SVG 图标"。
|
||||
//
|
||||
// 两个必查项,各自对应一种真实缺陷:
|
||||
// - 缺 icon / 空串 ⇒ 侧边栏空白(channels 的实测症状)
|
||||
// - emoji ⇒ 尺寸不跟原生 tab、忽略主题色(billing 的历史缺陷)
|
||||
func TestEveryPagePluginDeclaresSVGIcon(t *testing.T) {
|
||||
for _, p := range pagePlugins {
|
||||
t.Run(p, func(t *testing.T) {
|
||||
icons := pageIcons(t, p)
|
||||
if len(icons) == 0 {
|
||||
t.Fatalf("%s 有任何 page 却没有任何 icon 声明 —— 侧边栏那条会是空白", p)
|
||||
}
|
||||
// 只断言"至少有一个非空且是 SVG":一个文件可能有多个页面,
|
||||
// 但不能为了通过而声明一个空图标。
|
||||
var ok bool
|
||||
for _, ic := range icons {
|
||||
if strings.TrimSpace(ic) == "" {
|
||||
continue
|
||||
}
|
||||
if isEmojiIcon(ic) {
|
||||
t.Errorf("%s 的 page icon 是 emoji %q —— 原生 tab 用 inline SVG"+
|
||||
"(stroke: currentColor),emoji 尺寸不一致且不跟主题", p, ic)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(ic, "<svg") {
|
||||
t.Errorf("%s 的 page icon 不是 SVG 标记: %q", p, ic)
|
||||
continue
|
||||
}
|
||||
ok = true
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("%s 没有可用的 SVG page icon(声明的全是空串/emoji/非 SVG)", p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIconUsesCurrentColor 钉住图标**跟随主题色**。
|
||||
//
|
||||
// 参考插件的图标工厂(src/sponsor.js:52)统一用
|
||||
// `stroke="currentColor"` + `strokeWidth 1.6`。写死颜色("#333"、"black")
|
||||
// 会让图标在深色主题下几乎看不见 —— 那是同一个"图标不对"投诉的另一半。
|
||||
func TestIconUsesCurrentColor(t *testing.T) {
|
||||
for _, p := range pagePlugins {
|
||||
t.Run(p, func(t *testing.T) {
|
||||
for _, ic := range pageIcons(t, p) {
|
||||
if strings.TrimSpace(ic) == "" || isEmojiIcon(ic) {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(ic, "currentColor") {
|
||||
t.Errorf("%s 的 icon 没写 stroke/fill=currentColor —— "+
|
||||
"写死颜色在深色主题下不可见: %s", p, ic)
|
||||
}
|
||||
// 写死颜色的显式反例:出现 #rrggbb 或 rgb( 就报
|
||||
if regexp.MustCompile(`#[0-9a-fA-F]{3,8}\b|rgba?\(`).MatchString(ic) {
|
||||
t.Errorf("%s 的 icon 里含写死颜色: %s", p, ic)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -143,6 +143,14 @@ type PluginRuntime struct {
|
||||
ps *Plugins
|
||||
spec scheduleSpec
|
||||
|
||||
// stopOnce makes Stop idempotent. Stop closes a channel and waits on done;
|
||||
// without this, a second Stop panics ("close of closed channel"). That is
|
||||
// reachable in practice now that BOTH Unload and Close stop schedulers:
|
||||
// unloading a plugin then closing the plugin set would otherwise abort the
|
||||
// process during shutdown — the worst possible moment (a panic here masks
|
||||
// whatever the operator was actually shutting down for).
|
||||
stopOnce sync.Once
|
||||
|
||||
mu sync.Mutex
|
||||
// nextDue is when each task should next fire.
|
||||
nextDue map[string]time.Time
|
||||
@ -457,8 +465,10 @@ func (rt *PluginRuntime) Stop() {
|
||||
if rt == nil {
|
||||
return
|
||||
}
|
||||
close(rt.stop)
|
||||
<-rt.done
|
||||
rt.stopOnce.Do(func() {
|
||||
close(rt.stop)
|
||||
<-rt.done
|
||||
})
|
||||
}
|
||||
|
||||
// callFnOn invokes a plugin function on an already-locked state.
|
||||
|
||||
139
internal/lua/plugin_runtime_close_test.go
Normal file
139
internal/lua/plugin_runtime_close_test.go
Normal file
@ -0,0 +1,139 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ★ Close() 必须停掉每个插件的**调度循环**。
|
||||
//
|
||||
// 真实缺陷(与 on_unload 同一形状的第 3 个实例):`Runtime.Stop()` 此前
|
||||
// **只在单插件 Unload 里调**,Close() 不调 —— 于是「进程关闭/测试结束」
|
||||
// 这条路径上,每个插件的 PluginRuntime.loop 永不停止。
|
||||
//
|
||||
// 实测:全量回归在 internal/lua 出现 **69 个 goroutine 卡在 saver/runtime
|
||||
// 循环里**,最终 600s 超时。
|
||||
//
|
||||
// 判据用的是**进程里真实存在的 goroutine 数**:每 LoadSource 一个插件就
|
||||
// 起一个调度循环,Close 必须让它们全部退出 —— 否则 goroutine 只增不减。
|
||||
// 这比"任务被调用过"更贴近缺陷本身(缺陷就是循环不停)。
|
||||
func TestPluginsCloseStopsSchedulerGoroutines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 24 个带调度表的插件:每个起一个 PluginRuntime.loop
|
||||
const n = 24
|
||||
for i := 0; i < n; i++ {
|
||||
src := `
|
||||
local plugin = { name = "s` + string(rune('a'+i)) + `", version = "1.0.0", exports = {} }
|
||||
function plugin.noop_task() end
|
||||
plugin.schedule = { { fn = "noop_task", every = 3600 } }
|
||||
return plugin
|
||||
`
|
||||
name := filepath.Join(pluginDir, "s"+string(rune('a'+i))+".lua")
|
||||
if err := os.WriteFile(name, []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
before := runtime.NumGoroutine()
|
||||
ps := NewPlugins(nil, pluginDir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
loaded := runtime.NumGoroutine()
|
||||
if loaded <= before {
|
||||
t.Fatalf("加载 %d 个带调度的插件后 goroutine 没涨(%d → %d)—— 夹具失效",
|
||||
n, before, loaded)
|
||||
}
|
||||
ps.Close()
|
||||
|
||||
// ★ Close 之后循环必须**全部**退出,即回落到基线附近。
|
||||
//
|
||||
// ⚠️ 这里不能用「比 loaded 小就算过」:那只要**一个** goroutine 退出就成立
|
||||
// (27 → 26 也满足),而 24 个调度循环可以全部还在跑 —— 我第一版就是
|
||||
// 这么写的,被变异测试当场揭穿(去掉 stopSchedulersAll 后仍然绿)。
|
||||
// 判据必须锚定**基线**,容差只留给无关的运行时 goroutine。
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if runtime.NumGoroutine() <= before+2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
after := runtime.NumGoroutine()
|
||||
if after > before+2 {
|
||||
t.Fatalf("Close() 之后仍有调度循环在跑:goroutine 基线 %d、加载后 %d、Close 后 %d(加载了 %d 个插件)—— "+
|
||||
"它们会一直堆积(实测全量回归堆到 69 个卡死并 600s 超时)", before, loaded, after, n)
|
||||
}
|
||||
t.Logf("✓ Close() 停掉了全部调度循环(goroutine 基线 %d → 加载后 %d → Close 后 %d,%d 个插件)",
|
||||
before, loaded, after, n)
|
||||
}
|
||||
|
||||
// ★ Stop() 必须幂等:Unload 与 Close 都会停调度器,重复调不得 panic。
|
||||
//
|
||||
// 反例:Stop 里裸 `close(rt.stop)` —— 第二次调用 `panic: close of closed channel`。
|
||||
// 关闭路径上的 panic 最糟糕:它掩盖掉操作者本来在做的事(关服务/结束测试)。
|
||||
func TestPluginRuntimeStopIsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := `
|
||||
local plugin = { name = "sched2", version = "1.0.0", exports = {} }
|
||||
function plugin.noop_task() end
|
||||
plugin.schedule = { { fn = "noop_task", every = 30 } }
|
||||
return plugin
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "sched2.lua"), []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := NewPlugins(nil, pluginDir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
defer ps.Close()
|
||||
|
||||
ps.mu.RLock()
|
||||
var rt *PluginRuntime
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "sched2" {
|
||||
rt = p.Runtime
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
if rt == nil {
|
||||
t.Fatal("插件未拿到 Runtime(夹具失效)")
|
||||
}
|
||||
// 连调三次:第二次若 panic,本测试就会崩掉(正是要防的行为)
|
||||
rt.Stop()
|
||||
rt.Stop()
|
||||
rt.Stop()
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string, d time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("等待 %s 出现超时 —— 调度任务从未运行(夹具失效)", filepath.Base(path))
|
||||
}
|
||||
|
||||
func fileSize(t *testing.T, path string) int64 {
|
||||
t.Helper()
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat %s: %v", path, err)
|
||||
}
|
||||
return st.Size()
|
||||
}
|
||||
77
internal/lua/plugin_unload_on_close_test.go
Normal file
77
internal/lua/plugin_unload_on_close_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 关闭 Plugins 时必须给插件一次 on_unload 机会。
|
||||
//
|
||||
// 真实缺陷:Close() 此前只关 shard 与 saver,**从不调用 on_unload** —— 而
|
||||
// on_unload 只在单插件 Unload 时调。于是「进程关闭/测试结束」这条路径上,
|
||||
// 起了 OS 进程的插件(回环回调接收器、浏览器栈)永远收不了尾,留下孤儿进程。
|
||||
// 实测本机积累到 995 个 python 监听器(828 个超 24 小时),全是测试残留。
|
||||
//
|
||||
// 这个判据看的是**真实外部效应**(插件在 on_unload 里改磁盘上的文件),
|
||||
// 而不是「钩子被调用过」这类内部信号 —— 后者在钩子写错名字时也会假绿。
|
||||
func TestPluginsCloseCallsOnUnload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
marker := filepath.Join(dir, "unloaded.marker")
|
||||
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := `
|
||||
local plugin = { name = "leaky", version = "1.0.0", exports = {} }
|
||||
plugin.ui = nil
|
||||
function plugin.on_unload()
|
||||
local f = io.open("` + marker + `", "w")
|
||||
if f then f:write("reaped"); f:close() end
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "leaky.lua"), []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ps := NewPlugins(nil, pluginDir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
// 关之前不该有标记
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatal("on_unload 在 Close 之前就被调了?")
|
||||
}
|
||||
ps.Close()
|
||||
|
||||
// ★ 真判据:文件必须被写出来(= on_unload 真的跑过)
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("Close() 没有调用 on_unload —— 起了 OS 进程的插件会留下孤儿进程"+
|
||||
"(真实缺陷:995 个 python 监听器残留)。marker 不存在: %v", err)
|
||||
}
|
||||
t.Log("✓ Close() 触发了 on_unload(孤儿进程不会再堆积)")
|
||||
}
|
||||
|
||||
// on_unload 抛错不得让 Close 崩掉(best-effort 契约)。
|
||||
func TestPluginsCloseSurvivesFailingOnUnload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := `
|
||||
local plugin = { name = "boom", version = "1.0.0", exports = {} }
|
||||
function plugin.on_unload() error("故意炸") end
|
||||
return plugin
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "boom.lua"), []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := NewPlugins(nil, pluginDir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
ps.Close() // 不得 panic
|
||||
}
|
||||
@ -572,10 +572,101 @@ func (ps *Plugins) Close() {
|
||||
if ps == nil {
|
||||
return
|
||||
}
|
||||
// ★ 顺序至关重要:**先停调度,再落盘,最后给插件收尾**。
|
||||
//
|
||||
// saver 的最终写入要读每个插件的 Lua 表;on_unload 之后 state 已关,
|
||||
// 读到的就是空 —— 最后一个区间的累积静默丢失(persist_test 的
|
||||
// TestCloseFlushesTail 正是为守住这一点而存在)。
|
||||
//
|
||||
// 所以不能把 callOnUnloadAll 挪到前面,也不能把 save.Close 挪到后面。
|
||||
//
|
||||
// ★ 而调度器必须停在**落盘之前**:它每 tick 可能调 Lua、改状态,
|
||||
// 落在 save.Close 之后就会写进一个已经收尾的 state;停在最前面也顺带
|
||||
// 保证了「收尾期间不会再有新任务被派发」。
|
||||
//
|
||||
// 真实缺陷(与 on_unload 同一形状的第 3 个实例):Runtime.Stop() 此前
|
||||
// **只在单插件 Unload 里调**,Close() 不调 —— 于是每个插件的调度循环
|
||||
// (PluginRuntime.loop)在「进程关闭/测试结束」这条路径上永不停止。
|
||||
// 实测全量回归在 internal/lua 出现 69 个 goroutine 卡在 saver/runtime
|
||||
// 循环里并超时 600s。
|
||||
ps.stopSchedulersAll()
|
||||
// Shard VMs are separate golua states; leaving them open leaks a VM per
|
||||
// shard on every reload.
|
||||
ps.shards.Shutdown()
|
||||
ps.save.Close()
|
||||
ps.callOnUnloadAll()
|
||||
}
|
||||
|
||||
// stopSchedulersAll 停掉每个插件的调度循环(best-effort,幂等)。
|
||||
//
|
||||
// 与 Unload 里的 Runtime.Stop() 是同一契约:Close 是它在进程退出路径上的
|
||||
// 等价点,不能只有 Unload 有 —— 那正是 on_unload 缺陷的形状(“两处只修一处”)。
|
||||
func (ps *Plugins) stopSchedulersAll() {
|
||||
ps.mu.Lock()
|
||||
plugins := make([]*Plugin, len(ps.plugins))
|
||||
copy(plugins, ps.plugins)
|
||||
ps.mu.Unlock()
|
||||
for _, p := range plugins {
|
||||
if p == nil || p.Runtime == nil {
|
||||
continue
|
||||
}
|
||||
p.Runtime.Stop()
|
||||
p.Runtime = nil
|
||||
}
|
||||
}
|
||||
|
||||
// callOnUnloadAll 依次给每个插件调 on_unload(best-effort)。
|
||||
//
|
||||
// 与 Unload 里的调用共用同一实现:两处若各写一遍,将来必然漂移成一个修了
|
||||
// 一个没修 —— 这正是本次缺陷的形状(Unload 有、Close 没有)。
|
||||
func (ps *Plugins) callOnUnloadAll() {
|
||||
ps.mu.Lock()
|
||||
plugins := make([]*Plugin, len(ps.plugins))
|
||||
copy(plugins, ps.plugins)
|
||||
ps.mu.Unlock()
|
||||
for _, p := range plugins {
|
||||
ps.callOnUnload(p)
|
||||
}
|
||||
}
|
||||
|
||||
// callOnUnload 调单个插件的 on_unload(存在才调),并关掉它的 Lua state。
|
||||
//
|
||||
// 为什么关 state 也在这里:on_unload 之后 state 就没用了,而 state 一旦关掉,
|
||||
// 钩子再也调不了 —— 调用与关闭必须紧邻,否则又会漏。Unload 与 Close 各自持有
|
||||
// 的 state 生命周期不同(Unload 还要 rebuild,Close 之后整体销毁),所以这里
|
||||
// 只做「调用 + 置空」,由调用方决定其余步骤。
|
||||
func (ps *Plugins) callOnUnload(p *Plugin) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if err := ps.invokeOnUnload(p, p.Info.Name); err != nil {
|
||||
log.Printf("[plugins] %s: on_unload: %v", p.Info.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// invokeOnUnload 真正调钩子。抽出来是为了让 Unload 与 Close 共用一份实现
|
||||
// (含 state 判空与错误返回语义),避免两处契约不一致。
|
||||
func (ps *Plugins) invokeOnUnload(p *Plugin, name string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.state == nil || p.state.L == nil {
|
||||
return nil
|
||||
}
|
||||
L := p.state.L
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.Pop(1)
|
||||
return nil
|
||||
}
|
||||
L.GetField(-1, "on_unload")
|
||||
var err error
|
||||
if L.Type(-1) == golua.LUA_TFUNCTION {
|
||||
err = callFnOn(L, "on_unload")
|
||||
}
|
||||
L.Pop(2) // plugin table + field remnant
|
||||
p.state.L.Close()
|
||||
p.state = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadDir loads every .lua file in dir as a plugin. Files are loaded in
|
||||
@ -1941,24 +2032,10 @@ func (ps *Plugins) Unload(name string) error {
|
||||
// only guaranteed point where it still can. Best-effort by design — a
|
||||
// hook that errors must not block an unload the operator explicitly asked
|
||||
// for (a wedged plugin has its own path above).
|
||||
p.mu.Lock()
|
||||
if p.state != nil && p.state.L != nil {
|
||||
p.state.L.GetGlobal(pluginGlobal)
|
||||
if L := p.state.L; L.Type(-1) == golua.LUA_TTABLE {
|
||||
L.GetField(-1, "on_unload")
|
||||
if L.Type(-1) == golua.LUA_TFUNCTION {
|
||||
if err := callFnOn(L, "on_unload"); err != nil {
|
||||
log.Printf("[plugins] %s: on_unload: %v", name, err)
|
||||
}
|
||||
}
|
||||
L.Pop(2) // adapter/plugin table + field remnant
|
||||
} else {
|
||||
L.Pop(1)
|
||||
}
|
||||
p.state.L.Close()
|
||||
p.state = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
//
|
||||
// 与 Close 共用 invokeOnUnload:两处各写一遍必然漂移 —— 本次缺陷正是
|
||||
// 「Unload 有、Close 没有」。
|
||||
_ = ps.invokeOnUnload(p, name)
|
||||
ps.rebuild()
|
||||
return nil
|
||||
}
|
||||
@ -2359,6 +2436,8 @@ type HTTPHandler struct {
|
||||
// Path is the route suffix under /api/plugins/http/. It may contain ":name"
|
||||
// segments, captured into request.params.
|
||||
Path string `json:"path"`
|
||||
// Prefix claims this path and its descendants for a streaming proxy handler.
|
||||
Prefix bool `json:"prefix,omitempty"`
|
||||
// Method is the HTTP method; "" or GET. Only GET/POST/PUT/DELETE/PATCH are
|
||||
// accepted — an arbitrary verb would need a switch in the router for no gain.
|
||||
Method string `json:"method,omitempty"`
|
||||
@ -2483,7 +2562,7 @@ func readPluginHTTP(L *golua.State) []HTTPHandler {
|
||||
continue
|
||||
}
|
||||
ctx.strictKeys(h, "http[]", "path", "method", "handler", "description",
|
||||
"roles", "keys", "public")
|
||||
"roles", "keys", "public", "prefix")
|
||||
dh := HTTPHandler{
|
||||
Path: decString(h, "path"),
|
||||
Method: decString(h, "method"),
|
||||
@ -2504,6 +2583,9 @@ func readPluginHTTP(L *golua.State) []HTTPHandler {
|
||||
}
|
||||
dh.Handler = name
|
||||
}
|
||||
if p, ok := h["prefix"].(bool); ok {
|
||||
dh.Prefix = p
|
||||
}
|
||||
if p, ok := h["public"].(bool); ok {
|
||||
dh.Public = p
|
||||
}
|
||||
|
||||
@ -602,7 +602,7 @@ plugin.ui = {
|
||||
page = {
|
||||
page_id = "billing",
|
||||
title = "Billing",
|
||||
icon = [==[<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M14.5 9.5a3 3 0 0 0-2.5-1.3c-1.4 0-2.4.7-2.4 1.8 0 2.6 5.2 1.4 5.2 4 0 1.1-1 1.8-2.5 1.8-1.1 0-2.1-.4-2.7-1.2"/><path d="M12 6.4v11.2"/></svg>]==],
|
||||
icon = [==[<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M14.5 9.5a3 3 0 0 0-2.5-1.3c-1.4 0-2.4.7-2.4 1.8 0 2.6 5.2 1.4 5.2 4 0 1.1-1 1.8-2.5 1.8-1.1 0-2.1-.4-2.7-1.2"/><path d="M12 6.4v11.2"/></svg>]==],
|
||||
order = 40,
|
||||
mount = [==[
|
||||
<div id="billing-root" style="padding:16px;min-width:0;max-width:100%;overflow-x:auto">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user