@billjr99/pi-openai-compat 1.1.13 → 1.1.15

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 +19 -5
  2. package/index.ts +132 -5
  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
 
@@ -302,11 +311,16 @@ parse error.
302
311
  **Cloudflare AI Gateway returns 401 Unauthorized**
303
312
  Check both: (1) the API token has `AI Gateway: Run` *and* `Workers AI: Read`
304
313
  under "Permissions", scoped to the correct account; and (2) the gateway slug
305
- in the URL actually exists under that account — list with
306
- `curl https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/ai-gateway/gateways
307
- -H "Authorization: Bearer $TOKEN"`. An empty `result` array means the gateway
308
- isn't there (you may need to create it in **dash.cloudflare.com → AI → AI
309
- Gateway**, or you're querying the wrong account).
314
+ in the URL actually exists under that account — list them with:
315
+
316
+ ```bash
317
+ curl https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/ai-gateway/gateways \
318
+ -H "Authorization: Bearer $TOKEN"
319
+ ```
320
+
321
+ An empty `result` array means the gateway isn't there (you may need to create it
322
+ in **dash.cloudflare.com → AI → AI Gateway**, or you're querying the wrong
323
+ account).
310
324
 
311
325
  **No models appear after login**
312
326
  For Ollama: pull at least one model first (`ollama pull llama3`).
package/index.ts CHANGED
@@ -44,6 +44,10 @@ interface ProviderConfig {
44
44
  modelsUrl?: string;
45
45
  modelsIdField?: string;
46
46
  modelsKeepTask?: string;
47
+ // Set once we've warned the user that this provider's saved baseUrl no longer
48
+ // matches its template and can't be auto-migrated, so the notice is shown
49
+ // only once rather than on every session_start while the config stays stale.
50
+ staleNotified?: boolean;
47
51
  }
48
52
 
49
53
  interface ExtensionConfig {
@@ -349,6 +353,90 @@ function saveConfig(config: ExtensionConfig): void {
349
353
  }
350
354
  }
351
355
 
