Files
magpie/gateway_keys_cli.go
T
yetone 0879894af3 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).
2026-10-03 00:18:16 +08:00

205 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
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) }
func gatewayKeysTo(out io.Writer, args []string) error {
args = args[1:]
if len(args) == 0 {
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> | limit <id> [off | day|week|month [--tokens N] [--cost USD] [--cache-reads]]")
}
switch action {
case "list", "add", "rotate", "remove":
default:
return fmt.Errorf("unknown gateway-key command %q", action)
}
access.MigrateLegacyLANKeyBestEffort()
if action == "list" {
keys, err := access.List()
if err != nil {
return err
}
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
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\t%s\n", k.ID, strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return ' '
}
return r
}, k.Name), state, k.Masked, limitWords(k, now))
}
return w.Flush()
}
in := access.Change{Key: args[1]}
if action == "add" {
in = access.Change{Name: args[1]}
}
secret, err := access.Update(action+"-key", in)
if err != nil {
return err
}
if secret != "" {
_, err = fmt.Fprintln(out, secret)
} else {
_, err = fmt.Fprintln(out, "Gateway key removed")
}
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
}