Files
sub2api/backend/internal/service/openai_raw_stream_truncation.go
T
Wei-Shaw 4c1f920d58 fix(ops): close proxy attribution gaps and bound queued upstream events
Follow-up to the event-time proxy snapshot: every upstream attempt now
carries attribution, the direct/unknown contract is enforced everywhere,
and the queued upstream_errors array has hard bounds again.

Attribution coverage
- compact fallback retry helper and the streaming compact failover event
  now stamp proxy_id/proxy_name; the non-streaming SSE compact retry
  previously dropped its first attempt on `continue` and now records it
- raw chat-completions stream truncation events (new on main) attributed
- Anthropic/Bedrock transport errors get attribution inside the shared
  handleUpstreamTransportError helper, so all five callers inherit it
- OpenAI first-output timeout takes attribution from the caller: HTTP
  callers pass the HTTP rule, the WS passthrough relay passes the WS rule
  (no managed proxy => unknown, never direct, because http.DefaultClient
  may honor environment proxies)

Contract hardening
- single opsUpstreamProxyAttribution returns (id, name) from one decision;
  a hydrated Proxy without a durable ID is unknown, not id=null+name
- both normalizers (struct at append time, JSON on detail reads) enforce
  proxy_id==null <=> sentinel name; blank managed name => "proxy"
- detail-read normalization edits only the attribution keys via sjson, so
  keys from older struct versions and stored key order are preserved

Queue bounds
- keep at most 256 attempts and ~512 KiB serialized per entry, newest
  first; the oldest retained event records dropped_earlier_attempts
- attempts outside the 16-event body window trim url/message to 512 bytes
- restore the empty-event drop rule, judged before the body window clears
  detail so older real attempts survive

Tests cover each site above (managed proxy, direct, WS unknown), the
normalizer invariants, key/order preservation, and the count/byte bounds.
2026-09-03 15:11:36 +08:00

