diff --git a/internal/plugin/host.js b/internal/plugin/host.js index aa5325b0..df4fdd00 100644 --- a/internal/plugin/host.js +++ b/internal/plugin/host.js @@ -875,11 +875,14 @@ const handlers = { usage, // check tries one account as a request would: its loader, then its // models as the plugin lists them for it - async check(p) { - const key = accountKey(p.provider, p.account) - await load({ provider: p.provider, account: key }) - const pi = await info(p.provider, key, true) - return { models: Object.keys(pi.models) } + // (through the account's proxy, as its requests go) + check(p) { + return via.run(p.proxy ?? "", async () => { + const key = accountKey(p.provider, p.account) + await load({ provider: p.provider, account: key }) + const pi = await info(p.provider, key, true) + return { models: Object.keys(pi.models) } + }) }, // import keeps a sign-in made elsewhere (a built-in subscription's, moved // onto its plugin) as one more account, or as the account it already is diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index 87238b16..fc4a3aa2 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -337,6 +337,7 @@ func TestPluginProxy(t *testing.T) { t.Setenv("http_proxy", globalSrv.URL) t.Setenv("FAKE_BASE", "http://vendor.invalid/v1") t.Setenv("FAKE_USAGE", "http://vendor.invalid/usage") + t.Setenv("FAKE_MODELS", "http://vendor.invalid/models") ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() abs, _ := filepath.Abs("testdata/fake/index.js") @@ -380,6 +381,10 @@ func TestPluginProxy(t *testing.T) { if u, err := AccountUsage(mine, "fakeco", ""); err != nil || u.Plan != "own" || last(&own) != "http://vendor.invalid/usage" { t.Fatalf("usage with its own proxy: %+v, %v", u, err) } + // a move's check of the account asks the vendor as its requests do + if ms, err := Check(mine, "fakeco", ""); err != nil || !slices.Contains(ms, "fake-own") || last(&own) != "http://vendor.invalid/models" { + t.Fatalf("a check with its own proxy: %v, %v", ms, err) + } // direct: vendor.invalid can't be reached but through a proxy mu.Lock() n := len(global) + len(own) diff --git a/internal/plugin/providers.go b/internal/plugin/providers.go index f443240e..3992bafe 100644 --- a/internal/plugin/providers.go +++ b/internal/plugin/providers.go @@ -494,7 +494,7 @@ func Check(ctx context.Context, provider, account string) ([]string, error) { var r struct { Models []string `json:"models"` } - if err := Call(ctx, "check", map[string]any{"provider": provider, "account": account}, &r); err != nil { + if err := Call(ctx, "check", map[string]any{"provider": provider, "account": account, "proxy": proxyOf(ctx)}, &r); err != nil { return nil, err } return r.Models, nil diff --git a/internal/plugin/testdata/fake/index.js b/internal/plugin/testdata/fake/index.js index 448351ab..bc412157 100644 --- a/internal/plugin/testdata/fake/index.js +++ b/internal/plugin/testdata/fake/index.js @@ -99,6 +99,11 @@ export const FakePlugin = async ({ client }) => ({ // 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"] } + // $FAKE_MODELS: the vendor's list, whose answer names one more model + if (process.env.FAKE_MODELS && auth) { + const r = await fetch(process.env.FAKE_MODELS).then((r) => r.text()).catch(() => "") + if (r) p.models["fake-" + r] = { ...p.models["fake-1"], id: "fake-" + r, name: r } + } return p.models }, }, diff --git a/internal/provider/migrate.go b/internal/provider/migrate.go index 5c3cf949..48de33f7 100644 --- a/internal/provider/migrate.go +++ b/internal/provider/migrate.go @@ -195,6 +195,17 @@ func Moved(id string) bool { return ok && m.State == MovePlugin } +// movedAgent is whether the logins.json agent's accounts are a moved +// built-in's, its plugin's now. +func movedAgent(agent string) bool { + for id, mv := range movers { + if slices.Contains(mv.agents, agent) && Moved(id) { + return true + } + } + return false +} + // OnPlugins are the built-ins moved onto their plugins. func OnPlugins() []string { var out []string @@ -391,7 +402,8 @@ func move(ctx context.Context, id string, mv *mover) (err error) { if a.Lapsed { continue } - models, err := plugin.Check(ctx, id, moved[i].Key) + // through the account's proxy, as its requests go + models, err := plugin.Check(ViaLogin(ctx, id, a.User), id, moved[i].Key) if err != nil { return fmt.Errorf("%s through the plugin: %w", a.User, err) } @@ -665,6 +677,12 @@ func MoveBack(ctx context.Context, id string) error { // the backup first, for back to write into the accounts it came from prep := func(ls []savedLogin, _ map[string]string) []savedLogin { for _, b := range m.Backup { + // the agent's own sign-in is one row: one written while moved + // (the agent signed in to another account meanwhile) gives + // way to the one set aside, which takes the agent's user anew + if b.own() { + ls = slices.DeleteFunc(ls, func(l savedLogin) bool { return l.Agent == b.Agent && l.own() && !sameMoved(l, b) }) + } if !slices.ContainsFunc(ls, func(l savedLogin) bool { return sameMoved(l, b) }) { ls = append(ls, b) } diff --git a/internal/provider/migrate_parity_test.go b/internal/provider/migrate_parity_test.go new file mode 100644 index 00000000..eb6418eb --- /dev/null +++ b/internal/provider/migrate_parity_test.go @@ -0,0 +1,71 @@ +package provider + +import ( + "os" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/plugin" +) + +// A built-in moved onto its plugin offers the reasoning levels it did for +// a model its vendor lists none for (Command Code's, Grok's, WorkBuddy's): +// its maker's, as the built-in borrowed them; a plugin of its own gives +// what it says. +func TestMovedKeepsEfforts(t *testing.T) { + movedPlugin(t, "grok", map[string]map[string]any{}) + os.MkdirAll(filepath.Dir(catalog.CachePath()), 0o755) + os.WriteFile(catalog.CachePath(), []byte(`{"xai":{"id":"xai","models":{"grok-4.7":{"id":"grok-4.7", + "reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh"]}]}}}}`), 0o644) + catalog.Reset() + t.Cleanup(catalog.Reset) + id := "grok-4.7" + if len(borrowedEfforts(id)) == 0 { + t.Fatal("the catalog written gives grok-4.7 no efforts") + } + pp := plugin.Provider{ID: "grok", Spec: "opencode-grok-auth", Models: []plugin.Model{{ID: id, Reasoning: true}}} + if got := pluginCatalog(pp)[0].Efforts; !slices.Equal(got, borrowedEfforts(id)) { + t.Fatalf("moved: %s's efforts %v, want %v", id, got, borrowedEfforts(id)) + } + pp.ID = "someplugin" + if got := pluginCatalog(pp)[0].Efforts; len(got) != 0 { + t.Fatalf("a plugin's own provider borrowed %v", got) + } +} + +// Moved, the agent's own sign-in is the plugin's: the agent signing in to +// another account meanwhile writes no row of the built-in's, which came +// back beside the one set aside as a second own account. +func TestMovedOwnRowNotWritten(t *testing.T) { + movedPlugin(t, "grok", map[string]map[string]any{}) + sideLogins("grok", "new@x", func(savedLogin) bool { return true }) + for _, l := range readLogins() { + if l.Agent == "grok" { + t.Fatalf("a grok row written while moved: %+v", l) + } + } +} + +// Devin's own account moves with the plan the Devin CLI says, as the +// built-in showed it, though its saved row has none. +func TestDevinOwnMovesWithPlan(t *testing.T) { + home := claudeHome(t) + data := filepath.Join(home, "data") + t.Setenv("XDG_DATA_HOME", data) + os.MkdirAll(filepath.Join(data, "devin"), 0o700) + if err := os.WriteFile(filepath.Join(data, "devin", "credentials.toml"), devinCredentials("cog_own_key_1234", "", "", ""), 0o600); err != nil { + t.Fatal(err) + } + devinStatus.Lock() + devinStatus.user, devinStatus.plan, devinStatus.ok, devinStatus.at = "dev@example.com", "Devin Pro", true, time.Now() + devinStatus.Unlock() + t.Cleanup(forgetDevinStatus) + own := movingOf(t, "devin")["dev@example.com"] + md, _ := own.Auth["metadata"].(map[string]any) + if !own.Own || own.Plan != "Devin Pro" || md["plan"] != "Devin Pro" { + t.Fatalf("Devin's own: %+v", own) + } +} diff --git a/internal/provider/migrate_side.go b/internal/provider/migrate_side.go index 056ad28f..cb578224 100644 --- a/internal/provider/migrate_side.go +++ b/internal/provider/migrate_side.go @@ -26,9 +26,17 @@ func init() { if err != nil { continue } + // the CLI's own account has its plan from the CLI, as the + // built-in shows it, not saved on its row + plan := l.Plan + if l.Home == "" { + if _, p, ok := devinIdentity(); ok && p != "" { + plan = p + } + } md := map[string]any{"email": l.User} - if l.Plan != "" { - md["plan"] = l.Plan + if plan != "" { + md["plan"] = plan } if s := strings.TrimRight(server, "/"); s != "" && s != devinServer { md["server"] = s @@ -36,7 +44,7 @@ func init() { if l.Home == "" { md["cli"] = true // the plugin reads the CLI's key again, as the built-in does } - out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.Lapsed != "", Plan: l.Plan, Own: l.Home == "", + out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.Lapsed != "", Plan: plan, Own: l.Home == "", Auth: map[string]any{"type": "api", "key": key, "metadata": md}}) } return out, nil diff --git a/internal/provider/plugins.go b/internal/provider/plugins.go index 6891d247..6dfb9a12 100644 --- a/internal/provider/plugins.go +++ b/internal/provider/plugins.go @@ -152,6 +152,11 @@ func pluginCatalog(pp plugin.Provider) []catalog.Model { if m.Reasoning { c.Efforts = m.Variants } + // a built-in moved onto its plugin keeps the levels it had for a + // model its vendor gives none: its maker's, as effortsOf borrows + if len(c.Efforts) == 0 && Moved(pp.ID) { + c.Efforts = borrowedEfforts(m.ID) + } // OpenCode's price of a model it has none for is 0, as the // plugins give a plan's models: a price is only one above it if m.Cost != nil && (m.Cost.Input > 0 || m.Cost.Output > 0) { diff --git a/internal/provider/side_logins.go b/internal/provider/side_logins.go index 13c26810..f6f48ce0 100644 --- a/internal/provider/side_logins.go +++ b/internal/provider/side_logins.go @@ -35,7 +35,9 @@ func sideLogins(agent, ownUser string, usable func(savedLogin) bool) []sideLogin loginsMu.Lock() defer loginsMu.Unlock() ls := readLogins() - if ownUser != "" { + // moved onto its plugin, the agent's own sign-in is the plugin's: its + // row, set aside, is kept for going back + if ownUser != "" && !movedAgent(agent) { found := false for i := range ls { if ls[i].Agent == agent && ls[i].own() {