Files
sundynix-pets/pets-be/internal/ai/openai_compat.go
T
Blizzard 609f7d06cf init: 毛孩子计划 小程序 + Go 后端 + 内嵌后台
- pets-fe: 微信原生小程序(首页/计划/记录/报告/社区/引导),
  服务端驱动、无假数据;弹层改用 scroll-view,打开时隐藏自定义 tabBar
- pets-be: Gin + GORM(MySQL, sundynix_ 前缀) + MinIO,统一响应/分页,
  微信 code2session 登录,provider-neutral AI(DeepSeek),go:embed React 后台
- 修复:分段选择类型不匹配(字符串 vs 数字)导致选不中

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:36:55 +08:00

123 lines
3.1 KiB
Go

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