Files
openteam/server/internal/proxy/usage_refine_test.go
T
Sakurasan 04dfb4285d feat: 会话亲和粘住缓存渠道;透传 opaque 字段;用量记思考/1h 缓存写/实际模型
会话亲和(proxy/affinity.go):同一会话粘住上次应答的渠道,命中上游提示词
缓存而非被加权随机打散后全价重算。会话身份 = 密钥 + 模型 + 请求文本稳定前缀
摘要,不依赖各客户端会话头;TTL 默认 5m(OT_PROXY_AFFINITY_TTL,0=关闭),
成功应答刷新。亲和渠道必须在候选集内(健康/未冷却/绑定),否则回退加权随机。
proxyOne 增加 served 返回值区分「应答成功」与「已终结但失败」。

opaque 透传(convert):chat↔responses 互转原样携带 metadata、service_tier、
parallel_tool_calls。此前逐字段重建请求体会丢它们,而中转上游会校验/使用。

用量细化(proxy+store):usage_logs 新增 reasoning_tokens(展示口径,不重复
计费)、cache_write_1h_tokens(Anthropic 1h 缓存写按 2× 输入价,此前统一
1.25× 少算 60%)、served_model(上游实际应答模型,映射/聚合商场景对账)。
schema v6→v7。

验证:go vet ./... 干净;go test ./... 与 go test -race ./... 全绿;
新增测试覆盖亲和粘性与冷却回退、opaque 双向透传、reasoning 不重复计费、
1h/5m 缓存写分开计价、served_model 提取(非流式与流式)。
2026-10-30 11:41:51 +08:00

115 lines
4.4 KiB
Go
Raw 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 (
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/store"
)
// 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 math.Abs(l.Cost-4.5e-6) > 1e-12 {
t.Fatalf("cost 应为 4.5e-6(reasoning 不重复计费),实际 %g", 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
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)
}
// 落库 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())
}
}