From 40255b128e094aecd7359aa9e44c99390a542c68 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 27 Sep 2025 01:12:47 +0800 Subject: [PATCH] feat(translator): add usage metadata aggregation for Claude and OpenAI responses - Integrated input, output, reasoning, and total token tracking in response processing for Claude and OpenAI. - Ensured support for usage details even when specific fields are missing in the response. - Enhanced completion outputs with aggregated usage details for accurate reporting. --- .../claude_openai-responses_response.go | 48 +++++++++++++++- .../openai_openai-responses_response.go | 55 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index 8c169b66..ab88ab32 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -32,6 +32,10 @@ type claudeToResponsesState struct { ReasoningBuf strings.Builder ReasoningPartAdded bool ReasoningIndex int + // usage aggregation + InputTokens int64 + OutputTokens int64 + UsageSeen bool } var dataTag = []byte("data:") @@ -77,6 +81,19 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.FuncArgsBuf = make(map[int]*strings.Builder) st.FuncNames = make(map[int]string) st.FuncCallIDs = make(map[int]string) + st.InputTokens = 0 + st.OutputTokens = 0 + st.UsageSeen = false + if usage := msg.Get("usage"); usage.Exists() { + if v := usage.Get("input_tokens"); v.Exists() { + st.InputTokens = v.Int() + st.UsageSeen = true + } + if v := usage.Get("output_tokens"); v.Exists() { + st.OutputTokens = v.Int() + st.UsageSeen = true + } + } // response.created created := `{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"instructions":""}}` created, _ = sjson.Set(created, "sequence_number", nextSeq()) @@ -227,7 +244,6 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin out = append(out, emitEvent("response.output_item.done", itemDone)) st.InFuncBlock = false } else if st.ReasoningActive { - // close reasoning full := st.ReasoningBuf.String() textDone := `{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}` textDone, _ = sjson.Set(textDone, "sequence_number", nextSeq()) @@ -244,7 +260,19 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.ReasoningActive = false st.ReasoningPartAdded = false } + case "message_delta": + if usage := root.Get("usage"); usage.Exists() { + if v := usage.Get("output_tokens"); v.Exists() { + st.OutputTokens = v.Int() + st.UsageSeen = true + } + if v := usage.Get("input_tokens"); v.Exists() { + st.InputTokens = v.Int() + st.UsageSeen = true + } + } case "message_stop": + completed := `{"type":"response.completed","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null}}` completed, _ = sjson.Set(completed, "sequence_number", nextSeq()) completed, _ = sjson.Set(completed, "response.id", st.ResponseID) @@ -381,6 +409,24 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin if len(outputs) > 0 { completed, _ = sjson.Set(completed, "response.output", outputs) } + + reasoningTokens := int64(0) + if st.ReasoningBuf.Len() > 0 { + reasoningTokens = int64(st.ReasoningBuf.Len() / 4) + } + usagePresent := st.UsageSeen || reasoningTokens > 0 + if usagePresent { + completed, _ = sjson.Set(completed, "response.usage.input_tokens", st.InputTokens) + completed, _ = sjson.Set(completed, "response.usage.input_tokens_details.cached_tokens", 0) + completed, _ = sjson.Set(completed, "response.usage.output_tokens", st.OutputTokens) + if reasoningTokens > 0 { + completed, _ = sjson.Set(completed, "response.usage.output_tokens_details.reasoning_tokens", reasoningTokens) + } + total := st.InputTokens + st.OutputTokens + if total > 0 || st.UsageSeen { + completed, _ = sjson.Set(completed, "response.usage.total_tokens", total) + } + } out = append(out, emitEvent("response.completed", completed)) } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index e58e8bf6..c1dcb6ca 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -32,6 +32,13 @@ type oaiToResponsesState struct { // function item done state FuncArgsDone map[int]bool FuncItemDone map[int]bool + // usage aggregation + PromptTokens int64 + CachedTokens int64 + CompletionTokens int64 + TotalTokens int64 + ReasoningTokens int64 + UsageSeen bool } func emitRespEvent(event string, payload string) string { @@ -66,6 +73,35 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, return []string{} } + if usage := root.Get("usage"); usage.Exists() { + if v := usage.Get("prompt_tokens"); v.Exists() { + st.PromptTokens = v.Int() + st.UsageSeen = true + } + if v := usage.Get("prompt_tokens_details.cached_tokens"); v.Exists() { + st.CachedTokens = v.Int() + st.UsageSeen = true + } + if v := usage.Get("completion_tokens"); v.Exists() { + st.CompletionTokens = v.Int() + st.UsageSeen = true + } else if v := usage.Get("output_tokens"); v.Exists() { + st.CompletionTokens = v.Int() + st.UsageSeen = true + } + if v := usage.Get("output_tokens_details.reasoning_tokens"); v.Exists() { + st.ReasoningTokens = v.Int() + st.UsageSeen = true + } else if v := usage.Get("completion_tokens_details.reasoning_tokens"); v.Exists() { + st.ReasoningTokens = v.Int() + st.UsageSeen = true + } + if v := usage.Get("total_tokens"); v.Exists() { + st.TotalTokens = v.Int() + st.UsageSeen = true + } + } + nextSeq := func() int { st.Seq++; return st.Seq } var out []string @@ -85,6 +121,12 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.MsgItemDone = make(map[int]bool) st.FuncArgsDone = make(map[int]bool) st.FuncItemDone = make(map[int]bool) + st.PromptTokens = 0 + st.CachedTokens = 0 + st.CompletionTokens = 0 + st.TotalTokens = 0 + st.ReasoningTokens = 0 + st.UsageSeen = false // response.created created := `{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null}}` created, _ = sjson.Set(created, "sequence_number", nextSeq()) @@ -503,6 +545,19 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, if len(outputs) > 0 { completed, _ = sjson.Set(completed, "response.output", outputs) } + if st.UsageSeen { + completed, _ = sjson.Set(completed, "response.usage.input_tokens", st.PromptTokens) + completed, _ = sjson.Set(completed, "response.usage.input_tokens_details.cached_tokens", st.CachedTokens) + completed, _ = sjson.Set(completed, "response.usage.output_tokens", st.CompletionTokens) + if st.ReasoningTokens > 0 { + completed, _ = sjson.Set(completed, "response.usage.output_tokens_details.reasoning_tokens", st.ReasoningTokens) + } + total := st.TotalTokens + if total == 0 { + total = st.PromptTokens + st.CompletionTokens + } + completed, _ = sjson.Set(completed, "response.usage.total_tokens", total) + } out = append(out, emitRespEvent("response.completed", completed)) }