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:
1 parent
4dab45e3ff
commit
46f03154ff
27 files changed
+854
-17
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ".*") + "$")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ", "))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user