@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.
- package/README.md +19 -5
- package/index.ts +132 -5
- 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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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 : [
|
|
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.
|
|
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",
|