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

+58 -15
View File
@@ -43,7 +43,10 @@ func (h *Handler) AdminModels(c *gin.Context) {
out = append(out, gin.H{
"id": m.ID, "name": m.Name,
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
"cache_write_price": m.CacheWritePrice, "context_length": m.ContextLength,
"supports_tools": m.SupportsTools, "supports_vision": m.SupportsVision,
"supports_reasoning": m.SupportsReasoning,
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
"used": used, "needs_pricing": needsPricing, "denied": denied,
})
}
@@ -118,20 +121,28 @@ func containsStr(list []string, s string) bool {
// AdminCreateModel POST /api/v1/admin/models
func (h *Handler) AdminCreateModel(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required,min=1,max=128"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
CacheReadPrice float64 `json:"cache_read_price"`
Enabled *bool `json:"enabled"`
Name string `json:"name" binding:"required,min=1,max=128"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
CacheReadPrice float64 `json:"cache_read_price"`
CacheWritePrice float64 `json:"cache_write_price"`
ContextLength int `json:"context_length"`
SupportsTools bool `json:"supports_tools"`
SupportsVision bool `json:"supports_vision"`
SupportsReasoning bool `json:"supports_reasoning"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
return
}
m := store.Model{
Name: req.Name,
Name: req.Name,
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
Enabled: boolOr(req.Enabled, true),
CacheWritePrice: req.CacheWritePrice, ContextLength: req.ContextLength,
SupportsTools: req.SupportsTools, SupportsVision: req.SupportsVision,
SupportsReasoning: req.SupportsReasoning,
Enabled: boolOr(req.Enabled, true),
}
if err := h.a.DB.Create(&m).Error; err != nil {
resp.Fail(c, http.StatusConflict, "failed to create model (name may already exist)")
@@ -148,11 +159,16 @@ func (h *Handler) AdminUpdateModel(c *gin.Context) {
return
}
var req struct {
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
Enabled *bool `json:"enabled"`
Sort *int `json:"sort"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
ContextLength *int `json:"context_length"`
SupportsTools *bool `json:"supports_tools"`
SupportsVision *bool `json:"supports_vision"`
SupportsReasoning *bool `json:"supports_reasoning"`
Enabled *bool `json:"enabled"`
Sort *int `json:"sort"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input")
@@ -173,6 +189,21 @@ func (h *Handler) AdminUpdateModel(c *gin.Context) {
if req.CacheReadPrice != nil {
updates["cache_read_price"] = *req.CacheReadPrice
}
if req.CacheWritePrice != nil {
updates["cache_write_price"] = *req.CacheWritePrice
}
if req.ContextLength != nil {
updates["context_length"] = *req.ContextLength
}
if req.SupportsTools != nil {
updates["supports_tools"] = *req.SupportsTools
}
if req.SupportsVision != nil {
updates["supports_vision"] = *req.SupportsVision
}
if req.SupportsReasoning != nil {
updates["supports_reasoning"] = *req.SupportsReasoning
}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
@@ -184,8 +215,20 @@ func (h *Handler) AdminUpdateModel(c *gin.Context) {
resp.Fail(c, http.StatusInternalServerError, "failed to update model")
return
}
if err := h.a.DB.First(&m, id).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to reload model")
return
}
}
resp.OK(c, gin.H{"ok": true})
resp.OK(c, gin.H{
"id": m.ID, "name": m.Name,
"input_price": m.InputPrice, "output_price": m.OutputPrice,
"cache_read_price": m.CacheReadPrice, "cache_write_price": m.CacheWritePrice,
"context_length": m.ContextLength,
"supports_tools": m.SupportsTools, "supports_vision": m.SupportsVision,
"supports_reasoning": m.SupportsReasoning,
"enabled": m.Enabled, "sort": m.Sort,
})
}
// AdminDeleteModel DELETE /api/v1/admin/models/:id
@@ -288,4 +331,4 @@ func (h *Handler) AdminDeleteModelBinding(c *gin.Context) {
}
var _ = errors.Is
var _ = gorm.ErrRecordNotFound
var _ = gorm.ErrRecordNotFound