claude-autorouter 0.3.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -3
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +57 -0
- package/README.md +47 -70
- package/SECURITY.md +23 -0
- package/SUPPORT.md +18 -0
- package/bin/autorouter.mjs +40 -57
- package/docs/development.md +48 -2
- package/docs/hardware-benchmark.md +29 -0
- package/docs/hardware-comparison.md +55 -0
- package/docs/hardware-results-16gb.json +4002 -0
- package/docs/hardware-results-16gb.md +26 -0
- package/docs/hardware-results-64gb.json +4020 -0
- package/docs/reference.md +92 -37
- package/docs/releasing.md +79 -37
- package/docs/router-performance.json +1697 -0
- package/docs/router-performance.md +50 -0
- package/docs/status-performance.json +363 -0
- package/docs/status-performance.md +44 -0
- package/docs/subscription-integration.md +27 -0
- package/package.json +66 -10
- package/src/auto-routing.mjs +184 -24
- package/src/bounded-json.mjs +57 -0
- package/src/cli-help.mjs +90 -0
- package/src/config-command.mjs +158 -0
- package/src/config.mjs +53 -28
- package/src/contracts.mjs +123 -0
- package/src/evaluation-report.mjs +114 -0
- package/src/keychain.mjs +58 -0
- package/src/local-diagnostic.mjs +191 -0
- package/src/model-catalog.mjs +96 -0
- package/src/model-request.mjs +6 -7
- package/src/ollama-evaluator.mjs +9 -27
- package/src/onboarding.mjs +130 -26
- package/src/prompt-state.mjs +22 -7
- package/src/redaction.mjs +97 -0
- package/src/request-validation.mjs +54 -0
- package/src/response-observer.mjs +126 -18
- package/src/router.mjs +151 -61
- package/src/savings.mjs +74 -16
- package/src/server.mjs +79 -12
- package/src/session-history.mjs +262 -0
- package/src/session-log.mjs +9 -58
- package/src/status-state.mjs +110 -62
- package/src/statusline.mjs +57 -27
- package/src/telemetry-event.mjs +200 -0
- package/src/token-counter.mjs +3 -1
- package/src/turn-state.mjs +132 -0
- package/src/user-config.mjs +81 -10
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
|
-
//
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
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
|
-
|
|
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
|
|
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))
|
|
160
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,262 @@
|
|
|
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
|
+
// History needs only the log directory; never prompt for keychain access.
|
|
235
|
+
const loaded = loadUserConfig(env, { allowMissing: true, readSecrets: false });
|
|
236
|
+
const directory = parseSessionLogDir(loaded.env.AUTOROUTER_SESSION_LOG_DIR);
|
|
237
|
+
if (!directory) {
|
|
238
|
+
const report = { schema_version: 1, type: 'session_history', logging_enabled: false, sessions: [],
|
|
239
|
+
message: 'Session logging is disabled. Set AUTOROUTER_SESSION_LOG_DIR to record future sessions.' };
|
|
240
|
+
write(json ? JSON.stringify(report, null, 2) : report.message);
|
|
241
|
+
return operation === 'list';
|
|
242
|
+
}
|
|
243
|
+
const report = await readSessionHistory(directory, operation === 'show' ? { id: positional[0] } : {});
|
|
244
|
+
if (json) write(JSON.stringify({ ...report, logging_enabled: true }, null, 2));
|
|
245
|
+
else {
|
|
246
|
+
if (operation === 'list') {
|
|
247
|
+
if (!report.sessions.length) write('No readable session logs found.');
|
|
248
|
+
for (const summary of report.sessions) printSummary(summary, write);
|
|
249
|
+
if (report.coverage.partial) write('Partial scan: displayed sessions or counts are limited; use sessions show ID for an individual file.');
|
|
250
|
+
} else {
|
|
251
|
+
printSummary(report.summary, write);
|
|
252
|
+
for (const row of report.records) {
|
|
253
|
+
const description = row.event === 'decision'
|
|
254
|
+
? `selected ${row.selected_model}; ${row.source ?? 'unknown source'}; ${row.reason ?? 'unspecified reason'}`
|
|
255
|
+
: `${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'}`;
|
|
256
|
+
write(`${row.timestamp} ${row.request_id}: ${description}`);
|
|
257
|
+
if (row.prompt_excerpt) write(` Prompt excerpt: ${safeText(row.prompt_excerpt)}${row.prompt_truncated ? '… [truncated]' : ''}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return true;
|
|
262
|
+
}
|