gateway: each gateway key can have its own limit, a token total and/or an estimated cost per day, week or month, and a spent key is refused before any provider is asked (margbug01 on #585, v0.1.671: 每个网关密钥都能设置自己的用量上限) — a gateway key named who called and the Usage page grouped its calls, but nothing held a key to an amount, so one client shared on the network could spend a subscription's whole window. A key now carries limit {period day|week|month, tokens, cost, cacheReads} in caller-keys.json, set with the new "limit-key" change. Windows are calendar ones in local time (from midnight, from Monday, from the 1st) and a call counts in the window it started in. Tokens are a call's uncached input, output and cache writes, plus cache reads when the limit says so; cost is an estimate at the Usage page's effective prices (usage.NewCoster), a call with no known price adding none and counted as unpriced. What a key used is summed from usage.jsonl (usage.Visit), so it survives a restart; the new internal/budget keeps the sums in memory per window and appendUsage writes a key's record through budget.Append, which appends and counts under one lock so a window read meanwhile never counts a call twice. Before a keyed POST reaches the mux, keyLimited reserves for it: its body's size in tokens (4 bytes a token) plus the key's mean output per call this window, at the window's cost per token or the named model's input price; it is let in while used + reserved is under the cap, so requests sent at once overshoot by about one call, and the reservation is released when the request ends, a streamed reply being settled then with the usage its vendor reported. A spent key gets a 429 in its API's error shape (Anthropic, OpenAI chat/responses, Gemini) naming the key, the cap and the reset time, with Retry-After, X-Should-Retry: false and X-Magpie-Limit-Reset, recorded as a rejected call (gateway_key_limit); other keys go on. Requests from this computer without a gateway key are not limited; a key used from this computer is. count_tokens and bridge callbacks are never held. GET /v1/magpie/limit tells a key its own status. Gateway keys rows show a Limit badge (what is used of it, per what, Spent in red) and a line under a limited key (used of limit, left, resets / refused until, in flight; unpriced calls in its title); Limit opens an editor under the row (window, tokens with k/m/万/亿, cost in US$, Count cache reads too, what is counted) staged until Save, marked unsaved, Cancel/Escape dropping it, a bad amount disabling Save, No limit removing it; en and zh; no left-border accent; no click scrolls. The CLI gains magpie gateway-key limit <id> [off | day|week|month --tokens N --cost USD --cache-reads] and a LIMIT column in list. TestKeyLimitRefusesBeforeUpstream (two calls within 700 tokens, the third a 429 the fake upstream never sees, message/type/headers, the rejected record, Anthropic's shape on /v1/messages, another key and a keyless loopback request let in, still refused after budget.Forget as after a restart, a raised and a removed limit let in, /v1/magpie/limit), TestKeyLimitHoldsParallelRequests (20 at once against 2000 tokens: some refused, at most one call over, no reservation left; without the reservation all 20 got in and 7350 were used), TestKeyLimitWindowResets, TestLimitWindow, TestLimitValid and TestGatewayKeyLimitCLI are new; gateway-key-limit.test.cjs (WebKit, en and zh) is new and passes, as do gateway-caller-keys, gateway-connect-keys, api-key-theme and click-scroll (whose request-pick case flaked once under a parallel run and passes alone, as on main). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState's known TempDir-cleanup flake aside).

This commit is contained in:
yetone committed 2026-10-03 00:18:16 +08:00
1 parent b2ac45b0bc
commit 0879894af3
21 files changed
+1342 -15

No files matched your search

+22
View File
@@ -187,8 +187,30 @@ magpie gateway-key add "Remote laptop" # prints the new credential once
magpie gateway-key list # ids, names, enabled state and masked keys
magpie gateway-key rotate <id> # prints the replacement; identity stays the same
magpie gateway-key remove <id> # revokes remote access
magpie gateway-key limit <id> week --tokens 2m --cost 5 # its own limit
magpie gateway-key limit <id> # limit, used, left and reset
magpie gateway-key limit <id> off # no limit
```
Each gateway key can have its own **limit**: a token total, an estimated
cost in US$, or both, per day, week or month (calendar windows in local
time: from midnight, from Monday, from the 1st). Set it with **Limit** on
the key's row (saved with Save) or `magpie gateway-key limit`; the row shows
what the key has used, what is left and when it resets. Tokens counted are a
call's uncached input, output and cache writes, plus cache reads when **Count
cache reads too** is on. Cost is an estimate at the Usage page's prices; a
call with no known price adds none. A call counts in the window it started
in. Once a key is spent, its requests are refused before any provider is
asked, with a 429 in the API's own error shape that names the key, the
limit and the reset time, plus `Retry-After`; other keys are unaffected. The
counts are read from the usage log, so they survive a restart. A request in
flight holds a reservation (its body's size in tokens plus the key's mean
output per call), so requests sent at once overshoot by about one call; a
streamed reply is settled when it ends with the usage its vendor reported.
A key can read its own status with `GET /v1/magpie/limit`. Requests from
this computer that send no gateway key are not limited; a gateway key used
from this computer is.
While LAN sharing is enabled, remote requests require an enabled gateway key
sent as Bearer, `x-api-key`, `x-goog-api-key` or `?key=`. Loopback remains
permissive: any token works, including a stale or disabled gateway key.
+143 -4
View File
@@ -4,10 +4,14 @@ import (
"fmt"
"io"
"os"
"slices"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/budget"
)
func gatewayKeys(args []string) error { return gatewayKeysTo(os.Stdout, args) }
@@ -18,8 +22,12 @@ func gatewayKeysTo(out io.Writer, args []string) error {
args = []string{"list"}
}
action := args[0]
if action == "limit" {
access.MigrateLegacyLANKeyBestEffort()
return gatewayKeyLimit(out, args[1:])
}
if (action == "list" && len(args) != 1) || (action != "list" && len(args) != 2) {
return fmt.Errorf("usage: magpie gateway-key list | add <name> | rotate <id> | remove <id>")
return fmt.Errorf("usage: magpie gateway-key list | add <name> | rotate <id> | remove <id> | limit <id> [off | day|week|month [--tokens N] [--cost USD] [--cache-reads]]")
}
switch action {
case "list", "add", "rotate", "remove":
@@ -33,18 +41,19 @@ func gatewayKeysTo(out io.Writer, args []string) error {
return err
}
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tSTATE\tGATEWAY KEY")
fmt.Fprintln(w, "ID\tNAME\tSTATE\tGATEWAY KEY\tLIMIT")
now := time.Now()
for _, k := range keys {
state := "enabled"
if k.Off {
state = "disabled"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", k.ID, strings.Map(func(r rune) rune {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", k.ID, strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return ' '
}
return r
}, k.Name), state, k.Masked)
}, k.Name), state, k.Masked, limitWords(k, now))
}
return w.Flush()
}
@@ -63,3 +72,133 @@ func gatewayKeysTo(out io.Writer, args []string) error {
}
return err
}
// gatewayKeyLimit shows or sets a key's limit (#585):
//
// magpie gateway-key limit <id> what it has used of its limit
// magpie gateway-key limit <id> off no limit
// magpie gateway-key limit <id> day|week|month [--tokens N] [--cost USD] [--cache-reads]
func gatewayKeyLimit(out io.Writer, args []string) error {
usage := fmt.Errorf("usage: magpie gateway-key limit <id> [off | day|week|month [--tokens N] [--cost USD] [--cache-reads]]")
if len(args) == 0 {
return usage
}
id := args[0]
if len(args) > 1 {
var lim *access.Limit
if args[1] != "off" {
lim = &access.Limit{Period: args[1]}
if !slices.Contains(access.Periods, lim.Period) {
return fmt.Errorf("a limit's period is day, week or month, not %q", lim.Period)
}
rest := args[2:]
for i := 0; i < len(rest); i++ {
flag, val, has := strings.Cut(rest[i], "=")
next := func() (string, error) {
if has {
return val, nil
}
if i+1 >= len(rest) {
return "", fmt.Errorf("%s needs a value", flag)
}
i++
return rest[i], nil
}
switch flag {
case "--tokens":
v, err := next()
if err != nil {
return err
}
n, err := parseTokens(v)
if err != nil || n < 0 {
return fmt.Errorf("--tokens is a count, like 1000000 or 2m: %q", v)
}
lim.Tokens = int64(n)
case "--cost":
v, err := next()
if err != nil {
return err
}
if lim.Cost, err = strconv.ParseFloat(strings.TrimPrefix(v, "$"), 64); err != nil {
return fmt.Errorf("--cost is US dollars, like 5 or 2.50: %q", v)
}
case "--cache-reads":
lim.CacheReads = true
default:
return usage
}
}
if !lim.Limited() {
return fmt.Errorf("say --tokens, --cost or both, or off for no limit")
}
} else if len(args) > 2 {
return usage
}
if _, err := access.Update("limit-key", access.Change{Key: id, Limit: lim}); err != nil {
return err
}
}
keys, err := access.List()
if err != nil {
return err
}
i := slices.IndexFunc(keys, func(k access.Key) bool { return k.ID == id })
if i < 0 {
return fmt.Errorf("Key not found")
}
k, now := keys[i], time.Now()
st := budget.Of(k, now)
if st == nil {
_, err = fmt.Fprintf(out, "%s: no limit\n", k.Name)
return err
}
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, "Key\t%s (%s)\n", k.Name, k.ID)
fmt.Fprintf(w, "Window\tthis %s, %s – %s (local time)\n", st.Period, st.Start.Format("2006-01-02 15:04"), st.Reset.Format("2006-01-02 15:04"))
if st.TokenLimit > 0 {
counted := "input, output and cache writes"
if st.CacheReads {
counted += ", and cache reads"
}
fmt.Fprintf(w, "Tokens\t%d used of %d, %d left (%s)\n", st.Tokens, st.TokenLimit, st.TokensLeft, counted)
} else {
fmt.Fprintf(w, "Tokens\t%d used, no cap\n", st.Tokens)
}
unpriced := ""
if st.Unpriced > 0 {
unpriced = fmt.Sprintf(", %d calls without a known price not in it", st.Unpriced)
}
if st.CostLimit > 0 {
fmt.Fprintf(w, "Cost\t$%.2f used of $%.2f, $%.2f left (estimate at Usage prices%s)\n", st.Cost, st.CostLimit, st.CostLeft, unpriced)
} else {
fmt.Fprintf(w, "Cost\t$%.2f used, no cap (estimate%s)\n", st.Cost, unpriced)
}
state := "open"
if st.Spent {
state = "spent: requests are refused until " + st.Reset.Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "State\t%s\n", state)
return w.Flush()
}
// limitWords is a key's limit in the list: "-" for none, else what it has
// used of it, "1200/1000000 tokens/day".
func limitWords(k access.Key, now time.Time) string {
st := budget.Of(k, now)
if st == nil {
return "-"
}
var parts []string
if st.TokenLimit > 0 {
parts = append(parts, fmt.Sprintf("%d/%d tokens", st.Tokens, st.TokenLimit))
}
if st.CostLimit > 0 {
parts = append(parts, fmt.Sprintf("$%.2f/$%.2f", st.Cost, st.CostLimit))
}
s := strings.Join(parts, " ") + " per " + st.Period
if st.Spent {
s += " (spent until " + st.Reset.Format("01-02 15:04") + ")"
}
return s
}
+52
View File
@@ -79,3 +79,55 @@ func TestGatewayKeyListWithReadOnlyMigration(t *testing.T) {
t.Fatal("read-only migration blocked CLI list", out.String(), err)
}
}
// magpie gateway-key limit sets, shows and takes off a key's limit, and
// list says what each key has used of its own (#585).
func TestGatewayKeyLimitCLI(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", os.Getenv("HOME"))
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := settings.Save(settings.Settings{LAN: true}); err != nil {
t.Fatal(err)
}
call := func(args ...string) (string, error) {
var out bytes.Buffer
err := gatewayKeysTo(&out, append([]string{"gateway-key"}, args...))
return out.String(), err
}
if _, err := call("add", "Phone"); err != nil {
t.Fatal(err)
}
keys, _ := access.List()
id := keys[0].ID
if got, err := call("limit", id); err != nil || !strings.Contains(got, "no limit") {
t.Fatal(got, err)
}
got, err := call("limit", id, "week", "--tokens", "2m", "--cost=5", "--cache-reads")
if err != nil {
t.Fatal(err)
}
keys, _ = access.List()
if l := keys[0].Limit; l == nil || l.Period != "week" || l.Tokens != 2_000_000 || l.Cost != 5 || !l.CacheReads {
t.Fatalf("limit kept as %+v", keys[0].Limit)
}
for _, want := range []string{"0 used of 2000000, 2000000 left", "cache reads", "$0.00 used of $5.00", "State", "open"} {
if !strings.Contains(got, want) {
t.Fatalf("limit shows %q, without %q", got, want)
}
}
if got, _ := call("list"); !strings.Contains(got, "LIMIT") || !strings.Contains(got, "0/2000000 tokens $0.00/$5.00 per week") {
t.Fatal(got)
}
for _, args := range [][]string{{"limit"}, {"limit", id, "hour", "--tokens", "5"}, {"limit", id, "day"}, {"limit", id, "day", "--tokens", "lots"},
{"limit", id, "day", "--cost"}, {"limit", id, "day", "--what"}, {"limit", "missing", "day", "--tokens", "5"}, {"limit", id, "off", "extra"}} {
if _, err := call(args...); err == nil {
t.Error("accepted", args)
}
}
if got, err := call("limit", id, "off"); err != nil || !strings.Contains(got, "no limit") {
t.Fatal(got, err)
}
if keys, _ = access.List(); keys[0].Limit != nil {
t.Fatal("off kept", keys[0].Limit)
}
}
+18 -4
View File
@@ -37,9 +37,15 @@ type Key struct {
LAN bool `json:"lan,omitempty"` // default key and durable migration marker
Secret string `json:"secret,omitempty"`
Masked string `json:"masked,omitempty"`
// Limit is the key's own budget (#585); nil for none.
Limit *Limit `json:"limit,omitempty"`
}
type Identity struct{ KeyID, KeyName string }
// Identity is the gateway key a request came with, and its budget.
type Identity struct {
KeyID, KeyName string
Limit *Limit
}
type contextKey struct{}
func WithIdentity(ctx context.Context, who Identity) context.Context {
@@ -111,6 +117,8 @@ func random(n int) (string, error) {
type Change struct {
Key string `json:"key"`
Name string `json:"name"`
// Limit is what "limit-key" sets; nil or Unlimited takes the limit off.
Limit *Limit `json:"limit,omitempty"`
}
// Update writes the named key store atomically.
@@ -159,6 +167,12 @@ func Update(action string, in Change) (string, error) {
keys[i].Secret = secret
case "rename-key":
keys[i].Name = name
case "limit-key":
lim, err := in.Limit.Valid()
if err != nil {
return "", err
}
keys[i].Limit = lim
case "on-key", "off-key":
keys[i].Off = action == "off-key"
case "remove-key":
@@ -173,7 +187,7 @@ func Update(action string, in Change) (string, error) {
default:
return "", fmt.Errorf("unknown key action %q", action)
}
if defaultKey && action != "remove-key" && action != "rename-key" {
if defaultKey && action != "remove-key" && action != "rename-key" && action != "limit-key" {
mirror = &keys[i]
}
}
@@ -220,7 +234,7 @@ func Authenticate(secret string) (Identity, bool) {
if k.Off {
return Identity{}, false
}
return Identity{k.ID, k.Name}, true
return Identity{k.ID, k.Name, k.Limit}, true
}
}
s := settings.Load()
@@ -228,7 +242,7 @@ func Authenticate(secret string) (Identity, bool) {
// fall back after either durable marker exists, or to a revoked mirror.
if s.LAN && s.LANKeyID == "" && s.LANKey != "" && !strings.HasPrefix(s.LANKey, revokedLANPrefix) && !slices.ContainsFunc(keys, func(k Key) bool { return k.LAN }) && subtle.ConstantTimeCompare([]byte(secret), []byte(s.LANKey)) == 1 {
k := legacyLANKey(s.LANKey)
return Identity{k.ID, k.Name}, true
return Identity{KeyID: k.ID, KeyName: k.Name}, true
}
return Identity{}, false
}
+69
View File
@@ -0,0 +1,69 @@
package access
import (
"errors"
"math"
"time"
)
// Limit is a gateway key's budget for a calendar window in local time
// (#585): a day from midnight, a week from Monday's midnight, a month
// from the 1st's. Tokens counts each call's uncached input, output
// (reasoning included) and cache writes, and its cache reads too when
// CacheReads is set; Cost is an estimate in US dollars at the prices
// magpie shows on the Usage page, not a vendor's bill. A cap of 0 is no
// cap; a limit with neither is no limit.
type Limit struct {
Period string `json:"period"`
Tokens int64 `json:"tokens,omitempty"`
Cost float64 `json:"cost,omitempty"`
CacheReads bool `json:"cacheReads,omitempty"`
}
// Periods are the windows a limit can be set for.
var Periods = []string{"day", "week", "month"}
// Limited: l caps something.
func (l *Limit) Limited() bool { return l != nil && (l.Tokens > 0 || l.Cost > 0) }
// Valid is l as it is kept: nil for no limit, an error for one that
// can't be.
func (l *Limit) Valid() (*Limit, error) {
if l == nil {
return nil, nil
}
if l.Tokens < 0 || l.Cost < 0 || math.IsNaN(l.Cost) || math.IsInf(l.Cost, 0) {
return nil, errors.New("A limit can't be negative")
}
if !l.Limited() {
return nil, nil
}
v := *l
if v.Period == "" {
v.Period = "day"
}
switch v.Period {
case "day", "week", "month":
default:
return nil, errors.New("A limit's period is day, week or month")
}
v.Cost = math.Round(v.Cost*1e4) / 1e4
return &v, nil
}
// Window is the calendar window of period that now is in, in now's
// location: its start and when the next begins.
func Window(period string, now time.Time) (start, reset time.Time) {
y, m, d := now.Date()
loc := now.Location()
switch period {
case "week":
back := (int(now.Weekday()) + 6) % 7 // days since Monday
start = time.Date(y, m, d-back, 0, 0, 0, 0, loc)
return start, time.Date(y, m, d-back+7, 0, 0, 0, 0, loc)
case "month":
return time.Date(y, m, 1, 0, 0, 0, 0, loc), time.Date(y, m+1, 1, 0, 0, 0, 0, loc)
default:
return time.Date(y, m, d, 0, 0, 0, 0, loc), time.Date(y, m, d+1, 0, 0, 0, 0, loc)
}
}
+48
View File
@@ -0,0 +1,48 @@
package access
import (
"testing"
"time"
)
func TestLimitWindow(t *testing.T) {
loc := time.FixedZone("X", 8*3600)
at := func(s string) time.Time {
v, err := time.ParseInLocation("2006-01-02 15:04", s, loc)
if err != nil {
t.Fatal(err)
}
return v
}
for _, c := range []struct{ period, now, start, reset string }{
{"day", "2026-10-03 23:59", "2026-10-03 00:00", "2026-10-04 00:00"},
{"day", "2026-12-31 00:00", "2026-12-31 00:00", "2027-01-01 00:00"},
{"week", "2026-10-03 12:00", "2026-09-28 00:00", "2026-10-05 00:00"}, // a Saturday: from Monday
{"week", "2026-10-04 23:00", "2026-09-28 00:00", "2026-10-05 00:00"}, // Sunday is the week's last day
{"week", "2026-10-05 00:00", "2026-10-05 00:00", "2026-10-12 00:00"},
{"month", "2026-10-31 18:00", "2026-10-01 00:00", "2026-11-01 00:00"},
{"month", "2026-12-15 08:00", "2026-12-01 00:00", "2027-01-01 00:00"},
} {
start, reset := Window(c.period, at(c.now))
if !start.Equal(at(c.start)) || !reset.Equal(at(c.reset)) {
t.Errorf("%s at %s: %s – %s", c.period, c.now, start, reset)
}
}
}
func TestLimitValid(t *testing.T) {
if l, err := (&Limit{Period: "day"}).Valid(); l != nil || err != nil {
t.Fatal("no cap is no limit", l, err)
}
if l, err := (*Limit)(nil).Valid(); l != nil || err != nil {
t.Fatal(l, err)
}
if l, err := (&Limit{Tokens: 5}).Valid(); err != nil || l.Period != "day" {
t.Fatal("a period is a day unless said", l, err)
}
for _, bad := range []Limit{{Period: "hour", Tokens: 1}, {Period: "day", Tokens: -1}, {Period: "day", Cost: -2}} {
if _, err := bad.Valid(); err == nil {
t.Error("accepted", bad)
}
}
}
+302
View File
@@ -0,0 +1,302 @@
// Package budget holds each gateway key to its own limit (#585): what a
// key has used in its window is summed from usage.jsonl, the records the
// Usage page shows, so it survives a restart; a request is let in only
// while the key has something left after what its requests in flight are
// expected to use, and those are settled with what the call recorded.
//
// The rules:
// - A call counts in the window it started in (its record's time), and
// windows are calendar ones in local time (access.Window).
// - Tokens are a call's uncached input, output and cache writes, and
// its cache reads when the limit says so; cost is an estimate at the
// Usage page's prices, and a call with no known price adds none.
// - A request refused before any upstream (no model, a refusal like
// this one) uses nothing; a failed call counts the tokens it recorded.
// - A streamed reply is settled when it ends, with the usage its vendor
// reported; one cut off before that counts what was recorded.
// - While a request is in flight it holds a reservation: its body's
// size in tokens (4 bytes a token) plus the key's mean output per
// call this window. A request is let in while used + reserved is
// under the cap, so parallel requests overshoot by about one call.
// - Only requests that came with a gateway key are limited: from
// another computer one is required; on this one a request without one
// (or with a token that is no key) isn't limited.
package budget
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/usage"
)
// sums is what a key's calls came to in a window.
type sums struct {
start time.Time
calls int
input, output, cacheRead, cacheWrite int64
cost float64
unpriced int
}
func (s *sums) add(r usage.Record, cost float64, priced bool) {
s.calls++
s.input += int64(r.Input)
s.output += int64(r.Output)
s.cacheRead += int64(r.CacheRead)
s.cacheWrite += int64(r.CacheWrite)
s.cost += cost
if !priced {
s.unpriced++
}
}
func (s *sums) tokens(l *access.Limit) int64 {
n := s.input + s.output + s.cacheWrite
if l != nil && l.CacheReads {
n += s.cacheRead
}
return n
}
type held struct {
n int
tokens int64
cost float64
}
var state = struct {
sync.Mutex
sums map[string]*sums // by usage.Path() and key
held map[string]*held
}{sums: map[string]*sums{}, held: map[string]*held{}}
func slot(keyID string) string { return usage.Path() + "\x00" + keyID }
// counts reports whether r is a call a key's budget counts.
func counts(r usage.Record) bool { return !r.IsRejected() && r.Computer == "" }
// sumsFor is keyID's sums for the window starting at start, read from
// usage.jsonl the first time. Called with state held.
func sumsFor(keyID string, start time.Time) *sums {
k := slot(keyID)
if s := state.sums[k]; s != nil && s.start.Equal(start) {
return s
}
s := read(keyID, start)
state.sums[k] = s
return s
}
func read(keyID string, start time.Time) *sums {
s := &sums{start: start}
cost := usage.NewCoster()
usage.Visit(start, func(r usage.Record) {
if r.CallerKeyID == keyID && counts(r) && !r.Time.Before(start) {
c, ok := cost(r)
s.add(r, c, ok)
}
})
return s
}
// Forget drops what is kept in memory, as a restart would.
func Forget() {
state.Lock()
defer state.Unlock()
state.sums = map[string]*sums{}
state.held = map[string]*held{}
}
// Append writes rec to usage.jsonl and, for a key's call, counts it
// against the key's window. The two happen together so a window read from
// the file meanwhile counts the call once.
func Append(rec usage.Record) {
if rec.CallerKeyID == "" {
usage.Append(rec)
return
}
state.Lock()
defer state.Unlock()
if rec.Time.IsZero() {
rec.Time = time.Now()
}
usage.Append(rec)
s := state.sums[slot(rec.CallerKeyID)]
if s == nil || !counts(rec) || rec.Time.Before(s.start) {
return
}
c, ok := usage.NewCoster()(rec)
s.add(rec, c, ok)
}
// Status is a key's limit and what it has used of it.
type Status struct {
Period string `json:"period"`
Start time.Time `json:"start"`
Reset time.Time `json:"reset"`
Calls int `json:"calls"`
Tokens int64 `json:"tokens"`
Cost float64 `json:"cost"`
Unpriced int `json:"unpriced,omitempty"`
// InFlight requests, and the tokens and cost they are reserved at
InFlight int `json:"inFlight,omitempty"`
Reserved int64 `json:"reserved,omitempty"`
ReservedCost float64 `json:"reservedCost,omitempty"`
TokenLimit int64 `json:"tokenLimit,omitempty"`
CostLimit float64 `json:"costLimit,omitempty"`
CacheReads bool `json:"cacheReads,omitempty"`
// TokensLeft and CostLeft are what is left of each cap, 0 at most
TokensLeft int64 `json:"tokensLeft"`
CostLeft float64 `json:"costLeft"`
// Spent: the key is refused until Reset
Spent bool `json:"spent"`
}
func status(keyID string, l *access.Limit, s *sums, h *held, reset time.Time) Status {
st := Status{Period: l.Period, Start: s.start, Reset: reset, Calls: s.calls, Tokens: s.tokens(l), Cost: s.cost, Unpriced: s.unpriced,
TokenLimit: l.Tokens, CostLimit: l.Cost, CacheReads: l.CacheReads}
if h != nil {
st.InFlight, st.Reserved, st.ReservedCost = h.n, h.tokens, h.cost
}
if l.Tokens > 0 {
st.TokensLeft = max(l.Tokens-st.Tokens, 0)
st.Spent = st.Tokens >= l.Tokens
}
if l.Cost > 0 {
st.CostLeft = max(l.Cost-st.Cost, 0)
st.Spent = st.Spent || st.Cost >= l.Cost
}
return st
}
// Of is key k's status now, nil when it has no limit. It reads the file
// afresh rather than the gateway's sums, so it is right for a gateway
// served by another process too.
func Of(k access.Key, now time.Time) *Status {
if !k.Limit.Limited() {
return nil
}
start, reset := access.Window(k.Limit.Period, now)
s := read(k.ID, start)
state.Lock()
h := state.held[slot(k.ID)]
st := status(k.ID, k.Limit, s, h, reset)
state.Unlock()
return &st
}
// Refusal is why a key's request was turned away.
type Refusal struct {
Status
Key string
}
// Error says it as a client is told: the key, what it used of which
// cap, and when it resets.
func (r *Refusal) Error() string {
what := ""
if r.TokenLimit > 0 && r.Tokens+r.Reserved >= r.TokenLimit {
counted := "input, output and cache-write tokens"
if r.CacheReads {
counted = "input, output and cache tokens"
}
what = fmt.Sprintf("%d of its %d %s", r.Tokens, r.TokenLimit, counted)
} else {
what = fmt.Sprintf("$%.2f of its $%.2f estimated cost", r.Cost, r.CostLimit)
}
if !r.Spent {
what += fmt.Sprintf(", with %d requests in flight holding the rest", r.InFlight)
}
return fmt.Sprintf("magpie gateway key %q has used %s for this %s; it resets at %s",
r.Key, what, r.Period, r.Reset.Format("2006-01-02 15:04 MST"))
}
// RetryAfter is how long until the window resets, in whole seconds.
func (r *Refusal) RetryAfter(now time.Time) int {
return max(int(r.Reset.Sub(now).Seconds()+0.999), 1)
}
// Reserve lets a request of who's in, holding a reservation for it until
// the release it returns is called, or refuses it. body is the request's
// size in bytes (-1 unknown) and model the model it named, for a cost cap
// before the key has a price of its own this window.
func Reserve(who access.Identity, body int64, model string, now time.Time) (release func(), refused *Refusal) {
l := who.Limit
if who.KeyID == "" || !l.Limited() {
return func() {}, nil
}
start, reset := access.Window(l.Period, now)
state.Lock()
defer state.Unlock()
k := slot(who.KeyID)
s := sumsFor(who.KeyID, start)
h := state.held[k]
if h == nil {
h = &held{}
state.held[k] = h
}
used := s.tokens(l)
if (l.Tokens > 0 && used+h.tokens >= l.Tokens) || (l.Cost > 0 && s.cost+h.cost >= l.Cost) {
return nil, &Refusal{Status: status(who.KeyID, l, s, h, reset), Key: who.KeyName}
}
est := max(body, 0) / 4
if s.calls > 0 {
est += s.output / int64(s.calls)
}
var cost float64
if l.Cost > 0 {
if used > 0 && s.cost > 0 {
cost = float64(est) * s.cost / float64(used)
} else if p, ok := priceOf(model); ok {
cost = float64(est) * p
}
}
h.n++
h.tokens += est
h.cost += cost
once := sync.Once{}
return func() {
once.Do(func() {
state.Lock()
defer state.Unlock()
h.n--
h.tokens -= est
h.cost -= cost
if h.n == 0 && state.held[k] == h {
delete(state.held, k)
}
})
}, nil
}
// priceOf is the input price per token of model when it names its
// provider ("provider/model").
func priceOf(model string) (float64, bool) {
p, m, ok := strings.Cut(model, "/")
if !ok || p == "" || m == "" {
return 0, false
}
pr, ok := provider.EffectivePriceIn(settings.Load(), p, m)
if !ok {
return 0, false
}
return pr.Input / 1e6, true
}
// ModelOf is the model a JSON request body names, "" for none.
func ModelOf(body []byte) string {
var v struct {
Model string `json:"model"`
}
if json.Unmarshal(body, &v) != nil {
return ""
}
return v.Model
}
+2 -1
View File
@@ -443,6 +443,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /v1/magpie/quotas", s.quotas)
mux.HandleFunc("GET /v1/magpie/route", s.sessionRoute)
mux.HandleFunc("GET /v1/magpie/concurrency", s.concurrency)
mux.HandleFunc("GET /v1/magpie/limit", s.keyLimit)
mux.HandleFunc("POST /v1/chat/completions", s.handle(provider.Chat))
mux.HandleFunc("POST /chat/completions", s.handle(provider.Chat))
mux.HandleFunc("POST /v1/responses", s.handle(provider.Responses))
@@ -468,7 +469,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, provider.Chat, http.StatusNotFound, "magpie serves /v1/chat/completions, /v1/responses, /v1/messages, /v1/systemone, /v1/images/generations, /v1/images/edits, /v1/videos and /v1beta/models/*")
})
return s.counted(callerGuard(withCaller(mux)))
return s.counted(callerGuard(withCaller(keyLimited(mux))))
}
func (s *Server) info(w http.ResponseWriter, r *http.Request) {
+83
View File
@@ -0,0 +1,83 @@
package gateway
import (
"bytes"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/budget"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/usage"
)
// keyLimited holds a gateway key's requests to its limit (#585, see
// package budget for the rules): one that would go over is refused with a
// 429 before any provider is asked, saying the key, the limit and when it
// resets; one let in holds a reservation until it ends. Reads, the
// bridge's tool callbacks and counting tokens cost nothing and pass.
func keyLimited(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
who := access.Caller(r.Context())
if !who.Limit.Limited() || r.Method != http.MethodPost || strings.HasPrefix(r.URL.Path, "/_magpie/") || strings.HasSuffix(r.URL.Path, "/count_tokens") {
next.ServeHTTP(w, r)
return
}
size, model := r.ContentLength, ""
if who.Limit.Cost > 0 || size < 0 {
b, err := io.ReadAll(r.Body)
r.Body.Close()
if err != nil {
writeError(w, protoOfPath(r.URL.Path), http.StatusBadRequest, err.Error())
return
}
r.Body = io.NopCloser(bytes.NewReader(b))
size, model = int64(len(b)), budget.ModelOf(b)
}
now := time.Now()
release, refused := budget.Reserve(who, size, model, now)
if refused != nil {
msg := refused.Error()
w.Header().Set("Retry-After", strconv.Itoa(refused.RetryAfter(now)))
// the Anthropic and OpenAI SDKs would otherwise retry a 429
w.Header().Set("X-Should-Retry", "false")
w.Header().Set("X-Magpie-Limit-Reset", refused.Reset.Format(time.RFC3339))
writeError(w, protoOfPath(r.URL.Path), http.StatusTooManyRequests, msg)
c := callerOf(r)
appendUsage(r, usage.Record{Time: now, Agent: c.agent, Via: c.via, Requested: model, Status: http.StatusTooManyRequests, Rejected: true,
Error: msg, ErrType: "gateway_key_limit", Endpoint: r.URL.Path, Session: sessionOf(r.Header), NativeSession: nativeSessionOf(r.Header)})
return
}
defer release()
next.ServeHTTP(w, r)
})
}
// protoOfPath is the API a path is of, for an error said its way.
func protoOfPath(path string) provider.Protocol {
switch {
case strings.HasSuffix(path, "/messages"):
return provider.Anthropic
case strings.HasPrefix(path, "/v1beta/"):
return provider.Gemini
case strings.HasSuffix(path, "/responses"):
return provider.Responses
}
return provider.Chat
}
// keyLimit answers GET /v1/magpie/limit: the calling gateway key's limit,
// what it has used and when it resets; {"limited":false} for a key with
// none or a request without one.
func (s *Server) keyLimit(w http.ResponseWriter, r *http.Request) {
who := access.Caller(r.Context())
if who.KeyID == "" || !who.Limit.Limited() {
writeJSON(w, 200, map[string]any{"limited": false, "key": who.KeyName})
return
}
st := budget.Of(access.Key{ID: who.KeyID, Name: who.KeyName, Limit: who.Limit}, time.Now())
writeJSON(w, 200, map[string]any{"limited": true, "key": who.KeyName, "limit": st})
}
+228
View File
@@ -0,0 +1,228 @@
package gateway
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/budget"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/usage"
)
// limitedUpstream is a provider whose every call uses 300 input and 50
// output tokens, counting the calls it is sent.
func limitedUpstream(t *testing.T, delay time.Duration) *atomic.Int64 {
t.Helper()
var calls atomic.Int64
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
time.Sleep(delay)
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":300,"completion_tokens":50}}`)
}))
t.Cleanup(up.Close)
if err := provider.Save(provider.Provider{ID: "plan", Name: "Plan", Key: "upstream-secret", Chat: up.URL + "/v1", Models: []string{"m1"}}); err != nil {
t.Fatal(err)
}
return &calls
}
func setLimit(t *testing.T, id string, l *access.Limit) {
t.Helper()
if _, err := access.Update("limit-key", access.Change{Key: id, Limit: l}); err != nil {
t.Fatal(err)
}
}
// A key past its limit is refused before any provider is asked, with a
// 429 that names the key, the limit and when it resets; another key and a
// keyless request from this computer go on; what was used survives a
// restart; raising the limit lets it in again (#585).
func TestKeyLimitRefusesBeforeUpstream(t *testing.T) {
fresh(t)
budget.Forget()
calls := limitedUpstream(t, 0)
keys, secrets := newCaller(t, "Capped", "Other")
setLimit(t, keys[0].ID, &access.Limit{Period: "day", Tokens: 700})
h := New().Handler()
call := func(secret, path, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", path, strings.NewReader(body))
if secret != "" {
r.Header.Set("Authorization", "Bearer "+secret)
} else {
r.RemoteAddr = "127.0.0.1:5000"
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
for i := range 2 {
if w := call(secrets[0], "/v1/chat/completions", chatReq); w.Code != 200 {
t.Fatal("within the limit", i, w.Code, w.Body.String())
}
}
if calls.Load() != 2 {
t.Fatal(calls.Load())
}
w := call(secrets[0], "/v1/chat/completions", chatReq)
if w.Code != http.StatusTooManyRequests {
t.Fatal("spent key let in", w.Code, w.Body.String())
}
if calls.Load() != 2 {
t.Fatal("a spent key's request reached the provider")
}
var e struct {
Error struct{ Message, Type string }
}
json.Unmarshal(w.Body.Bytes(), &e)
if e.Error.Type != "rate_limit_error" || !strings.Contains(e.Error.Message, `"Capped"`) || !strings.Contains(e.Error.Message, "700 of its 700") || !strings.Contains(e.Error.Message, "resets at") {
t.Fatal("refusal says", w.Body.String())
}
if ra, _ := strconv.Atoi(w.Header().Get("Retry-After")); ra <= 0 || ra > 86400+3600 || w.Header().Get("X-Should-Retry") != "false" {
t.Fatal("headers", w.Header())
}
rec := lastUsage(t)
if !rec.Rejected || rec.CallerKeyID != keys[0].ID || rec.ErrType != "gateway_key_limit" || rec.Status != 429 {
t.Fatalf("refusal recorded as %+v", rec)
}
// said in Anthropic's shape on its API
w = call(secrets[0], "/v1/messages", `{"model":"plan/m1","max_tokens":10,"messages":[{"role":"user","content":"hi"}]}`)
var a struct {
Type string
Error struct{ Type string }
}
json.Unmarshal(w.Body.Bytes(), &a)
if w.Code != 429 || a.Type != "error" || a.Error.Type != "rate_limit_error" || calls.Load() != 2 {
t.Fatal("anthropic refusal", w.Code, w.Body.String())
}
if w := call(secrets[1], "/v1/chat/completions", chatReq); w.Code != 200 {
t.Fatal("another key held to the spent one's limit", w.Code)
}
if w := call("", "/v1/chat/completions", chatReq); w.Code != 200 {
t.Fatal("a keyless loopback request was limited", w.Code)
}
// the gateway restarts: the window is read back from usage.jsonl
budget.Forget()
if w := call(secrets[0], "/v1/chat/completions", chatReq); w.Code != 429 {
t.Fatal("a restart forgot what the key used", w.Code)
}
st := budget.Of(access.Key{ID: keys[0].ID, Limit: &access.Limit{Period: "day", Tokens: 700}}, time.Now())
if st == nil || st.Tokens != 700 || st.Calls != 2 || !st.Spent || st.TokensLeft != 0 {
t.Fatalf("status %+v", st)
}
setLimit(t, keys[0].ID, &access.Limit{Period: "day", Tokens: 1050})
if w := call(secrets[0], "/v1/chat/completions", chatReq); w.Code != 200 {
t.Fatal("a raised limit still refused", w.Code)
}
setLimit(t, keys[0].ID, nil)
if w := call(secrets[0], "/v1/chat/completions", chatReq); w.Code != 200 {
t.Fatal("no limit still refused", w.Code)
}
// the key asks after its own limit
setLimit(t, keys[0].ID, &access.Limit{Period: "week", Tokens: 5000})
r := httptest.NewRequest("GET", "/v1/magpie/limit", nil)
r.Header.Set("Authorization", "Bearer "+secrets[0])
lw := httptest.NewRecorder()
h.ServeHTTP(lw, r)
var got struct {
Limited bool
Limit budget.Status
}
json.Unmarshal(lw.Body.Bytes(), &got)
if !got.Limited || got.Limit.Tokens != 1400 || got.Limit.TokensLeft != 3600 || got.Limit.Period != "week" {
t.Fatal("limit endpoint", lw.Body.String())
}
}
// N requests at once can't all pass a check made before any is counted:
// those in flight hold a reservation, so the key goes over by about one
// call, not by N.
func TestKeyLimitHoldsParallelRequests(t *testing.T) {
fresh(t)
budget.Forget()
calls := limitedUpstream(t, 80*time.Millisecond)
keys, secrets := newCaller(t, "Busy")
setLimit(t, keys[0].ID, &access.Limit{Period: "month", Tokens: 2000})
h := New().Handler()
// a body of about the 300 tokens the call uses
body := `{"model":"plan/m1","messages":[{"role":"user","content":"` + strings.Repeat("x", 1130) + `"}]}`
call := func() int {
r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))
r.Header.Set("Authorization", "Bearer "+secrets[0])
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
if call() != 200 {
t.Fatal("first call")
}
var wg sync.WaitGroup
var ok, refused atomic.Int64
for range 20 {
wg.Go(func() {
switch call() {
case 200:
ok.Add(1)
case 429:
refused.Add(1)
}
})
}
wg.Wait()
st := budget.Of(access.Key{ID: keys[0].ID, Limit: &access.Limit{Period: "month", Tokens: 2000}}, time.Now())
if st.Tokens > 2000+350 || refused.Load() == 0 || ok.Load()+refused.Load() != 20 {
t.Fatalf("20 at once: %d let in, %d refused, %d tokens used of 2000, %d upstream calls", ok.Load(), refused.Load(), st.Tokens, calls.Load())
}
if st.InFlight != 0 || st.Reserved != 0 {
t.Fatal("reservations left behind", st)
}
if call() != 429 {
t.Fatal("spent key let in")
}
}
// A window ends at the calendar's: what was used today counts nothing
// tomorrow, and a call before the window started never counts.
func TestKeyLimitWindowResets(t *testing.T) {
fresh(t)
budget.Forget()
limitedUpstream(t, 0)
keys, secrets := newCaller(t, "Daily")
lim := &access.Limit{Period: "day", Tokens: 350}
setLimit(t, keys[0].ID, lim)
// yesterday's call
budget.Append(usage.Record{Time: time.Now().AddDate(0, 0, -1), Provider: "plan", Model: "m1", Input: 5000, Status: 200, CallerKeyID: keys[0].ID})
h := New().Handler()
call := func() int {
r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(chatReq))
r.Header.Set("Authorization", "Bearer "+secrets[0])
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
if call() != 200 {
t.Fatal("yesterday's use counted today")
}
if call() != 429 {
t.Fatal("spent key let in")
}
who := access.Identity{KeyID: keys[0].ID, KeyName: "Daily", Limit: lim}
if _, refused := budget.Reserve(who, 10, "", time.Now()); refused == nil {
t.Fatal("spent today")
}
_, reset := access.Window("day", time.Now())
release, refused := budget.Reserve(who, 10, "", reset.Add(time.Minute))
if refused != nil {
t.Fatal("tomorrow's window still spent", refused)
}
release()
}
+2 -1
View File
@@ -13,6 +13,7 @@ import (
"sync"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/budget"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/usage"
@@ -257,7 +258,7 @@ func accountOf(p provider.Provider) string {
func appendUsage(r *http.Request, rec usage.Record) {
who := access.Caller(r.Context())
rec.CallerKeyID, rec.CallerKeyName = who.KeyID, who.KeyName
usage.Append(rec)
budget.Append(rec)
}
// lanKeyed marks a request from another machine that carried the key.
+14
View File
@@ -1654,6 +1654,20 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.acc .plan { flex: none; }
#gatewayKeys .acc { min-width: 0; }
#gatewayKeys .acc .plan { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
/* a gateway key's own limit (#585): the badge, what it used under the
row, and its editor there */
#gatewayKeys .acc.with-limit { flex-wrap: wrap; row-gap: 3px; }
#gatewayKeys .amodels.limit.spent { color: var(--red); background: var(--red-soft); }
#gatewayKeys .key-limit { flex-basis: 100%; min-width: 0; padding: 0 0 2px 25px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
#gatewayKeys .key-limit.spent { color: var(--red); }
#gatewayKeys .key-limit-ed .key-limit { padding-left: 0; }
#gatewayKeys .klf { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px; }
#gatewayKeys .klf-field { display: grid; gap: 3px; min-width: 0; }
#gatewayKeys .klf-name { font-size: 11px; color: var(--muted); }
#gatewayKeys .klf input, #gatewayKeys .klf select { width: 100%; min-width: 0; height: 28px; padding: 0 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--card); color: var(--fg); font: inherit; font-size: 12px; }
#gatewayKeys .klf-check { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--fg-2); }
#gatewayKeys .klf-err { color: var(--red); }
@media (max-width: 520px) { #gatewayKeys .klf { grid-template-columns: 1fr; } }
/* the Settings row's picker: the key row's badge, always shown */
.row.pref .proto.pick { flex: none; margin-left: auto; display: inline-flex; align-items: center; gap: 3px; height: 24px; padding: 0 6px 0 9px; font: inherit; font-size: 12px; color: var(--fg-2); border: 0; border-radius: 7px; background: var(--pill); white-space: nowrap; cursor: default; transition: background .12s, color .12s; }
.row.pref .proto.pick svg { opacity: .7; transition: transform .15s var(--ease); }
+1 -1
View File
@@ -3309,7 +3309,7 @@ function renderGatewayView() {
renderGateway();
$("#gatewayKeysBlock").hidden = !providers.gateway.lan;
if (!providers.gateway.lan) gatewayKeyDraft = null;
if (gatewayKeyDraft === null && !$("#gatewayKeys .rename-in")) renderGatewayKeys();
if (gatewayKeyDraft === null && gatewayLimit === null && !$("#gatewayKeys .rename-in")) renderGatewayKeys();
renderConnect();
renderGatewayModels();
renderActivity();
+159 -1
View File
@@ -1,6 +1,9 @@
// Caller keys use the same account list and edit controls as provider keys.
let gatewayKeys = null, gatewayKeyDraft = null, gatewayKeysBusy = false;
let gatewayKeysRead = 0;
// gatewayLimit is the key whose limit editor is open, and its draft
// (#585): what is picked in it is sent only by its Save.
let gatewayLimit = null;
let connectURL = "", connectKeyID = "", connectSecret = null;
function connectPick(id, label, text, options, value, choose) {
@@ -56,6 +59,7 @@ async function gatewayKeyAction(action, body) {
connectSecret = null;
gatewayKeys = out.keys || [];
gatewayKeyDraft = null;
if (action === "limit-key" || !gatewayKeys.some((k) => k.id === gatewayLimit?.id)) gatewayLimit = null;
renderGatewayKeys();
if (view === "gateway" && providers) selectConnectKey(connectKeyID);
return out;
@@ -150,7 +154,9 @@ function renderGatewayKeys() {
rm.onclick = () => askGatewayKey(k, false);
const rotate = el("button", "text quiet", t("Rotate key"));
rotate.onclick = () => askGatewayKey(k, true);
row.append(tick, gatewayRename(k), el("span", "plan mono", k.masked), el("span", "grow"), copyCallerKeyBtn(k), rotate, rm);
row.append(tick, gatewayRename(k), el("span", "plan mono", k.masked), el("span", "grow"), gatewayLimitBadge(k), copyCallerKeyBtn(k), rotate, rm);
const under = gatewayLimit?.id === k.id ? gatewayLimitEditor(k) : k.used ? gatewayLimitLine(k) : null;
if (under) row.classList.add("with-limit"), row.append(under);
list.append(row);
}
if (gatewayKeyDraft !== null) list.append(gatewayKeyForm());
@@ -185,3 +191,155 @@ function askGatewayKey(k, rotate) {
$("#modal").classList.add("lib");
cancel.focus({ preventScroll: true });
}
// ---- a gateway key's own limit (#585) ----
const limitPeriods = ["day", "week", "month"];
const limitPer = { day: "per day", week: "per week", month: "per month" };
// limitTokens reads a count as typed: 2000000, 2,000,000, 500k, 2M, 1.5m,
// 100万, 1亿; "" for none, NaN for what isn't one.
function limitTokens(v) {
const s = String(v ?? "").trim().toLowerCase().replace(/[,\s_]/g, "");
if (!s) return 0;
const m = s.match(/^(\d+(?:\.\d+)?)(k|m|b|万|亿)?$/);
if (!m) return NaN;
const mult = { k: 1e3, m: 1e6, b: 1e9, "万": 1e4, "亿": 1e8 }[m[2]] || 1;
return Math.round(parseFloat(m[1]) * mult);
}
function limitCost(v) {
const s = String(v ?? "").trim().replace(/^[$$]/, "");
if (!s) return 0;
return /^\d+(\.\d+)?$/.test(s) ? parseFloat(s) : NaN;
}
const usd = (n) => "$" + (n >= 100 ? n.toFixed(0) : n.toFixed(2));
// gatewayLimitBadge opens a key's limit editor: "Limit" on hover when it
// has none, else what it has used, "1.2M / 2M · day", always shown.
function gatewayLimitBadge(k) {
const u = k.used;
const b = el("button", "amodels limit" + (u ? " set" : "") + (u?.spent ? " spent" : "") + (gatewayLimit?.id === k.id ? " open" : ""));
b.type = "button";
if (!u) b.textContent = t("Limit");
else {
const parts = [];
if (u.tokenLimit) parts.push(fmtN(u.tokens) + " / " + fmtN(u.tokenLimit));
if (u.costLimit) parts.push(usd(u.cost) + " / " + usd(u.costLimit));
b.textContent = (u.spent ? t("Spent") + " · " : "") + parts.join(" · ") + " · " + t(limitPer[u.period] || u.period);
}
b.title = t(u ? "Change this key's limit" : "Set a limit for this key");
b.setAttribute("aria-label", b.title);
b.setAttribute("aria-expanded", gatewayLimit?.id === k.id ? "true" : "false");
b.onclick = (e) => {
e.preventDefault();
if (gatewayLimit?.id === k.id) gatewayLimit = null;
else {
const l = k.limit || {};
gatewayLimit = { id: k.id, period: l.period || "day", tokens: l.tokens ? String(l.tokens) : "", cost: l.cost ? String(l.cost) : "", cacheReads: !!l.cacheReads };
}
renderGatewayKeys();
};
return b;
}
// gatewayLimitLine says what a limited key has used, has left and when
// it resets, under its row.
function gatewayLimitLine(k) {
const u = k.used;
const line = el("div", "key-limit" + (u.spent ? " spent" : ""));
const parts = [];
if (u.tokenLimit) parts.push(t("{used} of {limit} tokens · {left} left", { used: fmtN(u.tokens), limit: fmtN(u.tokenLimit), left: fmtN(u.tokensLeft) }));
if (u.costLimit) parts.push(t("{used} of {limit} estimated · {left} left", { used: usd(u.cost), limit: usd(u.costLimit), left: usd(u.costLeft) }));
const at = new Date(u.reset);
parts.push(t(u.spent ? "refused until {when}" : "resets {when}", { when: resetClock(at) }));
if (u.inFlight) parts.push(t(u.inFlight === 1 ? "1 in flight" : "{n} in flight", { n: u.inFlight }));
line.textContent = parts.join(" · ");
line.title = t("Counted from {start}; resets {when}", { start: new Date(u.start).toLocaleString(), when: at.toLocaleString() })
+ (u.unpriced ? "\n" + t("{n} calls without a known price aren't in the cost", { n: u.unpriced }) : "");
return line;
}
// gatewayLimitEditor stages a key's limit: nothing is sent until Save.
function gatewayLimitEditor(k) {
const d = gatewayLimit;
const box = el("div", "acct-models key-limit-ed");
box.onclick = (e) => e.stopPropagation();
const kept = k.limit || {};
const changed = () => d.period !== (kept.period || "day") || limitTokens(d.tokens) !== (kept.tokens || 0)
|| limitCost(d.cost) !== (kept.cost || 0) || d.cacheReads !== !!kept.cacheReads;
const fields = el("div", "klf");
const period = el("select", "klf-period");
period.setAttribute("aria-label", t("Limit window"));
for (const p of limitPeriods) {
const o = el("option", "", t(limitPer[p]));
o.value = p;
period.append(o);
}
period.value = d.period;
const tokens = input(d.tokens, t("No token cap"));
tokens.setAttribute("aria-label", t("Token limit"));
tokens.inputMode = "decimal";
const cost = input(d.cost, t("No cost cap"));
cost.setAttribute("aria-label", t("Estimated cost limit, US$"));
cost.inputMode = "decimal";
const tl = el("label", "klf-field");
tl.append(el("span", "klf-name", t("Tokens")), tokens);
const cl = el("label", "klf-field");
cl.append(el("span", "klf-name", t("Estimated cost, US$")), cost);
const pl = el("label", "klf-field");
pl.append(el("span", "klf-name", t("Window")), period);
fields.append(pl, tl, cl);
const cache = el("label", "klf-check");
const cb = el("input");
cb.type = "checkbox";
cb.checked = d.cacheReads;
cache.append(cb, el("span", "", t("Count cache reads too")));
const hint = el("div", "hint", t("Tokens are each call's uncached input, output and cache writes. The cost is an estimate at the Usage page's prices, not a bill. Windows are calendar ones in this computer's time (a week from Monday). Requests from this computer without a key aren't limited."));
const err = el("div", "hint klf-err");
const unsaved = el("span", "hint munsaved", t("unsaved"));
unsaved.title = t("Made when you Save; Cancel drops it");
const save = el("button", "text primary", t("Save"));
const update = () => {
const bad = Number.isNaN(limitTokens(d.tokens)) ? t("Tokens is a count, like 2000000 or 2M")
: Number.isNaN(limitCost(d.cost)) ? t("The cost is US dollars, like 5 or 2.50") : "";
err.textContent = bad;
err.hidden = !bad;
unsaved.hidden = !changed();
save.disabled = !!bad || !changed();
};
period.onchange = () => { d.period = period.value; update(); };
tokens.oninput = () => { d.tokens = tokens.value; update(); };
cost.oninput = () => { d.cost = cost.value; update(); };
cb.onchange = () => { d.cacheReads = cb.checked; update(); };
const close = () => { gatewayLimit = null; renderGatewayKeys(); };
for (const i of [tokens, cost]) i.onkeydown = (e) => {
e.stopPropagation();
if (e.key === "Escape") close();
else if (e.key === "Enter" && !save.disabled) save.click();
};
const cancel = el("button", "text", t("Cancel"));
cancel.onclick = close;
save.onclick = async () => {
if (save.disabled) return;
save.disabled = true;
const tk = limitTokens(d.tokens), c = limitCost(d.cost);
const limit = tk || c ? { period: d.period, tokens: tk, cost: c, cacheReads: d.cacheReads } : null;
const out = await gatewayKeyAction("limit-key", { key: k.id, limit });
if (!out) { update(); return; }
status(t(limit ? "Limit saved for {name}" : "Limit removed for {name}", { name: k.name }), "ok");
};
const foot = el("div", "acm-foot");
foot.append(cache, el("span", "grow"), unsaved);
if (k.limit) {
const off = el("button", "text", t("No limit"));
off.title = t("Clears the caps; Save makes it");
off.onclick = () => { d.tokens = ""; d.cost = ""; tokens.value = ""; cost.value = ""; update(); };
foot.append(off);
}
foot.append(cancel, save);
box.append(fields, hint, err);
if (k.used) box.append(gatewayLimitLine(k));
box.append(foot);
update();
return box;
}
+29
View File
@@ -6,6 +6,35 @@ const I18N = {
zh: {
"Gateway keys": "网关密钥",
"Gateway key": "网关密钥",
"Limit": "限额",
"Spent": "已用完",
"per day": "每日",
"per week": "每周",
"per month": "每月",
"Change this key's limit": "修改此密钥的限额",
"Set a limit for this key": "为此密钥设置限额",
"{used} of {limit} tokens · {left} left": "已用 {used} / {limit} Token · 剩余 {left}",
"{used} of {limit} estimated · {left} left": "估算费用 {used} / {limit} · 剩余 {left}",
"refused until {when}": "{when} 前拒绝请求",
"resets {when}": "{when} 重置",
"1 in flight": "1 个请求进行中",
"Counted from {start}; resets {when}": "自 {start} 起计;{when} 重置",
"{n} calls without a known price aren't in the cost": "{n} 次调用没有已知价格,未计入费用",
"Limit window": "限额周期",
"Window": "周期",
"No token cap": "不限 Token",
"No cost cap": "不限费用",
"Token limit": "Token 限额",
"Estimated cost limit, US$": "估算费用限额(美元)",
"Estimated cost, US$": "估算费用(美元)",
"Count cache reads too": "缓存读取也计入",
"Tokens are each call's uncached input, output and cache writes. The cost is an estimate at the Usage page's prices, not a bill. Windows are calendar ones in this computer's time (a week from Monday). Requests from this computer without a key aren't limited.": "Token 按每次调用未命中缓存的输入、输出和缓存写入计。费用按用量页的价格估算,并非账单。周期按本机时间的自然日、周、月计算(每周从周一开始)。本机不带密钥的请求不受限额。",
"Tokens is a count, like 2000000 or 2M": "Token 应为数量,例如 2000000、2M 或 200万",
"The cost is US dollars, like 5 or 2.50": "费用以美元计,例如 5 或 2.50",
"Made when you Save; Cancel drops it": "点击保存后生效,取消则放弃",
"Limit saved for {name}": "已保存 {name} 的限额",
"Limit removed for {name}": "已取消 {name} 的限额",
"Clears the caps; Save makes it": "清空上限,点击保存后生效",
"All gateway keys": "全部网关密钥",
"All accounts": "全部账号",
"account not recorded": "未记录账号",
+20 -2
View File
@@ -3,10 +3,28 @@ package gui
import (
"encoding/json"
"net/http"
"time"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/budget"
)
// keyRow is a gateway key as the Gateway page lists it, with what it has
// used of its limit (#585).
type keyRow struct {
access.Key
Used *budget.Status `json:"used,omitempty"`
}
func withLimits(keys []access.Key) []keyRow {
now := time.Now()
out := make([]keyRow, 0, len(keys))
for _, k := range keys {
out = append(out, keyRow{Key: k, Used: budget.Of(k, now)})
}
return out
}
func callerKeyRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/caller-keys", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
@@ -16,7 +34,7 @@ func callerKeyRoutes(mux *http.ServeMux) {
fail(w, err)
return
}
writeJSON(w, map[string]any{"keys": keys})
writeJSON(w, map[string]any{"keys": withLimits(keys)})
})
mux.HandleFunc("POST /api/caller-keys/{action}", func(w http.ResponseWriter, r *http.Request) {
var in access.Change
@@ -36,6 +54,6 @@ func callerKeyRoutes(mux *http.ServeMux) {
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, map[string]any{"keys": keys, "secret": secret})
writeJSON(w, map[string]any{"keys": withLimits(keys), "secret": secret})
})
}
+9 -1
View File
@@ -21,6 +21,14 @@ literal custom names and narrow layouts in Chinese and English on both engines.
Sharing off keeps the original API-key field and hides the gateway-key picker;
sharing on names the arbitrary local option separately from the Magpie key.
`gateway-key-limit.test.cjs` checks a gateway key's own limit (#585): each
limited key's badge and line (used, limit, left, reset, requests in flight,
calls without a price), a spent key's "refused until", and the limit editor
under a key's row. Window, tokens, cost and cache reads are staged, marked
unsaved and sent only by Save; Cancel drops them, a bad amount disables Save,
and No limit + Save sends `null`. No click scrolls the page, nothing has a
left border, and the open editor fits a 560px window, in Chinese and English.
`api-key-usage.test.cjs` exercises the existing provider key list: adding,
enabling and disabling, choosing the first key, renaming and removing.
The Usage page's Gateway key rows, request filter and CSV exports identify
@@ -42,7 +50,7 @@ The settings theme picker is also switched and reloaded in Chromium and
WebKit. No separate colours are defined for gateway keys.
```sh
node --test --test-concurrency=1 internal/gui/tests/gateway-caller-keys.test.cjs internal/gui/tests/gateway-connect-keys.test.cjs internal/gui/tests/api-key-usage.test.cjs internal/gui/tests/api-key-theme.test.cjs
node --test --test-concurrency=1 internal/gui/tests/gateway-caller-keys.test.cjs internal/gui/tests/gateway-connect-keys.test.cjs internal/gui/tests/gateway-key-limit.test.cjs internal/gui/tests/api-key-usage.test.cjs internal/gui/tests/api-key-theme.test.cjs
```
## Other Browser Regressions
+7
View File
@@ -14,6 +14,7 @@ function fixture(lang, theme, events, options = {}) {
{ id: "server", name: "Server", masked: "sk-magpie-key-…222222" },
{ id: "work", name: "Work", masked: "sk-magpie-key-…333333" },
];
for (const k of keys) if (options.limits?.[k.id]) Object.assign(k, options.limits[k.id]);
let serial = 0;
let lan = !!options.lan;
let lanKeyID = "", rotations = 0, lanSecret = "";
@@ -102,6 +103,12 @@ function fixture(lang, theme, events, options = {}) {
}
if (action === "remove-key") keys = keys.filter((v) => v !== k);
if (action === "on-key" || action === "off-key") k.off = action === "off-key";
if (action === "limit-key") {
// what a key has used is the fixture's; a new limit starts unused
k.limit = body.limit || undefined;
k.used = body.limit ? { period: body.limit.period, start: new Date().toISOString(), reset: new Date(Date.now() + 864e5).toISOString(),
calls: 0, tokens: 0, cost: 0, tokenLimit: body.limit.tokens, costLimit: body.limit.cost, tokensLeft: body.limit.tokens, costLeft: body.limit.cost, spent: false } : undefined;
}
if (action === "copy-key") secret = k.id === lanKeyID ? lanSecret : secrets.get(k.id);
return json({ keys, secret });
}
@@ -0,0 +1,114 @@
// A gateway key's own limit (#585): what each key has used of it, and
// its editor under the key's row, staged until Save.
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const { fixture } = require("./fixtures/caller-keys.cjs");
const day = 864e5;
const limits = () => ({
laptop: {
limit: { period: "day", tokens: 1000000 },
used: { period: "day", start: new Date(Date.now() - day / 2).toISOString(), reset: new Date(Date.now() + day / 2).toISOString(), calls: 12,
tokens: 250000, cost: 0.8, tokenLimit: 1000000, tokensLeft: 750000, costLeft: 0, inFlight: 2, spent: false },
},
server: {
limit: { period: "month", cost: 5 },
used: { period: "month", start: new Date(Date.now() - 3 * day).toISOString(), reset: new Date(Date.now() + 3 * day).toISOString(), calls: 40,
tokens: 900000, cost: 5.2, costLimit: 5, tokensLeft: 0, costLeft: 0, unpriced: 3, spent: true },
},
});
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
test(`${engine} ${lang}: a gateway key's limit is shown and set with Save`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width: 1000, height: 420 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(6000);
const events = [], errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", fixture(lang, "light", events, { lan: true, limits: limits() }));
const zh = lang === "zh";
const w = zh
? { limit: "为此密钥设置限额", change: "修改此密钥的限额", left: /剩余/, refused: /前拒绝请求/, tokens: "Token 限额", cost: "估算费用限额(美元)", window: "限额周期", cache: "缓存读取也计入", save: "保存", cancel: "取消", none: "不限制", unsaved: "未保存", week: "每周", bad: /Token 应为数量/ }
: { limit: "Set a limit for this key", change: "Change this key's limit", left: /tokens · .* left/, refused: /refused until/, tokens: "Token limit", cost: "Estimated cost limit, US$", window: "Limit window", cache: "Count cache reads too", save: "Save", cancel: "Cancel", none: "No limit", unsaved: "unsaved", week: "per week", bad: /Tokens is a count/ };
await page.goto("http://magpie.test/?view=gateway");
await page.locator("#gatewayKeys .acc[data-key]").last().waitFor();
const row = (id) => page.locator(`#gatewayKeys .acc[data-key="${id}"]`);
// what each limited key has used, has left and when it resets
assert.match(await row("laptop").locator(".key-limit").textContent(), w.left);
assert.match(await row("laptop").locator(".key-limit").textContent(), zh ? /2 个请求进行中/ : /2 in flight/);
assert.match(await row("laptop").locator(".amodels.limit").textContent(), zh ? /每日/ : /per day/);
assert.match(await row("server").locator(".key-limit").textContent(), w.refused);
assert.match(await row("server").locator(".key-limit").textContent(), /\$5\.20/);
assert.equal(await row("server").locator(".amodels.limit.spent").count(), 1);
assert.match(await row("server").locator(".key-limit").getAttribute("title"), zh ? /3 次调用没有已知价格/ : /3 calls without a known price/);
assert.equal(await row("work").locator(".key-limit").count(), 0, "a key without a limit says nothing under its row");
// nothing about it is drawn with a coloured left border
for (const sel of [".key-limit", ".amodels.limit"]) {
const styles = await page.locator("#gatewayKeys " + sel).evaluateAll((els) => els.map((e) => getComputedStyle(e).borderLeftStyle));
assert(styles.every((s) => s === "none"), sel + " has a left border");
}
const view = page.locator("#view-gateway");
const scrolled = async () => [await view.evaluate((v) => v.scrollTop), await page.evaluate(() => window.scrollY)];
await page.mouse.move(500, 300);
await page.mouse.wheel(0, 120);
await page.waitForTimeout(100);
const at = await scrolled();
// open Work's editor: staged, nothing sent
await row("work").getByRole("button", { name: w.limit, exact: true }).click();
const ed = row("work").locator(".key-limit-ed");
await ed.waitFor();
assert.deepEqual(await scrolled(), at, "opening the editor moved the page");
assert.equal(await ed.evaluate((e) => getComputedStyle(e).borderLeftStyle), "none");
await ed.getByRole("textbox", { name: w.tokens, exact: true }).fill("2M");
await ed.getByRole("combobox", { name: w.window, exact: true }).selectOption("week");
await ed.getByLabel(w.cache).check();
assert.equal(await ed.locator(".munsaved").isVisible(), true);
assert.equal(await ed.locator(".munsaved").textContent(), w.unsaved);
await ed.getByRole("button", { name: w.cancel, exact: true }).click();
await ed.waitFor({ state: "detached" });
assert.equal(events.filter((e) => e.action === "limit-key").length, 0, "Cancel sent the limit");
assert.deepEqual(await scrolled(), at, "Cancel moved the page");
// opened again, the draft is gone
await row("work").getByRole("button", { name: w.limit, exact: true }).click();
await ed.waitFor();
assert.equal(await ed.getByRole("textbox", { name: w.tokens, exact: true }).inputValue(), "");
assert.equal(await ed.locator(".munsaved").isVisible(), false);
await ed.getByRole("textbox", { name: w.tokens, exact: true }).fill("lots");
assert.match(await ed.locator(".klf-err").textContent(), w.bad);
assert.equal(await ed.getByRole("button", { name: w.save, exact: true }).isDisabled(), true);
await ed.getByRole("textbox", { name: w.tokens, exact: true }).fill(zh ? "200万" : "2M");
await ed.getByRole("textbox", { name: w.cost, exact: true }).fill("5");
await ed.getByRole("combobox", { name: w.window, exact: true }).selectOption("week");
await ed.getByLabel(w.cache).check();
assert.equal(events.filter((e) => e.action === "limit-key").length, 0, "a pick was sent before Save");
await ed.getByRole("button", { name: w.save, exact: true }).click();
await ed.waitFor({ state: "detached" });
const sent = events.filter((e) => e.action === "limit-key");
assert.deepEqual(sent.map((e) => e.body), [{ key: "work", limit: { period: "week", tokens: 2000000, cost: 5, cacheReads: true } }]);
assert.match(await row("work").locator(".amodels.limit").textContent(), new RegExp(w.week));
assert.match(await row("work").locator(".key-limit").textContent(), w.left);
assert.deepEqual(await scrolled(), at, "Save moved the page");
// the limit taken off
await row("laptop").getByRole("button", { name: w.change, exact: true }).click();
const led = row("laptop").locator(".key-limit-ed");
await led.waitFor();
assert.equal(await led.getByRole("textbox", { name: w.tokens, exact: true }).inputValue(), "1000000");
await led.getByRole("button", { name: w.none, exact: true }).click();
assert.equal(events.filter((e) => e.action === "limit-key").length, 1);
await led.getByRole("button", { name: w.save, exact: true }).click();
await led.waitFor({ state: "detached" });
assert.deepEqual(events.filter((e) => e.action === "limit-key").at(-1).body, { key: "laptop", limit: null });
assert.equal(await row("laptop").locator(".key-limit").count(), 0);
assert.deepEqual(await scrolled(), at, "no click moved the page");
// narrow: the editor fits
await row("server").getByRole("button", { name: w.change, exact: true }).click();
await row("server").locator(".key-limit-ed").waitFor();
await page.setViewportSize({ width: 560, height: 740 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), false);
assert.deepEqual(errors, []);
});
}
}
+19
View File
@@ -646,3 +646,22 @@ func LedgerSeries(p Period, rows []Row) (bucket string, pts []SeriesPoint) {
}
return bucket, pts
}
// Visit calls fn with each record from since on, as the ledger's index
// reads usage.jsonl: only its blocks that reach since are decoded.
func Visit(since time.Time, fn func(Record)) { readLogSnapshot().visit(since, fn) }
// NewCoster prices one record at a time at the effective prices the
// ledger uses, read once: its cost in USD, and false for a call with
// tokens but no known price.
func NewCoster() func(Record) (float64, bool) {
priceOf, renamed := pricer(), provider.Renamed()
return func(r Record) (float64, bool) {
if id, ok := renamed[r.Provider]; ok {
r.Provider = id
}
var t Totals
t.add(r, priceOf(r))
return t.Cost, t.Unpriced == 0
}
}
+1
View File
@@ -86,6 +86,7 @@ const usage = `magpie — one place to pick every agent's model
magpie serve run the gateway alone (the app runs it too)
magpie healthcheck exit 0 when the gateway answers (a container's HEALTHCHECK)
magpie gateway-key list|add <name>|rotate <id>|remove <id> manage the keys clients use to call a shared gateway
magpie gateway-key limit <id> [off|day|week|month --tokens N --cost USD --cache-reads] a key's own limit, and what it used
magpie mcp image the image and video generation MCP server an agent is given from the library (stdio)
magpie usage [today|7d|30d|all] tokens and cost per agent, model and subscription account (30d)
magpie usage --csv [--account <name>] [today|7d|30d|all] every request as CSV (or one account's): the model asked for, sent and served, tokens, cost, time, status, account