claude-autorouter 0.3.6 → 0.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.
Files changed (42) hide show
  1. package/.env.example +12 -7
  2. package/CONTRIBUTING.md +37 -0
  3. package/README.md +43 -70
  4. package/bin/autorouter.mjs +40 -56
  5. package/docs/development.md +50 -2
  6. package/docs/hardware-benchmark.md +29 -0
  7. package/docs/hardware-comparison.md +55 -0
  8. package/docs/hardware-results-16gb.json +4002 -0
  9. package/docs/hardware-results-16gb.md +26 -0
  10. package/docs/hardware-results-64gb.json +4020 -0
  11. package/docs/reference.md +83 -40
  12. package/docs/releasing.md +76 -34
  13. package/docs/router-performance.json +1697 -0
  14. package/docs/router-performance.md +50 -0
  15. package/docs/status-performance.json +363 -0
  16. package/docs/status-performance.md +44 -0
  17. package/package.json +57 -9
  18. package/src/auto-routing.mjs +214 -0
  19. package/src/bounded-json.mjs +57 -0
  20. package/src/cli-help.mjs +87 -0
  21. package/src/config-command.mjs +141 -0
  22. package/src/config.mjs +52 -27
  23. package/src/contracts.mjs +123 -0
  24. package/src/evaluation-report.mjs +114 -0
  25. package/src/local-diagnostic.mjs +191 -0
  26. package/src/model-catalog.mjs +96 -0
  27. package/src/model-request.mjs +10 -6
  28. package/src/ollama-evaluator.mjs +9 -27
  29. package/src/onboarding.mjs +82 -23
  30. package/src/request-validation.mjs +54 -0
  31. package/src/response-observer.mjs +126 -18
  32. package/src/router.mjs +174 -70
  33. package/src/savings.mjs +74 -16
  34. package/src/server.mjs +79 -12
  35. package/src/session-history.mjs +261 -0
  36. package/src/session-log.mjs +9 -58
  37. package/src/status-state.mjs +110 -62
  38. package/src/statusline.mjs +57 -27
  39. package/src/telemetry-event.mjs +196 -0
  40. package/src/token-counter.mjs +3 -1
  41. package/src/turn-state.mjs +132 -0
  42. package/src/user-config.mjs +18 -8
package/src/savings.mjs CHANGED
@@ -1,16 +1,24 @@
1
+ import { normalizeSessionRecord, UNPRICED_REASONS } from './telemetry-event.mjs';
2
+
1
3
  // Published standard, global API prices in cents per million tokens. These are
2
4
  // API-equivalent estimates, not subscription charges. Exact IDs only: an
3
5
  // unfamiliar model must not inherit another model's price from its name.
4
- // https://platform.claude.com/docs/en/about-claude/pricing (2026-09-29)
5
- const HAIKU_45 = { input: 100, output: 500, write5m: 125, write1h: 200, read: 10 };
6
- const SONNET_5 = { input: 200, output: 1000, write5m: 250, write1h: 400, read: 20 };
7
- const OPUS_5 = { input: 500, output: 2500, write5m: 625, write1h: 1000, read: 50 };
8
- const OPUS_55 = { input: 400, output: 2000, write5m: 500, write1h: 800, read: 20 };
6
+ // These facts retain the existing reviewed rates; a table change must publish
7
+ // a new version so historical usage never silently receives today's prices.
8
+ export const PRICING_VERSION = '2026-09-29.1';
9
+ export const PRICING_DATE = '2026-09-29';
10
+ export const PRICING_SOURCE = 'https://platform.claude.com/docs/en/about-claude/pricing';
11
+ const HAIKU_45 = Object.freeze({ input: 100, output: 500, write5m: 125, write1h: 200, read: 10 });
12
+ const SONNET_5 = Object.freeze({ input: 200, output: 1000, write5m: 250, write1h: 400, read: 20 });
13
+ const OPUS_5 = Object.freeze({ input: 500, output: 2500, write5m: 625, write1h: 1000, read: 50 });
14
+ const OPUS_55 = Object.freeze({ input: 400, output: 2000, write5m: 500, write1h: 800, read: 20 });
9
15
  const PRICES = new Map([
10
16
  ['claude-haiku-4-5', HAIKU_45], ['claude-haiku-4-5-20251001', HAIKU_45],
11
17
  ['claude-sonnet-5', SONNET_5], ['claude-sonnet-5-5', SONNET_5],
12
18
  ['claude-opus-5', OPUS_5], ['claude-opus-5-5', OPUS_55],
13
19
  ]);
20
+ export const PRICING_FACTS = Object.freeze({ version: PRICING_VERSION, date: PRICING_DATE, source: PRICING_SOURCE,
21
+ currency: 'USD', unit: 'cents_per_million_tokens', models: Object.freeze(Object.fromEntries(PRICES)) });
14
22
  const OPUS_MODELS = new Set(['claude-opus-5', 'claude-opus-5-5']);
15
23
  const EVENTS = new Set(['request_start', 'route', 'upstream_response', 'upstream_model', 'upstream_usage',
16
24
  'upstream_error', 'request_complete', 'request_error', 'request_cancelled']);
@@ -83,7 +91,7 @@ export function createSavingsTracker({ baselineModel = 'claude-opus-5-5' } = {})
83
91
  }
84
92
  }
85
93
 
