migrate: a moved built-in keeps its models' reasoning levels, its own account's plan and one own row, and the move checks each account through its proxy (要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样) — Command Code's, Grok's and WorkBuddy's models their vendor gives no levels for lost the ones the built-in borrowed from their maker; moved, they borrow them again. Devin's own account moved without the plan the Devin CLI says, which the built-in showed. The agent signing in to another account while moved wrote a new own row for the built-in, which came back beside the one set aside as a second own account; nothing is written while moved, and going back keeps the one. And the move's check of an account asked the vendor directly, not through the proxy its requests take. Found walking Command Code, Grok, Devin, ZCode and WorkBuddy through sandbox moves. TestMovedKeepsEfforts, TestMovedOwnRowNotWritten, TestDevinOwnMovesWithPlan and TestPluginProxy fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.

This commit is contained in:
yetone committed 2026-10-01 07:04:40 +08:00
1 parent 936a6fc710
commit 813fdcb2e5
9 files changed
+128 -11

No files matched your search

+8 -5
View File
@@ -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
+5
View File
@@ -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)
+1 -1
View File
@@ -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
+5
View File
@@ -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
},
},
+19 -1
View File
@@ -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)
}
+71
View File
@@ -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)
}
}
+11 -3
View File
@@ -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
+5
View File
@@ -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) {
+3 -1
View File
@@ -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() {