Merge remote-tracking branch 'origin/farm/8573d9d3/v15-11-4-openai-responses-400-previous-r'
This commit is contained in:
@@ -23,6 +23,10 @@
|
||||
- Fixed explicit request-debug path mode to create missing parent directories before writing request logs
|
||||
- Fixed explicit request-debug mode to overwrite existing `.res.log` files for the requested path instead of failing when they already exist
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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,19 @@ 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 +458,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);
|
||||
|
||||
@@ -279,6 +279,56 @@ 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<Record<string, unknown>> = [];
|
||||
const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
const request = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
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<string, ProviderSessionState>();
|
||||
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<Record<string, unknown>> = [];
|
||||
const fetchMock = createCapturingFetch(sentRequests);
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
- Fixed snapcompact `toolResults` imaging confusing models into reporting tool malfunctions: the note prepended to rasterized tool results now tells the model the result is in the PNG frame(s) below and that the image delivery is deliberate, not a tool error, instead of the bare `[Rasterized]` marker
|
||||
- Fixed tool-call boxes rendering "inside themselves" after slow tool runs: a pending collapsed preview that sat byte-static past the stable-prefix window (e.g. an edit's tail-window diff while the apply + LSP pass ran) had its settled head committed to native scrollback, and the result render then stranded that stale call-box fragment above the final block. Pending collapsed tool previews are now provisional (`isTranscriptBlockCommitStable`) and never enter scrollback mid-run; expanded top-anchored streams keep committing mid-stream
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -818,6 +818,84 @@ 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<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user