@victor-software-house/pi-openai-proxy 1.0.0 → 2.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 CHANGED
@@ -77,9 +77,14 @@ OPENAI_API_BASE=http://localhost:4141/v1 aider --model anthropic/claude-sonnet-4
77
77
  # Set "OpenAI API Base URL" to http://localhost:4141/v1
78
78
  ```
79
79
 
80
- ### Shorthand model names
80
+ ### Model resolution
81
81
 
82
- If a model ID is unique across providers, you can omit the provider prefix:
82
+ The proxy resolves model references in this order:
83
+
84
+ 1. **Exact public ID match** -- the ID from `GET /v1/models`
85
+ 2. **Canonical ID fallback** -- `provider/model-id` format (only for exposed models)
86
+
87
+ With the default `collision-prefixed` mode and no collisions, model IDs are exposed without prefixes:
83
88
 
84
89
  ```bash
85
90
  curl http://localhost:4141/v1/chat/completions \
@@ -87,21 +92,19 @@ curl http://localhost:4141/v1/chat/completions \
87
92
  -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hi"}]}'
88
93
  ```
89
94
 
90
- Ambiguous shorthand requests fail with a clear error listing the matching canonical IDs.
91
-
92
95
  ## Supported Endpoints
93
96
 
94
97
  | Endpoint | Description |
95
98
  |---|---|
96
- | `GET /v1/models` | List all available models (only those with configured credentials) |
97
- | `GET /v1/models/{model}` | Model details by canonical ID (supports URL-encoded IDs with `/`) |
99
+ | `GET /v1/models` | List exposed models (filtered by exposure mode, only those with configured credentials) |
100
+ | `GET /v1/models/{model}` | Model details by public ID or canonical ID (supports URL-encoded IDs with `/`) |
98
101
  | `POST /v1/chat/completions` | Chat completions (streaming and non-streaming) |
99
102
 
100
103
  ## Supported Chat Completions Features
101
104
 
102
105
  | Feature | Notes |
103
106
  |---|---|
104
- | `model` | Canonical (`provider/model-id`) or unique shorthand |
107
+ | `model` | Public ID, canonical (`provider/model-id`), or collision-prefixed shorthand |
105
108
  | `messages` (text) | `system`, `developer`, `user`, `assistant`, `tool` roles |
106
109
  | `messages` (base64 images) | Base64 data URI image content parts (`image/png`, `image/jpeg`, `image/gif`, `image/webp`) |
107
110
  | `stream` | SSE with `text_delta` and `toolcall_delta` mapping |
@@ -112,8 +115,8 @@ Ambiguous shorthand requests fail with a clear error listing the matching canoni
112
115
  | `stream_options.include_usage` | Final usage chunk in SSE stream |
113
116
  | `tools` / `tool_choice` | JSON Schema -> TypeBox conversion (supported subset) |
114
117
  | `tool_calls` in messages | Assistant tool call + tool result roundtrip |
115
- | `reasoning_effort` | Maps to pi's `ThinkingLevel` (`low`, `medium`, `high`) |
116
- | `response_format` | `text` and `json_object` via passthrough |
118
+ | `reasoning_effort` | Maps to pi's `ThinkingLevel` (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`) |
119
+ | `response_format` | `text`, `json_object`, and `json_schema` via passthrough |
117
120
  | `top_p` | Via passthrough |
118
121
  | `frequency_penalty` | Via passthrough |
119
122
  | `presence_penalty` | Via passthrough |
@@ -121,9 +124,33 @@ Ambiguous shorthand requests fail with a clear error listing the matching canoni
121
124
 
122
125
  **Not supported:** `n > 1`, `logprobs`, `logit_bias`, remote image URLs (disabled by default).
123
126
 
124
- ## Model Naming
127
+ ## Model Naming and Exposure
128
+
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:
125
144
 
126
- Models use the `provider/model-id` canonical format, matching pi's registry:
145
+ | Mode | Behavior |
146
+ |---|---|
147
+ | `all` (default) | Expose every model with configured credentials |
148
+ | `scoped` | Expose models from selected providers only (`scopedProviders`) |
149
+ | `custom` | Expose an explicit allowlist of canonical IDs (`customModels`) |
150
+
151
+ ### Canonical model IDs
152
+
153
+ Internal canonical IDs use the `provider/model-id` format matching pi's registry:
127
154
 
128
155
  ```
129
156
  anthropic/claude-sonnet-4-20250514
@@ -133,6 +160,8 @@ xai/grok-3
133
160
  openrouter/anthropic/claude-sonnet-4-20250514
134
161
  ```
