Files
magpie/internal/provider/models.go
T

1216 lines
42 KiB
Go

package provider
import (
"context"
"fmt"
"log"
"regexp"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/plugin"
"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 := p.live(); ok {
if p.Account != nil && p.Account.unusable != nil {
// a model the list offers that the account was refused
// (Copilot's, copilot_refused.go)
live = slices.DeleteFunc(slices.Clone(live), func(m catalog.Model) bool { return p.Account.unusable(m.ID) })
}
switch p.ID {
case "cursor":
live = withoutCursorCapacity(collapseCursorModels(withCursorContexts(live)))
case "devin":
live = withDevinContexts(devinCollapse(live, devinCached(), p.Models))
case "antigravity":
// after its names are filled in, and so that a family's
// levels aren't taken off by a known model of its id
return collapseAntigravityModels(catalog.Decorate(live, known))
}
return catalog.Decorate(live, known)
}
if p.IsAzure() {
// an Azure resource serves its deployments alone, named as the
// user likes: the catalog only names the ones its list gave
return nil
}
if signedIn {
if p.ID == "devin" { // with the variants the user picked
return withDevinContexts(devinCollapse(known, nil, p.Models))
}
return known
}
if len(known) == 0 {
// a plan's models, before its list was fetched
if ms := p.planModels(nil); len(ms) > 0 {
return ms
}
}
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 := p.live()
return t, ok
}
// Listed is when the models listed now were had from the vendor: fetched
// for a built-in, listed by its plugin for a plugin's.
func (p Provider) Listed() (time.Time, bool) {
if p.IsPlugin() {
return plugin.ListedAt()
}
return p.Fetched()
}
// live is the list last fetched from the vendor. A plugin's provider has
// none: its models are what the plugin lists now, and a built-in moved
// onto it left its own last list under the same id.
func (p Provider) live() ([]catalog.Model, time.Time, bool) {
if p.IsPlugin() {
return nil, time.Time{}, false
}
return catalog.Live(p.ID)
}
// Fetch asks the vendor which models it serves and remembers the answer.
func (p Provider) Fetch(ctx context.Context) ([]catalog.Model, error) {
ctx = p.Via(ctx)
if p.Decides() {
return p.fetchDecide(ctx)
}
if p.Account != nil && p.Account.fetch != nil {
return p.Account.fetch(ctx)
}
if p.Account != nil && p.Account.models != nil {
// a plan with no list to ask (ZCode's, WorkBuddy's): its models
// are the ones it has, and its endpoint's /models isn't one
return p.Account.models(), nil
}
// a vendor with no list to ask (Bedrock's runtime): the preset's
// models are it, unless the user said where one is or the provider
// sits at a region that serves one after all
if pr := Preset(p.Preset); pr != nil && pr.NoList && strings.TrimSpace(p.ModelsURL) == "" && !p.listRegion(pr) {
return catalog.Chat(p.planModels(nil)), nil
}
// 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 catalog.Chat(ms), catalog.SaveLive(p.ID, base, ms)
}
// newFetches is when each account with no list from its vendor yet was
// last asked for one by FetchNew.
var newFetches = struct {
sync.Mutex
m map[string]time.Time
}{m: map[string]time.Time{}}
// newFetchRetry is how long FetchNew leaves an account whose list it
// couldn't get before asking again. Short: until it has its list the
// account offers magpie's fallback (Kiro's Auto alone), and one try that
// failed — the first after an update, cut short by a page's 8 seconds, or
// made before the network was up — left it so for ten minutes, while only
// the Providers page asked again (#422: Auto alone until magpie was
// restarted by hand).
var newFetchRetry = time.Minute
// fetchingNew is set while a FetchNewSoon runs; newSoonAt is when the last
// one started.
var (
fetchingNew atomic.Bool
newSoonAt atomic.Int64
)
// newSoonEvery is how often FetchNewSoon starts at most.
var newSoonEvery = 15 * time.Second
// FetchNewSoon is FetchNew in the background, for a page that shouldn't
// wait on vendors (the panel, whose model picker otherwise kept an
// account's fallback list until the Providers page was opened). It does
// nothing while one runs or within newSoonEvery of the last.
func FetchNewSoon(timeout time.Duration) {
now := time.Now().UnixNano()
if now-newSoonAt.Load() < int64(newSoonEvery) || !fetchingNew.CompareAndSwap(false, true) {
return
}
newSoonAt.Store(now)
go func() {
defer fetchingNew.Store(false)
FetchNew(timeout)
}()
}
// FetchNew asks each signed-in account whose vendor list magpie hasn't
// fetched yet for it, each for at most timeout. Start-up does this for the
// accounts there then; an account signed in while magpie runs (in magpie or
// in the agent's own app) otherwise showed magpie's built-in list, fewer
// models than the vendor serves, until Refresh was clicked (#204). One that
// fails is asked again after newFetchRetry, not each time.
func FetchNew(timeout time.Duration) {
newFetches.Lock()
defer newFetches.Unlock()
for _, p := range All() {
if p.Account == nil || !p.Ready() {
continue
}
// a plugin's accounts were listed with the plugin's providers
if _, ok := p.Listed(); ok {
continue
}
if t, ok := newFetches.m[p.ID]; ok && time.Since(t) < newFetchRetry {
continue
}
newFetches.m[p.ID] = time.Now()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
if _, err := p.Fetch(ctx); err != nil {
log.Println(p.ID + ": " + err.Error())
}
cancel()
}
}
// 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.listHeaders())
if err != nil {
return nil, u, err
}
return catalog.WithDrawers(p.planModels(ms), catalog.PublicDrawers(ctx, u)), u, nil
}
if p.IsAzure() && (p.Chat != "" || p.Responses != "") {
// its deployments, asked with the key in api-key
return p.azureModels(ctx)
}
var errs []string
for _, proto := range p.Speaks() {
base := p.Base(proto)
ms, at, err := catalog.FetchAt(ctx, base, p.Key, proto == Anthropic, p.listHeaders())
if err == nil {
if proto != Anthropic {
base = p.fixV1(base, at)
}
// the image models its list leaves out (AIHubMix's gpt-image-2)
return catalog.WithDrawers(p.planModels(ms), catalog.PublicDrawers(ctx, base)), base, nil
}
// Chat and Responses at one base say the same thing
if !slices.Contains(errs, err.Error()) {
errs = append(errs, err.Error())
}
}
if len(errs) == 0 {
return nil, "", errorf("%s has no endpoint to ask", p.Name)
}
// the endpoints are kept as they were: a vendor with no list (or one
// that wants what the key can't give) still serves the models typed in
return nil, "", errorf("%s — type its model ids in by hand, or give the URL its list is at", strings.Join(errs, "; "))
}
// listRegion reports whether the provider sits at one of its preset's
// regions that serves a model list although the preset as a whole has
// none (Region.Lists): Qianfan's pay as you go at the v2 root answers
// /v2/models, while the plans' /tokenplan/ endpoints answer nothing.
func (p Provider) listRegion(pr *PresetDef) bool {
for _, r := range pr.Regions {
if r.Lists && p.atRegion(r) {
return true
}
}
return false
}
// atRegion reports whether the provider sits at a region's endpoints,
// by path — the host may be another (a mirror, a test).
func (p Provider) atRegion(r Region) bool {
for _, a := range []string{p.Chat, p.Responses, p.Anthropic} {
for _, b := range []string{r.Chat, r.Responses, r.Anthropic} {
if a != "" && b != "" && basePath(a) == basePath(b) {
return true
}
}
}
return false
}
// basePath is a base URL's path, without scheme or host.
func basePath(raw string) string {
if i := strings.Index(raw, "://"); i >= 0 {
raw = raw[i+3:]
}
if i := strings.IndexAny(raw, "/#?"); i >= 0 {
return raw[i:]
}
return ""
}
// planModels leaves out the models a vendor serves its own client alone
// (PresetDef.Drop), and keeps a plan's own models of a vendor's list (PresetDef.Only),
// or gives the plan's when the list has none; a plan with models but no
// Only gives them only when there is no list. Any other provider's list is
// as it came.
func (p Provider) planModels(ms []catalog.Model) []catalog.Model {
pr := Preset(p.Preset)
if pr != nil && pr.Drop != "" {
kept := ms[:0:0]
for _, m := range ms {
if !strings.HasSuffix(m.ID, pr.Drop) {
kept = append(kept, m)
}
}
ms = kept
}
if pr == nil || pr.Only == "" && (len(pr.Models) == 0 || len(ms) > 0) {
return ms
}
var out []catalog.Model
for _, m := range ms {
if strings.HasPrefix(m.ID, pr.Only) {
out = append(out, m)
}
}
if len(out) == 0 {
for _, id := range pr.Models {
out = append(out, catalog.Model{ID: id, Name: id})
}
}
for i, m := range out {
// the vendor's window for its model, as models.dev has it
if m.Context == 0 {
out[i].Context = catalog.ContextOf(strings.TrimPrefix(m.ID, pr.Only))
}
if m.Output == 0 {
out[i].Output = catalog.OutputOf(strings.TrimPrefix(m.ID, pr.Only))
}
}
return out
}
// 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. A base with a
// version in its path (Ark's …/api/plan/v3, Zhipu's …/api/paas/v4) is
// the vendor's API as written and is never given a /v1: no list is asked
// for under one there (catalog.FetchAt), and none is added here.
func (p Provider) fixV1(base, at string) string {
base = strings.TrimRight(strings.TrimSpace(base), "/")
if base == "" || at != base+"/v1/models" || catalog.Versioned(base) {
return base
}
fixed := base + "/v1"
f, err := read()
if err != nil {
return base
}
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)
old = append(old, catalog.LiveDrawers(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 catalog.Chat(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
}
picks := p.Models
if _, _, ok := p.live(); ok && p.Account != nil && p.Account.unusable != nil {
// A Copilot account is served what its list offers it, less what
// it was refused: a pick its list doesn't have (#371: gpt-6-luna,
// not in a Student plan's) or that it was refused is left out;
// with none left, as if none were picked.
picks = slices.DeleteFunc(slices.Clone(picks), func(id string) bool { _, ok := byID[id]; return !ok })
}
if len(picks) > 0 {
return pick(picks)
}
// another magpie's list is already the models its user exposed
if len(avail) <= manyModels || p.IsRemoteMagpie() {
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)
}
}
// one of Devin's variants an agent was set to, which the list offers as
// its family: the one effort its id runs at, whatever effort is asked
if p.ID == "devin" {
if l := devinEffortOf(model); l != "" {
return []string{l}
}
}
// one of the vendor's own its list leaves out (a preview) or typed in:
// the vendor's word on it, before the others'
if e, ok := catalog.ListedBy(p.Catalogs(), model); ok {
return e
}
return borrowedEfforts(model)
}
// Levelless reports whether the model is known to have no reasoning levels
// to pick from, as against not known to have any: its vendor or its maker
// lists it with a thinking switch alone, or nothing (Xiaomi's
// mimo-v2.6-flash), and the user gave it none.
func (p Provider) Levelless(model string) bool {
if len(p.Efforts(model)) > 0 {
return false
}
if e, ok := catalog.ListedBy(p.Catalogs(), model); ok {
return len(e) == 0
}
e, ok := catalog.ListedBy(makerCatalogs(), model)
return ok && len(e) == 0
}
// 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) — its maker's, else 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 borrowedEfforts(m.ID)
}
// borrowedEfforts are the reasoning levels of a model its provider has no
// word on: its maker's, when a vendor of magpie's presets makes it — none
// for Xiaomi's mimo-v2.6-flash, which takes a thinking switch alone and
// turns away the max resellers list for it (#214) — else those most of the
// providers giving any give it (a Volcengine endpoint's glm-5.3-flash).
func borrowedEfforts(id string) []string {
if e, ok := catalog.ListedBy(makerCatalogs(), id); ok {
return e
}
return catalog.EffortsOf(id)
}
// makerCatalogs are the models.dev ids of the vendors among the presets
// that make the models they serve, in the presets' order.
var makerCatalogs = sync.OnceValue(func() []string {
var out []string
for _, pr := range presets {
if pr.Kind != KindVendor || pr.Hosts {
continue
}
for _, c := range (Provider{Catalog: pr.Catalog}).Catalogs() {
if !slices.Contains(out, c) {
out = append(out, c)
}
}
}
return out
})
// ListPrice is a model's list price as its vendor's models.dev entry gives
// it, else as its maker's does (#224): a subscription (Codex's ChatGPT
// account, Copilot) or a relay with no models.dev id of its own is priced
// at gpt-6-astra's or gemini-3.8-flash's maker's price, as a Claude
// account is at Anthropic's.
func (p Provider) ListPrice(model string) (catalog.Price, bool) {
for _, m := range pricedNames(model) {
if pr, ok := catalog.PricedBy(p.Catalogs(), m); ok {
return pr, true
}
if pr, ok := catalog.PricedBy(makerCatalogs(), m); ok {
return pr, true
}
}
return catalog.Price{}, false
}
// MakerPrice is a model's list price as the first vendor among the presets
// that makes the models it serves lists it; for a call whose provider has
// gone since.
func MakerPrice(model string) (catalog.Price, bool) {
for _, m := range pricedNames(model) {
if pr, ok := catalog.PricedBy(makerCatalogs(), m); ok {
return pr, true
}
}
return catalog.Price{}, false
}
// EffectivePrice is what a call to a provider's model costs the user: the
// price they set for that model, or for every model of that provider, or for
// that model from any provider (settings' ModelPrices), else the provider's own list price, else its
// maker's. The second return is false only when no price is known at all,
// which is not the same as a price of zero: that one is set, deliberately.
//
// A price here is the provider's tariff, not the model's: the same model
// through two relays is two prices, and neither is what models.dev lists.
func EffectivePrice(providerID, model string) (catalog.Price, bool) {
return EffectivePriceIn(settings.Load(), providerID, model)
}
// EffectivePriceIn is EffectivePrice at the settings given, for a caller
// pricing a whole list of models at one read of the file rather than one
// read per model: every model of that list is priced at the same copy, so
// the list is one snapshot of the prices rather than a reading per row, and
// a price changed while it is read takes effect in the next call.
func EffectivePriceIn(s settings.Settings, providerID, model string) (catalog.Price, bool) {
// A price is keyed by the id the provider has now, so one written before
// a rename is read under the id it has. Only an id, or one the provider
// was renamed from, resolves; anything else is looked up as given, so a
// key under a display name counts only for a caller naming that name
// too. Nothing writes such a key — SetModelPrice re-keys the way
// SetModelName does — which is what keeps the two from drifting.
id := providerID
p, known := byIDOrWas(providerID)
if known {
id = p.ID
}
// then what they said the model costs from any provider (*/model):
// still the user's word, so before any list price
for _, key := range [...]string{id + "/" + model, id + "/*", AnyPriceKey(model)} {
if m, ok := s.ModelPrices[key]; ok {
if pr, bad := m.Price(); bad == "" {
return pr, true
}
}
}
if known {
if pr, ok := p.ListPrice(model); ok {
return pr, true
}
}
return MakerPrice(model)
}
// byIDOrWas is the provider with that id, else the one it was renamed from.
// Find would answer a display name as well, which is not how a price or a
// reply limit is keyed: both are stored under the id the provider has now, and
// both outlive the provider they were set for, so the spelling the user typed
// is the one that has to be checked before a name is resolved at all.
func byIDOrWas(id string) (Provider, bool) {
all := All()
if p, ok := find(all, id); ok {
return p, true
}
for _, p := range all {
if slices.Contains(p.Was, id) {
return p, true
}
}
return Provider{}, false
}
// grokEffort is a reasoning effort a Grok id is named at ("grok-4.7-low",
// "grok-4.7-xhigh"): the levels Grok's own model list gives grok-4.7, the
// words Cursor spells its ids in. A fast one ("grok-4.7-low-fast") doesn't
// match: fast is a model of its own (cursor_models.go), and no catalog
// prices it.
var grokEffort = regexp.MustCompile(`^((?:.*/)?grok-[0-9][^/]*?)-(?:minimal|low|medium|high|xhigh|extra-high)$`)
// pricedNames are the ids a model is priced by, in order: its own, then,
// for a Grok id named at an effort, the model it is that effort of — the
// same model, at the same price (#224). Codex Auto Review uses GPT-5.6 Luna
// according to OpenAI's rate card (2026-09-30):
// https://help.openai.com/en/articles/11481834-chatgpt-rate-card-business-enterpriseedu-credit-based-pricing
// This is a list-price estimate, not evidence of a response's served model.
// Other names without catalog prices stay unpriced (grok-4.7-build, grok-4.7-mini,
// grok-4.7-fast).
func pricedNames(model string) []string {
out := []string{model}
if model == "codex-auto-review" {
out = append(out, "gpt-5.6-luna")
}
if m := grokEffort.FindStringSubmatch(strings.ToLower(strings.TrimSpace(model))); m != nil {
out = append(out, m[1])
}
return out
}
// PricedName is the catalog ID used for a list-price estimate. Prefer a
// directly listed model before falling back to a documented alias.
func PricedName(model string) string {
n := pricedNames(model)
for _, name := range n {
if _, ok := catalog.PricedBy(makerCatalogs(), name); ok {
return name
}
}
return n[len(n)-1]
}
// 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"`
// Free is set on a model its subscription serves at no cost to it.
Free bool `json:"free,omitempty"`
// Shared are a group's levels its members have in common: its Efforts,
// unless the group names its own (Group.Levels).
Shared []string `json:"-"`
// Reasoning is set on a model that thinks, levels or not: one with a
// thinking switch alone has it and no Efforts (a group's: every
// member thinks).
Reasoning bool `json:"reasoning,omitempty"`
}
// Catalog lists the routing groups, then every exposed model of every ready
// provider not kept unlisted. A provider switched off has none in it.
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...)
}
// Unlisted are the models Served has and Catalog doesn't: those of the
// providers kept for routing groups (Provider.Unlisted), which agents
// aren't offered.
func Unlisted() []Entry {
var out []Entry
for _, e := range providerEntries() {
if e.Provider.Unlisted {
out = append(out, e)
}
}
return out
}
// providerEntries is the catalog without its groups.
func providerEntries() []Entry {
var out []Entry
s := settings.Load()
for _, p := range All() {
if !p.On() || p.Decides() { // a decision API only routes
continue
}
for _, m := range p.Exposed() {
out = append(out, entryFor(p, m, s))
}
}
return out
}
// entryFor is one of a provider's models as the catalog carries it: the
// window and the reply limit a request on it is routed and metered against,
// with what the user set taken over the vendor's list and models.dev.
func entryFor(p Provider, m catalog.Model, s settings.Settings) Entry {
// 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)
}
if n := outputOf(s, p.ID, m.ID); n > 0 {
output = n
}
// an agent asks for the reply limit it is told, and Command Code
// refuses one above its own
if p.ID == CommandCodePlanID && output > CommandCodeMaxOutput {
output = CommandCodeMaxOutput
}
images := m.Images || catalog.SeesImages(m.ID)
if m.ImageInput != nil {
images = *m.ImageInput
}
images, imageInput := ApplyImage(p.ID, m.ID, 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: imageInput, Context: ctx, Output: output, Free: m.Free}
if n, ok := modelNameIn(s.ModelNames, p.ID, m.ID); ok {
e.Name, e.Default = n, m.Name
}
// a model that thinks still does with the levels the user kept or
// none at all; one its source says nothing of thinks as most of the
// providers serving it say (#402)
e.Reasoning = m.Reasoning || len(e.Efforts) > 0 || catalog.Thinks(m.ID)
e.Efforts = effortsKept(e.Efforts, s.ModelEfforts[e.ID])
return e
}
// EntryOf is the model as the agents see it, by the id they ask for. It is
// the catalog's own entry, so the window and reply limit it carries are the
// ones a request is routed and metered against, with whatever the user set
// already applied.
func EntryOf(id string) (Entry, bool) {
return entryIn(Catalog(), id)
}
// ServedEntryOf is the model a limit may be set on, by the id the user
// typed: any model its provider serves, not only the catalog's own entry.
// The setters ask serves — the provider's own models deciding, switched off
// or not — so a provider kept unlisted, still served through a routing
// group, takes a window and a reply limit all the same, and a provider
// switched off is served again as soon as it is on, keeping what it was
// given. Both keep it in the entry they answer with, so a query that said
// "is not a model this provider serves" about such an id would contradict
// the command run right after it, which stores one.
func ServedEntryOf(id string) (Entry, bool) {
pid, model, ok := strings.Cut(id, "/")
if !ok {
return Entry{}, false
}
p, err := Find(pid)
if err != nil || !p.serves(model) {
return Entry{}, false
}
s := settings.Load()
for _, ms := range [][]catalog.Model{p.Available(), p.Exposed()} {
for _, m := range ms {
if m.ID == model {
return entryFor(*p, m, s), true
}
}
}
return Entry{}, false
}
func entryIn(entries []Entry, id string) (Entry, bool) {
for _, e := range entries {
if e.ID == id {
return e, true
}
}
return Entry{}, false
}
// 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.On() {
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.On() || 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["*"]
}
// outputOf is the most a reply of a model may hold, as the user said it: the
// model's own, else the one given for every model of its provider, else 0 for
// the vendor's own list and models.dev to answer. It mirrors ContextOf, which
// the user sets on the provider itself.
func outputOf(s settings.Settings, providerID, model string) int {
if n := s.ModelOutputs[providerID+"/"+model]; n > 0 {
return n
}
return s.ModelOutputs[providerID+"/*"]
}
// SetModelOutput is the most a reply of a provider's model may hold, in
// tokens, over what the vendor's list and models.dev say; 0 takes the user's
// away, which DropModelOutput does. The id is spelled "provider/model", or
// "provider/*" for every model of that provider; setting one has to name a
// model the provider serves, as one no lookup would ever match reads as set
// and is not. A removal may name a model that has since gone, or a provider
// that has: that is the entry left to be cleared.
//
// The agents are told of it (catalog.Touched), as for any change of the
// catalog: a reply limit is a number they keep in files of their own — Pi's
// maxTokens, OpenCode's limit — and read at start-up, so one only the
// gateway knew would leave every agent a reply limit behind.
func SetModelOutput(id string, n int) error {
if n < 0 {
return errorf("an output limit is a number of tokens, not %d", n)
}
if n == 0 {
// a removal is not this function's own work: it has to reach an
// entry whose provider is gone, and resolving the ref is the very
// step that refuses one
_, _, err := DropModelOutput(id)
return err
}
p, model, err := splitRef(id)
if err != nil {
return err
}
// the id given is not always the provider's own: an agent still
// running on a config written before a rename names its provider by
// the id the provider had, and Find takes that id, so a key under it
// is a reply limit no lookup will ever match — every read asks for the
// id the provider has now (outputOf, entryFor, the gateway's /models).
// It would sit in the settings reading as set and answer no reply ever,
// and a removal would clear an entry nobody reads instead of the one in
// force. The key is the provider's id as it is now, as every other
// per-model setter's is.
key := p.ID + "/" + model
if err := settings.CheckModelKey("an output limit", key); err != nil {
return err
}
// a limit for a model the provider does not serve is one that never
// applies and nothing later says so; the same refusal `magpie model
// name` makes for the same id.
if model != "*" && !p.serves(model) {
return errorf("%s has no model %s (magpie provider %s lists them)", p.ID, model, p.ID)
}
s := settings.Load()
if s.ModelOutputs == nil {
s.ModelOutputs = map[string]int{}
}
s.ModelOutputs[key] = n
if err := settings.Save(s); err != nil {
return err
}
catalog.Touched()
return nil
}
// DropModelOutput takes a user's reply limit away under the key it is
// stored at, and hands that key back, so a caller left without a provider
// to name it by can still say which entry it cleared. It does not ask
// whether the provider is still there: a reply limit outlives the provider
// it was set for, and that provider can be deleted — the entry then sits in
// the settings answering for a model nothing serves, and a removal that
// resolved the provider first would leave the user no way to take it away,
// the file being the only other place it is in. Which key that is comes
// from the spelling as given before anything is looked up (outputKeyOf),
// so that a provider which has since taken the name of one that is gone is
// not the one the entry is cleared under. A key holding no limit is no
// error: there is nothing there to take away, which is the state the entry is
// left in either way, and writing it out again would rewrite the settings and
// every agent's model lists over a limit that did not change.
//
// It reports whether there was a limit there to take away, as DropModelPrice
// does, and by neither saving nor telling the agents when there was none:
// only the caller can put that to the user, and a ✓ over a limit that was
// never set says the model had one.
func DropModelOutput(id string) (string, bool, error) {
key, err := outputKeyOf(id)
if err != nil {
return "", false, err
}
if err := settings.CheckModelKey("an output limit", key); err != nil {
return "", false, err
}
s := settings.Load()
if _, ok := s.ModelOutputs[key]; !ok {
return key, false, nil
}
delete(s.ModelOutputs, key)
if err := settings.Save(s); err != nil {
return "", false, err
}
// the agents are told as they are of a limit being set: the number they
// keep in files of their own goes back to the vendor's list only if
// they hear of it
catalog.Touched()
return key, true, nil
}
// outputKeyOf is the "provider/model" a caller spelled, as the key the reply
// limits are written under, and what the spelling has to be for it to name
// a model at all. It is half of splitRef, which goes on to resolve the
// provider's own id: a limit set by a name the provider is listed under
// needs that, and one removed by the same name has nothing left to resolve
// it against, so there the key as given is the one the entry is under.
//
// The spelling that was given comes first, because a reply limit outlives
// the provider it was set for and nothing rewrites its key when that
// provider goes: "b/sol" can still be where a limit is held while a
// provider of some other id has since been given the display name "b".
// Resolving that name first would take the second provider's key away
// instead — a limit of its own is stored under it, so the user is told the
// entry they asked for has gone, and the entry they meant stays in the
// settings to go on answering with. Only where nothing is stored under the
// spelling is the provider looked for, by the id it has or the one it was
// renamed from: that is where SetModelOutput keeps a limit set through a
// display name, and the same resolution outputOf reads it back under.
func outputKeyOf(id string) (string, error) {
r := strings.TrimPrefix(strings.TrimSpace(id), "magpie/")
if strings.HasPrefix(r, GroupPrefix) {
return "", errorf("that is a routing group, not a provider's model")
}
pid, model, ok := strings.Cut(r, "/")
if !ok || pid == "" || model == "" {
return "", errorf("name a model as provider/model, not %q", id)
}
key := pid + "/" + model
if _, under := settings.Load().ModelOutputs[key]; under {
return key, nil
}
if p, ok := byIDOrWas(pid); ok {
return p.ID + "/" + model, nil
}
// and last a display name, which is what a provider of the user's is
// often asked by. Nothing writes a reply limit under one —
// SetModelOutput re-keys it as it does a name — but the spelling above
// is the only thing that could have pointed at another provider's key,
// and a key nothing is stored under reaches no limit at all without
// this.
if p, err := Find(pid); err == nil {
return p.ID + "/" + model, nil
}
return key, nil
}
// SetContext is how long a request one of a provider's models takes, in
// tokens, over what the vendor's list and models.dev say; 0 takes the user's
// away, which DropContext does, and "*" is every model of that provider. As
// with a reply limit, setting one has to name a model the provider serves, and
// a removal may name one it no longer does.
//
// This is the provider's own Contexts, kept as the one value both the gateway
// advertises and the routing rules read — a context is a routing input, not
// only a number agents are shown: at 95% of a held member's window a request
// moves to a member that takes more (internal/gateway/rules.go). Saving it
// tells the agents (catalog.Touched), as for any change of the catalog: a
// window is a number they keep in files of their own — Pi's contextWindow,
// OpenCode's limit — and read at start-up, so a session already running
// keeps the window it began with while the gateway's own /models is right at
// once.
func SetContext(p Provider, model string, n int) error {
if n < 0 {
return errorf("a window is a number of tokens, not %d", n)
}
// a window for a model the provider does not serve is one that never
// applies and nothing later says so. A removal is exempt, so an entry
// left for a model that has since gone can still be taken away.
if n > 0 && model != "*" && !p.serves(model) {
return fmt.Errorf("%s has no model %s (magpie provider %s lists them)", p.ID, model, p.ID)
}
if n > 0 {
if p.Contexts == nil {
p.Contexts = map[string]int{}
}
p.Contexts[model] = n
return Save(p)
}
// a removal goes the way DropModelOutput's does: the provider is not
// saved over a window that is not there, so nothing is written and the
// agents are not told over a limit that did not change
_, err := DropContext(p, model)
return err
}
// DropContext takes a window off one of a provider's models and reports
// whether there was one there to take away, as DropModelPrice and
// DropModelOutput do: a removal over a window that is not there leaves the
// provider in the state it was already in, but only the caller can tell the
// user so, and a ✓ over a window that was never set says there was one.
// Nothing is saved in that case, and so the agents are not told either — a
// provider saved over a window that did not change rewrites every agent's
// model lists for nothing, which is the one round the removal itself earns.
//
// Save is what tells the agents here (store), so it is the save, and not a
// Touched of its own, that has to happen exactly once.
func DropContext(p Provider, model string) (bool, error) {
if _, ok := p.Contexts[model]; !ok {
return false, nil
}
delete(p.Contexts, model)
if err := Save(p); err != nil {
return false, err
}
return true, nil
}