@billjr99/pi-openai-compat 1.0.3 → 1.1.9

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 +82 -0
  2. package/index.ts +182 -16
  3. package/package.json +8 -4
package/README.md CHANGED
@@ -40,9 +40,32 @@ If pi is already running when you install, type `/reload` first.
40
40
  | **OpenRouter** | `https://openrouter.ai/api/v1` | `sk-or-...` from openrouter.ai/keys |
41
41
  | **NVIDIA NIM** | `https://integrate.api.nvidia.com/v1` | `nvapi-...` from build.nvidia.com |
42
42
  | **Nous Research Portal** | `https://inference-api.nousresearch.com/v1` | Nous Portal API key |
43
+ | **DeepSeek** | `https://api.deepseek.com/v1` | API key from platform.deepseek.com |
44
+ | **xAI (Grok)** | `https://api.x.ai/v1` | API key from console.x.ai |
45
+ | **Hugging Face** | `https://router.huggingface.co/v1` | `hf_...` token from huggingface.co/settings/tokens |
46
+ | **Moonshot (Kimi)** | `https://api.moonshot.ai/v1` | `sk-...` key from platform.moonshot.ai |
47
+ | **MiniMax** | `https://api.minimax.io/v1` | API key from platform.minimax.io |
48
+ | **Z.ai** | `https://api.z.ai/api/paas/v4` | API key from z.ai |
49
+ | **Venice AI** | `https://api.venice.ai/api/v1` | API key from venice.ai/settings/api |
50
+ | **Cloudflare Workers AI** | `https://api.cloudflare.com/client/v4/accounts/{account}/ai/v1` | API token from dash.cloudflare.com |
51
+ | **Cloudflare AI Gateway** | `https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/openai` | API token from dash.cloudflare.com |
52
+ | **Vercel AI Gateway** | `https://ai-gateway.vercel.sh/v1` | API key from vercel.com |
53
+ | **OpenCode Zen** | `https://opencode.ai/zen/v1` | API key from opencode.ai |
43
54
  | **Ollama (local)** | `http://localhost:11434/v1` | Keyless |
55
+ | **Ollama Cloud** | `https://ollama.com/v1` | Ollama Cloud API key from ollama.com |
44
56
  | **Custom** | Any URL you supply | Optional bearer token |
45
57
 
58
+ > **Providers with built-in fallback model lists (as of May 2026)**
59
+ > Some providers do not support `GET /v1/models` or return a non-JSON response.
60
+ > For these, the extension substitutes a built-in list and shows a warning during
61
+ > `/compat-login`.
62
+ >
63
+ > | Provider | Reason |
64
+ > |---|---|
65
+ > | **Cloudflare Workers AI** | Returns HTTP 405 — method not supported |
66
+ > | **Cloudflare AI Gateway** | Returns HTTP 401 — no anonymous model enumeration |
67
+ > | **Hugging Face** | Returns HTML rather than JSON for `/v1/models` |
68
+
46
69
  ---
47
70
 
48
71
  ## Commands
@@ -171,6 +194,65 @@ npm publish --access public
171
194
 
172
195
  `npm version` also creates a git tag, so GitHub gets release tags automatically.
173
196
 
