Files
sub2api/backend/internal/service/openai_gateway_response_handling.go
T
akihitohyhandClaude Opus 5 243921dc0a fix(openai): rebuild streaming terminal output from the reported items
A terminal event that arrives with an empty output was rebuilt from delta
accumulation. BufferedResponseAccumulator models only one reasoning item, one
message, and N function calls, and records no item id, status, or phase, so a
turn carrying several items collapsed into a single fabricated message: the
reasoning item disappeared, the real message id was replaced, and phase was
lost.

reconstructResponseOutputFromSSE already prefers the raw output_item.done
items over accumulation for buffered responses. The streaming path had no
equivalent because it never sees the whole body at once. Collect the raw item
of each output_item.done keyed by output_index and rebuild from those, falling
back to accumulation only when the stream reported no done item at all.

Items are stored as raw JSON, so vendor extensions and item types this gateway
does not model survive the rebuild verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:49:36 +08:00

2352 lines
83 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 (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"go.uber.org/zap"
)
// openaiStreamingResult streaming response result
type openaiStreamingResult struct {
usage *OpenAIUsage
firstTokenMs *int
responseID string
imageCount int
imageOutputSizes []string
searchCount int
}
type openaiNonStreamingResult struct {
*OpenAIUsage
usage *OpenAIUsage
responseID string
imageCount int
imageOutputSizes []string
searchCount int
}
func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) {
return s.handleStreamingResponseWithReasoning(ctx, resp, c, account, startTime, originalModel, mappedModel, "")
}
func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel, reasoningEffort string) (*openaiStreamingResult, error) {
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
firstOutputTimeout := time.Duration(0)
if account != nil && account.Platform == PlatformOpenAI {
firstOutputTimeout = s.openAIFirstOutputTimeout(reasoningEffort)
}
guardFirstOutput := firstOutputTimeout > 0
stageFirstOutput := account != nil && account.Platform == PlatformOpenAI
var attemptResponseHeaders http.Header
if stageFirstOutput {
if s.responseHeaderFilter != nil {
attemptResponseHeaders = responseheaders.FilterHeaders(resp.Header, s.responseHeaderFilter)
} else if requestID := strings.TrimSpace(resp.Header.Get("x-request-id")); requestID != "" {
attemptResponseHeaders = http.Header{"X-Request-Id": []string{requestID}}
}
} else if s.responseHeaderFilter != nil {
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
}
// x-codex-turn-state 不在通用响应头白名单内,按 Codex 协议显式回传:
// 客户端会在同回合的后续请求中回带(openai_codex_turn_state.go)。
// OpenAI 首个语义输出前只暂存,溯源在 applyAttemptResponseHeaders 真正提交时记录。
if stageFirstOutput {
stageOpenAICodexTurnState(&attemptResponseHeaders, resp.Header)
} else {
s.relayOpenAICodexTurnState(c, account, resp.Header)
}
// Set SSE response headers
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
// Pass through other headers
if !stageFirstOutput && resp.Header.Get("x-request-id") != "" {
v := resp.Header.Get("x-request-id")
c.Header("x-request-id", v)
}
applyAttemptResponseHeaders := func() {
if !stageFirstOutput || len(attemptResponseHeaders) == 0 || c.Writer.Written() {
return
}
for key, values := range attemptResponseHeaders {
for _, value := range values {
c.Writer.Header().Add(key, value)
}
}
// 暂存头此刻才真正写给客户端:turn-state 溯源在这里记录(见
// noteStagedOpenAICodexTurnStateCommitted 的 failover 说明)。
s.noteStagedOpenAICodexTurnStateCommitted(c, account, attemptResponseHeaders)
// These headers describe this gateway's SSE stream and are stable across
// account attempts. Keep them authoritative over upstream values.
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
}
w := c.Writer
flusher, ok := w.(http.Flusher)
if !ok {
return nil, errors.New("streaming not supported")
}
maxLineSize := defaultMaxLineSize
if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
maxLineSize = s.cfg.Gateway.MaxLineSize
}
var firstTokenMs *int
firstOutputProgressObserved := false
bufferedWriter := bufio.NewWriterSize(w, 4*1024)
var firstOutputStage *openAIFirstOutputStage
if stageFirstOutput {
firstOutputStage = newDefaultOpenAIFirstOutputStage()
defer func() {
if err := firstOutputStage.Close(); err != nil {
logger.LegacyPrintf("service.openai_gateway", "OpenAI first-output staging cleanup failed: account=%d model=%s error=%v", account.ID, originalModel, err)
}
}()
}
writePendingString := func(value string) (int, error) {
if firstOutputStage != nil && !firstOutputStage.closed {
return firstOutputStage.WriteString(value)
}
return bufferedWriter.WriteString(value)
}
pendingBytes := func() int64 {
if firstOutputStage != nil && !firstOutputStage.closed {
return firstOutputStage.Buffered()
}
return int64(bufferedWriter.Buffered())
}
flushBuffered := func() error {
if firstOutputStage != nil && !firstOutputStage.closed {
if err := firstOutputStage.CommitTo(w); err != nil {
return err
}
} else {
if err := bufferedWriter.Flush(); err != nil {
return err
}
}
flusher.Flush()
return nil
}
usage := &OpenAIUsage{}
imageCounter := newOpenAIImageOutputCounter()
responseID := ""
var firstOutputScanGuard atomic.Bool
firstOutputScanGuard.Store(stageFirstOutput)
scanner := bufio.NewScanner(resp.Body)
scanBuf := getSSEScannerBuf64K()
scanner.Buffer(scanBuf[:0], maxLineSize)
if stageFirstOutput {
scanner.Split(openAIFirstOutputDynamicScanLines(&firstOutputScanGuard))
}
documentScanner := newOpenAISSEJSONDocumentScanner(scanner)
streamInterval := time.Duration(0)
if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 {
streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second
}
// Grok: always enforce an upstream-read idle so hung SSE bodies fail over
// instead of holding the OAuth slot until the client cancels. Prefer the
// global gateway setting when set; otherwise apply a Grok-only default.
if account != nil && account.Platform == PlatformGrok {
cfgSec := 0
if s.cfg != nil {
cfgSec = s.cfg.Gateway.StreamDataIntervalTimeout
}
streamInterval = resolveGrokStreamIdleTimeout(cfgSec)
}
// 仅监控上游数据间隔超时,不被下游写入阻塞影响
var intervalTicker *time.Ticker
if streamInterval > 0 {
intervalTicker = time.NewTicker(streamInterval)
defer intervalTicker.Stop()
}
var intervalCh <-chan time.Time
if intervalTicker != nil {
intervalCh = intervalTicker.C
}
keepaliveInterval := time.Duration(0)
if s.cfg != nil && s.cfg.Gateway.StreamKeepaliveInterval > 0 {
keepaliveInterval = time.Duration(s.cfg.Gateway.StreamKeepaliveInterval) * time.Second
}
// 下游 keepalive 仅用于防止代理空闲断开
var keepaliveTicker *time.Ticker
if keepaliveInterval > 0 {
keepaliveTicker = time.NewTicker(keepaliveInterval)
defer keepaliveTicker.Stop()
}
var keepaliveCh <-chan time.Time
if keepaliveTicker != nil {
keepaliveCh = keepaliveTicker.C
}
var firstOutputTimer *time.Timer
var firstOutputCh <-chan time.Time
if firstOutputTimeout > 0 {
remaining := time.Until(startTime.Add(firstOutputTimeout))
if remaining <= 0 {
remaining = time.Nanosecond
}
firstOutputTimer = time.NewTimer(remaining)
firstOutputCh = firstOutputTimer.C
defer firstOutputTimer.Stop()
}
stopFirstOutputTimer := func() {
if firstOutputTimer == nil {
return
}
if !firstOutputTimer.Stop() {
select {
case <-firstOutputTimer.C:
default:
}
}
firstOutputTimer = nil
firstOutputCh = nil
}
// Track downstream writes separately from upstream reads: pre-output failover
// can buffer response.created / response.in_progress, so keepalive must be
// based on downstream idle time.
lastDownstreamWriteAt := time.Now()
// 仅发送一次错误事件,避免多次写入导致协议混乱。
// 注意:OpenAI `/v1/responses` streaming 事件必须符合 OpenAI Responses schema;
// 否则下游 SDK(例如 OpenCode)会因为类型校验失败而报错。
errorEventSent := false
clientDisconnected := false // 客户端断开后继续 drain 上游以收集 usage
sawTerminalEvent := false
sawFailedEvent := false
sawBareError := false
sawResponseFailed := false
terminalEventType := ""
responsesSemanticOutputSeen := false
capacityFailoverSuppressedLogged := false
failedMessage := ""
clientOutputStarted := false
codexFailureTerminal := account != nil && account.IsOpenAIOAuthLike()
upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
var streamEarlyErr error
terminalFailurePending := false
failureDelivered := false
suppressCurrentEvent := false
var bareErrorPayload []byte
bareErrorAccountSideEffectsPending := false
pendingSSEEventType := ""
eventInProgress := false
eventStartsClientOutput := false
eventStartsVisibleOutput := false
eventShouldFlush := false
handlePendingWriteError := func(err error) {
if firstOutputStage != nil && !firstOutputStage.closed {
message := "OpenAI first-output staging failed"
if errors.Is(err, errOpenAIFirstOutputStageLimit) {
message = "OpenAI first-output staging limit exceeded"
}
logger.LegacyPrintf("service.openai_gateway", "%s: account=%d model=%s error=%v", message, account.ID, originalModel, err)
failoverErr := s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, nil, message)
failoverErr.SafeToFailoverAfterWrite = true
streamEarlyErr = failoverErr
_ = resp.Body.Close()
return
}
clientDisconnected = true
logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing")
}
completeGuardedEvent := func(queueDrained bool) {
completedProgressEvent := eventStartsClientOutput
completedVisibleEvent := eventStartsVisibleOutput
shouldFlush := eventShouldFlush || (queueDrained && clientOutputStarted)
eventInProgress = false
if !clientDisconnected {
if completedProgressEvent {
applyAttemptResponseHeaders()
}
if shouldFlush {
if err := flushBuffered(); err != nil {
clientDisconnected = true
logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming flush, continuing to drain upstream for billing")
} else {
clientOutputStarted = true
lastDownstreamWriteAt = time.Now()
}
}
}
if completedProgressEvent && !firstOutputProgressObserved {
firstOutputScanGuard.Store(false)
firstOutputProgressObserved = true
stopFirstOutputTimer()
}
if completedVisibleEvent && firstTokenMs == nil {
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
}
eventStartsClientOutput = false
eventStartsVisibleOutput = false
eventShouldFlush = false
}
sendErrorEvent := func(reason string) {
if errorEventSent || clientDisconnected || failureDelivered {
return
}
errorEventSent = true
payload := `{"type":"error","sequence_number":0,"error":{"type":"upstream_error","message":` + strconv.Quote(reason) + `,"code":` + strconv.Quote(reason) + `}}`
if err := flushBuffered(); err != nil {
clientDisconnected = true
return
}
if _, err := writePendingString("data: " + payload + "\n\n"); err != nil {
clientDisconnected = true
return
}
if err := flushBuffered(); err != nil {
clientDisconnected = true
return
}
clientOutputStarted = true
lastDownstreamWriteAt = time.Now()
}
needModelReplace := originalModel != mappedModel
streamOutputAccumulator := apicompat.NewBufferedResponseAccumulator()
streamDoneItems := newResponsesStreamOutputItems()
streamImageOutputs := make([]json.RawMessage, 0, 1)
streamSeenImages := make(map[string]struct{})
searchCounter := 0
// Dedup search tool calls across SSE events (item.done + response.completed
// both list the same call_id — counting both would ~2× the surcharge).
streamSearchSeen := make(map[string]struct{})
resultWithUsage := func() *openaiStreamingResult {
return &openaiStreamingResult{
usage: usage,
firstTokenMs: firstTokenMs,
responseID: responseID,
imageCount: imageCounter.Count(),
imageOutputSizes: imageCounter.Sizes(),
searchCount: searchCounter,
}
}
flushPending := func(disconnectMessage string) {
if clientDisconnected || pendingBytes() == 0 {
return
}
if err := flushBuffered(); err != nil {
clientDisconnected = true
logger.LegacyPrintf("service.openai_gateway", "%s", disconnectMessage)
return
}
clientOutputStarted = true
lastDownstreamWriteAt = time.Now()
}
finalizeStream := func() (*openaiStreamingResult, error) {
if stageFirstOutput && eventInProgress {
// EOF dispatches the final SSE event even without a trailing blank line.
completeGuardedEvent(true)
}
if codexFailureTerminal && sawBareError && !sawResponseFailed && bareErrorAccountSideEffectsPending {
s.handleOpenAIStreamTerminalAccountSideEffects(c, account, bareErrorPayload, failedMessage, resp.Header)
bareErrorAccountSideEffectsPending = false
}
if codexFailureTerminal && sawBareError && !sawResponseFailed && !clientDisconnected {
applyAttemptResponseHeaders()
if _, err := writePendingString(buildOpenAIResponseFailedSSE(responseID, originalModel, bareErrorPayload, failedMessage)); err != nil {
handlePendingWriteError(err)
} else {
failureDelivered = true
}
}
if sawTerminalEvent && !sawFailedEvent {
s.clearOpenAIProxyStreamDisconnect(account)
}
if !sawTerminalEvent && !openAIStreamClientOutputStarted(c, clientOutputStarted) && !eventShouldFlush {
return resultWithUsage(), s.newOpenAIStreamFailoverError(
c,
account,
false,
upstreamRequestID,
nil,
"OpenAI stream ended before a terminal event",
)
}
flushPending("Client disconnected during final flush, returning collected usage")
if !sawTerminalEvent {
if openAIStreamClientOutputStarted(c, clientOutputStarted) && !clientDisconnected {
s.recordOpenAIProxyStreamDisconnect(account, errors.New("stream ended before terminal event"), upstreamRequestID)
}
return resultWithUsage(), fmt.Errorf("stream usage incomplete: missing terminal event")
}
if sawFailedEvent {
return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
}
logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, terminalEventType, clientDisconnected)
return resultWithUsage(), nil
}
handleScanErr := func(scanErr error) (*openaiStreamingResult, error, bool) {
if scanErr == nil {
return nil, nil, false
}
if errors.Is(scanErr, errOpenAIFirstOutputScannerLimit) && !firstOutputProgressObserved {
logger.LegacyPrintf("service.openai_gateway", "SSE token exceeded guarded first-output limit: account=%d limit=%d error=%v", account.ID, openAIFirstOutputStageMaxBytes+openAIFirstOutputScannerFramingAllowance, scanErr)
failoverErr := s.newOpenAIStreamFailoverError(
c, account, false, upstreamRequestID, nil,
"OpenAI SSE line exceeds guarded first-output limit",
)
failoverErr.SafeToFailoverAfterWrite = true
return resultWithUsage(), failoverErr, true
}
if errors.Is(scanErr, bufio.ErrTooLong) && stageFirstOutput && !firstOutputProgressObserved {
logger.LegacyPrintf("service.openai_gateway", "SSE line too long before first output: account=%d max_size=%d error=%v", account.ID, maxLineSize, scanErr)
failoverErr := s.newOpenAIStreamFailoverError(
c, account, false, upstreamRequestID, nil,
"OpenAI SSE line exceeds guarded first-output limit",
)
failoverErr.SafeToFailoverAfterWrite = true
return resultWithUsage(), failoverErr, true
}
if sawTerminalEvent {
if !sawFailedEvent {
s.clearOpenAIProxyStreamDisconnect(account)
logger.LegacyPrintf("service.openai_gateway", "Upstream scan ended after terminal event: %v", scanErr)
}
result, err := finalizeStream()
return result, err, true
}
// 客户端断开/取消请求时,上游读取往往会返回 context canceled。
// /v1/responses 的 SSE 事件必须符合 OpenAI 协议;这里不注入自定义 error event,避免下游 SDK 解析失败。
if errors.Is(scanErr, context.Canceled) || errors.Is(scanErr, context.DeadlineExceeded) {
if eventShouldFlush {
flushPending("Client disconnected during canceled stream flush, returning collected usage")
}
return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", scanErr), true
}
if errors.Is(scanErr, bufio.ErrTooLong) {
logger.LegacyPrintf("service.openai_gateway", "SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, scanErr)
sendErrorEvent("response_too_large")
return resultWithUsage(), scanErr, true
}
if !openAIStreamClientOutputStarted(c, clientOutputStarted) && !eventShouldFlush {
msg := "OpenAI stream disconnected before completion"
if errText := strings.TrimSpace(scanErr.Error()); errText != "" {
msg += ": " + errText
}
return resultWithUsage(), s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, nil, msg), true
}
// 客户端已断开时,上游出错仅影响体验,不影响计费;返回已收集 usage
if clientDisconnected {
return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", scanErr), true
}
s.recordOpenAIProxyStreamDisconnect(account, scanErr, upstreamRequestID)
sendErrorEvent("stream_read_error")
return resultWithUsage(), fmt.Errorf("stream read error: %w", scanErr), true
}
processSSELine := func(line string, queueDrained bool) {
if streamEarlyErr != nil {
return
}
if eventType, ok := extractOpenAISSEEventLine(line); ok {
pendingSSEEventType = eventType
eventType = strings.TrimSpace(eventType)
suppressCurrentEvent = codexFailureTerminal && (eventType == "error" || (sawBareError && !sawResponseFailed && eventType != "response.failed"))
}
// Extract data from SSE line (supports both "data: " and "data:" formats)
if data, ok := extractOpenAISSEDataLine(line); ok {
dataBytes := []byte(data)
eventType := effectiveOpenAISSEEventType(dataBytes, pendingSSEEventType)
if codexFailureTerminal && sawBareError && !sawResponseFailed &&
(eventType == "response.completed" || eventType == "response.done") {
// A later successful terminal is authoritative over a pending bare
// error. Keep its usage and terminal visible to the client.
sawBareError = false
sawFailedEvent = false
terminalFailurePending = false
suppressCurrentEvent = false
bareErrorPayload = nil
bareErrorAccountSideEffectsPending = false
failedMessage = ""
}
if codexFailureTerminal && sawBareError && !sawResponseFailed && eventType != "response.failed" {
suppressCurrentEvent = true
}
observer.ObserveOpenAI(dataBytes, eventType)
// 初始上游 data 的 type 只解析一次:原始值保持终止事件的精确匹配,规范化值供后续分支复用。
if openAIStreamEventIsTerminalWithType(data, eventType) {
sawTerminalEvent = true
terminalEventType = eventType
if strings.TrimSpace(data) == "[DONE]" {
terminalEventType = "[DONE]"
}
}
if responseID == "" {
responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes)
}
forceFlushFailedEvent := false
if !capacityFailoverSuppressedLogged && account != nil && account.Platform == PlatformOpenAI &&
(eventType == "error" || eventType == "response.failed") &&
openAIStreamClientOutputStarted(c, clientOutputStarted) &&
isOpenAIUpstreamCapacityShedEvent(dataBytes) {
logOpenAICapacityFailoverSuppressed(ctx, account, "native_sse", upstreamRequestID, eventType)
capacityFailoverSuppressedLogged = true
}
cyberHit := false
if eventType == "response.failed" || eventType == "error" {
if codexFailureTerminal && eventType == "error" {
sawBareError = true
bareErrorPayload = append(bareErrorPayload[:0], dataBytes...)
suppressCurrentEvent = true
} else if codexFailureTerminal && eventType == "response.failed" {
sawResponseFailed = true
}
failedMessage = extractOpenAISSEErrorMessage(dataBytes)
if failedMessage == "" {
failedMessage = "Upstream response failed"
}
// response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析
// 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费
// 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。
s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit {
cyberHit = true
MarkOpsCyberPolicy(c, CyberPolicyMark{
Code: code,
Message: msg,
Body: truncateString(string(dataBytes), 4096),
UpstreamStatus: http.StatusOK,
UpstreamInTok: usage.InputTokens,
UpstreamOutTok: usage.OutputTokens,
})
}
outputStarted := openAIStreamClientOutputStarted(c, clientOutputStarted)
if !outputStarted && !cyberHit {
if compactErr := newOpenAICompactFallbackSignal(c, dataBytes, failedMessage); compactErr != nil {
sawFailedEvent = true
streamEarlyErr = compactErr
return
}
}
if outputStarted && !cyberHit {
if codexFailureTerminal && eventType == "error" {
// OpenAI commonly follows a bare error with response.failed.
// Defer account health updates so the pair is applied once.
bareErrorAccountSideEffectsPending = true
} else {
s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header)
bareErrorAccountSideEffectsPending = false
}
}
if !outputStarted {
shouldFailover := false
if !cyberHit {
if eventType == "error" {
shouldFailover = openAIStreamErrorEventShouldFailover(dataBytes, failedMessage)
} else {
shouldFailover = openAIStreamFailedEventShouldFailover(dataBytes, failedMessage)
}
}
if shouldFailover {
sawFailedEvent = true
streamEarlyErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, failedMessage, resp.Header)
return
}
if !cyberHit && !sawBareError {
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
sawFailedEvent = true
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "http_error", dataBytes, failedMessage)
MarkResponseCommitted(c)
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.JSON(status, gin.H{
"error": gin.H{
"type": errType,
"message": errMsg,
},
})
streamEarlyErr = fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg)
return
}
}
}
forceFlushFailedEvent = true
sawFailedEvent = true
terminalFailurePending = !codexFailureTerminal || eventType == "response.failed"
}
if normalizedData, normalized := normalizeCompletedImageGenerationStatus(dataBytes); normalized {
dataBytes = normalizedData
data = string(normalizedData)
line = "data: " + data
}
imageCounter.AddSSEData(dataBytes)
searchCounter += countGrokNativeSearchCallsInSSEDataDedup(dataBytes, streamSearchSeen)
// Correct Codex tool calls if needed (apply_patch -> edit, etc.)
if correctedData, corrected := s.toolCorrector.CorrectToolCallsInSSEBytes(dataBytes); corrected {
dataBytes = correctedData
data = string(correctedData)
line = "data: " + data
eventType = effectiveOpenAISSEEventType(dataBytes, eventType)
}
if imageOutput, ok := extractImageGenerationOutputFromSSEData(dataBytes, streamSeenImages); ok {
streamImageOutputs = append(streamImageOutputs, imageOutput)
}
streamDoneItems.Observe(dataBytes)
if responsesStreamEventMayContributeToOutput(eventType) {
var streamEvent apicompat.ResponsesStreamEvent
if err := json.Unmarshal(dataBytes, &streamEvent); err == nil {
streamOutputAccumulator.ProcessEvent(&streamEvent)
}
}
if normalizedData, normalized := normalizeResponsesStreamingTerminalOutput(dataBytes, streamOutputAccumulator, streamDoneItems, streamImageOutputs); normalized {
dataBytes = normalizedData
data = string(normalizedData)
line = "data: " + data
eventType = effectiveOpenAISSEEventType(dataBytes, eventType)
}
restoredData, restoreErr := restoreGrokResponsesClientToolPayload(c, dataBytes)
if restoreErr != nil {
streamEarlyErr = fmt.Errorf("restore Grok Responses client tool response: %w", restoreErr)
return
}
restoredData, restoreErr = restoreOpenAIResponsesNamespacePayload(c, restoredData)
if restoreErr != nil {
streamEarlyErr = fmt.Errorf("restore OpenAI namespace response: %w", restoreErr)
return
}
restoredData = restoreCodexToolNamesFromSSEContext(c, restoredData, eventType)
if !bytes.Equal(restoredData, dataBytes) {
dataBytes = restoredData
data = string(restoredData)
line = "data: " + data
eventType = effectiveOpenAISSEEventType(dataBytes, eventType)
}
if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(
dataBytes,
eventType,
openAIStreamClientOutputStarted(c, clientOutputStarted),
); sanitized {
dataBytes = sanitizedData
data = string(sanitizedData)
line = "data: " + data
}
// Replace model in response if needed.
// Fast path: most events do not contain model field values.
if needModelReplace && mappedModel != "" && strings.Contains(line, mappedModel) {
line = s.replaceModelInSSELine(line, mappedModel, originalModel)
}
startsClientOutput := forceFlushFailedEvent || openAIStreamDataStartsClientOutput(data, eventType)
startsVisibleOutput := openAIStreamDataStartsVisibleOutput(data, eventType)
if stageFirstOutput {
eventStartsClientOutput = eventStartsClientOutput || startsClientOutput
eventStartsVisibleOutput = eventStartsVisibleOutput || startsVisibleOutput
if startsClientOutput {
firstOutputScanGuard.Store(false)
}
}
if startsClientOutput && !openAIStreamEventTypeIsTerminal(eventType) {
responsesSemanticOutputSeen = true
}
// OpenAI Responses streams that terminate with an empty
// response.completed (no output, no usage, no error, nothing sent
// to the client) are silent upstream refusals: fail over instead of
// recording a successful 0/0 usage turn (issue #5009).
if account != nil && account.Platform == PlatformOpenAI &&
(eventType == "response.completed" || eventType == "response.done") &&
!sawFailedEvent && !responsesSemanticOutputSeen && !clientOutputStarted &&
openAIResponsesCompletedEventIsEmpty(dataBytes, usage) {
sawTerminalEvent = true
streamEarlyErr = newOpenAIResponsesEmptyCompletedFailoverError(c, account, upstreamRequestID)
return
}
// 写入客户端(客户端断开后继续 drain 上游)
if !clientDisconnected && !failureDelivered && !suppressCurrentEvent {
shouldFlush := queueDrained && (clientOutputStarted || startsClientOutput)
if firstTokenMs == nil && startsVisibleOutput {
// 保证首个 token 事件尽快出站,避免影响 TTFT。
shouldFlush = true
}
eventShouldFlush = eventShouldFlush || shouldFlush
if _, err := writePendingString(line); err != nil {
handlePendingWriteError(err)
} else if _, err := writePendingString("\n"); err != nil {
handlePendingWriteError(err)
} else {
eventInProgress = true
}
}
// Record first token time
if !guardFirstOutput && firstTokenMs == nil && startsVisibleOutput {
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
stopFirstOutputTimer()
}
s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
return
}
// A blank line dispatches a guarded event from the attempt-local stage.
if stageFirstOutput && line == "" {
pendingSSEEventType = ""
if suppressCurrentEvent {
suppressCurrentEvent = false
terminalFailurePending = false
eventInProgress = false
eventStartsClientOutput = false
eventStartsVisibleOutput = false
eventShouldFlush = false
return
}
if failureDelivered {
terminalFailurePending = false
eventInProgress = false
eventStartsClientOutput = false
eventStartsVisibleOutput = false
eventShouldFlush = false
return
}
if !clientDisconnected {
if _, err := writePendingString("\n"); err != nil {
handlePendingWriteError(err)
}
}
if streamEarlyErr == nil {
completeGuardedEvent(queueDrained)
}
if terminalFailurePending && streamEarlyErr == nil {
terminalFailurePending = false
failureDelivered = true
}
return
}
// Non-guarded streams retain upstream's event-boundary flushing: a keepalive
// or queue-drain flush must never split an open SSE event.
shouldFlush := false
if line == "" {
pendingSSEEventType = ""
if suppressCurrentEvent {
suppressCurrentEvent = false
terminalFailurePending = false
eventInProgress = false
eventShouldFlush = false
return
}
shouldFlush = eventShouldFlush || (queueDrained && clientOutputStarted)
eventShouldFlush = false
if failureDelivered {
terminalFailurePending = false
}
}
if !clientDisconnected && !failureDelivered && !suppressCurrentEvent {
if _, err := writePendingString(line); err != nil {
handlePendingWriteError(err)
} else if _, err := writePendingString("\n"); err != nil {
handlePendingWriteError(err)
} else {
eventInProgress = line != ""
if shouldFlush {
if err := flushBuffered(); err != nil {
clientDisconnected = true
logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming flush, continuing to drain upstream for billing")
} else {
clientOutputStarted = true
lastDownstreamWriteAt = time.Now()
}
}
}
if line == "" && terminalFailurePending && streamEarlyErr == nil {
terminalFailurePending = false
failureDelivered = true
}
}
}
// 无超时/无 keepalive 的常见路径走同步扫描,减少 goroutine 与 channel 开销。
if streamInterval <= 0 && keepaliveInterval <= 0 && firstOutputTimeout <= 0 {
defer putSSEScannerBuf64K(scanBuf)
for documentScanner.Scan() {
processSSELine(documentScanner.Text(), true)
if streamEarlyErr != nil {
return resultWithUsage(), streamEarlyErr
}
}
if result, err, done := handleScanErr(documentScanner.Err()); done {
return result, err
}
return finalizeStream()
}
type scanEvent struct {
line string
err error
processed chan struct{}
}
// 独立 goroutine 读取上游,避免读取阻塞影响 keepalive/超时处理
// Guard mode permits one queued token plus the token being processed. With
// the guarded scanner cap this bounds scanner/channel retention near 16 MiB;
// the timeout-disabled path preserves the legacy depth of 16.
events := make(chan scanEvent, openAIFirstOutputEventQueueSize(guardFirstOutput))
done := make(chan struct{})
sendEvent := func(ev scanEvent) bool {
if firstOutputScanGuard.Load() {
ev.processed = make(chan struct{})
}
select {
case events <- ev:
case <-done:
return false
}
if ev.processed == nil {
return true
}
select {
case <-ev.processed:
return true
case <-done:
return false
}
}
markEventProcessed := func(ev scanEvent) {
if ev.processed != nil {
close(ev.processed)
}
}
var lastReadAt int64
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
go func(scanBuf *sseScannerBuf64K) {
defer putSSEScannerBuf64K(scanBuf)
defer close(events)
for documentScanner.Scan() {
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
if !sendEvent(scanEvent{line: documentScanner.Text()}) {
return
}
}
if err := documentScanner.Err(); err != nil {
_ = sendEvent(scanEvent{err: err})
}
}(scanBuf)
defer close(done)
for {
select {
case ev, ok := <-events:
if !ok {
if stageFirstOutput && eventInProgress {
// EOF dispatches the final SSE event even without a trailing blank
// line. Do not synthesize extra bytes on the downstream wire.
completeGuardedEvent(true)
}
return finalizeStream()
}
if result, err, done := handleScanErr(ev.err); done {
markEventProcessed(ev)
return result, err
}
processSSELine(ev.line, len(events) == 0)
markEventProcessed(ev)
if streamEarlyErr != nil {
return resultWithUsage(), streamEarlyErr
}
case <-intervalCh:
if failureDelivered {
return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
}
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
if time.Since(lastRead) < streamInterval {
continue
}
if codexFailureTerminal && sawBareError && !sawResponseFailed {
_ = resp.Body.Close()
return finalizeStream()
}
if clientDisconnected {
return resultWithUsage(), fmt.Errorf("stream usage incomplete after timeout")
}
logger.LegacyPrintf("service.openai_gateway", "Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval)
// 处理流超时,可能标记账户为临时不可调度或错误状态
if s.rateLimitService != nil {
s.rateLimitService.HandleStreamTimeout(ctx, account, originalModel)
}
// Grok: short cool + account failover when no client-visible bytes
// were committed yet (pre-commit). After output started we keep the
// legacy stream_timeout path so partial SSE is not dual-written.
if account != nil && account.Platform == PlatformGrok {
s.tempUnscheduleGrok(ctx, account, grokStreamIdleCooldown, "grok stream idle timeout")
if !openAIStreamClientOutputStarted(c, clientOutputStarted) && !eventShouldFlush {
_ = resp.Body.Close()
return resultWithUsage(), grokStreamIdleFailoverError(account, streamInterval)
}
}
sendErrorEvent("stream_timeout")
return resultWithUsage(), fmt.Errorf("stream data interval timeout")
case <-firstOutputCh:
if firstOutputProgressObserved {
stopFirstOutputTimer()
continue
}
if codexFailureTerminal && sawBareError && !sawResponseFailed && len(events) == 0 {
_ = resp.Body.Close()
return finalizeStream()
}
_ = resp.Body.Close()
for ev := range events {
markEventProcessed(ev)
}
return resultWithUsage(), s.newOpenAIFirstOutputTimeoutError(
ctx, c, account, startTime, originalModel, reasoningEffort,
firstOutputTimeout, "semantic_output", resp.Header,
)
case <-keepaliveCh:
if clientDisconnected || failureDelivered {
continue
}
if eventInProgress {
continue
}
if time.Since(lastDownstreamWriteAt) < keepaliveInterval {
continue
}
if stageFirstOutput {
// Bypass attempt-local buffered frames. The stable SSE headers may be
// committed here, but account headers remain private until semantic output.
n, err := w.Write([]byte(":\n\n"))
recordOpenAIStreamKeepaliveBytes(c, n)
if err != nil {
clientDisconnected = true
logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing")
continue
}
flusher.Flush()
lastDownstreamWriteAt = time.Now()
continue
}
if _, err := writePendingString(":\n\n"); err != nil {
clientDisconnected = true
logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing")
continue
}
if err := flushBuffered(); err != nil {
clientDisconnected = true
logger.LegacyPrintf("service.openai_gateway", "Client disconnected during keepalive flush, continuing to drain upstream for billing")
} else {
lastDownstreamWriteAt = time.Now()
}
}
}
}
// extractOpenAISSEDataLine 低开销提取 SSE `data:` 行内容。
// 兼容 `data: xxx` 与 `data:xxx` 两种格式。
func extractOpenAISSEDataLine(line string) (string, bool) {
if !strings.HasPrefix(line, "data:") {
return "", false
}
start := len("data:")
for start < len(line) {
if line[start] != ' ' && line[start] != ' ' {
break
}
start++
}
return line[start:], true
}
func extractOpenAISSEEventLine(line string) (string, bool) {
if !strings.HasPrefix(line, "event:") {
return "", false
}
start := len("event:")
for start < len(line) {
if line[start] != ' ' && line[start] != ' ' {
break
}
start++
}
return strings.TrimSpace(line[start:]), true
}
type openAICompatSSEFrame struct {
EventType string
Data string
}
type openAICompatSSEFrameParser struct {
eventType string
dataLines []string
}
func (p *openAICompatSSEFrameParser) AddLine(line string) (openAICompatSSEFrame, bool) {
if line == "" {
return p.dispatch()
}
if strings.HasPrefix(line, ":") {
return openAICompatSSEFrame{}, false
}
if eventType, ok := extractOpenAISSEEventLine(line); ok {
p.eventType = eventType
return openAICompatSSEFrame{}, false
}
if data, ok := extractOpenAISSEDataLine(line); ok {
p.dataLines = append(p.dataLines, data)
}
return openAICompatSSEFrame{}, false
}
func (p *openAICompatSSEFrameParser) Finish() (openAICompatSSEFrame, bool) {
return p.dispatch()
}
func (p *openAICompatSSEFrameParser) dispatch() (openAICompatSSEFrame, bool) {
frame := openAICompatSSEFrame{
EventType: p.eventType,
Data: strings.Join(p.dataLines, "\n"),
}
p.eventType = ""
p.dataLines = nil
return frame, frame.Data != ""
}
func openAICompatPayloadWithEventType(payload, eventType string) string {
eventType = strings.TrimSpace(eventType)
if eventType == "" || strings.TrimSpace(payload) == "" || strings.TrimSpace(payload) == "[DONE]" {
return payload
}
if strings.TrimSpace(gjson.Get(payload, "type").String()) != "" {
return payload
}
patched, err := sjson.Set(payload, "type", eventType)
if err != nil {
return payload
}
return patched
}
func effectiveOpenAISSEEventType(payload []byte, eventType string) string {
if payloadType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()); payloadType != "" {
return payloadType
}
return strings.TrimSpace(eventType)
}
func (s *OpenAIGatewayService) replaceModelInSSELine(line, fromModel, toModel string) string {
data, ok := extractOpenAISSEDataLine(line)
if !ok {
return line
}
if data == "" || data == "[DONE]" {
return line
}
// 使用 gjson 精确检查 model 字段,避免全量 JSON 反序列化
if m := gjson.Get(data, "model"); m.Exists() && m.Str == fromModel {
newData, err := sjson.Set(data, "model", toModel)
if err != nil {
return line
}
return "data: " + newData
}
// 检查嵌套的 response.model 字段
if m := gjson.Get(data, "response.model"); m.Exists() && m.Str == fromModel {
newData, err := sjson.Set(data, "response.model", toModel)
if err != nil {
return line
}
return "data: " + newData
}
return line
}
// correctToolCallsInResponseBody 修正响应体中的工具调用
func (s *OpenAIGatewayService) correctToolCallsInResponseBody(body []byte) []byte {
if len(body) == 0 {
return body
}
updated := body
if s != nil && s.toolCorrector != nil {
if corrected, changed := s.toolCorrector.CorrectToolCallsInSSEBytes(updated); changed {
updated = corrected
}
}
if normalized, changed := normalizeOpenAIResponsesFunctionCallArguments(updated); changed {
updated = normalized
}
return updated
}
func normalizeOpenAIResponsesFunctionCallArguments(data []byte) ([]byte, bool) {
if len(bytes.TrimSpace(data)) == 0 || !bytes.Contains(data, []byte(`"arguments"`)) {
return data, false
}
if !gjson.ValidBytes(data) {
return data, false
}
updated := data
changed := false
setDedupedArgument := func(path string) {
arg := gjson.GetBytes(updated, path)
if !arg.Exists() || arg.Type != gjson.String {
return
}
deduped, ok := dedupeRepeatedJSONArgumentString(arg.Str)
if !ok {
return
}
next, err := sjson.SetBytes(updated, path, deduped)
if err != nil {
return
}
updated = next
changed = true
}
eventType := strings.TrimSpace(gjson.GetBytes(updated, "type").String())
if eventType == "response.function_call_arguments.done" {
setDedupedArgument("arguments")
}
if itemType := strings.TrimSpace(gjson.GetBytes(updated, "item.type").String()); isResponsesFunctionCallItemType(itemType) {
setDedupedArgument("item.arguments")
}
dedupeResponsesFunctionCallOutputArguments(updated, "response.output", setDedupedArgument)
dedupeResponsesFunctionCallOutputArguments(updated, "output", setDedupedArgument)
return updated, changed
}
func dedupeResponsesFunctionCallOutputArguments(data []byte, outputPath string, setDedupedArgument func(string)) {
output := gjson.GetBytes(data, outputPath)
if !output.Exists() || !output.IsArray() {
return
}
for i, item := range output.Array() {
if !isResponsesFunctionCallItemType(strings.TrimSpace(item.Get("type").String())) {
continue
}
setDedupedArgument(outputPath + "." + strconv.Itoa(i) + ".arguments")
}
}
func isResponsesFunctionCallItemType(itemType string) bool {
return itemType == "function_call" || itemType == "custom_tool_call"
}
func dedupeRepeatedJSONArgumentString(arguments string) (string, bool) {
if len(arguments) == 0 || len(arguments)%2 != 0 {
return "", false
}
halfLen := len(arguments) / 2
first := arguments[:halfLen]
if first != arguments[halfLen:] {
return "", false
}
trimmed := strings.TrimSpace(first)
if trimmed == "" || (!strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[")) {
return "", false
}
if !json.Valid([]byte(first)) {
return "", false
}
return first, true
}
func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) {
s.parseSSEUsageBytes([]byte(data), usage)
}
func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsage) {
s.parseSSEUsageBytesWithType(data, "", usage)
}
func (s *OpenAIGatewayService) parseSSEUsageBytesWithType(data []byte, eventType string, usage *OpenAIUsage) {
if usage == nil || len(data) == 0 || bytes.Equal(bytes.TrimSpace(data), []byte("[DONE]")) {
return
}
// Usage is absent from nearly every delta event. Avoid full JSON validation
// and four gjson path scans on that hot path while retaining progressive
// usage from compatible upstreams on any event type.
if !bytes.Contains(data, []byte(`"usage"`)) {
return
}
parsedUsage, ok := extractOpenAIUsageFromJSONBytes(data)
if !ok {
return
}
if openAIStreamEventTypeIsTerminal(effectiveOpenAISSEEventType(data, eventType)) {
if !openAIUsageHasTokens(&parsedUsage) && openAIUsageHasTokens(usage) {
return
}
*usage = parsedUsage
return
}
mergeOpenAIUsageNonZero(usage, parsedUsage)
}
// Compatible Responses upstreams may report usage before the terminal event.
// Retain those non-zero fields as a fallback. A terminal usage with any billed
// tokens is authoritative as a whole; an all-zero terminal does not erase a
// non-zero progressive observation from the same turn.
func mergeOpenAIUsageNonZero(dst *OpenAIUsage, src OpenAIUsage) {
if dst == nil {
return
}
if src.InputTokens > 0 {
dst.InputTokens = src.InputTokens
}
if src.ImageInputTokens > 0 {
dst.ImageInputTokens = src.ImageInputTokens
}
if src.OutputTokens > 0 {
dst.OutputTokens = src.OutputTokens
}
if src.CacheCreationInputTokens > 0 {
dst.CacheCreationInputTokens = src.CacheCreationInputTokens
}
if src.CacheReadInputTokens > 0 {
dst.CacheReadInputTokens = src.CacheReadInputTokens
}
if src.ImageOutputTokens > 0 {
dst.ImageOutputTokens = src.ImageOutputTokens
}
}
func openAIUsageHasTokens(usage *OpenAIUsage) bool {
return usage != nil && (usage.InputTokens > 0 || usage.ImageInputTokens > 0 ||
usage.OutputTokens > 0 || usage.CacheCreationInputTokens > 0 ||
usage.CacheReadInputTokens > 0 || usage.ImageOutputTokens > 0)
}
const openAIMissingUsageLogInterval = time.Minute
type openAIMissingUsageLogSampler struct {
total atomic.Uint64
suppressed atomic.Uint64
lastLog atomic.Int64
}
var openAIMissingUsageSampler openAIMissingUsageLogSampler
func (s *openAIMissingUsageLogSampler) sample(now time.Time) (logNow bool, total uint64, suppressed uint64) {
total = s.total.Add(1)
nowNanos := now.UnixNano()
for {
last := s.lastLog.Load()
if last != 0 && nowNanos-last < int64(openAIMissingUsageLogInterval) {
s.suppressed.Add(1)
return false, total, 0
}
if s.lastLog.CompareAndSwap(last, nowNanos) {
return true, total, s.suppressed.Swap(0)
}
}
}
func logOpenAISuccessMissingUsage(ctx context.Context, c *gin.Context, account *Account, resp *http.Response, usage *OpenAIUsage, terminalEvent string, clientDisconnected bool) {
if resp == nil || resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || openAIUsageHasTokens(usage) {
return
}
terminalEvent = strings.TrimSpace(terminalEvent)
if terminalEvent != "response.completed" && terminalEvent != "response.done" && terminalEvent != "[DONE]" && terminalEvent != "json" {
return
}
logNow, total, suppressed := openAIMissingUsageSampler.sample(time.Now())
if !logNow {
return
}
accountID := int64(0)
accountType := ""
if account != nil {
accountID = account.ID
accountType = string(account.Type)
}
inboundEndpoint := ""
if c != nil && c.Request != nil && c.Request.URL != nil {
inboundEndpoint = c.Request.URL.Path
}
logger.FromContext(ctx).With(
zap.String("component", "service.openai_gateway"),
zap.Int64("account_id", accountID),
zap.String("account_type", accountType),
zap.String("inbound_endpoint", inboundEndpoint),
zap.String("upstream_request_id", strings.TrimSpace(resp.Header.Get("x-request-id"))),
zap.Int("upstream_status_code", resp.StatusCode),
zap.String("terminal_event", terminalEvent),
zap.Bool("client_disconnected", clientDisconnected),
zap.Uint64("missing_usage_total", total),
zap.Uint64("suppressed_since_last", suppressed),
).Warn("openai_usage.success_missing_usage")
}
func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) {
if len(body) == 0 || !gjson.ValidBytes(body) {
return OpenAIUsage{}, false
}
// 部分 OpenAI 兼容上游(例如 Cline API)会将标准响应包在 data 字段中:
// {"data":{"choices": [...], "usage": {...}}, "success":true}。
// 按优先级先保留原有路径,再尝试兼容层 data 包装,
// 避免同步请求能正常返回但用量被静默记录为 0。
candidates := []struct {
usagePath string
imageUsagePath string
}{
{usagePath: "usage", imageUsagePath: "tool_usage.image_gen"},
{usagePath: "response.usage", imageUsagePath: "response.tool_usage.image_gen"},
{usagePath: "data.usage", imageUsagePath: "data.tool_usage.image_gen"},
{usagePath: "data.response.usage", imageUsagePath: "data.response.tool_usage.image_gen"},
}
for _, candidate := range candidates {
if usage, ok := openAIUsageFromGJSON(gjson.GetBytes(body, candidate.usagePath)); ok {
mergeHostedImageGenToolUsage(gjson.GetBytes(body, candidate.imageUsagePath), &usage)
return usage, true
}
}
return OpenAIUsage{}, false
}
// openAIResponsesCompletedEventIsEmpty reports whether a response.completed /
// response.done SSE payload carries no usage, no error and no output items.
// The accumulated usage is consulted too, because OpenAI may deliver usage on
// an earlier event. An empty terminal event after a stream with no semantic
// output is treated as a silent upstream refusal (issue #5009).
func openAIResponsesCompletedEventIsEmpty(data []byte, usage *OpenAIUsage) bool {
if len(data) == 0 || !gjson.ValidBytes(data) {
return false
}
if usage != nil && (usage.InputTokens > 0 || usage.OutputTokens > 0 ||
usage.ImageInputTokens > 0 || usage.ImageOutputTokens > 0 ||
usage.CacheCreationInputTokens > 0 || usage.CacheReadInputTokens > 0) {
return false
}
if gjson.GetBytes(data, "usage").Exists() || gjson.GetBytes(data, "response.usage").Exists() {
return false
}
if gjson.GetBytes(data, "error").Exists() || gjson.GetBytes(data, "response.error").Exists() {
return false
}
if output := gjson.GetBytes(data, "response.output"); output.Exists() && output.IsArray() && len(output.Array()) > 0 {
return false
}
return true
}
func mergeHostedImageGenToolUsage(imageGen gjson.Result, usage *OpenAIUsage) {
if !imageGen.Exists() || !imageGen.IsObject() {
return
}
if usage.ImageOutputTokens == 0 {
if v := imageGen.Get("output_tokens_details.image_tokens").Int(); v > 0 {
usage.ImageOutputTokens = int(v)
}
}
if usage.ImageInputTokens == 0 {
if v := imageGen.Get("input_tokens_details.image_tokens").Int(); v > 0 {
usage.ImageInputTokens = int(v)
}
}
}
func extractOpenAIResponseIDFromJSONBytes(body []byte) string {
if len(body) == 0 || !gjson.ValidBytes(body) {
return ""
}
if id := strings.TrimSpace(gjson.GetBytes(body, "id").String()); id != "" {
return id
}
return strings.TrimSpace(gjson.GetBytes(body, "response.id").String())
}
const openAIHTTPResponseOwnerContextKey = "openai_http_response_owner"
type openAIHTTPResponseOwner struct {
userID int64
apiKeyID int64
}
// SetOpenAIHTTPResponseOwner marks the authenticated downstream owner whose
// successful Responses IDs may be used for later HTTP continuations.
func SetOpenAIHTTPResponseOwner(c *gin.Context, userID, apiKeyID int64) {
if c == nil || userID <= 0 || apiKeyID <= 0 {
return
}
c.Set(openAIHTTPResponseOwnerContextKey, openAIHTTPResponseOwner{userID: userID, apiKeyID: apiKeyID})
}
// ValidateOpenAIHTTPResponseOwner authorizes a continuation by downstream
// tenant. API key identity is retained in the binding, while keys owned by the
// same user remain interoperable.
func (s *OpenAIGatewayService) ValidateOpenAIHTTPResponseOwner(
ctx context.Context,
groupID int64,
responseID string,
userID, apiKeyID int64,
) (bool, error) {
if s == nil || strings.TrimSpace(responseID) == "" || userID <= 0 || apiKeyID <= 0 {
return false, nil
}
ownerUserID, ownerAPIKeyID, found, err := s.getOpenAIWSStateStore().GetHTTPResponseOwner(ctx, groupID, responseID)
if err != nil || !found {
return false, err
}
return ownerUserID == userID || (ownerUserID <= 0 && ownerAPIKeyID == apiKeyID), nil
}
// BindOpenAIHTTPResponseOwner records an HTTP continuation owner independently
// from the upstream account selected for that response.
func (s *OpenAIGatewayService) BindOpenAIHTTPResponseOwner(
ctx context.Context,
groupID int64,
responseID string,
userID, apiKeyID int64,
) error {
if s == nil {
return nil
}
return s.getOpenAIWSStateStore().BindHTTPResponseOwner(
ctx, groupID, responseID, userID, apiKeyID, s.openAIWSResponseStickyTTL(),
)
}
func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *gin.Context, account *Account, responseID string) {
if s == nil || account == nil || account.ID <= 0 {
return
}
responseID = strings.TrimSpace(responseID)
if responseID == "" {
return
}
store := s.getOpenAIWSStateStore()
if store == nil {
return
}
groupID := getOpenAIGroupIDFromContext(c)
ttl := s.openAIWSResponseStickyTTL()
logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl))
if rawOwner, ok := c.Get(openAIHTTPResponseOwnerContextKey); ok {
if owner, ok := rawOwner.(openAIHTTPResponseOwner); ok && owner.userID > 0 && owner.apiKeyID > 0 {
if err := s.BindOpenAIHTTPResponseOwner(ctx, groupID, responseID, owner.userID, owner.apiKeyID); err != nil {
logger.L().Warn(
"openai.http_bind_response_owner_failed",
zap.Int64("group_id", groupID),
zap.Int64("account_id", account.ID),
zap.Int64("user_id", owner.userID),
zap.Int64("api_key_id", owner.apiKeyID),
zap.String("response_id", truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen)),
zap.Error(err),
)
}
}
}
}
func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) {
if !value.Exists() || !value.IsObject() {
return OpenAIUsage{}, false
}
inputTokens := value.Get("input_tokens").Int()
if inputTokens == 0 {
inputTokens = value.Get("prompt_tokens").Int()
}
outputTokens := value.Get("output_tokens").Int()
if outputTokens == 0 {
outputTokens = value.Get("completion_tokens").Int()
}
// xAI reports visible output separately from reasoning_tokens; OpenAI
// folds reasoning into completion/output. Use total_tokens to tell them apart.
reasoningTokens := max(int(firstPositiveGJSONInt(
value.Get("completion_tokens_details.reasoning_tokens"),
value.Get("output_tokens_details.reasoning_tokens"),
)), 0)
if reasoningTokens > 0 {
outputTokens = xai.IncludeIndependentReasoningTokens(
inputTokens, outputTokens, value.Get("total_tokens").Int(), int64(reasoningTokens),
)
}
cacheReadTokens := openAICacheReadTokensFromUsage(value)
cacheCreationTokens := openAICacheCreationTokensFromUsage(value)
imageOutputTokens := value.Get("output_tokens_details.image_tokens").Int()
if imageOutputTokens == 0 {
imageOutputTokens = value.Get("completion_tokens_details.image_tokens").Int()
}
// 图片输入 token(如 gpt-image-2 的 /v1/images/edits 带图请求),
// 上游在 input_tokens_details.image_tokens 单独回传,用于图/文输入分价计费。
// 普通文本请求该字段为 0,走原路径行为不变。
imageInputTokens := firstPositiveGJSONInt(
value.Get("input_tokens_details.image_tokens"),
value.Get("prompt_tokens_details.image_tokens"),
)
return OpenAIUsage{
InputTokens: int(inputTokens),
ImageInputTokens: imageInputTokens,
OutputTokens: int(outputTokens),
CacheCreationInputTokens: cacheCreationTokens,
CacheReadInputTokens: cacheReadTokens,
ImageOutputTokens: int(imageOutputTokens),
}, true
}
func openAICacheReadTokensFromUsage(value gjson.Result) int {
for _, nested := range []gjson.Result{
value.Get("input_tokens_details.cached_tokens"),
value.Get("prompt_tokens_details.cached_tokens"),
} {
if nested.Exists() {
return max(int(nested.Int()), 0)
}
}
return firstPositiveGJSONInt(
value.Get("cache_read_input_tokens"),
value.Get("cache_read_tokens"),
value.Get("cached_tokens"),
)
}
func openAICacheCreationTokensFromUsage(value gjson.Result) int {
for _, nested := range []gjson.Result{
value.Get("input_tokens_details.cache_write_tokens"),
value.Get("prompt_tokens_details.cache_write_tokens"),
value.Get("input_tokens_details.cache_creation_tokens"),
value.Get("prompt_tokens_details.cache_creation_tokens"),
} {
if nested.Exists() {
return max(int(nested.Int()), 0)
}
}
return firstPositiveGJSONInt(
value.Get("cache_write_tokens"),
value.Get("cache_creation_input_tokens"),
value.Get("cache_write_input_tokens"),
value.Get("cache_creation_tokens"),
)
}
func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*openaiNonStreamingResult, error) {
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
if err != nil {
return nil, err
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
if bodyHasSSEFraming(body) {
observeOpenAISSEBody(observer, string(body))
} else {
observer.ObserveOpenAI(body, strings.TrimSpace(gjson.GetBytes(body, "type").String()))
}
// Detect SSE responses for ALL account types via Content-Type header.
// Some OpenAI-compatible upstreams (including other sub2api instances)
// may return SSE even when stream=false was requested.
if isEventStreamResponse(resp.Header) {
return s.handleSSEToJSON(resp, c, account, body, originalModel, mappedModel)
}
// bodyLooksLikeSSE is a line-level heuristic: real SSE framing requires
// "data:"/"event:" field names at the very start of a physical line. A
// plain bytes.Contains scan would also match ordinary JSON responses
// whose string content merely echoes the literal text "data:" or
// "event:" (e.g. compact tool output), causing those JSON bodies to be
// misrouted into handleSSEToJSON and lose their usage accounting.
bodyLooksLikeSSE := bodyHasSSEFraming(body)
// For OAuth accounts, also fall back to a body-content heuristic because
// the upstream may omit the Content-Type header while still sending SSE.
// This heuristic is NOT applied to API-key accounts to avoid false
// positives on JSON responses that coincidentally contain "data:" or
// "event:" in their text content.
if account.Type == AccountTypeOAuth && bodyLooksLikeSSE {
return s.handleSSEToJSON(resp, c, account, body, originalModel, mappedModel)
}
if account != nil && account.IsGrok() && isOpenAIResponsesCompactPath(c) {
body, err = convertGrokResponseToOpenAICompact(body)
if err != nil {
return nil, fmt.Errorf("convert Grok compact response: %w", err)
}
}
usageValue, usageOK := extractOpenAIUsageFromJSONBytes(body)
if !usageOK {
if bodyLooksLikeSSE {
return s.handleSSEToJSON(resp, c, account, body, originalModel, mappedModel)
}
return nil, fmt.Errorf("parse response: invalid json response")
}
usage := &usageValue
logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, "json", false)
// Replace model in response if needed
if originalModel != mappedModel {
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
}
body, err = restoreGrokResponsesClientToolPayload(c, body)
if err != nil {
return nil, fmt.Errorf("restore Grok Responses client tool response: %w", err)
}
body, err = restoreOpenAIResponsesClientToolPayload(c, body)
if err != nil {
return nil, fmt.Errorf("restore OpenAI Responses client tool response: %w", err)
}
body, err = restoreOpenAIResponsesNamespacePayload(c, body)
if err != nil {
return nil, fmt.Errorf("restore OpenAI namespace response: %w", err)
}
body = restoreCodexToolNamesFromContext(c, body)
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
// Codex 协议要求 /responses/compact JSON 响应携带 x-codex-turn-state
// (codex-api/src/endpoint/compact.rs 从响应头捕获),显式回传。
s.relayOpenAICodexTurnState(c, account, resp.Header)
contentType := "application/json"
if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled {
if upstreamType := resp.Header.Get("Content-Type"); upstreamType != "" {
contentType = upstreamType
}
}
if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) {
c.Data(resp.StatusCode, contentType, body)
}
return &openaiNonStreamingResult{
OpenAIUsage: usage,
usage: usage,
responseID: extractOpenAIResponseIDFromJSONBytes(body),
imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body),
searchCount: countGrokNativeSearchCallsFromJSONBytes(body),
}, nil
}
func isEventStreamResponse(header http.Header) bool {
contentType := strings.ToLower(header.Get("Content-Type"))
return strings.Contains(contentType, "text/event-stream")
}
// bodyHasSSEFraming reports whether body contains genuine SSE framing by
// scanning for physical lines that begin with the "data:" or "event:"
// field names, per the SSE spec. Unlike a raw substring scan, this does not
// match when those strings only appear embedded inside JSON string values
// (e.g. "data: foo" quoted as part of an assistant text field), since such
// occurrences never start a physical line in a valid JSON encoding.
func bodyHasSSEFraming(body []byte) bool {
for _, line := range bytes.Split(body, []byte("\n")) {
line = bytes.TrimRight(line, "\r")
if bytes.HasPrefix(line, []byte("data:")) || bytes.HasPrefix(line, []byte("event:")) {
return true
}
}
return false
}
func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, account *Account, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) {
bodyText := string(body)
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
if terminalOK && (terminalType == "response.failed" || terminalType == "error") {
msg := extractOpenAISSEErrorMessage(terminalPayload)
if msg == "" {
msg = "Upstream compact response failed"
}
if compactErr := newOpenAICompactFallbackSignal(c, terminalPayload, msg); compactErr != nil {
return nil, compactErr
}
return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
}
finalResponse, ok := extractCodexFinalResponse(bodyText)
usage := s.parseSSEUsageFromBody(bodyText)
if ok {
if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed {
*usage = parsedUsage
}
// When the terminal event has an empty output array, reconstruct
// output from accumulated delta events so the client gets full content.
// gjson Array() returns empty slice for null, missing, or empty arrays.
if len(gjson.GetBytes(finalResponse, "output").Array()) == 0 {
if outputJSON, reconstructed := reconstructResponseOutputFromSSE(bodyText); reconstructed {
if patched, err := sjson.SetRawBytes(finalResponse, "output", outputJSON); err == nil {
finalResponse = patched
}
}
}
finalResponse = supplementCompactionItemFromSSE(c, finalResponse, bodyText)
body = finalResponse
if originalModel != mappedModel {
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
}
// Correct tool calls in final response
body = s.correctToolCallsInResponseBody(body)
restoredBody, restoreErr := restoreGrokResponsesClientToolPayload(c, body)
if restoreErr != nil {
return nil, fmt.Errorf("restore Grok Responses client tool response: %w", restoreErr)
}
restoredBody, restoreErr = restoreOpenAIResponsesClientToolPayload(c, restoredBody)
if restoreErr != nil {
return nil, fmt.Errorf("restore OpenAI Responses client tool response: %w", restoreErr)
}
restoredBody, restoreErr = restoreOpenAIResponsesNamespacePayload(c, restoredBody)
if restoreErr != nil {
return nil, fmt.Errorf("restore OpenAI namespace response: %w", restoreErr)
}
restoredBody = restoreCodexToolNamesFromContext(c, restoredBody)
body = restoredBody
} else {
if originalModel != mappedModel {
bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel)
}
body = []byte(bodyText)
}
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, usage, terminalType, false)
s.relayOpenAICodexTurnState(c, account, resp.Header)
contentType := "application/json; charset=utf-8"
if !ok {
contentType = resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "text/event-stream"
}
}
if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) {
c.Data(resp.StatusCode, contentType, body)
}
return &openaiNonStreamingResult{
OpenAIUsage: usage,
usage: usage,
responseID: extractOpenAIResponseIDFromJSONBytes(body),
imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText),
searchCount: countGrokNativeSearchCallsFromSSEBody(bodyText),
}, nil
}
func extractOpenAISSETerminalEvent(body string) (string, []byte, bool) {
var terminalType string
var terminalPayload []byte
forEachOpenAISSEFrame(body, func(eventType string, data []byte) {
switch eventType {
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled", "error":
terminalType = eventType
terminalPayload = append([]byte(nil), data...)
}
})
if terminalPayload != nil {
return terminalType, terminalPayload, true
}
return "", nil, false
}
func extractOpenAISSEErrorMessage(payload []byte) string {
if len(payload) == 0 {
return ""
}
for _, path := range []string{"response.error.message", "error.message", "message"} {
if msg := strings.TrimSpace(gjson.GetBytes(payload, path).String()); msg != "" {
return sanitizeUpstreamErrorMessage(msg)
}
}
return sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(payload)))
}
func buildOpenAIResponseFailedSSE(responseID, model string, source []byte, fallbackMessage string) string {
responseID = strings.TrimSpace(responseID)
if responseID == "" {
responseID = "resp_" + strings.ReplaceAll(uuid.NewString(), "-", "")
}
errorType := strings.TrimSpace(gjson.GetBytes(source, "error.type").String())
if errorType == "" {
errorType = strings.TrimSpace(gjson.GetBytes(source, "response.error.type").String())
}
code := strings.TrimSpace(gjson.GetBytes(source, "error.code").String())
if code == "" {
code = strings.TrimSpace(gjson.GetBytes(source, "response.error.code").String())
}
if code == "" {
code = "upstream_error"
}
message := extractOpenAISSEErrorMessage(source)
if message == "" {
message = strings.TrimSpace(fallbackMessage)
}
if message == "" {
message = "Upstream response failed"
}
errorBody := gin.H{"code": code, "message": message}
if errorType != "" {
errorBody["type"] = errorType
}
response := gin.H{
"id": responseID,
"object": "response",
"status": "failed",
"output": []any{},
"error": errorBody,
}
if model = strings.TrimSpace(model); model != "" {
response["model"] = model
}
payload, err := marshalOpenAIUpstreamJSON(gin.H{
"type": "response.failed",
"response": response,
})
if err != nil {
// All values above are JSON primitives, so this is only a defensive fallback.
payload = []byte(`{"type":"response.failed","response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
}
return "event: response.failed\ndata: " + string(payload) + "\n\n"
}
func sanitizeOpenAIResponseFailedEventForClient(payload []byte, eventType string, clientOutputStarted bool) ([]byte, bool) {
eventType = strings.TrimSpace(eventType)
isFailedEvent := eventType == "response.failed"
if (!isFailedEvent && eventType != "error") || len(payload) == 0 || !gjson.ValidBytes(payload) {
return payload, false
}
updated := payload
// 容量降载码对 Codex CLI 是致命错误;事件既然要写给客户端(failover 已不可用),
// 就改写为客户端可重试的错误码。error 帧与 response.failed 都要改:上游降载
// 总是先推 error 帧再收 failed,两帧携带同一个错误。
if rewritten, changed := sanitizeOpenAICapacityShedErrorCodeForClient(updated); changed {
updated = rewritten
}
if !isFailedEvent {
return updated, !bytes.Equal(updated, payload)
}
if clientOutputStarted && isOpenAIContextWindowError(extractOpenAISSEErrorMessage(payload), payload) {
errorPath := ""
switch {
case gjson.GetBytes(updated, "response.error").Exists():
errorPath = "response.error"
case gjson.GetBytes(updated, "error").Exists():
errorPath = "error"
}
if errorPath != "" {
next, err := sjson.SetBytes(updated, errorPath+".type", "invalid_request_error")
if err != nil {
return payload, false
}
updated = next
next, err = sjson.SetBytes(updated, errorPath+".code", "context_length_exceeded")
if err != nil {
return payload, false
}
updated = next
}
}
if !gjson.GetBytes(updated, "response").Exists() {
return updated, !bytes.Equal(updated, payload)
}
for _, path := range []string{
"response.instructions",
"response.output",
"response.usage",
"response.metadata",
"response.reasoning",
"response.tools",
"response.tool_choice",
"response.parallel_tool_calls",
"response.text",
"response.truncation",
"response.max_output_tokens",
"response.incomplete_details",
} {
next, err := sjson.DeleteBytes(updated, path)
if err != nil {
return payload, false
}
updated = next
}
return updated, !bytes.Equal(updated, payload)
}
func (s *OpenAIGatewayService) writeOpenAINonStreamingProtocolError(resp *http.Response, c *gin.Context, message string) error {
message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message))
if message == "" {
message = "Upstream returned an invalid non-streaming response"
}
setOpsUpstreamError(c, http.StatusBadGateway, message, "")
// body-signal compact 心跳可能已把响应头提交为 200,此时只能以
// response.failed 终止事件回传错误,不能再写 JSON+状态码。
if openAICompactClientWantsStream(c) && StopOpenAICompactSSEKeepaliveCommitted(c) {
writeOpenAICompactSSEFailureMessage(c, http.StatusBadGateway, "upstream_error", message)
return fmt.Errorf("non-streaming openai protocol error: %s", message)
}
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.JSON(http.StatusBadGateway, gin.H{
"error": gin.H{
"type": "upstream_error",
"message": message,
},
})
return fmt.Errorf("non-streaming openai protocol error: %s", message)
}
func extractCodexFinalResponse(body string) ([]byte, bool) {
var finalResponse []byte
forEachOpenAISSEFrame(body, func(eventType string, data []byte) {
if finalResponse != nil {
return
}
if normalized, changed := normalizeCompletedImageGenerationStatus(data); changed {
data = normalized
}
if eventType == "response.done" || eventType == "response.completed" {
if response := gjson.GetBytes(data, "response"); response.Exists() && response.Type == gjson.JSON && response.Raw != "" {
finalResponse = []byte(response.Raw)
}
}
})
if finalResponse != nil {
return finalResponse, true
}
return nil, false
}
func normalizeCompletedImageGenerationStatus(data []byte) ([]byte, bool) {
if len(data) == 0 || !gjson.ValidBytes(data) {
return data, false
}
shouldNormalize := func(item gjson.Result) bool {
if !item.Exists() || !item.IsObject() ||
strings.TrimSpace(item.Get("type").String()) != "image_generation_call" {
return false
}
switch strings.TrimSpace(item.Get("status").String()) {
case "generating", "in_progress":
return strings.TrimSpace(item.Get("result").String()) != ""
default:
return false
}
}
eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String())
switch eventType {
case "response.output_item.done":
if !shouldNormalize(gjson.GetBytes(data, "item")) {
return data, false
}
updated, err := sjson.SetBytes(data, "item.status", "completed")
if err != nil {
return data, false
}
return updated, true
case "response.completed", "response.done":
output := gjson.GetBytes(data, "response.output")
if !output.Exists() || !output.IsArray() {
return data, false
}
updated := data
changed := false
for i, item := range output.Array() {
if !shouldNormalize(item) {
continue
}
next, err := sjson.SetBytes(updated, "response.output."+strconv.Itoa(i)+".status", "completed")
if err != nil {
return data, false
}
updated = next
changed = true
}
return updated, changed
default:
return data, false
}
}
// responsesStreamOutputItems remembers the raw item carried by each
// response.output_item.done event, keyed by output_index.
//
// reconstructResponseOutputFromSSE already prefers the raw done items over
// delta accumulation when it rebuilds a buffered response, because the
// accumulator models only "one reasoning, one message, N function calls" and
// therefore cannot preserve item identity, per-item status/phase, ordering, or
// item types it does not know about. The streaming path had no equivalent
// because it never sees the whole body at once; this collector gives it one.
type responsesStreamOutputItems struct {
items map[int]json.RawMessage
}
func newResponsesStreamOutputItems() *responsesStreamOutputItems {
return &responsesStreamOutputItems{items: make(map[int]json.RawMessage)}
}
// Observe records the item of a response.output_item.done event verbatim. The
// raw JSON is kept byte for byte so vendor extensions and future fields survive
// the rebuild.
func (r *responsesStreamOutputItems) Observe(data []byte) {
if r == nil || len(data) == 0 || !gjson.ValidBytes(data) {
return
}
if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "response.output_item.done" {
return
}
item := gjson.GetBytes(data, "item")
if !item.Exists() || !item.IsObject() {
return
}
index := int(gjson.GetBytes(data, "output_index").Int())
r.items[index] = json.RawMessage(append([]byte(nil), item.Raw...))
}
func (r *responsesStreamOutputItems) HasItems() bool {
return r != nil && len(r.items) > 0
}
// Count reports how many distinct output items the stream reported as done.
func (r *responsesStreamOutputItems) Count() int {
if r == nil {
return 0
}
return len(r.items)
}
// BuildOutput returns the remembered items ordered by output_index.
func (r *responsesStreamOutputItems) BuildOutput() ([]byte, bool) {
if !r.HasItems() {
return nil, false
}
indexes := make([]int, 0, len(r.items))
for index := range r.items {
indexes = append(indexes, index)
}
sort.Ints(indexes)
ordered := make([]json.RawMessage, 0, len(indexes))
for _, index := range indexes {
ordered = append(ordered, r.items[index])
}
encoded, err := json.Marshal(ordered)
if err != nil {
return nil, false
}
return encoded, true
}
func normalizeResponsesStreamingTerminalOutput(data []byte, acc *apicompat.BufferedResponseAccumulator, doneItems *responsesStreamOutputItems, imageOutputs []json.RawMessage) ([]byte, bool) {
eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String())
switch eventType {
case "response.completed", "response.done", "response.incomplete", "response.cancelled", "response.canceled":
default:
return data, false
}
output := gjson.GetBytes(data, "response.output")
hasAccumulatedOutput := (acc != nil && acc.HasContent()) || len(imageOutputs) > 0 || doneItems.HasItems()
if output.Exists() && output.IsArray() {
terminalCount := len(output.Array())
// A terminal output carrying at least as many items as the stream
// reported is left untouched. Carrying fewer means the terminal
// dropped items the stream already reported as done, and those
// reported items are the authoritative record of the turn.
if terminalCount > 0 && terminalCount >= doneItems.Count() {
return data, false
}
if terminalCount == 0 && !hasAccumulatedOutput {
return data, false
}
}
outputJSON := []byte("[]")
// Same precedence as reconstructResponseOutputFromSSE: the items the stream
// actually reported win over anything rebuilt from deltas. Image generation
// items arrive as done events too, so imageOutputs would duplicate them here.
if reconstructed, ok := doneItems.BuildOutput(); ok {
outputJSON = reconstructed
} else if reconstructed, ok := buildResponsesOutputJSON(acc, imageOutputs); ok {
outputJSON = reconstructed
}
updated, err := sjson.SetRawBytes(data, "response.output", outputJSON)
if err != nil {
return data, false
}
return updated, true
}
func responsesStreamEventMayContributeToOutput(eventType string) bool {
switch eventType {
case "response.output_text.delta",
"response.output_item.added",
"response.function_call_arguments.delta",
"response.reasoning_summary_text.delta":
return true
default:
return false
}
}
// collectRawResponsesOutputItemsFromSSE 按到达顺序收集 SSE 流中
// response.output_item.done 携带的原始 item。除已产生结果但仍停留在进行中
// 的图片状态外,item 以 raw JSON 逐字节保留,
// 避免经窄结构体重建时丢弃 encrypted_content/summary/opaque 等 compact
// 专属或未来新增字段(#3777 问题 2)。若整条流没有任何 done 事件,退回
// 收集 output_item.added 中的 compaction 类 item——compaction 结果没有
// delta 事件,部分上游只在 added 事件中携带完整 item。
func collectRawResponsesOutputItemsFromSSE(bodyText string) ([]byte, bool) {
var items []json.RawMessage
seen := make(map[string]struct{})
hasCompactionItem := false
appendItem := func(item gjson.Result) {
if !item.Exists() || !item.IsObject() {
return
}
key := strings.TrimSpace(item.Get("id").String())
if key == "" {
key = item.Raw
}
if _, dup := seen[key]; dup {
return
}
seen[key] = struct{}{}
if isResponsesCompactionItemType(item.Get("type").String()) {
hasCompactionItem = true
}
items = append(items, json.RawMessage(item.Raw))
}
forEachOpenAISSEFrame(bodyText, func(eventType string, data []byte) {
data = []byte(openAICompatPayloadWithEventType(string(data), eventType))
if normalized, changed := normalizeCompletedImageGenerationStatus(data); changed {
data = normalized
}
if eventType != "response.output_item.done" {
return
}
appendItem(gjson.GetBytes(data, "item"))
})
// done 事件未携带 compaction item 时再看 added:覆盖"其他 item 有 done、
// compaction 只在 added 中"的混合形态;done 已含 compaction 时跳过,
// 避免同一 item 在无 id 可去重时被收集两份(Codex 要求恰好一个)。
if !hasCompactionItem {
forEachOpenAISSEFrame(bodyText, func(eventType string, data []byte) {
if eventType != "response.output_item.added" {
return
}
item := gjson.GetBytes(data, "item")
if !isResponsesCompactionItemType(item.Get("type").String()) {
return
}
appendItem(item)
})
}
if len(items) == 0 {
return nil, false
}
outputJSON, err := json.Marshal(items)
if err != nil {
return nil, false
}
return outputJSON, true
}
// isResponsesCompactionItemType reports whether the item type is the Codex
// remote-compact result item ("compaction", upstream alias "compaction_summary").
func isResponsesCompactionItemType(itemType string) bool {
switch strings.TrimSpace(itemType) {
case "compaction", "compaction_summary":
return true
default:
return false
}
}
// supplementCompactionItemFromSSE 保证 compact 请求的终态 output 携带
// compaction item:终态 output 非空但缺失 compaction、而原始事件流的
// output_item.done(或 added)中存在时(上游不一致形态),以 raw JSON 补入。
// Codex remote compact v2 只从 output_item.done 收集 item 且要求恰好一个
// compaction item——纯流式透传(v0.1.146)下客户端直接读事件流天然拿得到,
// SSE→JSON 提取链路必须给出等价结果。非 compact 请求原样返回。
func supplementCompactionItemFromSSE(c *gin.Context, finalResponse []byte, bodyText string) []byte {
if !isOpenAIResponsesCompactPath(c) {
return finalResponse
}
if len(gjson.GetBytes(finalResponse, "output").Array()) == 0 {
// 空 output 由 reconstructResponseOutputFromSSE 整体修补,不在此处理。
return finalResponse
}
if responsesOutputHasCompactionItem(finalResponse) {
return finalResponse
}
item, found := findRawCompactionItemFromSSE(bodyText)
if !found {
return finalResponse
}
patched, err := sjson.SetRawBytes(finalResponse, "output.-1", item)
if err != nil {
return finalResponse
}
return patched
}
// responsesOutputHasCompactionItem reports whether the response JSON already
// carries a compaction item in its output array.
func responsesOutputHasCompactionItem(response []byte) bool {
for _, item := range gjson.GetBytes(response, "output").Array() {
if isResponsesCompactionItemType(item.Get("type").String()) {
return true
}
}
return false
}
// findRawCompactionItemFromSSE 从原始 SSE 事件流中提取第一个 compaction 类
// item 的 raw JSON:output_item.done 优先,output_item.added 兜底。
func findRawCompactionItemFromSSE(bodyText string) (json.RawMessage, bool) {
var found json.RawMessage
pick := func(eventType string) {
forEachOpenAISSEFrame(bodyText, func(effectiveType string, data []byte) {
if found != nil {
return
}
if effectiveType != eventType {
return
}
item := gjson.GetBytes(data, "item")
if !item.IsObject() || !isResponsesCompactionItemType(item.Get("type").String()) {
return
}
found = json.RawMessage(item.Raw)
})
}
pick("response.output_item.done")
if found == nil {
pick("response.output_item.added")
}
return found, found != nil
}
// reconstructResponseOutputFromSSE scans raw SSE body text and returns a
// JSON-encoded output array for a terminal event whose output is empty.
// Raw output_item.done items are preferred: per the Responses protocol they
// are the authoritative final form of each item. Delta accumulation only
// covers text/function_call/reasoning content and silently drops unknown
// item types such as compaction — Codex remote compact v2 then fails with
// "expected exactly one compaction output item, got 0" (#3887).
// Returns (nil, false) if nothing could be reconstructed.
func reconstructResponseOutputFromSSE(bodyText string) ([]byte, bool) {
if outputJSON, ok := collectRawResponsesOutputItemsFromSSE(bodyText); ok {
return outputJSON, true
}
acc := apicompat.NewBufferedResponseAccumulator()
imageOutputs := make([]json.RawMessage, 0, 1)
seenImages := make(map[string]struct{})
forEachOpenAISSEFrame(bodyText, func(eventType string, data []byte) {
data = []byte(openAICompatPayloadWithEventType(string(data), eventType))
if imageOutput, ok := extractImageGenerationOutputFromSSEData(data, seenImages); ok {
imageOutputs = append(imageOutputs, imageOutput)
}
if responsesStreamEventMayContributeToOutput(eventType) {
var event apicompat.ResponsesStreamEvent
if err := json.Unmarshal(data, &event); err == nil {
acc.ProcessEvent(&event)
}
}
})
return buildResponsesOutputJSON(acc, imageOutputs)
}
func buildResponsesOutputJSON(acc *apicompat.BufferedResponseAccumulator, imageOutputs []json.RawMessage) ([]byte, bool) {
if (acc == nil || !acc.HasContent()) && len(imageOutputs) == 0 {
return nil, false
}
var output []json.RawMessage
if acc != nil && acc.HasContent() {
outputJSON, err := json.Marshal(acc.BuildOutput())
if err == nil {
_ = json.Unmarshal(outputJSON, &output)
}
}
output = append(output, imageOutputs...)
if len(output) == 0 {
return nil, false
}
outputJSON, err := json.Marshal(output)
if err != nil {
return nil, false
}
return outputJSON, true
}
func extractImageGenerationOutputFromSSEData(data []byte, seen map[string]struct{}) (json.RawMessage, bool) {
if len(data) == 0 || !gjson.ValidBytes(data) {
return nil, false
}
if gjson.GetBytes(data, "type").String() != "response.output_item.done" {
return nil, false
}
item := gjson.GetBytes(data, "item")
if !item.Exists() || !item.IsObject() || item.Get("type").String() != "image_generation_call" {
return nil, false
}
if strings.TrimSpace(item.Get("result").String()) == "" {
return nil, false
}
key := strings.TrimSpace(item.Get("id").String())
if key == "" {
key = strings.TrimSpace(item.Get("output_format").String()) + "|" + strings.TrimSpace(item.Get("result").String())
}
if key != "" && seen != nil {
if _, exists := seen[key]; exists {
return nil, false
}
seen[key] = struct{}{}
}
return json.RawMessage(item.Raw), true
}
func (s *OpenAIGatewayService) parseSSEUsageFromBody(body string) *OpenAIUsage {
usage := &OpenAIUsage{}
forEachOpenAISSEFrame(body, func(eventType string, data []byte) {
s.parseSSEUsageBytesWithType(data, eventType, usage)
})
return usage
}
func (s *OpenAIGatewayService) replaceModelInSSEBody(body, fromModel, toModel string) string {
lines := strings.Split(body, "\n")
for i, line := range lines {
if _, ok := extractOpenAISSEDataLine(line); !ok {
continue
}
lines[i] = s.replaceModelInSSELine(line, fromModel, toModel)
}
return strings.Join(lines, "\n")
}