devin: whether a Devin model takes images is what Devin tells its CLI, for models models.dev doesn't know (#417, HwFee: swe-2 and gpt-6-1-sol were said to take no images) — devin models list says nothing of images and models.dev has no entry for Devin's own ids, so swe-2, gpt-6-1-sol and the like were served as text-only and agents dropped the images. When it reads the list, magpie now also asks Devin's GetCliModelConfigs, the read-only call the CLI's own model picker makes, with the same metadata magpie's chats already send. Each ClientModelConfig's supports_images is put on its variant, and on the family and its tiers when all their variants agree: swe-2, gpt-6-1-sol, claude-sonnet-5-5 and kimi-k3 take images, GLM and DeepSeek don't. A model Devin doesn't name, or a failed call, is left to models.dev as before; a list saved earlier takes what Devin said, and a user's own image setting still wins. No prompt, header, user agent or existing request changes. TestDevinImagesFromModelConfigs fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestUserPath and the Claude account tests flaked and pass alone).

This commit is contained in:
yetone committed 2026-10-01 19:38:36 +08:00
1 parent 47fd3bcb97
commit 52082f916f
3 files changed
+312 -3

No files matched your search

+34 -3
View File
@@ -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`:
+173
View File
@@ -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
}
+105
View File
@@ -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)
}
}
}