mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-06 16:15:50 +08:00
feat(translator): support cache write tokens in claude responses
- Extract cache write tokens from OpenAI and Codex usage details. - Map cache write tokens to Claude `cache_creation_input_tokens` for streaming and non-streaming responses. Closes: #4262
This commit is contained in:
@@ -157,12 +157,15 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
||||
output = append(output, stopCodexTextBlock(params)...)
|
||||
template, _ = sjson.SetBytes(template, "delta.stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), params.HasEmittedToolUse))
|
||||
template = setClaudeStopSequence(template, "delta.stop_sequence", responseData)
|
||||
inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage"))
|
||||
inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractResponsesUsage(responseData.Get("usage"))
|
||||
template, _ = sjson.SetBytes(template, "usage.input_tokens", inputTokens)
|
||||
template, _ = sjson.SetBytes(template, "usage.output_tokens", outputTokens)
|
||||
if cachedTokens > 0 {
|
||||
template, _ = sjson.SetBytes(template, "usage.cache_read_input_tokens", cachedTokens)
|
||||
}
|
||||
if cacheWriteTokens > 0 {
|
||||
template, _ = sjson.SetBytes(template, "usage.cache_creation_input_tokens", cacheWriteTokens)
|
||||
}
|
||||
|
||||
output = translatorcommon.AppendSSEEventBytes(output, "message_delta", template, 2)
|
||||
output = translatorcommon.AppendSSEEventBytes(output, "message_stop", []byte(`{"type":"message_stop"}`), 2)
|
||||
@@ -361,12 +364,15 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original
|
||||
out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
out, _ = sjson.SetBytes(out, "id", responseData.Get("id").String())
|
||||
out, _ = sjson.SetBytes(out, "model", responseData.Get("model").String())
|
||||
inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage"))
|
||||
inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractResponsesUsage(responseData.Get("usage"))
|
||||
out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
|
||||
if cachedTokens > 0 {
|
||||
out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens)
|
||||
}
|
||||
if cacheWriteTokens > 0 {
|
||||
out, _ = sjson.SetBytes(out, "usage.cache_creation_input_tokens", cacheWriteTokens)
|
||||
}
|
||||
|
||||
hasToolCall := false
|
||||
webSearchSeen := make(map[string]struct{})
|
||||
@@ -794,14 +800,18 @@ func resolveCodexClaudeToolUseName(originalRequestRawJSON []byte, name string) s
|
||||
return name
|
||||
}
|
||||
|
||||
func extractResponsesUsage(usage gjson.Result) (int64, int64, int64) {
|
||||
func extractResponsesUsage(usage gjson.Result) (int64, int64, int64, int64) {
|
||||
if !usage.Exists() || usage.Type == gjson.Null {
|
||||
return 0, 0, 0
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
|
||||
inputTokens := usage.Get("input_tokens").Int()
|
||||
outputTokens := usage.Get("output_tokens").Int()
|
||||
cachedTokens := usage.Get("input_tokens_details.cached_tokens").Int()
|
||||
cacheWriteTokens := usage.Get("input_tokens_details.cache_write_tokens").Int()
|
||||
if cacheWriteTokens == 0 {
|
||||
cacheWriteTokens = usage.Get("input_tokens_details.cache_creation_tokens").Int()
|
||||
}
|
||||
|
||||
if cachedTokens > 0 {
|
||||
if inputTokens >= cachedTokens {
|
||||
@@ -811,7 +821,7 @@ func extractResponsesUsage(usage gjson.Result) (int64, int64, int64) {
|
||||
}
|
||||
}
|
||||
|
||||
return inputTokens, outputTokens, cachedTokens
|
||||
return inputTokens, outputTokens, cachedTokens, cacheWriteTokens
|
||||
}
|
||||
|
||||
// buildReverseMapFromClaudeOriginalShortToOriginal builds a map[short]original from original Claude request tools.
|
||||
|
||||
@@ -3,6 +3,7 @@ package claude
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -1328,3 +1329,196 @@ func firstClaudeStreamPayloadForEvent(output, event string) (gjson.Result, bool)
|
||||
}
|
||||
return gjson.Result{}, false
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToClaude_StreamPreservesCacheWriteUsage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
terminalUsageJSON string
|
||||
wantInputTokens int64
|
||||
wantOutputTokens int64
|
||||
wantCacheReadTokens int64
|
||||
wantCacheWriteTokens int64
|
||||
}{
|
||||
{
|
||||
name: "cache_write_tokens field",
|
||||
terminalUsageJSON: `{"input_tokens":1000,"output_tokens":200,"input_tokens_details":{"cached_tokens":800,"cache_write_tokens":150}}`,
|
||||
wantInputTokens: 200,
|
||||
wantOutputTokens: 200,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 150,
|
||||
},
|
||||
{
|
||||
name: "cache_creation_tokens field alias",
|
||||
terminalUsageJSON: `{"input_tokens":1000,"output_tokens":200,"input_tokens_details":{"cached_tokens":800,"cache_creation_tokens":150}}`,
|
||||
wantInputTokens: 200,
|
||||
wantOutputTokens: 200,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 150,
|
||||
},
|
||||
{
|
||||
name: "cached_tokens greater than input_tokens clamps input_tokens to zero",
|
||||
terminalUsageJSON: `{"input_tokens":500,"output_tokens":100,"input_tokens_details":{"cached_tokens":800,"cache_write_tokens":50}}`,
|
||||
wantInputTokens: 0,
|
||||
wantOutputTokens: 100,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 50,
|
||||
},
|
||||
{
|
||||
name: "zero cache_write_tokens does not emit cache_creation_input_tokens",
|
||||
terminalUsageJSON: `{"input_tokens":1000,"output_tokens":200,"input_tokens_details":{"cached_tokens":800,"cache_write_tokens":0}}`,
|
||||
wantInputTokens: 200,
|
||||
wantOutputTokens: 200,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"messages":[]}`)
|
||||
var param any
|
||||
|
||||
chunks := [][]byte{
|
||||
[]byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5"}}`),
|
||||
[]byte(`data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}}`),
|
||||
[]byte(fmt.Sprintf(`data: {"type":"response.completed","response":{"stop_reason":"stop","usage":%s}}`, tt.terminalUsageJSON)),
|
||||
}
|
||||
|
||||
var outputs [][]byte
|
||||
for _, chunk := range chunks {
|
||||
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...)
|
||||
}
|
||||
|
||||
delta, ok := findClaudeStreamMessageDelta(outputs)
|
||||
if !ok {
|
||||
t.Fatalf("missing message_delta event; outputs=%q", outputs)
|
||||
}
|
||||
|
||||
usage := delta.Get("usage")
|
||||
if got := usage.Get("input_tokens").Int(); got != tt.wantInputTokens {
|
||||
t.Fatalf("input_tokens = %d, want %d", got, tt.wantInputTokens)
|
||||
}
|
||||
if got := usage.Get("output_tokens").Int(); got != tt.wantOutputTokens {
|
||||
t.Fatalf("output_tokens = %d, want %d", got, tt.wantOutputTokens)
|
||||
}
|
||||
if got := usage.Get("cache_read_input_tokens").Int(); got != tt.wantCacheReadTokens {
|
||||
t.Fatalf("cache_read_input_tokens = %d, want %d", got, tt.wantCacheReadTokens)
|
||||
}
|
||||
if tt.wantCacheWriteTokens == 0 {
|
||||
if usage.Get("cache_creation_input_tokens").Exists() {
|
||||
t.Fatalf("cache_creation_input_tokens should not be emitted when zero; got %v", usage.Get("cache_creation_input_tokens").Raw)
|
||||
}
|
||||
} else if got := usage.Get("cache_creation_input_tokens").Int(); got != tt.wantCacheWriteTokens {
|
||||
t.Fatalf("cache_creation_input_tokens = %d, want %d", got, tt.wantCacheWriteTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToClaudeNonStream_PreservesCacheWriteUsage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseJSON string
|
||||
wantInputTokens int64
|
||||
wantOutputTokens int64
|
||||
wantCacheReadTokens int64
|
||||
wantCacheWriteTokens int64
|
||||
}{
|
||||
{
|
||||
name: "cache_write_tokens field",
|
||||
responseJSON: `{
|
||||
"type":"response.completed",
|
||||
"response":{
|
||||
"id":"resp_1",
|
||||
"model":"gpt-5",
|
||||
"stop_reason":"stop",
|
||||
"usage":{"input_tokens":1000,"output_tokens":200,"input_tokens_details":{"cached_tokens":800,"cache_write_tokens":150}},
|
||||
"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]
|
||||
}
|
||||
}`,
|
||||
wantInputTokens: 200,
|
||||
wantOutputTokens: 200,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 150,
|
||||
},
|
||||
{
|
||||
name: "cache_creation_tokens alias",
|
||||
responseJSON: `{
|
||||
"type":"response.completed",
|
||||
"response":{
|
||||
"id":"resp_1",
|
||||
"model":"gpt-5",
|
||||
"stop_reason":"stop",
|
||||
"usage":{"input_tokens":1000,"output_tokens":200,"input_tokens_details":{"cached_tokens":800,"cache_creation_tokens":150}},
|
||||
"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]
|
||||
}
|
||||
}`,
|
||||
wantInputTokens: 200,
|
||||
wantOutputTokens: 200,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 150,
|
||||
},
|
||||
{
|
||||
name: "cached_tokens greater than input_tokens clamps input_tokens to zero",
|
||||
responseJSON: `{
|
||||
"type":"response.completed",
|
||||
"response":{
|
||||
"id":"resp_1",
|
||||
"model":"gpt-5",
|
||||
"stop_reason":"stop",
|
||||
"usage":{"input_tokens":500,"output_tokens":100,"input_tokens_details":{"cached_tokens":800,"cache_write_tokens":50}},
|
||||
"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]
|
||||
}
|
||||
}`,
|
||||
wantInputTokens: 0,
|
||||
wantOutputTokens: 100,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 50,
|
||||
},
|
||||
{
|
||||
name: "zero cache_write_tokens does not emit cache_creation_input_tokens",
|
||||
responseJSON: `{
|
||||
"type":"response.completed",
|
||||
"response":{
|
||||
"id":"resp_1",
|
||||
"model":"gpt-5",
|
||||
"stop_reason":"stop",
|
||||
"usage":{"input_tokens":1000,"output_tokens":200,"input_tokens_details":{"cached_tokens":800,"cache_write_tokens":0}},
|
||||
"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]
|
||||
}
|
||||
}`,
|
||||
wantInputTokens: 200,
|
||||
wantOutputTokens: 200,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"messages":[]}`)
|
||||
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, []byte(tt.responseJSON), nil)
|
||||
parsed := gjson.ParseBytes(out)
|
||||
|
||||
usage := parsed.Get("usage")
|
||||
if got := usage.Get("input_tokens").Int(); got != tt.wantInputTokens {
|
||||
t.Fatalf("input_tokens = %d, want %d", got, tt.wantInputTokens)
|
||||
}
|
||||
if got := usage.Get("output_tokens").Int(); got != tt.wantOutputTokens {
|
||||
t.Fatalf("output_tokens = %d, want %d", got, tt.wantOutputTokens)
|
||||
}
|
||||
if got := usage.Get("cache_read_input_tokens").Int(); got != tt.wantCacheReadTokens {
|
||||
t.Fatalf("cache_read_input_tokens = %d, want %d", got, tt.wantCacheReadTokens)
|
||||
}
|
||||
if tt.wantCacheWriteTokens == 0 {
|
||||
if usage.Get("cache_creation_input_tokens").Exists() {
|
||||
t.Fatalf("cache_creation_input_tokens should not be emitted when zero; got %v", usage.Get("cache_creation_input_tokens").Raw)
|
||||
}
|
||||
} else if got := usage.Get("cache_creation_input_tokens").Int(); got != tt.wantCacheWriteTokens {
|
||||
t.Fatalf("cache_creation_input_tokens = %d, want %d", got, tt.wantCacheWriteTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,8 +310,8 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
|
||||
usage := root.Get("usage")
|
||||
if usage.Exists() && usage.Type != gjson.Null {
|
||||
finalizeOpenAIAnthropicContentBlocks(param, &results)
|
||||
inputTokens, outputTokens, cachedTokens := extractOpenAIUsage(usage)
|
||||
emitAnthropicMessageDelta(param, &results, inputTokens, outputTokens, cachedTokens)
|
||||
inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractOpenAIUsage(usage)
|
||||
emitAnthropicMessageDelta(param, &results, inputTokens, outputTokens, cachedTokens, cacheWriteTokens)
|
||||
emitMessageStopIfNeeded(param, &results)
|
||||
}
|
||||
}
|
||||
@@ -326,7 +326,7 @@ func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams)
|
||||
finalizeOpenAIAnthropicContentBlocks(param, &results)
|
||||
|
||||
if !param.MessageDeltaSent {
|
||||
emitAnthropicMessageDelta(param, &results, 0, 0, 0)
|
||||
emitAnthropicMessageDelta(param, &results, 0, 0, 0, 0)
|
||||
}
|
||||
|
||||
emitMessageStopIfNeeded(param, &results)
|
||||
@@ -400,12 +400,15 @@ func convertOpenAINonStreamingToAnthropic(rawJSON []byte) [][]byte {
|
||||
|
||||
// Set usage information
|
||||
if usage := root.Get("usage"); usage.Exists() {
|
||||
inputTokens, outputTokens, cachedTokens := extractOpenAIUsage(usage)
|
||||
inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractOpenAIUsage(usage)
|
||||
out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
|
||||
if cachedTokens > 0 {
|
||||
out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens)
|
||||
}
|
||||
if cacheWriteTokens > 0 {
|
||||
out, _ = sjson.SetBytes(out, "usage.cache_creation_input_tokens", cacheWriteTokens)
|
||||
}
|
||||
}
|
||||
|
||||
return [][]byte{out}
|
||||
@@ -570,7 +573,7 @@ func finalizeOpenAIAnthropicContentBlocks(param *ConvertOpenAIResponseToAnthropi
|
||||
}
|
||||
}
|
||||
|
||||
func emitAnthropicMessageDelta(param *ConvertOpenAIResponseToAnthropicParams, results *[][]byte, inputTokens, outputTokens, cachedTokens int64) {
|
||||
func emitAnthropicMessageDelta(param *ConvertOpenAIResponseToAnthropicParams, results *[][]byte, inputTokens, outputTokens, cachedTokens, cacheWriteTokens int64) {
|
||||
if param == nil || param.MessageDeltaSent {
|
||||
return
|
||||
}
|
||||
@@ -581,6 +584,9 @@ func emitAnthropicMessageDelta(param *ConvertOpenAIResponseToAnthropicParams, re
|
||||
if cachedTokens > 0 {
|
||||
messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.cache_read_input_tokens", cachedTokens)
|
||||
}
|
||||
if cacheWriteTokens > 0 {
|
||||
messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.cache_creation_input_tokens", cacheWriteTokens)
|
||||
}
|
||||
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "message_delta", messageDeltaJSON, 2))
|
||||
param.MessageDeltaSent = true
|
||||
}
|
||||
@@ -748,12 +754,15 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina
|
||||
}
|
||||
|
||||
if respUsage := root.Get("usage"); respUsage.Exists() {
|
||||
inputTokens, outputTokens, cachedTokens := extractOpenAIUsage(respUsage)
|
||||
inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractOpenAIUsage(respUsage)
|
||||
out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
|
||||
if cachedTokens > 0 {
|
||||
out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens)
|
||||
}
|
||||
if cacheWriteTokens > 0 {
|
||||
out, _ = sjson.SetBytes(out, "usage.cache_creation_input_tokens", cacheWriteTokens)
|
||||
}
|
||||
}
|
||||
|
||||
if !stopReasonSet {
|
||||
@@ -771,14 +780,18 @@ func ClaudeTokenCount(ctx context.Context, count int64) []byte {
|
||||
return translatorcommon.ClaudeInputTokensJSON(count)
|
||||
}
|
||||
|
||||
func extractOpenAIUsage(usage gjson.Result) (int64, int64, int64) {
|
||||
func extractOpenAIUsage(usage gjson.Result) (int64, int64, int64, int64) {
|
||||
if !usage.Exists() || usage.Type == gjson.Null {
|
||||
return 0, 0, 0
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
|
||||
inputTokens := usage.Get("prompt_tokens").Int()
|
||||
outputTokens := usage.Get("completion_tokens").Int()
|
||||
cachedTokens := usage.Get("prompt_tokens_details.cached_tokens").Int()
|
||||
cacheWriteTokens := usage.Get("prompt_tokens_details.cache_write_tokens").Int()
|
||||
if cacheWriteTokens == 0 {
|
||||
cacheWriteTokens = usage.Get("prompt_tokens_details.cache_creation_tokens").Int()
|
||||
}
|
||||
|
||||
if cachedTokens > 0 {
|
||||
if inputTokens >= cachedTokens {
|
||||
@@ -788,5 +801,5 @@ func extractOpenAIUsage(usage gjson.Result) (int64, int64, int64) {
|
||||
}
|
||||
}
|
||||
|
||||
return inputTokens, outputTokens, cachedTokens
|
||||
return inputTokens, outputTokens, cachedTokens, cacheWriteTokens
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package claude
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -577,3 +578,165 @@ func TestStreamingTool_OmittedToolCallIndexPreservesParallelCalls(t *testing.T)
|
||||
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingUsage_PreservesCacheWriteTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
usageJSON string
|
||||
wantInputTokens int64
|
||||
wantOutputTokens int64
|
||||
wantCacheReadTokens int64
|
||||
wantCacheWriteTokens int64
|
||||
}{
|
||||
{
|
||||
name: "cache_write_tokens field",
|
||||
usageJSON: `{"prompt_tokens":1000,"completion_tokens":200,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":150}}`,
|
||||
wantInputTokens: 200,
|
||||
wantOutputTokens: 200,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 150,
|
||||
},
|
||||
{
|
||||
name: "cache_creation_tokens alias",
|
||||
usageJSON: `{"prompt_tokens":1000,"completion_tokens":200,"prompt_tokens_details":{"cached_tokens":800,"cache_creation_tokens":150}}`,
|
||||
wantInputTokens: 200,
|
||||
wantOutputTokens: 200,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 150,
|
||||
},
|
||||
{
|
||||
name: "cached_tokens greater than prompt_tokens clamps input_tokens to zero",
|
||||
usageJSON: `{"prompt_tokens":500,"completion_tokens":100,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":50}}`,
|
||||
wantInputTokens: 0,
|
||||
wantOutputTokens: 100,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 50,
|
||||
},
|
||||
{
|
||||
name: "zero cache_write_tokens does not emit cache_creation_input_tokens",
|
||||
usageJSON: `{"prompt_tokens":1000,"completion_tokens":200,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":0}}`,
|
||||
wantInputTokens: 200,
|
||||
wantOutputTokens: 200,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"hello"}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
|
||||
fmt.Sprintf(`{"id":"c1","model":"m","choices":[],"usage":%s}`, tt.usageJSON),
|
||||
)
|
||||
|
||||
var deltaEvent *sseEvent
|
||||
for _, e := range events {
|
||||
if e.Type == "message_delta" {
|
||||
deltaEvent = &e
|
||||
break
|
||||
}
|
||||
}
|
||||
if deltaEvent == nil {
|
||||
t.Fatalf("missing message_delta event")
|
||||
}
|
||||
if input := gjson.Get(deltaEvent.Payload, "usage.input_tokens").Int(); input != tt.wantInputTokens {
|
||||
t.Fatalf("input_tokens = %d, want %d", input, tt.wantInputTokens)
|
||||
}
|
||||
if output := gjson.Get(deltaEvent.Payload, "usage.output_tokens").Int(); output != tt.wantOutputTokens {
|
||||
t.Fatalf("output_tokens = %d, want %d", output, tt.wantOutputTokens)
|
||||
}
|
||||
if cacheRead := gjson.Get(deltaEvent.Payload, "usage.cache_read_input_tokens").Int(); cacheRead != tt.wantCacheReadTokens {
|
||||
t.Fatalf("cache_read_input_tokens = %d, want %d", cacheRead, tt.wantCacheReadTokens)
|
||||
}
|
||||
if tt.wantCacheWriteTokens == 0 {
|
||||
if gjson.Get(deltaEvent.Payload, "usage.cache_creation_input_tokens").Exists() {
|
||||
t.Fatalf("cache_creation_input_tokens should not be emitted when zero; got %v", gjson.Get(deltaEvent.Payload, "usage.cache_creation_input_tokens").Raw)
|
||||
}
|
||||
} else if cacheWrite := gjson.Get(deltaEvent.Payload, "usage.cache_creation_input_tokens").Int(); cacheWrite != tt.wantCacheWriteTokens {
|
||||
t.Fatalf("cache_creation_input_tokens = %d, want %d", cacheWrite, tt.wantCacheWriteTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonStreamingUsage_PreservesCacheWriteTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
usageJSON string
|
||||
wantInputTokens int64
|
||||
wantOutputTokens int64
|
||||
wantCacheReadTokens int64
|
||||
wantCacheWriteTokens int64
|
||||
}{
|
||||
{
|
||||
name: "cache_write_tokens field",
|
||||
usageJSON: `{"prompt_tokens":1000,"completion_tokens":200,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":150}}`,
|
||||
wantInputTokens: 200,
|
||||
wantOutputTokens: 200,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 150,
|
||||
},
|
||||
{
|
||||
name: "cache_creation_tokens alias",
|
||||
usageJSON: `{"prompt_tokens":1000,"completion_tokens":200,"prompt_tokens_details":{"cached_tokens":800,"cache_creation_tokens":150}}`,
|
||||
wantInputTokens: 200,
|
||||
wantOutputTokens: 200,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 150,
|
||||
},
|
||||
{
|
||||
name: "cached_tokens greater than prompt_tokens clamps input_tokens to zero",
|
||||
usageJSON: `{"prompt_tokens":500,"completion_tokens":100,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":50}}`,
|
||||
wantInputTokens: 0,
|
||||
wantOutputTokens: 100,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 50,
|
||||
},
|
||||
{
|
||||
name: "zero cache_write_tokens does not emit cache_creation_input_tokens",
|
||||
usageJSON: `{"prompt_tokens":1000,"completion_tokens":200,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":0}}`,
|
||||
wantInputTokens: 200,
|
||||
wantOutputTokens: 200,
|
||||
wantCacheReadTokens: 800,
|
||||
wantCacheWriteTokens: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rawJSON := []byte(fmt.Sprintf(`{
|
||||
"id":"chatcmpl-123",
|
||||
"object":"chat.completion",
|
||||
"created":1677652288,
|
||||
"model":"gpt-5.4",
|
||||
"choices":[{"index":0,"message":{"role":"assistant","content":"Hello world"},"finish_reason":"stop"}],
|
||||
"usage":%s
|
||||
}`, tt.usageJSON))
|
||||
|
||||
ctx := context.Background()
|
||||
reqJSON := []byte(`{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":"Hello"}]}`)
|
||||
|
||||
out := ConvertOpenAIResponseToClaudeNonStream(ctx, "", reqJSON, reqJSON, rawJSON, nil)
|
||||
parsed := gjson.ParseBytes(out)
|
||||
|
||||
usage := parsed.Get("usage")
|
||||
if got := usage.Get("input_tokens").Int(); got != tt.wantInputTokens {
|
||||
t.Fatalf("input_tokens = %d, want %d", got, tt.wantInputTokens)
|
||||
}
|
||||
if got := usage.Get("output_tokens").Int(); got != tt.wantOutputTokens {
|
||||
t.Fatalf("output_tokens = %d, want %d", got, tt.wantOutputTokens)
|
||||
}
|
||||
if got := usage.Get("cache_read_input_tokens").Int(); got != tt.wantCacheReadTokens {
|
||||
t.Fatalf("cache_read_input_tokens = %d, want %d", got, tt.wantCacheReadTokens)
|
||||
}
|
||||
if tt.wantCacheWriteTokens == 0 {
|
||||
if usage.Get("cache_creation_input_tokens").Exists() {
|
||||
t.Fatalf("cache_creation_input_tokens should not be emitted when zero; got %v", usage.Get("cache_creation_input_tokens").Raw)
|
||||
}
|
||||
} else if got := usage.Get("cache_creation_input_tokens").Int(); got != tt.wantCacheWriteTokens {
|
||||
t.Fatalf("cache_creation_input_tokens = %d, want %d", got, tt.wantCacheWriteTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user