197
+ ### Automated publishing via GitHub Actions
198
+
199
+ Every push to `main` automatically bumps the patch version and publishes to npm
200
+ via `.github/workflows/publish.yml`. This workflow uses **npm trusted publishing**
201
+ (OIDC) — no long-lived token or secret is needed anywhere.
202
+
203
+ #### Required one-time setup on npmjs.com
204
+
205
+ > **Do this before the first automated publish or it will fail.**
206
+
207
+ 1. Go to **npmjs.com** → sign in → click your avatar → **Packages** →
208
+ `@billjr99/pi-openai-compat`
209
+ 2. Click **Settings** (in the left sidebar of the package page)
210
+ 3. Scroll to **Publishing** → enable **Trusted Publishing**
211
+ 4. Add a publisher with these exact values:
212
+
213
+ | Field | Value |
214
+ |---|---|
215
+ | Repository owner | `BillJr99` |
216
+ | Repository name | `pi-openai-compat` |
217
+ | Workflow filename | `publish.yml` |
218
+ | Environment | *(leave blank)* |
219
+
220
+ 5. Click **Save**
221
+
222
+ No token to copy, no secret to rotate. That's the only setup step.
223
+
224
+ #### What the workflow does
225
+
226
+ ```yaml
227
+ # .github/workflows/publish.yml
228
+ on:
229
+ push:
230
+ branches: [main]
231
+
232
+ permissions:
233
+ contents: write # push the version-bump commit back to main
234
+ id-token: write # request an OIDC token from GitHub for npm auth
235
+ ```
236
+
237
+ On every push to `main` the workflow:
238
+
239
+ 1. Checks out the repo with `GITHUB_TOKEN` so it can push back
240
+ 2. Runs `npm version patch --no-git-tag-version` to increment the patch number
241
+ in `package.json` (e.g. `1.1.0` → `1.1.1`)
242
+ 3. Commits the updated `package.json` with `[skip ci]` in the message so the
243
+ commit does not re-trigger the workflow
244
+ 4. Pushes the bump commit to `main`
245
+ 5. Runs `npm publish --access public --provenance`
246
+ - GitHub mints a short-lived OIDC token proving the job is running from
247
+ this exact repo and workflow
248
+ - npm validates it against the trusted publisher config above and allows
249
+ the publish — no `NPM_TOKEN` secret required
250
+ - `--provenance` attaches a signed build attestation to the package,
251
+ visible on the npmjs.com package page under **Provenance**
252
+
253
+ The published package is immediately available at
254
+ https://www.npmjs.com/package/@billjr99/pi-openai-compat.
255
+
174
256
  ### Installing from npm
175
257
 
