@alexeiled/pi-model-router 0.5.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.
@@ -0,0 +1,483 @@
1
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
2
+ import type { Context, Message } from '@earendil-works/pi-ai';
3
+ import { streamSimple } from '@earendil-works/pi-ai/compat';
4
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
5
+ import { isRouterTier, parseCanonicalModelRef } from './config';
6
+ import {
7
+ hasUsableRequestAuth,
8
+ type RegistryWithProviderAuth,
9
+ resolveDelegatedModel,
10
+ } from './constants';
11
+ import type {
12
+ RouterPhase,
13
+ RouterProfile,
14
+ RouterThinkingByTier,
15
+ RouterTier,
16
+ RoutingDecision,
17
+ RoutingRule,
18
+ } from './types';
19
+
20
+ export const extractTextFromContent = (
21
+ content: string | Message['content'],
22
+ ): string => {
23
+ if (typeof content === 'string') {
24
+ return content;
25
+ }
26
+ return content
27
+ .map((part) => {
28
+ if (part.type === 'text') return part.text;
29
+ if (part.type === 'thinking') return part.thinking;
30
+ if (part.type === 'toolCall')
31
+ return `${part.name} ${JSON.stringify(part.arguments)}`;
32
+ return '';
33
+ })
34
+ .filter(Boolean)
35
+ .join('\n');
36
+ };
37
+
38
+ export const getLastUserText = (context: Context): string => {
39
+ for (let i = context.messages.length - 1; i >= 0; i--) {
40
+ const message = context.messages[i];
41
+ if (message.role === 'user') {
42
+ return extractTextFromContent(message.content).trim();
43
+ }
44
+ }
45
+ return '';
46
+ };
47
+
48
+ export const getRecentConversationText = (
49
+ context: Context,
50
+ limit = 6,
51
+ ): string => {
52
+ return context.messages
53
+ .slice(-limit)
54
+ .map((message) => extractTextFromContent(message.content).trim())
55
+ .filter(Boolean)
56
+ .join('\n')
57
+ .toLowerCase();
58
+ };
59
+
60
+ export const countToolResults = (context: Context): number => {
61
+ return context.messages.filter((message) => message.role === 'toolResult')
62
+ .length;
63
+ };
64
+
65
+ export const countWords = (text: string): number => {
66
+ return text.split(/\s+/).filter(Boolean).length;
67
+ };
68
+
69
+ export const hasImageAttachment = (context: Context): boolean => {
70
+ return context.messages.some(
71
+ (message) =>
72
+ Array.isArray(message.content) &&
73
+ message.content.some((part) => part.type === 'image'),
74
+ );
75
+ };
76
+
77
+ export const containsAny = (text: string, keywords: string[]): boolean => {
78
+ return keywords.some((keyword) => text.includes(keyword));
79
+ };
80
+
81
+ export const phaseForTier = (tier: RouterTier): RouterPhase => {
82
+ if (tier === 'high') return 'planning';
83
+ if (tier === 'medium') return 'implementation';
84
+ return 'lightweight';
85
+ };
86
+
87
+ export const resolveAvailableTier = (
88
+ profile: RouterProfile,
89
+ preferred: RouterTier,
90
+ ): RouterTier => {
91
+ if (profile[preferred]) return preferred;
92
+ // Fall "up": low → medium → high
93
+ const order: RouterTier[] = ['low', 'medium', 'high'];
94
+ const startIdx = order.indexOf(preferred);
95
+ for (let i = startIdx + 1; i < order.length; i++) {
96
+ if (profile[order[i]]) return order[i];
97
+ }
98
+ // Fall "down" as last resort
99
+ for (let i = startIdx - 1; i >= 0; i--) {
100
+ if (profile[order[i]]) return order[i];
101
+ }
102
+ return preferred; // unreachable if profile has ≥1 tier
103
+ };
104
+
105
+ export const buildRoutingDecision = (
106
+ profileName: string,
107
+ profile: RouterProfile,
108
+ tier: RouterTier,
109
+ phase: RouterPhase,
110
+ reasoning: string,
111
+ thinkingOverrides?: RouterThinkingByTier,
112
+ isClassifier?: boolean,
113
+ ): RoutingDecision => {
114
+ const routed = profile[tier];
115
+ if (!routed) {
116
+ throw new Error(
117
+ `Profile "${profileName}" has no configuration for the ${tier} tier.`,
118
+ );
119
+ }
120
+ const { provider, modelId } = parseCanonicalModelRef(routed.model);
121
+ const baseThinking =
122
+ routed.thinking ??
123
+ (tier === 'high' ? 'high' : tier === 'low' ? 'low' : 'medium');
124
+ const effectiveThinking = thinkingOverrides?.[tier] ?? baseThinking;
125
+
126
+ return {
127
+ profile: profileName,
128
+ tier,
129
+ phase,
130
+ targetProvider: provider,
131
+ targetModelId: modelId,
132
+ targetLabel: routed.model,
133
+ reasoning,
134
+ thinking: effectiveThinking,
135
+ timestamp: Date.now(),
136
+ isClassifier,
137
+ };
138
+ };
139
+
140
+ export const decideRouting = (
141
+ context: Context,
142
+ profileName: string,
143
+ profile: RouterProfile,
144
+ previousDecision: RoutingDecision | undefined,
145
+ pinnedTier?: RouterTier,
146
+ thinkingOverrides?: RouterThinkingByTier,
147
+ phaseBias = 0.5,
148
+ rules?: RoutingRule[],
149
+ isBudgetExceeded = false,
150
+ ): RoutingDecision => {
151
+ const prompt = getLastUserText(context).toLowerCase();
152
+ const recentConversation = getRecentConversationText(context);
153
+ const toolResultCount = countToolResults(context);
154
+ const wordCount = countWords(prompt);
155
+ const multiLinePrompt = prompt.split('\n').length >= 4;
156
+
157
+ const explicitHighHints = [
158
+ 'best',
159
+ 'deep',
160
+ 'deeply',
161
+ 'carefully',
162
+ 'thoroughly',
163
+ 'robust',
164
+ 'comprehensive',
165
+ 'step by step',
166
+ 'think hard',
167
+ 'highest quality',
168
+ 'ultrathink',
169
+ ];
170
+ const explicitLowHints = [
171
+ 'fast',
172
+ 'cheap',
173
+ 'quick',
174
+ 'quickly',
175
+ 'brief',
176
+ 'briefly',
177
+ 'one sentence',
178
+ 'one line',
179
+ 'tiny',
180
+ 'small',
181
+ ];
182
+ const planningKeywords = [
183
+ 'plan',
184
+ 'planning',
185
+ 'architecture',
186
+ 'architect',
187
+ 'design',
188
+ 'tradeoff',
189
+ 'trade-off',
190
+ 'research',
191
+ 'investigate',
192
+ 'root cause',
193
+ 'analyze',
194
+ 'analysis',
195
+ 'migration',
196
+ 'strategy',
197
+ 'compare',
198
+ 'options',
199
+ 'approach',
200
+ ];
201
+ const summaryKeywords = [
202
+ 'summarize',
203
+ 'summary',
204
+ 'changelog',
205
+ 'rewrite',
206
+ 'reformat',
207
+ 'format',
208
+ 'rename',
209
+ 'explain briefly',
210
+ 'recap',
211
+ 'tl;dr',
212
+ ];
213
+ const implementationKeywords = [
214
+ 'implement',
215
+ 'code',
216
+ 'fix',
217
+ 'update',
218
+ 'edit',
219
+ 'write',
220
+ 'refactor',
221
+ 'add tests',
222
+ 'patch',
223
+ 'change',
224
+ 'apply',
225
+ 'continue',
226
+ 'resume',
227
+ 'make the changes',
228
+ 'go ahead',
229
+ ];
230
+ const lookupKeywords = [
231
+ 'where is',
232
+ 'which file',
233
+ 'show me',
234
+ 'list',
235
+ 'what files',
236
+ 'find',
237
+ 'grep',
238
+ ];
239
+
240
+ let phase: RouterPhase = previousDecision?.phase ?? 'implementation';
241
+ let tier: RouterTier = 'medium';
242
+ let reasoning = 'Defaulted to medium tier for general coding work.';
243
+ let isRuleMatched = false;
244
+
245
+ if (pinnedTier) {
246
+ phase = phaseForTier(pinnedTier);
247
+ tier = pinnedTier;
248
+ reasoning = `Pinned to ${pinnedTier} tier via /router-pin.`;
249
+ } else {
250
+ // Check custom rules first
251
+ if (rules) {
252
+ let highestTier: RouterTier | undefined;
253
+ let winningRule: RoutingRule | undefined;
254
+ const tierRank: Record<RouterTier, number> = {
255
+ low: 1,
256
+ medium: 2,
257
+ high: 3,
258
+ };
259
+
260
+ for (const rule of rules) {
261
+ const matches = Array.isArray(rule.matches)
262
+ ? rule.matches
263
+ : [rule.matches];
264
+ const lowercaseMatches = matches.map((m) => m.toLowerCase());
265
+ if (containsAny(prompt, lowercaseMatches)) {
266
+ if (!highestTier || tierRank[rule.tier] > tierRank[highestTier]) {
267
+ highestTier = rule.tier;
268
+ winningRule = rule;
269
+ }
270
+ }
271
+ }
272
+
273
+ if (winningRule && highestTier) {
274
+ tier = highestTier;
275
+ phase = phaseForTier(tier);
276
+ const matches = Array.isArray(winningRule.matches)
277
+ ? winningRule.matches
278
+ : [winningRule.matches];
279
+ reasoning =
280
+ winningRule.reason ??
281
+ `Matched custom routing rule for: ${matches.join(', ')}`;
282
+ isRuleMatched = true;
283
+ }
284
+ }
285
+
286
+ if (!isRuleMatched) {
287
+ // Sticky phase adjustments
288
+ const highThreshold = Math.max(
289
+ 40,
290
+ 120 - (previousDecision?.phase === 'planning' ? phaseBias * 80 : 0),
291
+ );
292
+ const lowThreshold = Math.max(
293
+ 4,
294
+ 12 -
295
+ (previousDecision?.phase === 'implementation' ||
296
+ previousDecision?.phase === 'planning'
297
+ ? phaseBias * 8
298
+ : 0),
299
+ );
300
+
301
+ if (containsAny(prompt, explicitHighHints)) {
302
+ phase = 'planning';
303
+ tier = 'high';
304
+ reasoning =
305
+ 'Detected an explicit request for deeper or higher-quality reasoning.';
306
+ } else if (containsAny(prompt, explicitLowHints)) {
307
+ phase = 'lightweight';
308
+ tier = 'low';
309
+ reasoning =
310
+ 'Detected an explicit request for a faster or lighter response.';
311
+ } else if (containsAny(prompt, summaryKeywords)) {
312
+ phase = 'lightweight';
313
+ tier = 'low';
314
+ reasoning = 'Detected summary or lightweight transformation keywords.';
315
+ } else if (
316
+ containsAny(prompt, planningKeywords) ||
317
+ prompt.startsWith('why ') ||
318
+ wordCount >= highThreshold ||
319
+ multiLinePrompt
320
+ ) {
321
+ phase = 'planning';
322
+ tier = 'high';
323
+ reasoning =
324
+ previousDecision?.phase === 'planning'
325
+ ? 'Continued planning phase based on complexity or keywords.'
326
+ : 'Detected planning, broad analysis, or a high-complexity request.';
327
+ } else if (containsAny(prompt, implementationKeywords)) {
328
+ phase = 'implementation';
329
+ tier = 'medium';
330
+ reasoning =
331
+ 'Detected implementation-oriented work with bounded execution scope.';
332
+ } else if (
333
+ containsAny(prompt, lookupKeywords) &&
334
+ wordCount <= 24 &&
335
+ toolResultCount === 0
336
+ ) {
337
+ phase = 'lightweight';
338
+ tier = 'low';
339
+ reasoning = 'Detected a short read-only lookup request.';
340
+ } else if (
341
+ previousDecision?.phase === 'planning' &&
342
+ toolResultCount === 0 &&
343
+ wordCount > lowThreshold
344
+ ) {
345
+ phase = 'planning';
346
+ tier = 'high';
347
+ reasoning =
348
+ 'Kept the planning-phase bias because the conversation still looks exploratory.';
349
+ } else if (
350
+ toolResultCount > 0 ||
351
+ previousDecision?.phase === 'implementation' ||
352
+ recentConversation.includes('plan:')
353
+ ) {
354
+ phase = 'implementation';
355
+ tier = 'medium';
356
+ reasoning =
357
+ 'Detected active implementation work from prior tools or recent plan execution context.';
358
+ } else if (wordCount <= lowThreshold) {
359
+ phase = 'lightweight';
360
+ tier = 'low';
361
+ reasoning = 'Detected a short bounded request.';
362
+ }
363
+ }
364
+ }
365
+
366
+ let isBudgetForced = false;
367
+ if (isBudgetExceeded && tier === 'high') {
368
+ tier = 'medium';
369
+ phase = 'implementation';
370
+ reasoning = `Budget exceeded. Downgraded from high to medium tier. (Original: ${reasoning})`;
371
+ isBudgetForced = true;
372
+ }
373
+
374
+ // Resolve to nearest available tier if the selected tier is disabled
375
+ const resolvedTier = resolveAvailableTier(profile, tier);
376
+ if (resolvedTier !== tier) {
377
+ reasoning = `Resolved from ${tier} to ${resolvedTier} tier (${tier} tier is not configured). Original: ${reasoning}`;
378
+ phase = phaseForTier(resolvedTier);
379
+ tier = resolvedTier;
380
+ }
381
+
382
+ const decision = buildRoutingDecision(
383
+ profileName,
384
+ profile,
385
+ tier,
386
+ phase,
387
+ reasoning,
388
+ thinkingOverrides,
389
+ false,
390
+ );
391
+ decision.isRuleMatched = isRuleMatched;
392
+ decision.isBudgetForced = isBudgetForced;
393
+ return decision;
394
+ };
395
+
396
+ export const runClassifier = async (
397
+ classifierModelRef: string,
398
+ modelRegistry: ExtensionContext['modelRegistry'],
399
+ context: Context,
400
+ currentPhase?: RouterPhase,
401
+ thinking?: ThinkingLevel,
402
+ ): Promise<{ tier: RouterTier; reasoning: string } | undefined> => {
403
+ try {
404
+ const { provider, modelId } = parseCanonicalModelRef(classifierModelRef);
405
+ const model = modelRegistry.find(provider, modelId);
406
+ if (!model) return undefined;
407
+
408
+ const auth = await modelRegistry.getApiKeyAndHeaders(model);
409
+ if (!auth.ok || !hasUsableRequestAuth(auth)) return undefined;
410
+ const apiKey = auth.apiKey;
411
+ const headers = auth.headers;
412
+ const requestModel = await resolveDelegatedModel(
413
+ modelRegistry as unknown as RegistryWithProviderAuth,
414
+ model,
415
+ );
416
+
417
+ const promptText = getLastUserText(context);
418
+ const historyText = getRecentConversationText(context, 4);
419
+
420
+ const classifierPrompt = `You are a model router classifier. Your job is to categorize the user's latest request into one of three tiers: "high", "medium", or "low".
421
+
422
+ Tiers:
423
+ - high: Architecture, design, planning, tradeoff analysis, broad debugging, large refactors, codebase research.
424
+ - medium: Implementation of a known plan, multi-file edits, normal coding work, focused debugging, tests/fixes.
425
+ - low: Summaries, changelogs, formatting, quick explanations, small bounded transforms, simple read-only lookup.
426
+
427
+ ${currentPhase ? `Current conversation phase: ${currentPhase}\n` : ''}
428
+ Recent history:
429
+ ${historyText}
430
+
431
+ Latest user message:
432
+ ${promptText}
433
+
434
+ Return your decision in exactly two lines:
435
+ Tier: [high|medium|low]
436
+ Reasoning: [one short sentence]
437
+
438
+ ${currentPhase === 'planning' ? 'Consider that the conversation is currently in a planning phase. Bias toward "high" unless the request is clearly a simple implementation or summary.' : ''}
439
+ ${currentPhase === 'implementation' ? 'Consider that the conversation is currently in an implementation phase. Bias toward "medium" unless the request is clearly planning or a simple summary.' : ''}`;
440
+
441
+ const classifierContext: Context = {
442
+ messages: [
443
+ { role: 'user', content: classifierPrompt, timestamp: Date.now() },
444
+ ],
445
+ };
446
+
447
+ const reasoningOption =
448
+ model.reasoning && thinking && thinking !== 'off' ? thinking : undefined;
449
+
450
+ const stream = streamSimple(requestModel, classifierContext, {
451
+ apiKey,
452
+ headers,
453
+ ...(reasoningOption ? { reasoning: reasoningOption } : {}),
454
+ });
455
+ let fullText = '';
456
+ for await (const event of stream) {
457
+ if (event.type === 'text_delta' && typeof event.delta === 'string') {
458
+ fullText += event.delta;
459
+ }
460
+ }
461
+
462
+ const lines = fullText.trim().split('\n');
463
+ const tierLine = lines.find((l) => l.toLowerCase().startsWith('tier:'));
464
+ const reasoningLine = lines.find((l) =>
465
+ l.toLowerCase().startsWith('reasoning:'),
466
+ );
467
+
468
+ if (tierLine) {
469
+ const tierValue = tierLine.split(':')[1].trim().toLowerCase();
470
+ if (isRouterTier(tierValue)) {
471
+ return {
472
+ tier: tierValue,
473
+ reasoning: reasoningLine
474
+ ? reasoningLine.split(':')[1].trim()
475
+ : 'Classifier decision.',
476
+ };
477
+ }
478
+ }
479
+ } catch {
480
+ // Ignore classifier errors and fall back to heuristics
481
+ }
482
+ return undefined;
483
+ };
@@ -0,0 +1,101 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getAgentDir } from '@earendil-works/pi-coding-agent';
4
+ import type {
5
+ RouterLastProfileState,
6
+ RouterPersistedState,
7
+ RouterPinByProfile,
8
+ RouterThinkingByProfile,
9
+ RoutingDecision,
10
+ } from './types';
11
+
12
+ const LAST_PROFILE_STATE_FILE = 'model-router-state.json';
13
+
14
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
15
+ typeof value === 'object' && value !== null && !Array.isArray(value);
16
+
17
+ export const isRouterLastProfileState = (
18
+ value: unknown,
19
+ ): value is RouterLastProfileState =>
20
+ isRecord(value) &&
21
+ typeof value.selectedProfile === 'string' &&
22
+ value.selectedProfile.length > 0 &&
23
+ typeof value.timestamp === 'number';
24
+
25
+ export const loadLastRouterProfile = (
26
+ agentDir = getAgentDir(),
27
+ ): string | undefined => {
28
+ try {
29
+ const value: unknown = JSON.parse(
30
+ readFileSync(join(agentDir, LAST_PROFILE_STATE_FILE), 'utf8'),
31
+ );
32
+ return isRouterLastProfileState(value) ? value.selectedProfile : undefined;
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ };
37
+
38
+ export const saveLastRouterProfile = (
39
+ selectedProfile: string,
40
+ agentDir = getAgentDir(),
41
+ ): boolean => {
42
+ const state: RouterLastProfileState = {
43
+ selectedProfile,
44
+ timestamp: Date.now(),
45
+ };
46
+ try {
47
+ writeFileSync(
48
+ join(agentDir, LAST_PROFILE_STATE_FILE),
49
+ `${JSON.stringify(state, null, 2)}\n`,
50
+ { encoding: 'utf8', mode: 0o600 },
51
+ );
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ };
57
+
58
+ export const isRouterPersistedState = (
59
+ value: unknown,
60
+ ): value is RouterPersistedState => {
61
+ if (typeof value !== 'object' || value === null) {
62
+ return false;
63
+ }
64
+ if (!isRecord(value)) {
65
+ return false;
66
+ }
67
+ return (
68
+ typeof value.enabled === 'boolean' &&
69
+ typeof value.selectedProfile === 'string' &&
70
+ typeof value.timestamp === 'number'
71
+ );
72
+ };
73
+
74
+ export const buildPersistedState = (
75
+ routerEnabled: boolean,
76
+ selectedProfile: string | undefined,
77
+ pinnedTierByProfile: RouterPinByProfile,
78
+ thinkingByProfile: RouterThinkingByProfile,
79
+ debugEnabled: boolean,
80
+ widgetEnabled: boolean,
81
+ debugHistory: RoutingDecision[],
82
+ lastDecision: RoutingDecision | undefined,
83
+ lastNonRouterModel: string | undefined,
84
+ accumulatedCost: number,
85
+ ): RouterPersistedState => {
86
+ return {
87
+ enabled: routerEnabled,
88
+ selectedProfile: selectedProfile ?? '',
89
+ pinTier: selectedProfile ? pinnedTierByProfile[selectedProfile] : undefined,
90
+ pinByProfile: { ...pinnedTierByProfile },
91
+ thinkingByProfile: { ...thinkingByProfile },
92
+ debugEnabled,
93
+ widgetEnabled,
94
+ debugHistory,
95
+ lastPhase: lastDecision?.phase,
96
+ lastDecision,
97
+ lastNonRouterModel,
98
+ accumulatedCost,
99
+ timestamp: Date.now(),
100
+ };
101
+ };
@@ -0,0 +1,109 @@
1
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
2
+
3
+ export type RouterTier = 'high' | 'medium' | 'low';
4
+ export type RouterPin = RouterTier | 'auto';
5
+ export type RouterPhase = 'planning' | 'implementation' | 'lightweight';
6
+ export type RouterPinByProfile = Partial<Record<string, RouterTier>>;
7
+ export type RouterThinkingByTier = Partial<Record<RouterTier, ThinkingLevel>>;
8
+ export type RouterThinkingByProfile = Record<string, RouterThinkingByTier>;
9
+
10
+ export interface RoutingRule {
11
+ matches: string | string[];
12
+ tier: RouterTier;
13
+ reason?: string;
14
+ }
15
+
16
+ export interface ModelDefinition {
17
+ model: string;
18
+ contextWindow?: number;
19
+ maxTokens?: number;
20
+ reasoning?: boolean;
21
+ thinkingLevels?: ThinkingLevel[];
22
+ }
23
+
24
+ export interface ClassifierConfig {
25
+ model: string;
26
+ thinking?: ThinkingLevel;
27
+ }
28
+
29
+ export interface RoutedTierConfig {
30
+ model: string;
31
+ thinking?: ThinkingLevel;
32
+ fallbacks?: string[];
33
+ contextWindow?: number;
34
+ maxTokens?: number;
35
+ reasoning?: boolean;
36
+ thinkingLevels?: ThinkingLevel[];
37
+ resolvedContextWindow?: number;
38
+ resolvedMaxTokens?: number;
39
+ resolvedThinkingLevels?: ThinkingLevel[];
40
+ }
41
+
42
+ export interface RouterProfile {
43
+ high?: RoutedTierConfig;
44
+ medium?: RoutedTierConfig;
45
+ low?: RoutedTierConfig;
46
+ }
47
+
48
+ export interface RouterConfig {
49
+ debug?: boolean;
50
+ classifierModel?: ClassifierConfig;
51
+ phaseBias?: number;
52
+ maxSessionBudget?: number;
53
+ rules?: RoutingRule[];
54
+ profiles: Record<string, RouterProfile>;
55
+ models?: Record<string, ModelDefinition>;
56
+ }
57
+
58
+ export interface RoutingDecision {
59
+ profile: string;
60
+ tier: RouterTier;
61
+ phase: RouterPhase;
62
+ targetProvider: string;
63
+ targetModelId: string;
64
+ targetLabel: string;
65
+ reasoning: string;
66
+ thinking: ThinkingLevel;
67
+ timestamp: number;
68
+ isClassifier?: boolean;
69
+ isFallback?: boolean;
70
+ isBudgetForced?: boolean;
71
+ isRuleMatched?: boolean;
72
+ }
73
+
74
+ export interface RouterLastProfileState {
75
+ selectedProfile: string;
76
+ timestamp: number;
77
+ }
78
+
79
+ export interface RouterPersistedState {
80
+ enabled: boolean;
81
+ selectedProfile: string;
82
+ pinTier?: RouterTier;
83
+ pinByProfile?: RouterPinByProfile;
84
+ thinkingByProfile?: RouterThinkingByProfile;
85
+ debugEnabled?: boolean;
86
+ widgetEnabled?: boolean;
87
+ debugHistory?: RoutingDecision[];
88
+ lastPhase?: RouterPhase;
89
+ lastDecision?: RoutingDecision;
90
+ lastNonRouterModel?: string;
91
+ accumulatedCost?: number;
92
+ timestamp: number;
93
+ }
94
+
95
+ export interface ConfigLoadResult {
96
+ config: RouterConfig;
97
+ warnings: string[];
98
+ }
99
+
100
+ export interface ParsedConfigFile {
101
+ config: Partial<RouterConfig>;
102
+ warnings: string[];
103
+ }
104
+
105
+ export interface CustomSessionEntry {
106
+ type: string;
107
+ customType?: string;
108
+ data?: unknown;
109
+ }