@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,642 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
4
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
5
+ import { getAgentDir } from '@earendil-works/pi-coding-agent';
6
+ import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
7
+ import type {
8
+ ClassifierConfig,
9
+ ConfigLoadResult,
10
+ ModelDefinition,
11
+ ParsedConfigFile,
12
+ RoutedTierConfig,
13
+ RouterConfig,
14
+ RouterProfile,
15
+ RouterTier,
16
+ RoutingRule,
17
+ } from './types';
18
+
19
+ export const ROUTER_TIERS = ['high', 'medium', 'low'] as const;
20
+
21
+ // Pi accepts this model capability at runtime, but older peer type releases omit it.
22
+ export const MAX_THINKING_LEVEL = 'max' as ThinkingLevel;
23
+
24
+ export const THINKING_LEVELS: readonly ThinkingLevel[] = [
25
+ 'off',
26
+ 'minimal',
27
+ 'low',
28
+ 'medium',
29
+ 'high',
30
+ 'xhigh',
31
+ MAX_THINKING_LEVEL,
32
+ ];
33
+ export const ROUTER_PIN_VALUES = ['auto', 'high', 'medium', 'low'] as const;
34
+
35
+ export const DEFAULT_THINKING_LEVELS: readonly ThinkingLevel[] = [
36
+ 'high',
37
+ 'medium',
38
+ 'low',
39
+ ] as const;
40
+
41
+ export const isObjectRecord = (
42
+ value: unknown,
43
+ ): value is Record<string, unknown> =>
44
+ typeof value === 'object' && value !== null && !Array.isArray(value);
45
+
46
+ export const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
47
+ typeof value === 'string' && THINKING_LEVELS.includes(value as ThinkingLevel);
48
+
49
+ export const isRouterTier = (value: unknown): value is RouterTier =>
50
+ value === 'high' || value === 'medium' || value === 'low';
51
+
52
+ export const parseConfigFile = (path: string): ParsedConfigFile => {
53
+ if (!existsSync(path)) {
54
+ return { config: {}, warnings: [] };
55
+ }
56
+
57
+ try {
58
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
59
+ if (!isObjectRecord(parsed)) {
60
+ return {
61
+ config: {},
62
+ warnings: [`Ignored router config at ${path}: expected a JSON object.`],
63
+ };
64
+ }
65
+ return { config: parsed as Partial<RouterConfig>, warnings: [] };
66
+ } catch (error) {
67
+ return {
68
+ config: {},
69
+ warnings: [
70
+ `Failed to parse router config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
71
+ ],
72
+ };
73
+ }
74
+ };
75
+
76
+ /**
77
+ * Resolve a model reference: if it matches a key in the models map,
78
+ * return the canonical ref and definition; otherwise treat it as a
79
+ * canonical "provider/model" ref.
80
+ */
81
+ export const resolveModelRef = (
82
+ ref: string,
83
+ models: Record<string, ModelDefinition> | undefined,
84
+ ): { canonicalRef: string; definition?: ModelDefinition } => {
85
+ const definition = models?.[ref];
86
+ if (definition) {
87
+ return { canonicalRef: definition.model, definition };
88
+ }
89
+ return { canonicalRef: ref };
90
+ };
91
+
92
+ const mergeTier = (
93
+ existing?: RoutedTierConfig,
94
+ next?: Partial<RoutedTierConfig>,
95
+ ): RoutedTierConfig | undefined => {
96
+ if (!existing && !next) return undefined;
97
+ if (!next) return existing;
98
+ if (!existing) return next as RoutedTierConfig;
99
+ return { ...existing, ...next };
100
+ };
101
+
102
+ export const mergeConfig = (
103
+ base: RouterConfig,
104
+ override: Partial<RouterConfig>,
105
+ ): RouterConfig => {
106
+ const mergedProfiles: Record<string, RouterProfile> = { ...base.profiles };
107
+ for (const [name, profile] of Object.entries(override.profiles ?? {})) {
108
+ const existing = mergedProfiles[name];
109
+ const nextProfile = profile as Partial<RouterProfile>;
110
+ mergedProfiles[name] = {
111
+ high: mergeTier(existing?.high, nextProfile.high),
112
+ medium: mergeTier(existing?.medium, nextProfile.medium),
113
+ low: mergeTier(existing?.low, nextProfile.low),
114
+ };
115
+ }
116
+
117
+ const mergedModels: Record<string, ModelDefinition> = {
118
+ ...(base.models ?? {}),
119
+ ...(override.models ?? {}),
120
+ };
121
+
122
+ return {
123
+ debug: override.debug ?? base.debug,
124
+ classifierModel: override.classifierModel ?? base.classifierModel,
125
+ phaseBias: override.phaseBias ?? base.phaseBias,
126
+ maxSessionBudget: override.maxSessionBudget ?? base.maxSessionBudget,
127
+ rules: override.rules ?? base.rules,
128
+ profiles: mergedProfiles,
129
+ models: Object.keys(mergedModels).length > 0 ? mergedModels : undefined,
130
+ };
131
+ };
132
+
133
+ export const parseCanonicalModelRef = (
134
+ value: string,
135
+ ): { provider: string; modelId: string } => {
136
+ const slashIndex = value.indexOf('/');
137
+ if (slashIndex === -1) {
138
+ throw new Error(
139
+ `Invalid model reference "${value}". Expected "provider/model".`,
140
+ );
141
+ }
142
+ const provider = value.slice(0, slashIndex).trim();
143
+ const modelId = value.slice(slashIndex + 1).trim();
144
+ if (!provider || !modelId) {
145
+ throw new Error(
146
+ `Invalid model reference "${value}". Expected "provider/model".`,
147
+ );
148
+ }
149
+ return { provider, modelId };
150
+ };
151
+
152
+ /**
153
+ * Validate and normalize the models map from config.
154
+ */
155
+ export const normalizeModelsMap = (
156
+ raw: Record<string, unknown> | undefined,
157
+ warnings: string[],
158
+ ): Record<string, ModelDefinition> => {
159
+ const result: Record<string, ModelDefinition> = {};
160
+ if (!raw || !isObjectRecord(raw)) return result;
161
+
162
+ for (const [alias, entry] of Object.entries(raw)) {
163
+ if (!isObjectRecord(entry)) {
164
+ warnings.push(
165
+ `Ignored invalid model definition "${alias}": expected an object.`,
166
+ );
167
+ continue;
168
+ }
169
+
170
+ const model = typeof entry.model === 'string' ? entry.model.trim() : '';
171
+ if (!model) {
172
+ warnings.push(
173
+ `Model definition "${alias}" is missing the "model" field. Skipped.`,
174
+ );
175
+ continue;
176
+ }
177
+
178
+ try {
179
+ parseCanonicalModelRef(model);
180
+ } catch (error) {
181
+ warnings.push(
182
+ `Model definition "${alias}": ${error instanceof Error ? error.message : String(error)}`,
183
+ );
184
+ continue;
185
+ }
186
+
187
+ const contextWindow =
188
+ typeof entry.contextWindow === 'number' && entry.contextWindow > 0
189
+ ? entry.contextWindow
190
+ : undefined;
191
+ if (entry.contextWindow !== undefined && !contextWindow) {
192
+ warnings.push(
193
+ `Model definition "${alias}" has invalid contextWindow. Ignored.`,
194
+ );
195
+ }
196
+
197
+ const maxTokens =
198
+ typeof entry.maxTokens === 'number' && entry.maxTokens > 0
199
+ ? entry.maxTokens
200
+ : undefined;
201
+ if (entry.maxTokens !== undefined && !maxTokens) {
202
+ warnings.push(
203
+ `Model definition "${alias}" has invalid maxTokens. Ignored.`,
204
+ );
205
+ }
206
+
207
+ const reasoning =
208
+ typeof entry.reasoning === 'boolean' ? entry.reasoning : undefined;
209
+
210
+ let thinkingLevels: ThinkingLevel[] | undefined;
211
+ if (Array.isArray(entry.thinkingLevels)) {
212
+ thinkingLevels = entry.thinkingLevels.filter((l): l is ThinkingLevel =>
213
+ isThinkingLevel(l),
214
+ );
215
+ if (thinkingLevels.length === 0) thinkingLevels = undefined;
216
+ }
217
+
218
+ result[alias] = {
219
+ model,
220
+ contextWindow,
221
+ maxTokens,
222
+ reasoning,
223
+ thinkingLevels,
224
+ };
225
+ }
226
+
227
+ return result;
228
+ };
229
+
230
+ export const normalizeTierConfig = (
231
+ value: unknown,
232
+ profileName: string,
233
+ tier: RouterTier,
234
+ warnings: string[],
235
+ models?: Record<string, ModelDefinition>,
236
+ ): RoutedTierConfig | undefined => {
237
+ if (!isObjectRecord(value)) {
238
+ return undefined;
239
+ }
240
+
241
+ const rawModel = typeof value.model === 'string' ? value.model.trim() : '';
242
+
243
+ if (!rawModel) {
244
+ warnings.push(
245
+ `Profile "${profileName}" ${tier} tier is missing a model. Tier disabled.`,
246
+ );
247
+ return undefined;
248
+ }
249
+
250
+ // Try to resolve as an alias first
251
+ const resolved = resolveModelRef(rawModel, models);
252
+ const aliasDefinition = resolved.definition;
253
+ let parsedModel: string;
254
+ try {
255
+ parseCanonicalModelRef(resolved.canonicalRef);
256
+ parsedModel = resolved.canonicalRef;
257
+ } catch (error) {
258
+ warnings.push(
259
+ `Profile "${profileName}" ${tier} tier: ${error instanceof Error ? error.message : String(error)} Tier disabled.`,
260
+ );
261
+ return undefined;
262
+ }
263
+
264
+ const thinking = isThinkingLevel(value.thinking) ? value.thinking : 'medium';
265
+ if (value.thinking !== undefined && !isThinkingLevel(value.thinking)) {
266
+ warnings.push(
267
+ `Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to medium.`,
268
+ );
269
+ }
270
+
271
+ let fallbacks: string[] | undefined;
272
+ if (Array.isArray(value.fallbacks)) {
273
+ fallbacks = [];
274
+ for (const f of value.fallbacks) {
275
+ if (typeof f === 'string') {
276
+ // Resolve aliases in fallbacks too
277
+ const resolvedFallback = resolveModelRef(f, models);
278
+ try {
279
+ parseCanonicalModelRef(resolvedFallback.canonicalRef);
280
+ fallbacks.push(resolvedFallback.canonicalRef);
281
+ } catch (error) {
282
+ warnings.push(
283
+ `Invalid fallback model "${f}" in profile "${profileName}" ${tier} tier: ${error instanceof Error ? error.message : String(error)}`,
284
+ );
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ // Resolve contextWindow: tier config > alias > hardcoded default
291
+ const tierContextWindow =
292
+ typeof value.contextWindow === 'number' && value.contextWindow > 0
293
+ ? value.contextWindow
294
+ : undefined;
295
+ const resolvedContextWindow =
296
+ tierContextWindow ??
297
+ aliasDefinition?.contextWindow ??
298
+ DEFAULT_CONTEXT_WINDOW;
299
+
300
+ // Resolve maxTokens: tier config > alias > hardcoded default
301
+ const tierMaxTokens =
302
+ typeof value.maxTokens === 'number' && value.maxTokens > 0
303
+ ? value.maxTokens
304
+ : undefined;
305
+ const resolvedMaxTokens =
306
+ tierMaxTokens ?? aliasDefinition?.maxTokens ?? DEFAULT_MAX_TOKENS;
307
+
308
+ // Resolve reasoning: tier config > alias > undefined (assumed true)
309
+ const tierReasoning =
310
+ typeof value.reasoning === 'boolean' ? value.reasoning : undefined;
311
+ const effectiveReasoning = tierReasoning ?? aliasDefinition?.reasoning;
312
+
313
+ // Resolve thinkingLevels: tier config > alias > default
314
+ // Validate tier-level thinkingLevels array
315
+ let tierThinkingLevels: ThinkingLevel[] | undefined;
316
+ if (Array.isArray(value.thinkingLevels)) {
317
+ tierThinkingLevels = (value.thinkingLevels as unknown[]).filter(
318
+ (l): l is ThinkingLevel => isThinkingLevel(l),
319
+ );
320
+ if (tierThinkingLevels.length === 0) tierThinkingLevels = undefined;
321
+ }
322
+
323
+ const explicitThinkingLevels =
324
+ tierThinkingLevels ?? aliasDefinition?.thinkingLevels;
325
+ const baseThinkingLevels: ThinkingLevel[] =
326
+ explicitThinkingLevels ??
327
+ (effectiveReasoning === false ? [] : [...DEFAULT_THINKING_LEVELS]);
328
+
329
+ // Auto-add the tier's thinking value if it's not 'off' and not already present,
330
+ // but only if the user didn't explicitly constrain the thinkingLevels array.
331
+ const resolvedThinkingLevels: ThinkingLevel[] = [...baseThinkingLevels];
332
+ if (
333
+ !explicitThinkingLevels &&
334
+ thinking !== 'off' &&
335
+ !resolvedThinkingLevels.includes(thinking)
336
+ ) {
337
+ resolvedThinkingLevels.push(thinking);
338
+ }
339
+
340
+ return {
341
+ model: parsedModel,
342
+ thinking,
343
+ fallbacks,
344
+ contextWindow: tierContextWindow,
345
+ maxTokens: tierMaxTokens,
346
+ reasoning: tierReasoning,
347
+ thinkingLevels: tierThinkingLevels,
348
+ resolvedContextWindow,
349
+ resolvedMaxTokens,
350
+ resolvedThinkingLevels,
351
+ };
352
+ };
353
+
354
+ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
355
+ const warnings: string[] = [];
356
+
357
+ // Normalize models map first so aliases are available during tier normalization
358
+ const normalizedModels = normalizeModelsMap(
359
+ raw.models as Record<string, unknown> | undefined,
360
+ warnings,
361
+ );
362
+ const hasModels = Object.keys(normalizedModels).length > 0;
363
+
364
+ const normalizedProfiles: Record<string, RouterProfile> = {};
365
+
366
+ for (const [name, profile] of Object.entries(raw.profiles ?? {})) {
367
+ const high = normalizeTierConfig(
368
+ profile?.high,
369
+ name,
370
+ 'high',
371
+ warnings,
372
+ hasModels ? normalizedModels : undefined,
373
+ );
374
+ const medium = normalizeTierConfig(
375
+ profile?.medium,
376
+ name,
377
+ 'medium',
378
+ warnings,
379
+ hasModels ? normalizedModels : undefined,
380
+ );
381
+ const low = normalizeTierConfig(
382
+ profile?.low,
383
+ name,
384
+ 'low',
385
+ warnings,
386
+ hasModels ? normalizedModels : undefined,
387
+ );
388
+
389
+ if (!high && !medium && !low) {
390
+ warnings.push(`Profile "${name}" has no valid tiers. Skipped.`);
391
+ continue;
392
+ }
393
+
394
+ normalizedProfiles[name] = { high, medium, low };
395
+ }
396
+
397
+ const phaseBias =
398
+ typeof raw.phaseBias === 'number'
399
+ ? Math.max(0, Math.min(1, raw.phaseBias))
400
+ : 0.5;
401
+
402
+ const maxSessionBudget =
403
+ typeof raw.maxSessionBudget === 'number' && raw.maxSessionBudget > 0
404
+ ? raw.maxSessionBudget
405
+ : undefined;
406
+
407
+ const rules: RoutingRule[] = [];
408
+ if (Array.isArray(raw.rules)) {
409
+ for (const rule of raw.rules) {
410
+ if (isObjectRecord(rule)) {
411
+ const matches = rule.matches;
412
+ const tier = rule.tier;
413
+ if (
414
+ (typeof matches === 'string' || Array.isArray(matches)) &&
415
+ isRouterTier(tier)
416
+ ) {
417
+ rules.push({
418
+ matches,
419
+ tier,
420
+ reason: typeof rule.reason === 'string' ? rule.reason : undefined,
421
+ });
422
+ } else {
423
+ warnings.push(
424
+ `Ignored invalid routing rule: ${JSON.stringify(rule)}`,
425
+ );
426
+ }
427
+ }
428
+ }
429
+ }
430
+
431
+ // Resolve classifierModel — accepts string or { model, thinking } object
432
+ let classifierModel: ClassifierConfig | undefined;
433
+ const rawClassifier = raw.classifierModel as unknown;
434
+ if (typeof rawClassifier === 'string' && rawClassifier.trim()) {
435
+ const resolved = resolveModelRef(
436
+ rawClassifier.trim(),
437
+ hasModels ? normalizedModels : undefined,
438
+ );
439
+ try {
440
+ parseCanonicalModelRef(resolved.canonicalRef);
441
+ classifierModel = { model: resolved.canonicalRef };
442
+ } catch (error) {
443
+ warnings.push(
444
+ `Invalid classifierModel: ${error instanceof Error ? error.message : String(error)}`,
445
+ );
446
+ }
447
+ } else if (isObjectRecord(rawClassifier)) {
448
+ const modelRef =
449
+ typeof rawClassifier.model === 'string' ? rawClassifier.model.trim() : '';
450
+ if (modelRef) {
451
+ const resolved = resolveModelRef(
452
+ modelRef,
453
+ hasModels ? normalizedModels : undefined,
454
+ );
455
+ try {
456
+ parseCanonicalModelRef(resolved.canonicalRef);
457
+ const thinking = isThinkingLevel(rawClassifier.thinking)
458
+ ? rawClassifier.thinking
459
+ : undefined;
460
+ if (rawClassifier.thinking !== undefined && !thinking) {
461
+ warnings.push(
462
+ `classifierModel has invalid thinking level "${String(rawClassifier.thinking)}". Ignored.`,
463
+ );
464
+ }
465
+ classifierModel = { model: resolved.canonicalRef, thinking };
466
+ } catch (error) {
467
+ warnings.push(
468
+ `Invalid classifierModel: ${error instanceof Error ? error.message : String(error)}`,
469
+ );
470
+ }
471
+ } else {
472
+ warnings.push(
473
+ 'classifierModel object is missing the "model" field. Ignored.',
474
+ );
475
+ }
476
+ }
477
+
478
+ return {
479
+ config: {
480
+ debug: typeof raw.debug === 'boolean' ? raw.debug : false,
481
+ classifierModel,
482
+ phaseBias,
483
+ maxSessionBudget,
484
+ rules: rules.length > 0 ? rules : undefined,
485
+ profiles: normalizedProfiles,
486
+ models: hasModels ? normalizedModels : undefined,
487
+ },
488
+ warnings,
489
+ };
490
+ };
491
+
492
+ export const loadRouterConfig = (cwd: string): ConfigLoadResult => {
493
+ const globalPath = join(getAgentDir(), 'model-router.json');
494
+ const projectPath = join(cwd, '.pi', 'model-router.json');
495
+ const globalResult = parseConfigFile(globalPath);
496
+ const projectResult = parseConfigFile(projectPath);
497
+ const baseConfig: RouterConfig = { profiles: {} };
498
+ const merged = mergeConfig(
499
+ mergeConfig(baseConfig, globalResult.config),
500
+ projectResult.config,
501
+ );
502
+ const normalized = normalizeConfig(merged);
503
+ return {
504
+ config: normalized.config,
505
+ warnings: [
506
+ ...globalResult.warnings,
507
+ ...projectResult.warnings,
508
+ ...normalized.warnings,
509
+ ],
510
+ };
511
+ };
512
+
513
+ export const profileNames = (config: RouterConfig): string[] => {
514
+ return Object.keys(config.profiles).sort();
515
+ };
516
+
517
+ export const resolveProfileName = (
518
+ config: RouterConfig,
519
+ requested?: string,
520
+ ): string | undefined => {
521
+ if (requested && config.profiles[requested]) {
522
+ return requested;
523
+ }
524
+ return undefined;
525
+ };
526
+
527
+ /**
528
+ * Resolve the effective context window for a specific tier at runtime,
529
+ * incorporating the API model registry as the highest-priority source.
530
+ *
531
+ * Resolution chain: API > tier config > model alias > hardcoded default
532
+ */
533
+ export const resolveContextWindow = (
534
+ tier: RouterTier,
535
+ profile: RouterProfile,
536
+ modelRegistry: ExtensionContext['modelRegistry'] | undefined,
537
+ ): number => {
538
+ const tierConfig = profile[tier];
539
+ if (!tierConfig) return DEFAULT_CONTEXT_WINDOW;
540
+
541
+ // 1. API value (highest priority)
542
+ if (modelRegistry) {
543
+ try {
544
+ const { provider, modelId } = parseCanonicalModelRef(tierConfig.model);
545
+ const registryModel = modelRegistry.find(provider, modelId);
546
+ if (registryModel?.contextWindow) return registryModel.contextWindow;
547
+ } catch {
548
+ /* ignore */
549
+ }
550
+ }
551
+
552
+ // 2-4. Pre-resolved during config normalization (tier > alias > hardcoded)
553
+ return tierConfig.resolvedContextWindow ?? DEFAULT_CONTEXT_WINDOW;
554
+ };
555
+
556
+ /**
557
+ * Resolve the effective max tokens for a specific tier at runtime,
558
+ * incorporating the API model registry as the highest-priority source.
559
+ *
560
+ * Resolution chain: API > tier config > model alias > hardcoded default
561
+ */
562
+ export const resolveMaxTokens = (
563
+ tier: RouterTier,
564
+ profile: RouterProfile,
565
+ modelRegistry: ExtensionContext['modelRegistry'] | undefined,
566
+ ): number => {
567
+ const tierConfig = profile[tier];
568
+ if (!tierConfig) return DEFAULT_MAX_TOKENS;
569
+
570
+ // 1. API value (highest priority)
571
+ if (modelRegistry) {
572
+ try {
573
+ const { provider, modelId } = parseCanonicalModelRef(tierConfig.model);
574
+ const registryModel = modelRegistry.find(provider, modelId);
575
+ if (registryModel?.maxTokens) return registryModel.maxTokens;
576
+ } catch {
577
+ /* ignore */
578
+ }
579
+ }
580
+
581
+ // 2-4. Pre-resolved during config normalization (tier > alias > hardcoded)
582
+ return tierConfig.resolvedMaxTokens ?? DEFAULT_MAX_TOKENS;
583
+ };
584
+
585
+ /**
586
+ * Collect the union of all tier models' resolved thinking levels for a profile.
587
+ * Returns a Set of ThinkingLevel values.
588
+ */
589
+ export const collectProfileThinkingLevels = (
590
+ profile: RouterProfile,
591
+ ): Set<ThinkingLevel> => {
592
+ const levels = new Set<ThinkingLevel>();
593
+ for (const tier of ROUTER_TIERS) {
594
+ const tierConfig = profile[tier];
595
+ if (!tierConfig?.resolvedThinkingLevels) continue;
596
+ for (const level of tierConfig.resolvedThinkingLevels) {
597
+ levels.add(level);
598
+ }
599
+ }
600
+ return levels;
601
+ };
602
+
603
+ /**
604
+ * Returns tier names whose models don't include the given thinking level
605
+ * in their resolvedThinkingLevels.
606
+ */
607
+ export const getUnsupportedTiers = (
608
+ profile: RouterProfile,
609
+ level: ThinkingLevel,
610
+ ): string[] => {
611
+ const unsupported: string[] = [];
612
+ for (const tier of ROUTER_TIERS) {
613
+ const tierConfig = profile[tier];
614
+ if (!tierConfig) continue;
615
+ if (!tierConfig.resolvedThinkingLevels?.includes(level)) {
616
+ unsupported.push(tier);
617
+ }
618
+ }
619
+ return unsupported;
620
+ };
621
+
622
+ /**
623
+ * Clamps a requested thinking level to the highest supported level
624
+ * in the provided array of supported levels.
625
+ */
626
+ export const clampThinkingLevel = (
627
+ requested: ThinkingLevel,
628
+ supported: ThinkingLevel[] | undefined,
629
+ ): ThinkingLevel => {
630
+ if (requested === 'off' || !supported || supported.length === 0) {
631
+ return 'off';
632
+ }
633
+
634
+ const reqIdx = THINKING_LEVELS.indexOf(requested);
635
+ for (let i = reqIdx; i >= 0; i--) {
636
+ if (supported.includes(THINKING_LEVELS[i])) {
637
+ return THINKING_LEVELS[i];
638
+ }
639
+ }
640
+
641
+ return 'off';
642
+ };
@@ -0,0 +1,49 @@
1
+ export const MAX_DEBUG_HISTORY = 12;
2
+ export const DEFAULT_CONTEXT_WINDOW = 128_000;
3
+ export const DEFAULT_MAX_TOKENS = 16_384;
4
+
5
+ const AUTH_HEADERS = new Set([
6
+ 'authorization',
7
+ 'x-api-key',
8
+ 'cf-aig-authorization',
9
+ ]);
10
+
11
+ export interface RegistryWithProviderAuth {
12
+ getProviderAuth?: (
13
+ provider: string,
14
+ ) => Promise<{ auth: { baseUrl?: string } } | undefined>;
15
+ }
16
+
17
+ export const hasUsableRequestAuth = (auth: {
18
+ apiKey?: string;
19
+ headers?: Record<string, string | null | undefined>;
20
+ }): boolean => {
21
+ if (typeof auth.apiKey === 'string' && auth.apiKey.trim().length > 0) {
22
+ return true;
23
+ }
24
+
25
+ return Object.entries(auth.headers ?? {}).some(
26
+ ([name, value]) =>
27
+ AUTH_HEADERS.has(name.toLowerCase()) &&
28
+ typeof value === 'string' &&
29
+ value.trim().length > 0,
30
+ );
31
+ };
32
+
33
+ export const resolveDelegatedModel = async <
34
+ TModel extends { provider: string; baseUrl: string },
35
+ >(
36
+ registry: RegistryWithProviderAuth,
37
+ model: TModel,
38
+ ): Promise<TModel> => {
39
+ try {
40
+ const providerAuth = await registry.getProviderAuth?.(model.provider);
41
+ const authBaseUrl = providerAuth?.auth.baseUrl;
42
+ if (authBaseUrl && authBaseUrl !== model.baseUrl) {
43
+ return { ...model, baseUrl: authBaseUrl };
44
+ }
45
+ } catch {
46
+ // Older Pi versions and unavailable credentials use the model's static URL.
47
+ }
48
+ return model;
49
+ };