Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.
- Generate Devin chat and account-status protobuf types from extracted descriptors
with a manual update command; builds and CI use committed Go bindings without
downloading releases or regenerating schemas
- Neutralize Codex/Desktop MCP-looking tool names for Devin (`mcp__*`, `list_mcp_*`, and any name containing `mcp`) into reversible `cx_tool_*` aliases, restore the originals on tool calls for local execution, scrub residual MCP text on fallback, and if upstream still returns an MCP configuration `permission_denied` retry by stripping those tools then keeping only core local tools (`exec_command` / `write_stdin` / `view_image` / `request_user_input`) with minimal schemas; log each fallback stage and never drop all tools
- Ignore orphan `tool_choice` when Codex compact / recovery turns send a choice with no remaining tools, instead of failing with `tool_choice requires tools`
- Keep session affinity from pinning a later model onto an empty-catalog account, so a Deepseek compact after a Devin turn routes to WorkBuddy instead of Devin

### 中文

- OpenAI、Anthropic 与 Responses 流式转发会保留上游的类型化错误,避免无效的 Devin 请求被错误地冷却账号,同时传输中断仍可重试
- Devin 的缓存读取与写入会显示在 OpenAI 兼容 usage 中,prompt 总数包含全部上游输入 token
- 新增手动更新命令,提取 descriptor 并生成 Devin 聊天与账号状态 protobuf 类型;构建与 CI 直接使用已提交的 Go 文件,不下载发行包或重新生成 schema
- Devin 会把 Codex/Desktop 带 MCP 语义的工具名(`mcp__*`、`list_mcp_*` 以及名称含 `mcp` 的工具)中性化为可逆的 `cx_tool_*` 别名,并在返回的 tool_calls 中还原原名供本地执行;若上游仍返回 MCP 配置类 `permission_denied`,会先清洗残留 MCP 文案并去掉这些工具再试,再失败则只保留核心本地工具(`exec_command` / `write_stdin` / `view_image` / `request_user_input`,最小 schema),每次 fallback 都会打日志,且不再清空全部 tools
- Codex compact / 恢复轮次如果带了 `tool_choice` 但 tools 已被规范化为空,会忽略这个孤立的 `tool_choice`,不再报 `tool_choice requires tools`
- 会话粘性不会再把后续模型钉到空 catalog 账号上,因此 Devin 之后的 Deepseek compact 会走 WorkBuddy,而不是误打到 Devin

## 0.5.6 - 2026-09-17

