test(claude): expand OpenAI conversion tests for reasoning_content and stream/non-stream parity

Closes: #5148
This commit is contained in:
Luis Pater
2026-08-22 13:20:00 +08:00
parent d9869ed908
commit 65071f7c47

View File

@@ -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, &param)
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, &param)
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.")
}
}