Files
team-api/relay/handler/matrix_guard_test.go
Qian 1f2046d0fd 重构协议转换,实现更完整的转换功能 (#26)
* 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):补充手写测试数据集
2026-09-12 16:08:05 +08:00

254 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
// matrix_guard_test.go — relaykit 矩阵接管的供应商维度守卫。
//
// 背景:relaykit 转换矩阵只按协议格式裁决(入站格式 × 有效上游格式),与供应商无关;
// 方向命中后 adaptor.ConvertRequest 整个不再被调用。但供应商差异是(供应商 × RelayMode)
// 维度的,两个维度不对齐时矩阵会在自己看不见的地方替供应商做错决定,且不报错——
// 历史上已因此产生 4 个静默断链回归:
// 1. Anthropic 兼容端点漏登记(HasNativeClaudeEndpoint)→ Claude 体转成 chat 发到
// Anthropic 端点、响应又按 OpenAI 解析,两侧同时断链;
// 2. 供应商私有后处理(RequestPostProcessor)丢失或双写;
// 3. 目标 adaptor 的 GetRequestURL 不认入站 RelayMode → DoRequest 报
// "unsupported relay mode"、0ms 断链(bridge 测试只直测 DoResponse,测不到);
// 4. 多协议原生透传渠道(multinative)配模型映射时被矩阵按主协议 OpenAI 接管,
// chat 体发到 /v1/messages 原生端点,体与端点错配。
//
// 本文件把「新增/修改转换方向必查三条」的人工评审清单固化为自动化测试:
// 枚举 registry 全部供应商 × 四种文本入站,任何新增供应商 / 新增方向 / 改动判定函数
// 若破坏一致性会立即失败,不再依赖人记住检查项。
import (
"testing"
"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/relaykit_bridge"
)
// matrixInboundCombos 文本代理的四种入站(格式 × RelayMode)组合,与各入站 handler
// 的真实取值一致(mode 随请求全程不变,直达 adaptor.GetRequestURL)。
var matrixInboundCombos = []struct {
inbound constant.RelayFormat
mode constant.RelayMode
}{
{constant.RelayFormatOpenAI, constant.RelayModeChatCompletions},
{constant.RelayFormatClaude, constant.RelayModeClaudeMessages},
{constant.RelayFormatGemini, constant.RelayModeGeminiChat},
{constant.RelayFormatResponses, constant.RelayModeResponses},
}
// guardProviders 枚举 registry 中全部有文本 adaptor 的供应商类型。
// 上界取宽(现有编号最大 42),新增供应商自动纳入。
func guardProviders() []constant.ProviderType {
var out []constant.ProviderType
for p := 1; p <= 64; p++ {
if channel.GetAdaptor(p) != nil {
out = append(out, constant.ProviderType(p))
}
}
return out
}
// guardChannelMeta 构造最小可用渠道元数据(个别 adaptor 对 ApiKey 形态有硬要求)。
func guardChannelMeta(p constant.ProviderType) *common.ChannelMeta {
meta := &common.ChannelMeta{
ChannelType: int(p),
BaseURL: "https://upstream.example.com",
ApiKey: "sk-guard-test",
UpstreamModelName: "guard-model",
}
if p == constant.ProviderCloudflare {
meta.ApiKey = "cf-token|cf-account" // cloudflare 的 URL 需要 accountID(token|accountid 格式)
}
return meta
}
func guardInfo(p constant.ProviderType, inbound constant.RelayFormat, mode constant.RelayMode) *common.RelayInfo {
return &common.RelayInfo{
InboundFormat: inbound,
RelayMode: int(mode),
ChannelMeta: guardChannelMeta(p),
}
}
// matrixTakeoverExclusions 有意不参加「接管方向 URL 可达」守卫的供应商,必须注明原因。
// 新增排除项前先确认它不是第 3 类断链——排除即承诺该组合在调度层不可达。
var matrixTakeoverExclusions = map[constant.ProviderType]string{
// codex 是 responses-only 上游(GetRequestURL 有意只认 Responses mode)。
// 非 Responses 入站按主协议 OpenAI 参与矩阵裁决,但 codex 端点吃不了 chat 体。
// 已知缺口:依赖渠道只挂 responses 系模型 + 调度 proto 门控避免命中;
// 若未来放开 codex 承接 chat/claude/gemini 入站,须先实现相应桥接再移除本排除。
constant.ProviderCodex: "responses-only 上游,非 Responses 入站不可达(渠道模型配置 + 调度门控约束)",
// jimeng 是图片生成专用上游(GetRequestURL 有意只认 ImagesGenerations mode)。
// 文本类入站不可达:jimeng 渠道只会挂图片模型,chat/claude/gemini/responses
// 请求不会被调度到该渠道。
constant.ProviderJimeng: "图片生成专用上游,文本类入站不可达(渠道只挂图片模型)",
}
// TestMatrixTakeover_AdaptorAcceptsInboundMode 守卫第 3 类断链:
// 矩阵接管的每个(供应商 × 入站)组合,目标 adaptor 的 GetRequestURL 必须认该入站
// RelayMode——矩阵已把请求体转成上游格式,URL 拿不到就是 DoRequest 处 0ms 断链。
func TestMatrixTakeover_AdaptorAcceptsInboundMode(t *testing.T) {
for _, p := range guardProviders() {
if reason, excluded := matrixTakeoverExclusions[p]; excluded {
t.Logf("跳过 %s(%d):%s", p.String(), p, reason)
continue
}
for _, combo := range matrixInboundCombos {
info := guardInfo(p, combo.inbound, combo.mode)
upstream := relaykit_bridge.EffectiveUpstreamFormat(info)
converterID := relaykit_bridge.RequestConverterIDForRoute(combo.inbound, upstream, info.RelayMode)
if converterID == "" {
continue // 同格式直连或矩阵未覆盖方向,不经 relaykit 接管
}
adaptor := channel.GetAdaptor(int(p))
adaptor.Init(info)
url, err := adaptor.GetRequestURL(info)
if err != nil {
t.Errorf("%s(%d) 的 %s 入站(mode=%d)被矩阵接管(%s),但 GetRequestURL 报错:%v\n"+
"→ 该组合会在 DoRequest 处 0ms 断链。修复:让该 adaptor 的 GetRequestURL 认此 mode"+
"(矩阵已把请求体转成 %s 格式,通常应返回对应格式的主端点)",
p.String(), p, combo.inbound, combo.mode, converterID, err, upstream)
continue
}
if url == "" {
t.Errorf("%s(%d) 的 %s 入站:GetRequestURL 返回空 URL", p.String(), p, combo.inbound)
}
}
}
}
// TestMatrixGuard_PassthroughNeverConverted 守卫第 1/4 类断链的共同根因:
// 两个判定函数必须口径一致——被 inboundMatchesChannelNative 判为「原生匹配」的
// (供应商 × 入站),矩阵必须不产生转换器(有效上游格式 = 入站格式)。
//
// 口径不一致时,同一请求在直连与转换两条路上得到不同格式结论:无映射/改写时直连正常,
// 一旦配置模型映射(canPassThrough=false 强制走转换路径)矩阵就按错误方向转换体,
// 而 adaptor 仍按原生格式选端点——即 multinative 断链(第 4 例)与 Anthropic
// 端点断链(第 1 例)的完整机理。
func TestMatrixGuard_PassthroughNeverConverted(t *testing.T) {
for _, p := range guardProviders() {
for _, combo := range matrixInboundCombos {
info := guardInfo(p, combo.inbound, combo.mode)
if !inboundMatchesChannelNative(info) {
continue
}
upstream := relaykit_bridge.EffectiveUpstreamFormat(info)
if id := relaykit_bridge.RequestConverterIDForRoute(combo.inbound, upstream, info.RelayMode); id != "" {
t.Errorf("%s(%d) 的 %s 入站被判为原生匹配(可直连),但矩阵仍会转换(%s,有效上游=%s)\n"+
"→ 配置模型映射后请求体会被转成 %s 而 adaptor 按 %s 原生端点发送,体与端点错配。"+
"检查 EffectiveUpstreamFormat 与 inboundMatchesChannelNative 是否同步",
p.String(), p, combo.inbound, id, upstream, upstream, combo.inbound)
}
}
}
// Responses 上游声明(supports_responses)同样两函数联动:原生匹配 ⇒ 不转换
for _, p := range []constant.ProviderType{constant.ProviderOpenAI, constant.ProviderDeepSeek} {
info := guardInfo(p, constant.RelayFormatResponses, constant.RelayModeResponses)
info.ChannelMeta.SupportsResponses = true
if !inboundMatchesChannelNative(info) {
t.Errorf("%s(%d):supports_responses 渠道的 Responses 入站应判为原生匹配", p.String(), p)
}
upstream := relaykit_bridge.EffectiveUpstreamFormat(info)
if id := relaykit_bridge.RequestConverterIDForRoute(constant.RelayFormatResponses, upstream, info.RelayMode); id != "" {
t.Errorf("%s(%d):supports_responses 渠道的 Responses 入站不应被矩阵转换(got %s)", p.String(), p, id)
}
}
}
// TestMatrixGuard_NativeClaudeEndpointRegistry 守卫第 1 类断链的登记完整性:
// HasNativeClaudeEndpoint 集合必须与各 adaptor 的真实端点行为一致。
// 判据(与集合注释一致):GetRequestURL 对 ClaudeMessages 返回与 chat **不同**的地址
// ⇔ 该供应商另挂 Anthropic 兼容端点 ⇔ 必须登记。
//
// - 挂了独立端点却未登记 → Claude 入站被转成 chat 发到 Anthropic 端点(断链);
// - 未挂端点却登记了 → Claude 入站被直连到 chat 端点(chat 端点吃不了 Claude 体)。
func TestMatrixGuard_NativeClaudeEndpointRegistry(t *testing.T) {
for _, p := range guardProviders() {
// 只考察主协议 OpenAI 的供应商(集合语义即「主协议 OpenAI + 另挂 Anthropic 端点」)
if helper.ProviderNativeFormat(int(p)) != constant.RelayFormatOpenAI {
continue
}
// 多协议原生透传渠道由 IsMultiNativeProvider 独立判定(三种入站全部原生直连),
// 不属于「另挂 Anthropic 端点」语义
if constant.IsMultiNativeProvider(int(p)) {
continue
}
// Claude 入站的 URL(按真实链路:Claude 入站 + ClaudeMessages mode 初始化 adaptor)
claudeInfo := guardInfo(p, constant.RelayFormatClaude, constant.RelayModeClaudeMessages)
claudeAdaptor := channel.GetAdaptor(int(p))
claudeAdaptor.Init(claudeInfo)
claudeURL, claudeErr := claudeAdaptor.GetRequestURL(claudeInfo)
// chat 入站的 URL
chatInfo := guardInfo(p, constant.RelayFormatOpenAI, constant.RelayModeChatCompletions)
chatAdaptor := channel.GetAdaptor(int(p))
chatAdaptor.Init(chatInfo)
chatURL, chatErr := chatAdaptor.GetRequestURL(chatInfo)
hasDistinctEndpoint := claudeErr == nil && chatErr == nil && claudeURL != chatURL
registered := constant.HasNativeClaudeEndpoint(int(p))
if hasDistinctEndpoint && !registered {
t.Errorf("%s(%d):ClaudeMessages 端点(%s)≠ chat 端点(%s),但未登记进 HasNativeClaudeEndpoint\n"+
"→ Claude 入站会被矩阵转成 chat 发到 Anthropic 端点。请在 constant.HasNativeClaudeEndpoint 登记",
p.String(), p, claudeURL, chatURL)
}
if !hasDistinctEndpoint && registered {
t.Errorf("%s(%d):已登记 HasNativeClaudeEndpoint 但 ClaudeMessages 与 chat 端点相同(claude=%q err=%v, chat=%q err=%v)\n"+
"→ Claude 入站会被直连到 chat 端点(chat 端点吃不了 Claude 体)。请移除登记或修 GetRequestURL",
p.String(), p, claudeURL, claudeErr, chatURL, chatErr)
}
}
}
// expectedPostProcessors 实现了 common.RequestPostProcessor 的供应商期望清单。
// 该接口仅在 relaykit 接管转换时由 convertRequestBody 调用,承载格式转换之外的
// 供应商私有适配(zhipu GLM top_p 裁剪 + 图片前缀剥离、ali DashScope thinking_budget 裁剪)。
var expectedPostProcessors = map[constant.ProviderType]bool{
constant.ProviderAli: true,
constant.ProviderZhipu: true,
// vertex:Claude 模型需注入 anthropic_version 并删除 model 字段
//(rawPredict 端点要求)。ConvertRequest 与本接口共用 adaptClaudeBodyForVertex,
// 该函数幂等(delete 幂等、anthropic_version 仅在缺失时写入)
constant.ProviderVertex: true,
}
// TestMatrixGuard_RequestPostProcessorRegistry 守卫第 2 类断链:私有后处理的登记漂移。
//
// - 新 adaptor 实现了接口却没进清单 → 失败提醒登记,并核对两条契约:
// ① adaptor.ConvertRequest 内部含同一段后处理(legacy 路径不经本接口);
// ② 后处理幂等(relaykit 通用处理可能已写同名字段,不得依赖字段不存在)。
// - 清单里的供应商不再实现接口 → 失败提醒同步移除(多为重构时误删,实际是丢私有适配)。
//
// 注意:dispatch 型 adaptor(volcengine/tencent)通过匿名嵌入接口字段委托,接口断言
// 作用在外层 wrapper 上——若未来给其子 adaptor 添加 PostProcessor,必须让 wrapper
// 也实现并转发,否则 convertRequestBody 的断言拿不到(本测试同样会暴露这一点)。
func TestMatrixGuard_RequestPostProcessorRegistry(t *testing.T) {
for _, p := range guardProviders() {
info := guardInfo(p, constant.RelayFormatOpenAI, constant.RelayModeChatCompletions)
adaptor := channel.GetAdaptor(int(p))
adaptor.Init(info)
_, implements := adaptor.(common.RequestPostProcessor)
expected := expectedPostProcessors[p]
if implements && !expected {
t.Errorf("%s(%d) 实现了 RequestPostProcessor 但未登记进 expectedPostProcessors\n"+
"→ 请登记,并核对:① ConvertRequest 内部含同一段后处理(legacy 路径);② 后处理幂等",
p.String(), p)
}
if !implements && expected {
t.Errorf("%s(%d) 在 expectedPostProcessors 清单中但 adaptor 未实现 RequestPostProcessor\n"+
"→ 私有后处理疑似在重构中丢失(relaykit 接管路径将静默跳过参数裁剪),请核实",
p.String(), p)
}
}
}