164 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
// openAIRawStreamTruncatedUpstreamMessage 是 raw CC 直转路径上游截断的 ops 消息。
const openAIRawStreamTruncatedUpstreamMessage = "Upstream Chat Completions stream ended before any terminal chunk"
// openAIRawStreamTerminalState 记录 raw Chat Completions SSE 流是否收到过
// **终止信号**。
//
// 背景:CC 直转路径把上游 SSE 原样透传,此前只要 HTTP 状态是 200 就按成功收尾——
// 上游中途断流(Cloudflare edge reset、后端 worker 掉线)会被伪装成
// `HTTP 200 + usage 0/0`:客户端拿到半截回答,网关既不报错也不计入 SLA,
// Ops 侧完全不可见。
//
// 三种终止信号任一出现即认为上游"讲完了",只是尾巴可能丢失,不作截断处理:
//
// - [DONE] —— OpenAI CC 协议标准哨兵
// - usage chunk —— include_usage 生效时的末尾用量帧(网关强制打开)
// - finish_reason —— 生成正常结束(stop/length/tool_calls/...)
//
// 只认 [DONE] 会误伤那些跑完最后一帧就直接 EOF 的兼容上游;只认 usage 会误伤
// 不支持 include_usage 的上游。三者取并集,把误判压到"上游确实在生成中途被切断"。
type openAIRawStreamTerminalState struct {
// sawDataLine 表示上游至少发过一行 `data:`,即响应确实是 SSE 语义流。
// 非 SSE 响应体(上游对 stream 请求返回裸 JSON)不参与截断判定,保持既有透传行为。
sawDataLine bool
sawDone bool
sawUsage bool
sawFinishReason bool
}
// ObserveDataLine 从单行 SSE `data:` 载荷中提取终止信号。payload 需已 TrimSpace。
func (t *openAIRawStreamTerminalState) ObserveDataLine(payload string) {
if t == nil {
return
}
t.sawDataLine = true
if payload == "[DONE]" {
t.sawDone = true
return
}
if usage := gjson.Get(payload, "usage"); usage.Exists() && usage.IsObject() {
t.sawUsage = true
}
if t.sawFinishReason {
return
}
for _, choice := range gjson.Get(payload, "choices").Array() {
// finish_reason 为 null 时 String() 返回空串,不算终止。
if strings.TrimSpace(choice.Get("finish_reason").String()) != "" {
t.sawFinishReason = true
return
}
}
}
// Terminated 表示上游给出过终止信号。
func (t *openAIRawStreamTerminalState) Terminated() bool {
return t != nil && (t.sawDone || t.sawUsage || t.sawFinishReason)
}
// IsTruncated 判定上游是否在任何终止信号之前结束。clientOutputStarted 用于放行
// 非 SSE 响应体:那类响应本就没有 data: 行,既有行为是原样透传,不在本次判定范围内;
// 但"一个字节都没收到"的空 200 依然算截断。
func (t *openAIRawStreamTerminalState) IsTruncated(clientOutputStarted bool) bool {
if t == nil || t.Terminated() {
return false
}
return t.sawDataLine || !clientOutputStarted
}
// newOpenAIRawStreamTruncatedFailoverError 处理"上游截断且尚未向客户端写出任何
// 字节"的情况:响应头还没提交,可以透明换号重试,客户端不会看到半截流。
func newOpenAIRawStreamTruncatedFailoverError(
c *gin.Context,
account *Account,
upstreamRequestID string,
cause error,
) *UpstreamFailoverError {
recordOpenAIRawStreamTruncation(c, account, upstreamRequestID, cause, "failover")
headers := http.Header{}
if id := strings.TrimSpace(upstreamRequestID); id != "" {
headers.Set("x-request-id", id)
}
return &UpstreamFailoverError{
StatusCode: http.StatusBadGateway,
ResponseBody: openAIRawStreamTruncatedErrorBody(cause),
ResponseHeaders: headers,
}
}
// recordOpenAIRawStreamTruncation 把上游截断记入 ops 上下文,使其在错误日志与
// 账号健康度中可见——这正是此前"HTTP 200 假成功"丢掉的信息。
func recordOpenAIRawStreamTruncation(
c *gin.Context,
account *Account,
upstreamRequestID string,
cause error,
kind string,
) {
if c == nil {
return
}
message := openAIRawStreamTruncatedMessage(cause)
platform := PlatformOpenAI
accountID := int64(0)
accountName := ""
if account != nil {
platform = account.Platform
accountID = account.ID
accountName = account.Name
}
setOpsUpstreamError(c, http.StatusBadGateway, message, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: platform,
AccountID: accountID,
AccountName: accountName,
UpstreamStatusCode: http.StatusBadGateway,
UpstreamRequestID: strings.TrimSpace(upstreamRequestID),
Kind: kind,
Message: message,
})
}
// openAIRawStreamTruncatedMessage 拼出 ops 消息:干净 EOF 没有底层错误可带,
// 传输层错误(connection reset / http2 stream error)则保留原因以便定位。
func openAIRawStreamTruncatedMessage(cause error) string {
if cause == nil || errors.Is(cause, ErrOpenAIUpstreamStreamTruncated) {
return openAIRawStreamTruncatedUpstreamMessage
}
return openAIRawStreamTruncatedUpstreamMessage + ": " + cause.Error()
}
// openAIRawStreamTruncatedErrorBody 构造 failover 错误体,code/message 与
// 写出后走 openAIUpstreamStreamReadError 的客户端分类保持一致。
func openAIRawStreamTruncatedErrorBody(cause error) []byte {
code, message := classifyOpenAIUpstreamStreamReadError(cause)
body, err := json.Marshal(map[string]any{
"error": map[string]any{
"type": "upstream_error",
"code": code,
"message": message,
},
})
if err != nil {
return []byte(`{"error":{"type":"upstream_error","code":"` + OpenAIUpstreamStreamTruncatedCode +
`","message":"Upstream response stream ended before completion"}}`)
}
return body
}