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

### English

- Report Devin cache reads and writes in OpenAI-compatible usage, with prompt
totals including all upstream input tokens
- 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

### 中文

- Devin 的缓存读取与写入会显示在 OpenAI 兼容 usage 中,prompt 总数包含全部上游输入 token
- 新增手动更新命令,提取 descriptor 并生成 Devin 聊天与账号状态 protobuf 类型;构建与 CI 直接使用已提交的 Go 文件,不下载发行包或重新生成 schema

## 0.5.6 - 2026-09-17

### English
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require github.com/joho/godotenv v1.5.1

require (
golang.org/x/net v0.56.0
google.golang.org/protobuf v1.36.10
modernc.org/sqlite v1.57.0
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
Expand Down
2 changes: 2 additions & 0 deletions internal/executor/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,8 @@ func (e ChatExecutor) chatInProcessNonStreamAttempt(ctx context.Context, item ac
FinishReason: outcome.FinishReason,
PromptTokens: outcome.PromptTokens,
CompletionTokens: outcome.CompletionTokens,
CacheReadTokens: outcome.CacheReadTokens,
CacheWriteTokens: outcome.CacheWriteTokens,
UsageSource: outcome.UsageSource,
ConsumedCredits: outcome.Credits,
AccountID: item.ID,
Expand Down
119 changes: 62 additions & 57 deletions internal/providers/devin/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func (c *Client) ChatStream(ctx context.Context, accountID string, req translate

func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential, req translate.ChatRequest) (chatRequestBuild, error) {
payload := BuildChatPayload(req, currentLevels())
proto := BuildGetChatMessageRequest(
proto, err := BuildGetChatMessageRequest(
credential.SessionToken,
credential.DeviceSeed,
payload.ModelUID,
Expand All @@ -90,6 +90,9 @@ func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential
"",
"",
)
if err != nil {
return chatRequestBuild{}, fmt.Errorf("encode Devin chat request: %w", err)
}
body := WrapConnectEnvelope(proto)
endpoint := strings.TrimRight(firstNonEmpty(credential.BaseURL, c.serverBase, ServerBase), "/") + PathGetChatMessage
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
Expand Down Expand Up @@ -117,12 +120,46 @@ type aggregateResult struct {
FinishReason string
PromptTokens int
CompletionTokens int
CacheReadTokens *int
CacheWriteTokens *int
}

type toolCallAccumulator struct{ calls []*ToolCallDelta }

func (a *toolCallAccumulator) add(delta ToolCallDelta) (int, *ToolCallDelta) {
for index, call := range a.calls {
if delta.ID != "" && call.ID == delta.ID {
return index, mergeToolCallDelta(call, delta)
}
}
if len(a.calls) > 0 {
lastIndex := len(a.calls) - 1
last := a.calls[lastIndex]
if (delta.ID != "" && last.ID == "") || (delta.ID == "" && (delta.Name == "" || delta.Name == last.Name)) {
return lastIndex, mergeToolCallDelta(last, delta)
}
}
// LIMIT: Devin omits an index from ChatToolCall; interleaved anonymous tool
// deltas cannot be disambiguated until the upstream protocol supplies one.
a.calls = append(a.calls, &delta)
return len(a.calls) - 1, &delta
}

func mergeToolCallDelta(target *ToolCallDelta, delta ToolCallDelta) *ToolCallDelta {
if delta.ID != "" {
target.ID = delta.ID
}
if delta.Name != "" {
target.Name = delta.Name
}
target.Arguments += delta.Arguments
return target
}

func aggregateConnectStream(r io.Reader, originalByAlias map[string]string, toolsDiag string) (aggregateResult, error) {
var out aggregateResult
out.FinishReason = "stop"
toolAcc := map[int]*ToolCallDelta{}
var toolAcc toolCallAccumulator
sawEOS := false
for {
flag, payload, err := ReadConnectFrame(r)
Expand Down Expand Up @@ -150,25 +187,14 @@ func aggregateConnectStream(r io.Reader, originalByAlias map[string]string, tool
out.Reasoning += frame.ThinkingText
}
for _, delta := range frame.ToolCallDeltas {
idx := delta.Index
acc, ok := toolAcc[idx]
if !ok {
cp := delta
if cp.Name != "" {
cp.Name = restoreToolName(cp.Name, originalByAlias)
}
toolAcc[idx] = &cp
} else {
if delta.ID != "" {
acc.ID = delta.ID
}
if delta.Name != "" {
acc.Name = restoreToolName(delta.Name, originalByAlias)
}
acc.Arguments += delta.Arguments
if delta.Name != "" {
delta.Name = restoreToolName(delta.Name, originalByAlias)
}
toolAcc.add(delta)
}
if frame.Usage != nil {
cacheRead, cacheWrite := int(frame.Usage.CachedTokens), int(frame.Usage.CacheWriteTokens)
out.CacheReadTokens, out.CacheWriteTokens = &cacheRead, &cacheWrite
if frame.Usage.PromptTokens > 0 {
out.PromptTokens = int(frame.Usage.PromptTokens)
}
Expand All @@ -188,22 +214,9 @@ func aggregateConnectStream(r io.Reader, originalByAlias map[string]string, tool
if !sawEOS {
return out, classifiedErrorWithToolsDiag(502, "devin stream truncated: missing EOS trailer", toolsDiag)
}
if len(toolAcc) > 0 {
if len(toolAcc.calls) > 0 {
out.FinishReason = "tool_calls"
keys := make([]int, 0, len(toolAcc))
for k := range toolAcc {
keys = append(keys, k)
}
// stable-ish order by index
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[j] < keys[i] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
for _, idx := range keys {
tc := toolAcc[idx]
for idx, tc := range toolAcc.calls {
out.ToolCalls = append(out.ToolCalls, map[string]any{
"id": tc.ID,
"type": "function",
Expand All @@ -226,6 +239,8 @@ func outcomeFromAggregate(aggregate aggregateResult, fallbackModel string) provi
FinishReason: firstNonEmpty(aggregate.FinishReason, "stop"),
PromptTokens: aggregate.PromptTokens,
CompletionTokens: aggregate.CompletionTokens,
CacheReadTokens: aggregate.CacheReadTokens,
CacheWriteTokens: aggregate.CacheWriteTokens,
UsageSource: "upstream",
}
if len(aggregate.ToolCalls) > 0 {
Expand Down Expand Up @@ -267,7 +282,7 @@ func rewriteConnectStream(upstream *http.Response, model string, originalByAlias
return err
}

toolAcc := map[int]*ToolCallDelta{}
var toolAcc toolCallAccumulator
sawEOS := false
var lastUsage any
roleSent := false
Expand Down Expand Up @@ -317,21 +332,8 @@ func rewriteConnectStream(upstream *http.Response, model string, originalByAlias
if name != "" {
name = restoreToolName(name, originalByAlias)
}
idx := delta.Index
acc, ok := toolAcc[idx]
if !ok {
cp := delta
cp.Name = name
toolAcc[idx] = &cp
} else {
if delta.ID != "" {
acc.ID = delta.ID
}
if name != "" {
acc.Name = name
}
acc.Arguments += delta.Arguments
}
delta.Name = name
idx, _ := toolAcc.add(delta)
toolDelta := map[string]any{
"index": idx,
"id": delta.ID,
Expand All @@ -348,21 +350,24 @@ func rewriteConnectStream(upstream *http.Response, model string, originalByAlias
}
if frame.Usage != nil {
lastUsage = map[string]any{
"prompt_tokens": frame.Usage.PromptTokens,
"completion_tokens": frame.Usage.CompletionTokens,
"total_tokens": frame.Usage.PromptTokens + frame.Usage.CompletionTokens,
"prompt_tokens": frame.Usage.PromptTokens,
"completion_tokens": frame.Usage.CompletionTokens,
"total_tokens": frame.Usage.PromptTokens + frame.Usage.CompletionTokens,
"cache_read_tokens": frame.Usage.CachedTokens,
"cache_write_tokens": frame.Usage.CacheWriteTokens,
"prompt_tokens_details": map[string]any{"cached_tokens": frame.Usage.CachedTokens},
}
if frame.Usage.ModelName != "" {
model = frame.Usage.ModelName
}
}
}
if !sawEOS {
_ = pw.CloseWithError(classifiedErrorWithToolsDiag(502, "devin stream truncated: missing EOS trailer", toolsDiag))
return
}
if !sawEOS {
_ = pw.CloseWithError(classifiedErrorWithToolsDiag(502, "devin stream truncated: missing EOS trailer", toolsDiag))
return
}
finish := "stop"
if len(toolAcc) > 0 {
if len(toolAcc.calls) > 0 {
finish = "tool_calls"
}
if err := writeChunk(map[string]any{}, finish, lastUsage); err != nil {
Expand Down
Loading
Loading