| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- 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{
- ProxyID: opsUpstreamProxyID(account),
- ProxyName: opsUpstreamProxyName(account),
- 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{
- ProxyID: opsUpstreamProxyID(account),
- ProxyName: opsUpstreamProxyName(account),
- 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
- }
|