cachegate 1.3.0 → 1.3.1

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 MemoCode
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MemoCode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/failover.js CHANGED
@@ -1,76 +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 };
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 };
package/metrics.js CHANGED
@@ -358,8 +358,10 @@ function ensureWriteStream() {
358
358
  */
359
359
  function record(scope, entry) {
360
360
  if (usingPostgres()) {
361
- recordToPostgres(scope, entry); // fire-and-forget - see its own comment
362
- return;
361
+ // Returns the INSERT promise so tests/scripts can await it; request
362
+ // paths ignore it, so it stays fire-and-forget for them (a metrics
363
+ // write must never be the reason a real request fails or slows down).
364
+ return recordToPostgres(scope, entry);
363
365
  }
364
366
  try {
365
367
  const line = JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cachegate",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
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",
@@ -1,122 +1,122 @@
1
- // model-router/providers/anthropic.js
2
- const { Anthropic } = require('@anthropic-ai/sdk');
3
-
4
- function buildClient(apiKey) {
5
- return new Anthropic({ apiKey });
6
- }
7
-
8
- function estimateCost(model, inputTokens, outputTokens) {
9
- // Approximate pricing per 1M tokens — update as Anthropic changes rates
10
- const rates = {
11
- 'claude-sonnet-4-5-20250929': { input: 3.0, output: 15.0 },
12
- 'claude-haiku-4-5-20251001': { input: 0.8, output: 4.0 },
13
- 'claude-3-5-sonnet-20241022': { input: 3.0, output: 15.0 },
14
- 'claude-3-5-sonnet-20240620': { input: 3.0, output: 15.0 }
15
- };
16
- const rate = rates[model] || { input: 3.0, output: 15.0 };
17
- return ((inputTokens * rate.input) + (outputTokens * rate.output)) / 1_000_000;
18
- }
19
-
20
- async function chat(client, payload) {
21
- const systemMessage = payload.messages.find(m => m.role === 'system');
22
- const userMessages = payload.messages.filter(m => m.role !== 'system');
23
-
24
- const request = {
25
- model: payload.model,
26
- max_tokens: payload.max_tokens || 1024,
27
- temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
28
- messages: userMessages,
29
- ...(systemMessage && { system: systemMessage.content }),
30
- ...(payload.tools && { tools: payload.tools }),
31
- ...(payload.tool_choice && { tool_choice: payload.tool_choice })
32
- };
33
-
34
- const start = Date.now();
35
- const response = await client.messages.create(request);
36
- const latencyMs = Date.now() - start;
37
-
38
- const inputTokens = response.usage.input_tokens;
39
- const outputTokens = response.usage.output_tokens;
40
- const costUsd = estimateCost(payload.model, inputTokens, outputTokens);
41
-
42
- const toolCall = response.content.find(c => c.type === 'tool_use');
43
- const textContent = response.content
44
- .filter(c => c.type === 'text')
45
- .map(c => c.text)
46
- .join('');
47
-
48
- return {
49
- provider: 'anthropic',
50
- model: payload.model,
51
- latency_ms: latencyMs,
52
- usage: { input_tokens: inputTokens, output_tokens: outputTokens },
53
- cost_usd: costUsd,
54
- content: textContent,
55
- tool_calls: toolCall ? [toolCall] : undefined,
56
- raw: response
57
- };
58
- }
59
-
60
- /**
61
- * Pure state-accumulation for one Anthropic streaming event - factored
62
- * out from chatStream() so the trickiest part (pulling usage/cost data
63
- * out of a stream instead of one final response object) is directly
64
- * unit-testable with canned events, no live API needed. Mutates
65
- * `state` ({content, inputTokens, outputTokens}) and calls onDelta()
66
- * with each new piece of assistant text.
67
- */
68
- function applyStreamEvent(state, event, onDelta) {
69
- if (event.type === 'message_start') {
70
- state.inputTokens = event.message.usage.input_tokens;
71
- state.outputTokens = event.message.usage.output_tokens || 0;
72
- } else if (event.type === 'content_block_delta' && event.delta && event.delta.type === 'text_delta') {
73
- state.content += event.delta.text;
74
- onDelta(event.delta.text);
75
- } else if (event.type === 'message_delta' && event.usage) {
76
- // Anthropic reports output_tokens progressively here; the last one
77
- // received before message_stop is the final total.
78
- state.outputTokens = event.usage.output_tokens;
79
- }
80
- }
81
-
82
- /**
83
- * Streaming counterpart to chat(). Scope: plain text content only - no
84
- * tools/tool_choice forwarded (server.js rejects stream:true + tools
85
- * before this is ever called; see streaming.js for why).
86
- */
87
- async function chatStream(client, payload, { onDelta, signal } = {}) {
88
- const systemMessage = payload.messages.find(m => m.role === 'system');
89
- const userMessages = payload.messages.filter(m => m.role !== 'system');
90
-
91
- const request = {
92
- model: payload.model,
93
- max_tokens: payload.max_tokens || 1024,
94
- temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
95
- messages: userMessages,
96
- ...(systemMessage && { system: systemMessage.content }),
97
- stream: true
98
- };
99
-
100
- const start = Date.now();
101
- const stream = await client.messages.create(request, signal ? { signal } : undefined);
102
-
103
- const state = { content: '', inputTokens: 0, outputTokens: 0 };
104
- for await (const event of stream) {
105
- applyStreamEvent(state, event, onDelta || (() => {}));
106
- }
107
-
108
- const latencyMs = Date.now() - start;
109
- const costUsd = estimateCost(payload.model, state.inputTokens, state.outputTokens);
110
-
111
- return {
112
- provider: 'anthropic',
113
- model: payload.model,
114
- latency_ms: latencyMs,
115
- usage: { input_tokens: state.inputTokens, output_tokens: state.outputTokens },
116
- cost_usd: costUsd,
117
- content: state.content,
118
- tool_calls: undefined
119
- };
120
- }
121
-
122
- module.exports = { buildClient, chat, chatStream, applyStreamEvent, estimateCost };
1
+ // model-router/providers/anthropic.js
2
+ const { Anthropic } = require('@anthropic-ai/sdk');
3
+
4
+ function buildClient(apiKey) {
5
+ return new Anthropic({ apiKey });
6
+ }
7
+
8
+ function estimateCost(model, inputTokens, outputTokens) {
9
+ // Approximate pricing per 1M tokens — update as Anthropic changes rates
10
+ const rates = {
11
+ 'claude-sonnet-4-5-20250929': { input: 3.0, output: 15.0 },
12
+ 'claude-haiku-4-5-20251001': { input: 0.8, output: 4.0 },
13
+ 'claude-3-5-sonnet-20241022': { input: 3.0, output: 15.0 },
14
+ 'claude-3-5-sonnet-20240620': { input: 3.0, output: 15.0 }
15
+ };
16
+ const rate = rates[model] || { input: 3.0, output: 15.0 };
17
+ return ((inputTokens * rate.input) + (outputTokens * rate.output)) / 1_000_000;
18
+ }
19
+
20
+ async function chat(client, payload) {
21
+ const systemMessage = payload.messages.find(m => m.role === 'system');
22
+ const userMessages = payload.messages.filter(m => m.role !== 'system');
23
+
24
+ const request = {
25
+ model: payload.model,
26
+ max_tokens: payload.max_tokens || 1024,
27
+ temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
28
+ messages: userMessages,
29
+ ...(systemMessage && { system: systemMessage.content }),
30
+ ...(payload.tools && { tools: payload.tools }),
31
+ ...(payload.tool_choice && { tool_choice: payload.tool_choice })
32
+ };
33
+
34
+ const start = Date.now();
35
+ const response = await client.messages.create(request);
36
+ const latencyMs = Date.now() - start;
37
+
38
+ const inputTokens = response.usage.input_tokens;
39
+ const outputTokens = response.usage.output_tokens;
40
+ const costUsd = estimateCost(payload.model, inputTokens, outputTokens);
41
+
42
+ const toolCall = response.content.find(c => c.type === 'tool_use');
43
+ const textContent = response.content
44
+ .filter(c => c.type === 'text')
45
+ .map(c => c.text)
46
+ .join('');
47
+
48
+ return {
49
+ provider: 'anthropic',
50
+ model: payload.model,
51
+ latency_ms: latencyMs,
52
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens },
53
+ cost_usd: costUsd,
54
+ content: textContent,
55
+ tool_calls: toolCall ? [toolCall] : undefined,
56
+ raw: response
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Pure state-accumulation for one Anthropic streaming event - factored
62
+ * out from chatStream() so the trickiest part (pulling usage/cost data
63
+ * out of a stream instead of one final response object) is directly
64
+ * unit-testable with canned events, no live API needed. Mutates
65
+ * `state` ({content, inputTokens, outputTokens}) and calls onDelta()
66
+ * with each new piece of assistant text.
67
+ */
68
+ function applyStreamEvent(state, event, onDelta) {
69
+ if (event.type === 'message_start') {
70
+ state.inputTokens = event.message.usage.input_tokens;
71
+ state.outputTokens = event.message.usage.output_tokens || 0;
72
+ } else if (event.type === 'content_block_delta' && event.delta && event.delta.type === 'text_delta') {
73
+ state.content += event.delta.text;
74
+ onDelta(event.delta.text);
75
+ } else if (event.type === 'message_delta' && event.usage) {
76
+ // Anthropic reports output_tokens progressively here; the last one
77
+ // received before message_stop is the final total.
78
+ state.outputTokens = event.usage.output_tokens;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Streaming counterpart to chat(). Scope: plain text content only - no
84
+ * tools/tool_choice forwarded (server.js rejects stream:true + tools
85
+ * before this is ever called; see streaming.js for why).
86
+ */
87
+ async function chatStream(client, payload, { onDelta, signal } = {}) {
88
+ const systemMessage = payload.messages.find(m => m.role === 'system');
89
+ const userMessages = payload.messages.filter(m => m.role !== 'system');
90
+
91
+ const request = {
92
+ model: payload.model,
93
+ max_tokens: payload.max_tokens || 1024,
94
+ temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
95
+ messages: userMessages,
96
+ ...(systemMessage && { system: systemMessage.content }),
97
+ stream: true
98
+ };
99
+
100
+ const start = Date.now();
101
+ const stream = await client.messages.create(request, signal ? { signal } : undefined);
102
+
103
+ const state = { content: '', inputTokens: 0, outputTokens: 0 };
104
+ for await (const event of stream) {
105
+ applyStreamEvent(state, event, onDelta || (() => {}));
106
+ }
107
+
108
+ const latencyMs = Date.now() - start;
109
+ const costUsd = estimateCost(payload.model, state.inputTokens, state.outputTokens);
110
+
111
+ return {
112
+ provider: 'anthropic',
113
+ model: payload.model,
114
+ latency_ms: latencyMs,
115
+ usage: { input_tokens: state.inputTokens, output_tokens: state.outputTokens },
116
+ cost_usd: costUsd,
117
+ content: state.content,
118
+ tool_calls: undefined
119
+ };
120
+ }
121
+
122
+ module.exports = { buildClient, chat, chatStream, applyStreamEvent, estimateCost };