Files
magpie/internal/gui/providers.go
T

707 lines
24 KiB
Go

package gui
import (
"cmp"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"slices"
"strings"
"time"
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
)
// The providers page: the vendors the user added, the presets they can add
// with one key, the gateway that fronts them, and who is routed where.
type modelJSON struct {
ID string `json:"id"`
Name string `json:"name"` // the user's name for it, if they gave one
Default string `json:"default,omitempty"` // its own name, when the user gave it another
Kept []string `json:"kept,omitempty"` // the reasoning levels the user keeps of Efforts, when not all
Efforts []string `json:"efforts,omitempty"`
Given bool `json:"given,omitempty"` // its levels aren't known: Efforts are those it can be given, Kept those it was
On bool `json:"on"` // exposed to agents
}
type providerJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Preset string `json:"preset"`
Host string `json:"host"`
Chat string `json:"chat"`
Responses string `json:"responses"`
Anthropic string `json:"anthropic"`
Decide string `json:"decide,omitempty"` // a decision API: it only routes groups
Catalog string `json:"catalog"`
Website string `json:"website"`
KeysURL string `json:"keysUrl"`
Headers map[string]string `json:"headers,omitempty"`
// where a custom provider's balance is asked (see provider.Balance)
BalanceURL string `json:"balanceURL,omitempty"`
BalancePath string `json:"balancePath,omitempty"`
ModelsURL string `json:"modelsURL,omitempty"`
// an account-wide balance token (provider.BalanceToken): whether the
// vendor takes one, and whether one is saved; never the token itself
BalanceToken struct {
Takes bool `json:"takes"`
Set bool `json:"set"`
} `json:"balanceToken"`
Key struct {
Set bool `json:"set"`
Masked string `json:"masked"`
Optional bool `json:"optional"`
} `json:"key"`
Ready bool `json:"ready"`
Chosen []string `json:"chosen"` // the user's explicit picks, if any
Fallback []string `json:"fallback"` // where requests go when this one can't take them
Routing string `json:"routing"` // how requests spread over its keys or accounts
Affinity string `json:"affinity"` // how long a conversation stays with who answered it
Models []modelJSON `json:"models"` // everything the vendor lists, exposed ones flagged
Exposed int `json:"exposed"` // how many reach the agents
Unlisted bool `json:"unlisted"` // its models serve only through routing groups
Contexts map[string]int `json:"contexts,omitempty"` // the windows the user set, "*" for all its models
Fetched string `json:"fetched"` // "3h ago" when the list came from the vendor
Agents []providerAgent `json:"agents"` // detected agents, current ones flagged
Sponsored bool `json:"sponsored"`
KeyList []provider.KeyInfo `json:"keyList"` // its keys, in the order requests try them
Account *accountJSON `json:"account,omitempty"` // a signed-in agent, see provider.Account
}
type accountJSON struct {
provider.Account
Agent string `json:"agent"` // the agent's id
Name string `json:"agentName"` // the agent's name, for "from Codex CLI's sign-in"
Icon string `json:"agentIcon"`
// Logins are the agent's accounts magpie remembers, to switch between
Logins []provider.Login `json:"logins,omitempty"`
}
type providerAgent struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Current bool `json:"current"` // this agent is on one of this provider's models now
Model string `json:"model,omitempty"`
Group string `json:"group,omitempty"` // through this routing group, one of whose members it is
}
type presetJSON struct {
provider.PresetDef
Added bool `json:"added"`
}
type gatewayJSON struct {
URL string `json:"url"`
Running bool `json:"running"`
Mine bool `json:"mine"` // this process serves it
Window bool `json:"window"` // the magpie serving it shows its routing
Models int `json:"models"`
Calls []gateway.Call `json:"calls"`
Groups []gwGroupJSON `json:"groups"` // the catalog's routing groups, listed before the models
}
// gwGroupJSON is a routing group as the Gateway view lists it.
type gwGroupJSON struct {
ID string `json:"id"` // group/<id>, what a request names
Name string `json:"name"`
Icons []string `json:"icons"` // its providers', one each
Providers []string `json:"providers"` // their names, in the group's order
}
type excludedJSON struct {
provider.Exclusion
Name string `json:"agentName"`
Icon string `json:"agentIcon"`
}
type providersJSON struct {
Providers []providerJSON `json:"providers"`
Presets []presetJSON `json:"presets"`
Excluded []excludedJSON `json:"excluded"` // sign-ins magpie found but will not share
Gateway gatewayJSON `json:"gateway"`
}
// agentModel is the model an agent is on, as magpie's catalog names it.
func agentModel(a *agent.Agent) string {
if len(a.Fields) == 0 {
return ""
}
return strings.TrimPrefix(a.Fields[0].Get(), "magpie/")
}
// agentUse is what an agent is on now: read once for the page, not again
// for each provider (reading it may ask the agent itself, as Alma's does).
type agentUse struct {
*agent.Agent
pid, model string
group provider.Group
members []provider.Member
inGroup bool
}
func agentUses(agents []*agent.Agent, findGroup func(string) (provider.Group, []provider.Member, bool)) []agentUse {
out := make([]agentUse, 0, len(agents))
for _, a := range agents {
u := agentUse{Agent: a}
v := agentModel(a)
if strings.HasPrefix(v, provider.GroupPrefix) {
u.group, u.members, u.inGroup = findGroup(v)
} else if pid, model, ok := strings.Cut(v, "/"); ok {
if _, err := provider.Find(pid); err == nil {
u.pid, u.model = pid, model
}
}
out = append(out, u)
}
return out
}
func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
out := providerJSON{
ID: p.ID, Name: p.Name, Icon: p.Icon, Preset: p.Preset, Host: p.Host(),
Chat: p.Chat, Responses: p.Responses, Anthropic: p.Anthropic, Decide: p.Decide,
Catalog: p.Catalog, Website: p.Website, KeysURL: p.KeysURL,
Headers: p.Headers, BalanceURL: p.BalanceURL, BalancePath: p.BalancePath, ModelsURL: p.ModelsURL,
Ready: p.Ready(), Chosen: p.Models, Models: []modelJSON{}, Agents: []providerAgent{},
Fallback: p.Fallback, Routing: p.Routing, Affinity: p.Affinity, Unlisted: p.Unlisted, Contexts: p.Contexts,
}
if out.Fallback == nil {
out.Fallback = []string{}
}
if out.Chosen == nil {
out.Chosen = []string{}
}
if pr := provider.Preset(p.Preset); pr != nil {
out.Sponsored = pr.Sponsored
out.Key.Optional = pr.NoKey
}
out.BalanceToken.Takes, out.BalanceToken.Set = provider.TakesBalanceToken(p), p.BalanceToken != ""
out.Key.Set = p.Key != ""
out.Key.Masked = provider.Mask(p.Key)
out.KeyList = p.KeyList()
if out.KeyList == nil {
out.KeyList = []provider.KeyInfo{}
}
if !out.Key.Set && p.Ready() {
out.Key.Optional = true
}
if a := p.Account; a != nil {
out.Account = &accountJSON{Account: *a, Agent: a.Agent, Name: a.Agent, Icon: "generic"}
if ag, err := agent.Find(a.Agent); err == nil {
out.Account.Name, out.Account.Icon = ag.Name, ag.Icon
} else if a.Agent == "cursor" {
// cursor-agent runs behind the gateway, not as an agent magpie configures
out.Account.Name, out.Account.Icon = "Cursor CLI", "cursor"
} else if a.Agent == "kiro" {
// Kiro's sign-in is kiro-cli's or the Kiro IDE's
out.Account.Name, out.Account.Icon = "Kiro", "kiro-color"
} else if a.Agent == "antigravity" {
out.Account.Name, out.Account.Icon = "Antigravity", "antigravity-color"
}
out.Account.Logins = provider.Logins(a.Agent)
}
exposed := map[string]bool{}
for _, m := range p.Exposed() {
exposed[m.ID] = true
}
seen := map[string]bool{}
names, kept := p.ModelNames(), p.ModelEfforts()
named := func(m catalog.Model, on bool) modelJSON {
j := modelJSON{ID: m.ID, Name: m.Name, Efforts: provider.EffortsOf(m), On: on}
if n, ok := names[m.ID]; ok {
j.Default = cmp.Or(m.Name, m.ID)
j.Name = n
}
if len(j.Efforts) == 0 {
j.Efforts, j.Given = provider.Levels, true
}
if k, ok := kept[m.ID]; ok {
j.Kept = slices.DeleteFunc(slices.Clone(j.Efforts), func(e string) bool { return !slices.Contains(k, e) })
}
return j
}
for _, m := range p.Available() {
seen[m.ID] = true
out.Models = append(out.Models, named(m, exposed[m.ID]))
}
// picks the vendor list does not know go first, so they are visible
for _, m := range p.Exposed() {
if !seen[m.ID] {
out.Models = append([]modelJSON{named(m, true)}, out.Models...)
}
}
out.Exposed = len(exposed)
if t, ok := p.Fetched(); ok {
out.Fetched = ago(t)
}
for _, a := range agents {
pa := providerAgent{ID: a.ID, Name: a.Name, Icon: a.Icon, Current: a.pid == p.ID, Model: a.model}
if a.inGroup {
for _, m := range a.members {
if m.Provider.ID == p.ID {
pa.Current, pa.Model, pa.Group = true, m.Model, a.group.Name
break
}
}
}
out.Agents = append(out.Agents, pa)
}
return out
}
func providersState() providersJSON {
agents := agent.Detected()
s := providersJSON{Providers: []providerJSON{}, Presets: []presetJSON{}, Excluded: []excludedJSON{}}
for _, x := range provider.Excluded() {
e := excludedJSON{Exclusion: x, Name: x.Agent, Icon: "generic"}
if a, err := agent.Find(x.Agent); err == nil {
e.Name, e.Icon = a.Name, a.Icon
}
s.Excluded = append(s.Excluded, e)
}
findGroup := provider.GroupFinder()
uses := agentUses(agents, findGroup)
have := map[string]bool{}
for _, p := range provider.All() {
// a preset is added once any provider is its, whatever its id
have[p.ID], have[p.Preset] = true, true
s.Providers = append(s.Providers, providerInfo(p, uses))
}
for _, pr := range provider.Presets() {
s.Presets = append(s.Presets, presetJSON{PresetDef: pr, Added: have[pr.ID]})
}
cat := provider.Catalog()
s.Gateway = gatewayJSON{URL: gateway.URL(), Models: len(cat), Calls: []gateway.Call{}, Groups: []gwGroupJSON{}}
for _, e := range cat {
if e.Group == "" {
continue
}
g := gwGroupJSON{ID: e.ID, Name: e.Name, Icons: e.Icons}
if _, ms, ok := findGroup(e.ID); ok {
for _, m := range ms {
if !slices.Contains(g.Providers, m.Provider.Name) {
g.Providers = append(g.Providers, m.Provider.Name)
}
}
}
s.Gateway.Groups = append(s.Gateway.Groups, g)
}
if gw := served.Load(); gw != nil {
s.Gateway.Running, s.Gateway.Mine, s.Gateway.Window = true, true, true
s.Gateway.Calls = gw.Recent()
} else {
s.Gateway.Running, s.Gateway.Window = gateway.Serving()
}
return s
}
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.Round(time.Minute).Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Round(time.Hour).Hours()))
default:
return t.Format("Jan 2")
}
}
func providerRoutes(mux *http.ServeMux, w Windows) {
importAppsRoutes(mux)
traceRoutes(mux)
groupRoutes(mux)
mux.HandleFunc("GET /api/providers", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, providersState())
})
// a picture for a provider, picked in the editor: kept by content before
// the provider is saved, which then points at it. The page sends it as
// base64 in JSON — the app's web view hands a scheme handler no body for
// a File or Blob, so a picture posted as it is arrived empty — and the
// bytes as they are are still taken.
mux.HandleFunc("POST /api/icons", func(rw http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(io.LimitReader(r.Body, 2*provider.MaxIcon))
if err != nil {
fail(rw, err)
return
}
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
var in struct{ Data string }
if err := json.Unmarshal(b, &in); err != nil {
fail(rw, err)
return
}
if b, err = base64.StdEncoding.DecodeString(in.Data); err != nil {
fail(rw, err)
return
}
}
icon, err := provider.StoreIcon(b)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]string{"icon": icon})
})
// the site's own icon, found from the provider's base URL (#12)
mux.HandleFunc("POST /api/icons/favicon", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ URL string }
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
fail(rw, err)
return
}
icon, err := provider.FaviconFor(r.Context(), in.URL)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]string{"icon": icon})
})
mux.HandleFunc("GET /api/icons/{name}", func(rw http.ResponseWriter, r *http.Request) {
f := provider.IconFile(r.PathValue("name"))
if f == "" {
http.NotFound(rw, r)
return
}
// an SVG is only ever drawn as an image, never run
rw.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
rw.Header().Set("Cache-Control", "max-age=31536000, immutable")
http.ServeFile(rw, r, f)
})
mux.HandleFunc("POST /api/provider/{action}", func(rw http.ResponseWriter, r *http.Request) {
var req struct {
provider.Provider
// New is set by the editor's Add: the provider is one more, never
// one replacing the provider that has its id or name
New bool `json:"new"`
// ClearBalanceToken drops the saved balance token, which a
// blank one in the form otherwise keeps
ClearBalanceToken bool `json:"clearBalanceToken"`
// From is the id the provider had: another is a rename
From string `json:"from"`
// Model and ModelName, for name: the name the user gives one
// of its models, "" for its own again
Model string `json:"model"`
ModelName string `json:"modelName"`
// Efforts, for efforts: the reasoning levels it offers, none
// for all it has
Efforts []string `json:"efforts"`
// Test, for test: models to send a request each, in place of
// one per endpoint
Test []string `json:"test"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(rw, err)
return
}
in := req.Provider
switch r.PathValue("action") {
case "show":
// a signed-in account the user removed, back with its picks
if err := provider.ShowAccount(in.ID); err != nil {
fail(rw, err)
return
}
case "save":
// a preset needs nothing but the key; a saved provider keeps
// its key when the form left it blank
if pr, err := provider.FromPreset(in.Preset); err == nil && in.Chat == "" && in.Responses == "" && in.Anthropic == "" {
pr.Key, pr.Models, pr.Fallback, pr.Headers, pr.BalanceToken, pr.Contexts = in.Key, in.Models, in.Fallback, in.Headers, in.BalanceToken, in.Contexts
if in.Name != "" {
pr.Name = in.Name
}
if in.ID != "" {
pr.ID = in.ID
}
in = pr
}
var old *provider.Provider
if req.New {
// a second one of a preset, or a name already in use, is
// added beside the first under the next free id
id, err := provider.Add(in)
if err != nil {
fail(rw, err)
return
}
in.ID = id
} else {
// a rename saves the rest under the id it had, then moves it
to := strings.ToLower(strings.TrimSpace(in.ID))
rename := req.From != "" && req.From != to
if rename {
if to == "" || to != provider.Slug(to) {
fail(rw, fmt.Errorf("a provider's id must be lowercase letters, digits and dashes, not %q", in.ID))
return
}
in.ID = req.From
}
old, _ = provider.Find(in.ID)
if in.Key == "" && old != nil {
in.Key = old.Key
}
if in.BalanceToken == "" && old != nil && !req.ClearBalanceToken {
in.BalanceToken = old.BalanceToken
}
if old != nil {
// the other keys are kept apart, in the Accounts list
in.Keys = old.Keys
in.Routing = old.Routing // set on its own, with route
if in.Contexts == nil {
in.Contexts = old.Contexts // a save that doesn't say
}
if in.Key == old.Key {
in.KeyName, in.KeyProtocol = old.KeyName, old.KeyProtocol
}
}
if in.Icon == "" && old != nil && in.Preset == "" {
in.Icon = old.Icon
}
if err := provider.Save(in); err != nil {
fail(rw, err)
return
}
if rename {
if _, err := agent.RenameProvider(in.ID, to); err != nil {
fail(rw, err)
return
}
in.ID = to
}
}
provider.ForgetBalances()
// a new key means a new vendor list is worth a try; keep it short
if p, err := provider.Find(in.ID); err == nil && p.Ready() && (old == nil || old.Key != p.Key) {
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
p.Fetch(ctx)
cancel()
}
case "key":
// the saved key, for the editor's Show button; it never
// leaves this machine (the panel is served on loopback)
p, err := provider.Find(in.ID)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]string{"key": p.Key})
return
case "name":
// the agents' own model lists follow, through catalog.Changed
if err := provider.SetModelName(in.ID+"/"+req.Model, req.ModelName); err != nil {
fail(rw, err)
return
}
case "efforts":
if err := provider.SetModelEfforts(in.ID+"/"+req.Model, req.Efforts); err != nil {
fail(rw, err)
return
}
case "route":
if err := provider.SetRouting(in.ID, in.Routing); err != nil {
fail(rw, err)
return
}
case "affinity":
if err := provider.SetAffinity(in.ID, in.Affinity); err != nil {
fail(rw, err)
return
}
case "delete":
if err := provider.Delete(in.ID); err != nil {
fail(rw, err)
return
}
case "test":
p, err := provider.Find(in.ID)
if err != nil {
fail(rw, err)
return
}
if len(req.Test) > 0 {
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
writeJSON(rw, map[string]any{"results": p.TestModels(ctx, req.Test)})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
defer cancel()
writeJSON(rw, struct {
Results []provider.Result `json:"results"`
Provider providerJSON `json:"provider"`
}{p.Test(ctx), providerInfo(*p, agentUses(agent.Detected(), provider.GroupFinder()))})
return
case "unfetch":
// the vendor's list, forgotten until the next Refresh
if err := catalog.SaveLive(in.ID, "", nil); err != nil {
fail(rw, err)
return
}
case "models":
p, err := provider.Find(in.ID)
if err != nil {
fail(rw, err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
var ms []catalog.Model
if ms, err = p.Fetch(ctx); err != nil {
fail(rw, err)
return
}
writeJSON(rw, struct {
Count int `json:"count"`
Provider providerJSON `json:"provider"`
}{len(ms), providerInfo(*p, agentUses(agent.Detected(), provider.GroupFinder()))})
return
default:
http.NotFound(rw, r)
return
}
writeJSON(rw, providersState())
})
// How much of its allowance each of an agent's accounts has used.
mux.HandleFunc("GET /api/login/usage", func(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
writeJSON(rw, provider.LoginUsage(ctx, r.URL.Query().Get("agent")))
})
// Switching the account an agent is signed in to, among those magpie
// remembers, and forgetting one.
mux.HandleFunc("POST /api/login/{action}", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Agent, User string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
var err error
switch r.PathValue("action") {
case "switch":
err = provider.SwitchLogin(in.Agent, in.User)
case "forget":
err = provider.ForgetLogin(in.Agent, in.User)
case "on", "off":
err = provider.SetLoginOn(in.Agent, in.User, r.PathValue("action") == "on")
default:
http.NotFound(rw, r)
return
}
if err != nil {
fail(rw, err)
return
}
// an agent on its own models goes through magpie while more of
// its accounts are on, and straight to its vendor again once not
agent.SyncCatalog()
writeJSON(rw, providersState())
})
// A provider's several keys: add one, put one in use, name or remove it.
mux.HandleFunc("POST /api/keys/{action}", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
ID, Key, Name, Ref string
Protocol provider.Protocol
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
var err error
switch r.PathValue("action") {
case "add":
err = provider.AddKey(in.ID, in.Name, in.Key, in.Protocol)
case "protocol":
err = provider.SetKeyProtocol(in.ID, in.Ref, in.Protocol)
case "use":
err = provider.UseKey(in.ID, in.Ref)
case "remove":
err = provider.RemoveKey(in.ID, in.Ref)
case "rename":
err = provider.RenameKey(in.ID, in.Ref, in.Name)
case "on", "off":
err = provider.SetKeyOn(in.ID, in.Ref, r.PathValue("action") == "on")
default:
http.NotFound(rw, r)
return
}
if err != nil {
fail(rw, err)
return
}
// a key that now takes requests has its models asked for: which
// models a key sees is known only from its own list, and an off
// key's isn't asked (#76), so until then a relay that hands out a
// key per group would send the key nothing, or everything
if a := r.PathValue("action"); a == "add" || a == "on" {
if p, err := provider.Find(in.ID); err == nil && p.Ready() && len(p.KeysOn()) > 1 {
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
p.Fetch(ctx)
cancel()
}
}
writeJSON(rw, providersState())
})
// Adding a subscription: magpie opens the vendor's sign-in in the
// browser and the window follows it until the account is in.
mux.HandleFunc("POST /api/signin", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Agent string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
st, err := provider.StartSignIn(in.Agent)
if err != nil {
fail(rw, err)
return
}
if st.URL != "" {
// one that installs a CLI first has no URL yet: the window
// opens it once there is one
w.OpenURL(st.URL)
}
writeJSON(rw, st)
})
mux.HandleFunc("GET /api/signin/{id}", func(rw http.ResponseWriter, r *http.Request) {
st, ok := provider.SignInStatus(r.PathValue("id"))
if !ok {
http.NotFound(rw, r)
return
}
writeJSON(rw, st)
})
mux.HandleFunc("POST /api/signin/{id}/cancel", func(rw http.ResponseWriter, r *http.Request) {
provider.CancelSignIn(r.PathValue("id"))
rw.WriteHeader(http.StatusNoContent)
})
// the page copies through here first: in the app's window the
// clipboard API is refused or missing, depending on the system
mux.HandleFunc("POST /api/copy", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Text string }
_ = json.NewDecoder(r.Body).Decode(&in)
if in.Text == "" || !w.Copy(in.Text) {
http.Error(rw, "", http.StatusNotImplemented)
return
}
rw.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("POST /api/open", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ URL string }
_ = json.NewDecoder(r.Body).Decode(&in)
if strings.HasPrefix(in.URL, "https://") || strings.HasPrefix(in.URL, "http://") {
w.OpenURL(in.URL)
}
rw.WriteHeader(http.StatusNoContent)
})
}