Skip to content

feat(responses): add an opt-in bounded JSON fallback for custom providers - #1367

Draft
novelKR wants to merge 1 commit into
lidge-jun:devfrom
novelKR:agent/responses-bounded-json-fallback
Draft

feat(responses): add an opt-in bounded JSON fallback for custom providers#1367
novelKR wants to merge 1 commit into
lidge-jun:devfrom
novelKR:agent/responses-bounded-json-fallback

Conversation

@novelKR

@novelKR novelKR commented Aug 9, 2026

Copy link
Copy Markdown

Summary

This PR adds an opt-in per-model fallback for custom openai-responses providers that return a correct non-streaming Responses object but do not reliably deliver a streamed response to Native Codex through OpenCodex.

The new setting exposes the existing registry-only modelResponsesUpstreamStreaming policy to validated custom-provider configuration. Setting a model to false keeps the Responses API on both sides of the proxy, but asks the upstream for stream:false. OpenCodex then reads the completed JSON through its existing bounds and reframes it into the canonical Responses event sequence expected by a streaming Codex client.

The bounded fallback policy is opt-in and prioritizes correctness. It does not route traffic through Chat Completions, hard-code OpenCode Go or GPT-5.6 Luna, or claim to solve the underlying Bun/tee() transport defect. The submitted draft also tightens validation in the shared WebSocket JSON bridge beyond the opt-in path; the review addendum below records that broader behavior as an unresolved scope item rather than presenting it as an intentional default change.

What we observed

The same client-delivery split was reproduced in the two POSIX-family environments we tested:

Environment Architecture Result through OpenCodex
macOS arm64 OpenCodex sees response.completed, but Native Codex does not complete the turn
GNU/Linux over SSH x86_64 The same mismatch occurs, followed by Native Codex's stream-idle retry

We reproduced the same failure on macOS arm64 and Linux x86_64. That does not mean every POSIX implementation is affected. Linux arm64, native Windows, WSL, and other POSIX systems were not part of the live reproductions and remain unverified.

Discovery context and relay-independent reproduction

The symptom was first noticed in a personal multi-layer deployment that included an independently implemented OCI relay. That environment prompted the structural investigation, but the relay was not treated as the cause or retained as a reproduction prerequisite.

We then reproduced the same behavior in a separate minimal environment whose relevant AI request stack had only Native Codex and OpenCodex installed and configured—no personal OCI relay, sidecar, or additional gateway/proxy was present. With Native Codex connected directly to OpenCodex, OpenCodex reached its internal terminal state while Native Codex did not commit the turn and entered the idle-timeout path. In the opposite control, Native Codex connected directly to the same OpenCode Go Responses upstream—without OpenCodex or any intermediate relay—and completed four consecutive turns. The personal relay is therefore neither required for reproduction nor the failing component identified by this comparison.

This A/B evidence places the observed failure boundary inside OpenCodex's Responses relay/client-delivery path, after upstream completion and before the Codex client commits the terminal event. It does not by itself prove that one specific Bun primitive is the sole low-level cause.

Current built-in routing distinction

The current OpenCode Go endpoint matrix explicitly assigns GPT 5.6 Luna (gpt-5.6-luna) to https://opencode.ai/zen/go/v1/responses using @ai-sdk/openai. The same matrix assigns models such as DeepSeek V4 Flash to /v1/chat/completions using @ai-sdk/openai-compatible. OpenCode Go therefore exposes a model-specific protocol distinction rather than one provider-wide Chat Completions contract.

At this PR's exact dev base, however, OpenCodex's built-in opencode-go registry entry declares the provider-wide adapter as openai-chat and has no modelWireDefaults entry for gpt-5.6-luna. Without an explicit modelAdapters override, the wire resolver therefore keeps the built-in route on openai-chat, whose request builder posts to ${baseUrl}/chat/completions instead of Luna's documented Responses endpoint.

The motivating reproduction worked around that separate built-in mapping gap by registering a custom provider with adapter: "openai-responses", the OpenCode Go base URL, and gpt-5.6-luna. Sanitized direct probes observed completed Responses in both non-streaming JSON (stream:false) and SSE (stream:true) modes; the public Go documentation identifies the endpoint but does not separately guarantee both delivery modes, so those results are reported as reproduction evidence rather than as a documented service guarantee.

These are two distinct gaps. The built-in preset does not currently select Responses for Luna, while the failure reproduced here occurs after the custom provider has correctly selected the Responses endpoint: OpenCodex reaches response.completed, but Native Codex does not receive a terminal event it can commit. This PR addresses only the latter with an opt-in bounded fallback; it does not change the built-in opencode-go registry mapping.

The controls narrow the failure boundary:

  • direct stream:false and stream:true calls to the affected Responses upstream complete;
  • one Native Codex conversation completes four consecutive turns against that upstream without OpenCodex;
  • minimal calls through the same OpenCodex route complete;
  • the reviewed official OpenAI and custom DeepSeek Responses paths complete through OpenCodex;
  • the affected richer OpenCode Go turn reaches response.completed in OpenCodex inspection/state, while Native Codex receives no completion event it can commit and later retries.

This narrows the fault to the relay path, but it does not prove a specific Bun bug. Source inspection points most strongly to the interaction among ReadableStream.tee(), independently paced inspection and client consumers, the JavaScript relay, SSE chunk boundaries, and backpressure.

flowchart LR
    C[Native Codex] -->|stream=true| O[OpenCodex]
    O --> U[Custom Responses upstream]
    U -->|valid SSE| T[ReadableStream.tee]
    T --> I[Inspection branch]
    I -->|response.completed| L[Internal outcome: completed]
    T --> R[Client relay branch]
    R -. no semantic completion .-> C
    C -->|idle timeout| X[retry or failed turn]
    U -. direct control: 4/4 turns complete .-> C
Loading

Why use bounded JSON instead of enabling Linux eager relay

OpenCodex currently keeps its bounded single-reader eager relay behind a conservative runtime and platform gate. The bundled Bun is still 1.3.14, MIN_FIXED_BUN_VERSION is still null, and OpenCodex has not yet verified a stable Bun release for the relevant async-stream cancellation/backpressure path.

Simply allowing Linux to enter eager-relay would bypass that safety decision. It would also overlap the runtime-qualified, protocol-safe one-reader work already planned in issue #820.

The narrower option proposed here reuses behavior OpenCodex already has:

Native Codex
  POST /v1/responses, stream:true
        │
        ▼
OpenCodex
  modelResponsesUpstreamStreaming[model] = false
        │
        ▼
Responses upstream
  POST /v1/responses, stream:false
        │
        ▼
completed Responses JSON
        │
        ▼
OpenCodex bounded validation + canonical SSE reframe
        │
        ▼
Native Codex

The client and upstream both continue to use the Responses API. Only the upstream delivery mode changes.

Configuration and precedence

{
  "providers": {
    "<custom-responses-provider>": {
      "adapter": "openai-responses",
      "baseUrl": "<redacted-https-origin>",
      "authMode": "key",
      "modelResponsesUpstreamStreaming": {
        "gpt-5.6-luna": false
      }
    }
  }
}

The example intentionally contains no credential.

Policy lookup is case-insensitive but exact; it does not inherit colon-family entries. It runs after provider namespace/combo resolution and after the effective per-model wire is known, but before a client-facing response-model rewrite. Virtual aliases are resolved at both their public and wire-model identities.

The proposed precedence is:

  1. an explicit configured value for the selected public model id, then its resolved wire-model id;
  2. a matching built-in registry default for those same ids when the configured transport still matches that registry entry;
  3. no override, preserving the existing client-requested behavior.

The field should be rejected when the effective wire is not openai-responses and on effective forward-auth providers, so this option cannot silently alter the canonical OpenAI transport contract.

Response handling

For an opted-in model and a client request with stream:true, OpenCodex should:

  1. preserve the existing request semantics and change only the upstream stream value to false;
  2. read the JSON body through the existing total-size, total-time, and inactivity limits;
  3. validate the completed/failed/incomplete Responses object through one shared validator used by both HTTP synthesis and Responses WebSocket reframing;
  4. for HTTP/SSE, emit response.created, one response.output_item.done per output item, the original completed/failed/incomplete terminal, and one [DONE]; for WebSocket, emit the equivalent JSON lifecycle events without an SSE sentinel;
  5. close without waiting for an upstream SSE EOF because this path received bounded JSON, not a live stream.

The shared validator should require:

  • a 2xx JSON Responses object with a non-empty response id;
  • a top-level object rather than null or an array;
  • object either omitted under the documented compatibility policy or equal to response;
  • terminal status equal to completed, failed, or incomplete;
  • an output array whose entries are objects with non-empty string type fields;
  • usage either absent, null, or an object with non-negative integer input/output token counts; optional total/detail token fields are checked when present.

The fallback itself should not invent output items or usage. Existing explicitly configured client-facing normalizations—image-call restoration, response-model rewrite, snapshot repair, and item-id repair—should still run exactly once, in the same order as the streaming path. Function-call, repair-enabled, and parallel-call tests must verify that item ids, call ids, names, and argument strings remain usable on the next turn.

Malformed JSON, an unknown terminal status, an invalid usage object, an oversized or stalled body, or an unexpected 2xx content type must fail closed. If an upstream ignores stream:false and returns SSE, OpenCodex must not fall back to the suspect tee path and must not replay the model request. Non-2xx behavior and safe retry metadata should keep their existing semantics. Client cancellation must abort the in-flight upstream read.

Implementation

  • src/types.ts
    • add the documented optional provider field.
  • src/config.ts
    • validate non-empty model keys and boolean values;
    • enforce the effective-wire and forward-auth restrictions.
  • src/server/auth-cors.ts
    • mirror the same validation at the management write boundary so startup loading and persisted writes cannot disagree.
  • src/providers/registry.ts and the Responses route setup
    • resolve explicit config before the registry fallback;
    • use the case-insensitive model-map helper;
    • resolve the effective per-model wire before applying the policy.
  • the shared Responses JSON event boundary
    • validate terminal JSON through one shared validator for HTTP and WebSocket reframing;
    • do not let an unknown or missing status fall through to a default completed event.
  • src/server/responses/core.ts and the Responses WebSocket bridge
    • reuse the current bounded JSON and event-reframing machinery;
    • fail once on unexpected SSE rather than retrying or entering tee.
  • focused tests and provider-configuration documentation
    • cover exact-model precedence, aliases, HTTP/WebSocket parity, terminal status preservation, tool ids, limits, cancellation, and rollback.

No visual-interface change is required for this PR.

Compatibility, tradeoffs, and rollback

The bounded fallback policy is intended to be additive and inactive unless explicitly configured. At the submitted head, however, the shared WebSocket JSON validator also reaches successful snapshots from unconfigured routes. The review addendum records this as an unresolved scope mismatch; the claim that unconfigured behavior remains unchanged is contingent on resolving it before review readiness.

The benefit is a way to avoid the problematic streaming relay without adding a provider-specific branch or weakening the Responses contract and Bun runtime gate. The tradeoff is straightforward: the client receives no incremental text, reasoning, or tool deltas; its first event arrives only after the upstream completes; and the response is retained within the existing bounded JSON envelope. The upstream must genuinely support non-streaming Responses.

Setting the model entry to true disables this forced bounded-JSON fallback after the normal configuration reload or service restart. That restores the client-requested/default streaming policy but does not guarantee that the upstream will actually stream. Removing the entry restores the inherited registry/default policy, which may itself be false. No data migration is required.

This PR should not close #820. The long-term fix remains a runtime-qualified one-reader relay that preserves true streaming across supported platforms.

Related work

  • #820 — broader runtime-qualified, protocol-safe one-reader architecture; this proposal is intentionally narrower.

  • #1127 — similar macOS symptom: upstream/internal completion with zero client SSE events.

  • #1142 — merged explicit Darwin eager relay for client-rewrite traffic; it deliberately left Darwin auto and Linux unchanged.

  • #947 — closed, unmerged predecessor whose transport predicate was attributed in fix(streaming): relay Darwin rewrites eagerly (#1127) #1142.

  • #1133 — bounded translated SSE inspection while preserving downstream bytes.

  • #1241 — bounded client-facing SSE frame retention without removing the tee/client-pull boundary.

  • #1217 — complementary content-free transport observability.

  • #1176 — separate bounded-JSON timeout tradeoff that belongs in regression and operational risk coverage.

  • #1026 — the bounded JSON and canonical event-reframing foundation reused here.

  • #1155 — an open, model-specific proposal touching registry streaming policy for web-search handling; it does not expose a validated custom-provider policy.

