Files
kefu/im/backend/internal/app/ai_providers_test.go
T
Your NameandClaude Opus 5 a024d59827 后端接入 AI 模型并支持测试账号托管回复
按协议而不是按厂商做适配,与现有短信、对象存储的做法一致:openai 兼容格式
一个适配器即可覆盖 DeepSeek、通义、智谱、火山方舟、Ollama 等,新增厂商通常
只需在后台加一条模型记录;anthropic、gemini、webhook、debug 各一个适配器。
密钥沿用 integration.go 的 AES-GCM 加密存储。

测试账号托管的回复走 persistMessage 同一条落库和推送路径,因此未读数、
WebSocket 推送和会话排序全部复用现有逻辑,客户端无需改动。为此把
persistMessage 抽出 persistMessageContext,因为 worker 没有 *http.Request。

任务队列用数据库表而非内存:重启不丢回复,多实例不重复消费。领取用 UPDATE
打 claim_token 再回读,没有用 SELECT ... FOR UPDATE SKIP LOCKED——生产存在
MySQL 5.7 环境,那里该语法无法解析。同一会话同时只允许一个待处理任务
(pending_key 生成列 + 唯一键),所以用户连发多条消息只会得到一条回复。

闸门:仅 is_test=1 且在允许批次内的账号生效,托管账号之间不互相触发,三层
配额,调用失败默认静默。会话内是否标注 AI 身份由 ai.disclose_in_chat 控制
并默认开启,关闭前需确认所在地区的监管要求。

app.go、integration.go、im.go 三个文件同时包含本次改动之前工作区里就已存在
的未提交修改,一并带入。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 08:31:04 +08:00

219 lines
8.9 KiB
Go

