mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-03 20:38:16 +08:00
- 新增独立内联补全模型配置与服务端透传 - 优化 SQL 编辑器 Alt+\\ 触发、ghost 延续与对象位补全 - 引入基于已存查询和执行日志的 SQL 记忆补全 - 补充前后端本地化、快捷键与补全回归测试
499 lines
15 KiB
Go
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
|
|
}
|