Files
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

524 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_test.go — 协议代理链路的端到端集成测试(假上游)。
//
// 为什么需要这一层:既有三层防线(wire 保真 / 能力守恒 / 金样本)与矩阵守卫都是
// 静态或单元维度的——分别验证「DTO 形状对不对」「转换器输出对不对」「该不该转、
// URL 拿不拿得到」。但没有任何测试走过完整链路,于是「每个单元各自正确、缝隙处错配」
// 的问题能逃过全部测试:典型如 vertex(URL 不报错但端点吃的是原生格式,矩阵却发 chat 体)
// 与 GeminiChat mode 漏配(转换器和响应桥都就位、DoRequest 处 0ms 断链)。
//
// 本文件用 httptest 假上游驱动真实链路:
//
// convertRequestBody → adaptor.DoRequest → 假上游(捕获 URL/Header/Body)
// → 返回上游格式响应 → adaptor.DoResponse → 客户端可见字节
//
// 断言三条单元测试给不了的性质:
// 1. 体格式 = 端点格式:上游收到的请求体必须符合该端点期望的协议格式;
// 2. 响应闭环:客户端最终收到的字节必须符合入站协议的 wire 契约(含流式终止语义);
// 3. 错误映射:上游故障时客户端拿到的是入站协议的原生错误格式。
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/qianfree/team-api/relay/channel"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
)
// ---------- 假上游 ----------
// fakeUpstream 捕获网关实际发出的请求,并按配置返回响应。
type fakeUpstream struct {
srv *httptest.Server
mu sync.Mutex
gotPath string
gotQuery string
gotBody []byte
gotHeaders http.Header
gotCount int
status int
contentType string
body string
}
func newFakeUpstream(status int, contentType, body string) *fakeUpstream {
up := &fakeUpstream{status: status, contentType: contentType, body: body}
up.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
up.mu.Lock()
up.gotPath = r.URL.Path
up.gotQuery = r.URL.RawQuery
up.gotBody = raw
up.gotHeaders = r.Header.Clone()
up.gotCount++
status, ct, body := up.status, up.contentType, up.body
up.mu.Unlock()
w.Header().Set("Content-Type", ct)
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}))
return up
}
func (u *fakeUpstream) close() { u.srv.Close() }
func (u *fakeUpstream) request() (path, query string, body []byte, headers http.Header, count int) {
u.mu.Lock()
defer u.mu.Unlock()
return u.gotPath, u.gotQuery, u.gotBody, u.gotHeaders, u.gotCount
}
// ---------- 链路驱动 ----------
type e2eCase struct {
name string
provider constant.ProviderType
inbound constant.RelayFormat
mode constant.RelayMode
stream bool
// 渠道能力开关(Responses 桥接方向用)
chatViaResponses bool
supportsResponses bool
// upstreamModel 覆盖上游模型名(默认 "upstream-model")。
// 模型名驱动协议的上游(Vertex:含 "claude" 走 Anthropic 端点、否则走 Gemini 端点)
// 必须显式指定,否则测不出该渠道的协议分流。
upstreamModel string
clientBody string
// 上游侧期望
wantUpstreamFormat constant.RelayFormat
wantPathContains string
// wantUpstreamShape 覆盖按 wantUpstreamFormat 查表得到的结构签名。
// 供应商私有后处理会让体偏离标准格式时使用(如 Vertex 的 Anthropic 端点)。
wantUpstreamShape *shapeSpec
// 假上游返回
upstreamStatus int
upstreamContentType string
upstreamBody string
}
type e2eResult struct {
upstream *fakeUpstream
clientStatus int
clientBody string
clientHeader http.Header
usage *common.Usage
err error
}
// runE2E 驱动一次完整代理链路,返回上游侧与客户端侧的可观测结果。
func runE2E(t *testing.T, c e2eCase) e2eResult {
t.Helper()
ct := c.upstreamContentType
if ct == "" {
ct = "application/json"
}
status := c.upstreamStatus
if status == 0 {
status = http.StatusOK
}
up := newFakeUpstream(status, ct, c.upstreamBody)
t.Cleanup(up.close)
upstreamModel := c.upstreamModel
if upstreamModel == "" {
upstreamModel = "upstream-model"
}
info := &common.RelayInfo{
Context: context.Background(),
RequestID: "e2e-req-1",
RelayMode: int(c.mode),
IsStream: c.stream,
OriginModelName: "origin-model",
InboundFormat: c.inbound,
ClientFormat: c.inbound,
StreamStatus: common.NewStreamStatus(),
ChannelMeta: &common.ChannelMeta{
ChannelID: 1,
ChannelType: int(c.provider),
ChannelName: "e2e-channel",
BaseURL: up.srv.URL,
ApiKey: "sk-e2e-test",
UpstreamModelName: upstreamModel,
ChatViaResponses: c.chatViaResponses,
SupportsResponses: c.supportsResponses,
},
}
// chat_via_responses 渠道的 chat 入站:由 relay_handler 置位,此处按真实链路模拟
if c.chatViaResponses && c.mode == constant.RelayModeChatCompletions {
info.UseResponsesAPI = true
}
ctx := context.Background()
adaptor := channel.GetAdaptor(int(c.provider))
if adaptor == nil {
t.Fatalf("provider %d 没有注册 adaptor", c.provider)
}
adaptor.Init(info)
reqBody, err := convertRequestBody(ctx, info, []byte(c.clientBody), adaptor)
if err != nil {
return e2eResult{upstream: up, err: err}
}
resp, err := adaptor.DoRequest(ctx, info, reqBody)
if err != nil {
return e2eResult{upstream: up, err: err}
}
rec := httptest.NewRecorder()
usage, err := adaptor.DoResponse(ctx, resp, info, rec)
return e2eResult{
upstream: up,
clientStatus: rec.Code,
clientBody: rec.Body.String(),
clientHeader: rec.Header(),
usage: usage,
err: err,
}
}
// ---------- 格式签名断言 ----------
// shapeSpec 一种请求/响应体的结构签名:required 为必须存在的顶层 key,
// forbidden 为「其他格式独有、本形态绝不该出现」的顶层 key。
// 体与端点错配时(如把 chat 体发到 Gemini 的 :generateContent),forbidden 必然命中。
type shapeSpec struct {
required []string
forbidden []string
}
// requestSignature 各协议格式请求体的结构签名。
var requestSignature = map[constant.RelayFormat]shapeSpec{
constant.RelayFormatOpenAI: {
required: []string{"model", "messages"},
forbidden: []string{"contents", "generationConfig", "input", "system"},
},
constant.RelayFormatClaude: {
required: []string{"model", "messages", "max_tokens"},
forbidden: []string{"contents", "generationConfig", "input", "stream_options"},
},
constant.RelayFormatGemini: {
required: []string{"contents"},
forbidden: []string{"messages", "input", "max_tokens", "system"},
},
constant.RelayFormatResponses: {
required: []string{"model", "input"},
forbidden: []string{"messages", "contents", "generationConfig"},
},
constant.RelayFormatOllama: {
required: []string{"model", "messages"},
forbidden: []string{"contents", "input", "max_tokens", "system"},
},
}
// vertexClaudeShape Vertex AI 的 Anthropic 端点(rawPredict)请求体形态:
// 标准 Claude 体去掉 model(模型由 URL 指定)、加上 anthropic_version(必填)。
var vertexClaudeShape = shapeSpec{
required: []string{"messages", "max_tokens", "anthropic_version"},
forbidden: []string{"model", "contents", "generationConfig", "input"},
}
// responseSignature 各协议格式响应体的结构签名(客户端可见字节)。
var responseSignature = map[constant.RelayFormat]shapeSpec{
constant.RelayFormatOpenAI: {
required: []string{"choices"},
forbidden: []string{"candidates", "output", "content"},
},
constant.RelayFormatClaude: {
required: []string{"type", "content"},
forbidden: []string{"choices", "candidates", "output"},
},
constant.RelayFormatGemini: {
required: []string{"candidates"},
forbidden: []string{"choices", "output", "content"},
},
constant.RelayFormatResponses: {
required: []string{"output", "status"},
forbidden: []string{"choices", "candidates"},
},
}
func assertShape(t *testing.T, what string, label string, sig shapeSpec, body []byte) {
t.Helper()
var obj map[string]json.RawMessage
if err := json.Unmarshal(body, &obj); err != nil {
t.Fatalf("%s: 不是合法 JSON 对象(期望 %s 形态): %v\n实际内容: %s", what, label, err, truncate(body))
}
for _, k := range sig.required {
if _, exists := obj[k]; !exists {
t.Errorf("%s: 期望 %s 形态,但缺少必需字段 %q\n实际内容: %s", what, label, k, truncate(body))
}
}
for _, k := range sig.forbidden {
if _, exists := obj[k]; exists {
t.Errorf("%s: 期望 %s 形态,却出现了不该有的字段 %q——体与端点错配\n实际内容: %s",
what, label, k, truncate(body))
}
}
}
func assertTopLevelShape(t *testing.T, what string, format constant.RelayFormat, body []byte,
sigs map[constant.RelayFormat]shapeSpec) {
t.Helper()
sig, ok := sigs[format]
if !ok {
t.Fatalf("%s: 格式 %s 没有登记结构签名", what, format)
}
assertShape(t, what, string(format), sig, body)
}
func truncate(b []byte) string {
const max = 600
if len(b) <= max {
return string(b)
}
return string(b[:max]) + "...(truncated)"
}
// ---------- 语料 ----------
// 各入站格式的客户端请求(均带 system 提示词:它在 OpenAI 侧进 messages、
// 在 Claude 侧升为顶层 system,是两种相似结构的关键判别点)。
var e2eClientRequests = map[constant.RelayFormat]string{
constant.RelayFormatOpenAI: `{
"model": "origin-model",
"messages": [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"}
],
"max_tokens": 1024,
"temperature": 0.7
}`,
constant.RelayFormatClaude: `{
"model": "origin-model",
"system": "You are helpful.",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 1024
}`,
constant.RelayFormatGemini: `{
"systemInstruction": {"parts": [{"text": "You are helpful."}]},
"contents": [{"role": "user", "parts": [{"text": "Hello"}]}],
"generationConfig": {"maxOutputTokens": 1024, "temperature": 0.7}
}`,
constant.RelayFormatResponses: `{
"model": "origin-model",
"instructions": "You are helpful.",
"input": [{"role": "user", "content": [{"type": "input_text", "text": "Hello"}]}],
"max_output_tokens": 1024
}`,
}
// 各上游格式的非流式响应语料(含用量,供计费闭环断言)。
var e2eUpstreamResponses = map[constant.RelayFormat]string{
constant.RelayFormatOpenAI: `{
"id": "chatcmpl-up", "object": "chat.completion", "created": 1700000000, "model": "upstream-model",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Hi there!"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 12, "completion_tokens": 7, "total_tokens": 19}
}`,
constant.RelayFormatClaude: `{
"id": "msg_up", "type": "message", "role": "assistant", "model": "upstream-model",
"content": [{"type": "text", "text": "Hi there!"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 12, "output_tokens": 7}
}`,
constant.RelayFormatGemini: `{
"candidates": [{"index": 0, "content": {"role": "model", "parts": [{"text": "Hi there!"}]}, "finishReason": "STOP"}],
"usageMetadata": {"promptTokenCount": 12, "candidatesTokenCount": 7, "totalTokenCount": 19}
}`,
constant.RelayFormatResponses: `{
"id": "resp_up", "object": "response", "created_at": 1700000000, "status": "completed", "model": "upstream-model",
"output": [{"type": "message", "id": "msg_up", "status": "completed", "role": "assistant",
"content": [{"type": "output_text", "text": "Hi there!"}]}],
"usage": {"input_tokens": 12, "output_tokens": 7, "total_tokens": 19}
}`,
constant.RelayFormatOllama: `{
"model": "upstream-model", "created_at": "2026-01-01T00:00:00Z",
"message": {"role": "assistant", "content": "Hi there!"},
"done": true, "done_reason": "stop",
"prompt_eval_count": 12, "eval_count": 7
}`,
}
// ---------- 非流式方向矩阵 ----------
// e2eNonStreamCases 覆盖全部已注册转换方向的真实链路(含同格式直连基线)。
func e2eNonStreamCases() []e2eCase {
mk := func(name string, provider constant.ProviderType, inbound constant.RelayFormat,
mode constant.RelayMode, upstreamFormat constant.RelayFormat, pathContains string) e2eCase {
return e2eCase{
name: name,
provider: provider,
inbound: inbound,
mode: mode,
clientBody: e2eClientRequests[inbound],
wantUpstreamFormat: upstreamFormat,
wantPathContains: pathContains,
upstreamBody: e2eUpstreamResponses[upstreamFormat],
}
}
return []e2eCase{
// OpenAI 入站 → 各原生上游
mk("openai→claude", constant.ProviderClaude, constant.RelayFormatOpenAI,
constant.RelayModeChatCompletions, constant.RelayFormatClaude, "/v1/messages"),
mk("openai→gemini", constant.ProviderGemini, constant.RelayFormatOpenAI,
constant.RelayModeChatCompletions, constant.RelayFormatGemini, ":generateContent"),
mk("openai→ollama", constant.ProviderOllama, constant.RelayFormatOpenAI,
constant.RelayModeChatCompletions, constant.RelayFormatOllama, "/api/chat"),
// 非 OpenAI 入站 → OpenAI 上游(反向)
mk("claude→openai", constant.ProviderOpenAI, constant.RelayFormatClaude,
constant.RelayModeClaudeMessages, constant.RelayFormatOpenAI, "/v1/chat/completions"),
mk("gemini→openai", constant.ProviderOpenAI, constant.RelayFormatGemini,
constant.RelayModeGeminiChat, constant.RelayFormatOpenAI, "/v1/chat/completions"),
mk("responses→openai", constant.ProviderOpenAI, constant.RelayFormatResponses,
constant.RelayModeResponses, constant.RelayFormatOpenAI, "/v1/chat/completions"),
// 跨原生方向
mk("claude→gemini", constant.ProviderGemini, constant.RelayFormatClaude,
constant.RelayModeClaudeMessages, constant.RelayFormatGemini, ":generateContent"),
mk("gemini→claude", constant.ProviderClaude, constant.RelayFormatGemini,
constant.RelayModeGeminiChat, constant.RelayFormatClaude, "/v1/messages"),
mk("responses→claude", constant.ProviderClaude, constant.RelayFormatResponses,
constant.RelayModeResponses, constant.RelayFormatClaude, "/v1/messages"),
mk("responses→gemini", constant.ProviderGemini, constant.RelayFormatResponses,
constant.RelayModeResponses, constant.RelayFormatGemini, ":generateContent"),
// 同格式直连基线(矩阵不接管,走 adaptor 原生路径)
mk("openai→openai 直连", constant.ProviderOpenAI, constant.RelayFormatOpenAI,
constant.RelayModeChatCompletions, constant.RelayFormatOpenAI, "/v1/chat/completions"),
mk("claude→claude 直连", constant.ProviderClaude, constant.RelayFormatClaude,
constant.RelayModeClaudeMessages, constant.RelayFormatClaude, "/v1/messages"),
// Vertex AI:模型名驱动的双协议上游。协议判定必须跟着模型走,
// 否则矩阵按渠道类型判成 openai、把 chat 体发到 Gemini/Anthropic 原生端点。
vertexCase("openai→vertex(gemini)", constant.RelayFormatOpenAI, constant.RelayModeChatCompletions, "gemini-2.5-pro"),
vertexCase("claude→vertex(gemini)", constant.RelayFormatClaude, constant.RelayModeClaudeMessages, "gemini-2.5-pro"),
vertexCase("gemini→vertex(gemini) 同格式", constant.RelayFormatGemini, constant.RelayModeGeminiChat, "gemini-2.5-pro"),
vertexCase("responses→vertex(gemini)", constant.RelayFormatResponses, constant.RelayModeResponses, "gemini-2.5-pro"),
vertexCase("openai→vertex(claude)", constant.RelayFormatOpenAI, constant.RelayModeChatCompletions, "claude-sonnet-4-5"),
vertexCase("claude→vertex(claude) 同格式", constant.RelayFormatClaude, constant.RelayModeClaudeMessages, "claude-sonnet-4-5"),
vertexCase("gemini→vertex(claude)", constant.RelayFormatGemini, constant.RelayModeGeminiChat, "claude-sonnet-4-5"),
}
}
// vertexCase 构造 Vertex 渠道的 E2E 用例:按模型名推出上游协议、对应端点与体形态。
// - Gemini 模型 → publishers/google/{model}:generateContent,标准 Gemini 体;
// - Claude 模型 → publishers/anthropic/{model}:rawPredict,Claude 体去 model、带 anthropic_version。
func vertexCase(name string, inbound constant.RelayFormat, mode constant.RelayMode, model string) e2eCase {
c := e2eCase{
name: name,
provider: constant.ProviderVertex,
inbound: inbound,
mode: mode,
upstreamModel: model,
clientBody: e2eClientRequests[inbound],
}
if strings.Contains(model, "claude") {
c.wantUpstreamFormat = constant.RelayFormatClaude
c.wantPathContains = "publishers/anthropic/models/" + model + ":rawPredict"
c.wantUpstreamShape = &vertexClaudeShape
c.upstreamBody = e2eUpstreamResponses[constant.RelayFormatClaude]
return c
}
c.wantUpstreamFormat = constant.RelayFormatGemini
c.wantPathContains = "publishers/google/models/" + model + ":generateContent"
c.upstreamBody = e2eUpstreamResponses[constant.RelayFormatGemini]
return c
}
// TestE2E_RequestBodyMatchesEndpointFormat 断言 1(体格式 = 端点格式):
// 上游实际收到的请求体必须符合该端点期望的协议格式,且用户内容不丢。
//
// 这是单元测试结构上够不到的性质——转换器输出正确、URL 计算正确,但两者配错时
// 只有真实链路能发现(vertex 类问题的检测面)。
func TestE2E_RequestBodyMatchesEndpointFormat(t *testing.T) {
for _, c := range e2eNonStreamCases() {
t.Run(c.name, func(t *testing.T) {
res := runE2E(t, c)
if res.err != nil {
t.Fatalf("链路失败: %v", res.err)
}
path, query, body, _, count := res.upstream.request()
if count != 1 {
t.Fatalf("上游收到 %d 次请求,期望 1 次", count)
}
full := path
if query != "" {
full += "?" + query
}
if !strings.Contains(full, c.wantPathContains) {
t.Errorf("上游端点 = %q,期望包含 %q", full, c.wantPathContains)
}
if c.wantUpstreamShape != nil {
assertShape(t, "上游请求体(端点 "+full+")", string(c.wantUpstreamFormat)+"@vertex", *c.wantUpstreamShape, body)
} else {
assertTopLevelShape(t, "上游请求体(端点 "+full+")", c.wantUpstreamFormat, body, requestSignature)
}
if !strings.Contains(string(body), "Hello") {
t.Errorf("用户消息内容在转换链路中丢失\n实际内容: %s", truncate(body))
}
})
}
}
// TestE2E_ResponseMatchesInboundFormat 断言 2(响应闭环):
// 客户端最终收到的字节必须符合入站协议的响应 wire 契约,内容与用量都不丢。
func TestE2E_ResponseMatchesInboundFormat(t *testing.T) {
for _, c := range e2eNonStreamCases() {
t.Run(c.name, func(t *testing.T) {
res := runE2E(t, c)
if res.err != nil {
t.Fatalf("链路失败: %v", res.err)
}
if res.clientStatus != http.StatusOK {
t.Fatalf("客户端状态码 = %d,期望 200\n响应体: %s", res.clientStatus, truncate([]byte(res.clientBody)))
}
assertTopLevelShape(t, "客户端响应体", c.inbound, []byte(res.clientBody), responseSignature)
if !strings.Contains(res.clientBody, "Hi there!") {
t.Errorf("上游回答内容在响应转换中丢失\n实际内容: %s", truncate([]byte(res.clientBody)))
}
// 计费闭环:用量必须被提取(否则整条请求按 0 token 结算)
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)
}
})
}
}