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:
1 parent
e7f8a5e5ad
commit
04dfb4285d
14 files changed
+541
-38
No files matched your search
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user