mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 14:39:26 +08:00
- add shared `EnsureResponsesUsageDetails` helper to patch `usage` objects with: - `output_tokens_details.reasoning_tokens = 0` - `input_tokens_details.cached_tokens = 0` - for both plain JSON and SSE `data:` frames, including multi-line frames - apply the helper to OpenAI Response format outputs in non-stream and stream paths across executors/plugins so translated payloads consistently include required usage details - update websocket/completion payload builders to emit default `usage` detail fields for prewarm/finish responses Closes: #4985
211 lines
9.7 KiB
Go
211 lines
9.7 KiB
Go
package helps
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"testing"
|
|
|
|
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestEnsureResponsesUsageDetails_NonStreamJSON(t *testing.T) {
|
|
raw := []byte(`{"id":"resp_1","object":"response","status":"completed","usage":{"input_tokens":84,"output_tokens":16,"total_tokens":100}}`)
|
|
got := EnsureResponsesUsageDetails(raw)
|
|
|
|
if !gjson.GetBytes(got, "usage.output_tokens_details").Exists() {
|
|
t.Fatalf("expected usage.output_tokens_details to exist, got %s", string(got))
|
|
}
|
|
if gjson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int() != 0 {
|
|
t.Fatalf("expected usage.output_tokens_details.reasoning_tokens == 0, got %d", gjson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int())
|
|
}
|
|
if !gjson.GetBytes(got, "usage.input_tokens_details").Exists() {
|
|
t.Fatalf("expected usage.input_tokens_details to exist, got %s", string(got))
|
|
}
|
|
if gjson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int() != 0 {
|
|
t.Fatalf("expected usage.input_tokens_details.cached_tokens == 0, got %d", gjson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int())
|
|
}
|
|
}
|
|
|
|
func TestEnsureResponsesUsageDetails_NonStreamJSONWithDataSubstring(t *testing.T) {
|
|
raw := []byte(`{"id":"resp_1","object":"response","status":"completed","output":[{"type":"message","content":[{"type":"text","text":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUg"}]}],"usage":{"input_tokens":84,"output_tokens":16,"total_tokens":100}}`)
|
|
got := EnsureResponsesUsageDetails(raw)
|
|
|
|
if !gjson.GetBytes(got, "usage.output_tokens_details").Exists() {
|
|
t.Fatalf("expected usage.output_tokens_details to exist, got %s", string(got))
|
|
}
|
|
if gjson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int() != 0 {
|
|
t.Fatalf("expected usage.output_tokens_details.reasoning_tokens == 0, got %d", gjson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int())
|
|
}
|
|
if !gjson.GetBytes(got, "usage.input_tokens_details").Exists() {
|
|
t.Fatalf("expected usage.input_tokens_details to exist, got %s", string(got))
|
|
}
|
|
if gjson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int() != 0 {
|
|
t.Fatalf("expected usage.input_tokens_details.cached_tokens == 0, got %d", gjson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int())
|
|
}
|
|
}
|
|
|
|
func TestEnsureResponsesUsageDetails_SSEData(t *testing.T) {
|
|
raw := []byte(`data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14}}}`)
|
|
got := EnsureResponsesUsageDetails(raw)
|
|
|
|
if !bytes.HasPrefix(got, []byte("data: ")) {
|
|
t.Fatalf("expected data: prefix preserved, got %s", string(got))
|
|
}
|
|
jsonBody := bytes.TrimPrefix(got, []byte("data: "))
|
|
if !gjson.GetBytes(jsonBody, "response.usage.output_tokens_details").Exists() {
|
|
t.Fatalf("expected response.usage.output_tokens_details to exist, got %s", string(got))
|
|
}
|
|
if gjson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int() != 0 {
|
|
t.Fatalf("expected reasoning_tokens == 0, got %d", gjson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int())
|
|
}
|
|
if !gjson.GetBytes(jsonBody, "response.usage.input_tokens_details").Exists() {
|
|
t.Fatalf("expected response.usage.input_tokens_details to exist, got %s", string(got))
|
|
}
|
|
if gjson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int() != 0 {
|
|
t.Fatalf("expected cached_tokens == 0, got %d", gjson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int())
|
|
}
|
|
}
|
|
|
|
func TestEnsureResponsesUsageDetails_SSEEventDataMultiLine(t *testing.T) {
|
|
raw := []byte("event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":84,\"output_tokens\":16,\"total_tokens\":100}}}\n\n")
|
|
got := EnsureResponsesUsageDetails(raw)
|
|
|
|
if !bytes.HasPrefix(got, []byte("event: response.completed\n")) {
|
|
t.Fatalf("expected event header preserved, got %s", string(got))
|
|
}
|
|
|
|
for _, line := range bytes.Split(got, []byte("\n")) {
|
|
if bytes.HasPrefix(line, []byte("data: ")) {
|
|
jsonBody := bytes.TrimPrefix(line, []byte("data: "))
|
|
if !gjson.GetBytes(jsonBody, "response.usage.output_tokens_details").Exists() {
|
|
t.Fatalf("expected response.usage.output_tokens_details to exist in multi-line frame, got %s", string(got))
|
|
}
|
|
if gjson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int() != 0 {
|
|
t.Fatalf("expected reasoning_tokens == 0, got %d", gjson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int())
|
|
}
|
|
if !gjson.GetBytes(jsonBody, "response.usage.input_tokens_details").Exists() {
|
|
t.Fatalf("expected response.usage.input_tokens_details to exist in multi-line frame, got %s", string(got))
|
|
}
|
|
if gjson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int() != 0 {
|
|
t.Fatalf("expected cached_tokens == 0, got %d", gjson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEnsureResponsesUsageDetails_PreservesExistingDetails(t *testing.T) {
|
|
raw := []byte(`data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":3},"output_tokens":4,"output_tokens_details":{"reasoning_tokens":2},"total_tokens":14}}}`)
|
|
got := EnsureResponsesUsageDetails(raw)
|
|
|
|
jsonBody := bytes.TrimPrefix(got, []byte("data: "))
|
|
if gjson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int() != 2 {
|
|
t.Fatalf("expected reasoning_tokens == 2, got %d", gjson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int())
|
|
}
|
|
if gjson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int() != 3 {
|
|
t.Fatalf("expected cached_tokens == 3, got %d", gjson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int())
|
|
}
|
|
}
|
|
|
|
func TestEnsureResponsesUsageDetails_HandlesNullOrEmptyDetails(t *testing.T) {
|
|
raw := []byte(`{"id":"resp_1","usage":{"input_tokens":10,"input_tokens_details":null,"output_tokens":4,"output_tokens_details":{},"total_tokens":14}}`)
|
|
got := EnsureResponsesUsageDetails(raw)
|
|
|
|
if gjson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int() != 0 {
|
|
t.Fatalf("expected reasoning_tokens == 0, got %d", gjson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int())
|
|
}
|
|
if gjson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int() != 0 {
|
|
t.Fatalf("expected cached_tokens == 0, got %d", gjson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int())
|
|
}
|
|
}
|
|
|
|
func TestEnsureResponsesUsageDetails_NonJSONAndDone(t *testing.T) {
|
|
cases := [][]byte{
|
|
[]byte("data: [DONE]"),
|
|
[]byte("[DONE]"),
|
|
[]byte(": keepalive"),
|
|
[]byte(""),
|
|
[]byte(`{"type":"response.output_item.added"}`),
|
|
}
|
|
for _, c := range cases {
|
|
got := EnsureResponsesUsageDetails(c)
|
|
if !bytes.Equal(got, c) {
|
|
t.Fatalf("expected unchanged for %q, got %q", string(c), string(got))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTranslateStreamWithClaudeInputTokens_OpenAICompatTranslation_PatchesResponsesUsage(t *testing.T) {
|
|
ctx := context.Background()
|
|
reqBody := []byte(`{"model":"deepseek-v4-flash","input":"hi","stream":true}`)
|
|
translatedReq := []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"stream":true,"stream_options":{"include_usage":true}}`)
|
|
|
|
chunk1 := []byte(`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"hello"},"finish_reason":null}]}`)
|
|
chunk2 := []byte(`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":84,"completion_tokens":16,"total_tokens":100}}`)
|
|
chunk3 := []byte(`data: [DONE]`)
|
|
|
|
var param any
|
|
_ = TranslateStreamWithClaudeInputTokens(
|
|
ctx,
|
|
sdktranslator.FormatOpenAI,
|
|
sdktranslator.FormatOpenAIResponse,
|
|
"deepseek-v4-flash",
|
|
reqBody,
|
|
translatedReq,
|
|
chunk1,
|
|
¶m,
|
|
nil,
|
|
)
|
|
chunks2 := TranslateStreamWithClaudeInputTokens(
|
|
ctx,
|
|
sdktranslator.FormatOpenAI,
|
|
sdktranslator.FormatOpenAIResponse,
|
|
"deepseek-v4-flash",
|
|
reqBody,
|
|
translatedReq,
|
|
chunk2,
|
|
¶m,
|
|
nil,
|
|
)
|
|
chunks3 := TranslateStreamWithClaudeInputTokens(
|
|
ctx,
|
|
sdktranslator.FormatOpenAI,
|
|
sdktranslator.FormatOpenAIResponse,
|
|
"deepseek-v4-flash",
|
|
reqBody,
|
|
translatedReq,
|
|
chunk3,
|
|
¶m,
|
|
nil,
|
|
)
|
|
|
|
allChunks := append(chunks2, chunks3...)
|
|
foundCompleted := false
|
|
for _, ch := range allChunks {
|
|
for _, line := range bytes.Split(ch, []byte("\n")) {
|
|
if bytes.HasPrefix(line, []byte("data: ")) {
|
|
payload := bytes.TrimPrefix(line, []byte("data: "))
|
|
if gjson.GetBytes(payload, "type").String() == "response.completed" {
|
|
foundCompleted = true
|
|
if !gjson.GetBytes(payload, "response.usage.output_tokens_details").Exists() {
|
|
t.Fatalf("expected output_tokens_details to exist on translated response.completed: %s", string(ch))
|
|
}
|
|
if gjson.GetBytes(payload, "response.usage.output_tokens_details.reasoning_tokens").Int() != 0 {
|
|
t.Fatalf("expected reasoning_tokens == 0, got %d", gjson.GetBytes(payload, "response.usage.output_tokens_details.reasoning_tokens").Int())
|
|
}
|
|
if !gjson.GetBytes(payload, "response.usage.input_tokens_details").Exists() {
|
|
t.Fatalf("expected input_tokens_details to exist on translated response.completed: %s", string(ch))
|
|
}
|
|
if gjson.GetBytes(payload, "response.usage.input_tokens_details.cached_tokens").Int() != 0 {
|
|
t.Fatalf("expected cached_tokens == 0, got %d", gjson.GetBytes(payload, "response.usage.input_tokens_details.cached_tokens").Int())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !foundCompleted {
|
|
t.Fatalf("did not find response.completed chunk in stream translation output")
|
|
}
|
|
}
|