86
- function finish(key, failed = false, partial = false) {
94
+ function finish(key, failed = false, partial = false, failureReason = 'request_failed') {
87
95
  const request = inflight.get(key);
88
96
  if (!request) return;
89
97
  inflight.delete(key);
@@ -93,6 +101,9 @@ export function createSavingsTracker({ baselineModel = 'claude-opus-5-5' } = {})
93
101
  if (partial) session.partial = true;
94
102
  if (failed || request.failed || request.invalid || !request.prices || !request.usage || !baseline) {
95
103
  session.unpriced_requests++;
104
+ const reason = failed ? failureReason : request.failed ? 'request_failed' : request.unpricedReason
105
+ ?? (!baseline ? 'unknown_baseline' : !request.prices ? 'missing_model' : 'missing_usage');
106
+ session.unpriced_reasons[reason] = Math.min(Number.MAX_SAFE_INTEGER, (session.unpriced_reasons[reason] ?? 0) + 1);
96
107
  return;
97
108
  }
98
109
  session.actual += cost(request.usage, request.prices);
@@ -107,7 +118,7 @@ export function createSavingsTracker({ baselineModel = 'claude-opus-5-5' } = {})
107
118
  sessions.set(sessionId, session);
108
119
  return session;
109
120
  }
110
- session = { actual: 0n, baseline: 0n, requests: 0, unpriced_requests: 0,
121
+ session = { actual: 0n, baseline: 0n, requests: 0, unpriced_requests: 0, unpriced_reasons: {},
111
122
  partial: historyPartial || evictedSessions.has(sessionId) };
112
123
  sessions.set(sessionId, session);
113
124
  while (sessions.size > MAX_SESSIONS) {
@@ -135,29 +146,42 @@ export function createSavingsTracker({ baselineModel = 'claude-opus-5-5' } = {})
135
146
  if (event.event === 'request_start') {
136
147
  if (inflight.has(key) || settled.has(key)) return;
137
148
  sessionFor(sessionId);
138
- while (inflight.size >= MAX_INFLIGHT) finish(inflight.keys().next().value, true, true);
139
- inflight.set(key, { sessionId, invalid: !standardPricing(event.pricing_context) });
149
+ while (inflight.size >= MAX_INFLIGHT) finish(inflight.keys().next().value, true, true, 'request_evicted');
150
+ const invalid = !standardPricing(event.pricing_context);
151
+ inflight.set(key, { sessionId, invalid, ...(invalid ? { unpricedReason: 'unsupported_pricing' } : {}) });
140
152
  return;
141
153
  }
142
154
  const request = inflight.get(key);
143
155
  if (!request) return;
144
156
  switch (event.event) {
145
157
  case 'route':
146
- if (!standardPricing(event.pricing_context)) request.invalid = true;
158
+ if (!standardPricing(event.pricing_context)) { request.invalid = true; request.unpricedReason ??= 'unsupported_pricing'; }
147
159
  break;
148
160
  case 'upstream_response':
149
161
  if (!Number.isInteger(event.status) || event.status < 200 || event.status >= 300) request.failed = true;
150
162
  break;
151
163
  case 'upstream_model': {
152
164
  const prices = PRICES.get(event.model);
153
- if (!prices || (request.prices && request.prices !== prices)) request.invalid = true;
165
+ if (!prices) { request.invalid = true; request.unpricedReason ??= 'unknown_model'; }
166
+ // A fallback can mix usage even between models with identical rates.
167
+ // No aggregate can prove the attribution of all billed tokens.
168
+ if (request.model !== undefined && request.model !== event.model) {
169
+ request.invalid = true; request.unpricedReason = 'mixed_models';
170
+ }
171
+ request.model = event.model;
154
172
  request.prices = prices;
155
173
  break;
156
174
  }
157
175
  case 'upstream_usage': {
158
176
  const usage = normalizeUsage(event.usage, request.prices);
159
- if (!usage || !standardPricing(event.pricing_context)) request.invalid = true;
160
- else if (request.usage && Object.keys(usage).some(key => usage[key] !== request.usage[key])) request.invalid = true;
177
+ if (!usage || !standardPricing(event.pricing_context)) {
178
+ request.invalid = true;
179
+ request.unpricedReason ??= !standardPricing(event.pricing_context) || !standardPricing(event.usage, request.prices === HAIKU_45)
180
+ ? 'unsupported_pricing' : 'invalid_usage';
181
+ }
182
+ else if (request.usage && Object.keys(usage).some(key => usage[key] !== request.usage[key])) {
183
+ request.invalid = true; request.unpricedReason ??= 'conflicting_usage';
184
+ }
161
185
  else request.usage = usage;
162
186
  break;
163
187
  }
@@ -165,12 +189,14 @@ export function createSavingsTracker({ baselineModel = 'claude-opus-5-5' } = {})
165
189
  request.failed = true;
166
190
  break;
167
191
  case 'request_complete':
168
- finish(key);
192
+ finish(key, event.completion_confirmed === false, false, 'unconfirmed_completion');
169
193
  break;
170
194
  case 'request_error':
171
- case 'request_cancelled':
172
195
  finish(key, true);
173
196
  break;
197
+ case 'request_cancelled':
198
+ finish(key, true, false, 'request_cancelled');
199
+ break;
174
200
  }
175
201
  } catch {
176
202
  // Telemetry must never interfere with inference. A malformed event must
@@ -179,7 +205,7 @@ export function createSavingsTracker({ baselineModel = 'claude-opus-5-5' } = {})
179
205
  const sessionId = event?.session_id == null ? '' : identifier(event.session_id);
180
206
  const requestId = identifier(event?.request_id);
181
207
  const request = inflight.get(`${sessionId}\0${requestId}`);
182
- if (request) request.invalid = true;
208
+ if (request) { request.invalid = true; request.unpricedReason ??= 'invalid_telemetry'; }
183
209
  } catch {}
184
210
  }
185
211
  }
@@ -189,12 +215,17 @@ export function createSavingsTracker({ baselineModel = 'claude-opus-5-5' } = {})
189
215
  const saved = session.baseline - session.actual;
190
216
  return [sessionId, {
191
217
  baseline_model: baselineName,
218
+ pricing_version: PRICING_VERSION,
219
+ pricing_date: PRICING_DATE,
220
+ pricing_source: PRICING_SOURCE,
192
221
  actual_usd: Number(session.actual) / 100000000,
193
222
  baseline_usd: Number(session.baseline) / 100000000,
194
223
  saved_usd: Number(saved) / 100000000,
195
224
  percent: session.baseline === 0n ? 0 : Number(saved) / Number(session.baseline) * 100,
196
225
  requests: session.requests,
197
226
  unpriced_requests: session.unpriced_requests,
227
+ unpriced_reasons: Object.fromEntries(UNPRICED_REASONS.filter(reason => session.unpriced_reasons[reason])
228
+ .map(reason => [reason, session.unpriced_reasons[reason]])),
198
229
  ...(session.partial || historyPartial ? { partial: true } : {}),
199
230
  }];
