Files
openteam/server/internal/proxy/gateway.go
T
Sakurasan c39c3e7da4 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 过滤与双形状、缓存写价两条路径、失败归因。
2026-10-17 04:09:39 +08:00

472 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package proxy API 网关核心:代理 /v1/chat/completions、/v1/responses、/v1/messages、/v1/models。
// M1 直通 OpenAI 渠道;M4 起按客户端协议 × 渠道协议自动转换(见 convert)。
package proxy
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/pkg/apikey"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/pkg/ratelimit"
"github.com/openteam/server/internal/proxy/convert"
"github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage"
"gorm.io/gorm"
)
const (
CtxUserID = "proxy_user_id"
CtxKeyID = "proxy_key_id"
CtxKeyModel = "proxy_key_models"
CtxTrace = "proxy_trace_id"
)
type Gateway struct {
db *gorm.DB
ch *channel.Service
rec *usage.Recorder
enc *crypto.Encryptor
lim *ratelimit.Limiter
userRPS int
logRaw bool
// 上游超时兜底值(渠道 timeout_ms 非法时使用);按超时值缓存 Transport 复用连接池。
gwTimeout time.Duration
maxBodyMB int // /v1 请求体上限(MB),仅用于超限报错文案
// 故障转移给"第二个及以后的渠道"留的时间预算;超了就直接返回最后一个错误,
// 不限制首个渠道(长回答本来就可能跑很久)。
failoverBudget time.Duration
// 流式空闲多久补一行 SSE 注释心跳(0=关闭)。反向代理的空闲超时会掐掉长思考的流。
streamHeartbeat time.Duration
tpMu sync.Mutex
tps map[time.Duration]*http.Transport
policyMu sync.Mutex
policy modelPolicy
}
// modelPolicy 全局模型限制策略(来自系统配置,短时缓存)。
type modelPolicy struct {
allow []string
deny []string
at time.Time
}
const modelPolicyTTL = 5 * time.Second
// ResetModelPolicy 清空全局模型限制缓存(系统配置保存后调用)。
func (g *Gateway) ResetModelPolicy() {
g.policyMu.Lock()
g.policy = modelPolicy{}
g.policyMu.Unlock()
}
// ChannelRuntime 渠道运行期健康快照:连续失败次数 + 冷却剩余时间(管理后台展示)。
func (g *Gateway) ChannelRuntime(id uint64) (fails int, remaining time.Duration) {
return g.ch.RuntimeState(id)
}
// RecoverChannel 立即清除渠道的运行期冷却与失败计数(管理后台手动恢复)。
func (g *Gateway) RecoverChannel(id uint64) {
g.ch.ClearRuntime(id)
}
// globalModelRestrictions 读取全局模型允许/禁止列表(缓存 modelPolicyTTL)。
func (g *Gateway) globalModelRestrictions() (allow, deny []string) {
g.policyMu.Lock()
defer g.policyMu.Unlock()
if time.Since(g.policy.at) < modelPolicyTTL {
return g.policy.allow, g.policy.deny
}
var raw string
g.db.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &allow)
raw = ""
g.db.Model(&store.SystemConfig{}).Where("key = ?", "model_denylist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &deny)
g.policy = modelPolicy{allow: allow, deny: deny, at: time.Now()}
return
}
// checkModelAllowed 模型访问控制:用户级 > 密钥级 > 全局。
// 1. 用户禁止列表命中 → 拒绝
// 2. 密钥允许列表非空且不含该模型 → 拒绝(密钥级只收窄,不放宽)
// 3. 用户允许列表非空 → 仅列表内可访问,不再看全局(管理员显式授权该用户,短路全局)
// 4. 全局禁止命中 → 拒绝
// 5. 全局允许列表非空 → 仅列表内可访问
func (g *Gateway) checkModelAllowed(c *gin.Context, u *store.User, model string) bool {
if model == "" {
return true
}
if contains(u.DeniedModels, model) {
return false
}
if keyAllow, ok := c.Get(CtxKeyModel); ok {
if list, _ := keyAllow.([]string); len(list) > 0 && !contains(list, model) {
return false
}
}
if len(u.AllowedModels) > 0 {
return contains(u.AllowedModels, model)
}
allow, deny := g.globalModelRestrictions()
if contains(deny, model) {
return false
}
if len(allow) > 0 {
return contains(allow, model)
}
return true
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// 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: o.UserRPS,
logRaw: o.LogRaw,
gwTimeout: o.GWTimeout,
maxBodyMB: o.MaxBodyMB,
failoverBudget: o.FailoverBudget,
streamHeartbeat: o.StreamHeartbeat,
tps: map[time.Duration]*http.Transport{},
}
}
// channelTimeout 该渠道的上游超时:timeout_ms 优先,未配置则用全局兜底值。
func (g *Gateway) channelTimeout(ch *store.Channel) time.Duration {
if ch.TimeoutMS > 0 {
return time.Duration(ch.TimeoutMS) * time.Millisecond
}
return g.gwTimeout
}
// clientFor 返回带该超时的上游 client。Transport 按超时值缓存以复用连接池,
// ResponseHeaderTimeout 只约束"连上之后多久要回首包"——流式正文不限时长,长回答不会被砍。
// 整段时长(含读 body)由请求 context 的 deadline 控制,见 proxyOne。
func (g *Gateway) clientFor(d time.Duration) *http.Client {
if d < 0 {
d = 0
}
g.tpMu.Lock()
defer g.tpMu.Unlock()
tp, ok := g.tps[d]
if !ok {
tp = &http.Transport{
Proxy: http.ProxyFromEnvironment,
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}
if d > 0 {
tp.ResponseHeaderTimeout = d
}
g.tps[d] = tp
}
return &http.Client{Transport: tp}
}
// Auth 代理鉴权中间件:Bearer sk-xxx → 哈希查表 → 校验状态/过期/模型白名单。
func (g *Gateway) Auth(c *gin.Context) {
// 先按路径确定客户端协议,保证 Auth 阶段错误也按协议格式返回
switch c.Request.URL.Path {
case "/v1/messages":
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)
}
auth := c.GetHeader("Authorization")
key := strings.TrimPrefix(auth, "Bearer ")
key = strings.TrimSpace(key)
if key == "" {
// Anthropic 客户端(Claude Code / SDK)用 x-api-key 头而不是 Authorization
key = strings.TrimSpace(c.GetHeader("x-api-key"))
}
if !apikey.Valid(key) {
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Authorization: Bearer sk-ot-... or x-api-key: sk-ot-...")
c.Abort()
return
}
hash := apikey.Hash(key)
var k store.APIKey
if err := g.db.Where("key_hash = ? AND status = ?", hash, store.KeyStatusActive).First(&k).Error; err != nil {
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
c.Abort()
return
}
var u store.User
if err := g.db.First(&u, k.UserID).Error; err != nil || u.Status != store.UserStatusActive {
apiError(c, http.StatusForbidden, "user_disabled", "User account is disabled")
c.Abort()
return
}
if k.ExpiresAt != nil && time.Now().After(*k.ExpiresAt) {
apiError(c, http.StatusUnauthorized, "key_expired", "API key has expired")
c.Abort()
return
}
// 限流与配额(内存计数)
if g.lim != nil {
if k.QuotaRequestsPerDay != nil && !g.lim.AllowRequestDaily(k.ID, *k.QuotaRequestsPerDay) {
apiError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Daily request quota exceeded for this API key")
c.Abort()
return
}
if k.QuotaTokensPerDay != nil && g.lim.TokensUsed(k.ID) >= *k.QuotaTokensPerDay {
apiError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Daily token quota exceeded for this API key")
c.Abort()
return
}
if !g.lim.AllowUserRate(u.ID, g.userRPS) {
apiError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Too many requests. Please slow down.")
c.Abort()
return
}
}
c.Set(CtxUserID, u.ID)
c.Set(CtxKeyID, k.ID)
c.Set(CtxKeyModel, k.AllowedModels)
c.Set(CtxTrace, newTraceID())
g.db.Model(&store.APIKey{}).Where("id = ?", k.ID).Update("last_used_at", time.Now())
c.Next()
}
// Handle 路由到对应协议处理器。
func (g *Gateway) Handle(c *gin.Context) {
switch {
case c.Request.URL.Path == "/v1/chat/completions":
g.chatCompletions(c)
case c.Request.URL.Path == "/v1/responses":
g.responses(c)
case c.Request.URL.Path == "/v1/messages":
g.messages(c)
case c.Request.URL.Path == "/v1/models" && c.Request.Method == http.MethodGet:
g.models(c)
default:
apiError(c, http.StatusNotFound, "not_found", "Unknown endpoint: "+c.Request.URL.Path)
}
}
// 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 {
g.writeModelList(c, nil)
return
}
var ms []store.Model
if err := g.db.Where("id IN ? AND enabled = ?", ids, true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
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 {
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})
}
// candidateChannels 返回可用渠道候选(按模型绑定优先,退化全局;携带模型映射)。
func (g *Gateway) candidateChannels(model string) []channel.Candidate {
return g.ch.Candidates(model)
}
// resolveUser 取当前用户(含余额)。
func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
uid, _ := c.Get(CtxUserID)
var u store.User
if err := g.db.First(&u, uid).Error; err != nil {
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
return nil, false
}
return &u, true
}
// checkBalance 余额不足返回 402(PLANNING §4.4.3)。
func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
if u.Balance <= 0 {
apiError(c, http.StatusPaymentRequired, "insufficient_balance", "Insufficient balance. Please recharge or contact admin.")
return false
}
return true
}
// ---------------------------------------------------------------------------
// 协议分派
func upstreamPath(proto string) string {
switch proto {
case convert.ProtoMessages:
return "/messages"
case convert.ProtoResponses:
return "/responses"
default:
return "/chat/completions"
}
}
// upstreamPlan 描述一次代理请求的上游访问方式。
type upstreamPlan struct {
proto string // 上游协议(用于分协议 base_url)
path string // 上游路径
body []byte // 已转换的请求体
lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通)
}
// conversionTarget 决定客户端协议在渠道上的处理方式:
// 渠道声明支持该协议则直通(返回原协议);否则转为其首选支持协议(chat > messages > responses)。
func conversionTarget(formats []string, clientProto string) string {
for _, f := range formats {
if f == clientProto {
return clientProto
}
}
for _, p := range []string{convert.ProtoChat, convert.ProtoMessages, convert.ProtoResponses} {
for _, f := range formats {
if f == p {
return p
}
}
}
return ""
}
// prepareUpstream 计算上游访问计划:渠道声明支持客户端协议则直通,否则转换;
// 应用模型名称映射(upstream_model)。
func prepareUpstream(ch *store.Channel, clientProto string, body []byte, upstreamModel string) (*upstreamPlan, error) {
target := conversionTarget(ch.FormatsEffective(), clientProto)
if target == "" {
return nil, fmt.Errorf("channel %q declares no supported protocol format", ch.Name)
}
plan := &upstreamPlan{proto: target, path: upstreamPath(target), body: body}
if target != clientProto {
converted, err := convert.ConvertRequest(body, clientProto, target)
if err != nil {
return nil, err
}
plan.body = converted
plan.lineConv = convert.NewStreamTransformer(target, clientProto)
plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, target, clientProto) }
}
// 模型名称映射:把请求体 model 字段改写为渠道侧的 upstream_model
if upstreamModel != "" {
if out, err := rewriteModel(plan.body, upstreamModel); err == nil {
plan.body = out
}
}
return plan, nil
}
// rewriteModel 改写请求体中的 model 字段(三种协议 model 都在顶层)。
func rewriteModel(body []byte, model string) ([]byte, error) {
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
return body, nil
}
if cur, _ := m["model"].(string); cur == model {
return body, nil
}
m["model"] = model
return json.Marshal(m)
}
var errNoChannel = errors.New("no available channel")