Skip to content

[Bug]: failover combos stop on zero-output SSE terminal failures and model-EOL HTTP 410 responses #2431

Description

@FacuM

Client or integration

Codex App

Area

Proxy and routing

Summary

A strategy: "failover" combo can stop without trying other eligible targets in two target-specific failure cases:

  1. An OpenAI-compatible provider returns HTTP 200 for a streaming request, then emits a terminal service-busy error before any assistant output. OpenCodex records the attempt as 502 / upstream_server_error, but the combo does not advance to the next eligible target.
  2. A provider returns HTTP 410 because the selected model reached end of life. OpenCodex returns the 410 to Codex App and repeatedly selects the same dead target, although several other combo targets are eligible.

In both captured cases firstOutputMs was null, so no meaningful assistant output had been committed and bounded replay to the next target should have been safe.

The first upstream incident was described as a service-busy/503 condition inside an HTTP-200 SSE stream. OpenCodex normalized the terminal stream event to HTTP 502 in its usage log.

Expected behavior:

  • A terminal provider error received before the first meaningful assistant output should be eligible for bounded combo failover, including when the upstream HTTP status was 200 and the failure arrived inside SSE.
  • Once any text/tool output has been emitted, OpenCodex should continue to fail closed rather than replaying and risking duplicate output or tool calls.
  • HTTP 410 with a provider/model lifecycle signal should advance to the next combo target. The dead target should receive an appropriate cooldown/circuit-breaker state so immediate client retries do not select it again.
  • Attempts should remain capped by the declared target list and retain per-attempt usage/routing traces.

Current 2.31.0 behavior matches two source gaps:

  • src/server/responses/core.ts accepts response.ok at the combo boundary before the OpenAI-compatible SSE stream is parsed. A later terminal stream failure updates the request log, but the parent combo loop has already exited.
  • comboFailureDecision(410, "...end of life...") returns "stop" in src/combos/failover.ts.

