Files
opsxdb/server/internal/ai/remote.go
T
logan 4c68539db6
CI / Go server (push) Canceled after 0s
CI / Desktop UI (push) Canceled after 0s
CI / Desktop (Tauri) cargo check (push) Canceled after 0s
fix(ai): Ask P0 禁 CoT、可澄清、禁 UPDATE_TIME 终答
prompt 禁止推理外露与编造列;信息不足允许一句澄清。
新增 SanitizeAssistantContent 剥回复中的推理草稿;业务「最近 N 天」不得默认用 information_schema.UPDATE_TIME。
2026-10-26 23:03:03 +08:00

294 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
}
content = SanitizeAssistantContent(content)
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])
}