claude-autorouter 0.3.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -3
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +57 -0
- package/README.md +47 -70
- package/SECURITY.md +23 -0
- package/SUPPORT.md +18 -0
- package/bin/autorouter.mjs +40 -57
- package/docs/development.md +48 -2
- package/docs/hardware-benchmark.md +29 -0
- package/docs/hardware-comparison.md +55 -0
- package/docs/hardware-results-16gb.json +4002 -0
- package/docs/hardware-results-16gb.md +26 -0
- package/docs/hardware-results-64gb.json +4020 -0
- package/docs/reference.md +92 -37
- package/docs/releasing.md +79 -37
- package/docs/router-performance.json +1697 -0
- package/docs/router-performance.md +50 -0
- package/docs/status-performance.json +363 -0
- package/docs/status-performance.md +44 -0
- package/docs/subscription-integration.md +27 -0
- package/package.json +66 -10
- package/src/auto-routing.mjs +184 -24
- package/src/bounded-json.mjs +57 -0
- package/src/cli-help.mjs +90 -0
- package/src/config-command.mjs +158 -0
- package/src/config.mjs +53 -28
- package/src/contracts.mjs +123 -0
- package/src/evaluation-report.mjs +114 -0
- package/src/keychain.mjs +58 -0
- package/src/local-diagnostic.mjs +191 -0
- package/src/model-catalog.mjs +96 -0
- package/src/model-request.mjs +6 -7
- package/src/ollama-evaluator.mjs +9 -27
- package/src/onboarding.mjs +130 -26
- package/src/prompt-state.mjs +22 -7
- package/src/redaction.mjs +97 -0
- package/src/request-validation.mjs +54 -0
- package/src/response-observer.mjs +126 -18
- package/src/router.mjs +151 -61
- package/src/savings.mjs +74 -16
- package/src/server.mjs +79 -12
- package/src/session-history.mjs +262 -0
- package/src/session-log.mjs +9 -58
- package/src/status-state.mjs +110 -62
- package/src/statusline.mjs +57 -27
- package/src/telemetry-event.mjs +200 -0
- package/src/token-counter.mjs +3 -1
- package/src/turn-state.mjs +132 -0
- package/src/user-config.mjs +81 -10
package/src/session-log.mjs
CHANGED
|
@@ -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
|
|
137
|
-
if (!
|
|
138
|
-
const
|
|
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(
|
|
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(
|
|
97
|
+
const sessionHash = createHash('sha256').update(sessionKey).digest('hex');
|
|
147
98
|
session = { path: join(root, `autorouter-session-${launch}-${sessionHash}.jsonl`) };
|
|
148
|
-
sessions.set(
|
|
99
|
+
sessions.set(sessionKey, session);
|
|
149
100
|
}
|
|
150
101
|
queue.push({ session, line, bytes });
|
|
151
102
|
pendingBytes += bytes;
|
package/src/status-state.mjs
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
import
|
|
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,
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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 (
|
|
60
|
-
|
|
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 (
|
|
91
|
+
if (closing || disabled) return;
|
|
65
92
|
try {
|
|
66
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 =
|
|
88
|
-
const promptId =
|
|
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 =
|
|
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:
|
|
105
|
-
|
|
106
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
154
|
-
|
|
178
|
+
if (closingPromise) return closingPromise;
|
|
179
|
+
closing = true;
|
|
155
180
|
try { clearImmediate(pending); clearInterval(heartbeat); } catch {}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
}
|
package/src/statusline.mjs
CHANGED
|
@@ -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: '
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 (['
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
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'));
|