package ai import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/sundynix/pets-be/internal/config" ) // openaiCompatProvider 走 OpenAI 兼容的 /chat/completions 协议。 // DeepSeek、Qwen(dashscope 兼容模式)、Moonshot、Gemini(OpenAI 兼容端点) 等均可用。 type openaiCompatProvider struct { baseURL string apiKey string model string client *http.Client } func newOpenAICompatProvider(cfg config.AIConfig) *openaiCompatProvider { timeout := time.Duration(cfg.TimeoutSec) * time.Second if timeout <= 0 { timeout = 30 * time.Second } return &openaiCompatProvider{ baseURL: strings.TrimRight(cfg.BaseURL, "/"), apiKey: cfg.APIKey, model: cfg.Model, client: &http.Client{Timeout: timeout}, } } func (p *openaiCompatProvider) Name() string { return "openai-compat:" + p.model } type chatReq struct { Model string `json:"model"` Messages []chatMsg `json:"messages"` Temperature float64 `json:"temperature,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` ResponseFormat *respFormat `json:"response_format,omitempty"` } type chatMsg struct { Role string `json:"role"` Content string `json:"content"` } type respFormat struct { Type string `json:"type"` } type chatResp struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` Error *struct { Message string `json:"message"` } `json:"error"` } func (p *openaiCompatProvider) Complete(system string, messages []Message, opts Options) (string, error) { msgs := make([]chatMsg, 0, len(messages)+1) if system != "" { msgs = append(msgs, chatMsg{Role: "system", Content: system}) } for _, m := range messages { msgs = append(msgs, chatMsg{Role: m.Role, Content: m.Content}) } body := chatReq{ Model: p.model, Messages: msgs, Temperature: opts.Temperature, MaxTokens: opts.MaxTokens, } if opts.JSON { body.ResponseFormat = &respFormat{Type: "json_object"} } raw, _ := json.Marshal(body) ctx, cancel := context.WithTimeout(context.Background(), p.client.Timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(raw)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+p.apiKey) resp, err := p.client.Do(req) if err != nil { return "", err } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) var out chatResp if err := json.Unmarshal(data, &out); err != nil { return "", fmt.Errorf("ai 响应解析失败: %s", truncate(string(data), 200)) } if out.Error != nil { return "", fmt.Errorf("ai 供应商错误: %s", out.Error.Message) } if resp.StatusCode >= 400 || len(out.Choices) == 0 { return "", fmt.Errorf("ai 调用失败(%d): %s", resp.StatusCode, truncate(string(data), 200)) } return strings.TrimSpace(out.Choices[0].Message.Content), nil } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] }