feat(responses): add an opt-in bounded JSON fallback for custom providers - #1367
feat(responses): add an opt-in bounded JSON fallback for custom providers#1367novelKR wants to merge 1 commit into
Conversation
📝 WalkthroughWalkthroughThe change adds per-model ChangesResponses streaming policy
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
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2⚔️ Resolve merge conflicts 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
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. Comment |
|
⏳ DRAFT
What to do
Review readiness checklist
0/4 boxes ticked. This pull request was already a draft. Its draft status will be preserved after every issue above is resolved. |
There was a problem hiding this comment.
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
📒 Files selected for processing (19)
docs-site/src/content/docs/ja/reference/configuration/providers.mddocs-site/src/content/docs/ko/reference/configuration/providers.mddocs-site/src/content/docs/reference/configuration/providers.mddocs-site/src/content/docs/ru/reference/configuration/providers.mddocs-site/src/content/docs/zh-cn/reference/configuration/providers.mdsrc/config.tssrc/providers/registry.tssrc/server/auth-cors.tssrc/server/responses-json-events.tssrc/server/responses/core.tssrc/server/ws-bridge.tssrc/types.tsstructure/04_transports-and-sidecars.mdtests/config.test.tstests/deepseek-inbound-wire.test.tstests/management-provider-validation.test.tstests/openai-api-virtual-models.test.tstests/responses-json-events.test.tstests/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 制限が適用されます。 | |
There was a problem hiding this comment.
📐 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 theopenai-responseswire.docs-site/src/content/docs/ko/reference/configuration/providers.md#L87-L87: State that each configured model must resolve to theopenai-responseswire.
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
| /** 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; | ||
| } |
There was a problem hiding this comment.
🎯 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.
| /** 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.
| 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; |
There was a problem hiding this comment.
🗄️ 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.tsRepository: 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.tsRepository: 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));
}
JSRepository: 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
| 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"; | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 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: validateinput_tokens,output_tokens, andtotal_tokensonly 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 theinvalidarray and into the accepted-usage list at Line 70-82. Keepusage: []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.
| 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", | ||
| ); | ||
| } |
There was a problem hiding this comment.
🗄️ 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:
- Line 2133 installs a terminal outcome recorder that expects the SSE stream to be consumed.
- The
else ifat Line 2153 is skipped, sorecordCodexUpstreamOutcomenever 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.
| 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"); | ||
| } |
There was a problem hiding this comment.
🩺 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.
| 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.
| 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); |
There was a problem hiding this comment.
📐 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.
| 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"); | ||
| } |
There was a problem hiding this comment.
🎯 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.tsRepository: 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.tsRepository: 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.tsRepository: 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.tsRepository: 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: {} }, |
There was a problem hiding this comment.
🎯 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.
| 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"]); | ||
| } | ||
| }); |
There was a problem hiding this comment.
📐 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:
- The
JSON.parsecatch atsrc/server/ws-bridge.tsLine 402-405, which sends the fixed message "Upstream returned malformed Responses JSON". Reach it with a body that is not valid JSON under anapplication/jsoncontent type. - The sniffed-JSON path at
src/server/ws-bridge.tsLine 433-445. Reach it with a body that starts with{under a content type that is neitherapplication/jsonnortext/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
left a comment
There was a problem hiding this comment.
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.
Summary
This PR adds an opt-in per-model fallback for custom
openai-responsesproviders 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
modelResponsesUpstreamStreamingpolicy to validated custom-provider configuration. Setting a model tofalsekeeps the Responses API on both sides of the proxy, but asks the upstream forstream: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:
response.completed, but Native Codex does not complete the turnWe 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) tohttps://opencode.ai/zen/go/v1/responsesusing@ai-sdk/openai. The same matrix assigns models such as DeepSeek V4 Flash to/v1/chat/completionsusing@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
devbase, however, OpenCodex's built-inopencode-goregistry entry declares the provider-wide adapter asopenai-chatand has nomodelWireDefaultsentry forgpt-5.6-luna. Without an explicitmodelAdaptersoverride, the wire resolver therefore keeps the built-in route onopenai-chat, whose request builder posts to${baseUrl}/chat/completionsinstead 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, andgpt-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-inopencode-goregistry mapping.The controls narrow the failure boundary:
stream:falseandstream:truecalls to the affected Responses upstream complete;response.completedin 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 .-> CWhy 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_VERSIONis stillnull, and OpenCodex has not yet verified a stable Bun release for the relevant async-stream cancellation/backpressure path.Simply allowing Linux to enter
eager-relaywould 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:
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:
The field should be rejected when the effective wire is not
openai-responsesand 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:streamvalue tofalse;response.created, oneresponse.output_item.doneper output item, the originalcompleted/failed/incompleteterminal, and one[DONE]; for WebSocket, emit the equivalent JSON lifecycle events without an SSE sentinel;The shared validator should require:
nullor an array;objecteither omitted under the documented compatibility policy or equal toresponse;statusequal tocompleted,failed, orincomplete;outputarray whose entries are objects with non-empty stringtypefields;usageeither 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:falseand 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.tssrc/config.tssrc/server/auth-cors.tssrc/providers/registry.tsand the Responses route setupcompletedevent.src/server/responses/core.tsand the Responses WebSocket bridgeNo 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
truedisables 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 befalse. 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
autoand 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:
openai-responsesproviders;Out of scope:
opencode-gowire mapping forgpt-5.6-luna;streamMode;tee()or upgrading bundled Bun;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
modelResponsesUpstreamStreaming=falseselected 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.Non-blocking follow-up risks
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:
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.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:
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=200andtransport_phase=terminal_sseabove are OpenCodex request-history fields, not an independently capturedContent-Typeheader 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:
stream:false, directstream:true, and four consecutive Native Codex turns complete without OpenCodex.dev, and related upstream work were reviewed; no released general fix for the default POSIX tee path was found.Implementation verification:
stream, and completed/failed/incomplete JSON produces the correct HTTP and WebSocket events.nullusage is accepted; malformed JSON, invalid usage, unknown status, oversize/stall, unexpected content types, and unexpected SSE fail without replay.Required live validation before requesting review:
Checklist
maintainer-sponsored; external contributors cannot satisfy this repository gate themselves.한국어 번역 — 접근성 제공
요약
이 PR은 사용자 정의
openai-responsesprovider가 정상적인 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가 실제로 받은 결과가 일치하지 않는 현상이 재현되었습니다.
response.completed를 확인하지만 Native Codex는 turn을 완료하지 못함같은 현상을 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의 정확한
devbase에서 OpenCodex의 내장opencode-goregistry entry는 provider-wide adapter를openai-chat으로 선언하고,gpt-5.6-luna용modelWireDefaultsentry를 두지 않습니다. 명시적인modelAdaptersoverride가 없으면 wire resolver는 내장 경로를openai-chat으로 유지하고, request builder는 Luna에 문서화된 Responses endpoint가 아니라${baseUrl}/chat/completions로 전송합니다.최초 재현에서는 이 별도의 내장 mapping 누락을 우회하기 위해 OpenCode Go base URL,
gpt-5.6-luna및adapter: "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-goregistry mapping은 변경하지 않습니다.대조 결과는 실패 경계를 다음처럼 좁힙니다.
stream:false및stream:trueResponses 요청은 완료됩니다.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 완료 .-> CLinux eager relay 대신 bounded JSON을 사용하는 이유
OpenCodex는 bounded single-reader eager relay를 보수적인 runtime/platform gate 뒤에 두고 있습니다. Bundled Bun은 여전히 1.3.14이고
MIN_FIXED_BUN_VERSION도null입니다. OpenCodex가 관련 async-stream cancellation/backpressure 수정의 포함 여부를 확인한 Bun 안정 버전도 아직 없습니다.Linux를 단순히
eager-relay대상으로 추가하면 이 안전 결정을 해결하는 것이 아니라 우회하게 됩니다. 또한 Issue #820에서 이미 계획한 runtime-qualified, protocol-safe one-reader 작업과 범위가 겹칩니다.이번 제안은 OpenCodex에 이미 존재하는, 범위가 더 좁은 경로를 재사용합니다.
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를 모두 해석합니다.
우선순위는 다음과 같습니다.
Effective wire가
openai-responses가 아니거나 effective forward-auth provider이면 이 field를 거부하여 canonical OpenAI transport contract가 바뀌지 않게 합니다.응답 처리
Opt-in model에서 client가
stream:true를 요청하면 OpenCodex는 다음처럼 처리합니다.stream값만false로 변경합니다.response.created, output item별response.output_item.done, 원래의completed/failed/incompleteterminal 및 하나의[DONE]을 보냅니다. WebSocket에서는 SSE sentinel 없이 동등한 JSON lifecycle event를 보냅니다.Shared validator는 다음을 확인해야 합니다.
null이나 array가 아닌 top-level objectobjectfield가 문서화된 compatibility 정책에 따라 없거나response와 같음status가completed,failed,incomplete중 하나output이 array이고 각 entry가 비어 있지 않은 stringtype을 가진 objectusage가 없거나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.tssrc/config.tssrc/server/auth-cors.tssrc/providers/registry.ts및 Responses route setupcompletedevent로 바뀌지 않게 함src/server/responses/core.ts와 Responses WebSocket bridge이 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 설정을 구현하는 작업을 찾지 못했습니다.
범위
포함:
openai-responsesprovider제외:
opencode-go의gpt-5.6-lunawire mapping 변경streamMode변경tee()제거 또는 bundled Bun upgrade구현 중 진단 정보를 추가하더라도 요청·응답 본문을 포함하지 않아야 합니다. 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 전 반드시 해결할 항목
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로 분리하는 편이 적절합니다.비차단 후속 위험
처리 방침
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입니다.
이 대조군은 하나의 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+는 첫 요청 기준 상대 시간입니다.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을 정규화하면 다음과 같습니다.
Linux server-side row와 macOS client rollout은 서로 다른 재현이며 하나의 cross-log correlation처럼 제시하지 않습니다. 실패 요청의 원본 response header와 body는 보존되지 않았습니다. 따라서 위의
status=200및transport_phase=terminal_sse는 OpenCodex request-history field이며,Content-Typeheader 또는 client-facing SSE 전체 body를 독립적으로 캡처했다는 의미가 아닙니다. Relay가 없는 topology와 함께 보면, 이 기록은 개인 relay를 재현 또는 acceptance contract에 포함하지 않으면서 OpenCodex client-delivery 경계의 실패를 뒷받침합니다.검증
이 Draft branch의 구현 및 repository 수준 검증은 완료했습니다. 운영 서비스는 변경하지 않았고 새 fallback을 실제 provider에 적용하는 live canary는 아직 수행하지 않았습니다.
진단 근거:
stream:false, directstream:true, 하나의 대화에서 4회 연속 Native Codex turn이 모두 완료됨dev및 관련 upstream 작업을 검토했지만 정식 릴리스에서 기본 POSIX tee 경로를 일반적으로 해결한 수정은 찾지 못함구현 검증:
streamfield만 바뀌고 completed/failed/incomplete JSON이 HTTP와 WebSocket에서 올바른 event로 변환됨nullusage는 허용하고 malformed JSON, invalid usage, unknown status, oversize/stall, 예상 밖 content type 및 SSE는 replay 없이 실패함리뷰 요청 전 필수 실환경 검증:
체크리스트
maintainer-sponsoredlabel이 필요하며 외부 기여자가 직접 충족할 수 없습니다.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
Bug Fixes
completed,failed, andincompleteare preserved accurately.Documentation