cachegate 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +83 -4
- package/LICENSE +21 -21
- package/README.md +154 -15
- package/cache.js +133 -9
- package/cascade.js +206 -0
- package/coalescing.js +65 -0
- package/embeddings.js +78 -16
- package/failover.js +76 -76
- package/guardrails.js +80 -0
- package/metrics.js +93 -7
- package/package.json +12 -2
- package/pii.js +128 -0
- package/providers/anthropic.js +122 -122
- package/providers/deepseek.js +194 -0
- package/providers/index.js +62 -0
- package/providers/openai.js +122 -114
- package/providers/openrouter.js +233 -0
- package/public/dashboard.html +1120 -1119
- package/router.js +33 -4
- package/semanticCache.js +115 -3
- package/server.js +494 -99
- package/streaming.js +77 -77
- package/tracing.js +97 -0
package/metrics.js
CHANGED
|
@@ -105,6 +105,11 @@ async function ensureSchema() {
|
|
|
105
105
|
error_type TEXT
|
|
106
106
|
);
|
|
107
107
|
ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS scope TEXT;
|
|
108
|
+
ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS coalesced BOOLEAN;
|
|
109
|
+
ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS quality_score REAL;
|
|
110
|
+
ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS cascaded BOOLEAN;
|
|
111
|
+
ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS trace_id TEXT;
|
|
112
|
+
ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS joined_trace_id TEXT;
|
|
108
113
|
CREATE INDEX IF NOT EXISTS router_metrics_ts_idx ON router_metrics (ts DESC);
|
|
109
114
|
CREATE INDEX IF NOT EXISTS router_metrics_provider_ts_idx ON router_metrics (provider, ts DESC);
|
|
110
115
|
CREATE INDEX IF NOT EXISTS router_metrics_scope_ts_idx ON router_metrics (scope, ts DESC);
|
|
@@ -127,6 +132,11 @@ function rowFromPg(dbRow) {
|
|
|
127
132
|
model: dbRow.model || undefined,
|
|
128
133
|
requested_model: dbRow.requested_model || undefined,
|
|
129
134
|
cache_hit: dbRow.cache_hit === null ? undefined : dbRow.cache_hit,
|
|
135
|
+
coalesced: dbRow.coalesced === null ? undefined : dbRow.coalesced,
|
|
136
|
+
cascaded: dbRow.cascaded === null ? undefined : dbRow.cascaded,
|
|
137
|
+
quality_score: dbRow.quality_score === null ? undefined : dbRow.quality_score,
|
|
138
|
+
trace_id: dbRow.trace_id || undefined,
|
|
139
|
+
joined_trace_id: dbRow.joined_trace_id || undefined,
|
|
130
140
|
cache_type: dbRow.cache_type || undefined,
|
|
131
141
|
latency_ms: dbRow.latency_ms === null ? undefined : dbRow.latency_ms,
|
|
132
142
|
cost_usd: dbRow.cost_usd === null ? undefined : dbRow.cost_usd,
|
|
@@ -140,19 +150,24 @@ async function recordToPostgres(scope, entry) {
|
|
|
140
150
|
await ensureSchema();
|
|
141
151
|
await getPool().query(
|
|
142
152
|
`INSERT INTO router_metrics
|
|
143
|
-
(scope, provider, model, requested_model, cache_hit, cache_type, latency_ms, cost_usd, error, error_type)
|
|
144
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
|
153
|
+
(scope, provider, model, requested_model, cache_hit, coalesced, cascaded, quality_score, cache_type, latency_ms, cost_usd, error, error_type, trace_id, joined_trace_id)
|
|
154
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
|
145
155
|
[
|
|
146
156
|
scope != null ? String(scope) : null,
|
|
147
157
|
entry.provider ?? null,
|
|
148
158
|
entry.model ?? null,
|
|
149
159
|
entry.requested_model ?? null,
|
|
150
160
|
entry.cache_hit ?? null,
|
|
161
|
+
entry.coalesced ?? null,
|
|
162
|
+
entry.cascaded ?? null,
|
|
163
|
+
entry.quality_score ?? null,
|
|
151
164
|
entry.cache_type ?? null,
|
|
152
165
|
entry.latency_ms ?? null,
|
|
153
166
|
entry.cost_usd ?? null,
|
|
154
167
|
entry.error ?? null,
|
|
155
|
-
entry.error_type ?? null
|
|
168
|
+
entry.error_type ?? null,
|
|
169
|
+
entry.trace_id ?? null,
|
|
170
|
+
entry.joined_trace_id ?? null
|
|
156
171
|
]
|
|
157
172
|
);
|
|
158
173
|
} catch (err) {
|
|
@@ -358,8 +373,10 @@ function ensureWriteStream() {
|
|
|
358
373
|
*/
|
|
359
374
|
function record(scope, entry) {
|
|
360
375
|
if (usingPostgres()) {
|
|
361
|
-
|
|
362
|
-
|
|
376
|
+
// Returns the INSERT promise so tests/scripts can await it; request
|
|
377
|
+
// paths ignore it, so it stays fire-and-forget for them (a metrics
|
|
378
|
+
// write must never be the reason a real request fails or slows down).
|
|
379
|
+
return recordToPostgres(scope, entry);
|
|
363
380
|
}
|
|
364
381
|
try {
|
|
365
382
|
const line = JSON.stringify({
|
|
@@ -425,6 +442,13 @@ async function providerStats(scope, windowSize = 50) {
|
|
|
425
442
|
const avgLatencyMs = latencies.length
|
|
426
443
|
? latencies.reduce((sum, e) => sum + e.latency_ms, 0) / latencies.length
|
|
427
444
|
: null;
|
|
445
|
+
// Mean quality across entries that actually HAVE a quality_score
|
|
446
|
+
// (cache hits, coalesced joiners, and pre-this-change rows don't set
|
|
447
|
+
// one) - same null-tolerant pattern avgLatencyMs uses for latency.
|
|
448
|
+
const qualityScores = recent.filter((e) => typeof e.quality_score === 'number');
|
|
449
|
+
const avgQualityScore = qualityScores.length
|
|
450
|
+
? qualityScores.reduce((sum, e) => sum + e.quality_score, 0) / qualityScores.length
|
|
451
|
+
: null;
|
|
428
452
|
// The MOST RECENT error only, not a tally of every type seen in the
|
|
429
453
|
// window - an alert should reflect "what's wrong right now," not a
|
|
430
454
|
// mix that might include something already fixed earlier in the
|
|
@@ -437,6 +461,7 @@ async function providerStats(scope, windowSize = 50) {
|
|
|
437
461
|
sampleSize: recent.length,
|
|
438
462
|
errorRate: recent.length ? errorEntries.length / recent.length : 0,
|
|
439
463
|
avgLatencyMs,
|
|
464
|
+
avgQualityScore,
|
|
440
465
|
lastErrorType: lastError ? lastError.error_type || classifyErrorType(lastError.error) : null,
|
|
441
466
|
lastErrorAt: lastError ? lastError.timestamp : null
|
|
442
467
|
};
|
|
@@ -470,6 +495,61 @@ async function providerStats(scope, windowSize = 50) {
|
|
|
470
495
|
* underlying log, two different questions - not accidentally
|
|
471
496
|
* duplicated logic.
|
|
472
497
|
*/
|
|
498
|
+
|
|
499
|
+
// Shared "$ saved" formula (mirrors cachegate-cloud's usage.mjs
|
|
500
|
+
// estimatedSavings / estimatedSavingsGlobal, 2026-09-05 Phase 2 step 20):
|
|
501
|
+
// for each model, the average cost of a cache-MISS (cache_hit === false,
|
|
502
|
+
// no error) times that model's cache-HIT count, summed across models. A
|
|
503
|
+
// model with hits but no recorded miss yet contributes 0 - never a
|
|
504
|
+
// cross-model average (usage.mjs's own documented honesty floor). Rows
|
|
505
|
+
// with no model are skipped. Pure (takes rows, returns { total, perDay })
|
|
506
|
+
// so it's directly unit-testable and shared by rangeSummary() and
|
|
507
|
+
// server.js's GET /stats without duplicating the formula.
|
|
508
|
+
function computeSavings(rows) {
|
|
509
|
+
const all = new Map(); // model -> accumulator
|
|
510
|
+
const perDay = new Map(); // YYYY-MM-DD -> Map(model -> accumulator)
|
|
511
|
+
const accFor = (map, key) => {
|
|
512
|
+
let acc = map.get(key);
|
|
513
|
+
if (!acc) {
|
|
514
|
+
acc = { missCostSum: 0, missCount: 0, hits: 0 };
|
|
515
|
+
map.set(key, acc);
|
|
516
|
+
}
|
|
517
|
+
return acc;
|
|
518
|
+
};
|
|
519
|
+
for (const row of rows) {
|
|
520
|
+
if (!row.model) continue;
|
|
521
|
+
const acc = accFor(all, row.model);
|
|
522
|
+
const date = row.timestamp.slice(0, 10);
|
|
523
|
+
let dayMap = perDay.get(date);
|
|
524
|
+
if (!dayMap) {
|
|
525
|
+
dayMap = new Map();
|
|
526
|
+
perDay.set(date, dayMap);
|
|
527
|
+
}
|
|
528
|
+
const dayAcc = accFor(dayMap, row.model);
|
|
529
|
+
if (row.cache_hit === true) {
|
|
530
|
+
acc.hits += 1;
|
|
531
|
+
dayAcc.hits += 1;
|
|
532
|
+
} else if (row.cache_hit === false && !row.error) {
|
|
533
|
+
acc.missCostSum += row.cost_usd || 0;
|
|
534
|
+
acc.missCount += 1;
|
|
535
|
+
dayAcc.missCostSum += row.cost_usd || 0;
|
|
536
|
+
dayAcc.missCount += 1;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const sum = (map) => {
|
|
540
|
+
let total = 0;
|
|
541
|
+
for (const acc of map.values()) {
|
|
542
|
+
if (acc.hits > 0 && acc.missCount > 0) {
|
|
543
|
+
total += (acc.missCostSum / acc.missCount) * acc.hits;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return total;
|
|
547
|
+
};
|
|
548
|
+
const out = new Map();
|
|
549
|
+
for (const [date, map] of perDay) out.set(date, sum(map));
|
|
550
|
+
return { total: sum(all), perDay: out };
|
|
551
|
+
}
|
|
552
|
+
|
|
473
553
|
async function rangeSummary(scope, days = 14) {
|
|
474
554
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
475
555
|
|
|
@@ -506,7 +586,7 @@ async function rangeSummary(scope, days = 14) {
|
|
|
506
586
|
for (const row of inRange) {
|
|
507
587
|
const date = row.timestamp.slice(0, 10); // YYYY-MM-DD (UTC, from toISOString())
|
|
508
588
|
if (!dailyByDate.has(date)) {
|
|
509
|
-
dailyByDate.set(date, { date, requests: 0, cost_usd: 0, exact_hits: 0, semantic_hits: 0, misses: 0, errors: 0 });
|
|
589
|
+
dailyByDate.set(date, { date, requests: 0, cost_usd: 0, saved_usd: 0, exact_hits: 0, semantic_hits: 0, misses: 0, errors: 0 });
|
|
510
590
|
}
|
|
511
591
|
const bucket = dailyByDate.get(date);
|
|
512
592
|
bucket.requests += 1;
|
|
@@ -539,6 +619,8 @@ async function rangeSummary(scope, days = 14) {
|
|
|
539
619
|
}
|
|
540
620
|
}
|
|
541
621
|
|
|
622
|
+
const savings = computeSavings(inRange);
|
|
623
|
+
|
|
542
624
|
const providerSummary = {};
|
|
543
625
|
for (const [name, p] of Object.entries(byProvider)) {
|
|
544
626
|
providerSummary[name] = {
|
|
@@ -553,6 +635,7 @@ async function rangeSummary(scope, days = 14) {
|
|
|
553
635
|
days,
|
|
554
636
|
sample_size: inRange.length,
|
|
555
637
|
total_cost_usd: totalCostUsd,
|
|
638
|
+
saved_usd: savings.total,
|
|
556
639
|
cache_hit_rate: {
|
|
557
640
|
exact: inRange.length ? exactHits / inRange.length : 0,
|
|
558
641
|
semantic: inRange.length ? semanticHits / inRange.length : 0,
|
|
@@ -560,7 +643,9 @@ async function rangeSummary(scope, days = 14) {
|
|
|
560
643
|
},
|
|
561
644
|
error_rate: inRange.length ? errors / inRange.length : 0,
|
|
562
645
|
by_provider: providerSummary,
|
|
563
|
-
daily: [...dailyByDate.values()]
|
|
646
|
+
daily: [...dailyByDate.values()]
|
|
647
|
+
.sort((a, b) => a.date.localeCompare(b.date))
|
|
648
|
+
.map((d) => ({ ...d, saved_usd: savings.perDay.get(d.date) || 0 }))
|
|
564
649
|
};
|
|
565
650
|
}
|
|
566
651
|
|
|
@@ -645,6 +730,7 @@ module.exports = {
|
|
|
645
730
|
readRecent,
|
|
646
731
|
providerStats,
|
|
647
732
|
rangeSummary,
|
|
733
|
+
computeSavings,
|
|
648
734
|
pruneOlderThan,
|
|
649
735
|
pruneScopedOlderThan,
|
|
650
736
|
currentLogPath,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cachegate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|
|
@@ -10,13 +10,18 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"server.js",
|
|
12
12
|
"cache.js",
|
|
13
|
+
"cascade.js",
|
|
14
|
+
"coalescing.js",
|
|
13
15
|
"embeddings.js",
|
|
14
16
|
"failover.js",
|
|
17
|
+
"guardrails.js",
|
|
15
18
|
"metrics.js",
|
|
19
|
+
"pii.js",
|
|
16
20
|
"redisClient.js",
|
|
17
21
|
"router.js",
|
|
18
22
|
"semanticCache.js",
|
|
19
23
|
"streaming.js",
|
|
24
|
+
"tracing.js",
|
|
20
25
|
"providers/",
|
|
21
26
|
"public/",
|
|
22
27
|
".env.example"
|
|
@@ -26,10 +31,15 @@
|
|
|
26
31
|
},
|
|
27
32
|
"scripts": {
|
|
28
33
|
"start": "node server.js",
|
|
29
|
-
"test": "node --test"
|
|
34
|
+
"test": "node --test",
|
|
35
|
+
"eval:semantic-cache": "node eval/semantic-cache-eval.js"
|
|
30
36
|
},
|
|
31
37
|
"dependencies": {
|
|
32
38
|
"@anthropic-ai/sdk": "^0.115.0",
|
|
39
|
+
"@huggingface/transformers": "^3.0.0",
|
|
40
|
+
"@opentelemetry/api": "^1.9.0",
|
|
41
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
|
|
42
|
+
"@opentelemetry/sdk-node": "^0.222.0",
|
|
33
43
|
"dotenv": "^16.3.1",
|
|
34
44
|
"express": "^4.18.2",
|
|
35
45
|
"express-rate-limit": "^8.6.2",
|
package/pii.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// model-router/pii.js
|
|
2
|
+
//
|
|
3
|
+
// Pattern-based PII detection + redaction (roadmap step 25, PII track).
|
|
4
|
+
// Gated behind GUARDRAILS_PII_REDACTION (default OFF, same off-by-default
|
|
5
|
+
// convention as step 22's local embeddings) - ships fully inert until a
|
|
6
|
+
// deployment opts in. Standalone for now: NOT wired into server.js yet
|
|
7
|
+
// (see the step 25 kickoff directive) - this module only exposes the
|
|
8
|
+
// detection/redaction primitive; the pre-dispatch request-path wiring
|
|
9
|
+
// (and the coordination with the injection/policy track it needs to
|
|
10
|
+
// share a hook shape with) is a separate, joint follow-up change.
|
|
11
|
+
//
|
|
12
|
+
// Deliberately pattern/regex based, not a model call: PII redaction has
|
|
13
|
+
// to be synchronous, fast (it would run on every request, not just
|
|
14
|
+
// cache misses), and free of its own network dependency - a step whose
|
|
15
|
+
// whole job is stripping sensitive content out of a request shouldn't
|
|
16
|
+
// itself be a network hop that could leak that content to a third party.
|
|
17
|
+
//
|
|
18
|
+
// Redaction never surfaces the actual matched value anywhere, even in
|
|
19
|
+
// its own return value - only { type, count } counters. A false
|
|
20
|
+
// positive costs an unnecessary redaction (annoying); a leaked value in
|
|
21
|
+
// a log or a metrics row costs an actual PII exposure. This module is
|
|
22
|
+
// built to make the cheaper mistake.
|
|
23
|
+
|
|
24
|
+
// Order matters: more specific/longer patterns run first, so a token
|
|
25
|
+
// that could satisfy two shapes (e.g. a 16-digit run also containing a
|
|
26
|
+
// phone-shaped substring) is claimed by the more specific match before
|
|
27
|
+
// a looser pattern gets a chance to partially match what's left of it.
|
|
28
|
+
const PATTERNS = [
|
|
29
|
+
{
|
|
30
|
+
type: 'email',
|
|
31
|
+
regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
type: 'credit_card',
|
|
35
|
+
// 13-19 digits, optionally grouped by single spaces or dashes
|
|
36
|
+
// between digits (covers both "4111111111111111" and
|
|
37
|
+
// "4111 1111 1111 1111"/"4111-1111-1111-1111"). Matched by shape
|
|
38
|
+
// first, then narrowed by a Luhn checksum below - shape alone would
|
|
39
|
+
// false-positive on any long unrelated digit run (an order id, a
|
|
40
|
+
// padded invoice number).
|
|
41
|
+
regex: /\b(?:\d[ -]?){12,18}\d\b/g,
|
|
42
|
+
validate: (match) => luhnValid(match.replace(/[ -]/g, ''))
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: 'ssn',
|
|
46
|
+
// US SSN: NNN-NN-NNNN. Dashes required - a bare 9-digit run is too
|
|
47
|
+
// easy to confuse with an account/phone number to redact safely
|
|
48
|
+
// without a much higher false-positive rate.
|
|
49
|
+
regex: /\b\d{3}-\d{2}-\d{4}\b/g
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
type: 'phone',
|
|
53
|
+
// NA-style, requiring at least one separator (space/dash/dot/
|
|
54
|
+
// parens) - a bare 10-digit run is left to the credit-card pattern
|
|
55
|
+
// above (which needs 13+ digits, so no real overlap) rather than
|
|
56
|
+
// guessed at here. Uses digit lookaround, not \b, at both ends: a
|
|
57
|
+
// leading "(" is itself a non-word character, so a \b right before
|
|
58
|
+
// it never matches (word-boundary needs one word char and one
|
|
59
|
+
// non-word char either side) - \b would let the engine skip past a
|
|
60
|
+
// real "(555)" opening paren and leave it un-redacted outside the
|
|
61
|
+
// match. (?<!\d)/(?!\d) only cares that the run isn't glued to more
|
|
62
|
+
// digits, which is what actually needs guarding against here.
|
|
63
|
+
regex: /(?<!\d)(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]\d{3}[ .-]\d{4}(?!\d)/g
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: 'api_key',
|
|
67
|
+
// Common vendor secret shapes (OpenAI classic "sk-...", OpenAI
|
|
68
|
+
// project-scoped "sk-proj-...", Anthropic "sk-ant-...", AWS,
|
|
69
|
+
// GitHub, Slack, Google) - opaque tokens like these are exactly the
|
|
70
|
+
// kind of thing that ends up pasted into a prompt by accident. The
|
|
71
|
+
// "sk-" body allows dashes/underscores, not just alphanumerics, so
|
|
72
|
+
// it covers the dash-separated "-proj-"/"-ant-" variants too rather
|
|
73
|
+
// than needing one alternative per vendor prefix.
|
|
74
|
+
regex: /\b(?:sk-[a-zA-Z0-9_-]{20,}|AKIA[0-9A-Z]{16}|gh[pousr]_[a-zA-Z0-9]{20,}|xox[baprs]-[a-zA-Z0-9-]{10,}|AIza[0-9A-Za-z_-]{35})\b/g
|
|
75
|
+
}
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
function luhnValid(digits) {
|
|
79
|
+
let sum = 0;
|
|
80
|
+
let alternate = false;
|
|
81
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
82
|
+
let n = Number(digits[i]);
|
|
83
|
+
if (alternate) {
|
|
84
|
+
n *= 2;
|
|
85
|
+
if (n > 9) n -= 9;
|
|
86
|
+
}
|
|
87
|
+
sum += n;
|
|
88
|
+
alternate = !alternate;
|
|
89
|
+
}
|
|
90
|
+
return sum % 10 === 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isEnabled() {
|
|
94
|
+
return process.env.GUARDRAILS_PII_REDACTION === 'true';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// redact(text) -> { text, redactions: [{ type, count }] }.
|
|
98
|
+
// Side-effect free and safe to call unconditionally - when the flag is
|
|
99
|
+
// off, returns the text UNCHANGED (not an error, not a throw), same
|
|
100
|
+
// "always callable, flag decides" shape as embeddings.js's isEnabled().
|
|
101
|
+
function redact(text) {
|
|
102
|
+
if (!isEnabled() || typeof text !== 'string' || text.length === 0) {
|
|
103
|
+
return { text, redactions: [] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let result = text;
|
|
107
|
+
const counts = new Map();
|
|
108
|
+
|
|
109
|
+
for (const { type, regex, validate } of PATTERNS) {
|
|
110
|
+
// A fresh RegExp per pattern per call: the source patterns are
|
|
111
|
+
// global (/g), and a shared stateful regex's lastIndex would
|
|
112
|
+
// corrupt matching across concurrent/repeated calls in a
|
|
113
|
+
// long-running process handling many requests.
|
|
114
|
+
const re = new RegExp(regex.source, regex.flags);
|
|
115
|
+
result = result.replace(re, (match) => {
|
|
116
|
+
if (validate && !validate(match)) return match; // shape matched but failed validation - leave as-is
|
|
117
|
+
counts.set(type, (counts.get(type) || 0) + 1);
|
|
118
|
+
return `[REDACTED_${type.toUpperCase()}]`;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
text: result,
|
|
124
|
+
redactions: Array.from(counts.entries()).map(([type, count]) => ({ type, count }))
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { isEnabled, redact };
|
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 };
|