@billjr99/pi-openai-compat 1.1.13 → 1.1.14

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.
Files changed (3) hide show
  1. package/README.md +9 -0
  2. package/index.ts +95 -0
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -77,6 +77,15 @@ If pi is already running when you install, type `/reload` first.
77
77
  > | **Cloudflare Workers AI** | HTTP 405 (no `GET /v1/models`; real catalog at `/ai/models/search`, id field is `name`, mixed task types) | Live discovery — `modelsUrl: …/ai/models/search`, `modelsIdField: name`, `modelsKeepTask: "Text Generation"` |
78
78
  > | **Cloudflare AI Gateway** | HTTP 401 (token missing `AI Gateway: Run`) or HTTP 400 *"Please configure AI Gateway"* (gateway slug doesn't exist, or the upstream isn't configured on it) | Built-in fallback list |
79
79
  > | **Hugging Face** | Returns HTML rather than JSON | Built-in fallback list |
80
+ >
81
+ > **Auto-heal for older configs:** if you logged in before live discovery
82
+ > existed, your saved provider is missing these `modelsUrl`/`modelsIdField`/
83
+ > `modelsKeepTask` fields. On startup the extension backfills them from the
84
+ > template whenever it can do so without prompting — i.e. when your saved base
85
+ > URL still matches the template (Cloudflare Workers AI). When the base URL
86
+ > itself changed (e.g. GitHub Models' retired `models.inference.ai.azure.com`
87
+ > host), it can't be migrated silently and you'll get a one-time notice to run
88
+ > `/compat-login`.
80
89
 
81
90
  ---
82
91
 
package/index.ts CHANGED
@@ -349,6 +349,84 @@ function saveConfig(config: ExtensionConfig): void {
349
349
  }
350
350
  }
351
351
 
352
+ // ─────────────────────────────────────────────────────────────────────────────
353
+ // Discovery-field auto-heal
354
+ //
355
+ // Providers saved before model-discovery overrides existed (modelsUrl/
356
+ // modelsIdField/modelsKeepTask) have none on their config, so /compat-refresh
357
+ // and the session_start rehydrate hit the broken default <baseUrl>/models path
358
+ // (e.g. Cloudflare Workers AI's 405). When a provider's saved baseUrl still
359
+ // matches its template — so we can recover any account-id/slug placeholders —
360
+ // we backfill the discovery fields from the template. Providers whose baseUrl
361
+ // no longer matches the template (e.g. GitHub Models' retired Azure host) can't
362
+ // be healed safely and are reported so the user can re-run /compat-login.
363
+ // ─────────────────────────────────────────────────────────────────────────────
364
+
365
+ /** Placeholders that may appear in a template's baseUrl / modelsUrl. */
366
+ const URL_PLACEHOLDERS = ["YOUR_ACCOUNT_ID", "YOUR_GATEWAY_SLUG", "YOUR_PROVIDER"];
367
+
368
+ function escapeRegex(s: string): string {
369
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
370
+ }
371
+
372
+ /**
373
+ * Recover placeholder values by matching a saved URL against a template URL.
374
+ * Returns a map (possibly empty when the template has no placeholders) when the
375
+ * saved URL is consistent with the template, or null when it isn't — which we
376
+ * treat as "this provider can't be healed automatically".
377
+ */
378
+ function recoverPlaceholders(templateUrl: string, savedUrl: string): Record<string, string> | null {
379
+ const order: string[] = [];
380
+ const placeholderRe = new RegExp(URL_PLACEHOLDERS.join("|"), "g");
381
+ let source = "^";
382
+ let lastIndex = 0;
383
+ let m: RegExpExecArray | null;
384
+ while ((m = placeholderRe.exec(templateUrl)) !== null) {
385
+ source += escapeRegex(templateUrl.slice(lastIndex, m.index)) + "([^/]+)";
386
+ order.push(m[0]);
387
+ lastIndex = m.index + m[0].length;
388
+ }
389
+ source += escapeRegex(templateUrl.slice(lastIndex)) + "$";
390
+
391
+ const match = new RegExp(source).exec(savedUrl);
392
+ if (!match) return null;
393
+ const out: Record<string, string> = {};
394
+ order.forEach((name, i) => { out[name] = match[i + 1]; });
395
+ return out;
396
+ }
397
+
398
+ function applyPlaceholders(url: string, values: Record<string, string>): string {
399
+ let out = url;
400
+ for (const [name, value] of Object.entries(values)) out = out.split(name).join(value);
401
+ return out;
402
+ }
403
+
404
+ /**
405
+ * Backfill missing discovery fields on saved providers from their template.
406
+ * Mutates `config` in place; the caller is responsible for persisting when
407
+ * `healed` is non-empty. `stale` lists providers that need a manual re-login.
408
+ */
409
+ function migrateDiscoveryFields(config: ExtensionConfig): { healed: string[]; stale: string[] } {
410
+ const healed: string[] = [];
411
+ const stale: string[] = [];
412
+
413
+ for (const [key, p] of Object.entries(config.providers)) {
414
+ const tpl = TEMPLATES[key];
415
+ if (!tpl?.modelsUrl) continue; // no matching template, or template needs no overrides
416
+ if (p.modelsUrl) continue; // already set (fresh login or manual edit) — never clobber
417
+
418
+ const values = recoverPlaceholders(tpl.baseUrl, p.baseUrl);
419
+ if (!values) { stale.push(p.displayName); continue; }
420
+
421
+ p.modelsUrl = applyPlaceholders(tpl.modelsUrl, values);
422
+ p.modelsIdField = tpl.modelsIdField;
423
+ p.modelsKeepTask = tpl.modelsKeepTask;
424
+ healed.push(p.displayName);
425
+ }
426
+
427
+ return { healed, stale };
428
+ }
429
+
352
430
  // ─────────────────────────────────────────────────────────────────────────────
