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
@@ -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
+ }
@@ -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) return undefined;
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
+ }
@@ -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 = new Set([
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 (!CONFIG_KEYS.has(key)) {
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
- export function loadUserConfig(env = process.env) {
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) return { env: { ...env }, path, exists: false };
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
- return { env: { ...values, ...env }, path, exists: true };
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
- export function saveUserConfig(values, { env = process.env, overwrite = false } = {}) {
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(validated, null, 2)}\n`, 'utf8');
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') {