- 新增 ConversionEngine 核心引擎,支持 OpenAI 和 Anthropic 协议转换 - 添加 stream decoder/encoder 实现 - 更新 provider client 支持新引擎 - 补充单元测试和集成测试 - 更新 specs 文档
478 lines
14 KiB
Go
478 lines
14 KiB
Go
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)
|
|
}
|