mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-08 10:17:21 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28149bdd1a | |||
| ed9baae8e4 | |||
| 13e2397e9c | |||
| 0cd1cd51f6 | |||
| 9dc2b64013 | |||
| c66e1b5955 | |||
| 22decf2bd3 | |||
| 4b07be67b4 | |||
| 913974bb6c | |||
| ffb2b8f2f2 | |||
| 1fe379f630 | |||
| b03b12148f | |||
| a1d4365515 | |||
| 3548745f98 | |||
| 3fe0df5bf2 | |||
| 8ca59b4bb2 | |||
| 980408d045 | |||
| 4b37e1a0db | |||
| ebe60028f5 |
4
cmd/gui/package-lock.json
generated
4
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
@ -226,6 +226,7 @@ async function loadPlugins() {
|
||||
return;
|
||||
}
|
||||
const rows = j.on_disk || [];
|
||||
window.__plRows = rows; // read by editPlugin/deletePlugin for the builtin flag
|
||||
const active = rows.filter((p) => p.loaded && !p.disabled).length;
|
||||
const broken = rows.filter((p) => !p.loaded).length;
|
||||
hint.textContent =
|
||||
@ -250,11 +251,30 @@ async function loadPlugins() {
|
||||
const builtin = p.builtin
|
||||
? '<span class="pl-builtin">内置</span>'
|
||||
: "";
|
||||
// Edit / view source / delete are the operations an operator
|
||||
// actually needs after enable/disable: a plugin that cannot be
|
||||
// read back cannot be fixed, and one that cannot be removed cannot
|
||||
// be rolled back.
|
||||
const acts = [
|
||||
btn,
|
||||
`<button class="ghost" data-act="code" data-name="${escAttr(p.name)}">${
|
||||
p.builtin ? "查看源码" : "编辑"
|
||||
}</button>`,
|
||||
builtin
|
||||
? ""
|
||||
: `<button class="ghost danger" data-act="del" data-name="${escAttr(
|
||||
p.name,
|
||||
)}">删除</button>`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
const pages = Array.isArray(p.pages) ? p.pages : [];
|
||||
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>` : ""}
|
||||
${pages.length ? `<div class="pl-desc">页面:${esc(pages.join(", "))}</div>` : ""}
|
||||
${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""}
|
||||
<div class="pl-acts">${btn}</div>
|
||||
<div class="pl-acts">${acts}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("")
|
||||
@ -262,6 +282,160 @@ async function loadPlugins() {
|
||||
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
|
||||
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
|
||||
});
|
||||
list.querySelectorAll('button[data-act="code"]').forEach((b) => {
|
||||
b.onclick = () => editPlugin(b.dataset.name);
|
||||
});
|
||||
list.querySelectorAll('button[data-act="del"]').forEach((b) => {
|
||||
b.onclick = () => deletePlugin(b.dataset.name);
|
||||
});
|
||||
}
|
||||
|
||||
// editPlugin shows the plugin source in a modal. Built-in plugins are
|
||||
// read-only: their file ships inside the binary, so a save would either be
|
||||
// ignored or silently do nothing — showing an editable text area that
|
||||
// discards on save is worse than saying so up front.
|
||||
function editPlugin(name) {
|
||||
// The plugin list is the only place `builtin` is reported; keep the last
|
||||
// fetch so the modal does not need a second round trip (and cannot guess).
|
||||
const row = (window.__plRows || []).find((p) => p.name === name);
|
||||
const isBuiltin = !!(row && row.builtin);
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "overlay";
|
||||
wrap.innerHTML = `<div class="overlay-card pl-wide">
|
||||
<div class="overlay-head"><h2>${esc(name)}${isBuiltin ? "(内置,只读)" : ""}</h2></div>
|
||||
${isBuiltin
|
||||
? `<p class="hint">内置插件随内核发布,源码在二进制内,不可编辑。</p>`
|
||||
: `<textarea id="pl-code" spellcheck="false"></textarea>
|
||||
<p class="hint" id="pl-code-err"></p>`}
|
||||
<div class="row actions">
|
||||
${isBuiltin ? "" : `<button id="pl-code-save">保存并热重载</button>`}
|
||||
<button class="ghost" id="pl-code-close">关闭</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
wrap.addEventListener("click", (e) => {
|
||||
if (e.target === wrap) wrap.remove();
|
||||
});
|
||||
$("#pl-code-close").onclick = () => wrap.remove();
|
||||
const save = $("#pl-code-save");
|
||||
if (save) {
|
||||
save.onclick = async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
// Saving writes through POST /api/plugins — the same install path the
|
||||
// installer uses, which validates the name, compiles the source and
|
||||
// hot-reloads. PUT /{name} only toggles enable/disable, so pointing the
|
||||
// save there would silently disable nothing and store nothing.
|
||||
await window.modelrouter.plugins.request("POST", "/api/plugins", {
|
||||
name,
|
||||
code: $("#pl-code").value,
|
||||
});
|
||||
toast(`${name} 已保存`);
|
||||
wrap.remove();
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
$("#pl-code-err").textContent = e.message;
|
||||
save.disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
if (isBuiltin) return;
|
||||
(async () => {
|
||||
const ta = $("#pl-code");
|
||||
try {
|
||||
const j = await window.modelrouter.plugins.request(
|
||||
"GET",
|
||||
`/api/plugins/${encodeURIComponent(name)}`,
|
||||
);
|
||||
ta.value = j.code || "";
|
||||
const err = $("#pl-code-err");
|
||||
if (err && j.parse_error) err.textContent = String(j.parse_error);
|
||||
} catch (e) {
|
||||
$("#pl-code-err").textContent = e.message;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function deletePlugin(name) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "overlay";
|
||||
wrap.innerHTML = `<div class="overlay-card">
|
||||
<div class="overlay-head"><h2>删除插件</h2></div>
|
||||
<p>确定删除 <b>${esc(name)}</b>?插件目录中的文件会被移除,核心将不再加载它。</p>
|
||||
<div class="row actions">
|
||||
<button class="danger" id="pl-del-ok">删除</button>
|
||||
<button class="ghost" id="pl-del-no">取消</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
wrap.addEventListener("click", (e) => {
|
||||
if (e.target === wrap) wrap.remove();
|
||||
});
|
||||
$("#pl-del-no").onclick = () => wrap.remove();
|
||||
$("#pl-del-ok").onclick = async () => {
|
||||
try {
|
||||
await window.modelrouter.plugins.request(
|
||||
"DELETE",
|
||||
`/api/plugins/${encodeURIComponent(name)}`,
|
||||
);
|
||||
toast(`已删除 ${name}`);
|
||||
wrap.remove();
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// installPlugin creates a new plugin from source. The name becomes the file
|
||||
// name, so it is validated client-side to save a round trip on the obvious
|
||||
// mistakes (empty, path separators, non-portable characters).
|
||||
function installPlugin() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "overlay";
|
||||
wrap.innerHTML = `<div class="overlay-card pl-wide">
|
||||
<div class="overlay-head"><h2>安装插件</h2></div>
|
||||
<div class="row"><label>名称</label><input id="pl-new-name" placeholder="my_plugin" /></div>
|
||||
<p class="hint" id="pl-new-err"></p>
|
||||
<textarea id="pl-new-code" spellcheck="false" placeholder="-- 单文件 Lua 插件 plugin = {} ..."></textarea>
|
||||
<div class="row actions">
|
||||
<button id="pl-new-ok">安装</button>
|
||||
<button class="ghost" id="pl-new-no">取消</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
wrap.addEventListener("click", (e) => {
|
||||
if (e.target === wrap) wrap.remove();
|
||||
});
|
||||
$("#pl-new-no").onclick = () => wrap.remove();
|
||||
$("#pl-new-ok").onclick = async () => {
|
||||
const name = $("#pl-new-name").value.trim();
|
||||
const code = $("#pl-new-code").value;
|
||||
const err = $("#pl-new-err");
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
|
||||
err.textContent = "名称只能包含字母、数字、下划线和连字符";
|
||||
return;
|
||||
}
|
||||
if (!code.trim()) {
|
||||
err.textContent = "源码不能为空";
|
||||
return;
|
||||
}
|
||||
$("#pl-new-ok").disabled = true;
|
||||
try {
|
||||
await window.modelrouter.plugins.request("POST", "/api/plugins", {
|
||||
// The backend appends .lua itself (installPlugin writes name+".lua"),
|
||||
// so passing it here would create name.lua.lua.
|
||||
name,
|
||||
code,
|
||||
});
|
||||
toast(`已安装 ${name}`);
|
||||
wrap.remove();
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
$("#pl-new-ok").disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function togglePlugin(name, disabled) {
|
||||
@ -318,6 +492,8 @@ function init() {
|
||||
if (plReload) plReload.onclick = loadPlugins;
|
||||
const plAll = document.getElementById("pl-toggle-all");
|
||||
if (plAll) plAll.onclick = enableAllPlugins;
|
||||
const plInstall = document.getElementById("pl-install");
|
||||
if (plInstall) plInstall.onclick = installPlugin;
|
||||
$("#rail-autostart").onclick = toggleAutoStart;
|
||||
$("#rail-silent").onclick = toggleSilent;
|
||||
$("#rail-theme").onclick = () => {
|
||||
|
||||
@ -214,6 +214,7 @@
|
||||
<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-install">安装插件</button>
|
||||
<button class="ghost" id="pl-reload">刷新</button>
|
||||
</div>
|
||||
<div class="row actions">
|
||||
|
||||
@ -674,3 +674,51 @@ html[data-theme="dark"] .overlay {
|
||||
color: var(--muted);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
/* ---- plugin editor / installer --------------------------------------------
|
||||
The plugin source editor and the install dialog reuse the settings overlay
|
||||
(overlay / overlay-card) rather than a second popup system. They need three
|
||||
things the settings card does not: a wider column for code, a monospace
|
||||
textarea, and a destructive button for delete. */
|
||||
.overlay-card.pl-wide {
|
||||
width: 760px;
|
||||
}
|
||||
.pl-wide textarea {
|
||||
width: 100%;
|
||||
min-height: 380px;
|
||||
resize: vertical;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
|
||||
color: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
tab-size: 2;
|
||||
}
|
||||
.pl-wide textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.pl-wide .row input {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
|
||||
color: inherit;
|
||||
}
|
||||
.pl-editor .hint,
|
||||
.pl-wide .hint {
|
||||
min-height: 16px;
|
||||
color: var(--muted, #888);
|
||||
}
|
||||
button.danger {
|
||||
color: #e5484d;
|
||||
border-color: rgba(229, 72, 77, 0.4);
|
||||
}
|
||||
button.danger:hover {
|
||||
border-color: #e5484d;
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
@ -384,15 +384,25 @@ type SourceTemplate struct {
|
||||
// (full management) or "user" (sees only its own key); Models is the allowed
|
||||
// model scope with per-model token quota (0 = unlimited).
|
||||
type GWKey struct {
|
||||
Key string `yaml:"key" json:"key"`
|
||||
Role string `yaml:"role" json:"role"`
|
||||
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||
Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"`
|
||||
Note string `yaml:"note,omitempty" json:"note,omitempty"`
|
||||
CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"`
|
||||
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
|
||||
Key string `yaml:"key" json:"key"`
|
||||
Role string `yaml:"role" json:"role"`
|
||||
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||
// Auto is this key's own AUTO scheduling chain. It overrides the global
|
||||
// cfg.Auto for requests authenticated with this key; nil (or an empty
|
||||
// list) means "inherit the global chain", so keys created before per-key
|
||||
// chains existed keep working untouched.
|
||||
Auto []ModelScope `yaml:"auto,omitempty" json:"auto,omitempty"`
|
||||
Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"`
|
||||
Note string `yaml:"note,omitempty" json:"note,omitempty"`
|
||||
CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"`
|
||||
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
|
||||
}
|
||||
|
||||
// HasOwnAuto reports whether the key declares its own AUTO chain rather than
|
||||
// inheriting the global one. An explicitly empty list still counts as "no own
|
||||
// chain" so a user can clear an override back to the global default.
|
||||
func (k GWKey) HasOwnAuto() bool { return len(k.Auto) > 0 }
|
||||
|
||||
// BillingDSL holds declarative per-URL pricing profiles for the billing plugin.
|
||||
//
|
||||
// Profiles are the "let the user pick" axis: the same upstream URL can be
|
||||
|
||||
@ -37,6 +37,12 @@ type Core struct {
|
||||
registry *provider.Registry
|
||||
autoChain atomic.Pointer[scheduler.Chain] // chat AUTO chain
|
||||
autoImageChain atomic.Pointer[scheduler.Chain] // image-generation AUTO chain
|
||||
|
||||
// keyAutoChains caches per-key AUTO chains, keyed by the gateway key.
|
||||
// Entries are rebuilt by rebuildKeyAutoChains whenever sources or key
|
||||
// config change; AutoChainFor reads them without taking c.mu so request
|
||||
// handling stays lock-free like the global chain lookup.
|
||||
keyAutoChains atomic.Pointer[map[string]*scheduler.Chain]
|
||||
}
|
||||
|
||||
// New builds the core from a config file plus runtime overlay.
|
||||
@ -257,6 +263,68 @@ func (c *Core) Registry() *provider.Registry { return c.registry }
|
||||
// AutoChain returns the current AUTO scheduling chain.
|
||||
func (c *Core) AutoChain() *scheduler.Chain { return c.autoChain.Load() }
|
||||
|
||||
// AutoChainFor returns the AUTO chain to use for a request authenticated with
|
||||
// key. A key that declares its own chain gets that chain; every other key
|
||||
// inherits the global chain, so keys created before per-key chains existed
|
||||
// keep their current behaviour with no configuration change.
|
||||
//
|
||||
// It returns (chain, ok). ok is false only when the key has its own chain but
|
||||
// it compiled to nothing usable (e.g. every slot names a model that no longer
|
||||
// exists) — the caller must surface that rather than silently downgrading the
|
||||
// user to the global chain, which would be a confusing, invisible switch.
|
||||
func (c *Core) AutoChainFor(key string) (*scheduler.Chain, bool) {
|
||||
if m := c.keyAutoChains.Load(); m != nil {
|
||||
if ch, ok := (*m)[key]; ok {
|
||||
return ch, true
|
||||
}
|
||||
}
|
||||
return c.autoChain.Load(), true
|
||||
}
|
||||
|
||||
// rebuildKeyAutoChains recompiles every per-key AUTO chain from the current
|
||||
// config and provider registry, then swaps the cache in one shot so requests
|
||||
// never observe a half-built map. Keys without their own chain are absent
|
||||
// from the map and fall back to the global chain.
|
||||
func (c *Core) rebuildKeyAutoChains() {
|
||||
m := make(map[string]*scheduler.Chain)
|
||||
for _, k := range c.cfg.Keys {
|
||||
if !k.HasOwnAuto() {
|
||||
continue
|
||||
}
|
||||
m[k.Key] = c.chainForRules(k.Auto, false)
|
||||
}
|
||||
c.keyAutoChains.Store(&m)
|
||||
}
|
||||
|
||||
// SaveKeyAuto persists one key's own AUTO chain and recompiles the per-key
|
||||
// chain cache. Passing an empty list clears the override so the key inherits
|
||||
// the global chain again.
|
||||
func (c *Core) SaveKeyAuto(key string, entries []config.ModelScope) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
idx := -1
|
||||
for i, k := range c.cfg.Keys {
|
||||
if k.Key == key {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("key not found")
|
||||
}
|
||||
if err := ValidateScopeQuotas(entries); err != nil {
|
||||
return err
|
||||
}
|
||||
c.cfg.Keys[idx].Auto = cleanScopes(entries)
|
||||
if err := c.saveConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Rebuild the whole cache so a cleared override stops shadowing the
|
||||
// global chain for this key.
|
||||
c.rebuildKeyAutoChains()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoImageChain returns the persisted image-generation AUTO chain snapshot.
|
||||
func (c *Core) AutoImageChain() *scheduler.Chain { return c.autoImageChain.Load() }
|
||||
|
||||
@ -546,6 +614,7 @@ func (c *Core) rebuildRegistry() error {
|
||||
}
|
||||
c.buildAutoChain()
|
||||
c.buildAutoImageChain()
|
||||
c.rebuildKeyAutoChains()
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -555,17 +624,28 @@ func (c *Core) rebuildRegistry() error {
|
||||
// back to the source's best chat model and cooldown/quota bookkeeping would
|
||||
// key on a name that never matches.
|
||||
func (c *Core) buildAutoChain() {
|
||||
c.autoChain.Store(c.chainForRules(c.cfg.Auto, false))
|
||||
}
|
||||
|
||||
// chainForRules compiles a chat AUTO chain from a rule list. kindFilter is
|
||||
// false for the chat chain (image models are skipped) and true for the image
|
||||
// chain (only image models participate). Per-key chains always compile as
|
||||
// chat chains: a user's key configures the models *they* talk to.
|
||||
func (c *Core) chainForRules(rules []config.ModelScope, imageOnly bool) *scheduler.Chain {
|
||||
prov := func(model, source string) scheduler.Provider {
|
||||
p := c.registry.ProviderForSlot(model, source)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
|
||||
m := p.ModelByID(model)
|
||||
if imageOnly && (m == nil || m.Kind != "image") {
|
||||
return nil
|
||||
}
|
||||
if !imageOnly && m != nil && m.Kind == "image" {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
rules := c.cfg.Auto
|
||||
sr := make([]scheduler.Rule, 0, len(rules))
|
||||
for _, e := range rules {
|
||||
model, source := e.Model, e.Source
|
||||
@ -574,7 +654,11 @@ func (c *Core) buildAutoChain() {
|
||||
model = exact
|
||||
}
|
||||
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
|
||||
continue // image-kind models never join the chat AUTO chain
|
||||
if !imageOnly {
|
||||
continue // image-kind models never join the chat AUTO chain
|
||||
}
|
||||
} else if imageOnly {
|
||||
continue
|
||||
}
|
||||
if source == "" {
|
||||
source = p.Name()
|
||||
@ -589,7 +673,7 @@ func (c *Core) buildAutoChain() {
|
||||
Hours: e.Hours,
|
||||
})
|
||||
}
|
||||
c.autoChain.Store(scheduler.BuildChain(sr, prov))
|
||||
return scheduler.BuildChain(sr, prov)
|
||||
}
|
||||
|
||||
// buildAutoImageChain rebuilds the image-generation AUTO chain snapshot.
|
||||
|
||||
189
internal/core/key_auto_concurrency_test.go
Normal file
189
internal/core/key_auto_concurrency_test.go
Normal file
@ -0,0 +1,189 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// AutoChainFor sits on the request hot path and reads keyAutoChains with no
|
||||
// lock, while SaveKeyAuto / rebuildRegistry replace that map from the admin
|
||||
// path. A "call it twice" test never interleaves those, so it would pass even
|
||||
// if the map were mutated in place. These tests run both sides at once under
|
||||
// -race and assert what readers actually observe.
|
||||
//
|
||||
// The property: a reader sees a chain that is either the key's own or the
|
||||
// global one — never a torn or empty one. If rebuildKeyAutoChains built the
|
||||
// map in place instead of swapping a fresh pointer, readers would sometimes
|
||||
// see a partially-filled map and silently fall back to the global chain, i.e.
|
||||
// a user quietly losing their configured chain under load.
|
||||
|
||||
// concurrentChainConfig builds N keys, half with their own chain pointing at a
|
||||
// distinct model so a fallback to the global chain is distinguishable.
|
||||
func concurrentChainConfig(t *testing.T, n int) *config.Config {
|
||||
t.Helper()
|
||||
cfg := perKeyTestConfig(t)
|
||||
cfg.Keys = nil
|
||||
cfg.GatewayKeys = []string{"sk-gw-admin"}
|
||||
cfg.Auto = []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}}
|
||||
for i := 0; i < n; i++ {
|
||||
k := config.GWKey{
|
||||
Key: fmt.Sprintf("sk-gw-k%03d", i),
|
||||
Role: "user",
|
||||
Name: fmt.Sprintf("user%d", i),
|
||||
}
|
||||
if i%2 == 0 {
|
||||
k.Auto = []config.ModelScope{{Model: "gpt-4o-mini", Source: "s1", Tier: 1}}
|
||||
}
|
||||
cfg.Keys = append(cfg.Keys, k)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestAutoChainForConcurrentWithSaveKeyAuto(t *testing.T) {
|
||||
cfg := concurrentChainConfig(t, 40)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
var stop atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
var bad atomic.Int64
|
||||
|
||||
for r := 0; r < 8; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for !stop.Load() {
|
||||
for i := 0; i < 40; i++ {
|
||||
key := fmt.Sprintf("sk-gw-k%03d", i)
|
||||
ch, ok := c.AutoChainFor(key)
|
||||
if !ok || ch == nil || len(ch.Tiers) == 0 {
|
||||
bad.Add(1)
|
||||
continue
|
||||
}
|
||||
// Legal values are this key's own chain model (either
|
||||
// side of the writer's flip) or the global chain's. The
|
||||
// assertion is about the legal set, not a fixed value:
|
||||
// the writers legitimately change the chain underneath.
|
||||
got := ch.Tiers[0].Slots[0].Model
|
||||
if got != "gpt-4o" && got != "gpt-4o-mini" {
|
||||
bad.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for w := 0; w < 2; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
model := "gpt-4o-mini"
|
||||
for i := 0; i < 25 && !stop.Load(); i++ {
|
||||
for j := 0; j < 40; j += 2 {
|
||||
if err := c.SaveKeyAuto(fmt.Sprintf("sk-gw-k%03d", j), []config.ModelScope{
|
||||
{Model: model, Source: "s1", Tier: 1},
|
||||
}); err != nil {
|
||||
bad.Add(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
if model == "gpt-4o-mini" {
|
||||
model = "gpt-4o"
|
||||
} else {
|
||||
model = "gpt-4o-mini"
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
stop.Store(true)
|
||||
wg.Wait()
|
||||
|
||||
if n := bad.Load(); n > 0 {
|
||||
t.Fatalf("%d reads saw a chain outside {own, global} — the cache is "+
|
||||
"being swapped in place instead of atomically", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The registry rebuild path (AddSource / RemoveSource) also replaces the
|
||||
// cache, so readers must keep a usable chain throughout.
|
||||
//
|
||||
// Churned sources use a "churn" prefix on purpose: deleting a source a chain
|
||||
// references legitimately empties that chain (BuildChain drops unresolvable
|
||||
// slots), and asserting otherwise would be testing a fiction rather than the
|
||||
// code.
|
||||
func TestAutoChainForConcurrentWithSourceChanges(t *testing.T) {
|
||||
cfg := concurrentChainConfig(t, 20)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
var stop atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
var bad atomic.Int64
|
||||
|
||||
for r := 0; r < 6; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for !stop.Load() {
|
||||
for i := 0; i < 20; i++ {
|
||||
ch, ok := c.AutoChainFor(fmt.Sprintf("sk-gw-k%03d", i))
|
||||
if !ok || ch == nil || len(ch.Tiers) == 0 {
|
||||
bad.Add(1)
|
||||
continue
|
||||
}
|
||||
if got := ch.Tiers[0].Slots[0].Model; got != "gpt-4o" && got != "gpt-4o-mini" {
|
||||
bad.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for n := 0; n < 30 && !stop.Load(); n++ {
|
||||
name := fmt.Sprintf("churn%d", n)
|
||||
_ = c.AddSource(config.Source{
|
||||
Name: name, BaseURL: "http://127.0.0.1:9/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m-" + name, Priority: 10}}},
|
||||
)
|
||||
_ = c.RemoveSource(name)
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
stop.Store(true)
|
||||
wg.Wait()
|
||||
|
||||
if n := bad.Load(); n > 0 {
|
||||
t.Fatalf("%d reads lost the chain while unrelated sources were churned", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Many distinct keys: the lookup must stay a map hit and must not degrade into
|
||||
// returning the global chain as the key count grows.
|
||||
func TestAutoChainForManyKeys(t *testing.T) {
|
||||
cfg := concurrentChainConfig(t, 500)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
for i := 0; i < 500; i++ {
|
||||
key := fmt.Sprintf("sk-gw-k%03d", i)
|
||||
ch, _ := c.AutoChainFor(key)
|
||||
if ch == nil || len(ch.Tiers) == 0 {
|
||||
t.Fatalf("key %s lost its chain with 500 keys configured", key)
|
||||
}
|
||||
want := "gpt-4o"
|
||||
if i%2 == 0 {
|
||||
want = "gpt-4o-mini"
|
||||
}
|
||||
if got := ch.Tiers[0].Slots[0].Model; got != want {
|
||||
t.Fatalf("key %s got %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
221
internal/core/key_auto_test.go
Normal file
221
internal/core/key_auto_test.go
Normal file
@ -0,0 +1,221 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/scheduler"
|
||||
)
|
||||
|
||||
// Per-key AUTO chains let an admin give one user their own scheduling chain
|
||||
// while every other key keeps the global one. These tests pin the three
|
||||
// properties that make that safe:
|
||||
//
|
||||
// 1. a key WITH its own chain gets it;
|
||||
// 2. a key WITHOUT one inherits the global chain (backward compatible);
|
||||
// 3. the override is actually persisted, so it survives a restart.
|
||||
//
|
||||
// A test that only checked (1) would pass even if AutoChainFor ignored the key
|
||||
// and always returned the global chain whenever the two happened to be equal,
|
||||
// so (2) uses deliberately *different* chains.
|
||||
|
||||
func perKeyTestConfig(t *testing.T) *config.Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
return &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
Listen: "127.0.0.1:0",
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-gw-admin"},
|
||||
Sources: []config.Source{
|
||||
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
|
||||
},
|
||||
// Global chain points at gpt-4o.
|
||||
Auto: []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}},
|
||||
Keys: []config.GWKey{
|
||||
{Key: "sk-gw-admin", Role: "admin"},
|
||||
{Key: "sk-gw-inherits", Role: "user", Name: "inherits"},
|
||||
// This key's own chain points at a DIFFERENT model, so returning
|
||||
// the global chain by mistake is detectable.
|
||||
{Key: "sk-gw-own", Role: "user", Name: "own",
|
||||
Auto: []config.ModelScope{{Model: "gpt-4o-mini", Source: "s1", Tier: 1}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func firstSlotModel(t *testing.T, c *Core, key string) string {
|
||||
t.Helper()
|
||||
ch, _ := c.AutoChainFor(key)
|
||||
if ch == nil || len(ch.Tiers) == 0 {
|
||||
t.Fatalf("key %s: no chain tiers", key)
|
||||
}
|
||||
slots := ch.Tiers[0].Slots
|
||||
if len(slots) == 0 {
|
||||
t.Fatalf("key %s: tier 1 has no slots", key)
|
||||
}
|
||||
return slots[0].Model
|
||||
}
|
||||
|
||||
// A key that declares its own chain must be scheduled by it, not the global.
|
||||
func TestAutoChainForUsesKeyOwnChain(t *testing.T) {
|
||||
c := newTestCore(t, perKeyTestConfig(t))
|
||||
|
||||
if got := firstSlotModel(t, c, "sk-gw-own"); got != "gpt-4o-mini" {
|
||||
t.Fatalf("key with own chain must use it, got %q (want gpt-4o-mini)", got)
|
||||
}
|
||||
// Sanity: the global chain really is different, so the assertion above
|
||||
// cannot pass by accident.
|
||||
if got := firstSlotModel(t, c, "sk-gw-admin"); got != "gpt-4o" {
|
||||
t.Fatalf("admin must use global chain, got %q (want gpt-4o)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A key with no own chain inherits the global one. This is what keeps every
|
||||
// pre-existing deployment working with no config change.
|
||||
func TestAutoChainForInheritsGlobalWhenUnset(t *testing.T) {
|
||||
c := newTestCore(t, perKeyTestConfig(t))
|
||||
|
||||
if got := firstSlotModel(t, c, "sk-gw-inherits"); got != "gpt-4o" {
|
||||
t.Fatalf("key without own chain must inherit global, got %q (want gpt-4o)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A key that is not in the key table at all (e.g. a seed key) must also
|
||||
// inherit the global chain rather than erroring or returning nil.
|
||||
func TestAutoChainForUnknownKeyInheritsGlobal(t *testing.T) {
|
||||
c := newTestCore(t, perKeyTestConfig(t))
|
||||
|
||||
ch, ok := c.AutoChainFor("sk-gw-never-configured")
|
||||
if !ok || ch == nil || len(ch.Tiers) == 0 {
|
||||
t.Fatalf("unknown key must fall back to global chain, got ok=%v chain=%v", ok, ch)
|
||||
}
|
||||
}
|
||||
|
||||
// The per-key chain must survive a save/reload round trip: an admin configuring
|
||||
// a chain in the WebUI and restarting the gateway must not silently lose it.
|
||||
func TestSaveKeyAutoPersistsAndApplies(t *testing.T) {
|
||||
cfg := perKeyTestConfig(t)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
if err := c.SaveKeyAuto("sk-gw-inherits", []config.ModelScope{
|
||||
{Model: "gpt-4o-mini", Source: "s1", Tier: 1},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveKeyAuto: %v", err)
|
||||
}
|
||||
if got := firstSlotModel(t, c, "sk-gw-inherits"); got != "gpt-4o-mini" {
|
||||
t.Fatalf("saved chain must apply immediately, got %q", got)
|
||||
}
|
||||
|
||||
// Reload from disk exactly like a restart does.
|
||||
reloaded, err := config.Load(cfg.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload config: %v", err)
|
||||
}
|
||||
c2 := newTestCore(t, reloaded)
|
||||
if got := firstSlotModel(t, c2, "sk-gw-inherits"); got != "gpt-4o-mini" {
|
||||
t.Fatalf("per-key chain must survive restart, got %q", got)
|
||||
}
|
||||
// The other key must be unaffected by its neighbour's override.
|
||||
if got := firstSlotModel(t, c2, "sk-gw-admin"); got != "gpt-4o" {
|
||||
t.Fatalf("override must not leak to other keys, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing the override must return the key to the global chain, both in
|
||||
// memory and after a restart. This is what the WebUI's "use global" toggle
|
||||
// does, so a stale shadowing entry would silently pin the user to their old
|
||||
// chain forever.
|
||||
func TestSaveKeyAutoClearRestoresGlobal(t *testing.T) {
|
||||
cfg := perKeyTestConfig(t)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
if err := c.SaveKeyAuto("sk-gw-own", nil); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if got := firstSlotModel(t, c, "sk-gw-own"); got != "gpt-4o" {
|
||||
t.Fatalf("cleared key must inherit global again, got %q", got)
|
||||
}
|
||||
|
||||
reloaded, err := config.Load(cfg.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
c2 := newTestCore(t, reloaded)
|
||||
if got := firstSlotModel(t, c2, "sk-gw-own"); got != "gpt-4o" {
|
||||
t.Fatalf("clear must survive restart, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Saving an unknown key must fail loudly rather than creating a shadow entry
|
||||
// that no request can ever match.
|
||||
func TestSaveKeyAutoUnknownKeyFails(t *testing.T) {
|
||||
c := newTestCore(t, perKeyTestConfig(t))
|
||||
|
||||
if err := c.SaveKeyAuto("sk-gw-nope", []config.ModelScope{{Model: "gpt-4o", Source: "s1"}}); err == nil {
|
||||
t.Fatal("SaveKeyAuto on unknown key must return an error")
|
||||
}
|
||||
}
|
||||
|
||||
// Per-key quota validation must run, exactly like the model scope path: an
|
||||
// override is another place an operator can write a bad budget.
|
||||
func TestSaveKeyAutoRejectsBadQuota(t *testing.T) {
|
||||
c := newTestCore(t, perKeyTestConfig(t))
|
||||
|
||||
err := c.SaveKeyAuto("sk-gw-inherits", []config.ModelScope{
|
||||
{Model: "gpt-4o", Source: "s1", TokenQuota: -5},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("negative token quota in a per-key auto chain must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Source changes must recompile per-key chains. A key whose chain names a
|
||||
// model that does not exist yet compiles to an empty chain (BuildChain drops
|
||||
// slots it cannot resolve — sending the request into a black hole would be
|
||||
// worse), and must start working as soon as the source appears.
|
||||
func TestKeyAutoChainsRebuildOnSourceChange(t *testing.T) {
|
||||
cfg := perKeyTestConfig(t)
|
||||
cfg.Keys = append(cfg.Keys, config.GWKey{
|
||||
Key: "sk-gw-late", Role: "user", Name: "late",
|
||||
Auto: []config.ModelScope{{Model: "new-model", Source: "s2", Tier: 1}},
|
||||
})
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
// s2 does not exist yet, so the chain has no usable slot.
|
||||
if ch, _ := c.AutoChainFor("sk-gw-late"); chainSlotsOf(ch) != 0 {
|
||||
t.Fatalf("chain naming an unknown model must compile empty, got %d slots",
|
||||
chainSlotsOf(ch))
|
||||
}
|
||||
// The other keys are unaffected by the broken one.
|
||||
if got := firstSlotModel(t, c, "sk-gw-admin"); got != "gpt-4o" {
|
||||
t.Fatalf("broken per-key chain must not affect others, got %q", got)
|
||||
}
|
||||
|
||||
// Add the source; the per-key chain must pick it up without a restart.
|
||||
cfg.Sources = append(cfg.Sources, config.Source{
|
||||
Name: "s2", BaseURL: "http://127.0.0.1:2/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "new-model", Priority: 10}},
|
||||
})
|
||||
if err := c.Reload(); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
if got := firstSlotModel(t, c, "sk-gw-late"); got != "new-model" {
|
||||
t.Fatalf("per-key chain must resolve after the source is added, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// chainSlotsOf counts usable slots, tolerating a nil chain.
|
||||
func chainSlotsOf(ch *scheduler.Chain) int {
|
||||
if ch == nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, tn := range ch.Tiers {
|
||||
n += len(tn.Slots)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@ -400,7 +400,8 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
// 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()
|
||||
// The key's own chain wins; keys without one inherit the global chain.
|
||||
chain, _ := g.core.AutoChainFor(reqKey(r.Context()))
|
||||
if chain == nil || len(chain.Tiers) == 0 {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot configured")
|
||||
return
|
||||
|
||||
257
internal/gateway/key_auto_api_test.go
Normal file
257
internal/gateway/key_auto_api_test.go
Normal file
@ -0,0 +1,257 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
)
|
||||
|
||||
// Per-key AUTO chain admin API. The behaviours pinned here are the ones that
|
||||
// are easy to get wrong and invisible when they are:
|
||||
//
|
||||
// - /api/keys/me/auto must be reachable by a NON-admin (it is their own
|
||||
// chain); routing it through the generic {key}/auto handler would 403 it.
|
||||
// - /api/keys/{key}/auto must 403 a non-admin.
|
||||
// - an empty PUT and a DELETE mean the same thing ("inherit global"), so the
|
||||
// WebUI cannot persist a chain that would then 503 with no slots.
|
||||
// - a PUT that resolves to zero slots is reported back, so an admin who
|
||||
// mistyped a model learns it now instead of from the user's next 503.
|
||||
|
||||
// keyAutoGateway builds a gateway with one admin key and one user key.
|
||||
func keyAutoGateway(t *testing.T) *Gateway {
|
||||
t.Helper()
|
||||
td := t.TempDir()
|
||||
cfgPath := filepath.Join(td, "config.yaml")
|
||||
if err := os.WriteFile(cfgPath, []byte("listen: :0"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: cfgPath,
|
||||
AdapterDir: filepath.Join(td, "adapters"),
|
||||
RuntimeFile: filepath.Join(td, "runtime.json"),
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-admin"},
|
||||
Keys: []config.GWKey{
|
||||
{Key: "sk-admin", Role: "admin", Name: "admin"},
|
||||
{Key: "sk-user", Role: "user", Name: "user"},
|
||||
},
|
||||
Sources: []config.Source{
|
||||
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
|
||||
},
|
||||
}
|
||||
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)
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// doReqAs issues an authenticated request as a specific key.
|
||||
func doReqAs(t *testing.T, g *Gateway, key, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
g.Handler().ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestKeyAutoAPIPermissions(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
// A user may read their own chain...
|
||||
if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/me/auto", ""); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/keys/me/auto as user = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
// ...but not another key's.
|
||||
if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/sk-admin/auto", ""); rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("GET other key's auto as user = %d, want 403", rr.Code)
|
||||
}
|
||||
// ...and not their own via the admin route either.
|
||||
if rr := doReqAs(t, g, "sk-user", "PUT", "/api/keys/sk-user/auto", `{"auto":[{"model":"gpt-4o"}]}`); rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("user PUT own auto = %d, want 403 (only admin configures)", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyAutoAPICrudAndInherit(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
// Initially inherits.
|
||||
var got struct {
|
||||
Inherits bool `json:"inherits"`
|
||||
Auto []config.ModelScope `json:"auto"`
|
||||
}
|
||||
rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Inherits || len(got.Auto) != 0 {
|
||||
t.Fatalf("fresh key must inherit global, got inherits=%v auto=%v", got.Inherits, got.Auto)
|
||||
}
|
||||
|
||||
// Admin sets a chain; the response reports the resolved slot count.
|
||||
rr = doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
|
||||
`{"auto":[{"model":"gpt-4o-mini","source":"s1","tier":1}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var put struct {
|
||||
Inherits bool `json:"inherits"`
|
||||
Slots int `json:"slots"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &put); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if put.Inherits {
|
||||
t.Fatal("after a non-empty PUT the key must no longer inherit")
|
||||
}
|
||||
if put.Slots != 1 {
|
||||
t.Fatalf("resolved slots = %d, want 1", put.Slots)
|
||||
}
|
||||
|
||||
// And it reads back as an own chain.
|
||||
rr = doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Inherits || len(got.Auto) != 1 || got.Auto[0].Model != "gpt-4o-mini" {
|
||||
t.Fatalf("own chain must read back, got inherits=%v auto=%v", got.Inherits, got.Auto)
|
||||
}
|
||||
|
||||
// DELETE restores inheritance.
|
||||
if rr := doReqAs(t, g, "sk-admin", "DELETE", "/api/keys/sk-user/auto", ""); rr.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
rr = doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Inherits {
|
||||
t.Fatal("after DELETE the key must inherit the global chain again")
|
||||
}
|
||||
}
|
||||
|
||||
// An empty PUT must behave like DELETE. Otherwise a WebUI that submits an
|
||||
// empty list (every slot deleted in the editor) would persist an empty chain
|
||||
// and the next AUTO request would fail with no_provider.
|
||||
func TestKeyAutoAPIEmptyPutClearsOverride(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
if rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
|
||||
`{"auto":[{"model":"gpt-4o","source":"s1","tier":1}]}`); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed PUT = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto", `{"auto":[]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("empty PUT = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got struct {
|
||||
Inherits bool `json:"inherits"`
|
||||
}
|
||||
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", ""); rr.Code != http.StatusOK {
|
||||
t.Fatal(rr.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Inherits {
|
||||
t.Fatal("an empty PUT must clear the override, not persist an empty chain")
|
||||
}
|
||||
}
|
||||
|
||||
// A chain naming a model that does not exist compiles to zero slots. The API
|
||||
// must say so at write time instead of letting the user's next request 503.
|
||||
func TestKeyAutoAPIReportsUnresolvableSlots(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
|
||||
`{"auto":[{"model":"does-not-exist","source":"s1","tier":1}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var put struct {
|
||||
Inherits bool `json:"inherits"`
|
||||
Slots int `json:"slots"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &put); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if put.Slots != 0 {
|
||||
t.Fatalf("unknown model must resolve to 0 slots, got %d", put.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyAutoAPIUnknownKey404(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-nope/auto", ""); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET unknown key auto = %d, want 404", rr.Code)
|
||||
}
|
||||
if rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-nope/auto", `{"auto":[{"model":"gpt-4o"}]}`); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("PUT unknown key auto = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A bad quota must be rejected with 400, not persisted and not silently
|
||||
// clamped — same rule as the model scope path.
|
||||
func TestKeyAutoAPIRejectsBadQuota(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
|
||||
`{"auto":[{"model":"gpt-4o","source":"s1","tier":1,"token_quota":-1}]}`)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("negative quota PUT = %d, want 400 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
// Nothing may have been persisted.
|
||||
var got struct {
|
||||
Inherits bool `json:"inherits"`
|
||||
}
|
||||
get := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
|
||||
if get.Code != http.StatusOK {
|
||||
t.Fatal(get.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(get.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Inherits {
|
||||
t.Fatal("a rejected PUT must not persist a chain")
|
||||
}
|
||||
}
|
||||
|
||||
// The other key endpoints must keep working: the {key}/auto route must not
|
||||
// shadow /api/keys/{key} itself.
|
||||
func TestKeyAutoAPIDoesNotShadowKeyRoutes(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys", ""); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/keys = %d, want 200", rr.Code)
|
||||
}
|
||||
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/me", ""); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/keys/me = %d, want 200", rr.Code)
|
||||
}
|
||||
if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/me", ""); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/keys/me as user = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/scheduler"
|
||||
)
|
||||
|
||||
// handleKeysAPI manages gateway keys: GET /api/keys (admin: all keys),
|
||||
@ -17,10 +18,22 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
|
||||
path = strings.Trim(path, "/")
|
||||
role := reqRole(r.Context())
|
||||
|
||||
// /api/keys/me/auto — a user's own chain, readable without admin rights.
|
||||
// Must be matched before the generic {key}/auto case below so it does not
|
||||
// ask admin rights of a plain user asking about their own chain.
|
||||
if path == "me/auto" {
|
||||
g.handleKeyMeAuto(w, r)
|
||||
return
|
||||
}
|
||||
if path == "me" {
|
||||
g.handleKeyMe(w, r)
|
||||
return
|
||||
}
|
||||
// /api/keys/{key}/auto — the per-key AUTO chain editor.
|
||||
if i := strings.LastIndex(path, "/auto"); i > 0 && path[i+len("/auto"):] == "" {
|
||||
g.handleKeyAutoAPI(w, r, path[:i])
|
||||
return
|
||||
}
|
||||
if role != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
@ -139,6 +152,101 @@ func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
|
||||
return rec.Models
|
||||
}
|
||||
|
||||
// handleKeyAutoAPI manages one key's own AUTO chain:
|
||||
// GET /api/keys/{key}/auto returns it, PUT replaces it, DELETE clears the
|
||||
// override so the key falls back to the global chain. Admin only — a user can
|
||||
// inspect their own chain through GET /api/keys/me/auto.
|
||||
//
|
||||
// An empty chain and a cleared chain are deliberately the same state: "inherit
|
||||
// the global chain". That keeps the WebUI's "use global" toggle a plain DELETE
|
||||
// with no sentinel value to carry through config.yaml.
|
||||
func (g *Gateway) handleKeyAutoAPI(w http.ResponseWriter, r *http.Request, key string) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
rec, ok := g.core.FindKey(key)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "key not found")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"key": key,
|
||||
"auto": rec.Auto,
|
||||
"inherits": !rec.HasOwnAuto(),
|
||||
})
|
||||
case http.MethodPut, http.MethodPatch:
|
||||
var body struct {
|
||||
Auto []config.ModelScope `json:"auto"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(body.Auto) == 0 {
|
||||
// Treat an empty PUT as "give up the override" so the endpoint
|
||||
// cannot persist a chain that would 503 with no slots.
|
||||
if err := g.core.SaveKeyAuto(key, nil); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "key_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "inherits": true})
|
||||
return
|
||||
}
|
||||
if err := g.core.SaveKeyAuto(key, body.Auto); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "key_error", err.Error())
|
||||
return
|
||||
}
|
||||
// Report whether the chain actually resolved to usable slots. An
|
||||
// admin who typed a model that no longer exists should learn it here,
|
||||
// not from the user's next 503.
|
||||
chain, _ := g.core.AutoChainFor(key)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true, "inherits": false, "slots": chainSlots(chain),
|
||||
})
|
||||
case http.MethodDelete:
|
||||
if err := g.core.SaveKeyAuto(key, nil); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "key_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "inherits": true})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
}
|
||||
|
||||
// chainSlots counts the usable slots in a chain, for API feedback after an
|
||||
// edit. A chain that compiled to zero slots is reported so the caller can warn
|
||||
// instead of letting the next request fail with no_provider.
|
||||
func chainSlots(ch *scheduler.Chain) int {
|
||||
if ch == nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, tn := range ch.Tiers {
|
||||
n += len(tn.Slots)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// handleKeyMeAuto lets a user read their own AUTO chain without admin rights.
|
||||
func (g *Gateway) handleKeyMeAuto(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
||||
return
|
||||
}
|
||||
rec, ok := g.core.FindKey(reqKey(r.Context()))
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "invalid_api_key", "key not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"key": rec.Key, "auto": rec.Auto, "inherits": !rec.HasOwnAuto(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleAutoAPI manages the AUTO scheduling slots: GET /api/auto returns the
|
||||
// current rules; PUT /api/auto replaces them (admin only).
|
||||
func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@ -319,3 +319,85 @@ func TestPeriodFromQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeriodWeekMayStartBeforeMonth pins a fact that looks like a bug and
|
||||
// gets reported as one: the calendar periods are NOT nested.
|
||||
//
|
||||
// On 2026-10-03 (a Saturday) the ISO week runs Mon 2026-09-28 .. Sat
|
||||
// 10-03, while the month window is 10-01 .. 10-03. The week therefore reaches
|
||||
// back into the previous month and its total is legitimately LARGER than the
|
||||
// month's. Anyone reading only the totals will conclude the aggregator is
|
||||
// broken; it is not, and this test exists so a future "fix" that makes the
|
||||
// windows nested (e.g. clamping the week to the month) is caught as the
|
||||
// regression it would be.
|
||||
func TestPeriodWeekMayStartBeforeMonth(t *testing.T) {
|
||||
// Saturday 2026-10-03, UTC.
|
||||
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
weekFrom := periodStart(PeriodWeek, now)
|
||||
monthFrom := periodStart(PeriodMonth, now)
|
||||
|
||||
weekFromD := time.UnixMilli(weekFrom).UTC()
|
||||
monthFromD := time.UnixMilli(monthFrom).UTC()
|
||||
|
||||
if got := weekFromD.Format("2006-01-02"); got != "2026-09-28" {
|
||||
t.Fatalf("week window start = %s, want 2026-09-28 (the Monday)", got)
|
||||
}
|
||||
if got := monthFromD.Format("2006-01-02"); got != "2026-10-01" {
|
||||
t.Fatalf("month window start = %s, want 2026-10-01", got)
|
||||
}
|
||||
if !weekFromD.Before(monthFromD) {
|
||||
t.Fatal("this test only means anything when the week starts before the month")
|
||||
}
|
||||
// The week must reach strictly further back than the month window does,
|
||||
// otherwise the nesting intuition would hold and the UI note would be noise.
|
||||
if back := monthFromD.Sub(weekFromD).Hours() / 24; back <= 0 {
|
||||
t.Fatalf("week should start at least a day before the month window, got %v days back", back)
|
||||
}
|
||||
}
|
||||
|
||||
// The same overlap must be visible in the aggregate numbers, not just in the
|
||||
// window bounds: a week total larger than a month total is the exact symptom
|
||||
// an operator reported.
|
||||
func TestPeriodWeekTotalCanExceedMonthTotal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
audit := filepath.Join(dir, "audit.jsonl")
|
||||
|
||||
// Two records before the month window (inside this ISO week) and one
|
||||
// inside October.
|
||||
pre := time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC).UnixMilli()
|
||||
in1 := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC).UnixMilli()
|
||||
in2 := time.Date(2026, 10, 3, 10, 0, 0, 0, time.UTC).UnixMilli()
|
||||
|
||||
auditPeriod(t, audit,
|
||||
Req{Time: pre, OK: true, Prompt: 100, Compl: 10},
|
||||
Req{Time: pre + 1000, OK: true, Prompt: 100, Compl: 10},
|
||||
Req{Time: in1, OK: true, Prompt: 50, Compl: 5},
|
||||
Req{Time: in2, OK: true, Prompt: 50, Compl: 5},
|
||||
)
|
||||
|
||||
s := periodStats(t, audit)
|
||||
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
week := s.PeriodSnapshot(PeriodWeek, "", now)
|
||||
month := s.PeriodSnapshot(PeriodMonth, "", now)
|
||||
|
||||
if week.Total.Reqs != 4 {
|
||||
t.Fatalf("week reqs = %d, want 4 (both September records fall in this ISO week)", week.Total.Reqs)
|
||||
}
|
||||
if month.Total.Reqs != 2 {
|
||||
t.Fatalf("month reqs = %d, want 2", month.Total.Reqs)
|
||||
}
|
||||
if week.Total.Tokens <= month.Total.Tokens {
|
||||
t.Fatalf("week tokens (%d) must exceed month tokens (%d): the week reaches into September",
|
||||
week.Total.Tokens, month.Total.Tokens)
|
||||
}
|
||||
// Day must be a strict subset of both, which is the only nesting that holds.
|
||||
day := s.PeriodSnapshot(PeriodDay, "", now)
|
||||
if day.Total.Reqs != 1 {
|
||||
t.Fatalf("day reqs = %d, want 1", day.Total.Reqs)
|
||||
}
|
||||
if day.Total.Tokens >= month.Total.Tokens || day.Total.Tokens >= week.Total.Tokens {
|
||||
t.Fatal("today must be contained in both the week and the month totals")
|
||||
}
|
||||
}
|
||||
|
||||
@ -822,6 +822,17 @@
|
||||
keysHint:
|
||||
"管理员密钥可查看与管理全部密钥,并可为每个用户密钥配置可用模型范围;用户密钥只能看到自己。",
|
||||
kCreate: "新建密钥",
|
||||
kAutoChain: "AUTO 链",
|
||||
kAutoChainOwn: "独立链",
|
||||
kAutoChainGlobal: "跟随全局",
|
||||
kAutoChainTitle: "独立 AUTO 链",
|
||||
kAutoChainHint: "为空则跟随全局 AUTO 链",
|
||||
kAutoChainInherit: "使用全局链",
|
||||
kAutoChainAdd: "+ 添加槽位",
|
||||
kAutoChainSave: "保存",
|
||||
kAutoChainTier: "层级",
|
||||
kAutoChainEmpty: "该密钥使用全局 AUTO 链",
|
||||
kAutoChainBad: "槽位无法解析(模型或源不存在),保存后 AUTO 请求会失败",
|
||||
kName: "名称",
|
||||
kRole: "角色",
|
||||
kRoleAdmin: "管理员",
|
||||
@ -1043,6 +1054,7 @@
|
||||
periodWeek: "本周",
|
||||
periodMonth: "本月",
|
||||
periodAll: "全部",
|
||||
periodRange: "统计区间:",
|
||||
periodTruncated:
|
||||
"统计周期可能不完整(审计日志已轮转),数值为下限。",
|
||||
recsPartial:
|
||||
@ -1081,6 +1093,17 @@
|
||||
keysHint:
|
||||
"Admin keys can view and manage every key and configure each user key\u0027s allowed models; user keys only see themselves.",
|
||||
kCreate: "Create key",
|
||||
kAutoChain: "AUTO chain",
|
||||
kAutoChainOwn: "own",
|
||||
kAutoChainGlobal: "global",
|
||||
kAutoChainTitle: "Per-key AUTO chain",
|
||||
kAutoChainHint: "Leave empty to inherit the global AUTO chain",
|
||||
kAutoChainInherit: "Use global chain",
|
||||
kAutoChainAdd: "+ Add slot",
|
||||
kAutoChainSave: "Save",
|
||||
kAutoChainTier: "Tier",
|
||||
kAutoChainEmpty: "This key uses the global AUTO chain",
|
||||
kAutoChainBad: "Slot does not resolve (unknown model or source); AUTO requests would fail",
|
||||
kName: "Name",
|
||||
kRole: "Role",
|
||||
kRoleAdmin: "Admin",
|
||||
@ -1306,6 +1329,7 @@
|
||||
periodWeek: "This week",
|
||||
periodMonth: "This month",
|
||||
periodAll: "All time",
|
||||
periodRange: "Window:",
|
||||
periodTruncated:
|
||||
"This window may be incomplete (audit log rotated); figures are a lower bound.",
|
||||
recsPartial:
|
||||
@ -1717,9 +1741,10 @@
|
||||
<option value="month">${t("periodMonth")}</option>
|
||||
<option value="all">${t("periodAll")}</option>
|
||||
</select>
|
||||
<span class="grow"></span>
|
||||
<span class="grow"></span>
|
||||
<span id="period-note"></span>
|
||||
</div>
|
||||
<div class="muted" id="period-range" style="margin:-6px 0 12px"></div>
|
||||
<div class="kpis" id="kpi-row"><div class="kpi-skeletons" aria-hidden="true">${Array(
|
||||
5,
|
||||
)
|
||||
@ -1979,11 +2004,31 @@
|
||||
// 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");
|
||||
const pnote = $("#period-note");
|
||||
if (pnote)
|
||||
pnote.innerHTML = st.truncated
|
||||
? `<span class="muted" title="${escAttr(t("periodTruncated"))}">ⓘ ${esc(t("periodTruncated"))}</span>`
|
||||
: "";
|
||||
// Show the window the numbers actually cover. The calendar periods
|
||||
// are not nested: "this week" runs from its Monday, so early in a
|
||||
// month it legitimately reaches back into the previous month and
|
||||
// its total EXCEEDS "this month". That is correct, but it looks
|
||||
// like a bug unless the dates are on screen — so print them.
|
||||
const prange = $("#period-range");
|
||||
if (prange && st.period && st.period !== "all" && st.from) {
|
||||
const d0 = new Date(st.from);
|
||||
const d1 = st.buckets && st.buckets.length
|
||||
? new Date(st.buckets[st.buckets.length - 1].bucket + "T00:00:00Z")
|
||||
: d0;
|
||||
const f = (d) =>
|
||||
d.toISOString().slice(0, 10);
|
||||
prange.textContent =
|
||||
t("periodRange") +
|
||||
" " + f(d0) + " → " + f(d1) +
|
||||
(st.buckets ? " (" + st.buckets.length + "d)" : "");
|
||||
} else if (prange) {
|
||||
prange.textContent = "";
|
||||
}
|
||||
const rex = $("#rec-exit");
|
||||
if (rex)
|
||||
rex.innerHTML = statsKeyF
|
||||
@ -3435,8 +3480,96 @@
|
||||
// the toggle switches which chain the canvas edits
|
||||
const sortLanes = { chat: [], image: [] };
|
||||
let sortStateMap = new Map();
|
||||
// renderSortEditor paints the shared lane canvas into `mount` for whichever
|
||||
// chain sortState currently describes. Both the global editor and the
|
||||
// per-key modal call it, so a key's chain gets the identical canvas
|
||||
// (drag ordering, per-slot quota/period/hours) rather than a trimmed copy.
|
||||
function renderSortEditor(mount, opts) {
|
||||
const o = opts || {};
|
||||
mount.innerHTML = `
|
||||
<div class="sort-canvas" id="${o.canvasId}"></div>
|
||||
<div class="scr-hint">
|
||||
${
|
||||
sortState.lanes
|
||||
.map((l) => l.models[0])
|
||||
.map((it) => {
|
||||
const c = srcColor(it.src);
|
||||
return `<span><i style="background:linear-gradient(135deg,${c[0]},${c[1]})"></i>${esc(it.src)}</span>`;
|
||||
})
|
||||
.filter((v, i, a) => a.indexOf(v) === i)
|
||||
.join("") || ""
|
||||
}
|
||||
</div>`;
|
||||
paintSort();
|
||||
}
|
||||
// sortIndex holds the model/source lookups buildLanes needs. It is built once
|
||||
// per editor open so both the global chain and a per-key chain can compile
|
||||
// rules into the same lane structure.
|
||||
function buildSortIndex(sources) {
|
||||
const byModel = new Map();
|
||||
const byPair = new Map();
|
||||
const sourceRows = new Map();
|
||||
(sources || []).forEach((s) =>
|
||||
(s.models || []).forEach((m) => {
|
||||
const it = { src: s.name, id: m.id, prio: m.priority || 0, kind: m.kind };
|
||||
byPair.set(m.id + "|" + s.name, it);
|
||||
if (!byModel.has(m.id)) byModel.set(m.id, it);
|
||||
if (!sourceRows.has(it.prio)) sourceRows.set(it.prio, []);
|
||||
sourceRows.get(it.prio).push({ src: s.name, id: m.id });
|
||||
}),
|
||||
);
|
||||
return { byModel, byPair, sourceRows };
|
||||
}
|
||||
// buildLanes compiles saved rules into the tier-lane structure the canvas
|
||||
// paints. With no rules it falls back to discovery, grouped by source
|
||||
// priority, so an unconfigured chain still shows every available slot.
|
||||
// Shared by the global editor and the per-key editor.
|
||||
function buildLanes(idx, rules, kind) {
|
||||
const { byModel, byPair, sourceRows } = idx;
|
||||
if (rules.length) {
|
||||
const tiers = [];
|
||||
rules.forEach((r, i) => {
|
||||
const src = normSrc(r.source);
|
||||
const pair = src ? byPair.get(r.model + "|" + src) : null;
|
||||
if (src && !pair) return;
|
||||
const ref = pair || byModel.get(r.model) || { src: "*", id: r.model };
|
||||
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
|
||||
if (!tiers[ti]) tiers[ti] = [];
|
||||
tiers[ti].push({
|
||||
src: ref.src || "*",
|
||||
id: r.model,
|
||||
uid: nexUid(),
|
||||
meta: {
|
||||
quota: r.token_quota || 0,
|
||||
period: r.period || "",
|
||||
hours: r.hours || 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
return tiers
|
||||
.filter(Boolean)
|
||||
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
|
||||
}
|
||||
return [...sourceRows.entries()]
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.map(([prio, models]) => {
|
||||
const same = models.filter((m) => {
|
||||
const it = byPair.get(m.id + "|" + m.src);
|
||||
const k = it ? it.kind : "chat";
|
||||
return k === kind;
|
||||
});
|
||||
if (!same.length) return null;
|
||||
same.sort((x, y) => (x.src < y.src ? -1 : x.src > y.src ? 1 : 0));
|
||||
same.forEach((m) => {
|
||||
m.uid = nexUid();
|
||||
m.meta = null;
|
||||
});
|
||||
return { prio, models: same };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
async function renderSort() {
|
||||
const j = await api("/api/v1/sources");
|
||||
const j = await api("/api/v1/sources");
|
||||
let autoR = [];
|
||||
let autoImg = [];
|
||||
try {
|
||||
@ -3450,65 +3583,12 @@
|
||||
]),
|
||||
);
|
||||
} catch (e) {}
|
||||
const byModel = new Map();
|
||||
const byPair = new Map();
|
||||
const sourceRows = new Map();
|
||||
j.sources.forEach((s) =>
|
||||
(s.models || []).forEach((m) => {
|
||||
const it = { src: s.name, id: m.id, prio: m.priority || 0, kind: m.kind };
|
||||
byPair.set(m.id + "|" + s.name, it);
|
||||
if (!byModel.has(m.id)) byModel.set(m.id, it);
|
||||
if (!sourceRows.has(it.prio)) sourceRows.set(it.prio, []);
|
||||
sourceRows.get(it.prio).push({ src: s.name, id: m.id });
|
||||
}),
|
||||
);
|
||||
const sortIdx = buildSortIndex(j.sources);
|
||||
// build a lane structure for one kind from its saved rules; when
|
||||
// rules are empty, fall back to discovery from sourceRows filtered by
|
||||
// kind so an unconfigured chain still shows all available slots
|
||||
const buildLanes = (rules, kind) => {
|
||||
if (rules.length) {
|
||||
const tiers = [];
|
||||
rules.forEach((r, i) => {
|
||||
const src = normSrc(r.source);
|
||||
const pair = src ? byPair.get(r.model + "|" + src) : null;
|
||||
if (src && !pair) return;
|
||||
const ref = pair ||
|
||||
byModel.get(r.model) || { src: "*", id: r.model };
|
||||
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
|
||||
if (!tiers[ti]) tiers[ti] = [];
|
||||
tiers[ti].push({
|
||||
src: ref.src || "*",
|
||||
id: r.model,
|
||||
uid: nexUid(),
|
||||
meta: {
|
||||
quota: r.token_quota || 0,
|
||||
period: r.period || "",
|
||||
hours: r.hours || 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
return tiers
|
||||
.filter(Boolean)
|
||||
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
|
||||
}
|
||||
// discovery: group slots of this kind by their source priority
|
||||
return [...sourceRows.entries()]
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.map(([prio, models]) => {
|
||||
const same = models.filter((m) => {
|
||||
const it = byPair.get(m.id + "|" + m.src);
|
||||
const k = it ? it.kind : "chat";
|
||||
return k === kind;
|
||||
});
|
||||
if (!same.length) return null;
|
||||
same.sort((x, y) => x.src < y.src ? -1 : x.src > y.src ? 1 : 0);
|
||||
same.forEach((m) => { m.uid = nexUid(); m.meta = null; });
|
||||
return { prio, models: same };
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
sortLanes.chat = buildLanes(autoR, "chat");
|
||||
sortLanes.image = buildLanes(autoImg, "image");
|
||||
sortLanes.chat = buildLanes(sortIdx, autoR, "chat");
|
||||
sortLanes.image = buildLanes(sortIdx, autoImg, "image");
|
||||
sortState.lanes = sortLanes[sortState.kind] || [];
|
||||
// model picker for the "add slot" control: one option per (source, model)
|
||||
// pair so identical model ids on different sources stay distinguishable
|
||||
@ -3520,22 +3600,11 @@
|
||||
<div class="card"><h2><span>${t("sortTitle")}</span><span class="grow"></span>${kindToggle}
|
||||
<button class="ghost small" onclick="scrAddModal()">+ ${t("sortAdd")}</button>
|
||||
<button class="ghost small" onclick="sortReset()">${t("sortReset")}</button>
|
||||
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
|
||||
<div class="sort-canvas" id="scr-canvas"></div>
|
||||
<div class="scr-hint">
|
||||
${
|
||||
sortState.lanes
|
||||
.map((l) => l.models[0])
|
||||
.map((it) => {
|
||||
const c = srcColor(it.src);
|
||||
return `<span><i style="background:linear-gradient(135deg,${c[0]},${c[1]})"></i>${esc(it.src)}</span>`;
|
||||
})
|
||||
.filter((v, i, a) => a.indexOf(v) === i)
|
||||
.join("") || ""
|
||||
}
|
||||
</div>
|
||||
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
|
||||
</div>`;
|
||||
paintSort();
|
||||
renderSortEditor($("#tab-sort").querySelector(".card"), {
|
||||
canvasId: "scr-canvas",
|
||||
});
|
||||
}
|
||||
function switchSortKind(k) {
|
||||
if (sortState.kind === k) return;
|
||||
@ -3620,9 +3689,18 @@
|
||||
<span class="scr-grip"><i></i><i></i><i></i></span>
|
||||
</div>`;
|
||||
}
|
||||
// sortCanvasEl resolves the canvas the editor paints into. The global editor
|
||||
// owns #scr-canvas; the per-key editor renders the same canvas under a
|
||||
// different id inside its modal, so both share one implementation
|
||||
// instead of a reduced second copy that would drift.
|
||||
function sortCanvasEl() {
|
||||
const own = document.getElementById("scr-canvas");
|
||||
if (own) return own;
|
||||
return document.getElementById("key-auto-canvas");
|
||||
}
|
||||
function paintSort(affected) {
|
||||
const cv = $("#scr-canvas");
|
||||
if (!cv) return;
|
||||
const cv = sortCanvasEl();
|
||||
if (!cv) return;
|
||||
// compute the next lane-leaders so we can retract the outgoing ones first
|
||||
const nextFirst = new Set();
|
||||
sortState.lanes.forEach((lane) => {
|
||||
@ -3674,8 +3752,8 @@
|
||||
}
|
||||
paintSortNow(affected);
|
||||
}
|
||||
function paintSortNow(affected) {
|
||||
const cv = $("#scr-canvas");
|
||||
function paintSortNow(affected) {
|
||||
const cv = sortCanvasEl();
|
||||
if (!cv) return;
|
||||
// FLIP: capture old positions of lanes and blocks before repaint
|
||||
const prev = new Map();
|
||||
@ -3822,9 +3900,9 @@
|
||||
});
|
||||
}
|
||||
function onSortMove(e) {
|
||||
const d = sortState.drag;
|
||||
const d = sortState.drag;
|
||||
if (!d) return;
|
||||
const cv = document.getElementById("scr-canvas");
|
||||
const cv = sortCanvasEl();
|
||||
if (!cv) return;
|
||||
const laneEls = [...cv.querySelectorAll(".scr-lane")];
|
||||
const gapEls = [...cv.querySelectorAll(".scr-gap")];
|
||||
@ -3933,7 +4011,8 @@
|
||||
if (!d) return;
|
||||
const line = document.getElementById("scr-blockline");
|
||||
if (line) line.remove();
|
||||
const cv = document.getElementById("scr-canvas");
|
||||
const cv = sortCanvasEl();
|
||||
if (!cv) return;
|
||||
cv.querySelectorAll(".scr-gap, .scr-lane").forEach((el) =>
|
||||
el.classList.remove("hover", "drophere"),
|
||||
);
|
||||
@ -4033,10 +4112,42 @@
|
||||
}
|
||||
|
||||
/* ---------- AUTO quota editing on sort blocks ---------- */
|
||||
// sortState.scope decides where saveSort() persists: null/undefined =
|
||||
// the global chain, or a gateway key = that key's own chain. The canvas,
|
||||
// drag/drop and slot metadata all work unchanged in both modes; only the
|
||||
// persistence endpoint and the chain that gets loaded differ. Keeping one
|
||||
// editor means a per-key chain gets exactly the features the global one
|
||||
// has (drag ordering, per-slot quota/period/hours) instead of a reduced
|
||||
// second implementation that would drift.
|
||||
async function persistAuto() {
|
||||
const rules = lanesToRules(sortState.lanes);
|
||||
if (sortState.scope) {
|
||||
// Empty rules mean "inherit the global chain" server-side, so the
|
||||
// same PUT both saves a chain and clears the override.
|
||||
await api("/api/keys/" + encodeURIComponent(sortState.scope) + "/auto", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ auto: rules }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// each kind persists to its own chain; the other kind's saved rules
|
||||
// are left untouched (omitting the field means "no change")
|
||||
const body = { rules: undefined, image_rules: undefined };
|
||||
if (sortState.kind === "image") body.image_rules = rules;
|
||||
else body.rules = rules;
|
||||
await api("/api/auto", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
// lanesToRules flattens the tier lanes into the wire shape shared by
|
||||
// /api/auto and /api/keys/{key}/auto.
|
||||
function lanesToRules(lanes) {
|
||||
const rules = [];
|
||||
sortState.lanes.forEach((lane, li) =>
|
||||
lane.models.forEach((it) => {
|
||||
(lanes || []).forEach((lane, li) =>
|
||||
(lane.models || []).forEach((it) => {
|
||||
const m = it.meta;
|
||||
rules.push({
|
||||
model: it.id,
|
||||
@ -4048,16 +4159,7 @@
|
||||
});
|
||||
}),
|
||||
);
|
||||
// each kind persists to its own chain; the other kind's saved rules
|
||||
// are left untouched (omitting the field means "no change")
|
||||
const body = { rules: undefined, image_rules: undefined };
|
||||
if (sortState.kind === "image") body.image_rules = rules;
|
||||
else body.rules = rules;
|
||||
await api("/api/auto", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return rules;
|
||||
}
|
||||
function scrAddModal() {
|
||||
const cur = { quota: 0, period: "", hours: 0 };
|
||||
@ -4674,6 +4776,8 @@
|
||||
<button class="ghost small" onclick="copyText('${escAttr(k.key)}')">${t("kCopy")}</button>
|
||||
<span class="grow"></span>
|
||||
<span class="muted">${fmtCreated(k.created_at)}</span>
|
||||
<button class="ghost small" title="${escAttr(t("kAutoChainTitle"))}"
|
||||
onclick="openKeyAutoModal('${escAttr(k.key)}','${escAttr(k.name || "")}')">${t("kAutoChain")}</button>
|
||||
<button class="ghost small errc" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || "")}')">${t("kDel")}</button>
|
||||
<button class="ghost small errc" title="${escAttr(t("kDel"))}" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || "")}')">×</button>
|
||||
</div>
|
||||
@ -5085,6 +5189,116 @@
|
||||
document.body.appendChild(wrap);
|
||||
$("#kc-name").focus();
|
||||
}
|
||||
// Per-key AUTO chain editor. Kept deliberately simple: a plain table of
|
||||
// slots rather than a drag canvas, because an admin usually either
|
||||
// mirrors the global chain or deletes a few tiers, and the canvas layout
|
||||
// for the model scope list would be overkill here.
|
||||
// Per-key AUTO chain editor. It renders the SAME lane canvas as the
|
||||
// global editor (drag ordering, per-slot quota/period/hours) by pointing
|
||||
// sortState.scope at this key: persistAuto then saves to
|
||||
// /api/keys/{key}/auto instead of /api/auto. A separate reduced editor
|
||||
// would drift from the global one and lose features.
|
||||
async function openKeyAutoModal(key, name) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.id = "modal-wrap";
|
||||
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";
|
||||
wrap.innerHTML = `<div class="card" style="width:900px;max-width:100%">
|
||||
<h2>${esc(t("kAutoChainTitle"))} \u00b7 ${esc(name || key)}</h2>
|
||||
<div class="muted" style="margin-bottom:10px">${esc(t("kAutoChainHint"))}</div>
|
||||
<div id="ka-canvas-host"></div>
|
||||
<p><button class="ghost small" onclick="scrAddModal()">+ ${esc(t("sortAdd"))}</button>
|
||||
<button class="ghost small" onclick="keyAutoInherit()">${esc(t("kAutoChainInherit"))}</button>
|
||||
<button class="small" onclick="keyAutoSave(this)">${esc(t("sortSave"))}</button>
|
||||
<button class="ghost" onclick="keyAutoClose()">${esc(t("mCancel"))}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
// Park the key on the modal so a click that bubbles up cannot hit the
|
||||
// global editor behind it.
|
||||
wrap.addEventListener("click", (ev) => ev.stopPropagation());
|
||||
try {
|
||||
const j = await api("/api/v1/sources");
|
||||
const idx = buildSortIndex(j.sources || []);
|
||||
const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto");
|
||||
keyAutoState.key = key;
|
||||
keyAutoState.inherits = !!cur.inherits;
|
||||
// A key with no own chain shows an empty canvas rather than the
|
||||
// discovery fallback: filling it with every available slot would
|
||||
// suggest those slots are already this user's chain.
|
||||
sortState.scope = key;
|
||||
sortState.kind = "chat";
|
||||
sortState.lanes = buildLanes(idx, cur.auto || [], "chat");
|
||||
sortState.origin = JSON.stringify(sortState.lanes);
|
||||
await loadModelPairs();
|
||||
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" });
|
||||
if (keyAutoState.inherits) keyAutoInheritNotice();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
}
|
||||
const keyAutoState = { key: null, inherits: true };
|
||||
function keyAutoInheritNotice() {
|
||||
const host = $("#ka-canvas-host");
|
||||
if (!host) return;
|
||||
let n = host.querySelector(".ka-inherit-note");
|
||||
if (!n) {
|
||||
n = document.createElement("div");
|
||||
n.className = "muted ka-inherit-note";
|
||||
n.style.margin = "0 0 10px";
|
||||
host.appendChild(n);
|
||||
}
|
||||
n.textContent = t("kAutoChainEmpty");
|
||||
}
|
||||
// "Use the global chain" empties the lanes; saving then PUTs an empty
|
||||
// list, which the server treats as clearing the override.
|
||||
function keyAutoInherit() {
|
||||
keyAutoState.inherits = true;
|
||||
sortState.lanes = [];
|
||||
sortState.origin = "[]";
|
||||
const host = $("#ka-canvas-host");
|
||||
if (host) {
|
||||
const c = document.getElementById("key-auto-canvas");
|
||||
if (c) c.innerHTML = "";
|
||||
}
|
||||
paintSort();
|
||||
keyAutoInheritNotice();
|
||||
}
|
||||
function keyAutoClose() {
|
||||
const m = document.getElementById("modal-wrap");
|
||||
if (m) m.remove();
|
||||
// Leaving scope set would make the global editor save to this key.
|
||||
sortState.scope = null;
|
||||
keyAutoState.key = null;
|
||||
}
|
||||
async function keyAutoSave(btn) {
|
||||
const key = keyAutoState.key;
|
||||
if (!key) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
// An empty lane list means "inherit global" server-side, so the same
|
||||
// PUT both saves a chain and clears the override.
|
||||
const j = await api("/api/keys/" + encodeURIComponent(key) + "/auto", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ auto: lanesToRules(sortState.lanes) }),
|
||||
});
|
||||
if (j.inherits) {
|
||||
toast(t("kAutoChainGlobal"));
|
||||
} else if (!j.slots) {
|
||||
// Persisted, but nothing resolves — say so now instead of letting
|
||||
// the user meet it as a 503 on their next AUTO request.
|
||||
toast(t("kAutoChainBad"));
|
||||
} else {
|
||||
toast(t("sortSaved") + " (" + j.slots + ")");
|
||||
}
|
||||
keyAutoClose();
|
||||
loadKeys();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createKey(btn) {
|
||||
const name = $("#kc-name").value.trim();
|
||||
if (!name) {
|
||||
|
||||
127
internal/gateway/ui_key_auto_test.go
Normal file
127
internal/gateway/ui_key_auto_test.go
Normal file
@ -0,0 +1,127 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Per-key AUTO chain UI contract.
|
||||
//
|
||||
// The per-key editor deliberately REUSES the global lane canvas instead of
|
||||
// shipping a second, simpler editor. That is a design choice with teeth: a
|
||||
// reduced copy would lack drag ordering and per-slot quota/period/hours, and
|
||||
// the two would drift apart silently. These checks pin the sharing, because
|
||||
// the only symptom of un-sharing is "the per-key editor got worse" — no error,
|
||||
// just a smaller feature set that nobody notices until they use it.
|
||||
func TestUIPerKeyAutoWiring(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
|
||||
// The admin key list must offer the editor, otherwise the whole feature is
|
||||
// unreachable from the UI. Assert the *call site* inside keyCanvasHtml,
|
||||
// not just that the functions exist: removing the button leaves every
|
||||
// definition intact, and that is exactly the regression to catch.
|
||||
kcStart := strings.Index(src, "function keyCanvasHtml")
|
||||
if kcStart < 0 {
|
||||
t.Fatal("keyCanvasHtml is gone — cannot check the AUTO chain button")
|
||||
}
|
||||
kcEnd := strings.Index(src[kcStart:], "\n }")
|
||||
if kcEnd < 0 {
|
||||
t.Fatal("could not delimit keyCanvasHtml")
|
||||
}
|
||||
if !strings.Contains(src[kcStart:kcStart+kcEnd], "openKeyAutoModal(") {
|
||||
t.Fatal("per-key AUTO chain button is missing from the key card header")
|
||||
}
|
||||
for _, fn := range []string{
|
||||
"function openKeyAutoModal",
|
||||
"function keyAutoInherit",
|
||||
"function keyAutoSave",
|
||||
"function keyAutoClose",
|
||||
} {
|
||||
if !strings.Contains(src, fn) {
|
||||
t.Fatalf("UI is missing %s — the editor cannot function", fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The per-key editor must render the SAME canvas the global editor uses.
|
||||
// If this regresses, the per-key chain silently loses drag ordering and
|
||||
// per-slot quota/period/hours while still looking like it works.
|
||||
func TestUIPerKeyAutoReusesGlobalCanvas(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
|
||||
// One canvas implementation, two mount ids.
|
||||
if !strings.Contains(src, "function renderSortEditor(") {
|
||||
t.Fatal("no shared canvas renderer — the per-key editor must reuse renderSortEditor")
|
||||
}
|
||||
if !strings.Contains(src, `canvasId: "scr-canvas"`) {
|
||||
t.Fatal("the global editor no longer mounts the shared canvas")
|
||||
}
|
||||
if !strings.Contains(src, `canvasId: "key-auto-canvas"`) {
|
||||
t.Fatal("the per-key editor does not mount the shared canvas")
|
||||
}
|
||||
// Canvas lookup must consider both ids, or drag/drop silently no-ops on
|
||||
// whichever surface is not #scr-canvas.
|
||||
if !strings.Contains(src, "function sortCanvasEl(") ||
|
||||
!strings.Contains(src, `getElementById("key-auto-canvas")`) {
|
||||
t.Fatal("sortCanvasEl must resolve both canvas ids")
|
||||
}
|
||||
// The rules<->lanes conversion is shared too, so the per-key editor sends
|
||||
// exactly the fields the server understands (quota/period/hours included).
|
||||
for _, fn := range []string{"function buildLanes(", "function lanesToRules("} {
|
||||
if !strings.Contains(src, fn) {
|
||||
t.Fatalf("%s must be shared between the global and per-key editors", fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Saving must route through persistAuto's scope switch, not a hand-rolled
|
||||
// request, or the two editors will serialize rules differently.
|
||||
func TestUIPerKeyAutoSavesThroughScope(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
if !strings.Contains(src, "sortState.scope = key") {
|
||||
t.Fatal("openKeyAutoModal must set sortState.scope so the save targets the key")
|
||||
}
|
||||
if !strings.Contains(src, "sortState.scope = null") {
|
||||
t.Fatal("closing the modal must clear sortState.scope, or the GLOBAL editor would save to this key")
|
||||
}
|
||||
if !strings.Contains(src, "if (sortState.scope)") {
|
||||
t.Fatal("persistAuto must branch on sortState.scope")
|
||||
}
|
||||
if !strings.Contains(src, `"/auto"`) {
|
||||
t.Fatal("per-key editor must call the /api/keys/{key}/auto endpoint")
|
||||
}
|
||||
// An empty lane list is how "inherit the global chain" is expressed.
|
||||
if !strings.Contains(src, "JSON.stringify({ auto: lanesToRules(sortState.lanes) })") {
|
||||
t.Fatal("keyAutoSave must PUT lanesToRules(sortState.lanes) verbatim")
|
||||
}
|
||||
}
|
||||
|
||||
// Both languages need the strings, otherwise a non-Chinese admin sees raw
|
||||
// i18n keys in the modal.
|
||||
func TestUIPerKeyAutoHasBothLanguages(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
for _, k := range []string{
|
||||
"kAutoChainTitle", "kAutoChainHint", "kAutoChainInherit",
|
||||
"kAutoChainEmpty", "kAutoChainGlobal", "kAutoChainBad",
|
||||
} {
|
||||
n := strings.Count(src, k+`: "`)
|
||||
if n < 2 {
|
||||
t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The statistics dashboard must print the window it actually covers. Calendar
|
||||
// periods are not nested — "this week" starts on its Monday and therefore
|
||||
// reaches into the previous month early in a month, making its total LARGER
|
||||
// than "this month". That is correct arithmetic, but without the dates on
|
||||
// screen it reads as a bug, and it has been reported as one.
|
||||
func TestUIStatsShowsPeriodWindow(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
if !strings.Contains(src, `id="period-range"`) {
|
||||
t.Fatal("the stats dashboard has no element for the period window range")
|
||||
}
|
||||
if n := strings.Count(src, "periodRange"); n < 2 {
|
||||
t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n)
|
||||
}
|
||||
}
|
||||
@ -779,6 +779,34 @@ type DiskEntry struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Hooks int `json:"hooks"`
|
||||
// Pages lists the UI page ids the plugin contributes. The desktop panel
|
||||
// shows it so an operator can tell at a glance that a plugin actually
|
||||
// renders something, without opening its source.
|
||||
Pages []string `json:"pages,omitempty"`
|
||||
}
|
||||
|
||||
// pageNames returns the plugin's contributed UI page ids, de-duplicated.
|
||||
// A plugin may declare `page` or `pages` or both, and the same id can appear
|
||||
// twice; the loader's merge keeps the first, so this mirrors that order.
|
||||
func pageNames(p *Plugin) []string {
|
||||
if p == nil || p.UI == nil {
|
||||
return nil
|
||||
}
|
||||
declared := make([]*UIPage, 0, len(p.UI.Pages)+1)
|
||||
if p.UI.Page != nil {
|
||||
declared = append(declared, p.UI.Page)
|
||||
}
|
||||
declared = append(declared, p.UI.Pages...)
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, pg := range declared {
|
||||
if pg == nil || seen[pg.PageID] {
|
||||
continue
|
||||
}
|
||||
seen[pg.PageID] = true
|
||||
out = append(out, pg.PageID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// OnDisk lists every .lua file in the plugin directory with its load state.
|
||||
@ -810,6 +838,10 @@ func (ps *Plugins) OnDisk() []DiskEntry {
|
||||
de.Version = p.Info.Version
|
||||
de.Description = p.Info.Description
|
||||
de.Hooks = len(p.Hooks)
|
||||
// A plugin may declare a single page or several; both forms end
|
||||
// up in Pages (see the merge at load time), so reporting that one
|
||||
// field covers either.
|
||||
de.Pages = pageNames(p)
|
||||
} else {
|
||||
// On disk but not in the running set: either it failed so badly
|
||||
// that LoadSource never produced a record, or the dir was written
|
||||
|
||||
@ -375,3 +375,73 @@ return p
|
||||
t.Error("a plugin registered for chain_step is not reported as such")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnDiskReportsPages pins that the disk listing carries the plugin's UI
|
||||
// page ids. The desktop plugin panel reads on_disk (not List) and shows the
|
||||
// pages so an operator can see at a glance that a plugin renders something;
|
||||
// without this field the GUI reads p.pages === undefined and the line simply
|
||||
// never appears, which is the kind of omission nobody reports as a bug.
|
||||
func TestOnDiskReportsPages(t *testing.T) {
|
||||
_, ps, _ := newPluginVM(t)
|
||||
|
||||
code := `
|
||||
local p = { name = "paged", version = "1.0.0" }
|
||||
p.ui = { pages = {} }
|
||||
table.insert(p.ui.pages, { page_id = "alpha", title = "Alpha", mount = "<b>a</b>" })
|
||||
table.insert(p.ui.pages, { page_id = "beta", title = "Beta", mount = "<b>b</b>" })
|
||||
return p
|
||||
`
|
||||
if err := loadPlugin(t, ps, "paged", code); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
var found *DiskEntry
|
||||
for _, de := range ps.OnDisk() {
|
||||
if de.Name == "paged" {
|
||||
d := de
|
||||
found = &d
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("paged plugin missing from OnDisk")
|
||||
}
|
||||
if len(found.Pages) != 2 {
|
||||
t.Fatalf("Pages = %v, want [alpha beta]", found.Pages)
|
||||
}
|
||||
if found.Pages[0] != "alpha" || found.Pages[1] != "beta" {
|
||||
t.Fatalf("Pages = %v, want [alpha beta] in declaration order", found.Pages)
|
||||
}
|
||||
|
||||
// The field must survive JSON too — the desktop panel reads the HTTP body,
|
||||
// not the Go struct.
|
||||
b, err := json.Marshal(found)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(b), `"pages":["alpha","beta"]`) {
|
||||
t.Fatalf("json omits pages: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// The single-page form (`ui.page`) must also surface, since a plugin using it
|
||||
// is just as rendered as one using `ui.pages`.
|
||||
func TestOnDiskReportsSinglePage(t *testing.T) {
|
||||
_, ps, _ := newPluginVM(t)
|
||||
code := `
|
||||
local p = { name = "single", version = "1.0.0" }
|
||||
p.ui = { page = { page_id = "solo", title = "Solo", mount = "<b>x</b>" } }
|
||||
return p
|
||||
`
|
||||
if err := loadPlugin(t, ps, "single", code); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
for _, de := range ps.OnDisk() {
|
||||
if de.Name == "single" {
|
||||
if len(de.Pages) != 1 || de.Pages[0] != "solo" {
|
||||
t.Fatalf("Pages = %v, want [solo]", de.Pages)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("single plugin missing from OnDisk")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user