@victor-software-house/pi-openai-proxy 2.0.0 → 2.1.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/README.md CHANGED
@@ -5,10 +5,10 @@ A local OpenAI-compatible HTTP proxy built on [pi](https://github.com/badlogic/p
5
5
  ## Why
6
6
 
7
7
  - **Single gateway** to 20+ LLM providers (Anthropic, OpenAI, Google, Bedrock, Mistral, xAI, Groq, OpenRouter, Vertex, etc.) via one OpenAI-compatible API
8
- - **No duplicate config** -- reuses pi's `~/.pi/agent/auth.json` and `models.json` for credentials and model definitions
9
- - **Self-hosted** -- runs locally, no third-party proxy services
10
- - **Streaming** -- full SSE streaming with token usage and cost tracking
11
- - **Strict validation** -- unsupported parameters are rejected clearly, not silently ignored
8
+ - **No duplicate config** reuses pi's `~/.pi/agent/auth.json` and `models.json` for credentials and model definitions
9
+ - **Self-hosted** runs locally, no third-party proxy services
10
+ - **Streaming** full SSE streaming with token usage and cost tracking
11
+ - **Strict validation** unsupported parameters are rejected clearly, not silently ignored
12
12
 
13
13
  ## Prerequisites
14
14
 
@@ -81,8 +81,8 @@ OPENAI_API_BASE=http://localhost:4141/v1 aider --model anthropic/claude-sonnet-4
81
81
 
82
82
  The proxy resolves model references in this order:
83
83
 
84
- 1. **Exact public ID match** -- the ID from `GET /v1/models`
85
- 2. **Canonical ID fallback** -- `provider/model-id` format (only for exposed models)
84
+ 1. **Exact public ID match** the ID from `GET /v1/models`
85
+ 2. **Canonical ID fallback** `provider/model-id` format (only for exposed models)
86
86
 
87
87
  With the default `collision-prefixed` mode and no collisions, model IDs are exposed without prefixes:
88
88
 
@@ -109,7 +109,7 @@ curl http://localhost:4141/v1/chat/completions \
109
109
  | `messages` (base64 images) | Base64 data URI image content parts (`image/png`, `image/jpeg`, `image/gif`, `image/webp`) |
110
110
  | `stream` | SSE with `text_delta` and `toolcall_delta` mapping |
111
111
  | `temperature` | Direct passthrough |
112
- | `max_tokens` / `max_completion_tokens` | Normalized to `maxTokens` |
112
+ | `max_completion_tokens` | Preferred; `max_tokens` accepted as deprecated fallback |
113
113
  | `stop` | Via passthrough |
114
114
  | `user` | Via passthrough |
115
115
  | `stream_options.include_usage` | Final usage chunk in SSE stream |
@@ -284,29 +284,33 @@ The extension detects externally running instances and shows their status via `/
284
284
 
285
285
  ## Architecture
286
286
 
287
- ```
288
- HTTP Client pi-openai-proxy
289
- (curl, Aider, Continue, +--------------------------+
290
- LiteLLM, Open WebUI, etc.) | |
291
- | | Hono HTTP Server |
292
- | POST /v1/chat/ | +-- Request parser |
293
- +--completions------>| +-- Message converter |
294
- | | +-- Model resolver |
295
- | GET /v1/models | +-- Tool converter |
296
- +------------------>| +-- SSE encoder |
297
- | | |
298
- | | Pi SDK |
299
- | SSE / JSON | +-- ModelRegistry |
300
- |<------------------+ +-- AuthStorage |
301
- | +-- streamSimple() |
302
- | +-- completeSimple() |
303
- +--------------------------+
287
+ ```text
288
+ ┌─────────────────────────────┐ ┌──────────────────────────────────┐
289
+ │ HTTP Client │ │ pi-openai-proxy │
290
+ │ (curl, Aider, Continue, │ │ │
291
+ │ LiteLLM, Open WebUI, etc.)│ │ ┌────────────────────────────┐ │
292
+ └─────────────┬───────────────┘ │ Hono HTTP Server │ │
293
+ │ │ │ ├─ Request parser │ │
294
+ │ POST /v1/chat/ │ ├─ Message converter │ │
295
+ completions │ │ ├─ Model resolver │ │
296
+ ├────────────────────────►│ │ ├─ Tool converter │ │
297
+ │ │ │ └─ SSE encoder │ │
298
+ │ GET /v1/models └────────────────────────────┘ │
299
+ ├────────────────────────►│ │
300
+ │ │ ┌────────────────────────────┐ │
301
+ │ │ │ Pi SDK │ │
302
+ SSE / JSON │ │ ├─ ModelRegistry │ │
303
+ │◄────────────────────────┤ │ ├─ AuthStorage │ │
304
+ │ │ │ ├─ streamSimple() │ │
305
+ │ │ └─ completeSimple() │ │
306
+ │ └────────────────────────────┘ │
307
+ └──────────────────────────────────┘
304
308
  ```
305
309
 
306
310
  ### Pi SDK layers used
307
311
 
308
- - **`@mariozechner/pi-ai`** -- `streamSimple()`, `completeSimple()`, `Model`, `Usage`, `AssistantMessageEvent`
309
- - **`@mariozechner/pi-coding-agent`** -- `ModelRegistry`, `AuthStorage`
312
+ - **`@mariozechner/pi-ai`** `streamSimple()`, `completeSimple()`, `Model`, `Usage`, `AssistantMessageEvent`
313
+ - **`@mariozechner/pi-coding-agent`** `ModelRegistry`, `AuthStorage`
310
314
 
311
315
  ## Security defaults
312
316
 
@@ -330,14 +334,14 @@ bun test # Run all tests
330
334
 
331
335
  ### Tooling
332
336
 
333
- - **Bun** -- runtime, test runner, package manager
334
- - **tsdown** -- npm build (ESM + .d.ts)
335
- - **Biome** -- format + lint
336
- - **oxlint** -- type-aware lint with strict rules (`.oxlintrc.json`)
337
- - **lefthook** -- pre-commit hooks (format, lint, typecheck), pre-push hooks (test)
338
- - **commitlint** -- conventional commits
339
- - **semantic-release** -- automated versioning and npm publish
340
- - **mise** -- tool version management (node, bun)
337
+ - **Bun** runtime, test runner, package manager
338
+ - **tsdown** npm build (ESM + .d.ts)
339
+ - **Biome** format + lint
340
+ - **oxlint** type-aware lint with strict rules (`.oxlintrc.json`)
341
+ - **lefthook** pre-commit hooks (format, lint, typecheck), pre-push hooks (test)
342
+ - **commitlint** conventional commits
343
+ - **semantic-release** automated versioning and npm publish
344
+ - **mise** tool version management (node, bun)
341
345
 
342
346
  ## License
343
347
 
@@ -0,0 +1,52 @@
1
+
2
+ import { ModelExposureMode, PublicModelIdMode } from "./config.mjs";
3
+ import { Api, Model } from "@mariozechner/pi-ai";
4
+
5
+ //#region src/openai/model-exposure.d.ts
6
+ interface ExposedModel {
7
+ /** The public ID exposed on the HTTP API. */
8
+ readonly publicId: string;
9
+ /** The canonical internal ID: "provider/model-id". */
10
+ readonly canonicalId: string;
11
+ /** The underlying pi model. */
12
+ readonly model: Model<Api>;
13
+ /** The provider key. */
14
+ readonly provider: string;
15
+ }
16
+ interface ModelExposureConfig {
17
+ readonly publicModelIdMode: PublicModelIdMode;
18
+ readonly modelExposureMode: ModelExposureMode;
19
+ readonly scopedProviders: readonly string[];
20
+ readonly customModels: readonly string[];
21
+ readonly providerPrefixes: Readonly<Record<string, string>>;
22
+ }
23
+ interface ModelExposureResult {
24
+ readonly ok: true;
25
+ /** All exposed models with public IDs. */
26
+ readonly models: readonly ExposedModel[];
27
+ /** Public ID -> ExposedModel for O(1) lookup. */
28
+ readonly byPublicId: ReadonlyMap<string, ExposedModel>;
29
+ /** Canonical ID -> ExposedModel for backward-compat fallback. */
30
+ readonly byCanonicalId: ReadonlyMap<string, ExposedModel>;
31
+ }
32
+ interface ModelExposureError {
33
+ readonly ok: false;
34
+ readonly message: string;
35
+ }
36
+ type ModelExposureOutcome = ModelExposureResult | ModelExposureError;
37
+ /**
38
+ * Compute the full model-exposure result from config and available models.
39
+ *
40
+ * Call this at startup and whenever config or the model registry changes.
41
+ */
42
+ declare function computeModelExposure(available: readonly Model<Api>[], config: ModelExposureConfig): ModelExposureOutcome;
43
+ /**
44
+ * Resolve a model ID from an incoming request against the exposure result.
45
+ *
46
+ * Resolution order:
47
+ * 1. Exact public ID match
48
+ * 2. Exact canonical ID match (backward compat, only if model is exposed)
49
+ */
50
+ declare function resolveExposedModel(exposure: ModelExposureResult, requestModelId: string): ExposedModel | undefined;
51
+ //#endregion
52
+ export { ExposedModel, ModelExposureConfig, ModelExposureError, ModelExposureOutcome, ModelExposureResult, computeModelExposure, resolveExposedModel };
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env bun
2
+ //#region src/openai/model-exposure.ts
3
+ function filterExposedModels(available, config) {
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
+ }
10
+ case "custom": {
11
+ const allowed = new Set(config.customModels);
12
+ return available.filter((m) => allowed.has(`${m.provider}/${m.id}`));
13
+ }
14
+ }
15
+ }
16
+ /**
17
+ * Get the public prefix label for a provider.
18
+ * Uses the configured override if present, otherwise the provider key itself.
19
+ */
20
+ function getPublicPrefix(provider, prefixes) {
21
+ const override = prefixes[provider];
22
+ return override !== void 0 && override.length > 0 ? override : provider;
23
+ }
24
+ /**
25
+ * Find connected conflict groups: sets of providers that share at least one raw model ID.
26
+ *
27
+ * If provider A and B share a model ID, and B and C share a different model ID,
28
+ * then {A, B, C} form one connected conflict group.
29
+ */
30
+ function findConflictGroups(models) {
31
+ const modelToProviders = /* @__PURE__ */ new Map();
32
+ for (const m of models) {
33
+ const existing = modelToProviders.get(m.id);
34
+ if (existing !== void 0) existing.add(m.provider);
35
+ else modelToProviders.set(m.id, new Set([m.provider]));
36
+ }
37
+ const parent = /* @__PURE__ */ new Map();
38
+ function find(x) {
39
+ let root = x;
40
+ while (parent.get(root) !== root) {
41
+ const p = parent.get(root);
42
+ if (p === void 0) break;
43
+ root = p;
44
+ }
45
+ let current = x;
46
+ while (current !== root) {
47
+ const next = parent.get(current);
48
+ if (next === void 0) break;
49
+ parent.set(current, root);
50
+ current = next;
51
+ }
52
+ return root;
53
+ }
54
+ function union(a, b) {
55
+ const ra = find(a);
56
+ const rb = find(b);
57
+ if (ra !== rb) parent.set(ra, rb);
58
+ }
59
+ for (const m of models) if (!parent.has(m.provider)) parent.set(m.provider, m.provider);
60
+ for (const providers of modelToProviders.values()) {
61
+ if (providers.size <= 1) continue;
62
+ const arr = [...providers];
63
+ const first = arr[0];
64
+ if (first === void 0) continue;
65
+ for (let i = 1; i < arr.length; i++) {
66
+ const other = arr[i];
67
+ if (other !== void 0) union(first, other);
68
+ }
69
+ }
70
+ const groupsByRoot = /* @__PURE__ */ new Map();
71
+ for (const provider of parent.keys()) {
72
+ const root = find(provider);
73
+ const existing = groupsByRoot.get(root);
74
+ if (existing !== void 0) existing.add(provider);
75
+ else groupsByRoot.set(root, new Set([provider]));
76
+ }
77
+ return [...groupsByRoot.values()].filter((g) => g.size > 1);
78
+ }
79
+ function generateCollisionPrefixedIds(models, prefixes) {
80
+ const conflictGroups = findConflictGroups(models);
81
+ const prefixedProviders = /* @__PURE__ */ new Set();
82
+ for (const group of conflictGroups) for (const provider of group) prefixedProviders.add(provider);
83
+ const result = /* @__PURE__ */ new Map();
84
+ for (const m of models) if (prefixedProviders.has(m.provider)) {
85
+ const prefix = getPublicPrefix(m.provider, prefixes);
86
+ result.set(m, `${prefix}/${m.id}`);
87
+ } else result.set(m, m.id);
88
+ return result;
89
+ }
90
+ function generateUniversalIds(models) {
91
+ const seen = /* @__PURE__ */ new Map();
92
+ for (const m of models) {
93
+ const existing = seen.get(m.id);
94
+ if (existing !== void 0) return `Universal mode conflict: model ID '${m.id}' is provided by both '${existing}' and '${m.provider}'. Use 'collision-prefixed' or 'always-prefixed' mode, or reduce the exposed model set.`;
95
+ seen.set(m.id, m.provider);
96
+ }
97
+ const result = /* @__PURE__ */ new Map();
98
+ for (const m of models) result.set(m, m.id);
99
+ return result;
100
+ }
101
+ function generateAlwaysPrefixedIds(models, prefixes) {
102
+ const result = /* @__PURE__ */ new Map();
103
+ for (const m of models) {
104
+ const prefix = getPublicPrefix(m.provider, prefixes);
105
+ result.set(m, `${prefix}/${m.id}`);
106
+ }
107
+ return result;
108
+ }
109
+ function validatePrefixUniqueness(models, prefixes, mode) {
110
+ if (mode === "universal") return void 0;
111
+ const providers = /* @__PURE__ */ new Set();
112
+ if (mode === "always-prefixed") for (const m of models) providers.add(m.provider);
113
+ else {
114
+ const conflictGroups = findConflictGroups(models);
115
+ for (const group of conflictGroups) for (const provider of group) providers.add(provider);
116
+ }
117
+ const labelToProvider = /* @__PURE__ */ new Map();
118
+ for (const provider of providers) {
119
+ const label = getPublicPrefix(provider, prefixes);
120
+ const existing = labelToProvider.get(label);
121
+ if (existing !== void 0) return `Duplicate prefix label '${label}' used by providers '${existing}' and '${provider}'. Configure distinct providerPrefixes.`;
122
+ labelToProvider.set(label, provider);
123
+ }
124
+ }
125
+ /**
126
+ * Compute the full model-exposure result from config and available models.
127
+ *
128
+ * Call this at startup and whenever config or the model registry changes.
129
+ */
130
+ function computeModelExposure(available, config) {
131
+ const exposed = filterExposedModels(available, config);
132
+ const prefixError = validatePrefixUniqueness(exposed, config.providerPrefixes, config.publicModelIdMode);
133
+ if (prefixError !== void 0) return {
134
+ ok: false,
135
+ message: prefixError
136
+ };
137
+ let idMap;
138
+ switch (config.publicModelIdMode) {
139
+ case "collision-prefixed":
140
+ idMap = generateCollisionPrefixedIds(exposed, config.providerPrefixes);
141
+ break;
142
+ case "universal": {
143
+ const result = generateUniversalIds(exposed);
144
+ if (typeof result === "string") return {
145
+ ok: false,
146
+ message: result
147
+ };
148
+ idMap = result;
149
+ break;
150
+ }
151
+ case "always-prefixed":
152
+ idMap = generateAlwaysPrefixedIds(exposed, config.providerPrefixes);
153
+ break;
154
+ }
155
+ const models = [];
156
+ const byPublicId = /* @__PURE__ */ new Map();
157
+ const byCanonicalId = /* @__PURE__ */ new Map();
158
+ for (const m of exposed) {
159
+ const publicId = idMap.get(m);
160
+ if (publicId === void 0) continue;
161
+ const canonicalId = `${m.provider}/${m.id}`;
162
+ const entry = {
163
+ publicId,
164
+ canonicalId,
165
+ model: m,
166
+ provider: m.provider
167
+ };
168
+ models.push(entry);
169
+ byPublicId.set(publicId, entry);
170
+ byCanonicalId.set(canonicalId, entry);
171
+ }
172
+ return {
173
+ ok: true,
174
+ models,
175
+ byPublicId,
176
+ byCanonicalId
177
+ };
178
+ }
179
+ /**
180
+ * Resolve a model ID from an incoming request against the exposure result.
181
+ *
182
+ * Resolution order:
183
+ * 1. Exact public ID match
184
+ * 2. Exact canonical ID match (backward compat, only if model is exposed)
185
+ */
186
+ function resolveExposedModel(exposure, requestModelId) {
187
+ const byPublic = exposure.byPublicId.get(requestModelId);
188
+ if (byPublic !== void 0) return byPublic;
189
+ return exposure.byCanonicalId.get(requestModelId);
190
+ }
191
+ //#endregion
192
+ export { computeModelExposure, resolveExposedModel };
package/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { getConfigPath, loadConfigFromFile } from "./config.mjs";
3
+ import { computeModelExposure, resolveExposedModel } from "./exposure.mjs";
3
4
  import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
