pi-smart-router 0.6.0 → 0.7.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.
@@ -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
- const { modelCount, lastUpdated } = await refreshPricingCatalog(runtime);
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
- if (options?.signal?.aborted) {
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
- if (options?.signal?.aborted) {
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 async function delegateWithOutcome(
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
- sessionId: string | undefined,
69
- headroomContext?: DelegationHeadroomContext,
70
- ): Promise<DelegatedStreamResult> {
71
- const delegationContext = buildDelegationContext(
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 result = await collectDelegatedStream(
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 result;
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 reason = errorObj?.message || errorObj?.code || 'Unavailable';
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++) {
@@ -4,8 +4,8 @@ import {
4
4
  type ExtensionAPI,
5
5
  } from '@earendil-works/pi-coding-agent';
6
6
 
7
+ import { resolveOperatorConfigFromEnv } from '../../../src/config/defaults.js';
7
8
  import { ExecutionLedger } from '../../../src/domain/delegation/execution-ledger.js';
8
- import { SessionPinner } from '../../../src/domain/pinning/session-pinner.js';
9
9
  import type { SessionRoutingSnapshot } from '../../../src/infrastructure/telemetry/outcome-recorder.js';
10
10
  import { createRouterFromFleet, LifecycleHookState } from '../../../src/index.js';
11
11
 
@@ -14,7 +14,11 @@ import {
14
14
  createExtensionDatasetRecorder,
15
15
  createExtensionOutcomeRecorder,
16
16
  } from './dataset-export.js';
17
- import { createDispatchOptions, initHydraMatcher } from './fleet-bootstrap.js';
17
+ import {
18
+ createDispatchOptions,
19
+ createOperatorAwareSessionPinner,
20
+ initHydraMatcher,
21
+ } from './fleet-bootstrap.js';
18
22
  import { setupSessionHooks } from './session-lifecycle.js';
19
23
  import { createStreamSimple } from './stream-delegation.js';
20
24
  import type { SmartRouterRuntime } from './types.js';
@@ -35,7 +39,8 @@ export async function createSmartRouterRuntime(cwd: string): Promise<{
35
39
  const modelRegistry = ModelRegistry.inMemory(authStorage);
36
40
  const hydraMatcher = await initHydraMatcher();
37
41
  const store = createExtensionStore(cwd);
38
- const sessionPinner = new SessionPinner({ store });
42
+ const operatorConfig = resolveOperatorConfigFromEnv();
43
+ const sessionPinner = createOperatorAwareSessionPinner(store, operatorConfig);
39
44
  const executionLedger = new ExecutionLedger();
40
45
  const lifecycleHookState = new LifecycleHookState();
41
46
  const datasetNotify: DatasetNotify = {
@@ -62,7 +67,9 @@ export async function createSmartRouterRuntime(cwd: string): Promise<{
62
67
  sessionRouting,
63
68
  streamDeps: {
64
69
  router: createRouterFromFleet([], {
65
- ...createDispatchOptions(store, sessionPinner, hydraMatcher),
70
+ ...createDispatchOptions(store, sessionPinner, hydraMatcher, {
71
+ operatorConfig,
72
+ }),
66
73
  lifecycleHookState,
67
74
  }),
68
75
  modelRegistry,
@@ -6,13 +6,18 @@ import {
6
6
  } from '@earendil-works/pi-coding-agent';
7
7
 
8
8
  import { mapFleetFromRegistry } from '../../../src/config/pi-model-mapper.js';
9
- import { DEFAULT_OPERATOR_CONFIG } from '../../../src/config/defaults.js';
9
+ import {
10
+ DEFAULT_OPERATOR_CONFIG,
11
+ resolveOperatorConfigFromEnv,
12
+ } from '../../../src/config/defaults.js';
10
13
  import {
11
14
  HydraMatcher,
12
15
  createOnnxEmbeddingProvider,
13
16
  } from '../../../src/domain/matching/hydra-matcher.js';
14
17
  import { SessionPinner } from '../../../src/domain/pinning/session-pinner.js';
15
18
  import type { ModelProfile, PriceCatalog } from '../../../src/domain/types/index.js';
19
+ import type { QuotaWindowPosition } from '../../../src/domain/types/entities.js';
20
+ import type { OperatorConfig } from '../../../src/domain/types/schemas.js';
16
21
  import type { StorePort } from '../../../src/domain/types/store-port.js';
17
22
  import { getDefaultSystemInfo } from '../../../src/infrastructure/hardware/hardware-probe.js';
18
23
  import { DEFAULT_LOCAL_CONFIG } from '../../../src/infrastructure/local/local-zero-tier.js';
@@ -27,6 +32,16 @@ import { resolveModelScope } from './pi-model-scope.js';
27
32
  import type { FleetMode, SmartRouterRuntime } from './types.js';
28
33
  import { resolveRateLimiter } from './utils.js';
29
34
 
35
+ /** Optional overrides for extension dispatch wiring (SP-173). */
36
+ export interface CreateDispatchOptionsExtras {
37
+ /** Base operator config before env merge; defaults to DEFAULT_OPERATOR_CONFIG. */
38
+ readonly operatorConfig?: OperatorConfig;
39
+ /** Live price catalog when fleet discovery has loaded one. */
40
+ readonly priceCatalog?: PriceCatalog | null;
41
+ /** Rolling subscription quota position when available. */
42
+ readonly quotaWindowPosition?: QuotaWindowPosition;
43
+ }
44
+
30
45
  /** Minimal settings surface used for scoped fleet discovery. */
31
46
  export interface ScopedSettingsReader {
32
47
  getEnabledModels(): string[] | null | undefined;
@@ -152,26 +167,58 @@ export function createDispatchOptions(
152
167
  store: StorePort,
153
168
  sessionPinner: SessionPinner,
154
169
  hydraMatcher?: HydraMatcher,
170
+ extras?: CreateDispatchOptionsExtras,
155
171
  ): GatewayDispatchOptions {
172
+ const operatorConfig = resolveOperatorConfigFromEnv(
173
+ extras?.operatorConfig ?? DEFAULT_OPERATOR_CONFIG,
174
+ );
156
175
  const telemetryEmitter = new RoutingTelemetryEmitter({
157
176
  onRecord: (record) => {
158
177
  store.appendTelemetry(record);
159
178
  },
179
+ sessionPinner,
180
+ saarConfig: operatorConfig.saar,
181
+ ...(extras?.priceCatalog !== undefined ? { priceCatalog: extras.priceCatalog } : {}),
182
+ ...(extras?.quotaWindowPosition !== undefined
183
+ ? { quotaWindowPosition: extras.quotaWindowPosition }
184
+ : {}),
160
185
  });
161
186
  const rateLimiter = resolveRateLimiter(store);
162
187
 
163
188
  return {
164
189
  sessionPinner,
165
- hardwareConfig: DEFAULT_OPERATOR_CONFIG.local,
190
+ hardwareConfig: operatorConfig.local,
166
191
  systemInfoProvider: getDefaultSystemInfo,
167
192
  localConfig: DEFAULT_LOCAL_CONFIG,
168
- loopEscalationConfig: DEFAULT_OPERATOR_CONFIG.loop_escalation,
193
+ loopEscalationConfig: operatorConfig.loop_escalation,
194
+ saarConfig: operatorConfig.saar,
195
+ planningDelegateConfig: operatorConfig.planning_delegate,
196
+ pinOnlyFallback: operatorConfig.pin_only_fallback,
197
+ ...(extras?.priceCatalog !== undefined ? { priceCatalog: extras.priceCatalog } : {}),
198
+ ...(extras?.quotaWindowPosition !== undefined
199
+ ? { quotaWindowPosition: extras.quotaWindowPosition }
200
+ : {}),
169
201
  ...(hydraMatcher ? { hydraMatcher } : {}),
170
202
  ...(rateLimiter ? { rateLimiter } : {}),
171
203
  telemetryEmitter,
172
204
  };
173
205
  }
174
206
 
207
+ /**
208
+ * Build a SessionPinner wired with operator SAAR / pin-only settings (SP-173).
209
+ * No operator-config.json loader exists yet — env + optional base config only.
210
+ */
211
+ export function createOperatorAwareSessionPinner(
212
+ store: StorePort,
213
+ operatorConfig: OperatorConfig = resolveOperatorConfigFromEnv(),
214
+ ): SessionPinner {
215
+ return new SessionPinner({
216
+ store,
217
+ saarConfig: operatorConfig.saar,
218
+ pinOnlyFallback: operatorConfig.pin_only_fallback,
219
+ });
220
+ }
221
+
175
222
  export async function rebuildFleet(
176
223
  runtime: SmartRouterRuntime,
177
224
  pi: ExtensionAPI,
@@ -189,7 +236,9 @@ export async function rebuildFleet(
189
236
  runtime.priceCatalog = catalog;
190
237
  runtime.fleetScopeFingerprint = fingerprint;
191
238
  const router = createRouterFromFleet(fleet, {
192
- ...createDispatchOptions(runtime.store, runtime.sessionPinner, runtime.hydraMatcher),
239
+ ...createDispatchOptions(runtime.store, runtime.sessionPinner, runtime.hydraMatcher, {
240
+ priceCatalog: catalog,
241
+ }),
193
242
  lifecycleHookState: runtime.lifecycleHookState,
194
243
  });
195
244
  router.register(createHooksAdapter(pi));
@@ -24,6 +24,7 @@ import {
24
24
  } from './dataset-export.js';
25
25
  import {
26
26
  createDispatchOptions,
27
+ createOperatorAwareSessionPinner,
27
28
  discoverFleet,
28
29
  formatLmuStatus,
29
30
  initHydraMatcher,
@@ -62,6 +63,7 @@ export {
62
63
  createDispatchOptions,
63
64
  createExtensionDatasetRecorder,
64
65
  createExtensionOutcomeRecorder,
66
+ createOperatorAwareSessionPinner,
65
67
  createSmartRouterRuntime,
66
68
  createStreamSimple,
67
69
  deriveTurnType,
@@ -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