Files
oci-portal/internal/api/aigateway.go
T
wangdefa 489cb49cb3
CI / test (push) Successful in 31s
Release / release (push) Successful in 54s
AI 网关切换 OpenAI 兼容面并移除 chat 端点,新增模型黑白名单
2026-07-12 17:48:28 +08:00

463 lines
15 KiB
Go

package api
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"slices"
"strings"
"time"
"github.com/gin-gonic/gin"
"oci-portal/internal/aiwire"
"oci-portal/internal/model"
"oci-portal/internal/oci"
"oci-portal/internal/service"
)
// aiGatewayHandler 处理 /ai/v1/* 对外网关端点(OpenAI / Anthropic 协议)。
type aiGatewayHandler struct {
gw *service.AiGatewayService
}
const aiKeyCtx = "aiKey"
// auth 是网关鉴权中间件:Bearer 或 x-api-key 双头识别,失败按端点协议返回错误体。
func (h *aiGatewayHandler) auth(c *gin.Context) {
key, err := h.gw.VerifyKey(c.Request.Context(), extractAiKey(c))
if err != nil {
aiError(c, http.StatusUnauthorized, "authentication_error", "无效或已禁用的 API 密钥")
c.Abort()
return
}
c.Set(aiKeyCtx, key)
c.Next()
}
func extractAiKey(c *gin.Context) string {
if header := c.GetHeader("Authorization"); strings.HasPrefix(header, "Bearer ") {
return strings.TrimSpace(header[7:])
}
return strings.TrimSpace(c.GetHeader("x-api-key"))
}
// aiError 按端点协议输出错误体:/ai/v1/messages 用 Anthropic 格式,其余 OpenAI 格式。
func aiError(c *gin.Context, status int, code, msg string) {
if strings.HasSuffix(c.FullPath(), "/messages") {
c.JSON(status, aiwire.AnthErrorBody{Type: "error", Error: aiwire.AnthErrorDetail{Type: code, Message: msg}})
return
}
c.JSON(status, aiwire.ErrorBody{Error: aiwire.ErrorDetail{Message: msg, Type: code}})
}
// upstreamError 把编排层错误映射为网关响应(未知模型 404 / 无渠道 503 / 上游状态透传)。
func upstreamError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrAiUnknownModel):
aiError(c, http.StatusNotFound, "model_not_found", err.Error())
case errors.Is(err, service.ErrAiNoChannel):
aiError(c, http.StatusServiceUnavailable, "overloaded_error", err.Error())
case errors.Is(err, service.ErrAiUnsupportedBlock):
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
default:
if status, ok := oci.ServiceStatus(err); ok {
aiError(c, status, "upstream_error", oci.CompactError(err))
return
}
aiError(c, http.StatusBadGateway, "upstream_error", oci.CompactError(err))
}
}
func aiRandID(prefix string) string {
buf := make([]byte, 12)
_, _ = rand.Read(buf)
return prefix + hex.EncodeToString(buf)
}
// keyGroup 取当前请求密钥的分组(空 = 不限分组)。
func keyGroup(c *gin.Context) string {
if key, ok := c.Get(aiKeyCtx); ok {
return key.(*model.AiKey).Group
}
return ""
}
// keyModels 取当前请求密钥的模型白名单(空 = 不限模型)。
func keyModels(c *gin.Context) []string {
if key, ok := c.Get(aiKeyCtx); ok {
return key.(*model.AiKey).Models
}
return nil
}
// checkKeyModel 校验请求模型是否在密钥白名单内;拒绝时按端点协议返回
// 404 model_not_found(与未知模型同口径,不泄露密钥配置细节)并返回 false。
func checkKeyModel(c *gin.Context, modelName string) bool {
allowed := keyModels(c)
if len(allowed) == 0 || slices.Contains(allowed, modelName) {
return true
}
aiError(c, http.StatusNotFound, "model_not_found", "模型不存在或无权访问: "+modelName)
return false
}
// logEntry 组装调用日志骨架;usage / status 由调用方补齐。
func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string, stream bool, meta service.ChatMeta, start time.Time) model.AiCallLog {
entry := model.AiCallLog{
Endpoint: endpoint, Model: modelName, Stream: stream,
ChannelID: meta.ChannelID, ChannelName: meta.ChannelName,
Retries: meta.Retries, LatencyMs: time.Since(start).Milliseconds(),
ClientIP: requestIP(c),
}
if key, ok := c.Get(aiKeyCtx); ok {
k := key.(*model.AiKey)
entry.KeyID, entry.KeyName = k.ID, k.Name
}
return entry
}
// validateIR 做协议无关的入参检查(模型名、消息、不支持的内容块)。
// maybeLogContent 在调用密钥显式开启内容日志且未过期时写正文(红线例外);
// respBody 为 nil 时只记请求(流式响应与向量结果不记录);callLogID 关联同次调用日志。
func (h *aiGatewayHandler) maybeLogContent(c *gin.Context, callLogID uint, endpoint, modelName string, stream bool, reqBody, respBody any) {
keyVal, ok := c.Get(aiKeyCtx)
if !ok {
return
}
key := keyVal.(*model.AiKey)
if key.ContentLogUntil == nil || time.Now().After(*key.ContentLogUntil) {
return
}
entry := model.AiContentLog{CallLogID: callLogID, KeyID: key.ID, KeyName: key.Name, Endpoint: endpoint, Model: modelName, Stream: stream}
if b, err := json.Marshal(reqBody); err == nil {
entry.RequestBody = string(b)
}
if respBody != nil {
if b, err := json.Marshal(respBody); err == nil {
entry.ResponseBody = string(b)
}
}
h.gw.LogContent(entry)
}
// logFailure 记失败调用日志,并在密钥内容日志开启时留请求正文用于排障(错误信息已在 ErrMsg,不记响应)。
func (h *aiGatewayHandler) logFailure(c *gin.Context, entry model.AiCallLog, reqBody any) {
entry.Status = c.Writer.Status()
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, entry.Endpoint, entry.Model, entry.Stream, reqBody, nil)
}
func fillUsage(entry *model.AiCallLog, u *aiwire.Usage) {
if u == nil {
return
}
entry.PromptTokens, entry.CompletionTokens, entry.TotalTokens = u.PromptTokens, u.CompletionTokens, u.TotalTokens
entry.CachedTokens = u.CachedTokens()
}
// embeddings 是 OpenAI /ai/v1/embeddings 端点(非流式)。
//
// @Summary OpenAI 兼容向量嵌入
// @Tags AI 网关
// @Param body body object true "OpenAI embeddings 请求体"
// @Success 200 {object} map[string]any "OpenAI 兼容响应"
// @Router /ai/v1/embeddings [post]
func (h *aiGatewayHandler) embeddings(c *gin.Context) {
var req aiwire.EmbeddingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if strings.TrimSpace(req.Model) == "" || len(req.Input) == 0 {
aiError(c, http.StatusBadRequest, "invalid_request_error", "model 与 input 不能为空")
return
}
if req.EncodingFormat != "" && req.EncodingFormat != "float" {
aiError(c, http.StatusBadRequest, "invalid_request_error", "仅支持 encoding_format=float")
return
}
if !checkKeyModel(c, req.Model) {
return
}
start := time.Now()
resp, meta, err := h.gw.Embeddings(c.Request.Context(), req, keyGroup(c))
entry := h.logEntry(c, "embeddings", req.Model, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
entry.Status = http.StatusOK
if resp.Usage != nil {
entry.PromptTokens, entry.TotalTokens = resp.Usage.PromptTokens, resp.Usage.TotalTokens
}
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "embeddings", req.Model, false, req, nil)
c.JSON(http.StatusOK, resp)
}
func sseHeaders(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no")
c.Writer.Flush()
}
// messages 是 Anthropic /ai/v1/messages 端点。
//
// @Summary Anthropic Messages 兼容端点
// @Tags AI 网关
// @Param body body object true "Anthropic messages 请求体(支持 stream;经 OCI OpenAI 兼容面直通)"
// @Success 200 {object} map[string]any "Anthropic 兼容响应(流式为 SSE)"
// @Router /ai/v1/messages [post]
func (h *aiGatewayHandler) messages(c *gin.Context) {
var req aiwire.MessagesRequest
if err := c.ShouldBindJSON(&req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if req.MaxTokens <= 0 {
aiError(c, http.StatusBadRequest, "invalid_request_error", "max_tokens 必填且需大于 0")
return
}
if len(req.Messages) == 0 {
aiError(c, http.StatusBadRequest, "invalid_request_error", "messages 不能为空")
return
}
if !checkKeyModel(c, req.Model) {
return
}
body, err := service.AnthropicToResponsesBody(req)
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if req.Stream {
h.streamAnthropic(c, body, req)
return
}
start := time.Now()
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "anthropic", req.Model, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
out, err := service.ResponsesToAnthropic(payload, aiRandID("msg_"))
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, service.RespPassthroughUsage(payload))
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "anthropic", req.Model, false, req, out)
c.JSON(http.StatusOK, out)
}
// streamAnthropic 流式直通并桥接:上游 Responses SSE 事件经 AnthRespBridge
// 转为 Anthropic 事件序列逐块写出;usage 从 completed 事件记账。
func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwire.MessagesRequest) {
start := time.Now()
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "anthropic", 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)
bridge := service.NewAnthRespBridge(aiRandID("msg_"), req.Model)
if err := forwardAnthSSE(c, upstream, bridge); err != nil {
entry.ErrMsg = err.Error()
}
writeAnthEvents(c, bridge.Finish())
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
usage := bridge.Usage()
entry.PromptTokens, entry.CompletionTokens = usage.InputTokens, usage.OutputTokens
entry.TotalTokens = usage.InputTokens + usage.OutputTokens
entry.CachedTokens = usage.CacheReadInputTokens
callID := h.gw.LogCall(entry)
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 {
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))
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
for _, ev := range events {
b, err := json.Marshal(ev.Data)
if err != nil {
continue
}
c.Writer.WriteString("event: " + ev.Event + "\ndata: ")
c.Writer.Write(b)
c.Writer.WriteString("\n\n")
}
c.Writer.Flush()
}
// listModels 是 /ai/v1/models 端点(OpenAI 格式,从启用渠道的模型缓存聚合)。
//
// @Summary 可用模型列表
// @Tags AI 网关
// @Success 200 {object} map[string]any "OpenAI 兼容 models 列表"
// @Router /ai/v1/models [get]
func (h *aiGatewayHandler) listModels(c *gin.Context) {
list, err := h.gw.GatewayModels(c.Request.Context(), keyGroup(c))
if err != nil {
aiError(c, http.StatusInternalServerError, "api_error", "查询模型列表失败")
return
}
if allowed := keyModels(c); len(allowed) > 0 {
kept := make([]aiwire.Model, 0, len(list.Data))
for _, m := range list.Data {
if slices.Contains(allowed, m.ID) {
kept = append(kept, m)
}
}
list.Data = kept
}
c.JSON(http.StatusOK, list)
}
// responses 是 OpenAI /ai/v1/responses 端点(无状态子集)。
//
// @Summary OpenAI Responses 兼容端点
// @Tags AI 网关
// @Param body body object true "OpenAI responses 请求体(支持 stream;web_search/x_search 服务端工具仅非流式)"
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE)"
// @Router /ai/v1/responses [post]
func (h *aiGatewayHandler) responses(c *gin.Context) {
raw, err := c.GetRawData()
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
var req aiwire.RespRequest
if err := json.Unmarshal(raw, &req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if !checkKeyModel(c, req.Model) {
return
}
h.responsesPassthrough(c, raw, req)
}
// responsesPassthrough 直通链路(唯一上游):原始 body 改写后直达
// OCI /actions/v1/responses,响应原样透传。
func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req aiwire.RespRequest) {
if err := service.RespPassthroughValidate(req); err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
body, err := service.RespPassthroughBody(raw)
if err != nil {
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if req.Stream {
h.responsesPassthroughStream(c, body, req)
return
}
start := time.Now()
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "responses", req.Model, false, meta, start)
if err != nil {
upstreamError(c, err)
entry.ErrMsg = err.Error()
h.logFailure(c, entry, req)
return
}
entry.Status = http.StatusOK
fillUsage(&entry, service.RespPassthroughUsage(payload))
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "responses", req.Model, false, req, json.RawMessage(payload))
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
}
// responsesPassthroughStream 流式直通:SSE 事件原样转发(推理增量等直达客户端),
// 逐行扫描 completed 事件提取 usage 记账。
func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byte, req aiwire.RespRequest) {
start := time.Now()
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
entry := h.logEntry(c, "responses", 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)
usage, err := forwardSSE(c, upstream)
if err != nil {
entry.ErrMsg = err.Error()
}
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
fillUsage(&entry, usage)
callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "responses", req.Model, true, req, nil)
}
// forwardSSE 把上游 SSE 逐行转发给客户端,空行(事件边界)即 flush;
// 顺带从 data 行提取 response.completed 的 usage。
func forwardSSE(c *gin.Context, upstream io.Reader) (*aiwire.Usage, error) {
reader := bufio.NewReader(upstream)
var usage *aiwire.Usage
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
c.Writer.Write(line)
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 {
c.Writer.Flush()
} else if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
if u := service.RespStreamCompletedUsage(data); u != nil {
usage = u
}
}
}
if err != nil {
c.Writer.Flush()
if errors.Is(err, io.EOF) {
return usage, nil
}
return usage, err
}
}
}