* feat(gui): show and filter usage for each API key * feat(gateway): issue per-user caller keys and attribute usage * feat(gui): manage gateway users and filter caller-key usage * test: verify caller-key failover and document user management * refactor(gateway): use named caller keys without user accounts * feat(gui): manage named caller keys with existing account styles * fix: remove user breakdown from CLI and leftover gateway styling * fix(usage): drop stale user field from request response * feat(gateway): authenticate LAN calls with named caller keys * fix(gateway): preserve LAN key rotation in the named caller store * fix(gui): retain native LAN key copy and rotation controls * fix(gui): inherit the global theme for API key inputs * fix(usage): show client API keys using the existing terminology * fix(gateway): rotate named keys without changing usage identity * fix(gui): unify gateway keys and selectable connection examples Keep sharing controls in Settings and manage credentials only in Gateway. Refresh the named-key list on navigation, ignore stale reads after key actions, and generate all API examples from the selected address and enabled key. * fix(gui): keep caller key names literal in connection menus * fix(access): use Magpie as default key and drop user migration * refactor(gui): name caller key source and tests consistently * refactor(usage): split provider-key accounting out of caller-key PR * fix(gateway): preserve LAN compatibility and add headless key management * fix(gui): clarify gateway keys and confirm destructive actions * test(gateway): guard caller attribution under 120 concurrent requests * fix(gateway): keep open access, tolerate migration errors and revoke legacy keys * fix(backup): preserve named gateway keys and honor no-keys restores * fix(gui): keep local API-key semantics and disambiguate shared credentials * docs(gateway): keep review scope focused and append browser test documentation * fix(backup): retain existing default keys when old backups lack their store * fix(gateway): attribute video creation usage to the caller key * fix(access): explain default key revocation blocked by read-only settings * test(usage): preserve provider and caller identities after rebase * test(gui): reconcile caller key fixtures with merged main * docs(usage): clarify caller coverage and independent provider accounting * fix(gui): initialize fast members for model-shortcut groups Latest main introduced fast-member arrays but omitted the model shortcut's draft. The existing unlisted-models browser regression fails unchanged on main. Initialize fast to an empty list and assert a standard-speed group is saved, without relaxing existing assertions. * fix(access): retain legacy LAN access when the key store cannot be created Allow only the configured legacy credential before either migration marker exists. Refuse revoked mirrors, disabled matches and unreadable stores; keep the fallback's caller identity stable when migration later succeeds. Cover read-only configuration directories, remote credential transports and revocation. * fix(backup): save settings before replacing gateway credentials Leave the key store untouched when settings validation or writes fail. Restore the previous settings association when the key store refuses its write, and report a rollback failure separately. Cover invalid settings and read-only settings/store failures. * fix(gui): reset missing caller filters and focus key controls without scrolling * test(provider): synchronize Zhipu quota fixture observations
919 lines
29 KiB
Go
919 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"net"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/yetone/magpie/internal/agent"
|
|
"github.com/yetone/magpie/internal/catalog"
|
|
"github.com/yetone/magpie/internal/gateway"
|
|
"github.com/yetone/magpie/internal/provider"
|
|
"github.com/yetone/magpie/internal/settings"
|
|
"github.com/yetone/magpie/internal/stats"
|
|
)
|
|
|
|
var amber = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#B45309", Dark: "#F2B544"})
|
|
|
|
const providerUsage = `usage:
|
|
magpie providers list providers, keys and who uses them
|
|
magpie presets list the vendors magpie knows out of the box
|
|
magpie provider <id> show one provider and its models
|
|
magpie provider add <preset> <key> add a preset vendor e.g. magpie provider add deepseek sk-…
|
|
again, it adds another (deepseek-2); k=v pairs too: id, name, header.X-Foo
|
|
magpie provider add <name> k=v… add a custom vendor k: url, anthropic, responses, key, models, catalog, icon, header.X-Foo, balance, balance.path, balance.token, models.url, search
|
|
magpie provider set <id> k=v… change a provider's settings, with the same k=v pairs as add
|
|
magpie provider key <id> <key> change the API key
|
|
magpie provider icon <id> <file|name> give a custom provider a picture (PNG, JPEG, SVG…) or a built-in icon
|
|
magpie provider fallback <id> <provider/model>… where requests go when it's out of quota or down (none clears)
|
|
magpie provider models <id> [ids…] fetch the vendor's model list, or choose which models to expose
|
|
magpie provider listed <id> yes|no no: its models serve only through routing groups, not in the list
|
|
magpie provider off|on <id> switch it off (kept, but no agent or request uses it), or on again
|
|
magpie provider test <id> [model…] send a tiny request through each endpoint, or to each model
|
|
magpie provider rm <id> remove a provider
|
|
|
|
e.g. magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-…
|
|
magpie provider add "Own Claude" anthropic=https://gw.example.com key=sk-… catalog=anthropic
|
|
magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-… header.X-Org-Id=acme
|
|
magpie provider add remote-magpie sk-magpie-… url=http://192.168.1.20:3425 id=office
|
|
(another computer's magpie, shared on its network: its models and routing
|
|
groups as office/…, each request sent on in the API the agent spoke)
|
|
magpie provider add anthropic sk-… id=anthropic-ws2 name="Anthropic WS2" header.anthropic-workspace-id=wrkspc_…
|
|
magpie provider set my-relay models.url=https://relay.example.com/api/models catalog=
|
|
magpie provider set my-relay search=yes
|
|
(the relay answers Claude Code's WebSearch and Codex's web_search itself:
|
|
those go to it as sent, not through magpie's own search)
|
|
magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-… balance=https://relay.example.com/api/usage/token balance.path='$data.total_available / 500000'
|
|
magpie provider set my-relay balance.path='(1 - credits.monthlyCredits / 70) %'
|
|
magpie provider set my-relay balance=https://relay.example.com/api/user/self balance.path='$data.quota / 500000' balance.token=<access token> header.New-Api-User=<user id>
|
|
(a new-api relay's whole account: its access token and user id, from its personal settings)
|
|
(balance.path: where the amount is in the reply, or a sum of those with + - * / and
|
|
brackets; $ or ¥ in front adds the sign, % after it shows a percent of 1;
|
|
several go apart by ; each with a label: '5h: a.used / a.cap %; $credits.left')
|
|
magpie provider set my-relay context=272k context.gpt-6=1m
|
|
(context: how long a request agents are told the models take, over what the
|
|
vendor or models.dev says; context.<model> for one of them; empty clears)
|
|
magpie provider set opencode-go family=ocgo
|
|
magpie provider set my-relay id=relay (renames it: groups and agents on my-relay/… move to relay/…)
|
|
(family: a tag for which agents are shown its models, see magpie visible)`
|
|
|
|
// providers: `magpie providers`
|
|
func providers() error {
|
|
all := provider.All()
|
|
// a providers.json that can't be read is not "no providers yet": what
|
|
// is listed is then the signed-in accounts alone, and it ends in why
|
|
bad := provider.FileError()
|
|
if len(all) == 0 && len(provider.Excluded()) == 0 {
|
|
if bad != nil {
|
|
return bad
|
|
}
|
|
fmt.Println(muted.Render("no providers yet ·"), "magpie provider add deepseek sk-…", muted.Render("· magpie presets lists the vendors"))
|
|
return nil
|
|
}
|
|
uses := usesByProvider()
|
|
type row struct{ name, id, host, key, models, uses string }
|
|
var rows []row
|
|
w := [5]int{}
|
|
for _, p := range all {
|
|
r := row{name: bold.Render(p.Name), id: muted.Render(p.ID), host: p.Host()}
|
|
if p.Preset == "" && p.Account == nil {
|
|
r.name += " " + faint.Render("custom")
|
|
}
|
|
switch {
|
|
case p.Off:
|
|
r.key = faint.Render("○ switched off")
|
|
case p.Account != nil:
|
|
r.key = green.Render("●") + " " + muted.Render("signed in as "+p.Account.User)
|
|
case p.Key != "":
|
|
r.key = green.Render("●") + " " + muted.Render(provider.Mask(p.Key))
|
|
case p.Ready():
|
|
r.key = green.Render("●") + " " + muted.Render("no key needed")
|
|
default:
|
|
r.key = amber.Render("○ no key")
|
|
}
|
|
n := len(p.Exposed())
|
|
if t, ok := p.Listed(); ok {
|
|
r.models = fmt.Sprintf("%d of %d models", n, len(p.Available())) + muted.Render(" · fetched "+ago(t))
|
|
} else {
|
|
r.models = fmt.Sprintf("%d models", n)
|
|
}
|
|
if u := uses[p.ID]; len(u) > 0 {
|
|
r.uses = green.Render("← " + strings.Join(u, ", "))
|
|
}
|
|
if len(p.Fallback) > 0 {
|
|
r.uses += muted.Render(" ⤷ " + strings.Join(p.Fallback, " → "))
|
|
}
|
|
for i, s := range []string{r.name, r.id, r.host, r.key, r.models} {
|
|
w[i] = max(w[i], lipgloss.Width(s))
|
|
}
|
|
rows = append(rows, r)
|
|
}
|
|
for _, r := range rows {
|
|
fmt.Printf(" %s %s %s %s %s %s\n", pad(r.name, w[0]), pad(r.id, w[1]), pad(r.host, w[2]), pad(r.key, w[3]), pad(r.models, w[4]), r.uses)
|
|
}
|
|
for _, x := range provider.Excluded() {
|
|
name := x.Agent
|
|
if a, err := agent.Find(x.Agent); err == nil {
|
|
name = a.Name
|
|
}
|
|
fmt.Println()
|
|
if x.SignedOut {
|
|
fmt.Println(" ", amber.Render("!"), name+"'s saved accounts are not offered: "+x.Why)
|
|
continue
|
|
}
|
|
back := ""
|
|
if x.Provider != "" {
|
|
back = " · magpie provider add " + x.Provider + " brings it back"
|
|
}
|
|
fmt.Println(" ", muted.Render(name+" is signed in but not offered: "+x.Why+back))
|
|
}
|
|
return bad
|
|
}
|
|
|
|
// usesByProvider maps provider ids to the agents currently routed to them.
|
|
func usesByProvider() map[string][]string {
|
|
out := map[string][]string{}
|
|
for _, a := range agent.Detected() {
|
|
if len(a.Fields) == 0 {
|
|
continue
|
|
}
|
|
v := a.Fields[0].Get()
|
|
v = strings.TrimPrefix(v, "magpie/")
|
|
if strings.HasPrefix(v, provider.GroupPrefix) {
|
|
continue // a routing group, of no one provider
|
|
}
|
|
if pid, _, ok := strings.Cut(v, "/"); ok {
|
|
out[pid] = append(out[pid], a.Name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// presets: `magpie presets`
|
|
func presets() error {
|
|
have := map[string]bool{}
|
|
for _, p := range provider.All() {
|
|
have[p.ID], have[p.Preset] = true, true
|
|
}
|
|
kind := provider.Kind("")
|
|
for _, pr := range provider.Presets() {
|
|
if pr.Kind != kind {
|
|
kind = pr.Kind
|
|
fmt.Println(faint.Render(" " + map[provider.Kind]string{provider.KindVendor: "vendors", provider.KindRelay: "relays", provider.KindLocal: "local"}[kind]))
|
|
}
|
|
name := bold.Render(pr.Name)
|
|
if pr.Sponsored {
|
|
name += " " + faint.Render("sponsored")
|
|
}
|
|
state := muted.Render("magpie provider add " + pr.ID + " <key>")
|
|
if pr.NoKey {
|
|
state = muted.Render("magpie provider add " + pr.ID)
|
|
}
|
|
if have[pr.ID] {
|
|
state = green.Render("✓ added")
|
|
}
|
|
fmt.Printf(" %s %s %s\n", pad(name, 28), pad(muted.Render(pr.ID), 14), state)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// models: `magpie models [<agent>]` — the catalog every agent sees, or the
|
|
// one agent is shown and what is kept from it
|
|
func models(args []string) error {
|
|
entries := provider.Catalog()
|
|
bad := provider.FileError() // as in providers: said, not "no models yet"
|
|
var hidden []provider.Entry
|
|
agentID := ""
|
|
if len(args) > 0 {
|
|
agentID = strings.ToLower(strings.TrimPrefix(args[0], "--agent="))
|
|
if agentID == "--agent" && len(args) > 1 {
|
|
agentID = strings.ToLower(args[1])
|
|
}
|
|
if agentID = agentOf(agentID); !knownAgent(agentID) {
|
|
return fmt.Errorf("no agent %q (%s)", agentID, strings.Join(agentIDs(), ", "))
|
|
}
|
|
entries, hidden = provider.CatalogFor(agentID)
|
|
}
|
|
if len(entries) == 0 && agentID != "" {
|
|
names, _ := provider.VisibleTo(agentID)
|
|
fmt.Println(amber.Render("!"), agentID, "is shown none of them: nothing is in", strings.Join(names, ", "), muted.Render("· magpie visible "+agentID+" all shows it every model"))
|
|
} else if len(entries) == 0 && bad != nil {
|
|
return bad
|
|
} else if len(entries) == 0 {
|
|
fmt.Println(muted.Render("no models yet · add a provider first:"), "magpie provider add deepseek sk-…")
|
|
return nil
|
|
}
|
|
w := 0
|
|
for _, e := range entries {
|
|
w = max(w, len(e.ID))
|
|
}
|
|
last := ""
|
|
for _, e := range entries {
|
|
head, name := e.Provider.ID, e.Provider.Name
|
|
if e.Group != "" {
|
|
head, name = provider.GroupPrefix, "Routing groups"
|
|
}
|
|
if head != last {
|
|
last = head
|
|
fmt.Println(faint.Render(" " + name))
|
|
}
|
|
line := " " + pad(e.ID, w)
|
|
if e.Name != "" && e.Name != e.Model {
|
|
line += " " + muted.Render(e.Name)
|
|
}
|
|
if len(e.Efforts) > 0 {
|
|
line += faint.Render(" " + strings.Join(e.Efforts, "/"))
|
|
}
|
|
fmt.Println(line)
|
|
}
|
|
if agentID != "" {
|
|
explainHidden(agentID, hidden)
|
|
}
|
|
fmt.Println(faint.Render(" " + gateway.URL() + "/v1"))
|
|
return bad
|
|
}
|
|
|
|
// providerCmd: `magpie provider <verb> …`
|
|
func providerCmd(args []string) error {
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("%s", providerUsage)
|
|
}
|
|
verb, rest := args[1], args[2:]
|
|
switch verb {
|
|
case "add":
|
|
return addProvider(rest)
|
|
case "set":
|
|
// the same k=v pairs as add, on a provider already here
|
|
if len(rest) < 2 {
|
|
return fmt.Errorf("magpie provider set <id> k=v…\n\n%s", providerUsage)
|
|
}
|
|
p, err := provider.Find(rest[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// id= renames it: the rest is saved under the id it has, then the
|
|
// groups and agents on its models move to the new one
|
|
from := p.ID
|
|
if err := applyPairs(p, rest[1:]); err != nil {
|
|
return err
|
|
}
|
|
to := strings.ToLower(strings.TrimSpace(p.ID))
|
|
p.ID = from
|
|
if err := provider.Save(*p); err != nil {
|
|
return err
|
|
}
|
|
if to != from {
|
|
moved, err := agent.RenameProvider(from, to)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.ID = to
|
|
if len(moved) > 0 {
|
|
fmt.Println(green.Render("✓"), strings.Join(moved, ", "), "moved to", to+"/…")
|
|
}
|
|
}
|
|
fmt.Println(green.Render("✓"), "saved", p.Name, muted.Render("("+p.ID+")"))
|
|
return nil
|
|
case "key":
|
|
if len(rest) != 2 {
|
|
return fmt.Errorf("magpie provider key <id> <key>")
|
|
}
|
|
p, err := provider.Find(rest[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Key = rest[1]
|
|
if err := provider.Save(*p); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(green.Render("✓"), p.Name, "key", muted.Render(provider.Mask(p.Key)))
|
|
return nil
|
|
case "icon":
|
|
if len(rest) != 2 {
|
|
return fmt.Errorf("magpie provider icon <id> <picture file | built-in name | \"\">")
|
|
}
|
|
p, err := provider.Find(rest[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if p.Account != nil || p.Preset != "" {
|
|
return fmt.Errorf("%s has its own icon; only a custom provider takes one", p.Name)
|
|
}
|
|
p.Icon = rest[1]
|
|
if err := applyPairs(p, []string{"icon=" + rest[1]}); err != nil {
|
|
return err
|
|
}
|
|
if p.Icon == "" {
|
|
p.Icon = "generic"
|
|
}
|
|
if err := provider.Save(*p); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(green.Render("✓"), p.Name, "icon", muted.Render(p.Icon))
|
|
return nil
|
|
case "fallback":
|
|
// where requests go when this provider is out of quota, rate
|
|
// limited or down; "none" clears the list
|
|
if len(rest) < 1 {
|
|
return fmt.Errorf("magpie provider fallback <id> [provider/model… | none]")
|
|
}
|
|
p, err := provider.Find(rest[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(rest) > 1 {
|
|
p.Fallback = nil
|
|
if !(len(rest) == 2 && rest[1] == "none") {
|
|
for _, id := range rest[1:] {
|
|
if _, _, ok := provider.Resolve(id); !ok {
|
|
return fmt.Errorf("magpie knows no model %q (magpie models lists them)", id)
|
|
}
|
|
p.Fallback = append(p.Fallback, id)
|
|
}
|
|
}
|
|
if err := provider.Save(*p); err != nil {
|
|
return err
|
|
}
|
|
if p, err = provider.Find(p.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(p.Fallback) == 0 {
|
|
fmt.Println(p.Name, muted.Render("has no fallback · magpie provider fallback "+p.ID+" <provider/model>…"))
|
|
return nil
|
|
}
|
|
fmt.Println(green.Render("✓"), p.Name, "falls back to", strings.Join(p.Fallback, muted.Render(" → ")),
|
|
muted.Render("· when out of quota, rate limited or down"))
|
|
return nil
|
|
case "rm", "remove", "delete":
|
|
if len(rest) != 1 {
|
|
return fmt.Errorf("magpie provider rm <id>")
|
|
}
|
|
p, err := provider.Find(rest[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
moved, err := agent.Reseat(func() error { return provider.Delete(p.ID) })
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(green.Render("✓"), "removed", p.Name)
|
|
printMoved(moved)
|
|
return nil
|
|
case "test":
|
|
// with models named, a request to each of them; else one per endpoint
|
|
if len(rest) < 1 {
|
|
return fmt.Errorf("magpie provider test <id> [model…]")
|
|
}
|
|
p, err := provider.Find(rest[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, wait := p.Test, 30*time.Second
|
|
if models := rest[1:]; len(models) > 0 {
|
|
res, wait = func(ctx context.Context) []provider.Result { return p.TestModels(ctx, models) }, 90*time.Second
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), wait)
|
|
defer cancel()
|
|
ok := true
|
|
for _, r := range res(ctx) {
|
|
if r.OK {
|
|
fmt.Printf(" %s %-10s %s\n", green.Render("✓"), r.Protocol, muted.Render(fmt.Sprintf("%d ms · %s", r.Millis, r.Model)))
|
|
} else {
|
|
ok = false
|
|
msg := r.Error
|
|
if r.Status != 0 {
|
|
msg = fmt.Sprintf("%d · %s", r.Status, r.Error)
|
|
}
|
|
if len(rest) > 1 {
|
|
msg = r.Model + " · " + msg
|
|
}
|
|
fmt.Printf(" %s %-10s %s\n", amber.Render("✗"), r.Protocol, msg)
|
|
}
|
|
}
|
|
if !ok {
|
|
os.Exit(1)
|
|
}
|
|
return nil
|
|
case "listed":
|
|
// no: the provider's models leave the list agents see and serve
|
|
// only through the routing groups they are in
|
|
if len(rest) != 2 || (rest[1] != "yes" && rest[1] != "no") {
|
|
return fmt.Errorf("magpie provider listed <id> yes|no")
|
|
}
|
|
p, err := provider.Find(rest[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Unlisted = rest[1] == "no"
|
|
if err := provider.Save(*p); err != nil {
|
|
return err
|
|
}
|
|
if p.Unlisted {
|
|
fmt.Println(green.Render("✓"), p.Name, muted.Render("serves only through routing groups"))
|
|
} else {
|
|
fmt.Println(green.Render("✓"), p.Name, muted.Render("its models are listed"))
|
|
}
|
|
return nil
|
|
case "off", "on":
|
|
// off: kept with its keys, but agents are given none of its
|
|
// models and no request goes to it
|
|
if len(rest) != 1 {
|
|
return fmt.Errorf("magpie provider %s <id>", verb)
|
|
}
|
|
p, err := provider.Find(rest[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
moved, err := agent.Reseat(func() error { return provider.SetOff(p.ID, verb == "off") })
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer printMoved(moved)
|
|
if verb == "off" {
|
|
fmt.Println(green.Render("✓"), p.Name, muted.Render("is switched off: agents are given none of its models"))
|
|
} else {
|
|
fmt.Println(green.Render("✓"), p.Name, muted.Render("is switched on"))
|
|
}
|
|
return nil
|
|
case "models":
|
|
if len(rest) < 1 {
|
|
return fmt.Errorf("magpie provider models <id> [model ids to expose…]")
|
|
}
|
|
p, err := provider.Find(rest[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(rest) > 1 {
|
|
p.Models = rest[1:]
|
|
if len(rest) == 2 && (rest[1] == "-" || rest[1] == "all") {
|
|
p.Models = nil
|
|
}
|
|
if err := provider.Save(*p); err != nil {
|
|
return err
|
|
}
|
|
return showProvider(*p)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
ms, err := p.Fetch(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(green.Render("✓"), len(ms), "models from", fetchedFrom(*p))
|
|
return showProvider(*p)
|
|
}
|
|
// `magpie provider <id>`
|
|
p, err := provider.Find(verb)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return showProvider(*p)
|
|
}
|
|
|
|
// addProvider: `magpie provider add <preset> [key]` or `magpie provider add <name> k=v…`
|
|
func addProvider(rest []string) error {
|
|
if len(rest) == 0 {
|
|
return fmt.Errorf("magpie provider add <preset> <key> or magpie provider add <name> k=v…\n\n%s", providerUsage)
|
|
}
|
|
if len(rest) == 1 && slices.ContainsFunc(provider.Excluded(), func(x provider.Exclusion) bool { return x.Provider == strings.ToLower(rest[0]) }) {
|
|
// a signed-in account the user removed comes back with its picks
|
|
if err := provider.ShowAccount(strings.ToLower(rest[0])); err != nil {
|
|
return err
|
|
}
|
|
return announce(strings.ToLower(rest[0]))
|
|
}
|
|
var p provider.Provider
|
|
if pr, err := provider.FromPreset(strings.ToLower(rest[0])); err == nil {
|
|
p = pr
|
|
if len(rest) > 1 && !strings.Contains(rest[1], "=") {
|
|
p.Key = rest[1]
|
|
rest = rest[2:]
|
|
} else {
|
|
rest = rest[1:]
|
|
}
|
|
} else {
|
|
p = provider.Provider{Name: rest[0]}
|
|
rest = rest[1:]
|
|
}
|
|
if err := applyPairs(&p, rest); err != nil {
|
|
return err
|
|
}
|
|
// adding a preset that is already here adds another of it (deepseek-2),
|
|
// for another key or another header, rather than replacing the first
|
|
id, err := provider.Add(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return announce(id)
|
|
}
|
|
|
|
// saveNew saves a provider the user just added, then asks the vendor for
|
|
// its models.
|
|
func saveNew(p provider.Provider) error {
|
|
if err := provider.Save(p); err != nil {
|
|
return err
|
|
}
|
|
id := p.ID
|
|
if _, err := provider.Find(id); err != nil {
|
|
id = p.Name
|
|
}
|
|
return announce(id)
|
|
}
|
|
|
|
// announce says a provider was added and asks the vendor for its models.
|
|
func announce(id string) error {
|
|
saved, err := provider.Find(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(green.Render("✓"), "added", saved.Name, muted.Render("("+saved.ID+")"))
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
if ms, err := saved.Fetch(ctx); err == nil {
|
|
fmt.Println(green.Render("✓"), len(ms), "models from", fetchedFrom(*saved))
|
|
} else if !saved.Decides() {
|
|
// the URLs asked and what they said; the base stays as given
|
|
fmt.Println(amber.Render("!"), muted.Render(err.Error()))
|
|
}
|
|
n := len(saved.Exposed())
|
|
if saved.Decides() {
|
|
fmt.Println(" it routes groups: magpie group set <id> effort=auto classifier="+saved.ID+"/"+provider.JevLatest,
|
|
muted.Render("· or a rule's intent=…"))
|
|
return nil
|
|
}
|
|
if n == 0 {
|
|
fmt.Println(amber.Render("!"), "no models exposed yet ·", "magpie provider models", saved.ID, "<ids…>")
|
|
} else {
|
|
fmt.Printf(" %d models in the catalog · %s\n", n, muted.Render("magpie models"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func showProvider(p provider.Provider) error {
|
|
kv := func(k, v string) {
|
|
// a plugin's provider is reached through the plugin, not a URL
|
|
if v != "" && !strings.HasPrefix(v, "plugin://") {
|
|
fmt.Printf(" %s %s\n", muted.Render(pad(k, 10)), v)
|
|
}
|
|
}
|
|
name := bold.Render(p.Name) + muted.Render(" "+p.ID)
|
|
if p.Preset == "" && p.Account == nil {
|
|
name += faint.Render(" custom")
|
|
}
|
|
fmt.Println(" ", name)
|
|
kv("chat", p.Chat)
|
|
kv("responses", p.Responses)
|
|
kv("anthropic", p.Anthropic)
|
|
if p.Searches {
|
|
kv("search", "by itself"+muted.Render(" a client's web search goes to it as sent"))
|
|
}
|
|
switch {
|
|
case p.Account != nil:
|
|
who := p.Account.User
|
|
if p.Account.Plan != "" {
|
|
who += muted.Render(" " + p.Account.Plan)
|
|
}
|
|
from := p.Account.Agent + "'s own sign-in"
|
|
if p.IsPlugin() {
|
|
from = p.Name + "'s sign-in"
|
|
if provider.Moved(p.ID) {
|
|
from = p.ID + "'s own sign-in" // as the built-in said
|
|
}
|
|
}
|
|
kv("account", who+muted.Render(" from "+from))
|
|
case p.Key != "":
|
|
kv("key", muted.Render(provider.Mask(p.Key)))
|
|
case p.Ready():
|
|
kv("key", muted.Render("none needed"))
|
|
default:
|
|
kv("key", amber.Render("not set")+muted.Render(" magpie provider key "+p.ID+" …"))
|
|
}
|
|
kv("catalog", p.Catalog)
|
|
kv("website", p.Website)
|
|
kv("keys", p.KeysURL)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
|
if amount, ok, err := provider.Balance(ctx, p); ok {
|
|
from := ""
|
|
if p.BalanceURL != "" {
|
|
from = muted.Render(" from " + p.BalanceURL + " " + p.BalancePath)
|
|
}
|
|
if err != nil {
|
|
kv("balance", amber.Render("unavailable")+muted.Render(" "+err.Error())+from)
|
|
} else {
|
|
kv("balance", bold.Render(amount)+from)
|
|
}
|
|
}
|
|
cancel()
|
|
for _, k := range slices.Sorted(maps.Keys(p.Headers)) {
|
|
kv("header", k+": "+muted.Render(p.Headers[k]))
|
|
}
|
|
ms := p.Exposed()
|
|
src := "models.dev"
|
|
if t, ok := p.Listed(); ok {
|
|
src = fetchedFrom(p) + " · fetched " + ago(t)
|
|
}
|
|
kv("models", fmt.Sprintf("%d exposed of %d %s", len(ms), len(p.Available()), muted.Render("from "+src)))
|
|
names := p.ModelNames()
|
|
for i, m := range ms {
|
|
if i == 12 {
|
|
fmt.Println(faint.Render(fmt.Sprintf(" … %d more", len(ms)-12)))
|
|
break
|
|
}
|
|
line := " " + p.ID + "/" + m.ID
|
|
if n, ok := names[m.ID]; ok {
|
|
m.Name = n
|
|
}
|
|
if m.Name != "" && m.Name != m.ID {
|
|
line += muted.Render(" " + m.Name)
|
|
}
|
|
fmt.Println(line)
|
|
}
|
|
if u := usesByProvider()[p.ID]; len(u) > 0 {
|
|
kv("used by", green.Render(strings.Join(u, ", ")))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyPairs(p *provider.Provider, pairs []string) error {
|
|
for _, kv := range pairs {
|
|
k, v, ok := strings.Cut(kv, "=")
|
|
if !ok {
|
|
return fmt.Errorf("expected key=value, got %q\n\n%s", kv, providerUsage)
|
|
}
|
|
switch strings.ToLower(k) {
|
|
case "id":
|
|
p.ID = v
|
|
case "name":
|
|
p.Name = v
|
|
case "url", "chat", "openai":
|
|
p.Chat = v
|
|
case "responses":
|
|
p.Responses = v
|
|
case "anthropic":
|
|
p.Anthropic = v
|
|
case "key":
|
|
p.Key = v
|
|
case "catalog":
|
|
p.Catalog = v
|
|
case "models":
|
|
p.Models = strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' })
|
|
case "website":
|
|
p.Website = v
|
|
case "keys":
|
|
p.KeysURL = v
|
|
case "balance":
|
|
p.BalanceURL = v
|
|
case "balance.path":
|
|
p.BalancePath = v
|
|
case "balance.token":
|
|
p.BalanceToken = v
|
|
case "models.url":
|
|
p.ModelsURL = v
|
|
case "search":
|
|
// yes: the vendor searches the web by itself, a web search a
|
|
// client offers going to it as it was sent (a relay in front of
|
|
// Anthropic's or OpenAI's API)
|
|
if v != "yes" && v != "no" {
|
|
return fmt.Errorf("search=yes|no, not %q", v)
|
|
}
|
|
p.Searches = v == "yes"
|
|
case "context":
|
|
if err := setContext(p, "*", v); err != nil {
|
|
return err
|
|
}
|
|
case "family", "tag":
|
|
p.Family = strings.TrimSpace(v)
|
|
case "icon":
|
|
// a picture on disk is kept by magpie; anything else is one of
|
|
// the built-in icons' names
|
|
if st, err := os.Stat(v); err == nil && !st.IsDir() {
|
|
icon, err := provider.StoreIconFile(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v = icon
|
|
}
|
|
p.Icon = v
|
|
default:
|
|
// header.X-Foo=bar sets a custom request header (name kept as
|
|
// typed, replacing one of the same name in any case); an empty
|
|
// value leaves it out
|
|
if len(k) > len("header.") && strings.EqualFold(k[:len("header.")], "header.") {
|
|
name := k[len("header."):]
|
|
for h := range p.Headers {
|
|
if strings.EqualFold(h, name) {
|
|
delete(p.Headers, h)
|
|
}
|
|
}
|
|
if strings.TrimSpace(v) == "" {
|
|
continue
|
|
}
|
|
if p.Headers == nil {
|
|
p.Headers = map[string]string{}
|
|
}
|
|
p.Headers[name] = v
|
|
continue
|
|
}
|
|
if len(k) > len("context.") && strings.EqualFold(k[:len("context.")], "context.") {
|
|
if err := setContext(p, k[len("context."):], v); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
return fmt.Errorf("unknown field %q\n\n%s", k, providerUsage)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// setContext sets the context agents are told model takes ("*" for all
|
|
// the provider's); empty or 0 leaves it to the vendor and models.dev again.
|
|
func setContext(p *provider.Provider, model, v string) error {
|
|
n := 0
|
|
if strings.TrimSpace(v) != "" {
|
|
var err error
|
|
if n, err = parseTokens(v); err != nil {
|
|
return fmt.Errorf("context: %w", err)
|
|
}
|
|
}
|
|
if n == 0 {
|
|
delete(p.Contexts, model)
|
|
return nil
|
|
}
|
|
if p.Contexts == nil {
|
|
p.Contexts = map[string]int{}
|
|
}
|
|
p.Contexts[model] = n
|
|
return nil
|
|
}
|
|
|
|
func ago(t time.Time) string {
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
|
case d < 48*time.Hour:
|
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
|
default:
|
|
return t.Format("Jan 2")
|
|
}
|
|
}
|
|
|
|
// refreshLive re-fetches the model list of every ready provider; `magpie sync`
|
|
// calls it after the catalog download.
|
|
func refreshLive(ctx context.Context) {
|
|
var names []string
|
|
for _, p := range provider.All() {
|
|
if !p.Ready() {
|
|
continue
|
|
}
|
|
c, cancel := context.WithTimeout(ctx, 8*time.Second)
|
|
ms, err := p.Fetch(c)
|
|
cancel()
|
|
if err == nil {
|
|
names = append(names, fmt.Sprintf("%s (%d)", p.ID, len(ms)))
|
|
}
|
|
}
|
|
if len(names) > 0 {
|
|
fmt.Println(green.Render("✓"), "model lists:", strings.Join(names, ", "))
|
|
}
|
|
}
|
|
|
|
// keyNote says who the gateway takes any key from: this machine alone,
|
|
// unless MAGPIE_ADDR puts it on the network (a server, a Docker image)
|
|
// without sharing it from Settings, when it is anyone who reaches it.
|
|
func keyNote() string {
|
|
if s := settings.Load(); s.LAN {
|
|
return "(anything works from this machine; from others, an enabled gateway key — magpie gateway-key add <name>)"
|
|
}
|
|
if h, _, err := net.SplitHostPort(gateway.Addr()); err == nil && h != "localhost" && !net.ParseIP(h).IsLoopback() {
|
|
return "(anything works, from anyone who reaches it — share it from Settings to require a key)"
|
|
}
|
|
return "(anything works; the gateway only listens on localhost)"
|
|
}
|
|
|
|
// containerNote follows the addresses magpie finds for itself in a
|
|
// container, which are the container's own.
|
|
const containerNote = "these are the container's own addresses: other machines use the host's, and MAGPIE_PUBLIC_URL=http://<host>:<port> puts it here"
|
|
|
|
// shareLines are where other machines reach the gateway while it is shared
|
|
// from Settings, for the banner.
|
|
func shareLines() []string {
|
|
if s := settings.Load(); !s.LAN || s.LANKey == "" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, u := range gateway.LANURLs() {
|
|
out = append(out, muted.Render(" network ")+" "+u)
|
|
}
|
|
if gateway.ContainerAddrs() {
|
|
out = append(out, muted.Render(" "+containerNote))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// serve: `magpie serve` — the gateway alone, in the foreground.
|
|
func serve() error {
|
|
s := gateway.New()
|
|
go stats.Run(version, "serve")
|
|
go catalog.KeepFresh() // new models' prices, in a gateway left running
|
|
fmt.Println(green.Render("●"), "magpie gateway on", bold.Render(gateway.URL()))
|
|
fmt.Println(muted.Render(" OpenAI "), gateway.URL()+"/v1/chat/completions", muted.Render("·"), gateway.URL()+"/v1/responses")
|
|
fmt.Println(muted.Render(" Anthropic"), gateway.URL()+"/v1/messages")
|
|
fmt.Println(muted.Render(" key "), gateway.Token, muted.Render(keyNote()))
|
|
for _, l := range shareLines() {
|
|
fmt.Println(l)
|
|
}
|
|
n := len(provider.Catalog())
|
|
if err := provider.FileError(); err != nil {
|
|
fmt.Println(amber.Render("!"), err) // served without it, as no providers
|
|
}
|
|
if n == 0 {
|
|
fmt.Println(amber.Render("!"), "no models yet ·", "magpie provider add deepseek sk-…")
|
|
} else {
|
|
fmt.Printf(" %d models · %s\n", n, muted.Render("magpie models"))
|
|
}
|
|
for _, x := range provider.Excluded() {
|
|
if x.SignedOut {
|
|
fmt.Println(amber.Render("!"), x.Agent+":", x.Why)
|
|
}
|
|
}
|
|
return s.ListenAndServe(context.Background())
|
|
}
|
|
|
|
// importCmd adds the provider a magpie://import link describes, after
|
|
// showing it: magpie import [-y] <link>.
|
|
func importCmd(args []string) error {
|
|
yes := false
|
|
var link string
|
|
for _, a := range args {
|
|
switch a {
|
|
case "-y", "--yes":
|
|
yes = true
|
|
default:
|
|
link = a
|
|
}
|
|
}
|
|
if link == "" {
|
|
return errors.New("magpie import [-y] 'magpie://import?…'")
|
|
}
|
|
p, err := provider.ParseImport(link)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := showProvider(p); err != nil {
|
|
return err
|
|
}
|
|
if old, err := provider.Find(p.ID); err == nil {
|
|
fmt.Println(amber.Render("!"), "replaces your", old.Name)
|
|
}
|
|
if !yes {
|
|
if st, err := os.Stdin.Stat(); err != nil || st.Mode()&os.ModeCharDevice == 0 {
|
|
return errors.New("not a terminal: add -y to import without asking")
|
|
}
|
|
fmt.Print("Add it? [y/N] ")
|
|
var answer string
|
|
_, _ = fmt.Scanln(&answer)
|
|
if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" {
|
|
return errors.New("nothing added")
|
|
}
|
|
}
|
|
if p.Key == "" && !p.Ready() {
|
|
fmt.Println(amber.Render("!"), "the link has no key · magpie provider key", p.ID, "<key>")
|
|
}
|
|
return saveNew(p)
|
|
}
|
|
|
|
// fetchedFrom is where a provider's models were fetched: its API's host, or
|
|
// for a subscription run through its own CLI (Kiro, Devin, Cursor …), which
|
|
// has none, its name.
|
|
func fetchedFrom(p provider.Provider) string {
|
|
if h := p.Host(); h != "" {
|
|
return h
|
|
}
|
|
return p.Name
|
|
}
|
|
|
|
// printMoved says which agents a change moved off models it stopped
|
|
// serving (agent.Reseat).
|
|
func printMoved(moved []agent.Move) {
|
|
for _, m := range moved {
|
|
if m.Error != "" {
|
|
fmt.Println("!", m.String())
|
|
continue
|
|
}
|
|
fmt.Println(green.Render("✓"), "moved", m.String())
|
|
}
|
|
}
|