@billjr99/pi-openai-compat 1.1.28 → 1.1.30
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 +18 -6
- package/add-provider.sh +25 -2
- package/index.ts +168 -36
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -66,7 +66,8 @@ If pi is already running when you install, type `/reload` first.
|
|
|
66
66
|
| **TeamoRouter** | `https://api.teamorouter.com/v1` | `sk-teamo-...` key from teamorouter.com (teamorouter.com/docs) |
|
|
67
67
|
| **GMI Cloud** | `https://api.gmi-serving.com/v1` | API key from console.gmicloud.ai → Organization Settings → API Keys |
|
|
68
68
|
| **Token Harbor** | `https://tokenharbor.ai/v1` | `thk_live_...` Universal Key from tokenharbor.ai/dashboard/api-keys |
|
|
69
|
-
| **
|
|
69
|
+
| **Atria ASI** | `https://api.atria-asi.ai/v1` | `atr_...` key from api.atria-asi.ai/console/keys (Google sign-in) |
|
|
70
|
+
| **Ollama (local)** | `http://localhost:11434/v1` (editable) | Optional bearer token; leave blank for a default local install |
|
|
70
71
|
| **Ollama Cloud** | `https://ollama.com/v1` | Ollama Cloud API key from ollama.com |
|
|
71
72
|
| **llmproxy** | `http://localhost:8080/v1` (editable) | Keyless by default; bearer token if your instance requires one |
|
|
72
73
|
| **Custom** | Any URL you supply | Optional bearer token |
|
|
@@ -111,8 +112,12 @@ Three commands are available; `/compat-login` is the only one you need to get st
|
|
|
111
112
|
Walks you through a short wizard:
|
|
112
113
|
|
|
113
114
|
1. Select a provider from the list above (or choose Custom).
|
|
114
|
-
2. For Ollama and Custom, confirm or change the base URL.
|
|
115
|
-
3. Enter your API key
|
|
115
|
+
2. For Ollama, llmproxy and Custom, confirm or change the base URL.
|
|
116
|
+
3. Enter your API key. For the local templates the key is optional: press
|
|
117
|
+
Enter to skip it on a default install, or supply one if you have put the
|
|
118
|
+
server behind a reverse proxy or exposed it on your LAN. The prompt is
|
|
119
|
+
never skipped based on the hostname, because a `.local` or LAN address is
|
|
120
|
+
no guarantee that the endpoint is unauthenticated.
|
|
116
121
|
4. The extension connects, fetches the model list from `/v1/models`, and
|
|
117
122
|
registers the provider with pi.
|
|
118
123
|
|
|
@@ -200,8 +205,9 @@ Credentials and cached model lists are stored at:
|
|
|
200
205
|
~/.config/pi-openai-compat/config.json
|
|
201
206
|
```
|
|
202
207
|
|
|
203
|
-
API keys are stored in plaintext
|
|
204
|
-
|
|
208
|
+
API keys are stored in plaintext, so the extension creates the file `0600` and
|
|
209
|
+
the directory `0700`, and tightens both on load if an older version left them
|
|
210
|
+
world-readable. Delete the file to clear all saved credentials.
|
|
205
211
|
|
|
206
212
|
Each provider's `cachedModels` array holds one entry per model. Only `id` is
|
|
207
213
|
required; the rest are optional and fall back to conservative defaults when the
|
|
@@ -215,9 +221,15 @@ provider's `/models` catalog does not report them:
|
|
|
215
221
|
| `reasoning` | boolean | `false` | Enables pi's thinking mode for the model. |
|
|
216
222
|
| `input` | `["text"]` or `["text","image"]` | `["text"]` | Modalities pi may send; `image` lets pi attach image blocks. Any other value is ignored. |
|
|
217
223
|
| `thinkingLevelMap` | object | omitted | pi thinking-level remap, passed through to the registered model. Keys are pi thinking levels (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); string values are sent to the provider, `null` hides an unsupported level. See [pi's docs](https://pi.dev/docs/latest/models#thinking-level-map). |
|
|
218
|
-
| `samplingParams` | object | omitted |
|
|
224
|
+
| `samplingParams` | object | omitted | **Not currently applied.** Preserved in the config and passed to pi, but pi's `registerProvider` builds each model from a fixed field list that does not include it (verified against pi 0.73.1), so it has no effect today. Kept for forward compatibility. |
|
|
219
225
|
| `compat` | object | omitted | OpenAI compatibility flags for the model (`thinkingFormat`, `chatTemplateKwargs`, `maxTokensField`, `supportsDeveloperRole`, …). See [pi's docs](https://pi.dev/docs/latest/models#openai-compatibility). |
|
|
220
226
|
|
|
227
|
+
`contextWindow` and `maxTokens` are validated before they reach pi: a value
|
|
228
|
+
that is not a positive finite number is ignored in favor of the default, and
|
|
229
|
+
anything above 10,000,000 (context) or 1,000,000 (output) is clamped. pi drives
|
|
230
|
+
its context accounting off these numbers, so an inflated value from a
|
|
231
|
+
misreporting or hostile catalog would otherwise inflate every request sent.
|
|
232
|
+
|
|
221
233
|
Most catalogs report none of these beyond the ID, so aggregators and proxies
|
|
222
234
|
(CLIProxyAPI, for example) register every model as a 128K, text-only,
|
|
223
235
|
non-reasoning model. To correct that, edit the entry by hand and `/reload`:
|
package/add-provider.sh
CHANGED
|
@@ -54,6 +54,18 @@ const BUILTIN = {
|
|
|
54
54
|
keyless: false,
|
|
55
55
|
keyHint: "console.gmicloud.ai → Organization Settings → API Keys (docs at docs.gmicloud.ai/inference-engine)",
|
|
56
56
|
},
|
|
57
|
+
tokenharbor: {
|
|
58
|
+
displayName: "Token Harbor",
|
|
59
|
+
baseUrl: "https://tokenharbor.ai/v1",
|
|
60
|
+
keyless: false,
|
|
61
|
+
keyHint: "tokenharbor.ai/dashboard/api-keys (Universal Key, format thk_live_...; docs at tokenharbor.ai/docs)",
|
|
62
|
+
},
|
|
63
|
+
atria_asi: {
|
|
64
|
+
displayName: "Atria ASI",
|
|
65
|
+
baseUrl: "https://api.atria-asi.ai/v1",
|
|
66
|
+
keyless: false,
|
|
67
|
+
keyHint: "api.atria-asi.ai/console/keys (Google sign-in; keys look like atr_...; docs at api.atria-asi.ai/docs)",
|
|
68
|
+
},
|
|
57
69
|
};
|
|
58
70
|
|
|
59
71
|
// README "Auth" column text for the built-in providers, used only by patchreadme.
|
|
@@ -61,6 +73,8 @@ const BUILTIN_AUTH = {
|
|
|
61
73
|
xkiro: "`sk-xt-...` key from the xKiro console at xkiro.com (docs.xkiro.com)",
|
|
62
74
|
teamorouter: "`sk-teamo-...` key from teamorouter.com (teamorouter.com/docs)",
|
|
63
75
|
gmi: "API key from console.gmicloud.ai → Organization Settings → API Keys",
|
|
76
|
+
tokenharbor: "`thk_live_...` Universal Key from tokenharbor.ai/dashboard/api-keys",
|
|
77
|
+
atria_asi: "`atr_...` key from api.atria-asi.ai/console/keys (Google sign-in)",
|
|
64
78
|
};
|
|
65
79
|
|
|
66
80
|
/**
|
|
@@ -150,8 +164,17 @@ function normalizeModels(body, idField, keepTask) {
|
|
|
150
164
|
const rawId = m ? m[field] : undefined;
|
|
151
165
|
const id = typeof rawId === "string" ? rawId : typeof rawId === "number" ? String(rawId) : "";
|
|
152
166
|
const out = { id };
|
|
153
|
-
|
|
154
|
-
|
|
167
|
+
// Mirrors normalizeTokenCount() in index.ts: the catalog is untrusted,
|
|
168
|
+
// so a non-numeric or absurd token count is dropped/clamped rather than
|
|
169
|
+
// written into config.json.
|
|
170
|
+
const num = (v, max) =>
|
|
171
|
+
typeof v === "number" && Number.isFinite(v) && v > 0
|
|
172
|
+
? Math.min(Math.floor(v), max)
|
|
173
|
+
: undefined;
|
|
174
|
+
const ctx = num(m ? m.context_window : undefined, 10000000);
|
|
175
|
+
const max = num(m ? m.max_tokens : undefined, 1000000);
|
|
176
|
+
if (ctx !== undefined) out.contextWindow = ctx;
|
|
177
|
+
if (max !== undefined) out.maxTokens = max;
|
|
155
178
|
return out;
|
|
156
179
|
})
|
|
157
180
|
.filter((m) => Boolean(m.id))
|
package/index.ts
CHANGED
|
@@ -70,16 +70,12 @@ interface ExtensionConfig {
|
|
|
70
70
|
providers: Record<string, ProviderConfig>;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
interface OpenAIModelsResponse {
|
|
74
|
-
data: Array<{ id: string; context_window?: number; max_tokens?: number }>;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
73
|
/** Loose shape for a single entry in any /models response. */
|
|
78
74
|
type RawModel = {
|
|
79
75
|
id?: string;
|
|
80
76
|
name?: string;
|
|
81
|
-
context_window?:
|
|
82
|
-
max_tokens?:
|
|
77
|
+
context_window?: unknown;
|
|
78
|
+
max_tokens?: unknown;
|
|
83
79
|
reasoning?: unknown;
|
|
84
80
|
input?: unknown;
|
|
85
81
|
task?: { name?: string };
|
|
@@ -91,7 +87,7 @@ type RawModel = {
|
|
|
91
87
|
* default, rather than caching something pi would choke on (it calls
|
|
92
88
|
* `model.input.includes("image")` at tool time).
|
|
93
89
|
*/
|
|
94
|
-
function normalizeInput(value: unknown): ModelInput[] | undefined {
|
|
90
|
+
export function normalizeInput(value: unknown): ModelInput[] | undefined {
|
|
95
91
|
if (!Array.isArray(value)) return undefined;
|
|
96
92
|
const kept = value.filter(
|
|
97
93
|
(v): v is ModelInput => v === "text" || v === "image",
|
|
@@ -99,13 +95,41 @@ function normalizeInput(value: unknown): ModelInput[] | undefined {
|
|
|
99
95
|
return kept.length > 0 ? kept : undefined;
|
|
100
96
|
}
|
|
101
97
|
|
|
98
|
+
// Upper bounds for token counts accepted from a provider catalog or a
|
|
99
|
+
// hand-edited config. pi drives context accounting off these numbers, so an
|
|
100
|
+
// absurd value inflates every request this extension sends; clamping keeps a
|
|
101
|
+
// misreporting or hostile catalog from turning into runaway token spend.
|
|
102
|
+
export const MAX_CONTEXT_WINDOW = 10_000_000;
|
|
103
|
+
export const MAX_OUTPUT_TOKENS = 1_000_000;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Coerce an untrusted token count into a sane positive integer. A `/models`
|
|
107
|
+
* payload is attacker-controlled for the purposes of this extension (it exists
|
|
108
|
+
* to connect to arbitrary third-party endpoints), and `??` only rejects null
|
|
109
|
+
* and undefined — a string or NaN would flow straight through into pi. Anything
|
|
110
|
+
* that is not a finite positive number yields undefined so the caller's default
|
|
111
|
+
* applies; anything larger than `max` is clamped rather than discarded.
|
|
112
|
+
*/
|
|
113
|
+
export function normalizeTokenCount(value: unknown, max: number): number | undefined {
|
|
114
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
return Math.min(Math.floor(value), max);
|
|
118
|
+
}
|
|
119
|
+
|
|
102
120
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
103
121
|
// Provider templates
|
|
104
122
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
105
123
|
|
|
106
|
-
const TEMPLATES: Record<string, {
|
|
124
|
+
export const TEMPLATES: Record<string, {
|
|
107
125
|
displayName: string;
|
|
108
126
|
baseUrl: string;
|
|
127
|
+
/**
|
|
128
|
+
* True only for endpoints that cannot accept a key at all. Do not set it
|
|
129
|
+
* merely because a provider is usually run locally: a self-hosted server
|
|
130
|
+
* reachable over the LAN is commonly put behind a key, and skipping the
|
|
131
|
+
* prompt leaves the user no way to supply one.
|
|
132
|
+
*/
|
|
109
133
|
keyless: boolean;
|
|
110
134
|
/** If set, only models whose id appears in this list are kept after fetching. */
|
|
111
135
|
modelFilter?: string[];
|
|
@@ -301,9 +325,13 @@ const TEMPLATES: Record<string, {
|
|
|
301
325
|
keyHint: "api.together.ai/settings/api-keys",
|
|
302
326
|
},
|
|
303
327
|
ollama: {
|
|
304
|
-
displayName: "Ollama (local
|
|
328
|
+
displayName: "Ollama (local)",
|
|
305
329
|
baseUrl: "http://localhost:11434/v1",
|
|
306
|
-
keyless:
|
|
330
|
+
// Not keyless: Ollama is frequently exposed beyond loopback (a LAN or
|
|
331
|
+
// .local hostname) behind a reverse proxy that does require a key. The
|
|
332
|
+
// prompt says the key is optional, so a plain local install just presses
|
|
333
|
+
// Enter.
|
|
334
|
+
keyless: false,
|
|
307
335
|
promptUrl: true,
|
|
308
336
|
},
|
|
309
337
|
ollama_cloud: {
|
|
@@ -315,7 +343,9 @@ const TEMPLATES: Record<string, {
|
|
|
315
343
|
llmproxy: {
|
|
316
344
|
displayName: "llmproxy (local)",
|
|
317
345
|
baseUrl: "http://localhost:8080/v1",
|
|
318
|
-
|
|
346
|
+
// See the note on the ollama template: local by default, but reachable
|
|
347
|
+
// (and key-protected) off-host often enough that the prompt must appear.
|
|
348
|
+
keyless: false,
|
|
319
349
|
promptUrl: true,
|
|
320
350
|
},
|
|
321
351
|
vercel: {
|
|
@@ -365,6 +395,16 @@ const TEMPLATES: Record<string, {
|
|
|
365
395
|
// One "Universal Key" reaches every model; free models carry a ":free" id suffix.
|
|
366
396
|
keyHint: "tokenharbor.ai/dashboard/api-keys (Universal Key, format thk_live_...; docs at tokenharbor.ai/docs)",
|
|
367
397
|
},
|
|
398
|
+
atria_asi: {
|
|
399
|
+
displayName: "Atria ASI",
|
|
400
|
+
baseUrl: "https://api.atria-asi.ai/v1",
|
|
401
|
+
keyless: false,
|
|
402
|
+
// One model, Atria-Dawn-Preview: 256K context, text-only input. GET
|
|
403
|
+
// /v1/models is undocumented but registered and key-gated (it answers 401,
|
|
404
|
+
// where an unknown /v1 path answers 404), which is exactly what fetchModels
|
|
405
|
+
// already sends, so no modelsUrl or fallbackModels override is needed.
|
|
406
|
+
keyHint: "api.atria-asi.ai/console/keys (Google sign-in; keys look like atr_...; docs at api.atria-asi.ai/docs)",
|
|
407
|
+
},
|
|
368
408
|
custom: {
|
|
369
409
|
displayName: "Custom Endpoint",
|
|
370
410
|
baseUrl: "",
|
|
@@ -380,11 +420,43 @@ const TEMPLATES: Record<string, {
|
|
|
380
420
|
const CONFIG_DIR = path.join(os.homedir(), ".config", "pi-openai-compat");
|
|
381
421
|
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
382
422
|
|
|
423
|
+
// config.json holds every provider's API key in cleartext, so neither it nor
|
|
424
|
+
// its directory may be group- or world-readable. add-provider.sh already writes
|
|
425
|
+
// 0600; these constants keep the extension's own writes consistent with it.
|
|
426
|
+
export const CONFIG_DIR_MODE = 0o700;
|
|
427
|
+
export const CONFIG_FILE_MODE = 0o600;
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Tighten a path's permissions when they are broader than `mode`. Needed
|
|
431
|
+
* because the `mode` option of writeFileSync/mkdirSync applies only at
|
|
432
|
+
* creation time: a config.json written at 0644 by an earlier version keeps
|
|
433
|
+
* that mode forever otherwise. A no-op on platforms without POSIX modes.
|
|
434
|
+
*/
|
|
435
|
+
export function restrictPermissions(target: string, mode: number): void {
|
|
436
|
+
try {
|
|
437
|
+
const current = fs.statSync(target).mode & 0o777;
|
|
438
|
+
if ((current & ~mode) !== 0) fs.chmodSync(target, mode);
|
|
439
|
+
} catch (e) {
|
|
440
|
+
console.error(`[openai-compat:restrictPermissions] ${target}`, e);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Create the config directory if absent, and keep it owner-only either way. */
|
|
445
|
+
function ensureConfigDir(): void {
|
|
446
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
447
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: CONFIG_DIR_MODE });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
restrictPermissions(CONFIG_DIR, CONFIG_DIR_MODE);
|
|
451
|
+
}
|
|
452
|
+
|
|
383
453
|
function loadConfig(): ExtensionConfig {
|
|
384
454
|
const empty: ExtensionConfig = { previousModel: null, providers: {} };
|
|
385
455
|
try {
|
|
386
|
-
|
|
456
|
+
ensureConfigDir();
|
|
387
457
|
if (!fs.existsSync(CONFIG_PATH)) return empty;
|
|
458
|
+
// Repair a config written before the extension enforced 0600.
|
|
459
|
+
restrictPermissions(CONFIG_PATH, CONFIG_FILE_MODE);
|
|
388
460
|
|
|
389
461
|
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as Record<string, unknown>;
|
|
390
462
|
const config: ExtensionConfig = {
|
|
@@ -408,8 +480,12 @@ function loadConfig(): ExtensionConfig {
|
|
|
408
480
|
|
|
409
481
|
function saveConfig(config: ExtensionConfig): void {
|
|
410
482
|
try {
|
|
411
|
-
|
|
412
|
-
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2),
|
|
483
|
+
ensureConfigDir();
|
|
484
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), {
|
|
485
|
+
encoding: "utf-8",
|
|
486
|
+
mode: CONFIG_FILE_MODE,
|
|
487
|
+
});
|
|
488
|
+
restrictPermissions(CONFIG_PATH, CONFIG_FILE_MODE);
|
|
413
489
|
} catch (e) {
|
|
414
490
|
console.error("[openai-compat:saveConfig]", e);
|
|
415
491
|
}
|
|
@@ -503,7 +579,7 @@ function migrateDiscoveryFields(config: ExtensionConfig): { healed: string[]; st
|
|
|
503
579
|
// Networking
|
|
504
580
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
505
581
|
|
|
506
|
-
function isLocalUrl(url: string): boolean {
|
|
582
|
+
export function isLocalUrl(url: string): boolean {
|
|
507
583
|
try {
|
|
508
584
|
const { hostname } = new URL(url);
|
|
509
585
|
return (
|
|
@@ -518,6 +594,9 @@ function isLocalUrl(url: string): boolean {
|
|
|
518
594
|
}
|
|
519
595
|
}
|
|
520
596
|
|
|
597
|
+
/** Cap on how much of an upstream error body is surfaced to the user. */
|
|
598
|
+
export const MAX_ERROR_BODY = 500;
|
|
599
|
+
|
|
521
600
|
/** Optional per-provider overrides controlling how /models is fetched. */
|
|
522
601
|
interface FetchOverrides {
|
|
523
602
|
/** Full URL to fetch instead of `<baseUrl>/models`. */
|
|
@@ -528,7 +607,7 @@ interface FetchOverrides {
|
|
|
528
607
|
keepTask?: string;
|
|
529
608
|
}
|
|
530
609
|
|
|
531
|
-
async function fetchModels(
|
|
610
|
+
export async function fetchModels(
|
|
532
611
|
baseUrl: string,
|
|
533
612
|
apiKey: string | null,
|
|
534
613
|
overrides: FetchOverrides = {},
|
|
@@ -545,10 +624,16 @@ async function fetchModels(
|
|
|
545
624
|
|
|
546
625
|
const resp = await fetch(url, { headers });
|
|
547
626
|
if (!resp.ok) {
|
|
548
|
-
//
|
|
549
|
-
//
|
|
627
|
+
// This extension never echoes the Authorization header, but the body is
|
|
628
|
+
// the upstream's: some gateways reflect parts of the submitted credential
|
|
629
|
+
// or internal identifiers into error payloads, and this text is rendered
|
|
630
|
+
// straight into the UI. Keep enough to diagnose, not enough to dump a
|
|
631
|
+
// credential-bearing page.
|
|
550
632
|
const body = await resp.text().catch(() => "");
|
|
551
|
-
|
|
633
|
+
const snippet = body.length > MAX_ERROR_BODY
|
|
634
|
+
? `${body.slice(0, MAX_ERROR_BODY)}… (truncated)`
|
|
635
|
+
: body;
|
|
636
|
+
throw new Error(`HTTP ${resp.status} from ${url}: ${snippet}`);
|
|
552
637
|
}
|
|
553
638
|
|
|
554
639
|
// Normalize the various shapes /models can return:
|
|
@@ -589,8 +674,8 @@ async function fetchModels(
|
|
|
589
674
|
"";
|
|
590
675
|
return {
|
|
591
676
|
id,
|
|
592
|
-
contextWindow: m.context_window,
|
|
593
|
-
maxTokens: m.max_tokens,
|
|
677
|
+
contextWindow: normalizeTokenCount(m.context_window, MAX_CONTEXT_WINDOW),
|
|
678
|
+
maxTokens: normalizeTokenCount(m.max_tokens, MAX_OUTPUT_TOKENS),
|
|
594
679
|
reasoning: typeof m.reasoning === "boolean" ? m.reasoning : undefined,
|
|
595
680
|
input: normalizeInput(m.input),
|
|
596
681
|
};
|
|
@@ -603,7 +688,7 @@ async function fetchModels(
|
|
|
603
688
|
// Provider registration helpers
|
|
604
689
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
605
690
|
|
|
606
|
-
function buildProviderModels(models: CachedModel[]) {
|
|
691
|
+
export function buildProviderModels(models: CachedModel[]) {
|
|
607
692
|
return models.map((m) => {
|
|
608
693
|
const id = m.id;
|
|
609
694
|
return {
|
|
@@ -612,8 +697,10 @@ function buildProviderModels(models: CachedModel[]) {
|
|
|
612
697
|
reasoning: m.reasoning ?? false,
|
|
613
698
|
input: m.input ?? (["text"] as ModelInput[]),
|
|
614
699
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
615
|
-
|
|
616
|
-
|
|
700
|
+
// Re-validated here as well as at fetch time: cachedModels is a
|
|
701
|
+
// hand-editable file, so this is the single choke point pi sees.
|
|
702
|
+
contextWindow: normalizeTokenCount(m.contextWindow, MAX_CONTEXT_WINDOW) ?? 128_000,
|
|
703
|
+
maxTokens: normalizeTokenCount(m.maxTokens, MAX_OUTPUT_TOKENS) ?? 4_096,
|
|
617
704
|
...(m.thinkingLevelMap ? { thinkingLevelMap: m.thinkingLevelMap } : {}),
|
|
618
705
|
...(m.samplingParams ? { samplingParams: m.samplingParams } : {}),
|
|
619
706
|
...(m.compat ? { compat: m.compat } : {}),
|
|
@@ -627,7 +714,7 @@ function buildProviderModels(models: CachedModel[]) {
|
|
|
627
714
|
* it. The fetched list decides which ids exist; the old cache only fills gaps,
|
|
628
715
|
* so a provider that does report a field always wins.
|
|
629
716
|
*/
|
|
630
|
-
function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): CachedModel[] {
|
|
717
|
+
export function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): CachedModel[] {
|
|
631
718
|
const prior = new Map(previous.map((m) => [m.id, m]));
|
|
632
719
|
return fetched.map((m) => {
|
|
633
720
|
const old = prior.get(m.id);
|
|
@@ -645,20 +732,52 @@ function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): Ca
|
|
|
645
732
|
});
|
|
646
733
|
}
|
|
647
734
|
|
|
648
|
-
|
|
735
|
+
/**
|
|
736
|
+
* Stand-in API key for providers that genuinely use none. pi's registerProvider
|
|
737
|
+
* throws `"apiKey" or "oauth" is required when defining models` on a falsy
|
|
738
|
+
* value, which would abort the extension factory before any /compat-* command
|
|
739
|
+
* is registered — leaving no in-app way to repair the config.
|
|
740
|
+
*/
|
|
741
|
+
export const KEYLESS_PLACEHOLDER = "unused";
|
|
742
|
+
|
|
743
|
+
export function compatKey(key: string): string {
|
|
649
744
|
return `compat-${key}`;
|
|
650
745
|
}
|
|
651
746
|
|
|
652
|
-
function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
|
|
747
|
+
export function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
|
|
653
748
|
pi.registerProvider(compatKey(key), {
|
|
654
749
|
name: `compat/${key.replace(/_/g, "-")}`,
|
|
655
750
|
baseUrl: p.baseUrl,
|
|
656
|
-
apiKey
|
|
751
|
+
// pi rejects a provider that defines models unless apiKey is a non-empty
|
|
752
|
+
// string, so a genuinely keyless endpoint (Ollama, llmproxy) still needs a
|
|
753
|
+
// placeholder here. Deliberately not conditioned on the hostname: a .local
|
|
754
|
+
// or LAN host can be key-protected, which is what the wizard now asks
|
|
755
|
+
// about rather than inferring. Endpoints that ignore Authorization discard
|
|
756
|
+
// this; ones that require a key return a clean 401.
|
|
757
|
+
apiKey: p.apiKey ?? KEYLESS_PLACEHOLDER,
|
|
657
758
|
api: "openai-completions" as const,
|
|
658
759
|
models: buildProviderModels(p.cachedModels),
|
|
659
760
|
});
|
|
660
761
|
}
|
|
661
762
|
|
|
763
|
+
/**
|
|
764
|
+
* Register a provider, reporting failure instead of throwing.
|
|
765
|
+
*
|
|
766
|
+
* Every saved provider is registered before any command is registered, so an
|
|
767
|
+
* uncaught throw here would take down the whole extension — including the
|
|
768
|
+
* /compat-login and /compat-logout commands needed to fix whatever caused it.
|
|
769
|
+
* One malformed entry should cost the user that one provider, nothing more.
|
|
770
|
+
*/
|
|
771
|
+
export function tryRegisterProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): boolean {
|
|
772
|
+
try {
|
|
773
|
+
registerProvider(pi, key, p);
|
|
774
|
+
return true;
|
|
775
|
+
} catch (e) {
|
|
776
|
+
console.error(`[openai-compat:tryRegisterProvider] provider "${key}"`, e);
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
662
781
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
663
782
|
// Extension — async factory so registration completes before pi shows /model
|
|
664
783
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -676,7 +795,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
676
795
|
// continues — providers are visible in /model from the very first render.
|
|
677
796
|
for (const [key, p] of Object.entries(config.providers)) {
|
|
678
797
|
if (p.cachedModels.length > 0) {
|
|
679
|
-
|
|
798
|
+
tryRegisterProvider(pi, key, p);
|
|
680
799
|
}
|
|
681
800
|
}
|
|
682
801
|
|
|
@@ -697,8 +816,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
697
816
|
for (const [key, p] of Object.entries(config.providers)) {
|
|
698
817
|
if (p.cachedModels.length > 0) {
|
|
699
818
|
// Use cached list — fast, no network call.
|
|
700
|
-
|
|
701
|
-
|
|
819
|
+
if (tryRegisterProvider(pi, key, p)) registered.push(p.displayName);
|
|
820
|
+
else failed.push(p.displayName);
|
|
702
821
|
} else {
|
|
703
822
|
// Cache is empty (e.g. migrated from older config). Try a live fetch,
|
|
704
823
|
// honoring any per-provider discovery overrides stored on the config.
|
|
@@ -711,8 +830,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
711
830
|
if (models.length > 0) {
|
|
712
831
|
p.cachedModels = models;
|
|
713
832
|
saveConfig(config);
|
|
714
|
-
|
|
715
|
-
|
|
833
|
+
if (tryRegisterProvider(pi, key, p)) {
|
|
834
|
+
registered.push(`${p.displayName} (refreshed)`);
|
|
835
|
+
} else {
|
|
836
|
+
failed.push(p.displayName);
|
|
837
|
+
}
|
|
716
838
|
} else {
|
|
717
839
|
failed.push(p.displayName);
|
|
718
840
|
}
|
|
@@ -893,7 +1015,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
893
1015
|
modelsKeepTask: tpl.modelsKeepTask,
|
|
894
1016
|
};
|
|
895
1017
|
saveConfig(config);
|
|
896
|
-
|
|
1018
|
+
if (!tryRegisterProvider(pi, key, config.providers[key])) {
|
|
1019
|
+
ctx.ui.notify(
|
|
1020
|
+
`${tpl.displayName} was saved but could not be registered with pi. ` +
|
|
1021
|
+
`Run /compat-logout to remove it, or check the log for details.`,
|
|
1022
|
+
"error"
|
|
1023
|
+
);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
897
1026
|
|
|
898
1027
|
ctx.ui.notify(
|
|
899
1028
|
`${tpl.displayName} registered — ${models.length} model(s) added to /model.`,
|
|
@@ -952,8 +1081,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
952
1081
|
// flaky refresh must never blank out a working provider's models.
|
|
953
1082
|
p.cachedModels = mergeModelMetadata(p.cachedModels, models);
|
|
954
1083
|
saveConfig(config);
|
|
955
|
-
|
|
956
|
-
|
|
1084
|
+
if (tryRegisterProvider(pi, key, p)) {
|
|
1085
|
+
refreshed.push(`${p.displayName} (${models.length})`);
|
|
1086
|
+
} else {
|
|
1087
|
+
failed.push(p.displayName);
|
|
1088
|
+
}
|
|
957
1089
|
} else {
|
|
958
1090
|
failed.push(p.displayName);
|
|
959
1091
|
}
|
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.30",
|
|
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",
|
|
@@ -23,6 +23,12 @@
|
|
|
23
23
|
]
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
+
"@mariozechner/pi-coding-agent": "^0.73.1",
|
|
27
|
+
"@types/node": "^24.0.0",
|
|
26
28
|
"typescript": "^6.0.3"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
32
|
+
"test": "node --test test/index.test.ts"
|
|
27
33
|
}
|
|
28
34
|
}
|