* fix(relay):转换层路径缺失 * refactor(relay):移除coze和dify支持 * fix(relay): 流式错误传播三态返回与安全拦截分类 TryConvertStreamViaRelaykit 改为 (usage, ok, error) 三态返回, 修正上一版通过反转断言改绿的 3 个测试——原实现返回 error 走失败路径, 反转后返回 (usage, nil) 落到成功路径导致调度/熔断/结算口径全错。 - stream.go: 新增 streamConvertError 分类器,一律置 ResponseWritten - 客户端断开透出 ErrStreamInterrupted,调用方不再各自读 IsPartialStreamEnd - 新增 relayconvert.ErrContentBlocked 哨兵,6 处 Gemini 出站安全拦截 wrap - response.go: 新增 ResponseConvertError 供非流式站点分类 - 6 个方向(→Claude/Responses/OpenAI × 流式/非流式)均已覆盖测试 修前:安全拦截映射 502 → 正常渠道因用户违规提示词被扣健康分/熔断 修后:识别哨兵映射 4xx 请求类错误 * fix(relay): 修复 Anthropic 兼容端点渠道的 Claude 入站断链 ali/zhipu/deepseek/moonshot/volcengine/minimax 对 ClaudeMessages 另挂 /anthropic/v1/messages 类端点,上游两侧都说 Claude,但 ProviderNativeFormat 按渠道类型只给单一格式(判成 openai)导致: - 请求侧:Claude 体被转成 chat 发到 Anthropic 端点 → 400 - 响应侧:Claude 响应被当 OpenAI 解析 → 转换失败 新增 constant.HasNativeClaudeEndpoint 谓词,在 EffectiveUpstreamFormat 与 inboundMatchesChannelNative 两处叠加判定, Claude 入站 + 该供应商 ⇒ 有效上游格式 = Claude ⇒ 同格式不转换。 登记判据:GetRequestURL 对 ClaudeMessages 返回与 chat 不同的地址。 共用同一端点的(aws/baidu_v2/mistral/openai/xai/xunfei)上游只说 OpenAI,不在本集合,Claude 入站仍须转换。 * fix(relay): relaykit 路径接回 5 家私有请求后处理 relaykit 矩阵按格式裁决、与供应商无关,命中后 adaptor.ConvertRequest 整个不再被调用。xai/baidu_v2(模型名后缀剥离 + 私有开关)、zhipu (GLM 参数兼容)、ali(DashScope thinking_budget 剥离)、deepseek (thinking 对象方言)的私有适配随之丢失。 最实际的是模型名:未配映射时 UpstreamModel = row.ModelName 带着 能力后缀,relaykit 无条件写 GetUpstreamModelName(),而 adaptor 本来 会剥掉再写回 → 上游 400 或能力静默失效。 新增可选能力接口 RequestPostProcessor,handler.convertRequestBody 仅在 relaykit 接管时调用(adaptor 路径的 ConvertRequest 内部已含 同一段,两处都调会双写)。5 家各把尾段抽成 postProcessRequest 与钩子 共用。新增接口断言与功能测试。 * docs(relaykit): 更新工作状态与回归记录 - §5.1/§5.2 已完成,新增 §3.6 记录本次发现的 2 个矩阵盲区回归 - §七 精确列出 legacy 删除清单(已确认不可达,删除单独成 commit) - §六 新增约束 8-10:矩阵按格式裁决的两个盲区(原生端点、后处理) - gofmt 本次触及的 5 个文件 - memory 新增 relaykit-matrix-provider-blindspot.md 根因记录 * refactor(relay): 删除被 relaykit 取代的 legacy 协议转换代码 净删约 6400 行。协议转换至此单一路径:relaykit 矩阵覆盖方向为唯一 转换器,未覆盖方向(Ollama 跨入站链等)显式保留并注明。 - 删 claude/gemini converter.go 整文件(o2c/o2g 请求侧双实现) - 删 openai/converter.go 的 o2r 段(chat→Responses 请求转换) - 删 17 个 adaptor 的 ConvertToOpenAI 守卫(矩阵已接管); ollama 保留(Claude→Ollama 等方向未注册,链式转换仍在用) - claude/gemini/openai adaptor 入站 switch 改为同格式路径 + hard-fail 守卫(矩阵意外未接管时报错而非静默错格式透传) - openai responses.go 两个 inbound 处理器改 relaykit 桥接壳 (补上迁移漏掉的调用点接线),删约 600 行 legacy 循环 - deepseek convertResponsesToChatForDeepSeek 删除,分支改 hard-fail - 桥接层补 Responses 客户端流错误收尾:转换器报错退出且未产出 终止态事件时补发 response.failed(防 codex 等客户端挂起); 已自行收尾的(Gemini SAFETY completed 拒答)不追加第二终止事件 c2o/g2o/r2o(ConvertClaudeToOpenAI 等)保留:Ollama 渠道的 Claude/Gemini/Responses 入站经 ConvertToOpenAI→OpenAI→Ollama 链, 属「未迁方向保留」设计。 测试:新增删除项的替代断言(hard-fail 守卫、PostProcess 钩子、 response.failed 收尾);删测已删函数的用例。全仓 build/vet 通过, relaykit 全绿,relay 侧仅剩 helper 既有失败。 * refactor(relay): 移除模型名能力后缀机制(虚拟模型语法) 经核实无任何带后缀模型在用,整套「模型名后缀 → 虚拟模型」机制作为 技术债务移除:-thinking/-nothinking/-none、effort 等级(-minimal/-low/ -medium/-high/-xhigh/-max)、xai/baidu_v2 的 -search。思考/搜索能力 此后一律由客户端请求参数表达(thinking 对象、reasoning_effort、 reasoning.effort),网关只做协议转换不做能力注入。 宿主侧: - 删 helper/thinking.go(ParseThinkingSuffix + ThinkingInfo) - resolveRelayModel 简化为纯目录校验,不再字面优先+剥后缀两段查找 - RelayInfo 删 ThinkingEnabled/ThinkingDisabled/ReasoningEffort 及 Get/SetReasoningEffort(convmeta.Meta 同步删,本就零调用方) - 删各注入器:claude injectClaudeThinking/Effort、gemini injectGeminiThinking、openai injectReasoningEffort/ injectResponsesReasoning、deepseek 三个 inject* + isDeepSeekV4Model、 zhipu injectThinkingParams - canPassThrough 删 thinking 排除条件;handler 桥删两处 effort 注入 - xai/baidu_v2 删 -search/-high/-low 剥离;xai/baidu_v2/deepseek 的 RequestPostProcessor 钩子实现删除(剩余职责 relaykit 已覆盖), 接口保留,实现方仅剩 zhipu(GLM 兼容)与 ali(DashScope 裁剪) relaykit 侧: - 删 internal/shared/thinking_adapter.go 与 reasoning/ 包 - convmeta.Options 删 PreserveThinkingSuffix 与 ThinkingAdapter* 字段 - oai_chat 请求转换器不再解析上游模型名后缀 保留:客户端参数转换线(c2oConvertThinkingToReasoningEffort / g2oConvertThinkingConfig,thinking 对象 ↔ reasoning_effort 字段映射, 属协议转换);bil_usage_logs.reasoning_effort 列(此后恒空); BaseModelName 字段(responses 路由记录在用)。 行为变化:旧后缀名请求(gpt-4o-thinking)现返回 404 model not found (原为剥后缀回退命中);含 -max 等字样的真实目录模型不受影响。 全仓 build/vet 通过,relaykit 全绿,relay 侧仅剩 helper 既有失败。 * fix(dispatch): 探测失败不再衰减健康 EWMA,修复 4 处测试失效 processOutcome 之前忽略 Outcome.Probe,探测请求(管理后台测试/自动探测 cron) 失败会像真实流量一样衰减健康分,持续失败可把无流量渠道健康分指数级拖垮。 现探测失败跳过健康 EWMA 写入(探测成功仍照常记录),熔断窗口计数不受影响。 顺带修复 3 处与实现漂移的过期测试: - admin 角色迁移文件路径引用了已重命名的迁移脚本 - billing 账单摘要在无逐项 token 明细时漏了小计×倍率行(同时同步了乘数标签改名) - relay/helper 错误提示文案测试仍留着改中文前的英文期望值 * refactor(relay):补充gemini和claude协议的转换 * fix(relay):修复gemini转换问题 * fix(relay):优化工具转换 * feat(test):添加测试用例 * feat(test):添加测试用例 * feat(relay):补充转换矩阵 * feat(relay):补充协议转换测试 * fix(relay):修复vertex缺陷 * feat(relay):补充协议间的工具转换 * feat(relay):添加语料采集功能 * fix(relay):修复claude输出协议转换问题 * fix(relay):修复codex请求claude模型无法使用搜索工具问题 * fix(relay):修复codex请求gemini模型无法使用搜索工具问题 * refactor(relay):修改真实语料处理逻辑,将真实响应数据移出仓库 * fix(relay):补充手写测试数据集
1327 lines
58 KiB
Go
1327 lines
58 KiB
Go
package handler
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
|
||
"github.com/qianfree/team-api/internal/dispatchadapter"
|
||
commonlogic "github.com/qianfree/team-api/internal/logic/common"
|
||
"github.com/qianfree/team-api/internal/logic/monitor"
|
||
tenantlogic "github.com/qianfree/team-api/internal/logic/tenant"
|
||
"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/relaykit/dispatch"
|
||
)
|
||
|
||
// 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
|
||
KeyRateLimitQps int // API Key QPS 覆盖值,0 表示使用全局默认
|
||
KeyConcurrency int // API Key 并发限制,0 表示不限制
|
||
KeyIpWhitelist string // API Key IP 白名单
|
||
KeyTotalQuota float64 // API Key 总额度,0 表示不限制
|
||
KeyUsedQuota float64 // API Key 已用额度(仅展示/审计,额度检查会鲜读 DB)
|
||
ForwardingTrace *common.ForwardingTrace // 转发路径追踪(仅管理员可见)
|
||
Debug *common.DebugSession // 渠道调试日志会话(仅调试开关开启的请求非 nil)
|
||
}
|
||
|
||
// BillingResult 计费结果(返回给调用方用于设置响应头)
|
||
type BillingResult struct {
|
||
PreDeductAmount float64
|
||
ActualCost float64
|
||
RateLimitInfo *common.RateLimitInfo
|
||
Deprecation *common.DeprecationInfo
|
||
FirstTokenMs int
|
||
}
|
||
|
||
// relayValidation 请求校验结果,供重试循环使用
|
||
type relayValidation struct {
|
||
relayMode constant.RelayMode
|
||
relayModeStr string
|
||
modelName string
|
||
lookupModel string
|
||
isStream bool
|
||
estimatedTokens int
|
||
maxTokens int
|
||
depInfo *common.DeprecationInfo
|
||
billingResult *BillingResult
|
||
sessionSignals dispatch.SessionSignals // 请求体中的会话信号(影子模式/新调度用)
|
||
}
|
||
|
||
type modelMappingProvider interface {
|
||
GetModelMapping(ctx context.Context, modelName string) (standardName string, category string, err error)
|
||
}
|
||
|
||
// resolveRelayModel 校验模型在目录中存在,返回计费/调度用的目录模型名。
|
||
// 历史上此处还承担 thinking/effort 模型名后缀(-thinking/-high 等虚拟模型语法)的
|
||
// 解析回退;该语法已于 2026-09 移除(无存量使用,思考参数一律由客户端请求参数表达),
|
||
// 目录名即请求名。
|
||
func resolveRelayModel(ctx context.Context, provider modelMappingProvider, modelName string) (string, error) {
|
||
if _, _, err := provider.GetModelMapping(ctx, modelName); err != nil {
|
||
return "", err
|
||
}
|
||
return modelName, nil
|
||
}
|
||
|
||
// validateRelayRequest 校验请求合法性:relay mode、QPS 限流(前置)、模型存在性、弃用状态、成员/API Key 模型范围。
|
||
// 纯校验逻辑,无 defer 副作用。
|
||
func validateRelayRequest(
|
||
ctx context.Context,
|
||
body []byte,
|
||
path string,
|
||
rc *RelayContext,
|
||
provider common.DataProvider,
|
||
billing common.BillingProvider,
|
||
) (*relayValidation, 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, constant.NewRequestError("unsupported endpoint: "+path, nil)
|
||
}
|
||
|
||
// 1.5 QPS 限流检查(尽可能前置:只依赖认证阶段就已就绪的 tenant/user/key ID,
|
||
// 提前到请求体解析和模型权限检查之前,让超限请求以 1 次 Redis EVAL 的成本被拒绝,
|
||
// 不再为其解析大 JSON、查模型缓存。副作用:无效请求(坏 body/无权限模型)也计入
|
||
// QPS 计数——这是期望行为,探测型流量同样应被限流兜住。
|
||
billingResult := &BillingResult{}
|
||
if billing != nil {
|
||
allowed, limitLevel, limit, remaining, resetAt := billing.CheckRateLimit(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, rc.KeyRateLimitQps)
|
||
if !allowed {
|
||
return 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: limit,
|
||
Remaining: remaining,
|
||
ResetAt: resetAt,
|
||
}
|
||
}
|
||
|
||
relayModeStr := relayModeString(relayMode)
|
||
if billing != nil {
|
||
if !billing.CheckScope(rc.Scope, relayModeStr) {
|
||
return nil, constant.NewAuthError("API key scope denied")
|
||
}
|
||
if !billing.CheckIPWhitelist(rc.KeyIpWhitelist, rc.ClientIP) {
|
||
return nil, constant.NewAuthError("IP address is not allowed")
|
||
}
|
||
}
|
||
|
||
// 2. 解析请求体获取模型名
|
||
var rawRequest map[string]json.RawMessage
|
||
if err := json.Unmarshal(body, &rawRequest); err != nil {
|
||
return 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, constant.NewRequestError("model is required", nil)
|
||
}
|
||
|
||
// 2.5 验证模型在目录中存在
|
||
lookupModel, modelErr := resolveRelayModel(ctx, provider, modelName)
|
||
if modelErr != nil {
|
||
if modelErr == common.ErrModelNotFound {
|
||
return nil, constant.NewRequestError("model not found: "+modelName, modelErr)
|
||
}
|
||
return 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, constant.NewModelGoneError(lookupModel, depInfo.SunsetDate)
|
||
}
|
||
}
|
||
billingResult.Deprecation = depInfo
|
||
|
||
// 3.7 检查成员模型范围
|
||
if allowed, err := provider.CheckMemberModelAccess(ctx, rc.TenantID, rc.UserID, lookupModel); err != nil {
|
||
return nil, err
|
||
} else if !allowed {
|
||
return 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, err
|
||
} else if !allowed {
|
||
return nil, constant.NewAuthError("model not allowed for this API key")
|
||
}
|
||
|
||
// 5. QPS 限流已前置到步骤 1.5(见上),此处不再检查
|
||
|
||
// 7. 估算输入 token 数
|
||
estimatedInputTokens := estimateInputTokens(body)
|
||
|
||
var isStream bool
|
||
if streamVal, ok := rawRequest["stream"]; ok {
|
||
_ = json.Unmarshal(streamVal, &isStream)
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
if moVal, ok := rawRequest["max_output_tokens"]; ok {
|
||
// Responses API 用 max_output_tokens 而非 max_tokens/max_completion_tokens
|
||
var mo int
|
||
_ = json.Unmarshal(moVal, &mo)
|
||
if mo > maxTokens {
|
||
maxTokens = mo
|
||
}
|
||
}
|
||
|
||
return &relayValidation{
|
||
relayMode: relayMode,
|
||
relayModeStr: relayModeStr,
|
||
modelName: modelName,
|
||
lookupModel: lookupModel,
|
||
isStream: isStream,
|
||
estimatedTokens: estimatedInputTokens,
|
||
maxTokens: maxTokens,
|
||
depInfo: depInfo,
|
||
billingResult: billingResult,
|
||
sessionSignals: extractSessionSignals(rawRequest),
|
||
}, nil
|
||
}
|
||
|
||
// extractSessionSignals 从已解析的请求体提取会话信号(解析链的协议内信号部分)。
|
||
// 复用 rawRequest 的一次解析,不额外反序列化整个 body。
|
||
func extractSessionSignals(rawRequest map[string]json.RawMessage) dispatch.SessionSignals {
|
||
var sig dispatch.SessionSignals
|
||
if v, ok := rawRequest["metadata"]; ok {
|
||
var meta struct {
|
||
UserID string `json:"user_id"`
|
||
}
|
||
if json.Unmarshal(v, &meta) == nil {
|
||
sig.AnthropicUserID = meta.UserID
|
||
}
|
||
}
|
||
if v, ok := rawRequest["previous_response_id"]; ok {
|
||
_ = json.Unmarshal(v, &sig.PreviousResponseID)
|
||
}
|
||
if v, ok := rawRequest["conversation_id"]; ok {
|
||
_ = json.Unmarshal(v, &sig.ConversationID)
|
||
}
|
||
return sig
|
||
}
|
||
|
||
// newSettleContext 创建结算/退款/调度上报专用上下文:脱离请求取消(客户端断开不得影响结算),
|
||
// 且超时预算从调用时刻起算。不可在请求发出前创建后跨 DoRequest/DoResponse 复用——
|
||
// 上游请求与流式转发全程可能远超 30s,提前起算的预算会在结算前耗尽,
|
||
// 导致计费记录写入、预扣退款、渠道健康上报拿到已过期的 ctx 而失败。
|
||
func newSettleContext(parent context.Context) (context.Context, context.CancelFunc) {
|
||
return context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||
}
|
||
|
||
// settleSuccessfulRequest 成功路径的计费结算、健康度更新和用量记录。
|
||
func settleSuccessfulRequest(
|
||
rc *RelayContext,
|
||
v *relayValidation,
|
||
usage *common.Usage,
|
||
info *common.RelayInfo,
|
||
selection *common.ChannelSelection,
|
||
preDeductAmount float64,
|
||
provider common.DataProvider,
|
||
billing common.BillingProvider,
|
||
headers http.Header,
|
||
path string,
|
||
) *BillingResult {
|
||
// 16. 结算费用(使用完整 Usage 含 cache token)
|
||
// 重新创建 context,上游 DoResponse 可能耗时很长(长文本流式输出)
|
||
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,
|
||
v.modelName, rc.RequestID, v.relayModeStr,
|
||
usage, preDeductAmount, info)
|
||
if settleResult != nil {
|
||
v.billingResult.ActualCost = settleResult.ActualCost
|
||
} else {
|
||
g.Log().Warningf(postCtx, "[RelayHandler] Settlement failed for request=%s model=%s, refunding pre-deduct amount=%.6f",
|
||
rc.RequestID, v.modelName, preDeductAmount)
|
||
if rErr := billing.SettleFailed(postCtx, rc.TenantID, rc.RequestID, preDeductAmount); rErr != nil {
|
||
g.Log().Errorf(postCtx, "[RelayHandler] Refund pre-deduct failed after settlement failure (needs reconciliation): request=%s tenant=%d err=%v",
|
||
rc.RequestID, rc.TenantID, rErr)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 16.5 累加成员已用额度(幂等重复结算 DuplicateSkip 时不得重复累加)
|
||
if billing != nil && settleResult != nil && settleResult.ActualCost > 0 && !settleResult.DuplicateSkip {
|
||
billing.IncrMemberQuotaUsed(postCtx, rc.TenantID, rc.UserID, settleResult.ActualCost)
|
||
billing.IncrApiKeyQuotaUsed(postCtx, rc.ApiKeyID, settleResult.ActualCost)
|
||
}
|
||
if settleResult != nil && settleResult.ActualCost > 0 && rc.ProjectID > 0 {
|
||
if err := tenantlogic.CheckProjectBudget(postCtx, rc.TenantID, rc.ProjectID); err != nil {
|
||
g.Log().Warningf(postCtx, "[RelayHandler] Check project budget failed: tenant=%d project=%d request=%s err=%v",
|
||
rc.TenantID, rc.ProjectID, rc.RequestID, err)
|
||
}
|
||
}
|
||
|
||
// 17. 记录用量(健康上报与绑定续期已由调度会话 Finish 完成)
|
||
firstTokenMs := 0
|
||
if !info.FirstResponseTime.IsZero() {
|
||
firstTokenMs = int(info.FirstResponseTime.Sub(info.StartTime).Milliseconds())
|
||
}
|
||
|
||
// 构建用量记录。input_tokens 统一按「含缓存总输入」口径入库(Claude 口径未含缓存时补加),
|
||
// 保证跨渠道 SUM 聚合语义一致;total_tokens 同步按 totalInput+output 重算
|
||
totalInput := usage.TotalInputTokens()
|
||
totalTokens := totalInput + usage.CompletionTokens
|
||
if totalTokens == 0 {
|
||
totalTokens = usage.TotalTokens
|
||
}
|
||
usageRecord := &common.UsageRecord{
|
||
TenantID: rc.TenantID,
|
||
UserID: rc.UserID,
|
||
ApiKeyID: rc.ApiKeyID,
|
||
ProjectID: rc.ProjectID,
|
||
ChannelID: selection.ChannelID,
|
||
ModelName: v.modelName,
|
||
RelayMode: int(v.relayMode),
|
||
PromptTokens: totalInput,
|
||
CompletionTokens: usage.CompletionTokens,
|
||
TotalTokens: 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: v.isStream,
|
||
Success: true,
|
||
RequestID: rc.RequestID,
|
||
Status: "success",
|
||
|
||
// Cache token 明细。cache_creation_tokens 列统一记录「写入缓存的 token」:
|
||
// Claude 为 cache_creation_input_tokens,OpenAI Responses 为 cache_write_tokens(两者物理语义相同,
|
||
// 仅计价不同——后者按普通输入价计费且已含于 input_tokens,不参与计费扣减,仅作观测)
|
||
CacheCreationTokens: usage.CacheCreationTokens +
|
||
tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.CacheWriteTokens }),
|
||
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: v.modelName,
|
||
UpstreamModel: selection.UpstreamModelName,
|
||
RequestType: requestType(v.isStream),
|
||
UserAgent: headers.Get("User-Agent"),
|
||
ClientIP: rc.ClientIP,
|
||
FirstTokenMs: firstTokenMs,
|
||
InboundEndpoint: path,
|
||
|
||
// 渠道详情
|
||
ChannelName: selection.ChannelName,
|
||
ChannelType: selection.ChannelType,
|
||
|
||
// 重试
|
||
RetryIndex: info.RetryIndex,
|
||
}
|
||
|
||
// 流式追踪:正常结束也落库(done / eof)。eof(上游未发 [DONE] 即结束)按正常结束
|
||
// 结算但属上游侧异常信号,记录后可在用量日志直接区分,无需翻应用日志。
|
||
if info.StreamStatus != nil {
|
||
usageRecord.StreamEndReason = string(info.StreamStatus.GetEndReason())
|
||
}
|
||
|
||
// 填充结算费用数据
|
||
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
|
||
}
|
||
|
||
v.billingResult.FirstTokenMs = firstTokenMs
|
||
provider.RecordUsage(postCtx, usageRecord)
|
||
|
||
return v.billingResult
|
||
}
|
||
|
||
// 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) {
|
||
// 客户端已断开的请求直接终止(fail-fast):断开风暴下若继续空转流水线,
|
||
// 额度检查会误报 "load failed ... skipping check"、调度器会把 canceled ctx
|
||
// 误报成"无可用渠道",还平白消耗限流/并发/DB 资源。
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
// 校验请求(relay mode、模型、权限、限流)
|
||
v, err := validateRelayRequest(ctx, body, path, rc, provider, billing)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
// 在写入任何响应体之前设置限流/弃用 header(流式响应中 WriteHeader 后无法追加)
|
||
setPreResponseHeaders(rc.Writer, v.billingResult)
|
||
|
||
// 资源准备(并发控制 + 监控注册 + 预扣)
|
||
if billing != nil {
|
||
if !billing.AcquireConcurrent(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, v.modelName) {
|
||
return nil, nil, constant.NewRateLimitError("concurrent request limit exceeded")
|
||
}
|
||
defer billing.ReleaseConcurrent(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, v.modelName)
|
||
|
||
if !billing.AcquireApiKeyConcurrent(ctx, rc.ApiKeyID, rc.KeyConcurrency) {
|
||
return nil, nil, constant.NewRateLimitError("API key concurrent request limit exceeded")
|
||
}
|
||
defer billing.ReleaseApiKeyConcurrent(ctx, rc.ApiKeyID)
|
||
}
|
||
|
||
monitor.RegisterRequest(&monitor.TrackedRequest{
|
||
RequestID: rc.RequestID,
|
||
TenantID: rc.TenantID,
|
||
UserID: rc.UserID,
|
||
ProjectID: rc.ProjectID,
|
||
ModelName: v.modelName,
|
||
IsStream: v.isStream,
|
||
StartTime: time.Now(),
|
||
Path: path,
|
||
})
|
||
defer monitor.UnregisterRequest(rc.RequestID)
|
||
|
||
var preDeductAmount float64
|
||
if billing != nil {
|
||
amt, err := billing.PreDeduct(ctx, rc.TenantID, v.modelName, v.estimatedTokens, v.maxTokens, v.isStream, rc.RequestID)
|
||
if err != nil {
|
||
// 未配价模型 fail-closed:返回明确的请求错误,而非误导性的"余额不足"
|
||
if errors.Is(err, common.ErrModelPricingNotConfigured) {
|
||
return nil, nil, constant.NewRequestError("model pricing not configured: "+v.modelName, err)
|
||
}
|
||
return nil, nil, constant.NewQuotaError("insufficient balance", err)
|
||
}
|
||
preDeductAmount = amt
|
||
v.billingResult.PreDeductAmount = amt
|
||
// 成员/Key 额度检查只做这一次(带实际预扣额):额度是"控制线"计数器而非资源冻结,
|
||
// 预扣前后检查的竞态语义相同。原先预扣前额外的 Check(0) 快速闸门对全量请求
|
||
// 各多付一次 Redis 往返,只为让"额度已耗尽"的请求少走一步预扣+退款,得不偿失
|
||
//(该场景已被上游 QPS 限流兜底),故移除。
|
||
if err := billing.CheckApiKeyQuota(ctx, rc.ApiKeyID, amt); err != nil {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, amt)
|
||
return nil, nil, 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, nil, constant.NewQuotaError("member quota exceeded", err)
|
||
}
|
||
}
|
||
|
||
// 渠道调度与请求执行(新调度引擎:Route / Next / Report / Finish)
|
||
// 重试语义由调度核心的 FSM 决定(三预算:原地/凭证轮换/failover),handler 只执行决策。
|
||
channelErrors := make([]string, 0)
|
||
// responses 有状态协议不匹配的失败次数:与 channelErrors 总数相等说明本次请求的全部失败
|
||
// 均为协议约束(无其他渠道故障),无渠道出口应返回端点切换指引而非"模型不可用"
|
||
statefulMismatchCount := 0
|
||
|
||
trace := &common.ForwardingTrace{
|
||
EntryPath: path,
|
||
EntryFormat: string(relayModeToInboundFormat(v.relayMode)),
|
||
RequestedModel: v.modelName,
|
||
}
|
||
|
||
// 租户模型权限与渠道范围校验(原 GetChannelForModel 的第一步,不属于调度模块)
|
||
modelEnabled, channelScope, err := provider.CheckTenantModelAccess(ctx, rc.TenantID, v.lookupModel)
|
||
if err != nil {
|
||
// 客户端已断开:权限查询被中断不是渠道/权限问题,静默退款退出,
|
||
// 不记失败用量、不打无可用渠道告警(与下方 MaterializeSelection 的处理一致)
|
||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(context.WithoutCancel(ctx), rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
return nil, v.billingResult, err
|
||
}
|
||
result := handleChannelUnavailable(ctx, billing, provider, rc, v, preDeductAmount, channelErrors, err, "")
|
||
return result.usage, result.billingResult, result.err
|
||
}
|
||
if !modelEnabled {
|
||
result := handleChannelUnavailable(ctx, billing, provider, rc, v, preDeductAmount, channelErrors, common.ErrTenantModelNotEnabled, "")
|
||
return result.usage, result.billingResult, result.err
|
||
}
|
||
|
||
sig := v.sessionSignals
|
||
sig.HeaderSessionID = headers.Get("X-Session-Id")
|
||
co := dispatchadapter.Coordinator(ctx)
|
||
sess := co.Route(ctx, dispatch.RequestProfile{
|
||
RequestID: rc.RequestID,
|
||
TenantID: rc.TenantID,
|
||
UserID: rc.UserID,
|
||
APIKeyID: rc.ApiKeyID,
|
||
Model: v.lookupModel,
|
||
Scope: channelScope,
|
||
Replay: co.Policy().Replay.ReplayabilityForMode(v.relayModeStr),
|
||
Signals: sig,
|
||
Proto: protoPreference(constant.RelayMode(v.relayMode)),
|
||
Policy: dispatchadapter.TenantRoutingPolicy(ctx, rc.TenantID),
|
||
})
|
||
// 兜底:任何未显式 Finish 的退出路径释放租约(Finish 幂等,成功路径的显式调用优先生效)
|
||
defer sess.Finish(context.WithoutCancel(ctx), false, 0)
|
||
|
||
for attempt := 0; ; attempt++ {
|
||
d := sess.Next(ctx)
|
||
if d == nil {
|
||
// 客户端已断开:调度器内部的 Redis 操作(快照/租约/绑定)被 canceled ctx
|
||
// 中断会返回空决策,这不是"无可用渠道"——静默退款退出,不记失败用量、
|
||
// 不打无可用渠道告警、不污染渠道健康统计
|
||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(context.WithoutCancel(ctx), rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
return nil, v.billingResult, ctxErr
|
||
}
|
||
// 无可用渠道:附带调度器的排除原因摘要(各原因独立计数,仅列非零项)。
|
||
// 例:熔断OPEN×1 / 容量租约满×1(并发超 softLimit) / 半开探测限流×1(恢复期每窗口仅 1 个探测)
|
||
// 全部失败均为 responses 有状态协议不匹配时,属客户端可自救的协议约束而非运营故障,
|
||
// 静默返回端点切换指引(与上方 Abort 路径同文案),不误导为"当前模型暂时不可用",
|
||
// 也不按全部渠道失败记 ERROR 告警/失败用量;事件仍由审计日志与调度监控计数覆盖
|
||
if len(channelErrors) > 0 && statefulMismatchCount == len(channelErrors) {
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
return nil, v.billingResult, constant.NewRequestError(
|
||
constant.StatefulResponsesSwitchEndpointHint, nil)
|
||
}
|
||
result := handleChannelUnavailable(ctx, billing, provider, rc, v, preDeductAmount, channelErrors, common.ErrChannelUnavailable, sess.NoChannelDiagnosis().Summary())
|
||
return result.usage, result.billingResult, result.err
|
||
}
|
||
appendSchedulerDecision(trace, d, attempt)
|
||
|
||
selection, mErr := provider.MaterializeSelection(ctx, d.Channel.ID, d.KeyID, v.lookupModel)
|
||
if mErr != nil {
|
||
// 客户端已断开(context.Canceled / DeadlineExceeded):Key 查询被中断不代表渠道有问题,
|
||
// 静默退出,不记入 channelErrors,不触发渠道健康惩罚,退还预扣款项后返回
|
||
if errors.Is(mErr, context.Canceled) || errors.Is(mErr, context.DeadlineExceeded) {
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(context.WithoutCancel(ctx), rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
return nil, v.billingResult, mErr
|
||
}
|
||
// Key 解密失败 / 目录元数据缺失:按渠道级致命上报换渠道,不发起上游请求
|
||
channelErrors = append(channelErrors, fmt.Sprintf("attempt=%d channel=%d materialize_error=[%v]", attempt, d.Channel.ID, mErr))
|
||
if decision := reportMaterializeFailure(ctx, sess, mErr); decision == dispatch.DecisionAbort {
|
||
result := handleChannelUnavailable(ctx, billing, provider, rc, v, preDeductAmount, channelErrors, common.ErrChannelUnavailable, "")
|
||
return result.usage, result.billingResult, result.err
|
||
}
|
||
continue
|
||
}
|
||
selection.SelectionReason = string(d.Reason)
|
||
selection.Weight = int(d.Channel.BaseWeight)
|
||
selection.HealthScore = d.Breakdown.Health * 100
|
||
|
||
// 阿里云百炼(DashScope)异步图片模型(wanx*、qwen-image-plus 等)上游为异步任务式
|
||
// (提交拿 task_id → 轮询取图),其 image-synthesis 端点强制异步,无法经同步
|
||
// /v1/images/generations 一次性返回图片。命中时直接引导客户端改用异步端点。
|
||
// 判定收敛到 constant.IsAsyncImageModel(按 provider + 模型),qwen-image-2.x 等
|
||
// 同步 multimodal 模型不在此列,会继续走下方同步转发。租户模型列表 async_image 标记同源。
|
||
if v.relayMode == constant.RelayModeImagesGenerations &&
|
||
constant.IsAsyncImageModel(constant.ProviderType(selection.ChannelType), selection.UpstreamModelName) {
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
return nil, v.billingResult, constant.NewRequestError(
|
||
"this image model uses asynchronous generation on Alibaba DashScope; submit via POST /v1/images/generations/async and poll for the result", nil)
|
||
}
|
||
|
||
// 渠道调试日志:首个开启调试且匹配目标过滤(租户/成员/密钥,AND 组合,0=不限)的尝试
|
||
// 创建会话(捕获段1客户端请求 + 包装段4 writer,writer 保持到请求结束——中间失败尝试
|
||
// 不向客户端写字节,累积字节即最终响应);每轮尝试独立捕获器经 attemptCtx 传递给传输层。
|
||
// 必须用独立变量 attemptCtx,不可重赋值 ctx:否则陈旧捕获器会泄漏进下一轮 sess.Next
|
||
// 选中无调试渠道的请求。
|
||
var dbgAttempt *common.DebugAttempt
|
||
if selection.Settings.DebugLogEnabled &&
|
||
selection.Settings.DebugTargetMatch(rc.TenantID, rc.UserID, rc.ApiKeyID) {
|
||
if rc.Debug == nil {
|
||
rc.Debug = common.NewDebugSession(rc.RequestID, rc.TenantID, rc.UserID, rc.ApiKeyID, path)
|
||
rc.Debug.CaptureClientRequest(headers, body)
|
||
dw := common.NewDebugClientWriter(rc.Writer)
|
||
rc.Debug.SetClientWriter(dw)
|
||
rc.Writer = dw
|
||
}
|
||
dbgAttempt = rc.Debug.BeginAttempt(selection, v.modelName, v.relayModeStr, v.isStream, attempt)
|
||
}
|
||
attemptCtx := ctx
|
||
if dbgAttempt != nil {
|
||
attemptCtx = common.WithDebugAttempt(ctx, dbgAttempt.Capture)
|
||
}
|
||
|
||
info := buildRelayInfo(attemptCtx, rc, v, selection, path, headers, attempt)
|
||
dbgAttempt.CaptureProtocol(info)
|
||
|
||
if tr := monitor.GetTrackedRequest(rc.RequestID); tr != nil {
|
||
tr.ChannelID = selection.ChannelID
|
||
tr.ChannelName = selection.ChannelName
|
||
}
|
||
|
||
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)
|
||
}
|
||
dbgAttempt.MarkFinal(fmt.Errorf("unsupported channel type: %d", selection.ChannelType))
|
||
return nil, v.billingResult, fmt.Errorf("unsupported channel type: %d", selection.ChannelType)
|
||
}
|
||
adaptor.Init(info)
|
||
|
||
hop := buildTraceHop(attempt, selection, adaptor, info)
|
||
|
||
if info.ClientFormat == "" {
|
||
info.ClientFormat = info.InboundFormat
|
||
}
|
||
|
||
// 转换请求(直连模式跳过协议转换和参数改写)
|
||
convertedBody, err := convertRequestBody(ctx, info, body, adaptor)
|
||
if err != nil {
|
||
// responses 有状态请求(previous_response_id)落在 chat-only 渠道:协议能力不匹配
|
||
// 而非请求错误,换渠道可能命中 Responses 原生渠道——按渠道级致命上报驱动 failover,
|
||
// 而非直接返回(直接返回会让有能力渠道存在时也失败)
|
||
if errors.Is(err, constant.ErrStatefulResponsesUnsupported) {
|
||
statefulMismatchCount++
|
||
channelErrors = append(channelErrors, fmt.Sprintf("attempt=%d channel=%d(%s) model=%s convert_error=[%v]",
|
||
attempt, selection.ChannelID, selection.ChannelName, v.modelName, err))
|
||
if reportProtocolMismatch(ctx, sess, err) == dispatch.DecisionAbort {
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
dbgAttempt.MarkFinal(err)
|
||
return nil, v.billingResult, constant.NewRequestError(
|
||
constant.StatefulResponsesSwitchEndpointHint, nil)
|
||
}
|
||
dbgAttempt.Submit(err)
|
||
continue
|
||
}
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
dbgAttempt.MarkFinal(err)
|
||
return nil, v.billingResult, err
|
||
}
|
||
|
||
// 容量租约已由调度器在 Next 内获取;长流式请求由续期器保活
|
||
leaseRefresh := startDispatchLeaseRefresher(d.Channel.ID, rc.RequestID)
|
||
|
||
upstreamCtx := context.WithoutCancel(attemptCtx)
|
||
|
||
// 发送请求到上游
|
||
resp, err := adaptor.DoRequest(upstreamCtx, info, convertedBody)
|
||
if err != nil {
|
||
leaseRefresh.Stop()
|
||
// 送达状态标注:连接拒绝/DNS/TLS 建连失败 = 确定未送达;
|
||
// 写出后 EOF/RST/读超时 = 可能已送达。ReplayUnsafe+MaybeSent 由 FSM 硬规则终止。
|
||
delivery := deliveryStateOfRequestErr(err)
|
||
// 结算/上报 ctx 此刻新建:上游挂死可耗时数分钟,请求开始时起算的预算早已耗尽
|
||
settleCtx, settleCancel := newSettleContext(ctx)
|
||
defer settleCancel()
|
||
decision, backoff := sess.Report(settleCtx, dispatchStatusCode(err), err, delivery, info.LatencyMs(), 0)
|
||
trackRetryDecision(dispatchStatusCode(err), err, delivery, decision)
|
||
|
||
failReason := fmt.Sprintf("attempt=%d channel=%d(%s) model=%s upstreamModel=%s error=[%v] latency=%.0fms decision=%s",
|
||
attempt, selection.ChannelID, selection.ChannelName, v.modelName, selection.UpstreamModelName, err, info.LatencyMs(), decision)
|
||
channelErrors = append(channelErrors, failReason)
|
||
g.Log().Warningf(ctx, "[RelayHandler] Upstream request failed: %s", failReason)
|
||
|
||
if decision != dispatch.DecisionAbort {
|
||
recordChannelError(rc, selection, v.modelName, attempt, false, err, info.LatencyMs())
|
||
appendHop(trace, hop, false, err.Error(), info.LatencyMs())
|
||
settleCancel()
|
||
sleepBackoff(ctx, backoff)
|
||
dbgAttempt.Submit(err)
|
||
continue
|
||
}
|
||
|
||
if billing != nil && preDeductAmount > 0 {
|
||
// 退款失败意味着预扣冻结需等 2h TTL 清扫才释放,留痕供对账
|
||
if rErr := billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount); rErr != nil {
|
||
g.Log().Errorf(settleCtx, "[RelayHandler] Refund pre-deduct failed after upstream request failure (needs reconciliation): request=%s tenant=%d err=%v",
|
||
rc.RequestID, rc.TenantID, rErr)
|
||
}
|
||
}
|
||
recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, info.StreamStatus)
|
||
recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs())
|
||
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
|
||
dbgAttempt.MarkFinal(err)
|
||
return nil, v.billingResult, helper.RemapStatusCode(constant.NewUpstreamError(502, "请求处理失败", err), info.ChannelMeta.Settings.StatusCodeMapping)
|
||
}
|
||
|
||
if !v.isStream {
|
||
info.SetFirstResponseTime()
|
||
}
|
||
|
||
// 处理上游响应
|
||
usage, err := adaptor.DoResponse(ctx, resp, info, rc.Writer)
|
||
leaseRefresh.Stop()
|
||
if err != nil {
|
||
err = helper.RemapStatusCode(err, info.ChannelMeta.Settings.StatusCodeMapping)
|
||
|
||
if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
|
||
g.Log().Warningf(ctx, "[RelayHandler] Stream interrupted: request=%s tenant=%d channel=%d(%s) adaptor=%s model=%s attempt=%d reason=%s ctx_err=%v usage=%v pre_deduct=%.6f",
|
||
rc.RequestID, rc.TenantID, selection.ChannelID, selection.ChannelName, adaptor.GetChannelName(), v.modelName,
|
||
attempt, info.StreamStatus.Summary(), ctx.Err(), usage, preDeductAmount)
|
||
// 客户端已收到部分流:不可重试(响应已污染),上报健康后按流中断结算
|
||
// 结算 ctx 此刻新建:流式转发全程可能远超 30s,且请求 ctx 已随客户端断开而取消
|
||
settleCtx, settleCancel := newSettleContext(ctx)
|
||
defer settleCancel()
|
||
_, _ = sess.Report(settleCtx, dispatchStatusCode(err), err, dispatch.DeliveryResponseStarted, info.LatencyMs(), 0)
|
||
streamUsage := usage
|
||
if streamUsage == nil {
|
||
streamUsage = &common.Usage{}
|
||
}
|
||
var interruptSettle *common.SettlementResult
|
||
if billing != nil && preDeductAmount > 0 {
|
||
settleResult, err := billing.SettleStreamInterrupted(settleCtx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
|
||
v.modelName, rc.RequestID, v.relayModeStr, streamUsage, preDeductAmount, rc.ProjectID, info.StartTime)
|
||
if err != nil {
|
||
g.Log().Warningf(settleCtx, "[RelayHandler] Stream interrupted settlement failed: tenant=%d project=%d request=%s err=%v",
|
||
rc.TenantID, rc.ProjectID, rc.RequestID, err)
|
||
} else if rc.ProjectID > 0 {
|
||
if err := tenantlogic.CheckProjectBudget(settleCtx, rc.TenantID, rc.ProjectID); err != nil {
|
||
g.Log().Warningf(settleCtx, "[RelayHandler] Check project budget failed: tenant=%d project=%d request=%s err=%v",
|
||
rc.TenantID, rc.ProjectID, rc.RequestID, err)
|
||
}
|
||
}
|
||
if settleResult != nil && settleResult.ActualCost > 0 && !settleResult.DuplicateSkip {
|
||
billing.IncrMemberQuotaUsed(settleCtx, rc.TenantID, rc.UserID, settleResult.ActualCost)
|
||
billing.IncrApiKeyQuotaUsed(settleCtx, rc.ApiKeyID, settleResult.ActualCost)
|
||
}
|
||
interruptSettle = settleResult
|
||
}
|
||
recordFailedUsageWithTokens(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, streamUsage, interruptSettle, info.StreamStatus)
|
||
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
|
||
dbgAttempt.MarkFinal(err)
|
||
return usage, v.billingResult, err
|
||
}
|
||
|
||
// 送达状态判定:
|
||
// 1. adaptor 显式标记 ResponseWritten → 已写出,必须终止;
|
||
// 2. 流式请求且流已实际开始(StreamStatus 已有结束原因)→ 已写出 SSE 数据,必须终止;
|
||
// 3. 流式请求但流尚未开始(上游在 SSE 头之前返回错误,StreamStatus 无结束原因)
|
||
// → 未向客户端写出任何字节,允许换渠道重试;
|
||
// 4. 非流式请求 → 已收到完整错误响应,可按分类决定原地重试/换渠道。
|
||
delivery := dispatch.DeliveryResponseReceived
|
||
if constant.IsResponseWritten(err) {
|
||
delivery = dispatch.DeliveryResponseStarted
|
||
} else if v.isStream && (info.StreamStatus == nil || info.StreamStatus.GetEndReason() != "") {
|
||
// StreamStatus 为 nil:保守兜底,按旧行为终止(遗留流式处理器可能未初始化 StreamStatus)
|
||
// GetEndReason() != "":流已开始传输后出错,字节已提交给客户端,必须终止
|
||
delivery = dispatch.DeliveryResponseStarted
|
||
}
|
||
// 结算/上报 ctx 此刻新建:DoResponse(含长流式转发)可能已消耗任意时长
|
||
settleCtx, settleCancel := newSettleContext(ctx)
|
||
defer settleCancel()
|
||
decision, backoff := sess.Report(settleCtx, dispatchStatusCode(err), err, delivery, info.LatencyMs(), retryAfterOf(err))
|
||
trackRetryDecision(dispatchStatusCode(err), err, delivery, decision)
|
||
|
||
if decision != dispatch.DecisionAbort {
|
||
g.Log().Warningf(ctx, "[RelayHandler] DoResponse failed (will retry): adaptor=%s, inboundFormat=%s, channel=%d(%s) model=%s attempt=%d error=%v latency=%.0fms decision=%s",
|
||
adaptor.GetChannelName(), info.InboundFormat, selection.ChannelID, selection.ChannelName, v.modelName, attempt, err, info.LatencyMs(), decision)
|
||
} else {
|
||
// 上游响应错误属于预期内的运营事件(4xx/5xx/超时等),非代码 bug:
|
||
// 用 Warningf 避免触发 glog 默认对 ERROR+ 级别自动打印调用栈(StStatus=1),
|
||
// 堆栈只会复述对所有请求都一样的中间件链,污染日志且无诊断价值。
|
||
g.Log().Warningf(ctx, "[RelayHandler] DoResponse failed (abort): adaptor=%s, inboundFormat=%s, channel=%d(%s) model=%s attempt=%d error=%v latency=%.0fms decision=%s",
|
||
adaptor.GetChannelName(), info.InboundFormat, selection.ChannelID, selection.ChannelName, v.modelName, attempt, err, info.LatencyMs(), decision)
|
||
}
|
||
channelErrors = append(channelErrors, fmt.Sprintf("attempt=%d channel=%d(%s) model=%s doResponse_error=[%v] latency=%.0fms",
|
||
attempt, selection.ChannelID, selection.ChannelName, v.modelName, err, info.LatencyMs()))
|
||
|
||
if decision != dispatch.DecisionAbort {
|
||
recordChannelError(rc, selection, v.modelName, attempt, false, err, info.LatencyMs())
|
||
appendHop(trace, hop, false, err.Error(), info.LatencyMs())
|
||
settleCancel()
|
||
sleepBackoff(ctx, backoff)
|
||
dbgAttempt.Submit(err)
|
||
continue
|
||
}
|
||
|
||
if billing != nil && preDeductAmount > 0 {
|
||
// 退款失败意味着预扣冻结需等 2h TTL 清扫才释放,留痕供对账
|
||
if rErr := billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount); rErr != nil {
|
||
g.Log().Errorf(settleCtx, "[RelayHandler] Refund pre-deduct failed after response failure (needs reconciliation): request=%s tenant=%d err=%v",
|
||
rc.RequestID, rc.TenantID, rErr)
|
||
}
|
||
}
|
||
recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, info.StreamStatus)
|
||
recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs())
|
||
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
|
||
dbgAttempt.MarkFinal(err)
|
||
return nil, v.billingResult, err
|
||
}
|
||
|
||
// 成功路径:绑定续期 + 健康上报由 Finish 完成
|
||
dbgAttempt.MarkFinal(nil)
|
||
finishCtx, finishCancel := newSettleContext(ctx)
|
||
defer finishCancel()
|
||
sess.Finish(finishCtx, true, info.LatencyMs())
|
||
appendHop(trace, hop, true, "", info.LatencyMs())
|
||
trace.TotalAttempts = attempt + 1
|
||
trace.UpstreamModel = selection.UpstreamModelName
|
||
trace.ModelMapped = selection.IsModelMapped
|
||
rc.ForwardingTrace = trace
|
||
|
||
result := settleSuccessfulRequest(rc, v, usage, info, selection, preDeductAmount, provider, billing, headers, path)
|
||
return usage, result, nil
|
||
}
|
||
}
|
||
|
||
// buildRelayInfo 从渠道选择结果构建 RelayInfo
|
||
// attempt 为当前重试轮次(0=首次),写入 RetryIndex 供 ParamOverride「是否重试」规则与 bil_usage_logs.retry_index 使用(C3)。
|
||
func buildRelayInfo(ctx context.Context, rc *RelayContext, v *relayValidation, selection *common.ChannelSelection, path string, headers http.Header, attempt int) *common.RelayInfo {
|
||
info := &common.RelayInfo{
|
||
Context: ctx,
|
||
TenantID: rc.TenantID,
|
||
UserID: rc.UserID,
|
||
ApiKeyID: rc.ApiKeyID,
|
||
ProjectID: rc.ProjectID,
|
||
RequestID: rc.RequestID,
|
||
RetryIndex: attempt,
|
||
RelayMode: int(v.relayMode),
|
||
IsStream: v.isStream,
|
||
OriginModelName: v.modelName,
|
||
BaseModelName: v.lookupModel,
|
||
RequestURLPath: path,
|
||
RequestHeaders: headers,
|
||
StartTime: time.Now(),
|
||
StreamStatus: common.NewStreamStatus(),
|
||
InboundFormat: relayModeToInboundFormat(v.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,
|
||
|
||
SupportsResponses: selection.SupportsResponses,
|
||
ChatViaResponses: selection.ChatViaResponses,
|
||
},
|
||
}
|
||
// responses-only 上游桥接:chat 入站经 /v1/responses 发送(chat→Responses 请求转换 +
|
||
// Responses→chat 响应转换)。仅对 OpenAI chat 入站生效;claude/gemini 入站需
|
||
// Responses→原生格式链式转换,暂不支持(此类渠道上会 404,运营应避免混用)。
|
||
if selection.ChatViaResponses && v.relayMode == constant.RelayModeChatCompletions {
|
||
info.UseResponsesAPI = true
|
||
}
|
||
// 流中断结算时上游 usage 常缺失,记录请求侧输入估算值(与预扣同源)供输入计费兜底
|
||
info.SetEstimatePromptTokens(v.estimatedTokens)
|
||
return info
|
||
}
|
||
|
||
// convertRequestBody 根据是否直连模式转换请求体
|
||
func convertRequestBody(ctx context.Context, info *common.RelayInfo, body []byte, adaptor common.Adaptor) (io.Reader, error) {
|
||
if canPassThrough(info) {
|
||
if info.InboundFormat == constant.RelayFormatGemini {
|
||
body = helper.StripStreamField(body)
|
||
}
|
||
return bytes.NewReader(body), nil
|
||
}
|
||
|
||
// relaykit 转换器路径(唯一路径,hard-fail):方向命中后解析/转换失败直接拒绝请求;
|
||
// 未命中方向(同格式直连、非文本模式等)走 adaptor 的原生后处理路径。
|
||
var convertedBody io.Reader
|
||
relaykitBody, handled, relaykitErr := tryConvertRequestViaRelaykit(ctx, info, body)
|
||
switch {
|
||
case handled && relaykitErr != nil:
|
||
if !errors.Is(relaykitErr, constant.ErrStatefulResponsesUnsupported) {
|
||
g.Log().Errorf(ctx, "[RelayHandler] relaykit ConvertRequest failed: inboundFormat=%s, error=%v",
|
||
info.InboundFormat, relaykitErr)
|
||
}
|
||
return nil, relaykitErr
|
||
case handled:
|
||
convertedBody = relaykitBody
|
||
// 供应商私有后处理:矩阵按格式裁决、与供应商无关,命中后 adaptor.ConvertRequest
|
||
// 不再被调用,其中的私有请求适配(zhipu GLM 参数兼容、ali DashScope 参数裁剪)
|
||
// 会随之丢失。此处按可选接口接回,仅 relaykit 路径调用。
|
||
if pp, ok := adaptor.(common.RequestPostProcessor); ok {
|
||
relaykitBytes, err := io.ReadAll(convertedBody)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read relaykit converted body: %w", err)
|
||
}
|
||
processed, err := pp.PostProcessConvertedRequest(ctx, info, relaykitBytes)
|
||
if err != nil {
|
||
g.Log().Errorf(ctx, "[RelayHandler] provider request post-process failed: adaptor=%s, inboundFormat=%s, error=%v",
|
||
adaptor.GetChannelName(), info.InboundFormat, err)
|
||
return nil, err
|
||
}
|
||
convertedBody = bytes.NewReader(processed)
|
||
}
|
||
default:
|
||
legacyBody, err := adaptor.ConvertRequest(ctx, info, body)
|
||
if err != nil {
|
||
// responses 有状态协议不匹配为预期内错误(上层按哨兵驱动换渠道并向客户端返回
|
||
// 端点切换指引),不打 ERRO 噪音;其余转换失败仍按内部错误记录
|
||
if !errors.Is(err, constant.ErrStatefulResponsesUnsupported) {
|
||
g.Log().Errorf(ctx, "[RelayHandler] ConvertRequest failed: adaptor=%s, inboundFormat=%s, error=%v",
|
||
adaptor.GetChannelName(), info.InboundFormat, err)
|
||
}
|
||
return nil, err
|
||
}
|
||
convertedBody = legacyBody
|
||
}
|
||
|
||
// 注入渠道系统提示词
|
||
if info.ChannelMeta.Settings.SystemPrompt != "" {
|
||
bodyBytes, _ := io.ReadAll(convertedBody)
|
||
bodyBytes = helper.InjectSystemPrompt(bodyBytes, info)
|
||
convertedBody = bytes.NewReader(bodyBytes)
|
||
}
|
||
|
||
// 应用请求体改写(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)
|
||
return nil, err
|
||
}
|
||
bodyBytes, err = override.ApplyParamOverride(bodyBytes, info)
|
||
if err != nil {
|
||
if retErr, ok := override.AsReturnError(err); ok {
|
||
return nil, constant.NewUpstreamError(retErr.StatusCode, retErr.Message, retErr)
|
||
}
|
||
return nil, err
|
||
}
|
||
convertedBody = bytes.NewReader(bodyBytes)
|
||
}
|
||
|
||
// 字段清理
|
||
sanitized, _ := io.ReadAll(convertedBody)
|
||
sanitized = helper.SanitizeFields(sanitized, info.ChannelMeta.Settings)
|
||
return bytes.NewReader(sanitized), nil
|
||
}
|
||
|
||
// channelUnavailableResult 渠道不可用时的返回值
|
||
type channelUnavailableResult struct {
|
||
usage *common.Usage
|
||
billingResult *BillingResult
|
||
err error
|
||
}
|
||
|
||
// handleChannelUnavailable 处理渠道选择失败:退还预扣、记录错误
|
||
func handleChannelUnavailable(
|
||
ctx context.Context,
|
||
billing common.BillingProvider,
|
||
provider common.DataProvider,
|
||
rc *RelayContext,
|
||
v *relayValidation,
|
||
preDeductAmount float64,
|
||
channelErrors []string,
|
||
err error,
|
||
noChannelDiag string, // 调度器无可用渠道时的排除原因摘要(NoChannelDiag.Summary()),无诊断信息传 ""
|
||
) *channelUnavailableResult {
|
||
if err != common.ErrChannelUnavailable {
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
return &channelUnavailableResult{nil, v.billingResult, err}
|
||
}
|
||
|
||
diagSuffix := ""
|
||
if noChannelDiag != "" {
|
||
diagSuffix = " 原因: " + noChannelDiag
|
||
}
|
||
|
||
if len(channelErrors) > 0 {
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
// 全部渠道失败是真实运营告警,保留 ERROR 级别;但调用栈固定无意义,禁用堆栈打印
|
||
g.Log().Stack(false).Errorf(ctx, "[RelayHandler] All %d channels failed for model=%s tenant=%d user=%d request=%s.%s Failure details: %s",
|
||
len(channelErrors), v.modelName, rc.TenantID, rc.UserID, rc.RequestID, diagSuffix, strings.Join(channelErrors, "\n"))
|
||
// 用户侧只返回通用错误,不暴露渠道数量和详情
|
||
allFailedErr := constant.NewChannelError(
|
||
"当前模型暂时不可用",
|
||
constant.ErrAllChannelsFailed,
|
||
)
|
||
recordFailedUsage(provider, rc, nil, v.modelName, v.relayMode, v.isStream, allFailedErr, nil)
|
||
return &channelUnavailableResult{nil, v.billingResult, allFailedErr}
|
||
}
|
||
|
||
if billing != nil && preDeductAmount > 0 {
|
||
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||
}
|
||
// 无可用渠道属于正常业务条件(模型未配渠道/容量满/熔断中等),降级为 Warning 并禁用堆栈打印;
|
||
// 具体原因由调度器的排除明细摘要给出(熔断OPEN/半开探测限流/容量租约满/凭证冷却/目录为空)
|
||
g.Log().Stack(false).Warningf(ctx, "[RelayHandler] 无可用渠道: model=%s tenant=%d user=%d request=%s%s",
|
||
v.modelName, rc.TenantID, rc.UserID, rc.RequestID, diagSuffix)
|
||
// 用户侧返回通用错误,不暴露 "channel" 字眼
|
||
noChErr := constant.NewChannelError("当前模型暂时不可用", err)
|
||
// 无可用渠道时请求未转发到任何上游(与 QPS 限流、权限/额度拒绝等前置拦截同理),
|
||
// 不记用量日志,避免零消耗的 error 记录污染请求日志;运营侧的“被拒请求量”已由
|
||
// 上方 Warning 日志、调度器排除明细指标、chn_error_events 覆盖。
|
||
return &channelUnavailableResult{nil, v.billingResult, noChErr}
|
||
}
|
||
|
||
// buildTraceHop 构建转发追踪的单跳记录
|
||
func buildTraceHop(attempt int, selection *common.ChannelSelection, adaptor common.Adaptor, info *common.RelayInfo) common.ForwardingHop {
|
||
var upstreamURL string
|
||
if u, err := adaptor.GetRequestURL(info); err == nil {
|
||
upstreamURL = u
|
||
}
|
||
return 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,
|
||
SelectionReason: selection.SelectionReason,
|
||
Priority: selection.Priority,
|
||
Weight: selection.Weight,
|
||
HealthScore: selection.HealthScore,
|
||
}
|
||
}
|
||
|
||
// appendHop 追加一条 hop 到 trace
|
||
func appendHop(trace *common.ForwardingTrace, hop common.ForwardingHop, success bool, errMsg string, latencyMs float64) {
|
||
hop.Success = success
|
||
hop.Error = errMsg
|
||
hop.LatencyMs = latencyMs
|
||
trace.Hops = append(trace.Hops, hop)
|
||
}
|
||
|
||
// finalizeTrace 设置 trace 的最终状态并写入 rc
|
||
func finalizeTrace(trace *common.ForwardingTrace, rc *RelayContext, hop common.ForwardingHop, success bool, attempt int, selection *common.ChannelSelection, errMsg string, latencyMs float64) {
|
||
hop.Success = success
|
||
hop.Error = errMsg
|
||
hop.LatencyMs = latencyMs
|
||
trace.Hops = append(trace.Hops, hop)
|
||
trace.TotalAttempts = attempt + 1
|
||
trace.UpstreamModel = selection.UpstreamModelName
|
||
trace.ModelMapped = selection.IsModelMapped
|
||
rc.ForwardingTrace = trace
|
||
}
|
||
|
||
// RelayErrorWithRateLimit 带 429 限流信息的错误
|
||
type RelayErrorWithRateLimit struct {
|
||
StatusCode int
|
||
Message string
|
||
LimitLevel string
|
||
Remaining int
|
||
ResetAt int64
|
||
}
|
||
|
||
func (e *RelayErrorWithRateLimit) Error() string {
|
||
return e.Message
|
||
}
|
||
|
||
// applyStreamEndDiag 把流式结束原因落进失败用量记录,替代无归因价值的哨兵文案。
|
||
// stream_end_reason 区分 client_gone / handler_stop / scanner_err / timeout / ping_fail / eof,
|
||
// error_message 保留真实底层错误(ctx 取消原因、写客户端失败的 TCP 错误等);
|
||
// 哨兵错误(ErrStreamInterrupted)或状态里已带底层错误时直接用摘要,其余追加在原始错误后。
|
||
// error_message 对租户端可见,全部经 RedactMessage/SafeUpstreamErrorMessage 脱敏:
|
||
// 摘要中的本地连接地址抹 IP,外层错误按传输层归一化,禁止上游域名/URL/IP 落入用量日志。
|
||
func applyStreamEndDiag(record *common.UsageRecord, streamStatus *common.StreamStatus, err error) {
|
||
if streamStatus == nil {
|
||
return
|
||
}
|
||
reason := streamStatus.GetEndReason()
|
||
if reason == "" {
|
||
return
|
||
}
|
||
record.StreamEndReason = string(reason)
|
||
summary := helper.RedactMessage(streamStatus.Summary())
|
||
if err == nil || errors.Is(err, common.ErrStreamInterrupted) || streamStatus.Error() != nil {
|
||
record.ErrorMessage = summary
|
||
return
|
||
}
|
||
record.ErrorMessage = helper.SafeUpstreamErrorMessage(err) + " [stream: " + summary + "]"
|
||
}
|
||
|
||
// recordFailedUsage 记录失败用量。
|
||
// selection 非 nil 时记录具体失败渠道(ID/名称/类型/上游模型),便于在用量日志定位失败渠道;
|
||
// nil 表示无单一渠道(全部渠道失败),渠道字段留空,失败详情见 error_message。
|
||
// streamStatus 非 nil 且已有结束原因时,同步落 stream_end_reason 并修正 error_message。
|
||
func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, streamStatus *common.StreamStatus) {
|
||
record := &common.UsageRecord{
|
||
TenantID: rc.TenantID,
|
||
UserID: rc.UserID,
|
||
ApiKeyID: rc.ApiKeyID,
|
||
ProjectID: rc.ProjectID,
|
||
ModelName: modelName,
|
||
RequestedModel: modelName,
|
||
RelayMode: int(relayMode),
|
||
IsStream: isStream,
|
||
Success: false,
|
||
RequestID: rc.RequestID,
|
||
Status: "error",
|
||
// 租户端用量日志可见 error_message,须经脱敏(上游域名/URL/IP 禁止落库);
|
||
// 完整原始错误保留在应用日志与审计 ForwardingTrace(仅管理员可见)
|
||
ErrorMessage: helper.SafeUpstreamErrorMessage(err),
|
||
}
|
||
applyStreamEndDiag(record, streamStatus, err)
|
||
if selection != nil {
|
||
record.ChannelID = selection.ChannelID
|
||
record.ChannelName = selection.ChannelName
|
||
record.ChannelType = selection.ChannelType
|
||
record.UpstreamModel = selection.UpstreamModelName
|
||
}
|
||
provider.RecordUsage(context.Background(), record)
|
||
}
|
||
|
||
// recordFailedUsageWithTokens 记录失败用量(含 token 明细,用于流中断等已有部分 usage 的场景)。
|
||
// 与 recordFailedUsage 的区别:此函数会填充 token 字段,避免报表中流中断记录的 token 全为 0。
|
||
func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, usage *common.Usage, settleResult *common.SettlementResult, streamStatus *common.StreamStatus) {
|
||
record := &common.UsageRecord{
|
||
TenantID: rc.TenantID,
|
||
UserID: rc.UserID,
|
||
ApiKeyID: rc.ApiKeyID,
|
||
ProjectID: rc.ProjectID,
|
||
ModelName: modelName,
|
||
RequestedModel: modelName,
|
||
RelayMode: int(relayMode),
|
||
IsStream: isStream,
|
||
Success: false,
|
||
RequestID: rc.RequestID,
|
||
Status: "error",
|
||
// 租户端用量日志可见 error_message,须经脱敏(上游域名/URL/IP 禁止落库);
|
||
// 完整原始错误保留在应用日志与审计 ForwardingTrace(仅管理员可见)
|
||
ErrorMessage: helper.SafeUpstreamErrorMessage(err),
|
||
}
|
||
applyStreamEndDiag(record, streamStatus, err)
|
||
if selection != nil {
|
||
record.ChannelID = selection.ChannelID
|
||
record.ChannelName = selection.ChannelName
|
||
record.ChannelType = selection.ChannelType
|
||
record.UpstreamModel = selection.UpstreamModelName
|
||
}
|
||
if usage != nil {
|
||
record.PromptTokens = usage.PromptTokens
|
||
record.CompletionTokens = usage.CompletionTokens
|
||
record.TotalTokens = usage.TotalTokens
|
||
}
|
||
// 流中断结算成功时补充费用字段:中断已按已传输部分实际扣费,用量记录须与账本一致,
|
||
// 否则后台看到的中断请求永远是 0 费用(结算与用量记录是两条独立写入链路)
|
||
if settleResult != nil {
|
||
record.TotalCost = settleResult.BaseCost
|
||
record.ActualCost = settleResult.ActualCost
|
||
record.PreDeductAmount = settleResult.PreDeductAmount
|
||
record.RefundAmount = settleResult.RefundAmount
|
||
record.SupplementAmount = settleResult.SupplementAmount
|
||
record.BillingMode = settleResult.BillingMode
|
||
record.BillingSource = settleResult.BillingSource
|
||
}
|
||
provider.RecordUsage(context.Background(), record)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// setPreResponseHeaders 在写入响应体之前设置限流和弃用 header
|
||
func setPreResponseHeaders(w http.ResponseWriter, br *BillingResult) {
|
||
if br == nil {
|
||
return
|
||
}
|
||
if info := br.RateLimitInfo; info != nil {
|
||
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", info.Limit))
|
||
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", info.Remaining))
|
||
w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", info.ResetAt))
|
||
}
|
||
if dep := br.Deprecation; dep != nil {
|
||
w.Header().Set("Deprecation", "true")
|
||
if dep.SunsetDate != "" {
|
||
w.Header().Set("Sunset", dep.SunsetDate)
|
||
}
|
||
if dep.ReplacementModel != "" {
|
||
w.Header().Set("Link", fmt.Sprintf("</v1/models/%s>; rel=\"successor-version\"", dep.ReplacementModel))
|
||
}
|
||
}
|
||
}
|
||
|
||
// protoPreference 入站端点协议 → 调度软偏好(只降权不排除):
|
||
// responses 端点偏好声明支持 Responses 协议的渠道(避免经 chat 转换丢失有状态特性),
|
||
// chat 端点偏好原生 chat 渠道(responses-only 桥接渠道降权但仍可服务),其余端点无偏好。
|
||
func protoPreference(mode constant.RelayMode) dispatch.ProtoPreference {
|
||
switch mode {
|
||
case constant.RelayModeResponses, constant.RelayModeResponsesCompact:
|
||
return dispatch.ProtoResponses
|
||
case constant.RelayModeChatCompletions:
|
||
return dispatch.ProtoChat
|
||
}
|
||
return dispatch.ProtoAny
|
||
}
|
||
|
||
// 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"
|
||
case constant.RelayModeVideoGenerations:
|
||
return "video_generations"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|