No currently open issue or PR found in the repository search implements this custom-provider setting.

Scope

In scope:

  • custom openai-responses providers;
  • explicit per-model opt-in;
  • HTTP/SSE and existing Responses WebSocket reframing;
  • strict bounded JSON validation and canonical snapshot events;
  • synthetic, credential-free tests;
  • live validation on macOS arm64 and Linux x86_64 before requesting review.

Out of scope:

  • Completion API routing;
  • changing the built-in opencode-go wire mapping for gpt-5.6-luna;
  • provider- or model-name heuristics;
  • changing the default streamMode;
  • enabling Linux eager relay on Bun 1.3.14;
  • globally removing tee() or upgrading bundled Bun;
  • logging live request or response content;
  • native Windows, WSL, Linux arm64, or untested POSIX compatibility claims;
  • upstream-provider changes or a post-OpenCodex sidecar.

If implementation adds diagnostics, they must remain content-free: status, content-type category, byte counts, relative timing, selected mode, terminal type, cancellation, and bounded-read result only. Do not record credentials, provider origins, query strings, prompts, output text, raw SSE/JSON, account ids, or unredacted request/thread/response ids.

Review addendum — confirmed blockers and follow-up risks

A second static review of the submitted head identified two concrete code issues and one outstanding acceptance gate. This appendix records the current draft state; it does not claim that the findings are already fixed, and it is not a maintainer approval or a formal GitHub review decision.

Must be resolved before review readiness

  1. Shared WebSocket validation exceeds the opt-in scope. The current WebSocket bridge strictly validates every successful Responses JSON snapshot, without knowing whether modelResponsesUpstreamStreaming=false selected the bounded fallback. Before this change, a sparse unconfigured JSON snapshot could be reframed with compatibility defaults; the submitted head now returns a 502 protocol error. The safest resolution is to carry an internal bounded-fallback discriminator into the WebSocket bridge and apply the new strict contract only there. If global fail-closed validation is intentionally retained, it must instead be documented and tested as a broader behavioral change, preferably in a separate PR.
  2. Policy keys are not canonicalized. A key with surrounding whitespace passes configuration validation but is not found by runtime lookup. Keys that differ only by case may also hold conflicting values, making the result depend on exact request casing or insertion order. The policy-specific validator must reject surrounding whitespace and case-insensitive duplicates at both startup and management-write boundaries, with a runtime lookup regression test for a canonical key.
  3. The new fallback still lacks live Native Codex acceptance. Synthetic tests cover event order, terminal status, function calls, repairs, cancellation, and no replay, but they do not prove that Native Codex commits a real turn and continues the conversation through this fallback. Before review readiness, an isolated canary must complete an accumulating multi-turn workflow on macOS arm64 and Linux x86_64 with one upstream request per turn, zero client retries, exactly one terminal outcome, preserved tool-call identifiers, and content-free cleanup evidence. The personal relay was not needed to reproduce the failure and is outside this upstream acceptance criterion; deployment-specific relays or sidecars should be validated separately by their operators.

Non-blocking follow-up risks

  • Bounded-body timeout visibility. The reused limits allow 180 seconds for the first byte and total body, then 30 seconds between non-empty chunks. Long generation before the first body byte is therefore not itself a 30-second failure; the risk is an upstream that starts a JSON body and then pauses between chunks. Timeout phase, chunk count, received bytes, and relative timing should be exposed through content-free telemetry instead of collapsing every timeout into the same 502 message.
  • Repeated full validation. Raw and client-repaired snapshots should each be validated once, but the current HTTP/SSE path scans output roughly three times and the WebSocket path can scan it roughly five times. The body and item caps keep this bounded, so it is not a correctness blocker, but serializers should eventually consume a validated result or an internal unchecked iterator instead of rescanning the same repaired snapshot.
  • WebSocket backpressure. JSON reframing materializes the lifecycle event array and sends it synchronously. A queued/backpressured Bun send is accepted without pausing production, so a slow client can retain individual item frames alongside the final full snapshot. Existing Bun/body/item limits keep this from being an unbounded claim, but a drain-aware sender and slow-consumer stress tests remain appropriate follow-up work.

Disposition

The architectural direction remains unchanged: keep the workaround provider-agnostic, keep Responses on both sides, and avoid replaying an ambiguous model request. This draft should remain unready until the two code blockers are fixed, their focused tests are added, and the live fallback canary and repository review gates are complete. The timeout, duplicate-validation, and WebSocket backpressure items may be tracked separately unless new acceptance evidence raises their severity.

Sanitized retained runtime evidence

The following excerpts use selected fields from actual retained canary/tool output, OpenCodex request history, and Native Codex rollout records. They are not reconstructed model output. Secrets, prompts, responses, tool arguments, opaque identifiers, exact timestamps, host details, local paths, and private endpoints were removed. Relative t+ values are derived only from retained timestamps; protocol outcomes and durations remain the recorded values. No new model call was made to prepare this appendix.

Direct control — Native Codex to OpenCode Go Responses

Selected fields from the retained four-turn canary output:

source=retained_direct_canary_output
route=direct_https_responses
opencodex_endpoint_used=false
existing_wrapper_used=false
existing_appserver_used=false
config_wire_responses=true
config_websockets_false=true
config_retries_zero=true
config_reasoning_unset=true
turn=1 elapsed_ms=4588 turn_completed=1 agent_messages=1 usage_present=1 retry_markers=0 error_events=0 tool_items=0 thread_consistent=true sentinel_exact=true success=true
turn=2 elapsed_ms=5499 turn_completed=1 agent_messages=1 usage_present=1 retry_markers=0 error_events=0 tool_items=0 thread_consistent=true sentinel_exact=true success=true
turn=3 elapsed_ms=3825 turn_completed=1 agent_messages=1 usage_present=1 retry_markers=0 error_events=0 tool_items=0 thread_consistent=true sentinel_exact=true success=true
turn=4 elapsed_ms=3789 turn_completed=1 agent_messages=1 usage_present=1 retry_markers=0 error_events=0 tool_items=0 thread_consistent=true sentinel_exact=true success=true
persistent_thread_turns_completed=4 overall_success=true cleanup=true ssh_exit=0

This control called the same OpenCode Go Responses upstream directly from one persistent Native Codex thread, without an OpenCodex endpoint, wrapper, or existing AppServer. It establishes that the direct client/upstream path can complete consecutive turns. It did not exercise this PR's new fallback or a tool-call round trip.

Failure reproduction — OpenCodex server-side history

The following normalized rows are from one retained Linux/x86_64 OpenCodex conversation. The private correlation identifier was removed; t+ is relative to the first request.

source=retained_opencodex_request_history
t+0.000s    status=200 duration_ms=4690 first_output_ms=2998 terminal_status=completed close_reason=terminal transport_phase=terminal_sse terminal_source=upstream
t+302.694s  status=200 duration_ms=2246 first_output_ms=2192 terminal_status=completed close_reason=terminal transport_phase=terminal_sse terminal_source=upstream
t+604.637s  status=200 duration_ms=2634 first_output_ms=2628 terminal_status=completed close_reason=terminal transport_phase=terminal_sse terminal_source=upstream
t+907.266s  status=200 duration_ms=2420 first_output_ms=2416 terminal_status=completed close_reason=terminal transport_phase=terminal_sse terminal_source=upstream
t+1210.199s status=200 duration_ms=2853 first_output_ms=2770 terminal_status=completed close_reason=terminal transport_phase=terminal_sse terminal_source=upstream
t+1514.592s status=200 duration_ms=2155 first_output_ms=2152 terminal_status=completed close_reason=terminal transport_phase=terminal_sse terminal_source=upstream

OpenCodex recorded upstream terminal completion in 2.155–4.690 seconds on every attempt, while the same conversation was requested again at intervals of 302.694, 301.943, 302.629, 302.933, and 304.393 seconds. That cadence is consistent with the Native Codex 300-second stream-idle boundary, but the raw client retry diagnostic line was not retained and is not claimed here.

Failure reproduction — Native Codex client rollout

A separate macOS/arm64 reproduction used a minimal request stack containing only Native Codex and OpenCodex. The retained client rollout normalizes to:

source=retained_native_codex_rollout
event_sequence=task_started -> token_count -> token_count -> token_count -> token_count -> turn_aborted
assistant_messages=0 task_complete=0 tool_calls=0
turn_aborted=1 turn_aborted_duration_ms=1026650 turn_aborted_reason=interrupted
personal_relay_present=false sidecar_present=false additional_gateway_present=false

The Linux server-side rows and macOS client rollout are separate reproductions and are not presented as one cross-log correlation. The failed request's raw response headers and body were not retained. Therefore, status=200 and transport_phase=terminal_sse above are OpenCodex request-history fields, not an independently captured Content-Type header or a complete client-facing SSE body. Together with the relay-free topology, these records support an OpenCodex client-delivery boundary failure without making the personal relay part of the reproduction or acceptance contract.

Verification

Implementation and repository-level verification are complete on this draft branch. The production service was not changed, and the new fallback has not yet been exercised against a live provider.

Diagnostic basis:

  • Direct stream:false, direct stream:true, and four consecutive Native Codex turns complete without OpenCodex.
  • The failure remains reproducible in a separate minimal environment whose relevant AI request stack contains only Native Codex and OpenCodex, with no personal OCI relay, sidecar, or additional gateway/proxy.
  • On macOS arm64 and Linux x86_64, OpenCodex sees the terminal event while Native Codex does not complete the affected turn.
  • Minimal requests through the same proxy route complete, while the richer request reproduces the failure.
  • Official OpenAI and custom DeepSeek Responses paths provide successful controls in the reviewed evidence.
  • OpenCodex 2.10.1 through 2.11.1, current dev, and related upstream work were reviewed; no released general fix for the default POSIX tee path was found.

Implementation verification:

  • Config-file and management-write validation agree; model/wire precedence and unconfigured behavior are covered.
  • The upstream request changes only stream, and completed/failed/incomplete JSON produces the correct HTTP and WebSocket events.
  • Function-call, parallel-call, virtual-model, snapshot-repair, item-id, size-limit, and cancellation paths are covered with synthetic fixtures.
  • Absent or null usage is accepted; malformed JSON, invalid usage, unknown status, oversize/stall, unexpected content types, and unexpected SSE fail without replay.
  • A never-settling body cancellation cannot delay the fail-closed 502 response.
  • Focused tests, type checking, documentation build, privacy scan, and the repository pre-push gate pass on the submitted head.

Required live validation before requesting review:

  • Use an isolated canary without changing the active 2.10.1 service.
  • Complete at least four turns in one accumulating conversation on macOS arm64 and repeat the same workflow on Linux x86_64 over SSH.
  • Keep retries at zero, confirm no duplicate billable request, and record exactly one client terminal and matching turn outcome.
  • Retain only content-free evidence: mode, status, event counts/types, byte counts, elapsed time, terminal outcome, and cleanup state.
  • Keep Linux arm64, native Windows, WSL, and other POSIX systems marked unverified unless separately exercised.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.
  • Fixtures and evidence contain no live secret, private endpoint, payload, prompt/output, account id, or raw identifier; the public OpenCode Go endpoint above is cited only as protocol documentation.
  • A maintainer must review the management-validation change and apply maintainer-sponsored; external contributors cannot satisfy this repository gate themselves.
  • Live fallback validation on macOS arm64 and Linux x86_64 remains a follow-up before this draft is marked ready for review.

한국어 번역 — 접근성 제공

아래 내용은 위 영문 PR 본문의 접근성용 한국어 번역입니다. 제출 기준이 되는 원문은 영문이며, 구현 범위·검증 상태·제약 조건은 두 판본에서 동일합니다.

요약

이 PR은 사용자 정의 openai-responses provider가 정상적인 non-streaming Responses 객체는 반환하지만, streaming 응답을 OpenCodex를 거쳐 Native Codex까지 안정적으로 전달하지 못하는 경우에 사용할 모델별 호환성 설정을 추가합니다.

기존에 내장 provider registry에서만 사용하던 modelResponsesUpstreamStreaming 정책을 검증된 사용자 정의 provider 설정으로 노출했습니다. 특정 모델을 false로 설정하면 client와 upstream 모두 Responses API를 계속 사용하지만, OpenCodex는 upstream에 stream:false를 요청합니다. 이후 기존 제한 안에서 완료된 JSON을 읽고 streaming Codex client가 기대하는 canonical Responses event sequence로 다시 구성합니다.

