Files
MyGoNavi/internal/ai/provider/openai_test.go
Syngnat 29ee21e9d7 feat(query-editor): 增强 SQL 编辑器 AI 内联补全与独立模型配置
- 新增独立内联补全模型配置与服务端透传
- 优化 SQL 编辑器 Alt+\\ 触发、ghost 延续与对象位补全
- 引入基于已存查询和执行日志的 SQL 记忆补全
- 补充前后端本地化、快捷键与补全回归测试
2026-07-03 20:44:33 +08:00

499 lines
15 KiB
Go

package provider
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"GoNavi-Wails/internal/ai"
)
func TestNormalizeOpenAICompatibleBaseURL(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{
name: "empty uses default openai base url",
raw: "",
want: "https://api.openai.com/v1",
},
{
name: "domain only appends v1",
raw: "https://api.openai.com",
want: "https://api.openai.com/v1",
},
{
name: "keeps existing v1 suffix",
raw: "https://api.deepseek.com/v1",
want: "https://api.deepseek.com/v1",
},
{
name: "keeps dashscope compatible mode path",
raw: "https://dashscope.aliyuncs.com/compatible-mode/v1",
want: "https://dashscope.aliyuncs.com/compatible-mode/v1",
},
{
name: "keeps zhipu v4 path",
raw: "https://open.bigmodel.cn/api/paas/v4",
want: "https://open.bigmodel.cn/api/paas/v4",
},
{
name: "keeps volcengine ark v3 path",
raw: "https://ark.cn-beijing.volces.com/api/v3",
want: "https://ark.cn-beijing.volces.com/api/v3",
},
{
name: "keeps volcengine coding plan v3 path",
raw: "https://ark.cn-beijing.volces.com/api/coding/v3",
want: "https://ark.cn-beijing.volces.com/api/coding/v3",
},
{
name: "strips chat completions suffix before normalizing",
raw: "https://api.openai.com/v1/chat/completions",
want: "https://api.openai.com/v1",
},
{
name: "strips models suffix before normalizing",
raw: "https://ark.cn-beijing.volces.com/api/coding/v3/models",
want: "https://ark.cn-beijing.volces.com/api/coding/v3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NormalizeOpenAICompatibleBaseURL(tt.raw); got != tt.want {
t.Fatalf("expected normalized base url %q, got %q", tt.want, got)
}
})
}
}
func TestResolveOpenAICompatibleEndpoint(t *testing.T) {
got := ResolveOpenAICompatibleEndpoint("https://ark.cn-beijing.volces.com/api/coding/v3/models", "chat/completions")
want := "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions"
if got != want {
t.Fatalf("expected endpoint %q, got %q", want, got)
}
}
func TestOpenAIProvider_Validate_MissingAPIKey(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{Type: "openai", Model: "gpt-4o"})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
if err := p.Validate(); err == nil {
t.Fatal("expected validation error for missing API key")
}
}
func TestOpenAIProvider_Validate_Valid(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", APIKey: "sk-test-key", Model: "gpt-4o",
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
if err := p.Validate(); err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
}
func TestOpenAIProvider_Name_Custom(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", Name: "My OpenAI", APIKey: "sk-test", Model: "gpt-4o",
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
if p.Name() != "My OpenAI" {
t.Fatalf("expected name 'My OpenAI', got '%s'", p.Name())
}
}
func TestOpenAIProvider_Name_Default(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", APIKey: "sk-test", Model: "gpt-4o",
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
if p.Name() != "OpenAI" {
t.Fatalf("expected default name 'OpenAI', got '%s'", p.Name())
}
}
func TestOpenAIProvider_DefaultBaseURL(t *testing.T) {
p, _ := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", APIKey: "sk-test", Model: "gpt-4o",
})
op := p.(*OpenAIProvider)
if op.baseURL != "https://api.openai.com/v1" {
t.Fatalf("expected default base URL, got '%s'", op.baseURL)
}
}
func TestOpenAIProvider_CustomBaseURL(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", APIKey: "sk-test", BaseURL: "https://my-proxy.com/v1", Model: "gpt-4o",
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
op := p.(*OpenAIProvider)
if op.baseURL != "https://my-proxy.com/v1" {
t.Fatalf("expected custom base URL, got '%s'", op.baseURL)
}
}
func TestOpenAIProvider_RejectsMissingModel(t *testing.T) {
_, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", APIKey: "sk-test",
})
if err == nil {
t.Fatal("expected constructor error for missing model")
}
}
func TestOpenAIProvider_DefaultMaxTokens(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", APIKey: "sk-test", Model: "gpt-4o",
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
op := p.(*OpenAIProvider)
if op.config.MaxTokens != 4096 {
t.Fatalf("expected default max tokens 4096, got %d", op.config.MaxTokens)
}
}
func TestOpenAIProviderChatUsesRequestMaxTokens(t *testing.T) {
var received openAIChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
t.Fatalf("decode request body failed: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
providerInstance, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai",
APIKey: "sk-test",
BaseURL: server.URL,
Model: "gpt-chat",
MaxTokens: 4096,
Temperature: 0.7,
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
_, err = providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{
Role: "user",
Content: "ping",
}},
MaxTokens: 192,
Temperature: 0.1,
})
if err != nil {
t.Fatalf("chat failed: %v", err)
}
if received.MaxTokens != 192 {
t.Fatalf("expected request max_tokens 192, got %d", received.MaxTokens)
}
if received.Temperature != 0.1 {
t.Fatalf("expected request temperature 0.1, got %f", received.Temperature)
}
if received.Model != "gpt-chat" {
t.Fatalf("expected configured model, got %q", received.Model)
}
}
func TestOpenAIProviderChatRetriesWithoutImagesOnHTTP400(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body failed: %v", err)
}
defer r.Body.Close()
if strings.Contains(string(body), `"image_url"`) {
http.Error(w, `{"error":{"message":"Model do not support image input"}}`, http.StatusBadRequest)
return
}
if !strings.Contains(string(body), providerImageOmittedNotice("")) {
t.Fatalf("expected retry body to explain omitted image, got %s", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
providerInstance, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai",
Name: "test-openai",
APIKey: "sk-test",
BaseURL: server.URL,
Model: "custom-text-model",
MaxTokens: 64,
Temperature: 0.1,
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
resp, err := providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{
Role: "user",
Content: "请描述这张图片",
Images: []string{"data:image/png;base64,abc"},
}},
})
if err != nil {
t.Fatalf("expected chat image fallback to succeed, got %v", err)
}
if resp.Content != "pong" {
t.Fatalf("expected fallback content %q, got %q", "pong", resp.Content)
}
if requestCount != 2 {
t.Fatalf("expected 2 requests (with image then fallback), got %d", requestCount)
}
}
func TestOpenAIProviderChatOmitsImagesUpfrontForMiniMaxTextModel(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body failed: %v", err)
}
defer r.Body.Close()
bodyText := string(body)
if strings.Contains(bodyText, `"image_url"`) {
t.Fatalf("expected MiniMax text request to omit image_url, got %s", body)
}
if !strings.Contains(bodyText, providerImageOmittedNotice("")) {
t.Fatalf("expected request body to explain omitted image, got %s", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
providerInstance, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai",
Name: "test-openai",
APIKey: "sk-test",
BaseURL: server.URL,
Model: "MiniMax-M2.7-highspeed",
MaxTokens: 64,
Temperature: 0.1,
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
resp, err := providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{
Role: "user",
Content: "请描述这张图片",
Images: []string{"data:image/png;base64,abc"},
}},
})
if err != nil {
t.Fatalf("expected chat to succeed without sending image, got %v", err)
}
if resp.Content != "pong" {
t.Fatalf("expected content %q, got %q", "pong", resp.Content)
}
if requestCount != 1 {
t.Fatalf("expected 1 request without image retry, got %d", requestCount)
}
}
func TestPrepareOpenAIRequestMessagesKeepsImagesForVisionModel(t *testing.T) {
got := prepareOpenAIRequestMessages([]ai.Message{{
Role: "user",
Content: "请描述图片",
Images: []string{"data:image/png;base64,abc"},
}}, "gpt-5.4", "https://sub.syngnat.top/v1")
if len(got) != 1 || len(got[0].Images) != 1 {
t.Fatalf("expected vision-capable model to keep images, got %#v", got)
}
}
func TestOpenAIProviderChatStreamRetriesWithoutToolsThenImagesOnHTTP400(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body failed: %v", err)
}
defer r.Body.Close()
bodyText := string(body)
if strings.Contains(bodyText, `"tools"`) {
http.Error(w, `{"error":{"message":"A parameter specified in the request is not valid"}}`, http.StatusBadRequest)
return
}
if strings.Contains(bodyText, `"image_url"`) {
http.Error(w, `{"error":{"message":"A parameter specified in the request is not valid"}}`, http.StatusBadRequest)
return
}
if !strings.Contains(bodyText, providerImageOmittedNotice("")) {
t.Fatalf("expected retry body to explain omitted image, got %s", body)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(strings.Join([]string{
`data: {"choices":[{"delta":{"content":"pong"},"finish_reason":null}]}`,
``,
`data: [DONE]`,
``,
}, "\n")))
}))
defer server.Close()
providerInstance, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai",
Name: "test-openai",
APIKey: "sk-test",
BaseURL: server.URL,
Model: "custom-text-model",
MaxTokens: 64,
Temperature: 0.1,
})
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: "请描述这张图片",
Images: []string{"data:image/png;base64,abc"},
}},
Tools: []ai.Tool{{
Type: "function",
Function: ai.ToolFunction{
Name: "inspect_ai_last_render_error",
Description: "test tool",
Parameters: map[string]interface{}{"type": "object"},
},
}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected stream fallback to succeed, got %v", err)
}
if requestCount != 3 {
t.Fatalf("expected 3 requests (with tools, without tools, without images), got %d", requestCount)
}
if len(chunks) < 2 {
t.Fatalf("expected content and done chunks, got %#v", chunks)
}
if chunks[0].Content != "pong" {
t.Fatalf("expected first chunk content %q, got %#v", "pong", chunks[0])
}
if !chunks[len(chunks)-1].Done {
t.Fatalf("expected final done chunk, got %#v", chunks[len(chunks)-1])
}
}
func TestBuildOpenAIMessages_ReplaysDeepSeekReasoningContentForToolCalls(t *testing.T) {
toolCall := testOpenAIToolCall()
got := buildOpenAIMessages([]ai.Message{
{
Role: "assistant",
Content: "",
ToolCalls: []ai.ToolCall{toolCall},
ReasoningContent: "需要先检查表结构",
},
{
Role: "tool",
Content: `{"ok":true}`,
ToolCallID: toolCall.ID,
},
}, "deepseek-v4", "https://api.deepseek.com/v1")
if got[0].ReasoningContent != "需要先检查表结构" {
t.Fatalf("expected reasoning_content to be replayed for DeepSeek tool call, got %q", got[0].ReasoningContent)
}
if got[1].ReasoningContent != "" {
t.Fatalf("expected tool result message not to carry reasoning_content, got %q", got[1].ReasoningContent)
}
body, err := json.Marshal(got[0])
if err != nil {
t.Fatalf("marshal message: %v", err)
}
if !strings.Contains(string(body), `"reasoning_content":"需要先检查表结构"`) {
t.Fatalf("expected JSON payload to include reasoning_content, got %s", body)
}
}
func TestBuildOpenAIMessages_OmitsReasoningContentForNonDeepSeekProviders(t *testing.T) {
got := buildOpenAIMessages([]ai.Message{
{
Role: "assistant",
Content: "",
ToolCalls: []ai.ToolCall{testOpenAIToolCall()},
ReasoningContent: "reasoning should stay local",
},
}, "gpt-4o", "https://api.openai.com/v1")
if got[0].ReasoningContent != "" {
t.Fatalf("expected non-DeepSeek provider to omit reasoning_content, got %q", got[0].ReasoningContent)
}
body, err := json.Marshal(got[0])
if err != nil {
t.Fatalf("marshal message: %v", err)
}
if strings.Contains(string(body), "reasoning_content") {
t.Fatalf("expected JSON payload to omit reasoning_content for non-DeepSeek provider, got %s", body)
}
}
func TestBuildOpenAIMessages_ReplaysDeepSeekAssistantReasoningContentWithoutToolCalls(t *testing.T) {
got := buildOpenAIMessages([]ai.Message{
{
Role: "assistant",
Content: "最终分析",
ReasoningContent: "工具调用轮次的最终思考也需要保留",
},
}, "deepseek-v4", "https://api.deepseek.com/v1")
if got[0].ReasoningContent != "工具调用轮次的最终思考也需要保留" {
t.Fatalf("expected DeepSeek assistant reasoning_content to be replayed, got %q", got[0].ReasoningContent)
}
}
func testOpenAIToolCall() ai.ToolCall {
var toolCall ai.ToolCall
toolCall.ID = "call_schema"
toolCall.Type = "function"
toolCall.Function.Name = "inspect_table_schema"
toolCall.Function.Arguments = `{"table":"orders"}`
return toolCall
}