356
+ // ─────────────────────────────────────────────────────────────────────────────
357
+ // Discovery-field auto-heal
358
+ //
359
+ // Providers saved before model-discovery overrides existed (modelsUrl/
360
+ // modelsIdField/modelsKeepTask) have none on their config, so /compat-refresh
361
+ // and the session_start rehydrate hit the broken default <baseUrl>/models path
362
+ // (e.g. Cloudflare Workers AI's 405). When a provider's saved baseUrl still
363
+ // matches its template — so we can recover any account-id/slug placeholders —
364
+ // we backfill the discovery fields from the template. Providers whose baseUrl
365
+ // no longer matches the template (e.g. GitHub Models' retired Azure host) can't
366
+ // be healed safely and are reported so the user can re-run /compat-login.
367
+ // ─────────────────────────────────────────────────────────────────────────────
368
+
369
+ /** Placeholders that may appear in a template's baseUrl / modelsUrl. */
370
+ const URL_PLACEHOLDERS = ["YOUR_ACCOUNT_ID", "YOUR_GATEWAY_SLUG", "YOUR_PROVIDER"];
371
+
372
+ function escapeRegex(s: string): string {
373
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
374
+ }
375
+
376
+ /**
377
+ * Recover placeholder values by matching a saved URL against a template URL.
378
+ * Returns a map (possibly empty when the template has no placeholders) when the
379
+ * saved URL is consistent with the template, or null when it isn't — which we
380
+ * treat as "this provider can't be healed automatically".
381
+ */
382
+ function recoverPlaceholders(templateUrl: string, savedUrl: string): Record<string, string> | null {
383
+ // Tolerate trailing-slash differences the same way the rest of the code does
384
+ // (e.g. baseUrl.replace(/\/+$/, "")), so a saved URL that differs only by a
385
+ // trailing slash still heals instead of being marked stale.
386
+ const tpl = templateUrl.replace(/\/+$/, "");
387
+ const saved = savedUrl.replace(/\/+$/, "");
388
+ const order: string[] = [];
389
+ const placeholderRe = new RegExp(URL_PLACEHOLDERS.join("|"), "g");
390
+ let source = "^";
391
+ let lastIndex = 0;
392
+ let m: RegExpExecArray | null;
393
+ while ((m = placeholderRe.exec(tpl)) !== null) {
394
+ source += escapeRegex(tpl.slice(lastIndex, m.index)) + "([^/]+)";
395
+ order.push(m[0]);
396
+ lastIndex = m.index + m[0].length;
397
+ }
398
+ source += escapeRegex(tpl.slice(lastIndex)) + "$";
399
+
400
+ const match = new RegExp(source).exec(saved);
401
+ if (!match) return null;
402
+ const out: Record<string, string> = {};
403
+ order.forEach((name, i) => { out[name] = match[i + 1]; });
404
+ return out;
405
+ }
406
+
407
+ function applyPlaceholders(url: string, values: Record<string, string>): string {
408
+ let out = url;
409
+ for (const [name, value] of Object.entries(values)) out = out.split(name).join(value);
410
+ return out;
411
+ }
412
+
413
+ /**
414
+ * Backfill missing discovery fields on saved providers from their template.
415
+ * Mutates `config` in place; the caller is responsible for persisting when
416
+ * `healed` is non-empty. `stale` lists the provider *keys* that need a manual
417
+ * re-login (the caller resolves display names and dedupes notifications).
418
+ */
419
+ function migrateDiscoveryFields(config: ExtensionConfig): { healed: string[]; stale: string[] } {
420
+ const healed: string[] = [];
421
+ const stale: string[] = [];
422
+
423
+ for (const [key, p] of Object.entries(config.providers)) {
424
+ const tpl = TEMPLATES[key];
425
+ if (!tpl?.modelsUrl) continue; // no matching template, or template needs no overrides
426
+ if (p.modelsUrl) continue; // already set (fresh login or manual edit) — never clobber
427
+
428
+ const values = recoverPlaceholders(tpl.baseUrl, p.baseUrl);
429
+ if (!values) { stale.push(key); continue; }
430
+
431
+ p.modelsUrl = applyPlaceholders(tpl.modelsUrl, values);
432
+ p.modelsIdField = tpl.modelsIdField;
433
+ p.modelsKeepTask = tpl.modelsKeepTask;
434
+ healed.push(p.displayName);
435
+ }
436
+
437
+ return { healed, stale };
438
+ }
439
+
352
440
  // ─────────────────────────────────────────────────────────────────────────────
353
441
  // Networking
354
442
  // ─────────────────────────────────────────────────────────────────────────────
@@ -415,8 +503,10 @@ async function fetchModels(
415
503
  else if (Array.isArray(obj.result)) raw = obj.result as RawModel[];
416
504
  }
417
505
  if (!raw) {
506
+ // `url` may be an override (e.g. /catalog/models, /ai/models/search), so
507
+ // keep the wording generic rather than referring specifically to /models.
418
508
  throw new Error(
419
- `Unexpected /models payload shape from ${url} ` +
509
+ `Unexpected model catalog payload shape from ${url} ` +
420
510
  `(expected an array or an object with a "data" or "result" array).`
421
511
  );
422
512
  }
@@ -428,8 +518,14 @@ async function fetchModels(
428
518
  return taskName.toLowerCase() === keepTask.toLowerCase();
429
519
  })
430
520
  .map((m) => {
431
- const id = (m as Record<string, unknown>)[idField] as string | undefined;
432
- return { id: id ?? "", contextWindow: m.context_window, maxTokens: m.max_tokens };
521
+ // Coerce the id field defensively: some upstreams expose a numeric id,
522
+ // and storing a non-string would break the localeCompare sort below.
523
+ const rawId = (m as Record<string, unknown>)[idField];
524
+ const id =
525
+ typeof rawId === "string" ? rawId :
526
+ typeof rawId === "number" ? String(rawId) :
527
+ "";
528
+ return { id, contextWindow: m.context_window, maxTokens: m.max_tokens };
433
529
  })
434
530
  .filter((m) => Boolean(m.id))
435
531
  .sort((a, b) => a.id.localeCompare(b.id));
@@ -472,6 +568,11 @@ function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): voi
472
568
  export default async function (pi: ExtensionAPI) {
473
569
  let config = loadConfig();
474
570
 
571
+ // Self-heal older configs: backfill discovery fields (modelsUrl/idField/
572
+ // keepTask) we can derive from the template without prompting. Persist once
573
+ // so /compat-refresh and the session_start rehydrate use the fixed values.
574
+ if (migrateDiscoveryFields(config).healed.length > 0) saveConfig(config);
575
+
475
576
  // Register all saved providers immediately using cached model lists.
476
577
  // The factory is async, so pi waits for this to finish before startup
477
578
  // continues — providers are visible in /model from the very first render.
@@ -487,6 +588,11 @@ export default async function (pi: ExtensionAPI) {
487
588
  pi.on("session_start", async (_event, ctx) => {
488
589
  config = loadConfig();
489
590
 
591
+ // Self-heal what we can (silently), and collect providers whose saved
592
+ // baseUrl no longer matches the template — those need a manual re-login.
593
+ const { healed, stale } = migrateDiscoveryFields(config);
594
+ if (healed.length > 0) saveConfig(config);
595
+
490
596
  const registered: string[] = [];
491
597
  const failed: string[] = [];
492
598
 
@@ -527,6 +633,20 @@ export default async function (pi: ExtensionAPI) {
527
633
  "warning"
528
634
  );
529
635
  }
636
+ // Warn about stale providers only once: re-notifying on every session_start
637
+ // while the config stays stale would be noise. Persist the flag so the
638
+ // notice survives restarts.
639
+ const toNotify = stale.filter((key) => !config.providers[key]?.staleNotified);
640
+ if (toNotify.length > 0) {
641
+ const names = toNotify.map((key) => config.providers[key].displayName);
642
+ ctx.ui.notify(
643
+ `OpenAI-compat: ${names.join(", ")} ${names.length === 1 ? "has" : "have"} an out-of-date ` +
644
+ `base URL — run /compat-login to update (its endpoint changed and can't be migrated automatically).`,
645
+ "warning"
646
+ );
647
+ for (const key of toNotify) config.providers[key].staleNotified = true;
648
+ saveConfig(config);
649
+ }
530
650
  });
531
651
 
532
652
  // ── model_select ───────────────────────────────────────────────────────────
@@ -712,10 +832,17 @@ export default async function (pi: ExtensionAPI) {
712
832
  keys = providerKeys;
713
833
  } else {
714
834
  const ALL = "All providers";
715
- const labels = [ALL, ...providerKeys.map((k) => config.providers[k].displayName)];
835
+ // Embed the internal provider key in each label so duplicate
836
+ // displayNames (or a provider literally named "All providers") can't
837
+ // collide with each other or the special "All providers" option.
838
+ const options = providerKeys.map((k) => ({
839
+ key: k,
840
+ label: `${config.providers[k].displayName} [${k}]`,
841
+ }));
842
+ const labels = [ALL, ...options.map((o) => o.label)];
716
843
  const chosen = await ctx.ui.select("Refresh which provider?", labels);
717
844
  if (!chosen) { ctx.ui.notify("Cancelled.", "info"); return; }
718
- keys = chosen === ALL ? providerKeys : [providerKeys[labels.indexOf(chosen) - 1]];
845
+ keys = chosen === ALL ? providerKeys : [options[labels.indexOf(chosen) - 1].key];
719
846
  }
720
847
 
721
848
  const refreshed: string[] = [];
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.15",
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",