fix(idempotency): fingerprint Responses request semantics (#11532)

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.
This commit is contained in:
Paco Cartones
2026-08-25 18:12:18 +02:00
committed by GitHub
parent 3804ffb6ff
commit 30026b2e96
3 changed files with 118 additions and 3 deletions

View File

@@ -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.

View File

@@ -8,16 +8,79 @@ type HeadersLike = Headers | Record<string, unknown> | 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<string, unknown>;
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<string, unknown> {
if (!body || typeof body !== "object" || Array.isArray(body)) {
return { messages: legacyMessages };
}
const request = body as Record<string, unknown>;
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) {

View File

@@ -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 } })
);
});