mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
45 Commits
v2.0.49
...
fix/channe
| Author | SHA1 | Date | |
|---|---|---|---|
| d3bb47bd54 | |||
| 4174310421 | |||
| fc5326402f | |||
| 56ea269f98 | |||
| aea1a2c48d | |||
| 9e5a5f550b | |||
| 8a4153d18a | |||
| 59902082c6 | |||
| dda537cda9 | |||
| 2d7388ad35 | |||
| 00e59a7e1d | |||
| c0e08f907a | |||
| 3ac4b35eb1 | |||
| 969f3138fd | |||
| 00db19eb77 | |||
| 38490690c5 | |||
| 969c72bfb1 | |||
| 423dd5cf22 | |||
| eeba1e49f8 | |||
| ade9c122eb | |||
| 71cc142615 | |||
| 33bb7a98b2 | |||
| 1e6b4864ec | |||
| 89d759ece7 | |||
| 8d23005f7d | |||
| ddbafe822c | |||
| 482a65958b | |||
| 796b30f5b0 | |||
| 25a525be84 | |||
| ea8a9ac4c6 | |||
| 8f73f77ec2 | |||
| c36a794aa8 | |||
| 0f263f895a | |||
| 044ff1129b | |||
| 9bec158d2e | |||
| 0004545190 | |||
| 1dda9e4477 | |||
| 04f12fae71 | |||
| cf53d72af4 | |||
| 03430bcef5 | |||
| 192e545d40 | |||
| de9d7c8008 | |||
| 8a3059740f | |||
| 028e42b036 | |||
| 84c9066bed |
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 gui-test
|
||||
.PHONY: all build test gui gui-dev gui-dist gui-deb gui-win gui-win-docker help clean
|
||||
|
||||
# ModelRouter - single-binary gateway + optional Electron desktop GUI (embedded core)
|
||||
export BINARY
|
||||
@ -65,13 +65,6 @@ 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
|
||||
|
||||
@ -82,7 +75,6 @@ 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,7 +18,6 @@ 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) {
|
||||
@ -41,13 +40,6 @@ 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(
|
||||
@ -224,9 +216,6 @@ 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
|
||||
@ -279,9 +268,6 @@ 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,
|
||||
@ -636,55 +622,10 @@ 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 (!isInternal(url)) openExternally(url);
|
||||
if (url && url.startsWith("http")) shell.openExternal(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": "2.0.49",
|
||||
"version": "1.9.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "2.0.49",
|
||||
"version": "1.9.3",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "2.0.49",
|
||||
"version": "1.9.7",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
@ -81,7 +81,6 @@
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"plugin-sync.js",
|
||||
"preload.js",
|
||||
"renderer/**/*",
|
||||
"build/**/*",
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@ -1,80 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
@ -1,125 +0,0 @@
|
||||
# 渠道问题清单(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 无凭据属正常(未完成扫码)。
|
||||
@ -112,34 +112,184 @@
|
||||
|
||||
> 判据:`git tag --points-at <release HEAD>` 非空;`git diff release/v2.0.x main` 为空。
|
||||
|
||||
### P1 — 其余渠道的同类缺陷(**账号 0,验证不了**)
|
||||
### 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` | **阻塞**:二进制不在仓库任何位置 | 不可验证 |
|
||||
| P1-1 | `anthropic` 基座 | 基座**无 `build_headers`**,Anthropic 协议要 `x-api-key` 不是 Bearer | 给基座补 `build_headers`(`x-api-key` + `anthropic-version`) | ✅ 已修+已判据;运行需账号 |
|
||||
| P1-1b | `channels-zcode` | **★ 证据冲突**:zcode 的 Messages 端点虽然**说 Anthropic 协议**,鉴权却是 `Bearer <zcode_jwt>`(参考 `zcode-transport.ts:121-125`,全仓 `x-api-key` 命中 **0** 次)。基座补了 x-api-key 后它**继承就会变错** | zcode **显式覆盖** `build_headers` 发 Bearer,不用继承 | ✅ 已修+已判据;运行需账号 |
|
||||
| P1-2 | `channels-cline` | 参考要 `Bearer workos:<jwt>`,我们的 token 无 `workos:` 前缀 | 适配器 `build_headers` 补前缀(幂等,已带不重复) | ✅ 已修+已判据;运行需账号 |
|
||||
| P1-3 | `channels-gemini` | 有静态头 + Bearer ⇒ **本来就对**(参考 `gemini.ts:353-366` 也是 OAuth Bearer,且刻意不发 `x-goog-api-key`) | **不改**,只加判据把这个结论钉住 | ✅ 已确认+已判据 |
|
||||
| P1-4 | `channels-qoder` | 头由 **WASM 生成必须原样透传**,我们拿不到 `.wasm` | **仍阻塞**:二进制不在我们仓库,且本仓**没有 wasm 运行时入口**。结论与接入方案见下节与 `docs/qoder-wasm-integration.md` | 本机账号 0 ⇒ 运行时不可验证 |
|
||||
|
||||
> #### ★ P1-1 与 P1-1b 的证据冲突(本轮最重要的一条)
|
||||
>
|
||||
> 计划原文把 P1-1 写成「anthropic 基座没 `build_headers` ⇒ zcode 继承就 401」。
|
||||
> 逆向参考后**两半各对一半**:
|
||||
>
|
||||
> | 断言 | 结论 | 证据 |
|
||||
> |---|---|---|
|
||||
> | Anthropic 协议(真 · api.anthropic.com)要 `x-api-key` | ✅ 对 | 协议本身 |
|
||||
> | zcode 的 `/zcode-plan/anthropic/v1/messages` 也要 `x-api-key` | ❌ **错** | `zcode-transport.ts:121-125` 发 `Authorization: Bearer ${zcode_jwt}`;`zcode-upstream.ts:83-108` 的 `buildZcodeHeaders` 只有可选 Authorization;全仓 `grep -rn "x-api-key" src/` = **0** |
|
||||
>
|
||||
> zcode 是「**Anthropic 请求体形状 + Bearer 鉴权**」——协议与鉴权是两个独立维度,
|
||||
> 被这家上游拆开了。所以修复不能"把基座改成 x-api-key 就完事":
|
||||
> 那会把 zcode 从「对」改成「错」。正确形态是**基座发 x-api-key + zcode 显式覆盖成 Bearer**。
|
||||
>
|
||||
> 附带约束:`anthropic` 基座**同时服务生产里的其它源**(`/etc/llmsproxy/config.yaml`
|
||||
> 里 `justwoker` 就是 `adapter: anthropic`)。给基座补 `x-api-key` 后,内核
|
||||
> `buildHeaders` 的兜底 Bearer **必须让位**(否则同一请求带两套鉴权),
|
||||
> 这处改动在 `internal/provider/provider.go`(内核改动)。
|
||||
|
||||
> #### ★ P1-4 的结论:qoder 为什么卡住(事实陈述,**未修复**)
|
||||
>
|
||||
> 本段只记录查证结果,不代表任何修复已完成。
|
||||
>
|
||||
> **① 二进制客观存在,但不在我们仓库里。**
|
||||
>
|
||||
> | 项 | 值 |
|
||||
> |---|---|
|
||||
> | 绝对路径 | `/opt/dsh-plugins/dsh-our-free-model/2.0.0/vendor/channel-pack/qoder-auth-wasm.wasm` |
|
||||
> | 大小 | `298606` 字节(291.6 KiB) |
|
||||
> | sha256 | `6419471effa631519def7797d76d7ede38b9fcfa9a83c2148c8ef5d43355b43d` |
|
||||
> | 格式 | `WebAssembly (wasm) binary version 0x1 (MVP module)` |
|
||||
> | 归属 | 第三方插件 `dsh-our-free-model` 的 vendored 依赖(`NOTICE.md` 记明来自 MIT 许可的上游 channel-pack,**不随本仓分发**) |
|
||||
>
|
||||
> ⇒ 我们仓库 `git ls-files | grep -i wasm` = **0 条**。任何「我们直接调用它」的实现都是
|
||||
> 对**别人安装目录**的硬依赖,换机/升级插件即失效。这是 P1-4 的第一层阻塞。
|
||||
>
|
||||
> **② 它是 wasm-bindgen 产物,为 JS 宿主生成,不是 self-contained wasm。**
|
||||
>
|
||||
> 证据(解析二进制 section 得到,非推断):
|
||||
> - 只有 **1 个 import module**:`./qoder_auth_wasm_bg.js`,31 个 import **全部**来自它;
|
||||
> - import 名带 wasm-bindgen 的哈希与 `__wbindgen_*` ABI 前缀
|
||||
> (`__wbindgen_object_drop_ref` / `__wbindgen_object_clone_ref` /
|
||||
> `__wbindgen_cast_0000000000000001|2` / `__wbg_static_accessor_GLOBAL_THIS_*`);
|
||||
> - 导出含 `__wbindgen_add_to_stack_pointer` / `__wbindgen_export1..4`、`memory`。
|
||||
>
|
||||
> 结论:它**不是**「喂 WASI 就能跑」的模块,而是要求宿主**自己实现一套 JS 值语义**
|
||||
> (对象表 + 索引、`Uint8Array`、`Map`、`crypto.getRandomValues`、`Date.now`)。
|
||||
> wasm-bindgen 的标准做法是「随 wasm 一起生成 12 KB 的 JS glue」,而该目录里
|
||||
> **没有** `qoder_auth_wasm_bg.js` —— 上游改为手写了一份等价 glue
|
||||
> (`vendor/channel-pack/src/qoder-wasm.ts`,706 行,含 31 个 import 的逐个实现)。
|
||||
> 这是 P1-4 的第二层阻塞:**代价在宿主侧,不在 wasm 侧**。
|
||||
>
|
||||
> **③ 31 个 import 按用途分四类。**
|
||||
>
|
||||
> | 类 | 数量 | 代表 | 纯 Go 宿主能否实现 |
|
||||
> |---|---|---|---|
|
||||
> | A. bindgen 对象表 ABI | 5 | `__wbindgen_object_drop_ref` / `object_clone_ref` / `cast_..._1` / `cast_..._2` | 能(Go 侧自建 `[]any` 索引表) |
|
||||
> | B. 字节缓冲原语 | 6 | `__wbg_new_with_length` / `length` / `subarray` / `set` / `prototypesetcall` / `new_...`(Map) | 能(`[]byte` + `map[string]any`) |
|
||||
> | C. 随机源与时间 | 4 | `getRandomValues` **两个方向相反的变体** / `randomFillSync` / `now` | 能(`crypto/rand`、`time.Now`),**但两个变体必须区分**:一个写 wasm 内存,一个调 JS crypto 对象 |
|
||||
> | D. JS/Node 环境探针与调用 | 16 | `crypto` / `msCrypto` / `process` / `versions` / `node` / `require` / `call` / `globalThis` / `self` / `window` / `throw` / `Error` / `is_object|string|function|undefined` | 部分能:`self`/`window` 按 JS 惯例返 `0`;`process`/`node`/`require` 返回 `undefined` 即可 —— **前提是从未被调用** |
|
||||
>
|
||||
> **④ 34 个 export 按用途分四类。**
|
||||
>
|
||||
> | 类 | 数量 | 成员(去 bindgen plumbing) |
|
||||
> |---|---|---|
|
||||
> | 1. 鉴权字段生成 | 1 | `generate_runtime_auth_fields` |
|
||||
> | 2. 请求构造 / 加解密(**红线所在**) | 13 | `qodercontext_new` · `qodercontext_prepareInferRequest` · `qodercontext_prepareRequest` · `qodercontext_refreshAuthFields` · `qodercontext_get_external_providers_access` · `requestresult_url|body|headers|headerCount` · `decrypt_server_response` · `profile_encrypt` · 2 个 `__wbg_*_free` |
|
||||
> | 3. 本地存储 / 缓存 / 凭据加解密 | 8 | `credential_storage_encrypt|decrypt` · `model_cache_encrypt|decrypt` · `profileencryptor_new|encryptChunk|header` · `__wbg_profileencryptor_free` |
|
||||
> | 4. 静态常量 / HTTPDNS / 环境探测 | 6 | `get_httpdns_config|account_id|secret_key` · `build_httpdns_url` · `get_external_providers_feature_gate_fallback` · `get_profile_key_fingerprint` |
|
||||
> | plumbing | 6 | `memory` · `__wbindgen_export1..4` · `__wbindgen_add_to_stack_pointer` |
|
||||
>
|
||||
> 只有第 2 类与 qoder 的**推理鉴权**直接相关(`prepareInferRequest` 产出的
|
||||
> `Authorization` 是 `Bearer COSY.<载荷>.<签名>`,**必须原样透传**);
|
||||
> 第 1 类是它的前置(`encrypt_user_info` / `key`);第 3、4 类在推理路径上不需要。
|
||||
>
|
||||
> **⑤ 我们仓库当前没有 wasm 运行时入口 —— 这是 P1-4 卡住的直接原因。**
|
||||
>
|
||||
> | 查证项 | 命令 | 结果 |
|
||||
> |---|---|---|
|
||||
> | Go 侧 wasm 运行时依赖 | `grep -rniE 'wazero|wasmtime|wasmer|tetratelabs' --include=*.go --include=*.mod --include=*.sum .` | **0 命中** |
|
||||
> | `go.mod` 直接依赖 | `cat go.mod` | 仅 `aarzilli/golua` + `yaml.v3` |
|
||||
> | `go.sum` 中 wasm 相关 | `grep -ci wasm go.sum` | **0** |
|
||||
> | Lua 侧 wasm 能力 | `grep -rniE 'wasm' internal/lua/` | 仅 **注释**(`plugins/channels.lua:141` 明写「WASM 签名(Qoder) 这类无法在当前 Lua 环境完成」) |
|
||||
> | 插件 VM 外呼能力 | `internal/lua/plugin_http.go` | 只有受限 HTTP(host 白名单 + 禁重定向 + 4 MiB 上限),**无任意代码执行/无 wasm** |
|
||||
>
|
||||
> 即:现状是「**Go 内核无 wasm 依赖、Lua 插件无 wasm 能力**」,而 qoder 的
|
||||
> 加密端点头必须由该 wasm 生成。我们目前走的是**明文端点**
|
||||
> `api2-v2.qoder.sh` + `/model/v1/chat/completions`(见
|
||||
> `internal/core/channels_upstream_gaps_test.go` 的判据),因此**不需要** wasm ——
|
||||
> 代价是该端点只认一小撮通用模型名,拿不到目录里 `qmodel_*` 系列的完整能力。
|
||||
>
|
||||
> **⑥ 本机不可验证。** qoder 账号数 **0**(`plan.md` §1.1 快照),
|
||||
> 因此「签名头是否被上游接受」这一层**无法做运行时验证**。
|
||||
> 能验证的只有静态层:wasm 可实例化、能被驱动产出请求头(见方案文档的实测节),
|
||||
> **但「判据通过」≠「功能可用」**。
|
||||
>
|
||||
> 可执行的最小路径方案与实测记录另见 **`docs/qoder-wasm-integration.md`**。
|
||||
|
||||
### P2 — 内核对"限定名"的契约收口
|
||||
|
||||
`ResolvePinned` / `EffectiveModel` 现在各自调 `splitPinned`。建议:
|
||||
- 把"源名可含哪些字符"写进 `docs/`,并加判据:新增源名若含 `:` 或 `/` 必须报警
|
||||
(否则限定名会歧义)。
|
||||
- 现状:源名由插件生成 `channels-<platform>-<accountId>`,只含 `-`/字母数字,安全。
|
||||
已落地(详见 **`docs/source-name-contract.md`**):
|
||||
|
||||
| 位置 | 行为 |
|
||||
|---|---|
|
||||
| `config.ValidateSourceName` / `SourceNameWarning` | 纯函数校验器(单一定义) |
|
||||
| `core.normalizeSource`(`AddSource` 入口) | 新增/修改源**硬拒**(API 400)—— 新数据从源头合法 |
|
||||
| `config.ApplyDefaults`(启动) | 存量带 `:` 的源名**只告警不报错**(避免升级 brick 掉整个网关) |
|
||||
|
||||
字符集:**仅 `[A-Za-z0-9_-]`**,禁 `:` 与 `/`(限定名分隔符 ⇒ 切分歧义且**不报错**)。
|
||||
`-` 允许(渠道源名 `channels-<平台>-<账号id>` 含 `-`,分隔符在源名之后不歧义)。
|
||||
|
||||
解析入口 `internal/provider/splitPinned` **不做校验**(解析不该拒绝已有数据),
|
||||
只做最长前缀匹配 —— 校验责任全在写入口,保持解析路径纯函数无副作用。
|
||||
|
||||
### P3 — 判据防回归(防"夹具与生产不一致"再犯)
|
||||
|
||||
本轮最大的教训不是某个 bug,而是**夹具形状错 ⇒ 判据虚假通过**(trae 活了两轮)。
|
||||
|
||||
建议加一条**通用**判据:`TestAdaptersFixturesMatchRealUpstreamShape` ——
|
||||
对每个有夹具的渠道,把夹具响应体与
|
||||
`/tmp/pi-github-repos/.../vendor/channel-pack/src/*.ts` 里注释记录的真实响应形状
|
||||
做**结构对照**(键集合),不一致即报红。做不到全自动,至少把已知的
|
||||
"Result 信封 / SOLO SSE / 嵌套 quota" 三类钉住。
|
||||
已落地,分两层(文件:`internal/lua/channels_fixture_shapes_test.go` 行为层 +
|
||||
`internal/lua/channels_fixture_shapes_general_test.go` 通用形状层):
|
||||
|
||||
**① 形状登记表 `shapeFamilies`** —— 每族给出四项,缺一不可:
|
||||
|
||||
| 字段 | 含义 | 为什么必须有 |
|
||||
|---|---|---|
|
||||
| `real` | 生产原文(引自实测抓包/参考注释) | 夹具的真值来源 |
|
||||
| `naive` | 错误的「方便测试版」 | 证明判据能区分真假,不是恒等式 |
|
||||
| `fact(raw)` | 对**原始 JSON 的结构事实** | 判定「这段 body 是不是生产形状」 |
|
||||
| `eval` / `assert` | 用**真实实现**处理并断言行为 | 防判据自算一份解包逻辑 |
|
||||
|
||||
断言链:`fact(real)` 为真 **且** `fact(naive)` 为假 **且** `assert(eval(real))` 通过。
|
||||
|
||||
> ⚠️ 刻意**不比较 `eval(real)` 与 `eval(naive)` 的输出形状**:trae 余额实现
|
||||
> **合法保留**了「顶层 credits_limit」兼容兜底路径(实测两种上都返回 600),
|
||||
> 拿输出比对会把合法兼容判成错误。真正能区分真假的是 `fact`
|
||||
> —— 它问的是「生产响应里额度在哪一层」。
|
||||
|
||||
已登记三族(基线,元判据强制不可删):
|
||||
- **Result 信封** —— 凭据在 `Result` 里,顶层只有 `ResponseMetadata`
|
||||
- **SOLO 自定义 SSE** —— 正文在顶层 `response`,**无** `choices`
|
||||
- **嵌套 quota** —— `pack.entitlement_base_info.quota.credits_limit`(顶层**没**该字段)
|
||||
|
||||
**② 仓库扫描 `TestNoDegenerateFixturesInRepo`** —— 扫 `internal/**/*_test.go`,
|
||||
对**特征明确**的退化形状做静态反查(当前只留「额度挪到 pack 顶层」一条规则)。
|
||||
需要退化夹具来测兼容路径时,必须在文件里写 `// SHAPE-OK: <理由>` 显式豁免。
|
||||
|
||||
> 为什么扫描只留一条规则:文本匹配对另两族**必然误报** —— SOLO 判据自己会写
|
||||
> `Fatalf("不得返回 choices…")`,天然含 choices。那两族的精确钉法
|
||||
> 是 ① 的 `fact` 判据,不是文本匹配。
|
||||
|
||||
#### 本轮修复的判据清单(P1/P2/P3)
|
||||
|
||||
| 判据文件 | 用例数 | 钉住什么 |
|
||||
|---|---|---|
|
||||
| `internal/lua/adapter_anthropic_headers_test.go` | 3 | 基座发 `x-api-key`+`anthropic-version`(恰好 2 键)、不发 Authorization、无凭据不发空头、读 `source.meta.api_key` 兜底 |
|
||||
| `internal/lua/adapter_zcode_build_headers_test.go` | 3 | zcode **覆盖**成 `Bearer`、无 `x-api-key`;基座 vs zcode 鉴权**真的不同**;无凭据不发空头 |
|
||||
| `internal/lua/channels_cline_workos_test.go` | 3 | 无前缀补上 / 已有不重复 / 大小写变体不重复;无 token 不发 `Bearer workos:` |
|
||||
| `internal/lua/channels_gemini_auth_check_test.go` | 2 | **P1-3 结论**:gemini 不自带鉴权(身份五头 + 兜底 Bearer 即正确),不得发 Authorization/x-api-key/x-goog-api-key |
|
||||
| `internal/provider/build_headers_fallback_test.go` | 2 | 内核兜底让位给 x-api-key;其它适配器 Bearer 行为**不变** |
|
||||
| `internal/config/source_name_contract_test.go` | 4 | 允许字符集、`:`/`/` 点名拒绝、其它非法字符、存量只告警 |
|
||||
| `internal/core/source_name_contract_test.go` | 2 | **真实写入口** `AddSource` 硬拒且拒绝发生在写盘之前;合法渠道名照常接受 |
|
||||
| `internal/lua/channels_fixture_shapes_test.go` | 4 | 三族行为层:Result 信封 / SOLO SSE / 嵌套 quota + 元判据 |
|
||||
| `internal/lua/channels_fixture_shapes_general_test.go` | 3 | 通用形状层:`fact(real)&&!fact(naive)&&assert(eval(real))`;仓库扫描;登记表自检 |
|
||||
| `internal/core/adapter_install_probe_test.go` | 1 | `__adapter_install_probe` 报告 6/6 自装适配器全绿(`luac -p` 对长字符串无效) |
|
||||
| `internal/lua/adapter_zcode_harness_test.go` | —(辅助) | 兄弟目录测试台:自带 zcode 装载断言,装不上就响亮失败 |
|
||||
|
||||
### P4 — 待用户决策
|
||||
|
||||
|
||||
@ -503,57 +503,15 @@ end
|
||||
|
||||
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
|
||||
|
||||
### 7.0 分层:billing 完全在插件里,内核没有任何 billing 代码
|
||||
### 7.1 计费维度
|
||||
|
||||
`billing` 是**插件自己的事**:规则文件、编译、计价、账目、规则编辑页都由
|
||||
`plugins/billing.lua` 负责。内核里没有 billing 的配置结构、端点、编译或迁移逻辑
|
||||
—— 它只提供所有插件通用的能力(加载 `.lua`、插件状态持久化、插件目录、
|
||||
`plugin.http` 路由、插件可用的 `io`/`os`)。
|
||||
|
||||
| 项 | 位置 |
|
||||
|---|---|
|
||||
| 规则文件 | `<plugin_dir>/billing.json`(插件自己读写;内核不知道这个文件) |
|
||||
| 计费账目 | 插件状态文件 `<plugin_dir>/.billing.state.json`(内核的通用状态持久化) |
|
||||
| 规则编辑 API | `/api/plugins/http/billing/rules`(GET/PUT/POST/DELETE,插件路由) |
|
||||
| 编译/计价 | 插件内 `compileRules()` / `priceFor()` / `costFor()` |
|
||||
|
||||
规则以**源名**(`source`)为键,而不是 URL:URL→源名的映射属于内核配置,插件
|
||||
不需要为了它和内核对接;编辑页在浏览器里,直接取 `/api/sources` 给运营选源名。
|
||||
`url: "*"` 只表示"全局模型价目表"(模型维度本来就不分源)。
|
||||
|
||||
> 历史:早期 `billing:` 写在 `config.yaml`、编译在 Go 的 `internal/billing` 里。
|
||||
> GitCode issue #1 的根因正是"Go 与 Lua 各写一半覆盖语义",组合起来把模型价
|
||||
> 静默清零。现在内核侧 billing 代码已全部删除,规则文件也由插件自持。
|
||||
|
||||
### 7.1 计费维度### 7.1 计费维度
|
||||
|
||||
| 维度 | 用途 | token 价覆盖顺序 |
|
||||
| 维度 | 用途 | 优先级 |
|
||||
|---|---|---|
|
||||
| `default` | 兜底价(通常全 0) | 最低 |
|
||||
| `models` | 模型的 per-token 价 | 中 |
|
||||
| `sources` | 源级价:固定价,或覆盖该源全部模型的 token 价 | 高 |
|
||||
| `sources` | 源的固定价(如按请求计费的转发服务) | 中 |
|
||||
| `models` | 模型的 per-token 价 | 高 |
|
||||
| `keys` | 单个网关 key 的覆盖价 | **最高** |
|
||||
|
||||
**token 价**优先级:`keys` > `sources` > `models` > `default`。
|
||||
(实现顺序就是"后写覆盖前写":`default` → `models` → `sources` → `keys`;
|
||||
所以**源级 token 价会覆盖模型价**。这是有意的:运营要能给某个源整体改价。)
|
||||
|
||||
⚠️ 正因为 `sources` 会覆盖 `models`:**`mode: token` 的规则不产生任何源条目**。
|
||||
规则未列出的模型会进入未定价统计(可见),而不是静默计 0 —— 这是 issue #1 / PR #2
|
||||
的收口语义。只有 `free` / `subscription`("边际成本为零")会写有意为之的
|
||||
`{prompt:0, completion:0}` 零价条目。
|
||||
|
||||
规则写法:
|
||||
|
||||
```json
|
||||
{"active":"current","profiles":[{"id":"current","default":"unpriced","rules":[
|
||||
{"source":"localzen","mode":"free"},
|
||||
{"source":"codearts","mode":"token",
|
||||
"models":{"glm-5.3":{"prompt":"0.15","completion":"0.60"}}},
|
||||
{"url":"*","mode":"token","models":{"some-model":{"prompt":"1.0","completion":"2.0"}}}
|
||||
]}]}
|
||||
```
|
||||
|
||||
**token 价**优先级:`keys` > `models` > `default`。
|
||||
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
|
||||
**`cache_discount`** 见 §7.7。
|
||||
|
||||
@ -641,17 +599,6 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
|
||||
|
||||
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
|
||||
|
||||
**profile 级兜底**:`profiles[].default` 可设为 `free` 或 `unpriced`(默认)。
|
||||
它是插件运行时的策略(编译期写入 `prices.default_mode`):
|
||||
|
||||
| `default` | 没有单独规则的源 | `unpriced_reqs` |
|
||||
|---|---|---|
|
||||
| `unpriced`(省略时) | 零价,计入未定价债务 | +1 |
|
||||
| `free` | 零价,视为**刻意免费** | 不增加 |
|
||||
|
||||
显式 `mode: unpriced` 的源即使在 `default: free` 下也仍然计入("只统计不收费"
|
||||
的声明不能被兜底策略吞掉)。
|
||||
|
||||
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
|
||||
`total` 与各维度,只有金额是 0。
|
||||
|
||||
|
||||
404
docs/qoder-wasm-integration.md
Normal file
404
docs/qoder-wasm-integration.md
Normal file
@ -0,0 +1,404 @@
|
||||
# Qoder WASM 接入评估(`qoder-auth-wasm.wasm`)
|
||||
|
||||
> **状态:未实现(not implemented)。**
|
||||
> 本文给出**已实测的可行性结论**与**可执行的接入方案**,但我们仓库里
|
||||
> **没有任何 wasm 运行时、也没有任何代码加载这个 wasm**。
|
||||
> 本文写于 `fix/channel-auth-headers` 分支,仅文档 + 评测工具,不动内核。
|
||||
|
||||
> **判据 ≠ 功能可用。** 本文所有「PASS」都是**本机静态/离线**结论:
|
||||
> qoder 账号数 **0**,所以「签名头是否被 Qoder 上游接受」**没有做过运行时验证**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论先行
|
||||
|
||||
| 问题 | 结论 | 依据 |
|
||||
|---|---|---|
|
||||
| wasm 能不能被加载? | **能**,且**不需要 JS 引擎** —— 纯 Go(wazero)即可实例化 | 31 个 import 全部由 Go 侧桩提供,实例化阶段真实调用 **0** 次 |
|
||||
| 能不能真正跑出鉴权头? | **能**,且**已实测做到** | `prepareInferRequest` 产出 20 个头,含 `Authorization: Bearer COSY.<载荷>.<签名>`,body 为 60 字节加密串 |
|
||||
| 31 个 import 要全部实现吗? | **不用。实际路径只触碰 18 个**,其中 5 个是 node/JS 环境探针(`process`/`node`/`require`/`versions`/`call`),**本路径一次都没调用** | 逐 import 计数(§4 表) |
|
||||
| 最小可行接入是什么? | **新增一个 `build_headers` 后端(wazero)**,由适配器把 `meta` 交给它,拿回 headers 原样透传 | §5 |
|
||||
| 本轮能做进 `fix/` 分支吗? | **不能**。见 §7 改动面与风险 —— 这属于主分支级改动 | §7 |
|
||||
| 风险最高的是什么? | **不是** wasm 调用,而是**二进制来源**(不在我们仓库,也不可再分发)与**账号数为 0 无法端到端验证** | §6 |
|
||||
| 性能可以接受吗? | **可以**。实测 ≈ **2.2 ms/请求**,线性内存 ≈ **1.25 MiB**,实例可复用 | §8 |
|
||||
|
||||
> ⚠️ **一条红线**:qoder 加密端点要求 `Authorization` **原样透传**。
|
||||
> 任何「适配器里再兜底一次 `Bearer <api_key>`」的写法都会覆盖签名,得到
|
||||
> `403 Signature invalid`。这必须在接入时由代码结构保证,不能只靠注释
|
||||
> (见 §5.4)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标物:abs 路径 / 大小 / sha256
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 绝对路径 | `/opt/dsh-plugins/dsh-our-free-model/2.0.0/vendor/channel-pack/qoder-auth-wasm.wasm` |
|
||||
| 大小 | `298606` 字节(291.6 KiB) |
|
||||
| sha256 | `6419471effa631519def7797d76d7ede38b9fcfa9a83c2148c8ef5d43355b43d` |
|
||||
| 格式 | `WebAssembly (wasm) binary version 0x1 (MVP module)` |
|
||||
| 归属 | 第三方插件 `dsh-our-free-model` 的 vendored 依赖;`NOTICE.md` 注明来自 MIT 许可的上游 channel-pack,**不随本仓分发** |
|
||||
| 本仓引用 | `git ls-files \| grep -i wasm` = **0 条** |
|
||||
|
||||
同一目录下的相关文件(**都不在我们仓库里**):
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `pack.js`(2.8 MB) | 插件运行时实际加载的单文件 bundle |
|
||||
| `src/qoder-wasm.ts`(706 行) | **手写的 wasm-bindgen glue** —— 31 个 import 的逐个实现 + ABI 助手。本文的 Go 实现即照它翻译 |
|
||||
| `NOTICE.md` / `LICENSE` | 出处与许可(MIT) |
|
||||
|
||||
> `qoder_auth_wasm_bg.js`(wasm-bindgen 本该生成的那份 glue)在该目录中
|
||||
> **不存在** —— 上游改用手写的 `qoder-wasm.ts` 替代。这一点决定了接入代价
|
||||
> 落在**宿主侧**,而这正好是可以由 Go 承担的部分。
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么它是「wasm-bindgen 产物,给 JS 用」
|
||||
|
||||
解析二进制各 section(不是推断):
|
||||
|
||||
- **只有 1 个 import module**:`./qoder_auth_wasm_bg.js`,31 个 import **全部**来自它;
|
||||
- import 名同时带有 wasm-bindgen 哈希后缀(`__wbg_set_08463b1df38a7e29`)与
|
||||
`__wbindgen_*` ABI 前缀;
|
||||
- 导出含 ABI plumbing:`memory`、`__wbindgen_add_to_stack_pointer`、`__wbindgen_export1..4`;
|
||||
- 返回多值靠**写栈槽**传递(`ptr/len/valIdx/isErr`),这是 wasm-bindgen 的约定。
|
||||
|
||||
⇒ 它**不是**「喂 WASI 就能跑」的模块。它要求宿主实现 `JS 值语义`
|
||||
(对象表 + 索引、`Uint8Array`、`Map`、`crypto.getRandomValues`、`Date.now`)。
|
||||
`--export-all` 之类参数改不了这一点:ABI 就写在 import 表里。
|
||||
|
||||
---
|
||||
|
||||
## 4. 31 import / 34 export 的四类用途(附实测触碰次数)
|
||||
|
||||
### 4.1 imports(31 个,全部来自 `./qoder_auth_wasm_bg.js`)
|
||||
|
||||
| 类 | # | 成员 | 纯 Go 可否 | **实测触碰** |
|
||||
|---|---|---|---|---|
|
||||
| **A. bindgen 对象表 ABI** | 4 | `__wbindgen_object_drop_ref`、`object_clone_ref`、`cast_0000000000000001`、`cast_0000000000000002` | ✅ 自建 `[]any` 索引表 | 3(`cast_..2`×40、`clone`×2、`drop`×65) |
|
||||
| **B. 字节缓冲 / Map** | 6 | `new_with_length`、`length`、`subarray`、`set`、`prototypesetcall`、`new_99cabae`(Map) | ✅ `[]byte` + `map` | 6(全部触碰) |
|
||||
| **C. 随机源与时间** | 4 | `getRandomValues` **两个方向相反的变体**、`randomFillSync`、`now` | ✅ `crypto/rand`、`time.Now` | 3(`randomFillSync` 未被调用) |
|
||||
| **D. JS/Node 环境探针** | 17 | `crypto`、`msCrypto`、`process`、`versions`、`node`、`require`、`call`、`globalThis`、`self`、`window`、`throw`、`Error`、`is_object`、`is_string`、`is_function`、`is_undefined` | 部分:`self`/`window` 返 `0`;`process`/`node` 等**从不被调用**即可 | 4(`crypto`×1、`globalThis`×1、`self`×1、`window`×1、`is_object`×1、`is_undefined`×1) |
|
||||
|
||||
**实测合计:31 个中 13 个「从未被调用」。** 未触达的 13 个:
|
||||
|
||||
```
|
||||
__wbg_process_44c7a14e11e9f69e __wbg_versions_276b2795b1c6a219
|
||||
__wbg_node_84ea875411254db1 __wbg_require_b4edbdcf3e2a1ef0
|
||||
__wbg_msCrypto_bd5a034af96bcba6 __wbg_randomFillSync_6c25eac9869eb53c
|
||||
__wbg_call_d578befcc3145dee __wbg_static_accessor_GLOBAL_f2e0f995a21329ff
|
||||
__wbg___wbindgen_throw_81fc77679af83bc6 __wbg_Error_2e59b1b37a9a34c3
|
||||
__wbg___wbindgen_is_string_b29b5c5a8065ba1a
|
||||
__wbg___wbindgen_is_function_49868bde5eb1e745
|
||||
__wbindgen_cast_0000000000000001
|
||||
```
|
||||
|
||||
> 这是**本路径**的观测(`generate_runtime_auth_fields` + `qodercontext_new`
|
||||
> + `prepareInferRequest`)。错误分支会用到 `throw`/`Error`,所以实现里
|
||||
> 仍应给它们一个真实语义,而不是留空。
|
||||
|
||||
### 4.2 exports(34 个)
|
||||
|
||||
| 类 | # | 成员 | 与本接入的关系 |
|
||||
|---|---|---|---|
|
||||
| **1. 鉴权字段生成** | 1 | `generate_runtime_auth_fields` | **必需**(`encrypt_user_info` / `key` 是 `qodercontext_new` 的前置) |
|
||||
| **2. 请求构造 / 加解密** | 13 | `qodercontext_new`、`qodercontext_prepareInferRequest`、`qodercontext_prepareRequest`、`qodercontext_refreshAuthFields`、`qodercontext_get_external_providers_access`、`requestresult_url\|body\|headers\|headerCount`、`decrypt_server_response`、`profile_encrypt`、`__wbg_qodercontext_free`、`__wbg_requestresult_free` | **核心**:本接入只需其中 6 个 |
|
||||
| **3. 本地存储 / 缓存 / 凭据加解密** | 8 | `credential_storage_encrypt\|decrypt`、`model_cache_encrypt\|decrypt`、`profileencryptor_new\|encryptChunk\|header`、`__wbg_profileencryptor_free` | **不需要**(推理路径不涉及;仅「离线读本机目录缓存」会用到) |
|
||||
| **4. 静态常量 / HTTPDNS / 环境探测** | 6 | `get_httpdns_config\|account_id\|secret_key`、`build_httpdns_url`、`get_external_providers_feature_gate_fallback`、`get_profile_key_fingerprint` | **不需要**(可换成我们自己的常量) |
|
||||
| plumbing | 6 | `memory`、`__wbindgen_export1..4`、`__wbindgen_add_to_stack_pointer` | 必需 |
|
||||
|
||||
> 即:**34 个 export 里,接入只需要 1 + 6 + plumbing**。
|
||||
> 其余 27 个不影响「能不能发出一条被签名的 qoder 请求」。
|
||||
|
||||
---
|
||||
|
||||
## 5. 最小可行接入方案(设计,未实现)
|
||||
|
||||
### 5.1 依赖选型
|
||||
|
||||
| 候选 | 结论 | 理由 |
|
||||
|---|---|---|
|
||||
| **`github.com/tetratelabs/wazero` v1.9.0** | ✅ **推荐** | 纯 Go、`CGO_ENABLED=0` 可交叉编译、**零传递依赖**(`go list -m all` 只有它自己);`go 1.22` 下限,本仓 `go 1.25` 无冲突 |
|
||||
| `wasmtime-go` | ❌ | 需要 CGO + 预编译动态库 ⇒ 破坏 `go build` 的单二进制分发与 Windows 交叉编译 |
|
||||
| `wasmer-go` | ❌ | 同上,且维护活跃度低 |
|
||||
| 自己写解释器 | ❌ | 不值当 |
|
||||
| 用 Node 子进程跑上游 `pack.js` | ⚠️ 备选 | 现有 node 可跑,但把「一个 Go 单二进制网关」变成「依赖宿主 node + 一份 2.8 MB 外部 bundle」,运维形态倒退 |
|
||||
|
||||
体积实测(`CGO_ENABLED=0`,空 `main`):
|
||||
|
||||
| 构建 | 大小 |
|
||||
|---|---|
|
||||
| 空程序 | 1,895,775 B |
|
||||
| `+ wazero`(仅引用 `NewRuntime`) | 3,106,358 B |
|
||||
| 增量 | **≈ +1.16 MiB** |
|
||||
|
||||
> 交叉编译已验证:`GOOS=windows GOARCH=amd64 CGO_ENABLED=0` 出 3,050,496 B,
|
||||
> 与现有 `gui-win` 流程兼容。
|
||||
|
||||
### 5.2 二进制从哪来(**必须先解决**)
|
||||
|
||||
wasm **不入库**会带来三个问题:换机失效、插件升级后 sha256 漂移、离线构建不安全。
|
||||
三条可选路径,推荐 ①:
|
||||
|
||||
| 方案 | 做法 | 代价 |
|
||||
|---|---|---|
|
||||
| **① 运维侧提供,启动时校验**(推荐) | 新增配置项 `qoder_wasm_path`;启动/首次使用时读文件,**校验 sha256 必须等于** `6419471e…55b43d`,不符则拒绝启用并报错 | 需要运维放一份文件;但**不引入许可问题**,且校验把「用错版本」变成显式失败 |
|
||||
| ② 我们仓库 `go:embed` | 把 wasm 放进 `internal/qoderwasm/assets/` 并 embed | ✅ 单二进制、无外部依赖;❌ **许可与再分发**需先确认(上游 MIT,但需保留 NOTICE),且 291 KiB 进 git |
|
||||
| ③ 运行时从上游插件目录读(我们现在的路径) | 直接读 `/opt/dsh-plugins/...` | ❌ 对别人安装目录硬依赖,**不建议进主干** |
|
||||
|
||||
> **本轮不选任何一条** —— 选型本身就是需要主管决策的事项(涉及再分发与
|
||||
> 运维约定),不该由一条 fix 分支暗定。
|
||||
|
||||
### 5.3 接入点:放在哪
|
||||
|
||||
现架构事实(`grep` 实测):
|
||||
|
||||
- 适配器是 **Lua**(`internal/lua/adapters/*.lua` + 插件自装 `channels-qoder`);
|
||||
- `channels-qoder` 目前**没有 `build_headers`**,靠静态头 + 内核兜底 `Bearer`;
|
||||
- 内核统一入口是 `Provider.buildHeaders`(`internal/provider/provider.go:1104`)
|
||||
→ `VM.BuildHeaders`(`internal/lua/vm.go:1077`)→ Lua `adapter.build_headers(meta)`;
|
||||
- **`go.mod` 里没有任何 wasm 运行时**;`internal/` 下 wasm 相关命中**全部是注释**;
|
||||
- Lua 侧**没有** wasm 能力(`channels.lua:141` 自己写明「无法在当前 Lua 环境完成」)。
|
||||
|
||||
⇒ 既然桥接面是 `build_headers(meta) → map[string]string`,**最干净的接法是在
|
||||
这个桥后面加一个 Go 侧后端**,而不是把 wasm 塞进 Lua:
|
||||
|
||||
```
|
||||
Provider.buildHeaders (internal/provider/provider.go)
|
||||
└─ VM.BuildHeaders (internal/lua/vm.go)
|
||||
└─ Lua adapter.build_headers(meta) ← channels-qoder 适配器
|
||||
└─ __llmsproxy_kernel("qoder.wasm_headers", meta) ← 新增内核动作
|
||||
└─ internal/qoderwasm.PrepareHeaders(meta) ← 新增包(wazero)
|
||||
└─ qoder-auth-wasm.wasm
|
||||
```
|
||||
|
||||
**为什么走 `__llmsproxy_kernel` 而不是新增一个 Lua 全局**:
|
||||
`internal/lua/plugin_bridges.go:146` 已有 `registerKernelBridge`,它是
|
||||
**按插件声明授权**的(插件在 `plugin.kernel_actions` 里列白名单)。
|
||||
新增一个 Lua 全局等于给**所有**插件开能力;走 kernel action 则保持「谁声明
|
||||
谁获得」。这与现有 `plugin_http.go` 的收窄思路一致。
|
||||
|
||||
**为什么不在 provider 里直接 if-else 厂商名**:本仓的既有约束是
|
||||
「内核零厂商名」(见 `channels.lua:1017` 的注释与 P2 段)。wasm 调用应作为
|
||||
一个**通用内核能力**暴露(`wasm.headers`),由插件把 qoder 语义喂给它。
|
||||
|
||||
### 5.4 输入 / 输出边界
|
||||
|
||||
```go
|
||||
// internal/qoderwasm(新包,示例形参;未实现)
|
||||
type Credentials struct {
|
||||
UID string `json:"uid"`
|
||||
SecurityOauthToken string `json:"security_oauth_token"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
OrganizationTags []string `json:"organization_tags"`
|
||||
DataPolicyAgreed bool `json:"data_policy_agreed"`
|
||||
MachineID string `json:"machine_id"` // 必填:缺失会 AES-GCM 失败
|
||||
ClientVersion string `json:"client_version"` // 默认 "1.1.49"
|
||||
}
|
||||
|
||||
type Metadata struct { // 写入 QoderContext
|
||||
ClientType string `json:"client_type"`
|
||||
BusinessProduct string `json:"business_product"`
|
||||
BusinessType string `json:"business_type"`
|
||||
Scene string `json:"scene"`
|
||||
}
|
||||
|
||||
type InferRequest struct {
|
||||
Host string // ⚠️ 必须 api2.qoder.sh 系,不能是 api2-v2
|
||||
Body json.RawMessage // 明文 payload(由插件构造)
|
||||
ModelKey string // 目录 key,如 qfmodel
|
||||
Source string
|
||||
}
|
||||
|
||||
// PrepareHeaders 返回**必须原样透传**的头集合。
|
||||
func PrepareHeaders(ctx context.Context, c Credentials, m Metadata, r InferRequest) (map[string]string, error)
|
||||
```
|
||||
|
||||
**输出实测形态**(20 个头):
|
||||
|
||||
```
|
||||
Accept text/event-stream Authorization Bearer COSY.<载荷>.<签名>(len=209)
|
||||
Cache-Control no-cache Connection keep-alive
|
||||
Content-Type application/json Cosy-Business-Product qoder
|
||||
Cosy-Business-Type default Cosy-ClientType 10
|
||||
Cosy-Data-Policy disagree Cosy-Date 1792279699
|
||||
Cosy-Key <modelKey> Cosy-MachineId <machineId>
|
||||
Cosy-MachineToken <machineId> Cosy-MachineType 5
|
||||
Cosy-Scene chat Cosy-User <uid>
|
||||
Cosy-Version 1.1.49 Login-Version v2
|
||||
X-Model-Key <modelKey> X-Model-Source system
|
||||
```
|
||||
|
||||
> 其中只有 `Authorization` / `Cosy-Key` / `Cosy-MachineId` / `Cosy-User` 等是
|
||||
> **动态**的;其余来自 `Metadata`/常量。注意 `Cosy-Date` 并非当前时间
|
||||
> (实测 `1792279699`,是 WASM 内部计算),**不要用 Go 时间覆盖**。
|
||||
|
||||
**红线(结构保证,不是注释)**:`Provider.buildHeaders`
|
||||
(`provider.go:1141`)当前逻辑是
|
||||
`if h.Get("Authorization")=="" && h.Get("X-Api-Key")=="" { 补 Bearer }`。
|
||||
qoder 拿到签名 `Authorization` 后自然跳过兜底 —— 这个**已有**行为正好安全,
|
||||
但新接入**必须有一个判据钉住**:签名头存在时兜底 Bearer 不得生效。
|
||||
|
||||
### 5.5 31 个 import 的 Go 实现映射表
|
||||
|
||||
| import | Go 实现 | 备注 |
|
||||
|---|---|---|
|
||||
| `__wbindgen_object_drop_ref` | **no-op(不释放槽位)** | ⚠️ 实测该模块会 `drop` 一个 Map 之后**继续用同一索引** `set`。wasm-bindgen 新版把 drop 退化为空操作(表交给 JS GC)。按「释放槽位」实现会得到 `set on undefined` |
|
||||
| `__wbindgen_object_clone_ref` | `push(obj[idx])` | |
|
||||
| `__wbindgen_cast_..._1` | wasm slice → `[]byte` | 本路径未触碰 |
|
||||
| `__wbindgen_cast_..._2` | wasm str → Go `string` | 最高频(40 次):字符串返回靠它 |
|
||||
| `__wbg_new_with_length` | `make([]byte, n)` | |
|
||||
| `__wbg_length` | `len(buf)` | |
|
||||
| `__wbg_subarray` | `buf[lo:hi]`(**共享底层数组**,不要 copy) | |
|
||||
| `__wbg_set` | 两种形态:`[]byte.set(src, off)` / `Map.set(k,v)` | 全靠 `arg0` 的动态类型分派 |
|
||||
| `__wbg_prototypesetcall` | 直接写 wasm 内存 | |
|
||||
| `__wbg_new_99cabae` | `new Map()` → 有序 `{keys,vals}` | `requestresult_headers` 的容器 |
|
||||
| **`__wbg_getRandomValues_d49329ff89a07af1`** | `crypto/rand` 写 **wasm 内存** `(ptr,len)` | ⚠️ 与下面那个**签名方向相反** |
|
||||
| **`__wbg_getRandomValues_c44a50d8cfdaebeb`** | 对 **crypto 对象** `(objIdx, bufIdx)` 填随机 | ⚠️ 写反 ⇒ Rust `unreachable` panic |
|
||||
| `__wbg_randomFillSync` | `crypto/rand` 填 `[]byte` | 本路径未触碰 |
|
||||
| `__wbg_now` | `float64(time.Now().UnixMilli())` | 返回 **f64** |
|
||||
| `__wbg_crypto` / `__wbg_msCrypto` | 返回一个「crypto 对象」标记值 | 只为喂 `getRandomValues` 变体 B |
|
||||
| `__wbg_static_accessor_GLOBAL_THIS` / `GLOBAL` | 返回任意非 0 索引 | |
|
||||
| `__wbg_static_accessor_SELF` / `WINDOW` | **返回 0** | 服务端无 window/self,wasm-bindgen 约定 0 = 缺席 |
|
||||
| `__wbg___wbindgen_is_object` | 类型判断(`[]byte`/`Map`/crypto) | |
|
||||
| `__wbg___wbindgen_is_string` / `is_function` / `is_undefined` | 类型判断 | 后两个本路径未触碰 |
|
||||
| `__wbg___wbindgen_throw` | 记日志(或抛 panic 转 error) | 错误分支 |
|
||||
| `__wbg_Error` | `fmt.Errorf(读出的字符串)` | 错误分支 |
|
||||
| `__wbg_process` / `versions` / `node` / `require` / `call` | 返回 `undefined` / 0 | **实测从未被调用**;但若被调用说明换了 wasm 版本,应告警 |
|
||||
| `__wbindgen_add_to_stack_pointer` / `export1..4` | 由 wasm 自己提供(是 **export**,不是 import) | |
|
||||
|
||||
### 5.6 调用顺序与 ABI 陷阱(都实测过)
|
||||
|
||||
1. `generate_runtime_auth_fields(userJSON)` → `{encrypt_user_info, key}`(各 **172** 字符)
|
||||
2. `qodercontext_new(sp, machineId, len, version, len, userInfo, len, clientMeta, len)`
|
||||
- 返回布局是 **`ptr/errIdx/isErr`**(与字符串类的 `ptr/len/valIdx/isErr` **不同**,混用会得到 `null pointer passed to rust`)
|
||||
3. `qodercontext_prepareInferRequest(sp, ctxPtr, host, len, body, len, key, len, source, len)`
|
||||
- 同样是 `ptr/errIdx/isErr`
|
||||
4. `requestresult_headers(resPtr)` → Map
|
||||
5. `requestresult_url(sp, resPtr)` / `requestresult_body(sp, resPtr)`
|
||||
- ⚠️ **参数顺序是 `(栈指针, ptr)`**,与直觉相反
|
||||
|
||||
另外两条实测纪律:
|
||||
|
||||
- ⚠️ `machineId` 是**必填**:漏传得到 `AES-GCM decrypt failed: aead::Error`,
|
||||
看起来像「密文损坏」,实际是缺参数。
|
||||
- ⚠️ `host` 必须 `api2.qoder.sh` 系;传 `api2-v2.qoder.sh`(公开 OpenAI 兼容端点)
|
||||
会 **404**。
|
||||
|
||||
### 5.7 并发模型
|
||||
|
||||
- **wasm 实例无状态可复用**,实测 `-repeat 50` 全部 PASS;
|
||||
- 但上游注释明确 `QoderContext` **不是线程安全的**,且「一个账号一个实例」;
|
||||
- ⇒ 设计为 **每个账号一个 `QoderContext` + 一把互斥锁**(复用现有 pool 模式),
|
||||
或按 `(uid, machineId)` 做 key 的 `sync.Map`。**不要**单实例全局共享。
|
||||
- 兜底:`objectHeap` 是**只增不回收**的(见 §5.5 drop_ref),长跑需设
|
||||
`qodercontext_free` + 定期重建实例,否则对象表单调增长。
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险点
|
||||
|
||||
| # | 风险 | 级别 | 说明 / 缓解 |
|
||||
|---|---|---|---|
|
||||
| R1 | **二进制不在我们仓库,也不可再分发** | 🔴 高 | 需先定 §5.2 的三种来源方案之一;`go:embed` 需法务/许可确认 |
|
||||
| R2 | **账号数 0 ⇒ 端到端不可验证** | 🔴 高 | 本机能证明「产出了签名头」,**不能**证明「上游接受」。必须拿到 1 个 qoder 账号才能做真实验收 |
|
||||
| R3 | wasm 版本漂移 | 🟠 中 | 上游换版本 ⇒ sha256 变,import 表可能变(尤其那 13 个未触碰项)。**必须**启动时校验 sha256 并在版本不符时显式拒绝 |
|
||||
| R4 | `drop_ref` 语义与 wasm-bindgen 版本相关 | 🟠 中 | 本次实测必须 no-op;换 wasm 后需重跑 `tools/qoder-wasm-probe` 复验,否则会退化成 `set on undefined` |
|
||||
| R5 | 对象表只增不回收 ⇒ 内存缓涨 | 🟠 中 | 每个 `QoderContext` 定期重建;`requestresult_*` 用后 `__wbg_requestresult_free` |
|
||||
| R6 | 加密端点意味着**响应也是密文** | 🟠 中 | 需 `decrypt_server_response` 解包,且要处理 SSE 逐帧解密(上游走 `Encode=1`)。这是**第二条**未实现链路,工作量不小 |
|
||||
| R7 | 端点语义与现有明文路径**并存** | 🟡 低-中 | `channels_upstream_gaps_test.go` 现有判据钉住「走明文端点且无信封解包」。接入加密端点必须**同时**改那条判据,否则两套逻辑互相打脸 |
|
||||
| R8 | 引入 wazero 抬升内核版本 | 🟡 低 | 按 §4 的版本纪律,新增 Go 依赖 = 核心版本递增(不是 patch) |
|
||||
| R9 | 体积 +1.16 MiB | 🟡 低 | 桌面端/服务端均可接受,但需确认 `packaging/` 的体积校验阈值 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 预计改动面(为什么不该放进 `fix/` 分支)
|
||||
|
||||
| 层 | 文件(预计) | 性质 |
|
||||
|---|---|---|
|
||||
| 新依赖 | `go.mod` / `go.sum`(wazero) | **内核级** |
|
||||
| 新包 | `internal/qoderwasm/{glue.go,abi.go,context.go,headers.go}` + 测试 | 新代码 ≈ 600–900 行(glue 706 行 TS 的 Go 翻译) |
|
||||
| 内核桥 | `internal/lua/plugin_bridges.go`(新增 kernel action)、`internal/lua/kernel_api.go` 白名单 | **内核级** |
|
||||
| 插件 | `internal/lua/plugins/channels.lua`(`channels-qoder` 增加 `build_headers` + 密文响应解包) | 插件级 |
|
||||
| 判据 | `internal/core/channels_upstream_gaps_test.go`(**必须改**,见 R7)、新增 adapter 判据 | 判据级 |
|
||||
| 部署 | `packaging/`(wasm 放置 + 校验)、`config`(`qoder_wasm_path`) | 运维级 |
|
||||
| 文档 | 本文 + `docs/channel-repair-plan.md` P1-4 段 | 文档级 |
|
||||
|
||||
**结论:这是一个跨 7 个层、含新内核依赖、且需要账号才能验收的主分支级改动。**
|
||||
一条 `fix/channel-auth-headers` 分支承担它会把「已修好的三条头缺陷」
|
||||
一起拖成不可发布状态。⇒ 本轮**不做**,只交付本文 + 可复现工具。
|
||||
|
||||
### 建议的推进顺序(若主管决定做)
|
||||
|
||||
1. **先决策 §5.2**(wasm 从哪来)—— 这是唯一的前置阻塞。
|
||||
2. `internal/qoderwasm` 落地 + 用 `tools/qoder-wasm-probe` 的用例做单测
|
||||
(**离线可测**:不需要账号,断言头集合形状与 sha256 校验)。
|
||||
3. 内核桥 `wasm.headers` + 插件 `build_headers` 接线,**只走明文端点**先跑通
|
||||
头生成链路(不切端点)。
|
||||
4. 拿到 1 个真实账号后,验证「签名头被接受」→ 再决定是否切加密端点。
|
||||
5. 切端点那一轮再处理 R6(响应/SSE 解密)与 R7(判据改写)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 实测记录(可复现)
|
||||
|
||||
工具:`tools/qoder-wasm-probe`(**独立 module**,主仓 `go.mod` 不含 wazero)。
|
||||
|
||||
```bash
|
||||
cd tools/qoder-wasm-probe
|
||||
go run . -wasm /opt/dsh-plugins/dsh-our-free-model/2.0.0/vendor/channel-pack/qoder-auth-wasm.wasm
|
||||
# 加 -json 出结构化结果;-repeat N 测实例复用;-strict 让未实现 import 被调用即失败
|
||||
```
|
||||
|
||||
本次输出(2026-10-18,go1.27.1 linux/amd64,wazero v1.9.0):
|
||||
|
||||
```
|
||||
wasm : .../qoder-auth-wasm.wasm
|
||||
size : 298606 bytes
|
||||
imports : 31 (all from "./qoder_auth_wasm_bg.js")
|
||||
exports : 33
|
||||
instantiate: true calls-at-instantiate=0
|
||||
|
||||
-- LEVEL 2: generate_runtime_auth_fields --
|
||||
encrypt_user_info <string len=172>
|
||||
key <string len=172>
|
||||
|
||||
-- LEVEL 3: qodercontext_new + prepareInferRequest --
|
||||
url = api2.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1
|
||||
body = 60 bytes (encrypted, not logged)
|
||||
hdr Authorization Bearer COSY.eyJ2ZXJzaW9u…<len=209>
|
||||
...(共 20 个头)
|
||||
|
||||
-- IMPORT USAGE (18 of 31 touched) --
|
||||
RESULT: PASS
|
||||
```
|
||||
|
||||
量化(`-repeat 50 -json`):
|
||||
|
||||
| 指标 | 值 |
|
||||
|---|---|
|
||||
| 50 次 `prepareInferRequest` 端到端 | ≈ 108 ms ⇒ **≈ 2.2 ms/请求** |
|
||||
| wasm 线性内存(跑完后) | 20 页 = **1.25 MiB** |
|
||||
| 未触碰 import | **13 / 31** |
|
||||
| 实例复用 | 50 次全部 PASS(无状态泄漏迹象) |
|
||||
|
||||
> **再次声明:以上全部是离线静态/单机结论。**
|
||||
> 「签名头被 qoder 上游接受」**未验证** —— 本机 qoder 账号数 **0**。
|
||||
|
||||
---
|
||||
|
||||
## 9. 与现有实现的冲突(必须在动手前处理)
|
||||
|
||||
`internal/core/channels_upstream_gaps_test.go` 当前把**明文端点**写成了判据:
|
||||
|
||||
> 「qoder 的 chat 端点变了 —— 若改成加密端点 `agent_chat_generation`,
|
||||
> 则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名」
|
||||
|
||||
这条判据是对的(它防的是「只改一半」)。接入 wasm 时**它必须同步演进**,
|
||||
否则会出现「新代码已走加密端点、旧判据仍断言没有信封解包」的自相矛盾。
|
||||
|
||||
> 注意该判据注释里的一处**过时事实**:它写「该二进制在参考仓库里也不存在」。
|
||||
> 实测:文件**存在**于 `vendor/channel-pack/qoder-auth-wasm.wasm`
|
||||
> (sha256 `6419471e…`),只是**不在我们仓库**。接入时应顺手更正这句,
|
||||
> 但**不要**为了「改注释」而放宽判据。
|
||||
109
docs/source-name-contract.md
Normal file
109
docs/source-name-contract.md
Normal file
@ -0,0 +1,109 @@
|
||||
# 源名与限定名契约
|
||||
|
||||
> 本文只说一件事:**源名允许哪些字符,以及为什么**。
|
||||
> 判据:`internal/config/source_name_contract_test.go`、`internal/core/source_name_contract_test.go`。
|
||||
|
||||
## 1. 契约
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 允许字符集 | **仅 `[A-Za-z0-9_-]`** |
|
||||
| 禁止 | **`:`** 与 **`/`**(限定名分隔符,见下) |
|
||||
| 其它禁止 | 空白、换行、`.`、`+`、`@`、非 ASCII 等一切不在允许集内的字符 |
|
||||
| 长度 | 非空(上限未强制,但超长源名无实际用途) |
|
||||
|
||||
合法的真实形态(本仓渠道源):
|
||||
|
||||
```
|
||||
channels-trae-b8dd6b3df30e
|
||||
channels-buddy-buddy-main
|
||||
channels-zcode-<accountId>
|
||||
```
|
||||
|
||||
注意 `-` **是允许的**:渠道源名天生含 `-`,而 `-` 作分隔符时是「源名之后」
|
||||
才出现,不产生歧义。
|
||||
|
||||
## 2. 为什么禁 `:` 与 `/`
|
||||
|
||||
### 2.1 背景:限定名是**有意设计**,不是兼容尾巴
|
||||
|
||||
网关支持 `源名:模型名` 这种**限定名**来指定「从哪个源调用」:
|
||||
|
||||
```
|
||||
channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro
|
||||
```
|
||||
|
||||
这不是历史包袱 —— 生产实例里 **44 个模型被多个源同时提供**
|
||||
(`glm-5.2` 由 6 个源提供),WebUI 的模型下拉框用 `源名:模型名` 作
|
||||
option value。没有限定名,用户就无法指定源。
|
||||
|
||||
`:` / `/` / `-` 三种分隔符都支持(历史写了三种,都保留)。
|
||||
|
||||
### 2.2 根因:源名含分隔符 ⇒ 限定名有**两种都自圆其说**的切法
|
||||
|
||||
剥离源名的那一步是 `internal/provider/splitPinned`:先按**已知源名做最长
|
||||
前缀匹配**,命中后要求源名之后紧跟一个分隔符。
|
||||
|
||||
若源名**自己含** `:` 或 `/`,那个字符同时是「源名的一部分」和「分隔符」:
|
||||
|
||||
| 源名 | 模型 | 限定名 | 可能的切法 |
|
||||
|---|---|---|---|
|
||||
| `a:b` | `m` | `a:b:m` | `("a", "b:m")` 或 `("a:b", "m")` |
|
||||
| `a/b` | `m` | `a/b/m` | `("a", "b/m")` 或 `("a/b", "m")` |
|
||||
|
||||
两种切法都能自圆其说,结果取决于**当前恰好存在哪些源名** —— 也就是
|
||||
同一个客户端请求串,在不同部署、不同启动顺序下可能落到**不同的源**。
|
||||
|
||||
更糟的是它**不会报错**:`splitPinned` 会命中某一个分支,然后路由到别处。
|
||||
历史上这一族缺陷就非常难查:
|
||||
|
||||
> `IndexAny(model, "-:/")` 按**第一个**分隔符切,把
|
||||
> `channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` 切成 `src="channels"`、
|
||||
> `model="trae-b8dd6b3df30e:..."` ⇒ 源名永不匹配 ⇒ `Resolve()` 返回 nil
|
||||
> ⇒ 客户端看到 `model "channels-trae-xxx:..." is not configured`。
|
||||
> 用户只看到「模型不能用」,完全看不出是字符串切分错了。
|
||||
|
||||
所以约束必须放在**写入口**,而不是每次解析时去猜。
|
||||
|
||||
## 3. 校验加在哪(三个位置,职责不同)
|
||||
|
||||
| 位置 | 函数 | 行为 | 为什么在这儿 |
|
||||
|---|---|---|---|
|
||||
| **写入口(硬拒)** | `core.normalizeSource`(`internal/core/core.go`) | `AddSource` 返回 error(API 400) | WebUI 新增/修改源、插件 `add_source` 都走这里。**新数据必须从源头就是合法的** |
|
||||
| **启动(只告警)** | `config.ApplyDefaults`(`internal/config/config.go`) | `log.Printf` 一行,**不返回 error** | 存量配置可能已有带 `:` 的源名。升级瞬间让网关起不来比「限定名歧义」更坏 —— 整个服务不可用 |
|
||||
| **校验器本体** | `config.ValidateSourceName` / `config.SourceNameWarning` | 纯函数 | 单一定义,上面两处都调它,不会漂移 |
|
||||
|
||||
**解析入口(不在上面三处,但需要知道)**
|
||||
:`internal/provider/registry.go` 的 `splitPinned` 是限定名的**解析**入口,
|
||||
`Resolve` / `ResolvePinned` / `EffectiveModel` 三个都用它。
|
||||
它**不做校验**(解析不该拒绝已有数据),只做最长前缀匹配 + 分隔符判定。
|
||||
校验的责任全在写入口 —— 这样解析路径保持纯函数、无副作用。
|
||||
|
||||
## 4. 存量数据怎么办
|
||||
|
||||
启动时告警、不阻断:
|
||||
|
||||
```
|
||||
[config] source name "legacy:v1" contains ":", which is a reserved pinning
|
||||
separator; "legacy:v1" is ambiguous (source names allow only [A-Za-z0-9_-])
|
||||
— `source:model` pinning may resolve to the wrong source
|
||||
```
|
||||
|
||||
运维看到告警后可以:
|
||||
- 改名(推荐,WebUI 里编辑即可 —— 改名后 `channels-*` 源会在下次账号同步时重建)
|
||||
- 或者继续用,但**不要**对这种源使用 `源名:模型名` 限定名(用「模型 + 源」的
|
||||
显式槽位,即 AUTO 链里的 `(model, source)` 对,那条路径不经过 `splitPinned`)
|
||||
|
||||
## 5. 判据覆盖
|
||||
|
||||
| 判据 | 文件 | 钉住什么 |
|
||||
|---|---|---|
|
||||
| `TestValidateSourceNameAllowsContractCharset` | `internal/config/source_name_contract_test.go` | 合法集(含真实渠道源形态、含 `-`)不被误拒 |
|
||||
| `TestValidateSourceNameRejectsPinningSeparators` | 同上 | `:` / `/` 被**点名**拒绝(错误文案含 `pinning separator` + 回显源名) |
|
||||
| `TestValidateSourceNameRejectsOtherIllegalChars` | 同上 | 空白/换行/`.`/`+`/`@`/非 ASCII/空串 |
|
||||
| `TestSourceNameWarningIsSoftForLegacyConfig` | 同上 | 存量带 `:` 能启动(不 brick),且有告警文案 |
|
||||
| `TestAddSourceRejectsPinnedSeparatorInName` | `internal/core/source_name_contract_test.go` | **真实写入入口** `Core.AddSource` 硬拒,且拒绝发生在写盘之前 |
|
||||
| `TestAddSourceAcceptsChannelStyleNames` | 同上 | 真实渠道源名照常接受(防校验器写太严把渠道全废掉) |
|
||||
|
||||
> 写入口那条特意在 `internal/core` 而不是 `internal/config`:校验器写对了但
|
||||
> **没接到入口上**,问题依旧存在 —— 两条判据分别咬「规则对不对」和「规则有没有被调用」。
|
||||
323
internal/billing/compile.go
Normal file
323
internal/billing/compile.go
Normal file
@ -0,0 +1,323 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// Compile turns one billing profile into the prices table the billing plugin
|
||||
// expects, resolving URL rules against the gateway's actual sources.
|
||||
//
|
||||
// WHY URL RULES NEED RESOLVING AT ALL: the operator declares pricing by URL
|
||||
// because that is what a provider's price list is keyed on, and because several
|
||||
// sources can point at the same URL. The plugin, however, looks up by
|
||||
// `payload.source` (a source NAME) and by model — it has no idea what URL a
|
||||
// request went to. So the URL match happens here, at compile time, where the
|
||||
// config's name->base_url mapping is known, and the result is expressed in the
|
||||
// dimensions the plugin already supports.
|
||||
//
|
||||
// Profiles are the "let the user choose" axis: the same URL can appear in
|
||||
// several profiles and switching recomputes this table, so a gateway can be
|
||||
// repriced without editing the plugin.
|
||||
func Compile(profile *config.BillingProfile, sources []config.Source) (map[string]interface{}, error) {
|
||||
return CompileOpts(profile, sources, false)
|
||||
}
|
||||
|
||||
// CompileOpts is Compile with an explicit policy for rules that match no
|
||||
// source.
|
||||
//
|
||||
// The default is STRICT and that is correct for startup: a profile with a
|
||||
// typo'd URL prices nothing, every request on it is recorded as unpriced, and
|
||||
// the bill silently comes out wrong. Failing the load is the right response.
|
||||
//
|
||||
// The rules editor needs the opposite. An operator editing a half-finished
|
||||
// profile — adding a rule before the source that will use it exists, or
|
||||
// renaming a URL while typing — must be able to save and then see the warning,
|
||||
// not be blocked by an error they can only resolve by guessing. Lenient mode
|
||||
// keeps the offending rule in the config and drops it from the compiled table,
|
||||
// so it is preserved as text and clearly reported, without pretending it
|
||||
// prices anything.
|
||||
func CompileOpts(profile *config.BillingProfile, sources []config.Source, lenient bool) (map[string]interface{}, error) {
|
||||
if profile == nil {
|
||||
return nil, fmt.Errorf("no billing profile")
|
||||
}
|
||||
prices := map[string]interface{}{
|
||||
"currency": "USD",
|
||||
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
|
||||
"sources": map[string]interface{}{},
|
||||
"models": map[string]interface{}{},
|
||||
"keys": map[string]interface{}{},
|
||||
}
|
||||
if profile.Default != "" {
|
||||
prices["default_mode"] = profile.Default
|
||||
}
|
||||
|
||||
// Currency is a profile-level statement; a rule may override it.
|
||||
for i := range profile.Rules {
|
||||
if c := profile.Rules[i].Currency; c != "" {
|
||||
prices["currency"] = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
matched := map[string]bool{}
|
||||
for i := range profile.Rules {
|
||||
rule := &profile.Rules[i]
|
||||
targets := matchSources(rule.URL, sources)
|
||||
if len(targets) == 0 && rule.URL != "*" {
|
||||
// A rule for a URL no source uses is almost always a typo (or a
|
||||
// source that was removed). Failing loudly beats a profile that
|
||||
// silently prices nothing — the whole reason this is a config file
|
||||
// instead of a hand-written JSON blob.
|
||||
if lenient {
|
||||
continue // kept in the config text, reported as a warning
|
||||
}
|
||||
return nil, fmt.Errorf("rule url %q matches no configured source base_url", rule.URL)
|
||||
}
|
||||
for _, srcName := range targets {
|
||||
if matched[srcName] {
|
||||
// First rule wins. Two rules matching one source is ambiguous,
|
||||
// and silently letting the later one win makes the file's
|
||||
// meaning depend on ordering the operator cannot see.
|
||||
continue
|
||||
}
|
||||
matched[srcName] = true
|
||||
if err := applyRule(prices, rule, srcName, sources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sources no rule matched fall through to the profile default, which the
|
||||
// plugin applies via prices.default. Recording them explicitly means the
|
||||
// UI can say "this source is unpriced" instead of leaving the operator to
|
||||
// infer it from a zero.
|
||||
var unmatched []string
|
||||
for _, src := range sources {
|
||||
if !matched[src.Name] {
|
||||
unmatched = append(unmatched, src.Name)
|
||||
}
|
||||
}
|
||||
if len(unmatched) > 0 {
|
||||
prices["unmatched_sources"] = unmatched
|
||||
}
|
||||
return prices, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var out []string
|
||||
if pattern == "*" {
|
||||
for _, s := range sources {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
want := normalizeURL(pattern)
|
||||
for _, s := range sources {
|
||||
if normalizeURL(s.BaseURL) == want {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeURL compares URLs the way an operator expects: trailing slashes and
|
||||
// case in the host are differences the provider's price list does not care
|
||||
// about, and requiring an exact byte match would make the config brittle.
|
||||
func normalizeURL(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
u = strings.TrimRight(u, "/")
|
||||
return strings.ToLower(u)
|
||||
}
|
||||
|
||||
// applyRule writes one rule's pricing for one source into the prices table.
|
||||
func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName string, sources []config.Source) error {
|
||||
switch rule.Mode {
|
||||
case "free":
|
||||
// Explicitly priced at zero. This is NOT the same as unpriced: a source
|
||||
// the operator says is free must not appear in the unpriced warnings,
|
||||
// or those warnings become noise and stop being read.
|
||||
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
||||
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
||||
}
|
||||
return nil
|
||||
|
||||
case "subscription":
|
||||
// A fixed monthly commitment: the per-request MARGINAL cost is zero, and
|
||||
// the flat fee is reported separately. Spreading a monthly fee across
|
||||
// requests would invent a per-request number the provider never charges,
|
||||
// and it would change every time traffic did.
|
||||
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
||||
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
||||
}
|
||||
fixed, _ := prices["subscriptions"].(map[string]interface{})
|
||||
if fixed == nil {
|
||||
fixed = map[string]interface{}{}
|
||||
prices["subscriptions"] = fixed
|
||||
}
|
||||
cur := "USD"
|
||||
if rule.Currency != "" {
|
||||
cur = rule.Currency
|
||||
}
|
||||
fixed[srcName] = map[string]interface{}{
|
||||
"monthly": rule.Subscription, "currency": cur,
|
||||
}
|
||||
return nil
|
||||
|
||||
case "unpriced":
|
||||
// Deliberately left out of `sources` so the plugin's unpriced_models /
|
||||
// unpriced_reqs counters catch it. That is the point: a subscription
|
||||
// plan whose credits cannot be converted to tokens must be VISIBLE as
|
||||
// unbilled, not quietly estimated.
|
||||
return nil
|
||||
|
||||
case "token":
|
||||
if len(rule.Models) == 0 {
|
||||
return fmt.Errorf("rule for url %q: mode token requires at least one model", rule.URL)
|
||||
}
|
||||
models := prices["models"].(map[string]interface{})
|
||||
for modelID, t := range rule.Models {
|
||||
entry := map[string]interface{}{}
|
||||
prompt, err := usdPerM(t.Prompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q prompt: %w", modelID, err)
|
||||
}
|
||||
completion, err := usdPerM(t.Completion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q completion: %w", modelID, err)
|
||||
}
|
||||
entry["prompt"] = prompt
|
||||
entry["completion"] = completion
|
||||
if t.CacheDiscount != nil {
|
||||
entry["cache_discount"] = *t.CacheDiscount
|
||||
}
|
||||
// reasoning is optional and stays ABSENT (not nil, not 0) when the
|
||||
// rule does not set it, which is what makes the billing plugin skip
|
||||
// the repricing branch entirely. Writing an explicit 0 here would
|
||||
// mean "thinking is free" and silently under-bill every request
|
||||
// from a provider that bills it like ordinary output.
|
||||
if strings.TrimSpace(t.Reasoning) != "" {
|
||||
rp, err := usdPerM(t.Reasoning)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q reasoning: %w", modelID, err)
|
||||
}
|
||||
entry["reasoning_price"] = rp
|
||||
}
|
||||
if lc := t.LongContext; lc != nil {
|
||||
if lc.Above <= 0 {
|
||||
return fmt.Errorf("model %q long_context: above must be > 0", modelID)
|
||||
}
|
||||
above, err := longContextRate(lc, prompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q long_context: %w", modelID, err)
|
||||
}
|
||||
entry["long_context"] = map[string]interface{}{
|
||||
"above": lc.Above,
|
||||
// Marginal, not blended. The plugin charges the tokens past the
|
||||
// threshold at this rate and the rest at the base rate, so the
|
||||
// threshold composes with a cache discount (which applies to the
|
||||
// cached portion) instead of forcing the plugin to guess how
|
||||
// the two interact.
|
||||
"prompt": above,
|
||||
}
|
||||
}
|
||||
if rule.Peak != nil {
|
||||
entry["peak"] = compilePeak(rule.Peak)
|
||||
}
|
||||
models[modelID] = entry
|
||||
}
|
||||
// A specific-URL token rule needs a source entry so a model served
|
||||
// from THIS url but absent from `rule.Models` is still recognised as
|
||||
// belonging to a priced provider.
|
||||
//
|
||||
// A WILDCARD rule ("*") must NOT get one. "*" is a model catalogue — it
|
||||
// says "these model ids have these prices, wherever they come from" —
|
||||
// and marking every source priced at {0,0} would make priceFor() set
|
||||
// priced=true for every model the catalogue does NOT list. Paid
|
||||
// sources (commandcode, alittokenplan, …) would then record $0 instead
|
||||
// of showing up in unpriced_reqs, which is precisely the silent
|
||||
// zero-billing failure this whole plugin is built to make visible.
|
||||
// The model lookup p.models[payload.model] already marks a listed
|
||||
// model priced on its own, so the wildcard loses nothing.
|
||||
if rule.URL != "*" {
|
||||
if _, ok := prices["sources"].(map[string]interface{})[srcName]; !ok {
|
||||
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
||||
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown mode %q", rule.Mode)
|
||||
}
|
||||
|
||||
// usdPerM converts a USD-per-million string into the per-token rate the plugin
|
||||
// expects. Delegates to config.ParseFloatUSDPerM so the DSL validator and the
|
||||
// compiler agree byte-for-byte on what a valid price is — a value that passes
|
||||
// Validate() but fails here (or vice versa) would be the worst kind of drift.
|
||||
func usdPerM(s string) (float64, error) {
|
||||
return config.ParseFloatUSDPerM(s)
|
||||
}
|
||||
|
||||
// compilePeak renders the peak window in the shape the plugin reads:
|
||||
// { multiplier, windows = { { days = {...}, hours = { {lo,hi}, ... } } } }.
|
||||
//
|
||||
// The plugin reads `days` / `hours` pairs. The first version of the deployed
|
||||
// price table used { start, end, weekdays } — a shape nothing reads — so peak
|
||||
// traffic was billed at off-peak rates with no error anywhere. Compiling from
|
||||
// typed config fields removes the chance of writing the wrong key names by hand.
|
||||
func compilePeak(p *config.BillingPeak) map[string]interface{} {
|
||||
win := map[string]interface{}{}
|
||||
if len(p.Weekdays) > 0 {
|
||||
days := make([]interface{}, 0, len(p.Weekdays))
|
||||
for _, d := range p.Weekdays {
|
||||
days = append(days, d)
|
||||
}
|
||||
win["days"] = days
|
||||
}
|
||||
if len(p.Hours) > 0 {
|
||||
hours := make([]interface{}, 0, len(p.Hours))
|
||||
for _, h := range p.Hours {
|
||||
hours = append(hours, []interface{}{h[0], h[1]})
|
||||
}
|
||||
win["hours"] = hours
|
||||
}
|
||||
out := map[string]interface{}{"multiplier": p.Multiplier}
|
||||
if len(win) > 0 {
|
||||
out["windows"] = []interface{}{win}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// longContextRate resolves the marginal per-token rate for the tokens ABOVE a
|
||||
// threshold, given the model's base per-million prompt price.
|
||||
//
|
||||
// Two ways to express it are supported because providers publish both: an
|
||||
// absolute rate (OpenAI quotes \$3.00/M past 200k) and a multiple (DeepSeek
|
||||
// quotes exactly 2x past 128k). Neither may be missing: a step with no rate is a
|
||||
// silent no-op, and an operator who wrote one meant something.
|
||||
func longContextRate(lc *config.BillingLongContext, basePerToken float64) (float64, error) {
|
||||
hasPrompt := strings.TrimSpace(lc.Prompt) != ""
|
||||
hasMult := lc.Multiplier > 0
|
||||
switch {
|
||||
case hasPrompt && hasMult:
|
||||
p, err := usdPerM(lc.Prompt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Both given: the absolute rate wins, because it is the one the provider
|
||||
// actually quotes. Silently multiplying instead would produce a number
|
||||
// the operator never wrote.
|
||||
return p, nil
|
||||
case hasPrompt:
|
||||
return usdPerM(lc.Prompt)
|
||||
case hasMult:
|
||||
return basePerToken * lc.Multiplier, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("needs either prompt or multiplier")
|
||||
}
|
||||
}
|
||||
214
internal/billing/wildcard_test.go
Normal file
214
internal/billing/wildcard_test.go
Normal file
@ -0,0 +1,214 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// The wildcard token rule must not blanket-mark every source as priced.
|
||||
//
|
||||
// Why this is a hazard and not a style question: priceFor() sets priced=true as
|
||||
// soon as the request's SOURCE has an entry in the price table, and that flag
|
||||
// is what keeps a request OUT of unpriced_reqs. A "*" rule is a model
|
||||
// catalogue — it prices specific model ids wherever they come from — but
|
||||
// compiling it into a {0,0,0} entry on all 21 sources made every model the
|
||||
// catalogue does not list look "priced at zero". Paid providers (commandcode,
|
||||
// alittokenplan, …) then stopped appearing in the unpriced warnings, which is
|
||||
// exactly the silent zero-billing failure the plugin exists to surface.
|
||||
func TestWildcardTokenRuleDoesNotMarkEverySourcePriced(t *testing.T) {
|
||||
sources := []config.Source{
|
||||
{Name: "free1", BaseURL: "https://free1.example.com/v1"},
|
||||
{Name: "paid1", BaseURL: "https://paid1.example.com/v1"},
|
||||
{Name: "paid2", BaseURL: "https://paid2.example.com/v1"},
|
||||
}
|
||||
profile := &config.BillingProfile{
|
||||
ID: "p",
|
||||
Rules: []config.BillingRule{
|
||||
{URL: "https://free1.example.com/v1", Mode: "free"},
|
||||
{
|
||||
URL: "*", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"only-catalogued-model": {Prompt: "1.00", Completion: "2.00"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
prices, err := Compile(profile, sources)
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
srcs := prices["sources"].(map[string]interface{})
|
||||
|
||||
// The explicitly free source keeps its entry (free is a deliberate 0).
|
||||
if _, ok := srcs["free1"]; !ok {
|
||||
t.Error("the explicit free rule lost its source entry")
|
||||
}
|
||||
// Paid sources must NOT be marked, or their unlisted models bill $0.
|
||||
for _, name := range []string{"paid1", "paid2"} {
|
||||
if _, ok := srcs[name]; ok {
|
||||
t.Errorf("wildcard token rule marked %q as priced at 0 — its unlisted "+
|
||||
"models will bill $0 instead of appearing as unpriced", name)
|
||||
}
|
||||
}
|
||||
// The catalogue itself must still be present, or the rule prices nothing.
|
||||
models := prices["models"].(map[string]interface{})
|
||||
if _, ok := models["only-catalogued-model"]; !ok {
|
||||
t.Error("the wildcard rule did not publish its model price")
|
||||
}
|
||||
}
|
||||
|
||||
// The inverse guard: a token rule for a SPECIFIC url still marks that source,
|
||||
// because it genuinely declares pricing for the models served from it.
|
||||
func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) {
|
||||
sources := []config.Source{
|
||||
{Name: "mine", BaseURL: "https://mine.example.com/v1"},
|
||||
{Name: "other", BaseURL: "https://other.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: "1.00", Completion: "2.00"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
prices, err := Compile(profile, sources)
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
srcs := prices["sources"].(map[string]interface{})
|
||||
if _, ok := srcs["mine"]; !ok {
|
||||
t.Error("a specific-url token rule must still mark its own source priced")
|
||||
}
|
||||
if _, ok := srcs["other"]; ok {
|
||||
t.Error("an unrelated source was marked priced by a specific-url rule")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReasoningPriceIsOmittedUnlessSet pins the absence convention for the
|
||||
// thinking-token rate.
|
||||
//
|
||||
// A rule that says nothing about reasoning must produce a price entry with NO
|
||||
// reasoning_price key, so the billing plugin skips the repricing branch and
|
||||
// bills thinking at the ordinary output rate — which is what every provider
|
||||
// actually charges. Writing an explicit 0 instead would read as "thinking is
|
||||
// free" and silently under-bill every request from that model.
|
||||
func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) {
|
||||
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
|
||||
|
||||
// Case 1: no reasoning declared → the key must not appear.
|
||||
p1 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://up.example/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00"},
|
||||
},
|
||||
}}}
|
||||
prices, err := Compile(p1, sources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||
if _, present := entry["reasoning_price"]; present {
|
||||
t.Errorf("reasoning_price present (%v) without a rule; the plugin would treat 0 as free",
|
||||
entry["reasoning_price"])
|
||||
}
|
||||
|
||||
// Case 2: declared → present, and converted to per-token like every other rate.
|
||||
p2 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://up.example/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "6.00"},
|
||||
},
|
||||
}}}
|
||||
prices2, err := Compile(p2, sources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e2 := prices2["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||
rp, ok := e2["reasoning_price"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("reasoning_price missing or wrong type: %#v", e2["reasoning_price"])
|
||||
}
|
||||
if rp != 6e-6 {
|
||||
t.Errorf("reasoning_price = %v, want 6e-6 (USD per TOKEN)", rp)
|
||||
}
|
||||
// A malformed rate must be rejected, not silently ignored: an operator who
|
||||
// typed "6 USD" expects either that value or an error, never a rule that
|
||||
// looks applied and bills nothing.
|
||||
p3 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://up.example/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "six dollars"},
|
||||
},
|
||||
}}}
|
||||
if _, err := Compile(p3, sources); err == nil {
|
||||
t.Error("a non-numeric reasoning rate must fail the compile")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongContextStepCompiles pins the long-context step's two accepted forms
|
||||
// and its rejection of a step that would silently do nothing.
|
||||
//
|
||||
// Providers publish both shapes — DeepSeek says "2x past 128k", OpenAI quotes
|
||||
// an absolute "$3.00/M past 200k" — so both must compile. Neither may be
|
||||
// missing: a step with no rate is a rule that LOOKS like it handles long
|
||||
// context and does not, which under-bills without any visible sign.
|
||||
func TestLongContextStepCompiles(t *testing.T) {
|
||||
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
|
||||
|
||||
compile := func(lc config.BillingLongContext) (map[string]interface{}, error) {
|
||||
p := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://up.example/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00", LongContext: &lc},
|
||||
},
|
||||
}}}
|
||||
prices, err := Compile(p, sources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||
return e["long_context"].(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
// Absolute rate (OpenAI's published shape).
|
||||
lc1, err := compile(config.BillingLongContext{Above: 200000, Prompt: "3.00"})
|
||||
if err != nil {
|
||||
t.Fatalf("absolute long_context rate rejected: %v", err)
|
||||
}
|
||||
if lc1["above"] != 200000 || lc1["prompt"] != 3e-6 {
|
||||
t.Errorf("absolute step = %#v, want above=200000 prompt=3e-6", lc1)
|
||||
}
|
||||
|
||||
// Multiplier (DeepSeek's shape): 2x of the 1.00/M base.
|
||||
lc2, err := compile(config.BillingLongContext{Above: 128000, Multiplier: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("multiplier long_context rejected: %v", err)
|
||||
}
|
||||
if lc2["prompt"] != 2e-6 {
|
||||
t.Errorf("multiplier step = %#v, want prompt=2e-6", lc2)
|
||||
}
|
||||
|
||||
// Both given: the absolute rate wins, because that is the number the
|
||||
// provider quotes. Multiplying instead would produce a figure the operator
|
||||
// never wrote.
|
||||
lc3, err := compile(config.BillingLongContext{Above: 128000, Prompt: "5.00", Multiplier: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lc3["prompt"] != 5e-6 {
|
||||
t.Errorf("both given = %#v, want the quoted absolute rate 5e-6", lc3)
|
||||
}
|
||||
|
||||
// Neither: a no-op step must fail, not compile into a rule that silently
|
||||
// bills long context at the base rate.
|
||||
if _, err := compile(config.BillingLongContext{Above: 128000}); err == nil {
|
||||
t.Error("a step with neither prompt nor multiplier must be rejected")
|
||||
}
|
||||
// A zero threshold would make every request "long".
|
||||
if _, err := compile(config.BillingLongContext{Above: 0, Multiplier: 2}); err == nil {
|
||||
t.Error("above=0 must be rejected")
|
||||
}
|
||||
}
|
||||
@ -5,14 +5,77 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// SourceNameAllowedChars 是源名允许的字符集:仅 [A-Za-z0-9_-]。
|
||||
//
|
||||
// 为何必须收窄(本仓真的踩过):模型寻址支持 `源名:模型名` /
|
||||
// `源名/模型名` / `源名-模型名` 三种限定写法(WebUI 下拉框用 `源名:模型名`
|
||||
// 作 option value,因为 44 个模型被多个源同时提供,必须能指定从哪个源调)。
|
||||
// 剥离源名的那一步是 `internal/provider/splitPinned`:它先做**最长源名前缀
|
||||
// 匹配**,再要求源名后紧跟一个分隔符。
|
||||
//
|
||||
// ⇒ 若源名**自己含** `:` 或 `/`,那个字符就同时是「源名的一部分」和「分隔符」,
|
||||
// 限定名就成了歧义串:
|
||||
//
|
||||
// src = "a:b" model = "m" → "a:b:m" 可以被切成 ("a", "b:m") 或 ("a:b", "m")
|
||||
// src = "a/b" model = "m" → "a/b/m" 同上
|
||||
//
|
||||
// 两种切法都能自圆其说,结果取决于**当前恰好有哪些源名** —— 也就是同一个
|
||||
// 客户端请求串在不同部署/不同启动顺序下可能落到不同源。更坑的是它不会报错:
|
||||
// splitPinned 会命中某一个分支然后路由到错的地方(历史缺陷就是这类:
|
||||
// `IndexAny(model, "-:/")` 按第一个分隔符切,导致 channels-trae-xxx:model
|
||||
// 被切成 src="channels" ⇒ 永不匹配 ⇒ `model ... is not configured`)。
|
||||
//
|
||||
// 因此把约束放在**写入口**,而不是每次解析时猜:含 `:` 或 `/` 的源名一律拒。
|
||||
// 注意 `-` 是**允许**的:本仓的渠道源正是 `channels-<平台>-<账号id>`,
|
||||
// 分隔符在源名之后,不产生歧义。
|
||||
const SourceNameAllowedPattern = `[A-Za-z0-9_-]`
|
||||
|
||||
// ValidateSourceName reports why name is not a legal source name, or nil.
|
||||
// Callers turn this into a startup error (config) / API 400 (add_source).
|
||||
func ValidateSourceName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("source name is empty")
|
||||
}
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '-':
|
||||
// allowed
|
||||
case r == ':' || r == '/':
|
||||
// 单独点名:这两个字符是限定名的**分隔符**,出现在源名里必然歧义。
|
||||
return fmt.Errorf(
|
||||
"source name %q contains %q, which is a reserved pinning separator; "+
|
||||
"%q is ambiguous (source names allow only %s)",
|
||||
name, string(r), name, SourceNameAllowedPattern)
|
||||
default:
|
||||
return fmt.Errorf("source name %q contains illegal character %q (allowed: %s)",
|
||||
name, string(r), SourceNameAllowedPattern)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SourceNameWarning is the soft form of ValidateSourceName: it returns a
|
||||
// human-readable warning for names that BREAK the contract but existed before
|
||||
// the contract did. Startup logs it instead of failing, so an operator's
|
||||
// already-working config is not bricked by an upgrade; new sources are refused
|
||||
// outright (see core.normalizeSource).
|
||||
func SourceNameWarning(name string) string {
|
||||
if err := ValidateSourceName(name); err != nil {
|
||||
return err.Error() + " — `source:model` pinning may resolve to the wrong source"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Config is the top-level gateway configuration.
|
||||
type Config struct {
|
||||
Path string `yaml:"-" json:"-"`
|
||||
@ -29,6 +92,15 @@ type Config struct {
|
||||
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
|
||||
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
|
||||
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
|
||||
// BillingDSL declares per-URL pricing profiles for the billing plugin.
|
||||
//
|
||||
// Declarative, because hand-writing the prices JSON was error-prone in ways
|
||||
// that looked successful: the first PUT went out without the required
|
||||
// `prices` wrapper and silently REPLACED the accumulated totals with the
|
||||
// price table; the second used a peak-window shape the plugin does not read,
|
||||
// so peak traffic was billed at off-peak rates — no error, the bill just
|
||||
// quietly halved. A schema-checked config file catches both at load.
|
||||
BillingDSL *BillingDSL `yaml:"billing,omitempty"`
|
||||
// PluginVisibility overrides the audience a plugin declared for itself,
|
||||
// keyed by plugin name: "admin" hides a plugin (and its state) from user
|
||||
// keys, "all" widens one that shipped admin-only, "user,admin" names roles
|
||||
@ -52,18 +124,13 @@ const (
|
||||
DefaultSourceConcurrency = 8
|
||||
)
|
||||
|
||||
// Model is a single exposed model id bound to a source.
|
||||
//
|
||||
// ★ 曾经有 `Priority int` 字段,并号称"高者优先参与 AUTO 选源"。**那是错的**:
|
||||
//
|
||||
// AUTO 链由 `config.ModelScope.Tier` 驱动(见 scheduler.Chain),而 priority
|
||||
// 只被 `bestChatModel/bestImageModel` 用于"本源码内部该发哪个模型",
|
||||
// 与跨源调度毫无关系。把它当成调度依据会让人误以为改个数字就能改顺序,
|
||||
// 改完发现毫无效果 —— 已彻底删除,避免再次误导。
|
||||
// Model is a single exposed model id bound to a source, with priority used by
|
||||
// AUTO auto selection (higher number = preferred).
|
||||
type Model struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Kind string `yaml:"kind" json:"kind"` // "chat" (default) | "image"
|
||||
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// Source describes a single upstream LLM provider.
|
||||
@ -85,14 +152,7 @@ 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"`
|
||||
// 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"`
|
||||
ProxyURL string `yaml:"proxy_url" json:"proxy_url,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"`
|
||||
@ -102,63 +162,6 @@ 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`.
|
||||
@ -389,6 +392,14 @@ func (c *Config) ApplyDefaults() error {
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("config: sources[%d] missing name", i)
|
||||
}
|
||||
// ★ 契约(见 ValidateSourceName):源名只允许 [A-Za-z0-9_-],
|
||||
// 含 `:` 或 `/` 会让 `源名:模型名` 变歧义。
|
||||
// 这里**只告警不当场报错**:存量配置可能已经有这种名字,升级直接
|
||||
// 启动失败会让整个网关不可用(比歧义更坏)。新增源由
|
||||
// core.normalizeSource 硬拒 —— 契约从新数据开始强制。
|
||||
if warn := SourceNameWarning(s.Name); warn != "" {
|
||||
log.Printf("[config] %s", warn)
|
||||
}
|
||||
if s.BaseURL == "" {
|
||||
return fmt.Errorf("config: source %s missing base_url", s.Name)
|
||||
}
|
||||
@ -477,6 +488,182 @@ type GWKey struct {
|
||||
// chain" so a user can clear an override back to the global default.
|
||||
func (k GWKey) HasOwnAuto() bool { return len(k.Auto) > 0 }
|
||||
|
||||
// BillingDSL holds declarative per-URL pricing profiles for the billing plugin.
|
||||
//
|
||||
// Profiles are the "let the user pick" axis: the same upstream URL can be
|
||||
// declared under multiple profiles, and the operator chooses which is active.
|
||||
// Switching recomputes and re-injects the billing plugin's prices table.
|
||||
type BillingDSL struct {
|
||||
// Active is the id of the profile in effect. Empty => the first profile.
|
||||
Active string `yaml:"active,omitempty" json:"active,omitempty"`
|
||||
Profiles []BillingProfile `yaml:"profiles,omitempty" json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
// BillingProfile is one named set of per-URL pricing rules.
|
||||
type BillingProfile struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Label string `yaml:"label,omitempty" json:"label,omitempty"`
|
||||
// Default applies when no rule matches: "free" or "unpriced".
|
||||
Default string `yaml:"default,omitempty" json:"default,omitempty"`
|
||||
Rules []BillingRule `yaml:"rules,omitempty" json:"rules,omitempty"`
|
||||
}
|
||||
|
||||
// BillingRule declares the pricing for everything under one URL.
|
||||
type BillingRule struct {
|
||||
// URL matches a source's base_url. "*" = any unmatched URL.
|
||||
URL string `yaml:"url" json:"url"`
|
||||
// Mode is one of: free, token, subscription, unpriced.
|
||||
Mode string `yaml:"mode" json:"mode"`
|
||||
Currency string `yaml:"currency,omitempty" json:"currency,omitempty"`
|
||||
// Subscription is the flat monthly cost (for mode=subscription).
|
||||
Subscription float64 `yaml:"subscription,omitempty" json:"subscription,omitempty"`
|
||||
// Models declares per-token prices when mode=token.
|
||||
Models map[string]BillingToken `yaml:"models,omitempty" json:"models,omitempty"`
|
||||
// Free is true when mode=free (kept for explicitness).
|
||||
// Peak applies to ALL token-priced requests under this URL.
|
||||
Peak *BillingPeak `yaml:"peak,omitempty" json:"peak,omitempty"`
|
||||
}
|
||||
|
||||
// BillingToken is the per-model price.
|
||||
type BillingToken struct {
|
||||
Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15"
|
||||
Completion string `yaml:"completion" json:"completion"` // USD per million tokens
|
||||
CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"`
|
||||
// Reasoning is the USD-per-million rate for thinking tokens, for the rare
|
||||
// provider that charges them differently from ordinary output. Omit it and
|
||||
// reasoning is billed at Completion — which is what OpenAI, DeepSeek and
|
||||
// Anthropic all do, and what every source on this deployment reports.
|
||||
//
|
||||
// It is a rate for a SUBSET of completion tokens, never an extra charge: the
|
||||
// billing plugin applies only the difference between this and Completion, so
|
||||
// setting it to a multiple of Completion reprices the thinking share rather
|
||||
// than billing it twice.
|
||||
Reasoning string `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
||||
// LongContext is the prompt-price step above a token threshold. Nil = one
|
||||
// flat rate, which is what a rule means when it says nothing.
|
||||
LongContext *BillingLongContext `yaml:"long_context,omitempty" json:"long_context,omitempty"`
|
||||
}
|
||||
|
||||
// BillingPeak declares peak-hour pricing.
|
||||
type BillingPeak struct {
|
||||
Multiplier float64 `yaml:"multiplier" json:"multiplier"`
|
||||
// Weekdays: 1=Mon..5=Fri (Lua os.date !%w, 0=Sun)
|
||||
Weekdays []int `yaml:"weekdays,omitempty" json:"weekdays,omitempty"`
|
||||
// Hours: each pair is [lo,hi] inclusive
|
||||
Hours [][2]int `yaml:"hours,omitempty" json:"hours,omitempty"`
|
||||
}
|
||||
|
||||
// BillingLongContext is a prompt-price step for long inputs.
|
||||
//
|
||||
// Every major provider charges the input tokens ABOVE a threshold at a higher
|
||||
// rate — DeepSeek doubles past 128k, OpenAI and Anthropic step up as well. A
|
||||
// flat prompt price therefore under-bills exactly the traffic an agent workload
|
||||
// generates, and it under-bills it silently: the number is plausible, the bill
|
||||
// adds up, and nothing says the rate was wrong.
|
||||
//
|
||||
// The step applies to the PROMPT only. Output and cache reads are billed at
|
||||
// their own rates regardless of input length, which is how providers publish
|
||||
// it — the higher rate is for the extra context you made the model read, not for
|
||||
// what it wrote back.
|
||||
type BillingLongContext struct {
|
||||
// Above is the token count the step starts at. A request with exactly this
|
||||
// many prompt tokens is still at the base rate; the step covers the tokens
|
||||
// BEYOND it. Inclusive-below matches how the providers document it.
|
||||
Above int `yaml:"above" json:"above"`
|
||||
// Prompt is the USD-per-million rate for the tokens above Above.
|
||||
Prompt string `yaml:"prompt" json:"prompt"`
|
||||
// Multiplier is an alternative to Prompt: a factor applied to the base rate
|
||||
// (DeepSeek's published step is exactly 2x). When both are set Prompt wins,
|
||||
// and setting neither is a validation error rather than a silent no-op.
|
||||
Multiplier float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
|
||||
}
|
||||
|
||||
// Validate rejects a BillingDSL that could not work as written.
|
||||
func (b *BillingDSL) Validate() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for i := range b.Profiles {
|
||||
p := &b.Profiles[i]
|
||||
if p.ID == "" {
|
||||
return fmt.Errorf("billing profile [%d] has no id", i)
|
||||
}
|
||||
if ids[p.ID] {
|
||||
return fmt.Errorf("billing profile id %q appears more than once", p.ID)
|
||||
}
|
||||
ids[p.ID] = true
|
||||
if p.Default != "" && p.Default != "free" && p.Default != "unpriced" {
|
||||
return fmt.Errorf("profile %q: default must be free or unpriced, got %q", p.ID, p.Default)
|
||||
}
|
||||
for j := range p.Rules {
|
||||
r := &p.Rules[j]
|
||||
if r.URL == "" {
|
||||
return fmt.Errorf("profile %q rule [%d]: url is required", p.ID, j)
|
||||
}
|
||||
switch r.Mode {
|
||||
case "free", "token", "subscription", "unpriced":
|
||||
default:
|
||||
return fmt.Errorf("profile %q rule [%d]: mode must be free/token/subscription/unpriced, got %q", p.ID, j, r.Mode)
|
||||
}
|
||||
if r.Mode == "token" {
|
||||
for m, t := range r.Models {
|
||||
if t.Prompt == "" || t.Completion == "" {
|
||||
return fmt.Errorf("profile %q model %q: prompt and completion are required for token mode", p.ID, m)
|
||||
}
|
||||
if _, err := ParseFloatUSDPerM(t.Prompt); err != nil {
|
||||
return fmt.Errorf("profile %q model %q prompt: %w", p.ID, m, err)
|
||||
}
|
||||
if _, err := ParseFloatUSDPerM(t.Completion); err != nil {
|
||||
return fmt.Errorf("profile %q model %q completion: %w", p.ID, m, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Active must resolve to a profile id.
|
||||
if b.Active != "" && !ids[b.Active] {
|
||||
return fmt.Errorf("billing.active = %q but no profile has that id", b.Active)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve returns the profile with the given id; empty id (or an id that does
|
||||
// not exist) falls back to the FIRST profile. A config with profiles but no
|
||||
// active marker should mean "use the one I wrote first" rather than "price
|
||||
// nothing", which is what a nil here would silently do.
|
||||
func (b *BillingDSL) Resolve(id string) *BillingProfile {
|
||||
if b == nil || len(b.Profiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
if id != "" {
|
||||
for i := range b.Profiles {
|
||||
if b.Profiles[i].ID == id {
|
||||
return &b.Profiles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return &b.Profiles[0]
|
||||
}
|
||||
|
||||
// parseFloatUSDPerM parses a USD-per-million price string. Returns the
|
||||
// per-single-token rate the plugin expects (price / 1e6).
|
||||
//
|
||||
// Kept as a string in the DSL rather than a float: "0.15" survives round-trips
|
||||
// through YAML editors, and a malformed value must fail VALIDATION (with the
|
||||
// file and field named) rather than parse as 0 and under-bill silently — which
|
||||
// is exactly the failure mode that motivated the DSL.
|
||||
func ParseFloatUSDPerM(s string) (float64, error) {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("not a number: %q", s)
|
||||
}
|
||||
if f < 0 {
|
||||
return 0, fmt.Errorf("negative price: %q", s)
|
||||
}
|
||||
return f / 1e6, nil
|
||||
}
|
||||
|
||||
// KeyQuota is retained only to carry a scope entry's caps through the admin
|
||||
// API. Quotas are per model, never per key: there is deliberately no key-wide
|
||||
// total, so exhausting one model's budget never blocks the others.
|
||||
|
||||
@ -52,6 +52,9 @@ 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", Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Kind: "chat"},
|
||||
{ID: "deepseek-v4-flash-vision-exp", Kind: "chat"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -25,10 +25,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -37,10 +37,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -49,11 +49,11 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -62,9 +62,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 4,
|
||||
Models: []Model{
|
||||
{ID: "4.0Ultra", Kind: "chat"},
|
||||
{ID: "generalv3.5", Kind: "chat"},
|
||||
{ID: "max-32k", Kind: "chat"},
|
||||
{ID: "4.0Ultra", Priority: 100, Kind: "chat"},
|
||||
{ID: "generalv3.5", Priority: 80, Kind: "chat"},
|
||||
{ID: "max-32k", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -73,11 +73,11 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -86,9 +86,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "ernie-5.0-turbo", Kind: "chat"},
|
||||
{ID: "ernie-5.0", Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Kind: "chat"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -97,9 +97,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "doubao-seed-2.0-pro", Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0", Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0-flash", Kind: "chat"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -108,8 +108,8 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "MiniMax-M3", Kind: "chat"},
|
||||
{ID: "MiniMax-M2.5", Kind: "chat"},
|
||||
{ID: "MiniMax-M3", Priority: 100, Kind: "chat"},
|
||||
{ID: "MiniMax-M2.5", Priority: 80, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
// ── 国际主流 ────────────────────────────────────────────────────────────
|
||||
@ -119,9 +119,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "gpt-5.6-sol", Kind: "chat"},
|
||||
{ID: "gpt-5.6-terra", Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Kind: "chat"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -130,10 +130,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "anthropic",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -142,10 +142,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "gemini",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -154,9 +154,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "groq",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "openai/gpt-oss-120b", Kind: "chat"},
|
||||
{ID: "openai/gpt-oss-20b", Kind: "chat"},
|
||||
{ID: "groq/compound", Kind: "chat"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -165,9 +165,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "mistral",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "mistral-medium-latest", Kind: "chat"},
|
||||
{ID: "mistral-large-latest", Kind: "chat"},
|
||||
{ID: "codestral-latest", Kind: "chat"},
|
||||
{ID: "mistral-medium-latest", Priority: 100, Kind: "chat"},
|
||||
{ID: "mistral-large-latest", Priority: 90, Kind: "chat"},
|
||||
{ID: "codestral-latest", Priority: 85, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -180,10 +180,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
"X-Title": "ModelRouter",
|
||||
},
|
||||
Models: []Model{
|
||||
{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"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -192,8 +192,8 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "github",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "gpt-5.6-sol", Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Kind: "chat"},
|
||||
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
// ── 本地模型 ────────────────────────────────────────────────────────────
|
||||
@ -204,9 +204,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Endpoint: "/api/chat",
|
||||
MaxConcurrent: 4,
|
||||
Models: []Model{
|
||||
{ID: "qwen3", Kind: "chat"},
|
||||
{ID: "deepseek-r1", Kind: "chat"},
|
||||
{ID: "llama3.3", Kind: "chat"},
|
||||
{ID: "qwen3", Priority: 80, Kind: "chat"},
|
||||
{ID: "deepseek-r1", Priority: 80, Kind: "chat"},
|
||||
{ID: "llama3.3", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
107
internal/config/source_name_contract_test.go
Normal file
107
internal/config/source_name_contract_test.go
Normal file
@ -0,0 +1,107 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ P2:源名允许字符集契约 —— 仅 `[A-Za-z0-9_-]`,**不得含 `:` 与 `/`**。
|
||||
//
|
||||
// 为何必须收窄(原因写在这里,因为 docs 会被改、判据不会):
|
||||
//
|
||||
// 模型寻址支持 `源名:模型名` / `源名/模型名` / `源名-模型名` 三种限定写法
|
||||
// (WebUI 下拉框用 `源名:模型名` 作 option value:本实例 44 个模型被多个源
|
||||
// 同时提供,必须能指定「从哪个源调」)。剥离源名的那一步是
|
||||
// `internal/provider/splitPinned`:先做**最长源名前缀匹配**,再要求源名后
|
||||
// 紧跟一个分隔符。
|
||||
//
|
||||
// ⇒ 若源名**自己含** `:` 或 `/`,那个字符同时是「源名的一部分」和「分隔符」,
|
||||
// 限定名就成了歧义串:
|
||||
//
|
||||
// src="a:b" model="m" → "a:b:m" → ("a", "b:m") 还是 ("a:b", "m")?
|
||||
// src="a/b" model="m" → "a/b/m" → ("a", "b/m") 还是 ("a/b", "m")?
|
||||
//
|
||||
// 两种切法都自圆其说,结果取决于**当前恰好有哪些源名** —— 同一客户端请求串
|
||||
// 在不同部署/不同启动顺序下可能落到不同源,而且**不会报错**(splitPinned
|
||||
// 会命中某一个分支然后路由到别处)。历史缺陷正是这一族:`IndexAny(model,
|
||||
// "-:/")` 按第一个分隔符切,把 `channels-trae-xxx:model` 切成 src="channels"
|
||||
// ⇒ 永不匹配 ⇒ `model ... is not configured`。
|
||||
//
|
||||
// 注意 `-` 是**允许**的:本仓渠道源就是 `channels-<平台>-<账号id>`,
|
||||
// 分隔符在源名之后,不产生歧义。
|
||||
func TestValidateSourceNameAllowsContractCharset(t *testing.T) {
|
||||
allowed := []string{
|
||||
"channels-trae-b8dd6b3df30e", // 真实渠道源形态(含两个 -)
|
||||
"channels-buddy-buddy-main",
|
||||
"DeepSeek",
|
||||
"my_source_1",
|
||||
"a",
|
||||
"-leading-dash",
|
||||
"trailing-dash-",
|
||||
"123456",
|
||||
}
|
||||
for _, name := range allowed {
|
||||
if err := ValidateSourceName(name); err != nil {
|
||||
t.Errorf("★ 合法源名 %q 被拒: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 反例:`:` 与 `/` 必须**被点名拒绝**(而不是泛泛地说"非法字符")——
|
||||
// 运维看到错误信息才知道这是限定名分隔符冲突,不是手滑。
|
||||
func TestValidateSourceNameRejectsPinningSeparators(t *testing.T) {
|
||||
cases := []string{
|
||||
"a:b",
|
||||
"vendor:model",
|
||||
"a/b",
|
||||
"openai/gpt",
|
||||
"channels-trae-b8dd6b3df30e:glm-5.2", // 把"限定名"当源名写进来
|
||||
"src/sub:name",
|
||||
}
|
||||
for _, name := range cases {
|
||||
err := ValidateSourceName(name)
|
||||
if err == nil {
|
||||
t.Fatalf("★ 源名 %q 含限定名分隔符,必须被拒(否则 `源名:模型名` 歧义)", name)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "pinning separator") {
|
||||
t.Errorf("错误信息应点名「pinning separator」,实得: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, name) {
|
||||
t.Errorf("错误信息应回显源名 %q,实得: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 其它非法字符同样拒(防止 `源 名`、`源名\n` 这种把日志/URL 搞乱的写法)。
|
||||
func TestValidateSourceNameRejectsOtherIllegalChars(t *testing.T) {
|
||||
for _, name := range []string{"has space", "tab\there", "new\nline", "中文名", "dot.name", "plus+name", "@at"} {
|
||||
if err := ValidateSourceName(name); err == nil {
|
||||
t.Errorf("★ 源名 %q 含非法字符,必须被拒(允许集只有 %s)", name, SourceNameAllowedPattern)
|
||||
}
|
||||
}
|
||||
if err := ValidateSourceName(""); err == nil {
|
||||
t.Error("空源名必须被拒")
|
||||
}
|
||||
}
|
||||
|
||||
// 存量兼容:启动时**只告警不报错**。
|
||||
//
|
||||
// 为什么不能直接报错:存量配置里可能已经有带 `:` 的源名,升级瞬间让网关
|
||||
// 起不来(比"限定名歧义"更坏 —— 整个服务不可用)。契约从**新数据**开始强制
|
||||
// (core.normalizeSource 硬拒),老数据给一条可读告警。
|
||||
func TestSourceNameWarningIsSoftForLegacyConfig(t *testing.T) {
|
||||
cfg := Config{Sources: []Source{
|
||||
// 存量形态:带 `:` —— 必须能启动(不返回 error),但要被点名告警
|
||||
{Name: "legacy:v1", BaseURL: "http://x", Models: []Model{{ID: "m1"}}},
|
||||
}}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatalf("★ 存量配置带 `:` 不得导致启动失败(会把整个网关 brick 掉): %v", err)
|
||||
}
|
||||
if w := SourceNameWarning("legacy:v1"); w == "" {
|
||||
t.Fatal("★ 存量非法名必须给出告警文案(否则问题彻底静默)")
|
||||
}
|
||||
if w := SourceNameWarning("channels-trae-abc"); w != "" {
|
||||
t.Fatalf("合法源名不该产生告警,实得 %q", w)
|
||||
}
|
||||
}
|
||||
68
internal/core/adapter_install_probe_test.go
Normal file
68
internal/core/adapter_install_probe_test.go
Normal file
@ -0,0 +1,68 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 适配器**真实装载**必须可观测:`__adapter_install_probe` 要报告全部 6 个
|
||||
// 自装适配器,且每个都是 ok。
|
||||
//
|
||||
// 为什么这条判据值得存在(plan §3 第 1 条):
|
||||
//
|
||||
// `luac -p` 对 Lua 长字符串里的适配器代码**无效**(它只看外层插件脚本),
|
||||
// 会给出假绿。真实故障态是「插件加载成功、适配器却没装进去」—— 此时每次
|
||||
// 调用内核才响亮报 `missing transform_response`,而原因指向内核不像配置。
|
||||
//
|
||||
// 另一层:cline / qoder 此前**不在探针输出里**,于是「装了但没生效」在这两个
|
||||
// 适配器上完全不可观测 —— 本轮修 cline 的 `workos:` 前缀时正是因此先卡了一下。
|
||||
// 装上就该看得见。
|
||||
func TestAdapterInstallProbeReportsAllSelfInstalled(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)
|
||||
}
|
||||
|
||||
res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("__adapter_install_probe: %v", err)
|
||||
}
|
||||
// 探针的诊断价值就在于它能在 CI 里跑通(zcode 的基座在 core 测试台里
|
||||
// 是可得 —— pluginDir 与 adaptersDir 同一套配置)。
|
||||
top, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针返回值不是对象: %#v", res)
|
||||
}
|
||||
j, ok := top["json"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针返回值缺 json 层: %#v", top)
|
||||
}
|
||||
|
||||
// 6 个自装适配器:缺任何一个都是"插件装了、适配器没装"的故障态。
|
||||
want := []string{"codearts", "trae", "gemini", "zcode", "cline", "qoder"}
|
||||
for _, name := range want {
|
||||
entry, ok := j[name].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("★ 探针未报告 %s 适配器的装载结果(装上就该看得见;"+
|
||||
"漏报等于问题不可观测): %v", name, j)
|
||||
}
|
||||
if entry["ok"] != true {
|
||||
t.Fatalf("★ %s 适配器装载失败: %v\n"+
|
||||
" luac -p 对长字符串里的适配器代码**无效**(plan §3 第 1 条),"+
|
||||
"必须以本探针为准", name, entry["err"])
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 6/6 自装适配器全部装载成功(probe: %v)", keysOfProbe(j))
|
||||
}
|
||||
|
||||
func keysOfProbe(m map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -151,9 +151,7 @@ func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
|
||||
}
|
||||
aid, _ := j["account_id"].(string)
|
||||
srcs := listSourcesViaKernel(t, c)
|
||||
// ★ 一个通道 = 一个源:源名是 `channels-codearts`,**不含账号 id**。
|
||||
// 按账号 id 找会永远失败 —— 这正是"加账号了但测试说没出源"的假故障。
|
||||
sm, ok := srcHasAccount(srcs, "codearts")
|
||||
sm, ok := srcHasAccount(srcs, aid)
|
||||
if !ok {
|
||||
names := []string{}
|
||||
for _, x := range srcs {
|
||||
@ -161,10 +159,7 @@ func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
t.Fatalf("codearts 账号 %q 应已同步出通道源;现有源: %v", aid, names)
|
||||
}
|
||||
if nm, _ := sm["name"].(string); nm != "channels-codearts" {
|
||||
t.Errorf("通道源名应为 channels-codearts,实得 %q(账号 id=%s)", nm, aid)
|
||||
t.Fatalf("codearts 账号 %q 应已同步源;现有源: %v", aid, names)
|
||||
}
|
||||
if ad, _ := sm["adapter"].(string); ad != "codearts" {
|
||||
t.Errorf("源应引用插件装的 codearts 适配器,实得 %q(签名不会生效)", ad)
|
||||
|
||||
@ -1,184 +0,0 @@
|
||||
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,9 +37,6 @@ 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,
|
||||
"字段名后面还有内容(初版误判下架,实测教训)"},
|
||||
@ -68,16 +65,6 @@ 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",
|
||||
|
||||
@ -1,149 +0,0 @@
|
||||
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,12 +36,10 @@ func TestChannelSourcesAreSerialWithThrottle(t *testing.T) {
|
||||
aid, _ := j["account_id"].(string)
|
||||
|
||||
srcs := listSourcesViaKernel(t, c)
|
||||
// 一个通道 = 一个源:源名不含账号 id,按**本用例实际用的平台**找。
|
||||
sm, ok := srcHasAccount(srcs, "buddy")
|
||||
sm, ok := srcHasAccount(srcs, aid)
|
||||
if !ok {
|
||||
t.Fatalf("账号未出通道源,实际源: %v", srcs)
|
||||
t.Fatal("账号未出源")
|
||||
}
|
||||
_ = aid
|
||||
// ① 串行
|
||||
if mc, _ := toFloat(sm["max_concurrent"]); mc != 1 {
|
||||
t.Errorf("源必须 max_concurrent=1(并发发起必然浪费一个配额),实得 %v", sm["max_concurrent"])
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
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 的源(前缀匹配过宽)")
|
||||
}
|
||||
}
|
||||
@ -79,17 +79,9 @@ func TestChannelsPeriodicTaskLogsAreDowngraded(t *testing.T) {
|
||||
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)")
|
||||
// 「已同步源」是周期重同步的常规回音 —— 降级为 task
|
||||
if !strings.Contains(s, `logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)", "task", provider)`) {
|
||||
t.Error("「已同步源」是周期重同步的常规回音,必须 task 级且带平台归属")
|
||||
}
|
||||
t.Log("✓ 周期噪音已降级,异常保持 warn")
|
||||
}
|
||||
|
||||
@ -45,31 +45,24 @@ func TestChannelsReapOrphanSources(t *testing.T) {
|
||||
// (账号已删、撤源被拒 ⇒ 源留下继续对外服务)。
|
||||
// 不走 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"},
|
||||
if r, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "deadbeef1234", "credential": "test-cred",
|
||||
}); err != nil {
|
||||
t.Fatalf("注册遗留源(1): %v", err)
|
||||
t.Fatalf("account_add: %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
t.Fatalf("注册遗留源(1) 报错: %v", j["error"])
|
||||
t.Fatalf("account_add 报错: %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"},
|
||||
if r, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "dead-acct-1", "credential": "test-cred",
|
||||
}); err != nil {
|
||||
t.Fatalf("注册遗留源(2): %v", err)
|
||||
t.Fatalf("account_add(2): %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
t.Fatalf("注册遗留源(2) 报错: %v", j["error"])
|
||||
t.Fatalf("account_add(2) 报错: %v", j["error"])
|
||||
}
|
||||
// 运维手工源:名字与账号源形态无关 ⇒ 绝不碰
|
||||
if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
|
||||
@ -82,7 +75,14 @@ func TestChannelsReapOrphanSources(t *testing.T) {
|
||||
// Go 侧 err 仍是 nil ⇒ 只查 err 会让"源根本没注册"静默通过。
|
||||
t.Fatalf("注册手工源报错: %v", j["error"])
|
||||
}
|
||||
// 遗留源对应的账号本来就不存在(这正是"孤儿"的定义),无需再摘。
|
||||
// 摘账号(保留源)
|
||||
for _, id := range []string{"deadbeef1234", "dead-acct-1"} {
|
||||
if _, err := ps.Call("channels", "__forget_account_probe", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
}); err != nil {
|
||||
t.Fatalf("摘账号 %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ① dry_run(默认)只报告、不删
|
||||
res, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{})
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
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,17 +181,9 @@ 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 真实形态的余额响应:
|
||||
@ -215,10 +207,6 @@ 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,17 +120,9 @@ 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)
|
||||
// ★ 一个通道 = 一个源:「加账号」同步出的是**通道级**源 `channels-<platform>`,
|
||||
// 源名里**不含**账号 id(这正是本次重构的目的 —— 换号对客户端透明,
|
||||
// 用户书签里的 `源:模型` 不会因为换号而失效)。
|
||||
// 故这里按平台名找源,不按 aid 找。
|
||||
sm, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax")
|
||||
sm, ok := srcHasAccount(listSourcesViaKernel(t, c), aid)
|
||||
if !ok {
|
||||
t.Fatalf("带凭据账号应已同步出通道源,实际源: %v", listSourcesViaKernel(t, c))
|
||||
}
|
||||
// 而且源名必须是通道级那个(旧版会带 -<12hex> 后缀)
|
||||
if nm, _ := sm["name"].(string); nm != "channels-minimax" {
|
||||
t.Fatalf("通道源名应为 channels-minimax,实得 %q(账号 id=%s)", nm, aid)
|
||||
t.Fatal("带凭据账号应已同步源")
|
||||
}
|
||||
// 不写死具体数量:兜底目录是照参考 *-product.ts 补的,会随对齐
|
||||
// 进度变化(minimax 已从 3 项补到 4 项)。这里断言的是**关系**:
|
||||
@ -141,8 +133,7 @@ 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}`)
|
||||
// 同样按平台名找(源名不含账号 id)
|
||||
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), "minimax")
|
||||
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid)
|
||||
if n := len(asArr(sm["models"])); n != total-1 {
|
||||
t.Fatalf("关1模型后源应 %d 模型, 实得 %d", total-1, n)
|
||||
}
|
||||
@ -164,43 +155,14 @@ func TestChannelsAccountPoolBehavior(t *testing.T) {
|
||||
t.Fatal("上个用例应留有账号")
|
||||
}
|
||||
aid := accts[0].(map[string]interface{})["id"].(string)
|
||||
// ★ 语义已变(一个通道 = 一个源),断言按「通道源是否存在」判,不按数量差。
|
||||
//
|
||||
// 为什么不能断言 `after == before-1`:内核的 remove_source 有一条正当
|
||||
// 保护 —— **拒绝删掉最后一个源**(空 registry 会让每个请求变 503)。
|
||||
// 本夹具只有这一个源,撤源请求会被这条保护挡住,数量差恒为 0,
|
||||
// 于是断言无论实现对错都失败。生产上有 20+ 源不会撞这条保护,
|
||||
// 但判据不能依赖"恰好有别的源"这种环境事实。
|
||||
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok {
|
||||
t.Fatal("前置:minimax 通道源应存在")
|
||||
}
|
||||
before := len(listSourcesViaKernel(t, c))
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":false}}`)
|
||||
// 停用唯一账号 ⇒ 该通道无可用号 ⇒ 插件**尝试**撤源。夹具里会撞上内核
|
||||
// 「拒绝删最后一个源」的保护,故不断言源数量,改断言插件自己的判据:
|
||||
// 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(无可用账号)")
|
||||
if n := len(listSourcesViaKernel(t, c)); n != before-1 {
|
||||
t.Fatalf("停用账号应删源: 前 %d 后 %d", before, n)
|
||||
}
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":true}}`)
|
||||
if n := len(listSourcesViaKernel(t, c)); n < 1 {
|
||||
t.Fatalf("启用账号后应重建源,实际源数 %d", n)
|
||||
if n := len(listSourcesViaKernel(t, c)); n != before {
|
||||
t.Fatalf("启用账号应重建源: 前 %d 后 %d", before, n)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -49,17 +49,16 @@ func TestPrivateProtocolSourcesUsePluginAdapters(t *testing.T) {
|
||||
if j == nil || j["error"] != nil {
|
||||
t.Fatalf("account_add 报错: %#v", m)
|
||||
}
|
||||
_, _ = j["account_id"].(string)
|
||||
aid, _ := j["account_id"].(string)
|
||||
|
||||
srcs := listSourcesViaKernel(t, c)
|
||||
// ★ 一通道一源:按**平台名**找源,不按账号 id(源名已不含 id)。
|
||||
sm, ok := srcHasAccount(srcs, tc.platform)
|
||||
sm, ok := srcHasAccount(srcs, aid)
|
||||
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,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -221,75 +220,3 @@ 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,12 +27,6 @@ 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 {
|
||||
@ -40,31 +34,6 @@ 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) {
|
||||
@ -127,14 +96,12 @@ 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) {
|
||||
// ★ 2026-10-20:上游 /login/mp 已 404,登录落地页改为 /login(实测 200,
|
||||
// 页面内含「微信扫码」面板并接受 code 预置)。故断言改为 /login。
|
||||
t.Run("行1 本地生成code并给出二维码URL", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
j, _ := h.begin()
|
||||
qr, _ := j["qr_url"].(string)
|
||||
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "/login?") {
|
||||
t.Fatalf("应给出带 code 的二维码 URL(落地页 /login), 实得 %q", qr)
|
||||
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "login/mp") {
|
||||
t.Fatalf("应给出带 code 的二维码 URL, 实得 %q", qr)
|
||||
}
|
||||
// code 应是 32 位 hex(本地随机生成)
|
||||
code := qr[strings.Index(qr, "code=")+5:]
|
||||
@ -184,42 +151,6 @@ 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,7 +2,6 @@ package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@ -192,69 +191,3 @@ 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") {
|
||||
t.Fatalf("前置条件:通道源应已建立(一通道一源:channels-buddy),实得 %v", got)
|
||||
if got := h.sourceNames(); !hasName(got, "channels-buddy-del-a") {
|
||||
t.Fatalf("前置条件:账号源应已建立,实得 %v", got)
|
||||
}
|
||||
h.deleteAccount("del-a")
|
||||
if got := h.sourceNames(); hasName(got, "channels-buddy") {
|
||||
t.Fatalf("唯一账号已删但通道源仍在 ⇒ 已删账号继续对外服务:%v", got)
|
||||
if got := h.sourceNames(); hasName(got, "channels-buddy-del-a") {
|
||||
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, "撤旧源失败") && !strings.Contains(joined, "撤源失败") {
|
||||
if !strings.Contains(joined, "撤源失败") {
|
||||
t.Fatalf("撤源被拒却没有任何记录 ⇒ 权限缺失会一直静默(表现是"+
|
||||
"「删了账号却还在用」)\n事件日志: %s", joined)
|
||||
}
|
||||
|
||||
@ -16,40 +16,55 @@ 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")
|
||||
|
||||
// 初始:先建的 acct-a 优先(priority 相同 → createdAt 升序 → id 兜底)
|
||||
if got := h.chosenAccount(); got != "acct-a" {
|
||||
t.Fatalf("前置条件不成立:初始应选 acct-a,实得 %q", got)
|
||||
// 先确认两个源都在,且初始顺序是 [a, b](account_add 的注册顺序)。
|
||||
// ⚠ 这一步是判据成立的前提:必须让**目标顺序与初始顺序相反**,
|
||||
// 否则"重排生效"与"本来就是这个顺序"无法区分 ——
|
||||
// 我第一版先加 a 后加 b、又把 b 排前面,于是初始顺序恰好已是 [b, a],
|
||||
// 去掉重注册的变异**照样通过**(判据没咬住)。
|
||||
before := h.sourceOrder()
|
||||
if len(before) < 2 {
|
||||
t.Fatalf("应有 2 个账号源,实得 %v", before)
|
||||
}
|
||||
// 通道只有一个源,且确实是它
|
||||
if n := len(h.sourceOrder()); n != 1 {
|
||||
t.Fatalf("一通道一源:应有 1 个通道源,实得 %d", n)
|
||||
if before[0] != "channels-buddy-acct-a" {
|
||||
t.Fatalf("前置条件不成立:初始顺序应为 [a, b](add 顺序),实得 %v —— "+
|
||||
"若初始已是目标顺序,本判据无法区分「重排生效」与「本来就对」", before)
|
||||
}
|
||||
|
||||
// 把 acct-b 排到最前 —— 只有真的重新选号+重建源,chosen 才会切换
|
||||
// 把 b 排到前面(ordered_ids 顺序即优先级,第一个最高)——
|
||||
// 目标顺序与初始相反,故只有真的重注册才能达成。
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-b", "acct-a"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := h.chosenAccount(); got != "acct-b" {
|
||||
t.Fatalf("重排后应改选 acct-b(源同名覆盖成它的凭据),实得 %q\n"+
|
||||
"(只改 pool.priority 不重建源的话,内核仍用旧 api_key ⇒ 路由不变)", got)
|
||||
after := h.sourceOrder()
|
||||
if len(after) < 2 {
|
||||
t.Fatalf("重排后源丢了:%v", after)
|
||||
}
|
||||
// 源仍在(不能因为重建把源弄丢)
|
||||
if n := len(h.sourceOrder()); n != 1 {
|
||||
t.Fatalf("重排后通道源应仍为 1 个,实得 %d", n)
|
||||
|
||||
idxOf := func(list []string, id string) int {
|
||||
for i, n := range list {
|
||||
if n == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
t.Logf("✓ 重排已切换服务账号: %s", h.chosenAccount())
|
||||
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)
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
@ -82,8 +97,7 @@ func (h *reorderHarness) addAccount(id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
// 每个账号用**不同** token:cho sen 切换后源 api_key 变化才是可观测的
|
||||
"credential": `{"access_token":"AT-` + id + `","uid":"u1"}`,
|
||||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_add(%s): %v", id, err)
|
||||
}
|
||||
@ -109,8 +123,7 @@ func (h *reorderHarness) sourceOrder() []string {
|
||||
continue
|
||||
}
|
||||
n, _ := m["name"].(string)
|
||||
// 一通道一源:源名就是 channels-<provider>(不再带账号后缀)
|
||||
if n == "channels-buddy" {
|
||||
if len(n) > 15 && n[:15] == "channels-buddy-" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
@ -127,57 +140,54 @@ func (h *reorderHarness) sourceOrder() []string {
|
||||
// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分),
|
||||
// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。
|
||||
func TestPoolListOrderIsDeterministic(t *testing.T) {
|
||||
// ★ 一通道一源之后,"源注册顺序 = poolList 顺序"不再可观测(只有一个源)。
|
||||
// 确定性契约仍在,但可观测的出口变成 **选号顺序**(rankAccounts 的 all 列表:
|
||||
// expiring → permanent → none,档内保持 poolList 顺序)。
|
||||
// 故判据改为:同优先级 + 同秒创建时,选号顺序必须由 id 字典序决定。
|
||||
// ⚠ 这条判据必须走**批量重注册**路径(reorder 会按 poolList 顺序重放源)。
|
||||
// `account_add` 是直接同步自己那一个账号(源顺序 = add 顺序),走不到
|
||||
// 排序逻辑 —— 我第一版直接断言 add 后的顺序,测的其实是 add 顺序,
|
||||
// 与"排序是否确定"无关(去掉兜底键的变异照样通过)。
|
||||
//
|
||||
// 契约:同秒创建的账号(createdAt 相等 ⇒ 排序键相同)在批量重注册后,
|
||||
// 顺序必须由**稳定键**(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())
|
||||
}
|
||||
// priority 全平 + 同秒 ⇒ 只剩 id 兜底键
|
||||
order := h.rankOrder()
|
||||
want := "acct-a,acct-b,acct-c"
|
||||
if got := joinStrings(order, ","); got != want {
|
||||
t.Fatalf("同优先级同秒创建时,选号顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+
|
||||
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"+
|
||||
"(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)",
|
||||
got, want)
|
||||
got, 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
|
||||
t.Logf("✓ 同键时顺序由 id 稳定决定: %s", want2)
|
||||
}
|
||||
|
||||
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"]}}`+
|
||||
`]},`+
|
||||
// ② 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},`+
|
||||
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}},`+
|
||||
`{"config_name":"summary-model","usage":"summary","config_switch":true},`+
|
||||
`{"config_name":"disabled-model","usage":"chat_completion","config_switch":false},`+
|
||||
`{"config_name":"hidden-model","usage":"chat_completion","config_switch":true,"is_invisible_to_user":true},`+
|
||||
`{"config_name":"no-function-model","usage":"chat_completion","config_switch":true},`+
|
||||
// is_custom_model=true ⇒ 上游以流内 4001 拒绝("仅可见但不可调用")
|
||||
`{"config_name":"custom-flag-model","usage":"chat_completion","config_switch":true,`+
|
||||
`"display_config":{"is_custom_model":true}},`+
|
||||
// 对照组:名字带 custom_model_ 前缀但**标志为 false** ⇒ 必须**保留**
|
||||
// (参考点名:判据是标志的值,不是模型名)
|
||||
`{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+
|
||||
`"display_config":{"is_custom_model":false}}`+
|
||||
`]},`+
|
||||
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置
|
||||
`{"function":"solo_work_lite","config_info_list":[`+
|
||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true}`+
|
||||
`]},`+
|
||||
// ③ 白名单外通道:整组必须被丢弃
|
||||
`{"function":"inline_chat","config_info_list":[`+
|
||||
`{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
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 源应带模型清单")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,11 +11,13 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/billing"
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
"llmsproxy/internal/provider"
|
||||
@ -109,6 +111,7 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
log.Printf("[core] plugin %v %v loaded (hooks=%v ui=%v)",
|
||||
p["name"], p["version"], p["hooks"], p["ui"])
|
||||
}
|
||||
c.applyBillingDSL()
|
||||
c.store = config.NewStore(cfg.RuntimeFile)
|
||||
// Share one box between the runtime store and config.yaml so a single
|
||||
// master.key seals both files. config.Load left the config holding
|
||||
@ -147,6 +150,9 @@ 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
|
||||
}
|
||||
@ -189,6 +195,39 @@ 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.
|
||||
@ -838,23 +877,9 @@ 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")
|
||||
@ -982,6 +1007,13 @@ func normalizeSource(s *config.Source) error {
|
||||
if s.Name == "" || s.BaseURL == "" {
|
||||
return fmt.Errorf("source requires name and base_url")
|
||||
}
|
||||
// ★ 契约:源名只允许 [A-Za-z0-9_-](见 config.ValidateSourceName)。
|
||||
// 这里对**新增/修改的源硬拒**(API 400),而不是像启动时那样只告警 ——
|
||||
// 带 `:` 或 `/` 的源名会让 `源名:模型名` 这个限定名产生两种都自圆其说的
|
||||
// 切法,同一个请求串在不同部署下可能路由到不同源,且**不会报错**。
|
||||
if err := config.ValidateSourceName(s.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(s.Models) == 0 {
|
||||
return fmt.Errorf("source requires at least one model")
|
||||
}
|
||||
@ -1008,6 +1040,63 @@ func (c *Core) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// applyBillingDSL compiles the configured billing profile and injects it into
|
||||
// the billing plugin.
|
||||
//
|
||||
// Configured pricing replaces the hand-written JSON blob that used to be PUT
|
||||
// through the state API. That path was used twice in production and both times
|
||||
// it "succeeded" while being wrong: once without the required `prices` wrapper
|
||||
// (silently wiping the accumulated totals), once with a peak-window shape the
|
||||
// plugin does not read (peak traffic billed at off-peak rates, no error). A
|
||||
// typed config with load-time validation turns both mistakes into startup
|
||||
// errors naming the file and field.
|
||||
//
|
||||
// A DSL with no profile, or one that fails to compile, leaves the plugin's
|
||||
// prices untouched: billing keeps running on whatever it had, and the problem
|
||||
// is logged rather than becoming a startup failure — the gateway must forward
|
||||
// even when its cost accounting is misconfigured.
|
||||
func (c *Core) applyBillingDSL() {
|
||||
ps := c.plugins
|
||||
if ps == nil || ps.Count() == 0 {
|
||||
return
|
||||
}
|
||||
// The billing plugin may simply not be installed; a DSL without it is a
|
||||
// no-op, not an error.
|
||||
var have bool
|
||||
for _, row := range ps.List() {
|
||||
if row["name"] == "billing" && row["loaded"] == true {
|
||||
have = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !have {
|
||||
return
|
||||
}
|
||||
dsl := c.cfg.BillingDSL
|
||||
if dsl == nil || len(dsl.Profiles) == 0 {
|
||||
return
|
||||
}
|
||||
if err := dsl.Validate(); err != nil {
|
||||
log.Printf("[billing] config invalid, profile not applied: %v", err)
|
||||
return
|
||||
}
|
||||
profile := dsl.Resolve(dsl.Active)
|
||||
if profile == nil {
|
||||
log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active)
|
||||
return
|
||||
}
|
||||
prices, err := billing.Compile(profile, c.cfg.Sources)
|
||||
if err != nil {
|
||||
log.Printf("[billing] profile %q not applied: %v", profile.ID, err)
|
||||
return
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
// ValidateScopeQuotas checks every scope entry's caps before they are stored.
|
||||
// A typo in a period must be rejected at write time rather than silently
|
||||
// becoming a never-resetting budget — the opposite of what was typed.
|
||||
|
||||
@ -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"}}},
|
||||
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}}},
|
||||
Models: []config.Model{{ID: "m-" + name, Priority: 10}}},
|
||||
)
|
||||
_ = 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"}, {ID: "gpt-4o-mini"}}},
|
||||
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
|
||||
},
|
||||
// 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"}},
|
||||
Models: []config.Model{{ID: "new-model", Priority: 10}},
|
||||
})
|
||||
if err := c.Reload(); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
|
||||
72
internal/core/source_name_contract_test.go
Normal file
72
internal/core/source_name_contract_test.go
Normal file
@ -0,0 +1,72 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// ★ P2(内核改动):新增/修改源时,源名含 `:` 或 `/` 必须**硬拒**(API 400)。
|
||||
//
|
||||
// 这里验的是**真实写入入口** Core.AddSource(WebUI / 插件的 add_source 都走它),
|
||||
// 不是 config 层那个校验器 —— 校验器写对了但没接到入口上,问题依旧存在。
|
||||
//
|
||||
// 根因见 internal/config/config.go 的 ValidateSourceName 注释:`:` 与 `/` 是
|
||||
// `源名:模型名` 的**分隔符**,出现在源名里会让限定名产生两种都自圆其说的切法,
|
||||
// 同一请求串在不同部署下可能路由到不同源,且**不报错**。
|
||||
func TestAddSourceRejectsPinnedSeparatorInName(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"a:b",
|
||||
"channels-trae-abc:glm-5.2", // 把"限定名"当源名写进来
|
||||
"a/b",
|
||||
"openai/gpt",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
err := c.AddSource(config.Source{
|
||||
Name: name,
|
||||
BaseURL: "http://127.0.0.1:1/v1",
|
||||
Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m1", Kind: "chat"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("★ 源名 %q 含限定名分隔符,AddSource 必须拒绝\n"+
|
||||
" 否则 `%s:m1` 既可能解析成源 %q,也可能解析成别的源 —— 且不报错",
|
||||
name, name, name)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "pinning separator") {
|
||||
t.Fatalf("错误信息应点名「pinning separator」(运维才知道是限定名冲突),实得: %v", err)
|
||||
}
|
||||
// 源不得被写进配置(拒绝必须是**彻底的**,不能先写后报错)
|
||||
for _, s := range c.Sources() {
|
||||
if s.Name == name {
|
||||
t.Fatalf("★ 被拒绝的源 %q 竟然写进了配置 —— 拒绝必须发生在写盘之前", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 配对判据:合法的渠道源名(含 `-`)必须照常接受。
|
||||
// 这条防"校验器写太严把真实渠道源全拒了"——那会让整个渠道功能失效。
|
||||
func TestAddSourceAcceptsChannelStyleNames(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"channels-trae-b8dd6b3df30e",
|
||||
"channels-buddy-buddy-main",
|
||||
"my_source_1",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.AddSource(config.Source{
|
||||
Name: name,
|
||||
BaseURL: "http://127.0.0.1:1/v1",
|
||||
Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m1", Kind: "chat"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("★ 合法渠道源名 %q 被拒(会把真实渠道全废掉): %v", name, 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"}},
|
||||
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}},
|
||||
}},
|
||||
}
|
||||
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"}},
|
||||
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}},
|
||||
}},
|
||||
}
|
||||
c, err := NewFromConfig(cfg)
|
||||
|
||||
282
internal/gateway/billing_rules_api.go
Normal file
282
internal/gateway/billing_rules_api.go
Normal file
@ -0,0 +1,282 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"llmsproxy/internal/billing"
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// Billing rules API.
|
||||
//
|
||||
// GET /api/plugins/billing/rules the active profile's rules + all profiles
|
||||
// PUT /api/plugins/billing/rules replace the whole DSL and re-inject prices
|
||||
// POST /api/plugins/billing/rules { profile, rule } append one rule
|
||||
// DELETE /api/plugins/billing/rules { profile, url } remove one rule
|
||||
//
|
||||
// Why a separate endpoint instead of the generic plugin state: prices are
|
||||
// plugin state, but they are also a durable, reviewable CONFIGURATION. Routing
|
||||
// them through /state would let an admin key PUT arbitrary plugin state and
|
||||
// silently reset the accumulated accounting. Here the server owns the shape:
|
||||
// it validates the DSL, writes it back to config.yaml, recompiles, and hands
|
||||
// the plugin its prices table. The operator edits rules; the plugin keeps
|
||||
// numbers. The two are never mixed in one payload.
|
||||
//
|
||||
// Editing means "what the operator typed is what config.yaml holds". A
|
||||
// billing mistake found next week must be traceable to a reviewable file, not
|
||||
// to a blob in the plugin's state sidecar.
|
||||
func (g *Gateway) handleBillingRules(w http.ResponseWriter, r *http.Request) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
g.getBillingRules(w)
|
||||
case http.MethodPut:
|
||||
g.putBillingRules(w, r)
|
||||
case http.MethodPost:
|
||||
g.addBillingRule(w, r)
|
||||
case http.MethodDelete:
|
||||
g.delBillingRule(w, r)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET/PUT/POST/DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
// billingRulesPayload is the UI-facing view: the profiles as declared, plus
|
||||
// which one is active and the source URLs they can match — the editor needs
|
||||
// the URL list to offer suggestions, and an operator typing a URL that matches
|
||||
// no source is exactly the silent-no-pricing failure this feature exists to
|
||||
// remove.
|
||||
type billingRulesPayload struct {
|
||||
DSL *config.BillingDSL `json:"billing"`
|
||||
Active string `json:"active"`
|
||||
URLs []string `json:"urls"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
func (g *Gateway) billingRulesPayload() billingRulesPayload {
|
||||
out := billingRulesPayload{}
|
||||
cfg := g.core.Config()
|
||||
if cfg != nil && cfg.BillingDSL != nil {
|
||||
// Copy so the response cannot be mutated back through the pointer.
|
||||
cp := *cfg.BillingDSL
|
||||
out.DSL = &cp
|
||||
if p := cp.Resolve(""); p != nil {
|
||||
out.Active = p.ID
|
||||
}
|
||||
}
|
||||
for _, s := range g.sourceURLs() {
|
||||
out.URLs = append(out.URLs, s)
|
||||
}
|
||||
out.Warnings = g.billingRuleWarnings()
|
||||
return out
|
||||
}
|
||||
|
||||
func (g *Gateway) sourceURLs() []string {
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, s := range cfg.Sources {
|
||||
if s.BaseURL == "" || seen[s.BaseURL] {
|
||||
continue
|
||||
}
|
||||
seen[s.BaseURL] = true
|
||||
out = append(out, s.BaseURL)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// billingRuleWarnings reports rules that match no configured source. This is
|
||||
// the silent killer of hand-written price tables: the rule parses, validates,
|
||||
// and prices nothing at all, so every request on that URL lands in unpriced.
|
||||
// Saying so out loud is the difference between a five-second fix and an
|
||||
// afternoon wondering why the bill is zero.
|
||||
func (g *Gateway) billingRuleWarnings() []string {
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil || cfg.BillingDSL == nil {
|
||||
return nil
|
||||
}
|
||||
urls := map[string]bool{}
|
||||
for _, u := range g.sourceURLs() {
|
||||
urls[u] = true
|
||||
}
|
||||
var warns []string
|
||||
for _, p := range cfg.BillingDSL.Profiles {
|
||||
for _, r := range p.Rules {
|
||||
if r.URL == "*" || urls[r.URL] {
|
||||
continue
|
||||
}
|
||||
warns = append(warns, "profile "+p.ID+": rule url "+r.URL+" matches no configured source")
|
||||
}
|
||||
}
|
||||
return warns
|
||||
}
|
||||
|
||||
func (g *Gateway) getBillingRules(w http.ResponseWriter) {
|
||||
writeJSON(w, http.StatusOK, g.billingRulesPayload())
|
||||
}
|
||||
|
||||
func (g *Gateway) putBillingRules(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Billing *config.BillingDSL `json:"billing"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if body.Billing == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "billing is required")
|
||||
return
|
||||
}
|
||||
g.applyBillingDSLUpdate(w, body.Billing)
|
||||
}
|
||||
|
||||
func (g *Gateway) addBillingRule(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Profile string `json:"profile"`
|
||||
Rule config.BillingRule `json:"rule"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil || cfg.BillingDSL == nil || len(cfg.BillingDSL.Profiles) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "no_profile", "no billing profiles configured; create one first")
|
||||
return
|
||||
}
|
||||
next := cloneBillingDSL(cfg.BillingDSL)
|
||||
idx := profileIndex(next, body.Profile)
|
||||
if idx < 0 {
|
||||
writeError(w, http.StatusBadRequest, "unknown_profile", "no such profile: "+body.Profile)
|
||||
return
|
||||
}
|
||||
p := &next.Profiles[idx]
|
||||
// Replace rather than append when the URL is already declared: two rules
|
||||
// for one URL would silently make the first unreachable (first match wins),
|
||||
// which is the exact ambiguity an editor should not be able to create.
|
||||
replaced := false
|
||||
for i := range p.Rules {
|
||||
if p.Rules[i].URL == body.Rule.URL {
|
||||
p.Rules[i] = body.Rule
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
p.Rules = append(p.Rules, body.Rule)
|
||||
}
|
||||
g.applyBillingDSLUpdate(w, next)
|
||||
}
|
||||
|
||||
func (g *Gateway) delBillingRule(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Profile string `json:"profile"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil || cfg.BillingDSL == nil {
|
||||
writeError(w, http.StatusBadRequest, "no_profile", "no billing profiles configured")
|
||||
return
|
||||
}
|
||||
next := cloneBillingDSL(cfg.BillingDSL)
|
||||
idx := profileIndex(next, body.Profile)
|
||||
if idx < 0 {
|
||||
writeError(w, http.StatusBadRequest, "unknown_profile", "no such profile: "+body.Profile)
|
||||
return
|
||||
}
|
||||
p := &next.Profiles[idx]
|
||||
out := p.Rules[:0]
|
||||
found := false
|
||||
for _, rule := range p.Rules {
|
||||
if rule.URL == body.URL {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
out = append(out, rule)
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no rule for url "+body.URL)
|
||||
return
|
||||
}
|
||||
p.Rules = out
|
||||
g.applyBillingDSLUpdate(w, next)
|
||||
}
|
||||
|
||||
// applyBillingDSLUpdate is the single write path: validate, persist to
|
||||
// config.yaml, recompile, inject, and only then report success. If the config
|
||||
// cannot be written the change is NOT applied in memory either — a rules editor
|
||||
// that says "saved" and loses the edit on the next restart is worse than one
|
||||
// that refuses.
|
||||
func (g *Gateway) applyBillingDSLUpdate(w http.ResponseWriter, next *config.BillingDSL) {
|
||||
if err := next.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_rules", err.Error())
|
||||
return
|
||||
}
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil {
|
||||
writeError(w, http.StatusInternalServerError, "no_config", "no config loaded")
|
||||
return
|
||||
}
|
||||
// Assign BEFORE saving. The previous order saved first and assigned after,
|
||||
// so every rule edit reported success while writing a config.yaml with no
|
||||
// billing section at all — the operator's edit vanished on restart and
|
||||
// nothing in the API response said so.
|
||||
prev := cfg.BillingDSL
|
||||
cfg.BillingDSL = next
|
||||
|
||||
// Compile before persisting: a profile that cannot be turned into prices
|
||||
// must not reach the config file, or the next restart fails to load the
|
||||
// very rules the editor just accepted.
|
||||
prices, err := billing.CompileOpts(next.Resolve(next.Active), cfg.Sources, true)
|
||||
if err != nil {
|
||||
cfg.BillingDSL = prev // no half-applied state
|
||||
writeError(w, http.StatusBadRequest, "compile_failed", err.Error())
|
||||
return
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
cfg.BillingDSL = prev
|
||||
writeError(w, http.StatusInternalServerError, "persist_failed", err.Error())
|
||||
return
|
||||
}
|
||||
ps := g.core.Plugins()
|
||||
if ps != nil {
|
||||
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
p := g.billingRulesPayload()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true, "billing": p.DSL, "active": p.Active, "warnings": p.Warnings,
|
||||
})
|
||||
}
|
||||
|
||||
func cloneBillingDSL(d *config.BillingDSL) *config.BillingDSL {
|
||||
out := &config.BillingDSL{Active: d.Active}
|
||||
out.Profiles = make([]config.BillingProfile, len(d.Profiles))
|
||||
copy(out.Profiles, d.Profiles)
|
||||
return out
|
||||
}
|
||||
|
||||
func profileIndex(d *config.BillingDSL, id string) int {
|
||||
if id == "" {
|
||||
return -1
|
||||
}
|
||||
for i, p := range d.Profiles {
|
||||
if p.ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
281
internal/gateway/billing_rules_api_test.go
Normal file
281
internal/gateway/billing_rules_api_test.go
Normal file
@ -0,0 +1,281 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// rulesGateway needs a real billing plugin (the API injects prices into it)
|
||||
// AND two sources whose base_urls the rules will match, so it cannot reuse
|
||||
// either existing helper: gatewayWithBilling has no sources, and
|
||||
// newTestGateway loads no plugins.
|
||||
func rulesGateway(t *testing.T) *Gateway {
|
||||
t.Helper()
|
||||
g := gatewayWithBilling(t)
|
||||
cfg := g.core.Config()
|
||||
cfg.Sources = []config.Source{
|
||||
{Name: "localzen", BaseURL: "https://free.example.com/v1"},
|
||||
{Name: "commandcode", BaseURL: "https://api.commandcode.ai/v1"},
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func rulesDo(t *testing.T, g *Gateway, method, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return doReq(t, g, method, "/api/plugins/billing/rules", body)
|
||||
}
|
||||
|
||||
// TestBillingRulesCRUD covers the whole editor loop against the real HTTP
|
||||
// surface: read, add, replace-by-URL, delete. The replace-by-URL case is the
|
||||
// one worth pinning — two rules for one URL would make the first unreachable
|
||||
// (first match wins) without any error, which is exactly the kind of silent
|
||||
// ambiguity an editor must not be able to create.
|
||||
func TestBillingRulesCRUD(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
seed := `{"billing":{"active":"p1","profiles":[{"id":"p1","rules":[
|
||||
{"url":"https://free.example.com/v1","mode":"free"}]}]}}`
|
||||
if rr := rulesDo(t, g, http.MethodPut, seed); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed rules = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Add a token-priced rule for the second URL.
|
||||
add := `{"profile":"p1","rule":{"url":"https://api.commandcode.ai/v1","mode":"token",
|
||||
"models":{"deepseek-v4.1-flash":{"prompt":"0.15","completion":"0.60"}}}}`
|
||||
if rr := rulesDo(t, g, http.MethodPost, add); rr.Code != http.StatusOK {
|
||||
t.Fatalf("add rule = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got billingRulesPayload
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.DSL.Profiles[0].Rules) != 2 {
|
||||
t.Fatalf("after add there are %d rules, want 2", len(got.DSL.Profiles[0].Rules))
|
||||
}
|
||||
|
||||
// Adding the SAME url again must replace, not append.
|
||||
if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p1","rule":{"url":"https://api.commandcode.ai/v1","mode":"free"}}`); rr.Code != http.StatusOK {
|
||||
t.Fatalf("re-add same url = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(got.DSL.Profiles[0].Rules); n != 2 {
|
||||
t.Fatalf("re-adding an existing url appended instead of replacing: %d rules", n)
|
||||
}
|
||||
var mode string
|
||||
for _, r := range got.DSL.Profiles[0].Rules {
|
||||
if r.URL == "https://api.commandcode.ai/v1" {
|
||||
mode = r.Mode
|
||||
}
|
||||
}
|
||||
if mode != "free" {
|
||||
t.Errorf("re-added rule mode = %q, want free (the replacement must win)", mode)
|
||||
}
|
||||
|
||||
// Delete by URL.
|
||||
if rr := rulesDo(t, g, http.MethodDelete, `{"profile":"p1","url":"https://api.commandcode.ai/v1"}`); rr.Code != http.StatusOK {
|
||||
t.Fatalf("delete rule = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.DSL.Profiles[0].Rules) != 1 {
|
||||
t.Errorf("after delete there are %d rules, want 1", len(got.DSL.Profiles[0].Rules))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingRulesRejectUnknownURLWithWarning is the silent-failure guard: a
|
||||
// rule whose URL matches no source prices nothing, so every request on it
|
||||
// falls into unpriced. The API must say so instead of accepting it silently.
|
||||
func TestBillingRulesRejectUnknownURLWithWarning(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
if rr := rulesDo(t, g, http.MethodPut, `{"billing":{"active":"p","profiles":[{"id":"p",
|
||||
"rules":[{"url":"https://typo.example.com/v1","mode":"free"}]}]}}`); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got billingRulesPayload
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Warnings) == 0 {
|
||||
t.Fatal("a rule matching no configured source was accepted with no warning — " +
|
||||
"it will price nothing and every request will be recorded as unpriced")
|
||||
}
|
||||
if !strings.Contains(got.Warnings[0], "typo.example.com") {
|
||||
t.Errorf("warning does not name the offending url: %v", got.Warnings)
|
||||
}
|
||||
// The editor needs the real URL list to offer suggestions.
|
||||
if len(got.URLs) != 2 {
|
||||
t.Errorf("urls offered to the editor = %v, want the 2 configured base_urls", got.URLs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingRulesInvalidIsRejectedNotPersisted checks the write path refuses
|
||||
// bad input and leaves the previous rules in place — an editor that clears the
|
||||
// table on a typo loses the price table.
|
||||
func TestBillingRulesInvalidIsRejectedNotPersisted(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
seed := `{"billing":{"active":"p","profiles":[{"id":"p","rules":[
|
||||
{"url":"https://free.example.com/v1","mode":"free"}]}]}}`
|
||||
if rr := rulesDo(t, g, http.MethodPut, seed); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// An unknown mode must be refused.
|
||||
if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p","rule":{"url":"https://x/v1","mode":"banana"}}`); rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid mode accepted with %d, want 400", rr.Code)
|
||||
}
|
||||
var got billingRulesPayload
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.DSL.Profiles[0].Rules) != 1 {
|
||||
t.Errorf("a rejected rule was persisted anyway: %d rules", len(got.DSL.Profiles[0].Rules))
|
||||
}
|
||||
// An unparseable price must be refused too, not silently priced at zero.
|
||||
if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p","rule":{"url":"https://y/v1","mode":"token",
|
||||
"models":{"m":{"prompt":"free","completion":"0.6"}}}}`); rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("non-numeric price accepted with %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingRulesPersistAndReachThePlugin is the point of the whole
|
||||
// endpoint, and mutation verification showed the earlier CRUD tests missed it
|
||||
// twice: dropping cfg.Save() and dropping the SetState injection both left
|
||||
// every test green. Both failures are invisible in a GET — the response echoes
|
||||
// the in-memory rules either way — so this test checks the two consequences
|
||||
// that actually matter:
|
||||
//
|
||||
// 1. persistence: the rule must be in the config file on disk, because an
|
||||
// edit that evaporates on restart is a lie the UI told the operator;
|
||||
// 2. effect: the billing plugin must now PRICE the source (its published
|
||||
// prices table has a non-zero entry), because a rule that is stored but
|
||||
// never injected prices nothing at all.
|
||||
func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
cfg := g.core.Config()
|
||||
|
||||
seed := `{"billing":{"active":"p","profiles":[{"id":"p","rules":[
|
||||
{"url":"https://free.example.com/v1","mode":"free"},
|
||||
{"url":"https://api.commandcode.ai/v1","mode":"token",
|
||||
"models":{"deepseek-v4.1-flash":{"prompt":"0.15","completion":"0.60"}}}]}]}}`
|
||||
if rr := rulesDo(t, g, http.MethodPut, seed); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// 1. persisted to the file the process loaded from.
|
||||
raw, err := os.ReadFile(cfg.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "commandcode.ai") {
|
||||
t.Errorf("the rule is not in config.yaml on disk — an edit that does not "+
|
||||
"survive a restart:\n%s", raw)
|
||||
}
|
||||
reloaded, err := config.Load(cfg.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload saved config: %v", err)
|
||||
}
|
||||
if reloaded.BillingDSL == nil || len(reloaded.BillingDSL.Profiles) == 0 {
|
||||
t.Fatalf("saved config has no billing DSL: %s", raw)
|
||||
}
|
||||
// A round-trip through YAML must not lose the price strings.
|
||||
found := false
|
||||
for _, r := range reloaded.BillingDSL.Profiles[0].Rules {
|
||||
if strings.Contains(r.URL, "commandcode") {
|
||||
found = true
|
||||
if m, ok := r.Models["deepseek-v4.1-flash"]; !ok || m.Prompt != "0.15" {
|
||||
t.Errorf("price did not survive the YAML round-trip: %+v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the token rule is missing after reload:\n%s", raw)
|
||||
}
|
||||
|
||||
// 2. reached the plugin. Prices live in a separate Lua field from `state`,
|
||||
// and the GET /state endpoint returns the published state together with
|
||||
// the prices sidecar — which is exactly what the plugin's own UI reads.
|
||||
state := g.core.Plugins().State("billing")
|
||||
if state == nil {
|
||||
t.Fatalf("billing plugin published no state")
|
||||
}
|
||||
rr := doReq(t, g, http.MethodGet, "/api/plugins/billing/state", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET state = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Prices map[string]interface{} `json:"prices"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode state envelope: %v", err)
|
||||
}
|
||||
prices := envelope.Prices
|
||||
if prices == nil {
|
||||
t.Fatalf("state payload carries no prices")
|
||||
}
|
||||
sources, ok := prices["sources"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("prices has no sources map: %#v", prices)
|
||||
}
|
||||
cc, ok := sources["commandcode"]
|
||||
if !ok {
|
||||
t.Fatalf("the rule was saved but never injected: prices has %v, "+
|
||||
"no commandcode entry. Every request on it stays unpriced.", keysOf(sources))
|
||||
}
|
||||
if cc == nil {
|
||||
t.Error("commandcode price entry is nil")
|
||||
}
|
||||
// The free rule must price localzen, and the two must not be conflated.
|
||||
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.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TestBillingRulesRequireAdmin keeps price rewriting behind the admin role,
|
||||
// like every other plugin write: prices are configuration, not user data.
|
||||
func TestBillingRulesRequireAdmin(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
rec, err := g.core.CreateKey("viewer", "user", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userKey := rec.Key
|
||||
for _, m := range []string{http.MethodPut, http.MethodPost, http.MethodDelete} {
|
||||
req, _ := http.NewRequest(m, "/api/plugins/billing/rules", strings.NewReader(`{}`))
|
||||
req.Header.Set("Authorization", "Bearer "+userKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if rr := newRecorderFor(t, g, req); rr.Code != http.StatusForbidden {
|
||||
t.Errorf("user %s rules = %d, want 403", m, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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, Kind: "chat"})
|
||||
out = append(out, config.Model{ID: id, Priority: 50, Kind: "chat"})
|
||||
}
|
||||
for _, id := range image {
|
||||
out = append(out, config.Model{ID: id, Kind: "image"})
|
||||
out = append(out, config.Model{ID: id, Priority: 70, Kind: "image"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@ -61,20 +61,6 @@ 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.
|
||||
@ -131,36 +117,8 @@ 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 (or pass name of an existing source to reuse its base_url and credential)")
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "base_url required")
|
||||
return
|
||||
}
|
||||
if req.Adapter == "" {
|
||||
@ -186,58 +144,6 @@ 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,12 +126,7 @@ func TestRealMutants(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "nested metadata no longer read",
|
||||
// 锚点必须与 discover.go 逐字节一致:此前这里是 6 个空格,源码已
|
||||
// 变成 2 个空格,于是这条变异永远"找不到锚点"——变异campaign 每次
|
||||
// 都以 anchor not found 失败,而它本该验证"嵌套 metadata 被丢掉"能被
|
||||
// 测试抓住。锚点漂移 = 这条变异从未真正跑过(和 issue #1 里
|
||||
// `cc["models"]` 恒 false 的测试跳过是同一种失效:断言写了但没执行)。
|
||||
from: "\tcopyFields(m.Metadata) // nested first",
|
||||
from: "\tcopyFields(m.Metadata) // nested first",
|
||||
to: "\tcopyFields(json.RawMessage(nil)) // mutant: nested metadata dropped",
|
||||
},
|
||||
{
|
||||
|
||||
@ -497,6 +497,9 @@ 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,42 +53,6 @@ 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)
|
||||
@ -143,18 +107,9 @@ 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"}}},
|
||||
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m"}}},
|
||||
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}}},
|
||||
)
|
||||
// ★ 显式把两个源放进**同一 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 {
|
||||
@ -179,8 +134,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"}}},
|
||||
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m"}}},
|
||||
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}}},
|
||||
)
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
@ -201,7 +156,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"}}},
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
)
|
||||
// one slot with an hourly quota of 1 token
|
||||
rr := doReq(t, g, "PUT", "/api/auto",
|
||||
@ -236,7 +191,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"}}},
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
)
|
||||
doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
@ -279,7 +234,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"}}},
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
)
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
@ -393,8 +348,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"}}},
|
||||
config.Source{Name: "high", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "high-m"}}},
|
||||
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}}},
|
||||
)
|
||||
// no model -> AUTO -> picks the highest priority source
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
@ -473,8 +428,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"}}},
|
||||
config.Source{Name: "img", BaseURL: img.URL, Adapter: "openai", Models: []config.Model{{ID: "flux", Kind: "image"}}},
|
||||
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}}},
|
||||
)
|
||||
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"}, {ID: "gpt-4o-mini"}}},
|
||||
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
|
||||
},
|
||||
}
|
||||
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"}},
|
||||
Models: []config.Model{{ID: "m1", Priority: 100}},
|
||||
}},
|
||||
}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
@ -306,24 +306,14 @@ 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"},
|
||||
{ID: "m2"},
|
||||
{ID: "m1", Priority: 100},
|
||||
{ID: "m2", Priority: 90},
|
||||
},
|
||||
}},
|
||||
}
|
||||
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)
|
||||
@ -365,8 +355,8 @@ func TestOneModelsQuotaDoesNotBlockAnother(t *testing.T) {
|
||||
Sources: []config.Source{{
|
||||
Name: "up", BaseURL: up.URL, Adapter: "openai",
|
||||
Models: []config.Model{
|
||||
{ID: "m1"},
|
||||
{ID: "m2"},
|
||||
{ID: "m1", Priority: 100},
|
||||
{ID: "m2", Priority: 90},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
@ -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"}},
|
||||
Models: []config.Model{{ID: "claude-test", Kind: "chat", Priority: 100}},
|
||||
}},
|
||||
}
|
||||
|
||||
|
||||
@ -247,6 +247,11 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleSourceTemplatesAPI(w, r)
|
||||
case r.URL.Path == "/api/ui-inject" || strings.HasPrefix(r.URL.Path, "/api/ui-inject/"):
|
||||
g.handlePluginUI(w, r)
|
||||
case r.URL.Path == "/api/plugins/billing/rules":
|
||||
// Checked BEFORE the generic /api/plugins/ prefix: the plugin router
|
||||
// treats anything after the name as a plugin sub-resource and would
|
||||
// 404 on "rules" instead of reaching the billing editor.
|
||||
g.handleBillingRules(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, pluginHTTPPrefix):
|
||||
// Before the generic plugin router: these paths belong to a PLUGIN, not to
|
||||
// the plugin manager, and "http" is a valid sub-resource name otherwise.
|
||||
|
||||
@ -71,6 +71,11 @@ 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")
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import "sort"
|
||||
|
||||
// keysOf returns the sorted keys of a map, for readable failure messages.
|
||||
// (It used to live in the deleted billing-rules test file; other gateway tests
|
||||
// still use it.)
|
||||
func keysOf(m map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@ -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"}},
|
||||
Models: []config.Model{{ID: "m1", Priority: 100}},
|
||||
})
|
||||
|
||||
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"}},
|
||||
Models: []config.Model{{ID: "m1", Priority: 100}},
|
||||
})
|
||||
|
||||
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"}},
|
||||
Models: []config.Model{{ID: "m1", Priority: 100}},
|
||||
})
|
||||
|
||||
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", Kind: "image"}},
|
||||
Models: []config.Model{{ID: "img-m", Priority: 100, Kind: "image"}},
|
||||
}
|
||||
g := newTestGateway(t, src)
|
||||
|
||||
|
||||
@ -365,11 +365,10 @@
|
||||
.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);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-chat{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}
|
||||
.tab-image{max-width:840px;margin:0 auto;display:flex;flex-direction:column;
|
||||
height:calc(100vh - 190px)}
|
||||
.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}
|
||||
@ -590,6 +589,21 @@
|
||||
/></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"
|
||||
@ -808,7 +822,8 @@
|
||||
<main id="main">
|
||||
<div id="tab-status" class="tab-pane"></div>
|
||||
<div id="tab-chat" class="tab-pane hidden"></div>
|
||||
<div id="tab-keys" 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-sort" class="tab-pane hidden"></div>
|
||||
<div id="tab-sources" class="tab-pane hidden"></div>
|
||||
<div id="tab-adapters" class="tab-pane hidden"></div>
|
||||
@ -1574,7 +1589,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", "keys", "sort", "sources", "adapters", "plugins"];
|
||||
const TABS = ["status", "chat", "image", "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
|
||||
@ -2883,13 +2898,6 @@ 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">
|
||||
@ -2917,8 +2925,6 @@ async function renderChat() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-pane" id="ct-image-pane" style="display:none"></div>
|
||||
</div>`;
|
||||
chatHistory = [];
|
||||
chatImages = [];
|
||||
@ -2933,22 +2939,22 @@ async function renderChat() {
|
||||
$("#ct-imgs").style.display = "none";
|
||||
$("#ct-msg").focus();
|
||||
}
|
||||
/* ---------- chat/image modes ---------- */
|
||||
/* ---------- 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.
|
||||
let imgBusy = false;
|
||||
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);
|
||||
function imgModels() {
|
||||
const out = [];
|
||||
document.querySelectorAll("#it-model option").forEach((o) => {
|
||||
if (o.value) out.push(o.value);
|
||||
});
|
||||
if (chatMode === "image" && imagePane && !imagePane.dataset.rendered) {
|
||||
imagePane.dataset.rendered = "1";
|
||||
renderImage();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
async function renderImage() {
|
||||
const s = await api("/api/status");
|
||||
@ -2971,7 +2977,7 @@ async function renderChat() {
|
||||
const body = opts.length
|
||||
? ""
|
||||
: `<div class="empty">${esc(t("imgNoModels"))}</div>`;
|
||||
$("#ct-image-pane").innerHTML = `
|
||||
$("#tab-image").innerHTML = `
|
||||
<div class="tab-image">
|
||||
<div class="img-wrap">
|
||||
<div class="img-grid" id="it-grid">
|
||||
@ -6261,6 +6267,7 @@ 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,47 +42,28 @@ func TestUI_ChatPageGroupsImageModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUI_ImageModeIsReachableFromChatTab 钉住生图**仍可达**。
|
||||
//
|
||||
// ★ 断言已随 UI 结构调整:生图不再是独立侧栏项,而是对话页里的一个分段模式。
|
||||
// 原先这条测试钉的是 `data-tab="image"` / `id="tab-image"` / `tab === "image"`
|
||||
// —— 三者都已随合并移除。若继续钉旧结构,它只会红着挡住重构,而检不出
|
||||
// "生图入口彻底消失"这种真回归。所以改为钉**合并后的可达性**:
|
||||
// 分段控件在、切换函数在、面板容器在、端点仍在。
|
||||
//
|
||||
// 为什么"可达性"才是要守的东西:合并的动机是少一个导航项,而不是把生图功能
|
||||
// 藏起来。只要用户能从对话页一键切到生图并真的发出请求,这个功能就是完整的。
|
||||
func TestUI_ImageModeIsReachableFromChatTab(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) {
|
||||
ui := uiSource(t)
|
||||
|
||||
for _, want := range []string{
|
||||
`class="chat-mode-bar"`, // 分段栏存在
|
||||
`switchChatMode('image')`, // 能切到生图
|
||||
`id="ct-image-pane"`, // 生图面板容器
|
||||
`function renderImage`, // 渲染器还在
|
||||
`/v1/images/generations`, // 真正被调用的端点
|
||||
`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
|
||||
} {
|
||||
if !strings.Contains(ui, want) {
|
||||
t.Errorf("生图模式不可达,缺少 %q", want)
|
||||
t.Errorf("image page is missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// 合并后**不应**再出现独立 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(切换需要两个容器同时存在)")
|
||||
// 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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
122
internal/lua/adapter_anthropic_headers_test.go
Normal file
122
internal/lua/adapter_anthropic_headers_test.go
Normal file
@ -0,0 +1,122 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ P1-1:anthropic 基座必须发 `x-api-key`,不是 `Authorization: Bearer`。
|
||||
//
|
||||
// 真实缺陷:anthropic.lua 只有一个静态 `anthropic-version`、**没有**
|
||||
// build_headers ⇒ 内核走兜底 `Authorization: Bearer <api_key>` ⇒ Anthropic
|
||||
// 协议一律回 401 `invalid x-api-key`。继承该基座的渠道源(channels-zcode)
|
||||
// 一有账号就会立刻暴露,与 trae 修复前完全同型(静态头 + 内核兜底 Bearer,
|
||||
// 缺的正是协议要的那一个头)。
|
||||
//
|
||||
// 判据咬的是**真实产出表**(vm.BuildHeaders 就是内核发请求前走的那条路径),
|
||||
// 不造 HTTP 假上游 —— 假上游需要 URL 覆盖/凭据 ref/路径匹配全部对齐,任一环
|
||||
// 错都会让「没发请求」伪装成「头不对」(plan §3 第 4 条踩过三轮)。
|
||||
func TestAnthropicBuildHeadersSendsXAPIKey(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
if !vm.HasFn("anthropic", "build_headers") {
|
||||
t.Fatal("★ anthropic 基座没有 build_headers —— 内核会兜底发 " +
|
||||
"Authorization: Bearer,而 Anthropic 协议要 x-api-key ⇒ 404/401")
|
||||
}
|
||||
|
||||
hdrs, err := vm.BuildHeaders("anthropic", map[string]interface{}{
|
||||
"api_key": "sk-ant-KEY-123",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"method": "POST",
|
||||
"body": `{"model":"claude"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
|
||||
// ① 键集合:必须**恰好**是这两个。多出来的 Authorization 会让上游面对
|
||||
// 两套鉴权方案(部分 Anthropic 兼容上游对歧义鉴权直接拒)。
|
||||
if len(hdrs) != 2 {
|
||||
t.Fatalf("★ 头集合应恰好 2 个(x-api-key + anthropic-version),实得 %d: %v",
|
||||
len(hdrs), keysOf(hdrs))
|
||||
}
|
||||
if got := hdrs["x-api-key"]; got != "sk-ant-KEY-123" {
|
||||
t.Fatalf("★ x-api-key 必须取凭据原值,实得 %q(头: %v)", got, keysOf(hdrs))
|
||||
}
|
||||
if got := hdrs["anthropic-version"]; got != "2023-06-01" {
|
||||
t.Fatalf("anthropic-version 应为 2023-06-01,实得 %q", got)
|
||||
}
|
||||
if _, has := hdrs["Authorization"]; has {
|
||||
t.Fatalf("★ 不得同时发 Authorization(与 x-api-key 冲突,上游视为歧义): %v", hdrs)
|
||||
}
|
||||
// ② 值形状:不能带 Bearer 前缀(x-api-key 的值是裸 key)
|
||||
if strings.Contains(hdrs["x-api-key"], "Bearer") {
|
||||
t.Fatalf("★ x-api-key 的值是**裸 key**,不能带 Bearer 前缀,实得 %q", hdrs["x-api-key"])
|
||||
}
|
||||
// ③ key 绝不外泄到别的头里(防止某次改动把 key 顺手塞进 UA 之类)
|
||||
for k, v := range hdrs {
|
||||
if k != "x-api-key" && strings.Contains(v, "sk-ant-KEY-123") {
|
||||
t.Fatalf("★ 凭据只能出现在 x-api-key,泄露到了 %s=%q", k, v)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ anthropic 头: %v", keysOf(hdrs))
|
||||
}
|
||||
|
||||
// 配对判据:没有凭据时仍要发 anthropic-version(协议头),但**不得**发空
|
||||
// x-api-key —— 空值头会让上游回 `invalid x-api-key` 而不是清晰的"未配置凭据"。
|
||||
func TestAnthropicBuildHeadersWithoutCredential(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
for _, meta := range []map[string]interface{}{nil, {}, {"api_key": ""}} {
|
||||
hdrs, err := vm.BuildHeaders("anthropic", meta)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders(%v): %v", meta, err)
|
||||
}
|
||||
if hdrs["anthropic-version"] != "2023-06-01" {
|
||||
t.Fatalf("mate=%v 时仍须有 anthropic-version,实得 %v", meta, hdrs)
|
||||
}
|
||||
if _, has := hdrs["x-api-key"]; has {
|
||||
t.Fatalf("meta=%v 时不得发空 x-api-key,实得 %v", meta, hdrs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底路径:有些调用方只把 key 放在 source.meta.api_key(kimicode 同约定)。
|
||||
// 基座必须与 kimicode 一致地认这第二种位置,否则换调用方就静默少头。
|
||||
func TestAnthropicBuildHeadersReadsSourceMetaKey(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
hdrs, err := vm.BuildHeaders("anthropic", map[string]interface{}{
|
||||
"source": map[string]interface{}{
|
||||
"name": "claude-direct",
|
||||
"meta": map[string]interface{}{"api_key": "KEY-FROM-META"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
if hdrs["x-api-key"] != "KEY-FROM-META" {
|
||||
t.Fatalf("★ 应读 source.meta.api_key 兜底,实得 %q(头: %v)",
|
||||
hdrs["x-api-key"], keysOf(hdrs))
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -343,16 +343,6 @@ 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)。
|
||||
|
||||
@ -58,12 +58,6 @@ func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) {
|
||||
{"FastAPI detail", `{"detail":"boom"}`, "boom"},
|
||||
{"HTML title", `<html><title>413 Too Large</title></html>`, "413 Too Large"},
|
||||
{"裸文本", "plain failure line\nmore", "plain failure line"},
|
||||
// 腾讯 codebuddy 三层嵌套(实测 429 code=14018 额度用尽):
|
||||
// 此前落到"不识别",客户端只看到通用文案,用户分不清是额度还是故障。
|
||||
{"buddy 额度用尽嵌套", `{"error":{"data":{"code":14018,"msg":"额度已用尽,请购买加量包","requestId":"r1"}}}`,
|
||||
"14018: 额度已用尽,请购买加量包"},
|
||||
// 同一形状但没有 code:只回 msg
|
||||
{"buddy 嵌套无码", `{"error":{"data":{"msg":"请求被拒绝"}}}`, "请求被拒绝"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
143
internal/lua/adapter_zcode_build_headers_test.go
Normal file
143
internal/lua/adapter_zcode_build_headers_test.go
Normal file
@ -0,0 +1,143 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★ zcode 的 Messages 端点鉴权是 **Bearer**,不是 x-api-key。
|
||||
//
|
||||
// 背景(本轮**最重要的一处证据冲突**,必须写清):
|
||||
//
|
||||
// 任务前提是「anthropic 协议 ⇒ x-api-key,所以 channels-zcode 继承基座就 401」。
|
||||
// 但逆向参考(vendor/channel-pack,全部结论来自真实流量)明确写着相反的事:
|
||||
//
|
||||
// src/zcode-transport.ts:121-125 buildChannelRequest()
|
||||
// headers = buildZcodeHeaders(credential, { json: true })
|
||||
// headers.Authorization = `Bearer ${token}` // token = zcode_jwt
|
||||
// src/zcode-upstream.ts:83-108 buildZcodeHeaders()
|
||||
// 只发 User-Agent / HTTP-Referer / X-Device-Mid / anthropic-version …
|
||||
// Authorization 是**可选注入项**,且全仓 `grep -rn "x-api-key" src/` = **0 次**
|
||||
//
|
||||
// 也就是说 zcode 的这条路径是「**Anthropic 请求体形状 + Bearer 鉴权**」——
|
||||
// 协议与鉴权是两个独立维度,被同一家上游拆开了。
|
||||
//
|
||||
// 为什么修复前它其实是**对的**:zcode 未声明 credential_shape ⇒ api_key = 凭据里的
|
||||
// access_token = zcode_jwt;而基座当时没有 build_headers ⇒ 内核兜底发了
|
||||
// `Authorization: Bearer <zcode_jwt>`,与参考逐字一致。
|
||||
//
|
||||
// ⇒ 真正的风险来自**本轮的正向修复**:给 anthropic 基座补 x-api-key 之后,
|
||||
// 若 zcode 只是「继承」而不覆盖,它就会从 Bearer 变成 x-api-key ⇒ 立刻 401。
|
||||
// 所以 zcode 必须**显式覆盖** build_headers,把 Bearer 钉住。
|
||||
//
|
||||
// 判据咬真实产出(vm.BuildHeaders 是内核发请求前的那条路径),不造 HTTP 假上游。
|
||||
func TestZcodeBuildHeadersUsesBearerNotXAPIKey(t *testing.T) {
|
||||
vm, _ := zcodeHarness(t)
|
||||
if !vm.HasFn("channels-zcode", "build_headers") {
|
||||
t.Fatal("★ channels-zcode 必须有**自己的** build_headers(覆盖基座)," +
|
||||
"否则会继承基座的 x-api-key 而丢掉参考要求的 Bearer")
|
||||
}
|
||||
|
||||
hdrs, err := vm.BuildHeaders("channels-zcode", map[string]interface{}{
|
||||
"api_key": "ZCODE-JWT-ABC",
|
||||
"url": "https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages",
|
||||
"method": "POST",
|
||||
"body": `{"model":"GLM-5.3"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
|
||||
// ① 鉴权必须是 Bearer <zcode_jwt>(参考 zcode-transport.ts:125)
|
||||
if got := hdrs["Authorization"]; got != "Bearer ZCODE-JWT-ABC" {
|
||||
t.Fatalf("★ zcode 的 Authorization 必须是 `Bearer <zcode_jwt>`,实得 %q(头: %v)\n"+
|
||||
" 参考 zcode-transport.ts:125 逐字如此;全仓没有一处发 x-api-key",
|
||||
got, keysOf(hdrs))
|
||||
}
|
||||
// ② 不得同时发 x-api-key(那就是双鉴权;且参考从不发它)
|
||||
if v, has := hdrs["x-api-key"]; has {
|
||||
t.Fatalf("★ zcode 不得发 x-api-key(实得 %q)—— 参考全程没有这个头,"+
|
||||
"发了就是两套鉴权方案", v)
|
||||
}
|
||||
// ③ 协议头 anthropic-version 必须保留(它与鉴权无关,参考也发)
|
||||
if got := hdrs["anthropic-version"]; got != "2023-06-01" {
|
||||
t.Fatalf("anthropic-version 应保留为 2023-06-01,实得 %q", got)
|
||||
}
|
||||
// ④ 键集合恰好 2 个,不多不少(防止顺手塞进别的身份头)
|
||||
if len(hdrs) != 2 {
|
||||
t.Fatalf("头集合应恰好 2 个(Authorization + anthropic-version),实得 %d: %v",
|
||||
len(hdrs), keysOf(hdrs))
|
||||
}
|
||||
t.Logf("✓ zcode 头: %v", keysOf(hdrs))
|
||||
}
|
||||
|
||||
// 配对判据:**基座**必须发 x-api-key(这是 P1-1 的正向要求,也是与 zcode 的差异点)。
|
||||
//
|
||||
// 两条判据放在一起才有意义:它们证明「同一份基座 + 显式覆盖」能同时满足
|
||||
// 两种鉴权,而不是把整个 anthropic 生态压成一种。
|
||||
func TestAnthropicBaseAndZcodeDifferOnAuthHeader(t *testing.T) {
|
||||
vm, _ := zcodeHarness(t)
|
||||
|
||||
base, err := vm.BuildHeaders("anthropic", map[string]interface{}{"api_key": "SK-ANT"})
|
||||
if err != nil {
|
||||
t.Fatalf("anthropic BuildHeaders: %v", err)
|
||||
}
|
||||
zc, err := vm.BuildHeaders("channels-zcode", map[string]interface{}{"api_key": "JWT-Z"})
|
||||
if err != nil {
|
||||
t.Fatalf("zcode BuildHeaders: %v", err)
|
||||
}
|
||||
|
||||
// 基座:x-api-key,无 Authorization
|
||||
if base["x-api-key"] != "SK-ANT" {
|
||||
t.Fatalf("★ anthropic 基座必须发 x-api-key,实得 %v", base)
|
||||
}
|
||||
if _, has := base["Authorization"]; has {
|
||||
t.Fatalf("★ anthropic 基座不得发 Authorization(x-api-key 才是它的鉴权),实得 %v", base)
|
||||
}
|
||||
// zcode:Authorization,无 x-api-key
|
||||
if zc["Authorization"] == "" {
|
||||
t.Fatalf("★ zcode 必须发 Authorization: Bearer,实得 %v", zc)
|
||||
}
|
||||
if _, has := zc["x-api-key"]; has {
|
||||
t.Fatalf("★ zcode 不得发 x-api-key,实得 %v", zc)
|
||||
}
|
||||
// 两者必须**真的不同**——若某天有人删掉 zcode 的覆盖,这条会立刻红
|
||||
if base["x-api-key"] == "" || zc["Authorization"] == "" {
|
||||
t.Fatal("两者鉴权头方案必须各自成立")
|
||||
}
|
||||
b, _ := json.Marshal(base)
|
||||
z, _ := json.Marshal(zc)
|
||||
if string(b) == string(z) {
|
||||
t.Fatalf("★ 基座与 zcode 的鉴权方案不能相同:%s", b)
|
||||
}
|
||||
// 协议头必须相同(anthropic-version 与鉴权无关)
|
||||
if base["anthropic-version"] != zc["anthropic-version"] {
|
||||
t.Fatalf("两者都必须发 anthropic-version:base=%v zcode=%v", base, zc)
|
||||
}
|
||||
if strings.Contains(string(b), "Bearer") {
|
||||
t.Fatalf("基座不该出现 Bearer:%s", b)
|
||||
}
|
||||
t.Logf("✓ 基座=%s | zcode=%s", b, z)
|
||||
}
|
||||
|
||||
// 无凭据时:两边都只发协议头,都不发空鉴权(空值会让上游回认证失败,
|
||||
// 而真因是「没配置凭据」,会把人带偏)。
|
||||
func TestAuthHeadersOmitEmptyCredential(t *testing.T) {
|
||||
vm, _ := zcodeHarness(t)
|
||||
for _, name := range []string{"anthropic", "channels-zcode"} {
|
||||
h, err := vm.BuildHeaders(name, map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if h["anthropic-version"] != "2023-06-01" {
|
||||
t.Errorf("%s 仍须发 anthropic-version,实得 %v", name, h)
|
||||
}
|
||||
if _, has := h["x-api-key"]; has {
|
||||
t.Errorf("%s 无凭据时不得发空 x-api-key,实得 %v", name, h)
|
||||
}
|
||||
if _, has := h["Authorization"]; has {
|
||||
t.Errorf("%s 无凭据时不得发空 Authorization,实得 %v", name, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
internal/lua/adapter_zcode_harness_test.go
Normal file
68
internal/lua/adapter_zcode_harness_test.go
Normal file
@ -0,0 +1,68 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// zcodeHarness 是与 runtimePlugins 等价的测试台,唯一差别是 **adapters 与
|
||||
// plugins 目录互为兄弟**(`<tmp>/adapters` + `<tmp>/plugins`)。
|
||||
//
|
||||
// 为什么必须这样:installZcodeAdapter 要从「插件目录的父目录/adapters」读
|
||||
// anthropic.lua 的源码,再把基座**内联**进适配器脚本(适配器沙箱没有 fs)。
|
||||
// runtimePlugins 给 adapters/plugins 各用一个独立的 t.TempDir(),两个目录
|
||||
// 不是兄弟 ⇒ zcode 安装期就报「读不到 anthropic.lua」而被跳过,适配器根本
|
||||
// 不会出现(探针会如实报 ok:false,这本身是好事 —— 说明失败可见)。
|
||||
//
|
||||
// 生产里这两个目录是兄弟(config.PluginDir 与 config.AdapterDir 同级),
|
||||
// 所以这个测试台更贴近真实布局。
|
||||
func zcodeHarness(t *testing.T) (*VM, *Plugins) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
adir := filepath.Join(root, "adapters")
|
||||
vm := NewVM(adir)
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("vm: %v", err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
pdir := filepath.Join(root, "plugins")
|
||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pdir)
|
||||
}
|
||||
ps := NewPlugins(vm, pdir)
|
||||
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
// 同 runtimePlugins:关掉状态持久化,避免后台调度器写进生产 store。
|
||||
ps.DisableStatePersistence()
|
||||
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// zcode 装不上时必须**响亮失败**,不能静默跳过 —— 否则判据会在
|
||||
// 「适配器根本不存在」的情况下误判为通过(那正是 plan §3 第 1 条
|
||||
// 说的假绿形态)。
|
||||
res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("__adapter_install_probe: %v", err)
|
||||
}
|
||||
j := probeJSON(t, res)
|
||||
zc, ok := j["zcode"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针未报告 zcode 装载结果: %v", j)
|
||||
}
|
||||
if zc["ok"] != true {
|
||||
t.Fatalf("★ zcode 适配器装载失败: %v\n"+
|
||||
" 注意 luac -p 对长字符串内的适配器代码无效(plan §3 第 1 条),"+
|
||||
"必须以本探针为准", zc["err"])
|
||||
}
|
||||
if !vm.HasFn("channels-zcode", "build_headers") {
|
||||
t.Fatal("zcode 装载后仍然没有 build_headers")
|
||||
}
|
||||
_ = fmt.Sprint()
|
||||
return vm, ps
|
||||
}
|
||||
@ -5,6 +5,45 @@ adapter.endpoint = "/v1/messages"
|
||||
adapter.headers = {
|
||||
["anthropic-version"] = "2023-06-01",
|
||||
}
|
||||
|
||||
-- ============================================================
|
||||
-- build_headers (鉴权头)
|
||||
-- ============================================================
|
||||
-- Anthropic 协议要 **x-api-key**,不是 `Authorization: Bearer`。
|
||||
-- 此前本基座只有一个静态 `anthropic-version`、**没有** build_headers ⇒ 内核
|
||||
-- 走兜底 `Authorization: Bearer <api_key>` ⇒ 上游一律 401/403:
|
||||
-- `{"type":"error","error":{"type":"authentication_error",
|
||||
-- "message":"invalid x-api-key"}}`
|
||||
-- 继承本基座的渠道源(channels-zcode 等)一有账号就会立刻暴露 —— 与 trae
|
||||
-- 修复前完全同型(静态头 + 内核 Bearer,缺协议要的那一个头)。
|
||||
--
|
||||
-- 三条纪律:
|
||||
-- ① 值取自内核注入的 `meta.api_key`(源的凭据),**绝不**写日志、不额外
|
||||
-- 放进返回值以外的任何地方;本函数只把它放进请求头。
|
||||
-- ② **不自己发 Authorization** —— 源级 headers 若已显式配置 Authorization,
|
||||
-- 重复发两种鉴权方案会让部分 Anthropic 兼容上游直接拒(歧义)。
|
||||
-- ③ `anthropic-version` 与静态头同值,保证「装了 hook」与「没装 hook」两种
|
||||
-- 路径产出同一份协议头,不会因为加了 hook 而少一个版本头。
|
||||
function adapter.build_headers(meta)
|
||||
local h = {
|
||||
["anthropic-version"] = "2023-06-01",
|
||||
}
|
||||
local key = ""
|
||||
if type(meta) == "table" then
|
||||
if type(meta.api_key) == "string" then key = meta.api_key end
|
||||
-- 兜底:个别调用方只给 source.meta.api_key(与 kimicode 同一约定)
|
||||
if key == "" and type(meta.source) == "table"
|
||||
and type(meta.source.meta) == "table"
|
||||
and type(meta.source.meta.api_key) == "string" then
|
||||
key = meta.source.meta.api_key
|
||||
end
|
||||
end
|
||||
if key ~= "" then
|
||||
h["x-api-key"] = key
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
-- INGRESS CLAIM (inbound dual of serialize_*): this adapter owns parsing of the
|
||||
-- client-facing POST /v1/messages request body. The kernel keeps a built-in
|
||||
-- Anthropic parser as its fallback (R5) — that is what an unconfigured
|
||||
|
||||
@ -55,13 +55,6 @@ function adapter.transform_request(raw_body)
|
||||
-- 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
|
||||
@ -99,92 +92,7 @@ function adapter.transform_request(raw_body)
|
||||
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)
|
||||
@ -239,10 +147,11 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
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.
|
||||
if delta.tool_calls then
|
||||
-- Sanitize on the way OUT too: an id this upstream happily minted (it
|
||||
-- does not validate them) becomes a landmine once the client replays
|
||||
-- it to a Claude upstream. Only the first fragment of a streamed call
|
||||
-- carries an id; later argument fragments have none and are untouched.
|
||||
for _, tc in ipairs(delta.tool_calls) do
|
||||
if type(tc) == "table" and tc.id ~= nil then
|
||||
tc.id = safe_tool_id(tc.id)
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "opencodezen"
|
||||
adapter.version = "1.1.0"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
-- ★ hidden:不进用户可见适配器列表(v1.1.0)。本适配器是 OpenCode 免费层的
|
||||
-- **门禁专用形态**(强制 stream、注入 read 桩工具、会话号服从倒计时公式),
|
||||
-- 直接暴露会被误当成通用 OpenAI 适配器选去接别的上游,结果不可预测。
|
||||
-- 被 localzen 源引用时内核仍会把它列出来(源编辑器回显),其余场合隐藏。
|
||||
adapter.hidden = true
|
||||
|
||||
-- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1)
|
||||
--
|
||||
@ -104,7 +99,7 @@ local function now_ms(meta)
|
||||
return t * 1000
|
||||
end
|
||||
|
||||
local function rand_id(prefix, ms, kind, affinity)
|
||||
local function rand_id(prefix, ms, kind)
|
||||
local seg
|
||||
if kind == "session" then
|
||||
seg = (ID_K - ms * 4096) % (MASK48 + 1)
|
||||
@ -112,15 +107,7 @@ local function rand_id(prefix, ms, kind, affinity)
|
||||
seg = (ms * 16) % (MASK48 + 1)
|
||||
end
|
||||
-- %012x:12 位十六进制,不足补零
|
||||
-- affinity(可空):会话亲和键。非空时其 8 位 hex 摘要混入 tail ——
|
||||
-- 同一分钟桶内不同会话得到不同会号;hex 段不受影响(门禁只校验它)。
|
||||
local tail
|
||||
if type(affinity) == "string" and affinity ~= "" then
|
||||
tail = string.sub(sha256_hex(affinity), 1, 8) .. rand_tail(6)
|
||||
else
|
||||
tail = rand_tail(14)
|
||||
end
|
||||
return prefix .. string.format("%012x", seg) .. tail
|
||||
return prefix .. string.format("%012x", seg) .. rand_tail(14)
|
||||
end
|
||||
|
||||
-- session_bucket_ms 决定上游会话号的**稳定周期**(按整分钟对齐)。
|
||||
@ -133,41 +120,6 @@ 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)
|
||||
@ -177,9 +129,7 @@ function adapter.build_headers(meta)
|
||||
local ms = now_ms(meta)
|
||||
local bucket = session_bucket_ms(meta)
|
||||
-- 免费层要求两个头都在且值相同(对齐官方 CLI;缺任一个则 403)。
|
||||
-- 会话亲和键(可能为空)混入 tail:同一分钟桶内不同会话得到不同会号,
|
||||
-- 同一会话跨分钟仍共享指纹 ⇒ 前缀缓存按会话命中而非按分钟命中。
|
||||
local sid = rand_id("ses_", bucket, "session", session_affinity_key(meta))
|
||||
local sid = rand_id("ses_", bucket, "session")
|
||||
return {
|
||||
["User-Agent"] = adapter.headers["User-Agent"],
|
||||
["x-opencode-client"] = "cli",
|
||||
|
||||
@ -253,7 +253,7 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
if delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then
|
||||
if delta.tool_calls then
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
|
||||
@ -1,227 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// billing 的规则**完全在插件内**:以源名为键、由插件自己编译、自己写
|
||||
// `<plugin_dir>/billing.json`。内核没有 billing 代码,也不提供 billing 端点;
|
||||
// 这些判据直接打插件的导出(对应插件自己的 /api/plugins/http/billing/rules)。
|
||||
func putRules(t *testing.T, ps *Plugins, rules map[string]interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := ps.Call("billing", "rules_put", map[string]interface{}{"billing": rules})
|
||||
if err != nil {
|
||||
t.Fatalf("rules_put: %v", err)
|
||||
}
|
||||
m, _ := out.(map[string]interface{})
|
||||
return m
|
||||
}
|
||||
|
||||
func pricesOf(t *testing.T, ps *Plugins) map[string]interface{} {
|
||||
t.Helper()
|
||||
raw := ps.Prices("billing")
|
||||
p, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("billing has no prices: %#v", raw)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func oneProfile(rules []interface{}, def interface{}) map[string]interface{} {
|
||||
p := map[string]interface{}{"id": "p", "rules": rules}
|
||||
if def != nil {
|
||||
p["default"] = def
|
||||
}
|
||||
return map[string]interface{}{"active": "p", "profiles": []interface{}{p}}
|
||||
}
|
||||
|
||||
// 插件自己把规则写到 plugin_dir/billing.json,且读取回来一致。
|
||||
func TestBillingRulesPersistInPluginOwnedFile(t *testing.T) {
|
||||
ps, pdir := billingVM(t)
|
||||
rules := oneProfile([]interface{}{
|
||||
map[string]interface{}{"source": "localzen", "mode": "free"},
|
||||
}, "unpriced")
|
||||
if m := putRules(t, ps, rules); m["ok"] != true {
|
||||
t.Fatalf("rules_put 失败: %#v", m)
|
||||
}
|
||||
path := filepath.Join(pdir, "billing.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("插件没有自己写规则文件 %s: %v", path, err)
|
||||
}
|
||||
var onDisk map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &onDisk); err != nil {
|
||||
t.Fatalf("规则文件不是 JSON: %v\n%s", err, raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "localzen") {
|
||||
t.Fatalf("规则文件内容不对: %s", raw)
|
||||
}
|
||||
// 重新加载插件(模拟重启):规则应从文件恢复。
|
||||
ps2 := reloadBilling(t, pdir)
|
||||
got, err := ps2.Call("billing", "rules_get", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("rules_get: %v", err)
|
||||
}
|
||||
b, _ := json.Marshal(got)
|
||||
if !strings.Contains(string(b), "localzen") {
|
||||
t.Fatalf("重启后没有从 billing.json 恢复规则: %s", b)
|
||||
}
|
||||
// 编译结果也恢复了:free 源条目存在。
|
||||
if p := ps2.Prices("billing").(map[string]interface{}); p != nil {
|
||||
if _, ok := p["sources"].(map[string]interface{})["localzen"]; !ok {
|
||||
t.Errorf("恢复后价格表缺 localzen: %#v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reloadBilling(t *testing.T, pdir string) *Plugins {
|
||||
t.Helper()
|
||||
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("vm: %v", err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, pdir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
// 编译语义:free/subscription 写"有意零价",token 规则**不写源条目**(未列价
|
||||
// 的模型要可见地进入 unpriced),通配符只发布模型价。
|
||||
func TestBillingRulesCompileSemantics(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
rules := oneProfile([]interface{}{
|
||||
map[string]interface{}{"source": "free-src", "mode": "free"},
|
||||
map[string]interface{}{"source": "sub-src", "mode": "subscription", "subscription": 20},
|
||||
map[string]interface{}{"source": "tok-src", "mode": "token", "models": map[string]interface{}{
|
||||
"m1": map[string]interface{}{"prompt": "0.15", "completion": "0.60"},
|
||||
}},
|
||||
map[string]interface{}{"url": "*", "mode": "token", "models": map[string]interface{}{
|
||||
"m2": map[string]interface{}{"prompt": "1", "completion": "2"},
|
||||
}},
|
||||
}, nil)
|
||||
if m := putRules(t, ps, rules); m["ok"] != true {
|
||||
t.Fatalf("rules_put: %#v", m)
|
||||
}
|
||||
p := pricesOf(t, ps)
|
||||
srcs := p["sources"].(map[string]interface{})
|
||||
for _, n := range []string{"free-src", "sub-src"} {
|
||||
e, ok := srcs[n].(map[string]interface{})
|
||||
if !ok || e["prompt"] != 0.0 || e["completion"] != 0.0 {
|
||||
t.Errorf("%s 应写有意零价: %#v", n, srcs[n])
|
||||
}
|
||||
}
|
||||
if _, ok := srcs["tok-src"]; ok {
|
||||
t.Errorf("token 规则不得产生源条目: %#v", srcs["tok-src"])
|
||||
}
|
||||
if subs, ok := p["subscriptions"].(map[string]interface{})["sub-src"].(map[string]interface{}); !ok || subs["monthly"] != 20.0 {
|
||||
t.Errorf("subscription 固定费丢失: %#v", p["subscriptions"])
|
||||
}
|
||||
models := p["models"].(map[string]interface{})
|
||||
if models["m1"].(map[string]interface{})["prompt"] != 1.5e-7 {
|
||||
t.Errorf("m1 prompt = %#v, want 1.5e-7", models["m1"])
|
||||
}
|
||||
if _, ok := models["m2"]; !ok {
|
||||
t.Error("通配符规则的模型价丢失")
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:token 规则 + 1e6/1e6 tokens @ 0.15/0.60 USD/M = 0.75,
|
||||
// 且 unpriced 计数不涨(模型本身被标价)。
|
||||
func TestBillingRulesTokenCostEndToEnd(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
rules := oneProfile([]interface{}{
|
||||
map[string]interface{}{"source": "tok-src", "mode": "token", "models": map[string]interface{}{
|
||||
"m": map[string]interface{}{"prompt": "0.15", "completion": "0.60"},
|
||||
}},
|
||||
}, nil)
|
||||
if m := putRules(t, ps, rules); m["ok"] != true {
|
||||
t.Fatalf("rules_put: %#v", m)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "tok-src", "key": "***p", "ok": true,
|
||||
"prompt_tokens": 1000000.0, "completion_tokens": 1000000.0, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
approx(t, "cost", st["total"].(map[string]interface{})["cost"].(float64), 0.75)
|
||||
if got := st["unpriced_reqs"].(float64); got != 0 {
|
||||
t.Errorf("unpriced_reqs = %v, want 0", got)
|
||||
}
|
||||
// 同源未列价的模型必须落入 unpriced(可见),而不是静默计 0。
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "unlisted", "source": "tok-src", "key": "***p", "ok": true,
|
||||
"prompt_tokens": 100.0, "completion_tokens": 1.0, "time": 1750000000000,
|
||||
})
|
||||
if got := stateOf(t, ps)["unpriced_reqs"].(float64); got != 1 {
|
||||
t.Errorf("未列价模型应计入 unpriced,got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 非法规则必须被拒绝,且**不覆盖**已有文件(保存失败不能丢旧配置)。
|
||||
func TestBillingRulesRejectInvalidWithoutClobberingFile(t *testing.T) {
|
||||
ps, pdir := billingVM(t)
|
||||
good := oneProfile([]interface{}{
|
||||
map[string]interface{}{"source": "s1", "mode": "free"},
|
||||
}, nil)
|
||||
if m := putRules(t, ps, good); m["ok"] != true {
|
||||
t.Fatalf("seed: %#v", m)
|
||||
}
|
||||
path := filepath.Join(pdir, "billing.json")
|
||||
before, _ := os.ReadFile(path)
|
||||
|
||||
bad := oneProfile([]interface{}{
|
||||
map[string]interface{}{"source": "s1", "mode": "nope"},
|
||||
}, nil)
|
||||
out, err := ps.Call("billing", "rules_put", map[string]interface{}{"billing": bad})
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["error"] == nil {
|
||||
t.Fatalf("非法 mode 应被拒绝: %#v", m)
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if string(before) != string(after) {
|
||||
t.Errorf("被拒绝的保存改动了规则文件:\nbefore=%s\nafter=%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// reasoning 缺席 = 按输出价计;显式 long_context/peak 必须编译成插件读取的形状。
|
||||
func TestBillingRulesOptionalPricesCompile(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
rules := oneProfile([]interface{}{
|
||||
map[string]interface{}{"source": "s1", "mode": "token",
|
||||
"models": map[string]interface{}{
|
||||
"plain": map[string]interface{}{"prompt": "1", "completion": "2"},
|
||||
"reasoning": map[string]interface{}{"prompt": "1", "completion": "2", "reasoning": "3"},
|
||||
"long": map[string]interface{}{"prompt": "1", "completion": "2",
|
||||
"long_context": map[string]interface{}{"above": 128000, "multiplier": 2}},
|
||||
},
|
||||
"peak": map[string]interface{}{"multiplier": 2, "hours": []interface{}{[]interface{}{1, 4}}},
|
||||
},
|
||||
}, nil)
|
||||
if m := putRules(t, ps, rules); m["ok"] != true {
|
||||
t.Fatalf("rules_put: %#v", m)
|
||||
}
|
||||
models := pricesOf(t, ps)["models"].(map[string]interface{})
|
||||
if _, present := models["plain"].(map[string]interface{})["reasoning_price"]; present {
|
||||
t.Error("未配置 reasoning 不应出现 reasoning_price(0 会被当成免费)")
|
||||
}
|
||||
if got := models["reasoning"].(map[string]interface{})["reasoning_price"]; got != 3e-6 {
|
||||
t.Errorf("reasoning_price = %v, want 3e-6", got)
|
||||
}
|
||||
lc := models["long"].(map[string]interface{})["long_context"].(map[string]interface{})
|
||||
if lc["above"] != 128000.0 || lc["prompt"] != 2e-6 {
|
||||
t.Errorf("long_context = %#v", lc)
|
||||
}
|
||||
pk := models["long"].(map[string]interface{})["peak"].(map[string]interface{})
|
||||
if pk["multiplier"] != 2.0 {
|
||||
t.Errorf("peak = %#v", pk)
|
||||
}
|
||||
}
|
||||
@ -340,7 +340,7 @@ func TestBillingRuleEditorLivesInsideTheBillingPage(t *testing.T) {
|
||||
for _, needle := range []string{
|
||||
`id="br-body"`,
|
||||
"data-act='save'", "data-act='export'", "data-act='newprofile'",
|
||||
".r-src", ".r-mode",
|
||||
".r-url", ".r-mode",
|
||||
} {
|
||||
if !strings.Contains(mount, needle) {
|
||||
t.Errorf("the rule editor is missing %s", needle)
|
||||
@ -1236,136 +1236,3 @@ 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 策略:规则以**源名**为键,编译在插件内完成;
|
||||
// `default: free` 表示"没有单独规则的源一律免费"(不进 unpriced 统计),
|
||||
// `unpriced`/缺省则让这些源保持可统计;显式 mode=unpriced 的源即使
|
||||
// default=free 也必须仍被统计。
|
||||
func TestBillingProfileDefaultPolicy(t *testing.T) {
|
||||
newVM := func(t *testing.T, rules map[string]interface{}) *Plugins {
|
||||
t.Helper()
|
||||
ps, _ := billingVM(t)
|
||||
if _, err := ps.Call("billing", "rules_put", map[string]interface{}{"billing": rules}); err != nil {
|
||||
t.Fatalf("rules_put: %v", err)
|
||||
}
|
||||
return ps
|
||||
}
|
||||
fire := func(t *testing.T, ps *Plugins, source string) float64 {
|
||||
t.Helper()
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "some-unlisted-model", "source": source, "key": "***p", "ok": true,
|
||||
"prompt_tokens": 1000.0, "completion_tokens": 10.0, "time": 1750000000000,
|
||||
})
|
||||
return stateOf(t, ps)["unpriced_reqs"].(float64)
|
||||
}
|
||||
base := func(def interface{}, rules []interface{}) map[string]interface{} {
|
||||
p := map[string]interface{}{"id": "p", "rules": rules}
|
||||
if def != nil {
|
||||
p["default"] = def
|
||||
}
|
||||
return map[string]interface{}{"active": "p", "profiles": []interface{}{p}}
|
||||
}
|
||||
|
||||
// default=free:未列规则的源算"刻意免费"
|
||||
ps := newVM(t, base("free", []interface{}{
|
||||
map[string]interface{}{"source": "priced-src", "mode": "free"},
|
||||
}))
|
||||
if got := fire(t, ps, "unlisted-src"); got != 0 {
|
||||
t.Errorf("default=free: unpriced_reqs = %v, want 0", got)
|
||||
}
|
||||
// default=free 但显式 unpriced 的源仍要统计
|
||||
ps = newVM(t, base("free", []interface{}{
|
||||
map[string]interface{}{"source": "counted-src", "mode": "unpriced"},
|
||||
}))
|
||||
if got := fire(t, ps, "counted-src"); got != 1 {
|
||||
t.Errorf("显式 unpriced 在 default=free 下仍应统计,got %v", got)
|
||||
}
|
||||
// default 缺省/unpriced:未列规则的源计入 unpriced
|
||||
for _, def := range []interface{}{nil, "unpriced"} {
|
||||
ps := newVM(t, base(def, []interface{}{}))
|
||||
if got := fire(t, ps, "unlisted-src"); got != 1 {
|
||||
t.Errorf("default=%v: unpriced_reqs = %v, want 1", def, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -315,7 +315,7 @@ func TestBillingTableHeaderMatchesRowColumns(t *testing.T) {
|
||||
// headers were counted too and the check reported 17 vs 8 — a failure about
|
||||
// two unrelated tables. Stop at the rule editor's script.
|
||||
header := js[hStart:]
|
||||
if cut := strings.Index(header, "/api/plugins/http/billing/rules"); cut > 0 {
|
||||
if cut := strings.Index(header, "/api/plugins/billing/rules"); cut > 0 {
|
||||
header = header[:cut]
|
||||
}
|
||||
// Count <th> with OR without attributes: the name column now carries
|
||||
|
||||
129
internal/lua/channels_cline_workos_test.go
Normal file
129
internal/lua/channels_cline_workos_test.go
Normal file
@ -0,0 +1,129 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ P1-2:Cline 的推理端点要 `Authorization: Bearer workos:<jwt>`。
|
||||
//
|
||||
// 真实缺陷:参考实现(cline.ts)发头时直接拼 `workos:${token}`,而我们池里
|
||||
// 存的是**第二步 register 换回来的 Cline token**(裸 token,不带前缀)
|
||||
// ⇒ 之前 channels-cline 干脆没有 build_headers,内核兜底发
|
||||
// `Authorization: Bearer <裸token>` ⇒ 少了协议要求的前缀 ⇒ 上游只回泛化的
|
||||
// 认证失败(与 trae 修前同型:头发了,但形式不对)。
|
||||
//
|
||||
// 判据咬**真实产出**(vm.BuildHeaders 就是内核发请求前走的那条路径),
|
||||
// 不造 HTTP 假上游(那需要 URL 覆盖/凭据 ref/路径匹配全部对齐,任一环错
|
||||
// 都会让「没发请求」伪装成「头不对」,plan §3 第 4 条)。
|
||||
func TestClineBuildHeadersAddsWorkOSPrefix(t *testing.T) {
|
||||
vm, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !vm.HasFn("channels-cline", "build_headers") {
|
||||
t.Fatal("★ channels-cline 没有 build_headers —— 内核会兜底发裸 Bearer," +
|
||||
"而 Cline 推理端点要 `Bearer workos:<jwt>`")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tok string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "无前缀 → 补上 workos:",
|
||||
tok: "eyJhbGciOiJIUzI1NiJ9.jwt-payload.sig",
|
||||
want: "Bearer workos:eyJhbGciOiJIUzI1NiJ9.jwt-payload.sig",
|
||||
},
|
||||
{
|
||||
name: "已有 workos: 前缀 → 不重复拼",
|
||||
tok: "workos:eyJhbGciOiJIUzI1NiJ9.jwt-payload.sig",
|
||||
want: "Bearer workos:eyJhbGciOiJIUzI1NiJ9.jwt-payload.sig",
|
||||
},
|
||||
{
|
||||
name: "前缀大小写不同(WorkOS:)→ 视为已带,不重复拼",
|
||||
tok: "WorkOS:abc123",
|
||||
want: "Bearer WorkOS:abc123",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
hdrs, err := vm.BuildHeaders("channels-cline", map[string]interface{}{
|
||||
"api_key": tc.tok,
|
||||
"url": "https://api.cline.bot/api/v1/chat/completions",
|
||||
"method": "POST",
|
||||
"body": `{"model":"x"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
auth := hdrs["Authorization"]
|
||||
if auth != tc.want {
|
||||
t.Fatalf("★ Authorization 应为 %q,实得 %q\n"+
|
||||
" Cline 要 `Bearer workos:<jwt>`;缺前缀或重复拼都会认证失败",
|
||||
tc.want, auth)
|
||||
}
|
||||
// 不得出现 workos:workos: 这种重复前缀(旧数据可能已带前缀)
|
||||
if strings.Count(strings.ToLower(auth), "workos:") != 1 {
|
||||
t.Fatalf("★ workos: 前缀出现 %d 次(必须恰好 1 次): %q",
|
||||
strings.Count(strings.ToLower(auth), "workos:"), auth)
|
||||
}
|
||||
t.Logf("✓ %s → %s", tc.name, auth)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 配对判据:没有凭据时不得发出 `Bearer workos:` 这种空凭据头
|
||||
// (空凭据头会让上游回认证失败,而真因是「没配置 token」,会把人带偏)。
|
||||
func TestClineBuildHeadersWithoutToken(t *testing.T) {
|
||||
vm, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hdrs, err := vm.BuildHeaders("channels-cline", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders 不该因缺凭据报错: %v", err)
|
||||
}
|
||||
if auth, has := hdrs["Authorization"]; has {
|
||||
t.Fatalf("★ 无凭据时不得发 Authorization(会变成 `Bearer workos:`),实得 %q", auth)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 单一实现:前缀规则只在 with_workos_prefix 里写一次。
|
||||
// 判据直接咬那个函数(经适配器导出),确保 build_headers 用的是同一份逻辑 ——
|
||||
// 若哪天有人把规则复制到 build_headers 里内联、而导出版留着旧的,这条会红。
|
||||
func TestClineWorkOSPrefixHelperIsSingleSource(t *testing.T) {
|
||||
vm, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !vm.HasFn("channels-cline", "with_workos_prefix") {
|
||||
t.Fatal("★ channels-cline 应导出 with_workos_prefix 供判据咬同一份实现" +
|
||||
"(否则判据只能自算前缀规则,真实函数被改坏时会继续绿)")
|
||||
}
|
||||
// 用适配器池里真实的那份脚本再算一遍,与 build_headers 的产出对照。
|
||||
// 这里直接调 Transform 不方便(不是 transform 钩子),故用 build_headers
|
||||
// 的两种输入反推:带前缀的 token 必须**原样**出现在 Authorization 里。
|
||||
hdrs, err := vm.BuildHeaders("channels-cline", map[string]interface{}{
|
||||
"api_key": "workos:XYZ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := hdrs["Authorization"]; got != "Bearer workos:XYZ" {
|
||||
t.Fatalf("已带前缀的 token 必须原样透传,实得 %q", got)
|
||||
}
|
||||
}
|
||||
506
internal/lua/channels_fixture_shapes_general_test.go
Normal file
506
internal/lua/channels_fixture_shapes_general_test.go
Normal file
@ -0,0 +1,506 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// P3(通用版):把「夹具形状」本身变成判据,而不是逐个渠道写三条用例。
|
||||
// ============================================================================
|
||||
//
|
||||
// 为什么这条比「三个具体用例」重要(plan §3 第 1 条 / §1.4 ④):
|
||||
//
|
||||
// 本轮最大的教训**不是某个 bug**,而是「夹具形状错 ⇒ 判据必然虚假通过」。
|
||||
// trae ExchangeToken 的「只读顶层、不认 Result 信封」在两个版本的扁平夹具
|
||||
// `{"Token":...}` 下**全部判据都是绿的**,真实登录却报「缺少 Token」。
|
||||
//
|
||||
// ⇒ 判据写得再严,只要喂进去的形状不是生产形状,它就毫无意义。
|
||||
// 所以必须钉住的不是「行为」,而是「**喂进去的形状**」。
|
||||
//
|
||||
// 本文件做两层(`channels_fixture_shapes_test.go` 的三条具体用例仍在,负责
|
||||
// 行为断言;这里负责**形状本身**的守门):
|
||||
//
|
||||
// ① shapeFamilies 登记表:每族给出
|
||||
// real —— 生产原文(引自实测抓包/参考实现注释)
|
||||
// naive —— 错误的「方便测试版」
|
||||
// fact —— 对原始 JSON 的**结构事实**(real 必须满足,naive 必须不满足)
|
||||
// eval —— 用**真实实现**处理它
|
||||
// 断言:fact(real) && !fact(naive) && eval(real) 符合该族契约。
|
||||
// 注意**不比较 eval 的输出形状**:多族实现刻意保留了兼容兜底路径
|
||||
// (例如 trae 余额同时认顶层与嵌套),拿输出比对会把「合法兼容」判成错误。
|
||||
// 真正能区分真假的判据是 fact —— 它说的是「生产响应长什么样」。
|
||||
//
|
||||
// ② 仓库扫描:对**特征明确**的退化形状做静态反查(只用于不会误报的那一族:
|
||||
// 把额度挪到 pack 顶层的 `{"credits_limit":`)。其余族的精确钉法是 ①,
|
||||
// 不靠文本匹配 —— 文本匹配对「Fatalf 文案里提到 choices」这类必然误报。
|
||||
|
||||
// shapeFamily 一类真实上游形状。
|
||||
type shapeFamily struct {
|
||||
// name 族名(判据名里会带上它,便于定位)。
|
||||
name string
|
||||
// why 为什么这族形状会被「方便的夹具」写坏(写进失败信息)。
|
||||
why string
|
||||
// real 生产原文。
|
||||
real string
|
||||
// naive 错误的「方便版」。必须**不满足** fact。
|
||||
naive string
|
||||
// fact 结构事实:true 表示「这段 body 是生产形状」。
|
||||
// 只用它做形状判定,不用它替代 eval。
|
||||
fact func(raw map[string]interface{}) bool
|
||||
// eval 用真实实现处理一段 body。
|
||||
eval func(body string) (map[string]interface{}, error)
|
||||
// assert 断言 eval(real) 的结果符合该族契约(行为层)。
|
||||
assert func(t *testing.T, obs map[string]interface{})
|
||||
}
|
||||
|
||||
// shapeFamilies 是形状契约的**唯一登记处**。
|
||||
//
|
||||
// 新增渠道若出现「信封 / 自定义 SSE / 嵌套字段」这类容易被夹具写坏的形状,
|
||||
// 必须在这里加一行 —— 元判据会检查三类基线族都在,且每行都真的调了被测实现。
|
||||
func shapeFamilies(t *testing.T) []shapeFamily {
|
||||
t.Helper()
|
||||
|
||||
return []shapeFamily{
|
||||
{
|
||||
// ① Result 信封:凭据在 **Result 里**,顶层只有 ResponseMetadata。
|
||||
name: "Result 信封",
|
||||
why: "顶层看起来「也」该有 Token,于是夹具顺手写成扁平 {\"Token\":...};" +
|
||||
"实现只读顶层时判据全绿 —— 真实缺陷就是这么藏了两轮",
|
||||
real: `{"ResponseMetadata":{"RequestId":"req-1","Action":"ExchangeToken"},` +
|
||||
`"Result":{"ClientID":"cid","Token":"ACCESS-FROM-RESULT",` +
|
||||
`"RefreshToken":"RT-ROTATED","TokenExpireAt":1800000000000}}`,
|
||||
naive: `{"ResponseMetadata":{"RequestId":"req-1"},` +
|
||||
`"Token":"ACCESS-FROM-RESULT"}`,
|
||||
fact: func(raw map[string]interface{}) bool {
|
||||
// 生产形状的判据:Result 是对象且其中有 Token,**顶层没有** Token。
|
||||
r, ok := raw["Result"].(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, has := r["Token"]; !has {
|
||||
return false
|
||||
}
|
||||
_, topHas := raw["Token"]
|
||||
return !topHas
|
||||
},
|
||||
eval: func(body string) (map[string]interface{}, error) {
|
||||
_, ps := shapesProbeVM(t)
|
||||
out, err := ps.Call("channels", "__trae_exchange_parse_probe",
|
||||
map[string]interface{}{"body": body})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return probeJSON(t, out), nil
|
||||
},
|
||||
assert: func(t *testing.T, obs map[string]interface{}) {
|
||||
if obs["error"] != nil {
|
||||
t.Fatalf("★ 生产形状解析失败: %v —— 是否只读顶层、没认 Result 信封?", obs["error"])
|
||||
}
|
||||
if got, _ := obs["access"].(string); got != "ACCESS-FROM-RESULT" {
|
||||
t.Fatalf("★ access 必须来自 Result.Token,实得 %q", got)
|
||||
}
|
||||
if got, _ := obs["refresh"].(string); got != "RT-ROTATED" {
|
||||
t.Fatalf("★ 轮换后的 refresh 必须来自 Result.RefreshToken,实得 %q", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// ② SOLO 自定义 SSE:扁平帧,正文在 **response**,**没有 choices**。
|
||||
name: "SOLO 自定义 SSE",
|
||||
why: "内核按 UnifiedChunk 解**扁平**结构;夹具若照 OpenAI 写成 " +
|
||||
"{choices:[{delta}]},适配器返回外壳也会「看起来对」,而真机是 91 帧全空",
|
||||
real: `{"response":"### 分析","reasoning_content":null,"tool_calls":null}`,
|
||||
naive: `{"choices":[{"delta":{"content":"### 分析"}}]}`,
|
||||
fact: func(raw map[string]interface{}) bool {
|
||||
// 生产形状:正文在顶层 response,且**没有** choices。
|
||||
if _, has := raw["response"]; !has {
|
||||
return false
|
||||
}
|
||||
_, hasChoices := raw["choices"]
|
||||
return !hasChoices
|
||||
},
|
||||
eval: func(body string) (map[string]interface{}, error) {
|
||||
vm, _ := shapesProbeVM(t)
|
||||
out, err := vm.Transform("channels-trae", "transform_stream_chunk", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ck map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
// 适配器对非 SOLO 帧返回 "" 是合法行为,不是夹具问题。
|
||||
return map[string]interface{}{"raw": out}, nil
|
||||
}
|
||||
return ck, nil
|
||||
},
|
||||
assert: func(t *testing.T, obs map[string]interface{}) {
|
||||
if _, has := obs["choices"]; has {
|
||||
t.Fatalf("★ 不得返回 OpenAI 外壳(choices)—— 内核按 UnifiedChunk 解析: %v", obs)
|
||||
}
|
||||
if got, _ := obs["content"].(string); got != "### 分析" {
|
||||
t.Fatalf("★ SOLO 正文必须摊平到顶层 content,实得 %q(%v)", got, obs)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// ③ 嵌套 quota:额度在 **entitlement_base_info.quota.credits_limit**。
|
||||
name: "嵌套 quota",
|
||||
why: "pack 顶层**没有** credits_limit(该字段根本不存在);" +
|
||||
"夹具若照想象的形状写成 pack.credits_limit,实现读错字段也全绿。" +
|
||||
"注意实现**刻意**保留了顶层兼容路径,所以「输出形状」不能当判据 ——" +
|
||||
"能区分真假的是「生产响应里额度在哪一层」",
|
||||
real: `{"user_entitlement_pack_list":[` +
|
||||
`{"pack_type":"free","entitlement_base_info":{"quota":{"enable_free":true}}},` +
|
||||
`{"pack_type":"monthly","entitlement_base_info":{"quota":{"credits_limit":500}},"usage":{}},` +
|
||||
`{"pack_type":"checkin","entitlement_base_info":{"quota":{"credits_limit":100}},` +
|
||||
` "usage":{"credits_amount":0}}],` +
|
||||
`"usage_summary":{"total_amount":600,"consumed_amount":0}}`,
|
||||
// 退化版:额度直接挂 pack 顶层(= 旧实现假设的形状,真实上游不发)。
|
||||
naive: `{"user_entitlement_pack_list":[{"credits_limit":500},{"credits_limit":100}]}`,
|
||||
fact: func(raw map[string]interface{}) bool {
|
||||
list, ok := raw["user_entitlement_pack_list"].([]interface{})
|
||||
if !ok || len(list) == 0 {
|
||||
return false
|
||||
}
|
||||
nested := 0
|
||||
for _, it := range list {
|
||||
e, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, top := e["credits_limit"]; top {
|
||||
return false // 顶层有 credits_limit ⇒ 不是生产形状
|
||||
}
|
||||
base, ok := e["entitlement_base_info"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
q, ok := base["quota"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := q["credits_limit"]; ok {
|
||||
nested++
|
||||
}
|
||||
}
|
||||
return nested > 0
|
||||
},
|
||||
eval: func(body string) (map[string]interface{}, error) {
|
||||
_, ps := shapesProbeVM(t)
|
||||
out, err := ps.Call("channels", "__balance_total_probe",
|
||||
map[string]interface{}{"platform": "trae", "body": body})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return probeJSON(t, out), nil
|
||||
},
|
||||
assert: func(t *testing.T, obs map[string]interface{}) {
|
||||
if obs["error"] != nil {
|
||||
t.Fatalf("★ 生产形状解析失败: %v", obs["error"])
|
||||
}
|
||||
if got, _ := obs["total"].(float64); got != 600 {
|
||||
t.Fatalf("★ 余额应为 500+100=600(嵌套 entitlement_base_info.quota.credits_limit),"+
|
||||
"实得 %v —— 若为 0,检查是否又去读 pack 顶层的 credits_limit(该字段不存在)", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealUpstreamShapesArePinned 是 P3 的**通用**判据。
|
||||
//
|
||||
// 每族三重断言:
|
||||
// 1. fact(real) —— 生产原文**确实是**该族形状(防止有人把「生产原文」写错)
|
||||
// 2. !fact(naive) —— 退化形状**确实不是**该族形状(证明两者可区分)
|
||||
// 3. assert(eval(real)) —— 真实实现能吃下生产形状
|
||||
//
|
||||
// 第 1、2 条是关键:它们证明「判据在解结构」,而不是「碰巧对任何输入都成功」。
|
||||
func TestRealUpstreamShapesArePinned(t *testing.T) {
|
||||
fams := shapeFamilies(t)
|
||||
if len(fams) < 3 {
|
||||
t.Fatalf("★ 形状契约至少要覆盖 3 族(Result 信封 / SOLO SSE / 嵌套 quota),实得 %d", len(fams))
|
||||
}
|
||||
for _, f := range fams {
|
||||
t.Run(f.name, func(t *testing.T) {
|
||||
if f.real == "" || f.naive == "" || f.fact == nil || f.eval == nil || f.assert == nil {
|
||||
t.Fatalf("族 %q 登记不完整(real/naive/fact/eval/assert 全必填)—— "+
|
||||
"只钉生产形状无法证明判据真的在解结构", f.name)
|
||||
}
|
||||
|
||||
// ① 生产原文必须真的满足该族的结构事实
|
||||
realRaw := mustJSONObject(t, f.real, f.name, "real")
|
||||
if !f.fact(realRaw) {
|
||||
t.Fatalf("★ 族 %q:登记的「生产原文」不满足结构事实 —— "+
|
||||
"要么原文抄错了,要么 fact 写错了(两者都会让判据失去意义):\n %s", f.name, f.real)
|
||||
}
|
||||
// ② 退化形状必须不满足(证明 fact 能区分,不是恒真)
|
||||
naiveRaw := mustJSONObject(t, f.naive, f.name, "naive")
|
||||
if f.fact(naiveRaw) {
|
||||
t.Fatalf("★ 族 %q:退化形状也满足结构事实 ⇒ fact 是恒真式,"+
|
||||
"它区分不了真假夹具(这正是「夹具形状错⇒判据虚假通过」的机制):\n %s",
|
||||
f.name, f.naive)
|
||||
}
|
||||
|
||||
// ③ 真实实现必须能吃下生产形状,且行为符合契约
|
||||
obs, err := f.eval(f.real)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: 生产形状求值失败: %v", f.name, err)
|
||||
}
|
||||
f.assert(t, obs)
|
||||
|
||||
// ④ 退化形状**不得**被当成同一族的成功解析:要么报错,要么明确
|
||||
// 走了兼容路径(此时 fact 已经证明它不是生产形状)。
|
||||
// 这里只记录,不做断言 —— 兼容路径是刻意的(见 why)。
|
||||
naiveObs, naiveErr := f.eval(f.naive)
|
||||
t.Logf("✓ %s: real→%v | naive→(err=%v, obs=%v)", f.name, obs, naiveErr, naiveObs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSONObject(t *testing.T, s, fam, which string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var v map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
t.Fatalf("族 %q 的 %s 形状不是合法 JSON 对象: %v\n %s", fam, which, err, s)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// TestShapeFamilyTableIsHonest 元判据:登记表本身不许糊弄。
|
||||
//
|
||||
// ① 三类基线族必须在(Result 信封 / SOLO SSE / 嵌套 quota);
|
||||
// ② 每族的 eval 必须真的打到被测实现 —— 给一段垃圾 body 时它必须有反应
|
||||
// (报错,或产出与生产形状不同的观测)。恒等函数会在这里被抓住。
|
||||
func TestShapeFamilyTableIsHonest(t *testing.T) {
|
||||
fams := shapeFamilies(t)
|
||||
want := []string{"Result 信封", "SOLO 自定义 SSE", "嵌套 quota"}
|
||||
got := map[string]bool{}
|
||||
for _, f := range fams {
|
||||
got[f.name] = true
|
||||
}
|
||||
for _, w := range want {
|
||||
if !got[w] {
|
||||
t.Fatalf("★ 形状表缺基线族 %q —— 三类真实形状必须持续被钉住(plan §1.4 ④)", w)
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range fams {
|
||||
realObs, err := f.eval(f.real)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: 生产形状求值失败: %v", f.name, err)
|
||||
}
|
||||
junkObs, junkErr := f.eval(`<<<not json at all>>>`)
|
||||
if junkErr == nil && samePrimitives(realObs, junkObs) {
|
||||
t.Fatalf("★ 族 %q 的 eval 对垃圾输入与生产输入返回相同观测 ⇒ "+
|
||||
"它不是真的在调被测实现(恒等判据毫无意义)", f.name)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 形状表 %d 族,基线齐全且 eval 真的打到被测实现", len(fams))
|
||||
}
|
||||
|
||||
// samePrimitives 只比较"标量键值都相同",用于检测恒等实现。
|
||||
func samePrimitives(a, b map[string]interface{}) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, av := range a {
|
||||
bv, ok := b[k]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch x := av.(type) {
|
||||
case string:
|
||||
if y, ok := bv.(string); !ok || x != y {
|
||||
return false
|
||||
}
|
||||
case float64:
|
||||
if y, ok := bv.(float64); !ok || x != y {
|
||||
return false
|
||||
}
|
||||
case bool:
|
||||
if y, ok := bv.(bool); !ok || x != y {
|
||||
return false
|
||||
}
|
||||
case nil:
|
||||
if bv != nil {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ② 仓库扫描:只对**特征明确**的退化形状做静态反查
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// 为什么只留一条规则:文本匹配对另外两族**必然误报** ——
|
||||
// · SOLO:判据自己会写 `t.Fatalf("不得返回 choices …")`,天然含 choices;
|
||||
// · Result:真夹具里 `"Result":{…,"Token":…}` 与顶层 `"Token":` 是同一行。
|
||||
// 那两族的精确钉法是上面的 fact 判据(对原始 JSON 结构判定),不是文本。
|
||||
//
|
||||
// 剩下这一条规则的特征是**不会误报**的:把额度写到 pack 顶层必然写成
|
||||
// `{"credits_limit":`(花括号紧跟键名),而生产形状一定写成
|
||||
// `"quota":{"credits_limit":`(键名前是 `"quota":`)。
|
||||
|
||||
// degenerateFixturePattern 一条"退化夹具"特征。
|
||||
type degenerateFixturePattern struct {
|
||||
name string
|
||||
trigger string // 文件里出现它 ⇒ 该文件在测这个渠道
|
||||
forbidden string // 出现它 ⇒ 疑似把生产形状改成了方便版
|
||||
why string
|
||||
}
|
||||
|
||||
var degenerateFixturePatterns = []degenerateFixturePattern{
|
||||
{
|
||||
name: "trae 余额(嵌套 quota)",
|
||||
trigger: "user_entitlement_pack_list",
|
||||
forbidden: `{"credits_limit":`,
|
||||
why: "生产把额度放在 entitlement_base_info.quota.credits_limit(pack 顶层" +
|
||||
"**没有**该字段,实测上游从不发);`{\"credits_limit\":` 是把额度挪到顶层的" +
|
||||
"退化形状,会让「读错层级」这个缺陷继续绿(余额恒 0 就是这么来的)",
|
||||
},
|
||||
}
|
||||
|
||||
// TestNoDegenerateFixturesInRepo 扫 internal/**/*_test.go,任何"可疑退化夹具"
|
||||
// 都必须带 `// SHAPE-OK: <理由>` 显式豁免。
|
||||
//
|
||||
// 为什么扫描而不是只钉三条用例:三条用例只能钉住**它们自己**用的夹具;
|
||||
// 下一次有人在**别的**测试里写一个退化夹具,那三条照样绿。这条扫描对着
|
||||
// 整个仓库,是「防再犯」的那一半。
|
||||
func TestNoDegenerateFixturesInRepo(t *testing.T) {
|
||||
root, err := repoInternalDir()
|
||||
if err != nil {
|
||||
t.Fatalf("定位 internal/ 失败: %v", err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
err = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(p, "_test.go") {
|
||||
files = append(files, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("遍历 internal/: %v", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatal("没扫到任何 _test.go —— 扫描失效(这本身比告警更糟)")
|
||||
}
|
||||
|
||||
var hits []string
|
||||
for _, f := range files {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rel, _ := filepath.Rel(root, f)
|
||||
// 本文件自身在描述规则(含特征字面量),跳过。
|
||||
if strings.HasSuffix(f, "channels_fixture_shapes_general_test.go") {
|
||||
continue
|
||||
}
|
||||
code := stripGoComments(string(b))
|
||||
for _, pat := range degenerateFixturePatterns {
|
||||
if !strings.Contains(code, pat.trigger) {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(code, pat.forbidden) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(b), "SHAPE-OK:") {
|
||||
continue // 显式豁免,理由写在文件里
|
||||
}
|
||||
hits = append(hits, rel+" ["+pat.name+"] 含 "+pat.forbidden)
|
||||
}
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
t.Fatalf("★ 疑似退化夹具(形状与生产不一致 ⇒ 判据必然虚假通过):\n %s\n\n"+
|
||||
"若这**确实**是在测兼容/兜底路径,请在文件里加一行\n"+
|
||||
" // SHAPE-OK: <为什么这个退化形状是刻意的>\n"+
|
||||
"历史代价:trae 的 Result 信封缺陷就是这样藏了两轮(plan §1.4 ④)。\n"+
|
||||
"规则理由:\n%s", strings.Join(hits, "\n "), whyList())
|
||||
}
|
||||
t.Logf("✓ 扫描 %d 个 _test.go,无未豁免的退化夹具(%d 条特征规则)",
|
||||
len(files), len(degenerateFixturePatterns))
|
||||
}
|
||||
|
||||
func whyList() string {
|
||||
var b strings.Builder
|
||||
for _, p := range degenerateFixturePatterns {
|
||||
b.WriteString(" · " + p.name + ": " + p.why + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// stripGoComments 去掉 `//` 行注释与 `/* */` 块注释。
|
||||
//
|
||||
// 只做够用的一层:不解析字符串字面量里的 `//`(例如 URL "https://…")。
|
||||
// 略微保守(可能少剥一点)比误剥安全 —— 误剥会让扫描漏报,少剥只会多报,
|
||||
// 而多报可以由 SHAPE-OK 显式豁免。
|
||||
func stripGoComments(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
lines := strings.Split(s, "\n")
|
||||
inBlock := false
|
||||
for _, line := range lines {
|
||||
if inBlock {
|
||||
if i := strings.Index(line, "*/"); i >= 0 {
|
||||
inBlock = false
|
||||
line = line[i+2:]
|
||||
} else {
|
||||
b.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "//") {
|
||||
b.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
if i := strings.Index(line, "/*"); i >= 0 && !strings.Contains(line[:i], `"`) {
|
||||
if j := strings.Index(line[i:], "*/"); j >= 0 {
|
||||
line = line[:i] + line[i+j+2:]
|
||||
} else {
|
||||
line = line[:i]
|
||||
inBlock = true
|
||||
}
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// repoInternalDir 从测试工作目录(internal/lua)向上找 internal/。
|
||||
func repoInternalDir() (string, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for d := wd; d != "/" && d != ""; d = filepath.Dir(d) {
|
||||
if filepath.Base(d) == "internal" {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
return filepath.Dir(wd), nil
|
||||
}
|
||||
|
||||
// shapesProbeVM 提供形状判据共用的插件 VM(插件自装适配器需要它)。
|
||||
//
|
||||
// 用 zcodeHarness 而不是 runtimePlugins:后者给 adapters/plugins 各一个独立
|
||||
// 临时目录,而 installZcodeAdapter 要求两者是兄弟目录(见 zcodeHarness 注释)。
|
||||
func shapesProbeVM(t *testing.T) (*VM, *Plugins) {
|
||||
t.Helper()
|
||||
return zcodeHarness(t)
|
||||
}
|
||||
318
internal/lua/channels_fixture_shapes_test.go
Normal file
318
internal/lua/channels_fixture_shapes_test.go
Normal file
@ -0,0 +1,318 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★ P3:夹具形状防回归 —— 钉住三类**真实上游形状**。
|
||||
//
|
||||
// 本轮最大的教训不是某个 bug,而是「**夹具形状错 ⇒ 判据必然虚假通过**」:
|
||||
// trae 的 ExchangeToken 缺陷(只读顶层、不认 `Result` 信封)在两个版本的
|
||||
// 扁平夹具 `{"Token":...}` 下**全部判据都是绿的**,真实登录却报「缺少 Token」。
|
||||
// 也就是说:判据写得再严,只要它喂进去的形状不是生产形状,它就毫无意义。
|
||||
//
|
||||
// 因此这里不只钉行为,还要钉**形状**本身,且钉在**真实的解析器/转换器**上
|
||||
// (经插件探针调用同一份实现),而不是在 Go 侧重写一遍解包逻辑 ——
|
||||
// 自算版会在真实函数被改坏时继续绿(plan §3 第 4 条踩过三轮)。
|
||||
//
|
||||
// 三类形状(依据 docs/channel-repair-plan.md §1.4 ④ 与 §3 第 1 条):
|
||||
// ① Result 信封 —— trae ExchangeToken:凭据在 `Result` 里,顶层没有
|
||||
// ② SOLO 自定义 SSE —— trae 流式:`{"response":...}` 扁平帧,无 choices
|
||||
// ③ 嵌套 quota —— trae 余额:`pack.entitlement_base_info.quota.credits_limit`
|
||||
//
|
||||
// 任一形状被"改回方便测试的样子"(扁平/OpenAI 外壳/顶层字段),本判据立刻报红。
|
||||
|
||||
// TestFixtureShapeResultEnvelope ①:Result 信封必须被真实解析器认出来。
|
||||
//
|
||||
// 真实上游(2026-10-17 实测,HTTP 200):
|
||||
//
|
||||
// {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}}
|
||||
//
|
||||
// 顶层**只有** ResponseMetadata + Result、没有 Token。若夹具写成扁平
|
||||
// `{"Token":...}`,实现里「只读顶层」的错误会被完美掩盖 —— 这正是它藏了两轮的原因。
|
||||
func TestFixtureShapeResultEnvelope(t *testing.T) {
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 真实形状:凭据只在 Result 里
|
||||
real := `{"ResponseMetadata":{"RequestId":"req-1","Action":"ExchangeToken"},` +
|
||||
`"Result":{"ClientID":"cid","Token":"ACCESS-FROM-RESULT",` +
|
||||
`"RefreshToken":"RT-ROTATED","TokenExpireAt":1800000000000,` +
|
||||
`"TokenExpireDuration":1209600000,"RefreshExpireAt":1807755929432}}`
|
||||
out, err := ps.Call("channels", "__trae_exchange_parse_probe", map[string]interface{}{
|
||||
"body": real,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
j := probeJSON(t, out)
|
||||
if j["error"] != nil {
|
||||
t.Fatalf("★ 真实 Result 信封解析失败: %v —— 检查是否只读顶层", j["error"])
|
||||
}
|
||||
if got, _ := j["access"].(string); got != "ACCESS-FROM-RESULT" {
|
||||
t.Fatalf("★ access 必须来自 Result.Token,实得 %q", got)
|
||||
}
|
||||
if got, _ := j["refresh"].(string); got != "RT-ROTATED" {
|
||||
t.Fatalf("★ 轮换后的 refresh 必须来自 Result.RefreshToken,实得 %q", got)
|
||||
}
|
||||
|
||||
// 反例:把这个形状的 Result 剥掉(= 旧的扁平夹具),解析器必须**报错**而不是
|
||||
// 悄悄返回空 —— 否则"缺 Token"永远被误报成成功。
|
||||
flat := `{"ResponseMetadata":{"RequestId":"req-1"},"Token":"SHOULD-NOT-RESOLVE"}`
|
||||
out2, err := ps.Call("channels", "__trae_exchange_parse_probe", map[string]interface{}{
|
||||
"body": flat,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
j2 := probeJSON(t, out2)
|
||||
// 注意:顶层 Token 是**兼容兜底**(上游可能改版),所以这里允许成功;
|
||||
// 但要钉住「Result 缺失时不会崩、也不会把 ResponseMetadata 当 Result」。
|
||||
if j2["error"] == nil {
|
||||
if got, _ := j2["access"].(string); got != "SHOULD-NOT-RESOLVE" {
|
||||
t.Fatalf("顶层兜底路径应取顶层 Token,实得 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 真正必须报错的形状:既无 Result.Token 也无顶层 Token
|
||||
bad := `{"ResponseMetadata":{"RequestId":"req-1"},"Result":{"ClientID":"cid"}}`
|
||||
out3, err := ps.Call("channels", "__trae_exchange_parse_probe", map[string]interface{}{
|
||||
"body": bad,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
j3 := probeJSON(t, out3)
|
||||
if j3["error"] == nil {
|
||||
t.Fatalf("★ 无 Token 的响应必须报错(否则「缺少 Token」会被误判成成功): %v", j3)
|
||||
}
|
||||
t.Logf("✓ Result 信封: access=%v refresh=%v;缺 Token 报错=%v",
|
||||
j["access"], j["refresh"], j3["error"])
|
||||
}
|
||||
|
||||
// TestFixtureShapeSoloSSE ②:trae 的 SOLO 自定义 SSE 必须被摊平成扁平 UnifiedChunk。
|
||||
//
|
||||
// 真实上游帧(参考 trae.ts SOLO):
|
||||
//
|
||||
// event: output
|
||||
// data: {"response":"###","reasoning_content":null,"tool_calls":null}
|
||||
//
|
||||
// 内核只把 `data:` 行交给适配器(event 行在 provider.go 丢弃),且
|
||||
// `json.Unmarshal(unified, &UnifiedChunk)` 解的是**扁平结构** ——
|
||||
// 返回 OpenAI 外壳 `{choices:[{delta}]}` 会被整体丢弃(91 帧全空)。
|
||||
func TestFixtureShapeSoloSSE(t *testing.T) {
|
||||
vm, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 真实正文帧(注意:`reasoning_content` / `tool_calls` 显式 null,不是缺失)
|
||||
frames := []struct {
|
||||
name string
|
||||
frame string
|
||||
wantKey string
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "正文帧",
|
||||
frame: `{"response":"### 分析","reasoning_content":null,"tool_calls":null}`,
|
||||
wantKey: "content",
|
||||
wantText: "### 分析",
|
||||
},
|
||||
{
|
||||
name: "推理帧",
|
||||
frame: `{"response":"","reasoning_content":"We need","tool_calls":null}`,
|
||||
wantKey: "reasoning_content",
|
||||
wantText: "We need",
|
||||
},
|
||||
}
|
||||
for _, f := range frames {
|
||||
out, err := vm.Transform("channels-trae", "transform_stream_chunk", f.frame)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: transform 失败: %v", f.name, err)
|
||||
}
|
||||
var ck map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("%s: 产出不是 JSON: %q", f.name, out)
|
||||
}
|
||||
// ★ 形状一:必须是**扁平**(顶层 content),不能是 OpenAI 外壳
|
||||
if _, has := ck["choices"]; has {
|
||||
t.Fatalf("★ %s 不得返回 OpenAI 外壳(choices)—— 内核按 UnifiedChunk 解析,"+
|
||||
"外壳字段会被全部丢弃: %s", f.name, out)
|
||||
}
|
||||
got, _ := ck[f.wantKey].(string)
|
||||
if got != f.wantText {
|
||||
t.Fatalf("★ %s 应摊平到顶层 %s=%q,实得 %q(原始产出 %s)",
|
||||
f.name, f.wantKey, f.wantText, got, out)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ SOLO SSE 扁平形状正确(content / reasoning_content 均在顶层)")
|
||||
}
|
||||
|
||||
// TestFixtureShapeNestedQuota ③:trae 余额在**两层嵌套**里,不在 pack 顶层。
|
||||
//
|
||||
// 真实上游(2026-10-17 实测):
|
||||
//
|
||||
// pack[0] 免费 → entitlement_base_info.quota 无 credits_limit
|
||||
// pack[1] 每月登录赠送 → entitlement_base_info.quota.credits_limit = 500
|
||||
// pack[2] 签到奖励 → entitlement_base_info.quota.credits_limit = 100
|
||||
// usage_summary: {total_amount:600, consumed_amount:0}
|
||||
//
|
||||
// 旧实现读 `pack.credits_limit`(**根本不存在的字段**)⇒ 余额恒 0,
|
||||
// 而账号里其实有 600 —— 用户看到「额度显示异常」。
|
||||
func TestFixtureShapeNestedQuota(t *testing.T) {
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 真实形状:额度在 entitlement_base_info.quota 里(含一个无额度的免费包)
|
||||
real := `{"user_entitlement_pack_list":[` +
|
||||
`{"pack_type":"free","entitlement_base_info":{"quota":{"enable_free":true}}},` +
|
||||
`{"pack_type":"monthly","entitlement_base_info":{"quota":{"credits_limit":500}},` +
|
||||
` "usage":{}},` +
|
||||
`{"pack_type":"checkin","entitlement_base_info":{"quota":{"credits_limit":100}},` +
|
||||
` "usage":{"credits_amount":0}}` +
|
||||
`],"usage_summary":{"total_amount":600,"consumed_amount":0}}`
|
||||
out, err := ps.Call("channels", "__balance_total_probe", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"body": real,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
j := probeJSON(t, out)
|
||||
if j["error"] != nil {
|
||||
t.Fatalf("★ 嵌套 quota 解析失败: %v", j["error"])
|
||||
}
|
||||
if got, _ := j["total"].(float64); got != 600 {
|
||||
t.Fatalf("★ 余额应为 500+100=600(嵌套 entitlement_base_info.quota.credits_limit),"+
|
||||
"实得 %v —— 若为 0,检查是否又去读了 pack 顶层的 credits_limit(该字段不存在)",
|
||||
j["total"])
|
||||
}
|
||||
|
||||
// 反例:把额度放到**pack 顶层**(= 旧实现假设的形状)。真实上游不这么发,
|
||||
// 但实现保留了兼容路径 —— 这里钉住的是「嵌套路径是权威」,同时确认
|
||||
// 顶层兼容不会把数字算错(不会重复累加)。
|
||||
//
|
||||
// SHAPE-OK: 这段**刻意**用退化形状(额度挂 pack 顶层),因为它要测的正是
|
||||
// 兼容兵底路径。生产形状由上面的嵌套夹具铉住;本文件里“生产/退化”两种
|
||||
// 形状成对出现,才是“实现能区分两者”的证据。
|
||||
// (若删掉这句,TestNoDegenerateFixturesInRepo 会报红 —— 这是故意的:
|
||||
// 它强迫每一个退化夹具显式声明自己是刻意的。)
|
||||
topLevel := `{"user_entitlement_pack_list":[{"credits_limit":50}]}`
|
||||
out2, err := ps.Call("channels", "__balance_total_probe", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"body": topLevel,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
j2 := probeJSON(t, out2)
|
||||
if j2["error"] != nil {
|
||||
t.Fatalf("顶层兼容路径不该报错: %v", j2["error"])
|
||||
}
|
||||
if got, _ := j2["total"].(float64); got != 50 {
|
||||
t.Fatalf("顶层兼容路径应算 50,实得 %v", j2["total"])
|
||||
}
|
||||
|
||||
// 已用额度必须扣减(usage.credits_amount 也是嵌套的)
|
||||
consumed := `{"user_entitlement_pack_list":[` +
|
||||
`{"entitlement_base_info":{"quota":{"credits_limit":500}},` +
|
||||
` "usage":{"credits_amount":120}}]}`
|
||||
out3, err := ps.Call("channels", "__balance_total_probe", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"body": consumed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针调用失败: %v", err)
|
||||
}
|
||||
j3 := probeJSON(t, out3)
|
||||
if got, _ := j3["total"].(float64); got != 380 {
|
||||
t.Fatalf("★ 余额应为 500-120=380(usage.credits_amount 是嵌套的),实得 %v", j3["total"])
|
||||
}
|
||||
t.Logf("✓ 嵌套 quota: 600 / 扣减后 380 / 顶层兼容 50")
|
||||
}
|
||||
|
||||
// probeJSON 取探针返回的 {json:{...}} 那层。
|
||||
func probeJSON(t *testing.T, got interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针返回值不是对象: %#v", got)
|
||||
}
|
||||
j, ok := m["json"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针返回值缺 json 层: %#v", m)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
// TestFixtureShapeGuardCoversAllThree 元判据:上三条**真的存在且真的咬到了
|
||||
// 被测对象**。这是防「有人删掉形状判据」或「把判据改成不调真实解析器」的兜底。
|
||||
//
|
||||
// 它读自己的源码文本(同目录下),断言:
|
||||
// ① 三个形状判据函数都在;
|
||||
// ② 它们**都调用了**插件探针(而不是自己另写一份解包逻辑 ——
|
||||
// 那样真实函数被改坏时判据会继续绿,这正是 trae 藏了两轮的原因)。
|
||||
func TestFixtureShapeGuardCoversAllThree(t *testing.T) {
|
||||
self, err := os.ReadFile("channels_fixture_shapes_test.go")
|
||||
if err != nil {
|
||||
t.Fatalf("读不到自身源码(元判据靠它验证三条形状判据是否还在): %v", err)
|
||||
}
|
||||
s := string(self)
|
||||
|
||||
// ① 三个形状判据必须在
|
||||
for _, fn := range []string{
|
||||
"TestFixtureShapeResultEnvelope",
|
||||
"TestFixtureShapeSoloSSE",
|
||||
"TestFixtureShapeNestedQuota",
|
||||
} {
|
||||
if !strings.Contains(s, "func "+fn+"(") {
|
||||
t.Fatalf("★ 形状判据 %s 不见了 —— 三类真实形状必须持续被钉住", fn)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 三条判据必须真的调用探针/真实适配器(不能退化成读源码字符串断言)
|
||||
// Result 信封 / 嵌套 quota → 探针(同一个真实解析器)
|
||||
// SOLO SSE → 已装载的 channels-trae 适配器
|
||||
for _, anchor := range []string{
|
||||
`__trae_exchange_parse_probe`, // ① Result 信封
|
||||
`__balance_total_probe`, // ③ 嵌套 quota
|
||||
`vm.Transform("channels-trae", "transform_stream_chunk"`, // ② SOLO SSE
|
||||
} {
|
||||
if !strings.Contains(s, anchor) {
|
||||
t.Fatalf("★ 形状判据不再直接咬真实实现(缺 %s)—— "+
|
||||
"自算版会在真实函数被改坏时继续绿", anchor)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 探针必须真的在插件里导出(否则上面两条会在运行期变成"探针不存在")
|
||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, probe := range []string{"__trae_exchange_parse_probe", "__balance_total_probe"} {
|
||||
if !strings.Contains(string(src), probe) {
|
||||
t.Fatalf("★ 插件缺探针 %s —— 形状判据将无从咬真实解析器", probe)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 三类形状判据齐全,且均直接咬真实实现")
|
||||
}
|
||||
91
internal/lua/channels_gemini_auth_check_test.go
Normal file
91
internal/lua/channels_gemini_auth_check_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ P1-3(**只确认不改**):channels-gemini 的「静态身份五头 + 内核兜底 Bearer」
|
||||
// 本来就是**对的**,不需要像 zcode 那样补 x-api-key。
|
||||
//
|
||||
// 这是本轮唯一一个"看起来同型、实际不是缺陷"的渠道,所以把它钉成判据,
|
||||
// 免得下一轮有人照 P1-1 的模子给它也改一遍(那会把它弄坏)。
|
||||
//
|
||||
// 证据链(两条独立来源一致):
|
||||
//
|
||||
// ① 参考实现 vendor/channel-pack/src/gemini.ts:353-366 geminiHeaders()
|
||||
// headers.set('Authorization', `Bearer ${credential.access_token}`)
|
||||
// 再逐字注入 GEMINI_IDENTITY_HEADERS 五头
|
||||
// 并**刻意不注入** x-goog-api-key(源注释:原版不带)
|
||||
// ② 参考 src/gemini.ts:110-117 的 GEMINI_IDENTITY_HEADERS 与我们
|
||||
// adapter.headers 的五头逐字相同(UA / x-client-name / x-client-version /
|
||||
// x-machine-id / x-vscode-sessionid)
|
||||
//
|
||||
// 也就是说:gemini 走的是 **OAuth Bearer**(Cloud Code 凭据),不是
|
||||
// Anthropic 那种 x-api-key,也不是 Cline 那种 workos: 前缀。它的鉴权
|
||||
// 由内核兜底 `Authorization: Bearer <api_key>` 提供 —— 而 gemini 未声明
|
||||
// credential_shape ⇒ api_key = 凭据里的 access_token ⇒ 兜底值正确。
|
||||
//
|
||||
// ⇒ **结论:不改。** 本判据把这个结论钉住:一旦有人给 channels-gemini 加上
|
||||
//
|
||||
// build_headers 发别的鉴权头(例如照 zcode 改成 x-api-key),这里会红。
|
||||
func TestChannelsGeminiAuthIsBearerByDesign(t *testing.T) {
|
||||
vm, _ := zcodeHarness(t)
|
||||
|
||||
// ① 适配器不该自己发鉴权头:静态头里**不得**出现 Authorization /
|
||||
// x-api-key / x-goog-api-key。鉴权交给内核兜底 Bearer。
|
||||
hdrs, err := vm.BuildHeaders("channels-gemini", map[string]interface{}{
|
||||
"api_key": "GA-OAUTH-TOKEN",
|
||||
"url": "https://cloudcode-pa.googleapis.com/v1internal:generateContent",
|
||||
"method": "POST",
|
||||
"body": `{}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
for _, forbidden := range []string{"Authorization", "x-api-key", "x-goog-api-key"} {
|
||||
if v, has := hdrs[forbidden]; has {
|
||||
t.Fatalf("★ channels-gemini 不该自己发 %s(实得 %q)—— "+
|
||||
"Cloud Code 走 OAuth Bearer,由内核兜底发;"+
|
||||
"自己发会让它与兜底冲突或改变鉴权方案(P1-3 结论是「不改」)",
|
||||
forbidden, v)
|
||||
}
|
||||
}
|
||||
// ② 身份五头必须还在(它们是准入条件,与鉴权无关)
|
||||
for k, want := range map[string]string{
|
||||
"User-Agent": "antigravity/4.3.0 (cmdc-pak)",
|
||||
"x-client-name": "antigravity",
|
||||
"x-client-version": "4.3.0",
|
||||
"x-machine-id": "cmdc-pak",
|
||||
"x-vscode-sessionid": "proxy",
|
||||
} {
|
||||
if got := hdrs[k]; got != want {
|
||||
t.Fatalf("身份头 %s 应为 %q,实得 %q(参考 GEMINI_IDENTITY_HEADERS 逐字)", k, want, got)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ channels-gemini 只发身份五头、不自带鉴权(兜底 Bearer 即正确): %v", keysOf(hdrs))
|
||||
}
|
||||
|
||||
// 配对判据:经**内核**装出来的头必须真的是 `Authorization: Bearer <token>`。
|
||||
//
|
||||
// 这条在 internal/provider 侧另有一条同型判据(build_headers_fallback_test.go),
|
||||
// 这里从「适配器没抢鉴权」推到「兜底确实补上了」,把 P1-3 的结论补成闭环。
|
||||
func TestChannelsGeminiKernelFallbackSendsBearer(t *testing.T) {
|
||||
vm, _ := zcodeHarness(t)
|
||||
|
||||
// 适配器产出:不含任何鉴权头(见上一条)
|
||||
hdrs, err := vm.BuildHeaders("channels-gemini", map[string]interface{}{
|
||||
"api_key": "GA-OAUTH-TOKEN",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, has := hdrs["Authorization"]; has {
|
||||
t.Fatalf("适配器不该发 Authorization,实得 %v", hdrs)
|
||||
}
|
||||
// 于是内核兜底条件成立(Authorization 与 x-api-key 都为空)⇒ 会补 Bearer。
|
||||
// 这里断言**条件成立**,而不是重复实现兜底逻辑(兜底本身由 provider 判据咬)。
|
||||
if hdrs["x-api-key"] != "" {
|
||||
t.Fatalf("x-api-key 必须为空(否则内核兜底不会补 Bearer),实得 %v", hdrs)
|
||||
}
|
||||
t.Log("✓ 适配器不装鉴权 ⇒ 内核兜底条件成立 ⇒ 发 Authorization: Bearer <access_token>")
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -72,411 +72,6 @@ local DEFAULT_PRICES = {
|
||||
}
|
||||
plugin.prices = DEFAULT_PRICES
|
||||
|
||||
-- ===== 规则 → 价格表编译(全部在插件内) ==============================
|
||||
--
|
||||
-- billing 没有任何内核逻辑(issue #1 的方向):规则文件、编译、计价、账目、
|
||||
-- 规则编辑页全部由本插件负责。内核既不知道 billing.json,也不知道
|
||||
-- free/token/subscription 这些语义 —— 它只做通用的"加载插件 / 持久化插件状态 /
|
||||
-- 提供通用 HTTP 与文件能力"。
|
||||
--
|
||||
-- 规则以**源名**(`source`)为键,而不是 URL:URL→源名的映射属于内核配置,
|
||||
-- 插件不该为了它去和内核对接;而编辑页跑在浏览器里,可以直接取 /api/sources
|
||||
-- 让运营选源名。`url: "*"` 只保留为"全局模型价目表"(模型维度本来就不分源)。
|
||||
local VALID_MODES = { free = true, token = true, subscription = true, unpriced = true }
|
||||
|
||||
local function trim(v)
|
||||
local s = tostring(v == nil and "" or v)
|
||||
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
-- USD per million → USD per token.
|
||||
local function usdPerM(v)
|
||||
local n = tonumber(trim(v))
|
||||
if n == nil then return nil, "not a number: \"" .. tostring(v) .. "\"" end
|
||||
if n < 0 then return nil, "negative price: \"" .. tostring(v) .. "\"" end
|
||||
return n / 1e6, nil
|
||||
end
|
||||
|
||||
local function compilePeak(pk)
|
||||
local win = {}
|
||||
if type(pk.weekdays) == "table" and #pk.weekdays > 0 then
|
||||
local days = {}
|
||||
for i, d in ipairs(pk.weekdays) do days[i] = d end
|
||||
win.days = days
|
||||
end
|
||||
if type(pk.hours) == "table" and #pk.hours > 0 then
|
||||
local hours = {}
|
||||
for i, h in ipairs(pk.hours) do hours[i] = { h[1], h[2] } end
|
||||
win.hours = hours
|
||||
end
|
||||
local out = { multiplier = tonumber(pk.multiplier) or 1 }
|
||||
if next(win) ~= nil then out.windows = { win } end
|
||||
return out
|
||||
end
|
||||
|
||||
local function longContextRate(lc, basePerToken)
|
||||
local prompt = trim(lc.prompt)
|
||||
local mult = tonumber(lc.multiplier) or 0
|
||||
if prompt ~= "" then
|
||||
local v, err = usdPerM(prompt)
|
||||
if not v then return nil, err end
|
||||
return v, nil
|
||||
end
|
||||
if mult > 0 then return basePerToken * mult, nil end
|
||||
return nil, "needs either prompt or multiplier"
|
||||
end
|
||||
|
||||
-- 一条规则作用于哪个源。`source` 是正式写法;`url` 若不是 "*",仅在它恰好
|
||||
-- 等于源名时兼容(旧配置迁移用)。
|
||||
local function ruleSource(rule)
|
||||
local src = trim(rule.source)
|
||||
if src ~= "" then return src end
|
||||
local u = trim(rule.url)
|
||||
if u ~= "" and u ~= "*" then return u end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function applyRule(prices, rule, srcName)
|
||||
local mode = rule.mode
|
||||
if mode == "free" or mode == "subscription" then
|
||||
prices.sources[srcName] = { prompt = 0, completion = 0, per_request = 0 }
|
||||
if mode == "subscription" then
|
||||
if prices.subscriptions == nil then prices.subscriptions = {} end
|
||||
local cur = trim(rule.currency)
|
||||
if cur == "" then cur = "USD" end
|
||||
prices.subscriptions[srcName] = { monthly = tonumber(rule.subscription) or 0, currency = cur }
|
||||
end
|
||||
return true
|
||||
end
|
||||
if mode == "unpriced" then
|
||||
-- 刻意不写条目并显式记录:profile 的 default=free 也不能把它当"免费"。
|
||||
prices.explicit_unpriced[srcName] = true
|
||||
return true
|
||||
end
|
||||
if mode == "token" then
|
||||
if type(rule.models) ~= "table" or next(rule.models) == nil then
|
||||
return nil, string.format("token rule for %q requires at least one model",
|
||||
tostring(rule.source or rule.url))
|
||||
end
|
||||
for modelID, t in pairs(rule.models) do
|
||||
local prompt, perr = usdPerM(t.prompt)
|
||||
if not prompt then return nil, string.format("model %q prompt: %s", tostring(modelID), perr) end
|
||||
local completion, cerr = usdPerM(t.completion)
|
||||
if not completion then return nil, string.format("model %q completion: %s", tostring(modelID), cerr) end
|
||||
local entry = { prompt = prompt, completion = completion }
|
||||
if t.cache_discount ~= nil then entry.cache_discount = tonumber(t.cache_discount) end
|
||||
if trim(t.reasoning) ~= "" then
|
||||
local rp, rerr = usdPerM(t.reasoning)
|
||||
if not rp then return nil, string.format("model %q reasoning: %s", tostring(modelID), rerr) end
|
||||
entry.reasoning_price = rp
|
||||
end
|
||||
if type(t.long_context) == "table" then
|
||||
local above = tonumber(t.long_context.above) or 0
|
||||
if above <= 0 then
|
||||
return nil, string.format("model %q long_context: above must be > 0", tostring(modelID))
|
||||
end
|
||||
local rate, lerr = longContextRate(t.long_context, prompt)
|
||||
if not rate then return nil, string.format("model %q long_context: %s", tostring(modelID), lerr) end
|
||||
entry.long_context = { above = above, prompt = rate }
|
||||
end
|
||||
if type(rule.peak) == "table" then entry.peak = compilePeak(rule.peak) end
|
||||
prices.models[modelID] = entry
|
||||
end
|
||||
-- token 规则**不写源条目**:未列价的模型进入 unpriced 统计(可见)。
|
||||
return true
|
||||
end
|
||||
return nil, string.format("unknown mode %q", tostring(mode))
|
||||
end
|
||||
|
||||
-- compileRules(profile) → { prices, warnings } 或 nil, err。
|
||||
-- 不接收源列表:规则以源名为键,编译器不需要知道网关上有哪些源。
|
||||
local function compileRules(profile)
|
||||
if type(profile) ~= "table" then return nil, "no billing profile" end
|
||||
local prices = {
|
||||
currency = "USD",
|
||||
default = { prompt = 0, completion = 0, per_request = 0 },
|
||||
sources = {}, models = {}, keys = {}, explicit_unpriced = {},
|
||||
}
|
||||
if profile.default == "free" or profile.default == "unpriced" then
|
||||
prices.default_mode = profile.default
|
||||
end
|
||||
local rules = type(profile.rules) == "table" and profile.rules or {}
|
||||
for i = 1, #rules do
|
||||
local c = trim(rules[i].currency)
|
||||
if c ~= "" then prices.currency = c break end
|
||||
end
|
||||
local warnings = {}
|
||||
for i = 1, #rules do
|
||||
local rule = rules[i]
|
||||
local url = trim(rule.url)
|
||||
local src = ruleSource(rule)
|
||||
if url == "*" then
|
||||
local ok, err = applyRule(prices, rule, nil)
|
||||
if not ok then return nil, err end
|
||||
elseif src == nil then
|
||||
warnings[#warnings + 1] = "rule " .. i .. " 既没有 source 也没有 url=\"*\""
|
||||
else
|
||||
local ok, err = applyRule(prices, rule, src)
|
||||
if not ok then return nil, err end
|
||||
end
|
||||
end
|
||||
return { prices = prices, warnings = warnings }
|
||||
end
|
||||
|
||||
-- ===== 规则持久化:插件自己读写自己的配置文件 ========================
|
||||
--
|
||||
-- 规则文件放在**插件自己的目录**(内核通过通用的 __llmsproxy_plugin_dir 暴露,
|
||||
-- 任何插件都能用;内核不知道 billing 的存在):`<plugin_dir>/billing.json`。
|
||||
-- 用 JSON 因为插件沙箱只有 json 库;用独立文件因为"保存系统配置"绝不该改动
|
||||
-- 价目表,反之亦然。
|
||||
plugin.rules = { active = "", profiles = {} }
|
||||
plugin.rules_path = ""
|
||||
|
||||
local function defaultRulesPath()
|
||||
local dir = rawget(_G, "__llmsproxy_plugin_dir")
|
||||
if type(dir) ~= "string" or dir == "" then return "" end
|
||||
return dir .. "/billing.json"
|
||||
end
|
||||
|
||||
local function readRulesFile(path)
|
||||
if path == "" then return nil end
|
||||
local f = io.open(path, "r")
|
||||
if f == nil then return nil end
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
if data == nil or data == "" then return nil end
|
||||
local ok, dec = pcall(json.decode, data)
|
||||
if not ok or type(dec) ~= "table" then
|
||||
return nil, "规则文件不是合法 JSON: " .. tostring(path)
|
||||
end
|
||||
return dec
|
||||
end
|
||||
|
||||
local function writeRulesFile(path, rules)
|
||||
if path == "" then return nil, "插件目录不可用,无法保存规则文件" end
|
||||
local ok, enc = pcall(json.encode, rules)
|
||||
if not ok then return nil, "规则无法序列化" end
|
||||
local tmp = path .. ".tmp"
|
||||
local f, err = io.open(tmp, "w")
|
||||
if f == nil then return nil, "无法写规则文件: " .. tostring(err) end
|
||||
local wok, werr = f:write(enc)
|
||||
f:close()
|
||||
if not wok then os.remove(tmp) return nil, "写规则文件失败: " .. tostring(werr) end
|
||||
local rok, rerr = os.rename(tmp, path)
|
||||
if not rok then
|
||||
os.remove(tmp)
|
||||
return nil, "替换规则文件失败: " .. tostring(rerr)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function validateRulesShape(rules)
|
||||
if type(rules) ~= "table" then return nil, "规则必须是对象" end
|
||||
if rules.active ~= nil and type(rules.active) ~= "string" then return nil, "active 必须是字符串" end
|
||||
local profiles = rules.profiles
|
||||
if profiles == nil then profiles = {} rules.profiles = profiles end
|
||||
if type(profiles) ~= "table" then return nil, "profiles 必须是数组" end
|
||||
local seen = {}
|
||||
for i, p in ipairs(profiles) do
|
||||
if type(p) ~= "table" then return nil, "profiles[" .. i .. "] 必须是对象" end
|
||||
local id = trim(p.id)
|
||||
if id == "" then return nil, "profiles[" .. i .. "] 缺少 id" end
|
||||
if seen[id] then return nil, "profile id 重复: " .. id end
|
||||
seen[id] = true
|
||||
if p.default ~= nil and p.default ~= "free" and p.default ~= "unpriced" then
|
||||
return nil, "profile " .. id .. " 的 default 只能是 free 或 unpriced"
|
||||
end
|
||||
if p.rules ~= nil and type(p.rules) ~= "table" then
|
||||
return nil, "profile " .. id .. " 的 rules 必须是数组"
|
||||
end
|
||||
for j, r in ipairs(p.rules or {}) do
|
||||
if type(r) ~= "table" then return nil, "profile " .. id .. " 的 rules[" .. j .. "] 必须是对象" end
|
||||
local mode = trim(r.mode)
|
||||
if not VALID_MODES[mode] then
|
||||
return nil, "profile " .. id .. " 的 rules[" .. j .. "] 的 mode 非法: " .. mode
|
||||
end
|
||||
local wildcard = trim(r.url) == "*"
|
||||
if ruleSource(r) == nil and not wildcard then
|
||||
return nil, "profile " .. id .. " 的 rules[" .. j .. "] 必须有 source(或 url=\"*\" 作全局模型价目)"
|
||||
end
|
||||
if mode == "token" and (type(r.models) ~= "table" or next(r.models) == nil) then
|
||||
return nil, "profile " .. id .. " 的 token 规则缺少 models"
|
||||
end
|
||||
end
|
||||
end
|
||||
if rules.active ~= nil and rules.active ~= "" and not seen[rules.active] then
|
||||
return nil, "active 指向不存在的 profile: " .. rules.active
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function activeProfile(rules)
|
||||
local profile = nil
|
||||
for _, p in ipairs(rules.profiles or {}) do
|
||||
if rules.active ~= nil and rules.active ~= "" and p.id == rules.active then
|
||||
profile = p
|
||||
break
|
||||
end
|
||||
end
|
||||
if profile == nil then profile = (rules.profiles or {})[1] end
|
||||
return profile
|
||||
end
|
||||
|
||||
-- applyRules:用给定规则重编译并注入 plugin.prices。启动、保存、切方案都走它。
|
||||
local function applyRules(rules)
|
||||
local res, err = compileRules(activeProfile(rules) or {})
|
||||
if not res then return nil, tostring(err) end
|
||||
plugin.prices = res.prices
|
||||
return res
|
||||
end
|
||||
|
||||
-- 启动即加载:插件被内核加载时就自己读规则文件并编译 —— 内核不需要调用任何
|
||||
-- billing 专用接口,它只知道"加载插件"。
|
||||
plugin.rules_path = defaultRulesPath()
|
||||
do
|
||||
local loaded, lerr = readRulesFile(plugin.rules_path)
|
||||
if lerr then
|
||||
plugin.load_error = lerr
|
||||
elseif loaded ~= nil then
|
||||
plugin.rules = loaded
|
||||
end
|
||||
local res, err = applyRules(plugin.rules)
|
||||
if not res then plugin.load_error = tostring(err) end
|
||||
end
|
||||
|
||||
-- 插件 HTTP 分发把**整个请求表**交给 handler(method/path/body/headers…),
|
||||
-- body 是原始字符串;而直调导出(/call/...)拿到的就是参数表本身。这里统一
|
||||
-- 解出 JSON 载荷,避免"把请求信封当成规则写进 billing.json"(真实踩过:
|
||||
-- PUT 之后 billing.json 变成了整个请求的转储,规则全丢、价格表清空)。
|
||||
local function reqData(req)
|
||||
if type(req) ~= "table" then return {} end
|
||||
if type(req.json) == "table" then return req.json end
|
||||
if type(req.body) == "string" and req.body ~= "" then
|
||||
local ok, dec = pcall(json.decode, req.body)
|
||||
if ok and type(dec) == "table" then return dec end
|
||||
end
|
||||
if req.method == nil and req.path == nil then return req end
|
||||
return {}
|
||||
end
|
||||
|
||||
-- ---- 规则编辑器的 HTTP 面(挂在插件自己的 /api/plugins/http/billing/ 下)----
|
||||
-- 规则属于插件,写盘也在插件内完成;内核没有 billing 端点。
|
||||
function plugin.__http_rules_get(req)
|
||||
local out = { billing = plugin.rules, warnings = {} }
|
||||
local profile = activeProfile(plugin.rules or {})
|
||||
if profile then out.active = profile.id end
|
||||
local res = profile and compileRules(profile)
|
||||
if res and res.warnings then out.warnings = res.warnings end
|
||||
if plugin.load_error then out.error = plugin.load_error end
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
|
||||
function plugin.__http_rules_put(req)
|
||||
local body = reqData(req)
|
||||
if type(body) ~= "table" then
|
||||
return { status = 400, json = { error = "请求体必须是 JSON 对象" } }
|
||||
end
|
||||
local rules = body.billing or body
|
||||
local ok, verr = validateRulesShape(rules)
|
||||
if not ok then return { status = 400, json = { error = verr } } end
|
||||
local res, cerr = applyRules(rules)
|
||||
if not res then return { status = 400, json = { error = tostring(cerr) } } end
|
||||
local wok, werr = writeRulesFile(plugin.rules_path, rules)
|
||||
if not wok then return { status = 500, json = { error = werr } } end
|
||||
plugin.rules = rules
|
||||
return { status = 200, json = { ok = true, billing = rules, warnings = res.warnings } }
|
||||
end
|
||||
|
||||
function plugin.__http_rules_add(req)
|
||||
local body = reqData(req) or {}
|
||||
local copy = json.decode(json.encode(plugin.rules or { active = "", profiles = {} }))
|
||||
local target = nil
|
||||
for _, p in ipairs(copy.profiles or {}) do
|
||||
if body.profile == nil or body.profile == "" or p.id == body.profile then target = p break end
|
||||
end
|
||||
if target == nil then
|
||||
return { status = 400, json = { error = "没有该方案: " .. tostring(body.profile) } }
|
||||
end
|
||||
local rule = body.rule
|
||||
if type(rule) ~= "table" then return { status = 400, json = { error = "缺少 rule" } } end
|
||||
local key = ruleSource(rule)
|
||||
if key == nil then return { status = 400, json = { error = "规则必须有 source" } } end
|
||||
target.rules = target.rules or {}
|
||||
local replaced = false
|
||||
for i, r in ipairs(target.rules) do
|
||||
if ruleSource(r) == key and trim(r.url) == trim(rule.url) then
|
||||
target.rules[i] = rule replaced = true break
|
||||
end
|
||||
end
|
||||
if not replaced then target.rules[#target.rules + 1] = rule end
|
||||
local ok, verr = validateRulesShape(copy)
|
||||
if not ok then return { status = 400, json = { error = verr } } end
|
||||
local res, cerr = applyRules(copy)
|
||||
if not res then return { status = 400, json = { error = tostring(cerr) } } end
|
||||
local wok, werr = writeRulesFile(plugin.rules_path, copy)
|
||||
if not wok then return { status = 500, json = { error = werr } } end
|
||||
plugin.rules = copy
|
||||
return { status = 200, json = { ok = true, billing = copy } }
|
||||
end
|
||||
|
||||
function plugin.__http_rules_delete(req)
|
||||
local body = reqData(req) or {}
|
||||
local copy = json.decode(json.encode(plugin.rules or { active = "", profiles = {} }))
|
||||
local target = nil
|
||||
for _, p in ipairs(copy.profiles or {}) do
|
||||
if body.profile == nil or body.profile == "" or p.id == body.profile then target = p break end
|
||||
end
|
||||
if target == nil then
|
||||
return { status = 400, json = { error = "没有该方案: " .. tostring(body.profile) } }
|
||||
end
|
||||
local out = {}
|
||||
local found = false
|
||||
for _, r in ipairs(target.rules or {}) do
|
||||
if ruleSource(r) == body.source or trim(r.url) == trim(body.url) then
|
||||
found = true
|
||||
else
|
||||
out[#out + 1] = r
|
||||
end
|
||||
end
|
||||
if not found then return { status = 404, json = { error = "没有该规则" } } end
|
||||
target.rules = out
|
||||
local ok, verr = validateRulesShape(copy)
|
||||
if not ok then return { status = 400, json = { error = verr } } end
|
||||
local res, cerr = applyRules(copy)
|
||||
if not res then return { status = 400, json = { error = tostring(cerr) } } end
|
||||
local wok, werr = writeRulesFile(plugin.rules_path, copy)
|
||||
if not wok then return { status = 500, json = { error = werr } } end
|
||||
plugin.rules = copy
|
||||
return { status = 200, json = { ok = true, billing = copy } }
|
||||
end
|
||||
|
||||
-- 直调导出(/api/plugins/billing/call/<fn>):返回纯 JSON(不带 HTTP 状态壳),
|
||||
-- 便于运维/测试与插件自己的 HTTP 路由共用同一套实现。
|
||||
local function unwrap(res)
|
||||
if type(res) == "table" and res.json ~= nil then return res.json end
|
||||
return res
|
||||
end
|
||||
function plugin.rules_get(args) return unwrap(plugin.__http_rules_get({ json = args or {} })) end
|
||||
function plugin.rules_put(args) return unwrap(plugin.__http_rules_put({ json = args or {} })) end
|
||||
function plugin.rules_add(args) return unwrap(plugin.__http_rules_add({ json = args or {} })) end
|
||||
function plugin.rules_delete(args) return unwrap(plugin.__http_rules_delete({ json = args or {} })) end
|
||||
|
||||
-- 每条路由的 path 必须唯一:内核的路由冲突仲裁按**路径**(不看 method)判重,
|
||||
-- 同一路径挂 4 个 method 会被当成 4 个插件抢同一个路径。所以按操作拆路径。
|
||||
plugin.http = {
|
||||
{ path = "billing/rules", method = "GET", handler = "__http_rules_get",
|
||||
roles = { "admin" }, description = "计费规则读取" },
|
||||
{ path = "billing/rules/replace", method = "PUT", handler = "__http_rules_put",
|
||||
roles = { "admin" }, description = "替换整份计费规则(插件自己写盘)" },
|
||||
{ path = "billing/rules/add", method = "POST", handler = "__http_rules_add",
|
||||
roles = { "admin" }, description = "新增/替换一条计费规则" },
|
||||
{ path = "billing/rules/remove", method = "DELETE", handler = "__http_rules_delete",
|
||||
roles = { "admin" }, description = "删除一条计费规则" },
|
||||
}
|
||||
|
||||
-- 测试/运维直调导出(/api/plugins/billing/call/...);不是内核接口。
|
||||
plugin.exports = { "rules_get", "rules_put", "rules_add", "rules_delete" }
|
||||
|
||||
-- Default prompt-cache discount. 0.1 = a cache read costs a tenth of a fresh
|
||||
-- token, which is what DeepSeek/Qwen/Kimi and most others charge. It can be
|
||||
-- overridden per price entry (prices.models.<m>.cache_discount) or globally by
|
||||
@ -594,11 +189,7 @@ 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.
|
||||
-- ★ local 是必须的:此前写的是全局 `out = {`,而 priceFor 每个请求都会调用,
|
||||
-- 于是这个表挂在 VM 级全局变量上跨请求残留/泄漏(GitCode issue #1 顺带发现)。
|
||||
-- 功能上因为每次整体重建而侥幸正确,但它是共享可变状态,任何提前 return 或
|
||||
-- 后续分支忘了重建都会让上一单的价格漏到下一单 —— 计费错误不会报错。
|
||||
local out = {
|
||||
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
|
||||
@ -645,16 +236,6 @@ 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 级 default 策略(编译期写入 prices.default_mode)在这里落地:
|
||||
-- `free` = 运营声明"没有单独规则的源一律免费" ⇒ 计为已定价(不进 unpriced 统计);
|
||||
-- 但显式 mode=unpriced 的源必须仍计入,否则"只统计不收费"的声明失效。
|
||||
if not out.priced and p.default_mode == "free" then
|
||||
local explicit = p.explicit_unpriced
|
||||
if not (type(explicit) == "table" and payload.source and explicit[payload.source]) then
|
||||
out.priced = true
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
@ -1021,7 +602,7 @@ plugin.ui = {
|
||||
page = {
|
||||
page_id = "billing",
|
||||
title = "Billing",
|
||||
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>]==],
|
||||
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>]==],
|
||||
order = 40,
|
||||
mount = [==[
|
||||
<div id="billing-root" style="padding:16px;min-width:0;max-width:100%;overflow-x:auto">
|
||||
@ -1473,12 +1054,12 @@ plugin.ui = {
|
||||
})();
|
||||
|
||||
(function () {
|
||||
var API = "/api/plugins/http/billing/rules";
|
||||
var API = "/api/plugins/billing/rules";
|
||||
var STR = {
|
||||
en: {
|
||||
title: "Billing rules", profile: "Profile", newProfile: "New profile",
|
||||
addRule: "Add rule", delRule: "Delete", exportYaml: "Export YAML",
|
||||
source: "Source", mode: "Mode", prompt: "Prompt $/M",
|
||||
url: "Source URL", mode: "Mode", prompt: "Prompt $/M",
|
||||
completion: "Completion $/M", currency: "Currency",
|
||||
monthly: "Monthly fee", peakMult: "Peak multiplier",
|
||||
peakHours: "Peak hours (UTC)", peakDays: "Peak days (UTC 1=Mon)",
|
||||
@ -1490,11 +1071,11 @@ plugin.ui = {
|
||||
lcHint: "Long-context step: tokens PAST the threshold cost more. Leave all three blank for one flat rate.", reasoningHint: "Thinking tokens are billed as output unless you set a different rate here.", mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced",
|
||||
noProfile: "No profile yet — create one to start pricing.",
|
||||
noRules: "No rules in this profile",
|
||||
warnNoSource: "no such source on this gateway",
|
||||
warnNoSource: "matches no configured source",
|
||||
active: "ACTIVE", setActive: "Set active",
|
||||
saving: "saving…", saved: "Saved", rejected: "Rejected", save: "Save",
|
||||
newProfileId: "New profile id",
|
||||
needUrl: "a rule needs a source name (or url '*' for a global model catalogue)", needPrices: "every model needs both prices",
|
||||
needUrl: "a rule needs a URL", needPrices: "every model needs both prices",
|
||||
badPeakHours: "peak hours look like 1-4,6-10 (UTC, 0-23)",
|
||||
badPeakDays: "peak days are UTC weekday numbers 0-6",
|
||||
peakNeedsMult: "peak hours need a multiplier",
|
||||
@ -1504,7 +1085,7 @@ plugin.ui = {
|
||||
zh: {
|
||||
title: "计费规则", profile: "方案", newProfile: "新建方案",
|
||||
addRule: "新增规则", delRule: "删除", exportYaml: "导出 YAML",
|
||||
source: "源(source 名)", mode: "计费模式", prompt: "输入 $/M",
|
||||
url: "源 URL", mode: "计费模式", prompt: "输入 $/M",
|
||||
completion: "输出 $/M", currency: "币种",
|
||||
monthly: "月费", peakMult: "峰段倍数",
|
||||
peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)",
|
||||
@ -1517,11 +1098,11 @@ plugin.ui = {
|
||||
mSub: "subscription 订阅", mUnpriced: "unpriced 只统计",
|
||||
noProfile: "还没有方案 —— 新建一个开始定价。",
|
||||
noRules: "该方案暂无规则",
|
||||
warnNoSource: "网关上没有这个源",
|
||||
warnNoSource: "匹配不到已配置的源",
|
||||
active: "生效中", setActive: "设为生效",
|
||||
saving: "保存中…", saved: "已保存", rejected: "被拒绝", save: "保存",
|
||||
newProfileId: "新方案 id",
|
||||
needUrl: "规则必须有源名(或 url '*' 作全局模型价目)",
|
||||
needUrl: "规则必须有 URL",
|
||||
needPrices: "每个模型都要填输入和输出价格",
|
||||
badPeakHours: "峰段小时应形如 1-4,6-10(UTC,0-23)",
|
||||
badPeakDays: "峰段星期应为 UTC 星期编号 0-6",
|
||||
@ -1539,9 +1120,8 @@ plugin.ui = {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
||||
});
|
||||
}
|
||||
var OP_PATH = { GET: "", PUT: "/replace", POST: "/add", DELETE: "/remove" };
|
||||
function call(method, body) {
|
||||
return fetch(API + (OP_PATH[method] || ""), {
|
||||
return fetch(API, {
|
||||
method: method,
|
||||
credentials: "same-origin",
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
@ -1640,8 +1220,7 @@ plugin.ui = {
|
||||
}
|
||||
|
||||
function ruleRow(rule, known, T) {
|
||||
var src = rule.source || (rule.url !== "*" ? rule.url : "");
|
||||
var unknown = src !== "" && src !== "*" && known.indexOf(src) < 0;
|
||||
var unknown = rule.url !== "*" && known.indexOf(rule.url) < 0;
|
||||
var opts = [["free", T.mFree], ["token", T.mToken], ["subscription", T.mSub], ["unpriced", T.mUnpriced]]
|
||||
.map(function (o) {
|
||||
return "<option value='" + o[0] + "'" + (rule.mode === o[0] ? " selected" : "") + ">" + esc(o[1]) + "</option>";
|
||||
@ -1651,8 +1230,8 @@ plugin.ui = {
|
||||
// 的固有宽度会把列撑开:实测表格 986px 挤在 954px 容器里,最右侧的
|
||||
// 「删除」列被切掉 32px,按钮点不到;r-cur/r-sub 这些只有 style='width:74px'
|
||||
// 的输入框实测只有 40px 可用,几乎不能输入。
|
||||
return "<tr class='br-rule' data-source='" + esc(src) + "'>" +
|
||||
"<td style='min-width:200px'><input class='r-src' value='" + esc(src) + "' list='br-srcs' placeholder='source 名,或 * = 全局模型价目' style='width:100%;box-sizing:border-box'>" +
|
||||
return "<tr class='br-rule' data-url='" + esc(rule.url || "") + "'>" +
|
||||
"<td style='min-width:200px'><input class='r-url' value='" + esc(rule.url || "") + "' list='br-urls' style='width:100%;box-sizing:border-box'>" +
|
||||
(unknown ? "<div style='color:#e0a33e;font-size:11px'>⚠ " + esc(T.warnNoSource) + "</div>" : "") +
|
||||
"</td>" +
|
||||
"<td style='width:88px'><select class='r-mode' style='width:100%;box-sizing:border-box;padding:2px;font-size:12px'>" + opts + "</select></td>" +
|
||||
@ -1669,11 +1248,10 @@ plugin.ui = {
|
||||
|
||||
// ---- collecting one rule back out of the DOM ----------------------------
|
||||
function collectRule(tr, T) {
|
||||
var src = tr.querySelector(".r-src").value.trim();
|
||||
if (!src) throw new Error(T.needUrl);
|
||||
var url = tr.querySelector(".r-url").value.trim();
|
||||
if (!url) throw new Error(T.needUrl);
|
||||
var mode = tr.querySelector(".r-mode").value;
|
||||
var out = { source: src, mode: mode };
|
||||
if (src === "*") { out.source = ""; out.url = "*"; }
|
||||
var out = { url: url, mode: mode };
|
||||
var cur = tr.querySelector(".r-cur").value.trim();
|
||||
if (cur) out.currency = cur;
|
||||
if (mode === "subscription") {
|
||||
@ -1823,8 +1401,7 @@ plugin.ui = {
|
||||
dsl.profiles.push({ id: id, rules: rules });
|
||||
});
|
||||
dsl.active = DATA && DATA.active ? DATA.active : (dsl.profiles[0] || {}).id;
|
||||
// 规则现在是插件自持的 billing.json,导出就是同一个 JSON 结构。
|
||||
return JSON.stringify(dsl, null, 2) + "\n";
|
||||
return "billing:\n" + yamlValue(dsl, 1) + "\n";
|
||||
}
|
||||
|
||||
// ---- render --------------------------------------------------------------
|
||||
@ -1842,7 +1419,7 @@ plugin.ui = {
|
||||
var dsl = (DATA && DATA.billing) || null;
|
||||
var profiles = (dsl && dsl.profiles) || [];
|
||||
var active = (DATA && DATA.active) || (profiles[0] && profiles[0].id) || "";
|
||||
var known = (DATA && DATA.sources) || [];
|
||||
var known = (DATA && DATA.urls) || [];
|
||||
|
||||
var warn = "";
|
||||
if (DATA && DATA.warnings && DATA.warnings.length) {
|
||||
@ -1858,7 +1435,7 @@ plugin.ui = {
|
||||
return;
|
||||
}
|
||||
|
||||
var html = warn + "<datalist id='br-srcs'>" + dl + "</datalist>";
|
||||
var html = warn + "<datalist id='br-urls'>" + dl + "</datalist>";
|
||||
profiles.forEach(function (p) {
|
||||
var rules = p.rules || [];
|
||||
html += "<div data-profile='" + esc(p.id) + "' style='margin:0 0 18px'>" +
|
||||
@ -1956,7 +1533,7 @@ plugin.ui = {
|
||||
}
|
||||
if (idx < 0) return;
|
||||
dsl.profiles[idx].rules = dsl.profiles[idx].rules.filter(function (r) {
|
||||
return (r.source || r.url) !== tr.dataset.source;
|
||||
return r.url !== tr.dataset.url;
|
||||
});
|
||||
save(dsl);
|
||||
};
|
||||
@ -1970,10 +1547,10 @@ plugin.ui = {
|
||||
if (dsl.profiles[i].id === b.dataset.pid) idx = i;
|
||||
}
|
||||
if (idx < 0) return;
|
||||
// Seed with the first configured source: an empty source row would fail
|
||||
// Seed with the first configured URL: an empty url row would fail
|
||||
// validation on save and the operator would not know why.
|
||||
var first = ((DATA && DATA.sources) || [])[0] || "*";
|
||||
dsl.profiles[idx].rules.push({ source: first === "*" ? "" : first, url: first === "*" ? "*" : nil, mode: "free" });
|
||||
var first = ((DATA && DATA.urls) || [])[0] || "";
|
||||
dsl.profiles[idx].rules.push({ url: first, mode: "free" });
|
||||
save(dsl);
|
||||
};
|
||||
});
|
||||
@ -2050,27 +1627,19 @@ plugin.ui = {
|
||||
p.innerHTML = "<button class='ghost small' id='br-dl'>" + esc(T.exportYaml) + " (.yaml)</button>";
|
||||
holder.appendChild(p);
|
||||
document.getElementById("br-dl").onclick = function () {
|
||||
var blob = new Blob([text], { type: "application/json" });
|
||||
var blob = new Blob([text], { type: "text/yaml" });
|
||||
var a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "billing.json";
|
||||
a.download = "billing-rules.yaml";
|
||||
a.click();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
function load() {
|
||||
Promise.all([
|
||||
call("GET"),
|
||||
// 源名列表来自网关自己的 /api/sources(浏览器带管理员会话),插件不需要
|
||||
// 为了它去和内核对接 —— 这正是"规则以源名为键"能做到零内核依赖的原因。
|
||||
fetch("/api/sources", { credentials: "same-origin" })
|
||||
.then(function (r) { return r.ok ? r.json() : { sources: [] }; })
|
||||
.catch(function () { return { sources: [] }; }),
|
||||
]).then(function (res) {
|
||||
var j = res[0], sj = res[1] || {};
|
||||
var names = (sj.sources || []).map(function (s) { return s.name; }).filter(Boolean);
|
||||
DATA = { billing: j.billing || null, active: j.active, sources: names, warnings: j.warnings || [] };
|
||||
call("GET")
|
||||
.then(function (j) {
|
||||
DATA = { billing: j.billing || null, active: j.active, urls: j.urls || [], warnings: j.warnings || [] };
|
||||
render();
|
||||
})
|
||||
.catch(function (e) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1065,9 +1065,6 @@ 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,12 +93,6 @@ 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
|
||||
@ -602,11 +596,6 @@ 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
|
||||
@ -614,21 +603,6 @@ 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,
|
||||
@ -825,32 +799,14 @@ 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.
|
||||
//
|
||||
@ -1299,10 +1255,6 @@ 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)
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
76
internal/provider/build_headers_fallback_test.go
Normal file
76
internal/provider/build_headers_fallback_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 内核兜底鉴权必须让位给适配器已经给出的凭据头。
|
||||
//
|
||||
// 背景:anthropic 基座(及继承它的 channels-zcode / channels-cline 等)现在在
|
||||
// build_headers 里发 `x-api-key`(Anthropic 协议用它,不是 Bearer)。
|
||||
// 而内核在 buildHeaders 末尾有一段兜底:`Authorization` 为空就补
|
||||
// `Bearer <api_key>`。若不改,同一请求会同时携带两套鉴权方案 ——
|
||||
// 部分 Anthropic 兼容上游对歧义鉴权直接 401,等于把刚修好的头废掉。
|
||||
//
|
||||
// 本判据咬的是**真实产出**(p.buildHeaders 就是发请求前那条路径)。
|
||||
func TestBuildHeadersSkipsBearerWhenAdapterSuppliesXAPIKey(t *testing.T) {
|
||||
vm := lua.NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
|
||||
s := src("claude-direct", "http://127.0.0.1:1", "anthropic", "claude-x")
|
||||
s.APIKey = "sk-ant-SECRET"
|
||||
p := New(s, vm)
|
||||
|
||||
h, err := p.buildHeaders(`{"model":"claude-x"}`, "http://127.0.0.1:1/v1/messages", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildHeaders: %v", err)
|
||||
}
|
||||
if got := h.Get("X-Api-Key"); got != "sk-ant-SECRET" {
|
||||
t.Fatalf("★ x-api-key 应携带凭据,实得 %q", got)
|
||||
}
|
||||
if got := h.Get("Authorization"); got != "" {
|
||||
t.Fatalf("★ 适配器已给 x-api-key 时,内核**不得**再补 Authorization: Bearer(两套鉴权冲突)"+
|
||||
",实得 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 配对判据:普通适配器(不支持 x-api-key 的那些)兜底行为必须**不变**。
|
||||
// 这条防「改 anthropic 顺手把其它十几个适配器的鉴权搞没了」。
|
||||
func TestBuildHeadersKeepsBearerFallbackForNonAnthropicAdapters(t *testing.T) {
|
||||
vm := lua.NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
|
||||
for _, adapter := range []string{"openai", "deepseek", "groq"} {
|
||||
t.Run(adapter, func(t *testing.T) {
|
||||
src := config.Source{
|
||||
Name: "s-" + adapter,
|
||||
BaseURL: "http://127.0.0.1:1",
|
||||
APIKey: "sk-plain",
|
||||
Adapter: adapter,
|
||||
MaxConcurrent: 1,
|
||||
Models: []config.Model{{ID: "m", Kind: "chat"}},
|
||||
}
|
||||
p := New(src, vm)
|
||||
h, err := p.buildHeaders(`{"model":"m"}`, "http://127.0.0.1:1/chat/completions", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildHeaders: %v", err)
|
||||
}
|
||||
if got := h.Get("Authorization"); got != "Bearer sk-plain" {
|
||||
t.Fatalf("★ %s 的兜底 Bearer 必须保持原样,实得 %q", adapter, got)
|
||||
}
|
||||
if got := h.Get("X-Api-Key"); got != "" {
|
||||
t.Fatalf("%s 不该凭空多出 x-api-key,实得 %q", adapter, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -506,39 +506,42 @@ func (p *Provider) ModelFor(reqModel string) string {
|
||||
return p.bestChatModel()
|
||||
}
|
||||
|
||||
// bestChatModel returns the first chat-kind model of this source.
|
||||
//
|
||||
// ★ 曾经叫 "highest-priority chat model",但 `config.Model.Priority` 已删除
|
||||
//
|
||||
// (它从未参与跨源调度,只在这里用;而这里的语义其实只是"选一个能发 chat 的
|
||||
// 模型",选第一个就够了 —— 源内模型顺序由配置/发现顺序决定)。
|
||||
// bestChatModel returns the highest-priority chat-kind model of this source.
|
||||
func (p *Provider) bestChatModel() string {
|
||||
bestID, bestPrio := "", -1
|
||||
for _, m := range p.cfg.Models {
|
||||
if m.Kind != "" && m.Kind != "chat" {
|
||||
continue
|
||||
}
|
||||
return m.ID
|
||||
if m.Priority > bestPrio {
|
||||
bestPrio = m.Priority
|
||||
bestID = m.ID
|
||||
}
|
||||
}
|
||||
if len(p.cfg.Models) > 0 {
|
||||
return p.cfg.Models[0].ID
|
||||
if bestID == "" && len(p.cfg.Models) > 0 {
|
||||
bestID = p.cfg.Models[0].ID
|
||||
}
|
||||
return ""
|
||||
return bestID
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func (p *Provider) bestImageModel() string {
|
||||
bestID, bestPrio := "", -1
|
||||
for _, m := range p.cfg.Models {
|
||||
if m.Kind != "" && m.Kind != "image" {
|
||||
continue
|
||||
}
|
||||
return m.ID
|
||||
if m.Priority > bestPrio {
|
||||
bestPrio = m.Priority
|
||||
bestID = m.ID
|
||||
}
|
||||
}
|
||||
if len(p.cfg.Models) > 0 {
|
||||
return p.cfg.Models[0].ID
|
||||
if bestID == "" && len(p.cfg.Models) > 0 {
|
||||
bestID = p.cfg.Models[0].ID
|
||||
}
|
||||
return ""
|
||||
return bestID
|
||||
}
|
||||
|
||||
// IsAutoID reports whether s is an AUTO routing placeholder.
|
||||
@ -1126,7 +1129,16 @@ func (p *Provider) buildHeaders(body, url, clientSession string) (http.Header, e
|
||||
h.Set(k, v)
|
||||
}
|
||||
}
|
||||
if h.Get("Authorization") == "" && p.cfg.APIKey != "" {
|
||||
// 兜底鉴权:适配器没给鉴权头时,用源的 api_key 发 Bearer。
|
||||
//
|
||||
// ★ 必须同时看 X-Api-Key:Anthropic 协议用的是 `x-api-key` 而**不是**
|
||||
// `Authorization: Bearer`,而 anthropic 基座(及继承它的 channels-zcode)
|
||||
// 现在在 build_headers 里发 x-api-key。若这里仍无条件补 Bearer,同一请求
|
||||
// 就会同时携带两套鉴权方案 —— 部分 Anthropic 兼容上游对歧义鉴权直接
|
||||
// 401,等于把刚修好的头又废掉了。
|
||||
// 反过来也成立:其它适配器(openai/deepseek/…)不发 x-api-key,它们的
|
||||
// 兜底行为与之前**逐字节一致**(可观察行为无变化)。
|
||||
if h.Get("Authorization") == "" && h.Get("X-Api-Key") == "" && p.cfg.APIKey != "" {
|
||||
h.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
||||
}
|
||||
return h, nil
|
||||
@ -1156,7 +1168,7 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, status, err := p.doWithRetry(ctx, chatURL, body, hdrs)
|
||||
raw, status, err := p.do(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
|
||||
@ -1227,48 +1239,8 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
}
|
||||
rc := make(chan respOrErr, 1)
|
||||
go func() {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
resp, err := p.doRawStream(ctx, streamURL, body, hdrs)
|
||||
rc <- respOrErr{resp, err}
|
||||
}()
|
||||
|
||||
inner := make(chan types.UnifiedChunk, 64)
|
||||
@ -1300,7 +1272,6 @@ 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
|
||||
@ -1344,19 +1315,6 @@ 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
|
||||
@ -1600,50 +1558,6 @@ 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})
|
||||
s.Models = append(s.Models, config.Model{ID: m, Priority: 0})
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -93,39 +93,7 @@ func TestProviderChatStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamSuccessClearsBackoff guards P6: a clean streaming end must reset
|
||||
// a previously cooled (source, model) pair.
|
||||
func TestStreamSuccessClearsBackoff(t *testing.T) {
|
||||
var fail atomic.Bool
|
||||
@ -547,8 +515,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"},
|
||||
{ID: "img-m", Kind: "image"},
|
||||
{ID: "chat-m", Kind: "chat", Priority: 100},
|
||||
{ID: "img-m", Kind: "image", Priority: 50},
|
||||
}
|
||||
p := newTestProvider(t, s)
|
||||
resp, err := p.Image(context.Background(), &types.ImageGenRequest{Prompt: "cat", Model: "AUTO"})
|
||||
|
||||
@ -1,131 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
8
tools/qoder-wasm-probe/go.mod
Normal file
8
tools/qoder-wasm-probe/go.mod
Normal file
@ -0,0 +1,8 @@
|
||||
module qoder-wasm-probe
|
||||
|
||||
// 独立 module(不是主 module 的一部分):主仓 go.mod **不引入** wazero。
|
||||
// 本目录只用于**评测/取证**,`go build ./...` 与 `go test ./...` 在根目录
|
||||
// 会跳过嵌套 module,因此不会给内核带来任何依赖。
|
||||
go 1.25
|
||||
|
||||
require github.com/tetratelabs/wazero v1.9.0
|
||||
2
tools/qoder-wasm-probe/go.sum
Normal file
2
tools/qoder-wasm-probe/go.sum
Normal file
@ -0,0 +1,2 @@
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
678
tools/qoder-wasm-probe/main.go
Normal file
678
tools/qoder-wasm-probe/main.go
Normal file
@ -0,0 +1,678 @@
|
||||
// Command qoder-wasm-probe 评测 `qoder-auth-wasm.wasm` 能否被**纯 Go** wasm
|
||||
// 运行时(wazero,无 JS 引擎)驱动,并给出它实际需要宿主提供哪些能力。
|
||||
//
|
||||
// 这是**取证/评测工具**,不是生产代码:它不注册进网关,也不被内核 import。
|
||||
// 主仓的 go.mod **不包含** wazero —— 本目录是独立 module。
|
||||
//
|
||||
// 为什么需要它:qoder 的加密推理端点头由该 wasm 生成,
|
||||
// 而 wasm 是 wasm-bindgen 产物(31 个 import 全部来自一个 JS glue 模块)。
|
||||
// "能不能实例化" 与 "能不能真正跑出鉴权头" 是两件事 —— 上游参考甚至没有
|
||||
// 提供那份 JS glue(手写了 706 行替代),所以必须实测,不能推断。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run . -wasm /path/to/qoder-auth-wasm.wasm [-json]
|
||||
//
|
||||
// 退出码:0 = 三级证据全部通过;1 = 任一环节失败(详见输出)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/tetratelabs/wazero"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const importModule = "./qoder_auth_wasm_bg.js"
|
||||
|
||||
type probe struct {
|
||||
ctx context.Context
|
||||
rt wazero.Runtime
|
||||
mod api.Module
|
||||
hits map[string]int
|
||||
order []string
|
||||
warns []string
|
||||
strict bool
|
||||
repeat int
|
||||
}
|
||||
|
||||
// ---- JS-ish 值模型 -------------------------------------------------------
|
||||
//
|
||||
// wasm-bindgen 的 ABI 把 JS 值当作整数索引传进导出表。纯 Go 没有这张表,
|
||||
// 宿主必须自己模拟。qoder 模块实际用到的形状只有三种:
|
||||
// 1. Uint8Array —— 字节缓冲
|
||||
// 2. Map —— requestresult_headers 的产物
|
||||
// 3. crypto 对象 —— 只用来喂 getRandomValues(见 C 类 import)
|
||||
|
||||
type (
|
||||
undefined struct{}
|
||||
u8arr []byte
|
||||
jsMap struct{ keys, vals []string }
|
||||
cryptoObj struct{}
|
||||
)
|
||||
|
||||
// wasm-bindgen 把索引 0..1027 保留给 undefined/null/true/false 等哨兵。
|
||||
const sentinelN = 1028
|
||||
|
||||
func newHeap() []interface{} {
|
||||
o := make([]interface{}, sentinelN, 4096)
|
||||
for i := range o {
|
||||
o[i] = undefined{}
|
||||
}
|
||||
o[1024], o[1025], o[1026], o[1027] = undefined{}, nil, true, false
|
||||
return o
|
||||
}
|
||||
|
||||
func main() {
|
||||
wasmPath := flag.String("wasm", "",
|
||||
"qoder-auth-wasm.wasm 路径(必填)")
|
||||
asJSON := flag.Bool("json", false, "以 JSON 输出结果")
|
||||
repeat := flag.Int("repeat", 1, "重复调用 prepareInferRequest 的次数(测实例复用)")
|
||||
strict := flag.Bool("strict", false,
|
||||
"任何未实现的 import 被调用即失败(默认只告警)")
|
||||
flag.Parse()
|
||||
if *wasmPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "-wasm is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
p := &probe{ctx: context.Background(), hits: map[string]int{}, strict: *strict, repeat: *repeat}
|
||||
res := p.run(*wasmPath)
|
||||
|
||||
if *asJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(res)
|
||||
} else {
|
||||
p.report(res)
|
||||
}
|
||||
if !res.OK() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
type result struct {
|
||||
WasmPath string `json:"wasm_path"`
|
||||
WasmSize int `json:"wasm_size"`
|
||||
Imports int `json:"imports"`
|
||||
Exports int `json:"exports"`
|
||||
Instantiated bool `json:"instantiated"`
|
||||
CallsAtInstall int `json:"calls_at_instantiate"`
|
||||
AuthFields map[string]any `json:"generate_runtime_auth_fields"`
|
||||
AuthFieldLens map[string]int `json:"auth_field_lengths"`
|
||||
AuthErr string `json:"auth_error,omitempty"`
|
||||
InferURL string `json:"infer_url,omitempty"`
|
||||
InferBodyLen int `json:"infer_body_len"`
|
||||
InferHeaders map[string]string `json:"infer_headers,omitempty"`
|
||||
InferErr string `json:"infer_error,omitempty"`
|
||||
ImportHits map[string]int `json:"import_hits"`
|
||||
NeverCalled []string `json:"imports_never_called"`
|
||||
UnimplementedHit []string `json:"unimplemented_imports_hit,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
// MemPages 是跑完三级调用后 wasm 线性内存的页数(1 页 = 64 KiB)。
|
||||
MemPages uint32 `json:"mem_pages"`
|
||||
Repeat int `json:"repeat"`
|
||||
ElapsedMS float64 `json:"elapsed_ms"`
|
||||
}
|
||||
|
||||
func (r result) OK() bool {
|
||||
return r.Instantiated && r.AuthErr == "" && r.InferErr == ""
|
||||
}
|
||||
|
||||
func (p *probe) heap() []byte { b, _ := p.mod.Memory().Read(0, p.mod.Memory().Size()); return b }
|
||||
|
||||
func (p *probe) readStr(ptr, ln uint32) string {
|
||||
if b, ok := p.mod.Memory().Read(ptr, ln); ok {
|
||||
return string(b)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *probe) warn(format string, a ...any) {
|
||||
msg := fmt.Sprintf(format, a...)
|
||||
p.warns = append(p.warns, msg)
|
||||
fmt.Fprintln(os.Stderr, "warn: "+msg)
|
||||
}
|
||||
|
||||
func (p *probe) run(path string) result {
|
||||
res := result{WasmPath: path, ImportHits: map[string]int{}}
|
||||
t0 := time.Now()
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
res.InferErr = err.Error()
|
||||
return res
|
||||
}
|
||||
res.WasmSize = len(b)
|
||||
|
||||
p.rt = wazero.NewRuntime(p.ctx)
|
||||
defer p.rt.Close(p.ctx)
|
||||
|
||||
cm, err := p.rt.CompileModule(p.ctx, b)
|
||||
if err != nil {
|
||||
res.InferErr = "compile: " + err.Error()
|
||||
return res
|
||||
}
|
||||
imports := cm.ImportedFunctions()
|
||||
res.Imports = len(imports)
|
||||
res.Exports = len(cm.ExportedFunctions())
|
||||
|
||||
// ---- 宿主模块:31 个 import 的纯 Go 实现 ----
|
||||
host := p.rt.NewHostModuleBuilder(importModule)
|
||||
for _, f := range imports {
|
||||
_, name, _ := f.Import()
|
||||
params, results := f.ParamTypes(), f.ResultTypes()
|
||||
host.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(p.impl(name)), params, results).
|
||||
Export(name)
|
||||
}
|
||||
if _, err := host.Instantiate(p.ctx); err != nil {
|
||||
res.InferErr = "host module: " + err.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
p.mod, err = p.rt.InstantiateModule(p.ctx, cm, wazero.NewModuleConfig().WithName("qoder"))
|
||||
if err != nil {
|
||||
res.InferErr = "instantiate: " + err.Error()
|
||||
return res
|
||||
}
|
||||
res.Instantiated = true
|
||||
res.CallsAtInstall = len(p.order) // 实例化阶段真实调用次数
|
||||
|
||||
p.level2(&res)
|
||||
if res.AuthErr == "" {
|
||||
for i := 0; i < p.repeat; i++ {
|
||||
p.level3(&res)
|
||||
if res.InferErr != "" {
|
||||
res.InferErr = fmt.Sprintf("iteration %d/%d: %s", i+1, p.repeat, res.InferErr)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.ElapsedMS = float64(time.Since(t0).Microseconds()) / 1000
|
||||
if mem := p.mod.Memory(); mem != nil {
|
||||
res.MemPages = mem.Size() / 65536
|
||||
}
|
||||
res.ImportHits = p.hits
|
||||
res.NeverCalled = p.neverCalled(imports)
|
||||
res.Warnings = p.warns
|
||||
return res
|
||||
}
|
||||
|
||||
// ---- 三级证据 ------------------------------------------------------------
|
||||
|
||||
// level2: generate_runtime_auth_fields —— 第一次真实调用。
|
||||
func (p *probe) level2(res *result) {
|
||||
payload := `{"uid":"probe-uid","security_oauth_token":"probe-token",` +
|
||||
`"organization_id":"","organization_tags":[],"data_policy_agreed":false}`
|
||||
ptr, ln := p.writeString(payload)
|
||||
raw, err := p.callString("generate_runtime_auth_fields", uint64(ptr), uint64(ln))
|
||||
if err != nil {
|
||||
res.AuthErr = err.Error()
|
||||
return
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
res.AuthErr = "non-JSON output: " + err.Error()
|
||||
return
|
||||
}
|
||||
res.AuthFields = map[string]any{}
|
||||
res.AuthFieldLens = map[string]int{}
|
||||
for k, v := range fields {
|
||||
// 只暴露类型与长度:字段本身是敏感材料,不该进日志。
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
res.AuthFields[k] = fmt.Sprintf("<string len=%d>", len(s))
|
||||
res.AuthFieldLens[k] = len(s)
|
||||
default:
|
||||
res.AuthFields[k] = fmt.Sprintf("<%T>", v)
|
||||
res.AuthFieldLens[k] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// level3: qodercontext_new + prepareInferRequest —— 产出真实加密请求。
|
||||
func (p *probe) level3(res *result) {
|
||||
userInfo := `{"uid":"probe-uid","encrypt_user_info":"probe","key":"probe",` +
|
||||
`"organization_id":"","organization_tags":[],"data_policy_agreed":false}`
|
||||
meta := `{"client_type":"10","business_product":"qoder",` +
|
||||
`"business_type":"default","scene":"chat"}`
|
||||
|
||||
mp, ml := p.writeString("probe-machine-uuid")
|
||||
vp, vl := p.writeString("1.1.49")
|
||||
up, ul := p.writeString(userInfo)
|
||||
tp, tl := p.writeString(meta)
|
||||
|
||||
sp := p.stackPtr()
|
||||
_, err := p.export("qodercontext_new").Call(p.ctx, uint64(sp),
|
||||
uint64(mp), uint64(ml), uint64(vp), uint64(vl),
|
||||
uint64(up), uint64(ul), uint64(tp), uint64(tl))
|
||||
v := p.dv()
|
||||
ctxPtr := int32(v.u32(sp))
|
||||
isErr := int32(v.u32(sp + 8))
|
||||
errIdx := int32(v.u32(sp + 4))
|
||||
p.stackFree()
|
||||
if err != nil || isErr != 0 {
|
||||
res.InferErr = fmt.Sprintf("qodercontext_new err=%v isErr=%d obj=%v",
|
||||
err, isErr, p.getObj(uint32(errIdx)))
|
||||
return
|
||||
}
|
||||
|
||||
hp, hl := p.writeString("api2.qoder.sh")
|
||||
bp, bl := p.writeString(`{"messages":[{"role":"user","content":"hi"}]}`)
|
||||
kp, kl := p.writeString("qfmodel")
|
||||
skp, skl := p.writeString("system")
|
||||
|
||||
sp2 := p.stackPtr()
|
||||
_, err = p.export("qodercontext_prepareInferRequest").Call(p.ctx, uint64(sp2),
|
||||
uint64(ctxPtr), uint64(hp), uint64(hl), uint64(bp), uint64(bl),
|
||||
uint64(kp), uint64(kl), uint64(skp), uint64(skl))
|
||||
v2 := p.dv()
|
||||
resPtr := int32(v2.u32(sp2))
|
||||
rErr := int32(v2.u32(sp2 + 8))
|
||||
rErrIdx := int32(v2.u32(sp2 + 4))
|
||||
p.stackFree()
|
||||
if err != nil || rErr != 0 {
|
||||
res.InferErr = fmt.Sprintf("prepareInferRequest err=%v isErr=%d obj=%v",
|
||||
err, rErr, p.getObj(uint32(rErrIdx)))
|
||||
return
|
||||
}
|
||||
|
||||
hdrIdx, _ := p.export("requestresult_headers").Call(p.ctx, uint64(uint32(resPtr)))
|
||||
switch hm := p.getObj(uint32(hdrIdx[0])).(type) {
|
||||
case jsMap:
|
||||
res.InferHeaders = map[string]string{}
|
||||
for i := range hm.keys {
|
||||
k, val := hm.keys[i], hm.vals[i]
|
||||
// Authorization 是 WASM 产出的签名头:只报形状,不回显载荷。
|
||||
if len(val) > 24 {
|
||||
res.InferHeaders[k] = val[:24] + fmt.Sprintf("…<len=%d>", len(val))
|
||||
} else {
|
||||
res.InferHeaders[k] = val
|
||||
}
|
||||
}
|
||||
default:
|
||||
res.InferErr = fmt.Sprintf("requestresult_headers -> %T (expected jsMap)", hm)
|
||||
}
|
||||
|
||||
res.InferURL = p.resultString(resPtr, "requestresult_url")
|
||||
body := p.resultString(resPtr, "requestresult_body")
|
||||
res.InferBodyLen = len(body)
|
||||
res.Repeat = p.repeat
|
||||
}
|
||||
|
||||
func (p *probe) report(r result) {
|
||||
fmt.Printf("wasm : %s\n", r.WasmPath)
|
||||
fmt.Printf("size : %d bytes\n", r.WasmSize)
|
||||
fmt.Printf("imports : %d (all from %q)\n", r.Imports, importModule)
|
||||
fmt.Printf("exports : %d\n", r.Exports)
|
||||
fmt.Printf("instantiate: %v calls-at-instantiate=%d\n", r.Instantiated, r.CallsAtInstall)
|
||||
|
||||
fmt.Println("\n-- LEVEL 2: generate_runtime_auth_fields --")
|
||||
if r.AuthErr != "" {
|
||||
fmt.Println(" FAILED:", r.AuthErr)
|
||||
} else {
|
||||
ks := make([]string, 0, len(r.AuthFields))
|
||||
for k := range r.AuthFields {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
for _, k := range ks {
|
||||
fmt.Printf(" %-20s %s\n", k, r.AuthFields[k])
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n-- LEVEL 3: qodercontext_new + prepareInferRequest --")
|
||||
if r.InferErr != "" {
|
||||
fmt.Println(" FAILED:", r.InferErr)
|
||||
} else {
|
||||
fmt.Printf(" url = %s\n", r.InferURL)
|
||||
fmt.Printf(" body = %d bytes (encrypted, not logged)\n", r.InferBodyLen)
|
||||
for _, k := range sortedKeys(r.InferHeaders) {
|
||||
fmt.Printf(" hdr %-20s %s\n", k, r.InferHeaders[k])
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n-- IMPORT USAGE (%d of %d touched) --\n", len(r.ImportHits), r.Imports)
|
||||
for _, k := range sortedKeys(r.ImportHits) {
|
||||
fmt.Printf(" %-50s %d\n", k, r.ImportHits[k])
|
||||
}
|
||||
fmt.Printf(" never called: %d\n", len(r.NeverCalled))
|
||||
for _, n := range r.NeverCalled {
|
||||
fmt.Printf(" %s\n", n)
|
||||
}
|
||||
if len(r.Warnings) > 0 {
|
||||
fmt.Printf("\n-- WARNINGS (%d) --\n", len(r.Warnings))
|
||||
for _, w := range r.Warnings {
|
||||
fmt.Printf(" %s\n", w)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nRESULT: %s\n", map[bool]string{true: "PASS", false: "FAIL"}[r.OK()])
|
||||
}
|
||||
|
||||
// ---- wasm-bindgen ABI 助手(照 upstream src/qoder-wasm.ts 的语义)---------
|
||||
|
||||
func (p *probe) export(n string) api.Function {
|
||||
f := p.mod.ExportedFunction(n)
|
||||
if f == nil {
|
||||
panic("missing export " + n)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (p *probe) dv() dataView {
|
||||
b, _ := p.mod.Memory().Read(0, p.mod.Memory().Size())
|
||||
return dataView(b)
|
||||
}
|
||||
|
||||
type dataView []byte
|
||||
|
||||
func (d dataView) u32(off uint32) uint32 { return binary.LittleEndian.Uint32(d[off:]) }
|
||||
|
||||
func (p *probe) stackPtr() uint32 {
|
||||
out, _ := p.export("__wbindgen_add_to_stack_pointer").Call(p.ctx, api.EncodeI32(-16))
|
||||
return uint32(api.DecodeI32(out[0]))
|
||||
}
|
||||
|
||||
func (p *probe) stackFree() {
|
||||
p.export("__wbindgen_add_to_stack_pointer").Call(p.ctx, api.EncodeI32(16))
|
||||
}
|
||||
|
||||
func (p *probe) writeString(s string) (uint32, uint32) {
|
||||
b := []byte(s)
|
||||
out, err := p.export("__wbindgen_export2").Call(p.ctx, uint64(len(b)), 1)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr := uint32(out[0])
|
||||
p.mod.Memory().Write(ptr, b)
|
||||
return ptr, uint32(len(b))
|
||||
}
|
||||
|
||||
// callString 对应「返回值布局 = ptr/len/valIdx/isErr」那一类函数。
|
||||
func (p *probe) callString(name string, args ...uint64) (string, error) {
|
||||
sp := p.stackPtr()
|
||||
defer p.stackFree()
|
||||
if _, err := p.export(name).Call(p.ctx, append([]uint64{uint64(sp)}, args...)...); err != nil {
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
v := p.dv()
|
||||
ptr, ln := int32(v.u32(sp)), int32(v.u32(sp+4))
|
||||
if isErr := int32(v.u32(sp + 12)); isErr != 0 {
|
||||
return "", fmt.Errorf("%s: error idx=%d (%v)", name, int32(v.u32(sp+8)), p.getObj(v.u32(sp+8)))
|
||||
}
|
||||
return p.readStr(uint32(ptr), uint32(ln)), nil
|
||||
}
|
||||
|
||||
// resultString 对应 `requestresult_url|body`:参数顺序是 (栈指针, ptr)。
|
||||
func (p *probe) resultString(result int32, name string) string {
|
||||
sp := p.stackPtr()
|
||||
defer p.stackFree()
|
||||
if _, err := p.export(name).Call(p.ctx, uint64(sp), uint64(uint32(result))); err != nil {
|
||||
p.warn("%s: %v", name, err)
|
||||
return ""
|
||||
}
|
||||
v := p.dv()
|
||||
ptr, ln := int32(v.u32(sp)), int32(v.u32(sp+4))
|
||||
if ptr == 0 {
|
||||
return ""
|
||||
}
|
||||
return p.readStr(uint32(ptr), uint32(ln))
|
||||
}
|
||||
|
||||
// ---- 对象堆 --------------------------------------------------------------
|
||||
|
||||
var objects = newHeap()
|
||||
|
||||
func pushObj(v any) uint32 {
|
||||
objects = append(objects, v)
|
||||
return uint32(len(objects) - 1)
|
||||
}
|
||||
|
||||
func (p *probe) getObj(i uint32) any {
|
||||
if int(i) < len(objects) {
|
||||
return objects[i]
|
||||
}
|
||||
return undefined{}
|
||||
}
|
||||
|
||||
var dbg = os.Getenv("PROBE_DBG") != ""
|
||||
|
||||
func (p *probe) takeObj(i uint32) {
|
||||
if dbg && i >= sentinelN {
|
||||
fmt.Printf("[dbg] drop %d (was %T)\n", i, p.getObj(i))
|
||||
}
|
||||
if i >= sentinelN && int(i) < len(objects) {
|
||||
objects[i] = undefined{}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *probe) asBytes(i uint32) u8arr {
|
||||
switch v := p.getObj(i).(type) {
|
||||
case u8arr:
|
||||
return v
|
||||
case []byte:
|
||||
return u8arr(v)
|
||||
}
|
||||
p.warn("heap[%d] is %T, expected Uint8Array", i, p.getObj(i))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 31 个 import 的纯 Go 实现 -------------------------------------------
|
||||
//
|
||||
// 分四类(与 docs/qoder-wasm-integration.md 的接口映射表对应):
|
||||
// A bindgen 对象表 · B 字节缓冲 · C 随机/时间 · D JS 环境探针
|
||||
|
||||
func (p *probe) impl(name string) func(context.Context, api.Module, []uint64) {
|
||||
mark := func(n string) {
|
||||
p.hits[n]++
|
||||
if len(p.order) < 4096 {
|
||||
p.order = append(p.order, n)
|
||||
}
|
||||
}
|
||||
wrap := func(f func(s []uint64)) func(context.Context, api.Module, []uint64) {
|
||||
return func(_ context.Context, _ api.Module, s []uint64) {
|
||||
mark(name)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
p.warn("host func %s panicked: %v", name, r)
|
||||
}
|
||||
}()
|
||||
f(s)
|
||||
}
|
||||
}
|
||||
// 未在 auth 路径上验证过的 import:被调用就是新信息,必须记下来。
|
||||
unimpl := func(what string) func(context.Context, api.Module, []uint64) {
|
||||
return wrap(func(s []uint64) {
|
||||
p.warn("import %s reached (%s) -- not exercised by the auth path", name, what)
|
||||
if p.strict {
|
||||
panic("unimplemented import reached: " + name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
switch name {
|
||||
// -- A: bindgen 对象表 ------------------------------------------------
|
||||
case "__wbindgen_object_drop_ref":
|
||||
// ⚠️ 必须**不释放**槽位:实测该模块会 drop 一个 Map 之后继续用同一索引
|
||||
// 调 set(日志见 docs)。wasm-bindgen 新版把 drop 退化为空操作
|
||||
// (externref 表交给 JS GC),因此这里保持 no-op —— 代价是索引只增不回收。
|
||||
return wrap(func(s []uint64) {})
|
||||
case "__wbindgen_object_clone_ref":
|
||||
return wrap(func(s []uint64) { s[0] = uint64(pushObj(p.getObj(uint32(s[0])))) })
|
||||
case "__wbindgen_cast_0000000000000001": // wasm slice -> Uint8Array
|
||||
return wrap(func(s []uint64) {
|
||||
a, e := uint32(s[0]), uint32(s[1])
|
||||
dst := make(u8arr, e)
|
||||
copy(dst, p.heap()[a:a+e])
|
||||
s[0] = uint64(pushObj(dst))
|
||||
})
|
||||
case "__wbindgen_cast_0000000000000002": // wasm str -> JS string
|
||||
return wrap(func(s []uint64) {
|
||||
s[0] = uint64(pushObj(p.readStr(uint32(s[0]), uint32(s[1]))))
|
||||
})
|
||||
|
||||
// -- B: 字节缓冲 ------------------------------------------------------
|
||||
case "__wbg_new_with_length_9cedd08484b73942":
|
||||
return wrap(func(s []uint64) { s[0] = uint64(pushObj(make(u8arr, uint32(s[0])))) })
|
||||
case "__wbg_length_0c32cb8543c8e4c8":
|
||||
return wrap(func(s []uint64) { s[0] = uint64(len(p.asBytes(uint32(s[0])))) })
|
||||
case "__wbg_subarray_0f98d3fb634508ad":
|
||||
return wrap(func(s []uint64) {
|
||||
b := p.asBytes(uint32(s[0]))
|
||||
lo, hi := uint32(s[1]), uint32(s[2])
|
||||
if hi > uint32(len(b)) {
|
||||
hi = uint32(len(b))
|
||||
}
|
||||
if lo > hi {
|
||||
lo = hi
|
||||
}
|
||||
s[0] = uint64(pushObj(b[lo:hi]))
|
||||
})
|
||||
case "__wbg_set_08463b1df38a7e29":
|
||||
// 两种形态:Uint8Array.set(src, off) 与 Map.set()(headers 集合)。
|
||||
return wrap(func(s []uint64) {
|
||||
switch dst := p.getObj(uint32(s[0])).(type) {
|
||||
case jsMap:
|
||||
k, ok := p.getObj(uint32(s[1])).(string)
|
||||
if !ok {
|
||||
k = fmt.Sprint(p.getObj(uint32(s[1])))
|
||||
}
|
||||
dst.keys = append(dst.keys, k)
|
||||
dst.vals = append(dst.vals, fmt.Sprint(p.getObj(uint32(s[2]))))
|
||||
objects[s[0]] = dst
|
||||
case u8arr:
|
||||
src := p.asBytes(uint32(s[1]))
|
||||
off := int(uint32(s[2]))
|
||||
if off > len(dst) {
|
||||
return
|
||||
}
|
||||
copy(dst[off:], src)
|
||||
default:
|
||||
if dbg {
|
||||
fmt.Printf("[dbg] set arg0=%d type=%T arg2=%d\n", s[0], p.getObj(uint32(s[0])), s[2])
|
||||
}
|
||||
p.warn("set on unsupported %T", p.getObj(uint32(s[0])))
|
||||
}
|
||||
})
|
||||
case "__wbg_prototypesetcall_3e05eb9545565046":
|
||||
return wrap(func(s []uint64) {
|
||||
a, e := uint32(s[0]), uint32(s[1])
|
||||
copy(p.heap()[a:a+e], p.asBytes(uint32(s[2])))
|
||||
})
|
||||
case "__wbg_new_99cabae501c0a8a0": // new Map()
|
||||
return wrap(func(s []uint64) {
|
||||
idx := pushObj(jsMap{})
|
||||
if dbg {
|
||||
fmt.Printf("[dbg] newMap -> %d\n", idx)
|
||||
}
|
||||
s[0] = uint64(idx)
|
||||
})
|
||||
|
||||
// -- C: 随机源与时间 --------------------------------------------------
|
||||
//
|
||||
// ⚠️ 两个 getRandomValues 的签名**方向相反**(upstream 注释里踩过的坑):
|
||||
// _d49329ff89a07af1 = (wasmPtr, len) -> 直接写 wasm 内存
|
||||
// _c44a50d8cfdaebeb = (cryptoObjIdx, u8arrIdx) -> 调 JS 对象的方法
|
||||
// 写反会得到 Rust panic `unreachable`。
|
||||
case "__wbg_getRandomValues_d49329ff89a07af1":
|
||||
return wrap(func(s []uint64) {
|
||||
b := make([]byte, uint32(s[1]))
|
||||
rand.Read(b)
|
||||
p.mod.Memory().Write(uint32(s[0]), b)
|
||||
})
|
||||
case "__wbg_getRandomValues_c44a50d8cfdaebeb":
|
||||
return wrap(func(s []uint64) {
|
||||
if _, ok := p.getObj(uint32(s[0])).(cryptoObj); !ok {
|
||||
p.warn("getRandomValues(B): arg0 is %T, want cryptoObj", p.getObj(uint32(s[0])))
|
||||
}
|
||||
if b := p.asBytes(uint32(s[1])); b != nil {
|
||||
rand.Read(b)
|
||||
}
|
||||
})
|
||||
case "__wbg_randomFillSync_6c25eac9869eb53c":
|
||||
return wrap(func(s []uint64) {
|
||||
if b := p.asBytes(uint32(s[1])); b != nil {
|
||||
rand.Read(b)
|
||||
}
|
||||
})
|
||||
case "__wbg_now_88621c9c9a4f3ffc":
|
||||
return wrap(func(s []uint64) {
|
||||
s[0] = math.Float64bits(float64(time.Now().UnixMilli()))
|
||||
})
|
||||
|
||||
// -- D: JS / Node 环境探针 --------------------------------------------
|
||||
case "__wbg_crypto_38df2bab126b63dc", "__wbg_msCrypto_bd5a034af96bcba6":
|
||||
return wrap(func(s []uint64) { s[0] = uint64(pushObj(cryptoObj{})) })
|
||||
case "__wbg_static_accessor_GLOBAL_THIS_a1248013d790bf5f",
|
||||
"__wbg_static_accessor_GLOBAL_f2e0f995a21329ff":
|
||||
return wrap(func(s []uint64) { s[0] = uint64(pushObj(undefined{})) })
|
||||
case "__wbg_static_accessor_SELF_24f78b6d23f286ea",
|
||||
"__wbg_static_accessor_WINDOW_59fd959c540fe405":
|
||||
// 服务端没有 window/self:wasm-bindgen 约定用 0 表示缺席。
|
||||
return wrap(func(s []uint64) { s[0] = 0 })
|
||||
case "__wbg___wbindgen_is_object_40c5a80572e8f9d3":
|
||||
return wrap(func(s []uint64) {
|
||||
switch p.getObj(uint32(s[0])).(type) {
|
||||
case u8arr, jsMap, cryptoObj:
|
||||
s[0] = 1
|
||||
}
|
||||
})
|
||||
case "__wbg___wbindgen_is_string_b29b5c5a8065ba1a":
|
||||
return wrap(func(s []uint64) {
|
||||
if _, ok := p.getObj(uint32(s[0])).(string); ok {
|
||||
s[0] = 1
|
||||
}
|
||||
})
|
||||
case "__wbg___wbindgen_is_undefined_c0cca72b82b86f4d":
|
||||
return wrap(func(s []uint64) {
|
||||
if _, ok := p.getObj(uint32(s[0])).(undefined); ok {
|
||||
s[0] = 1
|
||||
}
|
||||
})
|
||||
case "__wbg___wbindgen_throw_81fc77679af83bc6":
|
||||
return wrap(func(s []uint64) {
|
||||
p.warn("wasm throw: %s", p.readStr(uint32(s[0]), uint32(s[1])))
|
||||
})
|
||||
case "__wbg_Error_2e59b1b37a9a34c3":
|
||||
return wrap(func(s []uint64) {
|
||||
s[0] = uint64(pushObj(fmt.Errorf("%s", p.readStr(uint32(s[0]), uint32(s[1])))))
|
||||
})
|
||||
case "__wbg_process_44c7a14e11e9f69e", "__wbg_versions_276b2795b1c6a219",
|
||||
"__wbg_node_84ea875411254db1", "__wbg_require_b4edbdcf3e2a1ef0",
|
||||
"__wbg_call_d578befcc3145dee", "__wbg___wbindgen_is_function_49868bde5eb1e745":
|
||||
return unimpl("node/JS 环境探针")
|
||||
}
|
||||
return unimpl("未知")
|
||||
}
|
||||
|
||||
func (p *probe) neverCalled(imports []api.FunctionDefinition) []string {
|
||||
var out []string
|
||||
for _, f := range imports {
|
||||
_, name, _ := f.Import()
|
||||
if p.hits[name] == 0 {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
Reference in New Issue
Block a user