feat: 补齐上游细节、渠道观测与模型定价元数据

- 上游 429 的 Retry-After 转成渠道冷却时长(夹在 10 分钟内;比默认值短则不缩短,
  否则 1s 就把坏渠道放回轮转)。失败计数改由 proxyOne 就地报——只有它读得到响应头。
- 故障转移时间预算 OT_PROXY_FAILOVER_BUDGET(默认 30s):只约束第二个及以后的渠道,
  首个不受限(长回答本就要跑几分钟),但坏渠道不再把客户端拖到 N×timeout。
- 流式空闲心跳 OT_PROXY_STREAM_HEARTBEAT(默认 15s):补一行 SSE 注释 `: ping`,
  防止 nginx/Cloudflare 的空闲超时掐掉长思考。心跳与正文共用一把写的锁,
  gin 的 ResponseWriter 不是并发安全的;注释行不进转换也不计费。
- 渠道观测:GET /api/v1/admin/channels/stats?window=5m|1h|6h|24h|7d 聚合请求量、
  成功率、P50/P95/max 延迟、成本与 top_errors;分位只取成功样本,canceled(客户端
  主动断开)不计入渠道错误率。渠道页桌面表格加「近N小时」列、移动端加同内容条,
  顶部可切窗口。
- 修一个让统计瞎掉的归因问题:全部渠道失败时 recordError 传的是 nil,
  usage_logs 里 channel_id=0,这类失败在渠道维度完全看不见。改为归到最后一个
  真正试过的渠道。
- 模型定价与元数据:Model 新增 cache_write_price(0 则回退输入价×1.25,可显式配
  Anthropic 1h 的 ×2)、context_length 与 supports_tools/vision/reasoning;
  finishUsage 走 CacheWritePriceOr()。/v1/models 叠加用户与密钥白名单过滤
  (原来会把限制外的模型名直接泄漏在列表里)并返回元数据,价格不外泄。
- /v1/models 的 Anthropic 分支原是死代码:协议只按路径判,而两种客户端共用这个路径。
  改为按 x-api-key / anthropic-version 头判,OpenAI SDK 从不发这两个头,互不影响。
- NewGateway 的十个位置参数换成 Options 结构体(三个 Duration 两个 int,传错编译期发现不了)。

新增 6 个用例:Retry-After 两种写法与夹顶、重试预算截止、空闲心跳出帧、
/v1/models 过滤与双形状、缓存写价两条路径、失败归因。
This commit is contained in:
Sakurasan committed 2026-10-17 04:09:39 +08:00
1 parent ad10bdc400
commit c39c3e7da4
16 files changed
+984 -125

No files matched your search

+88 -21
View File
@@ -42,8 +42,13 @@ type Gateway struct {
// 上游超时兜底值(渠道 timeout_ms 非法时使用);按超时值缓存 Transport 复用连接池。
gwTimeout time.Duration
maxBodyMB int // /v1 请求体上限(MB),仅用于超限报错文案
tpMu sync.Mutex
tps map[time.Duration]*http.Transport
// 故障转移给"第二个及以后的渠道"留的时间预算;超了就直接返回最后一个错误,
// 不限制首个渠道(长回答本来就可能跑很久)。
failoverBudget time.Duration
// 流式空闲多久补一行 SSE 注释心跳(0=关闭)。反向代理的空闲超时会掐掉长思考的流。
streamHeartbeat time.Duration
tpMu sync.Mutex
tps map[time.Duration]*http.Transport
policyMu sync.Mutex
policy modelPolicy
@@ -132,18 +137,31 @@ func contains(list []string, s string) bool {
return false
}
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int, logRaw bool, gw_timeout time.Duration, maxBodyMB int) *Gateway {
// Options 网关配置项。用结构体而不是十个位置参数:里面三个 time.Duration、
// 两个 int,传错的编译期发现不了。
type Options struct {
UserRPS int
LogRaw bool
GWTimeout time.Duration // 上游超时兜底(渠道 timeout_ms 优先)
MaxBodyMB int // /v1 请求体上限(MB)
FailoverBudget time.Duration // 故障转移重试的时间预算
StreamHeartbeat time.Duration // 流式空闲心跳间隔
}
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, o Options) *Gateway {
return &Gateway{
db: db,
ch: channel.NewService(db, enc),
rec: rec,
enc: enc,
lim: lim,
userRPS: userRPS,
logRaw: logRaw,
gwTimeout: gw_timeout,
maxBodyMB: maxBodyMB,
tps: map[time.Duration]*http.Transport{},
db: db,
ch: channel.NewService(db, enc),
rec: rec,
enc: enc,
lim: lim,
userRPS: o.UserRPS,
logRaw: o.LogRaw,
gwTimeout: o.GWTimeout,
maxBodyMB: o.MaxBodyMB,
failoverBudget: o.FailoverBudget,
streamHeartbeat: o.StreamHeartbeat,
tps: map[time.Duration]*http.Transport{},
}
}
@@ -194,6 +212,14 @@ func (g *Gateway) Auth(c *gin.Context) {
c.Set("protocol", convert.ProtoMessages)
case "/v1/responses":
c.Set("protocol", convert.ProtoResponses)
case "/v1/models":
// 模型目录两条协议共用同一路径,只能靠头区分:Anthropic 客户端带
// x-api-key / anthropic-version,OpenAI 客户端从不发这两个头。
if c.GetHeader("x-api-key") != "" || c.GetHeader("anthropic-version") != "" {
c.Set("protocol", convert.ProtoMessages)
} else {
c.Set("protocol", convert.ProtoChat)
}
default:
c.Set("protocol", convert.ProtoChat)
}
@@ -272,11 +298,18 @@ func (g *Gateway) Handle(c *gin.Context) {
}
}
// models GET /v1/models:返回对外可见的模型(启用且至少绑定到一个启用且健康的渠道;OpenAI 风格)。
// models GET /v1/models:返回调用方真正能用的模型。
// 口径与选路一致(启用 + 至少绑定一个启用且健康的渠道),再叠加该用户/密钥的模型白名单——
// 否则被限制的模型名照样泄漏在列表里,客户端试了才知道 403。
// 按客户端协议返回对应形状,并带上目录元数据(上下文长度、能力位);价格不外泄。
func (g *Gateway) models(c *gin.Context) {
u, ok := g.resolveUser(c)
if !ok {
return
}
ids := g.ch.AvailableModelIDs()
if len(ids) == 0 {
c.JSON(http.StatusOK, gin.H{"object": "list", "data": []gin.H{}})
g.writeModelList(c, nil)
return
}
var ms []store.Model
@@ -284,14 +317,48 @@ func (g *Gateway) models(c *gin.Context) {
apiError(c, http.StatusInternalServerError, "internal_error", "failed to load models")
return
}
out := make([]store.Model, 0, len(ms))
for _, m := range ms {
if g.checkModelAllowed(c, u, m.Name) {
out = append(out, m)
}
}
g.writeModelList(c, out)
}
func (g *Gateway) writeModelList(c *gin.Context, ms []store.Model) {
if p, _ := c.Get("protocol"); p == convert.ProtoMessages {
data := make([]gin.H, 0, len(ms))
for _, m := range ms {
data = append(data, gin.H{
"type": "model",
"id": m.Name,
"display_name": m.Name,
"created_at": m.CreatedAt.UTC().Format(time.RFC3339),
"context_length": m.ContextLength,
"capabilities": gin.H{
"tools": m.SupportsTools, "vision": m.SupportsVision, "reasoning": m.SupportsReasoning,
},
})
}
c.JSON(http.StatusOK, gin.H{"data": data, "has_more": false})
return
}
data := make([]gin.H, 0, len(ms))
for _, m := range ms {
data = append(data, gin.H{
"id": m.Name,
"object": "model",
"created": m.CreatedAt.Unix(),
"owned_by": "openteam",
})
item := gin.H{
"id": m.Name, "object": "model",
"created": m.CreatedAt.Unix(), "owned_by": "openteam",
}
if m.ContextLength > 0 {
item["context_length"] = m.ContextLength
}
if m.SupportsTools || m.SupportsVision || m.SupportsReasoning {
item["capabilities"] = gin.H{
"tools": m.SupportsTools, "vision": m.SupportsVision, "reasoning": m.SupportsReasoning,
}
}
data = append(data, item)
}
c.JSON(http.StatusOK, gin.H{"object": "list", "data": data})
}
+95 -6
View File
@@ -13,6 +13,8 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
@@ -160,6 +162,27 @@ func sseContentText(line []byte) string {
return ""
}
// retryAfter 解析上游 Retry-After(delta-秒或 HTTP 日期),用于按对端意愿延长渠道冷却。
// 解析不出或为非正返回 0,交给 channel.Service 用默认时长(并夹到上限)。
func retryAfter(h http.Header) time.Duration {
v := strings.TrimSpace(h.Get("Retry-After"))
if v == "" {
return 0
}
if secs, err := strconv.Atoi(v); err == nil {
if secs <= 0 {
return 0
}
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(v); err == nil {
if d := time.Until(t); d > 0 {
return d
}
}
return 0
}
// upstreamURL 组装上游地址:按协议选 base_url 再拼资源路径(见 store.Channel.UpstreamURL)。
func upstreamURL(ch *store.Channel, proto, path string) string {
return ch.UpstreamURL(proto, path)
@@ -184,8 +207,18 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
var lastStatus = http.StatusBadGateway
var lastBody = []byte("all upstream channels failed")
// 最后真正试过的渠道:全部失败时把错误归到它头上,否则 usage_logs 里
// channel_id=0,渠道维度的统计与排障就看不到这类失败。
var lastCh *store.Channel
startAll := time.Now()
// 从选中的渠道开始遍历,到末尾后再从头遍历到选中渠道之前
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
}
idx := (startIdx + offset) % len(cands)
cand := cands[idx]
ch := cand.Channel
@@ -207,6 +240,8 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
if !ok {
continue // 渠道满载,溢出到下一个
}
// 失败/成功计数由 proxyOne 就地报:只有它读得到上游响应头(429 的 Retry-After)
lastCh = ch
done, retry, st, b := g.proxyOne(c, ch, plan, stream, sink)
release()
if done {
@@ -219,12 +254,11 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
g.recordError(c, ch, nil, now(), "upstream_http_"+strconv.Itoa(st))
return
}
g.ch.ReportFailure(ch) // 网络错误/超时/429/5xx:累计运行期失败
lastStatus, lastBody = st, b
}
// 全部候选重试性失败
apiError(c, lastStatus, "upstream_error", string(lastBody))
g.recordError(c, nil, nil, now(), "all_channels_failed")
g.recordError(c, lastCh, nil, now(), "all_channels_failed")
}
// proxyOne 对单个渠道执行一次代理。
@@ -233,6 +267,7 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan, stream bool, sink *usageSink) (done bool, retry bool, status int, respBody []byte) {
upKey, err := g.ch.UpstreamKey(ch)
if err != nil {
g.ch.ReportFailure(ch, 0)
return false, true, http.StatusInternalServerError, []byte("failed to decrypt channel key")
}
@@ -286,6 +321,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
return true, false, 0, nil
}
g.ch.ReportFailure(ch, 0) // 网络错误/超时
return false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error())
}
defer resp.Body.Close()
@@ -294,6 +330,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
g.ch.ReportFailure(ch, retryAfter(resp.Header))
return false, true, resp.StatusCode, errBody
}
return false, false, resp.StatusCode, errBody
@@ -360,6 +397,11 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta
}
}
lw := &lockedWriter{w: w, f: flusher}
lw.lastN.Store(time.Now().UnixNano())
stopHB := lw.heartbeat(g.streamHeartbeat)
defer stopHB()
scanner := newSSEScanner(r)
for {
line, err := scanner.Next()
@@ -372,13 +414,12 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta
out = lineConv(line)
}
if out != nil {
if _, werr := w.Write(out); werr != nil {
if _, werr := lw.write(out); werr != nil {
// 客户端意外断开:按已生成部分收费(canceled)
commitRaw()
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
return
}
flusher.Flush()
if sink != nil {
sink.outputText += sseContentText(out)
}
@@ -406,6 +447,54 @@ type nopFlusher struct{}
func (nopFlusher) Flush() {}
// lockedWriter 给流式写出加锁:心跳在独立 goroutine 里写,而 gin 的 ResponseWriter
// 不是并发安全的,两路同时写会坏帧。
type lockedWriter struct {
mu sync.Mutex
w io.Writer
f http.Flusher
lastN atomic.Int64 // 最近一次写出时间(unix nano),心跳据此判断空闲
}
func (lw *lockedWriter) write(b []byte) (int, error) {
lw.mu.Lock()
defer lw.mu.Unlock()
n, err := lw.w.Write(b)
lw.lastN.Store(time.Now().UnixNano())
if err == nil {
lw.f.Flush()
}
return n, err
}
// heartbeat 距上次写出超过 interval 时补一行 SSE 注释(`: ping`),返回停止函数。
// 挂在 nginx/Cloudflare 后面时长思考(首包后上游安静几十秒)会被空闲超时掐掉,
// 客户端只看到流断在半路。注释行按 SSE 规范被解析器忽略,不影响计费与转换。
func (lw *lockedWriter) heartbeat(interval time.Duration) func() {
if interval <= 0 {
return func() {}
}
done := make(chan struct{})
var once sync.Once
go func() {
tk := time.NewTicker(interval)
defer tk.Stop()
for {
select {
case <-done:
return
case <-tk.C:
if time.Since(time.Unix(0, lw.lastN.Load())) < interval {
continue
}
// 心跳写失败说明客户端已断开,交给主循环下一次写出时统一记账
_, _ = lw.write([]byte(": ping\n\n"))
}
}
}()
return func() { once.Do(func() { close(done) }) }
}
// ---------------------------------------------------------------------------
// usage 提取
@@ -627,11 +716,11 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
modelID = model.ID
// 计价口径:in=非缓存输入、cacheRead=缓存读、cacheCreate=缓存写(Anthropic 语义,
// messages 流式 message_delta 的 input_tokens 即非缓存部分)。
// 缓存写按 1.25× 输入价(Anthropic 5m 口径)。
// 缓存写价未单独配置时按输入价 1.25×(Anthropic 5m 口径)。
cost = float64(in)/1e6*model.InputPrice +
float64(out)/1e6*model.OutputPrice +
float64(cacheRead)/1e6*model.CacheReadPrice +
float64(cacheCreate)/1e6*model.InputPrice*1.25
float64(cacheCreate)/1e6*model.CacheWritePriceOr()
} else {
cost = float64(in+cacheRead+cacheCreate)/1e6*0.15 + float64(out)/1e6*0.60 // 无定价模型时按示例价
}
+274 -2
View File
@@ -76,7 +76,12 @@ func newHarness(t *testing.T, baseURL string, timeoutMS int, bind bool, keyModel
rec := usage.NewRecorder(db)
t.Cleanup(rec.Close)
return &harness{
gw: NewGateway(db, enc, rec, ratelimit.New(), 0, false, 300*time.Second, 32),
gw: NewGateway(db, enc, rec, ratelimit.New(), Options{
GWTimeout: 300 * time.Second,
MaxBodyMB: 32,
FailoverBudget: 30 * time.Second,
StreamHeartbeat: 15 * time.Second,
}),
db: db, rec: rec, key: plain,
}
}
@@ -294,7 +299,7 @@ func TestRuntimeFailureCounterResetsOnSuccess(t *testing.T) {
// 回归:全局兜底超时值不再被二次换算。
func TestGatewayTimeoutIsNotMultiplied(t *testing.T) {
gw := NewGateway(nil, nil, nil, nil, 0, false, 30*time.Second, 8)
gw := NewGateway(nil, nil, nil, nil, Options{GWTimeout: 30 * time.Second, MaxBodyMB: 8})
if got := gw.channelTimeout(&store.Channel{TimeoutMS: 0}); got != 30*time.Second {
t.Fatalf("兜底超时 = %s,期望 30s", got)
}
@@ -333,3 +338,270 @@ func TestRequestBodyTooLarge(t *testing.T) {
t.Fatalf("报错应点名上限,实际:%s", w.Body.String())
}
}
// 429 的 Retry-After 要能读出来(数字与 HTTP 日期两种写法)。
func TestRetryAfterParsing(t *testing.T) {
cases := []struct {
val string
want time.Duration
}{
{"", 0},
{"7", 7 * time.Second},
{" 12 ", 12 * time.Second},
{"0", 0},
{"-5", 0},
{"not-a-number", 0},
}
for _, tc := range cases {
h := http.Header{}
if tc.val != "" {
h.Set("Retry-After", tc.val)
}
if got := retryAfter(h); got != tc.want {
t.Fatalf("Retry-After=%q 解析成 %s,期望 %s", tc.val, got, tc.want)
}
}
h := http.Header{}
h.Set("Retry-After", time.Now().UTC().Add(20*time.Second).Format(http.TimeFormat))
if got := retryAfter(h); got < 15*time.Second || got > 25*time.Second {
t.Fatalf("HTTP 日期写法解析异常:%s", got)
}
}
// 故障转移的时间预算只约束第二个及以后的渠道:坏渠道把预算吃光后不再拖着客户端等下一条。
func TestFailoverBudgetStopsRetrying(t *testing.T) {
var hitFast atomic.Int64
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(1200 * time.Millisecond) // 慢且最终失败,专门吃预算
w.WriteHeader(http.StatusBadGateway)
_, _ = io.WriteString(w, `{"error":{"message":"boom"}}`)
}))
defer slow.Close()
fast := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitFast.Add(1)
_, _ = io.WriteString(w, `{"id":"ok","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
defer fast.Close()
h := newHarness(t, slow.URL, 0, true, nil) // 渠道1:慢坏
// 加权随机决定起点,把权重压到一边,否则起点可能直接落在健康渠道上、测不到重试预算
if err := h.db.Model(&store.Channel{}).Where("name = ?", "c1").Update("weight", 1000).Error; err != nil {
t.Fatal(err)
}
// 渠道2:健康,绑定同一个模型
encKey, _ := crypto.NewEncryptor("test-master-key-1234567890").Encrypt("upstream-key")
var m store.Model
h.db.Where("name = ?", "m1").First(&m)
var ch2 store.Channel
ch2 = store.Channel{Name: "c2", Provider: store.ChannelProviderOpenAI,
Formats: []string{store.FormatChat}, BaseURL: fast.URL, APIKeyEnc: encKey,
Weight: 1, TimeoutMS: 0, MaxConcurrency: 4,
HealthStatus: store.ChannelHealthHealthy, Enabled: true}
if err := h.db.Create(&ch2).Error; err != nil {
t.Fatal(err)
}
if err := h.db.Create(&store.ChannelModelBinding{ChannelID: ch2.ID, ModelID: m.ID, UpstreamModel: "m1"}).Error; err != nil {
t.Fatal(err)
}
body := `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`
// 预算 300ms:渠道1 耗掉 1.2s 后就不再试渠道2,直接回最后一个错误
h.gw.failoverBudget = 300 * time.Millisecond
w := h.run(t, nil, body)
if hitFast.Load() != 0 {
t.Fatal("重试预算耗尽后不应再去试第二条渠道")
}
if w.Code != http.StatusBadGateway {
t.Fatalf("应返回 502,实际 %d: %s", w.Code, w.Body.String())
}
// 关掉预算:正常故障转移到渠道2 并成功
h.gw.failoverBudget = 0
w2 := h.run(t, nil, body)
if hitFast.Load() == 0 {
t.Fatalf("不限预算时应故障转移到渠道2,响应 %d: %s", w2.Code, w2.Body.String())
}
if w2.Code != http.StatusOK {
t.Fatalf("转移到健康渠道应成功,实际 %d: %s", w2.Code, w2.Body.String())
}
}
// 流式空闲时补 SSE 注释心跳:上游长时间不出字(长思考)不能被反代空闲超时掐掉。
func TestStreamHeartbeatWhileIdle(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
time.Sleep(700 * time.Millisecond) // 首包之后上游沉默
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"久等了"}}]}`+"\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
defer srv.Close()
h := newHarness(t, srv.URL, 0, true, nil)
h.gw.streamHeartbeat = 120 * time.Millisecond
w := h.run(t, nil, `{"model":"m1","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
if w.Code != http.StatusOK {
t.Fatalf("code=%d body=%s", w.Code, w.Body.String())
}
out := w.Body.String()
if !strings.Contains(out, ": ping") {
t.Fatalf("空闲期没有发出心跳:%q", out)
}
if !strings.Contains(out, "久等了") {
t.Fatalf("心跳把正文挤掉了:%q", out)
}
// 心跳是注释行,不该被当成输出去计费
var logs []store.UsageLog
h.db.Find(&logs)
for _, l := range logs {
if strings.Contains(l.RawResponse, ": ping") && l.Status == store.UsageStatusSuccess && l.OutputTokens == 0 {
t.Fatalf("心跳被当作输出了:%+v", l)
}
}
}
// GET /v1/models 只返回调用方真能用的模型,并带目录元数据。
func TestModelsListRespectsCallerAllowlist(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, `{}`) }))
defer srv.Close()
h := newHarness(t, srv.URL, 0, true, nil)
// 给 m1 补上元数据
if err := h.db.Model(&store.Model{}).Where("name = ?", "m1").
Updates(map[string]any{"context_length": 200000, "supports_tools": true, "supports_vision": true}).Error; err != nil {
t.Fatal(err)
}
listAs := func(key string) (int, string) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("Authorization", "Bearer "+key)
c.Request = req
h.gw.Auth(c)
if !c.IsAborted() {
h.gw.Handle(c)
}
var got struct {
Data []map[string]any `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &got)
n := 0
if len(got.Data) > 0 {
n = len(got.Data)
}
return n, w.Body.String()
}
// unrestricted key:m1 可见且带元数据
n, body := listAs(h.key)
if n != 1 || !strings.Contains(body, `"context_length":200000`) || !strings.Contains(body, `"tools":true`) {
t.Fatalf(" unrestricted 列表不对:%d %s", n, body)
}
// Anthropic 客户端(只有 x-api-key)应拿到它自己的形状
{
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("x-api-key", h.key)
c.Request = req
h.gw.Auth(c)
h.gw.Handle(c)
if !strings.Contains(w.Body.String(), `"type":"model"`) || !strings.Contains(w.Body.String(), "display_name") {
t.Fatalf("x-api-key 客户端应得到 Anthropic 形状:%s", w.Body.String())
}
if strings.Contains(w.Body.String(), `"owned_by"`) {
t.Fatalf("Anthropic 形状里不该有 OpenAI 字段:%s", w.Body.String())
}
}
// 受限密钥:模型名都不该泄漏出去
token := "sk-ot-" + strings.Repeat("a", 44)
var uid uint64
h.db.Model(&store.User{}).Select("id").Row().Scan(&uid)
if err := h.db.Create(&store.APIKey{UserID: uid, Name: "limited",
KeyHash: apikey.Hash(token), KeyPrefix: apikey.Prefix(token),
Status: store.KeyStatusActive, AllowedModels: []string{"another-model"}}).Error; err != nil {
t.Fatal(err)
}
if n, body := listAs(token); n != 0 {
t.Fatalf("密钥白名单外的模型不应出现在 /v1/models:%s", body)
}
}
// 缓存写价单独配置后必须按它计费,而不是继续按输入价×1.25。
func TestCacheWritePriceBilledSeparately(t *testing.T) {
body := `{"id":"x","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],` +
`"usage":{"prompt_tokens":10,"completion_tokens":5,"cache_creation_input_tokens":1000000}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, body)
}))
defer srv.Close()
reqBody := `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`
costOf := func(h *harness) float64 {
if w := h.run(t, nil, reqBody); w.Code != http.StatusOK {
t.Fatalf("code=%d body=%s", w.Code, w.Body.String())
}
h.rec.Close()
var l store.UsageLog
if err := h.db.Order("id DESC").First(&l).Error; err != nil {
t.Fatal(err)
}
if l.CacheCreationTokens != 1000000 {
t.Fatalf("缓存写 token 没记上:%+v", l)
}
return l.Cost
}
// 默认未配置:回退输入价 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 {
t.Fatalf("未配置缓存写价应按输入价×1.25,实际 cost=%v", fallback)
}
// 显式配置 2.0/百万:应显著高于回退值
h2 := newHarness(t, srv.URL, 0, true, nil)
if err := h2.db.Model(&store.Model{}).Where("name = ?", "m1").
Update("cache_write_price", 2.0).Error; err != nil {
t.Fatal(err)
}
explicit := costOf(h2)
if explicit < 1.99 || explicit > 2.02 {
t.Fatalf("按 cache_write_price=2.0 计费应为 ~2.0,实际 %v", explicit)
}
if !(explicit > fallback) {
t.Fatalf("显式价应高于回退价:%v vs %v", explicit, fallback)
}
}
// 全部渠道都失败时,错误行要归到最后一个试过的渠道上,
// 否则 channel_id=0,渠道维度的成功率统计永远看不到这类失败。
func TestAllChannelsFailedAttributesLastChannel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"error":{"message":"boom"}}`)
}))
defer srv.Close()
h := newHarness(t, srv.URL, 0, true, nil)
if w := h.run(t, nil, `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`); w.Code != http.StatusInternalServerError {
t.Fatalf("上游 500 应透出 500,实际 %d: %s", w.Code, w.Body.String())
}
h.rec.Close()
var l store.UsageLog
if err := h.db.Where("error_code = ?", "all_channels_failed").First(&l).Error; err != nil {
t.Fatalf("没有 all_channels_failed 记录:%v", err)
}
if l.ChannelID == 0 {
t.Fatalf("失败记录没归到渠道上:%+v", l)
}
if l.Status != store.UsageStatusError || l.Cost != 0 {
t.Fatalf("失败不应产生扣费:%+v", l)
}
}