176
258
  ```bash
package/index.ts CHANGED
@@ -60,67 +60,155 @@ const TEMPLATES: Record<string, {
60
60
  modelFilter?: string[];
61
61
  /** If true, prompt the user to confirm/edit the base URL (like ollama/custom). */
62
62
  promptUrl?: boolean;
63
+ /** Used when the provider does not support GET /v1/models (e.g. returns 405). */
64
+ fallbackModels?: string[];
65
+ /** Where to obtain the API key; presence implies the key is required. */
66
+ keyHint?: string;
63
67
  }> = {
64
68
  openrouter: {
65
69
  displayName: "OpenRouter",
66
70
  baseUrl: "https://openrouter.ai/api/v1",
67
71
  keyless: false,
72
+ keyHint: "openrouter.ai/keys",
68
73
  },
69
74
  nvidia_nim: {
70
75
  displayName: "NVIDIA NIM",
71
76
  baseUrl: "https://integrate.api.nvidia.com/v1",
72
77
  keyless: false,
78
+ keyHint: "build.nvidia.com",
73
79
  },
74
80
  nous: {
75
81
  displayName: "Nous Research Portal",
76
82
  baseUrl: "https://inference-api.nousresearch.com/v1",
77
83
  keyless: false,
84
+ keyHint: "nousresearch.com",
78
85
  },
79
86
  google: {
80
87
  displayName: "Google Gemini",
81
88
  baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
82
89
  keyless: false,
90
+ keyHint: "aistudio.google.com/apikey",
83
91
  },
84
92
  cerebras: {
85
93
  displayName: "Cerebras",
86
94
  baseUrl: "https://api.cerebras.ai/v1",
87
95
  keyless: false,
96
+ keyHint: "cloud.cerebras.ai",
88
97
  },
89
98
  github_models: {
90
99
  displayName: "GitHub Models",
91
100
  baseUrl: "https://models.inference.ai.azure.com",
92
101
  keyless: false,
102
+ keyHint: "github.com/settings/tokens",
93
103
  },
94
104
  sambanova: {
95
105
  displayName: "SambaNova",
96
106
  baseUrl: "https://api.sambanova.ai/v1",
97
107
  keyless: false,
108
+ keyHint: "cloud.sambanova.ai",
98
109
  },
99
110
  mistral: {
100
111
  displayName: "Mistral",
101
112
  baseUrl: "https://api.mistral.ai/v1",
102
113
  keyless: false,
114
+ keyHint: "console.mistral.ai/api-keys",
103
115
  },
104
116
  groq: {
105
117
  displayName: "Groq",
106
118
  baseUrl: "https://api.groq.com/openai/v1",
107
119
  keyless: false,
120
+ keyHint: "console.groq.com/keys",
108
121
  },
109
- cloudflare: {
122
+ cloudflare_workers: {
110
123
  displayName: "Cloudflare Workers AI",
111
124
  baseUrl: "https://api.cloudflare.com/client/v4/accounts/YOUR_ACCOUNT_ID/ai/v1",
112
125
  keyless: false,
113
126
  promptUrl: true,
127
+ keyHint: "dash.cloudflare.com → My Profile → API Tokens",
128
+ // Cloudflare Workers AI returns 405 for GET /v1/models; use a curated list.
129
+ fallbackModels: [
130
+ "@cf/meta/llama-4-scout-17b-16e-instruct",
131
+ "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
132
+ "@cf/meta/llama-3.1-8b-instruct",
133
+ "@cf/qwen/qwen3-30b-a3b-fp8",
134
+ "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",
135
+ ],
136
+ },
137
+ cloudflare_ai_gateway: {
138
+ displayName: "Cloudflare AI Gateway",
139
+ // YOUR_PROVIDER is the upstream slug (e.g. "workers-ai", "openai"). /v1 is
140
+ // appended so that fetchModels and chat completions hit the correct path.
141
+ baseUrl: "https://gateway.ai.cloudflare.com/v1/YOUR_ACCOUNT_ID/YOUR_GATEWAY_SLUG/YOUR_PROVIDER/v1",
142
+ keyless: false,
143
+ promptUrl: true,
144
+ keyHint: "dash.cloudflare.com → My Profile → API Tokens",
145
+ fallbackModels: [
146
+ "@cf/meta/llama-4-scout-17b-16e-instruct",
147
+ "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
148
+ "@cf/meta/llama-3.1-8b-instruct",
149
+ "@cf/qwen/qwen3-30b-a3b-fp8",
150
+ "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",
151
+ ],
114
152
  },
115
153
  zhipu: {
116
154
  displayName: "Zhipu (Z.ai / BigModel)",
117
155
  baseUrl: "https://open.bigmodel.cn/api/paas/v4",
118
156
  keyless: false,
157
+ keyHint: "open.bigmodel.cn/usercenter/apikeys",
158
+ },
159
+ zai: {
160
+ displayName: "Z.ai",
161
+ baseUrl: "https://api.z.ai/api/paas/v4",
162
+ keyless: false,
163
+ keyHint: "platform.z.ai",
119
164
  },
120
165
  cohere: {
121
166
  displayName: "Cohere",
122
167
  baseUrl: "https://api.cohere.com/compatibility/v1",
123
168
  keyless: false,
169
+ keyHint: "dashboard.cohere.com/api-keys",
170
+ },
171
+ deepseek: {
172
+ displayName: "DeepSeek",
173
+ baseUrl: "https://api.deepseek.com/v1",
174
+ keyless: false,
175
+ keyHint: "platform.deepseek.com/api_keys",
176
+ },
177
+ xai: {
178
+ displayName: "xAI (Grok)",
179
+ baseUrl: "https://api.x.ai/v1",
180
+ keyless: false,
181
+ keyHint: "console.x.ai",
182
+ },
183
+ huggingface: {
184
+ displayName: "Hugging Face",
185
+ baseUrl: "https://router.huggingface.co/v1",
186
+ keyless: false,
187
+ keyHint: "huggingface.co/settings/tokens",
188
+ fallbackModels: [
189
+ "meta-llama/Llama-3.3-70B-Instruct",
190
+ "meta-llama/Meta-Llama-3-8B-Instruct",
191
+ "mistralai/Mistral-7B-Instruct-v0.3",
192
+ "Qwen/Qwen2.5-72B-Instruct",
193
+ ],
194
+ },
195
+ moonshot: {
196
+ displayName: "Moonshot (Kimi)",
197
+ baseUrl: "https://api.moonshot.ai/v1",
198
+ keyless: false,
199
+ keyHint: "platform.moonshot.ai/console/api-key",
200
+ },
201
+ minimax: {
202
+ displayName: "MiniMax",
203
+ baseUrl: "https://api.minimax.io/v1",
204
+ keyless: false,
205
+ keyHint: "platform.minimax.io",
206
+ },
207
+ venice: {
208
+ displayName: "Venice AI",
209
+ baseUrl: "https://api.venice.ai/api/v1",
210
+ keyless: false,
211
+ keyHint: "venice.ai/settings/api",
124
212
  },
125
213
  ollama: {
126
214
  displayName: "Ollama (local, keyless)",
@@ -128,6 +216,32 @@ const TEMPLATES: Record<string, {
128
216
  keyless: true,
129
217
  promptUrl: true,
130
218
  },
219
+ ollama_cloud: {
220
+ displayName: "Ollama Cloud",
221
+ baseUrl: "https://ollama.com/v1",
222
+ keyless: false,
223
+ keyHint: "ollama.com/settings/api-keys",
224
+ },
225
+ llmproxy: {
226
+ displayName: "llmproxy (local)",
227
+ baseUrl: "http://localhost:8080/v1",
228
+ keyless: true,
229
+ promptUrl: true,
230
+ },
231
+ vercel: {
232
+ displayName: "Vercel AI Gateway",
233
+ baseUrl: "https://ai-gateway.vercel.sh/v1",
234
+ keyless: false,
235
+ keyHint: "vercel.com/account/tokens",
236
+ },
237
+ opencode_zen: {
238
+ displayName: "OpenCode Zen",
239
+ baseUrl: "https://opencode.ai/zen/v1",
240
+ keyless: false,
241
+ keyHint: "opencode.ai",
242
+ // Verify current model IDs via GET /v1/models — the free model list may change.
243
+ modelFilter: ["big-pickle", "deepseek-v4-flash-free", "minimax-m2.5-free", "nemotron-3-super-free"],
244
+ },
131
245
  custom: {
132
246
  displayName: "Custom Endpoint",
133
247
  baseUrl: "",
@@ -182,6 +296,21 @@ function saveConfig(config: ExtensionConfig): void {
182
296
  // Networking
183
297
  // ─────────────────────────────────────────────────────────────────────────────
184
298
 
299
+ function isLocalUrl(url: string): boolean {
300
+ try {
301
+ const { hostname } = new URL(url);
302
+ return (
303
+ hostname === "localhost" ||
304
+ hostname === "127.0.0.1" ||
305
+ hostname === "::1" ||
306
+ hostname.endsWith(".local") ||
307
+ hostname.endsWith(".localhost")
308
+ );
309
+ } catch {
310
+ return false;
311
+ }
312
+ }
313
+
185
314
  async function fetchModels(baseUrl: string, apiKey: string | null): Promise<CachedModel[]> {
186
315
  const url = `${baseUrl.replace(/\/+$/, "")}/models`;
187
316
  const headers: Record<string, string> = { Accept: "application/json" };
@@ -224,9 +353,9 @@ function compatKey(key: string): string {
224
353
 
225
354
  function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
226
355
  pi.registerProvider(compatKey(key), {
227
- name: p.displayName,
356
+ name: `compat/${key.replace(/_/g, "-")}`,
228
357
  baseUrl: p.baseUrl,
229
- apiKey: p.apiKey ?? "ollama",
358
+ apiKey: p.apiKey ?? (isLocalUrl(p.baseUrl) ? "local" : ""),
230
359
  api: "openai-completions" as const,
231
360
  models: buildProviderModels(p.cachedModels),
232
361
  });
@@ -315,8 +444,7 @@ export default async function (pi: ExtensionAPI) {
315
444
 
316
445
  // Step 2 — base URL
317
446
  let baseUrl = tpl.baseUrl;
318
- if (key === "cloudflare") {
319
- // Ask for just the account ID and splice it into the template URL.
447
+ if (key === "cloudflare_workers") {
320
448
  const entered = await ctx.ui.input(
321
449
  "Account ID",
322
450
  "Your Cloudflare Account ID (find it on the Cloudflare dashboard overview page):",
@@ -326,10 +454,41 @@ export default async function (pi: ExtensionAPI) {
326
454
  const accountId = entered.trim();
327
455
  if (!accountId) { ctx.ui.notify("Account ID cannot be empty.", "error"); return; }
328
456
  baseUrl = tpl.baseUrl.replace("YOUR_ACCOUNT_ID", accountId);
457
+ } else if (key === "cloudflare_ai_gateway") {
458
+ const accountIdInput = await ctx.ui.input(
459
+ "Account ID",
460
+ "Your Cloudflare Account ID (find it on the Cloudflare dashboard overview page):",
461
+ ""
462
+ );
463
+ if (accountIdInput == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
464
+ const accountId = accountIdInput.trim();
465
+ if (!accountId) { ctx.ui.notify("Account ID cannot be empty.", "error"); return; }
466
+
467
+ const gatewayInput = await ctx.ui.input(
468
+ "Gateway Name",
469
+ "Your AI Gateway name/slug (find it under AI → AI Gateway in the Cloudflare dashboard):",
470
+ ""
471
+ );
472
+ if (gatewayInput == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
473
+ const gatewaySlug = gatewayInput.trim();
474
+ if (!gatewaySlug) { ctx.ui.notify("Gateway name cannot be empty.", "error"); return; }
475
+
476
+ const providerInput = await ctx.ui.input(
477
+ "Provider",
478
+ "Upstream provider slug (e.g. openai, workers-ai, anthropic — must match your gateway config):",
479
+ "openai"
480
+ );
481
+ if (providerInput == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
482
+ const provider = providerInput.trim() || "openai";
483
+
484
+ baseUrl = tpl.baseUrl
485
+ .replace("YOUR_ACCOUNT_ID", accountId)
486
+ .replace("YOUR_GATEWAY_SLUG", gatewaySlug)
487
+ .replace("YOUR_PROVIDER", provider);
329
488
  } else if (tpl.promptUrl) {
330
489
  const defaultUrl = tpl.baseUrl;
331
- const prompt = key === "ollama"
332
- ? `Ollama base URL — press Enter for default (${defaultUrl}):`
490
+ const prompt = isLocalUrl(defaultUrl)
491
+ ? `Base URL — press Enter for default (${defaultUrl}):`
333
492
  : "Base URL of your endpoint (e.g. https://api.example.com/v1):";
334
493
  const entered = await ctx.ui.input("Base URL", prompt, defaultUrl);
335
494
  if (entered == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
@@ -337,14 +496,13 @@ export default async function (pi: ExtensionAPI) {
337
496
  if (!baseUrl) { ctx.ui.notify("Base URL cannot be empty.", "error"); return; }
338
497
  }
339
498
 
340
- // Step 3 — API key
499
+ // Step 3 — API key (skipped for keyless templates and detected local URLs)
341
500
  let apiKey: string | null = null;
342
- if (!tpl.keyless) {
343
- const entered = await ctx.ui.input(
344
- "API Key",
345
- "Your API key — leave blank if keyless:",
346
- ""
347
- );
501
+ if (!tpl.keyless && !isLocalUrl(baseUrl)) {
502
+ const keyPrompt = tpl.keyHint
503
+ ? `Your API key (required) — get it at ${tpl.keyHint}:`
504
+ : "Your API key — leave blank if keyless:";
505
+ const entered = await ctx.ui.input("API Key", keyPrompt, "");
348
506
  if (entered == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
349
507
  apiKey = entered.trim() || null;
350
508
  }
@@ -355,8 +513,16 @@ export default async function (pi: ExtensionAPI) {
355
513
  try {
356
514
  models = await fetchModels(baseUrl, apiKey);
357
515
  } catch (err) {
358
- ctx.ui.notify(`Connection failed not saved.\n${err}`, "error");
359
- return;
516
+ if (tpl.fallbackModels && tpl.fallbackModels.length > 0) {
517
+ ctx.ui.notify(
518
+ `Could not fetch model list from ${tpl.displayName} (${err}).\nUsing built-in model list instead.`,
519
+ "warning"
520
+ );
521
+ models = tpl.fallbackModels.map((id) => ({ id }));
522
+ } else {
523
+ ctx.ui.notify(`Connection failed — not saved.\n${err}`, "error");
524
+ return;
525
+ }
360
526
  }
361
527
  if (!models.length) {
362
528
  ctx.ui.notify("Connected but no models returned. Check URL and key.", "error");
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@billjr99/pi-openai-compat",
3
- "version": "1.0.3",
3
+ "version": "1.1.9",
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",
7
7
  "homepage": "https://github.com/BillJr99/pi-openai-compat#readme",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/BillJr99/pi-openai-compat.git"
10
+ "url": "git+https://github.com/BillJr99/pi-openai-compat.git"
11
11
  },
12
12
  "bugs": {
13
13
  "url": "https://github.com/BillJr99/pi-openai-compat/issues"
@@ -17,7 +17,11 @@
17
17
  "README.md"
18
18
  ],
19
19
  "pi": {
20
- "extensions": ["./index.ts"]
20
+ "extensions": [
21
+ "./index.ts"
22
+ ]
21
23
  },
22
- "dependencies": {}
24
+ "devDependencies": {
25
+ "typescript": "^6.0.3"
26
+ }
23
27
  }