@billjr99/pi-openai-compat 1.1.19 → 1.1.21

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
@@ -61,6 +61,10 @@ If pi is already running when you install, type `/reload` first.
61
61
  | **Cloudflare AI Gateway** | `https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/openai` | API token from dash.cloudflare.com (`AI Gateway: Run` + `Workers AI: Read`) |
62
62
  | **Vercel AI Gateway** | `https://ai-gateway.vercel.sh/v1` | API key from vercel.com |
63
63
  | **OpenCode Zen** | `https://opencode.ai/zen/v1` | API key from opencode.ai |
64
+ | **B.AI** | `https://api.b.ai/v1` | API key from the B.AI console at b.ai (docs.b.ai) |
65
+ | **xKiro** | `https://api.xkiro.com/v1` | `sk-xt-...` key from the xKiro console at xkiro.com (docs.xkiro.com) |
66
+ | **TeamoRouter** | `https://api.teamorouter.com/v1` | `sk-teamo-...` key from teamorouter.com (teamorouter.com/docs) |
67
+ | **GMI Cloud** | `https://api.gmi-serving.com/v1` | API key from console.gmicloud.ai → Organization Settings → API Keys |
64
68
  | **Ollama (local)** | `http://localhost:11434/v1` | Keyless |
65
69
  | **Ollama Cloud** | `https://ollama.com/v1` | Ollama Cloud API key from ollama.com |
66
70
  | **llmproxy** | `http://localhost:8080/v1` (editable) | Keyless by default; bearer token if your instance requires one |
@@ -195,6 +199,32 @@ Credentials and cached model lists are stored at:
195
199
  API keys are stored in plaintext. Protect the file with `chmod 600` if
196
200
  needed, or delete it to clear all saved credentials.
197
201
 
202
+ ### Adding a provider without pi — `add-provider.sh`
203
+
204
+ `/compat-login` is the normal path, but the repo also ships a standalone script
205
+ that writes the same config entry from a shell:
206
+
207
+ ```
208
+ ./add-provider.sh
209
+ ```
210
+
211
+ It lists every provider in `TEMPLATES` (read out of `index.ts` at runtime, so it
212
+ never drifts), then prompts for the API key and for the config file path —
213
+ defaulting to `~/.config/pi-openai-compat/config.json`, and offering to create it
214
+ if it is missing. It fetches the model catalog, writes
215
+ `providers.<key>` with `displayName` / `baseUrl` / `apiKey` / `cachedModels`
216
+ exactly as the wizard would, backs the old file up to `config.json.bak`, and
217
+ leaves the result `chmod 600`. Existing providers and `previousModel` are
218
+ preserved, and re-running it just updates the one entry.
219
+
220
+ If the catalog cannot be fetched (wrong key, provider down), it falls back to the
221
+ template's built-in model list, or lets you type model IDs by hand. If the
222
+ provider you pick is missing from this checkout's `index.ts` — the script carries
223
+ its own copy of the newest templates — it offers to add the template and the
224
+ README row too.
225
+
226
+ Afterwards, run `/reload` in pi to pick up the new provider.
227
+
198
228
  ---
199
229
 
200
230
  ## Publishing to npmjs.com
