mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-03 08:40:36 +00:00
A Responses stream that ends with response.completed but carries no output, no usage and no error (only response.created + response.completed observed) is a silent upstream refusal. It was previously treated as a successful turn, recording 0 input / 0 output tokens without triggering failover, so clients saw an empty reply and usage was never billed. Detect the empty terminal event in both the standard and passthrough streaming paths while nothing has been written to the client, and return an UpstreamFailoverError so the next account is tried; when failover is exhausted the client gets an explicit Responses-format error instead of an empty success. Streams with any semantic output, usage or error are untouched. Fixes #5009.
332 lines
9.5 KiB
Go
332 lines
9.5 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
const (
|
|
openAISilentRefusalMinRequestBodyBytes = 64 * 1024
|
|
openAISilentRefusalErrorCode = "openai_silent_refusal"
|
|
openAISilentRefusalUpstreamMessage = "OpenAI upstream returned an empty completion stream with finish_reason=stop and no usage"
|
|
openAISilentRefusalClientMessage = "Upstream returned an empty completion without usage; no fallback account was available"
|
|
openAIResponsesEmptyCompletedMessage = "OpenAI upstream returned an empty response.completed stream with no output and no usage"
|
|
)
|
|
|
|
type openAIChatSilentRefusalDetector struct {
|
|
enabled bool
|
|
sawContent bool
|
|
sawToolCall bool
|
|
sawFunctionCall bool
|
|
sawUsage bool
|
|
sawError bool
|
|
sawReasoning bool
|
|
sawFinish bool
|
|
finishReason string
|
|
}
|
|
|
|
func newOpenAIChatSilentRefusalDetector(requestBodyLen int) *openAIChatSilentRefusalDetector {
|
|
return &openAIChatSilentRefusalDetector{
|
|
enabled: requestBodyLen >= openAISilentRefusalMinRequestBodyBytes,
|
|
}
|
|
}
|
|
|
|
func (d *openAIChatSilentRefusalDetector) Enabled() bool {
|
|
return d != nil && d.enabled
|
|
}
|
|
|
|
func (d *openAIChatSilentRefusalDetector) ObserveSSELine(line string) {
|
|
if d == nil || !d.enabled {
|
|
return
|
|
}
|
|
if eventType, ok := extractOpenAISSEEventLine(line); ok {
|
|
d.observeEventType(eventType)
|
|
return
|
|
}
|
|
if payload, ok := extractOpenAISSEDataLine(line); ok {
|
|
d.ObservePayload([]byte(payload))
|
|
}
|
|
}
|
|
|
|
func (d *openAIChatSilentRefusalDetector) ObservePayload(payload []byte) {
|
|
if d == nil || !d.enabled {
|
|
return
|
|
}
|
|
payload = bytes.TrimSpace(payload)
|
|
if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
|
|
return
|
|
}
|
|
if !gjson.ValidBytes(payload) {
|
|
return
|
|
}
|
|
|
|
eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String())
|
|
d.observeEventType(eventType)
|
|
|
|
if gjson.GetBytes(payload, "error").Exists() {
|
|
d.sawError = true
|
|
}
|
|
if usage := gjson.GetBytes(payload, "usage"); usage.Exists() && usage.IsObject() {
|
|
d.sawUsage = true
|
|
}
|
|
if usage := gjson.GetBytes(payload, "response.usage"); usage.Exists() && usage.IsObject() {
|
|
d.sawUsage = true
|
|
}
|
|
|
|
d.observeChatChoicesPayload(payload)
|
|
d.observeResponsesPayload(payload, eventType)
|
|
}
|
|
|
|
func (d *openAIChatSilentRefusalDetector) ObserveChatChunk(chunk apicompat.ChatCompletionsChunk) {
|
|
if d == nil || !d.enabled {
|
|
return
|
|
}
|
|
if chunk.Usage != nil {
|
|
d.sawUsage = true
|
|
}
|
|
for _, choice := range chunk.Choices {
|
|
if choice.FinishReason != nil {
|
|
d.observeFinishReason(*choice.FinishReason)
|
|
}
|
|
delta := choice.Delta
|
|
if delta.Content != nil && *delta.Content != "" {
|
|
d.sawContent = true
|
|
}
|
|
if delta.ReasoningContent != nil {
|
|
d.sawReasoning = true
|
|
}
|
|
if len(delta.ToolCalls) > 0 {
|
|
d.sawToolCall = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *openAIChatSilentRefusalDetector) ShouldReleaseClientOutput() bool {
|
|
if d == nil || !d.enabled {
|
|
return true
|
|
}
|
|
if d.sawContent || d.sawToolCall || d.sawFunctionCall || d.sawUsage || d.sawError || d.sawReasoning {
|
|
return true
|
|
}
|
|
return d.sawFinish && d.finishReason != "" && d.finishReason != "stop"
|
|
}
|
|
|
|
func (d *openAIChatSilentRefusalDetector) IsSilentRefusal() bool {
|
|
if d == nil || !d.enabled {
|
|
return false
|
|
}
|
|
return !d.sawContent &&
|
|
!d.sawToolCall &&
|
|
!d.sawFunctionCall &&
|
|
!d.sawUsage &&
|
|
!d.sawError &&
|
|
!d.sawReasoning &&
|
|
d.sawFinish &&
|
|
d.finishReason == "stop"
|
|
}
|
|
|
|
func (d *openAIChatSilentRefusalDetector) observeEventType(eventType string) {
|
|
eventType = strings.TrimSpace(eventType)
|
|
if eventType == "" {
|
|
return
|
|
}
|
|
if eventType == "error" || eventType == "response.failed" {
|
|
d.sawError = true
|
|
}
|
|
if strings.Contains(eventType, "reasoning") || strings.Contains(eventType, "reasoning_summary") {
|
|
d.sawReasoning = true
|
|
}
|
|
}
|
|
|
|
func (d *openAIChatSilentRefusalDetector) observeFinishReason(reason string) {
|
|
reason = strings.TrimSpace(reason)
|
|
if reason == "" {
|
|
return
|
|
}
|
|
d.sawFinish = true
|
|
d.finishReason = reason
|
|
}
|
|
|
|
func (d *openAIChatSilentRefusalDetector) observeChatChoicesPayload(payload []byte) {
|
|
choices := gjson.GetBytes(payload, "choices")
|
|
if !choices.Exists() || !choices.IsArray() {
|
|
return
|
|
}
|
|
for _, choice := range choices.Array() {
|
|
if finish := choice.Get("finish_reason"); finish.Exists() {
|
|
d.observeFinishReason(finish.String())
|
|
}
|
|
delta := choice.Get("delta")
|
|
if !delta.Exists() {
|
|
continue
|
|
}
|
|
if content := delta.Get("content"); content.Exists() && content.String() != "" {
|
|
d.sawContent = true
|
|
}
|
|
if delta.Get("tool_calls").Exists() {
|
|
d.sawToolCall = true
|
|
}
|
|
if delta.Get("function_call").Exists() {
|
|
d.sawFunctionCall = true
|
|
}
|
|
if delta.Get("reasoning").Exists() ||
|
|
delta.Get("reasoning_content").Exists() ||
|
|
delta.Get("reasoning_summary").Exists() {
|
|
d.sawReasoning = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *openAIChatSilentRefusalDetector) observeResponsesPayload(payload []byte, eventType string) {
|
|
switch eventType {
|
|
case "response.output_text.delta":
|
|
if gjson.GetBytes(payload, "delta").String() != "" {
|
|
d.sawContent = true
|
|
}
|
|
case "response.output_item.added":
|
|
switch strings.TrimSpace(gjson.GetBytes(payload, "item.type").String()) {
|
|
case "function_call":
|
|
d.sawToolCall = true
|
|
case "reasoning":
|
|
d.sawReasoning = true
|
|
}
|
|
case "response.function_call_arguments.delta":
|
|
d.sawToolCall = true
|
|
case "response.reasoning_summary_text.delta", "response.reasoning_summary_text.done":
|
|
d.sawReasoning = true
|
|
case "response.completed", "response.done":
|
|
d.observeFinishReason("stop")
|
|
case "response.incomplete":
|
|
d.observeFinishReason("length")
|
|
case "response.failed":
|
|
d.sawError = true
|
|
}
|
|
|
|
if output := gjson.GetBytes(payload, "response.output"); output.Exists() && output.IsArray() {
|
|
for _, item := range output.Array() {
|
|
switch strings.TrimSpace(item.Get("type").String()) {
|
|
case "function_call":
|
|
d.sawToolCall = true
|
|
case "reasoning":
|
|
d.sawReasoning = true
|
|
case "message":
|
|
d.observeResponseMessageItem(item)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *openAIChatSilentRefusalDetector) observeResponseMessageItem(item gjson.Result) {
|
|
content := item.Get("content")
|
|
if !content.Exists() || !content.IsArray() {
|
|
return
|
|
}
|
|
for _, part := range content.Array() {
|
|
if part.Get("text").String() != "" {
|
|
d.sawContent = true
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func newOpenAISilentRefusalFailoverError(c *gin.Context, account *Account, upstreamRequestID string) *UpstreamFailoverError {
|
|
accountID := int64(0)
|
|
accountName := ""
|
|
platform := PlatformOpenAI
|
|
if account != nil {
|
|
accountID = account.ID
|
|
accountName = account.Name
|
|
platform = account.Platform
|
|
}
|
|
|
|
setOpsUpstreamError(c, http.StatusBadGateway, openAISilentRefusalUpstreamMessage, "")
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
Platform: platform,
|
|
AccountID: accountID,
|
|
AccountName: accountName,
|
|
UpstreamStatusCode: http.StatusBadGateway,
|
|
UpstreamRequestID: upstreamRequestID,
|
|
Kind: "failover",
|
|
Message: openAISilentRefusalUpstreamMessage,
|
|
})
|
|
|
|
headers := http.Header{}
|
|
if strings.TrimSpace(upstreamRequestID) != "" {
|
|
headers.Set("x-request-id", strings.TrimSpace(upstreamRequestID))
|
|
}
|
|
return &UpstreamFailoverError{
|
|
StatusCode: http.StatusBadGateway,
|
|
ResponseBody: openAISilentRefusalErrorBody(),
|
|
ResponseHeaders: headers,
|
|
}
|
|
}
|
|
|
|
// newOpenAIResponsesEmptyCompletedFailoverError marks an empty
|
|
// response.completed terminal event as a retryable upstream anomaly. OpenAI
|
|
// Responses streams that deliver only response.created + response.completed
|
|
// with no output, no usage and no error are treated as silent upstream
|
|
// refusals rather than successful empty replies (issue #5009).
|
|
func newOpenAIResponsesEmptyCompletedFailoverError(c *gin.Context, account *Account, upstreamRequestID string) *UpstreamFailoverError {
|
|
accountID := int64(0)
|
|
accountName := ""
|
|
platform := PlatformOpenAI
|
|
if account != nil {
|
|
accountID = account.ID
|
|
accountName = account.Name
|
|
platform = account.Platform
|
|
}
|
|
|
|
setOpsUpstreamError(c, http.StatusBadGateway, openAIResponsesEmptyCompletedMessage, "")
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|
Platform: platform,
|
|
AccountID: accountID,
|
|
AccountName: accountName,
|
|
UpstreamStatusCode: http.StatusBadGateway,
|
|
UpstreamRequestID: upstreamRequestID,
|
|
Kind: "failover",
|
|
Message: openAIResponsesEmptyCompletedMessage,
|
|
})
|
|
|
|
headers := http.Header{}
|
|
if strings.TrimSpace(upstreamRequestID) != "" {
|
|
headers.Set("x-request-id", strings.TrimSpace(upstreamRequestID))
|
|
}
|
|
return &UpstreamFailoverError{
|
|
StatusCode: http.StatusBadGateway,
|
|
ResponseBody: openAISilentRefusalErrorBody(),
|
|
ResponseHeaders: headers,
|
|
}
|
|
}
|
|
|
|
func openAISilentRefusalErrorBody() []byte {
|
|
body, err := json.Marshal(map[string]any{
|
|
"error": map[string]any{
|
|
"type": "upstream_error",
|
|
"code": openAISilentRefusalErrorCode,
|
|
"message": openAISilentRefusalUpstreamMessage,
|
|
},
|
|
})
|
|
if err != nil {
|
|
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"}}`)
|
|
}
|
|
return body
|
|
}
|
|
|
|
// IsOpenAISilentRefusalErrorBody reports whether a failover body was produced
|
|
// by the OpenAI silent-refusal detector.
|
|
func IsOpenAISilentRefusalErrorBody(body []byte) bool {
|
|
return strings.TrimSpace(gjson.GetBytes(body, "error.code").String()) == openAISilentRefusalErrorCode
|
|
}
|
|
|
|
// OpenAISilentRefusalClientMessage returns the exhausted-failover client message
|
|
// for OpenAI silent refusals.
|
|
func OpenAISilentRefusalClientMessage() string {
|
|
return openAISilentRefusalClientMessage
|
|
}
|