provider: a Claude model models.dev lists after the Claude account's last fetch, Haiku 5.5 among them, is offered at once, not after the next Refresh (wakaka on Discord)

The Claude subscription's models are magpie's own list, the models.dev
catalog's anthropic rows. Its fetch kept a copy of that list in
models/claude.json, and available() served that copy over the catalog
from then on. FetchNew fetches an account only while it has no list, so
the copy was never taken again unless the user pressed Refresh: the
hourly models.dev sync brought Claude Haiku 5.5 (listed from 2026-10-07)
into the catalog, while the Claude account kept offering the list as it
was at its first fetch. The owner's own copy is from 2026-10-03 and has
no claude-haiku-5-5, though the synced catalog beside it has.

An account whose fetch asks no vendor (magpieList: Claude's, and
Factory's compiled-in droid list, which had the same hold-out after an
update) is now always served its list as it is now. A vendor's own list
(Codex, Copilot, Kiro, ...) is served as fetched, as before.

Haiku 5.5's facts come from the catalog: models.dev's row (1M context,
128K output, low..max efforts, $0.10/$0.50 with $0.50/$2.50 over 100K)
matches Claude Code 2.1.296's own model catalog (window 1e6, native 1M,
max_output 128000, effort/xhigh_effort/max_effort, default medium). So
no static table needed a row: claudeEffortsFor gives an unknown newer
Claude all five levels, and claudeTopEffort rightly leaves it out
(2.1.296 reads the top effortLevel only for the models up to Fable 5.1).
With the model listed, the haiku tier follows the newest Haiku
(claudeLight), as Claude Code's own default haiku on the Anthropic API
is claude-haiku-5-5 since 2.1.293.

Verified:
- TestClaudeAccountServesModelsListedSinceItsFetch (models.dev's real
  rows) fails without the models.go change: "Haiku 5.5 not served on the
  Claude account: [claude-opus-5-5]". TestFactoryServesModelsAddedSince
  ItsFetch fails without Factory's flag. Both pass with the change.
- gofmt -l clean; go vet, GOOS=linux and GOOS=windows go vet -tags nogui
  ./internal/provider; go build, GOOS=linux and windows build -tags nogui.
- go test -tags nogui ./internal/provider ./internal/agent
  ./internal/catalog ./internal/gateway ./internal/gui under a temp HOME:
  all ok.
- Not tried against a running magpie with a real Claude sign-in: the
  owner's gateway on 3425 wasn't running. The stale models/claude.json
  and synced models.json were read from the owner's cache (read only).
This commit is contained in:
yetone committed 2026-10-10 07:37:24 +08:00
1 parent 1134c7a8af
commit 9b51428e16
4 files changed
+124 -1

No files matched your search