353
431
  // Networking
354
432
  // ─────────────────────────────────────────────────────────────────────────────
@@ -472,6 +550,11 @@ function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): voi
472
550
  export default async function (pi: ExtensionAPI) {
473
551
  let config = loadConfig();
474
552
 
553
+ // Self-heal older configs: backfill discovery fields (modelsUrl/idField/
554
+ // keepTask) we can derive from the template without prompting. Persist once
555
+ // so /compat-refresh and the session_start rehydrate use the fixed values.
556
+ if (migrateDiscoveryFields(config).healed.length > 0) saveConfig(config);
557
+
475
558
  // Register all saved providers immediately using cached model lists.
476
559
  // The factory is async, so pi waits for this to finish before startup
477
560
  // continues — providers are visible in /model from the very first render.
@@ -487,6 +570,11 @@ export default async function (pi: ExtensionAPI) {
487
570
  pi.on("session_start", async (_event, ctx) => {
488
571
  config = loadConfig();
489
572
 
573
+ // Self-heal what we can (silently), and collect providers whose saved
574
+ // baseUrl no longer matches the template — those need a manual re-login.
575
+ const { healed, stale } = migrateDiscoveryFields(config);
576
+ if (healed.length > 0) saveConfig(config);
577
+
490
578
  const registered: string[] = [];
491
579
  const failed: string[] = [];
492
580
 
@@ -527,6 +615,13 @@ export default async function (pi: ExtensionAPI) {
527
615
  "warning"
528
616
  );
529
617
  }
618
+ if (stale.length > 0) {
619
+ ctx.ui.notify(
620
+ `OpenAI-compat: ${stale.join(", ")} ${stale.length === 1 ? "has" : "have"} an out-of-date ` +
621
+ `base URL — run /compat-login to update (its endpoint changed and can't be migrated automatically).`,
622
+ "warning"
623
+ );
624
+ }
530
625
  });
531
626
 
532
627
  // ── model_select ───────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@billjr99/pi-openai-compat",
3
- "version": "1.1.13",
3
+ "version": "1.1.14",
4
4
  "description": "pi-coding-agent extension: OpenAI-compatible endpoint support (OpenRouter, NVIDIA NIM, Nous Portal, Ollama, custom)",
5
5
  "author": "Bill Mongan <https://github.com/BillJr99>",
6
6
  "license": "MIT",