@victor-software-house/pi-openai-proxy 2.1.0 → 3.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 {
@@ -532,27 +538,92 @@ export default function proxyExtension(pi: ExtensionAPI): void {
532
538
 
533
539
  let lastGeneratedToken = "";
534
540
 
535
- function buildSettingItems(): SettingItem[] {
536
- // Build dynamic choices for scoped providers and custom models
541
+ function customModelsDisplay(): string {
542
+ if (config.modelExposureMode !== "custom") return "n/a";
543
+ return config.customModels.length > 0
544
+ ? `${String(config.customModels.length)} selected`
545
+ : "(none)";
546
+ }
547
+
548
+ /**
549
+ * Build a submenu Component for selecting custom models.
550
+ * Shows all available models as a toggleable checklist.
551
+ */
552
+ function buildModelSelectorSubmenu(
553
+ _currentValue: string,
554
+ done: (selectedValue?: string) => void,
555
+ ): Component {
537
556
  const models = getAvailableModels();
538
- const availableProviders = getUniqueProviders(models);
557
+ const selected = new Set(config.customModels);
558
+ let selectedIndex = 0;
539
559
 
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)";
560
+ function toggle(canonicalId: string): void {
561
+ if (selected.has(canonicalId)) {
562
+ selected.delete(canonicalId);
563
+ } else {
564
+ selected.add(canonicalId);
565
+ }
566
+ config = { ...config, customModels: [...selected] };
567
+ saveConfigToFile(config);
568
+ config = loadConfigFromFile();
569
+ }
544
570
 
545
- // Custom models: show count
546
- const customDisplay =
547
- config.customModels.length > 0 ? `${String(config.customModels.length)} selected` : "(none)";
571
+ return {
572
+ render(width: number): string[] {
573
+ const lines: string[] = [];
574
+ lines.push(" Select models (Space: toggle, Esc: done)");
575
+ lines.push("");
576
+
577
+ if (models.length === 0) {
578
+ lines.push(" No models available (no auth configured)");
579
+ return lines;
580
+ }
548
581
 
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)";
582
+ const maxVisible = 15;
583
+ const start = Math.max(
584
+ 0,
585
+ Math.min(selectedIndex - Math.floor(maxVisible / 2), models.length - maxVisible),
586
+ );
587
+ const end = Math.min(start + maxVisible, models.length);
588
+
589
+ for (let i = start; i < end; i++) {
590
+ const m = models[i];
591
+ if (m === undefined) continue;
592
+ const canonical = `${m.provider}/${m.id}`;
593
+ const check = selected.has(canonical) ? "[x]" : "[ ]";
594
+ const cursor = i === selectedIndex ? "> " : " ";
595
+ const line = `${cursor}${check} ${canonical}`;
596
+ const truncated = line.length > width ? line.slice(0, width - 1) : line;
597
+ lines.push(truncated);
598
+ }
599
+
600
+ lines.push("");
601
+ lines.push(` ${String(selected.size)} of ${String(models.length)} selected`);
602
+ return lines;
603
+ },
604
+ invalidate(): void {
605
+ // no-op
606
+ },
607
+ handleInput(data: string): void {
608
+ if (data === "\x1B" || data === "q") {
609
+ done(`${String(selected.size)} selected`);
610
+ return;
611
+ }
612
+ if (data === "\x1B[A" && selectedIndex > 0) {
613
+ selectedIndex--;
614
+ } else if (data === "\x1B[B" && selectedIndex < models.length - 1) {
615
+ selectedIndex++;
616
+ } else if (data === " " || data === "\r") {
617
+ const m = models[selectedIndex];
618
+ if (m !== undefined) {
619
+ toggle(`${m.provider}/${m.id}`);
620
+ }
621
+ }
622
+ },
623
+ };
624
+ }
555
625
 
626
+ function buildSettingItems(): SettingItem[] {
556
627
  return [
557
628
  // --- Server ---
558
629
  {
@@ -618,67 +689,24 @@ export default function proxyExtension(pi: ExtensionAPI): void {
618
689
  {
619
690
  id: "modelExposureMode",
620
691
  label: "Exposure mode",
621
- description: "Which models are exposed on the API",
692
+ description:
693
+ "scoped = pi's available models, all = all registered, custom = manual selection",
622
694
  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
- }),
695
+ values: ["scoped", "all", "custom"],
634
696
  },
635
697
  {
636
698
  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),
699
+ label: "Select models",
700
+ description:
701
+ config.modelExposureMode === "custom"
702
+ ? "Press Enter to open model selector"
703
+ : "Switch exposure mode to 'custom' to select models",
704
+ currentValue: customModelsDisplay(),
705
+ submenu: config.modelExposureMode === "custom" ? buildModelSelectorSubmenu : undefined,
648
706
  },
649
707
  ];
650
708
  }
