@victor-software-house/pi-openai-proxy 1.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
 
@@ -77,9 +77,14 @@ OPENAI_API_BASE=http://localhost:4141/v1 aider --model anthropic/claude-sonnet-4
77
77
  # Set "OpenAI API Base URL" to http://localhost:4141/v1
78
78
  ```
79
79
 
80
- ### Shorthand model names
80
+ ### Model resolution
81
81
 
82
- If a model ID is unique across providers, you can omit the provider prefix:
82
+ The proxy resolves model references in this order:
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)
86
+
87
+ With the default `collision-prefixed` mode and no collisions, model IDs are exposed without prefixes:
83
88
 
84
89
  ```bash
85
90
  curl http://localhost:4141/v1/chat/completions \
@@ -87,33 +92,31 @@ curl http://localhost:4141/v1/chat/completions \
87
92
  -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hi"}]}'
88
93
  ```
89
94
 
90
- Ambiguous shorthand requests fail with a clear error listing the matching canonical IDs.
91
-
92
95
  ## Supported Endpoints
93
96
 
94
97
  | Endpoint | Description |
95
98
  |---|---|
96
- | `GET /v1/models` | List all available models (only those with configured credentials) |
97
- | `GET /v1/models/{model}` | Model details by canonical ID (supports URL-encoded IDs with `/`) |
99
+ | `GET /v1/models` | List exposed models (filtered by exposure mode, only those with configured credentials) |
100
+ | `GET /v1/models/{model}` | Model details by public ID or canonical ID (supports URL-encoded IDs with `/`) |
98
101
  | `POST /v1/chat/completions` | Chat completions (streaming and non-streaming) |
99
102
 
100
103
  ## Supported Chat Completions Features
101
104
 
102
105
  | Feature | Notes |
103
106
  |---|---|
104
- | `model` | Canonical (`provider/model-id`) or unique shorthand |
107
+ | `model` | Public ID, canonical (`provider/model-id`), or collision-prefixed shorthand |
105
108
  | `messages` (text) | `system`, `developer`, `user`, `assistant`, `tool` roles |
106
109
  | `messages` (base64 images) | Base64 data URI image content parts (`image/png`, `image/jpeg`, `image/gif`, `image/webp`) |
107
110
  | `stream` | SSE with `text_delta` and `toolcall_delta` mapping |
108
111
  | `temperature` | Direct passthrough |
109
- | `max_tokens` / `max_completion_tokens` | Normalized to `maxTokens` |
112
+ | `max_completion_tokens` | Preferred; `max_tokens` accepted as deprecated fallback |
110
113
  | `stop` | Via passthrough |
111
114
  | `user` | Via passthrough |
112
115
  | `stream_options.include_usage` | Final usage chunk in SSE stream |
113
116
  | `tools` / `tool_choice` | JSON Schema -> TypeBox conversion (supported subset) |
114
117
  | `tool_calls` in messages | Assistant tool call + tool result roundtrip |
115
- | `reasoning_effort` | Maps to pi's `ThinkingLevel` (`low`, `medium`, `high`) |
116
- | `response_format` | `text` and `json_object` via passthrough |
118
+ | `reasoning_effort` | Maps to pi's `ThinkingLevel` (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`) |
119
+ | `response_format` | `text`, `json_object`, and `json_schema` via passthrough |
117
120
  | `top_p` | Via passthrough |
118
121
  | `frequency_penalty` | Via passthrough |
119
122
  | `presence_penalty` | Via passthrough |
@@ -121,9 +124,33 @@ Ambiguous shorthand requests fail with a clear error listing the matching canoni
121
124
 
122
125
  **Not supported:** `n > 1`, `logprobs`, `logit_bias`, remote image URLs (disabled by default).
123
126
 
124
- ## Model Naming
127
+ ## Model Naming and Exposure
125
128
 
