mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
用户明确要求:配额应当是密钥对应的**每个模型的单独配额**,而非整体配额。 ## 语义变更 删除 GWKey.TokenQuota / ReqQuota / Period / Hours(整钥总额)。 ModelScope 新增 ReqQuota —— 请求数配额下沉到每条模型范围。 现在:每条 models[] 各自带 token 配额 + 请求数配额 + 重置周期, 彼此独立。一个模型用满只影响该模型。 ★ 为什么不保留整钥总额:它会让「把 A 模型的额度挪给 B」变成一次全局 重分配;按模型独立计费则每个模型各自可控,运维能直接看出哪个模型在吃预算。 ## 连带改动 - checkQuota 合并 key 级与 scope 级判定;checkKeyQuotaRetry 整体删除 (顺带修掉上轮遗留的双重判定:入口不再先判空再重算) - core:CreateKeyWithQuota / UpdateKeyWithQuota / ApplyQuota 全部删除, 改由 ValidateScopeQuotas 校验每条 scope 的配额 - admin key:scope 上的配额不强制(admin 的 scope 仍限制模型范围, 但不强制配额)—— 否则管理员会把自己锁在门外 - /api/v1/keys 不再回显 key 级配额字段(scope 里已含) - WebUI:删除整钥配额徽标 / 「配额」按钮 / 创建表单的配额组 / putScope 的整钥回传;模型砖块与范围编辑器新增「请求数配额」输入, 徽标显示 `1.0K 77×·1h`(未设配额显示 ∞) ## 判据 - TestOneModelsQuotaDoesNotBlockAnother 是本次核心保证。 ★ 它第一版是**假判据**:m2 从不消耗,key-wide 计数器与 m1 自己的计数器 读数恰好相同,退回 key-wide 仍通过。变异测试抓到后改为「先用 m2 花掉 远超 m1 配额的量,再验证 m1 仍可用」—— 这样两种设计才可区分。 - TestUncappedModelNeverBlocked / TestAdminKeyScopesAreNotEnforced 新增 - UI 契约判据重写:整钥配额界面必须彻底消失(13 个符号)、 scope 编辑器必须往返 req_quota、putScope 只发 scope 列表 - 错误消息点名具体模型(TestKeyAPIRejectionNamesTheModel) - 3/3 变异全被抓 实测(真实进程 + 浏览器):m2 配额 500000 连打 25 次全成功, m1 配额 1000 立即 429「token quota exceeded for "m1" (4315/1000)」, 此后 m2/m3 仍 200。UI:整钥配额元素全为 0,砖块各显配额, 编辑器预填/保存正确,零 JS 异常。
268 lines
9.4 KiB
Go
268 lines
9.4 KiB
Go
package gateway
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The WebUI dialogs all share the id "modal-wrap", and more than one can be
|
|
// open at the same time (the seed-key notice sits on top of the keys page).
|
|
// A save handler that closes "the" modal via $("#modal-wrap") therefore removes
|
|
// whichever one comes FIRST in the document — which is the wrong dialog: the
|
|
// form the user just submitted stays on screen while an unrelated dialog
|
|
// vanishes.
|
|
//
|
|
// This is the same class of bug the api() contract test pins: reviewing inline
|
|
// JS by eye does not catch it, and the symptom ("the dialog did not close")
|
|
// points away from the cause.
|
|
|
|
// modalCloseRe finds every `$(...)`-style lookup of the shared modal id.
|
|
var modalCloseRe = regexp.MustCompile(`\$\("#modal-wrap"\)`)
|
|
|
|
// closestModalRe finds the safe form: resolve the dialog from the clicked
|
|
// button instead of from the document.
|
|
var closestModalRe = regexp.MustCompile(`\.closest\("#modal-wrap"\)`)
|
|
|
|
func TestUIDialogClosesItselfNotTheFirstModal(t *testing.T) {
|
|
// Strip comments first: prose that *names* the unsafe pattern (as the fix's
|
|
// own comment does) would otherwise be flagged as a violation.
|
|
code := stripJSComments(uiSource(t))
|
|
|
|
for _, m := range modalCloseRe.FindAllStringIndex(code, -1) {
|
|
after := code[m[1]:]
|
|
stmtEnd := strings.Index(after, ";")
|
|
if stmtEnd < 0 || stmtEnd > 200 {
|
|
continue
|
|
}
|
|
stmt := after[:stmtEnd]
|
|
if strings.Contains(stmt, ".remove()") {
|
|
line := 1 + strings.Count(code[:m[0]], "\n")
|
|
t.Errorf("line %d closes the first #modal-wrap in the document, not its own dialog:\n\t%s",
|
|
line, strings.TrimSpace(stmt))
|
|
}
|
|
}
|
|
}
|
|
|
|
// stripJSComments removes // line comments and /* block */ comments from JS
|
|
// embedded in the UI document. It is deliberately simple: the document is our
|
|
// own source, and a false negative here only means the check is silent.
|
|
func stripJSComments(src string) string {
|
|
var out strings.Builder
|
|
lines := strings.Split(src, "\n")
|
|
inBlock := false
|
|
for _, ln := range lines {
|
|
trimmed := strings.TrimSpace(ln)
|
|
if inBlock {
|
|
if strings.Contains(ln, "*/") {
|
|
inBlock = false
|
|
}
|
|
continue
|
|
}
|
|
if strings.HasPrefix(trimmed, "/*") {
|
|
if !strings.Contains(ln, "*/") {
|
|
inBlock = true
|
|
}
|
|
continue
|
|
}
|
|
if i := strings.Index(ln, "//"); i >= 0 {
|
|
before := ln[:i]
|
|
if strings.Count(before, `"`)%2 == 0 && strings.Count(before, "'")%2 == 0 {
|
|
ln = before
|
|
}
|
|
}
|
|
out.WriteString(ln)
|
|
out.WriteString("\n")
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
// Every handler that closes a dialog must do it one of the two safe ways.
|
|
func TestUIDialogClosuresGoThroughSafePaths(t *testing.T) {
|
|
src := stripJSComments(uiSource(t))
|
|
for _, fn := range []string{
|
|
"downloadStatsCsv", "downloadKeysCsv", "saveSource", "saveTemplate",
|
|
"scrAddFromForm", "sortScopeSave", "scopeSave", "createKey",
|
|
} {
|
|
body, ok := jsFunctionBody(src, fn)
|
|
if !ok {
|
|
t.Errorf("%s not found", fn)
|
|
continue
|
|
}
|
|
if !strings.Contains(body, `closest("#modal-wrap")`) && !strings.Contains(body, "closeTopModal()") {
|
|
t.Errorf("%s closes a dialog with neither .closest nor closeTopModal", fn)
|
|
}
|
|
}
|
|
if !strings.Contains(src, "function closeTopModal(") {
|
|
t.Error("closeTopModal helper is missing")
|
|
}
|
|
}
|
|
|
|
// The helper must pick the LAST dialog (the topmost one the user sees), not the
|
|
// first — that inversion is the whole bug.
|
|
func TestUICloseTopModalTakesTheLast(t *testing.T) {
|
|
body, ok := jsFunctionBody(uiSource(t), "closeTopModal")
|
|
if !ok {
|
|
t.Fatal("closeTopModal not found")
|
|
}
|
|
if !strings.Contains(body, "all.length - 1") {
|
|
t.Errorf("closeTopModal does not take the last dialog:\n\t%s", oneLine(body))
|
|
}
|
|
}
|
|
|
|
// Handlers that resolve their dialog from a button must actually receive one:
|
|
// a signature without the parameter means the .closest() silently yields null
|
|
// and the save leaves its form stranded.
|
|
func TestUIDialogHandlersReceiveTheirButton(t *testing.T) {
|
|
src := stripJSComments(uiSource(t))
|
|
for _, fn := range []string{
|
|
"downloadStatsCsv", "saveSource", "scrAddFromForm", "sortScopeSave",
|
|
"scopeSave", "createKey",
|
|
} {
|
|
body, ok := jsFunctionBody(src, fn)
|
|
if !ok {
|
|
t.Errorf("%s not found", fn)
|
|
continue
|
|
}
|
|
if !strings.Contains(body, "closest(\"#modal-wrap\")") {
|
|
continue // uses closeTopModal only
|
|
}
|
|
sig := body[:strings.Index(body, ")")+1]
|
|
if !strings.Contains(sig, "btn") {
|
|
t.Errorf("%s uses .closest(\"#modal-wrap\") but its signature %s has no button parameter —\n"+
|
|
"the lookup would always be null and the form would never close", fn, oneLine(sig))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Quotas belong to the scope entries, so putScope only has to ship the scope
|
|
// list — the caps travel inside it. What must NOT come back is a key-wide
|
|
// total: it would be a second budget able to drift out of sync with the models
|
|
// it is supposed to cover.
|
|
func TestUIPutScopeShipsOnlyScopeQuotas(t *testing.T) {
|
|
body, ok := jsFunctionBody(uiSource(t), "putScope")
|
|
if !ok {
|
|
t.Fatal("putScope not found")
|
|
}
|
|
code := stripJSComments(body)
|
|
if !strings.Contains(code, "models:") || !strings.Contains(code, "scopes") {
|
|
t.Error("putScope must ship the scope list the caps live in")
|
|
}
|
|
for _, gone := range []string{"kquota", "kreqquota", "kperiod", "khours"} {
|
|
if strings.Contains(code, gone) {
|
|
t.Errorf("putScope still references the removed key-wide quota field %q", gone)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A model brick carries its own budgets, and the scope editor reads and writes
|
|
// both of them: dropping req_quota on the round trip would silently lift a
|
|
// request cap every time someone edited a token cap.
|
|
func TestUIScopeEditorRoundTripsBothQuotas(t *testing.T) {
|
|
src := stripJSComments(uiSource(t))
|
|
for _, fn := range []string{"scopeHtml", "readScopes", "scopeEdit", "scopeSave", "scopeQuotaBadge"} {
|
|
if _, ok := jsFunctionBody(src, fn); !ok {
|
|
t.Errorf("%s not found in the UI source", fn)
|
|
}
|
|
}
|
|
for _, fn := range []string{"scopeHtml", "readScopes", "scopeSave", "scopeQuotaBadge"} {
|
|
body, ok := jsFunctionBody(src, fn)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !strings.Contains(body, "req_quota") && !strings.Contains(body, "reqquota") {
|
|
t.Errorf("%s does not carry req_quota — a request cap would be lost on edit", fn)
|
|
}
|
|
}
|
|
if form, ok := jsFunctionBody(src, "scopeEdit"); ok && !strings.Contains(form, "sc-reqs") {
|
|
t.Error("the scope editor has no request-quota input")
|
|
}
|
|
}
|
|
|
|
// The whole key-wide quota surface must be gone from the UI: a badge or a
|
|
// button reading a field the server no longer has would render "undefined" or
|
|
// silently do nothing.
|
|
func TestUIHasNoKeyWideQuotaSurface(t *testing.T) {
|
|
src := uiSource(t)
|
|
for _, gone := range []string{
|
|
"keyCapBadges", "keyQuotaEdit", "keyQuotaSave",
|
|
"kq-tokens", "kq-reqs", "kq-period", "kq-hours",
|
|
"kc-tokens", "kc-reqs", "kc-period", "kc-hours",
|
|
"data-kquota", "data-kreqquota", "data-kperiod", "data-khours",
|
|
} {
|
|
if strings.Contains(src, gone) {
|
|
t.Errorf("UI still references the removed key-wide quota surface %q", gone)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Existence of the strings is not enough: the badge has to READ the scope's
|
|
// fields, and the editor has to read back what the brick writes. A field can
|
|
// be present in the source and still never reach the screen — e.g. left in a
|
|
// dead branch, or read from a data attribute the writer never sets.
|
|
func TestUIKeyQuotaDataflowIsLive(t *testing.T) {
|
|
src := stripJSComments(uiSource(t))
|
|
|
|
badge, ok := jsFunctionBody(src, "scopeQuotaBadge")
|
|
if !ok {
|
|
t.Fatal("scopeQuotaBadge not found")
|
|
}
|
|
for _, field := range []string{"m.token_quota", "m.req_quota", "m.period"} {
|
|
if !strings.Contains(badge, field) {
|
|
t.Errorf("scopeQuotaBadge does not read %q — the cap would never show on the model brick", field)
|
|
}
|
|
}
|
|
|
|
brick, ok := jsFunctionBody(src, "scopeHtml")
|
|
if !ok {
|
|
t.Fatal("scopeHtml not found")
|
|
}
|
|
edit, ok := jsFunctionBody(src, "scopeEdit")
|
|
if !ok {
|
|
t.Fatal("scopeEdit not found")
|
|
}
|
|
brickCode, editCode := stripJSComments(brick), stripJSComments(edit)
|
|
reader := stripJSComments(mustBody(t, src, "readScopes"))
|
|
for _, ds := range []string{"quota", "reqquota", "period", "hours"} {
|
|
if !strings.Contains(brickCode, "data-"+ds+"=") {
|
|
t.Errorf("scopeHtml does not write data-%s, so the editor has nothing to prefill", ds)
|
|
}
|
|
// the editor pre-fills through readScopes(), which is what walks the
|
|
// bricks' data attributes — check the reader, not the form
|
|
if !strings.Contains(reader, "dataset."+ds) {
|
|
t.Errorf("readScopes does not read dataset.%s — editing a brick would save zeros over it", ds)
|
|
}
|
|
}
|
|
// and the form must actually consume what readScopes produced
|
|
for _, field := range []string{"sc.token_quota", "sc.req_quota", "sc.period", "sc.hours"} {
|
|
if !strings.Contains(editCode, field) {
|
|
t.Errorf("scopeEdit does not prefill from %q", field)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustBody(t *testing.T, src, fn string) string {
|
|
t.Helper()
|
|
b, ok := jsFunctionBody(src, fn)
|
|
if !ok {
|
|
t.Fatalf("%s not found", fn)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// The quota period vocabulary must match the server's, or the UI can offer a
|
|
// value the API rejects.
|
|
func TestUIQuotaPeriodsMatchServer(t *testing.T) {
|
|
src := uiSource(t)
|
|
for _, p := range []string{`value=""`, `value="hour"`, `value="week"`, `value="month"`, `value="nhour"`} {
|
|
if !strings.Contains(src, p) {
|
|
t.Errorf("UI period select is missing %s", p)
|
|
}
|
|
}
|
|
for _, p := range []string{`"hour"`, `"week"`, `"month"`, `"nhour"`} {
|
|
if !strings.Contains(src, `if (p === `+p+`)`) && !strings.Contains(src, `=== `+p+`)`) {
|
|
t.Errorf("periodText() does not describe %s, so a badge would omit the window", p)
|
|
}
|
|
}
|
|
}
|