Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 69 additions & 3 deletions open-sse/handlers/chatCore/idempotency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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 {
Expand Down Expand Up @@ -75,6 +140,7 @@ export async function checkIdempotencyCache({
provider,
model,
messages: (body as { messages?: unknown } | undefined)?.messages,
body,
});
const cachedIdemp = checkIdempotency(idempotencyKey);
if (cachedIdemp) {
Expand Down
48 changes: 48 additions & 0 deletions tests/unit/idempotency-fusion-collision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } })
);
});
Loading