Files
magpie/gateway_keys_cli.go
T
yetone 46f03154ff gateway: a gateway key can be held to some models (#882, LCorleone) — a key could reach every model magpie serves. A key now keeps an optional list of models (caller-keys.json "models", none = every model, so older files read as before): <provider>/<model> or <provider>/*, matched against the provider that serves the call, so a bare model name is resolved first and OpenRouter's "anthropic/x" is not Anthropic's. Such a key sees only its models in /v1/models, /v1/models/{id} and the Anthropic (Claude Desktop) and Gemini lists, a routing group only when every member is its; asking for another, by id, bare name or through a group with a member it may not use, is refused before any provider is asked with a 403 in the API's own error shape (permission_error) that names the key and its models, logged as a rejected call like an over-limit key's 429; a fallback it may not use is skipped; embeddings, images and videos it names are held too. magpie's own calls for it (a web search, a picture described, Codex titles with the model Settings name) are marked in their context, not by a User-Agent a client could send, and go on. Set it with the new badge on the key's row in the Gateway page (the app's menu: All models, every model of a provider, each model; sent when the menu closes) or magpie gateway-key models <id> openai/gpt-5 anthropic/* (all takes it off); gateway-key list has a MODELS column. The app's menu, fitting neither below nor above its button, now goes on the roomier side and scrolls instead of running off the window. en/zh/ja/de. TestGatewayKeyModelsHoldAKey (lists, 403s by id, bare name and group, Anthropic shape, fallback skipped, magpie's own call, a free key) fails without the change; TestModelPatterns, TestKeyModelsKept, TestGatewayKeyModelsCLI, TestCallerKeyModelsRoutes pass; gateway-key-models.test.cjs (Chromium and WebKit, en and zh: no native select, menu inside a 420px window, picks sent on close, All models, no scroll, no left border, 560px fits) passes with gateway-connect-keys, gateway-key-limit, gateway-caller-keys and click-scroll. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
2026-10-05 16:52:23 +08:00

249 lines
7.0 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 == "models" {
access.MigrateLegacyLANKeyBestEffort()
return gatewayKeyModels(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]] | models <id> [all | <provider>/<model>|<provider>/* ...]")
}
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\tMODELS")
now := time.Now()
for _, k := range keys {
state := "enabled"
if k.Off {
state = "disabled"
}
models := "all"
if len(k.Models) > 0 {
models = strings.Join(k.Models, ",")
}
fmt.Fprintf(w, "%s\t%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), models)
}
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()
}
// gatewayKeyModels shows or sets the models a key may use (#882):
//
// magpie gateway-key models <id> the models it may use
// magpie gateway-key models <id> all every model
// magpie gateway-key models <id> openai/gpt-5 anthropic/* only these
func gatewayKeyModels(out io.Writer, args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: magpie gateway-key models <id> [all | <provider>/<model>|<provider>/* ...]")
}
id := args[0]
if len(args) > 1 {
ms := args[1:]
if len(ms) == 1 && ms[0] == "all" {
ms = nil
}
if _, err := access.Update("models-key", access.Change{Key: id, Models: ms}); 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 := keys[i]
if len(k.Models) == 0 {
_, err = fmt.Fprintf(out, "%s: every model\n", k.Name)
return err
}
_, err = fmt.Fprintf(out, "%s: only %s\n", k.Name, strings.Join(k.Models, ", "))
return err
}
// 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
}