Files
magpie/internal/provider/devin.go
T

419 lines
13 KiB
Go

package provider
// A Devin subscription is served through the API the devin CLI talks to
// (gateway/devin.go), with the key the CLI signed in with; here is who that
// account is, the models it offers, and the sign-in, which is `devin auth
// login`'s.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
toml "github.com/pelletier/go-toml/v2"
"github.com/yetone/magpie/internal/catalog"
)
// DevinExecutable finds the devin CLI; a var so tests can fake it.
var DevinExecutable = func() string {
if p, err := exec.LookPath("devin"); err == nil {
return p
}
home, _ := os.UserHomeDir()
for _, p := range []string{filepath.Join(home, ".local", "bin", "devin"), "/usr/local/bin/devin", "/opt/homebrew/bin/devin"} {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
return ""
}
// DevinCredentialsPath is where the CLI keeps its sign-in.
func DevinCredentialsPath() string {
if runtime.GOOS == "windows" {
if app := os.Getenv("APPDATA"); app != "" {
return filepath.Join(app, "devin", "credentials.toml")
}
}
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, _ := os.UserHomeDir()
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "devin", "credentials.toml")
}
var devinStatus = &cliIdentity{name: "devin", exe: func() string { return DevinExecutable() }, ask: func() (string, string, bool) { return askDevinIdentity() }}
// devinIdentity is who Devin's CLI says is signed in; see cliIdentity.
func devinIdentity() (user, plan string, ok bool) { return devinStatus.get() }
func forgetDevinStatus() { devinStatus.forget() }
func askDevinIdentity() (user, plan string, ok bool) {
path := DevinExecutable()
if path == "" {
return "", "", false
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := agentCommand(ctx, path, "auth", "status").Output()
if err != nil {
return "", "", false
}
return parseDevinStatus(string(out))
}
// parseDevinStatus reads `devin auth status`'s report:
//
// Logged in (via Devin).
// ...
// User:
// Name: <handle>
// Email: <email>
// Account:
// Tier: Devin Pro
// Plan: Pro
func parseDevinStatus(out string) (user, plan string, ok bool) {
if !strings.Contains(out, "Logged in") {
return "", "", false
}
field := func(key string) string {
for _, l := range strings.Split(out, "\n") {
l = strings.TrimSpace(l)
if v, found := strings.CutPrefix(l, key+":"); found {
return strings.TrimSpace(v)
}
}
return ""
}
user = field("Email")
if user == "" {
user = field("Name")
}
plan = field("Tier")
if plan == "" {
plan = field("Plan")
}
return user, plan, true
}
func devinAccount() (Provider, bool) {
user, plan, ok := devinIdentity()
if !ok {
return Provider{}, false
}
acct := &Account{Agent: "devin", User: user, Plan: plan}
acct.models = func() []catalog.Model {
// what a fetch or a picker visit last asked the CLI — never spawn
// one here: Available() runs on every gateway request
devinFamiliesCache.Lock()
families := devinFamiliesCache.families
devinFamiliesCache.Unlock()
return devinModelsFlatten(families)
}
acct.fetch = func(ctx context.Context) ([]catalog.Model, error) {
families, err := askDevinFamilies(ctx)
if err != nil {
return nil, err
}
ms := devinModelsFlatten(families)
devinFamiliesCached(families)
return ms, catalog.SaveLive("devin", "", ms)
}
return Provider{ID: "devin", Name: "Devin", Icon: "devin", Website: "https://devin.ai", Account: acct}, true
}
// DevinFamily is one entry of `devin models list`: a name that follows the
// family's newest model, and the pinned variants under it.
type DevinFamily struct {
UID string
Label string
Aliases []string
Models []catalog.Model
}
// devinModelsFlatten is the family list as a flat catalog: the family's
// follow-newest id, then each of its variants. Adaptive and Fusion, which
// route between models inside Devin's own agent, aren't served by the API.
func devinModelsFlatten(families []DevinFamily) []catalog.Model {
var out []catalog.Model
for _, f := range families {
if id := strings.ToLower(f.UID); id == "adaptive" || id == "fusion" {
continue
}
// a pinned variant (claude-opus-5-5-high) has its family's window:
// without it Claude Code takes a 1M model for 200K (no [1m] mark)
window, most := catalog.ContextOf(f.UID), catalog.OutputOf(f.UID)
out = append(out, catalog.Model{ID: f.UID, Name: f.Label, Provider: "devin", Context: window, Output: most})
for _, m := range f.Models {
if m.Context == 0 {
m.Context = window
}
if m.Output == 0 {
m.Output = most
}
out = append(out, m)
}
}
return out
}
var devinEffort = regexp.MustCompile(`-(min|low|medium|high|xhigh|max|fast)$`)
// withDevinContexts fills in a saved list's windows, fetched before a
// variant was given its family's: claude-opus-5-5-high-fast has
// claude-opus-5-5's.
func withDevinContexts(ms []catalog.Model) []catalog.Model {
out := slices.Clone(ms)
for i, m := range out {
for base := m.ID; m.Context == 0; {
if n := catalog.ContextOf(base); n > 0 {
out[i].Context = n
if m.Output == 0 {
out[i].Output = catalog.OutputOf(base)
}
break
}
b := devinEffort.ReplaceAllString(base, "")
if b == base {
break
}
base = b
}
}
return out
}
var devinFamiliesCache struct {
sync.Mutex
at time.Time
families []DevinFamily
}
// DevinFamilies is the CLI's model list, kept a few minutes: the picker and
// the provider ask for it often and `devin models list` spawns a process.
func DevinFamilies(ctx context.Context) ([]DevinFamily, error) {
devinFamiliesCache.Lock()
defer devinFamiliesCache.Unlock()
if time.Since(devinFamiliesCache.at) < 5*time.Minute && len(devinFamiliesCache.families) > 0 {
return devinFamiliesCache.families, nil
}
families, err := askDevinFamilies(ctx)
if err != nil {
return nil, err
}
devinFamiliesCache.families, devinFamiliesCache.at = families, time.Now()
return families, nil
}
func devinFamiliesCached(families []DevinFamily) {
devinFamiliesCache.Lock()
devinFamiliesCache.families, devinFamiliesCache.at = families, time.Now()
devinFamiliesCache.Unlock()
}
func askDevinFamilies(ctx context.Context) ([]DevinFamily, error) {
path := DevinExecutable()
if path == "" {
return nil, errors.New("devin is not installed")
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
out, err := agentCommand(ctx, path, "models", "list", "--format", "json").Output()
if err != nil {
return nil, errorf("devin models list: %v", err)
}
families := parseDevinModels(out)
if len(families) == 0 {
return nil, errors.New("devin models list: no models")
}
return families, nil
}
// parseDevinModels reads `devin models list --format json`:
//
// {"families": [{"family_label": "…", "family_uid": "…", "slug": "…",
// "aliases": ["…"], "variants": [{"model_uid": "…", "label": "…", …}]}]}
func parseDevinModels(b []byte) []DevinFamily {
var list struct {
Families []struct {
FamilyUID string `json:"family_uid"`
FamilyLabel string `json:"family_label"`
Aliases []string `json:"aliases"`
Variants []struct {
ModelUID string `json:"model_uid"`
Label string `json:"label"`
} `json:"variants"`
} `json:"families"`
}
if json.Unmarshal(b, &list) != nil {
return nil
}
var out []DevinFamily
for _, f := range list.Families {
if f.FamilyUID == "" {
continue
}
fam := DevinFamily{UID: f.FamilyUID, Label: f.FamilyLabel, Aliases: f.Aliases}
if fam.Label == "" {
fam.Label = f.FamilyUID
}
for _, v := range f.Variants {
if v.ModelUID == "" {
continue
}
name := v.Label
if name == "" {
name = v.ModelUID
}
fam.Models = append(fam.Models, catalog.Model{ID: v.ModelUID, Name: name, Provider: "devin"})
}
out = append(out, fam)
}
return out
}
// devinAPIServer is where the CLI's Connect RPCs live; devinExchangeURL is
// where the callback's code trades for the credentials `devin auth login`
// would write — a var so tests can point it elsewhere.
const devinAPIServer = "https://server.codeium.com"
var devinExchangeURL = devinAPIServer + "/exa.seat_management_pb.SeatManagementService/ExchangeDevinCLIPKCECode"
// devinExchange trades the code for the credentials file `devin auth login`
// writes, then asks the CLI who signed in. The CLI's own login makes the same
// Connect call: the answer's sessionToken — already shaped
// "devin-session-token$<jwt>" — is the windsurf_api_key the file wants.
func devinExchange(ctx context.Context, code, verifier, redirect string) (savedLogin, error) {
body, _ := json.Marshal(map[string]string{"code": code,
"code_verifier": verifier, "redirect_uri": redirect})
var res struct {
SessionToken string `json:"sessionToken"`
SessionTokenAlt string `json:"session_token"`
DevinWebappHost string `json:"devinWebappHost"`
DevinAPIURL string `json:"devinApiUrl"`
}
if err := postToken(ctx, devinExchangeURL, "application/json", body, &res); err != nil {
return savedLogin{}, err
}
key := res.SessionToken
if key == "" {
key = res.SessionTokenAlt
}
if key == "" {
return savedLogin{}, errors.New("Devin's exchange returned no session token")
}
creds := devinCredentials(key, devinAPIServer, res.DevinWebappHost, res.DevinAPIURL)
// devin keeps the one account it is signed in to: writing the file is
// signing it in, and puts the file where `devin auth status` reads it
if err := writePrivate(DevinCredentialsPath(), creds); err != nil {
return savedLogin{}, err
}
forgetDevinStatus()
forgetAccountCaches()
user, plan, ok := askDevinIdentity()
if !ok {
// the account is only ever seen through the CLI: without it
// answering, magpie has nothing to show and nothing to run
return savedLogin{}, errors.New("signed in, but `devin auth status` doesn't show the account; run it in a terminal to see why")
}
return savedLogin{Agent: "devin", User: user, Plan: plan}, nil
}
// devinCredentials formats credentials.toml the way `devin auth login`
// writes it, with the hosts the exchange left out defaulted.
func devinCredentials(key, server, webapp, api string) []byte {
if server == "" {
server = "https://server.codeium.com"
}
if webapp == "" {
webapp = "app.devin.ai"
}
if api == "" {
api = "https://api.devin.ai"
}
var b strings.Builder
for _, kv := range [][2]string{
{"windsurf_api_key", key},
{"api_server_url", server},
{"devin_webapp_host", webapp},
{"devin_api_url", api},
} {
fmt.Fprintf(&b, "%s = %s\n", kv[0], tomlString(kv[1]))
}
return []byte(b.String())
}
// tomlString quotes a TOML basic string; the values are plain ASCII tokens
// and URLs, so only the string's own delimiters need escaping.
func tomlString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
}
// DevinAuth is the key the CLI signed in with and the server its API is
// on, read from credentials.toml; WINDSURF_API_SERVER_URL moves the server,
// as it does the CLI's.
func DevinAuth() (key, server string, err error) {
b, err := os.ReadFile(DevinCredentialsPath())
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", "", errors.New("Devin isn't signed in; sign in from magpie's Providers page or run `devin auth login`")
}
return "", "", err
}
var c struct {
Key string `toml:"windsurf_api_key"`
Server string `toml:"api_server_url"`
}
if err := toml.Unmarshal(b, &c); err != nil {
return "", "", fmt.Errorf("%s: %w", DevinCredentialsPath(), err)
}
if c.Key == "" {
return "", "", errors.New("Devin isn't signed in: " + DevinCredentialsPath() + " has no key")
}
server = strings.TrimRight(c.Server, "/")
if v := os.Getenv("WINDSURF_API_SERVER_URL"); v != "" {
server = strings.TrimRight(v, "/")
}
if server == "" {
server = devinAPIServer
}
return c.Key, server, nil
}
// DevinVariant is the model to ask Devin's API for: a family's name
// follows its newest model, which the API takes only as one of its
// variants — the one at the effort asked for, else the family's default.
func DevinVariant(ctx context.Context, model, effort string) string {
families, err := DevinFamilies(ctx)
if err != nil {
return model
}
for _, f := range families {
if f.UID != model && !slices.Contains(f.Aliases, model) || len(f.Models) == 0 {
continue
}
if effort != "" {
for _, m := range f.Models {
if strings.HasSuffix(strings.ToLower(m.ID), "-"+effort) || strings.HasSuffix(m.ID, "_"+strings.ToUpper(effort)) {
return m.ID
}
}
}
return f.Models[0].ID
}
return model
}