Expand Down
8 changes: 4 additions & 4 deletions internal/api/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -800,10 +800,10 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "invalid_request", "messages required")
return
}
if err := translate.ValidateChatRequest(req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := translate.ValidateChatRequest(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
execution, err := s.prepareChatExecution(r, req)
if err != nil {
writeChatHTTPError(w, err)
Expand Down
26 changes: 22 additions & 4 deletions internal/executor/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,24 @@ func itemProvider(item accounts.Item) string {
return accounts.NormalizeProviderFamily(item.Provider)
}

// stickyAccountCanServeModel keeps a bound cooling empty-catalog account on
// the same model so regional escape still works, but does not let that
// unknown catalog pin a later, different model (Devin → Deepseek compact).
func stickyAccountCanServeModel(item accounts.Item, publicModel string) bool {
if item.Models != nil {
return accounts.ItemCouldServeModel(item, publicModel)
}
if strings.TrimSpace(publicModel) == "" {
return true
}
if len(item.ProvenModels) == 0 {
return true
}
probe := item
probe.Models = []string{}
return accounts.ItemCouldServeModel(probe, publicModel)
}

func (e ChatExecutor) prepareRouting(ctx context.Context, prefer, providerFilter string, req translate.ChatRequest) (string, string, string, routingPlan) {
prefer = strings.TrimSpace(prefer)
providerFilter = strings.ToLower(strings.TrimSpace(providerFilter))
Expand Down Expand Up @@ -209,10 +227,10 @@ func (e ChatExecutor) prepareRouting(ctx context.Context, prefer, providerFilter
e.SessionAffinity.RecordEscape("provider_not_allowed")
return "", providerFilter, "", plan
}
if !accounts.ItemCouldServeModel(item, publicModel) {
e.SessionAffinity.RecordEscape("model_unavailable")
return "", providerFilter, "", plan
}
if !stickyAccountCanServeModel(item, publicModel) {
e.SessionAffinity.RecordEscape("model_unavailable")
return "", providerFilter, "", plan
}
return item.ID, itemProvider(item), accounts.NormalizeRegion(item.Region), routingPlan{
Source: routingSticky, SessionKey: plan.SessionKey, BoundAccount: item.ID, PublicModel: publicModel,
}
Expand Down
25 changes: 25 additions & 0 deletions internal/executor/session_affinity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,31 @@ func TestChatNonStreamSessionAffinityAndPinPriority(t *testing.T) {
}
}

func TestChatNonStreamSessionAffinityEscapesUnknownCatalogOnLaterModel(t *testing.T) {
server := func(id string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"model":"`+id+`","choices":[{"message":{"content":"`+id+`"},"finish_reason":"stop"}],"usage":{"source":"upstream"}}`)
}))
}
devin := server("devin")
defer devin.Close()
workbuddy := server("workbuddy")
defer workbuddy.Close()

pool := accounts.NewPool(nil, nil)
pool.Upsert(accounts.Item{ID: "devin", URL: devin.URL, Provider: "devin", Region: "global", Runtime: "child_process", Models: []string{"gpt-5-6-sol"}, ProvenModels: []string{"gpt-5-6-sol"}})
pool.Upsert(accounts.Item{ID: "workbuddy", URL: workbuddy.URL, Provider: "workbuddy", Region: "global", Runtime: "child_process", Models: []string{"deepseek-v4.1-flash"}})
executor := NewChatExecutor(pool, "")
executor.SessionAffinity.Bind("compact-session", "devin")

result, err := executor.ChatNonStream(WithSessionKey(context.Background(), "compact-session"), translate.ChatRequest{
Model: "deepseek-v4.1-flash", Messages: []translate.ChatMessage{{Role: "user", Content: "compact"}},
}, "", "")
if err != nil || result.AccountID != "workbuddy" || result.Routing != routingPool {
t.Fatalf("result = %+v, err=%v", result, err)
}
}

func TestChatNonStreamSessionAffinityEscapesWithinBoundRegion(t *testing.T) {
server := func(id string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
Expand Down
221 changes: 201 additions & 20 deletions internal/providers/devin/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
Expand All @@ -16,68 +18,194 @@ import (

type chatRequestBuild struct {
httpReq *http.Request
payload ChatPayload
originalByAlias map[string]string
toolsDiag string
// fallbackStage:
// 0=initial
// 1=strip MCP-looking tools
// 2=keep only core local tools with minimal schemas (final)
fallbackStage int
}

func (c *Client) ChatNonStream(ctx context.Context, accountID string, req translate.ChatRequest) (providers.ChatOutcome, error) {
credential, err := c.credential(ctx, accountID)
if err != nil {
return providers.ChatOutcome{}, err
}
built, err := c.buildChatHTTPRequest(ctx, credential, req)
if err != nil {
return providers.ChatOutcome{}, err
}
client, err := c.httpClient(ctx, accountID)
if err != nil {
return providers.ChatOutcome{}, err
}
client.Timeout = 0
resp, err := client.Do(built.httpReq)

built, err := c.buildChatHTTPRequest(ctx, credential, req)
if err != nil {
return providers.ChatOutcome{}, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return providers.ChatOutcome{}, classifiedErrorWithToolsDiag(resp.StatusCode, string(body), built.toolsDiag)
}
aggregate, err := aggregateConnectStream(resp.Body, built.originalByAlias, built.toolsDiag)
if err != nil {
return providers.ChatOutcome{}, err
var lastErr error
for {
outcome, denial, err := c.chatNonStreamOnce(client, built, req.Model)
if err == nil {
return outcome, nil
}
lastErr = err
if !denial {
return providers.ChatOutcome{}, err
}
fallback, ok := c.buildNextMCPFallback(ctx, credential, built)
if !ok {
return providers.ChatOutcome{}, lastErr
}
built = fallback
}
return outcomeFromAggregate(aggregate, firstNonEmpty(req.Model, aggregate.Model)), nil
}

func (c *Client) ChatStream(ctx context.Context, accountID string, req translate.ChatRequest) (*http.Response, error) {
credential, err := c.credential(ctx, accountID)
if err != nil {
return nil, err
}
built, err := c.buildChatHTTPRequest(ctx, credential, req)
client, err := c.httpClient(ctx, accountID)
if err != nil {
return nil, err
}
client, err := c.httpClient(ctx, accountID)
client.Timeout = 0

built, err := c.buildChatHTTPRequest(ctx, credential, req)
if err != nil {
return nil, err
}
client.Timeout = 0
var lastErr error
for {
resp, denial, err := c.chatStreamOnce(client, built)
if err == nil {
return rewriteConnectStream(resp, firstNonEmpty(req.Model, "devin"), built.originalByAlias, built.toolsDiag)
}
lastErr = err
if !denial {
return nil, err
}
fallback, ok := c.buildNextMCPFallback(ctx, credential, built)
if !ok {
return nil, lastErr
}
built = fallback
}
}

func (c *Client) chatNonStreamOnce(client *http.Client, built chatRequestBuild, model string) (providers.ChatOutcome, bool, error) {
resp, err := client.Do(built.httpReq)
if err != nil {
return nil, err
return providers.ChatOutcome{}, false, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
err := classifiedErrorWithToolsDiag(resp.StatusCode, string(body), built.toolsDiag)
return providers.ChatOutcome{}, isMCPConfigDenialError(err), err
}
aggregate, err := aggregateConnectStream(resp.Body, built.originalByAlias, built.toolsDiag)
if err != nil {
return providers.ChatOutcome{}, isMCPConfigDenialError(err), err
}
return outcomeFromAggregate(aggregate, firstNonEmpty(model, aggregate.Model)), false, nil
}

func (c *Client) chatStreamOnce(client *http.Client, built chatRequestBuild) (*http.Response, bool, error) {
resp, err := client.Do(built.httpReq)
if err != nil {
return nil, false, err
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
return nil, classifiedErrorWithToolsDiag(resp.StatusCode, string(body), built.toolsDiag)
err := classifiedErrorWithToolsDiag(resp.StatusCode, string(body), built.toolsDiag)
return nil, isMCPConfigDenialError(err), err
}
// MCP configuration denials often arrive as an end-stream trailer on HTTP
// 200 before any content. Peek while we still have a fallback left
// (through core_tools). Do not drop all tools.
if built.fallbackStage < 2 {
peeked, denialErr, perr := peekConnectStreamMCPDenial(resp.Body, built.toolsDiag)
if perr != nil {
resp.Body.Close()
return nil, false, perr
}
if denialErr != nil {
resp.Body.Close()
return nil, true, denialErr
}
resp.Body = io.NopCloser(io.MultiReader(bytes.NewReader(peeked), resp.Body))
}
return rewriteConnectStream(resp, firstNonEmpty(req.Model, "devin"), built.originalByAlias, built.toolsDiag)
return resp, false, nil
}

func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential, req translate.ChatRequest) (chatRequestBuild, error) {
payload := BuildChatPayload(req, currentLevels())
return c.buildChatHTTPRequestFromPayload(ctx, credential, payload, 0)
}

func (c *Client) buildNextMCPFallback(ctx context.Context, credential Credential, built chatRequestBuild) (chatRequestBuild, bool) {
switch built.fallbackStage {
case 0:
if len(built.payload.Tools) == 0 && !promptHasMCPSemantics(built.payload) {
return chatRequestBuild{}, false
}
payload := built.payload
before := len(payload.Tools)
payload.Tools = stripMCPSemanticTools(payload.Tools, payload.OriginalByAlias)
payload.Tools = scrubMCPTextFromTools(payload.Tools)
payload.Prompts = scrubMCPToolCallsFromPrompts(payload.Prompts, payload.OriginalByAlias)
payload.System = scrubMCPText(payload.System)
payload.ToolsDiag = appendFallbackDiag(built.toolsDiag, "fallback=strip_mcp", payload.Tools)
logMCPFallback("strip_mcp", before, payload.Tools, payload.ToolsDiag)
next, err := c.buildChatHTTPRequestFromPayload(ctx, credential, payload, 1)
if err != nil {
return chatRequestBuild{}, false
}
return next, true
case 1:
before := len(built.payload.Tools)
// Final fallback: always keep the core local tools with minimal
// schemas. Never drop all tools — that leaves the model unable to read
// or run commands.
payload := built.payload
payload.Tools = coreLocalTools()
payload.Prompts = scrubMCPToolCallsFromPrompts(payload.Prompts, payload.OriginalByAlias)
payload.System = scrubMCPText(payload.System)
payload.ToolsDiag = appendFallbackDiag(built.toolsDiag, "fallback=core_tools", payload.Tools)
logMCPFallback("core_tools", before, payload.Tools, payload.ToolsDiag)
next, err := c.buildChatHTTPRequestFromPayload(ctx, credential, payload, 2)
if err != nil {
return chatRequestBuild{}, false
}
return next, true
default:
return chatRequestBuild{}, false
}
}

func appendFallbackDiag(base, label string, tools []Tool) string {
suffix := label + " out(" + fmt.Sprintf("%d", len(tools)) + ")"
base = strings.TrimSpace(base)
if base == "" {
return suffix
}
return base + " | " + suffix
}

func logMCPFallback(stage string, before int, tools []Tool, diag string) {
names := make([]string, 0, len(tools))
for _, tool := range tools {
if name := strings.TrimSpace(tool.Name); name != "" {
names = append(names, name)
}
}
log.Printf("devin mcp fallback stage=%s before_tools=%d after_tools=%d names=%v diag=%s", stage, before, len(tools), names, diag)
}

func (c *Client) buildChatHTTPRequestFromPayload(ctx context.Context, credential Credential, payload ChatPayload, fallbackStage int) (chatRequestBuild, error) {
proto, err := BuildGetChatMessageRequest(
credential.SessionToken,
credential.DeviceSeed,
Expand Down Expand Up @@ -107,11 +235,64 @@ func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential
httpReq.Header["User-Agent"] = []string{""}
return chatRequestBuild{
httpReq: httpReq,
payload: payload,
originalByAlias: payload.OriginalByAlias,
toolsDiag: payload.ToolsDiag,
fallbackStage: fallbackStage,
}, nil
}

func isMCPConfigDenialError(err error) bool {
if err == nil {
return false
}
var providerErr *providers.Error
if errors.As(err, &providerErr) {
return isDevinMCPConfigDenial(strings.ToLower(providerErr.Message))
}
return isDevinMCPConfigDenial(strings.ToLower(err.Error()))
}

// peekConnectStreamMCPDenial reads Connect frames until the stream either
// produces visible output or ends. If it ends with an MCP configuration
// denial and no output, the denial error is returned so the caller can retry.
// Otherwise the exact bytes consumed are returned for splicing back into Body.
func peekConnectStreamMCPDenial(r io.Reader, toolsDiag string) (peeked []byte, denialErr error, err error) {
var buf bytes.Buffer
tee := io.TeeReader(r, &buf)
sawOutput := false
for {
flag, payload, readErr := ReadConnectFrame(tee)
if readErr == io.EOF {
break
}
if readErr != nil {
return buf.Bytes(), nil, readErr
}
if flag&ConnectFlagEndStream != 0 {
if status, trailerErr := ParseTrailerError(payload); trailerErr != nil {
classified := classifiedErrorWithToolsDiag(status, trailerErr.Error(), toolsDiag)
if !sawOutput && isMCPConfigDenialError(classified) {
return buf.Bytes(), classified, nil
}
// Non-MCP trailer error (or denial after output): let the
// normal rewrite path surface it from the spliced bytes.
return buf.Bytes(), nil, nil
}
return buf.Bytes(), nil, nil
}
frame, parseErr := ParseFrame(payload)
if parseErr != nil {
return buf.Bytes(), nil, nil
}
if frame.ContentText != "" || frame.ThinkingText != "" || len(frame.ToolCallDeltas) > 0 {
sawOutput = true
return buf.Bytes(), nil, nil
}
}
return buf.Bytes(), nil, nil
}

type aggregateResult struct {
Model string
Content string
Expand Down
Loading
Loading