Files
wangdefa b4ef98a25e
CI / test (push) Successful in 32s
Release / release (push) Successful in 54s
AI网关新增xAI格式TTS端点与过滤弃用模型开关
- POST /ai/v1/tts:xAI 官方 TTS 格式转换层(text/voice_id→input/voice),复用 Speech 编排,model 缺省 xai.grok-tts;实测 output_format/speed 透传生效
- 「过滤弃用模型」开关(GET/PUT /api/v1/ai-settings,settings 表持久化):开启后已宣布弃用(未退役)模型从列表与路由排除;同步入库与退役提醒不受影响
- AI 网关文档更名 docs/AI网关.md,补 tts 矩阵段与开关说明;README 引用同步
- CHANGELOG 0.5.0(0.4.0 已发版冻结);DASH_VERSION v0.5.0
2026-07-14 09:49:28 +08:00

285 lines
9.8 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"oci-portal/internal/aiwire"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
// moderationsMaxInputs 限制单次审核条数,防止批量滥用拖垮渠道配额。
const moderationsMaxInputs = 8
// SpeechBodyNormalize 校验并规范化 TTS 请求体:model / input 必填;
// xAI 上游把 language 当必填(实测缺失返回 422),缺省注入 "auto"。
// 其余字段(voice / response_format / extra_body 平铺项)原样保留。
func SpeechBodyNormalize(raw []byte) (string, []byte, error) {
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.UseNumber()
var body map[string]any
if err := dec.Decode(&body); err != nil {
return "", nil, fmt.Errorf("解析请求体: %w", err)
}
modelName, _ := body["model"].(string)
input, _ := body["input"].(string)
if strings.TrimSpace(modelName) == "" || strings.TrimSpace(input) == "" {
return "", nil, fmt.Errorf("model 与 input 不能为空")
}
if lang, ok := body["language"].(string); !ok || strings.TrimSpace(lang) == "" {
body["language"] = "auto"
}
out, err := json.Marshal(body)
if err != nil {
return "", nil, fmt.Errorf("重组请求体: %w", err)
}
return modelName, out, nil
}
// ttsDefaultModel 是 /ai/v1/tts 缺省注入的模型(xAI 官方格式无 model 字段)。
const ttsDefaultModel = "xai.grok-tts"
// TtsBodyConvert 把 xAI 官方 TTS 格式转换为上游 OpenAI 兼容 audio/speech 形态:
// text→input、voice_id→voice,text 与 language 必填(对齐 xAI 官方);
// model 为网关扩展字段,缺省注入 ttsDefaultModel;其余字段原样保留。
func TtsBodyConvert(raw []byte) (string, []byte, error) {
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.UseNumber()
var body map[string]any
if err := dec.Decode(&body); err != nil {
return "", nil, fmt.Errorf("解析请求体: %w", err)
}
text, _ := body["text"].(string)
language, _ := body["language"].(string)
if strings.TrimSpace(text) == "" || strings.TrimSpace(language) == "" {
return "", nil, fmt.Errorf("text 与 language 不能为空")
}
modelName, _ := body["model"].(string)
if strings.TrimSpace(modelName) == "" {
modelName = ttsDefaultModel
body["model"] = modelName
}
delete(body, "text")
body["input"] = text
if voice, ok := body["voice_id"].(string); ok && strings.TrimSpace(voice) != "" {
body["voice"] = voice
}
delete(body, "voice_id")
out, err := json.Marshal(body)
if err != nil {
return "", nil, fmt.Errorf("重组请求体: %w", err)
}
return modelName, out, nil
}
// Speech 编排 TTS 调用:按 TTS 能力选渠道,可重试错误换渠道(上限 3 次)。
func (s *AiGatewayService) Speech(ctx context.Context, modelName string, body []byte, group string) ([]byte, string, ChatMeta, error) {
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
cand, err := s.pick(ctx, modelName, group, "TTS", excluded)
if err != nil {
return nil, "", meta, firstErr(lastErr, err)
}
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
if err != nil {
return nil, "", meta, err
}
audio, contentType, err := s.client.GenAiCompatSpeech(ctx, cred, cand.ch.Region, body)
if err == nil {
s.markSuccess(ctx, cand.ch.ID)
return audio, contentType, meta, nil
}
if done := s.retryStep(ctx, cand.ch.ID, err, excluded, &meta, &lastErr); done {
return nil, "", meta, err
}
}
return nil, "", meta, lastErr
}
// Rerank 编排文档重排:按 RERANK 能力选渠道,装配 Jina 风格响应。
func (s *AiGatewayService) Rerank(ctx context.Context, req aiwire.RerankRequest, group string) (*aiwire.RerankResponse, ChatMeta, error) {
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
cand, err := s.pick(ctx, req.Model, group, "RERANK", excluded)
if err != nil {
return nil, meta, firstErr(lastErr, err)
}
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
if err != nil {
return nil, meta, err
}
ranks, err := s.client.GenAiRerank(ctx, cred, cand.ch.Region, cand.modelOcid, req.Query, req.Documents, req.TopN)
if err == nil {
s.markSuccess(ctx, cand.ch.ID)
return rerankResponse(req, ranks), meta, nil
}
if done := s.retryStep(ctx, cand.ch.ID, err, excluded, &meta, &lastErr); done {
return nil, meta, err
}
}
return nil, meta, lastErr
}
// retryStep 统一处理换渠道重试:不可重试返回 true 终止,可重试记罚并排除渠道。
func (s *AiGatewayService) retryStep(ctx context.Context, chID uint, err error, excluded map[uint]bool, meta *ChatMeta, lastErr *error) bool {
retry, penalize := switchable(err)
if !retry {
return true
}
if penalize {
s.markFailure(ctx, chID)
}
excluded[chID] = true
meta.Retries++
*lastErr = err
return false
}
// rerankResponse 把上游排序结果装配为对外响应;return_documents 时回填原文。
func rerankResponse(req aiwire.RerankRequest, ranks []oci.RerankRank) *aiwire.RerankResponse {
out := &aiwire.RerankResponse{Model: req.Model, Results: make([]aiwire.RerankResult, 0, len(ranks))}
withDoc := req.ReturnDocuments != nil && *req.ReturnDocuments
for _, r := range ranks {
if r.Index < 0 || r.Index >= len(req.Documents) {
continue
}
item := aiwire.RerankResult{Index: r.Index, RelevanceScore: r.Score}
if withDoc {
item.Document = &aiwire.RerankDocument{Text: req.Documents[r.Index]}
}
out.Results = append(out.Results, item)
}
return out
}
// ModerationInputs 解析 moderations 的 input 字段:string 或 []string,
// 条数与空值校验(上限 moderationsMaxInputs)。
func ModerationInputs(raw json.RawMessage) ([]string, error) {
var single string
if err := json.Unmarshal(raw, &single); err == nil {
if strings.TrimSpace(single) == "" {
return nil, fmt.Errorf("input 不能为空")
}
return []string{single}, nil
}
var many []string
if err := json.Unmarshal(raw, &many); err != nil {
return nil, fmt.Errorf("input 需为字符串或字符串数组")
}
if len(many) == 0 || len(many) > moderationsMaxInputs {
return nil, fmt.Errorf("input 条数需在 1~%d 之间", moderationsMaxInputs)
}
for _, item := range many {
if strings.TrimSpace(item) == "" {
return nil, fmt.Errorf("input 含空条目")
}
}
return many, nil
}
// Moderations 编排内容审核:guardrails 为服务级 API 无模型维度,
// 按分组选启用渠道(整批同渠道),可重试错误换渠道(上限 3 次)。
func (s *AiGatewayService) Moderations(ctx context.Context, id string, inputs []string, group string) (*aiwire.ModerationsResponse, ChatMeta, error) {
meta := ChatMeta{}
excluded := map[uint]bool{}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
ch, err := s.pickGuardChannel(ctx, group, excluded)
if err != nil {
return nil, meta, firstErr(lastErr, err)
}
meta.ChannelID, meta.ChannelName = ch.ID, ch.Name
resp, err := s.moderateOnce(ctx, ch, id, inputs)
if err == nil {
s.markSuccess(ctx, ch.ID)
return resp, meta, nil
}
if done := s.retryStep(ctx, ch.ID, err, excluded, &meta, &lastErr); done {
return nil, meta, err
}
}
return nil, meta, lastErr
}
// pickGuardChannel 按分组挑一个启用且未熔断的渠道(权重加成,不查模型缓存)。
func (s *AiGatewayService) pickGuardChannel(ctx context.Context, group string, excluded map[uint]bool) (model.AiChannel, error) {
q := s.db.WithContext(ctx).Where("enabled = ?", true)
if group != "" {
q = q.Where("channel_group = ?", group)
}
var channels []model.AiChannel
if err := q.Find(&channels).Error; err != nil {
return model.AiChannel{}, err
}
now := time.Now()
avail := channels[:0]
for _, ch := range channels {
if excluded[ch.ID] || (ch.DisabledUntil != nil && ch.DisabledUntil.After(now)) {
continue
}
avail = append(avail, ch)
}
if len(avail) == 0 {
return model.AiChannel{}, ErrAiNoChannel
}
return weightedPick(topPriority(avail)), nil
}
// moderateOnce 在单渠道上逐条审核并装配 OpenAI moderations 外壳。
func (s *AiGatewayService) moderateOnce(ctx context.Context, ch model.AiChannel, id string, inputs []string) (*aiwire.ModerationsResponse, error) {
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
if err != nil {
return nil, err
}
out := &aiwire.ModerationsResponse{ID: id, Model: "oci-guardrails",
Results: make([]aiwire.ModerationResult, 0, len(inputs))}
for _, text := range inputs {
outcome, err := s.client.GenAiApplyGuardrails(ctx, cred, ch.Region, text)
if err != nil {
return nil, err
}
out.Results = append(out.Results, moderationResult(outcome))
}
return out, nil
}
// moderationFlagThreshold 是判定违规的得分阈值(上游 CM / PI 为二值得分)。
const moderationFlagThreshold = 0.5
// moderationResult 把 guardrails 结果映射为 OpenAI moderations 条目:
// flagged 由内容审核与提示注入判定,PII 命中只作扩展信息不参与 flagged。
func moderationResult(outcome *oci.GuardrailsOutcome) aiwire.ModerationResult {
res := aiwire.ModerationResult{Categories: map[string]bool{}, CategoryScores: map[string]float64{}}
for _, cat := range outcome.Categories {
key := strings.ToLower(cat.Name)
res.Categories[key] = cat.Score >= moderationFlagThreshold
res.CategoryScores[key] = cat.Score
if res.Categories[key] {
res.Flagged = true
}
}
if outcome.PromptInjectionScore != nil {
hit := *outcome.PromptInjectionScore >= moderationFlagThreshold
res.Categories["prompt_injection"] = hit
res.CategoryScores["prompt_injection"] = *outcome.PromptInjectionScore
if hit {
res.Flagged = true
}
}
for _, p := range outcome.Pii {
res.Pii = append(res.Pii, aiwire.ModerationPii{Text: p.Text, Label: p.Label,
Score: p.Score, Offset: p.Offset, Length: p.Length})
}
return res
}