cachegate 1.0.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/ROADMAP.md ADDED
@@ -0,0 +1,281 @@
1
+ # MemoCode Model Router — Roadmap & Review
2
+
3
+ > Review of `210_apps/001_model_router/` as it stands, and the plan to take it
4
+ > from an internal MemoCode utility to a standalone self-hosted product.
5
+ > Originally written by DeepSeek, 2026-08-23, auditing the version that
6
+ > existed on `deepseek/memocode-chat-and-router` at the time. **Corrected the
7
+ > same day**: that audit's "gaps" section was accurate for the version it was
8
+ > looking at, but a second, much more complete implementation had been built
9
+ > in parallel on `claude/memocode-project-review-v0ohnx` and never merged
10
+ > anywhere shared — neither branch's author knew the other's existed. This
11
+ > file now reflects the merged, actual state. See section 0.
12
+
13
+ ## 0. What happened, so it doesn't happen again
14
+
15
+ Two independent model-router implementations existed at once: this one
16
+ (minimal - proxy, exact-match cache, provider mapping) on
17
+ `deepseek/memocode-chat-and-router`, and a materially more complete one
18
+ (routing strategies, semantic cache, streaming, JSONL metrics with rotation,
19
+ a cost dashboard, hardened auth, 68 tests) built the same day on
20
+ `claude/memocode-project-review-v0ohnx` - a branch neither this file's
21
+ original audit nor DeepSeek's own semantic-cache attempt knew to check.
22
+ Discovered when the CEO asked to test the semantic cache and DeepSeek,
23
+ checking `master`/`deepseek/memocode-chat-and-router`/
24
+ `claude/session-import-feature`, correctly found it on none of them - but
25
+ hadn't checked the actual branch it lived on. Resolved by replacing this
26
+ directory's contents with the more complete version (a strict superset
27
+ except for this file) and keeping this file's own market/monetization
28
+ analysis, which stands independent of which codebase it's describing.
29
+ **The lesson, not just the fix**: a feature branch that never gets
30
+ merged toward `master` (or at least announced on the coordination board
31
+ with its branch name) is invisible to everyone not already looking at it,
32
+ no matter how complete the work on it is - completeness doesn't substitute
33
+ for discoverability.
34
+
35
+ ## 1. What's actually here (audited 2026-08-23, post-merge)
36
+
37
+ **Working, tested, pushed** (`210_apps/001_model_router/`, this directory,
38
+ now matching `claude/memocode-project-review-v0ohnx`):
39
+ - `server.js` — Express, OpenAI-compatible `POST /v1/chat/completions`
40
+ (streaming and non-streaming), internal bearer-key auth (fails CLOSED by
41
+ default - `ALLOW_INSECURE_LOCAL_DEV=true` required to opt into running
42
+ without a key), rate limiting, `/health`, `/stats`, `/dashboard`.
43
+ - `providers/anthropic.js` + `providers/openai.js` — request mapping,
44
+ token/cost estimation, `chatStream()` for SSE.
45
+ - `cache.js` — Redis exact-match cache keyed by a content hash of
46
+ `{model, messages, temperature, max_tokens, tools, tool_choice}`.
47
+ - `semanticCache.js` + `embeddings.js` — near-duplicate matching via
48
+ embeddings + cosine similarity over a bounded per-model Redis list
49
+ (brute-force, not RediSearch/vector-indexed - an honest, documented
50
+ scale limit, not a hidden one). Tool-calling requests excluded. Semantic
51
+ hits tracked separately from exact hits in metrics, never blended into
52
+ one number.
53
+ - `router.js` — three explicit `router:` virtual-model strategies (cost /
54
+ latency / latency-guarded-cost), no invented blended score.
55
+ - `metrics.js` — JSONL logs with day-rotation, bounded reads, opt-in
56
+ pruning.
57
+ - `public/dashboard.html` — a real cost dashboard (KPI tiles, charts,
58
+ auto-refresh, table-view twins), built to this project's own `dataviz`
59
+ skill standard.
60
+ - 68 tests across 8 test files, all passing.
61
+ - Live: dogfooded by `210_apps/000_backend/chat-import-logic.mjs`
62
+ (`localhost:4000/v1/chat/completions` for chat segmentation).
63
+
64
+ **Real remaining gaps** (honest, not "nothing left to do"; updated
65
+ 2026-08-29 after re-auditing against the actual code - two of the four
66
+ gaps below turned out to already be closed):
67
+ 1. Semantic cache is brute-force cosine over a capped list, not a real
68
+ vector index - fine at self-hosted single-instance volume, not meant to
69
+ scale past `SEMANTIC_CACHE_MAX_CANDIDATES` (default 200) per model.
70
+ 2. ~~No provider failover on a 5xx/rate-limit~~ **Closed 2026-08-29.**
71
+ `failover.js` + `server.js`'s non-streaming dispatch path now walk
72
+ `router.js`'s full ranked-candidate list, retrying the next candidate
73
+ when one fails for a reason that isn't the request's own fault (a
74
+ 400/404 still fails immediately - retrying elsewhere wouldn't help).
75
+ Streaming is a deliberate exception, documented inline in
76
+ `handleStreamingDispatch` - SSE headers and the first frame commit to
77
+ a model name before a failure could be known, so silent mid-stream
78
+ provider switching is a materially harder problem, left open rather
79
+ than shipped half-working.
80
+ 3. ~~No persistence layer under the dashboard~~ **Closed** (already true
81
+ before this pass, just never updated here): `metrics.js` has stored
82
+ metrics in Postgres since the `feat(model-router): persistent metrics
83
+ storage via Postgres` commit, with automatic JSONL fallback when no
84
+ database is configured.
85
+ 4. Not yet open-sourced/packaged standalone (Phase 5, section 4 below).
86
+
87
+ **What this actually is today, said plainly**: working, tested internal
88
+ infrastructure with zero users outside this codebase — not an app, not a
89
+ published package, not a running public service, not something anyone has
90
+ paid for or even tried. Everything from here through section 3 (market
91
+ position, monetization tiers, MRR estimates) describes a *hypothetical*
92
+ product this code could become, not a claim about what it is right now.
93
+ Read it as a plan, not a status report - a CEO challenge on 2026-08-23
94
+ ("this is not an app, it's not portable, we build nothing") is a fair
95
+ description of today's reality and is what section 4's Phase 5 (open
96
+ source release) exists to close.
97
+
98
+ ## 2. Honest market position
99
+
100
+ Do **not** try to out-LiteLLM LiteLLM (140+ providers, Python, huge community)
101
+ or OpenRouter (acquired by Stripe). The winnable niche:
102
+
103
+ - **Self-hosted first** — prompts/PII never leave your infra.
104
+ - **Semantic cache**, not just exact-match (most OSS options still hash-match).
105
+ - **Node.js/TypeScript** — the JS/TS AI-app crowd is underserved.
106
+ - **Embeddable** — usable as a module inside an existing app before it's sold.
107
+
108
+ **Correction, same day, on the semantic-cache claim specifically**: verified
109
+ via WebSearch that LiteLLM shipped a real vector-indexed (Valkey-search +
110
+ HNSW) semantic cache in 2026 - more sophisticated than this router's
111
+ brute-force cosine scan. "Semantic cache, not just exact-match" is no longer
112
+ a differentiator against LiteLLM by name, even though it's still true against
113
+ "most OSS options." The honest remaining edges: small/auditable codebase,
114
+ Node-native, and already dogfooded inside a real app.
115
+
116
+ The sellable, honest claim (do not quote inflated 86–95%): **"Caching alone
117
+ typically saves 20–45%; add routing and it can reach 47–90% on well-tuned
118
+ traffic."** Being the vendor that quotes the real number is the credibility
119
+ edge with the buyer who has been burned by an inflated claim.
120
+
121
+ ## 3. How this router makes money (open-core, honest)
122
+
123
+ Self-hosted OSS infrastructure monetizes by **open-core**, not by charging for
124
+ the free thing:
125
+
126
+ | Tier | What | Price | Why someone pays |
127
+ |---|---|---|---|
128
+ | **OSS core (free)** | exact-match cache, 2–3 providers, basic proxy | $0 | top-of-funnel; builds trust |
129
+ | **Pro** | semantic cache, cost-based routing + failover, cost dashboard | $49–99/mo | the "save 40–80% on LLM spend" features; charge ~10–20% of the savings |
130
+ | **Team** | multi-user, SSO, audit log, priority support | $199–499/mo | small teams running production workloads |
131
+ | **Hosted** | we run it for you (no ops) | usage-based, $99–999/mo | teams that don't want to self-host (where LiteLLM makes its money) |
132
+ | **Enterprise** | on-prem, compliance, SLAs | custom | regulated buyers |
133
+
134
+ **Honest caveats:** OSS → paid conversion is typically 1–5%; the money is in
135
+ the small fraction who want the semantic cache + dashboard and don't want to
136
+ self-host. It is a crowded market. Realistic: **$5–20K MRR in 12 months** by
137
+ owning the self-hosted Node.js niche. The metric that makes it work: a buyer
138
+ spending $1,000/mo on LLM APIs saves $400–800/mo with this — paying $50–100/mo
139
+ for the tool is an easy yes.
140
+
141
+ ## 4. Phases
142
+
143
+ **Note on an older numbering (added 2026-08-29):** early journal entries
144
+ (2026-08-24) refer to this work as a flat "20-step roadmap" (steps
145
+ 13-17 individually named there; 18 marked done but never described; 19
146
+ = the semantic-cache vector-index upgrade, paused; 20 = the standalone
147
+ product). That numbering was never written down as one document - it
148
+ only ever existed as scattered journal references, which made it
149
+ genuinely hard to reconstruct later (confirmed 2026-08-29: step 18's
150
+ actual content couldn't be found anywhere). **The 6 phases below
151
+ supersede that numbering entirely.** If an old "step N" reference ever
152
+ surfaces again, map it here rather than trying to revive the flat list.
153
+
154
+ ### Phase 1 — make what exists real — **done**
155
+ Redis verified end-to-end, honest README, tests (68, not the "no test files"
156
+ this section originally reported), the honest cost claim above.
157
+
158
+ ### Phase 2 — semantic cache — **done**
159
+ Embeddings + cosine similarity, `SEMANTIC_CACHE_THRESHOLD` default 0.93 (this
160
+ section's own original draft proposed ~0.95 - close, tuned during real
161
+ testing; see `semanticCache.js`'s own comment for the reasoning), tool-calls
162
+ excluded, tracked separately from exact-match hits.
163
+
164
+ ### Phase 3 — real routing — **done**
165
+ Three explicit strategies (`router.js`) rather than a single invented
166
+ blended score - cost-ascending, latency-then-cost, and latency-guarded-cost
167
+ (excludes anything too much slower than the fastest known candidate).
168
+ Failover on error for non-streaming requests shipped 2026-08-29
169
+ (`failover.js`) - see section 1, gap 2, for what it does and doesn't cover.
170
+
171
+ ### Phase 4 — cost visibility — **done**
172
+ `metrics.js` (JSONL, day-rotation, bounded reads) + `public/dashboard.html`
173
+ (KPI tiles, charts, auto-refresh, table-view twins).
174
+
175
+ ### Phase 5 — open-source release — not started
176
+ Public GitHub repo → npm package → Docker image → announce (Hacker News,
177
+ r/LocalLLaMA, r/selfhosted, Dev.to). Still the real next milestone - none of
178
+ phases 1-4 being done changes that this hasn't shipped to anyone outside
179
+ this project yet. **Execution plan, added 2026-08-29:**
180
+ [`OPEN_SOURCE_ROADMAP.md`](./OPEN_SOURCE_ROADMAP.md) - the 20-step
181
+ breakdown of everything this one-liner actually requires, written down
182
+ as one document on purpose (see that file's own note on why).
183
+
184
+ ### Phase 6 — hosted tier (only after OSS traction)
185
+ Render/Fly hosted; usage-based pricing.
186
+
187
+ ## 5. One honest caveat on dogfooding
188
+
189
+ Chat-import's one segmentation call per session is too low-repeat to prove
190
+ the *cache* saves money — it only proves the *proxy* works. **2026-08-23
191
+ update**: `210_apps/000_backend/ai-providers.mjs`'s "Generate with AI" now
192
+ routes through this router too (app's own shared key only, never a
193
+ signed-in user's BYOK key — the router is one shared-secret proxy today,
194
+ not multi-tenant).
195
+
196
+ **Correction, same day, on how that update was first worded here**: this
197
+ section originally called that traffic "a genuinely more promising source
198
+ of real cache hits" — that was a hunch stated as a finding, and a CEO
199
+ challenge caught it (fair: "what are the odds two people ask the same
200
+ thing" is the right question, and for this app's actual traffic the honest
201
+ answer is "mostly low, and nobody has measured it"). The one real
202
+ structural fact in favor of *some* hits: `ai-providers.mjs` builds that
203
+ prompt as `"Subject: <topic>. Generate about N <label> worth of
204
+ content."` — short and templated, with `topic` the only variable — so two
205
+ different users both studying, say, "the Roman Empire" would produce an
206
+ identical or near-identical request, which chat-import's full conversation
207
+ transcripts basically never do. That's a plausible *mechanism* for a
208
+ non-zero hit rate on a study app whose users cluster around common
209
+ curriculum topics — it is not a measured hit rate, and treating it as one
210
+ would repeat the same mistake. Nobody has looked at MemoCode's actual
211
+ topic distribution. `MODEL_ROUTER_URL` is also still unset in the current
212
+ Render deploy (`render.yaml` declares no router service), so none of this
213
+ runs in production yet regardless.
214
+
215
+ To validate savings for real, you need either that traffic running through
216
+ the router in production with real measurement, or synthetic load testing
217
+ against a realistic topic distribution — not a plausible-sounding argument
218
+ for why it might work, however structurally reasonable that argument is.
219
+
220
+ ## 6. Two-version architecture (decision 2026-08-23)
221
+
222
+ One engine (routing + cache + metrics), two wrappers — NOT two codebases:
223
+
224
+ | | Embedded | Standalone |
225
+ |---|---|---|
226
+ | Lives | inside MemoCode (own process) | its own hosted service (Render) |
227
+ | Keys | you bring Anthropic/OpenAI | it holds keys, issues its own key + URL |
228
+ | Login/billing | none | yes (login, payment, multi-tenancy) |
229
+ | Serves | your apps (fallback) | everybody, including your apps |
230
+ | Status | done — needs Redis + production wiring | future project (OpenRouter/LiteLLM competitor) |
231
+
232
+ Fallback: apps point at the standalone and drop back to the embedded
233
+ (localhost or MemoCode's own instance) when the standalone is down. Same job,
234
+ so the switch is invisible to the caller. Do NOT fork the router — wrap it:
235
+ same core logic, two thin deployment shells, so the two can never drift apart
236
+ the way the two parallel implementations did (section 0).
237
+
238
+ The embedded version **stays embedded in each new app** (each app inherits
239
+ this directory as-is) — it is NOT extracted to a shared repo. A shared repo
240
+ means maintaining one router copy per repo plus separate hosting/db, which
241
+ isn't worth it; a new app just copies `210_apps/001_model_router/` into its
242
+ own backend and points `MODEL_ROUTER_URL` at it. The standalone's key+URL are
243
+ left as comments in the app's `.env` as a future reminder, not wired yet.
244
+
245
+ Sequencing: finish the embedded first (cheap, real, no risk), then decide
246
+ whether to build the standalone's billing layer (the hard, deferred part).
247
+
248
+ ## 7. Embedded — production checklist
249
+
250
+ 1. Hosted Redis (Render Key Value) → set `REDIS_URL`.
251
+ 2. Deploy this directory as its own service (or co-locate with the backend).
252
+ The repo `render.yaml` now declares a `memocode-router` service (Node
253
+ runtime, `npm ci` + `npm start`) and a `Dockerfile` is included for
254
+ Docker-based deploys.
255
+ 3. Set production env: `MODEL_ROUTER_INTERNAL_KEY` (strong, `openssl rand -hex 32`),
256
+ `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `REDIS_URL`,
257
+ `SEMANTIC_CACHE_ENABLED=false` (low-repeat traffic: semantic cache spends
258
+ an embedding call on every miss for nothing), `ROUTER_STRATEGY=cost`.
259
+ 4. Route MemoCode's AI call sites through it via `MODEL_ROUTER_URL`.
260
+ **Corrected 2026-08-29**: this item previously named "image gen" and
261
+ "transcription" as call sites to route through the router - checked
262
+ directly, and that's not actually possible as scoped. The router is an
263
+ OpenAI-*chat-completions*-compatible proxy only; image generation
264
+ (`images.generate`) and Whisper transcription
265
+ (`audio.transcriptions.create`) are different API shapes it doesn't
266
+ speak. What's actually wired today: chat-import's segmentation call
267
+ (`chat-import-logic.mjs`) and "Generate with AI" text generation
268
+ (`ai-providers.mjs`'s `generateStructuredText`), both gated behind
269
+ `MODEL_ROUTER_URL` being set (`shouldRouteToModelRouter()`). Extending
270
+ the router to proxy image/audio calls too would be new scope, not a
271
+ pending item on this checklist.
272
+ 5. Verify `/health`, `/dashboard`, `/stats` on production with real traffic.
273
+
274
+ **Note on items 1 and 3 above (2026-08-29):** `render.yaml` declares the
275
+ env var slots for `REDIS_URL`, `MODEL_ROUTER_INTERNAL_KEY`,
276
+ `ANTHROPIC_API_KEY`, and `OPENAI_API_KEY` (all `sync: false` - filled in
277
+ manually on Render's dashboard), and the metrics Postgres connection is
278
+ wired automatically via `fromDatabase`. Whether those manual slots are
279
+ actually populated with real values on the live Render service isn't
280
+ something a sandboxed session can check - that verification needs
281
+ whoever has the Render dashboard.
package/SECURITY.md ADDED
@@ -0,0 +1,39 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ **Please do not open a public GitHub issue for a security
6
+ vulnerability.** A public issue is a disclosure before a fix exists.
7
+
8
+ Instead, use GitHub's private vulnerability reporting:
9
+
10
+ 1. Go to this repository's **Security** tab.
11
+ 2. Click **Report a vulnerability**.
12
+ 3. Describe the issue — what it is, how to reproduce it, and its
13
+ likely impact (e.g. "bypasses auth," "leaks another deployment's
14
+ cached data," "exhausts memory regardless of rate limiting").
15
+
16
+ This opens a private conversation with the maintainers, visible only to
17
+ you and them, and lets a fix be prepared and released before the
18
+ vulnerability is public.
19
+
20
+ *(Maintainer note, remove once live: this requires "Private vulnerability
21
+ reporting" to be turned on for the repository — Settings → Security →
22
+ Private vulnerability reporting — as part of step 15's repo setup.)*
23
+
24
+ ## What counts as a security issue here
25
+
26
+ Concretely, for this project: anything that lets a request bypass
27
+ `MODEL_ROUTER_INTERNAL_KEY` auth, read or corrupt another deployment's
28
+ cached data or metrics, or exhaust CPU/memory/Redis storage in a way
29
+ `RATE_LIMIT_MAX`/`RATE_LIMIT_WINDOW_MS` doesn't already bound. A
30
+ provider returning an unexpected error, a routing decision you disagree
31
+ with, or a missing feature are regular bugs — open a normal issue for
32
+ those (see `CONTRIBUTING.md`).
33
+
34
+ ## Supported versions
35
+
36
+ This project is pre-1.0 (see `OPEN_SOURCE_ROADMAP.md` on the version
37
+ plan) — security fixes go into the latest release only. Once a stable
38
+ 1.0 line exists, this section will name which major versions still
39
+ receive fixes.
package/cache.js ADDED
@@ -0,0 +1,51 @@
1
+ // model-router/cache.js
2
+ //
3
+ // The exact-match cache: same model + same messages + same params ->
4
+ // same cached response, by content hash. First and free - checked
5
+ // before the semantic cache (semanticCache.js), which is slower (an
6
+ // embedding call) and probabilistic (a similarity threshold, not an
7
+ // exact match). Connection is shared via redisClient.js.
8
+
9
+ const crypto = require('crypto');
10
+ const redis = require('./redisClient');
11
+
12
+ function buildCacheKey(payload) {
13
+ const normalized = JSON.stringify({
14
+ model: payload.model,
15
+ messages: payload.messages,
16
+ temperature: payload.temperature ?? 0.0,
17
+ max_tokens: payload.max_tokens,
18
+ tools: payload.tools,
19
+ tool_choice: payload.tool_choice
20
+ });
21
+ const hash = crypto.createHash('sha256').update(normalized).digest('hex');
22
+ return `ROUTER:${payload.model}:${hash}`;
23
+ }
24
+
25
+ module.exports = {
26
+ buildCacheKey,
27
+
28
+ isConnected() {
29
+ return redis.isConnected();
30
+ },
31
+
32
+ async get(payload) {
33
+ if (!redis.isConnected()) return null;
34
+ try {
35
+ const cached = await redis.client.get(buildCacheKey(payload));
36
+ return cached ? JSON.parse(cached) : null;
37
+ } catch (err) {
38
+ return null;
39
+ }
40
+ },
41
+
42
+ async set(payload, response, ttlSeconds = 3600) {
43
+ if (!redis.isConnected()) return false;
44
+ try {
45
+ await redis.client.set(buildCacheKey(payload), JSON.stringify(response), { EX: ttlSeconds });
46
+ return true;
47
+ } catch (err) {
48
+ return false;
49
+ }
50
+ }
51
+ };
package/embeddings.js ADDED
@@ -0,0 +1,32 @@
1
+ // model-router/embeddings.js
2
+ //
3
+ // The only embedding backend right now is OpenAI's - which means
4
+ // semantic caching needs an OPENAI_API_KEY configured even for a
5
+ // deployment that only ever talks to Anthropic for chat. That's a real
6
+ // constraint, not hidden: isEnabled() is what semanticCache.js checks
7
+ // before doing anything, and it degrades to "disabled" (not an error)
8
+ // when the key isn't set, same as the Redis cache does when REDIS_URL
9
+ // isn't set.
10
+
11
+ const { OpenAI } = require('openai');
12
+
13
+ let client;
14
+ function getClient() {
15
+ if (!client) client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
16
+ return client;
17
+ }
18
+
19
+ function isEnabled() {
20
+ return Boolean(process.env.OPENAI_API_KEY);
21
+ }
22
+
23
+ async function embed(text) {
24
+ if (!isEnabled()) {
25
+ throw new Error('OPENAI_API_KEY not configured - embeddings unavailable');
26
+ }
27
+ const model = process.env.EMBEDDING_MODEL || 'text-embedding-3-small';
28
+ const response = await getClient().embeddings.create({ model, input: text });
29
+ return response.data[0].embedding;
30
+ }
31
+
32
+ module.exports = { isEnabled, embed };
package/failover.js ADDED
@@ -0,0 +1,76 @@
1
+ // model-router/failover.js
2
+ //
3
+ // Pure control flow for trying a virtual model's ranked candidates in
4
+ // order until one succeeds - isolated from the actual provider-calling
5
+ // code (server.js's dispatchToProvider) so it's directly unit-testable
6
+ // with a fake dispatch function, no live API keys or network calls
7
+ // needed. Same reasoning as providers/*.js's own
8
+ // applyStreamEvent/applyStreamChunk factoring: the trickiest logic
9
+ // shouldn't require a real provider to test.
10
+ //
11
+ // Addresses ROADMAP.md's gap #2 ("no provider failover on a
12
+ // 5xx/rate-limit"): router.js's pickCandidate() already ranks every
13
+ // candidate in a tier by the configured strategy, but server.js used
14
+ // to dispatch to the top-ranked one only - if it failed, the whole
15
+ // request failed, even when a second healthy candidate existed in the
16
+ // same tier. This module is what actually walks that ranked list.
17
+
18
+ // Whether a failed dispatch attempt is worth retrying against the NEXT
19
+ // candidate, vs failing the request outright. The distinction: is the
20
+ // REQUEST itself broken (retrying elsewhere would fail identically),
21
+ // or did THIS provider fail in a way another provider might not (rate
22
+ // limit, an outage, a bad or expired key)? Bad request (400) and
23
+ // unknown model (404) are the request's own fault, not retried - the
24
+ // Anthropic and OpenAI SDKs both set `.status` on a thrown APIError.
25
+ // A network-level failure with no HTTP response at all (no `.status`)
26
+ // is treated as the provider's fault too, since it's not the
27
+ // request's content that's the problem. An auth failure (401) or
28
+ // missing-key misconfiguration is ALSO treated as retryable on
29
+ // purpose: a different candidate in the tier may use a different
30
+ // provider whose key is fine, so the request can still succeed - the
31
+ // broken key itself still surfaces on the dashboard's Provider alerts
32
+ // table via the metrics.record() call made before moving on (see
33
+ // server.js), so failover keeps requests succeeding without hiding
34
+ // the underlying problem from whoever needs to go fix that key.
35
+ function isRetryableError(err) {
36
+ const status = err && (err.status || err.statusCode);
37
+ return status !== 400 && status !== 404;
38
+ }
39
+
40
+ /**
41
+ * Calls `dispatch(candidate)` for each candidate in order until one
42
+ * resolves. On a rejection, calls `onAttemptFailed(candidate, err,
43
+ * isLastCandidate)` (for logging/metrics only - it has no bearing on
44
+ * control flow) and, unless the error is non-retryable or this was
45
+ * the last candidate, moves on to the next one. Rethrows the error
46
+ * from the LAST attempt if every candidate fails - the caller decides
47
+ * what HTTP status/response that becomes.
48
+ *
49
+ * Resolves to `{ result, candidate, attempts }` on success -
50
+ * `attempts` is 1 when the first candidate just worked, >1 when
51
+ * failover actually happened (worth logging distinctly - see
52
+ * server.js's caller).
53
+ *
54
+ * @param {Array<{provider: string, model: string}>} candidates ranked
55
+ * order, e.g. router.js's pickCandidate().rankedCandidates
56
+ * @param {(candidate) => Promise<any>} dispatch
57
+ * @param {(candidate, err, isLastCandidate) => void} [onAttemptFailed]
58
+ */
59
+ async function dispatchWithFailover(candidates, dispatch, onAttemptFailed) {
60
+ let lastErr;
61
+ for (let i = 0; i < candidates.length; i++) {
62
+ const candidate = candidates[i];
63
+ const isLastCandidate = i === candidates.length - 1;
64
+ try {
65
+ const result = await dispatch(candidate);
66
+ return { result, candidate, attempts: i + 1 };
67
+ } catch (err) {
68
+ lastErr = err;
69
+ if (onAttemptFailed) onAttemptFailed(candidate, err, isLastCandidate);
70
+ if (!isRetryableError(err) || isLastCandidate) throw err;
71
+ }
72
+ }
73
+ throw lastErr; // unreachable when candidates.length > 0; kept honest for an empty list
74
+ }
75
+
76
+ module.exports = { isRetryableError, dispatchWithFailover };