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:
1 parent
b2e500ff51
commit
0e10f0bbde
14 files changed
+493
-36
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 协议分派
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user