@billjr99/pi-openai-compat 1.1.20 → 1.1.22
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 +29 -0
- package/add-provider.sh +477 -0
- package/index.ts +31 -3
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -62,6 +62,9 @@ If pi is already running when you install, type `/reload` first.
|
|
|
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
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 |
|
|
65
68
|
| **Ollama (local)** | `http://localhost:11434/v1` | Keyless |
|
|
66
69
|
| **Ollama Cloud** | `https://ollama.com/v1` | Ollama Cloud API key from ollama.com |
|
|
67
70
|
| **llmproxy** | `http://localhost:8080/v1` (editable) | Keyless by default; bearer token if your instance requires one |
|
|
@@ -196,6 +199,32 @@ Credentials and cached model lists are stored at:
|
|
|
196
199
|
API keys are stored in plaintext. Protect the file with `chmod 600` if
|
|
197
200
|
needed, or delete it to clear all saved credentials.
|
|
198
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
|
+
|
|
199
228
|
---
|
|
200
229
|
|
|
201
230
|
## Publishing to npmjs.com
|
package/add-provider.sh
ADDED
|
@@ -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
|
@@ -29,6 +29,8 @@ interface CachedModel {
|
|
|
29
29
|
id: string;
|
|
30
30
|
contextWindow?: number;
|
|
31
31
|
maxTokens?: number;
|
|
32
|
+
reasoning?: boolean;
|
|
33
|
+
input?: string[];
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
interface ProviderConfig {
|
|
@@ -309,6 +311,26 @@ const TEMPLATES: Record<string, {
|
|
|
309
311
|
keyless: false,
|
|
310
312
|
keyHint: "b.ai console (docs at docs.b.ai/llmservice/api/)",
|
|
311
313
|
},
|
|
314
|
+
xkiro: {
|
|
315
|
+
displayName: "xKiro",
|
|
316
|
+
baseUrl: "https://api.xkiro.com/v1",
|
|
317
|
+
keyless: false,
|
|
318
|
+
keyHint: "xkiro.com console — keys look like sk-xt-... (docs at docs.xkiro.com)",
|
|
319
|
+
},
|
|
320
|
+
teamorouter: {
|
|
321
|
+
displayName: "TeamoRouter",
|
|
322
|
+
baseUrl: "https://api.teamorouter.com/v1",
|
|
323
|
+
keyless: false,
|
|
324
|
+
keyHint: "teamorouter.com — keys look like sk-teamo-... (docs at teamorouter.com/docs/api-integration)",
|
|
325
|
+
},
|
|
326
|
+
gmi: {
|
|
327
|
+
displayName: "GMI Cloud",
|
|
328
|
+
baseUrl: "https://api.gmi-serving.com/v1",
|
|
329
|
+
keyless: false,
|
|
330
|
+
// Inference is served from api.gmi-serving.com; console.gmicloud.ai is the
|
|
331
|
+
// control plane (containers, clusters, sandboxes) and has no /chat/completions.
|
|
332
|
+
keyHint: "console.gmicloud.ai → Organization Settings → API Keys (docs at docs.gmicloud.ai/inference-engine)",
|
|
333
|
+
},
|
|
312
334
|
custom: {
|
|
313
335
|
displayName: "Custom Endpoint",
|
|
314
336
|
baseUrl: "",
|
|
@@ -531,7 +553,13 @@ async function fetchModels(
|
|
|
531
553
|
typeof rawId === "string" ? rawId :
|
|
532
554
|
typeof rawId === "number" ? String(rawId) :
|
|
533
555
|
"";
|
|
534
|
-
return {
|
|
556
|
+
return {
|
|
557
|
+
id,
|
|
558
|
+
contextWindow: m.context_window,
|
|
559
|
+
maxTokens: m.max_tokens,
|
|
560
|
+
reasoning: m.reasoning,
|
|
561
|
+
input: m.input,
|
|
562
|
+
};
|
|
535
563
|
})
|
|
536
564
|
.filter((m) => Boolean(m.id))
|
|
537
565
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
@@ -547,8 +575,8 @@ function buildProviderModels(models: CachedModel[]) {
|
|
|
547
575
|
return {
|
|
548
576
|
id,
|
|
549
577
|
name: id,
|
|
550
|
-
reasoning: false,
|
|
551
|
-
input: ["text"] as string[],
|
|
578
|
+
reasoning: m.reasoning ?? false,
|
|
579
|
+
input: m.input ?? (["text"] as string[]),
|
|
552
580
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
553
581
|
contextWindow: m.contextWindow ?? 128_000,
|
|
554
582
|
maxTokens: m.maxTokens ?? 4_096,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@billjr99/pi-openai-compat",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.22",
|
|
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": [
|