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