Files

367 lines
13 KiB
Go
Raw Permalink 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 handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gorilla/websocket"
"github.com/qianfree/team-api/internal/dispatchadapter"
"github.com/qianfree/team-api/internal/logic/monitor"
"github.com/qianfree/team-api/relay/channel"
"github.com/qianfree/team-api/relay/channel/openai"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relaykit/dispatch"
)
// websocketUpgrader HTTP → WebSocket 升级器
var websocketUpgrader = websocket.Upgrader{
Subprotocols: []string{"realtime"},
CheckOrigin: checkRealtimeOrigin,
}
// checkRealtimeOrigin 校验 WebSocket 握手来源,防跨站 WebSocket 劫持(CSWSH,B15)。
// 策略:
// - 无 Origin 头:放行。Realtime API 的主要客户端是原生 SDK/CLI/服务端,不发送 Origin;
// 浏览器才强制带 Origin,据此区分。
// - 同源(Origin.host == 请求 Host):放行。
// - 命中 REALTIME_ALLOWED_ORIGINS(逗号分隔的可信来源,如租户控制台/playground 域名):放行。
// - 其余跨源浏览器请求:拒绝。
func checkRealtimeOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true // 非浏览器客户端
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
if strings.EqualFold(u.Host, r.Host) {
return true // 同源
}
for _, allowed := range strings.Split(os.Getenv("REALTIME_ALLOWED_ORIGINS"), ",") {
allowed = strings.TrimSpace(allowed)
if allowed == "" {
continue
}
if strings.EqualFold(origin, allowed) || strings.EqualFold(u.Host, allowed) {
return true
}
}
return false
}
// RealtimeContext Realtime 请求上下文
type RealtimeContext struct {
TenantID int64
UserID int64
ApiKeyID int64
ProjectID int64 // 通过 API Key 关联的项目 ID
RequestID string
ClientIP string
Scope string
KeyRateLimitQps int
KeyConcurrency int
KeyIpWhitelist string
KeyTotalQuota float64
KeyUsedQuota float64
}
// HandleRealtime 处理 /v1/realtime WebSocket 请求
func HandleRealtime(w http.ResponseWriter, r *http.Request, rc *RealtimeContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) {
ctx := r.Context()
// 0. 前置检查(scope / IP 白名单 / QPS 限流):只依赖认证上下文,放在 WebSocket
// 升级之前——升级后连接已被劫持,无法再写标准 HTTP 状态码;前置后被拒请求能拿到
// 正常的 401/429 响应,也省掉无谓的升级握手和首消息读取。
// 注意:上层调用方(internal/handler/relay)对 realtime 的返回错误只记日志、不写响应
//(因升级后的错误无法写回),故升级前的拒绝必须在此处自行写回 HTTP 错误响应,
// 否则客户端会收到空 body 的 200。
billingResult := &BillingResult{}
if billing != nil {
if !billing.CheckScope(rc.Scope, "realtime") {
err := constant.NewAuthError("API key scope denied")
WriteRelayError(w, err)
return nil, billingResult, err
}
if !billing.CheckIPWhitelist(rc.KeyIpWhitelist, rc.ClientIP) {
err := constant.NewAuthError("IP address is not allowed")
WriteRelayError(w, err)
return nil, billingResult, err
}
allowed, limitLevel, limit, remaining, resetAt := billing.CheckRateLimit(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, rc.KeyRateLimitQps)
if !allowed {
err := &RelayErrorWithRateLimit{
StatusCode: 429,
Message: fmt.Sprintf("rate limit exceeded at %s level", limitLevel),
LimitLevel: limitLevel,
Remaining: remaining,
ResetAt: resetAt,
}
WriteRelayError(w, err)
return nil, billingResult, err
}
billingResult.RateLimitInfo = &common.RateLimitInfo{
Limit: limit,
Remaining: remaining,
ResetAt: resetAt,
}
}
// 1. WebSocket 升级
clientConn, err := websocketUpgrader.Upgrade(w, r, nil)
if err != nil {
g.Log().Errorf(ctx, "[HandleRealtime] WebSocket upgrade failed: %v", err)
return nil, nil, fmt.Errorf("websocket upgrade failed: %w", err)
}
defer clientConn.Close()
// 2. 读取第一条消息获取 model 名称
_, firstMsg, err := clientConn.ReadMessage()
if err != nil {
return nil, nil, fmt.Errorf("read first websocket message failed: %w", err)
}
var firstEvent map[string]json.RawMessage
if err := json.Unmarshal(firstMsg, &firstEvent); err != nil {
return nil, nil, fmt.Errorf("parse first websocket message failed: %w", err)
}
modelName := extractModelFromEvent(firstEvent)
if modelName == "" {
errMsg, _ := json.Marshal(map[string]any{
"type": "error",
"error": map[string]string{"message": "model is required in the first message"},
})
_ = clientConn.WriteMessage(websocket.TextMessage, errMsg)
return nil, nil, fmt.Errorf("model not found in first websocket message")
}
// 3. 验证模型
_, _, err = provider.GetModelMapping(ctx, modelName)
if err != nil {
return nil, nil, fmt.Errorf("model not found: %s", modelName)
}
// 3.1 检查成员模型范围
if allowed, err := provider.CheckMemberModelAccess(ctx, rc.TenantID, rc.UserID, modelName); err != nil {
return nil, nil, err
} else if !allowed {
return nil, nil, constant.NewAuthError("model not allowed for this member")
}
if allowed, err := provider.CheckApiKeyModelAccess(ctx, rc.ApiKeyID, modelName); err != nil {
return nil, nil, err
} else if !allowed {
return nil, nil, constant.NewAuthError("model not allowed for this API key")
}
// 4. 渠道选择(新调度引擎;websocket 长连接由续期器保活租约)
enabled, channelScope, err := provider.CheckTenantModelAccess(ctx, rc.TenantID, modelName)
if err != nil {
return nil, nil, constant.NewChannelError("no available channel for model: "+modelName, err)
}
if !enabled {
return nil, nil, constant.NewChannelError("no available channel for model: "+modelName, common.ErrTenantModelNotEnabled)
}
sess := dispatchadapter.Coordinator(ctx).Route(ctx, dispatch.RequestProfile{
RequestID: rc.RequestID,
TenantID: rc.TenantID,
UserID: rc.UserID,
APIKeyID: rc.ApiKeyID,
Model: modelName,
Scope: channelScope,
Replay: dispatch.ReplayCostly,
Signals: dispatch.SessionSignals{HeaderSessionID: r.Header.Get("X-Session-Id")},
Policy: dispatchadapter.TenantRoutingPolicy(ctx, rc.TenantID),
})
defer sess.Finish(context.WithoutCancel(ctx), false, 0)
var selection *common.ChannelSelection
for {
d := sess.Next(ctx)
if d == nil {
monitor.TrackDispatchNoCandidate()
return nil, nil, constant.NewChannelError("no available channel for model: "+modelName, common.ErrChannelUnavailable)
}
monitor.TrackDispatchSelection(string(d.Reason), string(d.Channel.Tier), string(d.SessionKey.Source))
sel, mErr := provider.MaterializeSelection(ctx, d.Channel.ID, d.KeyID, modelName)
if mErr != nil {
if decision := reportMaterializeFailure(ctx, sess, mErr); decision == dispatch.DecisionAbort {
return nil, nil, constant.NewChannelError("no available channel for model: "+modelName, mErr)
}
continue
}
sel.SelectionReason = string(d.Reason)
selection = sel
break
}
leaseRefresh := startDispatchLeaseRefresher(selection.ChannelID, rc.RequestID)
defer leaseRefresh.Stop()
realtimeStart := time.Now()
// 5. 构造 RelayInfo
info := &common.RelayInfo{
Context: ctx,
TenantID: rc.TenantID,
UserID: rc.UserID,
ApiKeyID: rc.ApiKeyID,
RequestID: rc.RequestID,
RelayMode: int(constant.RelayModeRealtime),
OriginModelName: modelName,
RequestURLPath: "/realtime",
RequestHeaders: r.Header,
StartTime: time.Now(),
InboundFormat: constant.RelayFormatOpenAI,
ClientFormat: constant.RelayFormatOpenAI,
ChannelMeta: &common.ChannelMeta{
ChannelID: selection.ChannelID,
ChannelType: selection.ChannelType,
ChannelName: selection.ChannelName,
BaseURL: selection.BaseURL,
ApiKey: selection.ApiKey,
UpstreamModelName: selection.UpstreamModelName,
IsModelMapped: selection.IsModelMapped,
Settings: selection.Settings,
},
ClientConn: clientConn,
}
// 6. 获取适配器
adaptor := channel.GetAdaptor(selection.ChannelType)
if adaptor == nil {
return nil, nil, fmt.Errorf("unsupported channel type for realtime: %d", selection.ChannelType)
}
adaptor.Init(info)
// 7. 预扣费用(Realtime 使用 0 input tokens 作为初始估算)
var preDeductAmount float64
if billing != nil {
if !billing.AcquireConcurrent(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, modelName) {
return nil, billingResult, constant.NewRateLimitError("concurrent request limit exceeded")
}
defer billing.ReleaseConcurrent(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, modelName)
if !billing.AcquireApiKeyConcurrent(ctx, rc.ApiKeyID, rc.KeyConcurrency) {
return nil, billingResult, constant.NewRateLimitError("API key concurrent request limit exceeded")
}
defer billing.ReleaseApiKeyConcurrent(ctx, rc.ApiKeyID)
amt, billErr := billing.PreDeduct(ctx, rc.TenantID, modelName, 0, 0, false, rc.RequestID)
if billErr != nil {
// 未配价模型 fail-closed:返回明确的请求错误,而非误导性的"余额不足"
if errors.Is(billErr, common.ErrModelPricingNotConfigured) {
return nil, nil, constant.NewRequestError("model pricing not configured: "+modelName, billErr)
}
return nil, nil, constant.NewQuotaError("insufficient balance", billErr)
}
preDeductAmount = amt
// 成员/Key 额度检查只做这一次(带实际预扣额),与 RelayHandler 一致:
// 预扣前的 Check(0) 快速闸门对全量请求多付 Redis 往返,收益不成比例,已移除。
if err := billing.CheckApiKeyQuota(ctx, rc.ApiKeyID, amt); err != nil {
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, amt)
return nil, billingResult, constant.NewQuotaError("API key quota exceeded", err)
}
if err := billing.CheckMemberQuota(ctx, rc.TenantID, rc.UserID, amt); err != nil {
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, amt)
return nil, billingResult, constant.NewQuotaError("member quota exceeded", err)
}
}
billingResult.PreDeductAmount = preDeductAmount
// 8. 建立 Realtime 代理
proxy := openai.NewRealtimeProxy(info)
if err := proxy.DialUpstream(); err != nil {
// 拨号失败上报健康(绑定不删除,由守卫自然失效);realtime 不做跨渠道重试,保持原语义
_, _ = sess.Report(ctx, 0, err, deliveryStateOfRequestErr(err), float64(time.Since(realtimeStart).Milliseconds()), 0)
if billing != nil && preDeductAmount > 0 {
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
}
return nil, billingResult, err
}
defer proxy.Close()
// 把第一条消息转发到上游
if err := proxy.GetTargetConn().WriteMessage(websocket.TextMessage, firstMsg); err != nil {
if billing != nil && preDeductAmount > 0 {
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
}
return nil, billingResult, err
}
// 9. 启动双向代理
usage, proxyErr := proxy.Proxy(ctx)
if proxyErr == nil {
// 成功:绑定续期 + 健康上报由 Finish 完成
sess.Finish(context.WithoutCancel(ctx), true, float64(time.Since(realtimeStart).Milliseconds()))
} else {
// 会话中断:响应已开始,不可重试,仅上报健康
_, _ = sess.Report(context.WithoutCancel(ctx), 0, proxyErr, dispatch.DeliveryResponseStarted,
float64(time.Since(realtimeStart).Milliseconds()), 0)
}
// 10. 结算费用
if billing != nil && preDeductAmount > 0 {
if proxyErr != nil {
streamUsage := usage
if streamUsage == nil {
streamUsage = &common.Usage{}
}
settleResult, _ := billing.SettleStreamInterrupted(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
modelName, rc.RequestID, "realtime", streamUsage, preDeductAmount, rc.ProjectID, info.StartTime)
if settleResult != nil && settleResult.ActualCost > 0 && !settleResult.DuplicateSkip {
billing.IncrMemberQuotaUsed(ctx, rc.TenantID, rc.UserID, settleResult.ActualCost)
billing.IncrApiKeyQuotaUsed(ctx, rc.ApiKeyID, settleResult.ActualCost)
}
} else if usage != nil {
settleResult, _ := billing.Settle(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
modelName, rc.RequestID, "realtime", usage, preDeductAmount, rc.ProjectID, info.StartTime)
if settleResult != nil && settleResult.ActualCost > 0 && !settleResult.DuplicateSkip {
billing.IncrMemberQuotaUsed(ctx, rc.TenantID, rc.UserID, settleResult.ActualCost)
billing.IncrApiKeyQuotaUsed(ctx, rc.ApiKeyID, settleResult.ActualCost)
}
}
}
return usage, billingResult, proxyErr
}
// extractModelFromEvent 从 WebSocket 事件中提取模型名称
func extractModelFromEvent(event map[string]json.RawMessage) string {
// 顶层 model 字段
if m, ok := event["model"]; ok {
var s string
if json.Unmarshal(m, &s) == nil {
return strings.Trim(s, `"`)
}
}
// session.update 的 session.model
if sessionRaw, ok := event["session"]; ok {
var sess map[string]json.RawMessage
if json.Unmarshal(sessionRaw, &sess) == nil {
if m, ok := sess["model"]; ok {
var s string
if json.Unmarshal(m, &s) == nil {
return strings.Trim(s, `"`)
}
}
}
}
return ""
}