From e0b4956242e5a428a459593a44cbb88fdb08cfbf Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 15 Aug 2026 15:00:19 +0800 Subject: [PATCH] fix(openai): ensure Responses usage includes token detail fields - 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 --- internal/pluginhost/adapters_executors.go | 27 ++- .../runtime/executor/aistudio_executor.go | 3 + .../executor/antigravity_executor_execute.go | 6 + .../executor/claude_executor_execute.go | 3 + .../executor/claude_executor_stream.go | 5 + .../executor/codex_executor_execute.go | 6 + .../executor/codex_websockets_execute.go | 3 + .../executor/codex_websockets_stream.go | 3 +- internal/runtime/executor/gemini_executor.go | 9 +- .../executor/gemini_vertex_executor.go | 6 + .../executor/helps/claude_input_tokens.go | 5 + .../executor/helps/responses_usage_helpers.go | 108 +++++++++ .../helps/responses_usage_helpers_test.go | 210 ++++++++++++++++++ internal/runtime/executor/kimi_executor.go | 3 + .../executor/openai_compat_executor.go | 3 + .../runtime/executor/xai_executor_execute.go | 7 + .../runtime/executor/xai_executor_response.go | 2 + .../runtime/executor/xai_executor_test.go | 24 +- .../executor/xai_websockets_executor.go | 14 +- .../openai_responses_websocket_prewarm.go | 2 +- 20 files changed, 434 insertions(+), 15 deletions(-) create mode 100644 internal/runtime/executor/helps/responses_usage_helpers.go create mode 100644 internal/runtime/executor/helps/responses_usage_helpers_test.go diff --git a/internal/pluginhost/adapters_executors.go b/internal/pluginhost/adapters_executors.go index 38e9e383..a80f7f35 100644 --- a/internal/pluginhost/adapters_executors.go +++ b/internal/pluginhost/adapters_executors.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" @@ -518,7 +519,11 @@ func executorNativeStreamResponseTranslatorExists(from, to sdktranslator.Format) func (a *executorAdapter) translateExecutorResponse(ctx context.Context, prepared preparedExecutorCall, payload []byte, stream bool, param *any) []byte { if prepared.requestedFormat == "" || prepared.outputFormat == prepared.requestedFormat { - return bytes.Clone(payload) + out := bytes.Clone(payload) + if prepared.requestedFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } + return out } originalRequest := prepared.opts.OriginalRequest if len(originalRequest) == 0 { @@ -534,11 +539,15 @@ func (a *executorAdapter) translateExecutorResponse(ctx context.Context, prepare } return bytes.Join(frames, nil) } - return sdktranslator.TranslateNonStream(ctx, prepared.outputFormat, prepared.requestedFormat, prepared.req.Model, originalRequest, prepared.req.Payload, payload, param) + out := sdktranslator.TranslateNonStream(ctx, prepared.outputFormat, prepared.requestedFormat, prepared.req.Model, originalRequest, prepared.req.Payload, payload, param) + if prepared.requestedFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } + return out } func (a *executorAdapter) translateExecutorStreamChunks(ctx context.Context, prepared preparedExecutorCall, in <-chan pluginapi.ExecutorStreamChunk) <-chan pluginapi.ExecutorStreamChunk { - if prepared.requestedFormat == "" || prepared.outputFormat == prepared.requestedFormat { + if prepared.requestedFormat == "" || (prepared.outputFormat == prepared.requestedFormat && prepared.requestedFormat != sdktranslator.FormatOpenAIResponse) { return in } if in == nil { @@ -577,6 +586,13 @@ func (a *executorAdapter) translateExecutorStreamChunks(ctx context.Context, pre } func (a *executorAdapter) translateExecutorStreamPayload(ctx context.Context, prepared preparedExecutorCall, payload []byte, param *any) [][]byte { + if prepared.requestedFormat != "" && prepared.outputFormat == prepared.requestedFormat { + out := payload + if prepared.requestedFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } + return [][]byte{out} + } originalRequest := prepared.opts.OriginalRequest if len(originalRequest) == 0 { originalRequest = prepared.req.Payload @@ -585,6 +601,11 @@ func (a *executorAdapter) translateExecutorStreamPayload(ctx context.Context, pr if executorStreamTranslationFellBack(prepared, payload, frames) { return nil } + if prepared.requestedFormat == sdktranslator.FormatOpenAIResponse { + for i, frame := range frames { + frames[i] = helps.EnsureResponsesUsageDetails(frame) + } + } return frames } diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 47b8f43a..86db0e1d 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -187,6 +187,9 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) var param any out := sdktranslator.TranslateNonStream(ctx, body.toFormat, responseFormat, req.Model, opts.OriginalRequest, translatedReq, wsResp.Body, ¶m) + if responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } resp = cliproxyexecutor.Response{Payload: ensureColonSpacedJSON(out), Headers: wsResp.Headers.Clone()} return resp, nil } diff --git a/internal/runtime/executor/antigravity_executor_execute.go b/internal/runtime/executor/antigravity_executor_execute.go index 4fd9c6db..904a81f7 100644 --- a/internal/runtime/executor/antigravity_executor_execute.go +++ b/internal/runtime/executor/antigravity_executor_execute.go @@ -234,6 +234,9 @@ attemptLoop: reporter.Publish(ctx, helps.ParseAntigravityUsage(bodyBytes)) var param any converted := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, bodyBytes, ¶m) + if responseFormat == sdktranslator.FormatOpenAIResponse { + converted = helps.EnsureResponsesUsageDetails(converted) + } resp = cliproxyexecutor.Response{Payload: converted, Headers: httpResp.Header.Clone()} reporter.EnsurePublished(ctx) return resp, nil @@ -526,6 +529,9 @@ attemptLoop: reporter.Publish(ctx, helps.ParseAntigravityUsage(resp.Payload)) var param any converted := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, resp.Payload, ¶m) + if responseFormat == sdktranslator.FormatOpenAIResponse { + converted = helps.EnsureResponsesUsageDetails(converted) + } resp = cliproxyexecutor.Response{Payload: converted, Headers: httpResp.Header.Clone()} reporter.EnsurePublished(ctx) diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 5f8dacec..27c13e3e 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -325,6 +325,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r data, ¶m, ) + if responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} return resp, nil } diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 1e8136b7..fda85fc6 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -386,6 +386,11 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A bytes.Clone(line), ¶m, ) + if responseFormat == sdktranslator.FormatOpenAIResponse { + for i, chunk := range chunks { + chunks[i] = helps.EnsureResponsesUsageDetails(chunk) + } + } for i := range chunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: diff --git a/internal/runtime/executor/codex_executor_execute.go b/internal/runtime/executor/codex_executor_execute.go index d2a1ac2f..5d774908 100644 --- a/internal/runtime/executor/codex_executor_execute.go +++ b/internal/runtime/executor/codex_executor_execute.go @@ -178,6 +178,9 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re var param any clientCompletedData := applyCodexIdentityExposeResponsePayload(completedData, identityState) out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, originalPayload, body, clientCompletedData, ¶m) + if responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} return resp, nil } @@ -290,6 +293,9 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A var param any clientData := applyCodexIdentityExposeResponsePayload(upstreamData, identityState) out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, originalPayload, body, clientData, ¶m) + if responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} return resp, nil } diff --git a/internal/runtime/executor/codex_websockets_execute.go b/internal/runtime/executor/codex_websockets_execute.go index fb1fb719..136c5127 100644 --- a/internal/runtime/executor/codex_websockets_execute.go +++ b/internal/runtime/executor/codex_websockets_execute.go @@ -323,6 +323,9 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut var param any clientPayload := applyCodexIdentityExposeResponsePayload(payload, identityState) out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, originalPayload, clientBody, clientPayload, ¶m) + if responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } resp = cliproxyexecutor.Response{Payload: out} return resp, nil } diff --git a/internal/runtime/executor/codex_websockets_stream.go b/internal/runtime/executor/codex_websockets_stream.go index 1cad7676..1aec1c3f 100644 --- a/internal/runtime/executor/codex_websockets_stream.go +++ b/internal/runtime/executor/codex_websockets_stream.go @@ -400,7 +400,8 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr clientPayload := applyCodexIdentityExposeResponsePayload(payload, identityState) if cliproxyexecutor.DownstreamWebsocket(ctx) { - if !send(cliproxyexecutor.StreamChunk{Payload: clientPayload}) { + downstreamPayload := helps.EnsureResponsesUsageDetails(clientPayload) + if !send(cliproxyexecutor.StreamChunk{Payload: downstreamPayload}) { terminateReason = "context_done" terminateErr = ctx.Err() return diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 1c4a9851..4b878ba5 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -231,6 +231,9 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r reporter.Publish(ctx, helps.ParseGeminiUsage(data)) var param any out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, data, ¶m) + if responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} return resp, nil } @@ -453,8 +456,12 @@ func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxy return resp, err } reporter.Publish(ctx, helps.ParseInteractionsUsage(data)) + targetFormat := cliproxyexecutor.ResponseFormatOrSource(opts) var param any - out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatInteractions, cliproxyexecutor.ResponseFormatOrSource(opts), req.Model, opts.OriginalRequest, body, data, ¶m) + out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatInteractions, targetFormat, req.Model, opts.OriginalRequest, body, data, ¶m) + if targetFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil } diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index 0a00cede..395ddf19 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -433,6 +433,9 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au to := sdktranslator.FromString("gemini") var param any out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, data, ¶m) + if responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} return resp, nil } @@ -548,6 +551,9 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip reporter.Publish(ctx, helps.ParseGeminiUsage(data)) var param any out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, data, ¶m) + if responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} return resp, nil } diff --git a/internal/runtime/executor/helps/claude_input_tokens.go b/internal/runtime/executor/helps/claude_input_tokens.go index 702e8661..21475906 100644 --- a/internal/runtime/executor/helps/claude_input_tokens.go +++ b/internal/runtime/executor/helps/claude_input_tokens.go @@ -63,6 +63,11 @@ func TranslateStreamWithClaudeInputTokens( rawJSON, param, ) + if responseFormat == sdktranslator.FormatOpenAIResponse { + for i, chunk := range chunks { + chunks[i] = EnsureResponsesUsageDetails(chunk) + } + } if state == nil { return chunks } diff --git a/internal/runtime/executor/helps/responses_usage_helpers.go b/internal/runtime/executor/helps/responses_usage_helpers.go new file mode 100644 index 00000000..645a289f --- /dev/null +++ b/internal/runtime/executor/helps/responses_usage_helpers.go @@ -0,0 +1,108 @@ +package helps + +import ( + "bytes" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// EnsureResponsesUsageDetails ensures that Responses usage objects contain output_tokens_details +// (defaulting reasoning_tokens to 0) and input_tokens_details (defaulting cached_tokens to 0). +// It supports plain JSON payloads, single-line SSE data: lines, and multi-line SSE frames (e.g. event: ...\ndata: ...). +func EnsureResponsesUsageDetails(payload []byte) []byte { + if len(payload) == 0 { + return payload + } + + trimmed := bytes.TrimSpace(payload) + if len(trimmed) == 0 { + return payload + } + + // 1. JSON-first: If trimmed payload starts with '{', process as a plain JSON object. + if trimmed[0] == '{' { + if gjson.GetBytes(trimmed, "object").String() == "response.compaction" { + return payload + } + updated := trimmed + updated = ensureUsageDetailsAt(updated, "response.usage") + updated = ensureUsageDetailsAt(updated, "usage") + if bytes.Equal(updated, trimmed) { + return payload + } + return updated + } + + // 2. SSE frames: Scan lines for data: prefixed lines and patch their JSON payloads. + if bytes.Contains(payload, []byte("data:")) { + lines := bytes.Split(payload, []byte("\n")) + modified := false + for i, line := range lines { + trimmedLine := bytes.TrimSpace(line) + if !bytes.HasPrefix(trimmedLine, []byte("data:")) { + continue + } + prefixLen := len("data:") + if bytes.HasPrefix(line, []byte("data: ")) { + prefixLen = len("data: ") + } else if bytes.HasPrefix(line, []byte("data:")) { + prefixLen = len("data:") + } + dataPayload := bytes.TrimSpace(line[prefixLen:]) + if len(dataPayload) == 0 || dataPayload[0] != '{' { + continue + } + if gjson.GetBytes(dataPayload, "object").String() == "response.compaction" { + continue + } + updated := dataPayload + updated = ensureUsageDetailsAt(updated, "response.usage") + updated = ensureUsageDetailsAt(updated, "usage") + if !bytes.Equal(updated, dataPayload) { + newPrefix := bytes.Clone(line[:prefixLen]) + lines[i] = append(newPrefix, updated...) + modified = true + } + } + if modified { + return bytes.Join(lines, []byte("\n")) + } + return payload + } + + return payload +} + +func ensureUsageDetailsAt(jsonBody []byte, path string) []byte { + usageNode := gjson.GetBytes(jsonBody, path) + if !usageNode.Exists() || !usageNode.IsObject() { + return jsonBody + } + + outputDetails := usageNode.Get("output_tokens_details") + if !outputDetails.Exists() { + jsonBody, _ = sjson.SetBytes(jsonBody, path+".output_tokens_details.reasoning_tokens", 0) + } else if outputDetails.Type == gjson.Null || !outputDetails.IsObject() { + jsonBody, _ = sjson.SetRawBytes(jsonBody, path+".output_tokens_details", []byte(`{"reasoning_tokens":0}`)) + } else { + reasoning := outputDetails.Get("reasoning_tokens") + if !reasoning.Exists() || reasoning.Type == gjson.Null { + jsonBody, _ = sjson.SetBytes(jsonBody, path+".output_tokens_details.reasoning_tokens", 0) + } + } + + inputDetails := usageNode.Get("input_tokens_details") + if !inputDetails.Exists() { + jsonBody, _ = sjson.SetBytes(jsonBody, path+".input_tokens_details.cached_tokens", 0) + } else if inputDetails.Type == gjson.Null || !inputDetails.IsObject() { + jsonBody, _ = sjson.SetRawBytes(jsonBody, path+".input_tokens_details", []byte(`{"cached_tokens":0}`)) + } else { + cached := inputDetails.Get("cached_tokens") + if !cached.Exists() || cached.Type == gjson.Null { + jsonBody, _ = sjson.SetBytes(jsonBody, path+".input_tokens_details.cached_tokens", 0) + } + } + + return jsonBody +} diff --git a/internal/runtime/executor/helps/responses_usage_helpers_test.go b/internal/runtime/executor/helps/responses_usage_helpers_test.go new file mode 100644 index 00000000..80d5e8a5 --- /dev/null +++ b/internal/runtime/executor/helps/responses_usage_helpers_test.go @@ -0,0 +1,210 @@ +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") + } +} diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index de8e5130..e4424a70 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -206,6 +206,9 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req // Note: TranslateNonStream uses req.Model (original with suffix) to preserve // the original model name in the response for client compatibility. out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, data, ¶m) + if responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} return resp, nil } diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 57eae144..9429568d 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -208,6 +208,9 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A // Translate response back to source format when needed var param any out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, body, ¶m) + if responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} return resp, nil } diff --git a/internal/runtime/executor/xai_executor_execute.go b/internal/runtime/executor/xai_executor_execute.go index 6c434bf1..a7b59c8c 100644 --- a/internal/runtime/executor/xai_executor_execute.go +++ b/internal/runtime/executor/xai_executor_execute.go @@ -106,6 +106,9 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cacheXAIReasoningReplayFromCompleted(ctx, prepared.replayScope, completedData) var param any out := sdktranslator.TranslateNonStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, completedData, ¶m) + if prepared.responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil } } @@ -121,6 +124,9 @@ func (e *XAIExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.Aut var param any out := sdktranslator.TranslateNonStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, data, ¶m) + if prepared.responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } return cliproxyexecutor.Response{Payload: out, Headers: headers}, nil } @@ -303,6 +309,7 @@ func xaiBuildCompactionTriggerStreamChunks(prepared *xaiPreparedRequest, compact donePayload, _ = sjson.SetRawBytes(donePayload, "item", item) completedPayload := []byte(`{"type":"response.completed","sequence_number":5}`) completedPayload, _ = sjson.SetRawBytes(completedPayload, "response", completedResponse) + completedPayload = helps.EnsureResponsesUsageDetails(completedPayload) return [][]byte{ xaiBuildSSEFrame("response.created", createdPayload), diff --git a/internal/runtime/executor/xai_executor_response.go b/internal/runtime/executor/xai_executor_response.go index 7923cd59..a6e95858 100644 --- a/internal/runtime/executor/xai_executor_response.go +++ b/internal/runtime/executor/xai_executor_response.go @@ -10,6 +10,7 @@ import ( "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" log "github.com/sirupsen/logrus" @@ -812,6 +813,7 @@ func xaiCollectOutputItemDone(eventData []byte, outputItemsByIndex map[int64][]b } func xaiPatchCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte { + eventData = helps.EnsureResponsesUsageDetails(eventData) outputResult := gjson.GetBytes(eventData, "response.output") shouldPatchOutput := (!outputResult.Exists() || !outputResult.IsArray() || len(outputResult.Array()) == 0) && (len(outputItemsByIndex) > 0 || len(outputItemsFallback) > 0) if !shouldPatchOutput { diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index 74548bd8..0c69d75d 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -2202,8 +2202,8 @@ func TestXAIExecutorExecuteStreamCompactionTriggerUsesCompactEndpoint(t *testing if !strings.Contains(output, `"type":"compaction"`) || !strings.Contains(output, `"encrypted_content":"opaque"`) { t.Fatalf("compaction output missing from stream: %s", output) } - if !strings.Contains(output, `"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}`) { - t.Fatalf("usage missing from completed stream: %s", output) + if !strings.Contains(output, `"output_tokens_details":{"reasoning_tokens":0}`) || !strings.Contains(output, `"input_tokens_details":{"cached_tokens":0}`) { + t.Fatalf("usage details missing from completed stream: %s", output) } } @@ -5042,3 +5042,23 @@ func testValidGrokEncryptedContent() string { } return base64.RawStdEncoding.EncodeToString(buf[:256]) } + +func TestXAIPatchCompletedOutput_EnsuresUsageDetails(t *testing.T) { + eventData := []byte(`{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14}}}`) + outputItemsByIndex := make(map[int64][]byte) + var outputItemsFallback [][]byte + + got := xaiPatchCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback) + if !gjson.GetBytes(got, "response.usage.output_tokens_details").Exists() { + t.Fatalf("expected output_tokens_details to exist, got %s", string(got)) + } + if gjson.GetBytes(got, "response.usage.output_tokens_details.reasoning_tokens").Int() != 0 { + t.Fatalf("expected reasoning_tokens == 0, got %d", gjson.GetBytes(got, "response.usage.output_tokens_details.reasoning_tokens").Int()) + } + if !gjson.GetBytes(got, "response.usage.input_tokens_details").Exists() { + t.Fatalf("expected input_tokens_details to exist, got %s", string(got)) + } + if gjson.GetBytes(got, "response.usage.input_tokens_details.cached_tokens").Int() != 0 { + t.Fatalf("expected cached_tokens == 0, got %d", gjson.GetBytes(got, "response.usage.input_tokens_details.cached_tokens").Int()) + } +} diff --git a/internal/runtime/executor/xai_websockets_executor.go b/internal/runtime/executor/xai_websockets_executor.go index 841bfe0e..f0d39b04 100644 --- a/internal/runtime/executor/xai_websockets_executor.go +++ b/internal/runtime/executor/xai_websockets_executor.go @@ -803,12 +803,12 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox } if cliproxyexecutor.DownstreamWebsocket(ctx) { - downstreamPayload := payload - downstreamWarmupCompletedPayload := warmupCompletedPayload + downstreamPayload := helps.EnsureResponsesUsageDetails(payload) + downstreamWarmupCompletedPayload := helps.EnsureResponsesUsageDetails(warmupCompletedPayload) if idMapper != nil { - downstreamPayload = idMapper.downstreamResponsePayload(payload) + downstreamPayload = idMapper.downstreamResponsePayload(downstreamPayload) if len(warmupCompletedPayload) > 0 { - downstreamWarmupCompletedPayload = idMapper.downstreamResponsePayload(warmupCompletedPayload) + downstreamWarmupCompletedPayload = idMapper.downstreamResponsePayload(downstreamWarmupCompletedPayload) } } if !send(cliproxyexecutor.StreamChunk{Payload: downstreamPayload}) { @@ -960,7 +960,7 @@ func xaiWebsocketGenerateFalse(payload []byte) bool { } func buildXAIWebsocketWarmupCompletedPayload(createdPayload []byte) []byte { - completed := []byte(`{"type":"response.completed","response":{"output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`) + completed := []byte(`{"type":"response.completed","response":{"output":[],"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}}}`) if sequence := gjson.GetBytes(createdPayload, "sequence_number"); sequence.Exists() { completed, _ = sjson.SetBytes(completed, "sequence_number", sequence.Int()+1) } @@ -971,11 +971,11 @@ func buildXAIWebsocketWarmupCompletedPayload(createdPayload []byte) []byte { responsePayload, _ = sjson.SetRawBytes(responsePayload, "output", []byte("[]")) } if !gjson.GetBytes(responsePayload, "usage").Exists() { - responsePayload, _ = sjson.SetRawBytes(responsePayload, "usage", []byte(`{"input_tokens":0,"output_tokens":0,"total_tokens":0}`)) + responsePayload, _ = sjson.SetRawBytes(responsePayload, "usage", []byte(`{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}`)) } completed, _ = sjson.SetRawBytes(completed, "response", responsePayload) } - return completed + return helps.EnsureResponsesUsageDetails(completed) } func parseXAIWebsocketError(payload []byte) (error, bool) { diff --git a/sdk/api/handlers/openai/openai_responses_websocket_prewarm.go b/sdk/api/handlers/openai/openai_responses_websocket_prewarm.go index adb78221..e9870aaa 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_prewarm.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_prewarm.go @@ -78,7 +78,7 @@ func syntheticResponsesWebsocketPrewarmPayloads(requestJSON []byte) ([][]byte, e } - completedPayload := []byte(`{"type":"response.completed","sequence_number":1,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`) + completedPayload := []byte(`{"type":"response.completed","sequence_number":1,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"output":[],"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}}}`) completedPayload, errSet = sjson.SetBytes(completedPayload, "response.id", responseID) if errSet != nil { return nil, errSet -- 2.54.0