relay 中继核心:适配器与协议互转
专栏:new-api 源码拆解 · 第 6 / 12 篇:::info 学习目标 完成本篇后你能够:说出 Adaptor 接口的全部方法与调用时机;解释 OpenAI 格式请求如何变成 Claude 渠道请求再变回来;描述参数覆盖引擎的能力边界;照着清单新增一个上游提供方。 前置:第 2、5 篇完成。预计时长:75 分钟。 :::
:::note 本章术语速查(新手建议先读)
- relay(中继):网关的核心动作——把客户端请求转换后转发给上游,再把响应转回来。
- Adaptor(适配器):每个上游提供方的”翻译官”,实现统一的接口方法集。
- DTO(Data Transfer Object):专门用来传输数据的结构定义。
- SSE Scanner:逐行读取流式响应的组件。
- PassThrough(透传):不做任何转换、原样转发请求体的模式。
- param_override:渠道级的”请求参数改写规则”,不改代码就能强制修改转发的参数。 :::
网关的技术心脏是 relay 层:客户端用 OpenAI 格式说话,上游可能是 Claude/Gemini/AWS Bedrock/Ollama 等 40+ 种方言——中继层负责双向翻译。它的设计分两层:relay/(宿主层:HTTP 编排、计费交互、渠道生命周期)与 relaykit/(2025 年拆分的独立 Go module:纯协议 DTO 与转换,不依赖 gin 与数据库,README 给出 OpenAI Chat / Responses / Claude / Gemini 的 4×4 转换质量矩阵——Good/Fair/Discouraged,诚实标注哪些互转有损)。
协议与 handler 全景
controller/relay.go 按 relayInfo.RelayFormat 分发到各 handler:
| RelayFormat | handler | 入口 |
|---|---|---|
| OpenAI(chat) | relayHandler / compatible_handler | /v1/chat/completions |
| Claude | relay.ClaudeHelper | /v1/messages |
| Gemini | geminiRelayHandler | /v1beta/...:generateContent |
| OpenAIResponses | relay.ResponsesHelper | /v1/responses |
| Embedding / Rerank / Image / Audio | 各专用 handler | 对应端点 |
| Realtime | relay.WssHelper | /v1/realtime(WebSocket) |
| Task / MjProxy | relay_task.go / mjproxy_handler | Midjourney、视频(Kling/Sora/Vidu…) |
Adaptor 接口:一个提供方要实现什么
// relay/channel/adapter.go:16-39(节选)
type Adaptor interface {
Init(*types.ChannelMeta) error
GetRequestURL(meta *types.ChannelMeta, request *http.Request) (string, error)
SetupRequestHeader(c *gin.Context, req *http.Request, meta *types.ChannelMeta) error
ConvertOpenAIRequest(...) // 8 个转换入口:OpenAI / Rerank / Embedding /
ConvertClaudeRequest(...) // Audio / Image / Responses / Claude / Gemini
...
DoRequest(c *gin.Context, info *RelayInfo, request *http.Request) (*http.Response, error)
DoResponse(c *gin.Context, resp *http.Response, info *RelayInfo) (usage any, err error)
GetModelList() []string
GetChannelName() string
}
以 Claude 适配器为例,三种入口格式的转换统一委托给 relaykit 的转换注册表——适配器不再各写一套格式转换:
// relay/channel/claude/adaptor.go:118-127
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo,
request *dto.GeneralOpenAIRequest) (any, error) {
result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, request)
if err != nil {
return nil, err
}
return result.Value, nil
}
新增一个提供方的五步清单(源自源码结构):① constant/channel.go 加渠道类型常量与显示名;② common/api_type.go 的 ChannelType2APIType 加映射;③ 新建 relay/channel/<provider>/adaptor.go 实现接口;④ relay/relay_adaptor.go 的 GetAdaptor(40+ 个 case)加分支;⑤ 异步任务类(视频/音乐生成)再实现 TaskAdaptor(多了 ValidateRequestAndSetAction / EstimateBilling / FetchTask 等方法)。仓库里 40+ 个现成适配器就是 40 份参考答案。
SSE 流式处理与参数覆盖
流式核心是 relay/helper/stream_scanner.go 的 StreamScannerHandler:bufio.Scanner 逐事件读取上游 SSE,带超时 ticker、ping 心跳、写锁与 goroutine 清理,把每个 data: 行回调给适配器的 handler(如 Claude 的 ClaudeStreamHandler)。是否流式由上游响应的 Content-Type: text/event-stream 判定;转换分两类路径——入口协议=出口协议直接透传(还有 PassThrough 模式连 body 都原样重放),不同协议则在 DoResponse 里逐事件转换。
请求侧还有一个常被忽略的引擎:param_override(relay/common/override.go,约 2200 行)。渠道级 JSON 请求体改写,支持旧版键值 set/delete(gjson/sjson 路径语义)和新版 operations 操作列表(带条件上下文与审计记录器)——不改代码就能给某个渠道强制注入/覆盖任意参数(如强制 temperature、注入系统消息)。每个 handler 在请求序列化后调用 ApplyParamOverrideWithRelayInfo。
一次转换的完整时序
与其他模块的联系
- ← Distribute(第 2、7 篇):从上下文拿到渠道 key/type/setting/param_override/model_mapping——适配器自身不做选择;
- → 计费(第 8 篇):
DoResponse返回的usage是结算的输入;流式场景下 usage 随最后一个事件到达; - → 渠道熔断(第 7 篇):
DoRequest/DoResponse返回的错误带状态码,供processChannelError判定是否重试与禁用; - → 插件(第 11 篇):TaskPlugin 渠道实现的是同一套 Adaptor 语义,只是执行体是 JS 插件。
常见踩坑
- 新增提供方忘了 GetAdaptor 的 case——编译通过但运行时报未知渠道类型;
- 没看转换质量矩阵——Gemini↔Claude 某些参数(reasoning 细节等)是 Discouraged 级别,转换后行为差异要有预期;
- 流式响应缓冲了 body——SSE 必须边读边转发,攒整个响应会超时;
- param_override 写了非法 JSON 路径——开启
ParamOverrideReturnError时会转成 SkipRetry 错误直接失败,先在测试渠道验证。
随堂练习(带验收标准)
- 读
relay/channel/openai/adaptor.go全文,标注每个方法被调用的时机。验收:能对照第 2 篇的生命周期图说出顺序; - 配置一个 Claude 格式渠道,用 OpenAI SDK 流式请求它。验收:OpenAI 格式的流式回复正常(底层经历双向格式转换);
- 用 param_override 给某渠道强制注入一个
temperature: 0。验收:上游收到该参数(可通过上游日志或响应行为验证); - 照五步清单给一个假想提供方写最小 adaptor 骨架,编译通过即达标。