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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { redactSensitive } from './redaction.mjs';
|
|
3
|
+
// Shared, bounded telemetry contract. Never copy request bodies, headers, raw
|
|
4
|
+
// errors or arbitrary provider fields into status snapshots or saved history.
|
|
5
|
+
export const TELEMETRY_SCHEMA_VERSION = 2;
|
|
6
|
+
export const UNPRICED_REASONS = Object.freeze(['request_failed', 'request_cancelled', 'request_evicted',
|
|
7
|
+
'unknown_baseline', 'unknown_model', 'missing_model', 'missing_usage', 'invalid_usage',
|
|
8
|
+
'conflicting_usage', 'mixed_models', 'unsupported_pricing', 'invalid_telemetry',
|
|
9
|
+
'unknown_pricing_version', 'unconfirmed_completion', 'incomplete_usage', 'missing_outcome']);
|
|
10
|
+
const EVENTS = new Set(['request_start', 'route', 'upstream_response', 'upstream_model', 'upstream_usage',
|
|
11
|
+
'upstream_error', 'request_complete', 'request_error', 'request_cancelled']);
|
|
12
|
+
const SOURCES = new Set(['jev', 'ollama', 'cache', 'fallback', 'passthrough']);
|
|
13
|
+
const ERRORS = new Set(['invalid_request_error', 'authentication_error', 'billing_error', 'permission_error',
|
|
14
|
+
'not_found_error', 'request_too_large', 'rate_limit_error', 'api_error', 'overloaded_error', 'timeout_error',
|
|
15
|
+
'unknown_error', 'http_error', 'request_error']);
|
|
16
|
+
const CLASSIFIER_ERRORS = new Set(['timeout', 'http_error', 'invalid_response', 'network_error', 'capacity_exhausted']);
|
|
17
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
export const sanitizeIdentifier = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,200}$/.test(value) ? value : undefined;
|
|
19
|
+
export const sanitizeModel = value => typeof value === 'string' && /^[A-Za-z0-9_.:/-]{1,120}$/.test(value) ? value : undefined;
|
|
20
|
+
const code = value => typeof value === 'string' && /^[a-z][a-z0-9_]{0,79}$/.test(value) ? value : undefined;
|
|
21
|
+
const count = value => Number.isSafeInteger(value) && value >= 0;
|
|
22
|
+
const latency = value => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
23
|
+
const httpStatus = value => Number.isInteger(value) && value >= 100 && value <= 599;
|
|
24
|
+
const absent = value => value === undefined || value === null || value === '';
|
|
25
|
+
const assign = (row, field, value) => { if (value !== undefined) row[field] = value; };
|
|
26
|
+
|
|
27
|
+
export function normalizePricingContext(value) {
|
|
28
|
+
if (value === undefined) return;
|
|
29
|
+
if (!object(value)) return { pricing_unsupported: true };
|
|
30
|
+
const row = {};
|
|
31
|
+
for (const [key, allowed] of Object.entries({ speed: ['standard', 'fast'],
|
|
32
|
+
inference_geo: ['global', 'us', 'not_available'], service_tier: ['auto', 'standard_only', 'standard', 'priority', 'batch'] })) {
|
|
33
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
34
|
+
if (allowed.includes(value[key])) row[key] = value[key];
|
|
35
|
+
else row.pricing_unsupported = true;
|
|
36
|
+
}
|
|
37
|
+
for (const key of ['pricing_unsupported', 'unsupported']) {
|
|
38
|
+
if (Object.hasOwn(value, key) && value[key] !== false) row.pricing_unsupported = true;
|
|
39
|
+
}
|
|
40
|
+
return row;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function normalizeUsageTelemetry(value) {
|
|
44
|
+
if (value === undefined) return;
|
|
45
|
+
if (!object(value)) return { pricing_unsupported: true };
|
|
46
|
+
/** @type {Record<string,any>} */
|
|
47
|
+
const row = { ...normalizePricingContext(value) };
|
|
48
|
+
for (const key of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
|
|
49
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
50
|
+
if (count(value[key])) row[key] = value[key];
|
|
51
|
+
else row.pricing_unsupported = true;
|
|
52
|
+
}
|
|
53
|
+
if (Object.hasOwn(value, 'cache_creation')) {
|
|
54
|
+
if (value.cache_creation === null && value.cache_creation_input_tokens === 0) { /* no cache writes */ }
|
|
55
|
+
else if (!object(value.cache_creation)) row.pricing_unsupported = true;
|
|
56
|
+
else {
|
|
57
|
+
row.cache_creation = {};
|
|
58
|
+
for (const key of ['ephemeral_5m_input_tokens', 'ephemeral_1h_input_tokens']) {
|
|
59
|
+
if (!Object.hasOwn(value.cache_creation, key)) continue;
|
|
60
|
+
if (count(value.cache_creation[key])) row.cache_creation[key] = value.cache_creation[key];
|
|
61
|
+
else row.pricing_unsupported = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return row;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function reasons(value) {
|
|
69
|
+
if (!object(value)) return;
|
|
70
|
+
return Object.fromEntries(UNPRICED_REASONS.filter(key => count(value[key])).map(key => [key, value[key]]));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeSavings(value) {
|
|
74
|
+
if (!object(value)) return;
|
|
75
|
+
const row = {};
|
|
76
|
+
assign(row, 'baseline_model', sanitizeModel(value.baseline_model));
|
|
77
|
+
for (const key of ['actual_usd', 'baseline_usd', 'saved_usd', 'percent']) {
|
|
78
|
+
if (typeof value[key] === 'number' && Number.isFinite(value[key]) && Math.abs(value[key]) <= 1e18
|
|
79
|
+
&& (!['actual_usd', 'baseline_usd'].includes(key) || value[key] >= 0)) row[key] = value[key];
|
|
80
|
+
}
|
|
81
|
+
for (const key of ['requests', 'priced_requests', 'unpriced_requests']) if (count(value[key])) row[key] = value[key];
|
|
82
|
+
if (value.partial === true) row.partial = true;
|
|
83
|
+
if (typeof value.pricing_version === 'string' && /^[A-Za-z0-9_.-]{1,40}$/.test(value.pricing_version)) row.pricing_version = value.pricing_version;
|
|
84
|
+
if (typeof value.pricing_date === 'string' && /^\d{4}-\d\d-\d\d$/.test(value.pricing_date)) row.pricing_date = value.pricing_date;
|
|
85
|
+
// Provenance is a maintained public URL, never an arbitrary URL that might
|
|
86
|
+
// contain credentials or a private endpoint.
|
|
87
|
+
if (value.pricing_source === 'https://platform.claude.com/docs/en/about-claude/pricing') row.pricing_source = value.pricing_source;
|
|
88
|
+
assign(row, 'unpriced_reasons', reasons(value.unpriced_reasons));
|
|
89
|
+
return row;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function timestamp(value) {
|
|
93
|
+
return typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value)
|
|
94
|
+
&& Number.isFinite(Date.parse(value)) ? value : new Date().toISOString();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @template {import('./contracts.mjs').LifecycleName|'decision'|'outcome'} T
|
|
99
|
+
* @param {any} entry
|
|
100
|
+
* @param {T} event
|
|
101
|
+
* @returns {(import('./contracts.mjs').TelemetryFields & {event:T,request_id:string})|undefined}
|
|
102
|
+
*/
|
|
103
|
+
function base(entry, event) {
|
|
104
|
+
const requestId = sanitizeIdentifier(entry.request_id);
|
|
105
|
+
if (!requestId) return;
|
|
106
|
+
/** @type {import('./contracts.mjs').TelemetryFields & {event:T,request_id:string}} */
|
|
107
|
+
const row = { schema_version: TELEMETRY_SCHEMA_VERSION, event, timestamp: timestamp(entry.timestamp), request_id: requestId };
|
|
108
|
+
for (const key of ['session_id', 'agent_id', 'prompt_id']) {
|
|
109
|
+
if (absent(entry[key])) continue;
|
|
110
|
+
const value = sanitizeIdentifier(entry[key]);
|
|
111
|
+
// Reject invalid explicit identities instead of merging them into the
|
|
112
|
+
// anonymous session or treating an unknown agent as the foreground UI.
|
|
113
|
+
if (!value) return;
|
|
114
|
+
row[key] = value;
|
|
115
|
+
}
|
|
116
|
+
if (!absent(entry.request_class)) {
|
|
117
|
+
const requestClass = code(entry.request_class);
|
|
118
|
+
if (!requestClass) return;
|
|
119
|
+
row.request_class = requestClass;
|
|
120
|
+
}
|
|
121
|
+
for (const key of ['requested_model', 'selected_model', 'confirmed_model', 'model', 'baseline_model']) assign(row, key, sanitizeModel(entry[key]));
|
|
122
|
+
if (typeof entry.pricing_version === 'string' && /^[A-Za-z0-9_.-]{1,40}$/.test(entry.pricing_version)) row.pricing_version = entry.pricing_version;
|
|
123
|
+
for (const key of ['reason', 'compatibility_reason', 'continuity_state']) assign(row, key, code(entry[key]));
|
|
124
|
+
if (SOURCES.has(entry.source)) row.source = entry.source;
|
|
125
|
+
if (['jev', 'ollama'].includes(entry.evaluator)) row.evaluator = entry.evaluator;
|
|
126
|
+
for (const key of ['tier', 'classified_tier']) if (['haiku', 'sonnet', 'opus'].includes(entry[key])) row[key] = entry[key];
|
|
127
|
+
for (const key of ['latency_ms', 'evaluation_latency_ms', 'routing_latency_ms', 'decision_latency_ms',
|
|
128
|
+
'first_response_ms', 'upstream_latency_ms', 'total_latency_ms']) if (latency(entry[key])) row[key] = entry[key];
|
|
129
|
+
if (CLASSIFIER_ERRORS.has(entry.classifier_error)) row.classifier_error = entry.classifier_error;
|
|
130
|
+
if (httpStatus(entry.classifier_status)) row.classifier_status = entry.classifier_status;
|
|
131
|
+
if (ERRORS.has(entry.error_type)) row.error_type = entry.error_type;
|
|
132
|
+
if (httpStatus(entry.http_status)) row.http_status = entry.http_status;
|
|
133
|
+
if (['within_budget', 'over_budget', 'count_unavailable'].includes(entry.context_check)) row.context_check = entry.context_check;
|
|
134
|
+
if (count(entry.counted_input_tokens)) row.counted_input_tokens = entry.counted_input_tokens;
|
|
135
|
+
if (Array.isArray(entry.model_transitions)) {
|
|
136
|
+
const transitions = entry.model_transitions.slice(0, 16).map(sanitizeModel).filter(Boolean);
|
|
137
|
+
row.model_transitions = transitions;
|
|
138
|
+
if (entry.model_transitions.length > 16 || transitions.length !== entry.model_transitions.length) row.model_transitions_truncated = true;
|
|
139
|
+
} else if (entry.model_transitions !== undefined) row.model_transitions_truncated = true;
|
|
140
|
+
if (entry.model_transitions_truncated === true) row.model_transitions_truncated = true;
|
|
141
|
+
assign(row, 'usage', normalizeUsageTelemetry(entry.usage));
|
|
142
|
+
assign(row, 'pricing_context', normalizePricingContext(entry.pricing_context));
|
|
143
|
+
for (const key of ['usage_complete', 'pricing_eligible', 'completion_confirmed']) if (typeof entry[key] === 'boolean') row[key] = entry[key];
|
|
144
|
+
if (UNPRICED_REASONS.includes(entry.unpriced_reason)) row.unpriced_reason = entry.unpriced_reason;
|
|
145
|
+
assign(row, 'savings', normalizeSavings(entry.savings));
|
|
146
|
+
assign(row, 'savings_coverage', normalizeSavings(entry.savings_coverage));
|
|
147
|
+
return row;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @param {any} entry @returns {import('./contracts.mjs').LifecycleEvent|undefined} */
|
|
151
|
+
export function normalizeTelemetryEvent(entry) {
|
|
152
|
+
try {
|
|
153
|
+
if (!object(entry)) return;
|
|
154
|
+
const event = entry.event === 'error' ? 'request_error' : entry.event === 'cancelled' ? 'request_cancelled' : entry.event;
|
|
155
|
+
if (!EVENTS.has(event)) return;
|
|
156
|
+
const row = base(entry, event);
|
|
157
|
+
if (row && httpStatus(entry.status)) row.status = entry.status;
|
|
158
|
+
return row;
|
|
159
|
+
} catch { return; }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function excerpt(value) {
|
|
163
|
+
let text = '', length = 0;
|
|
164
|
+
if (typeof value !== 'string') return { text, truncated: false };
|
|
165
|
+
// Defense in depth, and it also covers records written before redaction
|
|
166
|
+
// existed when they are read back. Already-redacted text is unchanged.
|
|
167
|
+
value = redactSensitive(value);
|
|
168
|
+
for (const character of value) {
|
|
169
|
+
if (length++ === 500) return { text: text.toWellFormed(), truncated: true };
|
|
170
|
+
text += character;
|
|
171
|
+
}
|
|
172
|
+
return { text: text.toWellFormed(), truncated: false };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {any} entry
|
|
177
|
+
* @param {{includePrompts?:boolean}} [options]
|
|
178
|
+
* @returns {import('./contracts.mjs').SessionRecord|undefined}
|
|
179
|
+
*/
|
|
180
|
+
export function normalizeSessionRecord(entry, { includePrompts = true } = {}) {
|
|
181
|
+
try {
|
|
182
|
+
if (!object(entry) || !['decision', 'outcome'].includes(entry.event)) return;
|
|
183
|
+
if (entry.schema_version !== undefined && ![1, 2].includes(entry.schema_version)) return;
|
|
184
|
+
const row = base(entry, entry.event);
|
|
185
|
+
if (!row) return;
|
|
186
|
+
if (entry.event === 'decision') {
|
|
187
|
+
if (!row.requested_model || !row.selected_model) return;
|
|
188
|
+
if (includePrompts) {
|
|
189
|
+
const foreground = !row.request_class || row.request_class === 'main';
|
|
190
|
+
const prompt = foreground ? excerpt(entry.prompt_excerpt) : { text: '', truncated: false };
|
|
191
|
+
row.prompt_excerpt = prompt.text;
|
|
192
|
+
row.prompt_truncated = prompt.truncated || (foreground && entry.prompt_truncated === true);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
if (!['completed', 'error', 'cancelled'].includes(entry.status)) return;
|
|
196
|
+
row.status = entry.status;
|
|
197
|
+
}
|
|
198
|
+
return row;
|
|
199
|
+
} catch { return; }
|
|
200
|
+
}
|
package/src/token-counter.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { prepareRequest } from './model-request.mjs';
|
|
3
|
+
import { targetCompatibility } from './auto-routing.mjs';
|
|
3
4
|
|
|
4
5
|
// Count-token API fields, including the beta fields used by Claude Code.
|
|
5
6
|
// Keep unknown extensions out of the count path: dropping new input context
|
|
@@ -76,7 +77,8 @@ export function createTokenCounter(config, { fetchImpl = fetch } = {}) {
|
|
|
76
77
|
const cacheTtlMs = config.tokenCountCacheTtlMs ?? 5 * 60 * 1000;
|
|
77
78
|
const cache = new Map();
|
|
78
79
|
return async function countTokens(body, model, { headers = {}, signal, search = '' } = {}) {
|
|
79
|
-
if (signal?.aborted || !canCount(body) || typeof model !== 'string' || !model
|
|
80
|
+
if (signal?.aborted || !canCount(body) || typeof model !== 'string' || !model
|
|
81
|
+
|| !targetCompatibility(body, model, { autoMode: config.clientProfile === 'auto' }).compatible) return undefined;
|
|
80
82
|
let timer;
|
|
81
83
|
let onAbort;
|
|
82
84
|
const controller = new AbortController();
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Active execution state is not an evaluator cache. Pending tools and the
|
|
2
|
+
// current human task survive cache expiry; only retired tasks use the idle TTL.
|
|
3
|
+
export class TurnState {
|
|
4
|
+
constructor({ limit = 1000, idleTtlMs = 30 * 60 * 1000, now = Date.now } = {}) {
|
|
5
|
+
this.limit = limit;
|
|
6
|
+
this.idleTtlMs = idleTtlMs;
|
|
7
|
+
this.now = now;
|
|
8
|
+
this.records = new Set();
|
|
9
|
+
this.aliases = new Map();
|
|
10
|
+
this.attempts = new Map();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
remove(record) {
|
|
14
|
+
for (const key of record.keys) if (this.aliases.get(key) === record) this.aliases.delete(key);
|
|
15
|
+
this.records.delete(record);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get(key) {
|
|
19
|
+
const record = this.aliases.get(key);
|
|
20
|
+
if (!record) return undefined;
|
|
21
|
+
if (!record.active && !record.pending && record.expires <= this.now()) {
|
|
22
|
+
this.remove(record);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
if (this.ambiguous(key)) return undefined;
|
|
26
|
+
return record.pin;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
ambiguous(key) {
|
|
30
|
+
const record = this.aliases.get(key);
|
|
31
|
+
return Boolean(record && record.keys[0] !== key && [...this.records].filter(other =>
|
|
32
|
+
other.active && other.pin && other.scope === record.scope && other.keys.includes(key)).length > 1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
toolOwner(scope, ids) {
|
|
36
|
+
if (!ids.length) return undefined;
|
|
37
|
+
const matches = [...this.records].filter(record => record.scope === scope && record.pin?.confirmed
|
|
38
|
+
&& ids.every(id => record.pin.toolModels?.some(tool => tool.id === id)));
|
|
39
|
+
return matches.length === 1 ? { key: matches[0].keys[0], pin: matches[0].pin }
|
|
40
|
+
: matches.length > 1 ? { ambiguous: true } : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string[]} keys
|
|
45
|
+
* @param {any} pin
|
|
46
|
+
* @param {{scope?:string,requestId?:string,sequence?:number}} [options]
|
|
47
|
+
*/
|
|
48
|
+
select(keys, pin, { scope = '', requestId, sequence = 0 } = {}) {
|
|
49
|
+
// Rejected admission must not supersede an already accepted attempt.
|
|
50
|
+
if (requestId && (this.attempts.size >= this.limit || this.attempts.has(requestId))) return false;
|
|
51
|
+
// An explicit new prompt identity starts a new task even when its text is
|
|
52
|
+
// identical to an older task. Content aliases are lookup fallbacks, never
|
|
53
|
+
// authority to merge two different primary identities.
|
|
54
|
+
let record = this.aliases.get(keys[0]);
|
|
55
|
+
if (!record) {
|
|
56
|
+
for (const old of this.records) {
|
|
57
|
+
if (!old.active && !old.pending && (old.expires <= this.now() || this.records.size >= this.limit)) this.remove(old);
|
|
58
|
+
}
|
|
59
|
+
// Never evict an active task just because other agents fill the cache.
|
|
60
|
+
// The caller reports the capacity limit instead of inventing continuity.
|
|
61
|
+
if (this.records.size >= this.limit) return false;
|
|
62
|
+
record = { keys: [], scope, active: true, pending: 0, sequence, createdSequence: sequence, pin: undefined };
|
|
63
|
+
this.records.add(record);
|
|
64
|
+
}
|
|
65
|
+
record.sequence = Math.max(record.sequence, sequence);
|
|
66
|
+
for (const key of keys) {
|
|
67
|
+
if (record.keys.includes(key)) continue;
|
|
68
|
+
record.keys.push(key);
|
|
69
|
+
// Stage secondary aliases privately until execution succeeds. Otherwise
|
|
70
|
+
// a failed same-content task could destroy an older confirmed lookup.
|
|
71
|
+
if (record.keys.length === 1) this.aliases.set(key, record);
|
|
72
|
+
// Keep the primary identity and bounded recent discovery aliases.
|
|
73
|
+
if (record.keys.length > 8) {
|
|
74
|
+
const [expired] = record.keys.splice(1, 1);
|
|
75
|
+
if (this.aliases.get(expired) === record) this.aliases.delete(expired);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (requestId) {
|
|
79
|
+
record.pending++;
|
|
80
|
+
this.attempts.set(requestId, { record, pin, sequence });
|
|
81
|
+
} else {
|
|
82
|
+
// Embedders that only call route() have selection evidence, not a
|
|
83
|
+
// provider confirmation. The HTTP gateway always uses request IDs.
|
|
84
|
+
this.commit(record, { ...pin, confirmed: false }, sequence);
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
commit(record, pin, sequence) {
|
|
90
|
+
if (sequence !== record.sequence) return;
|
|
91
|
+
record.pin = pin;
|
|
92
|
+
for (const key of record.keys) {
|
|
93
|
+
const current = this.aliases.get(key);
|
|
94
|
+
if (!current || !current.pin || current === record || record.createdSequence >= current.createdSequence) this.aliases.set(key, record);
|
|
95
|
+
}
|
|
96
|
+
// Task age, not the order of later tool requests, determines retirement.
|
|
97
|
+
record.active = Boolean(pin.toolModels?.length) || ![...this.records].some(other =>
|
|
98
|
+
other !== record && other.scope === record.scope && other.pin && other.createdSequence > record.createdSequence);
|
|
99
|
+
if (!record.active) record.expires = this.now() + this.idleTtlMs;
|
|
100
|
+
for (const old of this.records) {
|
|
101
|
+
if (old !== record && old.scope === record.scope && old.createdSequence < record.createdSequence && !old.pending && !old.pin?.toolModels?.length) {
|
|
102
|
+
old.active = false;
|
|
103
|
+
old.expires = this.now() + this.idleTtlMs;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
complete(requestId, evidence) {
|
|
109
|
+
const attempt = this.attempts.get(requestId);
|
|
110
|
+
if (!attempt) return false;
|
|
111
|
+
this.attempts.delete(requestId);
|
|
112
|
+
const { record, pin, sequence } = attempt;
|
|
113
|
+
record.pending--;
|
|
114
|
+
const model = evidence?.continuation_model;
|
|
115
|
+
if (typeof model === 'string' && model && sequence === record.sequence) {
|
|
116
|
+
const toolModels = evidence.tool_uses ?? [];
|
|
117
|
+
// Ambiguous or unbounded tool ownership must not be committed as fact.
|
|
118
|
+
if (Array.isArray(toolModels) && toolModels.length <= 1000
|
|
119
|
+
&& toolModels.every(tool => typeof tool?.id === 'string' && tool.id.length > 0 && tool.id.length <= 256 && tool.model === model)) {
|
|
120
|
+
this.commit(record, { ...pin, model, confirmed: true, toolModels }, sequence);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!record.pin && !record.pending) this.remove(record);
|
|
125
|
+
else if (!record.pending && !record.pin?.toolModels?.length && [...this.records].some(other =>
|
|
126
|
+
other !== record && other.scope === record.scope && other.pin && other.createdSequence > record.createdSequence)) {
|
|
127
|
+
record.active = false;
|
|
128
|
+
record.expires = this.now() + this.idleTtlMs;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/user-config.mjs
CHANGED
|
@@ -2,22 +2,32 @@ import {
|
|
|
2
2
|
closeSync, constants, fchmodSync, fsyncSync, linkSync, lstatSync,
|
|
3
3
|
mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync,
|
|
4
4
|
} from 'node:fs';
|
|
5
|
-
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
8
|
+
import { createKeychain } from './keychain.mjs';
|
|
8
9
|
|
|
9
|
-
const CONFIG_KEYS =
|
|
10
|
-
'AUTOROUTER_AUTH_MODE', 'AUTOROUTER_CLIENT_PROFILE',
|
|
10
|
+
export const CONFIG_KEYS = Object.freeze([
|
|
11
|
+
'AUTOROUTER_AUTH_MODE', 'AUTOROUTER_CLIENT_PROFILE', 'AUTOROUTER_SECRET_STORE',
|
|
11
12
|
'ANTHROPIC_API_KEY', 'TYPESAFE_API_KEY', 'AUTOROUTER_TOKEN',
|
|
12
13
|
'AUTOROUTER_UPSTREAM_URL', 'AUTOROUTER_JEV_URL', 'AUTOROUTER_JEV_MODEL',
|
|
13
14
|
'AUTOROUTER_HAIKU_MODEL', 'AUTOROUTER_SONNET_MODEL', 'AUTOROUTER_OPUS_MODEL',
|
|
14
15
|
'AUTOROUTER_PORT', 'AUTOROUTER_JEV_TIMEOUT_MS', 'AUTOROUTER_TOKEN_COUNT_TIMEOUT_MS',
|
|
15
16
|
'AUTOROUTER_MIN_CONFIDENCE', 'AUTOROUTER_STATUSLINE', 'AUTOROUTER_DEBUG',
|
|
16
|
-
'AUTOROUTER_SESSION_LOG_DIR',
|
|
17
|
+
'AUTOROUTER_SESSION_LOG_DIR', 'AUTOROUTER_SESSION_LOG_MODE',
|
|
17
18
|
'ENABLE_TOOL_SEARCH', 'CLAUDE_CODE_STOP_HOOK_BLOCK_CAP',
|
|
18
19
|
'AUTOROUTER_EVALUATOR', 'AUTOROUTER_OLLAMA_URL', 'AUTOROUTER_OLLAMA_MODEL',
|
|
19
20
|
'AUTOROUTER_OLLAMA_TIMEOUT_MS', 'AUTOROUTER_OLLAMA_KEEP_ALIVE',
|
|
20
21
|
]);
|
|
22
|
+
export const SECRET_CONFIG_KEYS = Object.freeze(['ANTHROPIC_API_KEY', 'TYPESAFE_API_KEY', 'AUTOROUTER_TOKEN']);
|
|
23
|
+
export const SECRET_STORES = Object.freeze(['file', 'keychain']);
|
|
24
|
+
const allowedKeys = new Set(CONFIG_KEYS);
|
|
25
|
+
const defaultKeychain = createKeychain();
|
|
26
|
+
// Items are scoped to one configuration file, so separate configurations
|
|
27
|
+
// (including test fixtures) never share or overwrite each other's secrets.
|
|
28
|
+
const keychainAccount = (path, key) => `${key}:${createHash('sha256').update(path).digest('hex').slice(0, 16)}`;
|
|
29
|
+
const keychainLabel = (path, key) => `AutoRouter ${key} (${path})`;
|
|
30
|
+
const revision = content => createHash('sha256').update(content).digest('hex');
|
|
21
31
|
const SAFE_FS_CODES = new Set([
|
|
22
32
|
'EACCES', 'EPERM', 'ENOENT', 'ENOTDIR', 'EISDIR', 'ENOSPC', 'EROFS',
|
|
23
33
|
'EMFILE', 'ENFILE', 'ELOOP', 'EIO', 'EEXIST',
|
|
@@ -44,7 +54,7 @@ function validate(values) {
|
|
|
44
54
|
}
|
|
45
55
|
const validated = {};
|
|
46
56
|
for (const key of Reflect.ownKeys(values)) {
|
|
47
|
-
if (!
|
|
57
|
+
if (!allowedKeys.has(key)) {
|
|
48
58
|
// An unknown key can itself contain a pasted credential.
|
|
49
59
|
throw configError('AutoRouter configuration contains an unsupported key.');
|
|
50
60
|
}
|
|
@@ -54,6 +64,9 @@ function validate(values) {
|
|
|
54
64
|
}
|
|
55
65
|
validated[key] = descriptor.value;
|
|
56
66
|
}
|
|
67
|
+
if (validated.AUTOROUTER_SECRET_STORE !== undefined && !SECRET_STORES.includes(validated.AUTOROUTER_SECRET_STORE)) {
|
|
68
|
+
throw configError('AUTOROUTER_SECRET_STORE must be file or keychain.');
|
|
69
|
+
}
|
|
57
70
|
return validated;
|
|
58
71
|
}
|
|
59
72
|
|
|
@@ -71,14 +84,20 @@ export function getConfigPath(env = process.env) {
|
|
|
71
84
|
return join(xdg || join(homedir(), '.config'), 'claude-autorouter', 'config.json');
|
|
72
85
|
}
|
|
73
86
|
|
|
74
|
-
|
|
87
|
+
// The saved store setting decides where saved secrets live; the environment
|
|
88
|
+
// only overrides values. With the keychain store, `values` includes secrets
|
|
89
|
+
// read from the keychain so callers can update settings without losing them.
|
|
90
|
+
export function loadUserConfig(env = process.env, { allowMissing = false, readSecrets = true, keychain = defaultKeychain } = {}) {
|
|
75
91
|
const path = getConfigPath(env);
|
|
76
92
|
let content;
|
|
77
93
|
try {
|
|
78
94
|
content = readFileSync(path, 'utf8');
|
|
79
95
|
} catch (error) {
|
|
80
96
|
if (error.code === 'ENOENT') {
|
|
81
|
-
if (env.AUTOROUTER_CONFIG === undefined
|
|
97
|
+
if (env.AUTOROUTER_CONFIG === undefined || allowMissing) {
|
|
98
|
+
return { env: { ...env }, values: {}, path, exists: false, revision: null,
|
|
99
|
+
secretStore: 'file', keychainSecrets: [], unavailableSecrets: [] };
|
|
100
|
+
}
|
|
82
101
|
throw configError('AUTOROUTER_CONFIG points to a missing configuration file.');
|
|
83
102
|
}
|
|
84
103
|
throw filesystemError(error, 'read');
|
|
@@ -87,7 +106,36 @@ export function loadUserConfig(env = process.env) {
|
|
|
87
106
|
try { parsed = JSON.parse(content); }
|
|
88
107
|
catch { throw configError('AutoRouter configuration must contain valid JSON.'); }
|
|
89
108
|
const values = validate(parsed);
|
|
90
|
-
|
|
109
|
+
const secretStore = values.AUTOROUTER_SECRET_STORE ?? 'file';
|
|
110
|
+
const keychainSecrets = [], unavailableSecrets = [];
|
|
111
|
+
if (secretStore === 'keychain' && readSecrets) {
|
|
112
|
+
for (const key of SECRET_CONFIG_KEYS) {
|
|
113
|
+
if (Object.hasOwn(values, key)) continue;
|
|
114
|
+
let value;
|
|
115
|
+
try { value = keychain.read(keychainAccount(path, key)); }
|
|
116
|
+
catch (error) {
|
|
117
|
+
// An environment value can stand in for a locked keychain, such as
|
|
118
|
+
// over SSH. Only a missing required value stops the caller.
|
|
119
|
+
if (env[key] !== undefined) { unavailableSecrets.push(key); continue; }
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
if (value !== undefined) { values[key] = value; keychainSecrets.push(key); }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { env: { ...values, ...env }, values, path, exists: true, revision: revision(content),
|
|
126
|
+
secretStore, keychainSecrets, unavailableSecrets };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Keychain items to delete when saving `nextStore`, given what was loaded.
|
|
130
|
+
// Moving between stores needs every saved secret, so refuse while one could
|
|
131
|
+
// not be read; deleting it unseen would lose it.
|
|
132
|
+
export function keychainRemovals(loaded, nextStore, { replace = false, values = {} } = {}) {
|
|
133
|
+
if (loaded.secretStore !== 'keychain') return [];
|
|
134
|
+
if (nextStore !== 'keychain' && loaded.unavailableSecrets?.length) {
|
|
135
|
+
throw configError('Could not read every saved secret from the macOS Keychain. Unlock the login keychain and retry.');
|
|
136
|
+
}
|
|
137
|
+
if (nextStore !== 'keychain') return [...loaded.keychainSecrets];
|
|
138
|
+
return replace ? loaded.keychainSecrets.filter(key => !Object.hasOwn(values, key)) : [];
|
|
91
139
|
}
|
|
92
140
|
|
|
93
141
|
function existingFile(path) {
|
|
@@ -99,23 +147,42 @@ function existingFile(path) {
|
|
|
99
147
|
return stat;
|
|
100
148
|
}
|
|
101
149
|
|
|
102
|
-
|
|
150
|
+
// With the keychain store, secrets are written to the keychain before the file
|
|
151
|
+
// (an interrupted move leaves both copies, never neither) and stale items are
|
|
152
|
+
// removed only after the file is saved.
|
|
153
|
+
export function saveUserConfig(values, {
|
|
154
|
+
env = process.env, overwrite = false, expectedRevision, removeSecrets = [], keychain = defaultKeychain,
|
|
155
|
+
} = {}) {
|
|
103
156
|
const validated = validate(values);
|
|
157
|
+
const store = validated.AUTOROUTER_SECRET_STORE ?? 'file';
|
|
158
|
+
const keychainKeys = store === 'keychain' ? SECRET_CONFIG_KEYS.filter(key => Object.hasOwn(validated, key)) : [];
|
|
159
|
+
const fileValues = { ...validated };
|
|
160
|
+
for (const key of keychainKeys) delete fileValues[key];
|
|
104
161
|
const path = getConfigPath(env);
|
|
105
162
|
const parent = dirname(path);
|
|
106
163
|
let temporary;
|
|
107
164
|
let descriptor;
|
|
165
|
+
const checkRevision = () => {
|
|
166
|
+
if (expectedRevision === undefined) return;
|
|
167
|
+
const current = existingFile(path) ? revision(readFileSync(path, 'utf8')) : null;
|
|
168
|
+
if (current !== expectedRevision) throw configError('AutoRouter configuration changed while this operation was running. Retry with the current settings.');
|
|
169
|
+
};
|
|
108
170
|
try {
|
|
109
171
|
if (existingFile(path) && !overwrite) {
|
|
110
172
|
throw configError('AutoRouter configuration already exists; use overwrite to replace it.');
|
|
111
173
|
}
|
|
174
|
+
checkRevision();
|
|
175
|
+
if (store === 'keychain' && !keychain.available) {
|
|
176
|
+
throw configError('The macOS Keychain secret store is available only on macOS.');
|
|
177
|
+
}
|
|
178
|
+
for (const key of keychainKeys) keychain.write(keychainAccount(path, key), validated[key], keychainLabel(path, key));
|
|
112
179
|
// mkdir leaves existing directory permissions unchanged. Only directories
|
|
113
180
|
// created for this configuration receive the private creation mode.
|
|
114
181
|
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
115
182
|
temporary = join(parent, `.${basename(path)}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`);
|
|
116
183
|
descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
117
184
|
fchmodSync(descriptor, 0o600);
|
|
118
|
-
writeFileSync(descriptor, `${JSON.stringify(
|
|
185
|
+
writeFileSync(descriptor, `${JSON.stringify(fileValues, null, 2)}\n`, 'utf8');
|
|
119
186
|
fsyncSync(descriptor);
|
|
120
187
|
closeSync(descriptor);
|
|
121
188
|
descriptor = undefined;
|
|
@@ -125,6 +192,7 @@ export function saveUserConfig(values, { env = process.env, overwrite = false }
|
|
|
125
192
|
if (existingFile(path) && !overwrite) {
|
|
126
193
|
throw configError('AutoRouter configuration already exists; use overwrite to replace it.');
|
|
127
194
|
}
|
|
195
|
+
checkRevision();
|
|
128
196
|
if (overwrite) {
|
|
129
197
|
renameSync(temporary, path);
|
|
130
198
|
temporary = undefined;
|
|
@@ -133,6 +201,9 @@ export function saveUserConfig(values, { env = process.env, overwrite = false }
|
|
|
133
201
|
// symlink can never be replaced by the default save operation.
|
|
134
202
|
linkSync(temporary, path);
|
|
135
203
|
}
|
|
204
|
+
for (const key of removeSecrets) {
|
|
205
|
+
if (SECRET_CONFIG_KEYS.includes(key) && !keychainKeys.includes(key)) keychain.remove(keychainAccount(path, key));
|
|
206
|
+
}
|
|
136
207
|
return path;
|
|
137
208
|
} catch (error) {
|
|
138
209
|
if (error?.code === 'EEXIST') {
|