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 "" } }