cachegate 1.1.1 → 1.2.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 +127 -112
- package/README.md +31 -13
- package/cache.js +72 -51
- package/embeddings.js +42 -32
- package/metrics.js +609 -556
- package/package.json +15 -1
- package/redisClient.js +55 -45
- package/router.js +254 -218
- package/semanticCache.js +159 -154
- package/server.js +282 -113
- package/.dockerignore +0 -11
- package/.gitattributes +0 -12
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -33
- package/.github/ISSUE_TEMPLATE/config.yml +0 -5
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -29
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -25
- package/.github/workflows/test.yml +0 -63
- package/CODE_OF_CONDUCT.md +0 -66
- package/CONTRIBUTING.md +0 -94
- package/Dockerfile +0 -24
- package/SECURITY.md +0 -39
- package/sync-oss-release.sh +0 -160
- package/test/auth-config.test.js +0 -27
- package/test/cache.test.js +0 -33
- package/test/embeddings.test.js +0 -24
- package/test/env-path.test.js +0 -41
- package/test/failover.test.js +0 -99
- package/test/metrics-postgres.test.js +0 -183
- package/test/metrics.test.js +0 -282
- package/test/router.test.js +0 -195
- package/test/semanticCache.test.js +0 -167
- package/test/server.test.js +0 -357
- package/test/streaming.test.js +0 -248
package/package.json
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cachegate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Self-hostable, OpenAI-compatible LLM proxy: routes to the cheapest healthy provider, caches responses exactly and semantically, tracks cost and latency per call.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"cachegate": "./server.js"
|
|
9
9
|
},
|
|
10
|
+
"files": [
|
|
11
|
+
"server.js",
|
|
12
|
+
"cache.js",
|
|
13
|
+
"embeddings.js",
|
|
14
|
+
"failover.js",
|
|
15
|
+
"metrics.js",
|
|
16
|
+
"redisClient.js",
|
|
17
|
+
"router.js",
|
|
18
|
+
"semanticCache.js",
|
|
19
|
+
"streaming.js",
|
|
20
|
+
"providers/",
|
|
21
|
+
"public/",
|
|
22
|
+
".env.example"
|
|
23
|
+
],
|
|
10
24
|
"engines": {
|
|
11
25
|
"node": ">=18.0.0"
|
|
12
26
|
},
|
package/redisClient.js
CHANGED
|
@@ -1,45 +1,55 @@
|
|
|
1
|
-
// model-router/redisClient.js
|
|
2
|
-
//
|
|
3
|
-
// One shared Redis connection, used by both the exact-match cache
|
|
4
|
-
// (cache.js) and the semantic cache (semanticCache.js). Previously
|
|
5
|
-
// cache.js opened and owned this connection privately; pulled out here
|
|
6
|
-
// so the semantic cache doesn't open a second connection to the same
|
|
7
|
-
// Redis instance for the same purpose.
|
|
8
|
-
|
|
9
|
-
const { createClient } = require('redis');
|
|
10
|
-
|
|
11
|
-
const client = createClient({
|
|
12
|
-
url: process.env.REDIS_URL || 'redis://localhost:6379'
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
client.on('error', (err) => {
|
|
16
|
-
console.warn('⚠️ Redis client error:', err?.message || err?.code || String(err));
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
let readyResolve;
|
|
20
|
-
// Resolves once the initial connection attempt finishes, success or
|
|
21
|
-
// failure - tests await this instead of polling isConnected() in a
|
|
22
|
-
// loop. Normal request handling doesn't need it: get/set/find/store all
|
|
23
|
-
// already check isConnected() and degrade gracefully.
|
|
24
|
-
const ready = new Promise((resolve) => { readyResolve = resolve; });
|
|
25
|
-
|
|
26
|
-
(async () => {
|
|
27
|
-
try {
|
|
28
|
-
if (process.env.REDIS_URL && !client.isOpen) {
|
|
29
|
-
await client.connect();
|
|
30
|
-
console.log('✅ Model Router connected to Redis');
|
|
31
|
-
} else if (!process.env.REDIS_URL) {
|
|
32
|
-
console.warn('⚠️ REDIS_URL not set. Caching disabled.');
|
|
33
|
-
}
|
|
34
|
-
} catch (err) {
|
|
35
|
-
console.warn('⚠️ Failed to connect to Redis:', err.message);
|
|
36
|
-
} finally {
|
|
37
|
-
readyResolve();
|
|
38
|
-
}
|
|
39
|
-
})();
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
1
|
+
// model-router/redisClient.js
|
|
2
|
+
//
|
|
3
|
+
// One shared Redis connection, used by both the exact-match cache
|
|
4
|
+
// (cache.js) and the semantic cache (semanticCache.js). Previously
|
|
5
|
+
// cache.js opened and owned this connection privately; pulled out here
|
|
6
|
+
// so the semantic cache doesn't open a second connection to the same
|
|
7
|
+
// Redis instance for the same purpose.
|
|
8
|
+
|
|
9
|
+
const { createClient } = require('redis');
|
|
10
|
+
|
|
11
|
+
const client = createClient({
|
|
12
|
+
url: process.env.REDIS_URL || 'redis://localhost:6379'
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
client.on('error', (err) => {
|
|
16
|
+
console.warn('⚠️ Redis client error:', err?.message || err?.code || String(err));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let readyResolve;
|
|
20
|
+
// Resolves once the initial connection attempt finishes, success or
|
|
21
|
+
// failure - tests await this instead of polling isConnected() in a
|
|
22
|
+
// loop. Normal request handling doesn't need it: get/set/find/store all
|
|
23
|
+
// already check isConnected() and degrade gracefully.
|
|
24
|
+
const ready = new Promise((resolve) => { readyResolve = resolve; });
|
|
25
|
+
|
|
26
|
+
(async () => {
|
|
27
|
+
try {
|
|
28
|
+
if (process.env.REDIS_URL && !client.isOpen) {
|
|
29
|
+
await client.connect();
|
|
30
|
+
console.log('✅ Model Router connected to Redis');
|
|
31
|
+
} else if (!process.env.REDIS_URL) {
|
|
32
|
+
console.warn('⚠️ REDIS_URL not set. Caching disabled.');
|
|
33
|
+
}
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.warn('⚠️ Failed to connect to Redis:', err.message);
|
|
36
|
+
} finally {
|
|
37
|
+
readyResolve();
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
|
|
41
|
+
// node-redis v4: `isOpen` is true for the client's ENTIRE lifetime,
|
|
42
|
+
// including the automatic-reconnect loop after the socket dies - gating
|
|
43
|
+
// cache reads/writes on it lets every command queue on a dead socket and
|
|
44
|
+
// hang the request instead of failing open. `isReady` is true only when
|
|
45
|
+
// a command can actually execute right now. Live-verified in the
|
|
46
|
+
// Cachegate Cloud build (its PR #12 review): with isOpen, a stopped
|
|
47
|
+
// Redis hung /v1 requests for 2+ minutes; with isReady the same request
|
|
48
|
+
// returned in 11ms, correctly skipping the cache. This is the backport
|
|
49
|
+
// of that fix - the cloud vendored this engine and fixed its copy first;
|
|
50
|
+
// the engine and the public cachegate repo still shipped the bug.
|
|
51
|
+
function isConnected() {
|
|
52
|
+
return client.isReady;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { client, isConnected, ready };
|
package/router.js
CHANGED
|
@@ -1,218 +1,254 @@
|
|
|
1
|
-
// model-router/router.js
|
|
2
|
-
//
|
|
3
|
-
// The actual routing decision. Before this file existed, "the router"
|
|
4
|
-
// only dispatched: it read the model name the caller already sent
|
|
5
|
-
// (e.g. "claude-sonnet-4-5-20250929") and forwarded to whichever
|
|
6
|
-
// provider owns that name. That's not routing - the caller had already
|
|
7
|
-
// made the choice. This file adds the thing the product is named for:
|
|
8
|
-
// given a REQUEST FOR A CAPABILITY (not a specific vendor's model), pick
|
|
9
|
-
// a currently-healthy provider that can serve it - by cost (default),
|
|
10
|
-
// by latency, or by cost with a latency guard rail; see ROUTER_STRATEGY
|
|
11
|
-
// below for what each one actually does and doesn't guarantee.
|
|
12
|
-
//
|
|
13
|
-
// Backward compatibility is deliberate: a caller that already names a
|
|
14
|
-
// concrete model (any name not starting with "router:") is dispatched
|
|
15
|
-
// exactly as before, unchanged, in server.js. Nothing here overrides an
|
|
16
|
-
// explicit choice - virtual models are opt-in.
|
|
17
|
-
|
|
18
|
-
const anthropicProvider = require('./providers/anthropic');
|
|
19
|
-
const openaiProvider = require('./providers/openai');
|
|
20
|
-
const metrics = require('./metrics');
|
|
21
|
-
|
|
22
|
-
// A tier groups equivalent-capability models across providers - the
|
|
23
|
-
// deployer's judgment call about which models belong in the same
|
|
24
|
-
// bucket, not a claim this router can verify. Within a tier, selection
|
|
25
|
-
// is ALWAYS by estimated cost, full stop - never by some notion of
|
|
26
|
-
// "quality." That used to be fuzzy: a tier named "router:best" implied
|
|
27
|
-
// picking the best model, but pickCandidate() only ever compared cost,
|
|
28
|
-
// so it silently picked whichever candidate was cheaper - cheaper, not
|
|
29
|
-
// better. Naming it "frontier" instead of "best" says what's actually
|
|
30
|
-
// true: this is the pool of frontier-capability models the deployer
|
|
31
|
-
// trusts, and the router's only job is finding the cheapest healthy one
|
|
32
|
-
// in that pool. If a deployment genuinely needs "always this specific
|
|
33
|
-
// model regardless of price," that's what naming a concrete model
|
|
34
|
-
// directly (skipping "router:" tiers entirely) is for.
|
|
35
|
-
const DEFAULT_TIERS = {
|
|
36
|
-
'router:fast-cheap': [
|
|
37
|
-
{ provider: 'openai', model: 'gpt-4o-mini' },
|
|
38
|
-
{ provider: 'anthropic', model: 'claude-haiku-4-5-20251001' }
|
|
39
|
-
],
|
|
40
|
-
'router:frontier': [
|
|
41
|
-
{ provider: 'anthropic', model: 'claude-sonnet-4-5-20250929' },
|
|
42
|
-
{ provider: 'openai', model: 'gpt-4o' }
|
|
43
|
-
]
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
function loadTiers() {
|
|
47
|
-
if (!process.env.ROUTER_TIERS_JSON) return DEFAULT_TIERS;
|
|
48
|
-
try {
|
|
49
|
-
return JSON.parse(process.env.ROUTER_TIERS_JSON);
|
|
50
|
-
} catch (err) {
|
|
51
|
-
console.warn('⚠️ ROUTER_TIERS_JSON is not valid JSON, using defaults:', err.message);
|
|
52
|
-
return DEFAULT_TIERS;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Three strategies, not one blended score. A weighted cost/latency
|
|
57
|
-
// formula LOOKS more sophisticated but is really just a made-up
|
|
58
|
-
// tradeoff dressed up as intelligence - whatever weights it used would
|
|
59
|
-
// be a guess this router has no basis for making on the deployer's
|
|
60
|
-
// behalf. These three are each simple enough to state exactly what they
|
|
61
|
-
// do:
|
|
62
|
-
//
|
|
63
|
-
// cost - (default, unchanged from before) cheapest
|
|
64
|
-
// healthy candidate, full stop.
|
|
65
|
-
// latency - fastest healthy candidate by recent average
|
|
66
|
-
// latency, full stop. Cost isn't considered at
|
|
67
|
-
// all except as a tiebreaker.
|
|
68
|
-
// latency-guarded-cost - cheapest healthy candidate, EXCLUDING any
|
|
69
|
-
// candidate whose recent average latency is
|
|
70
|
-
// more than ROUTER_LATENCY_GUARD_MULTIPLIER
|
|
71
|
-
// (default 3x) slower than the fastest known
|
|
72
|
-
// healthy candidate. A candidate with no
|
|
73
|
-
// latency history yet is never excluded by the
|
|
74
|
-
// guard - it hasn't had a chance to be slow.
|
|
75
|
-
// This is the one genuinely "latency-aware"
|
|
76
|
-
// option that still keeps cost as the primary
|
|
77
|
-
// signal: it's a guard rail against picking
|
|
78
|
-
// something dramatically slower to save a
|
|
79
|
-
// fraction of a cent, not a full re-ranking.
|
|
80
|
-
const VALID_STRATEGIES = ['cost', 'latency', 'latency-guarded-cost'];
|
|
81
|
-
const DEFAULT_STRATEGY = 'cost';
|
|
82
|
-
const LATENCY_GUARD_MULTIPLIER = Number(process.env.ROUTER_LATENCY_GUARD_MULTIPLIER) || 3;
|
|
83
|
-
|
|
84
|
-
function loadStrategy() {
|
|
85
|
-
const raw = (process.env.ROUTER_STRATEGY || DEFAULT_STRATEGY).trim();
|
|
86
|
-
if (!VALID_STRATEGIES.includes(raw)) {
|
|
87
|
-
console.warn(`⚠️ Unknown ROUTER_STRATEGY "${raw}", falling back to "${DEFAULT_STRATEGY}". Valid values: ${VALID_STRATEGIES.join(', ')}`);
|
|
88
|
-
return DEFAULT_STRATEGY;
|
|
89
|
-
}
|
|
90
|
-
return raw;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function byCostAscending(a, b) {
|
|
94
|
-
return a.estimatedCostUsd - b.estimatedCostUsd;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function byLatencyThenCost(a, b) {
|
|
98
|
-
const aLatency = typeof a.avgLatencyMs === 'number' ? a.avgLatencyMs : Infinity;
|
|
99
|
-
const bLatency = typeof b.avgLatencyMs === 'number' ? b.avgLatencyMs : Infinity;
|
|
100
|
-
if (aLatency !== bLatency) return aLatency - bLatency;
|
|
101
|
-
return byCostAscending(a, b); // tiebreak: equal latency (often both unknown) falls back to cost
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Applies the `latency-guarded-cost` guard: drops any candidate whose
|
|
106
|
-
* avgLatencyMs is more than LATENCY_GUARD_MULTIPLIER times the fastest
|
|
107
|
-
* KNOWN healthy candidate's latency. If there's no latency data to
|
|
108
|
-
* compare at all (a fresh deployment with no history yet), the guard
|
|
109
|
-
* has nothing to guard against and every candidate passes through
|
|
110
|
-
* unchanged - this strategy degrades to plain cost-only until real
|
|
111
|
-
* latency data exists.
|
|
112
|
-
*/
|
|
113
|
-
function applyLatencyGuard(pool) {
|
|
114
|
-
const knownLatencies = pool
|
|
115
|
-
.map((c) => c.avgLatencyMs)
|
|
116
|
-
.filter((v) => typeof v === 'number');
|
|
117
|
-
if (knownLatencies.length === 0) return pool;
|
|
118
|
-
|
|
119
|
-
const fastest = Math.min(...knownLatencies);
|
|
120
|
-
const guarded = pool.filter(
|
|
121
|
-
(c) => typeof c.avgLatencyMs !== 'number' || c.avgLatencyMs <= fastest * LATENCY_GUARD_MULTIPLIER
|
|
122
|
-
);
|
|
123
|
-
// The guard is a filter, not a veto - never let it eliminate every
|
|
124
|
-
// candidate (a tier with one badly-behaved provider should still
|
|
125
|
-
// route somewhere rather than error out).
|
|
126
|
-
return guarded.length > 0 ? guarded : pool;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function isVirtualModel(model) {
|
|
130
|
-
return typeof model === 'string' && model.startsWith('router:');
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function estimatorFor(provider) {
|
|
134
|
-
if (provider === 'anthropic') return anthropicProvider.estimateCost;
|
|
135
|
-
if (provider === 'openai') return openaiProvider.estimateCost;
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// A fixed token assumption used ONLY to compare candidates against each
|
|
140
|
-
// other on a like-for-like basis (same assumed size for every
|
|
141
|
-
// candidate) - it is not a prediction of this request's real size.
|
|
142
|
-
const COMPARISON_INPUT_TOKENS = 1000;
|
|
143
|
-
const COMPARISON_OUTPUT_TOKENS = 500;
|
|
144
|
-
|
|
145
|
-
// A provider whose recent error rate is at or above this is treated as
|
|
146
|
-
// unhealthy and skipped unless every candidate in the tier is unhealthy
|
|
147
|
-
// (in which case we still have to pick one - see pickCandidate).
|
|
148
|
-
const UNHEALTHY_ERROR_RATE = 0.5;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
1
|
+
// model-router/router.js
|
|
2
|
+
//
|
|
3
|
+
// The actual routing decision. Before this file existed, "the router"
|
|
4
|
+
// only dispatched: it read the model name the caller already sent
|
|
5
|
+
// (e.g. "claude-sonnet-4-5-20250929") and forwarded to whichever
|
|
6
|
+
// provider owns that name. That's not routing - the caller had already
|
|
7
|
+
// made the choice. This file adds the thing the product is named for:
|
|
8
|
+
// given a REQUEST FOR A CAPABILITY (not a specific vendor's model), pick
|
|
9
|
+
// a currently-healthy provider that can serve it - by cost (default),
|
|
10
|
+
// by latency, or by cost with a latency guard rail; see ROUTER_STRATEGY
|
|
11
|
+
// below for what each one actually does and doesn't guarantee.
|
|
12
|
+
//
|
|
13
|
+
// Backward compatibility is deliberate: a caller that already names a
|
|
14
|
+
// concrete model (any name not starting with "router:") is dispatched
|
|
15
|
+
// exactly as before, unchanged, in server.js. Nothing here overrides an
|
|
16
|
+
// explicit choice - virtual models are opt-in.
|
|
17
|
+
|
|
18
|
+
const anthropicProvider = require('./providers/anthropic');
|
|
19
|
+
const openaiProvider = require('./providers/openai');
|
|
20
|
+
const metrics = require('./metrics');
|
|
21
|
+
|
|
22
|
+
// A tier groups equivalent-capability models across providers - the
|
|
23
|
+
// deployer's judgment call about which models belong in the same
|
|
24
|
+
// bucket, not a claim this router can verify. Within a tier, selection
|
|
25
|
+
// is ALWAYS by estimated cost, full stop - never by some notion of
|
|
26
|
+
// "quality." That used to be fuzzy: a tier named "router:best" implied
|
|
27
|
+
// picking the best model, but pickCandidate() only ever compared cost,
|
|
28
|
+
// so it silently picked whichever candidate was cheaper - cheaper, not
|
|
29
|
+
// better. Naming it "frontier" instead of "best" says what's actually
|
|
30
|
+
// true: this is the pool of frontier-capability models the deployer
|
|
31
|
+
// trusts, and the router's only job is finding the cheapest healthy one
|
|
32
|
+
// in that pool. If a deployment genuinely needs "always this specific
|
|
33
|
+
// model regardless of price," that's what naming a concrete model
|
|
34
|
+
// directly (skipping "router:" tiers entirely) is for.
|
|
35
|
+
const DEFAULT_TIERS = {
|
|
36
|
+
'router:fast-cheap': [
|
|
37
|
+
{ provider: 'openai', model: 'gpt-4o-mini' },
|
|
38
|
+
{ provider: 'anthropic', model: 'claude-haiku-4-5-20251001' }
|
|
39
|
+
],
|
|
40
|
+
'router:frontier': [
|
|
41
|
+
{ provider: 'anthropic', model: 'claude-sonnet-4-5-20250929' },
|
|
42
|
+
{ provider: 'openai', model: 'gpt-4o' }
|
|
43
|
+
]
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function loadTiers() {
|
|
47
|
+
if (!process.env.ROUTER_TIERS_JSON) return DEFAULT_TIERS;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(process.env.ROUTER_TIERS_JSON);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.warn('⚠️ ROUTER_TIERS_JSON is not valid JSON, using defaults:', err.message);
|
|
52
|
+
return DEFAULT_TIERS;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Three strategies, not one blended score. A weighted cost/latency
|
|
57
|
+
// formula LOOKS more sophisticated but is really just a made-up
|
|
58
|
+
// tradeoff dressed up as intelligence - whatever weights it used would
|
|
59
|
+
// be a guess this router has no basis for making on the deployer's
|
|
60
|
+
// behalf. These three are each simple enough to state exactly what they
|
|
61
|
+
// do:
|
|
62
|
+
//
|
|
63
|
+
// cost - (default, unchanged from before) cheapest
|
|
64
|
+
// healthy candidate, full stop.
|
|
65
|
+
// latency - fastest healthy candidate by recent average
|
|
66
|
+
// latency, full stop. Cost isn't considered at
|
|
67
|
+
// all except as a tiebreaker.
|
|
68
|
+
// latency-guarded-cost - cheapest healthy candidate, EXCLUDING any
|
|
69
|
+
// candidate whose recent average latency is
|
|
70
|
+
// more than ROUTER_LATENCY_GUARD_MULTIPLIER
|
|
71
|
+
// (default 3x) slower than the fastest known
|
|
72
|
+
// healthy candidate. A candidate with no
|
|
73
|
+
// latency history yet is never excluded by the
|
|
74
|
+
// guard - it hasn't had a chance to be slow.
|
|
75
|
+
// This is the one genuinely "latency-aware"
|
|
76
|
+
// option that still keeps cost as the primary
|
|
77
|
+
// signal: it's a guard rail against picking
|
|
78
|
+
// something dramatically slower to save a
|
|
79
|
+
// fraction of a cent, not a full re-ranking.
|
|
80
|
+
const VALID_STRATEGIES = ['cost', 'latency', 'latency-guarded-cost'];
|
|
81
|
+
const DEFAULT_STRATEGY = 'cost';
|
|
82
|
+
const LATENCY_GUARD_MULTIPLIER = Number(process.env.ROUTER_LATENCY_GUARD_MULTIPLIER) || 3;
|
|
83
|
+
|
|
84
|
+
function loadStrategy() {
|
|
85
|
+
const raw = (process.env.ROUTER_STRATEGY || DEFAULT_STRATEGY).trim();
|
|
86
|
+
if (!VALID_STRATEGIES.includes(raw)) {
|
|
87
|
+
console.warn(`⚠️ Unknown ROUTER_STRATEGY "${raw}", falling back to "${DEFAULT_STRATEGY}". Valid values: ${VALID_STRATEGIES.join(', ')}`);
|
|
88
|
+
return DEFAULT_STRATEGY;
|
|
89
|
+
}
|
|
90
|
+
return raw;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function byCostAscending(a, b) {
|
|
94
|
+
return a.estimatedCostUsd - b.estimatedCostUsd;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function byLatencyThenCost(a, b) {
|
|
98
|
+
const aLatency = typeof a.avgLatencyMs === 'number' ? a.avgLatencyMs : Infinity;
|
|
99
|
+
const bLatency = typeof b.avgLatencyMs === 'number' ? b.avgLatencyMs : Infinity;
|
|
100
|
+
if (aLatency !== bLatency) return aLatency - bLatency;
|
|
101
|
+
return byCostAscending(a, b); // tiebreak: equal latency (often both unknown) falls back to cost
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Applies the `latency-guarded-cost` guard: drops any candidate whose
|
|
106
|
+
* avgLatencyMs is more than LATENCY_GUARD_MULTIPLIER times the fastest
|
|
107
|
+
* KNOWN healthy candidate's latency. If there's no latency data to
|
|
108
|
+
* compare at all (a fresh deployment with no history yet), the guard
|
|
109
|
+
* has nothing to guard against and every candidate passes through
|
|
110
|
+
* unchanged - this strategy degrades to plain cost-only until real
|
|
111
|
+
* latency data exists.
|
|
112
|
+
*/
|
|
113
|
+
function applyLatencyGuard(pool) {
|
|
114
|
+
const knownLatencies = pool
|
|
115
|
+
.map((c) => c.avgLatencyMs)
|
|
116
|
+
.filter((v) => typeof v === 'number');
|
|
117
|
+
if (knownLatencies.length === 0) return pool;
|
|
118
|
+
|
|
119
|
+
const fastest = Math.min(...knownLatencies);
|
|
120
|
+
const guarded = pool.filter(
|
|
121
|
+
(c) => typeof c.avgLatencyMs !== 'number' || c.avgLatencyMs <= fastest * LATENCY_GUARD_MULTIPLIER
|
|
122
|
+
);
|
|
123
|
+
// The guard is a filter, not a veto - never let it eliminate every
|
|
124
|
+
// candidate (a tier with one badly-behaved provider should still
|
|
125
|
+
// route somewhere rather than error out).
|
|
126
|
+
return guarded.length > 0 ? guarded : pool;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isVirtualModel(model) {
|
|
130
|
+
return typeof model === 'string' && model.startsWith('router:');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function estimatorFor(provider) {
|
|
134
|
+
if (provider === 'anthropic') return anthropicProvider.estimateCost;
|
|
135
|
+
if (provider === 'openai') return openaiProvider.estimateCost;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A fixed token assumption used ONLY to compare candidates against each
|
|
140
|
+
// other on a like-for-like basis (same assumed size for every
|
|
141
|
+
// candidate) - it is not a prediction of this request's real size.
|
|
142
|
+
const COMPARISON_INPUT_TOKENS = 1000;
|
|
143
|
+
const COMPARISON_OUTPUT_TOKENS = 500;
|
|
144
|
+
|
|
145
|
+
// A provider whose recent error rate is at or above this is treated as
|
|
146
|
+
// unhealthy and skipped unless every candidate in the tier is unhealthy
|
|
147
|
+
// (in which case we still have to pick one - see pickCandidate).
|
|
148
|
+
const UNHEALTHY_ERROR_RATE = 0.5;
|
|
149
|
+
|
|
150
|
+
// Minimum number of recent requests before a provider's error rate is
|
|
151
|
+
// treated as meaningful. Without this, a brand-new provider (or one whose
|
|
152
|
+
// traffic just resumed) with a SINGLE request that happened to error has
|
|
153
|
+
// errorRate 1.0 and flips unhealthy instantly; 1/1 or 1/2 errors is noise,
|
|
154
|
+
// not a signal. Below this sample size a provider is always considered
|
|
155
|
+
// healthy (insufficient data to judge) so one unlucky request can't bounce
|
|
156
|
+
// it out of rotation.
|
|
157
|
+
const MIN_HEALTH_SAMPLES = Number(process.env.ROUTER_HEALTH_MIN_SAMPLES) || 5;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Choose a {provider, model} pair for a virtual model name. Always
|
|
161
|
+
* excludes unhealthy candidates first (recent error rate too high,
|
|
162
|
+
* unless every candidate is unhealthy - see below); the strategy (env
|
|
163
|
+
* ROUTER_STRATEGY, default "cost") decides how what's left gets ranked.
|
|
164
|
+
* See the strategy comment above loadStrategy() for what each one
|
|
165
|
+
* actually does.
|
|
166
|
+
*
|
|
167
|
+
* `scope` (seams work): passed straight through to metrics.providerStats
|
|
168
|
+
* - null/undefined (every call site in this codebase today) means the
|
|
169
|
+
* platform-wide rolling health this always used, byte-identical to
|
|
170
|
+
* before this parameter existed. A caller that passes a real scope gets
|
|
171
|
+
* that scope's OWN rolling health instead - deliberately left as a
|
|
172
|
+
* choice for whoever configures auth (see server.js's `configure()`),
|
|
173
|
+
* not decided here: per-scope health isolates one tenant's provider
|
|
174
|
+
* trouble from every other tenant's routing, platform-wide health
|
|
175
|
+
* reacts faster (more samples) but lets one tenant's bad luck degrade
|
|
176
|
+
* everyone's routing. This function doesn't take a side.
|
|
177
|
+
*/
|
|
178
|
+
async function pickCandidate(virtualModel, scope) {
|
|
179
|
+
const tiers = loadTiers();
|
|
180
|
+
const candidates = tiers[virtualModel];
|
|
181
|
+
if (!candidates || candidates.length === 0) {
|
|
182
|
+
return { error: `Unknown routing tier: ${virtualModel}` };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The metrics store being unreachable must not take routing down with
|
|
186
|
+
// it: health/latency data is an INPUT to the ranking below, not a
|
|
187
|
+
// prerequisite for it. Degrade to the exact state a brand-new
|
|
188
|
+
// deployment with zero history already routes in (every candidate
|
|
189
|
+
// healthy, latency unknown, cost-only ordering) rather than failing
|
|
190
|
+
// the request - a gateway's job during a dependency blip is to keep
|
|
191
|
+
// serving, and the per-candidate failover at dispatch time still
|
|
192
|
+
// catches a provider that's genuinely broken. Without this, a
|
|
193
|
+
// Postgres-backed metrics outage turned every router:* request into an
|
|
194
|
+
// unhandled rejection that crashed the process outright (Express 4
|
|
195
|
+
// never sees async rejections).
|
|
196
|
+
let stats = {};
|
|
197
|
+
try {
|
|
198
|
+
stats = await metrics.providerStats(scope);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.warn('⚠️ providerStats unavailable - routing on cost only:', err.message);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const scored = candidates.map((candidate) => {
|
|
204
|
+
const estimate = estimatorFor(candidate.provider);
|
|
205
|
+
const estimatedCostUsd = estimate
|
|
206
|
+
? estimate(candidate.model, COMPARISON_INPUT_TOKENS, COMPARISON_OUTPUT_TOKENS)
|
|
207
|
+
: Infinity;
|
|
208
|
+
const providerStat = stats[candidate.provider] || { errorRate: 0, avgLatencyMs: null, sampleSize: 0 };
|
|
209
|
+
return {
|
|
210
|
+
...candidate,
|
|
211
|
+
estimatedCostUsd,
|
|
212
|
+
errorRate: providerStat.errorRate,
|
|
213
|
+
avgLatencyMs: providerStat.avgLatencyMs,
|
|
214
|
+
healthy: providerStat.sampleSize < MIN_HEALTH_SAMPLES || providerStat.errorRate < UNHEALTHY_ERROR_RATE
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
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
|
|
220
|
+
|
|
221
|
+
const strategy = loadStrategy();
|
|
222
|
+
let ranked;
|
|
223
|
+
let guardApplied = false;
|
|
224
|
+
if (strategy === 'latency') {
|
|
225
|
+
ranked = [...pool].sort(byLatencyThenCost);
|
|
226
|
+
} else if (strategy === 'latency-guarded-cost') {
|
|
227
|
+
const guarded = applyLatencyGuard(pool);
|
|
228
|
+
guardApplied = guarded.length < pool.length;
|
|
229
|
+
ranked = [...guarded].sort(byCostAscending);
|
|
230
|
+
} else {
|
|
231
|
+
ranked = [...pool].sort(byCostAscending);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const chosen = ranked[0];
|
|
235
|
+
return {
|
|
236
|
+
provider: chosen.provider,
|
|
237
|
+
model: chosen.model,
|
|
238
|
+
// Same order `chosen` was drawn from, stripped down to just
|
|
239
|
+
// {provider, model} - lets a caller (server.js's failover loop)
|
|
240
|
+
// retry the next-best candidate if the top choice's live call
|
|
241
|
+
// fails, without re-running this scoring/health/strategy pass a
|
|
242
|
+
// second time. Always has at least one entry when `chosen` does.
|
|
243
|
+
rankedCandidates: ranked.map((c) => ({ provider: c.provider, model: c.model })),
|
|
244
|
+
reason: {
|
|
245
|
+
consideredTier: virtualModel,
|
|
246
|
+
strategy,
|
|
247
|
+
candidates: scored,
|
|
248
|
+
allUnhealthy: healthy.length === 0,
|
|
249
|
+
latencyGuardExcludedACandidate: guardApplied
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = { isVirtualModel, pickCandidate, loadTiers, loadStrategy };
|