cachegate 1.3.1 → 1.4.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/server.js CHANGED
@@ -30,30 +30,116 @@ const semanticCache = require('./semanticCache');
30
30
  const metrics = require('./metrics');
31
31
  const router = require('./router');
32
32
  const streaming = require('./streaming');
33
- const anthropicProvider = require('./providers/anthropic');
34
- const openaiProvider = require('./providers/openai');
33
+ const providers = require('./providers');
34
+ // Kept as aliases: the cascade/grader paths below name these two explicitly
35
+ // (OpenAI's logprobs are the only native confidence signal; Anthropic falls
36
+ // back to a grader model). Everything that generalizes goes through
37
+ // `providers` - see providers/index.js for why.
38
+ const anthropicProvider = providers.get('anthropic');
39
+ const openaiProvider = providers.get('openai');
35
40
  const failover = require('./failover');
41
+ const coalescing = require('./coalescing');
42
+ const cascade = require('./cascade');
43
+ const tracing = require('./tracing');
44
+ const pii = require('./pii');
45
+ const guardrails = require('./guardrails');
46
+
47
+ // Status-coded error for config/validation failures inside the dispatch
48
+ // (the /v1 catch below reads err.status to pick the HTTP code, but ONLY
49
+ // on the explicit-model path - see that catch's own comment on why the
50
+ // virtual-model/failover path never honors it). skipMetric marks a
51
+ // pre-dispatch validation failure (missing key, unsupported model) that
52
+ // never actually reached a provider - the ORIGINAL inline
53
+ // `return res.status(...)` code never called metrics.record() for these
54
+ // either, so this restores that (a static misconfiguration isn't a live
55
+ // provider-health signal; it shouldn't pollute the Provider alerts table
56
+ // the same way a real dispatch failure does).
57
+ function httpError(status, message, { skipMetric = false } = {}) {
58
+ const err = new Error(message);
59
+ err.status = status;
60
+ err.skipMetric = skipMetric;
61
+ return err;
62
+ }
63
+
64
+ // Applies pii.redact() to every string in a messages array (text content
65
+ // and the `text` field of multimodal content parts), returning a NEW
66
+ // array. Non-string content (images, etc.) passes through untouched. Only
67
+ // called when pii.isEnabled(), so redaction is identity when the flag is
68
+ // off.
69
+ function redactMessages(messages) {
70
+ return messages.map((m) => {
71
+ if (!m || typeof m !== 'object') return m;
72
+ if (typeof m.content === 'string') {
73
+ return { ...m, content: pii.redact(m.content).text };
74
+ }
75
+ if (Array.isArray(m.content)) {
76
+ return {
77
+ ...m,
78
+ content: m.content.map((part) => {
79
+ if (typeof part === 'string') return pii.redact(part).text;
80
+ if (part && typeof part === 'object' && typeof part.text === 'string') {
81
+ return { ...part, text: pii.redact(part.text).text };
82
+ }
83
+ return part;
84
+ })
85
+ };
86
+ }
87
+ return m;
88
+ });
89
+ }
36
90
 
37
91
  const app = express();
