From 64e239179773cbfb479b1aca4e08c946b7637d29 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 17:58:51 +0800 Subject: [PATCH 1/4] add opt-in responses background mode guardrails --- docs/development/CONFIG_FIELDS.md | 3 ++ docs/reference/public-api.md | 6 ++- index.ts | 2 + lib/config.ts | 18 ++++++++ lib/request/fetch-helpers.ts | 5 ++- lib/request/request-transformer.ts | 59 +++++++++++++++++++++++--- lib/schemas.ts | 1 + lib/types.ts | 1 + test/fetch-helpers.test.ts | 12 ++++++ test/plugin-config.test.ts | 25 +++++++++++ test/public-api-contract.test.ts | 12 ++++++ test/request-transformer.test.ts | 68 ++++++++++++++++++++++++++++++ 12 files changed, 203 insertions(+), 9 deletions(-) diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index f345e793..5349bcc0 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -76,10 +76,13 @@ Used only for host plugin mode through the host runtime config file. | `sessionRecovery` | `true` | | `autoResume` | `true` | | `responseContinuation` | `false` | +| `backgroundResponses` | `false` | | `proactiveRefreshGuardian` | `true` | | `proactiveRefreshIntervalMs` | `60000` | | `proactiveRefreshBufferMs` | `300000` | +`backgroundResponses` is an opt-in compatibility switch for Responses API `background: true` requests. When enabled, those requests become stateful (`store=true`) instead of following the default stateless Codex routing. + ### Storage / Sync | Key | Default | diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index a31a12c1..db0e1b84 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -67,7 +67,11 @@ Positional signatures are preserved for backward compatibility. The request-transform layer intentionally preserves and/or normalizes modern Responses API fields that callers may already send through the host SDK. -- The plugin preserves `previous_response_id` when explicitly provided and may auto-fill it from plugin continuation state when `pluginConfig.responseContinuation` is enabled, maintains `text.format` when verbosity defaults are applied, and honors `prompt_cache_retention` from the request body before falling back to `providerOptions.openai.promptCacheRetention` or user config defaults. +- `previous_response_id` is preserved when explicitly provided and may be auto-filled from plugin continuation state when `pluginConfig.responseContinuation` is enabled. +- `background` is typed as a first-class request field. It remains disabled by default and only passes through when `pluginConfig.backgroundResponses` or `CODEX_AUTH_BACKGROUND_RESPONSES=1` explicitly enables the stateful compatibility path. +- `text.format` is preserved when verbosity defaults are applied, so structured-output contracts continue to flow through untouched. +- `prompt_cache_retention` is preserved from the request body and can fall back to `providerOptions.openai.promptCacheRetention` or user config defaults. +- Background-mode requests force `store=true`, keep caller-supplied input item IDs, and skip stateless-only defaults such as `reasoning.encrypted_content` injection and fast-session trimming. - Hosted built-in tool definitions are typed and supported for: - `tool_search` - remote `mcp` diff --git a/index.ts b/index.ts index ec31a07b..c431282b 100644 --- a/index.ts +++ b/index.ts @@ -66,6 +66,7 @@ import { getLiveAccountSyncDebounceMs, getLiveAccountSyncPollMs, getResponseContinuation, + getBackgroundResponses, getSessionAffinity, getSessionAffinityTtlMs, getSessionAffinityMaxEntries, @@ -1371,6 +1372,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fastSessionStrategy, fastSessionMaxInputItems, deferFastSessionInputTrimming: fastSessionEnabled, + allowBackgroundResponses: getBackgroundResponses(pluginConfig), }, ); let requestInit = transformation?.updatedInit ?? baseInit; diff --git a/lib/config.ts b/lib/config.ts index 910ed14b..e6e5350c 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -149,6 +149,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, responseContinuation: false, + backgroundResponses: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -936,6 +937,23 @@ export function getResponseContinuation(pluginConfig: PluginConfig): boolean { ); } +/** + * Controls whether the plugin may preserve explicit Responses API background requests. + * + * Background mode is disabled by default because the normal Codex request path is stateless (`store=false`). + * When enabled, callers may opt into `background: true`, which switches the request to `store=true`. + * + * @param pluginConfig - The plugin configuration to consult for the setting + * @returns `true` if stateful background responses are allowed, `false` otherwise + */ +export function getBackgroundResponses(pluginConfig: PluginConfig): boolean { + return resolveBooleanSetting( + "CODEX_AUTH_BACKGROUND_RESPONSES", + pluginConfig.backgroundResponses, + false, + ); +} + /** * Controls whether the proactive refresh guardian is enabled. * diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index 348ea4f8..e3544362 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -663,6 +663,7 @@ export async function transformRequestForCodex( fastSessionStrategy?: "hybrid" | "always"; fastSessionMaxInputItems?: number; deferFastSessionInputTrimming?: boolean; + allowBackgroundResponses?: boolean; }, ): Promise { const hasParsedBody = @@ -719,6 +720,7 @@ export async function transformRequestForCodex( options?.fastSessionStrategy ?? "hybrid", options?.fastSessionMaxInputItems ?? 30, options?.deferFastSessionInputTrimming ?? false, + options?.allowBackgroundResponses ?? false, ); // Log transformed request @@ -740,7 +742,8 @@ export async function transformRequestForCodex( body: transformedBody, updatedInit: { ...(init ?? {}), body: JSON.stringify(transformedBody) }, deferredFastSessionInputTrim: - options?.deferFastSessionInputTrimming === true + options?.deferFastSessionInputTrimming === true && + transformedBody.background !== true ? fastSessionInputTrimPlan.trim : undefined, }; diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index a117ae73..fa2f601e 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -39,6 +39,7 @@ export interface TransformRequestBodyParams { fastSessionStrategy?: FastSessionStrategy; fastSessionMaxInputItems?: number; deferFastSessionInputTrimming?: boolean; + allowBackgroundResponses?: boolean; } const PLAN_MODE_ONLY_TOOLS = new Set(["request_user_input"]); @@ -229,6 +230,30 @@ function resolveInclude(modelConfig: ConfigOptions, body: RequestBody): string[] return include; } +function isBackgroundModeRequested(body: RequestBody): boolean { + return body.background === true; +} + +function assertBackgroundModeCompatibility( + body: RequestBody, + allowBackgroundResponses: boolean, +): boolean { + if (!isBackgroundModeRequested(body)) { + return false; + } + if (!allowBackgroundResponses) { + throw new Error( + "Responses background mode is disabled. Enable pluginConfig.backgroundResponses or CODEX_AUTH_BACKGROUND_RESPONSES=1 to opt in.", + ); + } + if (body.store === false || body.providerOptions?.openai?.store === false) { + throw new Error( + "Responses background mode requires store=true and cannot be combined with stateless store=false routing.", + ); + } + return true; +} + function parseCollaborationMode(value: string | undefined): CollaborationMode | undefined { if (!value) return undefined; const normalized = value.trim().toLowerCase(); @@ -466,8 +491,10 @@ function sanitizeReasoningSummary( */ export function filterInput( input: InputItem[] | undefined, + options?: { stripIds?: boolean }, ): InputItem[] | undefined { if (!Array.isArray(input)) return input; + const stripIds = options?.stripIds ?? true; const filtered: InputItem[] = []; for (const item of input) { if (!item || typeof item !== "object") { @@ -478,7 +505,7 @@ export function filterInput( continue; } // Strip IDs from all items (Codex API stateless mode). - if ("id" in item) { + if (stripIds && "id" in item) { const { id: _omit, ...itemWithoutId } = item; void _omit; filtered.push(itemWithoutId as InputItem); @@ -771,7 +798,7 @@ export function addToolRemapMessage( * NOTE: Configuration follows Codex CLI patterns instead of host defaults: * - host may set textVerbosity="low" for gpt-5, but Codex CLI uses "medium" * - host may exclude gpt-5-codex from reasoning configuration - * - This plugin uses store=false (stateless), requiring encrypted reasoning content + * - This plugin defaults to store=false (stateless), with an explicit opt-in for background mode * * @param body - Original request body * @param codexInstructions - Codex system instructions @@ -792,6 +819,7 @@ export async function transformRequestBody( fastSessionStrategy?: FastSessionStrategy, fastSessionMaxInputItems?: number, deferFastSessionInputTrimming?: boolean, + allowBackgroundResponses?: boolean, ): Promise; export async function transformRequestBody( bodyOrParams: RequestBody | TransformRequestBodyParams, @@ -802,6 +830,7 @@ export async function transformRequestBody( fastSessionStrategy: FastSessionStrategy = "hybrid", fastSessionMaxInputItems = 30, deferFastSessionInputTrimming = false, + allowBackgroundResponses = false, ): Promise { const useNamedParams = typeof codexInstructions === "undefined" && @@ -817,6 +846,7 @@ export async function transformRequestBody( let resolvedFastSessionStrategy: FastSessionStrategy; let resolvedFastSessionMaxInputItems: number; let resolvedDeferFastSessionInputTrimming: boolean; + let resolvedAllowBackgroundResponses: boolean; if (useNamedParams) { const namedParams = bodyOrParams as TransformRequestBodyParams; @@ -829,6 +859,8 @@ export async function transformRequestBody( resolvedFastSessionMaxInputItems = namedParams.fastSessionMaxInputItems ?? 30; resolvedDeferFastSessionInputTrimming = namedParams.deferFastSessionInputTrimming ?? false; + resolvedAllowBackgroundResponses = + namedParams.allowBackgroundResponses ?? false; } else { body = bodyOrParams as RequestBody; resolvedCodexInstructions = codexInstructions; @@ -838,6 +870,7 @@ export async function transformRequestBody( resolvedFastSessionStrategy = fastSessionStrategy; resolvedFastSessionMaxInputItems = fastSessionMaxInputItems; resolvedDeferFastSessionInputTrimming = deferFastSessionInputTrimming; + resolvedAllowBackgroundResponses = allowBackgroundResponses; } if (!body || typeof body !== "object") { @@ -872,21 +905,27 @@ export async function transformRequestBody( const reasoningModel = shouldUseNormalizedReasoningModel ? normalizedModel : lookupModel; + const backgroundModeRequested = assertBackgroundModeCompatibility( + body, + resolvedAllowBackgroundResponses, + ); const fastSessionInputTrimPlan = resolveFastSessionInputTrimPlan( body, resolvedFastSession, resolvedFastSessionStrategy, resolvedFastSessionMaxInputItems, ); - const shouldApplyFastSessionTuning = fastSessionInputTrimPlan.shouldApply; + const shouldApplyFastSessionTuning = + !backgroundModeRequested && fastSessionInputTrimPlan.shouldApply; const isTrivialTurn = fastSessionInputTrimPlan.isTrivialTurn; const shouldDisableToolsForTrivialTurn = shouldApplyFastSessionTuning && isTrivialTurn; // Codex required fields - // ChatGPT backend REQUIRES store=false (confirmed via testing) - body.store = false; + // ChatGPT backend normally requires store=false (confirmed via testing). + // Background mode is an explicit opt-in compatibility path that preserves stateful storage. + body.store = backgroundModeRequested ? true : false; // Always set stream=true for API - response handling detects original intent body.stream = true; @@ -934,7 +973,9 @@ export async function transformRequestBody( ); } - inputItems = filterInput(inputItems) ?? inputItems; + inputItems = filterInput(inputItems, { + stripIds: !backgroundModeRequested, + }) ?? inputItems; body.input = inputItems; // istanbul ignore next -- filterInput always removes IDs; this is defensive debug code @@ -1013,7 +1054,11 @@ export async function transformRequestBody( // Add include for encrypted reasoning content // Default: ["reasoning.encrypted_content"] (required for stateless operation with store=false) // This allows reasoning context to persist across turns without server-side storage - body.include = resolveInclude(modelConfig, body); + body.include = backgroundModeRequested + ? body.include ?? + body.providerOptions?.openai?.include ?? + modelConfig.include + : resolveInclude(modelConfig, body); // Remove unsupported parameters body.max_output_tokens = undefined; diff --git a/lib/schemas.ts b/lib/schemas.ts index 41585678..89c13669 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -48,6 +48,7 @@ export const PluginConfigSchema = z.object({ sessionAffinityTtlMs: z.number().min(1_000).optional(), sessionAffinityMaxEntries: z.number().min(8).optional(), responseContinuation: z.boolean().optional(), + backgroundResponses: z.boolean().optional(), proactiveRefreshGuardian: z.boolean().optional(), proactiveRefreshIntervalMs: z.number().min(5_000).optional(), proactiveRefreshBufferMs: z.number().min(30_000).optional(), diff --git a/lib/types.ts b/lib/types.ts index 589ff556..0fec156b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -189,6 +189,7 @@ export interface InputItem { */ export interface RequestBody { model: string; + background?: boolean; store?: boolean; stream?: boolean; instructions?: string; diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index f90108e7..4050ea85 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -153,6 +153,18 @@ describe('Fetch Helpers Module', () => { expect(rewriteUrlForCodex(url)).toBe('https://chatgpt.com/backend-api/codex/responses'); }); + it('should preserve response subresource paths for background polling and cancel routes', () => { + const retrieveUrl = 'https://api.openai.com/v1/responses/resp_123'; + const cancelUrl = 'https://api.openai.com/v1/responses/resp_123/cancel'; + + expect(rewriteUrlForCodex(retrieveUrl)).toBe( + 'https://chatgpt.com/backend-api/v1/codex/responses/resp_123', + ); + expect(rewriteUrlForCodex(cancelUrl)).toBe( + 'https://chatgpt.com/backend-api/v1/codex/responses/resp_123/cancel', + ); + }); + it('should keep backend-api paths when URL is already on codex origin', () => { const url = 'https://chatgpt.com/backend-api/other'; expect(rewriteUrlForCodex(url)).toBe(url); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index ed3bb8fa..e9c2762d 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -22,6 +22,7 @@ import { getPreemptiveQuotaRemainingPercent7d, getPreemptiveQuotaMaxDeferralMs, getResponseContinuation, + getBackgroundResponses, } from '../lib/config.js'; import type { PluginConfig } from '../lib/types.js'; import * as fs from 'node:fs'; @@ -132,6 +133,7 @@ describe('Plugin Configuration', () => { sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, responseContinuation: false, + backgroundResponses: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -191,6 +193,7 @@ describe('Plugin Configuration', () => { sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, responseContinuation: false, + backgroundResponses: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -447,6 +450,7 @@ describe('Plugin Configuration', () => { sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, responseContinuation: false, + backgroundResponses: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -512,6 +516,7 @@ describe('Plugin Configuration', () => { sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, responseContinuation: false, + backgroundResponses: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -571,6 +576,7 @@ describe('Plugin Configuration', () => { sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, responseContinuation: false, + backgroundResponses: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -683,6 +689,25 @@ describe('Plugin Configuration', () => { }); }); + describe('getBackgroundResponses', () => { + it('should default to false', () => { + delete process.env.CODEX_AUTH_BACKGROUND_RESPONSES; + expect(getBackgroundResponses({})).toBe(false); + }); + + it('should use config value when env var not set', () => { + delete process.env.CODEX_AUTH_BACKGROUND_RESPONSES; + expect(getBackgroundResponses({ backgroundResponses: true })).toBe(true); + }); + + it('should prioritize env override', () => { + process.env.CODEX_AUTH_BACKGROUND_RESPONSES = '1'; + expect(getBackgroundResponses({ backgroundResponses: false })).toBe(true); + process.env.CODEX_AUTH_BACKGROUND_RESPONSES = '0'; + expect(getBackgroundResponses({ backgroundResponses: true })).toBe(false); + }); + }); + describe('getCodexTuiV2', () => { it('should default to true', () => { delete process.env.CODEX_TUI_V2; diff --git a/test/public-api-contract.test.ts b/test/public-api-contract.test.ts index 6419b38b..a94c9565 100644 --- a/test/public-api-contract.test.ts +++ b/test/public-api-contract.test.ts @@ -115,6 +115,8 @@ describe("public api contract", () => { const baseBody: RequestBody = { model: "gpt-5.4", + background: true, + store: true, input: [{ type: "message", role: "user", content: "hi" }], prompt_cache_retention: "24h", tools: [ @@ -144,12 +146,22 @@ describe("public api contract", () => { const transformedPositional = await transformRequestBody( JSON.parse(JSON.stringify(baseBody)) as RequestBody, "codex", + undefined, + true, + false, + "hybrid", + 30, + false, + true, ); const transformedNamed = await transformRequestBody({ body: JSON.parse(JSON.stringify(baseBody)) as RequestBody, codexInstructions: "codex", + allowBackgroundResponses: true, }); expect(transformedNamed).toEqual(transformedPositional); expect(transformedNamed.tools).toEqual(baseBody.tools); + expect(transformedNamed.background).toBe(true); + expect(transformedNamed.store).toBe(true); }); }); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 74eb55ef..99b09ad1 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -2469,6 +2469,74 @@ describe('Request Transformer Module', () => { expect(named).toEqual(positional); }); + it('rejects background mode unless explicitly enabled', async () => { + await expect( + transformRequestBody( + { + model: 'gpt-5.4', + background: true, + input: [{ type: 'message', role: 'user', content: 'hello' }], + }, + codexInstructions, + ), + ).rejects.toThrowError( + 'Responses background mode is disabled. Enable pluginConfig.backgroundResponses or CODEX_AUTH_BACKGROUND_RESPONSES=1 to opt in.', + ); + }); + + it('preserves stateful request fields when background mode is enabled', async () => { + const result = await transformRequestBody( + { + model: 'gpt-5.4', + background: true, + input: [{ id: 'msg_stateful_123', type: 'message', role: 'user', content: 'hello' }], + }, + codexInstructions, + { global: {}, models: {} }, + true, + true, + 'always', + 12, + false, + true, + ); + + const userItem = result.input?.find((item) => item.role === 'user'); + expect(result.background).toBe(true); + expect(result.store).toBe(true); + expect(result.include).toBeUndefined(); + expect(result.text?.verbosity).toBe('medium'); + expect(userItem).toMatchObject({ + id: 'msg_stateful_123', + type: 'message', + role: 'user', + content: 'hello', + }); + }); + + it('rejects background mode when the request still forces store=false', async () => { + await expect( + transformRequestBody( + { + model: 'gpt-5.4', + background: true, + store: false, + input: [{ type: 'message', role: 'user', content: 'hello' }], + }, + codexInstructions, + { global: {}, models: {} }, + true, + false, + 'hybrid', + 30, + false, + true, + ), + ).rejects.toThrowError( + 'Responses background mode requires store=true and cannot be combined with stateless store=false routing.', + ); + }); + it('throws clear TypeError when named-parameter body is invalid', async () => { await expect( transformRequestBody({ From d5f9966c7b0c4d518ef27c43d67c6bc5d02c37ac Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 18:00:54 +0800 Subject: [PATCH 2/4] fix config mock for background response getter --- test/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/index.test.ts b/test/index.test.ts index 7882bc6f..6ea19cbc 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -100,6 +100,7 @@ vi.mock("../lib/config.js", () => ({ getSessionAffinityTtlMs: vi.fn(() => 1_200_000), getSessionAffinityMaxEntries: vi.fn(() => 512), getResponseContinuation: vi.fn(() => false), + getBackgroundResponses: vi.fn(() => false), getProactiveRefreshGuardian: () => false, getProactiveRefreshIntervalMs: () => 60000, getProactiveRefreshBufferMs: () => 300000, From cc04f161a0a07639ac8ce0aa2ad3505dc787a77e Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:11:13 +0800 Subject: [PATCH 3/4] fix-background-response-guardrails --- lib/request/fetch-helpers.ts | 8 ++++- lib/request/request-transformer.ts | 6 ++-- test/fetch-helpers.test.ts | 52 ++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index e3544362..ac0a7efb 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -738,7 +738,7 @@ export async function transformRequestForCodex( body: transformedBody as unknown as Record, }); - return { + return { body: transformedBody, updatedInit: { ...(init ?? {}), body: JSON.stringify(transformedBody) }, deferredFastSessionInputTrim: @@ -748,6 +748,12 @@ export async function transformRequestForCodex( : undefined, }; } catch (e) { + if ( + e instanceof Error && + e.message.startsWith("Responses background mode") + ) { + throw e; + } logError(`${ERROR_MESSAGES.REQUEST_PARSE_ERROR}`, e); return undefined; } diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index fa2f601e..e47d36ec 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -978,12 +978,12 @@ export async function transformRequestBody( }) ?? inputItems; body.input = inputItems; - // istanbul ignore next -- filterInput always removes IDs; this is defensive debug code + // istanbul ignore next -- filterInput always removes IDs in stateless mode; this is defensive debug code const remainingIds = (body.input || []) .filter((item) => item.id) .map((item) => item.id); - // istanbul ignore if -- filterInput always removes IDs; defensive debug warning - if (remainingIds.length > 0) { + // istanbul ignore if -- filterInput always removes IDs in stateless mode; background mode intentionally preserves them + if (remainingIds.length > 0 && !backgroundModeRequested) { logWarn( `WARNING: ${remainingIds.length} IDs still present after filtering:`, remainingIds, diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index 4050ea85..2267b02f 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -1127,6 +1127,58 @@ describe('createEntitlementErrorResponse', () => { expect(typeof result?.updatedInit.body).toBe('string'); }); + it('rethrows background-mode compatibility errors instead of falling back to the raw request', async () => { + const { transformRequestForCodex } = await import('../lib/request/fetch-helpers.js'); + + await expect( + transformRequestForCodex( + { + body: JSON.stringify({ + model: 'gpt-5.4', + background: true, + input: [{ type: 'message', role: 'user', content: 'hello' }], + }), + }, + 'https://example.com', + { global: {}, models: {} }, + ), + ).rejects.toThrow( + 'Responses background mode is disabled. Enable pluginConfig.backgroundResponses or CODEX_AUTH_BACKGROUND_RESPONSES=1 to opt in.', + ); + }); + + it('suppresses deferred fast-session trimming for allowed background requests', async () => { + const { transformRequestForCodex } = await import('../lib/request/fetch-helpers.js'); + + const result = await transformRequestForCodex( + { + body: JSON.stringify({ + model: 'gpt-5.4', + background: true, + input: [ + { id: 'msg_1', type: 'message', role: 'user', content: 'hello' }, + { id: 'msg_2', type: 'message', role: 'assistant', content: 'hi' }, + ], + }), + }, + 'https://example.com', + { global: {}, models: {} }, + true, + undefined, + { + fastSession: true, + fastSessionStrategy: 'always', + fastSessionMaxInputItems: 1, + deferFastSessionInputTrimming: true, + allowBackgroundResponses: true, + }, + ); + + expect(result).toBeDefined(); + expect(result?.body.background).toBe(true); + expect(result?.deferredFastSessionInputTrim).toBeUndefined(); + }); + it('returns undefined when parsedBody is empty object and init body is unavailable', async () => { const { transformRequestForCodex } = await import('../lib/request/fetch-helpers.js'); const result = await transformRequestForCodex( From 66f5d33536638015c306a5007fcef936c5fdf48a Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:00:02 +0800 Subject: [PATCH 4/4] document and test background responses compatibility --- README.md | 3 +++ docs/development/CONFIG_FIELDS.md | 5 +++++ docs/reference/public-api.md | 5 ++--- docs/upgrade.md | 10 ++++++++++ test/fetch-helpers.test.ts | 24 ++++++++++++++++++++++++ test/plugin-config.test.ts | 1 + test/request-transformer.test.ts | 23 +++++++++++++++++++++++ 7 files changed, 68 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f4e5e25f..73361f23 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ Selected runtime/environment overrides: | `CODEX_TUI_V2=0/1` | Disable/enable TUI v2 | | `CODEX_TUI_COLOR_PROFILE=truecolor|ansi256|ansi16` | TUI color profile | | `CODEX_TUI_GLYPHS=ascii|unicode|auto` | TUI glyph style | +| `CODEX_AUTH_BACKGROUND_RESPONSES=0/1` | Opt in/out of stateful Responses `background: true` compatibility | | `CODEX_AUTH_FETCH_TIMEOUT_MS=` | Request timeout override | | `CODEX_AUTH_STREAM_STALL_TIMEOUT_MS=` | Stream stall timeout override | @@ -210,6 +211,8 @@ codex auth check codex auth forecast --live ``` +Responses background mode stays opt-in. Enable `backgroundResponses` in settings or `CODEX_AUTH_BACKGROUND_RESPONSES=1` only for callers that intentionally send `background: true`, because those requests switch from stateless `store=false` routing to stateful `store=true`. See [docs/upgrade.md](docs/upgrade.md) for rollout guidance. + --- ## Experimental Settings Highlights diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index 5349bcc0..f5bf7b88 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -83,6 +83,11 @@ Used only for host plugin mode through the host runtime config file. `backgroundResponses` is an opt-in compatibility switch for Responses API `background: true` requests. When enabled, those requests become stateful (`store=true`) instead of following the default stateless Codex routing. +Upgrade note: +- Leave this disabled for existing stateless pipelines that do not intentionally send `background: true`. +- Enable it only for callers that need stateful background responses and can accept forced `store=true`, preserved input item IDs, and the loss of stateless-only defaults such as fast-session trimming. +- After enabling it, test one known `background: true` request end to end before rolling it across shared automation. + ### Storage / Sync | Key | Default | diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index db0e1b84..15182b3d 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -67,11 +67,10 @@ Positional signatures are preserved for backward compatibility. The request-transform layer intentionally preserves and/or normalizes modern Responses API fields that callers may already send through the host SDK. -- `previous_response_id` is preserved when explicitly provided and may be auto-filled from plugin continuation state when `pluginConfig.responseContinuation` is enabled. +- The plugin preserves `previous_response_id` when explicitly provided and may auto-fill it from plugin continuation state when `pluginConfig.responseContinuation` is enabled, maintains `text.format` when verbosity defaults are applied, and honors `prompt_cache_retention` from the request body before falling back to `providerOptions.openai.promptCacheRetention` or user config defaults. - `background` is typed as a first-class request field. It remains disabled by default and only passes through when `pluginConfig.backgroundResponses` or `CODEX_AUTH_BACKGROUND_RESPONSES=1` explicitly enables the stateful compatibility path. -- `text.format` is preserved when verbosity defaults are applied, so structured-output contracts continue to flow through untouched. -- `prompt_cache_retention` is preserved from the request body and can fall back to `providerOptions.openai.promptCacheRetention` or user config defaults. - Background-mode requests force `store=true`, keep caller-supplied input item IDs, and skip stateless-only defaults such as `reasoning.encrypted_content` injection and fast-session trimming. +- Upgrade note: leave background mode disabled for existing stateless pipelines. Enable it only for callers that intentionally send `background: true` and are ready for stateful `store=true` routing. For rollout steps, see [../upgrade.md](../upgrade.md). - Hosted built-in tool definitions are typed and supported for: - `tool_search` - remote `mcp` diff --git a/docs/upgrade.md b/docs/upgrade.md index 00883363..519f2641 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -76,6 +76,16 @@ For maintainer/debug flows, see advanced/internal controls in [development/CONFI --- +## Responses Background Mode Upgrade Note + +`backgroundResponses` and `CODEX_AUTH_BACKGROUND_RESPONSES=1` are opt-in compatibility controls for callers that intentionally send Responses API `background: true`. + +- Leave them disabled for existing stateless pipelines. The default routing remains `store=false`. +- Enabling them switches background requests onto the stateful path, forces `store=true`, preserves caller-supplied input item IDs, and skips stateless-only defaults such as fast-session trimming and `reasoning.encrypted_content` injection. +- No new npm scripts or storage migrations are required, but you should validate one known `background: true` request end to end before enabling the flag across shared automation. + +--- + ## Onboarding Restore Note `codex auth login` now opens directly into the sign-in menu when the active pool is empty, instead of opening the account dashboard first. diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index 2267b02f..4f0e724f 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -1179,6 +1179,30 @@ describe('createEntitlementErrorResponse', () => { expect(result?.deferredFastSessionInputTrim).toBeUndefined(); }); + it('rethrows store=false background guardrail errors at the fetch layer', async () => { + const { transformRequestForCodex } = await import('../lib/request/fetch-helpers.js'); + + await expect( + transformRequestForCodex( + { + body: JSON.stringify({ + model: 'gpt-5.4', + background: true, + store: false, + input: [{ type: 'message', role: 'user', content: 'hello' }], + }), + }, + 'https://example.com', + { global: {}, models: {} }, + true, + undefined, + { allowBackgroundResponses: true }, + ), + ).rejects.toThrow( + 'Responses background mode requires store=true and cannot be combined with stateless store=false routing.', + ); + }); + it('returns undefined when parsedBody is empty object and init body is unavailable', async () => { const { transformRequestForCodex } = await import('../lib/request/fetch-helpers.js'); const result = await transformRequestForCodex( diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index e9c2762d..5166ef00 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -66,6 +66,7 @@ describe('Plugin Configuration', () => { 'CODEX_AUTH_FALLBACK_UNSUPPORTED_MODEL', 'CODEX_AUTH_FALLBACK_GPT53_TO_GPT52', 'CODEX_AUTH_RESPONSE_CONTINUATION', + 'CODEX_AUTH_BACKGROUND_RESPONSES', 'CODEX_AUTH_PREEMPTIVE_QUOTA_ENABLED', 'CODEX_AUTH_PREEMPTIVE_QUOTA_5H_REMAINING_PCT', 'CODEX_AUTH_PREEMPTIVE_QUOTA_7D_REMAINING_PCT', diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 99b09ad1..670633d6 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -2537,6 +2537,29 @@ describe('Request Transformer Module', () => { ); }); + it('rejects background mode when providerOptions still forces store=false', async () => { + await expect( + transformRequestBody( + { + model: 'gpt-5.4', + background: true, + providerOptions: { openai: { store: false } }, + input: [{ type: 'message', role: 'user', content: 'hello' }], + }, + codexInstructions, + { global: {}, models: {} }, + true, + false, + 'hybrid', + 30, + false, + true, + ), + ).rejects.toThrowError( + 'Responses background mode requires store=true and cannot be combined with stateless store=false routing.', + ); + }); + it('throws clear TypeError when named-parameter body is invalid', async () => { await expect( transformRequestBody({