Files
OmniRoute/open-sse/handlers/chatCore/idempotency.ts
Paco Cartones 30026b2e96 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.
2026-08-25 13:12:18 -03:00

183 lines
5.7 KiB
TypeScript

import { createHash } from "node:crypto";
import { getIdempotencyKey, checkIdempotency } from "@/lib/idempotencyLayer";
import { calculateCost } from "@/lib/usage/costCalculator";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import type { EffectiveServiceTier } from "./serviceTier.ts";
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 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 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.
*/
export function composeIdempotencyKey({
rawKey,
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(stableSerialize(semanticRequestBody(body, messages)))
.digest("hex")
.slice(0, 16);
} catch {
digest = "nodigest";
}
return `${rawKey}|${provider}|${model}|${digest}`;
}
/**
* Resolve the request's idempotency key once and check the idempotency store. Returns the
* resolved `idempotencyKey` alongside the cache `hit` so the caller can reuse the SAME key
* for the later save path instead of re-deriving it — eliminating the dual-derivation that
* the chatCore modularization (#3598) introduced. (#3821-review LEDGER-6)
*/
export async function checkIdempotencyCache({
clientRawRequest,
provider,
model,
body,
effectiveServiceTier,
startTime,
log,
}: {
clientRawRequest: IdempotencyRequest;
provider: string;
model: string;
body?: unknown;
effectiveServiceTier: EffectiveServiceTier | null | undefined;
startTime: number;
log: LoggerLike;
}): Promise<{ hit: { success: true; response: Response } | null; idempotencyKey: string | null }> {
// NEXA fusion-idempotency fix: namespace the raw header key (see composeIdempotencyKey).
const rawIdempotencyKey = getIdempotencyKey(clientRawRequest?.headers);
const idempotencyKey = composeIdempotencyKey({
rawKey: rawIdempotencyKey,
provider,
model,
messages: (body as { messages?: unknown } | undefined)?.messages,
body,
});
const cachedIdemp = checkIdempotency(idempotencyKey);
if (cachedIdemp) {
log?.debug?.("IDEMPOTENCY", `Hit for key=${idempotencyKey?.slice(0, 12)}...`);
const idempotentUsage =
cachedIdemp.response && typeof cachedIdemp.response === "object"
? ((cachedIdemp.response as Record<string, unknown>).usage as
Record<string, unknown> | undefined)
: undefined;
const idempotentCost = idempotentUsage
? await calculateCost(provider, model, idempotentUsage as Record<string, number>, {
serviceTier: effectiveServiceTier,
})
: 0;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-OmniRoute-Idempotent": "true",
};
attachOmniRouteMetaHeaders(headers, {
provider,
model,
cacheHit: false,
latencyMs: Date.now() - startTime,
usage: idempotentUsage,
costUsd: idempotentCost,
});
return {
idempotencyKey,
hit: {
success: true,
response: new Response(JSON.stringify(cachedIdemp.response), {
status: cachedIdemp.status,
headers,
}),
},
};
}
return { hit: null, idempotencyKey };
}