@@ -0,0 +1,477 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # add-provider.sh — provision an OpenAI-compatible provider for pi-openai-compat
4
+ # without going through the /compat-login wizard inside pi.
5
+ #
6
+ # Prompts for the provider, its API key and the config file path, fetches the
7
+ # model catalog, and merges the result into ~/.config/pi-openai-compat/config.json
8
+ # — the same file, and the same shape, that /compat-login writes. If the chosen
9
+ # provider is missing from this checkout's index.ts, it also offers to add the
10
+ # template there and to the README table, so the script works on older checkouts.
11
+ #
12
+ # Requires: node (already required to run the extension) and curl. No jq.
13
+ #
14
+ # Usage: ./add-provider.sh
15
+ #
16
+ set -euo pipefail
17
+
18
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
19
+ INDEX_TS="$SCRIPT_DIR/index.ts"
20
+ README_MD="$SCRIPT_DIR/README.md"
21
+ DEFAULT_CONFIG="${HOME}/.config/pi-openai-compat/config.json"
22
+
23
+ command -v node >/dev/null 2>&1 || { echo "error: node is required but not on PATH." >&2; exit 1; }
24
+ command -v curl >/dev/null 2>&1 || { echo "error: curl is required but not on PATH." >&2; exit 1; }
25
+
26
+ TMPDIR_RUN="$(mktemp -d)"
27
+ trap 'rm -rf "$TMPDIR_RUN"' EXIT
28
+ HELPER="$TMPDIR_RUN/helper.js"
29
+
30
+ cat > "$HELPER" <<'NODE_EOF'
31
+ "use strict";
32
+ const fs = require("fs");
33
+ const path = require("path");
34
+
35
+ // Providers this script knows about even when the checkout's index.ts predates
36
+ // them. Merged under whatever index.ts already defines, so a current checkout
37
+ // simply wins and this table is inert.
38
+ const BUILTIN = {
39
+ xkiro: {
40
+ displayName: "xKiro",
41
+ baseUrl: "https://api.xkiro.com/v1",
42
+ keyless: false,
43
+ keyHint: "xkiro.com console — keys look like sk-xt-... (docs at docs.xkiro.com)",
44
+ },
45
+ teamorouter: {
46
+ displayName: "TeamoRouter",
47
+ baseUrl: "https://api.teamorouter.com/v1",
48
+ keyless: false,
49
+ keyHint: "teamorouter.com — keys look like sk-teamo-... (docs at teamorouter.com/docs/api-integration)",
50
+ },
51
+ gmi: {
52
+ displayName: "GMI Cloud",
53
+ baseUrl: "https://api.gmi-serving.com/v1",
54
+ keyless: false,
55
+ keyHint: "console.gmicloud.ai → Organization Settings → API Keys (docs at docs.gmicloud.ai/inference-engine)",
56
+ },
57
+ };
58
+
59
+ // README "Auth" column text for the built-in providers, used only by patchreadme.
60
+ const BUILTIN_AUTH = {
61
+ xkiro: "`sk-xt-...` key from the xKiro console at xkiro.com (docs.xkiro.com)",
62
+ teamorouter: "`sk-teamo-...` key from teamorouter.com (teamorouter.com/docs)",
63
+ gmi: "API key from console.gmicloud.ai → Organization Settings → API Keys",
64
+ };
65
+
66
+ /**
67
+ * Locate the TEMPLATES object literal in index.ts and return [start, end)
68
+ * offsets of the literal itself (braces included). Scans with a tiny tokenizer
69
+ * so braces inside strings, template literals and comments never confuse the
70
+ * depth count. Returns null if the shape is not what we expect.
71
+ */
72
+ function templatesRange(src) {
73
+ const decl = src.indexOf("const TEMPLATES");
74
+ if (decl === -1) return null;
75
+ // The declaration carries a TS type annotation between the name and the "= {".
76
+ const eq = src.indexOf("= {", decl);
77
+ if (eq === -1) return null;
78
+ const start = src.indexOf("{", eq);
79
+ let i = start, depth = 0;
80
+ while (i < src.length) {
81
+ const c = src[i];
82
+ if (c === "/" && src[i + 1] === "/") { i = src.indexOf("\n", i); if (i === -1) return null; continue; }
83
+ if (c === "/" && src[i + 1] === "*") { i = src.indexOf("*/", i); if (i === -1) return null; i += 2; continue; }
84
+ if (c === '"' || c === "'" || c === "`") {
85
+ const quote = c;
86
+ i++;
87
+ while (i < src.length && src[i] !== quote) { if (src[i] === "\\") i++; i++; }
88
+ i++;
89
+ continue;
90
+ }
91
+ if (c === "{") depth++;
92
+ else if (c === "}") { depth--; if (depth === 0) return [start, i + 1]; }
93
+ i++;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /** Parse TEMPLATES out of index.ts. The literal is pure data, so evaluating it
99
+ * is exact and stays in step with the file as providers are added. */
100
+ function parseTemplates(indexPath) {
101
+ try {
102
+ const src = fs.readFileSync(indexPath, "utf8");
103
+ const range = templatesRange(src);
104
+ if (!range) return null;
105
+ const literal = src.slice(range[0], range[1]);
106
+ // eslint-disable-next-line no-new-func
107
+ const obj = new Function("return (" + literal + ");")();
108
+ if (!obj || typeof obj !== "object" || !Object.keys(obj).length) return null;
109
+ return obj;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /** Templates from index.ts, with any BUILTIN entry the checkout lacks appended
116
+ * just before `custom` (which the wizard keeps last). */
117
+ function templateMenu(indexPath) {
118
+ const parsed = parseTemplates(indexPath);
119
+ const base = parsed || {};
120
+ const out = {};
121
+ for (const [k, v] of Object.entries(base)) {
122
+ if (k === "custom") continue;
123
+ out[k] = v;
124
+ }
125
+ for (const [k, v] of Object.entries(BUILTIN)) if (!(k in out)) out[k] = v;
126
+ if (base.custom) out.custom = base.custom;
127
+ else out.custom = { displayName: "Custom Endpoint", baseUrl: "", keyless: false, promptUrl: true };
128
+ return { templates: out, parsed: parsed !== null, inFile: Object.keys(base) };
129
+ }
130
+
131
+ /** Mirror of fetchModels() in index.ts: normalize the catalog payload, honour
132
+ * the id-field / task overrides, drop empty ids, sort by id. */
133
+ function normalizeModels(body, idField, keepTask) {
134
+ const json = JSON.parse(body);
135
+ let raw;
136
+ if (Array.isArray(json)) raw = json;
137
+ else if (json && typeof json === "object") {
138
+ if (Array.isArray(json.data)) raw = json.data;
139
+ else if (Array.isArray(json.result)) raw = json.result;
140
+ }
141
+ if (!raw) throw new Error('expected an array or an object with a "data" or "result" array');
142
+ const field = idField || "id";
143
+ return raw
144
+ .filter((m) => {
145
+ if (!keepTask) return true;
146
+ const name = (m && m.task && m.task.name) || "";
147
+ return name.toLowerCase() === keepTask.toLowerCase();
148
+ })
149
+ .map((m) => {
150
+ const rawId = m ? m[field] : undefined;
151
+ const id = typeof rawId === "string" ? rawId : typeof rawId === "number" ? String(rawId) : "";
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;
155
+ return out;
156
+ })
157
+ .filter((m) => Boolean(m.id))
158
+ .sort((a, b) => a.id.localeCompare(b.id));
159
+ }
160
+
161
+ function writeAtomic(file, text, mode) {
162
+ const tmp = file + ".tmp." + process.pid;
163
+ fs.writeFileSync(tmp, text, { mode: mode || 0o600 });
164
+ fs.renameSync(tmp, file);
165
+ if (mode) { try { fs.chmodSync(file, mode); } catch {} }
166
+ }
167
+
168
+ const [cmd, ...args] = process.argv.slice(2);
169
+
170
+ if (cmd === "menu") {
171
+ // args: indexPath
172
+ process.stdout.write(JSON.stringify(templateMenu(args[0])));
173
+
174
+ } else if (cmd === "models") {
175
+ // args: bodyFile idField keepTask modelFilterJson
176
+ const [bodyFile, idField, keepTask, filterJson] = args;
177
+ let models = normalizeModels(fs.readFileSync(bodyFile, "utf8"), idField || null, keepTask || null);
178
+ const filter = filterJson ? JSON.parse(filterJson) : null;
179
+ if (Array.isArray(filter) && filter.length) {
180
+ const set = new Set(filter);
181
+ const kept = models.filter((m) => set.has(m.id));
182
+ if (kept.length) models = kept;
183
+ else process.stderr.write(
184
+ "note: none of the template's expected models were returned — keeping all " +
185
+ models.length + " model(s) the provider listed.\n");
186
+ }
187
+ process.stdout.write(JSON.stringify(models));
188
+
189
+ } else if (cmd === "merge") {
190
+ // args: configPath providerKey providerJson
191
+ const [configPath, key, providerJson] = args;
192
+ const provider = JSON.parse(providerJson);
193
+ let config = { previousModel: null, providers: {} };
194
+ if (fs.existsSync(configPath)) {
195
+ const text = fs.readFileSync(configPath, "utf8").trim();
196
+ if (text) {
197
+ try {
198
+ config = JSON.parse(text);
199
+ } catch (e) {
200
+ console.error("error: " + configPath + " is not valid JSON (" + e.message + ").");
201
+ console.error("Refusing to overwrite it — fix or move the file and re-run.");
202
+ process.exit(1);
203
+ }
204
+ }
205
+ fs.copyFileSync(configPath, configPath + ".bak");
206
+ } else {
207
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
208
+ }
209
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
210
+ console.error("error: " + configPath + " does not contain a JSON object.");
211
+ process.exit(1);
212
+ }
213
+ if (!config.providers || typeof config.providers !== "object") config.providers = {};
214
+ if (!("previousModel" in config)) config.previousModel = null;
215
+ const existed = key in config.providers;
216
+ config.providers[key] = provider;
217
+ writeAtomic(configPath, JSON.stringify(config, null, 2) + "\n", 0o600);
218
+ process.stdout.write(existed ? "updated" : "added");
219
+
220
+ } else if (cmd === "patch-index") {
221
+ // args: indexPath key tplJson — insert a TEMPLATES entry before `custom:`
222
+ const [indexPath, key, tplJson] = args;
223
+ const tpl = JSON.parse(tplJson);
224
+ const src = fs.readFileSync(indexPath, "utf8");
225
+ const existing = parseTemplates(indexPath);
226
+ if (existing && key in existing) { process.stdout.write("present"); process.exit(0); }
227
+ const anchor = src.indexOf("\n custom: {");
228
+ if (anchor === -1) { process.stdout.write("noanchor"); process.exit(0); }
229
+ const lines = [" " + key + ": {"];
230
+ lines.push(' displayName: ' + JSON.stringify(tpl.displayName) + ",");
231
+ lines.push(' baseUrl: ' + JSON.stringify(tpl.baseUrl) + ",");
232
+ lines.push(' keyless: ' + (tpl.keyless ? "true" : "false") + ",");
233
+ if (tpl.keyHint) lines.push(' keyHint: ' + JSON.stringify(tpl.keyHint) + ",");
234
+ if (tpl.promptUrl) lines.push(" promptUrl: true,");
235
+ lines.push(" },");
236
+ const block = "\n" + lines.join("\n");
237
+ writeAtomic(indexPath, src.slice(0, anchor) + block + src.slice(anchor), null);
238
+ process.stdout.write("patched");
239
+
240
+ } else if (cmd === "patch-readme") {
241
+ // args: readmePath key displayName baseUrl
242
+ const [readmePath, key, displayName, baseUrl] = args;
243
+ const src = fs.readFileSync(readmePath, "utf8");
244
+ if (src.includes("| **" + displayName + "** |")) { process.stdout.write("present"); process.exit(0); }
245
+ const anchor = "| **Ollama (local)** |";
246
+ const at = src.indexOf(anchor);
247
+ if (at === -1) { process.stdout.write("noanchor"); process.exit(0); }
248
+ const auth = BUILTIN_AUTH[key] || "API key from the provider's console";
249
+ const row = "| **" + displayName + "** | `" + baseUrl + "` | " + auth + " |\n";
250
+ writeAtomic(readmePath, src.slice(0, at) + row + src.slice(at), null);
251
+ process.stdout.write("patched");
252
+
253
+ } else {
254
+ console.error("unknown subcommand: " + cmd);
255
+ process.exit(2);
256
+ }
257
+ NODE_EOF
258
+
259
+ node_helper() { node "$HELPER" "$@"; }
260
+
261
+ # ── Read the provider list straight out of index.ts ──────────────────────────
262
+ if [ ! -f "$INDEX_TS" ]; then
263
+ echo "error: index.ts not found next to this script ($INDEX_TS)." >&2
264
+ exit 1
265
+ fi
266
+ MENU_JSON="$(node_helper menu "$INDEX_TS")"
267
+ if [ "$(printf '%s' "$MENU_JSON" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).parsed?"1":"0"))')" != "1" ]; then
268
+ echo "warning: could not parse TEMPLATES out of index.ts — falling back to the" >&2
269
+ echo " providers built into this script." >&2
270
+ fi
271
+
272
+ mapfile -t KEYS < <(printf '%s' "$MENU_JSON" | node -e '
273
+ let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
274
+ const t=JSON.parse(s).templates;
275
+ for (const k of Object.keys(t)) console.log(k);
276
+ });')
277
+
278
+ tpl_field() { # tpl_field <key> <field>
279
+ printf '%s' "$MENU_JSON" | KEY="$1" FIELD="$2" node -e '
280
+ let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
281
+ const v=JSON.parse(s).templates[process.env.KEY][process.env.FIELD];
282
+ if (v===undefined||v===null) process.stdout.write("");
283
+ else if (typeof v==="object") process.stdout.write(JSON.stringify(v));
284
+ else process.stdout.write(String(v));
285
+ });'
286
+ }
287
+
288
+ echo
289
+ echo "pi-openai-compat — add a provider"
290
+ echo "================================="
291
+ echo
292
+ i=1
293
+ for k in "${KEYS[@]}"; do
294
+ disp="$(tpl_field "$k" displayName)"
295
+ url="$(tpl_field "$k" baseUrl)"
296
+ printf '%3d) %-28s %s\n' "$i" "$disp" "${url:-<you supply the URL>}"
297
+ i=$((i + 1))
298
+ done
299
+ echo
300
+
301
+ while :; do
302
+ read -r -p "Select a provider [1-${#KEYS[@]}]: " choice
303
+ case "$choice" in
304
+ ''|*[!0-9]*) echo " Enter a number." ;;
305
+ *) if [ "$choice" -ge 1 ] && [ "$choice" -le "${#KEYS[@]}" ]; then break; fi
306
+ echo " Out of range." ;;
307
+ esac
308
+ done
309
+ TPL_KEY="${KEYS[$((choice - 1))]}"
310
+
311
+ DISPLAY_NAME="$(tpl_field "$TPL_KEY" displayName)"
312
+ BASE_URL="$(tpl_field "$TPL_KEY" baseUrl)"
313
+ KEYLESS="$(tpl_field "$TPL_KEY" keyless)"
314
+ KEY_HINT="$(tpl_field "$TPL_KEY" keyHint)"
315
+ PROMPT_URL="$(tpl_field "$TPL_KEY" promptUrl)"
316
+ MODELS_URL="$(tpl_field "$TPL_KEY" modelsUrl)"
317
+ MODELS_ID_FIELD="$(tpl_field "$TPL_KEY" modelsIdField)"
318
+ MODELS_KEEP_TASK="$(tpl_field "$TPL_KEY" modelsKeepTask)"
319
+ MODEL_FILTER="$(tpl_field "$TPL_KEY" modelFilter)"
320
+ FALLBACK_MODELS="$(tpl_field "$TPL_KEY" fallbackModels)"
321
+
322
+ echo
323
+ echo "Selected: $DISPLAY_NAME"
324
+
325
+ # The config key doubles as pi's provider id (compat-<key>), so let it be renamed.
326
+ read -r -p "Config key (pi will show it as compat/${TPL_KEY//_/-}) [$TPL_KEY]: " PROVIDER_KEY
327
+ PROVIDER_KEY="${PROVIDER_KEY:-$TPL_KEY}"
328
+
329
+ if [ "$TPL_KEY" = "custom" ]; then
330
+ read -r -p "Display name [Custom Endpoint]: " in_disp
331
+ DISPLAY_NAME="${in_disp:-Custom Endpoint}"
332
+ fi
333
+
334
+ # ── Base URL, including the placeholder substitutions the wizard performs ────
335
+ for ph in YOUR_ACCOUNT_ID YOUR_GATEWAY_SLUG YOUR_PROVIDER; do
336
+ case "$BASE_URL$MODELS_URL" in
337
+ *"$ph"*)
338
+ read -r -p "Value for $ph: " val
339
+ [ -n "$val" ] || { echo "error: $ph is required for this provider." >&2; exit 1; }
340
+ BASE_URL="${BASE_URL//$ph/$val}"
341
+ MODELS_URL="${MODELS_URL//$ph/$val}"
342
+ ;;
343
+ esac
344
+ done
345
+
346
+ if [ "$PROMPT_URL" = "true" ] || [ -z "$BASE_URL" ]; then
347
+ read -r -p "Base URL${BASE_URL:+ [$BASE_URL]}: " in_url
348
+ BASE_URL="${in_url:-$BASE_URL}"
349
+ fi
350
+ [ -n "$BASE_URL" ] || { echo "error: a base URL is required." >&2; exit 1; }
351
+ BASE_URL="${BASE_URL%/}"
352
+
353
+ # ── API key ──────────────────────────────────────────────────────────────────
354
+ IS_LOCAL=0
355
+ case "$BASE_URL" in
356
+ *//localhost*|*//127.0.0.1*|*//[::1]*|*//0.0.0.0*) IS_LOCAL=1 ;;
357
+ esac
358
+
359
+ API_KEY=""
360
+ if [ "$KEYLESS" = "true" ] || [ "$IS_LOCAL" = "1" ]; then
361
+ echo " (this endpoint is keyless — press Enter to skip, or paste a token if yours requires one)"
362
+ read -r -s -p "API key (optional): " API_KEY; echo
363
+ else
364
+ [ -n "$KEY_HINT" ] && echo " Get your key at: $KEY_HINT"
365
+ read -r -s -p "API key: " API_KEY; echo
366
+ if [ -z "$API_KEY" ]; then
367
+ echo "error: this provider requires an API key." >&2
368
+ exit 1
369
+ fi
370
+ fi
371
+
372
+ # ── Config file path ─────────────────────────────────────────────────────────
373
+ echo
374
+ read -r -p "Config file [$DEFAULT_CONFIG]: " CONFIG_PATH
375
+ CONFIG_PATH="${CONFIG_PATH:-$DEFAULT_CONFIG}"
376
+ case "$CONFIG_PATH" in "~"/*) CONFIG_PATH="$HOME/${CONFIG_PATH#\~/}" ;; esac
377
+
378
+ if [ ! -f "$CONFIG_PATH" ]; then
379
+ read -r -p "$CONFIG_PATH does not exist. Create it? [Y/n]: " yn
380
+ case "${yn:-Y}" in [Nn]*) echo "Aborted."; exit 1 ;; esac
381
+ fi
382
+
383
+ # ── Fetch the model catalog ──────────────────────────────────────────────────
384
+ FETCH_URL="${MODELS_URL:-$BASE_URL/models}"
385
+ echo
386
+ echo "Fetching models from $FETCH_URL …"
387
+ BODY="$TMPDIR_RUN/models.json"
388
+ HTTP_CODE="$(curl -sS -o "$BODY" -w '%{http_code}' --max-time 45 \
389
+ -H 'Accept: application/json' \
390
+ ${API_KEY:+-H "Authorization: Bearer $API_KEY"} \
391
+ "$FETCH_URL" || echo 000)"
392
+
393
+ MODELS_JSON=""
394
+ if [ "$HTTP_CODE" = "200" ]; then
395
+ if MODELS_JSON="$(node_helper models "$BODY" "$MODELS_ID_FIELD" "$MODELS_KEEP_TASK" "$MODEL_FILTER" 2>"$TMPDIR_RUN/err")"; then
396
+ [ -s "$TMPDIR_RUN/err" ] && cat "$TMPDIR_RUN/err"
397
+ else
398
+ echo " Could not read the catalog payload: $(cat "$TMPDIR_RUN/err")"
399
+ MODELS_JSON=""
400
+ fi
401
+ else
402
+ echo " HTTP $HTTP_CODE from $FETCH_URL"
403
+ [ -s "$BODY" ] && head -c 300 "$BODY" && echo
404
+ fi
405
+
406
+ MODEL_COUNT=0
407
+ [ -n "$MODELS_JSON" ] && MODEL_COUNT="$(printf '%s' "$MODELS_JSON" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(String(JSON.parse(s).length)))')"
408
+
409
+ if [ "$MODEL_COUNT" = "0" ]; then
410
+ if [ -n "$FALLBACK_MODELS" ]; then
411
+ echo " Using this template's built-in model list instead."
412
+ MODELS_JSON="$(printf '%s' "$FALLBACK_MODELS" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.stringify(JSON.parse(s).map(id=>({id})))))')"
413
+ else
414
+ echo " No models discovered. Enter model IDs by hand (comma-separated), or"
415
+ echo " leave blank to save the provider anyway and run /compat-refresh later."
416
+ read -r -p " Model IDs: " manual
417
+ MODELS_JSON="$(MANUAL="$manual" node -e '
418
+ const ids=(process.env.MANUAL||"").split(",").map(s=>s.trim()).filter(Boolean);
419
+ process.stdout.write(JSON.stringify(ids.map(id=>({id}))));')"
420
+ fi
421
+ MODEL_COUNT="$(printf '%s' "$MODELS_JSON" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(String(JSON.parse(s).length)))')"
422
+ fi
423
+ echo " $MODEL_COUNT model(s)."
424
+
425
+ # ── Merge into the config ────────────────────────────────────────────────────
426
+ PROVIDER_JSON="$(
427
+ DISPLAY_NAME="$DISPLAY_NAME" BASE_URL="$BASE_URL" API_KEY="$API_KEY" \
428
+ MODELS_JSON="$MODELS_JSON" MODELS_URL="$MODELS_URL" \
429
+ MODELS_ID_FIELD="$MODELS_ID_FIELD" MODELS_KEEP_TASK="$MODELS_KEEP_TASK" \
430
+ node -e '
431
+ const e = process.env;
432
+ const out = {
433
+ displayName: e.DISPLAY_NAME,
434
+ baseUrl: e.BASE_URL,
435
+ apiKey: e.API_KEY ? e.API_KEY : null,
436
+ cachedModels: JSON.parse(e.MODELS_JSON || "[]"),
437
+ };
438
+ // Persisted so the session_start rehydrate path re-fetches from the right URL.
439
+ if (e.MODELS_URL) out.modelsUrl = e.MODELS_URL;
440
+ if (e.MODELS_ID_FIELD) out.modelsIdField = e.MODELS_ID_FIELD;
441
+ if (e.MODELS_KEEP_TASK) out.modelsKeepTask = e.MODELS_KEEP_TASK;
442
+ process.stdout.write(JSON.stringify(out));'
443
+ )"
444
+
445
+ RESULT="$(node_helper merge "$CONFIG_PATH" "$PROVIDER_KEY" "$PROVIDER_JSON")"
446
+ echo
447
+ if [ "$RESULT" = "updated" ]; then
448
+ echo "Updated provider '$PROVIDER_KEY' in $CONFIG_PATH (previous version saved to $CONFIG_PATH.bak)."
449
+ else
450
+ echo "Added provider '$PROVIDER_KEY' to $CONFIG_PATH."
451
+ fi
452
+
453
+ # ── Offer to add the template to this checkout if it is missing ──────────────
454
+ IN_FILE="$(printf '%s' "$MENU_JSON" | KEY="$TPL_KEY" node -e '
455
+ let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
456
+ process.stdout.write(JSON.parse(s).inFile.includes(process.env.KEY)?"1":"0");});')"
457
+
458
+ if [ "$IN_FILE" != "1" ] && [ "$TPL_KEY" != "custom" ]; then
459
+ echo
460
+ echo "'$TPL_KEY' is not in this checkout's index.ts TEMPLATES."
461
+ read -r -p "Add it to index.ts and the README table too? [y/N]: " yn
462
+ case "${yn:-N}" in
463
+ [Yy]*)
464
+ TPLJSON="$(DISPLAY_NAME="$DISPLAY_NAME" BASE_URL="$BASE_URL" KEYLESS="$KEYLESS" KEY_HINT="$KEY_HINT" node -e '
465
+ const e=process.env;
466
+ process.stdout.write(JSON.stringify({
467
+ displayName: e.DISPLAY_NAME, baseUrl: e.BASE_URL,
468
+ keyless: e.KEYLESS === "true", keyHint: e.KEY_HINT || undefined }));')"
469
+ echo " index.ts: $(node_helper patch-index "$INDEX_TS" "$TPL_KEY" "$TPLJSON")"
470
+ echo " README.md: $(node_helper patch-readme "$README_MD" "$TPL_KEY" "$DISPLAY_NAME" "$BASE_URL")"
471
+ ;;
472
+ *) echo " Skipped — the config entry above works regardless." ;;
473
+ esac
474
+ fi
475
+
476
+ echo
477
+ echo "Done. In pi, run /reload (then /model to pick a $DISPLAY_NAME model)."
package/index.ts CHANGED
@@ -303,6 +303,32 @@ const TEMPLATES: Record<string, {
303
303
  // Verify current model IDs via GET /v1/models — the free model list may change.
304
304
  modelFilter: ["big-pickle", "deepseek-v4-flash-free", "minimax-m2.5-free", "nemotron-3-super-free"],
305
305
  },
306
+ bai: {
307
+ displayName: "B.AI",
308
+ baseUrl: "https://api.b.ai/v1",
309
+ keyless: false,
310
+ keyHint: "b.ai console (docs at docs.b.ai/llmservice/api/)",
311
+ },
312
+ xkiro: {
313
+ displayName: "xKiro",
314
+ baseUrl: "https://api.xkiro.com/v1",
315
+ keyless: false,
316
+ keyHint: "xkiro.com console — keys look like sk-xt-... (docs at docs.xkiro.com)",
317
+ },
318
+ teamorouter: {
319
+ displayName: "TeamoRouter",
320
+ baseUrl: "https://api.teamorouter.com/v1",
321
+ keyless: false,
322
+ keyHint: "teamorouter.com — keys look like sk-teamo-... (docs at teamorouter.com/docs/api-integration)",
323
+ },
324
+ gmi: {
325
+ displayName: "GMI Cloud",
326
+ baseUrl: "https://api.gmi-serving.com/v1",
327
+ keyless: false,
328
+ // Inference is served from api.gmi-serving.com; console.gmicloud.ai is the
329
+ // control plane (containers, clusters, sandboxes) and has no /chat/completions.
330
+ keyHint: "console.gmicloud.ai → Organization Settings → API Keys (docs at docs.gmicloud.ai/inference-engine)",
331
+ },
306
332
  custom: {
307
333
  displayName: "Custom Endpoint",
308
334
  baseUrl: "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@billjr99/pi-openai-compat",
3
- "version": "1.1.19",
3
+ "version": "1.1.21",
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",
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "index.ts",
17
- "README.md"
17
+ "README.md",
18
+ "add-provider.sh"
18
19
  ],
19
20
  "pi": {
20
21
  "extensions": [