diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go index 3844503ba..d6aa357eb 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go @@ -210,22 +210,173 @@ func TestConvertClaudeResponseToOpenAINonStream_RefusalStopReason(t *testing.T) func TestConvertClaudeResponseToOpenAINonStream_ReasoningContent(t *testing.T) { rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\"}}\n" + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n" + - "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Let me think... \"}}\n" + - "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Done thinking.\"}}\n" + + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Let me analyze the problem.\"}}\n" + + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Step 2 is clear.\"}}\n" + "data: {\"type\":\"content_block_stop\",\"index\":0}\n" + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n" + - "data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello world\"}}\n" + + "data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Here is the solution.\"}}\n" + "data: {\"type\":\"content_block_stop\",\"index\":1}\n" + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":10,\"output_tokens\":20}}\n") out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil) - gotReasoningContent := gjson.GetBytes(out, "choices.0.message.reasoning_content").String() - wantReasoningContent := "Let me think... Done thinking." - if gotReasoningContent != wantReasoningContent { - t.Fatalf("expected reasoning_content %q, got %q, payload=%s", wantReasoningContent, gotReasoningContent, string(out)) + gotRC := gjson.GetBytes(out, "choices.0.message.reasoning_content") + if !gotRC.Exists() { + t.Fatalf("expected choices.0.message.reasoning_content to exist, payload=%s", string(out)) } - if gotLegacyReasoning := gjson.GetBytes(out, "choices.0.message.reasoning"); gotLegacyReasoning.Exists() { - t.Fatalf("expected reasoning to not exist, got %q, payload=%s", gotLegacyReasoning.String(), string(out)) + wantRC := "Let me analyze the problem. Step 2 is clear." + if gotRC.String() != wantRC { + t.Fatalf("reasoning_content = %q, want %q", gotRC.String(), wantRC) + } + + if gotOldReasoning := gjson.GetBytes(out, "choices.0.message.reasoning"); gotOldReasoning.Exists() { + t.Fatalf("choices.0.message.reasoning should not exist, got %q", gotOldReasoning.String()) + } + + gotContent := gjson.GetBytes(out, "choices.0.message.content").String() + wantContent := "Here is the solution." + if gotContent != wantContent { + t.Fatalf("content = %q, want %q", gotContent, wantContent) + } +} + +func TestConvertClaudeResponseToOpenAINonStream_OmitsReasoningContentWhenAbsent(t *testing.T) { + rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\"}}\n" + + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n" + + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Just plain text.\"}}\n" + + "data: {\"type\":\"content_block_stop\",\"index\":0}\n" + + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":10,\"output_tokens\":20}}\n") + + out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil) + + if gotRC := gjson.GetBytes(out, "choices.0.message.reasoning_content"); gotRC.Exists() { + t.Fatalf("choices.0.message.reasoning_content should be omitted when absent, got %q", gotRC.String()) + } + if gotReasoning := gjson.GetBytes(out, "choices.0.message.reasoning"); gotReasoning.Exists() { + t.Fatalf("choices.0.message.reasoning should not exist, got %q", gotReasoning.String()) + } + if gotContent := gjson.GetBytes(out, "choices.0.message.content").String(); gotContent != "Just plain text." { + t.Fatalf("content = %q, want %q", gotContent, "Just plain text.") + } +} + +func TestConvertClaudeResponseToOpenAI_StreamAndNonStreamParity(t *testing.T) { + events := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-opus-4-6","usage":{"input_tokens":15,"output_tokens":1}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"First thought. "}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Second thought."}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`), + []byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Final "}}`), + []byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"answer."}}`), + []byte(`data: {"type":"content_block_stop","index":1}`), + []byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":25}}`), + []byte(`data: {"type":"message_stop"}`), + } + + // 1. Process via streaming + ctx := context.Background() + var param any + var streamReasoning string + var streamContent string + var streamFinishReason string + + for _, ev := range events { + chunks := ConvertClaudeResponseToOpenAI(ctx, "claude-opus-4-6", nil, nil, ev, ¶m) + for _, chunk := range chunks { + if rc := gjson.GetBytes(chunk, "choices.0.delta.reasoning_content"); rc.Exists() { + streamReasoning += rc.String() + } + if c := gjson.GetBytes(chunk, "choices.0.delta.content"); c.Exists() { + streamContent += c.String() + } + if fr := gjson.GetBytes(chunk, "choices.0.finish_reason"); fr.Exists() && fr.String() != "" { + streamFinishReason = fr.String() + } + } + } + + // 2. Process via non-stream + var rawBuffer []byte + for _, ev := range events { + rawBuffer = append(rawBuffer, ev...) + rawBuffer = append(rawBuffer, '\n') + } + + nonStreamOut := ConvertClaudeResponseToOpenAINonStream(ctx, "", nil, nil, rawBuffer, nil) + nonStreamRC := gjson.GetBytes(nonStreamOut, "choices.0.message.reasoning_content").String() + nonStreamContent := gjson.GetBytes(nonStreamOut, "choices.0.message.content").String() + nonStreamFinishReason := gjson.GetBytes(nonStreamOut, "choices.0.finish_reason").String() + + if streamReasoning != "First thought. Second thought." { + t.Fatalf("streamReasoning = %q, want %q", streamReasoning, "First thought. Second thought.") + } + if nonStreamRC != streamReasoning { + t.Fatalf("parity mismatch for reasoning_content: nonStream=%q, stream=%q", nonStreamRC, streamReasoning) + } + if streamContent != "Final answer." { + t.Fatalf("streamContent = %q, want %q", streamContent, "Final answer.") + } + if nonStreamContent != streamContent { + t.Fatalf("parity mismatch for content: nonStream=%q, stream=%q", nonStreamContent, streamContent) + } + if streamFinishReason != "stop" { + t.Fatalf("streamFinishReason = %q, want %q", streamFinishReason, "stop") + } + if nonStreamFinishReason != streamFinishReason { + t.Fatalf("parity mismatch for finish_reason: nonStream=%q, stream=%q", nonStreamFinishReason, streamFinishReason) + } +} + +func TestConvertClaudeResponseToOpenAI_RedactedThinkingIgnored(t *testing.T) { + events := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-opus-4-6"}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"encrypted_blob"}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`), + []byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Visible reply."}}`), + []byte(`data: {"type":"content_block_stop","index":1}`), + []byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":20}}`), + } + + // Non-stream check + var rawJSON []byte + for _, ev := range events { + rawJSON = append(rawJSON, ev...) + rawJSON = append(rawJSON, '\n') + } + + outNonStream := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil) + if gotRC := gjson.GetBytes(outNonStream, "choices.0.message.reasoning_content"); gotRC.Exists() { + t.Fatalf("redacted_thinking must never map to reasoning_content in non-stream, got %q", gotRC.String()) + } + if gotReasoning := gjson.GetBytes(outNonStream, "choices.0.message.reasoning"); gotReasoning.Exists() { + t.Fatalf("redacted_thinking must not produce reasoning field in non-stream, got %q", gotReasoning.String()) + } + if gotContent := gjson.GetBytes(outNonStream, "choices.0.message.content").String(); gotContent != "Visible reply." { + t.Fatalf("content = %q, want %q", gotContent, "Visible reply.") + } + + // Stream check + ctx := context.Background() + var param any + var streamContent string + for _, line := range events { + chunks := ConvertClaudeResponseToOpenAI(ctx, "claude-opus-4-6", nil, nil, line, ¶m) + for _, chunk := range chunks { + if gotRC := gjson.GetBytes(chunk, "choices.0.delta.reasoning_content"); gotRC.Exists() { + t.Fatalf("redacted_thinking must never map to reasoning_content in stream, got %q", gotRC.String()) + } + if gotReasoning := gjson.GetBytes(chunk, "choices.0.delta.reasoning"); gotReasoning.Exists() { + t.Fatalf("redacted_thinking must not produce delta.reasoning field in stream, got %q", gotReasoning.String()) + } + if c := gjson.GetBytes(chunk, "choices.0.delta.content"); c.Exists() { + streamContent += c.String() + } + } + } + if streamContent != "Visible reply." { + t.Fatalf("stream content = %q, want %q", streamContent, "Visible reply.") } }