Files
magpie/gateway_keys_cli.go
T
july_lu 47c2382467 gateway: a gateway key can be held to some accounts (#905, LCorleone) — a key could burn any account a provider had: two coding plans of one vendor, and a key given to someone had them both. A key now keeps an optional list of accounts and keys (caller-keys.json "accounts", none = every account, so older files read as before): "<provider>/<account>", an account by its stable id — the login's, set once it is written and kept through renames (logins.json's savedLogin id, written by every write and stood in for by one made of the name until then, so an entry made before stands), a key by its fingerprint — every one a provider has when the list is set, and one gone later (a key rotated, an account signed out) kept as it is, matching nothing, holding the key closer, never wider. The command line and the menu name an account by who is signed in and keep its id. Routing holds a plan to the key's accounts beside its models: a candidate whose account the key may not use is left out — through a routing group the key names no less, for naming a group is not naming its accounts — the plan's order keeping step and its left-out list marking what the key held out (Weighed's held); magpie's own calls for the key — a web search, a picture described, a Codex title — are of the models the user picked, so the models don't hold them, but their spend lands on an account all the same, and the accounts hold them too. When every account or key a model has is one the key may not use, the request is refused with a 403 in the API's own error shape naming the model and the accounts the key may use, logged as a rejected call: after a usage cap's 429 and before an account barred for the model's, itself before the no-member 404. /v1/models and the Anthropic and Gemini lists show the key only the models an account or key it may use serves (a provider's account set not to serve one, #474, is none of the key's); a provider with no account and no key (a local ollama) is one the key names no account of — its models stay listed and its asks pass — while an entry kept after its provider lost its last account or key serves none, holding the key closer. The embeddings, images and videos endpoints hold their candidate to the key's accounts the same way, and counting tokens counts on those alone (the local estimate where it may use none). System One asks its decision model of an account or key the key may use — and sends on that key, of those in use, not the provider's first, which a key held to a later one was spent on; the videos and images keyed paths send on one the key may use too, held to a later key no longer refused for the first alone. Set it in the app's menu the models are picked in — the accounts after the models, an account by who is signed in with their plan in the note, a key by its fingerprint, what the CLI kept that no provider has now kept as "Not signed in now" — or magpie gateway-key accounts <id> codex/me@example.com openai/<key id> (all takes it off); gateway-key list has an ACCOUNTS column. en/zh/ja/de, the German "{n} accounts" main already had left to it rather than written twice. The images API's fan-out over a subscription's accounts is held to the key's accounts as one: one of the key's whose plan refuses to draw no longer hands the pen to an account it may not use, and the second in the order, when it is the key's, draws what the first may not (drawOnAccounts filters the candidates, the account pinned to with the account header told plainly when the key may not use it). The Routing view's left-out list says an account the key held out for what it is, not that its plan lacks the model, in Chinese, Japanese and German too; the key's badge names its accounts by who is signed in, not by the stable ids it keeps. (#932)
2026-10-06 10:51:50 +08:00

305 lines
8.8 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 (
"cmp"
"fmt"
"io"
"os"
"slices"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/budget"
"github.com/yetone/magpie/internal/provider"
)
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 == "accounts" {
access.MigrateLegacyLANKeyBestEffort()
return gatewayKeyAccounts(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>/* ...] | accounts <id> [all | <provider>/<account>|<provider>/<key id> ...]")
}
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\tACCOUNTS")
now := time.Now()
// an account is shown by who is signed in, kept by its stable id (#905)
names := provider.AccountNames()
for _, k := range keys {
state := "enabled"
if k.Off {
state = "disabled"
}
models := "all"
if len(k.Models) > 0 {
models = strings.Join(k.Models, ",")
}
accounts := "all"
if len(k.Accounts) > 0 {
shown := make([]string, len(k.Accounts))
for i, a := range k.Accounts {
shown[i] = cmp.Or(names[a], a)
}
accounts = strings.Join(shown, ",")
}
fmt.Fprintf(w, "%s\t%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, accounts)
}
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
}
// gatewayKeyAccounts shows or sets a key's account whitelist (#905): the
// accounts and keys it may use, "<provider>/<account>" — a signed-in
// account by its name, a key by its KeyID — every one when none are set.
func gatewayKeyAccounts(out io.Writer, args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: magpie gateway-key accounts <id> [all | <provider>/<account>|<provider>/<key id> ...]")
}
id := args[0]
if len(args) > 1 {
as := args[1:]
if len(as) == 1 && as[0] == "all" {
as = nil
}
if _, err := access.Update("accounts-key", access.Change{Key: id, Accounts: as}); 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.Accounts) == 0 {
_, err = fmt.Fprintf(out, "%s: every account\n", k.Name)
return err
}
// shown by who is signed in, kept by the account's stable id (#905)
names := provider.AccountNames()
shown := make([]string, len(k.Accounts))
for i, a := range k.Accounts {
shown[i] = cmp.Or(names[a], a)
}
_, err = fmt.Fprintf(out, "%s: only %s\n", k.Name, strings.Join(shown, ", "))
return err
}