mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 442fa5e3f6 | |||
| 0caa37ea6a | |||
| 33cb2bdb06 | |||
| 0ae7d2c8be | |||
| 02c01582f1 | |||
| 6454b094bc | |||
| 771b95a133 | |||
| 441de434f5 | |||
| 9feef8f4cb | |||
| b5c89fd3d7 | |||
| 7f193a997d | |||
| 055705c134 | |||
| 401a7ead27 | |||
| 411503aea5 | |||
| 662f8f02b6 | |||
| 2113bfc125 | |||
| c9f34538ec | |||
| 12bfd53f16 | |||
| 25970a2135 | |||
| 6f2d4dc6eb | |||
| aff8354c8a | |||
| 091f790734 | |||
| 6eeb5832a3 | |||
| a189c4f61c | |||
| 58073a6cbe | |||
| a5923b3797 | |||
| 91f6d1f32f | |||
| c1048dcd4e | |||
| 2b8fcaf434 |
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.47",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.3",
|
||||
"version": "2.0.47",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.7",
|
||||
"version": "2.0.47",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
@ -81,6 +81,7 @@
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"plugin-sync.js",
|
||||
"preload.js",
|
||||
"renderer/**/*",
|
||||
"build/**/*",
|
||||
|
||||
82
cmd/gui/plugin-sync.js
Normal file
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);
|
||||
});
|
||||
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 代码变更时递增。
|
||||
@ -505,13 +505,22 @@ end
|
||||
|
||||
### 7.1 计费维度
|
||||
|
||||
| 维度 | 用途 | 优先级 |
|
||||
| 维度 | 用途 | token 价覆盖顺序 |
|
||||
|---|---|---|
|
||||
| `sources` | 源的固定价(如按请求计费的转发服务) | 中 |
|
||||
| `models` | 模型的 per-token 价 | 高 |
|
||||
| `default` | 兜底价(通常全 0) | 最低 |
|
||||
| `models` | 模型的 per-token 价 | 中 |
|
||||
| `sources` | 源级价:固定价,或覆盖该源全部模型的 token 价 | 高 |
|
||||
| `keys` | 单个网关 key 的覆盖价 | **最高** |
|
||||
|
||||
**token 价**优先级:`keys` > `models` > `default`。
|
||||
**token 价**优先级:`keys` > `sources` > `models` > `default`。
|
||||
(实现顺序就是"后写覆盖前写":`default` → `models` → `sources` → `keys`;
|
||||
所以**源级 token 价会覆盖模型价**。这是有意的:运营要能给某个源整体改价。)
|
||||
|
||||
⚠️ 正因为 `sources` 会覆盖 `models`,**"标记某源已定价"的条目只能用
|
||||
`{ "per_request": 0 }`**,不能写 `{ "prompt": 0, "completion": 0 }` ——
|
||||
后者会把该源所有模型的 token 价清成 0。编译器为 `mode: token` 的具体 URL
|
||||
规则生成的就是前者(GitCode issue #1)。
|
||||
|
||||
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
|
||||
**`cache_discount`** 见 §7.7。
|
||||
|
||||
@ -599,6 +608,16 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
|
||||
|
||||
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
|
||||
|
||||
**profile 级兜底**:`profiles[].default` 可设为 `free` 或 `unpriced`(默认):
|
||||
|
||||
| `default` | 未匹配任何规则的源 | `unpriced_reqs` |
|
||||
|---|---|---|
|
||||
| `unpriced`(省略时) | 记 0 价,计入未定价债务 | +1 |
|
||||
| `free` | 记 0 价,但视为**刻意免费** | 不增加 |
|
||||
|
||||
另有 `prices.unmatched_sources`:列出没有规则命中的源,网关在应用 profile 时
|
||||
会以 `[billing] WARNING` 打到日志里 —— 看到它就去补规则(或确认 `default` 选对了)。
|
||||
|
||||
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
|
||||
`total` 与各维度,只有金额是 0。
|
||||
|
||||
|
||||
@ -106,6 +106,27 @@ func CompileOpts(profile *config.BillingProfile, sources []config.Source, lenien
|
||||
return prices, nil
|
||||
}
|
||||
|
||||
// UnmatchedRuleURLs lists the non-wildcard rule URLs that match no configured
|
||||
// source. Compile() treats such a URL as a hard error (a typo'd rule prices
|
||||
// nothing), but the CALLER needs the list to say which rule to fix instead of
|
||||
// just "compile failed" — and to explain what the degraded table dropped.
|
||||
func UnmatchedRuleURLs(profile *config.BillingProfile, sources []config.Source) []string {
|
||||
if profile == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for i := range profile.Rules {
|
||||
rule := &profile.Rules[i]
|
||||
if rule.URL == "*" {
|
||||
continue
|
||||
}
|
||||
if len(matchSources(rule.URL, sources)) == 0 {
|
||||
out = append(out, rule.URL)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchSources returns the source names whose base_url matches pattern.
|
||||
// "*" matches every source (used as a catch-all default rule).
|
||||
func matchSources(pattern string, sources []config.Source) []string {
|
||||
@ -245,8 +266,23 @@ func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName
|
||||
// model priced on its own, so the wildcard loses nothing.
|
||||
if rule.URL != "*" {
|
||||
if _, ok := prices["sources"].(map[string]interface{})[srcName]; !ok {
|
||||
// ONLY the marker — deliberately no "prompt"/"completion".
|
||||
//
|
||||
// priceFor() applies the source dimension AFTER the model
|
||||
// dimension, and treats a present source key as an override
|
||||
// (`sp.prompt ~= nil` => out.prompt = sp.prompt). Writing
|
||||
// zeros here therefore rewrote the model price of every model
|
||||
// this rule DOES list to 0 — while out.priced stayed true, so
|
||||
// unpriced_reqs never grew either. Cost 0, no warning: GitCode
|
||||
// issue #1.
|
||||
//
|
||||
// `per_request: 0` is the marker itself: it adds nothing to a
|
||||
// request's cost, but its presence makes the plugin see the
|
||||
// source as priced, which is the only thing this entry is for.
|
||||
// (free/subscription above keep their zero token prices: there
|
||||
// the zero IS the statement.)
|
||||
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
||||
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
||||
"per_request": 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -212,3 +212,105 @@ func TestLongContextStepCompiles(t *testing.T) {
|
||||
t.Error("above=0 must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// A specific-URL token rule marks its source priced, but the entry must carry
|
||||
// ONLY the "this source is priced" marker — not zero token prices.
|
||||
//
|
||||
// Why: `billing.lua`'s priceFor() applies the source dimension AFTER the model
|
||||
// dimension and treats `sp.prompt ~= nil` as an override. A {prompt:0,
|
||||
// completion:0} entry therefore zeroes the model price of every model the rule
|
||||
// DOES list. The symptom is silent: cost is 0 while out.priced is true, so
|
||||
// unpriced_reqs does not grow either (GitCode issue #1).
|
||||
//
|
||||
// The original test only asserted the KEY existed, which froze the broken shape
|
||||
// into a contract; this asserts the entry CONTENT.
|
||||
func TestSpecificURLTokenRuleSourceEntryHasNoZeroTokenPrices(t *testing.T) {
|
||||
sources := []config.Source{{Name: "mine", BaseURL: "https://mine.example.com/v1"}}
|
||||
profile := &config.BillingProfile{
|
||||
ID: "p",
|
||||
Rules: []config.BillingRule{{
|
||||
URL: "https://mine.example.com/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "0.15", Completion: "0.60"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
prices, err := Compile(profile, sources)
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
entry, ok := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("a specific-url token rule must still mark its own source priced")
|
||||
}
|
||||
for _, key := range []string{"prompt", "completion"} {
|
||||
if _, present := entry[key]; present {
|
||||
t.Errorf("source entry carries %s=%v: priceFor() treats it as an override "+
|
||||
"and zeroes the model price (issue #1)", key, entry[key])
|
||||
}
|
||||
}
|
||||
// per_request: 0 is the marker itself: adding a flat fee must not change
|
||||
// the total, and the key's presence is what tells the plugin "priced".
|
||||
if v, present := entry["per_request"]; !present || v != 0.0 {
|
||||
t.Errorf("source entry must carry per_request:0 as the priced marker, got %#v", entry)
|
||||
}
|
||||
// The model price must still be the real per-token value.
|
||||
m, ok := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("model price missing")
|
||||
}
|
||||
if m["prompt"] != 0.15e-6 {
|
||||
t.Errorf("model prompt price = %v, want 1.5e-7 per token", m["prompt"])
|
||||
}
|
||||
}
|
||||
|
||||
// free / subscription are DELIBERATE zeros: their source entries keep the
|
||||
// zero token prices, because there the zero IS the statement.
|
||||
func TestExplicitZeroModesKeepZeroTokenPrices(t *testing.T) {
|
||||
for _, mode := range []string{"free", "subscription"} {
|
||||
profile := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://mine.example.com/v1", Mode: mode, Subscription: 10,
|
||||
}}}
|
||||
prices, err := Compile(profile, []config.Source{
|
||||
{Name: "mine", BaseURL: "https://mine.example.com/v1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s compile: %v", mode, err)
|
||||
}
|
||||
entry, ok := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s: source entry missing", mode)
|
||||
}
|
||||
for _, key := range []string{"prompt", "completion"} {
|
||||
if v, present := entry[key]; !present || v != 0.0 {
|
||||
t.Errorf("%s: entry[%s] = %v (present=%v), want an explicit 0.0",
|
||||
mode, key, v, present)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UnmatchedRuleURLs 是给调用方的"哪条规则写错了"清单:严格编译只会说
|
||||
// "compile failed",而运维需要知道具体 URL;降级应用时也要能说明丢了什么。
|
||||
func TestUnmatchedRuleURLs(t *testing.T) {
|
||||
sources := []config.Source{{Name: "mine", BaseURL: "https://mine.example.com/v1"}}
|
||||
profile := &config.BillingProfile{
|
||||
ID: "p",
|
||||
Rules: []config.BillingRule{
|
||||
{URL: "https://mine.example.com/v1", Mode: "free"},
|
||||
{URL: "https://stale.example.com/v1", Mode: "free"},
|
||||
{URL: "*", Mode: "token", Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1", Completion: "1"},
|
||||
}},
|
||||
{URL: "https://mine.example.com/v1/", Mode: "free"}, // 尾斜杠归一后匹配
|
||||
},
|
||||
}
|
||||
got := UnmatchedRuleURLs(profile, sources)
|
||||
if len(got) != 1 || got[0] != "https://stale.example.com/v1" {
|
||||
t.Fatalf("unmatched = %v, want only the stale URL (wildcard and trailing-slash "+
|
||||
"variants must not be reported)", got)
|
||||
}
|
||||
if nil != UnmatchedRuleURLs(nil, sources) {
|
||||
t.Error("nil profile must yield nil")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
@ -90,7 +95,14 @@ type Source struct {
|
||||
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy
|
||||
// (e.g. http://127.0.0.1:7890). Empty = direct connection. Needed for
|
||||
// upstreams behind geo/IP blocks that only respond through a proxy.
|
||||
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
|
||||
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
|
||||
// Retry is an OPTIONAL upstream retry policy. It exists for upstreams that
|
||||
// answer a business-level rejection with a status code the kernel would
|
||||
// otherwise surface to the client — e.g. a per-account concurrency cap that
|
||||
// clears by itself after tens of seconds. The kernel stays vendor-agnostic:
|
||||
// the matcher is pure DATA (status list + response-body substrings), so no
|
||||
// provider name ever appears in core.
|
||||
Retry *RetryPolicy `yaml:"retry" json:"retry,omitempty"`
|
||||
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
|
||||
Temperature float64 `yaml:"temperature" json:"temperature,omitempty"`
|
||||
MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"`
|
||||
@ -100,6 +112,63 @@ type Source struct {
|
||||
RPM int `yaml:"rpm" json:"rpm,omitempty"` // optional requests-per-minute cap (0 = unlimited)
|
||||
}
|
||||
|
||||
// RetryPolicy describes when a failed upstream attempt may be retried BEFORE
|
||||
// anything is returned to the client. All conditions are data:
|
||||
//
|
||||
// OnStatus - retry only when the HTTP status is one of these
|
||||
// OnBodyContains - ... AND the response body contains one of these substrings
|
||||
// (empty = status alone decides)
|
||||
// Delay - wait between attempts ("10s" etc.); parsed on demand
|
||||
// MaxAttempts - TOTAL attempts including the first (1 or 0 = no retry)
|
||||
//
|
||||
// Scope: the retry happens inside one gateway request, so a stream is only
|
||||
// retried before its first chunk reaches the client (the kernel already holds
|
||||
// the first chunk back), and a non-stream call is retried as a whole.
|
||||
type RetryPolicy struct {
|
||||
OnStatus []int `yaml:"on_status,omitempty" json:"on_status,omitempty"`
|
||||
OnBodyContains []string `yaml:"on_body_contains,omitempty" json:"on_body_contains,omitempty"`
|
||||
Delay string `yaml:"delay,omitempty" json:"delay,omitempty"`
|
||||
MaxAttempts int `yaml:"max_attempts,omitempty" json:"max_attempts,omitempty"`
|
||||
}
|
||||
|
||||
// ShouldRetry reports whether one failed attempt matches this policy.
|
||||
func (r *RetryPolicy) ShouldRetry(status int, body string) bool {
|
||||
if r == nil || r.MaxAttempts <= 1 {
|
||||
return false
|
||||
}
|
||||
statusOK := len(r.OnStatus) == 0
|
||||
for _, s := range r.OnStatus {
|
||||
if s == status {
|
||||
statusOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !statusOK {
|
||||
return false
|
||||
}
|
||||
if len(r.OnBodyContains) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, sub := range r.OnBodyContains {
|
||||
if sub != "" && strings.Contains(body, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DelayDuration is the configured pause between attempts (default 10s).
|
||||
func (r *RetryPolicy) DelayDuration() time.Duration {
|
||||
if r == nil || r.Delay == "" {
|
||||
return 10 * time.Second
|
||||
}
|
||||
d, err := time.ParseDuration(r.Delay)
|
||||
if err != nil || d < 0 {
|
||||
return 10 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Load reads and validates a config file. When the file does not exist yet a
|
||||
// default config is generated at that path first (first-run bootstrap), so a
|
||||
// fresh binary just works: `llmsproxy -config /path/to/config.yaml`.
|
||||
|
||||
@ -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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
59
internal/config/retry_policy_test.go
Normal file
59
internal/config/retry_policy_test.go
Normal file
@ -0,0 +1,59 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ShouldRetry 是纯数据判据:状态码列表 + 响应体子串。它决定"这次失败值不值得
|
||||
// 在网关内部等一会儿重试"。厂商知识绝不能进内核,所以判据必须能用一个表格测清楚。
|
||||
func TestRetryPolicyShouldRetry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
policy *RetryPolicy
|
||||
status int
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{"nil policy", nil, 500, "anything", false},
|
||||
{"max_attempts=1 disables", &RetryPolicy{OnStatus: []int{400}, MaxAttempts: 1}, 400, "x", false},
|
||||
{"status matches, no body filter",
|
||||
&RetryPolicy{OnStatus: []int{429}, MaxAttempts: 3}, 429, "", true},
|
||||
{"status mismatch",
|
||||
&RetryPolicy{OnStatus: []int{429}, MaxAttempts: 3}, 400, "429", false},
|
||||
{"status + body match",
|
||||
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{"TM.00001041"}, MaxAttempts: 3},
|
||||
400, `{"error_code":"TM.00001041"}`, true},
|
||||
{"status matches but body does not",
|
||||
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{"TM.00001041"}, MaxAttempts: 3},
|
||||
400, `{"error_code":"TM.00001005"}`, false},
|
||||
{"empty substring never matches",
|
||||
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{""}, MaxAttempts: 3}, 400, "x", false},
|
||||
{"any of several body signals",
|
||||
&RetryPolicy{OnStatus: []int{400, 429}, OnBodyContains: []string{"TM.00001041", "TPM limit"}, MaxAttempts: 3},
|
||||
429, `{"message":"TPM limit exceeded"}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.policy.ShouldRetry(tc.status, tc.body); got != tc.want {
|
||||
t.Errorf("%s: ShouldRetry(%d, %q) = %v, want %v", tc.name, tc.status, tc.body, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryPolicyDelay(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
policy *RetryPolicy
|
||||
want time.Duration
|
||||
}{
|
||||
{"nil", nil, 10 * time.Second},
|
||||
{"explicit", &RetryPolicy{Delay: "250ms"}, 250 * time.Millisecond},
|
||||
{"garbage falls back", &RetryPolicy{Delay: "not-a-duration"}, 10 * time.Second},
|
||||
{"negative falls back", &RetryPolicy{Delay: "-5s"}, 10 * time.Second},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.policy.DelayDuration(); got != tc.want {
|
||||
t.Errorf("%s: DelayDuration() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
internal/core/billing_apply_test.go
Normal file
60
internal/core/billing_apply_test.go
Normal file
@ -0,0 +1,60 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// 一个过期的规则 URL 不得让**整个**计费 profile 失效。
|
||||
//
|
||||
// 真实事故(GitCode issue #1 排查中发现,生产现网):config.yaml 里
|
||||
// `billing.rules[0].url` 是 `https://opencode.ai/inference/v1`,而该源的
|
||||
// base_url 已是 `.../inference/openai/v1`。严格编译因此返回
|
||||
// "rule url ... matches no configured source base_url",applyBillingDSL 直接
|
||||
// return —— 于是**所有**计费规则都不生效:账单恒为 0、unpriced_reqs 涨到 3.7 万,
|
||||
// 而唯一的痕迹是一行启动日志。这正是「静默少报」最严重的一种。
|
||||
//
|
||||
// 修法:严格编译失败时降级为 lenient(用能匹配的规则),并打 ERROR 说明后果。
|
||||
// 一个 URL 写错不该把整个价目表一起葬送;但也不能装作成功。
|
||||
func TestBillingStaleRuleURLStillAppliesOtherRules(t *testing.T) {
|
||||
cfg := newTestConfig(t)
|
||||
dir := t.TempDir()
|
||||
cfg.PluginDir = filepath.Join(dir, "plugins")
|
||||
cfg.Sources = []config.Source{
|
||||
{Name: "localzen", BaseURL: "https://opencode.ai/inference/openai/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m"}}},
|
||||
}
|
||||
cfg.BillingDSL = &config.BillingDSL{
|
||||
Active: "p",
|
||||
Profiles: []config.BillingProfile{{
|
||||
ID: "p",
|
||||
Rules: []config.BillingRule{
|
||||
// 过期 URL:没有任何源的 base_url 与它相等
|
||||
{URL: "https://opencode.ai/inference/v1", Mode: "free"},
|
||||
// 有效规则:必须仍然生效
|
||||
{URL: "https://opencode.ai/inference/openai/v1", Mode: "free"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
c := newTestCore(t, cfg)
|
||||
c.applyBillingDSL()
|
||||
|
||||
raw := c.plugins.Prices("billing")
|
||||
if raw == nil {
|
||||
t.Fatal("billing has no price table (profile never applied)")
|
||||
}
|
||||
prices, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("price table shape: %#v", raw)
|
||||
}
|
||||
sources, ok := prices["sources"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("prices has no sources: %#v", prices)
|
||||
}
|
||||
if _, ok := sources["localzen"]; !ok {
|
||||
t.Fatalf("一条过期规则 URL 把整个 profile 弄失效了:有效的 free 规则也没有生效,"+
|
||||
"结果是账单恒为 0 且没有告警。prices=%#v", prices)
|
||||
}
|
||||
}
|
||||
@ -55,3 +55,31 @@ func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) {
|
||||
}
|
||||
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 例外也过闸")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
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,
|
||||
"字段名后面还有内容(初版误判下架,实测教训)"},
|
||||
@ -65,6 +68,16 @@ func TestDeadModelGoneJudgement(t *testing.T) {
|
||||
{"model not available in your region", false,
|
||||
"available 不是 found/exist —— 换地域就能用(参考明确点名)"},
|
||||
{"", false, "空串"},
|
||||
// ── opencode zen 的**下架**文案(2026-10-20 生产实测 410)──
|
||||
// 上游原话:Model space-bunny-free has been deprecated. Use
|
||||
// minimax-m3.1-flash-free instead. 此前不识别 ⇒ 该模型一直留在源里,
|
||||
// 用户选中必 410("渠道不可用"的观感来源之一)。
|
||||
{"Model space-bunny-free has been deprecated. Use minimax-m3.1-flash-free instead.",
|
||||
true, "opencode zen 410 下架文案"},
|
||||
{"model has been deprecated", true, "同族最短形态(无模型名)"},
|
||||
// 阴性:参数/字段被弃用 ≠ 模型下架(主语不是 Model)
|
||||
{"parameter temperature has been deprecated", false, "参数弃用不是模型下架"},
|
||||
{"the field max_tokens has been deprecated", false, "字段弃用不是模型下架"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
out, err := ps.Call("channels", "__is_model_gone_probe",
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
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
|
||||
}
|
||||
56
internal/core/channels_platforms_logged_in_test.go
Normal file
56
internal/core/channels_platforms_logged_in_test.go
Normal file
@ -0,0 +1,56 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
// `platforms` 的 logged_in 必须看**账号池**,不能只看旧单账号保险库键。
|
||||
//
|
||||
// 真实缺陷(2026-10-20 用户报「codearts 还是不可用」):
|
||||
// `list_platforms` 当时写的是 `credentialOf("channels:" .. id)` —— 那是旧版
|
||||
// 单账号的键;而现网凭据键是 `channels:<platform>:<accountId>`(一平台多账号)。
|
||||
// 于是**所有**通过账号池接入的平台都被判成未登录,UI 把渠道卡片渲染成"未接入",
|
||||
// 用户看到的正是"渠道不可用",而底层模型其实完全能用。
|
||||
//
|
||||
// 判据钉两层:池里的账号让 logged_in=true,且账号标识能被 UI 拿到。
|
||||
func TestPlatformsLoggedInFromAccountPool(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "codearts", "account_id": "acct-pool-1",
|
||||
"credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`,
|
||||
"nickname": "pool-nick",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
// list_platforms 是 HTTP 路由处理器(不在 plugin.exports 里),走探针取同一份数据
|
||||
m, err := ps.Call("channels", "__platforms_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("__platforms_probe: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
arr, _ := j["platforms"].([]interface{})
|
||||
if len(arr) == 0 {
|
||||
t.Fatalf("platforms 不应为空")
|
||||
}
|
||||
var got map[string]interface{}
|
||||
for _, raw := range arr {
|
||||
p, _ := raw.(map[string]interface{})
|
||||
if p["id"] == "codearts" {
|
||||
got = p
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("platforms 里找不到 codearts")
|
||||
}
|
||||
if got["logged_in"] != true {
|
||||
t.Fatalf("账号池里有凭据的账号,logged_in 必须为 true;实得 %#v", got)
|
||||
}
|
||||
if got["account_id"] != "acct-pool-1" {
|
||||
t.Errorf("account_id 应回池里的账号 id;实得 %#v", got["account_id"])
|
||||
}
|
||||
if got["account"] != "pool-nick" {
|
||||
t.Errorf("account 应回池里的昵称;实得 %#v", got["account"])
|
||||
}
|
||||
if s, _ := got["accounts"].(float64); s < 1 {
|
||||
t.Errorf("accounts 计数应 >=1;实得 %#v", got["accounts"])
|
||||
}
|
||||
}
|
||||
@ -181,9 +181,17 @@ func (h *poolReasonHarness) addAccountOn(platform, id string) {
|
||||
}
|
||||
|
||||
// serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。
|
||||
//
|
||||
// ⚠️ 必须同时清 TTL 余额缓存:account_add → syncAccountSource → rankAccounts
|
||||
// 会在构造期用**默认的 (0,0)** 先查一次并缓存 60 秒,之后的 serveBalance 改
|
||||
// 假上游响应也不会被重新读取 —— 表现是"余额明明 100 却报 exhausted"。
|
||||
// __ttl_reset_probe 是插件自己的清缓存出口(与生产同一实现)。
|
||||
func (h *poolReasonHarness) serveBalance(expiring, permanent int) {
|
||||
h.fail.Store(false)
|
||||
h.body.Store(buddyPkgBody(expiring, permanent))
|
||||
if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil {
|
||||
h.t.Fatalf("清 TTL 缓存: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buddyPkgBody 拼一份 buddy 真实形态的余额响应:
|
||||
@ -207,6 +215,10 @@ func buddyPkgBody(expiring, permanent int) string {
|
||||
|
||||
func (h *poolReasonHarness) serveBalanceError(code int) {
|
||||
h.fail.Store(true)
|
||||
// 同上:清掉构造期缓存,否则"查询失败"用例会命中旧的成功缓存。
|
||||
if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil {
|
||||
h.t.Fatalf("清 TTL 缓存: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *poolReasonHarness) reason() map[string]interface{} {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,环境时区不同也成立:用本地偏移动态算)。
|
||||
}
|
||||
|
||||
@ -23,14 +23,14 @@ 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-del-a") {
|
||||
t.Fatalf("前置条件:账号源应已建立,实得 %v", got)
|
||||
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-del-a") {
|
||||
t.Fatalf("账号已删但源仍在 ⇒ 已删账号继续对外服务:%v", got)
|
||||
if got := h.sourceNames(); hasName(got, "channels-buddy") {
|
||||
t.Fatalf("唯一账号已删但通道源仍在 ⇒ 已删账号继续对外服务:%v", got)
|
||||
}
|
||||
t.Log("✓ 删除账号同时撤掉其源")
|
||||
t.Log("✓ 删除唯一账号同时撤掉通道源")
|
||||
})
|
||||
|
||||
t.Run("未授予 remove_source 时必须**可见地**失败(不能静默)", func(t *testing.T) {
|
||||
@ -41,7 +41,7 @@ func TestAccountDeleteRemovesItsSource(t *testing.T) {
|
||||
// 我们要判的是:**这件事必须被记录下来**,而不是消失在一个被丢弃的返回值里。
|
||||
logs := h.eventLog()
|
||||
joined := strings.Join(logs, " | ")
|
||||
if !strings.Contains(joined, "撤源失败") {
|
||||
if !strings.Contains(joined, "撤旧源失败") && !strings.Contains(joined, "撤源失败") {
|
||||
t.Fatalf("撤源被拒却没有任何记录 ⇒ 权限缺失会一直静默(表现是"+
|
||||
"「删了账号却还在用」)\n事件日志: %s", joined)
|
||||
}
|
||||
|
||||
@ -16,55 +16,40 @@ import (
|
||||
// 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁),
|
||||
// 不是 pool 里的 priority 字段(那个改了不代表路由变了)。
|
||||
func TestAccountReorderActuallyReordersKernelSources(t *testing.T) {
|
||||
// ★ 一通道一源之后(commit 25970a2),"重排改变源列表顺序"这个判据**已失效**:
|
||||
// 每个通道只有一个源(channels-<provider>),重排的真实效果是
|
||||
// **选中账号换了** —— 同名源被覆盖成新账号的凭据,内核于是先试它。
|
||||
// 旧判据按 channels-buddy-<acct> 过滤,只会在新架构下恒得空列表
|
||||
// ("应有 2 个账号源,实得 []"),测的是已不存在的模型。
|
||||
h := newReorderHarness(t)
|
||||
h.addAccount("acct-a")
|
||||
h.addAccount("acct-b")
|
||||
|
||||
// 先确认两个源都在,且初始顺序是 [a, b](account_add 的注册顺序)。
|
||||
// ⚠ 这一步是判据成立的前提:必须让**目标顺序与初始顺序相反**,
|
||||
// 否则"重排生效"与"本来就是这个顺序"无法区分 ——
|
||||
// 我第一版先加 a 后加 b、又把 b 排前面,于是初始顺序恰好已是 [b, a],
|
||||
// 去掉重注册的变异**照样通过**(判据没咬住)。
|
||||
before := h.sourceOrder()
|
||||
if len(before) < 2 {
|
||||
t.Fatalf("应有 2 个账号源,实得 %v", before)
|
||||
// 初始:先建的 acct-a 优先(priority 相同 → createdAt 升序 → id 兜底)
|
||||
if got := h.chosenAccount(); got != "acct-a" {
|
||||
t.Fatalf("前置条件不成立:初始应选 acct-a,实得 %q", got)
|
||||
}
|
||||
if before[0] != "channels-buddy-acct-a" {
|
||||
t.Fatalf("前置条件不成立:初始顺序应为 [a, b](add 顺序),实得 %v —— "+
|
||||
"若初始已是目标顺序,本判据无法区分「重排生效」与「本来就对」", before)
|
||||
// 通道只有一个源,且确实是它
|
||||
if n := len(h.sourceOrder()); n != 1 {
|
||||
t.Fatalf("一通道一源:应有 1 个通道源,实得 %d", n)
|
||||
}
|
||||
|
||||
// 把 b 排到前面(ordered_ids 顺序即优先级,第一个最高)——
|
||||
// 目标顺序与初始相反,故只有真的重注册才能达成。
|
||||
// 把 acct-b 排到最前 —— 只有真的重新选号+重建源,chosen 才会切换
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-b", "acct-a"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := h.sourceOrder()
|
||||
if len(after) < 2 {
|
||||
t.Fatalf("重排后源丢了:%v", after)
|
||||
if got := h.chosenAccount(); got != "acct-b" {
|
||||
t.Fatalf("重排后应改选 acct-b(源同名覆盖成它的凭据),实得 %q\n"+
|
||||
"(只改 pool.priority 不重建源的话,内核仍用旧 api_key ⇒ 路由不变)", got)
|
||||
}
|
||||
|
||||
idxOf := func(list []string, id string) int {
|
||||
for i, n := range list {
|
||||
if n == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
// 源仍在(不能因为重建把源弄丢)
|
||||
if n := len(h.sourceOrder()); n != 1 {
|
||||
t.Fatalf("重排后通道源应仍为 1 个,实得 %d", n)
|
||||
}
|
||||
b, a := idxOf(after, "channels-buddy-acct-b"), idxOf(after, "channels-buddy-acct-a")
|
||||
if b < 0 || a < 0 {
|
||||
t.Fatalf("重排后找不到账号源:%v", after)
|
||||
}
|
||||
if b > a {
|
||||
t.Fatalf("重排未生效:acct-b 应排在 acct-a **之前**(内核先试它),"+
|
||||
"实际顺序 %v\n(只改 pool.priority 不改源注册顺序的话,路由不会变 —— "+
|
||||
"快到期积分仍不会优先消耗)", after)
|
||||
}
|
||||
t.Logf("✓ 源顺序已按重排生效: %v", after)
|
||||
t.Logf("✓ 重排已切换服务账号: %s", h.chosenAccount())
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
@ -97,7 +82,8 @@ func (h *reorderHarness) addAccount(id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
||||
// 每个账号用**不同** token:cho sen 切换后源 api_key 变化才是可观测的
|
||||
"credential": `{"access_token":"AT-` + id + `","uid":"u1"}`,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_add(%s): %v", id, err)
|
||||
}
|
||||
@ -123,7 +109,8 @@ func (h *reorderHarness) sourceOrder() []string {
|
||||
continue
|
||||
}
|
||||
n, _ := m["name"].(string)
|
||||
if len(n) > 15 && n[:15] == "channels-buddy-" {
|
||||
// 一通道一源:源名就是 channels-<provider>(不再带账号后缀)
|
||||
if n == "channels-buddy" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
@ -140,54 +127,57 @@ func (h *reorderHarness) sourceOrder() []string {
|
||||
// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分),
|
||||
// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。
|
||||
func TestPoolListOrderIsDeterministic(t *testing.T) {
|
||||
// ⚠ 这条判据必须走**批量重注册**路径(reorder 会按 poolList 顺序重放源)。
|
||||
// `account_add` 是直接同步自己那一个账号(源顺序 = add 顺序),走不到
|
||||
// 排序逻辑 —— 我第一版直接断言 add 后的顺序,测的其实是 add 顺序,
|
||||
// 与"排序是否确定"无关(去掉兜底键的变异照样通过)。
|
||||
//
|
||||
// 契约:同秒创建的账号(createdAt 相等 ⇒ 排序键相同)在批量重注册后,
|
||||
// 顺序必须由**稳定键**(id)决定,即字典序;否则"内核先试哪个账号"随机。
|
||||
// ★ 一通道一源之后,"源注册顺序 = poolList 顺序"不再可观测(只有一个源)。
|
||||
// 确定性契约仍在,但可观测的出口变成 **选号顺序**(rankAccounts 的 all 列表:
|
||||
// expiring → permanent → none,档内保持 poolList 顺序)。
|
||||
// 故判据改为:同优先级 + 同秒创建时,选号顺序必须由 id 字典序决定。
|
||||
h := newReorderHarness(t)
|
||||
for _, id := range []string{"acct-c", "acct-b", "acct-a"} {
|
||||
h.addAccount(id)
|
||||
}
|
||||
// 触发批量重注册:按 id 字典序显式指定 ordered_ids(priority 依次递减)
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-c", "acct-b", "acct-a"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// priority 已按给定顺序区分开 ⇒ 这里先确认**显式顺序生效**
|
||||
order := h.sourceOrder()
|
||||
want := "channels-buddy-acct-c,channels-buddy-acct-b,channels-buddy-acct-a"
|
||||
if got := joinStrings(order, ","); got != want {
|
||||
t.Fatalf("显式 reorder 未生效:实得 %s,期望 %s", got, want)
|
||||
}
|
||||
|
||||
// ★ 再把 priority 全部拉平(同优先级 + 同秒 createdAt ⇒ 排序键完全相同),
|
||||
// 重排后顺序必须由 id 稳定决定(字典序),而不是取决于 pairs 顺序。
|
||||
h.clearPriorities()
|
||||
if !sameSecond(h.createdAts()) {
|
||||
t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts())
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-a", "acct-b", "acct-c"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.clearPriorities()
|
||||
h.resync("buddy")
|
||||
order2 := h.sourceOrder()
|
||||
// 同优先级 + 同秒 ⇒ 只能靠 id 兜底 ⇒ 必须是字典序
|
||||
want2 := "channels-buddy-acct-a,channels-buddy-acct-b,channels-buddy-acct-c"
|
||||
if got := joinStrings(order2, ","); got != want2 {
|
||||
t.Fatalf("同优先级同秒创建时,顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+
|
||||
// priority 全平 + 同秒 ⇒ 只剩 id 兜底键
|
||||
order := h.rankOrder()
|
||||
want := "acct-a,acct-b,acct-c"
|
||||
if got := joinStrings(order, ","); got != want {
|
||||
t.Fatalf("同优先级同秒创建时,选号顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+
|
||||
"(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)",
|
||||
got, want2)
|
||||
got, want)
|
||||
}
|
||||
t.Logf("✓ 同键时顺序由 id 稳定决定: %s", want2)
|
||||
t.Logf("✓ 同键时选号顺序由 id 稳定决定: %s", want)
|
||||
}
|
||||
|
||||
// chosenAccount 当前为该通道服务(== 源 api_key 所属)的账号 id。
|
||||
func (h *reorderHarness) chosenAccount() string {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__chosen_account_probe", map[string]interface{}{"platform": "buddy"})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__chosen_account_probe: %v", err)
|
||||
}
|
||||
j := jsonOf(h.t, res)
|
||||
s, _ := j["account_id"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// rankOrder 选号顺序(rankAccounts.all 的 id 列表)。
|
||||
func (h *reorderHarness) rankOrder() []string {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__rank_accounts_probe", map[string]interface{}{"platform": "buddy"})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__rank_accounts_probe: %v", err)
|
||||
}
|
||||
j := jsonOf(h.t, res)
|
||||
var out []string
|
||||
arr, _ := j["all"].([]interface{})
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func joinStrings(xs []string, sep string) string {
|
||||
|
||||
@ -120,23 +120,23 @@ func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarnes
|
||||
// ① 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"]}},`+
|
||||
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}}`+
|
||||
`]},`+
|
||||
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置。
|
||||
// 另三个"必被过滤"的条目也放这里 —— 生产里适配器恒发这条通道,
|
||||
// 硬性过滤的判据必须在这条通道上被验证到(放 solo_agent 会因为
|
||||
// admit_functions 策略先行剔除,测试就测不到过滤逻辑了)。
|
||||
`{"function":"solo_work_lite","config_info_list":[`+
|
||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true},`+
|
||||
`{"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}`+
|
||||
|
||||
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
|
||||
}
|
||||
71
internal/core/channels_trae_headers_test.go
Normal file
71
internal/core/channels_trae_headers_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
// TRAE 的**两套头不能混用** —— 这是 2026-10-20 那次"目录永远拉不到"的真因。
|
||||
//
|
||||
// 事故链:模型目录端点在 `trae-api-cn.mchost.guru`(IDE 面),需要 IDE 头族
|
||||
// (Trae UA + X-App-Id + X-Ide-Version* + 凭据里的 X-Machine-Id/X-Device-Id);
|
||||
// 而 `api.trae.cn` 的签到/积分端点需要另一套(VSCode UA + X-Market-* +
|
||||
// X-Lscbd-* + 派生 market device id)。插件当时"复用签到头"去拉目录,上游对
|
||||
// 22 个 function 全部回 `error_code 2001 Config detail for function: X not found`
|
||||
// (HTTP 200!),于是远端目录恒空、只能退回兜底表 —— 用户看到的却是
|
||||
// "trae 模型不正确 / 渠道不可用",与真因隔了整整一层协议。
|
||||
//
|
||||
// 这个测试把两套头的**契约**钉死:键集合必须互不越界。它不联网,因此能
|
||||
// 在 CI 里稳定复现"用错头"这一类回归。
|
||||
func TestTraeHeadersKeptSeparate(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)
|
||||
}
|
||||
out, err := ps.Call("channels", "__trae_headers_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
pick := func(field string) map[string]bool {
|
||||
set := map[string]bool{}
|
||||
arr, _ := j[field].([]interface{})
|
||||
for _, raw := range arr {
|
||||
if s, ok := raw.(string); ok {
|
||||
set[s] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
ide, chk := pick("ide"), pick("checkin")
|
||||
if len(ide) == 0 || len(chk) == 0 {
|
||||
t.Fatalf("两套头都应有内容;实得 ide=%v checkin=%v", ide, chk)
|
||||
}
|
||||
// IDE 面(目录/对话):身份与版本头必须在
|
||||
for _, k := range []string{"X-App-Id", "X-Ide-Version", "X-App-Version-Code",
|
||||
"X-Machine-Id", "X-Device-Id", "Request-Traffic-Type"} {
|
||||
if !ide[k] {
|
||||
t.Errorf("IDE 头缺少 %q(目录端点会回 2001)", k)
|
||||
}
|
||||
}
|
||||
// IDE 面**不得**带 UG/积分的头
|
||||
for _, k := range []string{"X-Market-Client-Id", "X-Lscbd-Aid", "X-Market-User-Id"} {
|
||||
if ide[k] {
|
||||
t.Errorf("IDE 头不应包含 %q(那是签到头)", k)
|
||||
}
|
||||
}
|
||||
// 签到头:UG/积分标记必须在
|
||||
for _, k := range []string{"X-Market-Client-Id", "X-Market-User-Id", "X-Lscbd-Aid"} {
|
||||
if !chk[k] {
|
||||
t.Errorf("签到头缺少 %q", k)
|
||||
}
|
||||
}
|
||||
// 签到头不得冒用 IDE 的 App/机型身份
|
||||
for _, k := range []string{"X-App-Id", "X-Ide-Version", "X-Machine-Id"} {
|
||||
if chk[k] {
|
||||
t.Errorf("签到头不应包含 %q(那是 IDE 身份)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
111
internal/core/channels_trae_models_test.go
Normal file
111
internal/core/channels_trae_models_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
// TRAE 的模型选择器**不许**出现 `custom_model_*`。
|
||||
//
|
||||
// 真实缺陷(2026-10-20 用户报「trae 的模型不正确 / 渠道不可用」):
|
||||
// 这些 id 是 TRAE 的「自定义模型槽位」,上游目录里它们的权威判据是
|
||||
// `is_custom_model: true`(插件 trae_batch 解析器早已按这条准入剔除)。
|
||||
// 但**兜底表**把它们当普通模型列了出来 —— 而远端目录在部分账号下恒空
|
||||
// (企业租户账号实测:22 个 function 全回 error_code 2001 "Config detail
|
||||
// for function: X not found"),于是用户只能看到兜底表,选中这些槽位后
|
||||
// 上游回**空 content**(实测 custom_model_kimi / custom_model_claude /
|
||||
// custom_model_1M 均空),表现就是"模型坏了、渠道不可用"。
|
||||
//
|
||||
// 判据分两层:
|
||||
// - model_list(UI 选择器)不含它们;
|
||||
// - 交给内核的上游源里也不含它们(effectiveModels 走 models_unavailable)。
|
||||
func TestTraeCustomModelSlotsAreNotOffered(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
|
||||
|
||||
out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"})
|
||||
if err != nil {
|
||||
t.Fatalf("model_list: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
models, _ := j["models"].([]interface{})
|
||||
if len(models) == 0 {
|
||||
t.Fatal("trae 应至少有一个可选模型(兜底表)")
|
||||
}
|
||||
for _, raw := range models {
|
||||
m, _ := raw.(map[string]interface{})
|
||||
id, _ := m["id"].(string)
|
||||
if len(id) > 13 && id[:13] == "custom_model_" {
|
||||
t.Fatalf("model_list 不应包含自定义模型槽位 %q(选中后上游回空内容)", id)
|
||||
}
|
||||
}
|
||||
// 可调用模型必须还在(不能把整张表干掉换绿)
|
||||
found := false
|
||||
for _, raw := range models {
|
||||
m, _ := raw.(map[string]interface{})
|
||||
if m["id"] == "glm-5.2" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("可调用的 glm-5.2 不应被一起藏掉;实得 %v", models)
|
||||
}
|
||||
}
|
||||
|
||||
// 上游源的模型清单同样不含占位槽位(客户端按裸名就能调,藏 UI 不够)。
|
||||
func TestTraeSourceOmitsCustomModelSlots(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "trae", "account_id": "trae-a",
|
||||
"credential": `{"access_token":"jwt","uid":"1","device_id":"d","machine_id":"m"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
ls, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
j, _ := ls.(map[string]interface{})["json"].(map[string]interface{})
|
||||
arr, _ := j["sources"].([]interface{})
|
||||
haveTrae := false
|
||||
for _, raw := range arr {
|
||||
m, _ := raw.(map[string]interface{})
|
||||
if m["name"] == "channels-trae" {
|
||||
haveTrae = true
|
||||
}
|
||||
}
|
||||
if !haveTrae {
|
||||
t.Fatalf("trae 账号加好后应产出 channels-trae 源;实得 %v", arr)
|
||||
}
|
||||
// 源里的模型清单从内核真实源读(不是插件的 model_list)
|
||||
srcs := c.Sources()
|
||||
for _, s := range srcs {
|
||||
if s.Name != "channels-trae" {
|
||||
continue
|
||||
}
|
||||
for _, m := range s.Models {
|
||||
if len(m.ID) > 13 && m.ID[:13] == "custom_model_" {
|
||||
t.Fatalf("上游源不应包含自定义模型槽位 %q", m.ID)
|
||||
}
|
||||
}
|
||||
if len(s.Models) == 0 {
|
||||
t.Fatal("channels-trae 源应带模型清单")
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@ -252,14 +252,16 @@ func TestZcodeAdapterKeepsAnthropicProtocolConversion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// ★ 基座(anthropic.lua)读不到时必须**响亮失败**,不得静默退化。
|
||||
// ★ 基座(anthropic.lua)读不到时必须在**安装期响亮失败**,不得静默退化。
|
||||
//
|
||||
// 反例危害:若"尽力而为"(只注入身份块、不作协议转换),会产出一个身份块
|
||||
// 看着正确的请求,而 tools/图像/流式结构全未转换 —— 上游可能 200,但模型
|
||||
// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。
|
||||
//
|
||||
// 判据看的是**适配器的真实产出**:把适配器目录指到空目录(基座不可得),
|
||||
// transform_request 必须返回错误而不是原样透传。
|
||||
// ⚠ 判据随设计更新(v2.0.37):基座**不再由适配器自己读**(适配器沙箱没有
|
||||
//
|
||||
// fs,那样必然失败且静默降级)。现在由**插件主体**读好并内联 ⇒ 基座不可得
|
||||
// 时应在**安装期**就失败(返回 err),而不是等到运行期才发现。
|
||||
func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
|
||||
// 隔离目录:插件目录下没有兄弟 adapters/ 目录 ⇒ 基座读不到
|
||||
dir := t.TempDir()
|
||||
@ -278,21 +280,26 @@ func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := c.VM()
|
||||
if vm == nil {
|
||||
t.Fatal("VM 不可用")
|
||||
// 用空目录重新安装:基座不可得 ⇒ 必须**安装期**失败且原因点明 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)
|
||||
}
|
||||
// 插件自装时若基座读不到,适配器仍应装载(身份块是静态的),
|
||||
// 但调用必须报错
|
||||
out, err := vm.Transform("channels-zcode", "transform_request",
|
||||
`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err == nil {
|
||||
t.Fatalf("基座不可得时必须报错,却成功产出了:%s", out)
|
||||
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 !strings.Contains(err.Error(), "基座") {
|
||||
t.Fatalf("错误信息应点明基座问题(可定位),实得 %v", err)
|
||||
if zc["ok"] == true {
|
||||
t.Fatalf("基座不可得时安装必须失败(否则运行期才炸、且是逐帧炸),实得 %v", zc)
|
||||
}
|
||||
t.Logf("✓ 基座缺失时响亮失败: %v", err)
|
||||
msg, _ := zc["err"].(string)
|
||||
if !strings.Contains(msg, "anthropic") {
|
||||
t.Fatalf("错误信息应点明基座问题(可定位),实得 %q", msg)
|
||||
}
|
||||
t.Logf("✓ 基座缺失时安装期响亮失败: %s", msg)
|
||||
}
|
||||
|
||||
// ★ 日期块必须**幂等**:已以 <system-reminder> 开头则不重复插。
|
||||
|
||||
@ -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")
|
||||
@ -1080,14 +1057,60 @@ func (c *Core) applyBillingDSL() {
|
||||
}
|
||||
prices, err := billing.Compile(profile, c.cfg.Sources)
|
||||
if err != nil {
|
||||
log.Printf("[billing] profile %q not applied: %v", profile.ID, err)
|
||||
return
|
||||
// ONE stale rule URL must not take the whole price table down with it.
|
||||
//
|
||||
// The strict compiler rejects a profile whose rule matches no source,
|
||||
// which is right for catching typos — but the runtime consequence used
|
||||
// to be a bare `return`: every rule stopped applying, the bill came out
|
||||
// at 0 and unpriced_reqs grew forever, with a single startup log line as
|
||||
// the only trace. Found in production while fixing GitCode issue #1
|
||||
// (a rule still pointed at .../inference/v1 after the source moved to
|
||||
// .../inference/openai/v1; 37k requests had been recorded as unpriced).
|
||||
//
|
||||
// So: apply the rules that DO match, and say exactly what was dropped.
|
||||
// The profile still fails loudly — it just no longer fails silently for
|
||||
// everything else.
|
||||
lenient, lerr := billing.CompileOpts(profile, c.cfg.Sources, true)
|
||||
if lerr != nil {
|
||||
log.Printf("[billing] profile %q not applied: %v (lenient retry also failed: %v)",
|
||||
profile.ID, err, lerr)
|
||||
return
|
||||
}
|
||||
def := profile.Default
|
||||
if def == "" {
|
||||
def = "unpriced"
|
||||
}
|
||||
log.Printf("[billing] ERROR profile %q has %d rule URL(s) matching no configured "+
|
||||
"source: %v — applied the remaining %d rule(s); unmatched traffic falls through "+
|
||||
"to default=%q and keeps counting in unpriced_reqs. FIX THE RULE URL: a stale URL "+
|
||||
"used to zero out the entire price table.", profile.ID,
|
||||
len(billing.UnmatchedRuleURLs(profile, c.cfg.Sources)),
|
||||
billing.UnmatchedRuleURLs(profile, c.cfg.Sources),
|
||||
len(profile.Rules)-len(billing.UnmatchedRuleURLs(profile, c.cfg.Sources)), def)
|
||||
prices = lenient
|
||||
}
|
||||
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
|
||||
log.Printf("[billing] applying profile %q: %v", profile.ID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[billing] profile %q applied (%d rules, %d sources)", profile.ID, len(profile.Rules), len(c.cfg.Sources))
|
||||
// unmatched_sources used to be written by the compiler and read by nobody,
|
||||
// so a profile that silently left half the gateway unpriced looked applied
|
||||
// and healthy. Logging it is the cheap reader: an operator who sees source
|
||||
// names here knows exactly which rules to add (GitCode issue #1 follow-up).
|
||||
if unmatched, ok := prices["unmatched_sources"].([]string); ok && len(unmatched) > 0 {
|
||||
log.Printf("[billing] WARNING %d source(s) match no rule and fall through to the "+
|
||||
"profile default: %v (add rules, or set `default: free` if that is intended)",
|
||||
len(unmatched), unmatched)
|
||||
} else if raw, ok := prices["unmatched_sources"].([]interface{}); ok && len(raw) > 0 {
|
||||
names := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
names = append(names, fmt.Sprint(v))
|
||||
}
|
||||
log.Printf("[billing] WARNING %d source(s) match no rule and fall through to the "+
|
||||
"profile default: %v (add rules, or set `default: free` if that is intended)",
|
||||
len(names), names)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateScopeQuotas checks every scope entry's caps before they are stored.
|
||||
|
||||
@ -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"}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -236,19 +236,39 @@ func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
|
||||
if _, ok := sources["localzen"]; !ok {
|
||||
t.Errorf("the free rule did not reach the plugin: %v", keysOf(sources))
|
||||
}
|
||||
// The injected price must be per-token, i.e. 0.15 USD/M => 1.5e-7.
|
||||
// The source entry exists ONLY as the "this source is priced" marker: it
|
||||
// must not carry token prices, because priceFor() applies the source
|
||||
// dimension after the model dimension and would overwrite the model price
|
||||
// with 0 (GitCode issue #1 — silent, unpriced_reqs does not grow either).
|
||||
if entry, ok := cc.(map[string]interface{}); ok {
|
||||
for _, key := range []string{"prompt", "completion"} {
|
||||
if _, present := entry[key]; present {
|
||||
t.Errorf("the injected source entry carries %s: it will zero the "+
|
||||
"model price (issue #1): %#v", key, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The injected MODEL price must be per-token, i.e. 0.15 USD/M => 1.5e-7.
|
||||
// Asserting the exact magnitude catches a unit error (per-million vs
|
||||
// per-token), which would be off by a factor of a million and still look
|
||||
// like "a number".
|
||||
models, ok := cc.(map[string]interface{})["models"].(map[string]interface{})
|
||||
if ok {
|
||||
if m, ok := models["deepseek-v4.1-flash"].(map[string]interface{}); ok {
|
||||
p, _ := m["prompt"].(float64)
|
||||
if p <= 0 || p > 1e-6 {
|
||||
t.Errorf("injected prompt price = %v, want per-token (~1.5e-7); "+
|
||||
"a per-million value here would over-bill by 1e6x", p)
|
||||
}
|
||||
}
|
||||
//
|
||||
// ⚠️ This used to read `cc["models"]`, but `models` lives at
|
||||
// prices["models"], not inside a source entry — so the type assertion was
|
||||
// always false and the whole block was skipped. It had never run, which is
|
||||
// how a per-million unit error could survive a test whose comment claims
|
||||
// to catch exactly that.
|
||||
models, ok := prices["models"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("prices has no models map: %#v", prices)
|
||||
}
|
||||
m, ok := models["deepseek-v4.1-flash"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("the model price was not injected: %v", keysOf(models))
|
||||
}
|
||||
if p, _ := m["prompt"].(float64); p <= 0 || p > 1e-6 {
|
||||
t.Errorf("injected prompt price = %v, want per-token (~1.5e-7); "+
|
||||
"a per-million value here would over-bill by 1e6x", p)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -126,7 +126,12 @@ func TestRealMutants(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "nested metadata no longer read",
|
||||
from: "\tcopyFields(m.Metadata) // nested first",
|
||||
// 锚点必须与 discover.go 逐字节一致:此前这里是 6 个空格,源码已
|
||||
// 变成 2 个空格,于是这条变异永远"找不到锚点"——变异campaign 每次
|
||||
// 都以 anchor not found 失败,而它本该验证"嵌套 metadata 被丢掉"能被
|
||||
// 测试抓住。锚点漂移 = 这条变异从未真正跑过(和 issue #1 里
|
||||
// `cc["models"]` 恒 false 的测试跳过是同一种失效:断言写了但没执行)。
|
||||
from: "\tcopyFields(m.Metadata) // nested first",
|
||||
to: "\tcopyFields(json.RawMessage(nil)) // mutant: nested metadata dropped",
|
||||
},
|
||||
{
|
||||
|
||||
@ -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"}},
|
||||
}},
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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("✓ 四个转换函数齐备")
|
||||
}
|
||||
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 已丢弃")
|
||||
}
|
||||
@ -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
|
||||
@ -1,8 +1,13 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "opencodezen"
|
||||
adapter.version = "1.0.0"
|
||||
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)
|
||||
--
|
||||
@ -11,8 +16,14 @@ adapter.endpoint = "/chat/completions"
|
||||
--
|
||||
-- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端
|
||||
-- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in
|
||||
-- OpenCode"。Zen 的免费池以非 thinking 模型为主,历史上回传
|
||||
-- reasoning_content 会引出上游报错,故这里剥掉(Go 侧相反,见 opencodego.lua)。
|
||||
-- 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。
|
||||
--
|
||||
@ -93,7 +104,7 @@ local function now_ms(meta)
|
||||
return t * 1000
|
||||
end
|
||||
|
||||
local function rand_id(prefix, ms, kind)
|
||||
local function rand_id(prefix, ms, kind, affinity)
|
||||
local seg
|
||||
if kind == "session" then
|
||||
seg = (ID_K - ms * 4096) % (MASK48 + 1)
|
||||
@ -101,7 +112,15 @@ local function rand_id(prefix, ms, kind)
|
||||
seg = (ms * 16) % (MASK48 + 1)
|
||||
end
|
||||
-- %012x:12 位十六进制,不足补零
|
||||
return prefix .. string.format("%012x", seg) .. rand_tail(14)
|
||||
-- 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 决定上游会话号的**稳定周期**(按整分钟对齐)。
|
||||
@ -114,6 +133,41 @@ end
|
||||
-- 取整分钟(而非秒):同一分钟内的所有请求共享一个会话号 ⇒ 既保留缓存命中,
|
||||
-- 又不超出上游的时间容差。实测容差 ≥ 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)
|
||||
@ -123,7 +177,9 @@ function adapter.build_headers(meta)
|
||||
local ms = now_ms(meta)
|
||||
local bucket = session_bucket_ms(meta)
|
||||
-- 免费层要求两个头都在且值相同(对齐官方 CLI;缺任一个则 403)。
|
||||
local sid = rand_id("ses_", bucket, "session")
|
||||
-- 会话亲和键(可能为空)混入 tail:同一分钟桶内不同会话得到不同会号,
|
||||
-- 同一会话跨分钟仍共享指纹 ⇒ 前缀缓存按会话命中而非按分钟命中。
|
||||
local sid = rand_id("ses_", bucket, "session", session_affinity_key(meta))
|
||||
return {
|
||||
["User-Agent"] = adapter.headers["User-Agent"],
|
||||
["x-opencode-client"] = "cli",
|
||||
@ -219,26 +275,50 @@ function adapter.transform_request(raw_body)
|
||||
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
|
||||
msg.role = "system"
|
||||
end
|
||||
-- Zen 免费池不接受回传 reasoning_content(Go 侧相反)
|
||||
msg.reasoning_content = nil
|
||||
-- ★ 必须**保留** 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
|
||||
-- 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).
|
||||
-- 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 = ""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1236,3 +1236,122 @@ func TestLongContextComposesWithCacheDiscount(t *testing.T) {
|
||||
// total = 0.2325
|
||||
approx(t, "step composes with cache discount", got, 0.2325)
|
||||
}
|
||||
|
||||
// ---- 源层零价覆盖模型价(issue #1)---------------------------------------
|
||||
//
|
||||
// 真实缺陷(GitCode issue #1):`compile.go` 给每条**具体 URL** 的 token 规则
|
||||
// 写一条 `{prompt:0, completion:0, per_request:0}` 的源条目,本意只是"标记该源
|
||||
// 已定价";而插件 priceFor() 的源层是**无条件覆盖**模型层:`sp.prompt ~= nil`
|
||||
// 就把模型价改成 0。两条各自正当的规则叠加 ⇒ 每个请求 cost:0,且因为
|
||||
// out.priced=true,连 unpriced_reqs 都不涨 —— 完全静默。
|
||||
//
|
||||
// 这里把两种条目形状都钉住:旧形状必须**真的是错的**(A 行),新形状必须
|
||||
// **保住模型价同时仍算"已定价"**(B 行)。只断言"键存在"是不够的 —— 那正是
|
||||
// 现有测试把错误形状固化成契约的原因。
|
||||
func TestBillingSourceZeroPriceMustNotZeroModelPrice(t *testing.T) {
|
||||
const promptPrice = 1.5e-7 // 0.15 USD/M
|
||||
const completionPrice = 6e-7 // 0.60 USD/M
|
||||
const want = 0.75 // 1e6 * 1.5e-7 + 1e6 * 6e-7
|
||||
|
||||
newPrices := func(sourceEntry map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
|
||||
"models": map[string]interface{}{
|
||||
"m": map[string]interface{}{"prompt": promptPrice, "completion": completionPrice},
|
||||
},
|
||||
"sources": map[string]interface{}{"src": sourceEntry},
|
||||
}
|
||||
}
|
||||
run := func(t *testing.T, sourceEntry map[string]interface{}) (cost, unpriced float64) {
|
||||
t.Helper()
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{"prices": newPrices(sourceEntry)})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "src", "key": "***p", "ok": true,
|
||||
"prompt_tokens": 1000000.0, "completion_tokens": 1000000.0,
|
||||
"time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
total := st["total"].(map[string]interface{})
|
||||
return total["cost"].(float64), st["unpriced_reqs"].(float64)
|
||||
}
|
||||
|
||||
// A 行:compile.go 修复前的形状 —— 这是缺陷本身,必须算成 0。
|
||||
cost, unpriced := run(t, map[string]interface{}{
|
||||
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
||||
})
|
||||
approx(t, "旧形状(全零源条目) cost", cost, 0)
|
||||
if unpriced != 0 {
|
||||
t.Errorf("旧形状下 unpriced_reqs = %v,缺陷正是它也为 0(静默)", unpriced)
|
||||
}
|
||||
|
||||
// B 行:修复后的形状 —— 只带 per_request,不得覆盖模型价。
|
||||
cost, unpriced = run(t, map[string]interface{}{"per_request": 0.0})
|
||||
approx(t, "新形状(仅 per_request) cost", cost, want)
|
||||
if unpriced != 0 {
|
||||
t.Errorf("新形状下 unpriced_reqs = %v,want 0(该源仍算已定价)", unpriced)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一源上一个**未列入价目表**的模型:源条目(仅 per_request)让它保持
|
||||
// "已定价",所以不会被计入 unpriced_reqs —— 这正是 compile 写源条目的原意。
|
||||
func TestBillingSourceEntryStillMarksUnlistedModelPriced(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
|
||||
"models": map[string]interface{}{
|
||||
"priced-model": map[string]interface{}{"prompt": 1.5e-7, "completion": 6e-7},
|
||||
},
|
||||
"sources": map[string]interface{}{
|
||||
"src": map[string]interface{}{"per_request": 0.0},
|
||||
},
|
||||
},
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "unlisted-model", "source": "src", "key": "***p", "ok": true,
|
||||
"prompt_tokens": 1000.0, "completion_tokens": 0.0, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
if got := st["unpriced_reqs"].(float64); got != 0 {
|
||||
t.Errorf("unpriced_reqs = %v,want 0:源条目存在即为已定价源", got)
|
||||
}
|
||||
}
|
||||
|
||||
// profile 级 default(compile.go 写入的 default_mode)必须真的生效。
|
||||
//
|
||||
// 真实缺陷(GitCode issue #1 顺带发现):compile.go:54 写 `default_mode`、
|
||||
// :104 写 `unmatched_sources`,但全仓库没有任何读取方 —— 于是 profile 的
|
||||
// `default: free|unpriced` 在运行时完全等价,区别只存在于 YAML 里。
|
||||
// "free" 是**刻意定价为 0**,不该进未定价统计;"unpriced" 才该。
|
||||
func TestBillingProfileDefaultMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode string
|
||||
wantUnpriced float64
|
||||
}{
|
||||
{"free", 0},
|
||||
{"unpriced", 1},
|
||||
{"", 1}, // 未声明 = 现有行为(未定价债务)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
ps, _ := billingVM(t)
|
||||
prices := map[string]interface{}{
|
||||
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
|
||||
}
|
||||
if tc.mode != "" {
|
||||
prices["default_mode"] = tc.mode
|
||||
}
|
||||
_ = ps.SetState("billing", map[string]interface{}{"prices": prices})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "no-rule-model", "source": "no-rule-source", "key": "***p", "ok": true,
|
||||
"prompt_tokens": 1000.0, "completion_tokens": 10.0, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
if got := st["unpriced_reqs"].(float64); got != tc.wantUnpriced {
|
||||
t.Errorf("default_mode=%q: unpriced_reqs = %v, want %v", tc.mode, got, tc.wantUnpriced)
|
||||
}
|
||||
// 无论哪种模式,成本都是 0;区别只在"是否算未定价债务"。
|
||||
approx(t, "default_mode="+tc.mode+" cost",
|
||||
st["total"].(map[string]interface{})["cost"].(float64), 0)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -189,7 +189,11 @@ local function priceFor(payload)
|
||||
-- look the same: an unpriced model silently costing 0 is the most dangerous
|
||||
-- failure mode a cost plugin has, because the bill still adds up and just
|
||||
-- quietly under-reports. It is counted separately and surfaced in the UI.
|
||||
out = {
|
||||
-- ★ local 是必须的:此前写的是全局 `out = {`,而 priceFor 每个请求都会调用,
|
||||
-- 于是这个表挂在 VM 级全局变量上跨请求残留/泄漏(GitCode issue #1 顺带发现)。
|
||||
-- 功能上因为每次整体重建而侥幸正确,但它是共享可变状态,任何提前 return 或
|
||||
-- 后续分支忘了重建都会让上一单的价格漏到下一单 —— 计费错误不会报错。
|
||||
local out = {
|
||||
prompt = d.prompt or 0, completion = d.completion or 0,
|
||||
per_request = 0, cache_discount = d.cache_discount, peak = d.peak,
|
||||
-- nil = bill reasoning at the completion rate, which is what every
|
||||
@ -236,6 +240,18 @@ local function priceFor(payload)
|
||||
if kp.cache_discount ~= nil then out.cache_discount = kp.cache_discount end
|
||||
if kp.peak ~= nil then out.peak = kp.peak end
|
||||
end
|
||||
|
||||
-- profile-level default for sources no rule matched.
|
||||
--
|
||||
-- compile.go has always written `default_mode` ("free" | "unpriced") into the
|
||||
-- prices table, but NOTHING read it — so `default: free` and
|
||||
-- `default: unpriced` behaved identically at runtime (both fell through to
|
||||
-- prices.default's zeros and counted as unpriced debt). That is a second
|
||||
-- silent under-report (GitCode issue #1 follow-up). "free" is a DELIBERATE
|
||||
-- zero, so it must be marked priced; anything else stays countable debt.
|
||||
if not out.priced and p.default_mode == "free" then
|
||||
out.priced = true
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
@ -602,7 +618,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
@ -1065,6 +1065,9 @@ PLUGIN.ui = {
|
||||
page = {
|
||||
page_id = "quota",
|
||||
title = "Quota",
|
||||
-- ★ 侧边栏图标必须是 **inline SVG**(空串=空白,emoji=尺寸/主题不一致)。
|
||||
-- 图案:三格电池 —— “套餐余量”的直接语义。
|
||||
icon = [==[<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="7" width="16" height="10" rx="2.4"/><path d="M21.5 10.5v3"/><path d="M6 10.5v3M10 10.5v3M14 10.5v3"/></svg>]==],
|
||||
order = 45,
|
||||
mount = [==[
|
||||
<div id="qa-root" class="qa-root">
|
||||
|
||||
@ -93,6 +93,12 @@ type staticInfo struct {
|
||||
// but once an adapter claims the endpoint, protocol iteration lives in the
|
||||
// adapter, not the kernel. Empty means the adapter claims no ingress.
|
||||
ingress string
|
||||
// hidden marks an adapter that should NOT appear in the user-facing adapter
|
||||
// list (GET /api/adapters → UI 的适配器页与源编辑器的适配器下拉)。隐藏的
|
||||
// 适配器仍然正常加载、仍然可以被源引用——它只是不从选择列表里冒出来。
|
||||
// 用途:厂商探针类/内部实验类适配器(如 opencodezen 的免费层门禁形态),
|
||||
// 直接暴露会让运维误当成通用 OpenAI 适配器去选。声明方式:adapter.hidden = true。
|
||||
hidden bool
|
||||
}
|
||||
|
||||
// adapterPool owns the worker states for one adapter. Idle workers are held in
|
||||
@ -596,6 +602,11 @@ type VM struct {
|
||||
dir string
|
||||
pools map[string]*adapterPool
|
||||
|
||||
// hiddenRefs 记录"被源引用的隐藏适配器"。hidden=true 的适配器默认不进
|
||||
// ListAdapters,但被源引用时必须保持可见(源编辑器要能回显当前值)。
|
||||
// 由内核在配置装载/源变更时调用 SetHiddenRefs 维护;nil = 未标记。
|
||||
hiddenRefs map[string]bool
|
||||
|
||||
// janitor drives elastic shrinking. One goroutine serves every pool (not
|
||||
// one per adapter), so an idle gateway costs a single sleeping goroutine.
|
||||
janitorOnce sync.Once
|
||||
@ -603,6 +614,21 @@ type VM struct {
|
||||
janitorDone chan struct{}
|
||||
}
|
||||
|
||||
// SetHiddenRefs 供内核同步"哪些隐藏适配器正被源引用"。传 nil/空集 = 没有引用。
|
||||
func (v *VM) SetHiddenRefs(names []string) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if len(names) == 0 {
|
||||
v.hiddenRefs = nil
|
||||
return
|
||||
}
|
||||
m := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
m[n] = true
|
||||
}
|
||||
v.hiddenRefs = m
|
||||
}
|
||||
|
||||
func NewVM(dir string) *VM {
|
||||
return &VM{
|
||||
dir: dir,
|
||||
@ -799,14 +825,32 @@ func (v *VM) ConfigureConcurrency(adapterConcurrency map[string]int) {
|
||||
func (v *VM) ListAdapters() []APIAdapter {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
// hidden=true 的适配器不进用户可见列表(仍正常加载、仍可被源引用)。
|
||||
// 例外:**正在被某个源引用**的隐藏适配器必须仍出现在列表里——否则源编辑器
|
||||
// 打开时找不到当前值,下拉会被重置成空,造成"保存后适配器悄悄丢了"。
|
||||
// 引用关系由内核持有(core 在配置装载时标记),VM 只看这个位。
|
||||
keepHidden := v.hiddenKeep()
|
||||
list := make([]APIAdapter, 0, len(v.pools))
|
||||
for name, p := range v.pools {
|
||||
if p.static.hidden && !keepHidden[name] {
|
||||
continue
|
||||
}
|
||||
list = append(list, APIAdapter{Name: name, Version: p.static.version})
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
|
||||
return list
|
||||
}
|
||||
|
||||
// hiddenKeep returns the set of hidden adapter names that must STAY visible
|
||||
// because at least one source references them. nil 表示内核未标记(测试环境/
|
||||
// 未装载配置),此时隐藏适配器一律隐藏——与"新适配器默认隐藏"的预期一致。
|
||||
func (v *VM) hiddenKeep() map[string]bool {
|
||||
if v.hiddenRefs == nil {
|
||||
return nil
|
||||
}
|
||||
return v.hiddenRefs
|
||||
}
|
||||
|
||||
// AdapterForIngress returns the registered name of the adapter that CLAIMS the
|
||||
// given client-facing endpoint path (its static.ingress), or "" when none does.
|
||||
//
|
||||
@ -1255,6 +1299,10 @@ func inspectScript(name, code string) (*staticInfo, error) {
|
||||
info.endpoint = readString(L, -1, "endpoint")
|
||||
info.endpointStream = readString(L, -1, "endpoint_stream")
|
||||
info.ingress = readString(L, -1, "ingress")
|
||||
// hidden:适配器可声明 adapter.hidden = true 退出用户可见列表(仍可被源引用)。
|
||||
L.GetField(-1, "hidden")
|
||||
info.hidden = L.ToBoolean(-1)
|
||||
L.Pop(1)
|
||||
if getFieldBatch(L, "headers") {
|
||||
luaTableToMap(L, -1, info.headers)
|
||||
L.Pop(1)
|
||||
|
||||
49
internal/lua/vm_hidden_test.go
Normal file
49
internal/lua/vm_hidden_test.go
Normal file
@ -0,0 +1,49 @@
|
||||
package lua
|
||||
|
||||
import "testing"
|
||||
|
||||
// hidden=true 的适配器:不被任何源引用时从 ListAdapters 消失;被引用时保持可见。
|
||||
// 这个行为支撑"内部适配器默认隐藏"的约定(opencodezen 是第一个使用者)。
|
||||
func TestAdapterHiddenFlag(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
const src = `
|
||||
local adapter = {}
|
||||
adapter.name = "secretone"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.hidden = true
|
||||
function adapter.transform_request(raw) return raw end
|
||||
function adapter.transform_response(raw) return raw end
|
||||
return adapter
|
||||
`
|
||||
if err := vm.LoadAdapterSource("secretone", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 未被引用 → 隐藏
|
||||
vm.SetHiddenRefs(nil)
|
||||
for _, a := range vm.ListAdapters() {
|
||||
if a.Name == "secretone" {
|
||||
t.Fatal("hidden adapter must not be listed when unreferenced")
|
||||
}
|
||||
}
|
||||
// 被引用 → 可见
|
||||
vm.SetHiddenRefs([]string{"secretone"})
|
||||
found := false
|
||||
for _, a := range vm.ListAdapters() {
|
||||
if a.Name == "secretone" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("hidden adapter must stay listed while referenced by a source")
|
||||
}
|
||||
// transform 路径不受 hidden 影响:隐藏 ≠ 禁用
|
||||
out, err := vm.Transform("secretone", "transform_response", `{"x":1}`)
|
||||
if err != nil || out == "" {
|
||||
t.Fatalf("hidden adapter must still transform: %v %q", err, out)
|
||||
}
|
||||
}
|
||||
@ -506,42 +506,39 @@ func (p *Provider) ModelFor(reqModel string) string {
|
||||
return p.bestChatModel()
|
||||
}
|
||||
|
||||
// bestChatModel returns the highest-priority chat-kind model of this source.
|
||||
// bestChatModel returns the first chat-kind model of this source.
|
||||
//
|
||||
// ★ 曾经叫 "highest-priority chat model",但 `config.Model.Priority` 已删除
|
||||
//
|
||||
// (它从未参与跨源调度,只在这里用;而这里的语义其实只是"选一个能发 chat 的
|
||||
// 模型",选第一个就够了 —— 源内模型顺序由配置/发现顺序决定)。
|
||||
func (p *Provider) bestChatModel() string {
|
||||
bestID, bestPrio := "", -1
|
||||
for _, m := range p.cfg.Models {
|
||||
if m.Kind != "" && m.Kind != "chat" {
|
||||
continue
|
||||
}
|
||||
if m.Priority > bestPrio {
|
||||
bestPrio = m.Priority
|
||||
bestID = m.ID
|
||||
}
|
||||
return m.ID
|
||||
}
|
||||
if bestID == "" && len(p.cfg.Models) > 0 {
|
||||
bestID = p.cfg.Models[0].ID
|
||||
if len(p.cfg.Models) > 0 {
|
||||
return p.cfg.Models[0].ID
|
||||
}
|
||||
return bestID
|
||||
return ""
|
||||
}
|
||||
|
||||
// bestImageModel returns the highest-priority image-kind model of this source
|
||||
// (fallback: first configured model). Used for AUTO image generation so a
|
||||
// mixed source never sends a chat model to /v1/images/generations.
|
||||
// bestImageModel returns the first image-kind model of this source (fallback:
|
||||
// first configured model). Used for AUTO image generation so a mixed source
|
||||
// never sends a chat model to /v1/images/generations.
|
||||
func (p *Provider) bestImageModel() string {
|
||||
bestID, bestPrio := "", -1
|
||||
for _, m := range p.cfg.Models {
|
||||
if m.Kind != "" && m.Kind != "image" {
|
||||
continue
|
||||
}
|
||||
if m.Priority > bestPrio {
|
||||
bestPrio = m.Priority
|
||||
bestID = m.ID
|
||||
}
|
||||
return m.ID
|
||||
}
|
||||
if bestID == "" && len(p.cfg.Models) > 0 {
|
||||
bestID = p.cfg.Models[0].ID
|
||||
if len(p.cfg.Models) > 0 {
|
||||
return p.cfg.Models[0].ID
|
||||
}
|
||||
return bestID
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsAutoID reports whether s is an AUTO routing placeholder.
|
||||
@ -1159,7 +1156,7 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, status, err := p.do(ctx, chatURL, body, hdrs)
|
||||
raw, status, err := p.doWithRetry(ctx, chatURL, body, hdrs)
|
||||
if err != nil {
|
||||
// a client disconnect or cancelled context is neither a success nor
|
||||
// a failure for scheduling purposes — only upstream errors count
|
||||
@ -1230,8 +1227,48 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
}
|
||||
rc := make(chan respOrErr, 1)
|
||||
go func() {
|
||||
resp, err := p.doRawStream(ctx, streamURL, body, hdrs)
|
||||
rc <- respOrErr{resp, err}
|
||||
// Retry only the HANDSHAKE: a policy-matched rejection (e.g. a
|
||||
// per-account concurrency cap answered with a business 4xx) can clear
|
||||
// within seconds, and nothing has been sent to the client yet — the
|
||||
// kernel holds the first chunk back. Once a 200 arrives and streaming
|
||||
// starts, no retry happens (that would duplicate output).
|
||||
rp := p.cfg.Retry
|
||||
attempts := 1
|
||||
if rp != nil && rp.MaxAttempts > 1 {
|
||||
attempts = rp.MaxAttempts
|
||||
}
|
||||
for i := 0; i < attempts; i++ {
|
||||
resp, err := p.doRawStream(ctx, streamURL, body, hdrs)
|
||||
if err != nil {
|
||||
rc <- respOrErr{resp, err}
|
||||
return
|
||||
}
|
||||
if resp.StatusCode == 200 {
|
||||
rc <- respOrErr{resp, nil}
|
||||
return
|
||||
}
|
||||
// A rejection can only be classified after the body is read: the
|
||||
// policy matches on body substrings, and a source may use the same
|
||||
// status (400) for unrelated errors that must NOT be retried.
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if !rp.ShouldRetry(resp.StatusCode, string(raw)) {
|
||||
// Not the retryable body: hand the rejection back verbatim so
|
||||
// the caller reports the real upstream error.
|
||||
resp2 := &http.Response{StatusCode: resp.StatusCode, Body: io.NopCloser(strings.NewReader(string(raw)))}
|
||||
rc <- respOrErr{resp2, nil}
|
||||
return
|
||||
}
|
||||
if i == attempts-1 {
|
||||
resp2 := &http.Response{StatusCode: resp.StatusCode, Body: io.NopCloser(strings.NewReader(string(raw)))}
|
||||
rc <- respOrErr{resp2, nil}
|
||||
return
|
||||
}
|
||||
if serr := sleepCtx(ctx, rp.DelayDuration()); serr != nil {
|
||||
rc <- respOrErr{nil, serr}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
inner := make(chan types.UnifiedChunk, 64)
|
||||
@ -1263,6 +1300,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
var realChunks int
|
||||
var doneSeen bool
|
||||
var doneSent bool
|
||||
var sawToolCalls bool
|
||||
// toolIdx maps a per-stream tool fragment's upstream index (Anthropic
|
||||
// content-block ordinal, which is sparse when thinking/text blocks
|
||||
// precede tool_use) to a dense 0-based OpenAI tool_call ordinal so
|
||||
@ -1306,6 +1344,19 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
if err := json.Unmarshal([]byte(unified), &ck); err != nil {
|
||||
continue
|
||||
}
|
||||
// A few OpenAI-compatible upstreams emit a final `stop` marker
|
||||
// after streaming tool-call fragments. Once a real fragment was
|
||||
// observed, the gateway must preserve tool-call semantics for the
|
||||
// agent instead of making it treat the turn as ordinary text.
|
||||
if len(ck.ToolCalls) > 0 {
|
||||
trimmed := strings.TrimSpace(string(ck.ToolCalls))
|
||||
if trimmed != "null" && trimmed != "[]" && trimmed != "{}" {
|
||||
sawToolCalls = true
|
||||
}
|
||||
}
|
||||
if sawToolCalls && ck.FinishReason == "stop" {
|
||||
ck.FinishReason = "tool_calls"
|
||||
}
|
||||
ck.ToolCalls = toolIdx.remap(ck.ToolCalls)
|
||||
if ck.Done {
|
||||
doneSent = true
|
||||
@ -1459,8 +1510,18 @@ func (p *Provider) apiErrReason(status int, raw string) string {
|
||||
return fmt.Sprintf("api error %d: %s", status, types.OneLine(trimmed, 200))
|
||||
}
|
||||
}
|
||||
log.Printf("[provider] unhandled upstream error body (adapter %q lacks transform_error): status=%d body=%.300s",
|
||||
p.adapter, status, types.OneLine(raw, 300))
|
||||
// ⚠️ 措辞必须**区分两种原因**(实测教训):适配器**有** transform_error 钩子
|
||||
// 但它返回 nil(不认识的错误体)是**常见**情况,而"真的没这个钩子"是另一回事。
|
||||
// 原先一律说 "lacks transform_error" —— 我照着这句话去适配器里找那个函数,
|
||||
// 查了几轮才发现函数明明在、只是抽不出消息。
|
||||
// 这里直接问内核要事实,不再猜(判据也据此断言)。
|
||||
if p.vm.HasFn(p.adapter, "transform_error") {
|
||||
log.Printf("[provider] unhandled upstream error body (adapter %q: transform_error returned no reason for this shape): status=%d body=%.300s",
|
||||
p.adapter, status, types.OneLine(raw, 300))
|
||||
} else {
|
||||
log.Printf("[provider] unhandled upstream error body (adapter %q defines no transform_error): status=%d body=%.300s",
|
||||
p.adapter, status, types.OneLine(raw, 300))
|
||||
}
|
||||
return fmt.Sprintf("api error %d: unknown error", status)
|
||||
}
|
||||
|
||||
@ -1539,6 +1600,50 @@ func (p *Provider) do(ctx context.Context, url, body string, hdr http.Header) (s
|
||||
return string(raw), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// doWithRetry runs one non-stream request, retrying per the source's
|
||||
// declarative RetryPolicy. The policy is pure data (status list + body
|
||||
// substrings), so the kernel stays vendor-agnostic: a provider that answers a
|
||||
// per-account concurrency cap with a business 4xx can clear by itself while the
|
||||
// client just waits a few seconds instead of seeing an error.
|
||||
func (p *Provider) doWithRetry(ctx context.Context, url, body string, hdr http.Header) (string, int, error) {
|
||||
rp := p.cfg.Retry
|
||||
attempts := 1
|
||||
if rp != nil && rp.MaxAttempts > 1 {
|
||||
attempts = rp.MaxAttempts
|
||||
}
|
||||
var raw string
|
||||
var status int
|
||||
for i := 0; i < attempts; i++ {
|
||||
r, st, err := p.do(ctx, url, body, hdr)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
raw, status = r, st
|
||||
if status == 200 || !rp.ShouldRetry(status, raw) || i == attempts-1 {
|
||||
break
|
||||
}
|
||||
if serr := sleepCtx(ctx, rp.DelayDuration()); serr != nil {
|
||||
return "", 0, serr
|
||||
}
|
||||
}
|
||||
return raw, status, nil
|
||||
}
|
||||
|
||||
// sleepCtx sleeps for d unless the request context ends first.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) doRaw(ctx context.Context, url, body string, hdr http.Header) (*http.Response, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte(body)))
|
||||
if err != nil {
|
||||
|
||||
@ -32,7 +32,7 @@ func newTestProvider(t *testing.T, src config.Source) *Provider {
|
||||
func src(name, url, adapter string, models ...string) config.Source {
|
||||
s := config.Source{Name: name, BaseURL: url, Adapter: adapter, MaxConcurrent: 4}
|
||||
for _, m := range models {
|
||||
s.Models = append(s.Models, config.Model{ID: m, Priority: 0})
|
||||
s.Models = append(s.Models, config.Model{ID: m})
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -93,7 +93,39 @@ func TestProviderChatStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamSuccessClearsBackoff guards P6: a clean streaming end must reset
|
||||
func TestProviderChatStreamNormalizesStopAfterToolCall(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprint(w, "data: "+`{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"f"}}]}}]}`+"\n\n")
|
||||
fmt.Fprint(w, "data: "+`{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]},"finish_reason":"stop"}]}`+"\n\n")
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer up.Close()
|
||||
p := newTestProvider(t, src("mock", up.URL, "openai", "m"))
|
||||
ch, err := p.ChatStream(context.Background(), &types.ChatRequest{
|
||||
Model: "m", Stream: true,
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stream: %v", err)
|
||||
}
|
||||
var sawTool, sawToolFinish bool
|
||||
for ck := range ch {
|
||||
if len(ck.ToolCalls) > 0 && string(ck.ToolCalls) != "{}" {
|
||||
sawTool = true
|
||||
}
|
||||
if ck.FinishReason == "tool_calls" {
|
||||
sawToolFinish = true
|
||||
}
|
||||
}
|
||||
if !sawTool {
|
||||
t.Fatal("stream dropped the tool-call fragment")
|
||||
}
|
||||
if !sawToolFinish {
|
||||
t.Fatal("stop after a tool call was not normalized to tool_calls")
|
||||
}
|
||||
}
|
||||
|
||||
// a previously cooled (source, model) pair.
|
||||
func TestStreamSuccessClearsBackoff(t *testing.T) {
|
||||
var fail atomic.Bool
|
||||
@ -515,8 +547,8 @@ func TestImageAutoUsesImageModel(t *testing.T) {
|
||||
defer up.Close()
|
||||
s := config.Source{Name: "mix", BaseURL: up.URL, Adapter: "openai", MaxConcurrent: 4}
|
||||
s.Models = []config.Model{
|
||||
{ID: "chat-m", Kind: "chat", Priority: 100},
|
||||
{ID: "img-m", Kind: "image", Priority: 50},
|
||||
{ID: "chat-m", Kind: "chat"},
|
||||
{ID: "img-m", Kind: "image"},
|
||||
}
|
||||
p := newTestProvider(t, s)
|
||||
resp, err := p.Image(context.Background(), &types.ImageGenRequest{Prompt: "cat", Model: "AUTO"})
|
||||
|
||||
@ -117,13 +117,17 @@ func (r *Registry) Resolve(model string) []*Provider {
|
||||
// pinning prefix and returns the bare model id when that source serves it;
|
||||
// otherwise it returns the input unchanged.
|
||||
func (r *Registry) EffectiveModel(model string) string {
|
||||
sep := strings.IndexAny(model, "-:/")
|
||||
if sep < 1 || sep == len(model)-1 {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return model
|
||||
}
|
||||
src, m := model[:sep], model[sep+1:]
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
// 与 ResolvePinned 同一个拆分器:源名可含 `-`,不能用第一个分隔符切。
|
||||
src, m, ok := splitPinned(model, r.providers)
|
||||
if !ok {
|
||||
return model
|
||||
}
|
||||
for _, p := range r.providers {
|
||||
if strings.EqualFold(p.Name(), src) && p.ModelByID(m) != nil {
|
||||
return m
|
||||
@ -135,13 +139,16 @@ func (r *Registry) EffectiveModel(model string) string {
|
||||
// ResolvePinned resolves "source-model" / "source:model" / "source/model" to
|
||||
// the exact source, or nil if the source does not serve that model.
|
||||
func (r *Registry) ResolvePinned(model string) *Provider {
|
||||
sep := strings.IndexAny(model, "-:/")
|
||||
if sep < 1 || sep == len(model)-1 {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return nil
|
||||
}
|
||||
src, m := model[:sep], model[sep+1:]
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
src, m, ok := splitPinned(model, r.providers)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, p := range r.providers {
|
||||
if !strings.EqualFold(p.Name(), src) {
|
||||
continue
|
||||
@ -153,6 +160,54 @@ func (r *Registry) ResolvePinned(model string) *Provider {
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitPinned 把 "源名 + 分隔符 + 模型名" 拆开。
|
||||
//
|
||||
// ★★ 不能用 `IndexAny(model, "-:/")` 找**第一个**分隔符。
|
||||
// 源名本身常常含 `-`(本仓的渠道源是 `channels-<平台>-<账号id>`),
|
||||
// 于是 `channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` 会被切成
|
||||
//
|
||||
// src="channels"、model="trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro"
|
||||
//
|
||||
// ⇒ 源名永不匹配 ⇒ Resolve() 返回 nil ⇒ 客户端看到
|
||||
// `model "channels-trae-xxx:Doubao-Seed-2.1-Pro" is not configured`。
|
||||
//
|
||||
// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17):
|
||||
// WebUI 的模型下拉框用 `源名:模型名` 作为 option value(这是**有意**的:
|
||||
// 44 个模型被多个源同时提供,必须能指定从哪个源调用),而网关解不开它。
|
||||
//
|
||||
// 修法:**按已知源名做最长前缀匹配**,命中的那个源名才是真正的分隔点。
|
||||
// 这样既支持含 `-` 的源名,也保留 "src:model" / "src/model" 等旧写法。
|
||||
func splitPinned(model string, providers []*Provider) (src, m string, ok bool) {
|
||||
// ① 最长源名前缀匹配(唯一可靠的办法:源名是已知集合)
|
||||
best := ""
|
||||
for _, p := range providers {
|
||||
n := p.Name()
|
||||
if n == "" || len(n) >= len(model) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(model), strings.ToLower(n)) {
|
||||
continue
|
||||
}
|
||||
// 源名之后必须紧跟一个分隔符
|
||||
if c := model[len(n)]; c != ':' && c != '/' && c != '-' {
|
||||
continue
|
||||
}
|
||||
if len(n) > len(best) {
|
||||
best = n
|
||||
}
|
||||
}
|
||||
if best != "" {
|
||||
return best, model[len(best)+1:], true
|
||||
}
|
||||
// ② 兜底:旧行为(第一个分隔符)。仅当没有任何源名匹配时才走到这里,
|
||||
// 保持对非源名形态(例如第三方模型名自带 ":" 的)的兼容。
|
||||
sep := strings.IndexAny(model, "-:/")
|
||||
if sep < 1 || sep == len(model)-1 {
|
||||
return "", "", false
|
||||
}
|
||||
return model[:sep], model[sep+1:], true
|
||||
}
|
||||
|
||||
// ProviderForModel returns the provider owning the model id (nil if unknown).
|
||||
// When the same model exists on multiple sources, returns the first (source
|
||||
// config order). Callers that need a specific source use ProviderForSlot.
|
||||
@ -205,10 +260,10 @@ type ModelKindInfo struct {
|
||||
|
||||
// ModelStatus is a web-UI friendly snapshot per source.
|
||||
type SourceStatus struct {
|
||||
Name string `json:"name"`
|
||||
Adapter string `json:"adapter"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Models []string `json:"models"`
|
||||
Name string `json:"name"`
|
||||
Adapter string `json:"adapter"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Models []string `json:"models"`
|
||||
// ModelKinds parallels Models: Models[i] is ModelKinds[i].ID.
|
||||
//
|
||||
// It exists because a flat []string cannot answer "is gpt-image-2
|
||||
@ -217,15 +272,15 @@ type SourceStatus struct {
|
||||
// callers that only need names; this is purely additive and omitted when
|
||||
// empty, so an older UI keeps working.
|
||||
ModelKinds []ModelKindInfo `json:"model_kinds,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
Healthy bool `json:"healthy"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
LiveAvailable bool `json:"live_available"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastChecked int64 `json:"last_checked,omitempty"`
|
||||
FailCount int `json:"fail_count,omitempty"`
|
||||
BackoffUntil int64 `json:"backoff_until,omitempty"`
|
||||
Permanent bool `json:"permanent,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
Healthy bool `json:"healthy"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
LiveAvailable bool `json:"live_available"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastChecked int64 `json:"last_checked,omitempty"`
|
||||
FailCount int `json:"fail_count,omitempty"`
|
||||
BackoffUntil int64 `json:"backoff_until,omitempty"`
|
||||
Permanent bool `json:"permanent,omitempty"`
|
||||
// RecentOK / RecentErr count real gateway requests served by this source
|
||||
// within the last 300s (drives the status column so a source that is
|
||||
// actually serving traffic can never be shown as down just because a
|
||||
|
||||
98
internal/provider/registry_pinned_name_test.go
Normal file
98
internal/provider/registry_pinned_name_test.go
Normal file
@ -0,0 +1,98 @@
|
||||
package provider
|
||||
|
||||
import "testing"
|
||||
|
||||
// ★ `源名:模型名` 这种限定写法,源名本身**含 `-`** 时必须仍能解开。
|
||||
//
|
||||
// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17):
|
||||
//
|
||||
// WebUI 的模型下拉框用 `源名:模型名` 作为 option value —— 这是**有意**的:
|
||||
// 本实例有 44 个模型被多个源同时提供(glm-5.2 被 6 个源提供),
|
||||
// 用户必须能指定「从哪个源调用」。
|
||||
//
|
||||
// 但 ResolvePinned 用 `IndexAny(model, "-:/")` 找**第一个**分隔符,
|
||||
// 而渠道源名形如 `channels-trae-b8dd6b3df30e`(含 `-`)⇒ 被切成
|
||||
//
|
||||
// src="channels"、model="trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro"
|
||||
//
|
||||
// ⇒ 源名永不匹配 ⇒ Resolve() 返回 nil
|
||||
// ⇒ 客户端看到 `model "channels-trae-xxx:..." is not configured`。
|
||||
func TestResolvePinnedWithHyphenatedSourceName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sources map[string][]string // 源名 → 模型
|
||||
input string
|
||||
wantSrc string
|
||||
wantMdl string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "渠道源名含两个 -(真实形态)",
|
||||
sources: map[string][]string{
|
||||
"channels-trae-b8dd6b3df30e": {"Doubao-Seed-2.1-Pro", "glm-5.2"},
|
||||
"channels-buddy-buddy-main": {"hy4-preview", "glm-5.2"},
|
||||
},
|
||||
input: "channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro",
|
||||
wantSrc: "channels-trae-b8dd6b3df30e",
|
||||
wantMdl: "Doubao-Seed-2.1-Pro",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "同名模型跨源:必须能选中第二个源",
|
||||
sources: map[string][]string{
|
||||
"channels-trae-b8dd6b3df30e": {"glm-5.2"},
|
||||
"channels-buddy-buddy-main": {"glm-5.2"},
|
||||
},
|
||||
input: "channels-buddy-buddy-main:glm-5.2",
|
||||
wantSrc: "channels-buddy-buddy-main",
|
||||
wantMdl: "glm-5.2",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "斜杠写法仍要支持",
|
||||
sources: map[string][]string{
|
||||
"channels-trae-b8dd6b3df30e": {"glm-5.2"},
|
||||
},
|
||||
input: "channels-trae-b8dd6b3df30e/glm-5.2",
|
||||
wantSrc: "channels-trae-b8dd6b3df30e",
|
||||
wantMdl: "glm-5.2",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
// 自带 ":" 的第三方模型名,且**没有**任何源名与之匹配 ⇒ 走旧兜底。
|
||||
// ⚠️ 不能拿 "our-free-model_cohere/..." 当例子:本实例真有一个叫
|
||||
// `our-free-model_cohere` 的源,而仓库里还有叫 `our` 的源 ——
|
||||
// 最长前缀匹配会正确命中 `our`,那个例子的预期是我写错了。
|
||||
name: "无源名匹配时走旧兜底",
|
||||
sources: map[string][]string{
|
||||
"channels-trae-b8dd6b3df30e": {"glm-5.2"},
|
||||
},
|
||||
input: "vendor-lib/thing:free",
|
||||
wantSrc: "vendor",
|
||||
wantMdl: "lib/thing:free",
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var ps []*Provider
|
||||
for name, models := range c.sources {
|
||||
ps = append(ps, newTestProvider(t, src(name, "http://127.0.0.1:1", "openai", models...)))
|
||||
}
|
||||
src, m, ok := splitPinned(c.input, ps)
|
||||
if ok != c.wantOK {
|
||||
t.Fatalf("ok 应为 %v,实得 %v(输入 %q)", c.wantOK, ok, c.input)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if src != c.wantSrc {
|
||||
t.Fatalf("★ 源名解析错:期望 %q,实得 %q\n"+
|
||||
" 输入 %q —— 源名含 `-` 时不能按第一个分隔符切", c.wantSrc, src, c.input)
|
||||
}
|
||||
if m != c.wantMdl {
|
||||
t.Fatalf("模型名解析错:期望 %q,实得 %q", c.wantMdl, m)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
131
internal/provider/retry_policy_test.go
Normal file
131
internal/provider/retry_policy_test.go
Normal file
@ -0,0 +1,131 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// 上游重试是**内核的通用机制**,判据纯数据(status + body 子串),
|
||||
// 目的是让"账号级限流、等一会儿就好"这类拒绝不冒到客户端。
|
||||
//
|
||||
// 真实事故(2026-10-20):CodeArts 账户并发会话上限 3 个,连续第 3~4 个请求
|
||||
// 回 400 TM.00001041,最后一个请求结束后约 44~60 秒才释放;流式更糟 ——
|
||||
// 上游用 HTTP 200 + error 帧表达拒绝,内核只能报 `empty stream`。
|
||||
// 参考实现的处理是每 10s 重试一次 chat 直到放行,本测试钉住同一语义。
|
||||
func TestChatRetriesDeclaredUpstreamRejection(t *testing.T) {
|
||||
var hits int32
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&hits, 1)
|
||||
if n <= 2 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"error_code":"TM.00001041","error_msg":"并发会话数已达上限(3个)"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
s := src("codearts-like", up.URL, "openai", "m1")
|
||||
s.Retry = &config.RetryPolicy{
|
||||
OnStatus: []int{400},
|
||||
OnBodyContains: []string{"TM.00001041"},
|
||||
Delay: "1ms",
|
||||
MaxAttempts: 4,
|
||||
}
|
||||
p := newTestProvider(t, s)
|
||||
resp, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "m1",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("命中重试策略后应成功,实得错误: %v", err)
|
||||
}
|
||||
if resp.Content != "hi" {
|
||||
t.Fatalf("content = %q", resp.Content)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 3 {
|
||||
t.Fatalf("应恰好尝试 3 次(2 次失败 + 1 次成功),实得 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一状态码但 body 不匹配 ⇒ **不许重试**(400 也常用于真正的请求错误,
|
||||
// 无差别重试只会把错误延迟 3 分钟才暴露给用户)。
|
||||
func TestChatDoesNotRetryUnmatchedBody(t *testing.T) {
|
||||
var hits int32
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"error_code":"TM.00001005","error_msg":"无正常可用套餐"}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
s := src("codearts-like", up.URL, "openai", "m1")
|
||||
s.Retry = &config.RetryPolicy{
|
||||
OnStatus: []int{400},
|
||||
OnBodyContains: []string{"TM.00001041"},
|
||||
Delay: "1ms",
|
||||
MaxAttempts: 5,
|
||||
}
|
||||
p := newTestProvider(t, s)
|
||||
_, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "m1",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("不可重试的 body 必须失败")
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 1 {
|
||||
t.Fatalf("body 不匹配时只能尝试 1 次,实得 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 流式:只重试**握手**。命中策略 → 重试直到 200 才开始派发;一旦开始流式
|
||||
// 就不再重试(重复输出比报错更糟)。
|
||||
func TestChatStreamRetriesHandshakeOnly(t *testing.T) {
|
||||
var hits int32
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&hits, 1)
|
||||
if n == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"error_code":"TM.00001041","error_msg":"并发会话数已达上限(3个)"}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n")
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
s := src("codearts-like", up.URL, "openai", "m1")
|
||||
s.Retry = &config.RetryPolicy{
|
||||
OnStatus: []int{400},
|
||||
OnBodyContains: []string{"TM.00001041"},
|
||||
Delay: "1ms",
|
||||
MaxAttempts: 4,
|
||||
}
|
||||
p := newTestProvider(t, s)
|
||||
ch, err := p.ChatStream(context.Background(), &types.ChatRequest{
|
||||
Model: "m1",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("握手重试后应成功: %v", err)
|
||||
}
|
||||
var content string
|
||||
for ck := range ch {
|
||||
content += ck.Content
|
||||
}
|
||||
if content != "hi" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 2 {
|
||||
t.Fatalf("应握手重试 1 次(共 2 次请求),实得 %d", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user