Files
magpie/internal/provider/models.go
T

483 lines
14 KiB
Go

package provider
import (
"context"
"fmt"
"slices"
"sort"
"strings"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/settings"
)
func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) }
// manyModels is where "expose everything" stops being helpful.
const manyModels = 24
// Available lists every model the vendor is known to serve: the list fetched
// from the vendor itself when there is one, over the models.dev catalog (or,
// for an account, whatever the agent's own sign-in can see).
func (p Provider) Available() []catalog.Model {
if p.Decides() {
return p.decideModels()
}
signedIn := p.Account != nil && p.Account.models != nil
var known []catalog.Model
if signedIn {
known = p.Account.models()
} else {
seen := map[string]bool{}
for _, id := range p.Catalogs() {
for _, m := range catalog.Provider(id) {
if !seen[m.ID] {
seen[m.ID] = true
known = append(known, m)
}
}
}
}
if live, _, ok := catalog.Live(p.ID); ok {
switch p.ID {
case "cursor":
live = withCursorContexts(live)
case "devin":
live = withDevinContexts(live)
}
return catalog.Decorate(live, known)
}
if signedIn {
return known
}
var out []catalog.Model
for _, m := range known {
if !strings.Contains(m.ID, "-exp") && !strings.Contains(m.ID, "preview") {
out = append(out, m)
}
}
return out
}
func (p Provider) firstCatalog() string {
if cs := p.Catalogs(); len(cs) > 0 {
return cs[0]
}
return ""
}
// Fetched reports when the vendor's own list was last fetched.
func (p Provider) Fetched() (time.Time, bool) {
_, t, ok := catalog.Live(p.ID)
return t, ok
}
// Fetch asks the vendor which models it serves and remembers the answer.
func (p Provider) Fetch(ctx context.Context) ([]catalog.Model, error) {
if p.Decides() {
return p.fetchDecide(ctx)
}
if p.Account != nil && p.Account.fetch != nil {
return p.Account.fetch(ctx)
}
// Only keys in use. An off key is not asked, and its list does not
// join the catalog or take capabilities off a key that is on.
if keys := p.KeysOn(); len(keys) > 1 {
return p.fetchPerKey(ctx, keys)
}
ms, base, err := p.fetchOne(ctx)
if err != nil {
return nil, err
}
return ms, catalog.SaveLive(p.ID, base, ms)
}
// fetchOne asks the first endpoint that answers, with p's key.
func (p Provider) fetchOne(ctx context.Context) ([]catalog.Model, string, error) {
if u := strings.TrimSpace(p.ModelsURL); u != "" {
// asked where the user said, and nowhere else: the base URLs
// list nothing, or the wrong thing
ms, err := catalog.FetchURL(ctx, u, p.Key, p.Chat == "" && p.Responses == "", p.Headers)
return ms, u, err
}
var lastErr error
for _, proto := range p.Speaks() {
base := p.Base(proto)
ms, at, err := catalog.FetchAt(ctx, base, p.Key, proto == Anthropic, p.Headers)
if err == nil {
if proto != Anthropic {
base = p.fixV1(base, at)
}
return ms, base, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = errorf("%s has no endpoint to ask", p.Name)
}
return nil, "", lastErr
}
// fixV1 adds the /v1 an OpenAI-style base URL was given without, when
// the models were found only under it: base/models didn't answer and
// base/v1/models did. The list is asked for at both, but a request goes
// to the base as written, so base/chat/completions would miss what
// base/v1/chat/completions serves. Both OpenAI URLs that were base are
// set right, and the base the models are at is returned.
func (p Provider) fixV1(base, at string) string {
base = strings.TrimRight(strings.TrimSpace(base), "/")
if base == "" || at != base+"/v1/models" {
return base
}
fixed := base + "/v1"
f := load()
for i := range f.Providers {
q := &f.Providers[i]
if q.ID != p.ID {
continue
}
changed := false
for _, u := range []*string{&q.Chat, &q.Responses} {
if strings.TrimRight(strings.TrimSpace(*u), "/") == base {
*u, changed = fixed, true
}
}
if changed {
if err := store(f); err != nil {
return base
}
}
return fixed
}
return base
}
// fetchPerKey asks with each key in turn, at the endpoint it is made for: a
// relay that hands out a key per group lists each group's models to its key
// only. The lists are merged, each model marking the keys that see it. A
// key that can't be asked now keeps the models it saw last time.
func (p Provider) fetchPerKey(ctx context.Context, keys []KeyAccount) ([]catalog.Model, error) {
old, _, _ := catalog.Live(p.ID)
var out []catalog.Model
at := map[string]int{}
add := func(m catalog.Model, id string) {
i, ok := at[m.ID]
if !ok {
m.Keys = nil
at[m.ID], i = len(out), len(out)
out = append(out, m)
} else {
out[i].ImageInput = sharedImageInput(out[i].ImageInput, m.ImageInput)
out[i].Images = out[i].Images && m.Images
}
if !slices.Contains(out[i].Keys, id) {
out[i].Keys = append(out[i].Keys, id)
}
}
var base string
var lastErr error
for _, k := range keys {
id := keyID(k.Key)
q := p.WithKey(k)
ms, b, err := q.fetchOne(ctx)
if err != nil {
lastErr = err
for _, m := range old {
if slices.Contains(m.Keys, id) {
add(m, id)
}
}
continue
}
if base == "" {
base = b
}
for _, m := range ms {
add(m, id)
}
}
if len(out) == 0 {
return nil, lastErr
}
return out, catalog.SaveLive(p.ID, base, out)
}
// An explicit text-only answer wins. Without one, an unknown answer stays
// unknown; the caller can still use the catalog's image capability estimate.
func sharedImageInput(a, b *bool) *bool {
if a != nil && !*a {
return a
}
if b != nil && !*b {
return b
}
if a == nil || b == nil {
return nil
}
return a
}
// Serves reports whether key k can be asked for model: false only when the
// vendor's lists say another of the provider's keys sees it and k doesn't.
func (p Provider) Serves(k KeyAccount, model string) bool {
live, _, ok := catalog.Live(p.ID)
if !ok {
return true
}
for _, m := range live {
if m.ID == model {
return len(m.Keys) == 0 || slices.Contains(m.Keys, keyID(k.Key))
}
}
return true
}
// Exposed lists the models magpie offers to agents for this provider: the
// user's picks; else the preset's; else everything, when that is few.
func (p Provider) Exposed() []catalog.Model {
avail := p.Available()
byID := make(map[string]catalog.Model, len(avail))
for _, m := range avail {
byID[m.ID] = m
}
pick := func(ids []string) []catalog.Model {
out := make([]catalog.Model, 0, len(ids))
for _, id := range ids {
if m, ok := byID[id]; ok {
out = append(out, m)
} else {
out = append(out, catalog.Model{ID: id, Name: id, Provider: p.firstCatalog()})
}
}
return out
}
if len(p.Models) > 0 {
return pick(p.Models)
}
if len(avail) <= manyModels {
return avail
}
// More than an agent's picker wants. Show the first slice of the
// vendor's own list — theirs run newest first — and let the user pick
// from the rest; nothing here is compiled in.
return avail[:manyModels]
}
// RejectsTemperature reports whether the model is known to refuse
// temperature and top_p. The answer comes from the catalog — models.dev
// plus the vendor's own list — so a model released after this binary was
// built is handled without a code change.
func (p Provider) RejectsTemperature(model string) bool {
for _, m := range p.Available() {
if m.ID == model && m.Temperature != nil {
return !*m.Temperature
}
}
return false
}
// Efforts are the reasoning levels the model takes, when known: its own,
// or those the user gave it when it has none known.
func (p Provider) Efforts(model string) []string {
if all := p.Known(model); len(all) > 0 {
return all
}
return effortsKept(nil, settings.Load().ModelEfforts[p.ID+"/"+model])
}
// Known are the model's own reasoning levels, when known.
func (p Provider) Known(model string) []string {
for _, m := range p.Available() {
if m.ID == model {
return effortsOf(m)
}
}
return catalog.EffortsOf(model)
}
// effortsOf is a model's reasoning levels: its vendor's, as models.dev
// lists them, or — for a vendor models.dev doesn't list the model under (a
// custom provider, a proxy) — the ones the others serving it give. A model
// models.dev lists for this vendor without levels takes none: the vendor
// says it has none to pick from.
func effortsOf(m catalog.Model) []string {
if len(m.Efforts) > 0 || m.Provider != "" {
return m.Efforts
}
return catalog.EffortsOf(m.ID)
}
// Chosen reports whether a model is exposed.
func (p Provider) Chosen(id string) bool {
for _, m := range p.Exposed() {
if m.ID == id {
return true
}
}
return false
}
// ---- the magpie catalog ------------------------------------------------------
//
// Agents see one flat list of models across every provider, each spelled
// "provider/model" so nothing ever clashes. The bare model id works too
// when only one provider serves it.
// Entry is one model as the agents see it.
type Entry struct {
ID string `json:"id"` // what the agent sends magpie
Model string `json:"model"` // what magpie sends the vendor
Name string `json:"name"` // the user's name for it, when they gave one (SetModelName)
Default string `json:"default,omitempty"` // the model's own name, when the user gave it another
Efforts []string `json:"efforts,omitempty"`
Provider Provider `json:"-"` // a group's: its first member's
Group string `json:"group,omitempty"` // set on a routing group (group.go)
Icons []string `json:"-"` // a group's: its providers' icons, one per provider
Images bool `json:"images,omitempty"` // takes images as input (a group's: every member does)
ImageInput *bool `json:"-"` // explicit answer, nil when unknown
// Context is the tokens a prompt may hold, when known (a group's: the
// least of its members')
Context int `json:"context,omitempty"`
// Output is the most tokens a reply may hold, when known (a group's:
// the least of its members')
Output int `json:"output,omitempty"`
// Family is the provider's or group's tag (see Visible).
Family string `json:"family,omitempty"`
}
// Catalog lists the routing groups, then every exposed model of every ready
// provider not kept unlisted.
func Catalog() []Entry {
entries := providerEntries()
out := groupEntries(entries)
for _, e := range entries {
if !e.Provider.Unlisted {
out = append(out, e)
}
}
return out
}
// Served is the catalog with the unlisted providers' models as well: every
// model a routing group can be made of, or a request can name.
func Served() []Entry {
entries := providerEntries()
return append(groupEntries(entries), entries...)
}
// providerEntries is the catalog without its groups.
func providerEntries() []Entry {
var out []Entry
s := settings.Load()
for _, p := range All() {
if !p.Ready() || p.Decides() { // a decision API only routes
continue
}
for _, m := range p.Exposed() {
// a vendor models.dev doesn't list (a custom provider, a proxy)
// serves models it knows from others
ctx := m.Context
if ctx == 0 {
ctx = catalog.ContextOf(m.ID)
}
if n := p.ContextOf(m.ID); n > 0 {
ctx = n
}
output := m.Output
if output == 0 {
output = catalog.OutputOf(m.ID)
}
images := m.Images || catalog.SeesImages(m.ID)
if m.ImageInput != nil {
images = *m.ImageInput
}
e := Entry{ID: p.ID + "/" + m.ID, Model: m.ID, Family: p.Family, Name: m.Name, Efforts: effortsOf(m), Provider: p,
Images: images, ImageInput: m.ImageInput, Context: ctx, Output: output}
if n, ok := modelNameIn(s.ModelNames, p.ID, m.ID); ok {
e.Name, e.Default = n, m.Name
}
e.Efforts = effortsKept(e.Efforts, s.ModelEfforts[e.ID])
out = append(out, e)
}
}
return out
}
// Resolve maps an id an agent sent to a provider and the vendor's model id.
// It accepts catalog ids, "provider/model" for any model (exposed or not),
// and the bare model id when exactly one provider serves it.
// A group's id resolves to its first member.
func Resolve(id string) (Provider, string, bool) {
// Claude Code's mark for a model with a 1M window; it drops it before
// asking, but a value in its settings still has it
id = strings.TrimSuffix(strings.TrimSpace(id), "[1m]")
if strings.HasPrefix(id, GroupPrefix) {
for _, e := range Catalog() {
if e.ID == id {
return e.Provider, e.Model, true
}
}
return Provider{}, "", false
}
return resolveIn(providerEntries(), id)
}
func resolveIn(entries []Entry, id string) (Provider, string, bool) {
for _, e := range entries {
if e.ID == id {
return e.Provider, e.Model, true
}
}
if pid, model, ok := strings.Cut(id, "/"); ok {
if p, err := Find(pid); err == nil && p.Ready() {
return *p, model, true
}
}
var hits []Entry
for _, e := range entries {
if e.Model == id {
hits = append(hits, e)
}
}
if len(hits) >= 1 {
return hits[0].Provider, hits[0].Model, true
}
// not exposed, but some provider lists it
var found []Provider
for _, p := range All() {
if !p.Ready() || p.Decides() {
continue
}
for _, m := range p.Available() {
if m.ID == id {
found = append(found, p)
break
}
}
}
if len(found) == 1 {
return found[0], id, true
}
return Provider{}, "", false
}
// IDs lists the catalog ids, for error messages.
func IDs() []string {
var out []string
for _, e := range Catalog() {
out = append(out, e.ID)
}
sort.Strings(out)
return out
}
// ContextOf is the context the user set for the provider's model: its own,
// else the provider's "*"; 0 when none is set.
func (p Provider) ContextOf(model string) int {
if n := p.Contexts[model]; n > 0 {
return n
}
return p.Contexts["*"]
}