@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 +89 -51
- package/dist/config.d.mts +27 -1
- package/dist/config.mjs +42 -2
- package/dist/exposure.d.mts +52 -0
- package/dist/exposure.mjs +192 -0
- package/dist/index.mjs +58 -97
- package/extensions/proxy.ts +339 -8
- package/package.json +5 -1
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**
|
|
9
|
-
- **Self-hosted**
|
|
10
|
-
- **Streaming**
|
|
11
|
-
- **Strict validation**
|
|
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
|
-
###
|
|
80
|
+
### Model resolution
|
|
81
81
|
|
|
82
|
-
|
|
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
|
|
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` |
|
|
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
|
-
| `
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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`**
|
|
275
|
-
- **`@mariozechner/pi-coding-agent`**
|
|
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**
|
|
300
|
-
- **tsdown**
|
|
301
|
-
- **Biome**
|
|
302
|
-
- **oxlint**
|
|
303
|
-
- **lefthook**
|
|
304
|
-
- **commitlint**
|
|
305
|
-
- **semantic-release**
|
|
306
|
-
- **mise**
|
|
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
|
|
474
|
+
* Convert an ExposedModel to an OpenAI model object.
|
|
478
475
|
*/
|
|
479
|
-
function toOpenAIModel(
|
|
476
|
+
function toOpenAIModel(exposed) {
|
|
480
477
|
return {
|
|
481
|
-
id:
|
|
478
|
+
id: exposed.publicId,
|
|
482
479
|
object: "model",
|
|
483
480
|
created: 0,
|
|
484
|
-
owned_by:
|
|
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
|
|
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
|
-
*
|
|
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/
|
|
1279
|
+
//#region src/server/routes.ts
|
|
1271
1280
|
/**
|
|
1272
|
-
*
|
|
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
|
|
1279
|
-
const slashIndex = input.indexOf("/");
|
|
1280
|
-
if (slashIndex === -1) return null;
|
|
1283
|
+
function buildExposureConfig(config) {
|
|
1281
1284
|
return {
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
*
|
|
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
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1292
|
-
|
|
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
|
|
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
|
|
1347
|
-
if (
|
|
1348
|
-
|
|
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
|
|
1370
|
-
if (
|
|
1371
|
-
|
|
1372
|
-
|
|
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;
|
package/extensions/proxy.ts
CHANGED
|
@@ -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 = [
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
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",
|