| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363 |
- 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
- ttftMode := s.openAITTFTMode(ctx)
- 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
- eventStartsTTFTOutput := 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
- completedTTFTEvent := eventStartsTTFTOutput
- 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 completedTTFTEvent && firstTokenMs == nil {
- ms := int(time.Since(startTime).Milliseconds())
- firstTokenMs = &ms
- }
- eventStartsClientOutput = false
- eventStartsTTFTOutput = 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, mappedModel)
- 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, mappedModel)
- bareErrorAccountSideEffectsPending = false
- }
- if eventType == "response.failed" {
- // Once semantic output is committed, failover replay is unsafe. Keep
- // the terminal event on the existing stream, but retain the upstream
- // request ID and payload for operations diagnostics.
- s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "stream_failed", dataBytes, failedMessage)
- }
- }
- if !outputStarted {
- shouldFailover := false
- if !cyberHit {
- if eventType == "error" {
- shouldFailover = openAIStreamErrorEventShouldFailover(dataBytes, failedMessage)
- } else {
- shouldFailover = openAIStreamFailedEventShouldFailover(dataBytes, failedMessage)
- }
- }
- if shouldFailover {
- sawFailedEvent = true
- streamEarlyErr = s.newOpenAIStreamFailoverErrorWithModel(c, account, false, upstreamRequestID, dataBytes, failedMessage, mappedModel, 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)
- startsTTFTOutput := openAIStreamDataStartsTTFT(data, eventType, forceFlushFailedEvent, ttftMode)
- if stageFirstOutput {
- eventStartsClientOutput = eventStartsClientOutput || startsClientOutput
- eventStartsTTFTOutput = eventStartsTTFTOutput || startsTTFTOutput
- 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 && startsTTFTOutput {
- 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
- eventStartsTTFTOutput = false
- eventShouldFlush = false
- return
- }
- if failureDelivered {
- terminalFailurePending = false
- eventInProgress = false
- eventStartsClientOutput = false
- eventStartsTTFTOutput = 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, opsUpstreamProxyID(account), opsUpstreamProxyName(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
- }
- if failoverErr := s.nonStreamingTerminalFailureFailover(c, resp, account, false, terminalType, terminalPayload, msg, mappedModel); failoverErr != nil {
- return nil, failoverErr
- }
- 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")
- }
|