Skip to content

Commit 09da52a

Browse files
committed
feat(translator): improve error handling and response conversion for incomplete statuses
- Enhanced response handling to support `response.incomplete` events across translator components and executors. - Refactored `resultErrorFromError` to centralize error conversion and consolidate repeated logic for constructing custom errors. - Updated handling of `max_output_tokens` and similar terminal reasons in OpenAI and Gemini translator workflows. - Introduced `IsRequestScoped` and `IsRequestScopedError` to differentiate between request-scoped and non-request-scoped errors. - Added comprehensive test cases for incomplete responses, terminal failures, and error propagation in both streaming and non-streaming conditions. Closes: #3055
1 parent 466cee6 commit 09da52a

15 files changed

Lines changed: 817 additions & 94 deletions

internal/runtime/executor/codex_executor.go

Lines changed: 101 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ const (
4444

4545
var dataTag = []byte("data:")
4646

47+
const codexIncompleteStreamMessage = "stream error: stream disconnected before completion: stream closed before response.completed"
48+
49+
type codexIncompleteStreamError struct {
50+
statusErr
51+
}
52+
53+
func newCodexIncompleteStreamError() codexIncompleteStreamError {
54+
return codexIncompleteStreamError{statusErr: statusErr{
55+
code: http.StatusRequestTimeout,
56+
msg: codexIncompleteStreamMessage,
57+
}}
58+
}
59+
60+
func (codexIncompleteStreamError) IsRequestScoped() bool {
61+
return true
62+
}
63+
4764
// Streamed Codex responses may emit response.output_item.done events while leaving
4865
// response.completed.response.output empty. Keep the stream path aligned with the
4966
// already-patched non-stream path by reconstructing response.output from those items.
@@ -116,6 +133,50 @@ func codexTerminalStreamContextLengthErr(eventData []byte) (statusErr, bool) {
116133
}
117134

118135
func codexTerminalStreamErr(eventData []byte) (statusErr, []byte, bool) {
136+
body, ok := codexTerminalFailureBody(eventData)
137+
if !ok || !codexTerminalStreamErrShouldHandle(body) {
138+
return statusErr{}, nil, false
139+
}
140+
return newCodexStatusErr(http.StatusBadRequest, body), body, true
141+
}
142+
143+
func codexTerminalFailureErr(eventData []byte) (statusErr, []byte, bool) {
144+
if streamErr, body, ok := codexTerminalStreamErr(eventData); ok {
145+
return streamErr, body, true
146+
}
147+
body, ok := codexTerminalFailureBody(eventData)
148+
if !ok {
149+
return statusErr{}, nil, false
150+
}
151+
return newCodexStatusErr(codexTerminalFailureStatus(body), body), body, true
152+
}
153+
154+
func codexTerminalFailureStatus(body []byte) int {
155+
for _, path := range []string{"error.status_code", "error.status"} {
156+
if status := int(gjson.GetBytes(body, path).Int()); status >= 400 && status <= 599 {
157+
return status
158+
}
159+
}
160+
161+
errorType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.type").String()))
162+
errorCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
163+
switch {
164+
case errorType == "invalid_request_error", errorType == "bad_request_error":
165+
return http.StatusBadRequest
166+
case errorType == "authentication_error", errorCode == "invalid_api_key", errorCode == "unauthorized":
167+
return http.StatusUnauthorized
168+
case errorType == "permission_error", errorCode == "forbidden", errorCode == "permission_denied":
169+
return http.StatusForbidden
170+
case errorType == "not_found_error", errorCode == "not_found", errorCode == "model_not_found":
171+
return http.StatusNotFound
172+
case errorType == "rate_limit_error", errorCode == "rate_limit_exceeded":
173+
return http.StatusTooManyRequests
174+
default:
175+
return http.StatusBadGateway
176+
}
177+
}
178+
179+
func codexTerminalFailureBody(eventData []byte) ([]byte, bool) {
119180
eventType := gjson.GetBytes(eventData, "type").String()
120181
var body []byte
121182
switch eventType {
@@ -130,15 +191,12 @@ func codexTerminalStreamErr(eventData []byte) (statusErr, []byte, bool) {
130191
body = codexTerminalErrorBody(eventData, "error")
131192
}
132193
default:
133-
return statusErr{}, nil, false
194+
return nil, false
134195
}
135196
if len(body) == 0 {
136-
return statusErr{}, nil, false
197+
body = []byte(`{"error":{"message":"upstream stream failed without error details"}}`)
137198
}
138-
if !codexTerminalStreamErrShouldHandle(body) {
139-
return statusErr{}, nil, false
140-
}
141-
return newCodexStatusErr(http.StatusBadRequest, body), body, true
199+
return body, true
142200
}
143201

144202
func codexTerminalStreamErrShouldHandle(body []byte) bool {
@@ -854,11 +912,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
854912
err = newCodexStatusErr(httpResp.StatusCode, b)
855913
return resp, err
856914
}
857-
data, err := io.ReadAll(httpResp.Body)
858-
if err != nil {
859-
helps.RecordAPIResponseError(ctx, e.cfg, err)
860-
return resp, err
861-
}
915+
data, errRead := io.ReadAll(httpResp.Body)
862916
upstreamData := applyCodexIdentityConfuseResponsePayload(data, identityState)
863917
helps.AppendAPIResponseChunk(ctx, e.cfg, upstreamData)
864918

@@ -873,7 +927,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
873927
eventData := bytes.TrimSpace(line[5:])
874928
eventType := gjson.GetBytes(eventData, "type").String()
875929

876-
if streamErr, terminalBody, ok := codexTerminalStreamErr(eventData); ok {
930+
if streamErr, terminalBody, ok := codexTerminalFailureErr(eventData); ok {
877931
if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil {
878932
return resp, errClearReplay
879933
}
@@ -895,7 +949,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
895949
continue
896950
}
897951

898-
if eventType != "response.completed" {
952+
if eventType != "response.completed" && eventType != "response.incomplete" {
899953
continue
900954
}
901955

@@ -926,15 +980,25 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
926980
}
927981
completedData = completedDataPatched
928982
}
929-
cacheCodexReasoningReplayFromCompleted(replayScope, completedData)
983+
if eventType == "response.completed" {
984+
cacheCodexReasoningReplayFromCompleted(replayScope, completedData)
985+
}
930986

931987
var param any
932988
clientCompletedData := applyCodexIdentityExposeResponsePayload(completedData, identityState)
933989
out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, originalPayload, body, clientCompletedData, &param)
934990
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
935991
return resp, nil
936992
}
937-
err = statusErr{code: 408, msg: "stream error: stream disconnected before completion: stream closed before response.completed"}
993+
if errRead != nil {
994+
if errCtx := ctx.Err(); errCtx != nil {
995+
helps.RecordAPIResponseError(ctx, e.cfg, errCtx)
996+
err = errCtx
997+
return resp, err
998+
}
999+
helps.RecordAPIResponseError(ctx, e.cfg, errRead)
1000+
}
1001+
err = newCodexIncompleteStreamError()
9381002
return resp, err
9391003
}
9401004

