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:
- 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.
- 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
- Configure a failover combo with at least two eligible OpenAI-compatible targets.
- 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”).
- Keep target B healthy and eligible.
- Send a streaming
/v1/responses request through combo/<id> from Codex App.
- 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
- 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.
- Send any request after NVIDIA has removed that model.
- NVIDIA returns HTTP 410 with a model-EOL message.
- 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
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:502 / upstream_server_error, but the combo does not advance to the next eligible target.In both captured cases
firstOutputMswasnull, 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:
Current 2.31.0 behavior matches two source gaps:
src/server/responses/core.tsacceptsresponse.okat 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"insrc/combos/failover.ts.The same paths are still present in release 2.31.0 (
6ae83b1f189c353935d4977bb01227484fbdb52b) and currentdevinspected at4f41a8e936141af7ee828e335da314b9dc1ef761:opencodex/src/server/responses/core.ts
Lines 1903 to 1928 in 6ae83b1
opencodex/src/server/responses/core.ts
Lines 5136 to 5179 in 6ae83b1
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
text/event-stream, then a terminal provider error before any text/tool output. The captured provider emitted服务繁忙,请稍后重试。(“Service busy, please try again later”)./v1/responsesrequest throughcombo/<id>from Codex App.This was observed 11 times in one session. Every affected row had:
firstOutputMs: nullsynthetic/hf:zai-org/GLM-5.2candidateOpenference/GLM-5.2attempt with status 502Representative request with Openference selected directly:
Representative request where a pre-stream 429 already advanced correctly, but the later zero-output SSE failure stopped the chain:
Case B: model-EOL HTTP 410
nvidia/z-ai/glm-5.2and whose later targets are healthy/eligible GLM-5.2 providers.Codex App retried the same turn six times between
04:51:09Zand04:51:18Z. Every logical request selected NVIDIA and recorded exactly one attempt, despite four eligible candidates.The Codex turn then completed with:
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/opencodex2.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 currentdevcommit4f41a8e936141af7ee828e335da314b9dc1ef761.Operating system
CachyOS, Linux
6.19.11-1-cachyos, x86_64Provider and model
Failover combo for GLM-5.2 using NVIDIA NIM, Openference, Synthetic, and OpenRouter targets.
Logs or error output
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