Bounded fallback 정책은 명시적으로 선택해야 동작하며 정확성을 우선합니다. Chat Completions로 우회하거나 OpenCode Go 또는 GPT-5.6 Luna를 하드코딩하지 않고, 근본적인 Bun/tee() transport 결함까지 해결했다고 주장하지도 않습니다. 다만 제출된 Draft는 공용 WebSocket JSON bridge의 validation도 opt-in 범위 밖까지 강화합니다. 아래 검토 별첨에서는 이를 의도된 기본 동작 변경으로 포장하지 않고 아직 해결되지 않은 범위 문제로 기록합니다.

관측 결과

검증한 두 POSIX 계열 환경에서 OpenCodex의 내부 완료 상태와 Native Codex가 실제로 받은 결과가 일치하지 않는 현상이 재현되었습니다.

환경 아키텍처 OpenCodex 경유 결과
macOS arm64 OpenCodex는 response.completed를 확인하지만 Native Codex는 turn을 완료하지 못함
SSH 기반 GNU/Linux x86_64 같은 불일치가 나타난 뒤 Native Codex가 stream-idle 재시도에 진입함

같은 현상을 macOS arm64와 Linux x86_64에서 확인했지만, 모든 POSIX 구현이 영향을 받는다는 뜻은 아닙니다. Linux arm64, Native Windows, WSL 및 다른 POSIX 시스템은 실제 장애 재현에 포함되지 않았으며 계속 미검증 상태로 둡니다.

최초 발견 배경과 relay 독립 재현

증상은 개인적으로 사용하기 위해 독립 구현한 OCI relay가 포함된 다층 배포 환경에서 처음 발견되었습니다. 이 환경은 구조적 검토를 시작한 계기였지만, 해당 relay를 원인으로 전제하거나 재현의 필수 요소로 유지하지 않았습니다.

이후 관련 AI request stack에 Native Codex와 OpenCodex만 설치·구성된 별도의 최소 환경에서도 같은 동작을 재현했습니다. 이 환경에는 개인 OCI relay, sidecar 또는 추가 gateway/proxy가 존재하지 않았습니다. Native Codex를 OpenCodex에 직접 연결했을 때 OpenCodex는 내부 terminal state에 도달했지만 Native Codex는 turn을 commit하지 못하고 idle-timeout 경로에 진입했습니다. 반대 대조군에서는 OpenCodex와 모든 중간 relay를 제외하고 Native Codex를 같은 OpenCode Go Responses upstream에 직접 연결했으며, 하나의 대화에서 4회 연속 turn이 완료되었습니다. 따라서 이 비교에서 개인 relay는 재현에 필요하지 않았고 확인된 장애 구성 요소도 아닙니다.

이 A/B 증거는 관측된 실패 경계를 upstream 완료 이후부터 Codex client가 terminal event를 commit하기 전까지의 OpenCodex Responses relay/client-delivery 경로 내부로 좁힙니다. 다만 특정 Bun primitive 하나가 유일한 저수준 원인이라고 확정하는 증거는 아닙니다.

현행 내장 경로와 재현 경로의 차이

현행 OpenCode Go endpoint 표는 GPT 5.6 Luna(gpt-5.6-luna)를 @ai-sdk/openai 기반의 https://opencode.ai/zen/go/v1/responses에 명시적으로 배정합니다. 같은 표에서 DeepSeek V4 Flash 등의 모델은 @ai-sdk/openai-compatible 기반 /v1/chat/completions로 구분합니다. 따라서 OpenCode Go는 provider 전체를 Chat Completions 하나로 취급하는 것이 아니라 모델별 protocol 차이를 공개하고 있습니다.

그러나 이 PR의 정확한 dev base에서 OpenCodex의 내장 opencode-go registry entry는 provider-wide adapter를 openai-chat으로 선언하고, gpt-5.6-lunamodelWireDefaults entry를 두지 않습니다. 명시적인 modelAdapters override가 없으면 wire resolver는 내장 경로를 openai-chat으로 유지하고, request builder는 Luna에 문서화된 Responses endpoint가 아니라 ${baseUrl}/chat/completions로 전송합니다.

최초 재현에서는 이 별도의 내장 mapping 누락을 우회하기 위해 OpenCode Go base URL, gpt-5.6-lunaadapter: "openai-responses"를 사용하는 Custom Provider를 등록했습니다. 민감정보를 제거한 direct probe에서는 non-streaming JSON(stream:false)과 SSE(stream:true) 모두 completed Response가 관측되었습니다. 공개 Go 문서는 endpoint를 명시하지만 두 delivery mode를 별도로 보장하지는 않으므로, 이 결과는 공식 service guarantee가 아니라 재현 근거로 기록합니다.

따라서 두 문제는 구분해야 합니다. 내장 preset이 현재 Luna에 Responses를 선택하지 않는 문제와, Custom Provider가 Responses endpoint를 올바르게 선택한 뒤 OpenCodex는 response.completed에 도달하지만 Native Codex에는 확정 가능한 terminal event가 전달되지 않는 문제입니다. 이 PR은 opt-in bounded fallback으로 후자만 다루며, 내장 opencode-go registry mapping은 변경하지 않습니다.

대조 결과는 실패 경계를 다음처럼 좁힙니다.

  • 영향받는 upstream에 직접 보낸 stream:falsestream:true Responses 요청은 완료됩니다.
  • OpenCodex 없이 동일 upstream을 사용한 하나의 Native Codex 대화에서 4회 연속 turn이 모두 완료됩니다.
  • 같은 OpenCodex route의 최소 요청은 완료됩니다.
  • 검토한 공식 OpenAI 및 custom DeepSeek Responses 경로는 OpenCodex를 통해 완료됩니다.
  • 문제가 발생한 rich OpenCode Go turn은 OpenCodex inspection/state에서 response.completed에 도달하지만, Native Codex에는 완료로 확정할 수 있는 event가 도달하지 않고 이후 재시도합니다.

이 증거만으로 특정 Bun bug 하나를 원인으로 확정할 수는 없습니다. 다만 장애 구간은 OpenCodex가 upstream stream을 받은 뒤 Native Codex가 종료 event를 받아 turn을 완료로 확정하기 전까지로 좁혀집니다. 소스 분석상 가장 유력한 가설은 ReadableStream.tee(), 서로 독립적인 inspection/client 소비 속도, JavaScript relay, SSE chunk 경계 및 backpressure의 상호작용입니다.

flowchart LR
    C[Native Codex] -->|stream=true| O[OpenCodex]
    O --> U[Custom Responses upstream]
    U -->|유효한 SSE| T[ReadableStream.tee]
    T --> I[Inspection branch]
    I -->|response.completed| L[내부 결과: completed]
    T --> R[Client relay branch]
    R -. 완료 event가 전달되지 않음 .-> C
    C -->|idle timeout| X[retry 또는 failed turn]
    U -. 직접 대조군: 4/4 turn 완료 .-> C
Loading

Linux eager relay 대신 bounded JSON을 사용하는 이유

OpenCodex는 bounded single-reader eager relay를 보수적인 runtime/platform gate 뒤에 두고 있습니다. Bundled Bun은 여전히 1.3.14이고 MIN_FIXED_BUN_VERSIONnull입니다. OpenCodex가 관련 async-stream cancellation/backpressure 수정의 포함 여부를 확인한 Bun 안정 버전도 아직 없습니다.

Linux를 단순히 eager-relay 대상으로 추가하면 이 안전 결정을 해결하는 것이 아니라 우회하게 됩니다. 또한 Issue #820에서 이미 계획한 runtime-qualified, protocol-safe one-reader 작업과 범위가 겹칩니다.

이번 제안은 OpenCodex에 이미 존재하는, 범위가 더 좁은 경로를 재사용합니다.

Native Codex
  POST /v1/responses, stream:true
        │
        ▼
OpenCodex
  modelResponsesUpstreamStreaming[model] = false
        │
        ▼
Responses upstream
  POST /v1/responses, stream:false
        │
        ▼
완료된 Responses JSON
        │
        ▼
OpenCodex bounded validation + canonical SSE 재구성
        │
        ▼
Native Codex

Client와 upstream 모두 Responses API를 유지하며 upstream의 응답 전달 방식만 달라집니다.

설정과 우선순위

{
  "providers": {
    "<custom-responses-provider>": {
      "adapter": "openai-responses",
      "baseUrl": "<redacted-https-origin>",
      "authMode": "key",
      "modelResponsesUpstreamStreaming": {
        "gpt-5.6-luna": false
      }
    }
  }
}

예시에는 credential을 의도적으로 포함하지 않았습니다.

정책 조회는 대소문자를 구분하지 않지만 정확한 모델 id만 일치시키며 colon-family entry를 상속하지 않습니다. Provider namespace/combo 해석과 effective 모델별 wire 결정 이후, client-facing response-model rewrite 이전에 적용합니다. Virtual alias는 public id와 wire-model id를 모두 해석합니다.

우선순위는 다음과 같습니다.

  1. 선택된 public model id와 그 다음 해석된 wire-model id의 명시적 설정값
  2. 설정 transport와 일치할 때 같은 두 id에 대한 built-in registry 기본값
  3. override가 없으면 현재 client-requested 동작 유지

Effective wire가 openai-responses가 아니거나 effective forward-auth provider이면 이 field를 거부하여 canonical OpenAI transport contract가 바뀌지 않게 합니다.

응답 처리

Opt-in model에서 client가 stream:true를 요청하면 OpenCodex는 다음처럼 처리합니다.

  1. 기존 request 의미를 보존하고 upstream stream 값만 false로 변경합니다.
  2. 기존 total-size, total-time, inactivity limit을 사용해 JSON body를 읽습니다.
  3. HTTP synthesis와 Responses WebSocket reframing에서 함께 쓰는 하나의 validator로 completed/failed/incomplete Responses 객체를 검증합니다.
  4. HTTP/SSE에서는 response.created, output item별 response.output_item.done, 원래의 completed/failed/incomplete terminal 및 하나의 [DONE]을 보냅니다. WebSocket에서는 SSE sentinel 없이 동등한 JSON lifecycle event를 보냅니다.
  5. 이 경로는 live stream이 아니라 bounded JSON을 받았으므로 upstream SSE EOF를 기다리지 않습니다.

Shared validator는 다음을 확인해야 합니다.

  • 2xx JSON Responses 객체와 비어 있지 않은 response id
  • null이나 array가 아닌 top-level object
  • object field가 문서화된 compatibility 정책에 따라 없거나 response와 같음
  • terminal statuscompleted, failed, incomplete 중 하나
  • output이 array이고 각 entry가 비어 있지 않은 string type을 가진 object
  • usage가 없거나 null이거나 non-negative integer input/output token count를 가진 object. 선택형 total/detail token field도 존재하면 형식을 검증함

Fallback 자체는 output item이나 usage를 새로 만들지 않습니다. 기존에 명시적으로 설정한 client-facing normalization(image-call restore, response-model rewrite, snapshot repair, item-id repair)은 streaming 경로와 같은 순서로 정확히 한 번만 적용합니다. Function call, repair-enabled, parallel call 테스트로 다음 turn에서도 item id, call id, name, argument string을 사용할 수 있는지 확인해야 합니다.

Malformed JSON, unknown terminal status, invalid usage object, oversize/stall body 또는 예상하지 않은 2xx content type은 fail closed합니다. Upstream이 stream:false를 무시하고 SSE를 반환하면 의심되는 tee 경로로 fallback하거나 model 요청을 재실행하지 않습니다. Non-2xx와 안전한 retry metadata는 기존 의미를 유지하고 client cancellation은 진행 중인 upstream read를 abort해야 합니다.

구현

  • src/types.ts
    • 문서화된 optional provider field 추가
  • src/config.ts
    • 비어 있지 않은 model key와 boolean value 검증
    • effective-wire 및 forward-auth 제한 적용
  • src/server/auth-cors.ts
    • management write boundary에도 같은 검증을 적용하여 startup loading과 persisted write의 불일치 방지
  • src/providers/registry.ts 및 Responses route setup
    • registry fallback보다 명시적 config를 우선
    • case-insensitive model-map helper 사용
    • policy 적용 전에 effective model별 wire 결정
  • shared Responses JSON event boundary
    • HTTP 및 WebSocket reframing에서 terminal JSON을 같은 방식으로 검증
    • unknown/missing status가 default completed event로 바뀌지 않게 함
  • src/server/responses/core.ts와 Responses WebSocket bridge
    • 기존 bounded JSON 및 event-reframing 로직 재사용
    • 예상하지 않은 SSE에서 retry나 tee 진입 없이 한 번 실패
  • 집중 테스트와 provider-configuration 문서
    • exact-model precedence, alias, HTTP/WebSocket parity, terminal status, tool id, limit, cancellation, rollback 검증

