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.
- package/.pi/extensions/smart-router/delegation-runtime.ts +12 -2
- package/README.md +55 -17
- package/dist/api/explain/router-explain.d.ts +4 -1
- package/dist/api/explain/router-explain.d.ts.map +1 -1
- package/dist/api/explain/router-explain.js +9 -9
- package/dist/api/explain/router-explain.js.map +1 -1
- package/dist/config/defaults.d.ts +4 -1
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +10 -0
- package/dist/config/defaults.js.map +1 -1
- package/dist/domain/delegation/delegation-context.d.ts +23 -1
- package/dist/domain/delegation/delegation-context.d.ts.map +1 -1
- package/dist/domain/delegation/delegation-context.js +93 -0
- package/dist/domain/delegation/delegation-context.js.map +1 -1
- package/dist/domain/pinning/cache-breakeven.d.ts +51 -0
- package/dist/domain/pinning/cache-breakeven.d.ts.map +1 -0
- package/dist/domain/pinning/cache-breakeven.js +124 -0
- package/dist/domain/pinning/cache-breakeven.js.map +1 -0
- package/dist/domain/pinning/saar-session-state.d.ts +36 -0
- package/dist/domain/pinning/saar-session-state.d.ts.map +1 -0
- package/dist/domain/pinning/saar-session-state.js +74 -0
- package/dist/domain/pinning/saar-session-state.js.map +1 -0
- package/dist/domain/pinning/session-pinner.d.ts +35 -3
- package/dist/domain/pinning/session-pinner.d.ts.map +1 -1
- package/dist/domain/pinning/session-pinner.js +149 -1
- package/dist/domain/pinning/session-pinner.js.map +1 -1
- package/dist/domain/pipeline/router-pipeline.d.ts +15 -1
- package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -1
- package/dist/domain/pipeline/router-pipeline.js +118 -6
- package/dist/domain/pipeline/router-pipeline.js.map +1 -1
- package/dist/domain/routing/tool-history-guard.d.ts +21 -5
- package/dist/domain/routing/tool-history-guard.d.ts.map +1 -1
- package/dist/domain/routing/tool-history-guard.js +77 -10
- package/dist/domain/routing/tool-history-guard.js.map +1 -1
- package/dist/domain/types/entities.d.ts +48 -0
- package/dist/domain/types/entities.d.ts.map +1 -1
- package/dist/domain/types/index.d.ts +1 -1
- package/dist/domain/types/index.d.ts.map +1 -1
- package/dist/domain/types/schemas.d.ts +24 -0
- package/dist/domain/types/schemas.d.ts.map +1 -1
- package/dist/domain/types/schemas.js +54 -0
- package/dist/domain/types/schemas.js.map +1 -1
- package/dist/infra/gemini-provider.d.ts.map +1 -1
- package/dist/infra/gemini-provider.js +2 -1
- package/dist/infra/gemini-provider.js.map +1 -1
- package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -1
- package/dist/infrastructure/persistence/sqlite-store.js +3 -0
- package/dist/infrastructure/persistence/sqlite-store.js.map +1 -1
- package/dist/infrastructure/telemetry/routing-telemetry.d.ts +30 -2
- package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -1
- package/dist/infrastructure/telemetry/routing-telemetry.js +221 -3
- package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -1
- package/package.json +5 -4
- package/src/api/explain/router-explain.ts +14 -10
- package/src/config/defaults.ts +18 -1
- package/src/domain/delegation/delegation-context.ts +124 -0
- package/src/domain/pinning/cache-breakeven.ts +219 -0
- package/src/domain/pinning/saar-session-state.ts +93 -0
- package/src/domain/pinning/session-pinner.ts +244 -3
- package/src/domain/pipeline/router-pipeline.ts +159 -7
- package/src/domain/routing/tool-history-guard.ts +108 -10
- package/src/domain/types/entities.ts +54 -0
- package/src/domain/types/index.ts +4 -0
- package/src/domain/types/schemas.ts +69 -0
- package/src/infra/gemini-provider.ts +2 -1
- package/src/infrastructure/persistence/sqlite-store.ts +6 -0
- 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 {
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
|
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: '
|
|
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
|
|
5
|
-
* tool calls
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
|
196
|
+
return hasGoogleReplayRiskFromContext(contextMessages);
|
|
114
197
|
}
|
|
115
198
|
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
'
|
|
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
|
|