Files
magpie/model_cli.go
T
Comic Chang 901ee08280 Count usage at the configured provider/model price (#278)
Magpie reports models.dev's list price for every call. That is the right
number for a vendor's own key and the wrong one for a provider that
resells or exposes a model at a different tariff, and it leaves a model the
catalogue does not list with no cost at all — a provider's own /models
gives ids, not economics.

  magpie model price relay-a/gpt-5.5 0.12,1.20,0.01,0.15
  magpie model price relay-a/* 0.12,1.20,0.01,0.15
  magpie model price relay-a/gpt-5.5 --reset
  magpie model prices

Absolute USD per million tokens. All four parts are required: a price
missing one would understate the rest of every call, and a hand-edited file
that left a part out would bill it at zero, so each part is a pointer and
the setter refuses a price that is negative, NaN or infinite, naming the
part at fault. Zero stays distinct throughout — a model served at no cost
has a price, not the absence of one. A `<provider>/*` entry covers every
model of that provider and an exact entry wins over it; --reset removes
only the exact entry and says so when a provider-wide one is still in
force.

The order a price is looked for in is the price for this model, then
`<provider>/*`, then the provider's own catalogue, then models.dev for the
maker. A price is a provider's tariff, not the model's: the same model
through two providers has two prices, and each keeps its own. A provider is
resolved by its id, or by an id it was renamed from, and never by its
display name — Find answers those too, and a record naming one was counted
at another provider's price.

provider.EffectivePrice is the one lookup every cost caller shares, so the
usage ledger and a session's totals both go through it, and a session
naming a model bare is still priced at its maker's list price, which is a
different question from what one provider charges. catalog.PriceOf and
PricedBy keep returning the models.dev price for anything wanting an
unbiased number. The background-task pickers (draw, search, vision) still
rank by catalogue price: they choose which model answers, not what a call
is reported to cost.

Unlike every other per-model preference this does not call
catalog.Touched. What a call costs is not what an agent picks a model by,
and the model lists magpie keeps in the agents' own files are not its to
rewrite over a number in a cost report. A rename moves the prices with it
through the walk #288 adds, so nothing here has to say so, and an id
recorded before a rename still resolves.

Validation happens where the entry is written, not in Save: one hand-edited
bad price in the file must not make saving a theme or a window size fail. A
price for a model the provider does not serve is refused the way
`magpie model name` refuses the same id, since it is a price that never
applies and nothing reports it as dead; a removal is exempt, so an entry
left for a model that has since gone can still be taken away.
2026-09-30 20:31:11 +08:00

404 lines
14 KiB
Go

package main
import (
"fmt"
"maps"
"slices"
"strconv"
"strings"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
// A model's name and reasoning levels, from the terminal: magpie model.
const modelUsage = `usage:
magpie model name <provider/model> the name the model goes by
magpie model name <provider/model> <name> name it so everywhere: in magpie, in the gateway's
model list, and in the lists magpie writes into the agents
magpie model name <provider/model> --reset give it back its own name
magpie model efforts <provider/model> the reasoning levels it offers, and those it has
magpie model efforts <provider/model> <l>,<l> offer only these of them, e.g. low,medium,high
magpie model efforts <provider/model> --reset offer every level it has again
magpie model price <provider/model> what the model costs you, and what you said it costs
magpie model price <provider/model> <in>,<out>,<cache read>,<cache write>
say what it costs, in USD per million tokens, all four parts as
0.12,1.20,0.01,0.15; 0 is a model served for nothing, which is
a price, not the absence of one
magpie model price <provider/model> --reset take your price off this model
magpie model prices the models you priced
magpie model names the models you named or narrowed
Each is looked for in this order: this model, then <provider id>/*, then the provider's own
list, then models.dev. --reset removes only the first, and says so when a <provider id>/* value
still applies.
Only what agents are shown changes: they still pick the model, and requests still reach it,
as <provider/model>. The same model from another provider keeps its own name and levels.
A price is one provider's tariff for one model, not the model's own: it changes what the
usage and session totals report, and nothing an agent can see, and it is saved without
rewriting the model lists in the agents' own files.
e.g. magpie model name claude/claude-opus-5-5 "Opus 5.5"
magpie model efforts openai/gpt-6 low,medium,high
magpie model price relay-a/gpt-5.5 0.12,0.60,0.01,0.15`
func modelCmd(args []string) error {
if len(args) == 0 {
return fmt.Errorf("%s", modelUsage)
}
switch args[0] {
case "names", "ls", "list":
return modelNames()
case "name", "rename":
return modelName(args[1:])
case "efforts", "effort", "levels":
return modelEfforts(args[1:])
case "price", "cost":
return modelPrice(args[1:])
case "prices":
return modelPrices()
case "help", "-h", "--help":
fmt.Println(modelUsage)
return nil
}
return fmt.Errorf("unknown command %q\n\n%s", args[0], modelUsage)
}
// modelRef is the provider and model a typed provider/model names.
func modelRef(s string) (*provider.Provider, string, error) {
pid, model, err := splitModelRef(s)
if err != nil {
return nil, "", err
}
p, err := provider.Find(pid)
if err != nil {
return nil, "", err
}
return p, model, nil
}
// splitModelRef is "provider/model" as the id before the slash and the model
// after it, without asking for the provider. A ref the user must still be
// able to name is one whose provider may be gone: the key a price is
// stored at outlives the provider it was set for.
func splitModelRef(s string) (string, string, error) {
ref := strings.TrimPrefix(strings.TrimSpace(s), "magpie/")
pid, model, ok := strings.Cut(ref, "/")
if !ok || model == "" {
return "", "", fmt.Errorf("name a model as provider/model, not %q (magpie models lists them)", s)
}
return pid, model, nil
}
func isReset(args []string) bool {
return len(args) == 1 && slices.Contains([]string{"--reset", "-r", "--default", "default", "reset"}, args[0])
}
func modelName(args []string) error {
if len(args) == 0 {
return fmt.Errorf("%s", modelUsage)
}
p, model, err := modelRef(args[0])
if err != nil {
return err
}
own := model
for _, m := range p.Available() {
if m.ID == model && m.Name != "" {
own = m.Name
break
}
}
id := p.ID + "/" + model
rest := args[1:]
if len(rest) == 0 {
if n, ok := provider.ModelName(p.ID, model); ok {
fmt.Println(bold.Render(n), muted.Render("· "+id+" · its own name is "+own))
} else {
fmt.Println(bold.Render(own), muted.Render("· "+id+" · its own name"))
}
return nil
}
name := strings.Join(rest, " ")
if isReset(rest) {
name = ""
}
if err := provider.SetModelName(id, name); err != nil {
return err
}
if name == "" {
fmt.Println(green.Render("✓"), id, muted.Render("is called"), bold.Render(own), muted.Render("again"))
} else {
fmt.Println(green.Render("✓"), id, muted.Render("is called"), bold.Render(strings.Join(strings.Fields(name), " ")))
}
return nil
}
func modelEfforts(args []string) error {
if len(args) == 0 {
return fmt.Errorf("%s", modelUsage)
}
p, model, err := modelRef(args[0])
if err != nil {
return err
}
id := p.ID + "/" + model
all := p.Efforts(model)
rest := args[1:]
given := len(p.Known(model)) == 0 // levels aren't known: those it has are the user's
if len(rest) == 0 {
if len(all) == 0 {
fmt.Println(muted.Render(id+" has no reasoning levels known"), faint.Render("· give it some of "+strings.Join(provider.Levels, ", ")))
return nil
}
if given {
fmt.Println(bold.Render(strings.Join(all, " ")), muted.Render("· given it · --reset takes them away"))
return nil
}
kept, narrowed := p.ModelEfforts()[model]
for _, e := range all {
if !narrowed || slices.Contains(kept, e) {
fmt.Print(bold.Render(e), " ")
} else {
fmt.Print(faint.Render(e), " ")
}
}
if narrowed {
fmt.Println(muted.Render("· only the bold ones are offered · --reset offers them all"))
} else {
fmt.Println(muted.Render("· all offered"))
}
return nil
}
var levels []string
if !isReset(rest) {
for _, a := range rest {
for _, l := range strings.FieldsFunc(a, func(r rune) bool { return r == ',' || r == ' ' || r == '/' }) {
levels = append(levels, l)
}
}
}
if err := provider.SetModelEfforts(id, levels); err != nil {
return err
}
if given {
if all = p.Efforts(model); len(all) > 0 {
fmt.Println(green.Render("✓"), id, muted.Render("offers"), bold.Render(strings.Join(all, ", ")))
} else {
fmt.Println(green.Render("✓"), id, muted.Render("has no reasoning levels again"))
}
return nil
}
if kept, ok := p.ModelEfforts()[model]; ok {
fmt.Println(green.Render("✓"), id, muted.Render("offers"), bold.Render(strings.Join(kept, ", ")))
} else {
fmt.Println(green.Render("✓"), id, muted.Render("offers every level it has"), faint.Render(strings.Join(all, ", ")))
}
return nil
}
// modelPrice shows what a provider's model is counted at, or sets what the
// user says it costs in USD per million tokens. All four parts are given: a
// price is a cost report, and one missing a part would understate it.
func modelPrice(args []string) error {
if len(args) == 0 {
return fmt.Errorf("%s", modelUsage)
}
rest := args[1:]
// --reset comes before the provider is looked for, and works off the
// key the price is stored at: a price outlives the provider it was set
// for, and one whose provider is gone is still listed by `magpie model
// prices` and still counted, so a reset that resolved the provider
// first could not take it away — and would take another provider's
// price away instead where the gone one's name has been given to one.
if isReset(rest) {
return resetModelPrice(args[0])
}
p, model, err := modelRef(args[0])
if err != nil {
return err
}
id := p.ID + "/" + model
if len(rest) == 0 {
pr, ok := provider.EffectivePrice(p.ID, model)
if !ok {
fmt.Println(muted.Render(id), faint.Render("· no price is known for it"))
return nil
}
fmt.Println(bold.Render(perMillion(pr)), muted.Render("· "+id))
fmt.Println(faint.Render(" · cache read " + money(pr.CacheRead) + ", cache write " + money(pr.CacheWrite)))
switch priceFrom(settings.Load(), p.ID, model) {
case "model":
fmt.Println(faint.Render(" · what you said this model costs · --reset takes that away"))
case "provider":
fmt.Println(faint.Render(" · what you said every model of this provider costs · magpie model price " +
p.ID + "/* --reset takes that away"))
case "ignored":
fmt.Println(faint.Render(" · a price you gave is not usable and is ignored"))
default:
fmt.Println(faint.Render(" · what its provider lists, else its maker's on models.dev · magpie model price " +
id + " <in>,<out>,<cache read>,<cache write> to change it"))
}
return nil
}
var nums []float64
for _, a := range rest {
for _, f := range strings.FieldsFunc(a, func(r rune) bool { return r == ',' || r == ' ' || r == '/' }) {
v, err := strconv.ParseFloat(f, 64)
if err != nil {
return fmt.Errorf("a price is numbers in USD per million tokens, like 0.12,1.20,0.01,0.15, not %q", f)
}
nums = append(nums, v)
}
}
if len(nums) != 4 {
return fmt.Errorf("give all four parts, input,output,cache read,cache write — %d given", len(nums))
}
pr := catalog.Price{Input: nums[0], Output: nums[1], CacheRead: nums[2], CacheWrite: nums[3]}
if err := provider.SetModelPrice(id, &pr); err != nil {
return err
}
fmt.Println(green.Render("✓"), id, muted.Render("costs"), bold.Render(perMillion(pr)))
return nil
}
// resetModelPrice takes a price off and says what the model is counted at
// now. The key it works off is the one the price is stored at, which is not
// always the one that was typed: where nothing is stored under the spelling
// it is the id the provider has now, which is where a price set through a
// display name is kept. provider.PriceKey is what decides that, and it
// decides it the same way for both callers, so this cannot resolve one way
// and the removal another.
//
// A key with no price under it is an error rather than a tick: there was
// nothing to take away, and a user who mistyped an id, or who is priced only
// through a provider-wide price, has to be told so rather than left with a
// ✓ over a price that is still counted.
func resetModelPrice(ref string) error {
pid, model, err := splitModelRef(ref)
if err != nil {
return err
}
key, there := provider.PriceKey(pid, model)
dropped, err := provider.DropModelPrice(key)
if err != nil {
return err
}
s := settings.Load()
wid, _, _ := strings.Cut(key, "/")
if !dropped {
if _, wide := statedPrice(s.ModelPrices, wid+"/*"); wide {
return fmt.Errorf("%s has no price of its own; what it costs is what you set for every model of this provider, %s/*, which magpie model price %s/* --reset takes away",
key, wid, wid)
}
return fmt.Errorf("%s has no price of its own to reset — magpie model prices lists the ones you set", key)
}
if !there {
fmt.Println(green.Render("✓"), key,
muted.Render("no longer has a price of its own; its provider is gone, so it is costed at its maker's, if models.dev lists one"))
return nil
}
if _, wide := statedPrice(s.ModelPrices, wid+"/*"); wide {
fmt.Println(green.Render("✓"), key,
muted.Render("no longer has a price of its own; it still costs what you set for every model of this provider"))
return nil
}
fmt.Println(green.Render("✓"), key,
muted.Render("no longer has a price of its own; it is costed at what its provider lists, or its maker's"))
return nil
}
// perMillion is a price as the two numbers a reader wants first.
func perMillion(p catalog.Price) string {
return fmt.Sprintf("$%.4g/$%.4g per 1M in/out", p.Input, p.Output)
}
// money is one part of a per-million price as a price tuple writes it.
func money(v float64) string { return fmt.Sprintf("$%.4g", v) }
// statedPrice is the usable price a "<provider id>/<model id>" key holds, if
// it holds one at all. A key that is there with a part missing is not one:
// it would bill the rest of a call at zero.
func statedPrice(prices map[string]settings.ModelPrice, key string) (catalog.Price, bool) {
m, ok := prices[key]
if !ok {
return catalog.Price{}, false
}
p, bad := m.Price()
return p, bad == ""
}
// priceFrom says where a model's effective price came from: the price given
// for it, the one given for every model of its provider, a price that is
// there but unusable, or neither — which leaves the provider's own list price
// and then its maker's. The order is the one EffectivePrice looks in.
func priceFrom(s settings.Settings, providerID, model string) string {
if _, ok := statedPrice(s.ModelPrices, providerID+"/"+model); ok {
return "model"
}
if _, ok := statedPrice(s.ModelPrices, providerID+"/*"); ok {
return "provider"
}
if _, given := s.ModelPrices[providerID+"/"+model]; given {
return "ignored"
}
if _, given := s.ModelPrices[providerID+"/*"]; given {
return "ignored"
}
return ""
}
// modelPrices lists what the user has said models cost, and which provider
// and model each is for.
func modelPrices() error {
prices := settings.Load().ModelPrices
if len(prices) == 0 {
fmt.Println(muted.Render("no model is priced by you yet"))
fmt.Println(faint.Render("magpie model price <provider/model> <in>,<out>,<cache read>,<cache write>"))
return nil
}
for _, id := range slices.Sorted(maps.Keys(prices)) {
p, bad := prices[id].Price()
if bad != "" {
fmt.Println(bold.Render(id), muted.Render("· no "+bad+" price given, and ignored"))
continue
}
fmt.Println(bold.Render(id), muted.Render("· "+perMillion(p)+
" · cache "+money(p.CacheRead)+"/"+money(p.CacheWrite)))
}
return nil
}
func modelNames() error {
s := settings.Load()
keys := slices.Sorted(maps.Keys(s.ModelNames))
for k := range s.ModelEfforts {
if !slices.Contains(keys, k) {
keys = append(keys, k)
}
}
slices.Sort(keys)
if len(keys) == 0 {
fmt.Println(muted.Render("no model is named or narrowed yet · magpie model name <provider/model> <name>"))
return nil
}
w := 0
for _, k := range keys {
w = max(w, len(k))
}
for _, k := range keys {
line := " " + pad(k, w)
if n := s.ModelNames[k]; n != "" {
line += " " + n
}
if es := s.ModelEfforts[k]; len(es) > 0 {
line += " " + muted.Render(strings.Join(es, "/"))
}
fmt.Println(line)
}
return nil
}