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]) }