mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-04 14:49:59 +08:00
- Wire parity: align Connect-RPC Sentry-Trace, User-Agent suppression, float32 double pattern, and dynamic 732-char hex device fingerprint - Session ordinal & cache: implement process-scoped Field 15.2 with bounded LRU (5000 entries) and Field 15.4=14 user boundary; prioritize stable session_id over previous_interaction_id to preserve prompt caching - Streaming robustness: unblock hung TCP reads on client cancellation via context watcher; accurately propagate stream read errors and trailer errors instead of swallowing truncated frames - Thought signature & reasoning: emit raw delta signatures directly in active thought steps; eliminate redundant tail base64 re-encoding; ensure 1:1 assistant signature and thinking alignment across multi-turn history - Tool call de-multiplexing: route parallel tool calls by tc.Index in both streaming step events and non-streaming aggregations - Security & transport: escape OAuth callback error HTML against reflected XSS, enforce strict state validation, and isolate Devin HTTP transport with tr.Clone()
1026 lines
30 KiB
Go
1026 lines
30 KiB
Go
package helps
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
|
"google.golang.org/protobuf/encoding/protowire"
|
|
)
|
|
|
|
const (
|
|
// ConnectFlagData marks an uncompressed Connect-proto data frame.
|
|
ConnectFlagData byte = 0x00
|
|
// ConnectFlagCompressed marks a gzipped Connect-proto frame.
|
|
ConnectFlagCompressed byte = 0x01
|
|
// ConnectFlagEndStream marks the terminal Connect-proto trailer frame.
|
|
ConnectFlagEndStream byte = 0x02
|
|
|
|
// DevinDefaultBaseURL is the default upstream Codeium/Devin endpoint.
|
|
DevinDefaultBaseURL = "https://server.codeium.com"
|
|
// DevinChatPath is the Connect-RPC endpoint for chat completions.
|
|
DevinChatPath = "/exa.api_server_pb.ApiServerService/GetChatMessage"
|
|
|
|
// DevinDefaultClientName is the client identifier declared in metadata.
|
|
DevinDefaultClientName = "chisel"
|
|
// DevinDefaultClientVersion is the client version declared in metadata.
|
|
DevinDefaultClientVersion = "3000.10.21"
|
|
// DevinFingerprintHexLen is the mandatory 732-hex-character length for metadata #31.
|
|
DevinFingerprintHexLen = 732
|
|
|
|
// DevinDefaultMaxTokens is the fallback max completion tokens.
|
|
DevinDefaultMaxTokens = 128000
|
|
|
|
maxConnectFrameSize = 16 * 1024 * 1024
|
|
maxDecompressedFrameSize = 64 * 1024 * 1024
|
|
)
|
|
|
|
// DevinTool represents a tool definition in GetChatMessageRequest.
|
|
type DevinTool struct {
|
|
Name string
|
|
Description string
|
|
Parameters []byte
|
|
}
|
|
|
|
// DevinToolCall represents a tool call in a ChatMessagePrompt.
|
|
type DevinToolCall struct {
|
|
ID string
|
|
Name string
|
|
Arguments string
|
|
}
|
|
|
|
// DevinToolCallDelta represents a streaming tool call chunk from response Field 6.
|
|
type DevinToolCallDelta struct {
|
|
ID string
|
|
Name string
|
|
Arguments string
|
|
Index int
|
|
}
|
|
|
|
// DevinImage represents an image attachment in a DevinPrompt (Prompt #10).
|
|
type DevinImage struct {
|
|
Base64Data string
|
|
MimeType string
|
|
}
|
|
|
|
// DevinPrompt represents a single turn in the request history (repeated Field 3).
|
|
type DevinPrompt struct {
|
|
MessageID string
|
|
Source int // 1=user, 2=assistant, 4=tool
|
|
Content string
|
|
Images []DevinImage
|
|
ToolCalls []DevinToolCall
|
|
ToolCallID string // For source=4 (tool result)
|
|
Thinking string
|
|
Signature []byte
|
|
SignatureType string
|
|
}
|
|
|
|
// DevinUsage captures token accounting from response Field 7.
|
|
type DevinUsage struct {
|
|
PromptTokens int64
|
|
CompletionTokens int64
|
|
CachedTokens int64
|
|
StatusCode uint64
|
|
RequestID string
|
|
ModelName string
|
|
}
|
|
|
|
// DevinFrameResult represents decoded content from a single Connect-proto frame.
|
|
type DevinFrameResult struct {
|
|
OutputID string
|
|
Timestamp uint64
|
|
ContentText string
|
|
DeltaTokens uint64
|
|
StopReason uint64 // 2/4=stop, 10=tool_calls
|
|
ToolCallDeltas []DevinToolCallDelta
|
|
ThinkingText string
|
|
DeltaSignature []byte
|
|
DeltaSignatureType string
|
|
Latency float64
|
|
MessageID string
|
|
Usage *DevinUsage
|
|
ResponseDimensionGroups []byte
|
|
UnknownFieldNumbers []int
|
|
}
|
|
|
|
// GenerateDevinDeviceFingerprint generates a 732-character hex device fingerprint.
|
|
// When seed is empty, it generates a cryptographically random 732-character hex string per request (matching native devin-cli).
|
|
// When seed is provided, it derives a deterministic 732-character hex fingerprint.
|
|
func GenerateDevinDeviceFingerprint(seed string) string {
|
|
if seed == "" {
|
|
var b [DevinFingerprintHexLen / 2]byte
|
|
if _, err := rand.Read(b[:]); err == nil {
|
|
return hex.EncodeToString(b[:])
|
|
}
|
|
seed = uuid.New().String()
|
|
}
|
|
var sb strings.Builder
|
|
counter := 0
|
|
for sb.Len() < DevinFingerprintHexLen {
|
|
h := sha256.Sum256([]byte(fmt.Sprintf("%s-%d", seed, counter)))
|
|
sb.WriteString(hex.EncodeToString(h[:]))
|
|
counter++
|
|
}
|
|
// Truncate to exact 732 chars (11 full 64-char sha256 hex blocks + 28 chars of the 12th block) to match Devin CLI.
|
|
return sb.String()[:DevinFingerprintHexLen]
|
|
}
|
|
|
|
// GenerateDevinSentryTrace generates a Sentry distributed tracing header in the format:
|
|
// "<32-hex-trace-id>-<16-hex-span-id>-1"
|
|
func GenerateDevinSentryTrace() string {
|
|
var b [24]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
u1 := strings.ReplaceAll(uuid.New().String(), "-", "")
|
|
u2 := strings.ReplaceAll(uuid.New().String(), "-", "")[:16]
|
|
return u1 + "-" + u2 + "-1"
|
|
}
|
|
traceID := hex.EncodeToString(b[:16])
|
|
spanID := hex.EncodeToString(b[16:24])
|
|
return traceID + "-" + spanID + "-1"
|
|
}
|
|
|
|
const defaultMaxSessionTurnCounters = 5000
|
|
|
|
var sessionTurnLRU = cache.NewBoundedLRU[string, *atomic.Uint64](defaultMaxSessionTurnCounters, nil)
|
|
|
|
// NextDevinSessionTurnIndex returns the next 0-based request ordinal for a session (Field 15.2).
|
|
// In native devin-cli, the counter is process-scoped per session:
|
|
// First request in a session returns 0 (which is omitted on the wire).
|
|
// Subsequent requests return 1, 2, 3... monotonically.
|
|
func NextDevinSessionTurnIndex(sessionID string) int {
|
|
cleanID := strings.TrimSpace(sessionID)
|
|
if cleanID == "" {
|
|
return 0
|
|
}
|
|
|
|
counter := sessionTurnLRU.GetOrAdd(cleanID, func() *atomic.Uint64 {
|
|
return &atomic.Uint64{}
|
|
})
|
|
return int(counter.Add(1) - 1)
|
|
}
|
|
|
|
// ResetDevinSessionTurnIndex clears the session counter (used for testing or explicit session reset).
|
|
func ResetDevinSessionTurnIndex(sessionID string) {
|
|
sessionTurnLRU.Delete(strings.TrimSpace(sessionID))
|
|
}
|
|
|
|
// WrapConnectEnvelope wraps raw payload bytes into a standard 5-byte Connect envelope:
|
|
// [1 byte flag: 0x00] + [4 byte big-endian length] + [payload].
|
|
func WrapConnectEnvelope(protoBytes []byte) []byte {
|
|
return WrapConnectEnvelopeWithFlag(ConnectFlagData, protoBytes)
|
|
}
|
|
|
|
// WrapConnectEnvelopeWithFlag wraps payload bytes with an explicit Connect flag.
|
|
func WrapConnectEnvelopeWithFlag(flag byte, protoBytes []byte) []byte {
|
|
header := make([]byte, 5, 5+len(protoBytes))
|
|
header[0] = flag
|
|
binary.BigEndian.PutUint32(header[1:5], uint32(len(protoBytes)))
|
|
return append(header, protoBytes...)
|
|
}
|
|
|
|
// ReadConnectFrame reads a single framed message from a Connect-proto stream.
|
|
func ReadConnectFrame(r io.Reader) (flag byte, payload []byte, err error) {
|
|
header := make([]byte, 5)
|
|
if _, err := io.ReadFull(r, header); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
flag = header[0]
|
|
length := binary.BigEndian.Uint32(header[1:5])
|
|
if length > maxConnectFrameSize {
|
|
return flag, nil, fmt.Errorf("connect frame length %d exceeds maximum limit (%d)", length, maxConnectFrameSize)
|
|
}
|
|
|
|
payload = make([]byte, length)
|
|
if _, err := io.ReadFull(r, payload); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
|
|
if flag&ConnectFlagCompressed != 0 {
|
|
gz, errGz := gzip.NewReader(bytes.NewReader(payload))
|
|
if errGz != nil {
|
|
return flag, nil, fmt.Errorf("decompress gzip connect frame: %w", errGz)
|
|
}
|
|
defer func() { _ = gz.Close() }()
|
|
|
|
limitedReader := io.LimitReader(gz, maxDecompressedFrameSize+1)
|
|
decompressed, errRead := io.ReadAll(limitedReader)
|
|
if errRead != nil {
|
|
return flag, nil, fmt.Errorf("read decompressed connect frame: %w", errRead)
|
|
}
|
|
if len(decompressed) > maxDecompressedFrameSize {
|
|
return flag, nil, fmt.Errorf("decompressed frame size exceeds maximum limit (%d)", maxDecompressedFrameSize)
|
|
}
|
|
payload = decompressed
|
|
}
|
|
|
|
return flag, payload, nil
|
|
}
|
|
|
|
// BuildDevinClientMetadataBytes constructs the serialized bytes for Field 1 (ClientMetadata).
|
|
func BuildDevinClientMetadataBytes(sessionToken, deviceSeed, osName string) []byte {
|
|
if osName == "" {
|
|
osName = runtime.GOOS
|
|
}
|
|
deviceFingerprint := GenerateDevinDeviceFingerprint(deviceSeed)
|
|
|
|
var f1Bytes []byte
|
|
f1Bytes = protowire.AppendTag(f1Bytes, 1, protowire.BytesType)
|
|
f1Bytes = protowire.AppendString(f1Bytes, "devin-cli")
|
|
|
|
f1Bytes = protowire.AppendTag(f1Bytes, 2, protowire.BytesType)
|
|
f1Bytes = protowire.AppendString(f1Bytes, DevinDefaultClientVersion)
|
|
|
|
f1Bytes = protowire.AppendTag(f1Bytes, 3, protowire.BytesType)
|
|
f1Bytes = protowire.AppendString(f1Bytes, sessionToken)
|
|
|
|
f1Bytes = protowire.AppendTag(f1Bytes, 4, protowire.BytesType)
|
|
f1Bytes = protowire.AppendString(f1Bytes, "en")
|
|
|
|
f1Bytes = protowire.AppendTag(f1Bytes, 5, protowire.BytesType)
|
|
f1Bytes = protowire.AppendString(f1Bytes, osName)
|
|
|
|
f1Bytes = protowire.AppendTag(f1Bytes, 7, protowire.BytesType)
|
|
f1Bytes = protowire.AppendString(f1Bytes, DevinDefaultClientVersion)
|
|
|
|
f1Bytes = protowire.AppendTag(f1Bytes, 12, protowire.BytesType)
|
|
f1Bytes = protowire.AppendString(f1Bytes, DevinDefaultClientName)
|
|
|
|
f1Bytes = protowire.AppendTag(f1Bytes, 28, protowire.BytesType)
|
|
f1Bytes = protowire.AppendString(f1Bytes, DevinDefaultClientName)
|
|
|
|
f1Bytes = protowire.AppendTag(f1Bytes, 31, protowire.BytesType)
|
|
f1Bytes = protowire.AppendString(f1Bytes, deviceFingerprint)
|
|
return f1Bytes
|
|
}
|
|
|
|
// BuildDevinGetChatMessageRequest encodes an entire GetChatMessageRequest protobuf payload.
|
|
func BuildDevinGetChatMessageRequest(
|
|
sessionToken string,
|
|
deviceSeed string,
|
|
chatModelUID string,
|
|
systemPrompt string,
|
|
prompts []DevinPrompt,
|
|
tools []DevinTool,
|
|
temperature *float64,
|
|
maxTokens int,
|
|
sessionID string,
|
|
cascadeID string,
|
|
matcher *SensitiveWordMatcher,
|
|
) []byte {
|
|
if maxTokens <= 0 {
|
|
maxTokens = DevinDefaultMaxTokens
|
|
}
|
|
if sessionID == "" {
|
|
sessionID = uuid.New().String()
|
|
}
|
|
if cascadeID == "" {
|
|
cascadeID = sessionID
|
|
}
|
|
osName := runtime.GOOS
|
|
|
|
var reqBytes []byte
|
|
|
|
// 1. ClientMetadata (Field 1)
|
|
f1Bytes := BuildDevinClientMetadataBytes(sessionToken, deviceSeed, osName)
|
|
reqBytes = protowire.AppendTag(reqBytes, 1, protowire.BytesType)
|
|
reqBytes = protowire.AppendBytes(reqBytes, f1Bytes)
|
|
|
|
// 2. System prompt (Field 2)
|
|
if systemPrompt != "" {
|
|
sanitized := SanitizeDevinSystemPrompt(systemPrompt, matcher)
|
|
if sanitized != "" {
|
|
reqBytes = protowire.AppendTag(reqBytes, 2, protowire.BytesType)
|
|
reqBytes = protowire.AppendString(reqBytes, sanitized)
|
|
}
|
|
}
|
|
|
|
// 3. Repeated History Prompts (Field 3)
|
|
for _, p := range prompts {
|
|
var pBytes []byte
|
|
|
|
msgID := p.MessageID
|
|
if msgID == "" {
|
|
msgID = uuid.New().String()
|
|
}
|
|
pBytes = protowire.AppendTag(pBytes, 1, protowire.BytesType)
|
|
pBytes = protowire.AppendString(pBytes, msgID)
|
|
|
|
source := p.Source
|
|
if source <= 0 {
|
|
source = 1 // default to user
|
|
}
|
|
pBytes = protowire.AppendTag(pBytes, 2, protowire.VarintType)
|
|
pBytes = protowire.AppendVarint(pBytes, uint64(source))
|
|
|
|
content := p.Content
|
|
pBytes = protowire.AppendTag(pBytes, 3, protowire.BytesType)
|
|
pBytes = protowire.AppendString(pBytes, content)
|
|
|
|
for _, tc := range p.ToolCalls {
|
|
var tcBytes []byte
|
|
if tc.ID != "" {
|
|
tcBytes = protowire.AppendTag(tcBytes, 1, protowire.BytesType)
|
|
tcBytes = protowire.AppendString(tcBytes, tc.ID)
|
|
}
|
|
if tc.Name != "" {
|
|
tcBytes = protowire.AppendTag(tcBytes, 2, protowire.BytesType)
|
|
tcBytes = protowire.AppendString(tcBytes, tc.Name)
|
|
}
|
|
if tc.Arguments != "" {
|
|
tcBytes = protowire.AppendTag(tcBytes, 3, protowire.BytesType)
|
|
tcBytes = protowire.AppendString(tcBytes, tc.Arguments)
|
|
}
|
|
pBytes = protowire.AppendTag(pBytes, 6, protowire.BytesType)
|
|
pBytes = protowire.AppendBytes(pBytes, tcBytes)
|
|
}
|
|
|
|
if p.ToolCallID != "" {
|
|
pBytes = protowire.AppendTag(pBytes, 7, protowire.BytesType)
|
|
pBytes = protowire.AppendString(pBytes, p.ToolCallID)
|
|
}
|
|
|
|
for _, img := range p.Images {
|
|
data := strings.TrimSpace(img.Base64Data)
|
|
if data == "" {
|
|
continue
|
|
}
|
|
var imgBytes []byte
|
|
imgBytes = protowire.AppendTag(imgBytes, 1, protowire.BytesType)
|
|
imgBytes = protowire.AppendString(imgBytes, data)
|
|
|
|
mime := strings.TrimSpace(img.MimeType)
|
|
if mime == "" {
|
|
mime = "image/png"
|
|
}
|
|
imgBytes = protowire.AppendTag(imgBytes, 2, protowire.BytesType)
|
|
imgBytes = protowire.AppendString(imgBytes, mime)
|
|
|
|
pBytes = protowire.AppendTag(pBytes, 10, protowire.BytesType)
|
|
pBytes = protowire.AppendBytes(pBytes, imgBytes)
|
|
}
|
|
|
|
if p.Thinking != "" {
|
|
pBytes = protowire.AppendTag(pBytes, 11, protowire.BytesType)
|
|
pBytes = protowire.AppendString(pBytes, p.Thinking)
|
|
}
|
|
|
|
if len(p.Signature) > 0 {
|
|
pBytes = protowire.AppendTag(pBytes, 12, protowire.BytesType)
|
|
pBytes = protowire.AppendBytes(pBytes, p.Signature)
|
|
}
|
|
|
|
if p.SignatureType != "" {
|
|
pBytes = protowire.AppendTag(pBytes, 18, protowire.BytesType)
|
|
pBytes = protowire.AppendString(pBytes, p.SignatureType)
|
|
}
|
|
|
|
reqBytes = protowire.AppendTag(reqBytes, 3, protowire.BytesType)
|
|
reqBytes = protowire.AppendBytes(reqBytes, pBytes)
|
|
}
|
|
|
|
// 4. Fixed flags (Field 7: Varint 5)
|
|
reqBytes = protowire.AppendTag(reqBytes, 7, protowire.VarintType)
|
|
reqBytes = protowire.AppendVarint(reqBytes, 5)
|
|
|
|
// 5. Completion config (Field 8)
|
|
var f8Bytes []byte
|
|
f8Bytes = protowire.AppendTag(f8Bytes, 1, protowire.VarintType)
|
|
f8Bytes = protowire.AppendVarint(f8Bytes, 1)
|
|
|
|
f8Bytes = protowire.AppendTag(f8Bytes, 2, protowire.VarintType)
|
|
f8Bytes = protowire.AppendVarint(f8Bytes, uint64(maxTokens))
|
|
|
|
f8Bytes = protowire.AppendTag(f8Bytes, 3, protowire.VarintType)
|
|
f8Bytes = protowire.AppendVarint(f8Bytes, 400)
|
|
|
|
tempVal := 1.0
|
|
if temperature != nil {
|
|
tempVal = *temperature
|
|
}
|
|
f8Bytes = protowire.AppendTag(f8Bytes, 5, protowire.Fixed64Type)
|
|
f8Bytes = protowire.AppendFixed64(f8Bytes, math.Float64bits(tempVal))
|
|
|
|
f8Bytes = protowire.AppendTag(f8Bytes, 7, protowire.VarintType)
|
|
f8Bytes = protowire.AppendVarint(f8Bytes, 40)
|
|
|
|
f8Bytes = protowire.AppendTag(f8Bytes, 8, protowire.Fixed64Type)
|
|
f8Bytes = protowire.AppendFixed64(f8Bytes, math.Float64bits(float64(float32(0.95))))
|
|
|
|
reqBytes = protowire.AppendTag(reqBytes, 8, protowire.BytesType)
|
|
reqBytes = protowire.AppendBytes(reqBytes, f8Bytes)
|
|
|
|
// 6. Repeated Tools (Field 10)
|
|
for _, tool := range tools {
|
|
var tBytes []byte
|
|
if tool.Name != "" {
|
|
tBytes = protowire.AppendTag(tBytes, 1, protowire.BytesType)
|
|
tBytes = protowire.AppendString(tBytes, tool.Name)
|
|
}
|
|
desc := tool.Description
|
|
if strings.Contains(desc, "Takes a task_id parameter identifying the task") {
|
|
desc = strings.ReplaceAll(desc, "Takes a task_id parameter identifying the task", "Takes a taskId parameter identifying the task")
|
|
}
|
|
if desc != "" {
|
|
tBytes = protowire.AppendTag(tBytes, 2, protowire.BytesType)
|
|
tBytes = protowire.AppendString(tBytes, desc)
|
|
}
|
|
if len(tool.Parameters) > 0 {
|
|
tBytes = protowire.AppendTag(tBytes, 3, protowire.BytesType)
|
|
tBytes = protowire.AppendBytes(tBytes, tool.Parameters)
|
|
}
|
|
reqBytes = protowire.AppendTag(reqBytes, 10, protowire.BytesType)
|
|
reqBytes = protowire.AppendBytes(reqBytes, tBytes)
|
|
}
|
|
|
|
// 7. Thread session metadata (Field 15)
|
|
// In native devin-cli:
|
|
// Field 1: sessionID (UUID string)
|
|
// Field 2: turnIndex (per-session request ordinal, omitted when 0)
|
|
// Field 3: 4 (varint)
|
|
// Field 4: 14 (emitted conditionally on user-turn boundaries)
|
|
turnIndex := NextDevinSessionTurnIndex(sessionID)
|
|
|
|
var f15Bytes []byte
|
|
f15Bytes = protowire.AppendTag(f15Bytes, 1, protowire.BytesType)
|
|
f15Bytes = protowire.AppendString(f15Bytes, sessionID)
|
|
|
|
if turnIndex > 0 {
|
|
f15Bytes = protowire.AppendTag(f15Bytes, 2, protowire.VarintType)
|
|
f15Bytes = protowire.AppendVarint(f15Bytes, uint64(turnIndex))
|
|
}
|
|
|
|
f15Bytes = protowire.AppendTag(f15Bytes, 3, protowire.VarintType)
|
|
f15Bytes = protowire.AppendVarint(f15Bytes, 4)
|
|
|
|
// In native devin-cli, Field 15.4=14 is emitted on user-turn boundaries
|
|
if len(prompts) > 0 && prompts[len(prompts)-1].Source == 1 {
|
|
if turnIndex == 0 || len(prompts) < 2 || prompts[len(prompts)-2].Source != 1 {
|
|
f15Bytes = protowire.AppendTag(f15Bytes, 4, protowire.VarintType)
|
|
f15Bytes = protowire.AppendVarint(f15Bytes, 14)
|
|
}
|
|
}
|
|
|
|
reqBytes = protowire.AppendTag(reqBytes, 15, protowire.BytesType)
|
|
reqBytes = protowire.AppendBytes(reqBytes, f15Bytes)
|
|
|
|
// 8. Cascade ID (Field 16: session-stable prompt cache key)
|
|
reqBytes = protowire.AppendTag(reqBytes, 16, protowire.BytesType)
|
|
reqBytes = protowire.AppendString(reqBytes, cascadeID)
|
|
|
|
// 9. Fixed flag (Field 20: Varint 1)
|
|
reqBytes = protowire.AppendTag(reqBytes, 20, protowire.VarintType)
|
|
reqBytes = protowire.AppendVarint(reqBytes, 1)
|
|
|
|
// 10. Model UID (Field 21)
|
|
reqBytes = protowire.AppendTag(reqBytes, 21, protowire.BytesType)
|
|
reqBytes = protowire.AppendString(reqBytes, chatModelUID)
|
|
|
|
return reqBytes
|
|
}
|
|
|
|
// ParseDevinFrame extracts deltas, tool calls, thinking, signatures, and usage from a single response frame.
|
|
func ParseDevinFrame(payload []byte) (DevinFrameResult, error) {
|
|
var res DevinFrameResult
|
|
var textParts []string
|
|
var thinkingParts []string
|
|
|
|
pos := 0
|
|
for pos < len(payload) {
|
|
num, typ, n := protowire.ConsumeTag(payload[pos:])
|
|
if n <= 0 {
|
|
return res, fmt.Errorf("consume tag error at offset %d: %w", pos, protowire.ParseError(n))
|
|
}
|
|
pos += n
|
|
|
|
switch typ {
|
|
case protowire.VarintType:
|
|
v, vn := protowire.ConsumeVarint(payload[pos:])
|
|
if vn <= 0 {
|
|
return res, fmt.Errorf("consume varint error at offset %d: %w", pos, protowire.ParseError(vn))
|
|
}
|
|
pos += vn
|
|
switch num {
|
|
case 2:
|
|
res.Timestamp = v
|
|
case 4:
|
|
res.DeltaTokens = v
|
|
case 5:
|
|
res.StopReason = v
|
|
}
|
|
|
|
case protowire.Fixed64Type:
|
|
v, fn := protowire.ConsumeFixed64(payload[pos:])
|
|
if fn <= 0 {
|
|
return res, fmt.Errorf("consume fixed64 error at offset %d: %w", pos, protowire.ParseError(fn))
|
|
}
|
|
pos += fn
|
|
if num == 12 {
|
|
res.Latency = math.Float64frombits(v)
|
|
}
|
|
|
|
case protowire.Fixed32Type:
|
|
_, fn := protowire.ConsumeFixed32(payload[pos:])
|
|
if fn <= 0 {
|
|
return res, fmt.Errorf("consume fixed32 error at offset %d: %w", pos, protowire.ParseError(fn))
|
|
}
|
|
pos += fn
|
|
|
|
case protowire.BytesType:
|
|
val, bn := protowire.ConsumeBytes(payload[pos:])
|
|
if bn <= 0 {
|
|
return res, fmt.Errorf("consume bytes error at offset %d: %w", pos, protowire.ParseError(bn))
|
|
}
|
|
pos += bn
|
|
|
|
switch num {
|
|
case 1:
|
|
res.OutputID = string(val)
|
|
case 2:
|
|
res.Timestamp = parseDevinTimestamp(val)
|
|
case 3:
|
|
textParts = append(textParts, string(val))
|
|
case 6:
|
|
if tc, err := parseDevinToolCallDelta(val); err == nil {
|
|
res.ToolCallDeltas = append(res.ToolCallDeltas, tc)
|
|
}
|
|
case 7:
|
|
res.Usage = parseDevinUsageField(val)
|
|
case 9:
|
|
thinkingParts = append(thinkingParts, string(val))
|
|
case 10:
|
|
res.DeltaSignature = append(res.DeltaSignature, val...)
|
|
case 17:
|
|
res.MessageID = string(val)
|
|
case 21:
|
|
res.DeltaSignatureType = string(val)
|
|
case 28:
|
|
res.ResponseDimensionGroups = val
|
|
default:
|
|
res.UnknownFieldNumbers = append(res.UnknownFieldNumbers, int(num))
|
|
}
|
|
|
|
default:
|
|
return res, fmt.Errorf("unsupported wire type %d at offset %d", typ, pos)
|
|
}
|
|
}
|
|
|
|
if len(textParts) > 0 {
|
|
res.ContentText = strings.Join(textParts, "")
|
|
}
|
|
if len(thinkingParts) > 0 {
|
|
res.ThinkingText = strings.Join(thinkingParts, "")
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// SanitizeDevinSystemPrompt strips Claude Code attribution and CLI identity headers
|
|
// (referencing Antigravity conventions), and applies zero-width obfuscation for configured sensitive words.
|
|
func SanitizeDevinSystemPrompt(prompt string, matcher *SensitiveWordMatcher) string {
|
|
if prompt == "" {
|
|
return ""
|
|
}
|
|
lines := strings.Split(prompt, "\n")
|
|
var kept []string
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if util.IsClaudeCodeAttributionSystemText(trimmed) {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(trimmed, "You are Claude Code") {
|
|
continue
|
|
}
|
|
if strings.Contains(trimmed, "authorized security testing") || strings.Contains(trimmed, "destructive techniques, DoS attacks") {
|
|
continue
|
|
}
|
|
if strings.Contains(trimmed, "Claude Code is available as a CLI") {
|
|
continue
|
|
}
|
|
if strings.Contains(trimmed, "Fast mode for Claude Code") {
|
|
continue
|
|
}
|
|
if matcher != nil && matcher.Matches(trimmed) {
|
|
continue
|
|
}
|
|
kept = append(kept, line)
|
|
}
|
|
res := strings.TrimSpace(strings.Join(kept, "\n"))
|
|
if matcher != nil && res != "" {
|
|
res = matcher.ObfuscateText(res)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func parseDevinToolCallDelta(data []byte) (DevinToolCallDelta, error) {
|
|
var tc DevinToolCallDelta
|
|
pos := 0
|
|
for pos < len(data) {
|
|
num, typ, n := protowire.ConsumeTag(data[pos:])
|
|
if n <= 0 {
|
|
break
|
|
}
|
|
pos += n
|
|
|
|
switch typ {
|
|
case protowire.VarintType:
|
|
v, vn := protowire.ConsumeVarint(data[pos:])
|
|
if vn <= 0 {
|
|
return tc, nil
|
|
}
|
|
pos += vn
|
|
if num == 4 {
|
|
tc.Index = int(v)
|
|
}
|
|
case protowire.BytesType:
|
|
val, bn := protowire.ConsumeBytes(data[pos:])
|
|
if bn <= 0 {
|
|
return tc, nil
|
|
}
|
|
pos += bn
|
|
switch num {
|
|
case 1:
|
|
tc.ID = string(val)
|
|
case 2:
|
|
tc.Name = string(val)
|
|
case 3:
|
|
tc.Arguments = string(val)
|
|
}
|
|
default:
|
|
return tc, nil
|
|
}
|
|
}
|
|
return tc, nil
|
|
}
|
|
|
|
func parseDevinTimestamp(data []byte) uint64 {
|
|
pos := 0
|
|
var secs uint64
|
|
for pos < len(data) {
|
|
num, typ, n := protowire.ConsumeTag(data[pos:])
|
|
if n <= 0 {
|
|
break
|
|
}
|
|
pos += n
|
|
if typ == protowire.VarintType {
|
|
v, vn := protowire.ConsumeVarint(data[pos:])
|
|
if vn <= 0 {
|
|
break
|
|
}
|
|
pos += vn
|
|
if num == 1 {
|
|
secs = v
|
|
}
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return secs
|
|
}
|
|
|
|
func parseDevinUsageField(data []byte) *DevinUsage {
|
|
u := &DevinUsage{}
|
|
pos := 0
|
|
for pos < len(data) {
|
|
num, typ, n := protowire.ConsumeTag(data[pos:])
|
|
if n <= 0 {
|
|
break
|
|
}
|
|
pos += n
|
|
|
|
switch typ {
|
|
case protowire.VarintType:
|
|
v, vn := protowire.ConsumeVarint(data[pos:])
|
|
if vn <= 0 {
|
|
return u
|
|
}
|
|
pos += vn
|
|
switch num {
|
|
case 2: // Prompt tokens (uncached input)
|
|
u.PromptTokens = int64(v)
|
|
case 3: // Output tokens
|
|
u.CompletionTokens = int64(v)
|
|
case 5: // Cache read tokens
|
|
u.CachedTokens = int64(v)
|
|
case 6: // Status code
|
|
u.StatusCode = v
|
|
}
|
|
case protowire.BytesType:
|
|
val, bn := protowire.ConsumeBytes(data[pos:])
|
|
if bn <= 0 {
|
|
return u
|
|
}
|
|
pos += bn
|
|
switch num {
|
|
case 8:
|
|
u.RequestID = string(val)
|
|
case 9:
|
|
u.ModelName = string(val)
|
|
}
|
|
case protowire.Fixed64Type:
|
|
_, fn := protowire.ConsumeFixed64(data[pos:])
|
|
if fn <= 0 {
|
|
return u
|
|
}
|
|
pos += fn
|
|
case protowire.Fixed32Type:
|
|
_, fn := protowire.ConsumeFixed32(data[pos:])
|
|
if fn <= 0 {
|
|
return u
|
|
}
|
|
pos += fn
|
|
default:
|
|
return u
|
|
}
|
|
}
|
|
return u
|
|
}
|
|
|
|
// ParseDevinTrailerError inspects Connect-RPC EOS trailer frames and maps error status codes.
|
|
func ParseDevinTrailerError(payload []byte) (statusCode int, err error) {
|
|
trimmed := bytes.TrimSpace(payload)
|
|
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("{}")) {
|
|
return 0, nil
|
|
}
|
|
var trailer struct {
|
|
Error *struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if errUnmarshal := json.Unmarshal(trimmed, &trailer); errUnmarshal != nil || trailer.Error == nil {
|
|
return 0, nil
|
|
}
|
|
|
|
codeStr := strings.ToLower(trailer.Error.Code)
|
|
msgLower := strings.ToLower(trailer.Error.Message)
|
|
|
|
httpCode := http.StatusInternalServerError
|
|
switch codeStr {
|
|
case "invalid_argument":
|
|
httpCode = http.StatusBadRequest
|
|
case "unauthenticated":
|
|
httpCode = http.StatusUnauthorized
|
|
case "permission_denied":
|
|
httpCode = http.StatusForbidden
|
|
case "resource_exhausted":
|
|
httpCode = http.StatusTooManyRequests
|
|
case "unavailable":
|
|
httpCode = http.StatusServiceUnavailable
|
|
case "failed_precondition":
|
|
if strings.Contains(msgLower, "quota") ||
|
|
strings.Contains(msgLower, "credit") ||
|
|
strings.Contains(msgLower, "acu") ||
|
|
strings.Contains(msgLower, "exhausted") ||
|
|
strings.Contains(msgLower, "limit") {
|
|
httpCode = http.StatusTooManyRequests
|
|
} else {
|
|
httpCode = http.StatusBadRequest
|
|
}
|
|
}
|
|
|
|
return httpCode, fmt.Errorf("devin upstream error (%s): %s", trailer.Error.Code, trailer.Error.Message)
|
|
}
|
|
|
|
// UTF8SplitBuffer buffers incomplete UTF-8 byte sequences across chunk boundaries.
|
|
type UTF8SplitBuffer struct {
|
|
remainder []byte
|
|
}
|
|
|
|
// Feed consumes a byte chunk, prepending any pending remainder, and returns complete UTF-8 strings.
|
|
func (b *UTF8SplitBuffer) Feed(chunk []byte) string {
|
|
combined := append(b.remainder, chunk...)
|
|
b.remainder = nil
|
|
|
|
if len(combined) == 0 {
|
|
return ""
|
|
}
|
|
|
|
validUntil := 0
|
|
for validUntil < len(combined) {
|
|
r, size := utf8.DecodeRune(combined[validUntil:])
|
|
if r == utf8.RuneError && size == 1 {
|
|
trailingLen := len(combined) - validUntil
|
|
if trailingLen < utf8.UTFMax && !utf8.FullRune(combined[validUntil:]) {
|
|
break
|
|
}
|
|
validUntil++
|
|
continue
|
|
}
|
|
validUntil += size
|
|
}
|
|
|
|
validBytes := combined[:validUntil]
|
|
b.remainder = append(b.remainder, combined[validUntil:]...)
|
|
|
|
return string(validBytes)
|
|
}
|
|
|
|
// DevinUpstreamRequestLog represents the human-readable JSON representation of GetChatMessageRequest for request logs.
|
|
type DevinUpstreamRequestLog struct {
|
|
Model string `json:"model"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
CascadeID string `json:"cascade_id,omitempty"`
|
|
SystemPrompt string `json:"system_prompt,omitempty"`
|
|
Temperature *float64 `json:"temperature,omitempty"`
|
|
MaxTokens int `json:"max_tokens,omitempty"`
|
|
Prompts []DevinPromptLogItem `json:"prompts,omitempty"`
|
|
Tools []DevinToolLogItem `json:"tools,omitempty"`
|
|
}
|
|
|
|
// DevinPromptLogItem represents a single prompt item in the request log.
|
|
type DevinPromptLogItem struct {
|
|
ID string `json:"id,omitempty"`
|
|
Source int `json:"source"`
|
|
Role string `json:"role,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Thinking string `json:"thinking,omitempty"`
|
|
Signature string `json:"signature,omitempty"`
|
|
SignatureType string `json:"signature_type,omitempty"`
|
|
ToolCalls []DevinToolCall `json:"tool_calls,omitempty"`
|
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
Images []DevinImageLogItem `json:"images,omitempty"`
|
|
}
|
|
|
|
// DevinImageLogItem represents an image attached to a prompt in the request log.
|
|
type DevinImageLogItem struct {
|
|
MimeType string `json:"mime_type"`
|
|
DataLen int `json:"data_len"`
|
|
}
|
|
|
|
// DevinToolLogItem represents a tool declared in the request log.
|
|
type DevinToolLogItem struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Parameters json.RawMessage `json:"parameters,omitempty"`
|
|
}
|
|
|
|
func formatSignatureForLog(sig []byte) string {
|
|
if len(sig) == 0 {
|
|
return ""
|
|
}
|
|
if bytes.HasPrefix(sig, []byte("sealed.v1.")) {
|
|
return string(sig)
|
|
}
|
|
if utf8.Valid(sig) && isPrintableASCII(sig) {
|
|
return string(sig)
|
|
}
|
|
return base64.StdEncoding.EncodeToString(sig)
|
|
}
|
|
|
|
func isPrintableASCII(b []byte) bool {
|
|
for _, c := range b {
|
|
if c < 32 || c > 126 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// BuildDevinUpstreamLogBody formats the intermediate interactions and the decoded Devin request
|
|
// into a clear, aligned log body without synthetic wrapping.
|
|
func BuildDevinUpstreamLogBody(
|
|
interactionsPayload []byte,
|
|
isInteractionsSource bool,
|
|
chatModelUID string,
|
|
systemPrompt string,
|
|
prompts []DevinPrompt,
|
|
tools []DevinTool,
|
|
temp *float64,
|
|
maxTokens int,
|
|
sessionID string,
|
|
cascadeID string,
|
|
) []byte {
|
|
var promptItems []DevinPromptLogItem
|
|
for _, p := range prompts {
|
|
role := "user"
|
|
switch p.Source {
|
|
case 2:
|
|
role = "assistant"
|
|
case 4:
|
|
role = "tool"
|
|
}
|
|
var imgItems []DevinImageLogItem
|
|
for _, img := range p.Images {
|
|
imgItems = append(imgItems, DevinImageLogItem{
|
|
MimeType: img.MimeType,
|
|
DataLen: len(img.Base64Data),
|
|
})
|
|
}
|
|
promptItems = append(promptItems, DevinPromptLogItem{
|
|
ID: p.MessageID,
|
|
Source: p.Source,
|
|
Role: role,
|
|
Content: p.Content,
|
|
Thinking: p.Thinking,
|
|
Signature: formatSignatureForLog(p.Signature),
|
|
SignatureType: p.SignatureType,
|
|
ToolCalls: p.ToolCalls,
|
|
ToolCallID: p.ToolCallID,
|
|
Images: imgItems,
|
|
})
|
|
}
|
|
|
|
var toolItems []DevinToolLogItem
|
|
for _, t := range tools {
|
|
var params json.RawMessage
|
|
if len(t.Parameters) > 0 && json.Valid(t.Parameters) {
|
|
params = json.RawMessage(t.Parameters)
|
|
}
|
|
toolItems = append(toolItems, DevinToolLogItem{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
Parameters: params,
|
|
})
|
|
}
|
|
|
|
devinReq := DevinUpstreamRequestLog{
|
|
Model: chatModelUID,
|
|
SessionID: sessionID,
|
|
CascadeID: cascadeID,
|
|
SystemPrompt: systemPrompt,
|
|
Temperature: temp,
|
|
MaxTokens: maxTokens,
|
|
Prompts: promptItems,
|
|
Tools: toolItems,
|
|
}
|
|
|
|
devinReqJSON, errDevin := json.MarshalIndent(devinReq, "", " ")
|
|
if errDevin != nil {
|
|
devinReqJSON = []byte(fmt.Sprintf(`{"model": %q}`, chatModelUID))
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if !isInteractionsSource && len(interactionsPayload) > 0 {
|
|
buf.WriteString("=== INTERMEDIATE INTERACTIONS ===\n")
|
|
var prettyInteractions bytes.Buffer
|
|
if err := json.Indent(&prettyInteractions, interactionsPayload, "", " "); err == nil {
|
|
buf.Write(prettyInteractions.Bytes())
|
|
} else {
|
|
buf.Write(interactionsPayload)
|
|
}
|
|
buf.WriteString("\n\n=== DEVIN UPSTREAM REQUEST ===\n")
|
|
buf.Write(devinReqJSON)
|
|
} else {
|
|
buf.Write(devinReqJSON)
|
|
}
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// DevinUpstreamResponseLog represents the decoded response frames from Devin Connect-RPC.
|
|
type DevinUpstreamResponseLog struct {
|
|
Status string `json:"status,omitempty"`
|
|
FramesCount int `json:"frames_count"`
|
|
Content string `json:"content,omitempty"`
|
|
Thinking string `json:"thinking,omitempty"`
|
|
Signature string `json:"signature,omitempty"`
|
|
SignatureType string `json:"signature_type,omitempty"`
|
|
ToolCalls []DevinToolCall `json:"tool_calls,omitempty"`
|
|
Usage *DevinUsage `json:"usage,omitempty"`
|
|
UnknownFields []int `json:"unknown_fields,omitempty"`
|
|
}
|
|
|
|
// BuildDevinUpstreamResponseLogBody formats the decoded Devin response and the intermediate
|
|
// interactions into a clear, aligned log body.
|
|
func BuildDevinUpstreamResponseLogBody(respLog *DevinUpstreamResponseLog, interactionsJSON []byte) []byte {
|
|
var buf bytes.Buffer
|
|
if respLog != nil {
|
|
if len(respLog.Signature) > 0 {
|
|
respLog.Signature = formatSignatureForLog([]byte(respLog.Signature))
|
|
}
|
|
respJSON, err := json.MarshalIndent(respLog, "", " ")
|
|
if err == nil && len(respJSON) > 0 {
|
|
buf.WriteString("=== DEVIN UPSTREAM RESPONSE ===\n")
|
|
buf.Write(respJSON)
|
|
buf.WriteString("\n\n")
|
|
}
|
|
}
|
|
if len(interactionsJSON) > 0 {
|
|
buf.WriteString("=== INTERMEDIATE INTERACTIONS ===\n")
|
|
var pretty bytes.Buffer
|
|
if err := json.Indent(&pretty, interactionsJSON, "", " "); err == nil {
|
|
buf.Write(pretty.Bytes())
|
|
} else {
|
|
buf.Write(interactionsJSON)
|
|
}
|
|
}
|
|
return buf.Bytes()
|
|
}
|