@@ -1159,10 +1223,12 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
11591223
line := applyCodexIdentityConfuseResponsePayload(scanner.Bytes(), identityState)
11601224
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
11611225
translatedLine := bytes.Clone(line)
1226+
terminalSuccess := false
11621227

11631228
if bytes.HasPrefix(line, dataTag) {
11641229
data := bytes.TrimSpace(line[5:])
1165-
if streamErr, terminalBody, ok := codexTerminalStreamErr(data); ok {
1230+
eventType := gjson.GetBytes(data, "type").String()
1231+
if streamErr, terminalBody, ok := codexTerminalFailureErr(data); ok {
11661232
if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil {
11671233
helps.RecordAPIResponseError(ctx, e.cfg, errClearReplay)
11681234
reporter.PublishFailure(ctx, errClearReplay)
@@ -1180,16 +1246,19 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
11801246
}
11811247
return
11821248
}
1183-
switch gjson.GetBytes(data, "type").String() {
1249+
switch eventType {
11841250
case "response.output_item.done":
11851251
collectCodexOutputItemDone(data, outputItemsByIndex, &outputItemsFallback)
1186-
case "response.completed":
1252+
case "response.completed", "response.incomplete":
1253+
terminalSuccess = true
11871254
if detail, ok := helps.ParseCodexUsage(data); ok {
11881255
reporter.Publish(ctx, detail)
11891256
}
11901257
publishCodexImageToolUsage(ctx, reporter, body, data)
11911258
data = patchCodexCompletedOutput(data, outputItemsByIndex, outputItemsFallback)
1192-
cacheCodexReasoningReplayFromCompleted(replayScope, data)
1259+
if eventType == "response.completed" {
1260+
cacheCodexReasoningReplayFromCompleted(replayScope, data)
1261+
}
11931262
translatedLine = append([]byte("data: "), data...)
11941263
}
11951264
}
@@ -1203,14 +1272,22 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
12031272
return
12041273
}
12051274
}
1275+
if terminalSuccess {
1276+
return
1277+
}
12061278
}
12071279
if errScan := scanner.Err(); errScan != nil {
1208-
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
1209-
reporter.PublishFailure(ctx, errScan)
1210-
select {
1211-
case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
1212-
case <-ctx.Done():
1280+
if ctx.Err() != nil {
1281+
return
12131282
}
1283+
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
1284+
}
1285+
streamErr := newCodexIncompleteStreamError()
1286+
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
1287+
reporter.PublishFailure(ctx, streamErr)
1288+
select {
1289+
case out <- cliproxyexecutor.StreamChunk{Err: streamErr}:
1290+
case <-ctx.Done():
12141291
}
12151292
}()
12161293
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil

0 commit comments

Comments
 (0)