引入 Canonical Model 和 ProtocolAdapter 架构,支持 OpenAI/Anthropic 协议间 无缝转换,统一 ProxyHandler 替代分散的 OpenAI/Anthropic Handler,简化 ProviderClient 为协议无关的 HTTP 发送器,Provider 新增 protocol 字段。
351 lines
11 KiB
Go
351 lines
11 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 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"])
|
|
}
|