cachegate 1.1.1 → 1.3.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/package.json CHANGED
@@ -1,12 +1,26 @@
1
1
  {
2
2
  "name": "cachegate",
3
- "version": "1.1.1",
3
+ "version": "1.3.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
- function isConnected() {
42
- return client.isOpen;
43
- }
44
-
45
- module.exports = { client, isConnected, ready };
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
- * Choose a {provider, model} pair for a virtual model name. Always
152
- * excludes unhealthy candidates first (recent error rate too high,
153
- * unless every candidate is unhealthy - see below); the strategy (env
154
- * ROUTER_STRATEGY, default "cost") decides how what's left gets ranked.
155
- * See the strategy comment above loadStrategy() for what each one
156
- * actually does.
157
- */
158
- async function pickCandidate(virtualModel) {
159
- const tiers = loadTiers();
160
- const candidates = tiers[virtualModel];
161
- if (!candidates || candidates.length === 0) {
162
- return { error: `Unknown routing tier: ${virtualModel}` };
163
- }
164
-
165
- const stats = await metrics.providerStats();
166
-
167
- const scored = candidates.map((candidate) => {
168
- const estimate = estimatorFor(candidate.provider);
169
- const estimatedCostUsd = estimate
170
- ? estimate(candidate.model, COMPARISON_INPUT_TOKENS, COMPARISON_OUTPUT_TOKENS)
171
- : Infinity;
172
- const providerStat = stats[candidate.provider] || { errorRate: 0, avgLatencyMs: null, sampleSize: 0 };
173
- return {
174
- ...candidate,
175
- estimatedCostUsd,
176
- errorRate: providerStat.errorRate,
177
- avgLatencyMs: providerStat.avgLatencyMs,
178
- healthy: providerStat.errorRate < UNHEALTHY_ERROR_RATE
179
- };
180
- });
181
-
182
- const healthy = scored.filter((c) => c.healthy);
183
- const pool = healthy.length > 0 ? healthy : scored; // all unhealthy: pick the least-bad rather than fail outright
184
-
185
- const strategy = loadStrategy();
186
- let ranked;
187
- let guardApplied = false;
188
- if (strategy === 'latency') {
189
- ranked = [...pool].sort(byLatencyThenCost);
190
- } else if (strategy === 'latency-guarded-cost') {
191
- const guarded = applyLatencyGuard(pool);
192
- guardApplied = guarded.length < pool.length;
193
- ranked = [...guarded].sort(byCostAscending);
194
- } else {
195
- ranked = [...pool].sort(byCostAscending);
196
- }
197
-
198
- const chosen = ranked[0];
199
- return {
200
- provider: chosen.provider,
201
- model: chosen.model,
202
- // Same order `chosen` was drawn from, stripped down to just
203
- // {provider, model} - lets a caller (server.js's failover loop)
204
- // retry the next-best candidate if the top choice's live call
205
- // fails, without re-running this scoring/health/strategy pass a
206
- // second time. Always has at least one entry when `chosen` does.
207
- rankedCandidates: ranked.map((c) => ({ provider: c.provider, model: c.model })),
208
- reason: {
209
- consideredTier: virtualModel,
210
- strategy,
211
- candidates: scored,
212
- allUnhealthy: healthy.length === 0,
213
- latencyGuardExcludedACandidate: guardApplied
214
- }
215
- };
216
- }
217
-
218
- module.exports = { isVirtualModel, pickCandidate, loadTiers, loadStrategy };
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 };