package api import ( "net/http" "strconv" "time" "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 管理 // @Param id path int true "渠道 ID" // @Success 200 {object} itemsResponse[model.AiModelCache] // @Security BearerAuth // @Router /api/v1/ai-channels/{id}/models [get] func (h *aiAdminHandler) listChannelModels(c *gin.Context) { id, ok := aiPathID(c) if !ok { return } models, err := h.gw.ChannelModels(c.Request.Context(), id) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"items": models}) } type testChannelModelRequest struct { Model string `json:"model" binding:"required"` } // @Summary 测试渠道模型(max_tokens=16 试调;通过即设为探测验证模型并按需置渠道可用) // @Tags AI 管理 // @Param id path int true "渠道 ID" // @Param body body testChannelModelRequest true "模型名" // @Success 200 {object} model.AiChannel // @Failure 502 {object} errorResponse "试调未通过" // @Security BearerAuth // @Router /api/v1/ai-channels/{id}/test-model [post] func (h *aiAdminHandler) testChannelModel(c *gin.Context) { id, ok := aiPathID(c) if !ok { return } var req testChannelModelRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ch, err := h.gw.TestChannelModel(c.Request.Context(), id, req.Model) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, ch) } // ---- 聚合模型与调用日志 ---- // @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 } // modelCatalog 返回启用渠道聚合去重后的模型目录(含能力),黑名单添加弹窗用。 // // @Summary 聚合模型目录 // @Tags AI 管理 // @Success 200 {object} itemsResponse[service.AggregatedModel] // @Security BearerAuth // @Router /api/v1/ai-model-catalog [get] func (h *aiAdminHandler) modelCatalog(c *gin.Context) { items, err := h.gw.AggregatedModels(c.Request.Context()) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"items": items}) } // aiSettingsResponse 是 AI 网关全局设置(文档与响应共用)。 type aiSettingsResponse struct { // FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除 FilterDeprecated bool `json:"filterDeprecated"` // StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝: // instructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE StreamGuardEnabled bool `json:"streamGuardEnabled"` StreamGuardKB int `json:"streamGuardKB"` // GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关; // 请求 tools 已包含同名工具时不覆盖 GrokWebSearch bool `json:"grokWebSearch"` GrokXSearch bool `json:"grokXSearch"` // UpstreamWaitSeconds 是 responses 直通的上游无响应预算(秒):非流式为单次 // 尝试总超时,流式为等待响应头预算;multi-agent/搜索类模型需远超 60s UpstreamWaitSeconds int `json:"upstreamWaitSeconds"` } // currentAiSettings 汇总网关运行时设置为响应体。 func (h *aiAdminHandler) currentAiSettings() aiSettingsResponse { guardOn, guardKB := h.gw.StreamGuard() web, x := h.gw.GrokSearch() return aiSettingsResponse{ FilterDeprecated: h.gw.FilterDeprecated(), StreamGuardEnabled: guardOn, StreamGuardKB: guardKB, GrokWebSearch: web, GrokXSearch: x, UpstreamWaitSeconds: int(h.gw.UpstreamWait() / time.Second), } } // 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, h.currentAiSettings()) } // updateAiSettings 更新 AI 网关全局设置(过滤弃用/流式保险丝/grok 搜索工具默认注入)。 // // @Summary 更新 AI 网关全局设置 // @Tags AI 管理 // @Param body body aiSettingsResponse true "全量提交;保险丝阈值限 1..1024 KB,上游无响应预算限 30..900 秒" // @Success 200 {object} aiSettingsResponse // @Failure 400 {object} map[string]string // @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 req.StreamGuardKB < 1 || req.StreamGuardKB > 1024 { c.JSON(http.StatusBadRequest, gin.H{"error": "streamGuardKB 须在 1..1024"}) return } if req.UpstreamWaitSeconds < 30 || req.UpstreamWaitSeconds > 900 { c.JSON(http.StatusBadRequest, gin.H{"error": "upstreamWaitSeconds 须在 30..900"}) return } ctx := c.Request.Context() if err := h.gw.SetFilterDeprecated(ctx, req.FilterDeprecated); err != nil { respondError(c, err) return } if err := h.gw.SetStreamGuard(ctx, req.StreamGuardEnabled, req.StreamGuardKB); err != nil { respondError(c, err) return } if err := h.gw.SetGrokSearch(ctx, req.GrokWebSearch, req.GrokXSearch); err != nil { respondError(c, err) return } if err := h.gw.SetUpstreamWait(ctx, req.UpstreamWaitSeconds); err != nil { respondError(c, err) return } c.JSON(http.StatusOK, h.currentAiSettings()) }