proxy-playbook/docs/SSE_NORMALIZATION.md

2.1 KiB
Raw Blame History

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(客户端自动忽略)

设计原则

  1. 见到样本再实现:每个上游私有格式千差万别,不要盲抄
  2. 未知事件透传:宁可让客户端看到未知 data,也不要吞掉内容
  3. 工具调用增量合并:Trae 可能发累计快照或增量,两种都要能收敛成 tool_calls[].function.arguments
  4. 心跳:上游长时间停顿时发 SSE 注释行防超时
  5. 断流兜底:上游异常断开时补一个 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 流”探针)。