provider: moving Devin to its plugin no longer fails over the effort variants picked before Devin's families were one model — the move's check named every pick the plugin doesn't list as one it doesn't serve, and a Devin account picked from before the fold (swe-2-high, claude-opus-5-5-medium, claude-opus-5-5-low-fast…, which the editor shows folded into SWE-2 and Claude Opus 5.5) failed with nineteen of them, "Adaptive" twice. The plugin serves those as the built-in does: the plugin host sends a model it doesn't list under its own id, and the Devin plugin passes a variant's id to Devin as it is (variantFor), as it keeps a picked variant in its list. A mover may now say the plugin serves a pick it doesn't list (served): Devin's is a variant of a model the plugin lists (devinBase strips the tier and effort words: claude-opus-5-5-low-fast → claude-opus-5-5), and Adaptive and Fusion, which the built-in never served either (Devin's API doesn't take them). A variant of a model the plugin doesn't list still stops the move. The names an unserved move gives are each named once. TestDevinMoveKeepsVariants and TestMoveToPlugin's served-variant case fail without the change. No real move was run (it would move the live Devin account). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/provider/ ./internal/gui/ ./internal/plugin/ pass (provider flaked once under load, passes again).

This commit is contained in:
yetone committed 2026-10-02 15:29:24 +08:00
1 parent 901fd87ec0
commit 64922e1810
5 files changed
+88 -2

No files matched your search

+29
View File
@@ -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)
+29
View File
@@ -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)
}
}
}
+9 -2
View File
@@ -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
}
+11
View File
@@ -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() {
+10
View File
@@ -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