发布 0.3.0:恢复 Chat 接口并增强云端事件通知
This commit is contained in:
+108
-4
@@ -281,7 +281,7 @@ func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwi
|
||||
defer upstream.Close()
|
||||
sseHeaders(c)
|
||||
bridge := service.NewAnthRespBridge(aiRandID("msg_"), req.Model)
|
||||
if err := forwardAnthSSE(c, upstream, bridge); err != nil {
|
||||
if err := forwardSSEData(upstream, func(data []byte) { writeAnthEvents(c, bridge.Feed(data)) }); err != nil {
|
||||
entry.ErrMsg = err.Error()
|
||||
}
|
||||
writeAnthEvents(c, bridge.Finish())
|
||||
@@ -295,15 +295,16 @@ func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwi
|
||||
h.maybeLogContent(c, callID, "anthropic", req.Model, true, req, nil)
|
||||
}
|
||||
|
||||
// forwardAnthSSE 逐行读上游 SSE,把 data 行喂给桥并即时写出转换后的事件。
|
||||
func forwardAnthSSE(c *gin.Context, upstream io.Reader, bridge *service.AnthRespBridge) error {
|
||||
// forwardSSEData 逐行读上游 SSE,把 data 行交给 emit 即时转换写出;
|
||||
// Anthropic 与 Chat Completions 两条流式桥共用。
|
||||
func forwardSSEData(upstream io.Reader, emit func([]byte)) error {
|
||||
reader := bufio.NewReader(upstream)
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
|
||||
writeAnthEvents(c, bridge.Feed(data))
|
||||
emit(data)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -328,6 +329,109 @@ func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// chatCompletions 是 OpenAI /ai/v1/chat/completions 端点(Tier 2 兼容层,
|
||||
// 承接只会说 Chat Completions 的存量客户端)。
|
||||
//
|
||||
// @Summary OpenAI Chat Completions 兼容端点
|
||||
// @Tags AI 网关
|
||||
// @Param body body object true "OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)"
|
||||
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE,末尾 data: [DONE])"
|
||||
// @Router /ai/v1/chat/completions [post]
|
||||
func (h *aiGatewayHandler) chatCompletions(c *gin.Context) {
|
||||
var req aiwire.ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Model) == "" || len(req.Messages) == 0 {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", "model 与 messages 不能为空")
|
||||
return
|
||||
}
|
||||
if !checkKeyModel(c, req.Model) {
|
||||
return
|
||||
}
|
||||
body, err := service.ChatToResponsesBody(req)
|
||||
if err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if req.Stream {
|
||||
h.streamChat(c, body, req)
|
||||
return
|
||||
}
|
||||
h.chatOnce(c, body, req)
|
||||
}
|
||||
|
||||
// chatOnce 处理非流式:直通上游 → 转回 chat.completion → 记账。
|
||||
func (h *aiGatewayHandler) chatOnce(c *gin.Context, body []byte, req aiwire.ChatRequest) {
|
||||
start := time.Now()
|
||||
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||
entry := h.logEntry(c, "openai", req.Model, false, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
h.logFailure(c, entry, req)
|
||||
return
|
||||
}
|
||||
out, err := service.ResponsesToChat(payload, aiRandID("chatcmpl-"), time.Now().Unix())
|
||||
if err != nil {
|
||||
aiError(c, http.StatusBadGateway, "api_error", err.Error())
|
||||
entry.ErrMsg = err.Error()
|
||||
h.logFailure(c, entry, req)
|
||||
return
|
||||
}
|
||||
entry.Status = http.StatusOK
|
||||
fillUsage(&entry, out.Usage)
|
||||
callID := h.gw.LogCall(entry)
|
||||
h.maybeLogContent(c, callID, "openai", req.Model, false, req, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// streamChat 流式直通并桥接:上游 Responses SSE 经 ChatRespBridge 转为
|
||||
// chat.completion.chunk 序列逐块写出,末尾发 [DONE];usage 从 completed 事件记账。
|
||||
func (h *aiGatewayHandler) streamChat(c *gin.Context, body []byte, req aiwire.ChatRequest) {
|
||||
start := time.Now()
|
||||
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||
entry := h.logEntry(c, "openai", req.Model, true, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
h.logFailure(c, entry, req)
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
sseHeaders(c)
|
||||
includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage
|
||||
bridge := service.NewChatRespBridge(aiRandID("chatcmpl-"), req.Model, time.Now().Unix(), includeUsage)
|
||||
if err := forwardSSEData(upstream, func(data []byte) { writeChatChunks(c, bridge.Feed(data)) }); err != nil {
|
||||
entry.ErrMsg = err.Error()
|
||||
}
|
||||
writeChatChunks(c, bridge.Finish())
|
||||
c.Writer.WriteString("data: [DONE]\n\n")
|
||||
c.Writer.Flush()
|
||||
entry.Status = http.StatusOK
|
||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||
fillUsage(&entry, bridge.Usage())
|
||||
callID := h.gw.LogCall(entry)
|
||||
h.maybeLogContent(c, callID, "openai", req.Model, true, req, nil)
|
||||
}
|
||||
|
||||
// writeChatChunks 逐块写出 chunk 的 SSE data 行并 flush。
|
||||
func writeChatChunks(c *gin.Context, chunks []aiwire.ChatChunk) {
|
||||
for _, ch := range chunks {
|
||||
b, err := json.Marshal(ch)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
c.Writer.WriteString("data: ")
|
||||
c.Writer.Write(b)
|
||||
c.Writer.WriteString("\n\n")
|
||||
}
|
||||
if len(chunks) > 0 {
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// listModels 是 /ai/v1/models 端点(OpenAI 格式,从启用渠道的模型缓存聚合)。
|
||||
//
|
||||
// @Summary 可用模型列表
|
||||
|
||||
@@ -81,8 +81,18 @@ func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
||||
`{"model":"meta.llama-3.3-70b-instruct","input":"hi"}`, 404, deny},
|
||||
{"不限密钥穿透闸门", "open-key-12345", "/ai/v1/responses",
|
||||
`{"model":"ghost-model","input":"hi"}`, 404, pass},
|
||||
{"chat completions 端点已删除", "open-key-12345", "/ai/v1/chat/completions",
|
||||
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, []string{"not found"}},
|
||||
{"chat completions 白名单外拦截", "limited-key-1234", "/ai/v1/chat/completions",
|
||||
`{"model":"meta.llama-3.3-70b-instruct","messages":[{"role":"user","content":"hi"}]}`, 404, deny},
|
||||
{"chat completions 白名单内穿透", "limited-key-1234", "/ai/v1/chat/completions",
|
||||
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, pass},
|
||||
{"chat completions 缺 messages 拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
||||
`{"model":"ghost-model"}`, 400, []string{"invalid_request_error"}},
|
||||
{"chat completions 非 function 工具拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
||||
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"web_search","function":{}}]}`,
|
||||
400, []string{"仅支持 function"}},
|
||||
{"chat completions 不支持内容块拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
||||
`{"model":"ghost-model","messages":[{"role":"user","content":[{"type":"input_audio"}]}]}`,
|
||||
400, []string{"invalid_request_error"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
@@ -107,127 +106,6 @@ func (h *logEventHandler) list(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||
}
|
||||
|
||||
// alertRuleRequest 是创建/更新告警规则的请求体。
|
||||
type alertRuleRequest struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
OciConfigID uint `json:"ociConfigId"`
|
||||
EventTypes string `json:"eventTypes"`
|
||||
SourceIPs string `json:"sourceIps"`
|
||||
SourceIPMode string `json:"sourceIpMode"`
|
||||
ResourceMatch string `json:"resourceMatch"`
|
||||
Threshold int `json:"threshold"`
|
||||
WindowMinutes int `json:"windowMinutes"`
|
||||
}
|
||||
|
||||
// toModel 转为规则模型(默认阈值 1)。
|
||||
func (r alertRuleRequest) toModel() model.AlertRule {
|
||||
if r.Threshold == 0 {
|
||||
r.Threshold = 1
|
||||
}
|
||||
return model.AlertRule{
|
||||
Name: r.Name, Enabled: r.Enabled, OciConfigID: r.OciConfigID,
|
||||
EventTypes: r.EventTypes, SourceIPs: r.SourceIPs, SourceIPMode: r.SourceIPMode,
|
||||
ResourceMatch: r.ResourceMatch, Threshold: r.Threshold, WindowMinutes: r.WindowMinutes,
|
||||
}
|
||||
}
|
||||
|
||||
// listAlertRules 返回全部告警规则。
|
||||
//
|
||||
// @Summary 告警规则列表
|
||||
// @Tags 任务与日志回传
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/log-events/alert-rules [get]
|
||||
func (h *logEventHandler) listAlertRules(c *gin.Context) {
|
||||
rules, err := h.svc.ListAlertRules(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": rules})
|
||||
}
|
||||
|
||||
// createAlertRule 创建告警规则;字段非法返回 400。
|
||||
//
|
||||
// @Summary 创建告警规则
|
||||
// @Tags 任务与日志回传
|
||||
// @Param body body alertRuleRequest true "请求体"
|
||||
// @Success 201 {object} map[string]any
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/log-events/alert-rules [post]
|
||||
func (h *logEventHandler) createAlertRule(c *gin.Context) {
|
||||
var req alertRuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rule, err := h.svc.CreateAlertRule(c.Request.Context(), req.toModel())
|
||||
if err != nil {
|
||||
respondAlertRuleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, rule)
|
||||
}
|
||||
|
||||
// updateAlertRule 整体覆盖告警规则(含启停)。
|
||||
//
|
||||
// @Summary 更新告警规则
|
||||
// @Tags 任务与日志回传
|
||||
// @Param ruleId path int true "规则 ID"
|
||||
// @Param body body alertRuleRequest true "请求体"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/log-events/alert-rules/{ruleId} [put]
|
||||
func (h *logEventHandler) updateAlertRule(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("ruleId"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "规则 ID 非法"})
|
||||
return
|
||||
}
|
||||
var req alertRuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rule, err := h.svc.UpdateAlertRule(c.Request.Context(), uint(id), req.toModel())
|
||||
if err != nil {
|
||||
respondAlertRuleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rule)
|
||||
}
|
||||
|
||||
// deleteAlertRule 删除告警规则及其命中记录。
|
||||
//
|
||||
// @Summary 删除告警规则
|
||||
// @Tags 任务与日志回传
|
||||
// @Param ruleId path int true "规则 ID"
|
||||
// @Success 204 "无内容"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/log-events/alert-rules/{ruleId} [delete]
|
||||
func (h *logEventHandler) deleteAlertRule(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("ruleId"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "规则 ID 非法"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteAlertRule(c.Request.Context(), uint(id)); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// respondAlertRuleError 把字段校验错误映射为 400。
|
||||
func respondAlertRuleError(c *gin.Context, err error) {
|
||||
if errors.Is(err, service.ErrInvalidAlertRule) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
respondError(c, err)
|
||||
}
|
||||
|
||||
// getRelay 查询 OCI 侧链路状态与关键事件清单。
|
||||
//
|
||||
// @Summary 查询 OCI 侧链路状态与关键事件清单
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) {
|
||||
aih := &aiGatewayHandler{gw: aiGateway}
|
||||
ai := r.Group("/ai/v1", bodyLimit(10<<20), aih.auth)
|
||||
ai.POST("/chat/completions", aih.chatCompletions)
|
||||
ai.POST("/responses", aih.responses)
|
||||
ai.POST("/messages", aih.messages)
|
||||
ai.POST("/embeddings", aih.embeddings)
|
||||
|
||||
@@ -19,10 +19,6 @@ func registerTasksAndLogs(secured *gin.RouterGroup, tasks *service.TaskService,
|
||||
|
||||
le := &logEventHandler{svc: logEvents}
|
||||
secured.GET("/log-events", le.list)
|
||||
secured.GET("/log-events/alert-rules", le.listAlertRules)
|
||||
secured.POST("/log-events/alert-rules", le.createAlertRule)
|
||||
secured.PUT("/log-events/alert-rules/:ruleId", le.updateAlertRule)
|
||||
secured.DELETE("/log-events/alert-rules/:ruleId", le.deleteAlertRule)
|
||||
secured.GET("/oci-configs/:id/log-webhook", le.getWebhook)
|
||||
secured.POST("/oci-configs/:id/log-webhook", le.ensureWebhook)
|
||||
secured.DELETE("/oci-configs/:id/log-webhook", le.revokeWebhook)
|
||||
|
||||
Reference in New Issue
Block a user