135
162
 
163
+ Canonical IDs are accepted as backward-compatible fallback in requests, but only for models that are currently exposed. Hidden models cannot be reached by canonical ID.
164
+
136
165
  ## Configuration
137
166
 
138
167
  ### What comes from pi
@@ -159,22 +188,27 @@ Proxy-specific settings are configured via environment variables or the `/proxy
159
188
  | Max body size | `PI_PROXY_MAX_BODY_SIZE` | `52428800` (50 MB) | Maximum request body size in bytes |
160
189
  | Upstream timeout | `PI_PROXY_UPSTREAM_TIMEOUT_MS` | `120000` (120s) | Upstream request timeout in milliseconds |
161
190
 
162
- When used as a pi package, these settings are persisted in `~/.pi/agent/proxy-config.json` and applied when the extension spawns the proxy.
191
+ Model exposure settings are configured via the JSON config file (`~/.pi/agent/proxy-config.json`):
192
+
193
+ | Setting | Default | Description |
194
+ |---|---|---|
195
+ | `publicModelIdMode` | `collision-prefixed` | Public ID format: `collision-prefixed`, `universal`, `always-prefixed` |
196
+ | `modelExposureMode` | `all` | Which models to expose: `all`, `scoped`, `custom` |
197
+ | `scopedProviders` | `[]` | Provider keys for `scoped` mode |
198
+ | `customModels` | `[]` | Canonical model IDs for `custom` mode |
199
+ | `providerPrefixes` | `{}` | Provider key -> custom prefix label overrides |
200
+
201
+ When used as a pi package, all settings are persisted in `~/.pi/agent/proxy-config.json` and applied when the extension spawns the proxy.
163
202
 
164
203
  ### Discovering available models
165
204
 
166
- List all models the proxy can reach (models with configured credentials):
205
+ List all models the proxy exposes (filtered by the active exposure mode):
167
206
 
168
207
  ```bash
169
208
  curl http://localhost:4141/v1/models | jq '.data[].id'
