@billjr99/pi-openai-compat 1.1.22 → 1.1.24

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 +36 -1
  2. package/index.ts +56 -17
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -129,7 +129,10 @@ dropped) since you logged in, without restarting your session.
129
129
  If you have multiple providers registered, you are asked which one to refresh
130
130
  (or choose **All providers**); with a single provider it refreshes directly.
131
131
  A failed or empty refresh leaves the existing model list untouched, so a flaky
132
- network call can't blank out a working provider.
132
+ network call can't blank out a working provider. Capability metadata you have
133
+ hand-edited into `cachedModels` (see [Config file](#config-file)) survives a
134
+ refresh: the fetched list decides which model IDs exist, and any field the
135
+ provider's catalog omits is carried forward from the previous cache.
133
136
 
134
137
  ### `/compat-logout`
135
138
 
@@ -199,6 +202,38 @@ Credentials and cached model lists are stored at:
199
202
  API keys are stored in plaintext. Protect the file with `chmod 600` if
200
203
  needed, or delete it to clear all saved credentials.
201
204
 
205
+ Each provider's `cachedModels` array holds one entry per model. Only `id` is
206
+ required; the rest are optional and fall back to conservative defaults when the
207
+ provider's `/models` catalog does not report them:
208
+
209
+ | Field | Type | Default | Notes |
210
+ |---|---|---|---|
211
+ | `id` | string | required | Model ID as sent in requests. |
212
+ | `contextWindow` | number | `128000` | Context window in tokens. |
213
+ | `maxTokens` | number | `4096` | Maximum output tokens per response. |
214
+ | `reasoning` | boolean | `false` | Enables pi's thinking mode for the model. |
215
+ | `input` | `["text"]` or `["text","image"]` | `["text"]` | Modalities pi may send; `image` lets pi attach image blocks. Any other value is ignored. |
216
+
217
+ Most catalogs report none of these beyond the ID, so aggregators and proxies
218
+ (CLIProxyAPI, for example) register every model as a 128K, text-only,
219
+ non-reasoning model. To correct that, edit the entry by hand and `/reload`:
220
+
221
+ ```json
222
+ {
223
+ "providers": {
224
+ "cpa": {
225
+ "cachedModels": [
226
+ { "id": "claude-sonnet-4-6", "contextWindow": 1000000, "maxTokens": 128000,
227
+ "reasoning": true, "input": ["text", "image"] }
228
+ ]
229
+ }
230
+ }
231
+ }
232
+ ```
233
+
234
+ `/compat-refresh` keeps these hand-edited fields for any model ID that is still
235
+ present in the refreshed catalog; a field the provider does report always wins.
236
+
202
237
  ### Adding a provider without pi — `add-provider.sh`
203
238
 
204
239
  `/compat-login` is the normal path, but the repo also ships a standalone script
package/index.ts CHANGED
@@ -25,12 +25,18 @@ import * as os from "node:os";
25
25
  // Types
26
26
  // ─────────────────────────────────────────────────────────────────────────────
27
27
 
28
+ /** Input modalities pi understands; anything else is dropped at fetch time. */
29
+ type ModelInput = "text" | "image";
30
+
28
31
  interface CachedModel {
29
32
  id: string;
30
33
  contextWindow?: number;
31
34
  maxTokens?: number;
35
+ // Optional capability metadata. Rarely present in a /models payload, but a
36
+ // user may hand-edit them into cachedModels for providers whose catalog
37
+ // omits them (see README "Config file"); /compat-refresh preserves them.
32
38
  reasoning?: boolean;
33
- input?: string[];
39
+ input?: ModelInput[];
34
40
  }
35
41
 
36
42
  interface ProviderConfig {
@@ -69,9 +75,25 @@ type RawModel = {
69
75
  name?: string;
70
76
  context_window?: number;
71
77
  max_tokens?: number;
78
+ reasoning?: unknown;
79
+ input?: unknown;
72
80
  task?: { name?: string };
73
81
  };
74
82
 
83
+ /**
84
+ * Coerce an upstream `input` value into pi's modality list. Unknown or
85
+ * malformed values yield undefined so buildProviderModels falls back to the
86
+ * default, rather than caching something pi would choke on (it calls
87
+ * `model.input.includes("image")` at tool time).
88
+ */
89
+ function normalizeInput(value: unknown): ModelInput[] | undefined {
90
+ if (!Array.isArray(value)) return undefined;
91
+ const kept = value.filter(
92
+ (v): v is ModelInput => v === "text" || v === "image",
93
+ );
94
+ return kept.length > 0 ? kept : undefined;
95
+ }
96
+
75
97
  // ─────────────────────────────────────────────────────────────────────────────
76
98
  // Provider templates
77
99
  // ─────────────────────────────────────────────────────────────────────────────
@@ -557,8 +579,8 @@ async function fetchModels(
557
579
  id,
558
580
  contextWindow: m.context_window,
559
581
  maxTokens: m.max_tokens,
560
- reasoning: m.reasoning,
561
- input: m.input,
582
+ reasoning: typeof m.reasoning === "boolean" ? m.reasoning : undefined,
583
+ input: normalizeInput(m.input),
562
584
  };
563
585
  })
564
586
  .filter((m) => Boolean(m.id))
@@ -576,7 +598,7 @@ function buildProviderModels(models: CachedModel[]) {
576
598
  id,
577
599
  name: id,
578
600
  reasoning: m.reasoning ?? false,
579
- input: m.input ?? (["text"] as string[]),
601
+ input: m.input ?? (["text"] as ModelInput[]),
580
602
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
581
603
  contextWindow: m.contextWindow ?? 128_000,
582
604
  maxTokens: m.maxTokens ?? 4_096,
@@ -584,6 +606,27 @@ function buildProviderModels(models: CachedModel[]) {
584
606
  });
585
607
  }
