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:
1 parent
1134c7a8af
commit
9b51428e16
4 files changed
+124
-1
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user