claude-autorouter 0.2.0 → 0.3.2
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 +42 -14
- package/README.md +39 -18
- package/bin/autorouter.mjs +10 -7
- package/docs/development.md +33 -4
- package/docs/ollama-evaluation.md +83 -62
- package/docs/reference.md +60 -24
- package/docs/releasing.md +19 -13
- package/package.json +4 -3
- package/src/config.mjs +11 -3
- package/src/ollama-evaluator.mjs +64 -35
- package/src/ollama-models.mjs +15 -9
- package/src/ollama-setup.mjs +18 -4
- package/src/onboarding.mjs +20 -9
- package/src/statusline.mjs +30 -6
package/src/onboarding.mjs
CHANGED
|
@@ -6,11 +6,14 @@ import { promisify } from 'node:util';
|
|
|
6
6
|
import { readConfig, requireKeys } from './config.mjs';
|
|
7
7
|
import { buildClaudeEnv, conflictingProviders, LOCAL_AUTH_HEADER } from './auth.mjs';
|
|
8
8
|
import { getConfigPath, loadUserConfig, saveUserConfig } from './user-config.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import { DEFAULT_OLLAMA_MODEL, validateOllamaModel } from './ollama-models.mjs';
|
|
10
10
|
import { inspectOllama, setupOllama } from './ollama-setup.mjs';
|
|
11
11
|
|
|
12
12
|
const execute = promisify(execFile);
|
|
13
13
|
|
|
14
|
+
export const ollamaDeadlineText = timeoutMs => timeoutMs === 0
|
|
15
|
+
? 'routing deadline disabled' : `routing deadline ${timeoutMs} ms per request`;
|
|
16
|
+
|
|
14
17
|
// Readline manages editing and restores terminal state; its output is discarded
|
|
15
18
|
// so neither typing nor pasted credentials are echoed to the terminal.
|
|
16
19
|
export async function askSecret(label, { input = process.stdin, output = process.stderr } = {}) {
|
|
@@ -31,27 +34,32 @@ export async function askSecret(label, { input = process.stdin, output = process
|
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export async function setup(args, {
|
|
34
|
-
env = process.env, write = console.log, prompt = askSecret, fetchImpl = fetch, signal,
|
|
37
|
+
env = process.env, write = console.log, prompt = askSecret, fetchImpl = fetch, signal,
|
|
35
38
|
} = {}) {
|
|
36
39
|
let authMode = env.AUTOROUTER_AUTH_MODE ?? 'subscription';
|
|
37
40
|
let evaluator = env.AUTOROUTER_EVALUATOR ?? 'jev';
|
|
38
|
-
let preset = 'compact';
|
|
39
|
-
let explicitPreset = false;
|
|
40
41
|
let model;
|
|
42
|
+
let ollamaTimeoutMs;
|
|
41
43
|
let pull = false;
|
|
42
44
|
let overwrite = false;
|
|
43
45
|
for (let i = 0; i < args.length; i++) {
|
|
44
46
|
if (args[i] === '--auth-mode') authMode = args[++i];
|
|
45
47
|
else if (args[i] === '--evaluator') evaluator = args[++i];
|
|
46
|
-
else if (args[i] === '--ollama-preset') { preset = args[++i]; explicitPreset = true; if (preset === undefined) throw new Error('--ollama-preset requires compact, quality, or auto'); }
|
|
47
48
|
else if (args[i] === '--ollama-model') { model = args[++i]; if (model === undefined) throw new Error('--ollama-model requires a model tag'); }
|
|
49
|
+
else if (args[i] === '--ollama-timeout-ms') {
|
|
50
|
+
const value = args[++i];
|
|
51
|
+
if (typeof value !== 'string' || !/^[0-9]+$/.test(value.trim()) || Number(value) > 30000) {
|
|
52
|
+
throw new Error('--ollama-timeout-ms requires an integer between 0 and 30000 (0 disables the routing deadline)');
|
|
53
|
+
}
|
|
54
|
+
ollamaTimeoutMs = String(Number(value));
|
|
55
|
+
}
|
|
48
56
|
else if (args[i] === '--pull') pull = true;
|
|
49
57
|
else if (args[i] === '--force') overwrite = true;
|
|
50
|
-
else throw new Error('Usage: claude-autorouter setup [--auth-mode subscription|api-key] [--evaluator jev|ollama] [--ollama-
|
|
58
|
+
else throw new Error('Usage: claude-autorouter setup [--auth-mode subscription|api-key] [--evaluator jev|ollama] [--ollama-model TAG] [--ollama-timeout-ms N] [--pull] [--force]');
|
|
51
59
|
}
|
|
52
60
|
if (!['subscription', 'api-key'].includes(authMode)) throw new Error('--auth-mode must be subscription or api-key');
|
|
53
61
|
if (!['jev', 'ollama'].includes(evaluator)) throw new Error('--evaluator must be jev or ollama');
|
|
54
|
-
if (evaluator !== 'ollama' && (
|
|
62
|
+
if (evaluator !== 'ollama' && (model !== undefined || ollamaTimeoutMs !== undefined || pull)) throw new Error('Ollama model, deadline and download options require --evaluator ollama');
|
|
55
63
|
const path = getConfigPath(env);
|
|
56
64
|
if (!overwrite && existsSync(path)) throw new Error('AutoRouter configuration already exists. Use setup --force to replace it.');
|
|
57
65
|
write(evaluator === 'ollama'
|
|
@@ -59,11 +67,11 @@ export async function setup(args, {
|
|
|
59
67
|
: 'AutoRouter sends bounded prompt excerpts to TypeSafe Jev and complete requests to Anthropic.');
|
|
60
68
|
const values = { AUTOROUTER_AUTH_MODE: authMode, AUTOROUTER_CLIENT_PROFILE: 'compatible', AUTOROUTER_EVALUATOR: evaluator };
|
|
61
69
|
if (evaluator === 'ollama') {
|
|
62
|
-
values.AUTOROUTER_OLLAMA_MODEL =
|
|
70
|
+
values.AUTOROUTER_OLLAMA_MODEL = validateOllamaModel(model ?? env.AUTOROUTER_OLLAMA_MODEL ?? DEFAULT_OLLAMA_MODEL);
|
|
63
71
|
for (const key of ['AUTOROUTER_OLLAMA_URL', 'AUTOROUTER_OLLAMA_TIMEOUT_MS', 'AUTOROUTER_OLLAMA_KEEP_ALIVE']) {
|
|
64
72
|
if (env[key] !== undefined) values[key] = env[key];
|
|
65
73
|
}
|
|
66
|
-
|
|
74
|
+
if (ollamaTimeoutMs !== undefined) values.AUTOROUTER_OLLAMA_TIMEOUT_MS = ollamaTimeoutMs;
|
|
67
75
|
}
|
|
68
76
|
const keys = [...(evaluator === 'jev' ? ['TYPESAFE_API_KEY'] : []), ...(authMode === 'api-key' ? ['ANTHROPIC_API_KEY'] : [])];
|
|
69
77
|
for (const key of keys) {
|
|
@@ -74,6 +82,7 @@ export async function setup(args, {
|
|
|
74
82
|
const config = readConfig(values);
|
|
75
83
|
requireKeys(config);
|
|
76
84
|
if (evaluator === 'ollama') {
|
|
85
|
+
write(`Local evaluator: ${config.ollamaModel}; ${ollamaDeadlineText(config.ollamaTimeoutMs)}.`);
|
|
77
86
|
const controller = new AbortController();
|
|
78
87
|
const cancel = () => controller.abort();
|
|
79
88
|
if (!signal) for (const name of ['SIGINT', 'SIGTERM']) process.once(name, cancel);
|
|
@@ -107,6 +116,8 @@ export async function doctor({ env = process.env, write = console.log, run = exe
|
|
|
107
116
|
report(false, `Unset ${key}; AutoRouter uses the Anthropic Messages API`);
|
|
108
117
|
}
|
|
109
118
|
if (config?.evaluator === 'ollama') {
|
|
119
|
+
write(`Local evaluator: ${config.ollamaModel}; ${ollamaDeadlineText(config.ollamaTimeoutMs)}.`);
|
|
120
|
+
write('Model availability is checked below; classification speed and accuracy are not tested.');
|
|
110
121
|
try {
|
|
111
122
|
const result = await inspectOllama(config, { fetchImpl, signal });
|
|
112
123
|
report(result.installed, result.installed
|
package/src/statusline.mjs
CHANGED
|
@@ -6,6 +6,9 @@ const REASONS = {
|
|
|
6
6
|
context_capacity: 'large context',
|
|
7
7
|
internal_request: 'internal request', unknown_model: 'custom model', low_confidence: 'low confidence',
|
|
8
8
|
};
|
|
9
|
+
const CLASSIFIER_ERRORS = {
|
|
10
|
+
timeout: 'timeout', http_error: 'HTTP error', invalid_response: 'invalid response', network_error: 'network error',
|
|
11
|
+
};
|
|
9
12
|
const clean = (value, limit = 64) => typeof value === 'string' ? value
|
|
10
13
|
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
|
|
11
14
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
@@ -125,21 +128,28 @@ export function renderStatusLine(input, snapshot, { now = Date.now(), color = tr
|
|
|
125
128
|
|
|
126
129
|
const details = [];
|
|
127
130
|
const source = ['jev', 'ollama', 'cache', 'fallback'].includes(state?.source) ? state.source : undefined;
|
|
131
|
+
const evaluator = ['jev', 'ollama'].includes(state?.evaluator) ? state.evaluator : undefined;
|
|
132
|
+
const evaluatorLabel = evaluator === 'ollama' ? 'Ollama' : evaluator === 'jev' ? 'Jev' : '';
|
|
133
|
+
let fallbackCause = '';
|
|
134
|
+
let fallbackPhase = '';
|
|
135
|
+
let compactFallback = false;
|
|
128
136
|
if (source) {
|
|
129
|
-
const evaluator = ['jev', 'ollama'].includes(state.evaluator) ? state.evaluator : undefined;
|
|
130
|
-
const evaluatorLabel = evaluator === 'ollama' ? 'Ollama' : evaluator === 'jev' ? 'Jev' : '';
|
|
131
137
|
const sourceLabel = source === 'jev' ? 'Jev' : source === 'ollama' ? 'Ollama'
|
|
132
138
|
: evaluatorLabel ? `${evaluatorLabel} ${source}` : source;
|
|
133
139
|
const timing = Number.isFinite(state.latency_ms) && state.latency_ms >= 0 ? ` ${Math.round(Math.min(state.latency_ms, 999999))}ms` : '';
|
|
134
140
|
const classified = ['haiku', 'sonnet', 'opus'].includes(state.classified_tier) ? state.classified_tier : undefined;
|
|
135
141
|
const chosenFamily = /^claude-(haiku|sonnet|opus)-/.exec(state.selected_model ?? '')?.[1];
|
|
136
|
-
const override = classified && chosenFamily && classified !== chosenFamily
|
|
142
|
+
const override = source !== 'fallback' && classified && chosenFamily && classified !== chosenFamily
|
|
137
143
|
? `→${classified[0].toUpperCase()}${classified.slice(1)}` : '';
|
|
138
144
|
details.push(`${sourceLabel}${override}${timing}`);
|
|
139
145
|
}
|
|
140
146
|
if (source === 'fallback') {
|
|
141
|
-
|
|
142
|
-
|
|
147
|
+
// Snapshots normally contain allowlisted categories, but the renderer also
|
|
148
|
+
// rejects raw error messages so paths and provider response text stay out.
|
|
149
|
+
fallbackCause = Object.hasOwn(CLASSIFIER_ERRORS, state.classifier_error) ? CLASSIFIER_ERRORS[state.classifier_error] : '';
|
|
150
|
+
if (state.classifier_error === 'http_error' && Number.isInteger(state.classifier_status)
|
|
151
|
+
&& state.classifier_status >= 100 && state.classifier_status <= 599) fallbackCause = `HTTP ${state.classifier_status}`;
|
|
152
|
+
if (fallbackCause) details.push(fallbackCause);
|
|
143
153
|
}
|
|
144
154
|
if (Object.hasOwn(REASONS, state?.reason)) details.push(state.reason === 'context_capacity' && state.context_check === 'count_unavailable'
|
|
145
155
|
? 'size unverified' : REASONS[state.reason]);
|
|
@@ -157,11 +167,23 @@ export function renderStatusLine(input, snapshot, { now = Date.now(), color = tr
|
|
|
157
167
|
if (width(plain()) > available && source !== 'fallback' && phase !== 'error') detail = '';
|
|
158
168
|
if (width(plain()) > available && saving) saving = savings.compact;
|
|
159
169
|
if (width(plain()) > available) saving = '';
|
|
170
|
+
if (width(plain()) > available && source === 'fallback') {
|
|
171
|
+
// A successful Claude response can still follow evaluator failure. When
|
|
172
|
+
// space is tight, retain that cause instead of an ordinary "ready" phase,
|
|
173
|
+
// evaluator timing, or the guard details that followed the fallback.
|
|
174
|
+
compactFallback = true;
|
|
175
|
+
fallbackPhase = ['error', 'cancelled'].includes(phase) ? status : '';
|
|
176
|
+
status = [fallbackPhase, `${evaluatorLabel ? `${evaluatorLabel} ` : ''}fallback${fallbackCause ? `: ${fallbackCause}` : ''}`].filter(Boolean).join(' · ');
|
|
177
|
+
detail = '';
|
|
178
|
+
}
|
|
160
179
|
if (width(plain()) > available) detail = '';
|
|
161
180
|
if (width(plain()) > available) brand = '● AR';
|
|
162
181
|
if (width(plain()) > available) brand = '';
|
|
182
|
+
if (width(plain()) > available && compactFallback) {
|
|
183
|
+
status = [fallbackPhase, `fallback${fallbackCause ? `: ${fallbackCause}` : ''}`].filter(Boolean).join(' · ');
|
|
184
|
+
}
|
|
163
185
|
if (width(plain()) > available && model) {
|
|
164
|
-
if (phase === 'streaming') status = 'stream';
|
|
186
|
+
if (phase === 'streaming' && !compactFallback) status = 'stream';
|
|
165
187
|
const room = available - width(prefix + suffix + status) - 3;
|
|
166
188
|
if (room >= 1) model = shorten(model, room);
|
|
167
189
|
else {
|
|
@@ -172,6 +194,8 @@ export function renderStatusLine(input, snapshot, { now = Date.now(), color = tr
|
|
|
172
194
|
else if (width('● AR · ' + status) <= available) brand = '● AR';
|
|
173
195
|
}
|
|
174
196
|
}
|
|
197
|
+
if (width(plain()) > available && compactFallback && !fallbackPhase && available >= width('fallback')
|
|
198
|
+
&& available < width('fallback: ') + 2) status = 'fallback';
|
|
175
199
|
if (width(plain()) > available) status = shorten(status, Math.max(1, available - width(modelLabel()) - (model ? 3 : 0)));
|
|
176
200
|
const attention = phase === 'error' ? '31' : source === 'fallback' ? '33' : phase === 'cancelled' ? '2' : phase === 'streaming' || phase === 'ready' ? '32' : '36';
|
|
177
201
|
const chunks = [];
|