mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-20 04:11:56 +08:00
✨ feat(ai): 支持 MCP 少量查数与分服务商思考强度,修复 DeepSeek Anthropic
- MCP HTTP 默认可注册 execute_sql,行数默认 50/上限 200,设置页可切换仅结构模式 - 思考强度按 OpenAI/Anthropic/DeepSeek/Gemini 体系映射档位与请求参数 - 自定义端点 DeepSeek Anthropic 自动纠正 base 路径并回传 thinking 块 - 补充前后端回归测试与多语言文案
This commit is contained in:
@@ -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.com(OpenAI 路径),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 Anthropic:type=enabled + effort(budget 会被忽略)
|
||||
// 官方 Anthropic:budget_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)
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
227
internal/ai/provider/thinking_intensity.go
Normal file
227
internal/ai/provider/thinking_intensity.go
Normal 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
|
||||
}
|
||||
}
|
||||
69
internal/ai/provider/thinking_intensity_test.go
Normal file
69
internal/ai/provider/thinking_intensity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 表示用户级自定义提示词配置
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user