@billjr99/pi-openai-compat 1.1.28 → 1.1.29

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,7 @@ 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
+ | **Ollama (local)** | `http://localhost:11434/v1` (editable) | Optional bearer token; leave blank for a default local install |
70
70
  | **Ollama Cloud** | `https://ollama.com/v1` | Ollama Cloud API key from ollama.com |
71
71
  | **llmproxy** | `http://localhost:8080/v1` (editable) | Keyless by default; bearer token if your instance requires one |
72
72
  | **Custom** | Any URL you supply | Optional bearer token |
@@ -111,8 +111,12 @@ Three commands are available; `/compat-login` is the only one you need to get st
111
111
  Walks you through a short wizard:
112
112
 
113
113
  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).
114
+ 2. For Ollama, llmproxy and Custom, confirm or change the base URL.
115
+ 3. Enter your API key. For the local templates the key is optional: press
116
+ Enter to skip it on a default install, or supply one if you have put the
117
+ server behind a reverse proxy or exposed it on your LAN. The prompt is
118
+ never skipped based on the hostname, because a `.local` or LAN address is
119
+ no guarantee that the endpoint is unauthenticated.
116
120
  4. The extension connects, fetches the model list from `/v1/models`, and
117
121
  registers the provider with pi.
118
122
 
@@ -200,8 +204,9 @@ Credentials and cached model lists are stored at:
200
204
  ~/.config/pi-openai-compat/config.json
201
205
  ```
202
206
 
203
- API keys are stored in plaintext. Protect the file with `chmod 600` if
204
- needed, or delete it to clear all saved credentials.
207
+ API keys are stored in plaintext, so the extension creates the file `0600` and
208
+ the directory `0700`, and tightens both on load if an older version left them
209
+ world-readable. Delete the file to clear all saved credentials.
205
210
 
206
211
  Each provider's `cachedModels` array holds one entry per model. Only `id` is
207
212
  required; the rest are optional and fall back to conservative defaults when the
@@ -215,9 +220,15 @@ provider's `/models` catalog does not report them:
215
220
  | `reasoning` | boolean | `false` | Enables pi's thinking mode for the model. |
216
221
  | `input` | `["text"]` or `["text","image"]` | `["text"]` | Modalities pi may send; `image` lets pi attach image blocks. Any other value is ignored. |
217
222
  | `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. |
223
+ | `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
224
  | `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
225
 
226
+ `contextWindow` and `maxTokens` are validated before they reach pi: a value
227
+ that is not a positive finite number is ignored in favor of the default, and
228
+ anything above 10,000,000 (context) or 1,000,000 (output) is clamped. pi drives
229
+ its context accounting off these numbers, so an inflated value from a
230
+ misreporting or hostile catalog would otherwise inflate every request sent.
231
+
221
232
  Most catalogs report none of these beyond the ID, so aggregators and proxies
222
233
  (CLIProxyAPI, for example) register every model as a 128K, text-only,
223
234
  non-reasoning model. To correct that, edit the entry by hand and `/reload`:
package/add-provider.sh CHANGED
@@ -150,8 +150,17 @@ function normalizeModels(body, idField, keepTask) {
150
150
  const rawId = m ? m[field] : undefined;
151
151
  const id = typeof rawId === "string" ? rawId : typeof rawId === "number" ? String(rawId) : "";
152
152
  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;
153
+ // Mirrors normalizeTokenCount() in index.ts: the catalog is untrusted,
154
+ // so a non-numeric or absurd token count is dropped/clamped rather than
155
+ // written into config.json.
156
+ const num = (v, max) =>
157
+ typeof v === "number" && Number.isFinite(v) && v > 0
158
+ ? Math.min(Math.floor(v), max)
159
+ : undefined;
160
+ const ctx = num(m ? m.context_window : undefined, 10000000);
161
+ const max = num(m ? m.max_tokens : undefined, 1000000);
162
+ if (ctx !== undefined) out.contextWindow = ctx;
163
+ if (max !== undefined) out.maxTokens = max;
155
164
  return out;
156
165
  })
