@robhowley/pi-openrouter 0.8.2 → 0.9.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.
@@ -28,6 +28,16 @@ import {
28
28
  } from './models/sync.js';
29
29
  import { loadCache, getCacheAgeMs, formatDuration } from './models/cache.js';
30
30
  import { mapOpenRouterModels } from './models/mapper.js';
31
+ import {
32
+ loadModelOverrides,
33
+ saveModelOverrides,
34
+ setModelOverride,
35
+ removeModelOverride,
36
+ getModelOverride,
37
+ getOverrideModelIds,
38
+ hasOverrides,
39
+ } from './models/overrides.js';
40
+ import type { UserModelOverride, ThinkingLevelMap, ModelOverridesFile } from './models/types.js';
31
41
 
32
42
  // Store the current session state for use in command handlers
33
43
  let currentSessionState: OpenRouterSessionState | null = null;
@@ -43,6 +53,10 @@ let startupCacheInfo: { count: number; age: string } | undefined;
43
53
  /**
44
54
  * Format skipped models details for --skipped flag output.
45
55
  */
56
+ function getErrorMessage(error: unknown): string {
57
+ return error instanceof Error ? error.message : String(error);
58
+ }
59
+
46
60
  function formatSkippedDetails(
47
61
  skipCount: number,
48
62
  groupedReasons: Record<string, number>,
@@ -285,16 +299,26 @@ export default async function (pi: ExtensionAPI) {
285
299
  pi.registerCommand('openrouter', {
286
300
  description: 'OpenRouter commands: usage, account, session, models-sync, models-status',
287
301
  getArgumentCompletions: (prefix: string) => {
288
- const subcommands = ['usage', 'account', 'session', 'models-sync', 'models-status'];
302
+ const subcommands = [
303
+ 'usage',
304
+ 'account',
305
+ 'session',
306
+ 'models-sync',
307
+ 'models-status',
308
+ 'model-override-set',
309
+ 'model-override-clear',
310
+ 'model-override-list',
311
+ ];
289
312
  const items = subcommands
290
313
  .filter((s) => s.startsWith(prefix))
291
314
  .map((s) => ({ value: s, label: s }));
292
315
  return items.length > 0 ? items : null;
293
316
  },
294
317
  handler: async (args, ctx) => {
295
- // Parse subcommand and flags
318
+ // Parse subcommand and args
296
319
  const parts = args.trim().split(/\s+/);
297
320
  const subcommand = parts[0] || '';
321
+ const subcommandArgs = parts.slice(1).join(' ').trim();
298
322
  const flags = parts.slice(1).reduce(
299
323
  (acc, flag) => {
300
324
  acc[flag] = true;
@@ -382,8 +406,74 @@ export default async function (pi: ExtensionAPI) {
382
406
  }
383
407
  break;
384
408
  }
409
+ case 'model-override-set': {
410
+ let userOverrides: ModelOverridesFile;
411
+ try {
412
+ userOverrides = await loadModelOverrides();
413
+ } catch (error) {
414
+ ctx.ui.notify(`Failed to load model overrides: ${getErrorMessage(error)}`, 'error');
415
+ break;
416
+ }
417
+
418
+ const result = await handleModelOverrideSet(subcommandArgs, userOverrides);
419
+ if (result.success) {
420
+ ctx.ui.notify(result.message, 'info');
421
+ // Notify if we just changed the currently active model
422
+ if (result.modelId && ctx.model && result.modelId === ctx.model.id) {
423
+ ctx.ui.notify(
424
+ 'Model configuration updated. Run /openrouter models-sync to apply changes to the current conversation.',
425
+ 'info',
426
+ );
427
+ }
428
+ } else {
429
+ ctx.ui.notify(result.message, 'error');
430
+ }
431
+ break;
432
+ }
433
+ case 'model-override-clear': {
434
+ let userOverrides: ModelOverridesFile;
435
+ try {
436
+ userOverrides = await loadModelOverrides();
437
+ } catch (error) {
438
+ ctx.ui.notify(`Failed to load model overrides: ${getErrorMessage(error)}`, 'error');
439
+ break;
440
+ }
441
+
442
+ const result = await handleModelOverrideClear(subcommandArgs, userOverrides);
443
+ if (result.success) {
444
+ ctx.ui.notify(result.message, 'info');
445
+ // Notify if we just cleared the currently active model
446
+ if (result.modelId && ctx.model && result.modelId === ctx.model.id) {
447
+ ctx.ui.notify(
448
+ 'Model configuration updated. Run /openrouter models-sync to apply changes to the current conversation.',
449
+ 'info',
450
+ );
451
+ }
452
+ } else {
453
+ ctx.ui.notify(result.message, 'error');
454
+ }
455
+ break;
456
+ }
457
+ case 'model-override-list': {
458
+ try {
459
+ const result = await handleModelOverrideList(subcommandArgs);
460
+ ctx.ui.notify(result, 'info');
461
+ } catch (error) {
462
+ ctx.ui.notify(`Failed to load model overrides: ${getErrorMessage(error)}`, 'error');
463
+ }
464
+ break;
465
+ }
385
466
  default: {
386
- const available = ['usage', 'account', 'session', 'models-sync', 'models-status'];
467
+ const available = [
468
+ 'usage',
469
+ 'account',
470
+ 'session',
471
+ 'models-sync',
472
+ 'models-status',
473
+ 'model-override-set',
474
+ 'model-override-clear',
475
+ 'model-override-list',
476
+ ];
387
477
  const message =
388
478
  available.length > 0
389
479
  ? `Available subcommands: ${available.join(', ')}${available.length > 1 ? '' : ''}`
@@ -396,6 +486,317 @@ export default async function (pi: ExtensionAPI) {
396
486
  });
397
487
  }
398
488
 
489
+ // =============================================================================
490
+ // Generic Model Override Handlers (Scoped Syntax)
491
+ // =============================================================================
492
+
493
+ interface HandlerResult {
494
+ success: boolean;
495
+ message: string;
496
+ modelId?: string;
497
+ }
498
+
499
+ interface ScopedField {
500
+ targetField: string;
501
+ targetType: 'string' | 'number' | 'boolean';
502
+ }
503
+
504
+ /**
505
+ * Scoped field name mapping: converts user-facing 'thinking.X' to internal 'thinkingLevelMap.X'
506
+ * Also supports exact PiModelConfig field names for future extensibility.
507
+ */
508
+ const SCOPED_FIELD_MAP: Record<string, ScopedField> = {
509
+ // thinking.* shorthand - maps to thinkingLevelMap
510
+ 'thinking.off': { targetField: 'thinkingLevelMap.off', targetType: 'string' },
511
+ 'thinking.minimal': { targetField: 'thinkingLevelMap.minimal', targetType: 'string' },
512
+ 'thinking.low': { targetField: 'thinkingLevelMap.low', targetType: 'string' },
513
+ 'thinking.medium': { targetField: 'thinkingLevelMap.medium', targetType: 'string' },
514
+ 'thinking.high': { targetField: 'thinkingLevelMap.high', targetType: 'string' },
515
+ 'thinking.xhigh': { targetField: 'thinkingLevelMap.xhigh', targetType: 'string' },
516
+
517
+ // exact field names (passthrough)
518
+ 'thinkingLevelMap.off': { targetField: 'thinkingLevelMap.off', targetType: 'string' },
519
+ 'thinkingLevelMap.minimal': { targetField: 'thinkingLevelMap.minimal', targetType: 'string' },
520
+ 'thinkingLevelMap.low': { targetField: 'thinkingLevelMap.low', targetType: 'string' },
521
+ 'thinkingLevelMap.medium': { targetField: 'thinkingLevelMap.medium', targetType: 'string' },
522
+ 'thinkingLevelMap.high': { targetField: 'thinkingLevelMap.high', targetType: 'string' },
523
+ 'thinkingLevelMap.xhigh': { targetField: 'thinkingLevelMap.xhigh', targetType: 'string' },
524
+
525
+ // top-level fields (future extensibility)
526
+ contextWindow: { targetField: 'contextWindow', targetType: 'number' },
527
+ maxTokens: { targetField: 'maxTokens', targetType: 'number' },
528
+ reasoning: { targetField: 'reasoning', targetType: 'boolean' },
529
+ };
530
+
531
+ /**
532
+ * Parse a scoped assignment like "thinking.high=high" or "contextWindow=128000".
533
+ */
534
+ export function parseScopedAssignment(
535
+ assignment: string,
536
+ ): { fullPath: string; value: unknown } | null {
537
+ const eqIdx = assignment.indexOf('=');
538
+ if (eqIdx === -1) return null;
539
+
540
+ const scopedName = assignment.slice(0, eqIdx).trim();
541
+ const rawValue = assignment.slice(eqIdx + 1).trim();
542
+
543
+ const mapped = SCOPED_FIELD_MAP[scopedName];
544
+ if (!mapped) return null;
545
+
546
+ // Parse value by type
547
+ let parsedValue: unknown;
548
+ switch (mapped.targetType) {
549
+ case 'string':
550
+ // "null" -> null, otherwise string
551
+ parsedValue = rawValue === 'null' ? null : rawValue;
552
+ break;
553
+ case 'number': {
554
+ const num = parseInt(rawValue, 10);
555
+ if (isNaN(num)) return null;
556
+ parsedValue = num;
557
+ break;
558
+ }
559
+ case 'boolean':
560
+ if (rawValue !== 'true' && rawValue !== 'false') return null;
561
+ parsedValue = rawValue === 'true';
562
+ break;
563
+ default:
564
+ return null;
565
+ }
566
+
567
+ return { fullPath: mapped.targetField, value: parsedValue };
568
+ }
569
+
570
+ /**
571
+ * Apply a nested value to an object using dot notation path.
572
+ */
573
+ export function applyNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {
574
+ const parts = path.split('.');
575
+ let current: unknown = obj;
576
+
577
+ for (let i = 0; i < parts.length - 1; i++) {
578
+ const key = parts[i]!;
579
+ const currentRecord = current as Record<string, unknown>;
580
+ if (
581
+ !(key in currentRecord) ||
582
+ typeof currentRecord[key] !== 'object' ||
583
+ currentRecord[key] === null
584
+ ) {
585
+ currentRecord[key] = {};
586
+ }
587
+ current = currentRecord[key];
588
+ }
589
+
590
+ const finalKey = parts[parts.length - 1]!;
591
+ (current as Record<string, unknown>)[finalKey] = value;
592
+ }
593
+
594
+ /**
595
+ * Handle /openrouter model-override-set command.
596
+ * Format: model-override-set <model-id> <field=value>...
597
+ * Examples:
598
+ * /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max
599
+ * /openrouter model-override-set deepseek/deepseek-v4-pro contextWindow=128000
600
+ */
601
+ export async function handleModelOverrideSet(
602
+ args: string,
603
+ userOverrides: ModelOverridesFile,
604
+ ): Promise<HandlerResult> {
605
+ const parts = args.trim().split(/\s+/).filter(Boolean);
606
+
607
+ if (parts.length < 1) {
608
+ return {
609
+ success: false,
610
+ message:
611
+ 'Usage: /openrouter model-override-set <model-id> <field=value>...\nExample: /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max',
612
+ };
613
+ }
614
+
615
+ const modelId = parts[0];
616
+
617
+ if (!modelId) {
618
+ return {
619
+ success: false,
620
+ message:
621
+ 'Usage: /openrouter model-override-set <model-id> <field=value>...\nExample: /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max',
622
+ };
623
+ }
624
+
625
+ // Validate model ID format (should be provider/model)
626
+ if (!modelId.includes('/')) {
627
+ return {
628
+ success: false,
629
+ message: `Invalid model ID format: "${modelId}"\nExpected format: provider/model (e.g., "deepseek/deepseek-v4-pro")`,
630
+ };
631
+ }
632
+
633
+ // Build override incrementally from assignments
634
+ const override: UserModelOverride = {};
635
+ const assignments = parts.slice(1).filter((p) => !p.startsWith('--'));
636
+
637
+ for (const assignment of assignments) {
638
+ const parsed = parseScopedAssignment(assignment);
639
+ if (!parsed) {
640
+ return {
641
+ success: false,
642
+ message: `Invalid assignment: "${assignment}"\nExpected format: field=value (e.g., thinking.high=high or contextWindow=128000)\nSee available fields with /openrouter model-override-list --fields`,
643
+ };
644
+ }
645
+ applyNestedValue(override as Record<string, unknown>, parsed.fullPath, parsed.value);
646
+ }
647
+
648
+ // If no assignments provided, error out
649
+ if (Object.keys(override).length === 0) {
650
+ return {
651
+ success: false,
652
+ message:
653
+ 'No field assignments provided.\nUsage: /openrouter model-override-set <model-id> field=value [field=value]...\nExample: /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max',
654
+ };
655
+ }
656
+
657
+ // Update overrides file
658
+ const updatedOverrides = setModelOverride(userOverrides, modelId, override);
659
+ try {
660
+ await saveModelOverrides(updatedOverrides);
661
+ } catch (error) {
662
+ return {
663
+ success: false,
664
+ message: `Failed to save overrides for ${modelId}: ${getErrorMessage(error)}`,
665
+ };
666
+ }
667
+
668
+ const savedOverride = updatedOverrides.overrides[modelId] as UserModelOverride;
669
+
670
+ // Format success message
671
+ const lines: string[] = [`Saved overrides for ${modelId}:`];
672
+ for (const [key, val] of Object.entries(savedOverride)) {
673
+ if (key === 'thinkingLevelMap' && val) {
674
+ lines.push(' thinkingLevelMap:');
675
+ for (const [level, mapped] of Object.entries(val as ThinkingLevelMap)) {
676
+ lines.push(` ${level}: ${mapped === null ? 'null' : mapped}`);
677
+ }
678
+ } else {
679
+ lines.push(` ${key}: ${val}`);
680
+ }
681
+ }
682
+
683
+ return {
684
+ success: true,
685
+ message: lines.join('\n'),
686
+ modelId,
687
+ };
688
+ }
689
+
690
+ /**
691
+ * Handle /openrouter model-override-clear command.
692
+ */
693
+ export async function handleModelOverrideClear(
694
+ args: string,
695
+ userOverrides: ModelOverridesFile,
696
+ ): Promise<HandlerResult> {
697
+ const parts = args.trim().split(/\s+/).filter(Boolean);
698
+ const modelId = parts[0];
699
+
700
+ if (!modelId) {
701
+ return {
702
+ success: false,
703
+ message: 'Usage: /openrouter model-override-clear <model-id>',
704
+ };
705
+ }
706
+
707
+ if (!modelId.includes('/')) {
708
+ return {
709
+ success: false,
710
+ message: `Invalid model ID format: "${modelId}"\nExpected format: provider/model`,
711
+ };
712
+ }
713
+
714
+ const existing = getModelOverride(userOverrides, modelId);
715
+ if (!existing) {
716
+ return {
717
+ success: false,
718
+ message: `No overrides found for ${modelId}`,
719
+ };
720
+ }
721
+
722
+ const updatedOverrides = removeModelOverride(userOverrides, modelId);
723
+ try {
724
+ await saveModelOverrides(updatedOverrides);
725
+ } catch (error) {
726
+ return {
727
+ success: false,
728
+ message: `Failed to clear overrides for ${modelId}: ${getErrorMessage(error)}`,
729
+ };
730
+ }
731
+
732
+ return {
733
+ success: true,
734
+ message: `Cleared all overrides for ${modelId}`,
735
+ modelId,
736
+ };
737
+ }
738
+
739
+ /**
740
+ * Handle /openrouter model-override-list command.
741
+ */
742
+ export async function handleModelOverrideList(args: string): Promise<string> {
743
+ const userOverrides = await loadModelOverrides();
744
+ const modelId = args.trim();
745
+
746
+ // List available fields if --fields flag
747
+ if (modelId === '--fields') {
748
+ const fields = Object.keys(SCOPED_FIELD_MAP)
749
+ .map(
750
+ (k) => ` ${k}: ${SCOPED_FIELD_MAP[k]!.targetField} (${SCOPED_FIELD_MAP[k]!.targetType})`,
751
+ )
752
+ .join('\n');
753
+ return `Available override fields:\n${fields}`;
754
+ }
755
+
756
+ if (!hasOverrides(userOverrides)) {
757
+ return 'No model overrides configured.\nUse /openrouter model-override-set to add overrides.';
758
+ }
759
+
760
+ if (modelId) {
761
+ // Show specific model
762
+ const override = getModelOverride(userOverrides, modelId);
763
+ if (!override) {
764
+ return `No overrides configured for ${modelId}`;
765
+ }
766
+
767
+ const lines: string[] = [`Overrides for ${modelId}:`];
768
+ for (const [key, val] of Object.entries(override)) {
769
+ if (key === 'thinkingLevelMap' && val) {
770
+ lines.push(' thinkingLevelMap:');
771
+ for (const [level, mapped] of Object.entries(val as ThinkingLevelMap)) {
772
+ lines.push(` ${level}: ${mapped === null ? 'null' : mapped}`);
773
+ }
774
+ } else {
775
+ lines.push(` ${key}: ${val}`);
776
+ }
777
+ }
778
+ return lines.join('\n');
779
+ }
780
+
781
+ // List all overrides
782
+ const modelIds = getOverrideModelIds(userOverrides);
783
+ const lines: string[] = [`${modelIds.length} model(s) with overrides:`];
784
+ for (const id of modelIds) {
785
+ const override = getModelOverride(userOverrides, id);
786
+ if (override?.thinkingLevelMap && Object.keys(override.thinkingLevelMap).length > 0) {
787
+ const tlm = Object.entries(override.thinkingLevelMap)
788
+ .filter(([, v]) => v !== null)
789
+ .map(([k, v]) => `${k}=${v}`)
790
+ .join(',');
791
+ lines.push(` ${id}${tlm ? ` [${tlm}]` : ''}`);
792
+ } else {
793
+ lines.push(` ${id}`);
794
+ }
795
+ }
796
+ lines.push('\nUse /openrouter model-override-list <model-id> for details');
797
+ return lines.join('\n');
798
+ }
799
+
399
800
  async function showAccountOverlay(ctx: ExtensionContext) {
400
801
  let error: string | null = null;
401
802
  let keyInfo: KeyInfo[] | null = null;
@@ -0,0 +1,102 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createValidModel } from '../../__tests__/fixtures.js';
3
+ import type { ModelOverridesFile } from '../types.js';
4
+
5
+ const { loadModelOverrides } = vi.hoisted(() => ({
6
+ loadModelOverrides: vi.fn<() => Promise<ModelOverridesFile>>(),
7
+ }));
8
+
9
+ vi.mock('../overrides.js', () => ({
10
+ loadModelOverrides,
11
+ getModelOverride: (overrides: ModelOverridesFile, modelId: string) =>
12
+ overrides.overrides[modelId],
13
+ }));
14
+
15
+ vi.mock('@earendil-works/pi-ai', () => ({
16
+ getModels: vi.fn(() => [
17
+ {
18
+ id: 'test/model',
19
+ thinkingLevelMap: {
20
+ minimal: 'builtin-minimal',
21
+ high: 'builtin-high',
22
+ xhigh: 'builtin-xhigh',
23
+ },
24
+ },
25
+ ]),
26
+ }));
27
+
28
+ import { mapOpenRouterModels } from '../mapper.js';
29
+
30
+ describe('mapOpenRouterModels overrides', () => {
31
+ beforeEach(() => {
32
+ loadModelOverrides.mockResolvedValue({ version: 1, overrides: {} });
33
+ });
34
+
35
+ it('applies top-level overrides and merges sparse thinkingLevelMap with built-in values', async () => {
36
+ loadModelOverrides.mockResolvedValue({
37
+ version: 1,
38
+ overrides: {
39
+ 'test/model': {
40
+ contextWindow: 64000,
41
+ maxTokens: 8192,
42
+ reasoning: false,
43
+ thinkingLevelMap: {
44
+ high: 'override-high',
45
+ xhigh: null,
46
+ },
47
+ },
48
+ },
49
+ });
50
+
51
+ const result = await mapOpenRouterModels([
52
+ createValidModel({
53
+ id: 'test/model',
54
+ supported_parameters: ['reasoning'],
55
+ }),
56
+ ]);
57
+
58
+ expect(result.configs).toHaveLength(1);
59
+ expect(result.configs[0]).toMatchObject({
60
+ id: 'test/model',
61
+ contextWindow: 64000,
62
+ maxTokens: 8192,
63
+ reasoning: false,
64
+ thinkingLevelMap: {
65
+ minimal: 'builtin-minimal',
66
+ high: 'override-high',
67
+ xhigh: null,
68
+ },
69
+ });
70
+ });
71
+
72
+ it('applies user thinkingLevelMap when the built-in registry has no map for the model', async () => {
73
+ loadModelOverrides.mockResolvedValue({
74
+ version: 1,
75
+ overrides: {
76
+ 'new/model': {
77
+ thinkingLevelMap: {
78
+ high: 'high',
79
+ xhigh: 'max',
80
+ },
81
+ },
82
+ },
83
+ });
84
+
85
+ const result = await mapOpenRouterModels([
86
+ createValidModel({
87
+ id: 'new/model',
88
+ supported_parameters: ['reasoning'],
89
+ }),
90
+ ]);
91
+
92
+ expect(result.configs).toHaveLength(1);
93
+ expect(result.configs[0]).toMatchObject({
94
+ id: 'new/model',
95
+ reasoning: true,
96
+ thinkingLevelMap: {
97
+ high: 'high',
98
+ xhigh: 'max',
99
+ },
100
+ });
101
+ });
102
+ });