586
608
 
609
+ /**
610
+ * Carry hand-edited capability metadata (reasoning / input / contextWindow /
611
+ * maxTokens) forward from the previous cache when a fresh /models fetch omits
612
+ * it. The fetched list decides which ids exist; the old cache only fills gaps,
613
+ * so a provider that does report a field always wins.
614
+ */
615
+ function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): CachedModel[] {
616
+ const prior = new Map(previous.map((m) => [m.id, m]));
617
+ return fetched.map((m) => {
618
+ const old = prior.get(m.id);
619
+ if (!old) return m;
620
+ return {
621
+ ...m,
622
+ contextWindow: m.contextWindow ?? old.contextWindow,
623
+ maxTokens: m.maxTokens ?? old.maxTokens,
624
+ reasoning: m.reasoning ?? old.reasoning,
625
+ input: m.input ?? old.input,
626
+ };
627
+ });
628
+ }
629
+
587
630
  function compatKey(key: string): string {
588
631
  return `compat-${key}`;
589
632
  }
@@ -718,8 +761,7 @@ export default async function (pi: ExtensionAPI) {
718
761
  if (key === "cloudflare_workers") {
719
762
  const entered = await ctx.ui.input(
720
763
  "Account ID",
721
- "Your Cloudflare Account ID (find it on the Cloudflare dashboard overview page):",
722
- ""
764
+ "Your Cloudflare Account ID (find it on the Cloudflare dashboard overview page):"
723
765
  );
724
766
  if (entered == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
725
767
  const accountId = entered.trim();
@@ -729,8 +771,7 @@ export default async function (pi: ExtensionAPI) {
729
771
  } else if (key === "cloudflare_ai_gateway") {
730
772
  const accountIdInput = await ctx.ui.input(
731
773
  "Account ID",
732
- "Your Cloudflare Account ID (find it on the Cloudflare dashboard overview page):",
733
- ""
774
+ "Your Cloudflare Account ID (find it on the Cloudflare dashboard overview page):"
734
775
  );
735
776
  if (accountIdInput == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
736
777
  const accountId = accountIdInput.trim();
@@ -738,8 +779,7 @@ export default async function (pi: ExtensionAPI) {
738
779
 
739
780
  const gatewayInput = await ctx.ui.input(
740
781
  "Gateway Name",
741
- "Your AI Gateway name/slug (find it under AI → AI Gateway in the Cloudflare dashboard):",
742
- ""
782
+ "Your AI Gateway name/slug (find it under AI → AI Gateway in the Cloudflare dashboard):"
743
783
  );
744
784
  if (gatewayInput == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
745
785
  const gatewaySlug = gatewayInput.trim();
@@ -747,8 +787,7 @@ export default async function (pi: ExtensionAPI) {
747
787
 
748
788
  const providerInput = await ctx.ui.input(
749
789
  "Provider",
750
- "Upstream provider slug (e.g. openai, workers-ai, anthropic — must match your gateway config):",
751
- "openai"
790
+ "Upstream provider slug (e.g. openai, workers-ai, anthropic — must match your gateway config):"
752
791
  );
753
792
  if (providerInput == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
754
793
  const provider = providerInput.trim() || "openai";
@@ -768,7 +807,7 @@ export default async function (pi: ExtensionAPI) {
768
807
  const prompt = isLocalUrl(defaultUrl)
769
808
  ? `Base URL — press Enter for default (${defaultUrl}):`
770
809
  : "Base URL of your endpoint (e.g. https://api.example.com/v1):";
771
- const entered = await ctx.ui.input("Base URL", prompt, defaultUrl);
810
+ const entered = await ctx.ui.input("Base URL", prompt);
772
811
  if (entered == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
773
812
  baseUrl = (entered.trim() || defaultUrl).replace(/\/+$/, "");
774
813
  if (!baseUrl) { ctx.ui.notify("Base URL cannot be empty.", "error"); return; }
@@ -780,7 +819,7 @@ export default async function (pi: ExtensionAPI) {
780
819
  const keyPrompt = tpl.keyHint
781
820
  ? `Your API key (required) — get it at ${tpl.keyHint}:`
782
821
  : "Your API key — leave blank if keyless:";
783
- const entered = await ctx.ui.input("API Key", keyPrompt, "");
822
+ const entered = await ctx.ui.input("API Key", keyPrompt);
784
823
  if (entered == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
785
824
  apiKey = entered.trim() || null;
786
825
  }
@@ -843,7 +882,7 @@ export default async function (pi: ExtensionAPI) {
843
882
 
844
883
  ctx.ui.notify(
845
884
  `${tpl.displayName} registered — ${models.length} model(s) added to /model.`,
846
- "success"
885
+ "info"
847
886
  );
848
887
  },
849
888
  });
@@ -896,7 +935,7 @@ export default async function (pi: ExtensionAPI) {
896
935
  if (models.length > 0) {
897
936
  // Only overwrite the cache on a successful, non-empty fetch — a
898
937
  // flaky refresh must never blank out a working provider's models.
899
- p.cachedModels = models;
938
+ p.cachedModels = mergeModelMetadata(p.cachedModels, models);
900
939
  saveConfig(config);
901
940
  registerProvider(pi, key, p);
902
941
  refreshed.push(`${p.displayName} (${models.length})`);
@@ -909,7 +948,7 @@ export default async function (pi: ExtensionAPI) {
909
948
  }
910
949
 
911
950
  if (refreshed.length > 0) {
912
- ctx.ui.notify(`Refreshed: ${refreshed.join(", ")}.`, "success");
951
+ ctx.ui.notify(`Refreshed: ${refreshed.join(", ")}.`, "info");
913
952
  }
914
953
  if (failed.length > 0) {
915
954
  ctx.ui.notify(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@billjr99/pi-openai-compat",
3
- "version": "1.1.22",
3
+ "version": "1.1.24",
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",