Files
magpie/internal/gateway/devin.go
T

576 lines
15 KiB
Go

package gateway
// A Devin subscription is served through the API the devin CLI talks to —
// Windsurf's GetChatMessage, a Connect RPC in protobuf — the way a Kiro one
// is (kiro.go): each request goes whole, the conversation with the caller's
// tools, and the reply streams back as Connect frames, decoded here into
// events. The sign-in is the CLI's credentials.toml (provider/devin.go).
import (
"bufio"
"bytes"
"compress/gzip"
"context"
"encoding/binary"
"encoding/json"
"errors"
"io"
"math"
"net/http"
"runtime"
"slices"
"strings"
"github.com/yetone/magpie/internal/provider"
)
// devinAuth and devinVariant are the sign-in and the model the API is
// asked for; vars so tests can stand in for them.
var (
devinAuth = provider.DevinAuth
devinVariant = provider.DevinVariant
)
// devinCLIVersion is the CLI the requests say they come from.
const devinCLIVersion = "3000.11.3"
// serveDevin answers a request through Devin's API.
func (s *Server) serveDevin(w http.ResponseWriter, r *http.Request, from provider.Protocol, model string, body []byte, usage *Usage) (int, string) {
req, err := parse(from, body)
if err != nil {
return writeError(w, from, 400, err.Error()), err.Error()
}
req.Model = model
ask := s.askDevin(model)
if req.WebSearch && !searching(r.Context()) {
if _, _, ok := searcher(); ok {
return s.searchReply(w, r, from, "Devin", req, usage, ask)
}
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
events, status, msg := ask(ctx, req)
if events == nil {
return writeError(w, from, status, msg), msg
}
return relay(w, r, from, "Devin", req, events, usage, cancel, func(string, string, bool) {})
}
// askDevin is a round for Devin's API.
func (s *Server) askDevin(model string) round {
return func(ctx context.Context, req *Request) (<-chan Event, int, string) {
key, server, err := devinAuth()
if err != nil {
return nil, 401, "Devin: " + err.Error()
}
uid := devinVariant(ctx, model, req.Effort)
hr, err := http.NewRequestWithContext(ctx, http.MethodPost, server+"/exa.api_server_pb.ApiServerService/GetChatMessage",
bytes.NewReader(connectFrame(buildDevin(req, uid, key))))
if err != nil {
return nil, 500, "Devin: " + err.Error()
}
hr.Header.Set("Content-Type", "application/connect+proto")
hr.Header.Set("Connect-Protocol-Version", "1")
hr.Header.Set("Authorization", "Basic "+key)
res, err := s.client.Do(hr)
if err != nil {
return nil, 502, "Devin: " + err.Error()
}
if res.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
res.Body.Close()
status, msg := devinFailure(res.StatusCode, b)
return nil, status, "Devin: " + msg
}
// an error comes as the stream's end, before anything else: read
// that far so it is answered with its own status
br := bufio.NewReaderSize(res.Body, 64<<10)
first, err := readConnectFrame(br)
if err != nil {
res.Body.Close()
return nil, 502, "Devin: the reply broke off: " + err.Error()
}
if first.end {
res.Body.Close()
status, msg := devinFailure(200, first.data)
if status/100 == 2 {
status, msg = 502, "an empty reply"
}
return nil, status, "Devin: " + msg
}
events := make(chan Event, 16)
go func() {
defer res.Body.Close()
decodeDevin(ctx, first, br, events, model)
}()
return events, 0, ""
}
}
// devinFailure is the status and message for a Connect error:
// {"code": "...", "message": "..."}, or at a stream's end
// {"error": {"code": "...", "message": "..."}}.
func devinFailure(status int, body []byte) (int, string) {
var e struct {
Code string `json:"code"`
Message string `json:"message"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
msg := strings.TrimSpace(string(body))
code := ""
if json.Unmarshal(body, &e) == nil {
if e.Error != nil {
code, msg = e.Error.Code, e.Error.Message
} else if e.Code != "" {
code, msg = e.Code, e.Message
}
if msg == "" {
msg = code
}
}
if msg == "" {
msg = http.StatusText(status)
}
lower := strings.ToLower(msg)
switch {
case code == "unauthenticated":
return 401, msg + " — sign in to Devin again"
case code == "resource_exhausted" || strings.Contains(lower, "quota") || strings.Contains(lower, "rate limit"):
return 429, "usage limit reached: " + msg
case strings.Contains(lower, "content policy"):
// Devin turns away some system prompts outright; sent again, the
// same one is turned away again
return 400, msg
case strings.Contains(lower, "too long") || strings.Contains(lower, "context length") || strings.Contains(lower, "too many tokens"):
return 400, "input is too long for the model's context: " + msg
case code == "invalid_argument":
return 400, msg
case code == "unavailable":
return 503, msg
case code != "":
return 502, msg
}
return status, msg
}
// ---- protobuf, by hand ---------------------------------------------------
// pb builds a protobuf message field by field.
type pb []byte
func (b pb) tag(num, wire int) pb { return binary.AppendUvarint(b, uint64(num<<3|wire)) }
func (b pb) varint(num int, v uint64) pb { return binary.AppendUvarint(b.tag(num, 0), v) }
func (b pb) bytes(num int, v []byte) pb {
return append(binary.AppendUvarint(b.tag(num, 2), uint64(len(v))), v...)
}
func (b pb) str(num int, v string) pb { return b.bytes(num, []byte(v)) }
func (b pb) double(num int, v float64) pb {
return binary.LittleEndian.AppendUint64(b.tag(num, 1), math.Float64bits(v))
}
// pbField is one field of a message being read.
type pbField struct {
num int
wire int
n uint64 // a varint or fixed value
data []byte // a length-delimited one
}
// pbFields reads a message's fields; a malformed tail is dropped.
func pbFields(b []byte) []pbField {
var out []pbField
for len(b) > 0 {
key, n := binary.Uvarint(b)
if n <= 0 {
break
}
b = b[n:]
f := pbField{num: int(key >> 3), wire: int(key & 7)}
switch f.wire {
case 0:
v, n := binary.Uvarint(b)
if n <= 0 {
return out
}
f.n, b = v, b[n:]
case 1:
if len(b) < 8 {
return out
}
f.n, b = binary.LittleEndian.Uint64(b), b[8:]
case 2:
l, n := binary.Uvarint(b)
if n <= 0 || uint64(len(b)-n) < l {
return out
}
f.data, b = b[n:n+int(l)], b[n+int(l):]
case 5:
if len(b) < 4 {
return out
}
f.n, b = uint64(binary.LittleEndian.Uint32(b)), b[4:]
default:
return out
}
out = append(out, f)
}
return out
}
// connectFrame wraps a message as the one frame of a Connect stream.
func connectFrame(msg []byte) []byte {
out := make([]byte, 5, 5+len(msg))
binary.BigEndian.PutUint32(out[1:], uint32(len(msg)))
return append(out, msg...)
}
// connectMsg is one frame of a Connect stream: a message, or the stream's
// end with its JSON.
type connectMsg struct {
end bool
data []byte
}
func readConnectFrame(r *bufio.Reader) (connectMsg, error) {
var head [5]byte
if _, err := io.ReadFull(r, head[:]); err != nil {
return connectMsg{}, err
}
n := binary.BigEndian.Uint32(head[1:])
if n > 64<<20 {
return connectMsg{}, errors.New("a malformed stream")
}
data := make([]byte, n)
if _, err := io.ReadFull(r, data); err != nil {
return connectMsg{}, err
}
if head[0]&1 != 0 {
zr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return connectMsg{}, err
}
if data, err = io.ReadAll(io.LimitReader(zr, 64<<20)); err != nil {
return connectMsg{}, err
}
}
return connectMsg{end: head[0]&2 != 0, data: data}, nil
}
// ---- the request ---------------------------------------------------------
// Devin's roles for a message.
const (
devinUser = 1
devinAssistant = 2
devinTool = 4
)
// devinNoResult answers a call the conversation never answered.
const devinNoResult = "Tool use was interrupted and did not produce a result."
// devinMsg is a message of the conversation, before it is encoded.
type devinMsg struct {
role int
text string
calls []Part
callID string // what a tool result answers
images []Part
thinking []Part // with Devin's own signatures
}
// buildDevin is the GetChatMessage request for r, to the model uid.
func buildDevin(r *Request, uid, key string) []byte {
var msgs []devinMsg
var pending []Part // calls the last reply made, not yet answered
answer := func() {
for _, c := range pending {
msgs = append(msgs, devinMsg{role: devinTool, callID: c.ID, text: devinNoResult})
}
pending = nil
}
for _, m := range r.Messages {
if m.Role == "assistant" {
a := devinMsg{role: devinAssistant}
var texts []string
for _, p := range m.Parts {
switch p.Kind {
case Text:
if p.Text != "" {
texts = append(texts, p.Text)
}
case ToolCall:
a.calls = append(a.calls, p)
case Thinking:
if strings.HasPrefix(p.Signature, "sealed.") {
a.thinking = append(a.thinking, p)
}
}
}
a.text = strings.Join(texts, "\n\n")
if a.text == "" && len(a.calls) == 0 {
continue // a turn that only thought, or failed
}
answer()
msgs = append(msgs, a)
pending = a.calls
continue
}
u := devinMsg{role: devinUser}
var texts []string
for _, p := range m.Parts {
switch p.Kind {
case Text:
if p.Text != "" {
texts = append(texts, p.Text)
}
case File:
texts = append(texts, attachmentText(p))
case Image:
if p.Data != "" {
u.images = append(u.images, p)
}
case ToolResult:
// only a call just made is answered, and only once
for i, c := range pending {
if c.ID != p.CallID {
continue
}
out := p.Text
if out == "" {
out = "(no output)"
}
if p.IsError {
out = "Error: " + out
}
msgs = append(msgs, devinMsg{role: devinTool, callID: c.ID, text: out})
pending = append(pending[:i:i], pending[i+1:]...)
break
}
}
}
u.text = strings.Join(texts, "\n\n")
if u.text == "" && len(u.images) == 0 {
continue
}
answer()
msgs = append(msgs, u)
}
answer()
if len(msgs) == 0 || msgs[len(msgs)-1].role == devinAssistant && len(msgs[len(msgs)-1].calls) == 0 {
msgs = append(msgs, devinMsg{role: devinUser, text: "Please proceed with the task."})
}
// the instructions go at the head of the first message, as Kiro's do:
// Devin turns away some agents' own in its system field (Claude Code's
// "You are a Claude agent, built on Anthropic's Claude Agent SDK")
if r.System != "" {
at := slices.IndexFunc(msgs, func(m devinMsg) bool { return m.role == devinUser })
if at < 0 {
msgs = slices.Insert(msgs, 0, devinMsg{role: devinUser})
at = 0
}
msgs[at].text = joinNonEmpty(r.System, msgs[at].text)
}
meta := pb{}.str(1, "devin-cli").str(2, devinCLIVersion).str(3, key).str(4, "en").
str(5, runtime.GOOS).str(7, devinCLIVersion).str(12, "chisel").str(28, "chisel")
out := pb{}.bytes(1, meta)
for _, m := range msgs {
out = out.bytes(3, encodeDevinMsg(m))
}
out = out.varint(7, 5)
max := r.MaxTokens
if max <= 0 {
max = 128000 // the server holds it to the model's own
}
topP := 0.95
if r.TopP != nil {
topP = *r.TopP
}
temp := 1.0
if r.Temp != nil {
temp = *r.Temp
}
out = out.bytes(8, pb{}.varint(1, 1).varint(2, uint64(max)).varint(3, 400).double(5, temp).varint(7, 40).double(8, topP))
// the caller's tools, and any the conversation used that it no longer
// offers
offered := map[string]bool{}
tool := func(name, desc string, schema json.RawMessage) {
if len(bytes.TrimSpace(schema)) == 0 || string(bytes.TrimSpace(schema)) == "null" {
schema = json.RawMessage(`{"type":"object","properties":{}}`)
}
if desc == "" {
desc = name
}
offered[name] = true
out = out.bytes(10, pb{}.str(1, name).str(2, desc).bytes(3, schema))
}
if r.ToolChoice != "none" {
for _, t := range r.Tools {
tool(t.Name, t.Description, t.Schema)
}
}
for _, m := range msgs {
for _, c := range m.calls {
if !offered[c.Name] {
tool(c.Name, "Tool", nil)
}
}
}
return out.str(21, uid)
}
func encodeDevinMsg(m devinMsg) []byte {
b := pb{}.str(1, newUUID()).varint(2, uint64(m.role))
if m.text != "" {
b = b.str(3, m.text)
}
for _, c := range m.calls {
b = b.bytes(6, pb{}.str(1, c.ID).str(2, c.Name).bytes(3, argsOf(c)))
}
if m.callID != "" {
b = b.str(7, m.callID)
}
for _, p := range m.images {
mt := p.MediaType
if mt == "" {
mt = "image/png"
}
b = b.bytes(10, pb{}.str(1, p.Data).str(2, mt))
}
for _, t := range m.thinking {
b = b.str(11, t.Text).str(12, t.Signature).str(18, "sealed")
}
return b
}
// ---- the reply -----------------------------------------------------------
// decodeDevin turns Devin's reply, from its first frame on, into events.
func decodeDevin(ctx context.Context, first connectMsg, br *bufio.Reader, out chan<- Event, model string) {
defer close(out)
send := func(ev Event) bool {
select {
case out <- ev:
return true
case <-ctx.Done():
return false
}
}
if !send(Event{Kind: KStart, MsgID: "msg_" + randomToken()[:24], Model: model}) {
return
}
var d devinDecoder
var failed string
for f := first; ; {
if f.end {
if status, msg := devinFailure(200, f.data); status/100 != 2 {
failed = msg
}
break
}
for _, ev := range d.frame(f.data) {
if !send(ev) {
return
}
}
var err error
if f, err = readConnectFrame(br); err != nil {
if !errors.Is(err, io.EOF) && ctx.Err() == nil {
failed = "the reply broke off: " + err.Error()
}
break
}
}
if failed != "" {
send(Event{Kind: KError, Text: failed})
return
}
u := d.usage
if u.Output == 0 {
u.Output = (d.said + 3) / 4
}
send(Event{Kind: KUsage, Usage: u})
stop := "stop"
switch {
case d.tools > 0 || d.stop == 10:
stop = "tool"
case d.stop == 3:
stop = "length"
}
send(Event{Kind: KStop, Stop: stop})
}
// devinDecoder follows a reply: its text and thinking in pieces, its tool
// calls — each opened with its id and name, the arguments in pieces after
// — why it stopped, and what it used.
type devinDecoder struct {
tools int
stop uint64
usage Usage
said int
}
func (d *devinDecoder) frame(b []byte) []Event {
var evs []Event
for _, f := range pbFields(b) {
switch {
case f.num == 3 && f.wire == 2 && len(f.data) > 0:
d.said += len(f.data)
evs = append(evs, Event{Kind: KText, Text: string(f.data)})
case f.num == 9 && f.wire == 2 && len(f.data) > 0:
d.said += len(f.data)
evs = append(evs, Event{Kind: KThink, Text: string(f.data)})
case f.num == 6 && f.wire == 2:
var id, name, args string
for _, g := range pbFields(f.data) {
switch g.num {
case 1:
id = string(g.data)
case 2:
name = string(g.data)
case 3:
args += string(g.data)
}
}
if name != "" {
d.tools++
if id == "" {
id = "call_" + randomToken()[:24]
}
evs = append(evs, Event{Kind: KToolStart, ID: id, Name: name})
}
if args != "" {
evs = append(evs, Event{Kind: KToolArgs, Text: args})
}
case f.num == 5 && f.wire == 0:
d.stop = f.n
case f.num == 7 && f.wire == 2:
for _, g := range pbFields(f.data) {
if g.wire != 0 || g.n == 0 {
continue
}
switch g.num {
case 2:
d.usage.Input = int(g.n)
case 3:
d.usage.Output = int(g.n)
case 4:
d.usage.CacheWrite = int(g.n)
case 5:
d.usage.CacheRead = int(g.n)
}
}
}
}
return evs
}