diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index 86b6cbfa5fc..9e62d7e2017 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -13,6 +13,7 @@ description: リスナー、リモート アクセス、アドミッション | `hostname?` | `string` | `"127.0.0.1"` |バインドアドレス。非ループバック バインドには `OPENCODEX_API_AUTH_TOKEN` が必要です。 | | `proxy?` | `string` | — |送信 HTTP(S) プロキシ URL または `${ENV_VAR}`。これらの変数が設定されていない場合にのみ、`HTTP_PROXY` / `HTTPS_PROXY` に適用されます。ループバックは `NO_PROXY` に残ります。 | | `emptyCompletionRetry?` | `boolean` | `false` | テキストもツール呼び出しもない Responses ターンを、ターミナルイベント前にストリームが終了した場合も含め、同一リクエストで 1 回再試行するよう明示的に有効化します。再試行は課金対象になる場合があります。`OCX_EMPTY_COMPLETION_RETRY=0` で設定を変更せず無効化できます。combo と routed-compaction turn は対象外です。 | +| `dropCodexSafetyBuffering?` | `boolean` | `false` | Codex Responses パススルーから Codex の safety-buffering ヒントを除去します。対象は `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` 応答ヘッダー、`safety_buffering` 型の `response.metadata` SSE イベント、およびその他の SSE イベントにある `safety_buffering` フィールドです。Codex TUI はこれらを、既定の操作でセッションをより弱いモデルに切り替える「より高速なモデルで再試行」プロンプトとして表示します。その他の `x-codex-*` ヘッダーと SSE イベントの内容は、そのフィールドの除去を除いて変更せずに転送されます。既定ではオフです。 | | `stallTimeoutSec?` | `number` | `300` | `response.incomplete` より前にアップストリーム データがない秒数。最小 1。 | `connectTimeoutMs?` | `number` | `200000` |試行ごとの DNS/TCP/TLS/最終ヘッダーの期限。本体が生成される前に終了します。 | | `shutdownTimeoutMs?` | `number` | `5000` |アクティブなターンが中止される前の正常な排出期限。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 879b9d40a65..b95edc6bc63 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -13,6 +13,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | `hostname?` | `string` | `"127.0.0.1"` | 바인드 주소입니다. 루프백이 아닌 바인드에는 `OPENCODEX_API_AUTH_TOKEN`이 필요합니다. | | `proxy?` | `string` | — | 송신용 HTTP(S) 프록시 URL 또는 `${ENV_VAR}`입니다. 해당 변수가 비어 있을 때만 `HTTP_PROXY` / `HTTPS_PROXY`에 적용되며, 루프백은 `NO_PROXY`에 그대로 남습니다. | | `emptyCompletionRetry?` | `boolean` | `false` | 텍스트나 도구 호출이 없는 Responses 턴을, 터미널 이벤트 전에 스트림이 종료된 경우를 포함해 동일한 요청으로 한 번 재시도하도록 선택합니다. 재시도에는 비용이 발생할 수 있습니다. `OCX_EMPTY_COMPLETION_RETRY=0`은 설정을 바꾸지 않고 비활성화하며, combo 및 routed-compaction turn은 제외됩니다. | +| `dropCodexSafetyBuffering?` | `boolean` | `false` | Codex Responses 패스스루에서 Codex safety-buffering 힌트를 제거합니다. 대상은 `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` 응답 헤더, `safety_buffering` 형식의 `response.metadata` SSE 이벤트, 다른 SSE 이벤트의 `safety_buffering` 필드입니다. Codex TUI는 이 힌트를 기본 동작이 세션을 더 약한 모델로 전환하는 “더 빠른 모델로 재시도” 프롬프트로 표시합니다. 다른 `x-codex-*` 헤더와 다른 모든 SSE 이벤트 내용은 해당 필드 제거를 제외하고 그대로 전달됩니다. 기본적으로 꺼져 있습니다. | | `stallTimeoutSec?` | `number` | `300` | 업스트림 데이터가 없을 때 `response.incomplete`가 되기까지의 초 수입니다. 최소 1입니다. | | `connectTimeoutMs?` | `number` | `200000` | 시도별 DNS/TCP/TLS/최종 헤더 기한입니다. 본문 생성 전에 끝납니다. | | `shutdownTimeoutMs?` | `number` | `5000` | 진행 중인 turn을 중단하기 전에 허용하는 정상 종료 드레인 기한입니다. | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index c6994f74a2e..98bb6605386 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -15,6 +15,7 @@ runs helper features around provider requests. | `proxy?` | `string` | — | Outbound HTTP(S) proxy URL, `${ENV_VAR}`, or `"auto"`. Applied to `HTTP_PROXY` / `HTTPS_PROXY` only when those variables are unset; loopback remains in `NO_PROXY`. `"auto"` reads the Windows system proxy (WinINET `ProxyEnable`/`ProxyServer`, `https=` then `http=` entry) once at process start and logs the host it chose. On other platforms, or when the system proxy is off, SOCKS-only, or unreadable, it uses direct egress and says so. PAC/WPAD and live proxy changes are not followed; restart the service after changing the system proxy. | | `noProxy?` | `string \| string[]` | — | Hosts that bypass `proxy`, merged with inherited `NO_PROXY` and loopback entries. A string may use comma-separated `NO_PROXY` syntax or `${ENV_VAR}`. | | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | +| `dropCodexSafetyBuffering?` | `boolean` | `false` | Strip the Codex safety-buffering hints from Codex Responses passthroughs: the `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` response headers, `response.metadata` SSE events of type `safety_buffering`, and the `safety_buffering` field on other SSE events. The Codex TUI renders those hints as a "retry with a faster model" prompt whose default action switches the session to the weaker model, which an unattended session can trigger by accident. Other `x-codex-*` headers and all other SSE event content pass through unchanged except for removal of that field. Off by default. | | `stallTimeoutSec?` | `number` | `300` | Seconds without upstream data before `response.incomplete`. Minimum 1. | | `oauthOpenBrowser?` | `boolean` | `true` | Whether a login may open a browser on the machine running the proxy. Absent and `true` both open, so an existing install is unchanged; only an explicit `false` declines. Decline when you need the authorization link in a different browser profile, or when the dashboard is not on the proxy's machine — the login still starts and the URL is still returned and displayed. `POST /api/oauth/login` and `POST /api/codex-auth/login` accept a per-request `openBrowser` boolean that overrides this, and the dashboard exposes the same choice beside the login button. Device-code flows never open a browser either way. | | `connectTimeoutMs?` | `number` | `200000` | Per-attempt DNS/TCP/TLS/final-header deadline; it ends before body generation. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 306534a3e16..e3eb376c345 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -14,6 +14,7 @@ description: Listener, удалённый доступ, admission key, тайм | `hostname?` | `string` | `"127.0.0.1"` | Адрес bind'а. Не-loopback bind требует `OPENCODEX_API_AUTH_TOKEN`. | | `proxy?` | `string` | — | URL исходящего HTTP(S)-прокси или `${ENV_VAR}`. Применяется к `HTTP_PROXY` / `HTTPS_PROXY` только когда эти переменные не заданы; loopback всегда остаётся в `NO_PROXY`. | | `emptyCompletionRetry?` | `boolean` | `false` | Явно включает один идентичный повтор Responses, если в turn нет ни текста, ни tool call, включая случай, когда stream завершается до terminal event. Повтор может тарифицироваться. `OCX_EMPTY_COMPLETION_RETRY=0` отключает его без изменения config; combo и routed-compaction turn исключены. | +| `dropCodexSafetyBuffering?` | `boolean` | `false` | Удаляет подсказки Codex safety-buffering из passthrough-ответов Codex Responses: заголовки `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model`, SSE-события `response.metadata` типа `safety_buffering` и поле `safety_buffering` в других SSE-событиях. Codex TUI отображает их как предложение повторить запрос с более быстрой моделью, действие по умолчанию в котором переключает сессию на более слабую модель. Остальные заголовки `x-codex-*` и содержимое других SSE-событий передаются без изменений, кроме удаления этого поля. По умолчанию выключено. | | `stallTimeoutSec?` | `number` | `300` | Секунды без upstream-данных до `response.incomplete`. Минимум 1. | | `connectTimeoutMs?` | `number` | `200000` | Дедлайн одной попытки DNS/TCP/TLS/final-header; он завершается до генерации тела ответа. | | `shutdownTimeoutMs?` | `number` | `5000` | Дедлайн graceful-drain до принудительного прерывания активных turn'ов. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index bee79423983..9e936c5c5d0 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -14,6 +14,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | `hostname?` | `string` | `"127.0.0.1"` | 绑定地址。非回环绑定需要 `OPENCODEX_API_AUTH_TOKEN`。 | | `proxy?` | `string` | — | 出站 HTTP(S) 代理 URL,或 `${ENV_VAR}`。仅当 `HTTP_PROXY` / `HTTPS_PROXY` 未设置时才会应用;回环地址始终保留在 `NO_PROXY` 中。 | | `emptyCompletionRetry?` | `boolean` | `false` | 显式启用:当 Responses turn 既无文本也无工具调用时,使用相同请求重试一次,包括流在终止事件之前结束的情况。重试可能产生费用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不修改配置的情况下禁用;combo 与 routed-compaction turn 不参与。 | +| `dropCodexSafetyBuffering?` | `boolean` | `false` | 从 Codex Responses 透传响应中移除 Codex safety-buffering 提示:`x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` 响应头、类型为 `safety_buffering` 的 `response.metadata` SSE 事件,以及其他 SSE 事件中的 `safety_buffering` 字段。Codex TUI 会将这些提示显示为“使用更快模型重试”的提示框,其默认操作会把会话切换到较弱的模型。其他 `x-codex-*` 响应头和其他所有 SSE 事件内容均保持不变,但会移除该字段。默认关闭。 | | `stallTimeoutSec?` | `number` | `300` | 在上游没有数据之前可等待的秒数,超过后返回 `response.incomplete`。最小值为 1。 | | `connectTimeoutMs?` | `number` | `200000` | 每次尝试的 DNS/TCP/TLS/最终响应头截止时间;它在正文生成之前结束。 | | `shutdownTimeoutMs?` | `number` | `5000` | 优雅停机截止时间,超过后会中止仍在进行中的请求。 | diff --git a/src/config.ts b/src/config.ts index fdcda9547c5..cd0641feb25 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1125,6 +1125,8 @@ const configSchema = z.object({ configRebaseProvenance: z.unknown().optional(), // A retry can be billable, so absence and malformed hand edits both stay off. emptyCompletionRetry: z.boolean().optional().catch(false), + // Header suppression changes what Codex sees, so absence and malformed edits stay off. + dropCodexSafetyBuffering: z.boolean().optional().catch(false), // A malformed hand edit must not silently stop opening the browser: fall back // to undefined, which resolves to the historical auto-open behavior. oauthOpenBrowser: z.boolean().optional().catch(undefined), @@ -2613,6 +2615,14 @@ function emptyCompletionRetryError(value: unknown): string | null { return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted"; } +function dropCodexSafetyBufferingError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "dropCodexSafetyBuffering")) return null; + const enabled = raw.dropCodexSafetyBuffering; + if (enabled === undefined || typeof enabled === "boolean") return null; + return "schema_invalid: dropCodexSafetyBuffering: must be a boolean or omitted"; +} + function oauthOpenBrowserError(value: unknown): string | null { const raw = rawConfigRecord(value); if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null; @@ -2718,6 +2728,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? codexQuotaAutoRefreshError(value) ?? codexAccountPickerEnabledError(value) ?? emptyCompletionRetryError(value) + ?? dropCodexSafetyBufferingError(value) ?? oauthOpenBrowserError(value) ?? runtimeRoleError(value) ?? remoteGuiConfigError(value) @@ -3684,6 +3695,7 @@ export function getDefaultConfig(): OcxConfig { return { port: 10100, emptyCompletionRetry: false, + dropCodexSafetyBuffering: false, managementUsageMaxReadBytes: 64 * 1024 * 1024, appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024), // Fresh/re-initialized configs are already written in the current three-tier diff --git a/src/server/index.ts b/src/server/index.ts index aedd6bf2369..c6ce73b2f16 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -142,6 +142,7 @@ import { } from "./relay"; export { consumeForInspection, + codexSafetyBufferingFilterOptions, relaySseWithFailedTail, relaySseWithHeartbeat, relayWithAbort, diff --git a/src/server/relay-eager.ts b/src/server/relay-eager.ts index 655997b813d..a6e60d3d02e 100644 --- a/src/server/relay-eager.ts +++ b/src/server/relay-eager.ts @@ -26,6 +26,7 @@ import { adapterEofIncompleteFrame, + type CodexSafetyBufferingFilterOptions, createSseTerminalOutputBoundary, doneFrame, failedTailFrame, @@ -83,6 +84,8 @@ export type EagerRelayOptions = { postCancelDrainBytes?: number; /** Injectable clock for tests. */ now?: () => number; + /** Client output boundary filters (Codex safety-buffering hints). */ + terminalBoundary?: CodexSafetyBufferingFilterOptions; }; const DEFAULT_MAX_QUEUE_BYTES = 8 * 1024 * 1024; @@ -111,7 +114,7 @@ export function relaySseEagerBounded( const terminalEncoder = new TextEncoder(); const adapterEofFrame = adapterEofIncompleteFrame(terminalEncoder); const terminalSentinel = doneFrame(terminalEncoder); - const terminalBoundary = createSseTerminalOutputBoundary(); + const terminalBoundary = createSseTerminalOutputBoundary(opts?.terminalBoundary); const activeRewrite: SseBlockRewrite | undefined = hooks.rewriteBlocks ?? (hooks.rewritePayload ? payloadRewriteAsBlockRewrite(hooks.rewritePayload) : undefined); const encodeFailedTail = (error: unknown): Uint8Array | null => { diff --git a/src/server/relay.ts b/src/server/relay.ts index 60b57ea025e..d840b2e59ca 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -162,7 +162,10 @@ export type SseTerminalOutputBoundary = { * terminal, and drops every later block/byte. A premature [DONE] is held until * a terminal arrives so clean EOF can synthesize one terminal and one sentinel. */ -export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { +export function createSseTerminalOutputBoundary( + options?: CodexSafetyBufferingFilterOptions, +): SseTerminalOutputBoundary { + const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true; const decoder = new TextDecoder(); const encoder = new TextEncoder(); const framer = new BoundedSseFrameBuffer(MAX_INSPECTION_SSE_FRAME_BYTES); @@ -181,6 +184,10 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { const payload = sseDataPayload(decoder.decode(frame.block)); const isDone = payload === "[DONE]"; const parsed = payload === null ? undefined : parseSsePayload(payload); + const safetyBuffering = dropSafetyBuffering && parsed !== undefined + ? codexSafetyBufferingBlockAction(parsed) + : "keep"; + if (safetyBuffering === "drop") continue; const policyError = parsed !== undefined && isPolicyRewriteType(parsed) ? cyberPolicyTerminalError(parsed) : undefined; @@ -189,7 +196,9 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { decoder.decode(frame.block), policyFailurePayload(policyError, parsed), )) - : frame.block; + : safetyBuffering === "strip" + ? encoder.encode(stripCodexSafetyBufferingField(decoder.decode(frame.block), parsed)) + : frame.block; if (isDone) { done = true; if (responsesTerminal) { @@ -260,10 +269,11 @@ export function relaySseWithFailedTail( body: ReadableStream, upstream: AbortController, onClientGone?: (reason?: unknown) => void, + boundaryOptions?: CodexSafetyBufferingFilterOptions, ): ReadableStream { const reader = body.getReader(); const encoder = new TextEncoder(); - const terminalBoundary = createSseTerminalOutputBoundary(); + const terminalBoundary = createSseTerminalOutputBoundary(boundaryOptions); let closed = false; const relayChunk = ( controller: ReadableStreamDefaultController, @@ -438,6 +448,29 @@ function isPolicyRewriteType(parsed: unknown): boolean { return type === "response.failed" || type === "response.incomplete" || type === "error"; } +/** + * Codex emits its safety-buffering hint in the SSE body as well as in headers: + * a `response.metadata` event whose `metadata.type` is `safety_buffering`, or a + * `safety_buffering` field on another event. The metadata event is dropped whole; + * the field is stripped so the carrying event is otherwise relayed unchanged. + */ +function codexSafetyBufferingBlockAction(parsed: unknown): "keep" | "drop" | "strip" { + const root = asJsonRecord(parsed); + if (!root) return "keep"; + if (root.type === "response.metadata") { + const metadata = asJsonRecord(root.metadata); + if (metadata?.type === "safety_buffering") return "drop"; + } + return Object.hasOwn(root, "safety_buffering") ? "strip" : "keep"; +} + +function stripCodexSafetyBufferingField(block: string, parsed: unknown): string { + const root = asJsonRecord(parsed); + if (!root) return block; + const { safety_buffering: _safetyBuffering, ...rest } = root; + return replaceSseDataPayload(block, JSON.stringify(rest)); +} + function rewritePolicyTerminalBlock(block: string, payload: string): string { const newline = block.includes("\r\n") ? "\r\n" : "\n"; const rewritten = replaceSseDataPayload(block, payload); @@ -1422,7 +1455,31 @@ export function consumeForResponseLogMetadata( * body makes the caller (Codex) double-decode / truncate → "stream error" on every gpt passthrough. * Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim. */ -export function sanitizePassthroughHeaders(upstream: Headers): Headers { +export const CODEX_SAFETY_BUFFERING_HEADERS = [ + "x-codex-safety-buffering-enabled", + "x-codex-safety-buffering-faster-model", +] as const; + +const CODEX_SAFETY_BUFFERING_HEADER_SET: ReadonlySet = new Set(CODEX_SAFETY_BUFFERING_HEADERS); + +export interface CodexSafetyBufferingFilterOptions { + /** + * Drop Codex safety-buffering hints: the `x-codex-safety-buffering-*` response + * headers and the `safety_buffering` SSE metadata event / field. Absent and + * `false` relay everything unchanged. + */ + dropCodexSafetyBuffering?: boolean; +} + +/** Resolve the passthrough header policy from the loaded config (absent means "forward everything"). */ +export function codexSafetyBufferingFilterOptions( + config: { dropCodexSafetyBuffering?: boolean }, +): CodexSafetyBufferingFilterOptions { + return { dropCodexSafetyBuffering: config.dropCodexSafetyBuffering === true }; +} + +export function sanitizePassthroughHeaders(upstream: Headers, options?: CodexSafetyBufferingFilterOptions): Headers { + const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true; const DROP = new Set([ "content-encoding", "content-length", @@ -1439,7 +1496,10 @@ export function sanitizePassthroughHeaders(upstream: Headers): Headers { ]); const out = new Headers(); upstream.forEach((value, key) => { - if (!DROP.has(key.toLowerCase())) out.set(key, value); + const lower = key.toLowerCase(); + if (DROP.has(lower)) return; + if (dropSafetyBuffering && CODEX_SAFETY_BUFFERING_HEADER_SET.has(lower)) return; + out.set(key, value); }); return out; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 9d0eea0d764..e1999179683 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -304,6 +304,7 @@ import { markEagerRelaySseResponse, markNativePassthroughSseResponse, relaySseWithFailedTail, + codexSafetyBufferingFilterOptions, relayWithAbort, sanitizePassthroughHeaders, } from "../relay"; @@ -3850,6 +3851,9 @@ async function handleResponsesInner( let hostAdmissionLease = pendingHostAdmissionLease; pendingHostAdmissionLease = null; try { + const codexSafetyBufferingOptions = isCanonicalOpenAiForwardProvider(route.provider) + ? codexSafetyBufferingFilterOptions(config) + : undefined; const imageGenCallAliases = route.provider.authMode === "forward" ? new Map() : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); @@ -4732,7 +4736,7 @@ async function handleResponsesInner( } break; } - const headers = sanitizePassthroughHeaders(upstreamResponse.headers); + const headers = sanitizePassthroughHeaders(upstreamResponse.headers, codexSafetyBufferingOptions); const resolvedModel = headers.get("openai-model")?.trim(); if (resolvedModel && !logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel; if (isUsageDebugEnabled()) { @@ -4824,7 +4828,7 @@ async function handleResponsesInner( return new Response(upstreamResponse.body, { status: upstreamResponse.status, statusText: upstreamResponse.statusText, - headers: sanitizePassthroughHeaders(upstreamResponse.headers), + headers: sanitizePassthroughHeaders(upstreamResponse.headers, codexSafetyBufferingOptions), }); } if (!upstreamResponse.ok) { @@ -5027,6 +5031,7 @@ async function handleResponsesInner( onDone: () => unregisterTurn(turnAc), }, { clientGoneSignal: options.abortSignal, + terminalBoundary: codexSafetyBufferingOptions, ...(inlineEagerRewrite ? { rewriteBudget: translatorBudget } : {}), }); // When selected, this relay closes response.completed even if upstream @@ -5110,7 +5115,8 @@ async function handleResponsesInner( const rewrittenBody = clientBlockRewrite !== undefined ? relaySseWithBlockRewrite(nativeBody, clientBlockRewrite, translatorBudget) : nativeBody; - const clientBody = relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason)); + const clientBody = relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason), + codexSafetyBufferingOptions); return markNativePassthroughSseResponse(new Response(clientBody, { status: upstreamResponse.status, headers, @@ -5238,7 +5244,7 @@ async function handleResponsesInner( } throw error; } - const sseHeaders = sanitizePassthroughHeaders(headers); + const sseHeaders = sanitizePassthroughHeaders(headers, codexSafetyBufferingOptions); sseHeaders.set("content-type", "text/event-stream"); sseHeaders.set("cache-control", "no-store"); return new Response(stream, { diff --git a/src/types/config.ts b/src/types/config.ts index 8cf12469795..4d2c63fdf14 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -335,6 +335,16 @@ export interface OcxConfig { client?: OcxClientConnectionConfig; /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */ emptyCompletionRetry?: boolean; + /** + * Drop the Codex safety-buffering hints from a Codex Responses passthrough: the + * `x-codex-safety-buffering-*` response headers, `response.metadata` SSE events of + * type `safety_buffering`, and the `safety_buffering` field on other SSE events. + * The Codex TUI turns those hints into a "retry with a faster model" prompt whose + * default action switches the session to a weaker model, so an unattended session + * can lose its model to a stray keystroke. Absent and `false` relay everything + * unchanged. + */ + dropCodexSafetyBuffering?: boolean; /** * Whether a login may open a browser on the machine running the proxy. * diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index 8f9648e9511..91603f3f773 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -93,6 +93,55 @@ test("canonical forward providers normalize trailing slashes and let the pool ov expect(request.headers["chatgpt-account-id"]).toBe("runtime-account"); }); +test("noncanonical Responses preserves provider-owned safety-buffering hints", async () => { + const upstream = [ + 'event: response.created\ndata: {"type":"response.created","response":{"id":"resp_custom"},"safety_buffering":{"provider_owned":true}}\n\n', + 'event: response.metadata\ndata: {"type":"response.metadata","metadata":{"type":"safety_buffering","provider_owned":true}}\n\n', + 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_custom","status":"completed","output":[]}}\n\n', + "data: [DONE]\n\n", + ].join(""); + const savedFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(upstream, { headers: { + "content-type": "text/event-stream", + "x-codex-safety-buffering-enabled": "provider-owned", + "x-codex-safety-buffering-faster-model": "provider-model", + } })) as typeof fetch; + try { + for (const providerConfig of [ + { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key" as const, + apiKey: "fixture-key", + }, + { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "forward" as const, + headers: { authorization: "Bearer provider-static" }, + }, + ]) { + const config = { + port: 0, + defaultProvider: "fixture", + dropCodexSafetyBuffering: true, + providers: { fixture: providerConfig }, + } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", stream: true, input: "ping" }), + }), config, { model: "", provider: "" }); + + expect(response.headers.get("x-codex-safety-buffering-enabled")).toBe("provider-owned"); + expect(response.headers.get("x-codex-safety-buffering-faster-model")).toBe("provider-model"); + expect(await response.text()).toBe(upstream); + } + } finally { + globalThis.fetch = savedFetch; + } +}); + test("noncanonical pool-required providers use only their configured static credentials", () => { const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", diff --git a/tests/responses/passthrough-headers.test.ts b/tests/responses/passthrough-headers.test.ts index 8018e779068..2cd52616096 100644 --- a/tests/responses/passthrough-headers.test.ts +++ b/tests/responses/passthrough-headers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { sanitizePassthroughHeaders } from "../../src/server"; +import { codexSafetyBufferingFilterOptions, sanitizePassthroughHeaders } from "../../src/server"; +import { createSseTerminalOutputBoundary } from "../../src/server/relay"; describe("passthrough header sanitization (RC5 / F4)", () => { test("content-type: text/event-stream survives sanitization", () => { @@ -43,3 +44,75 @@ describe("passthrough header sanitization (RC5 / F4)", () => { expect(sanitized.get("content-type")).toBe("text/event-stream"); }); }); + +describe("codex safety-buffering hint headers", () => { + const upstream = () => new Headers({ + "content-type": "text/event-stream", + "x-codex-safety-buffering-enabled": "true", + "X-Codex-Safety-Buffering-Faster-Model": "gpt-5.6-luna", + "x-codex-primary-used-percent": "12", + "openai-model": "gpt-6-astra", + }); + + test("forwarded verbatim by default and when the option is off", () => { + for (const options of [undefined, {}, { dropCodexSafetyBuffering: false }]) { + const sanitized = sanitizePassthroughHeaders(upstream(), options); + expect(sanitized.get("x-codex-safety-buffering-enabled")).toBe("true"); + expect(sanitized.get("x-codex-safety-buffering-faster-model")).toBe("gpt-5.6-luna"); + } + }); + + test("dropped case-insensitively when opted in, other x-codex headers survive", () => { + const sanitized = sanitizePassthroughHeaders(upstream(), { dropCodexSafetyBuffering: true }); + expect(sanitized.has("x-codex-safety-buffering-enabled")).toBe(false); + expect(sanitized.has("x-codex-safety-buffering-faster-model")).toBe(false); + expect(sanitized.get("x-codex-primary-used-percent")).toBe("12"); + expect(sanitized.get("openai-model")).toBe("gpt-6-astra"); + expect(sanitized.get("content-type")).toBe("text/event-stream"); + }); + + test("codexSafetyBufferingFilterOptions only enables the drop on an explicit true", () => { + expect(codexSafetyBufferingFilterOptions({})).toEqual({ dropCodexSafetyBuffering: false }); + expect(codexSafetyBufferingFilterOptions({ dropCodexSafetyBuffering: false })) + .toEqual({ dropCodexSafetyBuffering: false }); + expect(codexSafetyBufferingFilterOptions({ dropCodexSafetyBuffering: true })) + .toEqual({ dropCodexSafetyBuffering: true }); + }); +}); + +describe("Codex safety-buffering SSE hints at the client output boundary", () => { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const frames = [ + 'event: response.created\ndata: {"type":"response.created","response":{"id":"resp_1"},"safety_buffering":{"retry_model":"gpt-5.6-luna"}}\n\n', + 'event: response.metadata\ndata: {"type":"response.metadata","metadata":{"type":"safety_buffering","retry_model":"gpt-5.6-luna"}}\n\n', + 'event: response.metadata\ndata: {"type":"response.metadata","metadata":{"type":"other","turn":1}}\n\n', + 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"hi"}\n\n', + 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}\n\n', + ]; + const relay = (options?: { dropCodexSafetyBuffering?: boolean }): string => { + const boundary = createSseTerminalOutputBoundary(options); + let out = ""; + for (const frame of frames) out += decoder.decode(boundary.feed(encoder.encode(frame))); + out += decoder.decode(boundary.finish()); + boundary.dispose(); + return out; + }; + + test("relayed verbatim by default and when the option is off", () => { + for (const options of [undefined, {}, { dropCodexSafetyBuffering: false }]) { + expect(relay(options)).toBe(frames.join("")); + } + }); + + test("metadata event dropped and field stripped when opted in, other events untouched", () => { + const out = relay({ dropCodexSafetyBuffering: true }); + expect(out).not.toContain("safety_buffering"); + expect(out).not.toContain("gpt-5.6-luna"); + expect(out).toContain('data: {"type":"response.created","response":{"id":"resp_1"}}'); + expect(out).toContain(frames[2]); + expect(out).toContain(frames[3]); + expect(out).toContain(frames[4]); + expect(out.match(/^event: /gm)).toHaveLength(4); + }); +}); diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 024ca4e65a4..ba129b85446 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -537,6 +537,19 @@ describe("opencodex config defaults", () => { }); }); + test("codex safety-buffering header drop is an explicit top-level opt-in", () => { + const defaults = getDefaultConfig(); + expect(defaults.dropCodexSafetyBuffering).toBe(false); + expect(validateConfigCandidate({ ...defaults, dropCodexSafetyBuffering: true })).toMatchObject({ + ok: true, + config: { dropCodexSafetyBuffering: true }, + }); + expect(validateConfigCandidate({ ...defaults, dropCodexSafetyBuffering: "yes" })).toMatchObject({ + ok: false, + error: expect.stringContaining("dropCodexSafetyBuffering"), + }); + }); + test("usage and MCP config overrides change the effective bound while defaults remain compatible", () => { const defaults = getDefaultConfig(); expect(defaults.managementUsageMaxReadBytes).toBe(64 * 1024 * 1024);