* 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):补充手写测试数据集
167 lines
6.6 KiB
Go
167 lines
6.6 KiB
Go
// Package dispatch 是渠道调度核心库(纯函数 + 端口)。
|
||
//
|
||
// 本包为纯逻辑库:不做任何 I/O,所有外部状态通过 ports.go 中的端口接口注入。
|
||
// relaykit module 不依赖 gf/redis/dao,纯度由模块边界强制。
|
||
package dispatch
|
||
|
||
// Tier 渠道调度层级,固定三档枚举(不采用数值优先级)。
|
||
type Tier string
|
||
|
||
const (
|
||
TierPrimary Tier = "primary" // 首选
|
||
TierSecondary Tier = "secondary" // 备用
|
||
TierReserve Tier = "reserve" // 保底
|
||
)
|
||
|
||
// tierOrder 层级从高到低的固定顺序,用于扩组兜底。
|
||
var tierOrder = []Tier{TierPrimary, TierSecondary, TierReserve}
|
||
|
||
// BreakerState 熔断器状态。
|
||
type BreakerState int
|
||
|
||
const (
|
||
BreakerClosed BreakerState = iota // 正常
|
||
BreakerOpen // 熔断中,硬排除
|
||
BreakerHalfOpen // 探活中,每窗口放行一个真实请求
|
||
)
|
||
|
||
// Replayability 请求可重放性(按 relay mode 静态映射)。
|
||
type Replayability uint8
|
||
|
||
const (
|
||
ReplaySafe Replayability = iota // 查询 / embedding / rerank:可安全重放
|
||
ReplayCostly // 文本生成:重放有成本但无外部副作用
|
||
ReplayUnsafe // 图片/视频生成、异步任务提交:重放可能产生重复任务
|
||
)
|
||
|
||
// DeliveryState 请求送达状态(由执行器在错误发生点标注)。
|
||
type DeliveryState uint8
|
||
|
||
const (
|
||
DeliveryNotSent DeliveryState = iota // 连接失败 / 请求体未发出
|
||
DeliveryMaybeSent // 写出后连接重置 / 读超时 / EOF,可能已送达上游
|
||
DeliveryResponseReceived // 已收到完整错误响应,尚未写给客户端
|
||
DeliveryResponseStarted // 已向客户端写出响应字节,禁止任何重试
|
||
)
|
||
|
||
// Channel 候选渠道快照。由 CatalogPort 提供,字段包含调度所需的
|
||
// 静态配置与最近一次刷新时的健康/负载读值(快照间隔内的滞后可接受)。
|
||
type Channel struct {
|
||
ID int64
|
||
Name string
|
||
Tier Tier
|
||
|
||
BaseWeight float64 // 运营配置的组内权重(chn_channels.weight)
|
||
CostRatio float64 // 成本比例(chn_abilities.cost_ratio),上游实际价/平台基准价,<=0 视为 1.0
|
||
|
||
SuccEwma float64 // 成功率 EWMA ∈ [0,1],无数据时为 1
|
||
LatEwmaMs float64 // 延迟 EWMA(毫秒),无数据时为 0
|
||
|
||
Inflight int // 当前并发(租约计数)
|
||
SoftLimit int // 软并发上限;<=0 表示无容量信息(headroom 因子恒为 1)
|
||
|
||
Breaker BreakerState // 渠道级熔断状态
|
||
ModelBreaker BreakerState // 渠道×模型级熔断状态
|
||
|
||
RampElapsedMs int64 // 距新启用/熔断恢复的毫秒数;<0 表示不在爬坡期
|
||
|
||
KeyIDs []int64 // active 状态的渠道 Key ID 列表(按 id 升序);空表示由适配层自行取 Key
|
||
StrictCapacity bool // Redis 故障时 fail-closed
|
||
|
||
// 协议能力(chn_abilities 渠道×模型级),参与 protoFactor 软偏好:
|
||
// responses 入站偏好 SupportsResponses 渠道(避免经 chat 转换丢失有状态 Responses 特性),
|
||
// chat 入站偏好原生 chat 渠道(ChatViaResponses 桥接渠道降权但仍可用)。
|
||
SupportsResponses bool // 模型在该渠道支持 /v1/responses 原生协议
|
||
ChatViaResponses bool // 上游仅有 Responses 协议,chat 需经桥接发送
|
||
}
|
||
|
||
// ProtoPreference 入站端点协议偏好(软偏好,只降权不排除)。
|
||
type ProtoPreference uint8
|
||
|
||
const (
|
||
ProtoAny ProtoPreference = iota // 非文本生成端点 / 无偏好
|
||
ProtoResponses // responses 入站:偏好 SupportsResponses 渠道
|
||
ProtoChat // chat 入站:偏好原生 chat 渠道
|
||
)
|
||
|
||
// ScoredChannel 打分后的候选。
|
||
type ScoredChannel struct {
|
||
Channel
|
||
Weight float64
|
||
Breakdown WeightBreakdown
|
||
}
|
||
|
||
// WeightBreakdown 权重分解,用于决策日志与 ForwardingTrace。
|
||
type WeightBreakdown struct {
|
||
Base float64 `json:"base"`
|
||
Tier float64 `json:"tier"`
|
||
Health float64 `json:"health"`
|
||
Headroom float64 `json:"headroom"`
|
||
Cost float64 `json:"cost"`
|
||
Ramp float64 `json:"ramp"`
|
||
Proto float64 `json:"proto"`
|
||
Effective float64 `json:"effective"`
|
||
}
|
||
|
||
// RequestProfile 一次请求的调度输入。
|
||
type RequestProfile struct {
|
||
RequestID string
|
||
TenantID int64
|
||
UserID int64
|
||
APIKeyID int64
|
||
Model string
|
||
Scope []int64 // 租户渠道范围(空 = 不限制),由适配层填充
|
||
Replay Replayability
|
||
Signals SessionSignals
|
||
// Proto 入站端点协议偏好(ProtoAny=无偏好)。responses 入站软偏好
|
||
// SupportsResponses 渠道、chat 入站软偏好原生 chat 渠道,只降权不排除。
|
||
Proto ProtoPreference
|
||
// Policy 请求级策略覆盖(租户级差异化)。
|
||
// nil = 使用协调器全局策略。由适配层解析(全局 + 租户浅合并)后传入。
|
||
Policy *RoutingPolicy
|
||
}
|
||
|
||
// DecisionReason 选择原因(对应 SelectionReason,前端渠道日志展示)。
|
||
type DecisionReason string
|
||
|
||
const (
|
||
ReasonBind DecisionReason = "bind" // 绑定守卫命中
|
||
ReasonHRW DecisionReason = "hrw" // HRW 新选择
|
||
ReasonOverflow DecisionReason = "overflow" // 选中渠道低于当前可用的最高层级(溢出)
|
||
ReasonProbe DecisionReason = "probe" // HALF_OPEN 探测放行
|
||
ReasonCredRotate DecisionReason = "cred_rotate" // 凭证轮换(同渠道换 Key)
|
||
)
|
||
|
||
// ExclusionStats 本次选择的排除统计。
|
||
type ExclusionStats struct {
|
||
Breaker int `json:"breaker"` // 熔断 OPEN 排除
|
||
Lease int `json:"lease"` // 容量租约获取失败排除
|
||
Request int `json:"request"` // 本请求已失败排除
|
||
}
|
||
|
||
// Decision 一次应尝试的调度决定。
|
||
type Decision struct {
|
||
Channel Channel
|
||
KeyID int64 // 0 = 无 Key 快照信息,适配层按旧逻辑自行取
|
||
SessionKey SessionKey
|
||
Reason DecisionReason
|
||
Breakdown WeightBreakdown
|
||
CandidateCount int
|
||
Excluded ExclusionStats
|
||
}
|
||
|
||
// Outcome 一次尝试的结果,用于健康上报(fire-and-forget)。
|
||
// Class 决定健康 EWMA 的衰减档位;CLIENT / CREDENTIAL 类不上报。
|
||
type Outcome struct {
|
||
ChannelID int64
|
||
KeyID int64
|
||
Model string
|
||
Success bool
|
||
Class ErrorClass
|
||
LatencyMs float64
|
||
// Probe 标记本结果来自探测请求(管理后台渠道测试 / 自动探测 cron)而非真实流量。
|
||
// 纯核心库不消费;适配层据此跳过探测失败对健康 EWMA 的衰减(避免持续失败的探测把
|
||
// 无真实流量渠道的健康分指数级拖垮),探测成功仍照常记录,熔断窗口计数不受影响。
|
||
Probe bool
|
||
}
|