@billjr99/pi-openai-compat 1.1.28 → 1.1.30

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 CHANGED
@@ -66,7 +66,8 @@ If pi is already running when you install, type `/reload` first.
66
66
  | **TeamoRouter** | `https://api.teamorouter.com/v1` | `sk-teamo-...` key from teamorouter.com (teamorouter.com/docs) |
67
67
  | **GMI Cloud** | `https://api.gmi-serving.com/v1` | API key from console.gmicloud.ai → Organization Settings → API Keys |
68
68
  | **Token Harbor** | `https://tokenharbor.ai/v1` | `thk_live_...` Universal Key from tokenharbor.ai/dashboard/api-keys |
69
- | **Ollama (local)** | `http://localhost:11434/v1` | Keyless |
69
+ | **Atria ASI** | `https://api.atria-asi.ai/v1` | `atr_...` key from api.atria-asi.ai/console/keys (Google sign-in) |
70
+ | **Ollama (local)** | `http://localhost:11434/v1` (editable) | Optional bearer token; leave blank for a default local install |
70
71
  | **Ollama Cloud** | `https://ollama.com/v1` | Ollama Cloud API key from ollama.com |
71
72
  | **llmproxy** | `http://localhost:8080/v1` (editable) | Keyless by default; bearer token if your instance requires one |
72
73
  | **Custom** | Any URL you supply | Optional bearer token |
@@ -111,8 +112,12 @@ Three commands are available; `/compat-login` is the only one you need to get st
111
112
  Walks you through a short wizard:
112
113
 
113
114
  1. Select a provider from the list above (or choose Custom).
114
- 2. For Ollama and Custom, confirm or change the base URL.
115
- 3. Enter your API key (skipped for keyless providers like Ollama).
115
+ 2. For Ollama, llmproxy and Custom, confirm or change the base URL.
116
+ 3. Enter your API key. For the local templates the key is optional: press
117
+ Enter to skip it on a default install, or supply one if you have put the
118
+ server behind a reverse proxy or exposed it on your LAN. The prompt is
119
+ never skipped based on the hostname, because a `.local` or LAN address is
120
+ no guarantee that the endpoint is unauthenticated.
116
121
  4. The extension connects, fetches the model list from `/v1/models`, and
117
122
  registers the provider with pi.
118
123
 
@@ -200,8 +205,9 @@ Credentials and cached model lists are stored at:
200
205
  ~/.config/pi-openai-compat/config.json