이 PR에는 시각적 인터페이스 변경이 필요하지 않습니다.

호환성, 제약 및 롤백

Bounded fallback 정책 자체는 기존 설정에 선택적으로 추가되며 명시하지 않으면 비활성 상태를 유지하는 것이 의도입니다. 그러나 제출된 head의 공용 WebSocket JSON validator는 설정하지 않은 route의 성공 JSON snapshot에도 적용됩니다. 아래 검토 별첨에서는 이를 아직 해결되지 않은 범위 불일치로 기록하며, 설정하지 않은 동작이 유지된다는 주장은 review-ready 전 이 문제를 해결하는 것을 전제로 합니다.

장점은 Responses contract와 Bun runtime gate를 약화하지 않으면서 특정 provider에만 적용되는 분기 없이 문제가 있는 streaming relay를 우회할 수 있다는 점입니다. 제약도 명확합니다. Opt-in model에서는 incremental text/reasoning/tool delta가 없고, upstream이 완료된 뒤 첫 client event가 도착하며, 완료된 응답을 기존 bounded JSON 범위 안에 보유합니다. Upstream이 non-streaming Responses를 실제로 지원해야 합니다.

정상적인 configuration reload 또는 service restart 후 model entry를 true로 설정하면 forced bounded-JSON fallback이 비활성화됩니다. 이는 client-requested/default streaming policy를 복원하지만 upstream이 실제로 stream할 것까지 보장하지는 않습니다. Entry를 제거하면 inherited registry/default policy로 복귀하며 그 값도 false일 수 있습니다. Data migration은 필요하지 않습니다.

이 PR은 #820을 닫지 않아야 합니다. 장기 해결책은 지원 플랫폼에서 true streaming을 보존하는 runtime-qualified one-reader relay입니다.

관련 작업

  • #820 — 더 넓은 runtime-qualified, protocol-safe one-reader architecture. 이번 제안은 의도적으로 범위가 더 좁습니다.

  • #1127 — upstream/internal completion 이후 client SSE event가 0개였던 유사한 macOS 증상

  • #1142 — client-rewrite traffic에 대한 Darwin explicit eager relay 수정. Darwin auto와 Linux는 의도적으로 변경하지 않았습니다.

  • #947 — transport predicate가 #1142에 attribution된 닫힌 미병합 선행 PR

  • #1133 — downstream byte를 유지하면서 translated SSE inspection을 bound

  • #1241 — tee/client-pull 경계를 제거하지 않고 client-facing SSE frame retention을 bound

  • #1217 — 요청·응답 본문을 남기지 않는 transport 관측성에 관한 상호 보완 작업

  • #1176 — regression 및 운영 위험에 포함해야 하는 별도의 bounded-JSON timeout tradeoff

  • #1026 — 이번 변경이 재사용하는 bounded JSON 및 canonical event reframe 기반

  • #1155 — web-search 처리를 위해 registry streaming policy를 다루는 열린 모델별 제안. 검증된 custom-provider 정책을 노출하지는 않습니다.

현재 열린 Issue/PR 검색에서는 이 사용자 정의 provider 설정을 구현하는 작업을 찾지 못했습니다.

범위

포함:

  • custom openai-responses provider
  • 명시적 모델별 opt-in
  • HTTP/SSE 및 기존 Responses WebSocket reframing
  • 엄격한 bounded JSON validation과 canonical snapshot event
  • credential이 없는 synthetic 테스트
  • 리뷰 요청 전 macOS arm64 및 Linux x86_64 실환경 검증

제외:

  • Completion API route
  • 내장 opencode-gogpt-5.6-luna wire mapping 변경
  • provider/model-name heuristic
  • 기본 streamMode 변경
  • Bun 1.3.14에서 Linux eager relay 활성화
  • 전역 tee() 제거 또는 bundled Bun upgrade
  • 실제 request/response content 로깅
  • Native Windows, WSL, Linux arm64 또는 미검증 POSIX compatibility 주장
  • upstream provider 수정 또는 post-OpenCodex sidecar

구현 중 진단 정보를 추가하더라도 요청·응답 본문을 포함하지 않아야 합니다. Status, content-type 범주, byte count, 상대 timing, 선택 mode, terminal type, cancellation, bounded-read 결과만 허용합니다. Credential, provider origin, query string, prompt, output text, raw SSE/JSON, account id 및 원문 request/thread/response id는 기록하지 않습니다.

검토 별첨 — 확인된 blocker와 후속 위험

제출된 head에 대한 2차 정적 검토에서 구체적인 코드 문제 두 가지와 아직 완료되지 않은 acceptance gate 한 가지가 확인되었습니다. 이 별첨은 현재 Draft 상태를 기록하며, 해당 finding이 이미 수정됐다고 주장하지 않습니다. 또한 maintainer 승인이나 GitHub의 공식 review 판정도 아닙니다.

Review-ready 전 반드시 해결할 항목

  1. 공용 WebSocket validation이 opt-in 범위를 넘습니다. 현재 WebSocket bridge는 modelResponsesUpstreamStreaming=false가 bounded fallback을 선택했는지 알지 못한 채 성공한 모든 Responses JSON snapshot을 엄격하게 검증합니다. 변경 전에는 설정하지 않은 sparse JSON snapshot이 compatibility default를 사용해 event로 재구성될 수 있었지만, 제출된 head에서는 502 protocol error가 됩니다. 가장 안전한 해결책은 bounded-fallback 내부 discriminator를 WebSocket bridge까지 전달해 새 strict contract를 해당 경로에만 적용하는 것입니다. 전역 fail-closed validation을 의도적으로 유지한다면 더 넓은 behavioral change로 문서화하고 회귀 테스트를 추가해야 하며, 가능하면 별도 PR로 분리하는 편이 적절합니다.
  2. 정책 key가 canonicalize되지 않습니다. 앞뒤 공백이 있는 key는 configuration validation을 통과하지만 runtime lookup에서 발견되지 않습니다. 대소문자만 다른 key가 서로 충돌하는 값을 가질 수도 있어 exact request casing 또는 insertion order에 따라 결과가 달라집니다. 정책 전용 validator는 startup과 management-write boundary 모두에서 surrounding whitespace 및 case-insensitive duplicate를 거부해야 하며, canonical key의 runtime lookup regression test도 필요합니다.
  3. 새 fallback에 대한 Native Codex 실환경 acceptance가 아직 없습니다. Synthetic test는 event 순서, terminal status, function call, repair, cancellation 및 no replay를 검증하지만, Native Codex가 이 fallback을 통해 실제 turn을 commit하고 다음 대화를 계속하는지는 증명하지 않습니다. Review-ready 전 격리 canary에서 macOS arm64와 Linux x86_64의 누적 multi-turn workflow를 완료하고, turn별 upstream request 1회, client retry 0회, terminal outcome 정확히 1개, tool-call identifier 보존 및 content-free cleanup evidence를 확인해야 합니다. 개인 relay는 장애 재현에 필요하지 않았으며 이 upstream acceptance 기준의 범위 밖입니다. 배포별 relay 또는 sidecar는 해당 운영자가 별도로 검증해야 합니다.

비차단 후속 위험

  • Bounded-body timeout 관측성. 재사용하는 제한은 first byte 및 전체 body에 180초, non-empty chunk 사이에 30초를 허용합니다. 따라서 첫 body byte 전의 긴 generation 자체가 30초 실패를 의미하지는 않으며, 위험은 upstream이 JSON body를 시작한 뒤 chunk 사이에서 멈추는 경우입니다. 모든 timeout을 같은 502 message로 합치기보다 timeout phase, chunk count, received byte 및 상대 timing을 content-free telemetry로 제공하는 편이 좋습니다.
  • 반복적인 전체 validation. Raw snapshot과 client repair 이후 snapshot은 각각 한 번 검증할 가치가 있지만, 현재 HTTP/SSE 경로는 output을 대략 세 번, WebSocket 경로는 대략 다섯 번 순회할 수 있습니다. Body/item cap으로 제한되므로 correctness blocker는 아니지만, serializer가 같은 repaired snapshot을 다시 순회하지 않도록 validated result 또는 내부 unchecked iterator를 재사용하는 후속 개선이 필요합니다.
  • WebSocket backpressure. JSON reframing은 lifecycle event array를 materialize한 뒤 동기적으로 전송합니다. Bun send가 queue/backpressure 상태여도 생성을 멈추지 않으므로 느린 client에서는 개별 item frame과 최종 전체 snapshot이 함께 유지될 수 있습니다. Bun/body/item limit이 있어 무제한 증가라고 볼 수는 없지만, drain-aware sender와 slow-consumer stress test는 적절한 후속 작업입니다.

처리 방침

Architecture 방향은 유지합니다. Workaround는 provider-independent하게 유지하고, client와 upstream 모두 Responses를 사용하며, 처리 여부가 불명확한 model request를 replay하지 않습니다. 두 코드 blocker를 수정하고 집중 테스트를 추가하며, live fallback canary와 repository review gate를 완료하기 전까지 이 Draft는 review-ready로 전환하지 않습니다. Timeout, duplicate validation 및 WebSocket backpressure는 acceptance evidence가 심각도를 높이지 않는 한 별도 후속 작업으로 관리할 수 있습니다.

검열된 보존 런타임 증거

아래 excerpt는 실제로 보존된 canary/tool output, OpenCodex request history 및 Native Codex rollout record에서 안전한 field만 선별한 것입니다. Model output을 재구성한 것이 아닙니다. Secret, prompt, response, tool argument, opaque identifier, 정확한 timestamp, host 정보, 로컬 경로 및 private endpoint는 제거했습니다. 상대 시간 t+만 보존 timestamp에서 파생했으며 protocol outcome과 duration은 기록값을 유지했습니다. 이 별첨을 만들기 위한 새 model call은 수행하지 않았습니다.

직접 연결 대조군 — Native Codex에서 OpenCode Go Responses로

보존된 4-turn canary output에서 선별한 field입니다.

source=retained_direct_canary_output
route=direct_https_responses
opencodex_endpoint_used=false
existing_wrapper_used=false
existing_appserver_used=false
config_wire_responses=true
config_websockets_false=true
config_retries_zero=true
config_reasoning_unset=true
turn=1 elapsed_ms=4588 turn_completed=1 agent_messages=1 usage_present=1 retry_markers=0 error_events=0 tool_items=0 thread_consistent=true sentinel_exact=true success=true
turn=2 elapsed_ms=5499 turn_completed=1 agent_messages=1 usage_present=1 retry_markers=0 error_events=0 tool_items=0 thread_consistent=true sentinel_exact=true success=true
turn=3 elapsed_ms=3825 turn_completed=1 agent_messages=1 usage_present=1 retry_markers=0 error_events=0 tool_items=0 thread_consistent=true sentinel_exact=true success=true
turn=4 elapsed_ms=3789 turn_completed=1 agent_messages=1 usage_present=1 retry_markers=0 error_events=0 tool_items=0 thread_consistent=true sentinel_exact=true success=true
persistent_thread_turns_completed=4 overall_success=true cleanup=true ssh_exit=0

이 대조군은 하나의 persistent Native Codex thread에서 같은 OpenCode Go Responses upstream을 직접 호출했으며 OpenCodex endpoint, wrapper 또는 기존 AppServer를 사용하지 않았습니다. 따라서 direct client/upstream 경로가 연속 turn을 완료할 수 있음을 보여줍니다. 이번 PR의 새 fallback이나 tool-call round trip을 검증한 것은 아닙니다.

실패 재현 — OpenCodex server-side history

아래 normalized row는 Linux/x86_64의 동일한 OpenCodex conversation에서 보존된 기록입니다. Private correlation identifier는 제거했고 t+는 첫 요청 기준 상대 시간입니다.

source=retained_opencodex_request_history
t+0.000s    status=200 duration_ms=4690 first_output_ms=2998 terminal_status=completed close_reason=terminal transport_phase=terminal_sse terminal_source=upstream
t+302.694s  status=200 duration_ms=2246 first_output_ms=2192 terminal_status=completed close_reason=terminal transport_phase=terminal_sse terminal_source=upstream
t+604.637s  status=200 duration_ms=2634 first_output_ms=2628 terminal_status=completed close_reason=terminal transport_phase=terminal_sse terminal_source=upstream
t+907.266s  status=200 duration_ms=2420 first_output_ms=2416 terminal_status=completed close_reason=terminal transport_phase=terminal_sse terminal_source=upstream
t+1210.199s status=200 duration_ms=2853 first_output_ms=2770 terminal_status=completed close_reason=terminal transport_phase=terminal_sse terminal_source=upstream
t+1514.592s status=200 duration_ms=2155 first_output_ms=2152 terminal_status=completed close_reason=terminal transport_phase=terminal_sse terminal_source=upstream