200
231
  }));
@@ -206,3 +237,30 @@ export function createSavingsTracker({ baselineModel = 'claude-opus-5-5' } = {})
206
237
 
207
238
  return { update, snapshot, clear };
208
239
  }
240
+
241
+ // Saved history must use the recorded baseline and reviewed table version,
242
+ // never retroactively assume the current baseline or a successful response.
243
+ export function estimateOutcomeSavings(value) {
244
+ const outcome = normalizeSessionRecord(value, { includePrompts: false });
245
+ const unpriced = unpriced_reason => ({ priced: false, unpriced_reason });
246
+ if (!outcome || outcome.event !== 'outcome') return unpriced('invalid_telemetry');
247
+ if (outcome.status === 'cancelled') return unpriced('request_cancelled');
248
+ if (outcome.status === 'error') return unpriced('request_failed');
249
+ if (outcome.completion_confirmed !== true) return unpriced('unconfirmed_completion');
250
+ if (outcome.pricing_version !== PRICING_VERSION) return unpriced('unknown_pricing_version');
251
+ if (!OPUS_MODELS.has(outcome.baseline_model)) return unpriced('unknown_baseline');
252
+ if (outcome.usage_complete === false) return unpriced('incomplete_usage');
253
+ if (outcome.model_transitions_truncated || new Set([outcome.confirmed_model, ...(outcome.model_transitions ?? [])].filter(Boolean)).size > 1) return unpriced('mixed_models');
254
+ if (outcome.pricing_eligible === false) return unpriced(outcome.unpriced_reason ?? 'unsupported_pricing');
255
+ const tracker = createSavingsTracker({ baselineModel: outcome.baseline_model });
256
+ const identity = { request_id: 'outcome', session_id: 'outcome' };
257
+ tracker.update({ ...identity, event: 'request_start', pricing_context: outcome.pricing_context });
258
+ if (outcome.http_status !== undefined) tracker.update({ ...identity, event: 'upstream_response', status: outcome.http_status });
259
+ if (outcome.confirmed_model) tracker.update({ ...identity, event: 'upstream_model', model: outcome.confirmed_model });
260
+ if (outcome.usage !== undefined) tracker.update({ ...identity, event: 'upstream_usage', usage: outcome.usage });
261
+ tracker.update({ ...identity, event: 'request_complete' });
262
+ const result = tracker.snapshot().outcome;
263
+ if (result.unpriced_requests) return unpriced(Object.keys(result.unpriced_reasons)[0]);
264
+ return { priced: true, actual_usd: result.actual_usd, baseline_usd: result.baseline_usd,
265
+ saved_usd: result.saved_usd, percent: result.percent, baseline_model: result.baseline_model, pricing_version: result.pricing_version };
266
+ }
package/src/server.mjs CHANGED
@@ -8,6 +8,9 @@ import { prepareRequest } from './model-request.mjs';
8
8
  import { createResponseObserver } from './response-observer.mjs';
9
9
  import { createTokenCounter } from './token-counter.mjs';
10
10
  import { promptExcerpt } from './prompt-state.mjs';
11
+ import { validateRequestShape } from './request-validation.mjs';
12
+ import { normalizeSessionRecord } from './telemetry-event.mjs';
13
+ import { PRICING_VERSION, estimateOutcomeSavings } from './savings.mjs';
11
14
 
12
15
  function cleanHeaders(headers) {
13
16
  const blocked = new Set(['host', 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'content-length']);
@@ -67,6 +70,7 @@ function upstreamHeaders(incoming, config) {
67
70
  }
68
71
 
69
72
  async function forward(url, req, res, body, config, signal, log, status) {
73
+ let execution;
70
74
  const headers = upstreamHeaders(req.headers, config);
71
75
  if (body) headers['content-length'] = String(body.length);
72
76
  const transport = url.protocol === 'https:' ? https : http;
@@ -91,6 +95,7 @@ async function forward(url, req, res, body, config, signal, log, status) {
91
95
  onModel: ({ model }) => { log({ event: 'upstream_model', model }); status('upstream_model', { model }); },
92
96
  onError: ({ error_type }) => { log({ event: 'upstream_error', error_type }); status('upstream_error', { error_type }); },
93
97
  onUsage: ({ usage }) => { if (upstream.statusCode >= 200 && upstream.statusCode < 300) status('upstream_usage', { usage }); },
98
+ onComplete: evidence => { execution = evidence; },
94
99
  });
95
100
  await pipeline(upstream, observer, res, { signal });
96
101
  } else {
@@ -100,19 +105,66 @@ async function forward(url, req, res, body, config, signal, log, status) {
100
105
  if (!signal.aborted || signal.reason?.name === 'TimeoutError') status('request_error', { status: 502 });
101
106
  throw error;
102
107
  }
108
+ return upstream.statusCode >= 200 && upstream.statusCode < 300 ? execution : undefined;
103
109
  }
104
110
 
