mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-04 13:28:03 +02:00
150 lines
4.8 KiB
Go
150 lines
4.8 KiB
Go
package openai
|
|
|
|
// eino_sse_sanitizer.go 解决 Eino 走 meguminnnnnnnnn/go-openai SDK 时,
|
|
// 中转站心跳/SSE 控制行累计 > 300 行触发 ErrTooManyEmptyStreamMessages
|
|
// (报错文案: "stream has sent too many empty messages")的问题。
|
|
//
|
|
// 触发链路:
|
|
// einoopenai.NewChatModel
|
|
// → eino-ext/libs/acl/openai → meguminnnnnnnnn/go-openai
|
|
// → streamReader.processLines() 对所有非 "data:" 行计数, > 300 即抛错。
|
|
//
|
|
// 中转站常见的非 data: 行(合法 SSE 但 SDK 不接受):
|
|
// ":" / ": keepalive" / ": ping" / "event: ping" / "retry: 3000"
|
|
// 以及思考型模型 prefill 期间穿插的大量心跳。
|
|
//
|
|
// 兜底策略: 在 HTTP transport 层把响应 Body 包一层 reader, 只放行 "data:"
|
|
// 开头的行, 把心跳/注释/事件类型行就地吞掉。下游 SDK 永远见不到非 data: 行,
|
|
// 计数器始终为 0, 该错误不可能再发生。
|
|
//
|
|
// 该层对调用方完全透明:
|
|
// - 仅当响应 Content-Type 是 text/event-stream 时介入;普通 JSON 响应原样透传
|
|
// - data: payload (含 [DONE] 与 {"error":...}) 一字节不改
|
|
// - 上游真断流 (EOF / connection reset / context cancel) 原样透传
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
// einoSSEReaderBufSize 给 bufio 一个较大的初始缓冲, 避免单行大 JSON chunk
|
|
// (含工具调用 arguments / reasoning_content) 频繁触发缓冲区扩容。
|
|
einoSSEReaderBufSize = 64 * 1024
|
|
)
|
|
|
|
// einoSSESanitizingRoundTripper 包装下游 RoundTripper, 对 SSE 响应做行级清洗。
|
|
type einoSSESanitizingRoundTripper struct {
|
|
base http.RoundTripper
|
|
}
|
|
|
|
func (rt *einoSSESanitizingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
resp, err := rt.base.RoundTrip(req)
|
|
if err != nil || resp == nil {
|
|
return resp, err
|
|
}
|
|
if !isSSEResponse(resp) {
|
|
return resp, nil
|
|
}
|
|
resp.Body = newEinoSSESanitizingBody(resp.Body)
|
|
return resp, nil
|
|
}
|
|
|
|
// isSSEResponse 仅对 200 + text/event-stream 的响应做清洗;
|
|
// 错误响应 (4xx/5xx 通常是 application/json) 不动, 由 SDK 走原错误路径。
|
|
func isSSEResponse(resp *http.Response) bool {
|
|
if resp.StatusCode != http.StatusOK {
|
|
return false
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
if ct == "" {
|
|
return false
|
|
}
|
|
ct = strings.ToLower(strings.TrimSpace(ct))
|
|
// 兼容 "text/event-stream", "text/event-stream; charset=utf-8" 等。
|
|
return strings.HasPrefix(ct, "text/event-stream")
|
|
}
|
|
|
|
// einoSSESanitizingBody 是包装后的响应体: 只放行 data: 行, 其它行吞掉。
|
|
type einoSSESanitizingBody struct {
|
|
upstream io.ReadCloser
|
|
reader *bufio.Reader
|
|
pending []byte // 已清洗、待返回给下游的字节 (永远以 \n 结尾的完整 data: 行)
|
|
err error // upstream 终态错误 (io.EOF 或网络错误)
|
|
}
|
|
|
|
func newEinoSSESanitizingBody(body io.ReadCloser) *einoSSESanitizingBody {
|
|
return &einoSSESanitizingBody{
|
|
upstream: body,
|
|
reader: bufio.NewReaderSize(body, einoSSEReaderBufSize),
|
|
}
|
|
}
|
|
|
|
func (b *einoSSESanitizingBody) Read(p []byte) (int, error) {
|
|
if len(p) == 0 {
|
|
return 0, nil
|
|
}
|
|
if len(b.pending) > 0 {
|
|
n := copy(p, b.pending)
|
|
b.pending = b.pending[n:]
|
|
return n, nil
|
|
}
|
|
|
|
// 从上游读, 直到攒出一行 data: 或拿到终态。
|
|
// 单次循环可能丢弃任意多行心跳, 但只放行至多一行 data: 后退出,
|
|
// 避免一次 Read 阻塞过久 / pending 缓冲过大。
|
|
for b.err == nil {
|
|
line, err := b.reader.ReadBytes('\n')
|
|
if len(line) > 0 {
|
|
if isPassThroughSSELine(line) {
|
|
if line[len(line)-1] != '\n' {
|
|
line = append(line, '\n')
|
|
}
|
|
b.pending = line
|
|
if err != nil {
|
|
b.err = err
|
|
}
|
|
break
|
|
}
|
|
// 非 data: 行 (空行 / ":" 注释 / event: / retry: / id: / 任何裸文本)
|
|
// 全部吞掉, 不向下游透出, 继续循环读下一行。
|
|
}
|
|
if err != nil {
|
|
b.err = err
|
|
break
|
|
}
|
|
}
|
|
|
|
if len(b.pending) > 0 {
|
|
n := copy(p, b.pending)
|
|
b.pending = b.pending[n:]
|
|
return n, nil
|
|
}
|
|
return 0, b.err
|
|
}
|
|
|
|
func (b *einoSSESanitizingBody) Close() error {
|
|
return b.upstream.Close()
|
|
}
|
|
|
|
// isPassThroughSSELine 判定该行是否需要原样放行给下游 SDK。
|
|
// 仅 "data:" (大小写不敏感, 可有任意前导空白) 开头的行需要保留。
|
|
// 注意: 不能用 TrimSpace 去尾部换行后再判, 否则 " data: x" 会被误判;
|
|
// 我们只 trim 前导空白, 与 SDK 内部 TrimSpace 后再正则 ^data:\s* 的语义一致。
|
|
func isPassThroughSSELine(line []byte) bool {
|
|
trimmed := bytes.TrimLeft(line, " \t")
|
|
if len(trimmed) < 5 {
|
|
return false
|
|
}
|
|
// 大小写不敏感比较前 5 字节是否为 "data:"。SSE 规范要求字段名小写,
|
|
// 但宽松匹配可以兼容个别中转站的非规范实现。
|
|
return (trimmed[0] == 'd' || trimmed[0] == 'D') &&
|
|
(trimmed[1] == 'a' || trimmed[1] == 'A') &&
|
|
(trimmed[2] == 't' || trimmed[2] == 'T') &&
|
|
(trimmed[3] == 'a' || trimmed[3] == 'A') &&
|
|
trimmed[4] == ':'
|
|
}
|