126
- Models use the `provider/model-id` canonical format, matching pi's registry:
129
+ ### Public model IDs
130
+
131
+ The proxy generates public model IDs based on a configurable ID mode:
132
+
133
+ | Mode | Behavior | Example |
134
+ |---|---|---|
135
+ | `collision-prefixed` (default) | Raw model IDs; prefix only providers that share a model name | `gpt-4o` or `openai/gpt-4o` if `codex` also has `gpt-4o` |
136
+ | `universal` | Raw model IDs only; rejects config if duplicates exist | `gpt-4o`, `claude-sonnet-4-20250514` |
137
+ | `always-prefixed` | Always `<prefix>/<model-id>` | `openai/gpt-4o`, `anthropic/claude-sonnet-4-20250514` |
138
+
139
+ The `collision-prefixed` mode prefixes **all** models from providers that form a connected conflict group (not just the colliding model names).
140
+
141
+ ### Exposure modes
142
+
143
+ Control which models appear in the API:
144
+
145
+ | Mode | Behavior |
146
+ |---|---|
147
+ | `all` (default) | Expose every model with configured credentials |
148
+ | `scoped` | Expose models from selected providers only (`scopedProviders`) |
149
+ | `custom` | Expose an explicit allowlist of canonical IDs (`customModels`) |
150
+
151
+ ### Canonical model IDs
152
+
153
+ Internal canonical IDs use the `provider/model-id` format matching pi's registry:
127
154
 
128
155
  ```
129
156
  anthropic/claude-sonnet-4-20250514
@@ -133,6 +160,8 @@ xai/grok-3
133
160
  openrouter/anthropic/claude-sonnet-4-20250514
134
161
  ```
135
162
 
163
+ Canonical IDs are accepted as backward-compatible fallback in requests, but only for models that are currently exposed. Hidden models cannot be reached by canonical ID.
164
+
136
165
  ## Configuration
137
166
 
138
167
  ### What comes from pi
@@ -159,22 +188,27 @@ Proxy-specific settings are configured via environment variables or the `/proxy
159
188
  | Max body size | `PI_PROXY_MAX_BODY_SIZE` | `52428800` (50 MB) | Maximum request body size in bytes |
160
189
  | Upstream timeout | `PI_PROXY_UPSTREAM_TIMEOUT_MS` | `120000` (120s) | Upstream request timeout in milliseconds |
161
190
 
162
- When used as a pi package, these settings are persisted in `~/.pi/agent/proxy-config.json` and applied when the extension spawns the proxy.
191
+ Model exposure settings are configured via the JSON config file (`~/.pi/agent/proxy-config.json`):
192
+
193
+ | Setting | Default | Description |
194
+ |---|---|---|
195
+ | `publicModelIdMode` | `collision-prefixed` | Public ID format: `collision-prefixed`, `universal`, `always-prefixed` |
196
+ | `modelExposureMode` | `all` | Which models to expose: `all`, `scoped`, `custom` |
197
+ | `scopedProviders` | `[]` | Provider keys for `scoped` mode |
198
+ | `customModels` | `[]` | Canonical model IDs for `custom` mode |
199
+ | `providerPrefixes` | `{}` | Provider key -> custom prefix label overrides |
200
+
201
+ When used as a pi package, all settings are persisted in `~/.pi/agent/proxy-config.json` and applied when the extension spawns the proxy.
163
202
 
164
203
  ### Discovering available models
165
204
 
166
- List all models the proxy can reach (models with configured credentials):
205
+ List all models the proxy exposes (filtered by the active exposure mode):
167
206
 
168
207
  ```bash
169
208
  curl http://localhost:4141/v1/models | jq '.data[].id'
170
209
  ```
171
210
 
172
- Each model includes extended metadata under `x_pi`:
173
-
174
- ```bash
175
- curl http://localhost:4141/v1/models/anthropic%2Fclaude-sonnet-4-20250514 | jq '.x_pi'
176
- # { "api": "anthropic", "reasoning": true, "input": ["text", "image"], ... }
177
- ```
211
+ Model objects follow the standard OpenAI shape (`id`, `object`, `created`, `owned_by`) with no proprietary extensions.
178
212
 
179
213
  ### Per-request API key override
180
214
 
@@ -250,29 +284,33 @@ The extension detects externally running instances and shows their status via `/
250
284
 
251
285
  ## Architecture
252
286
 