105
- export function createRouterServer(config, { router = new Router(config), tokenCounter = createTokenCounter(config), log = entry => process.stderr.write(`${JSON.stringify(entry)}\n`), onStatus = () => {}, onDecision } = {}) {
111
+ export function createRouterServer(config, { router = new Router(config), tokenCounter = createTokenCounter(config), log = entry => process.stderr.write(`${JSON.stringify(entry)}\n`), onStatus = () => {}, onDecision, onRecord } = {}) {
106
112
  if (!config.localToken || config.localToken.length < 16) throw new Error('AUTOROUTER_TOKEN must contain at least 16 characters');
107
113
  const server = http.createServer(async (req, res) => {
108
114
  const controller = new AbortController();
109
115
  let context;
110
116
  let finished = false;
117
+ const startedAt = performance.now();
118
+ let forwardedAt;
119
+ const outcome = { baseline_model: config.models.opus, pricing_version: PRICING_VERSION, completion_confirmed: false };
120
+ const record = entry => {
121
+ if (!onRecord) return;
122
+ try {
123
+ const row = normalizeSessionRecord({ timestamp: new Date().toISOString(), ...context, ...entry },
124
+ { includePrompts: config.sessionLogMode !== 'metadata' });
125
+ if (row) Promise.resolve(onRecord(row)).catch(() => {});
126
+ } catch {}
127
+ };
111
128
  const status = (event, fields = {}) => {
112
129
  if (!context || finished) return;
130
+ if (event === 'route') Object.assign(outcome, fields, { selected_model: fields.model,
131
+ routing_latency_ms: fields.latency_ms, decision_latency_ms: fields.latency_ms });
132
+ if (event === 'upstream_response') {
133
+ outcome.http_status = fields.status;
134
+ outcome.first_response_ms = Math.round((performance.now() - (forwardedAt ?? startedAt)) * 100) / 100;
135
+ fields.first_response_ms = outcome.first_response_ms;
136
+ if (fields.status >= 400) outcome.error_type = 'http_error';
137
+ }
138
+ if (event === 'upstream_model') {
139
+ outcome.confirmed_model = fields.model;
140
+ if (outcome.model_transitions?.at(-1) !== fields.model) {
141
+ outcome.model_transitions ??= [];
142
+ if (outcome.model_transitions.length < 16) outcome.model_transitions.push(fields.model);
143
+ else outcome.model_transitions_truncated = true;
144
+ }
145
+ }
146
+ if (event === 'upstream_usage') outcome.usage = fields.usage;
147
+ if (['upstream_error', 'request_error'].includes(event)) {
148
+ outcome.error_type = fields.error_type ?? 'request_error';
149
+ if (fields.status) outcome.http_status = fields.status;
150
+ }
113
151
  if (['request_complete', 'request_error', 'request_cancelled'].includes(event)) finished = true;
152
+ if (finished) {
153
+ fields.total_latency_ms = Math.round((performance.now() - startedAt) * 100) / 100;
154
+ fields.completion_confirmed = outcome.completion_confirmed;
155
+ }
114
156
  // Optional observability must never delay or fail an inference request.
115
157
  try { Promise.resolve(onStatus({ ...context, event, ...fields })).catch(() => {}); } catch {}
158
+ if (finished && onRecord) {
159
+ const completed = { ...context, ...outcome, event: 'outcome',
160
+ status: outcome.error_type ? 'error' : event === 'request_cancelled' ? 'cancelled' : 'completed',
161
+ usage_complete: outcome.completion_confirmed === true && Number.isSafeInteger(outcome.usage?.input_tokens)
162
+ && outcome.usage.input_tokens >= 0 && Number.isSafeInteger(outcome.usage.output_tokens) && outcome.usage.output_tokens >= 0,
163
+ total_latency_ms: Math.round((performance.now() - startedAt) * 100) / 100 };
164
+ const estimate = estimateOutcomeSavings(completed);
165
+ record({ ...completed, pricing_eligible: estimate.priced,
166
+ ...(!estimate.priced ? { unpriced_reason: estimate.unpriced_reason } : {}) });
167
+ }
116
168
  };
117
169
  const rejectRequest = (code, message) => {
118
170
  status('request_error', { status: code });
@@ -154,17 +206,19 @@ export function createRouterServer(config, { router = new Router(config), tokenC
154
206
  body = await readBody(req, config.maxBodyBytes);
155
207
  let parsed;
156
208
  try { parsed = JSON.parse(body); } catch { return rejectRequest(400, 'Invalid JSON body'); }
157
- if (!parsed || typeof parsed.model !== 'string' || !Array.isArray(parsed.messages) || parsed.messages.some(m => !m || !['user', 'assistant', 'system'].includes(m.role) || !(typeof m.content === 'string' || (Array.isArray(m.content) && m.content.every(b => b && typeof b.type === 'string'))))) {
209
+ const shape = validateRequestShape(parsed);
210
+ if (!shape.valid) {
158
211
  log({ event: 'invalid_request_shape', model_type: typeof parsed?.model, messages_type: Array.isArray(parsed?.messages) ? 'array' : typeof parsed?.messages,
159
212
  messages: Array.isArray(parsed?.messages) ? parsed.messages.slice(0, 10).map(m => ({
160
213
  role: ['user', 'assistant', 'system'].includes(m?.role) ? m.role : typeof m?.role,
161
214
  content_type: Array.isArray(m?.content) ? 'array' : typeof m?.content,
162
215
  blocks: Array.isArray(m?.content) ? m.content.slice(0, 10).map(b => ({ value_type: b === null ? 'null' : typeof b, type_type: typeof b?.type })) : undefined,
163
216
  })) : undefined });
164
- return rejectRequest(400, 'Expected a model and Messages API messages');
217
+ return rejectRequest(400, shape.error);
165
218
  }
166
219
  if (inference) {
167
220
  const decision = await router.route(parsed, {
221
+ requestId: context.request_id,
168
222
  signal: controller.signal,
169
223
  scope: JSON.stringify([req.headers['x-claude-code-session-id'], req.headers['x-claude-code-agent-id']]),
170
224
  promptId: req.headers['x-claude-code-prompt-id'],
@@ -179,20 +233,26 @@ export function createRouterServer(config, { router = new Router(config), tokenC
179
233
  // Prompt excerpts go only to this explicit opt-in sink, never to
180
234
  // ordinary diagnostics or the status snapshot. Optional logging
181
235
  // cannot delay or fail forwarding, including an async sink failure.
182
- if (onDecision) {
236
+ if (onDecision || onRecord) {
183
237
  try {
184
- const chars = [...(!context.request_class || context.request_class === 'main' ? promptExcerpt(parsed, 501) : '')];
185
- Promise.resolve(onDecision({
186
- schema_version: 1, event: 'decision', timestamp: new Date().toISOString(), ...context,
187
- prompt_excerpt: chars.slice(0, 500).join(''), prompt_truncated: chars.length > 500,
238
+ const includePrompts = config.sessionLogMode !== 'metadata';
239
+ const chars = [...(includePrompts && (!context.request_class || context.request_class === 'main') ? promptExcerpt(parsed, 501) : '')];
240
+ const row = {
241
+ schema_version: 2, event: 'decision', timestamp: new Date().toISOString(), ...context,
242
+ ...(includePrompts ? { prompt_excerpt: chars.slice(0, 500).join(''), prompt_truncated: chars.length > 500 } : {}),
188
243
  requested_model: parsed.model, selected_model: decision.model, decision_latency_ms: decision.latency_ms,
244
+ routing_latency_ms: decision.latency_ms, evaluation_latency_ms: decision.evaluation_latency_ms,
189
245
  source: decision.source, reason: decision.reason, evaluator: decision.evaluator,
190
246
  classified_tier: decision.classified_tier, classifier_error: decision.classifier_error,
191
- })).catch(() => {});
247
+ classifier_status: decision.classifier_status, compatibility_reason: decision.compatibility_reason,
248
+ continuity_state: decision.continuity_state, context_check: decision.context_check, counted_input_tokens: decision.counted_input_tokens,
249
+ };
250
+ record(row);
251
+ if (onDecision) Promise.resolve(onDecision(row)).catch(() => {});
192
252
  } catch {}
193
253
  }
194
254
  log({ event: 'route', requested_model: parsed.model, ...decision, request_adjustments: prepared.adjustments });
195
- const { model, source, evaluator, reason, latency_ms, classifier_error, classifier_status, classified_tier, context_check, counted_input_tokens } = decision;
255
+ const { model, source, evaluator, reason, latency_ms, evaluation_latency_ms, classifier_error, classifier_status, classified_tier, context_check, counted_input_tokens, continuity_state, compatibility_reason } = decision;
196
256
  const pricingValue = (field, allowed, fallback) => prepared.request[field] === undefined ? fallback
197
257
  : allowed.includes(prepared.request[field]) ? prepared.request[field] : 'unknown';
198
258
  const pricing_context = {
@@ -203,12 +263,15 @@ export function createRouterServer(config, { router = new Router(config), tokenC
203
263
  if (prepared.request.fallbacks != null || prepared.request.fallback_credit_token != null
204
264
  || (Array.isArray(prepared.request.tools) && prepared.request.tools.some(tool => typeof tool?.type === 'string' && /^advisor(?:_|$)/.test(tool.type)))) pricing_context.pricing_unsupported = true;
205
265
  status('route', { requested_model: parsed.model, model, source, evaluator, reason, latency_ms, classifier_error, classifier_status,
206
- classified_tier, context_check, counted_input_tokens, pricing_context });
266
+ evaluation_latency_ms, routing_latency_ms: latency_ms, classified_tier, context_check, counted_input_tokens, continuity_state, compatibility_reason, pricing_context });
207
267
  }
208
268
  }
209
269
  if (controller.signal.aborted) { status('request_cancelled'); return; }
210
270
  const target = new URL(`${config.upstream}${url.pathname}${url.search}`);
211
- await forward(target, req, res, body, config, AbortSignal.any([controller.signal, AbortSignal.timeout(config.upstreamTimeoutMs)]), log, status);
271
+ forwardedAt = performance.now();
272
+ const execution = await forward(target, req, res, body, config, AbortSignal.any([controller.signal, AbortSignal.timeout(config.upstreamTimeoutMs)]), log, status);
273
+ outcome.completion_confirmed = Boolean(execution) && !controller.signal.aborted;
274
+ if (context) router.complete?.(context.request_id, controller.signal.aborted ? undefined : execution);
212
275
  status('request_complete');
213
276
  } catch (error) {
214
277
  if (controller.signal.aborted) { status('request_cancelled'); return; }
@@ -216,6 +279,10 @@ export function createRouterServer(config, { router = new Router(config), tokenC
216
279
  // and metadata-only, including on error paths.
217
280
  log({ event: 'proxy_error', status: error.status ?? 502 });
218
281
  rejectRequest(error.status ?? 502, error.status === 413 ? 'Request body too large' : 'Router could not complete the upstream request');
282
+ } finally {
283
+ // Release staged attempts on every early return, abort and error. This
284
+ // is a no-op after a successfully committed execution.
285
+ if (context) router.complete?.(context.request_id);
219
286
  }
220
287
  });
221
288
  server.requestTimeout = 30000;
@@ -0,0 +1,261 @@
1
+ import { constants } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { loadUserConfig } from './user-config.mjs';
5
+ import { parseSessionLogDir } from './config.mjs';
6
+ import { normalizeSessionRecord } from './telemetry-event.mjs';
7
+ import { estimateOutcomeSavings, PRICING_FACTS } from './savings.mjs';
8
+
9
+ export const HISTORY_LIMITS = Object.freeze({ maxFiles: 100, maxDirectoryEntries: 10000,
10
+ maxFileBytes: 4 * 1024 * 1024, maxTotalBytes: 16 * 1024 * 1024, maxLineBytes: 16384,
11
+ maxRecords: 5000, maxLines: 10000 });
12
+ const ID = /^autorouter-session-[A-Za-z0-9-]{1,160}$/;
13
+ const safeText = value => String(value).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '');
14
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
15
+ const increment = (counts, key) => {
16
+ if (key) Object.defineProperty(counts, key, { value: (Object.hasOwn(counts, key) ? counts[key] : 0) + 1,
17
+ enumerable: true, writable: true, configurable: true });
18
+ };
19
+ const sortedCounts = counts => Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)));
20
+ function limitsFor(overrides) {
21
+ const limits = { ...HISTORY_LIMITS };
22
+ for (const [key, value] of Object.entries(overrides)) {
23
+ if (!Object.hasOwn(limits, key) || !Number.isSafeInteger(value) || value < 1 || value > limits[key]) throw new Error('Invalid session-history read limit.');
24
+ limits[key] = value;
25
+ }
26
+ return limits;
27
+ }
28
+ function latencyStats(values) {
29
+ values.sort((a, b) => a - b);
30
+ return values.length ? { samples: values.length, p50: values[Math.ceil(values.length * .5) - 1],
31
+ p95: values[Math.ceil(values.length * .95) - 1], max: values.at(-1) } : { samples: 0 };
32
+ }
33
+ function summarize(id, rows, coverage) {
34
+ const requests = new Map(), selected = {}, confirmed = {}, sources = {}, reasons = {}, classifierErrors = {};
35
+ const decisionLatencies = [], totalLatencies = [], baselines = new Set(), pricingVersions = new Set();
36
+ let decisions = 0, outcomes = 0, legacy = 0, duplicates = 0, unversionedOutcomes = 0;
37
+ for (const row of rows) {
38
+ let request = requests.get(row.request_id);
39
+ if (!request) { request = {}; requests.set(row.request_id, request); }
40
+ if (request[row.event]) {
41
+ duplicates++;
42
+ if (JSON.stringify(request[row.event]) !== JSON.stringify(row)) request.conflicting = true;
43
+ continue;
44
+ }
45
+ request[row.event] = row;
46
+ if (row.event === 'decision') {
47
+ decisions++;
48
+ if (row.schema_version === 1) legacy++;
49
+ increment(selected, row.selected_model); increment(sources, row.source); increment(reasons, row.reason);
50
+ increment(classifierErrors, row.classifier_error);
51
+ if (Number.isFinite(row.decision_latency_ms)) decisionLatencies.push(row.decision_latency_ms);
52
+ } else {
53
+ outcomes++;
54
+ increment(confirmed, row.confirmed_model);
55
+ if (row.baseline_model) baselines.add(row.baseline_model);
56
+ if (row.pricing_version) pricingVersions.add(row.pricing_version); else unversionedOutcomes++;
57
+ if (Number.isFinite(row.total_latency_ms)) totalLatencies.push(row.total_latency_ms);
58
+ }
59
+ }
60
+ let completed = 0, failed = 0, cancelled = 0, pending = 0, unconfirmed = 0, outcomeOnly = 0, conflicting = 0;
61
+ const savings = { basis: 'API-equivalent estimate, not subscription charges', actual_usd: 0, baseline_usd: 0,
62
+ saved_usd: 0, percent: 0, priced_requests: 0, unpriced_requests: 0, unpriced_reasons: {} };
63
+ for (const request of requests.values()) {
64
+ const { decision, outcome } = request;
65
+ if (request.conflicting) conflicting++;
66
+ const estimate = request.conflicting ? { priced: false, unpriced_reason: 'invalid_telemetry' }
67
+ : outcome ? estimateOutcomeSavings(outcome) : { priced: false, unpriced_reason: 'missing_outcome' };
68
+ if (estimate.priced) {
69
+ savings.priced_requests++;
70
+ for (const field of ['actual_usd', 'baseline_usd', 'saved_usd']) savings[field] += estimate[field];
71
+ } else { savings.unpriced_requests++; increment(savings.unpriced_reasons, estimate.unpriced_reason); }
72
+ if (!outcome) { pending++; continue; }
73
+ if (!decision) outcomeOnly++;
74
+ if (outcome.status === 'cancelled') cancelled++;
75
+ else if (outcome.status === 'error' || (outcome.http_status !== undefined && (outcome.http_status < 200 || outcome.http_status >= 300))) failed++;
76
+ else if (outcome.completion_confirmed === true && !request.conflicting) completed++;
77
+ else unconfirmed++;
78
+ }
79
+ savings.percent = savings.baseline_usd === 0 ? 0 : savings.saved_usd / savings.baseline_usd * 100;
80
+ savings.unpriced_reasons = sortedCounts(savings.unpriced_reasons);
81
+ const timestamps = rows.map(row => row.timestamp).sort();
82
+ return { id, ...(rows[0]?.session_id ? { session_id: rows[0].session_id } : {}),
83
+ started_at: timestamps[0] ?? null, updated_at: timestamps.at(-1) ?? null,
84
+ requests: requests.size, decisions, outcomes, completed, failed, cancelled, pending, unconfirmed, outcome_only: outcomeOnly,
85
+ selected_models: sortedCounts(selected), confirmed_models: sortedCounts(confirmed), sources: sortedCounts(sources),
86
+ routing_reasons: sortedCounts(reasons), classifier_errors: sortedCounts(classifierErrors),
87
+ fallbacks: sources.fallback ?? 0, fallback_rate: decisions ? (sources.fallback ?? 0) / decisions : 0,
88
+ decision_latency_ms: latencyStats(decisionLatencies), total_latency_ms: latencyStats(totalLatencies),
89
+ baseline_models: [...baselines].sort(), mixed_baselines: baselines.size > 1,
90
+ pricing_versions: [...pricingVersions].sort(), mixed_pricing_versions: pricingVersions.size > 1,
91
+ pricing_facts: pricingVersions.has(PRICING_FACTS.version)
92
+ ? [{ version: PRICING_FACTS.version, date: PRICING_FACTS.date, source: PRICING_FACTS.source }] : [],
93
+ unversioned_outcomes: unversionedOutcomes, savings,
94
+ coverage: { ...coverage, legacy_decisions: legacy, duplicate_records: duplicates, conflicting_requests: conflicting,
95
+ partial: coverage.partial || duplicates > 0 },
96
+ };
97
+ }
98
+
99
+ async function sameDirectory(root, identity) {
100
+ const current = await fs.lstat(root);
101
+ if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== identity.dev || current.ino !== identity.ino) {
102
+ throw new Error('Session log directory changed or is not a regular directory.');
103
+ }
104
+ }
105
+
106
+ async function readSession(root, identity, id, limits, remainingBytes) {
107
+ let handle;
108
+ try {
109
+ await sameDirectory(root, identity);
110
+ // Nonblocking open also prevents a replaced FIFO/device from hanging the
111
+ // local inspection before we can verify that it is a regular file.
112
+ handle = await fs.open(join(root, `${id}.jsonl`), constants.O_RDONLY | constants.O_NOFOLLOW | (constants.O_NONBLOCK ?? 0));
113
+ const stat = await handle.stat();
114
+ if (!stat.isFile() || stat.nlink !== 1) throw new Error('Session log is not a regular private file.');
115
+ await sameDirectory(root, identity);
116
+ const capacity = Math.min(stat.size, limits.maxFileBytes, remainingBytes);
117
+ const buffer = Buffer.alloc(capacity);
118
+ let bytesRead = 0;
119
+ while (bytesRead < capacity) {
120
+ const result = await handle.read(buffer, bytesRead, capacity - bytesRead, bytesRead);
121
+ if (!result.bytesRead) break;
122
+ bytesRead += result.bytesRead;
123
+ }
124
+ const coverage = { bytes_read: bytesRead, file_bytes: stat.size, lines_read: 0, invalid_records: 0,
125
+ oversized_lines: 0, mixed_session_records: 0, truncated: bytesRead < stat.size, incomplete_tail: false, partial: false };
126
+ const rows = [];
127
+ let offset = 0, sessionIdentity;
128
+ while (offset < bytesRead) {
129
+ if (rows.length >= limits.maxRecords || coverage.lines_read >= limits.maxLines) { coverage.truncated = true; break; }
130
+ const end = buffer.indexOf(10, offset);
131
+ if (end < 0 || end >= bytesRead) { coverage.incomplete_tail = true; break; }
132
+ const length = end - offset;
133
+ coverage.lines_read++;
134
+ if (length > limits.maxLineBytes) { coverage.oversized_lines++; offset = end + 1; continue; }
135
+ try {
136
+ const entry = JSON.parse(buffer.toString('utf8', offset, end));
137
+ if (!object(entry) || ![1, 2].includes(entry.schema_version)
138
+ || (entry.schema_version === 1 && entry.event !== 'decision')
139
+ || typeof entry.timestamp !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(entry.timestamp)
140
+ || !Number.isFinite(Date.parse(entry.timestamp))) throw new Error('Invalid record');
141
+ const row = normalizeSessionRecord(entry, { includePrompts: Object.hasOwn(entry, 'prompt_excerpt') });
142
+ if (!row) throw new Error('Invalid record');
143
+ const rowSession = row.session_id ?? '';
144
+ if (sessionIdentity === undefined) sessionIdentity = rowSession;
145
+ if (rowSession !== sessionIdentity) coverage.mixed_session_records++;
146
+ else rows.push({ ...row, schema_version: entry.schema_version });
147
+ } catch { coverage.invalid_records++; }
148
+ offset = end + 1;
149
+ }
150
+ coverage.partial = coverage.truncated || coverage.incomplete_tail || coverage.invalid_records > 0
151
+ || coverage.oversized_lines > 0 || coverage.mixed_session_records > 0;
152
+ return { summary: summarize(id, rows, coverage), records: rows };
153
+ } finally { if (handle) await handle.close(); }
154
+ }
155
+
156
+ /** Reads only bounded, recognized JSONL files; never modifies or deletes logs. */
157
+ export async function readSessionHistory(directory, { id, limits: overrides = {} } = {}) {
158
+ const limits = limitsFor(overrides);
159
+ if (id !== undefined && (typeof id !== 'string' || !ID.test(id))) throw new Error('Use an exact session ID from sessions list.');
160
+ if (typeof directory !== 'string' || !directory.trim() || typeof constants.O_NOFOLLOW !== 'number') throw new Error('A regular session log directory is required.');
161
+ const root = resolve(directory);
162
+ let identity;
163
+ try { identity = await fs.lstat(root); }
164
+ catch (error) {
165
+ if (error.code === 'ENOENT' && id === undefined) return { schema_version: 1, type: 'session_history', sessions: [], limits, coverage: { partial: false, directory_missing: true } };
166
+ throw new Error('Could not read the session log directory.');
167
+ }
168
+ if (!identity.isDirectory() || identity.isSymbolicLink()) throw new Error('Session logs must be read from a regular directory, not a symbolic link.');
169
+ if (id !== undefined) {
170
+ try {
171
+ const result = await readSession(root, identity, id, limits, limits.maxTotalBytes);
172
+ return { schema_version: 1, type: 'session_history', ...result, limits };
173
+ } catch { throw new Error('Could not read that session as a regular log file. Use sessions list for available IDs.'); }
174
+ }
175
+ const ids = [], coverage = { partial: false, directory_entries: 0, matching_files: 0, skipped_files: 0,
176
+ unreadable_files: 0, bytes_read: 0, directory_scan_truncated: false, byte_limit_reached: false };
177
+ let directoryHandle;
178
+ try {
179
+ directoryHandle = await fs.opendir(root);
180
+ for await (const entry of directoryHandle) {
181
+ if (++coverage.directory_entries > limits.maxDirectoryEntries) { coverage.directory_scan_truncated = true; break; }
182
+ if (!entry.name.endsWith('.jsonl')) continue;
183
+ const candidate = entry.name.slice(0, -6);
184
+ if (!ID.test(candidate)) continue;
185
+ if (!entry.isFile()) { coverage.skipped_files++; continue; }
186
+ coverage.matching_files++;
187
+ ids.push(candidate);
188
+ ids.sort().reverse();
189
+ if (ids.length > limits.maxFiles) ids.pop();
190
+ }
191
+ await sameDirectory(root, identity);
192
+ } catch { throw new Error('Could not safely list the session log directory.'); }
193
+ const sessions = [];
194
+ for (const candidate of ids) {
195
+ if (coverage.bytes_read >= limits.maxTotalBytes) { coverage.byte_limit_reached = true; break; }
196
+ try {
197
+ const result = await readSession(root, identity, candidate, limits, limits.maxTotalBytes - coverage.bytes_read);
198
+ coverage.bytes_read += result.summary.coverage.bytes_read;
199
+ sessions.push(result.summary);
200
+ } catch { coverage.unreadable_files++; }
201
+ }
202
+ coverage.skipped_files += coverage.matching_files - sessions.length - coverage.unreadable_files;
203
+ coverage.partial = coverage.directory_scan_truncated || coverage.byte_limit_reached || coverage.skipped_files > 0
204
+ || coverage.unreadable_files > 0 || sessions.some(session => session.coverage.partial);
205
+ return { schema_version: 1, type: 'session_history', sessions, limits, coverage };
206
+ }
207
+
208
+ const modelsText = models => Object.entries(models).map(([name, count]) => `${name} (${count})`).join(', ') || 'none';
209
+ function printSummary(summary, write) {
210
+ write(`ID: ${summary.id}`);
211
+ write(`Session: ${summary.session_id ?? 'anonymous'}; ${summary.started_at ?? 'no valid records'} to ${summary.updated_at ?? 'unknown'}`);
212
+ write(`Observed: ${summary.decisions} selections; ${summary.completed} confirmed completed; ${summary.failed} failed; ${summary.cancelled} cancelled; ${summary.pending} without outcome; ${summary.unconfirmed} unconfirmed outcomes.`);
213
+ write(`Selected models: ${modelsText(summary.selected_models)}`);
214
+ write(`Response models observed: ${modelsText(summary.confirmed_models)}`);
215
+ write(`Fallbacks: ${summary.fallbacks} of ${summary.decisions} selections (${(summary.fallback_rate * 100).toFixed(1)}%); routing reasons: ${modelsText(summary.routing_reasons)}`);
216
+ const saved = summary.savings.priced_requests ? `$${summary.savings.saved_usd.toFixed(4)}` : 'unavailable';
217
+ write(`API-equivalent savings: ${saved}; priced ${summary.savings.priced_requests}/${summary.requests} observed requests; ${summary.savings.unpriced_requests} unpriced. These are not subscription charges.`);
218
+ if (summary.baseline_models.length) write(`Recorded Opus baseline${summary.mixed_baselines ? 's (mixed)' : ''}: ${summary.baseline_models.join(', ')}`);
219
+ write(`Recorded pricing version${summary.mixed_pricing_versions ? 's (mixed)' : ''}: ${summary.pricing_versions.join(', ') || 'not recorded'}${summary.unversioned_outcomes ? `; ${summary.unversioned_outcomes} outcomes without a recorded version` : ''}.`);
220
+ for (const facts of summary.pricing_facts) write(`Pricing facts ${facts.version}, reviewed ${facts.date}: ${facts.source}`);
221
+ if (summary.savings.unpriced_requests) write(`Unpriced reasons: ${modelsText(summary.savings.unpriced_reasons)}`);
222
+ if (summary.decision_latency_ms.samples) write(`Decision latency: p50 ${summary.decision_latency_ms.p50} ms; p95 ${summary.decision_latency_ms.p95} ms (${summary.decision_latency_ms.samples} samples).`);
223
+ if (summary.coverage.partial) write('Partial history: limits, incomplete writes, duplicate or unreadable records affect these observed counts.');
224
+ if (summary.coverage.legacy_decisions) write(`${summary.coverage.legacy_decisions} legacy decisions record selection only; no successful response is implied.`);
225
+ }
226
+
227
+ export async function sessionsCommand(args, { env = process.env, write = console.log } = {}) {
228
+ const operation = args[0], json = args.includes('--json');
229
+ const positional = args.slice(1).filter(arg => arg !== '--json');
230
+ if (!['list', 'show'].includes(operation) || positional.some(arg => arg.startsWith('--'))
231
+ || (operation === 'list' ? positional.length !== 0 : positional.length !== 1)) {
232
+ throw new Error('Usage: claude-autorouter sessions list [--json] | sessions show ID [--json]');
233
+ }
234
+ const loaded = loadUserConfig(env, { allowMissing: true });
235
+ const directory = parseSessionLogDir(loaded.env.AUTOROUTER_SESSION_LOG_DIR);
236
+ if (!directory) {
237
+ const report = { schema_version: 1, type: 'session_history', logging_enabled: false, sessions: [],
238
+ message: 'Session logging is disabled. Set AUTOROUTER_SESSION_LOG_DIR to record future sessions.' };
239
+ write(json ? JSON.stringify(report, null, 2) : report.message);
240
+ return operation === 'list';
241
+ }
242
+ const report = await readSessionHistory(directory, operation === 'show' ? { id: positional[0] } : {});
243
+ if (json) write(JSON.stringify({ ...report, logging_enabled: true }, null, 2));
244
+ else {
245
+ if (operation === 'list') {
246
+ if (!report.sessions.length) write('No readable session logs found.');
247
+ for (const summary of report.sessions) printSummary(summary, write);
248
+ if (report.coverage.partial) write('Partial scan: displayed sessions or counts are limited; use sessions show ID for an individual file.');
249
+ } else {
250
+ printSummary(report.summary, write);
251
+ for (const row of report.records) {
252
+ const description = row.event === 'decision'
253
+ ? `selected ${row.selected_model}; ${row.source ?? 'unknown source'}; ${row.reason ?? 'unspecified reason'}`
254
+ : `${row.status}${row.http_status ? ` (HTTP ${row.http_status})` : ''}${row.error_type ? `; ${row.error_type}` : ''}; response model ${row.confirmed_model ?? 'not observed'}; completion ${row.completion_confirmed ? 'confirmed' : 'unconfirmed'}`;
255
+ write(`${row.timestamp} ${row.request_id}: ${description}`);
256
+ if (row.prompt_excerpt) write(` Prompt excerpt: ${safeText(row.prompt_excerpt)}${row.prompt_truncated ? '… [truncated]' : ''}`);
257
+ }
258
+ }
259
+ }
260
+ return true;
261
+ }