170
209
  ```
171
210
 
172
- Each model includes extended metadata under `x_pi`:
173
-
174
- ```bash
175
- curl http://localhost:4141/v1/models/anthropic%2Fclaude-sonnet-4-20250514 | jq '.x_pi'
176
- # { "api": "anthropic", "reasoning": true, "input": ["text", "image"], ... }
177
- ```
211
+ Model objects follow the standard OpenAI shape (`id`, `object`, `created`, `owned_by`) with no proprietary extensions.
178
212
 
179
213
  ### Per-request API key override
180
214
 
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() {
package/dist/index.mjs CHANGED
@@ -9,15 +9,6 @@ import { Hono } from "hono";
9
9
  import { stream } from "hono/streaming";
10
10
  import { defineCommand, runMain } from "citty";
11
11
  //#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
12
  function parsePositiveInt(raw, fallback) {
22
13
  if (raw === void 0) return fallback;
23
14
  const n = Number.parseInt(raw, 10);
@@ -43,7 +34,12 @@ function loadConfig(cli = {}) {
43
34
  agenticEnabled: process.env.PI_PROXY_AGENTIC === "true",
44
35
  remoteImagesEnabled,
45
36
  maxBodySize,
46
- upstreamTimeoutMs
37
+ upstreamTimeoutMs,
38
+ publicModelIdMode: file.publicModelIdMode,
39
+ modelExposureMode: file.modelExposureMode,
40
+ scopedProviders: file.scopedProviders,
41
+ customModels: file.customModels,
42
+ providerPrefixes: file.providerPrefixes
47
43
  };
48
44
  }
49
45
  //#endregion
@@ -472,23 +468,206 @@ function parseAndValidateDataUri(uri) {
472
468
  };
473
469
  }
474
470
  //#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
475
661
  //#region src/openai/models.ts
476
662
  /**
477
- * Convert a pi Model to an OpenAI model object.
663
+ * Convert an ExposedModel to an OpenAI model object.
478
664
  */
479
- function toOpenAIModel(model) {
665
+ function toOpenAIModel(exposed) {
480
666
  return {
481
- id: `${model.provider}/${model.id}`,
667
+ id: exposed.publicId,
482
668
  object: "model",
483
669
  created: 0,
484
- owned_by: model.provider,
485
- x_pi: {
486
- api: model.api,
487
- reasoning: model.reasoning,
488
- input: model.input,
489
- context_window: model.contextWindow,
490
- max_tokens: model.maxTokens
491
- }
670
+ owned_by: exposed.provider
492
671
  };
493
672
  }
494
673
  /**
@@ -1067,7 +1246,20 @@ const toolChoiceSchema = z.union([z.enum([
1067
1246
  "auto",
1068
1247
  "required"
1069
1248
  ]), namedToolChoiceSchema]);
1070
- const responseFormatSchema = z.discriminatedUnion("type", [z.object({ type: z.literal("text") }), z.object({ type: z.literal("json_object") })]);
1249
+ const jsonSchemaResponseFormatSchema = z.object({
1250
+ type: z.literal("json_schema"),
1251
+ json_schema: z.object({
1252
+ name: z.string().trim(),
1253
+ description: z.string().trim().optional(),
1254
+ schema: z.record(z.string().trim(), z.unknown()).optional(),
1255
+ strict: z.boolean().nullable().optional()
1256
+ })
1257
+ });
1258
+ const responseFormatSchema = z.discriminatedUnion("type", [
1259
+ z.object({ type: z.literal("text") }),
1260
+ z.object({ type: z.literal("json_object") }),
1261
+ jsonSchemaResponseFormatSchema
1262
+ ]);
1071
1263
  const chatCompletionRequestSchema = z.object({
1072
1264
  model: z.string().trim(),
1073
1265
  messages: z.array(messageSchema).min(1),
@@ -1081,9 +1273,12 @@ const chatCompletionRequestSchema = z.object({
1081
1273
  tools: z.array(functionToolSchema).optional(),
1082
1274
  tool_choice: toolChoiceSchema.optional(),
1083
1275
  reasoning_effort: z.enum([
1276
+ "none",
1277
+ "minimal",
1084
1278
  "low",
1085
1279
  "medium",
1086
- "high"
1280
+ "high",
1281
+ "xhigh"
1087
1282
  ]).optional(),
1088
1283
  top_p: z.number().min(0).max(1).optional(),
1089
1284
  frequency_penalty: z.number().min(-2).max(2).optional(),
@@ -1164,15 +1359,18 @@ function isRecord(value) {
1164
1359
  /**
1165
1360
  * Map OpenAI reasoning_effort to pi ThinkingLevel.
1166
1361
  *
1167
- * OpenAI: "low" | "medium" | "high"
1362
+ * OpenAI: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"
1168
1363
  * Pi: "minimal" | "low" | "medium" | "high" | "xhigh"
1169
1364
  *
1170
- * Direct mapping for the three shared values.
1365
+ * "none" maps to "minimal" (pi has no "none" level).
1171
1366
  */
1172
1367
  const REASONING_EFFORT_MAP = {
1368
+ none: "minimal",
1369
+ minimal: "minimal",
1173
1370
  low: "low",
1174
1371
  medium: "medium",
1175
- high: "high"
1372
+ high: "high",
1373
+ xhigh: "xhigh"
1176
1374
  };
1177
1375
  /**
1178
1376
  * APIs where onPayload passthrough fields are not supported.
@@ -1267,75 +1465,33 @@ async function piStream(model, context, request, options) {
1267
1465
  return streamSimple(model, context, await buildStreamOptions(model, request, options));
1268
1466
  }
1269
1467
  //#endregion
1270
- //#region src/pi/resolve-model.ts
1468
+ //#region src/server/routes.ts
1271
1469
  /**
1272
- * Parse a canonical model ID string into provider and model-id.
1273
- *
1274
- * "openai/gpt-4o" -> { provider: "openai", modelId: "gpt-4o" }
1275
- * "openrouter/anthropic/claude-sonnet-4-20250514" -> { provider: "openrouter", modelId: "anthropic/claude-sonnet-4-20250514" }
1276
- * "gpt-4o" -> null (shorthand, no slash)
1470
+ * Build a ModelExposureConfig from the server config.
1277
1471
  */
1278
- function parseCanonicalId(input) {
1279
- const slashIndex = input.indexOf("/");
1280
- if (slashIndex === -1) return null;
1472
+ function buildExposureConfig(config) {
1281
1473
  return {
1282
- provider: input.slice(0, slashIndex),
1283
- modelId: input.slice(slashIndex + 1)
1474
+ publicModelIdMode: config.publicModelIdMode,
1475
+ modelExposureMode: config.modelExposureMode,
1476
+ scopedProviders: config.scopedProviders,
1477
+ customModels: config.customModels,
1478
+ providerPrefixes: config.providerPrefixes
1284
1479
  };
1285
1480
  }
1286
1481
  /**
1287
- * Resolve a model string (canonical or shorthand) to a pi Model.
1482
+ * Compute or refresh the model exposure from the current registry and config.
1483
+ * Returns the exposure result or throws on config errors.
1288
1484
  */
1289
- function resolveModel(input) {
1290
- const registry = getRegistry();
1291
- const parsed = parseCanonicalId(input);
1292
- if (parsed !== null) {
1293
- const model = registry.find(parsed.provider, parsed.modelId);
1294
- if (model === void 0) return {
1295
- ok: false,
1296
- status: 404,
1297
- message: `Model '${input}' not found`
1298
- };
1299
- return {
1300
- ok: true,
1301
- model
1302
- };
1303
- }
1304
- const allModels = registry.getAll();
1305
- const matches = [];
1306
- for (const m of allModels) if (m.id === input) matches.push(m);
1307
- if (matches.length === 0) return {
1308
- ok: false,
1309
- status: 404,
1310
- message: `Model '${input}' not found`
1311
- };
1312
- if (matches.length === 1) {
1313
- const match = matches[0];
1314
- if (match === void 0) return {
1315
- ok: false,
1316
- status: 404,
1317
- message: `Model '${input}' not found`
1318
- };
1319
- return {
1320
- ok: true,
1321
- model: match
1322
- };
1323
- }
1324
- const candidates = matches.map((m) => `${m.provider}/${m.id}`);
1325
- return {
1326
- ok: false,
1327
- status: 400,
1328
- message: `Ambiguous model '${input}'. Matches: ${candidates.join(", ")}. Use the canonical form 'provider/model-id'.`,
1329
- candidates
1330
- };
1485
+ function getExposure(config) {
1486
+ const outcome = computeModelExposure(getAvailableModels(), buildExposureConfig(config));
1487
+ if (!outcome.ok) throw new Error(`Model exposure configuration error: ${outcome.message}`);
1488
+ return outcome;
1331
1489
  }
1332
- //#endregion
1333
- //#region src/server/routes.ts
1334
1490
  function createRoutes(config) {
1335
1491
  const routes = new Hono();
1336
1492
  routes.get("/v1/models", (c) => {
1337
- const models = getAvailableModels();
1338
- return c.json(buildModelList(models));
1493
+ const exposure = getExposure(config);
1494
+ return c.json(buildModelList(exposure.models));
1339
1495
  });
1340
1496
  routes.get("/v1/models/*", (c) => {
1341
1497
  const rawPath = c.req.path;
@@ -1343,12 +1499,9 @@ function createRoutes(config) {
1343
1499
  const modelIdEncoded = rawPath.slice(11);
1344
1500
  if (modelIdEncoded.length === 0) return c.json(modelNotFound(""), 404);
1345
1501
  const modelId = decodeURIComponent(modelIdEncoded);
1346
- const resolution = resolveModel(modelId);
1347
- if (!resolution.ok) {
1348
- if (resolution.status === 400) return c.json(invalidRequest(resolution.message, "model"), 400);
1349
- return c.json(modelNotFound(modelId), 404);
1350
- }
1351
- return c.json(toOpenAIModel(resolution.model));
1502
+ const resolved = resolveExposedModel(getExposure(config), modelId);
1503
+ if (resolved === void 0) return c.json(modelNotFound(modelId), 404);
1504
+ return c.json(toOpenAIModel(resolved));
1352
1505
  });
1353
1506
  routes.post("/v1/chat/completions", async (c) => {
1354
1507
  const requestId = c.get("requestId");
@@ -1366,13 +1519,10 @@ function createRoutes(config) {
1366
1519
  return c.json(invalidRequest(validation.message, validation.param ?? void 0), 400);
1367
1520
  }
1368
1521
  const request = validation.data;
1369
- const resolution = resolveModel(request.model);
1370
- if (!resolution.ok) {
1371
- if (resolution.status === 400) return c.json(invalidRequest(resolution.message, "model"), 400);
1372
- return c.json(modelNotFound(request.model), 404);
1373
- }
1374
- const model = resolution.model;
1375
- const canonicalModelId = `${model.provider}/${model.id}`;
1522
+ const resolved = resolveExposedModel(getExposure(config), request.model);
1523
+ if (resolved === void 0) return c.json(modelNotFound(request.model), 404);
1524
+ const model = resolved.model;
1525
+ const canonicalModelId = resolved.canonicalId;
1376
1526
  const conversion = convertMessages(request.messages);
1377
1527
  if (!conversion.ok) return c.json(invalidRequest(conversion.message, conversion.param), 400);
1378
1528
  const context = conversion.context;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-openai-proxy",
3
- "version": "1.0.0",
3
+ "version": "2.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",