claude-autorouter 0.2.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 +45 -0
- package/LICENSE +202 -0
- package/README.md +87 -0
- package/bin/autorouter.mjs +136 -0
- package/bin/statusline.mjs +32 -0
- package/docs/development.md +84 -0
- package/docs/ollama-evaluation.md +100 -0
- package/docs/reference.md +227 -0
- package/docs/releasing.md +152 -0
- package/package.json +25 -0
- package/src/auth.mjs +51 -0
- package/src/config.mjs +80 -0
- package/src/model-request.mjs +13 -0
- package/src/ollama-evaluator.mjs +114 -0
- package/src/ollama-models.mjs +29 -0
- package/src/ollama-setup.mjs +184 -0
- package/src/onboarding.mjs +149 -0
- package/src/prompt-state.mjs +121 -0
- package/src/response-observer.mjs +176 -0
- package/src/router.mjs +325 -0
- package/src/savings.mjs +208 -0
- package/src/server.mjs +215 -0
- package/src/status-settings.mjs +88 -0
- package/src/status-state.mjs +174 -0
- package/src/statusline.mjs +185 -0
- package/src/token-counter.mjs +127 -0
- package/src/user-config.mjs +145 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { closeSync, chmodSync, fchmodSync, mkdtempSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createSavingsTracker } from './savings.mjs';
|
|
6
|
+
|
|
7
|
+
const EVENTS = new Set(['request_start', 'route', 'upstream_response', 'upstream_model', 'upstream_usage', 'upstream_error', 'request_complete', 'request_error', 'request_cancelled']);
|
|
8
|
+
const CLASSIFIER_ERRORS = new Set(['timeout', 'http_error', 'invalid_response', 'network_error']);
|
|
9
|
+
const UPSTREAM_ERRORS = new Set(['invalid_request_error', 'authentication_error', 'billing_error', 'permission_error', 'not_found_error', 'request_too_large', 'rate_limit_error', 'api_error', 'overloaded_error', 'timeout_error', 'unknown_error', 'http_error', 'request_error']);
|
|
10
|
+
const identifier = value => typeof value === 'string' && /^[\w.:-]{1,200}$/.test(value) ? value : undefined;
|
|
11
|
+
const modelName = value => typeof value === 'string' && /^[\w.:/-]{1,120}$/.test(value) ? value : undefined;
|
|
12
|
+
const code = value => typeof value === 'string' && /^[a-z][a-z0-9_]{0,63}$/.test(value) ? value : undefined;
|
|
13
|
+
const httpStatus = value => Number.isInteger(value) && value >= 100 && value <= 599 ? value : undefined;
|
|
14
|
+
const latency = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 3600000 ? value : undefined;
|
|
15
|
+
const tokenCount = value => Number.isSafeInteger(value) && value >= 0;
|
|
16
|
+
|
|
17
|
+
function contextUsage(usage, model) {
|
|
18
|
+
if (!model || !usage || typeof usage !== 'object' || Array.isArray(usage) || usage.pricing_unsupported === true) return;
|
|
19
|
+
const counts = {};
|
|
20
|
+
for (const field of ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']) {
|
|
21
|
+
const value = Object.hasOwn(usage, field) ? usage[field] : field === 'input_tokens' ? undefined : 0;
|
|
22
|
+
if (!tokenCount(value)) return;
|
|
23
|
+
counts[field] = value;
|
|
24
|
+
}
|
|
25
|
+
const total = Object.values(counts).reduce((sum, value) => sum + value, 0);
|
|
26
|
+
if (!Number.isSafeInteger(total)) return;
|
|
27
|
+
// Match Claude's context percentage: cached and uncached input, excluding
|
|
28
|
+
// output. These are the latest response's counts, never session totals.
|
|
29
|
+
return { model, input_tokens: total };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// All telemetry is best-effort. Never let filesystem or malformed-event
|
|
33
|
+
// failures interrupt routing, and never persist request bodies or raw errors.
|
|
34
|
+
export function createStatusState(options = {}) {
|
|
35
|
+
let privateDirectory, path = null, pending, heartbeat, closed = false;
|
|
36
|
+
const sessions = new Map();
|
|
37
|
+
const savings = createSavingsTracker({ baselineModel: options?.baselineModel });
|
|
38
|
+
|
|
39
|
+
function flush() {
|
|
40
|
+
if (closed) return;
|
|
41
|
+
if (pending) { clearImmediate(pending); pending = undefined; }
|
|
42
|
+
if (!path) return;
|
|
43
|
+
let temporary, fd;
|
|
44
|
+
try {
|
|
45
|
+
const snapshot = { version: 1, pid: process.pid, heartbeat_at: Date.now(), sessions: Object.fromEntries(sessions), savings: savings.snapshot() };
|
|
46
|
+
temporary = join(privateDirectory, `.state-${randomBytes(8).toString('hex')}.tmp`);
|
|
47
|
+
fd = openSync(temporary, 'wx', 0o600);
|
|
48
|
+
fchmodSync(fd, 0o600);
|
|
49
|
+
writeFileSync(fd, JSON.stringify(snapshot));
|
|
50
|
+
closeSync(fd); fd = undefined;
|
|
51
|
+
renameSync(temporary, path);
|
|
52
|
+
} catch {
|
|
53
|
+
if (fd !== undefined) { try { closeSync(fd); } catch {} }
|
|
54
|
+
if (temporary) { try { rmSync(temporary, { force: true }); } catch {} }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function schedule() {
|
|
59
|
+
if (closed || pending || !path) return;
|
|
60
|
+
try { pending = setImmediate(flush); pending.unref?.(); } catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function update(event) {
|
|
64
|
+
if (closed) return;
|
|
65
|
+
try {
|
|
66
|
+
if (!event || !EVENTS.has(event.event)) return;
|
|
67
|
+
// Account for all session calls, including parallel agents and auxiliary
|
|
68
|
+
// requests, independently of which foreground request owns the model UI.
|
|
69
|
+
savings.update(event);
|
|
70
|
+
schedule();
|
|
71
|
+
if ((event.agent_id != null && event.agent_id !== '')
|
|
72
|
+
|| (event.request_class != null && event.request_class !== '' && event.request_class !== 'main')) return;
|
|
73
|
+
const sessionId = event.session_id == null || event.session_id === '' ? '' : identifier(event.session_id);
|
|
74
|
+
const requestId = identifier(event.request_id);
|
|
75
|
+
// Invalid IDs must never collapse into an unrelated/anonymous session.
|
|
76
|
+
if (sessionId === undefined || !requestId) return;
|
|
77
|
+
const previous = sessions.get(sessionId);
|
|
78
|
+
if (event.event === 'request_start') {
|
|
79
|
+
const state = {
|
|
80
|
+
request_id: requestId,
|
|
81
|
+
phase: 'routing',
|
|
82
|
+
updated_at: Date.now(),
|
|
83
|
+
...(previous?.last_model ? { last_model: previous.last_model } : {}),
|
|
84
|
+
...((previous?.context_usage ?? previous?.last_context_usage)
|
|
85
|
+
? { last_context_usage: previous.context_usage ?? previous.last_context_usage } : {}),
|
|
86
|
+
};
|
|
87
|
+
const requested = modelName(event.requested_model);
|
|
88
|
+
const promptId = identifier(event.prompt_id);
|
|
89
|
+
if (requested) state.requested_model = requested;
|
|
90
|
+
if (promptId) state.prompt_id = promptId;
|
|
91
|
+
sessions.delete(sessionId);
|
|
92
|
+
sessions.set(sessionId, state);
|
|
93
|
+
while (sessions.size > 100) sessions.delete(sessions.keys().next().value);
|
|
94
|
+
schedule();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!previous || previous.request_id !== requestId) return;
|
|
98
|
+
const state = previous;
|
|
99
|
+
state.updated_at = Date.now();
|
|
100
|
+
const status = httpStatus(event.status);
|
|
101
|
+
const terminal = ['error', 'cancelled', 'ready'].includes(state.phase);
|
|
102
|
+
switch (event.event) {
|
|
103
|
+
case 'route': {
|
|
104
|
+
const fields = { requested_model: modelName(event.requested_model), selected_model: modelName(event.model),
|
|
105
|
+
source: ['jev', 'ollama', 'cache', 'fallback'].includes(event.source) ? event.source : undefined,
|
|
106
|
+
evaluator: ['jev', 'ollama'].includes(event.evaluator) ? event.evaluator : undefined,
|
|
107
|
+
reason: code(event.reason), latency_ms: latency(event.latency_ms),
|
|
108
|
+
classified_tier: ['haiku', 'sonnet', 'opus'].includes(event.classified_tier) ? event.classified_tier : undefined,
|
|
109
|
+
context_check: ['within_budget', 'over_budget', 'count_unavailable'].includes(event.context_check) ? event.context_check : undefined,
|
|
110
|
+
counted_input_tokens: tokenCount(event.counted_input_tokens) ? event.counted_input_tokens : undefined,
|
|
111
|
+
classifier_error: CLASSIFIER_ERRORS.has(event.classifier_error) ? event.classifier_error : undefined, classifier_status: httpStatus(event.classifier_status) };
|
|
112
|
+
for (const [key, value] of Object.entries(fields)) if (value !== undefined) state[key] = value;
|
|
113
|
+
if (!terminal) state.phase = 'connecting';
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case 'upstream_response':
|
|
117
|
+
if (status !== undefined) state.status = status;
|
|
118
|
+
if (status >= 400) { delete state.context_usage; state.phase = 'error'; state.error_type = UPSTREAM_ERRORS.has(event.error_type) ? event.error_type : 'http_error'; }
|
|
119
|
+
else if (!terminal) state.phase = 'streaming';
|
|
120
|
+
break;
|
|
121
|
+
case 'upstream_model': {
|
|
122
|
+
const model = modelName(event.model);
|
|
123
|
+
if (model) { state.actual_model = model; state.last_model = model; }
|
|
124
|
+
if (!terminal) state.phase = 'streaming';
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case 'upstream_usage': {
|
|
128
|
+
if (state.phase === 'error' || state.phase === 'cancelled') break;
|
|
129
|
+
const usage = contextUsage(event.usage, state.actual_model);
|
|
130
|
+
if (usage) state.context_usage = usage;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case 'upstream_error':
|
|
134
|
+
case 'request_error':
|
|
135
|
+
delete state.context_usage;
|
|
136
|
+
state.phase = 'error';
|
|
137
|
+
state.error_type = UPSTREAM_ERRORS.has(event.error_type) ? event.error_type : (event.event === 'upstream_error' ? 'unknown_error' : 'request_error');
|
|
138
|
+
if (status !== undefined) state.status = status;
|
|
139
|
+
break;
|
|
140
|
+
case 'request_complete':
|
|
141
|
+
if (!terminal) state.phase = 'ready';
|
|
142
|
+
break;
|
|
143
|
+
case 'request_cancelled':
|
|
144
|
+
delete state.context_usage;
|
|
145
|
+
if (state.phase !== 'error') state.phase = 'cancelled';
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
schedule();
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function close() {
|
|
153
|
+
if (closed) return;
|
|
154
|
+
closed = true;
|
|
155
|
+
try { clearImmediate(pending); clearInterval(heartbeat); } catch {}
|
|
156
|
+
sessions.clear();
|
|
157
|
+
savings.clear();
|
|
158
|
+
if (privateDirectory) { try { rmSync(privateDirectory, { recursive: true, force: true }); } catch {} }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const directory = options?.directory ?? tmpdir();
|
|
163
|
+
privateDirectory = mkdtempSync(join(directory, 'autorouter-status-'));
|
|
164
|
+
chmodSync(privateDirectory, 0o700);
|
|
165
|
+
path = join(privateDirectory, 'state.json');
|
|
166
|
+
flush();
|
|
167
|
+
heartbeat = setInterval(schedule, 5000);
|
|
168
|
+
heartbeat.unref();
|
|
169
|
+
} catch {
|
|
170
|
+
if (privateDirectory) { try { rmSync(privateDirectory, { recursive: true, force: true }); } catch {} }
|
|
171
|
+
path = null;
|
|
172
|
+
}
|
|
173
|
+
return { path, update, flush, close };
|
|
174
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
const PHASES = new Set(['routing', 'connecting', 'streaming', 'ready', 'error', 'cancelled']);
|
|
2
|
+
const REASONS = {
|
|
3
|
+
tool_turn_pinned: 'turn pinned', thinking_history: 'thinking pinned', unknown_continuation: 'continuation pinned',
|
|
4
|
+
mid_conversation_system: 'system features', requires_sonnet_capabilities: 'capability guard',
|
|
5
|
+
model_specific_features: 'model features', large_or_multimodal_request: 'large request',
|
|
6
|
+
context_capacity: 'large context',
|
|
7
|
+
internal_request: 'internal request', unknown_model: 'custom model', low_confidence: 'low confidence',
|
|
8
|
+
};
|
|
9
|
+
const clean = (value, limit = 64) => typeof value === 'string' ? value
|
|
10
|
+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
|
|
11
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
12
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '')
|
|
13
|
+
.replace(/\s+/g, ' ').trim().slice(0, limit) : '';
|
|
14
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
const width = value => [...value].reduce((sum, char) => sum + (/[^\u0000-\u10ff\u2000-\u2e7f]/u.test(char) ? 2 : 1), 0);
|
|
16
|
+
function shorten(value, limit) {
|
|
17
|
+
if (width(value) <= limit) return value;
|
|
18
|
+
let text = '';
|
|
19
|
+
for (const char of value) { if (width(text + char) + 1 > limit) break; text += char; }
|
|
20
|
+
return text + '…';
|
|
21
|
+
}
|
|
22
|
+
function modelName(value) {
|
|
23
|
+
const name = clean(value, 48);
|
|
24
|
+
const match = /^claude-(haiku|sonnet|opus)-(\d+)(?:-(\d{1,2})(?=-|$))?/i.exec(name);
|
|
25
|
+
return match ? `${match[1][0].toUpperCase()}${match[1].slice(1).toLowerCase()} ${match[2]}${match[3] ? '.' + match[3] : ''}` : name;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Only unambiguous native windows are listed. Custom IDs and models whose
|
|
29
|
+
// subscription window depends on an opt-in cannot safely imply a capacity.
|
|
30
|
+
const CONTEXT_WINDOWS = new Map([
|
|
31
|
+
...['claude-haiku-4-5', 'claude-haiku-4-5-20251001', 'claude-sonnet-4-5', 'claude-sonnet-4-5-20250929',
|
|
32
|
+
'claude-opus-4-5', 'claude-opus-4-5-20251101'].map(model => [model, 200000]),
|
|
33
|
+
...['claude-sonnet-5', 'claude-sonnet-5-5', 'claude-opus-4-7', 'claude-opus-4-8',
|
|
34
|
+
'claude-opus-5', 'claude-opus-5-5'].map(model => [model, 1000000]),
|
|
35
|
+
]);
|
|
36
|
+
const capacityLabel = value => value % 1000000 === 0 ? `${value / 1000000}M` : value % 1000 === 0 ? `${value / 1000}K` : String(value);
|
|
37
|
+
|
|
38
|
+
function contextLabel(input, state) {
|
|
39
|
+
const client = object(input?.context_window) ? input.context_window : undefined;
|
|
40
|
+
const percent = client?.used_percentage;
|
|
41
|
+
const clientCapacity = Number.isSafeInteger(client?.context_window_size) && client.context_window_size > 0
|
|
42
|
+
? client.context_window_size : undefined;
|
|
43
|
+
const cli = typeof percent === 'number' && Number.isFinite(percent) && percent >= 0 && percent <= 100
|
|
44
|
+
? `CLI ctx ${Math.round(percent)}%${clientCapacity ? `/${capacityLabel(clientCapacity)}` : ''}` : '';
|
|
45
|
+
// Claude resets current_usage to null after /compact. Do not revive the
|
|
46
|
+
// previous request's count while that reset is waiting for a fresh response.
|
|
47
|
+
if (client && Object.hasOwn(client, 'current_usage') && client.current_usage === null) return cli;
|
|
48
|
+
const current = object(state?.context_usage) && state.context_usage.model === state.actual_model
|
|
49
|
+
&& ['streaming', 'ready'].includes(state.phase) ? state.context_usage : undefined;
|
|
50
|
+
const historical = !current && ['routing', 'connecting', 'streaming', 'error', 'cancelled'].includes(state?.phase)
|
|
51
|
+
&& object(state.last_context_usage) ? state.last_context_usage : undefined;
|
|
52
|
+
const usage = current ?? historical;
|
|
53
|
+
const capacity = CONTEXT_WINDOWS.get(usage?.model);
|
|
54
|
+
if (!capacity || !Number.isSafeInteger(usage.input_tokens) || usage.input_tokens < 0) return cli;
|
|
55
|
+
const api = `${historical ? 'last ' : ''}API ctx ${Math.round(usage.input_tokens * 100 / capacity)}%/${capacityLabel(capacity)}`;
|
|
56
|
+
// Keep the client limit visible when it differs: the API window does not
|
|
57
|
+
// change Claude Code's own compaction threshold or remaining-context UI.
|
|
58
|
+
return cli && clientCapacity !== capacity ? `${api} · ${cli}` : api;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function savingsLabels(entry) {
|
|
62
|
+
if (!object(entry)) return;
|
|
63
|
+
const count = value => Number.isSafeInteger(value) && value >= 0;
|
|
64
|
+
const dollars = value => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
65
|
+
const unavailable = { full: 'savings unavailable', compact: 'savings unavailable', color: '33' };
|
|
66
|
+
if (!count(entry.requests) || !count(entry.unpriced_requests)) return unavailable;
|
|
67
|
+
if (entry.requests === 0) return entry.unpriced_requests > 0 ? unavailable : undefined;
|
|
68
|
+
if (!dollars(entry.actual_usd) || !dollars(entry.baseline_usd)
|
|
69
|
+
|| typeof entry.saved_usd !== 'number' || !Number.isFinite(entry.saved_usd)) return unavailable;
|
|
70
|
+
const extra = entry.saved_usd < 0;
|
|
71
|
+
const amount = Math.abs(entry.saved_usd);
|
|
72
|
+
const money = amount > 0 && amount < 0.01 ? '<$0.01' : `$${amount.toFixed(2)}`;
|
|
73
|
+
const percent = entry.baseline_usd > 0 && typeof entry.percent === 'number' && Number.isFinite(entry.percent)
|
|
74
|
+
? `${Math.round(Math.abs(entry.percent))}%` : '';
|
|
75
|
+
const partial = entry.partial === true || entry.unpriced_requests > 0;
|
|
76
|
+
const label = `est ${extra ? 'extra' : 'saved'}`;
|
|
77
|
+
const ending = ` vs Opus${partial ? ' partial' : ''}`;
|
|
78
|
+
return {
|
|
79
|
+
full: `${label} ${money}${percent ? ` (${percent})` : ''}${ending}`,
|
|
80
|
+
compact: percent ? `${label} ${percent}${ending}` : `${label} ${money}${ending}`,
|
|
81
|
+
color: extra || partial ? '33' : '32',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function renderStatusLine(input, snapshot, { now = Date.now(), color = true, columns = process.env.COLUMNS, alive } = {}) {
|
|
86
|
+
const paint = (value, code) => color ? `\x1b[${code}m${value}\x1b[0m` : value;
|
|
87
|
+
const numericColumns = Number(columns);
|
|
88
|
+
const available = Number.isFinite(numericColumns) && numericColumns > 0 ? Math.max(1, Math.floor(numericColumns)) : 120;
|
|
89
|
+
let online = object(snapshot) && snapshot.version === 1 && Number.isSafeInteger(snapshot.pid) && snapshot.pid > 0
|
|
90
|
+
&& Number.isFinite(snapshot.heartbeat_at) && snapshot.heartbeat_at > 0
|
|
91
|
+
&& snapshot.heartbeat_at <= now + 5000 && now - snapshot.heartbeat_at <= 20000;
|
|
92
|
+
if (online && alive !== undefined) {
|
|
93
|
+
try { online = typeof alive === 'function' ? alive(snapshot.pid) !== false : alive !== false; } catch { online = false; }
|
|
94
|
+
}
|
|
95
|
+
if (!online) {
|
|
96
|
+
if (width('● AutoRouter offline') > available) return paint(shorten('AutoRouter offline', available), '2;31');
|
|
97
|
+
return `${paint('●', '31')} ${paint('AutoRouter', '1')} ${paint('offline', '2;31')}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sessionId = object(input) && (input.session_id === undefined || typeof input.session_id === 'string') ? input.session_id ?? '' : undefined;
|
|
101
|
+
const candidate = sessionId !== undefined && object(snapshot.sessions) && Object.hasOwn(snapshot.sessions, sessionId) ? snapshot.sessions[sessionId] : undefined;
|
|
102
|
+
const savingsEntry = sessionId !== undefined && object(snapshot.savings) && Object.hasOwn(snapshot.savings, sessionId) ? snapshot.savings[sessionId] : undefined;
|
|
103
|
+
const savings = savingsLabels(savingsEntry);
|
|
104
|
+
const state = object(candidate) && PHASES.has(candidate.phase) ? candidate : undefined;
|
|
105
|
+
const phase = state?.phase;
|
|
106
|
+
let status = phase ?? 'awaiting request';
|
|
107
|
+
let model = '';
|
|
108
|
+
let prefix = '';
|
|
109
|
+
let suffix = '';
|
|
110
|
+
let confirmed = false;
|
|
111
|
+
const last = modelName(state?.last_model);
|
|
112
|
+
const actual = modelName(state?.actual_model);
|
|
113
|
+
const selected = modelName(state?.selected_model);
|
|
114
|
+
if (phase === 'streaming' && actual) { model = actual; confirmed = true; }
|
|
115
|
+
else if (phase === 'connecting' && selected) { model = selected; suffix = ' selected'; }
|
|
116
|
+
else if (phase === 'streaming' && selected) { model = selected; suffix = ' unconfirmed'; }
|
|
117
|
+
else if (phase === 'ready' && actual) { model = actual; prefix = 'last '; confirmed = true; }
|
|
118
|
+
else if (phase === 'ready' && selected) { model = selected; suffix = ' unconfirmed'; }
|
|
119
|
+
else if (['ready', 'error', 'cancelled'].includes(phase) && (last || actual)) { model = last || actual; prefix = 'last '; confirmed = true; }
|
|
120
|
+
else if (last) { model = last; prefix = 'last '; confirmed = true; }
|
|
121
|
+
if (phase === 'ready' && !model) status = 'awaiting request';
|
|
122
|
+
const errorType = clean(state?.error_type, 32);
|
|
123
|
+
if (phase === 'error') status = Number.isInteger(state.status) && state.status >= 400 && state.status <= 599
|
|
124
|
+
? `error ${state.status}` : errorType ? `error ${errorType}` : 'error';
|
|
125
|
+
|
|
126
|
+
const details = [];
|
|
127
|
+
const source = ['jev', 'ollama', 'cache', 'fallback'].includes(state?.source) ? state.source : undefined;
|
|
128
|
+
if (source) {
|
|
129
|
+
const evaluator = ['jev', 'ollama'].includes(state.evaluator) ? state.evaluator : undefined;
|
|
130
|
+
const evaluatorLabel = evaluator === 'ollama' ? 'Ollama' : evaluator === 'jev' ? 'Jev' : '';
|
|
131
|
+
const sourceLabel = source === 'jev' ? 'Jev' : source === 'ollama' ? 'Ollama'
|
|
132
|
+
: evaluatorLabel ? `${evaluatorLabel} ${source}` : source;
|
|
133
|
+
const timing = Number.isFinite(state.latency_ms) && state.latency_ms >= 0 ? ` ${Math.round(Math.min(state.latency_ms, 999999))}ms` : '';
|
|
134
|
+
const classified = ['haiku', 'sonnet', 'opus'].includes(state.classified_tier) ? state.classified_tier : undefined;
|
|
135
|
+
const chosenFamily = /^claude-(haiku|sonnet|opus)-/.exec(state.selected_model ?? '')?.[1];
|
|
136
|
+
const override = classified && chosenFamily && classified !== chosenFamily
|
|
137
|
+
? `→${classified[0].toUpperCase()}${classified.slice(1)}` : '';
|
|
138
|
+
details.push(`${sourceLabel}${override}${timing}`);
|
|
139
|
+
}
|
|
140
|
+
if (source === 'fallback') {
|
|
141
|
+
const classifierError = clean(state.classifier_error, 24);
|
|
142
|
+
if (classifierError) details.push(classifierError.replaceAll('_', ' '));
|
|
143
|
+
}
|
|
144
|
+
if (Object.hasOwn(REASONS, state?.reason)) details.push(state.reason === 'context_capacity' && state.context_check === 'count_unavailable'
|
|
145
|
+
? 'size unverified' : REASONS[state.reason]);
|
|
146
|
+
if (phase === 'error' && errorType && !status.includes(errorType)) details.push(errorType);
|
|
147
|
+
let context = contextLabel(input, state);
|
|
148
|
+
let detail = details.join(' · ');
|
|
149
|
+
let saving = savings?.full ?? '';
|
|
150
|
+
let brand = '● AutoRouter';
|
|
151
|
+
const modelLabel = () => model ? prefix + model + suffix : '';
|
|
152
|
+
const plain = () => [brand, modelLabel(), status, detail, saving, context].filter(Boolean).join(' · ');
|
|
153
|
+
if (width(plain()) > available) context = '';
|
|
154
|
+
// Routine routing details can yield to the estimate, while errors and
|
|
155
|
+
// fallback explanations have priority over savings. Never shorten away
|
|
156
|
+
// "est" or "partial": hide the whole metric when its qualifiers cannot fit.
|
|
157
|
+
if (width(plain()) > available && source !== 'fallback' && phase !== 'error') detail = '';
|
|
158
|
+
if (width(plain()) > available && saving) saving = savings.compact;
|
|
159
|
+
if (width(plain()) > available) saving = '';
|
|
160
|
+
if (width(plain()) > available) detail = '';
|
|
161
|
+
if (width(plain()) > available) brand = '● AR';
|
|
162
|
+
if (width(plain()) > available) brand = '';
|
|
163
|
+
if (width(plain()) > available && model) {
|
|
164
|
+
if (phase === 'streaming') status = 'stream';
|
|
165
|
+
const room = available - width(prefix + suffix + status) - 3;
|
|
166
|
+
if (room >= 1) model = shorten(model, room);
|
|
167
|
+
else {
|
|
168
|
+
// A selection's qualifier is essential. If it cannot fit, show only the
|
|
169
|
+
// router phase instead of making an unconfirmed model look confirmed.
|
|
170
|
+
model = ''; prefix = ''; suffix = '';
|
|
171
|
+
if (width('● AutoRouter · ' + status) <= available) brand = '● AutoRouter';
|
|
172
|
+
else if (width('● AR · ' + status) <= available) brand = '● AR';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (width(plain()) > available) status = shorten(status, Math.max(1, available - width(modelLabel()) - (model ? 3 : 0)));
|
|
176
|
+
const attention = phase === 'error' ? '31' : source === 'fallback' ? '33' : phase === 'cancelled' ? '2' : phase === 'streaming' || phase === 'ready' ? '32' : '36';
|
|
177
|
+
const chunks = [];
|
|
178
|
+
if (brand) chunks.push(`${paint('●', attention)} ${paint(brand.slice(2), '1')}`);
|
|
179
|
+
if (model) chunks.push(paint(modelLabel(), confirmed ? '37' : '33'));
|
|
180
|
+
chunks.push(paint(status, attention));
|
|
181
|
+
if (detail) chunks.push(paint(detail, source === 'fallback' ? '33' : '2'));
|
|
182
|
+
if (saving) chunks.push(paint(saving, savings.color));
|
|
183
|
+
if (context) chunks.push(paint(context, '2'));
|
|
184
|
+
return chunks.join(paint(' · ', '2'));
|
|
185
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// Count-token API fields, including the beta fields used by Claude Code.
|
|
4
|
+
// Keep unknown extensions out of the count path: dropping new input context
|
|
5
|
+
// could produce an incorrectly low count and an unsafe routing decision.
|
|
6
|
+
const COUNT_FIELDS = new Set([
|
|
7
|
+
'model', 'messages', 'system', 'tools', 'tool_choice', 'thinking',
|
|
8
|
+
'cache_control', 'context_management', 'compaction', 'output_config',
|
|
9
|
+
'output_format', 'speed',
|
|
10
|
+
]);
|
|
11
|
+
const GENERATION_FIELDS = new Set([
|
|
12
|
+
'max_tokens', 'stream', 'temperature', 'top_p', 'top_k', 'stop_sequences',
|
|
13
|
+
'metadata', 'service_tier', 'inference_geo',
|
|
14
|
+
]);
|
|
15
|
+
const CACHE_HEADERS = [
|
|
16
|
+
'authorization', 'x-api-key', 'anthropic-beta', 'anthropic-version',
|
|
17
|
+
'anthropic-workspace-id', 'anthropic-user-profile-id',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function canCount(body) {
|
|
21
|
+
if (!body || !Array.isArray(body.messages)) return false;
|
|
22
|
+
if (Object.keys(body).some(key => !COUNT_FIELDS.has(key) && !GENERATION_FIELDS.has(key))) return false;
|
|
23
|
+
if (body.tools !== undefined && !Array.isArray(body.tools)) return false;
|
|
24
|
+
if (body.tools?.some(tool => !tool || (tool.type && tool.type !== 'custom'
|
|
25
|
+
&& !/^(?:bash|text_editor|computer|memory)_\d{8}$/.test(tool.type)
|
|
26
|
+
&& tool.type !== 'advisor_20260301'))) return false;
|
|
27
|
+
|
|
28
|
+
const pending = [body.system, ...body.messages.map(message => message?.content)];
|
|
29
|
+
while (pending.length) {
|
|
30
|
+
const content = pending.pop();
|
|
31
|
+
if (!Array.isArray(content)) continue;
|
|
32
|
+
for (const block of content) {
|
|
33
|
+
if (!block || typeof block !== 'object') continue;
|
|
34
|
+
// The count endpoint cannot fetch URL/File API sources. Do not drop
|
|
35
|
+
// them or turn their tiny JSON representation into a token estimate.
|
|
36
|
+
if (['image', 'document'].includes(block.type)) {
|
|
37
|
+
if (['url', 'file'].includes(block.source?.type)) return false;
|
|
38
|
+
if (block.source?.type === 'content') pending.push(block.source.content);
|
|
39
|
+
}
|
|
40
|
+
pending.push(block.content);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readCount(response) {
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
await response.body?.cancel();
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (!response.body) return undefined;
|
|
52
|
+
// A successful response is tiny. Bound parsing even if an upstream gateway
|
|
53
|
+
// returns an unexpected body; none of its content enters logs or the cache.
|
|
54
|
+
const reader = response.body.getReader();
|
|
55
|
+
const chunks = [];
|
|
56
|
+
let size = 0;
|
|
57
|
+
try {
|
|
58
|
+
for (;;) {
|
|
59
|
+
const { done, value } = await reader.read();
|
|
60
|
+
if (done) break;
|
|
61
|
+
size += value.byteLength;
|
|
62
|
+
if (size > 65536) { await reader.cancel(); return undefined; }
|
|
63
|
+
chunks.push(value);
|
|
64
|
+
}
|
|
65
|
+
const count = JSON.parse(Buffer.concat(chunks, size).toString('utf8'))?.input_tokens;
|
|
66
|
+
return Number.isSafeInteger(count) && count >= 0 ? count : undefined;
|
|
67
|
+
} finally { reader.releaseLock(); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Credentials are supplied for this one request by the server. The cache
|
|
71
|
+
// retains only hashes, token counts, and expiry times; never prompts or keys.
|
|
72
|
+
export function createTokenCounter(config, { fetchImpl = fetch } = {}) {
|
|
73
|
+
const timeoutMs = config.tokenCountTimeoutMs ?? 1500;
|
|
74
|
+
const cacheEntries = config.tokenCountCacheEntries ?? 100;
|
|
75
|
+
const cacheTtlMs = config.tokenCountCacheTtlMs ?? 5 * 60 * 1000;
|
|
76
|
+
const cache = new Map();
|
|
77
|
+
return async function countTokens(body, model, { headers = {}, signal, search = '' } = {}) {
|
|
78
|
+
if (signal?.aborted || !canCount(body) || typeof model !== 'string' || !model) return undefined;
|
|
79
|
+
let timer;
|
|
80
|
+
let onAbort;
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
try {
|
|
83
|
+
const payload = { ...Object.fromEntries(Object.entries(body).filter(([key]) => COUNT_FIELDS.has(key))), model };
|
|
84
|
+
const serialized = JSON.stringify(payload);
|
|
85
|
+
const requestHeaders = new Headers(headers);
|
|
86
|
+
requestHeaders.delete('content-length');
|
|
87
|
+
requestHeaders.set('content-type', 'application/json');
|
|
88
|
+
requestHeaders.set('accept', 'application/json');
|
|
89
|
+
const url = new URL(`${config.upstream.replace(/\/$/, '')}/v1/messages/count_tokens`);
|
|
90
|
+
url.search = search;
|
|
91
|
+
const key = createHash('sha256').update(JSON.stringify([
|
|
92
|
+
url.href, CACHE_HEADERS.map(name => requestHeaders.get(name)), serialized,
|
|
93
|
+
])).digest('hex');
|
|
94
|
+
const cached = cache.get(key);
|
|
95
|
+
if (cached) {
|
|
96
|
+
cache.delete(key);
|
|
97
|
+
if (cached.expires > Date.now()) { cache.set(key, cached); return cached.count; }
|
|
98
|
+
}
|
|
99
|
+
const cancelled = new Promise(resolve => {
|
|
100
|
+
onAbort = () => { controller.abort(); resolve(undefined); };
|
|
101
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
102
|
+
timer = setTimeout(onAbort, timeoutMs);
|
|
103
|
+
});
|
|
104
|
+
const pending = (async () => {
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetchImpl(url.href, {
|
|
107
|
+
method: 'POST', redirect: 'error', headers: requestHeaders,
|
|
108
|
+
body: serialized, signal: controller.signal,
|
|
109
|
+
});
|
|
110
|
+
const count = await readCount(response);
|
|
111
|
+
if (controller.signal.aborted || count === undefined) return undefined;
|
|
112
|
+
cache.set(key, { count, expires: Date.now() + cacheTtlMs });
|
|
113
|
+
while (cache.size > cacheEntries) cache.delete(cache.keys().next().value);
|
|
114
|
+
return count;
|
|
115
|
+
} catch { return undefined; }
|
|
116
|
+
})();
|
|
117
|
+
// Cover response-body consumption and implementations that do not reject
|
|
118
|
+
// promptly on abort. Unknown counts simply leave the router's fallback
|
|
119
|
+
// policy in charge; no count failure can fail an inference request.
|
|
120
|
+
return await Promise.race([pending, cancelled]);
|
|
121
|
+
} catch { return undefined; }
|
|
122
|
+
finally {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
if (onAbort) signal?.removeEventListener('abort', onAbort);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync, constants, fchmodSync, fsyncSync, linkSync, lstatSync,
|
|
3
|
+
mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync,
|
|
4
|
+
} from 'node:fs';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const CONFIG_KEYS = new Set([
|
|
10
|
+
'AUTOROUTER_AUTH_MODE', 'AUTOROUTER_CLIENT_PROFILE',
|
|
11
|
+
'ANTHROPIC_API_KEY', 'TYPESAFE_API_KEY', 'AUTOROUTER_TOKEN',
|
|
12
|
+
'AUTOROUTER_UPSTREAM_URL', 'AUTOROUTER_JEV_URL', 'AUTOROUTER_JEV_MODEL',
|
|
13
|
+
'AUTOROUTER_HAIKU_MODEL', 'AUTOROUTER_SONNET_MODEL', 'AUTOROUTER_OPUS_MODEL',
|
|
14
|
+
'AUTOROUTER_PORT', 'AUTOROUTER_JEV_TIMEOUT_MS', 'AUTOROUTER_TOKEN_COUNT_TIMEOUT_MS',
|
|
15
|
+
'AUTOROUTER_MIN_CONFIDENCE', 'AUTOROUTER_STATUSLINE', 'AUTOROUTER_DEBUG',
|
|
16
|
+
'ENABLE_TOOL_SEARCH',
|
|
17
|
+
'AUTOROUTER_EVALUATOR', 'AUTOROUTER_OLLAMA_URL', 'AUTOROUTER_OLLAMA_MODEL',
|
|
18
|
+
'AUTOROUTER_OLLAMA_TIMEOUT_MS', 'AUTOROUTER_OLLAMA_KEEP_ALIVE',
|
|
19
|
+
]);
|
|
20
|
+
const SAFE_FS_CODES = new Set([
|
|
21
|
+
'EACCES', 'EPERM', 'ENOENT', 'ENOTDIR', 'EISDIR', 'ENOSPC', 'EROFS',
|
|
22
|
+
'EMFILE', 'ENFILE', 'ELOOP', 'EIO', 'EEXIST',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function configError(message) {
|
|
26
|
+
const error = new Error(message);
|
|
27
|
+
error.code = 'AUTOROUTER_CONFIG_ERROR';
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function filesystemError(error, operation) {
|
|
32
|
+
if (error?.code === 'AUTOROUTER_CONFIG_ERROR') return error;
|
|
33
|
+
// Native filesystem errors include paths; JSON errors can include values.
|
|
34
|
+
// Neither is needed to explain the failure, and either might hold a secret.
|
|
35
|
+
const code = SAFE_FS_CODES.has(error?.code) ? ` (${error.code})` : '';
|
|
36
|
+
return configError(`Could not ${operation} AutoRouter configuration${code}.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validate(values) {
|
|
40
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)
|
|
41
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(values))) {
|
|
42
|
+
throw configError('AutoRouter configuration must be a JSON object of string values.');
|
|
43
|
+
}
|
|
44
|
+
const validated = {};
|
|
45
|
+
for (const key of Reflect.ownKeys(values)) {
|
|
46
|
+
if (!CONFIG_KEYS.has(key)) {
|
|
47
|
+
// An unknown key can itself contain a pasted credential.
|
|
48
|
+
throw configError('AutoRouter configuration contains an unsupported key.');
|
|
49
|
+
}
|
|
50
|
+
const descriptor = Object.getOwnPropertyDescriptor(values, key);
|
|
51
|
+
if (!descriptor || !('value' in descriptor) || typeof descriptor.value !== 'string') {
|
|
52
|
+
throw configError('AutoRouter configuration values must be strings.');
|
|
53
|
+
}
|
|
54
|
+
validated[key] = descriptor.value;
|
|
55
|
+
}
|
|
56
|
+
return validated;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getConfigPath(env = process.env) {
|
|
60
|
+
if (env.AUTOROUTER_CONFIG !== undefined) {
|
|
61
|
+
if (typeof env.AUTOROUTER_CONFIG !== 'string' || !env.AUTOROUTER_CONFIG.trim()) {
|
|
62
|
+
throw configError('AUTOROUTER_CONFIG must be a non-empty path.');
|
|
63
|
+
}
|
|
64
|
+
return resolve(env.AUTOROUTER_CONFIG);
|
|
65
|
+
}
|
|
66
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
67
|
+
if (xdg !== undefined && (typeof xdg !== 'string' || (xdg && !isAbsolute(xdg)))) {
|
|
68
|
+
throw configError('XDG_CONFIG_HOME must be an absolute path when set.');
|
|
69
|
+
}
|
|
70
|
+
return join(xdg || join(homedir(), '.config'), 'claude-autorouter', 'config.json');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function loadUserConfig(env = process.env) {
|
|
74
|
+
const path = getConfigPath(env);
|
|
75
|
+
let content;
|
|
76
|
+
try {
|
|
77
|
+
content = readFileSync(path, 'utf8');
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.code === 'ENOENT') {
|
|
80
|
+
if (env.AUTOROUTER_CONFIG === undefined) return { env: { ...env }, path, exists: false };
|
|
81
|
+
throw configError('AUTOROUTER_CONFIG points to a missing configuration file.');
|
|
82
|
+
}
|
|
83
|
+
throw filesystemError(error, 'read');
|
|
84
|
+
}
|
|
85
|
+
let parsed;
|
|
86
|
+
try { parsed = JSON.parse(content); }
|
|
87
|
+
catch { throw configError('AutoRouter configuration must contain valid JSON.'); }
|
|
88
|
+
const values = validate(parsed);
|
|
89
|
+
return { env: { ...values, ...env }, path, exists: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function existingFile(path) {
|
|
93
|
+
let stat;
|
|
94
|
+
try { stat = lstatSync(path); }
|
|
95
|
+
catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
|
|
96
|
+
if (stat.isSymbolicLink()) throw configError('Refusing to write a symbolic-link AutoRouter configuration.');
|
|
97
|
+
if (!stat.isFile()) throw configError('AutoRouter configuration must be a regular file.');
|
|
98
|
+
return stat;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function saveUserConfig(values, { env = process.env, overwrite = false } = {}) {
|
|
102
|
+
const validated = validate(values);
|
|
103
|
+
const path = getConfigPath(env);
|
|
104
|
+
const parent = dirname(path);
|
|
105
|
+
let temporary;
|
|
106
|
+
let descriptor;
|
|
107
|
+
try {
|
|
108
|
+
if (existingFile(path) && !overwrite) {
|
|
109
|
+
throw configError('AutoRouter configuration already exists; use overwrite to replace it.');
|
|
110
|
+
}
|
|
111
|
+
// mkdir leaves existing directory permissions unchanged. Only directories
|
|
112
|
+
// created for this configuration receive the private creation mode.
|
|
113
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
114
|
+
temporary = join(parent, `.${basename(path)}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`);
|
|
115
|
+
descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
116
|
+
fchmodSync(descriptor, 0o600);
|
|
117
|
+
writeFileSync(descriptor, `${JSON.stringify(validated, null, 2)}\n`, 'utf8');
|
|
118
|
+
fsyncSync(descriptor);
|
|
119
|
+
closeSync(descriptor);
|
|
120
|
+
descriptor = undefined;
|
|
121
|
+
|
|
122
|
+
// Check again after preparing the replacement. Renaming replaces the
|
|
123
|
+
// directory entry rather than following a target that changes to a link.
|
|
124
|
+
if (existingFile(path) && !overwrite) {
|
|
125
|
+
throw configError('AutoRouter configuration already exists; use overwrite to replace it.');
|
|
126
|
+
}
|
|
127
|
+
if (overwrite) {
|
|
128
|
+
renameSync(temporary, path);
|
|
129
|
+
temporary = undefined;
|
|
130
|
+
} else {
|
|
131
|
+
// A hard link makes creation exclusive and atomic: a concurrent file or
|
|
132
|
+
// symlink can never be replaced by the default save operation.
|
|
133
|
+
linkSync(temporary, path);
|
|
134
|
+
}
|
|
135
|
+
return path;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error?.code === 'EEXIST') {
|
|
138
|
+
throw configError('AutoRouter configuration already exists; use overwrite to replace it.');
|
|
139
|
+
}
|
|
140
|
+
throw filesystemError(error, 'save');
|
|
141
|
+
} finally {
|
|
142
|
+
if (descriptor !== undefined) { try { closeSync(descriptor); } catch {} }
|
|
143
|
+
if (temporary) { try { unlinkSync(temporary); } catch {} }
|
|
144
|
+
}
|
|
145
|
+
}
|