From 30026b2e96b1fb74a200c11ff89ab1be61e6617e Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:12:18 +0200 Subject: [PATCH] fix(idempotency): fingerprint Responses request semantics (#11532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated in a combined 10-PR batch worktree off release/v3.8.51 tip. - Focused test: tests/unit/idempotency-fusion-collision.test.ts — 8/8 pass - typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity gates — all OK - Full-repo lint: 503 pre-existing problems confirmed identical on the pure release/v3.8.51 tip — unrelated to this diff ⚠️ base-red inherited: #11449 Thanks for closing the idempotency-collision gap between unrelated Responses requests. --- .../oss026-idempotency-request-fingerprint.md | 1 + open-sse/handlers/chatCore/idempotency.ts | 72 ++++++++++++++++++- .../unit/idempotency-fusion-collision.test.ts | 48 +++++++++++++ 3 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/oss026-idempotency-request-fingerprint.md diff --git a/changelog.d/fixes/oss026-idempotency-request-fingerprint.md b/changelog.d/fixes/oss026-idempotency-request-fingerprint.md new file mode 100644 index 0000000000..91abcd39d3 --- /dev/null +++ b/changelog.d/fixes/oss026-idempotency-request-fingerprint.md @@ -0,0 +1 @@ +- Fix idempotency fingerprints so Responses API requests include `input` and other semantic generation fields using stable serialization, while excluding credentials and request metadata. diff --git a/open-sse/handlers/chatCore/idempotency.ts b/open-sse/handlers/chatCore/idempotency.ts index 0a6a01c178..664ea72648 100644 --- a/open-sse/handlers/chatCore/idempotency.ts +++ b/open-sse/handlers/chatCore/idempotency.ts @@ -8,16 +8,79 @@ type HeadersLike = Headers | Record | null | undefined; type IdempotencyRequest = { headers?: HeadersLike } | null | undefined; type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; +const IDEMPOTENCY_SEMANTIC_FIELDS = [ + "messages", + "input", + "instructions", + "tools", + "tool_choice", + "response_format", + "text", + "temperature", + "top_p", + "max_tokens", + "max_completion_tokens", + "max_output_tokens", + "reasoning", + "parallel_tool_calls", + "stream", + "stop", + "seed", + "n", + "modalities", + "audio", + "frequency_penalty", + "presence_penalty", + "logit_bias", + "logprobs", + "top_logprobs", + "verbosity", + "previous_response_id", + "conversation", + "prompt", + "include", + "truncation", + "service_tier", + "prediction", + "web_search_options", +] as const; + +function stableSerialize(value: unknown): string { + if (value === undefined) return "undefined"; + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`; + + const record = value as Record; + return `{${Object.keys(record) + .sort() + .filter((key) => record[key] !== undefined) + .map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`) + .join(",")}}`; +} + +function semanticRequestBody(body: unknown, legacyMessages: unknown): Record { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { messages: legacyMessages }; + } + const request = body as Record; + return Object.fromEntries( + IDEMPOTENCY_SEMANTIC_FIELDS.filter((field) => request[field] !== undefined).map((field) => [ + field, + request[field], + ]) + ); +} + /** * NEXA fusion-idempotency fix: compose the effective idempotency key from the raw - * header key + target provider/model + a digest of the request messages. + * header key + target provider/model + a digest of semantic request fields. * * Why: combo-internal sub-requests (fusion panel members AND the judge) re-enter * chatCore SHARING the client's headers, so the raw `Idempotency-Key`/`x-request-id` * key was identical for all of them. A panel answer saved under the key and the * judge's check (~1ms later, well inside the 5s window) replayed it — the client * received a panel member's answer instead of the judge synthesis. Namespacing by - * model separates panel members; the messages digest separates the judge even when + * model separates panel members; the request digest separates the judge even when * it reuses a panel member's model (the judge body appends the judge directive * turn). A genuine client retry (same key, same model, same body) still replays. */ @@ -26,17 +89,19 @@ export function composeIdempotencyKey({ provider, model, messages, + body, }: { rawKey: string | null | undefined; provider: string; model: string; messages: unknown; + body?: unknown; }): string | null { if (!rawKey) return null; let digest = ""; try { digest = createHash("sha256") - .update(JSON.stringify(messages ?? "")) + .update(stableSerialize(semanticRequestBody(body, messages))) .digest("hex") .slice(0, 16); } catch { @@ -75,6 +140,7 @@ export async function checkIdempotencyCache({ provider, model, messages: (body as { messages?: unknown } | undefined)?.messages, + body, }); const cachedIdemp = checkIdempotency(idempotencyKey); if (cachedIdemp) { diff --git a/tests/unit/idempotency-fusion-collision.test.ts b/tests/unit/idempotency-fusion-collision.test.ts index 1285b4a6a7..a6f2d6901a 100644 --- a/tests/unit/idempotency-fusion-collision.test.ts +++ b/tests/unit/idempotency-fusion-collision.test.ts @@ -97,3 +97,51 @@ test("genuine client retry (same key + model + body) -> SAME key (replay semanti }); assert.equal(a, b); }); + +test("Responses requests with different input get different keys", () => { + const base = { rawKey: "responses-1", provider: "openai", model: "gpt-5", messages: undefined }; + const first = composeIdempotencyKey({ ...base, body: { input: "first prompt" } }); + const second = composeIdempotencyKey({ ...base, body: { input: "second prompt" } }); + assert.notEqual(first, second); +}); + +test("semantic body serialization is stable and ignores credentials and request noise", () => { + const base = { rawKey: "responses-2", provider: "openai", model: "gpt-5", messages: undefined }; + const first = composeIdempotencyKey({ + ...base, + body: { + input: [{ role: "user", content: "hello" }], + tools: [{ type: "function", name: "lookup", parameters: { type: "object" } }], + metadata: { trace: "one" }, + api_key: "secret-one", + }, + }); + const second = composeIdempotencyKey({ + ...base, + body: { + tools: [{ parameters: { type: "object" }, name: "lookup", type: "function" }], + input: [{ content: "hello", role: "user" }], + metadata: { trace: "two" }, + api_key: "secret-two", + }, + }); + assert.equal(first, second); +}); + +test("Chat and Responses generation limits participate in the fingerprint", () => { + const base = { + rawKey: "responses-3", + provider: "openai", + model: "gpt-5", + messages: undefined, + body: { input: "hello", temperature: 0 }, + }; + assert.notEqual( + composeIdempotencyKey(base), + composeIdempotencyKey({ ...base, body: { ...base.body, temperature: 1 } }) + ); + assert.notEqual( + composeIdempotencyKey({ ...base, body: { input: "hello", max_output_tokens: 100 } }), + composeIdempotencyKey({ ...base, body: { input: "hello", max_output_tokens: 200 } }) + ); +});