253
- ```
254
- HTTP Client pi-openai-proxy
255
- (curl, Aider, Continue, +--------------------------+
256
- LiteLLM, Open WebUI, etc.) | |
257
- | | Hono HTTP Server |
258
- | POST /v1/chat/ | +-- Request parser |
259
- +--completions------>| +-- Message converter |
260
- | | +-- Model resolver |
261
- | GET /v1/models | +-- Tool converter |
262
- +------------------>| +-- SSE encoder |
263
- | | |
264
- | | Pi SDK |
265
- | SSE / JSON | +-- ModelRegistry |
266
- |<------------------+ +-- AuthStorage |
267
- | +-- streamSimple() |
268
- | +-- completeSimple() |
269
- +--------------------------+
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
+ └──────────────────────────────────┘
270
308
  ```
271
309
 
272
310
  ### Pi SDK layers used
273
311
 
274
- - **`@mariozechner/pi-ai`** -- `streamSimple()`, `completeSimple()`, `Model`, `Usage`, `AssistantMessageEvent`
275
- - **`@mariozechner/pi-coding-agent`** -- `ModelRegistry`, `AuthStorage`
312
+ - **`@mariozechner/pi-ai`** `streamSimple()`, `completeSimple()`, `Model`, `Usage`, `AssistantMessageEvent`
313
+ - **`@mariozechner/pi-coding-agent`** `ModelRegistry`, `AuthStorage`
276
314
 
277
315
  ## Security defaults
278
316
 
@@ -296,14 +334,14 @@ bun test # Run all tests
296
334
 
297
335
  ### Tooling
298
336
 
299
- - **Bun** -- runtime, test runner, package manager
300
- - **tsdown** -- npm build (ESM + .d.ts)
301
- - **Biome** -- format + lint
302
- - **oxlint** -- type-aware lint with strict rules (`.oxlintrc.json`)
303
- - **lefthook** -- pre-commit hooks (format, lint, typecheck), pre-push hooks (test)
304
- - **commitlint** -- conventional commits
305
- - **semantic-release** -- automated versioning and npm publish
306
- - **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)
307
345
 
308
346
  ## License
309
347
 
package/dist/config.d.mts CHANGED
@@ -7,6 +7,22 @@
7
7
  * The server reads the JSON config file as defaults, with env vars and CLI args as overrides.
8
8
  * The pi extension reads and writes the JSON config file via the /proxy config panel.
9
9
  */
