diff --git a/internal/plugin/host.js b/internal/plugin/host.js index bfef6341..a5133272 100644 --- a/internal/plugin/host.js +++ b/internal/plugin/host.js @@ -487,6 +487,7 @@ async function providers() { released: m.release_date ?? "", cost: m.cost, variants: Object.keys(m.variants ?? {}), + free: m.free === true, })), }) } diff --git a/internal/plugin/providers.go b/internal/plugin/providers.go index 7e1f4f93..1680dc4c 100644 --- a/internal/plugin/providers.go +++ b/internal/plugin/providers.go @@ -35,7 +35,10 @@ type Model struct { Image bool `json:"image"` Released string `json:"released"` Variants []string `json:"variants"` - Cost *struct { + // Free is set by the plugin on a model the plan serves at no cost to + // its allowance (WorkBuddy's "credits": "x0.00") + Free bool `json:"free"` + Cost *struct { Input float64 `json:"input"` Output float64 `json:"output"` } `json:"cost"` diff --git a/internal/plugin/testdata/fake/index.js b/internal/plugin/testdata/fake/index.js index 68df2a54..d2f8a116 100644 --- a/internal/plugin/testdata/fake/index.js +++ b/internal/plugin/testdata/fake/index.js @@ -82,6 +82,8 @@ export const FakePlugin = async ({ client }) => ({ if (auth?.type === "oauth" && auth.refresh?.startsWith("rot-")) { await client.auth.set({ path: { id: "fakeco" }, body: { ...auth, refresh: auth.refresh + "+" } }) } + // fake-1 costs the plan nothing, as a WorkBuddy model of x0.00 credits + p.models["fake-1"].free = true if (auth?.key === "few") return { "fake-1": p.models["fake-1"] } return p.models }, diff --git a/internal/provider/cursor_test.go b/internal/provider/cursor_test.go index 5fd6cb39..217f3375 100644 --- a/internal/provider/cursor_test.go +++ b/internal/provider/cursor_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/plugin" ) func TestParseCursorModels(t *testing.T) { @@ -197,3 +198,29 @@ func TestTokenExpiry(t *testing.T) { } } } + +// Cursor's plugin gives each model Cursor's 200K unless the name says 1M; +// a model known to hold less keeps its own there, as the built-in's list. +func TestCursorPluginContext(t *testing.T) { + pp := plugin.Provider{ID: "cursor", Models: []plugin.Model{ + {ID: "gpt-4o", Name: "GPT-4o", Context: 200_000}, + {ID: "kimi-k2-high", Name: "Kimi K2", Context: 200_000}, + {ID: "claude-opus-5-5-high", Name: "Claude Opus 5.5", Context: 1_000_000}, + {ID: "composer-2.5", Name: "Composer 2.5", Context: 200_000}, + }} + got := map[string]int{} + for _, m := range pluginCatalog(pp) { + got[m.ID] = m.Context + } + want := map[string]int{"gpt-4o": 128_000, "kimi-k2-high": 131_072, "claude-opus-5-5-high": 1_000_000, "composer-2.5": 200_000} + for id, n := range want { + if got[id] != n { + t.Errorf("%s: %d, want %d", id, got[id], n) + } + } + // another plugin's models are its own + pp.ID = "fakeco" + if c := pluginCatalog(pp)[0].Context; c != 200_000 { + t.Fatalf("fakeco gpt-4o: %d", c) + } +} diff --git a/internal/provider/migrate_movers_c_test.go b/internal/provider/migrate_movers_c_test.go index 69230ecd..bc71863b 100644 --- a/internal/provider/migrate_movers_c_test.go +++ b/internal/provider/migrate_movers_c_test.go @@ -182,11 +182,19 @@ func TestZCodeMover(t *testing.T) { own := ms["own@example.com"] var st map[string]any if !own.Own || own.Auth["access"] != "own.secret" || json.Unmarshal([]byte(str(own.Auth["refresh"])), &st) != nil || - st["site"] != "zai" || st["key"] != "own.secret" || st["device"] != zcodeDeviceMid() || st["base"] != ZCodeZaiBase { + st["site"] != "zai" || st["key"] != "own.secret" || st["device"] != zcodeDeviceMid() || st["base"] != ZCodeZaiBase || st["source"] != "zcode" { t.Fatalf("ZCode's own: %+v %+v", own, st) } + // marked as ZCode's, it comes back as ZCode's own, with nothing saved + var ownBack map[string]any + _ = json.Unmarshal([]byte(jsonText(own.Auth)), &ownBack) + before := len(readLogins()) + if ls, user, err := movers["zcode"].back(readLogins(), "own@example.com", ownBack); err != nil || len(ls) != before || user != "own@example.com" { + t.Fatalf("ZCode's own back: %v %q %+v", err, user, ls) + } tm := ms["team@x"] - if json.Unmarshal([]byte(str(tm.Auth["refresh"])), &st) != nil || st["site"] != "bigmodel" || st["org"] != "o1" || + st = nil + if json.Unmarshal([]byte(str(tm.Auth["refresh"])), &st) != nil || st["source"] != nil || st["site"] != "bigmodel" || st["org"] != "o1" || st["project"] != "p1" || st["token"] != "biz" || st["plan"] != "Team" || tm.Auth["expires"] != int64(0) { t.Fatalf("team@x: %+v %+v", tm.Auth, st) } diff --git a/internal/provider/migrate_zcode.go b/internal/provider/migrate_zcode.go index 7b14212f..0adc4f9d 100644 --- a/internal/provider/migrate_zcode.go +++ b/internal/provider/migrate_zcode.go @@ -12,9 +12,11 @@ import ( // the requests need as its refresh, as the plugin's own sign-in keeps // them. Nothing here rotates: a key lasts until it is deleted. // -// ZCode's own sign-in is kept encrypted, where the plugin can't read it, -// so it goes as a copy of what magpie reads now. A key or a team seat -// lasts, and the copy with it; ZCode's session token alone (the Start +// ZCode's own sign-in goes as a copy of what magpie reads now, marked as +// ZCode's (source "zcode"): the plugin from 0.1.2 reads ZCode's own +// credentials in its place for each request, as the built-in does, so it +// follows ZCode's switches; an older one uses the copy. A key or a team +// seat lasts, and the copy with it; ZCode's session token alone (the Start // Plan) is renewed by ZCode, and a copy would lapse behind it, so such an // account stops the move and the built-in carries on. func init() { @@ -29,13 +31,13 @@ func init() { return nil, errors.New("ZCode's own sign-in has no coding plan key, only ZCode's session, which the plugin can't follow") } // the own account's copy goes back to nothing: ZCode keeps it - out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.Lapsed != "", Own: l.Own, Auth: zcodeOut(l.User, l.Plan, k)}) + out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.Lapsed != "", Own: l.Own, Auth: zcodeOut(l.User, l.Plan, k, l.Own)}) } return out, nil }, back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) { var s struct { - Site, Device, Key, Base, JWT, Token, Org, Project, Plan string + Site, Device, Key, Base, JWT, Token, Org, Project, Plan, Source string } if json.Unmarshal([]byte(str(auth["refresh"])), &s) != nil { // a key signed in to in the plugin @@ -49,6 +51,10 @@ func init() { } else if s.Key == "" && s.JWT == "" { s.Key = str(auth["access"]) } + // ZCode's own goes back to nothing: ZCode keeps it + if _, _, ok := zcodeOwn(); ok && s.Source == "zcode" { + return ls, ownUser(ls, "zcode", firstNonEmpty(user, str(auth["accountId"]))), nil + } k := zcodeKey{Key: s.Key, Base: s.Base, JWT: s.JWT, Token: s.Token, Org: s.Org, Project: s.Project} if k.Base == "" { k.Base = ZCodeZaiBase @@ -76,13 +82,17 @@ func init() { } } -// zcodeOut is a ZCode account as the plugin's sign-in keeps one. -func zcodeOut(user, plan string, k zcodeKey) map[string]any { +// zcodeOut is a ZCode account as the plugin's sign-in keeps one; +// ZCode's own is marked as ZCode's, for the plugin to follow. +func zcodeOut(user, plan string, k zcodeKey, own bool) map[string]any { site := "zai" if strings.Contains(k.Base, "bigmodel.cn") { site = "bigmodel" } state := map[string]any{"site": site, "device": zcodeDeviceMid(), "base": k.Base} + if own { + state["source"] = "zcode" + } for name, v := range map[string]string{"key": k.Key, "jwt": k.JWT, "token": k.Token, "org": k.Org, "project": k.Project, "plan": plan} { if v != "" { state[name] = v diff --git a/internal/provider/plugin_usage_test.go b/internal/provider/plugin_usage_test.go index 22a58fa9..59e3feac 100644 --- a/internal/provider/plugin_usage_test.go +++ b/internal/provider/plugin_usage_test.go @@ -84,6 +84,20 @@ func TestPluginUsage(t *testing.T) { t.Fatalf("%d usage cards for the plugin's accounts, want 3", cards) } + // a model the plugin says the plan serves at no cost shows as free, + // as WorkBuddy's built-in marked its x0.00 models + free := map[string]bool{} + for _, pp := range plugin.Cached() { + if pp.ID == "fakeco" { + for _, m := range pluginCatalog(pp) { + free[m.ID] = m.Free + } + } + } + if !free["fake-1"] || free["fake-claude"] { + t.Fatalf("free models %v, want fake-1 alone", free) + } + // the gateway asks the allowance by the account's UsageAgent var agent string for _, p := range All() { diff --git a/internal/provider/plugins.go b/internal/provider/plugins.go index d768a4db..0ac298cc 100644 --- a/internal/provider/plugins.go +++ b/internal/provider/plugins.go @@ -133,11 +133,18 @@ func pluginCatalog(pp plugin.Provider) []catalog.Model { c := catalog.Model{ ID: m.ID, Name: m.Name, Provider: pp.ID, Released: m.Released, APIs: []string{string(pluginProtocol(pp.ID, m))}, Images: m.Image, - Context: m.Input, Output: m.Output, + Context: m.Input, Output: m.Output, Free: m.Free, } if c.Context == 0 { c.Context = m.Context } + // Cursor's own ids no catalog knows: one not named a 1M model + // holds what the catalog knows its base to, as the built-in's did + if pp.ID == "cursor" && c.Context <= cursorDefaultContext { + if n := cursorContext(m.ID, m.Name); c.Context == 0 || n < c.Context { + c.Context = n + } + } if c.Name == "" { c.Name = m.ID }