refactor: 实现 ConversionEngine 协议转换引擎,替代旧 protocol 包
引入 Canonical Model 和 ProtocolAdapter 架构,支持 OpenAI/Anthropic 协议间 无缝转换,统一 ProxyHandler 替代分散的 OpenAI/Anthropic Handler,简化 ProviderClient 为协议无关的 HTTP 发送器,Provider 新增 protocol 字段。
This commit is contained in:
274
backend/internal/conversion/anthropic/stream_decoder_test.go
Normal file
274
backend/internal/conversion/anthropic/stream_decoder_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"nex/backend/internal/conversion/canonical"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func makeAnthropicEvent(eventType string, data any) []byte {
|
||||
dataBytes, _ := json.Marshal(data)
|
||||
return []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", eventType, string(dataBytes)))
|
||||
}
|
||||
|
||||
func TestStreamDecoder_MessageStart(t *testing.T) {
|
||||
d := NewStreamDecoder()
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": "msg_1",
|
||||
"model": "claude-3",
|
||||
"usage": map[string]any{"input_tokens": 10, "output_tokens": 0},
|
||||
},
|
||||
}
|
||||
raw := makeAnthropicEvent("message_start", payload)
|
||||
|
||||
events := d.ProcessChunk(raw)
|
||||
require.NotEmpty(t, events)
|
||||
assert.Equal(t, canonical.EventMessageStart, events[0].Type)
|
||||
assert.Equal(t, "msg_1", events[0].Message.ID)
|
||||
assert.Equal(t, "claude-3", events[0].Message.Model)
|
||||
}
|
||||
|
||||
func TestStreamDecoder_ContentBlockDelta(t *testing.T) {
|
||||
d := NewStreamDecoder()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
deltaType string
|
||||
deltaData map[string]any
|
||||
checkField string
|
||||
checkValue string
|
||||
}{
|
||||
{
|
||||
name: "text_delta",
|
||||
deltaType: "text_delta",
|
||||
deltaData: map[string]any{"type": "text_delta", "text": "你好"},
|
||||
checkField: "text",
|
||||
checkValue: "你好",
|
||||
},
|
||||
{
|
||||
name: "input_json_delta",
|
||||
deltaType: "input_json_delta",
|
||||
deltaData: map[string]any{"type": "input_json_delta", "partial_json": "{\"key\":"},
|
||||
checkField: "partial_json",
|
||||
checkValue: "{\"key\":",
|
||||
},
|
||||
{
|
||||
name: "thinking_delta",
|
||||
deltaType: "thinking_delta",
|
||||
deltaData: map[string]any{"type": "thinking_delta", "thinking": "思考中"},
|
||||
checkField: "thinking",
|
||||
checkValue: "思考中",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": tt.deltaData,
|
||||
}
|
||||
raw := makeAnthropicEvent("content_block_delta", payload)
|
||||
|
||||
events := d.ProcessChunk(raw)
|
||||
require.NotEmpty(t, events)
|
||||
assert.Equal(t, canonical.EventContentBlockDelta, events[0].Type)
|
||||
assert.NotNil(t, events[0].Delta)
|
||||
|
||||
switch tt.checkField {
|
||||
case "text":
|
||||
assert.Equal(t, tt.checkValue, events[0].Delta.Text)
|
||||
case "partial_json":
|
||||
assert.Equal(t, tt.checkValue, events[0].Delta.PartialJSON)
|
||||
case "thinking":
|
||||
assert.Equal(t, tt.checkValue, events[0].Delta.Thinking)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamDecoder_RedactedThinking(t *testing.T) {
|
||||
d := NewStreamDecoder()
|
||||
|
||||
// redacted_thinking block start 应被抑制
|
||||
payload := map[string]any{
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": map[string]any{
|
||||
"type": "redacted_thinking",
|
||||
"data": "redacted-data",
|
||||
},
|
||||
}
|
||||
raw := makeAnthropicEvent("content_block_start", payload)
|
||||
events := d.ProcessChunk(raw)
|
||||
assert.Empty(t, events)
|
||||
assert.True(t, d.redactedBlocks[1])
|
||||
}
|
||||
|
||||
func TestStreamDecoder_RedactedBlockStopSuppressed(t *testing.T) {
|
||||
d := NewStreamDecoder()
|
||||
d.redactedBlocks[2] = true
|
||||
|
||||
// content_block_stop 对 redacted block 返回 nil
|
||||
payload := map[string]any{
|
||||
"type": "content_block_stop",
|
||||
"index": 2,
|
||||
}
|
||||
raw := makeAnthropicEvent("content_block_stop", payload)
|
||||
|
||||
events := d.ProcessChunk(raw)
|
||||
assert.Empty(t, events)
|
||||
// 应清理 redactedBlocks
|
||||
_, exists := d.redactedBlocks[2]
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestStreamDecoder_ContentBlockStart(t *testing.T) {
|
||||
d := NewStreamDecoder()
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": map[string]any{
|
||||
"type": "text",
|
||||
"text": "",
|
||||
},
|
||||
}
|
||||
raw := makeAnthropicEvent("content_block_start", payload)
|
||||
|
||||
events := d.ProcessChunk(raw)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, canonical.EventContentBlockStart, events[0].Type)
|
||||
require.NotNil(t, events[0].ContentBlock)
|
||||
assert.Equal(t, "text", events[0].ContentBlock.Type)
|
||||
require.NotNil(t, events[0].Index)
|
||||
assert.Equal(t, 0, *events[0].Index)
|
||||
}
|
||||
|
||||
func TestStreamDecoder_ContentBlockStart_ToolUse(t *testing.T) {
|
||||
d := NewStreamDecoder()
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_1",
|
||||
"name": "search",
|
||||
},
|
||||
}
|
||||
raw := makeAnthropicEvent("content_block_start", payload)
|
||||
|
||||
events := d.ProcessChunk(raw)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, canonical.EventContentBlockStart, events[0].Type)
|
||||
require.NotNil(t, events[0].ContentBlock)
|
||||
assert.Equal(t, "tool_use", events[0].ContentBlock.Type)
|
||||
assert.Equal(t, "toolu_1", events[0].ContentBlock.ID)
|
||||
assert.Equal(t, "search", events[0].ContentBlock.Name)
|
||||
}
|
||||
|
||||
func TestStreamDecoder_ContentBlockStop(t *testing.T) {
|
||||
d := NewStreamDecoder()
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "content_block_stop",
|
||||
"index": 0,
|
||||
}
|
||||
raw := makeAnthropicEvent("content_block_stop", payload)
|
||||
|
||||
events := d.ProcessChunk(raw)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, canonical.EventContentBlockStop, events[0].Type)
|
||||
require.NotNil(t, events[0].Index)
|
||||
assert.Equal(t, 0, *events[0].Index)
|
||||
}
|
||||
|
||||
func TestStreamDecoder_MessageDelta(t *testing.T) {
|
||||
d := NewStreamDecoder()
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]any{
|
||||
"stop_reason": "end_turn",
|
||||
},
|
||||
"usage": map[string]any{
|
||||
"output_tokens": 42,
|
||||
},
|
||||
}
|
||||
raw := makeAnthropicEvent("message_delta", payload)
|
||||
|
||||
events := d.ProcessChunk(raw)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, canonical.EventMessageDelta, events[0].Type)
|
||||
require.NotNil(t, events[0].StopReason)
|
||||
assert.Equal(t, canonical.StopReasonEndTurn, *events[0].StopReason)
|
||||
require.NotNil(t, events[0].Usage)
|
||||
assert.Equal(t, 42, events[0].Usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestStreamDecoder_MessageStop(t *testing.T) {
|
||||
d := NewStreamDecoder()
|
||||
|
||||
raw := makeAnthropicEvent("message_stop", map[string]any{"type": "message_stop"})
|
||||
|
||||
events := d.ProcessChunk(raw)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, canonical.EventMessageStop, events[0].Type)
|
||||
}
|
||||
|
||||
func TestStreamDecoder_Ping(t *testing.T) {
|
||||
d := NewStreamDecoder()
|
||||
|
||||
raw := makeAnthropicEvent("ping", map[string]any{"type": "ping"})
|
||||
|
||||
events := d.ProcessChunk(raw)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, canonical.EventPing, events[0].Type)
|
||||
}
|
||||
|
||||
func TestStreamDecoder_Error(t *testing.T) {
|
||||
d := NewStreamDecoder()
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "error",
|
||||
"error": map[string]any{
|
||||
"type": "overloaded_error",
|
||||
"message": "服务过载",
|
||||
},
|
||||
}
|
||||
raw := makeAnthropicEvent("error", payload)
|
||||
|
||||
events := d.ProcessChunk(raw)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, canonical.EventError, events[0].Type)
|
||||
require.NotNil(t, events[0].Error)
|
||||
assert.Equal(t, "overloaded_error", events[0].Error.Type)
|
||||
assert.Equal(t, "服务过载", events[0].Error.Message)
|
||||
}
|
||||
|
||||
func TestStreamDecoder_RedactedDeltaSuppressed(t *testing.T) {
|
||||
d := NewStreamDecoder()
|
||||
d.redactedBlocks[1] = true
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "content_block_delta",
|
||||
"index": 1,
|
||||
"delta": map[string]any{
|
||||
"type": "text_delta",
|
||||
"text": "被抑制的内容",
|
||||
},
|
||||
}
|
||||
raw := makeAnthropicEvent("content_block_delta", payload)
|
||||
|
||||
events := d.ProcessChunk(raw)
|
||||
assert.Empty(t, events)
|
||||
}
|
||||
Reference in New Issue
Block a user