发布 0.3.0:恢复 Chat 接口并增强云端事件通知
CI / test (push) Successful in 31s
Release / release (push) Successful in 48s

This commit is contained in:
2026-07-13 10:13:44 +08:00
parent 489cb49cb3
commit c7cc5616ed
31 changed files with 1682 additions and 1625 deletions
+108 -4
View File
@@ -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 可用模型列表
+12 -2
View File
@@ -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) {
-122
View File
@@ -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 侧链路状态与关键事件清单
+1
View File
@@ -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)
-4
View File
@@ -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)