Files
Sakurasan b2e500ff51 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)
2026-11-09 00:41:48 +08:00

119 lines
4.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package proxy
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/store"
"github.com/shopspring/decimal"
)
// runPath 走完整链路(Auth → Handle),路径与请求体由调用方给。
func runPath(t *testing.T, h *harness, path, body string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+h.key)
c.Request = req
h.gw.Auth(c)
if !c.IsAborted() {
h.gw.Handle(c)
}
return w
}
// usage 细化:reasoning token 与上游实际应答模型落库;计费口径不变
// (reasoning 已含在 completion_tokens 内,不重复计费)。
func TestUsageReasoningAndServedModelRecorded(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"model":"gpt-x-actual","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,"completion_tokens_details":{"reasoning_tokens":3}}}`))
}))
defer srv.Close()
h := newHarness(t, srv.URL, 0, true, nil)
w := h.run(t, nil, `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`)
if w.Code != http.StatusOK {
t.Fatalf("code=%d body=%s", w.Code, w.Body.String())
}
h.rec.Close()
var logs []store.UsageLog
h.db.Find(&logs)
if len(logs) != 1 {
t.Fatalf("应有一条记账,实际 %d", len(logs))
}
l := logs[0]
if l.ServedModel != "gpt-x-actual" {
t.Fatalf("served_model 应记录上游应答的模型名,实际 %q", l.ServedModel)
}
if l.ReasoningTokens != 3 {
t.Fatalf("reasoning_tokens 应为 3,实际 %d", l.ReasoningTokens)
}
// 计费不含 reasoning 加成:10×0.15/1e6 + 5×0.6/1e6 = 4.5e-6
if !l.Cost.Equal(decimal.NewFromFloat(4.5e-6)) {
t.Fatalf("cost 应为 4.5e-6(reasoning 不重复计费),实际 %s", l.Cost)
}
}
// Anthropic 缓存写细分:1h 与 5m 分开计价(1h = 2× 输入价,5m = 1.25×)。
func TestAnthropicCache1hBillingSplit(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"msg_1","model":"claude-x","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":10,"cache_read_input_tokens":200,"cache_creation_input_tokens":300,"cache_creation":{"ephemeral_5m_input_tokens":100,"ephemeral_1h_input_tokens":200}}}`))
}))
defer srv.Close()
h := newHarness(t, srv.URL, 0, true, nil)
// 渠道改为 Anthropic 形态,模型定价:输入 1、输出 2、缓存读 0.1、缓存写未配置(1.25× 输入)
var ch1 store.Channel
if err := h.db.Where("name = ?", "c1").First(&ch1).Error; err != nil {
t.Fatal(err)
}
ch1.Formats = []string{store.FormatMessages}
if err := h.db.Save(&ch1).Error; err != nil {
t.Fatal(err)
}
if err := h.db.Model(&store.Model{}).Where("name = ?", "m1").
Updates(map[string]any{"input_price": 1.0, "output_price": 2.0, "cache_read_price": 0.1}).Error; err != nil {
t.Fatal(err)
}
w := runPath(t, h, "/v1/messages", `{"model":"m1","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`)
if w.Code != http.StatusOK {
t.Fatalf("code=%d body=%s", w.Code, w.Body.String())
}
h.rec.Close()
var logs []store.UsageLog
h.db.Find(&logs)
if len(logs) != 1 {
t.Fatalf("应有一条记账,实际 %d", len(logs))
}
l := logs[0]
if l.CacheWrite1hTokens != 200 {
t.Fatalf("cache_write_1h_tokens 应为 200,实际 %d", l.CacheWrite1hTokens)
}
if l.ServedModel != "claude-x" {
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
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 {
t.Fatalf("in=%d out=%d,期望 600/10", l.InputTokens, l.OutputTokens)
}
if !strings.Contains(w.Body.String(), `"text":"hi"`) {
t.Fatalf("响应应原样透传:%s", w.Body.String())
}
}