mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-10 03:07:25 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ccf068db3 | |||
| b4c5f0f0fc | |||
| 442fa5e3f6 | |||
| 0caa37ea6a | |||
| 33cb2bdb06 | |||
| 0ae7d2c8be | |||
| 02c01582f1 | |||
| 6454b094bc | |||
| 771b95a133 | |||
| 441de434f5 | |||
| 9feef8f4cb | |||
| b5c89fd3d7 | |||
| 7f193a997d | |||
| 055705c134 | |||
| 401a7ead27 | |||
| 411503aea5 | |||
| 662f8f02b6 |
10
Makefile
10
Makefile
@ -1,4 +1,4 @@
|
|||||||
.PHONY: all build test gui gui-dev gui-dist gui-deb gui-win gui-win-docker help clean
|
.PHONY: all build test gui gui-dev gui-dist gui-deb gui-win gui-win-docker help clean gui-test
|
||||||
|
|
||||||
# ModelRouter - single-binary gateway + optional Electron desktop GUI (embedded core)
|
# ModelRouter - single-binary gateway + optional Electron desktop GUI (embedded core)
|
||||||
export BINARY
|
export BINARY
|
||||||
@ -65,6 +65,13 @@ gui-dist-dir:
|
|||||||
test:
|
test:
|
||||||
go 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:
|
clean:
|
||||||
rm -rf $(BUILD_DIR) cmd/build cmd/gui/dist cmd/gui/build/gui-dist cmd/gui/bin
|
rm -rf $(BUILD_DIR) cmd/build cmd/gui/dist cmd/gui/build/gui-dist cmd/gui/bin
|
||||||
|
|
||||||
@ -75,6 +82,7 @@ help:
|
|||||||
@echo " make gui run desktop GUI (dev)"
|
@echo " make gui run desktop GUI (dev)"
|
||||||
@echo " make gui-dev run GUI with devtools"
|
@echo " make gui-dev run GUI with devtools"
|
||||||
@echo " make gui-dist build deb + rpm + AppImage (linux)"
|
@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-deb build deb only"
|
||||||
@echo " make gui-win build Windows nsis (needs host mingw + wine)"
|
@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)"
|
@echo " make gui-win-docker build Windows nsis all-in-docker (no host mingw needed)"
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const http = require("http");
|
|||||||
const os = require("os");
|
const os = require("os");
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const { spawn, execFileSync } = require("child_process");
|
const { spawn, execFileSync } = require("child_process");
|
||||||
|
const pluginSync = require("./plugin-sync");
|
||||||
|
|
||||||
const LOG_FILE = path.join(app.getPath("userData"), "gui.log");
|
const LOG_FILE = path.join(app.getPath("userData"), "gui.log");
|
||||||
function log(msg) {
|
function log(msg) {
|
||||||
@ -40,6 +41,13 @@ console.error = function () {
|
|||||||
// Per-user profile dir: <userData>/profile
|
// Per-user profile dir: <userData>/profile
|
||||||
const PROFILE_DIR = path.join(app.getPath("userData"), "profile");
|
const PROFILE_DIR = path.join(app.getPath("userData"), "profile");
|
||||||
const CONFIG_FILE = path.join(PROFILE_DIR, "config.yaml");
|
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).
|
// Packaged: extraResource at process.resourcesPath/bin (outside asar, spawnable).
|
||||||
// Dev: cmd/gui/bin, populated by prepare-core.js.
|
// Dev: cmd/gui/bin, populated by prepare-core.js.
|
||||||
const CORE_EXE = path.join(
|
const CORE_EXE = path.join(
|
||||||
@ -216,6 +224,9 @@ function writeDefaultEmbeddedConfig() {
|
|||||||
"listen: 127.0.0.1:" + (settings.port || DEFAULT_PORT),
|
"listen: 127.0.0.1:" + (settings.port || DEFAULT_PORT),
|
||||||
"default_model: AUTO",
|
"default_model: AUTO",
|
||||||
"adapter_dir: " + JSON.stringify(path.join(PROFILE_DIR, "adapters")),
|
"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")),
|
"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
|
// Admin key as a plain `keys` entry (no seed flag) so the web UI never
|
||||||
@ -268,6 +279,9 @@ function startCore() {
|
|||||||
// ensure profile dir + config + local adapters exist
|
// ensure profile dir + config + local adapters exist
|
||||||
fs.mkdirSync(PROFILE_DIR, { recursive: true });
|
fs.mkdirSync(PROFILE_DIR, { recursive: true });
|
||||||
if (!fs.existsSync(CONFIG_FILE)) writeDefaultEmbeddedConfig();
|
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], {
|
coreProc = spawn(CORE_EXE, ["-config", CONFIG_FILE], {
|
||||||
cwd: PROFILE_DIR,
|
cwd: PROFILE_DIR,
|
||||||
@ -622,10 +636,55 @@ function createWindow() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
mainWindow.loadFile(path.join(__dirname, "renderer", "index.html"));
|
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 }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
if (url && url.startsWith("http")) shell.openExternal(url);
|
if (!isInternal(url)) openExternally(url);
|
||||||
return { action: "deny" };
|
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
|
// Silent start: `--silent` CLI flag or settings.silentStart keeps the app
|
||||||
// in the tray without showing the window (Clash-Verge style). First run
|
// in the tray without showing the window (Clash-Verge style). First run
|
||||||
// always shows the window so the user sees the dashboard.
|
// 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",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.9.3",
|
"version": "2.0.48",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.9.3",
|
"version": "2.0.48",
|
||||||
"license": "Proprietary",
|
"license": "Proprietary",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.9.7",
|
"version": "2.0.48",
|
||||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||||
"author": "ModelRouter",
|
"author": "ModelRouter",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
@ -81,6 +81,7 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"main.js",
|
"main.js",
|
||||||
|
"plugin-sync.js",
|
||||||
"preload.js",
|
"preload.js",
|
||||||
"renderer/**/*",
|
"renderer/**/*",
|
||||||
"build/**/*",
|
"build/**/*",
|
||||||
|
|||||||
82
cmd/gui/plugin-sync.js
Normal file
82
cmd/gui/plugin-sync.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
// Plugin-tree maintenance for the embedded core (no Electron imports, so this
|
||||||
|
// is unit-testable with plain node).
|
||||||
|
//
|
||||||
|
// Two problems this solves, both invisible from the gateway side:
|
||||||
|
// 1. The core deliberately does NOT default `plugin_dir` (an operator opts
|
||||||
|
// in). A desktop profile without it runs with ZERO plugins — the channels
|
||||||
|
// plugin (account login, credential vault, auto check-in) never appears.
|
||||||
|
// 2. The core seeds bundled plugins only when the file is ABSENT, so a
|
||||||
|
// desktop upgrade would keep the user's old channels.lua forever and
|
||||||
|
// silently skip every channel fix. On version change the shell must drop
|
||||||
|
// the built-in files so the new binary reseeds them.
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const BUILTIN_PLUGINS = ["billing.lua", "channels.lua", "quota.lua", "usage.lua"];
|
||||||
|
|
||||||
|
// ensurePluginDirConfigured back-fills `plugin_dir` into an existing config.
|
||||||
|
// Appending a top-level YAML key is safe for the shapes this file can have
|
||||||
|
// (flat mapping + a keys list). Returns "added" | "present" | "absent" | "error".
|
||||||
|
function ensurePluginDirConfigured(configFile, pluginsDir, log) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(configFile)) return "absent";
|
||||||
|
const cur = fs.readFileSync(configFile, "utf-8");
|
||||||
|
if (/^\s*plugin_dir\s*:/m.test(cur)) return "present";
|
||||||
|
fs.writeFileSync(
|
||||||
|
configFile,
|
||||||
|
cur.replace(/\s*$/, "") +
|
||||||
|
"\n# added by ModelRouter Desktop: enables the plugin tree (channels etc.)\n" +
|
||||||
|
"plugin_dir: " + JSON.stringify(pluginsDir) + "\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
if (log) log("[gui] plugin_dir back-filled into " + configFile);
|
||||||
|
return "added";
|
||||||
|
} catch (e) {
|
||||||
|
if (log) log("[gui] ensurePluginDirConfigured failed: " + e.message);
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncBundledPluginsOnUpgrade drops built-in plugin files when the app version
|
||||||
|
// changed, so the core reseeds them from the binary it shipped with. Returns
|
||||||
|
// { changed, dropped:[...], kept: "prev-<ver>" }.
|
||||||
|
function syncBundledPluginsOnUpgrade(profileDir, pluginsDir, version, log) {
|
||||||
|
const stamp = path.join(profileDir, ".plugin-seed-version");
|
||||||
|
let have = "";
|
||||||
|
try {
|
||||||
|
have = fs.readFileSync(stamp, "utf-8").trim();
|
||||||
|
} catch (e) {
|
||||||
|
/* first run */
|
||||||
|
}
|
||||||
|
if (have === version) return { changed: false, dropped: [] };
|
||||||
|
const dropped = [];
|
||||||
|
for (const name of BUILTIN_PLUGINS) {
|
||||||
|
const f = path.join(pluginsDir, name);
|
||||||
|
if (!fs.existsSync(f)) continue;
|
||||||
|
try {
|
||||||
|
if (have) fs.renameSync(f, f + ".prev-" + have);
|
||||||
|
else fs.unlinkSync(f);
|
||||||
|
dropped.push(name);
|
||||||
|
} catch (e) {
|
||||||
|
if (log) log("[gui] drop builtin plugin " + name + " failed: " + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(stamp, version + "\n", "utf-8");
|
||||||
|
} catch (e) {
|
||||||
|
if (log) log("[gui] write plugin seed stamp failed: " + e.message);
|
||||||
|
}
|
||||||
|
if (dropped.length && log) {
|
||||||
|
log(
|
||||||
|
"[gui] plugins refreshed for " + version + " (" + dropped.length +
|
||||||
|
" builtin file(s) reseeded by core)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { changed: true, dropped, kept: have ? "prev-" + have : "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
BUILTIN_PLUGINS,
|
||||||
|
ensurePluginDirConfigured,
|
||||||
|
syncBundledPluginsOnUpgrade,
|
||||||
|
};
|
||||||
80
cmd/gui/plugin-sync.test.js
Normal file
80
cmd/gui/plugin-sync.test.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
// Unit tests for the desktop plugin-tree maintenance (plain node, no Electron).
|
||||||
|
// Run: node --test cmd/gui/plugin-sync.test.js
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
const { BUILTIN_PLUGINS, ensurePluginDirConfigured, syncBundledPluginsOnUpgrade } = require("./plugin-sync");
|
||||||
|
|
||||||
|
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "plugin-sync-"));
|
||||||
|
|
||||||
|
test("back-fills plugin_dir into an existing profile without it", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
const cfg = path.join(dir, "config.yaml");
|
||||||
|
fs.writeFileSync(cfg, 'listen: 127.0.0.1:8787\ndefault_model: AUTO\nkeys:\n - key: k\n role: admin\n');
|
||||||
|
const plugins = path.join(dir, "plugins");
|
||||||
|
assert.equal(ensurePluginDirConfigured(cfg, plugins, null), "added");
|
||||||
|
const out = fs.readFileSync(cfg, "utf-8");
|
||||||
|
assert.match(out, /^plugin_dir: "/m);
|
||||||
|
assert.ok(out.includes(plugins.replace(/\\/g, "\\\\")) || out.includes(plugins), "path written");
|
||||||
|
// the rest of the profile must survive untouched
|
||||||
|
assert.match(out, /^listen: 127\.0\.0\.1:8787$/m);
|
||||||
|
assert.match(out, /^ - key: k$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves a profile that already sets plugin_dir alone", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
const cfg = path.join(dir, "config.yaml");
|
||||||
|
const orig = 'plugin_dir: "/custom/plugins"\nlisten: ":1"\n';
|
||||||
|
fs.writeFileSync(cfg, orig);
|
||||||
|
assert.equal(ensurePluginDirConfigured(cfg, "/whatever", null), "present");
|
||||||
|
assert.equal(fs.readFileSync(cfg, "utf-8"), orig);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing config is not created by the back-fill", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
const cfg = path.join(dir, "config.yaml");
|
||||||
|
assert.equal(ensurePluginDirConfigured(cfg, path.join(dir, "p"), null), "absent");
|
||||||
|
assert.equal(fs.existsSync(cfg), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upgrade drops builtin plugins (keeping a backup) but not user plugins", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
const plugins = path.join(dir, "plugins");
|
||||||
|
fs.mkdirSync(plugins, { recursive: true });
|
||||||
|
for (const f of BUILTIN_PLUGINS) fs.writeFileSync(path.join(plugins, f), "OLD");
|
||||||
|
fs.writeFileSync(path.join(plugins, "my-own.lua"), "MINE");
|
||||||
|
fs.writeFileSync(path.join(dir, ".plugin-seed-version"), "1.0.0\n");
|
||||||
|
|
||||||
|
const r = syncBundledPluginsOnUpgrade(dir, plugins, "2.0.42", null);
|
||||||
|
assert.equal(r.changed, true);
|
||||||
|
assert.deepEqual(r.dropped.sort(), [...BUILTIN_PLUGINS].sort());
|
||||||
|
for (const f of BUILTIN_PLUGINS) {
|
||||||
|
assert.equal(fs.existsSync(path.join(plugins, f)), false, f + " dropped");
|
||||||
|
assert.equal(fs.readFileSync(path.join(plugins, f + ".prev-1.0.0"), "utf-8"), "OLD");
|
||||||
|
}
|
||||||
|
assert.equal(fs.readFileSync(path.join(plugins, "my-own.lua"), "utf-8"), "MINE");
|
||||||
|
assert.equal(fs.readFileSync(path.join(dir, ".plugin-seed-version"), "utf-8").trim(), "2.0.42");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same version is a no-op (nothing dropped)", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
const plugins = path.join(dir, "plugins");
|
||||||
|
fs.mkdirSync(plugins, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(plugins, "channels.lua"), "CURRENT");
|
||||||
|
fs.writeFileSync(path.join(dir, ".plugin-seed-version"), "2.0.42\n");
|
||||||
|
const r = syncBundledPluginsOnUpgrade(dir, plugins, "2.0.42", null);
|
||||||
|
assert.equal(r.changed, false);
|
||||||
|
assert.equal(fs.readFileSync(path.join(plugins, "channels.lua"), "utf-8"), "CURRENT");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("first run on a fresh profile just stamps the version", () => {
|
||||||
|
const dir = tmp();
|
||||||
|
const plugins = path.join(dir, "plugins");
|
||||||
|
fs.mkdirSync(plugins, { recursive: true });
|
||||||
|
const r = syncBundledPluginsOnUpgrade(dir, plugins, "2.0.42", null);
|
||||||
|
assert.equal(r.changed, true);
|
||||||
|
assert.deepEqual(r.dropped, []);
|
||||||
|
assert.equal(fs.existsSync(path.join(dir, ".plugin-seed-version")), true);
|
||||||
|
});
|
||||||
@ -12,7 +12,7 @@
|
|||||||
`TRAE_SOLO_ADAPTER_SRC`(channels.lua:9744),两份代码不同步。)
|
`TRAE_SOLO_ADAPTER_SRC`(channels.lua:9744),两份代码不同步。)
|
||||||
- **修法**:`TRAE_SOLO_ADAPTER_SRC.transform_response` 加 SSE 聚合(复用流式帧解析,
|
- **修法**:`TRAE_SOLO_ADAPTER_SRC.transform_response` 加 SSE 聚合(复用流式帧解析,
|
||||||
聚合成一个非流式响应),并把 `internal/lua/adapters/trae.lua` 的聚合逻辑对齐。
|
聚合成一个非流式响应),并把 `internal/lua/adapters/trae.lua` 的聚合逻辑对齐。
|
||||||
- **状态**:⬜
|
- **状态**:✅ 已修复(`aggregate_solo_sse`)并部署生产,实测非流式返回 200 正常内容。
|
||||||
|
|
||||||
## P2 buddy(CodeBuddy)非流式 400(11101 Non-stream not supported)
|
## P2 buddy(CodeBuddy)非流式 400(11101 Non-stream not supported)
|
||||||
- **现象**:`stream:false` 打 `channels-buddy:*` 上游 400 `11101: Non-stream chat request is currently not supported`。
|
- **现象**:`stream:false` 打 `channels-buddy:*` 上游 400 `11101: Non-stream chat request is currently not supported`。
|
||||||
@ -20,7 +20,7 @@
|
|||||||
非流式请求原样透传被上游拒绝。buddy 的 `Accept: text/event-stream` 头已发,但 body.stream 仍是 false。
|
非流式请求原样透传被上游拒绝。buddy 的 `Accept: text/event-stream` 头已发,但 body.stream 仍是 false。
|
||||||
- **修法**:`buddy.lua transform_request` 强制 `req.stream = true`;`transform_response` 增加
|
- **修法**:`buddy.lua transform_request` 强制 `req.stream = true`;`transform_response` 增加
|
||||||
SSE 聚合兜底(内核非流式路径收到 SSE 时聚合)。参考 opencodezen.lua 的既有做法(门禁第 4 条)。
|
SSE 聚合兜底(内核非流式路径收到 SSE 时聚合)。参考 opencodezen.lua 的既有做法(门禁第 4 条)。
|
||||||
- **状态**:⬜
|
- **状态**:✅ 已修复并部署生产,实测非流式返回 200 正常内容。
|
||||||
|
|
||||||
## P3 buddy 偶发 provider busy(429)+ 11133 参数拒绝
|
## P3 buddy 偶发 provider busy(429)+ 11133 参数拒绝
|
||||||
- **现象**:连续请求 `channels-buddy` 间歇返回 `provider busy`(source 并发槽 max_concurrent=1
|
- **现象**:连续请求 `channels-buddy` 间歇返回 `provider busy`(source 并发槽 max_concurrent=1
|
||||||
@ -43,7 +43,7 @@
|
|||||||
空窗期 DOM 仍挂旧渠道账号行;且慢的旧请求返回会覆盖新渠道数据。
|
空窗期 DOM 仍挂旧渠道账号行;且慢的旧请求返回会覆盖新渠道数据。
|
||||||
- **修法**:① renderDetail 立即清空账号/模型区并置"加载中";② renderAccounts/renderModels
|
- **修法**:① renderDetail 立即清空账号/模型区并置"加载中";② renderAccounts/renderModels
|
||||||
加代际校验(`state.selected !== id` 时丢弃响应)。
|
加代际校验(`state.selected !== id` 时丢弃响应)。
|
||||||
- **状态**:✅ 已改(channels.lua),待部署。
|
- **状态**:✅ 已修复并部署生产。
|
||||||
|
|
||||||
## P6 CodeArts「完全不可用」——多数模型实际可用
|
## P6 CodeArts「完全不可用」——多数模型实际可用
|
||||||
- **现象**:用户报"codearts 完全不可用"。实测逐模型探测:
|
- **现象**:用户报"codearts 完全不可用"。实测逐模型探测:
|
||||||
@ -55,29 +55,68 @@
|
|||||||
基础设施丢了挂接点)。需要把 on_request_end 补上 dead-model 记录。
|
基础设施丢了挂接点)。需要把 on_request_end 补上 dead-model 记录。
|
||||||
- **凭据**:expiresAt 已过期(15:05),`account/refresh` 续期成功(refreshable=true)。
|
- **凭据**:expiresAt 已过期(15:05),`account/refresh` 续期成功(refreshable=true)。
|
||||||
建议把「凭据到期自动续期」纳入定时任务检查。
|
建议把「凭据到期自动续期」纳入定时任务检查。
|
||||||
- **状态**:⬜(待补 dead-model 挂接 + GLM-5.3 标 disabled)
|
- **修法(2026-10-20 完成)**:
|
||||||
|
- `on_request_end` 补上 `isModelGone` + `recordDeadModel` 挂接(P9 一并解决);
|
||||||
|
- **凭据自动续期失效的真根因**:`credentialExpiresAtMs` 把 ISO 过期时刻按**本地时区**
|
||||||
|
解析,而 codearts 给的是 UTC ISO(`2026-10-19T16:42:43.06Z`)⇒ 本机 +08 下过期时刻
|
||||||
|
虚早 8 小时 ⇒ `refresh_creds` 每 30 分钟都判"已过期"并对华为云真打一次 STS
|
||||||
|
(token 本来还有 23 小时寿命)。已改为按声明时区解析(Z/±hh:mm/无后缀=UTC 三种形态
|
||||||
|
同一 epoch),回归测试 `TestCredentialExpiresAtMsParsesISOAsUTC`。
|
||||||
|
- **状态**:✅ 已修复并部署生产。
|
||||||
|
|
||||||
## P7 CodeBuddy 海外账号 / Qoder 国内账号
|
## P7 CodeBuddy 海外账号 / Qoder 国内账号
|
||||||
- **现象**:CodeBuddy 登录只走国内(copilot.tencent.com),Qoder 登录只走国际
|
- **现象**:CodeBuddy 登录只走国内(copilot.tencent.com),Qoder 登录只走国际
|
||||||
(openapi.qoder.sh / qoder.com)。用户要求两边都能选区。
|
(openapi.qoder.sh / qoder.com)。用户要求两边都能选区。
|
||||||
- **根因**:平台表单区硬编码。buddy 的 state-poll 端点与 Qoder 的 PKCE 端点都锁死一个区域。
|
- **根因**:平台表单区硬编码。buddy 的 state-poll 端点与 Qoder 的 PKCE 端点都锁死一个区域。
|
||||||
- **修法**:平台定义增加区域变体(如 `region: "global" | "cn"`),登录页/账号列表按区域分组;
|
- **修法(2026-10-20 已实施,账号级 region)**:
|
||||||
或允许账号级 `region` 字段覆盖端点 host。
|
- 凭据 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)扫码登录「链接无效」
|
## P8 商汤(Raccoon)扫码登录「链接无效」——已修复
|
||||||
- **现象**:用户点登录后看到的二维码/链接打不开。
|
- **现象**:用户点登录看到的链接打不开(404)。
|
||||||
- **排查**:登录会话本身正常发起(二维码已生成、轮询端点存在)。
|
- **根因(两个,2026-10-20 实测定位)**:
|
||||||
待确认:二维码渲染 URL 是否被前端错误拼接(login_page 拼接 app_name 编码),
|
① 登录页 `/login/mp` **已被站点删除**(Next 路由 404,只有 404 页面返回);
|
||||||
或二维码过期太快(poll 402 但 UI 没提示重生成)。
|
② 旧实现**本地随机造 qrcode_code**,而上游早已不认自造码:旧端点
|
||||||
- **状态**:⬜(需要用户复现一次,抓前端渲染的 URL)
|
`/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 的根因之一)
|
## P9 死模型记录基础设施未挂接(P6 的根因之一)
|
||||||
- `isModelGone` / `recordDeadModel` 只有测试 probe 在调;`plugin.hooks = { request_end }` 的
|
- `isModelGone` / `recordDeadModel` 只有测试 probe 在调;`plugin.hooks = { request_end }` 的
|
||||||
`on_request_end` 只处理 term_error(账号级),不记模型级死亡。
|
`on_request_end` 只处理 term_error(账号级),不记模型级死亡。
|
||||||
- **修法**:on_request_end 失败分支里,取 payload.model + error message,
|
- **修法**:on_request_end 失败分支里,取 payload.model + error message,
|
||||||
`isModelGone(msg)` 为真时 `recordDeadModel(provider, model, msg)` 并重同步源。
|
`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 归一)、
|
- 工作树有未提交改动:buddy.lua / trae.lua(tool_calls 空 {} 过滤)、provider.go(stop→tool_calls 归一)、
|
||||||
|
|||||||
@ -503,15 +503,46 @@ end
|
|||||||
|
|
||||||
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
|
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
|
||||||
|
|
||||||
|
### 7.0 分层:计费语义在插件里,内核只给数据
|
||||||
|
|
||||||
|
`config.yaml` 的 `billing:` 是**声明**,真正的语义(规则怎么变成价目表、
|
||||||
|
覆盖顺序、未定价判定、峰谷/长上下文)全部在 `plugins/billing.lua`:
|
||||||
|
|
||||||
|
| 层 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| 内核 | 解 YAML、校验 DSL schema(`BillingProfile`/`BillingRule`);把 **profile + 每个源的 `base_url`** 作为数据交给插件;把插件算出的价目注入 `prices` 侧车;把计费记录持久化到插件状态文件 |
|
||||||
|
| 插件 | `compile_prices(profile, sources)`:URL 归一匹配、首条命中优先、`free`/`subscription`/`unpriced`/`token` 语义、USD/M → per-token、`peak` 窗口、`long_context` 边际价、`reasoning` 可选价、未匹配规则/源清单;`priceFor`/`costFor` 与账目累计 |
|
||||||
|
|
||||||
|
两条理由(GitCode issue #1):
|
||||||
|
|
||||||
|
1. 语义只写一处。曾经的 `compile.go`(Go)与 `priceFor`(Lua)各有一套覆盖
|
||||||
|
语义,组合起来把模型价静默清零 —— 账单加总正常、只是少报。
|
||||||
|
2. 插件沙箱**没有文件系统**,所以“插件直接读 config.yaml”在设计上不可行;
|
||||||
|
内核把解码后的 profile 作为钩子/接口传入,就是这里的分层形态。
|
||||||
|
|
||||||
|
**计费记录持久化**:插件状态(含累计账目)写在 `plugin_dir/.billing.state.json`
|
||||||
|
(同目录下 `.<插件名>.state.json`),随每次账目变更异步落盘,重启后恢复;
|
||||||
|
`prices` 与 `state` 分开存,改价不会抹掉历史账目。
|
||||||
|
|
||||||
### 7.1 计费维度
|
### 7.1 计费维度
|
||||||
|
|
||||||
| 维度 | 用途 | 优先级 |
|
| 维度 | 用途 | token 价覆盖顺序 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `sources` | 源的固定价(如按请求计费的转发服务) | 中 |
|
| `default` | 兜底价(通常全 0) | 最低 |
|
||||||
| `models` | 模型的 per-token 价 | 高 |
|
| `models` | 模型的 per-token 价 | 中 |
|
||||||
|
| `sources` | 源级价:固定价,或覆盖该源全部模型的 token 价 | 高 |
|
||||||
| `keys` | 单个网关 key 的覆盖价 | **最高** |
|
| `keys` | 单个网关 key 的覆盖价 | **最高** |
|
||||||
|
|
||||||
**token 价**优先级:`keys` > `models` > `default`。
|
**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}` 零价条目;`default: free` 给未匹配源写的是
|
||||||
|
`{per_request:0}`(只标记、不碰 token 价)。
|
||||||
|
|
||||||
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
|
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
|
||||||
**`cache_discount`** 见 §7.7。
|
**`cache_discount`** 见 §7.7。
|
||||||
|
|
||||||
@ -599,6 +630,15 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
|
|||||||
|
|
||||||
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
|
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
|
||||||
|
|
||||||
|
**profile 级兜底**:`profiles[].default` 可设为 `free` 或 `unpriced`(默认)。
|
||||||
|
它是**编译期**策略(以前被校验后写进无人读取的 `default_mode` 键,两种取值运行时
|
||||||
|
完全等价):
|
||||||
|
|
||||||
|
| `default` | 编译结果 | `unpriced_reqs` |
|
||||||
|
|---|---|---|
|
||||||
|
| `unpriced`(省略时) | 未匹配源不写条目 | +1 |
|
||||||
|
| `free` | 未匹配源写 `{per_request:0}` 标记(只标记、不碰 token 价) | 不增加 |
|
||||||
|
|
||||||
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
|
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
|
||||||
`total` 与各维度,只有金额是 0。
|
`total` 与各维度,只有金额是 0。
|
||||||
|
|
||||||
|
|||||||
@ -1,323 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -95,7 +95,14 @@ type Source struct {
|
|||||||
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy
|
// 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
|
// (e.g. http://127.0.0.1:7890). Empty = direct connection. Needed for
|
||||||
// upstreams behind geo/IP blocks that only respond through a proxy.
|
// upstreams behind geo/IP blocks that only respond through a proxy.
|
||||||
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
|
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
|
||||||
|
// Retry is an OPTIONAL upstream retry policy. It exists for upstreams that
|
||||||
|
// answer a business-level rejection with a status code the kernel would
|
||||||
|
// otherwise surface to the client — e.g. a per-account concurrency cap that
|
||||||
|
// clears by itself after tens of seconds. The kernel stays vendor-agnostic:
|
||||||
|
// the matcher is pure DATA (status list + response-body substrings), so no
|
||||||
|
// provider name ever appears in core.
|
||||||
|
Retry *RetryPolicy `yaml:"retry" json:"retry,omitempty"`
|
||||||
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
|
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
|
||||||
Temperature float64 `yaml:"temperature" json:"temperature,omitempty"`
|
Temperature float64 `yaml:"temperature" json:"temperature,omitempty"`
|
||||||
MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"`
|
MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"`
|
||||||
@ -105,6 +112,63 @@ type Source struct {
|
|||||||
RPM int `yaml:"rpm" json:"rpm,omitempty"` // optional requests-per-minute cap (0 = unlimited)
|
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
|
// 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
|
// default config is generated at that path first (first-run bootstrap), so a
|
||||||
// fresh binary just works: `llmsproxy -config /path/to/config.yaml`.
|
// fresh binary just works: `llmsproxy -config /path/to/config.yaml`.
|
||||||
|
|||||||
59
internal/config/retry_policy_test.go
Normal file
59
internal/config/retry_policy_test.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShouldRetry 是纯数据判据:状态码列表 + 响应体子串。它决定"这次失败值不值得
|
||||||
|
// 在网关内部等一会儿重试"。厂商知识绝不能进内核,所以判据必须能用一个表格测清楚。
|
||||||
|
func TestRetryPolicyShouldRetry(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
policy *RetryPolicy
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil policy", nil, 500, "anything", false},
|
||||||
|
{"max_attempts=1 disables", &RetryPolicy{OnStatus: []int{400}, MaxAttempts: 1}, 400, "x", false},
|
||||||
|
{"status matches, no body filter",
|
||||||
|
&RetryPolicy{OnStatus: []int{429}, MaxAttempts: 3}, 429, "", true},
|
||||||
|
{"status mismatch",
|
||||||
|
&RetryPolicy{OnStatus: []int{429}, MaxAttempts: 3}, 400, "429", false},
|
||||||
|
{"status + body match",
|
||||||
|
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{"TM.00001041"}, MaxAttempts: 3},
|
||||||
|
400, `{"error_code":"TM.00001041"}`, true},
|
||||||
|
{"status matches but body does not",
|
||||||
|
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{"TM.00001041"}, MaxAttempts: 3},
|
||||||
|
400, `{"error_code":"TM.00001005"}`, false},
|
||||||
|
{"empty substring never matches",
|
||||||
|
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{""}, MaxAttempts: 3}, 400, "x", false},
|
||||||
|
{"any of several body signals",
|
||||||
|
&RetryPolicy{OnStatus: []int{400, 429}, OnBodyContains: []string{"TM.00001041", "TPM limit"}, MaxAttempts: 3},
|
||||||
|
429, `{"message":"TPM limit exceeded"}`, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := tc.policy.ShouldRetry(tc.status, tc.body); got != tc.want {
|
||||||
|
t.Errorf("%s: ShouldRetry(%d, %q) = %v, want %v", tc.name, tc.status, tc.body, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryPolicyDelay(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
policy *RetryPolicy
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{"nil", nil, 10 * time.Second},
|
||||||
|
{"explicit", &RetryPolicy{Delay: "250ms"}, 250 * time.Millisecond},
|
||||||
|
{"garbage falls back", &RetryPolicy{Delay: "not-a-duration"}, 10 * time.Second},
|
||||||
|
{"negative falls back", &RetryPolicy{Delay: "-5s"}, 10 * time.Second},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := tc.policy.DelayDuration(); got != tc.want {
|
||||||
|
t.Errorf("%s: DelayDuration() = %v, want %v", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
internal/core/billing_apply_test.go
Normal file
60
internal/core/billing_apply_test.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"llmsproxy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 一个过期的规则 URL 不得让**整个**计费 profile 失效。
|
||||||
|
//
|
||||||
|
// 真实事故(GitCode issue #1 排查中发现,生产现网):config.yaml 里
|
||||||
|
// `billing.rules[0].url` 是 `https://opencode.ai/inference/v1`,而该源的
|
||||||
|
// base_url 已是 `.../inference/openai/v1`。严格编译因此返回
|
||||||
|
// "rule url ... matches no configured source base_url",applyBillingDSL 直接
|
||||||
|
// return —— 于是**所有**计费规则都不生效:账单恒为 0、unpriced_reqs 涨到 3.7 万,
|
||||||
|
// 而唯一的痕迹是一行启动日志。这正是「静默少报」最严重的一种。
|
||||||
|
//
|
||||||
|
// 修法:严格编译失败时降级为 lenient(用能匹配的规则),并打 ERROR 说明后果。
|
||||||
|
// 一个 URL 写错不该把整个价目表一起葬送;但也不能装作成功。
|
||||||
|
func TestBillingStaleRuleURLStillAppliesOtherRules(t *testing.T) {
|
||||||
|
cfg := newTestConfig(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg.PluginDir = filepath.Join(dir, "plugins")
|
||||||
|
cfg.Sources = []config.Source{
|
||||||
|
{Name: "localzen", BaseURL: "https://opencode.ai/inference/openai/v1", Adapter: "openai",
|
||||||
|
Models: []config.Model{{ID: "m"}}},
|
||||||
|
}
|
||||||
|
cfg.BillingDSL = &config.BillingDSL{
|
||||||
|
Active: "p",
|
||||||
|
Profiles: []config.BillingProfile{{
|
||||||
|
ID: "p",
|
||||||
|
Rules: []config.BillingRule{
|
||||||
|
// 过期 URL:没有任何源的 base_url 与它相等
|
||||||
|
{URL: "https://opencode.ai/inference/v1", Mode: "free"},
|
||||||
|
// 有效规则:必须仍然生效
|
||||||
|
{URL: "https://opencode.ai/inference/openai/v1", Mode: "free"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
c := newTestCore(t, cfg)
|
||||||
|
c.applyBillingDSL()
|
||||||
|
|
||||||
|
raw := c.plugins.Prices("billing")
|
||||||
|
if raw == nil {
|
||||||
|
t.Fatal("billing has no price table (profile never applied)")
|
||||||
|
}
|
||||||
|
prices, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("price table shape: %#v", raw)
|
||||||
|
}
|
||||||
|
sources, ok := prices["sources"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("prices has no sources: %#v", prices)
|
||||||
|
}
|
||||||
|
if _, ok := sources["localzen"]; !ok {
|
||||||
|
t.Fatalf("一条过期规则 URL 把整个 profile 弄失效了:有效的 free 规则也没有生效,"+
|
||||||
|
"结果是账单恒为 0 且没有告警。prices=%#v", prices)
|
||||||
|
}
|
||||||
|
}
|
||||||
84
internal/core/billing_compile.go
Normal file
84
internal/core/billing_compile.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"llmsproxy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// compileBillingPrices turns a billing profile into the prices table the billing
|
||||||
|
// plugin consumes — by ASKING THE PLUGIN TO DO IT.
|
||||||
|
//
|
||||||
|
// Why the kernel does not compile prices any more (GitCode issue #1, follow-up
|
||||||
|
// direction): "how a rule becomes a price table" is billing semantics, and it
|
||||||
|
// used to live in TWO places — `internal/billing/compile.go` produced source
|
||||||
|
// entries with zero token prices, while `billing.lua`'s priceFor() applied the
|
||||||
|
// source dimension after the model dimension. Each was defensible alone; together
|
||||||
|
// they silently zeroed every request (cost 0, unpriced_reqs frozen). Splitting
|
||||||
|
// the semantics across two languages is what made that possible.
|
||||||
|
//
|
||||||
|
// So the kernel keeps only what it must own — decoding config.yaml and validating
|
||||||
|
// the DSL — and hands the PROFILE plus the source base URLs to the plugin as
|
||||||
|
// plain DATA. The plugin compiles, and the kernel injects the result through the
|
||||||
|
// existing `prices` sidecar (SetState), which also keeps price-vs-ledger
|
||||||
|
// separation intact.
|
||||||
|
//
|
||||||
|
// A plugin sandbox has no filesystem access, so "the plugin reads the config
|
||||||
|
// file" is not achievable by design; passing the decoded profile is the hook /
|
||||||
|
// interface this architecture provides instead.
|
||||||
|
func (c *Core) compileBillingPrices(profile *config.BillingProfile, lenient bool) (map[string]interface{}, []string, error) {
|
||||||
|
if c.plugins == nil {
|
||||||
|
return nil, nil, fmt.Errorf("plugins not loaded")
|
||||||
|
}
|
||||||
|
if profile == nil {
|
||||||
|
return nil, nil, fmt.Errorf("no billing profile")
|
||||||
|
}
|
||||||
|
// Structs go through JSON so the Lua side receives plain data (maps/slices):
|
||||||
|
// pushGoValue is not a struct encoder, and a silent shape mismatch here would
|
||||||
|
// look exactly like "the plugin priced nothing".
|
||||||
|
rawProfile, err := json.Marshal(profile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var prof map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rawProfile, &prof); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
sources := make([]map[string]interface{}, 0, len(c.cfg.Sources))
|
||||||
|
for _, s := range c.cfg.Sources {
|
||||||
|
sources = append(sources, map[string]interface{}{"name": s.Name, "base_url": s.BaseURL})
|
||||||
|
}
|
||||||
|
out, err := c.plugins.Call("billing", "compile_prices", map[string]interface{}{
|
||||||
|
"profile": prof, "sources": sources, "lenient": lenient,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
m, ok := out.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("billing plugin returned an unexpected shape: %T", out)
|
||||||
|
}
|
||||||
|
if msg, hasErr := m["error"]; hasErr {
|
||||||
|
if e := fmt.Sprint(msg); e != "" && e != "<nil>" {
|
||||||
|
return nil, nil, fmt.Errorf("%s", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prices, ok := m["prices"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("billing plugin returned no prices table")
|
||||||
|
}
|
||||||
|
var unmatched []string
|
||||||
|
if arr, ok := m["unmatched_rules"].([]interface{}); ok {
|
||||||
|
for _, v := range arr {
|
||||||
|
unmatched = append(unmatched, fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prices, unmatched, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompileBillingPrices is compileBillingPrices for callers outside the package
|
||||||
|
// (the rules editor validates an edit before persisting it).
|
||||||
|
func (c *Core) CompileBillingPrices(profile *config.BillingProfile, lenient bool) (map[string]interface{}, []string, error) {
|
||||||
|
return c.compileBillingPrices(profile, lenient)
|
||||||
|
}
|
||||||
@ -68,6 +68,16 @@ func TestDeadModelGoneJudgement(t *testing.T) {
|
|||||||
{"model not available in your region", false,
|
{"model not available in your region", false,
|
||||||
"available 不是 found/exist —— 换地域就能用(参考明确点名)"},
|
"available 不是 found/exist —— 换地域就能用(参考明确点名)"},
|
||||||
{"", false, "空串"},
|
{"", 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 {
|
for _, tc := range cases {
|
||||||
out, err := ps.Call("channels", "__is_model_gone_probe",
|
out, err := ps.Call("channels", "__is_model_gone_probe",
|
||||||
|
|||||||
56
internal/core/channels_platforms_logged_in_test.go
Normal file
56
internal/core/channels_platforms_logged_in_test.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// `platforms` 的 logged_in 必须看**账号池**,不能只看旧单账号保险库键。
|
||||||
|
//
|
||||||
|
// 真实缺陷(2026-10-20 用户报「codearts 还是不可用」):
|
||||||
|
// `list_platforms` 当时写的是 `credentialOf("channels:" .. id)` —— 那是旧版
|
||||||
|
// 单账号的键;而现网凭据键是 `channels:<platform>:<accountId>`(一平台多账号)。
|
||||||
|
// 于是**所有**通过账号池接入的平台都被判成未登录,UI 把渠道卡片渲染成"未接入",
|
||||||
|
// 用户看到的正是"渠道不可用",而底层模型其实完全能用。
|
||||||
|
//
|
||||||
|
// 判据钉两层:池里的账号让 logged_in=true,且账号标识能被 UI 拿到。
|
||||||
|
func TestPlatformsLoggedInFromAccountPool(t *testing.T) {
|
||||||
|
_, ps := poolCore(t)
|
||||||
|
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||||
|
"platform": "codearts", "account_id": "acct-pool-1",
|
||||||
|
"credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`,
|
||||||
|
"nickname": "pool-nick",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("account_add: %v", err)
|
||||||
|
}
|
||||||
|
// list_platforms 是 HTTP 路由处理器(不在 plugin.exports 里),走探针取同一份数据
|
||||||
|
m, err := ps.Call("channels", "__platforms_probe", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("__platforms_probe: %v", err)
|
||||||
|
}
|
||||||
|
w, _ := m.(map[string]interface{})
|
||||||
|
j, _ := w["json"].(map[string]interface{})
|
||||||
|
arr, _ := j["platforms"].([]interface{})
|
||||||
|
if len(arr) == 0 {
|
||||||
|
t.Fatalf("platforms 不应为空")
|
||||||
|
}
|
||||||
|
var got map[string]interface{}
|
||||||
|
for _, raw := range arr {
|
||||||
|
p, _ := raw.(map[string]interface{})
|
||||||
|
if p["id"] == "codearts" {
|
||||||
|
got = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("platforms 里找不到 codearts")
|
||||||
|
}
|
||||||
|
if got["logged_in"] != true {
|
||||||
|
t.Fatalf("账号池里有凭据的账号,logged_in 必须为 true;实得 %#v", got)
|
||||||
|
}
|
||||||
|
if got["account_id"] != "acct-pool-1" {
|
||||||
|
t.Errorf("account_id 应回池里的账号 id;实得 %#v", got["account_id"])
|
||||||
|
}
|
||||||
|
if got["account"] != "pool-nick" {
|
||||||
|
t.Errorf("account 应回池里的昵称;实得 %#v", got["account"])
|
||||||
|
}
|
||||||
|
if s, _ := got["accounts"].(float64); s < 1 {
|
||||||
|
t.Errorf("accounts 计数应 >=1;实得 %#v", got["accounts"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -181,9 +181,17 @@ func (h *poolReasonHarness) addAccountOn(platform, id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。
|
// serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。
|
||||||
|
//
|
||||||
|
// ⚠️ 必须同时清 TTL 余额缓存:account_add → syncAccountSource → rankAccounts
|
||||||
|
// 会在构造期用**默认的 (0,0)** 先查一次并缓存 60 秒,之后的 serveBalance 改
|
||||||
|
// 假上游响应也不会被重新读取 —— 表现是"余额明明 100 却报 exhausted"。
|
||||||
|
// __ttl_reset_probe 是插件自己的清缓存出口(与生产同一实现)。
|
||||||
func (h *poolReasonHarness) serveBalance(expiring, permanent int) {
|
func (h *poolReasonHarness) serveBalance(expiring, permanent int) {
|
||||||
h.fail.Store(false)
|
h.fail.Store(false)
|
||||||
h.body.Store(buddyPkgBody(expiring, permanent))
|
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 真实形态的余额响应:
|
// buddyPkgBody 拼一份 buddy 真实形态的余额响应:
|
||||||
@ -207,6 +215,10 @@ func buddyPkgBody(expiring, permanent int) string {
|
|||||||
|
|
||||||
func (h *poolReasonHarness) serveBalanceError(code int) {
|
func (h *poolReasonHarness) serveBalanceError(code int) {
|
||||||
h.fail.Store(true)
|
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{} {
|
func (h *poolReasonHarness) reason() map[string]interface{} {
|
||||||
|
|||||||
@ -27,6 +27,12 @@ type raccoonHarness struct {
|
|||||||
polls int32
|
polls int32
|
||||||
step int32 // 第几次轮询开始 success
|
step int32 // 第几次轮询开始 success
|
||||||
gotCode string
|
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 {
|
func newRaccoonHarness(t *testing.T) *raccoonHarness {
|
||||||
@ -34,6 +40,31 @@ func newRaccoonHarness(t *testing.T) *raccoonHarness {
|
|||||||
h := &raccoonHarness{t: t, step: 3}
|
h := &raccoonHarness{t: t, step: 3}
|
||||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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") {
|
if strings.HasSuffix(r.URL.Path, "/login_with_qrcode_code") {
|
||||||
h.polls++
|
h.polls++
|
||||||
if h.polls < atomic.LoadInt32(&h.step) {
|
if h.polls < atomic.LoadInt32(&h.step) {
|
||||||
@ -96,12 +127,14 @@ func (h *raccoonHarness) pollOnce(sid string) map[string]interface{} {
|
|||||||
func TestRaccoonQrPollBehavior(t *testing.T) {
|
func TestRaccoonQrPollBehavior(t *testing.T) {
|
||||||
h := newRaccoonHarness(t)
|
h := newRaccoonHarness(t)
|
||||||
|
|
||||||
t.Run("行1 本地生成code并给出二维码URL", func(t *testing.T) {
|
t.Run("行1 服务端建码失败时退回本地code并给出可用页面URL", func(t *testing.T) {
|
||||||
|
// ★ 2026-10-20:上游 /login/mp 已 404,登录落地页改为 /login(实测 200,
|
||||||
|
// 页面内含「微信扫码」面板并接受 code 预置)。故断言改为 /login。
|
||||||
atomic.StoreInt32(&h.polls, 0)
|
atomic.StoreInt32(&h.polls, 0)
|
||||||
j, _ := h.begin()
|
j, _ := h.begin()
|
||||||
qr, _ := j["qr_url"].(string)
|
qr, _ := j["qr_url"].(string)
|
||||||
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "login/mp") {
|
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "/login?") {
|
||||||
t.Fatalf("应给出带 code 的二维码 URL, 实得 %q", qr)
|
t.Fatalf("应给出带 code 的二维码 URL(落地页 /login), 实得 %q", qr)
|
||||||
}
|
}
|
||||||
// code 应是 32 位 hex(本地随机生成)
|
// code 应是 32 位 hex(本地随机生成)
|
||||||
code := qr[strings.Index(qr, "code=")+5:]
|
code := qr[strings.Index(qr, "code=")+5:]
|
||||||
@ -151,6 +184,42 @@ func TestRaccoonQrPollBehavior(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("行6 IAM 建码:用服务端 code 且轮询走 IAM 端点", func(t *testing.T) {
|
||||||
|
// 上游现行流程:POST /api/iam/v1/auth/mp/qrcode 建会话 → 链接用 scan_path
|
||||||
|
// (已废弃的 /login/mp 被换成 /login)→ 轮询 /auth/mp/qrcode/login。
|
||||||
|
// 旧实现是本地造码+旧端点,服务端根本没有该会话 ⇒ 扫码永远 pending。
|
||||||
|
hh := newRaccoonHarness(t)
|
||||||
|
hh.iam = true
|
||||||
|
hh.serverCode = "qr-01a156a0-1111-2222-3333-444455556666"
|
||||||
|
hh.step = 2
|
||||||
|
j, sid := hh.begin()
|
||||||
|
qr, _ := j["qr_url"].(string)
|
||||||
|
if !strings.Contains(qr, "code="+hh.serverCode) {
|
||||||
|
t.Fatalf("必须使用**服务端**下发的 qrcode_code, 实得 %q", qr)
|
||||||
|
}
|
||||||
|
if strings.Contains(qr, "/login/mp") {
|
||||||
|
t.Fatalf("不能再用已 404 的 /login/mp, 实得 %q", qr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(qr, "&source=platform") {
|
||||||
|
t.Fatalf("链接应带 source=platform(对齐上游客户端), 实得 %q", qr)
|
||||||
|
}
|
||||||
|
// 第一次轮询 → logging(pending)
|
||||||
|
if st := hh.pollOnce(sid); st["state"] != "pending" {
|
||||||
|
t.Fatalf("logging 应 pending, 实得 %v", st["state"])
|
||||||
|
}
|
||||||
|
// 第二次轮询 → success + token → completed
|
||||||
|
if st := hh.pollOnce(sid); st["state"] != "completed" {
|
||||||
|
t.Fatalf("IAM success+token 应 completed, 实得 %v", st["state"])
|
||||||
|
}
|
||||||
|
v, ok, _ := hh.ps.GetSecret("channels", "channels:raccoon")
|
||||||
|
if !ok || !strings.Contains(v, "RC-AT-IAM") {
|
||||||
|
t.Fatalf("凭据应含 RC-AT-IAM, 实得 %q", v)
|
||||||
|
}
|
||||||
|
if !strings.Contains(hh.gotPollURL, "/api/iam/v1/auth/mp/qrcode/login") {
|
||||||
|
t.Fatalf("轮询必须打 IAM 端点, 实得 %q", hh.gotPollURL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("行5 success缺token视为未完成", func(t *testing.T) {
|
t.Run("行5 success缺token视为未完成", func(t *testing.T) {
|
||||||
hh := newRaccoonHarness(t)
|
hh := newRaccoonHarness(t)
|
||||||
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package core
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@ -191,3 +192,69 @@ func TestRefreshWritesBackAbsoluteExpiry(t *testing.T) {
|
|||||||
t.Errorf("索引 expiresAt 应换算成绝对时刻,实得 %.0f", idx)
|
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,环境时区不同也成立:用本地偏移动态算)。
|
||||||
|
}
|
||||||
|
|||||||
@ -16,55 +16,40 @@ import (
|
|||||||
// 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁),
|
// 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁),
|
||||||
// 不是 pool 里的 priority 字段(那个改了不代表路由变了)。
|
// 不是 pool 里的 priority 字段(那个改了不代表路由变了)。
|
||||||
func TestAccountReorderActuallyReordersKernelSources(t *testing.T) {
|
func TestAccountReorderActuallyReordersKernelSources(t *testing.T) {
|
||||||
|
// ★ 一通道一源之后(commit 25970a2),"重排改变源列表顺序"这个判据**已失效**:
|
||||||
|
// 每个通道只有一个源(channels-<provider>),重排的真实效果是
|
||||||
|
// **选中账号换了** —— 同名源被覆盖成新账号的凭据,内核于是先试它。
|
||||||
|
// 旧判据按 channels-buddy-<acct> 过滤,只会在新架构下恒得空列表
|
||||||
|
// ("应有 2 个账号源,实得 []"),测的是已不存在的模型。
|
||||||
h := newReorderHarness(t)
|
h := newReorderHarness(t)
|
||||||
h.addAccount("acct-a")
|
h.addAccount("acct-a")
|
||||||
h.addAccount("acct-b")
|
h.addAccount("acct-b")
|
||||||
|
|
||||||
// 先确认两个源都在,且初始顺序是 [a, b](account_add 的注册顺序)。
|
// 初始:先建的 acct-a 优先(priority 相同 → createdAt 升序 → id 兜底)
|
||||||
// ⚠ 这一步是判据成立的前提:必须让**目标顺序与初始顺序相反**,
|
if got := h.chosenAccount(); got != "acct-a" {
|
||||||
// 否则"重排生效"与"本来就是这个顺序"无法区分 ——
|
t.Fatalf("前置条件不成立:初始应选 acct-a,实得 %q", got)
|
||||||
// 我第一版先加 a 后加 b、又把 b 排前面,于是初始顺序恰好已是 [b, a],
|
|
||||||
// 去掉重注册的变异**照样通过**(判据没咬住)。
|
|
||||||
before := h.sourceOrder()
|
|
||||||
if len(before) < 2 {
|
|
||||||
t.Fatalf("应有 2 个账号源,实得 %v", before)
|
|
||||||
}
|
}
|
||||||
if before[0] != "channels-buddy-acct-a" {
|
// 通道只有一个源,且确实是它
|
||||||
t.Fatalf("前置条件不成立:初始顺序应为 [a, b](add 顺序),实得 %v —— "+
|
if n := len(h.sourceOrder()); n != 1 {
|
||||||
"若初始已是目标顺序,本判据无法区分「重排生效」与「本来就对」", before)
|
t.Fatalf("一通道一源:应有 1 个通道源,实得 %d", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把 b 排到前面(ordered_ids 顺序即优先级,第一个最高)——
|
// 把 acct-b 排到最前 —— 只有真的重新选号+重建源,chosen 才会切换
|
||||||
// 目标顺序与初始相反,故只有真的重注册才能达成。
|
|
||||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||||
"platform": "buddy",
|
"platform": "buddy",
|
||||||
"ordered_ids": []interface{}{"acct-b", "acct-a"},
|
"ordered_ids": []interface{}{"acct-b", "acct-a"},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
after := h.sourceOrder()
|
if got := h.chosenAccount(); got != "acct-b" {
|
||||||
if len(after) < 2 {
|
t.Fatalf("重排后应改选 acct-b(源同名覆盖成它的凭据),实得 %q\n"+
|
||||||
t.Fatalf("重排后源丢了:%v", after)
|
"(只改 pool.priority 不重建源的话,内核仍用旧 api_key ⇒ 路由不变)", got)
|
||||||
}
|
}
|
||||||
|
// 源仍在(不能因为重建把源弄丢)
|
||||||
idxOf := func(list []string, id string) int {
|
if n := len(h.sourceOrder()); n != 1 {
|
||||||
for i, n := range list {
|
t.Fatalf("重排后通道源应仍为 1 个,实得 %d", n)
|
||||||
if n == id {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
}
|
||||||
b, a := idxOf(after, "channels-buddy-acct-b"), idxOf(after, "channels-buddy-acct-a")
|
t.Logf("✓ 重排已切换服务账号: %s", h.chosenAccount())
|
||||||
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 ──
|
// ── harness ──
|
||||||
@ -97,7 +82,8 @@ func (h *reorderHarness) addAccount(id string) {
|
|||||||
h.t.Helper()
|
h.t.Helper()
|
||||||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||||||
"platform": "buddy", "account_id": id,
|
"platform": "buddy", "account_id": id,
|
||||||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
// 每个账号用**不同** token:cho sen 切换后源 api_key 变化才是可观测的
|
||||||
|
"credential": `{"access_token":"AT-` + id + `","uid":"u1"}`,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
h.t.Fatalf("account_add(%s): %v", id, err)
|
h.t.Fatalf("account_add(%s): %v", id, err)
|
||||||
}
|
}
|
||||||
@ -123,7 +109,8 @@ func (h *reorderHarness) sourceOrder() []string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
n, _ := m["name"].(string)
|
n, _ := m["name"].(string)
|
||||||
if len(n) > 15 && n[:15] == "channels-buddy-" {
|
// 一通道一源:源名就是 channels-<provider>(不再带账号后缀)
|
||||||
|
if n == "channels-buddy" {
|
||||||
out = append(out, n)
|
out = append(out, n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -140,54 +127,57 @@ func (h *reorderHarness) sourceOrder() []string {
|
|||||||
// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分),
|
// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分),
|
||||||
// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。
|
// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。
|
||||||
func TestPoolListOrderIsDeterministic(t *testing.T) {
|
func TestPoolListOrderIsDeterministic(t *testing.T) {
|
||||||
// ⚠ 这条判据必须走**批量重注册**路径(reorder 会按 poolList 顺序重放源)。
|
// ★ 一通道一源之后,"源注册顺序 = poolList 顺序"不再可观测(只有一个源)。
|
||||||
// `account_add` 是直接同步自己那一个账号(源顺序 = add 顺序),走不到
|
// 确定性契约仍在,但可观测的出口变成 **选号顺序**(rankAccounts 的 all 列表:
|
||||||
// 排序逻辑 —— 我第一版直接断言 add 后的顺序,测的其实是 add 顺序,
|
// expiring → permanent → none,档内保持 poolList 顺序)。
|
||||||
// 与"排序是否确定"无关(去掉兜底键的变异照样通过)。
|
// 故判据改为:同优先级 + 同秒创建时,选号顺序必须由 id 字典序决定。
|
||||||
//
|
|
||||||
// 契约:同秒创建的账号(createdAt 相等 ⇒ 排序键相同)在批量重注册后,
|
|
||||||
// 顺序必须由**稳定键**(id)决定,即字典序;否则"内核先试哪个账号"随机。
|
|
||||||
h := newReorderHarness(t)
|
h := newReorderHarness(t)
|
||||||
for _, id := range []string{"acct-c", "acct-b", "acct-a"} {
|
for _, id := range []string{"acct-c", "acct-b", "acct-a"} {
|
||||||
h.addAccount(id)
|
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()
|
h.clearPriorities()
|
||||||
if !sameSecond(h.createdAts()) {
|
if !sameSecond(h.createdAts()) {
|
||||||
t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts())
|
t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts())
|
||||||
}
|
}
|
||||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
// priority 全平 + 同秒 ⇒ 只剩 id 兜底键
|
||||||
"platform": "buddy",
|
order := h.rankOrder()
|
||||||
"ordered_ids": []interface{}{"acct-a", "acct-b", "acct-c"},
|
want := "acct-a,acct-b,acct-c"
|
||||||
}); err != nil {
|
if got := joinStrings(order, ","); got != want {
|
||||||
t.Fatal(err)
|
t.Fatalf("同优先级同秒创建时,选号顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+
|
||||||
}
|
|
||||||
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 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)",
|
"(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)",
|
||||||
got, want2)
|
got, want)
|
||||||
}
|
}
|
||||||
t.Logf("✓ 同键时顺序由 id 稳定决定: %s", want2)
|
t.Logf("✓ 同键时选号顺序由 id 稳定决定: %s", want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chosenAccount 当前为该通道服务(== 源 api_key 所属)的账号 id。
|
||||||
|
func (h *reorderHarness) chosenAccount() string {
|
||||||
|
h.t.Helper()
|
||||||
|
res, err := h.ps.Call("channels", "__chosen_account_probe", map[string]interface{}{"platform": "buddy"})
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("__chosen_account_probe: %v", err)
|
||||||
|
}
|
||||||
|
j := jsonOf(h.t, res)
|
||||||
|
s, _ := j["account_id"].(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// rankOrder 选号顺序(rankAccounts.all 的 id 列表)。
|
||||||
|
func (h *reorderHarness) rankOrder() []string {
|
||||||
|
h.t.Helper()
|
||||||
|
res, err := h.ps.Call("channels", "__rank_accounts_probe", map[string]interface{}{"platform": "buddy"})
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("__rank_accounts_probe: %v", err)
|
||||||
|
}
|
||||||
|
j := jsonOf(h.t, res)
|
||||||
|
var out []string
|
||||||
|
arr, _ := j["all"].([]interface{})
|
||||||
|
for _, v := range arr {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func joinStrings(xs []string, sep string) string {
|
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 有档位;另有三个必被硬性过滤的条目
|
// ① solo_agent:glm-5.2 有档位;另有三个必被硬性过滤的条目
|
||||||
`{"function":"solo_agent","config_info_list":[`+
|
`{"function":"solo_agent","config_info_list":[`+
|
||||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true,`+
|
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true,`+
|
||||||
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}},`+
|
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}}`+
|
||||||
|
`]},`+
|
||||||
|
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置。
|
||||||
|
// 另三个"必被过滤"的条目也放这里 —— 生产里适配器恒发这条通道,
|
||||||
|
// 硬性过滤的判据必须在这条通道上被验证到(放 solo_agent 会因为
|
||||||
|
// admit_functions 策略先行剔除,测试就测不到过滤逻辑了)。
|
||||||
|
`{"function":"solo_work_lite","config_info_list":[`+
|
||||||
|
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true},`+
|
||||||
`{"config_name":"summary-model","usage":"summary","config_switch":true},`+
|
`{"config_name":"summary-model","usage":"summary","config_switch":true},`+
|
||||||
`{"config_name":"disabled-model","usage":"chat_completion","config_switch":false},`+
|
`{"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":"hidden-model","usage":"chat_completion","config_switch":true,"is_invisible_to_user":true},`+
|
||||||
`{"config_name":"no-function-model","usage":"chat_completion","config_switch":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,`+
|
`{"config_name":"custom-flag-model","usage":"chat_completion","config_switch":true,`+
|
||||||
`"display_config":{"is_custom_model":true}},`+
|
`"display_config":{"is_custom_model":true}},`+
|
||||||
// 对照组:名字带 custom_model_ 前缀但**标志为 false** ⇒ 必须**保留**
|
|
||||||
// (参考点名:判据是标志的值,不是模型名)
|
|
||||||
`{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+
|
`{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+
|
||||||
`"display_config":{"is_custom_model":false}}`+
|
`"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":[`+
|
`{"function":"inline_chat","config_info_list":[`+
|
||||||
`{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+
|
`{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+
|
||||||
|
|||||||
71
internal/core/channels_trae_headers_test.go
Normal file
71
internal/core/channels_trae_headers_test.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TRAE 的**两套头不能混用** —— 这是 2026-10-20 那次"目录永远拉不到"的真因。
|
||||||
|
//
|
||||||
|
// 事故链:模型目录端点在 `trae-api-cn.mchost.guru`(IDE 面),需要 IDE 头族
|
||||||
|
// (Trae UA + X-App-Id + X-Ide-Version* + 凭据里的 X-Machine-Id/X-Device-Id);
|
||||||
|
// 而 `api.trae.cn` 的签到/积分端点需要另一套(VSCode UA + X-Market-* +
|
||||||
|
// X-Lscbd-* + 派生 market device id)。插件当时"复用签到头"去拉目录,上游对
|
||||||
|
// 22 个 function 全部回 `error_code 2001 Config detail for function: X not found`
|
||||||
|
// (HTTP 200!),于是远端目录恒空、只能退回兜底表 —— 用户看到的却是
|
||||||
|
// "trae 模型不正确 / 渠道不可用",与真因隔了整整一层协议。
|
||||||
|
//
|
||||||
|
// 这个测试把两套头的**契约**钉死:键集合必须互不越界。它不联网,因此能
|
||||||
|
// 在 CI 里稳定复现"用错头"这一类回归。
|
||||||
|
func TestTraeHeadersKeptSeparate(t *testing.T) {
|
||||||
|
c := kernelTestCore(t)
|
||||||
|
ps := c.Plugins()
|
||||||
|
src, err := readBundledChannels(t)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ps.LoadSource("channels", src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := ps.Call("channels", "__trae_headers_probe", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("probe: %v", err)
|
||||||
|
}
|
||||||
|
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||||
|
pick := func(field string) map[string]bool {
|
||||||
|
set := map[string]bool{}
|
||||||
|
arr, _ := j[field].([]interface{})
|
||||||
|
for _, raw := range arr {
|
||||||
|
if s, ok := raw.(string); ok {
|
||||||
|
set[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
ide, chk := pick("ide"), pick("checkin")
|
||||||
|
if len(ide) == 0 || len(chk) == 0 {
|
||||||
|
t.Fatalf("两套头都应有内容;实得 ide=%v checkin=%v", ide, chk)
|
||||||
|
}
|
||||||
|
// IDE 面(目录/对话):身份与版本头必须在
|
||||||
|
for _, k := range []string{"X-App-Id", "X-Ide-Version", "X-App-Version-Code",
|
||||||
|
"X-Machine-Id", "X-Device-Id", "Request-Traffic-Type"} {
|
||||||
|
if !ide[k] {
|
||||||
|
t.Errorf("IDE 头缺少 %q(目录端点会回 2001)", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// IDE 面**不得**带 UG/积分的头
|
||||||
|
for _, k := range []string{"X-Market-Client-Id", "X-Lscbd-Aid", "X-Market-User-Id"} {
|
||||||
|
if ide[k] {
|
||||||
|
t.Errorf("IDE 头不应包含 %q(那是签到头)", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 签到头:UG/积分标记必须在
|
||||||
|
for _, k := range []string{"X-Market-Client-Id", "X-Market-User-Id", "X-Lscbd-Aid"} {
|
||||||
|
if !chk[k] {
|
||||||
|
t.Errorf("签到头缺少 %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 签到头不得冒用 IDE 的 App/机型身份
|
||||||
|
for _, k := range []string{"X-App-Id", "X-Ide-Version", "X-Machine-Id"} {
|
||||||
|
if chk[k] {
|
||||||
|
t.Errorf("签到头不应包含 %q(那是 IDE 身份)", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
internal/core/channels_trae_models_test.go
Normal file
111
internal/core/channels_trae_models_test.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TRAE 的模型选择器**不许**出现 `custom_model_*`。
|
||||||
|
//
|
||||||
|
// 真实缺陷(2026-10-20 用户报「trae 的模型不正确 / 渠道不可用」):
|
||||||
|
// 这些 id 是 TRAE 的「自定义模型槽位」,上游目录里它们的权威判据是
|
||||||
|
// `is_custom_model: true`(插件 trae_batch 解析器早已按这条准入剔除)。
|
||||||
|
// 但**兜底表**把它们当普通模型列了出来 —— 而远端目录在部分账号下恒空
|
||||||
|
// (企业租户账号实测:22 个 function 全回 error_code 2001 "Config detail
|
||||||
|
// for function: X not found"),于是用户只能看到兜底表,选中这些槽位后
|
||||||
|
// 上游回**空 content**(实测 custom_model_kimi / custom_model_claude /
|
||||||
|
// custom_model_1M 均空),表现就是"模型坏了、渠道不可用"。
|
||||||
|
//
|
||||||
|
// 判据分两层:
|
||||||
|
// - model_list(UI 选择器)不含它们;
|
||||||
|
// - 交给内核的上游源里也不含它们(effectiveModels 走 models_unavailable)。
|
||||||
|
func TestTraeCustomModelSlotsAreNotOffered(t *testing.T) {
|
||||||
|
c := kernelTestCore(t)
|
||||||
|
ps := c.Plugins()
|
||||||
|
src, err := readBundledChannels(t)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ps.LoadSource("channels", src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = ps.SetPluginKernelPermission("channels",
|
||||||
|
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
|
||||||
|
|
||||||
|
out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("model_list: %v", err)
|
||||||
|
}
|
||||||
|
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||||
|
models, _ := j["models"].([]interface{})
|
||||||
|
if len(models) == 0 {
|
||||||
|
t.Fatal("trae 应至少有一个可选模型(兜底表)")
|
||||||
|
}
|
||||||
|
for _, raw := range models {
|
||||||
|
m, _ := raw.(map[string]interface{})
|
||||||
|
id, _ := m["id"].(string)
|
||||||
|
if len(id) > 13 && id[:13] == "custom_model_" {
|
||||||
|
t.Fatalf("model_list 不应包含自定义模型槽位 %q(选中后上游回空内容)", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 可调用模型必须还在(不能把整张表干掉换绿)
|
||||||
|
found := false
|
||||||
|
for _, raw := range models {
|
||||||
|
m, _ := raw.(map[string]interface{})
|
||||||
|
if m["id"] == "glm-5.2" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("可调用的 glm-5.2 不应被一起藏掉;实得 %v", models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上游源的模型清单同样不含占位槽位(客户端按裸名就能调,藏 UI 不够)。
|
||||||
|
func TestTraeSourceOmitsCustomModelSlots(t *testing.T) {
|
||||||
|
c := kernelTestCore(t)
|
||||||
|
ps := c.Plugins()
|
||||||
|
src, err := readBundledChannels(t)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ps.LoadSource("channels", src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = ps.SetPluginKernelPermission("channels",
|
||||||
|
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
|
||||||
|
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||||
|
"platform": "trae", "account_id": "trae-a",
|
||||||
|
"credential": `{"access_token":"jwt","uid":"1","device_id":"d","machine_id":"m"}`,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("account_add: %v", err)
|
||||||
|
}
|
||||||
|
ls, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("probe: %v", err)
|
||||||
|
}
|
||||||
|
j, _ := ls.(map[string]interface{})["json"].(map[string]interface{})
|
||||||
|
arr, _ := j["sources"].([]interface{})
|
||||||
|
haveTrae := false
|
||||||
|
for _, raw := range arr {
|
||||||
|
m, _ := raw.(map[string]interface{})
|
||||||
|
if m["name"] == "channels-trae" {
|
||||||
|
haveTrae = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !haveTrae {
|
||||||
|
t.Fatalf("trae 账号加好后应产出 channels-trae 源;实得 %v", arr)
|
||||||
|
}
|
||||||
|
// 源里的模型清单从内核真实源读(不是插件的 model_list)
|
||||||
|
srcs := c.Sources()
|
||||||
|
for _, s := range srcs {
|
||||||
|
if s.Name != "channels-trae" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, m := range s.Models {
|
||||||
|
if len(m.ID) > 13 && m.ID[:13] == "custom_model_" {
|
||||||
|
t.Fatalf("上游源不应包含自定义模型槽位 %q", m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.Models) == 0 {
|
||||||
|
t.Fatal("channels-trae 源应带模型清单")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,7 +16,6 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"llmsproxy/internal/billing"
|
|
||||||
"llmsproxy/internal/config"
|
"llmsproxy/internal/config"
|
||||||
"llmsproxy/internal/lua"
|
"llmsproxy/internal/lua"
|
||||||
"llmsproxy/internal/provider"
|
"llmsproxy/internal/provider"
|
||||||
@ -1055,16 +1054,49 @@ func (c *Core) applyBillingDSL() {
|
|||||||
log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active)
|
log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
prices, err := billing.Compile(profile, c.cfg.Sources)
|
prices, staleRules, err := c.compileBillingPrices(profile, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[billing] profile %q not applied: %v", profile.ID, err)
|
// ONE stale rule URL must not take the whole price table down with it.
|
||||||
return
|
//
|
||||||
|
// The strict compiler rejects a profile whose rule matches no source,
|
||||||
|
// which is right for catching typos — but the runtime consequence used
|
||||||
|
// to be a bare `return`: every rule stopped applying, the bill came out
|
||||||
|
// at 0 and unpriced_reqs grew forever, with a single startup log line as
|
||||||
|
// the only trace. Found in production while fixing GitCode issue #1
|
||||||
|
// (a rule still pointed at .../inference/v1 after the source moved to
|
||||||
|
// .../inference/openai/v1; 37k requests had been recorded as unpriced).
|
||||||
|
//
|
||||||
|
// So: apply the rules that DO match, and say exactly what was dropped.
|
||||||
|
// The profile still fails loudly — it just no longer fails silently for
|
||||||
|
// everything else.
|
||||||
|
lenient, stale, lerr := c.compileBillingPrices(profile, true)
|
||||||
|
if lerr != nil {
|
||||||
|
log.Printf("[billing] profile %q not applied: %v (lenient retry also failed: %v)",
|
||||||
|
profile.ID, err, lerr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
def := profile.Default
|
||||||
|
if def == "" {
|
||||||
|
def = "unpriced"
|
||||||
|
}
|
||||||
|
log.Printf("[billing] ERROR profile %q has %d rule URL(s) matching no configured "+
|
||||||
|
"source: %v — applied the remaining %d rule(s); unmatched traffic falls through "+
|
||||||
|
"to default=%q and keeps counting in unpriced_reqs. FIX THE RULE URL: a stale URL "+
|
||||||
|
"used to zero out the entire price table.", profile.ID, len(stale), stale,
|
||||||
|
len(profile.Rules)-len(stale), def)
|
||||||
|
prices, staleRules = lenient, stale
|
||||||
}
|
}
|
||||||
|
_ = staleRules
|
||||||
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
|
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
|
||||||
log.Printf("[billing] applying profile %q: %v", profile.ID, err)
|
log.Printf("[billing] applying profile %q: %v", profile.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[billing] profile %q applied (%d rules, %d sources)", profile.ID, len(profile.Rules), len(c.cfg.Sources))
|
log.Printf("[billing] profile %q applied (%d rules, %d sources)", profile.ID, len(profile.Rules), len(c.cfg.Sources))
|
||||||
|
// Sources that no rule matched are encoded by the COMPILER (the plugin):
|
||||||
|
// `default: free` writes a {per_request:0} marker, `unpriced` leaves them out
|
||||||
|
// so the plugin's counters see them. The old `unmatched_sources` key was
|
||||||
|
// written by the compiler and read by nobody — deleted rather than kept as
|
||||||
|
// decoration (GitCode issue #1 / PR #2).
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateScopeQuotas checks every scope entry's caps before they are stored.
|
// ValidateScopeQuotas checks every scope entry's caps before they are stored.
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"llmsproxy/internal/billing"
|
|
||||||
"llmsproxy/internal/config"
|
"llmsproxy/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -238,7 +237,15 @@ func (g *Gateway) applyBillingDSLUpdate(w http.ResponseWriter, next *config.Bill
|
|||||||
// Compile before persisting: a profile that cannot be turned into prices
|
// 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
|
// must not reach the config file, or the next restart fails to load the
|
||||||
// very rules the editor just accepted.
|
// very rules the editor just accepted.
|
||||||
prices, err := billing.CompileOpts(next.Resolve(next.Active), cfg.Sources, true)
|
//
|
||||||
|
// The compilation itself runs in the BILLING PLUGIN (see
|
||||||
|
// core.compileBillingPrices): the kernel only decodes config.yaml and passes
|
||||||
|
// the profile + source URLs as data, so the "what does this rule mean"
|
||||||
|
// semantics live in exactly one place. Editor saves are lenient: a
|
||||||
|
// half-finished rule (URL typed before its source exists) must be saveable
|
||||||
|
// and reported, not rejected with an error the operator can only resolve by
|
||||||
|
// guessing.
|
||||||
|
prices, _, err := g.core.CompileBillingPrices(next.Resolve(next.Active), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cfg.BillingDSL = prev // no half-applied state
|
cfg.BillingDSL = prev // no half-applied state
|
||||||
writeError(w, http.StatusBadRequest, "compile_failed", err.Error())
|
writeError(w, http.StatusBadRequest, "compile_failed", err.Error())
|
||||||
|
|||||||
@ -224,31 +224,58 @@ func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("prices has no sources map: %#v", prices)
|
t.Fatalf("prices has no sources map: %#v", prices)
|
||||||
}
|
}
|
||||||
cc, ok := sources["commandcode"]
|
// A token rule must NOT produce a source entry at all: priceFor() applies
|
||||||
if !ok {
|
// the source dimension after the model dimension and lets it override the
|
||||||
t.Fatalf("the rule was saved but never injected: prices has %v, "+
|
// token rate, so a marker here is what silently zeroed the bill (issue #1 /
|
||||||
"no commandcode entry. Every request on it stays unpriced.", keysOf(sources))
|
// PR #2). Models absent from the rule surface as unpriced instead.
|
||||||
}
|
if entry, ok := sources["commandcode"]; ok {
|
||||||
if cc == nil {
|
t.Errorf("the token rule produced a source entry %#v — it overrides the "+
|
||||||
t.Error("commandcode price entry is nil")
|
"model price (issue #1)", entry)
|
||||||
}
|
}
|
||||||
// The free rule must price localzen, and the two must not be conflated.
|
// The free rule must price localzen, and the two must not be conflated.
|
||||||
if _, ok := sources["localzen"]; !ok {
|
if _, ok := sources["localzen"]; !ok {
|
||||||
t.Errorf("the free rule did not reach the plugin: %v", keysOf(sources))
|
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.
|
// Invariant: only DELIBERATE zero-token entries (mode=free/subscription) may
|
||||||
|
// carry prompt/completion, and they must be exactly 0. In this fixture the
|
||||||
|
// free rule covers localzen, so any other source with token prices — or a
|
||||||
|
// non-zero one — would override model rates (issue #1).
|
||||||
|
for name, raw := range sources {
|
||||||
|
entry, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name == "localzen" { // the explicit free rule of this fixture
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, key := range []string{"prompt", "completion"} {
|
||||||
|
if _, present := entry[key]; present {
|
||||||
|
t.Errorf("source %q carries %s=%v: it overrides every model price "+
|
||||||
|
"on that source (issue #1)", name, key, entry[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The injected MODEL price must be per-token, i.e. 0.15 USD/M => 1.5e-7.
|
||||||
// Asserting the exact magnitude catches a unit error (per-million vs
|
// 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
|
// per-token), which would be off by a factor of a million and still look
|
||||||
// like "a number".
|
// like "a number".
|
||||||
models, ok := cc.(map[string]interface{})["models"].(map[string]interface{})
|
//
|
||||||
if ok {
|
// ⚠️ This used to read `cc["models"]`, but `models` lives at
|
||||||
if m, ok := models["deepseek-v4.1-flash"].(map[string]interface{}); ok {
|
// prices["models"], not inside a source entry — so the type assertion was
|
||||||
p, _ := m["prompt"].(float64)
|
// always false and the whole block was skipped. It had never run, which is
|
||||||
if p <= 0 || p > 1e-6 {
|
// how a per-million unit error could survive a test whose comment claims
|
||||||
t.Errorf("injected prompt price = %v, want per-token (~1.5e-7); "+
|
// to catch exactly that.
|
||||||
"a per-million value here would over-bill by 1e6x", p)
|
models, ok := prices["models"].(map[string]interface{})
|
||||||
}
|
if !ok {
|
||||||
}
|
t.Fatalf("prices has no models map: %#v", prices)
|
||||||
|
}
|
||||||
|
m, ok := models["deepseek-v4.1-flash"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("the model price was not injected: %v", keysOf(models))
|
||||||
|
}
|
||||||
|
if p, _ := m["prompt"].(float64); p <= 0 || p > 1e-6 {
|
||||||
|
t.Errorf("injected prompt price = %v, want per-token (~1.5e-7); "+
|
||||||
|
"a per-million value here would over-bill by 1e6x", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -126,7 +126,12 @@ func TestRealMutants(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nested metadata no longer read",
|
name: "nested metadata no longer read",
|
||||||
from: "\tcopyFields(m.Metadata) // nested first",
|
// 锚点必须与 discover.go 逐字节一致:此前这里是 6 个空格,源码已
|
||||||
|
// 变成 2 个空格,于是这条变异永远"找不到锚点"——变异campaign 每次
|
||||||
|
// 都以 anchor not found 失败,而它本该验证"嵌套 metadata 被丢掉"能被
|
||||||
|
// 测试抓住。锚点漂移 = 这条变异从未真正跑过(和 issue #1 里
|
||||||
|
// `cc["models"]` 恒 false 的测试跳过是同一种失效:断言写了但没执行)。
|
||||||
|
from: "\tcopyFields(m.Metadata) // nested first",
|
||||||
to: "\tcopyFields(json.RawMessage(nil)) // mutant: nested metadata dropped",
|
to: "\tcopyFields(json.RawMessage(nil)) // mutant: nested metadata dropped",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -343,6 +343,16 @@ function __prelude.openai_error(status, body)
|
|||||||
return resp.detail
|
return resp.detail
|
||||||
end
|
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. 网关与腾讯系形状(实测漏过,见函数头注释)。
|
-- 7/8/9. 网关与腾讯系形状(实测漏过,见函数头注释)。
|
||||||
-- error_code 与 error_msg 同现时拼起来 —— 只看 msg 会丢掉可分类的码
|
-- error_code 与 error_msg 同现时拼起来 —— 只看 msg 会丢掉可分类的码
|
||||||
-- (参考 llm-adapter.ts:531 把两者都放进 parts 再 join)。
|
-- (参考 llm-adapter.ts:531 把两者都放进 parts 再 join)。
|
||||||
|
|||||||
@ -58,6 +58,12 @@ func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) {
|
|||||||
{"FastAPI detail", `{"detail":"boom"}`, "boom"},
|
{"FastAPI detail", `{"detail":"boom"}`, "boom"},
|
||||||
{"HTML title", `<html><title>413 Too Large</title></html>`, "413 Too Large"},
|
{"HTML title", `<html><title>413 Too Large</title></html>`, "413 Too Large"},
|
||||||
{"裸文本", "plain failure line\nmore", "plain failure line"},
|
{"裸文本", "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 {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
|||||||
402
internal/lua/billing_compile_test.go
Normal file
402
internal/lua/billing_compile_test.go
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
package lua
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 规则 → 价格表的编译现在**在插件里**(GitCode issue #1 的建议方向:内核只提供
|
||||||
|
// 钩子/接口,计费语义下沉到 Lua)。这些判据从原 internal/billing 的 Go 单测
|
||||||
|
// 平移而来,并补上 issue #1 暴露的两个形状陷阱。
|
||||||
|
//
|
||||||
|
// 为什么必须保留这些判据:语义一旦分裂回两处,就会出现"源层零价覆盖模型价"
|
||||||
|
// 这类静默归零 —— 账单加总正常、只是少报,且 unpriced_reqs 也不涨。
|
||||||
|
func compilePricesForTest(t *testing.T, profile map[string]interface{}, sources interface{}, lenient bool) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
ps, _ := billingVM(t)
|
||||||
|
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
|
||||||
|
"profile": profile, "sources": sources, "lenient": lenient,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile_prices: %v", err)
|
||||||
|
}
|
||||||
|
m, ok := out.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("compile_prices returned %T", out)
|
||||||
|
}
|
||||||
|
if e, hasErr := m["error"]; hasErr && fmt.Sprint(e) != "" {
|
||||||
|
t.Fatalf("编译失败: %v", e)
|
||||||
|
}
|
||||||
|
if p, ok := m["prices"].(map[string]interface{}); ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
t.Fatalf("no prices in %#v", m)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编译失败通过载荷里的 error 字段回传(内核桥的第二个返回值通道在插件导出上
|
||||||
|
// 不可靠;{error=...} 是本仓库的通行约定)。返回错误文本供断言。
|
||||||
|
func compilePricesErr(t *testing.T, profile map[string]interface{}, sources interface{}, lenient bool) string {
|
||||||
|
t.Helper()
|
||||||
|
ps, _ := billingVM(t)
|
||||||
|
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
|
||||||
|
"profile": profile, "sources": sources, "lenient": lenient,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
m, ok := out.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("compile_prices returned %T", out)
|
||||||
|
}
|
||||||
|
if e, hasErr := m["error"]; hasErr {
|
||||||
|
if s := fmt.Sprint(e); s != "" && s != "<nil>" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("预期编译失败,实得 %#v", m)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func srcList(base string) []map[string]interface{} {
|
||||||
|
return []map[string]interface{}{{"name": "mine", "base_url": base}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通配符 token 规则**不能**给源写零价条目:源层覆盖模型层,会把该源所有未列
|
||||||
|
// 价模型悄悄算成 0(原 Go 测试 TestWildcardTokenRuleDoesNotMarkPaidSources)。
|
||||||
|
func TestBillingCompileWildcardTokenDoesNotMarkSources(t *testing.T) {
|
||||||
|
sources := []map[string]interface{}{
|
||||||
|
{"name": "paid1", "base_url": "https://paid1.example.com/v1"},
|
||||||
|
{"name": "paid2", "base_url": "https://paid2.example.com/v1"},
|
||||||
|
{"name": "free1", "base_url": "https://free.example.com/v1"},
|
||||||
|
}
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{
|
||||||
|
map[string]interface{}{"url": "https://free.example.com/v1", "mode": "free"},
|
||||||
|
map[string]interface{}{"url": "*", "mode": "token", "models": map[string]interface{}{
|
||||||
|
"only-catalogued-model": map[string]interface{}{"prompt": "1.0", "completion": "2.0"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, sources, false)
|
||||||
|
srcs := prices["sources"].(map[string]interface{})
|
||||||
|
if _, ok := srcs["free1"]; !ok {
|
||||||
|
t.Error("显式 free 规则的源条目丢了(free 的 0 是语义)")
|
||||||
|
}
|
||||||
|
for _, name := range []string{"paid1", "paid2"} {
|
||||||
|
if _, ok := srcs[name]; ok {
|
||||||
|
t.Errorf("通配符 token 规则把 %q 标成已定价/0 价 —— 其未列价模型会算成 0 而不是进入 unpriced", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models := prices["models"].(map[string]interface{})
|
||||||
|
if _, ok := models["only-catalogued-model"]; !ok {
|
||||||
|
t.Error("通配符规则没有发布模型价")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// token 规则**不得**产生源条目(PR #2 的结论,比 issue 正文的 per_request 标记更收口)。
|
||||||
|
//
|
||||||
|
// 只写 {per_request:0} 虽然不会清零模型价,但同源上未列入 rule.Models 的模型仍被
|
||||||
|
// 视为"已定价"、静默计 0、unpriced 计数不涨 —— 仍是静默少报。统一收口后它们
|
||||||
|
// 落到 unpriced 统计里(可见);真正"边际成本为零"用 free/subscription。
|
||||||
|
func TestBillingCompileTokenRuleEmitsNoSourceEntry(t *testing.T) {
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{map[string]interface{}{
|
||||||
|
"url": "https://mine.example.com/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{
|
||||||
|
"m": map[string]interface{}{"prompt": "0.15", "completion": "0.60"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
|
||||||
|
srcs := prices["sources"].(map[string]interface{})
|
||||||
|
if entry, ok := srcs["mine"]; ok {
|
||||||
|
t.Errorf("token 规则产生了源条目 %#v:源层会覆盖模型价 / 静默计 0", entry)
|
||||||
|
}
|
||||||
|
// per-million → per-token:0.15 USD/M = 1.5e-7
|
||||||
|
m := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||||
|
if m["prompt"].(float64) != 1.5e-7 {
|
||||||
|
t.Errorf("prompt = %v, want 1.5e-7(per-token)", m["prompt"])
|
||||||
|
}
|
||||||
|
if m["completion"].(float64) != 6e-7 {
|
||||||
|
t.Errorf("completion = %v, want 6e-7", m["completion"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根因直防:token 规则的源条目不得携带 token 价。
|
||||||
|
// (free/subscription 是唯一合法的"有意零价",由下一个测试单独守住。)
|
||||||
|
func TestBillingCompileNoSourceEntryEverCarriesTokenPrices(t *testing.T) {
|
||||||
|
sources := []map[string]interface{}{
|
||||||
|
{"name": "a", "base_url": "https://a.example/v1"},
|
||||||
|
{"name": "b", "base_url": "https://b.example/v1"},
|
||||||
|
{"name": "c", "base_url": "https://c.example/v1"},
|
||||||
|
{"name": "d", "base_url": "https://d.example/v1"},
|
||||||
|
}
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{
|
||||||
|
map[string]interface{}{"url": "https://a.example/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m1": map[string]interface{}{"prompt": "1", "completion": "2"}}},
|
||||||
|
map[string]interface{}{"url": "https://b.example/v1", "mode": "unpriced"},
|
||||||
|
map[string]interface{}{"url": "*", "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m2": map[string]interface{}{"prompt": "3", "completion": "4"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, sources, false)
|
||||||
|
for name, raw := range prices["sources"].(map[string]interface{}) {
|
||||||
|
entry, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("源 %q 条目不是表: %#v", name, raw)
|
||||||
|
}
|
||||||
|
if _, bad := entry["prompt"]; bad {
|
||||||
|
t.Errorf("源 %q 携带 prompt=%v:会覆盖该源所有模型价", name, entry["prompt"])
|
||||||
|
}
|
||||||
|
if _, bad := entry["completion"]; bad {
|
||||||
|
t.Errorf("源 %q 携带 completion=%v:会覆盖该源所有模型价", name, entry["completion"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// free / subscription 的零价是**语义**,必须保留 prompt/completion=0。
|
||||||
|
func TestBillingCompileExplicitZeroModesKeepZeroTokenPrices(t *testing.T) {
|
||||||
|
for _, mode := range []string{"free", "subscription"} {
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{map[string]interface{}{
|
||||||
|
"url": "https://mine.example.com/v1", "mode": mode, "subscription": 10,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
|
||||||
|
entry := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
|
||||||
|
for _, key := range []string{"prompt", "completion"} {
|
||||||
|
if v, present := entry[key]; !present || v != 0.0 {
|
||||||
|
t.Errorf("%s: entry[%s] = %v (present=%v), want 显式 0", mode, key, v, present)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reasoning 未配置时必须**缺席**(不是 0)。写 0 等于"思考免费",会静默少报
|
||||||
|
// 所有把 reasoning 按输出计价的 provider。
|
||||||
|
func TestBillingCompileReasoningPriceOmittedUnlessSet(t *testing.T) {
|
||||||
|
base := map[string]interface{}{"prompt": "1.0", "completion": "2.0"}
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{map[string]interface{}{
|
||||||
|
"url": "https://mine.example.com/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{
|
||||||
|
"plain": base,
|
||||||
|
"reasoning": map[string]interface{}{"prompt": "1.0", "completion": "2.0", "reasoning": "3.0"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
|
||||||
|
models := prices["models"].(map[string]interface{})
|
||||||
|
if _, present := models["plain"].(map[string]interface{})["reasoning_price"]; present {
|
||||||
|
t.Error("未配置 reasoning 的模型不应出现 reasoning_price(0 会被当成免费)")
|
||||||
|
}
|
||||||
|
rp := models["reasoning"].(map[string]interface{})["reasoning_price"].(float64)
|
||||||
|
if rp != 3e-6 {
|
||||||
|
t.Errorf("reasoning_price = %v, want 3e-6", rp)
|
||||||
|
}
|
||||||
|
_ = base
|
||||||
|
}
|
||||||
|
|
||||||
|
// long_context:绝对值优先;只给倍数时按基础价的倍数;两者都缺要报错。
|
||||||
|
func TestBillingCompileLongContextRates(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
lc map[string]interface{}
|
||||||
|
want float64
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"absolute", map[string]interface{}{"above": 128000, "prompt": "0.30"}, 3e-7, false},
|
||||||
|
{"multiplier", map[string]interface{}{"above": 128000, "multiplier": 2}, 3e-7, false},
|
||||||
|
{"both-absolute-wins", map[string]interface{}{"above": 128000, "prompt": "0.30", "multiplier": 5}, 3e-7, false},
|
||||||
|
{"neither", map[string]interface{}{"above": 128000}, 0, true},
|
||||||
|
{"above-zero", map[string]interface{}{"above": 0, "multiplier": 2}, 0, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{map[string]interface{}{
|
||||||
|
"url": "https://mine.example.com/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{
|
||||||
|
"m": map[string]interface{}{"prompt": "0.15", "completion": "0.6",
|
||||||
|
"long_context": tc.lc},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if tc.wantErr {
|
||||||
|
compilePricesErr(t, profile, srcList("https://mine.example.com/v1"), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
|
||||||
|
lc := prices["models"].(map[string]interface{})["m"].(map[string]interface{})["long_context"].(map[string]interface{})
|
||||||
|
if lc["prompt"].(float64) != tc.want {
|
||||||
|
t.Errorf("%s: long_context.prompt = %v, want %v", tc.name, lc["prompt"], tc.want)
|
||||||
|
}
|
||||||
|
if lc["above"].(float64) != 128000 {
|
||||||
|
t.Errorf("%s: above = %v", tc.name, lc["above"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// peak 的形状是插件唯一读取的 {multiplier, windows=[{days,hours}]}。
|
||||||
|
func TestBillingCompilePeakShape(t *testing.T) {
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{map[string]interface{}{
|
||||||
|
"url": "https://mine.example.com/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "1"}},
|
||||||
|
"peak": map[string]interface{}{"multiplier": 2,
|
||||||
|
"weekdays": []interface{}{1, 2}, "hours": []interface{}{[]interface{}{1, 4}}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
|
||||||
|
pk := prices["models"].(map[string]interface{})["m"].(map[string]interface{})["peak"].(map[string]interface{})
|
||||||
|
if pk["multiplier"].(float64) != 2 {
|
||||||
|
t.Errorf("multiplier = %v", pk["multiplier"])
|
||||||
|
}
|
||||||
|
wins, ok := pk["windows"].([]interface{})
|
||||||
|
if !ok || len(wins) != 1 {
|
||||||
|
t.Fatalf("windows = %#v", pk["windows"])
|
||||||
|
}
|
||||||
|
win := wins[0].(map[string]interface{})
|
||||||
|
if len(win["days"].([]interface{})) != 2 || len(win["hours"].([]interface{})) != 1 {
|
||||||
|
t.Errorf("window = %#v", win)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL 归一:尾斜杠与大小写不应影响匹配;首条命中优先。
|
||||||
|
func TestBillingCompileURLMatchingAndFirstWins(t *testing.T) {
|
||||||
|
sources := []map[string]interface{}{
|
||||||
|
{"name": "mine", "base_url": "https://Mine.Example.com/v1"},
|
||||||
|
}
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{
|
||||||
|
map[string]interface{}{"url": "https://mine.example.com/v1/", "mode": "free"},
|
||||||
|
map[string]interface{}{"url": "https://mine.example.com/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "1"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, sources, false)
|
||||||
|
entry := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
|
||||||
|
if entry["prompt"].(float64) != 0 {
|
||||||
|
t.Errorf("首条规则(free)应优先,实得 %#v", entry)
|
||||||
|
}
|
||||||
|
if _, ok := prices["models"].(map[string]interface{})["m"]; ok {
|
||||||
|
t.Error("第二条规则不应再被应用(首条命中优先,避免顺序歧义)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 严格模式:无源匹配的 URL 是硬错误;lenient 模式跳过并回报具体 URL。
|
||||||
|
func TestBillingCompileUnmatchedRules(t *testing.T) {
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p",
|
||||||
|
"rules": []interface{}{
|
||||||
|
map[string]interface{}{"url": "https://mine.example.com/v1", "mode": "free"},
|
||||||
|
map[string]interface{}{"url": "https://stale.example.com/v1", "mode": "free"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if msg := compilePricesErr(t, profile, srcList("https://mine.example.com/v1"), false); msg == "" {
|
||||||
|
t.Fatal("严格模式必须报错")
|
||||||
|
}
|
||||||
|
ps, _ := billingVM(t)
|
||||||
|
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
|
||||||
|
"profile": profile, "sources": srcList("https://mine.example.com/v1"), "lenient": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lenient: %v", err)
|
||||||
|
}
|
||||||
|
m := out.(map[string]interface{})
|
||||||
|
rules := m["unmatched_rules"].([]interface{})
|
||||||
|
if len(rules) != 1 || rules[0] != "https://stale.example.com/v1" {
|
||||||
|
t.Errorf("unmatched_rules = %#v", rules)
|
||||||
|
}
|
||||||
|
prices := m["prices"].(map[string]interface{})
|
||||||
|
if _, ok := prices["sources"].(map[string]interface{})["mine"]; !ok {
|
||||||
|
t.Error("lenient 模式下有效规则仍须生效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 硬错误:未知 mode、token 无模型、负价、非数字、profile 不是表。
|
||||||
|
func TestBillingCompileHardErrors(t *testing.T) {
|
||||||
|
base := func(rule map[string]interface{}) map[string]interface{} {
|
||||||
|
return map[string]interface{}{"id": "p", "rules": []interface{}{rule}}
|
||||||
|
}
|
||||||
|
url := "https://mine.example.com/v1"
|
||||||
|
src := srcList(url)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
profile map[string]interface{}
|
||||||
|
wantSub string
|
||||||
|
}{
|
||||||
|
{"unknown mode", base(map[string]interface{}{"url": url, "mode": "wat"}), "unknown mode"},
|
||||||
|
{"token no models", base(map[string]interface{}{"url": url, "mode": "token"}), "requires at least one model"},
|
||||||
|
{"negative price", base(map[string]interface{}{"url": url, "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "-1", "completion": "1"}}}), "negative price"},
|
||||||
|
{"not a number", base(map[string]interface{}{"url": url, "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "abc", "completion": "1"}}}), "not a number"},
|
||||||
|
{"profile not table", nil, "no billing profile"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
msg := compilePricesErr(t, tc.profile, src, false)
|
||||||
|
if !strings.Contains(msg, tc.wantSub) {
|
||||||
|
t.Errorf("%s: error = %q, want substring %q", tc.name, msg, tc.wantSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// profile.default 是"没有规则命中的源"的策略,以前被校验后写进无人读的
|
||||||
|
// default_mode 键,运行时行为与 unpriced 完全一样(PR #2 修复)。
|
||||||
|
func TestBillingCompileProfileDefault(t *testing.T) {
|
||||||
|
sources := []map[string]interface{}{
|
||||||
|
{"name": "listed", "base_url": "https://listed.example/v1"},
|
||||||
|
{"name": "unlisted", "base_url": "https://unlisted.example/v1"},
|
||||||
|
}
|
||||||
|
tokenRule := map[string]interface{}{
|
||||||
|
"url": "https://listed.example/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "2"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: free ⇒ 未匹配源写 {per_request:0}(只标记、不碰 token 价)
|
||||||
|
profile := map[string]interface{}{
|
||||||
|
"id": "p", "default": "free",
|
||||||
|
"rules": []interface{}{tokenRule},
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, sources, false)
|
||||||
|
srcs := prices["sources"].(map[string]interface{})
|
||||||
|
un, ok := srcs["unlisted"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("default: free 没有标记未匹配源: %#v", srcs)
|
||||||
|
}
|
||||||
|
if _, bad := un["prompt"]; bad {
|
||||||
|
t.Errorf("default-free 标记携带 prompt=%v:会清零该源模型价", un["prompt"])
|
||||||
|
}
|
||||||
|
if _, ok := srcs["listed"]; ok {
|
||||||
|
t.Error("default: free 不应标记已被规则命中的源")
|
||||||
|
}
|
||||||
|
if _, ok := prices["models"].(map[string]interface{})["m"]; !ok {
|
||||||
|
t.Error("token 规则的模型价丢了")
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: unpriced / 缺省 ⇒ 不写任何标记(让 unpriced 计数看得见)
|
||||||
|
for _, def := range []interface{}{"unpriced", nil} {
|
||||||
|
profile := map[string]interface{}{"id": "p", "rules": []interface{}{}}
|
||||||
|
if def != nil {
|
||||||
|
profile["default"] = def
|
||||||
|
}
|
||||||
|
prices := compilePricesForTest(t, profile, sources, false)
|
||||||
|
if n := len(prices["sources"].(map[string]interface{})); n != 0 {
|
||||||
|
t.Errorf("default=%v 标记了 %d 个源;它们应保持未定价以便计数", def, n)
|
||||||
|
}
|
||||||
|
if _, present := prices["default_mode"]; present {
|
||||||
|
t.Error("default_mode 是无人读取的死键,不应再写入")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1236,3 +1236,133 @@ func TestLongContextComposesWithCacheDiscount(t *testing.T) {
|
|||||||
// total = 0.2325
|
// total = 0.2325
|
||||||
approx(t, "step composes with cache discount", got, 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 策略必须真的生效 —— 且它是**编译期**语义(PR #2):
|
||||||
|
// `default: free` 由 compile_prices 给未匹配的源写 {per_request:0} 标记;
|
||||||
|
// `unpriced`/缺省不写。旧实现把 default 写进没人读的 default_mode 键,
|
||||||
|
// 两种取值运行时完全等价。
|
||||||
|
func TestBillingProfileDefaultIsDecidedAtCompileTime(t *testing.T) {
|
||||||
|
sources := []map[string]interface{}{
|
||||||
|
{"name": "listed", "base_url": "https://listed.example/v1"},
|
||||||
|
{"name": "unlisted", "base_url": "https://unlisted.example/v1"},
|
||||||
|
}
|
||||||
|
tokenRule := map[string]interface{}{
|
||||||
|
"url": "https://listed.example/v1", "mode": "token",
|
||||||
|
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "2"}},
|
||||||
|
}
|
||||||
|
run := func(t *testing.T, profile map[string]interface{}) float64 {
|
||||||
|
t.Helper()
|
||||||
|
ps, _ := billingVM(t)
|
||||||
|
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
|
||||||
|
"profile": profile, "sources": sources, "lenient": false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile_prices: %v", err)
|
||||||
|
}
|
||||||
|
m := out.(map[string]interface{})
|
||||||
|
if e, bad := m["error"]; bad && fmt.Sprint(e) != "" {
|
||||||
|
t.Fatalf("compile: %v", e)
|
||||||
|
}
|
||||||
|
_ = ps.SetState("billing", map[string]interface{}{"prices": m["prices"]})
|
||||||
|
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||||
|
"model": "some-unlisted-model", "source": "unlisted", "key": "***p", "ok": true,
|
||||||
|
"prompt_tokens": 1000.0, "completion_tokens": 10.0, "time": 1750000000000,
|
||||||
|
})
|
||||||
|
return stateOf(t, ps)["unpriced_reqs"].(float64)
|
||||||
|
}
|
||||||
|
|
||||||
|
free := map[string]interface{}{"id": "p", "default": "free", "rules": []interface{}{tokenRule}}
|
||||||
|
if got := run(t, free); got != 0 {
|
||||||
|
t.Errorf("default=free: unpriced_reqs = %v, want 0(刻意免费,不该计入债务)", got)
|
||||||
|
}
|
||||||
|
for _, def := range []interface{}{"unpriced", nil} {
|
||||||
|
profile := map[string]interface{}{"id": "p", "rules": []interface{}{tokenRule}}
|
||||||
|
if def != nil {
|
||||||
|
profile["default"] = def
|
||||||
|
}
|
||||||
|
if got := run(t, profile); got != 1 {
|
||||||
|
t.Errorf("default=%v: unpriced_reqs = %v, want 1(未匹配源必须可见)", def, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -72,6 +72,228 @@ local DEFAULT_PRICES = {
|
|||||||
}
|
}
|
||||||
plugin.prices = DEFAULT_PRICES
|
plugin.prices = DEFAULT_PRICES
|
||||||
|
|
||||||
|
-- ===== 规则 → 价格表编译(Lua 侧,内核只给数据) ======================
|
||||||
|
--
|
||||||
|
-- 为什么编译在插件里(GitCode issue #1 的建议方向):
|
||||||
|
-- 内核只负责把 config.yaml 解成结构体(YAML 解析 + schema 校验),并把
|
||||||
|
-- profile 与源的 base_url 作为**数据**通过 plugin.compile_prices 传进插件;
|
||||||
|
-- "规则如何变成价目表"(free/subscription/unpriced/token、URL 归一匹配、
|
||||||
|
-- 首条命中优先、token 价的 per-million → per-token、peak 窗口、
|
||||||
|
-- long_context 边际价、reasoning 可选价、未匹配规则/源)全部在这里。
|
||||||
|
--
|
||||||
|
-- 这样内核不需要认识任何计费语义,也不会再出现"两处各写一半、组合起来
|
||||||
|
-- 静默归零"——issue #1 的真因正是 compile.go 与 priceFor() 各有一套覆盖语义。
|
||||||
|
--
|
||||||
|
-- 返回形态(供内核 SetState 注入 plugin.prices):
|
||||||
|
-- { prices = <价目表>, unmatched_rules = {...}, unmatched_sources = {...} }
|
||||||
|
-- 硬错误用 `return nil, "原因"`,内核据此拒绝保存/记录 ERROR。
|
||||||
|
local function normalizeURL(u)
|
||||||
|
u = tostring(u or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
u = u:gsub("/+$", "")
|
||||||
|
return u:lower()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function matchSources(pattern, sources)
|
||||||
|
local out = {}
|
||||||
|
if pattern == "*" then
|
||||||
|
for _, s in ipairs(sources or {}) do out[#out + 1] = s.name end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
local want = normalizeURL(pattern)
|
||||||
|
for _, s in ipairs(sources or {}) do
|
||||||
|
if normalizeURL(s.base_url) == want then out[#out + 1] = s.name end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- USD per million → USD per token. 与内核校验用的是同一口径(/1e6),
|
||||||
|
-- 数值范围与 Go 的 ParseFloatUSDPerM 一致(不允许负数/非数字)。
|
||||||
|
local function usdPerM(v)
|
||||||
|
local s = tostring(v == nil and "" or v)
|
||||||
|
s = s:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
local n = tonumber(s)
|
||||||
|
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
|
||||||
|
|
||||||
|
-- peak 窗口的**唯一**合法形状:{ multiplier, windows = { { days, hours } } }。
|
||||||
|
-- 早期部署过的 {start,end,weekdays} 没有任何读取方(peak 静默按非高峰计价)。
|
||||||
|
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
|
||||||
|
|
||||||
|
-- long_context 的边际价:绝对值(USD/M 字符串)优先,其次倍数 × 基础价。
|
||||||
|
-- 两者都缺 ⇒ 报错(一个"没有价格的阶梯"是静默 no-op,写它的人一定有意图)。
|
||||||
|
local function longContextRate(lc, basePerToken)
|
||||||
|
local prompt = tostring(lc.prompt == nil and "" or lc.prompt)
|
||||||
|
prompt = prompt:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
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
|
||||||
|
|
||||||
|
local function applyRule(prices, rule, srcName)
|
||||||
|
local mode = rule.mode
|
||||||
|
if mode == "free" or mode == "subscription" then
|
||||||
|
-- 显式零价:这里 0 本身就是语义(不是"未定价")。
|
||||||
|
prices.sources[srcName] = { prompt = 0, completion = 0, per_request = 0 }
|
||||||
|
if mode == "subscription" then
|
||||||
|
if prices.subscriptions == nil then prices.subscriptions = {} end
|
||||||
|
local cur = tostring(rule.currency == nil and "" or 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
|
||||||
|
-- 刻意不写源条目:让 unpriced_reqs/unpriced_models 抓住它。
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if mode == "token" then
|
||||||
|
if type(rule.models) ~= "table" or next(rule.models) == nil then
|
||||||
|
return nil, string.format("rule for url %q: mode token requires at least one model",
|
||||||
|
tostring(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
|
||||||
|
-- reasoning 可选,且**缺席**而非 0:写 0 等于"思考免费",会静默少报
|
||||||
|
-- 每一个把 reasoning 按输出计价的 provider。
|
||||||
|
local reasoning = tostring(t.reasoning == nil and "" or t.reasoning)
|
||||||
|
reasoning = reasoning:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
if reasoning ~= "" then
|
||||||
|
local rp, rerr = usdPerM(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 规则**不写任何源条目**(模型维度只发布模型价)。
|
||||||
|
--
|
||||||
|
-- 此前写 `{per_request:0}` 或 `{prompt:0,completion:0}` 都是为了"标记该源
|
||||||
|
-- 已定价",但 priceFor() 让源层**无条件覆盖**模型层的 prompt/completion:
|
||||||
|
-- * `{prompt:0,completion:0}` ⇒ 规则刚发布的模型价被清零(issue #1 本体);
|
||||||
|
-- * 只写 `{per_request:0}` ⇒ 模型价没被清零,但同源上**未列入 rule.Models**
|
||||||
|
-- 的模型仍被评为"已定价"、静默计 0,unpriced 计数依旧不涨。
|
||||||
|
-- 第二条仍然是"静默少报",只是窄一点。所以统一收口:token 规则不产生源条目,
|
||||||
|
-- 未列价的模型落到 unpriced 统计里(可见)。真正"边际成本为零"用
|
||||||
|
-- mode=free / mode=subscription —— 它们写的是有意为之的零价标记。
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return nil, string.format("unknown mode %q", tostring(mode))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function compilePrices(profile, sources, lenient)
|
||||||
|
if type(profile) ~= "table" then return nil, "no billing profile" end
|
||||||
|
sources = sources or {}
|
||||||
|
local prices = {
|
||||||
|
currency = "USD",
|
||||||
|
default = { prompt = 0, completion = 0, per_request = 0 },
|
||||||
|
sources = {}, models = {}, keys = {},
|
||||||
|
}
|
||||||
|
local rules = type(profile.rules) == "table" and profile.rules or {}
|
||||||
|
-- currency 是 profile 级声明:第一条带 currency 的规则生效。
|
||||||
|
for i = 1, #rules do
|
||||||
|
local c = rules[i].currency
|
||||||
|
if type(c) == "string" and c ~= "" then prices.currency = c break end
|
||||||
|
end
|
||||||
|
local matched = {}
|
||||||
|
local unmatched_rules = {}
|
||||||
|
for i = 1, #rules do
|
||||||
|
local rule = rules[i]
|
||||||
|
local url = tostring(rule.url == nil and "" or rule.url)
|
||||||
|
local targets = matchSources(url, sources)
|
||||||
|
if #targets == 0 and url ~= "*" then
|
||||||
|
if lenient then
|
||||||
|
unmatched_rules[#unmatched_rules + 1] = url
|
||||||
|
else
|
||||||
|
return nil, string.format("rule url %q matches no configured source base_url", url)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for _, srcName in ipairs(targets) do
|
||||||
|
-- 首条命中优先:两条规则匹配同一个源是歧义,后面的不能悄悄覆盖前面的。
|
||||||
|
if not matched[srcName] then
|
||||||
|
matched[srcName] = true
|
||||||
|
local ok, err = applyRule(prices, rule, srcName)
|
||||||
|
if not ok then return nil, err end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- profile 的 default 策略:只影响**没有规则命中**的源。
|
||||||
|
--
|
||||||
|
-- `free` = 运营声明"其余源一律免费" ⇒ 必须被识别为已定价,否则它们会一直计入
|
||||||
|
-- unpriced,告警变成噪音;标记用 `{per_request:0}`(只标记、不碰 token 价,
|
||||||
|
-- 否则任何通配符价目表都会被它清零,等于同一个 bug 换个入口复现)。
|
||||||
|
-- `unpriced`(或缺省)= 不写条目,让插件的 unpriced 计数看得见它们。
|
||||||
|
--
|
||||||
|
-- 这个策略以前只被校验、却被写进一个没人读的 `default_mode` 键 ⇒
|
||||||
|
-- `default: free` 与 `default: unpriced` 运行时完全等价。
|
||||||
|
if profile.default == "free" then
|
||||||
|
for _, src in ipairs(sources) do
|
||||||
|
if not matched[src.name] and prices.sources[src.name] == nil then
|
||||||
|
prices.sources[src.name] = { per_request = 0 }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local unmatched_sources = {}
|
||||||
|
for _, src in ipairs(sources) do
|
||||||
|
if not matched[src.name] then unmatched_sources[#unmatched_sources + 1] = src.name end
|
||||||
|
end
|
||||||
|
return { prices = prices, unmatched_rules = unmatched_rules, unmatched_sources = unmatched_sources }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 内核入口:profile 与源的 base_url 都是**数据**,插件不认识 config.yaml。
|
||||||
|
plugin.compile_prices = function(args)
|
||||||
|
args = args or {}
|
||||||
|
local out, err = compilePrices(args.profile, args.sources, args.lenient and true or false)
|
||||||
|
-- 错误用**表里的 error 字段**回传,而不是第二个返回值:内核桥的
|
||||||
|
-- `Call` 只把第一个返回值解码成数据(第二个返回值的错误通道在插件导出上
|
||||||
|
-- 并不可靠),用 {error=...} 是本仓库其它插件导出一致的约定。
|
||||||
|
if not out then return { error = tostring(err) } end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
plugin.exports = plugin.exports or {}
|
||||||
|
plugin.exports[#plugin.exports + 1] = "compile_prices"
|
||||||
|
|
||||||
-- Default prompt-cache discount. 0.1 = a cache read costs a tenth of a fresh
|
-- 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
|
-- 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
|
-- overridden per price entry (prices.models.<m>.cache_discount) or globally by
|
||||||
@ -189,7 +411,11 @@ local function priceFor(payload)
|
|||||||
-- look the same: an unpriced model silently costing 0 is the most dangerous
|
-- 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
|
-- 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.
|
-- quietly under-reports. It is counted separately and surfaced in the UI.
|
||||||
out = {
|
-- ★ local 是必须的:此前写的是全局 `out = {`,而 priceFor 每个请求都会调用,
|
||||||
|
-- 于是这个表挂在 VM 级全局变量上跨请求残留/泄漏(GitCode issue #1 顺带发现)。
|
||||||
|
-- 功能上因为每次整体重建而侥幸正确,但它是共享可变状态,任何提前 return 或
|
||||||
|
-- 后续分支忘了重建都会让上一单的价格漏到下一单 —— 计费错误不会报错。
|
||||||
|
local out = {
|
||||||
prompt = d.prompt or 0, completion = d.completion or 0,
|
prompt = d.prompt or 0, completion = d.completion or 0,
|
||||||
per_request = 0, cache_discount = d.cache_discount, peak = d.peak,
|
per_request = 0, cache_discount = d.cache_discount, peak = d.peak,
|
||||||
-- nil = bill reasoning at the completion rate, which is what every
|
-- nil = bill reasoning at the completion rate, which is what every
|
||||||
@ -236,6 +462,7 @@ local function priceFor(payload)
|
|||||||
if kp.cache_discount ~= nil then out.cache_discount = kp.cache_discount end
|
if kp.cache_discount ~= nil then out.cache_discount = kp.cache_discount end
|
||||||
if kp.peak ~= nil then out.peak = kp.peak end
|
if kp.peak ~= nil then out.peak = kp.peak end
|
||||||
end
|
end
|
||||||
|
|
||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1156,7 +1156,7 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
raw, status, err := p.do(ctx, chatURL, body, hdrs)
|
raw, status, err := p.doWithRetry(ctx, chatURL, body, hdrs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// a client disconnect or cancelled context is neither a success nor
|
// a client disconnect or cancelled context is neither a success nor
|
||||||
// a failure for scheduling purposes — only upstream errors count
|
// a failure for scheduling purposes — only upstream errors count
|
||||||
@ -1227,8 +1227,48 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
|||||||
}
|
}
|
||||||
rc := make(chan respOrErr, 1)
|
rc := make(chan respOrErr, 1)
|
||||||
go func() {
|
go func() {
|
||||||
resp, err := p.doRawStream(ctx, streamURL, body, hdrs)
|
// Retry only the HANDSHAKE: a policy-matched rejection (e.g. a
|
||||||
rc <- respOrErr{resp, err}
|
// per-account concurrency cap answered with a business 4xx) can clear
|
||||||
|
// within seconds, and nothing has been sent to the client yet — the
|
||||||
|
// kernel holds the first chunk back. Once a 200 arrives and streaming
|
||||||
|
// starts, no retry happens (that would duplicate output).
|
||||||
|
rp := p.cfg.Retry
|
||||||
|
attempts := 1
|
||||||
|
if rp != nil && rp.MaxAttempts > 1 {
|
||||||
|
attempts = rp.MaxAttempts
|
||||||
|
}
|
||||||
|
for i := 0; i < attempts; i++ {
|
||||||
|
resp, err := p.doRawStream(ctx, streamURL, body, hdrs)
|
||||||
|
if err != nil {
|
||||||
|
rc <- respOrErr{resp, err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 200 {
|
||||||
|
rc <- respOrErr{resp, nil}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A rejection can only be classified after the body is read: the
|
||||||
|
// policy matches on body substrings, and a source may use the same
|
||||||
|
// status (400) for unrelated errors that must NOT be retried.
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if !rp.ShouldRetry(resp.StatusCode, string(raw)) {
|
||||||
|
// Not the retryable body: hand the rejection back verbatim so
|
||||||
|
// the caller reports the real upstream error.
|
||||||
|
resp2 := &http.Response{StatusCode: resp.StatusCode, Body: io.NopCloser(strings.NewReader(string(raw)))}
|
||||||
|
rc <- respOrErr{resp2, nil}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i == attempts-1 {
|
||||||
|
resp2 := &http.Response{StatusCode: resp.StatusCode, Body: io.NopCloser(strings.NewReader(string(raw)))}
|
||||||
|
rc <- respOrErr{resp2, nil}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if serr := sleepCtx(ctx, rp.DelayDuration()); serr != nil {
|
||||||
|
rc <- respOrErr{nil, serr}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
inner := make(chan types.UnifiedChunk, 64)
|
inner := make(chan types.UnifiedChunk, 64)
|
||||||
@ -1560,6 +1600,50 @@ func (p *Provider) do(ctx context.Context, url, body string, hdr http.Header) (s
|
|||||||
return string(raw), resp.StatusCode, nil
|
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) {
|
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)))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte(body)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
131
internal/provider/retry_policy_test.go
Normal file
131
internal/provider/retry_policy_test.go
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"llmsproxy/internal/config"
|
||||||
|
"llmsproxy/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 上游重试是**内核的通用机制**,判据纯数据(status + body 子串),
|
||||||
|
// 目的是让"账号级限流、等一会儿就好"这类拒绝不冒到客户端。
|
||||||
|
//
|
||||||
|
// 真实事故(2026-10-20):CodeArts 账户并发会话上限 3 个,连续第 3~4 个请求
|
||||||
|
// 回 400 TM.00001041,最后一个请求结束后约 44~60 秒才释放;流式更糟 ——
|
||||||
|
// 上游用 HTTP 200 + error 帧表达拒绝,内核只能报 `empty stream`。
|
||||||
|
// 参考实现的处理是每 10s 重试一次 chat 直到放行,本测试钉住同一语义。
|
||||||
|
func TestChatRetriesDeclaredUpstreamRejection(t *testing.T) {
|
||||||
|
var hits int32
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := atomic.AddInt32(&hits, 1)
|
||||||
|
if n <= 2 {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
fmt.Fprint(w, `{"error_code":"TM.00001041","error_msg":"并发会话数已达上限(3个)"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]}`)
|
||||||
|
}))
|
||||||
|
defer up.Close()
|
||||||
|
|
||||||
|
s := src("codearts-like", up.URL, "openai", "m1")
|
||||||
|
s.Retry = &config.RetryPolicy{
|
||||||
|
OnStatus: []int{400},
|
||||||
|
OnBodyContains: []string{"TM.00001041"},
|
||||||
|
Delay: "1ms",
|
||||||
|
MaxAttempts: 4,
|
||||||
|
}
|
||||||
|
p := newTestProvider(t, s)
|
||||||
|
resp, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||||
|
Model: "m1",
|
||||||
|
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("命中重试策略后应成功,实得错误: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Content != "hi" {
|
||||||
|
t.Fatalf("content = %q", resp.Content)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&hits); got != 3 {
|
||||||
|
t.Fatalf("应恰好尝试 3 次(2 次失败 + 1 次成功),实得 %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一状态码但 body 不匹配 ⇒ **不许重试**(400 也常用于真正的请求错误,
|
||||||
|
// 无差别重试只会把错误延迟 3 分钟才暴露给用户)。
|
||||||
|
func TestChatDoesNotRetryUnmatchedBody(t *testing.T) {
|
||||||
|
var hits int32
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&hits, 1)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
fmt.Fprint(w, `{"error_code":"TM.00001005","error_msg":"无正常可用套餐"}`)
|
||||||
|
}))
|
||||||
|
defer up.Close()
|
||||||
|
|
||||||
|
s := src("codearts-like", up.URL, "openai", "m1")
|
||||||
|
s.Retry = &config.RetryPolicy{
|
||||||
|
OnStatus: []int{400},
|
||||||
|
OnBodyContains: []string{"TM.00001041"},
|
||||||
|
Delay: "1ms",
|
||||||
|
MaxAttempts: 5,
|
||||||
|
}
|
||||||
|
p := newTestProvider(t, s)
|
||||||
|
_, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||||
|
Model: "m1",
|
||||||
|
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("不可重试的 body 必须失败")
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&hits); got != 1 {
|
||||||
|
t.Fatalf("body 不匹配时只能尝试 1 次,实得 %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式:只重试**握手**。命中策略 → 重试直到 200 才开始派发;一旦开始流式
|
||||||
|
// 就不再重试(重复输出比报错更糟)。
|
||||||
|
func TestChatStreamRetriesHandshakeOnly(t *testing.T) {
|
||||||
|
var hits int32
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := atomic.AddInt32(&hits, 1)
|
||||||
|
if n == 1 {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
fmt.Fprint(w, `{"error_code":"TM.00001041","error_msg":"并发会话数已达上限(3个)"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n")
|
||||||
|
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||||
|
}))
|
||||||
|
defer up.Close()
|
||||||
|
|
||||||
|
s := src("codearts-like", up.URL, "openai", "m1")
|
||||||
|
s.Retry = &config.RetryPolicy{
|
||||||
|
OnStatus: []int{400},
|
||||||
|
OnBodyContains: []string{"TM.00001041"},
|
||||||
|
Delay: "1ms",
|
||||||
|
MaxAttempts: 4,
|
||||||
|
}
|
||||||
|
p := newTestProvider(t, s)
|
||||||
|
ch, err := p.ChatStream(context.Background(), &types.ChatRequest{
|
||||||
|
Model: "m1",
|
||||||
|
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("握手重试后应成功: %v", err)
|
||||||
|
}
|
||||||
|
var content string
|
||||||
|
for ck := range ch {
|
||||||
|
content += ck.Content
|
||||||
|
}
|
||||||
|
if content != "hi" {
|
||||||
|
t.Fatalf("content = %q", content)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&hits); got != 2 {
|
||||||
|
t.Fatalf("应握手重试 1 次(共 2 次请求),实得 %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user