引入 Canonical Model 和 ProtocolAdapter 架构,支持 OpenAI/Anthropic 协议间 无缝转换,统一 ProxyHandler 替代分散的 OpenAI/Anthropic Handler,简化 ProviderClient 为协议无关的 HTTP 发送器,Provider 新增 protocol 字段。
412 lines
11 KiB
Go
412 lines
11 KiB
Go
package openai
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"nex/backend/internal/conversion/canonical"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestDecodeRequest_BasicChat(t *testing.T) {
|
|
body := []byte(`{
|
|
"model": "gpt-4",
|
|
"messages": [
|
|
{"role": "user", "content": "你好"}
|
|
],
|
|
"temperature": 0.7
|
|
}`)
|
|
|
|
req, err := decodeRequest(body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "gpt-4", req.Model)
|
|
assert.Len(t, req.Messages, 1)
|
|
assert.Equal(t, canonical.RoleUser, req.Messages[0].Role)
|
|
assert.NotNil(t, req.Parameters.Temperature)
|
|
assert.Equal(t, 0.7, *req.Parameters.Temperature)
|
|
}
|
|
|
|
func TestDecodeRequest_SystemAndDeveloper(t *testing.T) {
|
|
body := []byte(`{
|
|
"model": "gpt-4",
|
|
"messages": [
|
|
{"role": "system", "content": "你是助手"},
|
|
{"role": "developer", "content": "额外指令"},
|
|
{"role": "user", "content": "你好"}
|
|
]
|
|
}`)
|
|
|
|
req, err := decodeRequest(body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "你是助手\n\n额外指令", req.System)
|
|
assert.Len(t, req.Messages, 1)
|
|
assert.Equal(t, canonical.RoleUser, req.Messages[0].Role)
|
|
}
|
|
|
|
func TestDecodeRequest_ToolCalls(t *testing.T) {
|
|
body := []byte(`{
|
|
"model": "gpt-4",
|
|
"messages": [
|
|
{"role": "user", "content": "天气"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [{
|
|
"id": "call_123",
|
|
"type": "function",
|
|
"function": {"name": "get_weather", "arguments": "{\"city\":\"北京\"}"}
|
|
}]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
req, err := decodeRequest(body)
|
|
require.NoError(t, err)
|
|
assert.Len(t, req.Messages, 2)
|
|
assistantMsg := req.Messages[1]
|
|
assert.Equal(t, canonical.RoleAssistant, assistantMsg.Role)
|
|
found := false
|
|
for _, b := range assistantMsg.Content {
|
|
if b.Type == "tool_use" {
|
|
found = true
|
|
assert.Equal(t, "call_123", b.ID)
|
|
assert.Equal(t, "get_weather", b.Name)
|
|
}
|
|
}
|
|
assert.True(t, found)
|
|
}
|
|
|
|
func TestDecodeRequest_ToolMessage(t *testing.T) {
|
|
body := []byte(`{
|
|
"model": "gpt-4",
|
|
"messages": [
|
|
{"role": "user", "content": "天气"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}]
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_1",
|
|
"content": "晴天 25°C"
|
|
}
|
|
]
|
|
}`)
|
|
|
|
req, err := decodeRequest(body)
|
|
require.NoError(t, err)
|
|
toolMsg := req.Messages[2]
|
|
assert.Equal(t, canonical.RoleTool, toolMsg.Role)
|
|
assert.Equal(t, "call_1", toolMsg.Content[0].ToolUseID)
|
|
}
|
|
|
|
func TestDecodeRequest_MissingModel(t *testing.T) {
|
|
body := []byte(`{"messages":[{"role":"user","content":"hi"}]}`)
|
|
_, err := decodeRequest(body)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "INVALID_INPUT")
|
|
}
|
|
|
|
func TestDecodeRequest_MissingMessages(t *testing.T) {
|
|
body := []byte(`{"model":"gpt-4"}`)
|
|
_, err := decodeRequest(body)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "INVALID_INPUT")
|
|
}
|
|
|
|
func TestDecodeRequest_DeprecatedFunctions(t *testing.T) {
|
|
body := []byte(`{
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "test"}],
|
|
"functions": [{
|
|
"name": "get_weather",
|
|
"description": "获取天气",
|
|
"parameters": {"type":"object","properties":{"city":{"type":"string"}}}
|
|
}]
|
|
}`)
|
|
|
|
req, err := decodeRequest(body)
|
|
require.NoError(t, err)
|
|
assert.Len(t, req.Tools, 1)
|
|
assert.Equal(t, "get_weather", req.Tools[0].Name)
|
|
}
|
|
|
|
func TestDecodeResponse_Basic(t *testing.T) {
|
|
body := []byte(`{
|
|
"id": "chatcmpl-123",
|
|
"model": "gpt-4",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": "你好"},
|
|
"finish_reason": "stop"
|
|
}],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
|
}`)
|
|
|
|
resp, err := decodeResponse(body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "chatcmpl-123", resp.ID)
|
|
assert.Equal(t, "gpt-4", resp.Model)
|
|
assert.Len(t, resp.Content, 1)
|
|
assert.Equal(t, "你好", resp.Content[0].Text)
|
|
assert.NotNil(t, resp.StopReason)
|
|
assert.Equal(t, canonical.StopReasonEndTurn, *resp.StopReason)
|
|
assert.Equal(t, 10, resp.Usage.InputTokens)
|
|
assert.Equal(t, 5, resp.Usage.OutputTokens)
|
|
}
|
|
|
|
func TestDecodeResponse_ToolCalls(t *testing.T) {
|
|
body := []byte(`{
|
|
"id": "chatcmpl-456",
|
|
"model": "gpt-4",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"tool_calls": [{
|
|
"id": "call_abc",
|
|
"type": "function",
|
|
"function": {"name": "search", "arguments": "{\"q\":\"test\"}"}
|
|
}]
|
|
},
|
|
"finish_reason": "tool_calls"
|
|
}]
|
|
}`)
|
|
|
|
resp, err := decodeResponse(body)
|
|
require.NoError(t, err)
|
|
found := false
|
|
for _, b := range resp.Content {
|
|
if b.Type == "tool_use" {
|
|
found = true
|
|
assert.Equal(t, "call_abc", b.ID)
|
|
assert.Equal(t, "search", b.Name)
|
|
}
|
|
}
|
|
assert.True(t, found)
|
|
assert.Equal(t, canonical.StopReasonToolUse, *resp.StopReason)
|
|
}
|
|
|
|
func TestDecodeResponse_Thinking(t *testing.T) {
|
|
body := []byte(`{
|
|
"id": "chatcmpl-789",
|
|
"model": "gpt-4",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "回答",
|
|
"reasoning_content": "思考过程"
|
|
},
|
|
"finish_reason": "stop"
|
|
}]
|
|
}`)
|
|
|
|
resp, err := decodeResponse(body)
|
|
require.NoError(t, err)
|
|
assert.Len(t, resp.Content, 2)
|
|
assert.Equal(t, "回答", resp.Content[0].Text)
|
|
assert.Equal(t, "thinking", resp.Content[1].Type)
|
|
assert.Equal(t, "思考过程", resp.Content[1].Thinking)
|
|
}
|
|
|
|
func TestDecodeModelsResponse(t *testing.T) {
|
|
body := []byte(`{
|
|
"object": "list",
|
|
"data": [
|
|
{"id": "gpt-4", "object": "model", "created": 1700000000, "owned_by": "openai"},
|
|
{"id": "gpt-3.5-turbo", "object": "model", "created": 1700000001, "owned_by": "openai"}
|
|
]
|
|
}`)
|
|
|
|
list, err := decodeModelsResponse(body)
|
|
require.NoError(t, err)
|
|
assert.Len(t, list.Models, 2)
|
|
assert.Equal(t, "gpt-4", list.Models[0].ID)
|
|
assert.Equal(t, "gpt-3.5-turbo", list.Models[1].ID)
|
|
assert.Equal(t, int64(1700000000), list.Models[0].Created)
|
|
}
|
|
|
|
func TestDecodeRequest_InvalidJSON(t *testing.T) {
|
|
_, err := decodeRequest([]byte(`invalid json`))
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "JSON_PARSE_ERROR")
|
|
}
|
|
|
|
func TestDecodeRequest_Parameters(t *testing.T) {
|
|
body := []byte(`{
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"temperature": 0.5,
|
|
"max_completion_tokens": 2048,
|
|
"top_p": 0.9,
|
|
"frequency_penalty": 0.1,
|
|
"presence_penalty": 0.2,
|
|
"stop": ["STOP"]
|
|
}`)
|
|
|
|
req, err := decodeRequest(body)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, req.Parameters.Temperature)
|
|
assert.Equal(t, 0.5, *req.Parameters.Temperature)
|
|
assert.NotNil(t, req.Parameters.MaxTokens)
|
|
assert.Equal(t, 2048, *req.Parameters.MaxTokens)
|
|
assert.NotNil(t, req.Parameters.TopP)
|
|
assert.Equal(t, 0.9, *req.Parameters.TopP)
|
|
assert.NotNil(t, req.Parameters.FrequencyPenalty)
|
|
assert.Equal(t, 0.1, *req.Parameters.FrequencyPenalty)
|
|
assert.NotNil(t, req.Parameters.PresencePenalty)
|
|
assert.Equal(t, 0.2, *req.Parameters.PresencePenalty)
|
|
assert.Equal(t, []string{"STOP"}, req.Parameters.StopSequences)
|
|
}
|
|
|
|
func TestDecodeRequest_ToolChoice(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
jsonBody string
|
|
want *canonical.ToolChoice
|
|
}{
|
|
{
|
|
name: "auto",
|
|
jsonBody: `{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"tool_choice":"auto"}`,
|
|
want: canonical.NewToolChoiceAuto(),
|
|
},
|
|
{
|
|
name: "none",
|
|
jsonBody: `{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"tool_choice":"none"}`,
|
|
want: canonical.NewToolChoiceNone(),
|
|
},
|
|
{
|
|
name: "required",
|
|
jsonBody: `{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"tool_choice":"required"}`,
|
|
want: canonical.NewToolChoiceAny(),
|
|
},
|
|
{
|
|
name: "named",
|
|
jsonBody: `{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"tool_choice":{"type":"function","function":{"name":"x"}}}`,
|
|
want: canonical.NewToolChoiceNamed("x"),
|
|
},
|
|
}
|
|
|
|
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.want.Type, req.ToolChoice.Type)
|
|
assert.Equal(t, tt.want.Name, req.ToolChoice.Name)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDecodeRequest_OutputFormat_JSONSchema(t *testing.T) {
|
|
body := []byte(`{
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"response_format": {
|
|
"type": "json_schema",
|
|
"json_schema": {
|
|
"name": "my_schema",
|
|
"schema": {"type":"object","properties":{"name":{"type":"string"}}},
|
|
"strict": true
|
|
}
|
|
}
|
|
}`)
|
|
|
|
req, err := decodeRequest(body)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, req.OutputFormat)
|
|
assert.Equal(t, "json_schema", req.OutputFormat.Type)
|
|
assert.Equal(t, "my_schema", req.OutputFormat.Name)
|
|
assert.NotNil(t, req.OutputFormat.Schema)
|
|
require.NotNil(t, req.OutputFormat.Strict)
|
|
assert.True(t, *req.OutputFormat.Strict)
|
|
}
|
|
|
|
func TestDecodeRequest_OutputFormat_JSON(t *testing.T) {
|
|
body := []byte(`{
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"response_format": {"type": "json_object"}
|
|
}`)
|
|
|
|
req, err := decodeRequest(body)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, req.OutputFormat)
|
|
assert.Equal(t, "json_object", req.OutputFormat.Type)
|
|
}
|
|
|
|
func TestDecodeResponse_StopReasons(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
finishReason string
|
|
want canonical.StopReason
|
|
}{
|
|
{"stop→end_turn", "stop", canonical.StopReasonEndTurn},
|
|
{"length→max_tokens", "length", canonical.StopReasonMaxTokens},
|
|
{"tool_calls→tool_use", "tool_calls", canonical.StopReasonToolUse},
|
|
{"content_filter→content_filter", "content_filter", canonical.StopReasonContentFilter},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
body := []byte(fmt.Sprintf(`{
|
|
"id": "resp-1",
|
|
"model": "gpt-4",
|
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "%s"}],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
|
|
}`, tt.finishReason))
|
|
|
|
resp, err := decodeResponse(body)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.StopReason)
|
|
assert.Equal(t, tt.want, *resp.StopReason)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDecodeResponse_Usage(t *testing.T) {
|
|
body := []byte(`{
|
|
"id": "resp-1",
|
|
"model": "gpt-4",
|
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
|
|
"usage": {
|
|
"prompt_tokens": 100,
|
|
"completion_tokens": 50,
|
|
"total_tokens": 150,
|
|
"prompt_tokens_details": {"cached_tokens": 80}
|
|
}
|
|
}`)
|
|
|
|
resp, err := decodeResponse(body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 100, resp.Usage.InputTokens)
|
|
assert.Equal(t, 50, resp.Usage.OutputTokens)
|
|
require.NotNil(t, resp.Usage.CacheReadTokens)
|
|
assert.Equal(t, 80, *resp.Usage.CacheReadTokens)
|
|
}
|
|
|
|
func TestDecodeResponse_Refusal(t *testing.T) {
|
|
body := []byte(`{
|
|
"id": "resp-1",
|
|
"model": "gpt-4",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": null, "refusal": "我拒绝回答"},
|
|
"finish_reason": "stop"
|
|
}],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
|
|
}`)
|
|
|
|
resp, err := decodeResponse(body)
|
|
require.NoError(t, err)
|
|
found := false
|
|
for _, b := range resp.Content {
|
|
if b.Text == "我拒绝回答" {
|
|
found = true
|
|
}
|
|
}
|
|
assert.True(t, found)
|
|
}
|