diff --git a/docs/reference.md b/docs/reference.md index 04d2c02d..85f90f57 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -210,6 +210,8 @@ magpie gateway-key remove # revokes remote access magpie gateway-key limit week --tokens 2m --cost 5 # its own limit magpie gateway-key limit # limit, used, left and reset magpie gateway-key limit off # no limit +magpie gateway-key models openai/gpt-5 anthropic/* # only these models +magpie gateway-key models 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 +openai/gpt-5 anthropic/*` (`all` takes the restriction off). A pattern is +`/` or `/*`, 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. diff --git a/gateway_keys_cli.go b/gateway_keys_cli.go index 52ebf015..38789286 100644 --- a/gateway_keys_cli.go +++ b/gateway_keys_cli.go @@ -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 | rotate | remove | limit [off | day|week|month [--tokens N] [--cost USD] [--cache-reads]]") + return fmt.Errorf("usage: magpie gateway-key list | add | rotate | remove | limit [off | day|week|month [--tokens N] [--cost USD] [--cache-reads]] | models [all | /|/* ...]") } 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 the models it may use +// magpie gateway-key models all every model +// magpie gateway-key models 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 [all | /|/* ...]") + } + 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 { diff --git a/gateway_keys_cli_test.go b/gateway_keys_cli_test.go index 73f54da5..8195838a 100644 --- a/gateway_keys_cli_test.go +++ b/gateway_keys_cli_test.go @@ -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) + } +} diff --git a/internal/access/access.go b/internal/access/access.go index 8cba9c1d..75bcf3bc 100644 --- a/internal/access/access.go +++ b/internal/access/access.go @@ -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): "/", + // "/*" or a group's "group/"; 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() diff --git a/internal/access/access_test.go b/internal/access/access_test.go index b3373e17..f7649fd1 100644 --- a/internal/access/access_test.go +++ b/internal/access/access_test.go @@ -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) } }) diff --git a/internal/access/legacy_fallback_test.go b/internal/access/legacy_fallback_test.go index 533a13a6..31df23d7 100644 --- a/internal/access/legacy_fallback_test.go +++ b/internal/access/legacy_fallback_test.go @@ -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) } } diff --git a/internal/access/models.go b/internal/access/models.go new file mode 100644 index 00000000..5f38fc78 --- /dev/null +++ b/internal/access/models.go @@ -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, +// "/", every model of one, "/*", or a routing +// group, "group/". +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 / or /*, 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 ("/") 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, ".*") + "$") +} diff --git a/internal/access/models_test.go b/internal/access/models_test.go new file mode 100644 index 00000000..4797f155 --- /dev/null +++ b/internal/access/models_test.go @@ -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)) + } +} diff --git a/internal/gateway/codex_titles.go b/internal/gateway/codex_titles.go index cefbac48..15e6b9b1 100644 --- a/internal/gateway/codex_titles.go +++ b/internal/gateway/codex_titles.go @@ -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 { diff --git a/internal/gateway/draw.go b/internal/gateway/draw.go index b55d4e3e..258baa31 100644 --- a/internal/gateway/draw.go +++ b/internal/gateway/draw.go @@ -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() diff --git a/internal/gateway/embeddings.go b/internal/gateway/embeddings.go index ffd43372..079f1852 100644 --- a/internal/gateway/embeddings.go +++ b/internal/gateway/embeddings.go @@ -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 } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index c68bed24..65d14052 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -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 diff --git a/internal/gateway/key_models.go b/internal/gateway/key_models.go new file mode 100644 index 00000000..101f856f --- /dev/null +++ b/internal/gateway/key_models.go @@ -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, "/", 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, ", ")) +} diff --git a/internal/gateway/key_models_test.go b/internal/gateway/key_models_test.go new file mode 100644 index 00000000..7bf1a0db --- /dev/null +++ b/internal/gateway/key_models_test.go @@ -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") + } +} diff --git a/internal/gateway/search.go b/internal/gateway/search.go index 1063dffb..583be62e 100644 --- a/internal/gateway/search.go +++ b/internal/gateway/search.go @@ -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 } diff --git a/internal/gateway/video.go b/internal/gateway/video.go index 1ce2c678..8da8f456 100644 --- a/internal/gateway/video.go +++ b/internal/gateway/video.go @@ -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 diff --git a/internal/gateway/vision.go b/internal/gateway/vision.go index bfdd36d6..d7578f29 100644 --- a/internal/gateway/vision.go +++ b/internal/gateway/vision.go @@ -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 } diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 0e8b7304..af5513ca 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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); } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 06d71063..c33ba0c1 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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"; diff --git a/internal/gui/assets/caller-keys.js b/internal/gui/assets/caller-keys.js index c2c0891a..64a076ad 100644 --- a/internal/gui/assets/caller-keys.js +++ b/internal/gui/assets/caller-keys.js @@ -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"]; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 8eb8584e..d8b8dc64 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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}", diff --git a/internal/gui/caller_key_models_test.go b/internal/gui/caller_key_models_test.go new file mode 100644 index 00000000..5cf39bcb --- /dev/null +++ b/internal/gui/caller_key_models_test.go @@ -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) + } +} diff --git a/internal/gui/caller_keys.go b/internal/gui/caller_keys.go index ae8cdfaf..8a165162 100644 --- a/internal/gui/caller_keys.go +++ b/internal/gui/caller_keys.go @@ -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"` // "/", 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 { diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 0f2f415a..7a58124c 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -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 diff --git a/internal/gui/tests/fixtures/caller-keys.cjs b/internal/gui/tests/fixtures/caller-keys.cjs index 86562ee3..1ff3840b 100644 --- a/internal/gui/tests/fixtures/caller-keys.cjs +++ b/internal/gui/tests/fixtures/caller-keys.cjs @@ -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 }); } diff --git a/internal/gui/tests/gateway-key-models.test.cjs b/internal/gui/tests/gateway-key-models.test.cjs new file mode 100644 index 00000000..06cfe269 --- /dev/null +++ b/internal/gui/tests/gateway-key-models.test.cjs @@ -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, []); + }); + } +} diff --git a/main.go b/main.go index d8124da7..7224f3ea 100644 --- a/main.go +++ b/main.go @@ -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 |rotate |remove manage the keys clients use to call a shared gateway magpie gateway-key limit [off|day|week|month --tokens N --cost USD --cache-reads] a key's own limit, and what it used + magpie gateway-key models [all|/|/* ...] 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 ] [today|7d|30d|all] every request as CSV (or one account's): the model asked for, sent and served, tokens, cost, time, status, account