The same paths are still present in release 2.31.0 (6ae83b1f189c353935d4977bb01227484fbdb52b) and current dev inspected at 4f41a8e936141af7ee828e335da314b9dc1ef761:

  • https://github.com/lidge-jun/opencodex/blob/6ae83b1f189c353935d4977bb01227484fbdb52b/src/combos/failover.ts#L112-L165
  • if (response.ok) {
    sealRequestAttemptIdentity(
    attempt,
    childLog.provider,
    childLog.providerAdapter ?? attempt.adapter,
    childLog.accountLogLabel,
    );
    (logCtx.attempts ??= []).push(attempt);
    attemptRetained = true;
    noteComboSuccess(comboId, combo, pick.target, pick.writerGeneration);
    Object.assign(logCtx, childLog, {
    requestedModel,
    model: requestedModel,
    provider: "combo",
    comboId,
    routeDecision: logCtx.routeDecision,
    attempts: logCtx.attempts,
    activeAttempt: attempt,
    activeAttemptStartedAt: started,
    resolvedModel: childLog.resolvedModel ?? childLog.model,
    });
    options.onCodexAuthContextResolved?.(resolvedAuth);
    options.setTerminalOutcomeRecorder?.(terminalRecorder);
    callbackGate.commit();
    return response;
    }
  • if (parsed.stream) {
    const initialEventStream = activeAdapter.parseStream(
    upstreamResponse,
    translatorBudget,
    logCtx.activeTierMetadata,
    );
    const eventStream = terminalGuardEnabled
    ? guardTerminalEventStream({
    parsed,
    firstEvents: initialEventStream,
    adapterName: activeAdapter.name,
    maxAutoContinuations: 1,
    continuation: fetchTerminalGuardContinuation,
    })
    : initialEventStream;
    // The empty-completion guard sits OUTSIDE the terminal guard: a completed
    // turn with no text and no tool call is retried with the IDENTICAL request
    // (fetchTerminalGuardContinuation(parsed) replays the cached byte-identical
    // request — same body, same headers, same signal).
    const guardedEventStream = emptyCompletionGuardEnabled
    ? guardEmptyCompletionEventStream({
    firstEvents: eventStream,
    continuation: fetchGuardedEmptyCompletionRetry,
    })
    : eventStream;
    const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
    const sseStream = bridgeToResponsesSSE(
    guardedEventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
    () => upstream.abort(), 2_000,
    {
    translatorBudget,
    replayCacheScope: parsed._reasoningReplayScope,
    ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
    stallTimeoutSec: config.stallTimeoutSec,
    hideThinkingSummary: parsed.options.hideThinkingSummary,
    declaredToolNames,
    toolParameterSchemas,
    ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
    ...(routedCompaction ? { compaction: true } : {}),
    // Same grok-surface split as the runTurn branch above.
    ...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}),
    onUsage: usage => {
    // Raw adapter usage, pre wire-normalization (see the runTurn branch above).
    logCtx.usageFromBridge = true;

This is distinct from #1572, which tracks outcome-aware failover for routing profiles. These requests already use explicit strategy: "failover" combos, which do advance on ordinary pre-stream 429/5xx failures. It is also distinct from #590, which concerned reset-derived cooldowns blocking later same-account models.

Reproduction

Case A: terminal SSE error before first output

  1. Configure a failover combo with at least two eligible OpenAI-compatible targets.
  2. Make target A return HTTP 200 with text/event-stream, then a terminal provider error before any text/tool output. The captured provider emitted 服务繁忙,请稍后重试。 (“Service busy, please try again later”).
  3. Keep target B healthy and eligible.
  4. Send a streaming /v1/responses request through combo/<id> from Codex App.
  5. Observe that the logical request ends as 502 and target B is not attempted.

This was observed 11 times in one session. Every affected row had:

  • firstOutputMs: null
  • an eligible synthetic/hf:zai-org/GLM-5.2 candidate
  • a final Openference/GLM-5.2 attempt with status 502
  • no subsequent Synthetic attempt

Representative request with Openference selected directly:

2026-08-19T06:59:05Z
requestId: ocx-mszqqnpo-zv
requestedModel: combo/glm-5.2-wrapper
status: 502
firstOutputMs: null
terminalStatus: failed
attempts:
  1. Openference/GLM-5.2 -> 502 upstream_server_error
route candidates:
  nvidia/z-ai/glm-5.2 -> cooldown
  Openference/GLM-5.2 -> selected
  synthetic/hf:zai-org/GLM-5.2 -> eligible, not selected
upstreamError: "Provider stream error: 服务繁忙,请稍后重试。"

Representative request where a pre-stream 429 already advanced correctly, but the later zero-output SSE failure stopped the chain:

2026-08-19T06:53:59Z
requestId: ocx-mszqk3fb-zh
status: 502
firstOutputMs: null
attempts:
  1. nvidia/z-ai/glm-5.2 -> 429
  2. Openference/GLM-5.2 -> 502 upstream_server_error
route candidates also included eligible synthetic/hf:zai-org/GLM-5.2
actual: no third attempt

Case B: model-EOL HTTP 410

  1. Configure a failover combo whose first target is nvidia/z-ai/glm-5.2 and whose later targets are healthy/eligible GLM-5.2 providers.
  2. Send any request after NVIDIA has removed that model.
  3. NVIDIA returns HTTP 410 with a model-EOL message.
  4. Observe that OpenCodex returns 410 without trying the remaining targets.

Codex App retried the same turn six times between 04:51:09Z and 04:51:18Z. Every logical request selected NVIDIA and recorded exactly one attempt, despite four eligible candidates.

requestId: ocx-mt5bxjta-2fb
requestedModel: combo/glm-5.2-wrapper
status: 410
firstOutputMs: null
attempts:
  1. nvidia/z-ai/glm-5.2 -> 410 http_410
eligible candidates:
  - nvidia/z-ai/glm-5.2
  - synthetic/hf:zai-org/GLM-5.2
  - Openference/GLM-5.2
  - openrouter/z-ai/glm-5.2:free
upstreamError: "The model 'z-ai/glm-5.2' has reached its end of life on 2026-08-21T09:00:00Z and is no longer available."

The Codex turn then completed with:

unexpected status 410 Gone: Provider error 410: ... model ... reached its end of life ...
url: http://127.0.0.1:10200/v1/responses

Removing the dead NVIDIA target and starting the same prompt again routed to Openference and succeeded. That was a manual/config workaround, not same-request combo failover.

A conservative implementation could preflight/buffer combo child events only until either the first meaningful output is observed, after which no cross-target replay is allowed, or a terminal retryable error is observed before meaningful output, in which case the next target is attempted. For HTTP 410, a structured provider/model lifecycle classification would be safer than making every 4xx retryable.

Version

Observed on @bitkyc08/opencodex 2.29.0 for the HTTP 410 incident. The machine was subsequently updated to 2.31.0; source inspection confirms the same decision/path in 2.31.0 and current dev commit 4f41a8e936141af7ee828e335da314b9dc1ef761.

Operating system

CachyOS, Linux 6.19.11-1-cachyos, x86_64

Provider and model

Failover combo for GLM-5.2 using NVIDIA NIM, Openference, Synthetic, and OpenRouter targets.

Logs or error output

# Zero-output terminal stream failures (11 captured rows)
status=502
errorCode=upstream_server_error
firstOutputMs=null
upstreamError="Provider stream error: 服务繁忙,请稍后重试。"
# A later eligible combo target was not attempted.

# Model EOL (6 consecutive logical requests)
status=410
errorCode=http_410
firstOutputMs=null
attemptCount=1
upstreamError="... z-ai/glm-5.2 ... reached its end of life ... and is no longer available."
# Every route trace listed multiple eligible candidates.

Screenshots and supporting files

No screenshots are required. The request IDs and redacted usage rows above are available from ~/.opencodex/usage.jsonl. Full redacted rows can be attached if requested.

Redacted configuration

{
  "combos": {
    "glm-5.2-wrapper": {
      "strategy": "failover",
      "stickyLimit": 1,
      "defaultEffort": "max",
      "targets": [
        { "provider": "nvidia", "model": "z-ai/glm-5.2", "weight": 1 },
        { "provider": "Openference", "model": "GLM-5.2", "weight": 1 },
        { "provider": "synthetic", "model": "hf:zai-org/GLM-5.2", "weight": 1 },
        { "provider": "openrouter", "model": "z-ai/glm-5.2:free", "weight": 1 }
      ]
    }
  }
}

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't workingproviderProvider adapters, OpenAI-compat presets, upstream API quirksproxyHTTP proxy, routing, reverse-proxy / management authstreamingSSE, WebSocket, terminal stream framestoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions