pi-smart-router 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.pi/extensions/smart-router/delegation-runtime.ts +12 -2
  2. package/README.md +55 -17
  3. package/dist/api/explain/router-explain.d.ts +4 -1
  4. package/dist/api/explain/router-explain.d.ts.map +1 -1
  5. package/dist/api/explain/router-explain.js +9 -9
  6. package/dist/api/explain/router-explain.js.map +1 -1
  7. package/dist/config/defaults.d.ts +4 -1
  8. package/dist/config/defaults.d.ts.map +1 -1
  9. package/dist/config/defaults.js +10 -0
  10. package/dist/config/defaults.js.map +1 -1
  11. package/dist/domain/delegation/delegation-context.d.ts +23 -1
  12. package/dist/domain/delegation/delegation-context.d.ts.map +1 -1
  13. package/dist/domain/delegation/delegation-context.js +93 -0
  14. package/dist/domain/delegation/delegation-context.js.map +1 -1
  15. package/dist/domain/pinning/cache-breakeven.d.ts +51 -0
  16. package/dist/domain/pinning/cache-breakeven.d.ts.map +1 -0
  17. package/dist/domain/pinning/cache-breakeven.js +124 -0
  18. package/dist/domain/pinning/cache-breakeven.js.map +1 -0
  19. package/dist/domain/pinning/saar-session-state.d.ts +36 -0
  20. package/dist/domain/pinning/saar-session-state.d.ts.map +1 -0
  21. package/dist/domain/pinning/saar-session-state.js +74 -0
  22. package/dist/domain/pinning/saar-session-state.js.map +1 -0
  23. package/dist/domain/pinning/session-pinner.d.ts +35 -3
  24. package/dist/domain/pinning/session-pinner.d.ts.map +1 -1
  25. package/dist/domain/pinning/session-pinner.js +149 -1
  26. package/dist/domain/pinning/session-pinner.js.map +1 -1
  27. package/dist/domain/pipeline/router-pipeline.d.ts +15 -1
  28. package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -1
  29. package/dist/domain/pipeline/router-pipeline.js +118 -6
  30. package/dist/domain/pipeline/router-pipeline.js.map +1 -1
  31. package/dist/domain/routing/tool-history-guard.d.ts +21 -5
  32. package/dist/domain/routing/tool-history-guard.d.ts.map +1 -1
  33. package/dist/domain/routing/tool-history-guard.js +77 -10
  34. package/dist/domain/routing/tool-history-guard.js.map +1 -1
  35. package/dist/domain/types/entities.d.ts +48 -0
  36. package/dist/domain/types/entities.d.ts.map +1 -1
  37. package/dist/domain/types/index.d.ts +1 -1
  38. package/dist/domain/types/index.d.ts.map +1 -1
  39. package/dist/domain/types/schemas.d.ts +24 -0
  40. package/dist/domain/types/schemas.d.ts.map +1 -1
  41. package/dist/domain/types/schemas.js +54 -0
  42. package/dist/domain/types/schemas.js.map +1 -1
  43. package/dist/infra/gemini-provider.d.ts.map +1 -1
  44. package/dist/infra/gemini-provider.js +2 -1
  45. package/dist/infra/gemini-provider.js.map +1 -1
  46. package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -1
  47. package/dist/infrastructure/persistence/sqlite-store.js +3 -0
  48. package/dist/infrastructure/persistence/sqlite-store.js.map +1 -1
  49. package/dist/infrastructure/telemetry/routing-telemetry.d.ts +30 -2
  50. package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -1
  51. package/dist/infrastructure/telemetry/routing-telemetry.js +221 -3
  52. package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -1
  53. package/package.json +5 -4
  54. package/src/api/explain/router-explain.ts +14 -10
  55. package/src/config/defaults.ts +18 -1
  56. package/src/domain/delegation/delegation-context.ts +124 -0
  57. package/src/domain/pinning/cache-breakeven.ts +219 -0
  58. package/src/domain/pinning/saar-session-state.ts +93 -0
  59. package/src/domain/pinning/session-pinner.ts +244 -3
  60. package/src/domain/pipeline/router-pipeline.ts +159 -7
  61. package/src/domain/routing/tool-history-guard.ts +108 -10
  62. package/src/domain/types/entities.ts +54 -0
  63. package/src/domain/types/index.ts +4 -0
  64. package/src/domain/types/schemas.ts +69 -0
  65. package/src/infra/gemini-provider.ts +2 -1
  66. package/src/infrastructure/persistence/sqlite-store.ts +6 -0
  67. package/src/infrastructure/telemetry/routing-telemetry.ts +327 -7
@@ -9,7 +9,16 @@
9
9
  * Any stage failure falls back to safeCloudDefault(); never throws to host.
10
10
  */
11
11
 
12
- import type { ModelProfile, PriceCatalog, RoutingDecision, RoutingFeatureSidecar, RoutingRequest, Tier, CandidateScore } from '../types/index.js';
12
+ import type {
13
+ ModelProfile,
14
+ PriceCatalog,
15
+ RoutingDecision,
16
+ RoutingFeatureSidecar,
17
+ RoutingRequest,
18
+ SaarConfig,
19
+ Tier,
20
+ CandidateScore,
21
+ } from '../types/index.js';
13
22
  import type { LowIntensityConfig } from '../types/schemas.js';
14
23
  import type { HardwareProbeConfig, HardwareProbeResult, SystemInfo } from '../../infrastructure/hardware/hardware-probe.js';
15
24
  import type { HttpFetchPort, LocalZeroTierConfig } from '../../infrastructure/local/local-zero-tier.js';
@@ -20,8 +29,8 @@ import type { TriageResult, TriageVerdict } from '../triage/triage-engine.js';
20
29
  import { classifyTurnEnvelope } from '../triage/turn-envelope.js';
21
30
  import { safeCloudDefault } from './safe-default.js';
22
31
  import {
23
- hasToolCallHistory,
24
32
  isGoogleGeminiProfile,
33
+ sessionHasGoogleReplayRiskForDeprioritize,
25
34
  } from '../routing/tool-history-guard.js';
