mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
* fix(open-sse): stop concurrent requests colliding on dedup hash for non-OpenAI formats computeRequestHash() in requestDedup.ts projected the prompt content from body.messages only. The dedup site in chatCore.ts hashes the *translated* (target-format) request body, and non-OpenAI target formats don't carry a messages field: Gemini-translated bodies use `contents`, Responses-API bodies use `input`. So for those formats messages was always undefined, every prompt hashed to the same null-backed value for a given model, and concurrent requests with different prompts joined the same in-flight promise -- the second caller silently received the first caller's response verbatim (#10249). Fix: project body.messages ?? body.contents ?? body.input ?? null instead of only body.messages, keeping the rest of the canonical hash projection unchanged. Genuinely identical concurrent requests still dedupe (the intended perf behavior); different prompts under Gemini/Responses-API target formats no longer collide. Regression test: tests/unit/request-dedup-10249.test.ts reproduces the two collision scenarios from the plan-file (Gemini `contents`, Responses-API `input`), confirms the OpenAI `messages` case was already correct, and asserts identical-request dedup keeps working. Verified RED (byte-identical hashes 0b24fd88.../dc16d5b7... pre-fix) -> GREEN (distinct hashes, dedup preserved) against this exact diff. * fix(open-sse): cover nested translator shapes + system fields in dedup hash (#10438) computeRequestHash() only read top-level body.messages ?? body.contents ?? body.input, but several translated request shapes nest their prompt content: the Antigravity Cloud Code envelope under request.contents, and Kiro under conversationState.currentMessage.userInputMessage.content (plus conversationState.history). Two different concurrent prompts to those targets could hash identically and share/leak a response between callers. Adds extractPromptContent()/extractSystemContent() helpers covering every prompt-bearing shape produced by open-sse/translator/request/*.ts (OpenAI/Cursor messages, Claude messages+system, Gemini contents+ systemInstruction, Responses input+instructions, Antigravity and Kiro nesting), and folds system/instructions/systemInstruction into the canonical hash so two requests with the same user message but a different system prompt no longer collide either. --------- Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
222 lines
7.7 KiB
TypeScript
222 lines
7.7 KiB
TypeScript
/**
|
|
* Request Deduplication Service
|
|
*
|
|
* Deduplicates **concurrent** identical requests to the same upstream.
|
|
* Inspired by ClawRouter's dedup.ts (BlockRunAI / github.com/BlockRunAI/ClawRouter).
|
|
*
|
|
* IMPORTANT: In-memory only — does NOT persist across restarts and does NOT
|
|
* work across multiple process instances (no cross-instance dedup).
|
|
*/
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
const MAX_INFLIGHT = 1000;
|
|
|
|
export interface DedupConfig {
|
|
enabled: boolean;
|
|
maxTemperatureForDedup: number;
|
|
timeoutMs: number;
|
|
}
|
|
|
|
export const DEFAULT_DEDUP_CONFIG: DedupConfig = {
|
|
enabled: true,
|
|
maxTemperatureForDedup: 0.1,
|
|
timeoutMs: 60_000,
|
|
};
|
|
|
|
export interface DedupResult<T> {
|
|
result: T;
|
|
wasDeduplicated: boolean;
|
|
hash: string;
|
|
}
|
|
|
|
const inflight = new Map<string, Promise<unknown>>();
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Extract the prompt-bearing content from a (possibly translated) request body.
|
|
*
|
|
* The prompt content lives under different keys depending on the target
|
|
* provider format the body has already been translated to:
|
|
* - OpenAI-style bodies (`open-sse/translator/request/*-to-openai.ts`,
|
|
* `openai-to-cursor.ts`): `messages`
|
|
* - Gemini-translated bodies (`openai-to-gemini.ts`,
|
|
* `claude-to-gemini.ts`): `contents`
|
|
* - Responses-API-translated bodies (`openai-responses/toResponses.ts`):
|
|
* `input`
|
|
* - Antigravity-translated bodies (`openai-to-gemini.ts`
|
|
* `openaiToAntigravityRequest` / `wrapInCloudCodeEnvelope`): nested under
|
|
* `request.contents` (a Cloud Code envelope wrapper)
|
|
* - Kiro-translated bodies (`openai-to-kiro.ts` `buildKiroPayload`): nested
|
|
* under `conversationState.currentMessage.userInputMessage.content` (the
|
|
* current turn) plus `conversationState.history` (prior turns)
|
|
*
|
|
* Falling back to only `messages` made every non-OpenAI-format body hash the
|
|
* prompt as `null`, colliding different prompts onto the same dedup hash
|
|
* (#10249). The Antigravity/Kiro nesting was still missed by the flat
|
|
* `messages ?? contents ?? input` fallback chain, so different prompts
|
|
* targeting those two providers still collided (#10438).
|
|
*/
|
|
function extractPromptContent(body: Record<string, unknown>): unknown {
|
|
if (body.messages !== undefined) return body.messages;
|
|
if (body.contents !== undefined) return body.contents;
|
|
if (body.input !== undefined) return body.input;
|
|
|
|
// Antigravity Cloud Code envelope: { request: { contents, ... } }
|
|
const request = asRecord(body.request);
|
|
if (request && request.contents !== undefined) {
|
|
return request.contents;
|
|
}
|
|
|
|
// Kiro conversationState envelope:
|
|
// { conversationState: { currentMessage: { userInputMessage: { content } }, history } }
|
|
const conversationState = asRecord(body.conversationState);
|
|
if (conversationState) {
|
|
const currentMessage = asRecord(conversationState.currentMessage);
|
|
const userInputMessage = asRecord(currentMessage?.userInputMessage);
|
|
if (userInputMessage || conversationState.history !== undefined) {
|
|
return {
|
|
content: userInputMessage?.content ?? null,
|
|
history: conversationState.history ?? null,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract the system/instruction content that shapes generation but is not
|
|
* carried in the message list itself. Two requests with the same user
|
|
* message but a different system prompt must hash differently — omitting
|
|
* this field let them collide.
|
|
*
|
|
* - Claude-translated bodies (`openai-to-claude.ts`): `system`
|
|
* - Responses-API-translated bodies (`openai-responses/toResponses.ts`):
|
|
* `instructions`
|
|
* - Gemini-translated bodies (`openai-to-gemini.ts`, `claude-to-gemini.ts`):
|
|
* `systemInstruction`
|
|
* - Antigravity-translated bodies: nested under `request.systemInstruction`
|
|
* (note: the client system prompt is folded into `request.contents[0]`
|
|
* instead per #9030, so this is usually the constant Antigravity
|
|
* default — it is still included for completeness/future-proofing)
|
|
*/
|
|
function extractSystemContent(body: Record<string, unknown>): unknown {
|
|
if (body.system !== undefined) return body.system;
|
|
if (body.instructions !== undefined) return body.instructions;
|
|
if (body.systemInstruction !== undefined) return body.systemInstruction;
|
|
|
|
const request = asRecord(body.request);
|
|
if (request && request.systemInstruction !== undefined) {
|
|
return request.systemInstruction;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Compute a deterministic hash for a request body.
|
|
* Includes: model, messages/prompt content, system/instructions, temperature,
|
|
* tools, tool_choice, max_tokens, response_format
|
|
* Excludes: stream, user, metadata (don't affect LLM output)
|
|
*
|
|
* `computeRequestHash` is called post-translation (`chatCore.ts`, on
|
|
* `translatedBody`), so the body shape here is whatever the target provider
|
|
* format produced — see `extractPromptContent`/`extractSystemContent` for the
|
|
* full list of shapes this must cover (#10249, #10438).
|
|
*/
|
|
export function computeRequestHash(requestBody: unknown): string {
|
|
const body = requestBody as Record<string, unknown>;
|
|
const canonical = {
|
|
model: body.model ?? null,
|
|
messages: extractPromptContent(body),
|
|
system: extractSystemContent(body),
|
|
temperature: typeof body.temperature === "number" ? body.temperature : 1.0,
|
|
tools: body.tools ?? null,
|
|
tool_choice: body.tool_choice ?? null,
|
|
max_tokens: body.max_tokens ?? null,
|
|
response_format: body.response_format ?? null,
|
|
top_p: body.top_p ?? null,
|
|
frequency_penalty: body.frequency_penalty ?? null,
|
|
presence_penalty: body.presence_penalty ?? null,
|
|
};
|
|
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
/** Determine whether a request should be deduplicated */
|
|
export function shouldDeduplicate(
|
|
requestBody: unknown,
|
|
config: DedupConfig = DEFAULT_DEDUP_CONFIG
|
|
): boolean {
|
|
if (!config.enabled) return false;
|
|
const body = requestBody as Record<string, unknown>;
|
|
if (body.stream === true) return false;
|
|
const temperature = typeof body.temperature === "number" ? body.temperature : 1.0;
|
|
if (temperature > config.maxTemperatureForDedup) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Execute a request with deduplication.
|
|
* Concurrent identical requests share one upstream call.
|
|
*/
|
|
export async function deduplicate<T>(
|
|
hash: string,
|
|
fn: () => Promise<T>,
|
|
config: DedupConfig = DEFAULT_DEDUP_CONFIG
|
|
): Promise<DedupResult<T>> {
|
|
if (!config.enabled) {
|
|
return { result: await fn(), wasDeduplicated: false, hash };
|
|
}
|
|
|
|
const existing = inflight.get(hash);
|
|
if (existing) {
|
|
const result = (await existing) as T;
|
|
return { result, wasDeduplicated: true, hash };
|
|
}
|
|
|
|
if (inflight.size >= MAX_INFLIGHT) {
|
|
const oldestKey = inflight.keys().next().value;
|
|
if (oldestKey !== undefined) inflight.delete(oldestKey);
|
|
}
|
|
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason: unknown) => void;
|
|
const sharedPromise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
inflight.set(hash, sharedPromise as Promise<unknown>);
|
|
|
|
const timer = setTimeout(() => {
|
|
if (inflight.get(hash) === sharedPromise) inflight.delete(hash);
|
|
}, config.timeoutMs);
|
|
|
|
try {
|
|
const result = await fn();
|
|
resolve(result);
|
|
return { result, wasDeduplicated: false, hash };
|
|
} catch (err) {
|
|
reject(err);
|
|
throw err;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
if (inflight.get(hash) === sharedPromise) inflight.delete(hash);
|
|
}
|
|
}
|
|
|
|
export function getInflightCount(): number {
|
|
return inflight.size;
|
|
}
|
|
export function getInflightHashes(): string[] {
|
|
return [...inflight.keys()];
|
|
}
|
|
export function clearInflight(): void {
|
|
inflight.clear();
|
|
}
|