refactor(executor): centralize OpenAI stream usage handling with StreamUsageBuffer

- Introduced `StreamUsageBuffer` to encapsulate stream usage observation and publishing logic.
- Updated OpenAI-compatible executors and helpers to use `StreamUsageBuffer` for consistent stream usage tracking.
- Added unit tests to validate `StreamUsageBuffer` behavior, ensuring proper handling of usage details.

Closes: #4053
This commit is contained in:
Luis Pater
2026-07-08 04:01:22 +08:00
parent 4f157fbdff
commit dea4787970
5 changed files with 80 additions and 10 deletions
@@ -428,10 +428,12 @@ func (e *CodexExecutor) executeDirectOpenAIImageStream(ctx context.Context, auth
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
var streamUsage helps.StreamUsageBuffer
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("codex executor: close response body error: %v", errClose)
}
streamUsage.Publish(ctx, reporter)
reporter.EnsurePublished(ctx)
}()
@@ -443,9 +445,7 @@ func (e *CodexExecutor) executeDirectOpenAIImageStream(ctx context.Context, auth
chunk = applyCodexIdentityConfuseResponsePayload(chunk, identityState)
helps.AppendAPIResponseChunk(ctx, e.cfg, chunk)
for _, line := range bytes.Split(chunk, []byte("\n")) {
if detail, ok := helps.ParseOpenAIStreamUsage(bytes.TrimSpace(line)); ok {
reporter.Publish(ctx, detail)
}
streamUsage.Observe(helps.ParseOpenAIStreamUsage(bytes.TrimSpace(line)))
}
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunk}:
@@ -447,6 +447,38 @@ func resolveUsageAuthType(auth *cliproxyauth.Auth) string {
return auth.AuthKind()
}
// StreamUsageBuffer keeps the latest usage detail observed in a stream.
type StreamUsageBuffer struct {
detail usage.Detail
ok bool
}
// Observe records detail when ok is true, allowing the final stream usage to win.
func (b *StreamUsageBuffer) Observe(detail usage.Detail, ok bool) {
if b == nil || !ok {
return
}
b.detail = detail
b.ok = true
}
// Publish emits the latest observed usage detail, if any.
func (b *StreamUsageBuffer) Publish(ctx context.Context, reporter *UsageReporter) bool {
if b == nil || !b.ok || reporter == nil {
return false
}
reporter.Publish(ctx, b.detail)
return true
}
// Detail returns the latest observed usage detail.
func (b *StreamUsageBuffer) Detail() (usage.Detail, bool) {
if b == nil || !b.ok {
return usage.Detail{}, false
}
return b.detail, true
}
func ParseCodexUsage(data []byte) (usage.Detail, bool) {
usageNode := gjson.ParseBytes(data).Get("response.usage")
if !hasOpenAIStyleUsageTokenFields(usageNode) {
@@ -89,6 +89,43 @@ func TestParseOpenAIStreamUsageResponsesFields(t *testing.T) {
}
}
func TestStreamUsageBufferKeepsLastUsage(t *testing.T) {
var buffer StreamUsageBuffer
buffer.Observe(usage.Detail{}, true)
buffer.Observe(usage.Detail{InputTokens: 1, OutputTokens: 1, TotalTokens: 2}, false)
buffer.Observe(usage.Detail{InputTokens: 39320, OutputTokens: 26, TotalTokens: 39346, CachedTokens: 33280}, true)
detail, ok := buffer.Detail()
if !ok {
t.Fatal("buffer detail ok = false, want true")
}
if detail.InputTokens != 39320 {
t.Fatalf("input tokens = %d, want %d", detail.InputTokens, 39320)
}
if detail.OutputTokens != 26 {
t.Fatalf("output tokens = %d, want %d", detail.OutputTokens, 26)
}
if detail.TotalTokens != 39346 {
t.Fatalf("total tokens = %d, want %d", detail.TotalTokens, 39346)
}
if detail.CachedTokens != 33280 {
t.Fatalf("cached tokens = %d, want %d", detail.CachedTokens, 33280)
}
}
func TestStreamUsageBufferPreservesOnlyZeroUsage(t *testing.T) {
var buffer StreamUsageBuffer
buffer.Observe(usage.Detail{}, true)
detail, ok := buffer.Detail()
if !ok {
t.Fatal("buffer detail ok = false, want true")
}
if detail != (usage.Detail{}) {
t.Fatalf("detail = %+v, want zero detail", detail)
}
}
func TestParseClaudeUsageIncludesCacheTokensInTotal(t *testing.T) {
data := []byte(`{"usage":{"input_tokens":3085,"output_tokens":253,"cache_read_input_tokens":7,"cache_creation_input_tokens":19514}}`)
detail := ParseClaudeUsage(data)
+3 -3
View File
@@ -288,12 +288,12 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, 1_048_576) // 1MB
var param any
var streamUsage helps.StreamUsageBuffer
defer streamUsage.Publish(ctx, reporter)
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
if detail, ok := helps.ParseOpenAIStreamUsage(line); ok {
reporter.Publish(ctx, detail)
}
streamUsage.Observe(helps.ParseOpenAIStreamUsage(line))
chunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)
for i := range chunks {
select {
@@ -393,12 +393,12 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, 52_428_800) // 50MB
var param any
var streamUsage helps.StreamUsageBuffer
defer streamUsage.Publish(ctx, reporter)
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
if detail, ok := helps.ParseOpenAIStreamUsage(line); ok {
reporter.Publish(ctx, detail)
}
streamUsage.Observe(helps.ParseOpenAIStreamUsage(line))
trimmedLine := bytes.TrimSpace(line)
if len(trimmedLine) == 0 {
continue
@@ -452,7 +452,8 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
}
}
}
// Ensure we record the request if no usage chunk was ever seen
// Ensure we record the request if no usage chunk was ever seen.
streamUsage.Publish(ctx, reporter)
reporter.EnsurePublished(ctx)
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil