@billjr99/pi-openai-compat 1.1.12 → 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 +22 -1
  2. package/index.ts +164 -2
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -77,12 +77,21 @@ 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
 
83
92
  ## Commands
84
93
 
85
- Only two commands are needed.
94
+ Three commands are available; `/compat-login` is the only one you need to get started.
86
95
 
87
96
  ### `/compat-login`
88
97
 
@@ -98,6 +107,18 @@ After login, the provider's models appear in pi's `/model` command and
98
107
  `Ctrl+L` picker immediately. You can run `/compat-login` again to add a
99
108
  second provider — all providers are active simultaneously.
100
109
 
110
+ ### `/compat-refresh`
111
+
112
+ Re-fetches the model list for an already-registered provider, reusing the
113
+ saved base URL, API key, and discovery settings — no need to re-enter URLs,
114
+ keys, or account IDs. Use it to pick up models a provider has added (or
115
+ dropped) since you logged in, without restarting your session.
116
+
117
+ If you have multiple providers registered, you are asked which one to refresh
118
+ (or choose **All providers**); with a single provider it refreshes directly.
119
+ A failed or empty refresh leaves the existing model list untouched, so a flaky
120
+ network call can't blank out a working provider.
121
+
101
122
  ### `/compat-logout`
102
123
 
103
124
  Unregisters a provider from pi. If you have multiple providers registered,
package/index.ts CHANGED
@@ -8,8 +8,9 @@
8
8
  * - Every provider saved in config.json is registered automatically at
9
9
  * startup. The factory is async so pi waits for registration to complete
10
10
  * before showing the model list — no session_start delay.
11
- * - /compat-login adds a provider (fetches fresh model list, registers).
12
- * - /compat-logout removes a provider (unregisters, restores previous model).
11
+ * - /compat-login adds a provider (fetches fresh model list, registers).
12
+ * - /compat-refresh re-fetches the model list for a registered provider.
13
+ * - /compat-logout removes a provider (unregisters, restores previous model).
13
14
  * - No activeProviders list — presence in config.providers means registered.
14
15
  *
15
16
  * Config: ~/.config/pi-openai-compat/config.json
@@ -348,6 +349,84 @@ function saveConfig(config: ExtensionConfig): void {
348
349
  }
349
350
  }
350
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
+
351
430
  // ─────────────────────────────────────────────────────────────────────────────
352
431
  // Networking
353
432
  // ─────────────────────────────────────────────────────────────────────────────
@@ -471,6 +550,11 @@ function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): voi
471
550
  export default async function (pi: ExtensionAPI) {
472
551
  let config = loadConfig();
473
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
+
474
558
  // Register all saved providers immediately using cached model lists.
475
559
  // The factory is async, so pi waits for this to finish before startup
476
560
  // continues — providers are visible in /model from the very first render.
@@ -486,6 +570,11 @@ export default async function (pi: ExtensionAPI) {
486
570
  pi.on("session_start", async (_event, ctx) => {
487
571
  config = loadConfig();
488
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
+
489
578
  const registered: string[] = [];
490
579
  const failed: string[] = [];
491
580
 
@@ -526,6 +615,13 @@ export default async function (pi: ExtensionAPI) {
526
615
  "warning"
527
616
  );
528
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
+ }
529
625
  });
530
626
 
531
627
  // ── model_select ───────────────────────────────────────────────────────────
@@ -690,6 +786,72 @@ export default async function (pi: ExtensionAPI) {
690
786
  },
691
787
  });
692
788
 
789
+ // ── /compat-refresh ──────────────────────────────────────────────────────────
790
+ // Force a re-fetch of an already-registered provider's model list, reusing
791
+ // the persisted baseUrl/apiKey and discovery overrides — no need to re-enter
792
+ // URLs, keys, or account IDs like /compat-login. Picks up newly added (or
793
+ // dropped) upstream models without restarting the session.
794
+ pi.registerCommand("compat-refresh", {
795
+ description: "Re-fetch the model list for a registered OpenAI-compatible provider",
796
+ handler: async (_args, ctx) => {
797
+ const providerKeys = Object.keys(config.providers);
798
+ if (!providerKeys.length) {
799
+ ctx.ui.notify("No compat providers are registered. Run /compat-login first.", "info");
800
+ return;
801
+ }
802
+
803
+ // Choose which provider(s) to refresh. With one provider, refresh it
804
+ // directly; otherwise offer each by name plus an "All providers" option.
805
+ let keys: string[];
806
+ if (providerKeys.length === 1) {
807
+ keys = providerKeys;
808
+ } else {
809
+ const ALL = "All providers";
810
+ const labels = [ALL, ...providerKeys.map((k) => config.providers[k].displayName)];
811
+ const chosen = await ctx.ui.select("Refresh which provider?", labels);
812
+ if (!chosen) { ctx.ui.notify("Cancelled.", "info"); return; }
813
+ keys = chosen === ALL ? providerKeys : [providerKeys[labels.indexOf(chosen) - 1]];
814
+ }
815
+
816
+ const refreshed: string[] = [];
817
+ const failed: string[] = [];
818
+ for (const key of keys) {
819
+ const p = config.providers[key];
820
+ ctx.ui.notify(`Refreshing ${p.displayName} …`, "info");
821
+ try {
822
+ const models = await fetchModels(p.baseUrl, p.apiKey, {
823
+ url: p.modelsUrl,
824
+ idField: p.modelsIdField,
825
+ keepTask: p.modelsKeepTask,
826
+ });
827
+ if (models.length > 0) {
828
+ // Only overwrite the cache on a successful, non-empty fetch — a
829
+ // flaky refresh must never blank out a working provider's models.
830
+ p.cachedModels = models;
831
+ saveConfig(config);
832
+ registerProvider(pi, key, p);
833
+ refreshed.push(`${p.displayName} (${models.length})`);
834
+ } else {
835
+ failed.push(p.displayName);
836
+ }
837
+ } catch {
838
+ failed.push(p.displayName);
839
+ }
840
+ }
841
+
842
+ if (refreshed.length > 0) {
843
+ ctx.ui.notify(`Refreshed: ${refreshed.join(", ")}.`, "success");
844
+ }
845
+ if (failed.length > 0) {
846
+ ctx.ui.notify(
847
+ `Could not refresh ${failed.join(", ")} — kept the existing model list. ` +
848
+ `Run /compat-login if the provider's URL or key changed.`,
849
+ "warning"
850
+ );
851
+ }
852
+ },
853
+ });
854
+
693
855
  // ── /compat-logout ─────────────────────────────────────────────────────────
694
856
  pi.registerCommand("compat-logout", {
695
857
  description: "Remove an OpenAI-compatible provider from pi's model list",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@billjr99/pi-openai-compat",
3
- "version": "1.1.12",
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",