Files
opencatd-open/backend/internal/proxy/convert/convert_test.go
T
Sakurasan 9f4d631fc4 feat: 网关路由对齐参考实现 + 用量落库 + e2e 全绿
路由与故障转移(参考 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 次稳定
2026-09-01 00:46:05 +08:00

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")
}
}