refactor: 实现 ConversionEngine 协议转换引擎,替代旧 protocol 包
- 新增 ConversionEngine 核心引擎,支持 OpenAI 和 Anthropic 协议转换 - 添加 stream decoder/encoder 实现 - 更新 provider client 支持新引擎 - 补充单元测试和集成测试 - 更新 specs 文档
This commit is contained in:
477
backend/internal/conversion/anthropic/supplemental_test.go
Normal file
477
backend/internal/conversion/anthropic/supplemental_test.go
Normal file
@@ -0,0 +1,477 @@
|
||||
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 TestDecodeTools(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model": "claude-3",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": [
|
||||
{"name": "search", "description": "Search", "input_schema": {"type":"object"}},
|
||||
{"name": "calc", "input_schema": {"type":"object"}}
|
||||
]
|
||||
}`)
|
||||
req, err := decodeRequest(body)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, req.Tools, 2)
|
||||
assert.Equal(t, "search", req.Tools[0].Name)
|
||||
assert.Equal(t, "Search", req.Tools[0].Description)
|
||||
assert.Equal(t, "calc", req.Tools[1].Name)
|
||||
}
|
||||
|
||||
func TestDecodeToolChoice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jsonBody string
|
||||
wantType string
|
||||
wantName string
|
||||
}{
|
||||
{
|
||||
"auto string",
|
||||
`{"model":"claude-3","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"tool_choice":"auto"}`,
|
||||
"auto", "",
|
||||
},
|
||||
{
|
||||
"none string",
|
||||
`{"model":"claude-3","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"tool_choice":"none"}`,
|
||||
"none", "",
|
||||
},
|
||||
{
|
||||
"any string",
|
||||
`{"model":"claude-3","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"tool_choice":"any"}`,
|
||||
"any", "",
|
||||
},
|
||||
{
|
||||
"tool object",
|
||||
`{"model":"claude-3","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"tool_choice":{"type":"tool","name":"search"}}`,
|
||||
"tool", "search",
|
||||
},
|
||||
{
|
||||
"auto object",
|
||||
`{"model":"claude-3","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"tool_choice":{"type":"auto"}}`,
|
||||
"auto", "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := decodeRequest([]byte(tt.jsonBody))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.ToolChoice)
|
||||
assert.Equal(t, tt.wantType, req.ToolChoice.Type)
|
||||
assert.Equal(t, tt.wantName, req.ToolChoice.Name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeParameters_TopK(t *testing.T) {
|
||||
topK := 10
|
||||
body := []byte(`{
|
||||
"model": "claude-3",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"top_k": 10,
|
||||
"stop_sequences": ["STOP"]
|
||||
}`)
|
||||
req, err := decodeRequest(body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.Parameters.TopK)
|
||||
assert.Equal(t, topK, *req.Parameters.TopK)
|
||||
assert.Equal(t, []string{"STOP"}, req.Parameters.StopSequences)
|
||||
}
|
||||
|
||||
func TestDecodeRequest_MetadataUserID(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model": "claude-3",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"metadata": {"user_id": "user-123"}
|
||||
}`)
|
||||
req, err := decodeRequest(body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user-123", req.UserID)
|
||||
}
|
||||
|
||||
func TestDecodeSystem_Empty(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model": "claude-3",
|
||||
"max_tokens": 1024,
|
||||
"system": "",
|
||||
"messages": [{"role": "user", "content": "hi"}]
|
||||
}`)
|
||||
req, err := decodeRequest(body)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, req.System)
|
||||
}
|
||||
|
||||
func TestDecodeSystem_Nil(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model": "claude-3",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "hi"}]
|
||||
}`)
|
||||
req, err := decodeRequest(body)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, req.System)
|
||||
}
|
||||
|
||||
func TestDecodeThinking_WithEffort(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model": "claude-3",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"thinking": {"type": "enabled", "budget_tokens": 5000},
|
||||
"output_config": {"effort": "high"}
|
||||
}`)
|
||||
req, err := decodeRequest(body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.Thinking)
|
||||
assert.Equal(t, "enabled", req.Thinking.Type)
|
||||
assert.Equal(t, "high", req.Thinking.Effort)
|
||||
}
|
||||
|
||||
func TestDecodeOutputFormat_NilOutputConfig(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model": "claude-3",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "hi"}]
|
||||
}`)
|
||||
req, err := decodeRequest(body)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, req.OutputFormat)
|
||||
}
|
||||
|
||||
func TestDecodeMessage_UserWithOnlyToolResults(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model": "claude-3",
|
||||
"max_tokens": 1024,
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "fn", "input": {}}]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "result"}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
req, err := decodeRequest(body)
|
||||
require.NoError(t, err)
|
||||
lastMsg := req.Messages[len(req.Messages)-1]
|
||||
assert.Equal(t, canonical.RoleTool, lastMsg.Role)
|
||||
assert.Equal(t, "t1", lastMsg.Content[0].ToolUseID)
|
||||
}
|
||||
|
||||
func TestDecodeContentBlocks_Nil(t *testing.T) {
|
||||
blocks := decodeContentBlocks(nil)
|
||||
assert.Len(t, blocks, 1)
|
||||
assert.Equal(t, "", blocks[0].Text)
|
||||
}
|
||||
|
||||
func TestDecodeContentBlocks_String(t *testing.T) {
|
||||
blocks := decodeContentBlocks("hello")
|
||||
assert.Len(t, blocks, 1)
|
||||
assert.Equal(t, "hello", blocks[0].Text)
|
||||
}
|
||||
|
||||
func TestParseTimestamp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want int64
|
||||
}{
|
||||
{"valid RFC3339", "2024-01-15T00:00:00Z", time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC).Unix()},
|
||||
{"empty", "", 0},
|
||||
{"invalid", "not-a-date", 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, parseTimestamp(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeToolChoice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
choice *canonical.ToolChoice
|
||||
want map[string]any
|
||||
}{
|
||||
{"auto", canonical.NewToolChoiceAuto(), map[string]any{"type": "auto"}},
|
||||
{"none", canonical.NewToolChoiceNone(), map[string]any{"type": "none"}},
|
||||
{"any", canonical.NewToolChoiceAny(), map[string]any{"type": "any"}},
|
||||
{"tool", canonical.NewToolChoiceNamed("search"), map[string]any{"type": "tool", "name": "search"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := encodeToolChoice(tt.choice)
|
||||
assert.Equal(t, tt.want["type"], result.(map[string]any)["type"])
|
||||
assert.Equal(t, tt.want["name"], result.(map[string]any)["name"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeThinkingConfig(t *testing.T) {
|
||||
budget := 5000
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *canonical.ThinkingConfig
|
||||
want map[string]any
|
||||
}{
|
||||
{"enabled", &canonical.ThinkingConfig{Type: "enabled", BudgetTokens: &budget}, map[string]any{"type": "enabled", "budget_tokens": float64(5000)}},
|
||||
{"disabled", &canonical.ThinkingConfig{Type: "disabled"}, map[string]any{"type": "disabled"}},
|
||||
{"adaptive", &canonical.ThinkingConfig{Type: "adaptive"}, map[string]any{"type": "adaptive"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := encodeThinkingConfig(tt.cfg)
|
||||
assert.Equal(t, tt.want["type"], result["type"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRequest_PublicFields(t *testing.T) {
|
||||
maxTokens := 1024
|
||||
parallel := false
|
||||
req := &canonical.CanonicalRequest{
|
||||
Model: "claude-3",
|
||||
Parameters: canonical.RequestParameters{MaxTokens: &maxTokens},
|
||||
Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}},
|
||||
UserID: "user-123",
|
||||
ParallelToolUse: ¶llel,
|
||||
}
|
||||
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))
|
||||
assert.Equal(t, map[string]any{"user_id": "user-123"}, result["metadata"])
|
||||
assert.Equal(t, true, result["disable_parallel_tool_use"])
|
||||
}
|
||||
|
||||
func TestEncodeRequest_DefaultMaxTokens(t *testing.T) {
|
||||
req := &canonical.CanonicalRequest{
|
||||
Model: "claude-3",
|
||||
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))
|
||||
assert.Equal(t, float64(4096), result["max_tokens"])
|
||||
}
|
||||
|
||||
func TestEncodeRequest_TopK(t *testing.T) {
|
||||
maxTokens := 1024
|
||||
topK := 10
|
||||
req := &canonical.CanonicalRequest{
|
||||
Model: "claude-3",
|
||||
Parameters: canonical.RequestParameters{MaxTokens: &maxTokens, TopK: &topK},
|
||||
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))
|
||||
assert.Equal(t, float64(10), result["top_k"])
|
||||
}
|
||||
|
||||
func TestEncodeRequest_WithTools(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")}}},
|
||||
Tools: []canonical.CanonicalTool{
|
||||
{Name: "search", Description: "Search things", InputSchema: json.RawMessage(`{"type":"object"}`)},
|
||||
},
|
||||
ToolChoice: canonical.NewToolChoiceAuto(),
|
||||
}
|
||||
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))
|
||||
tools := result["tools"].([]any)
|
||||
assert.Len(t, tools, 1)
|
||||
tool := tools[0].(map[string]any)
|
||||
assert.Equal(t, "search", tool["name"])
|
||||
assert.Equal(t, "Search things", tool["description"])
|
||||
tc := result["tool_choice"].(map[string]any)
|
||||
assert.Equal(t, "auto", tc["type"])
|
||||
}
|
||||
|
||||
func TestEncodeRequest_ThinkingWithEffort(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: "enabled", Effort: "high"},
|
||||
}
|
||||
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)
|
||||
assert.Equal(t, "high", oc["effort"])
|
||||
}
|
||||
|
||||
func TestEncodeResponse_UsageWithCacheAndCreation(t *testing.T) {
|
||||
cacheRead := 30
|
||||
cacheCreation := 10
|
||||
sr := canonical.StopReasonEndTurn
|
||||
resp := &canonical.CanonicalResponse{
|
||||
ID: "msg-1",
|
||||
Model: "claude-3",
|
||||
Content: []canonical.ContentBlock{canonical.NewTextBlock("ok")},
|
||||
StopReason: &sr,
|
||||
Usage: canonical.CanonicalUsage{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 50,
|
||||
CacheReadTokens: &cacheRead,
|
||||
CacheCreationTokens: &cacheCreation,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
assert.Equal(t, float64(100), usage["input_tokens"])
|
||||
assert.Equal(t, float64(30), usage["cache_read_input_tokens"])
|
||||
assert.Equal(t, float64(10), usage["cache_creation_input_tokens"])
|
||||
}
|
||||
|
||||
func TestEncodeResponse_StopReasons(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stopReason canonical.StopReason
|
||||
want string
|
||||
}{
|
||||
{"end_turn", canonical.StopReasonEndTurn, "end_turn"},
|
||||
{"max_tokens", canonical.StopReasonMaxTokens, "max_tokens"},
|
||||
{"tool_use", canonical.StopReasonToolUse, "tool_use"},
|
||||
{"stop_sequence", canonical.StopReasonStopSequence, "stop_sequence"},
|
||||
{"refusal", canonical.StopReasonRefusal, "refusal"},
|
||||
{"content_filter→end_turn", canonical.StopReasonContentFilter, "end_turn"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sr := tt.stopReason
|
||||
resp := &canonical.CanonicalResponse{
|
||||
ID: "r1",
|
||||
Model: "claude-3",
|
||||
Content: []canonical.ContentBlock{canonical.NewTextBlock("ok")},
|
||||
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, tt.want, result["stop_reason"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeSystem_SystemBlocks(t *testing.T) {
|
||||
result := encodeSystem([]canonical.SystemBlock{{Text: "part1"}, {Text: "part2"}})
|
||||
blocks, ok := result.([]map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Len(t, blocks, 2)
|
||||
assert.Equal(t, "part1", blocks[0]["text"])
|
||||
}
|
||||
|
||||
func TestEncodeModelInfoResponse(t *testing.T) {
|
||||
info := &canonical.CanonicalModelInfo{
|
||||
ID: "claude-3-opus",
|
||||
Name: "Claude 3 Opus",
|
||||
Created: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC).Unix(),
|
||||
}
|
||||
|
||||
body, err := encodeModelInfoResponse(info)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
assert.Equal(t, "claude-3-opus", result["id"])
|
||||
assert.Equal(t, "Claude 3 Opus", result["display_name"])
|
||||
}
|
||||
|
||||
func TestDecodeModelInfoResponse(t *testing.T) {
|
||||
body := []byte(`{"id":"claude-3-opus","type":"model","display_name":"Claude 3 Opus","created_at":"2024-01-15T00:00:00Z"}`)
|
||||
info, err := decodeModelInfoResponse(body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "claude-3-opus", info.ID)
|
||||
assert.Equal(t, "Claude 3 Opus", info.Name)
|
||||
assert.NotEqual(t, int64(0), info.Created)
|
||||
}
|
||||
|
||||
func TestDecodeResponse_PauseTurn(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"id": "msg-1", "type": "message", "role": "assistant", "model": "claude-3",
|
||||
"content": [{"type": "text", "text": "ok"}],
|
||||
"stop_reason": "pause_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1}
|
||||
}`)
|
||||
resp, err := decodeResponse(body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, canonical.StopReason("pause_turn"), *resp.StopReason)
|
||||
}
|
||||
|
||||
func TestEncodeResponse_NoStopReason(t *testing.T) {
|
||||
resp := &canonical.CanonicalResponse{
|
||||
ID: "msg-1",
|
||||
Model: "claude-3",
|
||||
Content: []canonical.ContentBlock{canonical.NewTextBlock("ok")},
|
||||
}
|
||||
|
||||
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 TestDecodeRequest_MaxTokensZero(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model": "claude-3",
|
||||
"max_tokens": 0,
|
||||
"messages": [{"role": "user", "content": "hi"}]
|
||||
}`)
|
||||
req, err := decodeRequest(body)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, req.Parameters.MaxTokens)
|
||||
}
|
||||
Reference in New Issue
Block a user