38
- // Any deployment behind a reverse proxy or load balancer (nginx,
39
- // Traefik, Render, Heroku, ...) forwards the real client IP in
40
- // X-Forwarded-For rather than as the raw socket address. Express's own
41
- // default (`trust proxy` unset, i.e. false) makes express-rate-limit
42
- // refuse that header outright the moment it's present - it throws
43
- // ERR_ERL_UNEXPECTED_X_FORWARDED_FOR inside its key generator on every
44
- // request through a rate-limited route, rather than risk keying
45
- // per-caller limits off a spoofable header it hasn't been told to
46
- // trust. Found running this behind a single-hop proxy in production
47
- // (Cachegate Cloud, 2026-09-04) - not fatal to the request itself, but
48
- // it means the per-IP rate limiter was keying off the proxy's own IP
49
- // for every caller instead of each real client, so brute-force/abuse
50
- // limiting on auth-style routes was effectively shared across ALL
51
- // users rather than per-user. `1` (trust exactly one hop) is the
52
- // correct value for a single reverse-proxy topology - the common case
53
- // this engine actually runs behind. A deployment with more than one
54
- // proxy hop in front of it should set this to the real hop count
55
- // instead (see Express's own `trust proxy` docs) rather than assume 1.
56
- app.set('trust proxy', 1);
92
+ // Step 36 (observability): initialize OTel once, at module load. A no-op
93
+ // unless OTEL_ENABLED + OTEL_EXPORTER_OTLP_ENDPOINT are both set (see
94
+ // tracing.js) - so both the standalone server and a wrapping deployment
95
+ // (cachegate-cloud's cloud-server.js) get tracing without any extra call.
96
+ tracing.initTracing();
97
+ // `trust proxy`, now CONFIGURABLE and secure by default. The previous hardcoded `1` was chosen for
98
+ // Cachegate Cloud's single-hop topology (2026-09-04) and is right THERE, but it is the wrong default
99
+ // for the topology this engine's own README documents (`docker run -p 4000:4000`: no proxy at all).
100
+ // In that topology `1` trusts a client-controlled X-Forwarded-For, so any caller can present a fresh
101
+ // IP on every request and the per-IP limiter on the key-holding routes is defeated - fail-OPEN, and
102
+ // silent. `false` behind a real proxy is fail-CLOSED and loud: the limiter keys globally, one env var
103
+ // away from correct. Every other security decision in this file fails closed (no DATABASE_URL, no
104
+ // API_KEY_ENCRYPTION_SECRET, auth) and this should not be the exception.
105
+ //
106
+ // Deployment note: a proxied deployment MUST now set TRUST_PROXY explicitly - Cachegate Cloud sets
107
+ // TRUST_PROXY=1 in render.yaml and RENDER-ENV-MAP.md. Upgrade impact is a boot-time warning on Render
108
+ // (see below) plus a CHANGELOG entry, not a silent change of limiter scope.
109
+ function resolveTrustProxy(raw) {
110
+ const value = raw == null ? '' : String(raw).trim();
111
+ if (value === '') {
112
+ // Only warn where a proxy demonstrably exists: Render sets RENDER/RENDER_EXTERNAL_URL. Warning on
113
+ // every unset boot would be noise in the topology where false is the correct answer.
114
+ if (process.env.RENDER || process.env.RENDER_EXTERNAL_URL) {
115
+ console.warn(
116
+ '⚠️ TRUST_PROXY is unset and this looks like a proxied deployment: assuming NO proxy. ' +
117
+ 'Set TRUST_PROXY=1 (single hop) or the real hop count, or per-IP rate limiting will be global.'
118
+ );
119
+ }
120
+ return false;
121
+ }
122
+ const lower = value.toLowerCase();
123
+ if (['false', '0', 'off', 'no'].includes(lower)) return false;
124
+ if (lower === 'true') return true;
125
+ if (/^\d+$/.test(value)) return parseInt(value, 10);
126
+ // Express also accepts a list of addresses/CIDRs plus the keywords loopback/linklocal/uniquelocal -
127
+ // the form a real multi-hop deployment needs - so that is passed through. A guess here is not
128
+ // harmless: a typo that express cannot parse would fall back to trusting nothing (or everything),
129
+ // silently changing limiter scope, which is the class of bug this whole block exists to prevent.
130
+ const tokens = value.split(',').map((t) => t.trim()).filter(Boolean);
131
+ const valid = tokens.length > 0 && tokens.every((t) =>
132
+ ['loopback', 'linklocal', 'uniquelocal'].includes(t.toLowerCase()) ||
133
+ /^[0-9a-fA-F:.]+(\/\d{1,3})?$/.test(t));
134
+ if (valid) return tokens;
135
+ throw new Error(
136
+ `TRUST_PROXY="${value}" is not a value express can use. Expected false/true, a hop count, or a ` +
137
+ 'comma-separated list of IPs/CIDRs (loopback, linklocal, uniquelocal are also accepted). ' +
138
+ 'Refusing to start rather than silently changing rate-limiter scope.'
139
+ );
140
+ }
141
+
142
+ app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY));
57
143
  // No X-Powered-By: Express - free, standard hardening (avoids handing a
58
144
  // public-facing service's framework fingerprint to every caller for no
59
145
  // benefit).
