Files
team-api/relay/handler/e2e_relay_stream_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

445 lines
20 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
// e2e_relay_stream_test.go — 端到端流式与错误链路(假上游)。
//
// 与 e2e_relay_test.go 共用驱动(runE2E),覆盖非流式测不到的两类性质:
// - 流式终止语义:各客户端协议的收尾契约互不相同,写错会让官方 SDK 直接报错
// (Gemini 客户端多发一个 [DONE] 会让 @google/genai 的 JSON.parse 抛 SyntaxError,
// Responses 客户端缺 response.completed 会让 codex 一直挂起等待);
// - 错误映射:上游故障时客户端必须收到**入站协议的原生错误格式**,
// 而不是网关统一 JSON 或另一种协议的错误体。
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/qianfree/team-api/relay/constant"
)
// ---------- 流式上游语料 ----------
var e2eUpstreamStreams = map[constant.RelayFormat]string{
constant.RelayFormatOpenAI: "data: {\"id\":\"chatcmpl-up\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"upstream-model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\n" +
"data: {\"id\":\"chatcmpl-up\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"upstream-model\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi there!\"},\"finish_reason\":null}]}\n\n" +
"data: {\"id\":\"chatcmpl-up\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"upstream-model\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":7,\"total_tokens\":19}}\n\n" +
"data: [DONE]\n\n",
constant.RelayFormatClaude: "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_up\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"upstream-model\",\"stop_reason\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":1}}}\n\n" +
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" +
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi there!\"}}\n\n" +
"event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" +
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":7}}\n\n" +
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
constant.RelayFormatGemini: "data: {\"candidates\":[{\"index\":0,\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi there!\"}]}}]}\n\n" +
"data: {\"candidates\":[{\"index\":0,\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":12,\"candidatesTokenCount\":7,\"totalTokenCount\":19}}\n\n",
constant.RelayFormatResponses: "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_up\",\"object\":\"response\",\"created_at\":1700000000,\"status\":\"in_progress\",\"model\":\"upstream-model\"}}\n\n" +
"event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_up\",\"output_index\":0,\"content_index\":0,\"delta\":\"Hi there!\"}\n\n" +
"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_up\",\"object\":\"response\",\"created_at\":1700000000,\"status\":\"completed\",\"model\":\"upstream-model\",\"usage\":{\"input_tokens\":12,\"output_tokens\":7,\"total_tokens\":19}}}\n\n" +
"data: [DONE]\n\n",
constant.RelayFormatOllama: "{\"model\":\"upstream-model\",\"created_at\":\"2026-01-01T00:00:00Z\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi there!\"},\"done\":false}\n" +
"{\"model\":\"upstream-model\",\"created_at\":\"2026-01-01T00:00:01Z\",\"message\":{\"role\":\"assistant\",\"content\":\"\"},\"done\":true,\"done_reason\":\"stop\",\"prompt_eval_count\":12,\"eval_count\":7}\n",
}
// ---------- SSE 解析 ----------
type sseFrame struct {
event string
data string
}
// parseSSE 把客户端收到的字节流拆成帧(按空行分隔,聚合 event/data 行)。
func parseSSE(raw string) []sseFrame {
var frames []sseFrame
for _, block := range strings.Split(raw, "\n\n") {
block = strings.TrimSpace(block)
if block == "" {
continue
}
var f sseFrame
var dataLines []string
for _, line := range strings.Split(block, "\n") {
switch {
case strings.HasPrefix(line, "event:"):
f.event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
if len(dataLines) == 0 && f.event == "" {
continue // 保活注释等非事件块
}
f.data = strings.Join(dataLines, "\n")
frames = append(frames, f)
}
return frames
}
func eventNames(frames []sseFrame) []string {
out := make([]string, 0, len(frames))
for _, f := range frames {
out = append(out, f.event)
}
return out
}
// ---------- 流式方向矩阵 ----------
func e2eStreamCases() []e2eCase {
mk := func(name string, provider constant.ProviderType, inbound constant.RelayFormat,
mode constant.RelayMode, upstreamFormat constant.RelayFormat) e2eCase {
return e2eCase{
name: name,
provider: provider,
inbound: inbound,
mode: mode,
stream: true,
clientBody: e2eClientRequests[inbound],
wantUpstreamFormat: upstreamFormat,
upstreamContentType: "text/event-stream",
upstreamBody: e2eUpstreamStreams[upstreamFormat],
}
}
return []e2eCase{
// OpenAI 客户端(收尾需 [DONE])
mk("openai客户端←claude上游", constant.ProviderClaude, constant.RelayFormatOpenAI,
constant.RelayModeChatCompletions, constant.RelayFormatClaude),
mk("openai客户端←gemini上游", constant.ProviderGemini, constant.RelayFormatOpenAI,
constant.RelayModeChatCompletions, constant.RelayFormatGemini),
mk("openai客户端←ollama上游", constant.ProviderOllama, constant.RelayFormatOpenAI,
constant.RelayModeChatCompletions, constant.RelayFormatOllama),
// Claude 客户端(收尾需 message_stop,禁 [DONE])
mk("claude客户端←openai上游", constant.ProviderOpenAI, constant.RelayFormatClaude,
constant.RelayModeClaudeMessages, constant.RelayFormatOpenAI),
mk("claude客户端←gemini上游", constant.ProviderGemini, constant.RelayFormatClaude,
constant.RelayModeClaudeMessages, constant.RelayFormatGemini),
// Gemini 客户端(收尾禁 [DONE]——官方 SDK 对每个 data 帧 JSON.parse)
mk("gemini客户端←openai上游", constant.ProviderOpenAI, constant.RelayFormatGemini,
constant.RelayModeGeminiChat, constant.RelayFormatOpenAI),
mk("gemini客户端←claude上游", constant.ProviderClaude, constant.RelayFormatGemini,
constant.RelayModeGeminiChat, constant.RelayFormatClaude),
// Responses 客户端(收尾需 response.completed)
mk("responses客户端←openai上游", constant.ProviderOpenAI, constant.RelayFormatResponses,
constant.RelayModeResponses, constant.RelayFormatOpenAI),
mk("responses客户端←claude上游", constant.ProviderClaude, constant.RelayFormatResponses,
constant.RelayModeResponses, constant.RelayFormatClaude),
mk("responses客户端←gemini上游", constant.ProviderGemini, constant.RelayFormatResponses,
constant.RelayModeResponses, constant.RelayFormatGemini),
}
}
// TestE2E_StreamTerminalSemantics 断言 3(流式闭环):客户端收到的 SSE 必须符合
// 入站协议的收尾契约,且每个 data 帧都是可解析的 JSON、用量可被计费捕获。
func TestE2E_StreamTerminalSemantics(t *testing.T) {
for _, c := range e2eStreamCases() {
t.Run(c.name, func(t *testing.T) {
res := runE2E(t, c)
if res.err != nil {
t.Fatalf("链路失败: %v", res.err)
}
frames := parseSSE(res.clientBody)
if len(frames) == 0 {
t.Fatalf("客户端未收到任何 SSE 帧\n原始输出: %s", truncate([]byte(res.clientBody)))
}
// 每个 data 帧必须是合法 JSON([DONE] 哨兵除外):
// 写出空 data 或畸形 JSON 会让客户端 SDK 解析失败并中止整条请求,
// 而网关侧只会看到 ctx 取消、记成 client_gone(问题被掩盖)
hasDone := false
for i, f := range frames {
if f.data == "[DONE]" {
hasDone = true
continue
}
if f.data == "" {
t.Errorf("第 %d 帧(event=%q)的 data 为空——客户端 JSON.parse(\"\") 会抛错", i, f.event)
continue
}
var probe any
if err := json.Unmarshal([]byte(f.data), &probe); err != nil {
t.Errorf("第 %d 帧(event=%q)的 data 不是合法 JSON: %v\n内容: %s", i, f.event, err, truncate([]byte(f.data)))
}
}
// 收尾契约按客户端协议区分
switch c.inbound {
case constant.RelayFormatOpenAI:
if !hasDone {
t.Error("OpenAI 客户端流缺少 data: [DONE] 收尾哨兵")
}
case constant.RelayFormatGemini:
if hasDone {
t.Error("Gemini 客户端流写出了 [DONE]——官方 SDK(@google/genai)对每个 data 帧做 JSON.parse,会抛 SyntaxError")
}
if !strings.Contains(res.clientBody, "finishReason") {
t.Errorf("Gemini 客户端流缺少带 finishReason 的终止帧\n原始输出: %s", truncate([]byte(res.clientBody)))
}
case constant.RelayFormatClaude:
if hasDone {
t.Error("Claude 客户端流写出了 [DONE]——Claude 协议以 message_stop 事件收尾,不使用该哨兵")
}
if !containsEvent(frames, "message_stop") {
t.Errorf("Claude 客户端流缺少 message_stop 终止事件(客户端会一直等待)\n事件序列: %v", eventNames(frames))
}
case constant.RelayFormatResponses:
if !containsEvent(frames, "response.completed") {
t.Errorf("Responses 客户端流缺少 response.completed 终止事件(codex 等客户端会挂起)\n事件序列: %v", eventNames(frames))
}
}
if !strings.Contains(res.clientBody, "Hi there!") {
t.Errorf("上游回答内容在流式转换中丢失\n原始输出: %s", truncate([]byte(res.clientBody)))
}
// 计费闭环
if res.usage == nil {
t.Fatal("流式未返回用量(计费将按 0 token 结算)")
}
if res.usage.PromptTokens == 0 || res.usage.CompletionTokens == 0 {
t.Errorf("流式用量提取不完整: prompt=%d completion=%d(上游语料为 12/7)",
res.usage.PromptTokens, res.usage.CompletionTokens)
}
})
}
}
func containsEvent(frames []sseFrame, name string) bool {
for _, f := range frames {
if f.event == name {
return true
}
// 无 event 行时退回按 data 里的 type 字段判定(部分方向以纯 data 帧承载事件)
if f.event == "" && strings.Contains(f.data, `"type":"`+name+`"`) {
return true
}
}
return false
}
// ---------- 错误映射 ----------
// errorShapeCheck 各入站协议的原生错误体判据:客户端必须拿到自己协议的错误格式,
// 而不是网关统一 JSON、也不是另一种协议的错误体(SDK 会解析失败)。
var errorShapeCheck = map[constant.RelayFormat]func(t *testing.T, body string){
constant.RelayFormatOpenAI: func(t *testing.T, body string) {
t.Helper()
var obj struct {
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(body), &obj); err != nil || obj.Error == nil {
t.Errorf("OpenAI 客户端应收到 {\"error\":{...}} 形态的错误体(err=%v)\n实际内容: %s", err, truncate([]byte(body)))
}
},
constant.RelayFormatClaude: func(t *testing.T, body string) {
t.Helper()
var obj struct {
Type string `json:"type"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(body), &obj); err != nil || obj.Error == nil || obj.Type != "error" {
t.Errorf("Claude 客户端应收到 {\"type\":\"error\",\"error\":{...}} 形态的错误体(err=%v)\n实际内容: %s",
err, truncate([]byte(body)))
}
},
constant.RelayFormatGemini: func(t *testing.T, body string) {
t.Helper()
// Gemini 错误体为 {"error":{"code","message","status"}}(也接受数组包装形态)
trimmed := strings.TrimSpace(body)
if strings.HasPrefix(trimmed, "[") {
trimmed = strings.TrimSuffix(strings.TrimPrefix(trimmed, "["), "]")
}
var obj struct {
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(trimmed), &obj); err != nil || obj.Error == nil {
t.Errorf("Gemini 客户端应收到 {\"error\":{...}} 形态的错误体(err=%v)\n实际内容: %s", err, truncate([]byte(body)))
}
},
constant.RelayFormatResponses: func(t *testing.T, body string) {
t.Helper()
var obj struct {
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(body), &obj); err != nil || obj.Error == nil {
t.Errorf("Responses 客户端应收到 {\"error\":{...}} 形态的错误体(err=%v)\n实际内容: %s", err, truncate([]byte(body)))
}
},
}
// hostErrorWriterFor 返回宿主按入站格式分派的错误写出器
// (与 internal/handler/relay 的真实接线一致:/v1/messages → Claude、
// /v1beta/* → Gemini、chat 与 responses → OpenAI)。
func hostErrorWriterFor(inbound constant.RelayFormat) func(http.ResponseWriter, error) {
switch inbound {
case constant.RelayFormatClaude:
return WriteClaudeRelayError
case constant.RelayFormatGemini:
return WriteGeminiRelayError
default:
return WriteRelayError
}
}
// TestE2E_UpstreamErrorMapping 断言 4(错误映射):上游返回 4xx/5xx 时,
// 客户端最终收到的必须是入站协议的原生错误格式,且链路向上报错
// (供调度按上游故障扣健康分 / 换渠道)。
//
// 两种写出路径都要覆盖,否则测试会在「adaptor 没写、宿主才写」的方向上静默空过:
// - adaptor 内写出(置 ResponseWritten):直接校验其字节;
// - adaptor 只上抛(未写出):按宿主真实接线调用 Write*RelayError 再校验。
//
// 两条路径的判定结果也一并断言(expectAdaptorWrites),行为漂移时立即可见。
func TestE2E_UpstreamErrorMapping(t *testing.T) {
upstreamErrBodies := map[constant.RelayFormat]string{
constant.RelayFormatOpenAI: `{"error":{"message":"rate limit exceeded","type":"rate_limit_error","code":"rate_limit"}}`,
constant.RelayFormatClaude: `{"type":"error","error":{"type":"rate_limit_error","message":"rate limit exceeded"}}`,
constant.RelayFormatGemini: `{"error":{"code":429,"message":"rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}`,
}
// 当前各方向的错误写出归属:OpenAI 上游的三个入站方向由 adaptor 就地改写为
// 入站协议错误体(置 ResponseWritten,上层不再重写、也不换渠道重试);
// Claude/Gemini 上游方向只上抛错误,由宿主统一写出(保留换渠道重试的可能)。
expectAdaptorWrites := map[string]bool{
"claude→openai": true,
"gemini→openai": true,
"responses→openai": true,
}
for _, c := range e2eNonStreamCases() {
// 同格式方向不参与:上游错误原样透传,没有跨协议映射行为可测
//(openai→openai / claude→claude 直连基线、gemini→vertex(gemini) 等)
if c.inbound == c.wantUpstreamFormat {
continue
}
errBody, ok := upstreamErrBodies[c.wantUpstreamFormat]
if !ok {
continue // 上游格式无错误语料(ollama 等)
}
t.Run(c.name, func(t *testing.T) {
ec := c
ec.upstreamStatus = http.StatusTooManyRequests
ec.upstreamBody = errBody
res := runE2E(t, ec)
// 上游故障必须向上报错:吞掉错误会让调度器把故障渠道判成健康
if res.err == nil {
t.Fatal("上游 429 未向上返回错误(调度器无法据此扣健康分 / 换渠道)")
}
adaptorWrote := res.clientBody != ""
if want := expectAdaptorWrites[c.name]; adaptorWrote != want {
t.Errorf("错误写出归属变化:adaptor 写出 = %v,期望 %v\n"+
"→ 若为有意变更请更新 expectAdaptorWrites;否则可能是 ResponseWritten 标记漏置/误置"+
"(漏置导致错误体被写两次,误置导致本可重试的请求不再换渠道)", adaptorWrote, want)
}
// 取客户端最终可见的字节:adaptor 已写出就用它,否则按宿主接线补写
clientBody := res.clientBody
if !adaptorWrote {
rec := httptest.NewRecorder()
hostErrorWriterFor(c.inbound)(rec, res.err)
clientBody = rec.Body.String()
if clientBody == "" {
t.Fatal("adaptor 与宿主都没有写出错误体,客户端将收到空响应")
}
}
if check, ok := errorShapeCheck[c.inbound]; ok {
check(t, clientBody)
}
// 上游的人类可读错误消息必须传达到客户端,而不是被替换成通用文案或整坨转义 JSON
if !strings.Contains(clientBody, "rate limit exceeded") {
t.Errorf("上游错误消息在映射中丢失\n实际内容: %s", truncate([]byte(clientBody)))
}
if strings.Contains(clientBody, `\"error\"`) {
t.Errorf("错误体被双重编码(上游 JSON 原文被塞进 message 字段),客户端拿到的是一坨转义 JSON 而非可读消息\n实际内容: %s",
truncate([]byte(clientBody)))
}
})
}
}
// TestE2E_ClaudeInboundErrorTypeMapping Claude 入站错误类型映射:
// error.type 是 Anthropic SDK 的分类与退避依据,必须按上游真实错误类型/状态码映射,
// 不能一律 api_error(限流被当成服务端故障会让客户端退避策略失效)。
func TestE2E_ClaudeInboundErrorTypeMapping(t *testing.T) {
cases := []struct {
name string
upstreamStatus int
upstreamBody string
wantType string
}{
{"限流", http.StatusTooManyRequests,
`{"error":{"message":"rate limit exceeded","type":"rate_limit_error"}}`, "rate_limit_error"},
{"鉴权失败", http.StatusUnauthorized,
`{"error":{"message":"invalid api key","type":"authentication_error"}}`, "authentication_error"},
{"参数非法", http.StatusBadRequest,
`{"error":{"message":"bad param","type":"invalid_request_error"}}`, "invalid_request_error"},
{"服务端故障", http.StatusInternalServerError,
`{"error":{"message":"boom","type":"server_error"}}`, "api_error"},
// 上游未给出 type:按状态码兜底,仍优于一律 api_error
{"无类型按状态码兜底", http.StatusTooManyRequests,
`{"error":{"message":"slow down"}}`, "rate_limit_error"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := runE2E(t, e2eCase{
provider: constant.ProviderOpenAI,
inbound: constant.RelayFormatClaude,
mode: constant.RelayModeClaudeMessages,
clientBody: e2eClientRequests[constant.RelayFormatClaude],
upstreamStatus: tc.upstreamStatus,
upstreamBody: tc.upstreamBody,
})
if res.clientBody == "" {
t.Fatal("adaptor 未写出 Claude 错误体")
}
var obj struct {
Type string `json:"type"`
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(res.clientBody), &obj); err != nil {
t.Fatalf("错误体不是合法 JSON: %v\n内容: %s", err, res.clientBody)
}
if obj.Type != "error" {
t.Errorf("顶层 type = %q,期望 \"error\"", obj.Type)
}
if obj.Error.Type != tc.wantType {
t.Errorf("error.type = %q,期望 %q(SDK 据此分类与退避)", obj.Error.Type, tc.wantType)
}
if strings.HasPrefix(obj.Error.Message, "{") {
t.Errorf("error.message 是 JSON 原文而非可读消息: %s", obj.Error.Message)
}
if res.clientStatus != tc.upstreamStatus {
t.Errorf("客户端状态码 = %d,期望 %d", res.clientStatus, tc.upstreamStatus)
}
})
}
}