From 25967db9743f6f10cdb0cabbc6800ebc1da829a6 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 12 Jun 2026 03:31:49 +0000 Subject: [PATCH 1/2] fix(ai): classified OpenAI ZDR 400 as chain-disable signal v15.11.4 introduced stateful previous_response_id chaining on the official OpenAI endpoint. The in-provider retry classifier matched only the generic stale-id phrasing ('previous response ... not found | invalid | expired | stale'), missing the Zero Data Retention 400 'Previous response cannot be used for this organization due to Zero Data Retention.'. The error therefore bypassed the categorical-disable path, so the chain was reset (not disabled), the next successful turn re-armed it, and every other turn 400'd in a loop. Add a dedicated isOpenAIResponsesZeroDataRetentionError detector and a markOpenAIResponsesChainZeroDataRetention helper that disables chaining on the first hit (skipping the three-strike circuit breaker). The in-call retry now drops 'store: true' from the replay so the request is semantically valid for ZDR orgs, and reasoning continuity is preserved by the existing include: ['reasoning.encrypted_content'] flag. AgentSession.#isStaleOpenAIResponsesReplayError gains the ZDR phrasing too, so any ZDR error that does bubble past the provider retry resets the Responses session and retries at zero backoff instead of falling back to a different model. Fixes #2341 --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/providers/openai-responses.ts | 57 ++++++++++--- .../ai/test/openai-responses-stateful.test.ts | 51 ++++++++++++ packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/session/agent-session.ts | 3 +- .../test/agent-session-retry-fallback.test.ts | 79 +++++++++++++++++++ 6 files changed, 187 insertions(+), 11 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5833c389f..7f6c1a6e4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI Responses `previous_response_id` chaining on Zero Data Retention orgs: the in-provider retry classifier missed the ZDR-specific 400 ("Previous response cannot be used for this organization due to Zero Data Retention"), so chained turns kept failing every other request after a brief recovery — the chain was reset but not disabled, so the next successful full-replay turn re-armed it. The ZDR phrasing is now classified categorically: one strike disables chaining for the session (skipping the three-strike circuit breaker) and the in-call retry drops `store: true`/`previous_response_id` and replays the full transcript instead ([#2341](https://github.com/can1357/oh-my-pi/issues/2341)). + ## [15.11.4] - 2026-06-12 ### Added diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 856aaef99..0bc671655 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -288,6 +288,18 @@ function isOpenAIResponsesStalePreviousResponseError(error: unknown): boolean { return /previous[ _]?response/i.test(error.message) && /not[ _]?found|invalid|expired|stale/i.test(error.message); } +/** + * Zero Data Retention orgs accept `store: true` but refuse to resolve any + * `previous_response_id` — the prior response was never persisted server-side. + * The 400 carries a fixed phrasing ("Zero Data Retention") that the generic + * stale-id regex above does not match, so it is classified separately and + * disables chaining categorically (one strike, not three). + */ +function isOpenAIResponsesZeroDataRetentionError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return /previous[ _]?response/i.test(error.message) && /zero[ _-]?data[ _-]?retention/i.test(error.message); +} + function registerOpenAIResponsesChainStaleFailure(chain: OpenAIResponsesChainState, error: unknown): void { resetOpenAIResponsesChainState(chain); chain.staleFailures += 1; @@ -301,6 +313,22 @@ function registerOpenAIResponsesChainStaleFailure(chain: OpenAIResponsesChainSta }); } +/** + * One-shot ZDR signal: the org will never resolve a stored response, so skip + * the staleFailures counter and disable chaining immediately for this session. + */ +function markOpenAIResponsesChainZeroDataRetention( + chain: OpenAIResponsesChainState, + error: unknown, +): void { + resetOpenAIResponsesChainState(chain); + chain.disabled = true; + chain.staleFailures = OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT; + logger.debug("OpenAI responses chaining disabled (Zero Data Retention)", { + error: error instanceof Error ? error.message : String(error), + }); +} + type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & { top_p?: number; top_k?: number; @@ -433,18 +461,27 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( try { openaiStream = await openResponsesStream(chained.params); } catch (error) { - if ( - !chainState || - !sentPreviousResponseId || - requestSignal.aborted || - !isOpenAIResponsesStalePreviousResponseError(error) - ) { + if (!chainState || !sentPreviousResponseId || requestSignal.aborted) { throw error; } - // Server rejected the chain baseline: reset, count the failure, and - // retry once with the full transcript. Structurally cannot loop — the - // retry carries no previous_response_id. - registerOpenAIResponsesChainStaleFailure(chainState, error); + const zdrRejection = isOpenAIResponsesZeroDataRetentionError(error); + if (!zdrRejection && !isOpenAIResponsesStalePreviousResponseError(error)) { + throw error; + } + // Server rejected the chain baseline: reset, count the failure (or + // disable categorically on ZDR), and retry once with the full + // transcript. Structurally cannot loop — the retry carries no + // previous_response_id. + if (zdrRejection) { + markOpenAIResponsesChainZeroDataRetention(chainState, error); + // ZDR orgs cannot store responses; the original request forced + // `store: true` for chaining, which is meaningless here and would + // otherwise leave subsequent turns asking the server to retain + // data it must discard. + params.store = false; + } else { + registerOpenAIResponsesChainStaleFailure(chainState, error); + } sentPreviousResponseId = undefined; rawRequestDump.body = params; openaiStream = await openResponsesStream(params); diff --git a/packages/ai/test/openai-responses-stateful.test.ts b/packages/ai/test/openai-responses-stateful.test.ts index 81d56ed8a..3b95630ec 100644 --- a/packages/ai/test/openai-responses-stateful.test.ts +++ b/packages/ai/test/openai-responses-stateful.test.ts @@ -279,6 +279,57 @@ describe("openai-responses stateful chaining", () => { expect(sentRequests[7]?.store).toBe(false); }); + it("disables chaining categorically when the org has Zero Data Retention enabled", async () => { + const sentRequests: Array> = []; + const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)) as Record; + sentRequests.push(request); + if (typeof request.previous_response_id === "string") { + return new Response( + JSON.stringify({ + error: { + message: + "Previous response cannot be used for this organization due to Zero Data Retention.", + type: "invalid_request_error", + param: "previous_response_id", + code: "zero_data_retention", + }, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + return createStatefulSse(`Answer ${sentRequests.length}`, `resp_${sentRequests.length}`); + }) as FetchImpl; + const providerSessionState = new Map(); + const options = { + apiKey: "test-key", + sessionId: "stateful-zdr-session", + providerSessionState, + statefulResponses: true, + reasoning: "low" as const, + fetch: fetchMock, + }; + + const messages: Context["messages"] = []; + for (let turn = 1; turn <= 3; turn++) { + messages.push({ role: "user", content: `Question ${turn}`, timestamp: 1000 + turn }); + const result = await streamOpenAIResponses(model, { systemPrompt, messages }, options).result(); + expect(result.stopReason).toBe("stop"); + messages.push(result); + } + + // Turn 1: no previous_response_id (cold chain). Turn 2: tries chaining, + // gets a ZDR 400, retries once with full transcript and store: false. + // Turn 3: chain is permanently disabled — no second 400. + expect(sentRequests).toHaveLength(4); + expect(sentRequests[0]?.previous_response_id).toBeUndefined(); + expect(sentRequests[1]?.previous_response_id).toBe("resp_1"); + expect(sentRequests[2]?.previous_response_id).toBeUndefined(); + expect(sentRequests[2]?.store).toBe(false); + expect(sentRequests[3]?.previous_response_id).toBeUndefined(); + expect(sentRequests[3]?.store).toBe(false); + }); + it("chains by default against the official OpenAI API", async () => { const sentRequests: Array> = []; const fetchMock = createCapturingFetch(sentRequests); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fc3a76c44..c3c764858 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the higher-level Responses retry classifier to recognize OpenAI Zero Data Retention rejections (`Previous response cannot be used for this organization due to Zero Data Retention`) as stale-replay errors. The provider-level fix in `@oh-my-pi/pi-ai` covers the common case in a single turn; this layer additionally guarantees that any ZDR error that still bubbles up resets the Responses session and retries at zero backoff instead of falling back to a different model ([#2341](https://github.com/can1357/oh-my-pi/issues/2341)). + ## [15.11.4] - 2026-06-12 ### Added diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index c992c627a..4f6367e76 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -8354,7 +8354,8 @@ export class AgentSession { return ( /\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) || - (/previous[ _]?response/i.test(errorMessage) && /not[ _]?found|invalid|expired|stale/i.test(errorMessage)) + (/previous[ _]?response/i.test(errorMessage) && + /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i.test(errorMessage)) ); } diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index a4d82e3f4..2bdee9415 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -818,6 +818,85 @@ describe("AgentSession retry fallback", () => { }); }); + it("restarts Responses provider state before retrying Zero Data Retention errors", async () => { + const model = getBundledModel("openai", "gpt-4o-mini"); + const fallbackModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + // Mirrors the live wire error from OpenAI ZDR orgs after the in-provider + // retry has already exhausted itself; the higher-level retry must still + // classify the failure as a stale-replay event so the session reset and + // zero-delay backoff fire instead of a model fallback. + const zdrReplayError = + "400 Previous response cannot be used for this organization due to Zero Data Retention."; + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + const mock = createMockModel({ + responses: [{ throw: zdrReplayError }, { content: ["Recovered after ZDR reset"] }], + }); + const agent = new Agent({ + getApiKey: provider => `${provider}-test-key`, + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: (requestedModel, context, options) => { + requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`); + return mock.stream(requestedModel, context, options); + }, + }); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 5, + "retry.maxRetries": 1, + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${model.provider}/${model.id}`); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + const closeSpy = vi.fn(); + session.providerSessionState.set("openai-responses:openai", { + close: closeSpy, + } satisfies ProviderSessionState); + const { retryStartEvents, retryEndEvents } = trackRetryEvents(session); + + await session.prompt("Retry ZDR replay"); + await session.waitForIdle(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(session.providerSessionState.has("openai-responses:openai")).toBe(false); + expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]); + expect(fallbackAppliedEvents).toHaveLength(0); + expect(retryStartEvents).toHaveLength(1); + expect(retryStartEvents[0]).toMatchObject({ + attempt: 1, + delayMs: 0, + errorMessage: zdrReplayError, + }); + expect(retryEndEvents).toHaveLength(1); + expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 }); + const lastAssistant = getLastAssistantMessage(session); + expect(lastAssistant.stopReason).toBe("stop"); + expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered after ZDR reset" }); + }); + it("auto-retries Anthropic stream-envelope failures before message_start", async () => { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) { From 884e7dc1155f455599805a9f5ec1d6d652817c26 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 12 Jun 2026 03:32:13 +0000 Subject: [PATCH 2/2] style: bun run fix --- packages/ai/src/providers/openai-responses.ts | 5 +---- packages/ai/test/openai-responses-stateful.test.ts | 3 +-- .../coding-agent/test/agent-session-retry-fallback.test.ts | 3 +-- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 0bc671655..8f30cab42 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -317,10 +317,7 @@ function registerOpenAIResponsesChainStaleFailure(chain: OpenAIResponsesChainSta * One-shot ZDR signal: the org will never resolve a stored response, so skip * the staleFailures counter and disable chaining immediately for this session. */ -function markOpenAIResponsesChainZeroDataRetention( - chain: OpenAIResponsesChainState, - error: unknown, -): void { +function markOpenAIResponsesChainZeroDataRetention(chain: OpenAIResponsesChainState, error: unknown): void { resetOpenAIResponsesChainState(chain); chain.disabled = true; chain.staleFailures = OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT; diff --git a/packages/ai/test/openai-responses-stateful.test.ts b/packages/ai/test/openai-responses-stateful.test.ts index 3b95630ec..fa3fc33a8 100644 --- a/packages/ai/test/openai-responses-stateful.test.ts +++ b/packages/ai/test/openai-responses-stateful.test.ts @@ -288,8 +288,7 @@ describe("openai-responses stateful chaining", () => { return new Response( JSON.stringify({ error: { - message: - "Previous response cannot be used for this organization due to Zero Data Retention.", + message: "Previous response cannot be used for this organization due to Zero Data Retention.", type: "invalid_request_error", param: "previous_response_id", code: "zero_data_retention", diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index 2bdee9415..68158adda 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -829,8 +829,7 @@ describe("AgentSession retry fallback", () => { // retry has already exhausted itself; the higher-level retry must still // classify the failure as a stale-replay event so the session reset and // zero-delay backoff fire instead of a model fallback. - const zdrReplayError = - "400 Previous response cannot be used for this organization due to Zero Data Retention."; + const zdrReplayError = "400 Previous response cannot be used for this organization due to Zero Data Retention."; const requestedModels: string[] = []; const fallbackAppliedEvents: Array> = []; const mock = createMockModel({