pi-smart-router 0.16.2 → 0.18.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/route-and-delegate.ts +155 -6
- package/.pi/extensions/smart-router/routing-context.ts +77 -16
- package/README.md +37 -13
- package/config/benchmark-profiles.json +2 -2
- package/config/operator-config.json.example +6 -0
- package/dist/domain/delegation/delegation-context.d.ts +12 -5
- package/dist/domain/delegation/delegation-context.d.ts.map +1 -1
- package/dist/domain/delegation/delegation-context.js +14 -10
- package/dist/domain/delegation/delegation-context.js.map +1 -1
- package/dist/domain/pinning/session-pinner.d.ts.map +1 -1
- package/dist/domain/pinning/session-pinner.js +16 -6
- package/dist/domain/pinning/session-pinner.js.map +1 -1
- package/dist/domain/pipeline/router-pipeline.d.ts +26 -0
- package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -1
- package/dist/domain/pipeline/router-pipeline.js +39 -0
- package/dist/domain/pipeline/router-pipeline.js.map +1 -1
- package/dist/domain/routing/p-success-classifier.d.ts +2 -2
- package/dist/domain/routing/tool-history-guard.d.ts +27 -6
- package/dist/domain/routing/tool-history-guard.d.ts.map +1 -1
- package/dist/domain/routing/tool-history-guard.js +70 -10
- package/dist/domain/routing/tool-history-guard.js.map +1 -1
- package/dist/domain/types/entities.d.ts +17 -1
- package/dist/domain/types/entities.d.ts.map +1 -1
- package/dist/domain/types/schemas.d.ts +505 -8
- package/dist/domain/types/schemas.d.ts.map +1 -1
- package/dist/domain/types/schemas.js +188 -9
- package/dist/domain/types/schemas.js.map +1 -1
- package/dist/domain/types/store-port.d.ts +47 -5
- package/dist/domain/types/store-port.d.ts.map +1 -1
- package/dist/domain/types/store-port.js +21 -0
- package/dist/domain/types/store-port.js.map +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/infra/gemini-provider.d.ts +7 -0
- package/dist/infra/gemini-provider.d.ts.map +1 -1
- package/dist/infra/gemini-provider.js +9 -1
- package/dist/infra/gemini-provider.js.map +1 -1
- package/dist/infrastructure/persistence/sqlite-store.d.ts +34 -1
- package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -1
- package/dist/infrastructure/persistence/sqlite-store.js +210 -110
- package/dist/infrastructure/persistence/sqlite-store.js.map +1 -1
- package/dist/infrastructure/persistence/write-queue.d.ts +94 -0
- package/dist/infrastructure/persistence/write-queue.d.ts.map +1 -0
- package/dist/infrastructure/persistence/write-queue.js +144 -0
- package/dist/infrastructure/persistence/write-queue.js.map +1 -0
- package/dist/infrastructure/telemetry/routing-telemetry.d.ts +5 -5
- package/package.json +1 -1
- package/src/domain/delegation/delegation-context.ts +14 -11
- package/src/domain/pinning/session-pinner.ts +16 -6
- package/src/domain/pipeline/router-pipeline.ts +49 -0
- package/src/domain/routing/tool-history-guard.ts +91 -9
- package/src/domain/types/entities.ts +24 -1
- package/src/domain/types/schemas.ts +231 -37
- package/src/domain/types/store-port.ts +47 -5
- package/src/index.ts +11 -0
- package/src/infra/gemini-provider.ts +10 -1
- package/src/infrastructure/persistence/sqlite-store.ts +242 -121
- package/src/infrastructure/persistence/write-queue.ts +232 -0
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import {
|
|
17
17
|
assertRoutableFleetAfterGeminiToolHistoryGuard,
|
|
18
18
|
GEMINI_TOOL_HISTORY_EXCLUDED,
|
|
19
|
+
isGoogleGeminiProfile,
|
|
19
20
|
resolveEffectiveFleet,
|
|
20
21
|
} from '../../../src/domain/routing/tool-history-guard.js';
|
|
21
22
|
import type { ModelProfile, RoutingDecision, RoutingRequest } from '../../../src/domain/types/index.js';
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
isGeminiThoughtSignatureAssistantError,
|
|
25
26
|
parseAssistantMessageError,
|
|
26
27
|
} from '../../../src/infrastructure/delegation/provider-error.js';
|
|
28
|
+
import { GEMINI_REPLAY_INCOMPATIBLE } from '../../../src/infra/gemini-provider.js';
|
|
27
29
|
import { shouldFailoverOnProviderError } from '../../../src/infrastructure/gateway/gateway-dispatch.js';
|
|
28
30
|
import {
|
|
29
31
|
commitPipedTerminal,
|
|
@@ -52,6 +54,11 @@ function isPipedResult(
|
|
|
52
54
|
return 'heldTerminal' in result;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/** Fail-open reason codes (SP-226) — emitted in telemetry and SMART_ROUTER_LOG_ROUTING=1. */
|
|
58
|
+
export const NO_REGISTRY_MODEL = 'no_registry_model';
|
|
59
|
+
export const FAILOVER_EXHAUSTED = 'failover_exhausted';
|
|
60
|
+
export const DELEGATION_ABORTED = 'delegation_aborted';
|
|
61
|
+
|
|
55
62
|
function isRoutingLogEnabled(): boolean {
|
|
56
63
|
return process.env.SMART_ROUTER_LOG_ROUTING === '1';
|
|
57
64
|
}
|
|
@@ -152,6 +159,68 @@ function isZeroOutputLengthStop(message: AssistantMessage): boolean {
|
|
|
152
159
|
return message.stopReason === 'length' && message.usage.output === 0;
|
|
153
160
|
}
|
|
154
161
|
|
|
162
|
+
/** Minimal model identity for degraded terminal messages when no Model resolved. */
|
|
163
|
+
interface DegradedModelRef {
|
|
164
|
+
readonly api: Api;
|
|
165
|
+
readonly provider: string;
|
|
166
|
+
readonly id: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function createDegradedErrorMessage(
|
|
170
|
+
model: DegradedModelRef,
|
|
171
|
+
reasonCode: string,
|
|
172
|
+
detail: string,
|
|
173
|
+
): AssistantMessage {
|
|
174
|
+
return {
|
|
175
|
+
role: 'assistant',
|
|
176
|
+
content: [],
|
|
177
|
+
api: model.api,
|
|
178
|
+
provider: model.provider,
|
|
179
|
+
model: model.id,
|
|
180
|
+
usage: {
|
|
181
|
+
input: 0,
|
|
182
|
+
output: 0,
|
|
183
|
+
cacheRead: 0,
|
|
184
|
+
cacheWrite: 0,
|
|
185
|
+
totalTokens: 0,
|
|
186
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
187
|
+
},
|
|
188
|
+
stopReason: 'error',
|
|
189
|
+
errorMessage: `Smart router degraded response (${reasonCode}): ${detail}`,
|
|
190
|
+
timestamp: Date.now(),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Fail-open terminal (SP-226): never throw to the host on exhaustion paths.
|
|
196
|
+
* Emit a structured warning with the reason code and end the outer stream with
|
|
197
|
+
* a degraded error message so the pi host receives an actionable response.
|
|
198
|
+
*/
|
|
199
|
+
function emitDegradedFailure(
|
|
200
|
+
outer: AssistantMessageEventStream,
|
|
201
|
+
model: DegradedModelRef | undefined,
|
|
202
|
+
selectedModelId: string,
|
|
203
|
+
reasonCode: string,
|
|
204
|
+
detail: string,
|
|
205
|
+
): void {
|
|
206
|
+
console.warn(
|
|
207
|
+
'[smart-router] fail-open degraded response',
|
|
208
|
+
JSON.stringify({
|
|
209
|
+
reason_code: reasonCode,
|
|
210
|
+
selected_model_id: selectedModelId,
|
|
211
|
+
detail,
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
const ref: DegradedModelRef = model ?? {
|
|
215
|
+
api: 'unknown',
|
|
216
|
+
provider: 'unknown',
|
|
217
|
+
id: selectedModelId,
|
|
218
|
+
};
|
|
219
|
+
const errorMessage = createDegradedErrorMessage(ref, reasonCode, detail);
|
|
220
|
+
outer.push({ type: 'error', reason: 'error', error: errorMessage });
|
|
221
|
+
outer.end(errorMessage);
|
|
222
|
+
}
|
|
223
|
+
|
|
155
224
|
function buildOverflowRoutingDecision(
|
|
156
225
|
base: RoutingDecision,
|
|
157
226
|
fallback: ReturnType<typeof resolveContextOverflowFallback>,
|
|
@@ -357,9 +426,18 @@ export async function routeAndDelegate(
|
|
|
357
426
|
if (decision.selected_model_id === 'unknown' && guardResult) {
|
|
358
427
|
assertRoutableFleetAfterGeminiToolHistoryGuard(guardResult);
|
|
359
428
|
}
|
|
360
|
-
|
|
429
|
+
// SP-226 fail-open: no registry model resolved — degrade instead of throwing.
|
|
430
|
+
deps.router.dispatch.recordOutcome(decision.selected_model_id, {
|
|
431
|
+
code: 'NO_REGISTRY_MODEL',
|
|
432
|
+
});
|
|
433
|
+
emitDegradedFailure(
|
|
434
|
+
outer,
|
|
435
|
+
undefined,
|
|
436
|
+
decision.selected_model_id,
|
|
437
|
+
NO_REGISTRY_MODEL,
|
|
361
438
|
`No registry model available for routing decision ${decision.selected_model_id}`,
|
|
362
439
|
);
|
|
440
|
+
return;
|
|
363
441
|
}
|
|
364
442
|
|
|
365
443
|
logRoutingDecision(decision, {
|
|
@@ -370,6 +448,7 @@ export async function routeAndDelegate(
|
|
|
370
448
|
|
|
371
449
|
const failedModelIds: string[] = [];
|
|
372
450
|
const headroomExcludedModelIds: string[] = [];
|
|
451
|
+
let geminiReplayFailoverAttempted = false;
|
|
373
452
|
const estimatedInputTokens =
|
|
374
453
|
request.estimated_input_tokens ?? request.prompt_text.length;
|
|
375
454
|
let pendingFailoverInfo: FailoverNoticeInfo | undefined;
|
|
@@ -494,6 +573,60 @@ export async function routeAndDelegate(
|
|
|
494
573
|
result.finalMessage &&
|
|
495
574
|
isGeminiThoughtSignatureAssistantError(result.finalMessage)
|
|
496
575
|
) {
|
|
576
|
+
// SP-233 residual safety net: a thought_signature 400 that survived
|
|
577
|
+
// repair/guard means the session replay state is incompatible with
|
|
578
|
+
// Gemini — fail over ONCE to a non-Google fleet member so the agent
|
|
579
|
+
// loop continues. Never counted as provider infra failure; never
|
|
580
|
+
// Gemini↔Gemini.
|
|
581
|
+
if (!geminiReplayFailoverAttempted) {
|
|
582
|
+
geminiReplayFailoverAttempted = true;
|
|
583
|
+
if (!failedModelIds.includes(targetModel.id)) {
|
|
584
|
+
failedModelIds.push(targetModel.id);
|
|
585
|
+
}
|
|
586
|
+
const nonGoogleFleet = effectiveFleet.filter(
|
|
587
|
+
(profile) => !isGoogleGeminiProfile(profile),
|
|
588
|
+
);
|
|
589
|
+
const replayFailover =
|
|
590
|
+
nonGoogleFleet.length > 0
|
|
591
|
+
? deps.router.dispatch.selectFailover(
|
|
592
|
+
decision,
|
|
593
|
+
failedModelIds,
|
|
594
|
+
nonGoogleFleet,
|
|
595
|
+
)
|
|
596
|
+
: undefined;
|
|
597
|
+
const failover = replayFailover
|
|
598
|
+
? { ...replayFailover, reason_code: GEMINI_REPLAY_INCOMPATIBLE }
|
|
599
|
+
: undefined;
|
|
600
|
+
const alternateModel = failover ? resolveTargetModel(deps, failover) : undefined;
|
|
601
|
+
|
|
602
|
+
if (failover && alternateModel && alternateModel.id !== targetModel.id) {
|
|
603
|
+
console.warn(
|
|
604
|
+
'[smart-router] gemini replay incompatible, failing over to non-Google model',
|
|
605
|
+
alternateModel.id,
|
|
606
|
+
);
|
|
607
|
+
if (isRoutingLogEnabled()) {
|
|
608
|
+
console.warn(
|
|
609
|
+
'[smart-router] routing decision',
|
|
610
|
+
JSON.stringify({
|
|
611
|
+
reason_code: GEMINI_REPLAY_INCOMPATIBLE,
|
|
612
|
+
failed_model_id: targetModel.id,
|
|
613
|
+
selected_model_id: alternateModel.id,
|
|
614
|
+
}),
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
pendingFailoverInfo = {
|
|
618
|
+
failedModelId: targetModel.id,
|
|
619
|
+
alternateModelId: alternateModel.id,
|
|
620
|
+
errorObj: resolveFailoverProviderError(result.finalMessage),
|
|
621
|
+
};
|
|
622
|
+
decision = failover;
|
|
623
|
+
deps.onRoutingDecision?.(decision);
|
|
624
|
+
targetModel = alternateModel;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// No non-Google candidate (or one-shot already used): actionable
|
|
629
|
+
// terminal guidance — never a silent loop.
|
|
497
630
|
commitPipedTerminal(result, { sanitizeErrors: true });
|
|
498
631
|
return;
|
|
499
632
|
}
|
|
@@ -546,6 +679,16 @@ export async function routeAndDelegate(
|
|
|
546
679
|
return;
|
|
547
680
|
} catch (error) {
|
|
548
681
|
if (isAbortError(error, options)) {
|
|
682
|
+
// SP-226: telemetry for phase-boundary aborts (previously silent).
|
|
683
|
+
if (isRoutingLogEnabled()) {
|
|
684
|
+
console.warn(
|
|
685
|
+
'[smart-router] delegation aborted',
|
|
686
|
+
JSON.stringify({
|
|
687
|
+
reason_code: DELEGATION_ABORTED,
|
|
688
|
+
model_id: targetModel.id,
|
|
689
|
+
}),
|
|
690
|
+
);
|
|
691
|
+
}
|
|
549
692
|
const abortMessage = createErrorMessage(targetModel, options, error);
|
|
550
693
|
outer.push({ type: 'error', reason: 'aborted', error: abortMessage });
|
|
551
694
|
outer.end(abortMessage);
|
|
@@ -565,7 +708,7 @@ export async function routeAndDelegate(
|
|
|
565
708
|
);
|
|
566
709
|
const alternateModel = failover ? resolveTargetModel(deps, failover) : undefined;
|
|
567
710
|
|
|
568
|
-
if (alternateModel && alternateModel.id !== targetModel.id) {
|
|
711
|
+
if (failover && alternateModel && alternateModel.id !== targetModel.id) {
|
|
569
712
|
console.warn(
|
|
570
713
|
'[smart-router] stream delegation failed, failing over',
|
|
571
714
|
error instanceof Error ? error.message : String(error),
|
|
@@ -575,9 +718,6 @@ export async function routeAndDelegate(
|
|
|
575
718
|
alternateModelId: alternateModel.id,
|
|
576
719
|
errorObj: { message: error instanceof Error ? error.message : String(error) },
|
|
577
720
|
};
|
|
578
|
-
if (!failover) {
|
|
579
|
-
throw error;
|
|
580
|
-
}
|
|
581
721
|
decision = failover;
|
|
582
722
|
targetModel = alternateModel;
|
|
583
723
|
continue;
|
|
@@ -585,7 +725,16 @@ export async function routeAndDelegate(
|
|
|
585
725
|
|
|
586
726
|
const fallbackModel = resolveFallbackModel(deps, effectiveFleet);
|
|
587
727
|
if (!fallbackModel || fallbackModel.id === targetModel.id) {
|
|
588
|
-
|
|
728
|
+
// SP-226 fail-open: fleet/failover exhausted and no distinct safe
|
|
729
|
+
// default — degrade instead of throwing to the host.
|
|
730
|
+
emitDegradedFailure(
|
|
731
|
+
outer,
|
|
732
|
+
targetModel,
|
|
733
|
+
decision.selected_model_id,
|
|
734
|
+
FAILOVER_EXHAUSTED,
|
|
735
|
+
error instanceof Error ? error.message : String(error),
|
|
736
|
+
);
|
|
737
|
+
return;
|
|
589
738
|
}
|
|
590
739
|
|
|
591
740
|
console.warn(
|
|
@@ -76,6 +76,35 @@ function messageContentToString(content: string | readonly (TextContent | { type
|
|
|
76
76
|
.join('\n');
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* SP-225 / #137: read an HTTP-ish status a host may attach to a tool result
|
|
81
|
+
* (either directly on the message or inside `details`). Returns undefined
|
|
82
|
+
* when no finite numeric status is present.
|
|
83
|
+
*/
|
|
84
|
+
function readOptionalStatus(source: unknown): number | undefined {
|
|
85
|
+
if (source === null || typeof source !== 'object') {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const record = source as Record<string, unknown>;
|
|
89
|
+
const direct = record.status;
|
|
90
|
+
if (typeof direct === 'number' && Number.isFinite(direct) && direct >= 0) {
|
|
91
|
+
return Math.floor(direct);
|
|
92
|
+
}
|
|
93
|
+
return readOptionalStatus(record.details);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface MapContextMessagesOptions {
|
|
97
|
+
/**
|
|
98
|
+
* Opt-in: include assistant `thinking` blocks in routing `content`.
|
|
99
|
+
* Default false — thinking is model-internal reasoning, not a routing
|
|
100
|
+
* signal, and leaking it inflates token estimates (#137).
|
|
101
|
+
*/
|
|
102
|
+
includeThinking?: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Operator env gate restoring the pre-SP-225 thinking-in-content behavior. */
|
|
106
|
+
const INCLUDE_THINKING_ENV = 'SMART_ROUTER_INCLUDE_THINKING';
|
|
107
|
+
|
|
79
108
|
export function extractPromptText(messages: readonly Message[]): string {
|
|
80
109
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
81
110
|
const message = messages[i];
|
|
@@ -117,7 +146,11 @@ export function deriveTurnType(messages: readonly Message[]): TurnType {
|
|
|
117
146
|
return 'main_loop';
|
|
118
147
|
}
|
|
119
148
|
|
|
120
|
-
export function mapContextMessages(
|
|
149
|
+
export function mapContextMessages(
|
|
150
|
+
messages: readonly Message[],
|
|
151
|
+
options?: MapContextMessagesOptions,
|
|
152
|
+
): RoutingMessage[] {
|
|
153
|
+
const includeThinking = options?.includeThinking === true;
|
|
121
154
|
return messages.map((message) => {
|
|
122
155
|
if (message.role === 'user') {
|
|
123
156
|
return {
|
|
@@ -127,27 +160,53 @@ export function mapContextMessages(messages: readonly Message[]): RoutingMessage
|
|
|
127
160
|
}
|
|
128
161
|
|
|
129
162
|
if (message.role === 'assistant') {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
163
|
+
const contentParts: string[] = [];
|
|
164
|
+
const toolBlocks: Record<string, unknown>[] = [];
|
|
165
|
+
for (const block of message.content) {
|
|
166
|
+
if (block.type === 'text') {
|
|
167
|
+
contentParts.push(block.text);
|
|
168
|
+
} else if (block.type === 'thinking') {
|
|
169
|
+
if (includeThinking) {
|
|
170
|
+
contentParts.push(block.thinking);
|
|
134
171
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
172
|
+
} else if (block.type === 'toolCall') {
|
|
173
|
+
toolBlocks.push({
|
|
174
|
+
type: 'tool_call',
|
|
175
|
+
tool_call_id: block.id,
|
|
176
|
+
tool_name: block.name,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
142
180
|
|
|
143
|
-
|
|
181
|
+
const mapped: RoutingMessage = {
|
|
182
|
+
role: message.role,
|
|
183
|
+
content: contentParts.filter(Boolean).join('\n'),
|
|
184
|
+
};
|
|
185
|
+
if (toolBlocks.length > 0) {
|
|
186
|
+
return { ...mapped, tool_blocks: toolBlocks };
|
|
187
|
+
}
|
|
188
|
+
return mapped;
|
|
144
189
|
}
|
|
145
190
|
|
|
191
|
+
const status = readOptionalStatus(message);
|
|
192
|
+
// When a host attaches an HTTP-ish status, let the domain status>=400 rule
|
|
193
|
+
// arbitrate (#137): a bare isError=false would otherwise mask the
|
|
194
|
+
// structured signal. Without a status, preserve the host isError verbatim.
|
|
195
|
+
const isError = message.isError === true || status === undefined
|
|
196
|
+
? message.isError
|
|
197
|
+
: undefined;
|
|
146
198
|
return {
|
|
147
199
|
role: 'tool',
|
|
148
200
|
content: messageContentToString(message.content),
|
|
149
|
-
tool_blocks: [
|
|
150
|
-
|
|
201
|
+
tool_blocks: [
|
|
202
|
+
{
|
|
203
|
+
type: 'tool_result',
|
|
204
|
+
tool_call_id: message.toolCallId,
|
|
205
|
+
tool_name: message.toolName,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
...(isError !== undefined ? { is_error: isError } : {}),
|
|
209
|
+
...(status !== undefined ? { status } : {}),
|
|
151
210
|
};
|
|
152
211
|
});
|
|
153
212
|
}
|
|
@@ -164,7 +223,9 @@ export function buildRoutingRequest(
|
|
|
164
223
|
request_id: randomUUID(),
|
|
165
224
|
session_id: sessionId,
|
|
166
225
|
prompt_text: extractPromptText(context.messages),
|
|
167
|
-
messages: mapContextMessages(context.messages
|
|
226
|
+
messages: mapContextMessages(context.messages, {
|
|
227
|
+
includeThinking: process.env[INCLUDE_THINKING_ENV] === '1',
|
|
228
|
+
}),
|
|
168
229
|
turn_type: deriveTurnType(context.messages),
|
|
169
230
|
estimated_input_tokens: estimateInputTokens(context, options),
|
|
170
231
|
...lifecycleFlags,
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Auto-model router middleware for the [pi](https://pi.dev) coding agent.**
|
|
4
4
|
|
|
5
|
-
> **v0.
|
|
5
|
+
> Current release: **v0.16.2** (mirrors `package.json`; SemVer `0.y.z`). The public API and routing behavior may change until `1.0.0`.
|
|
6
6
|
|
|
7
7
|
pi-smart-router intercepts every LLM inference request and dynamically routes it to the optimal execution engine — balancing cost, capability, latency, and time-to-first-token (TTFT) — without requiring you to manually pick a model for each turn.
|
|
8
8
|
|
|
@@ -18,8 +18,8 @@ pi-smart-router intercepts every LLM inference request and dynamically routes it
|
|
|
18
18
|
```text
|
|
19
19
|
request → hardware probe → loop escalation → turn envelope → context-fit gate
|
|
20
20
|
→ low-intensity tier gate → session pin → deterministic triage
|
|
21
|
-
→ local zero-tier →
|
|
22
|
-
→ context overflow fallback
|
|
21
|
+
→ local zero-tier → triage cloud fallback → HyDRA embedding matcher
|
|
22
|
+
→ safe cloud default → context overflow fallback
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
The pipeline runs **12 stages sequentially with early exit** — the moment any stage reaches a routing decision, subsequent stages are skipped. Every decision includes the stage name, reason code, candidates considered, estimated cost, and routing latency for full observability.
|
|
@@ -34,6 +34,7 @@ The pipeline runs **12 stages sequentially with early exit** — the moment any
|
|
|
34
34
|
| Session Pin | <1ms | Returns pinned model if session has one; breaks pin on compaction or overflow |
|
|
35
35
|
| Deterministic Triage | <5ms | Aho-Corasick keyword scan + cyclomatic complexity analysis |
|
|
36
36
|
| Local Zero-Tier | <15ms | Pings LM Studio + Ollama in parallel; routes locally when eligible |
|
|
37
|
+
| Triage Cloud Fallback | <2ms | Trivial prompts not claimed locally route to the first healthy economical-cloud model |
|
|
37
38
|
| HyDRA Matcher | 80-120ms | ONNX embeddings, 3D requirement projection, shortfall gate, multi-objective scoring |
|
|
38
39
|
| Safe Cloud Default | — | First healthy economical-cloud model (context-fit aware) |
|
|
39
40
|
| Context Overflow Fallback | — | Escalates to largest-fit model when economical tiers cannot fit |
|
|
@@ -200,7 +201,7 @@ pi exposes two different **auto** models. They are easy to confuse but play diff
|
|
|
200
201
|
|
|
201
202
|
- You want cost/capability-aware model selection across your full authenticated fleet
|
|
202
203
|
- You rely on session pinning, failover, or `/smart-router status` / `history` / `stats` telemetry
|
|
203
|
-
- Tool-heavy sessions with Gemini economical models work via in-repo replay repair;
|
|
204
|
+
- Tool-heavy sessions with Gemini economical models work via in-repo replay repair (cross-provider included); the tool-history guard reroutes to non-Google models such as `cursor/auto` for unrepairable replay state (see [pi-smart-router#85](https://github.com/beettlle/pi-smart-router/issues/85), [pi-smart-router#158](https://github.com/beettlle/pi-smart-router/issues/158))
|
|
204
205
|
|
|
205
206
|
Cursor models (`cursor/*`, `composer-*`, and the opaque fleet id `default`) map to **frontier-cloud** tier in `pi-model-mapper.ts` so HyDRA can score them against Gemini and Claude instead of treating them as unknown economical models ([pi-smart-router#40](https://github.com/beettlle/pi-smart-router/issues/40), [pi-smart-router#70](https://github.com/beettlle/pi-smart-router/issues/70)). Related: [pi-smart-router#23](https://github.com/beettlle/pi-smart-router/issues/23) (turn envelope / pin order), [pi-smart-router#37](https://github.com/beettlle/pi-smart-router/issues/37) (Gemini `thought_signature` errors).
|
|
206
207
|
|
|
@@ -258,6 +259,10 @@ After typing `/smart-router ` (with a trailing space), press **TAB** to see subc
|
|
|
258
259
|
npm run verify:ci
|
|
259
260
|
```
|
|
260
261
|
|
|
262
|
+
## Concurrency contract
|
|
263
|
+
|
|
264
|
+
`RouterPipeline.route()` calls on a single router instance are **single-flight**: concurrent calls are serialized internally (SP-230, [#141](https://github.com/beettlle/pi-smart-router/issues/141)). The pipeline keeps per-route transient state on instance fields while stages run, so overlapping executions are queued rather than interleaved — each queued call waits at most one routing latency. This applies to `createRouter()` / `createRouterFromFleet()` handles: a shared `router.dispatch` is safe to call concurrently, and serialization does not change routing policy outcomes. For parallel routing throughput, create separate router instances.
|
|
265
|
+
|
|
261
266
|
## Fleet behavior
|
|
262
267
|
|
|
263
268
|
When you use `smart-router/auto`, the extension does **not** read `config/models.yaml`. Instead:
|
|
@@ -873,23 +878,25 @@ The `GatewayDispatch` layer wraps the pipeline with:
|
|
|
873
878
|
|
|
874
879
|
#### Gemini `thought_signature` 400 errors
|
|
875
880
|
|
|
876
|
-
If Gemini returns **400 INVALID_ARGUMENT** mentioning `thought_signature`, the router treats this as a **protocol validation error** (incomplete tool-call replay), not provider unavailability — it
|
|
881
|
+
If Gemini returns **400 INVALID_ARGUMENT** mentioning `thought_signature`, the router treats this as a **protocol validation error** (incomplete tool-call replay), not provider unavailability — it is never classified as an infrastructure failure and never trips the circuit breaker. If the error survives repair and the tool-history guard, the router fails over **once** to a non-Google fleet model automatically (telemetry `reason_code: gemini_replay_incompatible`, distinct from infra failover) so the agent loop continues; only when no non-Google candidate exists does it surface a terminal error with actionable guidance.
|
|
877
882
|
|
|
878
883
|
See [Google's thought signatures documentation](https://ai.google.dev/gemini-api/docs/generate-content/thought-signatures).
|
|
879
884
|
|
|
880
|
-
**Primary
|
|
885
|
+
**Primary path — silent repair and reroute (SP-127/128, SP-231/232):** in the common cross-provider case (tool-heavy turns on OpenAI/Anthropic/GLM/Cursor, then a Gemini selection) you do **not** need `/new` or manual model switching. Before every Google-target delegation, smart-router repairs tool-call replay state for **any** prior provider: unsigned tool calls receive the Google-accepted skip sentinel, captured signatures are preserved, and assistant identity aligns so pi-ai replays the turns without a 400. When history carries replay state repair cannot make Google-safe, the tool-history guard silently reroutes to a non-Google model instead (`reason_code: gemini_tool_history_excluded`) — see below.
|
|
881
886
|
|
|
882
|
-
**
|
|
887
|
+
**Guard fail-safe (SP-129, expanded SP-232):** sessions with **unrepairable** replay state exclude Gemini from routing unless the operator sets `force_model_id` via `/model`. Unrepairable means: Google-origin turns with redacted thinking or captured signatures (SP-129), and — after SP-232 — **cross-provider** turns whose state repair preserves but Google rejects, such as foreign provider signatures (Claude signed thinking, signed text, or signed tool calls) or redacted thinking from any origin replayed toward a Google target. Unsigned cross-provider tool calls alone are repairable and stay routable to Gemini.
|
|
883
888
|
|
|
884
|
-
**Empty fleet fail-safe (SP-084):** when the guard filters every model in the scoped fleet (e.g. Google/Gemini-only dogfood configs with unrepairable replay risk), the router throws an actionable error instead of delegating with `selected_model_id: unknown`. Add a non-Google model such as `openai/gpt-4o-mini` or `cursor/auto` to the fleet,
|
|
889
|
+
**Empty fleet fail-safe (SP-084):** when the guard filters every model in the scoped fleet (e.g. Google/Gemini-only dogfood configs with unrepairable replay risk), the router throws an actionable error instead of delegating with `selected_model_id: unknown`. Add a non-Google model such as `openai/gpt-4o-mini` or `cursor/auto` to the fleet, or pin `/model` to force a specific model.
|
|
890
|
+
|
|
891
|
+
**Residual path — one-shot non-Google failover (SP-233):** when a `thought_signature` 400 still reaches the stream (repair could not make the session replay state Google-safe), smart-router selects at most one non-Google fleet member and continues the stream with it. This is protocol-affinity failover, not infra failover: it is not recorded as a provider outage, does not trip the circuit breaker, and never retries Gemini↔Gemini for this error. If the scoped fleet has no non-Google model, the router fails fast with terminal guidance instead of looping silently.
|
|
885
892
|
|
|
886
893
|
**If you still see a `thought_signature` error:**
|
|
887
894
|
|
|
888
|
-
1.
|
|
889
|
-
2.
|
|
895
|
+
1. Add a non-Google model (e.g. `openai/gpt-4o-mini` or `cursor/auto`) to the scoped fleet so the residual failover can reroute automatically — or switch manually with `/model openai/gpt-4o-mini` for that session.
|
|
896
|
+
2. Start a fresh session with `/new` in pi (clears unrepairable history) — last resort, rarely needed outside Google-only fleets or residual edge cases.
|
|
890
897
|
3. Upstream: [pi#6342](https://github.com/earendil-works/pi/issues/6342) tracks pi preserving thought signatures in session replay; smart-router repair covers the common cross-model routing case without waiting on that fix.
|
|
891
898
|
|
|
892
|
-
Related: [pi-smart-router#37](https://github.com/beettlle/pi-smart-router/issues/37), [pi-smart-router#38](https://github.com/beettlle/pi-smart-router/issues/38), [pi-smart-router#40](https://github.com/beettlle/pi-smart-router/issues/40), [pi-smart-router#41](https://github.com/beettlle/pi-smart-router/issues/41), [pi-smart-router#85](https://github.com/beettlle/pi-smart-router/issues/85).
|
|
899
|
+
Related: [pi-smart-router#37](https://github.com/beettlle/pi-smart-router/issues/37), [pi-smart-router#38](https://github.com/beettlle/pi-smart-router/issues/38), [pi-smart-router#40](https://github.com/beettlle/pi-smart-router/issues/40), [pi-smart-router#41](https://github.com/beettlle/pi-smart-router/issues/41), [pi-smart-router#85](https://github.com/beettlle/pi-smart-router/issues/85), [pi-smart-router#158](https://github.com/beettlle/pi-smart-router/issues/158), [pi-smart-router#159](https://github.com/beettlle/pi-smart-router/issues/159).
|
|
893
900
|
|
|
894
901
|
### Explain endpoint (library API)
|
|
895
902
|
|
|
@@ -949,7 +956,7 @@ Contributors must run `npm run build` before publishing or consuming the library
|
|
|
949
956
|
| `npm run release:check` | Pre-release gate: `verify:ci` + consumer pack + Tier 0 functional smoke |
|
|
950
957
|
| `npm run release:functional-smoke` | Tier 0 functional smoke: calibration verify (`--skip-embed`), benchmark profiles, release gate assertions |
|
|
951
958
|
| `npm run release:consumer-pack` | Pack tarball and verify production dependencies resolve (catches missing runtime deps) |
|
|
952
|
-
| `npm run verify:ci` | Full CI parity: build, typecheck, lint, test, coverage |
|
|
959
|
+
| `npm run verify:ci` | Full CI parity: build, typecheck, lint, test, coverage (baseline PR gate; see [PR and pre-release quality gate set](#pr-and-pre-release-quality-gate-set)) |
|
|
953
960
|
| `npm run typecheck` | TypeScript strict mode check (`tsc --noEmit`) |
|
|
954
961
|
| `npm test` | Run test suite (`vitest run`) |
|
|
955
962
|
| `npm run coverage:check` | Tests with line-coverage thresholds |
|
|
@@ -1008,7 +1015,9 @@ npm run routing:eval-replay
|
|
|
1008
1015
|
npm run routing:twinrouterbench:full-track
|
|
1009
1016
|
```
|
|
1010
1017
|
|
|
1011
|
-
**CI smoke:** `.github/workflows/eval-harness-smoke.yml` runs on PRs that touch eval scripts, fixtures, or the workflow. It executes `routing:eval-harness:smoke`, `routing:eval-harness:corpus-smoke`, and eval unit tests — fast, offline, no provider network calls. Job timeout stays at 10 minutes. The optional full-track nightly (`.github/workflows/twinrouterbench-full-nightly.yml`, `schedule` + `workflow_dispatch` only) is **not** on `pull_request` and must not be configured as a required status check — failures there do not gate PR CI or `release:functional-smoke`.
|
|
1018
|
+
**CI smoke:** `.github/workflows/eval-harness-smoke.yml` runs on PRs that touch eval scripts, fixtures, **any `src/**` or `.pi/extensions/smart-router/**` change**, or the workflow. It executes `routing:eval-harness:smoke`, `routing:eval-harness:corpus-smoke`, and eval unit tests — fast, offline, no provider network calls. Job timeout stays at 10 minutes. The optional full-track nightly (`.github/workflows/twinrouterbench-full-nightly.yml`, `schedule` + `workflow_dispatch` only) is **not** on `pull_request` and must not be configured as a required status check — failures there do not gate PR CI or `release:functional-smoke`.
|
|
1019
|
+
|
|
1020
|
+
**Calibration verify:** `.github/workflows/calibration-verify.yml` runs on PRs that touch calibration config/scripts (`config/routing-calibration.json*`, `config/p-success-weights.json`, `scripts/train-routing-calibration.ts`, `scripts/verify-routing-calibration.ts`, `scripts/lib/isotonic-calibrator.ts`, `scripts/lib/oats-centroid-refinement.ts`) **or calibration-consuming routing code** (`src/domain/routing/**`, `src/domain/pipeline/**`, `src/domain/types/**`, `src/cli/**`). It builds the library and verifies `config/routing-calibration.json.example` against benchmark prompts via `npm run routing:verify-calibration`.
|
|
1012
1021
|
|
|
1013
1022
|
**TwinRouterBench static track:** import step-level router-visible prefixes with execution-verified target tiers (`track: "static"`). The adapter in `scripts/eval/twinrouterbench-adapter.ts` converts static track records into native eval fixtures for the three-track harness. See `docs/gemini-research.md` §9 for methodology context.
|
|
1014
1023
|
|
|
@@ -1178,6 +1187,21 @@ npm run routing:verify-benchmark-profiles
|
|
|
1178
1187
|
|
|
1179
1188
|
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.
|
|
1180
1189
|
|
|
1190
|
+
#### PR and pre-release quality gate set
|
|
1191
|
+
|
|
1192
|
+
Operators should treat the following as the full gate set before merging routing changes and before tagging a release ([#135](https://github.com/beettlle/pi-smart-router/issues/135)):
|
|
1193
|
+
|
|
1194
|
+
| Gate | Workflow / command | Runs when |
|
|
1195
|
+
|------|--------------------|-----------|
|
|
1196
|
+
| Build / typecheck / lint / coverage | `.github/workflows/ci.yml` (`npm run verify:ci`) | Every PR and push to `main` |
|
|
1197
|
+
| Eval harness smoke (offline) | `.github/workflows/eval-harness-smoke.yml` | PRs touching `scripts/eval/**`, `tests/eval/**`, **`src/**`**, **`.pi/extensions/smart-router/**`**, `package.json`, or the workflow |
|
|
1198
|
+
| Calibration verify | `.github/workflows/calibration-verify.yml` | PRs touching calibration config/scripts or calibration-consuming routing code (`src/domain/routing/**`, `src/domain/pipeline/**`, `src/domain/types/**`, `src/cli/**`) |
|
|
1199
|
+
| Benchmark profile smoke | `.github/workflows/benchmark-profile-refresh.yml` (`routing:verify-benchmark-profiles`) | PRs touching fixtures / ingest / profiles |
|
|
1200
|
+
| Pre-release functional smoke | `npm run release:check` (Tier 0: calibration `--skip-embed` + benchmark profiles + release-gate assertions) | Operator-run before `npm version` / tag |
|
|
1201
|
+
| TwinRouterBench full track | `.github/workflows/twinrouterbench-full-nightly.yml` | Nightly / manual only — **never** a required PR check |
|
|
1202
|
+
|
|
1203
|
+
Required-check configuration for branch protection is a human-operator repo-settings decision; the workflow path filters above guarantee the jobs **run** on routing code edits regardless of which subset the operator marks required.
|
|
1204
|
+
|
|
1181
1205
|
**Scope composition:** use `/skill:router-release-operator` for themed release planning (not open-ended backlog cycles). **Patch** = docs + bugfixes only; **minor** = new capability (1–3 related issues under one theme). Budgets and audit rules: [`skills/router-release-operator/references/release-profiles.md`](skills/router-release-operator/references/release-profiles.md).
|
|
1182
1206
|
|
|
1183
1207
|
**Tier 0 functional smoke** (`release:functional-smoke`) runs before tag publish and chains:
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
"livecodebench": "https://livecodebench.github.io/leaderboard.html",
|
|
8
8
|
"bfcl": "https://gorilla.cs.berkeley.edu/leaderboard.html"
|
|
9
9
|
},
|
|
10
|
-
"scrape_date": "2026-08-
|
|
11
|
-
"catalog_freeze_date": "2026-08-
|
|
10
|
+
"scrape_date": "2026-08-28",
|
|
11
|
+
"catalog_freeze_date": "2026-08-28"
|
|
12
12
|
},
|
|
13
13
|
"aliases": {
|
|
14
14
|
"anthropic/claude-opus-4": "claude-opus-4-5",
|
|
@@ -58,6 +58,12 @@
|
|
|
58
58
|
"max_messages": 12,
|
|
59
59
|
"max_tokens": 16384,
|
|
60
60
|
"exclude_execution_history": true
|
|
61
|
+
},
|
|
62
|
+
"global_timeout_ms": 120000,
|
|
63
|
+
"sub_call_timeout_ms": 30000,
|
|
64
|
+
"_timeouts_documentation": {
|
|
65
|
+
"global_timeout_ms": "Global cap (ms) for the whole delegate stage; mirrors llm-use WORKER_GLOBAL_TIMEOUT. Default 120000 (SP-213, #120).",
|
|
66
|
+
"sub_call_timeout_ms": "Per-call cap (ms) for each delegate sub-call worker; mirrors llm-use WORKER_CALL_TIMEOUT. Default 30000 (SP-213, #120)."
|
|
61
67
|
}
|
|
62
68
|
},
|
|
63
69
|
"local_zero": {
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Delegation context normalization — provider-agnostic replay identity fix.
|
|
3
3
|
*
|
|
4
4
|
* pi-ai transformMessages compares assistant message provider/api/model to the
|
|
5
|
-
* target model.
|
|
5
|
+
* target model. Foreign identities (virtual smart-router tags or any prior
|
|
6
|
+
* provider such as OpenAI/Anthropic/GLM) break isSameModel and strip replay
|
|
6
7
|
* state (thoughtSignature, thinkingSignature, etc.).
|
|
7
8
|
*/
|
|
8
9
|
import type { Api, AssistantMessage, Context, Message, Model } from '@earendil-works/pi-ai/compat';
|
|
@@ -35,11 +36,17 @@ export interface NormalizeDelegationContextOptions {
|
|
|
35
36
|
*/
|
|
36
37
|
export declare function normalizeDelegationContext<TApi extends Api>(context: Context, targetModel: Model<TApi>, options?: NormalizeDelegationContextOptions): Context;
|
|
37
38
|
/**
|
|
38
|
-
* Repair Gemini tool-call replay for
|
|
39
|
+
* Repair Gemini tool-call replay for Google delegation targets.
|
|
39
40
|
*
|
|
40
|
-
* Call after {@link normalizeDelegationContext}. Aligns
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* Call after {@link normalizeDelegationContext}. Aligns assistant identity to
|
|
42
|
+
* the delegation target and injects the thought-signature sentinel on every
|
|
43
|
+
* unsigned tool call, regardless of which provider produced the prior turn
|
|
44
|
+
* (Google, OpenAI, Anthropic, GLM, …). Captured signatures are preserved.
|
|
45
|
+
*
|
|
46
|
+
* Cross-provider repair (SP-231, #158): pi-ai strips replay state from any
|
|
47
|
+
* assistant message whose identity fails isSameModel, so a Gemini target
|
|
48
|
+
* would receive unsigned tool calls from non-Google turns and the Google API
|
|
49
|
+
* would reject the replay with a thought_signature error.
|
|
43
50
|
*/
|
|
44
51
|
export declare function repairGeminiReplayContext<TApi extends Api>(context: Context, targetModel: Model<TApi>, sessionExecution?: ExecutionModel | null): Context;
|
|
45
52
|
//# sourceMappingURL=delegation-context.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"delegation-context.d.ts","sourceRoot":"","sources":["../../../src/domain/delegation/delegation-context.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"delegation-context.d.ts","sourceRoot":"","sources":["../../../src/domain/delegation/delegation-context.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EACV,GAAG,EACH,gBAAgB,EAChB,OAAO,EACP,OAAO,EACP,KAAK,EACN,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D,eAAO,MAAM,uBAAuB,EAAG,cAAuB,CAAC;AAC/D,eAAO,MAAM,uBAAuB,EAAG,MAAe,CAAC;AAEvD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sCAAsC,EACjD,8CAAuD,CAAC;AAW1D,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAElF;AAED,wBAAgB,wBAAwB,CAAC,IAAI,SAAS,GAAG,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,GAAG,OAAO,CAetF;AAED,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAmBjF;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CA+B7E;AAoCD,MAAM,WAAW,iCAAiC;IAChD,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,uBAAuB,CAAC;IAC1D,QAAQ,CAAC,gBAAgB,CAAC,EAAE,cAAc,GAAG,IAAI,CAAC;CACnD;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,SAAS,GAAG,EACzD,OAAO,EAAE,OAAO,EAChB,WAAW,EAAE,KAAK,CAAC,IAAI,CAAC,EACxB,OAAO,CAAC,EAAE,iCAAiC,GAC1C,OAAO,CA2BT;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,SAAS,GAAG,EACxD,OAAO,EAAE,OAAO,EAChB,WAAW,EAAE,KAAK,CAAC,IAAI,CAAC,EACxB,gBAAgB,CAAC,EAAE,cAAc,GAAG,IAAI,GACvC,OAAO,CAwBT"}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Delegation context normalization — provider-agnostic replay identity fix.
|
|
3
3
|
*
|
|
4
4
|
* pi-ai transformMessages compares assistant message provider/api/model to the
|
|
5
|
-
* target model.
|
|
5
|
+
* target model. Foreign identities (virtual smart-router tags or any prior
|
|
6
|
+
* provider such as OpenAI/Anthropic/GLM) break isSameModel and strip replay
|
|
6
7
|
* state (thoughtSignature, thinkingSignature, etc.).
|
|
7
8
|
*/
|
|
8
9
|
export const VIRTUAL_ROUTER_PROVIDER = 'smart-router';
|
|
@@ -92,7 +93,7 @@ function rewriteAssistantIdentity(message, executionModel) {
|
|
|
92
93
|
model: executionModel.id,
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
|
-
function
|
|
96
|
+
function repairAssistantForGoogleReplay(message, targetExecution) {
|
|
96
97
|
const content = message.content.map((block) => {
|
|
97
98
|
if (block.type !== 'toolCall') {
|
|
98
99
|
return block;
|
|
@@ -134,11 +135,17 @@ export function normalizeDelegationContext(context, targetModel, options) {
|
|
|
134
135
|
return { ...context, messages };
|
|
135
136
|
}
|
|
136
137
|
/**
|
|
137
|
-
* Repair Gemini tool-call replay for
|
|
138
|
+
* Repair Gemini tool-call replay for Google delegation targets.
|
|
138
139
|
*
|
|
139
|
-
* Call after {@link normalizeDelegationContext}. Aligns
|
|
140
|
-
*
|
|
141
|
-
*
|
|
140
|
+
* Call after {@link normalizeDelegationContext}. Aligns assistant identity to
|
|
141
|
+
* the delegation target and injects the thought-signature sentinel on every
|
|
142
|
+
* unsigned tool call, regardless of which provider produced the prior turn
|
|
143
|
+
* (Google, OpenAI, Anthropic, GLM, …). Captured signatures are preserved.
|
|
144
|
+
*
|
|
145
|
+
* Cross-provider repair (SP-231, #158): pi-ai strips replay state from any
|
|
146
|
+
* assistant message whose identity fails isSameModel, so a Gemini target
|
|
147
|
+
* would receive unsigned tool calls from non-Google turns and the Google API
|
|
148
|
+
* would reject the replay with a thought_signature error.
|
|
142
149
|
*/
|
|
143
150
|
export function repairGeminiReplayContext(context, targetModel, sessionExecution) {
|
|
144
151
|
// Accepted for SP-128 call-site symmetry with normalizeDelegationContext; identity
|
|
@@ -156,10 +163,7 @@ export function repairGeminiReplayContext(context, targetModel, sessionExecution
|
|
|
156
163
|
if (message.role !== 'assistant') {
|
|
157
164
|
return message;
|
|
158
165
|
}
|
|
159
|
-
|
|
160
|
-
return message;
|
|
161
|
-
}
|
|
162
|
-
return repairGoogleOriginAssistantMessage(message, targetExecution);
|
|
166
|
+
return repairAssistantForGoogleReplay(message, targetExecution);
|
|
163
167
|
});
|
|
164
168
|
return { ...context, messages };
|
|
165
169
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"delegation-context.js","sourceRoot":"","sources":["../../../src/domain/delegation/delegation-context.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"delegation-context.js","sourceRoot":"","sources":["../../../src/domain/delegation/delegation-context.ts"],"names":[],"mappings":"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"}
|