diff --git a/internal/provider/devin.go b/internal/provider/devin.go index 1f3ebe56..c60bb3d7 100644 --- a/internal/provider/devin.go +++ b/internal/provider/devin.go @@ -235,14 +235,16 @@ func devinModelsFlatten(families []DevinFamily) []catalog.Model { if most == 0 { most = catalog.OutputOf(f.UID) } - out = append(out, catalog.Model{ID: f.UID, Name: f.Label, Provider: "devin", Context: window, Output: most}) + // Devin's word on images, as its variants all say it (#417) + images := f.images() + out = append(out, catalog.Model{ID: f.UID, Name: f.Label, Provider: "devin", Context: window, Output: most, ImageInput: images}) // the family's fast run, one model for its fast variants as the // family's id is for the rest; not an id of Devin's, the gateway // asks for the variant at the effort (DevinVariant). One a family // of that name already is (swe-1.6-fast) isn't made up. for _, t := range f.tiers() { if id := f.UID + "-" + t; !slices.ContainsFunc(families, func(g DevinFamily) bool { return g.named(id) }) { - out = append(out, catalog.Model{ID: id, Name: f.Label + " " + devinTierLabel(t), Provider: "devin", Context: window, Output: most}) + out = append(out, catalog.Model{ID: id, Name: f.Label + " " + devinTierLabel(t), Provider: "devin", Context: window, Output: most, ImageInput: images}) } } for _, m := range f.Models { @@ -313,6 +315,30 @@ func devinDeclared() map[string][2]int { return out } +// devinDeclaredImages is what Devin said of each model id's images, as +// devinModelsFlatten gives it, for a list saved before Devin was asked. +func devinDeclaredImages() map[string]*bool { + out := map[string]*bool{} + for _, f := range devinCached() { + if v := f.images(); v != nil { + for _, id := range append([]string{f.UID}, f.Aliases...) { + out[id] = v + } + for _, t := range f.tiers() { + if _, ok := out[f.UID+"-"+t]; !ok { + out[f.UID+"-"+t] = v + } + } + } + for _, m := range f.Models { + if m.ImageInput != nil { + out[m.ID] = m.ImageInput + } + } + } + return out +} + var devinEffort = regexp.MustCompile(`-(none|min|minimal|low|medium|high|xhigh|max|fast|priority)$`) // devinKnown is models.dev's window and reply cap for a model id, with the @@ -338,8 +364,12 @@ func devinKnown(id string) (window, most int) { // has claude-opus-5-5's). func withDevinContexts(ms []catalog.Model) []catalog.Model { declared := devinDeclared() + images := devinDeclaredImages() out := slices.Clone(ms) for i, m := range out { + if m.ImageInput == nil { + out[i].ImageInput = images[m.ID] + } // Devin's own numbers, over what an older magpie took from models.dev window, most := declared[m.ID][0], declared[m.ID][1] if window > 0 { @@ -520,7 +550,8 @@ func askDevinFamiliesAt(ctx context.Context, home string) ([]DevinFamily, error) if len(families) == 0 { return nil, errors.New("devin models list: no models") } - return families, nil + // which take images, which the list doesn't say + return withDevinImages(families, devinImagesAt(ctx, home)), nil } // parseDevinModels reads `devin models list --format json`: diff --git a/internal/provider/devin_images.go b/internal/provider/devin_images.go new file mode 100644 index 00000000..2b8a0047 --- /dev/null +++ b/internal/provider/devin_images.go @@ -0,0 +1,173 @@ +package provider + +// Which of Devin's models take images, as Devin tells its CLI: `devin models +// list` leaves it out, and models.dev doesn't know Devin's own ids (swe-2, +// gpt-6-1-sol), so agents were told a model that takes images doesn't and +// dropped them (#417). The CLI's picker reads GetCliModelConfigs, whose +// ClientModelConfig gives each model's supports_images. + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "net/http" + "runtime" + "time" +) + +var devinImagesClient = &http.Client{Timeout: 15 * time.Second} + +const devinModelConfigsRPC = "/exa.api_server_pb.ApiServerService/GetCliModelConfigs" + +// devinImagesAt is whether each of Devin's model ids takes images, for the +// account signed in in home ("" for the CLI's own); nil when Devin can't be +// asked. A var so tests can stand in for it. +var devinImagesAt = func(ctx context.Context, home string) map[string]bool { + key, server, err := DevinAuthAt(home) + if err != nil { + return nil + } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + // the metadata the gateway's chats send (gateway/devin.go), which the + // CLI's own requests carry: with the CLI's ide and extension names + // alone Devin lists one model + meta := devinPB{}.str(1, "devin-cli").str(2, devinUsageVersion).str(3, key).str(4, "en"). + str(5, runtime.GOOS).str(7, devinUsageVersion).str(12, "chisel").str(28, "chisel") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server+devinModelConfigsRPC, bytes.NewReader(devinPB{}.bytes(1, meta))) + if err != nil { + return nil + } + req.Header.Set("Content-Type", "application/proto") + req.Header.Set("Connect-Protocol-Version", "1") + res, err := devinImagesClient.Do(req) + if err != nil { + return nil + } + defer res.Body.Close() + b, _ := io.ReadAll(io.LimitReader(res.Body, 8<<20)) + if res.StatusCode != http.StatusOK { + return nil + } + return parseDevinModelConfigs(b) +} + +// parseDevinModelConfigs reads GetCliModelConfigsResponse: its +// client_model_configs (1), each a ClientModelConfig with its model_uid (22) +// and supports_images (5), a bool proto3 leaves out when false — as Devin +// leaves it out for GLM, DeepSeek, Inkling and Nemotron. +func parseDevinModelConfigs(b []byte) map[string]bool { + out := map[string]bool{} + for _, f := range devinFields(b) { + if f.num != 1 || f.wire != 2 { + continue + } + uid, images := "", false + for _, g := range devinFields(f.data) { + switch { + case g.num == 22 && g.wire == 2: + uid = string(g.data) + case g.num == 5 && g.wire == 0: + images = g.n != 0 + } + } + if uid != "" { + out[uid] = images + } + } + if len(out) == 0 { + return nil + } + return out +} + +// withDevinImages is families with what Devin said of each variant's images +// on it; a variant Devin didn't name is left unsaid. +func withDevinImages(families []DevinFamily, images map[string]bool) []DevinFamily { + if len(images) == 0 { + return families + } + for i := range families { + for j, m := range families[i].Models { + if v, ok := images[m.ID]; ok { + families[i].Models[j].ImageInput = &v + } + } + } + return families +} + +// images is whether the family takes images, as its variants Devin named +// all say; nil when they say different things or none was named. +func (f DevinFamily) images() *bool { + var said *bool + for _, m := range f.Models { + if m.ImageInput == nil { + continue + } + if said != nil && *said != *m.ImageInput { + return nil + } + v := *m.ImageInput + said = &v + } + return said +} + +// devinPB is a protobuf message being written. +type devinPB []byte + +func (b devinPB) bytes(num int, v []byte) devinPB { + b = binary.AppendUvarint(b, uint64(num<<3|2)) + return append(binary.AppendUvarint(b, uint64(len(v))), v...) +} + +func (b devinPB) str(num int, v string) devinPB { return b.bytes(num, []byte(v)) } + +type devinField struct { + num, wire int + n uint64 + data []byte +} + +// devinFields reads a message's fields; a malformed tail is dropped. +func devinFields(b []byte) []devinField { + var out []devinField + for len(b) > 0 { + key, k := binary.Uvarint(b) + if k <= 0 { + break + } + b = b[k:] + f := devinField{num: int(key >> 3), wire: int(key & 7)} + switch f.wire { + case 0: + v, k := binary.Uvarint(b) + if k <= 0 { + return out + } + f.n, b = v, b[k:] + case 1: + if len(b) < 8 { + return out + } + b = b[8:] + case 2: + l, k := binary.Uvarint(b) + if k <= 0 || uint64(len(b)-k) < l { + return out + } + f.data, b = b[k:k+int(l)], b[k+int(l):] + case 5: + if len(b) < 4 { + return out + } + b = b[4:] + default: + return out + } + out = append(out, f) + } + return out +} diff --git a/internal/provider/devin_images_test.go b/internal/provider/devin_images_test.go new file mode 100644 index 00000000..b4b2dccb --- /dev/null +++ b/internal/provider/devin_images_test.go @@ -0,0 +1,105 @@ +package provider + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/settings" +) + +// Devin's own ids models.dev doesn't know (swe-2, gpt-6-1-sol) take images, +// as Devin tells its CLI in GetCliModelConfigs; `devin models list` says +// nothing of images, so they were served as text-only and agents dropped +// the images (#417). A model Devin says takes none (GLM) is text-only +// whatever models.dev says, and one Devin doesn't name is left to it. +func TestDevinImagesFromModelConfigs(t *testing.T) { + home := claudeHome(t) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, "data")) + creds := DevinCredentialsPath() + os.MkdirAll(filepath.Dir(creds), 0o700) + os.WriteFile(creds, []byte("windsurf_api_key = \"devin-session-token$k\"\n"), 0o600) + + // a ClientModelConfig: model_uid (22), and supports_images (5) when true + config := func(uid string, images bool) []byte { + var b []byte + b = binary.AppendUvarint(b, 22<<3|2) + b = binary.AppendUvarint(b, uint64(len(uid))) + b = append(b, uid...) + if images { + b = append(b, 5<<3, 1) + } + return b + } + var asked []byte + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/exa.api_server_pb.ApiServerService/GetCliModelConfigs" || r.Header.Get("Content-Type") != "application/proto" { + http.Error(w, "no", 404) + return + } + asked, _ = io.ReadAll(r.Body) + var out []byte + for _, c := range [][]byte{config("swe-2-high", true), config("swe-2-medium", true), config("glm-5-2", false), config("glm-5-2-max", false)} { + out = binary.AppendUvarint(out, 1<<3|2) + out = binary.AppendUvarint(out, uint64(len(c))) + out = append(out, c...) + } + w.Header().Set("Content-Type", "application/proto") + w.Write(out) + })) + defer fake.Close() + t.Setenv("WINDSURF_API_SERVER_URL", fake.URL) + + exe := filepath.Join(home, "devin") + os.WriteFile(exe, []byte(`#!/bin/sh +cat <<'X' +{"families":[ + {"family_label":"SWE-2","family_uid":"swe-2","aliases":["swe"],"variants":[ + {"model_uid":"swe-2-high","label":"SWE-2 High"},{"model_uid":"swe-2-medium","label":"SWE-2 Medium"}]}, + {"family_label":"GLM-5.2","family_uid":"glm-5.2","variants":[ + {"model_uid":"glm-5-2","label":"GLM-5.2 High"},{"model_uid":"glm-5-2-max","label":"GLM-5.2 Max"}]}, + {"family_label":"New","family_uid":"new-model","variants":[{"model_uid":"new-model-high","label":"New High"}]}]} +X +`), 0o755) + fakeDevin(t, exe) + + families, err := askDevinFamiliesAt(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(asked, []byte("devin-session-token$k")) || !bytes.Contains(asked, []byte("chisel")) { + t.Fatalf("GetCliModelConfigs was asked with %q", asked) + } + images := map[string]Entry{} + for _, m := range devinModels(families) { + images[m.ID] = entryFor(Provider{ID: "devin"}, m, settings.Settings{}) + } + for id, want := range map[string]bool{"swe-2": true, "glm-5.2": false} { + e, ok := images[id] + if !ok { + t.Fatalf("%s isn't listed: %v", id, images) + } + if e.Images != want || e.ImageInput == nil || *e.ImageInput != want { + t.Errorf("%s: images %v (said %v), want %v", id, e.Images, e.ImageInput, want) + } + } + if e := images["new-model"]; e.ImageInput != nil { + t.Errorf("a model Devin didn't name was said to take images: %v", *e.ImageInput) + } + + // a list saved before Devin was asked takes what it said + devinFamiliesCached(families) + t.Cleanup(func() { devinFamiliesCached(nil) }) + for _, m := range withDevinContexts([]catalog.Model{{ID: "swe-2", Provider: "devin"}, {ID: "swe", Provider: "devin"}}) { + if m.ImageInput == nil || !*m.ImageInput { + t.Errorf("saved %s: images %v", m.ID, m.ImageInput) + } + } +}