mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-06 15:50:49 +08:00
1258 lines
40 KiB
Go
1258 lines
40 KiB
Go
package executor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
// DevinExecutor executes requests against the Codeium/Devin Connect-RPC backend.
|
|
type DevinExecutor struct {
|
|
cfg *config.Config
|
|
}
|
|
|
|
// NewDevinExecutor creates a new Devin executor instance.
|
|
func NewDevinExecutor(cfg *config.Config) *DevinExecutor {
|
|
return &DevinExecutor{
|
|
cfg: cfg,
|
|
}
|
|
}
|
|
|
|
// Identifier returns the unique provider key for Devin.
|
|
func (e *DevinExecutor) Identifier() string {
|
|
return "devin"
|
|
}
|
|
|
|
// RequestToFormat specifies that Devin expects the interactions intermediate format.
|
|
func (e *DevinExecutor) RequestToFormat(_ cliproxyexecutor.Request, _ cliproxyexecutor.Options) sdktranslator.Format {
|
|
return sdktranslator.FormatInteractions
|
|
}
|
|
|
|
// PrepareRequest decorates the outgoing HTTP request with Devin Connect-RPC headers.
|
|
func (e *DevinExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
apiKey, _, _ := devinAuthCredentials(auth)
|
|
if apiKey != "" {
|
|
// Codeium/Devin Connect-RPC upstream expects "Basic <token>-<token>" as its wire authentication header.
|
|
req.Header.Set("Authorization", "Basic "+apiKey+"-"+apiKey)
|
|
}
|
|
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)")
|
|
|
|
var attrs map[string]string
|
|
if auth != nil {
|
|
attrs = auth.Attributes
|
|
}
|
|
util.ApplyCustomHeadersFromAttrs(req, attrs)
|
|
return nil
|
|
}
|
|
|
|
// HttpRequest injects Devin credentials into the request and executes it.
|
|
func (e *DevinExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("devin executor: request is nil")
|
|
}
|
|
if ctx == nil {
|
|
ctx = req.Context()
|
|
}
|
|
httpReq := req.WithContext(ctx)
|
|
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
|
return nil, err
|
|
}
|
|
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
|
return httpClient.Do(httpReq)
|
|
}
|
|
|
|
// Refresh is a no-op since Devin uses permanent API keys.
|
|
func (e *DevinExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
|
return auth, nil
|
|
}
|
|
|
|
// CountTokens provides token counting for Devin requests.
|
|
func (e *DevinExecutor) CountTokens(_ context.Context, _ *cliproxyauth.Auth, req cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
// Devin has no standalone token count endpoint; estimate via length heuristic.
|
|
promptTokens := int64(len(req.Payload) / 4)
|
|
out := []byte(fmt.Sprintf(`{"total_tokens":%d,"input_tokens":%d}`, promptTokens, promptTokens))
|
|
return cliproxyexecutor.Response{Payload: out}, nil
|
|
}
|
|
|
|
// Execute performs non-streaming execution against the Devin backend.
|
|
func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
|
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
|
|
targetModel := thinking.ParseSuffix(req.Model).ModelName
|
|
|
|
reporter := helps.NewExecutorUsageReporter(ctx, e, targetModel, auth)
|
|
defer reporter.TrackFailure(ctx, &err)
|
|
|
|
httpReq, chatModelUID, errPrep := e.prepareDevinHTTPRequest(ctx, auth, req, opts)
|
|
if errPrep != nil {
|
|
return resp, errPrep
|
|
}
|
|
|
|
authID, authLabel, authType, authValue := devinAuthLogFields(auth)
|
|
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
|
|
URL: httpReq.URL.String(),
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: []byte(fmt.Sprintf("[connect-proto GetChatMessage: model=%s]", chatModelUID)),
|
|
Provider: e.Identifier(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
|
|
httpClient := reporter.TrackHTTPClient(helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0))
|
|
httpResp, errDo := httpClient.Do(httpReq)
|
|
if errDo != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
|
|
return resp, errDo
|
|
}
|
|
defer func() {
|
|
_ = httpResp.Body.Close()
|
|
}()
|
|
|
|
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
errData, _ := io.ReadAll(httpResp.Body)
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, errData)
|
|
return resp, newDevinStatusError(httpResp.StatusCode, httpResp.Header, errData)
|
|
}
|
|
|
|
interactionsJSON, errConsume := consumeDevinFramesToInteractions(httpResp.Body, req.Model, chatModelUID)
|
|
if errConsume != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, errConsume)
|
|
return resp, errConsume
|
|
}
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, interactionsJSON)
|
|
|
|
reporter.Publish(ctx, helps.ParseInteractionsUsage(interactionsJSON))
|
|
|
|
targetFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
|
|
var param any
|
|
out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatInteractions, targetFormat, req.Model, opts.OriginalRequest, req.Payload, interactionsJSON, ¶m)
|
|
if targetFormat == sdktranslator.FormatOpenAIResponse {
|
|
out = helps.EnsureResponsesUsageDetails(out)
|
|
}
|
|
return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil
|
|
}
|
|
|
|
// ExecuteStream performs streaming execution against the Devin backend.
|
|
func (e *DevinExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
|
|
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
|
|
targetModel := thinking.ParseSuffix(req.Model).ModelName
|
|
|
|
reporter := helps.NewExecutorUsageReporter(ctx, e, targetModel, auth)
|
|
defer reporter.TrackFailure(ctx, &err)
|
|
|
|
httpReq, chatModelUID, errPrep := e.prepareDevinHTTPRequest(ctx, auth, req, opts)
|
|
if errPrep != nil {
|
|
return nil, errPrep
|
|
}
|
|
|
|
authID, authLabel, authType, authValue := devinAuthLogFields(auth)
|
|
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
|
|
URL: httpReq.URL.String(),
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: []byte(fmt.Sprintf("[connect-proto GetChatMessage: model=%s]", chatModelUID)),
|
|
Provider: e.Identifier(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
|
|
httpClient := reporter.TrackHTTPClient(helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0))
|
|
httpResp, errDo := httpClient.Do(httpReq)
|
|
if errDo != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
|
|
return nil, errDo
|
|
}
|
|
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
errData, _ := io.ReadAll(httpResp.Body)
|
|
_ = httpResp.Body.Close()
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, errData)
|
|
return nil, statusErr{code: httpResp.StatusCode, msg: string(errData)}
|
|
}
|
|
|
|
out := make(chan cliproxyexecutor.StreamChunk)
|
|
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
|
|
|
|
streamCtx, cancelStream := context.WithCancel(ctx)
|
|
|
|
go func() {
|
|
defer close(out)
|
|
defer cancelStream()
|
|
defer func() {
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("devin executor: close stream body error: %v", errClose)
|
|
}
|
|
}()
|
|
|
|
e.streamDevinFrames(streamCtx, httpResp.Body, req, opts, chatModelUID, responseFormat, reporter, out)
|
|
}()
|
|
|
|
return &cliproxyexecutor.StreamResult{
|
|
Headers: httpResp.Header.Clone(),
|
|
Chunks: out,
|
|
}, nil
|
|
}
|
|
|
|
func (e *DevinExecutor) prepareDevinHTTPRequest(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*http.Request, string, error) {
|
|
apiKey, baseURL, deviceSeed := devinAuthCredentials(auth)
|
|
if apiKey == "" {
|
|
return nil, "", fmt.Errorf("devin credentials missing: api_key or session_token required")
|
|
}
|
|
if deviceSeed == "" {
|
|
deviceSeed = apiKey
|
|
}
|
|
|
|
payload := req.Payload
|
|
if opts.SourceFormat != "" && opts.SourceFormat != sdktranslator.FormatInteractions {
|
|
payload = sdktranslator.TranslateRequest(opts.SourceFormat, sdktranslator.FormatInteractions, req.Model, payload, opts.Stream)
|
|
}
|
|
systemPrompt, prompts, tools, temp, maxTokens, sessionID, cascadeID, thinkingLevel, budgetTokens := parseInteractionsPayload(payload, opts.OriginalRequest)
|
|
sessionID, cascadeID = resolveDevinSessionAndCascadeIDs(ctx, sessionID, cascadeID, opts)
|
|
|
|
chatModelUID := helps.ResolveDevinChatModelUID(req.Model, thinkingLevel, budgetTokens)
|
|
|
|
sensitiveWords := e.getSensitiveWords()
|
|
var matcher *helps.SensitiveWordMatcher
|
|
if len(sensitiveWords) > 0 {
|
|
matcher = helps.BuildSensitiveWordMatcher(sensitiveWords)
|
|
}
|
|
|
|
protoBytes := helps.BuildDevinGetChatMessageRequest(
|
|
apiKey,
|
|
deviceSeed,
|
|
chatModelUID,
|
|
systemPrompt,
|
|
prompts,
|
|
tools,
|
|
temp,
|
|
maxTokens,
|
|
sessionID,
|
|
cascadeID,
|
|
matcher,
|
|
)
|
|
|
|
framed := helps.WrapConnectEnvelope(protoBytes)
|
|
url := strings.TrimRight(baseURL, "/") + helps.DevinChatPath
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(framed))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
return httpReq, chatModelUID, nil
|
|
}
|
|
|
|
func (e *DevinExecutor) streamDevinFrames(
|
|
ctx context.Context,
|
|
body io.Reader,
|
|
req cliproxyexecutor.Request,
|
|
opts cliproxyexecutor.Options,
|
|
chatModelUID string,
|
|
responseFormat sdktranslator.Format,
|
|
reporter *helps.UsageReporter,
|
|
out chan<- cliproxyexecutor.StreamChunk,
|
|
) {
|
|
interactionID := fmt.Sprintf("interaction_%s", uuid.New().String()[:12])
|
|
stepIndex := 0
|
|
thoughtStarted := false
|
|
contentStarted := false
|
|
currentToolCallActive := false
|
|
thinkingBuf := &helps.UTF8SplitBuffer{}
|
|
contentBuf := &helps.UTF8SplitBuffer{}
|
|
var finalUsage *helps.DevinUsage
|
|
var accumulatedSignature []byte
|
|
var signatureType string
|
|
|
|
claudeInputTokens := helps.NewClaudeInputTokenState(opts.SourceFormat, sdktranslator.FormatInteractions, responseFormat, opts.OriginalRequest)
|
|
var translateParam any
|
|
|
|
emitInteractionsEvent := func(rawJSON []byte) bool {
|
|
if len(rawJSON) == 0 {
|
|
return true
|
|
}
|
|
trimmed := bytes.TrimSpace(rawJSON)
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, append(append([]byte{}, trimmed...), '\n'))
|
|
|
|
if responseFormat == sdktranslator.FormatInteractions {
|
|
frame := []byte(fmt.Sprintf("data: %s\n\n", string(trimmed)))
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Payload: frame}:
|
|
return true
|
|
case <-ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
|
|
lines := helps.TranslateStreamWithClaudeInputTokens(
|
|
ctx,
|
|
sdktranslator.FormatInteractions,
|
|
responseFormat,
|
|
req.Model,
|
|
opts.OriginalRequest,
|
|
req.Payload,
|
|
trimmed,
|
|
&translateParam,
|
|
claudeInputTokens,
|
|
)
|
|
for _, line := range lines {
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Payload: line}:
|
|
case <-ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 1. Send initial interaction.created event
|
|
createdEvent, _ := sjson.SetBytes([]byte(`{"event_type":"interaction.created","interaction":{"id":"","model":""}}`), "interaction.id", interactionID)
|
|
createdEvent, _ = sjson.SetBytes(createdEvent, "interaction.model", req.Model)
|
|
if !emitInteractionsEvent(createdEvent) {
|
|
return
|
|
}
|
|
|
|
thoughtStepIndex := -1
|
|
|
|
// 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) {
|
|
break
|
|
}
|
|
log.Debugf("devin executor: read connect frame error: %v", errRead)
|
|
break
|
|
}
|
|
|
|
// EOS Trailer
|
|
if flag&helps.ConnectFlagEndStream != 0 {
|
|
code, errTrailer := helps.ParseDevinTrailerError(payload)
|
|
if errTrailer != nil {
|
|
log.Warnf("devin executor: trailer error (%d): %v", code, 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)
|
|
return
|
|
}
|
|
break
|
|
}
|
|
|
|
frameRes, errParse := helps.ParseDevinFrame(payload)
|
|
if errParse != nil {
|
|
log.Debugf("devin executor: parse frame error: %v", errParse)
|
|
continue
|
|
}
|
|
|
|
if frameRes.Usage != nil {
|
|
finalUsage = frameRes.Usage
|
|
}
|
|
if len(frameRes.DeltaSignature) > 0 {
|
|
accumulatedSignature = append(accumulatedSignature, frameRes.DeltaSignature...)
|
|
}
|
|
if frameRes.DeltaSignatureType != "" {
|
|
signatureType = frameRes.DeltaSignatureType
|
|
}
|
|
|
|
// Emit thinking delta
|
|
if frameRes.ThinkingText != "" {
|
|
chunk := thinkingBuf.Feed([]byte(frameRes.ThinkingText))
|
|
if chunk != "" {
|
|
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
|
|
}
|
|
deltaEvent := []byte(`{"event_type":"step.delta","index":0,"delta":{"type":"thought_summary","text":"","content":{"type":"text","text":""}}}`)
|
|
deltaEvent, _ = sjson.SetBytes(deltaEvent, "index", thoughtStepIndex)
|
|
deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.text", chunk)
|
|
deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.content.text", chunk)
|
|
if !emitInteractionsEvent(deltaEvent) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Emit thinking signature delta targeting the thought step
|
|
if len(frameRes.DeltaSignature) > 0 {
|
|
targetIdx := thoughtStepIndex
|
|
if targetIdx < 0 {
|
|
targetIdx = 0
|
|
}
|
|
sigEvent := []byte(`{"event_type":"step.delta","index":0,"delta":{"type":"thought_signature","signature":""}}`)
|
|
sigEvent, _ = sjson.SetBytes(sigEvent, "index", targetIdx)
|
|
sigEvent, _ = sjson.SetBytes(sigEvent, "delta.signature", string(frameRes.DeltaSignature))
|
|
_ = emitInteractionsEvent(sigEvent)
|
|
}
|
|
|
|
// Emit content text delta
|
|
if frameRes.ContentText != "" {
|
|
chunk := contentBuf.Feed([]byte(frameRes.ContentText))
|
|
if chunk != "" {
|
|
if thoughtStarted {
|
|
stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
|
|
if !emitInteractionsEvent(stopEvent) {
|
|
return
|
|
}
|
|
thoughtStarted = false
|
|
stepIndex++
|
|
}
|
|
if !contentStarted {
|
|
startEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"model_output"}}`), "index", stepIndex)
|
|
if !emitInteractionsEvent(startEvent) {
|
|
return
|
|
}
|
|
contentStarted = true
|
|
}
|
|
deltaEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"text","text":""}}`), "index", stepIndex)
|
|
deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.text", chunk)
|
|
if !emitInteractionsEvent(deltaEvent) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Emit tool call deltas
|
|
for _, tc := range frameRes.ToolCallDeltas {
|
|
if thoughtStarted {
|
|
stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
|
|
_ = emitInteractionsEvent(stopEvent)
|
|
thoughtStarted = false
|
|
stepIndex++
|
|
}
|
|
if contentStarted {
|
|
stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
|
|
_ = emitInteractionsEvent(stopEvent)
|
|
contentStarted = false
|
|
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)
|
|
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(deltaEvent, "delta.arguments", tc.Arguments)
|
|
if !emitInteractionsEvent(deltaEvent) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
|
|
_ = emitInteractionsEvent(stopEvent)
|
|
}
|
|
|
|
// 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}}}`)
|
|
completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.id", interactionID)
|
|
completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.model", req.Model)
|
|
if finalUsage != nil {
|
|
completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.usage.total_input_tokens", finalUsage.PromptTokens)
|
|
completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.usage.total_output_tokens", finalUsage.CompletionTokens)
|
|
completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.usage.total_cached_tokens", finalUsage.CachedTokens)
|
|
if detail, ok := helps.ParseInteractionsStreamUsage(completedEvent); ok {
|
|
reporter.Publish(ctx, detail)
|
|
}
|
|
}
|
|
_ = emitInteractionsEvent(completedEvent)
|
|
|
|
// 6. Terminate stream with [DONE]
|
|
if responseFormat == sdktranslator.FormatInteractions {
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Payload: []byte("data: [DONE]\n\n")}:
|
|
case <-ctx.Done():
|
|
}
|
|
} else {
|
|
lines := helps.TranslateStreamWithClaudeInputTokens(
|
|
ctx,
|
|
sdktranslator.FormatInteractions,
|
|
responseFormat,
|
|
req.Model,
|
|
opts.OriginalRequest,
|
|
req.Payload,
|
|
[]byte("[DONE]"),
|
|
&translateParam,
|
|
claudeInputTokens,
|
|
)
|
|
for _, line := range lines {
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Payload: line}:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string) ([]byte, error) {
|
|
interactionID := fmt.Sprintf("interaction_%s", uuid.New().String()[:12])
|
|
var textParts []string
|
|
var thinkingParts []string
|
|
var toolCalls []helps.DevinToolCall
|
|
var finalUsage *helps.DevinUsage
|
|
var accumulatedSignature []byte
|
|
|
|
for {
|
|
flag, payload, errRead := helps.ReadConnectFrame(body)
|
|
if errRead != nil {
|
|
if errors.Is(errRead, io.EOF) || errors.Is(errRead, io.ErrUnexpectedEOF) {
|
|
break
|
|
}
|
|
return nil, errRead
|
|
}
|
|
|
|
if flag&helps.ConnectFlagEndStream != 0 {
|
|
code, errTrailer := helps.ParseDevinTrailerError(payload)
|
|
if errTrailer != nil {
|
|
return nil, statusErr{code: code, msg: errTrailer.Error()}
|
|
}
|
|
break
|
|
}
|
|
|
|
frameRes, errParse := helps.ParseDevinFrame(payload)
|
|
if errParse != nil {
|
|
continue
|
|
}
|
|
|
|
if frameRes.Usage != nil {
|
|
finalUsage = frameRes.Usage
|
|
}
|
|
if len(frameRes.DeltaSignature) > 0 {
|
|
accumulatedSignature = append(accumulatedSignature, frameRes.DeltaSignature...)
|
|
}
|
|
if frameRes.ThinkingText != "" {
|
|
thinkingParts = append(thinkingParts, frameRes.ThinkingText)
|
|
}
|
|
if frameRes.ContentText != "" {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
out := []byte(`{"id":"","model":"","status":"completed","steps":[],"usage":{"total_input_tokens":0,"total_output_tokens":0,"total_cached_tokens":0}}`)
|
|
out, _ = sjson.SetBytes(out, "id", interactionID)
|
|
out, _ = sjson.SetBytes(out, "model", model)
|
|
|
|
var steps [][]byte
|
|
|
|
if len(thinkingParts) > 0 {
|
|
thoughtStep := []byte(`{"type":"thought","content":[{"type":"text","text":""}]}`)
|
|
thoughtStep, _ = sjson.SetBytes(thoughtStep, "content.0.text", strings.Join(thinkingParts, ""))
|
|
if len(accumulatedSignature) > 0 {
|
|
sigStr := string(accumulatedSignature)
|
|
thoughtStep, _ = sjson.SetBytes(thoughtStep, "signature", sigStr)
|
|
thoughtStep, _ = sjson.SetBytes(thoughtStep, "thought_signature", sigStr)
|
|
}
|
|
steps = append(steps, thoughtStep)
|
|
}
|
|
|
|
if len(textParts) > 0 {
|
|
modelStep := []byte(`{"type":"model_output","content":[{"type":"text","text":""}]}`)
|
|
modelStep, _ = sjson.SetBytes(modelStep, "content.0.text", strings.Join(textParts, ""))
|
|
steps = append(steps, modelStep)
|
|
}
|
|
|
|
for _, tc := range toolCalls {
|
|
fnStep := []byte(`{"type":"function_call","name":"","id":"","call_id":"","arguments":{}}`)
|
|
fnStep, _ = sjson.SetBytes(fnStep, "name", tc.Name)
|
|
fnStep, _ = sjson.SetBytes(fnStep, "id", tc.ID)
|
|
fnStep, _ = sjson.SetBytes(fnStep, "call_id", tc.ID)
|
|
if tc.Arguments != "" && json.Valid([]byte(tc.Arguments)) {
|
|
fnStep, _ = sjson.SetRawBytes(fnStep, "arguments", []byte(tc.Arguments))
|
|
}
|
|
steps = append(steps, fnStep)
|
|
}
|
|
|
|
if len(steps) > 0 {
|
|
var stepsRaw strings.Builder
|
|
stepsRaw.WriteString("[")
|
|
for i, s := range steps {
|
|
if i > 0 {
|
|
stepsRaw.WriteString(",")
|
|
}
|
|
stepsRaw.Write(s)
|
|
}
|
|
stepsRaw.WriteString("]")
|
|
out, _ = sjson.SetRawBytes(out, "steps", []byte(stepsRaw.String()))
|
|
}
|
|
|
|
if finalUsage != nil {
|
|
out, _ = sjson.SetBytes(out, "usage.total_input_tokens", finalUsage.PromptTokens)
|
|
out, _ = sjson.SetBytes(out, "usage.total_output_tokens", finalUsage.CompletionTokens)
|
|
out, _ = sjson.SetBytes(out, "usage.total_cached_tokens", finalUsage.CachedTokens)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func parseInteractionsPayload(payload, originalRequest []byte) (
|
|
systemPrompt string,
|
|
prompts []helps.DevinPrompt,
|
|
tools []helps.DevinTool,
|
|
temperature *float64,
|
|
maxTokens int,
|
|
sessionID string,
|
|
cascadeID string,
|
|
thinkingLevel string,
|
|
budgetTokens int,
|
|
) {
|
|
root := gjson.ParseBytes(payload)
|
|
|
|
// 1. System prompt
|
|
systemPrompt = strings.TrimSpace(root.Get("system_instruction").String())
|
|
if systemPrompt == "" {
|
|
systemPrompt = strings.TrimSpace(root.Get("systemInstruction").String())
|
|
}
|
|
|
|
// 2. Generation config
|
|
genCfg := root.Get("generation_config")
|
|
if !genCfg.Exists() {
|
|
genCfg = root.Get("generationConfig")
|
|
}
|
|
if genCfg.Exists() {
|
|
if t := genCfg.Get("temperature"); t.Exists() {
|
|
val := t.Float()
|
|
temperature = &val
|
|
}
|
|
maxTokens = int(genCfg.Get("max_output_tokens").Int())
|
|
thinkingLevel = genCfg.Get("thinking_level").String()
|
|
budgetTokens = int(genCfg.Get("thinking_config.thinking_budget").Int())
|
|
}
|
|
if temperature == nil {
|
|
if origRoot := gjson.ParseBytes(originalRequest); origRoot.Get("temperature").Exists() {
|
|
val := origRoot.Get("temperature").Float()
|
|
temperature = &val
|
|
} else if root.Get("temperature").Exists() {
|
|
val := root.Get("temperature").Float()
|
|
temperature = &val
|
|
}
|
|
}
|
|
if maxTokens <= 0 {
|
|
maxTokens = helps.DevinDefaultMaxTokens
|
|
}
|
|
|
|
// 3. Session and Cascade ID
|
|
sessionID = strings.TrimSpace(firstNonEmpty(
|
|
root.Get("previous_interaction_id").String(),
|
|
root.Get("session_id").String(),
|
|
root.Get("sessionId").String(),
|
|
root.Get("conversation_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(),
|
|
))
|
|
}
|
|
cascadeID = sessionID
|
|
|
|
// 4. Repeated History Prompts
|
|
inputRes := root.Get("input")
|
|
if inputRes.IsArray() {
|
|
for _, step := range inputRes.Array() {
|
|
stepType := strings.ToLower(strings.TrimSpace(step.Get("type").String()))
|
|
switch stepType {
|
|
case "user_input":
|
|
text, images := extractInteractionsStepContent(step)
|
|
prompts = append(prompts, helps.DevinPrompt{
|
|
MessageID: uuid.New().String(),
|
|
Source: 1,
|
|
Content: text,
|
|
Images: images,
|
|
})
|
|
|
|
case "model_output":
|
|
text := extractInteractionsStepText(step)
|
|
sigStr := firstNonEmpty(step.Get("signature").String(), step.Get("thought_signature").String())
|
|
sigBytes, sigType := parseSignatureBytes(sigStr)
|
|
if len(prompts) > 0 && prompts[len(prompts)-1].Source == 2 {
|
|
if prompts[len(prompts)-1].Content != "" {
|
|
prompts[len(prompts)-1].Content += "\n" + text
|
|
} else {
|
|
prompts[len(prompts)-1].Content = text
|
|
}
|
|
if len(sigBytes) > 0 && len(prompts[len(prompts)-1].Signature) == 0 {
|
|
prompts[len(prompts)-1].Signature = sigBytes
|
|
prompts[len(prompts)-1].SignatureType = sigType
|
|
}
|
|
} else {
|
|
prompts = append(prompts, helps.DevinPrompt{
|
|
MessageID: uuid.New().String(),
|
|
Source: 2,
|
|
Content: text,
|
|
Signature: sigBytes,
|
|
SignatureType: sigType,
|
|
})
|
|
}
|
|
|
|
case "thought":
|
|
// If previous prompt was assistant, attach thinking; otherwise append assistant prompt
|
|
text := extractInteractionsStepText(step)
|
|
sigStr := firstNonEmpty(step.Get("signature").String(), step.Get("thought_signature").String())
|
|
sigBytes, sigType := parseSignatureBytes(sigStr)
|
|
if len(prompts) > 0 && prompts[len(prompts)-1].Source == 2 {
|
|
if prompts[len(prompts)-1].Thinking != "" {
|
|
prompts[len(prompts)-1].Thinking += "\n\n" + text
|
|
} else {
|
|
prompts[len(prompts)-1].Thinking = text
|
|
}
|
|
if len(sigBytes) > 0 && len(prompts[len(prompts)-1].Signature) == 0 {
|
|
prompts[len(prompts)-1].Signature = sigBytes
|
|
prompts[len(prompts)-1].SignatureType = sigType
|
|
}
|
|
} else {
|
|
prompts = append(prompts, helps.DevinPrompt{
|
|
MessageID: uuid.New().String(),
|
|
Source: 2,
|
|
Thinking: text,
|
|
Signature: sigBytes,
|
|
SignatureType: sigType,
|
|
})
|
|
}
|
|
|
|
case "function_call":
|
|
name := step.Get("name").String()
|
|
id := firstNonEmpty(step.Get("id").String(), step.Get("call_id").String())
|
|
args := step.Get("arguments").Raw
|
|
tc := helps.DevinToolCall{ID: id, Name: name, Arguments: args}
|
|
if len(prompts) > 0 && prompts[len(prompts)-1].Source == 2 {
|
|
prompts[len(prompts)-1].ToolCalls = append(prompts[len(prompts)-1].ToolCalls, tc)
|
|
} else {
|
|
prompts = append(prompts, helps.DevinPrompt{
|
|
MessageID: uuid.New().String(),
|
|
Source: 2,
|
|
ToolCalls: []helps.DevinToolCall{tc},
|
|
})
|
|
}
|
|
|
|
case "function_result":
|
|
id := firstNonEmpty(step.Get("id").String(), step.Get("call_id").String())
|
|
resText := step.Get("result").String()
|
|
if resText == "" {
|
|
resText = step.Get("result").Raw
|
|
}
|
|
prompts = append(prompts, helps.DevinPrompt{
|
|
MessageID: uuid.New().String(),
|
|
Source: 4,
|
|
ToolCallID: id,
|
|
Content: resText,
|
|
})
|
|
}
|
|
}
|
|
} else if messagesRes := root.Get("messages"); messagesRes.IsArray() {
|
|
// Fallback for direct OpenAI format if not translated
|
|
for _, m := range messagesRes.Array() {
|
|
role := strings.ToLower(strings.TrimSpace(m.Get("role").String()))
|
|
switch role {
|
|
case "system", "developer":
|
|
if systemPrompt == "" {
|
|
systemPrompt = m.Get("content").String()
|
|
}
|
|
case "user":
|
|
text, images := extractInteractionsStepContent(m)
|
|
prompts = append(prompts, helps.DevinPrompt{
|
|
MessageID: uuid.New().String(),
|
|
Source: 1,
|
|
Content: text,
|
|
Images: images,
|
|
})
|
|
case "assistant":
|
|
text := extractInteractionsStepText(m)
|
|
prompts = append(prompts, helps.DevinPrompt{
|
|
MessageID: uuid.New().String(),
|
|
Source: 2,
|
|
Content: text,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Bypass from OriginalRequest if signatures, reasoning, or images were lost in translator
|
|
if len(originalRequest) > 0 {
|
|
supplementSignaturesFromOriginal(originalRequest, prompts)
|
|
supplementImagesFromOriginal(originalRequest, prompts)
|
|
}
|
|
|
|
// 6. Tools
|
|
toolsRes := root.Get("tools")
|
|
if toolsRes.IsArray() {
|
|
for _, t := range toolsRes.Array() {
|
|
name := t.Get("name").String()
|
|
desc := t.Get("description").String()
|
|
params := t.Get("parameters").Raw
|
|
tools = append(tools, helps.DevinTool{
|
|
Name: name,
|
|
Description: desc,
|
|
Parameters: []byte(params),
|
|
})
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func parseDataURL(raw string) (mimeType string, data string, ok bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if !strings.HasPrefix(raw, "data:") {
|
|
return "", "", false
|
|
}
|
|
idxComma := strings.Index(raw, ",")
|
|
if idxComma < 0 {
|
|
return "", "", false
|
|
}
|
|
header := raw[5:idxComma]
|
|
data = raw[idxComma+1:]
|
|
parts := strings.Split(header, ";")
|
|
mimeType = strings.TrimSpace(parts[0])
|
|
if mimeType == "" {
|
|
mimeType = "image/png"
|
|
}
|
|
return mimeType, data, true
|
|
}
|
|
|
|
func mimeExtension(mime string) string {
|
|
switch strings.ToLower(strings.TrimSpace(mime)) {
|
|
case "image/jpeg", "image/jpg":
|
|
return "jpg"
|
|
case "image/webp":
|
|
return "webp"
|
|
case "image/gif":
|
|
return "gif"
|
|
default:
|
|
return "png"
|
|
}
|
|
}
|
|
|
|
func extractInteractionsStepContent(step gjson.Result) (string, []helps.DevinImage) {
|
|
content := step.Get("content")
|
|
var textParts []string
|
|
var images []helps.DevinImage
|
|
|
|
extractFromPart := func(p gjson.Result) {
|
|
partType := strings.ToLower(strings.TrimSpace(p.Get("type").String()))
|
|
switch partType {
|
|
case "text":
|
|
if t := p.Get("text").String(); t != "" {
|
|
textParts = append(textParts, t)
|
|
}
|
|
case "image", "input_image", "image_url":
|
|
base64Data := strings.TrimSpace(p.Get("data").String())
|
|
mimeType := strings.TrimSpace(p.Get("mime_type").String())
|
|
|
|
if base64Data == "" {
|
|
base64Data = strings.TrimSpace(p.Get("source.data").String())
|
|
if mimeType == "" {
|
|
mimeType = strings.TrimSpace(p.Get("source.media_type").String())
|
|
}
|
|
}
|
|
|
|
if base64Data == "" {
|
|
url := firstNonEmpty(p.Get("image_url.url").String(), p.Get("image_url").String(), p.Get("url").String())
|
|
if m, d, ok := parseDataURL(url); ok {
|
|
base64Data = d
|
|
if mimeType == "" {
|
|
mimeType = m
|
|
}
|
|
}
|
|
}
|
|
|
|
if base64Data == "" {
|
|
base64Data = strings.TrimSpace(p.Get("inline_data.data").String())
|
|
if mimeType == "" {
|
|
mimeType = strings.TrimSpace(p.Get("inline_data.mime_type").String())
|
|
}
|
|
}
|
|
|
|
if base64Data != "" {
|
|
if mimeType == "" {
|
|
mimeType = "image/png"
|
|
}
|
|
images = append(images, helps.DevinImage{
|
|
Base64Data: base64Data,
|
|
MimeType: mimeType,
|
|
})
|
|
}
|
|
default:
|
|
if t := p.Get("text").String(); t != "" {
|
|
textParts = append(textParts, t)
|
|
}
|
|
}
|
|
}
|
|
|
|
if content.Type == gjson.String {
|
|
textParts = append(textParts, content.String())
|
|
} else if content.IsArray() {
|
|
for _, p := range content.Array() {
|
|
extractFromPart(p)
|
|
}
|
|
} else if step.Get("text").Exists() {
|
|
textParts = append(textParts, step.Get("text").String())
|
|
}
|
|
|
|
text := strings.Join(textParts, "\n")
|
|
|
|
if len(images) > 0 && !strings.Contains(text, "[Image ") {
|
|
var imgHeaders []string
|
|
for i, img := range images {
|
|
ext := mimeExtension(img.MimeType)
|
|
imgHeaders = append(imgHeaders, fmt.Sprintf("[Image %d: pasted_image_%d.%s]", i+1, i+1, ext))
|
|
}
|
|
header := strings.Join(imgHeaders, "\n")
|
|
if text != "" {
|
|
text = header + "\n\n" + text
|
|
} else {
|
|
text = header
|
|
}
|
|
}
|
|
|
|
return text, images
|
|
}
|
|
|
|
func extractInteractionsStepText(step gjson.Result) string {
|
|
content := step.Get("content")
|
|
if content.Type == gjson.String {
|
|
return content.String()
|
|
}
|
|
if content.IsArray() {
|
|
var parts []string
|
|
for _, p := range content.Array() {
|
|
if t := p.Get("text").String(); t != "" {
|
|
parts = append(parts, t)
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
return step.Get("text").String()
|
|
}
|
|
|
|
func supplementImagesFromOriginal(original []byte, prompts []helps.DevinPrompt) {
|
|
origRoot := gjson.ParseBytes(original)
|
|
messages := origRoot.Get("messages")
|
|
if !messages.IsArray() {
|
|
return
|
|
}
|
|
|
|
var userImages [][]helps.DevinImage
|
|
for _, m := range messages.Array() {
|
|
if strings.EqualFold(m.Get("role").String(), "user") {
|
|
var imgs []helps.DevinImage
|
|
content := m.Get("content")
|
|
if content.IsArray() {
|
|
for _, part := range content.Array() {
|
|
partType := strings.ToLower(part.Get("type").String())
|
|
if partType == "image" || partType == "image_url" || partType == "input_image" {
|
|
data := strings.TrimSpace(part.Get("source.data").String())
|
|
mime := strings.TrimSpace(part.Get("source.media_type").String())
|
|
if data == "" {
|
|
url := firstNonEmpty(part.Get("image_url.url").String(), part.Get("image_url").String(), part.Get("url").String())
|
|
if m, d, ok := parseDataURL(url); ok {
|
|
data = d
|
|
mime = m
|
|
}
|
|
}
|
|
if data != "" {
|
|
if mime == "" {
|
|
mime = "image/png"
|
|
}
|
|
imgs = append(imgs, helps.DevinImage{Base64Data: data, MimeType: mime})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
userImages = append(userImages, imgs)
|
|
}
|
|
}
|
|
|
|
userPromptIdx := 0
|
|
for i := range prompts {
|
|
if prompts[i].Source == 1 {
|
|
if len(prompts[i].Images) == 0 && userPromptIdx < len(userImages) && len(userImages[userPromptIdx]) > 0 {
|
|
prompts[i].Images = userImages[userPromptIdx]
|
|
if !strings.Contains(prompts[i].Content, "[Image ") {
|
|
var imgHeaders []string
|
|
for imgIdx, img := range prompts[i].Images {
|
|
imgHeaders = append(imgHeaders, fmt.Sprintf("[Image %d: pasted_image_%d.%s]", imgIdx+1, imgIdx+1, mimeExtension(img.MimeType)))
|
|
}
|
|
header := strings.Join(imgHeaders, "\n")
|
|
if prompts[i].Content != "" {
|
|
prompts[i].Content = header + "\n\n" + prompts[i].Content
|
|
} else {
|
|
prompts[i].Content = header
|
|
}
|
|
}
|
|
}
|
|
userPromptIdx++
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseSignatureBytes(sigStr string) ([]byte, string) {
|
|
s := strings.TrimSpace(sigStr)
|
|
if s == "" {
|
|
return nil, ""
|
|
}
|
|
if strings.HasPrefix(s, "sealed.v1.") {
|
|
return []byte(s), "sealed"
|
|
}
|
|
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil && len(decoded) > 0 {
|
|
decStr := string(decoded)
|
|
if strings.HasPrefix(decStr, "sealed.v1.") {
|
|
return decoded, "sealed"
|
|
}
|
|
if strings.HasPrefix(decStr, "CAQS") || strings.HasPrefix(decStr, "CAIS") {
|
|
return decoded, "anthropic"
|
|
}
|
|
if strings.HasPrefix(decStr, "gAAAA") {
|
|
return decoded, "openai"
|
|
}
|
|
}
|
|
return []byte(s), detectSignatureType(s)
|
|
}
|
|
|
|
func detectSignatureType(sig string) string {
|
|
s := strings.TrimSpace(sig)
|
|
if strings.HasPrefix(s, "sealed.v1.") {
|
|
return "sealed"
|
|
}
|
|
if strings.HasPrefix(s, "CAQS") || strings.HasPrefix(s, "CAIS") {
|
|
return "anthropic"
|
|
}
|
|
if strings.HasPrefix(s, "gAAAA") {
|
|
return "openai"
|
|
}
|
|
return "sealed"
|
|
}
|
|
|
|
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
|
|
for _, m := range messages.Array() {
|
|
if strings.EqualFold(m.Get("role").String(), "assistant") {
|
|
content := m.Get("content")
|
|
if content.IsArray() {
|
|
for _, part := range content.Array() {
|
|
if part.Get("type").String() == "thinking" {
|
|
if sig := part.Get("signature").String(); sig != "" {
|
|
bytes, sType := parseSignatureBytes(sig)
|
|
if len(bytes) > 0 {
|
|
assistantSigs = append(assistantSigs, bytes)
|
|
assistantSigTypes = append(assistantSigTypes, sType)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sigIdx := 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++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *DevinExecutor) getSensitiveWords() []string {
|
|
if e != nil && e.cfg != nil && len(e.cfg.Devin.SensitiveWords) > 0 {
|
|
return e.cfg.Devin.SensitiveWords
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newDevinStatusError(code int, headers http.Header, body []byte) statusErr {
|
|
err := statusErr{code: code, msg: string(body)}
|
|
if code == http.StatusTooManyRequests && headers != nil {
|
|
if raw := strings.TrimSpace(headers.Get("Retry-After")); raw != "" {
|
|
if seconds, errParse := strconv.ParseInt(raw, 10, 64); errParse == nil && seconds >= 0 {
|
|
delay := time.Duration(seconds) * time.Second
|
|
err.retryAfter = &delay
|
|
} else if deadline, errParse := http.ParseTime(raw); errParse == nil {
|
|
delay := time.Until(deadline)
|
|
if delay > 0 {
|
|
err.retryAfter = &delay
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func devinAuthCredentials(auth *cliproxyauth.Auth) (apiKey string, baseURL string, deviceSeed string) {
|
|
if auth == nil {
|
|
return "", helps.DevinDefaultBaseURL, ""
|
|
}
|
|
baseURL = helps.DevinDefaultBaseURL
|
|
if auth.Attributes != nil {
|
|
if v := strings.TrimSpace(auth.Attributes["api_key"]); v != "" {
|
|
apiKey = v
|
|
}
|
|
if v := strings.TrimSpace(auth.Attributes["session_token"]); v != "" && apiKey == "" {
|
|
apiKey = v
|
|
}
|
|
if v := strings.TrimSpace(auth.Attributes["token"]); v != "" && apiKey == "" {
|
|
apiKey = v
|
|
}
|
|
if v := strings.TrimSpace(auth.Attributes["base_url"]); v != "" {
|
|
baseURL = v
|
|
}
|
|
if v := strings.TrimSpace(auth.Attributes["device_seed"]); v != "" {
|
|
deviceSeed = v
|
|
}
|
|
}
|
|
if auth.Metadata != nil {
|
|
if v, ok := auth.Metadata["api_key"].(string); ok && strings.TrimSpace(v) != "" && apiKey == "" {
|
|
apiKey = strings.TrimSpace(v)
|
|
}
|
|
if v, ok := auth.Metadata["session_token"].(string); ok && strings.TrimSpace(v) != "" && apiKey == "" {
|
|
apiKey = strings.TrimSpace(v)
|
|
}
|
|
if v, ok := auth.Metadata["base_url"].(string); ok && strings.TrimSpace(v) != "" && baseURL == helps.DevinDefaultBaseURL {
|
|
baseURL = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func devinAuthLogFields(auth *cliproxyauth.Auth) (authID, authLabel, authType, authValue string) {
|
|
if auth == nil {
|
|
return "", "", "devin", ""
|
|
}
|
|
authID = auth.ID
|
|
authLabel = auth.Label
|
|
authType = "devin"
|
|
apiKey, _, _ := devinAuthCredentials(auth)
|
|
if apiKey != "" {
|
|
if len(apiKey) > 8 {
|
|
authValue = apiKey[:4] + "..." + apiKey[len(apiKey)-4:]
|
|
} else {
|
|
authValue = "***"
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func resolveDevinSessionAndCascadeIDs(ctx context.Context, sessionID, cascadeID string, opts cliproxyexecutor.Options) (string, string) {
|
|
if sessionID == "" {
|
|
if ctxSession := util.SessionIDFromContext(ctx); ctxSession != "" {
|
|
sessionID = ctxSession
|
|
} else if canon := cliproxyauth.CanonicalSessionID(opts.Headers, opts.OriginalRequest, opts.Metadata); canon != "" {
|
|
sessionID = canon
|
|
}
|
|
}
|
|
sessionID = normalizeDevinUUID(sessionID)
|
|
if cascadeID == "" {
|
|
cascadeID = sessionID
|
|
} else {
|
|
cascadeID = normalizeDevinUUID(cascadeID)
|
|
}
|
|
return sessionID, cascadeID
|
|
}
|
|
|
|
func normalizeDevinUUID(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return uuid.New().String()
|
|
}
|
|
if _, err := uuid.Parse(raw); err == nil {
|
|
return raw
|
|
}
|
|
// Deterministically map any non-UUID session string (e.g. lcp:hash, conv:id) to an RFC 4122 UUID v5
|
|
return uuid.NewSHA1(uuid.NameSpaceOID, []byte(raw)).String()
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|