@@ -151,8 +237,10 @@ const seams = {
151
237
  // (See the callers below: they never cache a client built from a
152
238
  // non-default resolver's key, so a decrypted per-tenant secret never
153
239
  // outlives the one request it was resolved for.)
154
- resolveProviderKey: (scope, provider) =>
155
- (provider === 'anthropic' ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY) || null,
240
+ resolveProviderKey: (scope, provider) => {
241
+ const key = providers.envKey(provider);
242
+ return (key ? process.env[key] : null) || null;
243
+ },
156
244
 
157
245
  // Passed straight through as express-rate-limit's own `keyGenerator`.
158
246
  // Default: undefined, so express-rate-limit's own per-IP default
@@ -280,25 +368,31 @@ app.use('/v1', rateLimiter, requireInternalKey, express.json({ limit: process.en
280
368
  // tenant), every call below builds a fresh client instead of caching
281
369
  // one - a decrypted secret must never outlive the one request it was
282
370
  // resolved for.
283
- let anthropicClient;
284
- let openaiClient;
371
+ // One cached client per provider for the default (unscoped) resolver. A
372
+ // scoped resolver may return a per-tenant decrypted secret, so those clients
373
+ // are deliberately NOT cached - a decrypted key must never outlive the request
374
+ // it was resolved for.
375
+ const defaultClients = new Map();
285
376
 
286
- async function getAnthropicClient(scope) {
287
- const key = await seams.resolveProviderKey(scope, 'anthropic');
377
+ async function getProviderClient(scope, provider) {
378
+ const mod = providers.get(provider);
379
+ if (!mod) throw Object.assign(new Error(`unknown provider: ${provider}`), { status: 500 });
380
+ const key = await seams.resolveProviderKey(scope, provider);
288
381
  if (scope == null) {
289
- if (!anthropicClient) anthropicClient = anthropicProvider.buildClient(key);
290
- return anthropicClient;
382
+ if (!defaultClients.has(provider)) defaultClients.set(provider, mod.buildClient(key));
383
+ return defaultClients.get(provider);
291
384
  }
292
- return anthropicProvider.buildClient(key);
385
+ return mod.buildClient(key);
293
386
  }
294
387
 
295
- async function getOpenAiClient(scope) {
296
- const key = await seams.resolveProviderKey(scope, 'openai');
297
- if (scope == null) {
298
- if (!openaiClient) openaiClient = openaiProvider.buildClient(key);
299
- return openaiClient;
300
- }
301
- return openaiProvider.buildClient(key);
388
+ const getAnthropicClient = (scope) => getProviderClient(scope, 'anthropic');
389
+ const getOpenAiClient = (scope) => getProviderClient(scope, 'openai');
390
+
391
+ // Thin wrappers kept for the call sites that genuinely mean "this one
392
+ // provider" (cascade confidence, the grader). Everything else asks the
393
+ // registry: providerForModel() is the single answer to "who serves this?".
394
+ function providerForModel(model) {
395
+ return providers.detectProvider(model);
302
396
  }
303
397
 
304
398
  function isModelAnthropic(model) {
@@ -352,6 +446,7 @@ app.get('/stats', requireInternalKey, readEndpointLimiter, async (req, res) => {
352
446
  const totalCostUsd = recent.reduce((sum, r) => sum + (r.cost_usd || 0), 0);
353
447
  const exactHits = recent.filter((r) => r.cache_hit && r.cache_type !== 'semantic').length;
354
448
  const semanticHits = recent.filter((r) => r.cache_hit && r.cache_type === 'semantic').length;
449
+ const savedUsd = metrics.computeSavings(recent).total;
355
450
  res.json({
356
451
  sample_size: recent.length,
357
452
  cache_hit_rate: {
@@ -360,6 +455,7 @@ app.get('/stats', requireInternalKey, readEndpointLimiter, async (req, res) => {
360
455
  combined: recent.length ? (exactHits + semanticHits) / recent.length : 0
361
456
  },
362
457
  total_cost_usd: totalCostUsd,
458
+ saved_usd: savedUsd,
363
459
  by_provider: byProvider,
364
460
  // Internal routing configuration - which providers have a key
365
461
  // configured, which virtual-model tiers exist, and which strategy picks
@@ -476,17 +572,75 @@ function streamCachedReplay(res, entry, cacheType) {
476
572
  // against more than one provider in the same request. Throws an error
477
573
  // with `.status` set so failover.isRetryableError() can decide whether
478
574
  // it's worth trying the next candidate.
479
- async function dispatchToProvider(scope, provider, payload) {
480
- if (provider === 'anthropic') {
481
- if (!(await seams.resolveProviderKey(scope, 'anthropic'))) {
482
- throw Object.assign(new Error('ANTHROPIC_API_KEY not configured'), { status: 500 });
483
- }
484
- return anthropicProvider.chat(await getAnthropicClient(scope), payload);
575
+ // `options` is provider-specific dispatch hints (cascade routing, step 34).
576
+ // It reaches only providers/openai.js today (requestLogprobs); Anthropic
577
+ // ignores it. Kept backward-compatible - existing callers pass no options.
578
+ async function dispatchToProvider(scope, provider, payload, options = {}) {
579
+ const mod = providers.get(provider);
580
+ if (!mod) throw Object.assign(new Error(`unknown provider: ${provider}`), { status: 500 });
581
+ if (!(await seams.resolveProviderKey(scope, provider))) {
582
+ // Names the variable the provider actually needs - the message is built
583
+ // from the registry, so it cannot drift from resolveProviderKey above.
584
+ throw Object.assign(new Error(`${providers.envKey(provider)} not configured`), { status: 500 });
485
585
  }
486
- if (!(await seams.resolveProviderKey(scope, 'openai'))) {
487
- throw Object.assign(new Error('OPENAI_API_KEY not configured'), { status: 500 });
586
+ return mod.chat(await getProviderClient(scope, provider), payload, options);
587
+ }
588
+
589
+ // Step 34 (cascade routing): per-provider confidence estimation. OpenAI's
590
+ // path is pure (cascade.openaiLogprobConfidence over the logprobs the
591
+ // dispatch asked for). Anthropic has no native logprobs, so its confidence
592
+ // comes from a grader model - one bounded extra request to a small/cheap
593
+ // model asking "does this response fully and confidently answer the
594
+ // question? reply with a number 0-1" (Claude's recommendation over
595
+ // self-consistency, which multiplies cost 2-3x on every cheap-tier dispatch
596
+ // and works directly against cascade's own cheap-first reason to exist).
597
+ //
598
+ // The grader is opt-in (CASCADE_GRADER_MODEL): unset means Anthropic
599
+ // candidates return null confidence - fail-open, no escalation, the same
600
+ // "insufficient data must never escalate" discipline as everywhere else.
601
+ // The grader's own provider call is recorded like any other real dispatch so
602
+ // its cost is never invisible.
603
+ function buildConfidenceEstimator(scope, payload, requestedModel, traceId) {
604
+ return async (result) => {
605
+ if (!result) return null;
606
+ if (result.provider === 'openai') return cascade.openaiLogprobConfidence(result);
607
+ if (result.provider === 'anthropic') return graderConfidence(scope, payload, requestedModel, result, traceId);
608
+ return null;
609
+ };
610
+ }
611
+
612
+ async function graderConfidence(scope, payload, requestedModel, result, traceId) {
613
+ const graderModel = process.env.CASCADE_GRADER_MODEL;
614
+ if (!graderModel) return null; // no grader configured -> no signal -> fail open
615
+ // Any chat-capable provider can grade (it is asked to reply with a number),
616
+ // so this asks the registry rather than hardcoding two names.
617
+ const graderProvider = providerForModel(graderModel);
618
+ if (!graderProvider) return null;
619
+ try {
620
+ const grade = await dispatchToProvider(scope, graderProvider, {
621
+ model: graderModel,
622
+ messages: cascade.buildGraderMessages(payload.messages, result.content),
623
+ temperature: 0,
624
+ max_tokens: 8 // the grader only needs to emit a single 0-1 number
625
+ });
626
+ metrics.record(scope, {
627
+ provider: grade.provider,
628
+ model: grade.model,
629
+ requested_model: requestedModel,
630
+ cache_hit: false,
631
+ quality_score: 1.0,
632
+ trace_id: traceId,
633
+ latency_ms: grade.latency_ms,
634
+ cost_usd: grade.cost_usd
635
+ });
636
+ return cascade.parseGraderScore(grade.content);
637
+ } catch (err) {
638
+ // A grader failure (missing key, provider down) must never fail the
639
+ // request being graded - treat it as "no confidence signal" and accept
640
+ // the candidate's answer (fail-open).
641
+ console.warn('⚠️ Cascade grader failed, accepting candidate without a confidence signal:', err.message);
642
+ return null;
488
643
  }
489
- return openaiProvider.chat(await getOpenAiClient(scope), payload);
490
644
  }
491
645
 
492
646
  // The real streaming dispatch path: an actual cache miss, forwarded
@@ -499,18 +653,15 @@ async function dispatchToProvider(scope, provider, payload) {
499
653
  // about which model answered - a materially harder problem than the
500
654
  // non-streaming case, left as a documented gap rather than shipped
501
655
  // half-working (see ROADMAP.md).
502
- async function handleStreamingDispatch(req, res, payload, requestedModel, routingDecision) {
656
+ async function handleStreamingDispatch(req, res, payload, requestedModel, routingDecision, traceId) {
503
657
  const scope = req.scope;
504
- let providerName;
505
- if (isModelAnthropic(payload.model)) {
506
- providerName = 'anthropic';
507
- if (!(await seams.resolveProviderKey(scope, 'anthropic'))) return res.status(500).json({ error: 'ANTHROPIC_API_KEY not configured' });
508
- } else if (isModelOpenAi(payload.model)) {
509
- providerName = 'openai';
510
- if (!(await seams.resolveProviderKey(scope, 'openai'))) return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
511
- } else {
658
+ const providerName = providerForModel(payload.model);
659
+ if (!providerName) {
512
660
  return res.status(400).json({ error: `Unsupported model: ${payload.model}` });
513
661
  }
662
+ if (!(await seams.resolveProviderKey(scope, providerName))) {
663
+ return res.status(500).json({ error: `${providers.envKey(providerName)} not configured` });
664
+ }
514
665
 
515
666
  streaming.startSse(res);
516
667
  const id = streaming.genId();
@@ -523,8 +674,8 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
523
674
 
524
675
  let result;
525
676
  try {
526
- const client = providerName === 'anthropic' ? await getAnthropicClient(scope) : await getOpenAiClient(scope);
527
- const chatStreamFn = providerName === 'anthropic' ? anthropicProvider.chatStream : openaiProvider.chatStream;
677
+ const client = await getProviderClient(scope, providerName);
678
+ const chatStreamFn = providers.get(providerName).chatStream;
528
679
  result = await chatStreamFn(client, payload, {
529
680
  signal: controller.signal,
530
681
  onDelta: (text) => res.write(streaming.deltaChunk({ id, model: payload.model, content: text }))
@@ -543,6 +694,8 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
543
694
  model: payload.model,
544
695
  requested_model: requestedModel,
545
696
  cache_hit: false,
697
+ quality_score: 0.0,
698
+ trace_id: traceId,
546
699
  error: err.message,
547
700
  error_type: metrics.classifyErrorType(err.message)
548
701
  });
@@ -568,12 +721,30 @@ async function handleStreamingDispatch(req, res, payload, requestedModel, routin
568
721
  model: result.model,
569
722
  requested_model: requestedModel,
570
723
  cache_hit: false,
724
+ quality_score: 1.0,
725
+ trace_id: traceId,
571
726
  latency_ms: result.latency_ms,
572
727
  cost_usd: result.cost_usd
573
728
  });
574
729
  }
575
730
 
731
+ // The one request-scoped correlation id (step 36.1): generated once per
732
+ // incoming request, returned as X-Cachegate-Trace-Id, and threaded through
733
+ // every metrics.record() this request makes - so a support conversation or a
734
+ // customer's own log line can reference the exact id that ties together every
735
+ // metrics row (cache hit, each dispatch attempt, a cascade's cheap+escalated
736
+ // pair, a coalesced joiner+leader pair) this request produced. Same generator
737
+ // family as streaming.genId() (crypto.randomBytes hex), no second scheme.
576
738
  app.post('/v1/chat/completions', async (req, res) => {
739
+ const traceId = crypto.randomBytes(16).toString('hex');
740
+ res.setHeader('X-Cachegate-Trace-Id', traceId);
741
+ const modelName = (req.body && req.body.model) || 'chat.completion';
742
+ await tracing.withRootSpan(modelName, { trace_id: traceId }, () =>
743
+ handleCompletion(req, res, traceId)
744
+ );
745
+ });
746
+
747
+ async function handleCompletion(req, res, traceId) {
577
748
  const payload = req.body;
578
749
  const scope = req.scope; // set by requireInternalKey/seams.authenticate - null unless configured
579
750
 
@@ -620,13 +791,51 @@ app.post('/v1/chat/completions', async (req, res) => {
620
791
  payload.model = routingDecision.model;
621
792
  }
622
793
 
794
+ // Step 25 (joint wiring): PII redaction + injection policy run BEFORE
795
+ // the cache lookup, so redacted content is what gets cached (never raw
796
+ // PII) and a blocked request never reaches a provider or a cache write.
797
+ // Both modules are gated off by default, so this is a no-op unless the
798
+ // deployment opts in.
799
+ if (pii.isEnabled()) {
800
+ payload.messages = redactMessages(payload.messages);
801
+ }
802
+ const policy = guardrails.evaluate(payload.messages);
803
+ if (policy.decision === 'block') {
804
+ return res.status(403).json({ error: 'Request blocked by content policy.' });
805
+ }
806
+ if (policy.decision !== 'allow') {
807
+ // flag/log: pass through but record the detection. Console for now - a
808
+ // dedicated metric column is a possible follow-up (same as the
809
+ // coalesced column step 24 added).
810
+ console.warn(`[guardrails] ${policy.decision}: ${policy.reasons.join(', ')}`);
811
+ }
812
+
623
813
  // 1. Try the exact-match cache first - free, zero-risk, checked
624
814
  // before anything else (keyed on the resolved concrete model, so a
625
815
  // routed request and a direct request for the same concrete model
626
816
  // share the same cache entries). A hit is served the same way
627
817
  // whether or not the caller asked for stream:true - see
628
818
  // streamCachedReplay() for the streaming case.
629
- const cached = await cache.get(scope, payload);
819
+ const cached = await tracing.withSpan('cache.exact', { trace_id: traceId }, () => cache.get(scope, payload));
820
+
821
+ // R4 (review 2026-09-10): the measurement that decides whether URL/email literal slotting earns its
822
+ // keep. One line per request carrying (a) whether the exact cache hit and (b) which slotting rules
823
+ // fired, so a week of logs splits hit rate by slotting instead of arguing about it. slottingFlags
824
+ // also reports the GATED rules (date, number), which is what makes the number/date decision priceable
825
+ // without turning it on - that gate stays off.
826
+ //
827
+ // Only this path: slotting lives in the exact-cache key. The semantic path embeds the raw prompt, so
828
+ // slotting does not touch it and a line there would measure nothing.
829
+ //
830
+ // Honest limit, stated here rather than discovered later: this is the hit rate AMONG requests that
831
+ // slot, not the rate they would have had WITHOUT slotting. The true counterfactual needs a second
832
+ // lookup per request, which is a behaviour change - and this measurement is not allowed to change the
833
+ // thing it measures.
834
+ if (process.env.CACHE_SLOT_STATS !== '0') {
835
+ const sf = cache.slottingFlags(payload.messages);
836
+ console.log(`[cacheslot] hit=${cached ? 1 : 0} model=${payload.model} url=${sf.url} email=${sf.email} date=${sf.date} number=${sf.number} numbers_gate=${sf.numbers_gate}`);
837
+ }
838
+
630
839
  if (cached) {
631
840
  metrics.record(scope, {
632
841
  provider: cached.provider,
@@ -634,6 +843,7 @@ app.post('/v1/chat/completions', async (req, res) => {
634
843
  requested_model: requestedModel,
635
844
  cache_hit: true,
636
845
  cache_type: 'exact',
846
+ trace_id: traceId,
637
847
  latency_ms: 0,
638
848
  cost_usd: 0
639
849
  });
@@ -661,7 +871,7 @@ app.post('/v1/chat/completions', async (req, res) => {
661
871
  // prompt, not an identical one). This costs one embedding call
662
872
  // whether or not it finds anything; see semanticCache.js for why
663
873
  // that's a deliberate tradeoff, not overhead to optimize away.
664
- const semanticMatch = await semanticCache.findMatch(scope, payload);
874
+ const semanticMatch = await tracing.withSpan('cache.semantic', { trace_id: traceId }, () => semanticCache.findMatch(scope, payload));
665
875
  if (semanticMatch) {
666
876
  const hit = semanticMatch.entry;
667
877
  metrics.record(scope, {
@@ -671,6 +881,7 @@ app.post('/v1/chat/completions', async (req, res) => {
671
881
  cache_hit: true,
672
882
  cache_type: 'semantic',
673
883
  semantic_similarity: semanticMatch.similarity,
884
+ trace_id: traceId,
674
885
  latency_ms: 0,
675
886
  cost_usd: 0
676
887
  });
@@ -701,76 +912,220 @@ app.post('/v1/chat/completions', async (req, res) => {
701
912
  // so the two need different error-reporting strategies (see
702
913
  // handleStreamingDispatch's error frame vs. this path's 502 JSON).
703
914
  if (wantsStream) {
704
- return handleStreamingDispatch(req, res, payload, requestedModel, routingDecision);
915
+ return handleStreamingDispatch(req, res, payload, requestedModel, routingDecision, traceId);
705
916
  }
706
917
 
707
918
  try {
708
- let result;
709
- let failedOver = false;
710
-
711
- if (routingDecision) {
712
- // Virtual model: try the ranked candidates in order (router.js's
713
- // own health/strategy scoring already produced this order),
714
- // falling over to the next one when a provider fails in a way
715
- // that isn't the REQUEST's own fault - see
716
- // failover.isRetryableError for exactly what that means. Every
717
- // failed attempt is recorded on the dashboard the same way a
718
- // non-failed-over error would be (below), so failover keeps the
719
- // request succeeding without hiding the underlying provider
720
- // problem from the Provider alerts table.
721
- const attempt = await failover.dispatchWithFailover(
722
- routingDecision.rankedCandidates,
723
- (candidate) => dispatchToProvider(scope, candidate.provider, { ...payload, model: candidate.model }),
724
- (candidate, err) => metrics.record(scope, {
725
- provider: candidate.provider,
726
- model: candidate.model,
919
+ // 2.5. Request coalescing (step 24): identical concurrent cache-miss
920
+ // requests share ONE upstream dispatch (single-flight). Keyed on the
921
+ // exact cache key, so only byte-identical requests coalesce. The
922
+ // callback below is the leader's dispatch - provider call, cache
923
+ // writes, and the leader's cache-miss metric - and a joiner awaits
924
+ // that same promise instead of dispatching again.
925
+ // The coalescing span carries a leader/joiner role attribute so a trace
926
+ // visibly distinguishes the request that actually dispatched (leader)
927
+ // from the ones that shared its upstream call (joiners). The role is only
928
+ // known after joinOrRun settles, so the attribute is set before end().
929
+ const coalesceSpan = tracing.startSpan('coalescing', { trace_id: traceId });
930
+ let joined;
931
+ try {
932
+ joined = await coalescing.joinOrRun(
933
+ cache.buildCacheKey(scope, payload),
934
+ async () => {
935
+ let result;
936
+ let failedOver = false;
937
+ let cascaded = false;
938
+
939
+ if (routingDecision) {
940
+ // Virtual model: try the ranked candidates in order (router.js's
941
+ // own health/strategy scoring already produced this order),
942
+ // falling over to the next one when a provider fails in a way
943
+ // that isn't the REQUEST's own fault - see
944
+ // failover.isRetryableError for exactly what that means. Every
945
+ // failed attempt is recorded on the dashboard the same way a
946
+ // non-failed-over error would be (below), so failover keeps the
947
+ // request succeeding without hiding the underlying provider
948
+ // problem from the Provider alerts table.
949
+ //
950
+ // Step 34 (cascade routing): when CASCADE_ENABLED, the same walk
951
+ // ALSO escalates on a successful-but-low-confidence response -
952
+ // orthogonal to failover (which retries on ERROR). The default
953
+ // (cascade off) stays on failover.dispatchWithFailover, byte-
954
+ // identical to before cascade existed.
955
+ const onAttemptFailed = (candidate, err) => metrics.record(scope, {
956
+ provider: candidate.provider,
957
+ model: candidate.model,
958
+ requested_model: requestedModel,
959
+ cache_hit: false,
960
+ quality_score: 0.0,
961
+ trace_id: traceId,
962
+ error: err.message,
963
+ error_type: metrics.classifyErrorType(err.message)
964
+ });
965
+
966
+ // Each provider dispatch attempt - a failover retry or a cascade
967
+ // escalation - is its own child span, not folded into one, so a
968
+ // trace visibly shows "escalate to a bigger model" as a distinct
969
+ // step, not just `cascaded: true` after the fact.
970
+ const dispatchAttempt = (candidate) => tracing.withSpan(
971
+ 'dispatch',
972
+ { provider: candidate.provider, model: candidate.model, trace_id: traceId },
973
+ () => dispatchToProvider(
974
+ scope,
975
+ candidate.provider,
976
+ { ...payload, model: candidate.model },
977
+ { requestLogprobs: cascade.isEnabled() && candidate.provider === 'openai' }
978
+ )
979
+ );
980
+
981
+ let attempt;
982
+ if (cascade.isEnabled()) {
983
+ attempt = await cascade.tryWithCascade(
984
+ routingDecision.rankedCandidates,
985
+ dispatchAttempt,
986
+ buildConfidenceEstimator(scope, payload, requestedModel, traceId),
987
+ {
988
+ threshold: cascade.threshold(),
989
+ onAttemptFailed,
990
+ onEscalated: (fromCandidate, toCandidate, cheapResult) => {
991
+ // The cheap answer is rejected (that's cascade's point),
992
+ // but its dispatch was a real provider call with real cost
993
+ // - record it so the spend is never invisible. It is NOT
994
+ // marked `cascaded`: that flag belongs to the dispatch we
995
+ // escalated TO (the final record below).
996
+ //
997
+ // quality_score is ALWAYS 0.5 here, never 1.0 - caught in
998
+ // review: the original version scored it 1.0 whenever no
999
+ // earlier candidate had failed over, meaning a candidate
1000
+ // whose answer was just rejected for low confidence still
1001
+ // got a PERFECT quality score. That directly corrupts the
1002
+ // exact signal Step 33's shed logic depends on: a provider
1003
+ // that's frequently escalated past due to low confidence
1004
+ // would show a misleadingly perfect avgQualityScore instead
1005
+ // of the "this one needs a second look" signal it should.
1006
+ // A low-confidence rejection alone already disqualifies a
1007
+ // perfect score, regardless of whether failover ALSO
1008
+ // happened earlier in the same walk.
1009
+ metrics.record(scope, {
1010
+ provider: cheapResult.provider,
1011
+ model: cheapResult.model,
1012
+ requested_model: requestedModel,
1013
+ cache_hit: false,
1014
+ quality_score: 0.5,
1015
+ trace_id: traceId,
1016
+ latency_ms: cheapResult.latency_ms,
1017
+ cost_usd: cheapResult.cost_usd
1018
+ });
1019
+ console.warn(`⚠️ Model router cascade: ${fromCandidate.provider}/${fromCandidate.model} answered with low confidence, escalating to ${toCandidate.provider}/${toCandidate.model}`);
1020
+ }
1021
+ }
1022
+ );
1023
+ cascaded = attempt.cascaded;
1024
+ } else {
1025
+ attempt = await failover.dispatchWithFailover(
1026
+ routingDecision.rankedCandidates,
1027
+ dispatchAttempt,
1028
+ onAttemptFailed
1029
+ );
1030
+ }
1031
+ result = attempt.result;
1032
+ failedOver = attempt.failedOver !== undefined ? attempt.failedOver : attempt.attempts > 1;
1033
+ payload.model = result.model; // the candidate that actually served it, if failover/cascade moved past the first choice
1034
+ if (failedOver) {
1035
+ console.warn(`⚠️ Model router failover: ${routingDecision.provider}/${routingDecision.model} unavailable, served by ${attempt.candidate.provider}/${attempt.candidate.model} instead (attempt ${attempt.attempts}/${routingDecision.rankedCandidates.length})`);
1036
+ }
1037
+ } else if (providerForModel(payload.model)) {
1038
+ const directProvider = providerForModel(payload.model);
1039
+ if (!(await seams.resolveProviderKey(scope, directProvider))) {
1040
+ throw httpError(500, `${providers.envKey(directProvider)} not configured`, { skipMetric: true });
1041
+ }
1042
+ result = await providers.get(directProvider)
1043
+ .chat(await getProviderClient(scope, directProvider), payload, {
1044
+ requestLogprobs: cascade.isEnabled() && directProvider === 'openai'
1045
+ });
1046
+ } else {
1047
+ throw httpError(400, `Unsupported model: ${payload.model}`, { skipMetric: true });
1048
+ }
1049
+
1050
+ // Store in both caches - exact-match for identical future
1051
+ // requests, semantic for near-duplicate ones. Both no-op quietly if
1052
+ // their prerequisites (Redis / OPENAI_API_KEY) aren't configured.
1053
+ // Cascade (step 34): only the response that PASSES confidence gets
1054
+ // cached. A rejected low-confidence answer was already escalated
1055
+ // away inside tryWithCascade, so `result` here is always the final
1056
+ // accepted response - never the cheap one we threw away.
1057
+ await cache.set(scope, payload, result);
1058
+ await semanticCache.store(scope, payload, result);
1059
+
1060
+ metrics.record(scope, {
1061
+ provider: result.provider,
1062
+ model: result.model,
727
1063
  requested_model: requestedModel,
728
1064
  cache_hit: false,
729
- error: err.message,
730
- error_type: metrics.classifyErrorType(err.message)
731
- })
1065
+ quality_score: failedOver ? 0.5 : 1.0,
1066
+ ...(cascaded ? { cascaded: true } : {}),
1067
+ trace_id: traceId,
1068
+ latency_ms: result.latency_ms,
1069
+ cost_usd: result.cost_usd
1070
+ });
1071
+
1072
+ return { result, failedOver, cascaded };
1073
+ },
1074
+ traceId
732
1075
  );
733
- result = attempt.result;
734
- failedOver = attempt.attempts > 1;
735
- payload.model = result.model; // the candidate that actually served it, if failover moved past the first choice
736
- if (failedOver) {
737
- console.warn(`⚠️ Model router failover: ${routingDecision.provider}/${routingDecision.model} unavailable, served by ${attempt.candidate.provider}/${attempt.candidate.model} instead (attempt ${attempt.attempts}/${routingDecision.rankedCandidates.length})`);
738
- }
739
- } else if (isModelAnthropic(payload.model)) {
740
- if (!(await seams.resolveProviderKey(scope, 'anthropic'))) {
741
- return res.status(500).json({ error: 'ANTHROPIC_API_KEY not configured' });
742
- }
743
- result = await anthropicProvider.chat(await getAnthropicClient(scope), payload);
744
- } else if (isModelOpenAi(payload.model)) {
745
- if (!(await seams.resolveProviderKey(scope, 'openai'))) {
746
- return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
1076
+ } finally {
1077
+ if (joined) {
1078
+ coalesceSpan.setAttribute('coalescing.role', joined.coalesced ? 'joiner' : 'leader');
1079
+ if (joined.coalesced && joined.joinedTraceId) {
1080
+ coalesceSpan.setAttribute('coalescing.joined_trace_id', joined.joinedTraceId);
1081
+ }
747
1082
  }
748
- result = await openaiProvider.chat(await getOpenAiClient(scope), payload);
749
- } else {
750
- return res.status(400).json({ error: `Unsupported model: ${payload.model}` });
1083
+ coalesceSpan.end();
751
1084
  }
752
1085
 
753
- // Store in both caches - exact-match for identical future
754
- // requests, semantic for near-duplicate ones. Both no-op quietly if
755
- // their prerequisites (Redis / OPENAI_API_KEY) aren't configured.
756
- await cache.set(scope, payload, result);
757
- await semanticCache.store(scope, payload, result);
1086
+ const { result: dispatchOutcome, coalesced: wasCoalesced, joinedTraceId } = joined;
1087
+ const { result, failedOver, cascaded } = dispatchOutcome;
758
1088
 
759
- metrics.record(scope, {
760
- provider: result.provider,
761
- model: result.model,
762
- requested_model: requestedModel,
763
- cache_hit: false,
764
- latency_ms: result.latency_ms,
765
- cost_usd: result.cost_usd
766
- });
1089
+ if (wasCoalesced) {
1090
+ // Joiner: shared the leader's upstream call - record it distinctly
1091
+ // (coalesced: true, zero NEW cost) so coalescing is measurable, not
1092
+ // just asserted. The leader's record above is the single source of
1093
+ // cost for the one upstream call that actually happened.
1094
+ //
1095
+ // quality_score is INHERITED from the leader (failedOver came back
1096
+ // on the same shared dispatchOutcome), not omitted like a cache hit
1097
+ // - a joiner isn't "no independent dispatch happened" in the same
1098
+ // sense a cache hit is; it received the exact same result as the
1099
+ // leader, over the exact same failedOver-or-not path, so its
1100
+ // quality signal is identical, not absent. Omitting it would
1101
+ // systematically under-sample avgQualityScore precisely for the
1102
+ // busiest, most-coalesced request shapes - the opposite of what a
1103
+ // signal meant to feed future routing decisions should do. `cascaded`
1104
+ // inherits for the exact same reason: a joiner received the
1105
+ // escalated result, so it must be visible as escalated too.
1106
+ metrics.record(scope, {
1107
+ provider: result.provider,
1108
+ model: result.model,
1109
+ requested_model: requestedModel,
1110
+ cache_hit: false,
1111
+ coalesced: true,
1112
+ quality_score: failedOver ? 0.5 : 1.0,
1113
+ ...(cascaded ? { cascaded: true } : {}),
1114
+ trace_id: traceId,
1115
+ ...(joinedTraceId ? { joined_trace_id: joinedTraceId } : {}),
1116
+ latency_ms: result.latency_ms,
1117
+ cost_usd: 0
1118
+ });
1119
+ }
767
1120
 
768
1121
  res.json({
769
1122
  cached: false,
1123
+ coalesced: wasCoalesced ? true : undefined,
770
1124
  provider: result.provider,
771
1125
  model: result.model,
772
1126
  routed_from: routingDecision ? requestedModel : undefined,
773
1127
  failover: failedOver ? true : undefined,
1128
+ cascaded: cascaded ? true : undefined,
774
1129
  latency_ms: result.latency_ms,
775
1130
  usage: result.usage,
776
1131
  cost_usd: result.cost_usd,
@@ -784,23 +1139,44 @@ app.post('/v1/chat/completions', async (req, res) => {
784
1139
  });
785
1140
  } catch (err) {
786
1141
  console.error('❌ Model router error:', err.message);
787
- if (!routingDecision) {
1142
+ if (!routingDecision && !err.skipMetric) {
788
1143
  // Virtual-model attempts already record one metrics entry PER
789
1144
  // candidate as each fails (see the onAttemptFailed callback
790
1145
  // above), including whichever one was last - recording again
791
- // here would double-count it.
1146
+ // here would double-count it. skipMetric is the OTHER exclusion:
1147
+ // a pre-dispatch validation failure (missing key, unsupported
1148
+ // model - see httpError()'s own comment) never reached a
1149
+ // provider at all, so recording it here would be new behavior,
1150
+ // not a restoration - the original inline `return res.status(...)`
1151
+ // code never touched metrics for these either.
792
1152
  metrics.record(scope, {
793
- provider: isModelAnthropic(payload.model) ? 'anthropic' : 'openai',
1153
+ // `|| 'openai'` keeps the pre-registry fallback for a model nothing
1154
+ // claims, so this metric row is not a behavior change.
1155
+ provider: providerForModel(payload.model) || 'openai',
794
1156
  model: payload.model,
795
1157
  requested_model: requestedModel,
796
1158
  cache_hit: false,
1159
+ quality_score: 0.0,
1160
+ trace_id: traceId,
797
1161
  error: err.message,
798
1162
  error_type: metrics.classifyErrorType(err.message)
799
1163
  });
800
1164
  }
801
- res.status(502).json({ error: err.message });
1165
+ // err.status is only honored on the explicit-model path (where it
1166
+ // can ONLY come from this file's own httpError() calls above - a
1167
+ // deliberate 500/400 for a config/validation failure). The virtual-
1168
+ // model/failover path always falls back to 502 regardless of
1169
+ // err.status: dispatchToProvider() sets .status=500 on ITS OWN
1170
+ // thrown errors too, but only for failover.isRetryableError()'s
1171
+ // internal retry decision - that was never meant to reach the
1172
+ // client as the final status once every candidate is exhausted
1173
+ // (see the dedicated test for this exact contract: exhausted
1174
+ // failover -> 502, always, whatever the last candidate's own
1175
+ // error looked like).
1176
+ const status = !routingDecision && err.status ? err.status : 502;
1177
+ res.status(status).json({ error: err.message });
802
1178
  }
803
- });
1179
+ }
804
1180
 
805
1181
  // Step 14 (ROADMAP.md): metrics.pruneOlderThan() has existed since the
806
1182
  // day metrics.js was written, but nothing ever actually CALLED it - the
@@ -873,4 +1249,4 @@ if (require.main === module) {
873
1249
  });
874
1250
  }
875
1251
 
876
- module.exports = { app, isAuthConfigured, resolveEnvPathFromArgv, configure };
1252
+ module.exports = { app, isAuthConfigured, resolveEnvPathFromArgv, resolveTrustProxy, configure };