cachegate 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ // model-router/providers/openrouter.js
2
+ //
3
+ // OpenRouter is an aggregator: one OpenAI-compatible endpoint in front of many
4
+ // vendors, addressed by `vendor/model` ids (e.g. `deepseek/deepseek-chat`,
5
+ // `anthropic/claude-sonnet-4.5`). Two things make it different from the direct
6
+ // providers in this directory:
7
+ //
8
+ // 1. ATTRIBUTION HEADERS. Requests may carry HTTP-Referer and X-Title so the
9
+ // app shows up correctly in OpenRouter's own dashboard. They are optional
10
+ // on the wire, so they are only sent when configured - inventing a
11
+ // referer for someone's deployment would be worse than omitting it.
12
+ //
13
+ // 2. PRICING CANNOT BE A CONSTANT TABLE HERE. providers/openai.js hardcodes
14
+ // two rates and providers/deepseek.js a handful, which is defensible for a
15
+ // vendor with a handful of models. OpenRouter fronts hundreds, and their
16
+ // prices change without a release of this project - a hardcoded table
17
+ // would rot into wrong money silently, which is the one failure mode this
18
+ // project's cost tracking exists to prevent. So the catalog is fetched
19
+ // from OpenRouter's own /models endpoint and cached in-process.
20
+ //
21
+ // WHEN PRICING IS UNKNOWN, cost is null - NEVER 0. A zero would be read as
22
+ // "free" by anything that sorts candidates by cost, and this router's whole
23
+ // pitch is routing to the cheapest healthy provider; a silent zero would
24
+ // route everything to whichever model we failed to price. Callers must
25
+ // treat null as "unknown, do not compare" (see providers/index.js).
26
+ const { OpenAI } = require('openai');
27
+
28
+ const BASE_URL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
29
+ const MODELS_PATH = '/models';
30
+ const PRICING_TTL_MS = Number(process.env.OPENROUTER_PRICING_TTL_MS) || 6 * 60 * 60 * 1000;
31
+
32
+ // model id -> { input, output } in USD per 1M tokens. Populated by
33
+ // refreshPricing() (network) or setPricingTable() (tests / a pinned table).
34
+ let pricingTable = null;
35
+ let pricingFetchedAt = 0;
36
+
37
+ /**
38
+ * OpenRouter's documented optional attribution headers, from the environment.
39
+ * A pure function so the "only send what was configured" rule is testable
40
+ * without constructing an SDK client.
41
+ */
42
+ function attributionHeaders(env = process.env) {
43
+ const headers = {};
44
+ if (env.OPENROUTER_SITE_URL) headers['HTTP-Referer'] = env.OPENROUTER_SITE_URL;
45
+ if (env.OPENROUTER_SITE_NAME) headers['X-Title'] = env.OPENROUTER_SITE_NAME;
46
+ return headers;
47
+ }
48
+
49
+ function buildClient(apiKey) {
50
+ const headers = attributionHeaders();
51
+
52
+ return new OpenAI({
53
+ apiKey,
54
+ baseURL: BASE_URL,
55
+ defaultHeaders: Object.keys(headers).length ? headers : undefined,
56
+ timeout: Number(process.env.OPENROUTER_TIMEOUT_MS) || 60000
57
+ });
58
+ }
59
+
60
+ // `vendor/model`, optionally written as `openrouter/vendor/model` so a caller
61
+ // can be explicit. Anything else is not an OpenRouter id.
62
+ function isOpenRouterModel(model) {
63
+ if (typeof model !== 'string') return false;
64
+ if (model.startsWith('openrouter/')) return true;
65
+ return /^[a-z0-9][a-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(model);
66
+ }
67
+
68
+ function normalizeModel(model) {
69
+ return typeof model === 'string' && model.startsWith('openrouter/') ? model.slice('openrouter/'.length) : model;
70
+ }
71
+
72
+ /** OpenRouter reports per-token prices as strings; convert to per-1M USD. */
73
+ function toPerMillion(value) {
74
+ const n = Number(value);
75
+ return Number.isFinite(n) ? n * 1_000_000 : null;
76
+ }
77
+
78
+ function setPricingTable(entries) {
79
+ // setPricingTable(null) resets to "price unknown" - what a test, or a caller
80
+ // forcing a refresh, needs. An empty object would instead be a loaded-but-
81
+ // empty table, which reads as "every model is unpriced" rather than "not
82
+ // loaded yet" and would make the null-vs-zero distinction untestable.
83
+ if (!entries) { pricingTable = null; pricingFetchedAt = 0; return null; }
84
+ const table = {};
85
+ for (const e of entries || []) {
86
+ const id = e && (e.id || e.model);
87
+ if (!id || !e.pricing) continue;
88
+ const input = toPerMillion(e.pricing.prompt ?? e.pricing.input);
89
+ const output = toPerMillion(e.pricing.completion ?? e.pricing.output);
90
+ if (input === null) continue;
91
+ table[id] = { input, output: output === null ? input : output };
92
+ }
93
+ pricingTable = table;
94
+ pricingFetchedAt = Date.now();
95
+ return table;
96
+ }
97
+
98
+ async function refreshPricing({ fetchImpl, force = false } = {}) {
99
+ if (!force && pricingTable && (Date.now() - pricingFetchedAt) < PRICING_TTL_MS) return pricingTable;
100
+ const doFetch = fetchImpl || globalThis.fetch;
101
+ if (typeof doFetch !== 'function') return pricingTable;
102
+ try {
103
+ const res = await doFetch(`${BASE_URL}${MODELS_PATH}`);
104
+ if (!res || !res.ok) return pricingTable;
105
+ const body = await res.json();
106
+ setPricingTable(body && body.data);
107
+ } catch {
108
+ // A pricing fetch failure must never break dispatch - the request itself
109
+ // does not need prices. Cost comes back null and is reported as unknown.
110
+ }
111
+ return pricingTable;
112
+ }
113
+
114
+ /**
115
+ * USD for one call, or null when the model's price is not known yet.
116
+ * (null, not 0 - see the header.)
117
+ */
118
+ function estimateCost(model, inputTokens, outputTokens) {
119
+ if (!pricingTable) return null;
120
+ const rate = pricingTable[normalizeModel(model)];
121
+ if (!rate) return null;
122
+ return (((inputTokens || 0) * rate.input) + ((outputTokens || 0) * rate.output)) / 1_000_000;
123
+ }
124
+
125
+ function usageFrom(usage = {}) {
126
+ return {
127
+ input_tokens: usage.prompt_tokens || 0,
128
+ output_tokens: usage.completion_tokens || 0,
129
+ // Some upstreams behind OpenRouter report their own cache tiers. Passed
130
+ // through when present, never invented when absent.
131
+ ...(typeof usage.prompt_tokens_details?.cached_tokens === 'number'
132
+ && { cached_input_tokens: usage.prompt_tokens_details.cached_tokens })
133
+ };
134
+ }
135
+
136
+ async function chat(client, payload, options = {}) {
137
+ const request = {
138
+ model: normalizeModel(payload.model),
139
+ messages: payload.messages,
140
+ temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
141
+ max_tokens: payload.max_tokens || 1024,
142
+ ...(payload.tools && { tools: payload.tools }),
143
+ ...(payload.tool_choice && { tool_choice: payload.tool_choice }),
144
+ ...(payload.response_format && { response_format: payload.response_format }),
145
+ // Ask for the upstream provider's identity + cost accounting in the same
146
+ // response, so the dashboard can attribute spend per vendor.
147
+ usage: { include: true },
148
+ ...(options.requestLogprobs && { logprobs: true, top_logprobs: 1 })
149
+ };
150
+
151
+ const start = Date.now();
152
+ const response = await client.chat.completions.create(request);
153
+ const latencyMs = Date.now() - start;
154
+
155
+ const choice = response.choices[0];
156
+ const usage = usageFrom(response.usage);
157
+ // Prefer OpenRouter's own accounting when it sends it: it is the billed
158
+ // amount, including any provider-specific pricing we did not model.
159
+ const billed = typeof response.usage?.cost === 'number' ? response.usage.cost : null;
160
+ const costUsd = billed !== null ? billed : estimateCost(payload.model, usage.input_tokens, usage.output_tokens);
161
+
162
+ return {
163
+ provider: 'openrouter',
164
+ model: payload.model,
165
+ latency_ms: latencyMs,
166
+ usage,
167
+ cost_usd: costUsd,
168
+ content: choice.message.content || '',
169
+ tool_calls: choice.message.tool_calls,
170
+ // Which upstream actually served it (OpenRouter routes among several).
171
+ upstream_provider: response.provider || undefined,
172
+ raw: response
173
+ };
174
+ }
175
+
176
+ function applyStreamChunk(state, chunk, onDelta) {
177
+ const choice = chunk.choices && chunk.choices[0];
178
+ if (choice && choice.delta && choice.delta.content) {
179
+ state.content += choice.delta.content;
180
+ onDelta(choice.delta.content);
181
+ }
182
+ if (chunk.usage) {
183
+ state.inputTokens = chunk.usage.prompt_tokens || 0;
184
+ state.outputTokens = chunk.usage.completion_tokens || 0;
185
+ if (typeof chunk.usage.cost === 'number') state.billedCost = chunk.usage.cost;
186
+ if (typeof chunk.usage.prompt_tokens_details?.cached_tokens === 'number') {
187
+ state.cachedInputTokens = chunk.usage.prompt_tokens_details.cached_tokens;
188
+ }
189
+ }
190
+ }
191
+
192
+ async function chatStream(client, payload, { onDelta, signal } = {}) {
193
+ const request = {
194
+ model: normalizeModel(payload.model),
195
+ messages: payload.messages,
196
+ temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
197
+ max_tokens: payload.max_tokens || 1024,
198
+ stream: true,
199
+ stream_options: { include_usage: true },
200
+ usage: { include: true }
201
+ };
202
+
203
+ const start = Date.now();
204
+ const stream = await client.chat.completions.create(request, signal ? { signal } : undefined);
205
+
206
+ const state = { content: '', inputTokens: 0, outputTokens: 0, billedCost: null, cachedInputTokens: 0 };
207
+ for await (const chunk of stream) {
208
+ applyStreamChunk(state, chunk, onDelta || (() => {}));
209
+ }
210
+
211
+ const latencyMs = Date.now() - start;
212
+ const usage = usageFrom({
213
+ prompt_tokens: state.inputTokens,
214
+ completion_tokens: state.outputTokens,
215
+ ...(state.cachedInputTokens ? { prompt_tokens_details: { cached_tokens: state.cachedInputTokens } } : {})
216
+ });
217
+ const costUsd = state.billedCost !== null ? state.billedCost : estimateCost(payload.model, usage.input_tokens, usage.output_tokens);
218
+
219
+ return {
220
+ provider: 'openrouter',
221
+ model: payload.model,
222
+ latency_ms: latencyMs,
223
+ usage,
224
+ cost_usd: costUsd,
225
+ content: state.content,
226
+ tool_calls: undefined
227
+ };
228
+ }
229
+
230
+ module.exports = {
231
+ buildClient, chat, chatStream, applyStreamChunk, estimateCost,
232
+ isOpenRouterModel, normalizeModel, refreshPricing, setPricingTable, attributionHeaders, BASE_URL
233
+ };
@@ -594,6 +594,7 @@
594
594
  function renderKpis(data) {
595
595
  var tiles = [
596
596
  { label: 'Total cost (' + data.days + 'd)', value: formatUsd(data.total_cost_usd), spark: data.daily.map(function (d) { return d.cost_usd; }) },
597
+ { label: 'Saved (' + data.days + 'd)', value: formatUsd(data.saved_usd), spark: data.daily.map(function (d) { return d.saved_usd; }) },
597
598
  { label: 'Total requests', value: formatCount(data.sample_size) },
598
599
  { label: 'Exact hit rate', value: formatPercent(data.cache_hit_rate.exact) },
599
600
  { label: 'Semantic hit rate', value: formatPercent(data.cache_hit_rate.semantic) },
package/router.js CHANGED
@@ -147,6 +147,16 @@ const COMPARISON_OUTPUT_TOKENS = 500;
147
147
  // (in which case we still have to pick one - see pickCandidate).
148
148
  const UNHEALTHY_ERROR_RATE = 0.5;
149
149
 
150
+ // Step 33 (health scoring + circuit breakers): a second, STRICTER tier
151
+ // above "unhealthy" - "shed". A candidate at or above SHED_ERROR_RATE is
152
+ // clearly down (not just having a rough patch), so it's fully excluded
153
+ // this request rather than merely deprioritized. avgQualityScore (step 32)
154
+ // is a second, independent confirming signal: a candidate that keeps
155
+ // needing failover to nominally "succeed" sheds even before its raw
156
+ // error-rate count crosses the threshold. Both thresholds are env-tunable.
157
+ const SHED_ERROR_RATE = Number(process.env.ROUTER_SHED_ERROR_RATE) || 0.9;
158
+ const SHED_QUALITY_SCORE = Number(process.env.ROUTER_SHED_QUALITY_SCORE) || 0.2;
159
+
150
160
  // Minimum number of recent requests before a provider's error rate is
151
161
  // treated as meaningful. Without this, a brand-new provider (or one whose
152
162
  // traffic just resumed) with a SINGLE request that happened to error has
@@ -205,18 +215,36 @@ async function pickCandidate(virtualModel, scope) {
205
215
  const estimatedCostUsd = estimate
206
216
  ? estimate(candidate.model, COMPARISON_INPUT_TOKENS, COMPARISON_OUTPUT_TOKENS)
207
217
  : Infinity;
208
- const providerStat = stats[candidate.provider] || { errorRate: 0, avgLatencyMs: null, sampleSize: 0 };
218
+ const providerStat = stats[candidate.provider] || { errorRate: 0, avgLatencyMs: null, avgQualityScore: null, sampleSize: 0 };
219
+ const hasEnoughSamples = providerStat.sampleSize >= MIN_HEALTH_SAMPLES;
220
+ // shed = clearly down (33.1): high error rate, OR low avgQualityScore
221
+ // (keeps failing over to "succeed"). avgQualityScore is null when there
222
+ // is no quality data yet - insufficient data must never shed.
223
+ const shed =
224
+ hasEnoughSamples &&
225
+ (providerStat.errorRate >= SHED_ERROR_RATE ||
226
+ (typeof providerStat.avgQualityScore === 'number' && providerStat.avgQualityScore < SHED_QUALITY_SCORE));
209
227
  return {
210
228
  ...candidate,
211
229
  estimatedCostUsd,
212
230
  errorRate: providerStat.errorRate,
213
231
  avgLatencyMs: providerStat.avgLatencyMs,
214
- healthy: providerStat.sampleSize < MIN_HEALTH_SAMPLES || providerStat.errorRate < UNHEALTHY_ERROR_RATE
232
+ avgQualityScore: providerStat.avgQualityScore,
233
+ shed,
234
+ healthy: !hasEnoughSamples || providerStat.errorRate < UNHEALTHY_ERROR_RATE
215
235
  };
216
236
  });
217
237
 
218
- const healthy = scored.filter((c) => c.healthy);
219
- const pool = healthy.length > 0 ? healthy : scored; // all unhealthy: pick the least-bad rather than fail outright
238
+ // 33.1: shed the clearly-down candidates first (stricter than unhealthy).
239
+ const notShed = scored.filter((c) => !c.shed);
240
+ // 33.2: all-down fallback - if every candidate is shed, fall back to the
241
+ // full list and try anyway (least-bad rather than fail outright), the
242
+ // same philosophy as the all-unhealthy fallback below.
243
+ const afterShed = notShed.length > 0 ? notShed : scored;
244
+ // Existing unhealthy filter (a rough patch, 0.5 <= errorRate < 0.9) on
245
+ // what remains after shedding.
246
+ const healthy = afterShed.filter((c) => c.healthy);
247
+ const pool = healthy.length > 0 ? healthy : afterShed; // all unhealthy: pick the least-bad rather than fail outright
220
248
 
221
249
  const strategy = loadStrategy();
222
250
  let ranked;
@@ -245,6 +273,7 @@ async function pickCandidate(virtualModel, scope) {
245
273
  consideredTier: virtualModel,
246
274
  strategy,
247
275
  candidates: scored,
276
+ shedExcludedACandidate: notShed.length < scored.length,
248
277
  allUnhealthy: healthy.length === 0,
249
278
  latencyGuardExcludedACandidate: guardApplied
250
279
  }
package/semanticCache.js CHANGED
@@ -20,6 +20,19 @@
20
20
  // meant to scale past that cap. A real vector index is the honest next
21
21
  // step if traffic outgrows it.
22
22
  //
23
+ // What that brute force actually COSTS was worth measuring rather than
24
+ // assuming, and it was not the arithmetic: each entry used to carry its
25
+ // embedding as JSON text, so a 1536-dim vector arrived as ~30 KB of
26
+ // digits and a lookup parsed up to 200 of them - 5.89 MB of JSON
27
+ // number-parsing per semantic miss, against 200 x 1536 multiply-adds to
28
+ // score them (48.7 ms, measured). The vector is now stored as base64
29
+ // Float32 with its L2 norm beside it: 1.58 MB and 3.6 ms for the same
30
+ // work, 13x faster. Entries written before this change are still read
31
+ // correctly - both formats live in the same list until the old ones age
32
+ // out - and the cap, the threshold and the honesty framing above are
33
+ // unchanged. This is an encoding improvement, NOT the vector index the
34
+ // last paragraph is still waiting for.
35
+ //
23
36
  // The threshold is a probabilistic judgment call, not a guarantee: a
24
37
  // "hit" above the threshold is the router's best guess that two
25
38
  // prompts want the same answer, not proof they do. Set it too low and
@@ -32,6 +45,15 @@
32
45
 
33
46
  const redis = require('./redisClient');
34
47
  const embeddingsDefault = require('./embeddings');
48
+ // The answer-shape definition is shared with the exact cache ON PURPOSE: the two paths diverged once -
49
+ // cache.js keyed response_format and this file never did - and a field added to one of them silently
50
+ // stops being enforced by the other. No cycle: cache.js does not require this file.
51
+ const { shapeFields } = require('./cache');
52
+
53
+ // The shape of a request that demands nothing about the answer's form: no response_format, no tools,
54
+ // no tool_choice, no seed. The semantic shape gate uses it to decide whether an entry carrying no
55
+ // recorded shape may still be served - see the gate in findMatch.
56
+ const SHAPE_OF_NEUTRAL = JSON.stringify(shapeFields({}));
35
57
 
36
58
  const MAX_CANDIDATES_PER_MODEL = Number(process.env.SEMANTIC_CACHE_MAX_CANDIDATES) || 200;
37
59
  const DEFAULT_TTL_SECONDS = Number(process.env.SEMANTIC_CACHE_TTL_SECONDS) || 3600;
@@ -45,6 +67,44 @@ function listKey(scope, model) {
45
67
  return scope != null ? `SEMANTIC_LIST:${scope}:${model}` : `SEMANTIC_LIST:${model}`;
46
68
  }
47
69
 
70
+ // Embeddings are stored as base64 Float32 rather than JSON numbers. Float32 loses ~7 significant digits
71
+ // versus the JSON doubles the old format held, and cosine similarity over 1536 dimensions cannot see the
72
+ // difference - test/semanticCache.test.js asserts both paths agree, and the legacy branch below is what
73
+ // keeps entries written before this change matching correctly.
74
+ function encodeEmbedding(vec) {
75
+ const f = Float32Array.from(vec);
76
+ return Buffer.from(f.buffer, f.byteOffset, f.byteLength).toString('base64');
77
+ }
78
+
79
+ function decodeEmbedding(record) {
80
+ const e = record && record.embedding;
81
+ if (Array.isArray(e)) return e; // legacy: JSON doubles
82
+ if (typeof e !== 'string') return null;
83
+ const buf = Buffer.from(e, 'base64');
84
+ // A Float32 vector is 4 bytes per element; anything else is not one. Checked rather than assumed so a
85
+ // corrupt or truncated entry is skipped (as a malformed JSON entry already was) instead of producing a
86
+ // wrong-length array that silently scores 0 against everything.
87
+ if (buf.length === 0 || buf.length % 4 !== 0) return null;
88
+ return new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4);
89
+ }
90
+
91
+ function normOf(v) {
92
+ let n = 0;
93
+ for (let i = 0; i < v.length; i++) n += v[i] * v[i];
94
+ return Math.sqrt(n);
95
+ }
96
+
97
+ // Same result as cosineSimilarity(), given the two norms the caller already has or has stored. The stored
98
+ // norm is the point: without it, every lookup recomputes the norm of every candidate - a third of the float
99
+ // work per candidate, for a value that never changes.
100
+ function similarityWithNorms(a, aNorm, b, bNorm) {
101
+ if (!a || !b || a.length !== b.length) return 0;
102
+ if (aNorm === 0 || bNorm === 0) return 0;
103
+ let dot = 0;
104
+ for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
105
+ return dot / (aNorm * bNorm);
106
+ }
107
+
48
108
  function cosineSimilarity(a, b) {
49
109
  if (!a || !b || a.length !== b.length) return 0;
50
110
  let dot = 0;
@@ -79,6 +139,11 @@ function isEnabled(embeddings = embeddingsDefault) {
79
139
  // approximate text match can't guarantee the exact argument values a
80
140
  // tool call needs, and returning a plausible-but-wrong tool call is a
81
141
  // worse failure than a cache miss.
142
+ //
143
+ // response_format is NOT excluded, it is FILTERED (see findMatch): the
144
+ // answer is still worth caching, it just may not be served to a caller
145
+ // who asked for a different shape. Excluding it outright would throw away
146
+ // hit rate to solve a matching problem.
82
147
  function isCacheable(payload) {
83
148
  return !payload.tools;
84
149
  }
@@ -86,6 +151,11 @@ function isCacheable(payload) {
86
151
  async function findMatch(scope, payload, { threshold = DEFAULT_THRESHOLD, embeddings = embeddingsDefault } = {}) {
87
152
  if (!isEnabled(embeddings) || !isCacheable(payload)) return null;
88
153
 
154
+ // The shape this request needs. A semantic match is approximate about the PROMPT, never about the
155
+ // answer's shape: a json_object caller served a cached plain-text answer fails to parse it, which
156
+ // surfaces as an upstream outage rather than a cache miss. Same reasoning that excludes tool calls.
157
+ const shape = JSON.stringify(shapeFields(payload));
158
+
89
159
  let queryEmbedding;
90
160
  try {
91
161
  queryEmbedding = await embeddings.embed(extractPromptText(payload));
@@ -102,7 +172,9 @@ async function findMatch(scope, payload, { threshold = DEFAULT_THRESHOLD, embedd
102
172
  return null;
103
173
  }
104
174
 
175
+ const queryNorm = normOf(queryEmbedding);
105
176
  let best = null;
177
+ let shapeSkipped = 0;
106
178
  for (const line of raw) {
107
179
  let record;
108
180
  try {
@@ -110,11 +182,36 @@ async function findMatch(scope, payload, { threshold = DEFAULT_THRESHOLD, embedd
110
182
  } catch {
111
183
  continue; // a malformed entry is skipped, not fatal
112
184
  }
113
- const similarity = cosineSimilarity(queryEmbedding, record.embedding);
185
+ // Shape gate. Refuse a candidate that cannot PROVE the shape matches.
186
+ //
187
+ // The asymmetry is deliberate, and it reconciles two requirements that both have to hold:
188
+ // * A request that demands nothing about the answer's shape can consume either form, so an entry
189
+ // with no recorded shape - stored before this gate existed - is still served. That is what
190
+ // keeps "an upgrade is not a cache flush" true for the plain-prose case, which is the vast
191
+ // majority of traffic.
192
+ // * A request that DOES demand a shape may only be served by an entry that proves it matches.
193
+ // Prose handed to a json_object caller fails to parse in the caller and surfaces as an upstream
194
+ // outage - worse than a miss - and that direction is the entire reason this gate exists.
195
+ // A recorded shape that differs is refused in both directions: the reverse (json handed to a prose
196
+ // caller) does not crash anything, but it is still the wrong answer to the question asked.
197
+ const shapeNeutral = shape === SHAPE_OF_NEUTRAL;
198
+ if (!(record.shape === shape || (record.shape === undefined && shapeNeutral))) {
199
+ shapeSkipped += 1;
200
+ continue;
201
+ }
202
+ const vector = decodeEmbedding(record);
203
+ if (!vector) continue; // ditto an entry whose vector cannot be read
204
+ const candidateNorm = typeof record.norm === 'number' ? record.norm : normOf(vector);
205
+ const similarity = similarityWithNorms(queryEmbedding, queryNorm, vector, candidateNorm);
114
206
  if (similarity >= threshold && (!best || similarity > best.similarity)) {
115
207
  best = { entry: record.entry, similarity };
116
208
  }
117
209
  }
210
+ if (!best && shapeSkipped > 0) {
211
+ // Visible, because "the semantic cache stopped hitting" otherwise looks like a tuning problem
212
+ // rather than the shape gate doing its job.
213
+ console.log(`[semantic] ${shapeSkipped} candidate(s) skipped: answer shape differs from this request`);
214
+ }
118
215
  return best;
119
216
  }
120
217
 
@@ -130,8 +227,17 @@ async function store(scope, payload, entry, { ttlSeconds = DEFAULT_TTL_SECONDS,
130
227
  }
131
228
 
132
229
  const key = listKey(scope, payload.model);
230
+ // The shape is stored BESIDE the entry rather than folded into the key: matching here is by
231
+ // embedding, so there is no key to fold it into. findMatch refuses a candidate whose shape differs.
232
+ const shape = JSON.stringify(shapeFields(payload));
133
233
  try {
134
- await redis.client.lPush(key, JSON.stringify({ embedding, entry, storedAt: Date.now() }));
234
+ await redis.client.lPush(key, JSON.stringify({
235
+ embedding: encodeEmbedding(embedding),
236
+ norm: normOf(embedding),
237
+ shape,
238
+ entry,
239
+ storedAt: Date.now()
240
+ }));
135
241
  await redis.client.lTrim(key, 0, MAX_CANDIDATES_PER_MODEL - 1);
136
242
  // A rolling TTL on the whole per-model bucket, reset on every
137
243
  // store - simple and predictable (as long as there's traffic to
@@ -155,5 +261,11 @@ module.exports = {
155
261
  listKey,
156
262
  cosineSimilarity,
157
263
  extractPromptText,
158
- DEFAULT_THRESHOLD
264
+ DEFAULT_THRESHOLD,
265
+ // exported for test/semanticCache.test.js: the storage format is a compatibility surface (both formats
266
+ // must keep matching), so its round-trip is asserted rather than assumed
267
+ encodeEmbedding,
268
+ decodeEmbedding,
269
+ normOf,
270
+ similarityWithNorms
159
271
  };