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
7 changes: 7 additions & 0 deletions changelog/unreleased/responses-cache-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Preserve cached input token usage in Responses API output for both streaming and non-streaming requests without double-counting total tokens.

### 中文

- 在 Responses API 的流式与非流式输出中保留缓存输入 Token 用量,同时避免在总 Token 数中重复计数。
6 changes: 5 additions & 1 deletion internal/gateway/compat_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,11 @@ func RelayResponsesStream(writer io.Writer, body io.Reader, requestID, model str
}
}
terminal := responsesTerminalForFinishReason(output.finishReason)
response := responsesResponse(requestID, model, content, output.reasoning.String(), calls, derefInt(stats.PromptTokens), derefInt(stats.CompletionTokens), output.finishReason)
response := responsesResponse(
requestID, model, content, output.reasoning.String(), calls,
derefInt(stats.PromptTokens), derefInt(stats.CompletionTokens), output.finishReason,
stats.CacheReadTokens, stats.CachedTokens,
)
if err := eventWriter.write(terminal.event, map[string]any{"type": terminal.event, "response": response}); err != nil {
return stats, err
}
Expand Down
6 changes: 5 additions & 1 deletion internal/gateway/openai_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,14 @@ func ParseStreamUsageLine(line string) (StreamRelayStats, bool) {
if credits == nil {
credits = parsed.Usage.Credit
}
cacheRead := parsed.Usage.CacheReadTokens
if cacheRead == nil {
cacheRead = parsed.Usage.PromptDetails.CachedTokens
}
return StreamRelayStats{
PromptTokens: parsed.Usage.PromptTokens,
CompletionTokens: parsed.Usage.CompletionTokens,
CacheReadTokens: parsed.Usage.CacheReadTokens,
CacheReadTokens: cacheRead,
CacheWriteTokens: parsed.Usage.CacheWriteTokens,
CachedTokens: parsed.Usage.PromptDetails.CachedTokens,
UsageSource: firstNonEmpty(parsed.Usage.Source, "estimate"),
Expand Down
27 changes: 22 additions & 5 deletions internal/gateway/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ func (h *Handler) HandleResponses(w http.ResponseWriter, r *http.Request) {
CachedTokens: result.CachedTokens, UsageSource: result.UsageSource, Credits: result.Credits,
ConsumedCredits: result.ConsumedCredits, Model: result.Model, FinishReason: result.FinishReason,
}, nil, result.AttemptCount, result.ReasoningLevel)
response := responsesResponse(execution.RequestID, firstNonEmpty(result.Model, execution.PublicModel), result.Content, result.Reasoning, decodeOpenAIToolCalls(result.ToolCalls), result.PromptTokens, result.CompletionTokens, result.FinishReason)
response := responsesResponse(
execution.RequestID, firstNonEmpty(result.Model, execution.PublicModel), result.Content, result.Reasoning,
decodeOpenAIToolCalls(result.ToolCalls), result.PromptTokens, result.CompletionTokens, result.FinishReason,
result.CacheReadTokens, result.CachedTokens,
)
translate.RestoreResponseToolNames(response, execution.Request.ResponseToolNames)
writeJSON(w, http.StatusOK, response)
}
Expand Down Expand Up @@ -130,12 +134,18 @@ func responsesRequestStatus(finishReason string) string {
return accounts.RequestStatusOK
}

