cachegate 1.2.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 +21 -21
- package/failover.js +76 -76
- package/metrics.js +52 -2
- package/package.json +1 -1
- package/providers/anthropic.js +122 -122
- package/providers/openai.js +114 -114
- package/public/dashboard.html +1119 -1119
- package/server.js +19 -0
- package/streaming.js +77 -77
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
|
@@ -206,6 +206,15 @@ async function pruneOlderThanPostgres(days) {
|
|
|
206
206
|
return result.rows.map((r) => r.id);
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
async function pruneScopedOlderThanPostgres(scope, days) {
|
|
210
|
+
await ensureSchema();
|
|
211
|
+
const result = await getPool().query(
|
|
212
|
+
`DELETE FROM router_metrics WHERE scope = $1 AND ts < now() - ($2::double precision * interval '1 day') RETURNING id`,
|
|
213
|
+
[String(scope), days]
|
|
214
|
+
);
|
|
215
|
+
return result.rows.map((r) => r.id);
|
|
216
|
+
}
|
|
217
|
+
|
|
209
218
|
// Turns a raw provider error message into one of a handful of stable,
|
|
210
219
|
// human-meaningful buckets - the difference between a dashboard that says
|
|
211
220
|
// "openai: 100% error rate" (true, but not actionable without opening a
|
|
@@ -349,8 +358,10 @@ function ensureWriteStream() {
|
|
|
349
358
|
*/
|
|
350
359
|
function record(scope, entry) {
|
|
351
360
|
if (usingPostgres()) {
|
|
352
|
-
|
|
353
|
-
|
|
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);
|
|
354
365
|
}
|
|
355
366
|
try {
|
|
356
367
|
const line = JSON.stringify({
|
|
@@ -581,6 +592,44 @@ async function pruneOlderThan(days) {
|
|
|
581
592
|
return deleted;
|
|
582
593
|
}
|
|
583
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Scope-isolated cleanup (seams work): deletes only `scope`'s records
|
|
597
|
+
* older than `days`, leaving every other tenant's history untouched.
|
|
598
|
+
* This is the primitive a per-tenant retention policy needs - the DAYS
|
|
599
|
+
* live in the caller (a billing/tier decision, e.g. Free 7d / Starter
|
|
600
|
+
* 30d / Growth 90d), while the scope-filtered DELETE lives here, under
|
|
601
|
+
* the same `scope` contract as readRecent/providerStats/rangeSummary (a
|
|
602
|
+
* primitive; `scope = $1`). Postgres-only: the JSONL file backend keeps
|
|
603
|
+
* every scope in shared per-day append-only files, so excising one scope
|
|
604
|
+
* would mean rewriting those files mid-append - scoped prune is a
|
|
605
|
+
* multi-tenant (Postgres) concern, and on the file backend it is a
|
|
606
|
+
* documented no-op (warns, returns []). The global pruneOlderThan(days)
|
|
607
|
+
* above stays the unscoped, delete-everything form, for legacy/null-
|
|
608
|
+
* scope history and standalone single-tenant runs.
|
|
609
|
+
*/
|
|
610
|
+
async function pruneScopedOlderThan(scope, days) {
|
|
611
|
+
// Fail closed on a null/undefined scope: this function is the SCOPED
|
|
612
|
+
// form, and passing null (which readRecent/providerStats/rangeSummary
|
|
613
|
+
// treat as "global") would otherwise either silently delete nothing
|
|
614
|
+
// (Postgres: scope = 'null' matches no real tenant) or warn-and-no-op
|
|
615
|
+
// (file backend) - both silent, both wrong for a caller who expected a
|
|
616
|
+
// global prune. Delegating to pruneOlderThan(days) instead would be
|
|
617
|
+
// the OPPOSITE hazard (silently deleting every tenant's history), so
|
|
618
|
+
// the safe answer is a loud error pointing at the right function.
|
|
619
|
+
if (scope == null) {
|
|
620
|
+
throw new Error(
|
|
621
|
+
'pruneScopedOlderThan(scope, days) requires a non-null scope. ' +
|
|
622
|
+
'Use pruneOlderThan(days) to prune globally.'
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
if (usingPostgres()) return pruneScopedOlderThanPostgres(scope, days);
|
|
626
|
+
console.warn(
|
|
627
|
+
'⚠️ pruneScopedOlderThan() is Postgres-only: the JSONL file backend ' +
|
|
628
|
+
'cannot excise one scope from shared per-day files. Nothing deleted.'
|
|
629
|
+
);
|
|
630
|
+
return [];
|
|
631
|
+
}
|
|
632
|
+
|
|
584
633
|
// Test-only: closes the cached pool (if one was ever opened) so a test
|
|
585
634
|
// run doesn't hang on an open connection, and so the NEXT test that
|
|
586
635
|
// re-requires this module with a different DATABASE_URL gets a fresh
|
|
@@ -599,6 +648,7 @@ module.exports = {
|
|
|
599
648
|
providerStats,
|
|
600
649
|
rangeSummary,
|
|
601
650
|
pruneOlderThan,
|
|
651
|
+
pruneScopedOlderThan,
|
|
602
652
|
currentLogPath,
|
|
603
653
|
listLogFiles,
|
|
604
654
|
classifyErrorType,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cachegate",
|
|
3
|
-
"version": "1.
|
|
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",
|
package/providers/anthropic.js
CHANGED
|
@@ -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 };
|