stream_error_event.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. package handler
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net/http"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  9. "github.com/gin-gonic/gin"
  10. "github.com/google/uuid"
  11. )
  12. // responsesFailedError 对齐 OpenAI Responses 协议 error 子对象。
  13. type responsesFailedError struct {
  14. Code string `json:"code"`
  15. Message string `json:"message"`
  16. }
  17. // responsesFailedBody 对齐 apicompat.makeResponsesCompletedEvent 输出的 response 子对象字段集。
  18. // Output 用空 slice(不是 nil)确保 marshal 为 `[]` 而非 `null`。
  19. // CreatedAt 不带 omitempty:严格客户端把它当必填字段,缺失会以
  20. // `missing field 'created_at'` 反序列化失败——那正是本文件要避免的"客户端读不懂终止事件"。
  21. type responsesFailedBody struct {
  22. ID string `json:"id"`
  23. Object string `json:"object"`
  24. CreatedAt int64 `json:"created_at"`
  25. Model string `json:"model,omitempty"`
  26. Status string `json:"status"`
  27. Output []any `json:"output"`
  28. Error responsesFailedError `json:"error"`
  29. }
  30. // responsesFailedEvent 是写入 SSE data 行的顶层结构。
  31. // 故意不带 sequence_number:spec 标记可选,且本函数被调用时无法可靠拿到 last seq。
  32. type responsesFailedEvent struct {
  33. Type string `json:"type"`
  34. Response responsesFailedBody `json:"response"`
  35. }
  36. // writeResponsesFailedSSE emits a `response.failed` SSE event in the OpenAI
  37. // Responses API protocol after the stream has already started.
  38. //
  39. // 必要性:一旦 SSE 头和任意数据(例如等待槽位时的 ping comment)已经 flush,
  40. // HTTP 200 状态码就被固化。此后若网关需要回报错误,只能继续通过 SSE 事件传达。
  41. // 通用的 `event: error` 帧不是 Responses 协议规定的终止事件,
  42. // Codex CLI 等严格 SDK 会因为没收到 `response.completed/failed/incomplete/cancelled`
  43. // 而抛出 "stream closed before response.completed"。
  44. //
  45. // 字段集对齐 apicompat.makeResponsesCompletedEvent:id/object/model/status/output/error。
  46. // 故意不写 sequence_number:本函数被调用时无法可靠拿到当前流的 last sequence,
  47. // 而 OpenAI spec 将 sequence_number 设为可选;省略避免破坏单调性约束。
  48. //
  49. // 返回 true 表示已尝试 SSE 写出(不论 Write 是否成功,caller 都应直接 return)。
  50. // 返回 false 表示 writer 不支持 Flusher,无法以 SSE 形式回报错误;
  51. // 此时 caller 也无法回退到 JSON(HTTP 200 已固化),通常意味着连接已经损坏,
  52. // 应当让请求处理函数 return,由上层关闭连接。
  53. func writeResponsesFailedSSE(c *gin.Context, errType, code, message string) bool {
  54. flusher, ok := c.Writer.(http.Flusher)
  55. if !ok {
  56. return false
  57. }
  58. payload, err := json.Marshal(responsesFailedEvent{
  59. Type: "response.failed",
  60. Response: responsesFailedBody{
  61. ID: synthesizeResponseID(c),
  62. Object: "response",
  63. CreatedAt: time.Now().Unix(),
  64. Model: requestModel(c),
  65. Status: "failed",
  66. Output: []any{},
  67. Error: responsesFailedError{
  68. Code: mapResponsesErrorCode(errType, code),
  69. Message: message,
  70. },
  71. },
  72. })
  73. if err != nil {
  74. _ = c.Error(err)
  75. return true
  76. }
  77. if _, err := fmt.Fprintf(c.Writer, "event: response.failed\ndata: %s\n\n", payload); err != nil {
  78. _ = c.Error(err)
  79. return true
  80. }
  81. flusher.Flush()
  82. return true
  83. }
  84. // inboundIsResponses 判断当前请求是否落在任意 Responses 路由上
  85. // (不区分 root 还是 compact 变体)。
  86. //
  87. // 不能直接用 GetInboundEndpoint(c) == EndpointResponses 比较,因为
  88. // GetInboundEndpoint/NormalizeInboundEndpoint 会把 compact 变体归一化为
  89. // 单独的 EndpointResponsesCompact(而不是 EndpointResponses),
  90. // 而本函数在这里只关心“是不是 Responses 家族的请求”,
  91. // 不需要区分 root/compact,所以不能用那个等值比较。
  92. //
  93. // 这里改用 FullPath 的后缀/子串判断,一次性覆盖 root 和 compact 的所有变体:
  94. // - /v1/responses
  95. // - /v1/responses/compact
  96. // - /responses
  97. // - /responses/compact
  98. // - /backend-api/codex/responses
  99. // - /backend-api/codex/responses/compact
  100. //
  101. // 对于通配路由(如 "/v1/responses/*action")注册的 FullPath 本身就带有
  102. // "/responses/" 子串(例如 "/v1/responses/*action"),所以下面的
  103. // strings.Contains(p, "/responses/") 分支同样能覆盖这些通配路由,
  104. // 不需要额外处理通配符本身。
  105. func inboundIsResponses(c *gin.Context) bool {
  106. if c == nil {
  107. return false
  108. }
  109. p := strings.TrimRight(c.FullPath(), "/")
  110. if p == "" && c.Request != nil && c.Request.URL != nil {
  111. p = strings.TrimRight(c.Request.URL.Path, "/")
  112. }
  113. if p == "" {
  114. return false
  115. }
  116. return strings.HasSuffix(p, "/responses") || strings.Contains(p, "/responses/")
  117. }
  118. // synthesizeResponseID 为合成的 response.failed 事件生成一个稳定的 id。
  119. // 优先复用 server 端生成的 request_id(存在 request.Context 里,由 request_logger 写入),
  120. // 以便客户端报错能与 server 日志关联;缺失时回退 uuid。
  121. func synthesizeResponseID(c *gin.Context) string {
  122. if c != nil && c.Request != nil {
  123. if rid, ok := c.Request.Context().Value(ctxkey.RequestID).(string); ok {
  124. if rid = strings.TrimSpace(rid); rid != "" {
  125. return "resp_" + strings.ReplaceAll(rid, "-", "")
  126. }
  127. }
  128. }
  129. return "resp_" + strings.ReplaceAll(uuid.NewString(), "-", "")
  130. }
  131. // requestModel 取当前请求的 inbound model(由 setOpsRequestContext 写入)。
  132. // 缺失时返回 "";caller 据此决定是否忽略该字段。
  133. func requestModel(c *gin.Context) string {
  134. if c == nil {
  135. return ""
  136. }
  137. if v, ok := c.Get(opsModelKey); ok {
  138. if s, ok := v.(string); ok {
  139. return strings.TrimSpace(s)
  140. }
  141. }
  142. return ""
  143. }
  144. // mapResponsesErrorCode 把内部 errType 映射为 Responses 协议常见的 error.code。
  145. // 无明确映射时原样返回,保证至少可读。
  146. func mapResponsesErrorCode(errType, code string) string {
  147. if code != "" {
  148. return code
  149. }
  150. switch errType {
  151. case "rate_limit_error":
  152. return "rate_limit_exceeded"
  153. case "invalid_request_error":
  154. return "invalid_request"
  155. case "permission_error":
  156. return "permission_denied"
  157. case "authentication_error":
  158. return "authentication_failed"
  159. case "upstream_error":
  160. return "upstream_error"
  161. case "server_error", "api_error", "":
  162. return "server_error"
  163. default:
  164. return errType
  165. }
  166. }