func responsesResponse(requestID, model, content, reasoning string, toolCalls []proxyToolCall, promptTokens, completionTokens int, finishReason string) map[string]any {
func responsesResponse(
requestID, model, content, reasoning string,
toolCalls []proxyToolCall,
promptTokens, completionTokens int,
finishReason string,
cacheReadTokens, cachedTokens *int,
) map[string]any {
terminal := responsesTerminalForFinishReason(finishReason)
response := map[string]any{
"id": "resp_" + requestID, "object": "response", "created_at": time.Now().Unix(), "status": terminal.status, "model": model,
"output": responsesOutputItems(requestID, content, reasoning, toolCalls),
"usage": responsesUsage(promptTokens, completionTokens),
"usage": responsesUsage(promptTokens, completionTokens, cacheReadTokens, cachedTokens),
}
if terminal.incompleteDetails != nil {
response["incomplete_details"] = terminal.incompleteDetails
Expand Down Expand Up @@ -167,6 +177,13 @@ func responseFunctionCallItem(requestID string, callIndex int, call proxyToolCal
}
}

func responsesUsage(promptTokens, completionTokens int) map[string]any {
return map[string]any{"input_tokens": promptTokens, "output_tokens": completionTokens, "total_tokens": promptTokens + completionTokens}
func responsesUsage(promptTokens, completionTokens int, cacheReadTokens, cachedTokens *int) map[string]any {
usage := map[string]any{"input_tokens": promptTokens, "output_tokens": completionTokens, "total_tokens": promptTokens + completionTokens}
if cacheReadTokens == nil {
cacheReadTokens = cachedTokens
}
if cacheReadTokens != nil {
usage["input_tokens_details"] = map[string]any{"cached_tokens": *cacheReadTokens}
}
return usage
}
62 changes: 61 additions & 1 deletion internal/gateway/responses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
)

func TestResponsesResponseMapsLengthToIncomplete(t *testing.T) {
response := responsesResponse("req", "model", "", "reasoning", nil, 10, 32, "length")
response := responsesResponse("req", "model", "", "reasoning", nil, 10, 32, "length", nil, nil)
if response["status"] != "incomplete" {
t.Fatalf("status=%v", response["status"])
}
Expand Down Expand Up @@ -54,3 +54,63 @@ func TestRelayResponsesStreamEmitsIncomplete(t *testing.T) {
t.Fatalf("response=%#v", response)
}
}
func TestResponsesUsagePreservesCachedInputTokens(t *testing.T) {
zero := 0
read := 64
cached := 48

withoutCache := responsesUsage(100, 20, nil, nil)
if _, ok := withoutCache["input_tokens_details"]; ok {
t.Fatalf("missing cache usage must not be fabricated: %#v", withoutCache)
}

withZero := responsesUsage(100, 20, &zero, nil)
zeroDetails := withZero["input_tokens_details"].(map[string]any)
if zeroDetails["cached_tokens"] != 0 {
t.Fatalf("explicit zero cache usage was lost: %#v", withZero)
}

withFallback := responsesUsage(100, 20, nil, &cached)
fallbackDetails := withFallback["input_tokens_details"].(map[string]any)
if fallbackDetails["cached_tokens"] != 48 {
t.Fatalf("cached token fallback mismatch: %#v", withFallback)
}

withTopLevel := responsesUsage(100, 20, &read, &cached)
topLevelDetails := withTopLevel["input_tokens_details"].(map[string]any)
if topLevelDetails["cached_tokens"] != 64 {
t.Fatalf("cache_read_tokens must win: %#v", withTopLevel)
}
if withTopLevel["total_tokens"] != 120 {
t.Fatalf("cached input must not be added twice: %#v", withTopLevel)
}
}

func TestParseStreamUsageLineCacheReadFallback(t *testing.T) {
for _, test := range []struct {
name string
usage string
want *int
}{
{name: "detail only", usage: `"prompt_tokens_details":{"cached_tokens":2176}`, want: ptrInt(2176)},
{name: "explicit zero", usage: `"prompt_tokens_details":{"cached_tokens":0}`, want: ptrInt(0)},
{name: "top-level wins", usage: `"cache_read_tokens":12,"prompt_tokens_details":{"cached_tokens":2176}`, want: ptrInt(12)},
{name: "unknown stays absent", usage: `"prompt_tokens":16`, want: nil},
} {
t.Run(test.name, func(t *testing.T) {
stats, ok := ParseStreamUsageLine(`data: {"usage":{` + test.usage + `}}`)
if !ok {
t.Fatal("usage not parsed")
}
if test.want == nil {
if stats.CacheReadTokens != nil {
t.Fatalf("fabricated cache read: %v", *stats.CacheReadTokens)
}
return
}
if stats.CacheReadTokens == nil || *stats.CacheReadTokens != *test.want {
t.Fatalf("cache read = %v, want %d", stats.CacheReadTokens, *test.want)
}
})
}
}
Loading