mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 00:52:18 +03:00
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:
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } })
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user