From faec6d04caecbda49fad34644b8526b8be7fe4a3 Mon Sep 17 00:00:00 2001 From: gwizz Date: Fri, 14 Aug 2026 19:52:18 +1000 Subject: [PATCH 1/6] feat(executor): add Devin (Codeium Cascade) provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new Devin executor that talks to the Codeium Cascade backend (server.codeium.com) using the Connect-RPC protocol, impersonating the Devin CLI (chisel). This enables CLIProxyAPIPlus to serve Devin's free models (GLM-5.2 High, SWE-1.7, SWE-1.6) through the standard OpenAI chat completions API. The executor implements the ProviderExecutor interface with: - OpenAI chat completion → Devin GetChatMessage protobuf translation - Connect-RPC framing (uncompressed, flag 0x00) matching the Devin CLI - Streaming response parsing (7-frame: init, thinking, text, finish, usage, display stats, trailer) with OpenAI SSE conversion - Non-streaming mode that collects stream chunks into a single response - Basic auth header (Basic -) matching the CLI pattern - Session token normalization (devin-session-token$ prefix) - Deterministic attestation field (f) generation - Caller overrides for maxTokens, temperature, and topP Auth is configured via a JSON file in the auth directory with: {"type": "devin", "devin_session_token": ""} Models are registered statically in models.json with 6 free Devin models. The executor is registered in service_executors.go and included in the baseline executor auths. Wire format verified against the Devin CLI v3000.4.25 via mitmproxy: - Metadata fields match (ideName, extensionName, ideType, version, os, f) - Completion config defaults match (maxTokens=128000, temperature=1.0, topK=40, topP=0.95, maxNewlines=400) - Request fields match (requestType=CASCADE, plannerMode=DEFAULT) - HTTP headers match (authorization, content-type, connect-protocol-version) All 15 unit tests pass covering protobuf encoding/decoding, Connect frame parsing, OpenAI request parsing, Devin request building, and OpenAI response/SSE generation. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/registry/model_definitions.go | 6 + internal/registry/models/models.json | 100 ++- internal/runtime/executor/devin_executor.go | 710 ++++++++++++++++++ .../runtime/executor/devin_executor_test.go | 470 ++++++++++++ internal/runtime/executor/devin_protobuf.go | 526 +++++++++++++ sdk/cliproxy/service_executors.go | 3 + 6 files changed, 1799 insertions(+), 16 deletions(-) create mode 100644 internal/runtime/executor/devin_executor.go create mode 100644 internal/runtime/executor/devin_executor_test.go create mode 100644 internal/runtime/executor/devin_protobuf.go diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index f17f403ca..104355d6f 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -31,6 +31,7 @@ type staticModelsJSON struct { Qoder []*ModelInfo `json:"qoder"` Antigravity []*ModelInfo `json:"antigravity"` XAI []*ModelInfo `json:"xai"` + Devin []*ModelInfo `json:"devin"` } // GetClaudeModels returns the standard Claude model definitions. @@ -888,3 +889,8 @@ func GetAmazonQModels() []*ModelInfo { func GetQoderModels() []*ModelInfo { return cloneModelInfos(getModels().Qoder) } + +// GetDevinModels returns the standard Devin model definitions. +func GetDevinModels() []*ModelInfo { + return cloneModelInfos(getModels().Devin) +} diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index 77a8a027f..002c3fbcf 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -1816,7 +1816,7 @@ "type": "openai", "display_name": "GPT 5.6 Sol", "version": "gpt-5.6", - "description": "Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research, produce polished documents, and take on your most ambitious work. Sol is highly capable at lower reasoning efforts—try starting lower, then turn it up for harder jobs.", + "description": "Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research, produce polished documents, and take on your most ambitious work. Sol is highly capable at lower reasoning efforts\u2014try starting lower, then turn it up for harder jobs.", "context_length": 372000, "max_completion_tokens": 128000, "supported_parameters": [ @@ -2011,7 +2011,7 @@ "type": "openai", "display_name": "GPT 5.6 Sol", "version": "gpt-5.6", - "description": "Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research, produce polished documents, and take on your most ambitious work. Sol is highly capable at lower reasoning efforts—try starting lower, then turn it up for harder jobs.", + "description": "Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research, produce polished documents, and take on your most ambitious work. Sol is highly capable at lower reasoning efforts\u2014try starting lower, then turn it up for harder jobs.", "context_length": 372000, "max_completion_tokens": 128000, "supported_parameters": [ @@ -2206,7 +2206,7 @@ "type": "openai", "display_name": "GPT 5.6 Sol", "version": "gpt-5.6", - "description": "Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research, produce polished documents, and take on your most ambitious work. Sol is highly capable at lower reasoning efforts—try starting lower, then turn it up for harder jobs.", + "description": "Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research, produce polished documents, and take on your most ambitious work. Sol is highly capable at lower reasoning efforts\u2014try starting lower, then turn it up for harder jobs.", "context_length": 372000, "max_completion_tokens": 128000, "supported_parameters": [ @@ -2688,7 +2688,7 @@ "type": "xai", "display_name": "Grok Build 0.1", "name": "grok-build-0.1", - "description": "Grok Build 0.1 is xAI’s fast coding model trained specifically for agentic software engineering workflows.", + "description": "Grok Build 0.1 is xAI\u2019s fast coding model trained specifically for agentic software engineering workflows.", "context_length": 256000, "max_completion_tokens": 256000 }, @@ -2835,7 +2835,7 @@ "owned_by": "qoder", "type": "qoder", "display_name": "Qoder Auto", - "description": "Qoder Auto — automatically selects the best model for your prompt", + "description": "Qoder Auto \u2014 automatically selects the best model for your prompt", "context_length": 180000, "supported_input_modalities": [ "TEXT", @@ -2849,7 +2849,7 @@ "owned_by": "qoder", "type": "qoder", "display_name": "Qoder Ultimate", - "description": "Qoder Ultimate — highest quality tier", + "description": "Qoder Ultimate \u2014 highest quality tier", "context_length": 180000, "supported_input_modalities": [ "TEXT", @@ -2876,7 +2876,7 @@ "owned_by": "qoder", "type": "qoder", "display_name": "Qoder Performance", - "description": "Qoder Performance — balanced quality and speed", + "description": "Qoder Performance \u2014 balanced quality and speed", "context_length": 272000, "supported_input_modalities": [ "TEXT", @@ -2903,7 +2903,7 @@ "owned_by": "qoder", "type": "qoder", "display_name": "Qoder Efficient", - "description": "Qoder Efficient — cost-efficient tier", + "description": "Qoder Efficient \u2014 cost-efficient tier", "context_length": 180000, "supported_input_modalities": [ "TEXT", @@ -2917,7 +2917,7 @@ "owned_by": "qoder", "type": "qoder", "display_name": "Qoder Lite", - "description": "Qoder Lite — fastest and most affordable tier", + "description": "Qoder Lite \u2014 fastest and most affordable tier", "context_length": 180000 }, { @@ -2927,7 +2927,7 @@ "owned_by": "qoder", "type": "qoder", "display_name": "Qwen3.7 Plus (Qoder)", - "description": "Qoder frontier — Qwen3.7 Plus", + "description": "Qoder frontier \u2014 Qwen3.7 Plus", "context_length": 180000, "supported_input_modalities": [ "TEXT", @@ -2941,7 +2941,7 @@ "owned_by": "qoder", "type": "qoder", "display_name": "Qwen3.7 Max (Qoder)", - "description": "Qoder frontier — Qwen3.7 Max (latest Qwen)", + "description": "Qoder frontier \u2014 Qwen3.7 Max (latest Qwen)", "context_length": 180000, "supported_input_modalities": [ "TEXT", @@ -2955,7 +2955,7 @@ "owned_by": "qoder", "type": "qoder", "display_name": "DeepSeek V4 Pro (Qoder)", - "description": "Qoder frontier — DeepSeek V4 Pro", + "description": "Qoder frontier \u2014 DeepSeek V4 Pro", "context_length": 180000, "supported_input_modalities": [ "TEXT", @@ -2979,7 +2979,7 @@ "owned_by": "qoder", "type": "qoder", "display_name": "DeepSeek V4 Flash (Qoder)", - "description": "Qoder frontier — DeepSeek V4 Flash", + "description": "Qoder frontier \u2014 DeepSeek V4 Flash", "context_length": 180000, "supported_input_modalities": [ "TEXT", @@ -3003,7 +3003,7 @@ "owned_by": "qoder", "type": "qoder", "display_name": "GLM 5.1 (Qoder)", - "description": "Qoder frontier — GLM 5.1", + "description": "Qoder frontier \u2014 GLM 5.1", "context_length": 180000, "supported_input_modalities": [ "TEXT", @@ -3027,7 +3027,7 @@ "owned_by": "qoder", "type": "qoder", "display_name": "Kimi K2.6 (Qoder)", - "description": "Qoder frontier — Kimi K2.6", + "description": "Qoder frontier \u2014 Kimi K2.6", "context_length": 256000, "supported_input_modalities": [ "TEXT", @@ -3041,12 +3041,80 @@ "owned_by": "qoder", "type": "qoder", "display_name": "MiniMax M3 (Qoder)", - "description": "Qoder frontier — MiniMax M3", + "description": "Qoder frontier \u2014 MiniMax M3", "context_length": 180000, "supported_input_modalities": [ "TEXT", "IMAGE" ] } + ], + "devin": [ + { + "id": "glm-5-2", + "object": "model", + "created": 1757500000, + "owned_by": "devin", + "type": "devin", + "display_name": "GLM-5.2 High", + "description": "GLM-5.2 High - Free model via Devin CLI (200K context)", + "context_length": 200000, + "max_completion_tokens": 64000 + }, + { + "id": "glm-5-2-1m", + "object": "model", + "created": 1757500000, + "owned_by": "devin", + "type": "devin", + "display_name": "GLM-5.2 High 1M", + "description": "GLM-5.2 High 1M context - via Devin CLI", + "context_length": 1000000, + "max_completion_tokens": 64000 + }, + { + "id": "glm-5-2-max", + "object": "model", + "created": 1757500000, + "owned_by": "devin", + "type": "devin", + "display_name": "GLM-5.2 Max", + "description": "GLM-5.2 Max - via Devin CLI", + "context_length": 200000, + "max_completion_tokens": 64000 + }, + { + "id": "swe-1-7", + "object": "model", + "created": 1757500000, + "owned_by": "devin", + "type": "devin", + "display_name": "SWE-1.7 Max", + "description": "SWE-1.7 Max - Free model via Devin CLI (262K context)", + "context_length": 262000, + "max_completion_tokens": 64000 + }, + { + "id": "swe-1-7-medium", + "object": "model", + "created": 1757500000, + "owned_by": "devin", + "type": "devin", + "display_name": "SWE-1.7 Medium", + "description": "SWE-1.7 Medium - Free model via Devin CLI (262K context)", + "context_length": 262000, + "max_completion_tokens": 64000 + }, + { + "id": "swe-1-6", + "object": "model", + "created": 1757500000, + "owned_by": "devin", + "type": "devin", + "display_name": "SWE-1.6", + "description": "SWE-1.6 - Free model via Devin CLI (200K context)", + "context_length": 200000, + "max_completion_tokens": 64000 + } ] } \ No newline at end of file diff --git a/internal/runtime/executor/devin_executor.go b/internal/runtime/executor/devin_executor.go new file mode 100644 index 000000000..504c1b8f4 --- /dev/null +++ b/internal/runtime/executor/devin_executor.go @@ -0,0 +1,710 @@ +package executor + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "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" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" +) + +// DevinExecutor handles requests to the Devin (Codeium Cascade) API using the +// Connect-RPC protocol. It impersonates the Devin CLI (chisel) and translates +// between OpenAI chat completion format and the Devin GetChatMessage protobuf. +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 identifier for this executor. +func (e *DevinExecutor) Identifier() string { return "devin" } + +// PrepareRequest prepares a raw HTTP request with Devin auth headers. +func (e *DevinExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + token := devinCredentials(auth) + if token == "" { + return fmt.Errorf("devin: missing session token") + } + req.Header.Set("Authorization", "Basic "+token+"-"+token) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(req, attrs) + return nil +} + +// HttpRequest executes a raw HTTP request with Devin auth. +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 := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + return httpClient.Do(httpReq) +} + +// Execute performs a non-streaming request. Devin only supports streaming, +// so we collect all stream chunks and assemble a single response. +func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + streamResult, err := e.ExecuteStream(ctx, auth, req, opts) + if err != nil { + return resp, err + } + + var allText strings.Builder + var thinkingText strings.Builder + var stopReason string + var usageDetail *devinUsageStats + var toolCalls []devinToolCall + var messageID string + + for chunk := range streamResult.Chunks { + if chunk.Err != nil { + return resp, chunk.Err + } + parsed, errParse := parseGetChatMessageResponse(chunk.Payload) + if errParse != nil { + continue + } + if parsed.MessageID != "" { + messageID = parsed.MessageID + } + if parsed.DeltaText != "" { + allText.WriteString(parsed.DeltaText) + } + if parsed.DeltaThinking != "" { + thinkingText.WriteString(parsed.DeltaThinking) + } + if parsed.StopReason != 0 { + stopReason = devinStopReasonToString(parsed.StopReason) + } + if parsed.Usage != nil { + usageDetail = parsed.Usage + } + if len(parsed.DeltaToolCalls) > 0 { + toolCalls = append(toolCalls, parsed.DeltaToolCalls...) + } + } + + if stopReason == "" { + stopReason = "stop" + } + + openaiResp := buildOpenAIChatCompletion(messageID, req.Model, allText.String(), thinkingText.String(), stopReason, toolCalls, usageDetail) + resp = cliproxyexecutor.Response{Payload: openaiResp} + return resp, nil +} + +// ExecuteStream performs a streaming request to the Devin GetChatMessage endpoint. +func (e *DevinExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { + baseModel := thinking.ParseSuffix(req.Model).ModelName + + reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth) + defer reporter.trackFailure(ctx, &err) + + token := devinCredentials(auth) + if token == "" { + return nil, fmt.Errorf("devin: missing session token") + } + + parsedReq, err := devinParseOpenAIRequest(req.Payload) + if err != nil { + return nil, fmt.Errorf("devin: failed to parse request: %w", err) + } + + devinReq := buildDevinRequest(parsedReq, token, baseModel) + reqBytes := encodeGetChatMessageRequest(devinReq) + frame := frameConnectMessage(reqBytes) + + url := devinAPIBaseURL + devinChatPath + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(frame)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", devinConnectProto) + httpReq.Header.Set("Connect-Protocol-Version", "1") + httpReq.Header.Set("Authorization", "Basic "+token+"-"+token) + httpReq.Header.Set("User-Agent", "") + httpReq.Header.Set("Accept-Encoding", "identity") + httpReq.Header.Set("Accept", "*/*") + + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) + + var authID, authLabel, authType, authValue string + if auth != nil { + authID = auth.ID + authLabel = auth.Label + authType, authValue = auth.AccountInfo() + } + recordAPIRequest(ctx, e.cfg, upstreamRequestLog{ + URL: url, + Method: http.MethodPost, + Headers: httpReq.Header.Clone(), + Body: frame, + Provider: e.Identifier(), + AuthID: authID, + AuthLabel: authLabel, + AuthType: authType, + AuthValue: authValue, + }) + + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpResp, err := httpClient.Do(httpReq) + if err != nil { + recordAPIResponseError(ctx, e.cfg, err) + return nil, err + } + + recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + b, _ := io.ReadAll(httpResp.Body) + appendAPIResponseChunk(ctx, e.cfg, b) + httpResp.Body.Close() + err = statusErr{code: httpResp.StatusCode, msg: string(b)} + return nil, err + } + + out := make(chan cliproxyexecutor.StreamChunk) + go func() { + defer close(out) + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("devin: failed to close response body: %v", errClose) + } + }() + + buf := make([]byte, 0, 64*1024) + tmp := make([]byte, 32*1024) + var usagePublished bool + + for { + n, errRead := httpResp.Body.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + appendAPIResponseChunk(ctx, e.cfg, tmp[:n]) + } + + // Parse complete Connect frames from the buffer. + for len(buf) >= 5 { + f, remaining, errFrame := readConnectFrame(buf) + if errFrame != nil { + break // need more data + } + buf = remaining + + if f.Flag&connectFlagEndStream != 0 { + continue // trailer frame (JSON), skip + } + + chunk, errParse := parseGetChatMessageResponse(f.Payload) + if errParse != nil { + log.Debugf("devin: failed to parse response frame: %v", errParse) + continue + } + + // Publish usage stats once. + if chunk.Usage != nil && !usagePublished { + reporter.publish(ctx, usage.Detail{ + InputTokens: int64(chunk.Usage.InputTokens), + OutputTokens: int64(chunk.Usage.OutputTokens), + CacheReadTokens: int64(chunk.Usage.CacheReadTokens), + TotalTokens: int64(chunk.Usage.InputTokens + chunk.Usage.OutputTokens), + }) + usagePublished = true + } + + sseData := buildOpenAIStreamChunk(chunk, req.Model) + if sseData != "" { + out <- cliproxyexecutor.StreamChunk{Payload: []byte(sseData)} + } + } + + if errRead != nil { + if errRead != io.EOF { + recordAPIResponseError(ctx, e.cfg, errRead) + reporter.publishFailure(ctx) + out <- cliproxyexecutor.StreamChunk{Err: errRead} + } + break + } + } + + out <- cliproxyexecutor.StreamChunk{Payload: []byte("data: [DONE]\n\n")} + if !usagePublished { + reporter.ensurePublished(ctx) + } + }() + + return &cliproxyexecutor.StreamResult{ + Headers: httpResp.Header.Clone(), + Chunks: out, + }, nil +} + +// Refresh validates the Devin token. Devin session tokens are static and don't +// support refresh, so we just return the auth unchanged. +func (e *DevinExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if auth == nil { + return nil, fmt.Errorf("missing auth") + } + return auth, nil +} + +// CountTokens returns the token count for the given request. +func (e *DevinExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, fmt.Errorf("devin: count tokens not supported") +} + +// devinCredentials extracts the session token from auth metadata/attributes. +func devinCredentials(auth *cliproxyauth.Auth) string { + if auth == nil { + return "" + } + if auth.Metadata != nil { + if token, ok := auth.Metadata["devin_session_token"].(string); ok && token != "" { + return normalizeDevinSessionToken(token) + } + if token, ok := auth.Metadata["session_token"].(string); ok && token != "" { + return normalizeDevinSessionToken(token) + } + if token, ok := auth.Metadata["api_key"].(string); ok && token != "" { + return normalizeDevinSessionToken(token) + } + } + if auth.Attributes != nil { + if token := auth.Attributes["devin_session_token"]; token != "" { + return normalizeDevinSessionToken(token) + } + if token := auth.Attributes["session_token"]; token != "" { + return normalizeDevinSessionToken(token) + } + if token := auth.Attributes[cliproxyauth.AttributeAPIKey]; token != "" { + return normalizeDevinSessionToken(token) + } + } + return "" +} + +// devinOpenAIMessage represents a message in the OpenAI chat format. +type devinOpenAIMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content,omitempty"` + ToolCalls []devinOpenAIToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +type devinOpenAIToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +type devinOpenAITool struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` + } `json:"function"` +} + +// devinParsedRequest holds the extracted fields from an OpenAI chat request. +type devinParsedRequest struct { + Model string + Messages []devinOpenAIMessage + Tools []devinOpenAITool + Temperature *float64 + MaxTokens *int + TopP *float64 + Stream bool + SystemPrompt string +} + +// devinParseOpenAIRequest extracts messages, tools, and config from an OpenAI chat request payload. +func devinParseOpenAIRequest(payload []byte) (*devinParsedRequest, error) { + parsed := &devinParsedRequest{} + parsed.Model = gjson.GetBytes(payload, "model").String() + parsed.Stream = gjson.GetBytes(payload, "stream").Bool() + + if temp := gjson.GetBytes(payload, "temperature"); temp.Exists() { + t := temp.Float() + parsed.Temperature = &t + } + if mt := gjson.GetBytes(payload, "max_tokens"); mt.Exists() { + m := int(mt.Int()) + parsed.MaxTokens = &m + } + if tp := gjson.GetBytes(payload, "top_p"); tp.Exists() { + p := tp.Float() + parsed.TopP = &p + } + + messagesResult := gjson.GetBytes(payload, "messages") + if !messagesResult.IsArray() { + return nil, fmt.Errorf("messages field is required and must be an array") + } + messagesResult.ForEach(func(_, msg gjson.Result) bool { + var m devinOpenAIMessage + m.Role = msg.Get("role").String() + contentResult := msg.Get("content") + if contentResult.Exists() { + if contentResult.Type == gjson.String { + m.Content = json.RawMessage(`"` + contentResult.String() + `"`) + } else { + m.Content = json.RawMessage(contentResult.Raw) + } + } + toolCallsResult := msg.Get("tool_calls") + if toolCallsResult.IsArray() { + toolCallsResult.ForEach(func(_, tc gjson.Result) bool { + var openAITC devinOpenAIToolCall + openAITC.ID = tc.Get("id").String() + openAITC.Type = tc.Get("type").String() + openAITC.Function.Name = tc.Get("function.name").String() + openAITC.Function.Arguments = tc.Get("function.arguments").String() + m.ToolCalls = append(m.ToolCalls, openAITC) + return true + }) + } + m.ToolCallID = msg.Get("tool_call_id").String() + m.Name = msg.Get("name").String() + parsed.Messages = append(parsed.Messages, m) + return true + }) + + for _, msg := range parsed.Messages { + if msg.Role == "system" { + if len(msg.Content) > 0 { + var s string + if err := json.Unmarshal(msg.Content, &s); err == nil { + if parsed.SystemPrompt != "" { + parsed.SystemPrompt += "\n" + } + parsed.SystemPrompt += s + } + } + } + } + + toolsResult := gjson.GetBytes(payload, "tools") + if toolsResult.IsArray() { + toolsResult.ForEach(func(_, tool gjson.Result) bool { + var t devinOpenAITool + t.Type = tool.Get("type").String() + t.Function.Name = tool.Get("function.name").String() + t.Function.Description = tool.Get("function.description").String() + paramsResult := tool.Get("function.parameters") + if paramsResult.Exists() { + t.Function.Parameters = json.RawMessage(paramsResult.Raw) + } + parsed.Tools = append(parsed.Tools, t) + return true + }) + } + + return parsed, nil +} + +// buildDevinRequest converts an OpenAI request to a Devin GetChatMessageRequest. +func buildDevinRequest(parsed *devinParsedRequest, token, model string) devinRequest { + cascadeID := uuid.New().String() + installID := "cliproxyapi-" + cascadeID[:8] + + req := devinRequest{ + Metadata: devinMetadata{ + IdeName: "devin-cli", + ExtensionVersion: "3000.4.25", + ApiKey: token, + Locale: "en", + OS: "darwin", + IdeVersion: "3000.4.25", + ExtensionName: "chisel", + IdeType: "chisel", + F: generateAttestationF(installID), + }, + Prompt: parsed.SystemPrompt, + RequestType: devinRequestTypeCascade, + Configuration: devinCompletionConfig{ + NumCompletions: 1, + MaxTokens: 128000, + MaxNewlines: 400, + Temperature: 1.0, + TopK: 40, + TopP: 0.95, + }, + CascadeID: cascadeID, + PlannerMode: devinPlannerModeDefault, + ChatModelUID: model, + } + + if parsed.MaxTokens != nil { + req.Configuration.MaxTokens = uint64(*parsed.MaxTokens) + } + if parsed.Temperature != nil { + req.Configuration.Temperature = *parsed.Temperature + } + if parsed.TopP != nil { + req.Configuration.TopP = *parsed.TopP + } + + for _, msg := range parsed.Messages { + if msg.Role == "system" { + continue + } + + prompt := devinChatMessagePrompt{ + MessageID: uuid.New().String(), + } + + switch msg.Role { + case "user": + prompt.Source = devinMsgSourceUser + prompt.Prompt = devinExtractTextContent(msg.Content) + case "assistant": + prompt.Source = devinMsgSourceSystem + prompt.Prompt = devinExtractTextContent(msg.Content) + prompt.MessageID = "bot-" + uuid.New().String() + for _, tc := range msg.ToolCalls { + prompt.ToolCalls = append(prompt.ToolCalls, devinToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + ArgumentsJSON: tc.Function.Arguments, + }) + } + case "tool": + prompt.Source = devinMsgSourceTool + prompt.Prompt = devinExtractTextContent(msg.Content) + prompt.ToolCallID = msg.ToolCallID + } + + req.ChatMessagePrompts = append(req.ChatMessagePrompts, prompt) + } + + for _, tool := range parsed.Tools { + req.Tools = append(req.Tools, devinToolDefinition{ + Name: tool.Function.Name, + Description: tool.Function.Description, + JSONSchemaString: string(tool.Function.Parameters), + }) + } + + return req +} + +// devinExtractTextContent extracts text from an OpenAI content field (string or array of parts). +func devinExtractTextContent(content json.RawMessage) string { + if len(content) == 0 { + return "" + } + var s string + if err := json.Unmarshal(content, &s); err == nil { + return s + } + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(content, &parts); err == nil { + var b strings.Builder + for _, p := range parts { + if p.Type == "text" { + b.WriteString(p.Text) + } + } + return b.String() + } + return "" +} + +// buildOpenAIChatCompletion creates a non-streaming OpenAI chat completion response. +func buildOpenAIChatCompletion(messageID, model, text, thinking, stopReason string, toolCalls []devinToolCall, usageStats *devinUsageStats) []byte { + var b strings.Builder + b.WriteString(`{"id":"chatcmpl-`) + b.WriteString(messageID) + b.WriteString(`","object":"chat.completion","created":`) + b.WriteString(fmt.Sprintf("%d", time.Now().Unix())) + b.WriteString(`,"model":"`) + b.WriteString(model) + b.WriteString(`","choices":[{"index":0,"message":{"role":"assistant"`) + + if thinking != "" { + b.WriteString(`,"reasoning":"`) + b.WriteString(devinJSONEscape(thinking)) + b.WriteString(`"`) + } + + if text != "" { + b.WriteString(`,"content":"`) + b.WriteString(devinJSONEscape(text)) + b.WriteString(`"`) + } else { + b.WriteString(`,"content":null`) + } + + if len(toolCalls) > 0 { + b.WriteString(`,"tool_calls":[`) + for i, tc := range toolCalls { + if i > 0 { + b.WriteString(",") + } + b.WriteString(`{"id":"`) + b.WriteString(devinJSONEscape(tc.ID)) + b.WriteString(`","type":"function","function":{"name":"`) + b.WriteString(devinJSONEscape(tc.Name)) + b.WriteString(`","arguments":"`) + b.WriteString(devinJSONEscape(tc.ArgumentsJSON)) + b.WriteString(`"}}`) + } + b.WriteString(`]`) + } + + b.WriteString(`},"finish_reason":"`) + b.WriteString(stopReason) + b.WriteString(`"}],"usage":{"prompt_tokens":`) + if usageStats != nil { + b.WriteString(fmt.Sprintf("%d", usageStats.InputTokens)) + b.WriteString(`,"completion_tokens":`) + b.WriteString(fmt.Sprintf("%d", usageStats.OutputTokens)) + b.WriteString(`,"total_tokens":`) + b.WriteString(fmt.Sprintf("%d", usageStats.InputTokens+usageStats.OutputTokens)) + } else { + b.WriteString("0,\"completion_tokens\":0,\"total_tokens\":0") + } + b.WriteString(`}}`) + return []byte(b.String()) +} + +// buildOpenAIStreamChunk converts a Devin response chunk to an OpenAI SSE data line. +func buildOpenAIStreamChunk(chunk *devinStreamChunk, model string) string { + if chunk.MessageID == "" && chunk.DeltaText == "" && chunk.DeltaThinking == "" && chunk.StopReason == 0 && len(chunk.DeltaToolCalls) == 0 { + return "" + } + + var b strings.Builder + b.WriteString("data: {") + b.WriteString(`"id":"chatcmpl-`) + if chunk.MessageID != "" { + b.WriteString(devinJSONEscape(chunk.MessageID)) + } else { + b.WriteString("devin-stream") + } + b.WriteString(`","object":"chat.completion.chunk","created":`) + b.WriteString(fmt.Sprintf("%d", time.Now().Unix())) + b.WriteString(`,"model":"`) + b.WriteString(devinJSONEscape(model)) + b.WriteString(`","choices":[{"index":0,"delta":{`) + + first := true + if chunk.DeltaThinking != "" { + if !first { + b.WriteString(",") + } + first = false + b.WriteString(`"reasoning":"`) + b.WriteString(devinJSONEscape(chunk.DeltaThinking)) + b.WriteString(`"`) + } + if chunk.DeltaText != "" { + if !first { + b.WriteString(",") + } + first = false + b.WriteString(`"content":"`) + b.WriteString(devinJSONEscape(chunk.DeltaText)) + b.WriteString(`"`) + } + if len(chunk.DeltaToolCalls) > 0 { + if !first { + b.WriteString(",") + } + first = false + b.WriteString(`"tool_calls":[`) + for i, tc := range chunk.DeltaToolCalls { + if i > 0 { + b.WriteString(",") + } + b.WriteString(`{"index":0,"id":"`) + b.WriteString(devinJSONEscape(tc.ID)) + b.WriteString(`","type":"function","function":{"name":"`) + b.WriteString(devinJSONEscape(tc.Name)) + b.WriteString(`","arguments":"`) + b.WriteString(devinJSONEscape(tc.ArgumentsJSON)) + b.WriteString(`"}}`) + } + b.WriteString(`]`) + } + if chunk.StopReason != 0 { + if !first { + b.WriteString(",") + } + first = false + b.WriteString(`"role":"assistant"`) + } + + b.WriteString(`},"finish_reason":`) + if chunk.StopReason != 0 { + b.WriteString(`"`) + b.WriteString(devinStopReasonToString(chunk.StopReason)) + b.WriteString(`"`) + } else { + b.WriteString("null") + } + b.WriteString(`}]}`) + + if chunk.Usage != nil { + b.WriteString(`,"usage":{"prompt_tokens":`) + b.WriteString(fmt.Sprintf("%d", chunk.Usage.InputTokens)) + b.WriteString(`,"completion_tokens":`) + b.WriteString(fmt.Sprintf("%d", chunk.Usage.OutputTokens)) + b.WriteString(`,"total_tokens":`) + b.WriteString(fmt.Sprintf("%d", chunk.Usage.InputTokens+chunk.Usage.OutputTokens)) + b.WriteString(`}`) + } + + b.WriteString("}\n\n") + return b.String() +} + +// devinJSONEscape escapes a string for safe embedding in JSON. +func devinJSONEscape(s string) string { + b, err := json.Marshal(s) + if err != nil { + return "" + } + return string(b[1 : len(b)-1]) +} diff --git a/internal/runtime/executor/devin_executor_test.go b/internal/runtime/executor/devin_executor_test.go new file mode 100644 index 000000000..143538427 --- /dev/null +++ b/internal/runtime/executor/devin_executor_test.go @@ -0,0 +1,470 @@ +package executor + +import ( + "encoding/json" + "testing" + + "google.golang.org/protobuf/encoding/protowire" +) + +func TestDevinEncodeMetadata(t *testing.T) { + meta := devinMetadata{ + IdeName: "devin-cli", + ExtensionVersion: "3000.4.25", + ApiKey: "devin-session-token$test", + Locale: "en", + OS: "darwin", + IdeVersion: "3000.4.25", + ExtensionName: "chisel", + IdeType: "chisel", + F: "abc123", + } + b := encodeMetadata(meta) + if len(b) == 0 { + t.Fatal("encodeMetadata returned empty bytes") + } + + // Verify field 1 (ide_name) contains "devin-cli". + val := extractStringField(t, b, 1) + if val != "devin-cli" { + t.Errorf("field 1: got %q, want %q", val, "devin-cli") + } + + // Verify field 12 (extension_name) contains "chisel". + val = extractStringField(t, b, 12) + if val != "chisel" { + t.Errorf("field 12: got %q, want %q", val, "chisel") + } + + // Verify field 31 (f) contains "abc123". + val = extractStringField(t, b, 31) + if val != "abc123" { + t.Errorf("field 31: got %q, want %q", val, "abc123") + } +} + +func TestDevinEncodeCompletionConfig(t *testing.T) { + cfg := devinCompletionConfig{ + NumCompletions: 1, + MaxTokens: 128000, + MaxNewlines: 400, + Temperature: 1.0, + TopK: 40, + TopP: 0.95, + } + b := encodeCompletionConfig(cfg) + if len(b) == 0 { + t.Fatal("encodeCompletionConfig returned empty bytes") + } + + // Verify field 1 (num_completions) = 1. + val := extractVarintField(t, b, 1) + if val != 1 { + t.Errorf("field 1: got %d, want 1", val) + } + + // Verify field 2 (max_tokens) = 128000. + val = extractVarintField(t, b, 2) + if val != 128000 { + t.Errorf("field 2: got %d, want 128000", val) + } +} + +func TestDevinFrameConnectMessage(t *testing.T) { + payload := []byte("hello world") + frame := frameConnectMessage(payload) + if len(frame) != 5+len(payload) { + t.Fatalf("frame length: got %d, want %d", len(frame), 5+len(payload)) + } + if frame[0] != connectFlagUncompressed { + t.Errorf("flag: got %d, want %d", frame[0], connectFlagUncompressed) + } + // Check length in big-endian. + length := uint32(frame[1])<<24 | uint32(frame[2])<<16 | uint32(frame[3])<<8 | uint32(frame[4]) + if int(length) != len(payload) { + t.Errorf("length: got %d, want %d", length, len(payload)) + } + // Check payload. + if string(frame[5:]) != string(payload) { + t.Errorf("payload mismatch") + } +} + +func TestDevinReadConnectFrame(t *testing.T) { + payload := []byte("test payload") + frame := frameConnectMessage(payload) + + parsed, remaining, err := readConnectFrame(frame) + if err != nil { + t.Fatalf("readConnectFrame error: %v", err) + } + if parsed.Flag != connectFlagUncompressed { + t.Errorf("flag: got %d, want %d", parsed.Flag, connectFlagUncompressed) + } + if string(parsed.Payload) != string(payload) { + t.Errorf("payload: got %q, want %q", string(parsed.Payload), string(payload)) + } + if len(remaining) != 0 { + t.Errorf("remaining: got %d bytes, want 0", len(remaining)) + } +} + +func TestDevinReadConnectFrameMultipleFrames(t *testing.T) { + frame1 := frameConnectMessage([]byte("first")) + frame2 := frameConnectMessage([]byte("second")) + combined := append(frame1, frame2...) + + parsed1, remaining, err := readConnectFrame(combined) + if err != nil { + t.Fatalf("first read error: %v", err) + } + if string(parsed1.Payload) != "first" { + t.Errorf("first payload: got %q, want %q", string(parsed1.Payload), "first") + } + + parsed2, remaining2, err := readConnectFrame(remaining) + if err != nil { + t.Fatalf("second read error: %v", err) + } + if string(parsed2.Payload) != "second" { + t.Errorf("second payload: got %q, want %q", string(parsed2.Payload), "second") + } + if len(remaining2) != 0 { + t.Errorf("remaining2: got %d bytes, want 0", len(remaining2)) + } +} + +func TestDevinReadConnectFrameTooShort(t *testing.T) { + _, _, err := readConnectFrame([]byte{0x00, 0x00, 0x00}) + if err == nil { + t.Fatal("expected error for short buffer, got nil") + } +} + +func TestDevinParseGetChatMessageResponse(t *testing.T) { + // Build a response with delta_text (field 3) and message_id (field 1). + var b []byte + b = protowire.AppendTag(b, 1, protowire.BytesType) + b = protowire.AppendString(b, "bot-test-message-id") + b = protowire.AppendTag(b, 3, protowire.BytesType) + b = protowire.AppendString(b, "pong") + b = protowire.AppendTag(b, 5, protowire.VarintType) + b = protowire.AppendVarint(b, devinStopReasonStopPattern) + + chunk, err := parseGetChatMessageResponse(b) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if chunk.MessageID != "bot-test-message-id" { + t.Errorf("message_id: got %q, want %q", chunk.MessageID, "bot-test-message-id") + } + if chunk.DeltaText != "pong" { + t.Errorf("delta_text: got %q, want %q", chunk.DeltaText, "pong") + } + if chunk.StopReason != devinStopReasonStopPattern { + t.Errorf("stop_reason: got %d, want %d", chunk.StopReason, devinStopReasonStopPattern) + } +} + +func TestDevinParseUsageStats(t *testing.T) { + var b []byte + b = protowire.AppendTag(b, 2, protowire.VarintType) + b = protowire.AppendVarint(b, 42) + b = protowire.AppendTag(b, 3, protowire.VarintType) + b = protowire.AppendVarint(b, 3) + b = protowire.AppendTag(b, 5, protowire.VarintType) + b = protowire.AppendVarint(b, 18560) + b = protowire.AppendTag(b, 9, protowire.BytesType) + b = protowire.AppendString(b, "glm-5-2") + + usage, err := parseUsageStats(b) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if usage.InputTokens != 42 { + t.Errorf("input_tokens: got %d, want 42", usage.InputTokens) + } + if usage.OutputTokens != 3 { + t.Errorf("output_tokens: got %d, want 3", usage.OutputTokens) + } + if usage.CacheReadTokens != 18560 { + t.Errorf("cache_read_tokens: got %d, want 18560", usage.CacheReadTokens) + } + if usage.ModelUID != "glm-5-2" { + t.Errorf("model_uid: got %q, want %q", usage.ModelUID, "glm-5-2") + } +} + +func TestDevinStopReasonToString(t *testing.T) { + tests := []struct { + reason int + want string + }{ + {devinStopReasonStopPattern, "stop"}, + {devinStopReasonMaxTokens, "length"}, + {devinStopReasonMaxNewlines, "length"}, + {devinStopReasonIncomplete, "length"}, + {devinStopReasonUnspecified, "stop"}, + } + for _, tt := range tests { + got := devinStopReasonToString(tt.reason) + if got != tt.want { + t.Errorf("devinStopReasonToString(%d): got %q, want %q", tt.reason, got, tt.want) + } + } +} + +func TestDevinNormalizeSessionToken(t *testing.T) { + // Token without prefix gets the prefix added. + got := normalizeDevinSessionToken("mytoken") + want := "devin-session-token$mytoken" + if got != want { + t.Errorf("normalize without prefix: got %q, want %q", got, want) + } + + // Token with prefix stays unchanged. + got = normalizeDevinSessionToken("devin-session-token$mytoken") + if got != want { + t.Errorf("normalize with prefix: got %q, want %q", got, want) + } + + // Empty token stays empty. + got = normalizeDevinSessionToken("") + if got != "" { + t.Errorf("normalize empty: got %q, want empty", got) + } +} + +func TestDevinGenerateAttestationF(t *testing.T) { + f := generateAttestationF("test-install-id") + if len(f) != 732 { + t.Fatalf("attestation length: got %d, want 732", len(f)) + } + // Should be hex characters. + for _, c := range f { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Fatalf("attestation contains non-hex character: %c", c) + } + } + + // Same input should produce same output (deterministic). + f2 := generateAttestationF("test-install-id") + if f != f2 { + t.Error("attestation is not deterministic") + } + + // Different input should produce different output. + f3 := generateAttestationF("different-install-id") + if f == f3 { + t.Error("different inputs produced same attestation") + } +} + +func TestDevinParseOpenAIRequest(t *testing.T) { + payload := []byte(`{ + "model": "glm-5-2", + "messages": [ + {"role": "system", "content": "You are a test assistant."}, + {"role": "user", "content": "reply with just: pong"} + ], + "stream": true, + "temperature": 0.5, + "max_tokens": 1000 + }`) + + parsed, err := devinParseOpenAIRequest(payload) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if parsed.Model != "glm-5-2" { + t.Errorf("model: got %q, want %q", parsed.Model, "glm-5-2") + } + if len(parsed.Messages) != 2 { + t.Fatalf("messages: got %d, want 2", len(parsed.Messages)) + } + if parsed.Messages[0].Role != "system" { + t.Errorf("message 0 role: got %q, want %q", parsed.Messages[0].Role, "system") + } + if parsed.Messages[1].Role != "user" { + t.Errorf("message 1 role: got %q, want %q", parsed.Messages[1].Role, "user") + } + if parsed.SystemPrompt != "You are a test assistant." { + t.Errorf("system prompt: got %q, want %q", parsed.SystemPrompt, "You are a test assistant.") + } + if !parsed.Stream { + t.Error("stream: got false, want true") + } + if parsed.Temperature == nil || *parsed.Temperature != 0.5 { + t.Errorf("temperature: got %v, want 0.5", parsed.Temperature) + } + if parsed.MaxTokens == nil || *parsed.MaxTokens != 1000 { + t.Errorf("max_tokens: got %v, want 1000", parsed.MaxTokens) + } +} + +func TestDevinBuildDevinRequest(t *testing.T) { + temp := 0.7 + maxTok := 500 + parsed := &devinParsedRequest{ + Model: "glm-5-2", + Messages: []devinOpenAIMessage{ + {Role: "system", Content: json.RawMessage(`"You are a test."`)}, + {Role: "user", Content: json.RawMessage(`"hello"`)}, + }, + Temperature: &temp, + MaxTokens: &maxTok, + SystemPrompt: "You are a test.", + } + + req := buildDevinRequest(parsed, "devin-session-token$test", "glm-5-2") + + if req.Metadata.IdeName != "devin-cli" { + t.Errorf("ide_name: got %q, want %q", req.Metadata.IdeName, "devin-cli") + } + if req.Metadata.ExtensionName != "chisel" { + t.Errorf("extension_name: got %q, want %q", req.Metadata.ExtensionName, "chisel") + } + if req.Metadata.ApiKey != "devin-session-token$test" { + t.Errorf("api_key: got %q, want %q", req.Metadata.ApiKey, "devin-session-token$test") + } + if req.Prompt != "You are a test." { + t.Errorf("prompt: got %q, want %q", req.Prompt, "You are a test.") + } + if req.RequestType != devinRequestTypeCascade { + t.Errorf("request_type: got %d, want %d", req.RequestType, devinRequestTypeCascade) + } + if req.Configuration.MaxTokens != 500 { + t.Errorf("max_tokens override: got %d, want 500", req.Configuration.MaxTokens) + } + if req.Configuration.Temperature != 0.7 { + t.Errorf("temperature override: got %f, want 0.7", req.Configuration.Temperature) + } + if req.PlannerMode != devinPlannerModeDefault { + t.Errorf("planner_mode: got %d, want %d", req.PlannerMode, devinPlannerModeDefault) + } + if req.ChatModelUID != "glm-5-2" { + t.Errorf("chat_model_uid: got %q, want %q", req.ChatModelUID, "glm-5-2") + } + // System message should be excluded from chat_message_prompts. + if len(req.ChatMessagePrompts) != 1 { + t.Fatalf("chat_message_prompts: got %d, want 1 (system excluded)", len(req.ChatMessagePrompts)) + } + if req.ChatMessagePrompts[0].Source != devinMsgSourceUser { + t.Errorf("message source: got %d, want %d", req.ChatMessagePrompts[0].Source, devinMsgSourceUser) + } +} + +func TestDevinBuildOpenAIStreamChunk(t *testing.T) { + chunk := &devinStreamChunk{ + MessageID: "bot-test-id", + DeltaText: "hello", + } + sse := buildOpenAIStreamChunk(chunk, "glm-5-2") + if sse == "" { + t.Fatal("buildOpenAIStreamChunk returned empty string") + } + if !contains(sse, `"content":"hello"`) { + t.Errorf("SSE missing content: %s", sse) + } + if !contains(sse, `"model":"glm-5-2"`) { + t.Errorf("SSE missing model: %s", sse) + } + + // Empty chunk should return empty string. + empty := buildOpenAIStreamChunk(&devinStreamChunk{}, "glm-5-2") + if empty != "" { + t.Errorf("empty chunk should return empty string, got %q", empty) + } +} + +func TestDevinBuildOpenAIChatCompletion(t *testing.T) { + usage := &devinUsageStats{ + InputTokens: 42, + OutputTokens: 3, + } + resp := buildOpenAIChatCompletion("bot-test", "glm-5-2", "pong", "", "stop", nil, usage) + + var result map[string]any + if err := json.Unmarshal(resp, &result); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if result["model"] != "glm-5-2" { + t.Errorf("model: got %v, want %v", result["model"], "glm-5-2") + } + choices := result["choices"].([]any) + choice := choices[0].(map[string]any) + msg := choice["message"].(map[string]any) + if msg["content"] != "pong" { + t.Errorf("content: got %v, want %v", msg["content"], "pong") + } + if choice["finish_reason"] != "stop" { + t.Errorf("finish_reason: got %v, want %v", choice["finish_reason"], "stop") + } + usageObj := result["usage"].(map[string]any) + if usageObj["prompt_tokens"] != float64(42) { + t.Errorf("prompt_tokens: got %v, want 42", usageObj["prompt_tokens"]) + } +} + +// extractStringField extracts a string field from a protobuf message for testing. +func extractStringField(t *testing.T, data []byte, fieldNum protowire.Number) string { + t.Helper() + for len(data) > 0 { + num, wireType, n := protowire.ConsumeTag(data) + if n < 0 { + t.Fatalf("ConsumeTag error for field %d", fieldNum) + } + data = data[n:] + if num == fieldNum && wireType == protowire.BytesType { + val, valLen := protowire.ConsumeString(data) + if valLen < 0 { + t.Fatalf("ConsumeString error for field %d", fieldNum) + } + return string(val) + } + skipLen := protowire.ConsumeFieldValue(num, wireType, data) + if skipLen < 0 { + t.Fatalf("ConsumeFieldValue error") + } + data = data[skipLen:] + } + return "" +} + +// extractVarintField extracts a varint field from a protobuf message for testing. +func extractVarintField(t *testing.T, data []byte, fieldNum protowire.Number) uint64 { + t.Helper() + for len(data) > 0 { + num, wireType, n := protowire.ConsumeTag(data) + if n < 0 { + t.Fatalf("ConsumeTag error for field %d", fieldNum) + } + data = data[n:] + if num == fieldNum && wireType == protowire.VarintType { + val, valLen := protowire.ConsumeVarint(data) + if valLen < 0 { + t.Fatalf("ConsumeVarint error for field %d", fieldNum) + } + return val + } + skipLen := protowire.ConsumeFieldValue(num, wireType, data) + if skipLen < 0 { + t.Fatalf("ConsumeFieldValue error") + } + data = data[skipLen:] + } + return 0 +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr)) +} + +func containsStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/runtime/executor/devin_protobuf.go b/internal/runtime/executor/devin_protobuf.go new file mode 100644 index 000000000..f3ba66690 --- /dev/null +++ b/internal/runtime/executor/devin_protobuf.go @@ -0,0 +1,526 @@ +package executor + +import ( + "encoding/binary" + "fmt" + "math" + "strings" + + "google.golang.org/protobuf/encoding/protowire" +) + +// Devin Connect-RPC constants. +const ( + devinAPIBaseURL = "https://server.codeium.com" + devinChatPath = "/exa.api_server_pb.ApiServerService/GetChatMessage" + devinConnectProto = "application/connect+proto" + + // Connect frame flags. + connectFlagUncompressed = 0x00 + connectFlagCompressed = 0x01 + connectFlagEndStream = 0x02 + + // ChatMessageRequestType.CASCADE = 5 + devinRequestTypeCascade = 5 + + // ConversationalPlannerMode.DEFAULT = 1 + devinPlannerModeDefault = 1 + + // ChatMessageSource enum values. + devinMsgSourceUser = 1 + devinMsgSourceSystem = 2 + devinMsgSourceTool = 4 + devinMsgSourceSystemPrompt = 5 + + // StopReason enum values. + devinStopReasonUnspecified = 0 + devinStopReasonIncomplete = 1 + devinStopReasonStopPattern = 2 + devinStopReasonMaxTokens = 3 + devinStopReasonMinLogProb = 4 + devinStopReasonMaxNewlines = 5 +) + +// devinMetadata holds the Metadata message fields used by the Devin CLI. +type devinMetadata struct { + IdeName string + ExtensionVersion string + ApiKey string + Locale string + OS string + IdeVersion string + ExtensionName string + IdeType string + F string // attestation blob (field 31) +} + +// devinCompletionConfig holds CompletionConfiguration fields. +type devinCompletionConfig struct { + NumCompletions uint64 + MaxTokens uint64 + MaxNewlines uint64 + Temperature float64 + TopK uint64 + TopP float64 + StopPatterns []string +} + +// devinChatMessagePrompt holds a ChatMessagePrompt (conversation message). +type devinChatMessagePrompt struct { + MessageID string + Source int // ChatMessageSource enum + Prompt string + Thinking string + Signature string + ToolCallID string + ToolCalls []devinToolCall + OutputID string + SignatureType string +} + +// devinToolCall holds a ChatToolCall. +type devinToolCall struct { + ID string + Name string + ArgumentsJSON string +} + +// devinToolDefinition holds a ChatToolDefinition. +type devinToolDefinition struct { + Name string + Description string + JSONSchemaString string +} + +// devinRequest holds the top-level GetChatMessageRequest fields we send. +type devinRequest struct { + Metadata devinMetadata + Prompt string + ChatMessagePrompts []devinChatMessagePrompt + RequestType int + Configuration devinCompletionConfig + Tools []devinToolDefinition + CascadeID string + PlannerMode int + ChatModelUID string +} + +// encodeMetadata builds the Metadata protobuf message (field 1 of the request). +func encodeMetadata(m devinMetadata) []byte { + var b []byte + b = protowire.AppendTag(b, 1, protowire.BytesType) + b = protowire.AppendString(b, m.IdeName) + b = protowire.AppendTag(b, 2, protowire.BytesType) + b = protowire.AppendString(b, m.ExtensionVersion) + b = protowire.AppendTag(b, 3, protowire.BytesType) + b = protowire.AppendString(b, m.ApiKey) + b = protowire.AppendTag(b, 4, protowire.BytesType) + b = protowire.AppendString(b, m.Locale) + b = protowire.AppendTag(b, 5, protowire.BytesType) + b = protowire.AppendString(b, m.OS) + b = protowire.AppendTag(b, 7, protowire.BytesType) + b = protowire.AppendString(b, m.IdeVersion) + b = protowire.AppendTag(b, 12, protowire.BytesType) + b = protowire.AppendString(b, m.ExtensionName) + b = protowire.AppendTag(b, 28, protowire.BytesType) + b = protowire.AppendString(b, m.IdeType) + if m.F != "" { + b = protowire.AppendTag(b, 31, protowire.BytesType) + b = protowire.AppendString(b, m.F) + } + return b +} + +// encodeCompletionConfig builds the CompletionConfiguration protobuf message. +func encodeCompletionConfig(c devinCompletionConfig) []byte { + var b []byte + b = protowire.AppendTag(b, 1, protowire.VarintType) + b = protowire.AppendVarint(b, c.NumCompletions) + b = protowire.AppendTag(b, 2, protowire.VarintType) + b = protowire.AppendVarint(b, c.MaxTokens) + b = protowire.AppendTag(b, 3, protowire.VarintType) + b = protowire.AppendVarint(b, c.MaxNewlines) + b = protowire.AppendTag(b, 5, protowire.Fixed64Type) + b = protowire.AppendFixed64(b, math.Float64bits(c.Temperature)) + b = protowire.AppendTag(b, 7, protowire.VarintType) + b = protowire.AppendVarint(b, c.TopK) + b = protowire.AppendTag(b, 8, protowire.Fixed64Type) + b = protowire.AppendFixed64(b, math.Float64bits(c.TopP)) + for _, sp := range c.StopPatterns { + b = protowire.AppendTag(b, 9, protowire.BytesType) + b = protowire.AppendString(b, sp) + } + return b +} + +// encodeToolCall builds a ChatToolCall protobuf message. +func encodeToolCall(tc devinToolCall) []byte { + var b []byte + b = protowire.AppendTag(b, 1, protowire.BytesType) + b = protowire.AppendString(b, tc.ID) + b = protowire.AppendTag(b, 2, protowire.BytesType) + b = protowire.AppendString(b, tc.Name) + b = protowire.AppendTag(b, 3, protowire.BytesType) + b = protowire.AppendString(b, tc.ArgumentsJSON) + return b +} + +// encodeChatMessagePrompt builds a ChatMessagePrompt protobuf message. +func encodeChatMessagePrompt(p devinChatMessagePrompt) []byte { + var b []byte + b = protowire.AppendTag(b, 1, protowire.BytesType) + b = protowire.AppendString(b, p.MessageID) + b = protowire.AppendTag(b, 2, protowire.VarintType) + b = protowire.AppendVarint(b, uint64(p.Source)) + b = protowire.AppendTag(b, 3, protowire.BytesType) + b = protowire.AppendString(b, p.Prompt) + if p.Thinking != "" { + b = protowire.AppendTag(b, 11, protowire.BytesType) + b = protowire.AppendString(b, p.Thinking) + } + if p.Signature != "" { + b = protowire.AppendTag(b, 12, protowire.BytesType) + b = protowire.AppendString(b, p.Signature) + } + if p.ToolCallID != "" { + b = protowire.AppendTag(b, 7, protowire.BytesType) + b = protowire.AppendString(b, p.ToolCallID) + } + for _, tc := range p.ToolCalls { + tcBytes := encodeToolCall(tc) + b = protowire.AppendTag(b, 6, protowire.BytesType) + b = protowire.AppendBytes(b, tcBytes) + } + if p.OutputID != "" { + b = protowire.AppendTag(b, 15, protowire.BytesType) + b = protowire.AppendString(b, p.OutputID) + } + if p.SignatureType != "" { + b = protowire.AppendTag(b, 18, protowire.BytesType) + b = protowire.AppendString(b, p.SignatureType) + } + return b +} + +// encodeToolDefinition builds a ChatToolDefinition protobuf message. +func encodeToolDefinition(t devinToolDefinition) []byte { + var b []byte + b = protowire.AppendTag(b, 1, protowire.BytesType) + b = protowire.AppendString(b, t.Name) + b = protowire.AppendTag(b, 2, protowire.BytesType) + b = protowire.AppendString(b, t.Description) + b = protowire.AppendTag(b, 3, protowire.BytesType) + b = protowire.AppendString(b, t.JSONSchemaString) + return b +} + +// encodeGetChatMessageRequest builds the full GetChatMessageRequest protobuf message. +func encodeGetChatMessageRequest(r devinRequest) []byte { + var b []byte + // Field 1: metadata + metaBytes := encodeMetadata(r.Metadata) + b = protowire.AppendTag(b, 1, protowire.BytesType) + b = protowire.AppendBytes(b, metaBytes) + + // Field 2: prompt (system prompt) + b = protowire.AppendTag(b, 2, protowire.BytesType) + b = protowire.AppendString(b, r.Prompt) + + // Field 3: chat_message_prompts (repeated) + for _, p := range r.ChatMessagePrompts { + pBytes := encodeChatMessagePrompt(p) + b = protowire.AppendTag(b, 3, protowire.BytesType) + b = protowire.AppendBytes(b, pBytes) + } + + // Field 7: request_type + b = protowire.AppendTag(b, 7, protowire.VarintType) + b = protowire.AppendVarint(b, uint64(r.RequestType)) + + // Field 8: configuration + cfgBytes := encodeCompletionConfig(r.Configuration) + b = protowire.AppendTag(b, 8, protowire.BytesType) + b = protowire.AppendBytes(b, cfgBytes) + + // Field 10: tools (repeated) + for _, t := range r.Tools { + tBytes := encodeToolDefinition(t) + b = protowire.AppendTag(b, 10, protowire.BytesType) + b = protowire.AppendBytes(b, tBytes) + } + + // Field 16: cascade_id + b = protowire.AppendTag(b, 16, protowire.BytesType) + b = protowire.AppendString(b, r.CascadeID) + + // Field 20: planner_mode + b = protowire.AppendTag(b, 20, protowire.VarintType) + b = protowire.AppendVarint(b, uint64(r.PlannerMode)) + + // Field 21: chat_model_uid + b = protowire.AppendTag(b, 21, protowire.BytesType) + b = protowire.AppendString(b, r.ChatModelUID) + + return b +} + +// frameConnectMessage wraps a protobuf payload in a Connect-RPC frame (flag + length + payload). +func frameConnectMessage(payload []byte) []byte { + frame := make([]byte, 5+len(payload)) + frame[0] = connectFlagUncompressed + binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload))) + copy(frame[5:], payload) + return frame +} + +// devinResponseFrame represents a parsed Connect-RPC streaming response frame. +type devinResponseFrame struct { + Flag byte + Payload []byte +} + +// readConnectFrame reads a single Connect-RPC frame from a byte buffer. +// Returns the frame and the remaining bytes, or an error if the buffer is too short. +func readConnectFrame(buf []byte) (devinResponseFrame, []byte, error) { + if len(buf) < 5 { + return devinResponseFrame{}, nil, fmt.Errorf("connect frame: buffer too short (%d bytes)", len(buf)) + } + flag := buf[0] + length := binary.BigEndian.Uint32(buf[1:5]) + if len(buf) < 5+int(length) { + return devinResponseFrame{}, nil, fmt.Errorf("connect frame: incomplete frame (need %d, have %d)", 5+length, len(buf)) + } + payload := buf[5 : 5+length] + remaining := buf[5+length:] + return devinResponseFrame{Flag: flag, Payload: payload}, remaining, nil +} + +// devinStreamChunk represents a parsed GetChatMessageResponse with the fields we care about. +type devinStreamChunk struct { + MessageID string + DeltaText string + DeltaThinking string + DeltaSignature string + StopReason int + DeltaToolCalls []devinToolCall + OutputID string + RequestID string + Usage *devinUsageStats + IsTrailer bool + TrailerJSON string +} + +// devinUsageStats holds ModelUsageStats fields. +type devinUsageStats struct { + InputTokens uint64 + OutputTokens uint64 + CacheReadTokens uint64 + CacheWriteTokens uint64 + ModelUID string +} + +// parseGetChatMessageResponse parses a GetChatMessageResponse protobuf message. +func parseGetChatMessageResponse(data []byte) (*devinStreamChunk, error) { + chunk := &devinStreamChunk{} + for len(data) > 0 { + num, wireType, length := protowire.ConsumeTag(data) + if length < 0 { + return nil, protowire.ParseError(length) + } + data = data[length:] + switch wireType { + case protowire.BytesType: + val, valLen := protowire.ConsumeBytes(data) + if valLen < 0 { + return nil, protowire.ParseError(valLen) + } + data = data[valLen:] + switch num { + case 1: // message_id + chunk.MessageID = string(val) + case 3: // delta_text + chunk.DeltaText = string(val) + case 6: // delta_tool_calls (repeated) + tc, err := parseToolCall(val) + if err == nil { + chunk.DeltaToolCalls = append(chunk.DeltaToolCalls, tc) + } + case 7: // usage + usage, err := parseUsageStats(val) + if err == nil { + chunk.Usage = usage + } + case 9: // delta_thinking + chunk.DeltaThinking = string(val) + case 10: // delta_signature + chunk.DeltaSignature = string(val) + case 15: // output_id + chunk.OutputID = string(val) + case 17: // request_id + chunk.RequestID = string(val) + } + case protowire.VarintType: + val, valLen := protowire.ConsumeVarint(data) + if valLen < 0 { + return nil, protowire.ParseError(valLen) + } + data = data[valLen:] + switch num { + case 5: // stop_reason + chunk.StopReason = int(val) + } + case protowire.Fixed64Type: + val, valLen := protowire.ConsumeFixed64(data) + if valLen < 0 { + return nil, protowire.ParseError(valLen) + } + data = data[valLen:] + _ = val // latency (field 12), not needed + default: + // Skip unknown fields + skipLen := protowire.ConsumeFieldValue(num, wireType, data) + if skipLen < 0 { + return nil, protowire.ParseError(skipLen) + } + data = data[skipLen:] + } + } + return chunk, nil +} + +// parseToolCall parses a ChatToolCall protobuf message. +func parseToolCall(data []byte) (devinToolCall, error) { + var tc devinToolCall + for len(data) > 0 { + num, wireType, length := protowire.ConsumeTag(data) + if length < 0 { + return tc, protowire.ParseError(length) + } + data = data[length:] + if wireType != protowire.BytesType { + skipLen := protowire.ConsumeFieldValue(num, wireType, data) + if skipLen < 0 { + return tc, protowire.ParseError(skipLen) + } + data = data[skipLen:] + continue + } + val, valLen := protowire.ConsumeBytes(data) + if valLen < 0 { + return tc, protowire.ParseError(valLen) + } + data = data[valLen:] + switch num { + case 1: + tc.ID = string(val) + case 2: + tc.Name = string(val) + case 3: + tc.ArgumentsJSON = string(val) + } + } + return tc, nil +} + +// parseUsageStats parses a ModelUsageStats protobuf message. +func parseUsageStats(data []byte) (*devinUsageStats, error) { + usage := &devinUsageStats{} + for len(data) > 0 { + num, wireType, length := protowire.ConsumeTag(data) + if length < 0 { + return nil, protowire.ParseError(length) + } + data = data[length:] + switch wireType { + case protowire.VarintType: + val, valLen := protowire.ConsumeVarint(data) + if valLen < 0 { + return nil, protowire.ParseError(valLen) + } + data = data[valLen:] + switch num { + case 2: + usage.InputTokens = val + case 3: + usage.OutputTokens = val + case 4: + usage.CacheWriteTokens = val + case 5: + usage.CacheReadTokens = val + } + case protowire.BytesType: + val, valLen := protowire.ConsumeBytes(data) + if valLen < 0 { + return nil, protowire.ParseError(valLen) + } + data = data[valLen:] + if num == 9 { // model_uid + usage.ModelUID = string(val) + } + default: + skipLen := protowire.ConsumeFieldValue(num, wireType, data) + if skipLen < 0 { + return nil, protowire.ParseError(skipLen) + } + data = data[skipLen:] + } + } + return usage, nil +} + +// devinStopReasonToString maps Devin stop reasons to OpenAI-compatible strings. +func devinStopReasonToString(reason int) string { + switch reason { + case devinStopReasonStopPattern: + return "stop" + case devinStopReasonMaxTokens: + return "length" + case devinStopReasonMaxNewlines: + return "length" + case devinStopReasonIncomplete: + return "length" + default: + return "stop" + } +} + +// generateAttestationF generates a deterministic 366-byte hex string (732 chars) +// from the given install ID. This mimics the Devin CLI's attestation field (f). +func generateAttestationF(installID string) string { + // Simple deterministic hash-based generation. + // The real CLI uses a machine-specific algorithm we can't reverse-engineer, + // but the server accepts any 732-char hex string. + if installID == "" { + installID = "cliproxyapi-default-install" + } + // Use a simple FNV-based expansion to fill 732 hex chars. + hash := fnvHash("devin-fingerprint" + installID) + hex := fmt.Sprintf("%016x", hash) + for len(hex) < 732 { + hash = fnvHash(hex) + hex += fmt.Sprintf("%016x", hash) + } + return hex[:732] +} + +// fnvHash is a simple 64-bit FNV-1a hash. +func fnvHash(s string) uint64 { + h := uint64(14695981039346656037) + for i := 0; i < len(s); i++ { + h ^= uint64(s[i]) + h *= 1099511628211 + } + return h +} + +// normalizeDevinSessionToken ensures the token has the devin-session-token$ prefix. +func normalizeDevinSessionToken(token string) string { + token = strings.TrimSpace(token) + if token == "" { + return "" + } + if !strings.HasPrefix(token, "devin-session-token$") { + token = "devin-session-token$" + token + } + return token +} diff --git a/sdk/cliproxy/service_executors.go b/sdk/cliproxy/service_executors.go index 8c24718bc..bce80db13 100644 --- a/sdk/cliproxy/service_executors.go +++ b/sdk/cliproxy/service_executors.go @@ -207,6 +207,7 @@ func baselineExecutorAuths() []*coreauth.Auth { "vertex", "aistudio", "antigravity", + "devin", "kimi", "xai", "openai-compatibility", @@ -292,6 +293,8 @@ func (s *Service) registerExecutorForAuth(a *coreauth.Auth, forceReplace bool) { s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(cfg)) case "claude": s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(cfg)) + case "devin": + s.coreManager.RegisterExecutor(executor.NewDevinExecutor(cfg)) case "kimi": s.coreManager.RegisterExecutor(executor.NewKimiExecutor(cfg)) case "kiro": From a031f9343d3cf23aa2f69a12ee52d9d8aaca2d90 Mon Sep 17 00:00:00 2001 From: gwizz Date: Fri, 14 Aug 2026 20:10:26 +1000 Subject: [PATCH 2/6] fix(devin): fix stream chunk format, non-streaming assembly, and model registration - Register Devin models in service_models.go switch (case "devin") so the /v1/models endpoint lists them when a Devin auth file is loaded. - Return raw JSON from buildOpenAIStreamChunk (no "data: " prefix or "\n\n" suffix) since the proxy's SSE handler wraps each chunk itself. Remove the manual "data: [DONE]" sentinel for the same reason. - Fix JSON structure: usage was appended after the root object's closing brace; move it inside before closing. - Only include usage in stream chunks when token counts are non-zero (the Devin server sends zero-valued usage in every frame). - Fix Execute() (non-streaming) to parse OpenAI JSON chunks from ExecuteStream() instead of calling parseGetChatMessageResponse() (which expects raw protobuf). Use gjson to extract content, reasoning, finish_reason, usage, and tool_calls from each chunk. - Change default max_tokens from 128000 to 64000 to match the Devin CLI. - Update unit tests for the new max_tokens default. Verified end-to-end: both streaming and non-streaming chat completions return correct content, reasoning, finish_reason, and usage. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/runtime/executor/devin_executor.go | 59 +++++++++++-------- .../runtime/executor/devin_executor_test.go | 8 +-- sdk/cliproxy/service_models.go | 3 + 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/internal/runtime/executor/devin_executor.go b/internal/runtime/executor/devin_executor.go index 504c1b8f4..013cd648c 100644 --- a/internal/runtime/executor/devin_executor.go +++ b/internal/runtime/executor/devin_executor.go @@ -89,27 +89,39 @@ func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re if chunk.Err != nil { return resp, chunk.Err } - parsed, errParse := parseGetChatMessageResponse(chunk.Payload) - if errParse != nil { - continue - } - if parsed.MessageID != "" { - messageID = parsed.MessageID + // Chunks from ExecuteStream are OpenAI chat completion chunk JSON. + // Extract fields using gjson. + id := gjson.GetBytes(chunk.Payload, "id").String() + if id != "" { + messageID = strings.TrimPrefix(id, "chatcmpl-") } - if parsed.DeltaText != "" { - allText.WriteString(parsed.DeltaText) + if content := gjson.GetBytes(chunk.Payload, "choices.0.delta.content").String(); content != "" { + allText.WriteString(content) } - if parsed.DeltaThinking != "" { - thinkingText.WriteString(parsed.DeltaThinking) + if reasoning := gjson.GetBytes(chunk.Payload, "choices.0.delta.reasoning").String(); reasoning != "" { + thinkingText.WriteString(reasoning) } - if parsed.StopReason != 0 { - stopReason = devinStopReasonToString(parsed.StopReason) + if fr := gjson.GetBytes(chunk.Payload, "choices.0.finish_reason"); fr.Exists() && fr.Type == gjson.String { + stopReason = fr.String() } - if parsed.Usage != nil { - usageDetail = parsed.Usage + if pt := gjson.GetBytes(chunk.Payload, "usage.prompt_tokens"); pt.Exists() { + if usageDetail == nil { + usageDetail = &devinUsageStats{} + } + usageDetail.InputTokens = uint64(pt.Int()) + usageDetail.OutputTokens = uint64(gjson.GetBytes(chunk.Payload, "usage.completion_tokens").Int()) } - if len(parsed.DeltaToolCalls) > 0 { - toolCalls = append(toolCalls, parsed.DeltaToolCalls...) + // Extract tool calls from delta. + toolCallsResult := gjson.GetBytes(chunk.Payload, "choices.0.delta.tool_calls") + if toolCallsResult.IsArray() { + toolCallsResult.ForEach(func(_, tc gjson.Result) bool { + toolCalls = append(toolCalls, devinToolCall{ + ID: tc.Get("id").String(), + Name: tc.Get("function.name").String(), + ArgumentsJSON: tc.Get("function.arguments").String(), + }) + return true + }) } } @@ -260,7 +272,6 @@ func (e *DevinExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au } } - out <- cliproxyexecutor.StreamChunk{Payload: []byte("data: [DONE]\n\n")} if !usagePublished { reporter.ensurePublished(ctx) } @@ -461,7 +472,7 @@ func buildDevinRequest(parsed *devinParsedRequest, token, model string) devinReq RequestType: devinRequestTypeCascade, Configuration: devinCompletionConfig{ NumCompletions: 1, - MaxTokens: 128000, + MaxTokens: 64000, MaxNewlines: 400, Temperature: 1.0, TopK: 40, @@ -609,15 +620,15 @@ func buildOpenAIChatCompletion(messageID, model, text, thinking, stopReason stri return []byte(b.String()) } -// buildOpenAIStreamChunk converts a Devin response chunk to an OpenAI SSE data line. +// buildOpenAIStreamChunk converts a Devin response chunk to an OpenAI chat completion +// chunk JSON object. The proxy wraps each chunk with "data: %s\n\n" framing. func buildOpenAIStreamChunk(chunk *devinStreamChunk, model string) string { if chunk.MessageID == "" && chunk.DeltaText == "" && chunk.DeltaThinking == "" && chunk.StopReason == 0 && len(chunk.DeltaToolCalls) == 0 { return "" } var b strings.Builder - b.WriteString("data: {") - b.WriteString(`"id":"chatcmpl-`) + b.WriteString(`{"id":"chatcmpl-`) if chunk.MessageID != "" { b.WriteString(devinJSONEscape(chunk.MessageID)) } else { @@ -684,9 +695,9 @@ func buildOpenAIStreamChunk(chunk *devinStreamChunk, model string) string { } else { b.WriteString("null") } - b.WriteString(`}]}`) + b.WriteString(`}]`) - if chunk.Usage != nil { + if chunk.Usage != nil && (chunk.Usage.InputTokens > 0 || chunk.Usage.OutputTokens > 0) { b.WriteString(`,"usage":{"prompt_tokens":`) b.WriteString(fmt.Sprintf("%d", chunk.Usage.InputTokens)) b.WriteString(`,"completion_tokens":`) @@ -696,7 +707,7 @@ func buildOpenAIStreamChunk(chunk *devinStreamChunk, model string) string { b.WriteString(`}`) } - b.WriteString("}\n\n") + b.WriteString("}") return b.String() } diff --git a/internal/runtime/executor/devin_executor_test.go b/internal/runtime/executor/devin_executor_test.go index 143538427..51e022ce7 100644 --- a/internal/runtime/executor/devin_executor_test.go +++ b/internal/runtime/executor/devin_executor_test.go @@ -46,7 +46,7 @@ func TestDevinEncodeMetadata(t *testing.T) { func TestDevinEncodeCompletionConfig(t *testing.T) { cfg := devinCompletionConfig{ NumCompletions: 1, - MaxTokens: 128000, + MaxTokens: 64000, MaxNewlines: 400, Temperature: 1.0, TopK: 40, @@ -63,10 +63,10 @@ func TestDevinEncodeCompletionConfig(t *testing.T) { t.Errorf("field 1: got %d, want 1", val) } - // Verify field 2 (max_tokens) = 128000. + // Verify field 2 (max_tokens) = 64000. val = extractVarintField(t, b, 2) - if val != 128000 { - t.Errorf("field 2: got %d, want 128000", val) + if val != 64000 { + t.Errorf("field 2: got %d, want 64000", val) } } diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index 654b50bfa..c86daa320 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -137,6 +137,9 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut models = registry.GetCodexProModels() } models = applyExcludedModels(models, excluded) + case "devin": + models = registry.GetDevinModels() + models = applyExcludedModels(models, excluded) case "kimi": models = registry.GetKimiModels() models = applyExcludedModels(models, excluded) From ba8ec48bf71664b98f05f9497d91a7f0970e466e Mon Sep 17 00:00:00 2001 From: gwizz Date: Fri, 14 Aug 2026 20:19:51 +1000 Subject: [PATCH 3/6] fix(devin): revert max_tokens to 128000 to match Devin CLI Fresh mitmproxy capture of the Devin CLI (v3000.4.25) confirms the CLI sends max_tokens=128000 in the CompletionConfiguration, not 64000. Reverted the previous change. Verified via side-by-side mitmproxy comparison of CLI vs proxy requests: - Metadata (field 1, 990 bytes): identical structure - Configuration (field 8): identical (128000, 400, 1.0, 40, 0.95) - request_type=5, planner_mode=1, cascade_id, chat_model_uid: all match - Response frame structure matches (field 1/3/4/5/7/9/12/17) Tested all 6 models (glm-5-2, glm-5-2-1m, glm-5-2-max, swe-1-7, swe-1-7-medium, swe-1-6) with streaming, non-streaming, system prompts, and multi-turn conversations. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/runtime/executor/devin_executor.go | 2 +- internal/runtime/executor/devin_executor_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/runtime/executor/devin_executor.go b/internal/runtime/executor/devin_executor.go index 013cd648c..67cc9af76 100644 --- a/internal/runtime/executor/devin_executor.go +++ b/internal/runtime/executor/devin_executor.go @@ -472,7 +472,7 @@ func buildDevinRequest(parsed *devinParsedRequest, token, model string) devinReq RequestType: devinRequestTypeCascade, Configuration: devinCompletionConfig{ NumCompletions: 1, - MaxTokens: 64000, + MaxTokens: 128000, MaxNewlines: 400, Temperature: 1.0, TopK: 40, diff --git a/internal/runtime/executor/devin_executor_test.go b/internal/runtime/executor/devin_executor_test.go index 51e022ce7..143538427 100644 --- a/internal/runtime/executor/devin_executor_test.go +++ b/internal/runtime/executor/devin_executor_test.go @@ -46,7 +46,7 @@ func TestDevinEncodeMetadata(t *testing.T) { func TestDevinEncodeCompletionConfig(t *testing.T) { cfg := devinCompletionConfig{ NumCompletions: 1, - MaxTokens: 64000, + MaxTokens: 128000, MaxNewlines: 400, Temperature: 1.0, TopK: 40, @@ -63,10 +63,10 @@ func TestDevinEncodeCompletionConfig(t *testing.T) { t.Errorf("field 1: got %d, want 1", val) } - // Verify field 2 (max_tokens) = 64000. + // Verify field 2 (max_tokens) = 128000. val = extractVarintField(t, b, 2) - if val != 64000 { - t.Errorf("field 2: got %d, want 64000", val) + if val != 128000 { + t.Errorf("field 2: got %d, want 128000", val) } } From e6104462803c5899eb342f759c91938231bd30a6 Mon Sep 17 00:00:00 2001 From: gwizz Date: Fri, 14 Aug 2026 20:25:01 +1000 Subject: [PATCH 4/6] fix(devin): merge tool call fragments in non-streaming mode; add docs Non-streaming assembly: tool call arguments arrive as fragments across multiple stream chunks (first chunk has id+name, subsequent chunks have argument pieces with empty id/name). Merge fragments into the last tool call instead of treating each as a separate entry. Add docs/devin.md with usage examples including tool calling and multi-turn tool results. Verified tool calling end-to-end: - Non-streaming: single merged tool call with complete arguments - Streaming: fragments accumulate correctly - Multi-turn: model correctly uses tool results in follow-up response Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/devin.md | 196 ++++++++++++++++++++ internal/runtime/executor/devin_executor.go | 22 ++- 2 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 docs/devin.md diff --git a/docs/devin.md b/docs/devin.md new file mode 100644 index 000000000..7093b00d7 --- /dev/null +++ b/docs/devin.md @@ -0,0 +1,196 @@ +# Devin (Codeium Cascade) guide + +CLIProxyAPI can use Devin's Codeium Cascade backend as a provider, exposing Devin's free models through the standard OpenAI chat completions API. + +It supports: + +- streaming and non-streaming chat completions +- system prompts +- multi-turn conversations +- tool calling (function calling) with multi-turn tool results +- reasoning/thinking tokens (exposed as `reasoning` in the response) + +## Available models + +| Model ID | Description | +|----------|-------------| +| `glm-5-2` | GLM-5.2 High (default) | +| `glm-5-2-1m` | GLM-5.2 High with 1M token context | +| `glm-5-2-max` | GLM-5.2 Max | +| `swe-1-7` | SWE-1.7 | +| `swe-1-7-medium` | SWE-1.7 Medium | +| `swe-1-6` | SWE-1.6 | + +## Auth configuration + +The Devin provider uses the same session token as the Devin CLI. The token is found in `~/.local/share/devin/credentials.toml` under the `session_token` field. + +Create a JSON file in the auth directory (e.g., `auths/devin.json`): + +```json +{ + "type": "devin", + "devin_session_token": "devin-session-token$eyJhbGci..." +} +``` + +## Usage + +### Basic chat completion + +```bash +curl http://127.0.0.1:18499/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "glm-5-2", + "messages": [{"role": "user", "content": "Hello!"}], + "stream": false + }' +``` + +### Streaming + +```bash +curl http://127.0.0.1:18499/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "glm-5-2", + "messages": [{"role": "user", "content": "Hello!"}], + "stream": true + }' +``` + +### System prompt + +```bash +curl http://127.0.0.1:18499/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "glm-5-2", + "messages": [ + {"role": "system", "content": "You are a helpful assistant that always replies in one sentence."}, + {"role": "user", "content": "Tell me about Go."} + ] + }' +``` + +### Tool calling + +The Devin provider supports OpenAI-compatible tool calling. Tool definitions are translated to Devin's `ChatToolDefinition` protobuf format, and tool call deltas in the response are converted to OpenAI's streaming tool call format. + +```bash +curl http://127.0.0.1:18499/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "glm-5-2", + "messages": [ + {"role": "user", "content": "What is the weather in San Francisco?"} + ], + "tools": [{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather in a given location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "The city and state, e.g. San Francisco, CA"} + }, + "required": ["location"] + } + } + }] + }' +``` + +Response: + +```json +{ + "id": "chatcmpl-bot-...", + "object": "chat.completion", + "model": "glm-5-2", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "chatcmpl-tool-...", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"San Francisco, CA\"}" + } + }] + }, + "finish_reason": "stop" + }] +} +``` + +### Multi-turn with tool results + +After receiving a tool call, execute the function and feed the result back as a `tool` role message: + +```bash +curl http://127.0.0.1:18499/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "glm-5-2", + "messages": [ + {"role": "user", "content": "What is the weather in San Francisco?"}, + {"role": "assistant", "content": null, "tool_calls": [{"id": "call_abc123", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\": \"San Francisco, CA\"}"}}]}, + {"role": "tool", "tool_call_id": "call_abc123", "content": "The weather in San Francisco, CA is 62 degrees and foggy."} + ], + "tools": [{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather in a given location", + "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]} + } + }] + }' +``` + +Response: + +```json +{ + "id": "chatcmpl-bot-...", + "object": "chat.completion", + "model": "glm-5-2", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "The current weather in San Francisco is **62°F** and **foggy**. Classic San Francisco! 🌁" + }, + "finish_reason": "stop" + }] +} +``` + +## How it works + +The Devin executor translates OpenAI chat completion requests into Devin's Connect-RPC protobuf format (`GetChatMessageRequest`) and sends them to `server.codeium.com`. Streaming responses are parsed from Connect frames (`GetChatMessageResponse`) and converted to OpenAI SSE chunks. + +| OpenAI field | Devin protobuf field | +|--------------|---------------------| +| `messages[system]` | `prompt` (field 2) | +| `messages[user/assistant/tool]` | `chat_message_prompts` (field 3, repeated) | +| `tools` | `tools` (field 10, repeated) | +| `temperature` | `configuration.temperature` (field 8.5) | +| `max_tokens` | `configuration.max_tokens` (field 8.2) | +| `top_p` | `configuration.top_p` (field 8.8) | +| response `content` | `delta_text` (field 3) | +| response `reasoning` | `delta_thinking` (field 9) | +| response `tool_calls` | `delta_tool_calls` (field 6, repeated) | +| response `finish_reason` | `stop_reason` (field 5) | +| response `usage` | `usage` (field 7) | diff --git a/internal/runtime/executor/devin_executor.go b/internal/runtime/executor/devin_executor.go index 67cc9af76..465585a3d 100644 --- a/internal/runtime/executor/devin_executor.go +++ b/internal/runtime/executor/devin_executor.go @@ -111,15 +111,25 @@ func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re usageDetail.InputTokens = uint64(pt.Int()) usageDetail.OutputTokens = uint64(gjson.GetBytes(chunk.Payload, "usage.completion_tokens").Int()) } - // Extract tool calls from delta. + // Extract tool calls from delta. Tool call arguments arrive in + // fragments across multiple chunks: the first chunk carries the + // id and function name, subsequent chunks carry argument pieces + // with empty id/name. Merge fragments into the last tool call. toolCallsResult := gjson.GetBytes(chunk.Payload, "choices.0.delta.tool_calls") if toolCallsResult.IsArray() { toolCallsResult.ForEach(func(_, tc gjson.Result) bool { - toolCalls = append(toolCalls, devinToolCall{ - ID: tc.Get("id").String(), - Name: tc.Get("function.name").String(), - ArgumentsJSON: tc.Get("function.arguments").String(), - }) + tcID := tc.Get("id").String() + tcName := tc.Get("function.name").String() + tcArgs := tc.Get("function.arguments").String() + if tcID != "" && tcName != "" { + toolCalls = append(toolCalls, devinToolCall{ + ID: tcID, + Name: tcName, + ArgumentsJSON: tcArgs, + }) + } else if len(toolCalls) > 0 { + toolCalls[len(toolCalls)-1].ArgumentsJSON += tcArgs + } return true }) } From 1c70816261c713b69121e35492fba3f9b0050d82 Mon Sep 17 00:00:00 2001 From: gwizz Date: Fri, 14 Aug 2026 20:36:51 +1000 Subject: [PATCH 5/6] test(devin): add tool calling unit tests Tests cover: - Tool definition encoding (name, description, json_schema_string) - Tool call encoding (id, name, arguments_json) - Non-streaming response with multiple tool calls - Streaming SSE chunk with tool call deltas All 19 Devin unit tests pass. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../runtime/executor/devin_executor_test.go | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/internal/runtime/executor/devin_executor_test.go b/internal/runtime/executor/devin_executor_test.go index 143538427..2cf9125a8 100644 --- a/internal/runtime/executor/devin_executor_test.go +++ b/internal/runtime/executor/devin_executor_test.go @@ -406,6 +406,118 @@ func TestDevinBuildOpenAIChatCompletion(t *testing.T) { } } +func TestDevinBuildOpenAIChatCompletionWithToolCalls(t *testing.T) { + toolCalls := []devinToolCall{ + {ID: "call-1", Name: "run_command", ArgumentsJSON: `{"command": "ls"}`}, + {ID: "call-2", Name: "edit_file", ArgumentsJSON: `{"path": "hello.txt", "content": "Hello World"}`}, + } + resp := buildOpenAIChatCompletion("bot-test", "glm-5-2", "", "", "stop", toolCalls, nil) + + var result map[string]any + if err := json.Unmarshal(resp, &result); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + choices := result["choices"].([]any) + choice := choices[0].(map[string]any) + msg := choice["message"].(map[string]any) + tcs, ok := msg["tool_calls"].([]any) + if !ok { + t.Fatalf("missing tool_calls in response") + } + if len(tcs) != 2 { + t.Fatalf("tool_calls: got %d, want 2", len(tcs)) + } + tc0 := tcs[0].(map[string]any) + if tc0["id"] != "call-1" { + t.Errorf("tool_call[0].id: got %v, want call-1", tc0["id"]) + } + fn0 := tc0["function"].(map[string]any) + if fn0["name"] != "run_command" { + t.Errorf("tool_call[0].name: got %v, want run_command", fn0["name"]) + } + if fn0["arguments"] != `{"command": "ls"}` { + t.Errorf("tool_call[0].arguments: got %v, want {\"command\": \"ls\"}", fn0["arguments"]) + } +} + +func TestDevinEncodeToolDefinition(t *testing.T) { + tool := devinToolDefinition{ + Name: "run_command", + Description: "Run a terminal command", + JSONSchemaString: `{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}`, + } + b := encodeToolDefinition(tool) + if len(b) == 0 { + t.Fatal("encodeToolDefinition returned empty bytes") + } + + // Verify field 1 (name) + name := extractStringField(t, b, 1) + if name != "run_command" { + t.Errorf("field 1 (name): got %q, want %q", name, "run_command") + } + // Verify field 2 (description) + desc := extractStringField(t, b, 2) + if desc != "Run a terminal command" { + t.Errorf("field 2 (description): got %q, want %q", desc, "Run a terminal command") + } + // Verify field 3 (json_schema_string) + schema := extractStringField(t, b, 3) + if !contains(schema, "command") { + t.Errorf("field 3 (json_schema): missing 'command' in %q", schema) + } +} + +func TestDevinEncodeToolCall(t *testing.T) { + tc := devinToolCall{ + ID: "call-1", + Name: "run_command", + ArgumentsJSON: `{"command": "ls"}`, + } + b := encodeToolCall(tc) + if len(b) == 0 { + t.Fatal("encodeToolCall returned empty bytes") + } + + // Verify field 1 (id) + id := extractStringField(t, b, 1) + if id != "call-1" { + t.Errorf("field 1 (id): got %q, want %q", id, "call-1") + } + // Verify field 2 (name) + name := extractStringField(t, b, 2) + if name != "run_command" { + t.Errorf("field 2 (name): got %q, want %q", name, "run_command") + } + // Verify field 3 (arguments_json) + args := extractStringField(t, b, 3) + if args != `{"command": "ls"}` { + t.Errorf("field 3 (arguments): got %q, want %q", args, `{"command": "ls"}`) + } +} + +func TestDevinStreamChunkWithToolCalls(t *testing.T) { + chunk := &devinStreamChunk{ + MessageID: "bot-test", + DeltaToolCalls: []devinToolCall{ + {ID: "call-1", Name: "edit_file", ArgumentsJSON: `{"path":"hello.txt"}`}, + }, + } + sse := buildOpenAIStreamChunk(chunk, "glm-5-2") + if sse == "" { + t.Fatal("buildOpenAIStreamChunk returned empty for tool call chunk") + } + if !contains(sse, `"tool_calls"`) { + t.Errorf("SSE missing tool_calls: %s", sse) + } + if !contains(sse, `"edit_file"`) { + t.Errorf("SSE missing tool name: %s", sse) + } + if !contains(sse, `"call-1"`) { + t.Errorf("SSE missing tool id: %s", sse) + } +} + // extractStringField extracts a string field from a protobuf message for testing. func extractStringField(t *testing.T, data []byte, fieldNum protowire.Number) string { t.Helper() From 6d07a9a10156807bdf0c1d39eb7bc5f991d002f7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 19 Aug 2026 16:53:45 -0400 Subject: [PATCH 6/6] test(util): record reviewed in-place byte write in devin_protobuf frameConnectMessage allocates the Connect frame with make() and copies the protobuf payload into it; no GJSON no-copy result can alias the fresh buffer. Same reviewed pattern as internal/client/codex/live/tcp_proxy.go. --- internal/util/nocopy_invariant_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/util/nocopy_invariant_test.go b/internal/util/nocopy_invariant_test.go index bfec46553..aa35b0e5a 100644 --- a/internal/util/nocopy_invariant_test.go +++ b/internal/util/nocopy_invariant_test.go @@ -106,6 +106,7 @@ var reviewedInPlaceByteWrites = map[string]reviewedInPlaceByteWrite{ "internal/runtime/executor/claude_executor_request.go": {2, "shifts []string headers to insert a part; no byte of any payload is rewritten"}, "internal/runtime/executor/helps/claude_mcp_alias.go": {1, "copies an HMAC sum into a local fixed-size digest array"}, "internal/client/codex/live/tcp_proxy.go": {1, "copies header and payload into a freshly allocated frame"}, + "internal/runtime/executor/devin_protobuf.go": {1, "copies a protobuf payload into a freshly allocated Connect frame"}, "internal/auth/cursor/proto/connect.go": {1, "copies a protobuf payload into a freshly allocated Connect frame"}, "internal/home/client.go": {1, "zeroes a secret buffer after json.Unmarshal has copied every value out"}, "internal/pluginstore/auth.go": {1, "zeroes a locally built credential buffer after base64 encoding copied it out"},