OpenCodex는 매 attempt에서 2.155~4.690초 안에 upstream terminal completion을 기록했지만 같은 conversation의 요청은 302.694, 301.943, 302.629, 302.933 및 304.393초 간격으로 반복됐습니다. 이 cadence는 Native Codex의 300초 stream-idle 경계와 일치하지만, 원본 client retry 진단 문자열은 보존되지 않았으므로 직접 retry log라고 주장하지 않습니다.

실패 재현 — Native Codex client rollout

별도의 macOS/arm64 재현은 Native Codex와 OpenCodex만 있는 최소 request stack에서 수행했습니다. 보존된 client rollout을 정규화하면 다음과 같습니다.

source=retained_native_codex_rollout
event_sequence=task_started -> token_count -> token_count -> token_count -> token_count -> turn_aborted
assistant_messages=0 task_complete=0 tool_calls=0
turn_aborted=1 turn_aborted_duration_ms=1026650 turn_aborted_reason=interrupted
personal_relay_present=false sidecar_present=false additional_gateway_present=false

Linux server-side row와 macOS client rollout은 서로 다른 재현이며 하나의 cross-log correlation처럼 제시하지 않습니다. 실패 요청의 원본 response header와 body는 보존되지 않았습니다. 따라서 위의 status=200transport_phase=terminal_sse는 OpenCodex request-history field이며, Content-Type header 또는 client-facing SSE 전체 body를 독립적으로 캡처했다는 의미가 아닙니다. Relay가 없는 topology와 함께 보면, 이 기록은 개인 relay를 재현 또는 acceptance contract에 포함하지 않으면서 OpenCodex client-delivery 경계의 실패를 뒷받침합니다.

검증

이 Draft branch의 구현 및 repository 수준 검증은 완료했습니다. 운영 서비스는 변경하지 않았고 새 fallback을 실제 provider에 적용하는 live canary는 아직 수행하지 않았습니다.

진단 근거:

  • OpenCodex 없이 direct stream:false, direct stream:true, 하나의 대화에서 4회 연속 Native Codex turn이 모두 완료됨
  • 관련 AI request stack에 Native Codex와 OpenCodex만 설치·구성되고 개인 OCI relay, sidecar 또는 추가 gateway/proxy가 없는 별도 최소 환경에서도 장애가 재현됨
  • macOS arm64 및 Linux x86_64에서 OpenCodex는 종료 event를 확인하지만 Native Codex는 해당 turn을 완료하지 못하는 현상을 재현함
  • 같은 proxy route에서 최소 요청은 완료되지만 rich 요청은 장애를 재현함
  • 검토한 증거에서 공식 OpenAI와 custom DeepSeek Responses 경로가 성공 대조군으로 동작함
  • OpenCodex 2.10.1부터 2.11.1, 현재 dev 및 관련 upstream 작업을 검토했지만 정식 릴리스에서 기본 POSIX tee 경로를 일반적으로 해결한 수정은 찾지 못함

구현 검증:

  • Config-file과 management-write validation이 일치하고 model/wire 우선순위와 미설정 동작을 검증함
  • Upstream request에서는 의도한 stream field만 바뀌고 completed/failed/incomplete JSON이 HTTP와 WebSocket에서 올바른 event로 변환됨
  • Function-call, parallel-call, virtual-model, snapshot-repair, item-id, size-limit 및 cancellation 경로를 synthetic fixture로 검증함
  • Absent 또는 null usage는 허용하고 malformed JSON, invalid usage, unknown status, oversize/stall, 예상 밖 content type 및 SSE는 replay 없이 실패함
  • Body cancellation이 영원히 끝나지 않아도 fail-closed 502 응답이 지연되지 않음
  • 집중 테스트, typecheck, 문서 build, privacy scan 및 repository pre-push gate가 제출 head에서 통과함

리뷰 요청 전 필수 실환경 검증:

  • 활성 2.10.1 service를 변경하지 않는 isolated canary를 사용함
  • macOS arm64에서 한 대화의 문맥이 누적되는 turn을 4개 이상 완료하고 SSH 기반 Linux x86_64에서 같은 workflow를 반복함
  • Retry를 0으로 유지하고 과금 가능한 중복 요청이 없으며, client terminal 하나와 대응하는 turn outcome 하나만 기록되는지 확인함
  • Mode, status, event count/type, byte count, elapsed time, terminal outcome, cleanup state만 증거로 보존함
  • Linux arm64, Native Windows, WSL 및 다른 POSIX 시스템은 별도 검증 전까지 미검증 표시를 유지함

체크리스트

  • 관련 없는 정리 없이 범위를 집중해서 유지했습니다.
  • 필요한 문서 또는 release note를 갱신했습니다.
  • Secret, auth, unsafe default 관점의 보안 검토를 완료했습니다.
  • Fixture와 증거에 실제 secret, private endpoint, payload, prompt/output, account id 또는 raw identifier가 없습니다. 위의 공개 OpenCode Go endpoint는 protocol 문서 근거로만 인용했습니다.
  • Management validation 변경은 maintainer 검토 후 maintainer-sponsored label이 필요하며 외부 기여자가 직접 충족할 수 없습니다.
  • 이 Draft를 review-ready로 전환하기 전 macOS arm64 및 Linux x86_64 live fallback 검증이 남아 있습니다.

Review readiness checklist

This PR stays in draft until every box below is ticked. Tick all four boxes once the requirements are met:

  • All CI tests are green on my local testing.

  • I pushed my PR to the latest dev commit.

  • I resolved all correct Codex and CodeRabbit findings.

  • My PR is ready for review.

Summary by CodeRabbit

  • New Features

    • Added per-model control over upstream Responses streaming for eligible providers.
    • Added case-insensitive model matching with virtual and wire-model fallbacks.
    • Added bounded JSON fallback that can be reframed as client-facing Responses events.
  • Bug Fixes

    • Invalid or malformed Responses payloads now return clear 502 errors instead of emitting invalid events.
    • Terminal statuses such as completed, failed, and incomplete are preserved accurately.
  • Documentation

    • Updated provider configuration and transport documentation in supported languages.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds per-model modelResponsesUpstreamStreaming configuration, validates terminal Responses JSON, resolves model and wire mappings, and enforces bounded JSON behavior across HTTP, SSE, and WebSocket transports.

Changes

Responses streaming policy

