mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 22:32:22 +03:00
fix(cache): make the vector layer opt-in and restore tip cache/discovery contracts (#14159)
The re-land of #12630 validated only its own tests and broke 15 existing ones. Three root causes, all fixed in production code so the tip's tests are untouched: 1. Dual-layer manager was ON by default. `DEFAULT_SEMANTIC_CACHE_CONFIG.enabled` was `true` and the DB bridge wired it to the pre-existing `semanticCacheEnabled` toggle (also default `true`), so every temperature=0 request called an embedding endpoint (lemonade @ localhost:13305 by default) on lookup AND store — two extra fetches per chat call (chat-combo-live-test, issue-agent-route-execution). Fix: new `semanticCacheVectorEnabled` setting (default false; types, DB keys, cache-config route, settings UI sub-toggle) and the bridge only enables the manager when BOTH toggles are on. Manager default is now `enabled: false`. With the opt-in off, chatCore behaves exactly like the legacy SQLite cache. 2. `X-OmniRoute-Cache` value changed from `HIT` to `HIT (exact)`/`HIT (semantic)`, and `cacheSource: "semantic_similarity"` fell through attemptLogging's `"semantic" | "upstream"` narrowing as "upstream". Fix: keep `HIT` verbatim (similarity hits are distinguished by X-OmniRoute-Cache-Similarity) and log both hit types as `cacheSource: "semantic"`. The PR's edits to chatcore-semantic-cache.test.ts are reverted to the tip version. 3. Model discovery stamped `modelType: "chat"` + `supportedInputTypes: ["text"]` on every model (import-mode diff noise, 6 catalog tests), and endpoint-based modality inference tagged a `["chat","embeddings"]` model as embedding-only (`apiFormat: "embeddings"`), dropping it from the chat catalog. Fix: an explicit chat endpoint vetoes the embedding/rerank/image heuristics; chat models keep the tip's row shape (metadata only stamped for non-chat modalities). Also: - Restore the tip's `isTruncatedStreamBody` dep in streamingSemanticCacheStore and widen the predicate to the assembled-object body chatCore actually passes (the PR's object-shaped streaming cases are appended to the tip's test file). - Pass `provider` from chatCore to both cache store paths — the manager scopes entries per provider on lookup, so writes without it could never hit. - test-embedding route: `validateBody()` has no `.response`; invalid payloads returned `undefined` (TS2339 in api-typecheck). Now a proper 400. - New guard: tests/unit/semantic-cache-vector-layer-opt-in.test.ts.
This commit is contained in:
@@ -11,7 +11,13 @@ export type SemanticCacheBackend = "memory" | "redis";
|
||||
export type SemanticCacheType = "direct" | "semantic" | "both";
|
||||
|
||||
export interface SemanticCacheConfig {
|
||||
/** Master toggle for semantic caching. */
|
||||
/**
|
||||
* Master toggle for the dual-layer (in-memory/Redis vector) manager. OFF by default:
|
||||
* the legacy SQLite exact-match cache (`semanticCacheEnabled`) keeps working on its
|
||||
* own; this layer is opt-in via the `semanticCacheVectorEnabled` setting or
|
||||
* `OMNIROUTE_SEMANTIC_CACHE_ENABLED=true`, because enabling it calls an embedding
|
||||
* endpoint on every cacheable request (#14159 re-land of #12630).
|
||||
*/
|
||||
enabled: boolean;
|
||||
/** Storage and vector backend. Defaults to "memory". */
|
||||
backend: SemanticCacheBackend;
|
||||
@@ -52,7 +58,7 @@ export interface SemanticCacheConfig {
|
||||
}
|
||||
|
||||
export const DEFAULT_SEMANTIC_CACHE_CONFIG: SemanticCacheConfig = {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
backend: "memory",
|
||||
similarityThreshold: 0.8,
|
||||
ttlMs: 1800000, // 30 minutes
|
||||
|
||||
@@ -5577,6 +5577,9 @@ export async function handleChatCore({
|
||||
headers: clientRawRequest?.headers,
|
||||
translatedResponse,
|
||||
model,
|
||||
// The dual-layer manager scopes entries per provider (cacheByProvider);
|
||||
// lookup passes the resolved provider, so the write must too (#14159).
|
||||
provider,
|
||||
apiKeyId: apiKeyInfo?.id ?? undefined,
|
||||
usage,
|
||||
log,
|
||||
@@ -6081,6 +6084,7 @@ export async function handleChatCore({
|
||||
body: bodyForCacheWrite,
|
||||
headers: clientRawRequest?.headers,
|
||||
model,
|
||||
provider,
|
||||
apiKeyId: apiKeyInfo?.id ?? undefined,
|
||||
streamUsage,
|
||||
log,
|
||||
|
||||
@@ -106,7 +106,10 @@ export async function checkSemanticCache({
|
||||
providerRequest: null,
|
||||
providerResponse: null,
|
||||
clientResponse: cached,
|
||||
cacheSource: hitType === "semantic" ? "semantic_similarity" : "semantic",
|
||||
// Both hit types are served without an upstream call; attemptLogging only
|
||||
// knows "semantic" | "upstream", so a similarity hit must not fall through
|
||||
// to "upstream" (#14159). The hit type is surfaced via the response headers.
|
||||
cacheSource: "semantic",
|
||||
});
|
||||
// Finalize by exact request id (#12910): a (model, provider, connectionId)
|
||||
// tuple can match the wrong in-flight request when connectionId is null or
|
||||
@@ -148,8 +151,12 @@ export async function checkSemanticCache({
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": cachedSse ? "text/event-stream" : "application/json",
|
||||
[OMNIROUTE_RESPONSE_HEADERS.cache]:
|
||||
hitType === "semantic" ? "HIT (semantic)" : "HIT (exact)",
|
||||
// Keep the legacy `HIT` value verbatim: consumers match it exactly
|
||||
// (tests/unit/chatcore-semantic-cache.test.ts and the chat-route suites).
|
||||
// A similarity hit is distinguished by X-OmniRoute-Cache-Similarity below.
|
||||
[OMNIROUTE_RESPONSE_HEADERS.cache]: "HIT",
|
||||
// Marker for latency measurement tools: this response served from cache
|
||||
// has synthetic (near-zero) latency, not real upstream latency.
|
||||
[OMNIROUTE_RESPONSE_HEADERS.cacheLatency]: "synthetic",
|
||||
[OMNIROUTE_RESPONSE_HEADERS.savingsTokens]: String(tokensSaved),
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
outputContractOf,
|
||||
setCachedResponse as defaultSetCachedResponse,
|
||||
isCacheableForWrite as defaultIsCacheableForWrite,
|
||||
isTruncatedCompletion as defaultIsTruncatedCompletion,
|
||||
isTruncatedStreamBody as defaultIsTruncatedStreamBody,
|
||||
} from "@/lib/semanticCache";
|
||||
import { isSmallEnoughForSemanticCache as defaultIsSmallEnough } from "../../utils/estimateSize.ts";
|
||||
import { getSemanticCacheManager } from "../../services/cache/semanticCacheManager.ts";
|
||||
@@ -31,7 +31,7 @@ type CacheBody = {
|
||||
export interface StreamingSemanticCacheStoreDeps {
|
||||
isCacheableForWrite: typeof defaultIsCacheableForWrite;
|
||||
/** Optional so pre-existing callers/tests with partial deps keep working. */
|
||||
isTruncatedCompletion?: typeof defaultIsTruncatedCompletion;
|
||||
isTruncatedStreamBody?: typeof defaultIsTruncatedStreamBody;
|
||||
isSmallEnoughForSemanticCache: typeof defaultIsSmallEnough;
|
||||
generateSignature: typeof defaultGenerateSignature;
|
||||
setCachedResponse: typeof defaultSetCachedResponse;
|
||||
@@ -39,7 +39,7 @@ export interface StreamingSemanticCacheStoreDeps {
|
||||
|
||||
const DEFAULT_DEPS: StreamingSemanticCacheStoreDeps = {
|
||||
isCacheableForWrite: defaultIsCacheableForWrite,
|
||||
isTruncatedCompletion: defaultIsTruncatedCompletion,
|
||||
isTruncatedStreamBody: defaultIsTruncatedStreamBody,
|
||||
isSmallEnoughForSemanticCache: defaultIsSmallEnough,
|
||||
generateSignature: defaultGenerateSignature,
|
||||
setCachedResponse: defaultSetCachedResponse,
|
||||
@@ -112,7 +112,7 @@ export function storeStreamingSemanticCacheResponse(
|
||||
args.streamStatus !== 200 ||
|
||||
!args.streamResponseBody ||
|
||||
!deps.isCacheableForWrite(args.body, args.headers) ||
|
||||
(deps.isTruncatedCompletion ?? defaultIsTruncatedCompletion)(args.streamResponseBody)
|
||||
(deps.isTruncatedStreamBody ?? defaultIsTruncatedStreamBody)(args.streamResponseBody)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface CacheConfigResponse {
|
||||
semanticCacheEnabled?: boolean;
|
||||
semanticCacheMaxSize?: number;
|
||||
semanticCacheTTL?: number;
|
||||
semanticCacheVectorEnabled?: boolean;
|
||||
semanticCacheBackend?: "memory" | "redis";
|
||||
semanticCacheThreshold?: number;
|
||||
semanticCacheEmbeddingProvider?: string;
|
||||
@@ -58,6 +59,8 @@ export default function CacheSettingsTab() {
|
||||
|
||||
// Semantic Cache State
|
||||
const [semEnabled, setSemEnabled] = useState(true);
|
||||
// Vector-similarity layer is opt-in (#14159): off unless the operator turns it on.
|
||||
const [semVectorEnabled, setSemVectorEnabled] = useState(false);
|
||||
const [semBackend, setSemBackend] = useState<"memory" | "redis">("memory");
|
||||
const [semThreshold, setSemThreshold] = useState(0.8);
|
||||
const [semTtlMinutes, setSemTtlMinutes] = useState(30);
|
||||
@@ -113,6 +116,9 @@ export default function CacheSettingsTab() {
|
||||
if (config.semanticCacheEnabled !== undefined) {
|
||||
setSemEnabled(config.semanticCacheEnabled);
|
||||
}
|
||||
if (config.semanticCacheVectorEnabled !== undefined) {
|
||||
setSemVectorEnabled(config.semanticCacheVectorEnabled);
|
||||
}
|
||||
if (config.semanticCacheBackend === "redis" || config.semanticCacheBackend === "memory") {
|
||||
setSemBackend(config.semanticCacheBackend);
|
||||
}
|
||||
@@ -250,6 +256,7 @@ export default function CacheSettingsTab() {
|
||||
|
||||
const payload = {
|
||||
semanticCacheEnabled: semEnabled,
|
||||
semanticCacheVectorEnabled: semVectorEnabled,
|
||||
semanticCacheBackend: semBackend,
|
||||
semanticCacheThreshold: Number(semThreshold),
|
||||
semanticCacheTTL: semTtlMinutes * 60000,
|
||||
@@ -340,8 +347,8 @@ export default function CacheSettingsTab() {
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Local vector-similarity cache. Reuses high-confidence matching responses to cut
|
||||
latency and upstream token costs.
|
||||
Exact-match response cache with an optional vector-similarity layer. Reuses matching
|
||||
responses to cut latency and upstream token costs.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
@@ -353,6 +360,25 @@ export default function CacheSettingsTab() {
|
||||
|
||||
{semEnabled && (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Vector-similarity layer opt-in (default off) */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
Vector Similarity Layer (embeddings)
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Off by default. When on, every cacheable request is embedded via the provider
|
||||
below so near-duplicate prompts can reuse a cached answer. Exact-match caching
|
||||
keeps working without it.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={semVectorEnabled}
|
||||
onChange={setSemVectorEnabled}
|
||||
ariaLabel="Enable vector similarity layer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider & Model Selection Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
|
||||
@@ -18,6 +18,7 @@ const cacheConfigUpdateSchema = z.object({
|
||||
semanticCacheEnabled: z.boolean().optional(),
|
||||
semanticCacheMaxSize: z.number().positive().optional(),
|
||||
semanticCacheTTL: z.number().positive().optional(),
|
||||
semanticCacheVectorEnabled: z.boolean().optional(),
|
||||
semanticCacheBackend: z.enum(["memory", "redis"]).optional(),
|
||||
semanticCacheThreshold: z.number().min(0).max(1).optional(),
|
||||
semanticCacheEmbeddingProvider: z.string().trim().optional(),
|
||||
@@ -39,6 +40,7 @@ const CACHE_CONFIG_KEYS = [
|
||||
"semanticCacheEnabled",
|
||||
"semanticCacheMaxSize",
|
||||
"semanticCacheTTL",
|
||||
"semanticCacheVectorEnabled",
|
||||
"semanticCacheBackend",
|
||||
"semanticCacheThreshold",
|
||||
"semanticCacheEmbeddingProvider",
|
||||
@@ -60,6 +62,7 @@ const DEFAULTS = {
|
||||
semanticCacheEnabled: true,
|
||||
semanticCacheMaxSize: 1000,
|
||||
semanticCacheTTL: 1800000,
|
||||
semanticCacheVectorEnabled: false,
|
||||
semanticCacheBackend: "memory",
|
||||
semanticCacheThreshold: 0.8,
|
||||
semanticCacheEmbeddingProvider: "lemonade",
|
||||
@@ -143,6 +146,9 @@ export async function PUT(request: NextRequest) {
|
||||
if (body.semanticCacheTTL !== undefined) {
|
||||
updates.semanticCacheTTL = body.semanticCacheTTL;
|
||||
}
|
||||
if (body.semanticCacheVectorEnabled !== undefined) {
|
||||
updates.semanticCacheVectorEnabled = body.semanticCacheVectorEnabled;
|
||||
}
|
||||
if (body.semanticCacheBackend !== undefined) {
|
||||
updates.semanticCacheBackend = body.semanticCacheBackend;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ export async function POST(request: Request) {
|
||||
|
||||
const validation = validateBody(testEmbeddingSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return validation.response;
|
||||
// `validateBody()` returns `{ success, error }` — there is no `.response`
|
||||
// (that shape belongs to `validatedJsonBody()`); returning `undefined` here
|
||||
// crashed the route on any invalid payload (TS2339 caught by api-typecheck).
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const { provider, model, baseUrl, apiKey } = validation.data;
|
||||
|
||||
5
src/lib/cache/semanticCacheDbBridge.ts
vendored
5
src/lib/cache/semanticCacheDbBridge.ts
vendored
@@ -58,7 +58,10 @@ export function ensureSemanticCacheDbBridge(): void {
|
||||
const embeddingApiKey = s.semanticCacheEmbeddingApiKey || conn.apiKey;
|
||||
|
||||
return {
|
||||
enabled: s.semanticCacheEnabled,
|
||||
// The vector layer is opt-in (#14159): it only runs when the operator turned
|
||||
// on BOTH the master semantic-cache toggle and the vector-layer toggle. With
|
||||
// it off, chatCore behaves exactly like the legacy SQLite exact-match cache.
|
||||
enabled: s.semanticCacheEnabled !== false && s.semanticCacheVectorEnabled === true,
|
||||
backend: s.semanticCacheBackend,
|
||||
similarityThreshold: s.semanticCacheThreshold,
|
||||
ttlMs: s.semanticCacheTTL,
|
||||
|
||||
@@ -37,6 +37,7 @@ const LEGACY_FLAT_KEYS: {
|
||||
semanticCacheEnabled: ["semanticCacheEnabled"],
|
||||
semanticCacheMaxSize: ["semanticCacheMaxSize"],
|
||||
semanticCacheTTL: ["semanticCacheTTL"],
|
||||
semanticCacheVectorEnabled: ["semanticCacheVectorEnabled"],
|
||||
semanticCacheBackend: ["semanticCacheBackend"],
|
||||
semanticCacheThreshold: ["semanticCacheThreshold"],
|
||||
semanticCacheEmbeddingProvider: ["semanticCacheEmbeddingProvider"],
|
||||
|
||||
@@ -424,6 +424,15 @@ const KNOWN_EMBEDDING_PREFIXES = [
|
||||
"multilingual-e5",
|
||||
];
|
||||
|
||||
/** Mirrors CHAT_ENDPOINTS in open-sse/services/modelEndpointPolicy.ts. */
|
||||
const CHAT_ENDPOINT_HINTS = new Set([
|
||||
"chat",
|
||||
"chat-completions",
|
||||
"chat/completions",
|
||||
"messages",
|
||||
"responses",
|
||||
]);
|
||||
|
||||
const KNOWN_EMBEDDING_DIMENSIONS: Record<string, number> = {
|
||||
"harrier-oss-v1-0.6b": 1024,
|
||||
"text-embedding-3-small": 1536,
|
||||
@@ -467,14 +476,22 @@ export function detectModelModality(
|
||||
(m) => m.id === modelLeaf || m.id === rawId || rawId.endsWith(`/${m.id}`)
|
||||
);
|
||||
|
||||
// An explicit chat endpoint is authoritative: a model that upstream says serves
|
||||
// chat (e.g. `supportedEndpoints: ["chat", "embeddings"]`) must never be
|
||||
// downgraded to embedding/rerank/image by the id/label heuristics below —
|
||||
// that would drop it from the chat catalog (#14159 re-land of #12630).
|
||||
const hasChatEndpoint = rawEndpoints.some((endpoint) => CHAT_ENDPOINT_HINTS.has(endpoint));
|
||||
|
||||
const isRerank =
|
||||
rawLabels.includes("reranking") ||
|
||||
rawLabels.includes("rerank") ||
|
||||
typeStr === "rerank" ||
|
||||
rawEndpoints.includes("rerank") ||
|
||||
modelLeaf.includes("rerank");
|
||||
!hasChatEndpoint &&
|
||||
(rawLabels.includes("reranking") ||
|
||||
rawLabels.includes("rerank") ||
|
||||
typeStr === "rerank" ||
|
||||
rawEndpoints.includes("rerank") ||
|
||||
modelLeaf.includes("rerank"));
|
||||
|
||||
const isImage =
|
||||
!hasChatEndpoint &&
|
||||
!isRerank &&
|
||||
(rawLabels.includes("image") ||
|
||||
rawLabels.includes("images") ||
|
||||
@@ -490,6 +507,7 @@ export function detectModelModality(
|
||||
modelLeaf.startsWith("stable-diffusion"));
|
||||
|
||||
const isEmbedding =
|
||||
!hasChatEndpoint &&
|
||||
!isRerank &&
|
||||
!isImage &&
|
||||
(rawLabels.includes("embeddings") ||
|
||||
@@ -569,13 +587,17 @@ export function normalizeDiscoveredModels(
|
||||
id;
|
||||
|
||||
const modality = detectModelModality(record, providerId);
|
||||
// Only non-chat modalities are stamped on the synced row. Chat models keep the
|
||||
// tip's exact shape (no `modelType`/`supportedInputTypes` defaults) so the
|
||||
// import-mode diff stays stable and existing catalog snapshots do not churn.
|
||||
const modelType = modality.isEmbedding
|
||||
? "embedding"
|
||||
: modality.isRerank
|
||||
? "rerank"
|
||||
: modality.isImage
|
||||
? "image"
|
||||
: "chat";
|
||||
: undefined;
|
||||
const explicitInputTypes = Array.isArray(record.supportedInputTypes);
|
||||
|
||||
const supportedEndpoints = Array.isArray(record.supportedEndpoints)
|
||||
? Array.from(
|
||||
@@ -689,10 +711,10 @@ export function normalizeDiscoveredModels(
|
||||
...(typeof modality.dimensions === "number" && modality.dimensions > 0
|
||||
? { dimensions: modality.dimensions }
|
||||
: {}),
|
||||
...(modality.supportedInputTypes.length > 0
|
||||
...((modelType || explicitInputTypes) && modality.supportedInputTypes.length > 0
|
||||
? { supportedInputTypes: modality.supportedInputTypes }
|
||||
: {}),
|
||||
modelType,
|
||||
...(modelType ? { modelType } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -548,6 +548,10 @@ export function isTruncatedCompletion(response: unknown): boolean {
|
||||
* disables caching.
|
||||
*/
|
||||
export function isTruncatedStreamBody(streamBody: unknown): boolean {
|
||||
// chatCore hands the streaming store the *assembled* body (an object with
|
||||
// `choices[].finish_reason`), not raw SSE text — so the object shape must be
|
||||
// checked too or the streaming guard is a no-op in production (#14159).
|
||||
if (streamBody && typeof streamBody === "object") return isTruncatedCompletion(streamBody);
|
||||
if (typeof streamBody !== "string" || streamBody.length === 0) return false;
|
||||
for (const line of streamBody.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface DatabaseSettings {
|
||||
semanticCacheEnabled: boolean;
|
||||
semanticCacheMaxSize: number;
|
||||
semanticCacheTTL: number;
|
||||
/**
|
||||
* Opt-in for the dual-layer vector-similarity cache (#14159). Off by default:
|
||||
* it makes an embedding call per cacheable request, so it must never be on
|
||||
* for an operator who only enabled the legacy exact-match cache.
|
||||
*/
|
||||
semanticCacheVectorEnabled?: boolean;
|
||||
semanticCacheBackend?: "memory" | "redis";
|
||||
semanticCacheThreshold?: number;
|
||||
semanticCacheEmbeddingProvider?: string;
|
||||
@@ -120,6 +126,7 @@ export const DEFAULT_DATABASE_SETTINGS: Omit<DatabaseSettings, "location" | "sta
|
||||
semanticCacheEnabled: true,
|
||||
semanticCacheMaxSize: 1000,
|
||||
semanticCacheTTL: 1800000,
|
||||
semanticCacheVectorEnabled: false,
|
||||
semanticCacheBackend: "memory",
|
||||
semanticCacheThreshold: 0.8,
|
||||
semanticCacheEmbeddingProvider: "lemonade",
|
||||
|
||||
@@ -227,11 +227,7 @@ test("checkSemanticCache returns a non-streaming JSON HIT with cache headers + l
|
||||
assert.ok(result, "HIT -> non-null result");
|
||||
assert.equal(result.success, true, "HIT result.success is true");
|
||||
const res = result.response as Response;
|
||||
assert.equal(
|
||||
res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache),
|
||||
"HIT (exact)",
|
||||
"X-OmniRoute-Cache: HIT (exact)"
|
||||
);
|
||||
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT", "X-OmniRoute-Cache: HIT");
|
||||
assert.equal(
|
||||
res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cacheHit),
|
||||
"true",
|
||||
@@ -293,7 +289,7 @@ test("checkSemanticCache returns a streaming SSE HIT (text/event-stream) when st
|
||||
"text/event-stream",
|
||||
"streaming HIT -> text/event-stream"
|
||||
);
|
||||
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT (exact)");
|
||||
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT");
|
||||
const bodyText = await res.text();
|
||||
assert.ok(bodyText.includes("data: "), "SSE body contains data frames");
|
||||
assert.ok(bodyText.includes("streamed cached answer"), "SSE body carries the cached content");
|
||||
@@ -328,7 +324,7 @@ test("checkSemanticCache HITs even when the cached body has no usage (cost falls
|
||||
assert.ok(result, "HIT with no usage -> non-null result");
|
||||
assert.equal(result.success, true);
|
||||
const res = result.response as Response;
|
||||
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT (exact)");
|
||||
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT");
|
||||
// cachedUsage resolves to undefined -> cachedCost = 0 -> the zero-cost sentinel header.
|
||||
assert.equal(
|
||||
res.headers.get(OMNIROUTE_RESPONSE_HEADERS.responseCost),
|
||||
@@ -379,7 +375,7 @@ test("checkSemanticCache HIT bills 0 incremental cost and reports the original c
|
||||
assert.ok(result, "HIT -> non-null result");
|
||||
const res = result.response as Response;
|
||||
|
||||
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT (exact)");
|
||||
assert.equal(res.headers.get(OMNIROUTE_RESPONSE_HEADERS.cache), "HIT");
|
||||
// Incremental cost billed to the client on a HIT is 0 (no upstream call happened).
|
||||
assert.equal(
|
||||
res.headers.get(OMNIROUTE_RESPONSE_HEADERS.responseCost),
|
||||
@@ -509,13 +505,10 @@ test("checkSemanticCache HIT includes X-OmniRoute-Cache-Latency: synthetic heade
|
||||
);
|
||||
});
|
||||
|
||||
test("checkSemanticCache HIT finalizes the exact pending request by id (#12910)", async () => {
|
||||
test("checkSemanticCache HIT finalizes the exact pending request by id", async () => {
|
||||
clearCache();
|
||||
const {
|
||||
clearPendingRequests,
|
||||
getPendingById,
|
||||
trackPendingRequest: trackPending,
|
||||
} = await import("../../src/lib/usage/usageHistory.ts");
|
||||
const { clearPendingRequests, getPendingById, trackPendingRequest } =
|
||||
await import("../../src/lib/usage/usageHistory.ts");
|
||||
const { getCompletedDetails } = await import("../../src/lib/usage/completedRequestDetails.ts");
|
||||
clearPendingRequests();
|
||||
try {
|
||||
@@ -530,7 +523,7 @@ test("checkSemanticCache HIT finalizes the exact pending request by id (#12910)"
|
||||
],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 5, total_tokens: 10 },
|
||||
};
|
||||
const pendingId = trackPending("gpt-4o", "openai", "account-a", true);
|
||||
const pendingId = trackPendingRequest("gpt-4o", "openai", "account-a", true);
|
||||
assert.ok(pendingId);
|
||||
const { args } = makeHitArgs({
|
||||
body: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// a mid-sentence answer that no retry can clear (only a cache flush).
|
||||
// Observed live on OmniRoute against github/gemini-3.5-flash: temperature:0 returned
|
||||
// finish_reason "length" at 93 completion tokens on every call, while the same request
|
||||
// with x-omniroute-no-cache:true returned a complete 239-token response. (#12885)
|
||||
// with x-omniroute-no-cache:true returned a complete 239-token response.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
@@ -12,14 +12,15 @@ const { storeSemanticCacheResponse } =
|
||||
await import("../../open-sse/handlers/chatCore/semanticCacheStore.ts");
|
||||
const { storeStreamingSemanticCacheResponse } =
|
||||
await import("../../open-sse/handlers/chatCore/streamingSemanticCacheStore.ts");
|
||||
// Real truncation predicate — only the cache backend and the temperature/size
|
||||
// Real truncation predicates — only the cache backend and the temperature/size
|
||||
// gates are stubbed, so these tests exercise the actual detection logic.
|
||||
const { isTruncatedCompletion } = await import("../../src/lib/semanticCache.ts");
|
||||
const { isTruncatedCompletion, isTruncatedStreamBody } = await import("@/lib/semanticCache");
|
||||
|
||||
function deps(stored: unknown[]) {
|
||||
return {
|
||||
isCacheableForWrite: () => true,
|
||||
isTruncatedCompletion,
|
||||
isTruncatedStreamBody,
|
||||
isSmallEnoughForSemanticCache: () => true,
|
||||
generateSignature: () => "sig",
|
||||
setCachedResponse: (_s: unknown, _m: string, r: unknown, t: number) => stored.push({ r, t }),
|
||||
@@ -63,6 +64,28 @@ test("still caches a complete non-streaming response", () => {
|
||||
});
|
||||
|
||||
test("does not cache a streaming response truncated by max_tokens", () => {
|
||||
const stored: unknown[] = [];
|
||||
storeStreamingSemanticCacheResponse(
|
||||
{
|
||||
enabled: true,
|
||||
streamStatus: 200,
|
||||
streamResponseBody:
|
||||
'data: {"choices":[{"finish_reason":"length","delta":{"content":"partial"}}]}\n\ndata: [DONE]\n\n',
|
||||
body: { messages: [{ role: "user", content: "hi" }], temperature: 0 },
|
||||
headers: undefined,
|
||||
model: "gemini-3.5-flash",
|
||||
streamUsage: { prompt_tokens: 10, completion_tokens: 93 },
|
||||
} as never,
|
||||
deps(stored)
|
||||
);
|
||||
assert.equal(stored.length, 0, "truncated streaming response must not be cached");
|
||||
});
|
||||
|
||||
// #14159 (re-land of #12630): chatCore hands the streaming store the *assembled*
|
||||
// body (an object with `choices[].finish_reason`), not raw SSE text. The guard must
|
||||
// therefore also work on that shape — otherwise the streaming half of #12885 is a
|
||||
// no-op in production. The SSE-string case above is kept as-is (tip contract).
|
||||
test("does not cache an assembled streaming body truncated by max_tokens", () => {
|
||||
const stored: unknown[] = [];
|
||||
storeStreamingSemanticCacheResponse(
|
||||
{
|
||||
@@ -78,10 +101,10 @@ test("does not cache a streaming response truncated by max_tokens", () => {
|
||||
},
|
||||
deps(stored)
|
||||
);
|
||||
assert.equal(stored.length, 0, "truncated streaming response must not be cached");
|
||||
assert.equal(stored.length, 0, "truncated assembled streaming body must not be cached");
|
||||
});
|
||||
|
||||
test("still caches a complete streaming response", () => {
|
||||
test("still caches a complete assembled streaming body", () => {
|
||||
const stored: unknown[] = [];
|
||||
storeStreamingSemanticCacheResponse(
|
||||
{
|
||||
|
||||
94
tests/unit/semantic-cache-vector-layer-opt-in.test.ts
Normal file
94
tests/unit/semantic-cache-vector-layer-opt-in.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// Regression guard for #14159 (re-land of #12630, dual-layer semantic cache).
|
||||
//
|
||||
// The original PR wired the new vector-similarity layer to the pre-existing
|
||||
// `semanticCacheEnabled` toggle (default ON), so every installation started
|
||||
// calling an embedding endpoint (lemonade @ localhost:13305 by default) on every
|
||||
// temperature=0 request — two extra `fetch()`es per chat call, visible as
|
||||
// `fetchCalls.length === 3` in tests/unit/chat-combo-live-test.test.ts.
|
||||
//
|
||||
// Contract enforced here:
|
||||
// 1. The manager config is OFF by default (no env, no DB override).
|
||||
// 2. The DB bridge only enables the manager when BOTH the master toggle and the
|
||||
// new `semanticCacheVectorEnabled` opt-in are on.
|
||||
// 3. A model whose upstream record declares a chat endpoint is never downgraded
|
||||
// to embedding/rerank/image by the modality heuristics, and chat models keep
|
||||
// the tip's row shape (no `modelType`/`supportedInputTypes` defaults).
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-semcache-optin-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
delete process.env.OMNIROUTE_SEMANTIC_CACHE_ENABLED;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { DEFAULT_SEMANTIC_CACHE_CONFIG, resolveSemanticCacheConfig } =
|
||||
await import("../../open-sse/config/semanticCacheConfig.ts");
|
||||
const { ensureSemanticCacheDbBridge } =
|
||||
await import("../../src/lib/cache/semanticCacheDbBridge.ts");
|
||||
const { getDatabaseSettings, updateDatabaseSettings } =
|
||||
await import("../../src/lib/db/databaseSettings.ts");
|
||||
const { DEFAULT_DATABASE_SETTINGS } = await import("../../src/types/databaseSettings.ts");
|
||||
const { detectModelModality, normalizeDiscoveredModels } =
|
||||
await import("../../src/lib/providerModels/modelDiscovery.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("dual-layer manager is off by default (no env, no DB override)", () => {
|
||||
assert.equal(DEFAULT_SEMANTIC_CACHE_CONFIG.enabled, false);
|
||||
assert.equal(DEFAULT_DATABASE_SETTINGS.cache.semanticCacheVectorEnabled, false);
|
||||
// Legacy exact-match cache default is untouched (tip contract).
|
||||
assert.equal(DEFAULT_DATABASE_SETTINGS.cache.semanticCacheEnabled, true);
|
||||
});
|
||||
|
||||
test("DB bridge enables the vector layer only when master + vector toggles are both on", () => {
|
||||
ensureSemanticCacheDbBridge();
|
||||
|
||||
// Fresh install: master on (legacy default), vector opt-in absent → manager off.
|
||||
assert.equal(getDatabaseSettings().cache.semanticCacheEnabled, true);
|
||||
assert.equal(resolveSemanticCacheConfig().enabled, false);
|
||||
|
||||
updateDatabaseSettings({ cache: { semanticCacheVectorEnabled: true } });
|
||||
assert.equal(resolveSemanticCacheConfig().enabled, true);
|
||||
|
||||
// Master off wins even when the vector opt-in is on.
|
||||
updateDatabaseSettings({ cache: { semanticCacheEnabled: false } });
|
||||
assert.equal(resolveSemanticCacheConfig().enabled, false);
|
||||
});
|
||||
|
||||
test("an explicit chat endpoint vetoes the embedding/rerank/image modality heuristics", () => {
|
||||
const modality = detectModelModality(
|
||||
{ id: "gemini-custom-preview", supportedEndpoints: ["chat", "embeddings"] },
|
||||
"gemini"
|
||||
);
|
||||
assert.equal(modality.isEmbedding, false);
|
||||
assert.equal(modality.isRerank, false);
|
||||
assert.equal(modality.isImage, false);
|
||||
|
||||
const [chatModel] = normalizeDiscoveredModels(
|
||||
[{ id: "gemini-custom-preview", supportedEndpoints: ["chat", "embeddings"] }],
|
||||
"gemini"
|
||||
);
|
||||
assert.equal(chatModel.modelType, undefined, "chat models carry no modelType stamp");
|
||||
assert.equal(chatModel.apiFormat, undefined, "chat models carry no synthesized apiFormat");
|
||||
assert.equal(
|
||||
chatModel.supportedInputTypes,
|
||||
undefined,
|
||||
"chat models carry no supportedInputTypes default"
|
||||
);
|
||||
|
||||
// A pure embedding record still gets its modality metadata.
|
||||
const [embeddingModel] = normalizeDiscoveredModels(
|
||||
[{ id: "text-embedding-3-small", supportedEndpoints: ["embeddings"] }],
|
||||
"openai"
|
||||
);
|
||||
assert.equal(embeddingModel.modelType, "embedding");
|
||||
assert.equal(embeddingModel.apiFormat, "embeddings");
|
||||
assert.deepEqual(embeddingModel.supportedInputTypes, ["text"]);
|
||||
});
|
||||
Reference in New Issue
Block a user