* 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):补充手写测试数据集
457 lines
19 KiB
Go
457 lines
19 KiB
Go
package handler
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/qianfree/team-api/relay/common"
|
||
"github.com/qianfree/team-api/relay/constant"
|
||
"github.com/qianfree/team-api/relay/relaykit_bridge"
|
||
"github.com/qianfree/team-api/relaykit/relayconvert"
|
||
)
|
||
|
||
func TestCanPassThrough_ExplicitEnabled(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatClaude,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderOpenAI),
|
||
Settings: common.ChannelSettings{
|
||
PassThroughBodyEnabled: true,
|
||
},
|
||
},
|
||
}
|
||
if !canPassThrough(info) {
|
||
t.Error("should pass through when explicitly enabled")
|
||
}
|
||
}
|
||
|
||
func TestCanPassThrough_ExplicitEnabledIgnoresFormatMismatch(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatOpenAI,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderClaude),
|
||
IsModelMapped: true,
|
||
Settings: common.ChannelSettings{
|
||
PassThroughBodyEnabled: true,
|
||
},
|
||
},
|
||
}
|
||
if !canPassThrough(info) {
|
||
t.Error("explicit PassThroughBodyEnabled should bypass format/mapping checks")
|
||
}
|
||
}
|
||
|
||
func TestCanPassThrough_AutoDetect_FormatMatch(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatOpenAI,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderOpenAI),
|
||
IsModelMapped: false,
|
||
Settings: common.ChannelSettings{},
|
||
},
|
||
}
|
||
if !canPassThrough(info) {
|
||
t.Error("should auto-detect pass through for OpenAI client → OpenAI upstream")
|
||
}
|
||
}
|
||
|
||
func TestCanPassThrough_AutoDetect_ClaudeMatch(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatClaude,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderClaude),
|
||
IsModelMapped: false,
|
||
Settings: common.ChannelSettings{},
|
||
},
|
||
}
|
||
if !canPassThrough(info) {
|
||
t.Error("should auto-detect pass through for Claude client → Claude upstream")
|
||
}
|
||
}
|
||
|
||
func TestCanPassThrough_AutoDetect_GeminiMatch(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatGemini,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderGemini),
|
||
IsModelMapped: false,
|
||
Settings: common.ChannelSettings{},
|
||
},
|
||
}
|
||
if !canPassThrough(info) {
|
||
t.Error("should auto-detect pass through for Gemini client → Gemini upstream")
|
||
}
|
||
}
|
||
|
||
func TestCanPassThrough_AutoDetect_FormatMismatch(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatOpenAI,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderClaude),
|
||
IsModelMapped: false,
|
||
Settings: common.ChannelSettings{},
|
||
},
|
||
}
|
||
if canPassThrough(info) {
|
||
t.Error("should NOT pass through when OpenAI client → Claude upstream (format mismatch)")
|
||
}
|
||
}
|
||
|
||
func TestCanPassThrough_AutoDetect_ModelMapped(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatOpenAI,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderOpenAI),
|
||
IsModelMapped: true,
|
||
Settings: common.ChannelSettings{},
|
||
},
|
||
}
|
||
if canPassThrough(info) {
|
||
t.Error("should NOT pass through when model mapping is needed")
|
||
}
|
||
}
|
||
|
||
func TestCanPassThrough_AutoDetect_HasParamOverride(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatOpenAI,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderOpenAI),
|
||
IsModelMapped: false,
|
||
Settings: common.ChannelSettings{
|
||
ParamOverride: map[string]any{"temperature": 0.5},
|
||
},
|
||
},
|
||
}
|
||
if canPassThrough(info) {
|
||
t.Error("should NOT pass through when ParamOverride is configured")
|
||
}
|
||
}
|
||
|
||
func TestCanPassThrough_DeepSeekIsOpenAI(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatOpenAI,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderDeepSeek),
|
||
IsModelMapped: false,
|
||
Settings: common.ChannelSettings{},
|
||
},
|
||
}
|
||
if !canPassThrough(info) {
|
||
t.Error("DeepSeek is OpenAI-compatible, should pass through for OpenAI format requests")
|
||
}
|
||
}
|
||
|
||
// TestCanPassThrough_MultiNative_AllFormats 多协议原生透传渠道(New API / Sub2API)
|
||
// 上游同时支持 OpenAI/Claude/Gemini,三种入站格式在无改写时都应原样直连转发。
|
||
func TestCanPassThrough_MultiNative_AllFormats(t *testing.T) {
|
||
for _, format := range []constant.RelayFormat{
|
||
constant.RelayFormatOpenAI,
|
||
constant.RelayFormatClaude,
|
||
constant.RelayFormatGemini,
|
||
} {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: format,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderNewAPI),
|
||
IsModelMapped: false,
|
||
Settings: common.ChannelSettings{},
|
||
},
|
||
}
|
||
if !canPassThrough(info) {
|
||
t.Errorf("New API channel should pass through %s inbound natively", format)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestCanPassThrough_MultiNative_ModelMappedForcesConversion 多协议原生透传渠道配置了
|
||
// 模型映射时仍需经过 ConvertRequest 替换模型名,不得原样透传。
|
||
func TestCanPassThrough_MultiNative_ModelMappedForcesConversion(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatClaude,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderSub2API),
|
||
IsModelMapped: true,
|
||
Settings: common.ChannelSettings{},
|
||
},
|
||
}
|
||
if canPassThrough(info) {
|
||
t.Error("Sub2API channel with model mapping should NOT pass through")
|
||
}
|
||
}
|
||
|
||
// TestCanPassThrough_MultiNative_ResponsesNotNative Responses 入站不在原生透传格式集合内,
|
||
// 不得原样直连(需经 ConvertRequest 转为 OpenAI chat)。
|
||
func TestCanPassThrough_MultiNative_ResponsesNotNative(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatResponses,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderNewAPI),
|
||
IsModelMapped: false,
|
||
Settings: common.ChannelSettings{},
|
||
},
|
||
}
|
||
if canPassThrough(info) {
|
||
t.Error("New API channel should NOT pass through Responses inbound natively")
|
||
}
|
||
}
|
||
|
||
// TestCanPassThrough_SupportsResponses_ResponsesInbound 模型能力声明支持 Responses 协议后,
|
||
// Responses 入站在无改写时应原样直连转发。
|
||
func TestCanPassThrough_SupportsResponses_ResponsesInbound(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatResponses,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderOpenAI),
|
||
IsModelMapped: false,
|
||
SupportsResponses: true,
|
||
},
|
||
}
|
||
if !canPassThrough(info) {
|
||
t.Error("channel declaring SupportsResponses should pass through Responses inbound natively")
|
||
}
|
||
}
|
||
|
||
// TestCanPassThrough_SupportsResponses_NonResponsesInbound 渠道声明支持 Responses 协议时,
|
||
// 其它入站格式(如 chat)不属于原生匹配,不得原样直连。
|
||
func TestCanPassThrough_SupportsResponses_NonResponsesInbound(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatOpenAI,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderOpenAI),
|
||
IsModelMapped: false,
|
||
SupportsResponses: true,
|
||
},
|
||
}
|
||
if canPassThrough(info) {
|
||
t.Error("SupportsResponses channel should NOT pass through non-Responses inbound natively")
|
||
}
|
||
}
|
||
|
||
// TestCanPassThrough_SupportsResponses_ModelMappedForcesConversion 上游为 Responses 协议
|
||
// 但配置了模型映射时,仍需经 ConvertRequest 替换模型名,不得原样透传。
|
||
func TestCanPassThrough_SupportsResponses_ModelMappedForcesConversion(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatResponses,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderOpenAI),
|
||
IsModelMapped: true,
|
||
SupportsResponses: true,
|
||
},
|
||
}
|
||
if canPassThrough(info) {
|
||
t.Error("SupportsResponses channel with model mapping should NOT pass through")
|
||
}
|
||
}
|
||
|
||
// TestCanPassThrough_ChatViaResponses_ChatInboundBlocked responses-only 桥接渠道的
|
||
// chat 入站:即使显式开启直连(pass_through_body_enabled)也不得原样透传,
|
||
// chat 体必须经桥接转换后才能发 /v1/responses。
|
||
func TestCanPassThrough_ChatViaResponses_ChatInboundBlocked(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatOpenAI,
|
||
RelayMode: int(constant.RelayModeChatCompletions),
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderOpenAI),
|
||
IsModelMapped: false,
|
||
ChatViaResponses: true,
|
||
SupportsResponses: true,
|
||
},
|
||
}
|
||
info.ChannelMeta.Settings.PassThroughBodyEnabled = true
|
||
if canPassThrough(info) {
|
||
t.Error("chat inbound on chat_via_responses channel must NOT pass through, even with explicit passthrough")
|
||
}
|
||
}
|
||
|
||
// TestCanPassThrough_ChatViaResponses_ResponsesInbound responses-only 桥接渠道的
|
||
// responses 入站视为原生匹配(上游本来就说 Responses 协议),可原样直连。
|
||
func TestCanPassThrough_ChatViaResponses_ResponsesInbound(t *testing.T) {
|
||
info := &common.RelayInfo{
|
||
InboundFormat: constant.RelayFormatResponses,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderOpenAI),
|
||
IsModelMapped: false,
|
||
ChatViaResponses: true,
|
||
},
|
||
}
|
||
if !canPassThrough(info) {
|
||
t.Error("responses inbound on chat_via_responses channel should pass through natively")
|
||
}
|
||
}
|
||
|
||
// TestRequestConverterIDForRoute 验证请求侧 converter ID 解析(矩阵已统一收敛到
|
||
// relaykit_bridge.RequestConverterIDForRoute),含反向、跨原生方向与 Ollama 的 RelayMode 守卫。
|
||
func TestRequestConverterIDForRoute(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
inbound constant.RelayFormat
|
||
upstream constant.RelayFormat
|
||
relayMode int
|
||
want string
|
||
}{
|
||
{"OpenAI→Claude", constant.RelayFormatOpenAI, constant.RelayFormatClaude, int(constant.RelayModeChatCompletions), relayconvert.ConverterOpenAIChatToClaudeMessages},
|
||
{"OpenAI→Gemini", constant.RelayFormatOpenAI, constant.RelayFormatGemini, int(constant.RelayModeChatCompletions), relayconvert.ConverterOpenAIChatToGeminiContent},
|
||
{"OpenAI→Ollama chat", constant.RelayFormatOpenAI, constant.RelayFormatOllama, int(constant.RelayModeChatCompletions), relayconvert.ConverterOpenAIChatToOllama},
|
||
{"OpenAI→Ollama generate 不迁移", constant.RelayFormatOpenAI, constant.RelayFormatOllama, int(constant.RelayModeCompletions), ""},
|
||
{"OpenAI→Ollama embedding 不迁移", constant.RelayFormatOpenAI, constant.RelayFormatOllama, int(constant.RelayModeEmbeddings), ""},
|
||
{"同格式不转换", constant.RelayFormatOpenAI, constant.RelayFormatOpenAI, int(constant.RelayModeChatCompletions), ""},
|
||
{"Claude→OpenAI 反向", constant.RelayFormatClaude, constant.RelayFormatOpenAI, int(constant.RelayModeClaudeMessages), relayconvert.ConverterClaudeMessagesToOpenAIChat},
|
||
{"Gemini→OpenAI 反向", constant.RelayFormatGemini, constant.RelayFormatOpenAI, int(constant.RelayModeGeminiChat), relayconvert.ConverterGeminiContentToOpenAIChat},
|
||
{"Responses→OpenAI", constant.RelayFormatResponses, constant.RelayFormatOpenAI, int(constant.RelayModeResponses), relayconvert.ConverterOpenAIResponsesToOpenAIChat},
|
||
{"OpenAI→Responses", constant.RelayFormatOpenAI, constant.RelayFormatResponses, int(constant.RelayModeChatCompletions), relayconvert.ConverterOpenAIChatToOpenAIResponses},
|
||
{"Claude→Gemini 跨原生", constant.RelayFormatClaude, constant.RelayFormatGemini, int(constant.RelayModeClaudeMessages), relayconvert.ConverterClaudeMessagesToGeminiContent},
|
||
{"Gemini→Claude 跨原生", constant.RelayFormatGemini, constant.RelayFormatClaude, int(constant.RelayModeGeminiChat), relayconvert.ConverterGeminiContentToClaudeMessages},
|
||
{"Responses→Claude 跨原生", constant.RelayFormatResponses, constant.RelayFormatClaude, int(constant.RelayModeResponses), relayconvert.ConverterResponsesToClaudeMessages},
|
||
{"Responses→Gemini 跨原生", constant.RelayFormatResponses, constant.RelayFormatGemini, int(constant.RelayModeResponses), relayconvert.ConverterOpenAIResponsesToGemini},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
got := relaykit_bridge.RequestConverterIDForRoute(tt.inbound, tt.upstream, tt.relayMode)
|
||
if got != tt.want {
|
||
t.Errorf("RequestConverterIDForRoute(%s,%s,%d) = %q, want %q", tt.inbound, tt.upstream, tt.relayMode, got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// nativeClaudeInfo 构造 Claude 入站 + 指定供应商的 RelayInfo(无映射/改写,纯格式判定)
|
||
func nativeClaudeInfo(provider constant.ProviderType, inbound constant.RelayFormat) *common.RelayInfo {
|
||
return &common.RelayInfo{
|
||
InboundFormat: inbound,
|
||
RelayMode: int(constant.RelayModeClaudeMessages),
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(provider),
|
||
Settings: common.ChannelSettings{},
|
||
},
|
||
}
|
||
}
|
||
|
||
// TestEffectiveUpstreamFormat_NativeClaudeEndpoint 另挂 Anthropic 兼容端点的供应商,
|
||
// Claude 入站的有效上游格式必须是 Claude(同格式 → 不转换)。
|
||
//
|
||
// 判成 OpenAI 会两侧同时断链:请求体转成 chat 发到 /anthropic/v1/messages(上游 400),
|
||
// 响应侧又把 Claude 响应体当 OpenAI 解析。
|
||
func TestEffectiveUpstreamFormat_NativeClaudeEndpoint(t *testing.T) {
|
||
// 另挂 Anthropic 端点(ClaudeMessages 走独立地址)
|
||
nativeClaude := []constant.ProviderType{
|
||
constant.ProviderAli, constant.ProviderZhipu, constant.ProviderDeepSeek,
|
||
constant.ProviderMoonshot, constant.ProviderVolcengine, constant.ProviderMiniMax,
|
||
}
|
||
for _, p := range nativeClaude {
|
||
info := nativeClaudeInfo(p, constant.RelayFormatClaude)
|
||
if got := relaykit_bridge.EffectiveUpstreamFormat(info); got != constant.RelayFormatClaude {
|
||
t.Errorf("provider %d: EffectiveUpstreamFormat = %q, want %q", p, got, constant.RelayFormatClaude)
|
||
}
|
||
// 同格式 → 矩阵不得返回转换器
|
||
if id := relaykit_bridge.RequestConverterIDForRoute(constant.RelayFormatClaude,
|
||
relaykit_bridge.EffectiveUpstreamFormat(info), info.RelayMode); id != "" {
|
||
t.Errorf("provider %d: 不应命中转换器, got %q", p, id)
|
||
}
|
||
}
|
||
|
||
// ClaudeMessages 与 chat 共用同一端点 → 上游只说 OpenAI,Claude 入站仍须转换
|
||
openaiOnly := []constant.ProviderType{constant.ProviderXAI, constant.ProviderBaiduV2}
|
||
for _, p := range openaiOnly {
|
||
info := nativeClaudeInfo(p, constant.RelayFormatClaude)
|
||
if got := relaykit_bridge.EffectiveUpstreamFormat(info); got != constant.RelayFormatOpenAI {
|
||
t.Errorf("provider %d: EffectiveUpstreamFormat = %q, want %q", p, got, constant.RelayFormatOpenAI)
|
||
}
|
||
}
|
||
|
||
// 覆盖只对 Claude 入站生效:同一供应商的 OpenAI 入站仍是 OpenAI
|
||
info := nativeClaudeInfo(constant.ProviderDeepSeek, constant.RelayFormatOpenAI)
|
||
info.RelayMode = int(constant.RelayModeChatCompletions)
|
||
if got := relaykit_bridge.EffectiveUpstreamFormat(info); got != constant.RelayFormatOpenAI {
|
||
t.Errorf("OpenAI 入站不应被覆盖: got %q, want %q", got, constant.RelayFormatOpenAI)
|
||
}
|
||
}
|
||
|
||
// TestCanPassThrough_NativeClaudeEndpoint_ClaudeInbound 同一批供应商的 Claude 入站
|
||
// 在无映射/改写时应可原样直连(上游端点本就吃 Claude 体)。
|
||
func TestCanPassThrough_NativeClaudeEndpoint_ClaudeInbound(t *testing.T) {
|
||
info := nativeClaudeInfo(constant.ProviderDeepSeek, constant.RelayFormatClaude)
|
||
if !canPassThrough(info) {
|
||
t.Error("Anthropic 兼容端点渠道的 Claude 入站应可直连")
|
||
}
|
||
|
||
// 有模型映射时仍须经转换(改写模型名),但方向是 Claude→Claude 而非 Claude→OpenAI
|
||
info.ChannelMeta.IsModelMapped = true
|
||
if canPassThrough(info) {
|
||
t.Error("有模型映射时不应直连")
|
||
}
|
||
if got := relaykit_bridge.EffectiveUpstreamFormat(info); got != constant.RelayFormatClaude {
|
||
t.Errorf("EffectiveUpstreamFormat = %q, want %q", got, constant.RelayFormatClaude)
|
||
}
|
||
}
|
||
|
||
// vertexInfo 构造 Vertex 渠道的 RelayInfo(模型名驱动协议分流)。
|
||
func vertexInfo(model string, inbound constant.RelayFormat, mode constant.RelayMode) *common.RelayInfo {
|
||
return &common.RelayInfo{
|
||
InboundFormat: inbound,
|
||
ClientFormat: inbound,
|
||
RelayMode: int(mode),
|
||
OriginModelName: model,
|
||
ChannelMeta: &common.ChannelMeta{
|
||
ChannelType: int(constant.ProviderVertex),
|
||
UpstreamModelName: model,
|
||
Settings: common.ChannelSettings{},
|
||
},
|
||
}
|
||
}
|
||
|
||
// TestEffectiveUpstreamFormat_VertexIsModelDriven Vertex 是模型名驱动的双协议上游:
|
||
// 协议判定必须跟着模型走,否则矩阵按渠道类型判成 openai,把请求体转成 chat
|
||
// 发到 :generateContent / :rawPredict 原生端点,上游必然 400。
|
||
func TestEffectiveUpstreamFormat_VertexIsModelDriven(t *testing.T) {
|
||
cases := []struct {
|
||
model string
|
||
want constant.RelayFormat
|
||
}{
|
||
{"gemini-2.5-pro", constant.RelayFormatGemini},
|
||
{"claude-sonnet-4-5", constant.RelayFormatClaude},
|
||
}
|
||
for _, tc := range cases {
|
||
// 四种入站格式下有效上游格式都应等于模型对应的原生协议
|
||
for _, inbound := range []constant.RelayFormat{
|
||
constant.RelayFormatOpenAI, constant.RelayFormatClaude,
|
||
constant.RelayFormatGemini, constant.RelayFormatResponses,
|
||
} {
|
||
info := vertexInfo(tc.model, inbound, constant.RelayModeChatCompletions)
|
||
if got := relaykit_bridge.EffectiveUpstreamFormat(info); got != tc.want {
|
||
t.Errorf("模型 %s / %s 入站: EffectiveUpstreamFormat = %q, want %q",
|
||
tc.model, inbound, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestCanPassThrough_VertexNativeInboundMatches 与模型协议相同的入站可直连
|
||
// (Gemini 模型 + Gemini 入站:体与 :generateContent 端点天然匹配)。
|
||
func TestCanPassThrough_VertexNativeInboundMatches(t *testing.T) {
|
||
info := vertexInfo("gemini-2.5-pro", constant.RelayFormatGemini, constant.RelayModeGeminiChat)
|
||
if !canPassThrough(info) {
|
||
t.Error("Vertex Gemini 模型的 Gemini 入站应可直连")
|
||
}
|
||
|
||
// OpenAI 入站与 Gemini 端点不匹配,必须经转换
|
||
info = vertexInfo("gemini-2.5-pro", constant.RelayFormatOpenAI, constant.RelayModeChatCompletions)
|
||
if canPassThrough(info) {
|
||
t.Error("Vertex Gemini 模型的 OpenAI 入站不得直连(chat 体发不了 :generateContent)")
|
||
}
|
||
}
|
||
|
||
// TestCanPassThrough_VertexClaudeNeverPassesThrough Vertex 的 Claude 模型即使入站
|
||
// 就是 Claude 格式也不得直连:rawPredict 端点要求体内带 anthropic_version、不接受
|
||
// model 字段,这段改写只在 ConvertRequest / PostProcessConvertedRequest 中执行,
|
||
// 直连会绕过两者导致上游 400。显式开启 pass_through_body_enabled 也不例外。
|
||
func TestCanPassThrough_VertexClaudeNeverPassesThrough(t *testing.T) {
|
||
info := vertexInfo("claude-sonnet-4-5", constant.RelayFormatClaude, constant.RelayModeClaudeMessages)
|
||
if canPassThrough(info) {
|
||
t.Error("Vertex Claude 模型的 Claude 入站不得直连(会绕过 anthropic_version 注入)")
|
||
}
|
||
|
||
info.ChannelMeta.Settings.PassThroughBodyEnabled = true
|
||
if canPassThrough(info) {
|
||
t.Error("即使显式开启直连,Vertex Claude 模型仍不得原样透传")
|
||
}
|
||
}
|