@alexeiled/pi-model-router 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,28 @@
1
- import type { Context } from '@earendil-works/pi-ai';
2
- import { parseCanonicalModelRef } from './config';
3
1
  import {
4
- containsAny,
5
- countToolResults,
6
- countWords,
7
- getLastUserText,
8
- getRecentConversationText,
9
- } from './context';
2
+ type Api,
3
+ getSupportedThinkingLevels,
4
+ type Model,
5
+ } from '@earendil-works/pi-ai';
6
+ import { parseCanonicalModelRef } from './config';
10
7
  import type {
8
+ ModelDefinition,
9
+ RoutePair,
11
10
  RouterPhase,
12
11
  RouterProfile,
13
12
  RouterThinkingByTier,
14
13
  RouterTier,
15
14
  RoutingDecision,
16
- RoutingRule,
15
+ RoutingReasonCode,
17
16
  } from './types';
17
+ import { ROUTER_TIERS } from './types';
18
+
19
+ /** The configured default preference order when no baseline tier is supplied. */
20
+ export const BASELINE_TIER_ORDER: readonly RouterTier[] = [
21
+ 'medium',
22
+ 'high',
23
+ 'low',
24
+ 'micro',
25
+ ] as const;
18
26
 
19
27
  export const phaseForTier = (tier: RouterTier): RouterPhase => {
20
28
  if (tier === 'high') return 'planning';
@@ -22,317 +30,249 @@ export const phaseForTier = (tier: RouterTier): RouterPhase => {
22
30
  return 'lightweight';
23
31
  };
24
32
 
25
- export const resolveAvailableTier = (
26
- profile: RouterProfile,
27
- preferred: RouterTier,
28
- ): RouterTier => {
29
- if (profile[preferred]) return preferred;
30
- // Fall "up": low → medium → high
31
- const order: RouterTier[] = ['low', 'medium', 'high'];
32
- const startIdx = order.indexOf(preferred);
33
- for (let i = startIdx + 1; i < order.length; i++) {
34
- const tier = order[i];
35
- if (tier && profile[tier]) return tier;
36
- }
37
- // Fall "down" as last resort
38
- for (let i = startIdx - 1; i >= 0; i--) {
39
- const tier = order[i];
40
- if (tier && profile[tier]) return tier;
41
- }
42
- return preferred; // unreachable if profile has ≥1 tier
43
- };
44
-
45
- export const buildRoutingDecision = (
46
- profileName: string,
33
+ export const resolveRoutePair = (
47
34
  profile: RouterProfile,
48
35
  tier: RouterTier,
49
- phase: RouterPhase,
50
- reasoning: string,
51
36
  thinkingOverrides?: RouterThinkingByTier,
52
- isClassifier?: boolean,
53
- ): RoutingDecision => {
37
+ ): RoutePair => {
54
38
  const routed = profile[tier];
55
- if (!routed) {
56
- throw new Error(
57
- `Profile "${profileName}" has no configuration for the ${tier} tier.`,
58
- );
59
- }
39
+ if (!routed)
40
+ throw new Error('No eligible route: selected tier is not configured.');
60
41
  const { provider, modelId } = parseCanonicalModelRef(routed.model);
61
- const baseThinking =
62
- routed.thinking ??
63
- (tier === 'high' ? 'high' : tier === 'low' ? 'low' : 'medium');
64
- const effectiveThinking = thinkingOverrides?.[tier] ?? baseThinking;
65
-
66
42
  return {
67
- profile: profileName,
68
43
  tier,
69
- phase,
70
- targetProvider: provider,
71
- targetModelId: modelId,
72
- targetLabel: routed.model,
73
- reasoning,
74
- thinking: effectiveThinking,
75
- timestamp: Date.now(),
76
- isClassifier,
44
+ model: `${provider}/${modelId}`,
45
+ thinking:
46
+ thinkingOverrides?.[tier] ??
47
+ routed.thinking ??
48
+ (tier === 'micro' ? 'off' : tier),
77
49
  };
78
50
  };
79
51
 
80
- export const decideRouting = (
81
- context: Context,
82
- profileName: string,
52
+ /**
53
+ * Revalidate the actual target against the live registry. A configured effort
54
+ * declaration is restrictive: it can reject a route, but never grants a
55
+ * capability the live model does not expose.
56
+ */
57
+ export const validateRoutePair = (
58
+ pair: RoutePair,
59
+ findModel: (provider: string, modelId: string) => Model<Api> | undefined,
60
+ imageAttached: boolean,
61
+ declaredLevels?: ModelDefinition['thinkingLevels'],
62
+ ): boolean => {
63
+ try {
64
+ const { provider, modelId } = parseCanonicalModelRef(pair.model);
65
+ if (provider === 'router') return false;
66
+ const model = findModel(provider, modelId);
67
+ return Boolean(
68
+ model?.input.includes(imageAttached ? 'image' : 'text') &&
69
+ getSupportedThinkingLevels(model).includes(pair.thinking) &&
70
+ (!declaredLevels ||
71
+ pair.thinking === 'off' ||
72
+ declaredLevels.includes(pair.thinking)),
73
+ );
74
+ } catch {
75
+ return false;
76
+ }
77
+ };
78
+
79
+ /**
80
+ * Return all generation attempts that the active profile can serve. The first
81
+ * route for a tier is its primary; subsequent routes are explicit generation
82
+ * fallbacks and remain in their configured order.
83
+ *
84
+ * Tier and fallback references are normalized before this boundary. Parse them
85
+ * directly so a canonical-looking model reference cannot resolve as an alias a
86
+ * second time.
87
+ */
88
+ export const availableRoutePairs = (
83
89
  profile: RouterProfile,
84
- previousDecision: RoutingDecision | undefined,
85
- pinnedTier?: RouterTier,
90
+ findModel: (provider: string, modelId: string) => Model<Api> | undefined,
91
+ imageAttached: boolean,
86
92
  thinkingOverrides?: RouterThinkingByTier,
87
- phaseBias = 0.5,
88
- rules?: RoutingRule[],
89
- isBudgetExceeded = false,
90
- ): RoutingDecision => {
91
- if (previousDecision?.profile !== profileName) previousDecision = undefined;
92
- const prompt = getLastUserText(context).toLowerCase();
93
- const recentConversation = getRecentConversationText(context);
94
- const toolResultCount = countToolResults(context);
95
- const wordCount = countWords(prompt);
96
- const multiLinePrompt = prompt.split('\n').length >= 4;
93
+ ): RoutePair[] =>
94
+ ROUTER_TIERS.flatMap((tier) => {
95
+ const config = profile[tier];
96
+ if (!config) return [];
97
97
 
98
- const explicitHighHints = [
99
- 'best',
100
- 'deep',
101
- 'deeply',
102
- 'carefully',
103
- 'thoroughly',
104
- 'robust',
105
- 'comprehensive',
106
- 'step by step',
107
- 'think hard',
108
- 'highest quality',
109
- 'ultrathink',
110
- ];
111
- const explicitLowHints = [
112
- 'fast',
113
- 'cheap',
114
- 'quick',
115
- 'quickly',
116
- 'brief',
117
- 'briefly',
118
- 'one sentence',
119
- 'one line',
120
- 'tiny',
121
- 'small',
122
- ];
123
- const planningKeywords = [
124
- 'plan',
125
- 'planning',
126
- 'architecture',
127
- 'architect',
128
- 'design',
129
- 'tradeoff',
130
- 'trade-off',
131
- 'research',
132
- 'investigate',
133
- 'root cause',
134
- 'analyze',
135
- 'analysis',
136
- 'migration',
137
- 'strategy',
138
- 'compare',
139
- 'options',
140
- 'approach',
141
- ];
142
- const summaryKeywords = [
143
- 'summarize',
144
- 'summary',
145
- 'changelog',
146
- 'rewrite',
147
- 'reformat',
148
- 'format',
149
- 'rename',
150
- 'explain briefly',
151
- 'recap',
152
- 'tl;dr',
153
- ];
154
- const implementationKeywords = [
155
- 'implement',
156
- 'code',
157
- 'fix',
158
- 'update',
159
- 'edit',
160
- 'write',
161
- 'refactor',
162
- 'add tests',
163
- 'patch',
164
- 'change',
165
- 'apply',
166
- 'continue',
167
- 'resume',
168
- 'make the changes',
169
- 'go ahead',
170
- ];
171
- const lookupKeywords = [
172
- 'where is',
173
- 'which file',
174
- 'show me',
175
- 'list',
176
- 'what files',
177
- 'find',
178
- 'grep',
179
- ];
98
+ let primary: RoutePair;
99
+ try {
100
+ primary = resolveRoutePair(profile, tier, thinkingOverrides);
101
+ } catch {
102
+ return [];
103
+ }
180
104
 
181
- let phase: RouterPhase = previousDecision?.phase ?? 'implementation';
182
- let tier: RouterTier = 'medium';
183
- let reasoning = 'Defaulted to medium tier for general coding work.';
184
- let isRuleMatched = false;
105
+ return [primary.model, ...(config.fallbacks ?? [])]
106
+ .flatMap((ref, index) => {
107
+ try {
108
+ const { provider, modelId } = parseCanonicalModelRef(ref);
109
+ const model = findModel(provider, modelId);
110
+ const ownConfig =
111
+ index === 0 ? config : config.resolvedFallbacks?.[index - 1];
112
+ // A non-reasoning model defaults to off, but explicit unsupported
113
+ // effort remains ineligible.
114
+ const thinking =
115
+ thinkingOverrides?.[tier] ??
116
+ ((config.thinkingExplicit ?? config.thinking !== undefined)
117
+ ? primary.thinking
118
+ : ownConfig?.reasoning === false || !model?.reasoning
119
+ ? 'off'
120
+ : primary.thinking);
121
+ const pair = { tier, model: `${provider}/${modelId}`, thinking };
122
+ return validateRoutePair(
123
+ pair,
124
+ findModel,
125
+ imageAttached,
126
+ ownConfig?.reasoning === false
127
+ ? []
128
+ : index === 0
129
+ ? (config.thinkingLevels ?? config.resolvedThinkingLevels)
130
+ : ownConfig?.thinkingLevels,
131
+ )
132
+ ? [pair]
133
+ : [];
134
+ } catch {
135
+ return [];
136
+ }
137
+ })
138
+ .filter(
139
+ (pair, index, pairs) =>
140
+ pairs.findIndex((other) => other.model === pair.model) === index,
141
+ );
142
+ });
185
143
 
186
- if (pinnedTier) {
187
- phase = phaseForTier(pinnedTier);
188
- tier = pinnedTier;
189
- reasoning = `Pinned to ${pinnedTier} tier via /router-pin.`;
190
- } else {
191
- // Check custom rules first
192
- if (rules) {
193
- let highestTier: RouterTier | undefined;
194
- let winningRule: RoutingRule | undefined;
195
- const tierRank: Record<RouterTier, number> = {
196
- low: 1,
197
- medium: 2,
198
- high: 3,
199
- };
144
+ /**
145
+ * Keep at least one configured route usable for each input kind when a
146
+ * thinking override is accepted. This prevents an override from silently
147
+ * removing all generation options while still allowing partial profiles and
148
+ * explicit pins to fail at their normal capability check.
149
+ */
150
+ export const preservesRouteCoverage = (
151
+ profile: RouterProfile,
152
+ findModel: (provider: string, modelId: string) => Model<Api> | undefined,
153
+ thinkingOverrides: RouterThinkingByTier,
154
+ ): boolean => {
155
+ for (const imageAttached of [false, true]) {
156
+ const configured = availableRoutePairs(profile, findModel, imageAttached);
157
+ const overridden = availableRoutePairs(
158
+ profile,
159
+ findModel,
160
+ imageAttached,
161
+ thinkingOverrides,
162
+ );
163
+ if (configured.length > 0 && overridden.length === 0) return false;
164
+ }
165
+ return [false, true].some(
166
+ (imageAttached) =>
167
+ availableRoutePairs(profile, findModel, imageAttached, thinkingOverrides)
168
+ .length > 0,
169
+ );
170
+ };
200
171
 
201
- for (const rule of rules) {
202
- const matches = Array.isArray(rule.matches)
203
- ? rule.matches
204
- : [rule.matches];
205
- const lowercaseMatches = matches.map((m) => m.toLowerCase());
206
- if (containsAny(prompt, lowercaseMatches)) {
207
- if (!highestTier || tierRank[rule.tier] > tierRank[highestTier]) {
208
- highestTier = rule.tier;
209
- winningRule = rule;
210
- }
211
- }
212
- }
172
+ export interface BaselineSelection {
173
+ pair: RoutePair;
174
+ reasonCode: 'baseline' | 'pinned' | 'budget';
175
+ isBudgetForced: boolean;
176
+ }
213
177
 
214
- if (winningRule && highestTier) {
215
- tier = highestTier;
216
- phase = phaseForTier(tier);
217
- const matches = Array.isArray(winningRule.matches)
218
- ? winningRule.matches
219
- : [winningRule.matches];
220
- reasoning =
221
- winningRule.reason ??
222
- `Matched custom routing rule for: ${matches.join(', ')}`;
223
- isRuleMatched = true;
224
- }
225
- }
178
+ const routeForTier = (
179
+ pairs: readonly RoutePair[],
180
+ tier: RouterTier,
181
+ ): RoutePair | undefined => pairs.find((pair) => pair.tier === tier);
226
182
 
227
- if (!isRuleMatched) {
228
- // Sticky phase adjustments
229
- const highThreshold = Math.max(
230
- 40,
231
- 120 - (previousDecision?.phase === 'planning' ? phaseBias * 80 : 0),
232
- );
233
- const lowThreshold = Math.max(
234
- 4,
235
- 12 -
236
- (previousDecision?.phase === 'implementation' ||
237
- previousDecision?.phase === 'planning'
238
- ? phaseBias * 8
239
- : 0),
240
- );
183
+ const preferredOrder = (profile: RouterProfile): readonly RouterTier[] => {
184
+ const baseline = profile.baselineTier;
185
+ if (!baseline || !profile[baseline]) return BASELINE_TIER_ORDER;
186
+ return [baseline, ...BASELINE_TIER_ORDER.filter((tier) => tier !== baseline)];
187
+ };
241
188
 
242
- if (containsAny(prompt, explicitHighHints)) {
243
- phase = 'planning';
244
- tier = 'high';
245
- reasoning =
246
- 'Detected an explicit request for deeper or higher-quality reasoning.';
247
- } else if (containsAny(prompt, explicitLowHints)) {
248
- phase = 'lightweight';
249
- tier = 'low';
250
- reasoning =
251
- 'Detected an explicit request for a faster or lighter response.';
252
- } else if (containsAny(prompt, summaryKeywords)) {
253
- phase = 'lightweight';
254
- tier = 'low';
255
- reasoning = 'Detected summary or lightweight transformation keywords.';
256
- } else if (
257
- containsAny(prompt, planningKeywords) ||
258
- prompt.startsWith('why ') ||
259
- wordCount >= highThreshold ||
260
- multiLinePrompt
261
- ) {
262
- phase = 'planning';
263
- tier = 'high';
264
- reasoning =
265
- previousDecision?.phase === 'planning'
266
- ? 'Continued planning phase based on complexity or keywords.'
267
- : 'Detected planning, broad analysis, or a high-complexity request.';
268
- } else if (containsAny(prompt, implementationKeywords)) {
269
- phase = 'implementation';
270
- tier = 'medium';
271
- reasoning =
272
- 'Detected implementation-oriented work with bounded execution scope.';
273
- } else if (
274
- containsAny(prompt, lookupKeywords) &&
275
- wordCount <= 24 &&
276
- toolResultCount === 0
277
- ) {
278
- phase = 'lightweight';
279
- tier = 'low';
280
- reasoning = 'Detected a short read-only lookup request.';
281
- } else if (
282
- previousDecision?.phase === 'planning' &&
283
- toolResultCount === 0 &&
284
- wordCount > lowThreshold
285
- ) {
286
- phase = 'planning';
287
- tier = 'high';
288
- reasoning =
289
- 'Kept the planning-phase bias because the conversation still looks exploratory.';
290
- } else if (
291
- toolResultCount > 0 ||
292
- previousDecision?.phase === 'implementation' ||
293
- recentConversation.includes('plan:')
294
- ) {
295
- phase = 'implementation';
296
- tier = 'medium';
297
- reasoning =
298
- 'Detected active implementation work from prior tools or recent plan execution context.';
299
- } else if (wordCount <= lowThreshold) {
300
- phase = 'lightweight';
301
- tier = 'low';
302
- reasoning = 'Detected a short bounded request.';
303
- }
304
- }
189
+ const chooseFromOrder = (
190
+ pairs: readonly RoutePair[],
191
+ order: readonly RouterTier[],
192
+ ): RoutePair | undefined => {
193
+ for (const tier of order) {
194
+ const pair = routeForTier(pairs, tier);
195
+ if (pair) return pair;
305
196
  }
197
+ return undefined;
198
+ };
306
199
 
307
- let isBudgetForced = false;
308
- if (isBudgetExceeded && tier === 'high') {
309
- tier = 'medium';
310
- phase = 'implementation';
311
- reasoning = `Budget exceeded. Downgraded from high to medium tier. (Original: ${reasoning})`;
312
- isBudgetForced = true;
200
+ /**
201
+ * Select the local route without inspecting prompt text. Eligibility has
202
+ * already been filtered by the registry/input/effort boundary.
203
+ */
204
+ export const selectBaselineRoute = (
205
+ profileName: string,
206
+ profile: RouterProfile,
207
+ pairs: readonly RoutePair[],
208
+ pinnedTier?: RouterTier,
209
+ isBudgetExceeded = false,
210
+ ): BaselineSelection => {
211
+ if (pinnedTier) {
212
+ const pair = routeForTier(pairs, pinnedTier);
213
+ if (!pair) {
214
+ throw new Error(
215
+ `Pinned tier "${pinnedTier}" for profile "${profileName}" has no eligible model for the current input and thinking level.`,
216
+ );
217
+ }
218
+ return { pair, reasonCode: 'pinned', isBudgetForced: false };
313
219
  }
314
220
 
315
- // Resolve to nearest available tier if the selected tier is disabled
316
- const resolvedTier =
317
- isBudgetForced && !profile.medium && profile.low
318
- ? 'low'
319
- : resolveAvailableTier(profile, tier);
320
- if (resolvedTier !== tier) {
321
- reasoning = `Resolved from ${tier} to ${resolvedTier} tier (${tier} tier is not configured). Original: ${reasoning}`;
322
- phase = phaseForTier(resolvedTier);
323
- tier = resolvedTier;
221
+ const order = preferredOrder(profile);
222
+ if (isBudgetExceeded) {
223
+ const budgetOrder = order.filter(
224
+ (tier) => tier === 'medium' || tier === 'low' || tier === 'micro',
225
+ );
226
+ const budgetPair = chooseFromOrder(pairs, budgetOrder);
227
+ if (budgetPair) {
228
+ return { pair: budgetPair, reasonCode: 'budget', isBudgetForced: true };
229
+ }
230
+ const configuredBaseline = chooseFromOrder(pairs, order);
231
+ if (configuredBaseline) {
232
+ return {
233
+ pair: configuredBaseline,
234
+ reasonCode: 'budget',
235
+ isBudgetForced: false,
236
+ };
237
+ }
238
+ } else {
239
+ const baseline = chooseFromOrder(pairs, order);
240
+ if (baseline)
241
+ return { pair: baseline, reasonCode: 'baseline', isBudgetForced: false };
324
242
  }
325
243
 
326
- const decision = buildRoutingDecision(
327
- profileName,
328
- profile,
329
- tier,
330
- phase,
331
- reasoning,
332
- thinkingOverrides,
333
- false,
244
+ throw new Error(
245
+ `No eligible route for profile "${profileName}": no configured model is available for the current input and thinking level.`,
334
246
  );
335
- decision.isRuleMatched = isRuleMatched;
336
- decision.isBudgetForced = isBudgetForced;
337
- return decision;
247
+ };
248
+
249
+ export const primaryRoutePairs = (
250
+ profile: RouterProfile,
251
+ pairs: readonly RoutePair[],
252
+ ): RoutePair[] =>
253
+ BASELINE_TIER_ORDER.flatMap((tier) => {
254
+ // Keep the effective effort already validated against the live registry.
255
+ const primary = pairs.find(
256
+ (pair) => pair.tier === tier && pair.model === profile[tier]?.model,
257
+ );
258
+ return primary ? [primary] : [];
259
+ });
260
+
261
+ export const decisionForPair = (
262
+ profile: string,
263
+ pair: RoutePair,
264
+ reasonCode: RoutingReasonCode,
265
+ ): RoutingDecision => {
266
+ const { provider, modelId } = parseCanonicalModelRef(pair.model);
267
+ return {
268
+ profile,
269
+ tier: pair.tier,
270
+ phase: phaseForTier(pair.tier),
271
+ targetProvider: provider,
272
+ targetModelId: modelId,
273
+ targetLabel: pair.model,
274
+ thinking: pair.thinking,
275
+ reasonCode,
276
+ timestamp: Date.now(),
277
+ };
338
278
  };
@@ -14,6 +14,7 @@ import type {
14
14
  RouterPinByProfile,
15
15
  RoutingDecision,
16
16
  } from './types';
17
+ import { isAdvisorOutcome, isRoutingReasonCode } from './types';
17
18
 
18
19
  const LAST_PROFILE_STATE_FILE = 'model-router-state.json';
19
20
 
@@ -30,20 +31,34 @@ const isModelRef = (value: unknown) => {
30
31
  return false;
31
32
  }
32
33
  };
34
+
35
+ // Historical snapshots remain readable, but obsolete prompt-derived sources
36
+ // are sanitized to `legacy` and never become live routing behavior again.
37
+ const OBSOLETE_REASON_CODES = new Set([
38
+ 'custom-rule',
39
+ 'micro-mechanical',
40
+ 'heuristic',
41
+ 'safety-floor',
42
+ 'budget-floor-conflict',
43
+ ]);
44
+ const isPersistedReasonCode = (value: unknown): boolean =>
45
+ isRoutingReasonCode(value) ||
46
+ (typeof value === 'string' && OBSOLETE_REASON_CODES.has(value));
47
+
33
48
  const isDecision = (value: unknown): value is RoutingDecision =>
34
49
  isObjectRecord(value) &&
35
50
  isRouterTier(value.tier) &&
36
51
  isPhase(value.phase) &&
37
52
  isThinkingLevel(value.thinking) &&
38
53
  isFiniteNumber(value.timestamp) &&
39
- [
40
- 'profile',
41
- 'targetProvider',
42
- 'targetModelId',
43
- 'targetLabel',
44
- 'reasoning',
45
- ].every((key) => typeof value[key] === 'string') &&
46
- ['isClassifier', 'isFallback', 'isBudgetForced', 'isRuleMatched'].every(
54
+ ['profile', 'targetProvider', 'targetModelId', 'targetLabel'].every(
55
+ (key) => typeof value[key] === 'string',
56
+ ) &&
57
+ (value.reasonCode === undefined
58
+ ? typeof value.reasoning === 'string'
59
+ : isPersistedReasonCode(value.reasonCode)) &&
60
+ (value.advisor === undefined || isAdvisorOutcome(value.advisor)) &&
61
+ ['isClassifier', 'isFallback', 'isBudgetForced'].every(
47
62
  (key) => value[key] === undefined || typeof value[key] === 'boolean',
48
63
  );
49
64
  const isMap = (value: unknown, validate: (entry: unknown) => boolean) =>
@@ -128,6 +143,36 @@ export const isRouterPersistedState = (
128
143
  );
129
144
  };
130
145
 
146
+ // Copy only the decision contract, never incidental runtime properties.
147
+ export const snapshotDecision = (
148
+ decision: RoutingDecision,
149
+ ): RoutingDecision => ({
150
+ profile: decision.profile,
151
+ tier: decision.tier,
152
+ phase: decision.phase,
153
+ targetProvider: decision.targetProvider,
154
+ targetModelId: decision.targetModelId,
155
+ targetLabel: decision.targetLabel,
156
+ reasonCode: isRoutingReasonCode(decision.reasonCode)
157
+ ? decision.reasonCode
158
+ : 'legacy',
159
+ routingLatencyMs:
160
+ isFiniteNumber(decision.routingLatencyMs) && decision.routingLatencyMs >= 0
161
+ ? decision.routingLatencyMs
162
+ : undefined,
163
+ errorClass:
164
+ decision.errorClass === 'advisor-unavailable' ||
165
+ decision.errorClass === 'deadline'
166
+ ? decision.errorClass
167
+ : undefined,
168
+ advisor: isAdvisorOutcome(decision.advisor) ? decision.advisor : undefined,
169
+ thinking: decision.thinking,
170
+ timestamp: decision.timestamp,
171
+ isClassifier: decision.isClassifier,
172
+ isFallback: decision.isFallback,
173
+ isBudgetForced: decision.isBudgetForced,
174
+ });
175
+
131
176
  export const buildPersistedState = ({
132
177
  routerEnabled,
133
178
  selectedProfile,
@@ -154,9 +199,9 @@ export const buildPersistedState = ({
154
199
  thinkingByProfile: { ...thinkingByProfile },
155
200
  debugEnabled,
156
201
  widgetEnabled,
157
- debugHistory,
202
+ debugHistory: debugHistory.map(snapshotDecision),
158
203
  lastPhase: lastDecision?.phase,
159
- lastDecision,
204
+ lastDecision: lastDecision ? snapshotDecision(lastDecision) : undefined,
160
205
  lastNonRouterModel,
161
206
  accumulatedCost,
162
207
  timestamp: Date.now(),