diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index 47f3f389..bdd0dfc6 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -336,7 +336,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI // Handle usage information separately (this comes in a later chunk) // Only process if usage has actual values (not null) - if param.FinishReason != "" { + if param.FinishReason != "" && !param.MessageDeltaSent { usage := root.Get("usage") var inputTokens, outputTokens, cachedTokens int64 if usage.Exists() && usage.Type != gjson.Null { diff --git a/internal/translator/openai/claude/openai_claude_response_test.go b/internal/translator/openai/claude/openai_claude_response_test.go index 35aa36f3..f5b063b2 100644 --- a/internal/translator/openai/claude/openai_claude_response_test.go +++ b/internal/translator/openai/claude/openai_claude_response_test.go @@ -103,6 +103,25 @@ func lastStopReason(events []sseEvent) string { const streamReq = `{"stream":true}` +func TestStreaming_LateUsageOnlyDoesNotEmitAfterMessageStop(t *testing.T) { + events := runStream(t, streamReq, + `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`, + `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}`, + `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`, + `{"id":"c1","model":"m","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1}}`, + ) + + if got := countByType(events, "message_delta"); got != 1 { + t.Fatalf("expected exactly one message_delta, got %d (events=%+v)", got, events) + } + if got := countByType(events, "message_stop"); got != 1 { + t.Fatalf("expected exactly one message_stop, got %d (events=%+v)", got, events) + } + if len(events) == 0 || events[len(events)-1].Type != "message_stop" { + t.Fatalf("message_stop must be the last semantic event (events=%+v)", events) + } +} + func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing.T) { originalRequest := []byte(streamReq) var param any