claude-autorouter 0.3.7 → 0.4.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 +4 -2
- package/CONTRIBUTING.md +37 -0
- package/README.md +43 -70
- 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 +71 -32
- package/docs/releasing.md +74 -34
- 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/package.json +57 -9
- package/src/auto-routing.mjs +184 -24
- package/src/bounded-json.mjs +57 -0
- package/src/cli-help.mjs +87 -0
- package/src/config-command.mjs +141 -0
- package/src/config.mjs +52 -27
- package/src/contracts.mjs +123 -0
- package/src/evaluation-report.mjs +114 -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 +82 -23
- 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 +261 -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 +196 -0
- package/src/token-counter.mjs +3 -1
- package/src/turn-state.mjs +132 -0
- package/src/user-config.mjs +18 -8
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Shared, bounded telemetry contract. Never copy request bodies, headers, raw
|
|
3
|
+
// errors or arbitrary provider fields into status snapshots or saved history.
|
|
4
|
+
export const TELEMETRY_SCHEMA_VERSION = 2;
|
|
5
|
+
export const UNPRICED_REASONS = Object.freeze(['request_failed', 'request_cancelled', 'request_evicted',
|
|
6
|
+
'unknown_baseline', 'unknown_model', 'missing_model', 'missing_usage', 'invalid_usage',
|
|
7
|
+
'conflicting_usage', 'mixed_models', 'unsupported_pricing', 'invalid_telemetry',
|
|
8
|
+
'unknown_pricing_version', 'unconfirmed_completion', 'incomplete_usage', 'missing_outcome']);
|
|
9
|
+
const EVENTS = new Set(['request_start', 'route', 'upstream_response', 'upstream_model', 'upstream_usage',
|
|
10
|
+
'upstream_error', 'request_complete', 'request_error', 'request_cancelled']);
|
|
11
|
+
const SOURCES = new Set(['jev', 'ollama', 'cache', 'fallback', 'passthrough']);
|
|
12
|
+
const ERRORS = new Set(['invalid_request_error', 'authentication_error', 'billing_error', 'permission_error',
|
|
13
|
+
'not_found_error', 'request_too_large', 'rate_limit_error', 'api_error', 'overloaded_error', 'timeout_error',
|
|
14
|
+
'unknown_error', 'http_error', 'request_error']);
|
|
15
|
+
const CLASSIFIER_ERRORS = new Set(['timeout', 'http_error', 'invalid_response', 'network_error', 'capacity_exhausted']);
|
|
16
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
export const sanitizeIdentifier = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,200}$/.test(value) ? value : undefined;
|
|
18
|
+
export const sanitizeModel = value => typeof value === 'string' && /^[A-Za-z0-9_.:/-]{1,120}$/.test(value) ? value : undefined;
|
|
19
|
+
const code = value => typeof value === 'string' && /^[a-z][a-z0-9_]{0,79}$/.test(value) ? value : undefined;
|
|
20
|
+
const count = value => Number.isSafeInteger(value) && value >= 0;
|
|
21
|
+
const latency = value => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
22
|
+
const httpStatus = value => Number.isInteger(value) && value >= 100 && value <= 599;
|
|
23
|
+
const absent = value => value === undefined || value === null || value === '';
|
|
24
|
+
const assign = (row, field, value) => { if (value !== undefined) row[field] = value; };
|
|
25
|
+
|
|
26
|
+
export function normalizePricingContext(value) {
|
|
27
|
+
if (value === undefined) return;
|
|
28
|
+
if (!object(value)) return { pricing_unsupported: true };
|
|
29
|
+
const row = {};
|
|
30
|
+
for (const [key, allowed] of Object.entries({ speed: ['standard', 'fast'],
|
|
31
|
+
inference_geo: ['global', 'us', 'not_available'], service_tier: ['auto', 'standard_only', 'standard', 'priority', 'batch'] })) {
|
|
32
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
33
|
+
if (allowed.includes(value[key])) row[key] = value[key];
|
|
34
|
+
else row.pricing_unsupported = true;
|
|
35
|
+
}
|
|
36
|
+
for (const key of ['pricing_unsupported', 'unsupported']) {
|
|
37
|
+
if (Object.hasOwn(value, key) && value[key] !== false) row.pricing_unsupported = true;
|
|
38
|
+
}
|
|
39
|
+
return row;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function normalizeUsageTelemetry(value) {
|
|
43
|
+
if (value === undefined) return;
|
|
44
|
+
if (!object(value)) return { pricing_unsupported: true };
|
|
45
|
+
/** @type {Record<string,any>} */
|
|
46
|
+
const row = { ...normalizePricingContext(value) };
|
|
47
|
+
for (const key of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
|
|
48
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
49
|
+
if (count(value[key])) row[key] = value[key];
|
|
50
|
+
else row.pricing_unsupported = true;
|
|
51
|
+
}
|
|
52
|
+
if (Object.hasOwn(value, 'cache_creation')) {
|
|
53
|
+
if (value.cache_creation === null && value.cache_creation_input_tokens === 0) { /* no cache writes */ }
|
|
54
|
+
else if (!object(value.cache_creation)) row.pricing_unsupported = true;
|
|
55
|
+
else {
|
|
56
|
+
row.cache_creation = {};
|
|
57
|
+
for (const key of ['ephemeral_5m_input_tokens', 'ephemeral_1h_input_tokens']) {
|
|
58
|
+
if (!Object.hasOwn(value.cache_creation, key)) continue;
|
|
59
|
+
if (count(value.cache_creation[key])) row.cache_creation[key] = value.cache_creation[key];
|
|
60
|
+
else row.pricing_unsupported = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return row;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function reasons(value) {
|
|
68
|
+
if (!object(value)) return;
|
|
69
|
+
return Object.fromEntries(UNPRICED_REASONS.filter(key => count(value[key])).map(key => [key, value[key]]));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeSavings(value) {
|
|
73
|
+
if (!object(value)) return;
|
|
74
|
+
const row = {};
|
|
75
|
+
assign(row, 'baseline_model', sanitizeModel(value.baseline_model));
|
|
76
|
+
for (const key of ['actual_usd', 'baseline_usd', 'saved_usd', 'percent']) {
|
|
77
|
+
if (typeof value[key] === 'number' && Number.isFinite(value[key]) && Math.abs(value[key]) <= 1e18
|
|
78
|
+
&& (!['actual_usd', 'baseline_usd'].includes(key) || value[key] >= 0)) row[key] = value[key];
|
|
79
|
+
}
|
|
80
|
+
for (const key of ['requests', 'priced_requests', 'unpriced_requests']) if (count(value[key])) row[key] = value[key];
|
|
81
|
+
if (value.partial === true) row.partial = true;
|
|
82
|
+
if (typeof value.pricing_version === 'string' && /^[A-Za-z0-9_.-]{1,40}$/.test(value.pricing_version)) row.pricing_version = value.pricing_version;
|
|
83
|
+
if (typeof value.pricing_date === 'string' && /^\d{4}-\d\d-\d\d$/.test(value.pricing_date)) row.pricing_date = value.pricing_date;
|
|
84
|
+
// Provenance is a maintained public URL, never an arbitrary URL that might
|
|
85
|
+
// contain credentials or a private endpoint.
|
|
86
|
+
if (value.pricing_source === 'https://platform.claude.com/docs/en/about-claude/pricing') row.pricing_source = value.pricing_source;
|
|
87
|
+
assign(row, 'unpriced_reasons', reasons(value.unpriced_reasons));
|
|
88
|
+
return row;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function timestamp(value) {
|
|
92
|
+
return typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value)
|
|
93
|
+
&& Number.isFinite(Date.parse(value)) ? value : new Date().toISOString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @template {import('./contracts.mjs').LifecycleName|'decision'|'outcome'} T
|
|
98
|
+
* @param {any} entry
|
|
99
|
+
* @param {T} event
|
|
100
|
+
* @returns {(import('./contracts.mjs').TelemetryFields & {event:T,request_id:string})|undefined}
|
|
101
|
+
*/
|
|
102
|
+
function base(entry, event) {
|
|
103
|
+
const requestId = sanitizeIdentifier(entry.request_id);
|
|
104
|
+
if (!requestId) return;
|
|
105
|
+
/** @type {import('./contracts.mjs').TelemetryFields & {event:T,request_id:string}} */
|
|
106
|
+
const row = { schema_version: TELEMETRY_SCHEMA_VERSION, event, timestamp: timestamp(entry.timestamp), request_id: requestId };
|
|
107
|
+
for (const key of ['session_id', 'agent_id', 'prompt_id']) {
|
|
108
|
+
if (absent(entry[key])) continue;
|
|
109
|
+
const value = sanitizeIdentifier(entry[key]);
|
|
110
|
+
// Reject invalid explicit identities instead of merging them into the
|
|
111
|
+
// anonymous session or treating an unknown agent as the foreground UI.
|
|
112
|
+
if (!value) return;
|
|
113
|
+
row[key] = value;
|
|
114
|
+
}
|
|
115
|
+
if (!absent(entry.request_class)) {
|
|
116
|
+
const requestClass = code(entry.request_class);
|
|
117
|
+
if (!requestClass) return;
|
|
118
|
+
row.request_class = requestClass;
|
|
119
|
+
}
|
|
120
|
+
for (const key of ['requested_model', 'selected_model', 'confirmed_model', 'model', 'baseline_model']) assign(row, key, sanitizeModel(entry[key]));
|
|
121
|
+
if (typeof entry.pricing_version === 'string' && /^[A-Za-z0-9_.-]{1,40}$/.test(entry.pricing_version)) row.pricing_version = entry.pricing_version;
|
|
122
|
+
for (const key of ['reason', 'compatibility_reason', 'continuity_state']) assign(row, key, code(entry[key]));
|
|
123
|
+
if (SOURCES.has(entry.source)) row.source = entry.source;
|
|
124
|
+
if (['jev', 'ollama'].includes(entry.evaluator)) row.evaluator = entry.evaluator;
|
|
125
|
+
for (const key of ['tier', 'classified_tier']) if (['haiku', 'sonnet', 'opus'].includes(entry[key])) row[key] = entry[key];
|
|
126
|
+
for (const key of ['latency_ms', 'evaluation_latency_ms', 'routing_latency_ms', 'decision_latency_ms',
|
|
127
|
+
'first_response_ms', 'upstream_latency_ms', 'total_latency_ms']) if (latency(entry[key])) row[key] = entry[key];
|
|
128
|
+
if (CLASSIFIER_ERRORS.has(entry.classifier_error)) row.classifier_error = entry.classifier_error;
|
|
129
|
+
if (httpStatus(entry.classifier_status)) row.classifier_status = entry.classifier_status;
|
|
130
|
+
if (ERRORS.has(entry.error_type)) row.error_type = entry.error_type;
|
|
131
|
+
if (httpStatus(entry.http_status)) row.http_status = entry.http_status;
|
|
132
|
+
if (['within_budget', 'over_budget', 'count_unavailable'].includes(entry.context_check)) row.context_check = entry.context_check;
|
|
133
|
+
if (count(entry.counted_input_tokens)) row.counted_input_tokens = entry.counted_input_tokens;
|
|
134
|
+
if (Array.isArray(entry.model_transitions)) {
|
|
135
|
+
const transitions = entry.model_transitions.slice(0, 16).map(sanitizeModel).filter(Boolean);
|
|
136
|
+
row.model_transitions = transitions;
|
|
137
|
+
if (entry.model_transitions.length > 16 || transitions.length !== entry.model_transitions.length) row.model_transitions_truncated = true;
|
|
138
|
+
} else if (entry.model_transitions !== undefined) row.model_transitions_truncated = true;
|
|
139
|
+
if (entry.model_transitions_truncated === true) row.model_transitions_truncated = true;
|
|
140
|
+
assign(row, 'usage', normalizeUsageTelemetry(entry.usage));
|
|
141
|
+
assign(row, 'pricing_context', normalizePricingContext(entry.pricing_context));
|
|
142
|
+
for (const key of ['usage_complete', 'pricing_eligible', 'completion_confirmed']) if (typeof entry[key] === 'boolean') row[key] = entry[key];
|
|
143
|
+
if (UNPRICED_REASONS.includes(entry.unpriced_reason)) row.unpriced_reason = entry.unpriced_reason;
|
|
144
|
+
assign(row, 'savings', normalizeSavings(entry.savings));
|
|
145
|
+
assign(row, 'savings_coverage', normalizeSavings(entry.savings_coverage));
|
|
146
|
+
return row;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @param {any} entry @returns {import('./contracts.mjs').LifecycleEvent|undefined} */
|
|
150
|
+
export function normalizeTelemetryEvent(entry) {
|
|
151
|
+
try {
|
|
152
|
+
if (!object(entry)) return;
|
|
153
|
+
const event = entry.event === 'error' ? 'request_error' : entry.event === 'cancelled' ? 'request_cancelled' : entry.event;
|
|
154
|
+
if (!EVENTS.has(event)) return;
|
|
155
|
+
const row = base(entry, event);
|
|
156
|
+
if (row && httpStatus(entry.status)) row.status = entry.status;
|
|
157
|
+
return row;
|
|
158
|
+
} catch { return; }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function excerpt(value) {
|
|
162
|
+
let text = '', length = 0;
|
|
163
|
+
if (typeof value !== 'string') return { text, truncated: false };
|
|
164
|
+
for (const character of value) {
|
|
165
|
+
if (length++ === 500) return { text: text.toWellFormed(), truncated: true };
|
|
166
|
+
text += character;
|
|
167
|
+
}
|
|
168
|
+
return { text: text.toWellFormed(), truncated: false };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {any} entry
|
|
173
|
+
* @param {{includePrompts?:boolean}} [options]
|
|
174
|
+
* @returns {import('./contracts.mjs').SessionRecord|undefined}
|
|
175
|
+
*/
|
|
176
|
+
export function normalizeSessionRecord(entry, { includePrompts = true } = {}) {
|
|
177
|
+
try {
|
|
178
|
+
if (!object(entry) || !['decision', 'outcome'].includes(entry.event)) return;
|
|
179
|
+
if (entry.schema_version !== undefined && ![1, 2].includes(entry.schema_version)) return;
|
|
180
|
+
const row = base(entry, entry.event);
|
|
181
|
+
if (!row) return;
|
|
182
|
+
if (entry.event === 'decision') {
|
|
183
|
+
if (!row.requested_model || !row.selected_model) return;
|
|
184
|
+
if (includePrompts) {
|
|
185
|
+
const foreground = !row.request_class || row.request_class === 'main';
|
|
186
|
+
const prompt = foreground ? excerpt(entry.prompt_excerpt) : { text: '', truncated: false };
|
|
187
|
+
row.prompt_excerpt = prompt.text;
|
|
188
|
+
row.prompt_truncated = prompt.truncated || (foreground && entry.prompt_truncated === true);
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
if (!['completed', 'error', 'cancelled'].includes(entry.status)) return;
|
|
192
|
+
row.status = entry.status;
|
|
193
|
+
}
|
|
194
|
+
return row;
|
|
195
|
+
} catch { return; }
|
|
196
|
+
}
|
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,25 @@ 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
8
|
|
|
9
|
-
const CONFIG_KEYS =
|
|
9
|
+
export const CONFIG_KEYS = Object.freeze([
|
|
10
10
|
'AUTOROUTER_AUTH_MODE', 'AUTOROUTER_CLIENT_PROFILE',
|
|
11
11
|
'ANTHROPIC_API_KEY', 'TYPESAFE_API_KEY', 'AUTOROUTER_TOKEN',
|
|
12
12
|
'AUTOROUTER_UPSTREAM_URL', 'AUTOROUTER_JEV_URL', 'AUTOROUTER_JEV_MODEL',
|
|
13
13
|
'AUTOROUTER_HAIKU_MODEL', 'AUTOROUTER_SONNET_MODEL', 'AUTOROUTER_OPUS_MODEL',
|
|
14
14
|
'AUTOROUTER_PORT', 'AUTOROUTER_JEV_TIMEOUT_MS', 'AUTOROUTER_TOKEN_COUNT_TIMEOUT_MS',
|
|
15
15
|
'AUTOROUTER_MIN_CONFIDENCE', 'AUTOROUTER_STATUSLINE', 'AUTOROUTER_DEBUG',
|
|
16
|
-
'AUTOROUTER_SESSION_LOG_DIR',
|
|
16
|
+
'AUTOROUTER_SESSION_LOG_DIR', 'AUTOROUTER_SESSION_LOG_MODE',
|
|
17
17
|
'ENABLE_TOOL_SEARCH', 'CLAUDE_CODE_STOP_HOOK_BLOCK_CAP',
|
|
18
18
|
'AUTOROUTER_EVALUATOR', 'AUTOROUTER_OLLAMA_URL', 'AUTOROUTER_OLLAMA_MODEL',
|
|
19
19
|
'AUTOROUTER_OLLAMA_TIMEOUT_MS', 'AUTOROUTER_OLLAMA_KEEP_ALIVE',
|
|
20
20
|
]);
|
|
21
|
+
export const SECRET_CONFIG_KEYS = Object.freeze(['ANTHROPIC_API_KEY', 'TYPESAFE_API_KEY', 'AUTOROUTER_TOKEN']);
|
|
22
|
+
const allowedKeys = new Set(CONFIG_KEYS);
|
|
23
|
+
const revision = content => createHash('sha256').update(content).digest('hex');
|
|
21
24
|
const SAFE_FS_CODES = new Set([
|
|
22
25
|
'EACCES', 'EPERM', 'ENOENT', 'ENOTDIR', 'EISDIR', 'ENOSPC', 'EROFS',
|
|
23
26
|
'EMFILE', 'ENFILE', 'ELOOP', 'EIO', 'EEXIST',
|
|
@@ -44,7 +47,7 @@ function validate(values) {
|
|
|
44
47
|
}
|
|
45
48
|
const validated = {};
|
|
46
49
|
for (const key of Reflect.ownKeys(values)) {
|
|
47
|
-
if (!
|
|
50
|
+
if (!allowedKeys.has(key)) {
|
|
48
51
|
// An unknown key can itself contain a pasted credential.
|
|
49
52
|
throw configError('AutoRouter configuration contains an unsupported key.');
|
|
50
53
|
}
|
|
@@ -71,14 +74,14 @@ export function getConfigPath(env = process.env) {
|
|
|
71
74
|
return join(xdg || join(homedir(), '.config'), 'claude-autorouter', 'config.json');
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
export function loadUserConfig(env = process.env) {
|
|
77
|
+
export function loadUserConfig(env = process.env, { allowMissing = false } = {}) {
|
|
75
78
|
const path = getConfigPath(env);
|
|
76
79
|
let content;
|
|
77
80
|
try {
|
|
78
81
|
content = readFileSync(path, 'utf8');
|
|
79
82
|
} catch (error) {
|
|
80
83
|
if (error.code === 'ENOENT') {
|
|
81
|
-
if (env.AUTOROUTER_CONFIG === undefined) return { env: { ...env }, path, exists: false };
|
|
84
|
+
if (env.AUTOROUTER_CONFIG === undefined || allowMissing) return { env: { ...env }, values: {}, path, exists: false, revision: null };
|
|
82
85
|
throw configError('AUTOROUTER_CONFIG points to a missing configuration file.');
|
|
83
86
|
}
|
|
84
87
|
throw filesystemError(error, 'read');
|
|
@@ -87,7 +90,7 @@ export function loadUserConfig(env = process.env) {
|
|
|
87
90
|
try { parsed = JSON.parse(content); }
|
|
88
91
|
catch { throw configError('AutoRouter configuration must contain valid JSON.'); }
|
|
89
92
|
const values = validate(parsed);
|
|
90
|
-
return { env: { ...values, ...env }, path, exists: true };
|
|
93
|
+
return { env: { ...values, ...env }, values, path, exists: true, revision: revision(content) };
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
function existingFile(path) {
|
|
@@ -99,16 +102,22 @@ function existingFile(path) {
|
|
|
99
102
|
return stat;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
export function saveUserConfig(values, { env = process.env, overwrite = false } = {}) {
|
|
105
|
+
export function saveUserConfig(values, { env = process.env, overwrite = false, expectedRevision } = {}) {
|
|
103
106
|
const validated = validate(values);
|
|
104
107
|
const path = getConfigPath(env);
|
|
105
108
|
const parent = dirname(path);
|
|
106
109
|
let temporary;
|
|
107
110
|
let descriptor;
|
|
111
|
+
const checkRevision = () => {
|
|
112
|
+
if (expectedRevision === undefined) return;
|
|
113
|
+
const current = existingFile(path) ? revision(readFileSync(path, 'utf8')) : null;
|
|
114
|
+
if (current !== expectedRevision) throw configError('AutoRouter configuration changed while this operation was running. Retry with the current settings.');
|
|
115
|
+
};
|
|
108
116
|
try {
|
|
109
117
|
if (existingFile(path) && !overwrite) {
|
|
110
118
|
throw configError('AutoRouter configuration already exists; use overwrite to replace it.');
|
|
111
119
|
}
|
|
120
|
+
checkRevision();
|
|
112
121
|
// mkdir leaves existing directory permissions unchanged. Only directories
|
|
113
122
|
// created for this configuration receive the private creation mode.
|
|
114
123
|
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
@@ -125,6 +134,7 @@ export function saveUserConfig(values, { env = process.env, overwrite = false }
|
|
|
125
134
|
if (existingFile(path) && !overwrite) {
|
|
126
135
|
throw configError('AutoRouter configuration already exists; use overwrite to replace it.');
|
|
127
136
|
}
|
|
137
|
+
checkRevision();
|
|
128
138
|
if (overwrite) {
|
|
129
139
|
renameSync(temporary, path);
|
|
130
140
|
temporary = undefined;
|