- 新增 domain 层:model、provider、route、stats 实体 - 新增 service 层:models、providers、routing、stats 业务逻辑 - 新增 repository 层:models、providers、stats 数据访问 - 新增 pkg 工具包:errors、logger、validator - 新增中间件:CORS、logging、recovery、request ID - 新增数据库迁移:初始 schema 和索引 - 新增单元测试和集成测试 - 新增规范文档:config-management、database-migration、error-handling、layered-architecture、middleware-system、request-validation、structured-logging、test-coverage - 移除 config 子包和 model_router(已迁移至分层架构)
230 lines
6.0 KiB
Go
230 lines
6.0 KiB
Go
package anthropic
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"nex/backend/internal/protocol/openai"
|
|
)
|
|
|
|
func TestStreamConverter_MessageStart(t *testing.T) {
|
|
converter := NewStreamConverter("msg_123", "claude-3-opus")
|
|
|
|
chunk := &openai.StreamChunk{
|
|
ID: "chatcmpl-123",
|
|
Choices: []openai.StreamChoice{{Index: 0, Delta: openai.Delta{}}},
|
|
}
|
|
|
|
events, err := converter.ConvertChunk(chunk)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, events)
|
|
|
|
// 第一个事件应该是 message_start
|
|
assert.Equal(t, "message_start", events[0].Type)
|
|
require.NotNil(t, events[0].Message)
|
|
assert.Equal(t, "msg_123", events[0].Message.ID)
|
|
assert.Equal(t, "message", events[0].Message.Type)
|
|
assert.Equal(t, "assistant", events[0].Message.Role)
|
|
assert.Equal(t, "claude-3-opus", events[0].Message.Model)
|
|
}
|
|
|
|
func TestStreamConverter_TextDelta(t *testing.T) {
|
|
converter := NewStreamConverter("msg_123", "claude-3-opus")
|
|
|
|
// 先发送一个空块以触发 message_start
|
|
chunk1 := &openai.StreamChunk{
|
|
Choices: []openai.StreamChoice{
|
|
{Delta: openai.Delta{Content: "Hello"}},
|
|
},
|
|
}
|
|
events1, err := converter.ConvertChunk(chunk1)
|
|
require.NoError(t, err)
|
|
// 应有 message_start + content_block_start + text delta
|
|
assert.GreaterOrEqual(t, len(events1), 3)
|
|
|
|
// 第二个文本块不应再发送 message_start 和 content_block_start
|
|
chunk2 := &openai.StreamChunk{
|
|
Choices: []openai.StreamChoice{
|
|
{Delta: openai.Delta{Content: " world"}},
|
|
},
|
|
}
|
|
events2, err := converter.ConvertChunk(chunk2)
|
|
require.NoError(t, err)
|
|
// 只有 text delta
|
|
assert.Len(t, events2, 1)
|
|
assert.Equal(t, "content_block_delta", events2[0].Type)
|
|
assert.Equal(t, "text_delta", events2[0].Delta.Type)
|
|
assert.Equal(t, " world", events2[0].Delta.Text)
|
|
}
|
|
|
|
func TestStreamConverter_FinishReason(t *testing.T) {
|
|
converter := NewStreamConverter("msg_123", "claude-3-opus")
|
|
|
|
chunk := &openai.StreamChunk{
|
|
Choices: []openai.StreamChoice{
|
|
{Delta: openai.Delta{Content: "Hello"}, FinishReason: "stop"},
|
|
},
|
|
}
|
|
events, err := converter.ConvertChunk(chunk)
|
|
require.NoError(t, err)
|
|
|
|
// 查找 message_delta 事件
|
|
var messageDelta *StreamEvent
|
|
for _, e := range events {
|
|
if e.Type == "message_delta" {
|
|
messageDelta = &e
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, messageDelta)
|
|
assert.Equal(t, "end_turn", messageDelta.Delta.StopReason)
|
|
|
|
// 查找 message_stop 事件
|
|
var messageStop *StreamEvent
|
|
for _, e := range events {
|
|
if e.Type == "message_stop" {
|
|
messageStop = &e
|
|
break
|
|
}
|
|
}
|
|
assert.NotNil(t, messageStop)
|
|
}
|
|
|
|
func TestStreamConverter_FinishReasonToolCalls(t *testing.T) {
|
|
converter := NewStreamConverter("msg_123", "claude-3-opus")
|
|
|
|
chunk := &openai.StreamChunk{
|
|
Choices: []openai.StreamChoice{
|
|
{Delta: openai.Delta{}, FinishReason: "tool_calls"},
|
|
},
|
|
}
|
|
events, err := converter.ConvertChunk(chunk)
|
|
require.NoError(t, err)
|
|
|
|
var messageDelta *StreamEvent
|
|
for _, e := range events {
|
|
if e.Type == "message_delta" {
|
|
messageDelta = &e
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, messageDelta)
|
|
assert.Equal(t, "tool_use", messageDelta.Delta.StopReason)
|
|
}
|
|
|
|
func TestStreamConverter_FinishReasonLength(t *testing.T) {
|
|
converter := NewStreamConverter("msg_123", "claude-3-opus")
|
|
|
|
chunk := &openai.StreamChunk{
|
|
Choices: []openai.StreamChoice{
|
|
{Delta: openai.Delta{}, FinishReason: "length"},
|
|
},
|
|
}
|
|
events, err := converter.ConvertChunk(chunk)
|
|
require.NoError(t, err)
|
|
|
|
var messageDelta *StreamEvent
|
|
for _, e := range events {
|
|
if e.Type == "message_delta" {
|
|
messageDelta = &e
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, messageDelta)
|
|
assert.Equal(t, "max_tokens", messageDelta.Delta.StopReason)
|
|
}
|
|
|
|
func TestStreamConverter_ToolCalls(t *testing.T) {
|
|
converter := NewStreamConverter("msg_123", "claude-3-opus")
|
|
|
|
chunk := &openai.StreamChunk{
|
|
Choices: []openai.StreamChoice{
|
|
{
|
|
Delta: openai.Delta{
|
|
ToolCalls: []openai.ToolCall{
|
|
{
|
|
ID: "call_123",
|
|
Type: "function",
|
|
Function: openai.FunctionCall{
|
|
Name: "get_weather",
|
|
Arguments: `{"city": "Beijing"}`,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
events, err := converter.ConvertChunk(chunk)
|
|
require.NoError(t, err)
|
|
|
|
// 应包含 content_block_start (tool_use) + content_block_delta (input_json_delta)
|
|
hasBlockStart := false
|
|
hasInputDelta := false
|
|
for _, e := range events {
|
|
if e.Type == "content_block_start" && e.ContentBlock != nil && e.ContentBlock.Type == "tool_use" {
|
|
hasBlockStart = true
|
|
assert.Equal(t, "call_123", e.ContentBlock.ID)
|
|
assert.Equal(t, "get_weather", e.ContentBlock.Name)
|
|
}
|
|
if e.Type == "content_block_delta" && e.Delta != nil && e.Delta.Type == "input_json_delta" {
|
|
hasInputDelta = true
|
|
assert.Equal(t, `{"city": "Beijing"}`, e.Delta.Input)
|
|
}
|
|
}
|
|
assert.True(t, hasBlockStart, "应有 tool_use content_block_start")
|
|
assert.True(t, hasInputDelta, "应有 input_json_delta")
|
|
}
|
|
|
|
func TestSerializeEvent(t *testing.T) {
|
|
event := StreamEvent{
|
|
Type: "message_start",
|
|
Message: &MessagesResponse{
|
|
ID: "msg_123",
|
|
Type: "message",
|
|
Role: "assistant",
|
|
},
|
|
}
|
|
|
|
result, err := SerializeEvent(event)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, result, "event: message_start")
|
|
assert.Contains(t, result, "data: ")
|
|
assert.Contains(t, result, "msg_123")
|
|
}
|
|
|
|
func TestSerializeEvent_InvalidJSON(t *testing.T) {
|
|
event := StreamEvent{
|
|
Type: "test",
|
|
}
|
|
// 这个应该能正常序列化
|
|
result, err := SerializeEvent(event)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, result, "event: test")
|
|
}
|
|
|
|
func TestContentBlock_ParseInputJSON(t *testing.T) {
|
|
t.Run("字符串输入", func(t *testing.T) {
|
|
cb := &ContentBlock{Input: `{"key": "value"}`}
|
|
result, err := cb.ParseInputJSON()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "value", result["key"])
|
|
})
|
|
|
|
t.Run("对象输入", func(t *testing.T) {
|
|
cb := &ContentBlock{Input: map[string]interface{}{"key": "value"}}
|
|
result, err := cb.ParseInputJSON()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "value", result["key"])
|
|
})
|
|
|
|
t.Run("无效类型", func(t *testing.T) {
|
|
cb := &ContentBlock{Input: 42}
|
|
_, err := cb.ParseInputJSON()
|
|
assert.Error(t, err)
|
|
})
|
|
}
|