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/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
- recordToPostgres(scope, entry); // fire-and-forget - see its own comment
362
- return;
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()].sort((a, b) => a.date.localeCompare(b.date))
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.0",
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 };
@@ -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 };