- Agent 支持 SSE(步骤边推、正文分块),JSON 非流式 fallback - tool calling 覆盖 openai-chat / anthropic / responses(单测解析回灌) - P1:mcp.json mode=read_write 才可写;需 confirm_danger;Agent 剥离模型自带令牌 - GET /api/ai/mcp-status、POST /api/ai/agent/confirm;审计 source=mcp|agent
293 lines
7.8 KiB
Go
293 lines
7.8 KiB
Go
package ai
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultOpenAIBaseURL = "https://api.openai.com/v1"
|
|
defaultOpenAIModel = "gpt-4o-mini"
|
|
remoteMaxTokens = 2048
|
|
)
|
|
|
|
// 协议:openai-chat 覆盖绝大多数兼容服务;openai-responses 为 OpenAI 官方;anthropic 为 Claude。
|
|
const (
|
|
ProtoChat = "openai-chat"
|
|
ProtoResponses = "openai-responses"
|
|
ProtoAnthropic = "anthropic"
|
|
)
|
|
|
|
// 鉴权风格:bearer = Authorization: Bearer;x-api-key = Anthropic 风格头;none = 本地服务免鉴权。
|
|
const (
|
|
AuthBearer = "bearer"
|
|
AuthXAPIKey = "x-api-key"
|
|
AuthNone = "none"
|
|
)
|
|
|
|
// RemoteProvider 远端 HTTP 模型实现,按协议分派 chat/responses/anthropic(对齐 opsxshell AiService)。
|
|
type RemoteProvider struct {
|
|
protocol string
|
|
authStyle string
|
|
baseURL string
|
|
apiKey string
|
|
model string
|
|
allowHTTP bool
|
|
hc *http.Client
|
|
}
|
|
|
|
// NewRemoteProvider 按配置构造;protocol 缺省按 provider 推断(anthropic → 原生协议)。
|
|
func NewRemoteProvider(cfg Config) *RemoteProvider {
|
|
protocol := strings.TrimSpace(cfg.Protocol)
|
|
if protocol == "" {
|
|
protocol = ProtoChat
|
|
if strings.EqualFold(cfg.Provider, ProtoAnthropic) {
|
|
protocol = ProtoAnthropic
|
|
}
|
|
}
|
|
authStyle := strings.TrimSpace(cfg.AuthStyle)
|
|
if authStyle == "" {
|
|
authStyle = AuthBearer
|
|
if protocol == ProtoAnthropic {
|
|
authStyle = AuthXAPIKey
|
|
}
|
|
}
|
|
base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
|
if base == "" {
|
|
base = defaultOpenAIBaseURL
|
|
}
|
|
model := strings.TrimSpace(cfg.Model)
|
|
if model == "" {
|
|
model = defaultOpenAIModel
|
|
}
|
|
return &RemoteProvider{
|
|
protocol: protocol,
|
|
authStyle: authStyle,
|
|
baseURL: base,
|
|
apiKey: cfg.APIKey,
|
|
model: model,
|
|
allowHTTP: cfg.AllowInsecureHTTP,
|
|
hc: &http.Client{Timeout: 120 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (p *RemoteProvider) Name() string { return p.protocol }
|
|
|
|
// validateEndpoint 默认只放行 https;本地/内网服务(Ollama、vLLM)需显式 AllowInsecureHTTP。
|
|
func validateEndpoint(baseURL string, allowInsecure bool) error {
|
|
u, err := url.Parse(strings.TrimSpace(baseURL))
|
|
if err != nil || u.Host == "" {
|
|
return fmt.Errorf("Base URL 格式无效")
|
|
}
|
|
switch u.Scheme {
|
|
case "https":
|
|
return nil
|
|
case "http":
|
|
if allowInsecure {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("非 HTTPS Base URL 已拒绝;本地/内网服务请在设置中开启「允许 HTTP」")
|
|
default:
|
|
return fmt.Errorf("Base URL 仅支持 HTTP/HTTPS")
|
|
}
|
|
}
|
|
|
|
// endpoint 拼接 basePath + suffix,已含 suffix 时不重复。
|
|
func endpoint(base, suffix string) string {
|
|
b := strings.TrimRight(strings.TrimSpace(base), "/")
|
|
s := strings.TrimLeft(suffix, "/")
|
|
if strings.HasSuffix(b, "/"+s) {
|
|
return b
|
|
}
|
|
return b + "/" + s
|
|
}
|
|
|
|
// anthropicEndpoint base 未含 /v1 时补 /v1/messages,否则直接 /messages。
|
|
func anthropicEndpoint(base string) string {
|
|
b := strings.TrimRight(strings.TrimSpace(base), "/")
|
|
if strings.HasSuffix(b, "/v1") {
|
|
return endpoint(b, "messages")
|
|
}
|
|
return endpoint(b, "v1/messages")
|
|
}
|
|
|
|
// Complete 单次非流式调用;req.Model 可临时覆盖配置模型。
|
|
func (p *RemoteProvider) Complete(ctx context.Context, req ChatReq) (ChatRes, error) {
|
|
if err := validateEndpoint(p.baseURL, p.allowHTTP); err != nil {
|
|
return ChatRes{}, err
|
|
}
|
|
model := p.model
|
|
if strings.TrimSpace(req.Model) != "" {
|
|
model = strings.TrimSpace(req.Model)
|
|
}
|
|
msgs := BuildMessages(req)
|
|
var (
|
|
urlStr string
|
|
payload any
|
|
)
|
|
switch p.protocol {
|
|
case ProtoAnthropic:
|
|
urlStr = anthropicEndpoint(p.baseURL)
|
|
payload = map[string]any{
|
|
"model": model,
|
|
"system": msgs[0].Content,
|
|
"messages": []Message{{Role: "user", Content: msgs[1].Content}},
|
|
"max_tokens": remoteMaxTokens,
|
|
"temperature": 0.2,
|
|
}
|
|
case ProtoResponses:
|
|
urlStr = endpoint(p.baseURL, "responses")
|
|
payload = map[string]any{
|
|
"model": model,
|
|
"instructions": msgs[0].Content,
|
|
"input": msgs[1].Content,
|
|
"max_output_tokens": remoteMaxTokens,
|
|
}
|
|
default: // openai-chat 及未知值按兼容协议处理
|
|
urlStr = endpoint(p.baseURL, "chat/completions")
|
|
payload = map[string]any{
|
|
"model": model,
|
|
"messages": msgs,
|
|
"max_tokens": remoteMaxTokens,
|
|
"temperature": 0.2,
|
|
}
|
|
}
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return ChatRes{}, err
|
|
}
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(body))
|
|
if err != nil {
|
|
return ChatRes{}, err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
switch p.authStyle {
|
|
case AuthXAPIKey:
|
|
httpReq.Header.Set("x-api-key", p.apiKey)
|
|
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
|
case AuthNone: // 本地免鉴权服务
|
|
default:
|
|
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
|
}
|
|
|
|
resp, err := p.hc.Do(httpReq)
|
|
if err != nil {
|
|
return ChatRes{}, fmt.Errorf("AI 请求失败: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
if err != nil {
|
|
return ChatRes{}, fmt.Errorf("读取 AI 响应失败: %w", err)
|
|
}
|
|
content, err := parseContent(p.protocol, raw, resp.StatusCode)
|
|
if err != nil {
|
|
return ChatRes{}, err
|
|
}
|
|
return ChatRes{Content: content, SQL: extractSQL(content)}, nil
|
|
}
|
|
|
|
// parseContent 按协议解析响应体;非 2xx 时尽量提取服务端错误信息。
|
|
func parseContent(protocol string, raw []byte, status int) (string, error) {
|
|
var errBody struct {
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
Message string `json:"message"`
|
|
}
|
|
_ = json.Unmarshal(raw, &errBody)
|
|
if status < 200 || status >= 300 {
|
|
msg := "未知错误"
|
|
if errBody.Error != nil && errBody.Error.Message != "" {
|
|
msg = errBody.Error.Message
|
|
} else if errBody.Message != "" {
|
|
msg = errBody.Message
|
|
}
|
|
return "", fmt.Errorf("AI 服务 HTTP %d: %s", status, msg)
|
|
}
|
|
switch protocol {
|
|
case ProtoAnthropic:
|
|
var out struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return "", fmt.Errorf("解析 AI 响应失败(HTTP %d)", status)
|
|
}
|
|
var sb strings.Builder
|
|
for _, c := range out.Content {
|
|
if c.Type == "text" {
|
|
sb.WriteString(c.Text)
|
|
}
|
|
}
|
|
if sb.Len() == 0 {
|
|
return "", fmt.Errorf("AI 服务未返回文本内容")
|
|
}
|
|
return sb.String(), nil
|
|
case ProtoResponses:
|
|
var out struct {
|
|
OutputText string `json:"output_text"`
|
|
Output []struct {
|
|
Content []struct {
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
} `json:"output"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return "", fmt.Errorf("解析 AI 响应失败(HTTP %d)", status)
|
|
}
|
|
if out.OutputText != "" {
|
|
return out.OutputText, nil
|
|
}
|
|
var sb strings.Builder
|
|
for _, o := range out.Output {
|
|
for _, c := range o.Content {
|
|
sb.WriteString(c.Text)
|
|
}
|
|
}
|
|
if sb.Len() == 0 {
|
|
return "", fmt.Errorf("AI 服务未返回文本内容")
|
|
}
|
|
return sb.String(), nil
|
|
default:
|
|
var out struct {
|
|
Choices []struct {
|
|
Message Message `json:"message"`
|
|
} `json:"choices"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return "", fmt.Errorf("解析 AI 响应失败(HTTP %d)", status)
|
|
}
|
|
if out.Error != nil {
|
|
return "", fmt.Errorf("AI 服务返回错误: %s", out.Error.Message)
|
|
}
|
|
if len(out.Choices) == 0 {
|
|
return "", fmt.Errorf("AI 服务未返回候选结果")
|
|
}
|
|
return strings.TrimSpace(out.Choices[0].Message.Content), nil
|
|
}
|
|
}
|
|
|
|
var sqlFence = regexp.MustCompile("(?s)```sql\\s*\n(.+?)```")
|
|
|
|
// extractSQL 从模型回复中提取 ```sql 代码块内容。
|
|
func extractSQL(content string) string {
|
|
m := sqlFence.FindStringSubmatch(content)
|
|
if len(m) < 2 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(m[1])
|
|
}
|