openai_silent_refusal.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. package service
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "net/http"
  6. "strings"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  8. "github.com/gin-gonic/gin"
  9. "github.com/tidwall/gjson"
  10. )
  11. const (
  12. openAISilentRefusalMinRequestBodyBytes = 64 * 1024
  13. openAISilentRefusalErrorCode = "openai_silent_refusal"
  14. openAISilentRefusalUpstreamMessage = "OpenAI upstream returned an empty completion stream with finish_reason=stop and no usage"
  15. openAISilentRefusalClientMessage = "Upstream returned an empty completion without usage; no fallback account was available"
  16. openAIResponsesEmptyCompletedMessage = "OpenAI upstream returned an empty response.completed stream with no output and no usage"
  17. )
  18. type openAIChatSilentRefusalDetector struct {
  19. enabled bool
  20. sawContent bool
  21. sawToolCall bool
  22. sawFunctionCall bool
  23. sawUsage bool
  24. sawError bool
  25. sawReasoning bool
  26. sawFinish bool
  27. finishReason string
  28. }
  29. func newOpenAIChatSilentRefusalDetector(requestBodyLen int) *openAIChatSilentRefusalDetector {
  30. return &openAIChatSilentRefusalDetector{
  31. enabled: requestBodyLen >= openAISilentRefusalMinRequestBodyBytes,
  32. }
  33. }
  34. func (d *openAIChatSilentRefusalDetector) Enabled() bool {
  35. return d != nil && d.enabled
  36. }
  37. func (d *openAIChatSilentRefusalDetector) ObserveSSELine(line string) {
  38. if d == nil || !d.enabled {
  39. return
  40. }
  41. if eventType, ok := extractOpenAISSEEventLine(line); ok {
  42. d.observeEventType(eventType)
  43. return
  44. }
  45. if payload, ok := extractOpenAISSEDataLine(line); ok {
  46. d.ObservePayload([]byte(payload))
  47. }
  48. }
  49. func (d *openAIChatSilentRefusalDetector) ObservePayload(payload []byte) {
  50. if d == nil || !d.enabled {
  51. return
  52. }
  53. payload = bytes.TrimSpace(payload)
  54. if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
  55. return
  56. }
  57. if !gjson.ValidBytes(payload) {
  58. return
  59. }
  60. eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String())
  61. d.observeEventType(eventType)
  62. if gjson.GetBytes(payload, "error").Exists() {
  63. d.sawError = true
  64. }
  65. if usage := gjson.GetBytes(payload, "usage"); usage.Exists() && usage.IsObject() {
  66. d.sawUsage = true
  67. }
  68. if usage := gjson.GetBytes(payload, "response.usage"); usage.Exists() && usage.IsObject() {
  69. d.sawUsage = true
  70. }
  71. d.observeChatChoicesPayload(payload)
  72. d.observeResponsesPayload(payload, eventType)
  73. }
  74. func (d *openAIChatSilentRefusalDetector) ObserveChatChunk(chunk apicompat.ChatCompletionsChunk) {
  75. if d == nil || !d.enabled {
  76. return
  77. }
  78. if chunk.Usage != nil {
  79. d.sawUsage = true
  80. }
  81. for _, choice := range chunk.Choices {
  82. if choice.FinishReason != nil {
  83. d.observeFinishReason(*choice.FinishReason)
  84. }
  85. delta := choice.Delta
  86. if delta.Content != nil && *delta.Content != "" {
  87. d.sawContent = true
  88. }
  89. if delta.ReasoningContent != nil {
  90. d.sawReasoning = true
  91. }
  92. if len(delta.ToolCalls) > 0 {
  93. d.sawToolCall = true
  94. }
  95. }
  96. }
  97. func (d *openAIChatSilentRefusalDetector) ShouldReleaseClientOutput() bool {
  98. if d == nil || !d.enabled {
  99. return true
  100. }
  101. if d.sawContent || d.sawToolCall || d.sawFunctionCall || d.sawUsage || d.sawError || d.sawReasoning {
  102. return true
  103. }
  104. return d.sawFinish && d.finishReason != "" && d.finishReason != "stop"
  105. }
  106. func (d *openAIChatSilentRefusalDetector) IsSilentRefusal() bool {
  107. if d == nil || !d.enabled {
  108. return false
  109. }
  110. return !d.sawContent &&
  111. !d.sawToolCall &&
  112. !d.sawFunctionCall &&
  113. !d.sawUsage &&
  114. !d.sawError &&
  115. !d.sawReasoning &&
  116. d.sawFinish &&
  117. d.finishReason == "stop"
  118. }
  119. func (d *openAIChatSilentRefusalDetector) observeEventType(eventType string) {
  120. eventType = strings.TrimSpace(eventType)
  121. if eventType == "" {
  122. return
  123. }
  124. if eventType == "error" || eventType == "response.failed" {
  125. d.sawError = true
  126. }
  127. if strings.Contains(eventType, "reasoning") || strings.Contains(eventType, "reasoning_summary") {
  128. d.sawReasoning = true
  129. }
  130. }
  131. func (d *openAIChatSilentRefusalDetector) observeFinishReason(reason string) {
  132. reason = strings.TrimSpace(reason)
  133. if reason == "" {
  134. return
  135. }
  136. d.sawFinish = true
  137. d.finishReason = reason
  138. }
  139. func (d *openAIChatSilentRefusalDetector) observeChatChoicesPayload(payload []byte) {
  140. choices := gjson.GetBytes(payload, "choices")
  141. if !choices.Exists() || !choices.IsArray() {
  142. return
  143. }
  144. for _, choice := range choices.Array() {
  145. if finish := choice.Get("finish_reason"); finish.Exists() {
  146. d.observeFinishReason(finish.String())
  147. }
  148. delta := choice.Get("delta")
  149. if !delta.Exists() {
  150. continue
  151. }
  152. if content := delta.Get("content"); content.Exists() && content.String() != "" {
  153. d.sawContent = true
  154. }
  155. if delta.Get("tool_calls").Exists() {
  156. d.sawToolCall = true
  157. }
  158. if delta.Get("function_call").Exists() {
  159. d.sawFunctionCall = true
  160. }
  161. if delta.Get("reasoning").Exists() ||
  162. delta.Get("reasoning_content").Exists() ||
  163. delta.Get("reasoning_summary").Exists() {
  164. d.sawReasoning = true
  165. }
  166. }
  167. }
  168. func (d *openAIChatSilentRefusalDetector) observeResponsesPayload(payload []byte, eventType string) {
  169. switch eventType {
  170. case "response.output_text.delta":
  171. if gjson.GetBytes(payload, "delta").String() != "" {
  172. d.sawContent = true
  173. }
  174. case "response.output_item.added":
  175. switch strings.TrimSpace(gjson.GetBytes(payload, "item.type").String()) {
  176. case "function_call":
  177. d.sawToolCall = true
  178. case "reasoning":
  179. d.sawReasoning = true
  180. }
  181. case "response.function_call_arguments.delta":
  182. d.sawToolCall = true
  183. case "response.reasoning_summary_text.delta", "response.reasoning_summary_text.done":
  184. d.sawReasoning = true
  185. case "response.completed", "response.done":
  186. d.observeFinishReason("stop")
  187. case "response.incomplete":
  188. d.observeFinishReason("length")
  189. case "response.failed":
  190. d.sawError = true
  191. }
  192. if output := gjson.GetBytes(payload, "response.output"); output.Exists() && output.IsArray() {
  193. for _, item := range output.Array() {
  194. switch strings.TrimSpace(item.Get("type").String()) {
  195. case "function_call":
  196. d.sawToolCall = true
  197. case "reasoning":
  198. d.sawReasoning = true
  199. case "message":
  200. d.observeResponseMessageItem(item)
  201. }
  202. }
  203. }
  204. }
  205. func (d *openAIChatSilentRefusalDetector) observeResponseMessageItem(item gjson.Result) {
  206. content := item.Get("content")
  207. if !content.Exists() || !content.IsArray() {
  208. return
  209. }
  210. for _, part := range content.Array() {
  211. if part.Get("text").String() != "" {
  212. d.sawContent = true
  213. return
  214. }
  215. }
  216. }
  217. func newOpenAISilentRefusalFailoverError(c *gin.Context, account *Account, upstreamRequestID string) *UpstreamFailoverError {
  218. accountID := int64(0)
  219. accountName := ""
  220. platform := PlatformOpenAI
  221. if account != nil {
  222. accountID = account.ID
  223. accountName = account.Name
  224. platform = account.Platform
  225. }
  226. setOpsUpstreamError(c, http.StatusBadGateway, openAISilentRefusalUpstreamMessage, "")
  227. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  228. ProxyID: opsUpstreamProxyID(account),
  229. ProxyName: opsUpstreamProxyName(account),
  230. Platform: platform,
  231. AccountID: accountID,
  232. AccountName: accountName,
  233. UpstreamStatusCode: http.StatusBadGateway,
  234. UpstreamRequestID: upstreamRequestID,
  235. Kind: "failover",
  236. Message: openAISilentRefusalUpstreamMessage,
  237. })
  238. headers := http.Header{}
  239. if strings.TrimSpace(upstreamRequestID) != "" {
  240. headers.Set("x-request-id", strings.TrimSpace(upstreamRequestID))
  241. }
  242. return &UpstreamFailoverError{
  243. StatusCode: http.StatusBadGateway,
  244. ResponseBody: openAISilentRefusalErrorBody(),
  245. ResponseHeaders: headers,
  246. }
  247. }
  248. // newOpenAIResponsesEmptyCompletedFailoverError marks an empty
  249. // response.completed terminal event as a retryable upstream anomaly. OpenAI
  250. // Responses streams that deliver only response.created + response.completed
  251. // with no output, no usage and no error are treated as silent upstream
  252. // refusals rather than successful empty replies (issue #5009).
  253. func newOpenAIResponsesEmptyCompletedFailoverError(c *gin.Context, account *Account, upstreamRequestID string) *UpstreamFailoverError {
  254. accountID := int64(0)
  255. accountName := ""
  256. platform := PlatformOpenAI
  257. if account != nil {
  258. accountID = account.ID
  259. accountName = account.Name
  260. platform = account.Platform
  261. }
  262. setOpsUpstreamError(c, http.StatusBadGateway, openAIResponsesEmptyCompletedMessage, "")
  263. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  264. ProxyID: opsUpstreamProxyID(account),
  265. ProxyName: opsUpstreamProxyName(account),
  266. Platform: platform,
  267. AccountID: accountID,
  268. AccountName: accountName,
  269. UpstreamStatusCode: http.StatusBadGateway,
  270. UpstreamRequestID: upstreamRequestID,
  271. Kind: "failover",
  272. Message: openAIResponsesEmptyCompletedMessage,
  273. })
  274. headers := http.Header{}
  275. if strings.TrimSpace(upstreamRequestID) != "" {
  276. headers.Set("x-request-id", strings.TrimSpace(upstreamRequestID))
  277. }
  278. return &UpstreamFailoverError{
  279. StatusCode: http.StatusBadGateway,
  280. ResponseBody: openAISilentRefusalErrorBody(),
  281. ResponseHeaders: headers,
  282. }
  283. }
  284. func openAISilentRefusalErrorBody() []byte {
  285. body, err := json.Marshal(map[string]any{
  286. "error": map[string]any{
  287. "type": "upstream_error",
  288. "code": openAISilentRefusalErrorCode,
  289. "message": openAISilentRefusalUpstreamMessage,
  290. },
  291. })
  292. if err != nil {
  293. return []byte(`{"error":{"type":"upstream_error","code":"openai_silent_refusal","message":"OpenAI upstream returned an empty completion stream with finish_reason=stop and no usage"}}`)
  294. }
  295. return body
  296. }
  297. // IsOpenAISilentRefusalErrorBody reports whether a failover body was produced
  298. // by the OpenAI silent-refusal detector.
  299. func IsOpenAISilentRefusalErrorBody(body []byte) bool {
  300. return strings.TrimSpace(gjson.GetBytes(body, "error.code").String()) == openAISilentRefusalErrorCode
  301. }
  302. // OpenAISilentRefusalClientMessage returns the exhausted-failover client message
  303. // for OpenAI silent refusals.
  304. func OpenAISilentRefusalClientMessage() string {
  305. return openAISilentRefusalClientMessage
  306. }