Skip to content

OpenRouter Ox Alpha returns HTTP 200 but Codex App receives an empty completion without retry #2423

Closed

Description

@optimikelabs

Client or integration

Codex App

Provider or upstream service

OpenRouter

OpenCodex version

2.31.0 (also reproduced after a controlled rollback to 2.29.0)

Endpoint or capability

POST /v1/responses, streaming, through the openai-chat adapter

Current behaviour

When Codex App sends a turn to openrouter/stealth-ox-alpha, OpenCodex routes it correctly and records HTTP 200, but Codex receives no assistant text, no tool call, no visible error, and no usage. The turn silently ends after roughly 4–8 seconds.

The request log records only one upstream send (sendCount: 1) and no recovery (recoveryKinds: []). Therefore the empty-completion guard introduced in #1655 does not retry this response shape and does not surface empty_completion_retry_failed.

The same symptom occurs with OpenCodex 2.29.0 and 2.31.0.

A direct request to the same OpenRouter model, with the same reasoning effort, succeeds outside OpenCodex:

  • non-streaming: HTTP 200, visible text and reasoning, usage returned;
  • streaming: HTTP 200, content deltas, reasoning deltas, usage and [DONE] returned.

This indicates that authentication, model availability, and the OpenRouter key are working.

Expected behaviour

OpenCodex should forward the model's visible output to Codex App. If the adapter actually produces a completed turn containing neither text nor a tool call, the guard from #1655 should retry the identical turn once and then either return content or surface empty_completion_retry_failed.

Minimal redacted request or reproduction

# Configure an OpenRouter provider with a valid key (not shown), then expose:
# provider: openrouter
# model: stealth/ox-alpha
# contextWindow: 1048576
# input modalities: text, image
# reasoning efforts: low, high, max
# default effort: max
# emptyCompletionRetry: true

ocx start --port 10100

# In Codex App, select openrouter/stealth-ox-alpha and send a simple prompt,
# for example: "Reply with exactly: test ok"

# Observed: the Codex turn ends without any assistant response.

Actual response or error

requestId: ocx-mt529pwy-j
provider: openrouter
model: stealth/ox-alpha
adapter: openai-chat
status: 200
durationMs: 7406
usageStatus: unreported
requestedEffort: max
effectiveEffort: max
reasoningWireField: reasoning_effort
reasoningWireValue: max
attempts[0].status: 200
attempts[0].durationMs: 4616
attempts[0].sendCount: 1
attempts[0].recoveryKinds: []
routeKind: explicit-provider

Codex App displays no assistant message and no error.

Upstream documentation

https://openrouter.ai/stealth/ox-alpha

OpenRouter uses the OpenAI-compatible chat completions API:
https://openrouter.ai/docs/api-reference/chat-completion

Suggested mapping or implementation notes

This looks like a response-shape coverage gap in the empty-completion guard from #1655. Possible areas to inspect:

  1. Whether the OpenRouter/Ox Alpha stream ends in a shape that the openai-chat adapter treats as a clean terminal/EOF but guardEmptyCompletionEventStream does not classify as an empty completed turn.
  2. Whether an Ox Alpha delta is classified as content by the guard but later discarded before reaching Codex App.
  3. The truncated-source branch: if the source ends without the exact terminal event expected by the guard, it may release held events and return without performing the configured retry.

The request log is especially relevant: a genuine empty-completion recovery should show a second send and an empty-completion recovery kind, but neither is present.

Additional context and attachments

Related work/issues:

Checks performed:

  • Existing provider and compatibility issues were searched; no issue specific to Ox Alpha was found.
  • The request and response details above are redacted and contain no credentials.
  • Direct OpenRouter tests confirmed that the upstream model returns visible output and usage.

