diff --git a/internal/auth/devin/devin_auth.go b/internal/auth/devin/devin_auth.go index bb2788fe9..53ad58ba7 100644 --- a/internal/auth/devin/devin_auth.go +++ b/internal/auth/devin/devin_auth.go @@ -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: diff --git a/internal/auth/devin/user_status.go b/internal/auth/devin/user_status.go index 31892c93b..eb34a3117 100644 --- a/internal/auth/devin/user_status.go +++ b/internal/auth/devin/user_status.go @@ -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 { diff --git a/internal/auth/devin/user_status_test.go b/internal/auth/devin/user_status_test.go index 0af8907c3..30eeb08ec 100644 --- a/internal/auth/devin/user_status_test.go +++ b/internal/auth/devin/user_status_test.go @@ -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 { diff --git a/internal/cache/bounded_lru.go b/internal/cache/bounded_lru.go index 458853be7..a50c69a77 100644 --- a/internal/cache/bounded_lru.go +++ b/internal/cache/bounded_lru.go @@ -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() diff --git a/internal/registry/models/devin_models.json b/internal/registry/models/devin_models.json index 39957e28b..8e5cdd89d 100644 --- a/internal/registry/models/devin_models.json +++ b/internal/registry/models/devin_models.json @@ -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" ], diff --git a/internal/runtime/executor/devin_executor.go b/internal/runtime/executor/devin_executor.go index 1f9eb49d4..48c570770 100644 --- a/internal/runtime/executor/devin_executor.go +++ b/internal/runtime/executor/devin_executor.go @@ -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 } diff --git a/internal/runtime/executor/devin_executor_test.go b/internal/runtime/executor/devin_executor_test.go index 0ce245434..101650633 100644 --- a/internal/runtime/executor/devin_executor_test.go +++ b/internal/runtime/executor/devin_executor_test.go @@ -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) { diff --git a/internal/runtime/executor/helps/devin_models.go b/internal/runtime/executor/helps/devin_models.go index 0c29a9054..e9b66e2fc 100644 --- a/internal/runtime/executor/helps/devin_models.go +++ b/internal/runtime/executor/helps/devin_models.go @@ -20,6 +20,7 @@ var ( // knownDevinSuffixes lists recognized model uid suffixes. var knownDevinSuffixes = []string{ + "-none", "-low", "-medium", "-high", diff --git a/internal/runtime/executor/helps/devin_wire.go b/internal/runtime/executor/helps/devin_wire.go index f67f29223..cadde89a4 100644 --- a/internal/runtime/executor/helps/devin_wire.go +++ b/internal/runtime/executor/helps/devin_wire.go @@ -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) diff --git a/internal/runtime/executor/helps/devin_wire_test.go b/internal/runtime/executor/helps/devin_wire_test.go index 4194640a9..c7b1aeff0 100644 --- a/internal/runtime/executor/helps/devin_wire_test.go +++ b/internal/runtime/executor/helps/devin_wire_test.go @@ -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 +} diff --git a/internal/runtime/executor/helps/proxy_helpers.go b/internal/runtime/executor/helps/proxy_helpers.go index 572f87c7a..404877b0d 100644 --- a/internal/runtime/executor/helps/proxy_helpers.go +++ b/internal/runtime/executor/helps/proxy_helpers.go @@ -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. // diff --git a/sdk/auth/devin.go b/sdk/auth/devin.go index fce08dbfe..8da1dfe19 100644 --- a/sdk/auth/devin.go +++ b/sdk/auth/devin.go @@ -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