786 lines
29 KiB
Go
786 lines
29 KiB
Go
package handler
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
|
||
commonlogic "github.com/qianfree/team-api/internal/logic/common"
|
||
"github.com/qianfree/team-api/internal/logic/monitor"
|
||
"github.com/qianfree/team-api/relay/channel"
|
||
"github.com/qianfree/team-api/relay/common"
|
||
"github.com/qianfree/team-api/relay/constant"
|
||
"github.com/qianfree/team-api/relay/helper"
|
||
"github.com/qianfree/team-api/relay/override"
|
||
"github.com/qianfree/team-api/relay/scheduler"
|
||
)
|
||
|
||
// RelayContext relay 请求上下文(从 GoFrame handler 传入)
|
||
type RelayContext struct {
|
||
TenantID int64
|
||
UserID int64
|
||
ApiKeyID int64
|
||
ProjectID int64 // 通过 API Key 关联的项目 ID
|
||
RequestID string
|
||
Writer http.ResponseWriter
|
||
Scope string // API Key scope
|
||
ClientIP string // 客户端 IP
|
||
ForwardingTrace *common.ForwardingTrace // 转发路径追踪(仅管理员可见)
|
||
}
|
||
|
||
// BillingResult 计费结果(返回给调用方用于设置响应头)
|
||
type BillingResult struct {
|
||
PreDeductAmount float64
|
||
ActualCost float64
|
||
RateLimitInfo *common.RateLimitInfo
|
||
Deprecation *common.DeprecationInfo
|
||
FirstTokenMs int
|
||
}
|
||
|
||
// RelayHandler 共享的 relay 请求编排逻辑(带重试 + 计费)
|
||
func RelayHandler(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) {
|
||
// 1. 确定 relay mode
|
||
relayMode := constant.Path2RelayMode(path)
|
||
if relayMode == constant.RelayModeUnknown {
|
||
g.Log().Errorf(ctx, "[RelayHandler] Unknown relay mode for path: %s", path)
|
||
return nil, nil, constant.NewRequestError("unsupported endpoint: "+path, nil)
|
||
}
|
||
|
||
relayModeStr := relayModeString(relayMode)
|
||
|
||
// 2. 解析请求体获取模型名
|
||
var rawRequest map[string]json.RawMessage
|
||
if err := json.Unmarshal(body, &rawRequest); err != nil {
|
||
return nil, nil, constant.NewRequestError("invalid request body", err)
|
||
}
|
||
|
||
modelName := ""
|
||
if v, ok := rawRequest["model"]; ok {
|
||
var m string
|
||
if err := json.Unmarshal(v, &m); err == nil {
|
||
modelName = m
|
||
}
|
||
}
|
||
if modelName == "" {
|
||
return nil, nil, constant.NewRequestError("model is required", nil)
|
||
}
|
||
|
||
// 2.5 解析 thinking/effort 后缀
|
||
thinkingInfo := helper.ParseThinkingSuffix(modelName)
|
||
lookupModel := modelName
|
||
if thinkingInfo.BaseModel != modelName {
|
||
lookupModel = thinkingInfo.BaseModel
|
||
}
|
||
|
||
// 3. 验证模型存在且活跃
|
||
_, _, modelErr := provider.GetModelMapping(ctx, modelName)
|
||
if modelErr != nil && thinkingInfo.BaseModel != modelName {
|
||
// 完整模型名查找失败,尝试基础模型名
|
||
_, _, modelErr = provider.GetModelMapping(ctx, thinkingInfo.BaseModel)
|
||
if modelErr == nil {
|
||
lookupModel = thinkingInfo.BaseModel
|
||
}
|
||
}
|
||
if modelErr != nil {
|
||
if modelErr == common.ErrModelNotFound {
|
||
return nil, nil, constant.NewRequestError("model not found: "+modelName, modelErr)
|
||
}
|
||
return nil, nil, modelErr
|
||
}
|
||
|
||
// 3.5 检查模型弃用状态
|
||
depInfo, _ := provider.GetModelDeprecationInfo(ctx, lookupModel)
|
||
if depInfo != nil && depInfo.Deprecated && depInfo.SunsetDate != "" {
|
||
sunsetTime, _ := time.Parse("2006-01-02", depInfo.SunsetDate)
|
||
if !sunsetTime.IsZero() && time.Now().After(sunsetTime) {
|
||
return nil, nil, constant.NewModelGoneError(lookupModel, depInfo.SunsetDate)
|
||
}
|
||
}
|
||
|
||
// 3.7 检查成员模型范围
|
||
if allowed, err := provider.CheckMemberModelAccess(ctx, rc.TenantID, rc.UserID, lookupModel); err != nil {
|
||
return nil, nil, err
|
||
} else if !allowed {
|
||
return nil, nil, constant.NewAuthError("model not allowed for this member")
|
||
}
|
||
|
||
// 4. API Key 模型范围校验
|
||
if allowed, err := provider.CheckApiKeyModelAccess(ctx, rc.ApiKeyID, lookupModel); err != nil {
|
||
return nil, nil, err
|
||
} else if !allowed {
|
||
return nil, nil, constant.NewAuthError("model not allowed for this API key")
|
||
}
|
||
|
||
// 5. QPS 限流检查
|
||
billingResult := &BillingResult{Deprecation: depInfo}
|
||
if billing != nil {
|
||
allowed, limitLevel, remaining, resetAt := billing.CheckRateLimit(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID)
|
||
if !allowed {
|
||
return nil, nil, &RelayErrorWithRateLimit{
|
||
StatusCode: 429,
|
||
Message: fmt.Sprintf("rate limit exceeded at %s level", limitLevel),
|
||
LimitLevel: limitLevel,
|
||
Remaining: remaining,
|
||
ResetAt: resetAt,
|
||
}
|
||
}
|
||
billingResult.RateLimitInfo = &common.RateLimitInfo{
|
||
Limit: 0, // 由 middleware 设置具体值
|
||
Remaining: remaining,
|
||
ResetAt: resetAt,
|
||
}
|
||
}
|
||
|
||
// 6. 并发限制(含模型级并发控制)
|
||
if billing != nil {
|
||
if !billing.AcquireConcurrent(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, modelName) {
|
||
return nil, nil, constant.NewRateLimitError("concurrent request limit exceeded")
|
||
}
|
||
defer billing.ReleaseConcurrent(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, modelName)
|
||
}
|
||
|
||
// 6.5 实时监控注册
|
||
var isStream bool
|
||
if streamVal, ok := rawRequest["stream"]; ok {
|
||
_ = json.Unmarshal(streamVal, &isStream)
|
||
}
|
||
monitor.RegisterRequest(&monitor.TrackedRequest{
|
||
RequestID: rc.RequestID,
|
||
TenantID: rc.TenantID,
|
||
UserID: rc.UserID,
|
||
ProjectID: rc.ProjectID,
|
||
ModelName: modelName,
|
||
IsStream: isStream,
|
||
StartTime: time.Now(),
|
||
Path: path,
|
||
})
|
||
defer monitor.UnregisterRequest(rc.RequestID)
|
||
|
||
// 7. 估算输入 token 数(粗略:按字符数 / 4)
|
||
estimatedInputTokens := estimateInputTokens(body)
|
||
|
||
// 获取 max_tokens
|
||
var maxTokens int
|
||
if mtVal, ok := rawRequest["max_tokens"]; ok {
|
||
_ = json.Unmarshal(mtVal, &maxTokens)
|
||
}
|
||
if mcVal, ok := rawRequest["max_completion_tokens"]; ok {
|
||
var mc int
|
||
_ = json.Unmarshal(mcVal, &mc)
|
||
if mc > maxTokens {
|
||
maxTokens = mc
|
||
}
|
||
}
|
||
|
||
// 7.5 成员额度预检查
|
||
if billing != nil {
|
||
if err := billing.CheckMemberQuota(ctx, rc.TenantID, rc.UserID, 0); err != nil {
|
||
return nil, nil, constant.NewQuotaError("member quota exceeded", err)
|
||
}
|
||
}
|
||
|
||
// 8. 预扣费用
|
||
var preDeductAmount float64
|
||
if billing != nil {
|
||
amt, err := billing.PreDeduct(ctx, rc.TenantID, modelName, estimatedInputTokens, maxTokens, isStream, rc.RequestID)
|
||
if err != nil {
|
||
return nil, nil, constant.NewQuotaError("insufficient balance", err)
|
||
}
|
||
preDeductAmount = amt
|
||
billingResult.PreDeductAmount = amt
|
||
}
|
||
|
||
// 10. 带重试的请求调度
|
||
excludeChannelIDs := make([]int64, 0)
|
||
maxRetries := 3
|
||
originalBody := body // 保存原始请求体,避免重试时重复转换
|
||
channelErrors := make([]string, 0) // 记录每次尝试的失败原因,用于最终汇总
|
||
|
||
// 初始化转发路径追踪
|
||
trace := &common.ForwardingTrace{
|
||
EntryPath: path,
|
||
EntryFormat: string(relayModeToInboundFormat(relayMode)),
|
||
RequestedModel: modelName,
|
||
}
|
||
|
||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||
// 选择渠道(排除已失败的)
|
||
selection, err := provider.GetChannelForModel(ctx, rc.TenantID, rc.UserID, lookupModel, excludeChannelIDs)
|
||
if err != nil {
|
||
if err == common.ErrChannelUnavailable {
|
||
if len(excludeChannelIDs) > 0 {
|
||
// 所有渠道失败,退还预扣
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
g.Log().Errorf(ctx, "[RelayHandler] All %d channels failed for model=%s tenant=%d user=%d request=%s. Failure details: %s",
|
||
len(excludeChannelIDs), modelName, rc.TenantID, rc.UserID, rc.RequestID, strings.Join(channelErrors, "\n"))
|
||
allFailedErr := constant.NewChannelError(
|
||
fmt.Sprintf("all %d channels failed for model: %s", len(excludeChannelIDs), modelName),
|
||
constant.ErrAllChannelsFailed,
|
||
)
|
||
recordFailedUsage(provider, rc, 0, modelName, relayMode, isStream, allFailedErr)
|
||
return nil, billingResult, allFailedErr
|
||
}
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
g.Log().Errorf(ctx, "[RelayHandler] No available channel for model=%s tenant=%d user=%d", modelName, rc.TenantID, rc.UserID)
|
||
noChErr := constant.NewChannelError("no available channel for model: "+modelName, err)
|
||
recordFailedUsage(provider, rc, 0, modelName, relayMode, isStream, noChErr)
|
||
return nil, billingResult, noChErr
|
||
}
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
return nil, billingResult, err
|
||
}
|
||
|
||
// 11. 构建 RelayInfo
|
||
info := &common.RelayInfo{
|
||
Context: ctx,
|
||
TenantID: rc.TenantID,
|
||
UserID: rc.UserID,
|
||
ApiKeyID: rc.ApiKeyID,
|
||
ProjectID: rc.ProjectID,
|
||
RequestID: rc.RequestID,
|
||
RelayMode: int(relayMode),
|
||
IsStream: isStream,
|
||
OriginModelName: modelName,
|
||
BaseModelName: lookupModel,
|
||
ThinkingEnabled: thinkingInfo.IsThinking,
|
||
ThinkingDisabled: thinkingInfo.IsNoThinking,
|
||
ReasoningEffort: thinkingInfo.EffortLevel,
|
||
RequestURLPath: path,
|
||
RequestHeaders: headers,
|
||
StartTime: time.Now(),
|
||
StreamStatus: common.NewStreamStatus(),
|
||
InboundFormat: relayModeToInboundFormat(relayMode),
|
||
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,
|
||
},
|
||
}
|
||
|
||
// 11.5 更新实时监控中的渠道信息
|
||
if tr := monitor.GetTrackedRequest(rc.RequestID); tr != nil {
|
||
tr.ChannelID = selection.ChannelID
|
||
tr.ChannelName = selection.ChannelName
|
||
}
|
||
|
||
// 12. 获取适配器
|
||
adaptor := channel.GetAdaptor(selection.ChannelType)
|
||
if adaptor == nil {
|
||
g.Log().Errorf(ctx, "[RelayHandler] No adaptor found for channelType: %d", selection.ChannelType)
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
return nil, billingResult, fmt.Errorf("unsupported channel type: %d", selection.ChannelType)
|
||
}
|
||
adaptor.Init(info)
|
||
|
||
// 捕获上游 URL 用于转发追踪
|
||
var upstreamURL string
|
||
if u, err := adaptor.GetRequestURL(info); err == nil {
|
||
upstreamURL = u
|
||
}
|
||
hop := common.ForwardingHop{
|
||
Attempt: attempt,
|
||
ChannelID: selection.ChannelID,
|
||
ChannelName: selection.ChannelName,
|
||
ChannelType: selection.ChannelType,
|
||
Provider: constant.ProviderType(selection.ChannelType).String(),
|
||
BaseURL: selection.BaseURL,
|
||
UpstreamURL: upstreamURL,
|
||
UpstreamModel: selection.UpstreamModelName,
|
||
ModelMapped: selection.IsModelMapped,
|
||
}
|
||
|
||
// 12.5 保存客户端原始格式(用于响应方向转换)
|
||
// 每次重试从原始请求体开始转换,避免重复转换
|
||
workingBody := originalBody
|
||
if info.ClientFormat == "" {
|
||
info.ClientFormat = info.InboundFormat
|
||
}
|
||
|
||
// 13. 转换请求(直连模式跳过协议转换和参数改写)
|
||
var convertedBody io.Reader
|
||
if canPassThrough(info) {
|
||
// Gemini 原生格式通过 URL 路径控制流式,body 中的 "stream" 字段会导致上游报错
|
||
if info.InboundFormat == constant.RelayFormatGemini {
|
||
workingBody = helper.StripStreamField(workingBody)
|
||
}
|
||
convertedBody = bytes.NewReader(workingBody)
|
||
} else {
|
||
var err error
|
||
convertedBody, err = adaptor.ConvertRequest(ctx, info, workingBody)
|
||
if err != nil {
|
||
g.Log().Errorf(ctx, "[RelayHandler] ConvertRequest failed: adaptor=%s, inboundFormat=%s, error=%v",
|
||
adaptor.GetChannelName(), info.InboundFormat, err)
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
return nil, billingResult, err
|
||
}
|
||
|
||
// 13.3 注入渠道系统提示词
|
||
if info.ChannelMeta.Settings.SystemPrompt != "" {
|
||
bodyBytes, _ := io.ReadAll(convertedBody)
|
||
bodyBytes = helper.InjectSystemPrompt(bodyBytes, info)
|
||
convertedBody = bytes.NewReader(bodyBytes)
|
||
}
|
||
|
||
// 13.5 应用请求体改写(ParamOverride)
|
||
if info.ChannelMeta.Settings.ParamOverride != nil {
|
||
bodyBytes, err := io.ReadAll(convertedBody)
|
||
if err != nil {
|
||
g.Log().Errorf(ctx, "[RelayHandler] Read converted body for param override failed: %v", err)
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
return nil, billingResult, err
|
||
}
|
||
bodyBytes, err = override.ApplyParamOverride(bodyBytes, info)
|
||
if err != nil {
|
||
if retErr, ok := override.AsReturnError(err); ok {
|
||
return nil, billingResult, constant.NewUpstreamError(retErr.StatusCode, retErr.Message, retErr)
|
||
}
|
||
return nil, billingResult, err
|
||
}
|
||
convertedBody = bytes.NewReader(bodyBytes)
|
||
}
|
||
|
||
// 13.7 字段清理
|
||
{
|
||
sanitized, _ := io.ReadAll(convertedBody)
|
||
sanitized = helper.SanitizeFields(sanitized, info.ChannelMeta.Settings)
|
||
convertedBody = bytes.NewReader(sanitized)
|
||
}
|
||
}
|
||
|
||
// 13.9 上游请求 context 解耦:客户端断开不应中断上游请求,
|
||
// 参考 new-api/sub2api:上游请求用 WithoutCancel 解耦,超时由 HTTP Client 和 StreamScanner 控制。
|
||
upstreamCtx := context.WithoutCancel(ctx)
|
||
|
||
// 独立结算 context(加超时保护,防止结算操作无限阻塞)
|
||
settleCtx, settleCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer settleCancel()
|
||
|
||
// 14. 发送请求到上游
|
||
resp, err := adaptor.DoRequest(upstreamCtx, info, convertedBody)
|
||
if err != nil {
|
||
provider.UpdateChannelHealth(settleCtx, selection.ChannelID, false, info.LatencyMs())
|
||
provider.IncrementConsecutiveFailure(settleCtx, selection.ChannelID)
|
||
excludeChannelIDs = append(excludeChannelIDs, selection.ChannelID)
|
||
|
||
failReason := fmt.Sprintf("attempt=%d channel=%d(%s) model=%s upstreamModel=%s error=[%v] latency=%.0fms retryable=%v",
|
||
attempt, selection.ChannelID, selection.ChannelName, modelName, selection.UpstreamModelName, err, info.LatencyMs(), constant.IsRetryable(err))
|
||
channelErrors = append(channelErrors, failReason)
|
||
g.Log().Warningf(ctx, "[RelayHandler] Upstream request failed: %s", failReason)
|
||
|
||
// 亲和性渠道失败,清除亲和性
|
||
scheduler.GetGlobalAffinity().Delete(rc.TenantID, rc.UserID, modelName)
|
||
|
||
if constant.IsRetryable(err) && attempt < maxRetries {
|
||
recordChannelError(rc, selection, modelName, attempt, false, err, info.LatencyMs())
|
||
hop.Success = false
|
||
hop.Error = err.Error()
|
||
hop.LatencyMs = info.LatencyMs()
|
||
trace.Hops = append(trace.Hops, hop)
|
||
continue
|
||
}
|
||
|
||
// 不可重试或达到上限:退还预扣
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
|
||
recordFailedUsage(provider, rc, selection.ChannelID, modelName, relayMode, isStream, err)
|
||
recordChannelError(rc, selection, modelName, attempt, true, err, info.LatencyMs())
|
||
|
||
hop.Success = false
|
||
hop.Error = err.Error()
|
||
hop.LatencyMs = info.LatencyMs()
|
||
trace.Hops = append(trace.Hops, hop)
|
||
trace.TotalAttempts = attempt + 1
|
||
trace.UpstreamModel = selection.UpstreamModelName
|
||
trace.ModelMapped = selection.IsModelMapped
|
||
rc.ForwardingTrace = trace
|
||
|
||
return nil, billingResult, helper.RemapStatusCode(constant.NewUpstreamError(502, "upstream request failed", err), info.ChannelMeta.Settings.StatusCodeMapping)
|
||
}
|
||
|
||
// 非流式:DoRequest 返回即表示收到上游首响应,设置首Token时间
|
||
if !isStream {
|
||
info.SetFirstResponseTime()
|
||
}
|
||
|
||
// 15. 处理响应
|
||
usage, err := adaptor.DoResponse(ctx, resp, info, rc.Writer)
|
||
if err != nil {
|
||
err = helper.RemapStatusCode(err, info.ChannelMeta.Settings.StatusCodeMapping)
|
||
|
||
// 流式中断(客户端断开、上游超时等):降级为 WARN,不影响渠道健康度
|
||
if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
|
||
g.Log().Warningf(ctx, "[RelayHandler] Stream interrupted: adaptor=%s, model=%s, reason=%s",
|
||
adaptor.GetChannelName(), modelName, info.StreamStatus.Summary())
|
||
|
||
streamUsage := usage
|
||
if streamUsage == nil {
|
||
streamUsage = &common.Usage{}
|
||
}
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleStreamInterrupted(settleCtx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
|
||
modelName, rc.RequestID, relayModeStr, streamUsage, preDeductAmount, rc.ProjectID)
|
||
}
|
||
recordFailedUsage(provider, rc, selection.ChannelID, modelName, relayMode, isStream, err)
|
||
hop.Success = false
|
||
hop.Error = err.Error()
|
||
hop.LatencyMs = info.LatencyMs()
|
||
trace.Hops = append(trace.Hops, hop)
|
||
trace.TotalAttempts = attempt + 1
|
||
trace.UpstreamModel = selection.UpstreamModelName
|
||
trace.ModelMapped = selection.IsModelMapped
|
||
rc.ForwardingTrace = trace
|
||
return usage, billingResult, err
|
||
}
|
||
|
||
// 非中断类错误:记录 ERROR 并更新渠道健康度
|
||
g.Log().Errorf(ctx, "[RelayHandler] DoResponse failed: adaptor=%s, inboundFormat=%s, channel=%d(%s) model=%s attempt=%d error=%v latency=%.0fms",
|
||
adaptor.GetChannelName(), info.InboundFormat, selection.ChannelID, selection.ChannelName, modelName, attempt, err, info.LatencyMs())
|
||
provider.UpdateChannelHealth(settleCtx, selection.ChannelID, false, info.LatencyMs())
|
||
provider.IncrementConsecutiveFailure(settleCtx, selection.ChannelID)
|
||
excludeChannelIDs = append(excludeChannelIDs, selection.ChannelID)
|
||
failReason := fmt.Sprintf("attempt=%d channel=%d(%s) model=%s doResponse_error=[%v] latency=%.0fms",
|
||
attempt, selection.ChannelID, selection.ChannelName, modelName, err, info.LatencyMs())
|
||
channelErrors = append(channelErrors, failReason)
|
||
|
||
// 亲和性渠道失败,清除亲和性
|
||
scheduler.GetGlobalAffinity().Delete(rc.TenantID, rc.UserID, modelName)
|
||
|
||
// 其他失败
|
||
if isStream || !constant.IsRetryable(err) || attempt >= maxRetries {
|
||
// 退还预扣
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
recordFailedUsage(provider, rc, selection.ChannelID, modelName, relayMode, isStream, err)
|
||
recordChannelError(rc, selection, modelName, attempt, true, err, info.LatencyMs())
|
||
hop.Success = false
|
||
hop.Error = err.Error()
|
||
hop.LatencyMs = info.LatencyMs()
|
||
trace.Hops = append(trace.Hops, hop)
|
||
trace.TotalAttempts = attempt + 1
|
||
trace.UpstreamModel = selection.UpstreamModelName
|
||
trace.ModelMapped = selection.IsModelMapped
|
||
rc.ForwardingTrace = trace
|
||
|
||
return nil, billingResult, err
|
||
}
|
||
recordChannelError(rc, selection, modelName, attempt, false, err, info.LatencyMs())
|
||
hop.Success = false
|
||
hop.Error = err.Error()
|
||
hop.LatencyMs = info.LatencyMs()
|
||
trace.Hops = append(trace.Hops, hop)
|
||
continue
|
||
}
|
||
|
||
hop.Success = true
|
||
hop.LatencyMs = info.LatencyMs()
|
||
trace.Hops = append(trace.Hops, hop)
|
||
trace.TotalAttempts = attempt + 1
|
||
trace.UpstreamModel = selection.UpstreamModelName
|
||
trace.ModelMapped = selection.IsModelMapped
|
||
rc.ForwardingTrace = trace
|
||
|
||
// 16. 成功:结算费用(使用完整 Usage 含 cache token)
|
||
// 流式响应完成后客户端可能已断开,ctx 已取消,
|
||
// 结算/健康度/用量记录等后置操作必须使用独立 context,不受请求生命周期影响。
|
||
// 重要:此处重新创建 context,因为上游 DoResponse 可能耗时很长(长文本流式输出),
|
||
// 循环开头创建的 settleCtx 此时可能已经过期。
|
||
postCtx, postCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer postCancel()
|
||
|
||
var settleResult *common.SettlementResult
|
||
if billing != nil && preDeductAmount > 0 {
|
||
settleResult = billing.SettleWithUsage(postCtx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
|
||
modelName, rc.RequestID, relayModeStr,
|
||
usage, preDeductAmount, info)
|
||
if settleResult != nil {
|
||
billingResult.ActualCost = settleResult.ActualCost
|
||
} else {
|
||
// 结算失败,退还预扣冻结金额
|
||
g.Log().Warningf(postCtx, "[RelayHandler] Settlement failed for request=%s model=%s, refunding pre-deduct amount=%.6f",
|
||
rc.RequestID, modelName, preDeductAmount)
|
||
_ = billing.SettleFailed(postCtx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
}
|
||
|
||
// 16.5 累加成员已用额度
|
||
if billing != nil && settleResult != nil && settleResult.ActualCost > 0 {
|
||
billing.IncrMemberQuotaUsed(postCtx, rc.TenantID, rc.UserID, settleResult.ActualCost)
|
||
}
|
||
|
||
// 17. 更新健康度 + 记录用量 + 更新亲和性
|
||
provider.UpdateChannelHealth(postCtx, selection.ChannelID, true, info.LatencyMs())
|
||
provider.ResetConsecutiveFailure(postCtx, selection.ChannelID)
|
||
|
||
scheduler.GetGlobalAffinity().Set(rc.TenantID, rc.UserID, modelName, selection.ChannelID)
|
||
|
||
firstTokenMs := 0
|
||
if !info.FirstResponseTime.IsZero() {
|
||
firstTokenMs = int(info.FirstResponseTime.Sub(info.StartTime).Milliseconds())
|
||
}
|
||
|
||
// 构建用量记录
|
||
usageRecord := &common.UsageRecord{
|
||
TenantID: rc.TenantID,
|
||
UserID: rc.UserID,
|
||
ApiKeyID: rc.ApiKeyID,
|
||
ProjectID: rc.ProjectID,
|
||
ChannelID: selection.ChannelID,
|
||
ModelName: modelName,
|
||
RelayMode: int(relayMode),
|
||
PromptTokens: usage.PromptTokens,
|
||
CompletionTokens: usage.CompletionTokens,
|
||
TotalTokens: usage.TotalTokens,
|
||
CachedTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.CachedTokens }),
|
||
AudioTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.AudioTokens }) +
|
||
tokenDetailField(usage.CompletionTokenDetails, func(d *common.TokenDetails) int { return d.AudioTokens }),
|
||
ImageTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.ImageTokens }),
|
||
ReasoningTokens: tokenDetailField(usage.CompletionTokenDetails, func(d *common.TokenDetails) int { return d.ReasoningTokens }),
|
||
LatencyMs: info.TotalLatencyMs(),
|
||
IsStream: isStream,
|
||
Success: true,
|
||
RequestID: rc.RequestID,
|
||
Status: "success",
|
||
|
||
// Cache token 明细
|
||
CacheCreationTokens: usage.CacheCreationTokens,
|
||
CacheCreation5mTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.CachedCreation5mTokens }),
|
||
CacheCreation1hTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.CachedCreation1hTokens }),
|
||
CacheReadTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.CachedTokens }),
|
||
|
||
// 音频 token 分离
|
||
AudioInputTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.AudioTokens }),
|
||
AudioOutputTokens: tokenDetailField(usage.CompletionTokenDetails, func(d *common.TokenDetails) int { return d.AudioTokens }),
|
||
|
||
// 其他 token
|
||
ImageOutputTokens: tokenDetailField(usage.CompletionTokenDetails, func(d *common.TokenDetails) int { return d.ImageTokens }),
|
||
|
||
// 请求元数据
|
||
RequestedModel: modelName,
|
||
UpstreamModel: selection.UpstreamModelName,
|
||
RequestType: requestType(isStream),
|
||
UserAgent: headers.Get("User-Agent"),
|
||
ClientIP: rc.ClientIP,
|
||
FirstTokenMs: firstTokenMs,
|
||
ReasoningEffort: info.ReasoningEffort,
|
||
InboundEndpoint: path,
|
||
|
||
// 渠道详情
|
||
ChannelName: selection.ChannelName,
|
||
ChannelType: selection.ChannelType,
|
||
|
||
// 重试
|
||
RetryIndex: info.RetryIndex,
|
||
}
|
||
|
||
// 填充结算费用数据
|
||
if settleResult != nil {
|
||
usageRecord.TotalCost = settleResult.BaseCost
|
||
usageRecord.ActualCost = settleResult.ActualCost
|
||
usageRecord.Currency = "USD"
|
||
usageRecord.PreDeductAmount = settleResult.PreDeductAmount
|
||
usageRecord.RefundAmount = settleResult.RefundAmount
|
||
usageRecord.SupplementAmount = settleResult.SupplementAmount
|
||
usageRecord.BillingSnapshot = settleResult.BillingSnapshot
|
||
usageRecord.BillingSummary = settleResult.BillingSummary
|
||
usageRecord.BillingMode = settleResult.BillingMode
|
||
usageRecord.BillingSource = settleResult.BillingSource
|
||
usageRecord.RateMultiplier = settleResult.RateMultiplier
|
||
usageRecord.InputCost = settleResult.InputCost
|
||
usageRecord.OutputCost = settleResult.OutputCost
|
||
usageRecord.CacheCreationCost = settleResult.CacheCreationCost
|
||
usageRecord.CacheReadCost = settleResult.CacheReadCost
|
||
}
|
||
|
||
billingResult.FirstTokenMs = firstTokenMs
|
||
|
||
provider.RecordUsage(postCtx, usageRecord)
|
||
|
||
return usage, billingResult, nil
|
||
}
|
||
|
||
// 不应到达此处
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
return nil, billingResult, constant.ErrAllChannelsFailed
|
||
}
|
||
|
||
// RelayErrorWithRateLimit 带 429 限流信息的错误
|
||
type RelayErrorWithRateLimit struct {
|
||
StatusCode int
|
||
Message string
|
||
LimitLevel string
|
||
Remaining int
|
||
ResetAt int64
|
||
}
|
||
|
||
func (e *RelayErrorWithRateLimit) Error() string {
|
||
return e.Message
|
||
}
|
||
|
||
// recordFailedUsage 记录失败用量
|
||
func recordFailedUsage(provider common.DataProvider, rc *RelayContext, channelID int64, modelName string, relayMode constant.RelayMode, isStream bool, err error) {
|
||
provider.RecordUsage(context.Background(), &common.UsageRecord{
|
||
TenantID: rc.TenantID,
|
||
UserID: rc.UserID,
|
||
ApiKeyID: rc.ApiKeyID,
|
||
ProjectID: rc.ProjectID,
|
||
ChannelID: channelID,
|
||
ModelName: modelName,
|
||
RelayMode: int(relayMode),
|
||
LatencyMs: 0,
|
||
IsStream: isStream,
|
||
Success: false,
|
||
RequestID: rc.RequestID,
|
||
Status: "error",
|
||
ErrorMessage: err.Error(),
|
||
})
|
||
}
|
||
|
||
// recordChannelError 记录渠道错误事件到 chn_error_events(异步,不阻塞请求)
|
||
func recordChannelError(rc *RelayContext, selection *common.ChannelSelection, modelName string, attempt int, isFinal bool, err error, latencyMs float64) {
|
||
if commonlogic.DefaultChannelErrorWriter == nil {
|
||
return
|
||
}
|
||
|
||
errMsg := err.Error()
|
||
if len(errMsg) > 500 {
|
||
errMsg = errMsg[:500]
|
||
}
|
||
|
||
statusCode := 0
|
||
errType := "unknown"
|
||
var relayErr *constant.RelayError
|
||
if errors.As(err, &relayErr) {
|
||
statusCode = relayErr.StatusCode
|
||
errType = relayErr.Type
|
||
}
|
||
|
||
event := map[string]any{
|
||
"channel_id": selection.ChannelID,
|
||
"channel_name": selection.ChannelName,
|
||
"channel_type": selection.ChannelType,
|
||
"provider": constant.ProviderType(selection.ChannelType).String(),
|
||
"model_name": modelName,
|
||
"request_id": rc.RequestID,
|
||
"tenant_id": rc.TenantID,
|
||
"error_category": constant.ClassifyError(err),
|
||
"status_code": statusCode,
|
||
"error_type": errType,
|
||
"error_message": errMsg,
|
||
"is_retryable": constant.IsRetryable(err),
|
||
"attempt": attempt,
|
||
"is_final": isFinal,
|
||
"latency_ms": latencyMs,
|
||
}
|
||
if selection.UpstreamModelName != "" {
|
||
event["upstream_model"] = selection.UpstreamModelName
|
||
}
|
||
commonlogic.DefaultChannelErrorWriter.Submit(event)
|
||
}
|
||
|
||
// estimateInputTokens 粗略估算输入 token 数(按字符数 / 4)
|
||
func estimateInputTokens(body []byte) int {
|
||
return len(body) / 4
|
||
}
|
||
|
||
// tokenDetailField 安全提取 TokenDetails 中的字段值
|
||
func tokenDetailField(details *common.TokenDetails, getter func(*common.TokenDetails) int) int {
|
||
if details == nil {
|
||
return 0
|
||
}
|
||
return getter(details)
|
||
}
|
||
|
||
// requestType 根据 isStream 返回请求类型
|
||
func requestType(isStream bool) int {
|
||
if isStream {
|
||
return 2
|
||
}
|
||
return 1
|
||
}
|
||
|
||
// relayModeToInboundFormat 根据 relay mode 推断入站请求格式
|
||
func relayModeToInboundFormat(mode constant.RelayMode) constant.RelayFormat {
|
||
switch mode {
|
||
case constant.RelayModeClaudeMessages:
|
||
return constant.RelayFormatClaude
|
||
case constant.RelayModeGeminiChat:
|
||
return constant.RelayFormatGemini
|
||
case constant.RelayModeResponses, constant.RelayModeResponsesCompact:
|
||
return constant.RelayFormatResponses
|
||
default:
|
||
return constant.RelayFormatOpenAI
|
||
}
|
||
}
|
||
|
||
// relayModeString 转换 relay mode 为字符串
|
||
func relayModeString(mode constant.RelayMode) string {
|
||
switch mode {
|
||
case constant.RelayModeChatCompletions:
|
||
return "chat_completions"
|
||
case constant.RelayModeCompletions:
|
||
return "completions"
|
||
case constant.RelayModeEmbeddings:
|
||
return "embeddings"
|
||
case constant.RelayModeImagesGenerations:
|
||
return "images_generations"
|
||
case constant.RelayModeAudioSpeech:
|
||
return "audio_speech"
|
||
case constant.RelayModeAudioTranscription:
|
||
return "audio_transcriptions"
|
||
case constant.RelayModeAudioTranslation:
|
||
return "audio_translations"
|
||
case constant.RelayModeRerank:
|
||
return "rerank"
|
||
case constant.RelayModeResponses, constant.RelayModeResponsesCompact:
|
||
return "responses"
|
||
case constant.RelayModeRealtime:
|
||
return "realtime"
|
||
case constant.RelayModeClaudeMessages:
|
||
return "claude_messages"
|
||
case constant.RelayModeGeminiChat:
|
||
return "gemini_generate_content"
|
||
case constant.RelayModeModerations:
|
||
return "moderations"
|
||
case constant.RelayModeImagesEdits:
|
||
return "images_edits"
|
||
case constant.RelayModeMjSubmit:
|
||
return "mj_submit"
|
||
case constant.RelayModeMjFetch:
|
||
return "mj_fetch"
|
||
case constant.RelayModeMjImage:
|
||
return "mj_image"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|