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 @@ -7,6 +7,7 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Preserve typed upstream stream errors through the OpenAI, Anthropic, and Responses relays so invalid Devin requests do not falsely cool accounts, while transport interruptions remain retryable
- 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
Expand All @@ -15,6 +16,7 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### 中文

- OpenAI、Anthropic 与 Responses 流式转发会保留上游的类型化错误,避免无效的 Devin 请求被错误地冷却账号,同时传输中断仍可重试
- Devin 的缓存读取与写入会显示在 OpenAI 兼容 usage 中,prompt 总数包含全部上游输入 token
- 新增手动更新命令,提取 descriptor 并生成 Devin 聊天与账号状态 protobuf 类型;构建与 CI 直接使用已提交的 Go 文件,不下载发行包或重新生成 schema

Expand Down
10 changes: 9 additions & 1 deletion internal/api/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func relayOpenAIStream(w http.ResponseWriter, body io.Reader) (stats streamRelay
if streamErr != nil {
return stats, streamErr
}
streamErr := newStreamProviderError("upstream_stream_interrupted", "stream read error: "+err.Error(), http.StatusBadGateway)
streamErr := streamReadProviderError(err)
if writeErr := writeStructuredStreamError(writer, streamErr); writeErr != nil {
return stats, writeErr
}
Expand Down Expand Up @@ -376,6 +376,14 @@ func newStreamProviderError(code, message string, status int) *providers.Error {
return providerErrorFromClassified(classified)
}

func streamReadProviderError(err error) *providers.Error {
var providerErr *providers.Error
if errors.As(err, &providerErr) && providerErr != nil {
return providerErr
}
return newStreamProviderError("upstream_stream_interrupted", "stream read error: "+err.Error(), http.StatusBadGateway)
}

func providerErrorFromClassified(classified accounts.Classified) *providers.Error {
failover := classified.Failover
retryAfter := classified.RetryAfter
Expand Down
57 changes: 57 additions & 0 deletions internal/api/chat_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package api

import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
Expand All @@ -17,6 +19,12 @@ import (

func intPtr(value int) *int { return &value }

func closedStreamPipe(err error) *io.PipeReader {
reader, writer := io.Pipe()
_ = writer.CloseWithError(err)
return reader
}

func TestWriteClassifiedErrKeepsTraeQuotaKind(t *testing.T) {
recorder := httptest.NewRecorder()
failover := true
Expand Down Expand Up @@ -247,6 +255,55 @@ func TestRelayOpenAIStreamReportsIncompleteStreamStructurally(t *testing.T) {
}
}

func TestRelayOpenAIStreamPreservesTypedReadError(t *testing.T) {
recorder := httptest.NewRecorder()
failover := false
want := &providers.Error{
Kind: accounts.KindInvalidRequest, Status: http.StatusBadRequest,
Code: "invalid_argument", Type: "invalid_request_error", Message: "upstream rejected request",
RetryAfter: 45 * time.Second, Failover: &failover,
}
_, err := relayOpenAIStream(recorder, closedStreamPipe(fmt.Errorf("Connect trailer: %w", want)))
var got *providers.Error
if !errors.As(err, &got) || got != want {
t.Fatalf("error=%T %+v want pointer=%p", err, err, want)
}
if got.Kind != accounts.KindInvalidRequest || got.Status != http.StatusBadRequest || got.Code != "invalid_argument" ||
got.Type != "invalid_request_error" || got.RetryAfter != 45*time.Second || got.Failover == nil || *got.Failover {
t.Fatalf("provider error=%+v", got)
}
output := recorder.Body.String()
if !strings.Contains(output, `"code":"invalid_argument"`) || !strings.Contains(output, `"retry_after":45`) || strings.Contains(output, "upstream_stream_interrupted") {
t.Fatalf("structured error=%s", output)
}
pool := accounts.NewPool(nil, nil)
pool.Upsert(accounts.Item{ID: "devin-account"})
executor.NewChatExecutor(pool, "").ObserveStreamFailure("devin-account", got, "swe-2")
item, _ := pool.ByID("devin-account")
if item.LastKind != "" || !item.DownUntil.IsZero() {
t.Fatalf("invalid request cooled account: kind=%q down=%v", item.LastKind, item.DownUntil)
}
}

func TestRelayOpenAIStreamWrapsUnknownReadError(t *testing.T) {
recorder := httptest.NewRecorder()
_, err := relayOpenAIStream(recorder, closedStreamPipe(errors.New("socket closed")))
var got *providers.Error
if !errors.As(err, &got) || got.Kind != accounts.KindUnavailable || got.Code != "upstream_stream_interrupted" || got.Status != http.StatusBadGateway {
t.Fatalf("error=%T %+v", err, err)
}
if !strings.Contains(got.Message, "stream read error: socket closed") {
t.Fatalf("message=%q", got.Message)
}
pool := accounts.NewPool(nil, nil)
pool.Upsert(accounts.Item{ID: "devin-account"})
executor.NewChatExecutor(pool, "").ObserveStreamFailure("devin-account", got, "swe-2")
item, _ := pool.ByID("devin-account")
if item.LastKind != accounts.KindUnavailable || item.DownUntil.IsZero() {
t.Fatalf("transport interruption was not unavailable: kind=%q down=%v", item.LastKind, item.DownUntil)
}
}

func TestParseStreamUsageLineReadsWorkBuddyCredit(t *testing.T) {
stats, ok := parseStreamUsageLine(`data: {"model":"hy3","usage":{"prompt_tokens":16,"completion_tokens":2,"credit":0.75}}`)
if !ok || stats.Credits == nil || *stats.Credits != 0.75 {
Expand Down
2 changes: 1 addition & 1 deletion internal/api/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ func consumeOpenAIStream(body io.Reader, handle func(json.RawMessage, *streamedC
frame = append(frame, line)
}
if err := scanner.Err(); err != nil {
return stats, output, newStreamProviderError("upstream_stream_interrupted", "stream read error: "+err.Error(), http.StatusBadGateway)
return stats, output, streamReadProviderError(err)
}
if err := flush(); err != nil {
return stats, output, err
Expand Down
25 changes: 25 additions & 0 deletions internal/api/compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,44 @@ package api

import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"

"github.com/caigee-cmd/cli2api/internal/accounts"
"github.com/caigee-cmd/cli2api/internal/auth"
"github.com/caigee-cmd/cli2api/internal/executor"
"github.com/caigee-cmd/cli2api/internal/providers"
"github.com/caigee-cmd/cli2api/internal/translate"
)

func TestCompatibilityStreamsPreserveTypedReadError(t *testing.T) {
failover := false
want := &providers.Error{
Kind: accounts.KindInvalidRequest, Status: http.StatusBadRequest,
Code: "invalid_argument", Type: "invalid_request_error", Message: "upstream rejected request",
RetryAfter: 45 * time.Second, Failover: &failover,
}
for name, relay := range map[string]func(io.Writer, io.Reader, string, string) (streamRelayStats, error){
"anthropic": relayAnthropicStream,
"responses": relayResponsesStream,
} {
t.Run(name, func(t *testing.T) {
_, err := relay(httptest.NewRecorder(), closedStreamPipe(fmt.Errorf("Connect trailer: %w", want)), "req_1", "devin/swe-2")
var got *providers.Error
if !errors.As(err, &got) || got != want {
t.Fatalf("error=%T %+v want pointer=%p", err, err, want)
}
})
}
}

func newCompatibilityServer(t *testing.T, worker http.HandlerFunc) (*Server, func()) {
t.Helper()
upstream := httptest.NewServer(worker)
Expand Down
Loading