@billjr99/pi-openai-compat 1.0.2 → 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 +177 -55
  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,111 +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,
83
- modelFilter: [
84
- "gemini-2.5-flash",
85
- "gemini-2.5-flash-lite",
86
- "gemini-3-flash-preview",
87
- "gemini-3.1-flash-lite-preview",
88
- "gemini-3.1-pro-preview",
89
- ],
90
+ keyHint: "aistudio.google.com/apikey",
90
91
  },
91
92
  cerebras: {
92
93
  displayName: "Cerebras",
93
94
  baseUrl: "https://api.cerebras.ai/v1",
94
95
  keyless: false,
95
- modelFilter: ["qwen3-235b"],
96
+ keyHint: "cloud.cerebras.ai",
96
97
  },
97
98
  github_models: {
98
99
  displayName: "GitHub Models",
99
100
  baseUrl: "https://models.inference.ai.azure.com",
100
101
  keyless: false,
101
- modelFilter: ["gpt-4o", "openai/gpt-4.1"],
102
+ keyHint: "github.com/settings/tokens",
102
103
  },
103
104
  sambanova: {
104
105
  displayName: "SambaNova",
105
106
  baseUrl: "https://api.sambanova.ai/v1",
106
107
  keyless: false,
107
- modelFilter: [
108
- "Meta-Llama-3.3-70B-Instruct",
109
- "DeepSeek-V3.1",
110
- "DeepSeek-V3.2",
111
- "gpt-oss-120b",
112
- "Llama-4-Maverick-17B-128E-Instruct",
113
- "gemma-3-12b-it",
114
- ],
108
+ keyHint: "cloud.sambanova.ai",
115
109
  },
116
110
  mistral: {
117
111
  displayName: "Mistral",
118
112
  baseUrl: "https://api.mistral.ai/v1",
119
113
  keyless: false,
120
- modelFilter: [
121
- "mistral-large-latest",
122
- "mistral-medium-latest",
123
- "magistral-medium-latest",
124
- "codestral-latest",
125
- "devstral-latest",
126
- ],
114
+ keyHint: "console.mistral.ai/api-keys",
127
115
  },
128
116
  groq: {
129
117
  displayName: "Groq",
130
118
  baseUrl: "https://api.groq.com/openai/v1",
131
119
  keyless: false,
132
- modelFilter: [
133
- "llama-3.3-70b-versatile",
134
- "meta-llama/llama-4-scout-17b-16e-instruct",
135
- "openai/gpt-oss-120b",
136
- "openai/gpt-oss-20b",
137
- "qwen/qwen3-32b",
138
- "llama-3.1-8b-instant",
139
- ],
120
+ keyHint: "console.groq.com/keys",
140
121
  },
