From 64922e18100b43c25eaf9a8400c4031c3a1dc45c Mon Sep 17 00:00:00 2001 From: yetone Date: Fri, 2 Oct 2026 15:29:24 +0800 Subject: [PATCH] =?UTF-8?q?provider:=20moving=20Devin=20to=20its=20plugin?= =?UTF-8?q?=20no=20longer=20fails=20over=20the=20effort=20variants=20picke?= =?UTF-8?q?d=20before=20Devin's=20families=20were=20one=20model=20?= =?UTF-8?q?=E2=80=94=20the=20move's=20check=20named=20every=20pick=20the?= =?UTF-8?q?=20plugin=20doesn't=20list=20as=20one=20it=20doesn't=20serve,?= =?UTF-8?q?=20and=20a=20Devin=20account=20picked=20from=20before=20the=20f?= =?UTF-8?q?old=20(swe-2-high,=20claude-opus-5-5-medium,=20claude-opus-5-5-?= =?UTF-8?q?low-fast=E2=80=A6,=20which=20the=20editor=20shows=20folded=20in?= =?UTF-8?q?to=20SWE-2=20and=20Claude=20Opus=205.5)=20failed=20with=20ninet?= =?UTF-8?q?een=20of=20them,=20"Adaptive"=20twice.=20The=20plugin=20serves?= =?UTF-8?q?=20those=20as=20the=20built-in=20does:=20the=20plugin=20host=20?= =?UTF-8?q?sends=20a=20model=20it=20doesn't=20list=20under=20its=20own=20i?= =?UTF-8?q?d,=20and=20the=20Devin=20plugin=20passes=20a=20variant's=20id?= =?UTF-8?q?=20to=20Devin=20as=20it=20is=20(variantFor),=20as=20it=20keeps?= =?UTF-8?q?=20a=20picked=20variant=20in=20its=20list.=20A=20mover=20may=20?= =?UTF-8?q?now=20say=20the=20plugin=20serves=20a=20pick=20it=20doesn't=20l?= =?UTF-8?q?ist=20(served):=20Devin's=20is=20a=20variant=20of=20a=20model?= =?UTF-8?q?=20the=20plugin=20lists=20(devinBase=20strips=20the=20tier=20an?= =?UTF-8?q?d=20effort=20words:=20claude-opus-5-5-low-fast=20=E2=86=92=20cl?= =?UTF-8?q?aude-opus-5-5),=20and=20Adaptive=20and=20Fusion,=20which=20the?= =?UTF-8?q?=20built-in=20never=20served=20either=20(Devin's=20API=20doesn'?= =?UTF-8?q?t=20take=20them).=20A=20variant=20of=20a=20model=20the=20plugin?= =?UTF-8?q?=20doesn't=20list=20still=20stops=20the=20move.=20The=20names?= =?UTF-8?q?=20an=20unserved=20move=20gives=20are=20each=20named=20once.=20?= =?UTF-8?q?TestDevinMoveKeepsVariants=20and=20TestMoveToPlugin's=20served-?= =?UTF-8?q?variant=20case=20fail=20without=20the=20change.=20No=20real=20m?= =?UTF-8?q?ove=20was=20run=20(it=20would=20move=20the=20live=20Devin=20acc?= =?UTF-8?q?ount).=20go=20vet=20./internal/...,=20go=20build=20-tags=20nogu?= =?UTF-8?q?i=20(darwin,=20GOOS=3Dlinux),=20GOOS=3Dwindows=20go=20build,=20?= =?UTF-8?q?go=20test=20-tags=20nogui=20./internal/provider/=20./internal/g?= =?UTF-8?q?ui/=20./internal/plugin/=20pass=20(provider=20flaked=20once=20u?= =?UTF-8?q?nder=20load,=20passes=20again).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/devin.go | 29 ++++++++++++++++++++++++++ internal/provider/devin_models_test.go | 29 ++++++++++++++++++++++++++ internal/provider/migrate.go | 11 ++++++++-- internal/provider/migrate_side.go | 11 ++++++++++ internal/provider/migrate_test.go | 10 +++++++++ 5 files changed, 88 insertions(+), 2 deletions(-) diff --git a/internal/provider/devin.go b/internal/provider/devin.go index c60bb3d7..d6fafe23 100644 --- a/internal/provider/devin.go +++ b/internal/provider/devin.go @@ -901,6 +901,35 @@ func devinTierOf(id string) (tier, level string) { return "", devinLevel(id) } +// devinBase is the model a Devin variant id is a variant of: the id less +// its tier and effort words (claude-opus-5-5-low-fast is claude-opus-5-5, +// swe-2-high swe-2); an id at no effort is itself. +func devinBase(id string) string { + tier, level := devinTierOf(id) + if level == "" { + return id + } + low := strings.ToLower(id) + cut := func(word string) bool { + for _, sep := range []string{"-", "_"} { + if strings.HasSuffix(low, sep+word) { + low, id = low[:len(low)-len(sep+word)], id[:len(low)-len(sep+word)] + return true + } + } + return false + } + if tier != "" { + cut(tier) + } + for _, l := range devinLevels { + if l.level == level && cut(l.word) { + break + } + } + return id +} + // devinEffortOf is the effort a Devin id runs at, in a tier or not. func devinEffortOf(id string) string { _, l := devinTierOf(id) diff --git a/internal/provider/devin_models_test.go b/internal/provider/devin_models_test.go index 8aef8305..44569f5a 100644 --- a/internal/provider/devin_models_test.go +++ b/internal/provider/devin_models_test.go @@ -204,3 +204,32 @@ func TestDevinTiers(t *testing.T) { t.Errorf("offered to the agent: %s", got) } } + +// The built-in's Devin picks from before the families were one model +// (swe-2-high, claude-opus-5-5-low-fast) are variants of a model the plugin +// lists, which it passes to Devin as they are: moving loses none of them, +// nor Adaptive, which the built-in never served. A variant of a model the +// plugin doesn't list, and a model of its own, are lost (moving Devin named every +// level the user had picked was named as one the plugin doesn't serve). +func TestDevinMoveKeepsVariants(t *testing.T) { + for id, want := range map[string]string{ + "swe-2-high": "swe-2", "claude-opus-5-5-low-fast": "claude-opus-5-5", "swe-1-7-lightning-medium": "swe-1-7-lightning", + "GPT-6-Sol_HIGH": "GPT-6-Sol", "claude-fable-5-1-xhigh": "claude-fable-5-1", "swe-1-6-fast": "swe-1-6-fast", "glm-5-2-1m": "glm-5-2-1m", "swe-2": "swe-2", + } { + if got := devinBase(id); got != want { + t.Errorf("devinBase(%q) = %q, want %q", id, got, want) + } + } + served := movers["devin"].served + listed := []string{"swe-2", "claude-opus-5-5", "claude-opus-5-5-fast", "swe-1-7-lightning"} + for _, m := range []string{"swe-2-high", "swe-2-max", "claude-opus-5-5-medium-fast", "swe-1-7-lightning-medium", "Adaptive", "adaptive"} { + if !served(m, listed) { + t.Errorf("moving loses %s", m) + } + } + for _, m := range []string{"claude-fable-5-1-high", "gpt-6-luna", "swe-2"} { + if served(m, listed) { + t.Errorf("%s counts as served", m) + } + } +} diff --git a/internal/provider/migrate.go b/internal/provider/migrate.go index 72586999..1d553d6f 100644 --- a/internal/provider/migrate.go +++ b/internal/provider/migrate.go @@ -219,6 +219,9 @@ type mover struct { // what else of the built-in's they carry (a key onto its provider), // whose saving syncs the agents, which read the accounts. settle func(auths map[string]map[string]any) error + // served is whether the plugin, listing listed, still serves model, one + // of the built-in's picks it doesn't list; nil for none. + served func(model string, listed []string) bool } var movers = map[string]*mover{} @@ -548,7 +551,9 @@ func move(ctx context.Context, id string, mv *mover) (err error) { return &moveError{MoveWhy{Code: "account", Args: map[string]string{"user": a.User, "error": err.Error()}}, fmt.Sprintf("%s doesn't work through the plugin: %s", a.User, err), err} } if !tried { - if missing := slices.DeleteFunc(slices.Clone(inUse), func(m string) bool { return slices.Contains(c.Models, m) }); len(missing) > 0 { + if missing := slices.DeleteFunc(slices.Clone(inUse), func(m string) bool { + return slices.Contains(c.Models, m) || mv.served != nil && mv.served(m, c.Models) + }); len(missing) > 0 { names := strings.Join(modelNames(id, missing), ", ") return &moveError{MoveWhy{Code: "unserved", Args: map[string]string{"models": names}}, fmt.Sprintf("the plugin doesn't serve %s. Untick them under Models, or keep the built-in.", names), nil} } @@ -588,7 +593,9 @@ func modelNames(id string, ids []string) []string { if names[m] != "" { m = names[m] } - out = append(out, m) + if !slices.Contains(out, m) { + out = append(out, m) + } } return out } diff --git a/internal/provider/migrate_side.go b/internal/provider/migrate_side.go index 8f1d0f85..b0971ea7 100644 --- a/internal/provider/migrate_side.go +++ b/internal/provider/migrate_side.go @@ -10,6 +10,7 @@ import ( "errors" "os" "path/filepath" + "slices" "strings" "time" ) @@ -19,6 +20,16 @@ func init() { pkg: "@magpie-community/opencode-devin-auth", min: "0.1.5", // a failure's status and its sign-in mark as the built-in's agents: []string{"devin"}, + // a variant picked before the families were one model (swe-2-high) + // goes to Devin as it is, through the plugin too, which keeps the + // picks it is given; Adaptive and Fusion the built-in never served + served: func(model string, listed []string) bool { + if id := strings.ToLower(model); id == "adaptive" || id == "fusion" { + return true + } + base := devinBase(model) + return base != model && slices.Contains(listed, base) + }, out: func() ([]Moving, error) { var out []Moving for _, l := range devinLogins() { diff --git a/internal/provider/migrate_test.go b/internal/provider/migrate_test.go index 1510acd7..b3eb7c50 100644 --- a/internal/provider/migrate_test.go +++ b/internal/provider/migrate_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" "time" @@ -150,6 +151,15 @@ func TestMoveToPlugin(t *testing.T) { if len(fakeSaved(t)) != 1 || plugin.SignedIn("fakeco") { t.Fatal("a move short of a model moved") } + // one the mover says the plugin serves though it doesn't list it (a + // Devin variant) isn't named, nor is a name twice + movers["fakeco"].served = func(m string, listed []string) bool { return m == "fake-1-high" && slices.Contains(listed, "fake-1") } + reset(fakeLogin("a@fake", "r-a", false, true)) + inUse = []string{"fake-1", "fake-1-high", "gone-model", "gone-model"} + if err := Move(ctx, "fakeco"); err == nil || strings.Contains(err.Error(), "fake-1-high") || strings.Count(err.Error(), "gone-model") != 1 { + t.Fatalf("Move with a served variant and a model the plugin lacks = %v", err) + } + movers["fakeco"].served = nil inUse = []string{"fake-1", "fake-claude"} // an account signed in through the plugin already gets back what it had