- 上游 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 过滤与双形状、缓存写价两条路径、失败归因。
335 lines
11 KiB
Go
335 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/openteam/server/internal/pkg/resp"
|
|
"github.com/openteam/server/internal/store"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// AdminModels GET /api/v1/admin/models — 模型列表(含价格、渠道绑定、定价/禁止状态)。
|
|
func (h *Handler) AdminModels(c *gin.Context) {
|
|
var ms []store.Model
|
|
if err := h.a.DB.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
|
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
|
|
return
|
|
}
|
|
|
|
// 全局模型限制策略
|
|
allow, deny := h.modelPolicyConfig()
|
|
|
|
out := make([]gin.H, 0, len(ms))
|
|
for _, m := range ms {
|
|
var bindings []store.ChannelModelBinding
|
|
h.a.DB.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings)
|
|
chs := make([]gin.H, 0, len(bindings))
|
|
for _, b := range bindings {
|
|
if !b.Channel.Enabled {
|
|
continue
|
|
}
|
|
chs = append(chs, gin.H{
|
|
"id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name,
|
|
"upstream_model": b.UpstreamModel, "weight": b.Weight,
|
|
})
|
|
}
|
|
used := len(chs) > 0
|
|
needsPricing := used && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0
|
|
denied := containsStr(deny, m.Name) || (len(allow) > 0 && !containsStr(allow, m.Name))
|
|
out = append(out, 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, "channels": chs,
|
|
"used": used, "needs_pricing": needsPricing, "denied": denied,
|
|
})
|
|
}
|
|
|
|
// 渠道选中但目录中缺失的模型(孤儿绑定:渠道绑定指向已被删除的模型)
|
|
var orphans []struct {
|
|
ChannelName string
|
|
UpstreamModel string
|
|
ModelID uint64
|
|
}
|
|
h.a.DB.Raw(`SELECT c.name as channel_name, b.model_id, b.upstream_model
|
|
FROM channel_model_bindings b
|
|
LEFT JOIN models m ON m.id = b.model_id
|
|
LEFT JOIN channels c ON c.id = b.channel_id
|
|
WHERE m.id IS NULL`).Scan(&orphans)
|
|
missing := make([]gin.H, 0, len(orphans))
|
|
for _, o := range orphans {
|
|
missing = append(missing, gin.H{
|
|
"channel": o.ChannelName, "model_id": o.ModelID, "upstream_model": o.UpstreamModel,
|
|
})
|
|
}
|
|
|
|
// 在用但未定价的模型数(渠道已提供、需定价)
|
|
unpriced := 0
|
|
{
|
|
var usedBindings []struct {
|
|
ModelID uint64
|
|
}
|
|
h.a.DB.Model(&store.ChannelModelBinding{}).Distinct("model_id").Scan(&usedBindings)
|
|
usedIDs := map[uint64]bool{}
|
|
for _, u := range usedBindings {
|
|
usedIDs[u.ModelID] = true
|
|
}
|
|
for _, m := range ms {
|
|
if usedIDs[m.ID] && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0 {
|
|
unpriced++
|
|
}
|
|
}
|
|
}
|
|
|
|
resp.OK(c, gin.H{
|
|
"items": out,
|
|
"summary": gin.H{
|
|
"total": len(ms),
|
|
"unpriced": unpriced,
|
|
"missing": missing,
|
|
"denied_count": len(deny),
|
|
},
|
|
})
|
|
}
|
|
|
|
// modelPolicyConfig 读取全局模型允许/禁止列表。
|
|
func (h *Handler) modelPolicyConfig() (allow, deny []string) {
|
|
var raw string
|
|
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
|
|
_ = json.Unmarshal([]byte(raw), &allow)
|
|
raw = ""
|
|
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "model_denylist").Pluck("value", &raw)
|
|
_ = json.Unmarshal([]byte(raw), &deny)
|
|
return
|
|
}
|
|
|
|
func containsStr(list []string, s string) bool {
|
|
for _, v := range list {
|
|
if v == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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"`
|
|
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,
|
|
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
|
|
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)")
|
|
return
|
|
}
|
|
resp.Created(c, gin.H{"id": m.ID, "name": m.Name})
|
|
}
|
|
|
|
// AdminUpdateModel PUT /api/v1/admin/models/:id — 价格/启停/排序。
|
|
func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
resp.Fail(c, http.StatusBadRequest, "invalid model id")
|
|
return
|
|
}
|
|
var req struct {
|
|
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")
|
|
return
|
|
}
|
|
var m store.Model
|
|
if err := h.a.DB.First(&m, id).Error; err != nil {
|
|
resp.Fail(c, http.StatusNotFound, "model not found")
|
|
return
|
|
}
|
|
updates := map[string]any{}
|
|
if req.InputPrice != nil {
|
|
updates["input_price"] = *req.InputPrice
|
|
}
|
|
if req.OutputPrice != nil {
|
|
updates["output_price"] = *req.OutputPrice
|
|
}
|
|
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
|
|
}
|
|
if req.Sort != nil {
|
|
updates["sort"] = *req.Sort
|
|
}
|
|
if len(updates) > 0 {
|
|
if err := h.a.DB.Model(&m).Updates(updates).Error; err != nil {
|
|
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{
|
|
"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
|
|
func (h *Handler) AdminDeleteModel(c *gin.Context) {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
resp.Fail(c, http.StatusBadRequest, "invalid model id")
|
|
return
|
|
}
|
|
res := h.a.DB.Delete(&store.Model{}, id)
|
|
if res.Error != nil {
|
|
resp.Fail(c, http.StatusInternalServerError, "failed to delete model")
|
|
return
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
resp.Fail(c, http.StatusNotFound, "model not found")
|
|
return
|
|
}
|
|
h.a.DB.Where("model_id = ?", id).Delete(&store.ChannelModelBinding{})
|
|
resp.OK(c, gin.H{"ok": true})
|
|
}
|
|
|
|
// AdminDeleteUnusedModels DELETE /api/v1/admin/models/unused — 一键清除未绑定任何渠道的模型。
|
|
// 这些通常是渠道抽屉里选中过、后来又取消绑定留下的目录条目,客户端无法调用。
|
|
func (h *Handler) AdminDeleteUnusedModels(c *gin.Context) {
|
|
var orphans []store.Model
|
|
if err := h.a.DB.Where("id NOT IN (SELECT DISTINCT model_id FROM channel_model_bindings)").Find(&orphans).Error; err != nil {
|
|
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
|
|
return
|
|
}
|
|
names := make([]string, 0, len(orphans))
|
|
ids := make([]uint64, 0, len(orphans))
|
|
for _, m := range orphans {
|
|
names = append(names, m.Name)
|
|
ids = append(ids, m.ID)
|
|
}
|
|
if len(ids) > 0 {
|
|
if err := h.a.DB.Delete(&store.Model{}, ids).Error; err != nil {
|
|
resp.Fail(c, http.StatusInternalServerError, "failed to delete models")
|
|
return
|
|
}
|
|
}
|
|
resp.OK(c, gin.H{"deleted": names, "count": len(names)})
|
|
}
|
|
|
|
// AdminCreateModelBinding POST /api/v1/admin/models/:id/bindings
|
|
func (h *Handler) AdminCreateModelBinding(c *gin.Context) {
|
|
modelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
resp.Fail(c, http.StatusBadRequest, "invalid model id")
|
|
return
|
|
}
|
|
var req struct {
|
|
ChannelID uint64 `json:"channel_id" binding:"required"`
|
|
UpstreamModel string `json:"upstream_model" binding:"required"`
|
|
Weight *int `json:"weight"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
resp.Fail(c, http.StatusBadRequest, "invalid input: channel_id and upstream_model required")
|
|
return
|
|
}
|
|
var m store.Model
|
|
if err := h.a.DB.First(&m, modelID).Error; err != nil {
|
|
resp.Fail(c, http.StatusNotFound, "model not found")
|
|
return
|
|
}
|
|
var ch store.Channel
|
|
if err := h.a.DB.First(&ch, req.ChannelID).Error; err != nil {
|
|
resp.Fail(c, http.StatusNotFound, "channel not found")
|
|
return
|
|
}
|
|
b := store.ChannelModelBinding{
|
|
ChannelID: req.ChannelID, ModelID: modelID,
|
|
UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
|
|
}
|
|
if err := h.a.DB.Create(&b).Error; err != nil {
|
|
resp.Fail(c, http.StatusConflict, "binding may already exist")
|
|
return
|
|
}
|
|
resp.Created(c, gin.H{"id": b.ID})
|
|
}
|
|
|
|
// AdminDeleteModelBinding DELETE /api/v1/admin/models/:id/bindings/:bid
|
|
func (h *Handler) AdminDeleteModelBinding(c *gin.Context) {
|
|
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
|
|
if err != nil {
|
|
resp.Fail(c, http.StatusBadRequest, "invalid binding id")
|
|
return
|
|
}
|
|
res := h.a.DB.Delete(&store.ChannelModelBinding{}, bid)
|
|
if res.Error != nil {
|
|
resp.Fail(c, http.StatusInternalServerError, "failed to delete binding")
|
|
return
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
resp.Fail(c, http.StatusNotFound, "binding not found")
|
|
return
|
|
}
|
|
resp.OK(c, gin.H{"ok": true})
|
|
}
|
|
|
|
var _ = errors.Is
|
|
var _ = gorm.ErrRecordNotFound
|