Layer / File(s) Summary
Policy contract and configuration validation
src/types.ts, src/config.ts, src/server/auth-cors.ts, tests/config.test.ts, tests/management-provider-validation.test.ts, docs-site/src/content/docs/*/reference/configuration/providers.md
Adds modelResponsesUpstreamStreaming?: Record<string, boolean>. Validation checks keys, values, provider type, model resolution, and the openai-responses wire.
Model policy resolution
src/providers/registry.ts, tests/deepseek-inbound-wire.test.ts, tests/openai-api-virtual-models.test.ts
Provider settings override registry defaults. Matching is case-insensitive and supports public virtual IDs with wire-model fallback.
Terminal Responses JSON validation
src/server/responses-json-events.ts, tests/responses-json-events.test.ts
Validates terminal IDs, statuses, output items, and usage fields before event conversion. Invalid payloads raise validation errors.
Bounded JSON transport handling
src/server/responses/core.ts, src/server/ws-bridge.ts, structure/04_transports-and-sidecars.md, tests/deepseek-inbound-wire.test.ts, tests/ws-endpoint.test.ts
Non-streaming upstream routes force stream=false, reject unexpected SSE or invalid JSON with 502 responses, preserve terminal statuses, and reframe validated JSON as client events. Abort signals cancel bounded reads.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ResponsesCore
  participant UpstreamProvider
  participant ResponsesJsonValidator
  participant ResponsesEventEncoder
  Client->>ResponsesCore: Send Responses request
  ResponsesCore->>UpstreamProvider: Send bounded JSON request
  UpstreamProvider-->>ResponsesCore: Return terminal JSON
  ResponsesCore->>ResponsesJsonValidator: Validate response
  ResponsesJsonValidator-->>ResponsesCore: Return validated response
  ResponsesCore->>ResponsesEventEncoder: Reframe response as events
  ResponsesEventEncoder-->>Client: Return SSE or WebSocket events
Loading

Possibly related PRs

Suggested reviewers: lidge-jun, luvs01, ingwannu

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: an opt-in bounded JSON fallback for custom Responses providers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch agent/responses-bounded-json-fallback
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the intake: hygiene-blocked Deterministic PR hygiene checks failed label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

⚠️ Deterministic hygiene checks failed.

  • unsponsored_surface — This changes an authentication, workflow, release-automation, or dependency surface. MAINTAINERS.md requires security review for these; ask a maintainer to apply maintainer-sponsored once they have reviewed it. Paths: src/server/auth-cors.ts.

@github-actions github-actions Bot added the enhancement New feature or request label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • hygiene: unsponsored_surface.

What to do

  • Fix unsponsored_surface — This changes an authentication, workflow, release-automation, or dependency surface. MAINTAINERS.md requires security review for these; ask a maintainer to apply maintainer-sponsored once they have reviewed it. Paths: src/server/auth-cors.ts.
  • Tick all four boxes in the PR description once you're done (currently 0/4).

Review readiness checklist

  • ⬜ All CI tests are green on my local testing.
  • ⬜ I pushed my PR to the latest dev commit.
  • ⬜ I resolved all correct Codex and CodeRabbit findings.
  • ⬜ My PR is ready for review.

0/4 boxes ticked.

This pull request was already a draft. Its draft status will be preserved after every issue above is resolved.
@novelKR Tick the boxes once your local CI is green, your branch is on the latest dev commit, and every correct Codex and CodeRabbit finding is resolved.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs-site/src/content/docs/ja/reference/configuration/providers.md`:
- Line 87: Update the modelResponsesUpstreamStreaming? documentation in
docs-site/src/content/docs/ja/reference/configuration/providers.md:87-87 and
docs-site/src/content/docs/ko/reference/configuration/providers.md:87-87 to
state that each configured model must resolve to the openai-responses wire,
including non-forward openai-chat providers resolved through modelAdapters,
rather than requiring the provider adapter itself to be openai-responses.

In `@src/config.ts`:
- Around line 912-987: Update modelResponsesUpstreamStreamingConfigError to
detect duplicate policy keys after case-insensitive normalization and return a
validation error before resolving effective wires; preserve acceptance of unique
normalized model IDs and their existing wire checks. Add regression coverage for
conflicting differently cased keys in tests/config.test.ts and
tests/management-provider-validation.test.ts.

In `@src/providers/registry.ts`:
- Around line 2289-2299: Reject case-insensitive duplicate keys while validating
the responses streaming policy configuration in the relevant config validation
logic, so entries such as “Model” and “model” produce a validation error.
Preserve boolean-value validation, and add coverage confirming the invalid
configuration is rejected and that valid policy resolution yields the expected
upstream stream option.

In `@src/server/responses-json-events.ts`:
- Around line 20-40: Update usageValidationError in
src/server/responses-json-events.ts (lines 20-40) to validate input_tokens,
output_tokens, and total_tokens only when present and non-null, while still
rejecting negative or non-integer values; in tests/responses-json-events.test.ts
(line 100), move the completed response with usage: {} into the accepted usage
cases and keep usage: [] invalid.

In `@src/server/responses/core.ts`:
- Around line 2427-2432: Update the oversized and truncated branches in the
surrounding response-handling function to call upstream.abort(...) before
returning their 502 formatErrorResponse results. Match the abort behavior and
reason style used by the sibling bail-outs near the other upstream failure
paths, while preserving the existing status codes and error messages.
- Around line 2208-2216: Move the forceBoundedResponsesJson && isEventStream
rejection to immediately after forceBoundedResponsesJson is computed and before
the terminalRecorder/quota outcome block in the surrounding response handler.
Preserve its abort, body cancellation, and 502 formatErrorResponse behavior,
then remove the later duplicate branch so no terminal recorder is installed for
this rejected stream.

In `@src/server/ws-bridge.ts`:
- Around line 399-411: Extract the duplicated JSON parse, validation, and
dispatch logic from the two response-body branches into a shared helper adjacent
to sendInvalidResponsesJson, accepting the WebSocket, Response, input text, and
existing options callbacks. Replace both the text and trimmed call-site blocks
with calls to this helper, preserving their current malformed-JSON and
validation-error handling.

In `@tests/deepseek-inbound-wire.test.ts`:
- Around line 548-560: Add a regression test covering non-bounded WebSocket SSE
handoff for the DeepSeek provider: exercise both omitted policy and
modelResponsesUpstreamStreaming: true with the provider adapter set to
openai-responses. Return text/event-stream data containing valid
response.created and response.completed events, route the response through
sendResponseToWebSocket, and assert both events remain WebSocket text frames
without a terminal JSON protocol error.

In `@tests/responses-json-events.test.ts`:
- Line 100: Update the test case for the completed response with empty usage in
responses-json-events.test.ts: move `{ id: "r", status: "completed", output: [],
usage: {} }` from the invalid assertions to the valid cases, consistent with
relaxing the presence rule in usageValidationError.

In `@tests/ws-endpoint.test.ts`:
- Around line 379-397: Add focused tests beside the existing
sendResponseToWebSocket coverage for both uncovered branches: malformed JSON
with application/json should produce exactly one websocket_protocol_error frame
and an "incomplete" terminal status, while valid JSON beginning with "{" under a
non-JSON, non-SSE content type should follow the sniffed-JSON validation path
and produce the same results.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6f244fe8-655f-4c1f-a405-8cf65ea80bed

📥 Commits

Reviewing files that changed from the base of the PR and between 4f746d1 and fff92b1.

📒 Files selected for processing (19)
  • docs-site/src/content/docs/ja/reference/configuration/providers.md
  • docs-site/src/content/docs/ko/reference/configuration/providers.md
  • docs-site/src/content/docs/reference/configuration/providers.md
  • docs-site/src/content/docs/ru/reference/configuration/providers.md
  • docs-site/src/content/docs/zh-cn/reference/configuration/providers.md
  • src/config.ts
  • src/providers/registry.ts
  • src/server/auth-cors.ts
  • src/server/responses-json-events.ts
  • src/server/responses/core.ts
  • src/server/ws-bridge.ts
  • src/types.ts
  • structure/04_transports-and-sidecars.md
  • tests/config.test.ts
  • tests/deepseek-inbound-wire.test.ts
  • tests/management-provider-validation.test.ts
  • tests/openai-api-virtual-models.test.ts
  • tests/responses-json-events.test.ts
  • tests/ws-endpoint.test.ts

| `modelSupportsReasoningSummaries?` | `Record<string, boolean>` |モデルを `false` に設定して、概要の広告を停止し、概要配信フィールドを削除します。 |
| `modelReasoningSummaryDelivery?` | `Record<string, "sequential" \| "sequential_cutoff" \| "concurrent" \| "concurrent_cutoff">` |モデルごとの応答配信列挙型。既存の配信フィールドを書き換えます。 |
| `modelAdapters?` | `Record<string, string>` | 混合配線ゲートウェイのモデルごとの `openai-chat` または `openai-responses` 配線オーバーライド。明示的なエントリはレジストリのデフォルトを破ります。DeepSeek のプリセットは `deepseek-v4-flash` のネイティブ Responses を選択でき、GitHub Copilot は GPT-5 ファミリー (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) を Responses 専用デフォルトとして宣言します。これらのモデルはエージェント トラフィックで `/chat/completions` を拒否するためです。`gpt-5.4-nano` のようなビルトイン デフォルトのないモデルはここでオプトインできます。単線アップストリーム ピンと正規の ChatGPT 転送はオーバーライドを拒否します。 |
| `modelResponsesUpstreamStreaming?` | `Record<string, boolean>` | forward 以外の `openai-responses` プロバイダー向けモデル別 upstream Responses ポリシーです。`false` は upstream に bounded JSON を要求し、検証済み terminal オブジェクトを streaming client 用 Responses イベントへ再構成します。`true` は registry の `false` 既定値を明示的に上書きします。照合は大文字小文字を区別せず、public virtual id を優先し、最終 wire-model id をフォールバックに使います。この correctness-first fallback では incremental delta がなくなり、bounded JSON のサイズと timeout 制限が適用されます。 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the effective model wire, not only the provider adapter.

Both rows state that this policy requires an openai-responses provider. The configuration validator accepts a non-forward openai-chat provider when the selected model resolves to openai-responses through modelAdapters. This wording incorrectly excludes supported mixed-wire providers.

  • docs-site/src/content/docs/ja/reference/configuration/providers.md#L87-L87: State that each configured model must resolve to the openai-responses wire.
  • docs-site/src/content/docs/ko/reference/configuration/providers.md#L87-L87: State that each configured model must resolve to the openai-responses wire.

As per path instructions, user-facing docs must stay in sync with actual CLI/API behavior.

📍 Affects 2 files
  • docs-site/src/content/docs/ja/reference/configuration/providers.md#L87-L87 (this comment)
  • docs-site/src/content/docs/ko/reference/configuration/providers.md#L87-L87
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/ja/reference/configuration/providers.md` at line
87, Update the modelResponsesUpstreamStreaming? documentation in
docs-site/src/content/docs/ja/reference/configuration/providers.md:87-87 and
docs-site/src/content/docs/ko/reference/configuration/providers.md:87-87 to
state that each configured model must resolve to the openai-responses wire,
including non-forward openai-chat providers resolved through modelAdapters,
rather than requiring the provider adapter itself to be openai-responses.

Source: Path instructions

Comment thread src/config.ts
Comment on lines +912 to +987
/** Validate the opt-in bounded-JSON policy against the model's effective Responses wire. */
export function modelResponsesUpstreamStreamingConfigError(
value: unknown,
field: string,
providerName: string,
provider: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown; modelAdapters?: unknown },
): string | null {
const shapeError = booleanRecordConfigError(value, field);
if (shapeError) return shapeError;
const entries = Object.entries((value ?? {}) as Record<string, boolean>);
if (entries.length === 0) return null;

const registry = getProviderRegistryEntry(providerName);
const registryTransportMatches = typeof provider.baseUrl === "string"
&& providerMatchesRegistryTransport(providerName, {
baseUrl: provider.baseUrl,
adapter: provider.adapter as OcxProviderConfig["adapter"],
...(typeof provider.authMode === "string"
? { authMode: provider.authMode as OcxProviderConfig["authMode"] }
: {}),
});
const effectiveForwardAuth = registryTransportMatches
? registry?.authKind === "forward"
: provider.authMode === "forward";
if (effectiveForwardAuth) {
return `${field} is not supported on forward-auth Responses providers`;
}

const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => {
const pinned = pinnedWireAdapter(providerName, modelId);
if (pinned) return pinned;
const configured = provider.modelAdapters && typeof provider.modelAdapters === "object"
&& !Array.isArray(provider.modelAdapters)
? (provider.modelAdapters as Record<string, unknown>)[modelId]
: undefined;
if (typeof configured === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured)) {
return configured;
}
const registryDefault = typeof currentWire === "string" && typeof provider.baseUrl === "string"
? providerModelWireDefault(
providerName,
{
baseUrl: provider.baseUrl,
adapter: currentWire,
...(typeof provider.authMode === "string"
? { authMode: provider.authMode as OcxProviderConfig["authMode"] }
: {}),
},
modelId,
MODEL_ADAPTER_OVERRIDE_ALLOWED,
"responses",
)
: undefined;
return registryDefault ?? currentWire;
};

for (const [modelId] of entries) {
const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter;
const virtualSelectedModelId = Object.keys(registry?.virtualModels ?? {}).find(
candidate => candidate.toLowerCase() === modelId.trim().toLowerCase(),
);
const effectiveSelectedModelId = virtualSelectedModelId ?? modelId;
let effectiveWire = resolveEffectiveWire(effectiveSelectedModelId, baseWire);
const virtualWireModel = resolveOpenAiVirtualModel(
providerName,
effectiveSelectedModelId,
)?.wireModelId;
if (virtualWireModel && virtualWireModel !== effectiveSelectedModelId) {
effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire);
}
if (effectiveWire !== "openai-responses") {
return `${field}.${modelId} requires the openai-responses wire`;
}
}
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject conflicting case-insensitive policy keys.

The validator accepts both { "model-a": false, "MODEL-A": true }. Under the documented case-insensitive lookup contract, these entries assign two values to one model. The downstream resolver receives the original record and must select one value. This can silently enable upstream streaming when an operator intended bounded JSON.

Normalize keys once or reject duplicate normalized keys before resolving the effective wire. Add regression cases to tests/config.test.ts and tests/management-provider-validation.test.ts.

