diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 1d09a25..2d5c428 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -866,6 +866,12 @@ func InitHealthScore(ctx context.Context, channelID int64) error { // MaterializeSelection 由新调度引擎的决策构造 ChannelSelection(阶段 3): // 转发元数据来自目录内存快照(零 DB),渠道 Key 按 keyID 解密(keyID=0 回退取首个 active Key)。 func (p *DataProviderImpl) MaterializeSelection(ctx context.Context, channelID, keyID int64, modelName string) (*common.ChannelSelection, error) { + // 优先检查上下文:客户端已断开时直接返回 ctx.Err(),而非 ErrChannelUnavailable, + // 让上层能区分"客户端断开"与"真实渠道无可用 Key",避免误伤渠道健康评分 + if err := ctx.Err(); err != nil { + return nil, err + } + catalog := dispatchadapter.CatalogInstance() if catalog == nil { return nil, common.ErrChannelUnavailable @@ -883,6 +889,10 @@ func (p *DataProviderImpl) MaterializeSelection(ctx context.Context, channelID, apiKey, err = getChannelKey(ctx, channelID) } if err != nil || apiKey == "" { + // Key 查询失败:再次检查 ctx,区分"客户端断开导致查询中断"与"真实无可用 Key" + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } return nil, common.ErrChannelUnavailable } diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index 46fe81a..5aace7c 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -480,6 +480,14 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He 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 {