4
5
  import { randomBytes } from "node:crypto";
5
6
  import * as z from "zod";
@@ -468,196 +469,6 @@ function parseAndValidateDataUri(uri) {
468
469
  };
469
470
  }
470
471
  //#endregion
471
- //#region src/openai/model-exposure.ts
472
- function filterExposedModels(available, config) {
473
- switch (config.modelExposureMode) {
474
- case "all": return [...available];
475
- case "scoped": {
476
- const providers = new Set(config.scopedProviders);
477
- return available.filter((m) => providers.has(m.provider));
478
- }
479
- case "custom": {
480
- const allowed = new Set(config.customModels);
481
- return available.filter((m) => allowed.has(`${m.provider}/${m.id}`));
482
- }
483
- }
484
- }
485
- /**
486
- * Get the public prefix label for a provider.
487
- * Uses the configured override if present, otherwise the provider key itself.
488
- */
489
- function getPublicPrefix(provider, prefixes) {
490
- const override = prefixes[provider];
491
- return override !== void 0 && override.length > 0 ? override : provider;
492
- }
493
- /**
494
- * Find connected conflict groups: sets of providers that share at least one raw model ID.
495
- *
496
- * If provider A and B share a model ID, and B and C share a different model ID,
497
- * then {A, B, C} form one connected conflict group.
498
- */
499
- function findConflictGroups(models) {
500
- const modelToProviders = /* @__PURE__ */ new Map();
501
- for (const m of models) {
502
- const existing = modelToProviders.get(m.id);
503
- if (existing !== void 0) existing.add(m.provider);
504
- else modelToProviders.set(m.id, new Set([m.provider]));
505
- }
506
- const parent = /* @__PURE__ */ new Map();
507
- function find(x) {
508
- let root = x;
509
- while (parent.get(root) !== root) {
510
- const p = parent.get(root);
511
- if (p === void 0) break;
512
- root = p;
513
- }
514
- let current = x;
515
- while (current !== root) {
516
- const next = parent.get(current);
517
- if (next === void 0) break;
518
- parent.set(current, root);
519
- current = next;
520
- }
521
- return root;
522
- }
523
- function union(a, b) {
524
- const ra = find(a);
525
- const rb = find(b);
526
- if (ra !== rb) parent.set(ra, rb);
527
- }
528
- for (const m of models) if (!parent.has(m.provider)) parent.set(m.provider, m.provider);
529
- for (const providers of modelToProviders.values()) {
530
- if (providers.size <= 1) continue;
531
- const arr = [...providers];
532
- const first = arr[0];
533
- if (first === void 0) continue;
534
- for (let i = 1; i < arr.length; i++) {
535
- const other = arr[i];
536
- if (other !== void 0) union(first, other);
537
- }
538
- }
539
- const groupsByRoot = /* @__PURE__ */ new Map();
540
- for (const provider of parent.keys()) {
541
- const root = find(provider);
542
- const existing = groupsByRoot.get(root);
543
- if (existing !== void 0) existing.add(provider);
544
- else groupsByRoot.set(root, new Set([provider]));
545
- }
546
- return [...groupsByRoot.values()].filter((g) => g.size > 1);
547
- }
548
- function generateCollisionPrefixedIds(models, prefixes) {
549
- const conflictGroups = findConflictGroups(models);
550
- const prefixedProviders = /* @__PURE__ */ new Set();
551
- for (const group of conflictGroups) for (const provider of group) prefixedProviders.add(provider);
552
- const result = /* @__PURE__ */ new Map();
553
- for (const m of models) if (prefixedProviders.has(m.provider)) {
554
- const prefix = getPublicPrefix(m.provider, prefixes);
555
- result.set(m, `${prefix}/${m.id}`);
556
- } else result.set(m, m.id);
557
- return result;
558
- }
559
- function generateUniversalIds(models) {
560
- const seen = /* @__PURE__ */ new Map();
561
- for (const m of models) {
562
- const existing = seen.get(m.id);
563
- if (existing !== void 0) return `Universal mode conflict: model ID '${m.id}' is provided by both '${existing}' and '${m.provider}'. Use 'collision-prefixed' or 'always-prefixed' mode, or reduce the exposed model set.`;
564
- seen.set(m.id, m.provider);
565
- }
566
- const result = /* @__PURE__ */ new Map();
567
- for (const m of models) result.set(m, m.id);
568
- return result;
569
- }
570
- function generateAlwaysPrefixedIds(models, prefixes) {
571
- const result = /* @__PURE__ */ new Map();
572
- for (const m of models) {
573
- const prefix = getPublicPrefix(m.provider, prefixes);
574
- result.set(m, `${prefix}/${m.id}`);
575
- }
576
- return result;
577
- }
578
- function validatePrefixUniqueness(models, prefixes, mode) {
579
- if (mode === "universal") return void 0;
580
- const providers = /* @__PURE__ */ new Set();
581
- if (mode === "always-prefixed") for (const m of models) providers.add(m.provider);
582
- else {
583
- const conflictGroups = findConflictGroups(models);
584
- for (const group of conflictGroups) for (const provider of group) providers.add(provider);
585
- }
586
- const labelToProvider = /* @__PURE__ */ new Map();
587
- for (const provider of providers) {
588
- const label = getPublicPrefix(provider, prefixes);
589
- const existing = labelToProvider.get(label);
590
- if (existing !== void 0) return `Duplicate prefix label '${label}' used by providers '${existing}' and '${provider}'. Configure distinct providerPrefixes.`;
591
- labelToProvider.set(label, provider);
592
- }
593
- }
594
- /**
595
- * Compute the full model-exposure result from config and available models.
596
- *
597
- * Call this at startup and whenever config or the model registry changes.
598
- */
599
- function computeModelExposure(available, config) {
600
- const exposed = filterExposedModels(available, config);
601
- const prefixError = validatePrefixUniqueness(exposed, config.providerPrefixes, config.publicModelIdMode);
602
- if (prefixError !== void 0) return {
603
- ok: false,
604
- message: prefixError
605
- };
606
- let idMap;
607
- switch (config.publicModelIdMode) {
608
- case "collision-prefixed":
609
- idMap = generateCollisionPrefixedIds(exposed, config.providerPrefixes);
610
- break;
611
- case "universal": {
612
- const result = generateUniversalIds(exposed);
613
- if (typeof result === "string") return {
614
- ok: false,
615
- message: result
616
- };
617
- idMap = result;
618
- break;
619
- }
620
- case "always-prefixed":
621
- idMap = generateAlwaysPrefixedIds(exposed, config.providerPrefixes);
622
- break;
623
- }
624
- const models = [];
625
- const byPublicId = /* @__PURE__ */ new Map();
626
- const byCanonicalId = /* @__PURE__ */ new Map();
627
- for (const m of exposed) {
628
- const publicId = idMap.get(m);
629
- if (publicId === void 0) continue;
630
- const canonicalId = `${m.provider}/${m.id}`;
631
- const entry = {
632
- publicId,
633
- canonicalId,
634
- model: m,
635
- provider: m.provider
636
- };
637
- models.push(entry);
638
- byPublicId.set(publicId, entry);
639
- byCanonicalId.set(canonicalId, entry);
640
- }
641
- return {
642
- ok: true,
643
- models,
644
- byPublicId,
645
- byCanonicalId
646
- };
647
- }
648
- /**
649
- * Resolve a model ID from an incoming request against the exposure result.
650
- *
651
- * Resolution order:
652
- * 1. Exact public ID match
653
- * 2. Exact canonical ID match (backward compat, only if model is exposed)
654
- */
655
- function resolveExposedModel(exposure, requestModelId) {
656
- const byPublic = exposure.byPublicId.get(requestModelId);
657
- if (byPublic !== void 0) return byPublic;
658
- return exposure.byCanonicalId.get(requestModelId);
659
- }
660
- //#endregion
661
472
  //#region src/openai/models.ts
