feat: 余额预扣 reserve/capture、冷却兜底重试、Prometheus /metrics

- 预扣:每请求按 body 估算成本上界计入冻结额(1e-8 USD 原子计数),402 判定
  改为「余额 - 在途冻结 ≤ 0」,并发的长流式请求不再互相透支;OT_PROXY_RESERVE_MAX_TOKENS
  控制估算基数(默认 4096,0=关闭),handler defer 释放,失败/中断/panic 均覆盖
- 调度:全部候选都在运行期冷却时,剩余冷却 ≤ OT_PROXY_COOLDOWN_GRACE 的最早恢复渠道
  兜底重试一次而不是 503(429 Retry-After 语义);DB 层不可用的渠道不参与兜底
- 指标:openteam_proxy_requests_total / request_duration / channel_failures /
  usage_queue_depth / frozen_balance,/metrics 默认关闭(OT_METRICS_ENABLED 开启)
This commit is contained in:
Sakurasan committed 2026-11-09 01:04:40 +08:00
1 parent b2e500ff51
commit 0e10f0bbde
14 files changed
+493 -36

No files matched your search

+112 -1
View File
@@ -16,11 +16,14 @@ import (
"github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/pkg/apikey"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/pkg/metrics"
"github.com/openteam/server/internal/pkg/ratelimit"
"github.com/openteam/server/internal/proxy/convert"
"github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"sync/atomic"
)
const (
@@ -29,6 +32,11 @@ const (
CtxKeyID = "proxy_key_id"
CtxKeyModel = "proxy_key_models"
CtxTrace = "proxy_trace_id"
// CtxReserve 本请求的预扣(冻结)金额,单位 1e-8 USD(与 numeric(20,8) 对齐)。
CtxReserve = "proxy_reserve_1e8"
// CtxForceCooling 候选集来自「全冷却时最早恢复」的兜底,选路时放行冷却中的渠道。
CtxForceCooling = "proxy_force_cooling"
)
type Gateway struct {
@@ -49,6 +57,16 @@ type Gateway struct {
// 按失败类型限制换渠道次数(0=不限):429/配额类失败是瞬时返回,
// 时间预算对它们形同虚设,按次数封顶才有意义。
failoverCaps map[channel.FailKind]int
// 冷却兜底宽限:全部候选都在运行期冷却时,最早恢复的渠道剩余冷却 ≤ 此值
// 就带上它兜底重试一次(429 的 Retry-After 语义本就是"到点再试");0=关闭。
cooldownGrace time.Duration
// 预扣(reserve/capture):每请求按 body 估算成本上界计入冻结额,
// 402 判定改为「余额 - 在途冻结 ≤ 0」,并发的长流式请求不再能把余额拖到深度负值。
// reserveMaxTokens 是输出 token 估算基数(0=关闭预扣);冻结额按用户分桶存
// 1e-8 USD 整数(每用户一个 atomic.Int64),冻结/释放 O(1) 无锁竞争。
reserveMaxTokens int
frozen sync.Map // uint64(用户ID) → *atomic.Int64(1e-8 USD)
frozenTotal atomic.Int64 // 全局冻结合计(指标用)
// 流式空闲多久补一行 SSE 注释心跳(0=关闭)。反向代理的空闲超时会掐掉长思考的流。
streamHeartbeat time.Duration
// 流式放行前等多久内容(0=不限制)。超时未出内容取消本次尝试换下一个渠道。
@@ -226,6 +244,8 @@ type Options struct {
MaxBodyMB int // /v1 请求体上限(MB)
FailoverBudget time.Duration // 故障转移重试的时间预算
FailoverSwitchCaps map[string]int // 按失败类型(net|server|rate|quota)限制换渠道次数,0=不限
CooldownGrace time.Duration // 全冷却时最早恢复渠道的兜底宽限(0=关闭)
ReserveMaxTokens int // 预扣估算的最大输出 token(0=关闭预扣)
StreamHeartbeat time.Duration // 流式空闲心跳间隔
FirstTokenWait time.Duration // 流式放行前等内容的最长时间(0=不限)
AffinityTTL time.Duration // 会话亲和 TTL(0=关闭)
@@ -267,6 +287,8 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ra
maxBodyMB: o.MaxBodyMB,
failoverBudget: o.FailoverBudget,
failoverCaps: failoverCapsFrom(o.FailoverSwitchCaps),
cooldownGrace: o.CooldownGrace,
reserveMaxTokens: o.ReserveMaxTokens,
streamHeartbeat: o.StreamHeartbeat,
firstTokenWait: o.FirstTokenWait,
affinityTTL: o.AffinityTTL,
@@ -500,6 +522,23 @@ func (g *Gateway) candidateChannels(model string) []channel.Candidate {
return g.ch.Candidates(model)
}
// candidatesFor 候选获取的统一入口:健康候选为空且配置了冷却宽限时,
// 找冷却最早结束的候选兜底重试一次(429 的 Retry-After 语义本就是"到点再试",
// 与其立刻 503 不如试试最先恢复的那条)。命中兜底时在 context 打标,
// doProxy 选路会放行这个冷却中的渠道。
func (g *Gateway) candidatesFor(c *gin.Context, model string) []channel.Candidate {
cands := g.candidateChannels(model)
if len(cands) > 0 || g.cooldownGrace <= 0 {
return cands
}
cand, remaining, ok := g.ch.EarliestRecovery(model)
if !ok || remaining > g.cooldownGrace {
return nil
}
c.Set(CtxForceCooling, true)
return []channel.Candidate{cand}
}
// resolveUser 取当前用户(含余额)。Auth 中间件已查过一次并放入上下文,
// handler 里直接复用,省掉每请求一次重复查询。
func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
@@ -518,14 +557,86 @@ func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
}
// checkBalance 余额不足返回 402(PLANNING §4.4.3)。
// 可用余额 = 余额 - 在途预扣(冻结)额:并发流式请求各持一份冻结,
// 不会互相透支把余额拖到深度负值(冻结语义见 freezeFor)。
func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
if !u.Balance.IsPositive() {
avail := u.Balance
if a := g.frozenOf(u.ID); a != nil && a.Load() > 0 {
avail = avail.Sub(decimal.NewFromInt(a.Load()).Shift(-8))
}
if !avail.IsPositive() {
apiError(c, http.StatusPaymentRequired, "insufficient_balance", "Insufficient balance. Please recharge or contact admin.")
return false
}
return true
}
// frozenOf 取用户的在途冻结额桶;无冻结返回 nil(零分配快路径)。
func (g *Gateway) frozenOf(uid uint64) *atomic.Int64 {
if v, ok := g.frozen.Load(uid); ok {
return v.(*atomic.Int64)
}
return nil
}
// freezeFor 预扣(reserve):按请求体估算本请求的成本上界并计入冻结额,随 gin context
// 携带,请求终结(含失败/中断/panic,由 handler 的 defer 释放)时按同一数值 capture。
// 估算只求上界不求准:输入按 ~4 字节/token 粗估并用缓存写价(1.25×/2× 输入价)封顶,
// 输出取 max_tokens(缺省用 reserveMaxTokens 基数)。
// 冻结只在本进程内存里,单实例语义;多实例部署时各实例独立冻结(余额本身允许为负兜底)。
// 检查(checkBalance)与冻结之间存在乐观竞窗:最坏超额 = 同时穿过竞窗的请求数,
// 有界且被「余额允许为负」兜底;预扣把无界的流式透支压成了这个常数。
func (g *Gateway) freezeFor(c *gin.Context, uid uint64, model store.Model, br *bodyReq, body []byte) {
if g.reserveMaxTokens <= 0 || model.ID == 0 {
return
}
outTok := int64(g.reserveMaxTokens)
if br != nil {
if br.MaxTokens != nil && *br.MaxTokens > 0 && int64(*br.MaxTokens) < outTok {
outTok = int64(*br.MaxTokens)
}
if br.MaxOutputTokens != nil && *br.MaxOutputTokens > 0 && int64(*br.MaxOutputTokens) < outTok {
outTok = int64(*br.MaxOutputTokens)
}
}
inPrice := model.InputPrice
if cwp := model.CacheWritePriceOr(); cwp.GreaterThan(inPrice) {
inPrice = cwp
}
million := decimal.NewFromInt(1e6)
est := decimal.NewFromInt(int64(len(body) / 4)).Mul(inPrice).
Add(decimal.NewFromInt(outTok).Mul(model.OutputPrice)).
Div(million).Round(8)
n := est.Shift(8).Ceil().BigInt().Int64() // 1e-8 USD 整数,向上取整保上界
if n <= 0 {
return
}
v, _ := g.frozen.LoadOrStore(uid, &atomic.Int64{})
v.(*atomic.Int64).Add(n)
g.frozenTotal.Add(n)
metrics.FrozenBalance.Set(float64(g.frozenTotal.Load()) / 1e8)
c.Set(CtxReserve, n)
}
// unfreeze 释放冻结额(capture)。幂等:CtxReserve 置零后重复调用为 no-op,
// handler 的 defer 与任何终结路径的双释放只生效一次。
func (g *Gateway) unfreeze(c *gin.Context, uid uint64) {
v, ok := c.Get(CtxReserve)
if !ok {
return
}
n, _ := v.(int64)
c.Set(CtxReserve, int64(0))
if n == 0 {
return
}
if a := g.frozenOf(uid); a != nil {
a.Add(-n)
}
total := g.frozenTotal.Add(-n)
metrics.FrozenBalance.Set(float64(total) / 1e8)
}
// ---------------------------------------------------------------------------
// 协议分派
+21 -3
View File
@@ -46,7 +46,13 @@ func (g *Gateway) chatCompletions(c *gin.Context) {
return
}
cands := g.candidateChannels(br.Model)
// 预扣(reserve):按 body 估算成本上界计入冻结额;defer 释放(capture),
// 失败/中断/panic 都会走到,finishUsage 的扣款在冻结释放前完成。
model, _ := g.modelByName(br.Model)
g.freezeFor(c, u.ID, model, br, body)
defer g.unfreeze(c, u.ID)
cands := g.candidatesFor(c, br.Model)
if len(cands) == 0 {
apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
@@ -82,7 +88,13 @@ func (g *Gateway) responses(c *gin.Context) {
return
}
cands := g.candidateChannels(br.Model)
// 预扣(reserve):按 body 估算成本上界计入冻结额;defer 释放(capture),
// 失败/中断/panic 都会走到,finishUsage 的扣款在冻结释放前完成。
model, _ := g.modelByName(br.Model)
g.freezeFor(c, u.ID, model, br, body)
defer g.unfreeze(c, u.ID)
cands := g.candidatesFor(c, br.Model)
if len(cands) == 0 {
apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
@@ -118,7 +130,13 @@ func (g *Gateway) messages(c *gin.Context) {
return
}
cands := g.candidateChannels(br.Model)
// 预扣(reserve):按 body 估算成本上界计入冻结额;defer 释放(capture),
// 失败/中断/panic 都会走到,finishUsage 的扣款在冻结释放前完成。
model, _ := g.modelByName(br.Model)
g.freezeFor(c, u.ID, model, br, body)
defer g.unfreeze(c, u.ID)
cands := g.candidatesFor(c, br.Model)
if len(cands) == 0 {
apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
+15 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/pkg/metrics"
"github.com/openteam/server/internal/pkg/tokenizer"
"github.com/openteam/server/internal/proxy/convert"
"github.com/openteam/server/internal/store"
@@ -34,6 +35,9 @@ func newTraceID() string {
type bodyReq struct {
Model string `json:"model"`
Stream bool `json:"stream"`
// 预扣估算用(freezeFor):chat/messages 的 max_tokens、responses 的 max_output_tokens
MaxTokens *int `json:"max_tokens"`
MaxOutputTokens *int `json:"max_output_tokens"`
}
// parseBody 读取并回填请求体,解析 model/stream。
@@ -283,6 +287,8 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
// 那不是「上游坏了」(502),而是「我方满了」(503 + Retry-After)。
saturated := false
startAll := time.Now()
// 全冷却兜底:候选来自 EarliestRecovery 时放行冷却中的渠道(只此一次尝试机会)
_, forceCooling := c.Get(CtxForceCooling)
// 换渠道次数按失败类型计数:cap 到了就不再为同类失败换渠道(配置见 failover_caps)。
switches := map[channel.FailKind]int{}
var lastKind channel.FailKind
@@ -311,7 +317,7 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
g.finishUsage(c, ch, now(), store.UsageStatusCanceled, "client_disconnect")
return
}
if g.ch.InCooldown(ch.ID) {
if !forceCooling && g.ch.InCooldown(ch.ID) {
continue // 本次请求期间已被并发请求判入运行期冷却
}
plan, err := prepareUpstream(ch, clientProto, body, cand.UpstreamModel)
@@ -1071,6 +1077,14 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
traceStr, _ := trace.(string)
latency := int(time.Since(start).Milliseconds())
// 指标:终结计数与耗时。channel 为 "none" 表示没打到任何渠道(无候选/满载等)
chName := "none"
if ch != nil {
chName = ch.Name
}
metrics.RequestsTotal.WithLabelValues(chName, mn, status).Inc()
metrics.RequestDuration.WithLabelValues(chName).Observe(time.Since(start).Seconds())
if status == store.UsageStatusSuccess && c.Writer.Status() >= 400 {
status = store.UsageStatusError
}
+85
View File
@@ -509,6 +509,91 @@ func TestFailoverSwitchCaps(t *testing.T) {
}
}
// 预扣(reserve/capture):在途请求的冻结额让「余额 - 冻结 ≤ 0」触发 402,
// 释放后恢复放行。并发的长流式请求不再互相透支。
func TestReserveFreezesBalance(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"id":"x","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
defer srv.Close()
h := newHarness(t, srv.URL, 0, true, nil)
h.gw.reserveMaxTokens = 4096
// 余额压到 0.001:4096 out × 0.6/1e6 = 0.00246 的冻结上界必然覆盖它
if err := h.db.Model(&store.User{}).Where("username = ?", "u1").
Update("balance", 0.001).Error; err != nil {
t.Fatal(err)
}
var u store.User
h.db.Where("username = ?", "u1").First(&u)
var model store.Model
h.db.Where("name = ?", "m1").First(&model)
// 模拟一条在途请求的冻结(不释放)
fr, _ := gin.CreateTestContext(httptest.NewRecorder())
fr.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.gw.freezeFor(fr, u.ID, model, &bodyReq{MaxTokens: nil}, make([]byte, 64))
if w := h.run(t, nil, `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`); w.Code != http.StatusPaymentRequired {
t.Fatalf("冻结额覆盖余额时应 402,实际 %d: %s", w.Code, w.Body.String())
}
// capture(释放)后恢复放行
h.gw.unfreeze(fr, u.ID)
if a := h.gw.frozenOf(u.ID); a != nil && a.Load() != 0 {
t.Fatalf("释放后冻结额应为 0,实际 %d", a.Load())
}
w := h.run(t, nil, `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`)
if w.Code != http.StatusOK {
t.Fatalf("释放后应正常代理,实际 %d: %s", w.Code, w.Body.String())
}
// 幂等:重复 unfreeze 不产生负冻结
h.gw.unfreeze(fr, u.ID)
if a := h.gw.frozenOf(u.ID); a != nil && a.Load() < 0 {
t.Fatalf("重复释放不应产生负冻结,实际 %d", a.Load())
}
}
// 冷却兜底宽限:唯一渠道进运行期冷却后,请求 503;配置 grace 且上游已恢复时,
// 带上冷却中的渠道兜底重试一次即成功。
func TestCooldownGraceFallback(t *testing.T) {
var healthy atomic.Bool
healthy.Store(false)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !healthy.Load() {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"error":{"message":"boom"}}`)
return
}
_, _ = io.WriteString(w, `{"id":"x","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
defer srv.Close()
h := newHarness(t, srv.URL, 0, true, nil)
body := `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`
// 连续 3 次 5xx:触发运行期冷却(failThreshold=3)
for i := 0; i < 3; i++ {
h.run(t, nil, body)
}
// 默认 grace=0(关闭):冷却中 → 无候选 → 503
if w := h.run(t, nil, body); w.Code != http.StatusServiceUnavailable {
t.Fatalf("冷却中且未配置宽限时应 503,实际 %d: %s", w.Code, w.Body.String())
}
// 上游恢复 + 配置宽限:兜底重试冷却中的渠道 → 200
healthy.Store(true)
h.gw.cooldownGrace = time.Minute
if w := h.run(t, nil, body); w.Code != http.StatusOK {
t.Fatalf("宽限期内应兜底重试恢复的渠道,实际 %d: %s", w.Code, w.Body.String())
}
// 冷却截止时间未被成功清除(由 InCooldown 到期惰性恢复,语义不变),
// 但宽限期内每次请求都能经兜底打到这条渠道
if w := h.run(t, nil, body); w.Code != http.StatusOK {
t.Fatalf("宽限期内后续请求也应走兜底,实际 %d: %s", w.Code, w.Body.String())
}
}
// 流式空闲时补 SSE 注释心跳:上游长时间不出字(长思考)不能被反代空闲超时掐掉。
func TestStreamHeartbeatWhileIdle(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {