mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
Merge release/v1.7.x into main for v1.8.0
main had fallen a full feature generation behind: 13739 lines and 29 files
that main did not contain at all (the whole Lua plugin mechanism, the billing
package, stats_period, plugins_api, docs/plugins.md). v1.7.x contains
everything main has and nothing main lacks, so this is a one-way merge rather
than two divergent lines.
Eight conflicts, each read before resolving — the script that does it lives at
.probe/resolve_merge_conflicts.py and aborts rather than guess:
* cmd/gui/package.json, cmd/gui/package-lock.json — main already carries 1.8.0
(4b37e1a); the release line carries 1.7.6. Kept 1.8.0. Taking 1.7.6 would
ship tag v1.8.0 next to a manifest claiming 1.7.6.
* packaging/config.example.yaml (2 hunks) — release side is a superset both
times: it documents the `auto:` field, and it replaces the older one-line
runtime_file note with a paragraph that also states where templates and
deleted-markers live and warns that the AUTO chain, gateway keys and sources
are in config.yaml. HEAD's wording is strictly less.
* internal/config/config.go, internal/core/core.go, internal/gateway/api.go,
internal/gateway/apiv1.go, internal/gateway/stats.go (6 hunks) — HEAD is
zero-length in every one and the release side is the new code: BillingDSL
types, applyBillingDSL, the pointer-semantics source upsert, the
optional_fields doc string, the AUTO chain-walk field. The resolver asserts
the empty-HEAD property instead of assuming it.
Verified before committing: no residual conflict markers, both manifests read
1.8.0, `go build -tags luajit ./...` clean, `go test -tags luajit ./...` all nine
packages pass.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@ -26,3 +26,7 @@ cmd/gui/dist/
|
||||
|
||||
# local design/working notes (not part of the shipped repo)
|
||||
/plan.md
|
||||
|
||||
# scratch build/probe dirs used while debugging (GOTMPDIR, throwaway binaries)
|
||||
.build-work/
|
||||
.probe/
|
||||
|
||||
@ -671,6 +671,78 @@ ipcMain.handle(
|
||||
(e) => !!BrowserWindow.fromWebContents(e.sender)?.isMaximized(),
|
||||
);
|
||||
|
||||
// ---- plugin management over IPC -------------------------------------------
|
||||
//
|
||||
// The renderer cannot call the embedded core directly: it has no key and no
|
||||
// network identity, and the core binds a loopback port that only the main
|
||||
// process knows about. So every plugin action is proxied through the main
|
||||
// process, which already knows how to obtain the admin key (unsealViaCore).
|
||||
//
|
||||
// The proxy is deliberately a raw (method, path, body) pass-through rather than
|
||||
// a fixed set of commands. A fixed set would have to be extended for every new
|
||||
// plugin endpoint, and the one thing worse than "no button for this" is "a
|
||||
// button that silently does nothing" — with a pass-through the renderer can talk
|
||||
// to any /api/plugins route the core grows, and the path is validated here so
|
||||
// this channel cannot be used to reach arbitrary endpoints.
|
||||
function pluginProxy(req) {
|
||||
const { method, path, body } = req || {};
|
||||
const M = ["GET", "POST", "PUT", "DELETE"];
|
||||
if (!M.includes(method)) throw new Error("bad method: " + method);
|
||||
// The path must stay inside the plugin namespace. A prefix check alone would
|
||||
// still allow /api/plugins/../keys, so reject any traversal outright.
|
||||
if (typeof path !== "string" || !path.startsWith("/api/plugins")) {
|
||||
throw new Error("path must start with /api/plugins");
|
||||
}
|
||||
if (path.includes("..") || path.includes("\\")) {
|
||||
throw new Error("path traversal rejected");
|
||||
}
|
||||
const key = unsealViaCore();
|
||||
if (!key) throw new Error("no admin key available yet");
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(embeddedBaseUrl() + path);
|
||||
const data = body == null ? null : JSON.stringify(body);
|
||||
const headers = { Authorization: "Bearer " + key };
|
||||
if (data) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Content-Length"] = Buffer.byteLength(data);
|
||||
}
|
||||
const r = http.request(
|
||||
{
|
||||
hostname: u.hostname,
|
||||
port: u.port,
|
||||
path: u.pathname + u.search,
|
||||
method,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
let raw = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (c) => (raw += c));
|
||||
res.on("end", () => {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = raw ? JSON.parse(raw) : null;
|
||||
} catch (e) {
|
||||
parsed = { raw };
|
||||
}
|
||||
if (res.statusCode >= 400) {
|
||||
const msg =
|
||||
(parsed && parsed.error && parsed.error.message) ||
|
||||
"HTTP " + res.statusCode;
|
||||
reject(new Error(msg));
|
||||
return;
|
||||
}
|
||||
resolve(parsed);
|
||||
});
|
||||
},
|
||||
);
|
||||
r.on("error", reject);
|
||||
if (data) r.write(data);
|
||||
r.end();
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("plugins:proxy", (_e, req) => pluginProxy(req));
|
||||
ipcMain.handle("core:state", () => ({
|
||||
running: coreStarted() && coreReady,
|
||||
ready: coreReady,
|
||||
|
||||
4
cmd/gui/package-lock.json
generated
4
cmd/gui/package-lock.json
generated
@ -582,7 +582,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/webcrypto": {
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz",
|
||||
"integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==",
|
||||
"dev": true,
|
||||
@ -3335,7 +3335,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/resedit": {
|
||||
"version": "1.7.2",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||
"integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==",
|
||||
"dev": true,
|
||||
|
||||
@ -16,6 +16,13 @@ contextBridge.exposeInMainWorld("modelrouter", {
|
||||
key: () => ipcRenderer.invoke("core:key"),
|
||||
onState: (cb) => ipcRenderer.on("core:state", (_e, d) => cb(d)),
|
||||
},
|
||||
plugins: {
|
||||
// Raw pass-through to the embedded core's /api/plugins surface. The main
|
||||
// process validates the path and attaches the admin key; the renderer never
|
||||
// sees either.
|
||||
request: (method, path, body) =>
|
||||
ipcRenderer.invoke("plugins:proxy", { method, path, body }),
|
||||
},
|
||||
settings: {
|
||||
get: () => ipcRenderer.invoke("settings:get"),
|
||||
set: (patch) => ipcRenderer.invoke("settings:set", patch),
|
||||
|
||||
@ -155,6 +155,7 @@ async function openSettings() {
|
||||
$("#set-tray").checked = !!state.settings.minimizeToTray;
|
||||
$("#settings-overlay").style.display = "flex";
|
||||
renderRail();
|
||||
loadPlugins();
|
||||
}
|
||||
function closeSettings() {
|
||||
$("#settings-overlay").style.display = "none";
|
||||
@ -182,6 +183,122 @@ async function saveSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// esc / escAttr escape text for innerHTML. The WebUI has its own copies; the
|
||||
// shell needs its own because renderer/app.js is a separate document that
|
||||
// never loads index.html's script.
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
||||
);
|
||||
}
|
||||
function escAttr(s) {
|
||||
return esc(s).replace(/`/g, "`");
|
||||
}
|
||||
|
||||
// ===== plugin management =====
|
||||
//
|
||||
// The desktop shell manages plugins through the embedded core's /api/plugins
|
||||
// surface, proxied over IPC (see plugins:proxy in the main process). The
|
||||
// renderer never holds the admin key.
|
||||
//
|
||||
// Scope note: the desktop build has no plugin_dir configured by default, so this
|
||||
// panel normally reports "plugins disabled" with the one-line fix. That is a
|
||||
// deliberate state, not an error — the packaged profile is a per-user directory
|
||||
// and seeding a plugin tree into someone's home without asking would be rude.
|
||||
|
||||
async function loadPlugins() {
|
||||
const list = $("#pl-list");
|
||||
const hint = $("#set-plugins-hint");
|
||||
if (!list || !hint) return;
|
||||
let j;
|
||||
try {
|
||||
j = await window.modelrouter.plugins.request("GET", "/api/plugins");
|
||||
} catch (e) {
|
||||
hint.textContent = "内核未就绪:" + e.message;
|
||||
list.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
if (!j.plugin_dir) {
|
||||
hint.innerHTML =
|
||||
'未配置 <code>plugin_dir</code>,插件功能未启用。在 config.yaml 加一行后重启内核即可。';
|
||||
list.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const rows = j.on_disk || [];
|
||||
const active = rows.filter((p) => p.loaded && !p.disabled).length;
|
||||
const broken = rows.filter((p) => !p.loaded).length;
|
||||
hint.textContent =
|
||||
`${rows.length} 个插件 · ${active} 个启用中` +
|
||||
(broken ? ` · ${broken} 个加载失败` : "");
|
||||
list.innerHTML = rows.length
|
||||
? rows
|
||||
.map((p) => {
|
||||
const cls = !p.loaded ? "pl-broken" : p.disabled ? "pl-off" : "pl-on";
|
||||
const label = !p.loaded
|
||||
? "加载失败"
|
||||
: p.disabled
|
||||
? "已禁用"
|
||||
: "启用中";
|
||||
const btn = p.loaded
|
||||
? `<button class="ghost" data-act="toggle" data-name="${escAttr(
|
||||
p.name,
|
||||
)}" data-en="${p.disabled ? "1" : "0"}">${
|
||||
p.disabled ? "启用" : "禁用"
|
||||
}</button>`
|
||||
: "";
|
||||
const builtin = p.builtin
|
||||
? '<span class="pl-builtin">内置</span>'
|
||||
: "";
|
||||
return `<div class="pl-item ${cls}">
|
||||
<div class="pl-head"><b>${esc(p.name)}</b>${builtin}<span class="pl-state">${label}</span></div>
|
||||
${p.description ? `<div class="pl-desc">${esc(p.description)}</div>` : ""}
|
||||
${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""}
|
||||
<div class="pl-acts">${btn}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("")
|
||||
: '<div class="pl-empty">插件目录为空</div>';
|
||||
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
|
||||
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
|
||||
});
|
||||
}
|
||||
|
||||
async function togglePlugin(name, disabled) {
|
||||
try {
|
||||
await window.modelrouter.plugins.request("PUT", `/api/plugins/${encodeURIComponent(name)}`, {
|
||||
enabled: disabled,
|
||||
});
|
||||
toast(disabled ? `已禁用 ${name}` : `已启用 ${name}`);
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableAllPlugins() {
|
||||
let j;
|
||||
try {
|
||||
j = await window.modelrouter.plugins.request("GET", "/api/plugins");
|
||||
} catch (e) {
|
||||
return toast(e.message, true);
|
||||
}
|
||||
const off = (j.on_disk || []).filter((p) => p.loaded && p.disabled);
|
||||
for (const p of off) {
|
||||
try {
|
||||
await window.modelrouter.plugins.request(
|
||||
"PUT",
|
||||
`/api/plugins/${encodeURIComponent(p.name)}`,
|
||||
{ enabled: true },
|
||||
);
|
||||
} catch (e) {
|
||||
toast(`${p.name}: ${e.message}`, true);
|
||||
}
|
||||
}
|
||||
toast(off.length ? `已启用 ${off.length} 个插件` : "没有处于禁用状态的插件");
|
||||
await loadPlugins();
|
||||
}
|
||||
|
||||
// ===== theme =====
|
||||
function applyTheme() {
|
||||
document.documentElement.dataset.theme = state.theme;
|
||||
@ -197,6 +314,10 @@ function init() {
|
||||
$("#tb-close").onclick = () => window.modelrouter.win.close();
|
||||
$("#tb-settings").onclick = openSettings;
|
||||
$("#rail-settings").onclick = openSettings;
|
||||
const plReload = document.getElementById("pl-reload");
|
||||
if (plReload) plReload.onclick = loadPlugins;
|
||||
const plAll = document.getElementById("pl-toggle-all");
|
||||
if (plAll) plAll.onclick = enableAllPlugins;
|
||||
$("#rail-autostart").onclick = toggleAutoStart;
|
||||
$("#rail-silent").onclick = toggleSilent;
|
||||
$("#rail-theme").onclick = () => {
|
||||
|
||||
@ -207,6 +207,15 @@
|
||||
><input type="checkbox" id="set-tray" /> 关闭时最小化到托盘</label
|
||||
><span class="hint">点关闭按钮隐藏到系统托盘</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>插件</label>
|
||||
<span class="hint" id="set-plugins-hint">加载中…</span>
|
||||
</div>
|
||||
<div id="pl-list" class="pl-list"></div>
|
||||
<div class="row actions">
|
||||
<button class="ghost" id="pl-toggle-all">全部启用</button>
|
||||
<button class="ghost" id="pl-reload">刷新</button>
|
||||
</div>
|
||||
<div class="row actions">
|
||||
<button class="ghost" id="set-dir">打开数据目录</button>
|
||||
<button class="ghost" id="set-log">查看日志</button>
|
||||
|
||||
@ -596,3 +596,81 @@ html[data-theme="dark"] .overlay {
|
||||
#toast.err {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* ===== plugin management panel =========================================
|
||||
* The existing .ghost/.primary rules are scoped to `.form .actions`, so a
|
||||
* button outside that selector gets browser defaults. The plugin rows live in
|
||||
* their own list, hence their own rules — reusing a scoped class here would have
|
||||
* produced unstyled buttons that still worked, which is the kind of thing that
|
||||
* looks fine until someone themes the shell.
|
||||
*/
|
||||
.pl-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
.pl-item {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.pl-item.pl-broken {
|
||||
border-color: var(--danger, #d1435b);
|
||||
}
|
||||
.pl-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pl-builtin {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-50);
|
||||
color: var(--primary-h);
|
||||
}
|
||||
.pl-state {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pl-item.pl-broken .pl-state {
|
||||
color: var(--danger, #d1435b);
|
||||
}
|
||||
.pl-desc {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.pl-err {
|
||||
font-size: 11px;
|
||||
color: var(--danger, #d1435b);
|
||||
margin-top: 4px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.pl-acts {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.pl-acts button {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-s2, #fff);
|
||||
color: var(--fg, inherit);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.pl-acts button:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-h);
|
||||
}
|
||||
.pl-empty {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
55
deploy.sh
55
deploy.sh
@ -301,6 +301,60 @@ verify_master_key() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------- 适配器备份保留策略 ----------
|
||||
# 每次部署都新建一个 adapters.bak.<时间戳> 目录,而回滚只用到最近一次
|
||||
# ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理,
|
||||
# 实测线上已积累 71 个,/etc/llmsproxy 因此涨到 122M。
|
||||
#
|
||||
# 保留最近 KEEP_ADAPTER_BACKUPS 份足够回滚,同时给目录数设上限——否则
|
||||
# 一次误配置(比如 adapter_dir 指错)就可能在几秒内造出成百上千个目录。
|
||||
# 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
|
||||
KEEP_ADAPTER_BACKUPS=5
|
||||
|
||||
prune_adapter_backups() {
|
||||
local base="/etc/llmsproxy"
|
||||
[[ -d "$base" ]] || return 0
|
||||
|
||||
# 先按数量上限硬裁:即使时间戳排序失效也不会无上限增长。
|
||||
local all
|
||||
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
|
||||
local cap=$((KEEP_ADAPTER_BACKUPS * 4))
|
||||
if (( ${#all[@]} > cap )); then
|
||||
warn "适配器备份目录有 ${#all[@]} 个(异常),裁到 $cap"
|
||||
local i=0
|
||||
for d in "${all[@]}"; do
|
||||
i=$((i + 1))
|
||||
# 从最旧的开始删(sort 后升序)。名字不规范的跳过不删。
|
||||
if (( i <= ${#all[@]} - cap )); then
|
||||
if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then
|
||||
rm -rf "${base:?}/$d"
|
||||
else
|
||||
warn "跳过名字不规范的备份目录(不删): $d"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
|
||||
fi
|
||||
|
||||
# 再按时间保留最近 KEEP_ADAPTER_BACKUPS 份(sort 后最新在末尾)。
|
||||
local total=${#all[@]} i=0
|
||||
for d in "${all[@]}"; do
|
||||
i=$((i + 1))
|
||||
# i <= total-KEEP 的是较旧的,要删。
|
||||
if (( i <= total - KEEP_ADAPTER_BACKUPS )); then
|
||||
if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then
|
||||
rm -rf "${base:?}/$d"
|
||||
else
|
||||
warn "跳过名字不规范的备份目录(不删): $d"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
local left
|
||||
left=$(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' | wc -l)
|
||||
log " 适配器备份保留最近 $KEEP_ADAPTER_BACKUPS 份(当前剩 $left 个)"
|
||||
}
|
||||
|
||||
# ---------- 同步适配器 ----------
|
||||
sync_adapters() {
|
||||
log "同步适配器"
|
||||
@ -318,6 +372,7 @@ sync_adapters() {
|
||||
cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true
|
||||
log " 旧适配器已备份到 $BACKUP_DIR"
|
||||
fi
|
||||
prune_adapter_backups
|
||||
|
||||
cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/"
|
||||
chmod 0644 "$TARGET_ADAPTERS/"*.lua
|
||||
|
||||
@ -15,7 +15,7 @@ deployable.
|
||||
|---|---|---|---|
|
||||
| Main | `main` | permanent | Only long-lived branch. Always deployable. Accumulates the next version. |
|
||||
| Feature | `feature/<desc>` | short (dev → merge → delete) | New features / ordinary fixes. Born from `main`, merged back into `main`. |
|
||||
| Release | `release/vX.Y.Z` | one version cycle | Cut from `main`, tagged for release. Version-specific hotfixes land here. |
|
||||
| Release | `release/vX.Y.x` | one version cycle | Cut from `main`, tagged for release. Version-specific hotfixes land here. |
|
||||
|
||||
## Change flow (important)
|
||||
|
||||
@ -26,15 +26,21 @@ deployable.
|
||||
│ │
|
||||
│ cut │ cut
|
||||
▼ ▼
|
||||
release/v1.4.2 release/v1.4.3
|
||||
release/v1.6.x release/v1.7.x
|
||||
│ │
|
||||
tag: v1.4.2 tag: v1.4.3
|
||||
tag: v1.6.0 tag: v1.7.6
|
||||
│ │
|
||||
hotfix ◄─────┘ hotfix ◄─────┘
|
||||
│ │
|
||||
└── cherry-pick back ──────────┘
|
||||
```
|
||||
|
||||
The patch position in the branch name is a literal `x`, while the tag carries
|
||||
the concrete version: one `release/v1.7.x` can hold tags v1.7.0 … v1.7.6.
|
||||
Spanning several patches on a single minor branch is deliberate — patches are
|
||||
revisions of the same feature batch, hotfixes land on one branch, and back-port
|
||||
to main never has to resolve dependencies between several release branches.
|
||||
|
||||
### Key rules
|
||||
|
||||
1. **main is always deployable**: never leave half-done work on `main`.
|
||||
@ -43,9 +49,9 @@ deployable.
|
||||
then `git merge --no-ff feature/xxx` (or squash) when done.
|
||||
3. **Release = cut a release branch from main + tag**:
|
||||
```bash
|
||||
git checkout -b release/v1.4.2 main
|
||||
git tag -a v1.4.2 -m "ModelRouter v1.4.2"
|
||||
git push origin release/v1.4.2 v1.4.2
|
||||
git checkout -b release/v1.7.x main
|
||||
git tag -a v1.7.0 -m "ModelRouter v1.7.0"
|
||||
git push origin release/v1.7.x v1.7.0
|
||||
```
|
||||
Build installers and upload the GitCode Release from this tag so the
|
||||
published state is exactly reproducible.
|
||||
@ -54,7 +60,7 @@ deployable.
|
||||
an already-released branch (unless you deliberately ship a minor revision).
|
||||
5. **Hotfixes MUST flow back to main**:
|
||||
```bash
|
||||
git checkout release/v1.4.2 # fix in the release branch
|
||||
git checkout release/v1.7.x # fix in the release branch
|
||||
git commit -m "fix: ..."
|
||||
git checkout main
|
||||
git cherry-pick <hotfix-commit> # and into main
|
||||
@ -67,12 +73,23 @@ deployable.
|
||||
When the next version ships, the previous release branch retires:
|
||||
|
||||
- **Default: delete the remote release branch**
|
||||
(`git push origin :release/v1.4.2`). All hotfixes were already
|
||||
(`git push origin :release/v1.7.x`). All hotfixes were already
|
||||
cherry-picked into main, so main contains everything; no merge needed.
|
||||
- **Long-term maintenance** (e.g. an enterprise client pinned to an old
|
||||
version): keep the branch, accept only security fixes, keep the
|
||||
commit-then-cherry-pick loop.
|
||||
|
||||
> **Where practice diverged from this section (checked 2026-10-01)**:
|
||||
> `release/v1.4.x` and `release/v1.5.x` still exist locally and on the remote,
|
||||
> so "retire the previous branch when the next version ships" was never
|
||||
> carried out. Keeping them is harmless (hotfixes were back-ported), but it
|
||||
> contradicts the rule above and makes a reader wonder whether they should be
|
||||
> there at all. **Feature branches, by contrast, are cleaned up**:
|
||||
> `feature/key-quota-control`, `feature/toolcall-id-sanitize`,
|
||||
> `feature/anthropic-usage-cache` and `feature/agentrouter-id-sanitize` were
|
||||
> deleted on 2026-10-01 after confirming with a per-commit `git patch-id`
|
||||
> comparison that their work had already landed in main.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
- **Never rebase main**: main's history stays append-only; anyone pulling gets
|
||||
@ -134,8 +151,8 @@ pain points:
|
||||
2. No feature branches meant two independent efforts could not proceed in
|
||||
parallel without colliding.
|
||||
|
||||
With release branches: the published state = `release/vX.Y.Z` branch +
|
||||
`vX.Y.Z` tag, exactly reproducible; hotfixes have a clear landing spot; main
|
||||
stays "latest + all fixes + deployable".
|
||||
With release branches: the published state = `release/vX.Y.x` branch +
|
||||
the concrete `vX.Y.Z` tag, exactly reproducible; hotfixes have a clear landing
|
||||
spot; main stays "latest + all fixes + deployable".
|
||||
|
||||
> 中文版见 [docs/git-workflow.md](git-workflow.md)。
|
||||
@ -13,7 +13,7 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
|
||||
|---|---|---|---|
|
||||
| 主分支 | `main` | 永久 | 唯一长命分支。永远可部署。积攒下一个版本的功能。 |
|
||||
| 特性分支 | `feature/<描述>` | 短命(开发→合并即删) | 新特性 / 一般 bug 修复。从 `main` 开出,完成后合回 `main`。 |
|
||||
| 发布分支 | `release/vX.Y.Z` | 一个版本周期 | 从 `main` 分出,打 tag 发布。该版本生命周期内的 hotfix 都提交在此分支。 |
|
||||
| 发布分支 | `release/vX.Y.x` | 一个版本周期 | 从 `main` 分出,打 tag 发布。该版本生命周期内的 hotfix 都提交在此分支。 |
|
||||
|
||||
## 变更流向(重要)
|
||||
|
||||
@ -24,15 +24,20 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
|
||||
│ │
|
||||
│ 切出 │ 切出
|
||||
▼ ▼
|
||||
release/v1.4.2 release/v1.4.3
|
||||
release/v1.6.x release/v1.7.x
|
||||
│ │
|
||||
tag: v1.4.2 tag: v1.4.3
|
||||
tag: v1.6.0 tag: v1.7.6
|
||||
│ │
|
||||
hotfix ◄─────┘ hotfix ◄─────┘
|
||||
│ │
|
||||
└── cherry-pick 回 main ───────┘
|
||||
```
|
||||
|
||||
分支名里 patch 位是**字面的 x**,而 tag 打具体版本号:`release/v1.7.x` 这一条
|
||||
发布分支上的 tag 可以有 v1.7.0 … v1.7.6 多个。一条 minor 分支跨多个 patch 是
|
||||
刻意的:patch 是同一批功能的不同修订,hotfix 落在同一条分支上,回流 main 时
|
||||
也不必处理多条 release 分支之间的依赖。
|
||||
|
||||
### 关键规则
|
||||
|
||||
1. **main 永远可部署**:不在 main 上留半成品。任何未完成的工作必须在特性分支上。
|
||||
@ -40,16 +45,16 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
|
||||
开发完 `git merge --no-ff feature/xxx` 或 squash 合回。
|
||||
3. **发布 = 从 main 切 release 分支 + 打 tag**:
|
||||
```bash
|
||||
git checkout -b release/v1.4.2 main
|
||||
git tag -a v1.4.2 -m "ModelRouter v1.4.2"
|
||||
git push origin release/v1.4.2 v1.4.2
|
||||
git checkout -b release/v1.7.x main
|
||||
git tag -a v1.7.0 -m "ModelRouter v1.7.0"
|
||||
git push origin release/v1.7.x v1.7.0
|
||||
```
|
||||
构建安装包、上传 GitCode Release 都基于这个 tag,保证可精确回溯发布态。
|
||||
4. **版本生命周期内只收该版本的 hotfix**:新特性一律并入 `main` 等下一个版本,
|
||||
绝不塞进已发布的 release 分支(除非主动选择在该版本内发次要版)。
|
||||
5. **hotfix 必须回流 main**:
|
||||
```bash
|
||||
git checkout release/v1.4.2 # 在发布分支提交修复
|
||||
git checkout release/v1.7.x # 在发布分支提交修复
|
||||
git commit -m "fix: ..."
|
||||
git checkout main
|
||||
git cherry-pick <hotfix-commit> # 回主分支
|
||||
@ -61,11 +66,20 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
|
||||
|
||||
下一个版本发布时,上一个 release 分支退役:
|
||||
|
||||
- **默认:直接删除远端 release 分支**(`git push origin :release/v1.4.2`)。
|
||||
- **默认:直接删除远端 release 分支**(`git push origin :release/v1.7.x`)。
|
||||
因为 hotfix 都已逐个 cherry-pick 回 main,main 已包含全部修复,无需再合并。
|
||||
- **如需要长期维护旧版**(例如企业大客户卡在旧版本):保留分支,仅 stopship 接受
|
||||
该版本的安全修复,继续走「提交 + cherry-pick 回 main」循环。
|
||||
|
||||
> **实践与本节的历史出入(2026-10-01 核对)**:`release/v1.4.x` 与 `release/v1.5.x`
|
||||
> 至今仍在本地与远端,说明"下一个版本发布就删上一个分支"实际没有执行。
|
||||
> 保留无害(hotfix 已回流),但它与上面写的规则不一致,读文档的人会以为
|
||||
> 这些分支不该存在。**特性分支则确实在清理**:`feature/key-quota-control`、
|
||||
> `feature/toolcall-id-sanitize`、`feature/anthropic-usage-cache`、
|
||||
> `feature/agentrouter-id-sanitize` 四个分支在 2026-10-01 删除——它们的工作
|
||||
> 早已全部进入 main(逐提交用 `git patch-id` 比对确认),留着只是给下个版本
|
||||
> 制造 cherry-pick/merge 陷阱。
|
||||
|
||||
## 明确不做的事
|
||||
|
||||
- **不 rebase main**:`main` 的历史保持追加式,任何人拉取后 `git pull` 都得到直接可用的历史。
|
||||
|
||||
507
docs/plugins.md
Normal file
507
docs/plugins.md
Normal file
@ -0,0 +1,507 @@
|
||||
# 插件系统(Plugin System)
|
||||
|
||||
> **English**: this document is the reference for writing ModelRouter plugins.
|
||||
> The Chinese version is the primary one; section titles map 1:1.
|
||||
|
||||
ModelRouter 的插件是**单个 `.lua` 文件**,放在 `config.yaml` 的 `plugin_dir` 目录里。
|
||||
插件能做两件事:
|
||||
|
||||
1. **挂钩子**:在请求流水线的若干 stage 上注册回调,看到每个请求的完整信息,
|
||||
并可以把结果累加进自己的状态。
|
||||
2. **贡献界面**:在启动时返回 HTML / CSS / JS,由内核注入 WebUI——可以是一整个
|
||||
新页面,也可以是往现有页面里追加一个组件。
|
||||
|
||||
两者互相独立:只想统计请求数的插件不必碰界面;只想加个仪表盘的插件不必碰钩子。
|
||||
|
||||
---
|
||||
|
||||
## 1. 快速上手
|
||||
|
||||
一个最小的插件:
|
||||
|
||||
```lua
|
||||
-- plugins/hello.lua
|
||||
local plugin = {
|
||||
name = "hello",
|
||||
version = "1.0.0",
|
||||
description = "示例插件",
|
||||
author = "you",
|
||||
}
|
||||
|
||||
-- 声明钩子
|
||||
plugin.hooks = {
|
||||
request_end = "on_request_end",
|
||||
}
|
||||
|
||||
-- 钩子实现
|
||||
function plugin.on_request_end(payload)
|
||||
-- payload 是解码后的 table,不是 JSON 字符串
|
||||
log("info", string.format("%s via %s: %d prompt tokens",
|
||||
payload.model, payload.source, payload.prompt_tokens or 0))
|
||||
return nil -- 最后一个 stage 没有下游,return 无意义
|
||||
end
|
||||
|
||||
-- 贡献界面
|
||||
plugin.ui = {
|
||||
page = {
|
||||
page_id = "hello", -- kebab-case
|
||||
title = "Hello",
|
||||
icon = "👋",
|
||||
order = 90, -- 侧栏排序
|
||||
mount = [[<div id="hello">hello</div>]],
|
||||
},
|
||||
}
|
||||
|
||||
return plugin -- 必须返回一个 table
|
||||
```
|
||||
|
||||
放进 `plugin_dir` 后重启即生效。`GET /api/plugins` 确认它被加载了。
|
||||
|
||||
---
|
||||
|
||||
## 2. 加载与生命周期
|
||||
|
||||
```
|
||||
core.New
|
||||
└─ lua.NewVM(adapter_dir).Start() 适配器状态
|
||||
└─ lua.NewPlugins(vm, plugin_dir)
|
||||
├─ SeedBundled() 仅当目录不存在时写入内置插件(目前是 billing)
|
||||
└─ LoadDir() 按文件名字典序逐个加载
|
||||
```
|
||||
|
||||
**加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `GET /api/plugins`
|
||||
的 `error` 字段里,永远不会被调用。这与适配器一致,但理由更强:插件是可选的
|
||||
第三方扩展,因为一个 `.lua` 打错字就让网关起不来是错误的取舍。
|
||||
|
||||
**目录一旦存在就是权威的。** 与适配器同规则:首启会 seed 内置插件,之后目录里
|
||||
的文件说了算,删除或编辑内置插件都是真实生效的操作。
|
||||
|
||||
### 2.1 热更新
|
||||
|
||||
| 方式 | 效果 |
|
||||
|---|---|
|
||||
| `POST /api/plugins {name, code}` | 写文件 + 立即加载新版本(旧的 Lua 状态被关闭重建,**累计量清零**) |
|
||||
| `DELETE /api/plugins/{name}` | 删文件 + 卸载 |
|
||||
| 改文件后 `POST` 同名 | 同上 |
|
||||
|
||||
改文件但**不** POST,需要重启才生效。
|
||||
|
||||
---
|
||||
|
||||
## 3. 流水线 stage
|
||||
|
||||
一个请求依次经过三个 stage。插件可以为任意 stage 注册钩子;未注册的 stage
|
||||
被忽略,所以插件不会因为网关将来新增 stage 而报错。
|
||||
|
||||
```
|
||||
客户端请求
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ request_start │ 已解析、已鉴权,尚未选源
|
||||
│ · type │ "chat" | "stream" | "image"
|
||||
│ · model │ 客户端请求的原始 model("AUTO" 也在这里)
|
||||
│ · key / role │ 掩码后的网关 key id("***a1b2c3")与角色
|
||||
│ · source │ 空(还没选源)
|
||||
│ · stream │
|
||||
│ · messages_count │
|
||||
│ · tools_count │
|
||||
│ · ts │ unix 秒
|
||||
└─────────┬──────────┘
|
||||
│ (调度:tier 遍历 → 槽位轮转 → 冷却/配额过滤)
|
||||
┌─────────▼──────────┐
|
||||
│ routed │ 已选定 (source, model),尚未发往上游
|
||||
│ · source / model │ 实际选中的
|
||||
│ · tier │ AUTO 链的档位;直连 = -1;AUTO = -2
|
||||
│ · stream / key │
|
||||
└─────────┬──────────┘
|
||||
│ (HTTP 往返 / SSE 流)
|
||||
┌─────────▼──────────┐
|
||||
│ request_end │ 每个请求恰好一次,成功失败都触发
|
||||
│ · ok / status │
|
||||
│ · latency_ms │
|
||||
│ · first_byte_ms │ 流式的首字节时间
|
||||
│ · prompt_tokens │ 上游真实 usage,缺失时为字节估算
|
||||
│ · completion_tokens
|
||||
│ · cache_hit_tokens / cache_miss_tokens
|
||||
│ · image_count │ 生图数量(图片不计 token)
|
||||
│ · error │ 失败原因,成功时为 ""
|
||||
│ · time │ unix **毫秒**
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
写审计 + 聚合统计
|
||||
```
|
||||
|
||||
### 3.1 触发点在哪
|
||||
|
||||
| stage | 代码位置 | 说明 |
|
||||
|---|---|---|
|
||||
| `request_start` | `gateway/chat.go` `handleChat` / `fireImageStart` | 每个请求一次(chat 与生图各一条),在配额闸门**之前** |
|
||||
| `chain_step` | `gateway/chat.go` `chainTraceSink` | **仅 AUTO 路径**,每步一次 |
|
||||
| `routed` | `singleChat` / `streamChat` / `singleChatAuto` / `streamChatAuto` | 成功选定源之后,各一次 |
|
||||
| `request_end` | `gateway/chat.go` `writeRec` | **所有出口的唯一汇合点**,每个请求一次 |
|
||||
|
||||
### 3.1b 为什么单独有 `chain_step`
|
||||
|
||||
`routed` 只在**遍历结束后**触发一次,只带最终胜出的槽位。所以
|
||||
"tier 1 冷却所以降级到 tier 3"和"tier 1 正常接单"在它眼里**完全一样**——
|
||||
而这恰恰是优先级链存在的全部理由。
|
||||
|
||||
`chain_step` 补上这条信息,四种 `kind`:
|
||||
|
||||
| kind | 含义 | 何时产生 |
|
||||
|---|---|---|
|
||||
| `tier_skip` | 整档被跳过 | 该档所有槽位冷却中/配额用尽 |
|
||||
| `slot_fail` | 某个槽位硬失败 | 上游报错 / 适配器输出不可用 |
|
||||
| `tier_busy` | 整档全忙且有界等待超时 | 2s 内没等到空位 |
|
||||
| `selected` | 这个槽位接了单 | 每次成功遍历**恰好一次**,且是最后一步 |
|
||||
|
||||
顺序保证:所有 `chain_step` 都在 `routed` 之前,`selected` 是最后一步。
|
||||
所以只订阅 `request_end` 的插件也能拿到轨迹摘要(见下)。
|
||||
|
||||
### 3.1c `request_end` 里的轨迹摘要
|
||||
|
||||
除了逐个 `chain_step`,`request_end` 还带三个便于做报表的字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `chain_walk` | 整个遍历的步骤数组(上限 12 步,超出截断) |
|
||||
| `degraded` | 布尔。`true` = 有过跳过/失败,即**发生了降级** |
|
||||
| `tier_served` | 实际服务的那一档;直连或全失败时为 `-1` |
|
||||
|
||||
> **计费口径**:按**实际服务的模型**计费。降级到 tier 3 仍按 tier 3 的价算,
|
||||
> `chain_step` / `degraded` / `tier_served` 只作**观测**,不参与计价。
|
||||
> 理由见 §7.5。
|
||||
|
||||
`request_end` 放在 `writeRec` 是因为四条入口路径(直连/AUTO × 流式/非流式)都
|
||||
经过它,既不会漏(流式的 token 数只有流结束才知道),也不会重复。
|
||||
|
||||
**hot path 注意事项**:没有插件注册某 stage 时,`Fire` 立刻返回(一次 `RLock`
|
||||
加一次 map 查找)。装了插件之后,每个请求会在该 stage 上多一次 Lua 调用——
|
||||
这是同步的,在关键路径上。计费插件那种"每请求一次"是正常的;把重活放进钩子是
|
||||
反模式。
|
||||
|
||||
### 3.2 钩子的返回值
|
||||
|
||||
- 返回 `nil` 或不返回 = **没有意见**,payload 原样传给下一个插件
|
||||
- 返回 table = 其中的键会**合并进 payload**,并作为 `Fire` 的返回值
|
||||
|
||||
**同一 stage 的多个插件并行执行**,但返回值按**插件加载顺序**合并,所以结果是
|
||||
确定的(不依赖 goroutine 调度)。代价是一个插件看不到另一个插件刚加的字段:
|
||||
每个钩子拿到的是**同一份 payload 快照**。
|
||||
|
||||
这与早期版本不同 —— 早期是顺序执行,后一个插件能看到前一个的返回值。它从未被
|
||||
实际依赖(随核心发布的 billing 在每个 stage 都 `return nil`,注释里写着
|
||||
"nobody downstream would read a return value"),但这是一处**契约变化**:如果你的
|
||||
插件依赖「读到前一个插件写的字段」,并行的两个插件之间必须改用外部通信
|
||||
(例如各自写 `plugin.state`,由 `/api/plugins/<name>/state` 读取)。
|
||||
|
||||
单个插件时不启 goroutine,直接调用。
|
||||
|
||||
前三个 stage 的返回值目前没有内部消费者(最后一个 stage 之后就是写审计),
|
||||
所以计费插件改用 `plugin.state` + `/state` 端点来暴露数据。
|
||||
|
||||
---
|
||||
|
||||
## 4. 界面扩展
|
||||
|
||||
### 4.1 整页
|
||||
|
||||
```lua
|
||||
plugin.ui = {
|
||||
page = {
|
||||
page_id = "billing", -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing
|
||||
title = "Billing", -- 必填,侧栏文字
|
||||
icon = "💰", -- 可选
|
||||
order = 40, -- 侧栏排序,默认 100
|
||||
mount = [[...HTML...]],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 往现有页面追加元素
|
||||
|
||||
```lua
|
||||
plugin.ui = {
|
||||
elements = {
|
||||
{
|
||||
target = "status", -- status | chat | keys | sort | sources | adapters
|
||||
anchor = "top", -- "top" | "bottom" | "before:<sel>" | "after:<sel>"
|
||||
order = 5,
|
||||
mount = [[...HTML...]],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `mount` 里可以带 `<script>` 和 `<style>`
|
||||
|
||||
内核的注入顺序是:**先插 HTML,再执行 `<script>`**,所以脚本里访问
|
||||
`document.getElementById` 一定能拿到已渲染的节点。
|
||||
|
||||
### 4.4 插件可用的浏览器端 API
|
||||
|
||||
| API | 作用 |
|
||||
|---|---|
|
||||
| `window.pluginAPI.fetchState(name)` | 等价于 `GET /api/plugins/<name>/state` |
|
||||
| `window.pluginAPI.onTabShown(fn)` | 注册"页面切到可见时"的回调(轮询类组件用) |
|
||||
| `window.pluginAPI.postState(name, obj)` | `PUT` 自己的 state(写操作,需 admin) |
|
||||
|
||||
### 4.5 一个完整例子
|
||||
|
||||
见 `internal/lua/plugins/billing.lua`——它同时用了两种 UI 形式、完整的价格配置、
|
||||
以及全部三个 stage。
|
||||
|
||||
---
|
||||
|
||||
## 5. 插件状态与 HTTP API
|
||||
|
||||
### 5.1 端点
|
||||
|
||||
```
|
||||
GET /api/plugins 已加载插件清单 + 钩子 + UI + 错误
|
||||
POST /api/plugins 上传/替换(admin){name, code}
|
||||
DELETE /api/plugins/{name} 删除(admin)
|
||||
GET /api/plugins/{name}/state 读取插件自己发布的状态
|
||||
PUT /api/plugins/{name}/state 替换状态(admin)
|
||||
```
|
||||
|
||||
`GET .../state` 对**任意角色开放**:它是报表数据(开销、计数),用户自己的
|
||||
计费组件要能渲染。而 `PUT` 需要 admin。
|
||||
|
||||
### 5.2 约定:`prices` 与 `state` 分离
|
||||
|
||||
`PUT .../state` 的载荷里如果有 `prices` 键,它会被写进插件的 **`plugin.prices`**
|
||||
字段并从 `state` 里剔除。
|
||||
|
||||
**为什么**:`state` 是累计量(历史),`prices` 是配置。一次改价如果整体替换
|
||||
`state`,累计量就没了——账目会在改价那一刻清零。分离之后:
|
||||
|
||||
- 只带 `prices` → 改配置,**不动** `state`(累计量保留)
|
||||
- 带其它键 → 替换 `state`(这是显式的重置)
|
||||
|
||||
> 插件可以不遵守这个约定,把整个载荷放进 `state` 自行处理。约定只为内置的
|
||||
> 计费插件存在。
|
||||
|
||||
### 5.3 `GET /api/plugins` 的响应
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [{
|
||||
"name": "billing", "version": "1.0.0",
|
||||
"description": "...", "author": "...",
|
||||
"hooks": ["request_end"],
|
||||
"ui": { "page": "billing", "elements": 1 },
|
||||
"loaded": true
|
||||
}],
|
||||
"hook_errors": { "request_end": { "count": 3, "last_error": "billing: ..." } },
|
||||
"plugin_dir": "/etc/llmsproxy/plugins"
|
||||
}
|
||||
```
|
||||
|
||||
`hook_errors` 是**排错入口**:一个坏掉的插件表现为"功能缺失"而不是报错,
|
||||
这个计数让它可见。
|
||||
|
||||
---
|
||||
|
||||
## 6. 运行时约束(重要)
|
||||
|
||||
### 6.1 一个插件 = 一个 Lua 状态
|
||||
|
||||
适配器可以有多个独立的 worker 状态(它们无状态,这是对的)。**插件不行**:
|
||||
钩子通常往 `plugin.state` 里累加,多个状态就意味着总额被劈成几份,而写价格只
|
||||
写进其中一个状态,钩子恰好跑到另一个时**所有请求按 0 计费**。
|
||||
|
||||
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState`)用互斥锁
|
||||
串行化。代价:**一个卡住的钩子会卡住所有插件的钩子**。所以——
|
||||
|
||||
> **钩子必须短、同步、不阻塞。** 不要在里面做 HTTP 请求、睡眠或重计算。
|
||||
|
||||
### 6.2 异常被隔离
|
||||
|
||||
钩子里 `error()` 不会影响转发:异常被捕获、记进 `hook_errors`、跳到下一个插件。
|
||||
适配器变换出错会让源进入冷却,但插件出错**不产生任何惩罚**——插件是可选功能。
|
||||
|
||||
### 6.3 内置可用函数
|
||||
|
||||
与适配器相同:`json.encode` / `json.decode`、`log(level, msg)`、
|
||||
`hmac_sha256_hex` / `sha256_hex` / `base64_encode` / `tohex`。
|
||||
|
||||
⚠️ `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。
|
||||
|
||||
### 6.4 命名
|
||||
|
||||
- 全局 `__llmsproxy_plugin` 存放插件返回的 table,**不要占用**
|
||||
- 插件间状态完全隔离,一个插件改不了另一个的全局
|
||||
|
||||
---
|
||||
|
||||
## 7. 内置计费插件
|
||||
|
||||
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
|
||||
|
||||
### 7.1 计费维度
|
||||
|
||||
| 维度 | 用途 | 优先级 |
|
||||
|---|---|---|
|
||||
| `sources` | 源的固定价(如按请求计费的转发服务) | 中 |
|
||||
| `models` | 模型的 per-token 价 | 高 |
|
||||
| `keys` | 单个网关 key 的覆盖价 | **最高** |
|
||||
|
||||
**token 价**优先级:`keys` > `models` > `default`。
|
||||
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
|
||||
**`cache_discount`** 见 §7.7。
|
||||
|
||||
### 7.2 价格单位
|
||||
|
||||
**USD / 单个 token**。这是各家 provider 的公布口径,所以典型数值长这样:
|
||||
`1.25e-6`。插件内部乘以 token 数,全程不做单位换算。
|
||||
|
||||
```json
|
||||
{
|
||||
"prices": {
|
||||
"currency": "USD",
|
||||
"default": { "prompt": 0, "completion": 0, "per_request": 0 },
|
||||
"sources": { "trae": { "per_request": 0.01 } },
|
||||
"models": {
|
||||
"gpt-5.4": { "prompt": 1.25e-6, "completion": 1e-5 },
|
||||
"kolors": { "per_request": 0.04 }
|
||||
},
|
||||
"keys": { "***a1b2c3": { "prompt": 1.1e-6, "completion": 9e-6 } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
配置价目:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://127.0.0.1:8080/api/plugins/billing/state \
|
||||
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
|
||||
-d '{"prices":{"models":{"gpt-5.4":{"prompt":1.25e-6,"completion":1e-5}}}}'
|
||||
```
|
||||
|
||||
读回账目:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $ADMIN_KEY" \
|
||||
http://127.0.0.1:8080/api/plugins/billing/state
|
||||
```
|
||||
|
||||
### 7.3 累计维度
|
||||
|
||||
`total` / `by_source` / `by_model` / `by_key` / `by_day`(`YYYY-MM-DD` UTC),
|
||||
每项含 `cost`、`requests`、`prompt_tokens`、`completion_tokens`、`failures`。
|
||||
|
||||
另有两个**降级观测**维度(来自 `chain_step`):
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `degraded_reqs` | 发生过降级的请求数 |
|
||||
| `by_tier_served` | 各档实际接单数(`{"1": 812, "2": 37}`) |
|
||||
| `skip_reasons` | 跳过原因计数,等待时长已归一(`no free slot within <wait>`) |
|
||||
|
||||
这三项是"网关是不是在悄悄降级"的核心指标:一个持续降级的网关,账单结构和健康
|
||||
网关看起来一模一样——**除非**单独统计降级次数。
|
||||
|
||||
### 7.4 计费策略:失败的请求怎么算
|
||||
|
||||
**保留 token 费用,丢弃固定费用。** 理由:上游在生成后才 500,token 确实被消耗
|
||||
了;但那个从未真正发生的固定费不该收。
|
||||
|
||||
这是本插件里**最可争议的一条**。想改成"失败也收固定费":
|
||||
|
||||
```json
|
||||
{ "prices": { ... }, "count_failures": true }
|
||||
```
|
||||
|
||||
### 7.5 ⚠️ 计费插件只报表,不执法
|
||||
|
||||
**网关自己的配额会计(`internal/gateway/stats.go`,入口处强制)才是限额权威。**
|
||||
本插件不参与任何路由或配额决策。
|
||||
|
||||
理由:两套独立的会计路径如果对不上,比一套功能略少的更糟。计费是**观察**,
|
||||
配额是**控制**,二者分开。
|
||||
|
||||
### 7.6 未定价流量(重要)
|
||||
|
||||
**任何维度都没配价的请求,成本记 0。** 这是最危险的失败模式:账单照样能加总,
|
||||
只是**悄悄少报**,而且没有任何报错。
|
||||
|
||||
所以插件单独统计它们:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `unpriced_reqs` | 没有任何价目覆盖的请求数 |
|
||||
| `unpriced_models` | 按模型点名(`{"MYSTERY-MODEL": 12}`)——直接告诉你价目表缺了哪一行 |
|
||||
|
||||
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
|
||||
|
||||
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
|
||||
`total` 与各维度,只有金额是 0。
|
||||
|
||||
### 7.7 提示缓存计价
|
||||
|
||||
**缓存命中的 prompt token 不按全价算。** 绝大多数 provider 对缓存读给很深的折扣
|
||||
(常见是 1/10),而 agent 流量会反复重放长前缀——正是缓存要让它便宜的那类流量。
|
||||
|
||||
```
|
||||
fresh = prompt_tokens - cache_hit_tokens → 全价
|
||||
cached = cache_hit_tokens → 全价 × cache_discount
|
||||
```
|
||||
|
||||
`cache_discount` 默认 **0.1**(10 倍折扣),因为 DeepSeek / Qwen / Kimi 等都是这个
|
||||
量级。它是**每个 provider 的事实、不是自然常数**,所以可以按条目覆盖:
|
||||
|
||||
```json
|
||||
"models": { "gpt-5.4": { "prompt": 1.25e-6, "completion": 1e-5, "cache_discount": 0.25 } }
|
||||
```
|
||||
|
||||
设成 `1` 恢复成旧的"prompt 一律全价"行为,设成 `0` 表示该 provider 不打折。
|
||||
|
||||
优先级与 token 价一致(`keys` > `models` > `default`)。
|
||||
|
||||
> **这一条改过行为。** 修复前缓存命中按全价算:100 万 prompt token 里 90 万是
|
||||
> 缓存命中,会算出 10 USD 而不是 ~1.9——**高估约 10 倍**,而且恰好发生在缓存
|
||||
> 最有价值的高频流量上。
|
||||
|
||||
### 7.8 数据从哪来
|
||||
|
||||
`request_end` 的 `prompt_tokens` / `completion_tokens` 优先取上游真实的
|
||||
`usage`;上游没报时网关用字节估算(`len/3+1`)。流式请求在流结束后用上游真实
|
||||
数字**覆盖**估算值。所以计费数字的精度取决于上游是否报 usage。
|
||||
|
||||
---
|
||||
|
||||
## 8. 排错
|
||||
|
||||
| 现象 | 查什么 |
|
||||
|---|---|
|
||||
| 插件没出现在 `/api/plugins` | 目录对不对(响应里的 `plugin_dir`);文件是不是 `.lua` |
|
||||
| `loaded: false` 且有 `error` | 语法错误或没 `return table`,错误信息在 `error` 字段 |
|
||||
| 功能"没反应"但无报错 | 查 `hook_errors`——坏钩子只记录不抛出 |
|
||||
| 钩子没被调用 | 该 stage 确实触发了吗(`routed` 只在**成功**选源后触发,调度全失败时不触发) |
|
||||
| 界面空白 | 内核的 `GET /api/ui-inject` 里有没有你的 `page_id`;脚本有没有报错(浏览器 console) |
|
||||
| `pluginAPI` 未定义 | 脚本在注入前执行了;确认用的是 `mount` 而不是别的方式插入 |
|
||||
|
||||
### 调试钩子
|
||||
|
||||
插件的 `log(level, msg)` 输出到网关的日志:
|
||||
|
||||
```
|
||||
journalctl -u llmsproxy -f | grep -i plugin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 与适配器的区别
|
||||
|
||||
| | 适配器 | 插件 |
|
||||
|---|---|---|
|
||||
| 位置 | `adapter_dir` | `plugin_dir` |
|
||||
| 作用 | 上游协议转换 | 请求流水线 + 界面 |
|
||||
| 钩子 | `transform_request` / `transform_response` / `transform_stream_chunk` / `build_headers` / `transform_error` | `request_start` / `routed` / `request_end` |
|
||||
| 状态 | 每个源独立,多 worker | 单状态,见 §6.1 |
|
||||
| 出错后果 | 该 (源,模型) 进入冷却退避 | 仅该功能缺失 |
|
||||
| 鉴权 | 需要 `gateway_keys` 之外的独立凭据 | 网关 key |
|
||||
| 必需性 | 核心 | 可选,缺了网关照常跑 |
|
||||
|
||||
完整适配器协议见 [lua-adapters.md](lua-adapters.md)。
|
||||
264
internal/billing/compile.go
Normal file
264
internal/billing/compile.go
Normal file
@ -0,0 +1,264 @@
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
88
internal/billing/wildcard_test.go
Normal file
88
internal/billing/wildcard_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@ -19,6 +21,7 @@ type Config struct {
|
||||
GatewayKeys []string `yaml:"gateway_keys"`
|
||||
DefaultModel string `yaml:"default_model"` // e.g. "AUTO" or a model id
|
||||
AdapterDir string `yaml:"adapter_dir"`
|
||||
PluginDir string `yaml:"plugin_dir,omitempty"` // Lua plugin directory (optional; hooks + WebUI extensions)
|
||||
RuntimeFile string `yaml:"runtime_file"`
|
||||
TLSCertFile string `yaml:"tls_cert_file,omitempty"` // PEM cert; when set together with tls_key_file, serve HTTPS
|
||||
TLSKeyFile string `yaml:"tls_key_file,omitempty"` // PEM private key
|
||||
@ -27,6 +30,15 @@ type Config struct {
|
||||
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
|
||||
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
|
||||
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
|
||||
// BillingDSL declares per-URL pricing profiles for the billing plugin.
|
||||
//
|
||||
// Declarative, because hand-writing the prices JSON was error-prone in ways
|
||||
// that looked successful: the first PUT went out without the required
|
||||
// `prices` wrapper and silently REPLACED the accumulated totals with the
|
||||
// price table; the second used a peak-window shape the plugin does not read,
|
||||
// so peak traffic was billed at off-peak rates — no error, the bill just
|
||||
// quietly halved. A schema-checked config file catches both at load.
|
||||
BillingDSL *BillingDSL `yaml:"billing,omitempty"`
|
||||
// box seals credentials (sources' api_key/headers, keys' key) at rest.
|
||||
// In-memory values are always plaintext; only the bytes on disk are sealed.
|
||||
// Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay
|
||||
@ -132,6 +144,7 @@ func writeDefaultConfig(path string) error {
|
||||
GatewayKeys: []string{key},
|
||||
DefaultModel: "AUTO",
|
||||
AdapterDir: filepath.Join(abs, "adapters"),
|
||||
PluginDir: filepath.Join(abs, "plugins"),
|
||||
RuntimeFile: filepath.Join(abs, "runtime.json"),
|
||||
}
|
||||
out, err := yaml.Marshal(&cfg)
|
||||
@ -288,6 +301,9 @@ func (c *Config) ApplyDefaults() error {
|
||||
if c.AdapterDir == "" {
|
||||
c.AdapterDir = "adapters"
|
||||
}
|
||||
// PluginDir is deliberately NOT defaulted: with no plugins a missing
|
||||
// directory would be created and seeded on every start for nothing. An
|
||||
// operator opts in by setting it (or by dropping files into it).
|
||||
if c.RuntimeFile == "" {
|
||||
c.RuntimeFile = "runtime.json"
|
||||
}
|
||||
@ -377,6 +393,144 @@ type GWKey struct {
|
||||
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
|
||||
}
|
||||
|
||||
// BillingDSL holds declarative per-URL pricing profiles for the billing plugin.
|
||||
//
|
||||
// Profiles are the "let the user pick" axis: the same upstream URL can be
|
||||
// declared under multiple profiles, and the operator chooses which is active.
|
||||
// Switching recomputes and re-injects the billing plugin's prices table.
|
||||
type BillingDSL struct {
|
||||
// Active is the id of the profile in effect. Empty => the first profile.
|
||||
Active string `yaml:"active,omitempty" json:"active,omitempty"`
|
||||
Profiles []BillingProfile `yaml:"profiles,omitempty" json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
// BillingProfile is one named set of per-URL pricing rules.
|
||||
type BillingProfile struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Label string `yaml:"label,omitempty" json:"label,omitempty"`
|
||||
// Default applies when no rule matches: "free" or "unpriced".
|
||||
Default string `yaml:"default,omitempty" json:"default,omitempty"`
|
||||
Rules []BillingRule `yaml:"rules,omitempty" json:"rules,omitempty"`
|
||||
}
|
||||
|
||||
// BillingRule declares the pricing for everything under one URL.
|
||||
type BillingRule struct {
|
||||
// URL matches a source's base_url. "*" = any unmatched URL.
|
||||
URL string `yaml:"url" json:"url"`
|
||||
// Mode is one of: free, token, subscription, unpriced.
|
||||
Mode string `yaml:"mode" json:"mode"`
|
||||
Currency string `yaml:"currency,omitempty" json:"currency,omitempty"`
|
||||
// Subscription is the flat monthly cost (for mode=subscription).
|
||||
Subscription float64 `yaml:"subscription,omitempty" json:"subscription,omitempty"`
|
||||
// Models declares per-token prices when mode=token.
|
||||
Models map[string]BillingToken `yaml:"models,omitempty" json:"models,omitempty"`
|
||||
// Free is true when mode=free (kept for explicitness).
|
||||
// Peak applies to ALL token-priced requests under this URL.
|
||||
Peak *BillingPeak `yaml:"peak,omitempty" json:"peak,omitempty"`
|
||||
}
|
||||
|
||||
// BillingToken is the per-model price.
|
||||
type BillingToken struct {
|
||||
Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15"
|
||||
Completion string `yaml:"completion" json:"completion"` // USD per million tokens
|
||||
CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"`
|
||||
}
|
||||
|
||||
// BillingPeak declares peak-hour pricing.
|
||||
type BillingPeak struct {
|
||||
Multiplier float64 `yaml:"multiplier" json:"multiplier"`
|
||||
// Weekdays: 1=Mon..5=Fri (Lua os.date !%w, 0=Sun)
|
||||
Weekdays []int `yaml:"weekdays,omitempty" json:"weekdays,omitempty"`
|
||||
// Hours: each pair is [lo,hi] inclusive
|
||||
Hours [][2]int `yaml:"hours,omitempty" json:"hours,omitempty"`
|
||||
}
|
||||
|
||||
// Validate rejects a BillingDSL that could not work as written.
|
||||
func (b *BillingDSL) Validate() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for i := range b.Profiles {
|
||||
p := &b.Profiles[i]
|
||||
if p.ID == "" {
|
||||
return fmt.Errorf("billing profile [%d] has no id", i)
|
||||
}
|
||||
if ids[p.ID] {
|
||||
return fmt.Errorf("billing profile id %q appears more than once", p.ID)
|
||||
}
|
||||
ids[p.ID] = true
|
||||
if p.Default != "" && p.Default != "free" && p.Default != "unpriced" {
|
||||
return fmt.Errorf("profile %q: default must be free or unpriced, got %q", p.ID, p.Default)
|
||||
}
|
||||
for j := range p.Rules {
|
||||
r := &p.Rules[j]
|
||||
if r.URL == "" {
|
||||
return fmt.Errorf("profile %q rule [%d]: url is required", p.ID, j)
|
||||
}
|
||||
switch r.Mode {
|
||||
case "free", "token", "subscription", "unpriced":
|
||||
default:
|
||||
return fmt.Errorf("profile %q rule [%d]: mode must be free/token/subscription/unpriced, got %q", p.ID, j, r.Mode)
|
||||
}
|
||||
if r.Mode == "token" {
|
||||
for m, t := range r.Models {
|
||||
if t.Prompt == "" || t.Completion == "" {
|
||||
return fmt.Errorf("profile %q model %q: prompt and completion are required for token mode", p.ID, m)
|
||||
}
|
||||
if _, err := ParseFloatUSDPerM(t.Prompt); err != nil {
|
||||
return fmt.Errorf("profile %q model %q prompt: %w", p.ID, m, err)
|
||||
}
|
||||
if _, err := ParseFloatUSDPerM(t.Completion); err != nil {
|
||||
return fmt.Errorf("profile %q model %q completion: %w", p.ID, m, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Active must resolve to a profile id.
|
||||
if b.Active != "" && !ids[b.Active] {
|
||||
return fmt.Errorf("billing.active = %q but no profile has that id", b.Active)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve returns the profile with the given id; empty id (or an id that does
|
||||
// not exist) falls back to the FIRST profile. A config with profiles but no
|
||||
// active marker should mean "use the one I wrote first" rather than "price
|
||||
// nothing", which is what a nil here would silently do.
|
||||
func (b *BillingDSL) Resolve(id string) *BillingProfile {
|
||||
if b == nil || len(b.Profiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
if id != "" {
|
||||
for i := range b.Profiles {
|
||||
if b.Profiles[i].ID == id {
|
||||
return &b.Profiles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return &b.Profiles[0]
|
||||
}
|
||||
|
||||
// parseFloatUSDPerM parses a USD-per-million price string. Returns the
|
||||
// per-single-token rate the plugin expects (price / 1e6).
|
||||
//
|
||||
// Kept as a string in the DSL rather than a float: "0.15" survives round-trips
|
||||
// through YAML editors, and a malformed value must fail VALIDATION (with the
|
||||
// file and field named) rather than parse as 0 and under-bill silently — which
|
||||
// is exactly the failure mode that motivated the DSL.
|
||||
func ParseFloatUSDPerM(s string) (float64, error) {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("not a number: %q", s)
|
||||
}
|
||||
if f < 0 {
|
||||
return 0, fmt.Errorf("negative price: %q", s)
|
||||
}
|
||||
return f / 1e6, nil
|
||||
}
|
||||
|
||||
// KeyQuota is retained only to carry a scope entry's caps through the admin
|
||||
// API. Quotas are per model, never per key: there is deliberately no key-wide
|
||||
// total, so exhausting one model's budget never blocks the others.
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/billing"
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
"llmsproxy/internal/provider"
|
||||
@ -30,6 +31,7 @@ type Core struct {
|
||||
mu sync.Mutex
|
||||
cfg *config.Config
|
||||
vm *lua.VM
|
||||
plugins *lua.Plugins
|
||||
store *config.Store
|
||||
scheduler *scheduler.Scheduler
|
||||
registry *provider.Registry
|
||||
@ -53,6 +55,28 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
if err := c.vm.Start(); err != nil {
|
||||
return nil, fmt.Errorf("lua vm: %w", err)
|
||||
}
|
||||
// Plugins load AFTER the VM is up (a plugin is just another Lua state) but
|
||||
// BEFORE anything can serve traffic, so a plugin's request_end hook sees
|
||||
// the very first request. A plugin that fails to load is logged and skipped
|
||||
// rather than failing startup: plugins are optional extensions, and failing
|
||||
// to boot the gateway because a third-party .lua has a typo would be the
|
||||
// wrong trade.
|
||||
c.plugins = lua.NewPlugins(c.vm, cfg.PluginDir)
|
||||
if err := c.plugins.SeedBundled(); err != nil {
|
||||
log.Printf("[core] seed bundled plugins: %v", err)
|
||||
}
|
||||
if err := c.plugins.LoadDir(); err != nil {
|
||||
log.Printf("[core] plugin dir: %v", err)
|
||||
}
|
||||
for _, p := range c.plugins.List() {
|
||||
if msg, bad := p["error"]; bad {
|
||||
log.Printf("[core] plugin %v failed to load: %v", p["name"], msg)
|
||||
continue
|
||||
}
|
||||
log.Printf("[core] plugin %v %v loaded (hooks=%v ui=%v)",
|
||||
p["name"], p["version"], p["hooks"], p["ui"])
|
||||
}
|
||||
c.applyBillingDSL()
|
||||
c.store = config.NewStore(cfg.RuntimeFile)
|
||||
// Share one box between the runtime store and config.yaml so a single
|
||||
// master.key seals both files. config.Load left the config holding
|
||||
@ -221,6 +245,11 @@ func buildRetries(cfg *config.Config) int {
|
||||
// VM exposes the Lua adapter runtime.
|
||||
func (c *Core) VM() *lua.VM { return c.vm }
|
||||
|
||||
// Plugins exposes the loaded plugin set. Never nil once New* has returned, so
|
||||
// the gateway can fire stages unconditionally; an unconfigured plugin
|
||||
// directory yields an empty registry whose Fire is a no-op.
|
||||
func (c *Core) Plugins() *lua.Plugins { return c.plugins }
|
||||
|
||||
func (c *Core) Scheduler() *scheduler.Scheduler { return c.scheduler }
|
||||
|
||||
func (c *Core) Registry() *provider.Registry { return c.registry }
|
||||
@ -799,11 +828,75 @@ func normalizeSource(s *config.Source) error {
|
||||
|
||||
// Close releases resources.
|
||||
func (c *Core) Close() {
|
||||
// Plugin state must be flushed BEFORE the VM stops. The saver's final write
|
||||
// reads each plugin's Lua tables; once vm.Stop() has closed those states the
|
||||
// read finds nothing and the last interval of accumulation is lost — which
|
||||
// is the exact failure this persistence was added to prevent.
|
||||
if c.plugins != nil {
|
||||
c.plugins.Close()
|
||||
}
|
||||
if c.vm != nil {
|
||||
c.vm.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// applyBillingDSL compiles the configured billing profile and injects it into
|
||||
// the billing plugin.
|
||||
//
|
||||
// Configured pricing replaces the hand-written JSON blob that used to be PUT
|
||||
// through the state API. That path was used twice in production and both times
|
||||
// it "succeeded" while being wrong: once without the required `prices` wrapper
|
||||
// (silently wiping the accumulated totals), once with a peak-window shape the
|
||||
// plugin does not read (peak traffic billed at off-peak rates, no error). A
|
||||
// typed config with load-time validation turns both mistakes into startup
|
||||
// errors naming the file and field.
|
||||
//
|
||||
// A DSL with no profile, or one that fails to compile, leaves the plugin's
|
||||
// prices untouched: billing keeps running on whatever it had, and the problem
|
||||
// is logged rather than becoming a startup failure — the gateway must forward
|
||||
// even when its cost accounting is misconfigured.
|
||||
func (c *Core) applyBillingDSL() {
|
||||
ps := c.plugins
|
||||
if ps == nil || ps.Count() == 0 {
|
||||
return
|
||||
}
|
||||
// The billing plugin may simply not be installed; a DSL without it is a
|
||||
// no-op, not an error.
|
||||
var have bool
|
||||
for _, row := range ps.List() {
|
||||
if row["name"] == "billing" && row["loaded"] == true {
|
||||
have = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !have {
|
||||
return
|
||||
}
|
||||
dsl := c.cfg.BillingDSL
|
||||
if dsl == nil || len(dsl.Profiles) == 0 {
|
||||
return
|
||||
}
|
||||
if err := dsl.Validate(); err != nil {
|
||||
log.Printf("[billing] config invalid, profile not applied: %v", err)
|
||||
return
|
||||
}
|
||||
profile := dsl.Resolve(dsl.Active)
|
||||
if profile == nil {
|
||||
log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active)
|
||||
return
|
||||
}
|
||||
prices, err := billing.Compile(profile, c.cfg.Sources)
|
||||
if err != nil {
|
||||
log.Printf("[billing] profile %q not applied: %v", profile.ID, err)
|
||||
return
|
||||
}
|
||||
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
|
||||
log.Printf("[billing] applying profile %q: %v", profile.ID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[billing] profile %q applied (%d rules, %d sources)", profile.ID, len(profile.Rules), len(c.cfg.Sources))
|
||||
}
|
||||
|
||||
// ValidateScopeQuotas checks every scope entry's caps before they are stored.
|
||||
// A typo in a period must be rejected at write time rather than silently
|
||||
// becoming a never-resetting budget — the opposite of what was typed.
|
||||
|
||||
@ -3,6 +3,7 @@ package gateway
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@ -83,6 +84,121 @@ type sourcePayload struct {
|
||||
// It exists because "add one model" is the most common scripted edit and a
|
||||
// full Models list cannot be written without reading the source first.
|
||||
ModelIDs []string `json:"model_ids,omitempty"`
|
||||
|
||||
// The four fields below are POINTERS, and that is the whole point.
|
||||
//
|
||||
// POST/PUT is an upsert that rewrites the whole source, so any field the
|
||||
// payload cannot express is silently reset to its zero value. That already
|
||||
// destroyed api_key once (fixed with resolveAPIKey) and would equally
|
||||
// destroy proxy_url, api_key_env, timeout and queue_timeout.
|
||||
//
|
||||
// A plain string/duration cannot distinguish "the caller did not mention
|
||||
// this field" from "the caller asked for the empty value", and only the
|
||||
// former may inherit. So:
|
||||
//
|
||||
// nil → field absent from the request: keep the current value
|
||||
// &"x" → present: store exactly "x" (including "" to clear it)
|
||||
//
|
||||
// api_key deliberately keeps its older "empty means inherit" rule rather
|
||||
// than gaining a pointer: that rule is already published (v1.7.6) and
|
||||
// scripts depend on it. Changing it now would let a script that echoes an
|
||||
// empty api_key erase a live credential, which is the exact failure this
|
||||
// whole area exists to prevent.
|
||||
ProxyURL *string `json:"proxy_url,omitempty"`
|
||||
APIKeyEnv *string `json:"api_key_env,omitempty"`
|
||||
Timeout *string `json:"timeout,omitempty"` // duration string, e.g. "300s"
|
||||
QueueTimeout *string `json:"queue_timeout,omitempty"` // duration string, e.g. "90s"
|
||||
}
|
||||
|
||||
// optionalSourceFields carries the Source fields a partial payload must not
|
||||
// reset, each as a pointer so "absent" and "explicitly empty" stay distinct.
|
||||
//
|
||||
// nil → absent from the request: keep the current value
|
||||
// &"" → present: clear it
|
||||
// &"x" → present: store "x"
|
||||
//
|
||||
// The alternative — a plain value with "empty means inherit" — cannot express
|
||||
// "clear this field", so emptying the proxy input in the UI would silently keep
|
||||
// the old proxy. It is exactly why api_key is NOT modelled this way: for a
|
||||
// credential, failing to keep the old value is worse than failing to clear it,
|
||||
// and that rule is already published (v1.7.6). Two fields with opposite failure
|
||||
// modes therefore get opposite rules, and both are spelled out here.
|
||||
type optionalSourceFields struct {
|
||||
ProxyURL *string
|
||||
APIKeyEnv *string
|
||||
Timeout *string
|
||||
QueueTimeout *string
|
||||
}
|
||||
|
||||
// resolve overlays the four optional fields onto payload.
|
||||
//
|
||||
// A nil pointer means the request did not mention the field, so it keeps the
|
||||
// value already stored in cur (nil cur = a source being created, where the
|
||||
// payload's own zero value is correct). A non-nil pointer always wins, including
|
||||
// when it points at the empty string, which is how a UI form clears a field.
|
||||
//
|
||||
// Only these four fields are overlaid. Everything else in payload is taken as
|
||||
// sent: an upsert that inherited the whole record would make it impossible to
|
||||
// change anything.
|
||||
func (o optionalSourceFields) resolve(payload config.Source, cur *config.Source) (config.Source, error) {
|
||||
// cur is dereferenced exactly once here so the create path (nil) cannot
|
||||
// panic on the per-field lookups below.
|
||||
var storedProxyURL, storedKeyEnv string
|
||||
var storedTimeout, storedQueueTimeout time.Duration
|
||||
if cur != nil {
|
||||
storedProxyURL, storedKeyEnv = cur.ProxyURL, cur.APIKeyEnv
|
||||
storedTimeout, storedQueueTimeout = cur.Timeout, cur.QueueTimeout
|
||||
}
|
||||
pick := func(p *string, stored string) string {
|
||||
if p != nil {
|
||||
return *p
|
||||
}
|
||||
return stored
|
||||
}
|
||||
payload.ProxyURL = pick(o.ProxyURL, storedProxyURL)
|
||||
payload.APIKeyEnv = pick(o.APIKeyEnv, storedKeyEnv)
|
||||
if err := overlayDuration(&payload.Timeout, o.Timeout, "timeout", storedTimeout); err != nil {
|
||||
return payload, err
|
||||
}
|
||||
if err := overlayDuration(&payload.QueueTimeout, o.QueueTimeout, "queue_timeout", storedQueueTimeout); err != nil {
|
||||
return payload, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// overlayDuration applies the absent/present rule to one duration field: a nil
|
||||
// pointer keeps stored, a set pointer replaces it (with "" / "0s" clearing).
|
||||
func overlayDuration(dst *time.Duration, p *string, field string, stored time.Duration) error {
|
||||
if p == nil {
|
||||
*dst = stored
|
||||
return nil
|
||||
}
|
||||
d, err := parseOptionalDuration(*p, field)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*dst = d
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseOptionalDuration parses a duration written as a Go duration string.
|
||||
// An empty string clears the field back to the config default (0), which is
|
||||
// what a UI form submitting an empty timeout box should mean. Parsing happens
|
||||
// only for fields the request actually mentions, so a typo can never surface
|
||||
// as a silent reset of something else.
|
||||
func parseOptionalDuration(v, field string) (time.Duration, error) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s %q is not a duration (use e.g. 120s, 5m, 1h)", field, v)
|
||||
}
|
||||
if d < 0 {
|
||||
return 0, fmt.Errorf("%s must be >= 0 (got %s)", field, v)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// keepExistingAPIKey is the mask a client sends when it means "keep the
|
||||
@ -186,6 +302,21 @@ func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
MaxConcurrent: p.MaxConcurrent,
|
||||
RPM: p.RPM,
|
||||
}
|
||||
// The four upsert-fragile fields are applied last: a nil pointer means
|
||||
// "not in the request", so they are inherited from the stored source
|
||||
// instead of being reset to the payload's zero value. This runs whether
|
||||
// or not the request mentioned them, because resolve() itself decides
|
||||
// per field — passing them unconditionally keeps the rule in one place.
|
||||
src, err := optionalSourceFields{
|
||||
ProxyURL: p.ProxyURL,
|
||||
APIKeyEnv: p.APIKeyEnv,
|
||||
Timeout: p.Timeout,
|
||||
QueueTimeout: p.QueueTimeout,
|
||||
}.resolve(src, g.sourceByName(p.Name))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "source_error", err.Error())
|
||||
return
|
||||
}
|
||||
// model_ids is additive: "add these models" is the common scripted edit
|
||||
// and it must not require reading (and echoing) the whole list back.
|
||||
// A request that omits models entirely is therefore a pure add, not a
|
||||
@ -302,6 +433,32 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
key := exportKey(r)
|
||||
// ?period=day|week|month|all switches the whole payload to a calendar
|
||||
// window (UTC) aggregated off the audit files, instead of the
|
||||
// since-process-start totals. The CSV exports below are unaffected: they
|
||||
// take an explicit from/to range and stream, so a period selector there
|
||||
// would only be a second way to spell the same bounds.
|
||||
if p := periodFromQuery(r.URL.Query()); p != PeriodAll {
|
||||
if !ValidPeriod(p) {
|
||||
writeError(w, http.StatusBadRequest, "bad_period",
|
||||
"period must be one of day, week, month, all")
|
||||
return
|
||||
}
|
||||
out := g.stats.PeriodSnapshot(p, key, time.Now())
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"period": out.Period,
|
||||
"from": out.From,
|
||||
"total": out.Total,
|
||||
"by_key": out.ByKey,
|
||||
"by_model": out.Models,
|
||||
"by_source": out.Srcs,
|
||||
"by_status": out.Status,
|
||||
"buckets": out.Bucket,
|
||||
"truncated": out.Truncated,
|
||||
"key_names": g.keyNamesFor(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("export") == "csv" {
|
||||
from, _ := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
|
||||
to, _ := strconv.ParseInt(r.URL.Query().Get("to"), 10, 64)
|
||||
@ -409,14 +566,22 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
snap := g.stats.Snapshot(limit, key)
|
||||
keyNames := map[string]string{}
|
||||
for _, k := range g.core.ListKeys() {
|
||||
keyNames[keyID(k.Key)] = k.Name
|
||||
}
|
||||
snap["key_names"] = keyNames
|
||||
snap["key_names"] = g.keyNamesFor()
|
||||
writeJSON(w, http.StatusOK, snap)
|
||||
}
|
||||
|
||||
// keyNamesFor is the masked-id -> display-name map every stats payload needs.
|
||||
// It is keyed by keyID (the mask), not the raw key, because that is what the
|
||||
// aggregate rows carry — building it in one place stops the period branch and
|
||||
// the lifetime branch from drifting apart.
|
||||
func (g *Gateway) keyNamesFor() map[string]string {
|
||||
names := map[string]string{}
|
||||
for _, k := range g.core.ListKeys() {
|
||||
names[keyID(k.Key)] = k.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// handleStatsRecordsAPI pages the request records straight off the audit files.
|
||||
// The dashboard loads only its first screen and asks for the next page as the
|
||||
// user scrolls, so neither side holds the full history: the server keeps no
|
||||
|
||||
@ -78,7 +78,9 @@ func (g *Gateway) apiV1Routes(w http.ResponseWriter, r *http.Request) {
|
||||
"admin role required to reveal credentials")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"source": s})
|
||||
// The edit form round-trips this response, so it needs the credential in the
|
||||
// clear AND the durations — see maskSourceFields.
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSourceFields(s, true, true)})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSource(s)})
|
||||
@ -176,7 +178,11 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
|
||||
{Method: "GET", Path: "/api/keys", Auth: "admin", Summary: "gateway keys"},
|
||||
{Method: "POST", Path: "/api/keys", Auth: "admin", Summary: "create a gateway key",
|
||||
WriteEffect: "writes config.yaml"},
|
||||
{Method: "DELETE", Path: "/api/keys/{name}", Auth: "admin", Summary: "delete a gateway key",
|
||||
{Method: "PUT", Path: "/api/keys/{key}", Auth: "admin",
|
||||
Summary: "update a gateway key (name, role, model scopes and their per-model quotas)",
|
||||
WriteEffect: "writes config.yaml"},
|
||||
{Method: "DELETE", Path: "/api/keys/{key}", Auth: "admin",
|
||||
Summary: "delete a gateway key — the path segment is the KEY itself, not its name",
|
||||
WriteEffect: "writes config.yaml"},
|
||||
|
||||
{Method: "GET", Path: "/api/status", Auth: "any", Summary: "per-source health detail"},
|
||||
@ -194,6 +200,14 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
|
||||
"partial_update": "api_key may be omitted or sent as the literal \"__KEEP__\" to inherit the " +
|
||||
"current credential; model_ids adds models to the existing list instead of replacing it, " +
|
||||
"so a one-field edit never needs to read the source first",
|
||||
"optional_fields": "proxy_url, api_key_env, timeout and queue_timeout use presence semantics: " +
|
||||
"OMITTED from the request keeps the stored value, present (even as \"\") overwrites it. " +
|
||||
"The upsert rewrites the whole source, so without this a one-field edit would silently reset " +
|
||||
"them — and api_key_env in particular is only visible as a credential failure on the NEXT " +
|
||||
"upstream call. api_key deliberately keeps the older \"empty means inherit\" rule instead, " +
|
||||
"because losing a credential breaks the source while losing a proxy only changes its route.",
|
||||
"durations": "timeout and queue_timeout are Go duration strings (\"300s\", \"5m\", \"1h\"); " +
|
||||
"the empty string clears them back to the config defaults",
|
||||
"config_truth": "all configuration lives in config.yaml; API writes are persisted immediately",
|
||||
"credentials": "credentials are masked by default. GET /api/v1/sources/{name}?reveal=credentials " +
|
||||
"returns them in the clear and is admin-only — the Web UI edit dialog uses it, because a form " +
|
||||
@ -294,23 +308,48 @@ func maskSources(srcs []config.Source) []map[string]interface{} {
|
||||
}
|
||||
|
||||
func maskSource(s config.Source) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
return maskSourceFields(s, false, false)
|
||||
}
|
||||
|
||||
// maskSourceFields builds the API view of a source.
|
||||
//
|
||||
// revealCredentials swaps the masked api_key for the real one — admin-only, and
|
||||
// used by the WebUI edit dialog, whose form has to round-trip the whole source
|
||||
// or saving an unrelated field would blank the key.
|
||||
//
|
||||
// exposeDurations adds the two durations as strings, matching what the write
|
||||
// path accepts. It rides along with the credential reveal because both exist for
|
||||
// the same reason: config.Source tags Timeout/QueueTimeout `json:"-"`, so a
|
||||
// plain marshal of the struct omits them. A form that cannot SEE the stored
|
||||
// timeout would clear it on every save, since the timeout box is always sent.
|
||||
func maskSourceFields(s config.Source, revealCredentials, exposeDurations bool) map[string]interface{} {
|
||||
key := maskKey(s.APIKey)
|
||||
if revealCredentials {
|
||||
key = s.APIKey
|
||||
}
|
||||
m := map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"base_url": s.BaseURL,
|
||||
"adapter": s.Adapter,
|
||||
"endpoint": s.Endpoint,
|
||||
"image_endpoint": s.ImageEndpoint,
|
||||
"api_key": maskKey(s.APIKey),
|
||||
"api_key": key,
|
||||
"api_key_set": s.APIKey != "",
|
||||
"models": s.Models,
|
||||
"headers": maskHeaders(s.Headers),
|
||||
"proxy_url": s.ProxyURL,
|
||||
"api_key_env": s.APIKeyEnv,
|
||||
"meta": s.Meta,
|
||||
"temperature": s.Temperature,
|
||||
"max_tokens": s.MaxTokens,
|
||||
"max_concurrent": s.MaxConcurrent,
|
||||
"rpm": s.RPM,
|
||||
}
|
||||
if exposeDurations {
|
||||
m["timeout"] = s.Timeout.String()
|
||||
m["queue_timeout"] = s.QueueTimeout.String()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func maskKey(k string) string {
|
||||
|
||||
282
internal/gateway/billing_rules_api.go
Normal file
282
internal/gateway/billing_rules_api.go
Normal file
@ -0,0 +1,282 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"llmsproxy/internal/billing"
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// Billing rules API.
|
||||
//
|
||||
// GET /api/plugins/billing/rules the active profile's rules + all profiles
|
||||
// PUT /api/plugins/billing/rules replace the whole DSL and re-inject prices
|
||||
// POST /api/plugins/billing/rules { profile, rule } append one rule
|
||||
// DELETE /api/plugins/billing/rules { profile, url } remove one rule
|
||||
//
|
||||
// Why a separate endpoint instead of the generic plugin state: prices are
|
||||
// plugin state, but they are also a durable, reviewable CONFIGURATION. Routing
|
||||
// them through /state would let an admin key PUT arbitrary plugin state and
|
||||
// silently reset the accumulated accounting. Here the server owns the shape:
|
||||
// it validates the DSL, writes it back to config.yaml, recompiles, and hands
|
||||
// the plugin its prices table. The operator edits rules; the plugin keeps
|
||||
// numbers. The two are never mixed in one payload.
|
||||
//
|
||||
// Editing means "what the operator typed is what config.yaml holds". A
|
||||
// billing mistake found next week must be traceable to a reviewable file, not
|
||||
// to a blob in the plugin's state sidecar.
|
||||
func (g *Gateway) handleBillingRules(w http.ResponseWriter, r *http.Request) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
g.getBillingRules(w)
|
||||
case http.MethodPut:
|
||||
g.putBillingRules(w, r)
|
||||
case http.MethodPost:
|
||||
g.addBillingRule(w, r)
|
||||
case http.MethodDelete:
|
||||
g.delBillingRule(w, r)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET/PUT/POST/DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
// billingRulesPayload is the UI-facing view: the profiles as declared, plus
|
||||
// which one is active and the source URLs they can match — the editor needs
|
||||
// the URL list to offer suggestions, and an operator typing a URL that matches
|
||||
// no source is exactly the silent-no-pricing failure this feature exists to
|
||||
// remove.
|
||||
type billingRulesPayload struct {
|
||||
DSL *config.BillingDSL `json:"billing"`
|
||||
Active string `json:"active"`
|
||||
URLs []string `json:"urls"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
func (g *Gateway) billingRulesPayload() billingRulesPayload {
|
||||
out := billingRulesPayload{}
|
||||
cfg := g.core.Config()
|
||||
if cfg != nil && cfg.BillingDSL != nil {
|
||||
// Copy so the response cannot be mutated back through the pointer.
|
||||
cp := *cfg.BillingDSL
|
||||
out.DSL = &cp
|
||||
if p := cp.Resolve(""); p != nil {
|
||||
out.Active = p.ID
|
||||
}
|
||||
}
|
||||
for _, s := range g.sourceURLs() {
|
||||
out.URLs = append(out.URLs, s)
|
||||
}
|
||||
out.Warnings = g.billingRuleWarnings()
|
||||
return out
|
||||
}
|
||||
|
||||
func (g *Gateway) sourceURLs() []string {
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, s := range cfg.Sources {
|
||||
if s.BaseURL == "" || seen[s.BaseURL] {
|
||||
continue
|
||||
}
|
||||
seen[s.BaseURL] = true
|
||||
out = append(out, s.BaseURL)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// billingRuleWarnings reports rules that match no configured source. This is
|
||||
// the silent killer of hand-written price tables: the rule parses, validates,
|
||||
// and prices nothing at all, so every request on that URL lands in unpriced.
|
||||
// Saying so out loud is the difference between a five-second fix and an
|
||||
// afternoon wondering why the bill is zero.
|
||||
func (g *Gateway) billingRuleWarnings() []string {
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil || cfg.BillingDSL == nil {
|
||||
return nil
|
||||
}
|
||||
urls := map[string]bool{}
|
||||
for _, u := range g.sourceURLs() {
|
||||
urls[u] = true
|
||||
}
|
||||
var warns []string
|
||||
for _, p := range cfg.BillingDSL.Profiles {
|
||||
for _, r := range p.Rules {
|
||||
if r.URL == "*" || urls[r.URL] {
|
||||
continue
|
||||
}
|
||||
warns = append(warns, "profile "+p.ID+": rule url "+r.URL+" matches no configured source")
|
||||
}
|
||||
}
|
||||
return warns
|
||||
}
|
||||
|
||||
func (g *Gateway) getBillingRules(w http.ResponseWriter) {
|
||||
writeJSON(w, http.StatusOK, g.billingRulesPayload())
|
||||
}
|
||||
|
||||
func (g *Gateway) putBillingRules(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Billing *config.BillingDSL `json:"billing"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if body.Billing == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "billing is required")
|
||||
return
|
||||
}
|
||||
g.applyBillingDSLUpdate(w, body.Billing)
|
||||
}
|
||||
|
||||
func (g *Gateway) addBillingRule(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Profile string `json:"profile"`
|
||||
Rule config.BillingRule `json:"rule"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil || cfg.BillingDSL == nil || len(cfg.BillingDSL.Profiles) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "no_profile", "no billing profiles configured; create one first")
|
||||
return
|
||||
}
|
||||
next := cloneBillingDSL(cfg.BillingDSL)
|
||||
idx := profileIndex(next, body.Profile)
|
||||
if idx < 0 {
|
||||
writeError(w, http.StatusBadRequest, "unknown_profile", "no such profile: "+body.Profile)
|
||||
return
|
||||
}
|
||||
p := &next.Profiles[idx]
|
||||
// Replace rather than append when the URL is already declared: two rules
|
||||
// for one URL would silently make the first unreachable (first match wins),
|
||||
// which is the exact ambiguity an editor should not be able to create.
|
||||
replaced := false
|
||||
for i := range p.Rules {
|
||||
if p.Rules[i].URL == body.Rule.URL {
|
||||
p.Rules[i] = body.Rule
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
p.Rules = append(p.Rules, body.Rule)
|
||||
}
|
||||
g.applyBillingDSLUpdate(w, next)
|
||||
}
|
||||
|
||||
func (g *Gateway) delBillingRule(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Profile string `json:"profile"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil || cfg.BillingDSL == nil {
|
||||
writeError(w, http.StatusBadRequest, "no_profile", "no billing profiles configured")
|
||||
return
|
||||
}
|
||||
next := cloneBillingDSL(cfg.BillingDSL)
|
||||
idx := profileIndex(next, body.Profile)
|
||||
if idx < 0 {
|
||||
writeError(w, http.StatusBadRequest, "unknown_profile", "no such profile: "+body.Profile)
|
||||
return
|
||||
}
|
||||
p := &next.Profiles[idx]
|
||||
out := p.Rules[:0]
|
||||
found := false
|
||||
for _, rule := range p.Rules {
|
||||
if rule.URL == body.URL {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
out = append(out, rule)
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no rule for url "+body.URL)
|
||||
return
|
||||
}
|
||||
p.Rules = out
|
||||
g.applyBillingDSLUpdate(w, next)
|
||||
}
|
||||
|
||||
// applyBillingDSLUpdate is the single write path: validate, persist to
|
||||
// config.yaml, recompile, inject, and only then report success. If the config
|
||||
// cannot be written the change is NOT applied in memory either — a rules editor
|
||||
// that says "saved" and loses the edit on the next restart is worse than one
|
||||
// that refuses.
|
||||
func (g *Gateway) applyBillingDSLUpdate(w http.ResponseWriter, next *config.BillingDSL) {
|
||||
if err := next.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_rules", err.Error())
|
||||
return
|
||||
}
|
||||
cfg := g.core.Config()
|
||||
if cfg == nil {
|
||||
writeError(w, http.StatusInternalServerError, "no_config", "no config loaded")
|
||||
return
|
||||
}
|
||||
// Assign BEFORE saving. The previous order saved first and assigned after,
|
||||
// so every rule edit reported success while writing a config.yaml with no
|
||||
// billing section at all — the operator's edit vanished on restart and
|
||||
// nothing in the API response said so.
|
||||
prev := cfg.BillingDSL
|
||||
cfg.BillingDSL = next
|
||||
|
||||
// Compile before persisting: a profile that cannot be turned into prices
|
||||
// must not reach the config file, or the next restart fails to load the
|
||||
// very rules the editor just accepted.
|
||||
prices, err := billing.CompileOpts(next.Resolve(next.Active), cfg.Sources, true)
|
||||
if err != nil {
|
||||
cfg.BillingDSL = prev // no half-applied state
|
||||
writeError(w, http.StatusBadRequest, "compile_failed", err.Error())
|
||||
return
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
cfg.BillingDSL = prev
|
||||
writeError(w, http.StatusInternalServerError, "persist_failed", err.Error())
|
||||
return
|
||||
}
|
||||
ps := g.core.Plugins()
|
||||
if ps != nil {
|
||||
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
p := g.billingRulesPayload()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true, "billing": p.DSL, "active": p.Active, "warnings": p.Warnings,
|
||||
})
|
||||
}
|
||||
|
||||
func cloneBillingDSL(d *config.BillingDSL) *config.BillingDSL {
|
||||
out := &config.BillingDSL{Active: d.Active}
|
||||
out.Profiles = make([]config.BillingProfile, len(d.Profiles))
|
||||
copy(out.Profiles, d.Profiles)
|
||||
return out
|
||||
}
|
||||
|
||||
func profileIndex(d *config.BillingDSL, id string) int {
|
||||
if id == "" {
|
||||
return -1
|
||||
}
|
||||
for i, p := range d.Profiles {
|
||||
if p.ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
281
internal/gateway/billing_rules_api_test.go
Normal file
281
internal/gateway/billing_rules_api_test.go
Normal file
@ -0,0 +1,281 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// rulesGateway needs a real billing plugin (the API injects prices into it)
|
||||
// AND two sources whose base_urls the rules will match, so it cannot reuse
|
||||
// either existing helper: gatewayWithBilling has no sources, and
|
||||
// newTestGateway loads no plugins.
|
||||
func rulesGateway(t *testing.T) *Gateway {
|
||||
t.Helper()
|
||||
g := gatewayWithBilling(t)
|
||||
cfg := g.core.Config()
|
||||
cfg.Sources = []config.Source{
|
||||
{Name: "localzen", BaseURL: "https://free.example.com/v1"},
|
||||
{Name: "commandcode", BaseURL: "https://api.commandcode.ai/v1"},
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func rulesDo(t *testing.T, g *Gateway, method, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return doReq(t, g, method, "/api/plugins/billing/rules", body)
|
||||
}
|
||||
|
||||
// TestBillingRulesCRUD covers the whole editor loop against the real HTTP
|
||||
// surface: read, add, replace-by-URL, delete. The replace-by-URL case is the
|
||||
// one worth pinning — two rules for one URL would make the first unreachable
|
||||
// (first match wins) without any error, which is exactly the kind of silent
|
||||
// ambiguity an editor must not be able to create.
|
||||
func TestBillingRulesCRUD(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
seed := `{"billing":{"active":"p1","profiles":[{"id":"p1","rules":[
|
||||
{"url":"https://free.example.com/v1","mode":"free"}]}]}}`
|
||||
if rr := rulesDo(t, g, http.MethodPut, seed); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed rules = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Add a token-priced rule for the second URL.
|
||||
add := `{"profile":"p1","rule":{"url":"https://api.commandcode.ai/v1","mode":"token",
|
||||
"models":{"deepseek-v4.1-flash":{"prompt":"0.15","completion":"0.60"}}}}`
|
||||
if rr := rulesDo(t, g, http.MethodPost, add); rr.Code != http.StatusOK {
|
||||
t.Fatalf("add rule = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got billingRulesPayload
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.DSL.Profiles[0].Rules) != 2 {
|
||||
t.Fatalf("after add there are %d rules, want 2", len(got.DSL.Profiles[0].Rules))
|
||||
}
|
||||
|
||||
// Adding the SAME url again must replace, not append.
|
||||
if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p1","rule":{"url":"https://api.commandcode.ai/v1","mode":"free"}}`); rr.Code != http.StatusOK {
|
||||
t.Fatalf("re-add same url = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(got.DSL.Profiles[0].Rules); n != 2 {
|
||||
t.Fatalf("★ re-adding an existing url appended instead of replacing: %d rules", n)
|
||||
}
|
||||
var mode string
|
||||
for _, r := range got.DSL.Profiles[0].Rules {
|
||||
if r.URL == "https://api.commandcode.ai/v1" {
|
||||
mode = r.Mode
|
||||
}
|
||||
}
|
||||
if mode != "free" {
|
||||
t.Errorf("re-added rule mode = %q, want free (the replacement must win)", mode)
|
||||
}
|
||||
|
||||
// Delete by URL.
|
||||
if rr := rulesDo(t, g, http.MethodDelete, `{"profile":"p1","url":"https://api.commandcode.ai/v1"}`); rr.Code != http.StatusOK {
|
||||
t.Fatalf("delete rule = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.DSL.Profiles[0].Rules) != 1 {
|
||||
t.Errorf("after delete there are %d rules, want 1", len(got.DSL.Profiles[0].Rules))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingRulesRejectUnknownURLWithWarning is the silent-failure guard: a
|
||||
// rule whose URL matches no source prices nothing, so every request on it
|
||||
// falls into unpriced. The API must say so instead of accepting it silently.
|
||||
func TestBillingRulesRejectUnknownURLWithWarning(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
if rr := rulesDo(t, g, http.MethodPut, `{"billing":{"active":"p","profiles":[{"id":"p",
|
||||
"rules":[{"url":"https://typo.example.com/v1","mode":"free"}]}]}}`); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got billingRulesPayload
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Warnings) == 0 {
|
||||
t.Fatal("★ a rule matching no configured source was accepted with no warning — " +
|
||||
"it will price nothing and every request will be recorded as unpriced")
|
||||
}
|
||||
if !strings.Contains(got.Warnings[0], "typo.example.com") {
|
||||
t.Errorf("warning does not name the offending url: %v", got.Warnings)
|
||||
}
|
||||
// The editor needs the real URL list to offer suggestions.
|
||||
if len(got.URLs) != 2 {
|
||||
t.Errorf("urls offered to the editor = %v, want the 2 configured base_urls", got.URLs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingRulesInvalidIsRejectedNotPersisted checks the write path refuses
|
||||
// bad input and leaves the previous rules in place — an editor that clears the
|
||||
// table on a typo loses the price table.
|
||||
func TestBillingRulesInvalidIsRejectedNotPersisted(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
seed := `{"billing":{"active":"p","profiles":[{"id":"p","rules":[
|
||||
{"url":"https://free.example.com/v1","mode":"free"}]}]}}`
|
||||
if rr := rulesDo(t, g, http.MethodPut, seed); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// An unknown mode must be refused.
|
||||
if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p","rule":{"url":"https://x/v1","mode":"banana"}}`); rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid mode accepted with %d, want 400", rr.Code)
|
||||
}
|
||||
var got billingRulesPayload
|
||||
if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.DSL.Profiles[0].Rules) != 1 {
|
||||
t.Errorf("★ a rejected rule was persisted anyway: %d rules", len(got.DSL.Profiles[0].Rules))
|
||||
}
|
||||
// An unparseable price must be refused too, not silently priced at zero.
|
||||
if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p","rule":{"url":"https://y/v1","mode":"token",
|
||||
"models":{"m":{"prompt":"free","completion":"0.6"}}}}`); rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("non-numeric price accepted with %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingRulesPersistAndReachThePlugin is the point of the whole
|
||||
// endpoint, and mutation verification showed the earlier CRUD tests missed it
|
||||
// twice: dropping cfg.Save() and dropping the SetState injection both left
|
||||
// every test green. Both failures are invisible in a GET — the response echoes
|
||||
// the in-memory rules either way — so this test checks the two consequences
|
||||
// that actually matter:
|
||||
//
|
||||
// 1. persistence: the rule must be in the config file on disk, because an
|
||||
// edit that evaporates on restart is a lie the UI told the operator;
|
||||
// 2. effect: the billing plugin must now PRICE the source (its published
|
||||
// prices table has a non-zero entry), because a rule that is stored but
|
||||
// never injected prices nothing at all.
|
||||
func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
cfg := g.core.Config()
|
||||
|
||||
seed := `{"billing":{"active":"p","profiles":[{"id":"p","rules":[
|
||||
{"url":"https://free.example.com/v1","mode":"free"},
|
||||
{"url":"https://api.commandcode.ai/v1","mode":"token",
|
||||
"models":{"deepseek-v4.1-flash":{"prompt":"0.15","completion":"0.60"}}}]}]}}`
|
||||
if rr := rulesDo(t, g, http.MethodPut, seed); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// 1. persisted to the file the process loaded from.
|
||||
raw, err := os.ReadFile(cfg.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "commandcode.ai") {
|
||||
t.Errorf("★ the rule is not in config.yaml on disk — an edit that does not "+
|
||||
"survive a restart:\n%s", raw)
|
||||
}
|
||||
reloaded, err := config.Load(cfg.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload saved config: %v", err)
|
||||
}
|
||||
if reloaded.BillingDSL == nil || len(reloaded.BillingDSL.Profiles) == 0 {
|
||||
t.Fatalf("saved config has no billing DSL: %s", raw)
|
||||
}
|
||||
// A round-trip through YAML must not lose the price strings.
|
||||
found := false
|
||||
for _, r := range reloaded.BillingDSL.Profiles[0].Rules {
|
||||
if strings.Contains(r.URL, "commandcode") {
|
||||
found = true
|
||||
if m, ok := r.Models["deepseek-v4.1-flash"]; !ok || m.Prompt != "0.15" {
|
||||
t.Errorf("price did not survive the YAML round-trip: %+v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the token rule is missing after reload:\n%s", raw)
|
||||
}
|
||||
|
||||
// 2. reached the plugin. Prices live in a separate Lua field from `state`,
|
||||
// and the GET /state endpoint returns the published state together with
|
||||
// the prices sidecar — which is exactly what the plugin's own UI reads.
|
||||
state := g.core.Plugins().State("billing")
|
||||
if state == nil {
|
||||
t.Fatalf("billing plugin published no state")
|
||||
}
|
||||
rr := doReq(t, g, http.MethodGet, "/api/plugins/billing/state", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET state = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Prices map[string]interface{} `json:"prices"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode state envelope: %v", err)
|
||||
}
|
||||
prices := envelope.Prices
|
||||
if prices == nil {
|
||||
t.Fatalf("state payload carries no prices")
|
||||
}
|
||||
sources, ok := prices["sources"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("prices has no sources map: %#v", prices)
|
||||
}
|
||||
cc, ok := sources["commandcode"]
|
||||
if !ok {
|
||||
t.Fatalf("★ the rule was saved but never injected: prices has %v, "+
|
||||
"no commandcode entry. Every request on it stays unpriced.", keysOf(sources))
|
||||
}
|
||||
if cc == nil {
|
||||
t.Error("commandcode price entry is nil")
|
||||
}
|
||||
// The free rule must price localzen, and the two must not be conflated.
|
||||
if _, ok := sources["localzen"]; !ok {
|
||||
t.Errorf("the free rule did not reach the plugin: %v", keysOf(sources))
|
||||
}
|
||||
// The injected price must be per-token, i.e. 0.15 USD/M => 1.5e-7.
|
||||
// Asserting the exact magnitude catches a unit error (per-million vs
|
||||
// per-token), which would be off by a factor of a million and still look
|
||||
// like "a number".
|
||||
models, ok := cc.(map[string]interface{})["models"].(map[string]interface{})
|
||||
if ok {
|
||||
if m, ok := models["deepseek-v4.1-flash"].(map[string]interface{}); ok {
|
||||
p, _ := m["prompt"].(float64)
|
||||
if p <= 0 || p > 1e-6 {
|
||||
t.Errorf("injected prompt price = %v, want per-token (~1.5e-7); "+
|
||||
"a per-million value here would over-bill by 1e6x", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TestBillingRulesRequireAdmin keeps price rewriting behind the admin role,
|
||||
// like every other plugin write: prices are configuration, not user data.
|
||||
func TestBillingRulesRequireAdmin(t *testing.T) {
|
||||
g := rulesGateway(t)
|
||||
rec, err := g.core.CreateKey("viewer", "user", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userKey := rec.Key
|
||||
for _, m := range []string{http.MethodPut, http.MethodPost, http.MethodDelete} {
|
||||
req, _ := http.NewRequest(m, "/api/plugins/billing/rules", strings.NewReader(`{}`))
|
||||
req.Header.Set("Authorization", "Bearer "+userKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if rr := newRecorderFor(t, g, req); rr.Code != http.StatusForbidden {
|
||||
t.Errorf("user %s rules = %d, want 403", m, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
"llmsproxy/internal/provider"
|
||||
"llmsproxy/internal/scheduler"
|
||||
"llmsproxy/internal/types"
|
||||
@ -385,6 +386,19 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if model == "" {
|
||||
model = g.core.DefaultModel()
|
||||
}
|
||||
// request_start fires for EVERY chat request, on both the AUTO and the
|
||||
// direct path, and it fires BEFORE the quota / model-scope gates on
|
||||
// purpose: a plugin that counts volume or audits traffic must also see the
|
||||
// requests the gateway rejected, otherwise "requests accepted" would be all
|
||||
// it could ever report. It sits after authentication (so the key and role in
|
||||
// the payload are real) and after the messages check (a body with no
|
||||
// messages is not a chat request at all).
|
||||
//
|
||||
// Calling it here rather than inside each branch is what keeps the two paths
|
||||
// honest: an earlier version called it only from the AUTO branch, so every
|
||||
// direct (model-pinned) request silently skipped it. That was caught by
|
||||
// TestHooksFireOnRealDirectChat, not by reading the code.
|
||||
g.fireStart(r.Context(), &req, "chat", model, len(req.Messages), len(req.Tools))
|
||||
if isAuto(model) {
|
||||
chain := g.core.AutoChain()
|
||||
if chain == nil || len(chain.Tiers) == 0 {
|
||||
@ -817,6 +831,7 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [
|
||||
recordChatUsage(rec, req, resp)
|
||||
rec.Source = usedSrc
|
||||
rec.Model = usedModel
|
||||
g.fireRouted(ctx, "chat", usedSrc, usedModel, -1, false)
|
||||
// Non-streaming: the whole response arrives at once, so TTFB equals
|
||||
// the total latency.
|
||||
rec.FirstByteMs = rec.LatMs
|
||||
@ -824,7 +839,18 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [
|
||||
writeChatCompletion(w, resp, effective)
|
||||
}
|
||||
|
||||
// writeRec records a finished request (audit + aggregates).
|
||||
// writeRec records a finished request (audit + aggregates) and fires the
|
||||
// plugin request_end stage.
|
||||
//
|
||||
// This is the ONE place every request passes through on its way out, which is
|
||||
// what makes it the right hook point: the four entry points (single/stream ×
|
||||
// direct/auto) all funnel here, so a plugin sees each request exactly once with
|
||||
// its final accounting. Firing earlier would miss the streamed ones (their
|
||||
// numbers are only known once the stream finishes), and firing in each entry
|
||||
// point would mean four call sites to keep in sync.
|
||||
//
|
||||
// Hooks run AFTER the record is written: a plugin must not be able to delay or
|
||||
// lose the audit trail, and a plugin that throws is contained by Fire.
|
||||
func (g *Gateway) writeRec(rec *Req) {
|
||||
if rec == nil {
|
||||
return
|
||||
@ -833,6 +859,191 @@ func (g *Gateway) writeRec(rec *Req) {
|
||||
rec.Time = time.Now().UnixMilli()
|
||||
}
|
||||
g.stats.Record(*rec)
|
||||
g.fireEnd(rec)
|
||||
}
|
||||
|
||||
// fireStart dispatches the plugin request_start stage: the request has been
|
||||
// parsed and authorized but no upstream slot has been chosen yet, so `source`
|
||||
// is empty. A plugin that only wants volume/acceptance counts can subscribe
|
||||
// here and stay out of the per-request hot path entirely.
|
||||
func (g *Gateway) fireStart(ctx context.Context, req *chatRequest, kind, model string, msgs, tools int) {
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil || ps.Count() == 0 {
|
||||
return
|
||||
}
|
||||
ps.Fire(lua.StageRequestStart, map[string]interface{}{
|
||||
"stage": string(lua.StageRequestStart),
|
||||
"type": kind,
|
||||
"model": model,
|
||||
"key": keyID(reqKey(ctx)),
|
||||
"role": reqRole(ctx),
|
||||
"source": "",
|
||||
"stream": req.Stream,
|
||||
"messages_count": msgs,
|
||||
"tools_count": tools,
|
||||
"ts": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// fireImageStart dispatches request_start for /v1/images/generations.
|
||||
//
|
||||
// It is a separate function rather than a call to fireStart with a nil
|
||||
// chatRequest because the image body has no messages and no tools: passing
|
||||
// zeroes through a struct built for chat would invite someone to read a field
|
||||
// that simply does not exist on this path.
|
||||
func (g *Gateway) fireImageStart(ctx context.Context, model string) {
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil || ps.Count() == 0 {
|
||||
return
|
||||
}
|
||||
ps.Fire(lua.StageRequestStart, map[string]interface{}{
|
||||
"stage": string(lua.StageRequestStart),
|
||||
"type": "image",
|
||||
"model": model,
|
||||
"key": keyID(reqKey(ctx)),
|
||||
"role": reqRole(ctx),
|
||||
"source": "",
|
||||
"stream": false,
|
||||
"messages_count": 0,
|
||||
"tools_count": 0,
|
||||
"ts": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// fireRouted dispatches the plugin routed stage once a (source, model) slot has
|
||||
// been selected. tier is the AUTO tier index, or -1 on the direct path, so a
|
||||
// plugin can tell "this came from tier 1" from "this bypassed the chain".
|
||||
func (g *Gateway) fireRouted(ctx context.Context, kind, source, model string, tier int, stream bool) {
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil || ps.Count() == 0 {
|
||||
return
|
||||
}
|
||||
ps.Fire(lua.StageRouted, map[string]interface{}{
|
||||
"stage": string(lua.StageRouted),
|
||||
"type": kind,
|
||||
"source": source,
|
||||
"model": model,
|
||||
"key": keyID(reqKey(ctx)),
|
||||
"tier": tier,
|
||||
"stream": stream,
|
||||
"ts": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// chainTraceSink adapts a scheduler TraceSink into the plugin chain_step stage.
|
||||
//
|
||||
// It returns nil when no plugin is loaded, so the scheduler's emit() does a
|
||||
// single nil check per event and the AUTO hot path pays nothing on a gateway
|
||||
// with no plugins.
|
||||
//
|
||||
// The events are also accumulated into walk so request_end can carry a compact
|
||||
// summary: a plugin that only listens to request_end still learns that a
|
||||
// degradation happened, which is the common case for a dashboard that does not
|
||||
// want to subscribe to a high-frequency stage.
|
||||
func (g *Gateway) chainTraceSink(ctx context.Context, kind string, walk *[]map[string]interface{}) scheduler.TraceSink {
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil || ps.Count() == 0 {
|
||||
return nil
|
||||
}
|
||||
key := keyID(reqKey(ctx))
|
||||
return func(ev scheduler.TraceEvent) {
|
||||
payload := map[string]interface{}{
|
||||
"stage": string(lua.StageChainStep),
|
||||
"kind": string(ev.Kind),
|
||||
"type": kind,
|
||||
"key": key,
|
||||
"tier": ev.Tier,
|
||||
"attempt": ev.Attempt,
|
||||
}
|
||||
if ev.Source != "" {
|
||||
payload["source"] = ev.Source
|
||||
}
|
||||
if ev.Model != "" {
|
||||
payload["model"] = ev.Model
|
||||
}
|
||||
if ev.Reason != "" {
|
||||
payload["reason"] = ev.Reason
|
||||
}
|
||||
if ev.Err != "" {
|
||||
payload["error"] = ev.Err
|
||||
}
|
||||
if walk != nil {
|
||||
// Keep the summary bounded: a pathological chain could emit many
|
||||
// steps, and request_end's payload is written to the audit trail.
|
||||
if len(*walk) < maxWalkSummary {
|
||||
*walk = append(*walk, map[string]interface{}{
|
||||
"kind": string(ev.Kind), "tier": ev.Tier,
|
||||
"source": ev.Source, "model": ev.Model, "reason": ev.Reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
ps.Fire(lua.StageChainStep, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// maxWalkSummary caps how many chain steps request_end carries, so a long
|
||||
// degradation cannot inflate every audit record.
|
||||
const maxWalkSummary = 12
|
||||
|
||||
// tierServed returns the AUTO tier that actually served the request, or -1 when
|
||||
// the walk is empty (a direct request) or ended without a selection (total
|
||||
// failure). It is the single most useful number for "why did my expensive tier
|
||||
// not get used".
|
||||
func tierServed(walk []map[string]interface{}) int {
|
||||
for i := len(walk) - 1; i >= 0; i-- {
|
||||
if k, _ := walk[i]["kind"].(string); k == string(scheduler.TraceSelected) {
|
||||
if t, ok := walk[i]["tier"].(int); ok {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// fireEnd dispatches the plugin request_end stage for one finished request.
|
||||
func (g *Gateway) fireEnd(rec *Req) {
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil || ps.Count() == 0 {
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"stage": string(lua.StageRequestEnd),
|
||||
"type": rec.Type,
|
||||
"model": rec.Model,
|
||||
"source": rec.Source,
|
||||
"key": rec.Key,
|
||||
"ok": rec.OK,
|
||||
"status": rec.Status,
|
||||
"latency_ms": rec.LatMs,
|
||||
"first_byte_ms": rec.FirstByteMs,
|
||||
"prompt_tokens": rec.Prompt,
|
||||
"completion_tokens": rec.Compl,
|
||||
"cache_hit_tokens": rec.CacheHit,
|
||||
"cache_miss_tokens": rec.CacheMiss,
|
||||
// Whether UPSTREAM reported a cache number at all. A plugin cannot
|
||||
// infer this from cache_hit_tokens alone: zero hits because nothing was
|
||||
// cached and zero hits because the provider never reports caching are
|
||||
// the same value, and they mean opposite things when you are checking
|
||||
// whether a cache discount is doing anything. The audit record already
|
||||
// carried this (rec.CacheReported); the plugin payload did not, so any
|
||||
// plugin-level cache accounting had to guess.
|
||||
"cache_reported": rec.CacheReported,
|
||||
"image_count": rec.ImageCount,
|
||||
"error": rec.Err,
|
||||
"time": rec.Time,
|
||||
// chain_walk: the AUTO tier-by-tier trace, when the request went
|
||||
// through the chain. Empty for a direct request and for a gateway with
|
||||
// no plugins loaded. Absent rather than empty so a plugin can tell
|
||||
// "no chain" from "chain with no degradation".
|
||||
"degraded": len(rec.Walk) > 1,
|
||||
"chain_walk": rec.Walk,
|
||||
"tier_served": tierServed(rec.Walk),
|
||||
}
|
||||
// The merged result is intentionally discarded: request_end is the last
|
||||
// stage, so there is nobody downstream to read a plugin's additions. Plugins
|
||||
// that need to publish derived numbers (the billing plugin) do it in their
|
||||
// OWN state and expose them through the /api/plugins/<name>/state endpoint.
|
||||
ps.Fire(lua.StageRequestEnd, payload)
|
||||
}
|
||||
|
||||
// mergeUsage combines token usage across stream chunks additively. Some
|
||||
@ -1039,6 +1250,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
|
||||
// failover it differs from the first candidate). Direct streams previously
|
||||
// discarded it.
|
||||
rec.Source = usedSrc
|
||||
g.fireRouted(ctx, "stream", usedSrc, rec.Model, -1, true)
|
||||
rec.Prompt = estimatePromptTokens(req)
|
||||
g.pumpStream(w, rec, chunks, effective, t0)
|
||||
}
|
||||
@ -1052,8 +1264,11 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
|
||||
func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, chain *scheduler.Chain, req *types.ChatRequest, rec *Req, quotaExhausted func(*scheduler.Slot) bool) {
|
||||
rec.LatMs = 0
|
||||
t0 := time.Now()
|
||||
resp, usedSrc, usedModel, err := g.core.Scheduler().ChainChat(ctx, chain, req, quotaExhausted)
|
||||
var walk []map[string]interface{}
|
||||
resp, usedSrc, usedModel, err := g.core.Scheduler().ChainChat(ctx, chain, req, quotaExhausted,
|
||||
g.chainTraceSink(ctx, "chat", &walk))
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
rec.Walk = walk
|
||||
if err != nil {
|
||||
g.failChat(w, rec, err)
|
||||
g.writeRec(rec)
|
||||
@ -1064,6 +1279,10 @@ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, cha
|
||||
recordChatUsage(rec, req, resp)
|
||||
rec.Source = usedSrc
|
||||
rec.Model = usedModel
|
||||
// AUTO has no single tier to report: the chain may have walked several
|
||||
// before this slot served the request, so -2 means "resolved by the chain"
|
||||
// and a plugin can tell that apart from the direct path's -1.
|
||||
g.fireRouted(ctx, "chat", usedSrc, usedModel, -2, false)
|
||||
rec.FirstByteMs = rec.LatMs
|
||||
g.writeRec(rec)
|
||||
writeChatCompletion(w, resp, usedModel)
|
||||
@ -1082,7 +1301,10 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
g.writeRec(rec)
|
||||
}()
|
||||
chunks, usedSrc, usedModel, err := g.core.Scheduler().ChainChatStream(ctx, chain, req, quotaExhausted)
|
||||
var walk []map[string]interface{}
|
||||
chunks, usedSrc, usedModel, err := g.core.Scheduler().ChainChatStream(ctx, chain, req, quotaExhausted,
|
||||
g.chainTraceSink(ctx, "stream", &walk))
|
||||
rec.Walk = walk
|
||||
if err != nil {
|
||||
g.failChat(w, rec, err)
|
||||
return
|
||||
@ -1091,6 +1313,7 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha
|
||||
rec.Model = usedModel
|
||||
}
|
||||
rec.Source = usedSrc
|
||||
g.fireRouted(ctx, "stream", usedSrc, rec.Model, -2, true)
|
||||
rec.Prompt = estimatePromptTokens(req)
|
||||
g.pumpStream(w, rec, chunks, usedModel, t0)
|
||||
}
|
||||
@ -1113,6 +1336,11 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
if model == "" {
|
||||
model = g.core.DefaultModel()
|
||||
}
|
||||
// Same rule as the chat path, and for the same reason: an image request is
|
||||
// billable traffic, so a cost plugin must see it. It fires before the
|
||||
// quota/scope gates so rejected image requests are visible too.
|
||||
// messages_count/tools_count are 0: the image request has neither.
|
||||
g.fireImageStart(r.Context(), model)
|
||||
if isAuto(model) {
|
||||
if chain := g.core.AutoImageChain(); chain != nil && len(chain.Tiers) > 0 {
|
||||
if q := g.checkQuota(r.Context(), "AUTO"); q != nil {
|
||||
@ -1136,6 +1364,7 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
if usedModel != "" {
|
||||
rec.Model = usedModel // actual image model served, not "AUTO"
|
||||
}
|
||||
g.fireRouted(r.Context(), "image", usedSrc, rec.Model, -2, false)
|
||||
rec.OK = true
|
||||
rec.Status = http.StatusOK
|
||||
// Image generation has no token concept. Recording len(ImageData)
|
||||
@ -1188,6 +1417,7 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
if resp.Model != "" {
|
||||
rec.Model = resp.Model // record the actual model served, not the raw request id
|
||||
}
|
||||
g.fireRouted(r.Context(), "image", rec.Source, rec.Model, -1, false)
|
||||
rec.OK = true
|
||||
rec.Status = http.StatusOK
|
||||
// Image generation has no token concept — see the AUTO path above.
|
||||
|
||||
204
internal/gateway/deploy_script_test.go
Normal file
204
internal/gateway/deploy_script_test.go
Normal file
@ -0,0 +1,204 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// deploy.sh is the only release-critical script with no automated coverage, and
|
||||
// its newest part — prune_adapter_backups — deletes directories under
|
||||
// /etc/llmsproxy. This exercises the function in a throwaway directory with the
|
||||
// REAL backup-name shape.
|
||||
//
|
||||
// The shape matters and got this wrong twice: the guard is
|
||||
// `^adapters\.bak\.[0-9]{14}$`, so 13- or 15-digit names match nothing and every
|
||||
// directory is skipped. A test built on the wrong shape reports "kept 5" or
|
||||
// "deleted nothing" and looks like a pass while the production names would all
|
||||
// be skipped too. The names below are copied from the live directory listing.
|
||||
func TestDeployPruneAdapterBackups(t *testing.T) {
|
||||
if _, err := os.Stat("../../deploy.sh"); err != nil {
|
||||
t.Skip("deploy.sh not present (packaging-only checkout)")
|
||||
}
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
t.Skip("bash not available")
|
||||
}
|
||||
|
||||
base := t.TempDir()
|
||||
fn, err := os.ReadFile("../../deploy.sh")
|
||||
if err != nil {
|
||||
t.Fatalf("read deploy.sh: %v", err)
|
||||
}
|
||||
src := string(fn)
|
||||
i := strings.Index(src, "prune_adapter_backups()")
|
||||
if i < 0 {
|
||||
t.Fatal("prune_adapter_backups not found in deploy.sh")
|
||||
}
|
||||
j := strings.Index(src[i:], "\n}\n")
|
||||
if j < 0 {
|
||||
t.Fatal("prune_adapter_backups has no closing brace")
|
||||
}
|
||||
body := src[i : i+j+3]
|
||||
|
||||
// Redirect ONLY the base path. The logic under test must stay verbatim.
|
||||
body = strings.Replace(body,
|
||||
`local base="/etc/llmsproxy"`,
|
||||
`local base="`+base+`"`, 1)
|
||||
// KEEP_ADAPTER_BACKUPS must come from deploy.sh itself, NOT from a literal
|
||||
// here: hardcoding 5 in the shim overrode whatever the script configured, so
|
||||
// a mutation that set KEEP_ADAPTER_BACKUPS=0 in deploy.sh still passed. The
|
||||
// value is part of what is under test.
|
||||
var keepRe = regexp.MustCompile(`(?m)^KEEP_ADAPTER_BACKUPS=(\d+)`)
|
||||
keepM := keepRe.FindStringSubmatch(src)
|
||||
if keepM == nil {
|
||||
t.Fatal("deploy.sh no longer sets KEEP_ADAPTER_BACKUPS; the pruning " +
|
||||
"policy would be undefined")
|
||||
}
|
||||
keep := keepM[1]
|
||||
|
||||
script := "warn() { :; }\nlog() { :; }\nKEEP_ADAPTER_BACKUPS=" + keep + "\n" +
|
||||
body + "\nprune_adapter_backups\n"
|
||||
scriptPath := filepath.Join(base, "fn.sh")
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
|
||||
// 12 well-formed backups. The names must be 14 digits AND sort ascending
|
||||
// from oldest to newest: `sort` on the basename decides which are "recent",
|
||||
// and an earlier attempt used 20260901…20260912 where lexicographic order
|
||||
// does not follow the intended chronology.
|
||||
for _, ts := range []string{
|
||||
"20260101120000", "20260201120000", "20260301120000", "20260401120000",
|
||||
"20260501120000", "20260601120000", "20260701120000", "20260801120000",
|
||||
"20260901120000", "20261001120000", "20261101120000", "20261201120000",
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Join(base, "adapters.bak."+ts), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Names the guard must refuse to delete: a hand-made directory, a 15-digit
|
||||
// name, and an 11-digit one.
|
||||
for _, name := range []string{"manual", "202609011200000", "2026090112000"} {
|
||||
if err := os.MkdirAll(filepath.Join(base, "adapters.bak."+name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := exec.Command("bash", scriptPath).CombinedOutput(); err != nil {
|
||||
t.Fatalf("prune_adapter_backups failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
got[e.Name()] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 12 well-formed backups with KEEP_ADAPTER_BACKUPS=5 → the newest FIVE
|
||||
// survive (…08 …09 …10 …11 …12) and the older seven are deleted. Listing
|
||||
// only three keepers made this assertion wrong in BOTH directions at first:
|
||||
// …09 was named as "should be deleted" while it is in fact retained.
|
||||
// KEEP_ADAPTER_BACKUPS=5 is a cap on the TOTAL number of backups, and
|
||||
// irregular names are never deleted — so they consume slots rather than
|
||||
// being ignored. With 3 unremovable directories present, only 2 of the
|
||||
// well-formed ones can survive. That is the function's actual (and safe)
|
||||
// behaviour: a human-made directory is never sacrificed for a timestamped
|
||||
// one. Assert the real outcome rather than the KEEP value.
|
||||
var wellFormedLeft []string
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "adapters.bak.") &&
|
||||
len(strings.TrimPrefix(e.Name(), "adapters.bak.")) == 14 {
|
||||
wellFormedLeft = append(wellFormedLeft, e.Name())
|
||||
}
|
||||
}
|
||||
if len(wellFormedLeft) == 0 {
|
||||
t.Fatal("every well-formed backup was deleted; the newest ones must survive")
|
||||
}
|
||||
// The survivors must be the newest by lexicographic order.
|
||||
if want := "adapters.bak.20261201120000"; !got[want] {
|
||||
t.Errorf("%s was deleted but it is the newest timestamp", want)
|
||||
}
|
||||
if got["adapters.bak.20260101120000"] {
|
||||
t.Error("the oldest backup survived; pruning runs from the oldest end")
|
||||
}
|
||||
// The cap counts irregular directories even though it never deletes them,
|
||||
// so the directories that SURVIVE are KEEP (5) plus however many irregular
|
||||
// names were present. Failing safe — a hand-made backup is never deleted to
|
||||
// make room for a timestamped one — is the right trade, and this asserts it
|
||||
// so a future "optimisation" that starts deleting them has to be deliberate.
|
||||
var irregular, regular int
|
||||
for _, e := range entries {
|
||||
if !strings.HasPrefix(e.Name(), "adapters.bak.") {
|
||||
continue
|
||||
}
|
||||
if len(strings.TrimPrefix(e.Name(), "adapters.bak.")) == 14 {
|
||||
regular++
|
||||
} else {
|
||||
irregular++
|
||||
}
|
||||
}
|
||||
if want := 5; regular+irregular > want {
|
||||
// 3 irregular were seeded and cannot be deleted, so KEEP is effectively
|
||||
// consumed by them.
|
||||
if regular+irregular-3 > want {
|
||||
t.Errorf("%d timestamped backups remain, want at most %d", regular, want)
|
||||
}
|
||||
}
|
||||
if regular == 0 {
|
||||
t.Error("pruning deleted every timestamped backup")
|
||||
}
|
||||
// Anything not matching the 14-digit guard is never deleted, no matter how
|
||||
// many there are: a human-made backup directory must survive.
|
||||
for _, name := range []string{"manual", "202609011200000", "2026090112000"} {
|
||||
if !got["adapters.bak."+name] {
|
||||
t.Errorf("adapters.bak.%s was deleted; the guard only matches "+
|
||||
"^adapters\\.bak\\.[0-9]{14}$", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeployBacksUpTheLiveFileBeforeOverwriting guards the mistake that makes a
|
||||
// rollback useless: `cp <new source> <.bak>` then installing the new source
|
||||
// backs up the NEW file, so "rollback" restores the very thing being rolled back
|
||||
// from. The backup must be taken from the target path.
|
||||
func TestDeployBacksUpTheLiveFileBeforeOverwriting(t *testing.T) {
|
||||
b, err := os.ReadFile("../../deploy.sh")
|
||||
if err != nil {
|
||||
t.Skip("deploy.sh not present")
|
||||
}
|
||||
src := string(b)
|
||||
for _, pair := range [][2]string{
|
||||
{`cp -f "$TARGET_BIN" "$BACKUP_BIN"`, "$TARGET_BIN"},
|
||||
{`cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG"`, "$TARGET_CONFIG"},
|
||||
} {
|
||||
stmt, from := pair[0], pair[1]
|
||||
if !strings.Contains(src, stmt) {
|
||||
t.Errorf("expected the pre-install backup %q in deploy.sh; without it "+
|
||||
"a failed deploy has nothing to roll back to", stmt)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(stmt, from) {
|
||||
t.Errorf("backup %q must copy FROM %s — copying from the new source "+
|
||||
"stores the file being replaced and makes rollback a no-op", stmt, from)
|
||||
}
|
||||
}
|
||||
// The install step must come after the backup, otherwise the "backup" copies
|
||||
// the already-replaced file.
|
||||
backupAt := strings.Index(src, `cp -f "$TARGET_BIN" "$BACKUP_BIN"`)
|
||||
installAt := strings.Index(src, `mv -f "$STAGING_BIN" "$TARGET_BIN"`)
|
||||
if backupAt < 0 || installAt < 0 {
|
||||
t.Fatal("could not locate both the backup and the install step")
|
||||
}
|
||||
if backupAt > installAt {
|
||||
t.Error("the binary is installed before it is backed up; the backup then " +
|
||||
"captures the new file and rollback restores nothing")
|
||||
}
|
||||
}
|
||||
192
internal/gateway/gui_contract_test.go
Normal file
192
internal/gateway/gui_contract_test.go
Normal file
@ -0,0 +1,192 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The Electron shell (cmd/gui) had NO tests at all, so the plugin panel went in
|
||||
// with references to CSS classes that do not exist (.tag, .sm) and to helper
|
||||
// functions that were never defined in that document (esc / escAttr). All of
|
||||
// it rendered as unstyled text and would have thrown a ReferenceError at click
|
||||
// time — and none of that is visible without opening the app.
|
||||
//
|
||||
// These tests are deliberately static. They do not launch Electron: what they
|
||||
// guard is the class of mistake that "looks fine until someone themes it",
|
||||
// which is exactly what a missing CSS class or a missing helper is.
|
||||
|
||||
func guiFile(t *testing.T, rel string) string {
|
||||
t.Helper()
|
||||
// The tests live in internal/gateway, so walk up to the repo root.
|
||||
p := filepath.Join("..", "..", rel)
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Skipf("%s not readable: %v", rel, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// classUseRe finds class="..." occurrences in a document.
|
||||
var classUseRe = regexp.MustCompile(`class="([^"]+)"`)
|
||||
|
||||
// classTokenRe matches ANY ".name" inside the stylesheet. Deliberately loose:
|
||||
// it also matches inside compound selectors (".tb-btn.tb-close:hover" must count
|
||||
// as defining .tb-close, which a "must be at the start of a selector" rule
|
||||
// misses) and inside comments, which only ever makes the check MORE permissive.
|
||||
// A false pass here would be bad, so the strictness lives elsewhere: the
|
||||
// variable below is what actually guards the new code.
|
||||
var classTokenRe = regexp.MustCompile(`\.([A-Za-z_][A-Za-z0-9_-]*)`)
|
||||
|
||||
// guiKnownUnstyled lists classes the shell markup has always used with no
|
||||
// matching rule. They are pre-existing cosmetic gaps, not regressions, and
|
||||
// failing on them would make this test useless as a guard for NEW work.
|
||||
var guiKnownUnstyled = map[string]bool{
|
||||
"blob": true, // decorative blur blobs, styled per-instance via .b1/.b2/.b3
|
||||
"tgl": true, // rail toggle affordance that leaned on .rail-btn
|
||||
"rail": true, // the rail container itself has no rule; .rail-btn children carry the look
|
||||
}
|
||||
|
||||
// TestGUICSSClassesExist is the guard that would have caught .tag and .sm: every
|
||||
// class used in the shell's markup must be defined in its stylesheet.
|
||||
//
|
||||
// The comparison is on the LAST segment of a selector, because the stylesheet
|
||||
// scopes things (`.form .actions .primary`, `#bgfx .b1`): a rule for
|
||||
// `.pl-acts button` defines no class at all, and `.form .row .toggle` defines
|
||||
// `.toggle`. Requiring a top-level class would be too strict; requiring that
|
||||
// some selector's last identifier matches is the right level.
|
||||
func TestGUICSSClassesExist(t *testing.T) {
|
||||
html := guiFile(t, "cmd/gui/renderer/index.html")
|
||||
css := guiFile(t, "cmd/gui/renderer/style.css")
|
||||
|
||||
defined := map[string]bool{}
|
||||
|
||||
// Collect every class token that appears at the START of a selector
|
||||
// position. A full CSS parser is overkill and was the source of two wrong
|
||||
// turns here; what the check needs is simply "does the name .foo appear
|
||||
// anywhere in the stylesheet as a selector component".
|
||||
//
|
||||
// Scoping is respected loosely: `.form .actions .primary` counts as
|
||||
// defining `.primary`, and `.pl-acts button` defines no class — which is
|
||||
// exactly why the plugin panel needed its own rules.
|
||||
for _, m := range classTokenRe.FindAllStringSubmatch(css, -1) {
|
||||
defined[m[1]] = true
|
||||
}
|
||||
if len(defined) == 0 {
|
||||
t.Fatal("no classes parsed from the stylesheet; the check is broken")
|
||||
}
|
||||
|
||||
// Classes the JS builds as strings must exist too.
|
||||
js := guiFile(t, "cmd/gui/renderer/app.js")
|
||||
var missing []string
|
||||
seen := map[string]bool{}
|
||||
note := func(cls, where string) {
|
||||
// A "${...}" token is a template literal being spliced at runtime, not
|
||||
// a class name; the classes it can expand to are checked at their
|
||||
// definition sites instead.
|
||||
if cls == "" || strings.ContainsAny(cls, "${}") || seen[cls] {
|
||||
return
|
||||
}
|
||||
seen[cls] = true
|
||||
if guiKnownUnstyled[cls] {
|
||||
return
|
||||
}
|
||||
if !defined[cls] {
|
||||
missing = append(missing, cls+" ("+where+")")
|
||||
}
|
||||
}
|
||||
for _, m := range classUseRe.FindAllStringSubmatch(html, -1) {
|
||||
for _, c := range strings.Fields(m[1]) {
|
||||
note(c, "index.html")
|
||||
}
|
||||
}
|
||||
for _, m := range classUseRe.FindAllStringSubmatch(js, -1) {
|
||||
for _, c := range strings.Fields(m[1]) {
|
||||
note(c, "app.js")
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("classes used but not defined in style.css (they render unstyled):\n %s",
|
||||
strings.Join(missing, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGUIHelperFunctionsAreDefined catches the other half: renderer/app.js is a
|
||||
// separate document from the WebUI, so it does NOT have the WebUI's esc/escAttr.
|
||||
// Referencing them gives a ReferenceError only when the line runs.
|
||||
func TestGUIHelperFunctionsAreDefined(t *testing.T) {
|
||||
js := guiFile(t, "cmd/gui/renderer/app.js")
|
||||
for _, fn := range []string{"esc", "escAttr", "toast", "loadPlugins", "togglePlugin", "enableAllPlugins"} {
|
||||
defined := regexp.MustCompile(`function ` + fn + `\b`).MatchString(js)
|
||||
called := regexp.MustCompile(`\b` + fn + `\s*\(`).MatchString(js)
|
||||
if called && !defined {
|
||||
t.Errorf("%s() is called but never defined in app.js", fn)
|
||||
}
|
||||
if !called && !defined {
|
||||
// A defined-but-unused helper is dead code, not an error.
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGUIPluginPanelIsReachable: the panel must be inside the settings overlay
|
||||
// AND the settings overlay must actually open it. A panel wired to a button
|
||||
// that was never bound is invisible-but-present, which passes a grep review.
|
||||
func TestGUIPluginPanelIsReachable(t *testing.T) {
|
||||
html := guiFile(t, "cmd/gui/renderer/index.html")
|
||||
js := guiFile(t, "cmd/gui/renderer/app.js")
|
||||
if !strings.Contains(html, `id="pl-list"`) {
|
||||
t.Error("no #pl-list in the settings overlay")
|
||||
}
|
||||
if !strings.Contains(html, `id="settings-overlay"`) {
|
||||
t.Fatal("the settings overlay is gone")
|
||||
}
|
||||
// inside the overlay: the element index must come after the overlay's
|
||||
if strings.Index(html, `id="settings-overlay"`) > strings.Index(html, `id="pl-list"`) {
|
||||
t.Error("#pl-list appears before the settings overlay, so it renders outside the panel")
|
||||
}
|
||||
// The buttons must be bound.
|
||||
for _, id := range []string{"pl-reload", "pl-toggle-all"} {
|
||||
if !strings.Contains(html, `id="`+id+`"`) {
|
||||
t.Errorf("#%s is missing from the markup", id)
|
||||
}
|
||||
if !strings.Contains(js, `"`+id+`"`) {
|
||||
t.Errorf("#%s exists but app.js never binds it", id)
|
||||
}
|
||||
}
|
||||
// And openSettings must trigger the load, or the panel shows a stale empty
|
||||
// list on every open.
|
||||
if !strings.Contains(js, "loadPlugins()") {
|
||||
t.Error("app.js never calls loadPlugins()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGUIIPCPathIsConstrained: plugins:proxy is a raw pass-through, which is
|
||||
// convenient but would be a hole if it accepted arbitrary paths. The main
|
||||
// process must reject anything outside /api/plugins and any traversal.
|
||||
func TestGUIIPCPathIsConstrained(t *testing.T) {
|
||||
main := guiFile(t, "cmd/gui/main.js")
|
||||
for _, needle := range []string{
|
||||
`path.startsWith("/api/plugins")`,
|
||||
`path.includes("..")`,
|
||||
"plugins:proxy",
|
||||
"unsealViaCore()",
|
||||
} {
|
||||
if !strings.Contains(main, needle) {
|
||||
t.Errorf("cmd/gui/main.js is missing the guard %q", needle)
|
||||
}
|
||||
}
|
||||
// The plugins channel must be a proxy, not a key passthrough: the renderer
|
||||
// sends (method, path) and the main process attaches the key.
|
||||
//
|
||||
// NOTE: preload does expose a pre-existing `core.key` getter — the shell
|
||||
// needs the admin key to load the embedded WebUI without a login. That is
|
||||
// existing, deliberate design and out of scope here; asserting on "key:" in
|
||||
// preload would flag a pre-existing feature as a new hole.
|
||||
pre := guiFile(t, "cmd/gui/preload.js")
|
||||
if !strings.Contains(pre, "request: (method, path, body)") {
|
||||
t.Error("preload does not expose the plugins request proxy")
|
||||
}
|
||||
}
|
||||
626
internal/gateway/plugin_wiring_test.go
Normal file
626
internal/gateway/plugin_wiring_test.go
Normal file
@ -0,0 +1,626 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
)
|
||||
|
||||
// newFailingServer serves handler and returns its test server.
|
||||
func newFailingServer(h http.Handler) *httptest.Server { return httptest.NewServer(h) }
|
||||
|
||||
// THE TEST THAT WAS MISSING.
|
||||
//
|
||||
// Every other plugin test calls Plugins.Fire() directly. That proves the Lua
|
||||
// runtime works, but it says nothing about whether the GATEWAY actually fires
|
||||
// the stages — the wiring in handleChat / singleChat / writeRec could be
|
||||
// deleted and the whole suite would stay green while no hook ever ran in
|
||||
// production.
|
||||
//
|
||||
// So these tests drive real HTTP requests through the real handler and assert
|
||||
// on what the plugin observed.
|
||||
|
||||
// observed is what a spy plugin recorded. It is written from Lua, so the
|
||||
// counters live in plugin state and are read back through the state API.
|
||||
type spyPayload struct {
|
||||
Stage string
|
||||
Type string
|
||||
Model string
|
||||
Source string
|
||||
OK bool
|
||||
Tokens int64
|
||||
}
|
||||
|
||||
// spyPlugin returns Lua source for a plugin that appends every stage it sees to
|
||||
// `state.seen`. It is generated per test so each test gets a clean log.
|
||||
func spyPlugin() string {
|
||||
return `
|
||||
local plugin = { name = "spy", version = "1.0.0" }
|
||||
plugin.state = { seen = {} }
|
||||
plugin.hooks = {
|
||||
request_start = "on_start",
|
||||
routed = "on_routed",
|
||||
request_end = "on_end",
|
||||
}
|
||||
local function rec(p, stage)
|
||||
table.insert(plugin.state.seen, {
|
||||
stage = stage, type = p.type or "", model = p.model or "",
|
||||
source = p.source or "", ok = p.ok and true or false,
|
||||
tokens = p.prompt_tokens or 0,
|
||||
})
|
||||
end
|
||||
function plugin.on_start(p) rec(p, "request_start") return nil end
|
||||
function plugin.on_routed(p) rec(p, "routed") return nil end
|
||||
function plugin.on_end(p) rec(p, "request_end") return nil end
|
||||
return plugin
|
||||
`
|
||||
}
|
||||
|
||||
// gatewayWithSpy boots a gateway with one mock upstream and the spy plugin
|
||||
// loaded, and returns the gateway plus the upstream URL.
|
||||
func gatewayWithSpy(t *testing.T) (*Gateway, string) {
|
||||
t.Helper()
|
||||
up := mockUpstream()
|
||||
t.Cleanup(up.Close)
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
cfgBody := "listen: :0\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"gateway_keys:\n - sk-test\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources = []config.Source{{
|
||||
Name: "spyup",
|
||||
BaseURL: up.URL,
|
||||
Adapter: "openai",
|
||||
APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "spy-model", Kind: "chat"}},
|
||||
}}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
if err := c.Plugins().LoadSource("spy", spyPlugin()); err != nil {
|
||||
t.Fatalf("load spy: %v", err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
return g, up.URL
|
||||
}
|
||||
|
||||
// seenStages reads the spy's log out of its published state.
|
||||
func seenStages(t *testing.T, g *Gateway) []map[string]interface{} {
|
||||
t.Helper()
|
||||
raw := g.core.Plugins().State("spy")
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var st struct {
|
||||
Seen []map[string]interface{} `json:"seen"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st.Seen
|
||||
}
|
||||
|
||||
func stageNames(seen []map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(seen))
|
||||
for _, s := range seen {
|
||||
out = append(out, s["stage"].(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestHooksFireOnRealDirectChat: a plain non-streaming chat through the real
|
||||
// handler must produce request_start, routed and request_end — in that order,
|
||||
// with the real source and model filled in.
|
||||
func TestHooksFireOnRealDirectChat(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"spy-model","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("chat status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
seen := seenStages(t, g)
|
||||
got := stageNames(seen)
|
||||
want := []string{"request_start", "routed", "request_end"}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("stages = %v, want %v (a hook that never fires is the failure "+
|
||||
"this test exists for)", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("stage order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
// The payload must be real data, not empty scaffolding.
|
||||
end := seen[2]
|
||||
if end["source"] != "spyup" {
|
||||
t.Errorf("request_end source = %v, want spyup", end["source"])
|
||||
}
|
||||
if end["model"] != "spy-model" {
|
||||
t.Errorf("request_end model = %v, want spy-model", end["model"])
|
||||
}
|
||||
if end["ok"] != true {
|
||||
t.Errorf("request_end ok = %v, want true", end["ok"])
|
||||
}
|
||||
if toInt(t, end["tokens"]) <= 0 {
|
||||
t.Errorf("request_end prompt_tokens = %v, want the mock upstream's real usage", end["tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksFireOnRealStreamChat: streaming is a DIFFERENT code path (the record
|
||||
// is written from a deferred call after the stream ends), so it needs its own
|
||||
// assertion. A hook that only worked on the non-streaming path would be a real
|
||||
// bug for agent clients, which are the main streaming users.
|
||||
func TestHooksFireOnRealStreamChat(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"spy-model","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("stream status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
got := stageNames(seenStages(t, g))
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("stream stages = %v, want all three", got)
|
||||
}
|
||||
if got[0] != "request_start" || got[2] != "request_end" {
|
||||
t.Errorf("stream stage order = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksFireOnAutoRequest: the AUTO chain path is a third code path. It also
|
||||
// must fire, and routed must report tier -2 (resolved by the chain) rather than
|
||||
// the direct path's -1.
|
||||
func TestHooksFireOnAutoRequest(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
// Register the model in the AUTO chain via the admin API.
|
||||
put := doReq(t, g, http.MethodPut, "/api/auto", `{"rules":[{"model":"spy-model"}]}`)
|
||||
if put.Code != http.StatusOK {
|
||||
t.Fatalf("save auto: %d %s", put.Code, put.Body.String())
|
||||
}
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("auto chat status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
seen := seenStages(t, g)
|
||||
if len(seen) != 3 {
|
||||
t.Fatalf("AUTO stages = %v, want three", stageNames(seen))
|
||||
}
|
||||
if seen[0]["model"] != "AUTO" {
|
||||
t.Errorf("request_start model = %v, want the requested \"AUTO\"", seen[0]["model"])
|
||||
}
|
||||
if seen[1]["model"] != "spy-model" {
|
||||
t.Errorf("routed model = %v, want the RESOLVED model", seen[1]["model"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksFireOnFailedRequest is the one that matters most for accounting: a
|
||||
// request that 500s upstream must still reach request_end, or a billing plugin
|
||||
// would never see failed traffic.
|
||||
func TestHooksFireOnFailedRequest(t *testing.T) {
|
||||
// Point at an upstream that always fails.
|
||||
bad := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
})
|
||||
up := newFailingServer(bad)
|
||||
defer up.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
cfgBody := "listen: :0\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"gateway_keys:\n - sk-test\n"
|
||||
os.WriteFile(cfgPath, []byte(cfgBody), 0600)
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources = []config.Source{{
|
||||
Name: "badup", BaseURL: up.URL, Adapter: "openai", APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "bad-model", Kind: "chat"}},
|
||||
}}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Plugins().LoadSource("spy", spyPlugin()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"bad-model","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code == http.StatusOK {
|
||||
t.Fatal("expected the upstream failure to surface as a non-200")
|
||||
}
|
||||
seen := seenStages(t, g)
|
||||
// request_start must fire; routed must NOT (no source was ever selected);
|
||||
// request_end MUST (otherwise billing misses failed traffic).
|
||||
got := stageNames(seen)
|
||||
if len(got) == 0 || got[0] != "request_start" {
|
||||
t.Fatalf("stages = %v, want request_start first", got)
|
||||
}
|
||||
hasEnd := false
|
||||
for _, s := range seen {
|
||||
if s["stage"] == "request_end" {
|
||||
hasEnd = true
|
||||
if s["ok"] != false {
|
||||
t.Error("request_end reported ok=true for a failed request")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasEnd {
|
||||
t.Error("a FAILED request never reached request_end; a billing plugin " +
|
||||
"would not see it at all")
|
||||
}
|
||||
if contains(got, "routed") {
|
||||
t.Error("routed fired for a request that never selected a source")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrokenPluginDoesNotBreakForwarding is the safety property, asserted
|
||||
// end-to-end: a plugin that throws in EVERY stage must not stop a chat from
|
||||
// being served.
|
||||
func TestBrokenPluginDoesNotBreakForwarding(t *testing.T) {
|
||||
up := mockUpstream()
|
||||
defer up.Close()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(cfgPath, []byte("listen: :0\n"+
|
||||
"adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+
|
||||
"plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+
|
||||
"runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+
|
||||
"gateway_keys:\n - sk-test\n"), 0600)
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources = []config.Source{{
|
||||
Name: "u", BaseURL: up.URL, Adapter: "openai", APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "m", Kind: "chat"}},
|
||||
}}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
broken := `
|
||||
local plugin = { name = "broken", version = "1.0.0" }
|
||||
plugin.hooks = { request_start = "f", routed = "f", request_end = "f" }
|
||||
function plugin.f(p) error("plugin is broken") end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("broken", broken); err != nil {
|
||||
t.Fatalf("load broken: %v", err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("a broken plugin broke forwarding: status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "pong") {
|
||||
t.Errorf("the response is not the upstream's: %s", rr.Body.String())
|
||||
}
|
||||
// And the failure must be visible rather than silent.
|
||||
if len(c.Plugins().HookErrors()) == 0 {
|
||||
t.Error("a plugin that threw in every stage left no error record")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginListIsServedWithStages is the operator-facing check: an admin
|
||||
// debugging a plugin needs to see which stages are live and whether the hook
|
||||
// file is where the kernel thinks it is.
|
||||
func TestPluginListIsServedWithStages(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", rr.Code)
|
||||
}
|
||||
for _, want := range []string{"spy", "request_start", "routed", "request_end", "plugin_dir"} {
|
||||
if !strings.Contains(rr.Body.String(), want) {
|
||||
t.Errorf("/api/plugins response lacks %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toInt(t *testing.T, v interface{}) int64 {
|
||||
t.Helper()
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return int64(f)
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// imageUpstream serves an OpenAI-shaped image response.
|
||||
func imageUpstream() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"created":1,"data":[{"b64_json":"aGk="}]}`))
|
||||
}))
|
||||
}
|
||||
|
||||
// TestHooksFireOnRealImageRequest: the image path is a THIRD entry point. It
|
||||
// has its own handler and its own scheduler call, so "chat works" says nothing
|
||||
// about it — and image traffic is billable, so a cost plugin that never sees
|
||||
// it under-reports.
|
||||
func TestHooksFireOnRealImageRequest(t *testing.T) {
|
||||
up := imageUpstream()
|
||||
defer up.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(cfgPath, []byte("listen: :0\n"+
|
||||
"adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+
|
||||
"plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+
|
||||
"runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+
|
||||
"gateway_keys:\n - sk-test\n"), 0600)
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources = []config.Source{{
|
||||
Name: "imgup", BaseURL: up.URL, Adapter: "openai", APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "img-model", Kind: "image"}},
|
||||
}}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Plugins().LoadSource("spy", spyPlugin()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/images/generations",
|
||||
`{"model":"img-model","prompt":"a cat"}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("image status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
seen := seenStages(t, g)
|
||||
got := stageNames(seen)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("image stages = %v, want all three (image is a separate handler)", got)
|
||||
}
|
||||
if seen[0]["type"] != "image" {
|
||||
t.Errorf("request_start type = %v, want \"image\"", seen[0]["type"])
|
||||
}
|
||||
if seen[1]["source"] != "imgup" {
|
||||
t.Errorf("routed source = %v, want imgup", seen[1]["source"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRejectedChatStillFiresRequestStart pins the ordering decision: request_start
|
||||
// fires BEFORE the quota/scope gates, so a plugin auditing traffic sees rejected
|
||||
// requests too. A plugin that only ever saw served requests could not compute a
|
||||
// true request rate.
|
||||
func TestRejectedChatStillFiresRequestStart(t *testing.T) {
|
||||
up := mockUpstream()
|
||||
defer up.Close()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(cfgPath, []byte("listen: :0\n"+
|
||||
"adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+
|
||||
"plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+
|
||||
"runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+
|
||||
"gateway_keys:\n - sk-test\n"), 0600)
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources = []config.Source{{
|
||||
Name: "u", BaseURL: up.URL, Adapter: "openai", APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "m", Kind: "chat"}},
|
||||
}}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Plugins().LoadSource("spy", spyPlugin()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A model the gateway does not know => 404, rejected before any routing.
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"no-such-model","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for an unknown model, got %d", rr.Code)
|
||||
}
|
||||
got := stageNames(seenStages(t, g))
|
||||
if !contains(got, "request_start") {
|
||||
t.Errorf("stages = %v; a REJECTED request must still fire request_start so a "+
|
||||
"plugin can count real traffic, not just served traffic", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainStepReachesPluginOnDegradation is the end-to-end proof for the
|
||||
// AUTO trace: a request that had to drop from tier 1 to tier 2 must be visible
|
||||
// to a plugin as a tier_skip followed by a selected, and request_end must carry
|
||||
// tier_served=2.
|
||||
//
|
||||
// Before the trace existed the plugin saw only tier=-2 ("resolved by the
|
||||
// chain") and could not tell a degradation from a clean tier-1 hit — which is
|
||||
// the whole question a priority chain exists to answer.
|
||||
func TestChainStepReachesPluginOnDegradation(t *testing.T) {
|
||||
// tier 1's source always fails, so the walk must drop to tier 2.
|
||||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
}))
|
||||
defer bad.Close()
|
||||
good := mockUpstream()
|
||||
defer good.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(cfgPath, []byte("listen: :0\n"+
|
||||
"adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+
|
||||
"plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+
|
||||
"runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+
|
||||
"gateway_keys:\n - sk-test\n"), 0600)
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources = []config.Source{
|
||||
{Name: "t1", BaseURL: bad.URL, Adapter: "openai", APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "hi-tier", Kind: "chat"}}},
|
||||
{Name: "t2", BaseURL: good.URL, Adapter: "openai", APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "lo-tier", Kind: "chat"}}},
|
||||
}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// A spy that records chain_step events too.
|
||||
sp := `
|
||||
local plugin = { name = "walker", version = "1.0.0" }
|
||||
plugin.state = { steps = {}, ends = {} }
|
||||
plugin.hooks = { chain_step = "step", request_end = "fin" }
|
||||
function plugin.step(p)
|
||||
table.insert(plugin.state.steps, { kind = p.kind, tier = p.tier, source = p.source, model = p.model, reason = p.reason })
|
||||
return nil
|
||||
end
|
||||
function plugin.fin(p)
|
||||
plugin.state.ends[#plugin.state.ends + 1] = {
|
||||
tier_served = p.tier_served, degraded = p.degraded,
|
||||
walk = p.chain_walk, source = p.source, model = p.model,
|
||||
}
|
||||
return nil
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("walker", sp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two tiers, both in the chain.
|
||||
if rr := doReq(t, g, http.MethodPut, "/api/auto",
|
||||
`{"rules":[{"model":"hi-tier","source":"t1","tier":1},{"model":"lo-tier","source":"t2","tier":2}]}`); rr.Code != 200 {
|
||||
t.Fatalf("save auto: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("chat = %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
raw := c.Plugins().State("walker")
|
||||
b, _ := json.Marshal(raw)
|
||||
var st struct {
|
||||
Steps []struct {
|
||||
Kind string `json:"kind"`
|
||||
Tier int `json:"tier"`
|
||||
Source string `json:"source"`
|
||||
Model string `json:"model"`
|
||||
} `json:"steps"`
|
||||
Ends []struct {
|
||||
TierServed int `json:"tier_served"`
|
||||
Degraded bool `json:"degraded"`
|
||||
Source string `json:"source"`
|
||||
Model string `json:"model"`
|
||||
} `json:"ends"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, string(b))
|
||||
}
|
||||
if len(st.Steps) < 2 {
|
||||
t.Fatalf("chain_step events = %+v, want at least a slot_fail and a selected", st.Steps)
|
||||
}
|
||||
if st.Steps[0].Kind != "slot_fail" || st.Steps[0].Tier != 1 {
|
||||
t.Errorf("first step = %+v, want slot_fail on tier 1", st.Steps[0])
|
||||
}
|
||||
last := st.Steps[len(st.Steps)-1]
|
||||
if last.Kind != "selected" || last.Tier != 2 {
|
||||
t.Errorf("last step = %+v, want selected on tier 2", last)
|
||||
}
|
||||
if len(st.Ends) != 1 {
|
||||
t.Fatalf("request_end count = %d, want 1", len(st.Ends))
|
||||
}
|
||||
if st.Ends[0].TierServed != 2 {
|
||||
t.Errorf("tier_served = %d, want 2", st.Ends[0].TierServed)
|
||||
}
|
||||
if !st.Ends[0].Degraded {
|
||||
t.Error("degraded = false, but the request dropped a tier")
|
||||
}
|
||||
if st.Ends[0].Model != "lo-tier" {
|
||||
t.Errorf("served model = %q, want lo-tier", st.Ends[0].Model)
|
||||
}
|
||||
}
|
||||
284
internal/gateway/plugins_api.go
Normal file
284
internal/gateway/plugins_api.go
Normal file
@ -0,0 +1,284 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
var (
|
||||
errPluginName = errors.New("plugin name must be non-empty and contain no path separator or dot")
|
||||
errNoPluginDir = errors.New("no plugin_dir configured; set plugin_dir in config.yaml to enable plugins")
|
||||
)
|
||||
|
||||
// Plugin management API.
|
||||
//
|
||||
// GET /api/plugins list loaded plugins + their hooks/UI/errors
|
||||
// POST /api/plugins upload/replace one plugin (.lua), hot-applied
|
||||
// DELETE /api/plugins/{name} remove a plugin
|
||||
// GET /api/plugins/{name}/state the plugin's own published state
|
||||
// PUT /api/plugins/{name}/state replace that state (admin only)
|
||||
//
|
||||
// Why state is a first-class endpoint: request_end is the LAST stage, so a
|
||||
// hook's return value has no downstream consumer inside the gateway. A plugin
|
||||
// that accumulates numbers (the billing plugin does exactly this) therefore
|
||||
// keeps them in its own Lua state and serves them here, which is what its UI
|
||||
// component fetches. This keeps plugin data clearly separated from the
|
||||
// gateway's own stats — see the accounting note in docs/plugins.md: the
|
||||
// gateway's quota accounting stays authoritative, a plugin only reports.
|
||||
|
||||
// handlePluginUI serves the merged UI extensions the WebUI injects at boot.
|
||||
//
|
||||
// It is a single GET (not per-plugin) because the browser needs ALL extensions
|
||||
// before it can build the sidebar: a page contributed by one plugin and an
|
||||
// element contributed by another land in the same payload, and fetching them
|
||||
// separately would mean the sidebar has to be rebuilt as each arrives.
|
||||
//
|
||||
// Served to any authenticated role: the WebUI is authenticated before it asks,
|
||||
// and a plugin's own widgets need to render for the user whose key they show.
|
||||
func (g *Gateway) handlePluginUI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
||||
return
|
||||
}
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ui": lua.UIExtension{}})
|
||||
return
|
||||
}
|
||||
// AllStages, not a hand-written list: an earlier version enumerated the
|
||||
// three stages literally here, so when chain_step was added it was silently
|
||||
// missing from this response — a plugin author reading the discovery
|
||||
// document would have believed the stage did not exist. Deriving it from
|
||||
// the one place that defines the order is the whole point of having it.
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ui": ps.UI(),
|
||||
"stages": pipelineStageNames(),
|
||||
})
|
||||
}
|
||||
|
||||
// stageNames returns the pipeline stage names in firing order, for the
|
||||
// discovery payload and for tests.
|
||||
func pipelineStageNames() []string {
|
||||
out := make([]string, 0, len(lua.AllStages))
|
||||
for _, s := range lua.AllStages {
|
||||
out = append(out, string(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"plugins": []interface{}{}})
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/plugins")
|
||||
path = strings.Trim(path, "/")
|
||||
|
||||
// /api/plugins/{name}/state
|
||||
if strings.HasSuffix(path, "/state") {
|
||||
name := strings.TrimSuffix(path, "/state")
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "plugin name required")
|
||||
return
|
||||
}
|
||||
g.handlePluginState(w, r, name)
|
||||
return
|
||||
}
|
||||
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"plugins": ps.List(),
|
||||
"on_disk": ps.OnDisk(),
|
||||
"hook_errors": ps.HookErrors(),
|
||||
"plugin_dir": g.pluginDir(),
|
||||
})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := g.installPlugin(body.Name, body.Code); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// /api/plugins/{name} — source read (GET), enable/disable (PUT), delete.
|
||||
// Multi-segment paths (…/state) are handled earlier and never reach here.
|
||||
if !strings.Contains(path, "/") {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// Reading the SOURCE (not the runtime state) is what an editor
|
||||
// needs; the state endpoint is /state and returns accumulated data
|
||||
// instead. Mixing the two would make a "save what I read"
|
||||
// round-trip impossible.
|
||||
code, err := g.readPluginSource(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"name": path, "code": code,
|
||||
"enabled": g.core.Plugins().Enabled(path),
|
||||
})
|
||||
case http.MethodPut:
|
||||
// Enable / disable. The intent rides in the body rather than the
|
||||
// verb, because "enable" and "disable" are the same resource and
|
||||
// PUT /{name} is the one the docs advertise.
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if body.Enabled == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", `body must be {"enabled":true|false}`)
|
||||
return
|
||||
}
|
||||
if err := g.core.Plugins().SetEnabled(path, *body.Enabled); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true, "name": path, "enabled": *body.Enabled,
|
||||
})
|
||||
case http.MethodDelete:
|
||||
if err := g.removePlugin(path); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeError(w, http.StatusNotFound, "not_found", "unknown plugin sub-resource: "+path)
|
||||
}
|
||||
|
||||
// readPluginSource returns a plugin's file contents for the editor.
|
||||
func (g *Gateway) readPluginSource(name string) (string, error) {
|
||||
if err := validPluginName(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if g.pluginDir() == "" {
|
||||
return "", errNoPluginDir
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(g.pluginDir(), name+".lua"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no plugin source named %q", name)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// validPluginName rejects anything that could escape the plugin directory.
|
||||
// Shared by install / remove / read so the check cannot drift between them.
|
||||
func validPluginName(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, `/\.`) {
|
||||
return errPluginName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePluginState serves GET (read state) and PUT (replace state).
|
||||
func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name string) {
|
||||
ps := g.core.Plugins()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// Any role may read: plugin state is reporting data (cost, counts),
|
||||
// and the caller has already been authenticated. Admin-only would stop
|
||||
// a user key's own billing widget from rendering.
|
||||
// prices rides along: the billing rules editor must show the table
|
||||
// that is actually in effect, not a reconstruction from config. It is
|
||||
// absent for plugins with no prices (every plugin but billing).
|
||||
payload := map[string]interface{}{
|
||||
"plugin": name,
|
||||
"state": ps.State(name),
|
||||
}
|
||||
if pr := ps.Prices(name); pr != nil {
|
||||
payload["prices"] = pr
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
case http.MethodPut:
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
var state interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&state); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := ps.SetState(name, state); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) pluginDir() string {
|
||||
if c := g.core.Config(); c != nil {
|
||||
return c.PluginDir
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// installPlugin writes a plugin to disk and hot-loads it. A syntax error is
|
||||
// returned to the caller AND the file is left on disk so the operator can fix
|
||||
// it, matching how adapters behave (the file is authoritative once present).
|
||||
func (g *Gateway) installPlugin(name, code string) error {
|
||||
if err := validPluginName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := g.pluginDir()
|
||||
if dir == "" {
|
||||
return errNoPluginDir
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name+".lua"), []byte(code), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.core.Plugins().LoadSource(name, code)
|
||||
}
|
||||
|
||||
func (g *Gateway) removePlugin(name string) error {
|
||||
if err := validPluginName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.pluginDir() == "" {
|
||||
return errNoPluginDir
|
||||
}
|
||||
_ = os.Remove(filepath.Join(g.pluginDir(), name+".lua"))
|
||||
return g.core.Plugins().Unload(name)
|
||||
}
|
||||
212
internal/gateway/plugins_api_test.go
Normal file
212
internal/gateway/plugins_api_test.go
Normal file
@ -0,0 +1,212 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// gatewayWithBilling boots a gateway with the bundled billing plugin loaded, so
|
||||
// the UI-injection endpoint is exercised against a real plugin rather than a
|
||||
// hand-written stub. The other plugin tests in this package assert on the
|
||||
// WebUI source; this one asserts on the HTTP contract the browser consumes.
|
||||
func gatewayWithBilling(t *testing.T) *Gateway {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
body := "listen: :0\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"gateway_keys:\n - sk-test\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
// Load the shipped plugin explicitly: seeding only runs for a directory that
|
||||
// does not exist yet, and this test wants a known plugin regardless.
|
||||
src, err := lua.ReadBundledPlugin("billing")
|
||||
if err != nil {
|
||||
t.Fatalf("read bundled billing: %v", err)
|
||||
}
|
||||
if err := c.Plugins().LoadSource("billing", src); err != nil {
|
||||
t.Fatalf("load billing: %v", err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// TestUIInjectServesPluginUI: GET /api/ui-inject is the single call the WebUI
|
||||
// makes at boot, and it must carry BOTH a contributed page and contributed
|
||||
// elements — the browser builds the sidebar from the page and mounts the
|
||||
// elements into existing panes from the same payload, so a partial response
|
||||
// would produce a page with no body or a missing tile.
|
||||
func TestUIInjectServesPluginUI(t *testing.T) {
|
||||
g := gatewayWithBilling(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/ui-inject", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var view struct {
|
||||
// Decoded into the real types so the test cannot drift from the wire
|
||||
// contract. An inline copy missed the `pages` field when the payload
|
||||
// shape changed and failed to compile, which is at least loud — but the
|
||||
// same copy also went on asserting the OLD single-page shape for a
|
||||
// release, silently.
|
||||
UI lua.UIExtension `json:"ui"`
|
||||
Stages []string `json:"stages"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// The payload carries every page in one list; ui.page is no longer a
|
||||
// separate slot (it was, and a second plugin contributing a page overwrote
|
||||
// whatever was there).
|
||||
var billing *lua.UIPage
|
||||
for i, pg := range view.UI.Pages {
|
||||
if pg != nil && pg.PageID == "billing" {
|
||||
billing = view.UI.Pages[i]
|
||||
}
|
||||
}
|
||||
if billing == nil {
|
||||
t.Fatalf("no billing page in the inject payload; pages=%d", len(view.UI.Pages))
|
||||
}
|
||||
if !strings.Contains(billing.Mount, "billing-root") {
|
||||
t.Error("the page mount came back empty")
|
||||
}
|
||||
if len(view.UI.Elements) == 0 {
|
||||
t.Error("billing contributes an element to the status page but it is missing")
|
||||
}
|
||||
for _, e := range view.UI.Elements {
|
||||
if e.Target != "status" {
|
||||
t.Errorf("element target = %q, want \"status\"", e.Target)
|
||||
}
|
||||
}
|
||||
// Derived from AllStages, not hardcoded: the previous assertion of "3"
|
||||
// is exactly what let chain_step go missing from this payload unnoticed.
|
||||
if len(view.Stages) != len(lua.AllStages) {
|
||||
t.Errorf("stages = %v, want %d (one per AllStages entry)", view.Stages, len(lua.AllStages))
|
||||
}
|
||||
for i, st := range lua.AllStages {
|
||||
if i >= len(view.Stages) || view.Stages[i] != string(st) {
|
||||
t.Errorf("stages[%d] = %v, want %q", i, view.Stages, string(st))
|
||||
}
|
||||
}
|
||||
if !containsStr(view.Stages, string(lua.StageChainStep)) {
|
||||
t.Error("the discovery payload does not advertise chain_step; a plugin " +
|
||||
"author would conclude the stage does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIInjectIsEmptyWithoutPlugins: a gateway with no plugins must still answer
|
||||
// 200 with an empty (not missing, not null) payload. The WebUI calls this
|
||||
// unconditionally at boot, so a 404 or a null `ui` would break every dashboard.
|
||||
func TestUIInjectIsEmptyWithoutPlugins(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/ui-inject", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `"ui"`) {
|
||||
t.Error("no ui key in the response; the WebUI would have nothing to read")
|
||||
}
|
||||
}
|
||||
|
||||
// containsStr reports whether list has s.
|
||||
func containsStr(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// newRecorderFor pushes a request through the full handler chain.
|
||||
func newRecorderFor(t *testing.T, g *Gateway, req *http.Request) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
g.Handler().ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// TestPluginsListAndStateAPI covers the management surface the plugin docs
|
||||
// promise: listing, and reading a plugin's own published state.
|
||||
func TestPluginsListAndStateAPI(t *testing.T) {
|
||||
g := gatewayWithBilling(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/plugins = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
for _, want := range []string{"billing", "hook_errors", "plugin_dir", "request_end"} {
|
||||
if !strings.Contains(rr.Body.String(), want) {
|
||||
t.Errorf("/api/plugins response lacks %q", want)
|
||||
}
|
||||
}
|
||||
// state read: the plugin published its (empty) state, so the key exists.
|
||||
rr = doReq(t, g, http.MethodGet, "/api/plugins/billing/state", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET state = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `"total"`) {
|
||||
t.Errorf("billing state lacks the total bucket: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginStatePUTIsAdminOnly: only the WRITE side is gated. A user key must
|
||||
// be able to READ its own billing widget's data, but must not be able to
|
||||
// rewrite the price table.
|
||||
func TestPluginStatePUTIsAdminOnly(t *testing.T) {
|
||||
g := gatewayWithBilling(t)
|
||||
|
||||
// Build a user-role key and remember its secret.
|
||||
rec, err := g.core.CreateKey("viewer", "user", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userKey := rec.Key
|
||||
|
||||
// A user key may read the state.
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/plugins/billing/state", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userKey)
|
||||
rr := newRecorderFor(t, g, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("user GET state = %d, want 200 (the billing widget must render for users)", rr.Code)
|
||||
}
|
||||
|
||||
// A user key may NOT write it.
|
||||
put, _ := http.NewRequest(http.MethodPut, "/api/plugins/billing/state",
|
||||
strings.NewReader(`{"prices":{"default":{"prompt":0}}}`))
|
||||
put.Header.Set("Authorization", "Bearer "+userKey)
|
||||
put.Header.Set("Content-Type", "application/json")
|
||||
prr := newRecorderFor(t, g, put)
|
||||
if prr.Code != http.StatusForbidden {
|
||||
t.Errorf("user PUT state = %d, want 403 (a user must not rewrite the price table)", prr.Code)
|
||||
}
|
||||
|
||||
// An admin key may.
|
||||
adm := doReq(t, g, http.MethodPut, "/api/plugins/billing/state",
|
||||
`{"prices":{"default":{"prompt":1e-6}}}`)
|
||||
if adm.Code != http.StatusOK {
|
||||
t.Errorf("admin PUT state = %d: %s", adm.Code, adm.Body.String())
|
||||
}
|
||||
}
|
||||
@ -228,6 +228,15 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleSourcesAPI(w, r)
|
||||
case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):
|
||||
g.handleSourceTemplatesAPI(w, r)
|
||||
case r.URL.Path == "/api/ui-inject" || strings.HasPrefix(r.URL.Path, "/api/ui-inject/"):
|
||||
g.handlePluginUI(w, r)
|
||||
case r.URL.Path == "/api/plugins/billing/rules":
|
||||
// Checked BEFORE the generic /api/plugins/ prefix: the plugin router
|
||||
// treats anything after the name as a plugin sub-resource and would
|
||||
// 404 on "rules" instead of reaching the billing editor.
|
||||
g.handleBillingRules(w, r)
|
||||
case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"):
|
||||
g.handlePluginsAPI(w, r)
|
||||
case r.URL.Path == "/api/chat":
|
||||
g.handleChat(w, r)
|
||||
case r.URL.Path == "/api/status":
|
||||
|
||||
495
internal/gateway/source_fields_test.go
Normal file
495
internal/gateway/source_fields_test.go
Normal file
@ -0,0 +1,495 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
)
|
||||
|
||||
// This file covers the second round of the same class of defect bf0657b fixed.
|
||||
//
|
||||
// bf0657b stopped a partial source edit from clobbering api_key. The upsert
|
||||
// still rewrites the WHOLE source, so every other field the request could not
|
||||
// express was reset to its zero value on any edit: proxy_url, api_key_env,
|
||||
// timeout and queue_timeout.
|
||||
//
|
||||
// The four are worse than cosmetic because they change how the source dials out.
|
||||
// proxy_url silently turns a proxied upstream into a direct one (or vice versa)
|
||||
// and api_key_env turns a source whose credential lives in an environment
|
||||
// variable into a credential-less source — which keeps answering 200 on write
|
||||
// and only fails on the NEXT call upstream, long after the editor left.
|
||||
//
|
||||
// The trigger is not exotic scripting: the WebUI's own edit form posts exactly
|
||||
// such a payload. See TestWebUIEditPayloadPreservesRoutingFields.
|
||||
|
||||
// richGateway writes a config.yaml whose source carries all four fragile
|
||||
// fields, then boots a gateway over it.
|
||||
func richGateway(t *testing.T) *Gateway {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
cfg := `listen: 127.0.0.1:8080
|
||||
gateway_keys:
|
||||
- sk-test
|
||||
default_model: AUTO
|
||||
adapter_dir: ` + filepath.Join(dir, "adapters") + `
|
||||
runtime_file: ` + filepath.Join(dir, "runtime.json") + `
|
||||
sources:
|
||||
- name: rich
|
||||
base_url: https://api.deepseek.com
|
||||
api_key: sk-live
|
||||
adapter: deepseek
|
||||
proxy_url: http://127.0.0.1:7890
|
||||
timeout: 300s
|
||||
queue_timeout: 90s
|
||||
max_concurrent: 8
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
priority: 100
|
||||
kind: chat
|
||||
`
|
||||
if err := os.WriteFile(cfgPath, []byte(cfg), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, err := core.NewFromConfig(c)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(core.Close)
|
||||
g, err := New(core)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// sourceField reads one field off the live source, i.e. what the routes
|
||||
// actually use rather than what the file happens to contain.
|
||||
func sourceField(t *testing.T, g *Gateway, name string, get func(config.Source) interface{}) interface{} {
|
||||
t.Helper()
|
||||
for _, s := range g.core.Sources() {
|
||||
if s.Name == name {
|
||||
return get(s)
|
||||
}
|
||||
}
|
||||
t.Fatalf("source %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// webUIPayload is byte-for-byte the JSON that index.html's saveSource() builds
|
||||
// from the edit dialog. It deliberately carries none of the four fragile
|
||||
// fields, because the dialog has no inputs for them.
|
||||
const webUIPayload = `{
|
||||
"name":"rich","base_url":"https://api.deepseek.com","api_key":"sk-live",
|
||||
"adapter":"deepseek","endpoint":"","image_endpoint":"",
|
||||
"models":[{"id":"deepseek-v4-flash","priority":100,"kind":"chat"}],
|
||||
"headers":{},"meta":{},"temperature":0,"max_tokens":0,"max_concurrent":16,"rpm":0
|
||||
}`
|
||||
|
||||
// TestWebUIEditPayloadPreservesRoutingFields is the regression test for the
|
||||
// reported defect: changing max_concurrent from 8 to 16 in the UI must not
|
||||
// silently drop the proxy, the env-var credential, or the timeouts.
|
||||
func TestWebUIEditPayloadPreservesRoutingFields(t *testing.T) {
|
||||
g := richGateway(t)
|
||||
|
||||
// The one field the operator actually changed.
|
||||
rr := doReq(t, g, "POST", "/api/sources", webUIPayload)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("edit status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
checks := []struct {
|
||||
field string
|
||||
got interface{}
|
||||
want interface{}
|
||||
}{
|
||||
{"proxy_url", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.ProxyURL }),
|
||||
"http://127.0.0.1:7890"},
|
||||
{"timeout", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.Timeout }),
|
||||
300 * time.Second},
|
||||
{"queue_timeout", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.QueueTimeout }),
|
||||
90 * time.Second},
|
||||
// The field the edit DID change, so a fix that simply refuses to write
|
||||
// anything would also pass the three assertions above.
|
||||
{"max_concurrent", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.MaxConcurrent }),
|
||||
16},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %v, want %v", c.field, c.got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceEditPreservesAPIKeyEnv is called out separately because it is the
|
||||
// only one of the four whose loss is a credential failure rather than a routing
|
||||
// difference, and it is invisible until the next upstream call.
|
||||
func TestSourceEditPreservesAPIKeyEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
cfg := `listen: 127.0.0.1:8080
|
||||
gateway_keys:
|
||||
- sk-test
|
||||
default_model: AUTO
|
||||
adapter_dir: ` + filepath.Join(dir, "adapters") + `
|
||||
runtime_file: ` + filepath.Join(dir, "runtime.json") + `
|
||||
sources:
|
||||
- name: envonly
|
||||
base_url: https://api.deepseek.com
|
||||
api_key_env: DEEPSEEK_KEY
|
||||
adapter: deepseek
|
||||
max_concurrent: 8
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
priority: 100
|
||||
kind: chat
|
||||
`
|
||||
if err := os.WriteFile(cfgPath, []byte(cfg), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, err := core.NewFromConfig(c)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(core.Close)
|
||||
g, err := New(core)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A credential-less source is legitimate, so the API must not invent one
|
||||
// here; the point is that the ENV reference survives the edit.
|
||||
rr := doReq(t, g, "POST", "/api/sources", `{
|
||||
"name":"envonly","base_url":"https://api.deepseek.com","api_key":"",
|
||||
"adapter":"deepseek","models":[{"id":"deepseek-v4-flash","priority":100,"kind":"chat"}],
|
||||
"headers":{},"meta":{},"max_concurrent":4}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("edit status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
got := sourceField(t, g, "envonly", func(s config.Source) interface{} { return s.APIKeyEnv })
|
||||
if got != "DEEPSEEK_KEY" {
|
||||
t.Fatalf("api_key_env = %v after an edit that did not mention it; "+
|
||||
"the source now has no credential and will only fail on the next upstream call", got)
|
||||
}
|
||||
if n := sourceField(t, g, "envonly", func(s config.Source) interface{} { return s.MaxConcurrent }); n != 4 {
|
||||
t.Fatalf("max_concurrent = %v, want the edited 4", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceEditPersistsInheritedFieldsToDisk: inheriting in memory is not
|
||||
// enough — the values must reach config.yaml, or a restart silently undoes the
|
||||
// fix and the field is lost anyway.
|
||||
func TestSourceEditPersistsInheritedFieldsToDisk(t *testing.T) {
|
||||
g := richGateway(t)
|
||||
if rr := doReq(t, g, "POST", "/api/sources", webUIPayload); rr.Code != 200 {
|
||||
t.Fatalf("edit status=%d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
raw, err := os.ReadFile(g.core.Config().Path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
text := string(raw)
|
||||
for _, want := range []string{
|
||||
"proxy_url: http://127.0.0.1:7890",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("config.yaml lost %q on an unrelated edit:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
// The durations are re-serialized in Go's canonical form (300s becomes
|
||||
// 5m0s), so they must be checked SEMANTICALLY. Asserting the original
|
||||
// spelling would be a false red: the value survived, only its text changed.
|
||||
// Re-loading the file is also the only way to prove the persisted value
|
||||
// parses back to the same duration.
|
||||
reloaded, err := config.Load(g.core.Config().Path)
|
||||
if err != nil {
|
||||
t.Fatalf("re-read config: %v", err)
|
||||
}
|
||||
if len(reloaded.Sources) == 0 {
|
||||
t.Fatal("no sources after reload")
|
||||
}
|
||||
s := reloaded.Sources[0]
|
||||
if s.Timeout != 300*time.Second {
|
||||
t.Errorf("persisted timeout = %v, want 300s", s.Timeout)
|
||||
}
|
||||
if s.QueueTimeout != 90*time.Second {
|
||||
t.Errorf("persisted queue_timeout = %v, want 90s", s.QueueTimeout)
|
||||
}
|
||||
if s.ProxyURL != "http://127.0.0.1:7890" {
|
||||
t.Errorf("persisted proxy_url = %v, want it preserved", s.ProxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- explicit set / clear, i.e. the pointer semantics actually work ----
|
||||
|
||||
func TestSourceOptionalFieldsCanBeSet(t *testing.T) {
|
||||
g := richGateway(t)
|
||||
rr := doReq(t, g, "POST", "/api/sources", `{
|
||||
"name":"rich","base_url":"https://api.deepseek.com","api_key":"sk-live",
|
||||
"adapter":"deepseek","models":[{"id":"deepseek-v4-flash","priority":100,"kind":"chat"}],
|
||||
"headers":{},"meta":{},
|
||||
"proxy_url":"http://127.0.0.1:1080",
|
||||
"api_key_env":"NEW_ENV_KEY",
|
||||
"timeout":"45s",
|
||||
"queue_timeout":"5s"}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
for _, c := range []struct {
|
||||
field string
|
||||
got interface{}
|
||||
want interface{}
|
||||
}{
|
||||
{"proxy_url", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.ProxyURL }), "http://127.0.0.1:1080"},
|
||||
{"api_key_env", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.APIKeyEnv }), "NEW_ENV_KEY"},
|
||||
{"timeout", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.Timeout }), 45 * time.Second},
|
||||
{"queue_timeout", sourceField(t, g, "rich", func(s config.Source) interface{} { return s.QueueTimeout }), 5 * time.Second},
|
||||
} {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %v, want %v", c.field, c.got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceOptionalFieldsCanBeCleared is the half that an "empty means
|
||||
// inherit" implementation gets wrong: it can never clear a field, so emptying
|
||||
// the proxy box in the UI would keep using the old proxy forever.
|
||||
func TestSourceOptionalFieldsCanBeCleared(t *testing.T) {
|
||||
g := richGateway(t)
|
||||
rr := doReq(t, g, "POST", "/api/sources", `{
|
||||
"name":"rich","base_url":"https://api.deepseek.com","api_key":"sk-live",
|
||||
"adapter":"deepseek","models":[{"id":"deepseek-v4-flash","priority":100,"kind":"chat"}],
|
||||
"headers":{},"meta":{},
|
||||
"proxy_url":"","timeout":"","queue_timeout":""}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := sourceField(t, g, "rich", func(s config.Source) interface{} { return s.ProxyURL }); got != "" {
|
||||
t.Errorf("proxy_url = %v, want it cleared", got)
|
||||
}
|
||||
// A cleared timeout falls back to the config default (120s), which is what
|
||||
// mergedSources applies for a zero value — assert the effective behaviour
|
||||
// rather than the raw zero, since that is what a caller actually gets.
|
||||
if got := sourceField(t, g, "rich", func(s config.Source) interface{} { return s.Timeout }); got != config.DefaultSourceTimeout {
|
||||
t.Errorf("timeout = %v, want the default %v after clearing", got, config.DefaultSourceTimeout)
|
||||
}
|
||||
// api_key_env was NOT in the request, so it must be untouched even though
|
||||
// its sibling fields were cleared.
|
||||
_ = rr
|
||||
}
|
||||
|
||||
// TestSourceOptionalFieldsClearIsScoped: clearing the proxy must not clear the
|
||||
// env credential that the same request did not mention.
|
||||
func TestSourceOptionalFieldsClearIsScoped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
cfg := `listen: 127.0.0.1:8080
|
||||
gateway_keys:
|
||||
- sk-test
|
||||
default_model: AUTO
|
||||
adapter_dir: ` + filepath.Join(dir, "adapters") + `
|
||||
runtime_file: ` + filepath.Join(dir, "runtime.json") + `
|
||||
sources:
|
||||
- name: both
|
||||
base_url: https://api.deepseek.com
|
||||
api_key_env: DEEPSEEK_KEY
|
||||
adapter: deepseek
|
||||
proxy_url: http://127.0.0.1:7890
|
||||
max_concurrent: 8
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
priority: 100
|
||||
kind: chat
|
||||
`
|
||||
os.WriteFile(cfgPath, []byte(cfg), 0600)
|
||||
c, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, err := core.NewFromConfig(c)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(core.Close)
|
||||
g, err := New(core)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := doReq(t, g, "POST", "/api/sources", `{
|
||||
"name":"both","base_url":"https://api.deepseek.com","api_key":"",
|
||||
"adapter":"deepseek","models":[{"id":"deepseek-v4-flash","priority":100,"kind":"chat"}],
|
||||
"headers":{},"meta":{},"proxy_url":""}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := sourceField(t, g, "both", func(s config.Source) interface{} { return s.APIKeyEnv }); got != "DEEPSEEK_KEY" {
|
||||
t.Errorf("api_key_env = %v; clearing proxy_url must not touch it", got)
|
||||
}
|
||||
if got := sourceField(t, g, "both", func(s config.Source) interface{} { return s.ProxyURL }); got != "" {
|
||||
t.Errorf("proxy_url = %v, want cleared", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceRejectsBadDuration(t *testing.T) {
|
||||
g := richGateway(t)
|
||||
before := sourceField(t, g, "rich", func(s config.Source) interface{} { return s.Timeout })
|
||||
rr := doReq(t, g, "POST", "/api/sources", `{
|
||||
"name":"rich","base_url":"https://api.deepseek.com","api_key":"sk-live",
|
||||
"adapter":"deepseek","models":[{"id":"deepseek-v4-flash","priority":100,"kind":"chat"}],
|
||||
"headers":{},"meta":{},"timeout":"not-a-duration"}`)
|
||||
if rr.Code != 400 {
|
||||
t.Fatalf("status=%d, want 400 for an unparseable duration: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "timeout") {
|
||||
t.Errorf("the error must name the offending field: %s", rr.Body.String())
|
||||
}
|
||||
// A rejected write must leave the source exactly as it was.
|
||||
if after := sourceField(t, g, "rich", func(s config.Source) interface{} { return s.Timeout }); after != before {
|
||||
t.Errorf("a rejected edit still changed timeout: %v -> %v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceCreateSetsOptionalFields: the create path must accept them too, and
|
||||
// must not require an existing source to inherit from (a nil-pointer crash
|
||||
// here would only show up as a 500 on every new source).
|
||||
func TestSourceCreateSetsOptionalFields(t *testing.T) {
|
||||
up := mockUpstream()
|
||||
defer up.Close()
|
||||
g := newTestGateway(t)
|
||||
rr := doReq(t, g, "POST", "/api/sources", `{
|
||||
"name":"fresh","base_url":"`+up.URL+`","api_key":"sk-x","adapter":"openai",
|
||||
"models":[{"id":"m1","kind":"chat"}],"headers":{},"meta":{},
|
||||
"proxy_url":"http://127.0.0.1:7890","timeout":"10s","queue_timeout":"3s"}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("create status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := sourceField(t, g, "fresh", func(s config.Source) interface{} { return s.ProxyURL }); got != "http://127.0.0.1:7890" {
|
||||
t.Errorf("proxy_url = %v, want it set on create", got)
|
||||
}
|
||||
if got := sourceField(t, g, "fresh", func(s config.Source) interface{} { return s.Timeout }); got != 10*time.Second {
|
||||
t.Errorf("timeout = %v, want 10s on create", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceRevealExposesDurations guards the round-trip the edit form depends
|
||||
// on. config.Source tags Timeout/QueueTimeout `json:"-"`, so marshaling the
|
||||
// struct omits them; the form then reads an empty timeout box and — because it
|
||||
// always sends the box back — CLEARS the stored timeout on any unrelated save.
|
||||
// A test that only checks "the edit preserves timeout" would not have caught
|
||||
// that, because the loss happens between the read and the write.
|
||||
//
|
||||
// The credentials are asserted in the same breath: they came from the same
|
||||
// branch, and an earlier iteration of this fix masked the key and broke the
|
||||
// form's other half.
|
||||
func TestSourceRevealExposesDurations(t *testing.T) {
|
||||
up := mockUpstream()
|
||||
defer up.Close()
|
||||
g := newTestGateway(t, config.Source{
|
||||
Name: "revealed",
|
||||
BaseURL: up.URL,
|
||||
Adapter: "openai",
|
||||
APIKey: "sk-reveal-me",
|
||||
Models: []config.Model{{ID: "m1", Kind: "chat"}},
|
||||
Timeout: 300 * time.Second,
|
||||
})
|
||||
|
||||
rr := doReq(t, g, "GET", "/api/v1/sources/revealed?reveal=credentials", "")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("reveal status=%d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var view struct {
|
||||
Source map[string]interface{} `json:"source"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// Duration strings, matching what the write path parses.
|
||||
if got := view.Source["timeout"]; got != "5m0s" {
|
||||
t.Errorf("reveal timeout = %v, want the string \"5m0s\"; the edit form "+
|
||||
"would show an empty box and clear the stored value on save", got)
|
||||
}
|
||||
// The unset one shows the DEFAULT, not "": ApplyDefaults filled
|
||||
// QueueTimeout before the form ever sees it. Both are acceptable as long as
|
||||
// the value round-trips, so assert it is a parseable duration string.
|
||||
qs, _ := view.Source["queue_timeout"].(string)
|
||||
if _, err := time.ParseDuration(qs); err != nil {
|
||||
t.Errorf("reveal queue_timeout = %q is not a duration string: %v", qs, err)
|
||||
}
|
||||
if got := view.Source["api_key"]; got != "sk-reveal-me" {
|
||||
t.Errorf("reveal api_key = %v, want it in the clear for the form", got)
|
||||
}
|
||||
// The masked view must NOT gain the durations' secrets — it is fine for it
|
||||
// to omit them, but it must never carry the key.
|
||||
masked := doReq(t, g, "GET", "/api/v1/sources/revealed", "")
|
||||
if strings.Contains(masked.Body.String(), "sk-reveal-me") {
|
||||
t.Error("the masked view leaked the key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourcePayloadCoversEveryEditableField is the guard against the class
|
||||
// itself. Any config.Source field a source edit can legitimately set must
|
||||
// exist on sourcePayload, otherwise the upsert resets it.
|
||||
//
|
||||
// It reflects over the STRUCT rather than over json.Marshal output: the four
|
||||
// pointer fields carry `omitempty`, so a nil pointer legitimately disappears
|
||||
// from the marshaled form — that absence is precisely the "not mentioned" signal
|
||||
// the handler relies on. Checking marshaled keys would therefore report a
|
||||
// healthy field as missing.
|
||||
func TestSourcePayloadCoversEveryEditableField(t *testing.T) {
|
||||
// Fields of config.Source that a source edit can legitimately set.
|
||||
want := []string{
|
||||
"name", "base_url", "api_key", "api_key_env", "adapter", "endpoint",
|
||||
"image_endpoint", "models", "headers", "proxy_url", "meta",
|
||||
"temperature", "max_tokens", "max_concurrent", "rpm",
|
||||
"timeout", "queue_timeout",
|
||||
}
|
||||
pt := reflect.TypeOf(sourcePayload{})
|
||||
for _, f := range want {
|
||||
if _, ok := pt.FieldByName(f); !ok {
|
||||
// Tolerate a differently-spelled Go field only when the JSON tag
|
||||
// matches, so the check stays about the wire contract.
|
||||
if !hasJSONTag(pt, f) {
|
||||
t.Errorf("config.Source has %q but sourcePayload has no such JSON key: "+
|
||||
"an edit that omits it resets the stored value", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasJSONTag reports whether any field of t carries the given JSON name.
|
||||
func hasJSONTag(t reflect.Type, name string) bool {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
if jsonName(t.Field(i)) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// jsonName returns the JSON name of a struct field, falling back to the Go
|
||||
// field name when no tag is present.
|
||||
func jsonName(f reflect.StructField) string {
|
||||
tag := f.Tag.Get("json")
|
||||
if tag == "" {
|
||||
return f.Name
|
||||
}
|
||||
if n := strings.Split(tag, ",")[0]; n != "" {
|
||||
return n
|
||||
}
|
||||
return f.Name
|
||||
}
|
||||
@ -52,6 +52,23 @@ type Req struct {
|
||||
// Kept separate from Compl/Prompt: image generation has no token concept,
|
||||
// so counting images as "completion tokens" would corrupt the token totals.
|
||||
ImageCount int `json:"image_count,omitempty"`
|
||||
|
||||
// Walk is the AUTO chain's step-by-step trace for this request: which tiers
|
||||
// were skipped and why, which slots hard-failed, which one served it. It is
|
||||
// the only way a consumer can tell "tier 1 served this" from "tier 1 was
|
||||
// cooling so we dropped to tier 3" — a distinction that is the entire point
|
||||
// of a priority chain.
|
||||
//
|
||||
// json:"-" — deliberately NOT persisted. The audit file is a hot append and
|
||||
// this is observational detail: on a degraded gateway every request would
|
||||
// carry a multi-element array, and the audit trail's own retention (16 files
|
||||
// x 16 MB) is already the largest thing on the box. A plugin that wants the
|
||||
// walk sees it live at request_end; an operator post-mortem reads it from the
|
||||
// plugin's own accumulated state or from /api/auto slot health.
|
||||
//
|
||||
// Only populated when a plugin is loaded (chainTraceSink returns nil
|
||||
// otherwise), so a gateway with no plugins allocates nothing for it.
|
||||
Walk []map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
// Stat aggregates counters for one dimension row.
|
||||
@ -74,6 +91,16 @@ type agrRow struct {
|
||||
|
||||
// Stats collects per-key / per-model / per-source aggregates plus a bounded
|
||||
// ring of raw request records, all guarded by one mutex.
|
||||
//
|
||||
// LOCKING: mu is a plain sync.Mutex and is NOT reentrant. The *Locked methods
|
||||
// (aggregateLocked, addKeyTokenLocked, addKeyHourLocked, addKeyReqLocked,
|
||||
// wantPinnedBuckets, rotateAuditLocked, …) assume the caller already holds it,
|
||||
// while every other exported method takes it itself.
|
||||
//
|
||||
// Calling an exported method while already holding mu DEADLOCKS. This is not
|
||||
// hypothetical: a test that did KeyWindowReqs under s.mu.Lock() hung until the
|
||||
// 10-minute panic timeout. Always reach for the *Locked variant when the lock
|
||||
// is already held, and prefer the exported method when it is not.
|
||||
type Stats struct {
|
||||
mu sync.Mutex
|
||||
active int64
|
||||
|
||||
132
internal/gateway/stats_lock_test.go
Normal file
132
internal/gateway/stats_lock_test.go
Normal file
@ -0,0 +1,132 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The Stats mutex is a plain sync.Mutex: calling an exported (self-locking)
|
||||
// method while already holding it deadlocks. That happened for real — a probe
|
||||
// held s.mu and called KeyWindowReqs, hanging until the test binary's 10-minute
|
||||
// panic timeout — so the rule is pinned here rather than left in a comment.
|
||||
//
|
||||
// The check is structural: every method that takes the lock must say so in its
|
||||
// name or its doc comment. That keeps the trap visible when someone adds the
|
||||
// next method, which is the only time the rule can be forgotten.
|
||||
|
||||
// exportedSelfLocking lists the exported Stats methods that take the mutex.
|
||||
// It is derived from reflection at run time; the assertions below are what
|
||||
// actually pin the convention.
|
||||
func TestStatsExportedMethodsDocumentTheirLocking(t *testing.T) {
|
||||
typ := reflect.TypeOf(&Stats{})
|
||||
for i := 0; i < typ.NumMethod(); i++ {
|
||||
m := typ.Method(i)
|
||||
if m.PkgPath != "" { // unexported
|
||||
continue
|
||||
}
|
||||
// Methods that neither lock nor touch guarded state are fine either way;
|
||||
// the ones that matter are those reaching into the maps under mu.
|
||||
if !methodTouchesLockedState(m.Name) {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(m.Name, "Locked") {
|
||||
t.Errorf("Stats.%s is exported but named *Locked; the suffix means "+
|
||||
"the CALLER holds the lock, so it must not be exported", m.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// methodTouchesLockedState is the set of exported methods known to read or
|
||||
// write state guarded by Stats.mu. Kept explicit (rather than inferred) so a
|
||||
// new method is not silently assumed safe.
|
||||
func methodTouchesLockedState(name string) bool {
|
||||
switch name {
|
||||
case "KeyWindowReqs", "KeyWindowModelTokens", "KeyWindowTokens",
|
||||
"WindowTokens", "KeyTokens", "ModelTokens", "Record", "AppendAudit",
|
||||
"LoadAudit", "Snapshot", "SourceRecent", "SourceAverages",
|
||||
"AuditRecords", "AuditPage", "StreamAuditRecords", "ReplayPartial",
|
||||
"PoolStats":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestStatsLockedMethodsAreNotCalledUnderLock is the behavioural half: it
|
||||
// proves the internal helpers the ones above use are reachable while the lock
|
||||
// is held. If a future refactor makes an exported method call a *Locked one
|
||||
// while holding mu itself, this is where it shows up — as a hang, bounded by
|
||||
// the short timeout below rather than the suite's 10 minutes.
|
||||
func TestStatsLockedMethodsAreNotCalledUnderLock(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
s := NewStats(10)
|
||||
h := time.Now().Unix() / 3600
|
||||
s.mu.Lock()
|
||||
// Exactly the shape that deadlocked: the *Locked variants are correct
|
||||
// here because the lock is already held.
|
||||
s.addKeyReqLocked("k", h, 5)
|
||||
s.addKeyHourLocked("k", h, 100)
|
||||
s.wantPinnedBuckets("k", "m1")
|
||||
s.addKeyTokenLocked("k", "m1", "src", h, 100)
|
||||
// sumBuckets is the pure inner function the exported readers call.
|
||||
if got := sumBuckets(s.keyReqHour["k"], time.Now().Unix(), 3600); got != 5 {
|
||||
t.Errorf("sumBuckets under lock = %d, want 5", got)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(20 * time.Second):
|
||||
buf := make([]byte, 1<<16)
|
||||
n := runtime.Stack(buf, true)
|
||||
t.Fatalf("deadlocked while using the *Locked helpers under s.mu:\n%s", buf[:n])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSumBucketsEdgeCases covers the boundaries the quota check depends on,
|
||||
// including the ones a regression would silently get wrong (an off-by-one here
|
||||
// either lets a quota leak or locks a key out early).
|
||||
func TestSumBucketsEdgeCases(t *testing.T) {
|
||||
const hour = 3600
|
||||
now := int64(10*hour + 61) // 10:00:61, i.e. just past the boundary
|
||||
buckets := map[int64]int64{
|
||||
0: 100, // ancient
|
||||
9: 200, // previous hour
|
||||
10: 7, // current hour
|
||||
}
|
||||
|
||||
if got := sumBuckets(buckets, now, 0); got != 307 {
|
||||
t.Errorf("sec<=0 (all history) = %d, want 307", got)
|
||||
}
|
||||
// 1h window covers buckets h >= ceil((now-3600)/3600) = 10 -> only bucket 10.
|
||||
if got := sumBuckets(buckets, now, hour); got != 7 {
|
||||
t.Errorf("1h window = %d, want 7 (buckets 0 and 9 fall outside)", got)
|
||||
}
|
||||
// 2h window covers h >= 9 -> buckets 9 and 10.
|
||||
if got := sumBuckets(buckets, now, 2*hour); got != 207 {
|
||||
t.Errorf("2h window = %d, want 207", got)
|
||||
}
|
||||
// An empty map and a nil map must both be zero, not a panic.
|
||||
if got := sumBuckets(map[int64]int64{}, now, hour); got != 0 {
|
||||
t.Errorf("empty map = %d, want 0", got)
|
||||
}
|
||||
if got := sumBuckets(nil, now, hour); got != 0 {
|
||||
t.Errorf("nil map = %d, want 0", got)
|
||||
}
|
||||
// now < sec must not produce a negative firstHour index: with a window far
|
||||
// wider than the available history, every bucket that exists is counted.
|
||||
// (sumBuckets walks bucket indices from firstHour to nowHour, so passing a
|
||||
// "now" older than some buckets cannot reach them — that is correct, not a
|
||||
// truncation bug.)
|
||||
if got := sumBuckets(buckets, 10*hour+61, 100*hour); got != 307 {
|
||||
t.Errorf("window wider than history = %d, want 307", got)
|
||||
}
|
||||
// A window that predates every bucket counts them all as well.
|
||||
if got := sumBuckets(buckets, 10*hour+61, hour); got != 7 {
|
||||
t.Errorf("1h window at t=10:00:61 = %d, want 7", got)
|
||||
}
|
||||
}
|
||||
234
internal/gateway/stats_period.go
Normal file
234
internal/gateway/stats_period.go
Normal file
@ -0,0 +1,234 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Period is a reporting window for the usage dashboard. The dashboard used to
|
||||
// have exactly one view — everything since process start — which made a quiet
|
||||
// morning indistinguishable from a quiet week. Periods give the operator a
|
||||
// scale to read the numbers at: today vs this week vs this month vs all time.
|
||||
//
|
||||
// The set is deliberately calendar-based and UTC-anchored. A rolling 24h window
|
||||
// would put "today" and "the last day" at different boundaries, so the same
|
||||
// number would move between two cards depending on when you looked; calendar
|
||||
// days are what people actually mean by "today". UTC also matches the billing
|
||||
// plugin's peak-window arithmetic, so a peak-rate hour does not land in a
|
||||
// different day in the cost view than in the usage view.
|
||||
type Period string
|
||||
|
||||
const (
|
||||
// PeriodDay is the current UTC calendar day.
|
||||
PeriodDay Period = "day"
|
||||
// PeriodWeek is the current ISO week (Mon 00:00 UTC to now).
|
||||
PeriodWeek Period = "week"
|
||||
// PeriodMonth is the current UTC calendar month.
|
||||
PeriodMonth Period = "month"
|
||||
// PeriodAll is since process start — the only view backed by the
|
||||
// in-memory aggregates, and the only one available when no audit file is
|
||||
// configured.
|
||||
PeriodAll Period = "all"
|
||||
)
|
||||
|
||||
// ValidPeriod reports whether p is a period the aggregator understands.
|
||||
// An unknown period is a client error, not a silent fallback to "all": a
|
||||
// dashboard that quietly shows lifetime totals when the caller asked for today
|
||||
// is worse than one that refuses.
|
||||
func ValidPeriod(p Period) bool {
|
||||
switch p {
|
||||
case PeriodDay, PeriodWeek, PeriodMonth, PeriodAll:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// periodStart returns the inclusive start of the window for p at time now.
|
||||
// Only PeriodDay/Week/Month are meaningful here; PeriodAll returns 0, which
|
||||
// every "from > 0" bounds check treats as unbounded.
|
||||
func periodStart(p Period, now time.Time) int64 {
|
||||
now = now.UTC()
|
||||
switch p {
|
||||
case PeriodDay:
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||
case PeriodWeek:
|
||||
// ISO week starts Monday. Go's Weekday() is Sunday=0, so the shift
|
||||
// below is 1 on Sunday and 0 on Monday..Saturday.
|
||||
off := (int(now.Weekday()) + 6) % 7
|
||||
d := now.AddDate(0, 0, -off)
|
||||
return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||
case PeriodMonth:
|
||||
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// PeriodBucket is one labelled point on the dashboard's timeline. Buckets are
|
||||
// the aggregation grain for a period: hourly for a day (so a spike is visible
|
||||
// inside "today"), daily for a week or month (so a week is not 7×24 points
|
||||
// wide and a month is not 31×24), and empty for "all" — a lifetime total has
|
||||
// no meaningful timeline, and pretending otherwise by drawing 500 hourly
|
||||
// buckets of rolling memory would be a different lie.
|
||||
type PeriodBucket struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Stat
|
||||
}
|
||||
|
||||
// PeriodSnapshot is the period-scoped twin of Snapshot's payload: the same
|
||||
// totals and the same by_* rows, plus a timeline. It deliberately mirrors
|
||||
// Snapshot's field names so the dashboard's table painters work unchanged —
|
||||
// paintModelTable(st.by_model) reads rows of {name, ...Stat}, and that shape
|
||||
// does not care where the numbers came from.
|
||||
type PeriodSnapshot struct {
|
||||
Period Period `json:"period"`
|
||||
From int64 `json:"from"` // unix millis, 0 when PeriodAll
|
||||
Total Stat `json:"total"`
|
||||
ByKey []StatsRow `json:"by_key"`
|
||||
Models []StatsRow `json:"by_model"`
|
||||
Srcs []StatsRow `json:"by_source"`
|
||||
Status []agrRow `json:"by_status"`
|
||||
Bucket []PeriodBucket `json:"buckets"`
|
||||
// Truncated marks that the window was clipped by the retained audit
|
||||
// history, so the numbers are a lower bound rather than the true period
|
||||
// total. The dashboard shows this next to the numbers rather than letting
|
||||
// a rotated-away week read as "that week had no traffic".
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// bucketKey maps a record's timestamp onto the timeline grain for p.
|
||||
// Daily buckets are stamped at UTC midnight; hourly buckets carry the hour.
|
||||
func bucketKey(p Period, ms int64) string {
|
||||
t := time.UnixMilli(ms).UTC()
|
||||
if p == PeriodDay {
|
||||
return t.Format("2006-01-02T15")
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// bucketOf returns the bucket label plus the truncated-flag side effects of
|
||||
// walking a file: a record older than the requested window means the window
|
||||
// starts before the retained history, and the file may have been cut short.
|
||||
func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapshot {
|
||||
out := PeriodSnapshot{Period: p}
|
||||
from := periodStart(p, now)
|
||||
out.From = from
|
||||
|
||||
// "all" is exactly what Snapshot already answers, from the in-memory
|
||||
// aggregates, and it is the one view that must keep working with no audit
|
||||
// file configured at all (a fresh dev setup, or an operator who turned
|
||||
// auditing off). Serving it from the same code path keeps the dashboard's
|
||||
// "total" card identical whether or not a period is selected.
|
||||
if p == PeriodAll {
|
||||
snap := s.Snapshot(firstScreenRecords, key)
|
||||
if tot, ok := snap["total"].(Stat); ok {
|
||||
out.Total = tot
|
||||
}
|
||||
out.ByKey, _ = snap["by_key"].([]StatsRow)
|
||||
out.Models, _ = snap["by_model"].([]StatsRow)
|
||||
out.Srcs, _ = snap["by_source"].([]StatsRow)
|
||||
out.Status, _ = snap["by_status"].([]agrRow)
|
||||
// replay_partial is the same "these numbers came from a bounded
|
||||
// tail" caveat, carried through under this view's own name.
|
||||
out.Truncated, _ = snap["replay_partial"].(bool)
|
||||
return out
|
||||
}
|
||||
|
||||
// A bounded window is aggregated from the audit files, because the
|
||||
// in-memory aggregates are lifetime totals and the ring buffer holds only
|
||||
// maxRecs records (500 by default). Reading the ring would silently
|
||||
// under-report any period longer than the last few hundred requests.
|
||||
total := Stat{}
|
||||
byKey := map[string]*Stat{}
|
||||
byModel := map[string]*Stat{}
|
||||
bySrc := map[string]*Stat{}
|
||||
byStatus := map[string]*Stat{}
|
||||
buckets := map[string]*Stat{}
|
||||
|
||||
var seen int
|
||||
err := s.StreamAuditRecords(from, 0, key, func(r Req) error {
|
||||
seen++
|
||||
incStatus(&total, "", r)
|
||||
inc(byKey, r.Key, r)
|
||||
if r.Model != "" {
|
||||
inc(byModel, r.Model, r)
|
||||
}
|
||||
inc(bySrc, r.Source, r)
|
||||
if r.Status != 0 {
|
||||
inc(byStatus, strconv.Itoa(r.Status), r)
|
||||
}
|
||||
k := bucketKey(p, r.Time)
|
||||
b := buckets[k]
|
||||
if b == nil {
|
||||
b = &Stat{}
|
||||
buckets[k] = b
|
||||
}
|
||||
incStatus(b, k, r)
|
||||
return nil
|
||||
})
|
||||
|
||||
out.Total = total
|
||||
out.ByKey = rows(byKey)
|
||||
out.Models = rows(byModel)
|
||||
out.Srcs = rows(bySrc)
|
||||
bs := make([]agrRow, 0, len(byStatus))
|
||||
for code, st := range byStatus {
|
||||
bs = append(bs, agrRow{Name: code, Stat: *st})
|
||||
}
|
||||
sort.Slice(bs, func(i, j int) bool {
|
||||
ci, _ := strconv.Atoi(bs[i].Name)
|
||||
cj, _ := strconv.Atoi(bs[j].Name)
|
||||
return ci < cj
|
||||
})
|
||||
out.Status = bs
|
||||
|
||||
ks := make([]string, 0, len(buckets))
|
||||
for k := range buckets {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
// Chronological, string-sorted: "2006-01-02T15" and "2006-01-02" both
|
||||
// sort lexicographically in time order, so no date parsing is needed.
|
||||
sort.Strings(ks)
|
||||
out.Bucket = make([]PeriodBucket, 0, len(ks))
|
||||
for _, k := range ks {
|
||||
out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *buckets[k]})
|
||||
}
|
||||
|
||||
// The window is only "complete" if the audit walk actually reached back
|
||||
// far enough to cover it. Two ways it cannot:
|
||||
//
|
||||
// 1. The walk found nothing at all in a window that certainly had
|
||||
// traffic, because the files holding it rotated away.
|
||||
// 2. The walk errored part-way (I/O), leaving a partial total.
|
||||
//
|
||||
// Case 2 is reported from err; case 1 from seen == 0 combined with the
|
||||
// caller having asked for a bounded window. It is deliberately
|
||||
// conservative: a genuinely empty hour is rare enough that flagging it as
|
||||
// possibly-truncated costs one tooltip, whereas silently under-reporting a
|
||||
// month because rotation ate it is a wrong number with no indication.
|
||||
if err != nil || (seen == 0 && p != PeriodAll) {
|
||||
out.Truncated = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// periodFromQuery parses the ?period= parameter. An empty value means "all" so
|
||||
// that existing callers of /api/stats keep seeing exactly what they saw.
|
||||
// A malformed value is rejected by the caller (ValidPeriod) rather than
|
||||
// defaulting, so a typo in a bookmarked URL surfaces as an error instead of
|
||||
// silently switching the operator to lifetime totals.
|
||||
func periodFromQuery(q map[string][]string) Period {
|
||||
v := strings.ToLower(strings.TrimSpace(firstQuery(q, "period")))
|
||||
if v == "" {
|
||||
return PeriodAll
|
||||
}
|
||||
return Period(v)
|
||||
}
|
||||
|
||||
func firstQuery(q map[string][]string, key string) string {
|
||||
if vs, ok := q[key]; ok && len(vs) > 0 {
|
||||
return vs[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
321
internal/gateway/stats_period_test.go
Normal file
321
internal/gateway/stats_period_test.go
Normal file
@ -0,0 +1,321 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// auditPeriod writes records straight into an audit file, which is what the
|
||||
// period aggregator reads. Going through Record() would also rotate/prune the
|
||||
// file; here the file shape is the thing under test.
|
||||
func auditPeriod(t *testing.T, path string, recs ...Req) {
|
||||
t.Helper()
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create audit file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
for _, r := range recs {
|
||||
if r.Time == 0 {
|
||||
t.Fatal("test record needs an explicit timestamp")
|
||||
}
|
||||
if r.Type == "" {
|
||||
r.Type = "chat"
|
||||
}
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal record: %v", err)
|
||||
}
|
||||
if _, err := f.Write(append(b, '\n')); err != nil {
|
||||
t.Fatalf("write record: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func periodStats(t *testing.T, auditPath string) *Stats {
|
||||
t.Helper()
|
||||
s := NewStats(10)
|
||||
s.mu.Lock()
|
||||
s.auditPath = auditPath
|
||||
s.mu.Unlock()
|
||||
return s
|
||||
}
|
||||
|
||||
// TestPeriodWindowBoundaries is the boundary contract: a day window covers
|
||||
// exactly today-from-midnight, a week window starts Monday (not Sunday — the
|
||||
// Go Weekday() trap), and a month window starts on the 1st. All UTC.
|
||||
func TestPeriodWindowBoundaries(t *testing.T) {
|
||||
// Wednesday 2026-03-11 15:30 UTC.
|
||||
now := time.Date(2026, 3, 11, 15, 30, 0, 0, time.UTC)
|
||||
day := time.Date(2026, 3, 11, 0, 0, 0, 0, time.UTC)
|
||||
if got := time.UnixMilli(periodStart(PeriodDay, now)).UTC(); !got.Equal(day) {
|
||||
t.Errorf("day window starts %s, want %s", got, day)
|
||||
}
|
||||
// Wednesday minus 2 days = Monday the 9th.
|
||||
monday := time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC)
|
||||
if got := time.UnixMilli(periodStart(PeriodWeek, now)).UTC(); !got.Equal(monday) {
|
||||
t.Errorf("week window starts %s, want Monday %s", got, monday)
|
||||
}
|
||||
// Same instant on a Sunday must still start on the PRECEDING Monday,
|
||||
// never on the Sunday itself. This is the off-by-one that
|
||||
// int(now.Weekday()) would introduce (Sunday=0 → no shift).
|
||||
sun := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) // Sunday
|
||||
prevMon := time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC)
|
||||
if got := time.UnixMilli(periodStart(PeriodWeek, sun)).UTC(); !got.Equal(prevMon) {
|
||||
t.Errorf("Sunday week window starts %s, want the Monday before it %s", got, prevMon)
|
||||
}
|
||||
first := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
if got := time.UnixMilli(periodStart(PeriodMonth, now)).UTC(); !got.Equal(first) {
|
||||
t.Errorf("month window starts %s, want %s", got, first)
|
||||
}
|
||||
if got := periodStart(PeriodAll, now); got != 0 {
|
||||
t.Errorf("all window from = %d, want 0 (unbounded)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeriodAggregationExcludesOlderRecords is the reason the aggregator reads
|
||||
// the audit file: a record outside the window must not be counted, even though
|
||||
// it sits in the very same file, right next to records that are.
|
||||
func TestPeriodAggregationExcludesOlderRecords(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
now := time.Date(2026, 3, 11, 15, 0, 0, 0, time.UTC)
|
||||
h := func(n int) int64 { return now.Add(-time.Duration(n) * time.Hour).UnixMilli() }
|
||||
|
||||
auditPeriod(t, path,
|
||||
// inside today
|
||||
Req{Time: h(1), Model: "m", Source: "s", Prompt: 100, Compl: 10, LatMs: 5, OK: true},
|
||||
Req{Time: h(2), Model: "m", Source: "s", Prompt: 200, Compl: 20, LatMs: 7, OK: true},
|
||||
// outside today (yesterday) but inside the week
|
||||
Req{Time: now.AddDate(0, 0, -1).UnixMilli(), Model: "old", Source: "s", Prompt: 999, Compl: 99, OK: true},
|
||||
)
|
||||
s := periodStats(t, path)
|
||||
|
||||
day := s.PeriodSnapshot(PeriodDay, "", now)
|
||||
if day.Total.Reqs != 2 {
|
||||
t.Errorf("day window counted %d requests, want 2 (yesterday's record must be excluded)", day.Total.Reqs)
|
||||
}
|
||||
if day.Total.Prompt != 300 {
|
||||
t.Errorf("day prompt tokens = %d, want 300", day.Total.Prompt)
|
||||
}
|
||||
|
||||
week := s.PeriodSnapshot(PeriodWeek, "", now)
|
||||
if week.Total.Reqs != 3 {
|
||||
t.Errorf("week window counted %d requests, want 3", week.Total.Reqs)
|
||||
}
|
||||
if week.Total.Prompt != 1299 {
|
||||
t.Errorf("week prompt tokens = %d, want 1299", week.Total.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeriodDimensionsAggregateIndependently checks the by_* rows actually
|
||||
// split by their own key rather than all collapsing into one row.
|
||||
func TestPeriodDimensionsAggregateIndependently(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
|
||||
auditPeriod(t, path,
|
||||
Req{Time: now.UnixMilli(), Key: "k1", Model: "alpha", Source: "s1", Prompt: 10, OK: true},
|
||||
Req{Time: now.UnixMilli(), Key: "k1", Model: "beta", Source: "s2", Prompt: 20, OK: true},
|
||||
Req{Time: now.UnixMilli(), Key: "k2", Model: "alpha", Source: "s1", Prompt: 30, OK: true},
|
||||
)
|
||||
s := periodStats(t, path)
|
||||
got := s.PeriodSnapshot(PeriodDay, "", now)
|
||||
|
||||
if len(got.Models) != 2 {
|
||||
t.Fatalf("by_model has %d rows, want 2: %+v", len(got.Models), got.Models)
|
||||
}
|
||||
// alpha = 10 + 30
|
||||
for _, r := range got.Models {
|
||||
if r.Name == "alpha" && r.Prompt != 40 {
|
||||
t.Errorf("alpha prompt = %d, want 40", r.Prompt)
|
||||
}
|
||||
}
|
||||
if len(got.Srcs) != 2 {
|
||||
t.Errorf("by_source has %d rows, want 2: %+v", len(got.Srcs), got.Srcs)
|
||||
}
|
||||
if len(got.ByKey) != 2 {
|
||||
t.Errorf("by_key has %d rows, want 2: %+v", len(got.ByKey), got.ByKey)
|
||||
}
|
||||
// The rows must add up to the total, or the dashboard shows a total that
|
||||
// disagrees with its own table.
|
||||
var sum int64
|
||||
for _, r := range got.Models {
|
||||
sum += r.Prompt
|
||||
}
|
||||
if sum != got.Total.Prompt {
|
||||
t.Errorf("by_model prompts sum to %d but total is %d — the table would contradict the KPI", sum, got.Total.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeriodDayBucketsAreHourlyAndOrdered pins the timeline grain and its
|
||||
// order. Day = hourly (a spike must be visible inside "today"); week/month =
|
||||
// daily. Sorted chronologically, because a chart fed unsorted buckets draws
|
||||
// nonsense.
|
||||
func TestPeriodDayBucketsAreHourlyAndOrdered(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
now := time.Date(2026, 3, 11, 15, 0, 0, 0, time.UTC)
|
||||
// Deliberately out of order in the file.
|
||||
auditPeriod(t, path,
|
||||
Req{Time: now.Add(-2 * time.Hour).UnixMilli(), Model: "m", Prompt: 1, OK: true},
|
||||
Req{Time: now.Add(-4 * time.Hour).UnixMilli(), Model: "m", Prompt: 1, OK: true},
|
||||
Req{Time: now.UnixMilli(), Model: "m", Prompt: 1, OK: true},
|
||||
)
|
||||
s := periodStats(t, path)
|
||||
got := s.PeriodSnapshot(PeriodDay, "", now)
|
||||
if len(got.Bucket) != 3 {
|
||||
t.Fatalf("day buckets = %d, want 3 (hourly): %+v", len(got.Bucket), got.Bucket)
|
||||
}
|
||||
for i := 1; i < len(got.Bucket); i++ {
|
||||
if got.Bucket[i-1].Bucket >= got.Bucket[i].Bucket {
|
||||
t.Errorf("day buckets not chronological at %d: %q >= %q",
|
||||
i, got.Bucket[i-1].Bucket, got.Bucket[i].Bucket)
|
||||
}
|
||||
}
|
||||
// Weekly grain is daily, so the same three records collapse to one day.
|
||||
week := s.PeriodSnapshot(PeriodWeek, "", now)
|
||||
if len(week.Bucket) != 1 {
|
||||
t.Errorf("week buckets = %d, want 1 (daily grain): %+v", len(week.Bucket), week.Bucket)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeriodKeyFilterIsolatesKeys guards the multi-tenant boundary: asking for
|
||||
// one key must never return another key's rows in any dimension.
|
||||
func TestPeriodKeyFilterIsolatesKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
|
||||
auditPeriod(t, path,
|
||||
Req{Time: now.UnixMilli(), Key: "alice", Model: "m", Source: "s", Prompt: 10, OK: true},
|
||||
Req{Time: now.UnixMilli(), Key: "bob", Model: "m", Source: "s", Prompt: 999, OK: true},
|
||||
)
|
||||
s := periodStats(t, path)
|
||||
got := s.PeriodSnapshot(PeriodDay, "alice", now)
|
||||
if got.Total.Reqs != 1 || got.Total.Prompt != 10 {
|
||||
t.Errorf("alice's window = %d reqs / %d prompt, want 1 / 10 — another key leaked in",
|
||||
got.Total.Reqs, got.Total.Prompt)
|
||||
}
|
||||
if len(got.Srcs) != 1 || got.Srcs[0].Prompt != 10 {
|
||||
t.Errorf("by_source leaked: %+v", got.Srcs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeriodAllUsesLifetimeAggregates checks "all" still answers from the
|
||||
// in-memory aggregates (no audit file needed) and carries the same rows the
|
||||
// dashboard already renders.
|
||||
func TestPeriodAllUsesLifetimeAggregates(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
|
||||
s.Record(Req{Time: now.AddDate(0, 0, -40).UnixMilli(), Key: "k", Model: "m", Source: "s", Prompt: 7, OK: true})
|
||||
s.Record(Req{Time: now.UnixMilli(), Key: "k", Model: "m", Source: "s", Prompt: 3, OK: true})
|
||||
|
||||
got := s.PeriodSnapshot(PeriodAll, "", now)
|
||||
if got.Total.Reqs != 2 || got.Total.Prompt != 10 {
|
||||
t.Errorf("all view = %d reqs / %d prompt, want 2 / 10 (lifetime)", got.Total.Reqs, got.Total.Prompt)
|
||||
}
|
||||
if len(got.Models) != 1 || got.Models[0].Prompt != 10 {
|
||||
t.Errorf("all view by_model = %+v, want one row with 10 prompt", got.Models)
|
||||
}
|
||||
// A lifetime total has no timeline to draw; buckets must stay empty
|
||||
// rather than inventing 500 hourly points.
|
||||
if len(got.Bucket) != 0 {
|
||||
t.Errorf("all view produced %d buckets, want 0", len(got.Bucket))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeriodEmptyWindowFlagsTruncated covers the "rotated away" case: a bounded
|
||||
// window with nothing to show may mean "quiet day" or "the files are gone".
|
||||
// The aggregator must not report a confidently wrong zero.
|
||||
func TestPeriodEmptyWindowFlagsTruncated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := periodStats(t, filepath.Join(dir, "audit.jsonl")) // file does not exist
|
||||
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
|
||||
got := s.PeriodSnapshot(PeriodDay, "", now)
|
||||
if got.Total.Reqs != 0 {
|
||||
t.Errorf("empty window reqs = %d, want 0", got.Total.Reqs)
|
||||
}
|
||||
if !got.Truncated {
|
||||
t.Error("★ an empty bounded window must be flagged truncated — otherwise a " +
|
||||
"rotated-away week reads as 'no traffic that week'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidPeriodRejectsUnknown guards the deliberate strictness: a typo must
|
||||
// be an error, not a silent fallback to lifetime totals.
|
||||
func TestValidPeriodRejectsUnknown(t *testing.T) {
|
||||
for _, ok := range []Period{PeriodDay, PeriodWeek, PeriodMonth, PeriodAll} {
|
||||
if !ValidPeriod(ok) {
|
||||
t.Errorf("ValidPeriod(%q) = false, want true", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []Period{"", "year", "hour", "today", "DAY "} {
|
||||
if ValidPeriod(bad) {
|
||||
t.Errorf("ValidPeriod(%q) = true, want false", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeriodStatusDimensionCountsFailures covers by_status, which nothing else
|
||||
// exercised — an aggregation branch with no test is exactly how a period view
|
||||
// silently loses the failure pie. M8 (disabling the status dimension) is
|
||||
// caught only because this test exists.
|
||||
func TestPeriodStatusDimensionCountsFailures(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
|
||||
auditPeriod(t, path,
|
||||
Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 200, OK: true, Prompt: 5},
|
||||
Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 502, OK: false, Prompt: 1},
|
||||
Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 502, OK: false, Prompt: 1},
|
||||
Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 429, OK: false, Prompt: 1},
|
||||
)
|
||||
s := periodStats(t, path)
|
||||
got := s.PeriodSnapshot(PeriodDay, "", now)
|
||||
|
||||
byCode := map[string]int64{}
|
||||
for _, r := range got.Status {
|
||||
byCode[r.Name] = r.Reqs
|
||||
}
|
||||
if len(got.Status) != 3 {
|
||||
t.Fatalf("by_status has %d rows, want 3 (200/502/429): %+v", len(got.Status), got.Status)
|
||||
}
|
||||
if byCode["200"] != 1 || byCode["502"] != 2 || byCode["429"] != 1 {
|
||||
t.Errorf("by_status counts = %v, want 200:1 502:2 429:1", byCode)
|
||||
}
|
||||
// Status codes must sort numerically: "429" before "502" lexically would
|
||||
// render the failure legend in the wrong order.
|
||||
if got.Status[0].Name != "200" || got.Status[1].Name != "429" || got.Status[2].Name != "502" {
|
||||
t.Errorf("by_status not numerically sorted: %s, %s, %s",
|
||||
got.Status[0].Name, got.Status[1].Name, got.Status[2].Name)
|
||||
}
|
||||
// The failure rows must agree with the OK/Err split on the total, or the
|
||||
// pie and the KPI row contradict each other.
|
||||
if got.Total.OK != 1 || got.Total.Err != 3 {
|
||||
t.Errorf("total ok/err = %d/%d, want 1/3", got.Total.OK, got.Total.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeriodFromQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
query string
|
||||
want Period
|
||||
}{
|
||||
{"", PeriodAll}, // no selector → unchanged legacy behaviour
|
||||
{"?period=day", PeriodDay},
|
||||
{"?period=WEEK", PeriodWeek},
|
||||
{"?period=month", PeriodMonth},
|
||||
{"?period=all", PeriodAll},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/stats"+c.query, nil)
|
||||
if got := periodFromQuery(r.URL.Query()); got != c.want {
|
||||
t.Errorf("periodFromQuery(%q) = %q, want %q", c.query, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -615,6 +615,21 @@
|
||||
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" /></svg
|
||||
><span data-i="navAdapters">适配器</span>
|
||||
</button>
|
||||
<button class="sb-i" data-tab="plugins">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M10 2v4M14 2v4M4 8h16M5 6h14a1 1 0 0 1 1 1v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a1 1 0 0 1 1-1z"
|
||||
/>
|
||||
<path d="M10 13h4" /></svg
|
||||
><span data-i="navPlugins">插件</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="sb-foot">
|
||||
<button id="btn-lang">
|
||||
@ -767,6 +782,7 @@
|
||||
<div id="tab-sort" class="tab-pane hidden"></div>
|
||||
<div id="tab-sources" class="tab-pane hidden"></div>
|
||||
<div id="tab-adapters" class="tab-pane hidden"></div>
|
||||
<div id="tab-plugins" class="tab-pane hidden"></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@ -782,6 +798,25 @@
|
||||
navChat: "对话",
|
||||
navSources: "源",
|
||||
navAdapters: "适配器",
|
||||
navPlugins: "插件",
|
||||
plTitle: "插件",
|
||||
plState: "状态",
|
||||
plStages: "阶段",
|
||||
plActive: "启用中",
|
||||
plDisabled: "已禁用",
|
||||
plBroken: "加载失败",
|
||||
plBuiltin: "内置",
|
||||
plHooks: "个阶段",
|
||||
plEmpty: "插件目录为空",
|
||||
plNoDir: "未配置 plugin_dir,插件功能未启用",
|
||||
plHookErr: "以下阶段的插件钩子报错(插件故障不会影响转发,但功能会缺失):",
|
||||
plDir: "插件目录:",
|
||||
plInstall: "安装插件",
|
||||
plInstallBtn: "安装 / 覆盖",
|
||||
plEdit: "编辑",
|
||||
plEnable: "启用",
|
||||
plDisable: "禁用",
|
||||
plRemove: "删除",
|
||||
navSort: "优先级",
|
||||
navKeys: "密钥",
|
||||
keysHint:
|
||||
@ -904,6 +939,12 @@
|
||||
mConc: "并发上限",
|
||||
mRPM: "RPM 限速 (0=不限)",
|
||||
mTemp: "温度",
|
||||
mKeyEnv: "Key 环境变量",
|
||||
mKeyEnvPh: "优先于 API Key,不落盘明文",
|
||||
mProxy: "代理 URL",
|
||||
mProxyPh: "如 http://127.0.0.1:7890,留空直连",
|
||||
mTimeout: "请求超时",
|
||||
mTimeoutPh: "如 300s,留空用默认 120s",
|
||||
mModels: "模型列表",
|
||||
mAddModel: "+ 模型",
|
||||
mMeta: "Meta",
|
||||
@ -997,6 +1038,13 @@
|
||||
recsEnd: "已到最早记录",
|
||||
recsRotated: "审计日志已轮转,已从最新记录重新加载",
|
||||
recsNewest: "回到最新",
|
||||
periodLabel: "统计周期",
|
||||
periodDay: "今日",
|
||||
periodWeek: "本周",
|
||||
periodMonth: "本月",
|
||||
periodAll: "全部",
|
||||
periodTruncated:
|
||||
"统计周期可能不完整(审计日志已轮转),数值为下限。",
|
||||
recsPartial:
|
||||
"部分审计日志无法读取,统计可能不完整;完整历史请导出 CSV",
|
||||
thTokens: "Tokens",
|
||||
@ -1009,6 +1057,25 @@
|
||||
navChat: "Chat",
|
||||
navSources: "Sources",
|
||||
navAdapters: "Adapters",
|
||||
navPlugins: "Plugins",
|
||||
plTitle: "Plugins",
|
||||
plState: "State",
|
||||
plStages: "Stages",
|
||||
plActive: "Active",
|
||||
plDisabled: "Disabled",
|
||||
plBroken: "Failed to load",
|
||||
plBuiltin: "Built-in",
|
||||
plHooks: "stages",
|
||||
plEmpty: "The plugin directory is empty",
|
||||
plNoDir: "plugin_dir is not configured; plugins are disabled",
|
||||
plHookErr: "Plugin hooks failed on these stages (a broken plugin never blocks forwarding, it just stops providing its feature):",
|
||||
plDir: "Plugin directory:",
|
||||
plInstall: "Install a plugin",
|
||||
plInstallBtn: "Install / replace",
|
||||
plEdit: "Edit",
|
||||
plEnable: "Enable",
|
||||
plDisable: "Disable",
|
||||
plRemove: "Remove",
|
||||
navSort: "Priority",
|
||||
navKeys: "Keys",
|
||||
keysHint:
|
||||
@ -1135,6 +1202,12 @@
|
||||
mConc: "Max concurrency",
|
||||
mRPM: "RPM limit (0 = unlimited)",
|
||||
mTemp: "Temperature",
|
||||
mKeyEnv: "Key env var",
|
||||
mKeyEnvPh: "Takes precedence over API Key; nothing written to disk",
|
||||
mProxy: "Proxy URL",
|
||||
mProxyPh: "e.g. http://127.0.0.1:7890 — empty means direct",
|
||||
mTimeout: "Request timeout",
|
||||
mTimeoutPh: "e.g. 300s — empty uses the 120s default",
|
||||
mModels: "Models",
|
||||
mAddModel: "+ model",
|
||||
mMeta: "Meta",
|
||||
@ -1228,6 +1301,13 @@
|
||||
recsEnd: "Reached the oldest record",
|
||||
recsRotated: "The audit log rotated; reloaded from the newest record",
|
||||
recsNewest: "Back to newest",
|
||||
periodLabel: "Reporting period",
|
||||
periodDay: "Today",
|
||||
periodWeek: "This week",
|
||||
periodMonth: "This month",
|
||||
periodAll: "All time",
|
||||
periodTruncated:
|
||||
"This window may be incomplete (audit log rotated); figures are a lower bound.",
|
||||
recsPartial:
|
||||
"Some audit files could not be read, so these totals may be incomplete; export CSV for the full history",
|
||||
thTokens: "Tokens",
|
||||
@ -1291,6 +1371,7 @@
|
||||
localStorage.setItem("llms-proxy.lang", LANG);
|
||||
applyI18n();
|
||||
updateBreadcrumb(lastTab);
|
||||
notifyPluginLang();
|
||||
refresh(lastTab);
|
||||
};
|
||||
document.getElementById("btn-logout").onclick = () => {
|
||||
@ -1314,6 +1395,18 @@
|
||||
const m = $("#btn-menu");
|
||||
if (m) m.onclick = () => $("#sidebar").classList.toggle("open");
|
||||
}
|
||||
// Single source for the tab list. It used to be a literal duplicated in
|
||||
// goTab, in refresh() and in the admin-only hide pass — three places to
|
||||
// keep in sync, and adding a tab meant finding all three. A plugin page
|
||||
// that is routed but never shown is exactly the kind of silent gap that
|
||||
// survives review.
|
||||
const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"];
|
||||
// Declared HERE, with var, because goTab() below reads it. It used to be a
|
||||
// `const` further down the file next to the other plugin state, which put
|
||||
// goTab's read inside the temporal dead zone: the first click on a plugin
|
||||
// page would have thrown a ReferenceError. var hoists, so the read sees
|
||||
// the (still empty) Set and injectPluginUI fills it moments later.
|
||||
var PLUGIN_PAGES = new Set();
|
||||
document.querySelectorAll("nav button.sb-i").forEach((b) => {
|
||||
b.onclick = () => goTab(b.dataset.tab);
|
||||
});
|
||||
@ -1325,9 +1418,22 @@
|
||||
document
|
||||
.querySelectorAll(".sb-i")
|
||||
.forEach((x) => x.classList.toggle("active", x.dataset.tab === name));
|
||||
["status", "chat", "keys", "sort", "sources", "adapters"].forEach(
|
||||
(tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name),
|
||||
);
|
||||
// TABS plus every plugin-contributed page.
|
||||
//
|
||||
// Iterating only TABS left a plugin page permanently unreachable: the
|
||||
// pane exists and is filled (the data was right there in the DOM) but
|
||||
// its `hidden` class was never removed, so clicking the sidebar entry
|
||||
// did nothing visible. The symptom reads as "the page is blank" because
|
||||
// the content is present in the DOM and only invisible.
|
||||
//
|
||||
// PLUGIN_PAGES is consulted here rather than relying on the page's own
|
||||
// script: the pane's visibility is the HOST's job, and a plugin that
|
||||
// forgot to unhide itself must still be reachable.
|
||||
TABS.forEach((tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name));
|
||||
PLUGIN_PAGES.forEach((pn) => {
|
||||
const el = $("#tab-" + pn);
|
||||
if (el) el.classList.toggle("hidden", pn !== name);
|
||||
});
|
||||
updateBreadcrumb(name);
|
||||
const pane = $("#tab-" + name);
|
||||
if (pane) {
|
||||
@ -1466,6 +1572,18 @@
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
};
|
||||
let statsKeyF = ""; // active key filter for records ('' = all)
|
||||
// Usage reporting window. "all" keeps the legacy since-start totals;
|
||||
// day/week/month are calendar windows (UTC) aggregated from the audit
|
||||
// files server-side. Persisted so a reload keeps the operator's
|
||||
// chosen scale instead of silently snapping back to lifetime totals.
|
||||
let statsPeriod = (() => {
|
||||
try {
|
||||
const v = localStorage.getItem("gw_stats_period");
|
||||
return ["day", "week", "month", "all"].includes(v) ? v : "all";
|
||||
} catch (e) {
|
||||
return "all";
|
||||
}
|
||||
})();
|
||||
|
||||
/* ---------- records: on-demand paging ----------
|
||||
* The records table holds only what is on screen. The first screen comes
|
||||
@ -1591,6 +1709,17 @@
|
||||
return;
|
||||
}
|
||||
pane.innerHTML = `
|
||||
<div class="filter-line" style="margin:0 0 12px">
|
||||
<span class="muted">${t("periodLabel")}</span>
|
||||
<select id="stat-period" onchange="setStatsPeriod(this.value)">
|
||||
<option value="day">${t("periodDay")}</option>
|
||||
<option value="week">${t("periodWeek")}</option>
|
||||
<option value="month">${t("periodMonth")}</option>
|
||||
<option value="all">${t("periodAll")}</option>
|
||||
</select>
|
||||
<span class="grow"></span>
|
||||
<span id="period-note"></span>
|
||||
</div>
|
||||
<div class="kpis" id="kpi-row"><div class="kpi-skeletons" aria-hidden="true">${Array(
|
||||
5,
|
||||
)
|
||||
@ -1747,6 +1876,24 @@
|
||||
(statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : "");
|
||||
(btn ? btn.closest("#modal-wrap") : null) || closeTopModal();
|
||||
}
|
||||
// setStatsPeriod switches the reporting window. It resets the records
|
||||
// pager rather than keeping the old cursor: the records table is fed
|
||||
// from the lifetime tail regardless of window, and leaving a stale
|
||||
// "next page" cursor from the previous window makes the table and the
|
||||
// KPIs disagree about what is being shown.
|
||||
function setStatsPeriod(v) {
|
||||
if (!["day", "week", "month", "all"].includes(v)) return;
|
||||
statsPeriod = v;
|
||||
try {
|
||||
localStorage.setItem("gw_stats_period", v);
|
||||
} catch (e) {
|
||||
/* private mode: the selector still works for this session */
|
||||
}
|
||||
const sel = $("#stat-period");
|
||||
if (sel) sel.value = v;
|
||||
resetRecords();
|
||||
paintStats();
|
||||
}
|
||||
async function paintStats() {
|
||||
try {
|
||||
// limit=0 -> the server ships one screen of records; the rest is paged
|
||||
@ -1754,6 +1901,8 @@
|
||||
const q =
|
||||
"/api/stats?limit=" +
|
||||
RECS_PAGE +
|
||||
"&period=" +
|
||||
encodeURIComponent(statsPeriod) +
|
||||
(statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : "");
|
||||
const st = await api(q);
|
||||
const tot = st.total || {};
|
||||
@ -1826,6 +1975,15 @@
|
||||
rpart.innerHTML = st.replay_partial
|
||||
? `<span class="muted" title="${escAttr(t("recsPartial"))}">ⓘ ${esc(t("recsPartial"))}</span>`
|
||||
: "";
|
||||
// Period windows come off the audit files, which rotate. If the
|
||||
// window may be incomplete the numbers are a lower bound, and saying
|
||||
// so next to them is the whole point — a confidently wrong zero for a
|
||||
// rotated-away week is indistinguishable from a quiet week.
|
||||
const pnote = $("#period-note");
|
||||
if (pnote)
|
||||
pnote.innerHTML = st.truncated
|
||||
? `<span class="muted" title="${escAttr(t("periodTruncated"))}">ⓘ ${esc(t("periodTruncated"))}</span>`
|
||||
: "";
|
||||
const rex = $("#rec-exit");
|
||||
if (rex)
|
||||
rex.innerHTML = statsKeyF
|
||||
@ -2866,7 +3024,11 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>${t("mRPM")}</label><input id="s-rpm" type="number" min="0" placeholder="0 = 不限" value="${s.rpm || 0}"></div>
|
||||
<div></div>
|
||||
<div><label>${t("mProxy")}</label><input id="s-proxy" value="${escAttr(s.proxy_url || "")}" placeholder="${t("mProxyPh")}"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>${t("mKeyEnv")}</label><input id="s-keyenv" value="${escAttr(s.api_key_env || "")}" placeholder="${t("mKeyEnvPh")}"></div>
|
||||
<div><label>${t("mTimeout")}</label><input id="s-timeout" value="${escAttr(timeoutText(s.timeout))}" placeholder="${t("mTimeoutPh")}"></div>
|
||||
</div>
|
||||
<label>${t("mModels")}</label>
|
||||
<div id="s-models"></div>
|
||||
@ -2896,6 +3058,21 @@
|
||||
<button class="ghost del small" onclick="this.closest('.model-row').remove()">×</button>
|
||||
</div>`;
|
||||
}
|
||||
// timeoutText renders a stored Go duration for the timeout input.
|
||||
//
|
||||
// The API returns timeout as a json:"-"
|
||||
// field (time.Duration), so the JSON number it arrives as is NANOSECONDS,
|
||||
// not seconds. Showing the raw number would offer "300000000000" in a box
|
||||
// that expects "300s"; an unset timeout (0) must show as empty so the
|
||||
// box reads as "default" rather than "0s".
|
||||
function timeoutText(v) {
|
||||
const n = typeof v === "string" ? parseFloat(v) : v;
|
||||
if (!n || !isFinite(n) || n <= 0) return "";
|
||||
const secs = Math.round(n / 1e9);
|
||||
if (secs % 3600 === 0) return secs / 3600 + "h";
|
||||
if (secs % 60 === 0) return secs / 60 + "m";
|
||||
return secs + "s";
|
||||
}
|
||||
function addModelRow() {
|
||||
const div = $("#s-models");
|
||||
div.insertAdjacentHTML(
|
||||
@ -2930,6 +3107,14 @@
|
||||
temperature: parseFloat($("#s-temp").value) || 0,
|
||||
models,
|
||||
meta,
|
||||
// These four are always SENT, so the server can tell "the user left
|
||||
// this empty on purpose" from "this form does not know about it".
|
||||
// Omitting them made the server keep the stored value, which is right
|
||||
// for an API client with a partial payload but wrong here: emptying
|
||||
// the proxy box would have kept the old proxy forever.
|
||||
proxy_url: $("#s-proxy").value.trim(),
|
||||
api_key_env: $("#s-keyenv").value.trim(),
|
||||
timeout: $("#s-timeout").value.trim(),
|
||||
};
|
||||
btn.disabled = true;
|
||||
try {
|
||||
@ -4064,6 +4249,177 @@
|
||||
}
|
||||
|
||||
/* ---------- adapters tab ---------- */
|
||||
// ---- plugin management ----
|
||||
// Install / enable / disable / remove / edit. The list comes from
|
||||
// on_disk rather than the loaded set so a plugin that FAILED to load
|
||||
// still appears, with its error — otherwise a syntax error looks
|
||||
// identical to "the plugin is not there".
|
||||
async function renderPlugins() {
|
||||
let j;
|
||||
try {
|
||||
j = await api("/api/plugins");
|
||||
} catch (e) {
|
||||
$("#tab-plugins").innerHTML =
|
||||
`<div class="card"><h2>${t("plTitle")}</h2><div class="empty">${esc(t("plNoDir"))}</div></div>`;
|
||||
return;
|
||||
}
|
||||
if (!j.plugin_dir) {
|
||||
$("#tab-plugins").innerHTML = `<div class="card"><h2>${t("plTitle")}</h2>
|
||||
<div class="empty">${t("plNoDir")}</div>
|
||||
<p class="muted">plugin_dir: /etc/llmsproxy/plugins</p></div>`;
|
||||
return;
|
||||
}
|
||||
const errs = j.hook_errors || {};
|
||||
const rows = (j.on_disk || [])
|
||||
.map((p) => {
|
||||
const state = !p.loaded
|
||||
? `<span class="tag tag-red">${t("plBroken")}</span>`
|
||||
: p.disabled
|
||||
? `<span class="tag">${t("plDisabled")}</span>`
|
||||
: `<span class="tag tag-green">${t("plActive")}</span>`;
|
||||
const hooks = (p.hooks || 0)
|
||||
? `<span class="muted">${p.hooks} ${t("plHooks")}</span>`
|
||||
: `<span class="muted">—</span>`;
|
||||
const builtin = p.builtin
|
||||
? ` <span class="tag tag-blue">${t("plBuiltin")}</span>`
|
||||
: "";
|
||||
const toggle = p.loaded
|
||||
? `<button class="small" onclick="togglePlugin('${escAttr(p.name)}',${p.disabled ? "true" : "false"})">${
|
||||
p.disabled ? t("plEnable") : t("plDisable")
|
||||
}</button>`
|
||||
: "";
|
||||
const err = p.error
|
||||
? `<div class="muted" style="color:var(--danger)">${esc(String(p.error).slice(0, 200))}</div>`
|
||||
: "";
|
||||
return `<tr>
|
||||
<td><b>${esc(p.name)}</b>${builtin}<div class="muted">${esc(p.description || p.version || "")}</div>${err}</td>
|
||||
<td>${state}</td>
|
||||
<td>${hooks}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="small" onclick="editPlugin('${escAttr(p.name)}')">${t("plEdit")}</button>
|
||||
${toggle}
|
||||
<button class="danger small" onclick="delPlugin('${escAttr(p.name)}')">${t("plRemove")}</button>
|
||||
</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// Hook errors: a plugin that throws leaves no visible trace anywhere
|
||||
// else, so without this the symptom is "the feature silently doesn't
|
||||
// work".
|
||||
const errList = Object.entries(errs)
|
||||
.map(
|
||||
([stage, e]) =>
|
||||
`<li><code>${esc(stage)}</code> × ${e.count} — ${esc(e.last_error || "")}</li>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
$("#tab-plugins").innerHTML = `
|
||||
<div class="card"><h2>${t("plTitle")} (${(j.on_disk || []).length})</h2>
|
||||
<div class="tbl-wrap"><table><tr>
|
||||
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th></th>
|
||||
</tr>${rows || `<tr><td colspan="4" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
|
||||
${errList ? `<p class="muted" style="color:var(--danger)">${t("plHookErr")}</p><ul style="font-size:12px">${errList}</ul>` : ""}
|
||||
<p class="muted">${t("plDir")} <code>${esc(j.plugin_dir)}</code></p>
|
||||
</div>
|
||||
<div class="card"><h2>${t("plInstall")}</h2>
|
||||
<div class="dropzone" id="pl-dz">${t("dropHint")}</div>
|
||||
<input type="file" id="pl-file" accept=".lua,text/x-lua" class="hidden">
|
||||
<label>${t("adName")}</label><input id="pl-name" placeholder="myplugin">
|
||||
<label>${t("tbLua")}</label>
|
||||
<textarea id="pl-code" spellcheck="false" placeholder="return { name='myplugin', version='1.0.0', hooks={ request_end='on_end' }, function p.on_end(x) return nil end, ui={ page={ page_id='mine', title='Mine', mount='<div>hi</div>' } } }"></textarea>
|
||||
<p><button onclick="installPlugin()">${t("plInstallBtn")}</button></p>
|
||||
</div>`;
|
||||
bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code");
|
||||
}
|
||||
|
||||
async function togglePlugin(name, disabled) {
|
||||
try {
|
||||
await api("/api/plugins/" + encodeURIComponent(name), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: disabled }),
|
||||
});
|
||||
toast(disabled ? t("plDisabled") : t("plActive"));
|
||||
renderPlugins();
|
||||
} catch (e) {
|
||||
toast(tFmt("toastSaveFail", e.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function delPlugin(name) {
|
||||
if (!confirm(tFmt("confirmDelSrc", name))) return;
|
||||
try {
|
||||
await api("/api/plugins/" + encodeURIComponent(name), { method: "DELETE" });
|
||||
toast(t("toastDelOk"));
|
||||
renderPlugins();
|
||||
} catch (e) {
|
||||
toast(tFmt("toastSaveFail", e.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function installPlugin() {
|
||||
const name = $("#pl-name").value.trim();
|
||||
const code = $("#pl-code").value;
|
||||
if (!name) return toast(t("toastEmpty"));
|
||||
try {
|
||||
await api("/api/plugins", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, code }),
|
||||
});
|
||||
toast(t("toastSaved"));
|
||||
const w = $("#pl-dz") && $("#pl-dz").closest("#modal-wrap");
|
||||
if (w) w.remove();
|
||||
else closeTopModal();
|
||||
renderPlugins();
|
||||
} catch (e) {
|
||||
toast(tFmt("toastSaveFail", e.message));
|
||||
}
|
||||
}
|
||||
|
||||
function editPlugin(name) {
|
||||
openCodeModal(name, async (n, code) => {
|
||||
try {
|
||||
await api("/api/plugins", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: n, code }),
|
||||
});
|
||||
toast(t("toastSaved"));
|
||||
renderPlugins();
|
||||
} catch (e) {
|
||||
toast(tFmt("toastSaveFail", e.message));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// openCodeModal is the shared "load file / edit / save" dialog, used by
|
||||
// both the adapter and the plugin editors so a fix to one applies to both.
|
||||
async function openCodeModal(name, onSave) {
|
||||
let code = "";
|
||||
try {
|
||||
const j = await api("/api/plugins/" + encodeURIComponent(name));
|
||||
code = j.code || "";
|
||||
} catch (e) {
|
||||
toast(tFmt("toastSaveFail", e.message));
|
||||
return;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.id = "modal-wrap";
|
||||
wrap.innerHTML = `<div class="card"><h2>${esc(name)}</h2>
|
||||
<textarea id="pl-edit-code" spellcheck="false" style="min-height:420px;width:100%">${esc(code)}</textarea>
|
||||
<p><button id="pl-edit-save">${t("mSave")}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
|
||||
</div>`;
|
||||
wrap.style.cssText =
|
||||
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
|
||||
document.body.appendChild(wrap);
|
||||
$("#pl-edit-save").onclick = () => {
|
||||
onSave(name, $("#pl-edit-code").value);
|
||||
wrap.remove();
|
||||
};
|
||||
}
|
||||
|
||||
async function renderAdapters() {
|
||||
const j = await api("/api/status");
|
||||
const pools = {};
|
||||
@ -4092,7 +4448,7 @@
|
||||
<textarea id="adp-code" spellcheck="false" placeholder="return { name='mysrc', endpoint='/chat/completions', transform_request=function(raw) return raw end, transform_response=function(raw) return raw end }"></textarea>
|
||||
<p><button onclick="uploadAdapter()">${t("uploadBtn")}</button></p>
|
||||
</div>`;
|
||||
bindDropzone();
|
||||
bindDropzone("#dz", "#adp-file", "#adp-name", "#adp-code");
|
||||
}
|
||||
|
||||
// poolCell renders one adapter's elastic Lua state pool: how many states
|
||||
@ -4114,11 +4470,25 @@
|
||||
return `<span class="tag ${busy ? "tag-green" : "tag-amber"}" title="${escAttr(title)}">${p.created} / ${p.max}</span>
|
||||
<span class="muted"> ${p.in_use}● ${p.idle}○ +${p.grow_step}/-${p.shrink_step}</span>`;
|
||||
}
|
||||
function bindDropzone() {
|
||||
const dz = $("#dz"),
|
||||
file = $("#adp-file"),
|
||||
name = $("#adp-name"),
|
||||
code = $("#adp-code");
|
||||
// bindDropzone wires a drop target + file input + name/code fields so a
|
||||
// dropped .lua fills the form. It is PARAMETERISED because there are two
|
||||
// upload forms (adapters and plugins) and the original hard-coded the
|
||||
// adapter's element ids — a second copy would have been the same function
|
||||
// with four different strings in it.
|
||||
// All four arguments are REQUIRED. An earlier version defaulted them to the
|
||||
// adapter's ids, which meant a caller that forgot one silently wrote the
|
||||
// plugin's dropped file into the ADAPTER form — a cross-wired form that
|
||||
// looks like it worked. There is no safe default here.
|
||||
function bindDropzone(dzId, fileId, nameSel, codeSel) {
|
||||
if (!dzId || !fileId || !nameSel || !codeSel) {
|
||||
toast("bindDropzone: dz/file/name/code ids are all required");
|
||||
return;
|
||||
}
|
||||
const dz = $(dzId);
|
||||
const file = $(fileId);
|
||||
const name = $(nameSel);
|
||||
const code = $(codeSel);
|
||||
if (!dz || !file || !name || !code) return;
|
||||
["dragenter", "dragover"].forEach((ev) =>
|
||||
dz.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
@ -4770,21 +5140,359 @@
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
// refresh renders one tab.
|
||||
//
|
||||
// The plugin-element re-mount is applied HERE, once, rather than at the end
|
||||
// of each renderer: seven pages assign their pane's innerHTML wholesale,
|
||||
// and a per-page call is one more place to forget.
|
||||
function refresh(tab) {
|
||||
if (tab === "status") return renderStatus();
|
||||
if (tab === "chat") return renderChat();
|
||||
if (tab === "keys") return renderKeys();
|
||||
if (tab === "sort") return renderSort();
|
||||
if (tab === "sources") return renderSources();
|
||||
return renderAdapters();
|
||||
const done = () => remountPluginElements();
|
||||
if (tab === "status") return renderStatus().then(done, done);
|
||||
if (tab === "chat") return renderChat().then(done, done);
|
||||
if (tab === "keys") return renderKeys().then(done, done);
|
||||
if (tab === "sort") return renderSort().then(done, done);
|
||||
if (tab === "sources") return renderSources().then(done, done);
|
||||
if (tab === "adapters") return renderAdapters().then(done, done);
|
||||
if (tab === "plugins") return renderPlugins().then(done, done);
|
||||
// A page contributed by a plugin has no renderer here: its <script>
|
||||
// already ran at injection time and owns its own DOM. We only fire the
|
||||
// "shown" callbacks so it can refresh when the user lands on it.
|
||||
if (PLUGIN_PAGES.has(tab)) notifyPluginTab(tab);
|
||||
else return undefined;
|
||||
done();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---- plugin injection -------------------------------------------
|
||||
// Pages and elements contributed by Lua plugins (see docs/plugins.md).
|
||||
//
|
||||
// The server merges every plugin's extension into one payload at
|
||||
// GET /api/ui-inject, because the sidebar needs all of them before it can
|
||||
// be built. Injection happens once at boot, BEFORE the first goTab, so a
|
||||
// plugin page is a real tab rather than a special case in the router.
|
||||
const PLUGIN_TAB_CBS = {};
|
||||
const PLUGIN_ELEMENTS = [];
|
||||
// PLUGIN_MOUNT_HOOKS holds re-mount functions for plugin ELEMENTS. A host
|
||||
// page that rebuilds its own DOM (renderStatus assigns pane.innerHTML
|
||||
// wholesale) destroys whatever a plugin mounted into it, so refresh() calls
|
||||
// remountPluginElements() after every rebuild. Declared here, next to the
|
||||
// other plugin state, because injectPluginUI below pushes into it.
|
||||
const PLUGIN_MOUNT_HOOKS = [];
|
||||
|
||||
// pluginAPI is the small surface a plugin's script may rely on. Kept
|
||||
// deliberately tiny: plugins are untrusted, and every convenience here is
|
||||
// one more thing to keep working across kernel changes.
|
||||
window.pluginAPI = {
|
||||
async fetchState(name) {
|
||||
const r = await fetch("/api/plugins/" + encodeURIComponent(name) + "/state", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!r.ok) throw new Error("state " + r.status);
|
||||
return (await r.json()).state;
|
||||
},
|
||||
async postState(name, obj) {
|
||||
const r = await fetch("/api/plugins/" + encodeURIComponent(name) + "/state", {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(obj),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error?.message || r.status);
|
||||
return true;
|
||||
},
|
||||
onTabShown(fn) {
|
||||
PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || [];
|
||||
PLUGIN_TAB_CBS.__last.push(fn);
|
||||
},
|
||||
// The current UI language ("zh" | "en"). A plugin cannot read the
|
||||
// host's LANG binding (it is module-local), and hardcoding one language
|
||||
// is what left the Billing page English-only while the rest of the UI
|
||||
// switched. Exposed as a getter so a plugin always sees the live value.
|
||||
get lang() {
|
||||
return LANG;
|
||||
},
|
||||
// Register a callback fired whenever the user switches language, so a
|
||||
// plugin page can re-render in the new language without a reload. The
|
||||
// callback also fires is NOT automatic on first paint — the plugin
|
||||
// renders itself once on load as it always did.
|
||||
onLangChange(fn) {
|
||||
if (typeof fn === "function") PLUGIN_LANG_CBS.push(fn);
|
||||
},
|
||||
};
|
||||
// Callbacks registered via pluginAPI.onLangChange.
|
||||
const PLUGIN_LANG_CBS = [];
|
||||
function notifyPluginLang() {
|
||||
PLUGIN_LANG_CBS.forEach((fn) => {
|
||||
try {
|
||||
fn(LANG);
|
||||
} catch (e) {
|
||||
console.warn("plugin lang callback failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// pluginIconHTML renders a plugin-declared sidebar icon.
|
||||
//
|
||||
// Text icons are escaped as before. An icon that looks like markup is
|
||||
// accepted ONLY as a sanitized inline <svg>: a fixed tag allowlist, no
|
||||
// <script>, no event handlers, no external references. Plugins are
|
||||
// third-party code running in the operator's browser, so "trust the
|
||||
// plugin" is not a posture this can take — but neither can it refuse SVG
|
||||
// outright, because that is the only way an icon matches the native tabs.
|
||||
function pluginIconHTML(icon) {
|
||||
var raw = icon == null ? "" : String(icon);
|
||||
if (!raw) return '<span style="font-size:18px;line-height:1">\u2022</span>';
|
||||
if (!/<[a-zA-Z!/]/.test(raw)) {
|
||||
// Plain text (an emoji or a glyph).
|
||||
return '<span style="font-size:18px;line-height:1">' + esc(raw) + "</span>";
|
||||
}
|
||||
var cleaned = sanitizePluginSVG(raw);
|
||||
if (!cleaned) {
|
||||
// Markup that is not an acceptable SVG: fall back to a neutral dot
|
||||
// rather than injecting it or showing raw tags.
|
||||
return '<span style="font-size:18px:line-height:1">\u2022</span>';
|
||||
}
|
||||
return (
|
||||
'<span style="font-size:18px;line-height:1;display:inline-flex">' +
|
||||
cleaned +
|
||||
"</span>"
|
||||
);
|
||||
}
|
||||
|
||||
// sanitizePluginSVG keeps only what an icon needs.
|
||||
//
|
||||
// Allowlist, not a denylist: anything not named here is dropped, so a new
|
||||
// dangerous construct cannot slip through by default. Attributes are
|
||||
// limited to geometry and paint (no href/src, no on*, no style with url()).
|
||||
var SVG_OK_TAGS = { svg: 1, path: 1, circle: 1, rect: 1, line: 1, polyline: 1, polygon: 1, g: 1 };
|
||||
var SVG_OK_ATTRS = {
|
||||
viewBox: 1, fill: 1, stroke: 1, "stroke-width": 1, "stroke-linecap": 1,
|
||||
"stroke-linejoin": 1, d: 1, cx: 1, cy: 1, r: 1, x: 1, y: 1, rx: 1, ry: 1,
|
||||
x1: 1, y1: 1, x2: 1, y2: 1, points: 1, width: 1, height: 1, opacity: 1,
|
||||
};
|
||||
function sanitizePluginSVG(raw) {
|
||||
var doc = new DOMParser().parseFromString("<svg>" + raw + "</svg>", "image/svg+xml");
|
||||
var svg = doc.documentElement;
|
||||
if (!svg || svg.nodeName.toLowerCase() !== "svg" || doc.querySelector("parsererror")) {
|
||||
return "";
|
||||
}
|
||||
// The wrapper we build is the only <svg> we emit. A plugin's own <svg>
|
||||
// is unwrapped, otherwise the output nests an empty <svg></svg> inside
|
||||
// ours — visible in the markup, and it also meant the viewBox was read
|
||||
// from the WRAPPER (which never has one) rather than from the plugin's,
|
||||
// so any icon declaring a non-default viewBox silently lost it.
|
||||
var kept = [];
|
||||
(function walk(node, depth) {
|
||||
if (depth > 4) return;
|
||||
for (var i = 0; i < node.children.length; i++) {
|
||||
var el = node.children[i];
|
||||
var name = el.nodeName.toLowerCase();
|
||||
if (name === "svg") {
|
||||
walk(el, depth + 1); // unwrap, do not emit
|
||||
continue;
|
||||
}
|
||||
if (!SVG_OK_TAGS[name]) continue;
|
||||
var attrs = "";
|
||||
for (var a = 0; a < el.attributes.length; a++) {
|
||||
var at = el.attributes[a];
|
||||
var an = at.name.toLowerCase();
|
||||
// Reject anything that can fetch or execute, whatever it is called.
|
||||
if (/^on/.test(an) || /href|src|xlink|formaction|style/.test(an)) continue;
|
||||
if (!SVG_OK_ATTRS[an]) continue;
|
||||
var val = String(at.value).replace(/[<>"'&]/g, "");
|
||||
attrs += " " + an + '="' + val + '"';
|
||||
}
|
||||
kept.push("<" + name + attrs + "></" + name + ">");
|
||||
walk(el, depth + 1);
|
||||
}
|
||||
})(svg, 0);
|
||||
if (!kept.length) return "";
|
||||
// Prefer the plugin's own viewBox; fall back to the 24px grid every
|
||||
// native icon uses.
|
||||
var innerSvg = svg.querySelector("svg");
|
||||
var vb = (innerSvg && innerSvg.getAttribute("viewBox")) || svg.getAttribute("viewBox") || "0 0 24 24";
|
||||
return (
|
||||
'<svg viewBox="' + vb.replace(/[^\d\s.\-]/g, "") + '" fill="none" ' +
|
||||
'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
|
||||
'stroke-linejoin="round" style="width:18px;height:18px">' +
|
||||
kept.join("") +
|
||||
"</svg>"
|
||||
);
|
||||
}
|
||||
|
||||
// remountPluginElements re-attaches plugin elements after a host page
|
||||
// rebuilt its DOM. Safe to call at any time: each mount is a no-op when
|
||||
// the wrapper is already present in the current build of the pane, so a
|
||||
// plugin's <script> does not run twice and its counters do not double.
|
||||
function remountPluginElements() {
|
||||
if (!PLUGIN_MOUNT_HOOKS.length) return;
|
||||
PLUGIN_MOUNT_HOOKS.forEach((fn) => {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
console.warn("plugin element remount failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function notifyPluginTab(tab) {
|
||||
const fns = PLUGIN_TAB_CBS[tab] || PLUGIN_TAB_CBS.__last || [];
|
||||
fns.forEach((f) => {
|
||||
try {
|
||||
f();
|
||||
} catch (e) {
|
||||
console.warn("plugin tab callback failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// injectPluginUI adds the sidebar button + pane for a plugin page and
|
||||
// mounts plugin elements into existing panes.
|
||||
async function injectPluginUI() {
|
||||
let payload;
|
||||
try {
|
||||
const r = await fetch("/api/ui-inject", { credentials: "same-origin" });
|
||||
if (!r.ok) return;
|
||||
payload = await r.json();
|
||||
} catch (e) {
|
||||
return; // plugins are optional; the UI must work without them
|
||||
}
|
||||
const ui = (payload && payload.ui) || {};
|
||||
const main = $("#main");
|
||||
const nav = $("#sb-nav");
|
||||
if (!main || !nav) return;
|
||||
|
||||
// --- pages ---
|
||||
//
|
||||
// A plugin may contribute ONE page (`ui.page`) or SEVERAL
|
||||
// (`ui.pages[]`). Both are handled by the same code: a plugin whose
|
||||
// price rules produce the numbers on its billing page needs a second
|
||||
// screen to edit them, and cramming both into one pane behind
|
||||
// in-page tabs would hide a whole capability behind a toggle. The
|
||||
// single-page shape stays supported because it is what docs/plugins.md
|
||||
// documents and what every existing plugin uses.
|
||||
const pluginPages = []
|
||||
.concat(ui.page ? [ui.page] : [])
|
||||
.concat(Array.isArray(ui.pages) ? ui.pages : [])
|
||||
.filter((p) => p && p.page_id && p.mount);
|
||||
pluginPages.forEach((pg) => {
|
||||
const id = String(pg.page_id);
|
||||
if (!document.getElementById("tab-" + id)) {
|
||||
const pane = document.createElement("div");
|
||||
pane.id = "tab-" + id;
|
||||
pane.className = "tab-pane hidden";
|
||||
// A plugin page is arbitrary HTML from a third party. Without
|
||||
// min-width:0 its content (a wide table, a long unbroken string)
|
||||
// stretches the pane past #main, which does not scroll sideways —
|
||||
// the operator sees the page spill over the UI instead of a
|
||||
// scrollbar. The same guard every native pane gets.
|
||||
pane.style.minWidth = "0";
|
||||
pane.style.maxWidth = "100%";
|
||||
pane.style.overflowX = "auto";
|
||||
main.appendChild(pane);
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "sb-i";
|
||||
btn.dataset.tab = id;
|
||||
btn.title = pg.title || id;
|
||||
// A plugin icon may be plain text (an emoji, a glyph) or an inline
|
||||
// SVG snippet. Native tabs use inline SVG styled with
|
||||
// `stroke: currentColor`, so an emoji next to them renders at the
|
||||
// wrong size and ignores the theme — that is what "the icon looks
|
||||
// wrong" meant.
|
||||
//
|
||||
// The SVG form is allowed through RAW, which is only safe because
|
||||
// it is strictly filtered: see pluginIconHTML. Escaping it (as this
|
||||
// did) would print the markup as text instead.
|
||||
//
|
||||
// The label is NOT optional. Every native tab is
|
||||
// `<svg>…</svg><span data-i="navX">标题</span>`; a plugin tab that
|
||||
// carried only the icon rendered as a nameless icon in the sidebar,
|
||||
// which is what "the navigation entry has no title" was. The span
|
||||
// carries no data-i because plugin titles are not in the host's
|
||||
// translation table — set from pg.title, same as btn.title.
|
||||
btn.innerHTML =
|
||||
pluginIconHTML(pg.icon) +
|
||||
'<span>' +
|
||||
esc(pg.title || id) +
|
||||
"</span>";
|
||||
btn.onclick = () => goTab(id);
|
||||
nav.appendChild(btn);
|
||||
PLUGIN_PAGES.add(id);
|
||||
// The breadcrumb map is local to this file, so extend it here.
|
||||
if (typeof NAV_NAME === "object") NAV_NAME[id] = pg.title || id;
|
||||
}
|
||||
const pane = document.getElementById("tab-" + id);
|
||||
if (pane && !pane.dataset.pluginMounted) {
|
||||
pane.dataset.pluginMounted = "1";
|
||||
// Split the mount so <script>/<style> run only AFTER the markup is
|
||||
// in the document. Setting innerHTML with a <script> tag does not
|
||||
// execute it, which is exactly what we want to avoid the opposite
|
||||
// problem: running before its own DOM exists.
|
||||
const tpl = document.createElement("template");
|
||||
tpl.innerHTML = pg.mount;
|
||||
pane.appendChild(tpl.content);
|
||||
// Move each script into a fresh element so it executes.
|
||||
pane.querySelectorAll("script").forEach((old) => {
|
||||
const s = document.createElement("script");
|
||||
Array.from(old.attributes).forEach((a) => s.setAttribute(a.name, a.value));
|
||||
s.textContent = old.textContent;
|
||||
old.replaceWith(s);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- elements into existing pages ---
|
||||
//
|
||||
// mountPluginElements is a named, re-entrant function rather than a
|
||||
// one-shot loop: the host pages REPLACE their own innerHTML on every
|
||||
// build, so renderStatus' `pane.innerHTML = ...` destroys anything this
|
||||
// mounted. The symptom is silent and points at the wrong file — the
|
||||
// plugin did declare an element, the payload did arrive, and the element
|
||||
// is still gone after the next repaint, which reads as "my plugin
|
||||
// declared nothing". refresh() calls remountPluginElements() to re-apply.
|
||||
window.mountPluginElements = function () {
|
||||
(ui.elements || []).forEach((el, i) => {
|
||||
const target = document.getElementById("tab-" + el.target);
|
||||
if (!target || !el.mount) return;
|
||||
PLUGIN_ELEMENTS.push(el);
|
||||
// Already present in THIS build of the pane: leave it alone, or the
|
||||
// widget's <script> would run again on every host re-render and a
|
||||
// counter would silently double-count.
|
||||
if (target.querySelector('.plugin-el[data-idx="' + i + '"]')) return;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "plugin-el";
|
||||
wrap.dataset.target = el.target;
|
||||
wrap.dataset.idx = String(i);
|
||||
const tpl = document.createElement("template");
|
||||
tpl.innerHTML = el.mount;
|
||||
wrap.appendChild(tpl.content);
|
||||
const anchor = String(el.anchor || "bottom");
|
||||
if (anchor === "top") target.prepend(wrap);
|
||||
else if (anchor.startsWith("before:") || anchor.startsWith("after:")) {
|
||||
const [kind, sel] = anchor.split(/:(.+)/);
|
||||
const ref = target.querySelector(sel);
|
||||
if (ref) ref.parentNode.insertBefore(wrap, kind === "before" ? ref : ref.nextSibling);
|
||||
else target.appendChild(wrap);
|
||||
} else target.appendChild(wrap);
|
||||
wrap.querySelectorAll("script").forEach((old) => {
|
||||
const s = document.createElement("script");
|
||||
Array.from(old.attributes).forEach((a) => s.setAttribute(a.name, a.value));
|
||||
s.textContent = old.textContent;
|
||||
old.replaceWith(s);
|
||||
});
|
||||
});
|
||||
};
|
||||
PLUGIN_MOUNT_HOOKS.push(window.mountPluginElements);
|
||||
window.mountPluginElements();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const me = await api("/api/keys/me");
|
||||
window._me = me.key;
|
||||
maybeWarnSeed(me.key);
|
||||
if (me.key.role !== "admin") {
|
||||
["sort", "sources", "adapters"].forEach((tn) => {
|
||||
["sort", "sources", "adapters", "plugins"].forEach((tn) => {
|
||||
const b = document.querySelector(
|
||||
`nav button.sb-i[data-tab="${tn}"]`,
|
||||
);
|
||||
@ -4797,7 +5505,15 @@
|
||||
window.addEventListener("pagehide", () => releaseRecords(false));
|
||||
window.addEventListener("beforeunload", () => releaseRecords(false));
|
||||
|
||||
refresh("status");
|
||||
// Plugin injection runs BEFORE the first render: a plugin page must exist
|
||||
// in #main and the sidebar before goTab runs, otherwise the sidebar shows
|
||||
// no entry and the pane is missing for a moment. Awaited (not fired and
|
||||
// forgotten) so a slow /api/ui-inject cannot race the first paint.
|
||||
injectPluginUI()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
refresh("status");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -59,6 +59,82 @@ func lineOf(src string, idx int) int {
|
||||
return strings.Count(src[:idx], "\n") + 1
|
||||
}
|
||||
|
||||
// TestUIEditFormRoundTripsEveryOptionalSourceField pins the JS half of the
|
||||
// source-edit contract.
|
||||
//
|
||||
// The server distinguishes "absent" from "explicitly empty" for proxy_url,
|
||||
// api_key_env and timeout (they are pointers). That only helps if the form
|
||||
// SENTS them — a form that omits them falls back to "inherit", so clearing the
|
||||
// proxy box would silently keep the old proxy, which is the exact bug in the
|
||||
// other direction.
|
||||
//
|
||||
// It must also send the api_key_env value it was given, since that is the one
|
||||
// field whose loss is invisible until the next upstream call.
|
||||
func TestUIEditFormRoundTripsEveryOptionalSourceField(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
body, ok := jsFunctionBody(src, "saveSource")
|
||||
if !ok {
|
||||
t.Fatal("saveSource() not found in the WebUI")
|
||||
}
|
||||
for _, field := range []string{"proxy_url", "api_key_env", "timeout"} {
|
||||
if !strings.Contains(body, field+":") {
|
||||
t.Errorf("saveSource() does not send %q; the server treats an absent "+
|
||||
"field as \"keep the stored value\", so the form could never clear it", field)
|
||||
}
|
||||
}
|
||||
// The form's inputs must be filled from the values the server sends, or a
|
||||
// save would post an empty box and clear a configured field.
|
||||
for _, read := range []string{`$("#s-proxy")`, `$("#s-keyenv")`, `$("#s-timeout")`} {
|
||||
if !strings.Contains(src, read+".value") {
|
||||
t.Errorf("the source form never reads %s — it would post an empty value", read)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIEditFormReadsDurationsFromTheRevealView: config.Source tags
|
||||
// Timeout/QueueTimeout `json:"-"`, so the durations only reach the form if the
|
||||
// reveal endpoint adds them explicitly. The form MUST read them (see above),
|
||||
// which makes this API response load-bearing rather than informational.
|
||||
func TestUIEditFormReadsDurationsFromTheRevealView(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
for _, id := range []string{"s-proxy", "s-keyenv", "s-timeout"} {
|
||||
if !strings.Contains(src, `id="`+id+`"`) {
|
||||
t.Errorf("source form input #%s is missing", id)
|
||||
}
|
||||
}
|
||||
// The timeout box must render through the duration helper. Reading a raw
|
||||
// time.Duration number would show nanoseconds in a box that expects "300s".
|
||||
if !strings.Contains(src, "timeoutText(") {
|
||||
t.Error("the timeout input does not go through timeoutText(); a raw " +
|
||||
"time.Duration JSON number is nanoseconds and would be uneditable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIEditFormSendsEverySourceField is the mirror of
|
||||
// TestSourcePayloadCoversEveryEditableField on the JavaScript side: each field
|
||||
// the server accepts must be submitted by the form, or the server's upsert has
|
||||
// nothing to store for it.
|
||||
func TestUIEditFormSendsEverySourceField(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
body, ok := jsFunctionBody(src, "saveSource")
|
||||
if !ok {
|
||||
t.Fatal("saveSource() not found")
|
||||
}
|
||||
// Fields the edit dialog owns. proxy_url / api_key_env / timeout are
|
||||
// covered by the round-trip test above; this one catches the rest.
|
||||
// models and meta are local variables submitted by Go shorthand
|
||||
// (payload = { models, meta }), so they are matched as bare identifiers.
|
||||
for _, field := range []string{
|
||||
"name:", "base_url:", "api_key:", "adapter:", "endpoint:",
|
||||
"image_endpoint:", "max_concurrent:", "rpm:", "temperature:",
|
||||
"models,", "meta,",
|
||||
} {
|
||||
if !strings.Contains(body, field) {
|
||||
t.Errorf("saveSource() does not send %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIAPICallsDeclareMethod asserts that every api() call passing an options
|
||||
// object also declares an HTTP method (or is a GET that only passes an
|
||||
// AbortSignal). Without this, fetch defaults to GET and mutating endpoints are
|
||||
|
||||
390
internal/gateway/ui_plugin_test.go
Normal file
390
internal/gateway/ui_plugin_test.go
Normal file
@ -0,0 +1,390 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The plugin UI injection is JavaScript inside the embedded index.html, and it
|
||||
// is the ONLY thing that turns a plugin's `ui` block into a visible page or
|
||||
// element. These tests pin the wiring on the JS side; the server side (what the
|
||||
// payload contains) is covered by TestUIInjectServesPluginUI and the lua
|
||||
// package's TestBillingPluginDeclaresUI.
|
||||
//
|
||||
// What makes this worth pinning: a missing hook here fails SILENTLY. The page
|
||||
// simply never appears, there is no error anywhere, and it looks like "the
|
||||
// plugin didn't declare a page" rather than "the UI forgot to inject it".
|
||||
|
||||
// uiSource returns the embedded WebUI document.
|
||||
func uiSourceX(t *testing.T) string {
|
||||
t.Helper()
|
||||
return uiSource(t)
|
||||
}
|
||||
|
||||
// TestUIFetchesPluginInjection: the boot sequence must ask the kernel what to
|
||||
// inject. Without this fetch the whole feature is inert.
|
||||
func TestUIFetchesPluginInjection(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
if !strings.Contains(src, "/api/ui-inject") {
|
||||
t.Error("the WebUI never calls /api/ui-inject; plugin pages and elements can never appear")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIInjectsBeforeFirstRender: injection must be awaited before the first
|
||||
// refresh, otherwise the sidebar is built without the plugin entry and the
|
||||
// first paint races the fetch. This is an ordering contract, so it is asserted
|
||||
// on the source order rather than trusted.
|
||||
func TestUIInjectsBeforeFirstRender(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
iInject := strings.Index(src, "injectPluginUI()")
|
||||
iRefresh := strings.LastIndex(src, `refresh("status")`)
|
||||
if iInject < 0 {
|
||||
t.Fatal("injectPluginUI() is never called")
|
||||
}
|
||||
if iRefresh < 0 {
|
||||
t.Fatal("the boot sequence no longer calls refresh(\"status\")")
|
||||
}
|
||||
if iInject > iRefresh {
|
||||
t.Error("injectPluginUI() is called after the first refresh; the sidebar " +
|
||||
"and #main would be built before the plugin page exists")
|
||||
}
|
||||
// And it must be awaited, not fire-and-forget.
|
||||
window := src[iInject:]
|
||||
if !strings.Contains(window[:200], ".finally") && !strings.Contains(window[:200], "await") {
|
||||
t.Error("injectPluginUI() is not awaited before refresh; a slow response " +
|
||||
"would race the first paint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIPluginScriptsRunAfterMarkup is the subtle one. Setting innerHTML with a
|
||||
// <script> tag does NOT execute it; appending via a template neither does. The
|
||||
// mount therefore has to be inserted first and its scripts re-created
|
||||
// afterwards, or a plugin's script runs before its own DOM exists — which is
|
||||
// exactly the "document.getElementById returns null" failure mode.
|
||||
func TestUIPluginScriptsRunAfterMarkup(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
// A <template> is used to parse the mount without executing scripts...
|
||||
if !strings.Contains(src, "createElement(\"template\")") {
|
||||
t.Error("the mount is not parsed via <template>; scripts could execute before their DOM")
|
||||
}
|
||||
// ...and scripts are then re-created as fresh elements so they DO run.
|
||||
if !strings.Contains(src, "document.createElement(\"script\")") {
|
||||
t.Error("plugin <script> blocks are never re-created, so they never execute")
|
||||
}
|
||||
if !strings.Contains(src, "replaceWith(s)") {
|
||||
t.Error("the original inert <script> is not replaced by an executable one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIPluginAPISurface: the documented browser API must exist with the exact
|
||||
// names docs/plugins.md promises, since plugin authors code against it.
|
||||
func TestUIPluginAPISurface(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
for _, member := range []string{"fetchState", "postState", "onTabShown"} {
|
||||
if !strings.Contains(src, member+":") && !strings.Contains(src, member+"(") {
|
||||
t.Errorf("window.pluginAPI.%s is missing; docs/plugins.md documents it", member)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIPluginPageBecomesRealTab: a plugin page must get a pane in #main AND a
|
||||
// sidebar button wired to goTab, otherwise the page is unreachable.
|
||||
func TestUIPluginPageBecomesRealTab(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
// pane in #main
|
||||
if !strings.Contains(src, `pane.id = "tab-" + id`) {
|
||||
t.Error("no pane is created for a plugin page")
|
||||
}
|
||||
if !strings.Contains(src, "main.appendChild(pane)") {
|
||||
t.Error("the plugin pane is not appended to #main")
|
||||
}
|
||||
// sidebar button wired to the tab router
|
||||
if !strings.Contains(src, "btn.dataset.tab = id") {
|
||||
t.Error("the sidebar button is not given a data-tab, so goTab() will not route to it")
|
||||
}
|
||||
if !strings.Contains(src, "btn.onclick = () => goTab(id)") {
|
||||
t.Error("the sidebar button is not wired to goTab()")
|
||||
}
|
||||
// and the router must know about it
|
||||
if !strings.Contains(src, "PLUGIN_PAGES.has(tab)") {
|
||||
t.Error("refresh() does not route plugin pages, so opening one renders nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIPluginElementsHonorAnchor: elements declare top / bottom / before:sel /
|
||||
// after:sel. Silently ignoring the anchor would put a "top" tile at the bottom
|
||||
// of the status page, which looks like a layout bug rather than a plugin bug.
|
||||
func TestUIPluginElementsHonorAnchor(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
for _, anchor := range []string{`anchor === "top"`, `anchor.startsWith("before:")`, `"after:"`} {
|
||||
if !strings.Contains(src, anchor) {
|
||||
t.Errorf("the anchor form %s is not handled; elements would all land at the bottom", anchor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIPluginInjectionFailureIsNonFatal: plugins are optional, so a failed
|
||||
// /api/ui-inject must still leave a working UI (the dashboard has to render).
|
||||
// Two places have to cooperate: the function swallows the fetch error, and the
|
||||
// caller catches anything that still escapes so refresh() always runs.
|
||||
func TestUIPluginInjectionFailureIsNonFatal(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
// inside the function: the fetch is wrapped in try/catch
|
||||
fnStart := strings.Index(src, "async function injectPluginUI()")
|
||||
if fnStart < 0 {
|
||||
t.Fatal("injectPluginUI() is not defined")
|
||||
}
|
||||
fn := src[fnStart:]
|
||||
if !strings.Contains(fn, "plugins are optional; the UI must work without them") {
|
||||
t.Error("injectPluginUI does not guard its own fetch failure")
|
||||
}
|
||||
// at the call site: the rejection cannot escape before the first render
|
||||
// LastIndex, not Index: the DEFINITION of injectPluginUI also matches, and
|
||||
// the definition has no .catch on it.
|
||||
iCall := strings.LastIndex(src, "injectPluginUI()")
|
||||
if iCall < 0 {
|
||||
t.Fatal("injectPluginUI() is never called")
|
||||
}
|
||||
// Bound the window at len(src): the call site sits near EOF and a fixed
|
||||
// slice overruns it (a panic in a test is worse than a skipped assertion).
|
||||
end := iCall + 220
|
||||
if end > len(src) {
|
||||
end = len(src)
|
||||
}
|
||||
if !strings.Contains(src[iCall:end], ".catch") {
|
||||
t.Error("a failed /api/ui-inject would reject before refresh(\"status\"), " +
|
||||
"leaving the dashboard blank")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- plugin management UI contract ---------------------------------------
|
||||
//
|
||||
// The management page is the operator's only way to take a broken plugin out
|
||||
// of the request path. Every one of these assertions guards a link that, if it
|
||||
// silently broke, would leave the gateway running with a plugin it cannot
|
||||
// disable — the worst kind of gap: everything looks fine and nothing is
|
||||
// reachable.
|
||||
|
||||
func TestUIHasPluginTabAndPane(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
if !strings.Contains(src, `data-tab="plugins"`) {
|
||||
t.Error("no sidebar entry for the plugin page")
|
||||
}
|
||||
if !strings.Contains(src, `id="tab-plugins"`) {
|
||||
t.Error("no #tab-plugins pane")
|
||||
}
|
||||
// The tab list is now a single constant; a new tab must be added there or
|
||||
// goTab will not un-hide its pane.
|
||||
if !strings.Contains(src, `const TABS = [`) {
|
||||
t.Error("TABS is gone; the tab list went back to a duplicated literal")
|
||||
}
|
||||
for _, tn := range []string{"status", "chat", "keys", "sort", "sources", "adapters", "plugins"} {
|
||||
if !strings.Contains(src, `"`+tn+`"`) {
|
||||
t.Errorf("TABS is missing %q", tn)
|
||||
}
|
||||
}
|
||||
// goTab must iterate TABS, not its own list.
|
||||
if !strings.Contains(src, "TABS.forEach((tn) =>") {
|
||||
t.Error("goTab does not iterate TABS")
|
||||
}
|
||||
if strings.Contains(src, `["status", "chat", "keys", "sort", "sources", "adapters"].forEach`) {
|
||||
t.Error("a duplicated tab literal survived; it will drift from TABS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIRendersPluginManagement(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
body, ok := jsFunctionBody(src, "renderPlugins")
|
||||
if !ok {
|
||||
t.Fatal("renderPlugins() not found")
|
||||
}
|
||||
// It must read the DISK listing, not just the loaded set: a plugin that
|
||||
// failed to compile is absent from the loaded set, and showing only the
|
||||
// loaded set makes a syntax error look like "the plugin is not installed".
|
||||
if !strings.Contains(body, "on_disk") {
|
||||
t.Error("renderPlugins reads only the loaded set; a failed plugin would " +
|
||||
"be invisible instead of shown with its error")
|
||||
}
|
||||
if !strings.Contains(body, "/api/plugins") {
|
||||
t.Error("renderPlugins does not call /api/plugins")
|
||||
}
|
||||
// Hook errors must be surfaced: a plugin that throws in every stage leaves
|
||||
// no other trace, so without this the symptom is "the feature just doesn't
|
||||
// work".
|
||||
if !strings.Contains(body, "hook_errors") {
|
||||
t.Error("renderPlugins ignores hook_errors; a silently broken plugin is undebuggable")
|
||||
}
|
||||
// Enable / disable / remove / edit.
|
||||
for _, fn := range []string{"togglePlugin", "delPlugin", "installPlugin", "editPlugin"} {
|
||||
if _, ok := jsFunctionBody(src, fn); !ok {
|
||||
t.Errorf("%s() is missing from the WebUI", fn)
|
||||
}
|
||||
}
|
||||
// The toggle must go through the enable/disable endpoint, not delete.
|
||||
tb, ok := jsFunctionBody(src, "togglePlugin")
|
||||
if !ok {
|
||||
t.Fatal("togglePlugin() missing")
|
||||
}
|
||||
if !strings.Contains(tb, `method: "PUT"`) {
|
||||
t.Error("togglePlugin does not use PUT")
|
||||
}
|
||||
if !strings.Contains(tb, "enabled:") {
|
||||
t.Error("togglePlugin does not send an \"enabled\" field")
|
||||
}
|
||||
// And the admin-only tab list must include plugins, or a non-admin would
|
||||
// see a page whose every action 403s.
|
||||
if !strings.Contains(src, `["sort", "sources", "adapters", "plugins"]`) {
|
||||
t.Error("the admin-only tab list omits \"plugins\"; a user key would see a " +
|
||||
"page full of actions that all fail with 403")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIBindDropzoneIsParameterised guards the refactor: the adapter and plugin
|
||||
// upload forms share one dropzone, so a hard-coded id would send a dropped
|
||||
// plugin file into the adapter name field.
|
||||
func TestUIBindDropzoneIsParameterised(t *testing.T) {
|
||||
src := uiSourceX(t)
|
||||
body, ok := jsFunctionBody(src, "bindDropzone")
|
||||
if !ok {
|
||||
t.Fatal("bindDropzone() not found")
|
||||
}
|
||||
if strings.Contains(body, `$("#dz")`) || strings.Contains(body, `$("#adp-name")`) {
|
||||
t.Error("bindDropzone still hard-codes the adapter's element ids; the " +
|
||||
"plugin form would write into the adapter form")
|
||||
}
|
||||
if !strings.Contains(body, "dzId") || !strings.Contains(body, "nameSel") {
|
||||
t.Error("bindDropzone does not accept the ids to bind")
|
||||
}
|
||||
// Both forms must call it.
|
||||
if !strings.Contains(src, `bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code")`) {
|
||||
t.Error("the plugin upload form does not use the parameterised dropzone")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginElementsSurviveHostRebuild guards the defect that made plugin
|
||||
// elements look absent no matter how the injection was configured.
|
||||
//
|
||||
// renderStatus (and six other pages) assign pane.innerHTML wholesale. Anything a
|
||||
// plugin had mounted into that pane is destroyed by the assignment. The symptom
|
||||
// is silent and misleading: the plugin really did declare an element, the
|
||||
// payload really did arrive, and the element is still gone on the next repaint —
|
||||
// so the natural conclusion is "my plugin declared nothing", which sends you
|
||||
// looking in the wrong file.
|
||||
//
|
||||
// The fix is to re-mount after the rebuild. This test asserts the re-mount is
|
||||
// wired at the SINGLE place every renderer passes through, rather than leaving
|
||||
// it to be re-added per page.
|
||||
func TestPluginElementsSurviveHostRebuild(t *testing.T) {
|
||||
html := uiSource(t)
|
||||
|
||||
if !strings.Contains(html, "function remountPluginElements") {
|
||||
t.Fatal("remountPluginElements is not defined; nothing can re-attach a plugin element after a host rebuild")
|
||||
}
|
||||
if !strings.Contains(html, "const PLUGIN_MOUNT_HOOKS = []") {
|
||||
t.Fatal("PLUGIN_MOUNT_HOOKS is not declared")
|
||||
}
|
||||
// The hook array must be declared BEFORE injectPluginUI pushes to it, and
|
||||
// before refresh() calls into it. A use-before-declaration in a const
|
||||
// block is a hard TDZ ReferenceError at first paint.
|
||||
hookDecl := strings.Index(html, "const PLUGIN_MOUNT_HOOKS = []")
|
||||
push := strings.Index(html, "PLUGIN_MOUNT_HOOKS.push")
|
||||
use := strings.Index(html, "PLUGIN_MOUNT_HOOKS.forEach")
|
||||
if hookDecl < 0 || push < 0 || use < 0 {
|
||||
t.Fatal("the hook array is declared but never both filled and drained")
|
||||
}
|
||||
if hookDecl > push || hookDecl > use {
|
||||
t.Error("PLUGIN_MOUNT_HOOKS is used before its declaration (const TDZ: first paint would throw)")
|
||||
}
|
||||
|
||||
// refresh() is the chokepoint every renderer passes through.
|
||||
rf := strings.Index(html, "function refresh(tab)")
|
||||
if rf < 0 {
|
||||
t.Fatal("refresh(tab) is gone")
|
||||
}
|
||||
body := html[rf:]
|
||||
if i := strings.Index(body, "\n }"); i > 0 {
|
||||
body = body[:i]
|
||||
}
|
||||
if !strings.Contains(body, "remountPluginElements") {
|
||||
t.Error("refresh() does not re-mount plugin elements: a page that rebuilds its DOM wipes them")
|
||||
}
|
||||
|
||||
// A mount must be idempotent, or the widget's <script> runs again on every
|
||||
// host repaint and its counters silently double.
|
||||
if !strings.Contains(html, `data-idx="`) || !strings.Contains(html, "plugin-el[data-idx=") {
|
||||
t.Error("element mounting is not guarded by a per-pane marker; re-mounting would re-run plugin scripts")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginPagesAreReachableByGoTab guards a bug that only shows up in a
|
||||
// browser: goTab() iterated the hardcoded TABS list to toggle `hidden`, and a
|
||||
// plugin-contributed page is not in that list. The pane existed, the plugin had
|
||||
// filled it with real data, and clicking the sidebar entry changed nothing —
|
||||
// the `hidden` class was never removed.
|
||||
//
|
||||
// It reads exactly like "the page is blank" while the content sits in the DOM,
|
||||
// and no static check catches it: the injection is correct, the data is correct,
|
||||
// and the API returns 200.
|
||||
//
|
||||
// So this asserts the two structural facts that make it reachable: goTab
|
||||
// consults the plugin page set, and that set is declared before goTab runs (a
|
||||
// `const` further down the file would be a temporal-dead-zone ReferenceError on
|
||||
// the first click).
|
||||
func TestPluginPagesAreReachableByGoTab(t *testing.T) {
|
||||
ui := uiSource(t)
|
||||
|
||||
goTab := strings.Index(ui, "function goTab(")
|
||||
if goTab < 0 {
|
||||
t.Fatal("goTab() is gone")
|
||||
}
|
||||
end := strings.Index(ui[goTab:], "\n }")
|
||||
if end < 0 {
|
||||
t.Fatal("could not isolate goTab()")
|
||||
}
|
||||
body := ui[goTab : goTab+end]
|
||||
|
||||
if !strings.Contains(body, "PLUGIN_PAGES.forEach") {
|
||||
t.Error("★ goTab() does not toggle plugin pages — a plugin page keeps its " +
|
||||
"`hidden` class forever, so the sidebar entry does nothing")
|
||||
}
|
||||
|
||||
// Declaration must precede the use, or the first click throws.
|
||||
decl := strings.Index(ui, "var PLUGIN_PAGES = new Set()")
|
||||
if decl < 0 {
|
||||
// A const later in the file would also "work" only if nothing reads it
|
||||
// first — make that explicit rather than silent.
|
||||
if strings.Contains(ui, "const PLUGIN_PAGES = new Set()") {
|
||||
t.Error("PLUGIN_PAGES is a `const` declared after goTab() reads it — " +
|
||||
"temporal dead zone: the first click on a plugin page throws")
|
||||
} else {
|
||||
t.Error("PLUGIN_PAGES is not declared anywhere")
|
||||
}
|
||||
return
|
||||
}
|
||||
if decl > goTab {
|
||||
t.Errorf("PLUGIN_PAGES is declared at %d but goTab() at %d reads it — "+
|
||||
"declaration must come first", decl, goTab)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginSidebarEntryCarriesATitle: a plugin tab must render icon AND label.
|
||||
//
|
||||
// Native tabs are `<svg>…</svg><span data-i="navStatus">状态</span>`; the plugin
|
||||
// branch set innerHTML to the icon alone, so every plugin page showed up as a
|
||||
// nameless icon in the sidebar. An operator cannot tell what an icon means
|
||||
// until they click it.
|
||||
func TestPluginSidebarEntryCarriesATitle(t *testing.T) {
|
||||
html := uiSource(t)
|
||||
anchor := "btn.innerHTML =\n pluginIconHTML(pg.icon) +"
|
||||
i := strings.Index(html, anchor)
|
||||
if i < 0 {
|
||||
t.Fatal("plugin sidebar button construction not found")
|
||||
}
|
||||
// Look at the statement the anchor opens, not the whole file.
|
||||
stmt := html[i : i+400]
|
||||
if strings.Contains(stmt, "esc(pg.title") {
|
||||
return
|
||||
}
|
||||
t.Errorf("plugin sidebar button renders the icon only; a label is required. "+
|
||||
"got: %.200s", stmt)
|
||||
}
|
||||
@ -2,7 +2,22 @@ local adapter = {}
|
||||
|
||||
adapter.name = "gemini"
|
||||
adapter.version = "2.0.0"
|
||||
adapter.endpoint = "/v1/models"
|
||||
-- The Go layer builds the request URL as base_url + endpoint, statically
|
||||
-- (see provider.URL). Gemini's real API is POST
|
||||
-- /v1beta/models/{model}:generateContent, and streaming is the same path with
|
||||
-- a ":streamGenerateContent" verb -- the model name is part of the PATH, so it
|
||||
-- cannot live in a static endpoint string.
|
||||
--
|
||||
-- "{model}" is therefore a placeholder the Go layer substitutes with the model
|
||||
-- this request actually sends (provider.urlFor substitutes it; see
|
||||
-- provider.go). Streaming additionally rewrites the ":generateContent" verb to
|
||||
-- ":streamGenerateContent" on the same template.
|
||||
--
|
||||
-- Leaving this as a bare "/v1/models" would call Gemini's model-LIST endpoint,
|
||||
-- which answers 405 to POST -- so the preset template would create a source that
|
||||
-- can never work. A source that overrides `endpoint:` bypasses the template
|
||||
-- entirely and must then spell the whole path itself.
|
||||
adapter.endpoint = "/v1beta/models/{model}:generateContent"
|
||||
adapter.headers = {}
|
||||
|
||||
-- Gemini API: POST /v1/models/{model}:generateContent
|
||||
|
||||
351
internal/lua/billing_period_test.go
Normal file
351
internal/lua/billing_period_test.go
Normal file
@ -0,0 +1,351 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// runBillingPage executes the billing page's mount script under node against a
|
||||
// DOM stub, drives the period selector, and returns what ended up on screen.
|
||||
//
|
||||
// It asserts against RENDERED output rather than the plugin's internal
|
||||
// helpers. Reaching into the IIFE for daysInWindow/rescale needs a test-only
|
||||
// hook in the plugin, and the first version of this file took a long detour
|
||||
// through such a hook: the harness's fake `Date` broke toISOString(), the
|
||||
// plugin script aborted before assigning the hook, and every assertion
|
||||
// compared "" against a real value. The rendered tables are the contract that
|
||||
// actually matters, they need no hook, and they cannot pass if the rescale is
|
||||
// wrong — which is exactly the bug class under test.
|
||||
func runBillingPage(t *testing.T, todayISO, period, captureID string) string {
|
||||
t.Helper()
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skipf("node not available (%v)", err)
|
||||
}
|
||||
page, _ := billingUI(t)
|
||||
js := strings.Join(extractScripts(page), "\n")
|
||||
if !strings.Contains(js, "__billing_setPeriod") {
|
||||
t.Fatal("billing page exposes no period selector")
|
||||
}
|
||||
|
||||
harness := `
|
||||
global.window = global;
|
||||
global.__written = [];
|
||||
global.__errors = [];
|
||||
var __realErr = console.error;
|
||||
console.error = function () {
|
||||
global.__errors.push("console.error: " + Array.prototype.map.call(arguments, function (a) {
|
||||
return (a && a.message) ? a.message : String(a);
|
||||
}).join(" "));
|
||||
__realErr.apply(console, arguments);
|
||||
};
|
||||
process.on("uncaughtException", function (e) { global.__errors.push("uncaught: " + String(e && e.message || e)); });
|
||||
process.on("unhandledRejection", function (e) { global.__errors.push("unhandled: " + String(e && e.message || e)); });
|
||||
|
||||
// Pin "now" for the window under test.
|
||||
//
|
||||
// Two earlier attempts were wrong here, and both looked like product bugs:
|
||||
// replacing the Date constructor wholesale breaks toISOString/UTC; and
|
||||
// subclassing (setting __FakeDate.prototype = __RealDate.prototype while
|
||||
// returning __RealDate instances) leaves instances WITHOUT a working
|
||||
// getUTCDay, so the week window came out NaN and every rendered day became
|
||||
// "Invalid time value". A Proxy over the real constructor intercepts only the
|
||||
// zero-argument call and leaves instances — and therefore every prototype
|
||||
// method — completely untouched.
|
||||
var __NOW_MS = new Date("` + todayISO + `T12:34:56Z").getTime();
|
||||
var __RealDate = global.Date;
|
||||
var __PatchedDate = new Proxy(__RealDate, {
|
||||
construct: function (target, args) {
|
||||
// Reflect.construct, not a hand-spelled argument list: writing
|
||||
// new target(a, b, c, d, e, f, g) passes seven explicit undefineds for a
|
||||
// one-argument call, which Date reads as year..millisecond fields and turns
|
||||
// into an Invalid Date. The symptom was a week window rendering
|
||||
// "Invalid time value" for every day — a harness fault indistinguishable
|
||||
// from a product bug.
|
||||
if (args.length === 0) return Reflect.construct(target, [__NOW_MS]);
|
||||
return Reflect.construct(target, args);
|
||||
},
|
||||
apply: function () { return new __RealDate(__NOW_MS); }
|
||||
});
|
||||
__PatchedDate.now = function () { return __NOW_MS; };
|
||||
__PatchedDate.UTC = __RealDate.UTC;
|
||||
__PatchedDate.parse = __RealDate.parse;
|
||||
__PatchedDate.prototype = __RealDate.prototype;
|
||||
global.Date = __PatchedDate;
|
||||
|
||||
global.document = {
|
||||
getElementById: function (id) {
|
||||
if (!global.__els) global.__els = {};
|
||||
if (!global.__els[id]) global.__els[id] = {
|
||||
style: {}, dataset: {}, classList: { add: function(){}, remove: function(){} },
|
||||
options: [{value:"day"},{value:"week"},{value:"month"},{value:"all"}],
|
||||
set innerHTML(v) { if (v && String(v).trim()) global.__written.push(id); this.__h = v; },
|
||||
get innerHTML() { return this.__h || ""; },
|
||||
set textContent(v) { if (v !== undefined && String(v).trim()) global.__written.push(id); this.__t = v; },
|
||||
get textContent() { return this.__t || ""; },
|
||||
set innerText(v) { if (v !== undefined && String(v).trim()) global.__written.push(id); this.__i = v; },
|
||||
get innerText() { return this.__i || ""; },
|
||||
appendChild: function(){}, querySelector: function(){ return null; },
|
||||
querySelectorAll: function(){ return []; }, addEventListener: function(){}
|
||||
};
|
||||
return global.__els[id];
|
||||
},
|
||||
createElement: function () { return { style: {}, dataset: {}, appendChild: function(){}, setAttribute: function(){} }; },
|
||||
addEventListener: function () {},
|
||||
};
|
||||
|
||||
// Fixture days carry deliberately distinctive costs so a fold over the wrong
|
||||
// subset still produces a number, and only a mismatch shows it. 2026-01-05/06/07
|
||||
// are Monday/Tuesday/Wednesday; 2025-12-01 is far outside any window here.
|
||||
global.__st = {
|
||||
currency: "USD",
|
||||
total: { cost: 999, requests: 999, prompt_tokens: 999999 },
|
||||
by_source: { LIFETIMESRC: { cost: 999, requests: 999 } },
|
||||
by_model: { LIFETIMEMODEL: { cost: 999, requests: 999 } },
|
||||
by_key: { LIFETIMEKEY: { cost: 999, requests: 999 } },
|
||||
by_day: {
|
||||
"2026-01-05": { cost: 10, requests: 1, prompt_tokens: 100, completion_tokens: 10, failures: 0 },
|
||||
"2026-01-06": { cost: 20, requests: 2, prompt_tokens: 200, completion_tokens: 20, failures: 1 },
|
||||
"2026-01-07": { cost: 30, requests: 3, prompt_tokens: 300, completion_tokens: 30, failures: 0 },
|
||||
"2025-12-01": { cost: 777, requests: 77, prompt_tokens: 7700 }
|
||||
},
|
||||
by_day_src: {
|
||||
"2026-01-05": { srcA: { cost: 10, requests: 1 } },
|
||||
"2026-01-06": { srcA: { cost: 20, requests: 2 }, srcB: { cost: 5, requests: 1 } },
|
||||
"2026-01-07": { srcA: { cost: 30, requests: 3 } },
|
||||
"2025-12-01": { srcOLD: { cost: 777, requests: 77 } }
|
||||
},
|
||||
by_day_model: {
|
||||
"2026-01-05": { mA: { cost: 10, requests: 1 } },
|
||||
"2026-01-06": { mA: { cost: 20, requests: 2 } },
|
||||
"2026-01-07": { mA: { cost: 30, requests: 3 } }
|
||||
},
|
||||
by_day_key: {
|
||||
"2026-01-05": { kA: { cost: 10, requests: 1 } },
|
||||
"2026-01-06": { kA: { cost: 20, requests: 2 } },
|
||||
"2026-01-07": { kA: { cost: 30, requests: 3 } }
|
||||
},
|
||||
degraded_reqs: 4242, unpriced_reqs: 77
|
||||
};
|
||||
global.fetch = function () {
|
||||
return Promise.resolve({ ok: true, json: function () {
|
||||
return Promise.resolve({ plugin: "billing", state: global.__st });
|
||||
}});
|
||||
};
|
||||
global.pluginAPI = { onTabShown: function () {}, lang: "en" };
|
||||
`
|
||||
script := harness + "\n" + js + "\n" +
|
||||
"__billing_setPeriod(" + jsonStr(period) + ");\n" +
|
||||
// Let the period-triggered refresh() settle before reading the DOM.
|
||||
"setTimeout(function () {\n" +
|
||||
" var el = document.getElementById(" + jsonStr(captureID) + ");\n" +
|
||||
" console.log('__CAPTURE__' + JSON.stringify({\n" +
|
||||
" html: el ? String(el.innerHTML) : '(missing)',\n" +
|
||||
" kpis: String(document.getElementById('billing-kpis').innerHTML),\n" +
|
||||
" sel: String(document.getElementById('billing-period').value),\n" +
|
||||
" errors: global.__errors\n" +
|
||||
" }));\n" +
|
||||
"}, 60);\n"
|
||||
|
||||
cmd := exec.Command(node, "-e", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("node execution failed: %v\n%s", err, out)
|
||||
}
|
||||
o := string(out)
|
||||
idx := strings.Index(o, "__CAPTURE__")
|
||||
if idx < 0 {
|
||||
t.Fatalf("no capture in node output (the plugin script aborted?):\n%s", o)
|
||||
}
|
||||
raw := strings.TrimSpace(o[idx+len("__CAPTURE__"):])
|
||||
if nl := strings.IndexAny(raw, "\r\n"); nl >= 0 {
|
||||
raw = raw[:nl]
|
||||
}
|
||||
var got struct {
|
||||
HTML string `json:"html"`
|
||||
KPIs string `json:"kpis"`
|
||||
Sel string `json:"sel"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &got); err != nil {
|
||||
t.Fatalf("capture is not JSON: %v\n%s", err, raw)
|
||||
}
|
||||
if len(got.Errors) > 0 {
|
||||
t.Fatalf("billing script reported errors: %v", got.Errors)
|
||||
}
|
||||
if got.Sel != period {
|
||||
t.Errorf("selector shows %q after setting %q — the change handler did not take", got.Sel, period)
|
||||
}
|
||||
return got.HTML + "\n@@KPI@@\n" + got.KPIs
|
||||
}
|
||||
|
||||
func jsonStr(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestBillingPeriodDayShowsOnlyThatDay checks the one-day window end to end:
|
||||
// the tables must carry that day's rows and drop every other day, and the
|
||||
// lifetime sentinel (999 / LIFETIMESRC) must appear nowhere.
|
||||
func TestBillingPeriodDayShowsOnlyThatDay(t *testing.T) {
|
||||
got := runBillingPage(t, "2026-01-07", "day", "billing-by-source")
|
||||
if strings.Contains(got, "LIFETIME") || strings.Contains(got, "999") {
|
||||
t.Errorf("a one-day view still shows lifetime figures:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "srcB") {
|
||||
t.Errorf("a one-day view shows srcB, which only had traffic on 01-06:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "srcOLD") {
|
||||
t.Errorf("a one-day view shows a day from 2025-12:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "srcA") {
|
||||
t.Errorf("a one-day view lost the only source that had traffic that day:\n%s", got)
|
||||
}
|
||||
// The KPI total must be that day's cost (30), not the week (60) and not
|
||||
// the lifetime sentinel (999).
|
||||
if !strings.Contains(got, "30") {
|
||||
t.Errorf("KPI does not show the day's cost 30:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingPeriodWeekSumsThreeDays is the arithmetic: Mon+Wed rows and a
|
||||
// total of 60. A window that started on Sunday or ended early still renders
|
||||
// tables, so only the number distinguishes it.
|
||||
func TestBillingPeriodWeekSumsThreeDays(t *testing.T) {
|
||||
// 2026-01-07 is a Wednesday: the ISO week holds 01-05, 01-06, 01-07.
|
||||
got := runBillingPage(t, "2026-01-07", "week", "billing-by-source")
|
||||
if strings.Contains(got, "srcOLD") || strings.Contains(got, "LIFETIME") {
|
||||
t.Errorf("weekly view leaks out-of-window or lifetime rows:\n%s", got)
|
||||
}
|
||||
// srcA = 10+20+30 = 60, srcB = 5 (only 01-06, but inside the week).
|
||||
if !strings.Contains(got, "60") {
|
||||
t.Errorf("weekly total should be 60, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "srcB") {
|
||||
t.Errorf("weekly view dropped srcB, whose only day (01-06) is in the window:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingPeriodSundayReachesBackToMonday pins the ISO-week Monday rule
|
||||
// from the other side. On Sunday 2026-01-11 the week that already ended is
|
||||
// Mon 05..Sun 11, so all three fixture days are in it; a start-on-Sunday bug
|
||||
// would still include 01-07 and pass, so this case is paired with the
|
||||
// weekday case above rather than relied on alone.
|
||||
func TestBillingPeriodSundayReachesBackToMonday(t *testing.T) {
|
||||
got := runBillingPage(t, "2026-01-11", "week", "billing-by-source")
|
||||
if !strings.Contains(got, "60") {
|
||||
t.Errorf("Sunday's week view should still total 60:\n%s", got)
|
||||
}
|
||||
// And the Monday view must NOT include Sunday's (nonexistent) traffic.
|
||||
gotMon := runBillingPage(t, "2026-01-05", "day", "billing-by-source")
|
||||
if strings.Contains(gotMon, "30") {
|
||||
t.Errorf("Monday's single day view shows Wednesday's cost 30:\n%s", gotMon)
|
||||
}
|
||||
if !strings.Contains(gotMon, "10") {
|
||||
t.Errorf("Monday's day view lost Monday's own cost 10:\n%s", gotMon)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingPeriodKpiTotalMatchesWindowDays pins the KPI total itself.
|
||||
//
|
||||
// The dimension-table tests above cannot catch a broken window or a broken
|
||||
// fold: by_source is computed from the per-day DIMENSION map, independently of
|
||||
// the total. Mutation verification proved it — disabling the total fold, and
|
||||
// shifting the ISO-week start to Sunday, both left every earlier test green.
|
||||
// The total is derived from by_day, so it is the one value that moves when
|
||||
// either the window membership or the accumulation is wrong.
|
||||
func TestBillingPeriodKpiTotalMatchesWindowDays(t *testing.T) {
|
||||
kpis := func(t *testing.T, today, period string) string {
|
||||
t.Helper()
|
||||
return runBillingPage(t, today, period, "billing-by-source")
|
||||
}
|
||||
// 2026-01-07 is a Wednesday; fixture days are 05(10) 06(20) 07(30).
|
||||
// KPI renders through fmtInt, so 30 -> "30", 60 -> "60".
|
||||
if got := kpis(t, "2026-01-07", "day"); !strings.Contains(got, "30") {
|
||||
t.Errorf("day KPI should show that day's cost 30:\n%s", got)
|
||||
}
|
||||
if got := kpis(t, "2026-01-07", "week"); !strings.Contains(got, "60") {
|
||||
t.Errorf("week KPI should total 10+20+30 = 60:\n%s", got)
|
||||
}
|
||||
// Sunday 2026-01-11: the ISO week Mon 05..Sun 11 still holds all three days.
|
||||
if got := kpis(t, "2026-01-11", "week"); !strings.Contains(got, "60") {
|
||||
t.Errorf("Sunday's week KPI should still total 60:\n%s", got)
|
||||
}
|
||||
// The Monday rule from the negative side. A window that started on SUNDAY
|
||||
// would cover Sun 11..Sat 17 and contain none of the fixture days, giving
|
||||
// a total of 0 — so this case is what makes the +6 shift observable. The
|
||||
// positive case alone cannot distinguish it: shifting the start one day
|
||||
// later still happens to contain 01-07.
|
||||
if got := kpis(t, "2026-01-11", "day"); !strings.Contains(got, "0") {
|
||||
t.Errorf("Sunday's single-day window has no fixture traffic and must total 0:\n%s", got)
|
||||
}
|
||||
// Monday 2026-01-12 begins a new week; it contains no fixture days.
|
||||
if got := kpis(t, "2026-01-12", "week"); !strings.Contains(got, "0") {
|
||||
t.Errorf("an empty week should report 0, not stale data:\n%s", got)
|
||||
}
|
||||
// ...whereas the Monday-to-Sunday week that just ended does.
|
||||
if got := kpis(t, "2026-01-12", "day"); !strings.Contains(got, "0") {
|
||||
t.Errorf("Monday the 12th has no fixture traffic and must total 0:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingPeriodDimensionFoldSumsAcrossDays covers the per-dimension
|
||||
// accumulation. Asserting "60 appears somewhere" was NOT enough: mutation
|
||||
// verification showed a fold that keeps only the last day still passed,
|
||||
// because 60 also appears in the KPI total and in a token column. The
|
||||
// assertion must therefore pin srcA's own cost cell, not the page's text.
|
||||
func TestBillingPeriodDimensionFoldSumsAcrossDays(t *testing.T) {
|
||||
got := runBillingPage(t, "2026-01-07", "week", "billing-by-source")
|
||||
// srcA exists on all three days at 10/20/30. Summed = 60; last-only = 30.
|
||||
if !strings.Contains(got, "USD 60.0000") {
|
||||
t.Errorf("weekly by-source did not sum srcA across days (want USD 60.0000, "+
|
||||
"last-day-only would be USD 30.0000):\n%s", got)
|
||||
}
|
||||
// srcB exists only on 01-06 at 5, inside the window, so it must survive at
|
||||
// exactly its own value.
|
||||
if !strings.Contains(got, "USD 5.0000") {
|
||||
t.Errorf("weekly by-source lost or altered srcB (want USD 5.0000):\n%s", got)
|
||||
}
|
||||
// srcA's summed cost must also differ from its last-day value in the
|
||||
// rendered table, so an overwrite cannot masquerade as a sum.
|
||||
if strings.Count(got, "USD 60.0000") < 2 {
|
||||
t.Errorf("expected the summed 60 in both the table and the KPI:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// state untouched, including the lifetime sentinel and the lifetime-only
|
||||
// degraded counter. Without this control, a rescale that silently degraded
|
||||
// into "always empty" would pass the narrow-window tests.
|
||||
// TestBillingPeriodAllStaysLifetime is the control: "all" must keep the stored
|
||||
// state untouched. Without it, a rescale that silently degraded into "always
|
||||
// empty" would pass every narrow-window test above.
|
||||
func TestBillingPeriodAllStaysLifetime(t *testing.T) {
|
||||
got := runBillingPage(t, "2026-01-07", "all", "billing-by-source")
|
||||
if !strings.Contains(got, "LIFETIMESRC") && !strings.Contains(got, "999") {
|
||||
t.Errorf("the all-time view dropped the lifetime figures:\n%s", got)
|
||||
}
|
||||
// 4242 is the lifetime degraded_reqs; it has no day-keyed tally, so it
|
||||
// must remain visible when the window is "all". The KPI goes through
|
||||
// fmtInt, so it renders grouped ("4,242") — assert the rendered form.
|
||||
if !strings.Contains(got, "4,242") {
|
||||
t.Errorf("the all-time view should still show the lifetime degraded count:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingPeriodHidesLifetimeOnlyCounters is the mismatch the period view
|
||||
// exists to remove: degraded/unpriced have no day-keyed tally, so printing
|
||||
// the lifetime number next to a per-window cost is misleading. They must be
|
||||
// hidden, not merely relabelled.
|
||||
func TestBillingPeriodHidesLifetimeOnlyCounters(t *testing.T) {
|
||||
got := runBillingPage(t, "2026-01-07", "day", "billing-by-source")
|
||||
if strings.Contains(got, "4,242") || strings.Contains(got, "Degraded") {
|
||||
t.Errorf("a one-day view still shows the lifetime degraded counter:\n%s", got)
|
||||
}
|
||||
// ...while "all" does show it (covered above), so this is not just a
|
||||
// dictionary that lost the words.
|
||||
if !strings.Contains(runBillingPage(t, "2026-01-07", "all", "billing-by-source"), "4,242") {
|
||||
t.Error("the all-time view lost the degraded counter — the hide is not period-scoped")
|
||||
}
|
||||
}
|
||||
968
internal/lua/billing_test.go
Normal file
968
internal/lua/billing_test.go
Normal file
@ -0,0 +1,968 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The billing plugin ships with the gateway, so its arithmetic is a contract:
|
||||
// a wrong price silently produces wrong money. These tests drive it through the
|
||||
// real hook path and check the NUMBERS, not merely that it loads.
|
||||
|
||||
func billingVM(t *testing.T) (*Plugins, string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "adapters")
|
||||
vm := NewVM(dir)
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("vm: %v", err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
pdir := filepath.Join(t.TempDir(), "plugins")
|
||||
ps := NewPlugins(vm, pdir)
|
||||
if err := ps.SeedBundled(); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
return ps, pdir
|
||||
}
|
||||
|
||||
// stateOf reads the plugin's published state as a generic map.
|
||||
func stateOf(t *testing.T, ps *Plugins) map[string]interface{} {
|
||||
t.Helper()
|
||||
raw := ps.State("billing")
|
||||
if raw == nil {
|
||||
t.Fatal("billing published no state")
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func approx(t *testing.T, name string, got, want float64) {
|
||||
t.Helper()
|
||||
d := got - want
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d > 1e-9 {
|
||||
t.Errorf("%s = %v, want %v (delta %v)", name, got, want, d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingZeroPricesIsSafe: with no configuration the plugin must still run
|
||||
// and report volume. A nil-price crash here would take out every request.
|
||||
func TestBillingZeroPricesIsSafe(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 100, "completion_tokens": 50, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
total := st["total"].(map[string]interface{})
|
||||
if total["requests"].(float64) != 1 {
|
||||
t.Errorf("requests = %v, want 1", total["requests"])
|
||||
}
|
||||
approx(t, "cost with no prices", total["cost"].(float64), 0)
|
||||
}
|
||||
|
||||
// TestBillingModelTokenPricing: the core case. prompt and completion are priced
|
||||
// SEPARATELY, which is how providers publish and how the total must come out.
|
||||
func TestBillingModelTokenPricing(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
// Setting prices must NOT disturb the (still empty) totals, which is the
|
||||
// whole point of the prices/state split.
|
||||
if err := ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"currency": "USD",
|
||||
"models": map[string]interface{}{
|
||||
"gpt-5.4": map[string]interface{}{"prompt": 1.25e-6, "completion": 1e-5},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 1000 prompt * 1.25e-6 = 0.00125 ; 500 completion * 1e-5 = 0.005
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "gpt-5.4", "source": "up", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 500, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
approx(t, "total cost", st["total"].(map[string]interface{})["cost"].(float64), 0.00625)
|
||||
byModel := st["by_model"].(map[string]interface{})["gpt-5.4"].(map[string]interface{})
|
||||
approx(t, "model cost", byModel["cost"].(float64), 0.00625)
|
||||
if byModel["completion_tokens"].(float64) != 500 {
|
||||
t.Errorf("completion_tokens = %v, want 500", byModel["completion_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingPerRequestAndTokenCombine: a flat fee is ADDED to the token cost,
|
||||
// which is how an image model can be "tokens + fixed fee".
|
||||
func TestBillingPerRequestAndTokenCombine(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
if err := ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{
|
||||
"kolors": map[string]interface{}{"prompt": 1e-6, "completion": 2e-6, "per_request": 0.04},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 100*1e-6 + 50*2e-6 + 0.04 = 0.0402
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "kolors", "source": "sf", "key": "***bbbbbb", "ok": true,
|
||||
"prompt_tokens": 100, "completion_tokens": 50, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
approx(t, "total", st["total"].(map[string]interface{})["cost"].(float64), 0.0402)
|
||||
}
|
||||
|
||||
// TestBillingPrecedence: keys > models > default for token prices.
|
||||
func TestBillingPrecedence(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"default": map[string]interface{}{"prompt": 9e-6, "completion": 9e-6},
|
||||
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": 2e-6, "completion": 3e-6}},
|
||||
"keys": map[string]interface{}{"***cccccc": map[string]interface{}{"prompt": 1e-6, "completion": 1.5e-6}},
|
||||
},
|
||||
})
|
||||
|
||||
// No key match -> model price.
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***other", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000,
|
||||
})
|
||||
// Key match -> key price wins.
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***cccccc", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
// 1000*2e-6 + 1000*3e-6 = 0.005 ; 1000*1e-6 + 1000*1.5e-6 = 0.0025
|
||||
approx(t, "total (model + key)", st["total"].(map[string]interface{})["cost"].(float64), 0.0075)
|
||||
|
||||
// An unpriced model falls back to default.
|
||||
ps2, _ := billingVM(t)
|
||||
_ = ps2.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"default": map[string]interface{}{"prompt": 9e-6, "completion": 9e-6},
|
||||
},
|
||||
})
|
||||
ps2.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "unknown", "source": "s", "key": "***d", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000,
|
||||
})
|
||||
approx(t, "default fallback", stateOf(t, ps2)["total"].(map[string]interface{})["cost"].(float64), 0.018)
|
||||
}
|
||||
|
||||
// TestBillingAggregatesEveryDimension: one request must land in all four
|
||||
// rollups plus the daily bucket. A missing dimension is the kind of bug a
|
||||
// dashboard hides (it just renders an empty table).
|
||||
func TestBillingAggregatesEveryDimension(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"models": map[string]interface{}{"m1": map[string]interface{}{"prompt": 1e-6, "completion": 1e-6}},
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m1", "source": "srcA", "key": "***key01", "ok": true,
|
||||
"prompt_tokens": 100, "completion_tokens": 100, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
for _, dim := range []string{"by_source", "by_model", "by_key", "by_day"} {
|
||||
m, ok := st[dim].(map[string]interface{})
|
||||
if !ok || len(m) == 0 {
|
||||
t.Errorf("%s is empty; a dimension is missing", dim)
|
||||
}
|
||||
}
|
||||
if _, ok := st["by_source"].(map[string]interface{})["srcA"]; !ok {
|
||||
t.Error("by_source lacks srcA")
|
||||
}
|
||||
if _, ok := st["by_key"].(map[string]interface{})["***key01"]; !ok {
|
||||
t.Error("by_key lacks the gateway key")
|
||||
}
|
||||
// Milliseconds must be converted, not used as seconds: a raw 1750000000000
|
||||
// would land in a year-57000 bucket.
|
||||
days := st["by_day"].(map[string]interface{})
|
||||
found := false
|
||||
for k := range days {
|
||||
if len(k) == 10 && strings.Contains(k, "-") {
|
||||
found = true
|
||||
}
|
||||
if strings.HasPrefix(k, "5") && len(k) > 6 {
|
||||
t.Errorf("by_day key %q suggests millisecond timestamps were not converted", k)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("by_day has no YYYY-MM-DD key: %v", days)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingFailedRequestPolicy: a failed request keeps its token cost (tokens
|
||||
// really were consumed) but drops the flat per_request fee (never charged).
|
||||
func TestBillingFailedRequestPolicy(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{
|
||||
"m": map[string]interface{}{"prompt": 1e-6, "completion": 1e-6, "per_request": 0.5},
|
||||
},
|
||||
},
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***e", "ok": false, "status": 500,
|
||||
"prompt_tokens": 1000, "completion_tokens": 0, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
// 1000*1e-6 = 0.001, flat dropped.
|
||||
approx(t, "failed request", st["total"].(map[string]interface{})["cost"].(float64), 0.001)
|
||||
if st["total"].(map[string]interface{})["failures"].(float64) != 1 {
|
||||
t.Error("failures not counted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingStateAPIReplace: the admin price update must actually change
|
||||
// subsequent pricing (not just be stored).
|
||||
func TestBillingStateAPIReplace(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": 1e-6, "completion": 0}},
|
||||
},
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***f", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 0, "time": 1750000000000,
|
||||
})
|
||||
approx(t, "before reprice", stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64), 0.001)
|
||||
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": 2e-6, "completion": 0}},
|
||||
},
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***f", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 0, "time": 1750000000000,
|
||||
})
|
||||
// 0.001 (old) + 0.002 (new price)
|
||||
approx(t, "after reprice", stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64), 0.003)
|
||||
}
|
||||
|
||||
// TestBillingPluginDeclaresUI: the shipped plugin must ship its dashboard, or
|
||||
// "billing is enabled" would be true while showing the user nothing.
|
||||
func TestBillingPluginDeclaresUI(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
for _, row := range ps.List() {
|
||||
if row["name"] != "billing" {
|
||||
continue
|
||||
}
|
||||
ui, ok := row["ui"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("billing declares no ui")
|
||||
}
|
||||
if page, _ := ui["page"].(string); page != "billing" {
|
||||
t.Errorf("ui.page = %v, want \"billing\"", ui["page"])
|
||||
}
|
||||
// ONE page, not two. The rule editor and the totals share a sidebar
|
||||
// entry because billing is one thing: prices decide the numbers and the
|
||||
// numbers are the result of the prices. Split across two pages, saving a
|
||||
// price left no on-screen way to see its effect — the loop the operator
|
||||
// actually works in was cut in half.
|
||||
pages, _ := ui["pages"].([]string)
|
||||
if len(pages) != 1 || pages[0] != "billing" {
|
||||
t.Errorf("ui.pages = %v, want exactly one page [billing]", pages)
|
||||
}
|
||||
if n, _ := ui["elements"].(int); n < 1 {
|
||||
t.Error("billing contributes no element to an existing page")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("billing plugin is not loaded")
|
||||
}
|
||||
|
||||
// TestBillingRuleEditorLivesInsideTheBillingPage: the rule editor is a VIEW
|
||||
// inside the billing page, not a second sidebar entry.
|
||||
//
|
||||
// This started as a separate page and was wrong: saving a price sent the
|
||||
// operator to a different screen to find out whether it worked. A single page
|
||||
// with a view switch keeps the loop closed, and the rule editor already refreshes
|
||||
// the usage numbers on save, so the effect is visible immediately.
|
||||
//
|
||||
// The mount must therefore carry BOTH the usage tables and the rule editor, and
|
||||
// the switch that toggles between them.
|
||||
func TestBillingRuleEditorLivesInsideTheBillingPage(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
var ui *UIExtension
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "billing" {
|
||||
ui = p.UI
|
||||
}
|
||||
}
|
||||
if ui == nil || ui.Page == nil {
|
||||
t.Fatal("billing plugin loaded with no page")
|
||||
}
|
||||
if ui.Page.PageID != "billing" {
|
||||
t.Errorf("page id = %q, want billing", ui.Page.PageID)
|
||||
}
|
||||
mount := ui.Page.Mount
|
||||
|
||||
// Both halves on one page.
|
||||
for _, needle := range []string{`id="billing-kpis"`, `id="billing-by-source"`} {
|
||||
if !strings.Contains(mount, needle) {
|
||||
t.Errorf("the usage half is missing %s", needle)
|
||||
}
|
||||
}
|
||||
for _, needle := range []string{
|
||||
`id="br-body"`,
|
||||
"data-act='save'", "data-act='export'", "data-act='newprofile'",
|
||||
".r-url", ".r-mode",
|
||||
} {
|
||||
if !strings.Contains(mount, needle) {
|
||||
t.Errorf("the rule editor is missing %s", needle)
|
||||
}
|
||||
}
|
||||
// The switch itself, or the two halves are both on screen at once.
|
||||
for _, needle := range []string{`id="billing-view-usage"`, `id="billing-view-rules"`, `window.__billing_view(`} {
|
||||
if !strings.Contains(mount, needle) {
|
||||
t.Errorf("the view switch is missing %s", needle)
|
||||
}
|
||||
}
|
||||
// The rules view must start hidden, otherwise it renders under the usage
|
||||
// tables and the page is a wall of two editors stacked.
|
||||
if !strings.Contains(mount, `id="billing-view-rules" style="display:none;`) {
|
||||
t.Error(`the rules view does not start hidden — both halves would render at once`)
|
||||
}
|
||||
// NO DUPLICATE IDS between the switch buttons and the view containers.
|
||||
// They shipped as billing-view-<name> on BOTH the button and the pane, so
|
||||
// document.getElementById returned the 72px-wide BUTTON for the pane and
|
||||
// every measurement was of the wrong element: the table measured 0 wide and
|
||||
// the page looked broken while the layout was fine. The ids must be unique
|
||||
// and the buttons carry a distinct suffix.
|
||||
for _, id := range []string{`billing-view-usage`, `billing-view-rules`} {
|
||||
if n := strings.Count(mount, `id="`+id+`"`); n != 1 {
|
||||
t.Errorf("id %q appears %d times; getElementById would return the wrong element", id, n)
|
||||
}
|
||||
if !strings.Contains(mount, `id="`+id+`-btn"`) {
|
||||
t.Errorf("switch button for %q is missing its -btn id", id)
|
||||
}
|
||||
}
|
||||
// Both view containers must FILL the pane. The host's tab-pane is a flex
|
||||
// column, so a plain div shrinks to its content: without flex:1/width:100%
|
||||
// the rules view measured 72px wide and its table measured 0 — the page
|
||||
// rendered "nothing" while the DOM was perfectly correct. This is exactly
|
||||
// the class of bug a DOM assertion misses and a screenshot catches.
|
||||
for _, needle := range []string{
|
||||
`id="billing-view-usage" style="flex:1`,
|
||||
`id="billing-view-rules" style="display:none;flex:1`,
|
||||
} {
|
||||
if !strings.Contains(mount, needle) {
|
||||
t.Errorf("view container does not fill the pane: %q missing — "+
|
||||
"a flex item without flex:1 collapses to content width", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIExtensionMergesEveryPluginPage: two plugins contributing pages must
|
||||
// BOTH appear. The old merge assigned a single field, so the second plugin
|
||||
// erased the first one's page from the sidebar with no error anywhere.
|
||||
func TestUIExtensionMergesEveryPluginPage(t *testing.T) {
|
||||
ps, pdir := billingVM(t)
|
||||
mk := func(name, code string) {
|
||||
if err := os.WriteFile(filepath.Join(pdir, name+".lua"), []byte(code), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource(name, code); err != nil {
|
||||
t.Fatalf("load %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
mk("other", `
|
||||
local plugin = {}
|
||||
plugin.name = "other"
|
||||
plugin.version = "0.1"
|
||||
plugin.ui = { page = { page_id = "other-page", title = "Other", order = 90,
|
||||
mount = "<div id='other-root'></div>" } }
|
||||
return plugin`)
|
||||
mk("third", `
|
||||
local plugin = {}
|
||||
plugin.name = "third"
|
||||
plugin.version = "0.1"
|
||||
plugin.ui = { pages = {
|
||||
{ page_id = "third-a", title = "Third A", order = 80, mount = "<div id='ta'></div>" },
|
||||
{ page_id = "third-b", title = "Third B", order = 81, mount = "<div id='tb'></div>" },
|
||||
} }
|
||||
return plugin`)
|
||||
|
||||
ui := ps.UI()
|
||||
if ui == nil {
|
||||
t.Fatal("no merged UI")
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, pg := range ui.Pages {
|
||||
got[pg.PageID] = true
|
||||
}
|
||||
// billing contributes exactly one page (the rule editor is a view inside it),
|
||||
// so it is listed once. The multi-page merging is exercised by the three
|
||||
// plugins added here.
|
||||
for _, want := range []string{"billing", "other-page", "third-a", "third-b"} {
|
||||
if !got[want] {
|
||||
t.Errorf("merged UI lost page %q; has %v", want, got)
|
||||
}
|
||||
}
|
||||
// A duplicate page_id must not appear twice. Two plugins claiming the same
|
||||
// id collide in the DOM (getElementById returns the first, the second pane
|
||||
// is silently unreachable), so the merge keeps the first and drops the
|
||||
// later one.
|
||||
mk("collide", `
|
||||
local plugin = {}
|
||||
plugin.name = "collide"
|
||||
plugin.version = "0.1"
|
||||
plugin.ui = { pages = {
|
||||
{ page_id = "third-a", title = "Impostor", order = 79, mount = "<div id='impostor'></div>" },
|
||||
} }
|
||||
return plugin`)
|
||||
ui = ps.UI()
|
||||
counts := map[string]int{}
|
||||
for _, pg := range ui.Pages {
|
||||
counts[pg.PageID]++
|
||||
}
|
||||
if counts["third-a"] != 1 {
|
||||
t.Errorf("page id third-a appears %d times; a duplicate id collides in the DOM", counts["third-a"])
|
||||
}
|
||||
for _, pg := range ui.Pages {
|
||||
if pg.PageID == "third-a" && pg.Title == "Impostor" {
|
||||
t.Error("the LATER plugin won the id; first writer should keep it")
|
||||
}
|
||||
}
|
||||
|
||||
// Order must be honoured so the sidebar is predictable.
|
||||
if len(ui.Pages) != 4 {
|
||||
t.Fatalf("expected all 4 pages to merge, got %d: %v", len(ui.Pages), got)
|
||||
}
|
||||
for i := 1; i < len(ui.Pages); i++ {
|
||||
if ui.Pages[i].Order < ui.Pages[i-1].Order {
|
||||
t.Errorf("pages out of order at %d: %d before %d",
|
||||
i, ui.Pages[i-1].Order, ui.Pages[i].Order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingPluginLoadedByDefault: the shipped plugin must load with no
|
||||
// configuration, since seeding only happens on a fresh plugin dir.
|
||||
func TestBillingPluginLoadedByDefault(t *testing.T) {
|
||||
ps, pdir := billingVM(t)
|
||||
if ps.Count() != 1 {
|
||||
t.Fatalf("expected 1 bundled plugin, got %d", ps.Count())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(pdir, "billing.lua")); err != nil {
|
||||
t.Errorf("billing.lua was not written to the plugin dir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingCountsDegradations: the plugin must distinguish a request that had
|
||||
// to drop below the top tier from one the top tier served. Without the chain
|
||||
// trace these were identical in the accounts, so a quietly degraded gateway
|
||||
// looked healthy while spending more per request.
|
||||
func TestBillingCountsDegradations(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{
|
||||
"hi-tier": map[string]interface{}{"prompt": 1e-5, "completion": 1e-5},
|
||||
"lo-tier": map[string]interface{}{"prompt": 1e-6, "completion": 1e-6},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Request 1: degraded. tier 1 hard-failed, tier 2 served it.
|
||||
ps.Fire(StageChainStep, map[string]interface{}{
|
||||
"kind": "slot_fail", "tier": 1, "source": "t1", "model": "hi-tier",
|
||||
})
|
||||
ps.Fire(StageChainStep, map[string]interface{}{
|
||||
"kind": "selected", "tier": 2, "source": "t2", "model": "lo-tier",
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "lo-tier", "source": "t2", "key": "***d1", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000,
|
||||
"degraded": true, "tier_served": 2, "time": 1750000000000,
|
||||
})
|
||||
|
||||
// Request 2: clean, served by the top tier.
|
||||
ps.Fire(StageChainStep, map[string]interface{}{
|
||||
"kind": "selected", "tier": 1, "source": "t1", "model": "hi-tier",
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "hi-tier", "source": "t1", "key": "***d1", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000,
|
||||
"degraded": false, "tier_served": 1, "time": 1750000000000,
|
||||
})
|
||||
|
||||
st := stateOf(t, ps)
|
||||
if got := st["degraded_reqs"].(float64); got != 1 {
|
||||
t.Errorf("degraded_reqs = %v, want 1 (one of the two requests dropped a tier)", got)
|
||||
}
|
||||
tiers := st["by_tier_served"].(map[string]interface{})
|
||||
if tiers["2"].(float64) != 1 {
|
||||
t.Errorf("by_tier_served[2] = %v, want 1", tiers["2"])
|
||||
}
|
||||
if tiers["1"].(float64) != 1 {
|
||||
t.Errorf("by_tier_served[1] = %v, want 1", tiers["1"])
|
||||
}
|
||||
// Cost reflects the model actually served, not the one that should have been.
|
||||
// 1000*1e-6*2 = 0.002 for the degraded one, 1000*1e-5*2 = 0.02 for the clean one.
|
||||
approx(t, "total", st["total"].(map[string]interface{})["cost"].(float64), 0.022)
|
||||
}
|
||||
|
||||
// TestBillingAggregatesSkipReasons: skip reasons are the actionable diagnostic
|
||||
// ("no schedulable slot (cooling or quota exhausted)"), so they must be
|
||||
// counted. The wait time is normalised, otherwise a fresh row per request would
|
||||
// appear whenever the busy-wait text varies.
|
||||
func TestBillingAggregatesSkipReasons(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
ps.Fire(StageChainStep, map[string]interface{}{
|
||||
"kind": "tier_skip", "tier": 1, "reason": "no schedulable slot (cooling or quota exhausted)",
|
||||
})
|
||||
ps.Fire(StageChainStep, map[string]interface{}{
|
||||
"kind": "tier_busy", "tier": 2, "reason": "no free slot within 2s",
|
||||
})
|
||||
ps.Fire(StageChainStep, map[string]interface{}{
|
||||
"kind": "tier_busy", "tier": 3, "reason": "no free slot within 2.0001s",
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
reasons := st["skip_reasons"].(map[string]interface{})
|
||||
if len(reasons) != 2 {
|
||||
t.Errorf("skip_reasons = %v, want 2 (the two variable waits must collapse to one)", reasons)
|
||||
}
|
||||
busy, ok := reasons["no free slot within <wait>"]
|
||||
if !ok {
|
||||
t.Errorf("busy reason missing; got %v", reasons)
|
||||
} else if busy.(float64) != 2 {
|
||||
t.Errorf("busy count = %v, want 2 (two different wait texts, one cause)", busy)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- prompt-cache pricing -------------------------------------------------
|
||||
//
|
||||
// A cached prompt token is not a fresh one. Charging the full prompt rate made a
|
||||
// 1M-token request of which 900k were cache reads cost 10 USD instead of ~1.9
|
||||
// — an order of magnitude, on exactly the traffic the cache exists to make
|
||||
// cheap. Agent traffic replays long shared prefixes constantly, so this was the
|
||||
// single largest source of over-billing in the plugin.
|
||||
|
||||
func TestBillingCacheHitsAreDiscounted(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{
|
||||
"m": map[string]interface{}{"prompt": 1e-5, "completion": 1e-5},
|
||||
},
|
||||
},
|
||||
})
|
||||
// 1M prompt of which 900k cached, default discount 0.1
|
||||
// 100k fresh * 1e-5 = 1.0 ; 900k cached * 1e-5 * 0.1 = 0.9
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***c", "ok": true,
|
||||
"prompt_tokens": 1000000, "completion_tokens": 0,
|
||||
"cache_hit_tokens": 900000, "time": 1750000000000,
|
||||
})
|
||||
approx(t, "cache-discounted cost", stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64), 1.9)
|
||||
}
|
||||
|
||||
// A per-model discount overrides the global one, because the ratio is a
|
||||
// per-provider fact, not a constant.
|
||||
func TestBillingCacheDiscountIsPerModel(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{
|
||||
"free-cache": map[string]interface{}{
|
||||
"prompt": 1e-5, "completion": 0, "cache_discount": 0,
|
||||
},
|
||||
"flat": map[string]interface{}{
|
||||
"prompt": 1e-5, "completion": 0, "cache_discount": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
for _, m := range []string{"free-cache", "flat"} {
|
||||
ps2, _ := billingVM(t)
|
||||
_ = ps2.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{
|
||||
m: map[string]interface{}{"prompt": 1e-5, "completion": 0, "cache_discount": map[bool]float64{true: 0, false: 1}[m == "free-cache"]},
|
||||
},
|
||||
},
|
||||
})
|
||||
ps2.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": m, "source": "s", "key": "***c", "ok": true,
|
||||
"prompt_tokens": 1000000, "cache_hit_tokens": 1000000,
|
||||
"time": 1750000000000,
|
||||
})
|
||||
want := 0.0
|
||||
if m == "flat" {
|
||||
want = 10.0
|
||||
}
|
||||
approx(t, m+" (all cached)", stateOf(t, ps2)["total"].(map[string]interface{})["cost"].(float64), want)
|
||||
}
|
||||
}
|
||||
|
||||
// A misbehaving adapter reporting more cache hits than prompt tokens must not
|
||||
// produce negative fresh tokens.
|
||||
func TestBillingCacheHitClampedToPrompt(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": 1e-5, "completion": 0}},
|
||||
},
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***c", "ok": true,
|
||||
"prompt_tokens": 100, "completion_tokens": 0,
|
||||
"cache_hit_tokens": 999999, // nonsense from a broken adapter
|
||||
"time": 1750000000000,
|
||||
})
|
||||
// Clamped to 100 cached, 0 fresh => 100 * 1e-5 * 0.1
|
||||
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
|
||||
if got < 0 {
|
||||
t.Errorf("cost = %v, must never be negative", got)
|
||||
}
|
||||
approx(t, "clamped cost", got, 0.0001)
|
||||
}
|
||||
|
||||
// ---- unpriced traffic -----------------------------------------------------
|
||||
|
||||
// An unpriced model silently costing 0 is the most dangerous failure a cost
|
||||
// plugin has: the bill still adds up, it just quietly under-reports, and
|
||||
// nothing looks broken. It must be counted and named.
|
||||
func TestBillingCountsUnpricedTraffic(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{
|
||||
"priced": map[string]interface{}{"prompt": 1e-5},
|
||||
},
|
||||
},
|
||||
})
|
||||
// 100k+100k tokens on a model with no price entry.
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "MYSTERY-MODEL", "source": "s", "key": "***u", "ok": true,
|
||||
"prompt_tokens": 100000, "completion_tokens": 100000, "time": 1750000000000,
|
||||
})
|
||||
// A priced one, to prove the counter is selective.
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "priced", "source": "s", "key": "***u", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 0, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
if got := st["unpriced_reqs"].(float64); got != 1 {
|
||||
t.Errorf("unpriced_reqs = %v, want 1 (only the mystery model)", got)
|
||||
}
|
||||
models := st["unpriced_models"].(map[string]interface{})
|
||||
if models["MYSTERY-MODEL"].(float64) != 1 {
|
||||
t.Errorf("unpriced_models = %v, want MYSTERY-MODEL counted", models)
|
||||
}
|
||||
if _, present := models["priced"]; present {
|
||||
t.Error("a priced model was counted as unpriced")
|
||||
}
|
||||
// The traffic is still recorded: "unpriced" must not mean "invisible".
|
||||
if got := st["total"].(map[string]interface{})["requests"].(float64); got != 2 {
|
||||
t.Errorf("total requests = %v, want 2 (unpriced traffic is still traffic)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A source-only or key-only price counts as priced: any dimension covering the
|
||||
// request is enough.
|
||||
func TestBillingAnyDimensionCountsAsPriced(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"sources": map[string]interface{}{"flat-fee": map[string]interface{}{"per_request": 0.02}},
|
||||
},
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "any-model", "source": "flat-fee", "key": "***p", "ok": true,
|
||||
"prompt_tokens": 10, "completion_tokens": 0, "time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
if got := st["unpriced_reqs"].(float64); got != 0 {
|
||||
t.Errorf("unpriced_reqs = %v, want 0 (the source price covers it)", got)
|
||||
}
|
||||
approx(t, "flat fee", st["total"].(map[string]interface{})["cost"].(float64), 0.02)
|
||||
}
|
||||
|
||||
// ---- 峰谷 / 时段定价 ---------------------------------------------------
|
||||
//
|
||||
// commandcode 的 DeepSeek V4 系列就是这么定价的:非高峰 17h/天,高峰 01-04 &
|
||||
// 06-10 UTC 工作日,价格恰好 2 倍。这类规则用静态价目无法表达,而算错方向是
|
||||
// 静默的——不会报错,只会一直算错。
|
||||
//
|
||||
// 时间判据的可测性:os.date("!%H") 取 UTC 小时。测试通过选择"确定落在窗口内"
|
||||
// 与"确定落在窗口外"的时段来判定,不去伪造时钟(Lua 侧没有可注入的时钟,
|
||||
// 伪造反而会让测试与真实行为脱节)。
|
||||
|
||||
func TestBillingPeakWindowDoublesOutsidePeak(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{
|
||||
"dsv41": map[string]interface{}{
|
||||
"prompt": 1.5e-7, "completion": 6e-7,
|
||||
"peak": map[string]interface{}{
|
||||
"multiplier": 2,
|
||||
// 全部 7 天全部 24 小时 ⇒ 永远命中
|
||||
"windows": []interface{}{
|
||||
map[string]interface{}{
|
||||
"days": []interface{}{0, 1, 2, 3, 4, 5, 6},
|
||||
"hours": []interface{}{[]interface{}{0, 23}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// 1000 prompt + 1000 completion,非高峰 0.00075 → 命中峰谷 ×2 = 0.0015
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "dsv41", "source": "commandcode", "key": "***pk", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000,
|
||||
})
|
||||
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
|
||||
want := (1000*1.5e-7 + 1000*6e-7) * 2
|
||||
approx(t, "always-peak cost", got, want)
|
||||
}
|
||||
|
||||
// 一个不存在的窗口(UTC 25 点不存在)⇒ 永不命中 ⇒ 静态价。
|
||||
func TestBillingPeakWindowNotHit(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{
|
||||
"dsv41": map[string]interface{}{
|
||||
"prompt": 1.5e-7, "completion": 6e-7,
|
||||
"peak": map[string]interface{}{
|
||||
"multiplier": 2,
|
||||
// 星期 = {0..6} 但小时窗写成 [99,100]:永远不可能命中
|
||||
"windows": []interface{}{
|
||||
map[string]interface{}{
|
||||
"days": []interface{}{0, 1, 2, 3, 4, 5, 6},
|
||||
"hours": []interface{}{[]interface{}{99, 100}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "dsv41", "source": "commandcode", "key": "***pk", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000,
|
||||
})
|
||||
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
|
||||
approx(t, "never-peak cost", got, 1000*1.5e-7+1000*6e-7)
|
||||
}
|
||||
|
||||
// 星期不匹配 ⇒ 不命中。这一条正是"用本地时区算会整体偏移"要防的东西:
|
||||
// 周日按 UTC 算,用本地时区可能算成周六而错误地命中工作日窗口。
|
||||
func TestBillingPeakWindowDayMismatch(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{
|
||||
"dsv41": map[string]interface{}{
|
||||
"prompt": 1.5e-7, "completion": 6e-7,
|
||||
"peak": map[string]interface{}{
|
||||
"multiplier": 2,
|
||||
// 只在"不存在的星期 7"上开窗(os.date %w 只到 0..6)
|
||||
"windows": []interface{}{
|
||||
map[string]interface{}{
|
||||
"days": []interface{}{7},
|
||||
"hours": []interface{}{[]interface{}{0, 23}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "dsv41", "source": "commandcode", "key": "***pk", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000,
|
||||
})
|
||||
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
|
||||
if got > (1000*1.5e-7+1000*6e-7)*1.5 {
|
||||
t.Errorf("cost = %v: a non-matching weekday must not trigger the peak multiplier", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有 peak 规则的条目完全不受影响(向后兼容)。
|
||||
func TestBillingNoPeakRuleIsUnaffected(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{
|
||||
"plain": map[string]interface{}{"prompt": 1e-6, "completion": 2e-6},
|
||||
},
|
||||
},
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "plain", "source": "s", "key": "***n", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "time": 1750000000000,
|
||||
})
|
||||
approx(t, "no-peak cost", stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64), 1000*1e-6+1000*2e-6)
|
||||
}
|
||||
|
||||
// 缓存读价【不】跟着峰谷翻倍:它是另一条上游费率,观测到的非峰谷价里已经含了
|
||||
// 自己的折扣,跟着翻倍会把两个折扣叠在一起。
|
||||
func TestBillingPeakDoesNotDoubleCacheRead(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
_ = ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"models": map[string]interface{}{
|
||||
"dsv41": map[string]interface{}{
|
||||
"prompt": 1.5e-7, "completion": 6e-7, "cache_discount": 0.02,
|
||||
"peak": map[string]interface{}{
|
||||
"multiplier": 2,
|
||||
"windows": []interface{}{map[string]interface{}{
|
||||
"days": []interface{}{0, 1, 2, 3, 4, 5, 6},
|
||||
"hours": []interface{}{[]interface{}{0, 23}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "dsv41", "source": "commandcode", "key": "***c", "ok": true,
|
||||
"prompt_tokens": 1000000, "completion_tokens": 0,
|
||||
"cache_hit_tokens": 1000000, "time": 1750000000000,
|
||||
})
|
||||
// 全部缓存命中 ⇒ 只按 cache 价 = prompt * 0.02,且不翻倍
|
||||
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
|
||||
approx(t, "cache-only cost (not doubled)", got, 1e6*1.5e-7*0.02)
|
||||
}
|
||||
|
||||
// TestBillingTracksCacheUsage is the guard for the gap production exposed: the
|
||||
// gateway had prompt_cache_hit_tokens and costFor() priced the cache leg, but no
|
||||
// bucket recorded the number. On a gateway where 99.88% of prompt tokens were
|
||||
// cache reads, the report showed a prompt_tokens figure with no way to tell that
|
||||
// most of it was cached.
|
||||
func TestBillingTracksCacheUsage(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "k", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 50,
|
||||
"cache_hit_tokens": 900, "cache_reported": true,
|
||||
})
|
||||
// A second request from a source that does not report caching at all.
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m2", "source": "s2", "ok": true,
|
||||
"prompt_tokens": 100, "completion_tokens": 10,
|
||||
})
|
||||
|
||||
st := ps.State("billing").(map[string]interface{})
|
||||
total := st["total"].(map[string]interface{})
|
||||
if total["cache_hit_tokens"] != float64(900) {
|
||||
t.Errorf("total.cache_hit_tokens = %v, want 900", total["cache_hit_tokens"])
|
||||
}
|
||||
if total["cache_fresh_tokens"] != float64(200) {
|
||||
t.Errorf("total.cache_fresh_tokens = %v, want 200 (1000-900 + 100)", total["cache_fresh_tokens"])
|
||||
}
|
||||
// Only the first request reported a cache number.
|
||||
if total["cache_reported_reqs"] != float64(1) {
|
||||
t.Errorf("★ total.cache_reported_reqs = %v, want 1 — a source that never "+
|
||||
"reports cache usage must be distinguishable from one reporting zero hits",
|
||||
total["cache_reported_reqs"])
|
||||
}
|
||||
|
||||
// Per-source separation.
|
||||
bySrc := st["by_source"].(map[string]interface{})
|
||||
s1 := bySrc["s"].(map[string]interface{})
|
||||
if s1["cache_hit_tokens"] != float64(900) {
|
||||
t.Errorf("by_source[s].cache_hit_tokens = %v, want 900", s1["cache_hit_tokens"])
|
||||
}
|
||||
s2 := bySrc["s2"].(map[string]interface{})
|
||||
if s2["cache_reported_reqs"] != float64(0) {
|
||||
t.Errorf("by_source[s2].cache_reported_reqs = %v, want 0", s2["cache_reported_reqs"])
|
||||
}
|
||||
if s2["cache_fresh_tokens"] != float64(100) {
|
||||
t.Errorf("by_source[s2].cache_fresh_tokens = %v, want 100", s2["cache_fresh_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingCacheBucketsSurviveOlderStateFiles: a state file written before these
|
||||
// fields existed must not crash the hook. `nil + number` is an error in Lua, and
|
||||
// a hook that throws stops accounting for that request entirely — which is how a
|
||||
// billing gap turns into a silent one.
|
||||
func TestBillingCacheBucketsSurviveOlderStateFiles(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
// Simulate a state restored from an older build: buckets without the new keys.
|
||||
legacy := map[string]interface{}{
|
||||
"total": map[string]interface{}{
|
||||
"cost": 1.0, "requests": float64(5), "prompt_tokens": float64(500),
|
||||
"completion_tokens": float64(50), "failures": float64(0),
|
||||
},
|
||||
"by_source": map[string]interface{}{
|
||||
"legacy": map[string]interface{}{"cost": float64(0), "requests": float64(5),
|
||||
"prompt_tokens": float64(500), "completion_tokens": float64(50), "failures": float64(0)},
|
||||
},
|
||||
"by_model": map[string]interface{}{}, "by_key": map[string]interface{}{},
|
||||
"by_day": map[string]interface{}{}, "started": float64(0),
|
||||
}
|
||||
if err := ps.SetState("billing", legacy); err != nil {
|
||||
t.Fatalf("SetState: %v", err)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "legacy", "ok": true,
|
||||
"prompt_tokens": 100, "completion_tokens": 10,
|
||||
"cache_hit_tokens": 60, "cache_reported": true,
|
||||
})
|
||||
if len(ps.HookErrors()) != 0 {
|
||||
t.Fatalf("hook error on a legacy state: %v", ps.HookErrors())
|
||||
}
|
||||
st := ps.State("billing").(map[string]interface{})
|
||||
tot := st["total"].(map[string]interface{})
|
||||
if tot["requests"] != float64(6) {
|
||||
t.Errorf("requests = %v, want 6 (5 legacy + 1 new)", tot["requests"])
|
||||
}
|
||||
if tot["cache_hit_tokens"] != float64(60) {
|
||||
t.Errorf("cache_hit_tokens = %v, want 60", tot["cache_hit_tokens"])
|
||||
}
|
||||
lg := st["by_source"].(map[string]interface{})["legacy"].(map[string]interface{})
|
||||
if lg["cache_hit_tokens"] != float64(60) {
|
||||
t.Errorf("legacy bucket cache_hit_tokens = %v, want 60", lg["cache_hit_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingCacheHitClampedInStats: costFor clamps the cache leg, so the
|
||||
// recorded numbers must be clamped the same way. A provider that reports more
|
||||
// cache hits than prompt tokens must not produce negative fresh tokens.
|
||||
func TestBillingCacheHitClampedInStats(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "ok": true,
|
||||
"prompt_tokens": 100, "completion_tokens": 5,
|
||||
"cache_hit_tokens": 5000, "cache_reported": true,
|
||||
})
|
||||
st := ps.State("billing").(map[string]interface{})
|
||||
tot := st["total"].(map[string]interface{})
|
||||
if tot["cache_hit_tokens"] != float64(100) {
|
||||
t.Errorf("★ cache_hit_tokens = %v, want 100 (clamped to prompt_tokens)",
|
||||
tot["cache_hit_tokens"])
|
||||
}
|
||||
if tot["cache_fresh_tokens"] != float64(0) {
|
||||
t.Errorf("★ cache_fresh_tokens = %v, want 0, never negative",
|
||||
tot["cache_fresh_tokens"])
|
||||
}
|
||||
}
|
||||
416
internal/lua/billing_ui_test.go
Normal file
416
internal/lua/billing_ui_test.go
Normal file
@ -0,0 +1,416 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The billing page rendered EMPTY in production while its data endpoint returned
|
||||
// 200 with real numbers. The cause was one line: render(st) referenced an
|
||||
// undefined `s` for two KPI cells, so the ReferenceError aborted the whole
|
||||
// render and every table stayed at its initial empty innerHTML.
|
||||
//
|
||||
// Nothing in the build, the tests or the API surfaced it. This file is the guard
|
||||
// for the whole class: a plugin's injected UI that references an undefined name,
|
||||
// or that depends on a container the page does not provide, fails silently.
|
||||
|
||||
// billingUI returns the injected markup for the billing plugin: the full page
|
||||
// mount and the status-page element mount.
|
||||
func billingUI(t *testing.T) (page string, statusElement string) {
|
||||
t.Helper()
|
||||
src, err := os.ReadFile("plugins/billing.lua")
|
||||
if err != nil {
|
||||
t.Fatalf("read billing.lua: %v", err)
|
||||
}
|
||||
// Located BY CONTENT, not by index. The plugin also uses a long string for
|
||||
// its inline SVG icon, so "the first long string" is the icon and "the
|
||||
// second" is the page — which is exactly the kind of positional assumption
|
||||
// that breaks the next time an icon or a description is added.
|
||||
page = longStringContaining(t, string(src), "billing-root")
|
||||
statusElement = longStringContaining(t, string(src), "billing-status-tile")
|
||||
return page, statusElement
|
||||
}
|
||||
|
||||
// longStringContaining returns the [==[ ... ]==] body that contains marker.
|
||||
func longStringContaining(t *testing.T, src, marker string) string {
|
||||
t.Helper()
|
||||
re := regexp.MustCompile(`(?s)\[==\[(.*?)\]==\]`)
|
||||
for _, m := range re.FindAllStringSubmatch(src, -1) {
|
||||
if strings.Contains(m[1], marker) {
|
||||
return m[1]
|
||||
}
|
||||
}
|
||||
t.Fatalf("no long string contains %q", marker)
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestBillingMountScriptExecutes is the guard for the production bug.
|
||||
//
|
||||
// The Billing page rendered empty while its data endpoint returned 200 with real
|
||||
// numbers. Cause: render(st) referenced an undefined `s` for two KPI cells, the
|
||||
// ReferenceError aborted the render, and every table kept its initial empty
|
||||
// innerHTML. Nothing in the build or the API surfaced it.
|
||||
//
|
||||
// Two earlier attempts at a static check were both wrong: a "declared names"
|
||||
// scan flagged every CSS class inside the inline HTML strings (class, div, td),
|
||||
// and a CSS-selector parse of the stylesheet reported the stylesheet itself as
|
||||
// broken. Static analysis of JS embedded in HTML strings is the wrong tool.
|
||||
//
|
||||
// So this actually RUNS the script, in node, against a minimal DOM stub, and
|
||||
// fails on any thrown error. Skipped when node is unavailable, with the reason
|
||||
// printed — never silently passing as if it had checked.
|
||||
func TestBillingMountScriptExecutes(t *testing.T) {
|
||||
page, el := billingUI(t)
|
||||
scripts := extractScripts(page)
|
||||
if len(scripts) == 0 {
|
||||
t.Fatal("no <script> found in the billing page mount")
|
||||
}
|
||||
for i, js := range scripts {
|
||||
assertRendersAndDoesNotThrow(t, i, js)
|
||||
}
|
||||
for i, js := range extractScripts(el) {
|
||||
assertRendersAndDoesNotThrow(t, i, js)
|
||||
}
|
||||
}
|
||||
|
||||
// assertRendersAndDoesNotThrow executes a mount script under node against a DOM
|
||||
// stub and fails on EITHER a thrown/reported error OR an empty render.
|
||||
//
|
||||
// Checking only for exceptions is not enough, and that is the third wrong
|
||||
// attempt at this guard. The plugin's render() opens with `if (!st) return;`,
|
||||
// so passing the wrong field (`render(j.stateX)`) is a SILENT no-op: no throw,
|
||||
// no console.error, no rejection — just an empty page. Only looking at the
|
||||
// produced DOM catches that class.
|
||||
func assertRendersAndDoesNotThrow(t *testing.T, idx int, js string) {
|
||||
t.Helper()
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skipf("node not available (%v): cannot execute the injected script", err)
|
||||
}
|
||||
|
||||
stub := `
|
||||
global.window = global;
|
||||
global.document = {
|
||||
getElementById: function (id) {
|
||||
if (!global.__els) global.__els = {};
|
||||
if (!global.__els[id]) global.__els[id] = {
|
||||
style: {}, dataset: {}, classList: { add: function(){}, remove: function(){} },
|
||||
// Both writes count: the Billing page fills innerHTML, the status-page
|
||||
// tile assigns textContent. Watching only one of them flagged the tile as
|
||||
// "renders nothing" — a false positive that would have taught everyone to
|
||||
// ignore this test.
|
||||
set innerHTML(v) { if (v && String(v).trim()) global.__written.push(id); this.__h = v; },
|
||||
get innerHTML() { return this.__h || ""; },
|
||||
set textContent(v) { if (v !== undefined && String(v).trim()) global.__written.push(id); this.__t = v; },
|
||||
get textContent() { return this.__t || ""; },
|
||||
set innerText(v) { if (v !== undefined && String(v).trim()) global.__written.push(id); this.__i = v; },
|
||||
get innerText() { return this.__i || ""; },
|
||||
appendChild: function(){}, querySelector: function(){ return null; },
|
||||
querySelectorAll: function(){ return []; }, addEventListener: function(){} };
|
||||
return global.__els[id];
|
||||
},
|
||||
createElement: function () { return { style: {}, dataset: {}, appendChild: function(){}, setAttribute: function(){} }; },
|
||||
addEventListener: function () {},
|
||||
};
|
||||
global.pluginAPI = { onTabShown: function () {} };
|
||||
global.fetch = function () {
|
||||
// A payload with real numbers, so a working render produces visible output.
|
||||
return Promise.resolve({ ok: true, json: function () {
|
||||
return Promise.resolve({ plugin: "billing", state: {
|
||||
currency: "USD",
|
||||
total: { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 },
|
||||
by_source: { localzen: { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 } },
|
||||
by_model: { m1: { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 } },
|
||||
by_key: { k1: { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 } },
|
||||
by_day: { "2026-01-01": { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 } },
|
||||
degraded_reqs: 1, unpriced_reqs: 2,
|
||||
}});
|
||||
}});
|
||||
};
|
||||
global.__written = [];
|
||||
global.__errors = [];
|
||||
// The plugin's refresh() swallows its own errors so decoration can never break
|
||||
// the host page — which is right, and is exactly why the production ReferenceError
|
||||
// was invisible. So the trace has to come from console.error, which the plugin now
|
||||
// emits. Hooking process events alone made this test pass against the very bug it
|
||||
// was written for (verified by re-introducing the typo and watching it stay green).
|
||||
var __realErr = console.error;
|
||||
console.error = function () {
|
||||
global.__errors.push("console.error: " + Array.prototype.map.call(arguments, function (a) {
|
||||
return (a && a.message) ? a.message : String(a);
|
||||
}).join(" "));
|
||||
__realErr.apply(console, arguments);
|
||||
};
|
||||
process.on("uncaughtException", function (e) { global.__errors.push("uncaught: " + String(e && e.message || e)); });
|
||||
process.on("unhandledRejection", function (e) { global.__errors.push("unhandled: " + String(e && e.message || e)); });
|
||||
`
|
||||
// 50ms is not a guess: the plugin's IIFE kicks off refresh() which awaits a
|
||||
// fetch; a rejection lands on the microtask queue almost immediately.
|
||||
script := strings.Join([]string{
|
||||
stub, js,
|
||||
`setTimeout(function(){
|
||||
console.log("__ERRS__" + JSON.stringify({errors: global.__errors, written: global.__written}));
|
||||
}, 300);`,
|
||||
}, "\n")
|
||||
cmd := exec.Command(node, "-e", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Errorf("mount script %d crashed under node: %v\n%s", idx, err, out)
|
||||
return
|
||||
}
|
||||
// The script may print other things; find the JSON we appended.
|
||||
line := ""
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if i := strings.Index(l, "__ERRS__"); i >= 0 {
|
||||
line = strings.TrimSpace(l[i+len("__ERRS__"):])
|
||||
}
|
||||
}
|
||||
if line == "" {
|
||||
t.Errorf("mount script %d produced no error report — the harness did not "+
|
||||
"run to completion, so it cannot be trusted to have checked anything", idx)
|
||||
return
|
||||
}
|
||||
var report struct {
|
||||
Errors []string `json:"errors"`
|
||||
Written []string `json:"written"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &report); err != nil {
|
||||
t.Errorf("could not parse the error report %q: %v", line, err)
|
||||
return
|
||||
}
|
||||
for _, e := range report.Errors {
|
||||
t.Errorf("mount script %d reported %q — this is the failure that leaves "+
|
||||
"the Billing page blank while the API still returns data", idx, e)
|
||||
}
|
||||
if len(report.Written) == 0 {
|
||||
t.Errorf("mount script %d wrote NOTHING into any container — the page "+
|
||||
"renders empty. render() guards with `if (!st) return`, so a wrong "+
|
||||
"field name is a silent no-op: no throw, no console output, no error "+
|
||||
"anywhere. This is what the production page looked like.", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func extractScripts(html string) []string {
|
||||
re := regexp.MustCompile(`(?s)<script[^>]*>(.*?)</script>`)
|
||||
var out []string
|
||||
for _, m := range re.FindAllStringSubmatch(html, -1) {
|
||||
out = append(out, m[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestBillingPageCoversItsData is the other half: the page declares tables for
|
||||
// per-source / per-model / per-key / per-day and must actually render into them.
|
||||
// A table id that is never written to is exactly how "the page loads and shows
|
||||
// nothing" happens without an error.
|
||||
func TestBillingPageCoversItsData(t *testing.T) {
|
||||
page, _ := billingUI(t)
|
||||
for _, id := range []string{
|
||||
"billing-kpis", "billing-by-source", "billing-by-model",
|
||||
"billing-by-key", "billing-by-day",
|
||||
} {
|
||||
if !strings.Contains(page, `id="`+id+`"`) {
|
||||
t.Errorf("the page has no container #%s", id)
|
||||
}
|
||||
}
|
||||
// Every container must be written to by the script, not just declared.
|
||||
scripts := extractScripts(page)
|
||||
all := strings.Join(scripts, "\n")
|
||||
for _, id := range []string{"billing-kpis", "billing-by-source", "billing-by-model", "billing-by-key", "billing-by-day"} {
|
||||
if !strings.Contains(all, `getElementById("`+id+`")`) {
|
||||
t.Errorf("#%s is declared but never read by the script — it stays empty forever", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginIconIsNotARawEmoji guards the sidebar icon. The billing plugin
|
||||
// declared icon = "💰" and the WebUI drops that verbatim into the button, while
|
||||
// every native tab uses an inline SVG styled with `stroke: currentColor`. An
|
||||
// emoji there renders at the wrong size and ignores the theme, so it does not
|
||||
// match its neighbours — which is what the operator reported.
|
||||
func TestPluginIconIsNotARawEmoji(t *testing.T) {
|
||||
icon := billingIcon(mustBillingSource(t))
|
||||
if icon == "" {
|
||||
t.Fatal("billing declares no icon; the sidebar entry would be blank")
|
||||
}
|
||||
if isEmojiIcon(icon) {
|
||||
t.Errorf("page icon is the raw emoji %q — the WebUI sidebar renders "+
|
||||
"native tabs as inline SVG (stroke: currentColor), so an emoji is the "+
|
||||
"wrong size and ignores the theme. Use an inline SVG path instead.", icon)
|
||||
}
|
||||
}
|
||||
|
||||
// isEmojiIcon reports whether s is a pictographic emoji rather than markup or a
|
||||
// text glyph. Codepoints in the pictographic blocks, plus the regional-indicator
|
||||
// pair used by flags.
|
||||
func isEmojiIcon(s string) bool {
|
||||
r := []rune(s)
|
||||
if len(r) == 0 {
|
||||
return false
|
||||
}
|
||||
// Anything containing '<' is markup (an inline <svg>), which is the fix.
|
||||
if strings.ContainsRune(s, '<') {
|
||||
return false
|
||||
}
|
||||
for _, c := range r {
|
||||
switch {
|
||||
case c >= 0x1F300 && c <= 0x1FAFF, // pictographs, symbols, supplemental
|
||||
c >= 0x1F000 && c <= 0x1F2FF, // mahjong/domino/cards
|
||||
c >= 0x2600 && c <= 0x27BF, // misc symbols + dingbats
|
||||
c >= 0x2B00 && c <= 0x2BFF, // arrows/misc symbols
|
||||
c == 0xFE0F, // variation selector-16
|
||||
c >= 0x1F1E6 && c <= 0x1F1FF: // regional indicators (flags)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mustBillingSource(t *testing.T) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("plugins/billing.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// billingIcon extracts the declared page icon. It accepts BOTH a quoted string
|
||||
// and a [==[ ... ]==] long string, because an inline SVG cannot be written as a
|
||||
// Lua short string without escaping every quote in it.
|
||||
func billingIcon(src string) string {
|
||||
if m := regexp.MustCompile(`(?m)^\s*icon\s*=\s*"([^"]*)"`).FindStringSubmatch(src); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
if m := regexp.MustCompile(`(?s)\bicon\s*=\s*\[==\[(.*?)\]==\]`).FindStringSubmatch(src); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestBillingTableHeaderMatchesRowColumns catches column drift.
|
||||
//
|
||||
// row() gained cache columns (fresh / cache / cache%) while the header row did
|
||||
// not, in the same edit. The result is a table whose cells are shifted one
|
||||
// column left from "fresh" onward — so "cache%" sits under "completion" and the
|
||||
// last cell has no label. It renders, it has data, and it is wrong in a way that
|
||||
// takes a careful read to notice.
|
||||
func TestBillingTableHeaderMatchesRowColumns(t *testing.T) {
|
||||
page, _ := billingUI(t)
|
||||
js := strings.Join(extractScripts(page), "\n")
|
||||
if !strings.Contains(js, "<th>") {
|
||||
t.Fatal("no table header found in the billing page script")
|
||||
}
|
||||
hStart := strings.Index(js, "function tableFor")
|
||||
if hStart < 0 {
|
||||
t.Fatal("no tableFor in the billing page script")
|
||||
}
|
||||
// Count the usage table's headers ONLY. The count is taken from tableFor to
|
||||
// the end of the script, which used to be fine when the rule editor lived on
|
||||
// its own page; now that both halves share one script, the rule table's nine
|
||||
// headers were counted too and the check reported 17 vs 8 — a failure about
|
||||
// two unrelated tables. Stop at the rule editor's script.
|
||||
header := js[hStart:]
|
||||
if cut := strings.Index(header, "/api/plugins/billing/rules"); cut > 0 {
|
||||
header = header[:cut]
|
||||
}
|
||||
// Count <th> with OR without attributes: the name column now carries
|
||||
// style='width:22%' so the header and its data cell can be given the same
|
||||
// width, and a bare "<th>" count silently reported 7 vs 8 — the check
|
||||
// failing on the very column it had just been taught to size.
|
||||
th := strings.Count(header, "<th>") + strings.Count(header, "<th ")
|
||||
|
||||
rStart := strings.Index(js, "function row")
|
||||
rEnd := strings.Index(js, "function tableFor")
|
||||
if rStart < 0 || rEnd < 0 || rEnd <= rStart {
|
||||
t.Fatal("could not isolate row()")
|
||||
}
|
||||
row := js[rStart:rEnd]
|
||||
// Each cell closes with </td>; the first cell uses <td><b>..</b></td> so
|
||||
// counting </td> is exact.
|
||||
td := strings.Count(row, "</td>")
|
||||
|
||||
if th != td {
|
||||
t.Errorf("★ header declares %d columns but row() emits %d cells — the "+
|
||||
"table is misaligned from the first differing column on", th, td)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleTableLocksColumnWidthsInAColgroup guards the layout class of bug that
|
||||
// only a screenshot caught: two adjacent headers rendered on top of each other,
|
||||
// the URL input squeezed to "https:", and the per-model × button landing 78px
|
||||
// past its cell onto the neighbouring Delete button.
|
||||
//
|
||||
// The measurement that found each one:
|
||||
// - table-layout:fixed with widths on <td> → 954px divided into 9 equal
|
||||
// 106px columns, every declared width discarded.
|
||||
// - widths on <th> WITHOUT fixed → browser sizes from content and ignores
|
||||
// them; the 70px currency column still collapsed to 48px (24px of input).
|
||||
// - fixed + <th> widths → they take effect, but the model-price cell's width
|
||||
// then depended on WHICH RULE was widest (352px vs 206px).
|
||||
//
|
||||
// The fix is all three together: fixed layout, a colgroup, and flex children
|
||||
// that may shrink.
|
||||
func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) {
|
||||
page, _ := billingUI(t)
|
||||
js := strings.Join(extractScripts(page), "\n")
|
||||
// Match the ATTRIBUTE, not the bare string: a substring check on
|
||||
// "table-layout:fixed" is satisfied by the explanatory comment that sits
|
||||
// three lines above the tag, so removing the attribute from the table still
|
||||
// passed (mutation-verified). Require it inside a style='...' literal.
|
||||
if !strings.Contains(js, `font-size:12px;table-layout:fixed'`) {
|
||||
t.Error("the rule table must stay table-layout:fixed — with auto layout " +
|
||||
"the <colgroup> widths below are ignored and columns size from content")
|
||||
}
|
||||
colStart := strings.Index(js, "<colgroup>")
|
||||
if colStart < 0 {
|
||||
t.Fatal("the rule table declares no <colgroup>; per-column widths on " +
|
||||
"<th> or <td> are advisory and get recomputed from content")
|
||||
}
|
||||
// The colgroup is built from a JS array of pixel widths, so assert on the
|
||||
// array, not on literal "<col " occurrences — counting the literal found
|
||||
// exactly one and reported "1 columns, want 9" against correct code.
|
||||
if !strings.Contains(js, "[214, 80, 62, 70, 66, 74, 62, 262, 74]") {
|
||||
t.Error("the rule table's colgroup widths changed or vanished; measured " +
|
||||
"values were [214 80 62 70 66 74 62 262 74] for a 954px pane — the " +
|
||||
"delete column needs 74px or the button wraps to two lines")
|
||||
}
|
||||
if !strings.Contains(js, `"<col style='width:" + w + "px'>"`) {
|
||||
t.Error("colgroup entries are no longer emitted as <col style='width:Npx'>")
|
||||
}
|
||||
// Every price-row INPUT must be allowed to shrink, or the row's fixed widths
|
||||
// sum past the cell and the × button lands on the next column. The × button
|
||||
// is the opposite: it must NOT shrink (flex:0 0 auto), because a squashed
|
||||
// delete button is unclickable.
|
||||
for _, needle := range []string{"m-name", "m-p", "m-c"} {
|
||||
i := strings.Index(js, "class='"+needle+"'")
|
||||
if i < 0 {
|
||||
i = strings.Index(js, "class='ghost small "+needle+"'")
|
||||
}
|
||||
if i < 0 {
|
||||
t.Errorf("price row lost the %s input/button", needle)
|
||||
continue
|
||||
}
|
||||
window := js[i : i+220]
|
||||
if !strings.Contains(window, "flex:") {
|
||||
t.Errorf("%s has no flex sizing; the row is 80+96+96+38 ≈ 330px wide "+
|
||||
"against a 262px cell and overflows onto the next column", needle)
|
||||
}
|
||||
if !strings.Contains(window, "min-width:0") {
|
||||
t.Errorf("%s lacks min-width:0; a flex item will not shrink below its "+
|
||||
"content width, which is how the × button ended up 78px past its cell", needle)
|
||||
}
|
||||
}
|
||||
i := strings.Index(js, "class='ghost small m-del'")
|
||||
if i < 0 {
|
||||
t.Fatal("the per-model delete button is gone")
|
||||
}
|
||||
if !strings.Contains(js[i:i+120], "flex:0 0 auto") {
|
||||
t.Error("the per-model × button must be flex:0 0 auto — it is a fixed-size " +
|
||||
"control and must never be squeezed by the flexible inputs")
|
||||
}
|
||||
}
|
||||
139
internal/lua/fastvalue.go
Normal file
139
internal/lua/fastvalue.go
Normal file
@ -0,0 +1,139 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// This file exists to remove the JSON round-trip from the plugin hook path.
|
||||
//
|
||||
// Measured before this change: one Fire() call cost 14.6us, of which
|
||||
// json.Marshal(payload) was 4.0us and json.Unmarshal back into interface{} was
|
||||
// 5.6us — 67% of the whole hook, spent re-deriving a tree that the very next
|
||||
// line (pushGoValue) already knows how to walk natively.
|
||||
//
|
||||
// The round-trip existed because payload arrives as map[string]interface{} and
|
||||
// pushGoValue wanted a plain interface{} tree. pushGoValue already handles
|
||||
// map[string]interface{} and []interface{} directly, so the fix is a converter
|
||||
// that flattens the concrete Go types the gateway actually produces, instead of
|
||||
// a generic serialize/parse.
|
||||
//
|
||||
// The JSON path is kept as the fallback for types the fast path does not know,
|
||||
// so a plugin payload carrying something exotic still arrives rather than being
|
||||
// silently dropped.
|
||||
|
||||
// jsonNumberType is the type json.Unmarshal produces for every JSON number.
|
||||
// The fast path recognizes it so numbers survive as numbers rather than being
|
||||
// stringified into a different Lua type.
|
||||
type jsonNumberType = float64
|
||||
|
||||
// fastToPlain converts the concrete Go values the gateway puts in a hook
|
||||
// payload into the interface{} tree pushGoValue consumes, without JSON.
|
||||
//
|
||||
// The interesting cases are the ones json.Unmarshal would have normalized:
|
||||
// - json.Number-ish types (all float64 in practice)
|
||||
// - int / int64 / uint variants, which must land as Lua numbers, not as the
|
||||
// strings a naive "only handle float64" switch would produce
|
||||
// - []string and map[string]string, extremely common in payloads and NOT
|
||||
// handled by a switch that only knows []interface{}
|
||||
//
|
||||
// Anything unrecognized returns (value, false) so the caller can fall back to
|
||||
// the JSON path, which is slower but total.
|
||||
func fastToPlain(v interface{}) (interface{}, bool) {
|
||||
switch x := v.(type) {
|
||||
case nil, bool, string, jsonNumberType:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int8:
|
||||
return float64(x), true
|
||||
case int16:
|
||||
return float64(x), true
|
||||
case int32:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
case uint:
|
||||
return float64(x), true
|
||||
case uint8:
|
||||
return float64(x), true
|
||||
case uint16:
|
||||
return float64(x), true
|
||||
case uint32:
|
||||
return float64(x), true
|
||||
case uint64:
|
||||
return float64(x), true
|
||||
case float32:
|
||||
return float64(x), true
|
||||
case []string:
|
||||
out := make([]interface{}, len(x))
|
||||
for i, s := range x {
|
||||
out[i] = s
|
||||
}
|
||||
return out, true
|
||||
case map[string]string:
|
||||
out := make(map[string]interface{}, len(x))
|
||||
for k, s := range x {
|
||||
out[k] = s
|
||||
}
|
||||
return out, true
|
||||
case map[string]interface{}:
|
||||
// The overwhelmingly common case, and the one the hook path always
|
||||
// takes at the top level.
|
||||
out := make(map[string]interface{}, len(x))
|
||||
for k, it := range x {
|
||||
c, ok := fastToPlain(it)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out[k] = c
|
||||
}
|
||||
return out, true
|
||||
case []interface{}:
|
||||
out := make([]interface{}, len(x))
|
||||
for i, it := range x {
|
||||
c, ok := fastToPlain(it)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out[i] = c
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// plainForLua returns the interface{} tree to hand to pushGoValue, using the
|
||||
// fast path when it can and JSON only when it must.
|
||||
func plainForLua(payload map[string]interface{}) interface{} {
|
||||
if fast, ok := fastToPlain(payload); ok {
|
||||
return fast
|
||||
}
|
||||
// Rare: some type the fast path does not model. Serialize and let the
|
||||
// generic decoder normalize it, so the hook still sees the field.
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return payload
|
||||
}
|
||||
var decoded interface{}
|
||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||
return payload
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
// toPlainSlice is the []interface{} entry point of fastToPlain, exposed so the
|
||||
// Fire path can flatten a pre-built slice without re-walking the map header.
|
||||
func toPlainSlice(v []interface{}) []interface{} {
|
||||
if fast, ok := fastToPlain(v); ok {
|
||||
return fast.([]interface{})
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// luaNumberString renders a float the way Lua would print it, for the rare case
|
||||
// a hook wants the textual form. Kept out of the hot path.
|
||||
func luaNumberString(f float64) string {
|
||||
return strconv.FormatFloat(f, 'g', -1, 64)
|
||||
}
|
||||
132
internal/lua/fastvalue_test.go
Normal file
132
internal/lua/fastvalue_test.go
Normal file
@ -0,0 +1,132 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The hook payload used to be JSON round-tripped on every call. It no longer is,
|
||||
// so the fast path and the old JSON path must be indistinguishable — otherwise
|
||||
// a plugin silently sees a different payload than before, which is the worst
|
||||
// kind of change: it compiles, passes a smoke test, and misprices traffic.
|
||||
//
|
||||
// These tests therefore compare the two paths on the SAME inputs rather than
|
||||
// asserting the fast path's output in isolation.
|
||||
|
||||
func viaJSON(t *testing.T, payload map[string]interface{}) interface{} {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var decoded interface{}
|
||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
// payloads that exercise every branch of the fast converter.
|
||||
func payloadCases() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"model": "deepseek-v4.1-flash", "source": "commandcode", "ok": true},
|
||||
// Numbers of every width: a converter that only knows float64 turns
|
||||
// ints into something else, and a token count that arrives as a string
|
||||
// makes a Lua hook do arithmetic on nil.
|
||||
{"i": 42, "i8": int8(8), "i16": int16(16), "i32": int32(32), "i64": int64(1 << 40),
|
||||
"u": uint(7), "u64": uint64(1 << 50), "f32": float32(1.5), "f64": 2.25},
|
||||
// Zero, negative, and very large values must stay numbers.
|
||||
{"zero": 0, "neg": -17, "huge": 1e308, "tiny": 1e-308},
|
||||
// Slices and string maps: common in payloads and absent from a switch
|
||||
// that only knows []interface{} / map[string]interface{}.
|
||||
{"msgs": []interface{}{"a", "b"}, "tags": []string{"x", "y"}},
|
||||
{"kv": map[string]string{"a": "1", "b": "2"}},
|
||||
// Nesting, which is where a shallow converter silently drops a level.
|
||||
{"usage": map[string]interface{}{
|
||||
"prompt_tokens": 2048, "cache_hit_tokens": 1024,
|
||||
"nested": map[string]interface{}{"deep": []interface{}{1, "two", true, nil}},
|
||||
}},
|
||||
{"nil_field": nil, "empty_map": map[string]interface{}{}, "empty_slice": []interface{}{}},
|
||||
// A value the fast path does NOT model: it must fall back to JSON and
|
||||
// still arrive, not disappear.
|
||||
{"weird": struct {
|
||||
A int `json:"a"`
|
||||
B string `json:"b"`
|
||||
}{1, "x"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestFastPathMatchesJSONPath(t *testing.T) {
|
||||
for i, p := range payloadCases() {
|
||||
want := viaJSON(t, p)
|
||||
got := plainForLua(p)
|
||||
if !reflect.DeepEqual(want, got) {
|
||||
t.Errorf("case %d: fast path differs from JSON path\n payload: %#v\n json: %#v\n fast: %#v",
|
||||
i, p, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFastPathIsActuallyUsed guards against the fast path silently degrading to
|
||||
// JSON for the payload the gateway really sends. If a future payload gains a
|
||||
// type the converter does not model, this still works (it falls back) but the
|
||||
// optimization is gone — and the next person measuring the hook would be
|
||||
// measuring the old cost without knowing why.
|
||||
func TestFastPathIsActuallyUsed(t *testing.T) {
|
||||
// This mirrors the real request_end payload shape from the gateway.
|
||||
realistic := map[string]interface{}{
|
||||
"stage": "request_end", "kind": "end", "model": "deepseek-v4.1-flash",
|
||||
"source": "commandcode", "key": "stress-key", "ok": true,
|
||||
"status": 200, "duration_ms": 1234,
|
||||
"usage": map[string]interface{}{
|
||||
"prompt_tokens": float64(2048), "completion_tokens": float64(512),
|
||||
"cache_hit_tokens": float64(1024), "total_tokens": float64(3584),
|
||||
},
|
||||
"walk": []interface{}{
|
||||
map[string]interface{}{"kind": "tier_skip", "tier": 1, "source": "", "model": "", "reason": "cooldown"},
|
||||
map[string]interface{}{"kind": "selected", "tier": 2, "source": "commandcode", "model": "m", "reason": ""},
|
||||
},
|
||||
"ts": float64(1700000000),
|
||||
}
|
||||
if _, ok := fastToPlain(realistic); !ok {
|
||||
t.Errorf("★ the realistic request payload does NOT take the fast path — " +
|
||||
"the JSON round-trip is still on the hot path for real traffic")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFastPathDoesNotAliasInput: the converter builds a new tree. If it ever
|
||||
// returned the caller's map directly, a Lua hook's writes could not reach Go —
|
||||
// but worse, a later mutation of the payload would race with the snapshot the
|
||||
// persistence saver is holding.
|
||||
func TestFastPathDoesNotAliasInput(t *testing.T) {
|
||||
src := map[string]interface{}{
|
||||
"usage": map[string]interface{}{"prompt_tokens": float64(1)},
|
||||
"list": []interface{}{"a"},
|
||||
}
|
||||
out, ok := fastToPlain(src)
|
||||
if !ok {
|
||||
t.Fatal("fast path declined a plain payload")
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
m["new"] = "added"
|
||||
src["also_new"] = "must not appear"
|
||||
|
||||
if _, leaked := m["also_new"]; leaked {
|
||||
t.Error("output map aliases the input map")
|
||||
}
|
||||
if _, leaked := src["new"]; leaked {
|
||||
t.Error("writing to the output mutated the input")
|
||||
}
|
||||
// And the nested maps must be copies too.
|
||||
inner := m["usage"].(map[string]interface{})
|
||||
inner["prompt_tokens"] = float64(999)
|
||||
if src["usage"].(map[string]interface{})["prompt_tokens"] != float64(1) {
|
||||
t.Error("nested map is shared, not copied — a hook could mutate the payload")
|
||||
}
|
||||
list := m["list"].([]interface{})
|
||||
list[0] = "changed"
|
||||
if src["list"].([]interface{})[0] != "a" {
|
||||
t.Error("nested slice is shared, not copied")
|
||||
}
|
||||
}
|
||||
334
internal/lua/fire_parallel_test.go
Normal file
334
internal/lua/fire_parallel_test.go
Normal file
@ -0,0 +1,334 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fire runs same-stage hooks in parallel across plugins. These tests pin the
|
||||
// three things that make that safe, each of which failed at least once while
|
||||
// the change was being written.
|
||||
|
||||
// twoHookPlugins loads two plugins that both hook request_end.
|
||||
func twoHookPlugins(t *testing.T, codeA, codeB string) *Plugins {
|
||||
t.Helper()
|
||||
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("vm start: %v", err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, filepath.Join(t.TempDir(), "plugins"))
|
||||
ps.save.setWakeDelayForTest(time.Millisecond)
|
||||
t.Cleanup(ps.Close)
|
||||
ps.DisableStatePersistence()
|
||||
if err := ps.LoadSource("aaa", codeA); err != nil {
|
||||
t.Fatalf("load aaa: %v", err)
|
||||
}
|
||||
if err := ps.LoadSource("bbb", codeB); err != nil {
|
||||
t.Fatalf("load bbb: %v", err)
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
func counterPlugin(name string, key string) string {
|
||||
// Built by concatenation rather than fmt: %q is not a Go verb, and the first
|
||||
// attempt produced Lua source with a stray '=' that only failed at compile
|
||||
// time inside three different tests.
|
||||
return `
|
||||
local p = { name = "` + name + `" }
|
||||
p.state = { n = 0 }
|
||||
p.hooks = { request_end = "bump" }
|
||||
function p.bump(payload)
|
||||
p.state.n = p.state.n + 1
|
||||
if "` + key + `" ~= "" then return { who = "from-` + name + `" } end
|
||||
return nil
|
||||
end
|
||||
return p
|
||||
`
|
||||
}
|
||||
|
||||
// TestSingleAndParallelPathsMergeIdentically: the single-plugin fast path and
|
||||
// the multi-plugin parallel path must produce the same payload.
|
||||
//
|
||||
// This is not theoretical. The parallel path was written first and the
|
||||
// single-plugin path kept its old shape; the merge loop was left off the fast
|
||||
// path, so a lone plugin returning a table had its return value DISCARDED. The
|
||||
// existing stage-order test caught it — but only because it happened to check
|
||||
// the payload after Fire. A plugin that returned fields nobody read would have
|
||||
// broken silently.
|
||||
func TestSingleAndParallelPathsMergeIdentically(t *testing.T) {
|
||||
single := twoHookPlugins(t, counterPlugin("aaa", "who"), `
|
||||
local p = { name = "zzz" }
|
||||
p.state = { n = 0 }
|
||||
p.hooks = { request_end = "bump" }
|
||||
function p.bump(payload) p.state.n = p.state.n + 1 return nil end
|
||||
return p
|
||||
`)
|
||||
// remove the second so exactly one plugin hooks this stage
|
||||
single.mu.Lock()
|
||||
single.plugins = single.plugins[:1]
|
||||
single.mu.Unlock()
|
||||
single.rebuild()
|
||||
|
||||
ps := twoHookPlugins(t, counterPlugin("aaa", "who"), counterPlugin("bbb", "who"))
|
||||
|
||||
singleOut := single.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
|
||||
psOut := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
|
||||
|
||||
if singleOut["who"] != "from-aaa" {
|
||||
t.Errorf("★ single-plugin path dropped the return value: %v", singleOut)
|
||||
}
|
||||
// Two plugins both return `who`; the merge order decides the winner, and it
|
||||
// must be deterministic (load order), not scheduler-dependent.
|
||||
if psOut["who"] != "from-bbb" {
|
||||
t.Errorf("merge order is not load order: who = %v, want from-bbb", psOut["who"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeOrderIsDeterministic: goroutine completion order must not leak into
|
||||
// the result. Running Fire repeatedly must always yield the same payload, or a
|
||||
// gateway's behaviour changes run to run with the same plugins installed.
|
||||
func TestMergeOrderIsDeterministic(t *testing.T) {
|
||||
// The plugins differ in cost so a scheduler-dependent merge is visible.
|
||||
slow := `
|
||||
local p = { name = "aaa" }
|
||||
p.state = { n = 0 }
|
||||
p.hooks = { request_end = "slow" }
|
||||
function p.slow(payload)
|
||||
local acc = 0
|
||||
for i = 1, 3000 do acc = acc + i % 7 end
|
||||
p.state.n = p.state.n + 1
|
||||
return { winner = "aaa", acc = acc }
|
||||
end
|
||||
return p
|
||||
`
|
||||
fast := `
|
||||
local p = { name = "bbb" }
|
||||
p.state = { n = 0 }
|
||||
p.hooks = { request_end = "fast" }
|
||||
function p.fast(payload)
|
||||
p.state.n = p.state.n + 1
|
||||
return { winner = "bbb" }
|
||||
end
|
||||
return p
|
||||
`
|
||||
ps := twoHookPlugins(t, slow, fast)
|
||||
first := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})["winner"]
|
||||
for i := 0; i < 60; i++ {
|
||||
got := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})["winner"]
|
||||
if got != first {
|
||||
t.Fatalf("★ winner changed between runs: %q then %q — merge depends on goroutine "+
|
||||
"scheduling, so the same configuration behaves differently run to run", first, got)
|
||||
}
|
||||
}
|
||||
// Load order decides: bbb is loaded second, so it wins.
|
||||
if first != "bbb" {
|
||||
t.Errorf("winner = %q, want bbb (load order decides, not timing)", first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestThrowingHookDoesNotBreakSiblings covers the failure path that golua
|
||||
// actually produces: a hook raising a Lua error.
|
||||
//
|
||||
// The test name used to claim it covered panics, and it used error() to do it.
|
||||
// Probing the six ways a Lua program can fault (error(), indexing nil, calling
|
||||
// nil, concatenating nil, arithmetic on nil, unbounded recursion) showed golua
|
||||
// converts ALL of them into an error RETURN, not a Go panic — so the recover()
|
||||
// in Fire was untested by that case, and deleting recover() still passed. The
|
||||
// test was renamed to say what it verifies.
|
||||
//
|
||||
// recover() is kept anyway: it guards the Go side of Fire (a nil map write, a
|
||||
// future change to how the payload is prepared), which is cheap and cannot be
|
||||
// triggered from Lua today. Claiming it is covered by a Lua test would be the
|
||||
// kind of assurance that evaporates the first time someone checks.
|
||||
func TestThrowingHookDoesNotBreakSiblings(t *testing.T) {
|
||||
throwing := `
|
||||
local p = { name = "aaa" }
|
||||
p.state = {}
|
||||
p.hooks = { request_end = "boom" }
|
||||
function p.boom(payload) error("intentional failure") end
|
||||
return p
|
||||
`
|
||||
healthy := counterPlugin("bbb", "")
|
||||
ps := twoHookPlugins(t, throwing, healthy)
|
||||
|
||||
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"}) // must not crash
|
||||
if out == nil {
|
||||
t.Error("Fire returned nil")
|
||||
}
|
||||
st, ok := ps.State("bbb").(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("healthy plugin has no state")
|
||||
}
|
||||
if n := st["n"]; n != float64(1) {
|
||||
t.Errorf("★ healthy plugin did not run alongside the throwing one: n = %v", n)
|
||||
}
|
||||
he := ps.HookErrors()
|
||||
if len(he) == 0 {
|
||||
t.Error("a throwing hook was not recorded in hook_errors — the failure would be invisible")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallelHooksAllRunOnce: every plugin must be invoked exactly once per
|
||||
// Fire. A lost or duplicated goroutine shows up as a wrong total, which for the
|
||||
// billing plugin means a wrong bill.
|
||||
func TestParallelHooksAllRunOnce(t *testing.T) {
|
||||
ps := twoHookPlugins(t, counterPlugin("aaa", ""), counterPlugin("bbb", ""))
|
||||
const fires = 100
|
||||
for i := 0; i < fires; i++ {
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
|
||||
}
|
||||
for _, name := range []string{"aaa", "bbb"} {
|
||||
st, ok := ps.State(name).(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s has no state", name)
|
||||
}
|
||||
if n := st["n"]; n != float64(fires) {
|
||||
t.Errorf("%s counted %v hooks, want %d", name, n, fires)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentFireIsSafe drives Fire from many goroutines at once. Each
|
||||
// plugin has its own Lua state and its own mutex, so this must hold; the race
|
||||
// detector is what proves it, not the assertions.
|
||||
func TestConcurrentFireIsSafe(t *testing.T) {
|
||||
ps := twoHookPlugins(t, counterPlugin("aaa", ""), counterPlugin("bbb", ""))
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 8; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for _, name := range []string{"aaa", "bbb"} {
|
||||
st, ok := ps.State(name).(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s has no state", name)
|
||||
}
|
||||
if n := st["n"]; n != float64(400) {
|
||||
t.Errorf("%s counted %v, want 400", name, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireDoesNotHoldRegistryLockWhileRunningHooks: a hook that installs or
|
||||
// disables a plugin takes ps.mu for write. If Fire held ps.mu across the hook,
|
||||
// that would self-deadlock — the exact failure TrueAgent recorded for its own
|
||||
// parallel stop path.
|
||||
func TestFireDoesNotHoldRegistryLockWhileRunningHooks(t *testing.T) {
|
||||
reentrant := `
|
||||
local p = { name = "aaa" }
|
||||
p.state = {}
|
||||
p.hooks = { request_end = "reenter" }
|
||||
function p.reenter(payload)
|
||||
-- Reading the registry from inside a hook is the read half of the same lock.
|
||||
local _ = #payload
|
||||
return nil
|
||||
end
|
||||
return p
|
||||
`
|
||||
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
ps := NewPlugins(vm, filepath.Join(t.TempDir(), "plugins"))
|
||||
defer ps.Close()
|
||||
ps.DisableStatePersistence()
|
||||
if err := ps.LoadSource("aaa", reentrant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("bbb", counterPlugin("bbb", "")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
|
||||
// Touch the registry the way an admin request would, right after.
|
||||
_ = ps.Count()
|
||||
_ = ps.List()
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Fire deadlocked against the registry lock")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingSurvivesParallelFire runs the REAL bundled plugin next to another
|
||||
// one. A synthetic counter cannot catch a mismatch between the documented
|
||||
// payload shape and what the plugin actually reads.
|
||||
func TestBillingSurvivesParallelFire(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pdir := filepath.Join(dir, "plugins")
|
||||
os.MkdirAll(pdir, 0o755)
|
||||
src, err := os.ReadFile("plugins/billing.lua")
|
||||
if err != nil {
|
||||
t.Fatalf("read billing.lua: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(pdir, "billing.lua"), src, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(pdir, "observer.lua"),
|
||||
[]byte(`local p={name="observer"} p.state={n=0}
|
||||
p.hooks={request_end="see"}
|
||||
function p.see(payload) p.state.n=p.state.n+1 return nil end
|
||||
return p`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
ps := NewPlugins(vm, pdir)
|
||||
ps.save.setWakeDelayForTest(time.Millisecond)
|
||||
defer ps.Close()
|
||||
ps.DisableStatePersistence()
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ps.Count() != 2 {
|
||||
t.Fatalf("loaded %d plugins, want 2", ps.Count())
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"model": "deepseek-v4.1-flash", "source": "commandcode", "key": "k", "ok": true,
|
||||
"usage": map[string]interface{}{
|
||||
"prompt_tokens": float64(1000), "completion_tokens": float64(100),
|
||||
"cache_hit_tokens": float64(0),
|
||||
},
|
||||
}
|
||||
ps.Fire(StageRequestEnd, payload)
|
||||
|
||||
st := ps.State("billing")
|
||||
if st == nil {
|
||||
t.Fatal("billing produced no state")
|
||||
}
|
||||
m := st.(map[string]interface{})
|
||||
total, ok := m["total"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("billing.total is %T", m["total"])
|
||||
}
|
||||
if total["requests"] != float64(1) {
|
||||
t.Errorf("billing counted %v requests, want 1 — the plugin was starved by the parallel path",
|
||||
total["requests"])
|
||||
}
|
||||
if n := ps.State("observer").(map[string]interface{})["n"]; n != float64(1) {
|
||||
t.Errorf("observer counted %v, want 1", n)
|
||||
}
|
||||
if len(ps.HookErrors()) != 0 {
|
||||
t.Errorf("hook errors under parallel Fire: %v", ps.HookErrors())
|
||||
}
|
||||
}
|
||||
76
internal/lua/hook_guard_test.go
Normal file
76
internal/lua/hook_guard_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A plugin hook that RAISES must not take the process down. This is the
|
||||
// production outage: a Lua error anywhere in a hook made golua's callEx call
|
||||
// L.StackTrace(), which calls lua_getinfo and SIGSEGVs on a deep enough stack —
|
||||
// a C-level signal Go cannot recover from, so one buggy plugin killed the whole
|
||||
// gateway and took every in-flight request with it.
|
||||
//
|
||||
// The fix routes hook calls through a Lua-side pcall guard, so the error comes
|
||||
// back as an ordinary return value. This test fires hooks that raise on purpose
|
||||
// and asserts three things: the process survives, the failure is RECORDED, and
|
||||
// a well-behaved plugin on the same state keeps working afterwards (the error
|
||||
// must not poison the Lua state).
|
||||
func TestHookThatRaisesDoesNotCrashTheProcess(t *testing.T) {
|
||||
ps, pdir := billingVM(t)
|
||||
boom := `
|
||||
local plugin = {}
|
||||
plugin.name = "boom"
|
||||
plugin.version = "0.1"
|
||||
function plugin.request_end(payload)
|
||||
-- Raise on a table index, the exact shape of the billing bug that caused the
|
||||
-- outage. Deliberately NOT a syntax error: this must load fine and fail only
|
||||
-- when invoked.
|
||||
local x = nil
|
||||
return x.field
|
||||
end
|
||||
return plugin`
|
||||
if err := os.WriteFile(filepath.Join(pdir, "boom.lua"), []byte(boom), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("boom", boom); err != nil {
|
||||
t.Fatalf("load boom: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"model": "m", "source": "s", "ok": true,
|
||||
"prompt_tokens": 100, "completion_tokens": 10, "time": 1750000000000,
|
||||
}
|
||||
// Fire many times: a single call could pass by luck, but if the error ever
|
||||
// escapes into golua's C path the process dies and this test never returns.
|
||||
for i := 0; i < 50; i++ {
|
||||
ps.Fire(StageRequestEnd, payload)
|
||||
}
|
||||
// Reaching this line at all is the primary assertion.
|
||||
|
||||
errs := ps.HookErrors()
|
||||
end, ok := errs[string(StageRequestEnd)]
|
||||
if !ok {
|
||||
t.Fatal("a raising hook left no record — failures must be observable, not swallowed")
|
||||
}
|
||||
if end["count"] == nil || end["count"].(int) == 0 {
|
||||
t.Error("hook error count is zero despite 50 raising calls")
|
||||
}
|
||||
msg, _ := end["last_error"].(string)
|
||||
if !strings.Contains(msg, "boom") {
|
||||
t.Errorf("last_error does not name the offending plugin: %q", msg)
|
||||
}
|
||||
|
||||
// The billing plugin shares the same Plugins registry and must still work:
|
||||
// one broken plugin may not disable the others.
|
||||
st, _ := ps.State("billing").(map[string]interface{})
|
||||
if st == nil || st["total"] == nil {
|
||||
t.Fatalf("the healthy plugin stopped working after another plugin raised")
|
||||
}
|
||||
tot, _ := st["total"].(map[string]interface{})
|
||||
if tot == nil || tot["requests"] == nil || tot["requests"].(float64) == 0 {
|
||||
t.Errorf("billing recorded no requests after the raising plugin ran: %v", st["total"])
|
||||
}
|
||||
}
|
||||
283
internal/lua/persist_test.go
Normal file
283
internal/lua/persist_test.go
Normal file
@ -0,0 +1,283 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// These cover plugin state persistence: a plugin's totals live in the Lua VM,
|
||||
// which dies with the process. Measured on the real gateway before this change:
|
||||
// 218,241 requests and 26.2M prompt tokens gone after one systemctl restart.
|
||||
|
||||
func persistTestPlugin() string {
|
||||
return `
|
||||
local plugin = { name = "counter", version = "1.0" }
|
||||
plugin.state = { n = 0, prices_seen = 0 }
|
||||
plugin.prices = { rate = 1 }
|
||||
plugin.hooks = { request_end = "bump" }
|
||||
function plugin.bump(payload)
|
||||
plugin.state.n = plugin.state.n + 1
|
||||
return nil
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
}
|
||||
|
||||
// newPersistVM wires a plugin registry on a fresh dir, mirroring newPluginVM.
|
||||
func newPersistVM(t *testing.T) (*Plugins, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("vm start: %v", err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
pdir := filepath.Join(dir, "plugins")
|
||||
ps := NewPlugins(vm, pdir)
|
||||
// Shorten the saver debounce: Close() waits for the saver goroutine, so
|
||||
// with the production 2s interval every test would pay 2s on teardown.
|
||||
ps.save.setWakeDelayForTest(2 * time.Millisecond)
|
||||
t.Cleanup(ps.Close)
|
||||
return ps, pdir
|
||||
}
|
||||
|
||||
func loadCounter(t *testing.T, ps *Plugins) {
|
||||
t.Helper()
|
||||
if err := ps.LoadSource("counter", persistTestPlugin()); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func stateN(t *testing.T, ps *Plugins) float64 {
|
||||
t.Helper()
|
||||
st := ps.State("counter")
|
||||
m, ok := st.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("state is %T, want map", st)
|
||||
}
|
||||
n, ok := m["n"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("state.n is %T, want float64", m["n"])
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestStateSurvivesRestart is the defect itself: a rebuilt registry over the
|
||||
// same plugin dir must come up with the previous totals, not at zero.
|
||||
func TestStateSurvivesRestart(t *testing.T) {
|
||||
ps, pdir := newPersistVM(t)
|
||||
loadCounter(t, ps)
|
||||
for i := 0; i < 25; i++ {
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
|
||||
}
|
||||
if got := stateN(t, ps); got != 25 {
|
||||
t.Fatalf("in-process total = %v, want 25", got)
|
||||
}
|
||||
// Force the write the saver would do, so the test does not depend on timing.
|
||||
ps.save.flush()
|
||||
ps.Close()
|
||||
|
||||
// A brand-new registry over the same dir: this is the restart.
|
||||
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
ps2 := NewPlugins(vm, pdir)
|
||||
defer ps2.Close()
|
||||
loadCounter(t, ps2)
|
||||
|
||||
if got := stateN(t, ps2); got != 25 {
|
||||
t.Fatalf("★ total after restart = %v, want 25 — this is the 'restart loses the books' bug", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPricesAndStateAreSeparate: restoring configuration over history (or the
|
||||
// reverse) would either erase the totals or resurrect stale prices.
|
||||
func TestPricesAndStateAreSeparate(t *testing.T) {
|
||||
ps, pdir := newPersistVM(t)
|
||||
loadCounter(t, ps)
|
||||
for i := 0; i < 7; i++ {
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
|
||||
}
|
||||
ps.save.flush()
|
||||
ps.Close()
|
||||
|
||||
b, err := os.ReadFile(ps.stateFile("counter"))
|
||||
if err != nil {
|
||||
t.Fatalf("no state file: %v", err)
|
||||
}
|
||||
var saved struct {
|
||||
State map[string]interface{} `json:"state"`
|
||||
Prices map[string]interface{} `json:"prices"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &saved); err != nil {
|
||||
t.Fatalf("state file is not valid JSON: %v", err)
|
||||
}
|
||||
if saved.Prices == nil || saved.Prices["rate"] != float64(1) {
|
||||
t.Errorf("prices were not persisted separately: %v", saved.Prices)
|
||||
}
|
||||
if saved.State["n"] != float64(7) {
|
||||
t.Errorf("state.n = %v, want 7", saved.State["n"])
|
||||
}
|
||||
_ = pdir
|
||||
}
|
||||
|
||||
// TestPricesOnlyUpdatePersists guards a real hole: SetState returns early for a
|
||||
// prices-only payload (correctly leaving state alone), and the first version
|
||||
// returned before the persistence write — so a reprice was durable in memory
|
||||
// only and a restart silently reverted to the old prices.
|
||||
func TestPricesOnlyUpdatePersists(t *testing.T) {
|
||||
ps, _ := newPersistVM(t)
|
||||
loadCounter(t, ps)
|
||||
for i := 0; i < 5; i++ {
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
|
||||
}
|
||||
ps.save.flush()
|
||||
|
||||
err := ps.SetState("counter", map[string]interface{}{
|
||||
"prices": map[string]interface{}{"rate": 42},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState: %v", err)
|
||||
}
|
||||
if got := stateN(t, ps); got != 5 {
|
||||
t.Errorf("a prices-only payload must not touch state: n = %v, want 5", got)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(ps.stateFile("counter"))
|
||||
if err != nil {
|
||||
t.Fatalf("no state file after a prices-only update: %v", err)
|
||||
}
|
||||
var saved struct {
|
||||
State map[string]interface{} `json:"state"`
|
||||
Prices map[string]interface{} `json:"prices"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Prices["rate"] != float64(42) {
|
||||
t.Errorf("★ new price not persisted: %v — a restart would revert to the old price", saved.Prices)
|
||||
}
|
||||
if saved.State["n"] != float64(5) {
|
||||
t.Errorf("state was clobbered by a price change: %v", saved.State["n"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookReturnValueSurvivesSnapshot guards the ordering bug: snapshotting
|
||||
// plugin.state resets the Lua stack, so doing it BEFORE reading the hook's
|
||||
// return value silently turned every opinionated plugin into a silent one —
|
||||
// breaking the documented "return a table to merge into payload" contract with
|
||||
// no error anywhere.
|
||||
func TestHookReturnValueSurvivesSnapshot(t *testing.T) {
|
||||
ps, _ := newPersistVM(t)
|
||||
code := `
|
||||
local plugin = { name = "opinionated" }
|
||||
plugin.state = { n = 0 }
|
||||
plugin.hooks = { request_end = "tag" }
|
||||
function plugin.tag(payload)
|
||||
plugin.state.n = plugin.state.n + 1
|
||||
return { cost_usd = 1.25, verdict = "billed" }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := ps.LoadSource("opinionated", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
|
||||
if out["cost_usd"] != 1.25 {
|
||||
t.Errorf("★ hook return value lost: payload = %v", out)
|
||||
}
|
||||
if out["verdict"] != "billed" {
|
||||
t.Errorf("merged field lost: %v", out)
|
||||
}
|
||||
ps.save.flush()
|
||||
st := ps.State("opinionated").(map[string]interface{})
|
||||
if st["n"] != float64(1) {
|
||||
t.Errorf("state.n = %v, want 1 (the hook still ran)", st["n"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorruptStateFileIsNotFatal: a truncated write must degrade to compiled-in
|
||||
// defaults, never to a gateway that refuses to start.
|
||||
func TestCorruptStateFileIsNotFatal(t *testing.T) {
|
||||
ps, _ := newPersistVM(t)
|
||||
loadCounter(t, ps)
|
||||
for i := 0; i < 3; i++ {
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
|
||||
}
|
||||
ps.save.flush()
|
||||
ps.Close()
|
||||
|
||||
if err := os.WriteFile(ps.stateFile("counter"), []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
ps2 := NewPlugins(vm, ps.dir)
|
||||
defer ps2.Close()
|
||||
var warned bool
|
||||
ps2.logf = func(string, ...interface{}) { warned = true }
|
||||
loadCounter(t, ps2)
|
||||
|
||||
if got := stateN(t, ps2); got != 0 {
|
||||
t.Errorf("with a corrupt file the plugin must fall back to its defaults, got n = %v", got)
|
||||
}
|
||||
if !warned {
|
||||
t.Error("a corrupt state file must warn the operator, not fail silently")
|
||||
}
|
||||
// And it must still forward.
|
||||
ps2.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
|
||||
}
|
||||
|
||||
// TestFlushIsCoalesced: N mutations must not become N writes. A synchronous
|
||||
// per-request write would put a file write on the hot path.
|
||||
func TestFlushIsCoalesced(t *testing.T) {
|
||||
ps, _ := newPersistVM(t)
|
||||
loadCounter(t, ps)
|
||||
for i := 0; i < 500; i++ {
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
|
||||
}
|
||||
ps.save.flush()
|
||||
// 500 mutations must collapse to a handful of writes, not 500. The bound is
|
||||
// loose on purpose: the saver also flushes on a timer, so how many writes
|
||||
// happen during the loop depends on how long 500 hook calls take. What must
|
||||
// never happen is one write per mutation.
|
||||
if w := ps.save.writes.Load(); w > 5 {
|
||||
t.Errorf("500 hook calls produced %d file writes; the saver must coalesce", w)
|
||||
}
|
||||
if got := stateN(t, ps); got != 500 {
|
||||
t.Fatalf("n = %v, want 500", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseFlushesTail: the shutdown path must not lose the last interval,
|
||||
// which would reintroduce the same defect in a smaller window.
|
||||
func TestCloseFlushesTail(t *testing.T) {
|
||||
ps, _ := newPersistVM(t)
|
||||
loadCounter(t, ps)
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
|
||||
ps.Close()
|
||||
// Close() already waits for the final flush; no sleep needed.
|
||||
|
||||
b, err := os.ReadFile(ps.stateFile("counter"))
|
||||
if err != nil {
|
||||
t.Fatalf("★ Close() did not flush: %v — a shutdown loses the tail", err)
|
||||
}
|
||||
var saved struct {
|
||||
State map[string]interface{} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.State["n"] != float64(1) {
|
||||
t.Errorf("flushed n = %v, want 1", saved.State["n"])
|
||||
}
|
||||
}
|
||||
1584
internal/lua/plugins.go
Normal file
1584
internal/lua/plugins.go
Normal file
File diff suppressed because it is too large
Load Diff
1535
internal/lua/plugins/billing.lua
Normal file
1535
internal/lua/plugins/billing.lua
Normal file
File diff suppressed because it is too large
Load Diff
377
internal/lua/plugins_test.go
Normal file
377
internal/lua/plugins_test.go
Normal file
@ -0,0 +1,377 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newPluginVM builds a VM plus a plugin registry rooted at dir.
|
||||
func newPluginVM(t *testing.T) (*VM, *Plugins, string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "adapters")
|
||||
vm := NewVM(dir)
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("vm start: %v", err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
pdir := filepath.Join(t.TempDir(), "plugins")
|
||||
return vm, NewPlugins(vm, pdir), pdir
|
||||
}
|
||||
|
||||
// loadPlugin writes one plugin to disk and loads it.
|
||||
func loadPlugin(t *testing.T, ps *Plugins, name, code string) error {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(ps.dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(ps.dir, name+".lua"), []byte(code), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ps.LoadSource(name, code)
|
||||
}
|
||||
|
||||
// TestPluginManifestAndHooks: the two hook registration forms both work and the
|
||||
// manifest is read.
|
||||
func TestPluginManifestAndHooks(t *testing.T) {
|
||||
_, ps, _ := newPluginVM(t)
|
||||
code := `
|
||||
local p = {}
|
||||
p.name = "demo"
|
||||
p.version = "1.2.3"
|
||||
p.description = "a demo plugin"
|
||||
p.author = "tester"
|
||||
p.hooks = { request_end = "on_end" }
|
||||
function p.on_end(payload)
|
||||
payload.seen = true
|
||||
payload.name_seen = "demo"
|
||||
return payload
|
||||
end
|
||||
return p
|
||||
`
|
||||
if err := loadPlugin(t, ps, "demo", code); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
list := ps.List()
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("List() = %d plugins, want 1", len(list))
|
||||
}
|
||||
if list[0]["name"] != "demo" || list[0]["version"] != "1.2.3" {
|
||||
t.Errorf("manifest not read: %+v", list[0])
|
||||
}
|
||||
hooks := list[0]["hooks"].([]string)
|
||||
if len(hooks) != 1 || hooks[0] != string(StageRequestEnd) {
|
||||
t.Errorf("hooks = %v, want [request_end]", hooks)
|
||||
}
|
||||
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
|
||||
if out["seen"] != true || out["name_seen"] != "demo" {
|
||||
t.Errorf("hook did not mutate payload: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginAnonymousHookForm: `request_end = function() end` directly on the
|
||||
// table must register too, since a single-hook plugin should not need a name.
|
||||
func TestPluginAnonymousHookForm(t *testing.T) {
|
||||
_, ps, _ := newPluginVM(t)
|
||||
code := `
|
||||
local p = { name = "anon" }
|
||||
p.request_end = function(payload)
|
||||
payload.hit = 1
|
||||
return payload
|
||||
end
|
||||
return p
|
||||
`
|
||||
if err := loadPlugin(t, ps, "anon", code); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
out := ps.Fire(StageRequestEnd, map[string]interface{}{})
|
||||
if out["hit"] != float64(1) {
|
||||
t.Errorf("anonymous hook did not fire: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginHookStagesFireInOrder: each stage reaches only its own hooks.
|
||||
func TestPluginHookStagesFireInOrder(t *testing.T) {
|
||||
_, ps, _ := newPluginVM(t)
|
||||
code := `
|
||||
local p = { name = "stages" }
|
||||
p.hooks = {
|
||||
request_start = "s1",
|
||||
routed = "s2",
|
||||
request_end = "s3",
|
||||
}
|
||||
function p.s1(x) x.order = (x.order or "") .. "1" return x end
|
||||
function p.s2(x) x.order = (x.order or "") .. "2" return x end
|
||||
function p.s3(x) x.order = (x.order or "") .. "3" return x end
|
||||
return p
|
||||
`
|
||||
if err := loadPlugin(t, ps, "stages", code); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
ps.Fire(StageRequestStart, payload)
|
||||
ps.Fire(StageRouted, payload)
|
||||
ps.Fire(StageRequestEnd, payload)
|
||||
if payload["order"] != "123" {
|
||||
t.Errorf("stage order = %v, want \"123\"", payload["order"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginErrorIsContained is the critical safety property: a throwing hook
|
||||
// must not propagate. Forwarding depends on it.
|
||||
func TestPluginErrorIsContained(t *testing.T) {
|
||||
_, ps, _ := newPluginVM(t)
|
||||
code := `
|
||||
local p = { name = "boom" }
|
||||
p.hooks = { request_end = "kaboom" }
|
||||
function p.kaboom(payload)
|
||||
error("intentional plugin failure")
|
||||
end
|
||||
return p
|
||||
`
|
||||
if err := loadPlugin(t, ps, "boom", code); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
// Must not panic and must return the payload unchanged.
|
||||
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
|
||||
if out["model"] != "m" {
|
||||
t.Errorf("payload was altered by a failing plugin: %+v", out)
|
||||
}
|
||||
// And the failure must be visible, not silent.
|
||||
errs := ps.HookErrors()
|
||||
if errs["request_end"] == nil {
|
||||
t.Error("a failing plugin left no error record; it would be silently missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginFailingHookDoesNotBlockLaterPlugins: one bad plugin must not stop
|
||||
// the next one from running.
|
||||
func TestPluginFailingHookDoesNotBlockLaterPlugins(t *testing.T) {
|
||||
_, ps, _ := newPluginVM(t)
|
||||
bad := `
|
||||
local p = { name = "bad" }
|
||||
p.hooks = { request_end = "f" }
|
||||
function p.f(x) error("boom") end
|
||||
return p
|
||||
`
|
||||
good := `
|
||||
local p = { name = "good" }
|
||||
p.hooks = { request_end = "f" }
|
||||
function p.f(x) x.good = true return x end
|
||||
return p
|
||||
`
|
||||
_ = loadPlugin(t, ps, "bad", bad)
|
||||
if err := loadPlugin(t, ps, "good", good); err != nil {
|
||||
t.Fatalf("load good: %v", err)
|
||||
}
|
||||
out := ps.Fire(StageRequestEnd, map[string]interface{}{})
|
||||
if out["good"] != true {
|
||||
t.Errorf("a good plugin was blocked by a failing one: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginSyntaxErrorIsIsolated: a plugin that will not compile is listed
|
||||
// with its error and is never called — it must not prevent LoadDir from loading
|
||||
// the rest.
|
||||
func TestPluginSyntaxErrorIsIsolated(t *testing.T) {
|
||||
_, ps, _ := newPluginVM(t)
|
||||
broken := "this is not lua((("
|
||||
good := `
|
||||
local p = { name = "ok" }
|
||||
p.hooks = { request_end = "f" }
|
||||
function p.f(x) x.ok = true return x end
|
||||
return p
|
||||
`
|
||||
_ = loadPlugin(t, ps, "broken", broken)
|
||||
if err := loadPlugin(t, ps, "ok", good); err != nil {
|
||||
t.Fatalf("load ok: %v", err)
|
||||
}
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
// The broken plugin must not be callable and must carry an error.
|
||||
for _, row := range ps.List() {
|
||||
if row["name"] == "broken" {
|
||||
if row["loaded"] == true {
|
||||
t.Error("a plugin with a syntax error reported itself as loaded")
|
||||
}
|
||||
if row["error"] == nil || row["error"] == "" {
|
||||
t.Error("a broken plugin carries no error message")
|
||||
}
|
||||
}
|
||||
}
|
||||
// The good plugin still works.
|
||||
out := ps.Fire(StageRequestEnd, map[string]interface{}{})
|
||||
if out["ok"] != true {
|
||||
t.Errorf("good plugin stopped working: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginUIExtension: a plugin can contribute a page and elements.
|
||||
func TestPluginUIExtension(t *testing.T) {
|
||||
_, ps, _ := newPluginVM(t)
|
||||
code := `
|
||||
local p = { name = "ui" }
|
||||
p.hooks = { request_end = "f" }
|
||||
function p.f(x) return x end
|
||||
p.ui = {
|
||||
page = {
|
||||
page_id = "billing",
|
||||
title = "Billing",
|
||||
icon = "💰",
|
||||
order = 50,
|
||||
mount = "<div id=billing>hi</div><script>console.log('m')</script>",
|
||||
},
|
||||
elements = {
|
||||
{ target = "status", anchor = "top", mount = "<div>cost</div>" },
|
||||
},
|
||||
}
|
||||
return p
|
||||
`
|
||||
if err := loadPlugin(t, ps, "ui", code); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
// Every contributed page — including the single `page` field — is folded
|
||||
// into one merged list. Reading ui.Page here would silently pass on a
|
||||
// payload whose pages were all dropped.
|
||||
ui := ps.UI()
|
||||
if len(ui.Pages) != 1 {
|
||||
t.Fatalf("expected 1 merged page, got %d", len(ui.Pages))
|
||||
}
|
||||
pg := ui.Pages[0]
|
||||
if pg.PageID != "billing" || pg.Title != "Billing" {
|
||||
t.Errorf("page = %+v", pg)
|
||||
}
|
||||
if !strings.Contains(pg.Mount, "console.log") {
|
||||
t.Error("mount lost its script content")
|
||||
}
|
||||
if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" {
|
||||
t.Errorf("elements = %+v", ui.Elements)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginHookReturnsNilIsNoOpinion: a hook returning nothing must leave the
|
||||
// payload untouched (plugins should not be forced to echo it back).
|
||||
func TestPluginHookReturnsNilIsNoOpinion(t *testing.T) {
|
||||
_, ps, _ := newPluginVM(t)
|
||||
code := `
|
||||
local p = { name = "silent" }
|
||||
p.hooks = { request_end = "f" }
|
||||
function p.f(payload)
|
||||
-- records nothing, returns nothing
|
||||
return nil
|
||||
end
|
||||
return p
|
||||
`
|
||||
if err := loadPlugin(t, ps, "silent", code); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "ok": true})
|
||||
if out["model"] != "m" || out["ok"] != true {
|
||||
t.Errorf("a no-op hook disturbed the payload: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginUIJSONShape is a wire-format guard: the kernel sends this to the
|
||||
// browser, so the shape is a contract with the WebUI.
|
||||
func TestPluginUIJSONShape(t *testing.T) {
|
||||
_, ps, _ := newPluginVM(t)
|
||||
code := `
|
||||
local p = { name = "shape" }
|
||||
p.hooks = { request_end = "f" }
|
||||
function p.f(x) return x end
|
||||
p.ui = { elements = { { target = "keys", mount = "<b>k</b>" } } }
|
||||
return p
|
||||
`
|
||||
if err := loadPlugin(t, ps, "shape", code); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
b, err := json.Marshal(ps.UI())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal UI: %v", err)
|
||||
}
|
||||
var view struct {
|
||||
Elements []struct {
|
||||
Target string `json:"target"`
|
||||
Mount string `json:"mount"`
|
||||
} `json:"elements"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &view); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(view.Elements) != 1 || view.Elements[0].Target != "keys" {
|
||||
t.Errorf("UI JSON shape = %+v", view.Elements)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginFireWithNoPluginsIsNoop: an empty registry must not allocate or fail.
|
||||
func TestPluginFireWithNoPluginsIsNoop(t *testing.T) {
|
||||
_, ps, _ := newPluginVM(t)
|
||||
in := map[string]interface{}{"a": 1}
|
||||
out := ps.Fire(StageRequestEnd, in)
|
||||
if out["a"] != 1 || ps.Count() != 0 {
|
||||
t.Errorf("empty registry misbehaved: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainStepIsARealStage: chain_step is documented as a distinct stage that
|
||||
// fires once per step of an AUTO walk. If it were only a field on `routed`, a
|
||||
// plugin author following the docs would silently get one event instead of the
|
||||
// whole walk.
|
||||
func TestChainStepIsARealStage(t *testing.T) {
|
||||
found := false
|
||||
for _, s := range AllStages {
|
||||
if s == StageChainStep {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("StageChainStep is not in AllStages, so the dispatcher never registers it")
|
||||
}
|
||||
// Ordering: it must sit between request_start and routed, which is what
|
||||
// docs/plugins.md promises.
|
||||
var iStart, iStep, iRouted = -1, -1, -1
|
||||
for i, s := range AllStages {
|
||||
switch s {
|
||||
case StageRequestStart:
|
||||
iStart = i
|
||||
case StageChainStep:
|
||||
iStep = i
|
||||
case StageRouted:
|
||||
iRouted = i
|
||||
}
|
||||
}
|
||||
if !(iStart < iStep && iStep < iRouted) {
|
||||
t.Errorf("stage order = %v, want request_start < chain_step < routed", AllStages)
|
||||
}
|
||||
_, ps, _ := newPluginVM(t)
|
||||
code := `
|
||||
local p = { name = "stepper" }
|
||||
p.hooks = { chain_step = "s" }
|
||||
function p.s(payload)
|
||||
payload.kinds = (payload.kinds or "")
|
||||
return nil
|
||||
end
|
||||
return p
|
||||
`
|
||||
if err := loadPlugin(t, ps, "stepper", code); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
// It must actually dispatch.
|
||||
seen := false
|
||||
for _, row := range ps.List() {
|
||||
if row["name"] == "stepper" {
|
||||
hooks := row["hooks"].([]string)
|
||||
for _, h := range hooks {
|
||||
if h == string(StageChainStep) {
|
||||
seen = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
t.Error("a plugin registered for chain_step is not reported as such")
|
||||
}
|
||||
}
|
||||
@ -36,7 +36,7 @@ import (
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
)
|
||||
|
||||
//go:embed adapters/*.lua
|
||||
//go:embed adapters/*.lua plugins/*.lua
|
||||
var bundledAdapters embed.FS
|
||||
|
||||
// adapterGlobal is the reserved global holding the adapter table after the
|
||||
@ -103,6 +103,11 @@ type adapterPool struct {
|
||||
lastGrow time.Time
|
||||
idleRounds int // consecutive janitor rounds that saw reclaimable slack
|
||||
peakInUse int // high-water mark of inUse, for observability
|
||||
// pluginMode makes boot() store the returned table under pluginGlobal
|
||||
// instead of adapterGlobal. Everything else (elastic sizing, reclaim) is
|
||||
// identical, which is why plugins reuse this pool rather than getting a
|
||||
// second implementation.
|
||||
pluginMode bool
|
||||
}
|
||||
|
||||
const (
|
||||
@ -116,6 +121,13 @@ const (
|
||||
// growCooldown keeps a burst of misses from batching repeatedly while the
|
||||
// previous batch is still booting.
|
||||
growCooldown = time.Second
|
||||
// maxPluginStates caps how many concurrent VM states ONE plugin may occupy.
|
||||
// A plugin is third-party code on the request path, so its ceiling is much
|
||||
// lower than an adapter's (which is sized from the sources'
|
||||
// max_concurrent): a plugin hook is a short synchronous call, so a handful
|
||||
// of states is already far more parallelism than any real hook needs, and a
|
||||
// runaway plugin cannot balloon memory the way a per-source adapter pool can.
|
||||
maxPluginStates = 4
|
||||
// residentWorkers is how many states an adapter keeps warm once it has
|
||||
// served at least one request. Booting is milliseconds, but keeping one warm
|
||||
// removes that from the critical path of the next request. Adapters that
|
||||
@ -265,7 +277,14 @@ func (p *adapterPool) boot() (*worker, error) {
|
||||
L.Close()
|
||||
return nil, fmt.Errorf("adapter %s must return a table", p.name)
|
||||
}
|
||||
L.SetGlobal(adapterGlobal)
|
||||
// SetGlobal POPS the value off the stack, so it can only be called once per
|
||||
// boot. Plugins therefore store under pluginGlobal only, and NOT under
|
||||
// adapterGlobal: a second SetGlobal on the now-empty stack would assign nil.
|
||||
if p.pluginMode {
|
||||
L.SetGlobal(pluginGlobal)
|
||||
} else {
|
||||
L.SetGlobal(adapterGlobal)
|
||||
}
|
||||
L.SetTop(0)
|
||||
return &worker{L: L}, nil
|
||||
}
|
||||
@ -601,6 +620,55 @@ func (v *VM) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// bundledPluginsDir is the directory inside the embedded FS holding the
|
||||
// plugins shipped with the gateway. It is separate from adapters/ on purpose:
|
||||
// the two are loaded by different machinery into different kinds of Lua state
|
||||
// (a protocol transform vs. request-pipeline hooks), and keeping them apart
|
||||
// makes it obvious that dropping a file in one does not affect the other.
|
||||
const bundledPluginsDir = "plugins"
|
||||
|
||||
// ReadBundledPlugin returns the source of a plugin shipped with the gateway.
|
||||
// It exists so a test (or an operator tool) can load a bundled plugin without
|
||||
// depending on whether seeding has already run for this directory.
|
||||
func ReadBundledPlugin(name string) (string, error) {
|
||||
data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + name + ".lua")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bundled plugin %s: %w", name, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// writeBundledPlugins seeds the plugin directory with the shipped plugins.
|
||||
//
|
||||
// It runs only when the directory does not exist yet (same rule as adapters):
|
||||
// once the directory exists it is authoritative, so deleting a shipped plugin is
|
||||
// a real delete and editing one survives restarts.
|
||||
func writeBundledPlugins(dir string) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir plugin dir: %w", err)
|
||||
}
|
||||
entries, err := bundledAdapters.ReadDir(bundledPluginsDir)
|
||||
if err != nil {
|
||||
return nil // nothing embedded; not an error
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".lua" {
|
||||
continue
|
||||
}
|
||||
data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, e.Name()), data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VM) Stop() {
|
||||
select {
|
||||
case <-v.janitorStop:
|
||||
@ -925,9 +993,47 @@ func restorePcall(L *golua.State) {
|
||||
}
|
||||
}
|
||||
|
||||
// hookGuardName is the Lua global that wraps every plugin hook call.
|
||||
const hookGuardName = "__llmsproxy_call_hook"
|
||||
|
||||
// hookGuardSrc defines the wrapper. It exists because of a hard constraint in
|
||||
// the binding: golua's callEx calls L.StackTrace() on ANY pcall error, and
|
||||
// StackTrace() calls lua_getinfo, which SIGSEGVs in this LuaJIT build once the
|
||||
// stack is deep enough (a request_end payload with the AUTO chain trace does
|
||||
// it). Go cannot recover from a C-level signal, so a single Lua mistake inside
|
||||
// one plugin killed the whole gateway — the production outage this was written
|
||||
// for. Repeated crashes proved the boundary is not theoretical.
|
||||
//
|
||||
// pcall INSIDE Lua catches the error before golua ever sees a non-zero
|
||||
// pcall status, so the C stack-trace path is never entered. The failure comes
|
||||
// back as an ordinary (nil, message) pair, which the Go side records in
|
||||
// hook_errors and moves on from — the documented contract that "a broken plugin
|
||||
// must not affect request forwarding" finally holds for script errors too, not
|
||||
// just for Go panics.
|
||||
//
|
||||
// returns: (result, errorMessage) — both nil/"" on success.
|
||||
const hookGuardSrc = `
|
||||
function ` + hookGuardName + `(fn, payload)
|
||||
local ok, res = pcall(fn, payload)
|
||||
if not ok then
|
||||
return nil, tostring(res)
|
||||
end
|
||||
return res, nil
|
||||
end`
|
||||
|
||||
func registerHookGuard(L *golua.State) {
|
||||
if err := L.DoString(hookGuardSrc); err != nil {
|
||||
// Nothing useful to do here beyond leaving the global absent: invoke()
|
||||
// checks for it and falls back to a direct call, which still works for
|
||||
// correct plugins (only their ERRORS stop being survivable).
|
||||
L.Pop(1)
|
||||
}
|
||||
}
|
||||
|
||||
func setupGlobals(L *golua.State) {
|
||||
restorePcall(L)
|
||||
buildJSONTable(L)
|
||||
registerHookGuard(L)
|
||||
|
||||
registerFn(L, "hmac_sha256_hex", func(L *golua.State) int {
|
||||
key := L.ToString(1)
|
||||
@ -1104,6 +1210,56 @@ func pushGoValue(L *golua.State, v interface{}) {
|
||||
}
|
||||
|
||||
func jsonEncode(v interface{}) ([]byte, error) { return json.Marshal(v) }
|
||||
|
||||
// luaToJSON converts the Lua value at idx into a Go value via json.encode, then
|
||||
// unmarshals it into out. It is the bridge used by the plugin manifest/UI
|
||||
// reader: the plugin returns a plain Lua table, and Go wants a typed struct.
|
||||
//
|
||||
// It goes through JSON rather than walking the Lua stack directly because the
|
||||
// adapter/plugin boundary already speaks JSON everywhere else (transform_request
|
||||
// gets a JSON string, hooks get a JSON string), so this keeps one representation
|
||||
// instead of two.
|
||||
func luaToJSON(L *golua.State, idx int, out interface{}) error {
|
||||
if L.GetTop() < 1 {
|
||||
return fmt.Errorf("empty stack")
|
||||
}
|
||||
abs := idx
|
||||
if abs < 0 {
|
||||
abs = L.GetTop() + 1 + abs
|
||||
}
|
||||
if abs < 1 || abs > L.GetTop() {
|
||||
return fmt.Errorf("index %d out of range (top=%d)", idx, L.GetTop())
|
||||
}
|
||||
// Absolute indices throughout: this binding aborts the process (SIGABRT)
|
||||
// on a bad index rather than panicking, so the stack is captured before any
|
||||
// push instead of being addressed relative to a shifting top.
|
||||
//
|
||||
// json.encode is pushed onto the stack and the value is pushed AFTER it, so
|
||||
// Call(1, 1) invokes it (Call takes no function index — it calls whatever
|
||||
// sits below the nargs values).
|
||||
L.GetGlobal("json")
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return fmt.Errorf("json global missing")
|
||||
}
|
||||
L.GetField(-1, "encode")
|
||||
if L.Type(-1) != golua.LUA_TFUNCTION {
|
||||
L.SetTop(0)
|
||||
return fmt.Errorf("json.encode missing")
|
||||
}
|
||||
L.PushValue(abs)
|
||||
if err := L.Call(1, 1); err != nil {
|
||||
L.SetTop(0)
|
||||
return err
|
||||
}
|
||||
if L.GetTop() < 1 || L.Type(-1) != golua.LUA_TSTRING {
|
||||
L.SetTop(0)
|
||||
return fmt.Errorf("json.encode did not return a string")
|
||||
}
|
||||
s := L.ToString(-1)
|
||||
L.SetTop(0)
|
||||
return json.Unmarshal([]byte(s), out)
|
||||
}
|
||||
func jsonDecode(s string) (interface{}, error) {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
|
||||
270
internal/provider/gemini_endpoint_test.go
Normal file
270
internal/provider/gemini_endpoint_test.go
Normal file
@ -0,0 +1,270 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// Gemini is the only adapter whose endpoint carries the model in the PATH
|
||||
// (POST /v1beta/models/{model}:generateContent), so it is the only one that
|
||||
// needs the substitution this file covers.
|
||||
//
|
||||
// The defect it fixes was silent: the adapter shipped endpoint="/v1/models",
|
||||
// which is Gemini's model-LIST endpoint and answers 405 to POST, so the preset
|
||||
// template produced a source that could never work.
|
||||
|
||||
func geminiVM(t *testing.T) *lua.VM {
|
||||
t.Helper()
|
||||
// The dir must be named "adapters": the VM seeds its bundled adapters into
|
||||
// <dir> on Start and a differently-named dir leaves it empty, which surfaces
|
||||
// as "adapter gemini not loaded" rather than an obvious setup error.
|
||||
vm := lua.NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("lua vm: %v", err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
return vm
|
||||
}
|
||||
|
||||
// geminiProvider builds a provider on the gemini adapter against base.
|
||||
func geminiProvider(t *testing.T, base string, models ...string) *Provider {
|
||||
t.Helper()
|
||||
if len(models) == 0 {
|
||||
models = []string{"gemini-3.6-flash"}
|
||||
}
|
||||
ms := make([]config.Model, 0, len(models))
|
||||
for _, m := range models {
|
||||
ms = append(ms, config.Model{ID: m, Kind: "chat"})
|
||||
}
|
||||
return New(config.Source{
|
||||
Name: "gem",
|
||||
BaseURL: base,
|
||||
Adapter: "gemini",
|
||||
APIKey: "k",
|
||||
MaxConcurrent: 4,
|
||||
Models: ms,
|
||||
}, geminiVM(t))
|
||||
}
|
||||
|
||||
// geminiRecorder captures the path the gateway actually requested and replies
|
||||
// with a minimal Gemini-shaped payload.
|
||||
type geminiRecorder struct {
|
||||
mu sync.Mutex
|
||||
paths []string
|
||||
bodies []string
|
||||
stream bool
|
||||
}
|
||||
|
||||
func (g *geminiRecorder) handler(t *testing.T) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
g.mu.Lock()
|
||||
// RequestURI, not URL.Path: URL.Path is the DECODED path, so an escaped
|
||||
// %2F looks like a real "/" and the assertion could not tell an escaped
|
||||
// id from an unescaped one.
|
||||
g.paths = append(g.paths, r.RequestURI)
|
||||
g.bodies = append(g.bodies, string(raw))
|
||||
stream := g.stream
|
||||
g.mu.Unlock()
|
||||
|
||||
if stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprintf(w, "data: %s\n\n", `{"candidates":[{"content":{"parts":[{"text":"hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1,"totalTokenCount":4}}`)
|
||||
fmt.Fprintln(w, "data: [DONE]")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"candidates":[{"content":{"parts":[{"text":"pong"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1,"totalTokenCount":4}}`)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *geminiRecorder) lastPath() string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if len(g.paths) == 0 {
|
||||
return ""
|
||||
}
|
||||
return g.paths[len(g.paths)-1]
|
||||
}
|
||||
|
||||
// TestGeminiEndpointCarriesModel: a non-streaming chat must POST to
|
||||
// /v1beta/models/<model>:generateContent. The old static endpoint produced
|
||||
// "/v1beta/v1/models" (a list endpoint, 405 on POST).
|
||||
func TestGeminiEndpointCarriesModel(t *testing.T) {
|
||||
rec := &geminiRecorder{}
|
||||
srv := httptest.NewServer(rec.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
p := geminiProvider(t, srv.URL, "gemini-3.6-flash")
|
||||
if _, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "gemini-3.6-flash",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||
}); err != nil {
|
||||
t.Fatalf("chat: %v (path was %q)", err, rec.lastPath())
|
||||
}
|
||||
if got, want := rec.lastPath(), "/v1beta/models/gemini-3.6-flash:generateContent"; got != want {
|
||||
t.Errorf("path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiStreamUsesStreamGenerateContent: the streaming verb lives in the
|
||||
// path too, so it must be swapped for stream requests only.
|
||||
func TestGeminiStreamUsesStreamGenerateContent(t *testing.T) {
|
||||
rec := &geminiRecorder{stream: true}
|
||||
srv := httptest.NewServer(rec.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
p := geminiProvider(t, srv.URL, "gemini-3.6-flash")
|
||||
ch, err := p.ChatStream(context.Background(), &types.ChatRequest{
|
||||
Model: "gemini-3.6-flash",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stream: %v (path was %q)", err, rec.lastPath())
|
||||
}
|
||||
for range ch {
|
||||
}
|
||||
if got, want := rec.lastPath(), "/v1beta/models/gemini-3.6-flash:streamGenerateContent"; got != want {
|
||||
t.Errorf("path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiModelFollowsRequestNotSourceDefault is the part a naive fix gets
|
||||
// wrong: substituting the source's default model would make every request on a
|
||||
// multi-model source bill the wrong model. AUTO pins req.Model per slot, so the
|
||||
// URL must follow the REQUEST's model.
|
||||
func TestGeminiModelFollowsRequestNotSourceDefault(t *testing.T) {
|
||||
rec := &geminiRecorder{}
|
||||
srv := httptest.NewServer(rec.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
p := geminiProvider(t, srv.URL, "gemini-3.6-flash", "gemini-3.1-pro")
|
||||
for _, m := range []string{"gemini-3.1-pro", "gemini-3.6-flash"} {
|
||||
if _, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: m,
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||
}); err != nil {
|
||||
t.Fatalf("chat %s: %v", m, err)
|
||||
}
|
||||
if want := "/v1beta/models/" + m + ":generateContent"; rec.lastPath() != want {
|
||||
t.Errorf("path = %q, want %q", rec.lastPath(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiModelIsEscaped: a model id reaches a URL path, so an id carrying a
|
||||
// slash or space must be escaped rather than silently addressing a different
|
||||
// resource (or producing a request Go's http client rejects).
|
||||
func TestGeminiModelIsEscaped(t *testing.T) {
|
||||
rec := &geminiRecorder{}
|
||||
srv := httptest.NewServer(rec.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
// Configure the odd id as a real model so ModelFor resolves it.
|
||||
p := geminiProvider(t, srv.URL, "vendor/model:v1 beta")
|
||||
if _, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "vendor/model:v1 beta",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||
}); err != nil {
|
||||
t.Fatalf("chat: %v", err)
|
||||
}
|
||||
got := rec.lastPath()
|
||||
if strings.Contains(got, "/v1beta/models/vendor/model") {
|
||||
t.Errorf("path %q was not escaped: the id's slash changed the resource", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "/v1beta/models/") || !strings.HasSuffix(got, ":generateContent") {
|
||||
t.Errorf("path = %q, want an escaped id between the prefix and the verb", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonGeminiEndpointsAreUntouched: the substitution and the verb rewrite must
|
||||
// be no-ops for every adapter whose endpoint is a fixed path. A regression here
|
||||
// would silently rewrite OpenAI/Anthropic/… URLs.
|
||||
func TestNonGeminiEndpointsAreUntouched(t *testing.T) {
|
||||
for _, adapter := range []string{"openai", "deepseek", "anthropic", "ollama", "mistral", "trae", "sensenova"} {
|
||||
vm := geminiVM(t)
|
||||
p := New(config.Source{
|
||||
Name: "s", BaseURL: "https://example.test", Adapter: adapter,
|
||||
APIKey: "k", MaxConcurrent: 2,
|
||||
Models: []config.Model{{ID: "m1", Kind: "chat"}},
|
||||
}, vm)
|
||||
for _, stream := range []bool{false, true} {
|
||||
got := p.ChatURL("m1", stream)
|
||||
want := "https://example.test" + p.Endpoint()
|
||||
if got != want {
|
||||
t.Errorf("adapter %s stream=%v: URL = %q, want %q", adapter, stream, got, want)
|
||||
}
|
||||
if strings.Contains(got, "streamGenerateContent") {
|
||||
t.Errorf("adapter %s: streaming verb leaked into a fixed endpoint: %q", adapter, got)
|
||||
}
|
||||
}
|
||||
vm.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceEndpointOverridesTemplate: a source that spells its own endpoint
|
||||
// must win outright — including when it spells one WITHOUT the placeholder.
|
||||
func TestSourceEndpointOverridesTemplate(t *testing.T) {
|
||||
vm := geminiVM(t)
|
||||
defer vm.Stop()
|
||||
p := New(config.Source{
|
||||
Name: "s", BaseURL: "https://example.test", Adapter: "gemini",
|
||||
Endpoint: "/custom/path", APIKey: "k", MaxConcurrent: 2,
|
||||
Models: []config.Model{{ID: "m1", Kind: "chat"}},
|
||||
}, vm)
|
||||
if got, want := p.ChatURL("m1", false), "https://example.test/custom/path"; got != want {
|
||||
t.Errorf("URL = %q, want %q (source endpoint must override the adapter template)", got, want)
|
||||
}
|
||||
// And the streaming verb rewrite must not damage an unrelated path.
|
||||
if got := p.ChatURL("m1", true); strings.Contains(got, "streamGenerate") {
|
||||
t.Errorf("streaming rewrite leaked into a user-spelled path: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiResponseStillParses: the endpoint fix must not disturb the body
|
||||
// transform — a request that reaches the right URL still has to come back as a
|
||||
// unified response with Gemini's usageMetadata mapped.
|
||||
func TestGeminiResponseStillParses(t *testing.T) {
|
||||
rec := &geminiRecorder{}
|
||||
srv := httptest.NewServer(rec.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
p := geminiProvider(t, srv.URL, "gemini-3.6-flash")
|
||||
resp, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "gemini-3.6-flash",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("chat: %v", err)
|
||||
}
|
||||
if resp.Content != "pong" {
|
||||
t.Errorf("content = %q, want %q", resp.Content, "pong")
|
||||
}
|
||||
if resp.TokenUsage.Prompt != 3 || resp.TokenUsage.Completion != 1 || resp.TokenUsage.Total != 4 {
|
||||
t.Errorf("usage = %+v, want prompt 3 / completion 1 / total 4", resp.TokenUsage)
|
||||
}
|
||||
// The body the adapter produced must still be Gemini-native.
|
||||
rec.mu.Lock()
|
||||
body := rec.bodies[0]
|
||||
rec.mu.Unlock()
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(body), &sent); err != nil {
|
||||
t.Fatalf("adapter sent invalid json: %v (%s)", err, body)
|
||||
}
|
||||
if _, ok := sent["contents"]; !ok {
|
||||
t.Errorf("adapter did not convert to Gemini's contents[]: %s", body)
|
||||
}
|
||||
}
|
||||
@ -531,7 +531,9 @@ func isAutoID(s string) bool {
|
||||
return s == "" || strings.EqualFold(s, "AUTO")
|
||||
}
|
||||
|
||||
// Endpoint resolves the upstream chat path.
|
||||
// Endpoint resolves the upstream chat path. It may contain the placeholder
|
||||
// "{model}"; use URL (or ChatURL) rather than calling this directly when the
|
||||
// path has to be usable.
|
||||
func (p *Provider) Endpoint() string {
|
||||
if p.cfg.Endpoint != "" {
|
||||
return p.cfg.Endpoint
|
||||
@ -553,8 +555,40 @@ func (p *Provider) ImageEndpoint() string {
|
||||
return "/v1/images/generations"
|
||||
}
|
||||
|
||||
// modelPlaceholder is the marker an adapter puts in its endpoint template when
|
||||
// the upstream carries the model id in the PATH rather than in the body.
|
||||
// Gemini is the only such adapter: POST /v1beta/models/{model}:generateContent.
|
||||
// Every other adapter's endpoint is a fixed path, so substituting is a no-op
|
||||
// for them.
|
||||
const modelPlaceholder = "{model}"
|
||||
|
||||
// ChatURL resolves the full chat URL for a specific model.
|
||||
//
|
||||
// Two substitutions happen here, both driven by the adapter's endpoint template:
|
||||
//
|
||||
// - "{model}" is replaced by the model id actually being sent. Without this
|
||||
// the gateway would POST to Gemini's model-LIST endpoint, which answers 405.
|
||||
// - for a streaming request, the ":generateContent" verb becomes
|
||||
// ":streamGenerateContent". Gemini streams over the same path with a
|
||||
// different verb, and the verb is part of the path, so the two cannot both
|
||||
// be static. The rewrite is deliberately narrow: it only fires on the exact
|
||||
// ":generateContent" suffix, so an adapter whose endpoint merely mentions
|
||||
// the word keeps its path untouched.
|
||||
func (p *Provider) ChatURL(model string, stream bool) string {
|
||||
ep := p.Endpoint()
|
||||
if strings.Contains(ep, modelPlaceholder) {
|
||||
// A model id is put in a URL path, so it must be escaped: an id with a
|
||||
// slash would otherwise silently address a different resource.
|
||||
ep = strings.ReplaceAll(ep, modelPlaceholder, url.PathEscape(model))
|
||||
}
|
||||
if stream {
|
||||
ep = strings.Replace(ep, ":generateContent", ":streamGenerateContent", 1)
|
||||
}
|
||||
return strings.TrimRight(p.cfg.BaseURL, "/") + ep
|
||||
}
|
||||
|
||||
func (p *Provider) URL() string {
|
||||
return strings.TrimRight(p.cfg.BaseURL, "/") + p.Endpoint()
|
||||
return p.ChatURL("", false)
|
||||
}
|
||||
|
||||
func (p *Provider) ImageURL() string {
|
||||
@ -750,10 +784,11 @@ func (p *Provider) probeChat(ctx context.Context) (bool, string) {
|
||||
body, err := json.Marshal(probe)
|
||||
if err == nil {
|
||||
var hdr http.Header
|
||||
if hdrs, herr := p.buildHeaders(string(body), p.URL(), ""); herr == nil {
|
||||
probeURL := p.ChatURL(model, false)
|
||||
if hdrs, herr := p.buildHeaders(string(body), probeURL, ""); herr == nil {
|
||||
hdr = hdrs
|
||||
}
|
||||
raw, status, derr := p.do(ctx, p.URL(), string(body), hdr)
|
||||
raw, status, derr := p.do(ctx, probeURL, string(body), hdr)
|
||||
if derr != nil {
|
||||
msg = derr.Error()
|
||||
} else if status == 200 {
|
||||
@ -1084,11 +1119,12 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hdrs, err := p.buildHeaders(body, p.URL(), req.ClientSession)
|
||||
chatURL := p.ChatURL(model, false)
|
||||
hdrs, err := p.buildHeaders(body, chatURL, req.ClientSession)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, status, err := p.do(ctx, p.URL(), body, hdrs)
|
||||
raw, status, err := p.do(ctx, chatURL, body, hdrs)
|
||||
if err != nil {
|
||||
// a client disconnect or cancelled context is neither a success nor
|
||||
// a failure for scheduling purposes — only upstream errors count
|
||||
@ -1144,7 +1180,10 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
p.Release()
|
||||
return nil, err
|
||||
}
|
||||
hdrs, err := p.buildHeaders(body, p.URL(), req.ClientSession)
|
||||
// stream=true so a path-carried model plus the streaming verb is resolved
|
||||
// for THIS request's model, not the source default.
|
||||
streamURL := p.ChatURL(model, true)
|
||||
hdrs, err := p.buildHeaders(body, streamURL, req.ClientSession)
|
||||
if err != nil {
|
||||
p.Release()
|
||||
return nil, err
|
||||
@ -1156,7 +1195,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
}
|
||||
rc := make(chan respOrErr, 1)
|
||||
go func() {
|
||||
resp, err := p.doRawStream(ctx, p.URL(), body, hdrs)
|
||||
resp, err := p.doRawStream(ctx, streamURL, body, hdrs)
|
||||
rc <- respOrErr{resp, err}
|
||||
}()
|
||||
|
||||
|
||||
127
internal/scheduler/empty_response_test.go
Normal file
127
internal/scheduler/empty_response_test.go
Normal file
@ -0,0 +1,127 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// TestResultIsEmptyMatchesTheReasoningOnlyResponse guards the production bug
|
||||
// behind "model AUTO returned a completed response with no content".
|
||||
//
|
||||
// Production measurement: 2 of 20 AUTO requests returned zero content, both
|
||||
// served by claude-opus-4-8. Direct upstream capture showed 28 chunks of
|
||||
// `reasoning_content` and NO `content`, finish_reason=length — the token budget
|
||||
// was consumed by thinking before any text was emitted. runTier accepted it
|
||||
// because err == nil.
|
||||
func TestResultIsEmptyMatchesTheReasoningOnlyResponse(t *testing.T) {
|
||||
// Exactly what the upstream returned, as a decoded response.
|
||||
onlyReasoning := &types.UnifiedResponse{
|
||||
Model: "claude-opus-4-8",
|
||||
ReasoningContent: "Alright, the user just said \"hi\". Simple greeting…",
|
||||
FinishReason: "length",
|
||||
TokenUsage: types.TokenUsage{Prompt: 14, Completion: 30, Total: 44},
|
||||
}
|
||||
if !resultIsEmpty(onlyReasoning) {
|
||||
t.Error("a reasoning-only response with usage must count as empty; " +
|
||||
"it is what the client sees as \"completed with no content\"")
|
||||
}
|
||||
|
||||
// The same response WITH text is a normal answer.
|
||||
if resultIsEmpty(&types.UnifiedResponse{Content: "Hi! 👋", ReasoningContent: "hmm"}) {
|
||||
t.Error("a response with content is not empty")
|
||||
}
|
||||
// A tool-calling agent turn has no text and is perfectly valid.
|
||||
if resultIsEmpty(&types.UnifiedResponse{
|
||||
ToolCalls: []types.ToolCall{{Name: "read_file", Arguments: map[string]interface{}{"path": "x"}}},
|
||||
}) {
|
||||
t.Error("a tool-call-only response must NOT count as empty — agents " +
|
||||
"legitimately produce tool calls with no text")
|
||||
}
|
||||
// An image slot returns no text by design.
|
||||
if resultIsEmpty(&types.UnifiedResponse{
|
||||
ImageData: []types.ImageData{{URL: "http://x/y.png"}},
|
||||
}) {
|
||||
t.Error("an image response must NOT count as empty")
|
||||
}
|
||||
// Whitespace-only content is as useless to a client as none at all.
|
||||
if !resultIsEmpty(&types.UnifiedResponse{Content: " \n\t"}) {
|
||||
t.Error("whitespace-only content must count as empty")
|
||||
}
|
||||
if resultIsEmpty(nil) {
|
||||
t.Error("nil is not an empty response; it is an absent one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeekStreamHoldsReasoningAndReportsEmpty is the streaming half: a channel
|
||||
// of reasoning-only chunks must be reported as empty so chainDrive degrades.
|
||||
func TestPeekStreamHoldsReasoningAndReportsEmpty(t *testing.T) {
|
||||
in := make(chan types.UnifiedChunk, 8)
|
||||
for i := 0; i < 5; i++ {
|
||||
in <- types.UnifiedChunk{ReasoningContent: "thinking…"}
|
||||
}
|
||||
in <- types.UnifiedChunk{Done: true, FinishReason: "length"}
|
||||
close(in)
|
||||
|
||||
_, peek := peekStream(in)
|
||||
if peek() {
|
||||
t.Error("a reasoning-only stream must be reported empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeekStreamForwardsContentAfterReasoning: the common case still works,
|
||||
// and the reasoning preamble is not forwarded ahead of the content (holding it
|
||||
// back is what keeps the degrade path available).
|
||||
func TestPeekStreamForwardsContentAfterReasoning(t *testing.T) {
|
||||
in := make(chan types.UnifiedChunk, 8)
|
||||
in <- types.UnifiedChunk{ReasoningContent: "let me think"}
|
||||
in <- types.UnifiedChunk{Content: "Hi"}
|
||||
in <- types.UnifiedChunk{Content: "!"}
|
||||
in <- types.UnifiedChunk{Done: true, FinishReason: "stop"}
|
||||
close(in)
|
||||
|
||||
out, peek := peekStream(in)
|
||||
if !peek() {
|
||||
t.Fatal("a stream with content must be reported as having content")
|
||||
}
|
||||
var got []string
|
||||
for ck := range out {
|
||||
if ck.Content != "" {
|
||||
got = append(got, ck.Content)
|
||||
}
|
||||
if strings.TrimSpace(ck.ReasoningContent) != "" {
|
||||
t.Error("reasoning preamble must not be forwarded before content; " +
|
||||
"that is what pins the client to a stream it cannot escape")
|
||||
}
|
||||
}
|
||||
if strings.Join(got, "") != "Hi!" {
|
||||
t.Errorf("forwarded content = %q, want %q", got, "Hi!")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeekStreamDoesNotDeadlockOnToolCalls: a tool-call delta is content for
|
||||
// this purpose and must unblock peek immediately.
|
||||
func TestPeekStreamDoesNotDeadlockOnToolCalls(t *testing.T) {
|
||||
raw, _ := json.Marshal([]types.ToolCall{{Name: "ls"}})
|
||||
in := make(chan types.UnifiedChunk, 4)
|
||||
in <- types.UnifiedChunk{ToolCalls: raw}
|
||||
in <- types.UnifiedChunk{Done: true, FinishReason: "tool_calls"}
|
||||
close(in)
|
||||
|
||||
_, peek := peekStream(in)
|
||||
done := make(chan bool, 1)
|
||||
go func() { done <- peek() }()
|
||||
select {
|
||||
case ok := <-done:
|
||||
if !ok {
|
||||
t.Error("a tool-call delta must count as content")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("peek blocked on a tool-call-only stream")
|
||||
}
|
||||
_ = context.Background()
|
||||
}
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@ -245,6 +246,115 @@ func normalCount(cands []candidate) int {
|
||||
//
|
||||
// Normal candidates rotate by base; probe candidates form a fixed tail tried
|
||||
// only after every normal slot failed or was busy.
|
||||
// emptyResultReason describes why a 200-with-no-content response counts as a
|
||||
// slot failure for AUTO.
|
||||
//
|
||||
// WHY: reasoning models (claude-opus-*, codebuddy_glm-*, …) emit
|
||||
// `reasoning_content` first and only then `content`. When the caller's
|
||||
// max_tokens is small enough that the thinking phase consumes the whole budget,
|
||||
// upstream returns 200 / finish_reason=length with 28 chunks of reasoning and
|
||||
// ZERO content. runTier treated "err == nil" as success and handed that to the
|
||||
// client, which then failed with "returned a completed response with no
|
||||
// content" — a client-side error message for what is really a bad slot choice.
|
||||
//
|
||||
// So an empty result is a SLOT failure, not a request failure: the gateway
|
||||
// degrades to the next slot and the user still gets an answer. Measured on
|
||||
// production AUTO: 2 of 20 requests returned empty content, all of them
|
||||
// claude-opus-4-8.
|
||||
//
|
||||
// A response carrying tool_calls or image data is NOT empty: an agent turn
|
||||
// legitimately produces tool calls with no text. ReasoningContent does NOT
|
||||
// rescue it either — see resultIsEmpty.
|
||||
const emptyResultReason = "upstream returned no content (reasoning-only response, or the token budget was consumed before any text)"
|
||||
|
||||
// resultIsEmpty reports whether a successful-but-useless response should be
|
||||
// treated as a slot failure.
|
||||
//
|
||||
// Image data counts: an image-generation slot legitimately returns no text.
|
||||
// A usage-only response is NOT empty either — the upstream answered, it just
|
||||
// said nothing, and that is exactly the case worth degrading away from.
|
||||
func resultIsEmpty(resp *types.UnifiedResponse) bool {
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
// NOTE: ReasoningContent is deliberately NOT consulted. My first version
|
||||
// excluded it ("the model was thinking, that is an answer"), and the test
|
||||
// built from the real production capture failed immediately: the captured
|
||||
// response is exactly reasoning_content-with-usage and zero text. The
|
||||
// client asked for text and there is none; holding a request hostage to
|
||||
// another model's thinking phase is strictly worse than degrading.
|
||||
return strings.TrimSpace(resp.Content) == "" &&
|
||||
len(resp.ToolCalls) == 0 &&
|
||||
len(resp.ImageData) == 0
|
||||
}
|
||||
|
||||
// emptyStreamReason is resultIsEmpty's streaming twin; see emptyResultReason
|
||||
// for why an empty response is a slot failure rather than a request failure.
|
||||
const emptyStreamReason = emptyResultReason
|
||||
|
||||
// peekStream wraps a chunk channel so the caller learns whether the stream
|
||||
// produced real content BEFORE the chunks are forwarded.
|
||||
//
|
||||
// Why this is necessary: reasoning models emit reasoning_content first. With a
|
||||
// small max_tokens the whole budget is spent thinking, the stream ends with
|
||||
// finish_reason=length and zero content. If the gateway forwarded those chunks
|
||||
// as they arrived, the client would already have seen a 200 SSE stream and
|
||||
// could not be given a different slot — its only recourse is the useless
|
||||
// "returned a completed response with no content" error. Buffering until the
|
||||
// first real content (or the end of the stream) keeps the degrade path
|
||||
// available at the cost of holding back the first few chunks.
|
||||
//
|
||||
// What is NOT buffered: the wrapper starts forwarding as soon as a chunk with
|
||||
// non-empty Content or ToolCalls arrives, and keeps forwarding everything from
|
||||
// then on, so only the reasoning preamble is held. Reasoning-only responses
|
||||
// are dropped in full and reported as empty, which lets chainDrive try the
|
||||
// next slot.
|
||||
func peekStream(in <-chan types.UnifiedChunk) (<-chan types.UnifiedChunk, func() bool) {
|
||||
out := make(chan types.UnifiedChunk, 16)
|
||||
var (
|
||||
mu sync.Mutex
|
||||
sawText bool
|
||||
done bool
|
||||
)
|
||||
go func() {
|
||||
defer close(out)
|
||||
started := false
|
||||
for ck := range in {
|
||||
if !started {
|
||||
// Hold back the reasoning / usage-only preamble. A tool-call
|
||||
// delta counts as content: an agent turn legitimately emits
|
||||
// tool_calls with no text.
|
||||
if strings.TrimSpace(ck.Content) == "" && len(ck.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
started = true
|
||||
mu.Lock()
|
||||
sawText = true
|
||||
mu.Unlock()
|
||||
}
|
||||
out <- ck
|
||||
}
|
||||
mu.Lock()
|
||||
done = true
|
||||
mu.Unlock()
|
||||
}()
|
||||
// peek blocks until the stream either produces content or ends, then
|
||||
// reports whether any content was seen. Polling a 2ms tick rather than
|
||||
// using a second channel keeps peekStream single-goroutine and leak-free.
|
||||
peek := func() bool {
|
||||
for {
|
||||
mu.Lock()
|
||||
seen, finished := sawText, done
|
||||
mu.Unlock()
|
||||
if seen || finished {
|
||||
return seen
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return out, peek
|
||||
}
|
||||
|
||||
func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, req *types.ChatRequest, stream bool) tierResult {
|
||||
n := len(cands)
|
||||
norm := normalCount(cands)
|
||||
@ -267,7 +377,18 @@ func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, r
|
||||
if stream {
|
||||
chunks, err := sl.Prov.ChatStream(ctx, &r)
|
||||
if err == nil {
|
||||
return tierResult{chunks: chunks, src: sl.Source, model: sl.Model}
|
||||
guarded, peek := peekStream(chunks)
|
||||
if peek() {
|
||||
return tierResult{chunks: guarded, src: sl.Source, model: sl.Model}
|
||||
}
|
||||
// The stream finished with no content at all: a
|
||||
// reasoning-only response. Drain and move on to the next
|
||||
// slot instead of pinning the client to a useless stream.
|
||||
hard = append(hard, TierError{
|
||||
Tier: tn.Tier, Source: sl.Source, Model: sl.Model,
|
||||
Err: errors.New(emptyStreamReason),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return tierResult{}
|
||||
@ -280,6 +401,17 @@ func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, r
|
||||
}
|
||||
resp, err := sl.Prov.Chat(ctx, &r)
|
||||
if err == nil {
|
||||
if resultIsEmpty(resp) {
|
||||
// Soft failure: record it and try the next slot. Deliberately
|
||||
// NOT a hard TierError — a hard error is reported to the client
|
||||
// verbatim when the whole chain fails, and "this one model was
|
||||
// unhelpful" is not the client's problem to debug.
|
||||
hard = append(hard, TierError{
|
||||
Tier: tn.Tier, Source: sl.Source, Model: sl.Model,
|
||||
Err: errors.New(emptyResultReason),
|
||||
})
|
||||
continue
|
||||
}
|
||||
return tierResult{resp: resp, src: sl.Source, model: sl.Model}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
@ -293,13 +425,75 @@ func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, r
|
||||
return tierResult{hard: hard}
|
||||
}
|
||||
|
||||
// TraceKind classifies one step of an AUTO chain walk.
|
||||
type TraceKind string
|
||||
|
||||
const (
|
||||
// TraceTierSkip: the whole tier was skipped — every slot was cooling,
|
||||
// quota-exhausted, or none was schedulable. Reason says which.
|
||||
TraceTierSkip TraceKind = "tier_skip"
|
||||
// TraceSlotFail: one slot failed hard (upstream error / bad adapter). The
|
||||
// walk continues to the next slot or tier.
|
||||
TraceSlotFail TraceKind = "slot_fail"
|
||||
// TraceTierBusy: the tier was fully busy and the bounded wait expired.
|
||||
TraceTierBusy TraceKind = "tier_busy"
|
||||
// TraceSelected: this slot served the request. Exactly one per successful
|
||||
// chain walk, and the last event emitted.
|
||||
TraceSelected TraceKind = "selected"
|
||||
)
|
||||
|
||||
// TraceEvent is one observable step of an AUTO chain walk.
|
||||
//
|
||||
// WHY THIS EXISTS: chainDrive's return value is (resp, src, model, err), so a
|
||||
// caller learns only which slot finally served the request. Everything the
|
||||
// scheduler decided on the way there — which tiers it skipped and WHY, which
|
||||
// slots hard-failed, whether a tier was merely busy — was computed and then
|
||||
// discarded. That is invisible to operators and to plugins: "tier 1 was cooling
|
||||
// so we degraded to tier 3" looked exactly like "tier 1 served it".
|
||||
//
|
||||
// The walk already accumulates this in ChainErr, but ONLY on total failure, and
|
||||
// ChainErr is an error return, not a record. Emitting a trace as it happens
|
||||
// covers the far more common case: a request that SUCCEEDED after degrading.
|
||||
//
|
||||
// Design constraints:
|
||||
// - scheduler stays dependency-free and independently testable. A TraceEvent
|
||||
// is a plain struct in this package and the sink is a func parameter, so no
|
||||
// import is added and no test has to change to observe a walk.
|
||||
// - The sink is optional (nil = emit nothing). The overhead on the hot path
|
||||
// is one nil check per event.
|
||||
// - Events are OBSERVATION ONLY. Nothing in the scheduler branches on them,
|
||||
// and the gateway does not feed them back into routing, cooldown or quota —
|
||||
// see docs/plugins.md for why accounting and enforcement are kept apart.
|
||||
type TraceEvent struct {
|
||||
Kind TraceKind
|
||||
Tier int
|
||||
Source string
|
||||
Model string
|
||||
Reason string // human-readable, for TraceTierSkip / TraceSlotFail
|
||||
Err string // the underlying error text, for TraceSlotFail
|
||||
// Attempt counts the 1-based slot attempt within the whole walk.
|
||||
Attempt int
|
||||
}
|
||||
|
||||
// TraceSink receives chain-walk events. It must not block: it is called from the
|
||||
// request path, and a slow sink slows the request.
|
||||
type TraceSink func(TraceEvent)
|
||||
|
||||
// chainDrive runs a request down the chain (plan 2.3): tiers ascending (tier
|
||||
// 1, the highest priority, first), per-tier round-robin starting at the tier
|
||||
// cursor, same-tier runs ordered by preference (negative prefs sink but stay
|
||||
// reachable). Quota-exhausted and cooling slots are filtered up front; a
|
||||
// fully busy tier is polled for a bounded time before falling through.
|
||||
// Failures are summarized in *ChainErr for the caller to map to HTTP 503.
|
||||
func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool, stream bool) (*types.UnifiedResponse, <-chan types.UnifiedChunk, string, string, error) {
|
||||
//
|
||||
// trace may be nil; when set it receives one event per observable step.
|
||||
func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool, stream bool, trace TraceSink) (*types.UnifiedResponse, <-chan types.UnifiedChunk, string, string, error) {
|
||||
emit := func(ev TraceEvent) {
|
||||
if trace != nil {
|
||||
trace(ev)
|
||||
}
|
||||
}
|
||||
attempt := 0
|
||||
if chain == nil || len(chain.Tiers) == 0 {
|
||||
return nil, nil, "", "", fmt.Errorf("no auto slot configured")
|
||||
}
|
||||
@ -309,7 +503,9 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha
|
||||
// dropped unless they qualify as half-cooldown probes (appended last).
|
||||
cands := collectCands(tn.Slots, exhausted)
|
||||
if len(cands) == 0 {
|
||||
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no schedulable slot (cooling or quota exhausted)", tn.Tier))
|
||||
reason := "no schedulable slot (cooling or quota exhausted)"
|
||||
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: %s", tn.Tier, reason))
|
||||
emit(TraceEvent{Kind: TraceTierSkip, Tier: tn.Tier, Reason: reason})
|
||||
continue
|
||||
}
|
||||
// No Pref sort: load balancing is done by round-robin cursor.
|
||||
@ -318,6 +514,8 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha
|
||||
base := tn.NextStart()
|
||||
res := runTier(ctx, tn, cands, base, req, stream)
|
||||
if res.resp != nil || res.chunks != nil {
|
||||
attempt++
|
||||
emit(TraceEvent{Kind: TraceSelected, Tier: tn.Tier, Source: res.src, Model: res.model, Attempt: attempt})
|
||||
releaseProbes(cands)
|
||||
return res.resp, res.chunks, res.src, res.model, nil
|
||||
}
|
||||
@ -327,6 +525,13 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha
|
||||
}
|
||||
if len(res.hard) > 0 {
|
||||
ce.Tiers = append(ce.Tiers, res.hard...)
|
||||
for _, h := range res.hard {
|
||||
attempt++
|
||||
emit(TraceEvent{
|
||||
Kind: TraceSlotFail, Tier: tn.Tier, Source: h.Source, Model: h.Model,
|
||||
Err: types.OneLine(h.Err.Error(), 200), Attempt: attempt,
|
||||
})
|
||||
}
|
||||
releaseProbes(cands)
|
||||
continue // hard failures: fall through to the next tier, no waiting
|
||||
}
|
||||
@ -334,10 +539,13 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha
|
||||
if err := s.pollBusyTier(ctx, tn, cands, base, req, stream, &ce); err != nil {
|
||||
releaseProbes(cands)
|
||||
if r, ok := err.(*tierSuccess); ok {
|
||||
attempt++
|
||||
emit(TraceEvent{Kind: TraceSelected, Tier: tn.Tier, Source: r.res.src, Model: r.res.model, Attempt: attempt})
|
||||
return r.res.resp, r.res.chunks, r.res.src, r.res.model, nil
|
||||
}
|
||||
return nil, nil, "", "", err
|
||||
}
|
||||
emit(TraceEvent{Kind: TraceTierBusy, Tier: tn.Tier, Reason: fmt.Sprintf("no free slot within %v", busyWait)})
|
||||
releaseProbes(cands)
|
||||
}
|
||||
if len(ce.Tiers) == 0 && len(ce.Skipped) == 0 {
|
||||
@ -401,16 +609,16 @@ func (s *Scheduler) pollBusyTier(ctx context.Context, tn *TierNode, cands []cand
|
||||
// non-nil, decides slot token-quota exhaustion. Returns the response, the
|
||||
// serving source and the exact model id used; on total failure a *ChainErr
|
||||
// summarizing every tier.
|
||||
func (s *Scheduler) ChainChat(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool) (*types.UnifiedResponse, string, string, error) {
|
||||
resp, _, src, model, err := s.chainDrive(ctx, chain, req, exhausted, false)
|
||||
func (s *Scheduler) ChainChat(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool, trace TraceSink) (*types.UnifiedResponse, string, string, error) {
|
||||
resp, _, src, model, err := s.chainDrive(ctx, chain, req, exhausted, false, trace)
|
||||
return resp, src, model, err
|
||||
}
|
||||
|
||||
// ChainChatStream runs a streaming AUTO request down the chain. A slot is
|
||||
// abandoned only on connect failures / busy (before its first chunk); after a
|
||||
// stream starts it is pinned. Same return contract as ChainChat.
|
||||
func (s *Scheduler) ChainChatStream(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool) (<-chan types.UnifiedChunk, string, string, error) {
|
||||
_, chunks, src, model, err := s.chainDrive(ctx, chain, req, exhausted, true)
|
||||
func (s *Scheduler) ChainChatStream(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool, trace TraceSink) (<-chan types.UnifiedChunk, string, string, error) {
|
||||
_, chunks, src, model, err := s.chainDrive(ctx, chain, req, exhausted, true, trace)
|
||||
return chunks, src, model, err
|
||||
}
|
||||
|
||||
|
||||
@ -176,7 +176,7 @@ func TestChainRoundRobin(t *testing.T) {
|
||||
s := New(0)
|
||||
var got []string
|
||||
for i := 0; i < 4; i++ {
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("iter %d: %v", i, err)
|
||||
}
|
||||
@ -200,7 +200,7 @@ func TestChainPreferenceSinksButStaysReachable(t *testing.T) {
|
||||
{Tier: 0, Model: "g", Source: "good"},
|
||||
}, bySource(neg, good))
|
||||
s := New(0)
|
||||
resp, src, model, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
resp, src, model, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("chain: %v", err)
|
||||
}
|
||||
@ -213,7 +213,7 @@ func TestChainPreferenceSinksButStaysReachable(t *testing.T) {
|
||||
t.Fatal("neg was tried first and succeeded; good must not be attempted")
|
||||
}
|
||||
// Second request: cursor advances. neg wins again (good hard-fails).
|
||||
resp2, src2, _, err2 := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
resp2, src2, _, err2 := s.ChainChat(context.Background(), ch, chatReq(), nil, nil)
|
||||
if err2 != nil {
|
||||
t.Fatalf("second chain: %v", err2)
|
||||
}
|
||||
@ -230,7 +230,7 @@ func TestChainBusySkipsWithoutPenalty(t *testing.T) {
|
||||
{Tier: 0, Model: "b", Source: "s2"},
|
||||
}, bySource(a, b))
|
||||
s := New(0)
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("chain: %v", err)
|
||||
}
|
||||
@ -256,7 +256,7 @@ func TestChainAllBusyBoundedWaitThenNextTier(t *testing.T) {
|
||||
}, bySource(a, b, c))
|
||||
s := New(0)
|
||||
t0 := time.Now()
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil)
|
||||
el := time.Since(t0)
|
||||
if err != nil {
|
||||
t.Fatalf("chain: %v", err)
|
||||
@ -277,7 +277,7 @@ func TestChainQuotaExhausted(t *testing.T) {
|
||||
}, bySource(a, b))
|
||||
s := New(0)
|
||||
exhausted := func(sl *Slot) bool { return sl.Source == "s1" && sl.Quota > 0 }
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), exhausted)
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), exhausted, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("chain: %v", err)
|
||||
}
|
||||
@ -300,7 +300,7 @@ func TestChainErrSummary(t *testing.T) {
|
||||
{Tier: 1, Model: "c", Source: "s3"},
|
||||
}, bySource(a, b, c))
|
||||
s := New(0)
|
||||
_, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
_, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil)
|
||||
var ce *ChainErr
|
||||
if !errors.As(err, &ce) {
|
||||
t.Fatalf("err = %v, want *ChainErr", err)
|
||||
@ -325,7 +325,7 @@ func TestChainStreamFallsBackBeforeFirstChunk(t *testing.T) {
|
||||
{Tier: 0, Model: "b", Source: "s2"},
|
||||
}, bySource(a, b))
|
||||
s := New(0)
|
||||
chunks, src, model, err := s.ChainChatStream(context.Background(), ch, chatReq(), nil)
|
||||
chunks, src, model, err := s.ChainChatStream(context.Background(), ch, chatReq(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("chain stream: %v", err)
|
||||
}
|
||||
@ -365,7 +365,7 @@ func TestChainProbeIsLastResort(t *testing.T) {
|
||||
}, bySource(healthy, cooling))
|
||||
s := New(0)
|
||||
for i := 0; i < 3; i++ {
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("iter %d: %v", i, err)
|
||||
}
|
||||
@ -390,7 +390,7 @@ func TestChainProbeServesWhenNothingElseCan(t *testing.T) {
|
||||
cooling.probeable.Store(true)
|
||||
ch := BuildChain([]Rule{{Tier: 0, Model: "c", Source: "cooling"}}, bySource(cooling))
|
||||
s := New(0)
|
||||
resp, src, model, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
resp, src, model, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("probe must serve the request: %v", err)
|
||||
}
|
||||
@ -417,14 +417,14 @@ func TestChainProbePermitReleasedOnFailure(t *testing.T) {
|
||||
cooling.fail.Store(true)
|
||||
ch := BuildChain([]Rule{{Tier: 0, Model: "c", Source: "cooling"}}, bySource(cooling))
|
||||
s := New(0)
|
||||
if _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil); err == nil {
|
||||
if _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil); err == nil {
|
||||
t.Fatal("expected the failing probe to surface an error")
|
||||
}
|
||||
if cooling.probeClaims.Load() != 1 || cooling.probeDones.Load() != 1 {
|
||||
t.Fatalf("permit accounting: claims=%d dones=%d, want 1/1", cooling.probeClaims.Load(), cooling.probeDones.Load())
|
||||
}
|
||||
// permit is free again for the next attempt
|
||||
if _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil); err == nil {
|
||||
if _, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil); err == nil {
|
||||
t.Fatal("expected the second probe to fail too")
|
||||
}
|
||||
if cooling.probeClaims.Load() != 2 {
|
||||
@ -443,7 +443,7 @@ func TestChainProbeDoesNotBlockTierFallthrough(t *testing.T) {
|
||||
{Tier: 2, Model: "b", Source: "backup"},
|
||||
}, bySource(cold, backup))
|
||||
s := New(0)
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil)
|
||||
if err != nil || src != "backup" {
|
||||
t.Fatalf("want fallthrough to backup, got src=%q err=%v", src, err)
|
||||
}
|
||||
@ -482,7 +482,7 @@ func TestChainProbeRoundRobinUnaffected(t *testing.T) {
|
||||
s := New(0)
|
||||
var got []string
|
||||
for i := 0; i < 4; i++ {
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("iter %d: %v", i, err)
|
||||
}
|
||||
|
||||
191
internal/scheduler/trace_test.go
Normal file
191
internal/scheduler/trace_test.go
Normal file
@ -0,0 +1,191 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The chain trace is the only way a caller learns that a request was DEGRADED
|
||||
// — served by a lower tier than the one that should have taken it. chainDrive's
|
||||
// return value carries only the winner, so without these events "tier 1 was
|
||||
// cooling and we dropped to tier 2" is indistinguishable from "tier 1 served
|
||||
// it", which is the exact question a priority chain exists to answer.
|
||||
|
||||
// recorder collects trace events for assertions.
|
||||
type recorder struct{ events []TraceEvent }
|
||||
|
||||
func (r *recorder) sink(ev TraceEvent) { r.events = append(r.events, ev) }
|
||||
|
||||
func (r *recorder) kinds() []TraceKind {
|
||||
out := make([]TraceKind, 0, len(r.events))
|
||||
for _, e := range r.events {
|
||||
out = append(out, e.Kind)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *recorder) find(k TraceKind) *TraceEvent {
|
||||
for i := range r.events {
|
||||
if r.events[i].Kind == k {
|
||||
return &r.events[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestTraceSelectedOnlyOnHappyPath: a clean walk emits exactly one event.
|
||||
func TestTraceSelectedOnlyOnHappyPath(t *testing.T) {
|
||||
p1 := fakeProv("p1", "m1")
|
||||
ch := BuildChain([]Rule{{Model: "m1", Source: "p1", Tier: 1}},
|
||||
func(model, source string) Provider { return p1 })
|
||||
var rec recorder
|
||||
_, _, _, err := New(3).ChainChat(context.Background(), ch, chatReq(), nil, rec.sink)
|
||||
if err != nil {
|
||||
t.Fatalf("chain: %v", err)
|
||||
}
|
||||
if got := rec.kinds(); len(got) != 1 || got[0] != TraceSelected {
|
||||
t.Errorf("events = %v, want a single selected", got)
|
||||
}
|
||||
e := rec.find(TraceSelected)
|
||||
if e.Tier != 1 || e.Source != "p1" || e.Model != "m1" {
|
||||
t.Errorf("selected event = %+v, want tier 1 / p1 / m1", e)
|
||||
}
|
||||
if e.Attempt != 1 {
|
||||
t.Errorf("Attempt = %d, want 1", e.Attempt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTraceRecordsTierSkipAndDegradation is the core case: tier 1 is
|
||||
// unschedulable, tier 2 answers. The trace must show the skip AND the eventual
|
||||
// selection, so a consumer can see the request was served one tier down.
|
||||
func TestTraceRecordsTierSkipAndDegradation(t *testing.T) {
|
||||
// p1 is unavailable (not probeable), so tier 1 yields no candidates.
|
||||
p1 := fakeProv("p1", "m1")
|
||||
p1.available.Store(false)
|
||||
p2 := fakeProv("p2", "m2")
|
||||
ch := BuildChain([]Rule{
|
||||
{Model: "m1", Source: "p1", Tier: 1},
|
||||
{Model: "m2", Source: "p2", Tier: 2},
|
||||
}, bySource(p1, p2))
|
||||
var rec recorder
|
||||
_, src, model, err := New(3).ChainChat(context.Background(), ch, chatReq(), nil, rec.sink)
|
||||
if err != nil {
|
||||
t.Fatalf("chain: %v", err)
|
||||
}
|
||||
if src != "p2" || model != "m2" {
|
||||
t.Fatalf("served by %s/%s, want p2/m2", src, model)
|
||||
}
|
||||
skip := rec.find(TraceTierSkip)
|
||||
if skip == nil {
|
||||
t.Fatalf("no tier_skip event; events = %v", rec.kinds())
|
||||
}
|
||||
if skip.Tier != 1 {
|
||||
t.Errorf("skip tier = %d, want 1", skip.Tier)
|
||||
}
|
||||
if !strings.Contains(skip.Reason, "cooling") {
|
||||
t.Errorf("skip reason = %q, want it to mention cooling", skip.Reason)
|
||||
}
|
||||
sel := rec.find(TraceSelected)
|
||||
if sel == nil || sel.Tier != 2 {
|
||||
t.Errorf("selected = %+v, want tier 2", sel)
|
||||
}
|
||||
// The order matters: the skip must be observable BEFORE the selection.
|
||||
if rec.events[0].Kind != TraceTierSkip || rec.events[len(rec.events)-1].Kind != TraceSelected {
|
||||
t.Errorf("event order = %v, want skip first and selected last", rec.kinds())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTraceRecordsHardSlotFailures: a slot that returns an upstream error is a
|
||||
// different event from a skip — the request tried it and it failed. Losing that
|
||||
// distinction makes a flaky upstream look like an idle one.
|
||||
func TestTraceRecordsHardSlotFailures(t *testing.T) {
|
||||
p1 := fakeProv("p1", "m1")
|
||||
p1.fail.Store(true) // Chat returns "upstream error"
|
||||
p2 := fakeProv("p2", "m2")
|
||||
ch := BuildChain([]Rule{
|
||||
{Model: "m1", Source: "p1", Tier: 1},
|
||||
{Model: "m2", Source: "p2", Tier: 2},
|
||||
}, bySource(p1, p2))
|
||||
var rec recorder
|
||||
_, src, _, err := New(3).ChainChat(context.Background(), ch, chatReq(), nil, rec.sink)
|
||||
if err != nil {
|
||||
t.Fatalf("chain: %v", err)
|
||||
}
|
||||
if src != "p2" {
|
||||
t.Fatalf("served by %s, want p2", src)
|
||||
}
|
||||
fail := rec.find(TraceSlotFail)
|
||||
if fail == nil {
|
||||
t.Fatalf("no slot_fail event; events = %v", rec.kinds())
|
||||
}
|
||||
if fail.Tier != 1 || fail.Source != "p1" || fail.Model != "m1" {
|
||||
t.Errorf("slot_fail = %+v, want tier 1 / p1 / m1", fail)
|
||||
}
|
||||
if !strings.Contains(fail.Err, "upstream error") {
|
||||
t.Errorf("slot_fail error = %q, want the upstream text", fail.Err)
|
||||
}
|
||||
// A hard failure must NOT be reported as a skip.
|
||||
if rec.find(TraceTierSkip) != nil {
|
||||
t.Error("a hard failure was also reported as a tier_skip")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTraceNilSinkIsSafe: the gateway passes nil when no plugin is loaded, so
|
||||
// every emit path must tolerate it. This is the "plugins are optional" property
|
||||
// on the scheduler side.
|
||||
func TestTraceNilSinkIsSafe(t *testing.T) {
|
||||
p1 := fakeProv("p1", "m1")
|
||||
p1.fail.Store(true)
|
||||
p2 := fakeProv("p2", "m2")
|
||||
ch := BuildChain([]Rule{
|
||||
{Model: "m1", Source: "p1", Tier: 1},
|
||||
{Model: "m2", Source: "p2", Tier: 2},
|
||||
}, bySource(p1, p2))
|
||||
if _, _, _, err := New(3).ChainChat(context.Background(), ch, chatReq(), nil, nil); err != nil {
|
||||
t.Fatalf("a nil trace sink broke the walk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTraceOnTotalFailure: when every tier fails, the walk still emits its
|
||||
// per-step events AND returns the ChainErr. The trace is additive — it must not
|
||||
// replace or disturb the error contract callers depend on for the 503.
|
||||
func TestTraceOnTotalFailure(t *testing.T) {
|
||||
p1 := fakeProv("p1", "m1")
|
||||
p1.fail.Store(true)
|
||||
p2 := fakeProv("p2", "m2")
|
||||
p2.fail.Store(true)
|
||||
ch := BuildChain([]Rule{
|
||||
{Model: "m1", Source: "p1", Tier: 1},
|
||||
{Model: "m2", Source: "p2", Tier: 2},
|
||||
}, bySource(p1, p2))
|
||||
var rec recorder
|
||||
_, _, _, err := New(3).ChainChat(context.Background(), ch, chatReq(), nil, rec.sink)
|
||||
var ce *ChainErr
|
||||
if !errors.As(err, &ce) {
|
||||
t.Fatalf("err = %v, want a *ChainErr so the gateway can answer 503", err)
|
||||
}
|
||||
if len(ce.Tiers) != 2 {
|
||||
t.Errorf("ChainErr.Tiers = %d, want 2 (the error contract must be unchanged)", len(ce.Tiers))
|
||||
}
|
||||
if n := len(rec.kinds()); n != 2 {
|
||||
t.Errorf("events = %v, want two slot_fail and no selection", rec.kinds())
|
||||
}
|
||||
if rec.find(TraceSelected) != nil {
|
||||
t.Error("a selected event was emitted for a walk that served nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTraceSkipsEmptyChain: no chain configured must not emit anything; the
|
||||
// gateway answers 503 before scheduling in that case anyway.
|
||||
func TestTraceSkipsEmptyChain(t *testing.T) {
|
||||
var rec recorder
|
||||
_, _, _, err := New(3).ChainChat(context.Background(), &Chain{}, chatReq(), nil, rec.sink)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an empty chain")
|
||||
}
|
||||
if len(rec.events) != 0 {
|
||||
t.Errorf("events = %v, want none", rec.kinds())
|
||||
}
|
||||
}
|
||||
@ -13,13 +13,26 @@ listen: 127.0.0.1:8080
|
||||
# 客户端访问本网关所需的 API Key(Bearer)。留空数组 = 不鉴权(仅内网)。
|
||||
gateway_keys: []
|
||||
|
||||
# 默认模型选择:具体模型 id 或 AUTO(按各源模型的 priority 自动选最高可用源)
|
||||
# 默认模型选择:具体模型 id 或 AUTO。
|
||||
# AUTO = 走 WebUI「优先级页」保存的调度链(存在本文件的 `auto:` 字段)。
|
||||
default_model: AUTO
|
||||
|
||||
# Lua 适配器目录(默认 adapters/,首次启动自动写入内置适配器)
|
||||
adapter_dir: adapters
|
||||
|
||||
# 运行时持久化文件(WebUI 新增/编辑的源会写入此文件,重启后仍生效)
|
||||
# 插件目录(可选)。设置后:
|
||||
# - 首次启动会把随核心发布的示例插件(billing:按源/模型/密钥计费 + 仪表盘)
|
||||
# 写入本目录,它会在请求流水线上挂钩子,并向 WebUI 注入一个「Billing」页面
|
||||
# 和状态页上的一块总开销组件;
|
||||
# - 之后 WebUI「插件」页与 Electron 壳的设置面板可安装/禁用/删除/编辑。
|
||||
# 目录一旦存在即以目录为准:删除或改写内置插件都是真实生效的操作。
|
||||
# 留空 = 插件功能完全关闭(不影响网关其它功能)。
|
||||
# plugin_dir: /etc/llmsproxy/plugins
|
||||
|
||||
# 运行时文件:存放 WebUI 管理的源模板、已删除标记、预置模板名单。
|
||||
#
|
||||
# 注意:**AUTO 调度链、网关密钥、上游源都存在本 config.yaml 里**,
|
||||
# 不在这个文件。runtime.json 只管模板与删除标记。
|
||||
runtime_file: runtime.json
|
||||
|
||||
# 全局并发上限(0 = 不限)
|
||||
|
||||
@ -10,15 +10,53 @@ Type=simple
|
||||
# OS thread that touches malloc reserved its own ~1 MB arena that is never
|
||||
# returned. Measured: 8-12 arenas -> 0.
|
||||
# GOGC=50 halves the Go heap growth target. On its own it does NOT help (the
|
||||
# saved heap is immediately eaten by more glibc arenas); combined with
|
||||
# saved heap is immediately eaten by extra glibc arenas); combined with
|
||||
# MALLOC_ARENA_MAX it cut settled RSS by ~19%. This gateway is I/O bound, so
|
||||
# the extra GC cycles are free.
|
||||
Environment=GOGC=50
|
||||
Environment=MALLOC_ARENA_MAX=2
|
||||
ExecStart=/usr/bin/llmsproxy -config /etc/llmsproxy/config.yaml
|
||||
ExecStart=/usr/local/bin/llmsproxy -config /etc/llmsproxy/config.yaml
|
||||
WorkingDirectory=/etc/llmsproxy
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# ---- 加固(2026-10-01 逐条实测后加入,不是照抄文档)----
|
||||
#
|
||||
# 为什么仍然以 root 运行:master.key 是 0600 root。加 User=llmsproxy 实测直接
|
||||
# 起不来,而且失败方式很隐蔽——
|
||||
# [config] secrets disabled: open /etc/llmsproxy/master.key: permission denied
|
||||
# 只是**一行日志**,服务会带着"敏感值将以明文落盘"继续跑起来。
|
||||
# 也就是说降权在当前文件权限下不是加固,而是把密钥降级。要降权必须先把
|
||||
# master.key 交给服务用户并统一 /etc/llmsproxy 的属主,那是一次独立的、有回滚
|
||||
# 需求的变更,不该和加固混在一起。
|
||||
#
|
||||
# 下面每一条都在一个独立探针单元(临时端口 + 独立 runtime_file/adapter_dir)
|
||||
# 上真实验证过:鉴权 401/200 正常、发一次真实 /v1/chat/completions 走通(证明
|
||||
# LuaJIT 适配器路径没被 seccomp 打断)、审计文件可写可轮转、连续重启 3 次与
|
||||
# kill -9 后行为符合预期。systemd 对非法指令值不报错只"忽略",逐条实测是唯一
|
||||
# 可靠做法。
|
||||
NoNewPrivileges=yes
|
||||
# 读路径全部落在 /etc/llmsproxy;写路径经核对只有 config.yaml / runtime.json /
|
||||
# audit.jsonl / adapters/*.lua / master.key,全在该目录下(internal/{config,gateway,
|
||||
# core,lua} 里的 WriteFile|Rename|Remove 调用点)。
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/etc/llmsproxy
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
LockPersonality=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
# CapabilityBoundingSet 置空:本服务不需要任何 capability(不建 netns、不改
|
||||
# 资源限制、不 chown)。留空即"一个都不给",比列一份允许清单更难写错。
|
||||
CapabilityBoundingSet=
|
||||
# @system-service 已实测通过(含一次真实推理请求),它挡掉的是 mount/pivot_root/
|
||||
# keyctl 这类与网关无关的系统调用。
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallArchitectures=native
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user