@cr1ms0n/pi-subagent 0.8.8 → 0.9.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.
@@ -0,0 +1,1036 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { randomUUID } from "node:crypto";
3
+ import { Semaphore } from "./semaphore.js";
4
+ import { isThinkingLevel } from "./thinking.js";
5
+ import {
6
+ DEFAULT_ROUTING_CONCURRENCY,
7
+ MAX_ROUTING_MODEL_ID_LENGTH,
8
+ MAX_ROUTING_MODELS,
9
+ MAX_ROUTING_REQUEST_BYTES,
10
+ MAX_ROUTING_RESPONSE_BYTES,
11
+ MAX_ROUTING_TOOL_QUESTIONS,
12
+ MAX_SELECTOR_VERSION_LENGTH,
13
+ PROBABILITY_SUM_TOLERANCE,
14
+ TYPESAFE_SYSTEMONE_ENDPOINT,
15
+ type JevRoutingConfig,
16
+ type RoutingDecision,
17
+ type RoutingFailureCode,
18
+ type RoutingModelCandidate,
19
+ type RoutingProfile,
20
+ type RoutingPurpose,
21
+ type RoutingReceipt,
22
+ type RoutingReceiptOutcome,
23
+ type RoutingResult,
24
+ type RoutingSelectInput,
25
+ type RoutingSelectOptions,
26
+ type RoutingToolCandidate,
27
+ } from "./routing-types.js";
28
+
29
+ // Re-exported so integration can import the selector contract from the router module.
30
+ export type {
31
+ JevRoutingConfig,
32
+ RoutingConstraints,
33
+ RoutingDecision,
34
+ RoutingFailureCode,
35
+ RoutingModelCandidate,
36
+ RoutingProfile,
37
+ RoutingPurpose,
38
+ RoutingReceipt,
39
+ RoutingResult,
40
+ RoutingSelectInput,
41
+ RoutingSelectOptions,
42
+ RoutingToolCandidate,
43
+ } from "./routing-types.js";
44
+
45
+ /**
46
+ * Injectable asynchronous Jev (TypeSafe `/v1/systemone`) selector.
47
+ *
48
+ * One `JevRouter` holds one frozen per-invocation config snapshot. Call `select(input,
49
+ * options)` once per worker task / synthesis stage / plan stage. `input` is the minimal,
50
+ * disclosure-bounded routing DTO (task text, eligible model IDs/descriptions, eligible
51
+ * non-mandatory tool names/descriptions, necessary constraints). `options` carries metadata
52
+ * and lifecycle only (purpose, task index, abort signal, absolute deadline) and is never
53
+ * serialized.
54
+ *
55
+ * Guarantees:
56
+ * - One model Choice first, then one binary include/exclude Choice per eligible tool, packed
57
+ * into bounded requests. Every eligible tool is asked; nothing is truncated or ranked.
58
+ * - A single logical deadline = min(config.timeoutMs, caller absolute deadline) spans every
59
+ * request and all limiter waiting. Concurrent HTTP requests are bounded to two by default.
60
+ * - Only `https://api.typesafe.ai/v1/systemone` with `redirect:"error"`; the Bearer key comes
61
+ * from the injected environment accessor and never appears in config, results or messages.
62
+ * - Responses are untrusted data: shape, answer type, question set, allowed options, finite
63
+ * probabilities, probability sum tolerance, confidence, usage counts and selector version
64
+ * are validated. Valid low-confidence choices are accepted (no threshold, no substitution).
65
+ * - One receipt per actual HTTP attempt, published to the optional sink as soon as that HTTP
66
+ * attempt completes and re-published by the same `requestId` when decision validation
67
+ * changes its outcome. Reported tokens are retained on rejected decisions, and
68
+ * `currency` is always `"unknown"`.
69
+ * - A moving selector alias may resolve to different actual versions between requests; every
70
+ * distinct version is recorded in the receipt and in `decision.selectorVersions`. That is
71
+ * never treated as a decision error.
72
+ * - Failures return a discriminated result that includes every available receipt; there is no
73
+ * automatic selector retry, fallback or emergency model.
74
+ *
75
+ * Collaborators (`fetchImpl`, `env`, `now`, `idFactory`, `limiter`, `onReceipt`) are all
76
+ * injectable so the whole surface is testable offline with zero provider calls.
77
+ */
78
+
79
+ export interface RoutingLimiter {
80
+ acquire(signal?: AbortSignal): Promise<void>;
81
+ release(): void;
82
+ }
83
+
84
+ export interface JevRouterOptions {
85
+ /** Frozen per-invocation config snapshot. */
86
+ config: JevRoutingConfig;
87
+ /** Injected transport; defaults to global `fetch`. */
88
+ fetchImpl?: typeof fetch;
89
+ /** Injected environment accessor; defaults to `process.env`. */
90
+ env?: (name: string) => string | undefined;
91
+ /** Injected clock; defaults to `Date.now`. */
92
+ now?: () => number;
93
+ /** Injected unique-ID factory; defaults to `randomUUID`. */
94
+ idFactory?: () => string;
95
+ /** Injected concurrency limiter; defaults to a shared two-slot `Semaphore`. */
96
+ limiter?: RoutingLimiter;
97
+ /**
98
+ * Optional receipt sink. Called once when an actual HTTP attempt completes, and again with
99
+ * the same `requestId` if later decision validation changes that receipt's outcome. The
100
+ * caller can therefore persist incrementally and deduplicate by `requestId`.
101
+ *
102
+ * A throwing sink does not change routing, but it is **not** silently ignored: its
103
+ * request ID is surfaced in the result's `persistenceErrors` while the receipt (and its
104
+ * reported tokens) is still returned.
105
+ */
106
+ onReceipt?: (receipt: RoutingReceipt) => void;
107
+ }
108
+
109
+ interface QuestionSpec {
110
+ readonly id: string;
111
+ readonly options: readonly string[];
112
+ readonly question: Record<string, unknown>;
113
+ /** Set for tool questions so the chosen `include` maps back to a tool name. */
114
+ readonly toolName?: string;
115
+ }
116
+
117
+ interface ReceiptDraft {
118
+ requestId: string;
119
+ purpose: RoutingPurpose;
120
+ taskIndex?: number;
121
+ selectorModel: string;
122
+ selectorVersion?: string;
123
+ outcome: RoutingReceiptOutcome;
124
+ code?: RoutingFailureCode;
125
+ httpStatus?: number;
126
+ durationMs: number;
127
+ inputTokens?: number;
128
+ outputTokens?: number;
129
+ usageStatus: "reported" | "unknown";
130
+ currency: "unknown";
131
+ /** Internal ordering so receipts stay in issue order across concurrent batches. */
132
+ sequence: number;
133
+ }
134
+
135
+ interface CallState {
136
+ drafts: ReceiptDraft[];
137
+ /** Safe, non-secret diagnostics for failed receipt-sink calls. */
138
+ sinkErrors: string[];
139
+ }
140
+
141
+ interface IssueMeta {
142
+ readonly purpose: RoutingPurpose;
143
+ readonly taskIndex?: number;
144
+ readonly sequence: number;
145
+ }
146
+
147
+ interface CallContext {
148
+ readonly controller: AbortController;
149
+ readonly startedAt: number;
150
+ readonly deadlineMs: number;
151
+ readonly apiKey: string;
152
+ timedOut: boolean;
153
+ cancelled: boolean;
154
+ timer?: ReturnType<typeof setTimeout>;
155
+ onExternalAbort?: () => void;
156
+ externalSignal?: AbortSignal;
157
+ }
158
+
159
+ interface BatchIssue {
160
+ issued: boolean;
161
+ receipt?: ReceiptDraft;
162
+ body?: unknown;
163
+ failure?: { code: RoutingFailureCode; message: string };
164
+ }
165
+
166
+ interface AnswerValidation {
167
+ ok: true;
168
+ selectorVersion: string;
169
+ choices: ReadonlyMap<string, string>;
170
+ confidences: ReadonlyMap<string, number>;
171
+ }
172
+
173
+ interface AnswerInvalid {
174
+ ok: false;
175
+ code: RoutingFailureCode;
176
+ message: string;
177
+ }
178
+
179
+ type BodyRead =
180
+ | { ok: true; text: string }
181
+ | { ok: false; code: "response_too_large" | "transport_error" | "abort"; message: string };
182
+
183
+ const MODEL_INSTRUCTIONS =
184
+ "Select exactly one candidate execution model for the delegated task described in state. "
185
+ + "Match the task text and constraints against each candidate's user-provided characteristics. "
186
+ + "Criteria keys are correlation IDs only. Candidate order carries no ranking; choose on fit, "
187
+ + "not on position, model name, cost or quality assumptions.";
188
+
189
+ const TOOL_INSTRUCTIONS =
190
+ "Decide whether this single tool should be enabled for the delegated task described in state. "
191
+ + "Choose 'include' only when this tool is relevant to completing that task; otherwise choose "
192
+ + "'exclude'. The tool name and description are in the criteria; option keys are correlation "
193
+ + "IDs. Each question is independent.";
194
+
195
+ const ROUTING_PROFILES = new Set<RoutingProfile>(["explore", "review", "general"]);
196
+ const ROUTING_PURPOSES = new Set<RoutingPurpose>(["plan", "dispatch", "synthesis"]);
197
+
198
+ /** Shared default limiter: bounds concurrent selector HTTP requests across router instances. */
199
+ const sharedLimiter = new Semaphore(DEFAULT_ROUTING_CONCURRENCY, 256);
200
+ const ABORTED = Symbol("jev-routing-aborted");
201
+
202
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
203
+ if (signal.aborted) return Promise.reject(ABORTED);
204
+ return new Promise<T>((resolve, reject) => {
205
+ const onAbort = () => {
206
+ signal.removeEventListener("abort", onAbort);
207
+ reject(ABORTED);
208
+ };
209
+ signal.addEventListener("abort", onAbort, { once: true });
210
+ promise.then(
211
+ (value) => {
212
+ signal.removeEventListener("abort", onAbort);
213
+ resolve(value);
214
+ },
215
+ (error) => {
216
+ signal.removeEventListener("abort", onAbort);
217
+ reject(error);
218
+ },
219
+ );
220
+ });
221
+ }
222
+
223
+ /** Fire-and-forget stream cancellation: never awaited during deadline/cancel cleanup. */
224
+ function cancelReader(reader: { cancel(reason?: unknown): Promise<void> }): void {
225
+ try {
226
+ Promise.resolve(reader.cancel()).catch(() => { /* best effort */ });
227
+ } catch { /* best effort */ }
228
+ }
229
+
230
+ function tryParseJson(text: string): unknown {
231
+ try {
232
+ return JSON.parse(text);
233
+ } catch {
234
+ return undefined;
235
+ }
236
+ }
237
+
238
+ async function readBodyBounded(response: Response, signal: AbortSignal, maxBytes: number): Promise<BodyRead> {
239
+ const body = response.body;
240
+ if (!body || typeof body.getReader !== "function") {
241
+ if (typeof response.text !== "function") return { ok: true, text: "" };
242
+ let text: string;
243
+ try {
244
+ text = await abortable(response.text(), signal);
245
+ } catch {
246
+ return signal.aborted
247
+ ? { ok: false, code: "abort", message: "The routing response body read was interrupted." }
248
+ : { ok: false, code: "transport_error", message: "The TypeSafe routing response body could not be read." };
249
+ }
250
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
251
+ return { ok: false, code: "response_too_large", message: `The TypeSafe routing response exceeded the ${maxBytes}-byte response limit.` };
252
+ }
253
+ return { ok: true, text };
254
+ }
255
+
256
+ const reader = body.getReader();
257
+ const chunks: Uint8Array[] = [];
258
+ let total = 0;
259
+ try {
260
+ for (;;) {
261
+ const step = await abortable(reader.read(), signal);
262
+ if (step.done) break;
263
+ const value = step.value;
264
+ if (!value) continue;
265
+ total += value.byteLength;
266
+ if (total > maxBytes) {
267
+ cancelReader(reader);
268
+ return { ok: false, code: "response_too_large", message: `The TypeSafe routing response exceeded the ${maxBytes}-byte response limit.` };
269
+ }
270
+ chunks.push(value);
271
+ }
272
+ } catch {
273
+ cancelReader(reader);
274
+ return signal.aborted
275
+ ? { ok: false, code: "abort", message: "The routing response body read was interrupted." }
276
+ : { ok: false, code: "transport_error", message: "The TypeSafe routing response body could not be read." };
277
+ }
278
+ return { ok: true, text: Buffer.concat(chunks).toString("utf8") };
279
+ }
280
+
281
+ function readSelectorVersion(body: unknown): string | undefined {
282
+ if (!body || typeof body !== "object" || Array.isArray(body)) return undefined;
283
+ const value = (body as Record<string, unknown>).model;
284
+ if (typeof value !== "string") return undefined;
285
+ const version = value.trim();
286
+ if (!version || version.length > MAX_SELECTOR_VERSION_LENGTH || /[\u0000-\u001f\u007f]/.test(version)) return undefined;
287
+ return version;
288
+ }
289
+
290
+ interface UsageExtraction {
291
+ inputTokens?: number;
292
+ outputTokens?: number;
293
+ usageStatus: "reported" | "unknown";
294
+ invalid: boolean;
295
+ }
296
+
297
+ function extractUsage(body: unknown): UsageExtraction {
298
+ const usage = body && typeof body === "object" && !Array.isArray(body)
299
+ ? (body as Record<string, unknown>).usage
300
+ : undefined;
301
+ if (usage === undefined) return { usageStatus: "unknown", invalid: false };
302
+ if (!usage || typeof usage !== "object" || Array.isArray(usage)) return { usageStatus: "unknown", invalid: true };
303
+
304
+ const record = usage as Record<string, unknown>;
305
+ const readToken = (value: unknown): { value?: number; invalid: boolean } => {
306
+ if (value === undefined || value === null) return { invalid: false };
307
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) return { invalid: true };
308
+ return { value, invalid: false };
309
+ };
310
+ const input = readToken(record.input_tokens);
311
+ const output = readToken(record.output_tokens);
312
+ const usageStatus = !input.invalid && !output.invalid && input.value !== undefined && output.value !== undefined ? "reported" : "unknown";
313
+ return {
314
+ usageStatus,
315
+ ...(input.value === undefined ? {} : { inputTokens: input.value }),
316
+ ...(output.value === undefined ? {} : { outputTokens: output.value }),
317
+ invalid: input.invalid || output.invalid,
318
+ };
319
+ }
320
+
321
+ function normalizeAnswers(raw: unknown): Map<string, unknown> | undefined {
322
+ const map = new Map<string, unknown>();
323
+ if (Array.isArray(raw)) {
324
+ for (const entry of raw) {
325
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
326
+ const id = (entry as Record<string, unknown>).question_id;
327
+ if (typeof id !== "string" || !id || map.has(id)) return undefined;
328
+ map.set(id, entry);
329
+ }
330
+ return map;
331
+ }
332
+ if (raw && typeof raw === "object") {
333
+ for (const [id, entry] of Object.entries(raw as Record<string, unknown>)) {
334
+ if (!id || map.has(id)) return undefined;
335
+ map.set(id, entry);
336
+ }
337
+ return map;
338
+ }
339
+ return undefined;
340
+ }
341
+
342
+ function validateAnswers(body: unknown, questions: readonly QuestionSpec[]): AnswerValidation | AnswerInvalid {
343
+ const invalid = (code: RoutingFailureCode, message: string): AnswerInvalid => ({ ok: false, code, message });
344
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
345
+ return invalid("malformed_response", "The TypeSafe routing response was not a JSON object.");
346
+ }
347
+ const selectorVersion = readSelectorVersion(body);
348
+ if (selectorVersion === undefined) {
349
+ return invalid("malformed_response", "The TypeSafe routing response did not report a usable selector model version.");
350
+ }
351
+ const answers = normalizeAnswers((body as Record<string, unknown>).answers);
352
+ if (!answers) {
353
+ return invalid("malformed_response", "The TypeSafe routing response did not contain a usable answers collection.");
354
+ }
355
+ if (answers.size !== questions.length) {
356
+ return invalid("malformed_response", "The TypeSafe routing response did not answer exactly the questions that were asked.");
357
+ }
358
+
359
+ const choices = new Map<string, string>();
360
+ const confidences = new Map<string, number>();
361
+ for (const question of questions) {
362
+ const answer = answers.get(question.id);
363
+ if (answer === undefined) {
364
+ return invalid("malformed_response", "The TypeSafe routing response omitted an answer for a requested question.");
365
+ }
366
+ if (!answer || typeof answer !== "object" || Array.isArray(answer)) {
367
+ return invalid("malformed_response", "A TypeSafe routing answer was not an object.");
368
+ }
369
+ const record = answer as Record<string, unknown>;
370
+
371
+ if (record.type !== "choice") {
372
+ return invalid("malformed_response", "A TypeSafe routing answer was not tagged as a choice answer.");
373
+ }
374
+
375
+ const choice = record.choice;
376
+ if (typeof choice !== "string" || !question.options.includes(choice)) {
377
+ return invalid("invalid_decision", "The TypeSafe routing response chose an option that was not offered for one of the questions.");
378
+ }
379
+
380
+ const probabilities = record.probabilities;
381
+ if (!probabilities || typeof probabilities !== "object" || Array.isArray(probabilities)) {
382
+ return invalid("malformed_response", "A TypeSafe routing answer did not include an option probability set.");
383
+ }
384
+ const probRecord = probabilities as Record<string, unknown>;
385
+ const keys = Object.keys(probRecord);
386
+ if (keys.length !== question.options.length || question.options.some((option) => !keys.includes(option))) {
387
+ return invalid("malformed_response", "A TypeSafe routing answer probability set did not match the offered options.");
388
+ }
389
+ let sum = 0;
390
+ for (const option of question.options) {
391
+ const value = probRecord[option];
392
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
393
+ return invalid("malformed_response", "A TypeSafe routing answer reported a probability outside the finite range 0..1.");
394
+ }
395
+ sum += value;
396
+ }
397
+ if (Math.abs(sum - 1) > PROBABILITY_SUM_TOLERANCE) {
398
+ return invalid("malformed_response", `A TypeSafe routing answer probability set did not sum to 1 within the documented tolerance (${PROBABILITY_SUM_TOLERANCE}).`);
399
+ }
400
+
401
+ const confidence = record.confidence;
402
+ if (typeof confidence !== "number" || !Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
403
+ return invalid("malformed_response", "A TypeSafe routing answer did not report a finite confidence in 0..1.");
404
+ }
405
+
406
+ choices.set(question.id, choice);
407
+ confidences.set(question.id, confidence);
408
+ }
409
+ return { ok: true, selectorVersion, choices, confidences };
410
+ }
411
+
412
+ function selectorStatusFailure(status: number): { code: RoutingFailureCode; message: string } {
413
+ if (status === 401 || status === 403) {
414
+ return { code: "unauthorized", message: "TypeSafe rejected the routing credential (HTTP 401/403). Check the configured apiKeyEnv environment variable." };
415
+ }
416
+ if (status === 422) {
417
+ return { code: "invalid_request", message: "TypeSafe rejected the routing request as invalid (HTTP 422). Check selectorModel and the configured candidate/tool descriptions." };
418
+ }
419
+ if (status === 429) {
420
+ return { code: "rate_limited", message: "TypeSafe rate-limited the routing request (HTTP 429). Retry the dispatch later." };
421
+ }
422
+ if (status === 529) {
423
+ return { code: "overloaded", message: "TypeSafe is overloaded (HTTP 529). Retry the dispatch later." };
424
+ }
425
+ return { code: "http_error", message: `TypeSafe returned an unexpected HTTP status (${status}) for the routing request.` };
426
+ }
427
+
428
+ function buildState(input: RoutingSelectInput, selectedModel: string | undefined): Record<string, unknown> {
429
+ const state: Record<string, unknown> = { task: input.task };
430
+ const constraints = input.constraints;
431
+ if (constraints) {
432
+ state.constraints = {
433
+ profile: constraints.profile,
434
+ ...(constraints.requestedThinking === undefined ? {} : { requested_thinking: constraints.requestedThinking }),
435
+ ...(constraints.structuredOutput === undefined ? {} : { structured_output: constraints.structuredOutput }),
436
+ };
437
+ }
438
+ if (selectedModel !== undefined) state.selected_model = selectedModel;
439
+ return state;
440
+ }
441
+
442
+ function serializeRequest(
443
+ selectorModel: string,
444
+ state: Record<string, unknown>,
445
+ questions: readonly QuestionSpec[],
446
+ ): string {
447
+ const map: Record<string, unknown> = {};
448
+ for (const question of questions) map[question.id] = question.question;
449
+ return JSON.stringify({ state, model: selectorModel, questions: map });
450
+ }
451
+
452
+ function withinRequestLimit(text: string): boolean {
453
+ return Buffer.byteLength(text, "utf8") <= MAX_ROUTING_REQUEST_BYTES;
454
+ }
455
+
456
+ function buildModelQuestion(models: readonly RoutingModelCandidate[]): QuestionSpec {
457
+ const criteria: Record<string, string> = {};
458
+ const options: string[] = [];
459
+ models.forEach((candidate, index) => {
460
+ const key = `m${index}`;
461
+ options.push(key);
462
+ criteria[key] = `Model: ${candidate.model}\nUser-provided characteristics: ${candidate.description}`;
463
+ });
464
+ return Object.freeze({
465
+ id: "model",
466
+ options: Object.freeze(options),
467
+ question: Object.freeze({ type: "choice", criteria, instructions: MODEL_INSTRUCTIONS }),
468
+ });
469
+ }
470
+
471
+ function buildToolQuestion(tool: RoutingToolCandidate, index: number): QuestionSpec {
472
+ const description = tool.description && tool.description.trim() ? tool.description : "(no description provided)";
473
+ return Object.freeze({
474
+ id: `tool-${index}`,
475
+ options: Object.freeze(["include", "exclude"]),
476
+ toolName: tool.name,
477
+ question: Object.freeze({
478
+ type: "choice",
479
+ criteria: {
480
+ include: `Include tool "${tool.name}": ${description}`,
481
+ exclude: `Exclude tool "${tool.name}"`,
482
+ },
483
+ instructions: TOOL_INSTRUCTIONS,
484
+ }),
485
+ });
486
+ }
487
+
488
+ interface PackedToolBatch {
489
+ readonly text: string;
490
+ readonly questions: readonly QuestionSpec[];
491
+ }
492
+
493
+ interface ToolBatches {
494
+ batches: PackedToolBatch[];
495
+ }
496
+
497
+ function packToolBatches(
498
+ tools: readonly RoutingToolCandidate[],
499
+ state: Record<string, unknown>,
500
+ selectorModel: string,
501
+ ): ToolBatches | { error: { code: RoutingFailureCode; message: string } } {
502
+ const oversized = (): { error: { code: RoutingFailureCode; message: string } } => ({
503
+ error: {
504
+ code: "request_too_large",
505
+ message: `A single tool routing question exceeds the ${MAX_ROUTING_REQUEST_BYTES}-byte request limit; shorten that tool description or exclude it from the eligible candidates.`,
506
+ },
507
+ });
508
+
509
+ const batches: PackedToolBatch[] = [];
510
+ let current: QuestionSpec[] = [];
511
+ for (let index = 0; index < tools.length; index++) {
512
+ const question = buildToolQuestion(tools[index], index);
513
+ current.push(question);
514
+ if (withinRequestLimit(serializeRequest(selectorModel, state, current))) continue;
515
+
516
+ current.pop();
517
+ if (current.length === 0) return oversized();
518
+ batches.push({ text: serializeRequest(selectorModel, state, current), questions: Object.freeze([...current]) });
519
+ current = [question];
520
+ if (!withinRequestLimit(serializeRequest(selectorModel, state, current))) return oversized();
521
+ }
522
+ if (current.length) {
523
+ batches.push({ text: serializeRequest(selectorModel, state, current), questions: Object.freeze([...current]) });
524
+ }
525
+ return { batches };
526
+ }
527
+
528
+ function validateOptions(options: RoutingSelectOptions | undefined): string | undefined {
529
+ if (!options || typeof options !== "object") return "Routing options are required.";
530
+ if (!ROUTING_PURPOSES.has(options.purpose)) return "Routing purpose must be plan, dispatch or synthesis.";
531
+ if (options.taskIndex !== undefined && (!Number.isInteger(options.taskIndex) || options.taskIndex < 0)) {
532
+ return "Routing taskIndex must be a non-negative integer.";
533
+ }
534
+ if (options.deadline !== undefined && (typeof options.deadline !== "number" || !Number.isFinite(options.deadline))) {
535
+ return "Routing deadline must be a finite absolute epoch-millisecond number.";
536
+ }
537
+ if (options.signal !== undefined && typeof (options.signal as AbortSignal)?.aborted !== "boolean") {
538
+ return "Routing signal must be an AbortSignal.";
539
+ }
540
+ return undefined;
541
+ }
542
+
543
+ function validateInput(input: RoutingSelectInput | undefined): string | undefined {
544
+ if (!input || typeof input !== "object") return "Routing input is required.";
545
+ if (typeof input.task !== "string" || !input.task.trim()) return "The current delegated task text is required for routing.";
546
+ if (!Array.isArray(input.models)) return "Routing input must include an array of eligible candidate models.";
547
+ const seenModels = new Set<string>();
548
+ for (let index = 0; index < input.models.length; index++) {
549
+ const candidate = input.models[index];
550
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return `Candidate model #${index + 1} is not an object.`;
551
+ if (typeof candidate.model !== "string" || !candidate.model.trim() || candidate.model.length > MAX_ROUTING_MODEL_ID_LENGTH) {
552
+ return `Candidate model #${index + 1} has an invalid model ID.`;
553
+ }
554
+ if (typeof candidate.description !== "string" || !candidate.description.trim()) {
555
+ return `Candidate model #${index + 1} is missing a user-written characteristics description.`;
556
+ }
557
+ if (candidate.thinking !== undefined && !isThinkingLevel(candidate.thinking)) {
558
+ return `Candidate model #${index + 1} has an invalid thinking default.`;
559
+ }
560
+ if (seenModels.has(candidate.model)) return `Candidate models contain duplicate ID ${JSON.stringify(candidate.model)}.`;
561
+ seenModels.add(candidate.model);
562
+ }
563
+ if (input.tools !== undefined) {
564
+ if (!Array.isArray(input.tools)) return "Eligible tool candidates must be an array.";
565
+ const seenTools = new Set<string>();
566
+ for (let index = 0; index < input.tools.length; index++) {
567
+ const tool = input.tools[index];
568
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) return `Tool candidate #${index + 1} is not an object.`;
569
+ if (typeof tool.name !== "string" || !tool.name.trim()) return `Tool candidate #${index + 1} has an invalid tool name.`;
570
+ if (tool.description !== undefined && typeof tool.description !== "string") return `Tool candidate #${index + 1} has an invalid description.`;
571
+ if (seenTools.has(tool.name)) return `Tool candidates contain duplicate name ${JSON.stringify(tool.name)}.`;
572
+ seenTools.add(tool.name);
573
+ }
574
+ }
575
+ if (input.constraints !== undefined) {
576
+ const constraints = input.constraints;
577
+ if (!constraints || typeof constraints !== "object" || Array.isArray(constraints)) return "Routing constraints must be an object.";
578
+ if (!ROUTING_PROFILES.has(constraints.profile)) return "Routing constraints require a profile of explore, review or general.";
579
+ if (constraints.requestedThinking !== undefined && !isThinkingLevel(constraints.requestedThinking)) {
580
+ return "Routing constraints requested thinking must be a valid opaque Pi thinking level.";
581
+ }
582
+ if (constraints.structuredOutput !== undefined && typeof constraints.structuredOutput !== "boolean") {
583
+ return "Routing constraints structuredOutput must be a boolean.";
584
+ }
585
+ }
586
+ return undefined;
587
+ }
588
+
589
+ function freezeReceipt(draft: ReceiptDraft): RoutingReceipt {
590
+ return Object.freeze({
591
+ requestId: draft.requestId,
592
+ purpose: draft.purpose,
593
+ ...(draft.taskIndex === undefined ? {} : { taskIndex: draft.taskIndex }),
594
+ selectorModel: draft.selectorModel,
595
+ ...(draft.selectorVersion === undefined ? {} : { selectorVersion: draft.selectorVersion }),
596
+ outcome: draft.outcome,
597
+ ...(draft.code === undefined ? {} : { code: draft.code }),
598
+ ...(draft.httpStatus === undefined ? {} : { httpStatus: draft.httpStatus }),
599
+ durationMs: draft.durationMs,
600
+ ...(draft.inputTokens === undefined ? {} : { inputTokens: draft.inputTokens }),
601
+ ...(draft.outputTokens === undefined ? {} : { outputTokens: draft.outputTokens }),
602
+ usageStatus: draft.usageStatus,
603
+ currency: "unknown",
604
+ });
605
+ }
606
+
607
+ export class JevRouter {
608
+ private readonly config: JevRoutingConfig;
609
+ private readonly fetchImpl: typeof fetch | undefined;
610
+ private readonly env: (name: string) => string | undefined;
611
+ private readonly now: () => number;
612
+ private readonly idFactory: () => string;
613
+ private readonly limiter: RoutingLimiter;
614
+ private readonly onReceipt: ((receipt: RoutingReceipt) => void) | undefined;
615
+
616
+ constructor(options: JevRouterOptions) {
617
+ this.config = options.config;
618
+ this.fetchImpl = options.fetchImpl ?? (typeof globalThis.fetch === "function" ? globalThis.fetch : undefined);
619
+ this.env = options.env ?? ((name) => process.env[name]);
620
+ this.now = options.now ?? Date.now;
621
+ this.idFactory = options.idFactory ?? randomUUID;
622
+ this.limiter = options.limiter ?? sharedLimiter;
623
+ this.onReceipt = options.onReceipt;
624
+ }
625
+
626
+ /** Run one logical selection. Never throws for expected I/O/validation failures. */
627
+ async select(input: RoutingSelectInput, options: RoutingSelectOptions): Promise<RoutingResult> {
628
+ const call: CallState = { drafts: [], sinkErrors: [] };
629
+ const startedAt = this.now();
630
+ const decisionId = this.idFactory();
631
+
632
+ const optionsProblem = validateOptions(options);
633
+ if (optionsProblem) return this.fail("invalid_input", optionsProblem, call);
634
+
635
+ const inputProblem = validateInput(input);
636
+ if (inputProblem) return this.fail("invalid_input", inputProblem, call);
637
+
638
+ const models = input.models;
639
+ const tools = input.tools ?? [];
640
+ if (models.length === 0) {
641
+ return this.fail("no_candidate_models", "No locally eligible candidate model was provided; check the dedicated jevRouting candidate list against local model availability.", call);
642
+ }
643
+ if (models.length > MAX_ROUTING_MODELS) {
644
+ return this.fail("too_many_models", `The selector accepts at most ${MAX_ROUTING_MODELS} candidate models per question.`, call);
645
+ }
646
+ if (tools.length > MAX_ROUTING_TOOL_QUESTIONS) {
647
+ return this.fail("too_many_tools", `At most ${MAX_ROUTING_TOOL_QUESTIONS} eligible tools can be considered in one selection.`, call);
648
+ }
649
+
650
+ const rawKey = this.env(this.config.apiKeyEnv);
651
+ const apiKey = typeof rawKey === "string" ? rawKey.trim() : "";
652
+ if (!apiKey) {
653
+ return this.fail(
654
+ "missing_api_key",
655
+ `The TypeSafe routing credential is missing: set the ${this.config.apiKeyEnv} environment variable locally, then retry the dispatch.`,
656
+ call,
657
+ );
658
+ }
659
+ if (typeof this.fetchImpl !== "function") {
660
+ return this.fail("transport_error", "No fetch implementation is available for TypeSafe routing.", call);
661
+ }
662
+
663
+ // Preflight grossly oversized single tool questions before paying for the model request.
664
+ // This uses the minimum state (no selected model yet); the residual case where adding the
665
+ // chosen model ID pushes a question over the bound is still rejected before tool HTTP.
666
+ if (tools.length > 0) {
667
+ const probe = packToolBatches(tools, buildState(input, undefined), this.config.selectorModel);
668
+ if ("error" in probe) return this.fail(probe.error.code, probe.error.message, call);
669
+ }
670
+
671
+ const configuredEnd = startedAt + this.config.timeoutMs;
672
+ const callerEnd = typeof options.deadline === "number" && Number.isFinite(options.deadline) ? options.deadline : undefined;
673
+ const deadlineAt = callerEnd === undefined ? configuredEnd : Math.min(configuredEnd, callerEnd);
674
+ if (deadlineAt <= startedAt) {
675
+ return this.fail("timeout", "The routing selection deadline had already passed before any selector request could be sent.", call);
676
+ }
677
+
678
+ const ctx = this.createContext(startedAt, deadlineAt, options.signal, apiKey);
679
+ try {
680
+ if (ctx.controller.signal.aborted) {
681
+ return this.fail(this.abortCode(ctx), this.abortMessage(ctx), call);
682
+ }
683
+
684
+ // ---- 1. Model Choice ------------------------------------------------------------
685
+ const modelQuestion = buildModelQuestion(models);
686
+ const modelRequest = serializeRequest(this.config.selectorModel, buildState(input, undefined), [modelQuestion]);
687
+ if (!withinRequestLimit(modelRequest)) {
688
+ return this.fail(
689
+ "request_too_large",
690
+ `The model routing request exceeds the ${MAX_ROUTING_REQUEST_BYTES}-byte limit; shorten the task text or candidate descriptions.`,
691
+ call,
692
+ );
693
+ }
694
+
695
+ const modelIssue = await this.issue(ctx, modelRequest, {
696
+ purpose: options.purpose,
697
+ ...(options.taskIndex === undefined ? {} : { taskIndex: options.taskIndex }),
698
+ sequence: 0,
699
+ }, call);
700
+ if (modelIssue.body === undefined) {
701
+ const failure = modelIssue.failure ?? { code: "transport_error" as const, message: "The model routing request did not produce a usable response." };
702
+ return this.fail(failure.code, failure.message, call);
703
+ }
704
+ const modelValidation = validateAnswers(modelIssue.body, [modelQuestion]);
705
+ if (!modelValidation.ok) {
706
+ if (modelIssue.receipt) this.markReceiptFailed(modelIssue.receipt, modelValidation.code, call);
707
+ return this.fail(modelValidation.code, modelValidation.message, call);
708
+ }
709
+ const modelChoice = modelValidation.choices.get("model");
710
+ const modelIndex = modelChoice === undefined ? -1 : modelQuestion.options.indexOf(modelChoice);
711
+ if (modelIndex < 0) {
712
+ if (modelIssue.receipt) this.markReceiptFailed(modelIssue.receipt, "invalid_decision", call);
713
+ return this.fail("invalid_decision", "The TypeSafe routing response did not select a valid candidate model.", call);
714
+ }
715
+ const selectedModel = models[modelIndex].model;
716
+ const modelConfidence = modelValidation.confidences.get("model");
717
+ const primaryVersion = modelValidation.selectorVersion;
718
+ const versions: string[] = [primaryVersion];
719
+
720
+ // ---- 2. One binary Choice per eligible tool ------------------------------------
721
+ const selectedTools: string[] = [];
722
+ if (tools.length > 0) {
723
+ const packed = packToolBatches(tools, buildState(input, selectedModel), this.config.selectorModel);
724
+ if ("error" in packed) return this.fail(packed.error.code, packed.error.message, call);
725
+
726
+ const settled = await Promise.all(packed.batches.map(async (batch, index) => {
727
+ const outcome = await this.issue(ctx, batch.text, {
728
+ purpose: options.purpose,
729
+ ...(options.taskIndex === undefined ? {} : { taskIndex: options.taskIndex }),
730
+ sequence: index + 1,
731
+ }, call);
732
+
733
+ if (outcome.body === undefined) {
734
+ const failure = outcome.failure ?? { code: "transport_error" as const, message: "A tool routing request did not produce a usable response." };
735
+ if (!ctx.controller.signal.aborted) ctx.controller.abort();
736
+ return { index, batch, choices: undefined as ReadonlyMap<string, string> | undefined, version: undefined as string | undefined, failure };
737
+ }
738
+
739
+ const validation = validateAnswers(outcome.body, batch.questions);
740
+ if (!validation.ok) {
741
+ if (outcome.receipt) this.markReceiptFailed(outcome.receipt, validation.code, call);
742
+ if (!ctx.controller.signal.aborted) ctx.controller.abort();
743
+ return { index, batch, choices: undefined, version: undefined, failure: { code: validation.code, message: validation.message } };
744
+ }
745
+ return { index, batch, choices: validation.choices, version: validation.selectorVersion, failure: undefined };
746
+ }));
747
+
748
+ const failures = settled.filter((entry) => entry.failure !== undefined).sort((a, b) => a.index - b.index);
749
+ if (failures.length > 0) {
750
+ const primary = failures.find((entry) => entry.failure!.code !== "aborted") ?? failures[0];
751
+ return this.fail(primary.failure!.code, primary.failure!.message, call);
752
+ }
753
+
754
+ for (const entry of settled.sort((a, b) => a.index - b.index)) {
755
+ if (entry.version !== undefined && !versions.includes(entry.version)) versions.push(entry.version);
756
+ for (const question of entry.batch.questions) {
757
+ if (entry.choices?.get(question.id) === "include" && question.toolName) selectedTools.push(question.toolName);
758
+ }
759
+ }
760
+ }
761
+
762
+ const decision: RoutingDecision = Object.freeze({
763
+ decisionId,
764
+ purpose: options.purpose,
765
+ ...(options.taskIndex === undefined ? {} : { taskIndex: options.taskIndex }),
766
+ selectedModel,
767
+ selectedTools: Object.freeze(selectedTools),
768
+ ...(modelConfidence === undefined ? {} : { confidence: modelConfidence }),
769
+ selectorModel: this.config.selectorModel,
770
+ selectorVersion: primaryVersion,
771
+ selectorVersions: Object.freeze(versions),
772
+ latencyMs: Math.max(0, this.now() - startedAt),
773
+ receiptIds: Object.freeze([...call.drafts].sort((a, b) => a.sequence - b.sequence).map((draft) => draft.requestId)),
774
+ });
775
+ return {
776
+ ok: true,
777
+ decision,
778
+ receipts: this.settle(call),
779
+ ...(call.sinkErrors.length ? { persistenceErrors: Object.freeze([...call.sinkErrors]) } : {}),
780
+ };
781
+ } catch {
782
+ // Unexpected internal failures stay inside the discriminated result and never echo
783
+ // provider bodies, headers or credentials.
784
+ return this.fail("transport_error", "The routing selection failed unexpectedly before a decision was available.", call);
785
+ } finally {
786
+ this.releaseContext(ctx);
787
+ }
788
+ }
789
+
790
+ private createContext(startedAt: number, deadlineAt: number, external: AbortSignal | undefined, apiKey: string): CallContext {
791
+ const controller = new AbortController();
792
+ const ctx: CallContext = {
793
+ controller,
794
+ startedAt,
795
+ deadlineMs: Math.max(0, deadlineAt - startedAt),
796
+ apiKey,
797
+ timedOut: false,
798
+ cancelled: false,
799
+ };
800
+ const timer = setTimeout(() => {
801
+ ctx.timedOut = true;
802
+ controller.abort();
803
+ }, Math.max(0, deadlineAt - startedAt));
804
+ timer.unref?.();
805
+ ctx.timer = timer;
806
+
807
+ if (external) {
808
+ if (external.aborted) {
809
+ ctx.cancelled = true;
810
+ controller.abort();
811
+ } else {
812
+ const onExternalAbort = () => {
813
+ ctx.cancelled = true;
814
+ controller.abort();
815
+ };
816
+ external.addEventListener("abort", onExternalAbort, { once: true });
817
+ ctx.onExternalAbort = onExternalAbort;
818
+ ctx.externalSignal = external;
819
+ }
820
+ }
821
+ return ctx;
822
+ }
823
+
824
+ private releaseContext(ctx: CallContext): void {
825
+ if (ctx.timer) clearTimeout(ctx.timer);
826
+ if (ctx.externalSignal && ctx.onExternalAbort) {
827
+ ctx.externalSignal.removeEventListener("abort", ctx.onExternalAbort);
828
+ }
829
+ }
830
+
831
+ private abortCode(ctx: CallContext): RoutingFailureCode {
832
+ return ctx.timedOut ? "timeout" : "aborted";
833
+ }
834
+
835
+ private abortOutcome(ctx: CallContext): RoutingReceiptOutcome {
836
+ return ctx.timedOut ? "timeout" : "aborted";
837
+ }
838
+
839
+ private abortMessage(ctx: CallContext): string {
840
+ return ctx.timedOut
841
+ ? `The routing selection exceeded its ${ctx.deadlineMs} ms logical deadline and no decision was available.`
842
+ : "The routing selection was cancelled before a decision was available.";
843
+ }
844
+
845
+ private publishReceipt(draft: ReceiptDraft, call: CallState): void {
846
+ if (!this.onReceipt) return;
847
+ try {
848
+ this.onReceipt(freezeReceipt(draft));
849
+ } catch {
850
+ call.sinkErrors.push(`Receipt persistence failed for request ${draft.requestId}; the receipt was still retained in the routing result.`);
851
+ }
852
+ }
853
+
854
+ private markReceiptFailed(receipt: ReceiptDraft, code: RoutingFailureCode, call: CallState): void {
855
+ receipt.outcome = "error";
856
+ receipt.code = code;
857
+ this.publishReceipt(receipt, call);
858
+ }
859
+
860
+ private async issue(ctx: CallContext, text: string, meta: IssueMeta, call: CallState): Promise<BatchIssue> {
861
+ const signal = ctx.controller.signal;
862
+ if (signal.aborted) {
863
+ return { issued: false, failure: { code: this.abortCode(ctx), message: this.abortMessage(ctx) } };
864
+ }
865
+
866
+ try {
867
+ await this.limiter.acquire(signal);
868
+ } catch {
869
+ if (signal.aborted) {
870
+ return { issued: false, failure: { code: this.abortCode(ctx), message: this.abortMessage(ctx) } };
871
+ }
872
+ return {
873
+ issued: false,
874
+ failure: {
875
+ code: "transport_error",
876
+ message: "Too many concurrent routing requests are already queued; retry with fewer eligible tools or a later dispatch.",
877
+ },
878
+ };
879
+ }
880
+
881
+ const startedAt = this.now();
882
+ const draft = (overrides: Partial<ReceiptDraft>): ReceiptDraft => {
883
+ const entry: ReceiptDraft = {
884
+ requestId: this.idFactory(),
885
+ purpose: meta.purpose,
886
+ ...(meta.taskIndex === undefined ? {} : { taskIndex: meta.taskIndex }),
887
+ selectorModel: this.config.selectorModel,
888
+ outcome: "error",
889
+ durationMs: Math.max(0, this.now() - startedAt),
890
+ usageStatus: "unknown",
891
+ currency: "unknown",
892
+ sequence: meta.sequence,
893
+ ...overrides,
894
+ };
895
+ call.drafts.push(entry);
896
+ return entry;
897
+ };
898
+
899
+ try {
900
+ // Recheck after queuing: the deadline or caller cancellation may have fired while waiting.
901
+ if (signal.aborted) {
902
+ return { issued: false, failure: { code: this.abortCode(ctx), message: this.abortMessage(ctx) } };
903
+ }
904
+
905
+ let response: Response;
906
+ try {
907
+ // `abortable` protects the logical deadline even when an injected transport ignores
908
+ // its AbortSignal and never settles on its own.
909
+ response = await abortable(Promise.resolve(this.fetchImpl!(TYPESAFE_SYSTEMONE_ENDPOINT, {
910
+ method: "POST",
911
+ redirect: "error",
912
+ headers: {
913
+ "content-type": "application/json",
914
+ accept: "application/json",
915
+ authorization: `Bearer ${ctx.apiKey}`,
916
+ },
917
+ body: text,
918
+ signal,
919
+ })), signal);
920
+ } catch {
921
+ if (signal.aborted) {
922
+ const code = this.abortCode(ctx);
923
+ const receipt = draft({ outcome: this.abortOutcome(ctx), code });
924
+ this.publishReceipt(receipt, call);
925
+ return { issued: true, receipt, failure: { code, message: this.abortMessage(ctx) } };
926
+ }
927
+ const receipt = draft({ outcome: "error", code: "transport_error" });
928
+ this.publishReceipt(receipt, call);
929
+ return {
930
+ issued: true,
931
+ receipt,
932
+ failure: { code: "transport_error", message: "The TypeSafe routing request did not complete (network or transport failure)." },
933
+ };
934
+ }
935
+
936
+ if (!response.ok) {
937
+ // Parse the bounded error body only to salvage reported usage/version; never echo it.
938
+ const failure = selectorStatusFailure(response.status);
939
+ const read = await readBodyBounded(response, signal, MAX_ROUTING_RESPONSE_BYTES);
940
+ if (!read.ok && read.code === "abort") {
941
+ const code = this.abortCode(ctx);
942
+ const receipt = draft({ outcome: this.abortOutcome(ctx), code, httpStatus: response.status });
943
+ this.publishReceipt(receipt, call);
944
+ return { issued: true, receipt, failure: { code, message: this.abortMessage(ctx) } };
945
+ }
946
+ const parsed = read.ok ? tryParseJson(read.text) : undefined;
947
+ const usage = parsed === undefined ? { usageStatus: "unknown" as const, invalid: false } : extractUsage(parsed);
948
+ const selectorVersion = parsed === undefined ? undefined : readSelectorVersion(parsed);
949
+ const receipt = draft({
950
+ outcome: "error",
951
+ code: failure.code,
952
+ httpStatus: response.status,
953
+ ...(selectorVersion === undefined ? {} : { selectorVersion }),
954
+ ...(usage.inputTokens === undefined ? {} : { inputTokens: usage.inputTokens }),
955
+ ...(usage.outputTokens === undefined ? {} : { outputTokens: usage.outputTokens }),
956
+ usageStatus: usage.usageStatus,
957
+ });
958
+ this.publishReceipt(receipt, call);
959
+ return { issued: true, receipt, failure };
960
+ }
961
+
962
+ const read = await readBodyBounded(response, signal, MAX_ROUTING_RESPONSE_BYTES);
963
+ if (!read.ok) {
964
+ if (read.code === "abort") {
965
+ const code = this.abortCode(ctx);
966
+ const receipt = draft({ outcome: this.abortOutcome(ctx), code });
967
+ this.publishReceipt(receipt, call);
968
+ return { issued: true, receipt, failure: { code, message: this.abortMessage(ctx) } };
969
+ }
970
+ const code: RoutingFailureCode = read.code === "response_too_large" ? "response_too_large" : "transport_error";
971
+ const receipt = draft({ outcome: "error", code });
972
+ this.publishReceipt(receipt, call);
973
+ return { issued: true, receipt, failure: { code, message: read.message } };
974
+ }
975
+
976
+ const parsed = tryParseJson(read.text);
977
+ if (parsed === undefined || parsed === null) {
978
+ const receipt = draft({ outcome: "error", code: "malformed_response" });
979
+ this.publishReceipt(receipt, call);
980
+ return {
981
+ issued: true,
982
+ receipt,
983
+ failure: { code: "malformed_response", message: "The TypeSafe routing response was not valid JSON." },
984
+ };
985
+ }
986
+
987
+ const selectorVersion = readSelectorVersion(parsed);
988
+ const usage = extractUsage(parsed);
989
+ const usageFields = {
990
+ ...(usage.inputTokens === undefined ? {} : { inputTokens: usage.inputTokens }),
991
+ ...(usage.outputTokens === undefined ? {} : { outputTokens: usage.outputTokens }),
992
+ usageStatus: usage.usageStatus,
993
+ };
994
+
995
+ if (selectorVersion === undefined) {
996
+ const receipt = draft({ outcome: "error", code: "malformed_response", ...usageFields });
997
+ this.publishReceipt(receipt, call);
998
+ return {
999
+ issued: true,
1000
+ receipt,
1001
+ failure: { code: "malformed_response", message: "The TypeSafe routing response did not report a usable selector model version." },
1002
+ };
1003
+ }
1004
+ if (usage.invalid) {
1005
+ const receipt = draft({ outcome: "error", code: "malformed_response", selectorVersion, ...usageFields });
1006
+ this.publishReceipt(receipt, call);
1007
+ return {
1008
+ issued: true,
1009
+ receipt,
1010
+ failure: { code: "malformed_response", message: "The TypeSafe routing response reported invalid token usage." },
1011
+ };
1012
+ }
1013
+
1014
+ const receipt = draft({ outcome: "success", selectorVersion, ...usageFields });
1015
+ this.publishReceipt(receipt, call);
1016
+ return { issued: true, receipt, body: parsed };
1017
+ } finally {
1018
+ this.limiter.release();
1019
+ }
1020
+ }
1021
+
1022
+ private settle(call: CallState): readonly RoutingReceipt[] {
1023
+ const ordered = [...call.drafts].sort((a, b) => a.sequence - b.sequence);
1024
+ return Object.freeze(ordered.map(freezeReceipt));
1025
+ }
1026
+
1027
+ private fail(code: RoutingFailureCode, message: string, call: CallState): RoutingResult {
1028
+ return {
1029
+ ok: false,
1030
+ code,
1031
+ message,
1032
+ receipts: this.settle(call),
1033
+ ...(call.sinkErrors.length ? { persistenceErrors: Object.freeze([...call.sinkErrors]) } : {}),
1034
+ };
1035
+ }
1036
+ }