662
473
  /**
663
474
  * Convert an ExposedModel to an OpenAI model object.
@@ -6,24 +6,29 @@
6
6
  * /proxy start Start the proxy server
7
7
  * /proxy stop Stop the proxy server
8
8
  * /proxy status Show proxy status
9
+ * /proxy verify Validate model exposure config against available models
9
10
  * /proxy config Open settings panel (alias)
10
- * /proxy show Summarize current config
11
+ * /proxy show Summarize current config and exposure policy
11
12
  * /proxy path Show config file location
12
13
  * /proxy reset Restore default settings
13
14
  * /proxy help Usage line
14
15
  *
15
16
  * Config schema imported from @victor-software-house/pi-openai-proxy/config (SSOT).
17
+ * Model-exposure engine imported from @victor-software-house/pi-openai-proxy/exposure.
16
18
  */
17
19
 
18
20
  import { type ChildProcess, spawn } from "node:child_process";
19
21
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
20
22
  import { dirname, resolve } from "node:path";
21
23
  import { fileURLToPath } from "node:url";
24
+ import type { Api, Model } from "@mariozechner/pi-ai";
22
25
  import {
26
+ AuthStorage,
23
27
  type ExtensionAPI,
24
28
  type ExtensionCommandContext,
25
29
  type ExtensionContext,
26
30
  getSettingsListTheme,
31
+ ModelRegistry,
27
32
  } from "@mariozechner/pi-coding-agent";