+9
View File
@@ -73,6 +73,14 @@ type Account struct {
body func(body []byte) []byte // request tweaks the backend insists on
models func() []catalog.Model
fetch func(ctx context.Context) ([]catalog.Model, error)
// magpieList is set on an account whose fetch asks no vendor and
// keeps a copy of models, magpie's own list (Claude's, from the
// models.dev catalog; Factory's, compiled in): the list served is
// always models as it is now, never the copy, which held a model
// listed after it out until the next Refresh (wakaka on Discord:
// Claude Haiku 5.5, in Claude Code on the same account, missing from
// magpie).
magpieList bool
// auto is set on a Copilot account: the session of Copilot's Auto,
// the model it picks for the account (copilot_auto.go).
@@ -660,6 +668,7 @@ func claudeProvider(acct *Account) Provider {
// one that would go straight to the API with its sign-in is refused
acct.sign = func(context.Context, *http.Request, []byte) error { return errClaudeViaCLI }
acct.models = func() []catalog.Model { return catalog.Provider("anthropic") }
acct.magpieList = true
// Claude's models are the ones magpie knows: listing them would ask
// Anthropic with the account's sign-in, which magpie never does
acct.fetch = func(context.Context) ([]catalog.Model, error) {
+113
View File
@@ -0,0 +1,113 @@
package provider
import (
"context"
"os"
"path/filepath"
"slices"
"testing"
"time"
"github.com/yetone/magpie/internal/catalog"
)
// The models.dev catalog's anthropic rows, as models.dev served them on
// 2026-10-10: Opus 5.5, and Haiku 5.5, which it listed from 2026-10-07.
const (
mdOpus55 = `"claude-opus-5-5":{"id":"claude-opus-5-5","name":"Claude Opus 5.5","family":"claude-opus","attachment":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"tool_call":true,"structured_output":true,"temperature":false,"knowledge":"2026-06","release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","pdf"],"output":["text"]},"open_weights":false,"limit":{"context":1000000,"output":128000},"cost":{"input":4,"output":20,"cache_read":0.2,"cache_write":5}}`
mdHaiku55 = `"claude-haiku-5-5":{"id":"claude-haiku-5-5","name":"Claude Haiku 5.5","family":"claude-haiku","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","medium","high","xhigh","max"]}],"tool_call":true,"structured_output":true,"temperature":false,"knowledge":"2026-06","release_date":"2026-10-07","last_updated":"2026-10-07","modalities":{"input":["text","image","pdf"],"output":["text"]},"open_weights":false,"limit":{"context":1000000,"output":128000},"cost":{"input":0.1,"output":0.5,"cache_read":0.01,"cache_write":0.125,"tiers":[{"input":0.5,"output":2.5,"cache_read":0.05,"cache_write":0.625,"tier":{"type":"context","size":100000}}]}}`
)
func writeAnthropicCatalog(t *testing.T, rows ...string) {
t.Helper()
body := `{"anthropic":{"id":"anthropic","name":"Anthropic","models":{`
for i, r := range rows {
if i > 0 {
body += ","
}
body += r
}
body += `}}}`
if err := os.MkdirAll(filepath.Dir(catalog.CachePath()), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(catalog.CachePath(), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
catalog.Reset()
}
// A Claude model models.dev lists after the Claude account's list was
// last fetched is served at once, as the catalog has it: the copy a fetch
// keeps doesn't hold it out until the next Refresh (wakaka on Discord:
// Haiku 5.5 in Claude Code on the same account, not in magpie).
func TestClaudeAccountServesModelsListedSinceItsFetch(t *testing.T) {
home := claudeHome(t)
claudeSignIn(t, home, time.Now().Add(time.Hour))
t.Cleanup(catalog.Reset)
writeAnthropicCatalog(t, mdOpus55)
p, ok := find(All(), "claude")
if !ok || p.Account == nil {
t.Fatalf("claude: %+v %v", p, ok)
}
if _, err := p.Fetch(context.Background()); err != nil {
t.Fatal(err)
}
// models.dev lists Haiku 5.5 now; the hourly sync wrote it
writeAnthropicCatalog(t, mdOpus55, mdHaiku55)
p, _ = find(All(), "claude")
if _, ok := p.Fetched(); !ok {
t.Fatal("the fetch left no list")
}
i := slices.IndexFunc(p.Available(), func(m catalog.Model) bool { return m.ID == "claude-haiku-5-5" })
if i < 0 {
t.Fatalf("Haiku 5.5 not served on the Claude account: %v", modelIDs(p.Available()))
}
h := p.Available()[i]
if h.Name != "Claude Haiku 5.5" || h.Context != 1_000_000 || h.Output != 128_000 || !h.Reasoning ||
!slices.Equal(h.Efforts, []string{"low", "medium", "high", "xhigh", "max"}) {
t.Fatalf("Haiku 5.5: %+v", h)
}
if h.Price == nil || h.Price.Input != 0.1 || h.Price.Output != 0.5 {
t.Fatalf("Haiku 5.5's price: %+v", h.Price)
}
var listed []string
for _, e := range Catalog() {
listed = append(listed, e.ID)
}
if !slices.Contains(listed, "claude/claude-haiku-5-5") {
t.Fatalf("not among the models agents are offered: %v", listed)
}
if rp, model, ok := Resolve("claude/claude-haiku-5-5"); !ok || rp.ID != "claude" || model != "claude-haiku-5-5" {
t.Fatalf("resolve: %+v %q %v", rp, model, ok)
}
// and a model models.dev no longer lists leaves the account's list too
writeAnthropicCatalog(t, mdHaiku55)
p, _ = find(All(), "claude")
if got := modelIDs(p.Available()); !slices.Equal(got, []string{"claude-haiku-5-5"}) {
t.Fatalf("after Opus 5.5 left the catalog: %v", got)
}
}
// Factory's list is magpie's own too (droid's registry, compiled in): a
// model a newer magpie adds is served at once, not held out by the copy an
// older one's fetch kept.
func TestFactoryServesModelsAddedSinceItsFetch(t *testing.T) {
claudeHome(t)
p := factoryProvider(factoryLogin{Login: Login{User: "a@example.com"}})
if _, err := p.Fetch(context.Background()); err != nil {
t.Fatal(err)
}
old := factoryModels
t.Cleanup(func() { factoryModels = old })
factoryModels = append(slices.Clone(old), factoryModel{"claude-test-added", "Test Added", Anthropic, "anthropic", 200000, 64000, nil, true})
if _, ok := p.Fetched(); !ok {
t.Fatal("the fetch left no list")
}
if !slices.Contains(modelIDs(p.Available()), "claude-test-added") {
t.Fatalf("a model added since the fetch: %v", modelIDs(p.Available()))
}
}
+1
View File
@@ -717,6 +717,7 @@ func factoryProvider(a factoryLogin) Provider {
}
acct.explain = factoryExplain
acct.models = factoryCatalog
acct.magpieList = true
acct.fetch = func(ctx context.Context) ([]catalog.Model, error) {
ms := factoryCatalog()
return ms, catalog.SaveLive("factory", factoryAPI+"/api/llm/o/v1", ms)
+1 -1
View File
@@ -60,7 +60,7 @@ func (p Provider) available() []catalog.Model {
}
}
}
if live, _, ok := p.live(); ok {
if live, _, ok := p.live(); ok && (p.Account == nil || !p.Account.magpieList) {
if p.Account != nil && p.Account.unusable != nil {
// a model the list offers that the account was refused
// (Copilot's, copilot_refused.go)