26
35
  import {
27
36
  filterFleetByContextFit,
@@ -31,6 +40,7 @@ import {
31
40
  type ContextFitConfig,
32
41
  } from '../routing/context-fit.js';
33
42
  import type { SessionPinner } from '../pinning/session-pinner.js';
43
+ import { evaluateModelSwitchBreakeven } from '../pinning/session-pinner.js';
34
44
  import { evaluateLoopEscalation } from '../pinning/loop-escalation.js';
35
45
  import type { LoopEscalationConfig } from '../pinning/loop-escalation.js';
36
46
  import { selectLowestCostModel } from '../pinning/sub-route-policy.js';
@@ -158,6 +168,8 @@ export interface PipelineOptions {
158
168
  /** Preloaded P(success) weights for tests; lazy-loads artifact when omitted (SP-105). */
159
169
  readonly pSuccessWeights?: PSuccessWeights;
160
170
  readonly pSuccessWeightsPath?: string;
171
+ /** SAAR pin policy (SP-123). Must match sessionPinner.saarConfig when enabled. */
172
+ readonly saarConfig?: SaarConfig;
161
173
  }
162
174
 
163
175
  // ─── Orchestrator ────────────────────────────────────────────────────────────
@@ -191,6 +203,8 @@ export class RouterPipeline {
191
203
  private currentContextFitViableCount = 0;
192
204
  private contextOverflowPreferredProvider: string | null = null;
193
205
  private contextOverflowTriggered = false;
206
+ /** Internal breakeven gate reason for SP-126 explain wiring. */
207
+ private currentBreakevenReason: string | null = null;
194
208
 
195
209
  constructor(fleet: readonly ModelProfile[], options?: PipelineOptions) {
196
210
  this.fleet = fleet;
@@ -236,6 +250,7 @@ export class RouterPipeline {
236
250
  this.currentContextFitViableCount = 0;
237
251
  this.contextOverflowPreferredProvider = null;
238
252
  this.contextOverflowTriggered = false;
253
+ this.currentBreakevenReason = null;
239
254
 
240
255
  let currentStage: NamedPipelineStage | undefined;
241
256
 
@@ -244,7 +259,7 @@ export class RouterPipeline {
244
259
  currentStage = stage;
245
260
  const result = await stage.run(request);
246
261
  if (result.decided && result.decision) {
247
- this.persistPinIfNeeded(request, result.decision);
262
+ this.finalizeRoute(request, result.decision);
248
263
  this.emitTelemetry(request, result.decision);
249
264
  return this.attachFeatures(request, result.decision);
250
265
  }
@@ -257,11 +272,12 @@ export class RouterPipeline {
257
272
  this.logPipelineError(request, failedStage, error);
258
273
  this.emitPipelineErrorTelemetry(request, failedStage, fallback);
259
274
  this.persistPinIfNeeded(request, fallback);
275
+ this.recordSaarTurnIfNeeded(request);
260
276
  return this.attachFeatures(request, fallback);
261
277
  }
262
278
 
263
279
  const fallback = this.buildFallbackDecision(request, Date.now() - start);
264
- this.persistPinIfNeeded(request, fallback);
280
+ this.finalizeRoute(request, fallback);
265
281
  this.emitTelemetry(request, fallback);
266
282
  return this.attachFeatures(request, fallback);
267
283
  }
@@ -280,7 +296,11 @@ export class RouterPipeline {
280
296
  }
281
297
 
282
298
  const messages = request.messages;
283
- if (!messages || messages.length === 0 || !hasToolCallHistory(messages)) {
299
+ if (
300
+ !messages ||
301
+ messages.length === 0 ||
302
+ !sessionHasGoogleReplayRiskForDeprioritize(request)
303
+ ) {
284
304
  return fleet;
285
305
  }
286
306
 
@@ -719,12 +739,37 @@ export class RouterPipeline {
719
739
  }
720
740
 
721
741
  const existingPin = pinner.getPin(request.session_id);
722
- const result = pinner.lookupPin(request, this.activeFleet);
742
+ const saarRequest = this.enrichRequestWithSaarCandidate(request);
743
+ const result = pinner.lookupPin(saarRequest, this.activeFleet);
723
744
 
724
745
  switch (result.action) {
725
746
  case 'use_pin': {
726
747
  const model = result.pinnedModel!;
727
748
  const pin = pinner.getPin(request.session_id);
749
+ const reasonCode =
750
+ result.saarReason === 'saar_hard_lock'
751
+ ? 'saar_hard_lock'
752
+ : result.saarReason === 'saar_tier_upgrade'
753
+ ? 'saar_tier_upgrade'
754
+ : 'session_pinned';
755
+ return {
756
+ decided: true,
757
+ stage: 'session_pin',
758
+ decision: this.withEstimatedCost(request, model, {
759
+ request_id: request.request_id,
760
+ selected_model_id: model.id,
761
+ tier: model.tier,
762
+ stage: 'session_pin',
763
+ reason_code: reasonCode,
764
+ routing_latency_ms: 0,
765
+ pin_reason: pin?.pin_reason ?? null,
766
+ }),
767
+ };
768
+ }
769
+
770
+ case 'saar_route': {
771
+ const model = result.saarRouteModel!;
772
+ const pin = pinner.getPin(request.session_id);
728
773
  return {
729
774
  decided: true,
730
775
  stage: 'session_pin',
@@ -733,7 +778,7 @@ export class RouterPipeline {
733
778
  selected_model_id: model.id,
734
779
  tier: model.tier,
735
780
  stage: 'session_pin',
736
- reason_code: 'session_pinned',
781
+ reason_code: result.saarReason ?? 'saar_buffer_active',
737
782
  routing_latency_ms: 0,
738
783
  pin_reason: pin?.pin_reason ?? null,
739
784
  }),
@@ -793,6 +838,8 @@ export class RouterPipeline {
793
838
 
794
839
  if (decision.reason_code === 'tool_result_sub_route') return;
795
840
  if (decision.reason_code === 'session_pinned') return;
841
+ if (decision.reason_code === 'saar_buffer_active') return;
842
+ if (decision.reason_code === 'saar_hard_lock') return;
796
843
 
797
844
  // Turn envelope is a per-turn tier bias — do not overwrite an existing pin (SP-064).
798
845
  if (decision.stage === 'turn_envelope' && pinner.getPin(request.session_id)) return;
@@ -800,6 +847,78 @@ export class RouterPipeline {
800
847
  pinner.recordPin(request.session_id, decision.selected_model_id, 'initial');
801
848
  }
802
849
 
850
+ /** Persist pin updates and advance SAAR turn index after each routed turn (SP-123). */
851
+ private finalizeRoute(request: RoutingRequest, decision: RoutingDecision): void {
852
+ this.persistPinIfNeeded(request, decision);
853
+ this.recordSaarTurnIfNeeded(request);
854
+ }
855
+
856
+ private recordSaarTurnIfNeeded(request: RoutingRequest): void {
857
+ this.options.sessionPinner?.recordSaarTurn(request.session_id);
858
+ }
859
+
860
+ private enrichRequestWithSaarCandidate(request: RoutingRequest): RoutingRequest {
861
+ if (request.candidate_model_id) {
862
+ return request;
863
+ }
864
+
865
+ const turnType = request.turn_type ?? classifyTurnEnvelope(request.messages);
866
+ const targetTier = RouterPipeline.TURN_TIER_MAP[turnType] ?? null;
867
+ if (!targetTier) {
868
+ return request;
869
+ }
870
+
871
+ const tierCandidates = this.activeFleet.filter(
872
+ (m) => m.tier === targetTier && m.healthy !== false,
873
+ );
874
+ const model = selectLowestCostModel(tierCandidates);
875
+ if (!model) {
876
+ return request;
877
+ }
878
+
879
+ return { ...request, candidate_model_id: model.id };
880
+ }
881
+
882
+ private shouldDeferPlanningForSaar(request: RoutingRequest): boolean {
883
+ const saarConfig = this.options.saarConfig;
884
+ const pinner = this.options.sessionPinner;
885
+ if (!saarConfig || !pinner) {
886
+ return false;
887
+ }
888
+
889
+ const pin = pinner.getPin(request.session_id);
890
+ if (!pin) {
891
+ return false;
892
+ }
893
+
894
+ const saarState = pinner.getSaarState(request.session_id);
895
+ const turnIndex = saarState?.turn_index ?? 0;
896
+ return turnIndex >= saarConfig.planning_turn_buffer;
897
+ }
898
+
899
+ /**
900
+ * SP-123: SAAR planning buffer explicitly allows frontier planning turns
901
+ * without breakeven gating (#73 composes with buffer, not replaces it).
902
+ */
903
+ private isSaarPlanningBufferActive(request: RoutingRequest): boolean {
904
+ const saarConfig = this.options.saarConfig;
905
+ const pinner = this.options.sessionPinner;
906
+ if (!saarConfig || !pinner) {
907
+ return false;
908
+ }
909
+
910
+ if (!pinner.getPin(request.session_id)) {
911
+ return false;
912
+ }
913
+
914
+ const saarState = pinner.getSaarState(request.session_id);
915
+ if (!saarState) {
916
+ return false;
917
+ }
918
+
919
+ return saarState.turn_index < saarConfig.planning_turn_buffer;
920
+ }
921
+
803
922
  // ─── Turn envelope stage (Step 2b, <2ms budget) ─────────────────────────
804
923
 
805
924
  private static readonly TURN_TIER_MAP: Readonly<Record<string, Tier | null>> = {
@@ -818,6 +937,11 @@ export class RouterPipeline {
818
937
  return { decided: false, stage: 'turn_envelope' };
819
938
  }
820
939
 
940
+ // SP-123: post-buffer planning defers to session_pin SAAR hard-lock.
941
+ if (turnType === 'planning' && this.shouldDeferPlanningForSaar(request)) {
942
+ return { decided: false, stage: 'turn_envelope' };
943
+ }
944
+
821
945
  const tierCandidates = this.activeFleet.filter(
822
946
  (m) => m.tier === targetTier && m.healthy !== false,
823
947
  );
@@ -826,6 +950,34 @@ export class RouterPipeline {
826
950
  return { decided: false, stage: 'turn_envelope' };
827
951
  }
828
952
 
953
+ const pinner = this.options.sessionPinner;
954
+ const pin = pinner?.getPin(request.session_id) ?? null;
955
+ if (pin) {
956
+ const pinnedModel = this.activeFleet.find(
957
+ (m) => m.id === pin.pinned_model_id && m.healthy !== false,
958
+ );
959
+ if (pinnedModel && pinnedModel.id !== model.id) {
960
+ const skipBreakeven =
961
+ turnType === 'planning' && this.isSaarPlanningBufferActive(request);
962
+ if (!skipBreakeven) {
963
+ const tokenEstimate =
964
+ request.estimated_input_tokens ?? request.prompt_text.length;
965
+ const breakeven = evaluateModelSwitchBreakeven(
966
+ pinnedModel,
967
+ model,
968
+ tokenEstimate,
969
+ tokenEstimate,
970
+ this.options.saarConfig,
971
+ );
972
+ if (!breakeven.shouldSwitch) {
973
+ this.currentBreakevenReason = 'breakeven_blocked';
974
+ return { decided: false, stage: 'turn_envelope' };
975
+ }
976
+ this.currentBreakevenReason = 'breakeven_pass';
977
+ }
978
+ }
979
+ }
980
+
829
981
  return {
830
982
  decided: true,
831
983
  stage: 'turn_envelope',
@@ -1,13 +1,17 @@
1
1
  /**
2
- * Gemini tool-history guard — SP-077.
2
+ * Gemini tool-history guard — SP-077, narrowed SP-129.
3
3
  *
4
- * Excludes Google/Gemini fleet entries when session history contains prior
5
- * tool calls, avoiding thought_signature 400s until pi preserves replay
6
- * signatures upstream (earendil-works/pi#6342).
4
+ * Excludes Google/Gemini fleet entries only when session history contains
5
+ * Google-origin assistant tool calls with replay state SP-128 repair cannot fix.
6
+ * OpenAI-only tool sessions may route to economical Gemini; repairable Google
7
+ * tool history relies on delegation replay repair instead of a blunt ban.
7
8
  */
8
9
 
9
10
  import type { Message as PiMessage } from '@earendil-works/pi-ai/compat';
10
11
 
12
+ import {
13
+ isGoogleOriginAssistantMessage,
14
+ } from '../delegation/delegation-context.js';
11
15
  import type { Message as RoutingMessage, ModelProfile, RoutingRequest } from '../types/index.js';
12
16
 
13
17
  export const GEMINI_TOOL_HISTORY_EXCLUDED = 'gemini_tool_history_excluded' as const;
@@ -105,23 +109,109 @@ export function hasToolCallHistory(
105
109
  return false;
106
110
  }
107
111
 
108
- function sessionHasToolCallHistory(
112
+ /** Google-origin assistant toolCall blocks in pi-ai context (SP-127 detector). */
113
+ export function hasGoogleReplayRiskFromContext(
114
+ messages: readonly PiMessage[],
115
+ ): boolean {
116
+ for (const message of messages) {
117
+ if (message.role !== 'assistant') {
118
+ continue;
119
+ }
120
+
121
+ if (!isGoogleOriginAssistantMessage(message)) {
122
+ continue;
123
+ }
124
+
125
+ for (const block of message.content) {
126
+ if (block.type === 'toolCall') {
127
+ return true;
128
+ }
129
+ }
130
+ }
131
+
132
+ return false;
133
+ }
134
+
135
+ /**
136
+ * Routing messages lack provider/api metadata, so Google-origin replay risk
137
+ * cannot be detected reliably — defer to contextMessages in resolveEffectiveFleet.
138
+ */
139
+ export function hasGoogleReplayRisk(
140
+ messages: readonly RoutingMessage[],
141
+ ): boolean {
142
+ // Routing messages lack provider/api metadata; Google-origin detection uses contextMessages.
143
+ void messages;
144
+ return false;
145
+ }
146
+
147
+ /**
148
+ * Replay-sensitive state on Google-origin turns that SP-128 repair does not rewrite
149
+ * (redacted thinking, thinking signatures, text signatures).
150
+ */
151
+ export function hasUnrepairableGoogleReplayStateFromContext(
152
+ messages: readonly PiMessage[],
153
+ ): boolean {
154
+ for (const message of messages) {
155
+ if (message.role !== 'assistant') {
156
+ continue;
157
+ }
158
+
159
+ if (!isGoogleOriginAssistantMessage(message)) {
160
+ continue;
161
+ }
162
+
163
+ for (const block of message.content) {
164
+ if (block.type === 'thinking') {
165
+ if (block.redacted) {
166
+ return true;
167
+ }
168
+ if (block.thinkingSignature && block.thinkingSignature.length > 0) {
169
+ return true;
170
+ }
171
+ }
172
+
173
+ if (block.type === 'text' && block.textSignature && block.textSignature.length > 0) {
174
+ return true;
175
+ }
176
+ }
177
+ }
178
+
179
+ return false;
180
+ }
181
+
182
+ export function hasUnrepairableGoogleReplayRiskFromContext(
183
+ messages: readonly PiMessage[],
184
+ ): boolean {
185
+ return (
186
+ hasGoogleReplayRiskFromContext(messages) &&
187
+ hasUnrepairableGoogleReplayStateFromContext(messages)
188
+ );
189
+ }
190
+
191
+ function sessionHasGoogleReplayRisk(
109
192
  request: RoutingRequest,
110
193
  contextMessages?: readonly PiMessage[],
111
194
  ): boolean {
112
195
  if (contextMessages !== undefined && contextMessages.length > 0) {
113
- return hasToolCallHistoryFromContext(contextMessages);
196
+ return hasGoogleReplayRiskFromContext(contextMessages);
114
197
  }
115
198
 
116
- if (request.messages !== undefined && request.messages.length > 0) {
117
- return hasToolCallHistory(request.messages);
199
+ return hasGoogleReplayRisk(request.messages ?? []);
200
+ }
201
+
202
+ function sessionHasUnrepairableGoogleReplayRisk(
203
+ request: RoutingRequest,
204
+ contextMessages?: readonly PiMessage[],
205
+ ): boolean {
206
+ if (contextMessages !== undefined && contextMessages.length > 0) {
207
+ return hasUnrepairableGoogleReplayRiskFromContext(contextMessages);
118
208
  }
119
209
 
120
210
  return false;
121
211
  }
122
212
 
123
213
  /**
124
- * Apply Gemini exclusion when tool history is present.
214
+ * Apply Gemini exclusion when unrepairable Google replay risk is present.
125
215
  * Honors `force_model_id` by returning the unfiltered fleet.
126
216
  */
127
217
  export function resolveEffectiveFleet(
@@ -133,7 +223,7 @@ export function resolveEffectiveFleet(
133
223
  return { effectiveFleet: fleet, excluded: false };
134
224
  }
135
225
 
136
- if (!sessionHasToolCallHistory(request, contextMessages)) {
226
+ if (!sessionHasUnrepairableGoogleReplayRisk(request, contextMessages)) {
137
227
  return { effectiveFleet: fleet, excluded: false };
138
228
  }
139
229
 
@@ -157,3 +247,11 @@ export function resolveEffectiveFleet(
157
247
  reasonCode: GEMINI_TOOL_HISTORY_EXCLUDED,
158
248
  };
159
249
  }
250
+
251
+ /** Shared SP-129 detector for SP-080 fleet deprioritization. */
252
+ export function sessionHasGoogleReplayRiskForDeprioritize(
253
+ request: RoutingRequest,
254
+ contextMessages?: readonly PiMessage[],
255
+ ): boolean {
256
+ return sessionHasGoogleReplayRisk(request, contextMessages);
257
+ }
@@ -54,6 +54,27 @@ export interface RoutingRequest {
54
54
  readonly estimated_input_tokens?: number;
55
55
  }
56
56
 
57
+ // ─── SAAR (Session-Aware Agentic Routing, SP-121 / #72) ─────────────────────
58
+
59
+ /** Operator knobs for SAAR pin policy (config surface; behavior in SP-122+). */
60
+ export interface SaarConfig {
61
+ /** Turns 0..(buffer-1) may reach frontier without permanent pin overwrite. */
62
+ readonly planning_turn_buffer: number;
63
+ /** Weight applied to prefix-cache value when gating model switches (0–1). */
64
+ readonly prefix_cache_weight: number;
65
+ /** Idle seconds before SAAR reopens the routing decision. */
66
+ readonly idle_timeout_seconds: number;
67
+ /** Minimum routing score delta required to break a warm pin (0–1). */
68
+ readonly switch_threshold: number;
69
+ }
70
+
71
+ /** Per-session SAAR runtime state (types only; state machine in SP-122). */
72
+ export interface SaarSessionState {
73
+ readonly turn_index: number;
74
+ readonly hard_lock: boolean;
75
+ readonly last_activity_at: string;
76
+ }
77
+
57
78
  // ─── SessionPin ──────────────────────────────────────────────────────────────
58
79
 
59
80
  export interface SessionPin {
@@ -175,6 +196,26 @@ export interface LowIntensityBreakdown {
175
196
  readonly rejected_tiers: readonly RejectedTierEntry[];
176
197
  }
177
198
 
199
+ /** Cache breakeven gate observability (SP-126, #73). */
200
+ export interface BreakevenObservability {
201
+ readonly marginal_savings: number | null;
202
+ readonly future_cache_value: number | null;
203
+ readonly cache_reprime_cost: number | null;
204
+ /** `pass` when switch clears breakeven; `blocked` when gate denies. */
205
+ readonly decision: 'pass' | 'blocked' | null;
206
+ readonly breakeven_reason_code: string | null;
207
+ }
208
+
209
+ /** SAAR pin policy observability (SP-126, #72). */
210
+ export interface SaarObservability {
211
+ readonly buffer_active: boolean;
212
+ readonly hard_lock: boolean;
213
+ readonly turn_index_in_session: number | null;
214
+ readonly planning_turn_buffer: number | null;
215
+ readonly idle_timeout_seconds: number | null;
216
+ readonly saar_reason_code: string | null;
217
+ }
218
+
178
219
  /** Tier/cluster selection observability (SP-113, #62). */
179
220
  export interface TierSelectionObservability {
180
221
  readonly cluster_id: string | null;
@@ -224,6 +265,10 @@ export interface RoutingFeatureSidecar {
224
265
  readonly context_fit?: ContextFitObservability;
225
266
  /** Tier/cluster selection observability (SP-113, #62). */
226
267
  readonly tier_selection?: TierSelectionObservability;
268
+ /** Cache breakeven gate breakdown (SP-126, #73). */
269
+ readonly breakeven?: BreakevenObservability;
270
+ /** SAAR pin state summary (SP-126, #72). */
271
+ readonly saar?: SaarObservability;
227
272
  /** Why local_zero eligibility passed (SP-111, #59). */
228
273
  readonly local_eligible_reason: string | null;
229
274
  }
@@ -381,4 +426,13 @@ export interface RoutingTelemetry {
381
426
  readonly p_success_cheap: number | null;
382
427
  readonly local_eligible_reason: string | null;
383
428
  readonly tier_selection_reason_code: string | null;
429
+ readonly marginal_savings: number | null;
430
+ readonly future_cache_value: number | null;
431
+ readonly cache_reprime_cost: number | null;
432
+ readonly breakeven_decision: string | null;
433
+ readonly breakeven_reason_code: string | null;
434
+ readonly saar_buffer_active: boolean;
435
+ readonly saar_hard_lock: boolean;
436
+ readonly turn_index_in_session: number | null;
437
+ readonly saar_reason_code: string | null;
384
438
  }
@@ -1,4 +1,5 @@
1
1
  export type {
2
+ BreakevenObservability,
2
3
  CandidateScore,
3
4
  ClusterMatchTableEntry,
4
5
  ContextFitObservability,
@@ -23,6 +24,9 @@ export type {
23
24
  RoutingOutcomeRecord,
24
25
  RoutingRequest,
25
26
  RoutingTelemetry,
27
+ SaarConfig,
28
+ SaarObservability,
29
+ SaarSessionState,
26
30
  SessionPin,
27
31
  TierFeatureSummary,
28
32
  TierSelectionObservability,
@@ -241,6 +241,74 @@ export const RoutingClustersConfigSchema = z.object({
241
241
  config_path: z.string().min(1),
242
242
  });
243
243
 
244
+ /** SAAR operator knobs (SP-121, #72). */
245
+ export const SaarConfigSchema = z.object({
246
+ planning_turn_buffer: z.number().int().positive(),
247
+ prefix_cache_weight: z.number().min(0).max(1),
248
+ idle_timeout_seconds: z.number().int().positive(),
249
+ switch_threshold: z.number().min(0).max(1),
250
+ });
251
+
252
+ export type SaarConfig = z.infer<typeof SaarConfigSchema>;
253
+
254
+ /** SAAR defaults per routing-roadmap.md §2 P0 (SP-121). */
255
+ export const DEFAULT_SAAR_CONFIG: Readonly<SaarConfig> = {
256
+ planning_turn_buffer: 2,
257
+ prefix_cache_weight: 0.20,
258
+ idle_timeout_seconds: 300,
259
+ switch_threshold: 0.5,
260
+ } as const;
261
+
262
+ /** Env: SMART_ROUTER_PLANNING_TURN_BUFFER — SAAR planning buffer turns (default 2). */
263
+ const ENV_PLANNING_TURN_BUFFER = 'SMART_ROUTER_PLANNING_TURN_BUFFER';
264
+ /** Env: SMART_ROUTER_PREFIX_CACHE_WEIGHT — SAAR prefix cache weight 0–1 (default 0.20). */
265
+ const ENV_PREFIX_CACHE_WEIGHT = 'SMART_ROUTER_PREFIX_CACHE_WEIGHT';
266
+ /** Env: SMART_ROUTER_IDLE_TIMEOUT_SECONDS — SAAR idle reopen timeout seconds (default 300). */
267
+ const ENV_IDLE_TIMEOUT_SECONDS = 'SMART_ROUTER_IDLE_TIMEOUT_SECONDS';
268
+ /** Env: SMART_ROUTER_SWITCH_THRESHOLD — SAAR switch score gate 0–1 (default 0.5). */
269
+ const ENV_SWITCH_THRESHOLD = 'SMART_ROUTER_SWITCH_THRESHOLD';
270
+
271
+ function readPositiveIntEnv(name: string): number | undefined {
272
+ const raw = process.env[name];
273
+ if (raw === undefined || raw === '') {
274
+ return undefined;
275
+ }
276
+ const parsed = Number.parseInt(raw, 10);
277
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
278
+ }
279
+
280
+ function readUnitIntervalEnv(name: string): number | undefined {
281
+ const raw = process.env[name];
282
+ if (raw === undefined || raw === '') {
283
+ return undefined;
284
+ }
285
+ const parsed = Number.parseFloat(raw);
286
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : undefined;
287
+ }
288
+
289
+ /** Merge SAAR env overrides onto defaults (invalid env values are ignored). */
290
+ export function resolveSaarConfigFromEnv(
291
+ base: SaarConfig = DEFAULT_SAAR_CONFIG,
292
+ ): SaarConfig {
293
+ return {
294
+ planning_turn_buffer:
295
+ readPositiveIntEnv(ENV_PLANNING_TURN_BUFFER) ?? base.planning_turn_buffer,
296
+ prefix_cache_weight:
297
+ readUnitIntervalEnv(ENV_PREFIX_CACHE_WEIGHT) ?? base.prefix_cache_weight,
298
+ idle_timeout_seconds:
299
+ readPositiveIntEnv(ENV_IDLE_TIMEOUT_SECONDS) ?? base.idle_timeout_seconds,
300
+ switch_threshold:
301
+ readUnitIntervalEnv(ENV_SWITCH_THRESHOLD) ?? base.switch_threshold,
302
+ };
303
+ }
304
+
305
+ /** Per-session SAAR runtime state (SP-121 types; logic in SP-122). */
306
+ export const SaarSessionStateSchema = z.object({
307
+ turn_index: z.number().int().nonnegative(),
308
+ hard_lock: z.boolean(),
309
+ last_activity_at: z.string().datetime(),
310
+ });
311
+
244
312
  /** Stable snake_case cluster id — used as reason-code suffix (`cluster_${id}`). */
245
313
  export const RoutingClusterIdSchema = z
246
314
  .string()
@@ -280,6 +348,7 @@ export const OperatorConfigSchema = z.object({
280
348
  local: LocalConfigSchema,
281
349
  hydra: HydraConfigSchema,
282
350
  low_intensity: LowIntensityConfigSchema,
351
+ saar: SaarConfigSchema,
283
352
  routing_clusters: RoutingClustersConfigSchema.optional(),
284
353
  });
285
354
 
@@ -67,7 +67,8 @@ export function formatGeminiThoughtSignatureErrorMessage(errorMessage: string):
67
67
  summary,
68
68
  '',
69
69
  'Gemini rejected this request because a prior tool call is missing its thought_signature (protocol validation, not provider outage).',
70
- 'Workarounds: start a fresh session with /new, or route to a non-Google model until pi preserves thought signatures in replay.',
70
+ 'pi-smart-router normally repairs replay state before Google delegation (thoughtSignature preservation + skip sentinel for missing signatures).',
71
+ 'If this error persists, the session may contain unrepairable Google replay state — start a fresh session with /new, or route to a non-Google model.',
71
72
  `Docs: ${GEMINI_THOUGHT_SIGNATURE_DOCS}`,
72
73
  `Upstream: ${PI_THOUGHT_SIGNATURE_ISSUE}`,
73
74
  ].join('\n');
@@ -22,6 +22,10 @@ import {
22
22
  TELEMETRY_MAX_ENTRIES,
23
23
  TELEMETRY_WINDOW_MS,
24
24
  } from '../telemetry/telemetry-limits.js';
25
+ import {
26
+ DEFAULT_BREAKEVEN_TELEMETRY_FIELDS,
27
+ DEFAULT_SAAR_TELEMETRY_FIELDS,
28
+ } from '../telemetry/routing-telemetry.js';
25
29
  import {
26
30
  DATASET_MAX_ENTRIES,
27
31
  DATASET_WINDOW_MS,
@@ -827,6 +831,8 @@ function telemetryRowToEntity(row: TelemetryRow): RoutingTelemetry {
827
831
  p_success_cheap: null,
828
832
  local_eligible_reason: null,
829
833
  tier_selection_reason_code: null,
834
+ ...DEFAULT_BREAKEVEN_TELEMETRY_FIELDS,
835
+ ...DEFAULT_SAAR_TELEMETRY_FIELDS,
830
836
  };
831
837
  }
832
838