mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-04 06:41:39 +08:00
fix(devin): align wire protocol, harden streaming, and resolve multi-turn tool/signature parity
- 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()
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -289,7 +290,7 @@ func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
errMsg = "missing authorization code"
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(loginFailureHTML, errMsg)))
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(loginFailureHTML, html.EscapeString(errMsg))))
|
||||
select {
|
||||
case s.resultChan <- &OAuthResult{Error: errMsg}:
|
||||
default:
|
||||
|
||||
@@ -2,6 +2,7 @@ package devin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -40,9 +41,15 @@ type DevinUserStatus struct {
|
||||
PlanEnd time.Time `json:"plan_end,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateDeviceFingerprint generates a stable 732-character hex device fingerprint.
|
||||
// GenerateDeviceFingerprint generates a 732-character hex device fingerprint.
|
||||
// When seed is empty, it generates a cryptographically random 732-character hex string per request.
|
||||
// When seed is provided, it derives a deterministic 732-character hex fingerprint.
|
||||
func GenerateDeviceFingerprint(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
|
||||
@@ -346,6 +353,8 @@ func (s *DevinAuthService) FetchUserStatus(ctx context.Context, sessionToken, de
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Basic %s-%s", sessionToken, sessionToken))
|
||||
req.Header.Set("Connect-Protocol-Version", "1")
|
||||
req.Header.Set("Content-Type", "application/proto")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header["User-Agent"] = []string{""}
|
||||
|
||||
resp, errDo := s.client.Do(req)
|
||||
if errDo != nil {
|
||||
|
||||
@@ -193,6 +193,9 @@ func TestFetchUserStatusLiveMock(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLiveDevinUserStatus(t *testing.T) {
|
||||
if os.Getenv("CPA_LIVE_TEST") != "true" {
|
||||
t.Skip("skipping live test; set CPA_LIVE_TEST=true to run")
|
||||
}
|
||||
authPath := "../../../auths/devin-cli.json"
|
||||
data, err := os.ReadFile(authPath)
|
||||
if err != nil {
|
||||
|
||||
18
internal/cache/bounded_lru.go
vendored
18
internal/cache/bounded_lru.go
vendored
@@ -76,6 +76,24 @@ func (cache *BoundedLRU[K, V]) Get(key K) (V, bool) {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
func (cache *BoundedLRU[K, V]) Delete(key K) bool {
|
||||
cache.mu.Lock()
|
||||
element, ok := cache.entries[key]
|
||||
if !ok {
|
||||
cache.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
entry := element.Value.(boundedLRUEntry[K, V])
|
||||
delete(cache.entries, key)
|
||||
cache.order.Remove(element)
|
||||
cache.mu.Unlock()
|
||||
|
||||
if cache.onEvict != nil {
|
||||
cache.onEvict(entry.key, entry.value)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (cache *BoundedLRU[K, V]) Len() int {
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"owned_by": "cognition",
|
||||
"display_name": "SWE-2",
|
||||
"context_length": 262000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -30,7 +30,7 @@
|
||||
"owned_by": "cognition",
|
||||
"display_name": "SWE-1.7",
|
||||
"context_length": 262000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -52,7 +52,7 @@
|
||||
"owned_by": "cognition",
|
||||
"display_name": "SWE-1.6",
|
||||
"context_length": 200000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text"
|
||||
],
|
||||
@@ -67,7 +67,7 @@
|
||||
"owned_by": "zhipu",
|
||||
"display_name": "GLM-5.2",
|
||||
"context_length": 200000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text"
|
||||
],
|
||||
@@ -88,7 +88,7 @@
|
||||
"owned_by": "zhipu",
|
||||
"display_name": "GLM-5.3",
|
||||
"context_length": 1048576,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text"
|
||||
],
|
||||
@@ -110,7 +110,7 @@
|
||||
"owned_by": "zhipu",
|
||||
"display_name": "GLM-5.3 Flash",
|
||||
"context_length": 1000000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text"
|
||||
],
|
||||
@@ -132,7 +132,7 @@
|
||||
"owned_by": "deepseek",
|
||||
"display_name": "DeepSeek V4 Flash",
|
||||
"context_length": 1048576,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text"
|
||||
],
|
||||
@@ -153,7 +153,7 @@
|
||||
"owned_by": "deepseek",
|
||||
"display_name": "DeepSeek V4.1 Flash",
|
||||
"context_length": 1048576,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text"
|
||||
],
|
||||
@@ -174,7 +174,7 @@
|
||||
"owned_by": "deepseek",
|
||||
"display_name": "DeepSeek V4 Pro",
|
||||
"context_length": 1048576,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text"
|
||||
],
|
||||
@@ -195,7 +195,7 @@
|
||||
"owned_by": "google",
|
||||
"display_name": "Gemini 3.8 Flash",
|
||||
"context_length": 1048576,
|
||||
"max_completion_tokens": 65536,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -218,7 +218,7 @@
|
||||
"owned_by": "google",
|
||||
"display_name": "Gemini 3.7 Flash",
|
||||
"context_length": 1048576,
|
||||
"max_completion_tokens": 65536,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -240,7 +240,7 @@
|
||||
"owned_by": "google",
|
||||
"display_name": "Gemini 3.6 Flash",
|
||||
"context_length": 1048576,
|
||||
"max_completion_tokens": 65536,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -264,7 +264,7 @@
|
||||
"owned_by": "google",
|
||||
"display_name": "Gemini 3.5 Flash",
|
||||
"context_length": 1048576,
|
||||
"max_completion_tokens": 65536,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -288,7 +288,7 @@
|
||||
"owned_by": "google",
|
||||
"display_name": "Gemini 3 Flash",
|
||||
"context_length": 1048576,
|
||||
"max_completion_tokens": 65536,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -312,7 +312,7 @@
|
||||
"owned_by": "anthropic",
|
||||
"display_name": "Claude Fable 5.1",
|
||||
"context_length": 1000000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -337,7 +337,7 @@
|
||||
"owned_by": "anthropic",
|
||||
"display_name": "Claude 5 Fable",
|
||||
"context_length": 1000000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -362,7 +362,7 @@
|
||||
"owned_by": "anthropic",
|
||||
"display_name": "Claude Sonnet 5",
|
||||
"context_length": 1000000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -387,7 +387,7 @@
|
||||
"owned_by": "anthropic",
|
||||
"display_name": "Claude Opus 5",
|
||||
"context_length": 1000000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -412,7 +412,7 @@
|
||||
"owned_by": "anthropic",
|
||||
"display_name": "Claude Opus 4.8",
|
||||
"context_length": 1000000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -437,7 +437,7 @@
|
||||
"owned_by": "anthropic",
|
||||
"display_name": "Claude Opus 4.7",
|
||||
"context_length": 1000000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -462,7 +462,7 @@
|
||||
"owned_by": "anthropic",
|
||||
"display_name": "Claude Opus 4.6",
|
||||
"context_length": 200000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -478,7 +478,7 @@
|
||||
"owned_by": "anthropic",
|
||||
"display_name": "Claude Sonnet 4.6",
|
||||
"context_length": 200000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -494,7 +494,7 @@
|
||||
"owned_by": "anthropic",
|
||||
"display_name": "Claude Haiku 4.5",
|
||||
"context_length": 200000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -510,7 +510,7 @@
|
||||
"owned_by": "anthropic",
|
||||
"display_name": "Claude Sonnet 4.5",
|
||||
"context_length": 200000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -526,7 +526,7 @@
|
||||
"owned_by": "openai",
|
||||
"display_name": "GPT-6 Astra",
|
||||
"context_length": 1000000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -551,7 +551,7 @@
|
||||
"owned_by": "openai",
|
||||
"display_name": "GPT-5.6 Sol",
|
||||
"context_length": 1000000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -577,7 +577,7 @@
|
||||
"owned_by": "openai",
|
||||
"display_name": "GPT-5.6 Terra",
|
||||
"context_length": 1000000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -603,7 +603,7 @@
|
||||
"owned_by": "openai",
|
||||
"display_name": "GPT-5.6 Luna",
|
||||
"context_length": 1000000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -629,7 +629,7 @@
|
||||
"owned_by": "openai",
|
||||
"display_name": "GPT-5.5",
|
||||
"context_length": 272000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -654,7 +654,7 @@
|
||||
"owned_by": "openai",
|
||||
"display_name": "GPT-5.4",
|
||||
"context_length": 272000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -679,7 +679,7 @@
|
||||
"owned_by": "openai",
|
||||
"display_name": "GPT-5.4 Mini",
|
||||
"context_length": 400000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -703,7 +703,7 @@
|
||||
"owned_by": "openai",
|
||||
"display_name": "GPT-5.3-Codex",
|
||||
"context_length": 400000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -727,7 +727,7 @@
|
||||
"owned_by": "openai",
|
||||
"display_name": "GPT-4.1",
|
||||
"context_length": 1047576,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text",
|
||||
"image"
|
||||
@@ -790,7 +790,7 @@
|
||||
"owned_by": "moonshot",
|
||||
"display_name": "Kimi K3",
|
||||
"context_length": 1048576,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text"
|
||||
],
|
||||
@@ -812,7 +812,7 @@
|
||||
"owned_by": "moonshot",
|
||||
"display_name": "Kimi K2.7",
|
||||
"context_length": 262144,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text"
|
||||
],
|
||||
@@ -827,7 +827,7 @@
|
||||
"owned_by": "moonshot",
|
||||
"display_name": "Kimi K2.6",
|
||||
"context_length": 262144,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text"
|
||||
],
|
||||
@@ -842,7 +842,7 @@
|
||||
"owned_by": "nvidia",
|
||||
"display_name": "Nemotron 3 Ultra",
|
||||
"context_length": 1000000,
|
||||
"max_completion_tokens": 64000,
|
||||
"max_completion_tokens": 128000,
|
||||
"supportedInputModalities": [
|
||||
"text"
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -97,7 +98,15 @@ func (e *DevinExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Aut
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/connect+proto")
|
||||
req.Header.Set("Connect-Protocol-Version", "1")
|
||||
req.Header.Set("User-Agent", "connect-go/1.19.1 (go1.25.0)")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
// Native devin-cli attaches Sentry-Trace only to chat streaming, omitting it on unary status/catalog calls.
|
||||
isUnary := req.URL != nil && (strings.Contains(req.URL.Path, "GetUserStatus") || strings.Contains(req.URL.Path, "GetCliModelConfigs") || strings.Contains(req.URL.Path, "SeatManagementService"))
|
||||
if !isUnary && req.Header.Get("Sentry-Trace") == "" {
|
||||
req.Header.Set("Sentry-Trace", helps.GenerateDevinSentryTrace())
|
||||
}
|
||||
// Native devin-cli suppresses User-Agent header entirely on the wire.
|
||||
// In Go net/http, setting the header slice to empty string suppresses default Go-http-client injection.
|
||||
req.Header["User-Agent"] = []string{""}
|
||||
|
||||
var attrs map[string]string
|
||||
if auth != nil {
|
||||
@@ -119,7 +128,7 @@ func (e *DevinExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth
|
||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
httpClient := helps.NewDevinHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
@@ -147,7 +156,7 @@ func (e *DevinExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
httpClient := helps.NewDevinHTTPClient(ctx, e.cfg, auth, 0)
|
||||
authService := devinauth.NewDevinAuthService(httpClient)
|
||||
if baseURL := strings.TrimSpace(auth.Attributes["base_url"]); baseURL != "" {
|
||||
authService.SetServerBaseURL(baseURL)
|
||||
@@ -276,7 +285,7 @@ func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
||||
AuthValue: authValue,
|
||||
})
|
||||
|
||||
httpClient := reporter.TrackHTTPClient(helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0))
|
||||
httpClient := reporter.TrackHTTPClient(helps.NewDevinHTTPClient(ctx, e.cfg, auth, 0))
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
|
||||
@@ -288,7 +297,7 @@ func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
||||
|
||||
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
errData, _ := io.ReadAll(httpResp.Body)
|
||||
errData, _ := io.ReadAll(io.LimitReader(httpResp.Body, 1<<20))
|
||||
helps.AppendAPIResponseChunk(ctx, e.cfg, errData)
|
||||
return resp, newDevinStatusError(httpResp.StatusCode, httpResp.Header, errData)
|
||||
}
|
||||
@@ -340,7 +349,7 @@ func (e *DevinExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
AuthValue: authValue,
|
||||
})
|
||||
|
||||
httpClient := reporter.TrackHTTPClient(helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0))
|
||||
httpClient := reporter.TrackHTTPClient(helps.NewDevinHTTPClient(ctx, e.cfg, auth, 0))
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
|
||||
@@ -348,10 +357,10 @@ func (e *DevinExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
}
|
||||
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
errData, _ := io.ReadAll(httpResp.Body)
|
||||
errData, _ := io.ReadAll(io.LimitReader(httpResp.Body, 1<<20))
|
||||
_ = httpResp.Body.Close()
|
||||
helps.AppendAPIResponseChunk(ctx, e.cfg, errData)
|
||||
return nil, statusErr{code: httpResp.StatusCode, msg: string(errData)}
|
||||
return nil, newDevinStatusError(httpResp.StatusCode, httpResp.Header, errData)
|
||||
}
|
||||
|
||||
out := make(chan cliproxyexecutor.StreamChunk)
|
||||
@@ -359,6 +368,11 @@ func (e *DevinExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
|
||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||
|
||||
go func() {
|
||||
<-streamCtx.Done()
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer close(out)
|
||||
defer cancelStream()
|
||||
@@ -382,9 +396,6 @@ func (e *DevinExecutor) prepareDevinHTTPRequest(ctx context.Context, auth *clipr
|
||||
if apiKey == "" {
|
||||
return nil, "", nil, fmt.Errorf("devin credentials missing: api_key or session_token required")
|
||||
}
|
||||
if deviceSeed == "" {
|
||||
deviceSeed = apiKey
|
||||
}
|
||||
|
||||
payload := req.Payload
|
||||
isInteractionsSource := opts.SourceFormat == "" || opts.SourceFormat == sdktranslator.FormatInteractions
|
||||
@@ -394,7 +405,8 @@ func (e *DevinExecutor) prepareDevinHTTPRequest(ctx context.Context, auth *clipr
|
||||
systemPrompt, prompts, tools, temp, maxTokens, sessionID, cascadeID, thinkingLevel, budgetTokens := parseInteractionsPayload(payload, opts.OriginalRequest)
|
||||
sessionID, cascadeID = resolveDevinSessionAndCascadeIDs(ctx, sessionID, cascadeID, opts)
|
||||
|
||||
if modelInfo := registry.LookupModelInfo(req.Model, "devin"); modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {
|
||||
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||
if modelInfo := registry.LookupModelInfo(baseModel, "devin"); modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {
|
||||
if maxTokens > modelInfo.MaxCompletionTokens || maxTokens <= 0 {
|
||||
maxTokens = modelInfo.MaxCompletionTokens
|
||||
}
|
||||
@@ -469,7 +481,7 @@ func (e *DevinExecutor) streamDevinFrames(
|
||||
stepIndex := 0
|
||||
thoughtStarted := false
|
||||
contentStarted := false
|
||||
currentToolCallActive := false
|
||||
toolCallSteps := make(map[int]int) // maps tc.Index -> stepIndex
|
||||
thinkingBuf := &helps.UTF8SplitBuffer{}
|
||||
contentBuf := &helps.UTF8SplitBuffer{}
|
||||
var accumulatedThinking strings.Builder
|
||||
@@ -533,15 +545,17 @@ func (e *DevinExecutor) streamDevinFrames(
|
||||
}
|
||||
|
||||
thoughtStepIndex := -1
|
||||
var streamErr error
|
||||
|
||||
// 2. Consume streaming Connect-proto frames
|
||||
for {
|
||||
flag, payload, errRead := helps.ReadConnectFrame(body)
|
||||
if errRead != nil {
|
||||
if errors.Is(errRead, io.EOF) || errors.Is(errRead, io.ErrUnexpectedEOF) {
|
||||
if errors.Is(errRead, io.EOF) || errors.Is(errRead, net.ErrClosed) || ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
log.Debugf("devin executor: read connect frame error: %v", errRead)
|
||||
streamErr = errRead
|
||||
log.Warnf("devin executor: stream read error: %v", errRead)
|
||||
break
|
||||
}
|
||||
streamFrameCount++
|
||||
@@ -551,6 +565,7 @@ func (e *DevinExecutor) streamDevinFrames(
|
||||
code, errTrailer := helps.ParseDevinTrailerError(payload)
|
||||
if errTrailer != nil {
|
||||
log.Warnf("devin executor: trailer error (%d): %v", code, errTrailer)
|
||||
helps.RecordAPIResponseError(ctx, e.cfg, errTrailer)
|
||||
failedEvent, _ := sjson.SetBytes([]byte(`{"event_type":"response.failed","error":{"message":"","code":""}}`), "error.message", errTrailer.Error())
|
||||
failedEvent, _ = sjson.SetBytes(failedEvent, "error.code", fmt.Sprintf("%d", code))
|
||||
_ = emitInteractionsEvent(failedEvent)
|
||||
@@ -600,14 +615,23 @@ func (e *DevinExecutor) streamDevinFrames(
|
||||
|
||||
// Emit thinking signature delta targeting the thought step
|
||||
if len(frameRes.DeltaSignature) > 0 {
|
||||
targetIdx := thoughtStepIndex
|
||||
if targetIdx < 0 {
|
||||
targetIdx = 0
|
||||
if !thoughtStarted {
|
||||
thoughtStepIndex = stepIndex
|
||||
startEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"thought"}}`), "index", stepIndex)
|
||||
if !emitInteractionsEvent(startEvent) {
|
||||
return
|
||||
}
|
||||
thoughtStarted = true
|
||||
}
|
||||
sigEvent := []byte(`{"event_type":"step.delta","index":0,"delta":{"type":"thought_signature","signature":""}}`)
|
||||
sigEvent, _ = sjson.SetBytes(sigEvent, "index", targetIdx)
|
||||
sigEvent, _ = sjson.SetBytes(sigEvent, "index", thoughtStepIndex)
|
||||
sigEvent, _ = sjson.SetBytes(sigEvent, "delta.signature", string(frameRes.DeltaSignature))
|
||||
_ = emitInteractionsEvent(sigEvent)
|
||||
if frameRes.DeltaSignatureType != "" {
|
||||
sigEvent, _ = sjson.SetBytes(sigEvent, "delta.signature_type", frameRes.DeltaSignatureType)
|
||||
}
|
||||
if !emitInteractionsEvent(sigEvent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Emit content text delta
|
||||
@@ -653,23 +677,22 @@ func (e *DevinExecutor) streamDevinFrames(
|
||||
stepIndex++
|
||||
}
|
||||
|
||||
if tc.Name != "" || tc.ID != "" {
|
||||
if currentToolCallActive {
|
||||
stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
|
||||
_ = emitInteractionsEvent(stopEvent)
|
||||
stepIndex++
|
||||
}
|
||||
startEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"function_call","name":"","id":"","call_id":"","arguments":{}}}`), "index", stepIndex)
|
||||
sIdx, exists := toolCallSteps[tc.Index]
|
||||
if !exists {
|
||||
sIdx = stepIndex
|
||||
stepIndex++
|
||||
toolCallSteps[tc.Index] = sIdx
|
||||
startEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"function_call","name":"","id":"","call_id":"","arguments":{}}}`), "index", sIdx)
|
||||
startEvent, _ = sjson.SetBytes(startEvent, "step.name", tc.Name)
|
||||
startEvent, _ = sjson.SetBytes(startEvent, "step.id", tc.ID)
|
||||
startEvent, _ = sjson.SetBytes(startEvent, "step.call_id", tc.ID)
|
||||
if !emitInteractionsEvent(startEvent) {
|
||||
return
|
||||
}
|
||||
currentToolCallActive = true
|
||||
}
|
||||
|
||||
if tc.Arguments != "" {
|
||||
deltaEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":""}}`), "index", stepIndex)
|
||||
deltaEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":""}}`), "index", sIdx)
|
||||
deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.arguments", tc.Arguments)
|
||||
if !emitInteractionsEvent(deltaEvent) {
|
||||
return
|
||||
@@ -678,26 +701,23 @@ func (e *DevinExecutor) streamDevinFrames(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Emit accumulated signature if present
|
||||
if len(accumulatedSignature) > 0 {
|
||||
targetIdx := thoughtStepIndex
|
||||
if targetIdx < 0 {
|
||||
targetIdx = 0
|
||||
}
|
||||
sigBase64 := base64.StdEncoding.EncodeToString(accumulatedSignature)
|
||||
sigEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"thought_signature","signature":""}}`), "index", targetIdx)
|
||||
sigEvent, _ = sjson.SetBytes(sigEvent, "delta.signature", sigBase64)
|
||||
if signatureType != "" {
|
||||
sigEvent, _ = sjson.SetBytes(sigEvent, "delta.signature_type", signatureType)
|
||||
}
|
||||
_ = emitInteractionsEvent(sigEvent)
|
||||
}
|
||||
|
||||
// 4. Close open steps
|
||||
if thoughtStarted || contentStarted || currentToolCallActive {
|
||||
// 3. Close open steps
|
||||
if thoughtStarted || contentStarted {
|
||||
stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
|
||||
_ = emitInteractionsEvent(stopEvent)
|
||||
}
|
||||
for _, sIdx := range toolCallSteps {
|
||||
stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", sIdx)
|
||||
_ = emitInteractionsEvent(stopEvent)
|
||||
}
|
||||
|
||||
// If stream encountered an abnormal read error mid-flight, record failure and emit response.failed
|
||||
if streamErr != nil && ctx.Err() == nil {
|
||||
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
|
||||
failedEvent, _ := sjson.SetBytes([]byte(`{"event_type":"response.failed","error":{"message":"","code":"stream_read_error"}}`), "error.message", streamErr.Error())
|
||||
_ = emitInteractionsEvent(failedEvent)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Emit interaction.completed with final usage
|
||||
completedEvent := []byte(`{"event_type":"interaction.completed","interaction":{"id":"","model":"","status":"completed","usage":{"total_input_tokens":0,"total_output_tokens":0,"total_cached_tokens":0}}}`)
|
||||
@@ -775,7 +795,7 @@ func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string
|
||||
for {
|
||||
flag, payload, errRead := helps.ReadConnectFrame(body)
|
||||
if errRead != nil {
|
||||
if errors.Is(errRead, io.EOF) || errors.Is(errRead, io.ErrUnexpectedEOF) {
|
||||
if errors.Is(errRead, io.EOF) {
|
||||
break
|
||||
}
|
||||
respLog := &helps.DevinUpstreamResponseLog{
|
||||
@@ -840,14 +860,18 @@ func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string
|
||||
textParts = append(textParts, frameRes.ContentText)
|
||||
}
|
||||
for _, tc := range frameRes.ToolCallDeltas {
|
||||
if tc.Name != "" || tc.ID != "" {
|
||||
toolCalls = append(toolCalls, helps.DevinToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Name,
|
||||
Arguments: tc.Arguments,
|
||||
})
|
||||
} else if len(toolCalls) > 0 {
|
||||
toolCalls[len(toolCalls)-1].Arguments += tc.Arguments
|
||||
idx := tc.Index
|
||||
for len(toolCalls) <= idx {
|
||||
toolCalls = append(toolCalls, helps.DevinToolCall{})
|
||||
}
|
||||
if tc.ID != "" {
|
||||
toolCalls[idx].ID = tc.ID
|
||||
}
|
||||
if tc.Name != "" {
|
||||
toolCalls[idx].Name = tc.Name
|
||||
}
|
||||
if tc.Arguments != "" {
|
||||
toolCalls[idx].Arguments += tc.Arguments
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -858,9 +882,13 @@ func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string
|
||||
|
||||
var steps [][]byte
|
||||
|
||||
if len(thinkingParts) > 0 {
|
||||
if len(thinkingParts) > 0 || len(accumulatedSignature) > 0 {
|
||||
thoughtStep := []byte(`{"type":"thought","content":[{"type":"text","text":""}]}`)
|
||||
thoughtStep, _ = sjson.SetBytes(thoughtStep, "content.0.text", strings.Join(thinkingParts, ""))
|
||||
if len(thinkingParts) > 0 {
|
||||
thoughtStep, _ = sjson.SetBytes(thoughtStep, "content.0.text", strings.Join(thinkingParts, ""))
|
||||
} else {
|
||||
thoughtStep, _ = sjson.DeleteBytes(thoughtStep, "content")
|
||||
}
|
||||
if len(accumulatedSignature) > 0 {
|
||||
sigStr := string(accumulatedSignature)
|
||||
thoughtStep, _ = sjson.SetBytes(thoughtStep, "signature", sigStr)
|
||||
@@ -972,19 +1000,22 @@ func parseInteractionsPayload(payload, originalRequest []byte) (
|
||||
}
|
||||
|
||||
// 3. Session and Cascade ID
|
||||
// Prioritize stable session identifiers across turns (session_id, sessionId, conversation_id)
|
||||
// to ensure upstream session ID and cascade ID remain stable, preserving prompt caching.
|
||||
// Fall back to previous_interaction_id only when no stable session identifier exists.
|
||||
sessionID = strings.TrimSpace(firstNonEmpty(
|
||||
root.Get("previous_interaction_id").String(),
|
||||
root.Get("session_id").String(),
|
||||
root.Get("sessionId").String(),
|
||||
root.Get("conversation_id").String(),
|
||||
root.Get("previous_interaction_id").String(),
|
||||
))
|
||||
if sessionID == "" && len(originalRequest) > 0 {
|
||||
origRoot := gjson.ParseBytes(originalRequest)
|
||||
sessionID = strings.TrimSpace(firstNonEmpty(
|
||||
origRoot.Get("previous_interaction_id").String(),
|
||||
origRoot.Get("session_id").String(),
|
||||
origRoot.Get("sessionId").String(),
|
||||
origRoot.Get("conversation_id").String(),
|
||||
origRoot.Get("previous_interaction_id").String(),
|
||||
))
|
||||
}
|
||||
cascadeID = sessionID
|
||||
@@ -1403,16 +1434,22 @@ func detectSignatureType(sig string) string {
|
||||
return "sealed"
|
||||
}
|
||||
|
||||
type originalAssistantMeta struct {
|
||||
signature []byte
|
||||
signatureType string
|
||||
thinking string
|
||||
}
|
||||
|
||||
func supplementSignaturesFromOriginal(original []byte, prompts []helps.DevinPrompt) {
|
||||
origRoot := gjson.ParseBytes(original)
|
||||
messages := origRoot.Get("messages")
|
||||
if !messages.IsArray() {
|
||||
return
|
||||
}
|
||||
var assistantSigs [][]byte
|
||||
var assistantSigTypes []string
|
||||
var originalAssistants []originalAssistantMeta
|
||||
for _, m := range messages.Array() {
|
||||
if strings.EqualFold(m.Get("role").String(), "assistant") {
|
||||
var meta originalAssistantMeta
|
||||
content := m.Get("content")
|
||||
if content.IsArray() {
|
||||
for _, part := range content.Array() {
|
||||
@@ -1420,22 +1457,34 @@ func supplementSignaturesFromOriginal(original []byte, prompts []helps.DevinProm
|
||||
if sig := part.Get("signature").String(); sig != "" {
|
||||
bytes, sType := parseSignatureBytes(sig)
|
||||
if len(bytes) > 0 {
|
||||
assistantSigs = append(assistantSigs, bytes)
|
||||
assistantSigTypes = append(assistantSigTypes, sType)
|
||||
meta.signature = bytes
|
||||
meta.signatureType = sType
|
||||
}
|
||||
}
|
||||
if t := part.Get("thinking").String(); t != "" {
|
||||
meta.thinking = t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
originalAssistants = append(originalAssistants, meta)
|
||||
}
|
||||
}
|
||||
|
||||
sigIdx := 0
|
||||
asstIdx := 0
|
||||
for i := range prompts {
|
||||
if prompts[i].Source == 2 && len(prompts[i].Signature) == 0 && sigIdx < len(assistantSigs) {
|
||||
prompts[i].Signature = assistantSigs[sigIdx]
|
||||
prompts[i].SignatureType = assistantSigTypes[sigIdx]
|
||||
sigIdx++
|
||||
if prompts[i].Source == 2 {
|
||||
if asstIdx < len(originalAssistants) {
|
||||
orig := originalAssistants[asstIdx]
|
||||
if len(prompts[i].Signature) == 0 && len(orig.signature) > 0 {
|
||||
prompts[i].Signature = orig.signature
|
||||
prompts[i].SignatureType = orig.signatureType
|
||||
}
|
||||
if prompts[i].Thinking == "" && orig.thinking != "" {
|
||||
prompts[i].Thinking = orig.thinking
|
||||
}
|
||||
asstIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1497,6 +1546,9 @@ func devinAuthCredentials(auth *cliproxyauth.Auth) (apiKey string, baseURL strin
|
||||
if v, ok := auth.Metadata["base_url"].(string); ok && strings.TrimSpace(v) != "" && baseURL == helps.DevinDefaultBaseURL {
|
||||
baseURL = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := auth.Metadata["device_seed"].(string); ok && strings.TrimSpace(v) != "" && deviceSeed == "" {
|
||||
deviceSeed = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,6 +61,41 @@ func TestDevinExecutorPrepareRequest(t *testing.T) {
|
||||
if req.Header.Get("Connect-Protocol-Version") != "1" {
|
||||
t.Fatalf("Connect-Protocol-Version = %q, want 1", req.Header.Get("Connect-Protocol-Version"))
|
||||
}
|
||||
if req.Header.Get("Accept") != "*/*" {
|
||||
t.Fatalf("Accept = %q, want */*", req.Header.Get("Accept"))
|
||||
}
|
||||
sentryTrace := req.Header.Get("Sentry-Trace")
|
||||
if sentryTrace == "" {
|
||||
t.Fatalf("Sentry-Trace header missing")
|
||||
}
|
||||
parts := strings.Split(sentryTrace, "-")
|
||||
if len(parts) != 3 || len(parts[0]) != 32 || len(parts[1]) != 16 || parts[2] != "1" {
|
||||
t.Fatalf("invalid Sentry-Trace format: %q", sentryTrace)
|
||||
}
|
||||
|
||||
// Verify User-Agent suppression on the wire
|
||||
var receivedUA []string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedUA = r.Header["User-Agent"]
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
wireReq, err := http.NewRequest(http.MethodPost, ts.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
if err := exec.PrepareRequest(wireReq, auth); err != nil {
|
||||
t.Fatalf("PrepareRequest failed: %v", err)
|
||||
}
|
||||
resp, err := ts.Client().Do(wireReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Do request failed: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if len(receivedUA) != 0 {
|
||||
t.Errorf("expected User-Agent to be completely omitted on wire, got: %v", receivedUA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevinAuthCredentials(t *testing.T) {
|
||||
|
||||
@@ -20,6 +20,7 @@ var (
|
||||
|
||||
// knownDevinSuffixes lists recognized model uid suffixes.
|
||||
var knownDevinSuffixes = []string{
|
||||
"-none",
|
||||
"-low",
|
||||
"-medium",
|
||||
"-high",
|
||||
|
||||
@@ -3,6 +3,7 @@ package helps
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
@@ -14,9 +15,11 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -117,9 +120,15 @@ type DevinFrameResult struct {
|
||||
UnknownFieldNumbers []int
|
||||
}
|
||||
|
||||
// GenerateDevinDeviceFingerprint generates a stable 732-character hex device fingerprint.
|
||||
// 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
|
||||
@@ -133,6 +142,45 @@ func GenerateDevinDeviceFingerprint(seed string) string {
|
||||
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 {
|
||||
@@ -373,7 +421,7 @@ func BuildDevinGetChatMessageRequest(
|
||||
f8Bytes = protowire.AppendVarint(f8Bytes, 40)
|
||||
|
||||
f8Bytes = protowire.AppendTag(f8Bytes, 8, protowire.Fixed64Type)
|
||||
f8Bytes = protowire.AppendFixed64(f8Bytes, math.Float64bits(0.95))
|
||||
f8Bytes = protowire.AppendFixed64(f8Bytes, math.Float64bits(float64(float32(0.95))))
|
||||
|
||||
reqBytes = protowire.AppendTag(reqBytes, 8, protowire.BytesType)
|
||||
reqBytes = protowire.AppendBytes(reqBytes, f8Bytes)
|
||||
@@ -402,18 +450,32 @@ func BuildDevinGetChatMessageRequest(
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
f15Bytes = protowire.AppendTag(f15Bytes, 2, protowire.VarintType)
|
||||
f15Bytes = protowire.AppendVarint(f15Bytes, 53)
|
||||
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)
|
||||
|
||||
f15Bytes = protowire.AppendTag(f15Bytes, 4, protowire.VarintType)
|
||||
f15Bytes = protowire.AppendVarint(f15Bytes, 14)
|
||||
// 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)
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func TestConnectEnvelopeFraming(t *testing.T) {
|
||||
@@ -255,10 +258,10 @@ func TestParseDevinTrailerError(t *testing.T) {
|
||||
|
||||
func TestUTF8SplitBuffer(t *testing.T) {
|
||||
buf := &UTF8SplitBuffer{}
|
||||
// "你好" in UTF-8: \xe4\xbd\xa0 \xe5\xa5\xbd (3 bytes each)
|
||||
chunk1 := []byte{0xe4, 0xbd} // first 2 bytes of 你
|
||||
chunk2 := []byte{0xa0, 0xe5, 0xa5} // last 1 byte of 你, first 2 bytes of 好
|
||||
chunk3 := []byte{0xbd} // last 1 byte of 好
|
||||
// Multi-byte UTF-8 test: \xe4\xbd\xa0 \xe5\xa5\xbd (3 bytes each)
|
||||
chunk1 := []byte{0xe4, 0xbd} // first 2 bytes of char 1
|
||||
chunk2 := []byte{0xa0, 0xe5, 0xa5} // last 1 byte of char 1, first 2 bytes of char 2
|
||||
chunk3 := []byte{0xbd} // last 1 byte of char 2
|
||||
|
||||
s1 := buf.Feed(chunk1)
|
||||
if s1 != "" {
|
||||
@@ -374,3 +377,178 @@ func TestBuildDevinUpstreamLogBody(t *testing.T) {
|
||||
t.Errorf("expected direct body to contain model UID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDevinSentryTrace(t *testing.T) {
|
||||
st1 := GenerateDevinSentryTrace()
|
||||
st2 := GenerateDevinSentryTrace()
|
||||
if st1 == st2 {
|
||||
t.Fatalf("traces should be randomly generated: %s == %s", st1, st2)
|
||||
}
|
||||
parts := strings.Split(st1, "-")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("sentry-trace should have 3 parts separated by hyphen, got %q", st1)
|
||||
}
|
||||
if len(parts[0]) != 32 {
|
||||
t.Errorf("traceID len = %d, want 32", len(parts[0]))
|
||||
}
|
||||
if len(parts[1]) != 16 {
|
||||
t.Errorf("spanID len = %d, want 16", len(parts[1]))
|
||||
}
|
||||
if parts[2] != "1" {
|
||||
t.Errorf("sampled = %q, want 1", parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDevinGetChatMessageRequest_Field15TurnIndex(t *testing.T) {
|
||||
sessID0 := "sess-turn0-" + uuid.New().String()
|
||||
defer ResetDevinSessionTurnIndex(sessID0)
|
||||
|
||||
// 1. Turn 0 with user prompt: turnIndex=0 (omitted), 15.4=14 emitted on user boundary
|
||||
promptsTurn0 := []DevinPrompt{
|
||||
{MessageID: "u1", Source: 1, Content: "hello"},
|
||||
}
|
||||
req0 := BuildDevinGetChatMessageRequest("tok", "seed", "swe-2-high", "", promptsTurn0, nil, nil, 1000, sessID0, "casc-turn0", nil)
|
||||
gotSess0, f15Sub0 := extractField15Subfields(t, req0)
|
||||
if gotSess0 != sessID0 {
|
||||
t.Errorf("Field 1 sessionID = %q, want %q", gotSess0, sessID0)
|
||||
}
|
||||
if _, hasF2 := f15Sub0[2]; hasF2 {
|
||||
t.Errorf("turn 0 should omit Field 2, got %v", f15Sub0[2])
|
||||
}
|
||||
if f15Sub0[3] != 4 {
|
||||
t.Errorf("Field 3 = %d, want 4", f15Sub0[3])
|
||||
}
|
||||
if f15Sub0[4] != 14 {
|
||||
t.Errorf("Field 4 = %d, want 14 on user turn boundary", f15Sub0[4])
|
||||
}
|
||||
|
||||
sessIDTool := "sess-tool-" + uuid.New().String()
|
||||
defer ResetDevinSessionTurnIndex(sessIDTool)
|
||||
|
||||
// 2. Tool-result continuation (source=4): 15.4 should be omitted
|
||||
promptsTool := []DevinPrompt{
|
||||
{MessageID: "u1", Source: 1, Content: "read file"},
|
||||
{MessageID: "a1", Source: 2, Content: "calling tool"},
|
||||
{MessageID: "t1", Source: 4, Content: "file content", ToolCallID: "call_1"},
|
||||
}
|
||||
reqTool := BuildDevinGetChatMessageRequest("tok", "seed", "swe-2-high", "", promptsTool, nil, nil, 1000, sessIDTool, "casc-tool", nil)
|
||||
_, f15SubTool := extractField15Subfields(t, reqTool)
|
||||
if _, hasF4 := f15SubTool[4]; hasF4 {
|
||||
t.Errorf("Field 4 should be omitted on tool result continuation, got %v", f15SubTool[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDevinGetChatMessageRequest_Field15SequentialCounter(t *testing.T) {
|
||||
sessionID := "sess-sequential-test-" + uuid.New().String()
|
||||
defer ResetDevinSessionTurnIndex(sessionID)
|
||||
|
||||
prompts := []DevinPrompt{
|
||||
{MessageID: "u1", Source: 1, Content: "hi"},
|
||||
}
|
||||
|
||||
// Request 1: fresh session -> turnIndex 0 (omitted from wire)
|
||||
req1 := BuildDevinGetChatMessageRequest("tok", "seed", "swe-2-high", "", prompts, nil, nil, 1000, sessionID, "casc-1", nil)
|
||||
_, sub1 := extractField15Subfields(t, req1)
|
||||
if _, hasF2 := sub1[2]; hasF2 {
|
||||
t.Errorf("Request 1 in fresh session should omit 15.2, got %v", sub1[2])
|
||||
}
|
||||
|
||||
// Request 2: turnIndex 1
|
||||
req2 := BuildDevinGetChatMessageRequest("tok", "seed", "swe-2-high", "", prompts, nil, nil, 1000, sessionID, "casc-1", nil)
|
||||
_, sub2 := extractField15Subfields(t, req2)
|
||||
if sub2[2] != 1 {
|
||||
t.Errorf("Request 2 should have 15.2 = 1, got %v", sub2[2])
|
||||
}
|
||||
|
||||
// Request 3: turnIndex 2
|
||||
req3 := BuildDevinGetChatMessageRequest("tok", "seed", "swe-2-high", "", prompts, nil, nil, 1000, sessionID, "casc-1", nil)
|
||||
_, sub3 := extractField15Subfields(t, req3)
|
||||
if sub3[2] != 2 {
|
||||
t.Errorf("Request 3 should have 15.2 = 2, got %v", sub3[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDevinDeviceFingerprint_RandomWhenEmptySeed(t *testing.T) {
|
||||
fp1 := GenerateDevinDeviceFingerprint("")
|
||||
fp2 := GenerateDevinDeviceFingerprint("")
|
||||
if len(fp1) != DevinFingerprintHexLen {
|
||||
t.Fatalf("fp1 len = %d, want %d", len(fp1), DevinFingerprintHexLen)
|
||||
}
|
||||
if len(fp2) != DevinFingerprintHexLen {
|
||||
t.Fatalf("fp2 len = %d, want %d", len(fp2), DevinFingerprintHexLen)
|
||||
}
|
||||
if fp1 == fp2 {
|
||||
t.Fatalf("fingerprints without explicit seed must be unique per call: %q == %q", fp1, fp2)
|
||||
}
|
||||
|
||||
// With explicit seed, it must be deterministic
|
||||
seeded1 := GenerateDevinDeviceFingerprint("my-stable-seed")
|
||||
seeded2 := GenerateDevinDeviceFingerprint("my-stable-seed")
|
||||
if seeded1 != seeded2 {
|
||||
t.Fatalf("seeded fingerprints must be identical: %q != %q", seeded1, seeded2)
|
||||
}
|
||||
}
|
||||
|
||||
func extractField15Subfields(t *testing.T, reqBytes []byte) (string, map[int]uint64) {
|
||||
t.Helper()
|
||||
b := reqBytes
|
||||
var f15Bytes []byte
|
||||
for len(b) > 0 {
|
||||
num, typ, n := protowire.ConsumeTag(b)
|
||||
if n < 0 {
|
||||
break
|
||||
}
|
||||
b = b[n:]
|
||||
if num == 15 && typ == protowire.BytesType {
|
||||
sub, m := protowire.ConsumeBytes(b)
|
||||
if m < 0 {
|
||||
t.Fatalf("failed to consume field 15 bytes")
|
||||
}
|
||||
f15Bytes = sub
|
||||
break
|
||||
}
|
||||
m := protowire.ConsumeFieldValue(num, typ, b)
|
||||
if m < 0 {
|
||||
break
|
||||
}
|
||||
b = b[m:]
|
||||
}
|
||||
if len(f15Bytes) == 0 {
|
||||
t.Fatalf("field 15 not found in request")
|
||||
}
|
||||
|
||||
var sessionID string
|
||||
subfields := make(map[int]uint64)
|
||||
sb := f15Bytes
|
||||
for len(sb) > 0 {
|
||||
num, typ, n := protowire.ConsumeTag(sb)
|
||||
if n < 0 {
|
||||
break
|
||||
}
|
||||
sb = sb[n:]
|
||||
if typ == protowire.VarintType {
|
||||
val, m := protowire.ConsumeVarint(sb)
|
||||
if m < 0 {
|
||||
break
|
||||
}
|
||||
subfields[int(num)] = val
|
||||
sb = sb[m:]
|
||||
} else if typ == protowire.BytesType {
|
||||
val, m := protowire.ConsumeBytes(sb)
|
||||
if m < 0 {
|
||||
break
|
||||
}
|
||||
if num == 1 {
|
||||
sessionID = string(val)
|
||||
}
|
||||
sb = sb[m:]
|
||||
} else {
|
||||
m := protowire.ConsumeFieldValue(num, typ, sb)
|
||||
if m < 0 {
|
||||
break
|
||||
}
|
||||
sb = sb[m:]
|
||||
}
|
||||
}
|
||||
return sessionID, subfields
|
||||
}
|
||||
|
||||
@@ -61,6 +61,24 @@ func NewProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *clip
|
||||
return httpClient
|
||||
}
|
||||
|
||||
// NewDevinHTTPClient creates an HTTP client customized for Devin Connect-RPC upstream.
|
||||
// Suppresses automatic Accept-Encoding: gzip while preserving robust HTTP/2 streaming.
|
||||
func NewDevinHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
|
||||
httpClient := NewProxyAwareHTTPClient(ctx, cfg, auth, timeout)
|
||||
if httpClient.Transport == nil {
|
||||
if dt, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
httpClient.Transport = dt.Clone()
|
||||
}
|
||||
} else if tr, ok := httpClient.Transport.(*http.Transport); ok {
|
||||
// Clone transport to avoid mutating a shared or cached roundtripper
|
||||
httpClient.Transport = tr.Clone()
|
||||
}
|
||||
if tr, ok := httpClient.Transport.(*http.Transport); ok {
|
||||
tr.DisableCompression = true
|
||||
}
|
||||
return httpClient
|
||||
}
|
||||
|
||||
// buildProxyTransport creates an HTTP transport configured for the given proxy URL.
|
||||
// It supports SOCKS5, HTTP, and HTTPS proxy protocols.
|
||||
//
|
||||
|
||||
@@ -132,7 +132,7 @@ waitForResult:
|
||||
if res.Error != "" {
|
||||
return nil, fmt.Errorf("devin oauth error: %s", res.Error)
|
||||
}
|
||||
if state != "" && res.State != "" && res.State != state {
|
||||
if state != "" && res.State != state {
|
||||
return nil, fmt.Errorf("devin oauth state mismatch (possible CSRF)")
|
||||
}
|
||||
authCode = res.Code
|
||||
@@ -180,7 +180,7 @@ waitForResult:
|
||||
// 2. Full callback redirect URL
|
||||
parsed, errParse := misc.ParseOAuthCallback(trimmed)
|
||||
if errParse == nil && parsed != nil && parsed.Code != "" {
|
||||
if state != "" && parsed.State != "" && parsed.State != state {
|
||||
if state != "" && parsed.State != state {
|
||||
return nil, fmt.Errorf("devin oauth state mismatch (possible CSRF)")
|
||||
}
|
||||
authCode = parsed.Code
|
||||
|
||||
Reference in New Issue
Block a user