Files
OmniRoute/open-sse/handlers/chatCore/streamingSemanticCacheStore.ts
Amirreza Kimiyaei 04cc8aab67 fix(cache): fold the response output contract into the semantic cache signature (#12309)
* fix(cache): fold the response output contract into the semantic cache signature

The signature hashed only {model, messages, temperature, top_p}, so two temp=0
requests with identical messages but different response_format shared a cache
key: the second was served the first's stored body under a 200, violating the
schema it asked for. tools/tool_choice had the same exposure.

generateSignature now takes an optional output contract — response_format,
text.format, tools, tool_choice, collected by outputContractOf() — and folds it
into the digest only when present, so plain-chat signatures (and every cache
entry already written for them) are unchanged. All three call sites pass it;
read/write symmetry is preserved because bodyForCacheWrite snapshots the same
body object the read path hashed (#cache-signature-asymmetry).

Closes #12307

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(changelog): add fragment for #12307 semantic-cache output-contract fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(cache): populate both constraint spellings in outputContractOf

The merge with #12734 left generateSignature reading the camelCase
constraints (toolChoice/responseFormat) with a snake_case fallback, but
outputContractOf only filled the snake_case keys, so the #12734
"signature is called with tool_choice/tools/response_format from body"
store tests failed on the merged branch. Set both spellings so either
caller shape reads the value it expects.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: amirrezakm <amirrezakm@users.noreply.github.com>
2026-09-18 11:59:50 -03:00

106 lines
3.7 KiB
TypeScript

/**
* chatCore streaming semantic-cache store (Quality Gate v2 / Fase 9 — chatCore god-file
* decomposition, #3501).
*
* Extracted from handleChatCore's onStreamComplete callback: after a 200 streaming response is
* assembled, store it under its signature so a future temp=0 request can be served from cache.
* Side-effect only (cache write + debug log), wrapped in fail-open try/catch. Behaviour is
* byte-identical to the previous inline block — including the `_streamed` strip, the early
* skip-on-too-large, and the `Number(...) || 0` token accounting. The early return was the last
* statement of the callback, so returning from this helper is equivalent.
*/
import {
generateSignature as defaultGenerateSignature,
outputContractOf,
setCachedResponse as defaultSetCachedResponse,
isCacheableForWrite as defaultIsCacheableForWrite,
isTruncatedStreamBody as defaultIsTruncatedStreamBody,
} from "@/lib/semanticCache";
import { isSmallEnoughForSemanticCache as defaultIsSmallEnough } from "../../utils/estimateSize.ts";
type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined;
type CacheBody = {
messages?: unknown;
input?: unknown;
temperature?: number;
top_p?: number;
};
export interface StreamingSemanticCacheStoreDeps {
isCacheableForWrite: typeof defaultIsCacheableForWrite;
/** Optional so pre-existing callers/tests with partial deps keep working. */
isTruncatedStreamBody?: typeof defaultIsTruncatedStreamBody;
isSmallEnoughForSemanticCache: typeof defaultIsSmallEnough;
generateSignature: typeof defaultGenerateSignature;
setCachedResponse: typeof defaultSetCachedResponse;
}
const DEFAULT_DEPS: StreamingSemanticCacheStoreDeps = {
isCacheableForWrite: defaultIsCacheableForWrite,
isTruncatedStreamBody: defaultIsTruncatedStreamBody,
isSmallEnoughForSemanticCache: defaultIsSmallEnough,
generateSignature: defaultGenerateSignature,
setCachedResponse: defaultSetCachedResponse,
};
interface StreamingCacheArgs {
enabled: boolean;
streamStatus: number;
streamResponseBody: Record<string, unknown> | null | undefined;
body: CacheBody;
headers: unknown;
model: string;
apiKeyId?: string;
streamUsage?: Record<string, unknown> | null;
log?: LoggerLike;
}
function streamTokensSaved(streamUsage: Record<string, unknown> | null | undefined): number {
const u = streamUsage as Record<string, unknown> | null;
return (Number(u?.prompt_tokens ?? 0) || 0) + (Number(u?.completion_tokens ?? 0) || 0);
}
function writeStreamingCacheEntry(
args: StreamingCacheArgs,
deps: StreamingSemanticCacheStoreDeps
): void {
try {
const cleanBody = { ...(args.streamResponseBody as Record<string, unknown>) };
delete cleanBody._streamed;
if (!deps.isSmallEnoughForSemanticCache(cleanBody)) return;
const sig = deps.generateSignature(
args.model,
args.body.messages ?? args.body.input,
args.body.temperature,
args.body.top_p,
args.apiKeyId ?? undefined,
outputContractOf(args.body)
);
const tokensSaved = streamTokensSaved(args.streamUsage);
deps.setCachedResponse(sig, args.model, cleanBody, tokensSaved);
args.log?.debug?.(
"CACHE",
`Stored streaming response for ${args.model} (${tokensSaved} tokens)`
);
} catch {
// Cache write failed — non-critical
}
}
export function storeStreamingSemanticCacheResponse(
args: StreamingCacheArgs,
deps: StreamingSemanticCacheStoreDeps = DEFAULT_DEPS
): void {
if (
!args.enabled ||
args.streamStatus !== 200 ||
!args.streamResponseBody ||
!deps.isCacheableForWrite(args.body, args.headers) ||
(deps.isTruncatedStreamBody ?? defaultIsTruncatedStreamBody)(args.streamResponseBody)
) {
return;
}
writeStreamingCacheEntry(args, deps);
}