mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-06 15:50:49 +08:00
The bootstrap buffer released the stream on the first frame outside the
handshake allow-list. Upstream sends `keepalive` heartbeats and
`response.output_item.added` before the first token, so the downstream headers
were committed while nothing had been produced, and an overload rejection
arriving afterwards could no longer be retried on another credential.
Extend the allow-list with `keepalive`, and with `response.output_item.added`,
`response.content_part.added` and `response.reasoning_summary_part.added` when
they announce something that is still empty. `response.output_item.added` is
accepted only for `message`, `reasoning`, `function_call` and `custom_tool_call`
items, and only while their content, summary, arguments or input is empty: every
other item type stands for a server-side operation that may already be running -
a `web_search_call` is announced with status "in_progress" and its `searching`
event follows immediately - and failing the attempt over after one would run
that operation again on another credential. Part announcements are matched on a
closed list of textual part types for the same reason.
The list stays closed. "Nothing has happened yet" cannot be derived from the
absence of a TTFT token, because TTFT deliberately ignores server-side tool
traffic such as `response.shell_call_output_content.delta` and its `.done`
counterpart, so an unrecognised frame, an unrecognised item type and an
unrecognised part type all release the stream exactly as before.
Holding those frames also required fixing the bound on how much may be held.
codexBootstrapMaxBufferedEvents was enforced with len(bufferedChunks), and a
chunk count bounds only the downstream formats that render every upstream frame:
the OpenAI Chat Completions and Gemini translators return zero chunks for a frame
they do not recognise, which is true of every frame added here. Count frames read
from the upstream instead, and cap what a bootstrap accumulates over the upstream
frames and the chunks they translate into. The SSE scanner walks physical lines
and an event can arrive as one line (": keepalive"), two, or three
(event:/data:/blank), so the frame budget is sized for the widest framing rather
than derived from any separator. The websocket loop counts a message as soon as
it is read, before the branches that skip non-text and whitespace-only messages,
so a peer sending only frames the loop skips cannot hold the downstream headers
open; the message that exhausts the budget is still processed and delivered
rather than dropped. Both caps are checked before a frame is admitted, so one
oversized frame cannot be taken on the strength of an empty buffer. This widens
the window where one frame rendered one chunk - the websocket path effectively
moves from 16 frames to 48.
An empty `response.incomplete` seen while buffering is now delivered in-stream
instead of failing the attempt over, matching the websocket executor and the
documented contract that only overload and rate-limit rejections trigger
failover.
`isCodexHandshakeMetadataEvent` is renamed to `isCodexBootstrapBufferableEvent`
because the list is no longer only handshake metadata. Both config doc sites
describe what is held, that heartbeats are held too, that SSE also holds the
lines around a `data:` frame, and that the hold is bounded by frames and bytes
rather than by wall-clock time.
431 lines
18 KiB
Go
431 lines
18 KiB
Go
package executor
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/client/grokbuild"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
|
|
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
|
|
if opts.Alt == "responses/compact" {
|
|
return nil, statusErr{code: http.StatusBadRequest, msg: "streaming not supported for /responses/compact"}
|
|
}
|
|
if isCodexOpenAIImageRequest(opts) {
|
|
return e.executeOpenAIImageStream(ctx, auth, req, opts)
|
|
}
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
|
|
apiKey, baseURL := codexCreds(auth)
|
|
if baseURL == "" {
|
|
baseURL = "https://chatgpt.com/backend-api/codex"
|
|
}
|
|
|
|
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
|
|
defer reporter.TrackFailure(ctx, &err)
|
|
|
|
from := opts.SourceFormat
|
|
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
|
|
isGrokClient := grokbuild.IsGrokClientContext(ctx, opts.Headers)
|
|
to := sdktranslator.FromString("codex")
|
|
originalPayloadSource := req.Payload
|
|
if len(opts.OriginalRequest) > 0 {
|
|
originalPayloadSource = opts.OriginalRequest
|
|
}
|
|
originalPayload := originalPayloadSource
|
|
originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, true, helps.APIKeyModelIsCompat(req))
|
|
|
|
body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
|
requestPath := helps.PayloadRequestPath(opts)
|
|
body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
|
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
|
body, _ = sjson.DeleteBytes(body, "generate")
|
|
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
|
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
|
reasoningSummaryDelivery := gjson.GetBytes(body, "stream_options.reasoning_summary_delivery")
|
|
body, _ = sjson.DeleteBytes(body, "stream_options")
|
|
if reasoningSummaryDelivery.Exists() {
|
|
body, _ = sjson.SetBytes(body, "stream_options.reasoning_summary_delivery", reasoningSummaryDelivery.Value())
|
|
}
|
|
body = helps.SetStringIfDifferent(body, "model", baseModel)
|
|
body = normalizeCodexInstructions(body)
|
|
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
|
|
body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
|
|
}
|
|
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
|
|
body = normalizeCodexParallelToolCalls(body, opts.Headers)
|
|
body = helps.NormalizeCodexToolSchemas(body)
|
|
body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel)
|
|
body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body)
|
|
if errReplay != nil {
|
|
return nil, errReplay
|
|
}
|
|
reporter.SetTranslatedReasoningEffort(body, to.String())
|
|
|
|
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
|
var identityState codexIdentityConfuseState
|
|
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body, opts.Headers)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers)
|
|
applyModelHeaderOverrides(httpReq.Header, baseModel)
|
|
applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
|
|
var authID, authLabel, authType, authValue string
|
|
if auth != nil {
|
|
authID = auth.ID
|
|
authLabel = auth.Label
|
|
authType, authValue = auth.AccountInfo()
|
|
}
|
|
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
|
|
URL: url,
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: upstreamBody,
|
|
Provider: e.Identifier(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
|
|
httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
|
|
httpClient = reporter.TrackHTTPClientRoundTripOnly(httpClient)
|
|
httpResp, err := httpClient.Do(httpReq)
|
|
if err != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
return nil, err
|
|
}
|
|
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
data, readErr := io.ReadAll(httpResp.Body)
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("codex executor: close response body error: %v", errClose)
|
|
}
|
|
if readErr != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, readErr)
|
|
return nil, readErr
|
|
}
|
|
data = applyCodexIdentityConfuseResponsePayload(data, identityState)
|
|
if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, httpResp.StatusCode, data); errClearReplay != nil {
|
|
return nil, errClearReplay
|
|
}
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
|
|
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
|
|
err = newCodexStatusErrWithCooling(httpResp.StatusCode, data, e.modelLevelCooling())
|
|
return nil, err
|
|
}
|
|
|
|
buffering := e.cfg != nil && e.cfg.Codex.StreamBootstrapBuffering
|
|
|
|
scanner := bufio.NewScanner(httpResp.Body)
|
|
scanner.Buffer(nil, 52_428_800) // 50MB
|
|
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
|
|
var param any
|
|
outputItemsByIndex := make(map[int64][]byte)
|
|
var outputItemsFallback [][]byte
|
|
|
|
var bufferedChunks [][]byte
|
|
// bufferedFrames counts the scanned lines this loop holds and bufferedBytes sums each line
|
|
// together with the chunks it translates into. Every iteration either holds the line or leaves
|
|
// the loop, so this is one unit per line read. Counting only the chunks would bound nothing for
|
|
// a downstream format that renders a frame as zero chunks, and counting only some line kinds
|
|
// would let the upstream's choice of framing decide whether the bound advances at all. The cost
|
|
// is that a verbose framing spends the budget faster: the three-line event:/data:/blank shape
|
|
// protects roughly a third as many events as a stream of bare ": keepalive" comments does.
|
|
//
|
|
// Neither bound is a bound on time. A peer that trickles frames, or that never terminates a
|
|
// line, holds the downstream headers for as long as the caller's context allows.
|
|
bufferedFrames := 0
|
|
bufferedBytes := 0
|
|
var initialChunks [][]byte
|
|
streamStarted := false
|
|
immediateTerminal := false
|
|
// bootstrapTerminalErr holds a non-overload terminal failure seen while buffering. It is
|
|
// delivered as an in-stream chunk after the buffered handshake so downstream behaviour stays
|
|
// identical to the unbuffered path instead of silently turning into a credential failover.
|
|
var bootstrapTerminalErr error
|
|
|
|
closeBootstrapBody := func() {
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("codex executor: close response body error: %v", errClose)
|
|
}
|
|
}
|
|
|
|
sawOutputDelta := false
|
|
if buffering {
|
|
for scanner.Scan() {
|
|
line := applyCodexIdentityConfuseResponsePayload(scanner.Bytes(), identityState)
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
|
|
translatedLine := bytes.Clone(line)
|
|
isHandshake := false
|
|
terminalSuccess := false
|
|
|
|
if transformed, ok := grokbuild.TransformKeepaliveSSELine(translatedLine, isGrokClient); ok {
|
|
translatedLine = transformed
|
|
isHandshake = true
|
|
} else if bytes.HasPrefix(line, dataTag) {
|
|
data := bytes.TrimSpace(line[5:])
|
|
data = helps.RestoreCodexMultiAgentV2Response(data, optimizeMultiAgentV2)
|
|
observeCodexTokenEvent(reporter, data)
|
|
translatedLine = append([]byte("data: "), data...)
|
|
eventType := gjson.GetBytes(data, "type").String()
|
|
if streamErr, terminalBody, ok := codexTerminalFailureErrWithCooling(data, e.modelLevelCooling()); ok {
|
|
closeBootstrapBody()
|
|
if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, errClearReplay)
|
|
reporter.PublishFailure(ctx, errClearReplay)
|
|
return nil, errClearReplay
|
|
}
|
|
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
|
|
reporter.PublishFailure(ctx, streamErr)
|
|
if isCodexOverloadBootstrapFailure(terminalBody) {
|
|
// Transient capacity rejection smuggled into an HTTP 200 stream. Fail the
|
|
// attempt before the downstream headers are committed so the conductor can
|
|
// transparently retry on another credential, and report the status the
|
|
// upstream refused to put on the wire.
|
|
helps.LogWithRequestID(ctx).Debugf("codex executor: bootstrap overload rejection after %d buffered lines, failing over", bufferedFrames)
|
|
return nil, newCodexBootstrapOverloadErr(terminalBody)
|
|
}
|
|
bootstrapTerminalErr = streamErr
|
|
break
|
|
}
|
|
if helps.HasMeaningfulCodexOutputDelta(data) {
|
|
sawOutputDelta = true
|
|
}
|
|
if helps.IsCodexTerminalEmptyIncomplete(data, len(outputItemsByIndex)+len(outputItemsFallback), sawOutputDelta) {
|
|
closeBootstrapBody()
|
|
streamErr := newCodexEmptyIncompleteStreamError()
|
|
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
|
|
reporter.PublishFailure(ctx, streamErr)
|
|
// Not an overload rejection, so it keeps its in-stream delivery: flush what is
|
|
// held and hand the error downstream, matching the websocket executor and the
|
|
// contract that only overload and rate-limit rejections fail the attempt over.
|
|
// The conductor commits a stream once it has seen a payload, so this only takes
|
|
// effect for downstream formats that render the held frames into a chunk.
|
|
bootstrapTerminalErr = streamErr
|
|
break
|
|
}
|
|
if isCodexBootstrapBufferableEvent(eventType, data) {
|
|
isHandshake = true
|
|
}
|
|
switch eventType {
|
|
case "response.output_item.done":
|
|
collectCodexOutputItemDone(data, outputItemsByIndex, &outputItemsFallback)
|
|
case "response.completed", "response.incomplete", "response.done":
|
|
terminalSuccess = true
|
|
data = normalizeCodexWebsocketCompletion(data)
|
|
if detail, ok := helps.ParseCodexUsage(data); ok {
|
|
reporter.Publish(ctx, detail)
|
|
} else {
|
|
reporter.EnsurePublished(ctx)
|
|
}
|
|
publishCodexImageToolUsage(ctx, reporter, body, data)
|
|
data = patchCodexCompletedOutput(data, outputItemsByIndex, outputItemsFallback)
|
|
if eventType == "response.completed" || eventType == "response.done" {
|
|
cacheCodexReasoningReplayFromCompleted(replayScope, data)
|
|
}
|
|
translatedLine = append([]byte("data: "), data...)
|
|
}
|
|
} else {
|
|
// Lines that are not data: frames - SSE comments, event:, id:, retry: and the blank
|
|
// separator - carry no event to check against the allow-list, so they are held with
|
|
// the frame they belong to. They spend the same budgets.
|
|
isHandshake = true
|
|
}
|
|
|
|
translatedLine = applyCodexIdentityExposeResponsePayload(translatedLine, identityState)
|
|
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, originalPayload, body, translatedLine, ¶m, claudeInputTokens)
|
|
if isHandshake && !terminalSuccess {
|
|
frameBytes := len(line)
|
|
for i := range chunks {
|
|
frameBytes += len(chunks[i])
|
|
}
|
|
if bufferedFrames < codexBootstrapMaxBufferedFrames && bufferedBytes+frameBytes <= codexBootstrapMaxBufferedBytes {
|
|
bufferedFrames++
|
|
bufferedBytes += frameBytes
|
|
bufferedChunks = append(bufferedChunks, chunks...)
|
|
continue
|
|
}
|
|
exhausted := "frame budget"
|
|
if bufferedFrames < codexBootstrapMaxBufferedFrames {
|
|
exhausted = "byte budget"
|
|
}
|
|
helps.LogWithRequestID(ctx).Debugf("codex executor: bootstrap %s exhausted after %d lines / %d bytes, releasing stream without overload probing", exhausted, bufferedFrames, bufferedBytes)
|
|
}
|
|
|
|
initialChunks = chunks
|
|
streamStarted = true
|
|
if terminalSuccess {
|
|
immediateTerminal = true
|
|
}
|
|
break
|
|
}
|
|
|
|
if !streamStarted && bootstrapTerminalErr == nil {
|
|
closeBootstrapBody()
|
|
if errScan := scanner.Err(); errScan != nil {
|
|
// A cancelled downstream request must not be recorded as an upstream failure or
|
|
// penalise the credential; mirror the unbuffered goroutine's guard.
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
|
|
reporter.PublishFailure(ctx, errScan)
|
|
return nil, errScan
|
|
}
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
streamErr := newCodexIncompleteStreamError()
|
|
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
|
|
reporter.PublishFailure(ctx, streamErr)
|
|
return nil, streamErr
|
|
}
|
|
}
|
|
|
|
chanCapacity := len(bufferedChunks) + len(initialChunks)
|
|
if bootstrapTerminalErr != nil {
|
|
chanCapacity++
|
|
}
|
|
out := make(chan cliproxyexecutor.StreamChunk, chanCapacity)
|
|
for _, chunk := range bufferedChunks {
|
|
out <- cliproxyexecutor.StreamChunk{Payload: chunk}
|
|
}
|
|
for _, chunk := range initialChunks {
|
|
out <- cliproxyexecutor.StreamChunk{Payload: chunk}
|
|
}
|
|
if bootstrapTerminalErr != nil {
|
|
// Buffered handshake payloads are flushed first so the conductor observes a committed
|
|
// stream and delivers this failure in-stream, exactly as the unbuffered path would.
|
|
out <- cliproxyexecutor.StreamChunk{Err: bootstrapTerminalErr}
|
|
close(out)
|
|
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
|
|
}
|
|
if immediateTerminal {
|
|
closeBootstrapBody()
|
|
close(out)
|
|
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
|
|
}
|
|
|
|
go func() {
|
|
defer close(out)
|
|
defer func() {
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("codex executor: close response body error: %v", errClose)
|
|
}
|
|
}()
|
|
for scanner.Scan() {
|
|
line := applyCodexIdentityConfuseResponsePayload(scanner.Bytes(), identityState)
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
|
|
translatedLine := bytes.Clone(line)
|
|
terminalSuccess := false
|
|
|
|
if transformed, ok := grokbuild.TransformKeepaliveSSELine(translatedLine, isGrokClient); ok {
|
|
translatedLine = transformed
|
|
} else if bytes.HasPrefix(line, dataTag) {
|
|
data := bytes.TrimSpace(line[5:])
|
|
data = helps.RestoreCodexMultiAgentV2Response(data, optimizeMultiAgentV2)
|
|
observeCodexTokenEvent(reporter, data)
|
|
translatedLine = append([]byte("data: "), data...)
|
|
eventType := gjson.GetBytes(data, "type").String()
|
|
if streamErr, terminalBody, ok := codexTerminalFailureErrWithCooling(data, e.modelLevelCooling()); ok {
|
|
if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, errClearReplay)
|
|
reporter.PublishFailure(ctx, errClearReplay)
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Err: errClearReplay}:
|
|
case <-ctx.Done():
|
|
}
|
|
return
|
|
}
|
|
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
|
|
reporter.PublishFailure(ctx, streamErr)
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Err: streamErr}:
|
|
case <-ctx.Done():
|
|
}
|
|
return
|
|
}
|
|
if helps.HasMeaningfulCodexOutputDelta(data) {
|
|
sawOutputDelta = true
|
|
}
|
|
if helps.IsCodexTerminalEmptyIncomplete(data, len(outputItemsByIndex)+len(outputItemsFallback), sawOutputDelta) {
|
|
streamErr := newCodexEmptyIncompleteStreamError()
|
|
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
|
|
reporter.PublishFailure(ctx, streamErr)
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Err: streamErr}:
|
|
case <-ctx.Done():
|
|
}
|
|
return
|
|
}
|
|
switch eventType {
|
|
case "response.output_item.done":
|
|
collectCodexOutputItemDone(data, outputItemsByIndex, &outputItemsFallback)
|
|
case "response.completed", "response.incomplete", "response.done":
|
|
terminalSuccess = true
|
|
data = normalizeCodexWebsocketCompletion(data)
|
|
if detail, ok := helps.ParseCodexUsage(data); ok {
|
|
reporter.Publish(ctx, detail)
|
|
} else {
|
|
reporter.EnsurePublished(ctx)
|
|
}
|
|
publishCodexImageToolUsage(ctx, reporter, body, data)
|
|
data = patchCodexCompletedOutput(data, outputItemsByIndex, outputItemsFallback)
|
|
if eventType == "response.completed" || eventType == "response.done" {
|
|
cacheCodexReasoningReplayFromCompleted(replayScope, data)
|
|
}
|
|
translatedLine = append([]byte("data: "), data...)
|
|
}
|
|
}
|
|
|
|
translatedLine = applyCodexIdentityExposeResponsePayload(translatedLine, identityState)
|
|
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, originalPayload, body, translatedLine, ¶m, claudeInputTokens)
|
|
for i := range chunks {
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
if terminalSuccess {
|
|
return
|
|
}
|
|
}
|
|
if errScan := scanner.Err(); errScan != nil {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
|
|
}
|
|
streamErr := newCodexIncompleteStreamError()
|
|
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
|
|
reporter.PublishFailure(ctx, streamErr)
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Err: streamErr}:
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
|
|
}
|