- 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
412 lines
12 KiB
Go
412 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
_ "oci-portal/internal/aiwire" // swagger 注解引用
|
|
_ "oci-portal/internal/model" // swagger 注解引用
|
|
|
|
"oci-portal/internal/service"
|
|
)
|
|
|
|
// aiAdminHandler 处理面板侧 AI 网关管理接口(secured 组,自动进系统日志)。
|
|
type aiAdminHandler struct {
|
|
gw *service.AiGatewayService
|
|
}
|
|
|
|
// ---- 密钥 ----
|
|
|
|
// @Summary ---- 密钥 ----
|
|
// @Tags AI 管理
|
|
// @Success 200 {object} itemsResponse[model.AiKey]
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-keys [get]
|
|
func (h *aiAdminHandler) listKeys(c *gin.Context) {
|
|
keys, err := h.gw.Keys(c.Request.Context())
|
|
if err != nil {
|
|
respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": keys})
|
|
}
|
|
|
|
// createKey 生成密钥;明文仅在本响应返回一次。
|
|
//
|
|
// @Summary 生成密钥
|
|
// @Tags AI 管理
|
|
// @Param body body object true "请求体(见接口说明)"
|
|
// @Success 201 {object} aiKeyCreateResponse "key 为明文密钥,仅本次返回"
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-keys [post]
|
|
func (h *aiAdminHandler) createKey(c *gin.Context) {
|
|
var req struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Value string `json:"value"`
|
|
Group string `json:"group"`
|
|
Models []string `json:"models"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
plaintext, key, err := h.gw.CreateKey(c.Request.Context(), req.Name, req.Value, req.Group, req.Models)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"key": plaintext, "item": key})
|
|
}
|
|
|
|
// @Summary 更新 AI 网关密钥
|
|
// @Tags AI 管理
|
|
// @Param id path int true "配置 ID"
|
|
// @Param body body object true "请求体(见接口说明)"
|
|
// @Success 204 "无内容"
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-keys/{id} [put]
|
|
func (h *aiAdminHandler) updateKey(c *gin.Context) {
|
|
id, ok := aiPathID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Enabled *bool `json:"enabled"`
|
|
Group *string `json:"group"`
|
|
Models *[]string `json:"models"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := h.gw.UpdateKey(c.Request.Context(), id, req.Name, req.Enabled, req.Group, req.Models); err != nil {
|
|
respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// @Summary 删除 AI 网关密钥
|
|
// @Tags AI 管理
|
|
// @Param id path int true "配置 ID"
|
|
// @Success 204 "无内容"
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-keys/{id} [delete]
|
|
func (h *aiAdminHandler) deleteKey(c *gin.Context) {
|
|
id, ok := aiPathID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.gw.DeleteKey(c.Request.Context(), id); err != nil {
|
|
respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// updateKeyContentLog 设置密钥内容日志窗口(红线例外:显式限时开启,0 关闭)。
|
|
//
|
|
// @Summary 设置密钥内容日志窗口
|
|
// @Tags AI 管理
|
|
// @Param id path int true "配置 ID"
|
|
// @Param body body object true "请求体(见接口说明)"
|
|
// @Success 200 {object} model.AiKey
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-keys/{id}/content-log [put]
|
|
func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
|
|
id, ok := aiPathID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Hours int `json:"hours"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
key, err := h.gw.UpdateKeyContentLog(c.Request.Context(), id, req.Hours)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, key)
|
|
}
|
|
|
|
// listContentLogs 分页查询内容日志(可选 keyId / callLogId 过滤)。
|
|
//
|
|
// @Summary 分页查询内容日志
|
|
// @Tags AI 管理
|
|
// @Success 200 {object} pagedResponse[model.AiCallLog]
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-content-logs [get]
|
|
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
|
keyID, _ := strconv.Atoi(c.DefaultQuery("keyId", "0"))
|
|
callLogID, _ := strconv.Atoi(c.DefaultQuery("callLogId", "0"))
|
|
items, total, err := h.gw.ContentLogs(c.Request.Context(), uint(keyID), uint(callLogID), page, size)
|
|
if err != nil {
|
|
respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
|
}
|
|
|
|
// ---- 渠道 ----
|
|
|
|
// @Summary ---- 渠道 ----
|
|
// @Tags AI 管理
|
|
// @Success 200 {object} itemsResponse[model.AiChannel]
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-channels [get]
|
|
func (h *aiAdminHandler) listChannels(c *gin.Context) {
|
|
chs, err := h.gw.Channels(c.Request.Context())
|
|
if err != nil {
|
|
respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": chs})
|
|
}
|
|
|
|
// @Summary 创建 AI 渠道
|
|
// @Tags AI 管理
|
|
// @Param body body service.ChannelInput true "请求体"
|
|
// @Success 201 {object} model.AiChannel
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-channels [post]
|
|
func (h *aiAdminHandler) createChannel(c *gin.Context) {
|
|
var req service.ChannelInput
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
ch, err := h.gw.CreateChannel(c.Request.Context(), req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, ch)
|
|
}
|
|
|
|
// @Summary 更新 AI 渠道
|
|
// @Tags AI 管理
|
|
// @Param id path int true "配置 ID"
|
|
// @Param body body service.ChannelInput true "请求体"
|
|
// @Success 204 "无内容"
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-channels/{id} [put]
|
|
func (h *aiAdminHandler) updateChannel(c *gin.Context) {
|
|
id, ok := aiPathID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req service.ChannelInput
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := h.gw.UpdateChannel(c.Request.Context(), id, req); err != nil {
|
|
respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// @Summary 删除 AI 渠道
|
|
// @Tags AI 管理
|
|
// @Param id path int true "配置 ID"
|
|
// @Success 204 "无内容"
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-channels/{id} [delete]
|
|
func (h *aiAdminHandler) deleteChannel(c *gin.Context) {
|
|
id, ok := aiPathID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.gw.DeleteChannel(c.Request.Context(), id); err != nil {
|
|
respondError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// probeChannel 触发探测:服务可见性 + 模型同步 + 配额试调,返回更新后的渠道。
|
|
//
|
|
// @Summary 触发探测:服务可见性 + 模型同步 + 配额试调,返回更新后的渠道
|
|
// @Tags AI 管理
|
|
// @Param id path int true "配置 ID"
|
|
// @Success 200 {object} model.AiChannel
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-channels/{id}/probe [post]
|
|
func (h *aiAdminHandler) probeChannel(c *gin.Context) {
|
|
id, ok := aiPathID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ch, err := h.gw.ProbeChannel(c.Request.Context(), id)
|
|
if err != nil {
|
|
respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, ch)
|
|
}
|
|
|
|
// @Summary 同步渠道模型缓存
|
|
// @Tags AI 管理
|
|
// @Param id path int true "配置 ID"
|
|
// @Success 200 {object} itemsResponse[model.AiModelCache]
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-channels/{id}/sync-models [post]
|
|
func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
|
|
id, ok := aiPathID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
models, err := h.gw.SyncModels(c.Request.Context(), id)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": models})
|
|
}
|
|
|
|
// ---- 聚合模型与调用日志 ----
|
|
|
|
// @Summary ---- 聚合模型与调用日志 ----
|
|
// @Tags AI 管理
|
|
// @Success 200 {object} itemsResponse[aiwire.Model]
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-models [get]
|
|
func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
|
|
list, err := h.gw.GatewayModels(c.Request.Context(), "")
|
|
if err != nil {
|
|
respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": list.Data})
|
|
}
|
|
|
|
// ---- 模型黑名单 ----
|
|
|
|
// @Summary 模型黑名单列表
|
|
// @Tags AI 管理
|
|
// @Success 200 {object} itemsResponse[model.AiModelBlacklist]
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-blacklist [get]
|
|
func (h *aiAdminHandler) listBlacklist(c *gin.Context) {
|
|
items, err := h.gw.Blacklist(c.Request.Context())
|
|
if err != nil {
|
|
respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
|
}
|
|
|
|
// addBlacklist 拉黑模型:删除全部渠道缓存中的同名条目,后续同步 / 探测均过滤。
|
|
//
|
|
// @Summary 添加模型黑名单
|
|
// @Tags AI 管理
|
|
// @Param body body object true "请求体:{name: 模型名}"
|
|
// @Success 201 {object} itemResponse[model.AiModelBlacklist]
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-blacklist [post]
|
|
func (h *aiAdminHandler) addBlacklist(c *gin.Context) {
|
|
var req struct {
|
|
Name string `json:"name" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
item, err := h.gw.AddBlacklist(c.Request.Context(), req.Name)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"item": item})
|
|
}
|
|
|
|
// @Summary 移除模型黑名单(重新同步后模型恢复入池)
|
|
// @Tags AI 管理
|
|
// @Param id path int true "黑名单条目 ID"
|
|
// @Success 204 "无内容"
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-blacklist/{id} [delete]
|
|
func (h *aiAdminHandler) removeBlacklist(c *gin.Context) {
|
|
id, ok := aiPathID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.gw.RemoveBlacklist(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// @Summary AI 调用日志列表
|
|
// @Tags AI 管理
|
|
// @Success 200 {object} pagedResponse[model.AiContentLog]
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-logs [get]
|
|
func (h *aiAdminHandler) listLogs(c *gin.Context) {
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "50"))
|
|
items, total, err := h.gw.CallLogs(c.Request.Context(), page, size)
|
|
if err != nil {
|
|
respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
|
}
|
|
|
|
func aiPathID(c *gin.Context) (uint, bool) {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return 0, false
|
|
}
|
|
return uint(id), true
|
|
}
|
|
|
|
// aiSettingsResponse 是 AI 网关全局设置(文档与响应共用)。
|
|
type aiSettingsResponse struct {
|
|
FilterDeprecated bool `json:"filterDeprecated"`
|
|
}
|
|
|
|
// aiSettings 返回 AI 网关全局设置。
|
|
//
|
|
// @Summary AI 网关全局设置
|
|
// @Tags AI 管理
|
|
// @Success 200 {object} aiSettingsResponse
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-settings [get]
|
|
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
|
|
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
|
}
|
|
|
|
// updateAiSettings 更新 AI 网关全局设置(当前仅「过滤弃用模型」开关)。
|
|
//
|
|
// @Summary 更新 AI 网关全局设置
|
|
// @Tags AI 管理
|
|
// @Param body body aiSettingsResponse true "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除"
|
|
// @Success 200 {object} aiSettingsResponse
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/ai-settings [put]
|
|
func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
|
var req aiSettingsResponse
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := h.gw.SetFilterDeprecated(c.Request.Context(), req.FilterDeprecated); err != nil {
|
|
respondError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
|
}
|