201
206
  ```
202
207
 
203
- API keys are stored in plaintext. Protect the file with `chmod 600` if
204
- needed, or delete it to clear all saved credentials.
208
+ API keys are stored in plaintext, so the extension creates the file `0600` and
209
+ the directory `0700`, and tightens both on load if an older version left them
210
+ world-readable. Delete the file to clear all saved credentials.
205
211
 
206
212
  Each provider's `cachedModels` array holds one entry per model. Only `id` is
207
213
  required; the rest are optional and fall back to conservative defaults when the
@@ -215,9 +221,15 @@ provider's `/models` catalog does not report them:
215
221
  | `reasoning` | boolean | `false` | Enables pi's thinking mode for the model. |
216
222
  | `input` | `["text"]` or `["text","image"]` | `["text"]` | Modalities pi may send; `image` lets pi attach image blocks. Any other value is ignored. |
217
223
  | `thinkingLevelMap` | object | omitted | pi thinking-level remap, passed through to the registered model. Keys are pi thinking levels (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); string values are sent to the provider, `null` hides an unsupported level. See [pi's docs](https://pi.dev/docs/latest/models#thinking-level-map). |
218
- | `samplingParams` | object | omitted | Free-form object merged verbatim into every request body for the model (e.g. `top_k`, `min_p`, `presence_penalty`). Only OpenAI-compatible APIs apply it. |
224
+ | `samplingParams` | object | omitted | **Not currently applied.** Preserved in the config and passed to pi, but pi's `registerProvider` builds each model from a fixed field list that does not include it (verified against pi 0.73.1), so it has no effect today. Kept for forward compatibility. |
219
225
  | `compat` | object | omitted | OpenAI compatibility flags for the model (`thinkingFormat`, `chatTemplateKwargs`, `maxTokensField`, `supportsDeveloperRole`, …). See [pi's docs](https://pi.dev/docs/latest/models#openai-compatibility). |
220
226
 
227
+ `contextWindow` and `maxTokens` are validated before they reach pi: a value
228
+ that is not a positive finite number is ignored in favor of the default, and
229
+ anything above 10,000,000 (context) or 1,000,000 (output) is clamped. pi drives
230
+ its context accounting off these numbers, so an inflated value from a
231
+ misreporting or hostile catalog would otherwise inflate every request sent.
232
+
221
233
  Most catalogs report none of these beyond the ID, so aggregators and proxies
222
234
  (CLIProxyAPI, for example) register every model as a 128K, text-only,
223
235
  non-reasoning model. To correct that, edit the entry by hand and `/reload`:
package/add-provider.sh CHANGED
@@ -54,6 +54,18 @@ const BUILTIN = {
54
54
  keyless: false,
55
55
  keyHint: "console.gmicloud.ai → Organization Settings → API Keys (docs at docs.gmicloud.ai/inference-engine)",
56
56
  },
57
+ tokenharbor: {
58
+ displayName: "Token Harbor",
59
+ baseUrl: "https://tokenharbor.ai/v1",
60
+ keyless: false,
61
+ keyHint: "tokenharbor.ai/dashboard/api-keys (Universal Key, format thk_live_...; docs at tokenharbor.ai/docs)",
62
+ },
63
+ atria_asi: {
64
+ displayName: "Atria ASI",
65
+ baseUrl: "https://api.atria-asi.ai/v1",
66
+ keyless: false,
67
+ keyHint: "api.atria-asi.ai/console/keys (Google sign-in; keys look like atr_...; docs at api.atria-asi.ai/docs)",
68
+ },
57
69
  };
58
70
 
59
71
  // README "Auth" column text for the built-in providers, used only by patchreadme.
@@ -61,6 +73,8 @@ const BUILTIN_AUTH = {
61
73
  xkiro: "`sk-xt-...` key from the xKiro console at xkiro.com (docs.xkiro.com)",
62
74
  teamorouter: "`sk-teamo-...` key from teamorouter.com (teamorouter.com/docs)",
63
75
  gmi: "API key from console.gmicloud.ai → Organization Settings → API Keys",
76
+ tokenharbor: "`thk_live_...` Universal Key from tokenharbor.ai/dashboard/api-keys",
77
+ atria_asi: "`atr_...` key from api.atria-asi.ai/console/keys (Google sign-in)",
64
78
  };
65
79
 
66
80
  /**
@@ -150,8 +164,17 @@ function normalizeModels(body, idField, keepTask) {
150
164
  const rawId = m ? m[field] : undefined;
151
165
  const id = typeof rawId === "string" ? rawId : typeof rawId === "number" ? String(rawId) : "";
152
166
  const out = { id };
153
- if (m && m.context_window !== undefined) out.contextWindow = m.context_window;
154
- if (m && m.max_tokens !== undefined) out.maxTokens = m.max_tokens;
167
+ // Mirrors normalizeTokenCount() in index.ts: the catalog is untrusted,
168
+ // so a non-numeric or absurd token count is dropped/clamped rather than
169
+ // written into config.json.
170
+ const num = (v, max) =>
171
+ typeof v === "number" && Number.isFinite(v) && v > 0
172
+ ? Math.min(Math.floor(v), max)
173
+ : undefined;
174
+ const ctx = num(m ? m.context_window : undefined, 10000000);
175
+ const max = num(m ? m.max_tokens : undefined, 1000000);
176
+ if (ctx !== undefined) out.contextWindow = ctx;
177
+ if (max !== undefined) out.maxTokens = max;
155
178
  return out;
156
179
  })
157
180
  .filter((m) => Boolean(m.id))
package/index.ts CHANGED
@@ -70,16 +70,12 @@ interface ExtensionConfig {
70
70
  providers: Record<string, ProviderConfig>;
71
71
  }
72
72
 
73
- interface OpenAIModelsResponse {
74
- data: Array<{ id: string; context_window?: number; max_tokens?: number }>;
75
- }
76
-
77
73
  /** Loose shape for a single entry in any /models response. */
78
74
  type RawModel = {
79
75
  id?: string;
80
76
  name?: string;
81
- context_window?: number;
82
- max_tokens?: number;
77
+ context_window?: unknown;
78
+ max_tokens?: unknown;
83
79
  reasoning?: unknown;
84
80
  input?: unknown;
85
81
  task?: { name?: string };
@@ -91,7 +87,7 @@ type RawModel = {
91
87
  * default, rather than caching something pi would choke on (it calls
92
88
  * `model.input.includes("image")` at tool time).
93
89
  */
94
- function normalizeInput(value: unknown): ModelInput[] | undefined {
90
+ export function normalizeInput(value: unknown): ModelInput[] | undefined {
95
91
  if (!Array.isArray(value)) return undefined;
96
92
  const kept = value.filter(
97
93
  (v): v is ModelInput => v === "text" || v === "image",
@@ -99,13 +95,41 @@ function normalizeInput(value: unknown): ModelInput[] | undefined {
99
95
  return kept.length > 0 ? kept : undefined;
100
96
  }
101
97
 
98
+ // Upper bounds for token counts accepted from a provider catalog or a
99
+ // hand-edited config. pi drives context accounting off these numbers, so an
100
+ // absurd value inflates every request this extension sends; clamping keeps a
101
+ // misreporting or hostile catalog from turning into runaway token spend.
102
+ export const MAX_CONTEXT_WINDOW = 10_000_000;
103
+ export const MAX_OUTPUT_TOKENS = 1_000_000;
104
+
105
+ /**
106
+ * Coerce an untrusted token count into a sane positive integer. A `/models`
107
+ * payload is attacker-controlled for the purposes of this extension (it exists
108
+ * to connect to arbitrary third-party endpoints), and `??` only rejects null
109
+ * and undefined — a string or NaN would flow straight through into pi. Anything
110
+ * that is not a finite positive number yields undefined so the caller's default
111
+ * applies; anything larger than `max` is clamped rather than discarded.
112
+ */
113
+ export function normalizeTokenCount(value: unknown, max: number): number | undefined {
114
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
115
+ return undefined;
116
+ }
117
+ return Math.min(Math.floor(value), max);
118
+ }
119
+
102
120
  // ─────────────────────────────────────────────────────────────────────────────
103
121
  // Provider templates
104
122
  // ─────────────────────────────────────────────────────────────────────────────
105
123
 
106
- const TEMPLATES: Record<string, {
124
+ export const TEMPLATES: Record<string, {
107
125
  displayName: string;
108
126
  baseUrl: string;
127
+ /**
128
+ * True only for endpoints that cannot accept a key at all. Do not set it
129
+ * merely because a provider is usually run locally: a self-hosted server
130
+ * reachable over the LAN is commonly put behind a key, and skipping the
131
+ * prompt leaves the user no way to supply one.
132
+ */
109
133
  keyless: boolean;
110
134
  /** If set, only models whose id appears in this list are kept after fetching. */
111
135
  modelFilter?: string[];
@@ -301,9 +325,13 @@ const TEMPLATES: Record<string, {
301
325
  keyHint: "api.together.ai/settings/api-keys",
302
326
  },
303
327
  ollama: {
304
- displayName: "Ollama (local, keyless)",
328
+ displayName: "Ollama (local)",
305
329
  baseUrl: "http://localhost:11434/v1",
306
- keyless: true,
330
+ // Not keyless: Ollama is frequently exposed beyond loopback (a LAN or
331
+ // .local hostname) behind a reverse proxy that does require a key. The
332
+ // prompt says the key is optional, so a plain local install just presses
333
+ // Enter.
334
+ keyless: false,
307
335
  promptUrl: true,
308
336
  },
309
337
  ollama_cloud: {
@@ -315,7 +343,9 @@ const TEMPLATES: Record<string, {
315
343
  llmproxy: {
316
344
  displayName: "llmproxy (local)",
317
345
  baseUrl: "http://localhost:8080/v1",
318
- keyless: true,
346
+ // See the note on the ollama template: local by default, but reachable
347
+ // (and key-protected) off-host often enough that the prompt must appear.
348
+ keyless: false,
319
349
  promptUrl: true,
320
350
  },
321
351
  vercel: {
@@ -365,6 +395,16 @@ const TEMPLATES: Record<string, {
365
395
  // One "Universal Key" reaches every model; free models carry a ":free" id suffix.
366
396
  keyHint: "tokenharbor.ai/dashboard/api-keys (Universal Key, format thk_live_...; docs at tokenharbor.ai/docs)",
367
397
  },
398
+ atria_asi: {
399
+ displayName: "Atria ASI",
400
+ baseUrl: "https://api.atria-asi.ai/v1",
401
+ keyless: false,
402
+ // One model, Atria-Dawn-Preview: 256K context, text-only input. GET
403
+ // /v1/models is undocumented but registered and key-gated (it answers 401,
404
+ // where an unknown /v1 path answers 404), which is exactly what fetchModels
405
+ // already sends, so no modelsUrl or fallbackModels override is needed.
406
+ keyHint: "api.atria-asi.ai/console/keys (Google sign-in; keys look like atr_...; docs at api.atria-asi.ai/docs)",
407
+ },
368
408
  custom: {
369
409
  displayName: "Custom Endpoint",
370
410
  baseUrl: "",
@@ -380,11 +420,43 @@ const TEMPLATES: Record<string, {
380
420
  const CONFIG_DIR = path.join(os.homedir(), ".config", "pi-openai-compat");
381
421
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
382
422
 
423
+ // config.json holds every provider's API key in cleartext, so neither it nor
424
+ // its directory may be group- or world-readable. add-provider.sh already writes
425
+ // 0600; these constants keep the extension's own writes consistent with it.
426
+ export const CONFIG_DIR_MODE = 0o700;
427
+ export const CONFIG_FILE_MODE = 0o600;
428
+
429
+ /**
430
+ * Tighten a path's permissions when they are broader than `mode`. Needed
431
+ * because the `mode` option of writeFileSync/mkdirSync applies only at
432
+ * creation time: a config.json written at 0644 by an earlier version keeps
433
+ * that mode forever otherwise. A no-op on platforms without POSIX modes.
434
+ */
435
+ export function restrictPermissions(target: string, mode: number): void {
436
+ try {
437
+ const current = fs.statSync(target).mode & 0o777;
438
+ if ((current & ~mode) !== 0) fs.chmodSync(target, mode);
439
+ } catch (e) {
440
+ console.error(`[openai-compat:restrictPermissions] ${target}`, e);
441
+ }
442
+ }
443
+
444
+ /** Create the config directory if absent, and keep it owner-only either way. */
445
+ function ensureConfigDir(): void {
446
+ if (!fs.existsSync(CONFIG_DIR)) {
447
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: CONFIG_DIR_MODE });
448
+ return;
449
+ }
450
+ restrictPermissions(CONFIG_DIR, CONFIG_DIR_MODE);
451
+ }
452
+
383
453
  function loadConfig(): ExtensionConfig {
384
454
  const empty: ExtensionConfig = { previousModel: null, providers: {} };
385
455
  try {
386
- if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
456
+ ensureConfigDir();
387
457
  if (!fs.existsSync(CONFIG_PATH)) return empty;
458
+ // Repair a config written before the extension enforced 0600.
459
+ restrictPermissions(CONFIG_PATH, CONFIG_FILE_MODE);
388
460
 
389
461
  const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as Record<string, unknown>;
390
462
  const config: ExtensionConfig = {
@@ -408,8 +480,12 @@ function loadConfig(): ExtensionConfig {
408
480
 
409
481
  function saveConfig(config: ExtensionConfig): void {
410
482
  try {
411
- if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
412
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
483
+ ensureConfigDir();
484
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), {
485
+ encoding: "utf-8",
486
+ mode: CONFIG_FILE_MODE,
487
+ });
488
+ restrictPermissions(CONFIG_PATH, CONFIG_FILE_MODE);
413
489
  } catch (e) {
414
490
  console.error("[openai-compat:saveConfig]", e);
415
491
  }
@@ -503,7 +579,7 @@ function migrateDiscoveryFields(config: ExtensionConfig): { healed: string[]; st
503
579
  // Networking
504
580
  // ─────────────────────────────────────────────────────────────────────────────
505
581
 
506
- function isLocalUrl(url: string): boolean {
582
+ export function isLocalUrl(url: string): boolean {
507
583
  try {
508
584
  const { hostname } = new URL(url);
509
585
  return (
@@ -518,6 +594,9 @@ function isLocalUrl(url: string): boolean {
518
594
  }
519
595
  }
520
596
 
597
+ /** Cap on how much of an upstream error body is surfaced to the user. */
598
+ export const MAX_ERROR_BODY = 500;
599
+
521
600
  /** Optional per-provider overrides controlling how /models is fetched. */
522
601
  interface FetchOverrides {
523
602
  /** Full URL to fetch instead of `<baseUrl>/models`. */
@@ -528,7 +607,7 @@ interface FetchOverrides {
528
607
  keepTask?: string;
529
608
  }
530
609
 
531
- async function fetchModels(
610
+ export async function fetchModels(
532
611
  baseUrl: string,
533
612
  apiKey: string | null,
534
613
  overrides: FetchOverrides = {},
@@ -545,10 +624,16 @@ async function fetchModels(
545
624
 
546
625
  const resp = await fetch(url, { headers });
547
626
  if (!resp.ok) {
548
- // The Authorization header is never echoed here, so the error body is
549
- // safe to surface even though we include the upstream's full response.
627
+ // This extension never echoes the Authorization header, but the body is
628
+ // the upstream's: some gateways reflect parts of the submitted credential
629
+ // or internal identifiers into error payloads, and this text is rendered
630
+ // straight into the UI. Keep enough to diagnose, not enough to dump a
631
+ // credential-bearing page.
550
632
  const body = await resp.text().catch(() => "");
551
- throw new Error(`HTTP ${resp.status} from ${url}: ${body}`);
633
+ const snippet = body.length > MAX_ERROR_BODY
634
+ ? `${body.slice(0, MAX_ERROR_BODY)}… (truncated)`
635
+ : body;
636
+ throw new Error(`HTTP ${resp.status} from ${url}: ${snippet}`);
552
637
  }
553
638
 
554
639
  // Normalize the various shapes /models can return:
@@ -589,8 +674,8 @@ async function fetchModels(
589
674
  "";
590
675
  return {
591
676
  id,
592
- contextWindow: m.context_window,
593
- maxTokens: m.max_tokens,
677
+ contextWindow: normalizeTokenCount(m.context_window, MAX_CONTEXT_WINDOW),
678
+ maxTokens: normalizeTokenCount(m.max_tokens, MAX_OUTPUT_TOKENS),
594
679
  reasoning: typeof m.reasoning === "boolean" ? m.reasoning : undefined,
595
680
  input: normalizeInput(m.input),
596
681
  };
@@ -603,7 +688,7 @@ async function fetchModels(
603
688
  // Provider registration helpers
604
689
  // ─────────────────────────────────────────────────────────────────────────────
605
690
 
606
- function buildProviderModels(models: CachedModel[]) {
691
+ export function buildProviderModels(models: CachedModel[]) {
607
692
  return models.map((m) => {
608
693
  const id = m.id;
609
694
  return {
@@ -612,8 +697,10 @@ function buildProviderModels(models: CachedModel[]) {
612
697
  reasoning: m.reasoning ?? false,
613
698
  input: m.input ?? (["text"] as ModelInput[]),
614
699
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
615
- contextWindow: m.contextWindow ?? 128_000,
616
- maxTokens: m.maxTokens ?? 4_096,
700
+ // Re-validated here as well as at fetch time: cachedModels is a
701
+ // hand-editable file, so this is the single choke point pi sees.
702
+ contextWindow: normalizeTokenCount(m.contextWindow, MAX_CONTEXT_WINDOW) ?? 128_000,
703
+ maxTokens: normalizeTokenCount(m.maxTokens, MAX_OUTPUT_TOKENS) ?? 4_096,
617
704
  ...(m.thinkingLevelMap ? { thinkingLevelMap: m.thinkingLevelMap } : {}),
618
705
  ...(m.samplingParams ? { samplingParams: m.samplingParams } : {}),
619
706
  ...(m.compat ? { compat: m.compat } : {}),
@@ -627,7 +714,7 @@ function buildProviderModels(models: CachedModel[]) {
627
714
  * it. The fetched list decides which ids exist; the old cache only fills gaps,
628
715
  * so a provider that does report a field always wins.
629
716
  */
630
- function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): CachedModel[] {
717
+ export function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): CachedModel[] {
631
718
  const prior = new Map(previous.map((m) => [m.id, m]));
632
719
  return fetched.map((m) => {
633
720
  const old = prior.get(m.id);
@@ -645,20 +732,52 @@ function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): Ca
645
732
  });
646
733
  }
647
734
 
648
- function compatKey(key: string): string {
735
+ /**
736
+ * Stand-in API key for providers that genuinely use none. pi's registerProvider
737
+ * throws `"apiKey" or "oauth" is required when defining models` on a falsy
738
+ * value, which would abort the extension factory before any /compat-* command
739
+ * is registered — leaving no in-app way to repair the config.
740
+ */
741
+ export const KEYLESS_PLACEHOLDER = "unused";
742
+
743
+ export function compatKey(key: string): string {
649
744
  return `compat-${key}`;
650
745
  }
651
746
 
652
- function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
747
+ export function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
653
748
  pi.registerProvider(compatKey(key), {
654
749
  name: `compat/${key.replace(/_/g, "-")}`,
655
750
  baseUrl: p.baseUrl,
656
- apiKey: p.apiKey,
751
+ // pi rejects a provider that defines models unless apiKey is a non-empty
752
+ // string, so a genuinely keyless endpoint (Ollama, llmproxy) still needs a
753
+ // placeholder here. Deliberately not conditioned on the hostname: a .local
754
+ // or LAN host can be key-protected, which is what the wizard now asks
755
+ // about rather than inferring. Endpoints that ignore Authorization discard
756
+ // this; ones that require a key return a clean 401.
757
+ apiKey: p.apiKey ?? KEYLESS_PLACEHOLDER,
657
758
  api: "openai-completions" as const,
658
759
  models: buildProviderModels(p.cachedModels),
659
760
  });
660
761
  }
661
762
 
763
+ /**
764
+ * Register a provider, reporting failure instead of throwing.
765
+ *
766
+ * Every saved provider is registered before any command is registered, so an
767
+ * uncaught throw here would take down the whole extension — including the
768
+ * /compat-login and /compat-logout commands needed to fix whatever caused it.
769
+ * One malformed entry should cost the user that one provider, nothing more.
770
+ */
771
+ export function tryRegisterProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): boolean {
772
+ try {
773
+ registerProvider(pi, key, p);
774
+ return true;
775
+ } catch (e) {
776
+ console.error(`[openai-compat:tryRegisterProvider] provider "${key}"`, e);
777
+ return false;
778
+ }
779
+ }
780
+
662
781
  // ─────────────────────────────────────────────────────────────────────────────
663
782
  // Extension — async factory so registration completes before pi shows /model
664
783
  // ─────────────────────────────────────────────────────────────────────────────
@@ -676,7 +795,7 @@ export default async function (pi: ExtensionAPI) {
676
795
  // continues — providers are visible in /model from the very first render.
677
796
  for (const [key, p] of Object.entries(config.providers)) {
678
797
  if (p.cachedModels.length > 0) {
679
- registerProvider(pi, key, p);
798
+ tryRegisterProvider(pi, key, p);
680
799
  }
681
800
  }
682
801
 
@@ -697,8 +816,8 @@ export default async function (pi: ExtensionAPI) {
697
816
  for (const [key, p] of Object.entries(config.providers)) {
698
817
  if (p.cachedModels.length > 0) {
699
818
  // Use cached list — fast, no network call.
700
- registerProvider(pi, key, p);
701
- registered.push(p.displayName);
819
+ if (tryRegisterProvider(pi, key, p)) registered.push(p.displayName);
820
+ else failed.push(p.displayName);
702
821
  } else {
703
822
  // Cache is empty (e.g. migrated from older config). Try a live fetch,
704
823
  // honoring any per-provider discovery overrides stored on the config.
@@ -711,8 +830,11 @@ export default async function (pi: ExtensionAPI) {
711
830
  if (models.length > 0) {
712
831
  p.cachedModels = models;
713
832
  saveConfig(config);
714
- registerProvider(pi, key, p);
715
- registered.push(`${p.displayName} (refreshed)`);
833
+ if (tryRegisterProvider(pi, key, p)) {
834
+ registered.push(`${p.displayName} (refreshed)`);
835
+ } else {
836
+ failed.push(p.displayName);
837
+ }
716
838
  } else {
717
839
  failed.push(p.displayName);
718
840
  }
@@ -893,7 +1015,14 @@ export default async function (pi: ExtensionAPI) {
893
1015
  modelsKeepTask: tpl.modelsKeepTask,
894
1016
  };
895
1017
  saveConfig(config);
896
- registerProvider(pi, key, config.providers[key]);
1018
+ if (!tryRegisterProvider(pi, key, config.providers[key])) {
1019
+ ctx.ui.notify(
1020
+ `${tpl.displayName} was saved but could not be registered with pi. ` +
1021
+ `Run /compat-logout to remove it, or check the log for details.`,
1022
+ "error"
1023
+ );
1024
+ return;
1025
+ }
897
1026
 
898
1027
  ctx.ui.notify(
899
1028
  `${tpl.displayName} registered — ${models.length} model(s) added to /model.`,
@@ -952,8 +1081,11 @@ export default async function (pi: ExtensionAPI) {
952
1081
  // flaky refresh must never blank out a working provider's models.
953
1082
  p.cachedModels = mergeModelMetadata(p.cachedModels, models);
954
1083
  saveConfig(config);
955
- registerProvider(pi, key, p);
956
- refreshed.push(`${p.displayName} (${models.length})`);
1084
+ if (tryRegisterProvider(pi, key, p)) {
1085
+ refreshed.push(`${p.displayName} (${models.length})`);
1086
+ } else {
1087
+ failed.push(p.displayName);
1088
+ }
957
1089
  } else {
958
1090
  failed.push(p.displayName);
959
1091
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@billjr99/pi-openai-compat",
3
- "version": "1.1.28",
3
+ "version": "1.1.30",
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",
@@ -23,6 +23,12 @@
23
23
  ]
24
24
  },
25
25
  "devDependencies": {
26
+ "@mariozechner/pi-coding-agent": "^0.73.1",
27
+ "@types/node": "^24.0.0",
26
28
  "typescript": "^6.0.3"
29
+ },
30
+ "scripts": {
31
+ "typecheck": "tsc -p tsconfig.json",
32
+ "test": "node --test test/index.test.ts"
27
33
  }
28
34
  }