0.11.0: 旗舰版功能补齐(License/登录记录/会话管理/角色管理/门户定时任务/模型配额/输出脱敏/供应链扫描/记忆管理/AI助手/真实概览)
- 新增迁移 000031-000034(登录日志/角色/模型配额/记忆) - 新增包: license/memory/modelquota/assistant,扫描引擎 - 全部功能后端+前端+端到端验证通过(25 包单测)
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// outputRedactReadCloser 对上游模型响应应用输出侧脱敏(隐私信息拦截替换):
|
||||
// - 非流式(application/json):首次 Read 前缓冲整个响应,处理后再输出;
|
||||
// - 流式(text/event-stream):逐 data 行处理,不改变事件边界。
|
||||
type outputRedactReadCloser struct {
|
||||
io.ReadCloser
|
||||
engine interface {
|
||||
OutputRedact([]byte) ([]byte, bool)
|
||||
}
|
||||
sse bool
|
||||
buffered []byte // 已处理待输出的字节
|
||||
done bool // 非流式已完成缓冲与处理
|
||||
pending []byte // 流式:未完成的行
|
||||
}
|
||||
|
||||
func newOutputRedactReadCloser(body io.ReadCloser, contentType string, engine interface {
|
||||
OutputRedact([]byte) ([]byte, bool)
|
||||
}) io.ReadCloser {
|
||||
if engine == nil {
|
||||
return body
|
||||
}
|
||||
return &outputRedactReadCloser{
|
||||
ReadCloser: body, engine: engine,
|
||||
sse: strings.Contains(strings.ToLower(contentType), "text/event-stream"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *outputRedactReadCloser) Read(buffer []byte) (int, error) {
|
||||
if !r.sse {
|
||||
// 非流式:首次 Read 时一次性缓冲+处理。
|
||||
if !r.done {
|
||||
r.done = true
|
||||
raw, err := io.ReadAll(r.ReadCloser)
|
||||
_ = err
|
||||
if replaced, changed := r.engine.OutputRedact(raw); changed {
|
||||
r.buffered = replaced
|
||||
} else {
|
||||
r.buffered = raw
|
||||
}
|
||||
}
|
||||
if len(r.buffered) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(buffer, r.buffered)
|
||||
r.buffered = r.buffered[n:]
|
||||
if len(r.buffered) == 0 {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
// 流式:先输出已处理的行,再读上游。
|
||||
if len(r.buffered) > 0 {
|
||||
n := copy(buffer, r.buffered)
|
||||
r.buffered = r.buffered[n:]
|
||||
return n, nil
|
||||
}
|
||||
chunk := make([]byte, 32<<10)
|
||||
n, err := r.ReadCloser.Read(chunk)
|
||||
if n > 0 {
|
||||
r.pending = append(r.pending, chunk[:n]...)
|
||||
r.processLines()
|
||||
}
|
||||
if err == io.EOF && len(r.pending) > 0 {
|
||||
// 流结束:剩余不完整行原样输出(不处理,避免破坏事件边界)。
|
||||
r.buffered = append(r.buffered, r.pending...)
|
||||
r.pending = nil
|
||||
}
|
||||
if len(r.buffered) > 0 {
|
||||
n2 := copy(buffer, r.buffered)
|
||||
r.buffered = r.buffered[n2:]
|
||||
return n2, err
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// processLines 把 pending 中的完整行处理进 buffered。
|
||||
func (r *outputRedactReadCloser) processLines() {
|
||||
for {
|
||||
index := bytes.IndexByte(r.pending, '\n')
|
||||
if index < 0 {
|
||||
return
|
||||
}
|
||||
line := r.pending[:index]
|
||||
r.pending = r.pending[index+1:]
|
||||
trimmed := strings.TrimSpace(string(line))
|
||||
if !strings.HasPrefix(trimmed, "data:") {
|
||||
r.buffered = append(r.buffered, line...)
|
||||
r.buffered = append(r.buffered, '\n')
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
|
||||
if payload == "" || payload == "[DONE]" {
|
||||
r.buffered = append(r.buffered, line...)
|
||||
r.buffered = append(r.buffered, '\n')
|
||||
continue
|
||||
}
|
||||
if replaced, changed := r.engine.OutputRedact([]byte(payload)); changed {
|
||||
r.buffered = append(r.buffered, []byte("data: "+string(replaced)+"\n")...)
|
||||
} else {
|
||||
r.buffered = append(r.buffered, line...)
|
||||
r.buffered = append(r.buffered, '\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,14 @@ type Proxy struct {
|
||||
circuits sync.Map
|
||||
admission AdmissionController
|
||||
tokenQuota TokenQuotaController
|
||||
modelQuota ModelQuotaController
|
||||
resilience ResiliencePolicy
|
||||
audit AuditRecorder
|
||||
policies *contentpolicy.Engine
|
||||
pricing *pricing.Service
|
||||
outputPolicies interface {
|
||||
OutputRedact([]byte) ([]byte, bool)
|
||||
}
|
||||
}
|
||||
|
||||
type cachedProxy struct {
|
||||
@@ -99,6 +103,11 @@ func (p *Proxy) SetTokenQuotaController(controller TokenQuotaController) {
|
||||
p.tokenQuota = controller
|
||||
}
|
||||
|
||||
// SetModelQuotaController 启用模型级 Token 配额(provider 解析后预留)。
|
||||
func (p *Proxy) SetModelQuotaController(controller ModelQuotaController) {
|
||||
p.modelQuota = controller
|
||||
}
|
||||
|
||||
func (p *Proxy) SetResiliencePolicy(policy ResiliencePolicy) {
|
||||
p.resilience = policy
|
||||
p.transport.ResponseHeaderTimeout = policy.ResponseHeaderTimeout
|
||||
@@ -106,6 +115,13 @@ func (p *Proxy) SetResiliencePolicy(policy ResiliencePolicy) {
|
||||
|
||||
func (p *Proxy) SetAuditRecorder(recorder AuditRecorder) { p.audit = recorder }
|
||||
func (p *Proxy) SetContentPolicyEngine(engine *contentpolicy.Engine) { p.policies = engine }
|
||||
|
||||
// SetOutputPolicyEngine 启用输出侧脱敏(模型回答隐私拦截替换)。
|
||||
func (p *Proxy) SetOutputPolicyEngine(engine interface {
|
||||
OutputRedact([]byte) ([]byte, bool)
|
||||
}) {
|
||||
p.outputPolicies = engine
|
||||
}
|
||||
func (p *Proxy) SetPricingService(service *pricing.Service) { p.pricing = service }
|
||||
|
||||
func (p *Proxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
@@ -288,6 +304,30 @@ func (p *Proxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
request.Header.Del("X-Gateway-Provider")
|
||||
writer.Header().Set("X-Gateway-Provider", resolved.Code)
|
||||
span.setRoute(resolved.Code, writer.Header().Get("X-Gateway-Model"))
|
||||
// 模型级配额(企业总配额,所有 Key 共享):在确定 provider 与 model 后
|
||||
// 预留,与 API Key 级配额叠加;未配置配额或额度不足时按 429 处理。
|
||||
if p.modelQuota != nil && usage != nil && modelPayload.model != "" {
|
||||
modelQuota, quotaErr := p.modelQuota.Reserve(request.Context(), resolved.Code, modelPayload.model, estimateForReserve(p, request, usage), time.Now())
|
||||
if quotaErr != nil {
|
||||
writeOpenAIError(writer, http.StatusServiceUnavailable, "model_quota_unavailable", "model quota service is unavailable")
|
||||
return
|
||||
}
|
||||
if modelQuota != nil {
|
||||
if reservation, ok := modelQuota.(interface {
|
||||
AllowedFlag() bool
|
||||
RemainingTokens() int64
|
||||
ResetTime() time.Time
|
||||
}); ok {
|
||||
if !reservation.AllowedFlag() {
|
||||
writeOpenAIError(writer, http.StatusTooManyRequests, "insufficient_quota", "model token quota exceeded")
|
||||
return
|
||||
}
|
||||
writer.Header().Set("X-ModelTokenLimit-Remaining", strconv.FormatInt(max(reservation.RemainingTokens(), 0), 10))
|
||||
usage.modelController = p.modelQuota
|
||||
usage.modelReservation = modelQuota
|
||||
}
|
||||
}
|
||||
}
|
||||
request.Body = span.captureBody(request.Body)
|
||||
p.proxyFor(resolved).ServeHTTP(writer, request)
|
||||
}
|
||||
@@ -323,6 +363,11 @@ func (p *Proxy) proxyFor(resolved ResolvedAdapter) *httputil.ReverseProxy {
|
||||
session.finish(TokenUsage{})
|
||||
}
|
||||
}
|
||||
// 输出侧脱敏:模型回答隐私信息拦截替换(仅 2xx 且启用了输出策略时)。
|
||||
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices && p.outputPolicies != nil {
|
||||
response.Body = newOutputRedactReadCloser(response.Body, response.Header.Get("Content-Type"), p.outputPolicies)
|
||||
response.Header.Set("X-Gateway-Output-Redacted", "true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
reverseProxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, err error) {
|
||||
@@ -351,6 +396,18 @@ func (p *Proxy) proxyFor(resolved ResolvedAdapter) *httputil.ReverseProxy {
|
||||
return reverseProxy
|
||||
}
|
||||
|
||||
// estimateForReserve 复用已有 usage session 的预留估算;不可用时回退 0。
|
||||
func estimateForReserve(p *Proxy, request *http.Request, usage *usageSession) int64 {
|
||||
if usage != nil && usage.reservation.Reserved > 0 {
|
||||
return usage.reservation.Reserved
|
||||
}
|
||||
estimate, err := prepareTokenBudget(request, p.maxBody)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return estimate
|
||||
}
|
||||
|
||||
func (p *Proxy) authorized(request *http.Request) (apikey.Principal, error) {
|
||||
presented := strings.TrimSpace(request.Header.Get("X-Gateway-API-Key"))
|
||||
if presented == "" {
|
||||
|
||||
@@ -22,10 +22,19 @@ const maxUsageDocumentBytes = 2 << 20
|
||||
type usageSession struct {
|
||||
controller TokenQuotaController
|
||||
reservation TokenReservation
|
||||
fallback int64
|
||||
onFinish func(TokenUsage)
|
||||
once sync.Once
|
||||
log *slog.Logger
|
||||
// modelController/modelReservation 是模型级配额(可选,企业模型总配额)。
|
||||
modelController ModelQuotaController
|
||||
modelReservation any
|
||||
fallback int64
|
||||
onFinish func(TokenUsage)
|
||||
once sync.Once
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// ModelQuotaController 抽象模型级配额控制器,避免 gateway 依赖 modelquota 包。
|
||||
type ModelQuotaController interface {
|
||||
Reserve(ctx context.Context, providerCode, model string, estimate int64, now time.Time) (any, error)
|
||||
Commit(ctx context.Context, reservation any, actual int64) error
|
||||
}
|
||||
|
||||
type TokenUsage struct {
|
||||
@@ -64,6 +73,13 @@ func (s *usageSession) finish(usage TokenUsage) {
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
if s.modelController != nil && s.modelReservation != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
if err := s.modelController.Commit(ctx, s.modelReservation, usage.Total); err != nil && s.log != nil {
|
||||
s.log.Warn("model quota commit failed", "error", err)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
if s.onFinish != nil {
|
||||
s.onFinish(usage)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user