按性能分析逐项落地,实测对比(16 并发、mock 上游零延迟、SQLite): - 每请求 SQL 10 → 2:模型/渠道目录(模型行、绑定、渠道行)进程内缓存 10s, 运行期状态(冷却、健康)仍每次实时判定;管理端写操作经中间件、 健康状态变化经回调立即失效。新增 TestProxyRequestSQLBudget 钉住预算。 - 端到端 p50 1.3–1.6ms → 0.66–0.70ms,RPS 3.5–8.9k → 16–18k。 - 流式正文累积:字符串 += 改 strings.Builder(原为 O(n²)), 逐行 JSON 提取改字节快路径,并加廉价的子串预过滤、拿到输出 usage 后停累积。 500 块:949µs/7.9MB 分配 → 83µs/136KB。 - 请求体定点改写:模型映射从整包 unmarshal/marshal 改为顶层字段替换 (100KB:585µs → 29µs),stream_options 注入同理;requestText 改为 只在「上游没报用量」时按需提取,亲和键改用原始体前缀。 - 渠道满载背压:新增 Acquire 有界排队(OT_PROXY_CHANNEL_ACQUIRE_WAIT, 默认 150ms);排队仍满时返回 503 + Retry-After 而不是 502。 实测渠道上限 4、16 并发:无排队 81 次 503;排队 150ms 后 400/400 成功。 - 记账按批聚合:同批同用户一次扣款、流水批量插入、日表按 (user,model,日期) 聚合,替代每行 5 条语句;幂等仍靠 ref_id 唯一索引兜底。 - gorm 开启 PrepareStmt(SQL 解析此前占 CPU 15%);tiktoken 编码器按模型复用。 - 管理端用量列表不再返回原始请求/响应体(可能几十 MB), 改为 has_raw 标记 + GET /admin/usage/:id 按需拉取,前端弹窗打开时取。 验证:go vet、go test ./...、go test -race(proxy/channel/usage/tokenizer)、 vue-tsc、eslint、vite build 全绿;本地起 mock 上游实测列表/详情接口与 管理页弹窗按需加载。
560 lines
19 KiB
Go
560 lines
19 KiB
Go
// 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"
|
||
CtxUser = "proxy_user" // Auth 阶段加载的完整用户(含余额),resolveUser 直接复用
|
||
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
|
||
// 流式放行前等多久内容(0=不限制)。超时未出内容取消本次尝试换下一个渠道。
|
||
firstTokenWait time.Duration
|
||
// 会话亲和 TTL(0=关闭):同一会话粘住上次应答的渠道,保住上游提示词缓存。
|
||
affinityTTL time.Duration
|
||
// 渠道并发满载时每个候选最多排队多久(0=立刻跳过)。
|
||
channelAcquireWait time.Duration
|
||
aff *affinityTable
|
||
tpMu sync.Mutex
|
||
tps map[time.Duration]*http.Transport
|
||
|
||
policyMu sync.Mutex
|
||
policy modelPolicy
|
||
|
||
// 模型目录缓存:每请求原本要查「是否存在」与「计价」两次同名查询,
|
||
// 这里按名称缓存 catalogTTL;管理端写操作立即失效。
|
||
modelMu sync.Mutex
|
||
modelCat map[string]modelEntry
|
||
modelAt time.Time
|
||
}
|
||
|
||
// catalogTTL 目录缓存有效期。运行期状态(冷却、健康)不受它影响,
|
||
// 它们每次读取时重新判定;这里只缓存「配置类」数据(模型、渠道、绑定)。
|
||
const catalogTTL = 10 * time.Second
|
||
|
||
type modelEntry struct {
|
||
m store.Model
|
||
ok bool
|
||
}
|
||
|
||
// modelByName 取模型(带进程内缓存);第二个返回值表示模型存在且启用。
|
||
func (g *Gateway) modelByName(name string) (store.Model, bool) {
|
||
g.modelMu.Lock()
|
||
if g.modelCat != nil && time.Since(g.modelAt) < catalogTTL {
|
||
if e, ok := g.modelCat[name]; ok {
|
||
g.modelMu.Unlock()
|
||
return e.m, e.ok
|
||
}
|
||
}
|
||
g.modelMu.Unlock()
|
||
|
||
var m store.Model
|
||
err := g.db.Where("name = ? AND enabled = ?", name, true).First(&m).Error
|
||
e := modelEntry{m: m, ok: err == nil}
|
||
|
||
g.modelMu.Lock()
|
||
if g.modelCat == nil || time.Since(g.modelAt) >= catalogTTL {
|
||
g.modelCat = map[string]modelEntry{}
|
||
g.modelAt = time.Now()
|
||
}
|
||
g.modelCat[name] = e
|
||
g.modelMu.Unlock()
|
||
return e.m, e.ok
|
||
}
|
||
|
||
// InvalidateCatalog 清空模型与渠道目录缓存(管理端改动、健康状态变化后调用)。
|
||
func (g *Gateway) InvalidateCatalog() {
|
||
g.modelMu.Lock()
|
||
g.modelCat = nil
|
||
g.modelAt = time.Time{}
|
||
g.modelMu.Unlock()
|
||
g.ch.InvalidateCatalog()
|
||
}
|
||
|
||
// 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, reason string) {
|
||
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
|
||
}
|
||
if _, ok := g.modelByName(model); !ok {
|
||
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 // 流式空闲心跳间隔
|
||
FirstTokenWait time.Duration // 流式放行前等内容的最长时间(0=不限)
|
||
AffinityTTL time.Duration // 会话亲和 TTL(0=关闭)
|
||
ChannelAcquireWait time.Duration // 渠道满载排队时长(0=立刻跳过)
|
||
}
|
||
|
||
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,
|
||
firstTokenWait: o.FirstTokenWait,
|
||
affinityTTL: o.AffinityTTL,
|
||
channelAcquireWait: o.ChannelAcquireWait,
|
||
aff: newAffinityTable(),
|
||
modelCat: map[string]modelEntry{},
|
||
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(CtxUser, &u)
|
||
c.Set(CtxUserID, u.ID)
|
||
c.Set(CtxKeyID, k.ID)
|
||
c.Set(CtxKeyModel, k.AllowedModels)
|
||
c.Set(CtxTrace, newTraceID())
|
||
// last_used_at 按分钟节流:纯展示字段,不值得每请求一次 DB 写(高 QPS 下是可观开销)
|
||
if k.LastUsedAt == nil || time.Since(*k.LastUsedAt) >= time.Minute {
|
||
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 取当前用户(含余额)。Auth 中间件已查过一次并放入上下文,
|
||
// handler 里直接复用,省掉每请求一次重复查询。
|
||
func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
|
||
if u, ok := c.Get(CtxUser); ok {
|
||
if u2, ok := u.(*store.User); ok {
|
||
return u2, true
|
||
}
|
||
}
|
||
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 字段(模型映射)。
|
||
// 定点替换而不是整包 unmarshal/marshal:后者在 100KB 请求体上实测 ~585µs、
|
||
// 470KB 分配,而这里只有一次扫描 + 拼接。
|
||
func rewriteModel(body []byte, model string) ([]byte, error) {
|
||
out, ok := rewriteModelField(body, model)
|
||
if !ok {
|
||
return body, nil // 没有顶层 model(非法或空体):原样转发,交给上游判
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
var errNoChannel = errors.New("no available channel")
|