路由与故障转移(参考 openteam 语义) - channel.Candidates:绑定模型优先(携带 upstream_model 映射), 未绑定模型回退到权重最低的健康备用渠道;新增 Pick 加权随机与 FilterHealthy 内存健康过滤 - gateway.Dispatch:遍历候选渠道,可重试失败(连接错误/429/5xx)自动故障转移, 4xx 透传;不再使用单一 SelectChannel - 修复 gorm default 标签把渠道 weight=0 静默改写为 1 的问题(去掉 default, 权重 0 语义 = 不参与加权选择,仅作备用承接 unbound 流量) - RecordFailure 连续 2 次进入 degraded 快速熔断,健康检查成功或冷却过期后复位 网关功能补全 - /v1/models 返回 DB 中启用的模型列表(替换 TODO 存根) - 请求级 request_id 生成与用量记录接入:流式 SSE 逐块累计 usage、 非流式从响应提取,按模型定价计算成本后经 usage.Recorder 异步落库 - 流式结束检测:chat 的 [DONE]、messages 的 message_stop、responses 的 response.completed,避免 keep-alive 上游发完不关连接导致读阻塞到超时 - ResponsesRequest.input 兼容字符串与条目数组两种客户端写法 测试 - 修复 convert_test 对新 input 形态的断言 - 网关 e2e(/tmp/test_gateway.py + mock upstream)72/72 全部通过,连续 3 次稳定
239 lines
5.7 KiB
Go
239 lines
5.7 KiB
Go
package convert
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
func TestChatToMessages(t *testing.T) {
|
|
maxTokens := 1024
|
|
temp := 0.7
|
|
|
|
req := &ChatCompletionRequest{
|
|
Model: "claude-3-sonnet-20240229",
|
|
Messages: []Message{
|
|
{Role: "system", Content: "You are a helpful assistant."},
|
|
{Role: "user", Content: "Hello!"},
|
|
},
|
|
MaxTokens: &maxTokens,
|
|
Temperature: &temp,
|
|
}
|
|
|
|
result, err := ChatToMessages(req)
|
|
if err != nil {
|
|
t.Fatalf("ChatToMessages() error = %v", err)
|
|
}
|
|
|
|
if result.Model != "claude-3-sonnet-20240229" {
|
|
t.Errorf("Model = %q, want %q", result.Model, "claude-3-sonnet-20240229")
|
|
}
|
|
|
|
if len(result.Messages) != 1 {
|
|
t.Errorf("Messages length = %d, want 1", len(result.Messages))
|
|
}
|
|
|
|
if result.Messages[0].Role != "user" {
|
|
t.Errorf("Messages[0].Role = %q, want %q", result.Messages[0].Role, "user")
|
|
}
|
|
|
|
if result.System == nil {
|
|
t.Error("System is nil, want non-nil")
|
|
}
|
|
|
|
if result.MaxTokens != 1024 {
|
|
t.Errorf("MaxTokens = %d, want 1024", result.MaxTokens)
|
|
}
|
|
}
|
|
|
|
func TestMessagesToChat(t *testing.T) {
|
|
resp := &MessagesResponse{
|
|
ID: "msg-123",
|
|
Model: "claude-3-sonnet-20240229",
|
|
Content: []ContentBlock{
|
|
{Type: "text", Text: "Hello! How can I help?"},
|
|
},
|
|
StopReason: "end_turn",
|
|
Usage: Usage{
|
|
PromptTokens: 10,
|
|
CompletionTokens: 20,
|
|
},
|
|
}
|
|
|
|
result, err := MessagesToChat(resp)
|
|
if err != nil {
|
|
t.Fatalf("MessagesToChat() error = %v", err)
|
|
}
|
|
|
|
if result.ID != "msg-123" {
|
|
t.Errorf("ID = %q, want %q", result.ID, "msg-123")
|
|
}
|
|
|
|
if result.Object != "chat.completion" {
|
|
t.Errorf("Object = %q, want %q", result.Object, "chat.completion")
|
|
}
|
|
|
|
if len(result.Choices) != 1 {
|
|
t.Errorf("Choices length = %d, want 1", len(result.Choices))
|
|
return
|
|
}
|
|
|
|
if result.Choices[0].Message.Role != "assistant" {
|
|
t.Errorf("Choices[0].Message.Role = %q, want %q", result.Choices[0].Message.Role, "assistant")
|
|
}
|
|
|
|
if result.Choices[0].Message.Content != "Hello! How can I help?" {
|
|
t.Errorf("Choices[0].Message.Content = %q, want %q", result.Choices[0].Message.Content, "Hello! How can I help?")
|
|
}
|
|
|
|
if result.Choices[0].FinishReason != "stop" {
|
|
t.Errorf("FinishReason = %q, want %q", result.Choices[0].FinishReason, "stop")
|
|
}
|
|
|
|
if result.Usage.TotalTokens != 30 {
|
|
t.Errorf("Usage.TotalTokens = %d, want 30", result.Usage.TotalTokens)
|
|
}
|
|
}
|
|
|
|
func TestChatToResponses(t *testing.T) {
|
|
maxTokens := 2048
|
|
|
|
req := &ChatCompletionRequest{
|
|
Model: "gpt-4o",
|
|
Messages: []Message{
|
|
{Role: "system", Content: "You are a helpful assistant."},
|
|
{Role: "user", Content: "What is 2+2?"},
|
|
},
|
|
MaxTokens: &maxTokens,
|
|
}
|
|
|
|
result, err := ChatToResponses(req)
|
|
if err != nil {
|
|
t.Fatalf("ChatToResponses() error = %v", err)
|
|
}
|
|
|
|
if result.Model != "gpt-4o" {
|
|
t.Errorf("Model = %q, want %q", result.Model, "gpt-4o")
|
|
}
|
|
|
|
if len(result.Input) == 0 {
|
|
t.Errorf("Input empty, want 1 item")
|
|
} else {
|
|
var items []InputItem
|
|
if err := json.Unmarshal(result.Input, &items); err != nil {
|
|
t.Fatalf("Input unmarshal = %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Errorf("Input length = %d, want 1", len(items))
|
|
} else if items[0].Role != "user" {
|
|
t.Errorf("Input[0].Role = %q, want %q", items[0].Role, "user")
|
|
}
|
|
}
|
|
|
|
if result.Instructions != "You are a helpful assistant." {
|
|
t.Errorf("Instructions = %q, want %q", result.Instructions, "You are a helpful assistant.")
|
|
}
|
|
}
|
|
|
|
func TestResponsesToChat(t *testing.T) {
|
|
resp := &ResponsesResponse{
|
|
ID: "resp-123",
|
|
Model: "gpt-4o",
|
|
Status: "completed",
|
|
Output: []OutputItem{
|
|
{
|
|
Type: "message",
|
|
Content: []OutputContent{
|
|
{Type: "output_text", Text: "2+2 equals 4."},
|
|
},
|
|
},
|
|
},
|
|
Usage: Usage{
|
|
PromptTokens: 15,
|
|
CompletionTokens: 10,
|
|
},
|
|
}
|
|
|
|
result, err := ResponsesToChat(resp)
|
|
if err != nil {
|
|
t.Fatalf("ResponsesToChat() error = %v", err)
|
|
}
|
|
|
|
if result.ID != "resp-123" {
|
|
t.Errorf("ID = %q, want %q", result.ID, "resp-123")
|
|
}
|
|
|
|
if len(result.Choices) != 1 {
|
|
t.Errorf("Choices length = %d, want 1", len(result.Choices))
|
|
return
|
|
}
|
|
|
|
if result.Choices[0].Message.Content != "2+2 equals 4." {
|
|
t.Errorf("Content = %q, want %q", result.Choices[0].Message.Content, "2+2 equals 4.")
|
|
}
|
|
}
|
|
|
|
func TestMapStopReason(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"end_turn", "stop"},
|
|
{"stop_sequence", "stop"},
|
|
{"tool_use", "tool_calls"},
|
|
{"max_tokens", "length"},
|
|
{"unknown", "stop"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
result := mapStopReason(tt.input)
|
|
if result != tt.expected {
|
|
t.Errorf("mapStopReason(%q) = %q, want %q", tt.input, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMessagesToChatToolUse(t *testing.T) {
|
|
resp := &MessagesResponse{
|
|
ID: "msg-456",
|
|
Model: "claude-3-sonnet-20240229",
|
|
Content: []ContentBlock{
|
|
{Type: "text", Text: "Let me search for that."},
|
|
{Type: "tool_use", ID: "toolu-123", Name: "web_search"},
|
|
},
|
|
StopReason: "tool_use",
|
|
Usage: Usage{
|
|
PromptTokens: 20,
|
|
CompletionTokens: 30,
|
|
},
|
|
}
|
|
|
|
result, err := MessagesToChat(resp)
|
|
if err != nil {
|
|
t.Fatalf("MessagesToChat() error = %v", err)
|
|
}
|
|
|
|
if len(result.Choices) != 1 {
|
|
t.Errorf("Choices length = %d, want 1", len(result.Choices))
|
|
return
|
|
}
|
|
|
|
if result.Choices[0].FinishReason != "tool_calls" {
|
|
t.Errorf("FinishReason = %q, want %q", result.Choices[0].FinishReason, "tool_calls")
|
|
}
|
|
|
|
if len(result.Choices[0].Message.ToolCalls) != 1 {
|
|
t.Errorf("ToolCalls length = %d, want 1", len(result.Choices[0].Message.ToolCalls))
|
|
return
|
|
}
|
|
|
|
if result.Choices[0].Message.ToolCalls[0].ID != "toolu-123" {
|
|
t.Errorf("ToolCall ID = %q, want %q", result.Choices[0].Message.ToolCalls[0].ID, "toolu-123")
|
|
}
|
|
|
|
if result.Choices[0].Message.ToolCalls[0].Function.Name != "web_search" {
|
|
t.Errorf("Function.Name = %q, want %q", result.Choices[0].Message.ToolCalls[0].Function.Name, "web_search")
|
|
}
|
|
}
|