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.
Files changed (49) hide show
  1. package/.env.example +6 -3
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +57 -0
  4. package/README.md +47 -70
  5. package/SECURITY.md +23 -0
  6. package/SUPPORT.md +18 -0
  7. package/bin/autorouter.mjs +40 -57
  8. package/docs/development.md +48 -2
  9. package/docs/hardware-benchmark.md +29 -0
  10. package/docs/hardware-comparison.md +55 -0
  11. package/docs/hardware-results-16gb.json +4002 -0
  12. package/docs/hardware-results-16gb.md +26 -0
  13. package/docs/hardware-results-64gb.json +4020 -0
  14. package/docs/reference.md +92 -37
  15. package/docs/releasing.md +79 -37
  16. package/docs/router-performance.json +1697 -0
  17. package/docs/router-performance.md +50 -0
  18. package/docs/status-performance.json +363 -0
  19. package/docs/status-performance.md +44 -0
  20. package/docs/subscription-integration.md +27 -0
  21. package/package.json +66 -10
  22. package/src/auto-routing.mjs +184 -24
  23. package/src/bounded-json.mjs +57 -0
  24. package/src/cli-help.mjs +90 -0
  25. package/src/config-command.mjs +158 -0
  26. package/src/config.mjs +53 -28
  27. package/src/contracts.mjs +123 -0
  28. package/src/evaluation-report.mjs +114 -0
  29. package/src/keychain.mjs +58 -0
  30. package/src/local-diagnostic.mjs +191 -0
  31. package/src/model-catalog.mjs +96 -0
  32. package/src/model-request.mjs +6 -7
  33. package/src/ollama-evaluator.mjs +9 -27
  34. package/src/onboarding.mjs +130 -26
  35. package/src/prompt-state.mjs +22 -7
  36. package/src/redaction.mjs +97 -0
  37. package/src/request-validation.mjs +54 -0
  38. package/src/response-observer.mjs +126 -18
  39. package/src/router.mjs +151 -61
  40. package/src/savings.mjs +74 -16
  41. package/src/server.mjs +79 -12
  42. package/src/session-history.mjs +262 -0
  43. package/src/session-log.mjs +9 -58
  44. package/src/status-state.mjs +110 -62
  45. package/src/statusline.mjs +57 -27
  46. package/src/telemetry-event.mjs +200 -0
  47. package/src/token-counter.mjs +3 -1
  48. package/src/turn-state.mjs +132 -0
  49. package/src/user-config.mjs +81 -10
@@ -2,64 +2,14 @@ import { constants } from 'node:fs';
2
2
  import fs from 'node:fs/promises';
3
3
  import { createHash, randomBytes } from 'node:crypto';
4
4
  import { join, resolve } from 'node:path';
5
+ import { normalizeSessionRecord } from './telemetry-event.mjs';
5
6
 
6
7
  const MAX_PENDING_BYTES = 1024 * 1024;
7
8
  const MAX_SESSIONS = 128;
8
9
  const WARNING = 'AutoRouter session logging disabled.';
