@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.
Files changed (3) hide show
  1. package/README.md +38 -5
  2. package/index.ts +93 -24
  3. 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
- `curl https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/ai-gateway/gateways
316
- -H "Authorization: Bearer $TOKEN"`. An empty `result` array means the gateway
317
- isn't there (you may need to create it in **dash.cloudflare.com → AI → AI
318
- Gateway**, or you're querying the wrong account).
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(templateUrl)) !== null) {
385
- source += escapeRegex(templateUrl.slice(lastIndex, m.index)) + "([^/]+)";
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(templateUrl.slice(lastIndex)) + "$";
415
+ source += escapeRegex(tpl.slice(lastIndex)) + "$";
390
416
 
391
- const match = new RegExp(source).exec(savedUrl);
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 providers that need a manual re-login.
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(p.displayName); continue; }
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 /models payload shape from ${url} ` +
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
- const id = (m as Record<string, unknown>)[idField] as string | undefined;
510
- return { id: id ?? "", contextWindow: m.context_window, maxTokens: m.max_tokens };
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
- function buildProviderModels(models: CachedModel[]) {
521
- return models.map((m) => ({
522
- id: m.id,
523
- name: m.id,
524
- reasoning: false,
525
- input: ["text"] as string[],
526
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
527
- contextWindow: m.contextWindow ?? 128_000,
528
- maxTokens: m.maxTokens ?? 4_096,
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
- if (stale.length > 0) {
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: ${stale.join(", ")} ${stale.length === 1 ? "has" : "have"} an out-of-date ` +
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
- const labels = [ALL, ...providerKeys.map((k) => config.providers[k].displayName)];
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 : [providerKeys[labels.indexOf(chosen) - 1]];
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.14",
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",