@billjr99/pi-openai-compat 1.1.14 → 1.1.16
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 +38 -5
- package/index.ts +93 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,8 +56,27 @@ If pi is already running when you install, type `/reload` first.
|
|
|
56
56
|
| **OpenCode Zen** | `https://opencode.ai/zen/v1` | API key from opencode.ai |
|
|
57
57
|
| **Ollama (local)** | `http://localhost:11434/v1` | Keyless |
|
|
58
58
|
| **Ollama Cloud** | `https://ollama.com/v1` | Ollama Cloud API key from ollama.com |
|
|
59
|
+
| **llmproxy** | `http://localhost:8080/v1` (editable) | Keyless by default; bearer token if your instance requires one |
|
|
59
60
|
| **Custom** | Any URL you supply | Optional bearer token |
|
|
60
61
|
|
|
62
|
+
> **llmproxy and the `__` model-id rewrite.**
|
|
63
|
+
> [llmproxy](https://github.com/BillJr99/llmproxy) advertises model ids in the
|
|
64
|
+
> `provider__model` form (e.g. `openrouter__gpt-4`) and exposes virtual models
|
|
65
|
+
> such as `llmproxy__free` and `llmproxy__loadbalanced`. pi rejects model ids that
|
|
66
|
+
> contain `__`, so without special handling **every llmproxy model is silently
|
|
67
|
+
> dropped from `/model`**. The llmproxy template therefore sets
|
|
68
|
+
> `rewriteDoubleUnderscore: true`: the extension rewrites the first `__` of each id
|
|
69
|
+
> to `/` (e.g. `openrouter__gpt-4` → `openrouter/gpt-4`, `llmproxy__free` →
|
|
70
|
+
> `llmproxy/free`) before registering with pi. llmproxy canonicalizes that slash
|
|
71
|
+
> form back to `__` on each request, so routing still works.
|
|
72
|
+
>
|
|
73
|
+
> The flag is a per-provider config field (`rewriteDoubleUnderscore`, default
|
|
74
|
+
> **false**) and is enabled automatically only for the llmproxy template — other
|
|
75
|
+
> providers are unaffected. If you added llmproxy as a **Custom** endpoint instead
|
|
76
|
+
> of via the llmproxy template, set `"rewriteDoubleUnderscore": true` on that
|
|
77
|
+
> provider in `~/.config/pi-openai-compat/config.json` (or re-run `/compat-login`
|
|
78
|
+
> and pick **llmproxy (local)**).
|
|
79
|
+
|
|
61
80
|
> **Providers whose model catalog lives at a non-standard `/models` path (as of June 2026)**
|
|
62
81
|
> Some providers don't return models at `<base_url>/models`. The extension
|
|
63
82
|
> handles them in one of two ways:
|
|
@@ -311,17 +330,31 @@ parse error.
|
|
|
311
330
|
**Cloudflare AI Gateway returns 401 Unauthorized**
|
|
312
331
|
Check both: (1) the API token has `AI Gateway: Run` *and* `Workers AI: Read`
|
|
313
332
|
under "Permissions", scoped to the correct account; and (2) the gateway slug
|
|
314
|
-
in the URL actually exists under that account — list with
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
333
|
+
in the URL actually exists under that account — list them with:
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
curl https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/ai-gateway/gateways \
|
|
337
|
+
-H "Authorization: Bearer $TOKEN"
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
An empty `result` array means the gateway isn't there (you may need to create it
|
|
341
|
+
in **dash.cloudflare.com → AI → AI Gateway**, or you're querying the wrong
|
|
342
|
+
account).
|
|
319
343
|
|
|
320
344
|
**No models appear after login**
|
|
321
345
|
For Ollama: pull at least one model first (`ollama pull llama3`).
|
|
322
346
|
For OpenRouter: some keys are restricted to free-tier models only.
|
|
323
347
|
For NIM: confirm your account has inference access enabled.
|
|
324
348
|
|
|
349
|
+
**`/compat-login` reports N models but far fewer appear in `/model`**
|
|
350
|
+
This is the classic llmproxy symptom: pi drops every model id containing `__`, so
|
|
351
|
+
llmproxy's `provider__model` ids (and `llmproxy__free` / `llmproxy__loadbalanced`)
|
|
352
|
+
never show. Make sure you logged in via the **llmproxy (local)** template (it sets
|
|
353
|
+
`rewriteDoubleUnderscore: true` automatically). If you used **Custom**, add
|
|
354
|
+
`"rewriteDoubleUnderscore": true` to that provider in
|
|
355
|
+
`~/.config/pi-openai-compat/config.json` and re-run `/compat-refresh`. After the
|
|
356
|
+
fix the ids appear in slash form (`openrouter/gpt-4`, `llmproxy/free`).
|
|
357
|
+
|
|
325
358
|
**Models appear in `/model` but requests fail**
|
|
326
359
|
Check `/compat-login` ran successfully (no error message).
|
|
327
360
|
Verify Ollama is still running if using a local endpoint.
|
package/index.ts
CHANGED
|
@@ -44,6 +44,17 @@ 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;
|
|
51
|
+
// When true, rewrite the FIRST "__" of every model id to "/" before
|
|
52
|
+
// registering with pi. pi rejects model ids containing "__", so providers like
|
|
53
|
+
// llmproxy (which uses "provider__model" ids) would otherwise have their entire
|
|
54
|
+
// catalog dropped from /model. The rewrite is the inverse of llmproxy's own
|
|
55
|
+
// request-side canonicalization, so requests still round-trip. Defaults false;
|
|
56
|
+
// the wizard turns it on only for the llmproxy template.
|
|
57
|
+
rewriteDoubleUnderscore?: boolean;
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
interface ExtensionConfig {
|
|
@@ -93,6 +104,12 @@ const TEMPLATES: Record<string, {
|
|
|
93
104
|
modelsIdField?: string;
|
|
94
105
|
/** Keep only models whose task.name matches this string (case-insensitive). */
|
|
95
106
|
modelsKeepTask?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Rewrite the first "__" of each model id to "/" before registering with pi.
|
|
109
|
+
* pi drops model ids containing "__", so this is required for llmproxy (whose
|
|
110
|
+
* ids are "provider__model"). Default false; set true only where pi needs it.
|
|
111
|
+
*/
|
|
112
|
+
rewriteDoubleUnderscore?: boolean;
|
|
96
113
|
}> = {
|
|
97
114
|
openrouter: {
|
|
98
115
|
displayName: "OpenRouter",
|
|
@@ -284,6 +301,10 @@ const TEMPLATES: Record<string, {
|
|
|
284
301
|
baseUrl: "http://localhost:8080/v1",
|
|
285
302
|
keyless: true,
|
|
286
303
|
promptUrl: true,
|
|
304
|
+
// llmproxy advertises "provider__model" ids (and virtuals like
|
|
305
|
+
// "llmproxy__free"). pi rejects "__" in model ids, so rewrite the first
|
|
306
|
+
// "__" to "/" here; llmproxy canonicalizes the slash form back on requests.
|
|
307
|
+
rewriteDoubleUnderscore: true,
|
|
287
308
|
},
|
|
288
309
|
vercel: {
|
|
289
310
|
displayName: "Vercel AI Gateway",
|
|
@@ -376,19 +397,24 @@ function escapeRegex(s: string): string {
|
|
|
376
397
|
* treat as "this provider can't be healed automatically".
|
|
377
398
|
*/
|
|
378
399
|
function recoverPlaceholders(templateUrl: string, savedUrl: string): Record<string, string> | null {
|
|
400
|
+
// Tolerate trailing-slash differences the same way the rest of the code does
|
|
401
|
+
// (e.g. baseUrl.replace(/\/+$/, "")), so a saved URL that differs only by a
|
|
402
|
+
// trailing slash still heals instead of being marked stale.
|
|
403
|
+
const tpl = templateUrl.replace(/\/+$/, "");
|
|
404
|
+
const saved = savedUrl.replace(/\/+$/, "");
|
|
379
405
|
const order: string[] = [];
|
|
380
406
|
const placeholderRe = new RegExp(URL_PLACEHOLDERS.join("|"), "g");
|
|
381
407
|
let source = "^";
|
|
382
408
|
let lastIndex = 0;
|
|
383
409
|
let m: RegExpExecArray | null;
|
|
384
|
-
while ((m = placeholderRe.exec(
|
|
385
|
-
source += escapeRegex(
|
|
410
|
+
while ((m = placeholderRe.exec(tpl)) !== null) {
|
|
411
|
+
source += escapeRegex(tpl.slice(lastIndex, m.index)) + "([^/]+)";
|
|
386
412
|
order.push(m[0]);
|
|
387
413
|
lastIndex = m.index + m[0].length;
|
|
388
414
|
}
|
|
389
|
-
source += escapeRegex(
|
|
415
|
+
source += escapeRegex(tpl.slice(lastIndex)) + "$";
|
|
390
416
|
|
|
391
|
-
const match = new RegExp(source).exec(
|
|
417
|
+
const match = new RegExp(source).exec(saved);
|
|
392
418
|
if (!match) return null;
|
|
393
419
|
const out: Record<string, string> = {};
|
|
394
420
|
order.forEach((name, i) => { out[name] = match[i + 1]; });
|
|
@@ -404,7 +430,8 @@ function applyPlaceholders(url: string, values: Record<string, string>): string
|
|
|
404
430
|
/**
|
|
405
431
|
* Backfill missing discovery fields on saved providers from their template.
|
|
406
432
|
* Mutates `config` in place; the caller is responsible for persisting when
|
|
407
|
-
* `healed` is non-empty. `stale` lists
|
|
433
|
+
* `healed` is non-empty. `stale` lists the provider *keys* that need a manual
|
|
434
|
+
* re-login (the caller resolves display names and dedupes notifications).
|
|
408
435
|
*/
|
|
409
436
|
function migrateDiscoveryFields(config: ExtensionConfig): { healed: string[]; stale: string[] } {
|
|
410
437
|
const healed: string[] = [];
|
|
@@ -416,7 +443,7 @@ function migrateDiscoveryFields(config: ExtensionConfig): { healed: string[]; st
|
|
|
416
443
|
if (p.modelsUrl) continue; // already set (fresh login or manual edit) — never clobber
|
|
417
444
|
|
|
418
445
|
const values = recoverPlaceholders(tpl.baseUrl, p.baseUrl);
|
|
419
|
-
if (!values) { stale.push(
|
|
446
|
+
if (!values) { stale.push(key); continue; }
|
|
420
447
|
|
|
421
448
|
p.modelsUrl = applyPlaceholders(tpl.modelsUrl, values);
|
|
422
449
|
p.modelsIdField = tpl.modelsIdField;
|
|
@@ -493,8 +520,10 @@ async function fetchModels(
|
|
|
493
520
|
else if (Array.isArray(obj.result)) raw = obj.result as RawModel[];
|
|
494
521
|
}
|
|
495
522
|
if (!raw) {
|
|
523
|
+
// `url` may be an override (e.g. /catalog/models, /ai/models/search), so
|
|
524
|
+
// keep the wording generic rather than referring specifically to /models.
|
|
496
525
|
throw new Error(
|
|
497
|
-
`Unexpected
|
|
526
|
+
`Unexpected model catalog payload shape from ${url} ` +
|
|
498
527
|
`(expected an array or an object with a "data" or "result" array).`
|
|
499
528
|
);
|
|
500
529
|
}
|
|
@@ -506,8 +535,14 @@ async function fetchModels(
|
|
|
506
535
|
return taskName.toLowerCase() === keepTask.toLowerCase();
|
|
507
536
|
})
|
|
508
537
|
.map((m) => {
|
|
509
|
-
|
|
510
|
-
|
|
538
|
+
// Coerce the id field defensively: some upstreams expose a numeric id,
|
|
539
|
+
// and storing a non-string would break the localeCompare sort below.
|
|
540
|
+
const rawId = (m as Record<string, unknown>)[idField];
|
|
541
|
+
const id =
|
|
542
|
+
typeof rawId === "string" ? rawId :
|
|
543
|
+
typeof rawId === "number" ? String(rawId) :
|
|
544
|
+
"";
|
|
545
|
+
return { id, contextWindow: m.context_window, maxTokens: m.max_tokens };
|
|
511
546
|
})
|
|
512
547
|
.filter((m) => Boolean(m.id))
|
|
513
548
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
@@ -517,16 +552,31 @@ async function fetchModels(
|
|
|
517
552
|
// Provider registration helpers
|
|
518
553
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
519
554
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
})
|
|
555
|
+
/**
|
|
556
|
+
* Rewrite the FIRST "__" of a model id to "/". Only the first occurrence is
|
|
557
|
+
* touched: the provider/virtual segment that precedes it never contains "__" or
|
|
558
|
+
* "/", so this is the exact inverse of the slash→"__" canonicalization llmproxy
|
|
559
|
+
* applies on the request side, keeping ids round-trippable. Ids without "__" are
|
|
560
|
+
* returned unchanged.
|
|
561
|
+
*/
|
|
562
|
+
function rewriteFirstDoubleUnderscore(id: string): string {
|
|
563
|
+
const i = id.indexOf("__");
|
|
564
|
+
return i === -1 ? id : `${id.slice(0, i)}/${id.slice(i + 2)}`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function buildProviderModels(models: CachedModel[], rewriteDoubleUnderscore = false) {
|
|
568
|
+
return models.map((m) => {
|
|
569
|
+
const id = rewriteDoubleUnderscore ? rewriteFirstDoubleUnderscore(m.id) : m.id;
|
|
570
|
+
return {
|
|
571
|
+
id,
|
|
572
|
+
name: id,
|
|
573
|
+
reasoning: false,
|
|
574
|
+
input: ["text"] as string[],
|
|
575
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
576
|
+
contextWindow: m.contextWindow ?? 128_000,
|
|
577
|
+
maxTokens: m.maxTokens ?? 4_096,
|
|
578
|
+
};
|
|
579
|
+
});
|
|
530
580
|
}
|
|
531
581
|
|
|
532
582
|
function compatKey(key: string): string {
|
|
@@ -534,12 +584,16 @@ function compatKey(key: string): string {
|
|
|
534
584
|
}
|
|
535
585
|
|
|
536
586
|
function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
|
|
587
|
+
// Honor the persisted per-provider flag; fall back to the template default for
|
|
588
|
+
// this key so providers logged in before the flag existed (e.g. an existing
|
|
589
|
+
// llmproxy provider) still get the rewrite without a re-login.
|
|
590
|
+
const rewrite = p.rewriteDoubleUnderscore ?? TEMPLATES[key]?.rewriteDoubleUnderscore ?? false;
|
|
537
591
|
pi.registerProvider(compatKey(key), {
|
|
538
592
|
name: `compat/${key.replace(/_/g, "-")}`,
|
|
539
593
|
baseUrl: p.baseUrl,
|
|
540
594
|
apiKey: p.apiKey ?? (isLocalUrl(p.baseUrl) ? "local" : ""),
|
|
541
595
|
api: "openai-completions" as const,
|
|
542
|
-
models: buildProviderModels(p.cachedModels),
|
|
596
|
+
models: buildProviderModels(p.cachedModels, rewrite),
|
|
543
597
|
});
|
|
544
598
|
}
|
|
545
599
|
|
|
@@ -615,12 +669,19 @@ export default async function (pi: ExtensionAPI) {
|
|
|
615
669
|
"warning"
|
|
616
670
|
);
|
|
617
671
|
}
|
|
618
|
-
|
|
672
|
+
// Warn about stale providers only once: re-notifying on every session_start
|
|
673
|
+
// while the config stays stale would be noise. Persist the flag so the
|
|
674
|
+
// notice survives restarts.
|
|
675
|
+
const toNotify = stale.filter((key) => !config.providers[key]?.staleNotified);
|
|
676
|
+
if (toNotify.length > 0) {
|
|
677
|
+
const names = toNotify.map((key) => config.providers[key].displayName);
|
|
619
678
|
ctx.ui.notify(
|
|
620
|
-
`OpenAI-compat: ${
|
|
679
|
+
`OpenAI-compat: ${names.join(", ")} ${names.length === 1 ? "has" : "have"} an out-of-date ` +
|
|
621
680
|
`base URL — run /compat-login to update (its endpoint changed and can't be migrated automatically).`,
|
|
622
681
|
"warning"
|
|
623
682
|
);
|
|
683
|
+
for (const key of toNotify) config.providers[key].staleNotified = true;
|
|
684
|
+
saveConfig(config);
|
|
624
685
|
}
|
|
625
686
|
});
|
|
626
687
|
|
|
@@ -775,6 +836,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
775
836
|
modelsUrl,
|
|
776
837
|
modelsIdField: tpl.modelsIdField,
|
|
777
838
|
modelsKeepTask: tpl.modelsKeepTask,
|
|
839
|
+
rewriteDoubleUnderscore: tpl.rewriteDoubleUnderscore,
|
|
778
840
|
};
|
|
779
841
|
saveConfig(config);
|
|
780
842
|
registerProvider(pi, key, config.providers[key]);
|
|
@@ -807,10 +869,17 @@ export default async function (pi: ExtensionAPI) {
|
|
|
807
869
|
keys = providerKeys;
|
|
808
870
|
} else {
|
|
809
871
|
const ALL = "All providers";
|
|
810
|
-
|
|
872
|
+
// Embed the internal provider key in each label so duplicate
|
|
873
|
+
// displayNames (or a provider literally named "All providers") can't
|
|
874
|
+
// collide with each other or the special "All providers" option.
|
|
875
|
+
const options = providerKeys.map((k) => ({
|
|
876
|
+
key: k,
|
|
877
|
+
label: `${config.providers[k].displayName} [${k}]`,
|
|
878
|
+
}));
|
|
879
|
+
const labels = [ALL, ...options.map((o) => o.label)];
|
|
811
880
|
const chosen = await ctx.ui.select("Refresh which provider?", labels);
|
|
812
881
|
if (!chosen) { ctx.ui.notify("Cancelled.", "info"); return; }
|
|
813
|
-
keys = chosen === ALL ? providerKeys : [
|
|
882
|
+
keys = chosen === ALL ? providerKeys : [options[labels.indexOf(chosen) - 1].key];
|
|
814
883
|
}
|
|
815
884
|
|
|
816
885
|
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.16",
|
|
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",
|