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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.
### English

- Show consumed points under the Tokens column in request history when a provider reports them; keep writing the value on the request log row, and fall back to the request-detail table for rows that only stored it there
- Temporary Devin diagnostic: when upstream returns an MCP configuration `permission_denied`, append a compact tools type/name summary (`tools_diag`) to the error so request logs can show what Desktop sent versus what was forwarded

### 中文

- 请求历史上游若回报消耗点数,会在 Tokens 列下方以绿色小字展示;继续写入请求日志主表对应字段,并对仅记在详情表的历史行做回退读取
- 临时诊断:Devin 上游返回 MCP 配置类 `permission_denied` 时,会在错误信息追加精简的 tools type/name 摘要(`tools_diag`),便于从请求日志对照 Desktop 入站与实际上游转发内容

## 0.5.4 - 2026-09-16

Expand Down
50 changes: 30 additions & 20 deletions internal/providers/devin/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@ import (
"github.com/caigee-cmd/cli2api/internal/translate"
)

type chatRequestBuild struct {
httpReq *http.Request
originalByAlias map[string]string
toolsDiag string
}

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
}
httpReq, originalByAlias, err := c.buildChatHTTPRequest(ctx, credential, req)
built, err := c.buildChatHTTPRequest(ctx, credential, req)
if err != nil {
return providers.ChatOutcome{}, err
}
Expand All @@ -28,16 +34,16 @@ func (c *Client) ChatNonStream(ctx context.Context, accountID string, req transl
return providers.ChatOutcome{}, err
}
client.Timeout = 0
resp, err := client.Do(httpReq)
resp, err := client.Do(built.httpReq)
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{}, classifiedError(resp.StatusCode, string(body))
return providers.ChatOutcome{}, classifiedErrorWithToolsDiag(resp.StatusCode, string(body), built.toolsDiag)
}
aggregate, err := aggregateConnectStream(resp.Body, originalByAlias)
aggregate, err := aggregateConnectStream(resp.Body, built.originalByAlias, built.toolsDiag)
if err != nil {
return providers.ChatOutcome{}, err
}
Expand All @@ -49,7 +55,7 @@ func (c *Client) ChatStream(ctx context.Context, accountID string, req translate
if err != nil {
return nil, err
}
httpReq, originalByAlias, err := c.buildChatHTTPRequest(ctx, credential, req)
built, err := c.buildChatHTTPRequest(ctx, credential, req)
if err != nil {
return nil, err
}
Expand All @@ -58,19 +64,19 @@ func (c *Client) ChatStream(ctx context.Context, accountID string, req translate
return nil, err
}
client.Timeout = 0
resp, err := client.Do(httpReq)
resp, err := client.Do(built.httpReq)
if err != nil {
return nil, err
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
return nil, classifiedError(resp.StatusCode, string(body))
return nil, classifiedErrorWithToolsDiag(resp.StatusCode, string(body), built.toolsDiag)
}
return rewriteConnectStream(resp, firstNonEmpty(req.Model, "devin"), originalByAlias)
return rewriteConnectStream(resp, firstNonEmpty(req.Model, "devin"), built.originalByAlias, built.toolsDiag)
}

func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential, req translate.ChatRequest) (*http.Request, map[string]string, error) {
func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential, req translate.ChatRequest) (chatRequestBuild, error) {
payload := BuildChatPayload(req, currentLevels())
proto := BuildGetChatMessageRequest(
credential.SessionToken,
Expand All @@ -88,15 +94,19 @@ func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential
endpoint := strings.TrimRight(firstNonEmpty(credential.BaseURL, c.serverBase, ServerBase), "/") + PathGetChatMessage
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, nil, err
return chatRequestBuild{}, err
}
httpReq.Header.Set("Authorization", BasicAuthHeader(credential.SessionToken))
httpReq.Header.Set("Content-Type", ContentTypeConnectProto)
httpReq.Header.Set("Connect-Protocol-Version", ConnectProtocolVersion)
httpReq.Header.Set("Accept", "*/*")
httpReq.Header.Set("Sentry-Trace", GenerateSentryTrace())
httpReq.Header["User-Agent"] = []string{""}
return httpReq, payload.OriginalByAlias, nil
return chatRequestBuild{
httpReq: httpReq,
originalByAlias: payload.OriginalByAlias,
toolsDiag: payload.ToolsDiag,
}, nil
}

