- 新增 ConversionEngine 核心引擎,支持 OpenAI 和 Anthropic 协议转换 - 添加 stream decoder/encoder 实现 - 更新 provider client 支持新引擎 - 补充单元测试和集成测试 - 更新 specs 文档
286 lines
7.7 KiB
Go
286 lines
7.7 KiB
Go
package openai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"nex/backend/internal/conversion/canonical"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestStreamEncoder_MessageStart(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
event := canonical.NewMessageStartEvent("chatcmpl-1", "gpt-4")
|
|
|
|
chunks := e.EncodeEvent(event)
|
|
require.Len(t, chunks, 1)
|
|
|
|
s := string(chunks[0])
|
|
assert.True(t, strings.HasPrefix(s, "data: "))
|
|
assert.Contains(t, s, "chatcmpl-1")
|
|
assert.Contains(t, s, "chat.completion.chunk")
|
|
|
|
var payload map[string]any
|
|
data := strings.TrimPrefix(s, "data: ")
|
|
data = strings.TrimRight(data, "\n")
|
|
require.NoError(t, json.Unmarshal([]byte(data), &payload))
|
|
choices := payload["choices"].([]any)
|
|
delta := choices[0].(map[string]any)["delta"].(map[string]any)
|
|
assert.Equal(t, "assistant", delta["role"])
|
|
}
|
|
|
|
func TestStreamEncoder_TextDelta(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
event := canonical.NewContentBlockDeltaEvent(0, canonical.StreamDelta{Type: "text_delta", Text: "你好"})
|
|
|
|
chunks := e.EncodeEvent(event)
|
|
require.Len(t, chunks, 1)
|
|
|
|
s := string(chunks[0])
|
|
assert.Contains(t, s, "你好")
|
|
}
|
|
|
|
func TestStreamEncoder_MessageStop(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
event := canonical.NewMessageStopEvent()
|
|
|
|
chunks := e.EncodeEvent(event)
|
|
require.Len(t, chunks, 1)
|
|
assert.Equal(t, "data: [DONE]\n\n", string(chunks[0]))
|
|
}
|
|
|
|
func TestStreamEncoder_Buffering(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
|
|
// ContentBlockStart 应被缓冲,不输出
|
|
startEvent := canonical.NewContentBlockStartEvent(0, canonical.StreamContentBlock{Type: "text", Text: ""})
|
|
chunks := e.EncodeEvent(startEvent)
|
|
assert.Nil(t, chunks)
|
|
assert.NotNil(t, e.bufferedStart)
|
|
|
|
// 第一个 delta 触发输出(清空缓冲)
|
|
deltaEvent := canonical.NewContentBlockDeltaEvent(0, canonical.StreamDelta{Type: "text_delta", Text: "hello"})
|
|
chunks = e.EncodeEvent(deltaEvent)
|
|
require.NotEmpty(t, chunks)
|
|
assert.Nil(t, e.bufferedStart)
|
|
}
|
|
|
|
func TestStreamEncoder_ContentBlockStop_ReturnsNil(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
idx := 0
|
|
event := canonical.CanonicalStreamEvent{
|
|
Type: canonical.EventContentBlockStop,
|
|
Index: &idx,
|
|
}
|
|
chunks := e.EncodeEvent(event)
|
|
assert.Nil(t, chunks)
|
|
}
|
|
|
|
func TestStreamEncoder_Ping_ReturnsNil(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
event := canonical.NewPingEvent()
|
|
chunks := e.EncodeEvent(event)
|
|
assert.Nil(t, chunks)
|
|
}
|
|
|
|
func TestStreamEncoder_Error_ReturnsNil(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
event := canonical.NewErrorEvent("test_error", "测试错误")
|
|
chunks := e.EncodeEvent(event)
|
|
assert.Nil(t, chunks)
|
|
}
|
|
|
|
func TestStreamEncoder_Flush_ReturnsNil(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
chunks := e.Flush()
|
|
assert.Nil(t, chunks)
|
|
}
|
|
|
|
func TestStreamEncoder_ThinkingDelta(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
event := canonical.NewContentBlockDeltaEvent(0, canonical.StreamDelta{
|
|
Type: string(canonical.DeltaTypeThinking),
|
|
Thinking: "思考内容",
|
|
})
|
|
|
|
chunks := e.EncodeEvent(event)
|
|
require.Len(t, chunks, 1)
|
|
|
|
s := string(chunks[0])
|
|
assert.Contains(t, s, "reasoning_content")
|
|
assert.Contains(t, s, "思考内容")
|
|
}
|
|
|
|
func TestStreamEncoder_InputJSONDelta(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
|
|
e.EncodeEvent(canonical.NewContentBlockStartEvent(0, canonical.StreamContentBlock{
|
|
Type: "tool_use",
|
|
ID: "call_1",
|
|
Name: "get_weather",
|
|
}))
|
|
|
|
event := canonical.NewContentBlockDeltaEvent(0, canonical.StreamDelta{
|
|
Type: string(canonical.DeltaTypeInputJSON),
|
|
PartialJSON: "{\"city\":\"北京\"}",
|
|
})
|
|
|
|
chunks := e.EncodeEvent(event)
|
|
require.NotEmpty(t, chunks)
|
|
|
|
s := string(chunks[0])
|
|
assert.Contains(t, s, "tool_calls")
|
|
assert.Contains(t, s, "北京")
|
|
}
|
|
|
|
func TestStreamEncoder_MessageDelta_WithStopReason(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
sr := canonical.StopReasonEndTurn
|
|
event := canonical.CanonicalStreamEvent{
|
|
Type: canonical.EventMessageDelta,
|
|
StopReason: &sr,
|
|
}
|
|
|
|
chunks := e.EncodeEvent(event)
|
|
require.NotEmpty(t, chunks)
|
|
|
|
s := string(chunks[0])
|
|
assert.Contains(t, s, "finish_reason")
|
|
assert.Contains(t, s, "stop")
|
|
}
|
|
|
|
func TestStreamEncoder_MessageDelta_WithUsage(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
usage := canonical.CanonicalUsage{
|
|
InputTokens: 100,
|
|
OutputTokens: 50,
|
|
}
|
|
event := canonical.CanonicalStreamEvent{
|
|
Type: canonical.EventMessageDelta,
|
|
Usage: &usage,
|
|
}
|
|
|
|
chunks := e.EncodeEvent(event)
|
|
require.NotEmpty(t, chunks)
|
|
|
|
s := string(chunks[0])
|
|
assert.Contains(t, s, "usage")
|
|
assert.Contains(t, s, "prompt_tokens")
|
|
}
|
|
|
|
func TestStreamEncoder_InputJSONDelta_SubsequentDelta(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
|
|
e.EncodeEvent(canonical.NewContentBlockStartEvent(0, canonical.StreamContentBlock{
|
|
Type: "tool_use",
|
|
ID: "call_1",
|
|
Name: "get_weather",
|
|
}))
|
|
|
|
e.EncodeEvent(canonical.NewContentBlockDeltaEvent(0, canonical.StreamDelta{
|
|
Type: string(canonical.DeltaTypeInputJSON),
|
|
PartialJSON: "{\"city\":",
|
|
}))
|
|
|
|
event := canonical.NewContentBlockDeltaEvent(0, canonical.StreamDelta{
|
|
Type: string(canonical.DeltaTypeInputJSON),
|
|
PartialJSON: "\"Beijing\"}",
|
|
})
|
|
|
|
chunks := e.EncodeEvent(event)
|
|
require.NotEmpty(t, chunks)
|
|
|
|
s := string(chunks[0])
|
|
assert.Contains(t, s, "tool_calls")
|
|
assert.Contains(t, s, "Beijing")
|
|
}
|
|
|
|
func TestStreamEncoder_MessageStart_NilMessage(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
event := canonical.CanonicalStreamEvent{Type: canonical.EventMessageStart}
|
|
|
|
chunks := e.EncodeEvent(event)
|
|
require.Len(t, chunks, 1)
|
|
|
|
s := string(chunks[0])
|
|
assert.Contains(t, s, "chat.completion.chunk")
|
|
}
|
|
|
|
func TestStreamEncoder_UnknownEvent_ReturnsNil(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
event := canonical.CanonicalStreamEvent{Type: "unknown_type"}
|
|
chunks := e.EncodeEvent(event)
|
|
assert.Nil(t, chunks)
|
|
}
|
|
|
|
func TestStreamEncoder_ContentBlockDelta_NilDelta(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
event := canonical.CanonicalStreamEvent{Type: canonical.EventContentBlockDelta}
|
|
chunks := e.EncodeEvent(event)
|
|
assert.Nil(t, chunks)
|
|
}
|
|
|
|
func TestStreamEncoder_MultiToolCall_IndexMapping(t *testing.T) {
|
|
e := NewStreamEncoder()
|
|
|
|
e.EncodeEvent(canonical.NewContentBlockStartEvent(0, canonical.StreamContentBlock{
|
|
Type: "tool_use",
|
|
ID: "call_1",
|
|
Name: "get_weather",
|
|
}))
|
|
|
|
firstDelta := canonical.NewContentBlockDeltaEvent(0, canonical.StreamDelta{
|
|
Type: string(canonical.DeltaTypeInputJSON),
|
|
PartialJSON: `{"city":"北京"}`,
|
|
})
|
|
chunks := e.EncodeEvent(firstDelta)
|
|
require.NotEmpty(t, chunks)
|
|
s := string(chunks[0])
|
|
assert.Contains(t, s, `"index":0`)
|
|
assert.Contains(t, s, "get_weather")
|
|
assert.Contains(t, s, "北京")
|
|
|
|
e.EncodeEvent(canonical.NewContentBlockStopEvent(0))
|
|
|
|
e.EncodeEvent(canonical.NewContentBlockStartEvent(1, canonical.StreamContentBlock{
|
|
Type: "tool_use",
|
|
ID: "call_2",
|
|
Name: "get_time",
|
|
}))
|
|
|
|
secondDelta := canonical.NewContentBlockDeltaEvent(1, canonical.StreamDelta{
|
|
Type: string(canonical.DeltaTypeInputJSON),
|
|
PartialJSON: `{"tz":"Asia/Shanghai"}`,
|
|
})
|
|
chunks = e.EncodeEvent(secondDelta)
|
|
require.NotEmpty(t, chunks)
|
|
s = string(chunks[0])
|
|
assert.Contains(t, s, `"index":1`)
|
|
assert.Contains(t, s, "get_time")
|
|
assert.Contains(t, s, "Asia/Shanghai")
|
|
|
|
subsequentDelta0 := canonical.NewContentBlockDeltaEvent(0, canonical.StreamDelta{
|
|
Type: string(canonical.DeltaTypeInputJSON),
|
|
PartialJSON: `"more_data"`,
|
|
})
|
|
chunks = e.EncodeEvent(subsequentDelta0)
|
|
require.NotEmpty(t, chunks)
|
|
s = string(chunks[0])
|
|
assert.Contains(t, s, `"index":0`)
|
|
assert.NotContains(t, s, "get_weather")
|
|
assert.Contains(t, s, "more_data")
|
|
|
|
subsequentDelta1 := canonical.NewContentBlockDeltaEvent(1, canonical.StreamDelta{
|
|
Type: string(canonical.DeltaTypeInputJSON),
|
|
PartialJSON: `"more_time"`,
|
|
})
|
|
chunks = e.EncodeEvent(subsequentDelta1)
|
|
require.NotEmpty(t, chunks)
|
|
s = string(chunks[0])
|
|
assert.Contains(t, s, `"index":1`)
|
|
assert.Contains(t, s, "more_time")
|
|
}
|