feat: 金额 decimal 精确计费、failover 按失败类型封顶、渠道小时桶预聚合、分层守护测试

- 计费:19 个金额字段 float64→decimal.Decimal,DB 列保持 numeric(20,8) 零迁移;
  API 金额仍为 JSON number(MarshalJSONWithoutQuotes),前端零改动
- failover:保留时间预算,新增 OT_PROXY_FAILOVER_CAPS_NET/SERVER/RATE/QUOTA
  按失败类型封顶换渠道次数(429/配额类瞬时失败时间预算形同虚设)
- 预聚合:新增 usage_channel_hourlies(schema v10),记账事务内同步 upsert,
  长窗口渠道统计走桶不再扫明细,spent_last_30d 改扫 usage_daily,v10 一次性回填幂等
- 守护:internal/arch_test.go 用 go/parser 强制分层依赖(fail-closed)
This commit is contained in:
Sakurasan committed 2026-11-09 00:41:48 +08:00
1 parent 5d6e5bdb04
commit b2e500ff51
25 files changed
+841 -253

No files matched your search

+35 -9
View File
@@ -46,6 +46,9 @@ type Gateway struct {
// 故障转移给"第二个及以后的渠道"留的时间预算;超了就直接返回最后一个错误,
// 不限制首个渠道(长回答本来就可能跑很久)。
failoverBudget time.Duration
// 按失败类型限制换渠道次数(0=不限):429/配额类失败是瞬时返回,
// 时间预算对它们形同虚设,按次数封顶才有意义。
failoverCaps map[channel.FailKind]int
// 流式空闲多久补一行 SSE 注释心跳(0=关闭)。反向代理的空闲超时会掐掉长思考的流。
streamHeartbeat time.Duration
// 流式放行前等多久内容(0=不限制)。超时未出内容取消本次尝试换下一个渠道。
@@ -219,14 +222,36 @@ func contains(list []string, s string) bool {
type Options struct {
UserRPS int
LogRaw bool
GWTimeout time.Duration // 上游超时兜底(渠道 timeout_ms 优先)
MaxBodyMB int // /v1 请求体上限(MB)
FailoverBudget time.Duration // 故障转移重试的时间预算
StreamHeartbeat time.Duration // 流式空闲心跳间隔
FirstTokenWait time.Duration // 流式放行前等内容的最长时间(0=不限)
AffinityTTL time.Duration // 会话亲和 TTL(0=关闭)
ChannelAcquireWait time.Duration // 渠道满载排队时长(0=立刻跳过)
AuthCacheTTL time.Duration // 鉴权缓存 TTL(0=关闭)
GWTimeout time.Duration // 上游超时兜底(渠道 timeout_ms 优先)
MaxBodyMB int // /v1 请求体上限(MB)
FailoverBudget time.Duration // 故障转移重试的时间预算
FailoverSwitchCaps map[string]int // 按失败类型(net|server|rate|quota)限制换渠道次数,0=不限
StreamHeartbeat time.Duration // 流式空闲心跳间隔
FirstTokenWait time.Duration // 流式放行前等内容的最长时间(0=不限)
AffinityTTL time.Duration // 会话亲和 TTL(0=关闭)
ChannelAcquireWait time.Duration // 渠道满载排队时长(0=立刻跳过)
AuthCacheTTL time.Duration // 鉴权缓存 TTL(0=关闭)
}
// failoverCapsFrom 把字符串键的失败类型上限配置转成 FailKind 键;未知键忽略(配置容错)。
func failoverCapsFrom(m map[string]int) map[channel.FailKind]int {
if len(m) == 0 {
return nil
}
out := make(map[channel.FailKind]int, len(m))
for k, v := range m {
switch k {
case "net":
out[channel.FailNet] = v
case "server":
out[channel.FailServer] = v
case "rate":
out[channel.FailRate] = v
case "quota":
out[channel.FailQuota] = v
}
}
return out
}
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, o Options) *Gateway {
@@ -241,6 +266,7 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ra
gwTimeout: o.GWTimeout,
maxBodyMB: o.MaxBodyMB,
failoverBudget: o.FailoverBudget,
failoverCaps: failoverCapsFrom(o.FailoverSwitchCaps),
streamHeartbeat: o.StreamHeartbeat,
firstTokenWait: o.FirstTokenWait,
affinityTTL: o.AffinityTTL,
@@ -493,7 +519,7 @@ func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
// checkBalance 余额不足返回 402(PLANNING §4.4.3)。
func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
if u.Balance <= 0 {
if !u.Balance.IsPositive() {
apiError(c, http.StatusPaymentRequired, "insufficient_balance", "Insufficient balance. Please recharge or contact admin.")
return false
}
+53 -30
View File
@@ -20,6 +20,7 @@ import (
"github.com/openteam/server/internal/pkg/tokenizer"
"github.com/openteam/server/internal/proxy/convert"
"github.com/openteam/server/internal/store"
"github.com/shopspring/decimal"
)
// newTraceID 生成请求 trace(用于日志与记账幂等 ref)。
@@ -282,13 +283,25 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
// 那不是「上游坏了」(502),而是「我方满了」(503 + Retry-After)。
saturated := false
startAll := time.Now()
// 换渠道次数按失败类型计数:cap 到了就不再为同类失败换渠道(配置见 failover_caps)。
switches := map[channel.FailKind]int{}
var lastKind channel.FailKind
// 从选中的渠道开始遍历,到末尾后再从头遍历到选中渠道之前
for offset := 0; offset < len(cands); offset++ {
// 重试预算只约束第二个及以后的渠道:首个渠道不受限(长回答本就要跑几分钟),
// 但坏渠道一个接一个地等下去会让客户端挂到 N×timeout。
if offset > 0 && g.failoverBudget > 0 && time.Since(startAll) > g.failoverBudget {
lastBody = []byte("failover budget exhausted: " + string(lastBody))
break
if offset > 0 {
if g.failoverBudget > 0 && time.Since(startAll) > g.failoverBudget {
lastBody = []byte("failover budget exhausted: " + string(lastBody))
break
}
// 429/配额类失败瞬时返回,时间预算对它们形同虚设;按同类失败次数封顶。
// switches 记该类型已发生的失败次数(= 已换渠道次数),本次是第
// switches+1 次换渠道,超过上限即停。
if cap := g.failoverCaps[lastKind]; cap > 0 && switches[lastKind] > cap {
lastBody = []byte("failover switch cap reached (" + lastKind.String() + "): " + string(lastBody))
break
}
}
idx := (startIdx + offset) % len(cands)
cand := cands[idx]
@@ -314,7 +327,7 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
}
// 失败/成功计数由 proxyOne 就地报:只有它读得到上游响应头(429 的 Retry-After)
lastCh = ch
done, served, retry, st, b := g.proxyOne(c, ch, plan, stream, sink, sh, clientProto)
done, served, retry, st, b, fkind := g.proxyOne(c, ch, plan, stream, sink, sh, clientProto)
release()
if done {
if served && hasAff {
@@ -336,6 +349,8 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
return
}
lastStatus, lastBody = st, b
switches[fkind]++
lastKind = fkind
}
// 全部候选都没打出去、且都是被并发满载挡住的:503 让客户端稍后重试,
// 而不是 502(那意味着上游故障,客户端通常会直接放弃)。
@@ -404,11 +419,13 @@ func streamErrorEvent(proto string, status int, msg string) []byte {
// served=该渠道成功应答(会话亲和据此绑定);retry=是否可安全换渠道重试;
// status+respBody=失败信息。
// sh 为流式容错缓冲(非流式为 nil);sink 为请求级记账 sink。
func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan, stream bool, sink *usageSink, sh *streamHold, clientProto string) (done bool, served bool, retry bool, status int, respBody []byte) {
// 最后一个返回值 fkind 是本次尝试的失败类型(仅 retry=true 时被 doProxy 消费,
// 用于按失败类型统计换渠道次数)。
func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan, stream bool, sink *usageSink, sh *streamHold, clientProto string) (done bool, served bool, retry bool, status int, respBody []byte, fkind channel.FailKind) {
upKey, err := g.ch.UpstreamKey(ch)
if err != nil {
g.ch.ReportFailure(ch, channel.FailNet, 0)
return false, false, true, http.StatusInternalServerError, []byte("failed to decrypt channel key")
return false, false, true, http.StatusInternalServerError, []byte("failed to decrypt channel key"), channel.FailNet
}
upBody := plan.body
@@ -449,7 +466,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
req, err := http.NewRequestWithContext(tryCtx, http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
if err != nil {
return false, false, false, http.StatusInternalServerError, []byte("failed to build upstream request")
return false, false, false, http.StatusInternalServerError, []byte("failed to build upstream request"), channel.FailNet
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+upKey)
@@ -478,10 +495,10 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
// 客户端断开导致的取消:就地记账并终结本次请求(不计入渠道失败,也不换渠道)
if c.Request.Context().Err() != nil {
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
return true, false, false, 0, nil
return true, false, false, 0, nil, channel.FailNet
}
g.ch.ReportFailure(ch, channel.FailNet, 0) // 网络错误/超时
return false, false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error())
return false, false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error()), channel.FailNet
}
defer resp.Body.Close()
@@ -492,9 +509,9 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
kind, retryable := classifyUpstreamError(resp.StatusCode, errBody)
if retryable {
g.ch.ReportFailure(ch, kind, retryAfter(resp.Header))
return false, false, true, resp.StatusCode, errBody
return false, false, true, resp.StatusCode, errBody, kind
}
return false, false, false, resp.StatusCode, errBody
return false, false, false, resp.StatusCode, errBody, kind
}
g.ch.ReportSuccess(ch)
@@ -510,7 +527,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
asked, got = "流式", "整包响应"
}
return false, false, true, http.StatusBadGateway,
[]byte(ch.Name + " answered a " + asked + " request with " + got + " (didNotStream)")
[]byte(ch.Name + " answered a " + asked + " request with " + got + " (didNotStream)"), channel.FailServer
}
if stream {
@@ -523,20 +540,20 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
// copyAndCapture 非流式:先读完整上游响应再写客户端——响应头与正文都在"确定成功"后
// 才写出,读失败(网络错误/超时)发生在写出之前,可换渠道重试。
// 此前实现先写 200 头再读,超时只能给客户端留一个 200 空响应。
func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, resp *http.Response, start time.Time, bodyConv func([]byte) ([]byte, error), sink *usageSink) (done bool, served bool, retry bool, status int, respBody []byte) {
func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, resp *http.Response, start time.Time, bodyConv func([]byte) ([]byte, error), sink *usageSink) (done bool, served bool, retry bool, status int, respBody []byte, fkind channel.FailKind) {
data, err := io.ReadAll(resp.Body)
if err != nil {
switch {
case c.Request.Context().Err() != nil:
// 客户端断开:就地记账终结
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
return true, false, false, 0, nil
return true, false, false, 0, nil, channel.FailNet
case errors.Is(err, context.DeadlineExceeded):
g.ch.ReportFailure(ch, channel.FailNet, 0)
return false, false, true, http.StatusBadGateway, []byte(ch.Name + " upstream timeout")
return false, false, true, http.StatusBadGateway, []byte(ch.Name + " upstream timeout"), channel.FailNet
default:
g.ch.ReportFailure(ch, channel.FailNet, 0)
return false, false, true, http.StatusBadGateway, []byte(ch.Name + " read error: " + err.Error())
return false, false, true, http.StatusBadGateway, []byte(ch.Name + " read error: " + err.Error()), channel.FailNet
}
}
if usageRaw := extractUsage(data); usageRaw != nil && sink != nil {
@@ -558,7 +575,7 @@ func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, resp *http.R
c.Set("raw_response", capRaw(data, rawResponseCap)) // 上游原始响应(未转换)
}
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
return true, true, false, 0, nil
return true, true, false, 0, nil, channel.FailNet
}
// streamHoldCopy 流式转发:边读上游 SSE 边经 hold 写客户端(放行前缓冲)。
@@ -568,7 +585,7 @@ func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, resp *http.R
// 失败渠道的 message_start 用量不能计入最终账单。
func (g *Gateway) streamHoldCopy(c *gin.Context, sh *streamHold, ch *store.Channel, r io.Reader, start time.Time,
lineConv func([]byte) []byte, clientProto string, trySink, reqSink *usageSink, onRelease func(), tryCtx context.Context,
) (done bool, served bool, retry bool, status int, respBody []byte) {
) (done bool, served bool, retry bool, status int, respBody []byte, fkind channel.FailKind) {
// 原始响应捕获:仅管理员且开关开启(raw_request 已 set)时累积上游原始行
_, capture := c.Get("raw_request")
var rawResp strings.Builder
@@ -580,9 +597,9 @@ func (g *Gateway) streamHoldCopy(c *gin.Context, sh *streamHold, ch *store.Chann
scanner := newSSEScanner(r)
releaseSeen := false
fail := func(msg string, kind channel.FailKind) (bool, bool, bool, int, []byte) {
fail := func(msg string, kind channel.FailKind) (bool, bool, bool, int, []byte, channel.FailKind) {
g.ch.ReportFailure(ch, kind, 0)
return false, false, true, http.StatusBadGateway, []byte(ch.Name + ": " + msg)
return false, false, true, http.StatusBadGateway, []byte(ch.Name + ": " + msg), kind
}
for {
line, err := scanner.Next()
@@ -605,7 +622,7 @@ func (g *Gateway) streamHoldCopy(c *gin.Context, sh *streamHold, ch *store.Chann
// 客户端意外断开:按已生成部分收费(canceled)
commitRaw()
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
return true, false, false, 0, nil
return true, false, false, 0, nil, channel.FailNet
}
if trySink != nil {
trySink.addContent(out)
@@ -639,12 +656,12 @@ func (g *Gateway) streamHoldCopy(c *gin.Context, sh *streamHold, ch *store.Chann
} else {
g.recordError(c, ch, nil, start, "stream_read_error")
}
return true, err == io.EOF, false, 0, nil
return true, err == io.EOF, false, 0, nil, channel.FailNet
}
// 放行前失败:换渠道重试
if c.Request.Context().Err() != nil {
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
return true, false, false, 0, nil
return true, false, false, 0, nil, channel.FailNet
}
if tryCtx != nil && tryCtx.Err() != nil {
return fail(fmt.Sprintf("first token timeout (no content in %s)", g.firstTokenWait), channel.FailNet)
@@ -1018,7 +1035,7 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
}
var model store.Model
var cost float64
var cost decimal.Decimal
var modelID uint64
model, _ = g.modelByName(mn)
if model.ID > 0 {
@@ -1026,12 +1043,18 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
// 计价口径:in=非缓存输入、cacheRead=缓存读、cacheCreate=缓存写(Anthropic 语义,
// messages 流式 message_delta 的 input_tokens 即非缓存部分)。
// 缓存写价未单独配置时按输入价 1.25×(Anthropic 5m 口径)。
// 金额用 decimal 精确运算:token×价/1e6,结果对齐 numeric(20,8) 列精度。
cwp := model.CacheWritePriceOr()
cost = float64(in)/1e6*model.InputPrice +
float64(out)/1e6*model.OutputPrice +
float64(cacheRead)/1e6*model.CacheReadPrice +
float64(cache5m)/1e6*cwp +
float64(cache1h)/1e6*cwp*1.6
million := decimal.NewFromInt(1e6)
perMillion := func(tokens int64, price decimal.Decimal) decimal.Decimal {
return decimal.NewFromInt(tokens).Mul(price).Div(million)
}
cost = perMillion(in, model.InputPrice).
Add(perMillion(out, model.OutputPrice)).
Add(perMillion(cacheRead, model.CacheReadPrice)).
Add(perMillion(cache5m, cwp)).
Add(perMillion(cache1h, cwp.Mul(decimal.NewFromFloat(1.6)))).
Round(8)
} else {
// 模型在记账时已被删除(请求进行中被下架):无法定价,按 0 记账留痕。
// 虚构单价扣用户的钱既不可审计也无法对账。
@@ -1111,7 +1134,7 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
})
// 请求级一行日志:usage_logs 落库是异步的且可能丢,排障时日志是唯一即时依据
accessf("proxy: trace=%s user=%d key=%d model=%q served=%q channel=%d proto=%s in=%d out=%d r=%d cache_r=%d cache_w=%d(1h=%d) cost=%.6f status=%s http=%d latency=%dms err=%s",
accessf("proxy: trace=%s user=%d key=%d model=%q served=%q channel=%d proto=%s in=%d out=%d r=%d cache_r=%d cache_w=%d(1h=%d) cost=%s status=%s http=%d latency=%dms err=%s",
traceStr, uidVal, kidVal, mn, servedModel, chID, p, in, out, reasoning, cacheRead, cacheCreate, cache1h, cost, status,
c.Writer.Status(), latency, errCode)
}
+90 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -14,11 +15,13 @@ import (
"time"
"github.com/gin-gonic/gin"
"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/ratelimit"
"github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
@@ -46,7 +49,7 @@ func newHarness(t *testing.T, baseURL string, timeoutMS int, bind bool, keyModel
enc := crypto.NewEncryptor("test-master-key-1234567890")
encKey, _ := enc.Encrypt("upstream-key")
u := store.User{Username: "u1", Email: "u1@x.com", PasswordHash: "x", Role: store.RoleUser,
Balance: 100, Status: store.UserStatusActive}
Balance: decimal.NewFromInt(100), Status: store.UserStatusActive}
if err := db.Create(&u).Error; err != nil {
t.Fatal(err)
}
@@ -56,7 +59,7 @@ func newHarness(t *testing.T, baseURL string, timeoutMS int, bind bool, keyModel
if err := db.Create(&k).Error; err != nil {
t.Fatal(err)
}
m := store.Model{Name: "m1", InputPrice: 0.15, OutputPrice: 0.6, Enabled: true}
m := store.Model{Name: "m1", InputPrice: decimal.NewFromFloat(0.15), OutputPrice: decimal.NewFromFloat(0.6), Enabled: true}
if err := db.Create(&m).Error; err != nil {
t.Fatal(err)
}
@@ -426,6 +429,86 @@ func TestFailoverBudgetStopsRetrying(t *testing.T) {
}
}
// 429/配额类失败瞬时返回,时间预算对它们形同虚设;按失败类型封顶换渠道次数。
// rate 上限 2 → 首渠道 + 至多 2 次换渠道 = 3 次尝试即止;上限 0 = 不限(4 条全试)。
func TestFailoverSwitchCaps(t *testing.T) {
var hits atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = io.WriteString(w, `{"error":{"message":"rate limited, try again"}}`)
}))
defer srv.Close()
newMultiChannel := func(t *testing.T, n int) *harness {
t.Helper()
h := newHarness(t, srv.URL, 0, true, nil)
var m store.Model
h.db.Where("name = ?", "m1").First(&m)
encKey, _ := crypto.NewEncryptor("test-master-key-1234567890").Encrypt("upstream-key")
for i := 2; i <= n; i++ {
ch := store.Channel{Name: fmt.Sprintf("c%d", i), Provider: store.ChannelProviderOpenAI,
Formats: []string{store.FormatChat}, BaseURL: srv.URL, APIKeyEnc: encKey,
Weight: 1, TimeoutMS: 0, MaxConcurrency: 4,
HealthStatus: store.ChannelHealthHealthy, Enabled: true}
if err := h.db.Create(&ch).Error; err != nil {
t.Fatal(err)
}
if err := h.db.Create(&store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: "m1"}).Error; err != nil {
t.Fatal(err)
}
}
return h
}
body := `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`
// rate 上限 2:打满即停,第 4 条渠道不应被碰
h := newMultiChannel(t, 4)
h.gw.failoverCaps = map[channel.FailKind]int{channel.FailRate: 2}
w := h.run(t, nil, body)
if hits.Load() != 3 {
t.Fatalf("rate 上限 2 应恰好尝试 3 条渠道(1+2),实际 %d", hits.Load())
}
if w.Code != http.StatusTooManyRequests {
t.Fatalf("应透传最后的 429,实际 %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "failover switch cap reached (rate_limited)") {
t.Fatalf("错误体应说明按类型封顶:%s", w.Body.String())
}
// 上限 0 = 不限:4 条渠道全部试完
hits.Store(0)
h.gw.failoverCaps = map[channel.FailKind]int{channel.FailRate: 0}
if w := h.run(t, nil, body); w.Code != http.StatusTooManyRequests {
t.Fatalf("应 429,实际 %d", w.Code)
}
if hits.Load() != 4 {
t.Fatalf("不限次时应尝试全部 4 条渠道,实际 %d", hits.Load())
}
// quota 上限 1:402 打满即停(402 归为 FailQuota,第一次失败即长冷却,但换渠道仍受上限约束)
hits.Store(0)
h2 := newMultiChannel(t, 4)
h2.gw.failoverCaps = map[channel.FailKind]int{channel.FailQuota: 1}
quotaSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusPaymentRequired)
_, _ = io.WriteString(w, `{"error":{"message":"insufficient balance"}}`)
}))
defer quotaSrv.Close()
if err := h2.db.Model(&store.Channel{}).Where("1=1").Update("base_url", quotaSrv.URL).Error; err != nil {
t.Fatal(err)
}
w2 := h2.run(t, nil, body)
if hits.Load() != 2 {
t.Fatalf("quota 上限 1 应恰好尝试 2 条渠道,实际 %d", hits.Load())
}
if w2.Code != http.StatusPaymentRequired {
t.Fatalf("应透传 402,实际 %d", w2.Code)
}
}
// 流式空闲时补 SSE 注释心跳:上游长时间不出字(长思考)不能被反代空闲超时掐掉。
func TestStreamHeartbeatWhileIdle(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -542,7 +625,7 @@ func TestCacheWritePriceBilledSeparately(t *testing.T) {
defer srv.Close()
reqBody := `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`
costOf := func(h *harness) float64 {
costOf := func(h *harness) decimal.Decimal {
if w := h.run(t, nil, reqBody); w.Code != http.StatusOK {
t.Fatalf("code=%d body=%s", w.Code, w.Body.String())
}
@@ -560,7 +643,7 @@ func TestCacheWritePriceBilledSeparately(t *testing.T) {
// 默认未配置:回退输入价 0.15 × 1.25 = 0.1875,加 in/out 的零头
h1 := newHarness(t, srv.URL, 0, true, nil)
fallback := costOf(h1)
if fallback < 0.18 || fallback > 0.20 {
if fallback.LessThan(decimal.NewFromFloat(0.18)) || fallback.GreaterThan(decimal.NewFromFloat(0.20)) {
t.Fatalf("未配置缓存写价应按输入价×1.25,实际 cost=%v", fallback)
}
@@ -571,10 +654,10 @@ func TestCacheWritePriceBilledSeparately(t *testing.T) {
t.Fatal(err)
}
explicit := costOf(h2)
if explicit < 1.99 || explicit > 2.02 {
if explicit.LessThan(decimal.NewFromFloat(1.99)) || explicit.GreaterThan(decimal.NewFromFloat(2.02)) {
t.Fatalf("按 cache_write_price=2.0 计费应为 ~2.0,实际 %v", explicit)
}
if !(explicit > fallback) {
if !explicit.GreaterThan(fallback) {
t.Fatalf("显式价应高于回退价:%v vs %v", explicit, fallback)
}
}
@@ -601,7 +684,7 @@ func TestAllChannelsFailedAttributesLastChannel(t *testing.T) {
if l.ChannelID == 0 {
t.Fatalf("失败记录没归到渠道上:%+v", l)
}
if l.Status != store.UsageStatusError || l.Cost != 0 {
if l.Status != store.UsageStatusError || !l.Cost.IsZero() {
t.Fatalf("失败不应产生扣费:%+v", l)
}
}
+10 -6
View File
@@ -1,7 +1,6 @@
package proxy
import (
"math"
"net/http"
"net/http/httptest"
"strings"
@@ -9,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/store"
"github.com/shopspring/decimal"
)
// runPath 走完整链路(Auth → Handle),路径与请求体由调用方给。
@@ -54,8 +54,8 @@ func TestUsageReasoningAndServedModelRecorded(t *testing.T) {
t.Fatalf("reasoning_tokens 应为 3,实际 %d", l.ReasoningTokens)
}
// 计费不含 reasoning 加成:10×0.15/1e6 + 5×0.6/1e6 = 4.5e-6
if math.Abs(l.Cost-4.5e-6) > 1e-12 {
t.Fatalf("cost 应为 4.5e-6(reasoning 不重复计费),实际 %g", l.Cost)
if !l.Cost.Equal(decimal.NewFromFloat(4.5e-6)) {
t.Fatalf("cost 应为 4.5e-6(reasoning 不重复计费),实际 %s", l.Cost)
}
}
@@ -100,9 +100,13 @@ func TestAnthropicCache1hBillingSplit(t *testing.T) {
t.Fatalf("served_model 应为 claude-x,实际 %q", l.ServedModel)
}
// 100/1e6*1 + 10/1e6*2 + 200/1e6*0.1 + 100/1e6*1.25 + 200/1e6*(1.25*1.6) = 6.65e-4
want := 100.0/1e6*1.0 + 10.0/1e6*2.0 + 200.0/1e6*0.1 + 100.0/1e6*1.25 + 200.0/1e6*2.0
if math.Abs(l.Cost-want) > 1e-9 {
t.Fatalf("cost 应为 %g(1h/5m 分开计价),实际 %g", want, l.Cost)
million := decimal.NewFromInt(1e6)
want := decimal.NewFromInt(100).Div(million).Add(decimal.NewFromInt(10).Mul(decimal.NewFromFloat(2)).Div(million)).
Add(decimal.NewFromInt(200).Mul(decimal.NewFromFloat(0.1)).Div(million)).
Add(decimal.NewFromInt(100).Mul(decimal.NewFromFloat(1.25)).Div(million)).
Add(decimal.NewFromInt(200).Mul(decimal.NewFromFloat(2.0)).Div(million))
if !l.Cost.Equal(want) {
t.Fatalf("cost 应为 %s(1h/5m 分开计价),实际 %s", want, l.Cost)
}
// 落库 input 为含缓存的总量口径
if l.InputTokens != 600 || l.OutputTokens != 10 {