feat(ai): 支持 MCP 少量查数与分服务商思考强度,修复 DeepSeek Anthropic

- MCP HTTP 默认可注册 execute_sql,行数默认 50/上限 200,设置页可切换仅结构模式
- 思考强度按 OpenAI/Anthropic/DeepSeek/Gemini 体系映射档位与请求参数
- 自定义端点 DeepSeek Anthropic 自动纠正 base 路径并回传 thinking 块
- 补充前后端回归测试与多语言文案
This commit is contained in:
Syngnat
2026-07-09 11:34:38 +08:00
parent f930ffe153
commit 4fcd7ff61f
29 changed files with 1129 additions and 56 deletions

View File

@@ -24,6 +24,7 @@ func normalizeAnthropicMessagesURL(baseURL string) string {
if url == "" {
url = defaultAnthropicBaseURL
}
url = normalizeDeepSeekAnthropicBaseURL(url)
if strings.HasSuffix(url, "/messages") {
return url
}
@@ -33,6 +34,46 @@ func normalizeAnthropicMessagesURL(baseURL string) string {
return url + "/v1/messages"
}
// normalizeDeepSeekAnthropicBaseURL 将 DeepSeek 自定义端点纠正为官方 Anthropic 兼容 base。
// 用户常填 https://api.deepseek.comOpenAI 路径Anthropic 格式必须使用 .../anthropic。
func normalizeDeepSeekAnthropicBaseURL(baseURL string) string {
raw := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if raw == "" {
return raw
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return raw
}
host := strings.ToLower(parsed.Hostname())
if host != "api.deepseek.com" && !strings.HasSuffix(host, ".deepseek.com") {
return raw
}
path := strings.TrimRight(parsed.Path, "/")
lowerPath := strings.ToLower(path)
if strings.Contains(lowerPath, "/anthropic") {
return raw
}
// OpenAI 兼容路径 /v1 不能直接拼 Anthropic messages
if lowerPath == "/v1" || strings.HasSuffix(lowerPath, "/v1") {
parsed.Path = "/anthropic"
} else if path == "" || path == "/" {
parsed.Path = "/anthropic"
} else {
parsed.Path = path + "/anthropic"
}
return strings.TrimRight(parsed.String(), "/")
}
func isDeepSeekHost(baseURL string) bool {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return strings.Contains(strings.ToLower(baseURL), "deepseek")
}
host := strings.ToLower(parsed.Hostname())
return host == "api.deepseek.com" || strings.HasSuffix(host, ".deepseek.com") || strings.Contains(host, "deepseek")
}
func IsDashScopeAnthropicCompatibleBaseURL(baseURL string) bool {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
@@ -65,6 +106,7 @@ func NewAnthropicProvider(config ai.ProviderConfig) (Provider, error) {
if baseURL == "" {
baseURL = defaultAnthropicBaseURL
}
baseURL = normalizeDeepSeekAnthropicBaseURL(baseURL)
model := strings.TrimSpace(config.Model)
if model == "" {
return nil, fmt.Errorf("model ID is required; select or enter a model in Settings")
@@ -83,6 +125,8 @@ func NewAnthropicProvider(config ai.ProviderConfig) (Provider, error) {
normalized.Model = model
normalized.MaxTokens = maxTokens
normalized.Temperature = temperature
profile := ResolveThinkingProfile(config.Type, config.APIFormat, baseURL, model)
normalized.ThinkingIntensity = string(clampThinkingIntensityToProfile(config.ThinkingIntensity, profile))
return &AnthropicProvider{
config: normalized,
@@ -108,13 +152,27 @@ func (p *AnthropicProvider) Validate() error {
// --- 请求体类型 ---
type anthropicRequest struct {
Model string `json:"model"`
Messages []anthropicMessage `json:"messages"`
System string `json:"system,omitempty"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Model string `json:"model"`
Messages []anthropicMessage `json:"messages"`
System string `json:"system,omitempty"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Thinking *anthropicThinking `json:"thinking,omitempty"`
OutputConfig *anthropicOutputConfig `json:"output_config,omitempty"`
}
// anthropicThinking Anthropic / DeepSeek Anthropic 兼容思考开关。
// DeepSeek 会忽略 budget_tokens但仍需 type=enabled。
type anthropicThinking struct {
Type string `json:"type"`
BudgetTokens int `json:"budget_tokens,omitempty"`
}
// anthropicOutputConfig DeepSeek Anthropic 兼容 effort 控制。
type anthropicOutputConfig struct {
Effort string `json:"effort,omitempty"`
}
// anthropicTool Anthropic 格式的工具定义
@@ -146,6 +204,10 @@ func convertToolsToAnthropic(tools []ai.Tool) []anthropicTool {
}
func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage {
return buildAnthropicMessagesWithOptions(reqMessages, false)
}
func buildAnthropicMessagesWithOptions(reqMessages []ai.Message, includeThinkingBlocks bool) []anthropicMessage {
messages := make([]anthropicMessage, 0, len(reqMessages))
for _, m := range reqMessages {
// tool result 消息:转换为 Anthropic 的 tool_result content block
@@ -166,6 +228,13 @@ func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage {
// assistant 带 tool_calls转换为 Anthropic 的 tool_use content block
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
var contentParts []map[string]interface{}
// DeepSeek Anthropic 思考模式下,后续轮次必须回传 thinking 块,否则 400。
if includeThinkingBlocks && strings.TrimSpace(m.ReasoningContent) != "" {
contentParts = append(contentParts, map[string]interface{}{
"type": "thinking",
"thinking": m.ReasoningContent,
})
}
if m.Content != "" {
contentParts = append(contentParts, map[string]interface{}{
"type": "text",
@@ -188,6 +257,23 @@ func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage {
continue
}
// assistant 纯文本但带思考内容(多轮回传)
if m.Role == "assistant" && includeThinkingBlocks && strings.TrimSpace(m.ReasoningContent) != "" {
var contentParts []map[string]interface{}
contentParts = append(contentParts, map[string]interface{}{
"type": "thinking",
"thinking": m.ReasoningContent,
})
if m.Content != "" {
contentParts = append(contentParts, map[string]interface{}{
"type": "text",
"text": m.Content,
})
}
messages = append(messages, anthropicMessage{Role: "assistant", Content: contentParts})
continue
}
// 图片消息
if len(m.Images) > 0 {
var contentParts []map[string]interface{}
@@ -223,11 +309,12 @@ func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage {
// --- 响应体类型 ---
type anthropicContentBlock struct {
Type string `json:"type"` // "text" | "tool_use"
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"` // tool_use
Name string `json:"name,omitempty"` // tool_use
Input json.RawMessage `json:"input,omitempty"` // tool_use
Type string `json:"type"` // "text" | "tool_use" | "thinking"
Text string `json:"text,omitempty"`
Thinking string `json:"thinking,omitempty"` // thinking block
ID string `json:"id,omitempty"` // tool_use
Name string `json:"name,omitempty"` // tool_use
Input json.RawMessage `json:"input,omitempty"` // tool_use
}
type anthropicResponse struct {
@@ -249,10 +336,47 @@ type anthropicStreamEvent struct {
Delta *struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
Thinking string `json:"thinking,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
} `json:"delta,omitempty"`
}
func (p *AnthropicProvider) shouldReplayThinkingBlocks() bool {
// DeepSeek Anthropic 思考链路要求历史 assistant 消息带回 thinking 块。
return isDeepSeekHost(p.baseURL) || strings.Contains(strings.ToLower(p.config.Model), "deepseek")
}
func (p *AnthropicProvider) applyThinkingToRequest(body *anthropicRequest) {
if body == nil {
return
}
profile := ResolveThinkingProfile(p.config.Type, p.config.APIFormat, p.baseURL, p.config.Model)
intensity := clampThinkingIntensityToProfile(p.config.ThinkingIntensity, profile)
// 空值:不强制改写请求,保持供应商默认
if intensity == "" {
return
}
if intensity == ai.ThinkingIntensityOff || intensity == ai.ThinkingIntensity("none") {
// DeepSeek 支持 disabled官方 Anthropic 省略 thinking 更稳妥
if profile == ThinkingProfileDeepSeek {
body.Thinking = &anthropicThinking{Type: "disabled"}
} else {
body.Thinking = nil
}
body.OutputConfig = nil
return
}
// DeepSeek Anthropictype=enabled + effortbudget 会被忽略)
// 官方 Anthropicbudget_tokens + effort新模型可用 adaptive/effort
body.Thinking = &anthropicThinking{
Type: "enabled",
BudgetTokens: anthropicThinkingBudgetTokens(intensity),
}
if effort := anthropicOutputEffort(intensity); effort != "" {
body.OutputConfig = &anthropicOutputConfig{Effort: effort}
}
}
// --- Chat 非流式 ---
func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
@@ -262,7 +386,7 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
systemMsg, messages := extractSystemMessage(req.Messages)
messages = applyImageFallbackPrompt(messages, req.ImageFallbackPrompt)
anthropicMsgs := buildAnthropicMessages(messages)
anthropicMsgs := buildAnthropicMessagesWithOptions(messages, p.shouldReplayThinkingBlocks())
temperature := req.Temperature
if temperature <= 0 {
@@ -281,6 +405,7 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
Temperature: temperature,
Tools: convertToolsToAnthropic(req.Tools),
}
p.applyThinkingToRequest(&body)
respBody, err := p.doRequest(ctx, body)
if err != nil {
@@ -307,13 +432,16 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
return nil, fmt.Errorf("Anthropic returned empty response")
}
// 解析响应中的 text tool_use content blocks
// 解析响应中的 text / thinking / tool_use content blocks
var textContent string
var reasoningContent string
var toolCalls []ai.ToolCall
for _, block := range result.Content {
switch block.Type {
case "text":
textContent += block.Text
case "thinking":
reasoningContent += block.Thinking
case "tool_use":
argsStr := "{}"
if len(block.Input) > 0 {
@@ -331,8 +459,9 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
}
return &ai.ChatResponse{
Content: textContent,
ToolCalls: toolCalls,
Content: textContent,
ReasoningContent: reasoningContent,
ToolCalls: toolCalls,
TokensUsed: ai.TokenUsage{
PromptTokens: result.Usage.InputTokens,
CompletionTokens: result.Usage.OutputTokens,
@@ -350,7 +479,7 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
systemMsg, messages := extractSystemMessage(req.Messages)
messages = applyImageFallbackPrompt(messages, req.ImageFallbackPrompt)
anthropicMsgs := buildAnthropicMessages(messages)
anthropicMsgs := buildAnthropicMessagesWithOptions(messages, p.shouldReplayThinkingBlocks())
temperature := req.Temperature
if temperature <= 0 {
@@ -370,6 +499,7 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
Stream: true,
Tools: convertToolsToAnthropic(req.Tools),
}
p.applyThinkingToRequest(&body)
respBody, err := p.doRequest(ctx, body)
if err != nil {
@@ -424,6 +554,13 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
if event.Delta.Text != "" {
callback(ai.StreamChunk{Content: event.Delta.Text})
}
case "thinking_delta":
if event.Delta.Thinking != "" {
callback(ai.StreamChunk{
Thinking: event.Delta.Thinking,
ReasoningContent: event.Delta.Thinking,
})
}
case "input_json_delta":
if block, ok := activeBlocks[event.Index]; ok {
block.argsJSON.WriteString(event.Delta.PartialJSON)

View File

@@ -26,6 +26,105 @@ func TestNormalizeAnthropicMessagesURL_UsesMoonshotAnthropicMessagesEndpoint(t *
}
}
func TestNormalizeAnthropicMessagesURL_DeepSeekCustomEndpointUsesAnthropicPath(t *testing.T) {
cases := map[string]string{
"https://api.deepseek.com": "https://api.deepseek.com/anthropic/v1/messages",
"https://api.deepseek.com/": "https://api.deepseek.com/anthropic/v1/messages",
"https://api.deepseek.com/v1": "https://api.deepseek.com/anthropic/v1/messages",
"https://api.deepseek.com/anthropic": "https://api.deepseek.com/anthropic/v1/messages",
"https://api.deepseek.com/anthropic/v1": "https://api.deepseek.com/anthropic/v1/messages",
"https://api.deepseek.com/anthropic/v1/messages": "https://api.deepseek.com/anthropic/v1/messages",
}
for input, want := range cases {
if got := normalizeAnthropicMessagesURL(input); got != want {
t.Fatalf("normalizeAnthropicMessagesURL(%q)=%q, want %q", input, got, want)
}
}
}
func TestBuildAnthropicMessagesReplaysThinkingBlocksForDeepSeek(t *testing.T) {
msgs := buildAnthropicMessagesWithOptions([]ai.Message{{
Role: "assistant",
Content: "done",
ReasoningContent: "step by step",
ToolCalls: []ai.ToolCall{{
ID: "call_1",
Type: "function",
Function: ai.ToolCallFunction{
Name: "get_tables",
Arguments: `{"connectionId":"c1"}`,
},
}},
}}, true)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
parts, ok := msgs[0].Content.([]map[string]interface{})
if !ok || len(parts) < 2 {
t.Fatalf("expected multi-part content, got %#v", msgs[0].Content)
}
if parts[0]["type"] != "thinking" || parts[0]["thinking"] != "step by step" {
t.Fatalf("expected leading thinking block, got %#v", parts[0])
}
}
func TestAnthropicProviderAppliesThinkingIntensity(t *testing.T) {
providerInstance, err := NewAnthropicProvider(ai.ProviderConfig{
Type: "custom",
Name: "deepseek-anthropic",
APIKey: "sk-test",
BaseURL: "https://api.deepseek.com",
Model: "deepseek-chat",
MaxTokens: 128,
Temperature: 0.2,
ThinkingIntensity: "high",
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
p := providerInstance.(*AnthropicProvider)
if p.baseURL != "https://api.deepseek.com/anthropic" {
t.Fatalf("expected deepseek base normalized with /anthropic, got %q", p.baseURL)
}
body := anthropicRequest{Model: p.config.Model, MaxTokens: 128}
p.applyThinkingToRequest(&body)
if body.Thinking == nil || body.Thinking.Type != "enabled" || body.Thinking.BudgetTokens != 16000 {
t.Fatalf("expected high thinking config, got %#v", body.Thinking)
}
if body.OutputConfig == nil || body.OutputConfig.Effort != "high" {
t.Fatalf("expected high effort, got %#v", body.OutputConfig)
}
// DeepSeek 档位集不含 xhigh应钳制到 high
p.config.ThinkingIntensity = "xhigh"
body = anthropicRequest{Model: p.config.Model, MaxTokens: 128}
p.applyThinkingToRequest(&body)
if body.OutputConfig == nil || body.OutputConfig.Effort != "high" {
t.Fatalf("expected deepseek to clamp xhigh to high, got %#v", body.OutputConfig)
}
// 官方 Anthropic 应保留 xhigh
official, err := NewAnthropicProvider(ai.ProviderConfig{
Type: "anthropic",
Name: "claude",
APIKey: "sk-test",
BaseURL: "https://api.anthropic.com",
Model: "claude-opus-4",
MaxTokens: 128,
Temperature: 0.2,
ThinkingIntensity: "xhigh",
})
if err != nil {
t.Fatalf("create official anthropic provider failed: %v", err)
}
op := official.(*AnthropicProvider)
body = anthropicRequest{Model: op.config.Model, MaxTokens: 128}
op.applyThinkingToRequest(&body)
if body.OutputConfig == nil || body.OutputConfig.Effort != "xhigh" {
t.Fatalf("expected anthropic xhigh effort, got %#v", body.OutputConfig)
}
}
func TestNormalizeAnthropicMessagesURL_PreservesExplicitMessagesPath(t *testing.T) {
url := normalizeAnthropicMessagesURL("https://api.moonshot.cn/anthropic/v1/messages")
if url != "https://api.moonshot.cn/anthropic/v1/messages" {

View File

@@ -48,6 +48,8 @@ func NewGeminiProvider(config ai.ProviderConfig) (Provider, error) {
normalized.Model = model
normalized.MaxTokens = maxTokens
normalized.Temperature = temperature
profile := ResolveThinkingProfile(config.Type, config.APIFormat, baseURL, model)
normalized.ThinkingIntensity = string(clampThinkingIntensityToProfile(config.ThinkingIntensity, profile))
return &GeminiProvider{
config: normalized,
@@ -92,8 +94,43 @@ type geminiBlob struct {
}
type geminiGenConfig struct {
Temperature float64 `json:"temperature,omitempty"`
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
ThinkingConfig *geminiThinkingConfig `json:"thinkingConfig,omitempty"`
}
// geminiThinkingConfig 兼容 Gemini 2.5 budget 与 Gemini 3 thinking_level。
type geminiThinkingConfig struct {
ThinkingBudget *int `json:"thinkingBudget,omitempty"`
ThinkingLevel string `json:"thinkingLevel,omitempty"`
}
func (p *GeminiProvider) applyThinkingToGenConfig(cfg *geminiGenConfig) {
if cfg == nil {
return
}
intensity := NormalizeThinkingIntensity(p.config.ThinkingIntensity)
if intensity == "" {
return
}
level := geminiThinkingLevel(intensity)
budget := geminiThinkingBudget(intensity)
thinking := &geminiThinkingConfig{}
if intensity == ai.ThinkingIntensityOff || intensity == ai.ThinkingIntensity("none") {
zero := 0
thinking.ThinkingBudget = &zero
cfg.ThinkingConfig = thinking
return
}
if level != "" {
thinking.ThinkingLevel = strings.ToUpper(level)
}
// 同时带 budget兼容仍识别 thinkingBudget 的 2.5 端点
if budget >= 0 {
b := budget
thinking.ThinkingBudget = &b
}
cfg.ThinkingConfig = thinking
}
type geminiResponse struct {
@@ -251,12 +288,19 @@ func (p *GeminiProvider) buildRequest(req ai.ChatRequest) geminiRequest {
})
}
genCfg := geminiGenConfig{
Temperature: temperature,
MaxOutputTokens: p.config.MaxTokens,
}
if req.MaxTokens > 0 {
genCfg.MaxOutputTokens = req.MaxTokens
}
p.applyThinkingToGenConfig(&genCfg)
return geminiRequest{
Contents: contents,
SystemInstruction: systemInstruction,
GenerationConfig: geminiGenConfig{
Temperature: temperature,
},
GenerationConfig: genCfg,
}
}

View File

@@ -49,6 +49,8 @@ func NewOpenAIProvider(config ai.ProviderConfig) (Provider, error) {
normalized.Model = model
normalized.MaxTokens = maxTokens
normalized.Temperature = temperature
profile := ResolveThinkingProfile(config.Type, config.APIFormat, baseURL, model)
normalized.ThinkingIntensity = string(clampThinkingIntensityToProfile(config.ThinkingIntensity, profile))
return &OpenAIProvider{
config: normalized,
@@ -59,6 +61,29 @@ func NewOpenAIProvider(config ai.ProviderConfig) (Provider, error) {
}, nil
}
func (p *OpenAIProvider) applyThinkingToRequest(body *openAIChatRequest) {
if body == nil {
return
}
intensity := NormalizeThinkingIntensity(p.config.ThinkingIntensity)
if intensity == "" {
return
}
// DeepSeek OpenAI 兼容thinking type 开关effort 通过 Anthropic 格式更完整)
if shouldReplayReasoningContent(p.config.Model, p.baseURL) {
if intensity == ai.ThinkingIntensityOff || intensity == ai.ThinkingIntensity("none") {
body.Thinking = map[string]string{"type": "disabled"}
return
}
body.Thinking = map[string]string{"type": "enabled"}
return
}
// OpenAI / GPT 推理模型reasoning_effort = none|minimal|low|medium|high|xhigh
if effort := openAIReasoningEffort(intensity); effort != "" {
body.ReasoningEffort = effort
}
}
func (p *OpenAIProvider) Name() string {
if strings.TrimSpace(p.config.Name) != "" {
return p.config.Name
@@ -81,6 +106,10 @@ type openAIChatRequest struct {
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []ai.Tool `json:"tools,omitempty"`
// ReasoningEffort OpenAI GPT/o 系列none|minimal|low|medium|high|xhigh
ReasoningEffort string `json:"reasoning_effort,omitempty"`
// Thinking DeepSeek 等 OpenAI 兼容接口的思考开关:{"type":"enabled"|"disabled"}
Thinking map[string]string `json:"thinking,omitempty"`
}
type openAIChatMessage struct {
@@ -221,6 +250,7 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.Chat
Stream: false,
Tools: req.Tools,
}
p.applyThinkingToRequest(&body)
respBody, err := p.doRequest(ctx, body)
if err != nil {
@@ -275,6 +305,7 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal
Stream: true,
Tools: req.Tools,
}
p.applyThinkingToRequest(&body)
respBody, err := p.doRequest(ctx, body)
if err != nil {

View File

@@ -662,7 +662,7 @@ func TestProviderImageFallbackPromptUsesCatalogKeyAndNoRawChinese(t *testing.T)
},
{
path: "anthropic.go",
signature: "func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage {",
signature: "func buildAnthropicMessagesWithOptions(reqMessages []ai.Message, includeThinkingBlocks bool) []anthropicMessage {",
},
{
path: "gemini.go",

View File

@@ -0,0 +1,227 @@
package provider
import (
"strings"
"GoNavi-Wails/internal/ai"
)
// ThinkingProfile 描述不同服务商/协议下的思考档位体系。
type ThinkingProfile string
const (
ThinkingProfileOpenAI ThinkingProfile = "openai" // none/minimal/low/medium/high/xhigh
ThinkingProfileAnthropic ThinkingProfile = "anthropic" // off/low/medium/high/xhigh/max
ThinkingProfileDeepSeek ThinkingProfile = "deepseek" // off/low/medium/high
ThinkingProfileGemini ThinkingProfile = "gemini" // off/minimal/low/medium/high
ThinkingProfileGeneric ThinkingProfile = "generic" // off/low/medium/high
)
// NormalizeThinkingIntensity 保留服务商原生档位字面量,不做跨档压缩。
// 空值表示“未设置 / 走供应商默认”。
func NormalizeThinkingIntensity(raw string) ai.ThinkingIntensity {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "":
return ""
case "auto", "default":
return ""
case "off", "disabled", "false", "0":
return ai.ThinkingIntensityOff
case "none":
// OpenAI 用 none 表示不推理;与 off 同义但保留字面量供映射。
return ai.ThinkingIntensity("none")
case "minimal", "min":
return ai.ThinkingIntensity("minimal")
case "low", "light":
return ai.ThinkingIntensityLow
case "medium", "mid", "normal", "standard":
return ai.ThinkingIntensityMedium
case "high":
return ai.ThinkingIntensityHigh
case "xhigh", "extra_high", "extra-high", "xh":
return ai.ThinkingIntensity("xhigh")
case "max", "maximum", "heavy":
return ai.ThinkingIntensity("max")
default:
return ""
}
}
// ResolveThinkingProfile 根据供应商类型、API 格式、端点与模型选择档位体系。
func ResolveThinkingProfile(providerType, apiFormat, baseURL, model string) ThinkingProfile {
ptype := strings.ToLower(strings.TrimSpace(providerType))
format := strings.ToLower(strings.TrimSpace(apiFormat))
base := strings.ToLower(strings.TrimSpace(baseURL))
modelName := strings.ToLower(strings.TrimSpace(model))
if isDeepSeekHost(baseURL) || strings.Contains(base, "deepseek") || strings.Contains(modelName, "deepseek") {
return ThinkingProfileDeepSeek
}
if ptype == "gemini" || format == "gemini" || strings.Contains(base, "generativelanguage.googleapis.com") || strings.Contains(base, "googleapis.com/v1beta") {
return ThinkingProfileGemini
}
if ptype == "anthropic" || format == "anthropic" {
return ThinkingProfileAnthropic
}
if ptype == "openai" || format == "" || format == "openai" {
return ThinkingProfileOpenAI
}
return ThinkingProfileGeneric
}
// ThinkingIntensityOptions 返回某 profile 支持的档位UI / 校验用)。
func ThinkingIntensityOptions(profile ThinkingProfile) []string {
switch profile {
case ThinkingProfileOpenAI:
return []string{"none", "minimal", "low", "medium", "high", "xhigh"}
case ThinkingProfileAnthropic:
return []string{"off", "low", "medium", "high", "xhigh", "max"}
case ThinkingProfileDeepSeek:
return []string{"off", "low", "medium", "high"}
case ThinkingProfileGemini:
return []string{"off", "minimal", "low", "medium", "high"}
default:
return []string{"off", "low", "medium", "high"}
}
}
func clampThinkingIntensityToProfile(raw string, profile ThinkingProfile) ai.ThinkingIntensity {
intensity := NormalizeThinkingIntensity(raw)
if intensity == "" {
return ""
}
// 统一 off/none
normalized := string(intensity)
if intensity == ai.ThinkingIntensityOff {
if profile == ThinkingProfileOpenAI {
normalized = "none"
} else {
normalized = "off"
}
}
if intensity == ai.ThinkingIntensity("none") && profile != ThinkingProfileOpenAI {
normalized = "off"
}
allowed := ThinkingIntensityOptions(profile)
for _, opt := range allowed {
if opt == normalized {
return ai.ThinkingIntensity(normalized)
}
}
// 跨体系兜底:尽量落到最接近的可用档位
switch normalized {
case "xhigh", "max":
if profile == ThinkingProfileOpenAI {
return ai.ThinkingIntensity("xhigh")
}
if profile == ThinkingProfileAnthropic && normalized == "max" {
// anthropic 支持 max此处仅当 max 不在 allowed 时才会进入(理论上不会)
return ai.ThinkingIntensity("max")
}
return ai.ThinkingIntensityHigh
case "minimal":
if profile == ThinkingProfileGemini || profile == ThinkingProfileOpenAI {
return ai.ThinkingIntensity("minimal")
}
return ai.ThinkingIntensityLow
case "none", "off":
if profile == ThinkingProfileOpenAI {
return ai.ThinkingIntensity("none")
}
return ai.ThinkingIntensityOff
default:
return ai.ThinkingIntensityMedium
}
}
func anthropicThinkingBudgetTokens(intensity ai.ThinkingIntensity) int {
switch intensity {
case ai.ThinkingIntensityLow, ai.ThinkingIntensity("minimal"):
return 1024
case ai.ThinkingIntensityHigh:
return 16000
case ai.ThinkingIntensity("xhigh"):
return 32000
case ai.ThinkingIntensity("max"):
return 64000
default:
return 8192
}
}
// anthropicOutputEffort 映射到 Anthropic/DeepSeek Anthropic 的 effort 字面量。
func anthropicOutputEffort(intensity ai.ThinkingIntensity) string {
switch intensity {
case ai.ThinkingIntensityOff, ai.ThinkingIntensity("none"), "":
return ""
case ai.ThinkingIntensityLow, ai.ThinkingIntensity("minimal"):
return "low"
case ai.ThinkingIntensityMedium:
return "medium"
case ai.ThinkingIntensityHigh:
return "high"
case ai.ThinkingIntensity("xhigh"):
return "xhigh"
case ai.ThinkingIntensity("max"):
return "max"
default:
return "medium"
}
}
// openAIReasoningEffort 映射到 OpenAI reasoning_effort。
func openAIReasoningEffort(intensity ai.ThinkingIntensity) string {
switch intensity {
case "", ai.ThinkingIntensityOff:
return ""
case ai.ThinkingIntensity("none"):
return "none"
case ai.ThinkingIntensity("minimal"):
return "minimal"
case ai.ThinkingIntensityLow:
return "low"
case ai.ThinkingIntensityMedium:
return "medium"
case ai.ThinkingIntensityHigh:
return "high"
case ai.ThinkingIntensity("xhigh"), ai.ThinkingIntensity("max"):
return "xhigh"
default:
return ""
}
}
// geminiThinkingLevel 映射到 Gemini thinking_level。
func geminiThinkingLevel(intensity ai.ThinkingIntensity) string {
switch intensity {
case ai.ThinkingIntensityOff, ai.ThinkingIntensity("none"):
return ""
case ai.ThinkingIntensity("minimal"):
return "minimal"
case ai.ThinkingIntensityLow:
return "low"
case ai.ThinkingIntensityMedium:
return "medium"
case ai.ThinkingIntensityHigh, ai.ThinkingIntensity("xhigh"), ai.ThinkingIntensity("max"):
return "high"
default:
return ""
}
}
// geminiThinkingBudget 给 Gemini 2.5 系列 thinking_budget 兜底。
func geminiThinkingBudget(intensity ai.ThinkingIntensity) int {
switch intensity {
case ai.ThinkingIntensityOff, ai.ThinkingIntensity("none"):
return 0
case ai.ThinkingIntensity("minimal"), ai.ThinkingIntensityLow:
return 1024
case ai.ThinkingIntensityMedium:
return 8192
case ai.ThinkingIntensityHigh, ai.ThinkingIntensity("xhigh"), ai.ThinkingIntensity("max"):
return 24576
default:
return -1 // dynamic
}
}

View File

@@ -0,0 +1,69 @@
package provider
import (
"testing"
"GoNavi-Wails/internal/ai"
)
func TestNormalizeThinkingIntensityKeepsProviderNativeLevels(t *testing.T) {
cases := map[string]ai.ThinkingIntensity{
"": "",
"auto": "",
"off": ai.ThinkingIntensityOff,
"none": ai.ThinkingIntensity("none"),
"minimal": ai.ThinkingIntensity("minimal"),
"low": ai.ThinkingIntensityLow,
"MEDIUM": ai.ThinkingIntensityMedium,
"high": ai.ThinkingIntensityHigh,
"xhigh": ai.ThinkingIntensity("xhigh"),
"max": ai.ThinkingIntensity("max"),
}
for input, want := range cases {
if got := NormalizeThinkingIntensity(input); got != want {
t.Fatalf("NormalizeThinkingIntensity(%q)=%q, want %q", input, got, want)
}
}
}
func TestResolveThinkingProfile(t *testing.T) {
if got := ResolveThinkingProfile("openai", "", "https://api.openai.com/v1", "gpt-5.2"); got != ThinkingProfileOpenAI {
t.Fatalf("expected openai profile, got %q", got)
}
if got := ResolveThinkingProfile("custom", "anthropic", "https://api.deepseek.com", "deepseek-chat"); got != ThinkingProfileDeepSeek {
t.Fatalf("expected deepseek profile, got %q", got)
}
if got := ResolveThinkingProfile("anthropic", "", "https://api.anthropic.com", "claude-opus-4"); got != ThinkingProfileAnthropic {
t.Fatalf("expected anthropic profile, got %q", got)
}
if got := ResolveThinkingProfile("gemini", "", "https://generativelanguage.googleapis.com", "gemini-3-flash"); got != ThinkingProfileGemini {
t.Fatalf("expected gemini profile, got %q", got)
}
}
func TestOpenAIAndAnthropicEffortMapping(t *testing.T) {
if openAIReasoningEffort(ai.ThinkingIntensity("xhigh")) != "xhigh" {
t.Fatal("expected openai xhigh")
}
if openAIReasoningEffort(ai.ThinkingIntensity("none")) != "none" {
t.Fatal("expected openai none")
}
if anthropicOutputEffort(ai.ThinkingIntensity("max")) != "max" {
t.Fatal("expected anthropic max effort")
}
if anthropicOutputEffort(ai.ThinkingIntensity("xhigh")) != "xhigh" {
t.Fatal("expected anthropic xhigh effort")
}
}
func TestClampThinkingIntensityAcrossProfiles(t *testing.T) {
if got := clampThinkingIntensityToProfile("xhigh", ThinkingProfileDeepSeek); got != ai.ThinkingIntensityHigh {
t.Fatalf("deepseek should clamp xhigh to high, got %q", got)
}
if got := clampThinkingIntensityToProfile("off", ThinkingProfileOpenAI); got != ai.ThinkingIntensity("none") {
t.Fatalf("openai should map off to none, got %q", got)
}
if got := clampThinkingIntensityToProfile("max", ThinkingProfileOpenAI); got != ai.ThinkingIntensity("xhigh") {
t.Fatalf("openai should map max to xhigh, got %q", got)
}
}

View File

@@ -223,7 +223,8 @@ func normalizeInAppMCPHTTPOptions(options ai.MCPHTTPServerOptions, textLookup mc
Addr: addr,
Path: path,
Token: token,
SchemaOnly: true,
// 尊重调用方配置false 时注册 execute_sql用于查少量样例数据仍受 AI 安全控制与行数上限约束)。
SchemaOnly: options.SchemaOnly,
}, token, nil
}
@@ -375,7 +376,7 @@ func statusFromMCPHTTPOptions(options mcpHTTPProcessStartOptions, token string,
Addr: options.Addr,
Path: options.Path,
URL: buildMCPHTTPURL(options.Addr, options.Path),
SchemaOnly: true,
SchemaOnly: options.SchemaOnly,
Token: token,
AuthorizationHeader: "Bearer " + token,
StartedAt: time.Now().UnixMilli(),
@@ -397,7 +398,8 @@ func defaultMCPHTTPServerStatus(textLookup mcpHTTPTextLookup) ai.MCPHTTPServerSt
Addr: defaultMCPHTTPAddr,
Path: defaultMCPHTTPPath,
URL: buildMCPHTTPURL(defaultMCPHTTPAddr, defaultMCPHTTPPath),
SchemaOnly: true,
// 默认允许只读 execute_sql 查少量数据;仍可在启动时显式传 schemaOnly=true 关闭。
SchemaOnly: false,
Message: localizeMCPHTTPText(textLookup, "ai_settings.mcp_http.status.not_running", nil),
}
}

View File

@@ -94,11 +94,11 @@ func TestMCPHTTPServerLifecycleFromAIService(t *testing.T) {
if started.Path != "/mcp" {
t.Fatalf("expected normalized path /mcp, got %q", started.Path)
}
if !started.SchemaOnly {
t.Fatal("expected in-app MCP HTTP server to default to schema-only mode")
if started.SchemaOnly {
t.Fatal("expected in-app MCP HTTP server to default to execute_sql-enabled mode for limited data queries")
}
if !capturedOptions.SchemaOnly || capturedOptions.Token != started.Token {
t.Fatalf("expected process to receive schema-only and generated token, got %#v", capturedOptions)
if capturedOptions.SchemaOnly || capturedOptions.Token != started.Token {
t.Fatalf("expected process to receive schemaOnly=false and generated token, got %#v", capturedOptions)
}
if !strings.HasPrefix(started.Token, "gnv_") || started.AuthorizationHeader != "Bearer "+started.Token {
t.Fatalf("expected generated bearer token in status, got token=%q header=%q", started.Token, started.AuthorizationHeader)
@@ -160,8 +160,23 @@ func TestMCPHTTPServerStartUsesCustomAddrAndToken(t *testing.T) {
if started.Token != "gnv_custom_token" || started.AuthorizationHeader != "Bearer gnv_custom_token" {
t.Fatalf("expected custom bearer token in status, got token=%q header=%q", started.Token, started.AuthorizationHeader)
}
if !started.SchemaOnly || !capturedOptions.SchemaOnly {
t.Fatal("expected custom in-app MCP HTTP server to remain schema-only")
if started.SchemaOnly || capturedOptions.SchemaOnly {
t.Fatal("expected custom in-app MCP HTTP server to keep default execute_sql-enabled mode")
}
// 显式 schemaOnly=true 仍可关闭 execute_sql
service.Shutdown()
_, err = service.AIStartMCPHTTPServer(ai.MCPHTTPServerOptions{
Addr: "127.0.0.1:9124",
Path: "mcp",
Token: "gnv_schema_only",
SchemaOnly: true,
})
if err != nil {
t.Fatalf("AIStartMCPHTTPServer schema-only returned error: %v", err)
}
if !capturedOptions.SchemaOnly {
t.Fatalf("expected process to receive schemaOnly=true, got %#v", capturedOptions)
}
}

View File

@@ -78,6 +78,22 @@ type StreamChunk struct {
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
// ThinkingIntensity 控制模型思考/推理强度。
// 字面量按服务商差异保留,例如:
// - OpenAI: none | minimal | low | medium | high | xhigh
// - Anthropic: off | low | medium | high | xhigh | max
// - DeepSeek: off | low | medium | high
// - Gemini: off | minimal | low | medium | high
// 不同供应商再映射到 reasoning_effort / thinking / output_config.effort / thinking_level 等字段。
type ThinkingIntensity string
const (
ThinkingIntensityOff ThinkingIntensity = "off"
ThinkingIntensityLow ThinkingIntensity = "low"
ThinkingIntensityMedium ThinkingIntensity = "medium"
ThinkingIntensityHigh ThinkingIntensity = "high"
)
// ProviderConfig AI Provider 配置
type ProviderConfig struct {
ID string `json:"id"`
@@ -94,6 +110,8 @@ type ProviderConfig struct {
Headers map[string]string `json:"headers,omitempty"`
MaxTokens int `json:"maxTokens"`
Temperature float64 `json:"temperature"`
// ThinkingIntensity 可选off/low/medium/high。空值表示沿用供应商默认多数等价 medium
ThinkingIntensity string `json:"thinkingIntensity,omitempty"`
}
// UserPromptSettings 表示用户级自定义提示词配置

View File

@@ -83,7 +83,7 @@ func NewServerWithOptions(backend Backend, options ServerOptions) *mcp.Server {
if !options.SchemaOnly {
mcp.AddTool(server, &mcp.Tool{
Name: "execute_sql",
Description: "执行 SQL,支持多语句结果集。执行范围受 GoNavi AI 设置中的安全控制约束;命中允许范围内的 DML/DDL 等非只读语句时,仍必须显式传 allowMutating=true。",
Description: "执行 SQL 并返回少量结果行(默认每结果集最多 50 行,上限 200。适合探查样例数据不适合大批量导出。执行范围受 GoNavi AI 设置中的安全控制约束;命中允许范围内的 DML/DDL 等非只读语句时,仍必须显式传 allowMutating=true。",
}, service.ExecuteSQL)
}

View File

@@ -15,8 +15,9 @@ import (
)
const (
defaultMaxRowsPerResult = 200
maxRowsPerResultLimit = 1000
// 默认/上限刻意压低MCP 只用于「少量样例数据」探查,避免把大结果集灌进 Agent 上下文。
defaultMaxRowsPerResult = 50
maxRowsPerResultLimit = 200
redactedOpaqueTarget = "opaque-connection-string-configured"
)
@@ -56,7 +57,7 @@ type executeSQLArgs struct {
DBName string `json:"dbName,omitempty" jsonschema:"可选数据库/Schema 名称。为空时优先使用保存连接里的默认数据库"`
SQL string `json:"sql" jsonschema:"待执行的 SQL 文本,可以包含多条语句"`
AllowMutating bool `json:"allowMutating,omitempty" jsonschema:"当 SQL 包含当前 AI 安全控制允许范围内的 DDL/DML 等非只读语句时,必须显式设为 true"`
MaxRowsPerResult int `json:"maxRowsPerResult,omitempty" jsonschema:"每个结果集最多返回多少行。默认 200最大 1000"`
MaxRowsPerResult int `json:"maxRowsPerResult,omitempty" jsonschema:"每个结果集最多返回多少行。默认 50最大 200少量数据探查"`
}
type connectionDescriptor struct {