9
- const SOURCES = new Set(['jev', 'ollama', 'cache', 'fallback', 'passthrough']);
10
- const ERRORS = new Set(['timeout', 'http_error', 'invalid_response', 'network_error']);
11
- const identifier = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,200}$/.test(value) ? value : undefined;
12
- const model = value => typeof value === 'string' && /^[A-Za-z0-9_.:/-]{1,120}$/.test(value) ? value : undefined;
13
- const code = value => typeof value === 'string' && /^[a-z][a-z0-9_]{0,79}$/.test(value) ? value : undefined;
14
-
15
- function excerpt(value) {
16
- if (typeof value !== 'string') return { text: '', truncated: false };
17
- let text = '', count = 0;
18
- for (const character of value) {
19
- if (count++ === 500) return { text: text.toWellFormed(), truncated: true };
20
- text += character;
21
- }
22
- return { text: text.toWellFormed(), truncated: false };
23
- }
24
-
25
- function normalize(entry) {
26
- if (!entry || typeof entry !== 'object' || Array.isArray(entry) || entry.event !== 'decision') return;
27
- const requestId = identifier(entry.request_id);
28
- const selectedModel = model(entry.selected_model);
29
- const requestedModel = model(entry.requested_model);
30
- if (!requestId || !selectedModel || !requestedModel) return;
31
- const anonymous = entry.session_id === undefined || entry.session_id === null || entry.session_id === '';
32
- const sessionId = anonymous ? undefined : identifier(entry.session_id);
33
- // An invalid explicit identity must not mix records into an anonymous file.
34
- if (!anonymous && !sessionId) return;
35
- const requestClass = code(entry.request_class);
36
- const foreground = entry.request_class === undefined || entry.request_class === null || entry.request_class === '' || entry.request_class === 'main';
37
- const prompt = foreground ? excerpt(entry.prompt_excerpt) : { text: '', truncated: false };
38
- const timestamp = typeof entry.timestamp === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(entry.timestamp)
39
- && Number.isFinite(Date.parse(entry.timestamp)) ? entry.timestamp : new Date().toISOString();
40
- const row = { schema_version: 1, event: 'decision', timestamp, request_id: requestId,
41
- ...(sessionId ? { session_id: sessionId } : {}),
42
- prompt_excerpt: prompt.text, prompt_truncated: prompt.truncated || (foreground && entry.prompt_truncated === true),
43
- requested_model: requestedModel, selected_model: selectedModel };
44
- for (const field of ['agent_id', 'prompt_id']) {
45
- const value = identifier(entry[field]);
46
- if (value) row[field] = value;
47
- }
48
- if (requestClass) row.request_class = requestClass;
49
- if (typeof entry.decision_latency_ms === 'number' && Number.isFinite(entry.decision_latency_ms)
50
- && entry.decision_latency_ms >= 0) row.decision_latency_ms = entry.decision_latency_ms;
51
- if (SOURCES.has(entry.source)) row.source = entry.source;
52
- const reason = code(entry.reason);
53
- if (reason) row.reason = reason;
54
- if (['jev', 'ollama'].includes(entry.evaluator)) row.evaluator = entry.evaluator;
55
- if (['haiku', 'sonnet', 'opus'].includes(entry.classified_tier)) row.classified_tier = entry.classified_tier;
56
- if (ERRORS.has(entry.classifier_error)) row.classifier_error = entry.classifier_error;
57
- return { sessionKey: sessionId ? `session:${sessionId}` : 'anonymous', row };
58
- }
59
-
60
10
  // Inference never waits on this writer. Each launch creates new files; only
61
11
  // accepted, bounded JSON lines are retained until the serialized writer drains.