package app
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func captureAIRequest(t *testing.T, response string, status int) (*httptest.Server, *map[string]any, *http.Header) {
t.Helper()
body := map[string]any{}
header := http.Header{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
_ = json.Unmarshal(raw, &body)
for key, values := range r.Header {
header[key] = values
}
body["__path"] = r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(response))
}))
t.Cleanup(server.Close)
return server, &body, &header
}
func testModel(protocol, baseURL string) aiModel {
return aiModel{
ID: 1, Name: "test", Protocol: protocol, BaseURL: baseURL, ModelName: "demo-model",
APIKey: "secret-key", Temperature: 0.5, MaxTokens: 128, TimeoutMS: 5000, Status: 1,
}
}
func chatOnce(t *testing.T, protocol, baseURL string) (aiChatResult, error) {
t.Helper()
provider, err := newAIProvider(testModel(protocol, baseURL), false)
if err != nil {
t.Fatalf("newAIProvider(%s) error: %v", protocol, err)
}
return provider.Chat(context.Background(), aiChatRequest{
System: "你是测试人设",
Messages: []aiChatMessage{{Role: "user", Text: "在吗"}, {Role: "assistant", Text: "在的"}, {Role: "user", Text: "吃了吗"}},
})
}
func TestOpenAIProviderRequestAndResponse(t *testing.T) {
server, body, header := captureAIRequest(t, `{"id":"cmpl-1","choices":[{"message":{"content":" 吃过啦 "},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":4}}`, http.StatusOK)
result, err := chatOnce(t, "openai", server.URL)
if err != nil {
t.Fatalf("openai chat failed: %v", err)
}
if result.Text != "吃过啦" || result.PromptTokens != 12 || result.CompletionTokens != 4 || result.FinishReason != "stop" {
t.Fatalf("unexpected openai result: %+v", result)
}
if (*body)["__path"] != "/chat/completions" {
t.Fatalf("unexpected openai path: %v", (*body)["__path"])
}
if header.Get("Authorization") != "Bearer secret-key" {
t.Fatalf("unexpected openai authorization: %q", header.Get("Authorization"))
}
// The system prompt is the first message for this protocol.
messages, _ := (*body)["messages"].([]any)
if len(messages) != 4 {
t.Fatalf("expected system + 3 messages, got %d", len(messages))
}
first, _ := messages[0].(map[string]any)
if first["role"] != "system" || first["content"] != "你是测试人设" {
t.Fatalf("unexpected first message: %v", first)
}
if (*body)["max_tokens"] != float64(128) || (*body)["temperature"] != 0.5 {
t.Fatalf("model defaults were not applied: %v %v", (*body)["max_tokens"], (*body)["temperature"])
}
}
func TestAnthropicProviderUsesTopLevelSystem(t *testing.T) {
server, body, header := captureAIRequest(t, `{"id":"msg-1","content":[{"type":"text","text":"吃过啦"}],"stop_reason":"end_turn","usage":{"input_tokens":9,"output_tokens":3}}`, http.StatusOK)
result, err := chatOnce(t, "anthropic", server.URL)
if err != nil {
t.Fatalf("anthropic chat failed: %v", err)
}
if result.Text != "吃过啦" || result.PromptTokens != 9 || result.CompletionTokens != 3 {
t.Fatalf("unexpected anthropic result: %+v", result)
}
if (*body)["__path"] != "/v1/messages" {
t.Fatalf("unexpected anthropic path: %v", (*body)["__path"])
}
if header.Get("X-Api-Key") != "secret-key" || header.Get("Anthropic-Version") == "" {
t.Fatalf("unexpected anthropic headers: %v", *header)
}
if (*body)["system"] != "你是测试人设" {
t.Fatalf("system prompt must stay outside the message list: %v", (*body)["system"])
}
if messages, _ := (*body)["messages"].([]any); len(messages) != 3 {
t.Fatalf("expected 3 messages, got %d", len(messages))
}
}
func TestGeminiProviderMapsAssistantToModel(t *testing.T) {
server, body, header := captureAIRequest(t, `{"candidates":[{"content":{"parts":[{"text":"吃过啦"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":2}}`, http.StatusOK)
result, err := chatOnce(t, "gemini", server.URL)
if err != nil {
t.Fatalf("gemini chat failed: %v", err)
}
if result.Text != "吃过啦" || result.PromptTokens != 7 || result.CompletionTokens != 2 {
t.Fatalf("unexpected gemini result: %+v", result)
}
if (*body)["__path"] != "/v1beta/models/demo-model:generateContent" {
t.Fatalf("unexpected gemini path: %v", (*body)["__path"])
}
// The key must not travel in the query string where proxies log it.
if header.Get("X-Goog-Api-Key") != "secret-key" {
t.Fatalf("gemini key must be sent as a header: %v", *header)
}
contents, _ := (*body)["contents"].([]any)
if len(contents) != 3 {
t.Fatalf("expected 3 contents, got %d", len(contents))
}
second, _ := contents[1].(map[string]any)
if second["role"] != "model" {
t.Fatalf("assistant turns must be mapped to role model: %v", second["role"])
}
}
func TestWebhookProviderAcceptsAlternateReplyFields(t *testing.T) {
server, _, _ := captureAIRequest(t, `{"reply":"吃过啦","usage":{"promptTokens":5,"completionTokens":2}}`, http.StatusOK)
result, err := chatOnce(t, "webhook", server.URL)
if err != nil {
t.Fatalf("webhook chat failed: %v", err)
}
if result.Text != "吃过啦" || result.PromptTokens != 5 {
t.Fatalf("unexpected webhook result: %+v", result)
}
}
func TestAIProviderSurfacesUpstreamErrors(t *testing.T) {
server, _, _ := captureAIRequest(t, `{"error":{"message":"insufficient quota"}}`, http.StatusTooManyRequests)
if _, err := chatOnce(t, "openai", server.URL); err == nil || !strings.Contains(err.Error(), "HTTP 429") {
t.Fatalf("upstream status must be reported: %v", err)
}
okServer, _, _ := captureAIRequest(t, `{"error":{"message":"model not found"}}`, http.StatusOK)
_, err := chatOnce(t, "openai", okServer.URL)
if err == nil || !strings.Contains(err.Error(), "model not found") {
t.Fatalf("an error body with HTTP 200 must still fail: %v", err)
}
emptyServer, _, _ := captureAIRequest(t, `{"choices":[]}`, http.StatusOK)
if _, err = chatOnce(t, "openai", emptyServer.URL); err == nil {
t.Fatal("an empty choice list must fail rather than send an empty reply")
}
brokenServer, _, _ := captureAIRequest(t, `not json`, http.StatusOK)
if _, err = chatOnce(t, "openai", brokenServer.URL); err == nil {
t.Fatal("a non-JSON body must fail")
}
}
func TestParseAIBaseURL(t *testing.T) {
tests := []struct {
protocol string
endpoint string
production bool
valid bool
}{
{protocol: "openai", endpoint: "https://api.openai.com/v1", production: true, valid: true},
{protocol: "openai", endpoint: "http://127.0.0.1:11434/v1", production: false, valid: true},
{protocol: "openai", endpoint: "http://127.0.0.1:11434/v1", production: true, valid: false},
{protocol: "openai", endpoint: "https://user:pass@api.example.com/v1", production: true, valid: false},
{protocol: "openai", endpoint: "https://api.example.com/v1?key=secret", production: true, valid: false},
{protocol: "openai", endpoint: "", production: false, valid: false},
{protocol: "debug", endpoint: "", production: false, valid: true},
}
for _, test := range tests {
_, err := parseAIBaseURL(test.protocol, test.endpoint, test.production)
if (err == nil) != test.valid {
t.Errorf("parseAIBaseURL(%q, %q, production=%v) error = %v", test.protocol, test.endpoint, test.production, err)
}
}
}
func TestAIProviderFactoryGuards(t *testing.T) {
if _, err := newAIProvider(testModel("debug", ""), true); err == nil {
t.Fatal("production must refuse the debug protocol")
}
if _, err := newAIProvider(testModel("unknown", "https://example.com"), false); err == nil {
t.Fatal("unknown protocols must be refused")
}
missingKey := testModel("openai", "https://example.com")
missingKey.APIKey = ""
if _, err := newAIProvider(missingKey, false); err == nil {
t.Fatal("a missing key must be refused before the request is made")
}
// A webhook may be protected by network rules instead of a key.
if _, err := newAIProvider(func() aiModel { m := testModel("webhook", "https://example.com/hook"); m.APIKey = ""; return m }(), false); err != nil {
t.Fatalf("webhook without a key must be allowed: %v", err)
}
}
func TestDebugProviderIsDeterministicAndOffline(t *testing.T) {
provider, err := newAIProvider(testModel("debug", ""), false)
if err != nil {
t.Fatalf("debug provider unavailable: %v", err)
}
request := aiChatRequest{Messages: []aiChatMessage{{Role: "user", Text: "今天天气怎么样呀在不在"}}}
first, err := provider.Chat(context.Background(), request)
if err != nil {
t.Fatalf("debug chat failed: %v", err)
}
second, _ := provider.Chat(context.Background(), request)
if first.Text != second.Text || first.Text == "" {
t.Fatalf("debug replies must be deterministic: %q vs %q", first.Text, second.Text)
}
if empty, _ := provider.Chat(context.Background(), aiChatRequest{}); empty.Text == "" {
t.Fatal("debug provider must answer even without history")
}
}