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.
- package/.env.example +83 -4
- package/README.md +154 -15
- package/cache.js +133 -9
- package/cascade.js +206 -0
- package/coalescing.js +65 -0
- package/embeddings.js +78 -16
- package/guardrails.js +80 -0
- package/metrics.js +89 -5
- package/package.json +12 -2
- package/pii.js +128 -0
- package/providers/deepseek.js +194 -0
- package/providers/index.js +62 -0
- package/providers/openai.js +10 -2
- package/providers/openrouter.js +233 -0
- package/public/dashboard.html +1 -0
- package/router.js +33 -4
- package/semanticCache.js +115 -3
- package/server.js +494 -118
- package/tracing.js +97 -0
|
@@ -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
|
+
};
|
package/public/dashboard.html
CHANGED
|
@@ -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
|
-
|
|
232
|
+
avgQualityScore: providerStat.avgQualityScore,
|
|
233
|
+
shed,
|
|
234
|
+
healthy: !hasEnoughSamples || providerStat.errorRate < UNHEALTHY_ERROR_RATE
|
|
215
235
|
};
|
|
216
236
|
});
|
|
217
237
|
|
|
218
|
-
|
|
219
|
-
const
|
|
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
|
-
|
|
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({
|
|
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
|
};
|