Files
magpie/internal/provider/group.go
T

593 lines
19 KiB
Go

package provider
// Routing groups: several models, from one provider or many, that an agent
// picks as one. The gateway serves a group's id like any model's and
// routes each request over every member's keys or accounts together — a
// subscription whose allowance renews soonest first across providers, one
// resting after a failure last — rather than over one provider's.
//
// The user makes groups, in the Routing view. magpie also finds some on
// its own: a model more than one provider serves under the same name is a
// group of those, derived each time and never stored until the user
// changes one. Models of different names are only ever grouped by the
// user: nothing here guesses which models are alike.
//
// A group's member may be another group ("group/<id>"): to the group it is
// one member, which a rule can put first like a model; its models are its
// own group's, ordered by its own routing and rules. A group can't be in
// itself, however deep: SaveGroup refuses the loop, and one written into
// providers.json by hand is cut where it closes.
import (
"errors"
"fmt"
"slices"
"strings"
)
// GroupPrefix starts a group's id in the catalog: "group/<id>".
const GroupPrefix = "group/"
// Affinities are how long a conversation stays with the key or account that
// answered it: "" auto, as long as what the vendor cached of it is worth
// keeping; for the whole session; within a turn only, free to move when
// the user speaks again; or never.
var Affinities = []string{"", AffinitySession, AffinityTurn, AffinityOff}
const (
AffinitySession = "session"
AffinityTurn = "turn"
AffinityOff = "off"
)
// EffortAuto is a group whose classifier picks each turn's reasoning
// effort (Group.Effort).
const EffortAuto = "auto"
// Ruled reports whether the group decides anything as a user's turn
// begins: a rule to put a member first, or the turn's effort.
func (g Group) Ruled() bool { return len(g.Rules) > 0 || g.Effort == EffortAuto }
// Group is a routing group.
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
Members []string `json:"members"` // "provider/model" or "group/<id>", in order
Routing string `json:"routing,omitempty"` // as Provider.Routing, over all the members' keys and accounts
Affinity string `json:"affinity,omitempty"` // as Provider.Affinity
// Rules send the requests they match to one member first, in order:
// the first that matches decides (see Rule).
Rules []Rule `json:"rules,omitempty"`
// Classifier is the model ("provider/model", not a group) asked which
// of the rules' intents a user's message is. Rules with an intent need
// one; a small, fast model without reasoning does.
Classifier string `json:"classifier,omitempty"`
// Effort "auto" has the classifier — Jev, from TypeSafe's decision API,
// or any model, asked in words — judge how hard each turn is to think
// about as it begins, and the turn asks its model for that much
// reasoning, where the agent asked for some (see EffortAuto).
Effort string `json:"effort,omitempty"`
// Context is how long a request the user says the group takes, in
// tokens: agents are told it rather than its shortest member's.
Context int `json:"context,omitempty"`
// Family is a tag the group goes by in which agents are shown it
// (settings' Visible), with its id.
Family string `json:"family,omitempty"`
// Auto is set on a group magpie found: one model served by several
// providers. It is derived, never stored.
Auto bool `json:"auto,omitempty"`
// Hidden is stored for a found group the user removed.
Hidden bool `json:"hidden,omitempty"`
}
// Member is one of a group's models as it resolves now.
type Member struct {
ID string // the group's member it is: the model, or the group in the group it is of
// Path is the members from the group's own down to the model: [ID] for
// a model the group names itself, ["group/fast", "a/m"] for one of
// its group fast, and so on down.
Path []string
// Via are the groups in the group it is of, the outermost first: the
// group each of Path's members but the last names.
Via []Group
Provider Provider
Model string // what the vendor is asked for
}
// Groups are the ids of the groups in the group the model is of, the
// outermost first.
func (m Member) Groups() []string {
out := make([]string, len(m.Via))
for i, g := range m.Via {
out[i] = g.ID
}
return out
}
// Below is the member as the group at depth (0 the group itself, 1 the
// group in it Path[0] names, …) has it.
func (m Member) Below(depth int) Member {
return Member{ID: m.Path[depth], Path: m.Path[depth:], Via: m.Via[depth:], Provider: m.Provider, Model: m.Model}
}
// maxNest is how deep groups in groups may go.
const maxNest = 8
// Groups lists the user's groups, then those magpie found, hidden ones
// too (marked so).
func Groups() []Group {
return groupsIn(providerEntries())
}
func groupsIn(entries []Entry) []Group {
f := load()
var out []Group
hidden := map[string]bool{}
for _, g := range f.Groups {
if g.Hidden {
hidden[g.ID] = true
continue
}
out = append(out, g)
}
for _, g := range autoGroups(entries) {
if slices.ContainsFunc(out, func(o Group) bool { return o.ID == g.ID }) {
continue // the user changed it: theirs now
}
g.Hidden = hidden[g.ID]
out = append(out, g)
}
return out
}
// autoGroups are the models more than one ready provider serves under the
// same name — however each vendor spells it (see sameModel) — in the order
// the providers were added.
func autoGroups(entries []Entry) []Group {
var order []string
by := map[string][]Entry{}
for _, e := range entries {
k := sameModel(e.Model)
if !slices.ContainsFunc(by[k], func(o Entry) bool { return o.Provider.ID == e.Provider.ID }) {
if by[k] == nil {
order = append(order, k)
}
by[k] = append(by[k], e)
}
}
var out []Group
for _, k := range order {
es := by[k]
if len(es) < 2 {
continue
}
// the model's own name: one a user gave it is that provider's alone
own := func(e Entry) string {
if e.Default != "" {
return e.Default
}
return e.Name
}
g := Group{ID: "auto-" + Slug(k), Name: own(es[0]), Auto: true}
for _, e := range es {
g.Members = append(g.Members, e.ID)
if g.Name == es[0].Model && own(e) != e.Model {
g.Name = own(e) // a vendor that names it, over one that only lists its id
}
}
out = append(out, g)
}
return out
}
// sameModel is a model's name as vendors agree on it: lowercase, without
// the vendor's own prefix ("anthropic/claude-sonnet-5" is claude-sonnet-5),
// with a version's dot as Anthropic writes it ("claude-opus-5.5" is
// claude-opus-5-5) and without the snapshot date some add
// ("claude-opus-5-5-20260801"). A variant after ":" (":batch") stays apart.
func sameModel(id string) string {
k := strings.ToLower(id)
if i := strings.LastIndex(k, "/"); i >= 0 {
k = k[i+1:]
}
b := []byte(k)
for i := 1; i+1 < len(b); i++ {
if b[i] == '.' && isDigit(b[i-1]) && isDigit(b[i+1]) {
b[i] = '-'
}
}
k = string(b)
if i := strings.LastIndex(k, "-"); i > 0 && len(k)-i-1 == 8 && strings.HasPrefix(k[i+1:], "20") && strings.Trim(k[i+1:], "0123456789") == "" {
k = k[:i]
}
return k
}
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
// FindGroup looks a group up by its catalog id ("group/<id>") and resolves
// its members; one not ready now is left out.
func FindGroup(id string) (Group, []Member, bool) {
return GroupFinder()(id)
}
// GroupFor is the group a model's id without a provider in it names, as
// "group/<id>": the group of that id, else the group of that model however
// a vendor spells it ("grok-4.7" is the group grok-4-7 or auto-grok-4-7).
// A request for the model is the group's then, as it would be for the
// group's own id; ok is false when no group has it. An id with a provider
// in it ("a/m") names that provider's model, never a group.
func GroupFor(id string) (string, bool) {
id = strings.TrimSuffix(strings.TrimSpace(id), "[1m]")
if id == "" || strings.Contains(id, "/") {
return "", false
}
all := Groups()
k := Slug(sameModel(id))
for _, gid := range []string{strings.ToLower(id), k, "auto-" + k} {
if g, ok := groupOf(all, gid); ok {
return GroupPrefix + g.ID, true
}
}
return "", false
}
// GroupFinder is FindGroup for looking up many: every provider's models
// are read once, when the first group is looked up, not again for each.
func GroupFinder() func(id string) (Group, []Member, bool) {
var (
entries []Entry
all []Group
read bool
)
return func(id string) (Group, []Member, bool) {
gid, ok := strings.CutPrefix(strings.TrimSpace(id), GroupPrefix)
if !ok {
return Group{}, nil, false
}
if !read {
entries, read = providerEntries(), true
all = groupsIn(entries)
}
if g, ok := groupOf(all, gid); ok {
return g, membersIn(entries, all, g), true
}
return Group{}, nil, false
}
}
// groupOf is the group of an id among all, unless it was removed.
func groupOf(all []Group, id string) (Group, bool) {
for _, g := range all {
if g.ID == id && !g.Hidden {
return g, true
}
}
return Group{}, false
}
// membersIn are a group's models, ready now, in its order: a group in it
// gives its own there, as deep as they go. A model met again is left
// where it was first, and a group that would be in itself is cut there.
func membersIn(entries []Entry, all []Group, g Group) []Member {
var out []Member
seen := map[string]bool{}
var walk func(g Group, path []string, via []Group, in []string)
walk = func(g Group, path []string, via []Group, in []string) {
for _, id := range g.Members {
at := append(slices.Clone(path), id)
if gid, ok := strings.CutPrefix(id, GroupPrefix); ok {
sub, ok := groupOf(all, gid)
if !ok || slices.Contains(in, gid) || len(via) >= maxNest {
continue // gone, a loop, or deeper than anyone nests
}
walk(sub, at, append(slices.Clone(via), sub), append(slices.Clone(in), gid))
continue
}
p, m, ok := resolveIn(entries, id)
if !ok || seen[p.ID+"/"+m] {
continue
}
seen[p.ID+"/"+m] = true
out = append(out, Member{ID: at[0], Path: at, Via: via, Provider: p, Model: m})
}
}
walk(g, nil, nil, []string{g.ID})
return out
}
// groupEntries are the catalog's groups: each with a member ready, named
// as the user named it, answering for its first member when an agent asks
// what the model can do, and offering only the reasoning levels every
// member has.
func groupEntries(entries []Entry) []Entry {
var out []Entry
all := groupsIn(entries)
for _, g := range all {
if g.Hidden {
continue
}
ms := membersIn(entries, all, g)
if len(ms) == 0 {
continue
}
e := Entry{ID: GroupPrefix + g.ID, Model: ms[0].Model, Name: g.Name, Provider: ms[0].Provider, Group: g.ID, Images: true}
for i, m := range ms {
if !slices.ContainsFunc(ms[:i], func(o Member) bool { return o.Provider.ID == m.Provider.ID }) {
e.Icons = append(e.Icons, m.Provider.Icon) // each provider once, "" for one without
}
var efforts []string
images, ctx, output := false, 0, 0
var imageInput *bool
for _, x := range entries {
if x.Provider.ID == m.Provider.ID && x.Model == m.Model {
efforts, images, ctx, output, imageInput = x.Efforts, x.Images, x.Context, x.Output, x.ImageInput
}
}
if output > 0 && (e.Output == 0 || output < e.Output) {
e.Output = output
}
e.Images = e.Images && images
if ctx > 0 && (e.Context == 0 || ctx < e.Context) {
e.Context = ctx
}
if i == 0 {
e.Efforts = efforts
e.ImageInput = imageInput
continue
}
e.Efforts = slices.DeleteFunc(slices.Clone(e.Efforts), func(v string) bool { return !slices.Contains(efforts, v) })
e.ImageInput = sharedImageInput(e.ImageInput, imageInput)
}
if e.ImageInput != nil && !*e.ImageInput {
e.Images = false
}
ruledEntry(&e, g, ms, entries)
if g.Context > 0 {
e.Context = g.Context
}
e.Family = g.Family
out = append(out, e)
}
return out
}
// SaveGroup adds or replaces a group of the user's. Changing one magpie
// found makes it the user's.
func SaveGroup(g Group) error {
g.ID = strings.ToLower(strings.TrimSpace(g.ID))
g.Name = strings.TrimSpace(g.Name)
if g.ID == "" {
g.ID = Slug(g.Name)
}
if g.ID == "" || g.ID != Slug(g.ID) {
return fmt.Errorf("a group's id must be lowercase letters, digits and dashes, not %q", g.ID)
}
if g.Name == "" {
g.Name = g.ID
}
g.Members = cleanList(g.Members)
if len(g.Members) == 0 {
return errors.New("a group needs a model in it")
}
if err := groupsInGroup(g, groupsIn(providerEntries())); err != nil {
return err
}
for _, m := range g.Members {
if IsDecider(m) {
return fmt.Errorf("%s decides a group's model and effort; it holds no conversation, so it can only be the group's classifier", m)
}
}
if g.Routing != Ordered && g.Routing != Rotate && g.Routing != LeastUsed {
g.Routing = ""
}
if !slices.Contains(Affinities, g.Affinity) {
g.Affinity = ""
}
rules, err := cleanRules(g.Rules, g.Members)
if err != nil {
return err
}
g.Rules = rules
g.Classifier = strings.TrimPrefix(strings.TrimSpace(g.Classifier), "magpie/")
g.Effort = strings.ToLower(strings.TrimSpace(g.Effort))
intents := slices.ContainsFunc(g.Rules, func(r Rule) bool { return r.Intent != "" })
switch {
case g.Effort != "" && g.Effort != EffortAuto:
return fmt.Errorf("a group's effort is %q or left to the agent, not %q", EffortAuto, g.Effort)
case strings.HasPrefix(g.Classifier, GroupPrefix):
return fmt.Errorf("the classifier is a model, not a group (%s)", g.Classifier)
case intents && g.Classifier == "":
return errors.New("a rule with an intent needs the group's classifier: the model that tells which intent a message is")
case g.Effort == EffortAuto && g.Classifier == "":
return errors.New("effort picked per turn needs the group's classifier: the model that rates how hard a turn is")
case !intents && g.Effort == "":
g.Classifier = "" // nothing to ask it
}
if g.Classifier != "" {
if _, _, ok := Resolve(g.Classifier); !ok {
return fmt.Errorf("magpie knows no model %q to classify with", g.Classifier)
}
}
g.Auto, g.Hidden = false, false
f := load()
for i := range f.Groups {
if f.Groups[i].ID == g.ID {
f.Groups[i] = g
return store(f)
}
}
f.Groups = append(f.Groups, g)
return store(f)
}
// groupsInGroup checks the groups a group has in it: each one magpie has,
// and none that has the group in it, however deep — the group would be
// in itself, and a request to it would go round for ever.
func groupsInGroup(g Group, all []Group) error {
for _, id := range g.Members {
gid, ok := strings.CutPrefix(id, GroupPrefix)
if !ok {
continue
}
if gid == g.ID {
return fmt.Errorf("%s can't be in itself", g.Name)
}
sub, ok := groupOf(all, gid)
if !ok {
return fmt.Errorf("magpie has no group %q to put in %s", gid, g.Name)
}
if way := wayTo(all, sub, g.ID, nil); way != nil {
return fmt.Errorf("%s can't be in %s: %s is in it already (%s), so %s would be in itself",
sub.Name, g.Name, g.Name, strings.Join(append(append([]string{sub.ID}, way...), g.ID), " ⊃ "), g.Name)
}
if d := depthOf(all, sub, nil); d+1 > maxNest {
return fmt.Errorf("%s has groups in it %d deep; a group in a group goes at most %d deep", sub.Name, d, maxNest)
}
}
return nil
}
// wayTo is the groups from g down to the group id, when g has it in it
// however deep (its first step first, the id itself left out); nil when
// it hasn't.
func wayTo(all []Group, g Group, id string, in []string) []string {
for _, m := range g.Members {
gid, ok := strings.CutPrefix(m, GroupPrefix)
if !ok || slices.Contains(in, gid) {
continue
}
if gid == id {
return []string{}
}
if sub, ok := groupOf(all, gid); ok {
if way := wayTo(all, sub, id, append(slices.Clone(in), g.ID)); way != nil {
return append([]string{gid}, way...)
}
}
}
return nil
}
// depthOf is how deep the groups in g go: 0 when it has none.
func depthOf(all []Group, g Group, in []string) int {
d := 0
for _, m := range g.Members {
gid, ok := strings.CutPrefix(m, GroupPrefix)
if !ok || slices.Contains(in, gid) {
continue
}
if sub, ok := groupOf(all, gid); ok {
d = max(d, 1+depthOf(all, sub, append(slices.Clone(in), g.ID)))
}
}
return d
}
// GroupsWith are the groups that have the group id in them as a member.
func GroupsWith(id string) []Group {
var out []Group
for _, g := range groupsIn(providerEntries()) {
if !g.Hidden && slices.Contains(g.Members, GroupPrefix+id) {
out = append(out, g)
}
}
return out
}
// DeleteGroup removes a group of the user's; one magpie found is hidden,
// to come back with ShowGroup. A group another has in it stays until it is
// taken out of that one.
func DeleteGroup(id string) error {
if in := GroupsWith(id); len(in) > 0 {
var names []string
for _, g := range in {
names = append(names, g.Name)
}
return fmt.Errorf("%s is in %s: take it out first", id, strings.Join(names, ", "))
}
f := load()
found := false
f.Groups = slices.DeleteFunc(f.Groups, func(g Group) bool {
if g.ID == id {
found = true
return true
}
return false
})
if slices.ContainsFunc(autoGroups(providerEntries()), func(g Group) bool { return g.ID == id }) {
f.Groups = append(f.Groups, Group{ID: id, Hidden: true})
found = true
}
if !found {
return fmt.Errorf("no group %q", id)
}
return store(f)
}
// RemovedGroups are the found groups the user removed, whether or not
// magpie finds them now: each is a record in providers.json ({"id", "hidden":
// true}) that keeps it removed when two providers serve its model again.
func RemovedGroups() []string {
var out []string
for _, g := range load().Groups {
if g.Hidden {
out = append(out, g.ID)
}
}
return out
}
// ShowGroup brings back a group magpie found that the user had removed.
func ShowGroup(id string) error {
f := load()
f.Groups = slices.DeleteFunc(f.Groups, func(g Group) bool { return g.ID == id && g.Hidden })
return store(f)
}
// RenameGroup gives a group another id, the one agents pick it by
// (group/<id>). A group magpie found becomes the user's under the new id,
// the found one kept removed so it doesn't come back beside it. The
// groups that have it in them, and their rules, name it by the new id.
func RenameGroup(from, to string) error {
from = strings.ToLower(strings.TrimSpace(from))
to = strings.ToLower(strings.TrimSpace(to))
if to == "" || to != Slug(to) {
return fmt.Errorf("a group's id must be lowercase letters, digits and dashes, not %q", to)
}
if to == from {
return nil
}
all := groupsIn(providerEntries())
g, ok := groupOf(all, from)
if !ok {
return fmt.Errorf("no group %q", from)
}
f := load()
if slices.ContainsFunc(all, func(o Group) bool { return o.ID == to }) ||
slices.ContainsFunc(f.Groups, func(o Group) bool { return o.ID == to }) {
return fmt.Errorf("there is a group %q already", to)
}
found := slices.ContainsFunc(autoGroups(providerEntries()), func(o Group) bool { return o.ID == from })
g.ID, g.Auto, g.Hidden = to, false, false
f.Groups = slices.DeleteFunc(f.Groups, func(o Group) bool { return o.ID == from })
if found {
f.Groups = append(f.Groups, Group{ID: from, Hidden: true})
}
f.Groups = append(f.Groups, g)
old, now := GroupPrefix+from, GroupPrefix+to
for i := range f.Groups {
for j, m := range f.Groups[i].Members {
if m == old {
f.Groups[i].Members[j] = now
}
}
for j, r := range f.Groups[i].Rules {
if r.Use == old {
f.Groups[i].Rules[j].Use = now
}
}
}
return store(f)
}