62
- export async function createSessionLog(directory, { warn = () => {} } = {}) {
12
+ export async function createSessionLog(directory, { includePrompts = true, warn = () => {} } = {}) {
63
13
  let accepting = true, failed = false, warned = false, pendingBytes = 0;
64
14
  let root, directoryIdentity, pump, closePromise;
65
15
  const sessions = new Map(), queue = [];
@@ -133,19 +83,20 @@ export async function createSessionLog(directory, { warn = () => {} } = {}) {
133
83
  function record(entry) {
134
84
  if (!accepting) return false;
135
85
  try {
136
- const normalized = normalize(entry);
137
- if (!normalized) return false;
138
- const line = `${JSON.stringify(normalized.row)}\n`;
86
+ const row = normalizeSessionRecord(entry, { includePrompts });
87
+ if (!row) return false;
88
+ const sessionKey = row.session_id ? `session:${row.session_id}` : 'anonymous';
89
+ const line = `${JSON.stringify(row)}\n`;
139
90
  const bytes = Buffer.byteLength(line);
140
- let session = sessions.get(normalized.sessionKey);
91
+ let session = sessions.get(sessionKey);
141
92
  if (pendingBytes + bytes > MAX_PENDING_BYTES || (!session && sessions.size >= MAX_SESSIONS)) {
142
93
  disable();
143
94
  return false;
144
95
  }
145
96
  if (!session) {
146
- const sessionHash = createHash('sha256').update(normalized.sessionKey).digest('hex');
97
+ const sessionHash = createHash('sha256').update(sessionKey).digest('hex');
147
98
  session = { path: join(root, `autorouter-session-${launch}-${sessionHash}.jsonl`) };
148
- sessions.set(normalized.sessionKey, session);
99
+ sessions.set(sessionKey, session);
149
100
  }
150
101
  queue.push({ session, line, bytes });
151
102
  pendingBytes += bytes;
@@ -1,18 +1,12 @@
1
- import { closeSync, chmodSync, fchmodSync, mkdtempSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs';
1
+ import * as fileSystem from 'node:fs/promises';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { createSavingsTracker } from './savings.mjs';
6
+ import { normalizeTelemetryEvent } from './telemetry-event.mjs';
6
7
 
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
8
  const tokenCount = value => Number.isSafeInteger(value) && value >= 0;
9
+ const TIMING_FIELDS = ['evaluation_latency_ms', 'routing_latency_ms', 'decision_latency_ms', 'latency_ms', 'first_response_ms', 'total_latency_ms'];
16
10
 
17
11
  function contextUsage(usage, model) {
18
12
  if (!model || !usage || typeof usage !== 'object' || Array.isArray(usage) || usage.pricing_unsupported === true) return;
@@ -32,48 +26,82 @@ function contextUsage(usage, model) {
32
26
  // All telemetry is best-effort. Never let filesystem or malformed-event
33
27
  // failures interrupt routing, and never persist request bodies or raw errors.
34
28
  export function createStatusState(options = {}) {
35
- let privateDirectory, path = null, pending, heartbeat, closed = false;
29
+ let privateDirectory, snapshotPath, path = null, pending, heartbeat, closing = false, disabled = false;
30
+ let initialized = false, dirty = false, writing, closingPromise;
36
31
  const sessions = new Map();
37
32
  const savings = createSavingsTracker({ baselineModel: options?.baselineModel });
33
+ let io = fileSystem;
38
34
 
39
- function flush() {
40
- if (closed) return;
41
- if (pending) { clearImmediate(pending); pending = undefined; }
42
- if (!path) return;
43
- let temporary, fd;
35
+ async function writeSnapshot() {
36
+ let temporary, handle;
44
37
  try {
45
38
  const snapshot = { version: 1, pid: process.pid, heartbeat_at: Date.now(), sessions: Object.fromEntries(sessions), savings: savings.snapshot() };
39
+ // Capture only one bounded snapshot. Events arriving during I/O merely
40
+ // mark the latest in-memory state dirty; they never queue more copies.
41
+ const serialized = JSON.stringify(snapshot);
46
42
  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);
43
+ handle = await io.open(temporary, 'wx', 0o600);
44
+ await handle.chmod(0o600);
45
+ await handle.writeFile(serialized);
46
+ await handle.close(); handle = undefined;
47
+ if (disabled) return false;
48
+ await io.rename(temporary, snapshotPath);
49
+ temporary = undefined;
50
+ return true;
52
51
  } catch {
53
- if (fd !== undefined) { try { closeSync(fd); } catch {} }
54
- if (temporary) { try { rmSync(temporary, { force: true }); } catch {} }
52
+ return false;
53
+ } finally {
54
+ if (handle) { try { await handle.close(); } catch {} }
55
+ if (temporary) { try { await io.rm(temporary, { force: true }); } catch {} }
55
56
  }
56
57
  }
57
58
 
59
+ function drain() {
60
+ if (writing) return writing;
61
+ if (!initialized || disabled || !dirty) return Promise.resolve();
62
+ writing = (async () => {
63
+ while (dirty && !disabled) {
64
+ dirty = false;
65
+ await writeSnapshot();
66
+ }
67
+ })().finally(() => { writing = undefined; });
68
+ return writing;
69
+ }
70
+
71
+ function flush() {
72
+ if (closing) return closingPromise ?? Promise.resolve();
73
+ if (disabled) return Promise.resolve();
74
+ dirty = true;
75
+ if (pending) { clearImmediate(pending); pending = undefined; }
76
+ // A caller may await durability, but update() never waits for this work.
77
+ return initialization.then(drain).catch(() => {});
78
+ }
79
+
58
80
  function schedule() {
59
- if (closed || pending || !path) return;
60
- try { pending = setImmediate(flush); pending.unref?.(); } catch {}
81
+ if (closing || disabled) return;
82
+ dirty = true;
83
+ if (pending || !initialized) return;
84
+ try {
85
+ pending = setImmediate(() => { pending = undefined; void drain(); });
86
+ pending.unref?.();
87
+ } catch {}
61
88
  }
62
89
 
63
90
  function update(event) {
64
- if (closed) return;
91
+ if (closing || disabled) return;
65
92
  try {
66
- if (!event || !EVENTS.has(event.event)) return;
93
+ event = normalizeTelemetryEvent(event);
94
+ if (!event) return;
67
95
  // Account for all session calls, including parallel agents and auxiliary
68
96
  // requests, independently of which foreground request owns the model UI.
69
97
  savings.update(event);
70
98
  schedule();
71
99
  if ((event.agent_id != null && event.agent_id !== '')
72
100
  || (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;
101
+ // The normalizer rejects invalid explicit identities; absent IDs remain
102
+ // the anonymous session, and future non-main classes stay isolated.
103
+ const sessionId = event.session_id ?? '';
104
+ const requestId = event.request_id;
77
105
  const previous = sessions.get(sessionId);
78
106
  if (event.event === 'request_start') {
79
107
  const state = {
@@ -84,8 +112,8 @@ export function createStatusState(options = {}) {
84
112
  ...((previous?.context_usage ?? previous?.last_context_usage)
85
113
  ? { last_context_usage: previous.context_usage ?? previous.last_context_usage } : {}),
86
114
  };
87
- const requested = modelName(event.requested_model);
88
- const promptId = identifier(event.prompt_id);
115
+ const requested = event.requested_model;
116
+ const promptId = event.prompt_id;
89
117
  if (requested) state.requested_model = requested;
90
118
  if (promptId) state.prompt_id = promptId;
91
119
  sessions.delete(sessionId);
@@ -97,29 +125,26 @@ export function createStatusState(options = {}) {
97
125
  if (!previous || previous.request_id !== requestId) return;
98
126
  const state = previous;
99
127
  state.updated_at = Date.now();
100
- const status = httpStatus(event.status);
128
+ const status = event.status;
101
129
  const terminal = ['error', 'cancelled', 'ready'].includes(state.phase);
130
+ for (const key of TIMING_FIELDS) if (event[key] !== undefined) state[key] = event[key];
131
+ if (event.completion_confirmed !== undefined) state.completion_confirmed = event.completion_confirmed;
102
132
  switch (event.event) {
103
133
  case 'route': {
104
- const fields = { requested_model: modelName(event.requested_model), selected_model: modelName(event.model),
105
- source: ['jev', 'ollama', 'cache', 'fallback', 'passthrough'].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) };
134
+ const fields = { requested_model: event.requested_model, selected_model: event.selected_model ?? event.model };
135
+ for (const key of ['source', 'evaluator', 'reason', 'compatibility_reason', 'continuity_state',
136
+ 'classified_tier', 'context_check', 'counted_input_tokens', 'classifier_error', 'classifier_status']) fields[key] = event[key];
112
137
  for (const [key, value] of Object.entries(fields)) if (value !== undefined) state[key] = value;
113
138
  if (!terminal) state.phase = 'connecting';
114
139
  break;
115
140
  }
116
141
  case 'upstream_response':
117
142
  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'; }
143
+ if (status >= 400) { delete state.context_usage; state.phase = 'error'; state.error_type = event.error_type ?? 'http_error'; }
119
144
  else if (!terminal) state.phase = 'streaming';
120
145
  break;
121
146
  case 'upstream_model': {
122
- const model = modelName(event.model);
147
+ const model = event.confirmed_model ?? event.model;
123
148
  if (model) { state.actual_model = model; state.last_model = model; }
124
149
  if (!terminal) state.phase = 'streaming';
125
150
  break;
@@ -134,7 +159,7 @@ export function createStatusState(options = {}) {
134
159
  case 'request_error':
135
160
  delete state.context_usage;
136
161
  state.phase = 'error';
137
- state.error_type = UPSTREAM_ERRORS.has(event.error_type) ? event.error_type : (event.event === 'upstream_error' ? 'unknown_error' : 'request_error');
162
+ state.error_type = event.error_type ?? (event.event === 'upstream_error' ? 'unknown_error' : 'request_error');
138
163
  if (status !== undefined) state.status = status;
139
164
  break;
140
165
  case 'request_complete':
@@ -150,25 +175,48 @@ export function createStatusState(options = {}) {
150
175
  }
151
176
 
152
177
  function close() {
153
- if (closed) return;
154
- closed = true;
178
+ if (closingPromise) return closingPromise;
179
+ closing = true;
155
180
  try { clearImmediate(pending); clearInterval(heartbeat); } catch {}
156
- sessions.clear();
157
- savings.clear();
158
- if (privateDirectory) { try { rmSync(privateDirectory, { recursive: true, force: true }); } catch {} }
181
+ closingPromise = (async () => {
182
+ // An accepted write must finish before removing its directory. No
183
+ // detached filesystem promise may recreate a file after shutdown.
184
+ await initialization;
185
+ await drain();
186
+ if (privateDirectory) { try { await io.rm(privateDirectory, { recursive: true, force: true }); } catch {} }
187
+ sessions.clear();
188
+ savings.clear();
189
+ })().catch(() => {});
190
+ return closingPromise;
159
191
  }
160
192
 
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 };
193
+ const initialization = (async () => {
194
+ try {
195
+ io = options?.fileSystem ?? fileSystem;
196
+ const directory = options?.directory ?? tmpdir();
197
+ privateDirectory = await io.mkdtemp(join(directory, 'autorouter-status-'));
198
+ await io.chmod(privateDirectory, 0o700);
199
+ if (disabled) return;
200
+ snapshotPath = join(privateDirectory, 'state.json');
201
+ dirty = false;
202
+ if (!await writeSnapshot() || disabled) { disabled = true; return; }
203
+ initialized = true;
204
+ path = snapshotPath;
205
+ if (!closing) {
206
+ heartbeat = setInterval(schedule, 5000);
207
+ heartbeat.unref();
208
+ if (dirty) schedule();
209
+ }
210
+ } catch { disabled = true; }
211
+ finally {
212
+ if (disabled && privateDirectory) { try { await io.rm(privateDirectory, { recursive: true, force: true }); } catch {} }
213
+ }
214
+ })();
215
+ // Optional UI storage must not hold up Claude startup indefinitely. The
216
+ // underlying operation still belongs to close(), even after this deadline.
217
+ let readinessTimer;
218
+ const ready = Promise.race([initialization, new Promise(resolve => {
219
+ readinessTimer = setTimeout(() => { disabled = true; path = null; resolve(); }, 1000);
220
+ })]).finally(() => clearTimeout(readinessTimer));
221
+ return { get path() { return path; }, ready, update, flush, close };
174
222
  }
@@ -1,15 +1,26 @@
1
+ import { modelContextWindow } from './model-catalog.mjs';
1
2
  const PHASES = new Set(['routing', 'connecting', 'streaming', 'ready', 'error', 'cancelled']);
2
3
  const REASONS = {
3
4
  tool_turn_pinned: 'turn pinned', prompt_turn_pinned: 'prompt pinned', goal_turn_pinned: 'goal pinned',
4
- thinking_history: 'thinking pinned', unknown_continuation: 'continuation pinned',
5
+ thinking_history: 'thinking pinned', unknown_continuation: 'continuity unknown',
5
6
  mid_conversation_system: 'system features', requires_sonnet_capabilities: 'capability guard',
6
- model_specific_features: 'model features', large_or_multimodal_request: 'large request',
7
+ model_specific_features: 'model features', model_incompatible: 'model guard', large_or_multimodal_request: 'large request',
7
8
  context_capacity: 'large context',
8
9
  internal_request: 'internal request', unknown_model: 'custom model', low_confidence: 'low confidence',
9
- auto_mode_floor: 'Auto mode floor', auto_mode_safeguards: 'Auto safety', auto_mode_incompatible: 'Auto model guard',
10
+ auto_mode_floor: 'Auto floor from Haiku', auto_mode_safeguards: 'Auto safety', auto_mode_incompatible: 'Auto model guard',
11
+ };
12
+ const COMPATIBILITY_REASONS = {
13
+ unknown_model: 'unknown model', invalid_request_shape: 'request shape', auto_model: 'Auto support',
14
+ request_extension: 'request extension', safeguards: 'safety review', output_limit: 'output limit', speed: 'speed',
15
+ execution_facility: 'execution features', tool_type: 'tool type', system_message: 'system messages',
16
+ message_effort: 'message effort', inline_tool: 'inline tools', content_extension: 'content extension',
17
+ assistant_prefill: 'prefill', tool_choice: 'tool choice', forced_tool_choice: 'tool choice',
18
+ context_management: 'context edits', thinking_mode: 'thinking mode', thinking_extension: 'thinking fields',
19
+ thinking_effort: 'thinking effort', output_extension: 'output fields', effort: 'effort', task_budget: 'task budget', sampling: 'sampling',
10
20
  };
11
21
  const CLASSIFIER_ERRORS = {
12
22
  timeout: 'timeout', http_error: 'HTTP error', invalid_response: 'invalid response', network_error: 'network error',
23
+ capacity_exhausted: 'evaluator busy',
13
24
  };
14
25
  const clean = (value, limit = 64) => typeof value === 'string' ? value
15
26
  .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
@@ -30,14 +41,6 @@ function modelName(value) {
30
41
  return match ? `${match[1][0].toUpperCase()}${match[1].slice(1).toLowerCase()} ${match[2]}${match[3] ? '.' + match[3] : ''}` : name;
31
42
  }
32
43
 
33
- // Only unambiguous native windows are listed. Custom IDs and models whose
34
- // subscription window depends on an opt-in cannot safely imply a capacity.
35
- const CONTEXT_WINDOWS = new Map([
36
- ...['claude-haiku-4-5', 'claude-haiku-4-5-20251001', 'claude-sonnet-4-5', 'claude-sonnet-4-5-20250929',
37
- 'claude-opus-4-5', 'claude-opus-4-5-20251101'].map(model => [model, 200000]),
38
- ...['claude-sonnet-5', 'claude-sonnet-5-5', 'claude-opus-4-7', 'claude-opus-4-8',
39
- 'claude-opus-5', 'claude-opus-5-5'].map(model => [model, 1000000]),
40
- ]);
41
44
  const capacityLabel = value => value % 1000000 === 0 ? `${value / 1000000}M` : value % 1000 === 0 ? `${value / 1000}K` : String(value);
42
45
 
43
46
  function contextLabel(input, state) {
@@ -55,7 +58,7 @@ function contextLabel(input, state) {
55
58
  const historical = !current && ['routing', 'connecting', 'streaming', 'error', 'cancelled'].includes(state?.phase)
56
59
  && object(state.last_context_usage) ? state.last_context_usage : undefined;
57
60
  const usage = current ?? historical;
58
- const capacity = CONTEXT_WINDOWS.get(usage?.model);
61
+ const capacity = modelContextWindow(usage?.model);
59
62
  if (!capacity || !Number.isSafeInteger(usage.input_tokens) || usage.input_tokens < 0) return cli;
60
63
  const api = `${historical ? 'last ' : ''}API ctx ${Math.round(usage.input_tokens * 100 / capacity)}%/${capacityLabel(capacity)}`;
61
64
  // Keep the client limit visible when it differs: the API window does not
@@ -67,7 +70,8 @@ function savingsLabels(entry) {
67
70
  if (!object(entry)) return;
68
71
  const count = value => Number.isSafeInteger(value) && value >= 0;
69
72
  const dollars = value => typeof value === 'number' && Number.isFinite(value) && value >= 0;
70
- const unavailable = { full: 'savings unavailable', compact: 'savings unavailable', color: '33' };
73
+ const unpriced = count(entry.unpriced_requests) && entry.unpriced_requests > 0 ? ` · unpriced ${entry.unpriced_requests}` : '';
74
+ const unavailable = { full: `savings unavailable${unpriced}`, compact: 'savings unavailable', color: '33' };
71
75
  if (!count(entry.requests) || !count(entry.unpriced_requests)) return unavailable;
72
76
  if (entry.requests === 0) return entry.unpriced_requests > 0 ? unavailable : undefined;
73
77
  if (!dollars(entry.actual_usd) || !dollars(entry.baseline_usd)
@@ -81,7 +85,7 @@ function savingsLabels(entry) {
81
85
  const label = `est ${extra ? 'extra' : 'saved'}`;
82
86
  const ending = ` vs Opus${partial ? ' partial' : ''}`;
83
87
  return {
84
- full: `${label} ${money}${percent ? ` (${percent})` : ''}${ending}`,
88
+ full: `${label} ${money}${percent ? ` (${percent})` : ''}${ending}${unpriced}`,
85
89
  compact: percent ? `${label} ${percent}${ending}` : `${label} ${money}${ending}`,
86
90
  color: extra || partial ? '33' : '32',
87
91
  };
@@ -108,7 +112,10 @@ export function renderStatusLine(input, snapshot, { now = Date.now(), color = tr
108
112
  const savings = savingsLabels(savingsEntry);
109
113
  const state = object(candidate) && PHASES.has(candidate.phase) ? candidate : undefined;
110
114
  const phase = state?.phase;
111
- let status = phase ?? 'awaiting request';
115
+ // Receiving a model identifier proves who served some output, not that the
116
+ // protocol completed. Observation limits can also leave completion unknown.
117
+ const completionUnknown = phase === 'ready' && state?.completion_confirmed === false;
118
+ let status = completionUnknown ? 'completion unknown' : phase ?? 'awaiting request';
112
119
  let model = '';
113
120
  let prefix = '';
114
121
  let suffix = '';
@@ -121,9 +128,11 @@ export function renderStatusLine(input, snapshot, { now = Date.now(), color = tr
121
128
  else if (phase === 'streaming' && selected) { model = selected; suffix = ' unconfirmed'; }
122
129
  else if (phase === 'ready' && actual) { model = actual; prefix = 'last '; confirmed = true; }
123
130
  else if (phase === 'ready' && selected) { model = selected; suffix = ' unconfirmed'; }
124
- else if (['ready', 'error', 'cancelled'].includes(phase) && (last || actual)) { model = last || actual; prefix = 'last '; confirmed = true; }
131
+ else if (['error', 'cancelled'].includes(phase) && actual) { model = actual; prefix = 'last '; confirmed = true; }
132
+ else if (['error', 'cancelled'].includes(phase) && selected) { model = selected; suffix = ' selected'; }
133
+ else if (['ready', 'error', 'cancelled'].includes(phase) && last) { model = last; prefix = 'last '; confirmed = true; }
125
134
  else if (last) { model = last; prefix = 'last '; confirmed = true; }
126
- if (phase === 'ready' && !model) status = 'awaiting request';
135
+ if (phase === 'ready' && !model && !completionUnknown) status = 'awaiting request';
127
136
  const errorType = clean(state?.error_type, 32);
128
137
  if (phase === 'error') status = Number.isInteger(state.status) && state.status >= 400 && state.status <= 599
129
138
  ? `error ${state.status}` : errorType ? `error ${errorType}` : 'error';
@@ -135,15 +144,19 @@ export function renderStatusLine(input, snapshot, { now = Date.now(), color = tr
135
144
  let fallbackCause = '';
136
145
  let fallbackPhase = '';
137
146
  let compactFallback = false;
147
+ let compactGuard = false;
138
148
  if (source) {
139
149
  const sourceLabel = source === 'passthrough' ? 'pass-through' : source === 'jev' ? 'Jev' : source === 'ollama' ? 'Ollama'
140
150
  : evaluatorLabel ? `${evaluatorLabel} ${source}` : source;
141
- const timing = Number.isFinite(state.latency_ms) && state.latency_ms >= 0 ? ` ${Math.round(Math.min(state.latency_ms, 999999))}ms` : '';
151
+ const evaluationLatency = state.evaluation_latency_ms ?? state.latency_ms;
152
+ const timing = Number.isFinite(evaluationLatency) && evaluationLatency >= 0 ? ` ${Math.round(Math.min(evaluationLatency, 999999))}ms` : '';
142
153
  const classified = ['haiku', 'sonnet', 'opus'].includes(state.classified_tier) ? state.classified_tier : undefined;
143
154
  const chosenFamily = /^claude-(haiku|sonnet|opus)-/.exec(state.selected_model ?? '')?.[1];
144
155
  const override = source !== 'fallback' && classified && chosenFamily && classified !== chosenFamily
145
156
  ? `→${classified[0].toUpperCase()}${classified.slice(1)}` : '';
146
157
  details.push(`${sourceLabel}${override}${timing}`);
158
+ if (Number.isFinite(state.routing_latency_ms) && state.routing_latency_ms >= 0
159
+ && state.evaluation_latency_ms !== undefined) details.push(`route ${Math.round(Math.min(state.routing_latency_ms, 999999))}ms`);
147
160
  }
148
161
  if (source === 'fallback') {
149
162
  // Snapshots normally contain allowlisted categories, but the renderer also
@@ -153,8 +166,16 @@ export function renderStatusLine(input, snapshot, { now = Date.now(), color = tr
153
166
  && state.classifier_status >= 100 && state.classifier_status <= 599) fallbackCause = `HTTP ${state.classifier_status}`;
154
167
  if (fallbackCause) details.push(fallbackCause);
155
168
  }
156
- if (Object.hasOwn(REASONS, state?.reason)) details.push(state.reason === 'context_capacity' && state.context_check === 'count_unavailable'
157
- ? 'size unverified' : REASONS[state.reason]);
169
+ let guard = Object.hasOwn(REASONS, state?.reason) ? state.reason === 'context_capacity' && state.context_check === 'count_unavailable'
170
+ ? 'size unverified' : REASONS[state.reason] : '';
171
+ if (state?.continuity_state === 'unknown') guard = 'continuity unknown';
172
+ else if (state?.continuity_state === 'capacity_exhausted') guard = 'continuity capacity full';
173
+ else if (['model_incompatible', 'auto_mode_incompatible'].includes(state?.reason)
174
+ && Object.hasOwn(COMPATIBILITY_REASONS, state.compatibility_reason)) guard = `${guard}: ${COMPATIBILITY_REASONS[state.compatibility_reason]}`;
175
+ const shortGuard = state?.continuity_state === 'capacity_exhausted' ? 'state full'
176
+ : guard === 'Auto floor from Haiku' ? 'Auto floor'
177
+ : guard.includes(': ') ? REASONS[state.reason] : guard;
178
+ if (guard) details.push(guard);
158
179
  if (phase === 'error' && errorType && !status.includes(errorType)) details.push(errorType);
159
180
  let context = contextLabel(input, state);
160
181
  let detail = details.join(' · ');
@@ -163,10 +184,9 @@ export function renderStatusLine(input, snapshot, { now = Date.now(), color = tr
163
184
  const modelLabel = () => model ? prefix + model + suffix : '';
164
185
  const plain = () => [brand, modelLabel(), status, detail, saving, context].filter(Boolean).join(' · ');
165
186
  if (width(plain()) > available) context = '';
166
- // Routine routing details can yield to the estimate, while errors and
167
- // fallback explanations have priority over savings. Never shorten away
168
- // "est" or "partial": hide the whole metric when its qualifiers cannot fit.
169
- if (width(plain()) > available && source !== 'fallback' && phase !== 'error') detail = '';
187
+ // Preserve why this model was chosen before estimates, timing, or context.
188
+ // Never shorten away "est" or "partial": hide an estimate as one unit.
189
+ if (width(plain()) > available && source !== 'fallback' && phase !== 'error') detail = guard;
170
190
  if (width(plain()) > available && saving) saving = savings.compact;
171
191
  if (width(plain()) > available) saving = '';
172
192
  if (width(plain()) > available && source === 'fallback') {
@@ -174,18 +194,28 @@ export function renderStatusLine(input, snapshot, { now = Date.now(), color = tr
174
194
  // space is tight, retain that cause instead of an ordinary "ready" phase,
175
195
  // evaluator timing, or the guard details that followed the fallback.
176
196
  compactFallback = true;
177
- fallbackPhase = ['error', 'cancelled'].includes(phase) ? status : '';
197
+ fallbackPhase = completionUnknown || ['error', 'cancelled'].includes(phase) ? status : '';
178
198
  status = [fallbackPhase, `${evaluatorLabel ? `${evaluatorLabel} ` : ''}fallback${fallbackCause ? `: ${fallbackCause}` : ''}`].filter(Boolean).join(' · ');
179
199
  detail = '';
180
200
  }
201
+ if (width(plain()) > available && source !== 'fallback' && guard) {
202
+ compactGuard = true;
203
+ status = [completionUnknown || ['error', 'cancelled'].includes(phase) ? status : '', shortGuard].filter(Boolean).join(' · ');
204
+ detail = '';
205
+ }
181
206
  if (width(plain()) > available) detail = '';
182
207
  if (width(plain()) > available) brand = '● AR';
183
208
  if (width(plain()) > available) brand = '';
209
+ if (width(plain()) > available && completionUnknown) {
210
+ // The response qualifier takes priority over an earlier routing decision.
211
+ // Keep the observed/selected model when both it and the qualifier can fit.
212
+ status = 'completion unknown'; detail = ''; compactFallback = false;
213
+ }
184
214
  if (width(plain()) > available && compactFallback) {
185
215
  status = [fallbackPhase, `fallback${fallbackCause ? `: ${fallbackCause}` : ''}`].filter(Boolean).join(' · ');
186
216
  }
187
217
  if (width(plain()) > available && model) {
188
- if (phase === 'streaming' && !compactFallback) status = 'stream';
218
+ if (phase === 'streaming' && !compactFallback && !compactGuard) status = 'stream';
189
219
  const room = available - width(prefix + suffix + status) - 3;
190
220
  if (room >= 1) model = shorten(model, room);
191
221
  else {
@@ -199,7 +229,7 @@ export function renderStatusLine(input, snapshot, { now = Date.now(), color = tr
199
229
  if (width(plain()) > available && compactFallback && !fallbackPhase && available >= width('fallback')
200
230
  && available < width('fallback: ') + 2) status = 'fallback';
201
231
  if (width(plain()) > available) status = shorten(status, Math.max(1, available - width(modelLabel()) - (model ? 3 : 0)));
202
- const attention = phase === 'error' ? '31' : source === 'fallback' ? '33' : phase === 'cancelled' ? '2' : phase === 'streaming' || phase === 'ready' ? '32' : '36';
232
+ const attention = phase === 'error' ? '31' : completionUnknown || source === 'fallback' ? '33' : phase === 'cancelled' ? '2' : phase === 'streaming' || phase === 'ready' ? '32' : '36';
203
233
  const chunks = [];
204
234
  if (brand) chunks.push(`${paint('●', attention)} ${paint(brand.slice(2), '1')}`);
205
235
  if (model) chunks.push(paint(modelLabel(), confirmed ? '37' : '33'));