141
- cloudflare: {
122
+ cloudflare_workers: {
142
123
  displayName: "Cloudflare Workers AI",
143
124
  baseUrl: "https://api.cloudflare.com/client/v4/accounts/YOUR_ACCOUNT_ID/ai/v1",
144
125
  keyless: false,
145
126
  promptUrl: true,
146
- modelFilter: [
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",
147
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: [
148
146
  "@cf/meta/llama-4-scout-17b-16e-instruct",
149
- "@cf/openai/gpt-oss-120b",
150
- "@cf/zai-org/glm-4.7-flash",
151
- "@cf/moonshotai/kimi-k2.5",
152
- "@cf/moonshotai/kimi-k2.6",
147
+ "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
148
+ "@cf/meta/llama-3.1-8b-instruct",
153
149
  "@cf/qwen/qwen3-30b-a3b-fp8",
154
150
  "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",
155
- "@cf/ibm-granite/granite-4.0-h-micro",
156
151
  ],
157
152
  },
158
153
  zhipu: {
159
154
  displayName: "Zhipu (Z.ai / BigModel)",
160
155
  baseUrl: "https://open.bigmodel.cn/api/paas/v4",
161
156
  keyless: false,
162
- modelFilter: ["glm-4.5-flash", "glm-4.7-flash"],
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",
163
164
  },
164
165
  cohere: {
165
166
  displayName: "Cohere",
166
167
  baseUrl: "https://api.cohere.com/compatibility/v1",
167
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",
168
212
  },
169
213
  ollama: {
170
214
  displayName: "Ollama (local, keyless)",
@@ -172,6 +216,32 @@ const TEMPLATES: Record<string, {
172
216
  keyless: true,
173
217
  promptUrl: true,
174
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
+ },
175
245
  custom: {
176
246
  displayName: "Custom Endpoint",
177
247
  baseUrl: "",
@@ -226,6 +296,21 @@ function saveConfig(config: ExtensionConfig): void {
226
296
  // Networking
227
297
  // ─────────────────────────────────────────────────────────────────────────────
228
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
+
229
314
  async function fetchModels(baseUrl: string, apiKey: string | null): Promise<CachedModel[]> {
230
315
  const url = `${baseUrl.replace(/\/+$/, "")}/models`;
231
316
  const headers: Record<string, string> = { Accept: "application/json" };
@@ -268,9 +353,9 @@ function compatKey(key: string): string {
268
353
 
269
354
  function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
270
355
  pi.registerProvider(compatKey(key), {
271
- name: p.displayName,
356
+ name: `compat/${key.replace(/_/g, "-")}`,
272
357
  baseUrl: p.baseUrl,
273
- apiKey: p.apiKey ?? "ollama",
358
+ apiKey: p.apiKey ?? (isLocalUrl(p.baseUrl) ? "local" : ""),
274
359
  api: "openai-completions" as const,
275
360
  models: buildProviderModels(p.cachedModels),
276
361
  });
@@ -359,8 +444,7 @@ export default async function (pi: ExtensionAPI) {
359
444
 
360
445
  // Step 2 — base URL
361
446
  let baseUrl = tpl.baseUrl;
362
- if (key === "cloudflare") {
363
- // Ask for just the account ID and splice it into the template URL.
447
+ if (key === "cloudflare_workers") {
364
448
  const entered = await ctx.ui.input(
365
449
  "Account ID",
366
450
  "Your Cloudflare Account ID (find it on the Cloudflare dashboard overview page):",
@@ -370,10 +454,41 @@ export default async function (pi: ExtensionAPI) {
370
454
  const accountId = entered.trim();
371
455
  if (!accountId) { ctx.ui.notify("Account ID cannot be empty.", "error"); return; }
372
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);
373
488
  } else if (tpl.promptUrl) {
374
489
  const defaultUrl = tpl.baseUrl;
375
- const prompt = key === "ollama"
376
- ? `Ollama base URL — press Enter for default (${defaultUrl}):`
490
+ const prompt = isLocalUrl(defaultUrl)
491
+ ? `Base URL — press Enter for default (${defaultUrl}):`
377
492
  : "Base URL of your endpoint (e.g. https://api.example.com/v1):";
378
493
  const entered = await ctx.ui.input("Base URL", prompt, defaultUrl);
379
494
  if (entered == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
@@ -381,14 +496,13 @@ export default async function (pi: ExtensionAPI) {
381
496
  if (!baseUrl) { ctx.ui.notify("Base URL cannot be empty.", "error"); return; }
382
497
  }
383
498
 
384
- // Step 3 — API key
499
+ // Step 3 — API key (skipped for keyless templates and detected local URLs)
385
500
  let apiKey: string | null = null;
386
- if (!tpl.keyless) {
387
- const entered = await ctx.ui.input(
388
- "API Key",
389
- "Your API key — leave blank if keyless:",
390
- ""
391
- );
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, "");
392
506
  if (entered == null) { ctx.ui.notify("Login cancelled.", "info"); return; }
393
507
  apiKey = entered.trim() || null;
394
508
  }
@@ -399,8 +513,16 @@ export default async function (pi: ExtensionAPI) {
399
513
  try {
400
514
  models = await fetchModels(baseUrl, apiKey);
401
515
  } catch (err) {
402
- ctx.ui.notify(`Connection failed not saved.\n${err}`, "error");
403
- 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
+ }
404
526
  }
405
527
  if (!models.length) {
406
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.2",
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
  }