| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- 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
- }
|