mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
* feat(codex): add opt-in stream bootstrap buffering
The upstream smuggles capacity rejections into an HTTP 200 stream. The
handshake events arrive normally and only a later event carries
{"error":{"type":"service_unavailable_error","code":
"server_is_overloaded"}}. By then the executor has already handed the
first chunk downstream, the response is committed, and the conductor can
no longer retry on another credential, so the request fails even though
other credentials were available.
When codex.stream-bootstrap-buffering is enabled the executor holds back
the handshake events until it can tell whether the stream carries real
output or a rejection. An overload rejection then fails the attempt
before any chunk is delivered, letting the conductor retry on another
credential; every other terminal failure is flushed in order and
delivered in-stream exactly as before.
Detection uses an event-type allow-list rather than a fixed count. On the
websocket transport codex.rate_limits and codex.response.metadata arrive
before response.created, making the first generated event the fifth
frame, so a small counter would release the stream before the rejection
is visible. Buffering is bounded and hitting the bound degrades to the
original unbuffered behaviour.
Two details are load-bearing. The error must be returned synchronously:
delivering it as the first stream chunk makes ExecuteStream downgrade it
into a committed 200 and the status is lost. And the websocket path must
not signal an upstream disconnect for a rejection it intends to retry,
because the downstream handler closes the client connection on that
signal and the retry would have nowhere to deliver.
The 503 status is produced only on this path rather than in the shared
codexTerminalFailureStatus mapping, so disabling the feature restores the
previous behaviour exactly, including cooldown classification and
retry-after parsing.
Defaults to false: response headers are withheld until generation
starts, which can trip client or reverse-proxy read timeouts.
* test(codex): pin bootstrap overload failover through the conductor
Executor-level tests cannot show what the client finally receives. These
exercise ExecuteStream end to end to pin three properties that are easy
to regress:
- consecutive overloaded credentials are skipped until one serves the
request, and retries are capped by max-retry-credentials rather than
multiplying with request-retry
- exhausting the pool surfaces the upstream status instead of a
committed 200 stream
- with buffering disabled the rejection stays an in-stream error on a
committed stream, which is the behaviour the feature must preserve
The third case also documents why the executor returns its error
synchronously: an error arriving as the first stream chunk is wrapped and
downgraded into a committed 200, silently losing the status.
456 lines
17 KiB
Go
456 lines
17 KiB
Go
package executor
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
const codexIncompleteStreamMessage = "stream error: stream disconnected before completion: stream closed before response.completed"
|
|
|
|
type codexIncompleteStreamError struct {
|
|
statusErr
|
|
}
|
|
|
|
func newCodexIncompleteStreamError() codexIncompleteStreamError {
|
|
return codexIncompleteStreamError{statusErr: statusErr{
|
|
code: http.StatusRequestTimeout,
|
|
msg: codexIncompleteStreamMessage,
|
|
}}
|
|
}
|
|
|
|
func (codexIncompleteStreamError) IsRequestScoped() bool {
|
|
return true
|
|
}
|
|
|
|
// Streamed Codex responses may emit response.output_item.done events while leaving
|
|
// response.completed.response.output empty. Keep the stream path aligned with the
|
|
// already-patched non-stream path by reconstructing response.output from those items.
|
|
func collectCodexOutputItemDone(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback *[][]byte) {
|
|
itemResult := gjson.GetBytes(eventData, "item")
|
|
if !itemResult.Exists() || itemResult.Type != gjson.JSON {
|
|
return
|
|
}
|
|
outputIndexResult := gjson.GetBytes(eventData, "output_index")
|
|
if outputIndexResult.Exists() {
|
|
outputItemsByIndex[outputIndexResult.Int()] = []byte(itemResult.Raw)
|
|
return
|
|
}
|
|
*outputItemsFallback = append(*outputItemsFallback, []byte(itemResult.Raw))
|
|
}
|
|
|
|
func hydrateCodexCompletedOutputItemIDs(eventData []byte, outputItems []gjson.Result, outputItemsByIndex map[int64][]byte) []byte {
|
|
patchedData := eventData
|
|
for outputIndex, outputItem := range outputItems {
|
|
itemData := []byte(outputItem.Raw)
|
|
itemID := gjson.GetBytes(itemData, "id")
|
|
if itemID.Exists() && itemID.Type != gjson.Null && (itemID.Type != gjson.String || strings.TrimSpace(itemID.String()) != "") {
|
|
continue
|
|
}
|
|
|
|
completedItem, ok := outputItemsByIndex[int64(outputIndex)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
completedID := gjson.GetBytes(completedItem, "id")
|
|
if completedID.Type != gjson.String || strings.TrimSpace(completedID.String()) == "" {
|
|
continue
|
|
}
|
|
|
|
updatedData, errSet := sjson.SetRawBytes(patchedData, "response.output."+strconv.Itoa(outputIndex)+".id", []byte(completedID.Raw))
|
|
if errSet != nil {
|
|
continue
|
|
}
|
|
patchedData = updatedData
|
|
}
|
|
return patchedData
|
|
}
|
|
|
|
func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
|
|
outputResult := gjson.GetBytes(eventData, "response.output")
|
|
if outputResult.Exists() && outputResult.IsArray() && len(outputResult.Array()) > 0 {
|
|
return hydrateCodexCompletedOutputItemIDs(eventData, outputResult.Array(), outputItemsByIndex)
|
|
}
|
|
|
|
shouldPatchOutput := (!outputResult.Exists() || !outputResult.IsArray() || len(outputResult.Array()) == 0) && (len(outputItemsByIndex) > 0 || len(outputItemsFallback) > 0)
|
|
if !shouldPatchOutput {
|
|
return eventData
|
|
}
|
|
|
|
indexes := make([]int64, 0, len(outputItemsByIndex))
|
|
for idx := range outputItemsByIndex {
|
|
indexes = append(indexes, idx)
|
|
}
|
|
sort.Slice(indexes, func(i, j int) bool {
|
|
return indexes[i] < indexes[j]
|
|
})
|
|
|
|
items := make([][]byte, 0, len(outputItemsByIndex)+len(outputItemsFallback))
|
|
for _, idx := range indexes {
|
|
items = append(items, outputItemsByIndex[idx])
|
|
}
|
|
items = append(items, outputItemsFallback...)
|
|
|
|
outputArray := []byte("[]")
|
|
if len(items) > 0 {
|
|
var buf bytes.Buffer
|
|
totalLen := 2
|
|
for _, item := range items {
|
|
totalLen += len(item)
|
|
}
|
|
if len(items) > 1 {
|
|
totalLen += len(items) - 1
|
|
}
|
|
buf.Grow(totalLen)
|
|
buf.WriteByte('[')
|
|
for i, item := range items {
|
|
if i > 0 {
|
|
buf.WriteByte(',')
|
|
}
|
|
buf.Write(item)
|
|
}
|
|
buf.WriteByte(']')
|
|
outputArray = buf.Bytes()
|
|
}
|
|
|
|
completedDataPatched, _ := sjson.SetRawBytes(eventData, "response.output", outputArray)
|
|
return completedDataPatched
|
|
}
|
|
|
|
func codexTerminalStreamContextLengthErr(eventData []byte) (statusErr, bool) {
|
|
streamErr, body, ok := codexTerminalStreamErr(eventData)
|
|
if !ok || !codexTerminalErrorIsContextLength(body) {
|
|
return statusErr{}, false
|
|
}
|
|
return streamErr, true
|
|
}
|
|
|
|
func codexTerminalStreamErr(eventData []byte) (statusErr, []byte, bool) {
|
|
body, ok := codexTerminalFailureBody(eventData)
|
|
if !ok || !codexTerminalStreamErrShouldHandle(body) {
|
|
return statusErr{}, nil, false
|
|
}
|
|
return newCodexStatusErr(http.StatusBadRequest, body), body, true
|
|
}
|
|
|
|
func codexTerminalFailureErr(eventData []byte) (statusErr, []byte, bool) {
|
|
if streamErr, body, ok := codexTerminalStreamErr(eventData); ok {
|
|
return streamErr, body, true
|
|
}
|
|
body, ok := codexTerminalFailureBody(eventData)
|
|
if !ok {
|
|
return statusErr{}, nil, false
|
|
}
|
|
return newCodexStatusErr(codexTerminalFailureStatus(body), body), body, true
|
|
}
|
|
|
|
func codexTerminalFailureStatus(body []byte) int {
|
|
for _, path := range []string{"error.status_code", "error.status"} {
|
|
if status := int(gjson.GetBytes(body, path).Int()); status >= 400 && status <= 599 {
|
|
return status
|
|
}
|
|
}
|
|
|
|
errorType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.type").String()))
|
|
errorCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
|
|
switch {
|
|
case errorCode == "cyber_policy":
|
|
return http.StatusBadRequest
|
|
case errorType == "invalid_request_error", errorType == "bad_request_error":
|
|
return http.StatusBadRequest
|
|
case errorType == "authentication_error", errorCode == "invalid_api_key", errorCode == "unauthorized":
|
|
return http.StatusUnauthorized
|
|
case errorType == "permission_error", errorCode == "forbidden", errorCode == "permission_denied":
|
|
return http.StatusForbidden
|
|
case errorType == "not_found_error", errorCode == "not_found", errorCode == "model_not_found":
|
|
return http.StatusNotFound
|
|
case errorType == "rate_limit_error", errorCode == "rate_limit_exceeded":
|
|
return http.StatusTooManyRequests
|
|
default:
|
|
return http.StatusBadGateway
|
|
}
|
|
}
|
|
|
|
func codexTerminalFailureBody(eventData []byte) ([]byte, bool) {
|
|
eventType := gjson.GetBytes(eventData, "type").String()
|
|
var body []byte
|
|
switch eventType {
|
|
case "error":
|
|
body = codexTerminalErrorBody(eventData, "error")
|
|
if len(body) == 0 {
|
|
body = codexTerminalTopLevelErrorBody(eventData)
|
|
}
|
|
case "response.failed":
|
|
body = codexTerminalErrorBody(eventData, "response.error")
|
|
if len(body) == 0 {
|
|
body = codexTerminalErrorBody(eventData, "error")
|
|
}
|
|
default:
|
|
return nil, false
|
|
}
|
|
if len(body) == 0 {
|
|
body = []byte(`{"error":{"message":"upstream stream failed without error details"}}`)
|
|
}
|
|
return body, true
|
|
}
|
|
|
|
func codexTerminalStreamErrShouldHandle(body []byte) bool {
|
|
if codexTerminalErrorIsContextLength(body) {
|
|
return true
|
|
}
|
|
if isCodexUsageLimitError(body) || isCodexModelCapacityError(body) {
|
|
return true
|
|
}
|
|
code, _, ok := codexStatusErrorClassification(http.StatusBadRequest, body)
|
|
return ok && code == "thinking_signature_invalid"
|
|
}
|
|
|
|
func codexTerminalErrorBody(eventData []byte, path string) []byte {
|
|
errorResult := gjson.GetBytes(eventData, path)
|
|
if !errorResult.Exists() {
|
|
return nil
|
|
}
|
|
body := []byte(`{"error":{}}`)
|
|
if errorResult.Type == gjson.JSON {
|
|
body, _ = sjson.SetRawBytes(body, "error", []byte(errorResult.Raw))
|
|
} else if message := strings.TrimSpace(errorResult.String()); message != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", message)
|
|
}
|
|
if strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) == "" {
|
|
if message := strings.TrimSpace(gjson.GetBytes(eventData, "response.error.message").String()); message != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", message)
|
|
}
|
|
}
|
|
if strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) == "" {
|
|
if code := strings.TrimSpace(gjson.GetBytes(body, "error.code").String()); code != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", code)
|
|
}
|
|
}
|
|
if strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) == "" {
|
|
if errorType := strings.TrimSpace(gjson.GetBytes(body, "error.type").String()); errorType != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", errorType)
|
|
}
|
|
}
|
|
return body
|
|
}
|
|
|
|
func codexTerminalTopLevelErrorBody(eventData []byte) []byte {
|
|
message := strings.TrimSpace(gjson.GetBytes(eventData, "message").String())
|
|
code := strings.TrimSpace(gjson.GetBytes(eventData, "code").String())
|
|
errorType := strings.TrimSpace(gjson.GetBytes(eventData, "error_type").String())
|
|
param := strings.TrimSpace(gjson.GetBytes(eventData, "param").String())
|
|
if message == "" && code == "" && errorType == "" && param == "" {
|
|
return nil
|
|
}
|
|
|
|
body := []byte(`{"error":{}}`)
|
|
if message != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", message)
|
|
}
|
|
if code != "" {
|
|
body, _ = sjson.SetBytes(body, "error.code", code)
|
|
}
|
|
if errorType != "" {
|
|
body, _ = sjson.SetBytes(body, "error.type", errorType)
|
|
}
|
|
if param != "" {
|
|
body, _ = sjson.SetBytes(body, "error.param", param)
|
|
}
|
|
if strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) == "" {
|
|
if code != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", code)
|
|
} else if errorType != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", errorType)
|
|
}
|
|
}
|
|
return body
|
|
}
|
|
|
|
func codexTerminalErrorIsContextLength(body []byte) bool {
|
|
errorCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
|
|
message := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.message").String()))
|
|
return errorCode == "context_length_exceeded" ||
|
|
errorCode == "context_too_large" ||
|
|
strings.Contains(message, "context window") ||
|
|
strings.Contains(message, "context length") ||
|
|
strings.Contains(message, "too many tokens")
|
|
}
|
|
|
|
func newCodexStatusErr(statusCode int, body []byte) statusErr {
|
|
errCode := statusCode
|
|
if isCodexModelCapacityError(body) || isCodexUsageLimitError(body) {
|
|
errCode = http.StatusTooManyRequests
|
|
}
|
|
body = classifyCodexStatusError(errCode, body)
|
|
err := statusErr{code: errCode, msg: string(body)}
|
|
if retryAfter := parseCodexRetryAfter(errCode, body, time.Now()); retryAfter != nil {
|
|
err.retryAfter = retryAfter
|
|
}
|
|
return err
|
|
}
|
|
|
|
func classifyCodexStatusError(statusCode int, body []byte) []byte {
|
|
code, errType, ok := codexStatusErrorClassification(statusCode, body)
|
|
if !ok {
|
|
return body
|
|
}
|
|
message := gjson.GetBytes(body, "error.message").String()
|
|
if message == "" {
|
|
message = gjson.GetBytes(body, "message").String()
|
|
}
|
|
if message == "" {
|
|
message = strings.TrimSpace(string(body))
|
|
}
|
|
if message == "" {
|
|
message = http.StatusText(statusCode)
|
|
}
|
|
out := []byte(`{"error":{}}`)
|
|
out, _ = sjson.SetBytes(out, "error.message", message)
|
|
out, _ = sjson.SetBytes(out, "error.type", errType)
|
|
out, _ = sjson.SetBytes(out, "error.code", code)
|
|
return out
|
|
}
|
|
|
|
func codexStatusErrorClassification(statusCode int, body []byte) (code string, errType string, ok bool) {
|
|
errorMessage := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.message").String()))
|
|
if errorMessage == "" {
|
|
errorMessage = strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "message").String()))
|
|
}
|
|
lower := strings.ToLower(strings.TrimSpace(string(body)))
|
|
upstreamCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
|
|
upstreamType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.type").String()))
|
|
isInvalidRequest := upstreamType == "" || upstreamType == "invalid_request_error"
|
|
|
|
switch {
|
|
case statusCode == http.StatusRequestEntityTooLarge || upstreamCode == "context_length_exceeded" || upstreamCode == "context_too_large" || isInvalidRequest && (strings.Contains(errorMessage, "context length") || strings.Contains(errorMessage, "context_length") || strings.Contains(errorMessage, "maximum context") || strings.Contains(errorMessage, "too many tokens")):
|
|
return "context_too_large", "invalid_request_error", true
|
|
case strings.Contains(lower, "invalid signature in thinking block") || strings.Contains(lower, "invalid_encrypted_content"):
|
|
return "thinking_signature_invalid", "invalid_request_error", true
|
|
case upstreamCode == "previous_response_not_found" || strings.Contains(lower, "previous_response_not_found") || strings.Contains(lower, "previous_response_id") && strings.Contains(lower, "not found"):
|
|
return "previous_response_not_found", "invalid_request_error", true
|
|
case statusCode == http.StatusUnauthorized || upstreamType == "authentication_error" || upstreamCode == "invalid_api_key" || strings.Contains(lower, "invalid or expired token") || strings.Contains(lower, "refresh_token_reused"):
|
|
return "auth_unavailable", "authentication_error", true
|
|
default:
|
|
return "", "", false
|
|
}
|
|
}
|
|
|
|
func isCodexModelCapacityError(errorBody []byte) bool {
|
|
if len(errorBody) == 0 {
|
|
return false
|
|
}
|
|
candidates := []string{
|
|
gjson.GetBytes(errorBody, "error.message").String(),
|
|
gjson.GetBytes(errorBody, "message").String(),
|
|
string(errorBody),
|
|
}
|
|
for _, candidate := range candidates {
|
|
lower := strings.ToLower(strings.TrimSpace(candidate))
|
|
if lower == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(lower, "selected model is at capacity") ||
|
|
strings.Contains(lower, "model is at capacity. please try a different model") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isCodexUsageLimitError reports whether the error body represents a Codex
|
|
// quota/plan-limit exhaustion (error.type == "usage_limit_reached"). This is the
|
|
// signal Codex emits when a credential's usage quota is depleted, and it carries
|
|
// reset timing (resets_at/resets_in_seconds) parsed by parseCodexRetryAfter.
|
|
// Transient per-minute rate limits (rate_limit_error/rate_limit_exceeded) are
|
|
// intentionally excluded, as they should be retried rather than cooled down.
|
|
func isCodexUsageLimitError(errorBody []byte) bool {
|
|
if len(errorBody) == 0 {
|
|
return false
|
|
}
|
|
candidates := []string{
|
|
gjson.GetBytes(errorBody, "error.type").String(),
|
|
gjson.GetBytes(errorBody, "type").String(),
|
|
}
|
|
for _, candidate := range candidates {
|
|
if strings.EqualFold(strings.TrimSpace(candidate), "usage_limit_reached") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time.Duration {
|
|
if statusCode != http.StatusTooManyRequests || len(errorBody) == 0 {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(gjson.GetBytes(errorBody, "error.type").String()) != "usage_limit_reached" {
|
|
return nil
|
|
}
|
|
if resetsAt := gjson.GetBytes(errorBody, "error.resets_at").Int(); resetsAt > 0 {
|
|
resetAtTime := time.Unix(resetsAt, 0)
|
|
if resetAtTime.After(now) {
|
|
retryAfter := resetAtTime.Sub(now)
|
|
return &retryAfter
|
|
}
|
|
}
|
|
if resetsInSeconds := gjson.GetBytes(errorBody, "error.resets_in_seconds").Int(); resetsInSeconds > 0 {
|
|
retryAfter := time.Duration(resetsInSeconds) * time.Second
|
|
return &retryAfter
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// codexBootstrapMaxBufferedEvents bounds how many handshake metadata events may be held
|
|
// back while probing for an upstream rejection embedded in an HTTP 200 stream. The websocket
|
|
// transport prefixes response events with codex.response.metadata and codex.rate_limits frames,
|
|
// so the limit must comfortably exceed the four handshake frames observed in practice. Once the
|
|
// limit is reached the stream is released and the original unbuffered semantics apply.
|
|
const codexBootstrapMaxBufferedEvents = 16
|
|
|
|
// isCodexHandshakeMetadataEvent reports whether an event carries no generated output and is
|
|
// therefore safe to hold back before the downstream response headers are committed. Keeping a type
|
|
// allow-list rather than a fixed event count matters for the websocket transport, where the
|
|
// handshake frames arrive before response.created and would otherwise exhaust a small counter
|
|
// before the rejection event is seen.
|
|
func isCodexHandshakeMetadataEvent(eventType string) bool {
|
|
switch eventType {
|
|
case "response.created", "response.in_progress", "codex.rate_limits", "codex.response.metadata":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// newCodexBootstrapOverloadErr reports a buffered overload rejection with its real status.
|
|
//
|
|
// The status is deliberately produced here instead of in codexTerminalFailureStatus: that mapping
|
|
// is shared with the unbuffered path, where the rejection is delivered in-stream and a status
|
|
// change would alter cooldown classification and retry-after parsing for everyone. Keeping 503
|
|
// scoped to this path means disabling the feature restores the previous behaviour exactly.
|
|
func newCodexBootstrapOverloadErr(body []byte) statusErr {
|
|
return newCodexStatusErr(http.StatusServiceUnavailable, body)
|
|
}
|
|
|
|
// isCodexOverloadBootstrapFailure reports whether a terminal failure delivered inside an HTTP 200
|
|
// stream is a transient capacity rejection that a different credential may be able to serve.
|
|
// Only these failures justify replacing the whole attempt during bootstrap; every other terminal
|
|
// failure keeps the original in-stream delivery semantics so downstream behaviour is unchanged.
|
|
func isCodexOverloadBootstrapFailure(body []byte) bool {
|
|
errorType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.type").String()))
|
|
errorCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
|
|
switch {
|
|
case errorType == "service_unavailable_error", errorCode == "server_is_overloaded":
|
|
return true
|
|
case errorType == "rate_limit_error", errorCode == "rate_limit_exceeded":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|