@victor-software-house/pi-openai-proxy 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -34
- package/dist/config.mjs +1 -1
- package/dist/exposure.d.mts +56 -0
- package/dist/exposure.mjs +193 -0
- package/dist/index.mjs +8 -191
- package/extensions/proxy.ts +366 -48
- 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
|
|
|
@@ -81,8 +81,8 @@ OPENAI_API_BASE=http://localhost:4141/v1 aider --model anthropic/claude-sonnet-4
|
|
|
81
81
|
|
|
82
82
|
The proxy resolves model references in this order:
|
|
83
83
|
|
|
84
|
-
1. **Exact public ID match**
|
|
85
|
-
2. **Canonical ID fallback**
|
|
84
|
+
1. **Exact public ID match** — the ID from `GET /v1/models`
|
|
85
|
+
2. **Canonical ID fallback** — `provider/model-id` format (only for exposed models)
|
|
86
86
|
|
|
87
87
|
With the default `collision-prefixed` mode and no collisions, model IDs are exposed without prefixes:
|
|
88
88
|
|
|
@@ -109,7 +109,7 @@ curl http://localhost:4141/v1/chat/completions \
|
|
|
109
109
|
| `messages` (base64 images) | Base64 data URI image content parts (`image/png`, `image/jpeg`, `image/gif`, `image/webp`) |
|
|
110
110
|
| `stream` | SSE with `text_delta` and `toolcall_delta` mapping |
|
|
111
111
|
| `temperature` | Direct passthrough |
|
|
112
|
-
| `
|
|
112
|
+
| `max_completion_tokens` | Preferred; `max_tokens` accepted as deprecated fallback |
|
|
113
113
|
| `stop` | Via passthrough |
|
|
114
114
|
| `user` | Via passthrough |
|
|
115
115
|
| `stream_options.include_usage` | Final usage chunk in SSE stream |
|
|
@@ -284,29 +284,33 @@ The extension detects externally running instances and shows their status via `/
|
|
|
284
284
|
|
|
285
285
|
## Architecture
|
|
286
286
|
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
287
|
+
```text
|
|
288
|
+
┌─────────────────────────────┐ ┌──────────────────────────────────┐
|
|
289
|
+
│ HTTP Client │ │ pi-openai-proxy │
|
|
290
|
+
│ (curl, Aider, Continue, │ │ │
|
|
291
|
+
│ LiteLLM, Open WebUI, etc.)│ │ ┌────────────────────────────┐ │
|
|
292
|
+
└─────────────┬───────────────┘ │ │ Hono HTTP Server │ │
|
|
293
|
+
│ │ │ ├─ Request parser │ │
|
|
294
|
+
│ POST /v1/chat/ │ │ ├─ Message converter │ │
|
|
295
|
+
│ completions │ │ ├─ Model resolver │ │
|
|
296
|
+
├────────────────────────►│ │ ├─ Tool converter │ │
|
|
297
|
+
│ │ │ └─ SSE encoder │ │
|
|
298
|
+
│ GET /v1/models │ └────────────────────────────┘ │
|
|
299
|
+
├────────────────────────►│ │
|
|
300
|
+
│ │ ┌────────────────────────────┐ │
|
|
301
|
+
│ │ │ Pi SDK │ │
|
|
302
|
+
│ SSE / JSON │ │ ├─ ModelRegistry │ │
|
|
303
|
+
│◄────────────────────────┤ │ ├─ AuthStorage │ │
|
|
304
|
+
│ │ │ ├─ streamSimple() │ │
|
|
305
|
+
│ │ └─ completeSimple() │ │
|
|
306
|
+
│ └────────────────────────────┘ │
|
|
307
|
+
└──────────────────────────────────┘
|
|
304
308
|
```
|
|
305
309
|
|
|
306
310
|
### Pi SDK layers used
|
|
307
311
|
|
|
308
|
-
- **`@mariozechner/pi-ai`**
|
|
309
|
-
- **`@mariozechner/pi-coding-agent`**
|
|
312
|
+
- **`@mariozechner/pi-ai`** — `streamSimple()`, `completeSimple()`, `Model`, `Usage`, `AssistantMessageEvent`
|
|
313
|
+
- **`@mariozechner/pi-coding-agent`** — `ModelRegistry`, `AuthStorage`
|
|
310
314
|
|
|
311
315
|
## Security defaults
|
|
312
316
|
|
|
@@ -330,14 +334,14 @@ bun test # Run all tests
|
|
|
330
334
|
|
|
331
335
|
### Tooling
|
|
332
336
|
|
|
333
|
-
- **Bun**
|
|
334
|
-
- **tsdown**
|
|
335
|
-
- **Biome**
|
|
336
|
-
- **oxlint**
|
|
337
|
-
- **lefthook**
|
|
338
|
-
- **commitlint**
|
|
339
|
-
- **semantic-release**
|
|
340
|
-
- **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)
|
|
341
345
|
|
|
342
346
|
## License
|
|
343
347
|
|
package/dist/config.mjs
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
* @param available - Models with auth configured (pi's getAvailable())
|
|
41
|
+
* @param allRegistered - All registered models regardless of auth (pi's getAll())
|
|
42
|
+
* @param config - Model exposure configuration
|
|
43
|
+
*
|
|
44
|
+
* Call this at startup and whenever config or the model registry changes.
|
|
45
|
+
*/
|
|
46
|
+
declare function computeModelExposure(available: readonly Model<Api>[], allRegistered: readonly Model<Api>[], config: ModelExposureConfig): ModelExposureOutcome;
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a model ID from an incoming request against the exposure result.
|
|
49
|
+
*
|
|
50
|
+
* Resolution order:
|
|
51
|
+
* 1. Exact public ID match
|
|
52
|
+
* 2. Exact canonical ID match (backward compat, only if model is exposed)
|
|
53
|
+
*/
|
|
54
|
+
declare function resolveExposedModel(exposure: ModelExposureResult, requestModelId: string): ExposedModel | undefined;
|
|
55
|
+
//#endregion
|
|
56
|
+
export { ExposedModel, ModelExposureConfig, ModelExposureError, ModelExposureOutcome, ModelExposureResult, computeModelExposure, resolveExposedModel };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
//#region src/openai/model-exposure.ts
|
|
3
|
+
function filterExposedModels(available, allRegistered, config) {
|
|
4
|
+
switch (config.modelExposureMode) {
|
|
5
|
+
case "scoped": return [...available];
|
|
6
|
+
case "all": return [...allRegistered];
|
|
7
|
+
case "custom": {
|
|
8
|
+
const allowed = new Set(config.customModels);
|
|
9
|
+
return available.filter((m) => allowed.has(`${m.provider}/${m.id}`));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Get the public prefix label for a provider.
|
|
15
|
+
* Uses the configured override if present, otherwise the provider key itself.
|
|
16
|
+
*/
|
|
17
|
+
function getPublicPrefix(provider, prefixes) {
|
|
18
|
+
const override = prefixes[provider];
|
|
19
|
+
return override !== void 0 && override.length > 0 ? override : provider;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Find connected conflict groups: sets of providers that share at least one raw model ID.
|
|
23
|
+
*
|
|
24
|
+
* If provider A and B share a model ID, and B and C share a different model ID,
|
|
25
|
+
* then {A, B, C} form one connected conflict group.
|
|
26
|
+
*/
|
|
27
|
+
function findConflictGroups(models) {
|
|
28
|
+
const modelToProviders = /* @__PURE__ */ new Map();
|
|
29
|
+
for (const m of models) {
|
|
30
|
+
const existing = modelToProviders.get(m.id);
|
|
31
|
+
if (existing !== void 0) existing.add(m.provider);
|
|
32
|
+
else modelToProviders.set(m.id, new Set([m.provider]));
|
|
33
|
+
}
|
|
34
|
+
const parent = /* @__PURE__ */ new Map();
|
|
35
|
+
function find(x) {
|
|
36
|
+
let root = x;
|
|
37
|
+
while (parent.get(root) !== root) {
|
|
38
|
+
const p = parent.get(root);
|
|
39
|
+
if (p === void 0) break;
|
|
40
|
+
root = p;
|
|
41
|
+
}
|
|
42
|
+
let current = x;
|
|
43
|
+
while (current !== root) {
|
|
44
|
+
const next = parent.get(current);
|
|
45
|
+
if (next === void 0) break;
|
|
46
|
+
parent.set(current, root);
|
|
47
|
+
current = next;
|
|
48
|
+
}
|
|
49
|
+
return root;
|
|
50
|
+
}
|
|
51
|
+
function union(a, b) {
|
|
52
|
+
const ra = find(a);
|
|
53
|
+
const rb = find(b);
|
|
54
|
+
if (ra !== rb) parent.set(ra, rb);
|
|
55
|
+
}
|
|
56
|
+
for (const m of models) if (!parent.has(m.provider)) parent.set(m.provider, m.provider);
|
|
57
|
+
for (const providers of modelToProviders.values()) {
|
|
58
|
+
if (providers.size <= 1) continue;
|
|
59
|
+
const arr = [...providers];
|
|
60
|
+
const first = arr[0];
|
|
61
|
+
if (first === void 0) continue;
|
|
62
|
+
for (let i = 1; i < arr.length; i++) {
|
|
63
|
+
const other = arr[i];
|
|
64
|
+
if (other !== void 0) union(first, other);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const groupsByRoot = /* @__PURE__ */ new Map();
|
|
68
|
+
for (const provider of parent.keys()) {
|
|
69
|
+
const root = find(provider);
|
|
70
|
+
const existing = groupsByRoot.get(root);
|
|
71
|
+
if (existing !== void 0) existing.add(provider);
|
|
72
|
+
else groupsByRoot.set(root, new Set([provider]));
|
|
73
|
+
}
|
|
74
|
+
return [...groupsByRoot.values()].filter((g) => g.size > 1);
|
|
75
|
+
}
|
|
76
|
+
function generateCollisionPrefixedIds(models, prefixes) {
|
|
77
|
+
const conflictGroups = findConflictGroups(models);
|
|
78
|
+
const prefixedProviders = /* @__PURE__ */ new Set();
|
|
79
|
+
for (const group of conflictGroups) for (const provider of group) prefixedProviders.add(provider);
|
|
80
|
+
const result = /* @__PURE__ */ new Map();
|
|
81
|
+
for (const m of models) if (prefixedProviders.has(m.provider)) {
|
|
82
|
+
const prefix = getPublicPrefix(m.provider, prefixes);
|
|
83
|
+
result.set(m, `${prefix}/${m.id}`);
|
|
84
|
+
} else result.set(m, m.id);
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
function generateUniversalIds(models) {
|
|
88
|
+
const seen = /* @__PURE__ */ new Map();
|
|
89
|
+
for (const m of models) {
|
|
90
|
+
const existing = seen.get(m.id);
|
|
91
|
+
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.`;
|
|
92
|
+
seen.set(m.id, m.provider);
|
|
93
|
+
}
|
|
94
|
+
const result = /* @__PURE__ */ new Map();
|
|
95
|
+
for (const m of models) result.set(m, m.id);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
function generateAlwaysPrefixedIds(models, prefixes) {
|
|
99
|
+
const result = /* @__PURE__ */ new Map();
|
|
100
|
+
for (const m of models) {
|
|
101
|
+
const prefix = getPublicPrefix(m.provider, prefixes);
|
|
102
|
+
result.set(m, `${prefix}/${m.id}`);
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
function validatePrefixUniqueness(models, prefixes, mode) {
|
|
107
|
+
if (mode === "universal") return void 0;
|
|
108
|
+
const providers = /* @__PURE__ */ new Set();
|
|
109
|
+
if (mode === "always-prefixed") for (const m of models) providers.add(m.provider);
|
|
110
|
+
else {
|
|
111
|
+
const conflictGroups = findConflictGroups(models);
|
|
112
|
+
for (const group of conflictGroups) for (const provider of group) providers.add(provider);
|
|
113
|
+
}
|
|
114
|
+
const labelToProvider = /* @__PURE__ */ new Map();
|
|
115
|
+
for (const provider of providers) {
|
|
116
|
+
const label = getPublicPrefix(provider, prefixes);
|
|
117
|
+
const existing = labelToProvider.get(label);
|
|
118
|
+
if (existing !== void 0) return `Duplicate prefix label '${label}' used by providers '${existing}' and '${provider}'. Configure distinct providerPrefixes.`;
|
|
119
|
+
labelToProvider.set(label, provider);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Compute the full model-exposure result from config and available models.
|
|
124
|
+
*
|
|
125
|
+
* @param available - Models with auth configured (pi's getAvailable())
|
|
126
|
+
* @param allRegistered - All registered models regardless of auth (pi's getAll())
|
|
127
|
+
* @param config - Model exposure configuration
|
|
128
|
+
*
|
|
129
|
+
* Call this at startup and whenever config or the model registry changes.
|
|
130
|
+
*/
|
|
131
|
+
function computeModelExposure(available, allRegistered, config) {
|
|
132
|
+
const exposed = filterExposedModels(available, allRegistered, config);
|
|
133
|
+
const prefixError = validatePrefixUniqueness(exposed, config.providerPrefixes, config.publicModelIdMode);
|
|
134
|
+
if (prefixError !== void 0) return {
|
|
135
|
+
ok: false,
|
|
136
|
+
message: prefixError
|
|
137
|
+
};
|
|
138
|
+
let idMap;
|
|
139
|
+
switch (config.publicModelIdMode) {
|
|
140
|
+
case "collision-prefixed":
|
|
141
|
+
idMap = generateCollisionPrefixedIds(exposed, config.providerPrefixes);
|
|
142
|
+
break;
|
|
143
|
+
case "universal": {
|
|
144
|
+
const result = generateUniversalIds(exposed);
|
|
145
|
+
if (typeof result === "string") return {
|
|
146
|
+
ok: false,
|
|
147
|
+
message: result
|
|
148
|
+
};
|
|
149
|
+
idMap = result;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "always-prefixed":
|
|
153
|
+
idMap = generateAlwaysPrefixedIds(exposed, config.providerPrefixes);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
const models = [];
|
|
157
|
+
const byPublicId = /* @__PURE__ */ new Map();
|
|
158
|
+
const byCanonicalId = /* @__PURE__ */ new Map();
|
|
159
|
+
for (const m of exposed) {
|
|
160
|
+
const publicId = idMap.get(m);
|
|
161
|
+
if (publicId === void 0) continue;
|
|
162
|
+
const canonicalId = `${m.provider}/${m.id}`;
|
|
163
|
+
const entry = {
|
|
164
|
+
publicId,
|
|
165
|
+
canonicalId,
|
|
166
|
+
model: m,
|
|
167
|
+
provider: m.provider
|
|
168
|
+
};
|
|
169
|
+
models.push(entry);
|
|
170
|
+
byPublicId.set(publicId, entry);
|
|
171
|
+
byCanonicalId.set(canonicalId, entry);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
models,
|
|
176
|
+
byPublicId,
|
|
177
|
+
byCanonicalId
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Resolve a model ID from an incoming request against the exposure result.
|
|
182
|
+
*
|
|
183
|
+
* Resolution order:
|
|
184
|
+
* 1. Exact public ID match
|
|
185
|
+
* 2. Exact canonical ID match (backward compat, only if model is exposed)
|
|
186
|
+
*/
|
|
187
|
+
function resolveExposedModel(exposure, requestModelId) {
|
|
188
|
+
const byPublic = exposure.byPublicId.get(requestModelId);
|
|
189
|
+
if (byPublic !== void 0) return byPublic;
|
|
190
|
+
return exposure.byCanonicalId.get(requestModelId);
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
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";
|
|
@@ -65,6 +66,12 @@ function getRegistry() {
|
|
|
65
66
|
function getAvailableModels() {
|
|
66
67
|
return getRegistry().getAvailable();
|
|
67
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Get all registered models (regardless of auth state).
|
|
71
|
+
*/
|
|
72
|
+
function getAllModels() {
|
|
73
|
+
return getRegistry().getAll();
|
|
74
|
+
}
|
|
68
75
|
//#endregion
|
|
69
76
|
//#region src/server/errors.ts
|
|
70
77
|
function makeError(message, type, param, code) {
|
|
@@ -468,196 +475,6 @@ function parseAndValidateDataUri(uri) {
|
|
|
468
475
|
};
|
|
469
476
|
}
|
|
470
477
|
//#endregion
|
|
471
|
-
//#region src/openai/model-exposure.ts
|
|
472
|
-
function filterExposedModels(available, config) {
|
|
473
|
-
switch (config.modelExposureMode) {
|
|
474
|
-
case "all": return [...available];
|
|
475
|
-
case "scoped": {
|
|
476
|
-
const providers = new Set(config.scopedProviders);
|
|
477
|
-
return available.filter((m) => providers.has(m.provider));
|
|
478
|
-
}
|
|
479
|
-
case "custom": {
|
|
480
|
-
const allowed = new Set(config.customModels);
|
|
481
|
-
return available.filter((m) => allowed.has(`${m.provider}/${m.id}`));
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
/**
|
|
486
|
-
* Get the public prefix label for a provider.
|
|
487
|
-
* Uses the configured override if present, otherwise the provider key itself.
|
|
488
|
-
*/
|
|
489
|
-
function getPublicPrefix(provider, prefixes) {
|
|
490
|
-
const override = prefixes[provider];
|
|
491
|
-
return override !== void 0 && override.length > 0 ? override : provider;
|
|
492
|
-
}
|
|
493
|
-
/**
|
|
494
|
-
* Find connected conflict groups: sets of providers that share at least one raw model ID.
|
|
495
|
-
*
|
|
496
|
-
* If provider A and B share a model ID, and B and C share a different model ID,
|
|
497
|
-
* then {A, B, C} form one connected conflict group.
|
|
498
|
-
*/
|
|
499
|
-
function findConflictGroups(models) {
|
|
500
|
-
const modelToProviders = /* @__PURE__ */ new Map();
|
|
501
|
-
for (const m of models) {
|
|
502
|
-
const existing = modelToProviders.get(m.id);
|
|
503
|
-
if (existing !== void 0) existing.add(m.provider);
|
|
504
|
-
else modelToProviders.set(m.id, new Set([m.provider]));
|
|
505
|
-
}
|
|
506
|
-
const parent = /* @__PURE__ */ new Map();
|
|
507
|
-
function find(x) {
|
|
508
|
-
let root = x;
|
|
509
|
-
while (parent.get(root) !== root) {
|
|
510
|
-
const p = parent.get(root);
|
|
511
|
-
if (p === void 0) break;
|
|
512
|
-
root = p;
|
|
513
|
-
}
|
|
514
|
-
let current = x;
|
|
515
|
-
while (current !== root) {
|
|
516
|
-
const next = parent.get(current);
|
|
517
|
-
if (next === void 0) break;
|
|
518
|
-
parent.set(current, root);
|
|
519
|
-
current = next;
|
|
520
|
-
}
|
|
521
|
-
return root;
|
|
522
|
-
}
|
|
523
|
-
function union(a, b) {
|
|
524
|
-
const ra = find(a);
|
|
525
|
-
const rb = find(b);
|
|
526
|
-
if (ra !== rb) parent.set(ra, rb);
|
|
527
|
-
}
|
|
528
|
-
for (const m of models) if (!parent.has(m.provider)) parent.set(m.provider, m.provider);
|
|
529
|
-
for (const providers of modelToProviders.values()) {
|
|
530
|
-
if (providers.size <= 1) continue;
|
|
531
|
-
const arr = [...providers];
|
|
532
|
-
const first = arr[0];
|
|
533
|
-
if (first === void 0) continue;
|
|
534
|
-
for (let i = 1; i < arr.length; i++) {
|
|
535
|
-
const other = arr[i];
|
|
536
|
-
if (other !== void 0) union(first, other);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
const groupsByRoot = /* @__PURE__ */ new Map();
|
|
540
|
-
for (const provider of parent.keys()) {
|
|
541
|
-
const root = find(provider);
|
|
542
|
-
const existing = groupsByRoot.get(root);
|
|
543
|
-
if (existing !== void 0) existing.add(provider);
|
|
544
|
-
else groupsByRoot.set(root, new Set([provider]));
|
|
545
|
-
}
|
|
546
|
-
return [...groupsByRoot.values()].filter((g) => g.size > 1);
|
|
547
|
-
}
|
|
548
|
-
function generateCollisionPrefixedIds(models, prefixes) {
|
|
549
|
-
const conflictGroups = findConflictGroups(models);
|
|
550
|
-
const prefixedProviders = /* @__PURE__ */ new Set();
|
|
551
|
-
for (const group of conflictGroups) for (const provider of group) prefixedProviders.add(provider);
|
|
552
|
-
const result = /* @__PURE__ */ new Map();
|
|
553
|
-
for (const m of models) if (prefixedProviders.has(m.provider)) {
|
|
554
|
-
const prefix = getPublicPrefix(m.provider, prefixes);
|
|
555
|
-
result.set(m, `${prefix}/${m.id}`);
|
|
556
|
-
} else result.set(m, m.id);
|
|
557
|
-
return result;
|
|
558
|
-
}
|
|
559
|
-
function generateUniversalIds(models) {
|
|
560
|
-
const seen = /* @__PURE__ */ new Map();
|
|
561
|
-
for (const m of models) {
|
|
562
|
-
const existing = seen.get(m.id);
|
|
563
|
-
if (existing !== void 0) return `Universal mode conflict: model ID '${m.id}' is provided by both '${existing}' and '${m.provider}'. Use 'collision-prefixed' or 'always-prefixed' mode, or reduce the exposed model set.`;
|
|
564
|
-
seen.set(m.id, m.provider);
|
|
565
|
-
}
|
|
566
|
-
const result = /* @__PURE__ */ new Map();
|
|
567
|
-
for (const m of models) result.set(m, m.id);
|
|
568
|
-
return result;
|
|
569
|
-
}
|
|
570
|
-
function generateAlwaysPrefixedIds(models, prefixes) {
|
|
571
|
-
const result = /* @__PURE__ */ new Map();
|
|
572
|
-
for (const m of models) {
|
|
573
|
-
const prefix = getPublicPrefix(m.provider, prefixes);
|
|
574
|
-
result.set(m, `${prefix}/${m.id}`);
|
|
575
|
-
}
|
|
576
|
-
return result;
|
|
577
|
-
}
|
|
578
|
-
function validatePrefixUniqueness(models, prefixes, mode) {
|
|
579
|
-
if (mode === "universal") return void 0;
|
|
580
|
-
const providers = /* @__PURE__ */ new Set();
|
|
581
|
-
if (mode === "always-prefixed") for (const m of models) providers.add(m.provider);
|
|
582
|
-
else {
|
|
583
|
-
const conflictGroups = findConflictGroups(models);
|
|
584
|
-
for (const group of conflictGroups) for (const provider of group) providers.add(provider);
|
|
585
|
-
}
|
|
586
|
-
const labelToProvider = /* @__PURE__ */ new Map();
|
|
587
|
-
for (const provider of providers) {
|
|
588
|
-
const label = getPublicPrefix(provider, prefixes);
|
|
589
|
-
const existing = labelToProvider.get(label);
|
|
590
|
-
if (existing !== void 0) return `Duplicate prefix label '${label}' used by providers '${existing}' and '${provider}'. Configure distinct providerPrefixes.`;
|
|
591
|
-
labelToProvider.set(label, provider);
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
/**
|
|
595
|
-
* Compute the full model-exposure result from config and available models.
|
|
596
|
-
*
|
|
597
|
-
* Call this at startup and whenever config or the model registry changes.
|
|
598
|
-
*/
|
|
599
|
-
function computeModelExposure(available, config) {
|
|
600
|
-
const exposed = filterExposedModels(available, config);
|
|
601
|
-
const prefixError = validatePrefixUniqueness(exposed, config.providerPrefixes, config.publicModelIdMode);
|
|
602
|
-
if (prefixError !== void 0) return {
|
|
603
|
-
ok: false,
|
|
604
|
-
message: prefixError
|
|
605
|
-
};
|
|
606
|
-
let idMap;
|
|
607
|
-
switch (config.publicModelIdMode) {
|
|
608
|
-
case "collision-prefixed":
|
|
609
|
-
idMap = generateCollisionPrefixedIds(exposed, config.providerPrefixes);
|
|
610
|
-
break;
|
|
611
|
-
case "universal": {
|
|
612
|
-
const result = generateUniversalIds(exposed);
|
|
613
|
-
if (typeof result === "string") return {
|
|
614
|
-
ok: false,
|
|
615
|
-
message: result
|
|
616
|
-
};
|
|
617
|
-
idMap = result;
|
|
618
|
-
break;
|
|
619
|
-
}
|
|
620
|
-
case "always-prefixed":
|
|
621
|
-
idMap = generateAlwaysPrefixedIds(exposed, config.providerPrefixes);
|
|
622
|
-
break;
|
|
623
|
-
}
|
|
624
|
-
const models = [];
|
|
625
|
-
const byPublicId = /* @__PURE__ */ new Map();
|
|
626
|
-
const byCanonicalId = /* @__PURE__ */ new Map();
|
|
627
|
-
for (const m of exposed) {
|
|
628
|
-
const publicId = idMap.get(m);
|
|
629
|
-
if (publicId === void 0) continue;
|
|
630
|
-
const canonicalId = `${m.provider}/${m.id}`;
|
|
631
|
-
const entry = {
|
|
632
|
-
publicId,
|
|
633
|
-
canonicalId,
|
|
634
|
-
model: m,
|
|
635
|
-
provider: m.provider
|
|
636
|
-
};
|
|
637
|
-
models.push(entry);
|
|
638
|
-
byPublicId.set(publicId, entry);
|
|
639
|
-
byCanonicalId.set(canonicalId, entry);
|
|
640
|
-
}
|
|
641
|
-
return {
|
|
642
|
-
ok: true,
|
|
643
|
-
models,
|
|
644
|
-
byPublicId,
|
|
645
|
-
byCanonicalId
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
/**
|
|
649
|
-
* Resolve a model ID from an incoming request against the exposure result.
|
|
650
|
-
*
|
|
651
|
-
* Resolution order:
|
|
652
|
-
* 1. Exact public ID match
|
|
653
|
-
* 2. Exact canonical ID match (backward compat, only if model is exposed)
|
|
654
|
-
*/
|
|
655
|
-
function resolveExposedModel(exposure, requestModelId) {
|
|
656
|
-
const byPublic = exposure.byPublicId.get(requestModelId);
|
|
657
|
-
if (byPublic !== void 0) return byPublic;
|
|
658
|
-
return exposure.byCanonicalId.get(requestModelId);
|
|
659
|
-
}
|
|
660
|
-
//#endregion
|
|
661
478
|
//#region src/openai/models.ts
|
|
662
479
|
/**
|
|
663
480
|
* Convert an ExposedModel to an OpenAI model object.
|
|
@@ -1483,7 +1300,7 @@ function buildExposureConfig(config) {
|
|
|
1483
1300
|
* Returns the exposure result or throws on config errors.
|
|
1484
1301
|
*/
|
|
1485
1302
|
function getExposure(config) {
|
|
1486
|
-
const outcome = computeModelExposure(getAvailableModels(), buildExposureConfig(config));
|
|
1303
|
+
const outcome = computeModelExposure(getAvailableModels(), getAllModels(), buildExposureConfig(config));
|
|
1487
1304
|
if (!outcome.ok) throw new Error(`Model exposure configuration error: ${outcome.message}`);
|
|
1488
1305
|
return outcome;
|
|
1489
1306
|
}
|
package/extensions/proxy.ts
CHANGED
|
@@ -6,26 +6,37 @@
|
|
|
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
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
type Component,
|
|
35
|
+
Container,
|
|
36
|
+
type SettingItem,
|
|
37
|
+
SettingsList,
|
|
38
|
+
Text,
|
|
39
|
+
} from "@mariozechner/pi-tui";
|
|
29
40
|
|
|
30
41
|
// Config schema -- single source of truth
|
|
31
42
|
import {
|
|
@@ -33,9 +44,17 @@ import {
|
|
|
33
44
|
DEFAULT_CONFIG,
|
|
34
45
|
getConfigPath,
|
|
35
46
|
loadConfigFromFile,
|
|
47
|
+
type ModelExposureMode,
|
|
48
|
+
type PublicModelIdMode,
|
|
36
49
|
saveConfigToFile,
|
|
37
50
|
} from "@victor-software-house/pi-openai-proxy/config";
|
|
38
51
|
|
|
52
|
+
// Model-exposure engine
|
|
53
|
+
import {
|
|
54
|
+
computeModelExposure,
|
|
55
|
+
type ModelExposureConfig,
|
|
56
|
+
} from "@victor-software-house/pi-openai-proxy/exposure";
|
|
57
|
+
|
|
39
58
|
// ---------------------------------------------------------------------------
|
|
40
59
|
// Runtime status
|
|
41
60
|
// ---------------------------------------------------------------------------
|
|
@@ -56,6 +75,32 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
56
75
|
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
57
76
|
const packageRoot = resolve(extensionDir, "..");
|
|
58
77
|
|
|
78
|
+
// --- Model registry access (for verify/show/selectors) ---
|
|
79
|
+
|
|
80
|
+
function getAvailableModels(): Model<Api>[] {
|
|
81
|
+
const auth = AuthStorage.create();
|
|
82
|
+
const registry = new ModelRegistry(auth);
|
|
83
|
+
return registry.getAvailable();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getUniqueProviders(models: readonly Model<Api>[]): string[] {
|
|
87
|
+
const seen = new Set<string>();
|
|
88
|
+
for (const m of models) {
|
|
89
|
+
seen.add(m.provider);
|
|
90
|
+
}
|
|
91
|
+
return [...seen].sort();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildExposureConfig(): ModelExposureConfig {
|
|
95
|
+
return {
|
|
96
|
+
publicModelIdMode: config.publicModelIdMode,
|
|
97
|
+
modelExposureMode: config.modelExposureMode,
|
|
98
|
+
scopedProviders: config.scopedProviders,
|
|
99
|
+
customModels: config.customModels,
|
|
100
|
+
providerPrefixes: config.providerPrefixes,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
59
104
|
// Resolve pi-proxy binary: try workspace node_modules, then global
|
|
60
105
|
function findProxyBinary(): string {
|
|
61
106
|
// In workspace: node_modules/pi-proxy/dist/index.mjs
|
|
@@ -85,8 +130,18 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
85
130
|
|
|
86
131
|
// --- Command family ---
|
|
87
132
|
|
|
88
|
-
const SUBCOMMANDS = [
|
|
89
|
-
|
|
133
|
+
const SUBCOMMANDS = [
|
|
134
|
+
"start",
|
|
135
|
+
"stop",
|
|
136
|
+
"status",
|
|
137
|
+
"verify",
|
|
138
|
+
"config",
|
|
139
|
+
"show",
|
|
140
|
+
"path",
|
|
141
|
+
"reset",
|
|
142
|
+
"help",
|
|
143
|
+
];
|
|
144
|
+
const USAGE = "/proxy [start|stop|status|verify|config|show|path|reset|help]";
|
|
90
145
|
|
|
91
146
|
pi.registerCommand("proxy", {
|
|
92
147
|
description: "Manage the OpenAI-compatible proxy",
|
|
@@ -108,8 +163,11 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
108
163
|
case "status":
|
|
109
164
|
await showStatus(ctx);
|
|
110
165
|
return;
|
|
166
|
+
case "verify":
|
|
167
|
+
verifyExposure(ctx);
|
|
168
|
+
return;
|
|
111
169
|
case "show":
|
|
112
|
-
|
|
170
|
+
showConfig(ctx);
|
|
113
171
|
return;
|
|
114
172
|
case "path":
|
|
115
173
|
ctx.ui.notify(getConfigPath(), "info");
|
|
@@ -358,11 +416,13 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
358
416
|
await refreshStatus(ctx);
|
|
359
417
|
}
|
|
360
418
|
|
|
361
|
-
|
|
419
|
+
function showConfig(ctx: ExtensionContext): void {
|
|
362
420
|
config = loadConfigFromFile();
|
|
363
421
|
const authDisplay =
|
|
364
422
|
config.authToken.length > 0 ? `enabled (token: ${config.authToken})` : "disabled";
|
|
365
|
-
|
|
423
|
+
|
|
424
|
+
// Server settings
|
|
425
|
+
const serverLines = [
|
|
366
426
|
`lifetime: ${config.lifetime}`,
|
|
367
427
|
`host: ${config.host}`,
|
|
368
428
|
`port: ${String(config.port)}`,
|
|
@@ -371,8 +431,96 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
371
431
|
`max body: ${String(config.maxBodySizeMb)} MB`,
|
|
372
432
|
`timeout: ${String(config.upstreamTimeoutSec)}s`,
|
|
373
433
|
];
|
|
374
|
-
|
|
375
|
-
|
|
434
|
+
|
|
435
|
+
// Exposure policy
|
|
436
|
+
const exposureLines = [
|
|
437
|
+
`id mode: ${config.publicModelIdMode}`,
|
|
438
|
+
`exposure: ${config.modelExposureMode}`,
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
if (config.modelExposureMode === "scoped" && config.scopedProviders.length > 0) {
|
|
442
|
+
exposureLines.push(`providers: ${config.scopedProviders.join(", ")}`);
|
|
443
|
+
}
|
|
444
|
+
if (config.modelExposureMode === "custom" && config.customModels.length > 0) {
|
|
445
|
+
exposureLines.push(`models: ${String(config.customModels.length)} custom`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const prefixKeys = Object.keys(config.providerPrefixes);
|
|
449
|
+
if (prefixKeys.length > 0) {
|
|
450
|
+
const pairs = prefixKeys.map((k) => `${k}=${config.providerPrefixes[k] ?? k}`);
|
|
451
|
+
exposureLines.push(`prefixes: ${pairs.join(", ")}`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Public ID preview (first 5 exposed models)
|
|
455
|
+
const models = getAvailableModels();
|
|
456
|
+
const outcome = computeModelExposure(models, buildExposureConfig());
|
|
457
|
+
if (outcome.ok && outcome.models.length > 0) {
|
|
458
|
+
const preview = outcome.models.slice(0, 5).map((m) => m.publicId);
|
|
459
|
+
const suffix =
|
|
460
|
+
outcome.models.length > 5 ? ` (+${String(outcome.models.length - 5)} more)` : "";
|
|
461
|
+
exposureLines.push(`exposed: ${preview.join(", ")}${suffix}`);
|
|
462
|
+
} else if (outcome.ok) {
|
|
463
|
+
exposureLines.push("exposed: none");
|
|
464
|
+
} else {
|
|
465
|
+
exposureLines.push(`error: ${outcome.message}`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
ctx.ui.notify(`${serverLines.join(" | ")}\n${exposureLines.join(" | ")}`, "info");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// --- /proxy verify ---
|
|
472
|
+
|
|
473
|
+
function verifyExposure(ctx: ExtensionContext): void {
|
|
474
|
+
config = loadConfigFromFile();
|
|
475
|
+
const models = getAvailableModels();
|
|
476
|
+
const issues: string[] = [];
|
|
477
|
+
|
|
478
|
+
// Check available models
|
|
479
|
+
if (models.length === 0) {
|
|
480
|
+
issues.push("No models have auth configured. The proxy will expose 0 models.");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Check scoped providers reference valid providers
|
|
484
|
+
if (config.modelExposureMode === "scoped") {
|
|
485
|
+
const availableProviders = new Set(getUniqueProviders(models));
|
|
486
|
+
for (const p of config.scopedProviders) {
|
|
487
|
+
if (!availableProviders.has(p)) {
|
|
488
|
+
issues.push(`Scoped provider '${p}' has no available models (no auth or unknown).`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (config.scopedProviders.length === 0) {
|
|
492
|
+
issues.push("Scoped mode with empty provider list will expose 0 models.");
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Check custom models reference valid canonical IDs
|
|
497
|
+
if (config.modelExposureMode === "custom") {
|
|
498
|
+
const canonicalSet = new Set(models.map((m) => `${m.provider}/${m.id}`));
|
|
499
|
+
for (const id of config.customModels) {
|
|
500
|
+
if (!canonicalSet.has(id)) {
|
|
501
|
+
issues.push(`Custom model '${id}' is not available (no auth or unknown).`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (config.customModels.length === 0) {
|
|
505
|
+
issues.push("Custom mode with empty model list will expose 0 models.");
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Run the full exposure computation to catch ID/prefix errors
|
|
510
|
+
const outcome = computeModelExposure(models, buildExposureConfig());
|
|
511
|
+
if (!outcome.ok) {
|
|
512
|
+
issues.push(outcome.message);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (issues.length === 0) {
|
|
516
|
+
const count = outcome.ok ? outcome.models.length : 0;
|
|
517
|
+
ctx.ui.notify(`Verification passed. ${String(count)} models exposed.`, "info");
|
|
518
|
+
} else {
|
|
519
|
+
ctx.ui.notify(
|
|
520
|
+
`Verification found ${String(issues.length)} issue(s):\n${issues.join("\n")}`,
|
|
521
|
+
"warning",
|
|
522
|
+
);
|
|
523
|
+
}
|
|
376
524
|
}
|
|
377
525
|
|
|
378
526
|
async function waitForReady(timeoutMs: number): Promise<RuntimeStatus> {
|
|
@@ -390,8 +538,94 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
390
538
|
|
|
391
539
|
let lastGeneratedToken = "";
|
|
392
540
|
|
|
541
|
+
function customModelsDisplay(): string {
|
|
542
|
+
if (config.modelExposureMode !== "custom") return "n/a";
|
|
543
|
+
return config.customModels.length > 0
|
|
544
|
+
? `${String(config.customModels.length)} selected`
|
|
545
|
+
: "(none)";
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Build a submenu Component for selecting custom models.
|
|
550
|
+
* Shows all available models as a toggleable checklist.
|
|
551
|
+
*/
|
|
552
|
+
function buildModelSelectorSubmenu(
|
|
553
|
+
_currentValue: string,
|
|
554
|
+
done: (selectedValue?: string) => void,
|
|
555
|
+
): Component {
|
|
556
|
+
const models = getAvailableModels();
|
|
557
|
+
const selected = new Set(config.customModels);
|
|
558
|
+
let selectedIndex = 0;
|
|
559
|
+
|
|
560
|
+
function toggle(canonicalId: string): void {
|
|
561
|
+
if (selected.has(canonicalId)) {
|
|
562
|
+
selected.delete(canonicalId);
|
|
563
|
+
} else {
|
|
564
|
+
selected.add(canonicalId);
|
|
565
|
+
}
|
|
566
|
+
config = { ...config, customModels: [...selected] };
|
|
567
|
+
saveConfigToFile(config);
|
|
568
|
+
config = loadConfigFromFile();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
render(width: number): string[] {
|
|
573
|
+
const lines: string[] = [];
|
|
574
|
+
lines.push(" Select models (Space: toggle, Esc: done)");
|
|
575
|
+
lines.push("");
|
|
576
|
+
|
|
577
|
+
if (models.length === 0) {
|
|
578
|
+
lines.push(" No models available (no auth configured)");
|
|
579
|
+
return lines;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const maxVisible = 15;
|
|
583
|
+
const start = Math.max(
|
|
584
|
+
0,
|
|
585
|
+
Math.min(selectedIndex - Math.floor(maxVisible / 2), models.length - maxVisible),
|
|
586
|
+
);
|
|
587
|
+
const end = Math.min(start + maxVisible, models.length);
|
|
588
|
+
|
|
589
|
+
for (let i = start; i < end; i++) {
|
|
590
|
+
const m = models[i];
|
|
591
|
+
if (m === undefined) continue;
|
|
592
|
+
const canonical = `${m.provider}/${m.id}`;
|
|
593
|
+
const check = selected.has(canonical) ? "[x]" : "[ ]";
|
|
594
|
+
const cursor = i === selectedIndex ? "> " : " ";
|
|
595
|
+
const line = `${cursor}${check} ${canonical}`;
|
|
596
|
+
const truncated = line.length > width ? line.slice(0, width - 1) : line;
|
|
597
|
+
lines.push(truncated);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
lines.push("");
|
|
601
|
+
lines.push(` ${String(selected.size)} of ${String(models.length)} selected`);
|
|
602
|
+
return lines;
|
|
603
|
+
},
|
|
604
|
+
invalidate(): void {
|
|
605
|
+
// no-op
|
|
606
|
+
},
|
|
607
|
+
handleInput(data: string): void {
|
|
608
|
+
if (data === "\x1B" || data === "q") {
|
|
609
|
+
done(`${String(selected.size)} selected`);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (data === "\x1B[A" && selectedIndex > 0) {
|
|
613
|
+
selectedIndex--;
|
|
614
|
+
} else if (data === "\x1B[B" && selectedIndex < models.length - 1) {
|
|
615
|
+
selectedIndex++;
|
|
616
|
+
} else if (data === " " || data === "\r") {
|
|
617
|
+
const m = models[selectedIndex];
|
|
618
|
+
if (m !== undefined) {
|
|
619
|
+
toggle(`${m.provider}/${m.id}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
393
626
|
function buildSettingItems(): SettingItem[] {
|
|
394
627
|
return [
|
|
628
|
+
// --- Server ---
|
|
395
629
|
{
|
|
396
630
|
id: "lifetime",
|
|
397
631
|
label: "Lifetime",
|
|
@@ -444,9 +678,47 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
444
678
|
currentValue: `${String(config.upstreamTimeoutSec)}s`,
|
|
445
679
|
values: ["30s", "60s", "120s", "300s"],
|
|
446
680
|
},
|
|
681
|
+
// --- Model exposure ---
|
|
682
|
+
{
|
|
683
|
+
id: "publicModelIdMode",
|
|
684
|
+
label: "Public ID mode",
|
|
685
|
+
description: "How public model IDs are generated from canonical provider/model-id",
|
|
686
|
+
currentValue: config.publicModelIdMode,
|
|
687
|
+
values: ["collision-prefixed", "universal", "always-prefixed"],
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
id: "modelExposureMode",
|
|
691
|
+
label: "Exposure mode",
|
|
692
|
+
description:
|
|
693
|
+
"scoped = pi's available models, all = all registered, custom = manual selection",
|
|
694
|
+
currentValue: config.modelExposureMode,
|
|
695
|
+
values: ["scoped", "all", "custom"],
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
id: "customModels",
|
|
699
|
+
label: "Select models",
|
|
700
|
+
description:
|
|
701
|
+
config.modelExposureMode === "custom"
|
|
702
|
+
? "Press Enter to open model selector"
|
|
703
|
+
: "Switch exposure mode to 'custom' to select models",
|
|
704
|
+
currentValue: customModelsDisplay(),
|
|
705
|
+
submenu: config.modelExposureMode === "custom" ? buildModelSelectorSubmenu : undefined,
|
|
706
|
+
},
|
|
447
707
|
];
|
|
448
708
|
}
|
|
449
709
|
|
|
710
|
+
const VALID_ID_MODES = new Set<string>(["collision-prefixed", "universal", "always-prefixed"]);
|
|
711
|
+
|
|
712
|
+
function isPublicModelIdMode(v: string): v is PublicModelIdMode {
|
|
713
|
+
return VALID_ID_MODES.has(v);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const VALID_EXPOSURE_MODES = new Set<string>(["all", "scoped", "custom"]);
|
|
717
|
+
|
|
718
|
+
function isModelExposureMode(v: string): v is ModelExposureMode {
|
|
719
|
+
return VALID_EXPOSURE_MODES.has(v);
|
|
720
|
+
}
|
|
721
|
+
|
|
450
722
|
function applySetting(id: string, value: string): void {
|
|
451
723
|
switch (id) {
|
|
452
724
|
case "lifetime":
|
|
@@ -489,64 +761,110 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
489
761
|
if (Number.isFinite(sec) && sec > 0) config = { ...config, upstreamTimeoutSec: sec };
|
|
490
762
|
break;
|
|
491
763
|
}
|
|
764
|
+
case "publicModelIdMode":
|
|
765
|
+
if (isPublicModelIdMode(value)) {
|
|
766
|
+
config = { ...config, publicModelIdMode: value };
|
|
767
|
+
}
|
|
768
|
+
break;
|
|
769
|
+
case "modelExposureMode":
|
|
770
|
+
if (isModelExposureMode(value)) {
|
|
771
|
+
config = { ...config, modelExposureMode: value };
|
|
772
|
+
}
|
|
773
|
+
break;
|
|
774
|
+
case "customModels":
|
|
775
|
+
// Handled by submenu -- no cycling
|
|
776
|
+
break;
|
|
492
777
|
}
|
|
493
778
|
saveConfigToFile(config);
|
|
494
779
|
config = loadConfigFromFile();
|
|
495
780
|
}
|
|
496
781
|
|
|
782
|
+
/**
|
|
783
|
+
* Get the display value for a setting after it has been applied.
|
|
784
|
+
*/
|
|
785
|
+
function getDisplayValue(id: string): string {
|
|
786
|
+
switch (id) {
|
|
787
|
+
case "lifetime":
|
|
788
|
+
return config.lifetime;
|
|
789
|
+
case "host":
|
|
790
|
+
return config.host;
|
|
791
|
+
case "port":
|
|
792
|
+
return String(config.port);
|
|
793
|
+
case "authToken":
|
|
794
|
+
return config.authToken.length > 0 ? "enabled" : "disabled";
|
|
795
|
+
case "remoteImages":
|
|
796
|
+
return config.remoteImages ? "on" : "off";
|
|
797
|
+
case "maxBodySizeMb":
|
|
798
|
+
return `${String(config.maxBodySizeMb)} MB`;
|
|
799
|
+
case "upstreamTimeoutSec":
|
|
800
|
+
return `${String(config.upstreamTimeoutSec)}s`;
|
|
801
|
+
case "publicModelIdMode":
|
|
802
|
+
return config.publicModelIdMode;
|
|
803
|
+
case "modelExposureMode":
|
|
804
|
+
return config.modelExposureMode;
|
|
805
|
+
case "customModels":
|
|
806
|
+
return customModelsDisplay();
|
|
807
|
+
default:
|
|
808
|
+
return "";
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
497
812
|
async function openSettingsPanel(ctx: ExtensionCommandContext): Promise<void> {
|
|
498
813
|
config = loadConfigFromFile();
|
|
499
814
|
|
|
500
815
|
await ctx.ui.custom<void>(
|
|
501
816
|
(tui, theme, _kb, done) => {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
container.addChild(
|
|
526
|
-
new Text(
|
|
527
|
-
theme.fg(
|
|
528
|
-
"dim",
|
|
529
|
-
"Esc: close | Arrow keys: navigate | Space: toggle | Restart proxy to apply",
|
|
530
|
-
),
|
|
531
|
-
1,
|
|
532
|
-
0,
|
|
533
|
-
),
|
|
534
|
-
);
|
|
817
|
+
const container = new Container();
|
|
818
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Proxy Settings")), 1, 0));
|
|
819
|
+
container.addChild(new Text(theme.fg("dim", getConfigPath()), 1, 0));
|
|
820
|
+
|
|
821
|
+
const items = buildSettingItems();
|
|
822
|
+
const settingsList = new SettingsList(
|
|
823
|
+
items,
|
|
824
|
+
12,
|
|
825
|
+
getSettingsListTheme(),
|
|
826
|
+
(id, newValue) => {
|
|
827
|
+
lastGeneratedToken = "";
|
|
828
|
+
applySetting(id, newValue);
|
|
829
|
+
if (lastGeneratedToken.length > 0) {
|
|
830
|
+
ctx.ui.notify(`Auth token: ${lastGeneratedToken}`, "info");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Update display value in-place (no rebuild, preserves selection)
|
|
834
|
+
settingsList.updateValue(id, getDisplayValue(id));
|
|
835
|
+
|
|
836
|
+
// When exposure mode changes, update the "Select models" item
|
|
837
|
+
if (id === "modelExposureMode") {
|
|
838
|
+
settingsList.updateValue("customModels", customModelsDisplay());
|
|
839
|
+
}
|
|
535
840
|
|
|
536
|
-
|
|
537
|
-
|
|
841
|
+
tui.requestRender();
|
|
842
|
+
},
|
|
843
|
+
() => done(undefined),
|
|
844
|
+
{ enableSearch: true },
|
|
845
|
+
);
|
|
538
846
|
|
|
539
|
-
|
|
847
|
+
container.addChild(settingsList);
|
|
848
|
+
container.addChild(
|
|
849
|
+
new Text(
|
|
850
|
+
theme.fg(
|
|
851
|
+
"dim",
|
|
852
|
+
"Esc: close | Arrow keys: navigate | Space: toggle | Restart proxy to apply",
|
|
853
|
+
),
|
|
854
|
+
1,
|
|
855
|
+
0,
|
|
856
|
+
),
|
|
857
|
+
);
|
|
540
858
|
|
|
541
859
|
return {
|
|
542
860
|
render(width: number): string[] {
|
|
543
|
-
return
|
|
861
|
+
return container.render(width);
|
|
544
862
|
},
|
|
545
863
|
invalidate(): void {
|
|
546
|
-
|
|
864
|
+
container.invalidate();
|
|
547
865
|
},
|
|
548
866
|
handleInput(data: string): void {
|
|
549
|
-
|
|
867
|
+
settingsList.handleInput?.(data);
|
|
550
868
|
tui.requestRender();
|
|
551
869
|
},
|
|
552
870
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@victor-software-house/pi-openai-proxy",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "OpenAI-compatible HTTP proxy for pi's multi-provider model registry",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Victor Software House",
|
|
@@ -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",
|