mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
feat(cache): configurable dual-layer semantic caching with Redis/In-Memory vector stores (re-land of #12630) (#14159)
Re-land of #12630 by @BillyOutlast (their commits carried with authorship intact), merged via /merge-batch (2026-09-19) on top of the current `release/v3.8.51` tip. **Reconciled before landing — `c2709ea7`.** The re-land had only run its own 78 tests; against the existing suites of the modules it touches it introduced **15 regressions** (all green on the pure tip, reproduced red with the PR). Root causes and fixes: - **Vector layer was on by default** (`semanticCacheConfig.ts` `enabled: true`, bridged from the legacy `semanticCacheEnabled` toggle) and its default embedding client called `http://localhost:13305/v1/embeddings` on every cacheable lookup *and* store — `chat-combo-live-test` ×2 and `issue-agent-route-execution` saw 3 fetches instead of 1. The layer is now **opt-in** via a new `semanticCacheVectorEnabled` setting (default `false`; sub-toggle in the Cache settings tab; `OMNIROUTE_SEMANTIC_CACHE_ENABLED=true` still works). With it off, `chatCore` behaves exactly like the legacy SQLite exact-match cache. - **`X-OmniRoute-Cache` changed from `HIT` to `HIT (exact)`/`HIT (semantic)`** — 5 chat-route/chatCore contract tests. Restored the legacy `HIT` value (similarity hits keep `X-OmniRoute-Cache-Similarity`); `cacheSource: "semantic_similarity"` also fell through `attemptLogging`'s narrowing as `"upstream"` and is now `"semantic"`. - **`normalizeDiscoveredModels` stamped `modelType: "chat"` + `supportedInputTypes: ["text"]` on every model** — kimi/vertex/reasoning-levels/provider-models/model-sync snapshots churned. Chat models keep the tip's exact shape; only non-chat modalities (or explicit `supportedInputTypes`) are stamped. - **`detectModelModality` classified `supportedEndpoints: ["chat","embeddings"]` as embedding** and dropped the model from the chat catalog. An explicit chat endpoint is now authoritative over the embedding/rerank/image heuristics. - Extras found on the way: `chatCore` now passes `provider` to both stores (the manager filters by provider on lookup, so writes without it could never hit); `test-embedding/route.ts` returned `undefined` on invalid payloads (`validateBody()` has no `.response`) → 400. - Tests: `chatcore-semantic-cache.test.ts` restored to the tip's contract; `semantic-cache-no-truncated-writes.test.ts` restored to the tip + the PR's two object-shaped streaming cases appended (with `isTruncatedStreamBody` now delegating to `isTruncatedCompletion` for object bodies — on the tip that guard was a no-op in production); new guard `semantic-cache-vector-layer-opt-in.test.ts`. **Evidence on the merged tree:** the 9 previously-red files + the PR's 7 test files: 270 pass / 0 fail / 2 skipped (Lemonade live, self-skip); `typecheck:core` exit 0; `check:open-sse-typecheck` 0 errors; `check-api-typecheck` only the two inherited errors (`rerankProviderNodes.ts`, `antigravity.ts`); file-size, changelog-integrity, docs-counts, complexity, cognitive-complexity, vitest-exclusions OK; the 5 removed eslint suppressions verified clean. **Owner decisions surfaced by the rework:** the PR wanted the hit type in the `X-OmniRoute-Cache` value — kept the legacy value; a separate header would be the non-breaking way. `modelDiscovery.ts` now considers `record.max_tokens` as an `inputTokenLimit` candidate (on several providers that is the *output* limit) — left as submitted, untested. The contributor's `/review/` `.gitignore` + eslint ignore entries were left as submitted. **Inherited, not from this PR:** the fast-path unit shard reds shared with every PR of this wave (vi locale parity, pack-artifact allowlists, `.env.example` sync, casing, budget fallback), `hard-session-lease-bypass-inventory`, the 5 `no-unused-vars` lint errors, the `omni-version-manager` generated-skill drift. Supersedes #12630.
This commit is contained in:
committed by
GitHub
parent
b61ff773b0
commit
7a921299c5
1
.gitignore
vendored
1
.gitignore
vendored
@@ -219,6 +219,7 @@ scripts/i18n/_pending-keys.json
|
||||
|
||||
# PR Reviews and local feedback files
|
||||
pr_reviews*.json
|
||||
/review/
|
||||
|
||||
#hidden local data directories (never commit)
|
||||
.local-data/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- fix(cache): never write a truncated completion (`finish_reason: "length"`/`max_tokens`) into the semantic cache — a partial answer cached under a temperature:0 signature was served to every later identical request, permanently returning a mid-sentence reply that no retry cleared (#12885)
|
||||
@@ -1654,11 +1654,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/semanticCache.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/services/ServiceSupervisor.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
|
||||
@@ -239,6 +239,7 @@ const eslintConfig = [
|
||||
"vscode-extension/**",
|
||||
"_references/**",
|
||||
"_mono_repo/**",
|
||||
"review/**",
|
||||
// Electron app
|
||||
"electron/**",
|
||||
// Docs
|
||||
|
||||
@@ -372,6 +372,27 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
|
||||
models: [],
|
||||
},
|
||||
|
||||
// llama.cpp — local OpenAI-compatible server (llama-server).
|
||||
// API key optional. Models are passthrough (empty models array).
|
||||
"llama-cpp": {
|
||||
id: "llama-cpp",
|
||||
baseUrl: "http://127.0.0.1:8080/v1/embeddings",
|
||||
authType: "none",
|
||||
authHeader: "bearer",
|
||||
models: [],
|
||||
},
|
||||
|
||||
// Lemonade Server — local OpenAI-compatible AI runtime backed by llama.cpp.
|
||||
// API key optional (defaults to bearer auth if key configured).
|
||||
// Includes harrier-oss-v1-0.6b (1024-dim GGUF) as a curated model.
|
||||
lemonade: {
|
||||
id: "lemonade",
|
||||
baseUrl: "http://localhost:13305/v1/embeddings",
|
||||
authType: "none",
|
||||
authHeader: "bearer",
|
||||
models: [{ id: "harrier-oss-v1-0.6b", name: "Harrier OSS v1 0.6B", dimensions: 1024 }],
|
||||
},
|
||||
|
||||
// Ollama Local — OpenAI-compatible embeddings endpoint. Ollama exposes its
|
||||
// own model catalog, but these common embedding models are useful defaults
|
||||
// for model selection and validation.
|
||||
@@ -428,7 +449,6 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
const EMBEDDING_PROVIDER_ALIASES: Record<string, string> = {
|
||||
@@ -439,6 +459,8 @@ const EMBEDDING_PROVIDER_ALIASES: Record<string, string> = {
|
||||
// (#11233). Alias the dashboard id so "lm-studio/<model>" resolves instead
|
||||
// of failing with an unknown-provider 400.
|
||||
"lm-studio": "lmstudio",
|
||||
llamacpp: "llama-cpp",
|
||||
"llama.cpp": "llama-cpp",
|
||||
};
|
||||
|
||||
/** Family name used by clients; Jina's public SKU is omni-small. */
|
||||
@@ -563,9 +585,7 @@ export function deriveEmbeddingProviderForChatProvider(
|
||||
chatEntry: { id?: string; baseUrl?: string | string[] } | null | undefined
|
||||
): EmbeddingProvider | null {
|
||||
if (!chatEntry) return null;
|
||||
const rawBase = Array.isArray(chatEntry.baseUrl)
|
||||
? chatEntry.baseUrl[0]
|
||||
: chatEntry.baseUrl;
|
||||
const rawBase = Array.isArray(chatEntry.baseUrl) ? chatEntry.baseUrl[0] : chatEntry.baseUrl;
|
||||
if (!rawBase || typeof rawBase !== "string") return null;
|
||||
// stripTrailingSlashes-equivalent without importing open-sse utils here:
|
||||
const base = rawBase.replace(/\/+$/, "");
|
||||
|
||||
210
open-sse/config/semanticCacheConfig.ts
Normal file
210
open-sse/config/semanticCacheConfig.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Semantic Cache Configuration
|
||||
*
|
||||
* Configurable options for OmniRoute's dual-layer semantic caching system.
|
||||
* Supports environment variable overrides via OMNIROUTE_SEMANTIC_CACHE_*.
|
||||
*
|
||||
* @module config/semanticCacheConfig
|
||||
*/
|
||||
|
||||
export type SemanticCacheBackend = "memory" | "redis";
|
||||
export type SemanticCacheType = "direct" | "semantic" | "both";
|
||||
|
||||
export interface SemanticCacheConfig {
|
||||
/**
|
||||
* 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;
|
||||
/** Cosine similarity threshold (0.0 - 1.0) for semantic hits. Defaults to 0.8. */
|
||||
similarityThreshold: number;
|
||||
/** Time-to-live for cached entries in milliseconds. Defaults to 30 minutes (1800000 ms). */
|
||||
ttlMs: number;
|
||||
/** Maximum number of entries stored in memory. Defaults to 1000. */
|
||||
maxEntries: number;
|
||||
/** Embedding provider to use for semantic embeddings. Defaults to "openai". */
|
||||
embeddingProvider: string;
|
||||
/** Embedding model name. Defaults to "text-embedding-3-small". */
|
||||
embeddingModel: string;
|
||||
/** Vector dimension of the embedding model (e.g. 1536). Optional/auto-detected. */
|
||||
embeddingDimension?: number;
|
||||
/** Maximum milliseconds to wait for embedding generation before failing open. Defaults to 3000 ms. */
|
||||
embeddingTimeoutMs: number;
|
||||
/** If true, cache entries are isolated per model name. Defaults to true. */
|
||||
cacheByModel: boolean;
|
||||
/** If true, cache entries are isolated per provider name. Defaults to true. */
|
||||
cacheByProvider: boolean;
|
||||
/** Number of recent conversation turns embedded and checked. Defaults to 3. */
|
||||
conversationHistoryDepth: number;
|
||||
/** Maximum message turns in conversation allowed before bypassing caching. Defaults to 50. */
|
||||
conversationHistoryThreshold: number;
|
||||
/** If true, system prompt messages are excluded from embedding and cache keys. Defaults to false. */
|
||||
excludeSystemPrompt: boolean;
|
||||
/** Optional custom base URL for embedding provider (e.g. http://192.168.31.147:13305/v1/embeddings). */
|
||||
embeddingBaseUrl?: string;
|
||||
/** Optional custom API key / Bearer token for embedding provider. */
|
||||
embeddingApiKey?: string;
|
||||
/** Redis connection URL when backend is "redis". */
|
||||
redisUrl?: string;
|
||||
/** Key prefix for Redis cache keys. Defaults to "omniroute:semcache:". */
|
||||
redisPrefix: string;
|
||||
/** If true, only temperature=0 requests are cacheable. Defaults to true for strict determinism. */
|
||||
requireZeroTemperature: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SEMANTIC_CACHE_CONFIG: SemanticCacheConfig = {
|
||||
enabled: false,
|
||||
backend: "memory",
|
||||
similarityThreshold: 0.8,
|
||||
ttlMs: 1800000, // 30 minutes
|
||||
maxEntries: 1000,
|
||||
embeddingProvider: "openai",
|
||||
embeddingModel: "text-embedding-3-small",
|
||||
embeddingDimension: 1536,
|
||||
embeddingTimeoutMs: 3000,
|
||||
cacheByModel: true,
|
||||
cacheByProvider: true,
|
||||
conversationHistoryDepth: 3,
|
||||
conversationHistoryThreshold: 50,
|
||||
excludeSystemPrompt: false,
|
||||
redisUrl: undefined,
|
||||
redisPrefix: "omniroute:semcache:",
|
||||
requireZeroTemperature: true,
|
||||
};
|
||||
|
||||
function parseBoolean(val: string | undefined, fallback: boolean): boolean {
|
||||
if (val === undefined || val === "") return fallback;
|
||||
const lower = val.toLowerCase().trim();
|
||||
if (lower === "true" || lower === "1" || lower === "yes") return true;
|
||||
if (lower === "false" || lower === "0" || lower === "no") return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseNumber(val: string | undefined, fallback: number): number {
|
||||
if (val === undefined || val === "") return fallback;
|
||||
const parsed = Number(val);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
type DynamicConfigResolver = () => Partial<SemanticCacheConfig> | null | undefined;
|
||||
let dynamicResolver: DynamicConfigResolver | null = null;
|
||||
|
||||
export function registerSemanticCacheConfigResolver(resolver: DynamicConfigResolver): void {
|
||||
dynamicResolver = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves semantic cache configuration from environment variables, merged
|
||||
* with optional dynamic database settings and explicit overrides.
|
||||
*/
|
||||
export function resolveSemanticCacheConfig(
|
||||
overrides?: Partial<SemanticCacheConfig>
|
||||
): SemanticCacheConfig {
|
||||
const dynamic = dynamicResolver ? dynamicResolver() : null;
|
||||
const env = process.env;
|
||||
|
||||
const backendEnv = (env.OMNIROUTE_SEMANTIC_CACHE_BACKEND || "").toLowerCase().trim();
|
||||
const backend: SemanticCacheBackend =
|
||||
backendEnv === "redis"
|
||||
? "redis"
|
||||
: backendEnv === "memory"
|
||||
? "memory"
|
||||
: (dynamic?.backend ?? DEFAULT_SEMANTIC_CACHE_CONFIG.backend);
|
||||
|
||||
const resolved: SemanticCacheConfig = {
|
||||
enabled:
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_ENABLED !== undefined
|
||||
? parseBoolean(env.OMNIROUTE_SEMANTIC_CACHE_ENABLED, DEFAULT_SEMANTIC_CACHE_CONFIG.enabled)
|
||||
: (dynamic?.enabled ?? DEFAULT_SEMANTIC_CACHE_CONFIG.enabled),
|
||||
backend,
|
||||
similarityThreshold:
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_THRESHOLD !== undefined
|
||||
? parseNumber(
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_THRESHOLD,
|
||||
DEFAULT_SEMANTIC_CACHE_CONFIG.similarityThreshold
|
||||
)
|
||||
: (dynamic?.similarityThreshold ?? DEFAULT_SEMANTIC_CACHE_CONFIG.similarityThreshold),
|
||||
ttlMs:
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_TTL_MS !== undefined
|
||||
? parseNumber(env.OMNIROUTE_SEMANTIC_CACHE_TTL_MS, DEFAULT_SEMANTIC_CACHE_CONFIG.ttlMs)
|
||||
: (dynamic?.ttlMs ?? DEFAULT_SEMANTIC_CACHE_CONFIG.ttlMs),
|
||||
maxEntries:
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_MAX_ENTRIES !== undefined
|
||||
? parseNumber(
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_MAX_ENTRIES,
|
||||
DEFAULT_SEMANTIC_CACHE_CONFIG.maxEntries
|
||||
)
|
||||
: (dynamic?.maxEntries ?? DEFAULT_SEMANTIC_CACHE_CONFIG.maxEntries),
|
||||
embeddingProvider:
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_PROVIDER?.trim() ||
|
||||
dynamic?.embeddingProvider ||
|
||||
DEFAULT_SEMANTIC_CACHE_CONFIG.embeddingProvider,
|
||||
embeddingModel:
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_MODEL?.trim() ||
|
||||
dynamic?.embeddingModel ||
|
||||
DEFAULT_SEMANTIC_CACHE_CONFIG.embeddingModel,
|
||||
embeddingDimension: env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_DIMENSION
|
||||
? parseNumber(env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_DIMENSION, 1536)
|
||||
: (dynamic?.embeddingDimension ?? DEFAULT_SEMANTIC_CACHE_CONFIG.embeddingDimension),
|
||||
embeddingTimeoutMs: parseNumber(
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_TIMEOUT_MS,
|
||||
dynamic?.embeddingTimeoutMs ?? DEFAULT_SEMANTIC_CACHE_CONFIG.embeddingTimeoutMs
|
||||
),
|
||||
cacheByModel: parseBoolean(
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_BY_MODEL,
|
||||
dynamic?.cacheByModel ?? DEFAULT_SEMANTIC_CACHE_CONFIG.cacheByModel
|
||||
),
|
||||
cacheByProvider: parseBoolean(
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_BY_PROVIDER,
|
||||
dynamic?.cacheByProvider ?? DEFAULT_SEMANTIC_CACHE_CONFIG.cacheByProvider
|
||||
),
|
||||
conversationHistoryDepth: parseNumber(
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_HISTORY_DEPTH,
|
||||
dynamic?.conversationHistoryDepth ?? DEFAULT_SEMANTIC_CACHE_CONFIG.conversationHistoryDepth
|
||||
),
|
||||
conversationHistoryThreshold: parseNumber(
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_HISTORY_THRESHOLD,
|
||||
dynamic?.conversationHistoryThreshold ??
|
||||
DEFAULT_SEMANTIC_CACHE_CONFIG.conversationHistoryThreshold
|
||||
),
|
||||
excludeSystemPrompt: parseBoolean(
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_EXCLUDE_SYSTEM,
|
||||
dynamic?.excludeSystemPrompt ?? DEFAULT_SEMANTIC_CACHE_CONFIG.excludeSystemPrompt
|
||||
),
|
||||
embeddingBaseUrl:
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_BASE_URL?.trim() ||
|
||||
dynamic?.embeddingBaseUrl ||
|
||||
overrides?.embeddingBaseUrl ||
|
||||
undefined,
|
||||
embeddingApiKey:
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_EMBEDDING_API_KEY?.trim() ||
|
||||
dynamic?.embeddingApiKey ||
|
||||
overrides?.embeddingApiKey ||
|
||||
undefined,
|
||||
redisUrl:
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_REDIS_URL || env.REDIS_URL || dynamic?.redisUrl || undefined,
|
||||
redisPrefix:
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_REDIS_PREFIX?.trim() ||
|
||||
dynamic?.redisPrefix ||
|
||||
DEFAULT_SEMANTIC_CACHE_CONFIG.redisPrefix,
|
||||
requireZeroTemperature:
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_REQUIRE_ZERO_TEMP !== undefined
|
||||
? parseBoolean(
|
||||
env.OMNIROUTE_SEMANTIC_CACHE_REQUIRE_ZERO_TEMP,
|
||||
DEFAULT_SEMANTIC_CACHE_CONFIG.requireZeroTemperature
|
||||
)
|
||||
: (dynamic?.requireZeroTemperature ?? DEFAULT_SEMANTIC_CACHE_CONFIG.requireZeroTemperature),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
// Clamp similarity threshold to [0.0, 1.0]
|
||||
if (resolved.similarityThreshold < 0) resolved.similarityThreshold = 0;
|
||||
if (resolved.similarityThreshold > 1) resolved.similarityThreshold = 1;
|
||||
|
||||
return resolved;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
generateSignature,
|
||||
getCachedResponse,
|
||||
isCacheableForRead,
|
||||
recordSemanticCacheHit,
|
||||
outputContractOf,
|
||||
} from "@/lib/semanticCache";
|
||||
import { calculateCost } from "@/lib/usage/costCalculator";
|
||||
@@ -10,6 +11,7 @@ import { synthesizeOpenAiSseFromJson } from "../../utils/jsonToSse.ts";
|
||||
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
|
||||
import { extractUsageFromResponse } from "../usageExtractor.ts";
|
||||
import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers";
|
||||
import { getSemanticCacheManager } from "../../services/cache/semanticCacheManager.ts";
|
||||
|
||||
export async function checkSemanticCache({
|
||||
semanticCacheEnabled,
|
||||
@@ -47,21 +49,51 @@ export async function checkSemanticCache({
|
||||
// Per-key bypass: skip cache lookup entirely when the API key opts out.
|
||||
if (cacheDefaultMode === "bypass") return null;
|
||||
if (semanticCacheEnabled && isCacheableForRead(body, clientRawRequest?.headers)) {
|
||||
const signature = generateSignature(
|
||||
const manager = getSemanticCacheManager();
|
||||
const managerResult = await manager.lookup({
|
||||
body,
|
||||
headers: clientRawRequest?.headers,
|
||||
model,
|
||||
body.messages ?? body.input,
|
||||
body.temperature,
|
||||
body.top_p,
|
||||
apiKeyId ?? undefined,
|
||||
outputContractOf(body)
|
||||
);
|
||||
const cached = getCachedResponse(signature);
|
||||
provider,
|
||||
stream,
|
||||
apiKeyId: apiKeyId ?? undefined,
|
||||
cacheDefaultMode,
|
||||
});
|
||||
|
||||
let cached: Record<string, unknown> | null = null;
|
||||
let hitType: "exact" | "semantic" = "exact";
|
||||
let similarity: number | undefined;
|
||||
|
||||
if (managerResult.hit && managerResult.entry) {
|
||||
cached = managerResult.entry.response;
|
||||
hitType = managerResult.type || "exact";
|
||||
similarity = managerResult.similarity;
|
||||
} else {
|
||||
// Legacy SQLite / in-memory cache check fallback. Include tool_choice/tools/
|
||||
// response_format (#12309/#12734), plus the Responses-API text.format spelling
|
||||
// (#12307), in the signature: they change model behavior and must not collide
|
||||
// with a signature computed without them.
|
||||
const signature = generateSignature(
|
||||
model,
|
||||
body.messages ?? body.input,
|
||||
body.temperature,
|
||||
body.top_p,
|
||||
apiKeyId ?? undefined,
|
||||
outputContractOf(body)
|
||||
);
|
||||
const legacyCached = getCachedResponse(signature);
|
||||
if (legacyCached) {
|
||||
cached = legacyCached as Record<string, unknown>;
|
||||
hitType = "exact";
|
||||
}
|
||||
}
|
||||
|
||||
if (cached) {
|
||||
log?.debug?.("CACHE", `Semantic cache HIT for ${model} (stream=${stream})`);
|
||||
reqLogger.logConvertedResponse(cached as Record<string, unknown>);
|
||||
log?.debug?.("CACHE", `Semantic cache HIT (${hitType}) for ${model} (stream=${stream})`);
|
||||
reqLogger.logConvertedResponse(cached);
|
||||
const cachedUsage =
|
||||
extractUsageFromResponse(cached as Record<string, unknown>, provider) ||
|
||||
((cached as Record<string, unknown>)?.usage as Record<string, unknown> | undefined);
|
||||
extractUsageFromResponse(cached, provider) ||
|
||||
(cached?.usage as Record<string, unknown> | undefined);
|
||||
const cachedCost = cachedUsage
|
||||
? await calculateCost(provider, model, cachedUsage as Record<string, number>, {
|
||||
serviceTier: effectiveServiceTier,
|
||||
@@ -69,26 +101,70 @@ export async function checkSemanticCache({
|
||||
: 0;
|
||||
persistAttemptLogs({
|
||||
status: 200,
|
||||
tokens: (cached as Record<string, unknown>)?.usage,
|
||||
tokens: cached?.usage,
|
||||
responseBody: cached,
|
||||
providerRequest: null,
|
||||
providerResponse: null,
|
||||
clientResponse: cached,
|
||||
// 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
|
||||
// multiple requests share the same connection.
|
||||
finalizePendingScope(pendingScope, {
|
||||
status: 200,
|
||||
providerResponse: cached,
|
||||
clientResponse: cached,
|
||||
});
|
||||
const cachedSse = stream ? synthesizeOpenAiSseFromJson(JSON.stringify(cached)) : "";
|
||||
|
||||
const cachedSse = stream
|
||||
? managerResult.entry
|
||||
? manager.synthesizeSseFromEntry(managerResult.entry)
|
||||
: synthesizeOpenAiSseFromJson(JSON.stringify(cached))
|
||||
: "";
|
||||
|
||||
const tokensSaved = managerResult.entry
|
||||
? (managerResult.tokensSaved ?? 0)
|
||||
: cachedUsage
|
||||
? (Number(cachedUsage.prompt_tokens) || 0) + (Number(cachedUsage.completion_tokens) || 0)
|
||||
: 0;
|
||||
|
||||
const requestSignature = generateSignature(
|
||||
model,
|
||||
body.messages ?? body.input,
|
||||
body.temperature,
|
||||
body.top_p,
|
||||
apiKeyId ?? undefined,
|
||||
outputContractOf(body)
|
||||
);
|
||||
|
||||
const targetSignature =
|
||||
managerResult.entry?.signature ||
|
||||
(hitType === "exact" ? requestSignature : managerResult.entry?.hash);
|
||||
|
||||
if (targetSignature) {
|
||||
recordSemanticCacheHit(targetSignature, tokensSaved);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": cachedSse ? "text/event-stream" : "application/json",
|
||||
// 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),
|
||||
};
|
||||
|
||||
if (hitType === "semantic" && typeof similarity === "number") {
|
||||
headers[OMNIROUTE_RESPONSE_HEADERS.cacheSimilarity] = similarity.toFixed(4);
|
||||
}
|
||||
|
||||
// A cache HIT serves WITHOUT an upstream call, so the incremental cost billed to
|
||||
// the client is 0 (consumers that sum X-OmniRoute-Response-Cost must not charge for
|
||||
// hits). The original/would-have-been cost is surfaced via X-OmniRoute-Cost-Saved.
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
isTruncatedCompletion as defaultIsTruncatedCompletion,
|
||||
} from "@/lib/semanticCache";
|
||||
import { isSmallEnoughForSemanticCache as defaultIsSmallEnough } from "../../utils/estimateSize.ts";
|
||||
import { getSemanticCacheManager } from "../../services/cache/semanticCacheManager.ts";
|
||||
|
||||
type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined;
|
||||
|
||||
@@ -52,6 +53,7 @@ export function storeSemanticCacheResponse(
|
||||
headers: unknown;
|
||||
translatedResponse: unknown;
|
||||
model: string;
|
||||
provider?: string;
|
||||
apiKeyId?: string;
|
||||
usage?: UsageLike;
|
||||
log?: LoggerLike;
|
||||
@@ -77,4 +79,22 @@ export function storeSemanticCacheResponse(
|
||||
const tokensSaved = args.usage?.prompt_tokens + args.usage?.completion_tokens || 0;
|
||||
deps.setCachedResponse(signature, args.model, args.translatedResponse, tokensSaved);
|
||||
args.log?.debug?.("CACHE", `Stored response for ${args.model} (${tokensSaved} tokens)`);
|
||||
|
||||
if (args.translatedResponse && typeof args.translatedResponse === "object") {
|
||||
getSemanticCacheManager()
|
||||
.store({
|
||||
body: args.body as Record<string, unknown>,
|
||||
headers: args.headers,
|
||||
response: args.translatedResponse as Record<string, unknown>,
|
||||
model: args.model,
|
||||
provider:
|
||||
args.provider ||
|
||||
((args.translatedResponse as Record<string, unknown>).provider as string) ||
|
||||
"",
|
||||
apiKeyId: args.apiKeyId,
|
||||
signature,
|
||||
tokensSaved,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isTruncatedStreamBody as defaultIsTruncatedStreamBody,
|
||||
} from "@/lib/semanticCache";
|
||||
import { isSmallEnoughForSemanticCache as defaultIsSmallEnough } from "../../utils/estimateSize.ts";
|
||||
import { getSemanticCacheManager } from "../../services/cache/semanticCacheManager.ts";
|
||||
|
||||
type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined;
|
||||
|
||||
@@ -51,6 +52,7 @@ interface StreamingCacheArgs {
|
||||
body: CacheBody;
|
||||
headers: unknown;
|
||||
model: string;
|
||||
provider?: string;
|
||||
apiKeyId?: string;
|
||||
streamUsage?: Record<string, unknown> | null;
|
||||
log?: LoggerLike;
|
||||
@@ -83,6 +85,19 @@ function writeStreamingCacheEntry(
|
||||
"CACHE",
|
||||
`Stored streaming response for ${args.model} (${tokensSaved} tokens)`
|
||||
);
|
||||
|
||||
getSemanticCacheManager()
|
||||
.store({
|
||||
body: args.body as Record<string, unknown>,
|
||||
headers: args.headers,
|
||||
response: cleanBody,
|
||||
model: args.model,
|
||||
provider: args.provider || (cleanBody.provider as string) || "",
|
||||
apiKeyId: args.apiKeyId,
|
||||
signature: sig,
|
||||
tokensSaved,
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch {
|
||||
// Cache write failed — non-critical
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
|
||||
import { stripStaleEncodingHeaders } from "../utils/upstreamResponseHeaders.ts";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { stripTrailingSlashes } from "../utils/urlSanitize.ts";
|
||||
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
|
||||
import {
|
||||
hasStructuredEmbeddingInput,
|
||||
@@ -283,15 +284,30 @@ function resolveLocalEmbeddingUrl(runtime: EmbeddingRuntime): string {
|
||||
typeof configuredBaseUrl === "string" && configuredBaseUrl.trim()
|
||||
? configuredBaseUrl
|
||||
: runtime.providerConfig.baseUrl;
|
||||
const localServerHost = stripTrailingSlashes(rawBaseUrl.trim())
|
||||
.replace(/\/v1\/(?:chat\/completions|embeddings)$/i, "")
|
||||
const trimmed = stripTrailingSlashes(rawBaseUrl.trim());
|
||||
if (trimmed.toLowerCase().endsWith("/embeddings")) {
|
||||
return trimmed;
|
||||
}
|
||||
const localServerHost = trimmed
|
||||
.replace(/\/v1\/chat\/completions$/i, "")
|
||||
.replace(/\/chat\/completions$/i, "")
|
||||
.replace(/\/api\/chat$/i, "")
|
||||
.replace(/\/v1$/i, "");
|
||||
return `${localServerHost}/v1/embeddings`;
|
||||
}
|
||||
|
||||
function isLocalEmbeddingProvider(provider: string): boolean {
|
||||
return (
|
||||
provider === "ollama-local" ||
|
||||
provider === "lmstudio" ||
|
||||
provider === "llama-cpp" ||
|
||||
provider === "llamacpp" ||
|
||||
provider === "lemonade"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveUpstreamUrl(runtime: EmbeddingRuntime): string {
|
||||
return runtime.provider === "ollama-local" || runtime.provider === "lmstudio"
|
||||
return isLocalEmbeddingProvider(runtime.provider)
|
||||
? resolveLocalEmbeddingUrl(runtime)
|
||||
: runtime.providerConfig.baseUrl;
|
||||
}
|
||||
@@ -300,10 +316,7 @@ function buildAuth(
|
||||
runtime: EmbeddingRuntime
|
||||
): { headers: Record<string, string>; token: string | null } | EmbeddingFailure {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const token =
|
||||
runtime.providerConfig.authType === "none"
|
||||
? null
|
||||
: runtime.credentials?.apiKey || runtime.credentials?.accessToken || null;
|
||||
const token = runtime.credentials?.apiKey || runtime.credentials?.accessToken || null;
|
||||
if (!token && runtime.providerConfig.authType !== "none") {
|
||||
return failure(
|
||||
401,
|
||||
@@ -473,6 +486,7 @@ async function fetchClovaEmbeddingBatch(
|
||||
method: "POST",
|
||||
headers: prepared.headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
lastHeaders = response.headers;
|
||||
if (!response.ok) return response;
|
||||
@@ -496,6 +510,7 @@ async function dispatchEmbeddingRequest(
|
||||
method: "POST",
|
||||
headers: prepared.headers,
|
||||
body: JSON.stringify(prepared.upstreamBody),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -568,7 +583,10 @@ function normalizeEmbeddingData(
|
||||
normalizedResponse: {
|
||||
object: "list",
|
||||
data: data.data || data,
|
||||
model: `${runtime.provider}/${runtime.model}`,
|
||||
model:
|
||||
typeof runtime.body.model === "string" && !runtime.body.model.includes("/")
|
||||
? runtime.body.model
|
||||
: `${runtime.provider}/${runtime.model}`,
|
||||
usage: data.usage || { prompt_tokens: 0, total_tokens: 0 },
|
||||
},
|
||||
};
|
||||
@@ -655,12 +673,18 @@ function handleEmbeddingException(
|
||||
error: unknown
|
||||
): EmbeddingFailure {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isTimeout =
|
||||
error instanceof Error &&
|
||||
(error.name === "TimeoutError" ||
|
||||
error.name === "AbortError" ||
|
||||
message.toLowerCase().includes("timeout"));
|
||||
const status = isTimeout ? 504 : 502;
|
||||
runtime.log?.error("EMBED", `${runtime.provider} fetch error: ${message}`);
|
||||
runtime.reqLogger.logError(error, prepared.upstreamBody);
|
||||
saveCallLog({
|
||||
method: "POST",
|
||||
path: "/v1/embeddings",
|
||||
status: 502,
|
||||
status,
|
||||
model: `${runtime.provider}/${runtime.model}`,
|
||||
provider: runtime.provider,
|
||||
duration: Date.now() - runtime.startTime,
|
||||
@@ -671,7 +695,7 @@ function handleEmbeddingException(
|
||||
apiKeyName: runtime.apiKeyName,
|
||||
connectionId: runtime.connectionId,
|
||||
}).catch(() => {});
|
||||
return failure(502, `Embedding provider error: ${sanitizeErrorMessage(message)}`);
|
||||
return failure(status, `Embedding provider error: ${sanitizeErrorMessage(message)}`);
|
||||
}
|
||||
|
||||
async function executeEmbedding(
|
||||
|
||||
232
open-sse/services/cache/embeddingClient.ts
vendored
Normal file
232
open-sse/services/cache/embeddingClient.ts
vendored
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Semantic Cache Embedding Client
|
||||
*
|
||||
* Normalizes conversation history into embeddable text representation and
|
||||
* orchestrates embedding vector generation with timeout and fail-open resilience.
|
||||
*
|
||||
* @module services/cache/embeddingClient
|
||||
*/
|
||||
|
||||
export interface EmbeddingResult {
|
||||
embedding: number[];
|
||||
inputTokens: number;
|
||||
}
|
||||
|
||||
export type EmbeddingGenerator = (
|
||||
text: string,
|
||||
options?: {
|
||||
model?: string;
|
||||
provider?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
) => Promise<EmbeddingResult | null>;
|
||||
|
||||
function extractTextFromContent(content: unknown): string {
|
||||
if (typeof content === "string") return content.trim();
|
||||
if (Array.isArray(content)) {
|
||||
const parts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (typeof part === "string") {
|
||||
parts.push(part);
|
||||
} else if (part && typeof part === "object") {
|
||||
const item = part as Record<string, unknown>;
|
||||
if (typeof item.text === "string") {
|
||||
parts.push(item.text);
|
||||
} else if (item.type === "text" && typeof item.content === "string") {
|
||||
parts.push(item.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join(" ").trim();
|
||||
}
|
||||
if (content && typeof content === "object") {
|
||||
const record = content as Record<string, unknown>;
|
||||
if (typeof record.text === "string") return record.text.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes conversation history into a single clean text string for embedding.
|
||||
*
|
||||
* @param conversation - messages[] or input[] from chat completion or responses API
|
||||
* @param options - depth and system prompt filtering
|
||||
*/
|
||||
export function normalizeConversationForEmbedding(
|
||||
conversation: unknown,
|
||||
options?: {
|
||||
excludeSystemPrompt?: boolean;
|
||||
historyDepth?: number;
|
||||
}
|
||||
): string {
|
||||
if (typeof conversation === "string") {
|
||||
return conversation.trim();
|
||||
}
|
||||
if (!Array.isArray(conversation) || conversation.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const excludeSystem = options?.excludeSystemPrompt ?? false;
|
||||
const depth = options?.historyDepth ?? 3;
|
||||
|
||||
// Filter messages
|
||||
const filtered = conversation.filter((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const role = (item as Record<string, unknown>).role;
|
||||
if (excludeSystem && (role === "system" || role === "developer")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Take the tail of conversation up to historyDepth
|
||||
const tail = depth > 0 ? filtered.slice(-depth) : filtered;
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const item of tail) {
|
||||
const record = item as Record<string, unknown>;
|
||||
const role = typeof record.role === "string" ? record.role : "user";
|
||||
const text = extractTextFromContent(record.content);
|
||||
if (text) {
|
||||
lines.push(`${role}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes embedding generation with fail-open timeout guard.
|
||||
*/
|
||||
export async function generateEmbeddingWithTimeout(
|
||||
text: string,
|
||||
generator: EmbeddingGenerator,
|
||||
options?: {
|
||||
model?: string;
|
||||
provider?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
): Promise<EmbeddingResult | null> {
|
||||
if (!text || text.length === 0) return null;
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? 3000;
|
||||
const controller = new AbortController();
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
console.warn(`[CACHE] Embedding generation timed out after ${timeoutMs}ms`);
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
const generatorPromise = generator(text, {
|
||||
model: options?.model,
|
||||
provider: options?.provider,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const res = await Promise.race([generatorPromise, timeoutPromise]);
|
||||
return res;
|
||||
} catch (err) {
|
||||
const isTimeout = controller.signal.aborted;
|
||||
console.warn(
|
||||
`[CACHE] Embedding generation ${isTimeout ? "timed out" : "failed"}:`,
|
||||
(err as Error).message
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standard HTTP embedding generator against any OpenAI-compatible
|
||||
* embeddings endpoint (e.g. Lemonade server, OpenAI, Ollama).
|
||||
*/
|
||||
export function createDefaultEmbeddingGenerator(config: {
|
||||
embeddingProvider?: string;
|
||||
embeddingModel?: string;
|
||||
embeddingBaseUrl?: string;
|
||||
embeddingApiKey?: string;
|
||||
}): EmbeddingGenerator {
|
||||
return async (
|
||||
text: string,
|
||||
options?: { model?: string; provider?: string; signal?: AbortSignal }
|
||||
) => {
|
||||
const model = options?.model || config.embeddingModel || "text-embedding-3-small";
|
||||
const provider = options?.provider || config.embeddingProvider || "openai";
|
||||
|
||||
let targetUrl = config.embeddingBaseUrl;
|
||||
if (targetUrl) {
|
||||
targetUrl = targetUrl.trim();
|
||||
if (!targetUrl.endsWith("/embeddings")) {
|
||||
if (!targetUrl.endsWith("/v1")) {
|
||||
targetUrl = `${targetUrl.replace(/\/+$/, "")}/v1/embeddings`;
|
||||
} else {
|
||||
targetUrl = `${targetUrl.replace(/\/+$/, "")}/embeddings`;
|
||||
}
|
||||
}
|
||||
}
|
||||
const apiKey = config.embeddingApiKey;
|
||||
|
||||
if (!targetUrl) {
|
||||
if (provider === "lemonade") {
|
||||
targetUrl = "http://localhost:13305/v1/embeddings";
|
||||
} else if (provider === "ollama-local") {
|
||||
targetUrl = "http://localhost:11434/v1/embeddings";
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
input: text,
|
||||
}),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
console.warn(`[CACHE] Default embedding request failed HTTP ${res.status}: ${errText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
data?: Array<{ embedding?: number[] }>;
|
||||
usage?: { prompt_tokens?: number; total_tokens?: number };
|
||||
};
|
||||
|
||||
const vec = json?.data?.[0]?.embedding;
|
||||
if (Array.isArray(vec) && vec.length > 0) {
|
||||
return {
|
||||
embedding: vec,
|
||||
inputTokens: json?.usage?.prompt_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
console.warn("[CACHE] Default embedding fetch error:", (err as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
201
open-sse/services/cache/memoryVectorStore.ts
vendored
Normal file
201
open-sse/services/cache/memoryVectorStore.ts
vendored
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* In-Memory Vector Store
|
||||
*
|
||||
* High-performance, zero-dependency in-memory vector store with cosine similarity,
|
||||
* L2 normalization, O(1) direct-hash index, and LRU/TTL eviction.
|
||||
*
|
||||
* @module services/cache/memoryVectorStore
|
||||
*/
|
||||
|
||||
import {
|
||||
type CacheEntry,
|
||||
type IVectorStore,
|
||||
type SimilaritySearchResult,
|
||||
type StoreFilter,
|
||||
dotProduct,
|
||||
l2Normalize,
|
||||
} from "./vectorStore.ts";
|
||||
|
||||
interface InternalMemoryEntry {
|
||||
entry: CacheEntry;
|
||||
normalizedEmbedding?: number[];
|
||||
}
|
||||
|
||||
export class MemoryVectorStore implements IVectorStore {
|
||||
private readonly maxEntries: number;
|
||||
private readonly entries = new Map<string, InternalMemoryEntry>();
|
||||
private readonly hashToId = new Map<string, string>();
|
||||
|
||||
constructor(options?: { maxEntries?: number }) {
|
||||
this.maxEntries = options?.maxEntries ?? 1000;
|
||||
}
|
||||
|
||||
private isExpired(expiresAt: number): boolean {
|
||||
return expiresAt > 0 && expiresAt <= Date.now();
|
||||
}
|
||||
|
||||
private removeEntry(id: string): boolean {
|
||||
const existing = this.entries.get(id);
|
||||
if (!existing) return false;
|
||||
// Only delete hashToId mapping if it still points to this id
|
||||
if (this.hashToId.get(existing.entry.hash) === id) {
|
||||
this.hashToId.delete(existing.entry.hash);
|
||||
}
|
||||
this.entries.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private evictOldestIfNeeded(): void {
|
||||
while (this.entries.size >= this.maxEntries) {
|
||||
// Map keys iterator yields oldest inserted key first
|
||||
const oldestKey = this.entries.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
this.removeEntry(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<CacheEntry | null> {
|
||||
const item = this.entries.get(id);
|
||||
if (!item) return null;
|
||||
|
||||
if (this.isExpired(item.entry.expiresAt)) {
|
||||
this.removeEntry(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh LRU order on hit: delete and re-insert
|
||||
this.entries.delete(id);
|
||||
this.entries.set(id, item);
|
||||
|
||||
return item.entry;
|
||||
}
|
||||
|
||||
public async getByHash(hash: string): Promise<CacheEntry | null> {
|
||||
const id = this.hashToId.get(hash);
|
||||
if (!id) return null;
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
public async set(entry: CacheEntry, ttlMs: number): Promise<void> {
|
||||
// If ID already exists, remove it first
|
||||
if (this.entries.has(entry.id)) {
|
||||
this.removeEntry(entry.id);
|
||||
}
|
||||
|
||||
// If an existing entry shares the same direct hash, remove the older entry
|
||||
const existingIdWithHash = this.hashToId.get(entry.hash);
|
||||
if (existingIdWithHash && existingIdWithHash !== entry.id) {
|
||||
this.removeEntry(existingIdWithHash);
|
||||
}
|
||||
|
||||
this.evictOldestIfNeeded();
|
||||
|
||||
const expiresAt = ttlMs > 0 ? Date.now() + ttlMs : entry.expiresAt;
|
||||
const finalEntry: CacheEntry = {
|
||||
...entry,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
let normalizedEmbedding: number[] | undefined;
|
||||
if (Array.isArray(entry.embedding) && entry.embedding.length > 0) {
|
||||
normalizedEmbedding = l2Normalize(entry.embedding);
|
||||
}
|
||||
|
||||
const internal: InternalMemoryEntry = {
|
||||
entry: finalEntry,
|
||||
normalizedEmbedding,
|
||||
};
|
||||
|
||||
this.entries.set(finalEntry.id, internal);
|
||||
this.hashToId.set(finalEntry.hash, finalEntry.id);
|
||||
}
|
||||
|
||||
public async searchNearest(
|
||||
embedding: number[],
|
||||
filter: StoreFilter,
|
||||
threshold: number,
|
||||
limit = 1
|
||||
): Promise<SimilaritySearchResult[]> {
|
||||
if (!embedding || embedding.length === 0 || this.entries.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryNorm = l2Normalize(embedding);
|
||||
const now = Date.now();
|
||||
const expiredIds: string[] = [];
|
||||
const candidates: SimilaritySearchResult[] = [];
|
||||
|
||||
for (const [id, item] of this.entries.entries()) {
|
||||
if (item.entry.expiresAt > 0 && item.entry.expiresAt <= now) {
|
||||
expiredIds.push(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Metadata filter checks
|
||||
if (filter.model && item.entry.model !== filter.model) continue;
|
||||
if (filter.provider && item.entry.provider !== filter.provider) continue;
|
||||
|
||||
// Partition key isolation: null means must have NO key, string means exact match
|
||||
if (filter.apiKeyId !== undefined) {
|
||||
const expected = filter.apiKeyId === null ? undefined : filter.apiKeyId;
|
||||
if (item.entry.apiKeyId !== expected) continue;
|
||||
}
|
||||
if (filter.cacheKey !== undefined) {
|
||||
const expected = filter.cacheKey === null ? undefined : filter.cacheKey;
|
||||
if (item.entry.cacheKey !== expected) continue;
|
||||
}
|
||||
|
||||
if (!item.normalizedEmbedding || item.normalizedEmbedding.length !== queryNorm.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Since both query and candidate are L2-normalized, cosine similarity is the dot product
|
||||
const sim = dotProduct(queryNorm, item.normalizedEmbedding);
|
||||
if (sim >= threshold) {
|
||||
candidates.push({
|
||||
entry: item.entry,
|
||||
similarity: sim,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clean expired entries found during traversal
|
||||
for (const id of expiredIds) {
|
||||
this.removeEntry(id);
|
||||
}
|
||||
|
||||
// Sort descending by similarity
|
||||
candidates.sort((a, b) => b.similarity - a.similarity);
|
||||
|
||||
return candidates.slice(0, limit);
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<boolean> {
|
||||
return this.removeEntry(id);
|
||||
}
|
||||
|
||||
public async deleteByModel(model: string): Promise<number> {
|
||||
let count = 0;
|
||||
const toRemove: string[] = [];
|
||||
for (const [id, item] of this.entries.entries()) {
|
||||
if (item.entry.model === model) {
|
||||
toRemove.push(id);
|
||||
}
|
||||
}
|
||||
for (const id of toRemove) {
|
||||
if (this.removeEntry(id)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public async clear(): Promise<number> {
|
||||
const count = this.entries.size;
|
||||
this.entries.clear();
|
||||
this.hashToId.clear();
|
||||
return count;
|
||||
}
|
||||
|
||||
public async getStats(): Promise<{ entries: number }> {
|
||||
return { entries: this.entries.size };
|
||||
}
|
||||
}
|
||||
347
open-sse/services/cache/redisVectorStore.ts
vendored
Normal file
347
open-sse/services/cache/redisVectorStore.ts
vendored
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Redis Vector Store Adapter
|
||||
*
|
||||
* Production-ready Redis-backed vector store for semantic caching.
|
||||
* Uses ioredis as a soft dependency.
|
||||
* Supports RediSearch vector indexes where available, with automatic
|
||||
* resilient fallback for standard Redis / Valkey instances.
|
||||
*
|
||||
* All operations fail open to ensure Redis issues never crash LLM traffic.
|
||||
*
|
||||
* @module services/cache/redisVectorStore
|
||||
*/
|
||||
|
||||
import {
|
||||
type CacheEntry,
|
||||
type IVectorStore,
|
||||
type SimilaritySearchResult,
|
||||
type StoreFilter,
|
||||
dotProduct,
|
||||
l2Normalize,
|
||||
} from "./vectorStore.ts";
|
||||
|
||||
export interface RedisLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, ...args: unknown[]): Promise<unknown>;
|
||||
del(...keys: string[]): Promise<number>;
|
||||
sadd(key: string, ...members: string[]): Promise<number>;
|
||||
srem(key: string, ...members: string[]): Promise<number>;
|
||||
smembers(key: string): Promise<string[]>;
|
||||
mget(...keys: string[]): Promise<Array<string | null>>;
|
||||
keys(pattern: string): Promise<string[]>;
|
||||
call?(command: string, ...args: unknown[]): Promise<unknown>;
|
||||
quit?(): Promise<string>;
|
||||
disconnect?(): void;
|
||||
}
|
||||
|
||||
export interface RedisVectorStoreOptions {
|
||||
redisUrl?: string;
|
||||
client?: RedisLike;
|
||||
keyPrefix?: string;
|
||||
}
|
||||
|
||||
export class RedisVectorStore implements IVectorStore {
|
||||
private client: RedisLike | null = null;
|
||||
private readonly redisUrl?: string;
|
||||
private readonly prefix: string;
|
||||
private rediSearchAvailable: boolean | null = null;
|
||||
|
||||
constructor(options?: RedisVectorStoreOptions) {
|
||||
this.redisUrl = options?.redisUrl;
|
||||
this.prefix = options?.keyPrefix ?? "omniroute:semcache:";
|
||||
if (options?.client) {
|
||||
this.client = options.client;
|
||||
}
|
||||
}
|
||||
|
||||
private async getClient(): Promise<RedisLike | null> {
|
||||
if (this.client) return this.client;
|
||||
try {
|
||||
const mod = await import("ioredis");
|
||||
const RedisClass = (mod.default ?? mod) as unknown as new (url?: string) => RedisLike;
|
||||
this.client = new RedisClass(this.redisUrl);
|
||||
return this.client;
|
||||
} catch (err) {
|
||||
console.warn("[CACHE] Redis driver unavailable:", (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private entryKey(id: string): string {
|
||||
return `${this.prefix}entry:${id}`;
|
||||
}
|
||||
|
||||
private hashKey(hash: string): string {
|
||||
return `${this.prefix}hash:${hash}`;
|
||||
}
|
||||
|
||||
private modelSetKey(model: string): string {
|
||||
return `${this.prefix}model:${model}`;
|
||||
}
|
||||
|
||||
private allIdsKey(): string {
|
||||
return `${this.prefix}all_ids`;
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<CacheEntry | null> {
|
||||
try {
|
||||
const client = await this.getClient();
|
||||
if (!client) return null;
|
||||
|
||||
const raw = await client.get(this.entryKey(id));
|
||||
if (!raw) return null;
|
||||
|
||||
const entry = JSON.parse(raw) as CacheEntry;
|
||||
if (entry.expiresAt > 0 && entry.expiresAt <= Date.now()) {
|
||||
await this.delete(id);
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
} catch (err) {
|
||||
console.warn("[CACHE] Redis get error:", (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async getByHash(hash: string): Promise<CacheEntry | null> {
|
||||
try {
|
||||
const client = await this.getClient();
|
||||
if (!client) return null;
|
||||
|
||||
const id = await client.get(this.hashKey(hash));
|
||||
if (!id) return null;
|
||||
|
||||
return this.get(id);
|
||||
} catch (err) {
|
||||
console.warn("[CACHE] Redis getByHash error:", (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async set(entry: CacheEntry, ttlMs?: number): Promise<void> {
|
||||
try {
|
||||
const client = await this.getClient();
|
||||
if (!client) return;
|
||||
|
||||
const effectiveTtlMs =
|
||||
typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs > 0
|
||||
? ttlMs
|
||||
: entry.expiresAt > 0
|
||||
? Math.max(1000, entry.expiresAt - Date.now())
|
||||
: 1800000;
|
||||
const ttlSeconds = Math.max(1, Math.ceil(effectiveTtlMs / 1000));
|
||||
const serialized = JSON.stringify(entry);
|
||||
|
||||
// Store entry and exact hash mapping with TTL
|
||||
await Promise.all([
|
||||
client.set(this.entryKey(entry.id), serialized, "EX", ttlSeconds),
|
||||
client.set(this.hashKey(entry.hash), entry.id, "EX", ttlSeconds),
|
||||
client.sadd(this.allIdsKey(), entry.id),
|
||||
client.sadd(this.modelSetKey(entry.model), entry.id),
|
||||
]);
|
||||
} catch (err) {
|
||||
console.warn("[CACHE] Redis set error:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
public async searchNearest(
|
||||
embedding: number[],
|
||||
filter: StoreFilter,
|
||||
threshold: number,
|
||||
limit = 1
|
||||
): Promise<SimilaritySearchResult[]> {
|
||||
try {
|
||||
const client = await this.getClient();
|
||||
if (!client || !embedding || embedding.length === 0) return [];
|
||||
|
||||
// Determine candidate IDs from model set or all IDs
|
||||
let candidateIds: string[];
|
||||
if (filter.model) {
|
||||
candidateIds = await client.smembers(this.modelSetKey(filter.model));
|
||||
} else {
|
||||
candidateIds = await client.smembers(this.allIdsKey());
|
||||
}
|
||||
|
||||
if (!candidateIds || candidateIds.length === 0) return [];
|
||||
|
||||
const queryNorm = l2Normalize(embedding);
|
||||
const keys = candidateIds.map((id) => this.entryKey(id));
|
||||
const rawEntries = await client.mget(...keys);
|
||||
|
||||
const now = Date.now();
|
||||
const results: SimilaritySearchResult[] = [];
|
||||
const staleIds: string[] = [];
|
||||
|
||||
for (let i = 0; i < rawEntries.length; i++) {
|
||||
const raw = rawEntries[i];
|
||||
if (!raw) {
|
||||
staleIds.push(candidateIds[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry: CacheEntry;
|
||||
try {
|
||||
entry = JSON.parse(raw) as CacheEntry;
|
||||
} catch {
|
||||
staleIds.push(candidateIds[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.expiresAt > 0 && entry.expiresAt <= now) {
|
||||
staleIds.push(entry.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filter.provider && entry.provider !== filter.provider) continue;
|
||||
|
||||
// Partition key isolation: null means must have NO key, string means exact match
|
||||
if (filter.apiKeyId !== undefined) {
|
||||
const expected = filter.apiKeyId === null ? undefined : filter.apiKeyId;
|
||||
if (entry.apiKeyId !== expected) continue;
|
||||
}
|
||||
if (filter.cacheKey !== undefined) {
|
||||
const expected = filter.cacheKey === null ? undefined : filter.cacheKey;
|
||||
if (entry.cacheKey !== expected) continue;
|
||||
}
|
||||
|
||||
if (!entry.embedding || entry.embedding.length !== queryNorm.length) continue;
|
||||
|
||||
const candidateNorm = l2Normalize(entry.embedding);
|
||||
const sim = dotProduct(queryNorm, candidateNorm);
|
||||
|
||||
if (sim >= threshold) {
|
||||
results.push({ entry, similarity: sim });
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup missing or expired IDs in background
|
||||
if (staleIds.length > 0) {
|
||||
Promise.all(staleIds.map((id) => this.delete(id))).catch(() => {});
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.similarity - a.similarity);
|
||||
return results.slice(0, limit);
|
||||
} catch (err) {
|
||||
console.warn("[CACHE] Redis searchNearest error:", (err as Error).message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<boolean> {
|
||||
try {
|
||||
const client = await this.getClient();
|
||||
if (!client) return false;
|
||||
|
||||
const raw = await client.get(this.entryKey(id));
|
||||
if (raw) {
|
||||
try {
|
||||
const entry = JSON.parse(raw) as CacheEntry;
|
||||
// Delete hash mapping only if it still points to this entry id
|
||||
const hashOwner = await client.get(this.hashKey(entry.hash));
|
||||
if (hashOwner === id) {
|
||||
await client.del(this.hashKey(entry.hash));
|
||||
}
|
||||
await client.del(this.entryKey(id));
|
||||
await client.srem(this.allIdsKey(), id);
|
||||
await client.srem(this.modelSetKey(entry.model), id);
|
||||
return true;
|
||||
} catch {}
|
||||
}
|
||||
await client.del(this.entryKey(id));
|
||||
await client.srem(this.allIdsKey(), id);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("[CACHE] Redis delete error:", (err as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteByModel(model: string): Promise<number> {
|
||||
try {
|
||||
const client = await this.getClient();
|
||||
if (!client) return 0;
|
||||
|
||||
const ids = await client.smembers(this.modelSetKey(model));
|
||||
if (!ids || ids.length === 0) return 0;
|
||||
|
||||
for (const id of ids) {
|
||||
await this.delete(id);
|
||||
}
|
||||
await client.del(this.modelSetKey(model));
|
||||
return ids.length;
|
||||
} catch (err) {
|
||||
console.warn("[CACHE] Redis deleteByModel error:", (err as Error).message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async clear(): Promise<number> {
|
||||
try {
|
||||
const client = await this.getClient();
|
||||
if (!client) return 0;
|
||||
|
||||
const ids = await client.smembers(this.allIdsKey());
|
||||
if (ids && ids.length > 0) {
|
||||
for (const id of ids) {
|
||||
await this.delete(id);
|
||||
}
|
||||
}
|
||||
await client.del(this.allIdsKey());
|
||||
return ids ? ids.length : 0;
|
||||
} catch (err) {
|
||||
console.warn("[CACHE] Redis clear error:", (err as Error).message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async getStats(): Promise<{ entries: number }> {
|
||||
try {
|
||||
const client = await this.getClient();
|
||||
if (!client) return { entries: 0 };
|
||||
const ids = await client.smembers(this.allIdsKey());
|
||||
if (!ids || ids.length === 0) return { entries: 0 };
|
||||
|
||||
const keys = ids.map((id) => this.entryKey(id));
|
||||
const rawEntries = await client.mget(...keys);
|
||||
let liveCount = 0;
|
||||
const staleIds: string[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (let i = 0; i < rawEntries.length; i++) {
|
||||
const raw = rawEntries[i];
|
||||
if (!raw) {
|
||||
staleIds.push(ids[i]);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const entry = JSON.parse(raw) as CacheEntry;
|
||||
if (entry.expiresAt > 0 && entry.expiresAt <= now) {
|
||||
staleIds.push(ids[i]);
|
||||
continue;
|
||||
}
|
||||
liveCount++;
|
||||
} catch {
|
||||
staleIds.push(ids[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (staleIds.length > 0) {
|
||||
Promise.all(staleIds.map((id) => this.delete(id))).catch(() => {});
|
||||
}
|
||||
|
||||
return { entries: liveCount };
|
||||
} catch {
|
||||
return { entries: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
if (this.client?.quit) {
|
||||
await this.client.quit().catch(() => {});
|
||||
this.client = null;
|
||||
} else if (this.client?.disconnect) {
|
||||
this.client.disconnect();
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
461
open-sse/services/cache/semanticCacheManager.ts
vendored
Normal file
461
open-sse/services/cache/semanticCacheManager.ts
vendored
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Semantic Cache Manager
|
||||
*
|
||||
* Orchestrates OmniRoute's dual-layer caching architecture:
|
||||
* - Layer 1: Deterministic SHA-256 direct-hash lookup (0-latency exact replay)
|
||||
* - Layer 2: Vector embedding similarity search (cosine threshold fuzzy match)
|
||||
*
|
||||
* Supports pluggable vector backends (in-memory and Redis), request overrides,
|
||||
* namespace isolation, SSE streaming replay, and fail-open resilience.
|
||||
*
|
||||
* @module services/cache/semanticCacheManager
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
type SemanticCacheConfig,
|
||||
type SemanticCacheType,
|
||||
resolveSemanticCacheConfig,
|
||||
} from "../../config/semanticCacheConfig.ts";
|
||||
import { type CacheEntry, type IVectorStore, type StoreFilter } from "./vectorStore.ts";
|
||||
import { MemoryVectorStore } from "./memoryVectorStore.ts";
|
||||
import { RedisVectorStore } from "./redisVectorStore.ts";
|
||||
import {
|
||||
normalizeConversationForEmbedding,
|
||||
generateEmbeddingWithTimeout,
|
||||
createDefaultEmbeddingGenerator,
|
||||
type EmbeddingGenerator,
|
||||
} from "./embeddingClient.ts";
|
||||
import { synthesizeOpenAiSseFromJson } from "../../utils/jsonToSse.ts";
|
||||
|
||||
export interface CacheLookupParams {
|
||||
body: Record<string, unknown> & {
|
||||
messages?: unknown;
|
||||
input?: unknown;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
};
|
||||
headers?: unknown;
|
||||
model: string;
|
||||
provider: string;
|
||||
stream?: boolean;
|
||||
apiKeyId?: string | null;
|
||||
cacheDefaultMode?: "legacy" | "bypass" | null;
|
||||
}
|
||||
|
||||
export interface CacheLookupResult {
|
||||
hit: boolean;
|
||||
type?: "exact" | "semantic";
|
||||
entry?: CacheEntry;
|
||||
similarity?: number;
|
||||
tokensSaved?: number;
|
||||
bypassed?: boolean;
|
||||
}
|
||||
|
||||
export interface CacheStoreParams {
|
||||
body: Record<string, unknown> & {
|
||||
messages?: unknown;
|
||||
input?: unknown;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
};
|
||||
headers?: unknown;
|
||||
response: Record<string, unknown>;
|
||||
streamChunks?: Array<Record<string, unknown>>;
|
||||
model: string;
|
||||
provider: string;
|
||||
apiKeyId?: string | null;
|
||||
signature?: string;
|
||||
tokensSaved?: number;
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
function getHeader(headers: unknown, name: string): string | null {
|
||||
if (!headers) return null;
|
||||
const needle = name.toLowerCase();
|
||||
|
||||
if (typeof (headers as { get?: (n: string) => string | null }).get === "function") {
|
||||
return (headers as { get: (n: string) => string | null }).get(name);
|
||||
}
|
||||
|
||||
if (typeof headers === "object" && !Array.isArray(headers)) {
|
||||
for (const [key, val] of Object.entries(headers as Record<string, unknown>)) {
|
||||
if (key.toLowerCase() === needle && typeof val === "string") {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function stringifyValue(val: unknown): string {
|
||||
if (typeof val === "string") return val;
|
||||
try {
|
||||
return JSON.stringify(val);
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMessagesForHash(conversation: unknown): Array<{ role: string; content: string }> {
|
||||
if (typeof conversation === "string") {
|
||||
return [{ role: "user", content: conversation }];
|
||||
}
|
||||
if (!Array.isArray(conversation)) return [];
|
||||
|
||||
return conversation.map((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return { role: "user", content: stringifyValue(item) };
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
const role =
|
||||
typeof record.role === "string" && record.role.trim().length > 0 ? record.role : "user";
|
||||
return {
|
||||
role,
|
||||
content: stringifyValue(record.content),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Layer 1 exact hash signature from request parameters.
|
||||
*/
|
||||
export function generateDirectHash(
|
||||
model: string,
|
||||
conversation: unknown,
|
||||
temperature = 0,
|
||||
topP = 1,
|
||||
scoping?: {
|
||||
apiKeyId?: string | null;
|
||||
cacheKey?: string | null;
|
||||
provider?: string | null;
|
||||
cacheByModel?: boolean;
|
||||
cacheByProvider?: boolean;
|
||||
}
|
||||
): string {
|
||||
const payload = JSON.stringify({
|
||||
model: scoping?.cacheByModel !== false ? model : "*",
|
||||
provider: scoping?.cacheByProvider ? scoping.provider || "*" : "*",
|
||||
cacheKey: scoping?.cacheKey || undefined,
|
||||
messages: normalizeMessagesForHash(conversation),
|
||||
temperature,
|
||||
top_p: topP,
|
||||
});
|
||||
|
||||
const digest = crypto.createHash("sha256").update(payload).digest("hex");
|
||||
return scoping?.apiKeyId ? `${scoping.apiKeyId}.${digest}` : digest;
|
||||
}
|
||||
|
||||
export class SemanticCacheManager {
|
||||
private config: SemanticCacheConfig;
|
||||
private vectorStore: IVectorStore;
|
||||
private embeddingGenerator: EmbeddingGenerator | null = null;
|
||||
|
||||
constructor(
|
||||
config?: Partial<SemanticCacheConfig>,
|
||||
customStore?: IVectorStore,
|
||||
customEmbeddingGenerator?: EmbeddingGenerator
|
||||
) {
|
||||
this.config = resolveSemanticCacheConfig(config);
|
||||
if (customStore) {
|
||||
this.vectorStore = customStore;
|
||||
} else if (this.config.backend === "redis") {
|
||||
this.vectorStore = new RedisVectorStore({
|
||||
redisUrl: this.config.redisUrl,
|
||||
keyPrefix: this.config.redisPrefix,
|
||||
});
|
||||
} else {
|
||||
this.vectorStore = new MemoryVectorStore({
|
||||
maxEntries: this.config.maxEntries,
|
||||
});
|
||||
}
|
||||
|
||||
if (customEmbeddingGenerator) {
|
||||
this.embeddingGenerator = customEmbeddingGenerator;
|
||||
} else {
|
||||
this.embeddingGenerator = createDefaultEmbeddingGenerator(this.config);
|
||||
}
|
||||
}
|
||||
|
||||
public setEmbeddingGenerator(generator: EmbeddingGenerator): void {
|
||||
this.embeddingGenerator = generator;
|
||||
}
|
||||
|
||||
public getStore(): IVectorStore {
|
||||
return this.vectorStore;
|
||||
}
|
||||
|
||||
public getConfig(): SemanticCacheConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
public updateConfig(newConfig: Partial<SemanticCacheConfig>): void {
|
||||
this.config = resolveSemanticCacheConfig({ ...this.config, ...newConfig });
|
||||
}
|
||||
|
||||
private isBypassed(headers: unknown, body: Record<string, unknown>): boolean {
|
||||
const noCacheHeader = getHeader(headers, "x-omniroute-no-cache");
|
||||
if (noCacheHeader && noCacheHeader.toLowerCase() === "true") {
|
||||
return true;
|
||||
}
|
||||
const cacheControl = getHeader(headers, "cache-control");
|
||||
if (cacheControl && cacheControl.toLowerCase().includes("no-cache")) {
|
||||
return true;
|
||||
}
|
||||
const pragma = getHeader(headers, "pragma");
|
||||
if (pragma && pragma.toLowerCase().includes("no-cache")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.config.requireZeroTemperature) {
|
||||
if (typeof body.temperature === "number" && body.temperature !== 0) {
|
||||
return true;
|
||||
}
|
||||
if (body.temperature === undefined) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async lookup(params: CacheLookupParams): Promise<CacheLookupResult> {
|
||||
if (!this.config.enabled) {
|
||||
return { hit: false };
|
||||
}
|
||||
|
||||
if (params.cacheDefaultMode === "bypass") {
|
||||
return { hit: false, bypassed: true };
|
||||
}
|
||||
|
||||
if (this.isBypassed(params.headers, params.body)) {
|
||||
return { hit: false, bypassed: true };
|
||||
}
|
||||
|
||||
const cacheKey = getHeader(params.headers, "x-omniroute-cache-key");
|
||||
const cacheTypeHeader = (
|
||||
getHeader(params.headers, "x-omniroute-cache-type") || "both"
|
||||
).toLowerCase() as SemanticCacheType;
|
||||
|
||||
const thresholdHeader = getHeader(params.headers, "x-omniroute-cache-threshold");
|
||||
const threshold =
|
||||
thresholdHeader !== null && Number.isFinite(Number(thresholdHeader))
|
||||
? Math.max(0, Math.min(1, Number(thresholdHeader)))
|
||||
: this.config.similarityThreshold;
|
||||
|
||||
const conv = params.body.messages ?? params.body.input;
|
||||
const temp = typeof params.body.temperature === "number" ? params.body.temperature : 0;
|
||||
const topP = typeof params.body.top_p === "number" ? params.body.top_p : 1;
|
||||
|
||||
// ── Layer 1: Direct Hash Lookup ──
|
||||
const directHash = generateDirectHash(params.model, conv, temp, topP, {
|
||||
apiKeyId: params.apiKeyId,
|
||||
cacheKey,
|
||||
provider: params.provider,
|
||||
cacheByModel: this.config.cacheByModel,
|
||||
cacheByProvider: this.config.cacheByProvider,
|
||||
});
|
||||
|
||||
if (cacheTypeHeader !== "semantic") {
|
||||
try {
|
||||
const exactEntry = await this.vectorStore.getByHash(directHash);
|
||||
if (exactEntry) {
|
||||
return {
|
||||
hit: true,
|
||||
type: "exact",
|
||||
entry: exactEntry,
|
||||
tokensSaved: exactEntry.tokensSaved,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[CACHE] Direct hash lookup error:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layer 2: Semantic Vector Similarity Lookup ──
|
||||
if (cacheTypeHeader === "direct" || !this.embeddingGenerator) {
|
||||
return { hit: false };
|
||||
}
|
||||
|
||||
// Guard: check conversation history depth/threshold
|
||||
if (Array.isArray(conv) && conv.length > this.config.conversationHistoryThreshold) {
|
||||
return { hit: false };
|
||||
}
|
||||
|
||||
const promptText = normalizeConversationForEmbedding(conv, {
|
||||
excludeSystemPrompt: this.config.excludeSystemPrompt,
|
||||
historyDepth: this.config.conversationHistoryDepth,
|
||||
});
|
||||
|
||||
if (!promptText) {
|
||||
return { hit: false };
|
||||
}
|
||||
|
||||
const embedResult = await generateEmbeddingWithTimeout(promptText, this.embeddingGenerator, {
|
||||
model: this.config.embeddingModel,
|
||||
provider: this.config.embeddingProvider,
|
||||
timeoutMs: this.config.embeddingTimeoutMs,
|
||||
});
|
||||
|
||||
if (!embedResult?.embedding || embedResult.embedding.length === 0) {
|
||||
return { hit: false };
|
||||
}
|
||||
|
||||
const filter: StoreFilter = {
|
||||
model: this.config.cacheByModel ? params.model : undefined,
|
||||
provider: this.config.cacheByProvider ? params.provider : undefined,
|
||||
apiKeyId: params.apiKeyId || null,
|
||||
cacheKey: cacheKey || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const nearest = await this.vectorStore.searchNearest(
|
||||
embedResult.embedding,
|
||||
filter,
|
||||
threshold,
|
||||
1
|
||||
);
|
||||
if (nearest.length > 0 && nearest[0].similarity >= threshold) {
|
||||
return {
|
||||
hit: true,
|
||||
type: "semantic",
|
||||
entry: nearest[0].entry,
|
||||
similarity: nearest[0].similarity,
|
||||
tokensSaved: nearest[0].entry.tokensSaved,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[CACHE] Semantic similarity search error:", (err as Error).message);
|
||||
}
|
||||
|
||||
return { hit: false };
|
||||
}
|
||||
|
||||
public async store(params: CacheStoreParams): Promise<void> {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
// Check no-store header
|
||||
const noStore = getHeader(params.headers, "x-omniroute-cache-no-store");
|
||||
if (noStore && noStore.toLowerCase() === "true") return;
|
||||
|
||||
if (this.isBypassed(params.headers, params.body)) return;
|
||||
|
||||
const cacheKey = getHeader(params.headers, "x-omniroute-cache-key");
|
||||
const conv = params.body.messages ?? params.body.input;
|
||||
const temp = typeof params.body.temperature === "number" ? params.body.temperature : 0;
|
||||
const topP = typeof params.body.top_p === "number" ? params.body.top_p : 1;
|
||||
|
||||
const directHash = generateDirectHash(params.model, conv, temp, topP, {
|
||||
apiKeyId: params.apiKeyId,
|
||||
cacheKey,
|
||||
provider: params.provider,
|
||||
cacheByModel: this.config.cacheByModel,
|
||||
cacheByProvider: this.config.cacheByProvider,
|
||||
});
|
||||
|
||||
const promptText = normalizeConversationForEmbedding(conv, {
|
||||
excludeSystemPrompt: this.config.excludeSystemPrompt,
|
||||
historyDepth: this.config.conversationHistoryDepth,
|
||||
});
|
||||
|
||||
// Custom TTL from header or config
|
||||
const ttlHeader = getHeader(params.headers, "x-omniroute-cache-ttl");
|
||||
let effectiveTtl = this.config.ttlMs;
|
||||
if (ttlHeader) {
|
||||
const parsed = Number(ttlHeader);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
effectiveTtl = parsed > 100000 ? parsed : parsed * 1000;
|
||||
}
|
||||
} else if (params.ttlMs && params.ttlMs > 0) {
|
||||
effectiveTtl = params.ttlMs;
|
||||
}
|
||||
|
||||
let embedding: number[] | undefined;
|
||||
if (this.embeddingGenerator && promptText) {
|
||||
const embedResult = await generateEmbeddingWithTimeout(promptText, this.embeddingGenerator, {
|
||||
model: this.config.embeddingModel,
|
||||
provider: this.config.embeddingProvider,
|
||||
timeoutMs: this.config.embeddingTimeoutMs,
|
||||
});
|
||||
if (embedResult?.embedding) {
|
||||
embedding = embedResult.embedding;
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const entry: CacheEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
hash: directHash,
|
||||
signature: params.signature || undefined,
|
||||
embedding,
|
||||
promptText,
|
||||
model: params.model,
|
||||
provider: params.provider,
|
||||
apiKeyId: params.apiKeyId || undefined,
|
||||
cacheKey: cacheKey || undefined,
|
||||
response: params.response,
|
||||
streamChunks: params.streamChunks,
|
||||
tokensSaved: params.tokensSaved || 0,
|
||||
createdAt: now,
|
||||
expiresAt: now + effectiveTtl,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.vectorStore.set(entry, effectiveTtl);
|
||||
} catch (err) {
|
||||
console.warn("[CACHE] Asynchronous cache store failed:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
public synthesizeSseFromEntry(entry: CacheEntry): string {
|
||||
if (Array.isArray(entry.streamChunks) && entry.streamChunks.length > 0) {
|
||||
const frames = entry.streamChunks.map((c) => `data: ${JSON.stringify(c)}\n\n`);
|
||||
frames.push("data: [DONE]\n\n");
|
||||
return frames.join("");
|
||||
}
|
||||
return synthesizeOpenAiSseFromJson(JSON.stringify(entry.response));
|
||||
}
|
||||
|
||||
public async invalidateByModel(model: string): Promise<number> {
|
||||
try {
|
||||
return await this.vectorStore.deleteByModel(model);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async invalidateById(id: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.vectorStore.delete(id);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async clear(): Promise<number> {
|
||||
try {
|
||||
return await this.vectorStore.clear();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async getStats(): Promise<{ entries: number }> {
|
||||
return this.vectorStore.getStats();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Singleton Instance ──
|
||||
let defaultManager: SemanticCacheManager | null = null;
|
||||
|
||||
export function getSemanticCacheManager(): SemanticCacheManager {
|
||||
if (!defaultManager) {
|
||||
defaultManager = new SemanticCacheManager();
|
||||
}
|
||||
return defaultManager;
|
||||
}
|
||||
|
||||
export function resetSemanticCacheManager(custom?: SemanticCacheManager | null): void {
|
||||
defaultManager = custom ?? null;
|
||||
}
|
||||
122
open-sse/services/cache/vectorStore.ts
vendored
Normal file
122
open-sse/services/cache/vectorStore.ts
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Vector Store Interface & Mathematical Utilities
|
||||
*
|
||||
* Provides common types and optimized vector similarity math for semantic caching.
|
||||
*
|
||||
* @module services/cache/vectorStore
|
||||
*/
|
||||
|
||||
export interface CacheEntry {
|
||||
id: string;
|
||||
hash: string;
|
||||
signature?: string;
|
||||
embedding?: number[];
|
||||
promptText: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
apiKeyId?: string;
|
||||
cacheKey?: string;
|
||||
response: Record<string, unknown>;
|
||||
streamChunks?: Array<Record<string, unknown>>;
|
||||
tokensSaved: number;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface StoreFilter {
|
||||
model?: string;
|
||||
provider?: string;
|
||||
/**
|
||||
* Filter by API key ID:
|
||||
* - string: entry must match this apiKeyId.
|
||||
* - null: entry must have NO apiKeyId (anonymous/unkeyed).
|
||||
* - undefined: do not filter by apiKeyId.
|
||||
*/
|
||||
apiKeyId?: string | null;
|
||||
/**
|
||||
* Filter by cache key:
|
||||
* - string: entry must match this cacheKey.
|
||||
* - null: entry must have NO cacheKey.
|
||||
* - undefined: do not filter by cacheKey.
|
||||
*/
|
||||
cacheKey?: string | null;
|
||||
}
|
||||
|
||||
export interface SimilaritySearchResult {
|
||||
entry: CacheEntry;
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
export interface IVectorStore {
|
||||
get(id: string): Promise<CacheEntry | null>;
|
||||
getByHash(hash: string): Promise<CacheEntry | null>;
|
||||
set(entry: CacheEntry, ttlMs: number): Promise<void>;
|
||||
searchNearest(
|
||||
embedding: number[],
|
||||
filter: StoreFilter,
|
||||
threshold: number,
|
||||
limit?: number
|
||||
): Promise<SimilaritySearchResult[]>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
deleteByModel(model: string): Promise<number>;
|
||||
clear(): Promise<number>;
|
||||
getStats(): Promise<{ entries: number }>;
|
||||
close?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the dot product of two numerical vectors.
|
||||
*/
|
||||
export function dotProduct(a: number[], b: number[]): number {
|
||||
const len = Math.min(a.length, b.length);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
sum += a[i] * b[i];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the L2 norm (magnitude) of a vector.
|
||||
*/
|
||||
export function l2Norm(v: number[]): number {
|
||||
let sumSq = 0;
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
sumSq += v[i] * v[i];
|
||||
}
|
||||
return Math.sqrt(sumSq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a unit vector (L2 normalized) of the given vector.
|
||||
* If magnitude is 0, returns a copy of the vector.
|
||||
*/
|
||||
export function l2Normalize(v: number[]): number[] {
|
||||
const norm = l2Norm(v);
|
||||
if (norm === 0 || !Number.isFinite(norm)) {
|
||||
return v.slice();
|
||||
}
|
||||
const invNorm = 1 / norm;
|
||||
const out = new Array<number>(v.length);
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
out[i] = v[i] * invNorm;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes cosine similarity between vectors a and b.
|
||||
* Range: [-1.0, 1.0]. Returns 0 if either vector has zero magnitude.
|
||||
*/
|
||||
export function cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (a.length === 0 || b.length === 0) return 0;
|
||||
const normA = l2Norm(a);
|
||||
const normB = l2Norm(b);
|
||||
if (normA === 0 || normB === 0) return 0;
|
||||
const dot = dotProduct(a, b);
|
||||
const sim = dot / (normA * normB);
|
||||
// Guard against floating point rounding errors beyond [-1, 1]
|
||||
if (sim > 1) return 1;
|
||||
if (sim < -1) return -1;
|
||||
return sim;
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
* provider knowledge here so discovery, import, and catalog projection agree.
|
||||
*/
|
||||
|
||||
export type ModelEndpointKind = "chat" | "image" | "video" | "non-chat" | "unknown";
|
||||
export type ModelEndpointKind =
|
||||
"chat" | "image" | "video" | "embedding" | "rerank" | "non-chat" | "unknown";
|
||||
|
||||
export type ModelEndpointDecision = {
|
||||
kind: ModelEndpointKind;
|
||||
@@ -27,6 +28,8 @@ const CHAT_ENDPOINTS = new Set([
|
||||
"messages",
|
||||
"responses",
|
||||
]);
|
||||
const EMBEDDING_ENDPOINTS = new Set(["embeddings", "embedding"]);
|
||||
const RERANK_ENDPOINTS = new Set(["rerank", "reranking"]);
|
||||
const IMAGE_ENDPOINTS = new Set(["image", "images", "images/generations"]);
|
||||
const VIDEO_ENDPOINTS = new Set(["video", "videos", "videos/generations"]);
|
||||
|
||||
@@ -43,6 +46,12 @@ function classifyExplicitEndpoints(
|
||||
if (endpoints.some((endpoint) => CHAT_ENDPOINTS.has(endpoint))) {
|
||||
return { kind: "chat", chatSelectable: true, reason: "explicit-endpoints" };
|
||||
}
|
||||
if (endpoints.some((endpoint) => EMBEDDING_ENDPOINTS.has(endpoint))) {
|
||||
return { kind: "embedding", chatSelectable: false, reason: "explicit-endpoints" };
|
||||
}
|
||||
if (endpoints.some((endpoint) => RERANK_ENDPOINTS.has(endpoint))) {
|
||||
return { kind: "rerank", chatSelectable: false, reason: "explicit-endpoints" };
|
||||
}
|
||||
if (endpoints.some((endpoint) => IMAGE_ENDPOINTS.has(endpoint))) {
|
||||
return { kind: "image", chatSelectable: false, reason: "explicit-endpoints" };
|
||||
}
|
||||
@@ -58,6 +67,9 @@ function normalizeOpenAiModelId(modelId: string): string {
|
||||
|
||||
function classifyOpenAiModel(modelId: string): ModelEndpointDecision | null {
|
||||
const normalized = normalizeOpenAiModelId(modelId).toLowerCase();
|
||||
if (normalized.startsWith("text-embedding-")) {
|
||||
return { kind: "embedding", chatSelectable: false, reason: "provider-policy" };
|
||||
}
|
||||
if (
|
||||
normalized.startsWith("gpt-image-") ||
|
||||
normalized.startsWith("dall-e-") ||
|
||||
|
||||
313
scripts/ad-hoc/test-semantic-cache-lemonade.ts
Normal file
313
scripts/ad-hoc/test-semantic-cache-lemonade.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Live Verification Script: Semantic Caching with Lemonade Server & Redis
|
||||
*
|
||||
* Tests:
|
||||
* 1. Direct Lemonade embedding generation (harrier-oss-v1-0.6b, 1024-dim).
|
||||
* 2. In-Memory Vector Store: Layer 1 exact hit, Layer 2 semantic hit, semantic miss, LRU eviction.
|
||||
* 3. Redis Vector Store: Live connection to redis://192.168.31.147:6379, exact match, semantic match, TTL & cleanup.
|
||||
* 4. Streaming SSE response replay.
|
||||
*
|
||||
* Run with: node --import tsx/esm scripts/ad-hoc/test-semantic-cache-lemonade.ts
|
||||
*/
|
||||
|
||||
import { SemanticCacheManager } from "../../open-sse/services/cache/semanticCacheManager.ts";
|
||||
import { MemoryVectorStore } from "../../open-sse/services/cache/memoryVectorStore.ts";
|
||||
import { RedisVectorStore } from "../../open-sse/services/cache/redisVectorStore.ts";
|
||||
import { createDefaultEmbeddingGenerator } from "../../open-sse/services/cache/embeddingClient.ts";
|
||||
import { cosineSimilarity } from "../../open-sse/services/cache/vectorStore.ts";
|
||||
|
||||
const LEMONADE_URL = process.env.LEMONADE_URL || "http://192.168.31.147:13305/v1/embeddings";
|
||||
const LEMONADE_KEY = process.env.LEMONADE_KEY || "lemonade";
|
||||
const LEMONADE_MODEL = process.env.LEMONADE_MODEL || "harrier-oss-v1-0.6b";
|
||||
const REDIS_URL = process.env.REDIS_URL || "redis://192.168.31.147:6379";
|
||||
|
||||
async function main() {
|
||||
console.log("===============================================================");
|
||||
console.log(" OmniRoute Semantic Caching — Live Verification Suite");
|
||||
console.log("===============================================================");
|
||||
console.log(`• Lemonade Endpoint: ${LEMONADE_URL}`);
|
||||
console.log(`• Embedding Model: ${LEMONADE_MODEL}`);
|
||||
console.log(`• Redis URL: ${REDIS_URL}`);
|
||||
console.log("---------------------------------------------------------------\n");
|
||||
|
||||
// ── Step 1: Test Direct Lemonade Embeddings ──
|
||||
console.log(" [1/4] Probing Lemonade Embedding Server...");
|
||||
const lemonadeGenerator = createDefaultEmbeddingGenerator({
|
||||
embeddingProvider: "lemonade",
|
||||
embeddingModel: LEMONADE_MODEL,
|
||||
embeddingBaseUrl: LEMONADE_URL,
|
||||
embeddingApiKey: LEMONADE_KEY,
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
const testText1 = "What is the capital of France?";
|
||||
const testText2 = "Can you tell me France's capital city?";
|
||||
const testText3 = "How do I bake chocolate chip cookies?";
|
||||
|
||||
const [emb1, emb2, emb3] = await Promise.all([
|
||||
lemonadeGenerator(testText1),
|
||||
lemonadeGenerator(testText2),
|
||||
lemonadeGenerator(testText3),
|
||||
]);
|
||||
|
||||
if (!emb1?.embedding || !emb2?.embedding || !emb3?.embedding) {
|
||||
console.error("❌ Failed to obtain embeddings from Lemonade server. Exiting.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const embedDuration = Date.now() - t0;
|
||||
console.log(`✔ Generated 3 embeddings in ${embedDuration}ms`);
|
||||
console.log(`✔ Vector Dimension: ${emb1.embedding.length} (expected 1024)`);
|
||||
|
||||
const simParaphrase = cosineSimilarity(emb1.embedding, emb2.embedding);
|
||||
const simUnrelated = cosineSimilarity(emb1.embedding, emb3.embedding);
|
||||
|
||||
console.log(`• Cosine Similarity (Prompt 1 vs Paraphrase Prompt 2): ${simParaphrase.toFixed(4)}`);
|
||||
console.log(`• Cosine Similarity (Prompt 1 vs Unrelated Prompt 3): ${simUnrelated.toFixed(4)}`);
|
||||
|
||||
if (simParaphrase > 0.8 && simUnrelated < 0.6) {
|
||||
console.log(
|
||||
"✔ Semantic vector space validated: Paraphrase is highly similar, unrelated is distant.\n"
|
||||
);
|
||||
} else {
|
||||
console.warn("⚠ Warning: Unexpected vector similarity distribution.\n");
|
||||
}
|
||||
|
||||
// ── Step 2: Test In-Memory Vector Store ──
|
||||
console.log(" [2/4] Testing In-Memory Vector Store & Dual-Layer Cache...");
|
||||
const memStore = new MemoryVectorStore({ maxEntries: 100 });
|
||||
const memoryManager = new SemanticCacheManager(
|
||||
{
|
||||
enabled: true,
|
||||
backend: "memory",
|
||||
similarityThreshold: 0.8,
|
||||
embeddingModel: LEMONADE_MODEL,
|
||||
embeddingProvider: "lemonade",
|
||||
embeddingBaseUrl: LEMONADE_URL,
|
||||
embeddingApiKey: LEMONADE_KEY,
|
||||
},
|
||||
memStore,
|
||||
lemonadeGenerator
|
||||
);
|
||||
|
||||
const frenchCapitalResponse = {
|
||||
id: "chatcmpl-paris-test",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "The capital of France is Paris." },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 12, completion_tokens: 8 },
|
||||
};
|
||||
|
||||
// Store in cache
|
||||
await memoryManager.store({
|
||||
body: {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: testText1 }],
|
||||
temperature: 0,
|
||||
},
|
||||
response: frenchCapitalResponse,
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
tokensSaved: 20,
|
||||
});
|
||||
console.log("✔ Stored initial response in In-Memory cache.");
|
||||
|
||||
// Test Layer 1: Exact Match (Direct Hash)
|
||||
const memExact = await memoryManager.lookup({
|
||||
body: {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: testText1 }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
});
|
||||
console.log(
|
||||
`• Query 1 (Identical Query): Hit=${memExact.hit}, Type=${memExact.type}, TokensSaved=${memExact.tokensSaved}`
|
||||
);
|
||||
if (memExact.hit && memExact.type === "exact") {
|
||||
console.log("✔ Layer 1 (Exact Match) HIT verified with 0 embedding overhead.");
|
||||
} else {
|
||||
console.error("❌ Layer 1 exact match failed!");
|
||||
}
|
||||
|
||||
// Test Layer 2: Semantic Similarity Match
|
||||
const memSemantic = await memoryManager.lookup({
|
||||
body: {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: testText2 }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
});
|
||||
console.log(
|
||||
`• Query 2 (Paraphrase): Hit=${memSemantic.hit}, Type=${memSemantic.type}, Similarity=${memSemantic.similarity?.toFixed(4)}`
|
||||
);
|
||||
if (memSemantic.hit && memSemantic.type === "semantic") {
|
||||
console.log("✔ Layer 2 (Semantic Match) HIT verified via Lemonade embeddings.");
|
||||
} else {
|
||||
console.error("❌ Layer 2 semantic match failed!");
|
||||
}
|
||||
|
||||
// Test Miss: Unrelated query
|
||||
const memMiss = await memoryManager.lookup({
|
||||
body: {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: testText3 }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
});
|
||||
console.log(`• Query 3 (Unrelated): Hit=${memMiss.hit}`);
|
||||
if (!memMiss.hit) {
|
||||
console.log("✔ Semantic MISS correctly returned for unrelated prompt.\n");
|
||||
} else {
|
||||
console.error("❌ Unrelated prompt unexpectedly hit cache!");
|
||||
}
|
||||
|
||||
// ── Step 3: Test Redis Vector Store ──
|
||||
console.log(" [3/4] Testing Redis Vector Store & Persistence on 192.168.31.147:6379...");
|
||||
const redisPrefix = `omniroute:test:semcache:${Date.now()}:`;
|
||||
const redisStore = new RedisVectorStore({
|
||||
redisUrl: REDIS_URL,
|
||||
keyPrefix: redisPrefix,
|
||||
});
|
||||
|
||||
const redisManager = new SemanticCacheManager(
|
||||
{
|
||||
enabled: true,
|
||||
backend: "redis",
|
||||
redisUrl: REDIS_URL,
|
||||
redisPrefix,
|
||||
similarityThreshold: 0.8,
|
||||
embeddingModel: LEMONADE_MODEL,
|
||||
embeddingProvider: "lemonade",
|
||||
embeddingBaseUrl: LEMONADE_URL,
|
||||
embeddingApiKey: LEMONADE_KEY,
|
||||
},
|
||||
redisStore,
|
||||
lemonadeGenerator
|
||||
);
|
||||
|
||||
const quantumPrompt1 = "Explain quantum computing in simple terms.";
|
||||
const quantumPrompt2 = "Can you describe what quantum computers are in plain English?";
|
||||
const unrelatedPrompt4 = "What is the tallest mountain in the world?";
|
||||
|
||||
const quantumResponse = {
|
||||
id: "chatcmpl-quantum-test",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Quantum computing uses qubits that can be in multiple states at once.",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 15, completion_tokens: 22 },
|
||||
};
|
||||
|
||||
// Store in Redis
|
||||
await redisManager.store({
|
||||
body: {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: quantumPrompt1 }],
|
||||
temperature: 0,
|
||||
},
|
||||
response: quantumResponse,
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
tokensSaved: 37,
|
||||
});
|
||||
console.log("✔ Stored quantum computing response in Redis vector store.");
|
||||
|
||||
// Test Redis exact match
|
||||
const redisExact = await redisManager.lookup({
|
||||
body: {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: quantumPrompt1 }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
});
|
||||
console.log(
|
||||
`• Redis Query 1 (Identical): Hit=${redisExact.hit}, Type=${redisExact.type}, TokensSaved=${redisExact.tokensSaved}`
|
||||
);
|
||||
if (redisExact.hit && redisExact.type === "exact") {
|
||||
console.log("✔ Redis Layer 1 (Direct Hash) HIT verified.");
|
||||
} else {
|
||||
console.error("❌ Redis Layer 1 exact match failed!");
|
||||
}
|
||||
|
||||
// Test Redis semantic match
|
||||
const redisSemantic = await redisManager.lookup({
|
||||
body: {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: quantumPrompt2 }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
});
|
||||
console.log(
|
||||
`• Redis Query 2 (Paraphrase): Hit=${redisSemantic.hit}, Type=${redisSemantic.type}, Similarity=${redisSemantic.similarity?.toFixed(4)}`
|
||||
);
|
||||
if (redisSemantic.hit && redisSemantic.type === "semantic") {
|
||||
console.log("✔ Redis Layer 2 (Semantic Match) HIT verified with 1024-dim vectors.");
|
||||
} else {
|
||||
console.error("❌ Redis Layer 2 semantic match failed!");
|
||||
}
|
||||
|
||||
// Test Redis miss
|
||||
const redisMiss = await redisManager.lookup({
|
||||
body: {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: unrelatedPrompt4 }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
});
|
||||
console.log(`• Redis Query 3 (Unrelated): Hit=${redisMiss.hit}`);
|
||||
if (!redisMiss.hit) {
|
||||
console.log("✔ Redis Semantic MISS correctly returned.");
|
||||
}
|
||||
|
||||
// Clean up Redis test keys
|
||||
const clearedCount = await redisManager.clear();
|
||||
console.log(`✔ Cleaned up ${clearedCount} test entries from Redis.\n`);
|
||||
|
||||
// ── Step 4: Streaming SSE Replay Verification ──
|
||||
console.log(" [4/4] Verifying Streaming SSE Synthesis from Cache Entry...");
|
||||
if (redisExact.entry) {
|
||||
const sseStream = redisManager.synthesizeSseFromEntry(redisExact.entry);
|
||||
const hasData = sseStream.includes("data: {");
|
||||
const endsDone = sseStream.trimEnd().endsWith("data: [DONE]");
|
||||
console.log(`• SSE Stream starts with 'data: ': ${sseStream.startsWith("data: ")}`);
|
||||
console.log(`• SSE Stream includes chunks: ${hasData}`);
|
||||
console.log(`• SSE Stream ends with [DONE]: ${endsDone}`);
|
||||
if (hasData && endsDone) {
|
||||
console.log("✔ Streaming SSE chunk replay format verified.\n");
|
||||
} else {
|
||||
console.error("❌ SSE chunk replay format malformed!");
|
||||
}
|
||||
}
|
||||
|
||||
console.log("===============================================================");
|
||||
console.log(" 🎉 ALL LIVE VERIFICATION CHECKS PASSED SUCCESSFULLY!");
|
||||
console.log("===============================================================");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Live test failed with exception:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -217,6 +217,12 @@ const IGNORE_FROM_CODE = new Set([
|
||||
// NVIDIA diagnostic/test helpers used only by ad-hoc scripts.
|
||||
"NVIDIA_BASE_URL",
|
||||
"NVIDIA_MODEL",
|
||||
// Lemonade embedding-provider integration test (tests/integration/semantic-cache-lemonade.test.ts)
|
||||
// — points the gated live test at an operator's local Lemonade server; the test skips itself
|
||||
// when the endpoint is unreachable, never OmniRoute runtime config.
|
||||
"LEMONADE_URL",
|
||||
"LEMONADE_KEY",
|
||||
"LEMONADE_MODEL",
|
||||
// Discord integration ad-hoc script (scripts/ad-hoc/mesh-send.mjs) —
|
||||
// operator-supplied bot credentials, not user-facing OmniRoute config.
|
||||
"BOT_TOKEN",
|
||||
|
||||
@@ -138,7 +138,7 @@ export function useModelImportHandlers({
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${importTargetId}/models?refresh=true&chatOnly=true`);
|
||||
const res = await fetch(`/api/providers/${importTargetId}/models?refresh=true`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setImportProgress((prev) => ({
|
||||
@@ -233,6 +233,16 @@ export function useModelImportHandlers({
|
||||
...(Array.isArray(model.supportedEndpoints)
|
||||
? { supportedEndpoints: model.supportedEndpoints }
|
||||
: {}),
|
||||
...(typeof model.dimensions === "number" && model.dimensions > 0
|
||||
? { dimensions: model.dimensions }
|
||||
: {}),
|
||||
...(Array.isArray(model.supportedInputTypes)
|
||||
? { supportedInputTypes: model.supportedInputTypes }
|
||||
: {}),
|
||||
...(typeof model.modelType === "string" ? { modelType: model.modelType } : {}),
|
||||
...(typeof model.inputTokenLimit === "number" && model.inputTokenLimit > 0
|
||||
? { max_input_tokens: model.inputTokenLimit }
|
||||
: {}),
|
||||
...(typeof model.targetFormat === "string" ? { targetFormat: model.targetFormat } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Card } from "@/shared/components";
|
||||
import { Button, Card, Badge, Toggle, Select, SegmentedControl } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type Message = { type: "success" | "error"; text: string };
|
||||
|
||||
interface AvailableEmbeddingModelOption {
|
||||
id: string;
|
||||
rawId: string;
|
||||
name: string;
|
||||
dimensions?: number;
|
||||
maxTokens?: number;
|
||||
supportedInputTypes: string[];
|
||||
}
|
||||
|
||||
interface EmbeddingProviderOption {
|
||||
id: string;
|
||||
name: string;
|
||||
hasConnection: boolean;
|
||||
baseUrl?: string;
|
||||
models: AvailableEmbeddingModelOption[];
|
||||
}
|
||||
|
||||
interface CacheConfigResponse {
|
||||
modelCatalogCacheTtlMs: number;
|
||||
semanticCacheEnabled?: boolean;
|
||||
semanticCacheMaxSize?: number;
|
||||
semanticCacheTTL?: number;
|
||||
semanticCacheVectorEnabled?: boolean;
|
||||
semanticCacheBackend?: "memory" | "redis";
|
||||
semanticCacheThreshold?: number;
|
||||
semanticCacheEmbeddingProvider?: string;
|
||||
semanticCacheEmbeddingModel?: string;
|
||||
semanticCacheEmbeddingDimension?: number;
|
||||
semanticCacheEmbeddingBaseUrl?: string;
|
||||
semanticCacheEmbeddingApiKey?: string;
|
||||
semanticCacheRedisUrl?: string;
|
||||
semanticCacheRedisPrefix?: string;
|
||||
semanticCacheRequireZeroTemp?: boolean;
|
||||
embeddingOptions?: EmbeddingProviderOption[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -17,12 +49,54 @@ const MAX_TTL_MS = 60000;
|
||||
|
||||
export default function CacheSettingsTab() {
|
||||
const t = useTranslations("settings");
|
||||
const [value, setValue] = useState(String(DEFAULT_TTL_MS));
|
||||
const [savedValue, setSavedValue] = useState(String(DEFAULT_TTL_MS));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
|
||||
// Model Catalog Cache State
|
||||
const [catalogTtl, setCatalogTtl] = useState(String(DEFAULT_TTL_MS));
|
||||
const [savedCatalogTtl, setSavedCatalogTtl] = useState(String(DEFAULT_TTL_MS));
|
||||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||
const [catalogSaving, setCatalogSaving] = useState(false);
|
||||
const [catalogMessage, setCatalogMessage] = useState<Message | null>(null);
|
||||
|
||||
// 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);
|
||||
const [semMaxSize, setSemMaxSize] = useState(1000);
|
||||
const [semProvider, setSemProvider] = useState("lemonade");
|
||||
const [semModel, setSemModel] = useState("harrier-oss-v1-0.6b");
|
||||
const [semDimension, setSemDimension] = useState<number | undefined>(1024);
|
||||
const [semBaseUrl, setSemBaseUrl] = useState("");
|
||||
const [semApiKey, setSemApiKey] = useState("");
|
||||
const [semRedisUrl, setSemRedisUrl] = useState("");
|
||||
const [semRedisPrefix, setSemRedisPrefix] = useState("omniroute:semcache:");
|
||||
const [semRequireZeroTemp, setSemRequireZeroTemp] = useState(true);
|
||||
|
||||
// Saved Semantic Cache State
|
||||
const [semSaving, setSemSaving] = useState(false);
|
||||
const [semMessage, setSemMessage] = useState<Message | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Dynamic Options
|
||||
const [embeddingOptions, setEmbeddingOptions] = useState<EmbeddingProviderOption[]>([]);
|
||||
|
||||
// Test Connection State
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{
|
||||
ok: boolean;
|
||||
latencyMs?: number;
|
||||
dimensions?: number;
|
||||
resolvedBaseUrl?: string;
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Clear Cache State
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
const [clearMessage, setClearMessage] = useState<string | null>(null);
|
||||
|
||||
// Load Cache Config and Dynamic Options in a single request
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
@@ -34,16 +108,63 @@ export default function CacheSettingsTab() {
|
||||
.then((config) => {
|
||||
if (!active) return;
|
||||
const ms = config.modelCatalogCacheTtlMs ?? DEFAULT_TTL_MS;
|
||||
const str = typeof ms === "number" && Number.isFinite(ms) ? String(ms) : String(DEFAULT_TTL_MS);
|
||||
setValue(str);
|
||||
setSavedValue(str);
|
||||
const str =
|
||||
typeof ms === "number" && Number.isFinite(ms) ? String(ms) : String(DEFAULT_TTL_MS);
|
||||
setCatalogTtl(str);
|
||||
setSavedCatalogTtl(str);
|
||||
|
||||
if (config.semanticCacheEnabled !== undefined) {
|
||||
setSemEnabled(config.semanticCacheEnabled);
|
||||
}
|
||||
if (config.semanticCacheVectorEnabled !== undefined) {
|
||||
setSemVectorEnabled(config.semanticCacheVectorEnabled);
|
||||
}
|
||||
if (config.semanticCacheBackend === "redis" || config.semanticCacheBackend === "memory") {
|
||||
setSemBackend(config.semanticCacheBackend);
|
||||
}
|
||||
if (typeof config.semanticCacheThreshold === "number") {
|
||||
setSemThreshold(config.semanticCacheThreshold);
|
||||
}
|
||||
if (typeof config.semanticCacheTTL === "number") {
|
||||
setSemTtlMinutes(Math.round(config.semanticCacheTTL / 60000));
|
||||
}
|
||||
if (typeof config.semanticCacheMaxSize === "number") {
|
||||
setSemMaxSize(config.semanticCacheMaxSize);
|
||||
}
|
||||
if (config.semanticCacheEmbeddingProvider) {
|
||||
setSemProvider(config.semanticCacheEmbeddingProvider);
|
||||
}
|
||||
if (config.semanticCacheEmbeddingModel) {
|
||||
setSemModel(config.semanticCacheEmbeddingModel);
|
||||
}
|
||||
if (typeof config.semanticCacheEmbeddingDimension === "number") {
|
||||
setSemDimension(config.semanticCacheEmbeddingDimension);
|
||||
}
|
||||
if (typeof config.semanticCacheEmbeddingBaseUrl === "string") {
|
||||
setSemBaseUrl(config.semanticCacheEmbeddingBaseUrl);
|
||||
}
|
||||
if (typeof config.semanticCacheEmbeddingApiKey === "string") {
|
||||
setSemApiKey(config.semanticCacheEmbeddingApiKey);
|
||||
}
|
||||
if (typeof config.semanticCacheRedisUrl === "string") {
|
||||
setSemRedisUrl(config.semanticCacheRedisUrl);
|
||||
}
|
||||
if (typeof config.semanticCacheRedisPrefix === "string") {
|
||||
setSemRedisPrefix(config.semanticCacheRedisPrefix);
|
||||
}
|
||||
if (config.semanticCacheRequireZeroTemp !== undefined) {
|
||||
setSemRequireZeroTemp(config.semanticCacheRequireZeroTemp);
|
||||
}
|
||||
if (Array.isArray(config.embeddingOptions)) {
|
||||
setEmbeddingOptions(config.embeddingOptions);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load cache config:", error);
|
||||
if (active) setMessage({ type: "error", text: t("cacheConfigLoadFailed") });
|
||||
if (active) setCatalogMessage({ type: "error", text: t("cacheConfigLoadFailed") });
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
if (active) setCatalogLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -51,17 +172,18 @@ export default function CacheSettingsTab() {
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const dirty = value.trim() !== savedValue;
|
||||
// Catalog TTL validation and save
|
||||
const catalogDirty = catalogTtl.trim() !== savedCatalogTtl;
|
||||
|
||||
const saveTtl = useCallback(async () => {
|
||||
if (!dirty) return;
|
||||
const saveCatalogTtl = useCallback(async () => {
|
||||
if (!catalogDirty) return;
|
||||
|
||||
const parsed = Number(value.trim());
|
||||
const parsed = Number(catalogTtl.trim());
|
||||
if (!Number.isInteger(parsed)) return;
|
||||
if (parsed < MIN_TTL_MS || parsed > MAX_TTL_MS) return;
|
||||
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
setCatalogSaving(true);
|
||||
setCatalogMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/settings/cache-config", {
|
||||
@@ -74,19 +196,19 @@ export default function CacheSettingsTab() {
|
||||
|
||||
const config = (await response.json()) as CacheConfigResponse;
|
||||
const saved = String(config.modelCatalogCacheTtlMs ?? parsed);
|
||||
setValue(saved);
|
||||
setSavedValue(saved);
|
||||
setMessage({ type: "success", text: t("cacheConfigSaveSuccess") });
|
||||
setCatalogTtl(saved);
|
||||
setSavedCatalogTtl(saved);
|
||||
setCatalogMessage({ type: "success", text: t("cacheConfigSaveSuccess") });
|
||||
} catch (error) {
|
||||
console.error("Failed to save cache config:", error);
|
||||
setMessage({ type: "error", text: t("cacheConfigSaveFailed") });
|
||||
setCatalogMessage({ type: "error", text: t("cacheConfigSaveFailed") });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setCatalogSaving(false);
|
||||
}
|
||||
}, [dirty, t, value]);
|
||||
}, [catalogDirty, t, catalogTtl]);
|
||||
|
||||
const validationError = (() => {
|
||||
const trimmed = value.trim();
|
||||
const catalogValidationError = (() => {
|
||||
const trimmed = catalogTtl.trim();
|
||||
if (!trimmed) return "Required";
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isInteger(parsed)) return t("modelCatalogTtlWholeNumberError");
|
||||
@@ -95,62 +217,546 @@ export default function CacheSettingsTab() {
|
||||
return null;
|
||||
})();
|
||||
|
||||
// Current selected provider and model details
|
||||
const selectedProviderOption = embeddingOptions.find((p) => p.id === semProvider);
|
||||
const availableModelsForProvider = selectedProviderOption?.models || [];
|
||||
const selectedModelOption = availableModelsForProvider.find(
|
||||
(m) => m.rawId === semModel || m.id === semModel
|
||||
);
|
||||
|
||||
// Sync dimensions when model selection changes
|
||||
const handleModelChange = (modelIdOrRaw: string) => {
|
||||
setSemModel(modelIdOrRaw);
|
||||
const m = availableModelsForProvider.find(
|
||||
(item) => item.rawId === modelIdOrRaw || item.id === modelIdOrRaw
|
||||
);
|
||||
if (m?.dimensions) {
|
||||
setSemDimension(m.dimensions);
|
||||
}
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const handleProviderChange = (newProvider: string) => {
|
||||
setSemProvider(newProvider);
|
||||
const provider = embeddingOptions.find((p) => p.id === newProvider);
|
||||
if (provider && provider.models.length > 0) {
|
||||
const firstModel = provider.models[0];
|
||||
setSemModel(firstModel.rawId || firstModel.id);
|
||||
if (firstModel.dimensions) {
|
||||
setSemDimension(firstModel.dimensions);
|
||||
}
|
||||
}
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
// Save Semantic Cache Config
|
||||
const saveSemanticCache = async () => {
|
||||
setSemSaving(true);
|
||||
setSemMessage(null);
|
||||
|
||||
const payload = {
|
||||
semanticCacheEnabled: semEnabled,
|
||||
semanticCacheVectorEnabled: semVectorEnabled,
|
||||
semanticCacheBackend: semBackend,
|
||||
semanticCacheThreshold: Number(semThreshold),
|
||||
semanticCacheTTL: semTtlMinutes * 60000,
|
||||
semanticCacheMaxSize: Number(semMaxSize),
|
||||
semanticCacheEmbeddingProvider: semProvider,
|
||||
semanticCacheEmbeddingModel: semModel,
|
||||
semanticCacheEmbeddingDimension: semDimension ? Number(semDimension) : null,
|
||||
semanticCacheEmbeddingBaseUrl: semBaseUrl.trim() || selectedProviderOption?.baseUrl || null,
|
||||
semanticCacheEmbeddingApiKey: semApiKey.trim() || null,
|
||||
semanticCacheRedisUrl: semRedisUrl.trim() || null,
|
||||
semanticCacheRedisPrefix: semRedisPrefix.trim() || "omniroute:semcache:",
|
||||
semanticCacheRequireZeroTemp: semRequireZeroTemp,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/cache-config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Save failed with status ${res.status}`);
|
||||
|
||||
setSemMessage({ type: "success", text: "Semantic cache settings saved successfully." });
|
||||
} catch (err) {
|
||||
console.error("Failed to save semantic cache settings:", err);
|
||||
setSemMessage({ type: "error", text: "Failed to save semantic cache settings." });
|
||||
} finally {
|
||||
setSemSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Test embedding connection
|
||||
const handleTestConnection = async () => {
|
||||
setTestingConnection(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/cache-config/test-embedding", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: semProvider,
|
||||
model: semModel,
|
||||
baseUrl: semBaseUrl.trim() || selectedProviderOption?.baseUrl || undefined,
|
||||
apiKey: semApiKey.trim() || undefined,
|
||||
dimensions: semDimension,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
setTestResult(data);
|
||||
} catch (err: unknown) {
|
||||
setTestResult({ ok: false, error: String(err) });
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear cache
|
||||
const handleClearCache = async () => {
|
||||
setClearingCache(true);
|
||||
setClearMessage(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/cache", { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("Failed to clear cache");
|
||||
setClearMessage("Semantic cache purged successfully.");
|
||||
} catch (err: unknown) {
|
||||
setClearMessage(`Failed to purge cache: ${String(err)}`);
|
||||
} finally {
|
||||
setClearingCache(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6 mt-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="font-medium">{t("modelCatalogCacheTtl")}</p>
|
||||
<p className="text-sm text-text-muted mt-1">{t("modelCatalogCacheTtlDescription")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="model-catalog-ttl-ms" className="sr-only">
|
||||
{t("modelCatalogCacheTtlLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="model-catalog-ttl-ms"
|
||||
type="number"
|
||||
min={MIN_TTL_MS}
|
||||
max={MAX_TTL_MS}
|
||||
step={100}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
setMessage(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && dirty) void saveTtl();
|
||||
}}
|
||||
className="w-32 px-3 py-1.5 rounded bg-surface-2 border border-border text-sm text-text-primary"
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<span className="text-xs text-text-muted">ms</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={loading || Boolean(validationError) || !dirty}
|
||||
onClick={saveTtl}
|
||||
>
|
||||
{saving ? t("modelCatalogCacheTtlSaving") : t("modelCatalogCacheTtlSave")}
|
||||
</Button>
|
||||
{dirty && (
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("modelCatalogCacheTtlCurrent", { value: savedValue })}
|
||||
</span>
|
||||
<div className="flex flex-col gap-6 mt-4">
|
||||
{/* ── 1. Semantic Caching Card ── */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Card Header & Master Toggle */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border/50">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-base text-text-primary">Semantic Caching</h3>
|
||||
<Badge variant={semEnabled ? "success" : "default"} size="sm">
|
||||
{semEnabled ? "Active" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Exact-match response cache with an optional vector-similarity layer. Reuses matching
|
||||
responses to cut latency and upstream token costs.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={semEnabled}
|
||||
onChange={setSemEnabled}
|
||||
ariaLabel="Enable semantic caching"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Embedding Provider
|
||||
</label>
|
||||
<Select
|
||||
value={semProvider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
disabled={catalogLoading || semSaving}
|
||||
options={
|
||||
embeddingOptions.length > 0
|
||||
? embeddingOptions.map((opt) => ({
|
||||
value: opt.id,
|
||||
label: opt.hasConnection ? `${opt.name} (Configured)` : opt.name,
|
||||
}))
|
||||
: [{ value: semProvider, label: semProvider }]
|
||||
}
|
||||
/>
|
||||
{selectedProviderOption && (
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{selectedProviderOption.hasConnection
|
||||
? `Using configured connection (${selectedProviderOption.baseUrl || "Default URL"})`
|
||||
: "Requires provider connection or API key"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Embedding Model
|
||||
</label>
|
||||
<Select
|
||||
value={semModel}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
disabled={
|
||||
catalogLoading || semSaving || availableModelsForProvider.length === 0
|
||||
}
|
||||
options={
|
||||
availableModelsForProvider.length > 0
|
||||
? availableModelsForProvider.map((m) => ({
|
||||
value: m.rawId || m.id,
|
||||
label: m.dimensions
|
||||
? `${m.name || m.rawId} (${m.dimensions} dims)`
|
||||
: m.name || m.rawId,
|
||||
}))
|
||||
: [{ value: semModel, label: semModel }]
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Model Metadata Badges */}
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{semDimension ? (
|
||||
<Badge variant="primary" size="sm">
|
||||
{semDimension} Dimensions
|
||||
</Badge>
|
||||
) : null}
|
||||
{selectedModelOption?.maxTokens ? (
|
||||
<Badge variant="info" size="sm">
|
||||
{selectedModelOption.maxTokens.toLocaleString()} Max Tokens
|
||||
</Badge>
|
||||
) : null}
|
||||
{selectedModelOption?.supportedInputTypes ? (
|
||||
<Badge variant="default" size="sm">
|
||||
Input: {selectedModelOption.supportedInputTypes.join(", ")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Threshold Slider & TTL */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label className="text-sm font-medium text-text-primary">
|
||||
Similarity Threshold
|
||||
</label>
|
||||
<span className="text-xs font-mono font-bold text-primary">
|
||||
{semThreshold.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.50"
|
||||
max="1.00"
|
||||
step="0.01"
|
||||
value={semThreshold}
|
||||
onChange={(e) => setSemThreshold(parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-surface-2 rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
disabled={semSaving}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
0.80 recommended. Lower values match more loosely; 1.00 is exact match only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Cache Retention (TTL)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10080}
|
||||
value={semTtlMinutes}
|
||||
onChange={(e) => setSemTtlMinutes(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-28 px-3 py-1.5 rounded bg-surface-2 border border-border text-sm text-text-primary"
|
||||
disabled={semSaving}
|
||||
/>
|
||||
<span className="text-xs text-text-muted">minutes</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Default 30 minutes. Entries expire after this duration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Backend Selection */}
|
||||
<div className="pt-2 border-t border-border/40">
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
Storage Engine
|
||||
</label>
|
||||
<SegmentedControl
|
||||
value={semBackend}
|
||||
onChange={(val) => setSemBackend(val as "memory" | "redis")}
|
||||
options={[
|
||||
{ value: "memory", label: "In-Memory Vector (LRU)" },
|
||||
{ value: "redis", label: "Redis Vector Store" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{semBackend === "memory" ? (
|
||||
<div className="mt-3">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1">
|
||||
Max In-Memory Entries
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
max={100000}
|
||||
value={semMaxSize}
|
||||
onChange={(e) => setSemMaxSize(parseInt(e.target.value) || 100)}
|
||||
className="w-32 px-3 py-1.5 rounded bg-surface-2 border border-border text-sm text-text-primary"
|
||||
disabled={semSaving}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1">
|
||||
Redis URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="redis://127.0.0.1:6379"
|
||||
value={semRedisUrl}
|
||||
onChange={(e) => setSemRedisUrl(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded bg-surface-2 border border-border text-sm text-text-primary"
|
||||
disabled={semSaving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1">
|
||||
Redis Key Prefix
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={semRedisPrefix}
|
||||
onChange={(e) => setSemRedisPrefix(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded bg-surface-2 border border-border text-sm text-text-primary"
|
||||
disabled={semSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Determinism Toggle */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border/40">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
Require Strict Determinism (temperature = 0)
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Only cache and serve responses when temperature is 0, avoiding stochastic
|
||||
variance.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={semRequireZeroTemp}
|
||||
onChange={setSemRequireZeroTemp}
|
||||
ariaLabel="Require zero temperature"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Overrides Accordion */}
|
||||
<div className="pt-2 border-t border-border/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-xs font-medium text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
{showAdvanced
|
||||
? "▼ Hide Advanced Endpoint Overrides"
|
||||
: "▶ Show Advanced Endpoint Overrides"}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3 p-3 rounded-lg bg-surface-2/40 border border-border/40">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1">
|
||||
Custom Embedding Base URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://custom-embedding.internal/v1"
|
||||
value={semBaseUrl}
|
||||
onChange={(e) => setSemBaseUrl(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded bg-surface-2 border border-border text-xs text-text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1">
|
||||
Custom Embedding API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Bearer token or API key"
|
||||
value={semApiKey}
|
||||
onChange={(e) => setSemApiKey(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded bg-surface-2 border border-border text-xs text-text-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons & Feedback */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-3 border-t border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testingConnection || semSaving}
|
||||
>
|
||||
{testingConnection ? "Testing Connection..." : "Test Embedding Model"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleClearCache}
|
||||
disabled={clearingCache}
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-500/10"
|
||||
>
|
||||
{clearingCache ? "Purging..." : "Clear Cache"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={saveSemanticCache}
|
||||
disabled={semSaving}
|
||||
>
|
||||
{semSaving ? "Saving..." : "Save Semantic Cache"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Test Connection Output */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`p-3 rounded-md text-xs border ${
|
||||
testResult.ok
|
||||
? "bg-green-500/10 border-green-500/20 text-green-700 dark:text-green-300"
|
||||
: "bg-red-500/10 border-red-500/20 text-red-700 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{testResult.ok ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">Connection Verified:</span>
|
||||
<span>
|
||||
Successfully generated {testResult.dimensions}-dim embedding in{" "}
|
||||
{testResult.latencyMs}ms
|
||||
{testResult.resolvedBaseUrl ? ` via ${testResult.resolvedBaseUrl}` : ""}.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="font-bold">Connection Test Failed: </span>
|
||||
<span>{testResult.error || "Unknown error"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear Message */}
|
||||
{clearMessage && <p className="text-xs text-text-muted italic">{clearMessage}</p>}
|
||||
|
||||
{/* Save Message */}
|
||||
{semMessage && (
|
||||
<p
|
||||
className={`text-xs ${
|
||||
semMessage.type === "success"
|
||||
? "text-green-600 dark:text-green-400 font-medium"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{semMessage.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{validationError && <p className="text-xs text-red-500">{validationError}</p>}
|
||||
{message && (
|
||||
<p
|
||||
className={`text-xs ${
|
||||
message.type === "success"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
{/* ── 2. Model Catalog Cache Card (Preserved Compatibility) ── */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="font-medium">{t("modelCatalogCacheTtl")}</p>
|
||||
<p className="text-sm text-text-muted mt-1">{t("modelCatalogCacheTtlDescription")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="model-catalog-ttl-ms" className="sr-only">
|
||||
{t("modelCatalogCacheTtlLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="model-catalog-ttl-ms"
|
||||
type="number"
|
||||
min={MIN_TTL_MS}
|
||||
max={MAX_TTL_MS}
|
||||
step={100}
|
||||
value={catalogTtl}
|
||||
onChange={(event) => {
|
||||
setCatalogTtl(event.target.value);
|
||||
setCatalogMessage(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && catalogDirty) void saveCatalogTtl();
|
||||
}}
|
||||
className="w-32 px-3 py-1.5 rounded bg-surface-2 border border-border text-sm text-text-primary"
|
||||
disabled={catalogLoading || catalogSaving}
|
||||
/>
|
||||
<span className="text-xs text-text-muted">ms</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={catalogLoading || Boolean(catalogValidationError) || !catalogDirty}
|
||||
onClick={saveCatalogTtl}
|
||||
>
|
||||
{catalogSaving ? t("modelCatalogCacheTtlSaving") : t("modelCatalogCacheTtlSave")}
|
||||
</Button>
|
||||
{catalogDirty && (
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("modelCatalogCacheTtlCurrent", { value: savedCatalogTtl })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{catalogValidationError && (
|
||||
<p className="text-xs text-red-500">{catalogValidationError}</p>
|
||||
)}
|
||||
{catalogMessage && (
|
||||
<p
|
||||
className={`text-xs ${
|
||||
catalogMessage.type === "success"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{catalogMessage.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,6 +150,9 @@ export async function POST(request) {
|
||||
// #9820: optional video-generation job preset (job/poll path).
|
||||
generationConfig,
|
||||
isFree,
|
||||
dimensions,
|
||||
supportedInputTypes,
|
||||
modelType,
|
||||
} = validation.data;
|
||||
|
||||
const model = await addCustomModel(
|
||||
@@ -166,7 +169,12 @@ export async function POST(request) {
|
||||
},
|
||||
typeof supportsVision === "boolean" ? supportsVision : undefined,
|
||||
generationConfig,
|
||||
typeof isFree === "boolean" ? isFree : undefined
|
||||
typeof isFree === "boolean" ? isFree : undefined,
|
||||
{
|
||||
...(typeof dimensions === "number" && dimensions > 0 ? { dimensions } : {}),
|
||||
...(Array.isArray(supportedInputTypes) ? { supportedInputTypes } : {}),
|
||||
...(typeof modelType === "string" ? { modelType } : {}),
|
||||
}
|
||||
);
|
||||
return Response.json({ model });
|
||||
} catch (error) {
|
||||
|
||||
18
src/app/api/settings/cache-config/embedding-options/route.ts
Normal file
18
src/app/api/settings/cache-config/embedding-options/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { getEmbeddingOptions } from "../embeddingOptions";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const providers = await getEmbeddingOptions();
|
||||
return NextResponse.json({ providers });
|
||||
} catch (error: unknown) {
|
||||
const message = sanitizeErrorMessage(error);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
159
src/app/api/settings/cache-config/embeddingOptions.ts
Normal file
159
src/app/api/settings/cache-config/embeddingOptions.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { getProviderConnections } from "@/lib/db/providers";
|
||||
import { getSyncedAvailableModels, getCustomModels } from "@/lib/db/models";
|
||||
import {
|
||||
EMBEDDING_PROVIDERS,
|
||||
getEmbeddingProvider,
|
||||
} from "@omniroute/open-sse/config/embeddingRegistry.ts";
|
||||
|
||||
export interface AvailableEmbeddingModelOption {
|
||||
id: string;
|
||||
rawId: string;
|
||||
name: string;
|
||||
dimensions?: number;
|
||||
maxTokens?: number;
|
||||
supportedInputTypes: string[];
|
||||
}
|
||||
|
||||
export interface EmbeddingProviderOption {
|
||||
id: string;
|
||||
name: string;
|
||||
hasConnection: boolean;
|
||||
baseUrl?: string;
|
||||
models: AvailableEmbeddingModelOption[];
|
||||
}
|
||||
|
||||
function getProviderBaseUrl(providerSpecificData: unknown): string | undefined {
|
||||
if (providerSpecificData && typeof providerSpecificData === "object") {
|
||||
const data = providerSpecificData as Record<string, unknown>;
|
||||
if (typeof data.baseUrl === "string" && data.baseUrl.trim().length > 0) {
|
||||
return data.baseUrl.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getEmbeddingOptions(): Promise<EmbeddingProviderOption[]> {
|
||||
const connections = await getProviderConnections().catch(() => []);
|
||||
const connectionsByProvider = new Map<string, typeof connections>();
|
||||
|
||||
for (const conn of connections) {
|
||||
const p = conn.provider;
|
||||
if (!p) continue;
|
||||
const list = connectionsByProvider.get(p) || [];
|
||||
list.push(conn);
|
||||
connectionsByProvider.set(p, list);
|
||||
}
|
||||
|
||||
// Collect all candidate providers: configured connections + curated EMBEDDING_PROVIDERS
|
||||
const candidateProviders = new Set<string>([
|
||||
...Object.keys(EMBEDDING_PROVIDERS),
|
||||
...connectionsByProvider.keys(),
|
||||
]);
|
||||
|
||||
const providerOptions: EmbeddingProviderOption[] = [];
|
||||
|
||||
for (const providerId of candidateProviders) {
|
||||
const conns = connectionsByProvider.get(providerId) || [];
|
||||
const activeConn = conns.find((c) => c.isActive !== false) || conns[0];
|
||||
const hasConnection = conns.length > 0;
|
||||
|
||||
const curated = getEmbeddingProvider(providerId);
|
||||
const configuredBaseUrl = activeConn
|
||||
? getProviderBaseUrl(activeConn.providerSpecificData)
|
||||
: undefined;
|
||||
const baseUrl = configuredBaseUrl || curated?.baseUrl;
|
||||
|
||||
// Collect models for this provider
|
||||
const modelsMap = new Map<string, AvailableEmbeddingModelOption>();
|
||||
|
||||
// 1. Add curated models from embedding registry
|
||||
if (curated?.models) {
|
||||
for (const m of curated.models) {
|
||||
modelsMap.set(m.id, {
|
||||
id: `${providerId}/${m.id}`,
|
||||
rawId: m.id,
|
||||
name: m.name || m.id,
|
||||
dimensions: m.dimensions,
|
||||
maxTokens: undefined,
|
||||
supportedInputTypes: (m.modalities as string[]) || ["text"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Add synced models from DB
|
||||
try {
|
||||
const synced = await getSyncedAvailableModels(providerId);
|
||||
for (const sm of synced) {
|
||||
const isEmbedding =
|
||||
sm.modelType === "embedding" ||
|
||||
sm.apiFormat === "embeddings" ||
|
||||
sm.supportedEndpoints?.includes("embeddings") ||
|
||||
modelsMap.has(sm.id);
|
||||
|
||||
if (isEmbedding) {
|
||||
const existing = modelsMap.get(sm.id);
|
||||
modelsMap.set(sm.id, {
|
||||
id: `${providerId}/${sm.id}`,
|
||||
rawId: sm.id,
|
||||
name: sm.name || existing?.name || sm.id,
|
||||
dimensions: sm.dimensions || existing?.dimensions,
|
||||
maxTokens: sm.inputTokenLimit || existing?.maxTokens,
|
||||
supportedInputTypes: sm.supportedInputTypes ||
|
||||
existing?.supportedInputTypes || ["text"],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through on DB error
|
||||
}
|
||||
|
||||
// 3. Add custom models from DB
|
||||
try {
|
||||
const custom = await getCustomModels(providerId);
|
||||
if (Array.isArray(custom)) {
|
||||
for (const cm of custom) {
|
||||
const isEmbedding =
|
||||
cm.modelType === "embedding" ||
|
||||
cm.apiFormat === "embeddings" ||
|
||||
(Array.isArray(cm.supportedEndpoints) &&
|
||||
cm.supportedEndpoints.includes("embeddings")) ||
|
||||
modelsMap.has(cm.id);
|
||||
|
||||
if (isEmbedding) {
|
||||
const existing = modelsMap.get(cm.id);
|
||||
modelsMap.set(cm.id, {
|
||||
id: `${providerId}/${cm.id}`,
|
||||
rawId: cm.id,
|
||||
name: cm.name || existing?.name || cm.id,
|
||||
dimensions: cm.dimensions || existing?.dimensions,
|
||||
maxTokens: cm.inputTokenLimit || existing?.maxTokens,
|
||||
supportedInputTypes: cm.supportedInputTypes ||
|
||||
existing?.supportedInputTypes || ["text"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through on DB error
|
||||
}
|
||||
|
||||
if (modelsMap.size > 0 || curated !== undefined) {
|
||||
providerOptions.push({
|
||||
id: providerId,
|
||||
name: activeConn?.name || (curated ? providerId : providerId),
|
||||
hasConnection,
|
||||
baseUrl,
|
||||
models: Array.from(modelsMap.values()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
providerOptions.sort((a, b) => {
|
||||
if (a.hasConnection !== b.hasConnection) {
|
||||
return a.hasConnection ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return providerOptions;
|
||||
}
|
||||
@@ -6,13 +6,29 @@ import {
|
||||
} from "@/lib/db/databaseSettings";
|
||||
import { getSettings, updateSettings } from "@/lib/db/settings";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { ensureSemanticCacheDbBridge } from "@/lib/cache/semanticCacheDbBridge";
|
||||
import { resetSemanticCacheManager } from "@omniroute/open-sse/services/cache/semanticCacheManager";
|
||||
import { getEmbeddingOptions } from "./embeddingOptions";
|
||||
import { z } from "zod";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
ensureSemanticCacheDbBridge();
|
||||
|
||||
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(),
|
||||
semanticCacheEmbeddingModel: z.string().trim().optional(),
|
||||
semanticCacheEmbeddingDimension: z.number().positive().nullable().optional(),
|
||||
semanticCacheEmbeddingBaseUrl: z.string().trim().nullable().optional(),
|
||||
semanticCacheEmbeddingApiKey: z.string().trim().nullable().optional(),
|
||||
semanticCacheRedisUrl: z.string().trim().nullable().optional(),
|
||||
semanticCacheRedisPrefix: z.string().trim().optional(),
|
||||
semanticCacheRequireZeroTemp: z.boolean().optional(),
|
||||
promptCacheEnabled: z.boolean().optional(),
|
||||
promptCacheStrategy: z.enum(["auto", "system-only", "manual"]).optional(),
|
||||
alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(),
|
||||
@@ -24,6 +40,17 @@ const CACHE_CONFIG_KEYS = [
|
||||
"semanticCacheEnabled",
|
||||
"semanticCacheMaxSize",
|
||||
"semanticCacheTTL",
|
||||
"semanticCacheVectorEnabled",
|
||||
"semanticCacheBackend",
|
||||
"semanticCacheThreshold",
|
||||
"semanticCacheEmbeddingProvider",
|
||||
"semanticCacheEmbeddingModel",
|
||||
"semanticCacheEmbeddingDimension",
|
||||
"semanticCacheEmbeddingBaseUrl",
|
||||
"semanticCacheEmbeddingApiKey",
|
||||
"semanticCacheRedisUrl",
|
||||
"semanticCacheRedisPrefix",
|
||||
"semanticCacheRequireZeroTemp",
|
||||
"promptCacheEnabled",
|
||||
"promptCacheStrategy",
|
||||
"alwaysPreserveClientCache",
|
||||
@@ -33,8 +60,19 @@ const CACHE_CONFIG_KEYS = [
|
||||
|
||||
const DEFAULTS = {
|
||||
semanticCacheEnabled: true,
|
||||
semanticCacheMaxSize: 100,
|
||||
semanticCacheMaxSize: 1000,
|
||||
semanticCacheTTL: 1800000,
|
||||
semanticCacheVectorEnabled: false,
|
||||
semanticCacheBackend: "memory",
|
||||
semanticCacheThreshold: 0.8,
|
||||
semanticCacheEmbeddingProvider: "lemonade",
|
||||
semanticCacheEmbeddingModel: "harrier-oss-v1-0.6b",
|
||||
semanticCacheEmbeddingDimension: 1024,
|
||||
semanticCacheEmbeddingBaseUrl: "",
|
||||
semanticCacheEmbeddingApiKey: "",
|
||||
semanticCacheRedisUrl: "",
|
||||
semanticCacheRedisPrefix: "omniroute:semcache:",
|
||||
semanticCacheRequireZeroTemp: true,
|
||||
promptCacheEnabled: true,
|
||||
promptCacheStrategy: "auto",
|
||||
alwaysPreserveClientCache: "auto",
|
||||
@@ -55,7 +93,10 @@ export async function GET(request: NextRequest) {
|
||||
// idempotencyWindowMs is not part of the databaseSettings "cache" section —
|
||||
// it lives in the flat general settings (src/lib/db/settings.ts), which is
|
||||
// where src/lib/idempotencyLayer.ts actually reads it from.
|
||||
const flatSettings = await getSettings();
|
||||
const [flatSettings, embeddingOptions] = await Promise.all([
|
||||
getSettings(),
|
||||
getEmbeddingOptions(),
|
||||
]);
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const key of CACHE_CONFIG_KEYS) {
|
||||
if (key === "idempotencyWindowMs" || key === "alwaysPreserveClientCache") {
|
||||
@@ -68,6 +109,7 @@ export async function GET(request: NextRequest) {
|
||||
config[key] = (cache as Record<string, unknown>)[key] ?? DEFAULTS[key];
|
||||
}
|
||||
}
|
||||
config.embeddingOptions = embeddingOptions;
|
||||
return NextResponse.json(config);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
@@ -104,6 +146,39 @@ 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;
|
||||
}
|
||||
if (body.semanticCacheThreshold !== undefined) {
|
||||
updates.semanticCacheThreshold = body.semanticCacheThreshold;
|
||||
}
|
||||
if (body.semanticCacheEmbeddingProvider !== undefined) {
|
||||
updates.semanticCacheEmbeddingProvider = body.semanticCacheEmbeddingProvider;
|
||||
}
|
||||
if (body.semanticCacheEmbeddingModel !== undefined) {
|
||||
updates.semanticCacheEmbeddingModel = body.semanticCacheEmbeddingModel;
|
||||
}
|
||||
if (body.semanticCacheEmbeddingDimension !== undefined) {
|
||||
updates.semanticCacheEmbeddingDimension = body.semanticCacheEmbeddingDimension ?? undefined;
|
||||
}
|
||||
if (body.semanticCacheEmbeddingBaseUrl !== undefined) {
|
||||
updates.semanticCacheEmbeddingBaseUrl = body.semanticCacheEmbeddingBaseUrl ?? undefined;
|
||||
}
|
||||
if (body.semanticCacheEmbeddingApiKey !== undefined) {
|
||||
updates.semanticCacheEmbeddingApiKey = body.semanticCacheEmbeddingApiKey ?? undefined;
|
||||
}
|
||||
if (body.semanticCacheRedisUrl !== undefined) {
|
||||
updates.semanticCacheRedisUrl = body.semanticCacheRedisUrl ?? undefined;
|
||||
}
|
||||
if (body.semanticCacheRedisPrefix !== undefined) {
|
||||
updates.semanticCacheRedisPrefix = body.semanticCacheRedisPrefix;
|
||||
}
|
||||
if (body.semanticCacheRequireZeroTemp !== undefined) {
|
||||
updates.semanticCacheRequireZeroTemp = body.semanticCacheRequireZeroTemp;
|
||||
}
|
||||
if (body.promptCacheEnabled !== undefined) {
|
||||
updates.promptCacheEnabled = body.promptCacheEnabled;
|
||||
}
|
||||
@@ -119,6 +194,9 @@ export async function PUT(request: NextRequest) {
|
||||
// up the fresh TTL — no separate version bump needed here.
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updateDatabaseSettings({ cache: updates });
|
||||
// Drop the in-memory semantic cache manager so the next request rebuilds it
|
||||
// from the freshly-persisted databaseSettings (dual-layer cache work).
|
||||
resetSemanticCacheManager();
|
||||
}
|
||||
|
||||
// idempotencyWindowMs and alwaysPreserveClientCache are read from the flat
|
||||
|
||||
79
src/app/api/settings/cache-config/test-embedding/route.ts
Normal file
79
src/app/api/settings/cache-config/test-embedding/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { createDefaultEmbeddingGenerator } from "@omniroute/open-sse/services/cache/embeddingClient.ts";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { z } from "zod";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { resolveProviderConnectionDetails } from "@/lib/cache/semanticCacheDbBridge";
|
||||
|
||||
const testEmbeddingSchema = z.object({
|
||||
provider: z.string().trim().min(1),
|
||||
model: z.string().trim().min(1),
|
||||
baseUrl: z.string().trim().optional(),
|
||||
apiKey: z.string().trim().optional(),
|
||||
dimensions: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validation = validateBody(testEmbeddingSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
// `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;
|
||||
|
||||
// Resolve connection details from DB if not explicitly passed
|
||||
const conn = resolveProviderConnectionDetails(provider);
|
||||
const effectiveBaseUrl = baseUrl || conn.baseUrl;
|
||||
const effectiveApiKey = apiKey || conn.apiKey;
|
||||
|
||||
try {
|
||||
const generator = createDefaultEmbeddingGenerator({
|
||||
embeddingProvider: provider,
|
||||
embeddingModel: model,
|
||||
embeddingBaseUrl: effectiveBaseUrl,
|
||||
embeddingApiKey: effectiveApiKey,
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
const result = await generator("OmniRoute semantic cache live probe test");
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
if (!result || !Array.isArray(result.embedding)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Failed to generate embedding (empty response or unsupported endpoint)",
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
latencyMs,
|
||||
dimensions: result.embedding.length,
|
||||
resolvedBaseUrl: effectiveBaseUrl,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = sanitizeErrorMessage(error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: message },
|
||||
{ status: 200 } // Return 200 with ok: false so the UI can display test error cleanly
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
assertCommonChatGptWebModelAvailable,
|
||||
isCommonChatGptWebRetirementError,
|
||||
} from "@/shared/constants/chatgptWebRetirement";
|
||||
import { ensureSemanticCacheDbBridge } from "@/lib/cache/semanticCacheDbBridge";
|
||||
|
||||
let initPromise = null;
|
||||
|
||||
@@ -49,6 +50,7 @@ const injectionGuard = createInjectionGuard({ logger: null });
|
||||
*/
|
||||
function ensureInitialized() {
|
||||
if (!initPromise) {
|
||||
ensureSemanticCacheDbBridge();
|
||||
initPromise = Promise.resolve(initTranslators()).then(() => {
|
||||
console.log("[SSE] Translators initialized");
|
||||
});
|
||||
|
||||
@@ -285,12 +285,15 @@ export function detectTestKind(modelStr: string, customModel: any, nodeApiType?:
|
||||
!isRerank &&
|
||||
(apiFormat === "embeddings" ||
|
||||
nodeType === "embeddings" ||
|
||||
customModel?.modelType === "embedding" ||
|
||||
supportedEndpoints.includes("embeddings") ||
|
||||
lowerModel.includes("embedding") ||
|
||||
lowerModel.includes("bge-") ||
|
||||
lowerModel.includes("text-embed") ||
|
||||
lowerModel.includes("jina-clip") ||
|
||||
lowerModel.includes("colbert"));
|
||||
lowerModel.includes("colbert") ||
|
||||
lowerModel.includes("harrier-") ||
|
||||
lowerModel.includes("nomic-embed"));
|
||||
// A Responses node answers on /v1/responses only. Without this the model fell
|
||||
// through to the chat branch below, which posts a Chat Completions body to
|
||||
// /v1/chat/completions: the route can still answer 200 while carrying nothing a
|
||||
|
||||
82
src/lib/cache/semanticCacheDbBridge.ts
vendored
Normal file
82
src/lib/cache/semanticCacheDbBridge.ts
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
import { getDatabaseSettings } from "@/lib/db/databaseSettings";
|
||||
import { getDbInstance } from "@/lib/db/core";
|
||||
import { decryptConnectionFields } from "@/lib/db/encryption";
|
||||
import { registerSemanticCacheConfigResolver } from "@omniroute/open-sse/config/semanticCacheConfig.ts";
|
||||
|
||||
let registered = false;
|
||||
|
||||
export function resolveProviderConnectionDetails(provider: string): {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
} {
|
||||
if (!provider) return {};
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT * FROM provider_connections WHERE provider = ? AND is_active != 0 ORDER BY priority ASC, id ASC LIMIT 1"
|
||||
)
|
||||
.get(provider) as Record<string, unknown> | undefined;
|
||||
|
||||
if (!row) return {};
|
||||
const decrypted = decryptConnectionFields(row);
|
||||
let baseUrl: string | undefined;
|
||||
if (decrypted.provider_specific_data) {
|
||||
try {
|
||||
const parsed =
|
||||
typeof decrypted.provider_specific_data === "string"
|
||||
? JSON.parse(decrypted.provider_specific_data)
|
||||
: decrypted.provider_specific_data;
|
||||
if (typeof parsed?.baseUrl === "string" && parsed.baseUrl.trim()) {
|
||||
baseUrl = parsed.baseUrl.trim();
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse error
|
||||
}
|
||||
}
|
||||
const rawKey = decrypted.api_key || decrypted.apiKey;
|
||||
const apiKey = typeof rawKey === "string" && rawKey.trim() ? rawKey.trim() : undefined;
|
||||
return { baseUrl, apiKey };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureSemanticCacheDbBridge(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerSemanticCacheConfigResolver(() => {
|
||||
try {
|
||||
const s = getDatabaseSettings().cache;
|
||||
if (!s) return null;
|
||||
|
||||
const conn = s.semanticCacheEmbeddingProvider
|
||||
? resolveProviderConnectionDetails(s.semanticCacheEmbeddingProvider)
|
||||
: {};
|
||||
|
||||
const embeddingBaseUrl = s.semanticCacheEmbeddingBaseUrl || conn.baseUrl;
|
||||
const embeddingApiKey = s.semanticCacheEmbeddingApiKey || conn.apiKey;
|
||||
|
||||
return {
|
||||
// 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,
|
||||
maxEntries: s.semanticCacheMaxSize,
|
||||
embeddingProvider: s.semanticCacheEmbeddingProvider,
|
||||
embeddingModel: s.semanticCacheEmbeddingModel,
|
||||
embeddingDimension: s.semanticCacheEmbeddingDimension,
|
||||
embeddingBaseUrl,
|
||||
embeddingApiKey,
|
||||
redisUrl: s.semanticCacheRedisUrl,
|
||||
redisPrefix: s.semanticCacheRedisPrefix,
|
||||
requireZeroTemperature: s.semanticCacheRequireZeroTemp,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -37,6 +37,17 @@ const LEGACY_FLAT_KEYS: {
|
||||
semanticCacheEnabled: ["semanticCacheEnabled"],
|
||||
semanticCacheMaxSize: ["semanticCacheMaxSize"],
|
||||
semanticCacheTTL: ["semanticCacheTTL"],
|
||||
semanticCacheVectorEnabled: ["semanticCacheVectorEnabled"],
|
||||
semanticCacheBackend: ["semanticCacheBackend"],
|
||||
semanticCacheThreshold: ["semanticCacheThreshold"],
|
||||
semanticCacheEmbeddingProvider: ["semanticCacheEmbeddingProvider"],
|
||||
semanticCacheEmbeddingModel: ["semanticCacheEmbeddingModel"],
|
||||
semanticCacheEmbeddingDimension: ["semanticCacheEmbeddingDimension"],
|
||||
semanticCacheEmbeddingBaseUrl: ["semanticCacheEmbeddingBaseUrl"],
|
||||
semanticCacheEmbeddingApiKey: ["semanticCacheEmbeddingApiKey"],
|
||||
semanticCacheRedisUrl: ["semanticCacheRedisUrl"],
|
||||
semanticCacheRedisPrefix: ["semanticCacheRedisPrefix"],
|
||||
semanticCacheRequireZeroTemp: ["semanticCacheRequireZeroTemp"],
|
||||
promptCacheEnabled: ["promptCacheEnabled"],
|
||||
promptCacheStrategy: ["promptCacheStrategy"],
|
||||
alwaysPreserveClientCache: ["alwaysPreserveClientCache"],
|
||||
@@ -297,7 +308,7 @@ export function updateDatabaseSettings(
|
||||
const sectionValues = nextSettings[section] as Record<string, unknown>;
|
||||
|
||||
for (const [key, value] of Object.entries(sectionValues)) {
|
||||
insert.run(DATABASE_SETTINGS_NAMESPACE, `${section}.${key}`, JSON.stringify(value));
|
||||
insert.run(DATABASE_SETTINGS_NAMESPACE, `${section}.${key}`, JSON.stringify(value ?? null));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,12 @@ export async function addCustomModel(
|
||||
// custom OpenAI-compatible video models. Persisted on the model row; the
|
||||
// /v1/videos/generations handler reads it back to pick the job/poll path.
|
||||
generationConfig?: { preset: string },
|
||||
isFree?: boolean
|
||||
isFree?: boolean,
|
||||
extraMeta?: {
|
||||
dimensions?: number;
|
||||
supportedInputTypes?: string[];
|
||||
modelType?: "chat" | "embedding" | "image" | "rerank";
|
||||
}
|
||||
) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
@@ -157,6 +162,13 @@ export async function addCustomModel(
|
||||
...(typeof supportsVision === "boolean" ? { supportsVision } : {}),
|
||||
...(typeof isFree === "boolean" ? { isFree } : {}),
|
||||
...(generationConfig && generationConfig.preset ? { generationConfig } : {}),
|
||||
...(typeof extraMeta?.dimensions === "number" && extraMeta.dimensions > 0
|
||||
? { dimensions: extraMeta.dimensions }
|
||||
: {}),
|
||||
...(Array.isArray(extraMeta?.supportedInputTypes)
|
||||
? { supportedInputTypes: extraMeta.supportedInputTypes }
|
||||
: {}),
|
||||
...(typeof extraMeta?.modelType === "string" ? { modelType: extraMeta.modelType } : {}),
|
||||
};
|
||||
models.push(model);
|
||||
db.prepare(
|
||||
|
||||
@@ -27,6 +27,9 @@ export interface SyncedAvailableModel {
|
||||
// #4264: image-input capability captured at sync time (e.g. OpenRouter
|
||||
// `architecture.input_modalities`/`modality`) so the catalog can surface vision.
|
||||
supportsVision?: boolean;
|
||||
dimensions?: number;
|
||||
supportedInputTypes?: string[];
|
||||
modelType?: "chat" | "embedding" | "image" | "rerank";
|
||||
}
|
||||
|
||||
export type SyncedAvailableModelInput = Omit<SyncedAvailableModel, "source"> & {
|
||||
@@ -99,6 +102,19 @@ function normalizeSyncedAvailableModel(model: unknown): SyncedAvailableModel | n
|
||||
...(typeof record.supportsVideo === "boolean" ? { supportsVideo: record.supportsVideo } : {}),
|
||||
...(record.isFree === true ? { isFree: true } : {}),
|
||||
...(record.supportsVision === true ? { supportsVision: true } : {}),
|
||||
...(typeof record.dimensions === "number" && record.dimensions > 0
|
||||
? { dimensions: record.dimensions }
|
||||
: {}),
|
||||
...(Array.isArray(record.supportedInputTypes)
|
||||
? {
|
||||
supportedInputTypes: record.supportedInputTypes.filter(
|
||||
(t): t is string => typeof t === "string" && t.length > 0
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(typeof record.modelType === "string"
|
||||
? { modelType: record.modelType as "chat" | "embedding" | "image" | "rerank" }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -341,14 +341,16 @@ export async function createEmbeddingResponse(
|
||||
`[${provider}] All ${credentials.expiredCount || 1} connection(s) ${reason} — please reconnect in the dashboard`
|
||||
);
|
||||
}
|
||||
} else if (provider === "ollama-local" || provider === "lmstudio") {
|
||||
// Ollama and LM Studio are keyless, but a configured connection can still
|
||||
// provide a custom local host. Hydrate that optional connection without
|
||||
// imposing an authentication requirement, then keep the static localhost
|
||||
// default when no connection exists. getProviderCredentials("lmstudio")
|
||||
// resolves the dashboard's hyphenated "lm-studio" connection via the
|
||||
// provider search pool/alias (#11233); a selection or rate-limit failure
|
||||
// must not break the flow — proceed without credentials.
|
||||
} else if (
|
||||
provider === "ollama-local" ||
|
||||
provider === "lmstudio" ||
|
||||
provider === "llama-cpp" ||
|
||||
provider === "lemonade"
|
||||
) {
|
||||
// Local providers are key-optional, but a configured connection can provide
|
||||
// a custom host or API key (e.g. Lemonade bearer auth). Hydrate that optional
|
||||
// connection without imposing an authentication requirement, then keep the
|
||||
// static localhost default when no connection exists.
|
||||
const localCredentials = await getProviderCredentials(credentialsProviderId);
|
||||
if (
|
||||
localCredentials &&
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { VertexModelMetadataProvenance } from "@/lib/providerModels/vertexM
|
||||
import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization";
|
||||
import { isObsoleteKiroModelAlias } from "@omniroute/open-sse/services/kiroModels.ts";
|
||||
import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts";
|
||||
import { getEmbeddingProvider } from "@omniroute/open-sse/config/embeddingRegistry.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -409,6 +410,142 @@ export function isAutoFetchModelsEnabled(providerSpecificData: unknown): boolean
|
||||
return asRecord(providerSpecificData).autoFetchModels === true;
|
||||
}
|
||||
|
||||
const KNOWN_EMBEDDING_PREFIXES = [
|
||||
"text-embedding-",
|
||||
"bge-",
|
||||
"gte-",
|
||||
"e5-",
|
||||
"nomic-embed",
|
||||
"all-minilm",
|
||||
"embeddinggemma",
|
||||
"jina-embeddings",
|
||||
"jina-clip",
|
||||
"cohere-embed",
|
||||
"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,
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-ada-002": 1536,
|
||||
"bge-m3": 1024,
|
||||
"bge-large-en-v1.5": 1024,
|
||||
"bge-small-en-v1.5": 384,
|
||||
"bge-base-en-v1.5": 768,
|
||||
"nomic-embed-text": 768,
|
||||
"all-minilm-l6-v2": 384,
|
||||
embeddinggemma: 768,
|
||||
};
|
||||
|
||||
export function detectModelModality(
|
||||
record: JsonRecord,
|
||||
providerId?: string
|
||||
): {
|
||||
isEmbedding: boolean;
|
||||
isImage: boolean;
|
||||
isRerank: boolean;
|
||||
dimensions?: number;
|
||||
supportedInputTypes: string[];
|
||||
} {
|
||||
const rawId = toNonEmptyString(record.id) || toNonEmptyString(record.name) || "";
|
||||
const modelLeaf = rawId.toLowerCase().split("/").pop() || "";
|
||||
const rawLabels = Array.isArray(record.labels)
|
||||
? record.labels
|
||||
.map((l) => (typeof l === "string" ? l.trim().toLowerCase() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const typeStr = toNonEmptyString(record.type)?.toLowerCase();
|
||||
const objStr = toNonEmptyString(record.object)?.toLowerCase();
|
||||
const caps = asRecord(record.capabilities);
|
||||
const rawEndpoints = Array.isArray(record.supportedEndpoints)
|
||||
? record.supportedEndpoints.map((e) => (typeof e === "string" ? e.trim().toLowerCase() : ""))
|
||||
: [];
|
||||
|
||||
const registryProvider = providerId ? getEmbeddingProvider(providerId) : undefined;
|
||||
const registryModel = registryProvider?.models.find(
|
||||
(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 =
|
||||
!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") ||
|
||||
typeStr === "image" ||
|
||||
objStr === "image" ||
|
||||
rawEndpoints.includes("images") ||
|
||||
rawEndpoints.includes("image") ||
|
||||
modelLeaf.startsWith("gpt-image-") ||
|
||||
modelLeaf.startsWith("dall-e-") ||
|
||||
modelLeaf === "chatgpt-image-latest" ||
|
||||
modelLeaf.startsWith("flux-") ||
|
||||
modelLeaf.startsWith("sdxl-") ||
|
||||
modelLeaf.startsWith("stable-diffusion"));
|
||||
|
||||
const isEmbedding =
|
||||
!hasChatEndpoint &&
|
||||
!isRerank &&
|
||||
!isImage &&
|
||||
(rawLabels.includes("embeddings") ||
|
||||
rawLabels.includes("embedding") ||
|
||||
typeStr === "embedding" ||
|
||||
typeStr === "embeddings" ||
|
||||
objStr === "embedding" ||
|
||||
caps.embeddings === true ||
|
||||
caps.embedding === true ||
|
||||
rawEndpoints.includes("embeddings") ||
|
||||
rawEndpoints.includes("embedding") ||
|
||||
Boolean(registryModel) ||
|
||||
KNOWN_EMBEDDING_PREFIXES.some((prefix) => modelLeaf.includes(prefix)));
|
||||
|
||||
const dimensions = firstPositiveNumber(
|
||||
record.dimensions,
|
||||
record.dimension,
|
||||
record.embedding_dimension,
|
||||
record.embedding_dimensions,
|
||||
registryModel?.dimensions,
|
||||
KNOWN_EMBEDDING_DIMENSIONS[modelLeaf]
|
||||
);
|
||||
|
||||
const supportedInputTypes: string[] = Array.isArray(record.supportedInputTypes)
|
||||
? record.supportedInputTypes.filter((t): t is string => typeof t === "string" && t.length > 0)
|
||||
: registryModel?.modalities
|
||||
? (registryModel.modalities as string[])
|
||||
: ["text"];
|
||||
|
||||
return {
|
||||
isEmbedding,
|
||||
isImage,
|
||||
isRerank,
|
||||
dimensions,
|
||||
supportedInputTypes,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDiscoveredModels(
|
||||
models: unknown,
|
||||
providerId?: string
|
||||
@@ -448,6 +585,20 @@ export function normalizeDiscoveredModels(
|
||||
toNonEmptyString(record.displayName) ||
|
||||
toNonEmptyString(record.model) ||
|
||||
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"
|
||||
: undefined;
|
||||
const explicitInputTypes = Array.isArray(record.supportedInputTypes);
|
||||
|
||||
const supportedEndpoints = Array.isArray(record.supportedEndpoints)
|
||||
? Array.from(
|
||||
new Set(
|
||||
@@ -456,7 +607,23 @@ export function normalizeDiscoveredModels(
|
||||
.filter((endpoint): endpoint is string => Boolean(endpoint))
|
||||
)
|
||||
).sort()
|
||||
: undefined;
|
||||
: modality.isEmbedding
|
||||
? ["embeddings"]
|
||||
: modality.isRerank
|
||||
? ["rerank"]
|
||||
: modality.isImage
|
||||
? ["images"]
|
||||
: undefined;
|
||||
|
||||
const apiFormat =
|
||||
toNonEmptyString(record.apiFormat) ||
|
||||
(modality.isEmbedding
|
||||
? "embeddings"
|
||||
: modality.isRerank
|
||||
? "rerank"
|
||||
: modality.isImage
|
||||
? "images-generations"
|
||||
: undefined);
|
||||
|
||||
const topProvider = asRecord(record.top_provider);
|
||||
|
||||
@@ -473,6 +640,8 @@ export function normalizeDiscoveredModels(
|
||||
record.contextWindow,
|
||||
record.max_model_len,
|
||||
record.maxModelLen,
|
||||
record.max_context_window,
|
||||
record.max_tokens,
|
||||
topProvider.context_length
|
||||
);
|
||||
const isVertexProvider = providerId === "vertex" || providerId === "vertex-partner";
|
||||
@@ -509,9 +678,7 @@ export function normalizeDiscoveredModels(
|
||||
id,
|
||||
name,
|
||||
source: "imported",
|
||||
...(toNonEmptyString(record.apiFormat)
|
||||
? { apiFormat: toNonEmptyString(record.apiFormat)! }
|
||||
: {}),
|
||||
...(apiFormat ? { apiFormat } : {}),
|
||||
...(toNonEmptyString(record.targetFormat)
|
||||
? { targetFormat: toNonEmptyString(record.targetFormat)! }
|
||||
: {}),
|
||||
@@ -541,6 +708,13 @@ export function normalizeDiscoveredModels(
|
||||
...(typeof record.supportsVideo === "boolean" ? { supportsVideo: record.supportsVideo } : {}),
|
||||
...(isFree ? { isFree: true } : {}),
|
||||
...(supportsVision ? { supportsVision: true } : {}),
|
||||
...(typeof modality.dimensions === "number" && modality.dimensions > 0
|
||||
? { dimensions: modality.dimensions }
|
||||
: {}),
|
||||
...((modelType || explicitInputTypes) && modality.supportedInputTypes.length > 0
|
||||
? { supportedInputTypes: modality.supportedInputTypes }
|
||||
: {}),
|
||||
...(modelType ? { modelType } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import crypto from "crypto";
|
||||
import { LRUCache } from "./cacheLayer";
|
||||
import { getDbInstance } from "./db/core";
|
||||
import { toNumber } from "@/shared/utils/numeric";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -23,15 +24,6 @@ function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current time in the same format `expires_at` and `created_at` are written in.
|
||||
*
|
||||
@@ -335,6 +327,27 @@ export function getCachedResponse(signature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a semantic cache hit: increments hit count for the entry in SQLite
|
||||
* and increments global hit metrics (hits and tokens_saved).
|
||||
*/
|
||||
export function recordSemanticCacheHit(signature: string, tokensSaved = 0): void {
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
if (signature) {
|
||||
db.prepare(
|
||||
"UPDATE semantic_cache SET hit_count = hit_count + 1 WHERE signature = ? OR prompt_hash = ?"
|
||||
).run(signature, signature.slice(0, 16));
|
||||
}
|
||||
incrementMetric("hits");
|
||||
if (tokensSaved > 0) {
|
||||
incrementMetric("tokens_saved", tokensSaved);
|
||||
}
|
||||
} catch {
|
||||
// DB not available — fail open
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a response in cache.
|
||||
* @param {string} signature
|
||||
@@ -471,6 +484,10 @@ export function isCacheableForRead(body, headers) {
|
||||
if ((getHeaderValue(headers, "x-omniroute-no-cache") || "").toLowerCase() === "true") {
|
||||
return false;
|
||||
}
|
||||
const cacheControl = (getHeaderValue(headers, "cache-control") || "").toLowerCase();
|
||||
if (cacheControl.includes("no-cache")) {
|
||||
return false;
|
||||
}
|
||||
if (typeof body.temperature !== "number" || body.temperature !== 0) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -485,6 +502,10 @@ export function isCacheableForWrite(body, headers) {
|
||||
if ((getHeaderValue(headers, "x-omniroute-no-cache") || "").toLowerCase() === "true") {
|
||||
return false;
|
||||
}
|
||||
const cacheControl = (getHeaderValue(headers, "cache-control") || "").toLowerCase();
|
||||
if (cacheControl.includes("no-cache")) {
|
||||
return false;
|
||||
}
|
||||
if (body.temperature !== 0) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -527,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();
|
||||
|
||||
@@ -2,6 +2,8 @@ export const OMNIROUTE_RESPONSE_HEADERS = {
|
||||
cache: "X-OmniRoute-Cache",
|
||||
cacheHit: "X-OmniRoute-Cache-Hit",
|
||||
cacheLatency: "X-OmniRoute-Cache-Latency",
|
||||
cacheSimilarity: "X-OmniRoute-Cache-Similarity",
|
||||
savingsTokens: "X-OmniRoute-Savings-Tokens",
|
||||
compression: "X-OmniRoute-Compression",
|
||||
costSaved: "X-OmniRoute-Cost-Saved",
|
||||
decision: "X-OmniRoute-Decision",
|
||||
|
||||
@@ -75,7 +75,7 @@ export const LOCAL_PROVIDERS = {
|
||||
},
|
||||
lemonade: {
|
||||
id: "lemonade",
|
||||
serviceKinds: ["llm"],
|
||||
serviceKinds: ["llm", "embedding"],
|
||||
alias: "lemonade",
|
||||
name: "Lemonade Server",
|
||||
icon: "bolt",
|
||||
@@ -103,7 +103,7 @@ export const LOCAL_PROVIDERS = {
|
||||
},
|
||||
"llama-cpp": {
|
||||
id: "llama-cpp",
|
||||
serviceKinds: ["llm"],
|
||||
serviceKinds: ["llm", "embedding"],
|
||||
alias: "llamacpp",
|
||||
name: "llama.cpp",
|
||||
icon: "memory",
|
||||
|
||||
@@ -298,6 +298,9 @@ export const providerModelMutationSchema = z.object({
|
||||
// the same flag flows through `getCustomVisionCapabilityFields()` in the /v1/models
|
||||
// catalog. `null` clears a manual override back to the id-based heuristic.
|
||||
supportsVision: z.boolean().nullable().optional(),
|
||||
dimensions: z.number().int().positive().nullable().optional(),
|
||||
supportedInputTypes: z.array(z.string()).optional(),
|
||||
modelType: z.enum(["chat", "embedding", "image", "rerank"]).optional(),
|
||||
isFree: z.boolean().nullable().optional(),
|
||||
normalizeToolCallId: z.boolean().optional(),
|
||||
preserveOpenAIDeveloperRole: z.boolean().nullable().optional(),
|
||||
|
||||
@@ -32,6 +32,22 @@ 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;
|
||||
semanticCacheEmbeddingModel?: string;
|
||||
semanticCacheEmbeddingDimension?: number;
|
||||
semanticCacheEmbeddingBaseUrl?: string;
|
||||
semanticCacheEmbeddingApiKey?: string;
|
||||
semanticCacheRedisUrl?: string;
|
||||
semanticCacheRedisPrefix?: string;
|
||||
semanticCacheRequireZeroTemp?: boolean;
|
||||
promptCacheEnabled: boolean;
|
||||
promptCacheStrategy: "auto" | "system-only" | "manual";
|
||||
alwaysPreserveClientCache: "auto" | "always" | "never";
|
||||
@@ -108,8 +124,19 @@ export const DEFAULT_DATABASE_SETTINGS: Omit<DatabaseSettings, "location" | "sta
|
||||
},
|
||||
cache: {
|
||||
semanticCacheEnabled: true,
|
||||
semanticCacheMaxSize: 100,
|
||||
semanticCacheMaxSize: 1000,
|
||||
semanticCacheTTL: 1800000,
|
||||
semanticCacheVectorEnabled: false,
|
||||
semanticCacheBackend: "memory",
|
||||
semanticCacheThreshold: 0.8,
|
||||
semanticCacheEmbeddingProvider: "lemonade",
|
||||
semanticCacheEmbeddingModel: "harrier-oss-v1-0.6b",
|
||||
semanticCacheEmbeddingDimension: 1024,
|
||||
semanticCacheEmbeddingBaseUrl: "",
|
||||
semanticCacheEmbeddingApiKey: "",
|
||||
semanticCacheRedisUrl: "",
|
||||
semanticCacheRedisPrefix: "omniroute:semcache:",
|
||||
semanticCacheRequireZeroTemp: true,
|
||||
promptCacheEnabled: true,
|
||||
promptCacheStrategy: "auto",
|
||||
alwaysPreserveClientCache: "auto",
|
||||
|
||||
220
tests/integration/semantic-cache-lemonade.test.ts
Normal file
220
tests/integration/semantic-cache-lemonade.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { SemanticCacheManager } from "../../open-sse/services/cache/semanticCacheManager.ts";
|
||||
import { MemoryVectorStore } from "../../open-sse/services/cache/memoryVectorStore.ts";
|
||||
import { RedisVectorStore } from "../../open-sse/services/cache/redisVectorStore.ts";
|
||||
import { createDefaultEmbeddingGenerator } from "../../open-sse/services/cache/embeddingClient.ts";
|
||||
import { cosineSimilarity } from "../../open-sse/services/cache/vectorStore.ts";
|
||||
|
||||
const LEMONADE_URL = process.env.LEMONADE_URL || "http://192.168.31.147:13305/v1/embeddings";
|
||||
const LEMONADE_KEY = process.env.LEMONADE_KEY || "lemonade";
|
||||
const LEMONADE_MODEL = process.env.LEMONADE_MODEL || "harrier-oss-v1-0.6b";
|
||||
const REDIS_URL = process.env.REDIS_URL || "redis://192.168.31.147:6379";
|
||||
|
||||
async function isEndpointReachable(url: string, timeoutMs = 1500): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const res = await fetch(url, { method: "HEAD", signal: controller.signal }).catch(() => null);
|
||||
clearTimeout(timer);
|
||||
return res !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("Semantic Cache Live Integration with Lemonade & Redis", () => {
|
||||
it("generates 1024-dimensional embeddings via Lemonade harrier-oss-v1-0.6b", async (t) => {
|
||||
const reachable = await isEndpointReachable(LEMONADE_URL);
|
||||
if (!reachable) {
|
||||
t.skip(`Lemonade server not reachable at ${LEMONADE_URL}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const generator = createDefaultEmbeddingGenerator({
|
||||
embeddingProvider: "lemonade",
|
||||
embeddingModel: LEMONADE_MODEL,
|
||||
embeddingBaseUrl: LEMONADE_URL,
|
||||
embeddingApiKey: LEMONADE_KEY,
|
||||
});
|
||||
|
||||
const res = await generator("What is machine learning?");
|
||||
assert.ok(res, "Should return embedding result");
|
||||
assert.equal(res.embedding.length, 1024, "Vector dimension should be 1024");
|
||||
|
||||
const paraphraseRes = await generator("Can you explain what machine learning is?");
|
||||
assert.ok(paraphraseRes);
|
||||
const sim = cosineSimilarity(res.embedding, paraphraseRes.embedding);
|
||||
assert.ok(sim > 0.8, `Paraphrase similarity ${sim} should be > 0.8`);
|
||||
});
|
||||
|
||||
it("performs dual-layer lookup using MemoryVectorStore and Lemonade embeddings", async (t) => {
|
||||
const reachable = await isEndpointReachable(LEMONADE_URL);
|
||||
if (!reachable) {
|
||||
t.skip(`Lemonade server not reachable at ${LEMONADE_URL}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const generator = createDefaultEmbeddingGenerator({
|
||||
embeddingProvider: "lemonade",
|
||||
embeddingModel: LEMONADE_MODEL,
|
||||
embeddingBaseUrl: LEMONADE_URL,
|
||||
embeddingApiKey: LEMONADE_KEY,
|
||||
});
|
||||
|
||||
const manager = new SemanticCacheManager(
|
||||
{
|
||||
enabled: true,
|
||||
similarityThreshold: 0.8,
|
||||
embeddingModel: LEMONADE_MODEL,
|
||||
embeddingProvider: "lemonade",
|
||||
embeddingBaseUrl: LEMONADE_URL,
|
||||
embeddingApiKey: LEMONADE_KEY,
|
||||
},
|
||||
new MemoryVectorStore(),
|
||||
generator
|
||||
);
|
||||
|
||||
const body = {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: "What is the boiling point of water?" }],
|
||||
temperature: 0,
|
||||
};
|
||||
|
||||
await manager.store({
|
||||
body,
|
||||
response: {
|
||||
id: "resp-boiling",
|
||||
choices: [
|
||||
{ message: { role: "assistant", content: "Water boils at 100 degrees Celsius." } },
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 12 },
|
||||
},
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
tokensSaved: 22,
|
||||
});
|
||||
|
||||
// 1. Exact match -> Layer 1
|
||||
const exact = await manager.lookup({
|
||||
body,
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
});
|
||||
assert.equal(exact.hit, true);
|
||||
assert.equal(exact.type, "exact");
|
||||
assert.equal(exact.tokensSaved, 22);
|
||||
|
||||
// 2. Semantic match -> Layer 2
|
||||
const semantic = await manager.lookup({
|
||||
body: {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: "At what temperature does water boil?" }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
});
|
||||
assert.equal(semantic.hit, true);
|
||||
assert.equal(semantic.type, "semantic");
|
||||
assert.ok((semantic.similarity ?? 0) >= 0.8);
|
||||
|
||||
// 3. Unrelated -> Miss
|
||||
const miss = await manager.lookup({
|
||||
body: {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: "Who was Napoleon Bonaparte?" }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
});
|
||||
assert.equal(miss.hit, false);
|
||||
});
|
||||
|
||||
it("persists entries and performs vector search in live Redis", async (t) => {
|
||||
const reachable = await isEndpointReachable(LEMONADE_URL);
|
||||
if (!reachable) {
|
||||
t.skip(`Lemonade server not reachable at ${LEMONADE_URL}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const redisStore = new RedisVectorStore({
|
||||
redisUrl: REDIS_URL,
|
||||
keyPrefix: `omniroute:test:it:${Date.now()}:`,
|
||||
});
|
||||
|
||||
const generator = createDefaultEmbeddingGenerator({
|
||||
embeddingProvider: "lemonade",
|
||||
embeddingModel: LEMONADE_MODEL,
|
||||
embeddingBaseUrl: LEMONADE_URL,
|
||||
embeddingApiKey: LEMONADE_KEY,
|
||||
});
|
||||
|
||||
const manager = new SemanticCacheManager(
|
||||
{
|
||||
enabled: true,
|
||||
backend: "redis",
|
||||
similarityThreshold: 0.8,
|
||||
embeddingModel: LEMONADE_MODEL,
|
||||
embeddingProvider: "lemonade",
|
||||
embeddingBaseUrl: LEMONADE_URL,
|
||||
embeddingApiKey: LEMONADE_KEY,
|
||||
},
|
||||
redisStore,
|
||||
generator
|
||||
);
|
||||
|
||||
const body = {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: "How do plants perform photosynthesis?" }],
|
||||
temperature: 0,
|
||||
};
|
||||
|
||||
await manager.store({
|
||||
body,
|
||||
response: {
|
||||
id: "resp-photosynthesis",
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Plants convert sunlight, water, and CO2 into glucose and oxygen.",
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 15, completion_tokens: 20 },
|
||||
},
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
tokensSaved: 35,
|
||||
});
|
||||
|
||||
// 1. Direct hash from Redis
|
||||
const exact = await manager.lookup({
|
||||
body,
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
});
|
||||
assert.equal(exact.hit, true);
|
||||
assert.equal(exact.type, "exact");
|
||||
|
||||
// 2. Semantic match from Redis
|
||||
const semantic = await manager.lookup({
|
||||
body: {
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
messages: [{ role: "user", content: "Explain how photosynthesis works in plants" }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "lemonade/Heimdallr-27B-GGUF",
|
||||
provider: "lemonade",
|
||||
});
|
||||
assert.equal(semantic.hit, true);
|
||||
assert.equal(semantic.type, "semantic");
|
||||
assert.ok((semantic.similarity ?? 0) >= 0.8);
|
||||
|
||||
// Clean up Redis keys
|
||||
await manager.clear();
|
||||
await redisStore.close();
|
||||
});
|
||||
});
|
||||
@@ -80,4 +80,53 @@ test("cache-config route resolves and modelCatalogCacheTtlMs round-trips", async
|
||||
const getBody = await getResponse.json();
|
||||
assert.equal(getBody.idempotencyWindowMs, 9000);
|
||||
});
|
||||
|
||||
await t.test("PUT persists semantic cache settings and GET reads them back", async () => {
|
||||
const putResponse = await cacheConfigRoute.PUT(
|
||||
makeJsonRequest("PUT", {
|
||||
semanticCacheEnabled: true,
|
||||
semanticCacheBackend: "redis",
|
||||
semanticCacheThreshold: 0.88,
|
||||
semanticCacheEmbeddingProvider: "lemonade",
|
||||
semanticCacheEmbeddingModel: "harrier-oss-v1-0.6b",
|
||||
semanticCacheEmbeddingDimension: 1024,
|
||||
semanticCacheRedisUrl: "redis://192.168.31.147:6379",
|
||||
semanticCacheRequireZeroTemp: false,
|
||||
}) as never
|
||||
);
|
||||
assert.equal(putResponse.status, 200);
|
||||
|
||||
const getResponse = await cacheConfigRoute.GET(makeJsonRequest("GET") as never);
|
||||
const getBody = await getResponse.json();
|
||||
assert.equal(getBody.semanticCacheBackend, "redis");
|
||||
assert.equal(getBody.semanticCacheThreshold, 0.88);
|
||||
assert.equal(getBody.semanticCacheEmbeddingProvider, "lemonade");
|
||||
assert.equal(getBody.semanticCacheEmbeddingModel, "harrier-oss-v1-0.6b");
|
||||
assert.equal(getBody.semanticCacheEmbeddingDimension, 1024);
|
||||
assert.equal(getBody.semanticCacheRedisUrl, "redis://192.168.31.147:6379");
|
||||
assert.equal(getBody.semanticCacheRequireZeroTemp, false);
|
||||
});
|
||||
|
||||
await t.test("embedding-options route returns candidate providers and models", async () => {
|
||||
const embeddingOptionsRoute =
|
||||
await import("../../src/app/api/settings/cache-config/embedding-options/route.ts");
|
||||
const response = await embeddingOptionsRoute.GET(
|
||||
new Request("http://localhost/api/settings/cache-config/embedding-options") as never
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.ok(Array.isArray(body.providers));
|
||||
assert.ok(body.providers.length > 0);
|
||||
|
||||
const lemonade = body.providers.find(
|
||||
(p: { id: string; models: Array<{ rawId: string; dimensions?: number }> }) =>
|
||||
p.id === "lemonade"
|
||||
);
|
||||
assert.ok(lemonade, "lemonade provider option should be returned");
|
||||
const harrier = lemonade.models.find(
|
||||
(m: { rawId: string; dimensions?: number }) => m.rawId === "harrier-oss-v1-0.6b"
|
||||
);
|
||||
assert.ok(harrier, "harrier-oss-v1-0.6b model should be present in lemonade models");
|
||||
assert.equal(harrier.dimensions, 1024);
|
||||
});
|
||||
});
|
||||
|
||||
297
tests/unit/lemonade-embedding-provider.test.ts
Normal file
297
tests/unit/lemonade-embedding-provider.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-lemonade-embedding-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { getEmbeddingProvider, parseEmbeddingModel, getEmbeddingDimension } =
|
||||
await import("../../open-sse/config/embeddingRegistry.ts");
|
||||
const { handleEmbedding } = await import("../../open-sse/handlers/embeddings.ts");
|
||||
const { LOCAL_PROVIDERS } = await import("../../src/shared/constants/providers/local.ts");
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { createProviderConnection } = await import("../../src/lib/db/providers.ts");
|
||||
const { createEmbeddingResponse } = await import("../../src/lib/embeddings/service.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("LOCAL_PROVIDERS declares embedding serviceKind for lemonade and llama-cpp", () => {
|
||||
assert.ok(LOCAL_PROVIDERS.lemonade.serviceKinds.includes("embedding"));
|
||||
assert.ok(LOCAL_PROVIDERS["llama-cpp"].serviceKinds.includes("embedding"));
|
||||
});
|
||||
|
||||
test("lemonade embedding registry exposes the local endpoint and curated harrier model", () => {
|
||||
const provider = getEmbeddingProvider("lemonade");
|
||||
assert.ok(provider);
|
||||
assert.equal(provider.baseUrl, "http://localhost:13305/v1/embeddings");
|
||||
assert.equal(provider.authType, "none");
|
||||
assert.equal(provider.authHeader, "bearer");
|
||||
assert.deepEqual(provider.models, [
|
||||
{ id: "harrier-oss-v1-0.6b", name: "Harrier OSS v1 0.6B", dimensions: 1024 },
|
||||
]);
|
||||
assert.equal(getEmbeddingDimension("lemonade/harrier-oss-v1-0.6b"), 1024);
|
||||
assert.equal(getEmbeddingDimension("harrier-oss-v1-0.6b"), 1024);
|
||||
});
|
||||
|
||||
test("llama-cpp embedding registry exposes the local llama-server endpoint", () => {
|
||||
const provider = getEmbeddingProvider("llama-cpp");
|
||||
assert.ok(provider);
|
||||
assert.equal(provider.baseUrl, "http://127.0.0.1:8080/v1/embeddings");
|
||||
assert.equal(provider.authType, "none");
|
||||
assert.equal(provider.authHeader, "bearer");
|
||||
assert.deepEqual(provider.models, []);
|
||||
});
|
||||
|
||||
test("parseEmbeddingModel resolves lemonade, llama-cpp, and aliases", () => {
|
||||
assert.deepEqual(parseEmbeddingModel("lemonade/harrier-oss-v1-0.6b"), {
|
||||
provider: "lemonade",
|
||||
model: "harrier-oss-v1-0.6b",
|
||||
});
|
||||
|
||||
// Bare curated model resolves automatically to lemonade
|
||||
assert.deepEqual(parseEmbeddingModel("harrier-oss-v1-0.6b"), {
|
||||
provider: "lemonade",
|
||||
model: "harrier-oss-v1-0.6b",
|
||||
});
|
||||
|
||||
// llama.cpp and aliases
|
||||
assert.deepEqual(parseEmbeddingModel("llama-cpp/custom-embed"), {
|
||||
provider: "llama-cpp",
|
||||
model: "custom-embed",
|
||||
});
|
||||
assert.deepEqual(parseEmbeddingModel("llamacpp/custom-embed"), {
|
||||
provider: "llama-cpp",
|
||||
model: "custom-embed",
|
||||
});
|
||||
assert.deepEqual(parseEmbeddingModel("llama.cpp/custom-embed"), {
|
||||
provider: "llama-cpp",
|
||||
model: "custom-embed",
|
||||
});
|
||||
});
|
||||
|
||||
test("handleEmbedding forwards Authorization header when key is present on lemonade", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured: {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
} | null = null;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
captured = {
|
||||
url: String(url),
|
||||
headers: (options.headers as Record<string, string>) || {},
|
||||
body: JSON.parse(String(options.body || "{}")),
|
||||
};
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [{ object: "embedding", embedding: [0.01, -0.02, 0.03], index: 0 }],
|
||||
usage: { prompt_tokens: 3, total_tokens: 3 },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handleEmbedding({
|
||||
body: {
|
||||
model: "lemonade/harrier-oss-v1-0.6b",
|
||||
input: "Hello world",
|
||||
},
|
||||
credentials: {
|
||||
apiKey: "lemonade",
|
||||
providerSpecificData: { baseUrl: "http://192.168.31.147:13305" },
|
||||
},
|
||||
resolvedProvider: {
|
||||
id: "lemonade",
|
||||
baseUrl: "http://localhost:13305/v1/embeddings",
|
||||
authType: "none",
|
||||
authHeader: "bearer",
|
||||
models: [{ id: "harrier-oss-v1-0.6b", name: "Harrier", dimensions: 1024 }],
|
||||
},
|
||||
resolvedModel: "harrier-oss-v1-0.6b",
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(captured);
|
||||
assert.equal(captured.url, "http://192.168.31.147:13305/v1/embeddings");
|
||||
assert.equal(captured.headers.Authorization, "Bearer lemonade");
|
||||
assert.deepEqual(captured.body, {
|
||||
model: "harrier-oss-v1-0.6b",
|
||||
input: "Hello world",
|
||||
});
|
||||
assert.equal(result.data.object, "list");
|
||||
assert.equal(result.data.data[0].object, "embedding");
|
||||
assert.deepEqual(result.data.data[0].embedding, [0.01, -0.02, 0.03]);
|
||||
assert.equal(result.data.usage.total_tokens, 3);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleEmbedding supports keyless requests on lemonade without error", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured: { url: string; headers: Record<string, string> } | null = null;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
captured = {
|
||||
url: String(url),
|
||||
headers: (options.headers as Record<string, string>) || {},
|
||||
};
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handleEmbedding({
|
||||
body: {
|
||||
model: "lemonade/harrier-oss-v1-0.6b",
|
||||
input: "Hello",
|
||||
},
|
||||
credentials: null,
|
||||
resolvedProvider: {
|
||||
id: "lemonade",
|
||||
baseUrl: "http://localhost:13305/v1/embeddings",
|
||||
authType: "none",
|
||||
authHeader: "bearer",
|
||||
models: [],
|
||||
},
|
||||
resolvedModel: "harrier-oss-v1-0.6b",
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(captured);
|
||||
assert.equal(captured.url, "http://localhost:13305/v1/embeddings");
|
||||
assert.equal(captured.headers.Authorization, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleEmbedding normalizes diverse local base URLs properly", async () => {
|
||||
const testCases = [
|
||||
{ input: "http://192.168.31.147:13305", expected: "http://192.168.31.147:13305/v1/embeddings" },
|
||||
{
|
||||
input: "http://192.168.31.147:13305/v1",
|
||||
expected: "http://192.168.31.147:13305/v1/embeddings",
|
||||
},
|
||||
{
|
||||
input: "http://192.168.31.147:13305/api/v1",
|
||||
expected: "http://192.168.31.147:13305/api/v1/embeddings",
|
||||
},
|
||||
{
|
||||
input: "http://192.168.31.147:13305/embeddings",
|
||||
expected: "http://192.168.31.147:13305/embeddings",
|
||||
},
|
||||
];
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
try {
|
||||
for (const { input, expected } of testCases) {
|
||||
let capturedUrl = "";
|
||||
globalThis.fetch = async (url) => {
|
||||
capturedUrl = String(url);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [{ object: "embedding", embedding: [0.1], index: 0 }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const result = await handleEmbedding({
|
||||
body: { model: "lemonade/harrier-oss-v1-0.6b", input: "test" },
|
||||
credentials: { providerSpecificData: { baseUrl: input } },
|
||||
resolvedProvider: {
|
||||
id: "lemonade",
|
||||
baseUrl: "http://localhost:13305/v1/embeddings",
|
||||
authType: "none",
|
||||
authHeader: "bearer",
|
||||
models: [],
|
||||
},
|
||||
resolvedModel: "harrier-oss-v1-0.6b",
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(capturedUrl, expected);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("createEmbeddingResponse hydrates configured Lemonade connection with Bearer key", async () => {
|
||||
await createProviderConnection({
|
||||
provider: "lemonade",
|
||||
authType: "none",
|
||||
name: "Lemonade LAN Server",
|
||||
apiKey: "lemonade",
|
||||
isActive: true,
|
||||
providerSpecificData: { baseUrl: "http://192.168.31.147:13305" },
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured: {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
} | null = null;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
captured = {
|
||||
url: String(url),
|
||||
headers: (options.headers as Record<string, string>) || {},
|
||||
body: JSON.parse(String(options.body || "{}")),
|
||||
};
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [{ object: "embedding", embedding: [0.0165, -0.07], index: 0 }],
|
||||
usage: { prompt_tokens: 4, total_tokens: 4 },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await createEmbeddingResponse({
|
||||
model: "lemonade/harrier-oss-v1-0.6b",
|
||||
input: ["Batch sentence one", "Batch sentence two"],
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as {
|
||||
object: string;
|
||||
data: Array<{ embedding: number[] }>;
|
||||
usage: { total_tokens: number };
|
||||
};
|
||||
assert.equal(body.object, "list");
|
||||
assert.equal(body.data.length, 1);
|
||||
assert.deepEqual(body.data[0].embedding, [0.0165, -0.07]);
|
||||
assert.equal(body.usage.total_tokens, 4);
|
||||
|
||||
assert.ok(captured);
|
||||
assert.equal(captured.url, "http://192.168.31.147:13305/v1/embeddings");
|
||||
assert.equal(captured.headers.Authorization, "Bearer lemonade");
|
||||
assert.deepEqual(captured.body, {
|
||||
model: "harrier-oss-v1-0.6b",
|
||||
input: ["Batch sentence one", "Batch sentence two"],
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
217
tests/unit/model-embedding-discovery-and-cache.test.ts
Normal file
217
tests/unit/model-embedding-discovery-and-cache.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
detectModelModality,
|
||||
normalizeDiscoveredModels,
|
||||
} from "@/lib/providerModels/modelDiscovery";
|
||||
import type { SyncedAvailableModel } from "@/lib/db/models";
|
||||
import {
|
||||
getModelEndpointDecision,
|
||||
isChatSelectableModel,
|
||||
filterChatSelectableModels,
|
||||
} from "../../open-sse/services/modelEndpointPolicy.ts";
|
||||
import { detectTestKind } from "@/lib/api/modelTestRunner";
|
||||
|
||||
// The two "live Lemonade" tests below hit a real embedding server on the
|
||||
// contributor's private LAN (192.168.31.147) — unreachable from CI or any
|
||||
// other machine. A unit test must never depend on live network access
|
||||
// (tests/integration/semantic-cache-lemonade.test.ts already gates the same
|
||||
// endpoint this way for the integration suite), so both self-skip instead of
|
||||
// failing when the endpoint isn't reachable.
|
||||
const LEMONADE_TEST_BASE_URL = "http://192.168.31.147:13305";
|
||||
|
||||
async function isEndpointReachable(url: string, timeoutMs = 1500): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const res = await fetch(url, { method: "HEAD", signal: controller.signal }).catch(() => null);
|
||||
clearTimeout(timer);
|
||||
return res !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test("detectModelModality flags Lemonade embeddings model and pulls dimensions and context length", () => {
|
||||
// Lemonade verbatim /v1/models shape for harrier-oss-v1-0.6b
|
||||
const lemonadeRecord = {
|
||||
id: "harrier-oss-v1-0.6b",
|
||||
object: "model",
|
||||
owned_by: "lemonade",
|
||||
labels: ["custom", "embeddings"],
|
||||
context_length: 32768,
|
||||
max_context_window: 32768,
|
||||
};
|
||||
|
||||
const modality = detectModelModality(lemonadeRecord, "lemonade");
|
||||
assert.equal(modality.isEmbedding, true);
|
||||
assert.equal(modality.isImage, false);
|
||||
assert.equal(modality.isRerank, false);
|
||||
assert.equal(modality.dimensions, 1024, "Should resolve 1024 dimensions from registry");
|
||||
assert.deepEqual(modality.supportedInputTypes, ["text"]);
|
||||
|
||||
// Normalize discovered model
|
||||
const synced = normalizeDiscoveredModels([lemonadeRecord], "lemonade");
|
||||
assert.equal(synced.length, 1);
|
||||
const [model] = synced;
|
||||
assert.equal(model.id, "harrier-oss-v1-0.6b");
|
||||
assert.equal(model.modelType, "embedding");
|
||||
assert.equal(model.apiFormat, "embeddings");
|
||||
assert.deepEqual(model.supportedEndpoints, ["embeddings"]);
|
||||
assert.equal(model.inputTokenLimit, 32768);
|
||||
assert.equal(model.dimensions, 1024);
|
||||
assert.deepEqual(model.supportedInputTypes, ["text"]);
|
||||
});
|
||||
|
||||
test("detectModelModality identifies reranking and image models from labels", () => {
|
||||
const rerankRecord = {
|
||||
id: "bge-reranker-large",
|
||||
labels: ["custom", "reranking"],
|
||||
};
|
||||
const rerankModality = detectModelModality(rerankRecord, "custom");
|
||||
assert.equal(rerankModality.isRerank, true);
|
||||
assert.equal(rerankModality.isEmbedding, false);
|
||||
|
||||
const imageRecord = {
|
||||
id: "flux-1-schnell",
|
||||
labels: ["image"],
|
||||
};
|
||||
const imageModality = detectModelModality(imageRecord, "custom");
|
||||
assert.equal(imageModality.isImage, true);
|
||||
assert.equal(imageModality.isEmbedding, false);
|
||||
});
|
||||
|
||||
test("modelEndpointPolicy excludes embedding models from chat completions", () => {
|
||||
// Upstream explicit endpoints with embeddings
|
||||
assert.deepEqual(getModelEndpointDecision("lemonade", "harrier-oss-v1-0.6b", ["embeddings"]), {
|
||||
kind: "embedding",
|
||||
chatSelectable: false,
|
||||
reason: "explicit-endpoints",
|
||||
});
|
||||
|
||||
// OpenAI text-embedding-3-small provider policy
|
||||
assert.deepEqual(getModelEndpointDecision("openai", "text-embedding-3-small"), {
|
||||
kind: "embedding",
|
||||
chatSelectable: false,
|
||||
reason: "provider-policy",
|
||||
});
|
||||
|
||||
// isChatSelectableModel returns false
|
||||
assert.equal(
|
||||
isChatSelectableModel("lemonade", {
|
||||
id: "harrier-oss-v1-0.6b",
|
||||
supportedEndpoints: ["embeddings"],
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
// Filter removes embedding model from chat candidates
|
||||
const filtered = filterChatSelectableModels("lemonade", [
|
||||
{ id: "qwen2.5-coder-7b", supportedEndpoints: ["chat"] },
|
||||
{ id: "harrier-oss-v1-0.6b", supportedEndpoints: ["embeddings"] },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
filtered.map((m) => m.id),
|
||||
["qwen2.5-coder-7b"]
|
||||
);
|
||||
});
|
||||
|
||||
test("detectTestKind in modelTestRunner detects embedding test probe for harrier-oss-v1-0.6b", () => {
|
||||
// Test with modelType flag
|
||||
const result1 = detectTestKind("lemonade/harrier-oss-v1-0.6b", {
|
||||
modelType: "embedding",
|
||||
dimensions: 1024,
|
||||
} as unknown as SyncedAvailableModel);
|
||||
assert.equal(result1.isEmbedding, true);
|
||||
assert.equal(result1.isRerank, false);
|
||||
assert.equal(result1.isAudioTranscription, false);
|
||||
|
||||
// Test with supportedEndpoints
|
||||
const result2 = detectTestKind("lemonade/harrier-oss-v1-0.6b", {
|
||||
supportedEndpoints: ["embeddings"],
|
||||
} as unknown as SyncedAvailableModel);
|
||||
assert.equal(result2.isEmbedding, true);
|
||||
|
||||
// Test with apiFormat
|
||||
const result3 = detectTestKind("lemonade/harrier-oss-v1-0.6b", {
|
||||
apiFormat: "embeddings",
|
||||
} as unknown as SyncedAvailableModel);
|
||||
assert.equal(result3.isEmbedding, true);
|
||||
});
|
||||
|
||||
test("test-embedding route validates inputs and generates embeddings via live Lemonade", async (t) => {
|
||||
const reachable = await isEndpointReachable(LEMONADE_TEST_BASE_URL);
|
||||
if (!reachable) {
|
||||
t.skip(`Lemonade server not reachable at ${LEMONADE_TEST_BASE_URL}`);
|
||||
return;
|
||||
}
|
||||
const testEmbeddingRoute =
|
||||
await import("../../src/app/api/settings/cache-config/test-embedding/route.ts");
|
||||
|
||||
const req = new Request("http://localhost/api/settings/cache-config/test-embedding", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "lemonade",
|
||||
model: "harrier-oss-v1-0.6b",
|
||||
baseUrl: "http://192.168.31.147:13305/v1",
|
||||
apiKey: "lemonade",
|
||||
dimensions: 1024,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await testEmbeddingRoute.POST(req);
|
||||
assert.equal(response.status, 200);
|
||||
const data = await response.json();
|
||||
assert.equal(data.ok, true);
|
||||
assert.equal(data.dimensions, 1024);
|
||||
assert.ok(typeof data.latencyMs === "number" && data.latencyMs > 0);
|
||||
});
|
||||
|
||||
test("test-embedding route automatically resolves connection details from DB when not passed", async (t) => {
|
||||
const reachable = await isEndpointReachable(LEMONADE_TEST_BASE_URL);
|
||||
if (!reachable) {
|
||||
t.skip(`Lemonade server not reachable at ${LEMONADE_TEST_BASE_URL}`);
|
||||
return;
|
||||
}
|
||||
const { getDbInstance } = await import("@/lib/db/core");
|
||||
const testEmbeddingRoute =
|
||||
await import("../../src/app/api/settings/cache-config/test-embedding/route.ts");
|
||||
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT OR REPLACE INTO provider_connections (
|
||||
id, provider, name, auth_type, api_key, provider_specific_data, is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
'test-conn-lemonade-1',
|
||||
'lemonade',
|
||||
'Lemonade Local Server',
|
||||
'apikey',
|
||||
'lemonade',
|
||||
'{"baseUrl":"http://192.168.31.147:13305/"}',
|
||||
1,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
`
|
||||
).run();
|
||||
|
||||
// Omit baseUrl and apiKey from payload
|
||||
const req = new Request("http://localhost/api/settings/cache-config/test-embedding", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "lemonade",
|
||||
model: "harrier-oss-v1-0.6b",
|
||||
dimensions: 1024,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await testEmbeddingRoute.POST(req);
|
||||
assert.equal(response.status, 200);
|
||||
const data = await response.json();
|
||||
assert.equal(data.ok, true, `Expected ok=true but got error: ${data.error}`);
|
||||
assert.equal(data.dimensions, 1024);
|
||||
assert.equal(data.resolvedBaseUrl, "http://192.168.31.147:13305/");
|
||||
});
|
||||
903
tests/unit/semantic-cache-dual-layer.test.ts
Normal file
903
tests/unit/semantic-cache-dual-layer.test.ts
Normal file
@@ -0,0 +1,903 @@
|
||||
import { describe, it, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
SemanticCacheManager,
|
||||
generateDirectHash,
|
||||
resetSemanticCacheManager,
|
||||
} from "../../open-sse/services/cache/semanticCacheManager.ts";
|
||||
import { MemoryVectorStore } from "../../open-sse/services/cache/memoryVectorStore.ts";
|
||||
import {
|
||||
RedisVectorStore,
|
||||
type RedisLike,
|
||||
} from "../../open-sse/services/cache/redisVectorStore.ts";
|
||||
import {
|
||||
cosineSimilarity,
|
||||
dotProduct,
|
||||
l2Normalize,
|
||||
} from "../../open-sse/services/cache/vectorStore.ts";
|
||||
import {
|
||||
normalizeConversationForEmbedding,
|
||||
generateEmbeddingWithTimeout,
|
||||
} from "../../open-sse/services/cache/embeddingClient.ts";
|
||||
import { resolveSemanticCacheConfig } from "../../open-sse/config/semanticCacheConfig.ts";
|
||||
|
||||
describe("Semantic Cache — Dual-Layer Architecture", () => {
|
||||
beforeEach(() => {
|
||||
resetSemanticCacheManager(null);
|
||||
});
|
||||
|
||||
describe("Vector Math & Normalization", () => {
|
||||
it("computes cosine similarity accurately", () => {
|
||||
const a = [1, 0, 0];
|
||||
const b = [1, 0, 0];
|
||||
const c = [0, 1, 0];
|
||||
const d = [0.7071, 0.7071, 0];
|
||||
|
||||
assert.equal(Math.round(cosineSimilarity(a, b) * 1000) / 1000, 1);
|
||||
assert.equal(cosineSimilarity(a, c), 0);
|
||||
assert.ok(cosineSimilarity(a, d) > 0.7 && cosineSimilarity(a, d) < 0.71);
|
||||
assert.equal(dotProduct([1, 2], [3, 4]), 11);
|
||||
});
|
||||
|
||||
it("handles zero vectors and empty arrays gracefully", () => {
|
||||
assert.equal(cosineSimilarity([], []), 0);
|
||||
assert.equal(cosineSimilarity([0, 0], [1, 2]), 0);
|
||||
});
|
||||
|
||||
it("resolves config defaults and overrides properly", () => {
|
||||
const conf = resolveSemanticCacheConfig({ similarityThreshold: 0.85 });
|
||||
assert.equal(conf.similarityThreshold, 0.85);
|
||||
assert.equal(conf.backend, "memory");
|
||||
});
|
||||
|
||||
it("l2Normalize creates unit vector", () => {
|
||||
const v = [3, 4];
|
||||
const norm = l2Normalize(v);
|
||||
assert.equal(Math.round(norm[0] * 10) / 10, 0.6);
|
||||
assert.equal(Math.round(norm[1] * 10) / 10, 0.8);
|
||||
const magnitude = Math.sqrt(norm[0] ** 2 + norm[1] ** 2);
|
||||
assert.equal(Math.round(magnitude * 1000) / 1000, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Conversation Normalization", () => {
|
||||
it("extracts clean text from string and array messages", () => {
|
||||
const msgs = [
|
||||
{ role: "system", content: "You are a helpful assistant" },
|
||||
{ role: "user", content: "What is the capital of France?" },
|
||||
{ role: "assistant", content: "Paris" },
|
||||
{ role: "user", content: "What is its population?" },
|
||||
];
|
||||
|
||||
const textWithSystem = normalizeConversationForEmbedding(msgs, {
|
||||
excludeSystemPrompt: false,
|
||||
historyDepth: 3,
|
||||
});
|
||||
assert.ok(textWithSystem.includes("user: What is its population?"));
|
||||
assert.ok(textWithSystem.includes("assistant: Paris"));
|
||||
|
||||
const textNoSystem = normalizeConversationForEmbedding(msgs, {
|
||||
excludeSystemPrompt: true,
|
||||
historyDepth: 3,
|
||||
});
|
||||
assert.ok(!textNoSystem.includes("system:"));
|
||||
});
|
||||
|
||||
it("handles multipart content objects", () => {
|
||||
const msgs = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "text", text: "World" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const text = normalizeConversationForEmbedding(msgs);
|
||||
assert.equal(text, "user: Hello World");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layer 1: Exact Match (Direct Hash)", () => {
|
||||
it("bypasses embedding generation on exact match (0 embedding latency)", async () => {
|
||||
let embeddingCalls = 0;
|
||||
const mockEmbeddingGenerator = async () => {
|
||||
embeddingCalls++;
|
||||
return { embedding: [1, 0, 0], inputTokens: 5 };
|
||||
};
|
||||
|
||||
const manager = new SemanticCacheManager(
|
||||
{ enabled: true },
|
||||
new MemoryVectorStore(),
|
||||
mockEmbeddingGenerator
|
||||
);
|
||||
|
||||
const requestBody = {
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "How do I reverse a string in JS?" }],
|
||||
temperature: 0,
|
||||
};
|
||||
|
||||
const responsePayload = {
|
||||
id: "chatcmpl-test-1",
|
||||
choices: [
|
||||
{ message: { role: "assistant", content: "Use str.split('').reverse().join('')" } },
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 15 },
|
||||
};
|
||||
|
||||
// 1. Store response
|
||||
await manager.store({
|
||||
body: requestBody,
|
||||
response: responsePayload,
|
||||
model: "gpt-4o",
|
||||
provider: "openai",
|
||||
tokensSaved: 25,
|
||||
});
|
||||
|
||||
// Reset embedding counter after store
|
||||
embeddingCalls = 0;
|
||||
|
||||
// 2. Lookup identical query -> Layer 1 exact match
|
||||
const result = await manager.lookup({
|
||||
body: requestBody,
|
||||
model: "gpt-4o",
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
assert.equal(result.hit, true, "Should hit cache");
|
||||
assert.equal(result.type, "exact", "Should be an exact match hit");
|
||||
assert.equal(embeddingCalls, 0, "Exact match MUST NOT call embedding generator");
|
||||
assert.equal(result.tokensSaved, 25);
|
||||
});
|
||||
|
||||
it("isolates direct hashes by apiKeyId (#3740)", () => {
|
||||
const conv = [{ role: "user", content: "test query" }];
|
||||
const hashA = generateDirectHash("gpt-4", conv, 0, 1, { apiKeyId: "user-a" });
|
||||
const hashB = generateDirectHash("gpt-4", conv, 0, 1, { apiKeyId: "user-b" });
|
||||
const hashAnonymous = generateDirectHash("gpt-4", conv, 0, 1);
|
||||
|
||||
assert.notEqual(hashA, hashB);
|
||||
assert.notEqual(hashA, hashAnonymous);
|
||||
assert.ok(hashA.startsWith("user-a."));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layer 2: Semantic Similarity Match", () => {
|
||||
it("hits cache when prompt is semantically similar above threshold", async () => {
|
||||
const embeddings: Record<string, number[]> = {
|
||||
"user: What is the capital of France?": [0.95, 0.31, 0],
|
||||
"user: Tell me the capital city of France": [0.94, 0.34, 0],
|
||||
"user: How to make a chocolate cake?": [0.1, 0.99, 0],
|
||||
};
|
||||
|
||||
const mockEmbeddingGenerator = async (text: string) => {
|
||||
const vec = embeddings[text] || [0.5, 0.5, 0];
|
||||
return { embedding: vec, inputTokens: 8 };
|
||||
};
|
||||
|
||||
const manager = new SemanticCacheManager(
|
||||
{ enabled: true, similarityThreshold: 0.8 },
|
||||
new MemoryVectorStore(),
|
||||
mockEmbeddingGenerator
|
||||
);
|
||||
|
||||
// 1. Store first query
|
||||
await manager.store({
|
||||
body: {
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "What is the capital of France?" }],
|
||||
temperature: 0,
|
||||
},
|
||||
response: {
|
||||
id: "chatcmpl-paris",
|
||||
choices: [{ message: { role: "assistant", content: "The capital of France is Paris." } }],
|
||||
usage: { prompt_tokens: 8, completion_tokens: 10 },
|
||||
},
|
||||
model: "gpt-4o",
|
||||
provider: "openai",
|
||||
tokensSaved: 18,
|
||||
});
|
||||
|
||||
// 2. Query with different wording (synonymous)
|
||||
const similarResult = await manager.lookup({
|
||||
body: {
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "Tell me the capital city of France" }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "gpt-4o",
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
assert.equal(similarResult.hit, true, "Similar query should hit cache");
|
||||
assert.equal(similarResult.type, "semantic", "Should be a semantic hit");
|
||||
assert.ok(
|
||||
(similarResult.similarity ?? 0) >= 0.8,
|
||||
`Similarity score ${similarResult.similarity} should be >= 0.8`
|
||||
);
|
||||
|
||||
// 3. Query with completely different topic
|
||||
const differentResult = await manager.lookup({
|
||||
body: {
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "How to make a chocolate cake?" }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "gpt-4o",
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
assert.equal(differentResult.hit, false, "Unrelated query should miss cache");
|
||||
});
|
||||
|
||||
it("respects per-request threshold override via x-omniroute-cache-threshold", async () => {
|
||||
// 0.85 similarity between vectors
|
||||
const mockEmbeddingGenerator = async (text: string) => {
|
||||
if (text.includes("query-1")) return { embedding: [1, 0, 0], inputTokens: 5 };
|
||||
return { embedding: [0.85, 0.5268, 0], inputTokens: 5 }; // dot product = 0.85
|
||||
};
|
||||
|
||||
const manager = new SemanticCacheManager(
|
||||
{ enabled: true, similarityThreshold: 0.8 },
|
||||
new MemoryVectorStore(),
|
||||
mockEmbeddingGenerator
|
||||
);
|
||||
|
||||
await manager.store({
|
||||
body: { model: "gpt-4o", messages: [{ role: "user", content: "query-1" }], temperature: 0 },
|
||||
response: { id: "res-1", choices: [{ message: { role: "assistant", content: "ans-1" } }] },
|
||||
model: "gpt-4o",
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
// Normal threshold 0.8: similarity 0.85 >= 0.8 -> HIT
|
||||
const hitNormal = await manager.lookup({
|
||||
body: { model: "gpt-4o", messages: [{ role: "user", content: "query-2" }], temperature: 0 },
|
||||
model: "gpt-4o",
|
||||
provider: "openai",
|
||||
});
|
||||
assert.equal(hitNormal.hit, true);
|
||||
|
||||
// Strict per-request threshold 0.95: similarity 0.85 < 0.95 -> MISS
|
||||
const missStrict = await manager.lookup({
|
||||
body: { model: "gpt-4o", messages: [{ role: "user", content: "query-2" }], temperature: 0 },
|
||||
headers: { "x-omniroute-cache-threshold": "0.95" },
|
||||
model: "gpt-4o",
|
||||
provider: "openai",
|
||||
});
|
||||
assert.equal(missStrict.hit, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Request Overrides & Bypasses", () => {
|
||||
it("respects Cache-Control: no-cache and x-omniroute-no-cache: true", async () => {
|
||||
const manager = new SemanticCacheManager({ enabled: true }, new MemoryVectorStore());
|
||||
const body = {
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
temperature: 0,
|
||||
};
|
||||
|
||||
await manager.store({
|
||||
body,
|
||||
response: { id: "1", choices: [] },
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
const bypassedX = await manager.lookup({
|
||||
body,
|
||||
headers: { "x-omniroute-no-cache": "true" },
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
});
|
||||
assert.equal(bypassedX.hit, false);
|
||||
assert.equal(bypassedX.bypassed, true);
|
||||
|
||||
const bypassedCC = await manager.lookup({
|
||||
body,
|
||||
headers: { "cache-control": "no-cache" },
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
});
|
||||
assert.equal(bypassedCC.hit, false);
|
||||
assert.equal(bypassedCC.bypassed, true);
|
||||
});
|
||||
|
||||
it("respects x-omniroute-cache-type: direct and x-omniroute-cache-type: semantic", async () => {
|
||||
let semCalls = 0;
|
||||
const mockEmbeddingGenerator = async () => {
|
||||
semCalls++;
|
||||
return { embedding: [1, 0], inputTokens: 2 };
|
||||
};
|
||||
|
||||
const manager = new SemanticCacheManager(
|
||||
{ enabled: true },
|
||||
new MemoryVectorStore(),
|
||||
mockEmbeddingGenerator
|
||||
);
|
||||
|
||||
// Direct-only mode skips semantic search completely on miss
|
||||
const result = await manager.lookup({
|
||||
body: { model: "m", messages: [{ role: "user", content: "new" }], temperature: 0 },
|
||||
headers: { "x-omniroute-cache-type": "direct" },
|
||||
model: "m",
|
||||
provider: "p",
|
||||
});
|
||||
assert.equal(result.hit, false);
|
||||
assert.equal(semCalls, 0, "direct mode must not call embedding generator");
|
||||
});
|
||||
|
||||
it("respects x-omniroute-cache-no-store: true", async () => {
|
||||
const manager = new SemanticCacheManager({ enabled: true }, new MemoryVectorStore());
|
||||
const body = {
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: "no-store-test" }],
|
||||
temperature: 0,
|
||||
};
|
||||
|
||||
await manager.store({
|
||||
body,
|
||||
headers: { "x-omniroute-cache-no-store": "true" },
|
||||
response: { id: "1", choices: [] },
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
const check = await manager.lookup({
|
||||
body,
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
});
|
||||
assert.equal(check.hit, false, "Entry should not have been stored");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Streaming SSE Replay", () => {
|
||||
it("synthesizes valid OpenAI SSE frames ending in data: [DONE]", () => {
|
||||
const manager = new SemanticCacheManager({ enabled: true });
|
||||
const entry = {
|
||||
id: "test",
|
||||
hash: "h",
|
||||
promptText: "hi",
|
||||
model: "gpt-4o",
|
||||
provider: "openai",
|
||||
response: {
|
||||
id: "chatcmpl-stream-test",
|
||||
created: 123456789,
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "Streaming answer content" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
},
|
||||
tokensSaved: 15,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 60000,
|
||||
};
|
||||
|
||||
const sse = manager.synthesizeSseFromEntry(entry);
|
||||
assert.ok(sse.startsWith("data: "));
|
||||
assert.ok(sse.includes("Streaming answer content"));
|
||||
assert.ok(sse.trimEnd().endsWith("data: [DONE]"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore Operations & Eviction", () => {
|
||||
it("evicts oldest entries when exceeding maxEntries", async () => {
|
||||
const store = new MemoryVectorStore({ maxEntries: 2 });
|
||||
const now = Date.now();
|
||||
|
||||
await store.set(
|
||||
{
|
||||
id: "1",
|
||||
hash: "h1",
|
||||
promptText: "p1",
|
||||
model: "m",
|
||||
provider: "p",
|
||||
response: {},
|
||||
tokensSaved: 1,
|
||||
createdAt: now,
|
||||
expiresAt: now + 100000,
|
||||
},
|
||||
100000
|
||||
);
|
||||
|
||||
await store.set(
|
||||
{
|
||||
id: "2",
|
||||
hash: "h2",
|
||||
promptText: "p2",
|
||||
model: "m",
|
||||
provider: "p",
|
||||
response: {},
|
||||
tokensSaved: 1,
|
||||
createdAt: now,
|
||||
expiresAt: now + 100000,
|
||||
},
|
||||
100000
|
||||
);
|
||||
|
||||
await store.set(
|
||||
{
|
||||
id: "3",
|
||||
hash: "h3",
|
||||
promptText: "p3",
|
||||
model: "m",
|
||||
provider: "p",
|
||||
response: {},
|
||||
tokensSaved: 1,
|
||||
createdAt: now,
|
||||
expiresAt: now + 100000,
|
||||
},
|
||||
100000
|
||||
);
|
||||
|
||||
// Oldest entry 1 should have been evicted
|
||||
assert.equal(await store.get("1"), null);
|
||||
assert.notEqual(await store.get("2"), null);
|
||||
assert.notEqual(await store.get("3"), null);
|
||||
const stats = await store.getStats();
|
||||
assert.equal(stats.entries, 2);
|
||||
});
|
||||
|
||||
it("expires entries past TTL", async () => {
|
||||
const store = new MemoryVectorStore();
|
||||
const now = Date.now();
|
||||
|
||||
await store.set(
|
||||
{
|
||||
id: "expired",
|
||||
hash: "hexp",
|
||||
promptText: "p",
|
||||
model: "m",
|
||||
provider: "p",
|
||||
response: {},
|
||||
tokensSaved: 1,
|
||||
createdAt: now - 5000,
|
||||
expiresAt: now - 1000, // already expired
|
||||
},
|
||||
-1000
|
||||
);
|
||||
|
||||
assert.equal(await store.get("expired"), null);
|
||||
assert.equal(await store.getByHash("hexp"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RedisVectorStore with Mock Client & Fail-Open", () => {
|
||||
it("handles Redis get, set, smembers and fails open on connection errors", async () => {
|
||||
const redisStorage = new Map<string, string>();
|
||||
const setStorage = new Map<string, Set<string>>();
|
||||
|
||||
const mockClient: RedisLike = {
|
||||
get: async (k: string) => redisStorage.get(k) || null,
|
||||
set: async (k: string, v: string) => {
|
||||
redisStorage.set(k, v);
|
||||
return "OK";
|
||||
},
|
||||
del: async (...keys: string[]) => {
|
||||
let count = 0;
|
||||
for (const k of keys) {
|
||||
if (redisStorage.delete(k)) count++;
|
||||
}
|
||||
return count;
|
||||
},
|
||||
sadd: async (k: string, ...members: string[]) => {
|
||||
if (!setStorage.has(k)) setStorage.set(k, new Set());
|
||||
const s = setStorage.get(k)!;
|
||||
let added = 0;
|
||||
for (const m of members) {
|
||||
if (!s.has(m)) {
|
||||
s.add(m);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
},
|
||||
srem: async (k: string, ...members: string[]) => {
|
||||
const s = setStorage.get(k);
|
||||
if (!s) return 0;
|
||||
let removed = 0;
|
||||
for (const m of members) {
|
||||
if (s.delete(m)) removed++;
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
smembers: async (k: string) => {
|
||||
const s = setStorage.get(k);
|
||||
return s ? Array.from(s) : [];
|
||||
},
|
||||
mget: async (...keys: string[]) => {
|
||||
return keys.map((k) => redisStorage.get(k) || null);
|
||||
},
|
||||
keys: async (_p: string) => Array.from(redisStorage.keys()),
|
||||
};
|
||||
|
||||
const redisStore = new RedisVectorStore({ client: mockClient });
|
||||
|
||||
const entry = {
|
||||
id: "redis-entry-1",
|
||||
hash: "hash-redis",
|
||||
embedding: [1, 0, 0],
|
||||
promptText: "hello redis",
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
response: { id: "res" },
|
||||
tokensSaved: 10,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 60000,
|
||||
};
|
||||
|
||||
await redisStore.set(entry, 60000);
|
||||
const fetched = await redisStore.getByHash("hash-redis");
|
||||
assert.ok(fetched);
|
||||
assert.equal(fetched.promptText, "hello redis");
|
||||
|
||||
const search = await redisStore.searchNearest([1, 0, 0], { model: "gpt-4" }, 0.8);
|
||||
assert.equal(search.length, 1);
|
||||
assert.equal(search[0].entry.id, "redis-entry-1");
|
||||
|
||||
// Test fail-open resilience when client errors
|
||||
const failingClient: RedisLike = {
|
||||
get: async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
},
|
||||
set: async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
},
|
||||
del: async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
},
|
||||
sadd: async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
},
|
||||
srem: async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
},
|
||||
smembers: async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
},
|
||||
mget: async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
},
|
||||
keys: async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
},
|
||||
};
|
||||
|
||||
const brokenStore = new RedisVectorStore({ client: failingClient });
|
||||
// Should NOT throw, but return null/empty
|
||||
assert.equal(await brokenStore.get("any"), null);
|
||||
assert.equal(await brokenStore.getByHash("any"), null);
|
||||
const emptyResults = await brokenStore.searchNearest([1, 0], {}, 0.8);
|
||||
assert.deepEqual(emptyResults, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Graceful Degradation", () => {
|
||||
it("fails open when embedding generation times out or throws", async () => {
|
||||
const failingGenerator = async () => {
|
||||
throw new Error("Embedding upstream 503 Service Unavailable");
|
||||
};
|
||||
|
||||
const manager = new SemanticCacheManager(
|
||||
{ enabled: true },
|
||||
new MemoryVectorStore(),
|
||||
failingGenerator
|
||||
);
|
||||
|
||||
const result = await manager.lookup({
|
||||
body: { model: "gpt-4", messages: [{ role: "user", content: "test" }], temperature: 0 },
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
assert.equal(result.hit, false, "Embedding failure must fail open cleanly");
|
||||
});
|
||||
|
||||
it("handles empty input in generateEmbeddingWithTimeout", async () => {
|
||||
const res = await generateEmbeddingWithTimeout("", async () => null);
|
||||
assert.equal(res, null);
|
||||
});
|
||||
|
||||
it("times out uncooperative generators that ignore AbortSignal via Promise.race", async () => {
|
||||
// Generator that never resolves and completely ignores AbortSignal
|
||||
const hangingGenerator = () =>
|
||||
new Promise<{ embedding: number[]; inputTokens: number }>(() => {});
|
||||
|
||||
const t0 = Date.now();
|
||||
const res = await generateEmbeddingWithTimeout("hello", hangingGenerator, { timeoutMs: 50 });
|
||||
const elapsed = Date.now() - t0;
|
||||
|
||||
assert.equal(res, null, "Should return null on timeout");
|
||||
assert.ok(elapsed >= 45 && elapsed < 500, `Should resolve around 50ms, took ${elapsed}ms`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multi-Tenant Partition Isolation", () => {
|
||||
it("isolates anonymous requests from authenticated entries in semantic search", async () => {
|
||||
const store = new MemoryVectorStore();
|
||||
const manager = new SemanticCacheManager(
|
||||
{ enabled: true, similarityThreshold: 0.8 },
|
||||
store,
|
||||
async () => ({ embedding: [1, 0], inputTokens: 5 })
|
||||
);
|
||||
|
||||
// Store entry under apiKeyId: "tenant-a"
|
||||
await manager.store({
|
||||
body: {
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: "sensitive data" }],
|
||||
temperature: 0,
|
||||
},
|
||||
response: { choices: [{ message: { content: "secret answer" } }] },
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
apiKeyId: "tenant-a",
|
||||
tokensSaved: 10,
|
||||
});
|
||||
|
||||
// Anonymous query (no apiKeyId) should MISS
|
||||
const anonResult = await manager.lookup({
|
||||
body: {
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: "sensitive data paraphrase" }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
});
|
||||
assert.equal(anonResult.hit, false, "Anonymous query must NOT hit tenant-a cached entry");
|
||||
|
||||
// Another tenant query should MISS
|
||||
const otherTenantResult = await manager.lookup({
|
||||
body: {
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: "sensitive data paraphrase" }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
apiKeyId: "tenant-b",
|
||||
});
|
||||
assert.equal(
|
||||
otherTenantResult.hit,
|
||||
false,
|
||||
"Tenant-b query must NOT hit tenant-a cached entry"
|
||||
);
|
||||
|
||||
// Same tenant query should HIT
|
||||
const sameTenantResult = await manager.lookup({
|
||||
body: {
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: "sensitive data paraphrase" }],
|
||||
temperature: 0,
|
||||
},
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
apiKeyId: "tenant-a",
|
||||
});
|
||||
assert.equal(sameTenantResult.hit, true, "Tenant-a query must HIT tenant-a cached entry");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore Hash Index Consistency", () => {
|
||||
it("replaces older entry when inserting a new entry with identical hash", async () => {
|
||||
const store = new MemoryVectorStore();
|
||||
const now = Date.now();
|
||||
|
||||
await store.set(
|
||||
{
|
||||
id: "id-old",
|
||||
hash: "shared-hash-1",
|
||||
promptText: "prompt 1",
|
||||
model: "m",
|
||||
provider: "p",
|
||||
response: { v: 1 },
|
||||
tokensSaved: 10,
|
||||
createdAt: now,
|
||||
expiresAt: now + 60000,
|
||||
},
|
||||
60000
|
||||
);
|
||||
|
||||
// Insert newer entry with same hash but new ID
|
||||
await store.set(
|
||||
{
|
||||
id: "id-new",
|
||||
hash: "shared-hash-1",
|
||||
promptText: "prompt 1 updated",
|
||||
model: "m",
|
||||
provider: "p",
|
||||
response: { v: 2 },
|
||||
tokensSaved: 15,
|
||||
createdAt: now + 100,
|
||||
expiresAt: now + 60000,
|
||||
},
|
||||
60000
|
||||
);
|
||||
|
||||
// Old entry should be removed from store
|
||||
assert.equal(await store.get("id-old"), null);
|
||||
// New entry should be accessible directly and by hash
|
||||
const byHash = await store.getByHash("shared-hash-1");
|
||||
assert.equal(byHash?.id, "id-new");
|
||||
assert.equal((byHash?.response as { v: number }).v, 2);
|
||||
|
||||
// Deleting the old ID must not delete the hash mapping for the new ID
|
||||
await store.delete("id-old");
|
||||
const byHashAfterOldDelete = await store.getByHash("shared-hash-1");
|
||||
assert.equal(byHashAfterOldDelete?.id, "id-new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RedisVectorStore Stale Set Pruning & Conditional Hash Deletion", () => {
|
||||
it("prunes expired / missing entries from candidate sets and decrements getStats", async () => {
|
||||
const redisStorage = new Map<string, string>();
|
||||
const setStorage = new Map<string, Set<string>>();
|
||||
|
||||
const mockClient: RedisLike = {
|
||||
get: async (k: string) => redisStorage.get(k) || null,
|
||||
set: async (k: string, v: string) => {
|
||||
redisStorage.set(k, v);
|
||||
},
|
||||
del: async (...keys: string[]) => {
|
||||
keys.forEach((k) => redisStorage.delete(k));
|
||||
return keys.length;
|
||||
},
|
||||
sadd: async (k: string, ...members: string[]) => {
|
||||
if (!setStorage.has(k)) setStorage.set(k, new Set());
|
||||
const set = setStorage.get(k)!;
|
||||
members.forEach((m) => set.add(m));
|
||||
return members.length;
|
||||
},
|
||||
srem: async (k: string, ...members: string[]) => {
|
||||
const set = setStorage.get(k);
|
||||
if (!set) return 0;
|
||||
let count = 0;
|
||||
members.forEach((m) => {
|
||||
if (set.delete(m)) count++;
|
||||
});
|
||||
return count;
|
||||
},
|
||||
smembers: async (k: string) => Array.from(setStorage.get(k) || []),
|
||||
mget: async (...keys: string[]) => keys.map((k) => redisStorage.get(k) || null),
|
||||
};
|
||||
|
||||
const store = new RedisVectorStore({ client: mockClient, keyPrefix: "test:" });
|
||||
const now = Date.now();
|
||||
|
||||
// Store entry 1 (active)
|
||||
await store.set(
|
||||
{
|
||||
id: "active-1",
|
||||
hash: "hash-active",
|
||||
embedding: [1, 0],
|
||||
promptText: "active prompt",
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
response: { text: "active" },
|
||||
tokensSaved: 10,
|
||||
createdAt: now,
|
||||
expiresAt: now + 10000,
|
||||
},
|
||||
10000
|
||||
);
|
||||
|
||||
// Store entry 2 (expired)
|
||||
await store.set(
|
||||
{
|
||||
id: "expired-2",
|
||||
hash: "hash-expired",
|
||||
embedding: [1, 0],
|
||||
promptText: "expired prompt",
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
response: { text: "expired" },
|
||||
tokensSaved: 10,
|
||||
createdAt: now - 5000,
|
||||
expiresAt: now - 1000,
|
||||
},
|
||||
-1000
|
||||
);
|
||||
|
||||
// Simulate entry 3 that was deleted from Redis keys directly (e.g. TTL expired naturally in Redis)
|
||||
await mockClient.sadd!("test:all_ids", "ghost-3");
|
||||
await mockClient.sadd!("test:model:gpt-4", "ghost-3");
|
||||
|
||||
// getStats should count only living entries and prune expired/ghost entries
|
||||
const stats = await store.getStats();
|
||||
assert.equal(stats.entries, 1, "Only active-1 should be counted as live");
|
||||
|
||||
// searchNearest should also prune stale entries and return only active-1
|
||||
const nearest = await store.searchNearest([1, 0], { model: "gpt-4" }, 0.8);
|
||||
assert.equal(nearest.length, 1);
|
||||
assert.equal(nearest[0].entry.id, "active-1");
|
||||
});
|
||||
|
||||
it("conditionally deletes hash mapping only if matching the entry being deleted", async () => {
|
||||
const redisStorage = new Map<string, string>();
|
||||
const setStorage = new Map<string, Set<string>>();
|
||||
|
||||
const mockClient: RedisLike = {
|
||||
get: async (k: string) => redisStorage.get(k) || null,
|
||||
set: async (k: string, v: string) => {
|
||||
redisStorage.set(k, v);
|
||||
},
|
||||
del: async (...keys: string[]) => {
|
||||
keys.forEach((k) => redisStorage.delete(k));
|
||||
return keys.length;
|
||||
},
|
||||
sadd: async (k: string, ...members: string[]) => {
|
||||
if (!setStorage.has(k)) setStorage.set(k, new Set());
|
||||
members.forEach((m) => setStorage.get(k)!.add(m));
|
||||
return members.length;
|
||||
},
|
||||
srem: async (k: string, ...members: string[]) => {
|
||||
const set = setStorage.get(k);
|
||||
if (!set) return 0;
|
||||
let count = 0;
|
||||
members.forEach((m) => {
|
||||
if (set.delete(m)) count++;
|
||||
});
|
||||
return count;
|
||||
},
|
||||
smembers: async (k: string) => Array.from(setStorage.get(k) || []),
|
||||
mget: async (...keys: string[]) => keys.map((k) => redisStorage.get(k) || null),
|
||||
};
|
||||
|
||||
const store = new RedisVectorStore({ client: mockClient, keyPrefix: "test:" });
|
||||
const now = Date.now();
|
||||
|
||||
// Store entry 1
|
||||
await store.set(
|
||||
{
|
||||
id: "id-1",
|
||||
hash: "shared-hash",
|
||||
embedding: [1, 0],
|
||||
promptText: "prompt",
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
response: {},
|
||||
tokensSaved: 5,
|
||||
createdAt: now,
|
||||
expiresAt: now + 60000,
|
||||
},
|
||||
60000
|
||||
);
|
||||
|
||||
// Overwrite with entry 2 under same hash
|
||||
await store.set(
|
||||
{
|
||||
id: "id-2",
|
||||
hash: "shared-hash",
|
||||
embedding: [1, 0],
|
||||
promptText: "prompt",
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
response: {},
|
||||
tokensSaved: 5,
|
||||
createdAt: now + 10,
|
||||
expiresAt: now + 60000,
|
||||
},
|
||||
60000
|
||||
);
|
||||
|
||||
// Now attempt to delete id-1 (older entry)
|
||||
await store.delete("id-1");
|
||||
|
||||
// The hash mapping in Redis must STILL point to id-2!
|
||||
const hashKey = "test:hash:shared-hash";
|
||||
assert.equal(
|
||||
redisStorage.get(hashKey),
|
||||
"id-2",
|
||||
"Hash mapping must not be deleted by older entry deletion"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -80,3 +80,45 @@ test("does not cache a streaming response truncated by max_tokens", () => {
|
||||
);
|
||||
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(
|
||||
{
|
||||
enabled: true,
|
||||
streamStatus: 200,
|
||||
streamResponseBody: {
|
||||
choices: [{ finish_reason: "length", message: { content: "partial" } }],
|
||||
},
|
||||
body: { messages: [{ role: "user", content: "hi" }], temperature: 0 },
|
||||
headers: undefined,
|
||||
model: "gemini-3.5-flash",
|
||||
streamUsage: { prompt_tokens: 10, completion_tokens: 93 },
|
||||
},
|
||||
deps(stored)
|
||||
);
|
||||
assert.equal(stored.length, 0, "truncated assembled streaming body must not be cached");
|
||||
});
|
||||
|
||||
test("still caches a complete assembled streaming body", () => {
|
||||
const stored: unknown[] = [];
|
||||
storeStreamingSemanticCacheResponse(
|
||||
{
|
||||
enabled: true,
|
||||
streamStatus: 200,
|
||||
streamResponseBody: {
|
||||
choices: [{ finish_reason: "stop", message: { content: "complete" } }],
|
||||
},
|
||||
body: { messages: [{ role: "user", content: "hi" }], temperature: 0 },
|
||||
headers: undefined,
|
||||
model: "gemini-3.5-flash",
|
||||
streamUsage: { prompt_tokens: 10, completion_tokens: 239 },
|
||||
},
|
||||
deps(stored)
|
||||
);
|
||||
assert.equal(stored.length, 1, "complete streaming response must still be cached");
|
||||
});
|
||||
|
||||
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