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:
3 files changed
+312
-3
No files matched your search
@@ -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`:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user