type aggregateResult struct {
Expand All @@ -109,7 +119,7 @@ type aggregateResult struct {
CompletionTokens int
}

func aggregateConnectStream(r io.Reader, originalByAlias map[string]string) (aggregateResult, error) {
func aggregateConnectStream(r io.Reader, originalByAlias map[string]string, toolsDiag string) (aggregateResult, error) {
var out aggregateResult
out.FinishReason = "stop"
toolAcc := map[int]*ToolCallDelta{}
Expand All @@ -125,7 +135,7 @@ func aggregateConnectStream(r io.Reader, originalByAlias map[string]string) (agg
if flag&ConnectFlagEndStream != 0 {
sawEOS = true
if status, trailerErr := ParseTrailerError(payload); trailerErr != nil {
return out, classifiedError(status, trailerErr.Error())
return out, classifiedErrorWithToolsDiag(status, trailerErr.Error(), toolsDiag)
}
break
}
Expand Down Expand Up @@ -176,7 +186,7 @@ func aggregateConnectStream(r io.Reader, originalByAlias map[string]string) (agg
}
}
if !sawEOS {
return out, classifiedError(502, "devin stream truncated: missing EOS trailer")
return out, classifiedErrorWithToolsDiag(502, "devin stream truncated: missing EOS trailer", toolsDiag)
}
if len(toolAcc) > 0 {
out.FinishReason = "tool_calls"
Expand Down Expand Up @@ -225,7 +235,7 @@ func outcomeFromAggregate(aggregate aggregateResult, fallbackModel string) provi
return out
}

func rewriteConnectStream(upstream *http.Response, model string, originalByAlias map[string]string) (*http.Response, error) {
func rewriteConnectStream(upstream *http.Response, model string, originalByAlias map[string]string, toolsDiag string) (*http.Response, error) {
pr, pw := io.Pipe()
go func() {
defer upstream.Body.Close()
Expand Down Expand Up @@ -273,7 +283,7 @@ func rewriteConnectStream(upstream *http.Response, model string, originalByAlias
if flag&ConnectFlagEndStream != 0 {
sawEOS = true
if status, trailerErr := ParseTrailerError(payload); trailerErr != nil {
_ = pw.CloseWithError(classifiedError(status, trailerErr.Error()))
_ = pw.CloseWithError(classifiedErrorWithToolsDiag(status, trailerErr.Error(), toolsDiag))
return
}
break
Expand Down Expand Up @@ -347,10 +357,10 @@ func rewriteConnectStream(upstream *http.Response, model string, originalByAlias
}
}
}
if !sawEOS {
_ = pw.CloseWithError(classifiedError(502, "devin stream truncated: missing EOS trailer"))
return
}
if !sawEOS {
_ = pw.CloseWithError(classifiedErrorWithToolsDiag(502, "devin stream truncated: missing EOS trailer", toolsDiag))
return
}
finish := "stop"
if len(toolAcc) > 0 {
finish = "tool_calls"
Expand Down
56 changes: 55 additions & 1 deletion internal/providers/devin/devin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ func TestAggregateConnectStreamMissingEOS(t *testing.T) {
textFrame = AppendTag(textFrame, 3, BytesType)
textFrame = AppendString(textFrame, "orphan")
framed := WrapConnectEnvelope(textFrame)
_, err := aggregateConnectStream(bytes.NewReader(framed), nil)
_, err := aggregateConnectStream(bytes.NewReader(framed), nil, "")
if err == nil {
t.Fatal("expected missing EOS error")
}
Expand Down Expand Up @@ -542,6 +542,60 @@ func TestClassifyMCPConfigPermissionDenied(t *testing.T) {
if providerErr.Failover == nil || *providerErr.Failover {
t.Fatalf("failover=%v want false", providerErr.Failover)
}

diag := "in=[namespace:mcp__computer-use,ns.function:left_click] out(1)=[mcp_computer_use_left_click]"
err = classifiedErrorWithToolsDiag(403, body, diag)
if !errors.As(err, &providerErr) {
t.Fatalf("classifiedErrorWithToolsDiag type=%T", err)
}
if !strings.Contains(providerErr.Message, "tools_diag="+diag) {
t.Fatalf("message missing tools_diag: %s", providerErr.Message)
}
if providerErr.Kind != accounts.KindInvalidRequest || providerErr.Cooldown != 0 {
t.Fatalf("diag classify=%+v", providerErr)
}
}

func TestBuildToolsDiagSummarizesInboundAndOutbound(t *testing.T) {
raw := json.RawMessage(`[
{"type":"namespace","name":"mcp__computer-use","tools":[
{"type":"function","name":"left_click","parameters":{"type":"object"}},
{"type":"function","function":{"name":"mcp__computer-use__type","parameters":{"type":"object"}}}
]},
{"type":"mcp","server_label":"browser"},
{"type":"web_search"},
{"type":"custom","name":"weird_shell"},
{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}
]`)
payload := BuildChatPayload(translate.ChatRequest{
Model: "swe-2",
Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}},
Tools: raw,
}, nil)
if !strings.Contains(payload.ToolsDiag, "in=[") {
t.Fatalf("missing inbound summary: %s", payload.ToolsDiag)
}
if !strings.Contains(payload.ToolsDiag, "namespace:mcp__computer-use") {
t.Fatalf("missing namespace entry: %s", payload.ToolsDiag)
}
if !strings.Contains(payload.ToolsDiag, "ns.function:left_click") {
t.Fatalf("missing nested function: %s", payload.ToolsDiag)
}
if !strings.Contains(payload.ToolsDiag, "mcp:browser") {
t.Fatalf("missing mcp shell: %s", payload.ToolsDiag)
}
if !strings.Contains(payload.ToolsDiag, "web_search") {
t.Fatalf("missing web_search: %s", payload.ToolsDiag)
}
if !strings.Contains(payload.ToolsDiag, "custom:weird_shell") {
t.Fatalf("missing custom type: %s", payload.ToolsDiag)
}
if !strings.Contains(payload.ToolsDiag, "out(") || !strings.Contains(payload.ToolsDiag, "lookup") {
t.Fatalf("missing outbound summary: %s", payload.ToolsDiag)
}
if strings.Contains(payload.ToolsDiag, `"parameters"`) || strings.Contains(payload.ToolsDiag, "description") {
t.Fatalf("diag leaked schema/description: %s", payload.ToolsDiag)
}
}

func TestParseToolsAliasesMCPNamespace(t *testing.T) {
Expand Down
28 changes: 27 additions & 1 deletion internal/providers/devin/errors.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package devin

import (
"log"
"regexp"
"strings"
"time"
Expand Down Expand Up @@ -94,6 +95,10 @@ func Classify(status int, body string) providers.ClassifiedError {
}

func classifiedError(status int, body string) error {
return classifiedErrorWithToolsDiag(status, body, "")
}

func classifiedErrorWithToolsDiag(status int, body, toolsDiag string) error {
classified := Classify(status, body)
if classified.Kind == "" {
classified = providers.ClassifiedError{
Expand All @@ -102,16 +107,37 @@ func classifiedError(status int, body string) error {
Message: firstNonEmpty(redactSecrets(strings.TrimSpace(body)), "upstream error"),
}
}
message := classified.Message
if toolsDiag != "" && isDevinMCPConfigDenial(strings.ToLower(message)+" "+strings.ToLower(body)) {
log.Printf("devin mcp configuration denial tools_diag=%s", toolsDiag)
message = appendToolsDiag(message, toolsDiag)
}
failover := classified.Kind != accounts.KindInvalidRequest
return &providers.Error{
Kind: classified.Kind,
Status: classified.Status,
Message: classified.Message,
Message: message,
Cooldown: classifiedCooldown(classified.Kind),
Failover: &failover,
}
}

func appendToolsDiag(message, toolsDiag string) string {
message = strings.TrimSpace(message)
toolsDiag = strings.TrimSpace(toolsDiag)
if toolsDiag == "" {
return message
}
suffix := "tools_diag=" + toolsDiag
if message == "" {
return suffix
}
if strings.Contains(message, "tools_diag=") {
return message
}
return message + " | " + suffix
}

func classifiedCooldown(kind string) time.Duration {
switch kind {
case accounts.KindQuota:
Expand Down
Loading
Loading