@billjr99/pi-openai-compat 1.0.0

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 +199 -0
  2. package/index.ts +377 -0
  3. package/package.json +23 -0
package/README.md ADDED
@@ -0,0 +1,199 @@
1
+ # @billjr99/pi-openai-compat — pi-coding-agent extension
2
+
3
+ Registers OpenAI-compatible LLM endpoints as first-class providers inside
4
+ [pi](https://pi.dev), so their models appear directly in pi's native `/model`
5
+ list and `Ctrl+L` picker alongside built-in Anthropic, OpenAI, and Google
6
+ models. No custom model selection UI — pi handles it natively.
7
+
8
+ Multiple providers can be registered at the same time. All of their models
9
+ appear together in `/model` under their own provider label.
10
+
11
+ ---
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ # Install from npm
17
+ pi install npm:@billjr99/pi-openai-compat
18
+
19
+ # Or from GitHub
20
+ pi install git:github.com/BillJr99/pi-openai-compat
21
+ ```
22
+
23
+ Then inside pi:
24
+
25
+ ```
26
+ /compat-login
27
+ ```
28
+
29
+ Pick your provider, enter credentials, and pi fetches the model list
30
+ automatically. Your models are immediately available in `/model`.
31
+
32
+ If pi is already running when you install, type `/reload` first.
33
+
34
+ ---
35
+
36
+ ## Supported providers
37
+
38
+ | Provider | Default base URL | Auth |
39
+ |---|---|---|
40
+ | **OpenRouter** | `https://openrouter.ai/api/v1` | `sk-or-...` from openrouter.ai/keys |
41
+ | **NVIDIA NIM** | `https://integrate.api.nvidia.com/v1` | `nvapi-...` from build.nvidia.com |
42
+ | **Nous Research Portal** | `https://inference-api.nousresearch.com/v1` | Nous Portal API key |
43
+ | **Ollama (local)** | `http://localhost:11434/v1` | Keyless |
44
+ | **Custom** | Any URL you supply | Optional bearer token |
45
+
46
+ ---
47
+
48
+ ## Commands
49
+
50
+ Only two commands are needed.
51
+
52
+ ### `/compat-login`
53
+
54
+ Walks you through a short wizard:
55
+
56
+ 1. Select a provider from the list above (or choose Custom).
57
+ 2. For Ollama and Custom, confirm or change the base URL.
58
+ 3. Enter your API key (skipped for keyless providers like Ollama).
59
+ 4. The extension connects, fetches the model list from `/v1/models`, and
60
+ registers the provider with pi.
61
+
62
+ After login, the provider's models appear in pi's `/model` command and
63
+ `Ctrl+L` picker immediately. You can run `/compat-login` again to add a
64
+ second provider — all providers are active simultaneously.
65
+
66
+ ### `/compat-logout`
67
+
68
+ Unregisters a provider from pi. If you have multiple providers registered,
69
+ you are asked which one to remove. If you have only one, you are asked to
70
+ confirm.
71
+
72
+ When the last compat provider is removed, the extension restores whichever
73
+ built-in model you were using before you added any compat providers.
74
+
75
+ ---
76
+
77
+ ## Typical workflow
78
+
79
+ ```
80
+ /compat-login
81
+ → pick Ollama
82
+ → press Enter to accept http://localhost:11434/v1
83
+ → 3 models added to /model
84
+
85
+ /model
86
+ → scroll to ollama/gemma4:latest
87
+ → select it
88
+
89
+ (start chatting)
90
+
91
+ /compat-logout
92
+ → confirm remove Ollama
93
+ → previous model restored automatically
94
+ ```
95
+
96
+ To add OpenRouter alongside Ollama:
97
+
98
+ ```
99
+ /compat-login
100
+ → pick OpenRouter
101
+ → enter API key
102
+ → 300+ models added to /model alongside your Ollama models
103
+ ```
104
+
105
+ ---
106
+
107
+ ## How it works
108
+
109
+ The extension uses pi's `registerProvider` API to register each configured
110
+ endpoint as a named provider. This is the same mechanism pi uses internally
111
+ for Anthropic, OpenAI, and Google. Registered providers appear in `/model`
112
+ and `Ctrl+L` with their own label, and pi handles all model selection,
113
+ routing, and request formatting natively.
114
+
115
+ On unregistration, pi's built-in `unregisterProvider` restores the original
116
+ model list automatically.
117
+
118
+ Model lists are cached in `config.json` at startup so no network call is
119
+ needed to re-register providers when pi restarts.
120
+
121
+ ---
122
+
123
+ ## Config file
124
+
125
+ Credentials and cached model lists are stored at:
126
+
127
+ ```
128
+ ~/.config/pi-openai-compat/config.json
129
+ ```
130
+
131
+ API keys are stored in plaintext. Protect the file with `chmod 600` if
132
+ needed, or delete it to clear all saved credentials.
133
+
134
+ ---
135
+
136
+ ## Publishing to npmjs.com
137
+
138
+ ### First-time setup
139
+
140
+ 1. Register at https://www.npmjs.com/signup — use `billjr99` as your username
141
+ to match the `@billjr99/` package scope.
142
+ 2. Verify your email address (required before publishing).
143
+ 3. Enable two-factor authentication under your npm account settings.
144
+ 4. Log in from the command line:
145
+
146
+ ```bash
147
+ npm login
148
+ ```
149
+
150
+ ### Publishing
151
+
152
+ ```bash
153
+ cd /path/to/pi-openai-compat
154
+ npm publish --access public
155
+ ```
156
+
157
+ `--access public` is required for scoped packages on the first publish.
158
+ Subsequent publishes do not need it.
159
+
160
+ The package is immediately available at
161
+ https://www.npmjs.com/package/@billjr99/pi-openai-compat.
162
+
163
+ ### Releasing a new version
164
+
165
+ ```bash
166
+ npm version patch # 1.0.0 → 1.0.1 (bug fixes)
167
+ npm version minor # 1.0.0 → 1.1.0 (new features)
168
+ npm version major # 1.0.0 → 2.0.0 (breaking changes)
169
+ npm publish --access public
170
+ ```
171
+
172
+ `npm version` also creates a git tag, so GitHub gets release tags automatically.
173
+
174
+ ### Installing from npm
175
+
176
+ ```bash
177
+ pi install npm:@billjr99/pi-openai-compat # latest
178
+ pi install npm:@billjr99/pi-openai-compat@1.0.0 # pinned version
179
+ pi update npm:@billjr99/pi-openai-compat # update to latest
180
+ pi remove npm:@billjr99/pi-openai-compat # uninstall
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Troubleshooting
186
+
187
+ **Connection fails during `/compat-login`**
188
+ Verify the base URL does not have a trailing slash and ends with `/v1`.
189
+ Confirm the API key is correct and has model-access permissions.
190
+ For Ollama: ensure `ollama serve` is running.
191
+
192
+ **No models appear after login**
193
+ For Ollama: pull at least one model first (`ollama pull llama3`).
194
+ For OpenRouter: some keys are restricted to free-tier models only.
195
+ For NIM: confirm your account has inference access enabled.
196
+
197
+ **Models appear in `/model` but requests fail**
198
+ Check `/compat-login` ran successfully (no error message).
199
+ Verify Ollama is still running if using a local endpoint.
package/index.ts ADDED
@@ -0,0 +1,377 @@
1
+ /**
2
+ * @billjr99/pi-openai-compat — pi-coding-agent extension
3
+ *
4
+ * Registers OpenAI-compatible LLM endpoints as first-class pi providers so
5
+ * their models appear in pi's native /model list and Ctrl+L picker.
6
+ *
7
+ * Design:
8
+ * - Every provider saved in config.json is registered automatically at
9
+ * startup. The factory is async so pi waits for registration to complete
10
+ * before showing the model list — no session_start delay.
11
+ * - /compat-login adds a provider (fetches fresh model list, registers).
12
+ * - /compat-logout removes a provider (unregisters, restores previous model).
13
+ * - No activeProviders list — presence in config.providers means registered.
14
+ *
15
+ * Config: ~/.config/pi-openai-compat/config.json
16
+ */
17
+
18
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import * as os from "node:os";
22
+
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+ // Types
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+
27
+ interface CachedModel {
28
+ id: string;
29
+ contextWindow?: number;
30
+ maxTokens?: number;
31
+ }
32
+
33
+ interface ProviderConfig {
34
+ displayName: string;
35
+ baseUrl: string;
36
+ apiKey: string | null;
37
+ cachedModels: CachedModel[];
38
+ }
39
+
40
+ interface ExtensionConfig {
41
+ /** Last non-compat model; restored when the last compat provider is removed. */
42
+ previousModel: { provider: string; id: string } | null;
43
+ /** Every key here is a registered provider. Presence = registered. */
44
+ providers: Record<string, ProviderConfig>;
45
+ }
46
+
47
+ interface OpenAIModelsResponse {
48
+ data: Array<{ id: string; context_window?: number; max_tokens?: number }>;
49
+ }
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+ // Provider templates
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+
55
+ const TEMPLATES: Record<string, {
56
+ displayName: string;
57
+ baseUrl: string;
58
+ keyless: boolean;
59
+ keyHint?: string;
60
+ }> = {
61
+ openrouter: {
62
+ displayName: "OpenRouter",
63
+ baseUrl: "https://openrouter.ai/api/v1",
64
+ keyless: false,
65
+ keyHint: "sk-or-... from openrouter.ai/keys",
66
+ },
67
+ nvidia_nim: {
68
+ displayName: "NVIDIA NIM",
69
+ baseUrl: "https://integrate.api.nvidia.com/v1",
70
+ keyless: false,
71
+ keyHint: "nvapi-... from build.nvidia.com",
72
+ },
73
+ nous: {
74
+ displayName: "Nous Research Portal",
75
+ baseUrl: "https://inference-api.nousresearch.com/v1",
76
+ keyless: false,
77
+ keyHint: "Your Nous Portal API key",
78
+ },
79
+ ollama: {
80
+ displayName: "Ollama (local, keyless)",
81
+ baseUrl: "http://localhost:11434/v1",
82
+ keyless: true,
83
+ },
84
+ custom: {
85
+ displayName: "Custom Endpoint",
86
+ baseUrl: "",
87
+ keyless: false,
88
+ keyHint: "Bearer token or API key (leave blank if keyless)",
89
+ },
90
+ };
91
+
92
+ // ─────────────────────────────────────────────────────────────────────────────
93
+ // Config
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+
96
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "pi-openai-compat");
97
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
98
+
99
+ function loadConfig(): ExtensionConfig {
100
+ const empty: ExtensionConfig = { previousModel: null, providers: {} };
101
+ try {
102
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
103
+ if (!fs.existsSync(CONFIG_PATH)) return empty;
104
+
105
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as Record<string, unknown>;
106
+ const config: ExtensionConfig = {
107
+ previousModel: (raw.previousModel as any) ?? null,
108
+ providers: (raw.providers as Record<string, ProviderConfig>) ?? {},
109
+ };
110
+
111
+ // Migrate: cachedModels missing on older records.
112
+ for (const key of Object.keys(config.providers)) {
113
+ if (!Array.isArray(config.providers[key].cachedModels)) {
114
+ config.providers[key].cachedModels = [];
115
+ }
116
+ }
117
+
118
+ return config;
119
+ } catch (e) {
120
+ console.error("[openai-compat:loadConfig]", e);
121
+ return empty;
122
+ }
123
+ }
124
+
125
+ function saveConfig(config: ExtensionConfig): void {
126
+ try {
127
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
128
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
129
+ } catch (e) {
130
+ console.error("[openai-compat:saveConfig]", e);
131
+ }
132
+ }
133
+
134
+ // ─────────────────────────────────────────────────────────────────────────────
135
+ // Networking
136
+ // ─────────────────────────────────────────────────────────────────────────────
137
+
138
+ async function fetchModels(baseUrl: string, apiKey: string | null): Promise<CachedModel[]> {
139
+ const url = `${baseUrl.replace(/\/+$/, "")}/models`;
140
+ const headers: Record<string, string> = { Accept: "application/json" };
141
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
142
+
143
+ const resp = await fetch(url, { headers });
144
+ if (!resp.ok) {
145
+ const body = await resp.text().catch(() => "");
146
+ throw new Error(`HTTP ${resp.status} from ${url}: ${body}`);
147
+ }
148
+
149
+ const json = (await resp.json()) as OpenAIModelsResponse;
150
+ if (!Array.isArray(json?.data)) throw new Error(`Unexpected response from ${url}`);
151
+
152
+ return json.data
153
+ .map((m) => ({ id: m.id, contextWindow: m.context_window, maxTokens: m.max_tokens }))
154
+ .filter((m) => Boolean(m.id))
155
+ .sort((a, b) => a.id.localeCompare(b.id));
156
+ }
157
+
158
+ // ─────────────────────────────────────────────────────────────────────────────
159
+ // Provider registration helpers
160
+ // ─────────────────────────────────────────────────────────────────────────────
161
+
162
+ function buildProviderModels(models: CachedModel[]) {
163
+ return models.map((m) => ({
164
+ id: m.id,
165
+ name: m.id,
166
+ reasoning: false,
167
+ input: ["text"] as string[],
168
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
169
+ contextWindow: m.contextWindow ?? 128_000,
170
+ maxTokens: m.maxTokens ?? 4_096,
171
+ }));
172
+ }
173
+
174
+ function compatKey(key: string): string {
175
+ return `compat-${key}`;
176
+ }
177
+
178
+ function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
179
+ pi.registerProvider(compatKey(key), {
180
+ name: p.displayName,
181
+ baseUrl: p.baseUrl,
182
+ apiKey: p.apiKey ?? "ollama",
183
+ api: "openai-completions" as const,
184
+ models: buildProviderModels(p.cachedModels),
185
+ });
186
+ }
187
+
188
+ // ─────────────────────────────────────────────────────────────────────────────
189
+ // Extension — async factory so registration completes before pi shows /model
190
+ // ─────────────────────────────────────────────────────────────────────────────
191
+
192
+ export default async function (pi: ExtensionAPI) {
193
+ let config = loadConfig();
194
+
195
+ // Register all saved providers immediately using cached model lists.
196
+ // The factory is async, so pi waits for this to finish before startup
197
+ // continues — providers are visible in /model from the very first render.
198
+ for (const [key, p] of Object.entries(config.providers)) {
199
+ if (p.cachedModels.length > 0) {
200
+ registerProvider(pi, key, p);
201
+ }
202
+ }
203
+
204
+ // ── session_start ──────────────────────────────────────────────────────────
205
+ // Reload config in case it changed (e.g. edited by hand) and re-register
206
+ // any providers whose cache was empty at factory time.
207
+ pi.on("session_start", async (_event, ctx) => {
208
+ config = loadConfig();
209
+
210
+ const registered: string[] = [];
211
+ const failed: string[] = [];
212
+
213
+ for (const [key, p] of Object.entries(config.providers)) {
214
+ if (p.cachedModels.length > 0) {
215
+ // Use cached list — fast, no network call.
216
+ registerProvider(pi, key, p);
217
+ registered.push(p.displayName);
218
+ } else {
219
+ // Cache is empty (e.g. migrated from older config). Try a live fetch.
220
+ try {
221
+ const models = await fetchModels(p.baseUrl, p.apiKey);
222
+ if (models.length > 0) {
223
+ p.cachedModels = models;
224
+ saveConfig(config);
225
+ registerProvider(pi, key, p);
226
+ registered.push(`${p.displayName} (refreshed)`);
227
+ } else {
228
+ failed.push(p.displayName);
229
+ }
230
+ } catch {
231
+ failed.push(p.displayName);
232
+ }
233
+ }
234
+ }
235
+
236
+ if (registered.length > 0) {
237
+ ctx.ui.notify(`OpenAI-compat: ${registered.join(", ")} available in /model`, "info");
238
+ }
239
+ if (failed.length > 0) {
240
+ ctx.ui.notify(
241
+ `OpenAI-compat: could not reach ${failed.join(", ")} — run /compat-login to refresh.`,
242
+ "warning"
243
+ );
244
+ }
245
+ });
246
+
247
+ // ── model_select ───────────────────────────────────────────────────────────
248
+ // Track the last non-compat model for logout restoration.
249
+ pi.on("model_select", async (event, _ctx) => {
250
+ const compatProviderKeys = Object.keys(config.providers).map(compatKey);
251
+ if (!compatProviderKeys.includes(event.model.provider)) {
252
+ config.previousModel = { provider: event.model.provider, id: event.model.id };
253
+ saveConfig(config);
254
+ }
255
+ });
256
+
257
+ // ── /compat-login ──────────────────────────────────────────────────────────
258
+ pi.registerCommand("compat-login", {
259
+ description: "Fetch models from an OpenAI-compatible endpoint and add it to /model",
260
+ handler: async (args, ctx) => {
261
+ // Step 1 — pick provider template
262
+ const keys = Object.keys(TEMPLATES);
263
+ const labels = keys.map((k) => TEMPLATES[k].displayName);
264
+ const selectedLabel = await ctx.ui.select("Select Provider", labels);
265
+ if (!selectedLabel) { ctx.ui.notify("Login cancelled.", "info"); return; }
266
+ const key = keys[labels.indexOf(selectedLabel)];
267
+ const tpl = TEMPLATES[key];
268
+
269
+ // Step 2 — base URL (prompted for ollama / custom)
270
+ let baseUrl = tpl.baseUrl;
271
+ if (key === "ollama" || key === "custom") {
272
+ const defaultUrl = tpl.baseUrl;
273
+ const entered = await ctx.ui.input(
274
+ "Base URL",
275
+ key === "ollama"
276
+ ? `Ollama base URL — press Enter for default (${defaultUrl}):`
277
+ : "Base URL of your endpoint (e.g. https://api.example.com/v1):",
278
+ defaultUrl
279
+ );
280
+ if (entered === null) { ctx.ui.notify("Login cancelled.", "info"); return; }
281
+ baseUrl = (entered.trim() || defaultUrl).replace(/\/+$/, "");
282
+ if (!baseUrl) { ctx.ui.notify("Base URL cannot be empty.", "error"); return; }
283
+ }
284
+
285
+ // Step 3 — API key
286
+ let apiKey: string | null = null;
287
+ if (!tpl.keyless) {
288
+ const entered = await ctx.ui.input(
289
+ "API Key",
290
+ `${tpl.keyHint ?? "Your API key"} — leave blank if keyless:`,
291
+ ""
292
+ );
293
+ if (entered === null) { ctx.ui.notify("Login cancelled.", "info"); return; }
294
+ apiKey = entered.trim() || null;
295
+ }
296
+
297
+ // Step 4 — fetch fresh model list
298
+ ctx.ui.notify(`Connecting to ${baseUrl} …`, "info");
299
+ let models: CachedModel[];
300
+ try {
301
+ models = await fetchModels(baseUrl, apiKey);
302
+ } catch (err) {
303
+ ctx.ui.notify(`Connection failed — not saved.\n${err}`, "error");
304
+ return;
305
+ }
306
+ if (!models.length) {
307
+ ctx.ui.notify("Connected but no models returned. Check URL and key.", "error");
308
+ return;
309
+ }
310
+
311
+ // Step 5 — save to config and register with pi
312
+ config.providers[key] = {
313
+ displayName: tpl.displayName,
314
+ baseUrl,
315
+ apiKey,
316
+ cachedModels: models,
317
+ };
318
+ saveConfig(config);
319
+ registerProvider(pi, key, config.providers[key]);
320
+
321
+ ctx.ui.notify(
322
+ `${tpl.displayName} registered — ${models.length} model(s) added to /model.`,
323
+ "success"
324
+ );
325
+ },
326
+ });
327
+
328
+ // ── /compat-logout ─────────────────────────────────────────────────────────
329
+ pi.registerCommand("compat-logout", {
330
+ description: "Remove an OpenAI-compatible provider from pi's model list",
331
+ handler: async (_args, ctx) => {
332
+ const providerKeys = Object.keys(config.providers);
333
+ if (!providerKeys.length) {
334
+ ctx.ui.notify("No compat providers are registered.", "info");
335
+ return;
336
+ }
337
+
338
+ // If only one provider, confirm directly; otherwise ask which to remove.
339
+ let key: string;
340
+ if (providerKeys.length === 1) {
341
+ key = providerKeys[0];
342
+ const name = config.providers[key].displayName;
343
+ const ok = await ctx.ui.confirm("Remove Provider", `Unregister "${name}"?`);
344
+ if (!ok) { ctx.ui.notify("Cancelled.", "info"); return; }
345
+ } else {
346
+ const labels = providerKeys.map((k) => config.providers[k].displayName);
347
+ const chosen = await ctx.ui.select("Remove which provider?", labels);
348
+ if (!chosen) { ctx.ui.notify("Cancelled.", "info"); return; }
349
+ key = providerKeys[labels.indexOf(chosen)];
350
+ }
351
+
352
+ const name = config.providers[key].displayName;
353
+ pi.unregisterProvider(compatKey(key));
354
+ delete config.providers[key];
355
+ saveConfig(config);
356
+
357
+ // If no compat providers remain, restore the previous model.
358
+ const isLast = Object.keys(config.providers).length === 0;
359
+ if (isLast && config.previousModel) {
360
+ const prev = config.previousModel;
361
+ try {
362
+ await (pi as any).setModel({ provider: prev.provider, id: prev.id });
363
+ ctx.ui.notify(`"${name}" removed. Restored ${prev.provider}/${prev.id}.`, "info");
364
+ } catch {
365
+ ctx.ui.notify(`"${name}" removed. Use /model to pick a model.`, "info");
366
+ }
367
+ } else {
368
+ ctx.ui.notify(
369
+ isLast
370
+ ? `"${name}" removed. Use /model to pick a model.`
371
+ : `"${name}" removed.`,
372
+ "info"
373
+ );
374
+ }
375
+ },
376
+ });
377
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@billjr99/pi-openai-compat",
3
+ "version": "1.0.0",
4
+ "description": "pi-coding-agent extension: OpenAI-compatible endpoint support (OpenRouter, NVIDIA NIM, Nous Portal, Ollama, custom)",
5
+ "author": "Bill Mongan <https://github.com/BillJr99>",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/BillJr99/pi-openai-compat#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/BillJr99/pi-openai-compat.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/BillJr99/pi-openai-compat/issues"
14
+ },
15
+ "files": [
16
+ "index.ts",
17
+ "README.md"
18
+ ],
19
+ "pi": {
20
+ "extensions": ["./index.ts"]
21
+ },
22
+ "dependencies": {}
23
+ }