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.

This commit is contained in:
yetone committed 2026-10-05 16:52:23 +08:00
1 parent 4dab45e3ff
commit 46f03154ff
27 files changed
+854 -17

No files matched your search

+12
View File
@@ -210,6 +210,8 @@ 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
magpie gateway-key models <id> openai/gpt-5 anthropic/* # only these models
magpie gateway-key models <id> all # every model
```
Each gateway key can have its own **limit**: a token total, an estimated
@@ -231,6 +233,16 @@ 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.
A gateway key can also be held to some **models** (#882): pick them with the
**All models** badge on the key's row, or run `magpie gateway-key models <id>
openai/gpt-5 anthropic/*` (`all` takes the restriction off). A pattern is
`<provider>/<model>` or `<provider>/*`, matched against the provider that
serves the call, so a bare model name is resolved first. Such a key sees only
its models in `/v1/models` and the Anthropic and Gemini lists, a routing group
only when it may use every member, and is refused any other model with a 403
in the API's error shape before a provider is asked; a fallback it may not
use is skipped. A key with no models listed may use every model.
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.
+48 -4
View File
@@ -26,8 +26,12 @@ func gatewayKeysTo(out io.Writer, args []string) error {
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]]")
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":
@@ -41,19 +45,23 @@ 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\tLIMIT")
fmt.Fprintln(w, "ID\tNAME\tSTATE\tGATEWAY KEY\tLIMIT\tMODELS")
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 {
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))
}, k.Name), state, k.Masked, limitWords(k, now), models)
}
return w.Flush()
}
@@ -182,6 +190,42 @@ func gatewayKeyLimit(out io.Writer, args []string) error {
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 {
+43
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"os"
"slices"
"strings"
"testing"
@@ -131,3 +132,45 @@ func TestGatewayKeyLimitCLI(t *testing.T) {
t.Fatal("off kept", keys[0].Limit)
}
}
func TestGatewayKeyModelsCLI(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("models", id); err != nil || got != "Phone: every model\n" {
t.Fatal(got, err)
}
if got, err := call("models", id, "openai/gpt-5", "anthropic/*"); err != nil || got != "Phone: only openai/gpt-5, anthropic/*\n" {
t.Fatal(got, err)
}
if keys, _ = access.List(); !slices.Equal(keys[0].Models, []string{"openai/gpt-5", "anthropic/*"}) {
t.Fatal("kept", keys[0].Models)
}
if got, _ := call("list"); !strings.Contains(got, "MODELS") || !strings.Contains(got, "openai/gpt-5,anthropic/*") {
t.Fatal(got)
}
for _, args := range [][]string{{"models"}, {"models", id, "gpt-5"}, {"models", "missing", "a/b"}, {"models", "missing"}} {
if _, err := call(args...); err == nil {
t.Error("accepted", args)
}
}
if got, err := call("models", id, "all"); err != nil || got != "Phone: every model\n" {
t.Fatal(got, err)
}
if keys, _ = access.List(); keys[0].Models != nil {
t.Fatal("all kept", keys[0].Models)
}
}
+16 -3
View File
@@ -39,12 +39,17 @@ type Key struct {
Masked string `json:"masked,omitempty"`
// Limit is the key's own budget (#585); nil for none.
Limit *Limit `json:"limit,omitempty"`
// Models are the models the key may use (#882): "<provider>/<model>",
// "<provider>/*" or a group's "group/<name>"; none for every model.
Models []string `json:"models,omitempty"`
}
// Identity is the gateway key a request came with, and its budget.
// Identity is the gateway key a request came with, its budget and the
// models it may use.
type Identity struct {
KeyID, KeyName string
Limit *Limit
Models []string
}
type contextKey struct{}
@@ -147,6 +152,8 @@ type Change struct {
Name string `json:"name"`
// Limit is what "limit-key" sets; nil or Unlimited takes the limit off.
Limit *Limit `json:"limit,omitempty"`
// Models is what "models-key" sets; none lets the key use every model.
Models []string `json:"models,omitempty"`
}
// Update writes the named key store atomically.
@@ -201,6 +208,12 @@ func Update(action string, in Change) (string, error) {
return "", err
}
keys[i].Limit = lim
case "models-key":
ms, err := CleanModels(in.Models)
if err != nil {
return "", err
}
keys[i].Models = ms
case "on-key", "off-key":
keys[i].Off = action == "off-key"
case "remove-key":
@@ -215,7 +228,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" && action != "limit-key" {
if defaultKey && action != "remove-key" && action != "rename-key" && action != "limit-key" && action != "models-key" {
mirror = &keys[i]
}
}
@@ -262,7 +275,7 @@ func Authenticate(secret string) (Identity, bool) {
if k.Off {
return Identity{}, false
}
return Identity{k.ID, k.Name, k.Limit}, true
return Identity{KeyID: k.ID, KeyName: k.Name, Limit: k.Limit, Models: k.Models}, true
}
}
s := settings.Load()
+2 -1
View File
@@ -3,6 +3,7 @@ package access
import (
"encoding/json"
"os"
"reflect"
"runtime"
"strings"
"sync"
@@ -226,7 +227,7 @@ func TestNamedLANKeyKeepsItsName(t *testing.T) {
}
}
keys, err := load()
if err != nil || len(keys) != 3 || keys[0] != original[0] || keys[1] != original[1] || keys[2] != original[2] {
if err != nil || !reflect.DeepEqual(keys, original) {
t.Fatal("migration changed an existing named key", keys, err)
}
})
+2 -1
View File
@@ -3,6 +3,7 @@ package access
import (
"errors"
"os"
"reflect"
"testing"
"github.com/yetone/magpie/internal/settings"
@@ -29,7 +30,7 @@ func TestLegacyLANFallbackBeforeMigration(t *testing.T) {
if err := MigrateLegacyLANKey(); err != nil {
t.Fatal(err)
}
if after, ok := Authenticate(s.LANKey); !ok || after != before {
if after, ok := Authenticate(s.LANKey); !ok || !reflect.DeepEqual(after, before) {
t.Fatal("migration split the legacy caller's usage identity", before, after, ok)
}
}
+76
View File
@@ -0,0 +1,76 @@
package access
import (
"errors"
"regexp"
"slices"
"strings"
)
// CleanModels is a key's model list as kept (#882): each pattern trimmed,
// empty and repeated ones dropped. A pattern names a provider and a model,
// "<provider>/<model>", every model of one, "<provider>/*", or a routing
// group, "group/<name>".
func CleanModels(in []string) ([]string, error) {
var out []string
for _, m := range in {
m = strings.TrimSpace(m)
if m == "" || slices.ContainsFunc(out, func(o string) bool { return strings.EqualFold(o, m) }) {
continue
}
if !strings.Contains(m, "/") || strings.HasPrefix(m, "/") || strings.HasSuffix(m, "/") {
return nil, errors.New("A key's model is <provider>/<model> or <provider>/*, like openai/gpt-5 or anthropic/*: " + m)
}
if len(m) > 300 {
return nil, errors.New("A key's model is at most 300 characters")
}
out = append(out, m)
}
if len(out) > 500 {
return nil, errors.New("A key can list at most 500 models")
}
return out, nil
}
// Restricted says the key may use only the models it lists.
func (who Identity) Restricted() bool { return len(who.Models) > 0 }
// Allows says one of ids is among the key's models; any, for a key that
// lists none.
func (who Identity) Allows(ids ...string) bool {
if !who.Restricted() {
return true
}
for _, id := range ids {
if id != "" && ModelAllowed(who.Models, id) {
return true
}
}
return false
}
// ModelAllowed says id ("<provider>/<model>") matches one of patterns,
// letter case aside; a "*" in a pattern stands for any run of characters,
// slashes too. No patterns allow every model.
func ModelAllowed(patterns []string, id string) bool {
if len(patterns) == 0 {
return true
}
for _, p := range patterns {
if strings.EqualFold(p, id) {
return true
}
if strings.Contains(p, "*") && globRe(p).MatchString(id) {
return true
}
}
return false
}
func globRe(p string) *regexp.Regexp {
parts := strings.Split(p, "*")
for i, s := range parts {
parts[i] = regexp.QuoteMeta(s)
}
return regexp.MustCompile("(?is)^" + strings.Join(parts, ".*") + "$")
}
+89
View File
@@ -0,0 +1,89 @@
package access
import (
"os"
"slices"
"strings"
"testing"
)
func TestModelPatterns(t *testing.T) {
ps := []string{"openai/gpt-5", "anthropic/*", "relay/*-mini"}
for id, want := range map[string]bool{
"openai/gpt-5": true,
"OpenAI/GPT-5": true,
"openai/gpt-5-mini": false,
"anthropic/claude-x": true,
"anthropic/vendor/x": true,
"anthropics/x": false,
"relay/m-mini": true,
"relay/m": false,
"openrouter/anthropic/x": false,
} {
if got := ModelAllowed(ps, id); got != want {
t.Errorf("%s: %v", id, got)
}
}
if !ModelAllowed(nil, "any/thing") {
t.Fatal("no patterns hold a model")
}
who := Identity{Models: ps}
if !who.Restricted() || who.Allows("relay/m", "") || !who.Allows("relay/m", "openai/gpt-5") || (Identity{}).Restricted() || !(Identity{}).Allows("x/y") {
t.Fatal("Identity.Allows")
}
got, err := CleanModels([]string{" openai/gpt-5 ", "", "OPENAI/GPT-5", "anthropic/*"})
if err != nil || !slices.Equal(got, []string{"openai/gpt-5", "anthropic/*"}) {
t.Fatal(got, err)
}
for _, bad := range []string{"gpt-5", "/gpt-5", "openai/", strings.Repeat("a", 300) + "/b"} {
if _, err := CleanModels([]string{bad}); err == nil {
t.Error("accepted", bad)
}
}
}
// A key's models are kept in caller-keys.json, stay through a rotation and
// a restart, reach the gateway with the key, and an older file without
// them reads as every model (#882).
func TestKeyModelsKept(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
secret, err := Update("add-key", Change{Name: "Phone"})
if err != nil {
t.Fatal(err)
}
keys, _ := List()
id := keys[0].ID
if who, ok := Authenticate(secret); !ok || who.Restricted() {
t.Fatal("a new key is held", who)
}
if _, err := Update("models-key", Change{Key: id, Models: []string{"gpt-5"}}); err == nil {
t.Fatal("accepted a model without its provider")
}
if _, err := Update("models-key", Change{Key: "missing", Models: []string{"a/b"}}); err == nil {
t.Fatal("held a missing key")
}
if _, err := Update("models-key", Change{Key: id, Models: []string{"openai/gpt-5", "anthropic/*"}}); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(Path())
if !strings.Contains(string(b), `"models": [`) {
t.Fatal("caller-keys.json", string(b))
}
secret, err = Update("rotate-key", Change{Key: id})
if err != nil {
t.Fatal(err)
}
who, ok := Authenticate(secret)
if !ok || !slices.Equal(who.Models, []string{"openai/gpt-5", "anthropic/*"}) || !who.Allows("anthropic/claude") || who.Allows("openai/gpt-4o") {
t.Fatal("authenticated as", who, ok)
}
if _, err := Update("models-key", Change{Key: id}); err != nil {
t.Fatal(err)
}
if who, _ := Authenticate(secret); who.Restricted() {
t.Fatal("every model kept a list", who)
}
if b, _ := os.ReadFile(Path()); strings.Contains(string(b), `"models"`) {
t.Fatal("an empty list written", string(b))
}
}
+5
View File
@@ -81,6 +81,11 @@ func (s *Server) codexTitle(w http.ResponseWriter, r *http.Request, body []byte,
}
ctx := context.WithValue(r.Context(), titleCheckKey{}, check)
ctx = context.WithValue(ctx, titleShapeKey{}, &shape)
if to == settings.Load().CodexTitles {
// the model picked for titles in magpie's settings, not by the
// caller's key (#882)
ctx = magpieChose(ctx)
}
s.serve(rec, r.WithContext(ctx), provider.Responses, withModel(body, to))
if rec.status >= 400 {
for k, vs := range rec.header {
+5
View File
@@ -248,6 +248,7 @@ func (s *Server) images(edit bool) http.HandlerFunc {
writeError(w, provider.Chat, code, msg)
s.record(call)
}
named := d.Model != "" // a gateway key's models hold one the caller names (#882)
if d.Model == "" {
m, ok := drawer()
if !ok {
@@ -265,6 +266,10 @@ func (s *Server) images(edit bool) http.HandlerFunc {
fail(404, fmt.Sprintf("magpie knows no model %q to draw with", d.Model))
return
}
if keyWho, held := keyHolds(r); held && named && !modelAllowed(keyWho, p, model) {
fail(403, keyModelError(keyWho, d.Model))
return
}
var unmask func()
w, d.Prompt, unmask = redactedPrompt(w, d.Prompt)
defer unmask()
+9 -1
View File
@@ -56,13 +56,21 @@ func (s *Server) retrieve(path, operation string) http.HandlerFunc {
s.record(call)
return
}
g, ms, isGroup := provider.FindGroup(asked)
if keyWho, held := keyHolds(r); held && (isGroup && !membersAllowed(keyWho, ms) || !isGroup && !modelAllowed(keyWho, p, model)) {
msg := keyModelError(keyWho, asked)
call.Status, call.Error = 403, msg
writeError(w, provider.Chat, 403, msg)
s.record(call)
return
}
call.To = provider.Chat
// a routing group's members are tried as its routing orders them,
// each account or key of theirs too (#773), the next asked when one
// fails: a member that serves no such API (404), one out of quota
// (429) or one whose vendor fails; a model is asked on its own
tries := []candidate{{p: p, model: model}}
if g, ms, isGroup := provider.FindGroup(asked); isGroup {
if isGroup {
if cs, _ := s.planGroup(g.Live(), ms, provider.Chat); len(cs) > 0 {
tries = cs
}
+14 -2
View File
@@ -685,7 +685,7 @@ func videomakerObjects() []map[string]any {
// catalogFor is the catalog as the agent asking is shown it.
func catalogFor(r *http.Request) []provider.Entry {
shown, _ := provider.CatalogFor(agentOf(r))
return shown
return keyAllowed(r, shown)
}
func (s *Server) models(w http.ResponseWriter, r *http.Request) {
@@ -744,7 +744,7 @@ func (s *Server) models(w http.ResponseWriter, r *http.Request) {
func (s *Server) model(w http.ResponseWriter, r *http.Request) {
id := unprefixed(r.PathValue("id"))
for _, e := range provider.Catalog() {
for _, e := range keyAllowed(r, provider.Catalog()) {
if e.ID == id {
writeJSON(w, 200, modelObject(e))
return
@@ -1179,6 +1179,15 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
// before any image is taken out of the request: one may be for images
g, ms, isGroup := provider.FindGroup(asked)
g = g.Live() // a manual group's rules wait
// a gateway key held to some models (#882) is refused another, or a
// group with one it may not use in it
keyWho, keyHeld := keyHolds(r)
if keyHeld && (isGroup && !membersAllowed(keyWho, ms) || !isGroup && !modelAllowed(keyWho, p, model)) {
call.Status, call.Error = 403, "model not allowed for the gateway key"
writeError(w, from, 403, keyModelError(keyWho, call.Model))
turnedAway()
return
}
// a Codex subagent's task its lead sealed — the lead answered by a
// ChatGPT account, the group's own or Codex's — goes only to a ChatGPT
// account, the lead's first (#619); with none, it is turned away
@@ -1298,6 +1307,9 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
if sealedTask {
cands, pl = sealedReaders(cands, pl)
}
if keyHeld {
cands = allowedCandidates(keyWho, cands)
}
if len(cands) == 0 && slices.ContainsFunc(pl.left, func(w Weighed) bool { return w.Capped > 0 }) &&
!slices.ContainsFunc(pl.left, func(w Weighed) bool { return !w.Barred && w.Capped == 0 }) {
// every account there is is held at its usage cap: used up, as far
+112
View File
@@ -0,0 +1,112 @@
package gateway
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/provider"
)
// A gateway key may be held to some models (#882, access.Key.Models): it
// is shown only those in the model lists, and a request of it for another
// is refused before any provider is asked. A model is held to by its
// provider's id and its own, "<provider>/<model>", whatever the agent
// called it; a routing group passes only when every model in it does.
// magpieChoseKey marks, in a request's context, a call magpie makes for a
// model the user picked in its settings rather than the caller (a web
// search's, an image description's, a Codex title's): the key's models
// don't hold it.
type magpieChoseKey struct{}
func magpieChose(ctx context.Context) context.Context {
return context.WithValue(ctx, magpieChoseKey{}, true)
}
// keyHolds is the calling key's models, when they hold this request.
func keyHolds(r *http.Request) (access.Identity, bool) {
who := access.Caller(r.Context())
if !who.Restricted() {
return who, false
}
if chose, _ := r.Context().Value(magpieChoseKey{}).(bool); chose {
return who, false
}
return who, true
}
// modelAllowed says the key may use provider p's model.
func modelAllowed(who access.Identity, p provider.Provider, model string) bool {
return who.Allows(p.ID + "/" + model)
}
// memberAllowed says the key may use a routing group's member.
func memberAllowed(who access.Identity, m provider.Member) bool {
ids := []string{m.Provider.ID + "/" + m.Model}
if n := len(m.Path); n > 0 {
ids = append(ids, m.Path[n-1])
}
return who.Allows(ids...)
}
// membersAllowed says the key may use every member of a routing group:
// one it may use only some of is the key's no more than an empty one.
func membersAllowed(who access.Identity, ms []provider.Member) bool {
if len(ms) == 0 {
return !who.Restricted()
}
for _, m := range ms {
if !memberAllowed(who, m) {
return false
}
}
return true
}
// entryAllowed says the key is shown a model of the catalog.
func entryAllowed(who access.Identity, e provider.Entry) bool {
if !who.Restricted() {
return true
}
if e.Group != "" {
_, ms, ok := provider.FindGroup(e.ID)
return ok && membersAllowed(who, ms)
}
return modelAllowed(who, e.Provider, e.Model)
}
// keyAllowed filters the catalog to what the calling key may use.
func keyAllowed(r *http.Request, es []provider.Entry) []provider.Entry {
who, held := keyHolds(r)
if !held {
return es
}
out := make([]provider.Entry, 0, len(es))
for _, e := range es {
if entryAllowed(who, e) {
out = append(out, e)
}
}
return out
}
// allowedCandidates leaves out of a plan the providers' models the key
// may not use: a model's fallbacks are other models.
func allowedCandidates(who access.Identity, cs []candidate) []candidate {
out := cs[:0:0]
for _, c := range cs {
if modelAllowed(who, c.p, c.model) {
out = append(out, c)
}
}
return out
}
// keyModelError is what a request for a model its key may not use is told.
func keyModelError(who access.Identity, model string) string {
return fmt.Sprintf("The gateway key %q may not use %s; it may use %s. Change the key's models in magpie's Gateway page, or use a model it has.",
who.KeyName, model, strings.Join(who.Models, ", "))
}
+152
View File
@@ -0,0 +1,152 @@
package gateway
import (
"encoding/json"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/provider"
)
// A gateway key held to some models (#882) sees only those in the model
// lists, is refused any other before a provider is asked — by a bare
// name, through a group with one it may not use or as a fallback — while
// another key and magpie's own calls for it go on.
func TestGatewayKeyModelsHoldAKey(t *testing.T) {
fresh(t)
plan := &fake{t: t, ctype: "application/json", reply: `{"id":"from-plan","choices":[]}`}
spare := &fake{t: t, ctype: "application/json", reply: `{"id":"from-spare","choices":[]}`}
twoProviders(t, plan, spare)
keys, secrets := newCaller(t, "Held", "Free")
if _, err := access.Update("models-key", access.Change{Key: keys[0].ID, Models: []string{" plan/* ", "PLAN/*"}}); err != nil {
t.Fatal(err)
}
if ks, _ := access.List(); !slices.Equal(ks[len(ks)-2].Models, []string{"plan/*"}) {
t.Fatalf("stored %+v", ks[len(ks)-2])
}
for _, g := range []provider.Group{
{Name: "Both", Members: []string{"plan/m1", "spare/m2"}, Routing: provider.Ordered},
{Name: "Solo", Members: []string{"plan/m1"}, Routing: provider.Ordered},
} {
if err := provider.SaveGroup(g); err != nil {
t.Fatal(err)
}
}
h := New().Handler()
do := func(secret, method, path, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Authorization", "Bearer "+secret)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
listed := func(secret string) []string {
var l struct{ Data []struct{ ID string } }
w := do(secret, "GET", "/v1/models", "")
if err := json.Unmarshal(w.Body.Bytes(), &l); err != nil {
t.Fatal(w.Body.String())
}
var ids []string
for _, m := range l.Data {
ids = append(ids, m.ID)
}
return ids
}
if ids := listed(secrets[0]); !slices.Contains(ids, "plan/m1") || !slices.Contains(ids, "group/solo") || slices.Contains(ids, "spare/m2") || slices.Contains(ids, "group/both") {
t.Fatalf("held key lists %v", ids)
}
if ids := listed(secrets[1]); !slices.Contains(ids, "spare/m2") || !slices.Contains(ids, "group/both") {
t.Fatalf("free key lists %v", ids)
}
if w := do(secrets[0], "GET", "/v1beta/models", ""); w.Code != 200 || !strings.Contains(w.Body.String(), "m1") || strings.Contains(w.Body.String(), "m2") {
t.Fatalf("gemini list %d %s", w.Code, w.Body.String())
}
if w := do(secrets[0], "GET", "/v1/models/spare/m2", ""); w.Code == 200 {
t.Fatal("held key found a model it may not use", w.Body.String())
}
chat := func(secret, model string) *httptest.ResponseRecorder {
return do(secret, "POST", "/v1/chat/completions", `{"model":"`+model+`","messages":[{"role":"user","content":"hi"}]}`)
}
for _, model := range []string{"spare/m2", "m2", "group/both"} {
w := chat(secrets[0], model)
var e struct {
Error struct{ Message, Type string }
}
json.Unmarshal(w.Body.Bytes(), &e)
if w.Code != 403 || e.Error.Type != "permission_error" || !strings.Contains(e.Error.Message, `"Held"`) || !strings.Contains(e.Error.Message, "plan/*") {
t.Fatalf("%s: %d %s", model, w.Code, w.Body.String())
}
if rec := lastUsage(t); !rec.Rejected || rec.Status != 403 || rec.CallerKeyID != keys[0].ID {
t.Fatalf("%s recorded as %+v", model, rec)
}
}
if spare.calls != 0 || plan.calls != 0 {
t.Fatal("a refused request reached a provider", plan.calls, spare.calls)
}
// said in Anthropic's shape on its API
w := do(secrets[0], "POST", "/v1/messages", `{"model":"spare/m2","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 != 403 || a.Type != "error" || a.Error.Type != "permission_error" {
t.Fatal("anthropic refusal", w.Code, w.Body.String())
}
for _, model := range []string{"plan/m1", "m1", "group/solo"} {
if w := chat(secrets[0], model); w.Code != 200 || !strings.Contains(w.Body.String(), "from-plan") {
t.Fatalf("%s: %d %s", model, w.Code, w.Body.String())
}
}
if w := chat(secrets[1], "spare/m2"); w.Code != 200 || !strings.Contains(w.Body.String(), "from-spare") {
t.Fatal("a free key was held", w.Code, w.Body.String())
}
// magpie's own call for the key (a search, a picture described) goes on
r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"spare/m2","messages":[{"role":"user","content":"hi"}]}`))
r = r.WithContext(magpieChose(r.Context()))
r.Header.Set("Authorization", "Bearer "+secrets[0])
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if rec.Code != 200 {
t.Fatal("magpie's own call was held", rec.Code, rec.Body.String())
}
// plan out of quota: its fallback is a model the key may not use
plan.code, plan.reply = 429, `{"error":{"message":"slow down"}}`
spareCalls := spare.calls
if w := chat(secrets[0], "plan/m1"); w.Code == 200 || spare.calls != spareCalls {
t.Fatal("a held key fell back to a model it may not use", w.Code, w.Body.String())
}
if w := chat(secrets[1], "plan/m1"); w.Code != 200 || !strings.Contains(w.Body.String(), "from-spare") {
t.Fatal("a free key's fallback", w.Code, w.Body.String())
}
// "all" takes it off
if _, err := access.Update("models-key", access.Change{Key: keys[0].ID}); err != nil {
t.Fatal(err)
}
plan.code, plan.reply = 0, `{"id":"from-plan","choices":[]}`
if w := chat(secrets[0], "spare/m2"); w.Code != 200 {
t.Fatal("a key let off is still held", w.Code, w.Body.String())
}
}
// A provider's id inside the asked model can't pass for another
// provider's: OpenRouter's "anthropic/x" isn't Anthropic's.
func TestGatewayKeyModelsMatchTheServingProvider(t *testing.T) {
who := access.Identity{KeyName: "k", Models: []string{"anthropic/*"}}
if modelAllowed(who, provider.Provider{ID: "openrouter"}, "anthropic/claude") {
t.Fatal("a model id passed for its provider")
}
if !modelAllowed(who, provider.Provider{ID: "anthropic"}, "claude") {
t.Fatal("anthropic's own")
}
if !modelAllowed(access.Identity{}, provider.Provider{ID: "x"}, "y") {
t.Fatal("a free key")
}
if membersAllowed(who, nil) || !membersAllowed(access.Identity{}, nil) {
t.Fatal("an empty group")
}
}
+1 -1
View File
@@ -471,7 +471,7 @@ func (s *Server) searchWith(ctx context.Context, p provider.Provider, model, que
"messages": []map[string]any{{"role": "user", "content": "Search the web for: " + query}},
"tools": []map[string]any{{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}},
})
r, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://magpie/v1/messages", nil)
r, err := http.NewRequestWithContext(magpieChose(ctx), http.MethodPost, "http://magpie/v1/messages", nil)
if err != nil {
return "", nil, err
}
+5
View File
@@ -499,6 +499,7 @@ func (s *Server) videosCreate(w http.ResponseWriter, r *http.Request) {
writeError(w, provider.Chat, code, msg)
s.record(call)
}
named := f.Model != "" // a gateway key's models hold one the caller names (#882)
if f.Model == "" {
m, ok := videomaker()
if !ok {
@@ -519,6 +520,10 @@ func (s *Server) videosCreate(w http.ResponseWriter, r *http.Request) {
fail(404, fmt.Sprintf("magpie knows no model %q to make videos with", f.Model))
return
}
if keyWho, held := keyHolds(r); held && named && !modelAllowed(keyWho, p, model) {
fail(403, keyModelError(keyWho, f.Model))
return
}
call.Provider, call.To = p.ID, provider.Chat
remote := p.IsRemoteMagpie()
// any grok-imagine-video*, not only those listed: the vendor's newer ones work before magpie names them
+1 -1
View File
@@ -217,7 +217,7 @@ func (s *Server) askVision(ctx context.Context, model, src string) (string, erro
req["reasoning_effort"] = effort
}
body, _ := json.Marshal(req)
r, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://magpie/v1/chat/completions", nil)
r, err := http.NewRequestWithContext(magpieChose(ctx), http.MethodPost, "http://magpie/v1/chat/completions", nil)
if err != nil {
return "", err
}
+3
View File
@@ -1876,6 +1876,9 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
#gatewayKeys .klf-err { color: var(--red); }
#gatewayKeys .klf .klf-period { width: 100%; max-width: none; height: 28px; justify-content: space-between; color: var(--fg); }
@media (max-width: 520px) { #gatewayKeys .klf { grid-template-columns: 1fr; } }
/* the models a key may use (#882): a long model's id is cut, and the
badge gives way first on a narrow window */
#gatewayKeys .amodels.key-models { display: inline-block; flex: 0 1 auto; min-width: 24px; max-width: 180px; line-height: 20px; overflow: hidden; text-overflow: ellipsis; }
/* 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); }
+8 -1
View File
@@ -10819,7 +10819,14 @@ function openProtoMenu(anchor, opts, value, choose, head = "Protocol this key sp
// under the pill, or above it when the window runs out
const r = anchor.getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight, pad = 8;
let y = r.bottom + 5;
if (y + h > innerHeight - pad && r.top - 5 - h >= pad) { y = r.top - 5 - h; box.classList.add("up"); }
const below = innerHeight - pad - y, above = r.top - 5 - pad;
if (h > below && above >= h) { y = r.top - 5 - h; box.classList.add("up"); }
else if (h > below && above > below) {
// fits neither way: above, where there is more room, scrolling
box.style.maxHeight = above + "px";
y = pad;
box.classList.add("up");
} else if (h > below) box.style.maxHeight = Math.max(below, 0) + "px";
const left = align === "right" ? r.right - w : r.left;
box.style.left = Math.max(pad, Math.min(left, innerWidth - w - pad)) + "px";
box.style.top = Math.max(pad, y) + "px";
+42 -1
View File
@@ -154,7 +154,7 @@ 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"), gatewayLimitBadge(k), copyCallerKeyBtn(k), rotate, rm);
row.append(tick, gatewayRename(k), el("span", "plan mono", k.masked), el("span", "grow"), gatewayModelsBadge(k), 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);
@@ -192,6 +192,47 @@ function askGatewayKey(k, rotate) {
cancel.focus({ preventScroll: true });
}
// ---- the models a gateway key may use (#882) ----
// gatewayModelsBadge picks the models a key may use, in the app's menu:
// "All models" on hover when it may use any, else which it may, always
// shown. A pick is sent when the menu closes.
function gatewayModelsBadge(k) {
const ms = k.models || [];
const b = el("button", "amodels key-models" + (ms.length ? " set" : ""));
b.type = "button";
b.textContent = !ms.length ? t("All models") : ms.length === 1 ? ms[0] : t("{n} models", { n: ms.length });
b.title = ms.length ? t("Only these models: {list}", { list: ms.join(", ") }) : t("Pick the models this key may use");
b.setAttribute("aria-label", t("Models this key may use"));
b.setAttribute("aria-haspopup", "menu");
b.setAttribute("aria-expanded", "false");
b.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
if (b.classList.contains("open")) return closeProtoMenu();
let models = [];
try { ({ models } = await api("caller-keys/models")); } catch (err) { status(t(err.message), "err"); return; }
if (!b.isConnected) return;
const opts = [{ v: "", name: "All models", note: "Any model, now and later" }];
const seen = new Set();
for (const m of models || []) {
if (!seen.has(m.provider)) {
seen.add(m.provider);
opts.push({ v: m.provider + "/*", name: t("Every {provider} model", { provider: m.providerName }), note: m.provider + "/*", literalName: true });
}
opts.push({ v: m.id, name: m.name, note: m.id, literalName: true });
}
// what the CLI kept that the list hasn't, a pattern or a model gone
for (const v of ms) if (!opts.some((o) => o.v === v)) opts.push({ v, name: v, note: "Not served now", literalName: true });
openProtoMenu(b, opts, ms, (picked) => {
gatewayKeyAction("models-key", { key: k.id, models: picked }).then((out) => {
if (out) status(t(picked.length ? "{name} may use only the models picked" : "{name} may use every model", { name: k.name }), "ok");
});
}, "Models this key may use", "sess-menu", "right");
};
return b;
}
// ---- a gateway key's own limit (#585) ----
const limitPeriods = ["day", "week", "month"];
+24
View File
@@ -40,6 +40,14 @@ const I18N = {
"per month": "每月",
"Change this key's limit": "修改此密钥的限额",
"Set a limit for this key": "为此密钥设置限额",
"Models this key may use": "此密钥可用的模型",
"Pick the models this key may use": "选择此密钥可用的模型",
"Only these models: {list}": "仅限这些模型:{list}",
"Any model, now and later": "任何模型,包括以后添加的",
"Every {provider} model": "{provider} 的全部模型",
"Not served now": "当前未提供",
"{name} may use only the models picked": "{name} 仅可使用所选模型",
"{name} may use every model": "{name} 可使用全部模型",
"{used} of {limit} tokens · {left} left": "已用 {used} / {limit} Token · 剩余 {left}",
"{used} of {limit} estimated · {left} left": "估算费用 {used} / {limit} · 剩余 {left}",
"refused until {when}": "{when} 前拒绝请求",
@@ -3239,6 +3247,14 @@ const I18N = {
"per month": "1か月あたり",
"Change this key's limit": "このキーの上限を変更",
"Set a limit for this key": "このキーに上限を設定",
"Models this key may use": "このキーで使えるモデル",
"Pick the models this key may use": "このキーで使えるモデルを選ぶ",
"Only these models: {list}": "これらのモデルのみ:{list}",
"Any model, now and later": "今後追加されるものも含め、すべてのモデル",
"Every {provider} model": "{provider} のすべてのモデル",
"Not served now": "現在は提供されていません",
"{name} may use only the models picked": "{name} は選んだモデルのみ使えます",
"{name} may use every model": "{name} はすべてのモデルを使えます",
"{used} of {limit} tokens · {left} left": "{used} / {limit} トークン · 残り {left}",
"{used} of {limit} estimated · {left} left": "推定 {used} / {limit} · 残り {left}",
"refused until {when}": "{when} まで拒否",
@@ -6415,6 +6431,14 @@ const I18N = {
"per month": "pro Monat",
"Change this key's limit": "Limit dieses Schlüssels ändern",
"Set a limit for this key": "Limit für diesen Schlüssel festlegen",
"Models this key may use": "Modelle, die dieser Schlüssel nutzen darf",
"Pick the models this key may use": "Modelle wählen, die dieser Schlüssel nutzen darf",
"Only these models: {list}": "Nur diese Modelle: {list}",
"Any model, now and later": "Jedes Modell, auch künftige",
"Every {provider} model": "Alle Modelle von {provider}",
"Not served now": "Derzeit nicht angeboten",
"{name} may use only the models picked": "{name} darf nur die gewählten Modelle nutzen",
"{name} may use every model": "{name} darf alle Modelle nutzen",
"{used} of {limit} tokens · {left} left": "{used} von {limit} Token · {left} übrig",
"{used} of {limit} estimated · {left} left": "{used} von {limit} geschätzt · {left} übrig",
"refused until {when}": "abgelehnt bis {when}",
+64
View File
@@ -0,0 +1,64 @@
package gui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/provider"
)
// The Gateway page lists the models a key can be held to and keeps what
// was picked for it (#882).
func TestCallerKeyModelsRoutes(t *testing.T) {
sandboxHome(t)
if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m", "m-mini"}}); err != nil {
t.Fatal(err)
}
if err := provider.SaveGroup(provider.Group{Name: "Mine", Members: []string{"relay/m"}, Routing: provider.Ordered}); err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
callerKeyRoutes(mux)
request := func(method, path, body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(method, path, strings.NewReader(body)))
return w
}
var l struct{ Models []keyModelJSON }
if w := request("GET", "/api/caller-keys/models", ""); w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &l) != nil {
t.Fatal(w.Code, w.Body)
}
var ids []string
for _, m := range l.Models {
ids = append(ids, m.ID)
if m.Provider != "relay" || m.ProviderName != "Relay" || m.Name == "" {
t.Fatalf("%+v", m)
}
}
if !slices.Equal(ids, []string{"relay/m", "relay/m-mini"}) {
t.Fatal("models", ids)
}
var s struct{ Keys []access.Key }
if w := request("POST", "/api/caller-keys/add-key", `{"name":"Phone"}`); w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &s) != nil {
t.Fatal(w.Code, w.Body)
}
id := s.Keys[0].ID
if w := request("POST", "/api/caller-keys/models-key", `{"key":"`+id+`","models":["relay/*","openai/gpt-5"]}`); w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &s) != nil {
t.Fatal(w.Code, w.Body)
}
if !slices.Equal(s.Keys[0].Models, []string{"relay/*", "openai/gpt-5"}) {
t.Fatal("kept", s.Keys[0].Models)
}
if w := request("POST", "/api/caller-keys/models-key", `{"key":"`+id+`","models":["gpt-5"]}`); w.Code == 200 {
t.Fatal("accepted a model without its provider")
}
var free struct{ Keys []access.Key }
if w := request("POST", "/api/caller-keys/models-key", `{"key":"`+id+`","models":[]}`); w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &free) != nil || free.Keys[0].Models != nil {
t.Fatal("every model", w.Code, w.Body)
}
}
+24
View File
@@ -1,12 +1,14 @@
package gui
import (
"cmp"
"encoding/json"
"net/http"
"time"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/budget"
"github.com/yetone/magpie/internal/provider"
)
// keyRow is a gateway key as the Gateway page lists it, with what it has
@@ -16,6 +18,15 @@ type keyRow struct {
Used *budget.Status `json:"used,omitempty"`
}
// keyModelJSON is a model a gateway key may be held to, as its picker
// lists it.
type keyModelJSON struct {
ID string `json:"id"` // "<provider>/<model>", what the key keeps
Name string `json:"name"`
Provider string `json:"provider"`
ProviderName string `json:"providerName"`
}
func withLimits(keys []access.Key) []keyRow {
now := time.Now()
out := make([]keyRow, 0, len(keys))
@@ -36,6 +47,19 @@ func callerKeyRoutes(mux *http.ServeMux) {
}
writeJSON(w, map[string]any{"keys": withLimits(keys)})
})
// the models a key may be held to (#882): each provider's, a group's
// passing when every model in it does
mux.HandleFunc("GET /api/caller-keys/models", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
out := []keyModelJSON{}
for _, e := range provider.Catalog() {
if e.Group != "" {
continue
}
out = append(out, keyModelJSON{ID: e.Provider.ID + "/" + e.Model, Name: cmp.Or(e.Name, e.Model), Provider: e.Provider.ID, ProviderName: cmp.Or(e.Provider.Name, e.Provider.ID)})
}
writeJSON(w, map[string]any{"models": out})
})
mux.HandleFunc("POST /api/caller-keys/{action}", func(w http.ResponseWriter, r *http.Request) {
var in access.Change
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+8 -1
View File
@@ -37,6 +37,13 @@ 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.
`gateway-key-models.test.cjs` checks the models a gateway key may use
(#882): the badge on each key's row ("All models", or how many), the app's
own menu (no native select) that stays inside a short window, picks sent
only when it closes, and "All models" taking the restriction off. No click
scrolls the page, nothing has a left border, and the row 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
@@ -58,7 +65,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/gateway-key-limit.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/gateway-key-models.test.cjs internal/gui/tests/api-key-usage.test.cjs internal/gui/tests/api-key-theme.test.cjs
```
## Other Browser Regressions
+6
View File
@@ -82,6 +82,11 @@ function fixture(lang, theme, events, options = {}) {
return json(lanState());
}
if (url.pathname === "/api/caller-keys") return json({ keys });
if (url.pathname === "/api/caller-keys/models") return json({ models: [
{ id: "relay/m", name: "Model", provider: "relay", providerName: "Relay" },
{ id: "relay/m-mini", name: "Model mini", provider: "relay", providerName: "Relay" },
{ id: "openai/gpt-5", name: "GPT-5", provider: "openai", providerName: "OpenAI" },
] });
if (url.pathname.startsWith("/api/caller-keys/")) {
const action = url.pathname.split("/").at(-1), body = req.postDataJSON();
events.push({ action, body });
@@ -109,6 +114,7 @@ function fixture(lang, theme, events, options = {}) {
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 === "models-key") k.models = body.models.length ? body.models : undefined;
if (action === "copy-key") secret = k.id === lanKeyID ? lanSecret : secrets.get(k.id);
return json({ keys, secret });
}
@@ -0,0 +1,82 @@
// The models a gateway key may use (#882): picked in the app's own menu
// on the key's row, sent when the menu closes, "All models" taking the
// restriction off.
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const { fixture } = require("./fixtures/caller-keys.cjs");
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
test(`${engine} ${lang}: a gateway key is held to the models picked for it`, 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: { server: { models: ["openai/*", "relay/m"] } } }));
const zh = lang === "zh";
const w = zh
? { label: "此密钥可用的模型", all: "全部模型", two: "2 个模型", relay: "Relay 的全部模型", only: /仅限这些模型/ }
: { label: "Models this key may use", all: "All models", two: "2 models", relay: "Every Relay model", only: /Only these models/ };
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}"]`);
const badge = (id) => row(id).getByRole("button", { name: w.label, exact: true });
// a held key says how many it may use, always; a free one "All models"
assert.equal((await badge("server").textContent()).trim(), w.two);
assert.match(await badge("server").getAttribute("title"), w.only);
assert.equal(await badge("server").evaluate((b) => b.classList.contains("set")), true);
assert.equal((await badge("work").textContent()).trim(), w.all);
// nothing drawn with a coloured left border
const lefts = await page.locator("#gatewayKeys .key-models").evaluateAll((els) => els.map((e) => getComputedStyle(e).borderLeftStyle));
assert(lefts.every((s) => s === "none"), "a badge 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();
// the app's menu, not a native select
await row("work").hover();
await badge("work").click();
const menu = page.locator(".proto-menu");
await menu.waitFor();
assert.equal(await page.locator("select").count(), 0, "no native select");
assert.equal((await menu.locator(".pm-head").textContent()).trim(), w.label);
assert.deepEqual(await scrolled(), at, "opening the menu moved the page");
assert.equal(await menu.locator(".pm-item.on").count(), 1, "All models is ticked for a free key");
// a short window: the menu stays in it, scrolling, rather than running off it
// (reduced motion still runs a .01ms transition, so it is waited for)
await page.waitForFunction(() => {
const r = document.querySelector(".proto-menu").getBoundingClientRect();
return r.top >= 0 && r.bottom <= innerHeight;
}, null, { timeout: 2000 }).catch(async () => assert.fail(`the menu runs off the window: ${JSON.stringify(await menu.boundingBox())}`));
// picks stay in the menu, sent only when it closes
await menu.locator(".pm-item", { hasText: w.relay }).click();
await menu.locator(".pm-item", { hasText: "GPT-5" }).click();
assert.equal(events.filter((e) => e.action === "models-key").length, 0, "a pick was sent before the menu closed");
assert.equal(await menu.isVisible(), true, "a pick closed the menu");
await page.keyboard.press("Escape");
await menu.waitFor({ state: "detached" });
assert.deepEqual(events.filter((e) => e.action === "models-key").map((e) => e.body), [{ key: "work", models: ["relay/*", "openai/gpt-5"] }]);
await page.waitForFunction(() => document.querySelector('#gatewayKeys .acc[data-key="work"] .key-models.set'));
assert.equal((await badge("work").textContent()).trim(), w.two);
assert.deepEqual(await scrolled(), at, "a pick moved the page");
// All models takes it off
await badge("server").click();
await menu.waitFor();
assert.equal(await menu.locator(".pm-item.on").count(), 2);
await menu.locator(".pm-item", { hasText: w.all }).first().click();
await menu.waitFor({ state: "detached" });
assert.deepEqual(events.filter((e) => e.action === "models-key").at(-1).body, { key: "server", models: [] });
await page.waitForFunction(() => !document.querySelector('#gatewayKeys .acc[data-key="server"] .key-models.set'));
assert.deepEqual(await scrolled(), at, "no click moved the page");
// narrow: the row fits
await page.setViewportSize({ width: 560, height: 740 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), false);
assert.deepEqual(errors, []);
});
}
}
+1
View File
@@ -92,6 +92,7 @@ const usage = `magpie — one place to pick every agent's model
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 gateway-key models <id> [all|<provider>/<model>|<provider>/* ...] the models a key may use, every one unless it names some
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