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 提取(非流式与流式)。
This commit is contained in:
Sakurasan committed 2026-10-30 11:41:51 +08:00
1 parent e7f8a5e5ad
commit 04dfb4285d
14 files changed
+541 -38

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
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())
}
}