Files

476 lines
16 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 helper
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/dto"
)
// SetEventStreamHeaders 设置 SSE 必要的响应头。
// 使用 SafeWriter 的 context 字段实现幂等性保护,防止重复设置导致的 panic。
func SetEventStreamHeaders(w http.ResponseWriter) {
// 幂等性检查:如果是 SafeWriter 且已设置过,直接返回
if sw, ok := w.(*SafeWriter); ok {
sw.mu.Lock()
if sw.headersSet {
sw.mu.Unlock()
return
}
sw.headersSet = true
sw.mu.Unlock()
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Transfer-Encoding", "chunked") // 显式声明分块传输
w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 缓冲
w.WriteHeader(http.StatusOK)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
// WriteSSEData 写入一行 SSE 数据
func WriteSSEData(w http.ResponseWriter, data string) error {
_, err := fmt.Fprintf(w, "data: %s\n\n", data)
if err != nil {
return err
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
}
// WriteSSEEvent 写入一个完整的 SSE 事件(包含 event 和 data)
func WriteSSEEvent(w http.ResponseWriter, event string, data string) error {
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data)
if err != nil {
return err
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
}
// ssePingFrame 保活注释帧。SSE 规范中以 `:` 开头的行是注释,客户端会忽略但会保持连接活跃。
const ssePingFrame = ": PING\n\n"
// WriteSSEPing 写入 SSE 保活注释(`: PING\n\n`)。
// w 为 SafeWriter 时走 WritePing:在同一把锁内检查「调用方是否正处于分多次写同一帧的中途」,
// 是则跳过本次 ping —— ping 自带的空行会把那一帧劈成两个事件(详见 SSEFrameWriter 说明)。
func WriteSSEPing(w http.ResponseWriter) error {
if sw, ok := w.(*SafeWriter); ok {
_, err := sw.WritePing()
return err
}
_, err := w.Write([]byte(ssePingFrame))
if err != nil {
return err
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
}
// WriteJSON 写入 JSON 响应
func WriteJSON(w http.ResponseWriter, statusCode int, data any) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_, err := fmt.Fprintf(w, "%s", data)
return err
}
const (
// sseEventBufInitCap 池中缓冲的初始容量,覆盖绝大多数流式事件,避免首次写入扩容
sseEventBufInitCap = 2 * 1024
// sseEventBufKeepCap 归还池的缓冲容量上限,超过则丢弃,避免个别超大事件把容量永久钉在池里
sseEventBufKeepCap = 64 * 1024
)
// ErrSSEPayloadMarshal payload 序列化失败,事件未写出(一个字节都没有出网)。
// 调用方据此区分两类失败:
// - 序列化失败 → 事件被跳过,应记日志:写出 data 为空的畸形帧会让客户端 JSON.parse("")
// 抛错并中止整条请求,而网关侧只会看到 ctx 取消、记成 client_gone;
// - 写客户端失败 → 客户端已不可达,由主循环的中断路径统一处理,不必逐事件刷日志。
var ErrSSEPayloadMarshal = errors.New("sse payload marshal failed")
// sseEventBuffer 复用的帧组装缓冲。encoder 在构造时绑定到 buf,二者一起池化,
// 使「序列化 + 拼帧」全程零分配。
//
// 复用 json.Encoder 的前提:Encoder 只在底层 writer 写失败时置位其内部粘滞错误,
// 而 bytes.Buffer 的 Write 永不返回错误,故池中的 encoder 不会被毒化。
type sseEventBuffer struct {
buf *bytes.Buffer
enc *json.Encoder
}
var sseEventBufPool = sync.Pool{
New: func() any {
buf := bytes.NewBuffer(make([]byte, 0, sseEventBufInitCap))
return &sseEventBuffer{buf: buf, enc: json.NewEncoder(buf)}
},
}
func getSSEEventBuffer() *sseEventBuffer {
eb := sseEventBufPool.Get().(*sseEventBuffer)
eb.buf.Reset()
return eb
}
func putSSEEventBuffer(eb *sseEventBuffer) {
if eb.buf.Cap() > sseEventBufKeepCap {
return
}
sseEventBufPool.Put(eb)
}
// writeSSEFrame 把 eb 中已拼好的帧一次性写出并 Flush。
func writeSSEFrame(w http.ResponseWriter, eb *sseEventBuffer) error {
if _, err := w.Write(eb.buf.Bytes()); err != nil {
return err
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
}
// WriteSSEEventJSON 序列化 payload 并作为一个完整 SSE 事件帧一次性写出(`event: X\ndata: {...}\n\n`)。
// 相比「json.Marshal → string() → Fprintf」少若干次分配,且整帧单次 Write —— 天然满足帧原子,
// 并发的保活 ping 无法把空行插进帧中间。
// 序列化失败时不写出任何字节,返回包装 ErrSSEPayloadMarshal 的错误。
func WriteSSEEventJSON(w http.ResponseWriter, event string, payload any) error {
eb := getSSEEventBuffer()
defer putSSEEventBuffer(eb)
eb.buf.WriteString("event: ")
eb.buf.WriteString(event)
eb.buf.WriteString("\ndata: ")
// Encode 在 JSON 之后自带换行,正好充当 data 行的行尾;序列化失败时不会向 buf 写入任何内容
if err := eb.enc.Encode(payload); err != nil {
return fmt.Errorf("%w: %v", ErrSSEPayloadMarshal, err)
}
eb.buf.WriteByte('\n') // 帧分隔空行
return writeSSEFrame(w, eb)
}
// WriteSSEDataJSON 序列化 payload 并作为一个只有 data 行的完整 SSE 帧一次性写出
// (`data: {...}\n\n`)。语义与 WriteSSEEventJSON 相同,用于 OpenAI 风格的无 event 名流式块。
func WriteSSEDataJSON(w http.ResponseWriter, payload any) error {
eb := getSSEEventBuffer()
defer putSSEEventBuffer(eb)
eb.buf.WriteString("data: ")
if err := eb.enc.Encode(payload); err != nil {
return fmt.Errorf("%w: %v", ErrSSEPayloadMarshal, err)
}
eb.buf.WriteByte('\n')
return writeSSEFrame(w, eb)
}
// ExtractSSEData 从 SSE 行中提取 data 字段内容
// 兼容 "data: content"(带空格)和 "data:content"(不带空格)两种格式
// 如果不是 data 行返回 ("", false)
func ExtractSSEData(line string) (string, bool) {
if !strings.HasPrefix(line, "data:") {
return "", false
}
data := line[5:] // 跳过 "data:" 前缀
// 跳过可选的单个空格(SSE 规范:data: 后最多忽略一个空格)
if len(data) > 0 && data[0] == ' ' {
data = data[1:]
}
return strings.TrimSpace(data), true
}
// BuildOpenAIStreamChunk 构建 OpenAI 格式的流式响应块
func BuildOpenAIStreamChunk(id string, created int64, model string, content string, finishReason *string) dto.ChatCompletionStreamResponse {
delta := dto.Message{}
if content != "" {
delta.Content = content
}
if finishReason == nil {
delta.Role = "assistant"
}
return dto.ChatCompletionStreamResponse{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []dto.StreamChoice{
{
Index: 0,
Delta: delta,
FinishReason: finishReason,
},
},
}
}
// EstimateTokens 粗略估算 token 数(每 4 个字符约 1 个 token)
func EstimateTokens(text string) int {
if text == "" {
return 0
}
return (len(text) + 3) / 4
}
// EstimateStreamOutputTokens 流式输出兜底估算(上游未返回 usage 时按累计文本长度粗估)。
// 正常结束按每 4 字符 1 token;流中断(客户端断开/空闲超时等部分传输,IsPartialStreamEnd)
// 按每 2 字符 1 token —— 中断时拿不到上游最终 usage,按保守口径计费。
func EstimateStreamOutputTokens(info *common.RelayInfo, textLen int) int {
if textLen <= 0 {
return 0
}
if info != nil && info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
return (textLen + 1) / 2
}
return textLen / 4
}
// ApplyInterruptedUsageFallback 流中断(IsPartialStreamEnd)时的计费兜底修正,正常结束为 no-op:
// 1. 输出:上游未返回输出 token(多数供应商 usage 在最后一个 chunk,中断时拿不到)时,
// 按已转发文本长度以每 2 字符 1 token 估算;
// 2. 输入:上游未返回输入 token 时,用请求侧估算值(与预扣同源)补齐,保证输入正常计费。
//
// 已有真实 usage(如 Claude message_delta 的累计值)时不覆盖,仅重算 TotalTokens。
func ApplyInterruptedUsageFallback(info *common.RelayInfo, usage *common.Usage, transferredTextLen int) {
if info == nil || usage == nil || info.StreamStatus == nil || !info.StreamStatus.IsPartialStreamEnd() {
return
}
if usage.CompletionTokens == 0 && transferredTextLen > 0 {
usage.CompletionTokens = (transferredTextLen + 1) / 2
}
if usage.PromptTokens == 0 {
usage.PromptTokens = info.GetEstimatePromptTokens()
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
// SafeWriter 包装 http.ResponseWriter,用互斥锁串行化 Write/WriteHeader/Flush,
// 使保活 ping goroutine 与主循环可以安全地并发写同一个 ResponseWriter。
//
// 注意:本类型只保证**单次 Write 调用**原子,不保证 SSE 帧原子。
// 调用方一次 Write 写完整帧时(如 WriteSSEData/WriteSSEEvent 的单次 Fprintf),帧即原子;
// 而按行转发上游 SSE 的调用方必须改用 SSEFrameWriter 攒齐整帧再写,否则 ping 的空行
// 会插进帧中间,让客户端派发出 data 为空的事件(详见 SSEFrameWriter 的说明)。
type SafeWriter struct {
w http.ResponseWriter
mu sync.Mutex
headersSet bool // SSE 头幂等性标志(防止 SetEventStreamHeaders 重复调用导致 panic)
midFrame bool // 调用方正在分多次写出同一帧(SSEFrameWriter 超大帧降级),期间抑制 ping
}
// NewSafeWriter 创建一个并发安全的 ResponseWriter 包装器。
func NewSafeWriter(w http.ResponseWriter) *SafeWriter {
return &SafeWriter{
w: w,
headersSet: false,
}
}
func (s *SafeWriter) Header() http.Header {
return s.w.Header()
}
func (s *SafeWriter) WriteHeader(statusCode int) {
s.mu.Lock()
defer s.mu.Unlock()
s.w.WriteHeader(statusCode)
}
func (s *SafeWriter) Write(b []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.w.Write(b)
}
// Flush 实现 http.Flusher,刷新底层 writer(若支持)。
func (s *SafeWriter) Flush() {
s.mu.Lock()
defer s.mu.Unlock()
if f, ok := s.w.(http.Flusher); ok {
f.Flush()
}
}
// SetMidFrame 标记/清除「当前有一帧正在分多次写出」。置位期间 WritePing 跳过写入,
// 避免 ping 自带的空行落在帧中间。仅 SSEFrameWriter 的超大帧降级路径会用到。
func (s *SafeWriter) SetMidFrame(v bool) {
s.mu.Lock()
s.midFrame = v
s.mu.Unlock()
}
// WritePing 在持锁状态下写出保活注释帧,返回是否实际写出。
// 检查与写入必须在同一临界区内完成,否则「检查通过 → 调用方开始写半帧 → ping 写入」
// 的交错依然会劈开帧。
func (s *SafeWriter) WritePing() (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.midFrame {
return false, nil
}
if _, err := s.w.Write([]byte(ssePingFrame)); err != nil {
return false, err
}
if f, ok := s.w.(http.Flusher); ok {
f.Flush()
}
return true, nil
}
var (
_ http.ResponseWriter = (*SafeWriter)(nil)
_ http.Flusher = (*SafeWriter)(nil)
)
const (
// sseFrameMaxBuf 单帧缓冲上限:超过则先把已缓冲内容写出(降级为逐行写),
// 防止上游异常(始终不发空行)把整条流攒进内存。正常 SSE 帧远小于此。
sseFrameMaxBuf = 1 << 20 // 1 MiB
// sseFrameKeepBuf 帧写出后保留复用的缓冲上限,超过则释放,避免个别大帧长期占用内存
sseFrameKeepBuf = 64 * 1024
)
// IsSSEFrameBoundary 判断一行是否为 SSE 帧分隔空行(兼容 LF / CRLF 两种行尾)。
func IsSSEFrameBoundary(line string) bool {
return line == "\n" || line == "\r\n"
}
// SSEFrameWriter 为「逐行转发上游 SSE」的场景提供帧级原子写入。
//
// SSE 以空行分帧,客户端读到空行即派发事件。若转发方按行写,而保活 ping goroutine
// 又并发写同一个 writer,ping 自带的空行就可能插进一帧中间,把只写了 `event: X` 的
// 半帧提前派发出去 —— 客户端拿到 data 为空的事件,对其做 JSON.parse 直接抛错
// (如 Claude Code 的 "JSON Parse error: Unexpected EOF")并中止请求,网关侧随后
// 只观察到 ctx 取消,被记成 client_gone,真实成因被掩盖。
// SafeWriter 只保证单次 Write 原子,保证不了整帧原子,故需本类型按帧攒齐再写。
//
// 用法:逐行 WriteLine(遇空行自动整帧写出并 Flush);上游 EOF 时 FlushPartial
// 冲刷无空行结尾的残留;中断或上游读错时 Discard 丢弃半帧(半帧尚未出网,丢弃即可)。
// 单帧超过 sseFrameMaxBuf 时降级为分次写出,此期间通过 SafeWriter.SetMidFrame 抑制 ping,
// 保证降级路径同样不会被空行劈开。
// 本类型非并发安全,只应由转发主循环单协程使用,底层 w 需为 SafeWriter。
type SSEFrameWriter struct {
w http.ResponseWriter
buf []byte
}
// NewSSEFrameWriter 创建帧级原子写入器,w 应为并发安全的 writer(如 SafeWriter)。
func NewSSEFrameWriter(w http.ResponseWriter) *SSEFrameWriter {
return &SSEFrameWriter{w: w}
}
// WriteLine 追加一行(含行尾换行符)。该行为帧分隔空行时整帧写出;
// 缓冲超过上限时降级为分次写出,并在帧写完前抑制保活 ping。
func (f *SSEFrameWriter) WriteLine(line string) error {
f.buf = append(f.buf, line...)
if IsSSEFrameBoundary(line) {
return f.flush(true)
}
if len(f.buf) >= sseFrameMaxBuf {
// 超大帧降级:写出已缓冲部分,但这一批字节不含帧尾,
// 标记 midFrame 让 ping 让路,直到该帧的空行到达
return f.flush(false)
}
return nil
}
// FlushPartial 冲刷未以空行结尾的残留内容(上游 EOF 时调用,避免丢掉最后一帧)。
func (f *SSEFrameWriter) FlushPartial() error {
return f.flush(true)
}
// Discard 丢弃尚未写出的半帧内容。
func (f *SSEFrameWriter) Discard() {
f.buf = f.buf[:0]
f.setMidFrame(false)
}
// Buffered 返回当前缓冲的字节数(尚未写给客户端的半帧)。
func (f *SSEFrameWriter) Buffered() int {
return len(f.buf)
}
// setMidFrame 把「帧写到一半」的状态透给底层 SafeWriter,由它在写 ping 时同锁判定。
func (f *SSEFrameWriter) setMidFrame(v bool) {
if sw, ok := f.w.(*SafeWriter); ok {
sw.SetMidFrame(v)
}
}
// flush 写出缓冲内容。frameComplete 表示这批字节写完后帧是否完整
// (遇到分隔空行 / 上游 EOF 收尾为 true,超大帧降级为 false)。
func (f *SSEFrameWriter) flush(frameComplete bool) error {
if len(f.buf) == 0 {
if frameComplete {
f.setMidFrame(false)
}
return nil
}
if !frameComplete {
// 必须在 Write 之前置位:否则 ping 可能挤在「写出半帧」与「置位」之间
f.setMidFrame(true)
}
_, err := f.w.Write(f.buf)
if cap(f.buf) > sseFrameKeepBuf {
f.buf = nil
} else {
f.buf = f.buf[:0]
}
if frameComplete {
// 帧已完整出网,放行 ping(写失败也要清除,避免把标记永久留在 writer 上)
f.setMidFrame(false)
}
if err != nil {
return err
}
if fl, ok := f.w.(http.Flusher); ok {
fl.Flush()
}
return nil
}
// PingTicker 在后台定期发送 SSE 保活注释
// 调用方必须传入并发安全的 writer(如 SafeWriter),以避免与主循环并发写 ResponseWriter
// 返回一个 stop 函数用于停止 goroutine
func PingTicker(w http.ResponseWriter, interval time.Duration) (stop func()) {
ticker := time.NewTicker(interval)
done := make(chan struct{})
go func() {
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := WriteSSEPing(w); err != nil {
return
}
case <-done:
return
}
}
}()
return func() {
close(done)
}
}