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.
@@ -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 { selectOllamaModel } from './ollama-models.mjs';
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, totalMemory,
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-preset compact|quality|auto] [--ollama-model TAG] [--pull] [--force]');
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' && (explicitPreset || model !== undefined || pull)) throw new Error('Ollama model and download options require --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 = selectOllamaModel({ preset, model: model ?? (explicitPreset ? undefined : env.AUTOROUTER_OLLAMA_MODEL), totalMemory });
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
- write(`Local evaluator model: ${values.AUTOROUTER_OLLAMA_MODEL}.`);
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
@@ -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
- const classifierError = clean(state.classifier_error, 24);
142
- if (classifierError) details.push(classifierError.replaceAll('_', ' '));
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 = [];