@victor-software-house/pi-openai-proxy 2.1.0 → 4.0.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/dist/config.mjs CHANGED
@@ -18,7 +18,7 @@ const DEFAULT_CONFIG = {
18
18
  upstreamTimeoutSec: 120,
19
19
  lifetime: "detached",
20
20
  publicModelIdMode: "collision-prefixed",
21
- modelExposureMode: "all",
21
+ modelExposureMode: "scoped",
22
22
  scopedProviders: [],
23
23
  customModels: [],
24
24
  providerPrefixes: {}
@@ -37,9 +37,13 @@ type ModelExposureOutcome = ModelExposureResult | ModelExposureError;
37
37
  /**
38
38
  * Compute the full model-exposure result from config and available models.
39
39
  *
40
+ * @param available - Models with auth configured (pi's getAvailable())
41
+ * @param allRegistered - All registered models regardless of auth (pi's getAll())
42
+ * @param config - Model exposure configuration
43
+ *
40
44
  * Call this at startup and whenever config or the model registry changes.
41
45
  */
42
- declare function computeModelExposure(available: readonly Model<Api>[], config: ModelExposureConfig): ModelExposureOutcome;
46
+ declare function computeModelExposure(available: readonly Model<Api>[], allRegistered: readonly Model<Api>[], config: ModelExposureConfig): ModelExposureOutcome;
43
47
  /**
44
48
  * Resolve a model ID from an incoming request against the exposure result.
45
49
  *
package/dist/exposure.mjs CHANGED
@@ -1,12 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  //#region src/openai/model-exposure.ts
3
- function filterExposedModels(available, config) {
3
+ function filterExposedModels(available, allRegistered, config) {
4
4
  switch (config.modelExposureMode) {
5
- case "all": return [...available];
6
- case "scoped": {
7
- const providers = new Set(config.scopedProviders);
8
- return available.filter((m) => providers.has(m.provider));
9
- }
5
+ case "scoped": return [...available];
6
+ case "all": return [...allRegistered];
10
7
  case "custom": {
11
8
  const allowed = new Set(config.customModels);
12
9
  return available.filter((m) => allowed.has(`${m.provider}/${m.id}`));
@@ -125,10 +122,14 @@ function validatePrefixUniqueness(models, prefixes, mode) {
125
122
  /**
126
123
  * Compute the full model-exposure result from config and available models.
127
124
  *
125
+ * @param available - Models with auth configured (pi's getAvailable())
126
+ * @param allRegistered - All registered models regardless of auth (pi's getAll())
127
+ * @param config - Model exposure configuration
128
+ *
128
129
  * Call this at startup and whenever config or the model registry changes.
129
130
  */
130
- function computeModelExposure(available, config) {
131
- const exposed = filterExposedModels(available, config);
131
+ function computeModelExposure(available, allRegistered, config) {
132
+ const exposed = filterExposedModels(available, allRegistered, config);
132
133
  const prefixError = validatePrefixUniqueness(exposed, config.providerPrefixes, config.publicModelIdMode);
133
134
  if (prefixError !== void 0) return {
134
135
  ok: false,
package/dist/index.mjs CHANGED
@@ -66,6 +66,12 @@ function getRegistry() {
66
66
  function getAvailableModels() {
67
67
  return getRegistry().getAvailable();
68
68
  }
69
+ /**
70
+ * Get all registered models (regardless of auth state).
71
+ */
72
+ function getAllModels() {
73
+ return getRegistry().getAll();
74
+ }
69
75
  //#endregion
70
76
  //#region src/server/errors.ts
71
77
  function makeError(message, type, param, code) {
@@ -1294,7 +1300,7 @@ function buildExposureConfig(config) {
1294
1300
  * Returns the exposure result or throws on config errors.
1295
1301
  */
1296
1302
  function getExposure(config) {
1297
- const outcome = computeModelExposure(getAvailableModels(), buildExposureConfig(config));
1303
+ const outcome = computeModelExposure(getAvailableModels(), getAllModels(), buildExposureConfig(config));
1298
1304
  if (!outcome.ok) throw new Error(`Model exposure configuration error: ${outcome.message}`);
1299
1305
  return outcome;
1300
1306
  }
@@ -30,7 +30,13 @@ import {
30
30
  getSettingsListTheme,
31
31
  ModelRegistry,
32
32
  } from "@mariozechner/pi-coding-agent";
33
- import { Container, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui";
33
+ import {
34
+ type Component,
35
+ Container,
36
+ type SettingItem,
37
+ SettingsList,
38
+ Text,
39
+ } from "@mariozechner/pi-tui";
34
40
 
35
41
  // Config schema -- single source of truth
36
42
  import {
@@ -77,6 +83,12 @@ export default function proxyExtension(pi: ExtensionAPI): void {
77
83
  return registry.getAvailable();
78
84
  }
79
85
 
86
+ function getAllRegisteredModels(): Model<Api>[] {
87
+ const auth = AuthStorage.create();
88
+ const registry = new ModelRegistry(auth);
89
+ return registry.getAll();
90
+ }
91
+
80
92
  function getUniqueProviders(models: readonly Model<Api>[]): string[] {
81
93
  const seen = new Set<string>();
82
94
  for (const m of models) {
@@ -447,7 +459,8 @@ export default function proxyExtension(pi: ExtensionAPI): void {
447
459
 
448
460
  // Public ID preview (first 5 exposed models)
449
461
  const models = getAvailableModels();
450
- const outcome = computeModelExposure(models, buildExposureConfig());
462
+ const allModels = getAllRegisteredModels();
463
+ const outcome = computeModelExposure(models, allModels, buildExposureConfig());
451
464
  if (outcome.ok && outcome.models.length > 0) {
452
465
  const preview = outcome.models.slice(0, 5).map((m) => m.publicId);
453
466
  const suffix =
@@ -469,24 +482,13 @@ export default function proxyExtension(pi: ExtensionAPI): void {
469
482
  const models = getAvailableModels();
470
483
  const issues: string[] = [];
471
484
 
485
+ const allModels = getAllRegisteredModels();
486
+
472
487
  // Check available models
473
488
  if (models.length === 0) {
474
489
  issues.push("No models have auth configured. The proxy will expose 0 models.");
475
490
  }
476
491
 
477
- // Check scoped providers reference valid providers
478
- if (config.modelExposureMode === "scoped") {
479
- const availableProviders = new Set(getUniqueProviders(models));
480
- for (const p of config.scopedProviders) {
481
- if (!availableProviders.has(p)) {
482
- issues.push(`Scoped provider '${p}' has no available models (no auth or unknown).`);
483
- }
484
- }
485
- if (config.scopedProviders.length === 0) {
486
- issues.push("Scoped mode with empty provider list will expose 0 models.");
487
- }
488
- }
489
-
490
492
  // Check custom models reference valid canonical IDs
491
493
  if (config.modelExposureMode === "custom") {
492
494
  const canonicalSet = new Set(models.map((m) => `${m.provider}/${m.id}`));
@@ -501,7 +503,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
501
503
  }
502
504
 
503
505
  // Run the full exposure computation to catch ID/prefix errors
504
- const outcome = computeModelExposure(models, buildExposureConfig());
506
+ const outcome = computeModelExposure(models, allModels, buildExposureConfig());
505
507
  if (!outcome.ok) {
506
508
  issues.push(outcome.message);
507
509
  }
@@ -532,27 +534,92 @@ export default function proxyExtension(pi: ExtensionAPI): void {
532
534
 
533
535
  let lastGeneratedToken = "";
534
536
 
535
- function buildSettingItems(): SettingItem[] {
536
- // Build dynamic choices for scoped providers and custom models
537
+ function customModelsDisplay(): string {
538
+ if (config.modelExposureMode !== "custom") return "n/a";
539
+ return config.customModels.length > 0
540
+ ? `${String(config.customModels.length)} selected`
541
+ : "(none)";
542
+ }
543
+
544
+ /**
545
+ * Build a submenu Component for selecting custom models.
546
+ * Shows all available models as a toggleable checklist.
547
+ */
548
+ function buildModelSelectorSubmenu(
549
+ _currentValue: string,
550
+ done: (selectedValue?: string) => void,
551
+ ): Component {
537
552
  const models = getAvailableModels();
538
- const availableProviders = getUniqueProviders(models);
553
+ const selected = new Set(config.customModels);
554
+ let selectedIndex = 0;
539
555
 
540
- // Scoped providers: show available + current selection indicator
541
- const scopedSet = new Set(config.scopedProviders);
542
- const scopedDisplay =
543
- config.scopedProviders.length > 0 ? config.scopedProviders.join(", ") : "(none)";
556
+ function toggle(canonicalId: string): void {
557
+ if (selected.has(canonicalId)) {
558
+ selected.delete(canonicalId);
559
+ } else {
560
+ selected.add(canonicalId);
561
+ }
562
+ config = { ...config, customModels: [...selected] };
563
+ saveConfigToFile(config);
564
+ config = loadConfigFromFile();
565
+ }
544
566
 
545
- // Custom models: show count
546
- const customDisplay =
547
- config.customModels.length > 0 ? `${String(config.customModels.length)} selected` : "(none)";
567
+ return {
568
+ render(width: number): string[] {
569
+ const lines: string[] = [];
570
+ lines.push(" Select models (Space: toggle, Esc: done)");
571
+ lines.push("");
572
+
573
+ if (models.length === 0) {
574
+ lines.push(" No models available (no auth configured)");
575
+ return lines;
576
+ }
548
577
 
549
- // Prefix overrides: show current
550
- const prefixKeys = Object.keys(config.providerPrefixes);
551
- const prefixDisplay =
552
- prefixKeys.length > 0
553
- ? prefixKeys.map((k) => `${k}=${config.providerPrefixes[k] ?? k}`).join(", ")
554
- : "(defaults)";
578
+ const maxVisible = 15;
579
+ const start = Math.max(
580
+ 0,
581
+ Math.min(selectedIndex - Math.floor(maxVisible / 2), models.length - maxVisible),
582
+ );
583
+ const end = Math.min(start + maxVisible, models.length);
584
+
585
+ for (let i = start; i < end; i++) {
586
+ const m = models[i];
587
+ if (m === undefined) continue;
588
+ const canonical = `${m.provider}/${m.id}`;
589
+ const check = selected.has(canonical) ? "[x]" : "[ ]";
590
+ const cursor = i === selectedIndex ? "> " : " ";
591
+ const line = `${cursor}${check} ${canonical}`;
592
+ const truncated = line.length > width ? line.slice(0, width - 1) : line;
593
+ lines.push(truncated);
594
+ }
595
+
596
+ lines.push("");
597
+ lines.push(` ${String(selected.size)} of ${String(models.length)} selected`);
598
+ return lines;
599
+ },
600
+ invalidate(): void {
601
+ // no-op
602
+ },
603
+ handleInput(data: string): void {
604
+ if (data === "\x1B" || data === "q") {
605
+ done(`${String(selected.size)} selected`);
606
+ return;
607
+ }
608
+ if (data === "\x1B[A" && selectedIndex > 0) {
609
+ selectedIndex--;
610
+ } else if (data === "\x1B[B" && selectedIndex < models.length - 1) {
611
+ selectedIndex++;
612
+ } else if (data === " " || data === "\r") {
613
+ const m = models[selectedIndex];
614
+ if (m !== undefined) {
615
+ toggle(`${m.provider}/${m.id}`);
616
+ }
617
+ }
618
+ },
619
+ };
620
+ }
555
621
 
622
+ function buildSettingItems(): SettingItem[] {
556
623
  return [
557
624
  // --- Server ---
558
625
  {
@@ -618,67 +685,24 @@ export default function proxyExtension(pi: ExtensionAPI): void {
618
685
  {
619
686
  id: "modelExposureMode",
620
687
  label: "Exposure mode",
621
- description: "Which models are exposed on the API",
688
+ description:
689
+ "scoped = pi's available models, all = all registered, custom = manual selection",
622
690
  currentValue: config.modelExposureMode,
623
- values: ["all", "scoped", "custom"],
624
- },
625
- {
626
- id: "scopedProviders",
627
- label: "Scoped providers",
628
- description: `Toggle providers for scoped mode. Available: ${availableProviders.join(", ") || "(none)"}`,
629
- currentValue: scopedDisplay,
630
- values: availableProviders.map((p) => {
631
- const selected = scopedSet.has(p);
632
- return selected ? `[-] ${p}` : `[+] ${p}`;
633
- }),
691
+ values: ["scoped", "all", "custom"],
634
692
  },
635
693
  {
636
694
  id: "customModels",
637
- label: "Custom models",
638
- description: "Toggle individual models for custom mode",
639
- currentValue: customDisplay,
640
- values: buildCustomModelValues(models),
641
- },
642
- {
643
- id: "providerPrefixes",
644
- label: "Prefix overrides",
645
- description: "Custom prefix labels for providers (provider=label)",
646
- currentValue: prefixDisplay,
647
- values: buildPrefixValues(availableProviders),
695
+ label: "Select models",
696
+ description:
697
+ config.modelExposureMode === "custom"
698
+ ? "Press Enter to open model selector"
699
+ : "Switch exposure mode to 'custom' to select models",
700
+ currentValue: customModelsDisplay(),
701
+ submenu: config.modelExposureMode === "custom" ? buildModelSelectorSubmenu : undefined,
648
702
  },
649
703
  ];
650
704
  }
651
705
 
652
- /**
653
- * Build toggle values for custom model selection.
654
- * Each model is shown as "[+] provider/id" or "[-] provider/id".
655
- */
656
- function buildCustomModelValues(models: readonly Model<Api>[]): string[] {
657
- const customSet = new Set(config.customModels);
658
- return models.map((m) => {
659
- const canonical = `${m.provider}/${m.id}`;
660
- const selected = customSet.has(canonical);
661
- return selected ? `[-] ${canonical}` : `[+] ${canonical}`;
662
- });
663
- }
664
-
665
- /**
666
- * Build toggle values for prefix override editing.
667
- * Each provider is shown as "provider=label" or "provider (default)".
668
- */
669
- function buildPrefixValues(providers: readonly string[]): string[] {
670
- const values: string[] = [];
671
- for (const p of providers) {
672
- const override = config.providerPrefixes[p];
673
- if (override !== undefined && override.length > 0) {
674
- values.push(`clear ${p}`);
675
- } else {
676
- values.push(`set ${p}`);
677
- }
678
- }
679
- return values;
680
- }
681
-
682
706
  const VALID_ID_MODES = new Set<string>(["collision-prefixed", "universal", "always-prefixed"]);
683
707
 
684
708
  function isPublicModelIdMode(v: string): v is PublicModelIdMode {
@@ -743,14 +767,8 @@ export default function proxyExtension(pi: ExtensionAPI): void {
743
767
  config = { ...config, modelExposureMode: value };
744
768
  }
745
769
  break;
746
- case "scopedProviders":
747
- applyScopedProviderToggle(value);
748
- break;
749
770
  case "customModels":
750
- applyCustomModelToggle(value);
751
- break;
752
- case "providerPrefixes":
753
- applyPrefixAction(value);
771
+ // Handled by submenu -- no cycling
754
772
  break;
755
773
  }
756
774
  saveConfigToFile(config);
@@ -758,70 +776,32 @@ export default function proxyExtension(pi: ExtensionAPI): void {
758
776
  }
759
777
 
760
778
  /**
761
- * Toggle a provider in/out of the scoped providers list.
762
- * Value format: "[+] provider" to add, "[-] provider" to remove.
763
- */
764
- function applyScopedProviderToggle(value: string): void {
765
- const match = /^\[([+-])\]\s+(.+)$/.exec(value);
766
- if (match === null) return;
767
- const action = match[1];
768
- const provider = match[2];
769
- if (provider === undefined) return;
770
-
771
- const current = new Set(config.scopedProviders);
772
- if (action === "+") {
773
- current.add(provider);
774
- } else {
775
- current.delete(provider);
776
- }
777
- config = { ...config, scopedProviders: [...current] };
778
- }
779
-
780
- /**
781
- * Toggle a model in/out of the custom models list.
782
- * Value format: "[+] provider/model-id" to add, "[-] provider/model-id" to remove.
783
- */
784
- function applyCustomModelToggle(value: string): void {
785
- const match = /^\[([+-])\]\s+(.+)$/.exec(value);
786
- if (match === null) return;
787
- const action = match[1];
788
- const canonicalId = match[2];
789
- if (canonicalId === undefined) return;
790
-
791
- const current = new Set(config.customModels);
792
- if (action === "+") {
793
- current.add(canonicalId);
794
- } else {
795
- current.delete(canonicalId);
796
- }
797
- config = { ...config, customModels: [...current] };
798
- }
799
-
800
- /**
801
- * Apply a prefix override action.
802
- * Value format: "set provider" to prompt for a label, "clear provider" to remove override.
779
+ * Get the display value for a setting after it has been applied.
803
780
  */
804
- function applyPrefixAction(value: string): void {
805
- const clearMatch = /^clear\s+(.+)$/.exec(value);
806
- if (clearMatch !== null) {
807
- const provider = clearMatch[1];
808
- if (provider === undefined) return;
809
- const next = { ...config.providerPrefixes };
810
- delete next[provider];
811
- config = { ...config, providerPrefixes: next };
812
- return;
813
- }
814
-
815
- // "set provider" -- set a simple abbreviated prefix
816
- const setMatch = /^set\s+(.+)$/.exec(value);
817
- if (setMatch !== null) {
818
- const provider = setMatch[1];
819
- if (provider === undefined) return;
820
- // Use first 3 characters as a short prefix (user can edit JSON for custom values)
821
- const shortPrefix = provider.slice(0, 3);
822
- const next = { ...config.providerPrefixes };
823
- next[provider] = shortPrefix;
824
- config = { ...config, providerPrefixes: next };
781
+ function getDisplayValue(id: string): string {
782
+ switch (id) {
783
+ case "lifetime":
784
+ return config.lifetime;
785
+ case "host":
786
+ return config.host;
787
+ case "port":
788
+ return String(config.port);
789
+ case "authToken":
790
+ return config.authToken.length > 0 ? "enabled" : "disabled";
791
+ case "remoteImages":
792
+ return config.remoteImages ? "on" : "off";
793
+ case "maxBodySizeMb":
794
+ return `${String(config.maxBodySizeMb)} MB`;
795
+ case "upstreamTimeoutSec":
796
+ return `${String(config.upstreamTimeoutSec)}s`;
797
+ case "publicModelIdMode":
798
+ return config.publicModelIdMode;
799
+ case "modelExposureMode":
800
+ return config.modelExposureMode;
801
+ case "customModels":
802
+ return customModelsDisplay();
803
+ default:
804
+ return "";
825
805
  }
826
806
  }
827
807
 
@@ -830,54 +810,57 @@ export default function proxyExtension(pi: ExtensionAPI): void {
830
810
 
831
811
  await ctx.ui.custom<void>(
832
812
  (tui, theme, _kb, done) => {
833
- function build(): { container: Container; settingsList: SettingsList } {
834
- const container = new Container();
835
- container.addChild(new Text(theme.fg("accent", theme.bold("Proxy Settings")), 1, 0));
836
- container.addChild(new Text(theme.fg("dim", getConfigPath()), 1, 0));
837
-
838
- const settingsList = new SettingsList(
839
- buildSettingItems(),
840
- 10,
841
- getSettingsListTheme(),
842
- (id, newValue) => {
843
- lastGeneratedToken = "";
844
- applySetting(id, newValue);
845
- if (lastGeneratedToken.length > 0) {
846
- ctx.ui.notify(`Auth token: ${lastGeneratedToken}`, "info");
847
- }
848
- current = build();
849
- tui.requestRender();
850
- },
851
- () => done(undefined),
852
- { enableSearch: true },
853
- );
854
-
855
- container.addChild(settingsList);
856
- container.addChild(
857
- new Text(
858
- theme.fg(
859
- "dim",
860
- "Esc: close | Arrow keys: navigate | Space: toggle | Restart proxy to apply",
861
- ),
862
- 1,
863
- 0,
864
- ),
865
- );
813
+ const container = new Container();
814
+ container.addChild(new Text(theme.fg("accent", theme.bold("Proxy Settings")), 1, 0));
815
+ container.addChild(new Text(theme.fg("dim", getConfigPath()), 1, 0));
816
+
817
+ const items = buildSettingItems();
818
+ const settingsList = new SettingsList(
819
+ items,
820
+ 12,
821
+ getSettingsListTheme(),
822
+ (id, newValue) => {
823
+ lastGeneratedToken = "";
824
+ applySetting(id, newValue);
825
+ if (lastGeneratedToken.length > 0) {
826
+ ctx.ui.notify(`Auth token: ${lastGeneratedToken}`, "info");
827
+ }
828
+
829
+ // Update display value in-place (no rebuild, preserves selection)
830
+ settingsList.updateValue(id, getDisplayValue(id));
831
+
832
+ // When exposure mode changes, update the "Select models" item
833
+ if (id === "modelExposureMode") {
834
+ settingsList.updateValue("customModels", customModelsDisplay());
835
+ }
866
836
 
867
- return { container, settingsList };
868
- }
837
+ tui.requestRender();
838
+ },
839
+ () => done(undefined),
840
+ { enableSearch: true },
841
+ );
869
842
 
870
- let current = build();
843
+ container.addChild(settingsList);
844
+ container.addChild(
845
+ new Text(
846
+ theme.fg(
847
+ "dim",
848
+ "Esc: close | Arrow keys: navigate | Space: toggle | Restart proxy to apply",
849
+ ),
850
+ 1,
851
+ 0,
852
+ ),
853
+ );
871
854
 
872
855
  return {
873
856
  render(width: number): string[] {
874
- return current.container.render(width);
857
+ return container.render(width);
875
858
  },
876
859
  invalidate(): void {
877
- current.container.invalidate();
860
+ container.invalidate();
878
861
  },
879
862
  handleInput(data: string): void {
880
- current.settingsList.handleInput?.(data);
863
+ settingsList.handleInput?.(data);
881
864
  tui.requestRender();
882
865
  },
883
866
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-openai-proxy",
3
- "version": "2.1.0",
3
+ "version": "4.0.0",
4
4
  "description": "OpenAI-compatible HTTP proxy for pi's multi-provider model registry",
5
5
  "license": "MIT",
6
6
  "author": "Victor Software House",