157
166
  .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: {
@@ -380,11 +410,43 @@ const TEMPLATES: Record<string, {
380
410
  const CONFIG_DIR = path.join(os.homedir(), ".config", "pi-openai-compat");
381
411
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
382
412
 
413
+ // config.json holds every provider's API key in cleartext, so neither it nor
414
+ // its directory may be group- or world-readable. add-provider.sh already writes
415
+ // 0600; these constants keep the extension's own writes consistent with it.
416
+ export const CONFIG_DIR_MODE = 0o700;
417
+ export const CONFIG_FILE_MODE = 0o600;
418
+
419
+ /**
420
+ * Tighten a path's permissions when they are broader than `mode`. Needed
421
+ * because the `mode` option of writeFileSync/mkdirSync applies only at
422
+ * creation time: a config.json written at 0644 by an earlier version keeps
423
+ * that mode forever otherwise. A no-op on platforms without POSIX modes.
424
+ */
425
+ export function restrictPermissions(target: string, mode: number): void {
426
+ try {
427
+ const current = fs.statSync(target).mode & 0o777;
428
+ if ((current & ~mode) !== 0) fs.chmodSync(target, mode);
429
+ } catch (e) {
430
+ console.error(`[openai-compat:restrictPermissions] ${target}`, e);
431
+ }
432
+ }
433
+
434
+ /** Create the config directory if absent, and keep it owner-only either way. */
435
+ function ensureConfigDir(): void {
436
+ if (!fs.existsSync(CONFIG_DIR)) {
437
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: CONFIG_DIR_MODE });
438
+ return;
439
+ }
440
+ restrictPermissions(CONFIG_DIR, CONFIG_DIR_MODE);
441
+ }
442
+
383
443
  function loadConfig(): ExtensionConfig {
384
444
  const empty: ExtensionConfig = { previousModel: null, providers: {} };
385
445
  try {
386
- if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
446
+ ensureConfigDir();
387
447
  if (!fs.existsSync(CONFIG_PATH)) return empty;
448
+ // Repair a config written before the extension enforced 0600.
449
+ restrictPermissions(CONFIG_PATH, CONFIG_FILE_MODE);
388
450
 
389
451
  const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as Record<string, unknown>;
390
452
  const config: ExtensionConfig = {
@@ -408,8 +470,12 @@ function loadConfig(): ExtensionConfig {
408
470
 
409
471
  function saveConfig(config: ExtensionConfig): void {
410
472
  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");
473
+ ensureConfigDir();
474
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), {
475
+ encoding: "utf-8",
476
+ mode: CONFIG_FILE_MODE,
477
+ });
478
+ restrictPermissions(CONFIG_PATH, CONFIG_FILE_MODE);
413
479
  } catch (e) {
414
480
  console.error("[openai-compat:saveConfig]", e);
415
481
  }
@@ -503,7 +569,7 @@ function migrateDiscoveryFields(config: ExtensionConfig): { healed: string[]; st
503
569
  // Networking
504
570
  // ─────────────────────────────────────────────────────────────────────────────
505
571
 
506
- function isLocalUrl(url: string): boolean {
572
+ export function isLocalUrl(url: string): boolean {
507
573
  try {
508
574
  const { hostname } = new URL(url);
509
575
  return (
@@ -518,6 +584,9 @@ function isLocalUrl(url: string): boolean {
518
584
  }
519
585
  }
520
586
 
587
+ /** Cap on how much of an upstream error body is surfaced to the user. */
588
+ export const MAX_ERROR_BODY = 500;
589
+
521
590
  /** Optional per-provider overrides controlling how /models is fetched. */
522
591
  interface FetchOverrides {
523
592
  /** Full URL to fetch instead of `<baseUrl>/models`. */
@@ -528,7 +597,7 @@ interface FetchOverrides {
528
597
  keepTask?: string;
529
598
  }
530
599
 
531
- async function fetchModels(
600
+ export async function fetchModels(
532
601
  baseUrl: string,
533
602
  apiKey: string | null,
534
603
  overrides: FetchOverrides = {},
@@ -545,10 +614,16 @@ async function fetchModels(
545
614
 
546
615
  const resp = await fetch(url, { headers });
547
616
  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.
617
+ // This extension never echoes the Authorization header, but the body is
618
+ // the upstream's: some gateways reflect parts of the submitted credential
619
+ // or internal identifiers into error payloads, and this text is rendered
620
+ // straight into the UI. Keep enough to diagnose, not enough to dump a
621
+ // credential-bearing page.
550
622
  const body = await resp.text().catch(() => "");
551
- throw new Error(`HTTP ${resp.status} from ${url}: ${body}`);
623
+ const snippet = body.length > MAX_ERROR_BODY
624
+ ? `${body.slice(0, MAX_ERROR_BODY)}… (truncated)`
625
+ : body;
626
+ throw new Error(`HTTP ${resp.status} from ${url}: ${snippet}`);
552
627
  }
553
628
 
554
629
  // Normalize the various shapes /models can return:
@@ -589,8 +664,8 @@ async function fetchModels(
589
664
  "";
590
665
  return {
591
666
  id,
592
- contextWindow: m.context_window,
593
- maxTokens: m.max_tokens,
667
+ contextWindow: normalizeTokenCount(m.context_window, MAX_CONTEXT_WINDOW),
668
+ maxTokens: normalizeTokenCount(m.max_tokens, MAX_OUTPUT_TOKENS),
594
669
  reasoning: typeof m.reasoning === "boolean" ? m.reasoning : undefined,
595
670
  input: normalizeInput(m.input),
596
671
  };
@@ -603,7 +678,7 @@ async function fetchModels(
603
678
  // Provider registration helpers
604
679
  // ─────────────────────────────────────────────────────────────────────────────
605
680
 
606
- function buildProviderModels(models: CachedModel[]) {
681
+ export function buildProviderModels(models: CachedModel[]) {
607
682
  return models.map((m) => {
608
683
  const id = m.id;
609
684
  return {
@@ -612,8 +687,10 @@ function buildProviderModels(models: CachedModel[]) {
612
687
  reasoning: m.reasoning ?? false,
613
688
  input: m.input ?? (["text"] as ModelInput[]),
614
689
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
615
- contextWindow: m.contextWindow ?? 128_000,
616
- maxTokens: m.maxTokens ?? 4_096,
690
+ // Re-validated here as well as at fetch time: cachedModels is a
691
+ // hand-editable file, so this is the single choke point pi sees.
692
+ contextWindow: normalizeTokenCount(m.contextWindow, MAX_CONTEXT_WINDOW) ?? 128_000,
693
+ maxTokens: normalizeTokenCount(m.maxTokens, MAX_OUTPUT_TOKENS) ?? 4_096,
617
694
  ...(m.thinkingLevelMap ? { thinkingLevelMap: m.thinkingLevelMap } : {}),
618
695
  ...(m.samplingParams ? { samplingParams: m.samplingParams } : {}),
619
696
  ...(m.compat ? { compat: m.compat } : {}),
@@ -627,7 +704,7 @@ function buildProviderModels(models: CachedModel[]) {
627
704
  * it. The fetched list decides which ids exist; the old cache only fills gaps,
628
705
  * so a provider that does report a field always wins.
629
706
  */
630
- function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): CachedModel[] {
707
+ export function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): CachedModel[] {
631
708
  const prior = new Map(previous.map((m) => [m.id, m]));
632
709
  return fetched.map((m) => {
633
710
  const old = prior.get(m.id);
@@ -645,20 +722,52 @@ function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): Ca
645
722
  });
646
723
  }
647
724
 
648
- function compatKey(key: string): string {
725
+ /**
726
+ * Stand-in API key for providers that genuinely use none. pi's registerProvider
727
+ * throws `"apiKey" or "oauth" is required when defining models` on a falsy
728
+ * value, which would abort the extension factory before any /compat-* command
729
+ * is registered — leaving no in-app way to repair the config.
730
+ */
731
+ export const KEYLESS_PLACEHOLDER = "unused";
732
+
733
+ export function compatKey(key: string): string {
649
734
  return `compat-${key}`;
650
735
  }
651
736
 
652
- function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
737
+ export function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
653
738
  pi.registerProvider(compatKey(key), {
654
739
  name: `compat/${key.replace(/_/g, "-")}`,
655
740
  baseUrl: p.baseUrl,
656
- apiKey: p.apiKey,
741
+ // pi rejects a provider that defines models unless apiKey is a non-empty
742
+ // string, so a genuinely keyless endpoint (Ollama, llmproxy) still needs a
743
+ // placeholder here. Deliberately not conditioned on the hostname: a .local
744
+ // or LAN host can be key-protected, which is what the wizard now asks
745
+ // about rather than inferring. Endpoints that ignore Authorization discard
746
+ // this; ones that require a key return a clean 401.
747
+ apiKey: p.apiKey ?? KEYLESS_PLACEHOLDER,
657
748
  api: "openai-completions" as const,
658
749
  models: buildProviderModels(p.cachedModels),
659
750
  });
660
751
  }
661
752
 
753
+ /**
754
+ * Register a provider, reporting failure instead of throwing.
755
+ *
756
+ * Every saved provider is registered before any command is registered, so an
757
+ * uncaught throw here would take down the whole extension — including the
758
+ * /compat-login and /compat-logout commands needed to fix whatever caused it.
759
+ * One malformed entry should cost the user that one provider, nothing more.
760
+ */
761
+ export function tryRegisterProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): boolean {
762
+ try {
763
+ registerProvider(pi, key, p);
764
+ return true;
765
+ } catch (e) {
766
+ console.error(`[openai-compat:tryRegisterProvider] provider "${key}"`, e);
767
+ return false;
768
+ }
769
+ }
770
+
662
771
  // ─────────────────────────────────────────────────────────────────────────────
663
772
  // Extension — async factory so registration completes before pi shows /model
664
773
  // ─────────────────────────────────────────────────────────────────────────────
@@ -676,7 +785,7 @@ export default async function (pi: ExtensionAPI) {
676
785
  // continues — providers are visible in /model from the very first render.
677
786
  for (const [key, p] of Object.entries(config.providers)) {
678
787
  if (p.cachedModels.length > 0) {
679
- registerProvider(pi, key, p);
788
+ tryRegisterProvider(pi, key, p);
680
789
  }
681
790
  }
682
791
 
@@ -697,8 +806,8 @@ export default async function (pi: ExtensionAPI) {
697
806
  for (const [key, p] of Object.entries(config.providers)) {
698
807
  if (p.cachedModels.length > 0) {
699
808
  // Use cached list — fast, no network call.
700
- registerProvider(pi, key, p);
701
- registered.push(p.displayName);
809
+ if (tryRegisterProvider(pi, key, p)) registered.push(p.displayName);
810
+ else failed.push(p.displayName);
702
811
  } else {
703
812
  // Cache is empty (e.g. migrated from older config). Try a live fetch,
704
813
  // honoring any per-provider discovery overrides stored on the config.
@@ -711,8 +820,11 @@ export default async function (pi: ExtensionAPI) {
711
820
  if (models.length > 0) {
712
821
  p.cachedModels = models;
713
822
  saveConfig(config);
714
- registerProvider(pi, key, p);
715
- registered.push(`${p.displayName} (refreshed)`);
823
+ if (tryRegisterProvider(pi, key, p)) {
824
+ registered.push(`${p.displayName} (refreshed)`);
825
+ } else {
826
+ failed.push(p.displayName);
827
+ }
716
828
  } else {
717
829
  failed.push(p.displayName);
718
830
  }
@@ -893,7 +1005,14 @@ export default async function (pi: ExtensionAPI) {
893
1005
  modelsKeepTask: tpl.modelsKeepTask,
894
1006
  };
895
1007
  saveConfig(config);
896
- registerProvider(pi, key, config.providers[key]);
1008
+ if (!tryRegisterProvider(pi, key, config.providers[key])) {
1009
+ ctx.ui.notify(
1010
+ `${tpl.displayName} was saved but could not be registered with pi. ` +
1011
+ `Run /compat-logout to remove it, or check the log for details.`,
1012
+ "error"
1013
+ );
1014
+ return;
1015
+ }
897
1016
 
898
1017
  ctx.ui.notify(
899
1018
  `${tpl.displayName} registered — ${models.length} model(s) added to /model.`,
@@ -952,8 +1071,11 @@ export default async function (pi: ExtensionAPI) {
952
1071
  // flaky refresh must never blank out a working provider's models.
953
1072
  p.cachedModels = mergeModelMetadata(p.cachedModels, models);
954
1073
  saveConfig(config);
955
- registerProvider(pi, key, p);
956
- refreshed.push(`${p.displayName} (${models.length})`);
1074
+ if (tryRegisterProvider(pi, key, p)) {
1075
+ refreshed.push(`${p.displayName} (${models.length})`);
1076
+ } else {
1077
+ failed.push(p.displayName);
1078
+ }
957
1079
  } else {
958
1080
  failed.push(p.displayName);
959
1081
  }
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.29",
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
  }