651
709
 
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
710
  const VALID_ID_MODES = new Set<string>(["collision-prefixed", "universal", "always-prefixed"]);
683
711
 
684
712
  function isPublicModelIdMode(v: string): v is PublicModelIdMode {
@@ -743,14 +771,8 @@ export default function proxyExtension(pi: ExtensionAPI): void {
743
771
  config = { ...config, modelExposureMode: value };
744
772
  }
745
773
  break;
746
- case "scopedProviders":
747
- applyScopedProviderToggle(value);
748
- break;
749
774
  case "customModels":
750
- applyCustomModelToggle(value);
751
- break;
752
- case "providerPrefixes":
753
- applyPrefixAction(value);
775
+ // Handled by submenu -- no cycling
754
776
  break;
755
777
  }
756
778
  saveConfigToFile(config);
@@ -758,70 +780,32 @@ export default function proxyExtension(pi: ExtensionAPI): void {
758
780
  }
759
781
 
760
782
  /**
761
- * Toggle a provider in/out of the scoped providers list.
762
- * Value format: "[+] provider" to add, "[-] provider" to remove.
783
+ * Get the display value for a setting after it has been applied.
763
784
  */
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.
803
- */
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 };
785
+ function getDisplayValue(id: string): string {
786
+ switch (id) {
787
+ case "lifetime":
788
+ return config.lifetime;
789
+ case "host":
790
+ return config.host;
791
+ case "port":
792
+ return String(config.port);
793
+ case "authToken":
794
+ return config.authToken.length > 0 ? "enabled" : "disabled";
795
+ case "remoteImages":
796
+ return config.remoteImages ? "on" : "off";
797
+ case "maxBodySizeMb":
798
+ return `${String(config.maxBodySizeMb)} MB`;
799
+ case "upstreamTimeoutSec":
800
+ return `${String(config.upstreamTimeoutSec)}s`;
801
+ case "publicModelIdMode":
802
+ return config.publicModelIdMode;
803
+ case "modelExposureMode":
804
+ return config.modelExposureMode;
805
+ case "customModels":
806
+ return customModelsDisplay();
807
+ default:
808
+ return "";
825
809
  }
826
810
  }
827
811
 
@@ -830,54 +814,57 @@ export default function proxyExtension(pi: ExtensionAPI): void {
830
814
 
831
815
  await ctx.ui.custom<void>(
832
816
  (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
- );
817
+ const container = new Container();
818
+ container.addChild(new Text(theme.fg("accent", theme.bold("Proxy Settings")), 1, 0));
819
+ container.addChild(new Text(theme.fg("dim", getConfigPath()), 1, 0));
820
+
821
+ const items = buildSettingItems();
822
+ const settingsList = new SettingsList(
823
+ items,
824
+ 12,
825
+ getSettingsListTheme(),
826
+ (id, newValue) => {
827
+ lastGeneratedToken = "";
828
+ applySetting(id, newValue);
829
+ if (lastGeneratedToken.length > 0) {
830
+ ctx.ui.notify(`Auth token: ${lastGeneratedToken}`, "info");
831
+ }
832
+
833
+ // Update display value in-place (no rebuild, preserves selection)
834
+ settingsList.updateValue(id, getDisplayValue(id));
835
+
836
+ // When exposure mode changes, update the "Select models" item
837
+ if (id === "modelExposureMode") {
838
+ settingsList.updateValue("customModels", customModelsDisplay());
839
+ }
866
840
 
867
- return { container, settingsList };
868
- }
841
+ tui.requestRender();
842
+ },
843
+ () => done(undefined),
844
+ { enableSearch: true },
845
+ );
869
846
 
870
- let current = build();
847
+ container.addChild(settingsList);
848
+ container.addChild(
849
+ new Text(
850
+ theme.fg(
851
+ "dim",
852
+ "Esc: close | Arrow keys: navigate | Space: toggle | Restart proxy to apply",
853
+ ),
854
+ 1,
855
+ 0,
856
+ ),
857
+ );
871
858
 
872
859
  return {
873
860
  render(width: number): string[] {
874
- return current.container.render(width);
861
+ return container.render(width);
875
862
  },
876
863
  invalidate(): void {
877
- current.container.invalidate();
864
+ container.invalidate();
878
865
  },
879
866
  handleInput(data: string): void {
880
- current.settingsList.handleInput?.(data);
867
+ settingsList.handleInput?.(data);
881
868
  tui.requestRender();
882
869
  },
883
870
  };
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": "3.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",