mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
上一版把消息加在请求路径的范围检查上,实测才发现它永远走不到: AUTO 请求先经过 checkQuota(ctx, "AUTO")(在链被读取之前),范围不含 AUTO 时它当场返回旧文案「model \"AUTO\" is not allowed for this key」——那句会 把运维引去查配额,而配额并不是原因。 改为在 checkQuota 内对 AUTO 分流,输出说明真实原因与修法。请求路径那处 换成注释说明为何不再重复。 判据 1 条,断言 checkQuota 函数体内必须同时出现 isAuto(model) 与新文案 ——放在别处不算数。变异(删掉该分支)导致编译失败,被捕获。 生产实测(临时把某密钥范围改成不含 AUTO 再恢复): HTTP 403 model_not_allowed model \"AUTO\" is not in this key's model scope, so it cannot use AUTO; add \"AUTO\" to the key's models or remove the scope restriction Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
396 lines
16 KiB
Go
396 lines
16 KiB
Go
package gateway
|
|
|
|
import (
|
|
"os"
|
|
"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)
|
|
}
|
|
}
|
|
// Both canvases can exist in the DOM at once: the modal is mounted on top of
|
|
// the settings tab, whose #scr-canvas stays alive behind it. Resolving the
|
|
// canvas by document order (whichever comes first) painted the per-key chain
|
|
// into the hidden background tab and left the dialog empty — and worse,
|
|
// overwrote the global chain the user returns to. The lookup must follow
|
|
// sortState.scope instead.
|
|
func TestUISortCanvasResolvesByScope(t *testing.T) {
|
|
src := uiSource(t)
|
|
el := readFuncBody(t, src, "sortCanvasEl")
|
|
if !strings.Contains(el, "if (sortState.scope)") {
|
|
t.Fatal("sortCanvasEl must branch on sortState.scope, not document order")
|
|
}
|
|
if strings.Contains(el, `getElementById("scr-canvas")`) &&
|
|
strings.Index(el, `getElementById("scr-canvas")`) < strings.Index(el, "sortState.scope") {
|
|
t.Fatal("sortCanvasEl returns #scr-canvas before checking scope — the " +
|
|
"per-key editor paints into the global canvas")
|
|
}
|
|
}
|
|
|
|
// A successful save must close the dialog even when the slots do not resolve.
|
|
// The write DID succeed; leaving the modal open makes the user click Save
|
|
// again and again. This regressed once: the !slots branch returned early.
|
|
func TestUIKeyAutoSaveClosesOnSuccess(t *testing.T) {
|
|
src := uiSource(t)
|
|
body := readFuncBody(t, src, "keyAutoSave")
|
|
// keyAutoClose() must be reached on every success path, not only in the
|
|
// happy branch.
|
|
iClose := strings.Index(body, "keyAutoClose()")
|
|
if iClose < 0 {
|
|
t.Fatal("keyAutoSave never calls keyAutoClose — the dialog would stay open")
|
|
}
|
|
iToast := strings.Index(body, "toast(")
|
|
if iToast >= 0 && iClose < iToast {
|
|
t.Fatal("keyAutoClose is called before the toast; the warning must be " +
|
|
"shown first or it is lost with the modal")
|
|
}
|
|
// An early `return` inside a branch bypasses keyAutoClose entirely, so
|
|
// merely finding the call is not enough: every `return` that sits between
|
|
// entering the try block and the close call is a path that leaves the
|
|
// dialog open after a successful write.
|
|
tryStart := strings.Index(body, "try {")
|
|
seg := body[tryStart:iClose]
|
|
for _, line := range strings.Split(seg, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "return;" || strings.HasPrefix(trimmed, "return ") {
|
|
t.Fatal("a `return` before keyAutoClose leaves the dialog open after " +
|
|
"a successful save: " + trimmed)
|
|
}
|
|
}
|
|
}
|
|
|
|
// readFuncBody returns the source of a top-level function by name, using
|
|
// brace matching so nested blocks do not truncate it.
|
|
func readFuncBody(t *testing.T, src, name string) string {
|
|
t.Helper()
|
|
idx := strings.Index(src, "function "+name+"(")
|
|
if idx < 0 {
|
|
t.Fatalf("function %s not found", name)
|
|
}
|
|
start := strings.Index(src[idx:], "{")
|
|
if start < 0 {
|
|
t.Fatalf("function %s has no body", name)
|
|
}
|
|
i := idx + start
|
|
depth := 0
|
|
for j := i; j < len(src); j++ {
|
|
switch src[j] {
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
depth--
|
|
if depth == 0 {
|
|
return src[i : j+1]
|
|
}
|
|
}
|
|
}
|
|
t.Fatalf("function %s body is unterminated", name)
|
|
return ""
|
|
}
|
|
|
|
// The per-key dialog must not share the app-wide #modal-wrap id.
|
|
//
|
|
// Every dialog in this file uses that id, and more than one can be open at
|
|
// once (the "add slot" picker opens ON TOP of this one). Handlers resolve
|
|
// their own dialog via `.closest()` from their button, which works — but this
|
|
// dialog is also looked up from the CANVAS side (sortCanvasEl, keyAutoClose,
|
|
// keyAutoInherit), where there is no button to walk up from.
|
|
// getElementById("#modal-wrap") then returns whichever dialog comes first in
|
|
// the document, i.e. not the one being edited: Cancel closed the wrong box and
|
|
// the canvas could resolve to the picker's.
|
|
func TestUIKeyAutoModalHasOwnID(t *testing.T) {
|
|
src := uiSource(t)
|
|
body := readFuncBody(t, src, "openKeyAutoModal")
|
|
if !strings.Contains(body, `wrap.id = "key-auto-modal"`) {
|
|
t.Fatal("openKeyAutoModal must give its dialog its own id, not the shared " +
|
|
"#modal-wrap (another dialog may be open on top of it)")
|
|
}
|
|
close := readFuncBody(t, src, "keyAutoClose")
|
|
if strings.Contains(close, `"modal-wrap"`) {
|
|
t.Fatal("keyAutoClose resolves the shared #modal-wrap; it would close " +
|
|
"whichever dialog comes first in the document")
|
|
}
|
|
if !strings.Contains(close, `"key-auto-modal"`) {
|
|
t.Fatal("keyAutoClose must resolve the per-key dialog by its own id")
|
|
}
|
|
}
|
|
|
|
// Editing a chain inside the per-key dialog must NOT persist it. That dialog
|
|
// has Save and Cancel, so auto-writing on add/drag/delete meant the change was
|
|
// already committed before Save and Cancel could not undo it.
|
|
//
|
|
// The global page is the opposite: it has always applied edits immediately.
|
|
func TestUIKeyAutoEditsDoNotAutoPersist(t *testing.T) {
|
|
src := uiSource(t)
|
|
body := readFuncBody(t, src, "afterChainEdit")
|
|
if !strings.Contains(body, "if (sortState.scope)") {
|
|
t.Fatal("afterChainEdit must branch on sortState.scope")
|
|
}
|
|
// The scoped branch must return before reaching persistAuto.
|
|
iScope := strings.Index(body, "if (sortState.scope)")
|
|
iReturn := strings.Index(body[iScope:], "return")
|
|
iPersist := strings.Index(body, "persistAuto()")
|
|
if iScope < 0 || iReturn < 0 || iPersist < 0 {
|
|
t.Fatal("afterChainEdit no longer has the expected shape")
|
|
}
|
|
if iScope+iReturn > iPersist {
|
|
t.Fatal("the scoped branch reaches persistAuto() — a per-key edit would " +
|
|
"be written before Save")
|
|
}
|
|
// And it must not call itself (a regex-driven rewrite did exactly that).
|
|
if strings.Contains(body, "afterChainEdit()") {
|
|
t.Fatal("afterChainEdit calls itself: infinite recursion")
|
|
}
|
|
// Every in-canvas edit goes through it, so none of them bypass the check.
|
|
for _, fn := range []string{"scrAddFromForm", "sortScopeSave", "scrDelSlot"} {
|
|
b := readFuncBody(t, src, fn)
|
|
if strings.Contains(b, "persistAuto()") {
|
|
t.Fatalf("%s calls persistAuto() directly, bypassing afterChainEdit", fn)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The stats range line formats its end date from the LAST bucket label.
|
|
// Bucket labels are hourly for the day view ("2026-10-03T09") and daily for
|
|
// week/month ("2026-10-01"), so blindly appending "T00:00:00Z" to the hourly
|
|
// form builds "2026-10-03T09T00:00:00Z" — Date rejects it, toISOString()
|
|
// throws, and the assignment is lost. The symptom is an empty range line on
|
|
// exactly the view people look at most, with no error anywhere.
|
|
func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) {
|
|
src := uiSource(t)
|
|
el := readFuncBody(t, src, "paintStats")
|
|
// It must branch on the label shape instead of concatenating blindly.
|
|
if !strings.Contains(el, `T\d{2}$`) && !strings.Contains(el, `\d{2}-\d{2}T\d{2}`) {
|
|
t.Fatal("paintStats must recognise the hourly bucket label shape " +
|
|
"(YYYY-MM-DDTHH); concatenating a time onto it yields an invalid Date")
|
|
}
|
|
// And it must not contain the naive form that broke it.
|
|
if strings.Contains(el, `bucket + "T00:00:00Z"`) {
|
|
t.Fatal("paintStats still appends T00:00:00Z to the bucket label — " +
|
|
"this throws on the day view and leaves the range line empty")
|
|
}
|
|
}
|
|
|
|
// A key with no chain of its own must open on a COPY OF THE GLOBAL CHAIN, not
|
|
// on an empty canvas. The realistic admin task is "this user gets the global chain
|
|
// minus the sources they must not hit"; starting blank forces retyping every
|
|
// tier against a 226-model picker. This is deliberately NOT the discovery
|
|
// fallback (which dumps every model of every source) — it is the same rules
|
|
// the global chain actually runs.
|
|
func TestUIKeyAutoSeedsFromGlobalChain(t *testing.T) {
|
|
src := uiSource(t)
|
|
body := readFuncBody(t, src, "openKeyAutoModal")
|
|
if !strings.Contains(body, `api("/api/auto")`) {
|
|
t.Fatal("openKeyAutoModal must read the global chain to seed the editor")
|
|
}
|
|
if !strings.Contains(body, "seededFromGlobal") {
|
|
t.Fatal("the editor must remember whether its content came from the " +
|
|
"global chain, so the notice can say so")
|
|
}
|
|
// And it must not fall back to the discovery path that fills 226 models.
|
|
if strings.Contains(body, `buildLanes(idx, [], `) {
|
|
t.Fatal("seeding from an empty rule list triggers buildLanes discovery")
|
|
}
|
|
}
|
|
|
|
// The notice must distinguish "already this key's chain" from "a copy that
|
|
// only becomes its chain on Save" — those two look identical on the canvas and
|
|
// behave very differently if the admin hits Cancel.
|
|
func TestUIKeyAutoNoticeDistinguishesSavedFromCopied(t *testing.T) {
|
|
src := uiSource(t)
|
|
body := readFuncBody(t, src, "keyAutoInheritNotice")
|
|
for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} {
|
|
if !strings.Contains(body, k) {
|
|
t.Fatalf("the notice never uses %s — a copied chain and a saved one "+
|
|
"would look the same", k)
|
|
}
|
|
}
|
|
// Both languages, or a non-Chinese admin sees raw keys.
|
|
for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} {
|
|
if n := strings.Count(src, k+`: "`); n < 2 {
|
|
t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A key whose model scope excludes AUTO cannot use AUTO at all — the scope
|
|
// filter runs before the chain — yet the editor happily lets an admin
|
|
// configure a per-key chain. That combination is easy to create by accident
|
|
// and produces a 403 with no visible cause, so the dialog must say so.
|
|
//
|
|
// We deliberately do NOT widen the scope automatically: silently granting a
|
|
// model the operator did not ask for is worse than a loud warning.
|
|
func TestUIKeyAutoWarnsOnScopeConflict(t *testing.T) {
|
|
src := uiSource(t)
|
|
if !strings.Contains(src, "function keyAutoScopeWarning(") {
|
|
t.Fatal("no scope-conflict check in the per-key editor")
|
|
}
|
|
body := readFuncBody(t, src, "keyAutoScopeWarning")
|
|
if !strings.Contains(body, "AUTO") {
|
|
t.Fatal("keyAutoScopeWarning must test whether the scope lists AUTO")
|
|
}
|
|
// It must not write anything back to the key: reporting only.
|
|
for _, forbidden := range []string{"PUT", "POST", "fetch(", "api("} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("keyAutoScopeWarning calls %s — it must only report the "+
|
|
"conflict, never widen the scope itself", forbidden)
|
|
}
|
|
}
|
|
// Empty scope means unrestricted, so no warning is correct there.
|
|
if !strings.Contains(body, "if (!models.length)") {
|
|
t.Fatal("an unrestricted key (no models) must not be warned")
|
|
}
|
|
// Both languages.
|
|
if n := strings.Count(src, "kAutoChainScopeWarn"); n < 2 {
|
|
t.Fatalf("i18n key kAutoChainScopeWarn appears %d time(s), want zh+en", n)
|
|
}
|
|
}
|
|
|
|
// The scope conflict for AUTO must be reported by checkQuota, because that is
|
|
// the check an AUTO request actually reaches first (it runs before the chain is
|
|
// consulted). Putting the message on the later path made it dead code — the
|
|
// request was rejected one check earlier with the generic "not allowed for this
|
|
// key" wording, which sends an operator looking for a quota that isn't the
|
|
// problem.
|
|
func TestAutoScopeConflictMessageComesFromCheckQuota(t *testing.T) {
|
|
b, err := os.ReadFile("chat.go")
|
|
if err != nil {
|
|
t.Fatalf("read chat.go: %v", err)
|
|
}
|
|
src := string(b)
|
|
i := strings.Index(src, "func (g *Gateway) checkQuota(")
|
|
if i < 0 {
|
|
t.Fatal("checkQuota not found")
|
|
}
|
|
// Body = up to the next top-level func.
|
|
rest := src[i:]
|
|
if j := strings.Index(rest[1:], "\nfunc "); j > 0 {
|
|
rest = rest[:j+1]
|
|
}
|
|
if !strings.Contains(rest, "isAuto(model)") {
|
|
t.Fatal("checkQuota must special-case AUTO: a scope that omits AUTO is " +
|
|
"rejected there before anything else looks at the chain")
|
|
}
|
|
if !strings.Contains(rest, "not in this key's model scope") {
|
|
t.Fatal("checkQuota's AUTO branch must name the scope as the cause")
|
|
}
|
|
}
|