10
+ /**
11
+ * How public model IDs are generated from canonical provider/model-id pairs.
12
+ *
13
+ * - "collision-prefixed": prefix only providers in conflict groups that share a raw model ID
14
+ * - "universal": expose raw model IDs only; duplicates are a config error
15
+ * - "always-prefixed": always expose <prefix>/<model-id> for every model
16
+ */
17
+ type PublicModelIdMode = "collision-prefixed" | "universal" | "always-prefixed";
18
+ /**
19
+ * Which models are exposed on the public HTTP API.
20
+ *
21
+ * - "all": every available model
22
+ * - "scoped": all available models from selected providers only
23
+ * - "custom": explicit allowlist of canonical model IDs
24
+ */
25
+ type ModelExposureMode = "all" | "scoped" | "custom";
10
26
  interface ProxyConfig {
11
27
  /** Bind address. Default: "127.0.0.1" */
12
28
  readonly host: string;
@@ -22,6 +38,16 @@ interface ProxyConfig {
22
38
  readonly upstreamTimeoutSec: number;
23
39
  /** "detached" = background daemon, "session" = dies with pi session. Default: "detached" */
24
40
  readonly lifetime: "detached" | "session";
41
+ /** How public model IDs are generated. Default: "collision-prefixed" */
42
+ readonly publicModelIdMode: PublicModelIdMode;
43
+ /** Which models are exposed. Default: "all" */
44
+ readonly modelExposureMode: ModelExposureMode;
45
+ /** Provider keys to expose when modelExposureMode is "scoped". */
46
+ readonly scopedProviders: readonly string[];
47
+ /** Canonical model IDs to expose when modelExposureMode is "custom". */
48
+ readonly customModels: readonly string[];
49
+ /** Provider key -> custom public prefix label. Default prefix = provider key. */
50
+ readonly providerPrefixes: Readonly<Record<string, string>>;
25
51
  }
26
52
  declare const DEFAULT_CONFIG: Readonly<ProxyConfig>;
27
53
  declare function normalizeConfig(raw: unknown): ProxyConfig;
@@ -30,4 +56,4 @@ declare function loadConfigFromFile(): ProxyConfig;
30
56
  declare function saveConfigToFile(config: ProxyConfig): void;
31
57
  declare function configToEnv(config: ProxyConfig): Record<string, string>;
32
58
  //#endregion
33
- export { DEFAULT_CONFIG, ProxyConfig, configToEnv, getConfigPath, loadConfigFromFile, normalizeConfig, saveConfigToFile };
59
+ export { DEFAULT_CONFIG, ModelExposureMode, ProxyConfig, PublicModelIdMode, configToEnv, getConfigPath, loadConfigFromFile, normalizeConfig, saveConfigToFile };
package/dist/config.mjs CHANGED
@@ -16,7 +16,12 @@ const DEFAULT_CONFIG = {
16
16
  remoteImages: false,
17
17
  maxBodySizeMb: 50,
18
18
  upstreamTimeoutSec: 120,
19
- lifetime: "detached"
19
+ lifetime: "detached",
20
+ publicModelIdMode: "collision-prefixed",
21
+ modelExposureMode: "all",
22
+ scopedProviders: [],
23
+ customModels: [],
24
+ providerPrefixes: {}
20
25
  };
21
26
  function isRecord(value) {
22
27
  return value !== null && value !== void 0 && typeof value === "object" && !Array.isArray(value);
@@ -25,11 +30,41 @@ function clampInt(raw, min, max, fallback) {
25
30
  if (typeof raw !== "number" || !Number.isFinite(raw)) return fallback;
26
31
  return Math.max(min, Math.min(max, Math.round(raw)));
27
32
  }
33
+ const VALID_PUBLIC_ID_MODES = new Set([
34
+ "collision-prefixed",
35
+ "universal",
36
+ "always-prefixed"
37
+ ]);
38
+ function isPublicModelIdMode(value) {
39
+ return VALID_PUBLIC_ID_MODES.has(value);
40
+ }
41
+ const VALID_EXPOSURE_MODES = new Set([
42
+ "all",
43
+ "scoped",
44
+ "custom"
45
+ ]);
46
+ function isModelExposureMode(value) {
47
+ return VALID_EXPOSURE_MODES.has(value);
48
+ }
49
+ function normalizeStringArray(raw) {
50
+ if (!Array.isArray(raw)) return [];
51
+ const result = [];
52
+ for (const item of raw) if (typeof item === "string" && item.length > 0) result.push(item);
53
+ return result;
54
+ }
55
+ function normalizeStringRecord(raw) {
56
+ if (!isRecord(raw)) return {};
57
+ const result = {};
58
+ for (const [key, val] of Object.entries(raw)) if (typeof val === "string" && val.length > 0) result[key] = val;
59
+ return result;
60
+ }
28
61
  function normalizeConfig(raw) {
29
62
  const v = isRecord(raw) ? raw : {};
30
63
  const rawHost = v["host"];
31
64
  const rawAuthToken = v["authToken"];
32
65
  const rawRemoteImages = v["remoteImages"];
66
+ const rawPublicIdMode = v["publicModelIdMode"];
67
+ const rawExposureMode = v["modelExposureMode"];
33
68
  return {
34
69
  host: typeof rawHost === "string" && rawHost.length > 0 ? rawHost : DEFAULT_CONFIG.host,
35
70
  port: clampInt(v["port"], 1, 65535, DEFAULT_CONFIG.port),
@@ -37,7 +72,12 @@ function normalizeConfig(raw) {
37
72
  remoteImages: typeof rawRemoteImages === "boolean" ? rawRemoteImages : DEFAULT_CONFIG.remoteImages,
38
73
  maxBodySizeMb: clampInt(v["maxBodySizeMb"], 1, 500, DEFAULT_CONFIG.maxBodySizeMb),
39
74
  upstreamTimeoutSec: clampInt(v["upstreamTimeoutSec"], 5, 600, DEFAULT_CONFIG.upstreamTimeoutSec),
40
- lifetime: v["lifetime"] === "session" ? "session" : "detached"
75
+ lifetime: v["lifetime"] === "session" ? "session" : "detached",
76
+ publicModelIdMode: typeof rawPublicIdMode === "string" && isPublicModelIdMode(rawPublicIdMode) ? rawPublicIdMode : DEFAULT_CONFIG.publicModelIdMode,
77
+ modelExposureMode: typeof rawExposureMode === "string" && isModelExposureMode(rawExposureMode) ? rawExposureMode : DEFAULT_CONFIG.modelExposureMode,
78
+ scopedProviders: normalizeStringArray(v["scopedProviders"]),
79
+ customModels: normalizeStringArray(v["customModels"]),
80
+ providerPrefixes: normalizeStringRecord(v["providerPrefixes"])
41
81
  };
42
82
  }
43
83
  function getConfigPath() {
@@ -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";
@@ -9,15 +10,6 @@ import { Hono } from "hono";
9
10
  import { stream } from "hono/streaming";
10
11
  import { defineCommand, runMain } from "citty";
11
12
  //#region src/config/env.ts
12
- /**
13
- * Server configuration loading.
14
- *
15
- * Priority: CLI args > env vars > JSON config file > defaults.
16
- *
17
- * The JSON config file (~/.pi/agent/proxy-config.json) is the shared config
18
- * written by the pi extension's /proxy config panel. Env vars and CLI args
19
- * override it for one-off adjustments or container use.
20
- */
21
13
  function parsePositiveInt(raw, fallback) {
22
14
  if (raw === void 0) return fallback;
23
15
  const n = Number.parseInt(raw, 10);
@@ -43,7 +35,12 @@ function loadConfig(cli = {}) {
43
35
  agenticEnabled: process.env.PI_PROXY_AGENTIC === "true",
44
36
  remoteImagesEnabled,
45
37
  maxBodySize,
46
- upstreamTimeoutMs
38
+ upstreamTimeoutMs,
39
+ publicModelIdMode: file.publicModelIdMode,
40
+ modelExposureMode: file.modelExposureMode,
41
+ scopedProviders: file.scopedProviders,
42
+ customModels: file.customModels,
43
+ providerPrefixes: file.providerPrefixes
47
44
  };
48
45
  }
49
46
  //#endregion
@@ -474,21 +471,14 @@ function parseAndValidateDataUri(uri) {
474
471
  //#endregion
475
472
  //#region src/openai/models.ts
476
473
  /**
477
- * Convert a pi Model to an OpenAI model object.
474
+ * Convert an ExposedModel to an OpenAI model object.
478
475
  */
479
- function toOpenAIModel(model) {
476
+ function toOpenAIModel(exposed) {
480
477
  return {
481
- id: `${model.provider}/${model.id}`,
478
+ id: exposed.publicId,
482
479
  object: "model",
483
480
  created: 0,
484
- owned_by: model.provider,
485
- x_pi: {
486
- api: model.api,
487
- reasoning: model.reasoning,
488
- input: model.input,
489
- context_window: model.contextWindow,
490
- max_tokens: model.maxTokens
491
- }
481
+ owned_by: exposed.provider
492
482
  };
493
483
  }
494
484
  /**
@@ -1067,7 +1057,20 @@ const toolChoiceSchema = z.union([z.enum([
1067
1057
  "auto",
1068
1058
  "required"
1069
1059
  ]), namedToolChoiceSchema]);
1070
- const responseFormatSchema = z.discriminatedUnion("type", [z.object({ type: z.literal("text") }), z.object({ type: z.literal("json_object") })]);
1060
+ const jsonSchemaResponseFormatSchema = z.object({
1061
+ type: z.literal("json_schema"),
1062
+ json_schema: z.object({
1063
+ name: z.string().trim(),
1064
+ description: z.string().trim().optional(),
1065
+ schema: z.record(z.string().trim(), z.unknown()).optional(),
1066
+ strict: z.boolean().nullable().optional()
1067
+ })
1068
+ });
1069
+ const responseFormatSchema = z.discriminatedUnion("type", [
1070
+ z.object({ type: z.literal("text") }),
1071
+ z.object({ type: z.literal("json_object") }),
1072
+ jsonSchemaResponseFormatSchema
1073
+ ]);
1071
1074
  const chatCompletionRequestSchema = z.object({
1072
1075
  model: z.string().trim(),
1073
1076
  messages: z.array(messageSchema).min(1),
@@ -1081,9 +1084,12 @@ const chatCompletionRequestSchema = z.object({
1081
1084
  tools: z.array(functionToolSchema).optional(),
1082
1085
  tool_choice: toolChoiceSchema.optional(),
1083
1086
  reasoning_effort: z.enum([
1087
+ "none",
1088
+ "minimal",
1084
1089
  "low",
1085
1090
  "medium",
1086
- "high"
1091
+ "high",
1092
+ "xhigh"
1087
1093
  ]).optional(),
1088
1094
  top_p: z.number().min(0).max(1).optional(),
1089
1095
  frequency_penalty: z.number().min(-2).max(2).optional(),
@@ -1164,15 +1170,18 @@ function isRecord(value) {
1164
1170
  /**
1165
1171
  * Map OpenAI reasoning_effort to pi ThinkingLevel.
1166
1172
  *
1167
- * OpenAI: "low" | "medium" | "high"
1173
+ * OpenAI: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"
1168
1174
  * Pi: "minimal" | "low" | "medium" | "high" | "xhigh"
1169
1175
  *
1170
- * Direct mapping for the three shared values.
1176
+ * "none" maps to "minimal" (pi has no "none" level).
1171
1177
  */
1172
1178
  const REASONING_EFFORT_MAP = {
1179
+ none: "minimal",
1180
+ minimal: "minimal",
1173
1181
  low: "low",
1174
1182
  medium: "medium",
1175
- high: "high"
1183
+ high: "high",
1184
+ xhigh: "xhigh"
1176
1185
  };
1177
1186
  /**
1178
1187
  * APIs where onPayload passthrough fields are not supported.
@@ -1267,75 +1276,33 @@ async function piStream(model, context, request, options) {
1267
1276
  return streamSimple(model, context, await buildStreamOptions(model, request, options));
1268
1277
  }
1269
1278
  //#endregion
1270
- //#region src/pi/resolve-model.ts
1279
+ //#region src/server/routes.ts
1271
1280
  /**
1272
- * Parse a canonical model ID string into provider and model-id.
1273
- *
1274
- * "openai/gpt-4o" -> { provider: "openai", modelId: "gpt-4o" }
1275
- * "openrouter/anthropic/claude-sonnet-4-20250514" -> { provider: "openrouter", modelId: "anthropic/claude-sonnet-4-20250514" }
1276
- * "gpt-4o" -> null (shorthand, no slash)
1281
+ * Build a ModelExposureConfig from the server config.
1277
1282
  */
1278
- function parseCanonicalId(input) {
1279
- const slashIndex = input.indexOf("/");
1280
- if (slashIndex === -1) return null;
1283
+ function buildExposureConfig(config) {
1281
1284
  return {
1282
- provider: input.slice(0, slashIndex),
1283
- modelId: input.slice(slashIndex + 1)
1285
+ publicModelIdMode: config.publicModelIdMode,
1286
+ modelExposureMode: config.modelExposureMode,
1287
+ scopedProviders: config.scopedProviders,
1288
+ customModels: config.customModels,
1289
+ providerPrefixes: config.providerPrefixes
1284
1290
  };
1285
1291
  }
1286
1292
  /**
1287
- * Resolve a model string (canonical or shorthand) to a pi Model.
1293
+ * Compute or refresh the model exposure from the current registry and config.
1294
+ * Returns the exposure result or throws on config errors.
1288
1295
  */
1289
- function resolveModel(input) {
1290
- const registry = getRegistry();
1291
- const parsed = parseCanonicalId(input);
1292
- if (parsed !== null) {
1293
- const model = registry.find(parsed.provider, parsed.modelId);
1294
- if (model === void 0) return {
1295
- ok: false,
1296
- status: 404,
1297
- message: `Model '${input}' not found`
1298
- };
1299
- return {
1300
- ok: true,
1301
- model
1302
- };
1303
- }
1304
- const allModels = registry.getAll();
1305
- const matches = [];
1306
- for (const m of allModels) if (m.id === input) matches.push(m);
1307
- if (matches.length === 0) return {
1308
- ok: false,
1309
- status: 404,
1310
- message: `Model '${input}' not found`
1311
- };
1312
- if (matches.length === 1) {
1313
- const match = matches[0];
1314
- if (match === void 0) return {
1315
- ok: false,
1316
- status: 404,
1317
- message: `Model '${input}' not found`
1318
- };
1319
- return {
1320
- ok: true,
1321
- model: match
1322
- };
1323
- }
1324
- const candidates = matches.map((m) => `${m.provider}/${m.id}`);
1325
- return {
1326
- ok: false,
1327
- status: 400,
1328
- message: `Ambiguous model '${input}'. Matches: ${candidates.join(", ")}. Use the canonical form 'provider/model-id'.`,
1329
- candidates
1330
- };
1296
+ function getExposure(config) {
1297
+ const outcome = computeModelExposure(getAvailableModels(), buildExposureConfig(config));
1298
+ if (!outcome.ok) throw new Error(`Model exposure configuration error: ${outcome.message}`);
1299
+ return outcome;
1331
1300
  }
1332
- //#endregion
1333
- //#region src/server/routes.ts
1334
1301
  function createRoutes(config) {
1335
1302
  const routes = new Hono();
1336
1303
  routes.get("/v1/models", (c) => {
1337
- const models = getAvailableModels();
1338
- return c.json(buildModelList(models));
1304
+ const exposure = getExposure(config);
1305
+ return c.json(buildModelList(exposure.models));
1339
1306
  });
1340
1307
  routes.get("/v1/models/*", (c) => {
1341
1308
  const rawPath = c.req.path;
@@ -1343,12 +1310,9 @@ function createRoutes(config) {
1343
1310
  const modelIdEncoded = rawPath.slice(11);
1344
1311
  if (modelIdEncoded.length === 0) return c.json(modelNotFound(""), 404);
1345
1312
  const modelId = decodeURIComponent(modelIdEncoded);
1346
- const resolution = resolveModel(modelId);
1347
- if (!resolution.ok) {
1348
- if (resolution.status === 400) return c.json(invalidRequest(resolution.message, "model"), 400);
1349
- return c.json(modelNotFound(modelId), 404);
1350
- }
1351
- return c.json(toOpenAIModel(resolution.model));
1313
+ const resolved = resolveExposedModel(getExposure(config), modelId);
1314
+ if (resolved === void 0) return c.json(modelNotFound(modelId), 404);
1315
+ return c.json(toOpenAIModel(resolved));
1352
1316
  });
1353
1317
  routes.post("/v1/chat/completions", async (c) => {
1354
1318
  const requestId = c.get("requestId");
@@ -1366,13 +1330,10 @@ function createRoutes(config) {
1366
1330
  return c.json(invalidRequest(validation.message, validation.param ?? void 0), 400);
1367
1331
  }
1368
1332
  const request = validation.data;
1369
- const resolution = resolveModel(request.model);
1370
- if (!resolution.ok) {
1371
- if (resolution.status === 400) return c.json(invalidRequest(resolution.message, "model"), 400);
1372
- return c.json(modelNotFound(request.model), 404);
1373
- }
1374
- const model = resolution.model;
1375
- const canonicalModelId = `${model.provider}/${model.id}`;
1333
+ const resolved = resolveExposedModel(getExposure(config), request.model);
1334
+ if (resolved === void 0) return c.json(modelNotFound(request.model), 404);
1335
+ const model = resolved.model;
1336
+ const canonicalModelId = resolved.canonicalId;
1376
1337
  const conversion = convertMessages(request.messages);
1377
1338
  if (!conversion.ok) return c.json(invalidRequest(conversion.message, conversion.param), 400);
1378
1339
  const context = conversion.context;
@@ -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": "1.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",