2.1 KiB
2.1 KiB
SSE 归一化(StreamNormalizer)设计
为什么需要
- WorkBuddy 上游 ≈ OpenAI SSE,透传即可
- Trae SOLO 上游是私有事件协议,字段/信封和 OpenAI 不一样
- 客户端只认 data: {"choices":[{"delta":{"content":...}}]} 和 data: [DONE]
当前实现模式
先做 raw passthrough(实时转发上游字节),再做归一:
上游 InputStream -> respondOutputStream -> 客户端
好处:先解决“能不能连上/能不能出字”,再解决“客户端能不能解析”。
归一化目标映射
把 Trae 私有事件翻译成 OpenAI Chat Completions chunk:
| Trae 事件 | OpenAI chunk |
|---|---|
| 正文增量事件 | data: {"choices":[{"delta":{"content":"..."}}]} |
| 推理/思考块 | delta.reasoning_content(部分客户端支持) |
| 结束事件 | delta {} finish_reason stop + data: [DONE] |
| 用量事件 | 合并到 finish chunk 的 usage |
| 业务错误 | data: {"error":{...}} + data: [DONE] |
| 心跳注释 | : relay-keepalive(客户端自动忽略) |
设计原则
- 见到样本再实现:每个上游私有格式千差万别,不要盲抄
- 未知事件透传:宁可让客户端看到未知 data,也不要吞掉内容
- 工具调用增量合并:Trae 可能发累计快照或增量,两种都要能收敛成 tool_calls[].function.arguments
- 心跳:上游长时间停顿时发 SSE 注释行防超时
- 断流兜底:上游异常断开时补一个 finish_reason,避免客户端永远 loading
最小实现骨架
fun normalizeLine(raw: String): String? {
if (!raw.startsWith("data:")) return null // 忽略注释/空行
val payload = raw.removePrefix("data:").trim()
if (payload == "[DONE]") return raw
val json = Json.parseToJsonElement(payload) as? JsonObject ?: return null
// 根据 event/type 字段抽取 content/reasoning/tool_calls
val content = extractTraeText(json) ?: return null
return """data: {"choices":[{"delta":{"content":"$content"}}]}"""
}
具体 Trae 事件字段需要以真实抓包/探针输出为准(仓库里自带“测试 Trae 流”探针)。