28
33
  import { Container, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui";
29
34
 
@@ -33,9 +38,17 @@ import {
33
38
  DEFAULT_CONFIG,
34
39
  getConfigPath,
35
40
  loadConfigFromFile,
41
+ type ModelExposureMode,
42
+ type PublicModelIdMode,
36
43
  saveConfigToFile,
37
44
  } from "@victor-software-house/pi-openai-proxy/config";
38
45
 
46
+ // Model-exposure engine
47
+ import {
48
+ computeModelExposure,
49
+ type ModelExposureConfig,
50
+ } from "@victor-software-house/pi-openai-proxy/exposure";
51
+
39
52
  // ---------------------------------------------------------------------------
40
53
  // Runtime status
41
54
  // ---------------------------------------------------------------------------
@@ -56,6 +69,32 @@ export default function proxyExtension(pi: ExtensionAPI): void {
56
69
  const extensionDir = dirname(fileURLToPath(import.meta.url));
57
70
  const packageRoot = resolve(extensionDir, "..");
58
71
 
72
+ // --- Model registry access (for verify/show/selectors) ---
73
+
74
+ function getAvailableModels(): Model<Api>[] {
75
+ const auth = AuthStorage.create();
76
+ const registry = new ModelRegistry(auth);
77
+ return registry.getAvailable();
78
+ }
79
+
80
+ function getUniqueProviders(models: readonly Model<Api>[]): string[] {
81
+ const seen = new Set<string>();
82
+ for (const m of models) {
83
+ seen.add(m.provider);
84
+ }
85
+ return [...seen].sort();
86
+ }
87
+
88
+ function buildExposureConfig(): ModelExposureConfig {
89
+ return {
90
+ publicModelIdMode: config.publicModelIdMode,
91
+ modelExposureMode: config.modelExposureMode,
92
+ scopedProviders: config.scopedProviders,
93
+ customModels: config.customModels,
94
+ providerPrefixes: config.providerPrefixes,
95
+ };
96
+ }
97
+
59
98
  // Resolve pi-proxy binary: try workspace node_modules, then global
60
99
  function findProxyBinary(): string {
61
100
  // In workspace: node_modules/pi-proxy/dist/index.mjs
@@ -85,8 +124,18 @@ export default function proxyExtension(pi: ExtensionAPI): void {
85
124
 
86
125
  // --- Command family ---
87
126
 
88
- const SUBCOMMANDS = ["start", "stop", "status", "config", "show", "path", "reset", "help"];
89
- const USAGE = "/proxy [start|stop|status|config|show|path|reset|help]";
127
+ const SUBCOMMANDS = [
128
+ "start",
129
+ "stop",
130
+ "status",
131
+ "verify",
132
+ "config",
133
+ "show",
134
+ "path",
135
+ "reset",
136
+ "help",
137
+ ];
138
+ const USAGE = "/proxy [start|stop|status|verify|config|show|path|reset|help]";
90
139
 
91
140
  pi.registerCommand("proxy", {
92
141
  description: "Manage the OpenAI-compatible proxy",
@@ -108,8 +157,11 @@ export default function proxyExtension(pi: ExtensionAPI): void {
108
157
  case "status":
109
158
  await showStatus(ctx);
110
159
  return;
160
+ case "verify":
161
+ verifyExposure(ctx);
162
+ return;
111
163
  case "show":
112
- await showConfig(ctx);
164
+ showConfig(ctx);
113
165
  return;
114
166
  case "path":
115
167
  ctx.ui.notify(getConfigPath(), "info");
@@ -358,11 +410,13 @@ export default function proxyExtension(pi: ExtensionAPI): void {
358
410
  await refreshStatus(ctx);
359
411
  }
360
412
 
361
- async function showConfig(ctx: ExtensionContext): Promise<void> {
413
+ function showConfig(ctx: ExtensionContext): void {
362
414
  config = loadConfigFromFile();
363
415
  const authDisplay =
364
416
  config.authToken.length > 0 ? `enabled (token: ${config.authToken})` : "disabled";
365
- const lines = [
417
+
418
+ // Server settings
419
+ const serverLines = [
366
420
  `lifetime: ${config.lifetime}`,
367
421
  `host: ${config.host}`,
368
422
  `port: ${String(config.port)}`,
@@ -371,8 +425,96 @@ export default function proxyExtension(pi: ExtensionAPI): void {
371
425
  `max body: ${String(config.maxBodySizeMb)} MB`,
372
426
  `timeout: ${String(config.upstreamTimeoutSec)}s`,
373
427
  ];
374
- ctx.ui.notify(lines.join(" | "), "info");
375
- await refreshStatus(ctx);
428
+
429
+ // Exposure policy
430
+ const exposureLines = [
431
+ `id mode: ${config.publicModelIdMode}`,
432
+ `exposure: ${config.modelExposureMode}`,
433
+ ];
434
+
435
+ if (config.modelExposureMode === "scoped" && config.scopedProviders.length > 0) {
436
+ exposureLines.push(`providers: ${config.scopedProviders.join(", ")}`);
437
+ }
438
+ if (config.modelExposureMode === "custom" && config.customModels.length > 0) {
439
+ exposureLines.push(`models: ${String(config.customModels.length)} custom`);
440
+ }
441
+
442
+ const prefixKeys = Object.keys(config.providerPrefixes);
443
+ if (prefixKeys.length > 0) {
444
+ const pairs = prefixKeys.map((k) => `${k}=${config.providerPrefixes[k] ?? k}`);
445
+ exposureLines.push(`prefixes: ${pairs.join(", ")}`);
446
+ }
447
+
448
+ // Public ID preview (first 5 exposed models)
449
+ const models = getAvailableModels();
450
+ const outcome = computeModelExposure(models, buildExposureConfig());
451
+ if (outcome.ok && outcome.models.length > 0) {
452
+ const preview = outcome.models.slice(0, 5).map((m) => m.publicId);
453
+ const suffix =
454
+ outcome.models.length > 5 ? ` (+${String(outcome.models.length - 5)} more)` : "";
455
+ exposureLines.push(`exposed: ${preview.join(", ")}${suffix}`);
456
+ } else if (outcome.ok) {
457
+ exposureLines.push("exposed: none");
458
+ } else {
459
+ exposureLines.push(`error: ${outcome.message}`);
460
+ }
461
+
462
+ ctx.ui.notify(`${serverLines.join(" | ")}\n${exposureLines.join(" | ")}`, "info");
463
+ }
464
+
465
+ // --- /proxy verify ---
466
+
467
+ function verifyExposure(ctx: ExtensionContext): void {
468
+ config = loadConfigFromFile();
469
+ const models = getAvailableModels();
470
+ const issues: string[] = [];
471
+
472
+ // Check available models
473
+ if (models.length === 0) {
474
+ issues.push("No models have auth configured. The proxy will expose 0 models.");
475
+ }
476
+
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
+ // Check custom models reference valid canonical IDs
491
+ if (config.modelExposureMode === "custom") {
492
+ const canonicalSet = new Set(models.map((m) => `${m.provider}/${m.id}`));
493
+ for (const id of config.customModels) {
494
+ if (!canonicalSet.has(id)) {
495
+ issues.push(`Custom model '${id}' is not available (no auth or unknown).`);
496
+ }
497
+ }
498
+ if (config.customModels.length === 0) {
499
+ issues.push("Custom mode with empty model list will expose 0 models.");
500
+ }
501
+ }
502
+
503
+ // Run the full exposure computation to catch ID/prefix errors
504
+ const outcome = computeModelExposure(models, buildExposureConfig());
505
+ if (!outcome.ok) {
506
+ issues.push(outcome.message);
507
+ }
508
+
509
+ if (issues.length === 0) {
510
+ const count = outcome.ok ? outcome.models.length : 0;
511
+ ctx.ui.notify(`Verification passed. ${String(count)} models exposed.`, "info");
512
+ } else {
513
+ ctx.ui.notify(
514
+ `Verification found ${String(issues.length)} issue(s):\n${issues.join("\n")}`,
515
+ "warning",
516
+ );
517
+ }
376
518
  }
377
519
 
378
520
  async function waitForReady(timeoutMs: number): Promise<RuntimeStatus> {
@@ -391,7 +533,28 @@ export default function proxyExtension(pi: ExtensionAPI): void {
391
533
  let lastGeneratedToken = "";
392
534
 
393
535
  function buildSettingItems(): SettingItem[] {
536
+ // Build dynamic choices for scoped providers and custom models
537
+ const models = getAvailableModels();
538
+ const availableProviders = getUniqueProviders(models);
539
+
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)";
544
+
545
+ // Custom models: show count
546
+ const customDisplay =
547
+ config.customModels.length > 0 ? `${String(config.customModels.length)} selected` : "(none)";
548
+
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)";
555
+
394
556
  return [
557
+ // --- Server ---
395
558
  {
396
559
  id: "lifetime",
397
560
  label: "Lifetime",
@@ -444,9 +607,90 @@ export default function proxyExtension(pi: ExtensionAPI): void {
444
607
  currentValue: `${String(config.upstreamTimeoutSec)}s`,
445
608
  values: ["30s", "60s", "120s", "300s"],
446
609
  },
610
+ // --- Model exposure ---
611
+ {
612
+ id: "publicModelIdMode",
613
+ label: "Public ID mode",
614
+ description: "How public model IDs are generated from canonical provider/model-id",
615
+ currentValue: config.publicModelIdMode,
616
+ values: ["collision-prefixed", "universal", "always-prefixed"],
617
+ },
618
+ {
619
+ id: "modelExposureMode",
620
+ label: "Exposure mode",
621
+ description: "Which models are exposed on the API",
622
+ 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
+ }),
634
+ },
635
+ {
636
+ 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),
648
+ },
447
649
  ];
448
650
  }
449
651
 
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
+ const VALID_ID_MODES = new Set<string>(["collision-prefixed", "universal", "always-prefixed"]);
683
+
684
+ function isPublicModelIdMode(v: string): v is PublicModelIdMode {
685
+ return VALID_ID_MODES.has(v);
686
+ }
687
+
688
+ const VALID_EXPOSURE_MODES = new Set<string>(["all", "scoped", "custom"]);
689
+
690
+ function isModelExposureMode(v: string): v is ModelExposureMode {
691
+ return VALID_EXPOSURE_MODES.has(v);
692
+ }
693
+
450
694
  function applySetting(id: string, value: string): void {
451
695
  switch (id) {
452
696
  case "lifetime":
@@ -489,11 +733,98 @@ export default function proxyExtension(pi: ExtensionAPI): void {
489
733
  if (Number.isFinite(sec) && sec > 0) config = { ...config, upstreamTimeoutSec: sec };
490
734
  break;
491
735
  }
736
+ case "publicModelIdMode":
737
+ if (isPublicModelIdMode(value)) {
738
+ config = { ...config, publicModelIdMode: value };
739
+ }
740
+ break;
741
+ case "modelExposureMode":
742
+ if (isModelExposureMode(value)) {
743
+ config = { ...config, modelExposureMode: value };
744
+ }
745
+ break;
746
+ case "scopedProviders":
747
+ applyScopedProviderToggle(value);
748
+ break;
749
+ case "customModels":
750
+ applyCustomModelToggle(value);
751
+ break;
752
+ case "providerPrefixes":
753
+ applyPrefixAction(value);
754
+ break;
492
755
  }
493
756
  saveConfigToFile(config);
494
757
  config = loadConfigFromFile();
495
758
  }
496
759
 
760
+ /**
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.
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 };
825
+ }
826
+ }
827
+
497
828
  async function openSettingsPanel(ctx: ExtensionCommandContext): Promise<void> {
498
829
  config = loadConfigFromFile();
499
830
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-openai-proxy",
3
- "version": "2.0.0",
3
+ "version": "2.1.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",
@@ -37,6 +37,10 @@
37
37
  "./config": {
38
38
  "import": "./dist/config.mjs",
39
39
  "types": "./dist/config.d.mts"
40
+ },
41
+ "./exposure": {
42
+ "import": "./dist/exposure.mjs",
43
+ "types": "./dist/exposure.d.mts"
40
44
  }
41
45
  },
42
46
  "main": "dist/index.mjs",