Files
MyGoNavi/internal/ai/provider/provider_i18n_test.go
Syngnat 4fcd7ff61f feat(ai): 支持 MCP 少量查数与分服务商思考强度,修复 DeepSeek Anthropic
- MCP HTTP 默认可注册 execute_sql,行数默认 50/上限 200,设置页可切换仅结构模式
- 思考强度按 OpenAI/Anthropic/DeepSeek/Gemini 体系映射档位与请求参数
- 自定义端点 DeepSeek Anthropic 自动纠正 base 路径并回传 thinking 块
- 补充前后端回归测试与多语言文案
2026-07-09 11:34:38 +08:00

734 lines
22 KiB
Go

package provider
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"GoNavi-Wails/internal/ai"
"GoNavi-Wails/shared/i18n"
)
func providerFunctionSource(t *testing.T, path string, signature string) string {
t.Helper()
sourceBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
source := string(sourceBytes)
start := strings.Index(source, signature)
if start < 0 {
t.Fatalf("%s missing function signature %q", path, signature)
}
rest := source[start+len(signature):]
end := strings.Index(rest, "\nfunc ")
if end < 0 {
return source[start:]
}
return source[start : start+len(signature)+end]
}
func TestNewCustomProviderRequiresBaseURLInEnglish(t *testing.T) {
t.Parallel()
cases := []string{"", " \t"}
for _, baseURL := range cases {
_, err := NewCustomProvider(ai.ProviderConfig{
Type: "custom",
BaseURL: baseURL,
})
if err == nil {
t.Fatalf("expected error for BaseURL %q", baseURL)
}
if got, want := err.Error(), "custom provider Base URL is required"; got != want {
t.Fatalf("expected %q, got %q", want, got)
}
if strings.Contains(err.Error(), "自定义 Provider 必须指定 Base URL") {
t.Fatalf("expected english wrapper, got %q", err.Error())
}
}
}
func TestNewProviderUnsupportedTypePreservesRawTypeInEnglish(t *testing.T) {
t.Parallel()
rawType := "Custom-Raw/测试"
_, err := NewProvider(ai.ProviderConfig{Type: rawType})
if err == nil {
t.Fatal("expected unsupported type error")
}
if got, want := err.Error(), "unsupported AI provider type: "+rawType; got != want {
t.Fatalf("expected %q, got %q", want, got)
}
if strings.Contains(err.Error(), "不支持的 AI Provider 类型") {
t.Fatalf("expected english wrapper, got %q", err.Error())
}
}
func TestAnthropicAndGeminiSourceUseEnglishErrorWrappers(t *testing.T) {
t.Parallel()
checks := []struct {
path string
signature string
rawMessages []string
requiredTexts []string
}{
{
path: "anthropic.go",
signature: "func NewAnthropicProvider(config ai.ProviderConfig) (Provider, error) {",
rawMessages: []string{`"模型 ID 不能为空,请在设置中选择或输入模型"`},
requiredTexts: []string{
`"model ID is required; select or enter a model in Settings"`,
},
},
{
path: "anthropic.go",
signature: "func (p *AnthropicProvider) Validate() error {",
rawMessages: []string{`"API Key 不能为空"`},
requiredTexts: []string{
`"API key is required"`,
},
},
{
path: "anthropic.go",
signature: "func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {",
rawMessages: []string{`"解析 Anthropic 响应失败: %w"`, `"Anthropic API 错误: %s"`, `"Anthropic 返回空响应"`},
requiredTexts: []string{
`"parse Anthropic response failed: %w"`,
`"Anthropic API error: %s"`,
`"Anthropic returned empty response"`,
},
},
{
path: "anthropic.go",
signature: "func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) {",
rawMessages: []string{`"序列化请求失败: %w"`, `"创建 HTTP 请求失败: %w"`, `"发送请求到 %s 失败: %w"`, `"Anthropic API 返回错误 (HTTP %d): %s"`},
requiredTexts: []string{
`"serialize request failed: %w"`,
`"create HTTP request failed: %w"`,
`"request to %s failed: %w"`,
`"Anthropic API returned error (HTTP %d): %s"`,
},
},
{
path: "gemini.go",
signature: "func NewGeminiProvider(config ai.ProviderConfig) (Provider, error) {",
rawMessages: []string{`"模型 ID 不能为空,请在设置中选择或输入模型"`},
requiredTexts: []string{
`"model ID is required; select or enter a model in Settings"`,
},
},
{
path: "gemini.go",
signature: "func (p *GeminiProvider) Validate() error {",
rawMessages: []string{`"API Key 不能为空"`},
requiredTexts: []string{
`"API key is required"`,
},
},
{
path: "gemini.go",
signature: "func (p *GeminiProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {",
rawMessages: []string{`"解析 Gemini 响应失败: %w"`, `"Gemini API 错误: %s"`, `"Gemini 返回空响应"`},
requiredTexts: []string{
`"parse Gemini response failed: %w"`,
`"Gemini API error: %s"`,
`"Gemini returned empty response"`,
},
},
{
path: "gemini.go",
signature: "func (p *GeminiProvider) doRequest(ctx context.Context, url string, body interface{}) (io.ReadCloser, error) {",
rawMessages: []string{`"序列化请求失败: %w"`, `"创建 HTTP 请求失败: %w"`, `"发送请求到 Gemini 失败: %w"`, `"Gemini API 返回错误 (HTTP %d): %s"`},
requiredTexts: []string{
`"serialize request failed: %w"`,
`"create HTTP request failed: %w"`,
`"request to Gemini failed: %w"`,
`"Gemini API returned error (HTTP %d): %s"`,
},
},
}
for _, check := range checks {
functionSource := providerFunctionSource(t, check.path, check.signature)
for _, rawMessage := range check.rawMessages {
if strings.Contains(functionSource, rawMessage) {
t.Fatalf("%s still contains raw provider text %q", check.signature, rawMessage)
}
}
for _, requiredText := range check.requiredTexts {
if !strings.Contains(functionSource, requiredText) {
t.Fatalf("%s does not reference english wrapper %q", check.signature, requiredText)
}
}
}
}
func TestNewAnthropicProviderRequiresModelInEnglish(t *testing.T) {
t.Parallel()
_, err := NewAnthropicProvider(ai.ProviderConfig{Type: "anthropic", Model: " \t"})
if err == nil {
t.Fatal("expected missing model error")
}
if got, want := err.Error(), "model ID is required; select or enter a model in Settings"; got != want {
t.Fatalf("expected %q, got %q", want, got)
}
if strings.Contains(err.Error(), "模型 ID 不能为空") {
t.Fatalf("expected english wrapper, got %q", err.Error())
}
}
func TestAnthropicProviderValidateRequiresAPIKeyInEnglish(t *testing.T) {
t.Parallel()
providerInstance, err := NewAnthropicProvider(ai.ProviderConfig{
Type: "anthropic",
Model: "claude-test",
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
err = providerInstance.Validate()
if err == nil {
t.Fatal("expected missing api key error")
}
if got, want := err.Error(), "API key is required"; got != want {
t.Fatalf("expected %q, got %q", want, got)
}
if strings.Contains(err.Error(), "API Key 不能为空") {
t.Fatalf("expected english wrapper, got %q", err.Error())
}
}
func TestAnthropicProviderChatUsesEnglishErrorWrappers(t *testing.T) {
t.Parallel()
cases := []struct {
name string
body string
statusCode int
want string
notWant []string
}{
{
name: "parse_error",
body: `{`,
want: "parse Anthropic response failed:",
notWant: []string{"解析 Anthropic 响应失败"},
},
{
name: "api_error",
body: `{"error":{"message":"permission denied"}}`,
want: "Anthropic API error: permission denied",
notWant: []string{"Anthropic API 错误"},
},
{
name: "empty_response",
body: `{"content":[]}`,
want: "Anthropic returned empty response",
notWant: []string{"Anthropic 返回空响应"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.body))
}))
defer server.Close()
providerInstance, err := NewAnthropicProvider(ai.ProviderConfig{
Type: "anthropic",
Model: "claude-test",
APIKey: "sk-test",
BaseURL: server.URL,
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
_, err = providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
})
if err == nil {
t.Fatal("expected provider error")
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected %q in %q", tc.want, err.Error())
}
for _, fragment := range tc.notWant {
if strings.Contains(err.Error(), fragment) {
t.Fatalf("expected no chinese wrapper %q in %q", fragment, err.Error())
}
}
})
}
}
func TestNewGeminiProviderRequiresModelInEnglish(t *testing.T) {
t.Parallel()
_, err := NewGeminiProvider(ai.ProviderConfig{Type: "gemini", Model: " \t"})
if err == nil {
t.Fatal("expected missing model error")
}
if got, want := err.Error(), "model ID is required; select or enter a model in Settings"; got != want {
t.Fatalf("expected %q, got %q", want, got)
}
if strings.Contains(err.Error(), "模型 ID 不能为空") {
t.Fatalf("expected english wrapper, got %q", err.Error())
}
}
func TestGeminiProviderValidateRequiresAPIKeyInEnglish(t *testing.T) {
t.Parallel()
providerInstance, err := NewGeminiProvider(ai.ProviderConfig{
Type: "gemini",
Model: "gemini-test",
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
err = providerInstance.Validate()
if err == nil {
t.Fatal("expected missing api key error")
}
if got, want := err.Error(), "API key is required"; got != want {
t.Fatalf("expected %q, got %q", want, got)
}
if strings.Contains(err.Error(), "API Key 不能为空") {
t.Fatalf("expected english wrapper, got %q", err.Error())
}
}
func TestGeminiProviderChatUsesEnglishErrorWrappers(t *testing.T) {
t.Parallel()
cases := []struct {
name string
body string
want string
notWant []string
}{
{
name: "parse_error",
body: `{`,
want: "parse Gemini response failed:",
notWant: []string{"解析 Gemini 响应失败"},
},
{
name: "api_error",
body: `{"error":{"message":"permission denied"}}`,
want: "Gemini API error: permission denied",
notWant: []string{"Gemini API 错误"},
},
{
name: "empty_response",
body: `{"candidates":[]}`,
want: "Gemini returned empty response",
notWant: []string{"Gemini 返回空响应"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.body))
}))
defer server.Close()
providerInstance, err := NewGeminiProvider(ai.ProviderConfig{
Type: "gemini",
Model: "gemini-test",
APIKey: "sk-test",
BaseURL: server.URL,
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
_, err = providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
})
if err == nil {
t.Fatal("expected provider error")
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected %q in %q", tc.want, err.Error())
}
for _, fragment := range tc.notWant {
if strings.Contains(err.Error(), fragment) {
t.Fatalf("expected no chinese wrapper %q in %q", fragment, err.Error())
}
}
})
}
}
func TestOpenAISourceUsesEnglishErrorWrappers(t *testing.T) {
t.Parallel()
checks := []struct {
path string
signature string
rawMessages []string
requiredTexts []string
}{
{
path: "openai.go",
signature: "func NewOpenAIProvider(config ai.ProviderConfig) (Provider, error) {",
rawMessages: []string{`"模型 ID 不能为空,请在设置中选择或输入模型"`},
requiredTexts: []string{
`"model ID is required; select or enter a model in Settings"`,
},
},
{
path: "openai.go",
signature: "func (p *OpenAIProvider) Validate() error {",
rawMessages: []string{`"API Key 不能为空"`},
requiredTexts: []string{
`"API key is required"`,
},
},
{
path: "openai.go",
signature: "func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {",
rawMessages: []string{`"解析 OpenAI 响应失败: %w"`, `"OpenAI API 错误: %s"`, `"OpenAI 返回空响应"`},
requiredTexts: []string{
`"parse OpenAI response failed: %w"`,
`"OpenAI API error: %s"`,
`"OpenAI returned empty response"`,
},
},
{
path: "openai.go",
signature: "func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {",
rawMessages: []string{`"服务端返回异常: %s"`, `"API 错误: %s"`, `"读取 OpenAI 流式响应失败: %w"`, `"未收到任何有效响应内容,请检查 API 端点和模型是否正确"`},
requiredTexts: []string{
`"server returned abnormal response: %s"`,
`"API error: %s"`,
`"read OpenAI streaming response failed: %w"`,
`"no valid response content received; check the API endpoint and model configuration"`,
},
},
{
path: "openai.go",
signature: "func (p *OpenAIProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) {",
rawMessages: []string{`"序列化请求失败: %w"`, `"创建 HTTP 请求失败: %w"`, `"发送请求到 %s 失败: %w"`, `"OpenAI API 返回错误 (HTTP %d): %s"`},
requiredTexts: []string{
`"serialize request failed: %w"`,
`"create HTTP request failed: %w"`,
`"request to %s failed: %w"`,
`"OpenAI API returned error (HTTP %d): %s"`,
},
},
}
for _, check := range checks {
functionSource := providerFunctionSource(t, check.path, check.signature)
for _, rawMessage := range check.rawMessages {
if strings.Contains(functionSource, rawMessage) {
t.Fatalf("%s still contains raw provider text %q", check.signature, rawMessage)
}
}
for _, requiredText := range check.requiredTexts {
if !strings.Contains(functionSource, requiredText) {
t.Fatalf("%s does not reference english wrapper %q", check.signature, requiredText)
}
}
}
}
func TestNewOpenAIProviderRequiresModelInEnglish(t *testing.T) {
t.Parallel()
_, err := NewOpenAIProvider(ai.ProviderConfig{Type: "openai", Model: " \t"})
if err == nil {
t.Fatal("expected missing model error")
}
if got, want := err.Error(), "model ID is required; select or enter a model in Settings"; got != want {
t.Fatalf("expected %q, got %q", want, got)
}
if strings.Contains(err.Error(), "模型 ID 不能为空") {
t.Fatalf("expected english wrapper, got %q", err.Error())
}
}
func TestOpenAIProviderValidateRequiresAPIKeyInEnglish(t *testing.T) {
t.Parallel()
providerInstance, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai",
Model: "gpt-4o-mini",
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
err = providerInstance.Validate()
if err == nil {
t.Fatal("expected missing api key error")
}
if got, want := err.Error(), "API key is required"; got != want {
t.Fatalf("expected %q, got %q", want, got)
}
if strings.Contains(err.Error(), "API Key 不能为空") {
t.Fatalf("expected english wrapper, got %q", err.Error())
}
}
func TestOpenAIProviderChatUsesEnglishErrorWrappers(t *testing.T) {
t.Parallel()
cases := []struct {
name string
statusCode int
body string
want string
notWant []string
}{
{
name: "parse_error",
body: `{`,
want: "parse OpenAI response failed:",
notWant: []string{"解析 OpenAI 响应失败"},
},
{
name: "api_error",
body: `{"error":{"message":"permission denied"}}`,
want: "OpenAI API error: permission denied",
notWant: []string{"OpenAI API 错误"},
},
{
name: "empty_response",
body: `{"choices":[]}`,
want: "OpenAI returned empty response",
notWant: []string{"OpenAI 返回空响应"},
},
{
name: "http_status_error",
statusCode: http.StatusBadGateway,
body: `proxy upstream failed`,
want: "OpenAI API returned error (HTTP 502): proxy upstream failed",
notWant: []string{"OpenAI API 返回错误"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if tc.statusCode != 0 {
http.Error(w, tc.body, tc.statusCode)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.body))
}))
defer server.Close()
providerInstance, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai",
Model: "gpt-4o-mini",
APIKey: "sk-test",
BaseURL: server.URL,
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
_, err = providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
})
if err == nil {
t.Fatal("expected provider error")
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected %q in %q", tc.want, err.Error())
}
for _, fragment := range tc.notWant {
if strings.Contains(err.Error(), fragment) {
t.Fatalf("expected no chinese wrapper %q in %q", fragment, err.Error())
}
}
})
}
}
func TestOpenAIProviderChatStreamUsesEnglishErrorWrappers(t *testing.T) {
t.Parallel()
cases := []struct {
name string
handler http.HandlerFunc
want string
notWant []string
}{
{
name: "server_error_line",
handler: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("Error: upstream gateway failed"))
},
want: "server returned abnormal response: Error: upstream gateway failed",
notWant: []string{"服务端返回异常"},
},
{
name: "api_error",
handler: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"error\":{\"message\":\"permission denied\"}}\n\n"))
},
want: "API error: permission denied",
notWant: []string{"API 错误"},
},
{
name: "empty_response",
handler: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
},
want: "no valid response content received; check the API endpoint and model configuration",
notWant: []string{"未收到任何有效响应内容,请检查 API 端点和模型是否正确"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(tc.handler)
defer server.Close()
providerInstance, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai",
Model: "gpt-4o-mini",
APIKey: "sk-test",
BaseURL: server.URL,
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
var chunks []ai.StreamChunk
err = providerInstance.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected stream wrapper error, got %v", err)
}
if len(chunks) == 0 {
t.Fatal("expected at least one stream chunk")
}
if got := chunks[len(chunks)-1].Error; got != tc.want {
t.Fatalf("expected final error %q, got %q", tc.want, got)
}
if !chunks[len(chunks)-1].Done {
t.Fatalf("expected final chunk to mark done, got %#v", chunks[len(chunks)-1])
}
for _, fragment := range tc.notWant {
if strings.Contains(chunks[len(chunks)-1].Error, fragment) {
t.Fatalf("expected no chinese wrapper %q in %q", fragment, chunks[len(chunks)-1].Error)
}
}
})
}
}
func TestProviderImageFallbackPromptUsesCatalogKeyAndNoRawChinese(t *testing.T) {
t.Parallel()
for _, check := range []struct {
path string
signature string
}{
{
path: "openai.go",
signature: "func buildOpenAIMessages(reqMessages []ai.Message, modelName string, baseURL string) []openAIChatMessage {",
},
{
path: "anthropic.go",
signature: "func buildAnthropicMessagesWithOptions(reqMessages []ai.Message, includeThinkingBlocks bool) []anthropicMessage {",
},
{
path: "gemini.go",
signature: "func (p *GeminiProvider) buildRequest(req ai.ChatRequest) geminiRequest {",
},
} {
functionSource := providerFunctionSource(t, check.path, check.signature)
if strings.Contains(functionSource, "请描述和分析这张图片。") {
t.Fatalf("%s still contains raw image fallback prompt", check.signature)
}
if !strings.Contains(functionSource, "providerImageFallbackPrompt") {
t.Fatalf("%s does not use shared provider image fallback prompt helper", check.signature)
}
}
promptSourceBytes, err := os.ReadFile("prompt_i18n.go")
if err != nil {
t.Fatalf("read prompt_i18n.go: %v", err)
}
if !strings.Contains(string(promptSourceBytes), "ai_service.backend.provider.image_fallback_prompt") {
t.Fatalf("provider image fallback helper source does not reference catalog key")
}
const key = "ai_service.backend.provider.image_fallback_prompt"
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("load catalogs: %v", err)
}
for _, language := range i18n.SupportedLanguages() {
if strings.TrimSpace(catalogs[language][key]) == "" {
t.Fatalf("%s catalog missing provider image fallback prompt key %q", language, key)
}
}
}
func TestOpenAIImageOmittedNoticeUsesCatalogKeyAndNoRawChinese(t *testing.T) {
t.Parallel()
openAISource, err := os.ReadFile("openai.go")
if err != nil {
t.Fatalf("read openai.go: %v", err)
}
if strings.Contains(string(openAISource), "【图片已省略:当前模型或上游接口不支持图片输入,请切换支持视觉的模型后重新发送图片。】") {
t.Fatal("openai.go still contains raw Chinese omitted image notice")
}
if !strings.Contains(string(openAISource), "providerImageOmittedNotice") {
t.Fatal("openai.go does not use shared provider image omitted notice helper")
}
promptSourceBytes, err := os.ReadFile("prompt_i18n.go")
if err != nil {
t.Fatalf("read prompt_i18n.go: %v", err)
}
if !strings.Contains(string(promptSourceBytes), "ai_service.backend.provider.image_omitted_notice") {
t.Fatalf("provider image omitted notice helper source does not reference catalog key")
}
const key = "ai_service.backend.provider.image_omitted_notice"
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("load catalogs: %v", err)
}
for _, language := range i18n.SupportedLanguages() {
if strings.TrimSpace(catalogs[language][key]) == "" {
t.Fatalf("%s catalog missing provider image omitted notice key %q", language, key)
}
}
}