* 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):补充手写测试数据集
238 lines
7.8 KiB
Go
238 lines
7.8 KiB
Go
package helper
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"net"
|
||
"net/url"
|
||
"strings"
|
||
"syscall"
|
||
"testing"
|
||
|
||
"github.com/qianfree/team-api/relay/constant"
|
||
)
|
||
|
||
// fakeTimeoutError 是一个独立的 net.Error 实现,Timeout() 返回 true,
|
||
// 用于验证 categorizeTransportError 中 net.Error.Timeout() 分支(区别于 context.DeadlineExceeded)。
|
||
type fakeTimeoutError struct{}
|
||
|
||
func (fakeTimeoutError) Error() string { return "i/o timeout" }
|
||
func (fakeTimeoutError) Timeout() bool { return true }
|
||
func (fakeTimeoutError) Temporary() bool { return true }
|
||
|
||
// 上游域名泄露的典型形态:client.Do 失败返回的 *url.Error 含完整 URL。
|
||
func leakyURLError(inner error) *url.Error {
|
||
return &url.Error{
|
||
Op: "Post",
|
||
URL: "https://api.openai.com/v1/chat/completions",
|
||
Err: inner,
|
||
}
|
||
}
|
||
|
||
func TestSafeUpstreamErrorMessage_TransportCategories(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
err error
|
||
want string
|
||
}{
|
||
{"超时-context截止", leakyURLError(context.DeadlineExceeded), "请求超时,请重试"},
|
||
{"超时-i/o timeout", leakyURLError(fakeTimeoutError{}), "请求超时,请重试"},
|
||
{"客户端取消", leakyURLError(context.Canceled), "请求已取消"},
|
||
{"连接拒绝", leakyURLError(syscall.ECONNREFUSED), "服务暂时不可用,请稍后重试"},
|
||
{"DNS失败", leakyURLError(&net.DNSError{Err: "no such host", Name: "api.openai.com"}), "服务暂时不可用,请稍后重试"},
|
||
{"连接重置", leakyURLError(syscall.ECONNRESET), "连接中断,请重试"},
|
||
{"EOF", leakyURLError(io.EOF), "连接中断,请重试"},
|
||
{"意外EOF", leakyURLError(io.ErrUnexpectedEOF), "连接中断,请重试"},
|
||
{"其余传输层错误", leakyURLError(errors.New("tls: handshake failure")), "服务暂时不可用,请稍后重试"},
|
||
}
|
||
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
got := SafeUpstreamErrorMessage(c.err)
|
||
if got != c.want {
|
||
t.Fatalf("got %q, want %q", got, c.want)
|
||
}
|
||
// 核心安全断言:任何传输层错误的归一化结果都不得包含上游域名或协议
|
||
if strings.Contains(got, "openai.com") {
|
||
t.Errorf("结果泄露了上游域名: %q", got)
|
||
}
|
||
if strings.Contains(got, "http") {
|
||
t.Errorf("结果泄露了协议/URL: %q", got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestSafeUpstreamErrorMessage_Nil(t *testing.T) {
|
||
if got := SafeUpstreamErrorMessage(nil); got != "" {
|
||
t.Fatalf("nil 应返回空串,got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestSafeUpstreamErrorMessage_BusinessErrorPreservedButRedacted(t *testing.T) {
|
||
// 非传输层错误:消息保留,但其中的 URL/IP 必须被抹除
|
||
err := errors.New(`dial upstream websocket failed: Get "https://api.x.com/v1": connection refused at 10.0.0.5`)
|
||
got := SafeUpstreamErrorMessage(err)
|
||
|
||
if strings.Contains(got, "https://api.x.com") {
|
||
t.Errorf("URL 未被抹除: %q", got)
|
||
}
|
||
if strings.Contains(got, "10.0.0.5") {
|
||
t.Errorf("IP 未被抹除: %q", got)
|
||
}
|
||
if !strings.Contains(got, "dial upstream websocket failed") {
|
||
t.Errorf("业务消息正文不应被破坏: %q", got)
|
||
}
|
||
if !strings.Contains(got, "[redacted]") {
|
||
t.Errorf("应包含 [redacted] 占位: %q", got)
|
||
}
|
||
}
|
||
|
||
func TestSafeUpstreamErrorMessage_LegitimateBusinessMessageUntouched(t *testing.T) {
|
||
// 上游 provider 的合法业务错误(无 URL/IP)应原样保留
|
||
cases := []string{
|
||
"rate limit exceeded",
|
||
"invalid model: gpt-5-mini",
|
||
"This model's maximum context length is 8192 tokens.",
|
||
}
|
||
for _, msg := range cases {
|
||
got := SafeUpstreamErrorMessage(errors.New(msg))
|
||
if got != msg {
|
||
t.Errorf("合法消息被改动: got %q, want %q", got, msg)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSafeUpstreamErrorMessage_WrappedRelayError(t *testing.T) {
|
||
// 端到端:编排层把 *url.Error 塞进 NewUpstreamError 的 Cause,
|
||
// 传入 SafeUpstreamErrorMessage 应能经 errors.As 找到 *url.Error 并归一化。
|
||
relayErr := constant.NewUpstreamError(502, "upstream request failed",
|
||
leakyURLError(&net.DNSError{Err: "no such host", Name: "api.openai.com"}))
|
||
|
||
got := SafeUpstreamErrorMessage(relayErr)
|
||
if got != "服务暂时不可用,请稍后重试" {
|
||
t.Fatalf("经 RelayError 包装后归一化失败,got %q", got)
|
||
}
|
||
if strings.Contains(got, "openai.com") || strings.Contains(got, "https") {
|
||
t.Errorf("仍泄露上游信息: %q", got)
|
||
}
|
||
}
|
||
|
||
func TestRedactMessage(t *testing.T) {
|
||
cases := []struct {
|
||
in string
|
||
want string
|
||
}{
|
||
{"", ""},
|
||
{"no sensitive info here", "no sensitive info here"},
|
||
{
|
||
`Post "https://api.openai.com/v1/chat": dial tcp: i/o timeout`,
|
||
`Post "[redacted]": dial tcp: i/o timeout`,
|
||
},
|
||
{
|
||
`connect to http://10.0.0.5:8080 failed`,
|
||
`connect to [redacted] failed`,
|
||
},
|
||
{
|
||
"https://sub.example.co.uk/a/b?x=1 and 192.168.1.1 both redacted",
|
||
"[redacted] and [redacted] both redacted",
|
||
},
|
||
}
|
||
for _, c := range cases {
|
||
got := RedactMessage(c.in)
|
||
if got != c.want {
|
||
t.Errorf("RedactMessage(%q)\n got %q\n want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestUnwrapUpstreamErrorMessage 上游错误信封解包:各协议形状都应提取出可读消息,
|
||
// 非错误信封的文本(网关自身消息)必须原样返回。
|
||
func TestUnwrapUpstreamErrorMessage(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
raw string
|
||
want string
|
||
}{
|
||
{
|
||
name: "OpenAI 信封",
|
||
raw: `{"error":{"message":"rate limit exceeded","type":"rate_limit_error","code":"rate_limit"}}`,
|
||
want: "rate limit exceeded",
|
||
},
|
||
{
|
||
name: "Claude 信封(顶层带 type)",
|
||
raw: `{"type":"error","error":{"type":"rate_limit_error","message":"number of request tokens has exceeded"}}`,
|
||
want: "number of request tokens has exceeded",
|
||
},
|
||
{
|
||
name: "Gemini 信封",
|
||
raw: `{"error":{"code":429,"message":"Resource has been exhausted","status":"RESOURCE_EXHAUSTED"}}`,
|
||
want: "Resource has been exhausted",
|
||
},
|
||
{
|
||
name: "Gemini 数组包装",
|
||
raw: `[{"error":{"code":400,"message":"API key not valid","status":"INVALID_ARGUMENT"}}]`,
|
||
want: "API key not valid",
|
||
},
|
||
{
|
||
name: "Ollama 信封(error 为字符串)",
|
||
raw: `{"error":"model 'llama3' not found"}`,
|
||
want: "model 'llama3' not found",
|
||
},
|
||
{
|
||
name: "网关自身消息(非 JSON)原样返回",
|
||
raw: "convert request (openai_to_claude): unexpected content part",
|
||
want: "convert request (openai_to_claude): unexpected content part",
|
||
},
|
||
{
|
||
name: "空字符串",
|
||
raw: "",
|
||
want: "",
|
||
},
|
||
{
|
||
name: "畸形 JSON 原样返回",
|
||
raw: `{"error":{"message":`,
|
||
want: `{"error":{"message":`,
|
||
},
|
||
{
|
||
name: "无 error 字段的 JSON 原样返回",
|
||
raw: `{"detail":"something else"}`,
|
||
want: `{"detail":"something else"}`,
|
||
},
|
||
{
|
||
name: "HTML 错误页原样返回",
|
||
raw: "<html><body>502 Bad Gateway</body></html>",
|
||
want: "<html><body>502 Bad Gateway</body></html>",
|
||
},
|
||
{
|
||
name: "error.message 为空时回退原文",
|
||
raw: `{"error":{"type":"server_error"}}`,
|
||
want: `{"error":{"type":"server_error"}}`,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := UnwrapUpstreamErrorMessage(tt.raw); got != tt.want {
|
||
t.Errorf("UnwrapUpstreamErrorMessage(%q) = %q, want %q", tt.raw, got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestUnwrapUpstreamErrorMessage_NotDoubleEncoded 回归守卫:解包结果不得再是 JSON 信封。
|
||
// 这是本函数存在的原因——客户端曾收到 message 字段里嵌一整个转义的上游错误 JSON。
|
||
func TestUnwrapUpstreamErrorMessage_NotDoubleEncoded(t *testing.T) {
|
||
for _, raw := range []string{
|
||
`{"error":{"message":"rate limit exceeded","type":"rate_limit_error"}}`,
|
||
`{"type":"error","error":{"type":"rate_limit_error","message":"rate limit exceeded"}}`,
|
||
`{"error":{"code":429,"message":"rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}`,
|
||
} {
|
||
got := UnwrapUpstreamErrorMessage(raw)
|
||
if strings.Contains(got, `"error"`) || strings.Contains(got, "{") {
|
||
t.Errorf("解包后仍含 JSON 信封结构: %q(输入 %q)", got, raw)
|
||
}
|
||
}
|
||
}
|