pi-smart-router 0.6.0 → 0.6.1
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/.pi/extensions/smart-router/commands.ts +49 -1
- package/.pi/extensions/smart-router/delegate-stream.ts +189 -20
- package/.pi/extensions/smart-router/delegation-runtime.ts +84 -3
- package/.pi/extensions/smart-router/planning-delegate.ts +11 -1
- package/.pi/extensions/smart-router/route-and-delegate.ts +85 -41
- package/.pi/extensions/smart-router/utils.ts +29 -0
- package/README.md +27 -3
- package/config/release-gates.json +16 -0
- package/dist/infrastructure/pricing/litellm-fetch.d.ts +2 -0
- package/dist/infrastructure/pricing/litellm-fetch.d.ts.map +1 -1
- package/dist/infrastructure/pricing/litellm-fetch.js +32 -2
- package/dist/infrastructure/pricing/litellm-fetch.js.map +1 -1
- package/package.json +6 -3
- package/src/infrastructure/pricing/litellm-fetch.ts +39 -2
|
@@ -105,6 +105,38 @@ export function getSmartRouterArgumentCompletions(prefix: string): CompletionIte
|
|
|
105
105
|
return filtered.length > 0 ? filtered : null;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/** Throw AbortError when ESC / ctx.signal has cancelled the command. */
|
|
109
|
+
export function throwIfCommandAborted(signal: AbortSignal | undefined): void {
|
|
110
|
+
if (!signal?.aborted) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const reason = signal.reason;
|
|
114
|
+
if (reason instanceof Error) {
|
|
115
|
+
throw reason;
|
|
116
|
+
}
|
|
117
|
+
const error = new Error(typeof reason === 'string' && reason.length > 0 ? reason : 'Aborted');
|
|
118
|
+
error.name = 'AbortError';
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isAbortError(error: unknown): boolean {
|
|
123
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Wrap fetch so LiteLLM pricing requests honor ctx.signal without changing
|
|
128
|
+
* refreshPricingCatalog's signature (out of File Scope for SP-172).
|
|
129
|
+
*/
|
|
130
|
+
export function createSignalAwareFetch(signal: AbortSignal | undefined): typeof fetch | undefined {
|
|
131
|
+
if (!signal) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
return ((input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
135
|
+
throwIfCommandAborted(signal);
|
|
136
|
+
return fetch(input, { ...init, signal });
|
|
137
|
+
}) as typeof fetch;
|
|
138
|
+
}
|
|
139
|
+
|
|
108
140
|
export function registerSmartRouterCommand(
|
|
109
141
|
pi: ExtensionAPI,
|
|
110
142
|
runtime: SmartRouterRuntime,
|
|
@@ -117,6 +149,7 @@ export function registerSmartRouterCommand(
|
|
|
117
149
|
try {
|
|
118
150
|
bindSharedModelRegistry(runtime, ctx.modelRegistry);
|
|
119
151
|
const parsed = parseSmartRouterArgs(args);
|
|
152
|
+
const signal = ctx.signal;
|
|
120
153
|
|
|
121
154
|
if (parsed.command === 'status') {
|
|
122
155
|
ctx.ui.notify(formatStatusMessage(runtime, runtime.lastDecision), 'info');
|
|
@@ -124,13 +157,21 @@ export function registerSmartRouterCommand(
|
|
|
124
157
|
}
|
|
125
158
|
|
|
126
159
|
if (parsed.command === 'history') {
|
|
160
|
+
throwIfCommandAborted(signal);
|
|
127
161
|
const rows = await runtime.store.listTelemetry({ limit: parsed.limit });
|
|
162
|
+
throwIfCommandAborted(signal);
|
|
128
163
|
ctx.ui.notify(formatHistoryMessage(rows), 'info');
|
|
129
164
|
return;
|
|
130
165
|
}
|
|
131
166
|
|
|
132
167
|
if (parsed.command === 'pricing') {
|
|
133
|
-
|
|
168
|
+
throwIfCommandAborted(signal);
|
|
169
|
+
const { modelCount, lastUpdated } = await refreshPricingCatalog(
|
|
170
|
+
runtime,
|
|
171
|
+
createSignalAwareFetch(signal),
|
|
172
|
+
);
|
|
173
|
+
// Abort before fleet mutation so cancel does not leave a half-rebuilt fleet.
|
|
174
|
+
throwIfCommandAborted(signal);
|
|
134
175
|
await rebuildFleet(runtime, pi, ctx.cwd);
|
|
135
176
|
ctx.ui.notify(
|
|
136
177
|
`Pricing refreshed: ${modelCount} models loaded (last_updated: ${lastUpdated}). Fleet rebuilt (${runtime.streamDeps.fleet.length} models).`,
|
|
@@ -140,6 +181,7 @@ export function registerSmartRouterCommand(
|
|
|
140
181
|
}
|
|
141
182
|
|
|
142
183
|
if (parsed.command === 'export' && parsed.subcommand === 'dataset') {
|
|
184
|
+
throwIfCommandAborted(signal);
|
|
143
185
|
const result = await exportDatasetToFile(runtime.store, ctx.cwd, parsed.limit);
|
|
144
186
|
if (!result) {
|
|
145
187
|
ctx.ui.notify('No routing dataset records to export.', 'info');
|
|
@@ -153,6 +195,7 @@ export function registerSmartRouterCommand(
|
|
|
153
195
|
}
|
|
154
196
|
|
|
155
197
|
if (parsed.command === 'export' && parsed.subcommand === 'telemetry-contrib') {
|
|
198
|
+
throwIfCommandAborted(signal);
|
|
156
199
|
const result = await exportTelemetryContrib({
|
|
157
200
|
store: runtime.store,
|
|
158
201
|
cwd: ctx.cwd,
|
|
@@ -226,6 +269,7 @@ export function registerSmartRouterCommand(
|
|
|
226
269
|
return;
|
|
227
270
|
}
|
|
228
271
|
|
|
272
|
+
throwIfCommandAborted(signal);
|
|
229
273
|
runtime.fleetMode = parsed.mode;
|
|
230
274
|
await rebuildFleet(runtime, pi, ctx.cwd);
|
|
231
275
|
pi.appendEntry(FLEET_MODE_ENTRY_TYPE, { mode: parsed.mode });
|
|
@@ -234,6 +278,10 @@ export function registerSmartRouterCommand(
|
|
|
234
278
|
'info',
|
|
235
279
|
);
|
|
236
280
|
} catch (error) {
|
|
281
|
+
if (isAbortError(error) || ctx.signal?.aborted) {
|
|
282
|
+
ctx.ui.notify('Cancelled.', 'info');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
237
285
|
const message = error instanceof Error ? error.message : String(error);
|
|
238
286
|
ctx.ui.notify(message, 'error');
|
|
239
287
|
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type Api,
|
|
3
3
|
type AssistantMessage,
|
|
4
4
|
type AssistantMessageEvent,
|
|
5
|
+
type AssistantMessageEventStream,
|
|
5
6
|
type Context,
|
|
6
7
|
type Model,
|
|
7
8
|
type SimpleStreamOptions,
|
|
@@ -11,13 +12,31 @@ import {
|
|
|
11
12
|
import { parseAssistantMessageError } from '../../../src/infrastructure/delegation/provider-error.js';
|
|
12
13
|
import {
|
|
13
14
|
buildDelegationContext,
|
|
15
|
+
forwardDelegatedEvent,
|
|
14
16
|
modelToExecutionModel,
|
|
17
|
+
pushFailoverNotice,
|
|
15
18
|
resolveDelegationOptions,
|
|
16
19
|
type DelegatedStreamResult,
|
|
17
20
|
type DelegationHeadroomContext,
|
|
21
|
+
type FailoverNoticeInfo,
|
|
22
|
+
type FlushDelegatedEventsOptions,
|
|
18
23
|
} from './delegation-runtime.js';
|
|
19
24
|
import type { StreamDelegationDeps } from './types.js';
|
|
25
|
+
import { throwIfAborted } from './utils.js';
|
|
20
26
|
|
|
27
|
+
function isTerminalEvent(
|
|
28
|
+
event: AssistantMessageEvent,
|
|
29
|
+
): event is Extract<AssistantMessageEvent, { type: 'done' | 'error' }> {
|
|
30
|
+
return event.type === 'done' || event.type === 'error';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Buffer the full inner stream (no outer push).
|
|
35
|
+
*
|
|
36
|
+
* Used by the planning-delegate ephemeral sub-call: only the final observation
|
|
37
|
+
* text is injected into primary context — intermediate tokens must not reach the
|
|
38
|
+
* user-facing outer stream (SP-170: planning stays buffered by design).
|
|
39
|
+
*/
|
|
21
40
|
export async function collectDelegatedStream(
|
|
22
41
|
targetModel: Model<Api>,
|
|
23
42
|
context: Context,
|
|
@@ -25,9 +44,7 @@ export async function collectDelegatedStream(
|
|
|
25
44
|
options: SimpleStreamOptions | undefined,
|
|
26
45
|
headroomContext?: DelegationHeadroomContext,
|
|
27
46
|
): Promise<DelegatedStreamResult> {
|
|
28
|
-
|
|
29
|
-
throw new Error('Request was aborted');
|
|
30
|
-
}
|
|
47
|
+
throwIfAborted(options);
|
|
31
48
|
|
|
32
49
|
const delegationOptions = await resolveDelegationOptions(
|
|
33
50
|
deps.modelRegistry,
|
|
@@ -41,9 +58,7 @@ export async function collectDelegatedStream(
|
|
|
41
58
|
let finalMessage: AssistantMessage | undefined;
|
|
42
59
|
|
|
43
60
|
for await (const event of inner) {
|
|
44
|
-
|
|
45
|
-
throw new Error('Request was aborted');
|
|
46
|
-
}
|
|
61
|
+
throwIfAborted(options);
|
|
47
62
|
events.push(event);
|
|
48
63
|
|
|
49
64
|
if (event.type === 'done') {
|
|
@@ -60,31 +75,145 @@ export async function collectDelegatedStream(
|
|
|
60
75
|
return { finalMessage, failed, events };
|
|
61
76
|
}
|
|
62
77
|
|
|
63
|
-
export
|
|
78
|
+
export interface PipeDelegatedStreamOptions extends FlushDelegatedEventsOptions {
|
|
79
|
+
readonly outer: AssistantMessageEventStream;
|
|
80
|
+
/**
|
|
81
|
+
* When set, push a synthetic failover `text_delta` immediately after the first
|
|
82
|
+
* live `start` event (before further retry tokens).
|
|
83
|
+
*/
|
|
84
|
+
readonly failoverNotice?: FailoverNoticeInfo;
|
|
85
|
+
/**
|
|
86
|
+
* When true (default), forward non-terminal events live and hold `done`/`error`
|
|
87
|
+
* until {@link commitPipedTerminal}. Set false to buffer only (no outer push).
|
|
88
|
+
*/
|
|
89
|
+
readonly live?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface PipedDelegatedStreamResult extends DelegatedStreamResult {
|
|
93
|
+
/** Terminal event held back so callers can decide failover before commit. */
|
|
94
|
+
readonly heldTerminal: AssistantMessageEvent | undefined;
|
|
95
|
+
readonly flushOptions: FlushDelegatedEventsOptions;
|
|
96
|
+
readonly outer: AssistantMessageEventStream | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Live-pipe provider events to `outer` as they arrive (SP-170).
|
|
101
|
+
*
|
|
102
|
+
* Non-terminal events (`start`, `text_delta`, …) are forwarded immediately so the
|
|
103
|
+
* UI is not frozen. Terminal `done`/`error` are held until the caller commits or
|
|
104
|
+
* discards them (failover discards without ending the outer stream).
|
|
105
|
+
*/
|
|
106
|
+
export async function pipeDelegatedStream(
|
|
64
107
|
targetModel: Model<Api>,
|
|
65
108
|
context: Context,
|
|
66
109
|
deps: StreamDelegationDeps,
|
|
67
110
|
options: SimpleStreamOptions | undefined,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
): Promise<
|
|
71
|
-
|
|
72
|
-
context,
|
|
73
|
-
targetModel,
|
|
74
|
-
deps,
|
|
75
|
-
sessionId,
|
|
76
|
-
);
|
|
111
|
+
headroomContext: DelegationHeadroomContext | undefined,
|
|
112
|
+
pipe: PipeDelegatedStreamOptions,
|
|
113
|
+
): Promise<PipedDelegatedStreamResult> {
|
|
114
|
+
throwIfAborted(options);
|
|
77
115
|
|
|
78
|
-
const
|
|
116
|
+
const delegationOptions = await resolveDelegationOptions(
|
|
117
|
+
deps.modelRegistry,
|
|
79
118
|
targetModel,
|
|
80
|
-
delegationContext,
|
|
81
|
-
deps,
|
|
82
119
|
options,
|
|
83
120
|
headroomContext,
|
|
84
121
|
);
|
|
122
|
+
const delegateStream = deps.delegateStream ?? defaultDelegateStream;
|
|
123
|
+
const inner = delegateStream(targetModel, context, delegationOptions);
|
|
124
|
+
const events: AssistantMessageEvent[] = [];
|
|
125
|
+
let finalMessage: AssistantMessage | undefined;
|
|
126
|
+
let heldTerminal: AssistantMessageEvent | undefined;
|
|
127
|
+
let noticePushed = false;
|
|
128
|
+
const live = pipe.live !== false;
|
|
129
|
+
const flushOptions: FlushDelegatedEventsOptions = {
|
|
130
|
+
...(pipe.sanitizeErrors !== undefined
|
|
131
|
+
? { sanitizeErrors: pipe.sanitizeErrors }
|
|
132
|
+
: {}),
|
|
133
|
+
...(pipe.contextWindow !== undefined
|
|
134
|
+
? { contextWindow: pipe.contextWindow }
|
|
135
|
+
: {}),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
for await (const event of inner) {
|
|
139
|
+
throwIfAborted(options);
|
|
140
|
+
events.push(event);
|
|
141
|
+
|
|
142
|
+
if (event.type === 'done') {
|
|
143
|
+
finalMessage = event.message;
|
|
144
|
+
heldTerminal = event;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (event.type === 'error') {
|
|
148
|
+
finalMessage = event.error;
|
|
149
|
+
heldTerminal = event;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!live) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
85
156
|
|
|
157
|
+
forwardDelegatedEvent(pipe.outer, event, flushOptions);
|
|
158
|
+
|
|
159
|
+
if (
|
|
160
|
+
!noticePushed &&
|
|
161
|
+
pipe.failoverNotice &&
|
|
162
|
+
event.type === 'start'
|
|
163
|
+
) {
|
|
164
|
+
pushFailoverNotice(pipe.outer, pipe.failoverNotice, event.partial);
|
|
165
|
+
noticePushed = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Error-only streams never emit `start` — still surface the notice before commit
|
|
170
|
+
// when the caller is about to show a successful retry that also lacked start
|
|
171
|
+
// (handled by commit path via leftover failoverNotice on next pipe call).
|
|
172
|
+
|
|
173
|
+
const failed =
|
|
174
|
+
finalMessage !== undefined &&
|
|
175
|
+
(finalMessage.stopReason === 'error' || finalMessage.stopReason === 'aborted');
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
finalMessage,
|
|
179
|
+
failed,
|
|
180
|
+
events,
|
|
181
|
+
heldTerminal,
|
|
182
|
+
flushOptions,
|
|
183
|
+
outer: pipe.outer,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Forward a held terminal event and end the outer stream. */
|
|
188
|
+
export function commitPipedTerminal(
|
|
189
|
+
result: PipedDelegatedStreamResult,
|
|
190
|
+
overrides?: FlushDelegatedEventsOptions,
|
|
191
|
+
): void {
|
|
192
|
+
const outer = result.outer;
|
|
193
|
+
if (!outer) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const opts = { ...result.flushOptions, ...overrides };
|
|
197
|
+
if (result.heldTerminal) {
|
|
198
|
+
forwardDelegatedEvent(outer, result.heldTerminal, opts);
|
|
199
|
+
}
|
|
200
|
+
const endMessage =
|
|
201
|
+
result.heldTerminal && isTerminalEvent(result.heldTerminal)
|
|
202
|
+
? result.heldTerminal.type === 'done'
|
|
203
|
+
? result.heldTerminal.message
|
|
204
|
+
: result.heldTerminal.error
|
|
205
|
+
: result.finalMessage;
|
|
206
|
+
outer.end(endMessage);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function recordDelegateOutcome(
|
|
210
|
+
targetModel: Model<Api>,
|
|
211
|
+
deps: StreamDelegationDeps,
|
|
212
|
+
sessionId: string | undefined,
|
|
213
|
+
result: DelegatedStreamResult,
|
|
214
|
+
): void {
|
|
86
215
|
if (!result.finalMessage) {
|
|
87
|
-
return
|
|
216
|
+
return;
|
|
88
217
|
}
|
|
89
218
|
|
|
90
219
|
if (result.failed) {
|
|
@@ -100,6 +229,46 @@ export async function delegateWithOutcome(
|
|
|
100
229
|
id: targetModel.id,
|
|
101
230
|
});
|
|
102
231
|
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Delegate with outcome recording. When `pipe` is provided, live-forwards to outer
|
|
236
|
+
* (holding the terminal event). Otherwise collects into a buffer (planning / probes).
|
|
237
|
+
*/
|
|
238
|
+
export async function delegateWithOutcome(
|
|
239
|
+
targetModel: Model<Api>,
|
|
240
|
+
context: Context,
|
|
241
|
+
deps: StreamDelegationDeps,
|
|
242
|
+
options: SimpleStreamOptions | undefined,
|
|
243
|
+
sessionId: string | undefined,
|
|
244
|
+
headroomContext?: DelegationHeadroomContext,
|
|
245
|
+
pipe?: PipeDelegatedStreamOptions,
|
|
246
|
+
): Promise<PipedDelegatedStreamResult | DelegatedStreamResult> {
|
|
247
|
+
const delegationContext = buildDelegationContext(
|
|
248
|
+
context,
|
|
249
|
+
targetModel,
|
|
250
|
+
deps,
|
|
251
|
+
sessionId,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const result = pipe
|
|
255
|
+
? await pipeDelegatedStream(
|
|
256
|
+
targetModel,
|
|
257
|
+
delegationContext,
|
|
258
|
+
deps,
|
|
259
|
+
options,
|
|
260
|
+
headroomContext,
|
|
261
|
+
pipe,
|
|
262
|
+
)
|
|
263
|
+
: await collectDelegatedStream(
|
|
264
|
+
targetModel,
|
|
265
|
+
delegationContext,
|
|
266
|
+
deps,
|
|
267
|
+
options,
|
|
268
|
+
headroomContext,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
recordDelegateOutcome(targetModel, deps, sessionId, result);
|
|
103
272
|
|
|
104
273
|
return result;
|
|
105
274
|
}
|
|
@@ -271,6 +271,85 @@ export interface FlushDelegatedEventsOptions {
|
|
|
271
271
|
readonly contextWindow?: number;
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
export interface FailoverNoticeInfo {
|
|
275
|
+
readonly failedModelId: string;
|
|
276
|
+
readonly alternateModelId: string;
|
|
277
|
+
readonly errorObj?: ReturnType<typeof parseAssistantMessageError>;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Build the operator-visible failover notice string. */
|
|
281
|
+
export function buildFailoverNoticeText(
|
|
282
|
+
failedModelId: string,
|
|
283
|
+
alternateModelId: string,
|
|
284
|
+
errorObj?: ReturnType<typeof parseAssistantMessageError>,
|
|
285
|
+
): string {
|
|
286
|
+
const reason = errorObj?.message || errorObj?.code || 'Unavailable';
|
|
287
|
+
return (
|
|
288
|
+
`> ⚠️ **pi-smart-router failover:** \`${failedModelId}\` failed (${reason}). ` +
|
|
289
|
+
`Retrying with \`${alternateModelId}\`...`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Push a synthetic failover `text_delta` to the live outer stream (SP-170).
|
|
295
|
+
* Call after the retry stream's `start` (or with a start partial) — no buffered-array mutation.
|
|
296
|
+
*/
|
|
297
|
+
export function pushFailoverNotice(
|
|
298
|
+
outer: AssistantMessageEventStream,
|
|
299
|
+
notice: FailoverNoticeInfo,
|
|
300
|
+
startPartial: AssistantMessage,
|
|
301
|
+
): void {
|
|
302
|
+
const text = buildFailoverNoticeText(
|
|
303
|
+
notice.failedModelId,
|
|
304
|
+
notice.alternateModelId,
|
|
305
|
+
notice.errorObj,
|
|
306
|
+
);
|
|
307
|
+
const delta = `${text}\n\n`;
|
|
308
|
+
const partial: AssistantMessage = {
|
|
309
|
+
...startPartial,
|
|
310
|
+
content: [{ type: 'text', text: delta }, ...startPartial.content],
|
|
311
|
+
};
|
|
312
|
+
outer.push({
|
|
313
|
+
type: 'text_delta',
|
|
314
|
+
contentIndex: 0,
|
|
315
|
+
delta,
|
|
316
|
+
partial,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Sanitize a single delegated event for live forwarding (length-stop + optional error UX).
|
|
322
|
+
*/
|
|
323
|
+
export function sanitizeDelegatedEvent(
|
|
324
|
+
event: AssistantMessageEvent,
|
|
325
|
+
options?: FlushDelegatedEventsOptions,
|
|
326
|
+
): AssistantMessageEvent {
|
|
327
|
+
const lengthStopHints =
|
|
328
|
+
options?.contextWindow !== undefined
|
|
329
|
+
? { contextWindow: options.contextWindow }
|
|
330
|
+
: undefined;
|
|
331
|
+
const events = [event];
|
|
332
|
+
sanitizeLengthStopEvents(events, lengthStopHints);
|
|
333
|
+
if (options?.sanitizeErrors) {
|
|
334
|
+
sanitizeDelegatedErrorEvents(events, lengthStopHints);
|
|
335
|
+
}
|
|
336
|
+
return events[0]!;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Push one sanitized event to the outer live stream (does not end the stream). */
|
|
340
|
+
export function forwardDelegatedEvent(
|
|
341
|
+
outer: AssistantMessageEventStream,
|
|
342
|
+
event: AssistantMessageEvent,
|
|
343
|
+
options?: FlushDelegatedEventsOptions,
|
|
344
|
+
): void {
|
|
345
|
+
outer.push(sanitizeDelegatedEvent(event, options));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Flush a buffered event list to outer and end the stream.
|
|
350
|
+
* Prefer live `forwardDelegatedEvent` / pipe paths for primary delegation (SP-170).
|
|
351
|
+
* Still used for terminal error-only buffers and legacy collect paths.
|
|
352
|
+
*/
|
|
274
353
|
export function flushDelegatedEvents(
|
|
275
354
|
outer: AssistantMessageEventStream,
|
|
276
355
|
events: readonly AssistantMessageEvent[],
|
|
@@ -291,15 +370,17 @@ export function flushDelegatedEvents(
|
|
|
291
370
|
outer.end();
|
|
292
371
|
}
|
|
293
372
|
|
|
373
|
+
/**
|
|
374
|
+
* @deprecated SP-170 — prefer `pushFailoverNotice` on the live outer stream.
|
|
375
|
+
* Mutates a buffered event array (legacy collect-then-flush path only).
|
|
376
|
+
*/
|
|
294
377
|
export function injectFailoverNotice(
|
|
295
378
|
events: AssistantMessageEvent[],
|
|
296
379
|
failedModelId: string,
|
|
297
380
|
alternateModelId: string,
|
|
298
381
|
errorObj?: ReturnType<typeof parseAssistantMessageError>,
|
|
299
382
|
): void {
|
|
300
|
-
const
|
|
301
|
-
const notice = `> ⚠️ **pi-smart-router failover:** \`${failedModelId}\` failed (${reason}). Retrying with \`${alternateModelId}\`...`;
|
|
302
|
-
|
|
383
|
+
const notice = buildFailoverNoticeText(failedModelId, alternateModelId, errorObj);
|
|
303
384
|
let noticeInjected = false;
|
|
304
385
|
|
|
305
386
|
for (let i = 0; i < events.length; i++) {
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import { collectDelegatedStream } from './delegate-stream.js';
|
|
33
33
|
import { findFleetProfile, resolveRegistryModel } from './delegation-runtime.js';
|
|
34
34
|
import type { StreamDelegationDeps } from './types.js';
|
|
35
|
+
import { throwIfAborted } from './utils.js';
|
|
35
36
|
|
|
36
37
|
/** Prefix for injected planning observations visible to the primary model. */
|
|
37
38
|
export const PLANNING_DELEGATE_OBSERVATION_PREFIX =
|
|
@@ -169,7 +170,12 @@ export function injectPlanningDelegateObservation(
|
|
|
169
170
|
};
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
/** Default frontier sub-call via provider stream (ephemeral one-shot delegate).
|
|
173
|
+
/** Default frontier sub-call via provider stream (ephemeral one-shot delegate).
|
|
174
|
+
*
|
|
175
|
+
* SP-170: intentionally uses collectDelegatedStream (buffered), not live outer
|
|
176
|
+
* piping. Only the final observation text is injected into the primary context;
|
|
177
|
+
* intermediate frontier tokens must not reach the user-facing stream.
|
|
178
|
+
*/
|
|
173
179
|
export async function defaultSpawnPlanningDelegate(
|
|
174
180
|
frontierModel: Model<Api>,
|
|
175
181
|
compressedContext: Context,
|
|
@@ -225,6 +231,9 @@ export async function resolvePlanningDelegatePath(
|
|
|
225
231
|
options: SimpleStreamOptions | undefined,
|
|
226
232
|
deps: StreamDelegationDeps,
|
|
227
233
|
): Promise<PlanningDelegateResolution> {
|
|
234
|
+
// Abort before planning sub-call work (SP-171 phase boundary).
|
|
235
|
+
throwIfAborted(options);
|
|
236
|
+
|
|
228
237
|
const observability = decision.features!.planning_delegate!;
|
|
229
238
|
const delegateModelId = observability.delegate_model_id!;
|
|
230
239
|
const primaryModelId = decision.selected_model_id;
|
|
@@ -252,6 +261,7 @@ export async function resolvePlanningDelegatePath(
|
|
|
252
261
|
context,
|
|
253
262
|
observability.compressed_context,
|
|
254
263
|
);
|
|
264
|
+
throwIfAborted(options);
|
|
255
265
|
const spawnFn = deps.spawnPlanningDelegate ?? defaultSpawnPlanningDelegate;
|
|
256
266
|
const spawnResult = await spawnFn(frontierModel, compressedContext, options, deps);
|
|
257
267
|
|
|
@@ -25,11 +25,16 @@ import {
|
|
|
25
25
|
parseAssistantMessageError,
|
|
26
26
|
} from '../../../src/infrastructure/delegation/provider-error.js';
|
|
27
27
|
import { shouldFailoverOnProviderError } from '../../../src/infrastructure/gateway/gateway-dispatch.js';
|
|
28
|
-
import { delegateWithOutcome } from './delegate-stream.js';
|
|
29
28
|
import {
|
|
29
|
+
commitPipedTerminal,
|
|
30
|
+
delegateWithOutcome,
|
|
31
|
+
type PipedDelegatedStreamResult,
|
|
32
|
+
} from './delegate-stream.js';
|
|
33
|
+
import {
|
|
34
|
+
createErrorMessage,
|
|
30
35
|
findFleetProfile,
|
|
31
36
|
flushDelegatedEvents,
|
|
32
|
-
|
|
37
|
+
type FailoverNoticeInfo,
|
|
33
38
|
resolveRegistryModel,
|
|
34
39
|
} from './delegation-runtime.js';
|
|
35
40
|
import { buildRoutingRequest } from './routing-context.js';
|
|
@@ -39,6 +44,13 @@ import {
|
|
|
39
44
|
resolvePlanningDelegatePath,
|
|
40
45
|
} from './planning-delegate.js';
|
|
41
46
|
import type { StreamDelegationDeps } from './types.js';
|
|
47
|
+
import { isAbortError, throwIfAborted } from './utils.js';
|
|
48
|
+
|
|
49
|
+
function isPipedResult(
|
|
50
|
+
result: Awaited<ReturnType<typeof delegateWithOutcome>>,
|
|
51
|
+
): result is PipedDelegatedStreamResult {
|
|
52
|
+
return 'heldTerminal' in result;
|
|
53
|
+
}
|
|
42
54
|
|
|
43
55
|
function isRoutingLogEnabled(): boolean {
|
|
44
56
|
return process.env.SMART_ROUTER_LOG_ROUTING === '1';
|
|
@@ -222,6 +234,10 @@ function resolveHeadroomFallbackTarget(
|
|
|
222
234
|
/**
|
|
223
235
|
* Route a request and delegate to the selected provider with failover.
|
|
224
236
|
* Kept as one module to preserve the atomic failover state machine (#33).
|
|
237
|
+
*
|
|
238
|
+
* Abort checks run at phase boundaries (entry, fleet refresh, dispatch,
|
|
239
|
+
* planning delegate, each failover iteration). HyDRA/ONNX embedding inference
|
|
240
|
+
* cannot cancel mid-run — fail-fast only before/after that stage (SP-171).
|
|
225
241
|
*/
|
|
226
242
|
export async function routeAndDelegate(
|
|
227
243
|
context: Context,
|
|
@@ -229,8 +245,12 @@ export async function routeAndDelegate(
|
|
|
229
245
|
deps: StreamDelegationDeps,
|
|
230
246
|
outer: AssistantMessageEventStream,
|
|
231
247
|
): Promise<void> {
|
|
248
|
+
// Phase boundary: abort before any long work (fleet refresh, HyDRA dispatch, delegate).
|
|
249
|
+
throwIfAborted(options);
|
|
250
|
+
|
|
232
251
|
const sessionId = options?.sessionId;
|
|
233
252
|
if (deps.ensureFleetFresh) {
|
|
253
|
+
throwIfAborted(options);
|
|
234
254
|
await deps.ensureFleetFresh();
|
|
235
255
|
}
|
|
236
256
|
let decision: RoutingDecision;
|
|
@@ -264,8 +284,14 @@ export async function routeAndDelegate(
|
|
|
264
284
|
);
|
|
265
285
|
}
|
|
266
286
|
capturePreRouteOutcomes(request, deps, priorSnapshot, hadPin);
|
|
287
|
+
// Phase boundary: abort before HyDRA/dispatch (mid-ONNX cancel unsupported).
|
|
288
|
+
throwIfAborted(options);
|
|
267
289
|
decision = await deps.router.dispatch.dispatch(request, { effectiveFleet });
|
|
268
290
|
} catch (error) {
|
|
291
|
+
// Do not treat abort as a routing failure — never failover on cancel.
|
|
292
|
+
if (isAbortError(error, options)) {
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
269
295
|
const fallbackModel = resolveFallbackModel(deps, effectiveFleet);
|
|
270
296
|
if (!fallbackModel) {
|
|
271
297
|
throw error;
|
|
@@ -280,10 +306,19 @@ export async function routeAndDelegate(
|
|
|
280
306
|
deps,
|
|
281
307
|
options,
|
|
282
308
|
sessionId,
|
|
309
|
+
undefined,
|
|
310
|
+
{
|
|
311
|
+
outer,
|
|
312
|
+
contextWindow: fallbackModel.contextWindow,
|
|
313
|
+
},
|
|
283
314
|
);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
315
|
+
if (isPipedResult(fallbackResult)) {
|
|
316
|
+
commitPipedTerminal(fallbackResult);
|
|
317
|
+
} else {
|
|
318
|
+
flushDelegatedEvents(outer, fallbackResult.events, {
|
|
319
|
+
contextWindow: fallbackModel.contextWindow,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
287
322
|
return;
|
|
288
323
|
}
|
|
289
324
|
|
|
@@ -294,6 +329,8 @@ export async function routeAndDelegate(
|
|
|
294
329
|
let delegationContext: Context = context;
|
|
295
330
|
|
|
296
331
|
if (isPlanningDelegateActive(decision)) {
|
|
332
|
+
// Phase boundary: abort before planning-delegate sub-call.
|
|
333
|
+
throwIfAborted(options);
|
|
297
334
|
const planningResolution = await resolvePlanningDelegatePath(
|
|
298
335
|
context,
|
|
299
336
|
decision,
|
|
@@ -335,13 +372,11 @@ export async function routeAndDelegate(
|
|
|
335
372
|
const headroomExcludedModelIds: string[] = [];
|
|
336
373
|
const estimatedInputTokens =
|
|
337
374
|
request.estimated_input_tokens ?? request.prompt_text.length;
|
|
338
|
-
let pendingFailoverInfo:
|
|
339
|
-
failedModelId: string;
|
|
340
|
-
alternateModelId: string;
|
|
341
|
-
errorObj?: ReturnType<typeof parseAssistantMessageError>;
|
|
342
|
-
} | undefined;
|
|
375
|
+
let pendingFailoverInfo: FailoverNoticeInfo | undefined;
|
|
343
376
|
|
|
344
377
|
while (true) {
|
|
378
|
+
// Phase boundary: abort before each failover / delegation attempt.
|
|
379
|
+
throwIfAborted(options);
|
|
345
380
|
try {
|
|
346
381
|
const targetProfile =
|
|
347
382
|
findFleetProfile(effectiveFleet, targetModel.id) ??
|
|
@@ -392,6 +427,9 @@ export async function routeAndDelegate(
|
|
|
392
427
|
? { profile: targetProfile, estimatedInputTokens }
|
|
393
428
|
: undefined;
|
|
394
429
|
|
|
430
|
+
const failoverNotice = pendingFailoverInfo;
|
|
431
|
+
pendingFailoverInfo = undefined;
|
|
432
|
+
|
|
395
433
|
const result = await delegateWithOutcome(
|
|
396
434
|
targetModel,
|
|
397
435
|
delegationContext,
|
|
@@ -399,16 +437,19 @@ export async function routeAndDelegate(
|
|
|
399
437
|
options,
|
|
400
438
|
sessionId,
|
|
401
439
|
headroomContext,
|
|
440
|
+
{
|
|
441
|
+
outer,
|
|
442
|
+
...(failoverNotice !== undefined ? { failoverNotice } : {}),
|
|
443
|
+
contextWindow: targetModel.contextWindow,
|
|
444
|
+
},
|
|
402
445
|
);
|
|
403
446
|
|
|
404
|
-
if (
|
|
405
|
-
|
|
406
|
-
result.
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
);
|
|
411
|
-
pendingFailoverInfo = undefined;
|
|
447
|
+
if (!isPipedResult(result)) {
|
|
448
|
+
flushDelegatedEvents(outer, result.events, {
|
|
449
|
+
sanitizeErrors: result.failed,
|
|
450
|
+
contextWindow: targetModel.contextWindow,
|
|
451
|
+
});
|
|
452
|
+
return;
|
|
412
453
|
}
|
|
413
454
|
|
|
414
455
|
if (result.finalMessage && isZeroOutputLengthStop(result.finalMessage)) {
|
|
@@ -453,10 +494,7 @@ export async function routeAndDelegate(
|
|
|
453
494
|
result.finalMessage &&
|
|
454
495
|
isGeminiThoughtSignatureAssistantError(result.finalMessage)
|
|
455
496
|
) {
|
|
456
|
-
|
|
457
|
-
sanitizeErrors: true,
|
|
458
|
-
contextWindow: targetModel.contextWindow,
|
|
459
|
-
});
|
|
497
|
+
commitPipedTerminal(result, { sanitizeErrors: true });
|
|
460
498
|
return;
|
|
461
499
|
}
|
|
462
500
|
|
|
@@ -475,19 +513,13 @@ export async function routeAndDelegate(
|
|
|
475
513
|
effectiveFleet,
|
|
476
514
|
);
|
|
477
515
|
if (!failover) {
|
|
478
|
-
|
|
479
|
-
sanitizeErrors: true,
|
|
480
|
-
contextWindow: targetModel.contextWindow,
|
|
481
|
-
});
|
|
516
|
+
commitPipedTerminal(result, { sanitizeErrors: true });
|
|
482
517
|
return;
|
|
483
518
|
}
|
|
484
519
|
|
|
485
520
|
const alternateModel = resolveTargetModel(deps, failover);
|
|
486
521
|
if (!alternateModel || alternateModel.id === targetModel.id) {
|
|
487
|
-
|
|
488
|
-
sanitizeErrors: true,
|
|
489
|
-
contextWindow: targetModel.contextWindow,
|
|
490
|
-
});
|
|
522
|
+
commitPipedTerminal(result, { sanitizeErrors: true });
|
|
491
523
|
return;
|
|
492
524
|
}
|
|
493
525
|
|
|
@@ -495,6 +527,7 @@ export async function routeAndDelegate(
|
|
|
495
527
|
'[smart-router] infra error, failing over to alternate model',
|
|
496
528
|
alternateModel.id,
|
|
497
529
|
);
|
|
530
|
+
// Discard held terminal from the failed attempt — do not forward to outer.
|
|
498
531
|
pendingFailoverInfo = {
|
|
499
532
|
failedModelId: targetModel.id,
|
|
500
533
|
alternateModelId: alternateModel.id,
|
|
@@ -506,12 +539,19 @@ export async function routeAndDelegate(
|
|
|
506
539
|
}
|
|
507
540
|
}
|
|
508
541
|
|
|
509
|
-
|
|
542
|
+
commitPipedTerminal(result, {
|
|
510
543
|
sanitizeErrors: result.failed,
|
|
511
544
|
contextWindow: targetModel.contextWindow,
|
|
512
545
|
});
|
|
513
546
|
return;
|
|
514
547
|
} catch (error) {
|
|
548
|
+
if (isAbortError(error, options)) {
|
|
549
|
+
const abortMessage = createErrorMessage(targetModel, options, error);
|
|
550
|
+
outer.push({ type: 'error', reason: 'aborted', error: abortMessage });
|
|
551
|
+
outer.end(abortMessage);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
515
555
|
deps.router.dispatch.recordOutcome(targetModel.id, { code: 'STREAM_DELEGATION_ERROR' });
|
|
516
556
|
|
|
517
557
|
if (!failedModelIds.includes(targetModel.id)) {
|
|
@@ -558,24 +598,28 @@ export async function routeAndDelegate(
|
|
|
558
598
|
errorObj: { message: error instanceof Error ? error.message : String(error) },
|
|
559
599
|
};
|
|
560
600
|
|
|
601
|
+
const failoverNotice = pendingFailoverInfo;
|
|
602
|
+
pendingFailoverInfo = undefined;
|
|
561
603
|
const fallbackResult = await delegateWithOutcome(
|
|
562
604
|
fallbackModel,
|
|
563
605
|
context,
|
|
564
606
|
deps,
|
|
565
607
|
options,
|
|
566
608
|
sessionId,
|
|
609
|
+
undefined,
|
|
610
|
+
{
|
|
611
|
+
outer,
|
|
612
|
+
...(failoverNotice !== undefined ? { failoverNotice } : {}),
|
|
613
|
+
contextWindow: fallbackModel.contextWindow,
|
|
614
|
+
},
|
|
567
615
|
);
|
|
568
|
-
if (
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
);
|
|
616
|
+
if (isPipedResult(fallbackResult)) {
|
|
617
|
+
commitPipedTerminal(fallbackResult);
|
|
618
|
+
} else {
|
|
619
|
+
flushDelegatedEvents(outer, fallbackResult.events, {
|
|
620
|
+
contextWindow: fallbackModel.contextWindow,
|
|
621
|
+
});
|
|
575
622
|
}
|
|
576
|
-
flushDelegatedEvents(outer, fallbackResult.events, {
|
|
577
|
-
contextWindow: fallbackModel.contextWindow,
|
|
578
|
-
});
|
|
579
623
|
return;
|
|
580
624
|
}
|
|
581
625
|
}
|
|
@@ -56,3 +56,32 @@ export function resolveRateLimiter(store: StorePort): RateLimitPort | undefined
|
|
|
56
56
|
}
|
|
57
57
|
return createSqliteRateLimiter(store);
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/** True when the request was aborted via AbortSignal or an abort-shaped error. */
|
|
61
|
+
export function isAbortError(
|
|
62
|
+
error: unknown,
|
|
63
|
+
options?: { signal?: AbortSignal },
|
|
64
|
+
): boolean {
|
|
65
|
+
if (options?.signal?.aborted) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
if (error instanceof Error) {
|
|
72
|
+
if (error.name === 'AbortError') {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
if (error.message === 'Request was aborted') {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Throw if `options.signal` is already aborted. */
|
|
83
|
+
export function throwIfAborted(options?: { signal?: AbortSignal }): void {
|
|
84
|
+
if (options?.signal?.aborted) {
|
|
85
|
+
throw new Error('Request was aborted');
|
|
86
|
+
}
|
|
87
|
+
}
|
package/README.md
CHANGED
|
@@ -383,6 +383,8 @@ When a **planning** turn would route primary inference to frontier while a warm
|
|
|
383
383
|
2. **Pi extension** (`.pi/extensions/smart-router`) runs an ephemeral frontier sub-call with compressed context (tool execution history excluded by default), injects the result as an observation user message, then delegates **primary** streaming to the pinned economical model.
|
|
384
384
|
3. **Fallback** — when delegate is disabled, spawn fails, or the delegate model is missing from the registry, the extension falls back to a **direct frontier** route with a documented `fallback_reason` in explain/telemetry.
|
|
385
385
|
|
|
386
|
+
**Stream piping (SP-170):** Primary delegated inference **live-forwards** provider events to pi (`start` / `text_delta` / … as they arrive). The planning-delegate sub-call stays **buffered** — only the final observation text is injected into primary context; frontier tokens from the ephemeral sub-call are discarded and never reach the user-facing stream. On infra failover, a synthetic `text_delta` notice is pushed after the retry stream's `start` (no mutation of a buffered event array).
|
|
387
|
+
|
|
386
388
|
| Knob | Env var | Default | Effect |
|
|
387
389
|
|------|---------|---------|--------|
|
|
388
390
|
| Delegate enabled | `SMART_ROUTER_PLANNING_DELEGATE_ENABLED` | `true` | When `false`, SAAR buffer allows direct frontier planning (`planning_direct_frontier` + `planning_delegate_disabled`) |
|
|
@@ -580,6 +582,8 @@ Exit code `2` signals regression above threshold — operators can wire this int
|
|
|
580
582
|
|
|
581
583
|
The embedding matcher uses `@huggingface/transformers` with ONNX models (384-dim embeddings). Artifacts are downloaded at runtime and cached under `.pi-smart-router/models/` (configurable via `hydra.artifact_cache_path`). This directory is gitignored.
|
|
582
584
|
|
|
585
|
+
**Abort / cancel limitation (SP-171):** `AbortSignal` is checked at phase boundaries before fleet refresh, HyDRA/dispatch, planning delegate, and each failover iteration. Mid-ONNX embedding inference cannot be cancelled — abort is fail-fast only before or after that stage, not during an in-flight ONNX run.
|
|
586
|
+
|
|
583
587
|
| Encoder | Model | Context | Default |
|
|
584
588
|
|---------|-------|---------|---------|
|
|
585
589
|
| `minilm` | `Xenova/all-MiniLM-L6-v2` | 512 tokens | yes |
|
|
@@ -729,7 +733,8 @@ Contributors must run `npm run build` before publishing or consuming the library
|
|
|
729
733
|
| Script | Purpose |
|
|
730
734
|
|--------|---------|
|
|
731
735
|
| `npm run build` | Compile library to `dist/` (`tsc --project tsconfig.build.json`) |
|
|
732
|
-
| `npm run release:check` | Pre-release gate: `verify:ci` + consumer pack
|
|
736
|
+
| `npm run release:check` | Pre-release gate: `verify:ci` + consumer pack + Tier 0 functional smoke |
|
|
737
|
+
| `npm run release:functional-smoke` | Tier 0 functional smoke: calibration verify (`--skip-embed`), benchmark profiles, release gate assertions |
|
|
733
738
|
| `npm run release:consumer-pack` | Pack tarball and verify production dependencies resolve (catches missing runtime deps) |
|
|
734
739
|
| `npm run verify:ci` | Full CI parity: build, typecheck, lint, test, coverage |
|
|
735
740
|
| `npm run typecheck` | TypeScript strict mode check (`tsc --noEmit`) |
|
|
@@ -794,10 +799,29 @@ npm run routing:verify-benchmark-profiles
|
|
|
794
799
|
|
|
795
800
|
Tag-triggered publish via GitHub Actions (requires `NPMSECRET` repository secret). pi.dev gallery listing syncs automatically from npm (`pi-package` keyword); no separate submit step.
|
|
796
801
|
|
|
797
|
-
|
|
802
|
+
**Tier 0 functional smoke** (`release:functional-smoke`) runs before tag publish and chains:
|
|
803
|
+
|
|
804
|
+
1. `routing:verify-calibration --skip-embed` — artifact shape + triage benchmark gates (no ONNX embedding)
|
|
805
|
+
2. `routing:verify-benchmark-profiles` — checked-in capability profiles match fixture ingest
|
|
806
|
+
3. `assert-release-gates --fixtures tests/eval/fixtures --baseline-version 0.6.0` — eval harness aggregate metrics vs `config/release-gates.json` and semver baseline regression vs `tests/eval/baselines/v0.6.0.json`
|
|
807
|
+
|
|
808
|
+
`release:check` runs the full pre-release path: `verify:ci`, consumer pack verify, then Tier 0 functional smoke.
|
|
809
|
+
|
|
810
|
+
**Baseline re-capture (post-tag):** after shipping a new semver (e.g. v0.7.0), freeze harness metrics for the next regression reference:
|
|
811
|
+
|
|
812
|
+
```bash
|
|
813
|
+
# Capture aggregate metrics from current fixtures (writes tests/eval/baselines/v0.7.0.json)
|
|
814
|
+
npm run routing:capture-baseline -- --version 0.7.0
|
|
815
|
+
|
|
816
|
+
# Point release gates at the new reference (config/release-gates.json + release:functional-smoke --baseline-version)
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
Commit the new baseline JSON and update `baseline_regression.reference_version` in `config/release-gates.json` plus the `--baseline-version` flag in `release:functional-smoke`. Re-run `npm run release:check` before tagging the next release.
|
|
820
|
+
|
|
821
|
+
1. `npm run release:check` (CI parity + consumer pack + Tier 0 functional smoke)
|
|
798
822
|
2. `npm version 0.1.1` (creates commit + `v0.1.1` tag)
|
|
799
823
|
3. `git push && git push --tags`
|
|
800
|
-
4. Actions → **Release** runs pack smoke, consumer pack verify, `npm publish`, and creates a GitHub Release
|
|
824
|
+
4. Actions → **Release** runs pack smoke, consumer pack verify, Tier 0 functional smoke, `npm publish`, and creates a GitHub Release
|
|
801
825
|
|
|
802
826
|
Re-publish a failed release: Actions → Release → Run workflow with existing tag (e.g. `v0.1.1`).
|
|
803
827
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"absolute_gates": {
|
|
4
|
+
"mean_capability_adequacy_rate_min": 0.85,
|
|
5
|
+
"mean_quality_retention_min": 0.7,
|
|
6
|
+
"mean_over_routing_rate_max": 0.15,
|
|
7
|
+
"mean_pin_preserved_rate_min": 0.6
|
|
8
|
+
},
|
|
9
|
+
"baseline_regression": {
|
|
10
|
+
"reference_version": "0.6.0",
|
|
11
|
+
"max_quality_retention_drop": 0.05,
|
|
12
|
+
"max_capability_adequacy_rate_drop": 0.05,
|
|
13
|
+
"max_pin_preserved_rate_drop": 0.05,
|
|
14
|
+
"max_over_routing_rate_increase": 0.05
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -16,6 +16,8 @@ export interface LitellmFetchResult {
|
|
|
16
16
|
export interface LitellmFetchDeps {
|
|
17
17
|
readonly fetchFn?: typeof fetch;
|
|
18
18
|
readonly pricingUrl?: string;
|
|
19
|
+
/** When set, aborts the LiteLLM pricing HTTP request (ESC / ctx.signal). */
|
|
20
|
+
readonly signal?: AbortSignal;
|
|
19
21
|
}
|
|
20
22
|
export declare class LitellmFetchError extends Error {
|
|
21
23
|
readonly name = "LitellmFetchError";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"litellm-fetch.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/pricing/litellm-fetch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAE7E,eAAO,MAAM,2BAA2B,gGACuD,CAAC;AAIhG,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC7D,QAAQ,CAAC,wBAAwB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC;IACzE,+EAA+E;IAC/E,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IAChC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"litellm-fetch.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/pricing/litellm-fetch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAE7E,eAAO,MAAM,2BAA2B,gGACuD,CAAC;AAIhG,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC7D,QAAQ,CAAC,wBAAwB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC;IACzE,+EAA+E;IAC/E,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IAChC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AAkBD,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,SAAkB,IAAI,uBAAuB;CAC9C;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAGjF;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,iBAAiB,EAAE,MAAM,EACzB,kBAAkB,EAAE,MAAM,GACzB,MAAM,CAIR;AA4CD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,GAAG,kBAAkB,CA8DxE;AAED,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC;IAC/B,sEAAsE;IACtE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;GAEG;AACH,wBAAsB,wBAAwB,CAC5C,IAAI,GAAE,gBAAqB,GAC1B,OAAO,CAAC,yBAAyB,CAAC,CA4DpC"}
|
|
@@ -7,6 +7,20 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export const DEFAULT_LITELLM_PRICING_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
|
|
9
9
|
const CHAT_MODES = new Set(['chat', 'completion']);
|
|
10
|
+
function throwIfAborted(signal) {
|
|
11
|
+
if (signal?.aborted) {
|
|
12
|
+
const reason = signal.reason;
|
|
13
|
+
if (reason instanceof Error) {
|
|
14
|
+
throw reason;
|
|
15
|
+
}
|
|
16
|
+
const error = new Error(typeof reason === 'string' && reason.length > 0 ? reason : 'Aborted');
|
|
17
|
+
error.name = 'AbortError';
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function isAbortError(error) {
|
|
22
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
23
|
+
}
|
|
10
24
|
export class LitellmFetchError extends Error {
|
|
11
25
|
name = 'LitellmFetchError';
|
|
12
26
|
}
|
|
@@ -112,14 +126,23 @@ export function normalizeLitellmPricing(raw) {
|
|
|
112
126
|
export async function fetchLitellmPriceCatalog(deps = {}) {
|
|
113
127
|
const fetchFn = deps.fetchFn ?? fetch;
|
|
114
128
|
const url = deps.pricingUrl ?? getLitellmPricingUrl();
|
|
129
|
+
const { signal } = deps;
|
|
130
|
+
throwIfAborted(signal);
|
|
115
131
|
let response;
|
|
116
132
|
try {
|
|
117
|
-
response = await fetchFn(url);
|
|
133
|
+
response = signal ? await fetchFn(url, { signal }) : await fetchFn(url);
|
|
118
134
|
}
|
|
119
135
|
catch (error) {
|
|
136
|
+
if (isAbortError(error) || signal?.aborted) {
|
|
137
|
+
throwIfAborted(signal);
|
|
138
|
+
if (isAbortError(error)) {
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
120
142
|
const detail = error instanceof Error ? error.message : String(error);
|
|
121
143
|
throw new LitellmFetchError(`Failed to fetch LiteLLM pricing from ${url}: ${detail}`);
|
|
122
144
|
}
|
|
145
|
+
throwIfAborted(signal);
|
|
123
146
|
if (!response.ok) {
|
|
124
147
|
throw new LitellmFetchError(`LiteLLM pricing fetch failed (${response.status} ${response.statusText}) from ${url}`);
|
|
125
148
|
}
|
|
@@ -127,9 +150,16 @@ export async function fetchLitellmPriceCatalog(deps = {}) {
|
|
|
127
150
|
try {
|
|
128
151
|
raw = await response.json();
|
|
129
152
|
}
|
|
130
|
-
catch {
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (isAbortError(error) || signal?.aborted) {
|
|
155
|
+
throwIfAborted(signal);
|
|
156
|
+
if (isAbortError(error)) {
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
131
160
|
throw new LitellmFetchError(`LiteLLM pricing response from ${url} is not valid JSON.`);
|
|
132
161
|
}
|
|
162
|
+
throwIfAborted(signal);
|
|
133
163
|
const normalized = normalizeLitellmPricing(raw);
|
|
134
164
|
return {
|
|
135
165
|
catalog: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"litellm-fetch.js","sourceRoot":"","sources":["../../../src/infrastructure/pricing/litellm-fetch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,CAAC,MAAM,2BAA2B,GACtC,6FAA6F,CAAC;AAEhG,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"litellm-fetch.js","sourceRoot":"","sources":["../../../src/infrastructure/pricing/litellm-fetch.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-smart-router",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Auto-model router middleware for the pi.dev coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,10 +51,12 @@
|
|
|
51
51
|
"scripts": {
|
|
52
52
|
"build": "tsc --project tsconfig.build.json",
|
|
53
53
|
"prepublishOnly": "npm run release:check",
|
|
54
|
-
"release:check": "npm run verify:ci && npm run release:consumer-pack",
|
|
54
|
+
"release:check": "npm run verify:ci && npm run release:consumer-pack && npm run release:functional-smoke",
|
|
55
55
|
"release:consumer-pack": "bash scripts/verify-consumer-pack.sh",
|
|
56
|
+
"release:functional-smoke": "npm run routing:verify-calibration -- --skip-embed && npm run routing:verify-benchmark-profiles && tsx scripts/eval/assert-release-gates.ts --fixtures tests/eval/fixtures --baseline-version 0.6.0",
|
|
56
57
|
"typecheck": "tsc --noEmit",
|
|
57
58
|
"test": "vitest run",
|
|
59
|
+
"test:release": "vitest run --testNamePattern '@release'",
|
|
58
60
|
"coverage:check": "vitest run --coverage",
|
|
59
61
|
"lint": "eslint . --ext .ts && node --input-type=module -e \"import { readFileSync } from 'node:fs'; import { parse } from 'yaml'; const doc = parse(readFileSync('config/models.yaml.example', 'utf8')); if (!Array.isArray(doc.models) || doc.models.length < 3) throw new Error('models.yaml.example must list at least one model per tier'); const tiers = new Set(doc.models.map((m) => m.tier)); for (const tier of ['zero-tier', 'economical-cloud', 'frontier-cloud']) { if (!tiers.has(tier)) throw new Error('missing tier: ' + tier); } console.log('models.yaml.example: valid');\"",
|
|
60
62
|
"routing:bootstrap-centroids": "node --experimental-strip-types scripts/bootstrap-routing-centroids.ts",
|
|
@@ -62,10 +64,11 @@
|
|
|
62
64
|
"routing:train-calibration": "node --experimental-strip-types scripts/train-routing-calibration.ts",
|
|
63
65
|
"routing:ingest-benchmarks": "tsx scripts/ingest-benchmark-profiles.ts",
|
|
64
66
|
"routing:verify-benchmark-profiles": "vitest run tests/unit/ingest-benchmark-profiles.test.ts -t \"checked-in artifact matches fixture ingest\"",
|
|
65
|
-
"routing:verify-calibration": "npm run build &&
|
|
67
|
+
"routing:verify-calibration": "bash -c 'if [[ \" $* \" == *\" --skip-embed \"* ]]; then vitest run tests/unit/train-routing-calibration.test.ts -t \"verifyRoutingCalibration passes|evaluates benchmark triage gates\"; else npm run build && tsx scripts/verify-routing-calibration.ts \"$@\"; fi' --",
|
|
66
68
|
"routing:eval-replay": "tsx scripts/eval/counterfactual-replay.ts",
|
|
67
69
|
"routing:eval-harness": "tsx scripts/eval/run-harness.ts",
|
|
68
70
|
"routing:eval-harness:smoke": "tsx scripts/eval/run-harness.ts --summary-only",
|
|
71
|
+
"routing:capture-baseline": "tsx scripts/eval/capture-baseline.ts",
|
|
69
72
|
"routing:test-projection": "npm run build && node --experimental-strip-types scripts/test-hydra-projection.ts",
|
|
70
73
|
"benchmark:encoder": "tsx scripts/benchmark-encoder-latency.ts",
|
|
71
74
|
"verify:ci": "npm run build && npm run typecheck && npm run lint && npm test && npm run coverage:check"
|
|
@@ -23,6 +23,24 @@ export interface LitellmFetchResult {
|
|
|
23
23
|
export interface LitellmFetchDeps {
|
|
24
24
|
readonly fetchFn?: typeof fetch;
|
|
25
25
|
readonly pricingUrl?: string;
|
|
26
|
+
/** When set, aborts the LiteLLM pricing HTTP request (ESC / ctx.signal). */
|
|
27
|
+
readonly signal?: AbortSignal;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
31
|
+
if (signal?.aborted) {
|
|
32
|
+
const reason = signal.reason;
|
|
33
|
+
if (reason instanceof Error) {
|
|
34
|
+
throw reason;
|
|
35
|
+
}
|
|
36
|
+
const error = new Error(typeof reason === 'string' && reason.length > 0 ? reason : 'Aborted');
|
|
37
|
+
error.name = 'AbortError';
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isAbortError(error: unknown): boolean {
|
|
43
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
26
44
|
}
|
|
27
45
|
|
|
28
46
|
export class LitellmFetchError extends Error {
|
|
@@ -174,17 +192,28 @@ export async function fetchLitellmPriceCatalog(
|
|
|
174
192
|
): Promise<LitellmPriceCatalogResult> {
|
|
175
193
|
const fetchFn = deps.fetchFn ?? fetch;
|
|
176
194
|
const url = deps.pricingUrl ?? getLitellmPricingUrl();
|
|
195
|
+
const { signal } = deps;
|
|
196
|
+
|
|
197
|
+
throwIfAborted(signal);
|
|
177
198
|
|
|
178
199
|
let response: Response;
|
|
179
200
|
try {
|
|
180
|
-
response = await fetchFn(url);
|
|
201
|
+
response = signal ? await fetchFn(url, { signal }) : await fetchFn(url);
|
|
181
202
|
} catch (error) {
|
|
203
|
+
if (isAbortError(error) || signal?.aborted) {
|
|
204
|
+
throwIfAborted(signal);
|
|
205
|
+
if (isAbortError(error)) {
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
182
209
|
const detail = error instanceof Error ? error.message : String(error);
|
|
183
210
|
throw new LitellmFetchError(
|
|
184
211
|
`Failed to fetch LiteLLM pricing from ${url}: ${detail}`,
|
|
185
212
|
);
|
|
186
213
|
}
|
|
187
214
|
|
|
215
|
+
throwIfAborted(signal);
|
|
216
|
+
|
|
188
217
|
if (!response.ok) {
|
|
189
218
|
throw new LitellmFetchError(
|
|
190
219
|
`LiteLLM pricing fetch failed (${response.status} ${response.statusText}) from ${url}`,
|
|
@@ -194,12 +223,20 @@ export async function fetchLitellmPriceCatalog(
|
|
|
194
223
|
let raw: unknown;
|
|
195
224
|
try {
|
|
196
225
|
raw = await response.json();
|
|
197
|
-
} catch {
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (isAbortError(error) || signal?.aborted) {
|
|
228
|
+
throwIfAborted(signal);
|
|
229
|
+
if (isAbortError(error)) {
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
198
233
|
throw new LitellmFetchError(
|
|
199
234
|
`LiteLLM pricing response from ${url} is not valid JSON.`,
|
|
200
235
|
);
|
|
201
236
|
}
|
|
202
237
|
|
|
238
|
+
throwIfAborted(signal);
|
|
239
|
+
|
|
203
240
|
const normalized = normalizeLitellmPricing(raw);
|
|
204
241
|
|
|
205
242
|
return {
|