Files
openteam/server/internal/proxy/gateway.go
T
Sakurasan d6db0f8162 feat: 补齐三个方向的流式工具调用;Anthropic 上游走 x-api-key;禁用模型真正下架
- 流式转换补齐 chat→responses / responses→chat / messages→responses 的工具调用(此前只转文本,SDK 客户端拿不到 tool_calls);completed 事件携带完整 output 数组
- Anthropic 协议上游同时发 x-api-key(官方 API 不接受 API key 走 Bearer)并透传 anthropic-beta
- 请求侧校验模型存在且启用:禁用模型 404 model_not_found,不再只是从 /v1/models 消失、实际照发照扣费;空模型名 400
- 模型在请求中途被删时按 0 记账留痕,不再按硬编码示例价扣用户的钱
- finishUsage 新增一行式请求日志(trace/user/model/channel/tokens/cost/status/latency),记账是异步的,日志是唯一即时排障依据
2026-10-29 02:56:37 +08:00

489 lines
16 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
}
// checkModelExists 拦截不存在或已禁用的模型。/v1/models 只列启用的模型,请求侧口径必须一致:
// 禁用模型不能只是从列表消失——否则照样能调用、照样计费,管理员的下架操作形同虚设。
func (g *Gateway) checkModelExists(c *gin.Context, model string) bool {
if model == "" {
apiError(c, http.StatusBadRequest, "invalid_request_error", "model is required")
return false
}
var n int64
g.db.Model(&store.Model{}).Where("name = ? AND enabled = ?", model, true).Count(&n)
if n == 0 {
apiError(c, http.StatusNotFound, "model_not_found",
fmt.Sprintf("The model %q does not exist or is not enabled", model))
return false
}
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")