Activity

  1. added
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    toolstool_calls, MCP, web-search / sidecar tools
    on Aug 23, 2026
  2. lidge-jun commented on Aug 23, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 58 / 80

    설명: 이 이슈는 오픈라우터 옥스 알파로 말을 보내면 서버는 200 을 기록하는데, 코덱스 앱에는 글도 도구도 에러도 안 보인다고 한다. 지금 CURRENT dev HEAD 는 4f41a8e93 이다. 이번 시간에 origin/dev 는 그대로다. 새 머지는 없다. 착지는 여전히 2396 사용량 CLI 오늘 비용이다. package.json 은 2.27.0 이다. src/config.ts 는 3975줄이다. src/runtime 폴더는 지금 HEAD 에 없다. 작성자는 메인 2.31.0 과 2.29.0 에서 재현했다고 적었다. 지금 origin/dev 에도 같은 오픈에이아이 채팅 어댑터와 빈 완료 가드가 있다.

    작성자 로그는 제공자 오픈라우터, 모델 stealth/ox-alpha, 어댑터 openai-chat, 상태 200, 사용량 없음, 보냄 횟수 1, 복구 종류 빈 목록이다. 빈 완료 가드가 한 번 더 보냈다면 보냄 횟수가 2 가 되고, 그래도 비면 empty_completion_retry_failed 가 보여야 한다. 둘 다 없다. 같은 키로 오픈라우터에 직접 보내면 글과 생각과 사용량이 나온다. 인증과 모델 자체는 산다. src/providers/registry.ts 1457줄 오픈라우터 칸은 어댑터가 openai-chat 이고 1466줄 모델 목록에 stealth/ox-alpha 가 있다. 컨텍스트는 1048576, 입력은 글과 그림이다. 카탈로그 팁의 x-preview-f-free 는 젠 무료 슬러그다. 이 이슈의 stealth/ox-alpha 와 같은 모델의 다른 이름이다.

    지금 HEAD 의 src/adapters/openai-chat.ts 305줄 reasoningTextFrom 은 생각 칸이 글자일 때만 읽는다. 1666줄은 내용이 글자이고 길이가 있을 때만 텍스트로 보낸다. 오픈라우터가 생각을 객체로 주거나 내용을 조각 배열로 주면, 둘 다 조용히 버린다. 1614줄은 끝난 표식을 보면 done 을 낸다. 1853줄은 끝난 표식도 사용자 글도 없으면 에러를 낸다. 이 이슈는 에러도 없다고 하니, 어댑터는 done 을 낸 쪽으로 보는 편이 맞다. src/server/responses/empty-completion-guard.ts 72줄 isContentEvent 는 빈 글자를 내용으로 안 본다. 206줄은 내용 없이 done 이면 같은 요청을 한 번 더 보낸다. 269줄은 끝난 사건이 없이 소스만 끝나면 들고 있던 것을 풀어 주고 재시도하지 않는다. 작성자가 가리킨 세 갈래와 같다.

    가드는 설정이 켜져 있어야 한다. src/config.ts 1264줄과 3390줄 기본값은 꺼짐이다. 작성자 재현은 켜 두었다고 적었다. 켜져 있는데도 보냄 횟수가 1 이면, 가드가 내용을 보았거나, 끝난 사건이 없어서 재시도를 건너뛴 것이다. src/server/responses/core.ts 4285줄은 콤보와 압축이 아니면 가드를 씌운다. 관련 1655 가 이 가드를 넣었다. 1995 는 안 끝난 턴을 완료로 표시하지 말라는 것이고, 2180 은 하트비트, 2210 은 다른 제공자의 조용한 스트림이다. 2210 은 아직 연다. 이 이슈만으로 2210 을 닫지 않는다. 고치는 PR 은 아직 없다. 사용자 길이로는 앱이 아무 말 없이 끝나는 구멍이라서 58. 카탈로그 팁은 Ox Alpha x-preview-f-free + deepseek-v4-flash-vision-exp. Cursor 정적 카탈로그는 opus-4-8-fast / opus-5-fast. 2334 CursorCredentialRouter 는 여전히 src/providers/cursor-pool.ts 모듈+테스트만 있고 어댑터에 연결되지 않았다. 2332 H2 는 discovery 전용. 2320 overflow + 2342 는 이미 dev. 2188 사이드카는 이미 dev. 2382 데스크톱 앱 재시작은 이미 dev. 2292 는 아직 연다.

    src/adapters/openai-chat.ts 라인 305 - reasoningTextFrom 은 생각 칸이 글자일 때만 읽는다. 객체면 버린다
    src/adapters/openai-chat.ts 라인 1666 - 내용은 글자이고 길이가 있을 때만 텍스트로 보낸다. 조각 배열은 버린다
    src/adapters/openai-chat.ts 라인 1614 - 끝난 표식을 보면 done 을 낸다. 에러 없이 끝나면 가드가 재시도해야 한다
    src/server/responses/empty-completion-guard.ts 라인 269 - 끝난 사건이 없이 소스만 끝나면 들고 있던 것을 풀고 재시도하지 않는다
    src/config.ts 라인 3390 - 빈 완료 재시도 기본값은 꺼짐이다. 작성자는 켜 두었다고 적었다
    src/providers/registry.ts 라인 1457 - 오픈라우터는 openai-chat 이고 stealth/ox-alpha 가 목록에 있다
    이슈 2210 - 다른 제공자의 조용한 스트림. 이 이슈만으로 닫지 않는다

    메인테이너의 판단이 필요한 지점

    • 오픈라우터 옥스 알파 스트림이 생각 객체인지, 내용 조각 배열인지, 끝난 표식 없는 EOF 인지 먼저 찍을지
    • 가드 기본값을 켤지. 재시도는 과금될 수 있어서 지금 HEAD 는 꺼 둔다
    • 2210 과 한 이슈로 묶을지. 제공자가 다르니 열어 두는 편이 맞다

    너의 추천
    이슈는 열어 둔다. 고치는 PR 이 오면 그 PR 로 닫는다. 2210 과 2279 는 닫지 않는다. 호출 길을 넓히지 말 것. 먼저 오픈라우터 한 프레임이 글자인지 객체인지 잠근다. 라벨은 그대로 둔다. 프리뷰 배포가 아니다.

    이 댓글은 grok-bot이 작성했습니다

  3. optimikelabs commented on Aug 23, 2026

    @optimikelabs
    Author

    I captured the upstream frame shapes for one minimal proxy request and one actual Codex App turn. No payload text, credentials, headers, or reasoning content were logged.

    Control request through the same OpenCodex proxy

    • request body: 182 bytes
    • messages: 1
    • tools: 0
    • result: visible assistant text, response.completed, usage returned

    Failing Codex App turn

    • request body: 262,163 bytes
    • messages: 14
    • tools: 10
    • Codex turn duration: 12.3 s
    • Codex result: completed turn, latestAssistantMessage: null, no tool marker, no error

    The OpenRouter stream was not an array-content or terminal-less EOF case. The instrumented openai-chat adapter observed:

    many deltas:
      keys: [content, reasoning, reasoning_details, role]
      contentType: string
      contentLength: 0
      reasoningType: string
      reasoningDetailsType: array
    
    then visible content:
      contentType: string, contentLength: 2
      contentType: string, contentLength: 17
    
    then terminal frames:
      finishReason: stop
      reasoningType: object (on one empty-content terminal delta)
      final frame has usage: true
      finishReason: stop
    

    The two visible content fragments reconstruct the exact expected 19-character reply. Therefore OpenRouter returns visible text, the openai-chat adapter sees string content and a normal stop, and usage is present. Yet Codex App records no assistant item.

    This also explains sendCount: 1 and recoveryKinds: []: the empty-completion guard correctly sees text_delta content, so it does not retry. The loss appears to happen after adapter decoding / empty-completion classification, while converting or emitting the Responses stream consumed by Codex App.

    The request-size/tool difference may be relevant: the small no-tool request succeeds through the same proxy, while the full Codex turn fails despite valid upstream output.

    Temporary instrumentation was removed and OpenCodex was restarted after the capture.

  4. Ingwannu commented on Aug 23, 2026

    @Ingwannu
    Owner

    Thanks — this capture materially changes the diagnosis. It rules out the two first hypotheses: the OpenAI Chat adapter did decode two non-empty text_delta fragments, and the empty-completion guard correctly committed the first attempt after seeing them. I will not change the retry guard or broaden retries based on this evidence.

    The remaining boundary is after adapter decoding, but there are two distinct runtime paths there. A full Codex tool catalog normally contains hosted web_search; when a sidecar credential is available, current dev routes that request through runWithWebSearch, while the 182-byte no-tool control bypasses that loop. Both eventually use bridgeToResponsesSSE, so we need to distinguish the loop from the bridge/client contract before changing code.

    Please add one more redacted capture with metadata only:

    • effective webSearchSidecar.enabled, webSearchSidecar.backend, and webSearchSidecar.streamRoutedModelOutput values (no credentials);
    • whether the request log/runtime printed any [web-search-loop] line for the failing turn;
    • downstream OCX Responses event names, in order, plus for response.output_item.done only the item type, optional phase, and output-text length;
    • for response.completed, only response.id length, output.length, each output item type/optional phase, and message text lengths.

    Do not include text, reasoning content, headers, tokens, or tool arguments. With those fields we can write the correct regression: either runWithWebSearch is dropping/replaying the wrong boundary for a tool-bearing no-search turn, or the bridge emits a message that Codex App does not accept. Until that boundary is known, changing the empty-completion guard would add paid duplicate calls without fixing the loss.

  5. added
    needs-infoWaiting on reporter for a concrete spec or reproduction
    on Aug 23, 2026
  6. jkir4n commented on Aug 23, 2026

    @jkir4n

    Reproduced same root cause via https://opencode.ai/zen/go/v1 ox-alpha-free (Hermes Agent 0.20.5) — not just OpenRouter, and not specific to mcp__sequential_thinking.

    Proof (2026-08-23 19:12 IST, direct curl bypassing Hermes):

    • 0 tools → streams reasoning_content correctly on all UAs
    • 1 dummy tool {"name":"t","properties":{"x":{"type":"string"}}} → finish_reason: network_error empty on all UAs: HermesAgent/0.20.5 / OpenAI/Python 1.99.0 / curl default → not a Hermes User-Agent block
    • Same 1 tool on muse-spark-1.2-contributor → streams correctly, so schema valid
    • only sequential_thinking, 5, 20, 53 tools → same network_error
    • Non-stream with 1 tool → HTTP 503: Error from provider (Console Go): Upstream request failed: Endpoint is unavailable.

    Hermes logs: was working 02:55-06:11 IST with 32+53 tools (390 turns), then 11:52:43 first Empty response (no content or reasoning) — retry 1/3 → 3/3 → exhausted, 100% fail since.

    Filed upstream with full curl + UA tests: anomalyco/opencode#44382
    This is the anomalyco/opencode#36889 Go outage family — the tools: adapter for stealth/ox-alpha broke at ~11:52 IST. Without tools it still streams.

  7. lidge-jun commented on Aug 26, 2026

    @lidge-jun
    Owner

    Closing as already implemented on dev.

    A terminal-less pre-output EOF now raises a retryable empty-completion error at empty-completion-guard.ts:309, pinned by tests/empty-completion-guard.test.ts:326. That is precisely the HTTP 200 with no completion and no retry described here: the stream ends without a terminal event, and the guard converts it into a retry instead of surfacing an empty turn.

    Verified against dev at 37909c670. Reopen with a request id if an Ox Alpha turn still ends empty without retrying — the remaining possibility is a terminal event arriving that the guard treats as legitimate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-infoWaiting on reporter for a concrete spec or reproductionproviderProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reportstoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions