openai_raw_stream_truncation.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. package service
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "net/http"
  6. "strings"
  7. "github.com/gin-gonic/gin"
  8. "github.com/tidwall/gjson"
  9. )
  10. // openAIRawStreamTruncatedUpstreamMessage 是 raw CC 直转路径上游截断的 ops 消息。
  11. const openAIRawStreamTruncatedUpstreamMessage = "Upstream Chat Completions stream ended before any terminal chunk"
  12. // openAIRawStreamTerminalState 记录 raw Chat Completions SSE 流是否收到过
  13. // **终止信号**。
  14. //
  15. // 背景:CC 直转路径把上游 SSE 原样透传,此前只要 HTTP 状态是 200 就按成功收尾——
  16. // 上游中途断流(Cloudflare edge reset、后端 worker 掉线)会被伪装成
  17. // `HTTP 200 + usage 0/0`:客户端拿到半截回答,网关既不报错也不计入 SLA,
  18. // Ops 侧完全不可见。
  19. //
  20. // 三种终止信号任一出现即认为上游"讲完了",只是尾巴可能丢失,不作截断处理:
  21. //
  22. // - [DONE] —— OpenAI CC 协议标准哨兵
  23. // - usage chunk —— include_usage 生效时的末尾用量帧(网关强制打开)
  24. // - finish_reason —— 生成正常结束(stop/length/tool_calls/...)
  25. //
  26. // 只认 [DONE] 会误伤那些跑完最后一帧就直接 EOF 的兼容上游;只认 usage 会误伤
  27. // 不支持 include_usage 的上游。三者取并集,把误判压到"上游确实在生成中途被切断"。
  28. type openAIRawStreamTerminalState struct {
  29. // sawDataLine 表示上游至少发过一行 `data:`,即响应确实是 SSE 语义流。
  30. // 非 SSE 响应体(上游对 stream 请求返回裸 JSON)不参与截断判定,保持既有透传行为。
  31. sawDataLine bool
  32. sawDone bool
  33. sawUsage bool
  34. sawFinishReason bool
  35. }
  36. // ObserveDataLine 从单行 SSE `data:` 载荷中提取终止信号。payload 需已 TrimSpace。
  37. func (t *openAIRawStreamTerminalState) ObserveDataLine(payload string) {
  38. if t == nil {
  39. return
  40. }
  41. t.sawDataLine = true
  42. if payload == "[DONE]" {
  43. t.sawDone = true
  44. return
  45. }
  46. if usage := gjson.Get(payload, "usage"); usage.Exists() && usage.IsObject() {
  47. t.sawUsage = true
  48. }
  49. if t.sawFinishReason {
  50. return
  51. }
  52. for _, choice := range gjson.Get(payload, "choices").Array() {
  53. // finish_reason 为 null 时 String() 返回空串,不算终止。
  54. if strings.TrimSpace(choice.Get("finish_reason").String()) != "" {
  55. t.sawFinishReason = true
  56. return
  57. }
  58. }
  59. }
  60. // Terminated 表示上游给出过终止信号。
  61. func (t *openAIRawStreamTerminalState) Terminated() bool {
  62. return t != nil && (t.sawDone || t.sawUsage || t.sawFinishReason)
  63. }
  64. // IsTruncated 判定上游是否在任何终止信号之前结束。clientOutputStarted 用于放行
  65. // 非 SSE 响应体:那类响应本就没有 data: 行,既有行为是原样透传,不在本次判定范围内;
  66. // 但"一个字节都没收到"的空 200 依然算截断。
  67. func (t *openAIRawStreamTerminalState) IsTruncated(clientOutputStarted bool) bool {
  68. if t == nil || t.Terminated() {
  69. return false
  70. }
  71. return t.sawDataLine || !clientOutputStarted
  72. }
  73. // newOpenAIRawStreamTruncatedFailoverError 处理"上游截断且尚未向客户端写出任何
  74. // 字节"的情况:响应头还没提交,可以透明换号重试,客户端不会看到半截流。
  75. func newOpenAIRawStreamTruncatedFailoverError(
  76. c *gin.Context,
  77. account *Account,
  78. upstreamRequestID string,
  79. cause error,
  80. ) *UpstreamFailoverError {
  81. recordOpenAIRawStreamTruncation(c, account, upstreamRequestID, cause, "failover")
  82. headers := http.Header{}
  83. if id := strings.TrimSpace(upstreamRequestID); id != "" {
  84. headers.Set("x-request-id", id)
  85. }
  86. return &UpstreamFailoverError{
  87. StatusCode: http.StatusBadGateway,
  88. ResponseBody: openAIRawStreamTruncatedErrorBody(cause),
  89. ResponseHeaders: headers,
  90. }
  91. }
  92. // recordOpenAIRawStreamTruncation 把上游截断记入 ops 上下文,使其在错误日志与
  93. // 账号健康度中可见——这正是此前"HTTP 200 假成功"丢掉的信息。
  94. func recordOpenAIRawStreamTruncation(
  95. c *gin.Context,
  96. account *Account,
  97. upstreamRequestID string,
  98. cause error,
  99. kind string,
  100. ) {
  101. if c == nil {
  102. return
  103. }
  104. message := openAIRawStreamTruncatedMessage(cause)
  105. platform := PlatformOpenAI
  106. accountID := int64(0)
  107. accountName := ""
  108. if account != nil {
  109. platform = account.Platform
  110. accountID = account.ID
  111. accountName = account.Name
  112. }
  113. setOpsUpstreamError(c, http.StatusBadGateway, message, "")
  114. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  115. ProxyID: opsUpstreamProxyID(account),
  116. ProxyName: opsUpstreamProxyName(account),
  117. Platform: platform,
  118. AccountID: accountID,
  119. AccountName: accountName,
  120. UpstreamStatusCode: http.StatusBadGateway,
  121. UpstreamRequestID: strings.TrimSpace(upstreamRequestID),
  122. Kind: kind,
  123. Message: message,
  124. })
  125. }
  126. // openAIRawStreamTruncatedMessage 拼出 ops 消息:干净 EOF 没有底层错误可带,
  127. // 传输层错误(connection reset / http2 stream error)则保留原因以便定位。
  128. func openAIRawStreamTruncatedMessage(cause error) string {
  129. if cause == nil || errors.Is(cause, ErrOpenAIUpstreamStreamTruncated) {
  130. return openAIRawStreamTruncatedUpstreamMessage
  131. }
  132. return openAIRawStreamTruncatedUpstreamMessage + ": " + cause.Error()
  133. }
  134. // openAIRawStreamTruncatedErrorBody 构造 failover 错误体,code/message 与
  135. // 写出后走 openAIUpstreamStreamReadError 的客户端分类保持一致。
  136. func openAIRawStreamTruncatedErrorBody(cause error) []byte {
  137. code, message := classifyOpenAIUpstreamStreamReadError(cause)
  138. body, err := json.Marshal(map[string]any{
  139. "error": map[string]any{
  140. "type": "upstream_error",
  141. "code": code,
  142. "message": message,
  143. },
  144. })
  145. if err != nil {
  146. return []byte(`{"error":{"type":"upstream_error","code":"` + OpenAIUpstreamStreamTruncatedCode +
  147. `","message":"Upstream response stream ended before completion"}}`)
  148. }
  149. return body
  150. }