refactor: 实现 ConversionEngine 协议转换引擎,替代旧 protocol 包
引入 Canonical Model 和 ProtocolAdapter 架构,支持 OpenAI/Anthropic 协议间 无缝转换,统一 ProxyHandler 替代分散的 OpenAI/Anthropic Handler,简化 ProviderClient 为协议无关的 HTTP 发送器,Provider 新增 protocol 字段。
This commit is contained in:
350
backend/internal/conversion/anthropic/encoder_test.go
Normal file
350
backend/internal/conversion/anthropic/encoder_test.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nex/backend/internal/conversion"
|
||||
"nex/backend/internal/conversion/canonical"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEncodeRequest_Basic(t *testing.T) {
|
||||
maxTokens := 1024
|
||||
req := &canonical.CanonicalRequest{
|
||||
Model: "claude-3",
|
||||
Stream: true,
|
||||
Parameters: canonical.RequestParameters{MaxTokens: &maxTokens},
|
||||
Messages: []canonical.CanonicalMessage{
|
||||
{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}},
|
||||
},
|
||||
}
|
||||
provider := conversion.NewTargetProvider("", "key", "my-model")
|
||||
|
||||
body, err := encodeRequest(req, provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
assert.Equal(t, "my-model", result["model"])
|
||||
assert.Equal(t, true, result["stream"])
|
||||
assert.Equal(t, float64(1024), result["max_tokens"])
|
||||
|
||||
msgs := result["messages"].([]any)
|
||||
assert.Len(t, msgs, 1)
|
||||
}
|
||||
|
||||
func TestEncodeRequest_ToolMergeIntoUser(t *testing.T) {
|
||||
maxTokens := 1024
|
||||
req := &canonical.CanonicalRequest{
|
||||
Model: "claude-3",
|
||||
Parameters: canonical.RequestParameters{MaxTokens: &maxTokens},
|
||||
Messages: []canonical.CanonicalMessage{
|
||||
{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("查询")}},
|
||||
{Role: canonical.RoleAssistant, Content: []canonical.ContentBlock{canonical.NewToolUseBlock("tool_1", "search", json.RawMessage(`{"q":"test"}`))}},
|
||||
{Role: canonical.RoleTool, Content: []canonical.ContentBlock{canonical.NewToolResultBlock("tool_1", "结果", false)}},
|
||||
},
|
||||
}
|
||||
provider := conversion.NewTargetProvider("", "key", "model")
|
||||
|
||||
body, err := encodeRequest(req, provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
msgs := result["messages"].([]any)
|
||||
|
||||
// tool 消息应被合并到相邻 user 消息
|
||||
foundToolResult := false
|
||||
for _, m := range msgs {
|
||||
msgMap := m.(map[string]any)
|
||||
if msgMap["role"] == "user" {
|
||||
content, ok := msgMap["content"].([]any)
|
||||
if ok {
|
||||
for _, c := range content {
|
||||
block := c.(map[string]any)
|
||||
if block["type"] == "tool_result" {
|
||||
foundToolResult = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, foundToolResult)
|
||||
}
|
||||
|
||||
func TestEncodeRequest_FirstUserGuarantee(t *testing.T) {
|
||||
maxTokens := 1024
|
||||
req := &canonical.CanonicalRequest{
|
||||
Model: "claude-3",
|
||||
Parameters: canonical.RequestParameters{MaxTokens: &maxTokens},
|
||||
Messages: []canonical.CanonicalMessage{
|
||||
{Role: canonical.RoleAssistant, Content: []canonical.ContentBlock{canonical.NewTextBlock("前置")}},
|
||||
{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}},
|
||||
},
|
||||
}
|
||||
provider := conversion.NewTargetProvider("", "key", "model")
|
||||
|
||||
body, err := encodeRequest(req, provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
msgs := result["messages"].([]any)
|
||||
firstMsg := msgs[0].(map[string]any)
|
||||
assert.Equal(t, "user", firstMsg["role"])
|
||||
}
|
||||
|
||||
func TestEncodeRequest_ThinkingEnabled(t *testing.T) {
|
||||
budget := 10000
|
||||
maxTokens := 8096
|
||||
req := &canonical.CanonicalRequest{
|
||||
Model: "claude-3",
|
||||
Parameters: canonical.RequestParameters{MaxTokens: &maxTokens},
|
||||
Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}},
|
||||
Thinking: &canonical.ThinkingConfig{Type: "enabled", BudgetTokens: &budget},
|
||||
}
|
||||
provider := conversion.NewTargetProvider("", "key", "model")
|
||||
|
||||
body, err := encodeRequest(req, provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
thinking, ok := result["thinking"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "enabled", thinking["type"])
|
||||
assert.Equal(t, float64(10000), thinking["budget_tokens"])
|
||||
}
|
||||
|
||||
func TestEncodeResponse_Basic(t *testing.T) {
|
||||
sr := canonical.StopReasonEndTurn
|
||||
resp := &canonical.CanonicalResponse{
|
||||
ID: "msg_1",
|
||||
Model: "claude-3",
|
||||
Content: []canonical.ContentBlock{canonical.NewTextBlock("你好")},
|
||||
StopReason: &sr,
|
||||
Usage: canonical.CanonicalUsage{InputTokens: 10, OutputTokens: 5},
|
||||
}
|
||||
|
||||
body, err := encodeResponse(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
assert.Equal(t, "msg_1", result["id"])
|
||||
assert.Equal(t, "message", result["type"])
|
||||
assert.Equal(t, "assistant", result["role"])
|
||||
assert.Equal(t, "end_turn", result["stop_reason"])
|
||||
|
||||
content := result["content"].([]any)
|
||||
assert.Len(t, content, 1)
|
||||
block := content[0].(map[string]any)
|
||||
assert.Equal(t, "text", block["type"])
|
||||
assert.Equal(t, "你好", block["text"])
|
||||
}
|
||||
|
||||
func TestEncodeModelsResponse(t *testing.T) {
|
||||
ts := time.Date(2024, 3, 15, 0, 0, 0, 0, time.UTC).Unix()
|
||||
list := &canonical.CanonicalModelList{
|
||||
Models: []canonical.CanonicalModel{
|
||||
{ID: "claude-3-opus", Name: "Claude 3 Opus", Created: ts, OwnedBy: "anthropic"},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := encodeModelsResponse(list)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
data := result["data"].([]any)
|
||||
assert.Len(t, data, 1)
|
||||
|
||||
model := data[0].(map[string]any)
|
||||
assert.Equal(t, "claude-3-opus", model["id"])
|
||||
// created 应为 RFC3339 格式
|
||||
createdAt, ok := model["created_at"].(string)
|
||||
assert.True(t, ok)
|
||||
assert.Contains(t, createdAt, "2024")
|
||||
}
|
||||
|
||||
func TestEncodeRequest_ThinkingDisabled(t *testing.T) {
|
||||
maxTokens := 1024
|
||||
req := &canonical.CanonicalRequest{
|
||||
Model: "claude-3",
|
||||
Parameters: canonical.RequestParameters{MaxTokens: &maxTokens},
|
||||
Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}},
|
||||
}
|
||||
provider := conversion.NewTargetProvider("", "key", "model")
|
||||
|
||||
body, err := encodeRequest(req, provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
_, hasThinking := result["thinking"]
|
||||
assert.False(t, hasThinking)
|
||||
}
|
||||
|
||||
func TestEncodeRequest_ThinkingAdaptive(t *testing.T) {
|
||||
maxTokens := 1024
|
||||
req := &canonical.CanonicalRequest{
|
||||
Model: "claude-3",
|
||||
Parameters: canonical.RequestParameters{MaxTokens: &maxTokens},
|
||||
Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}},
|
||||
Thinking: &canonical.ThinkingConfig{Type: "adaptive"},
|
||||
}
|
||||
provider := conversion.NewTargetProvider("", "key", "model")
|
||||
|
||||
body, err := encodeRequest(req, provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
thinking, ok := result["thinking"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "adaptive", thinking["type"])
|
||||
}
|
||||
|
||||
func TestEncodeRequest_OutputFormat_JSONSchema(t *testing.T) {
|
||||
maxTokens := 1024
|
||||
schema := json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"}}}`)
|
||||
req := &canonical.CanonicalRequest{
|
||||
Model: "claude-3",
|
||||
Parameters: canonical.RequestParameters{MaxTokens: &maxTokens},
|
||||
Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}},
|
||||
OutputFormat: &canonical.OutputFormat{
|
||||
Type: "json_schema",
|
||||
Schema: schema,
|
||||
},
|
||||
}
|
||||
provider := conversion.NewTargetProvider("", "key", "model")
|
||||
|
||||
body, err := encodeRequest(req, provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
oc, ok := result["output_config"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
format, ok := oc["format"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "json_schema", format["type"])
|
||||
assert.NotNil(t, format["schema"])
|
||||
}
|
||||
|
||||
func TestEncodeRequest_OutputFormat_JSON(t *testing.T) {
|
||||
maxTokens := 1024
|
||||
req := &canonical.CanonicalRequest{
|
||||
Model: "claude-3",
|
||||
Parameters: canonical.RequestParameters{MaxTokens: &maxTokens},
|
||||
Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}},
|
||||
OutputFormat: &canonical.OutputFormat{
|
||||
Type: "json_object",
|
||||
},
|
||||
}
|
||||
provider := conversion.NewTargetProvider("", "key", "model")
|
||||
|
||||
body, err := encodeRequest(req, provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
oc, ok := result["output_config"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
format, ok := oc["format"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "json_schema", format["type"])
|
||||
schemaMap, ok := format["schema"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "object", schemaMap["type"])
|
||||
}
|
||||
|
||||
func TestEncodeRequest_ConsecutiveRoleMerge(t *testing.T) {
|
||||
maxTokens := 1024
|
||||
req := &canonical.CanonicalRequest{
|
||||
Model: "claude-3",
|
||||
Parameters: canonical.RequestParameters{MaxTokens: &maxTokens},
|
||||
Messages: []canonical.CanonicalMessage{
|
||||
{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("A")}},
|
||||
{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("B")}},
|
||||
},
|
||||
}
|
||||
provider := conversion.NewTargetProvider("", "key", "model")
|
||||
|
||||
body, err := encodeRequest(req, provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
msgs := result["messages"].([]any)
|
||||
assert.Len(t, msgs, 1)
|
||||
userMsg := msgs[0].(map[string]any)
|
||||
assert.Equal(t, "user", userMsg["role"])
|
||||
content := userMsg["content"].([]any)
|
||||
assert.Len(t, content, 2)
|
||||
}
|
||||
|
||||
func TestEncodeResponse_ContentFilter(t *testing.T) {
|
||||
sr := canonical.StopReasonContentFilter
|
||||
resp := &canonical.CanonicalResponse{
|
||||
ID: "msg-cf",
|
||||
Model: "claude-3",
|
||||
Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")},
|
||||
StopReason: &sr,
|
||||
}
|
||||
|
||||
body, err := encodeResponse(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
assert.Equal(t, "end_turn", result["stop_reason"])
|
||||
}
|
||||
|
||||
func TestEncodeResponse_ReasoningTokens(t *testing.T) {
|
||||
reasoning := 100
|
||||
sr := canonical.StopReasonEndTurn
|
||||
resp := &canonical.CanonicalResponse{
|
||||
ID: "msg-rt",
|
||||
Model: "claude-3",
|
||||
Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")},
|
||||
StopReason: &sr,
|
||||
Usage: canonical.CanonicalUsage{InputTokens: 10, OutputTokens: 5, ReasoningTokens: &reasoning},
|
||||
}
|
||||
|
||||
body, err := encodeResponse(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
usage := result["usage"].(map[string]any)
|
||||
_, hasReasoning := usage["reasoning_tokens"]
|
||||
assert.False(t, hasReasoning)
|
||||
}
|
||||
|
||||
func TestEncodeResponse_ToolUse(t *testing.T) {
|
||||
sr := canonical.StopReasonToolUse
|
||||
input := json.RawMessage(`{"q":"test"}`)
|
||||
resp := &canonical.CanonicalResponse{
|
||||
ID: "msg-tool",
|
||||
Model: "claude-3",
|
||||
Content: []canonical.ContentBlock{canonical.NewToolUseBlock("tool_1", "search", input)},
|
||||
StopReason: &sr,
|
||||
}
|
||||
|
||||
body, err := encodeResponse(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
content := result["content"].([]any)
|
||||
assert.Len(t, content, 1)
|
||||
block := content[0].(map[string]any)
|
||||
assert.Equal(t, "tool_use", block["type"])
|
||||
assert.Equal(t, "tool_1", block["id"])
|
||||
assert.Equal(t, "search", block["name"])
|
||||
}
|
||||
Reference in New Issue
Block a user