Proposed validation
+  const normalizedPolicyKeys = new Set<string>();
   for (const [modelId] of entries) {
+    const normalizedModelId = modelId.trim().toLowerCase();
+    if (normalizedPolicyKeys.has(normalizedModelId)) {
+      return `${field} contains duplicate case-insensitive model id ${modelId}`;
+    }
+    normalizedPolicyKeys.add(normalizedModelId);
     const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** Validate the opt-in bounded-JSON policy against the model's effective Responses wire. */
export function modelResponsesUpstreamStreamingConfigError(
value: unknown,
field: string,
providerName: string,
provider: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown; modelAdapters?: unknown },
): string | null {
const shapeError = booleanRecordConfigError(value, field);
if (shapeError) return shapeError;
const entries = Object.entries((value ?? {}) as Record<string, boolean>);
if (entries.length === 0) return null;
const registry = getProviderRegistryEntry(providerName);
const registryTransportMatches = typeof provider.baseUrl === "string"
&& providerMatchesRegistryTransport(providerName, {
baseUrl: provider.baseUrl,
adapter: provider.adapter as OcxProviderConfig["adapter"],
...(typeof provider.authMode === "string"
? { authMode: provider.authMode as OcxProviderConfig["authMode"] }
: {}),
});
const effectiveForwardAuth = registryTransportMatches
? registry?.authKind === "forward"
: provider.authMode === "forward";
if (effectiveForwardAuth) {
return `${field} is not supported on forward-auth Responses providers`;
}
const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => {
const pinned = pinnedWireAdapter(providerName, modelId);
if (pinned) return pinned;
const configured = provider.modelAdapters && typeof provider.modelAdapters === "object"
&& !Array.isArray(provider.modelAdapters)
? (provider.modelAdapters as Record<string, unknown>)[modelId]
: undefined;
if (typeof configured === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured)) {
return configured;
}
const registryDefault = typeof currentWire === "string" && typeof provider.baseUrl === "string"
? providerModelWireDefault(
providerName,
{
baseUrl: provider.baseUrl,
adapter: currentWire,
...(typeof provider.authMode === "string"
? { authMode: provider.authMode as OcxProviderConfig["authMode"] }
: {}),
},
modelId,
MODEL_ADAPTER_OVERRIDE_ALLOWED,
"responses",
)
: undefined;
return registryDefault ?? currentWire;
};
for (const [modelId] of entries) {
const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter;
const virtualSelectedModelId = Object.keys(registry?.virtualModels ?? {}).find(
candidate => candidate.toLowerCase() === modelId.trim().toLowerCase(),
);
const effectiveSelectedModelId = virtualSelectedModelId ?? modelId;
let effectiveWire = resolveEffectiveWire(effectiveSelectedModelId, baseWire);
const virtualWireModel = resolveOpenAiVirtualModel(
providerName,
effectiveSelectedModelId,
)?.wireModelId;
if (virtualWireModel && virtualWireModel !== effectiveSelectedModelId) {
effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire);
}
if (effectiveWire !== "openai-responses") {
return `${field}.${modelId} requires the openai-responses wire`;
}
}
return null;
}
/** Validate the opt-in bounded-JSON policy against the model's effective Responses wire. */
export function modelResponsesUpstreamStreamingConfigError(
value: unknown,
field: string,
providerName: string,
provider: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown; modelAdapters?: unknown },
): string | null {
const shapeError = booleanRecordConfigError(value, field);
if (shapeError) return shapeError;
const entries = Object.entries((value ?? {}) as Record<string, boolean>);
if (entries.length === 0) return null;
const registry = getProviderRegistryEntry(providerName);
const registryTransportMatches = typeof provider.baseUrl === "string"
&& providerMatchesRegistryTransport(providerName, {
baseUrl: provider.baseUrl,
adapter: provider.adapter as OcxProviderConfig["adapter"],
...(typeof provider.authMode === "string"
? { authMode: provider.authMode as OcxProviderConfig["authMode"] }
: {}),
});
const effectiveForwardAuth = registryTransportMatches
? registry?.authKind === "forward"
: provider.authMode === "forward";
if (effectiveForwardAuth) {
return `${field} is not supported on forward-auth Responses providers`;
}
const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => {
const pinned = pinnedWireAdapter(providerName, modelId);
if (pinned) return pinned;
const configured = provider.modelAdapters && typeof provider.modelAdapters === "object"
&& !Array.isArray(provider.modelAdapters)
? (provider.modelAdapters as Record<string, unknown>)[modelId]
: undefined;
if (typeof configured === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured)) {
return configured;
}
const registryDefault = typeof currentWire === "string" && typeof provider.baseUrl === "string"
? providerModelWireDefault(
providerName,
{
baseUrl: provider.baseUrl,
adapter: currentWire,
...(typeof provider.authMode === "string"
? { authMode: provider.authMode as OcxProviderConfig["authMode"] }
: {}),
},
modelId,
MODEL_ADAPTER_OVERRIDE_ALLOWED,
"responses",
)
: undefined;
return registryDefault ?? currentWire;
};
const normalizedPolicyKeys = new Set<string>();
for (const [modelId] of entries) {
const normalizedModelId = modelId.trim().toLowerCase();
if (normalizedPolicyKeys.has(normalizedModelId)) {
return `${field} contains duplicate case-insensitive model id ${modelId}`;
}
normalizedPolicyKeys.add(normalizedModelId);
const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter;
const virtualSelectedModelId = Object.keys(registry?.virtualModels ?? {}).find(
candidate => candidate.toLowerCase() === modelId.trim().toLowerCase(),
);
const effectiveSelectedModelId = virtualSelectedModelId ?? modelId;
let effectiveWire = resolveEffectiveWire(effectiveSelectedModelId, baseWire);
const virtualWireModel = resolveOpenAiVirtualModel(
providerName,
effectiveSelectedModelId,
)?.wireModelId;
if (virtualWireModel && virtualWireModel !== effectiveSelectedModelId) {
effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire);
}
if (effectiveWire !== "openai-responses") {
return `${field}.${modelId} requires the openai-responses wire`;
}
}
return null;
}
🧰 Tools
🪛 ast-grep (0.45.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFileSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config.ts` around lines 912 - 987, Update
modelResponsesUpstreamStreamingConfigError to detect duplicate policy keys after
case-insensitive normalization and return a validation error before resolving
effective wires; preserve acceptance of unique normalized model IDs and their
existing wire checks. Add regression coverage for conflicting differently cased
keys in tests/config.test.ts and tests/management-provider-validation.test.ts.

Comment thread src/providers/registry.ts
Comment on lines +2289 to +2299
function responsesStreamingPolicyValue(
record: Record<string, boolean> | undefined,
modelId: string,
): boolean | undefined {
if (!record) return undefined;
if (Object.prototype.hasOwnProperty.call(record, modelId)) return record[modelId];
const folded = modelId.toLowerCase();
for (const [key, value] of Object.entries(record)) {
if (key.toLowerCase() === folded) return value;
}
return undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/config.ts --items all
rg -n -C 5 'modelResponsesUpstreamStreaming' \
  src/config.ts \
  tests/config.test.ts \
  tests/management-provider-validation.test.ts \
  src/server/auth-cors.ts

Repository: lidge-jun/opencodex

Length of output: 29810


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- config validation helpers ---'
sed -n '700,960p' src/config.ts
printf '%s\n' '--- registry resolver and callers ---'
sed -n '2240,2345p' src/providers/registry.ts
printf '%s\n' '--- all resolver references ---'
rg -n -C 8 'responsesStreamingPolicyValue|modelResponsesUpstreamStreaming' src/providers/registry.ts src tests
printf '%s\n' '--- focused validation tests ---'
sed -n '1025,1120p' tests/config.test.ts

Repository: lidge-jun/opencodex

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- complete policy validator ---'
sed -n '913,1005p' src/config.ts
printf '%s\n' '--- resolver call sites only ---'
rg -n -C 12 'providerModelResponsesUpstreamStreaming\(' src tests
printf '%s\n' '--- stream option consumers ---'
rg -n -C 8 'providerModelResponsesUpstreamStreaming|upstreamStreaming|stream:\s*' src/providers src/server src/responses
printf '%s\n' '--- focused behavioral probe ---'
node - <<'JS'
function responsesStreamingPolicyValue(record, modelId) {
  if (!record) return undefined;
  if (Object.prototype.hasOwnProperty.call(record, modelId)) return record[modelId];
  const folded = modelId.toLowerCase();
  for (const [key, value] of Object.entries(record)) {
    if (key.toLowerCase() === folded) return value;
  }
  return undefined;
}
const record = { Model: false, model: true };
for (const modelId of ["model", "MODEL", "Model"]) {
  console.log(modelId, responsesStreamingPolicyValue(record, modelId));
}
JS

Repository: lidge-jun/opencodex

Length of output: 50375


Reject case-folded duplicate policy keys.

src/config.ts:913-1005 validates boolean values but allows keys that differ only by case. src/providers/registry.ts:2289-2299 then gives exact-key lookup priority. Thus, { "Model": false, "model": true } returns different values for model and MODEL.

This reaches src/server/responses/core.ts:944-954, where false sets the upstream request to stream: false. Reject duplicate keys after case folding during validation. Add coverage for the validation error and the resulting stream option.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/registry.ts` around lines 2289 - 2299, Reject case-insensitive
duplicate keys while validating the responses streaming policy configuration in
the relevant config validation logic, so entries such as “Model” and “model”
produce a validation error. Preserve boolean-value validation, and add coverage
confirming the invalid configuration is rejected and that valid policy
resolution yields the expected upstream stream option.

Source: Path instructions

Comment on lines +20 to +40
function usageValidationError(value: unknown): string | null {
if (value === undefined || value === null) return null;
if (typeof value !== "object" || Array.isArray(value)) {
return "upstream Responses JSON usage must be an object or null";
}
const usage = value as Record<string, unknown>;
for (const field of ["input_tokens", "output_tokens"] as const) {
if (!isTokenCount(usage[field])) {
return "upstream Responses JSON usage token counts must be non-negative integers";
}
}
if (usage.total_tokens !== undefined && !isTokenCount(usage.total_tokens)) {
return "upstream Responses JSON usage token counts must be non-negative integers";
}
for (const field of ["input_tokens_details", "output_tokens_details"] as const) {
const details = usage[field];
if (details === undefined || details === null) continue;
if (typeof details !== "object" || Array.isArray(details)) {
return "upstream Responses JSON usage details must be objects when present";
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

A presence requirement on usage token counts rejects valid terminal snapshots. usageValidationError treats a usage object that omits input_tokens or output_tokens as malformed, because isTokenCount(undefined) returns false. Every consumer turns that verdict into a hard 502: src/server/ws-bridge.ts Line 406-410, and src/server/responses/core.ts Line 2442-2445 and Line 2492-2495. A terminal response with a valid id, a terminal status, and valid output items is therefore discarded over an incomplete accounting block. The PR description already lists "overly broad WebSocket validation" as a blocker; this is the mechanism. Both sites below must change together.

  • src/server/responses-json-events.ts#L20-L40: validate input_tokens, output_tokens, and total_tokens only when each is present and not null; reject non-integer or negative values, not absent ones.
  • tests/responses-json-events.test.ts#L100-L100: move { id: "r", status: "completed", output: [], usage: {} } out of the invalid array and into the accepted-usage list at Line 70-82. Keep usage: [] at Line 99 in the invalid list, because a non-object usage stays invalid.
📍 Affects 2 files
  • src/server/responses-json-events.ts#L20-L40 (this comment)
  • tests/responses-json-events.test.ts#L100-L100
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/responses-json-events.ts` around lines 20 - 40, Update
usageValidationError in src/server/responses-json-events.ts (lines 20-40) to
validate input_tokens, output_tokens, and total_tokens only when present and
non-null, while still rejecting negative or non-integer values; in
tests/responses-json-events.test.ts (line 100), move the completed response with
usage: {} into the accepted usage cases and keep usage: [] invalid.

Comment on lines +2208 to +2216
if (forceBoundedResponsesJson && isEventStream) {
upstream.abort(new DOMException("Unexpected event stream for bounded Responses request", "AbortError"));
try { void upstreamResponse.body?.cancel(upstream.signal.reason).catch(() => undefined); } catch { /* already closed */ }
return formatErrorResponse(
502,
"upstream_error",
"upstream ignored the bounded Responses policy and returned an event stream",
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

This early 502 skips upstream outcome recording for pool-auth turns.

The quota block at Line 2120-2167 runs before this branch. It computes terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream. When forceBoundedResponsesJson && isEventStream is true, isEventStream is true, so terminalBodyWillRecord is true. Two things follow:

  1. Line 2133 installs a terminal outcome recorder that expects the SSE stream to be consumed.
  2. The else if at Line 2153 is skipped, so recordCodexUpstreamOutcome never runs.

This branch then aborts the upstream and returns 502 at Line 2211 without consuming any stream. No terminal is ever emitted, and the installed recorder is never called. The turn disappears from quota and cooldown accounting for that account.

Reachability: providerModelResponsesUpstreamStreaming in src/providers/registry.ts Line 2303-2327 gates only the configured provider lookup on !effectiveForwardAuth. The registry-entry lookup at the end of that function is not forward-gated, so a registry entry that sets false for a forward-auth provider satisfies both forceBoundedResponsesJson and usesCodexForwardPoolAuth. The doc in structure/04_transports-and-sidecars.md Line 223 states no production registry entry opts in today, so this is latent rather than live. It still becomes live the moment the documented "one-line rollback" knob is used.

Move the rejection above the quota block, or record the numeric outcome on this path.

🐛 Proposed fix: reject before the quota/recorder block

Move this check to immediately after forceBoundedResponsesJson is computed at Line 2111, before the terminalRecorder block at Line 2112:

     const forceBoundedResponsesJson = responsesUpstreamStreaming === false
       && route.provider.adapter === "openai-responses";
+    if (forceBoundedResponsesJson && isEventStream) {
+      upstream.abort(new DOMException("Unexpected event stream for bounded Responses request", "AbortError"));
+      try { void upstreamResponse.body?.cancel(upstream.signal.reason).catch(() => undefined); } catch { /* already closed */ }
+      return formatErrorResponse(
+        502,
+        "upstream_error",
+        "upstream ignored the bounded Responses policy and returned an event stream",
+      );
+    }
     const terminalRecorder = codexForwardTerminalOutcomeRecorder(

Then delete the block at Line 2208-2216.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/responses/core.ts` around lines 2208 - 2216, Move the
forceBoundedResponsesJson && isEventStream rejection to immediately after
forceBoundedResponsesJson is computed and before the terminalRecorder/quota
outcome block in the surrounding response handler. Preserve its abort, body
cancellation, and 502 formatErrorResponse behavior, then remove the later
duplicate branch so no terminal recorder is installed for this rejected stream.

Comment on lines 2427 to 2432
if (bounded.oversized) {
return formatErrorResponse(502, "upstream_error", "upstream JSON response exceeded the safe body limit");
}
if (bounded.truncated) {
return formatErrorResponse(502, "upstream_error", "upstream JSON response stalled before completing");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Abort the upstream on the oversized and truncated paths.

Both returns produce a 502 without calling upstream.abort(). readBoundedResponseBody in src/lib/bounded-body.ts cancels the reader on oversize and on deadline, but it never aborts the fetch controller that owns the connection.

Compare the two sibling bail-outs added in this same diff: Line 2209 and Line 2538 both call upstream.abort(...) before returning 502. These two do not. A stuck or hostile upstream therefore keeps its connection open after opencodex has already given up on it.

♻️ Proposed fix: abort before returning
       if (bounded.oversized) {
+        upstream.abort(new DOMException("Upstream JSON body exceeded the safe limit", "AbortError"));
         return formatErrorResponse(502, "upstream_error", "upstream JSON response exceeded the safe body limit");
       }
       if (bounded.truncated) {
+        upstream.abort(new DOMException("Upstream JSON body stalled", "AbortError"));
         return formatErrorResponse(502, "upstream_error", "upstream JSON response stalled before completing");
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (bounded.oversized) {
return formatErrorResponse(502, "upstream_error", "upstream JSON response exceeded the safe body limit");
}
if (bounded.truncated) {
return formatErrorResponse(502, "upstream_error", "upstream JSON response stalled before completing");
}
if (bounded.oversized) {
upstream.abort(new DOMException("Upstream JSON body exceeded the safe limit", "AbortError"));
return formatErrorResponse(502, "upstream_error", "upstream JSON response exceeded the safe body limit");
}
if (bounded.truncated) {
upstream.abort(new DOMException("Upstream JSON body stalled", "AbortError"));
return formatErrorResponse(502, "upstream_error", "upstream JSON response stalled before completing");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/responses/core.ts` around lines 2427 - 2432, Update the oversized
and truncated branches in the surrounding response-handling function to call
upstream.abort(...) before returning their 502 formatErrorResponse results.
Match the abort behavior and reason style used by the sibling bail-outs near the
other upstream failure paths, while preserving the existing status codes and
error messages.

Comment thread src/server/ws-bridge.ts
Comment on lines +399 to +411
let json: unknown;
try {
json = JSON.parse(text) as unknown;
} catch {
sendInvalidResponsesJson(ws, response, "Upstream returned malformed Responses JSON", options.onTerminal);
return;
}
const validation = validateResponsesJsonEventResponse(json);
if (!validation.ok) {
sendInvalidResponsesJson(ws, response, validation.message, options.onTerminal);
return;
}
sendResponsesJsonAsEvents(ws, validation.response, options.onTerminal, options.onSsePayload);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated parse-validate-dispatch block.

Line 399-411 and Line 433-445 are the same thirteen lines. The only difference is the input string: text in the first block and trimmed in the second. Both must stay in lockstep, because one handles a declared application/json content type and the other handles a sniffed JSON body. A future change to one path that misses the other would make the two content-type routes disagree on what counts as a valid terminal snapshot.

♻️ Proposed refactor: one shared helper

Add next to sendInvalidResponsesJson:

function sendResponsesJsonTextAsEvents(
  ws: ServerWebSocket<WsData>,
  response: Response,
  text: string,
  options: { onTerminal?: ResponsesTerminalReporter; onSsePayload?: ResponsesPayloadObserver },
): void {
  let json: unknown;
  try {
    json = JSON.parse(text) as unknown;
  } catch {
    sendInvalidResponsesJson(ws, response, "Upstream returned malformed Responses JSON", options.onTerminal);
    return;
  }
  const validation = validateResponsesJsonEventResponse(json);
  if (!validation.ok) {
    sendInvalidResponsesJson(ws, response, validation.message, options.onTerminal);
    return;
  }
  sendResponsesJsonAsEvents(ws, validation.response, options.onTerminal, options.onSsePayload);
}

Then both call sites collapse:

   if (contentType.includes("application/json")) {
     const text = await response.text();
     if (!isCurrent()) return;
-    let json: unknown;
-    try {
-      json = JSON.parse(text) as unknown;
-    } catch {
-      sendInvalidResponsesJson(ws, response, "Upstream returned malformed Responses JSON", options.onTerminal);
-      return;
-    }
-    const validation = validateResponsesJsonEventResponse(json);
-    if (!validation.ok) {
-      sendInvalidResponsesJson(ws, response, validation.message, options.onTerminal);
-      return;
-    }
-    sendResponsesJsonAsEvents(ws, validation.response, options.onTerminal, options.onSsePayload);
+    sendResponsesJsonTextAsEvents(ws, response, text, options);
     return;
   }
   if (trimmed.startsWith("{")) {
-    let json: unknown;
-    try {
-      json = JSON.parse(trimmed) as unknown;
-    } catch {
-      sendInvalidResponsesJson(ws, response, "Upstream returned malformed Responses JSON", options.onTerminal);
-      return;
-    }
-    const validation = validateResponsesJsonEventResponse(json);
-    if (!validation.ok) {
-      sendInvalidResponsesJson(ws, response, validation.message, options.onTerminal);
-      return;
-    }
-    sendResponsesJsonAsEvents(ws, validation.response, options.onTerminal, options.onSsePayload);
+    sendResponsesJsonTextAsEvents(ws, response, trimmed, options);
     return;
   }

Also applies to: 433-445

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/ws-bridge.ts` around lines 399 - 411, Extract the duplicated JSON
parse, validation, and dispatch logic from the two response-body branches into a
shared helper adjacent to sendInvalidResponsesJson, accepting the WebSocket,
Response, input text, and existing options callbacks. Replace both the text and
trimmed call-site blocks with calls to this helper, preserving their current
malformed-JSON and validation-error handling.

Comment on lines +548 to +560
test("malformed terminal JSON fails closed for HTTP and WebSocket handoff", async () => {
for (const websocket of [false, true]) {
globalThis.fetch = (async () => Response.json({
id: "resp_invalid",
object: "response",
status: "still_running",
output: [],
})) as typeof fetch;
const response = await drive(customProvider(), websocket);
expect(response.status).toBe(502);
expect(response.headers.get("content-type")).toContain("application/json");
expect(await response.text()).toContain("terminal status");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/server/ws-bridge.ts --items all
rg -n -C 8 \
  'providerModelResponsesUpstreamStreaming|bounded|terminal|responsesJson|validate|websocket' \
  src/server/ws-bridge.ts src/server/responses/core.ts tests/ws-endpoint.test.ts

Repository: lidge-jun/opencodex

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- ws-bridge response handoff ---'
sed -n '350,456p' src/server/ws-bridge.ts

printf '%s\n' '--- Responses WebSocket call sites and policy gate ---'
rg -n -C 12 \
  'sendResponseToWebSocket|providerModelResponsesUpstreamStreaming|modelResponsesUpstreamStreaming|responsesJsonToSseStream|guardTerminalEventStream' \
  src/server/responses/core.ts src/server src/providers tests/deepseek-inbound-wire.test.ts

printf '%s\n' '--- relevant deepseek tests ---'
sed -n '480,590p' tests/deepseek-inbound-wire.test.ts

Repository: lidge-jun/opencodex

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- bounded-policy derivation and response handoff ---'
rg -n -C 10 \
  'forceBoundedResponsesJson|boundedResponse|clientRequestedStream|inboundTransport === "websocket"|inboundTransport !== "websocket"' \
  src/server/responses/core.ts

printf '%s\n' '--- complete custom bounded test block ---'
sed -n '420,590p' tests/deepseek-inbound-wire.test.ts

printf '%s\n' '--- WebSocket-specific deepseek tests ---'
rg -n -C 16 \
  'websocket|WebSocket|malformed terminal|terminal JSON|streaming' \
  tests/deepseek-inbound-wire.test.ts

Repository: lidge-jun/opencodex

Length of output: 27244


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- normal event-stream path after bounded-policy guard ---'
sed -n '2204,2265p' src/server/responses/core.ts

printf '%s\n' '--- WebSocket bridge tests for sendResponseToWebSocket ---'
sed -n '330,415p' tests/ws-endpoint.test.ts

printf '%s\n' '--- test helpers and imports ---'
sed -n '1,38p' tests/ws-endpoint.test.ts

Repository: lidge-jun/opencodex

Length of output: 8041


Add a non-bounded WebSocket SSE regression test.

forceBoundedResponsesJson gates core terminal validation only when modelResponsesUpstreamStreaming is false (src/server/responses/core.ts:2210-2217). However, sendResponseToWebSocket validates all application/json responses, while text/event-stream responses use the SSE pump (src/server/ws-bridge.ts:387-411). The current test does not exercise this handoff, and the DeepSeek WebSocket test checks only the upstream request body.

Add a test for an omitted policy and for modelResponsesUpstreamStreaming: true. Return text/event-stream data containing valid response.created and response.completed events. Pass the resulting response through sendResponseToWebSocket, then assert that the events remain WebSocket text frames and no terminal JSON protocol error is emitted. Keep the provider adapter set to openai-responses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/deepseek-inbound-wire.test.ts` around lines 548 - 560, Add a regression
test covering non-bounded WebSocket SSE handoff for the DeepSeek provider:
exercise both omitted policy and modelResponsesUpstreamStreaming: true with the
provider adapter set to openai-responses. Return text/event-stream data
containing valid response.created and response.completed events, route the
response through sendResponseToWebSocket, and assert both events remain
WebSocket text frames without a terminal JSON protocol error.

Source: Path instructions

{ id: "r", status: "completed", output: [null] },
{ id: "r", status: "completed", output: [{}] },
{ id: "r", status: "completed", output: [], usage: [] },
{ id: "r", status: "completed", output: [], usage: {} },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

This assertion pins the over-strict usage rule.

Line 100 asserts that { id: "r", status: "completed", output: [], usage: {} } is invalid. That expectation encodes the presence requirement in usageValidationError at src/server/responses-json-events.ts Line 26-30, which I flagged as too broad. If you relax the source rule, move this case to the valid list in the test at Line 68-87.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/responses-json-events.test.ts` at line 100, Update the test case for
the completed response with empty usage in responses-json-events.test.ts: move
`{ id: "r", status: "completed", output: [], usage: {} }` from the invalid
assertions to the valid cases, consistent with relaxing the presence rule in
usageValidationError.

Comment thread tests/ws-endpoint.test.ts
Comment on lines +379 to +397
test("invalid successful Responses JSON becomes one protocol error", async () => {
for (const body of [
{ id: "json", status: "running", output: [] },
{ id: "json", status: "completed", output: {}, usage: [] },
]) {
const { ws, sent } = mockWs();
const terminals: string[] = [];
await sendResponseToWebSocket(ws, Response.json(body), () => true, {
onTerminal: status => terminals.push(status),
});
expect(sent).toHaveLength(1);
expect(JSON.parse(sent[0])).toMatchObject({
type: "error",
status: 502,
error: { code: "websocket_protocol_error" },
});
expect(terminals).toEqual(["incomplete"]);
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the two sibling branches this PR also added.

The test is well-isolated: it builds a fresh mockWs() per fixture and asserts sent has exactly one frame, which is the assertion that actually catches a duplicate-error regression. Both fixtures, however, are well-formed JSON that fails validation. Two new branches stay uncovered:

  1. The JSON.parse catch at src/server/ws-bridge.ts Line 402-405, which sends the fixed message "Upstream returned malformed Responses JSON". Reach it with a body that is not valid JSON under an application/json content type.
  2. The sniffed-JSON path at src/server/ws-bridge.ts Line 433-445. Reach it with a body that starts with { under a content type that is neither application/json nor text/event-stream. That path is a full duplicate of the declared-JSON path, so it can silently diverge.

As per path instructions, "A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem."

💚 Proposed additional tests
test("malformed JSON body becomes one protocol error", async () => {
  const { ws, sent } = mockWs();
  const terminals: string[] = [];
  await sendResponseToWebSocket(ws, new Response("{not json", {
    headers: { "content-type": "application/json" },
  }), () => true, {
    onTerminal: status => terminals.push(status),
  });
  expect(sent).toHaveLength(1);
  expect(JSON.parse(sent[0])).toMatchObject({
    type: "error",
    status: 502,
    error: { code: "websocket_protocol_error" },
  });
  expect(terminals).toEqual(["incomplete"]);
});

test("sniffed JSON body is validated like a declared JSON body", async () => {
  const { ws, sent } = mockWs();
  const terminals: string[] = [];
  await sendResponseToWebSocket(ws, new Response(
    JSON.stringify({ id: "json", status: "running", output: [] }),
    { headers: { "content-type": "text/plain" } },
  ), () => true, {
    onTerminal: status => terminals.push(status),
  });
  expect(sent).toHaveLength(1);
  expect(JSON.parse(sent[0])).toMatchObject({
    type: "error",
    status: 502,
    error: { code: "websocket_protocol_error" },
  });
  expect(terminals).toEqual(["incomplete"]);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/ws-endpoint.test.ts` around lines 379 - 397, Add focused tests beside
the existing sendResponseToWebSocket coverage for both uncovered branches:
malformed JSON with application/json should produce exactly one
websocket_protocol_error frame and an "incomplete" terminal status, while valid
JSON beginning with "{" under a non-JSON, non-SSE content type should follow the
sniffed-JSON validation path and produce the same results.

Source: Path instructions

@Wibias Wibias left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: this draft still has current blocking findings.

Most importantly, the shared Responses JSON validator rejects valid sparse usage snapshots such as usage: {} by requiring input/output token fields, which can turn otherwise valid HTTP and WebSocket terminal snapshots into 502 responses. The case-insensitive per-model policy map also still permits conflicting differently-cased keys, leaving lookup semantics ambiguous. The forced bounded-mode early error path additionally needs to preserve the normal upstream outcome/quota bookkeeping if that policy is ever used with forward auth.

These current major threads should be resolved before approval. The branch is also presently not mergeable against current dev, so please rebase/resolve conflicts as part of the next revision.

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

Labels

enhancement New feature or request intake: hygiene-blocked Deterministic PR hygiene checks failed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants