@billjr99/pi-openai-compat 1.1.28 → 1.1.29
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 +17 -6
- package/add-provider.sh +11 -2
- package/index.ts +158 -36
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ 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
|
-
| **Ollama (local)** | `http://localhost:11434/v1` |
|
|
69
|
+
| **Ollama (local)** | `http://localhost:11434/v1` (editable) | Optional bearer token; leave blank for a default local install |
|
|
70
70
|
| **Ollama Cloud** | `https://ollama.com/v1` | Ollama Cloud API key from ollama.com |
|
|
71
71
|
| **llmproxy** | `http://localhost:8080/v1` (editable) | Keyless by default; bearer token if your instance requires one |
|
|
72
72
|
| **Custom** | Any URL you supply | Optional bearer token |
|
|
@@ -111,8 +111,12 @@ Three commands are available; `/compat-login` is the only one you need to get st
|
|
|
111
111
|
Walks you through a short wizard:
|
|
112
112
|
|
|
113
113
|
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
|
|
114
|
+
2. For Ollama, llmproxy and Custom, confirm or change the base URL.
|
|
115
|
+
3. Enter your API key. For the local templates the key is optional: press
|
|
116
|
+
Enter to skip it on a default install, or supply one if you have put the
|
|
117
|
+
server behind a reverse proxy or exposed it on your LAN. The prompt is
|
|
118
|
+
never skipped based on the hostname, because a `.local` or LAN address is
|
|
119
|
+
no guarantee that the endpoint is unauthenticated.
|
|
116
120
|
4. The extension connects, fetches the model list from `/v1/models`, and
|
|
117
121
|
registers the provider with pi.
|
|
118
122
|
|
|
@@ -200,8 +204,9 @@ Credentials and cached model lists are stored at:
|
|
|
200
204
|
~/.config/pi-openai-compat/config.json
|
|
201
205
|
```
|
|
202
206
|
|
|
203
|
-
API keys are stored in plaintext
|
|
204
|
-
|
|
207
|
+
API keys are stored in plaintext, so the extension creates the file `0600` and
|
|
208
|
+
the directory `0700`, and tightens both on load if an older version left them
|
|
209
|
+
world-readable. Delete the file to clear all saved credentials.
|
|
205
210
|
|
|
206
211
|
Each provider's `cachedModels` array holds one entry per model. Only `id` is
|
|
207
212
|
required; the rest are optional and fall back to conservative defaults when the
|
|
@@ -215,9 +220,15 @@ provider's `/models` catalog does not report them:
|
|
|
215
220
|
| `reasoning` | boolean | `false` | Enables pi's thinking mode for the model. |
|
|
216
221
|
| `input` | `["text"]` or `["text","image"]` | `["text"]` | Modalities pi may send; `image` lets pi attach image blocks. Any other value is ignored. |
|
|
217
222
|
| `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 |
|
|
223
|
+
| `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
224
|
| `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
225
|
|
|
226
|
+
`contextWindow` and `maxTokens` are validated before they reach pi: a value
|
|
227
|
+
that is not a positive finite number is ignored in favor of the default, and
|
|
228
|
+
anything above 10,000,000 (context) or 1,000,000 (output) is clamped. pi drives
|
|
229
|
+
its context accounting off these numbers, so an inflated value from a
|
|
230
|
+
misreporting or hostile catalog would otherwise inflate every request sent.
|
|
231
|
+
|
|
221
232
|
Most catalogs report none of these beyond the ID, so aggregators and proxies
|
|
222
233
|
(CLIProxyAPI, for example) register every model as a 128K, text-only,
|
|
223
234
|
non-reasoning model. To correct that, edit the entry by hand and `/reload`:
|
package/add-provider.sh
CHANGED
|
@@ -150,8 +150,17 @@ function normalizeModels(body, idField, keepTask) {
|
|
|
150
150
|
const rawId = m ? m[field] : undefined;
|
|
151
151
|
const id = typeof rawId === "string" ? rawId : typeof rawId === "number" ? String(rawId) : "";
|
|
152
152
|
const out = { id };
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
// Mirrors normalizeTokenCount() in index.ts: the catalog is untrusted,
|
|
154
|
+
// so a non-numeric or absurd token count is dropped/clamped rather than
|
|
155
|
+
// written into config.json.
|
|
156
|
+
const num = (v, max) =>
|
|
157
|
+
typeof v === "number" && Number.isFinite(v) && v > 0
|
|
158
|
+
? Math.min(Math.floor(v), max)
|
|
159
|
+
: undefined;
|
|
160
|
+
const ctx = num(m ? m.context_window : undefined, 10000000);
|
|
161
|
+
const max = num(m ? m.max_tokens : undefined, 1000000);
|
|
162
|
+
if (ctx !== undefined) out.contextWindow = ctx;
|
|
163
|
+
if (max !== undefined) out.maxTokens = max;
|
|
155
164
|
return out;
|
|
156
165
|
})
|
|
157
166
|
.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: {
|
|
@@ -380,11 +410,43 @@ const TEMPLATES: Record<string, {
|
|
|
380
410
|
const CONFIG_DIR = path.join(os.homedir(), ".config", "pi-openai-compat");
|
|
381
411
|
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
382
412
|
|
|
413
|
+
// config.json holds every provider's API key in cleartext, so neither it nor
|
|
414
|
+
// its directory may be group- or world-readable. add-provider.sh already writes
|
|
415
|
+
// 0600; these constants keep the extension's own writes consistent with it.
|
|
416
|
+
export const CONFIG_DIR_MODE = 0o700;
|
|
417
|
+
export const CONFIG_FILE_MODE = 0o600;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Tighten a path's permissions when they are broader than `mode`. Needed
|
|
421
|
+
* because the `mode` option of writeFileSync/mkdirSync applies only at
|
|
422
|
+
* creation time: a config.json written at 0644 by an earlier version keeps
|
|
423
|
+
* that mode forever otherwise. A no-op on platforms without POSIX modes.
|
|
424
|
+
*/
|
|
425
|
+
export function restrictPermissions(target: string, mode: number): void {
|
|
426
|
+
try {
|
|
427
|
+
const current = fs.statSync(target).mode & 0o777;
|
|
428
|
+
if ((current & ~mode) !== 0) fs.chmodSync(target, mode);
|
|
429
|
+
} catch (e) {
|
|
430
|
+
console.error(`[openai-compat:restrictPermissions] ${target}`, e);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Create the config directory if absent, and keep it owner-only either way. */
|
|
435
|
+
function ensureConfigDir(): void {
|
|
436
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
437
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: CONFIG_DIR_MODE });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
restrictPermissions(CONFIG_DIR, CONFIG_DIR_MODE);
|
|
441
|
+
}
|
|
442
|
+
|
|
383
443
|
function loadConfig(): ExtensionConfig {
|
|
384
444
|
const empty: ExtensionConfig = { previousModel: null, providers: {} };
|
|
385
445
|
try {
|
|
386
|
-
|
|
446
|
+
ensureConfigDir();
|
|
387
447
|
if (!fs.existsSync(CONFIG_PATH)) return empty;
|
|
448
|
+
// Repair a config written before the extension enforced 0600.
|
|
449
|
+
restrictPermissions(CONFIG_PATH, CONFIG_FILE_MODE);
|
|
388
450
|
|
|
389
451
|
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as Record<string, unknown>;
|
|
390
452
|
const config: ExtensionConfig = {
|
|
@@ -408,8 +470,12 @@ function loadConfig(): ExtensionConfig {
|
|
|
408
470
|
|
|
409
471
|
function saveConfig(config: ExtensionConfig): void {
|
|
410
472
|
try {
|
|
411
|
-
|
|
412
|
-
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2),
|
|
473
|
+
ensureConfigDir();
|
|
474
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), {
|
|
475
|
+
encoding: "utf-8",
|
|
476
|
+
mode: CONFIG_FILE_MODE,
|
|
477
|
+
});
|
|
478
|
+
restrictPermissions(CONFIG_PATH, CONFIG_FILE_MODE);
|
|
413
479
|
} catch (e) {
|
|
414
480
|
console.error("[openai-compat:saveConfig]", e);
|
|
415
481
|
}
|
|
@@ -503,7 +569,7 @@ function migrateDiscoveryFields(config: ExtensionConfig): { healed: string[]; st
|
|
|
503
569
|
// Networking
|
|
504
570
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
505
571
|
|
|
506
|
-
function isLocalUrl(url: string): boolean {
|
|
572
|
+
export function isLocalUrl(url: string): boolean {
|
|
507
573
|
try {
|
|
508
574
|
const { hostname } = new URL(url);
|
|
509
575
|
return (
|
|
@@ -518,6 +584,9 @@ function isLocalUrl(url: string): boolean {
|
|
|
518
584
|
}
|
|
519
585
|
}
|
|
520
586
|
|
|
587
|
+
/** Cap on how much of an upstream error body is surfaced to the user. */
|
|
588
|
+
export const MAX_ERROR_BODY = 500;
|
|
589
|
+
|
|
521
590
|
/** Optional per-provider overrides controlling how /models is fetched. */
|
|
522
591
|
interface FetchOverrides {
|
|
523
592
|
/** Full URL to fetch instead of `<baseUrl>/models`. */
|
|
@@ -528,7 +597,7 @@ interface FetchOverrides {
|
|
|
528
597
|
keepTask?: string;
|
|
529
598
|
}
|
|
530
599
|
|
|
531
|
-
async function fetchModels(
|
|
600
|
+
export async function fetchModels(
|
|
532
601
|
baseUrl: string,
|
|
533
602
|
apiKey: string | null,
|
|
534
603
|
overrides: FetchOverrides = {},
|
|
@@ -545,10 +614,16 @@ async function fetchModels(
|
|
|
545
614
|
|
|
546
615
|
const resp = await fetch(url, { headers });
|
|
547
616
|
if (!resp.ok) {
|
|
548
|
-
//
|
|
549
|
-
//
|
|
617
|
+
// This extension never echoes the Authorization header, but the body is
|
|
618
|
+
// the upstream's: some gateways reflect parts of the submitted credential
|
|
619
|
+
// or internal identifiers into error payloads, and this text is rendered
|
|
620
|
+
// straight into the UI. Keep enough to diagnose, not enough to dump a
|
|
621
|
+
// credential-bearing page.
|
|
550
622
|
const body = await resp.text().catch(() => "");
|
|
551
|
-
|
|
623
|
+
const snippet = body.length > MAX_ERROR_BODY
|
|
624
|
+
? `${body.slice(0, MAX_ERROR_BODY)}… (truncated)`
|
|
625
|
+
: body;
|
|
626
|
+
throw new Error(`HTTP ${resp.status} from ${url}: ${snippet}`);
|
|
552
627
|
}
|
|
553
628
|
|
|
554
629
|
// Normalize the various shapes /models can return:
|
|
@@ -589,8 +664,8 @@ async function fetchModels(
|
|
|
589
664
|
"";
|
|
590
665
|
return {
|
|
591
666
|
id,
|
|
592
|
-
contextWindow: m.context_window,
|
|
593
|
-
maxTokens: m.max_tokens,
|
|
667
|
+
contextWindow: normalizeTokenCount(m.context_window, MAX_CONTEXT_WINDOW),
|
|
668
|
+
maxTokens: normalizeTokenCount(m.max_tokens, MAX_OUTPUT_TOKENS),
|
|
594
669
|
reasoning: typeof m.reasoning === "boolean" ? m.reasoning : undefined,
|
|
595
670
|
input: normalizeInput(m.input),
|
|
596
671
|
};
|
|
@@ -603,7 +678,7 @@ async function fetchModels(
|
|
|
603
678
|
// Provider registration helpers
|
|
604
679
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
605
680
|
|
|
606
|
-
function buildProviderModels(models: CachedModel[]) {
|
|
681
|
+
export function buildProviderModels(models: CachedModel[]) {
|
|
607
682
|
return models.map((m) => {
|
|
608
683
|
const id = m.id;
|
|
609
684
|
return {
|
|
@@ -612,8 +687,10 @@ function buildProviderModels(models: CachedModel[]) {
|
|
|
612
687
|
reasoning: m.reasoning ?? false,
|
|
613
688
|
input: m.input ?? (["text"] as ModelInput[]),
|
|
614
689
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
615
|
-
|
|
616
|
-
|
|
690
|
+
// Re-validated here as well as at fetch time: cachedModels is a
|
|
691
|
+
// hand-editable file, so this is the single choke point pi sees.
|
|
692
|
+
contextWindow: normalizeTokenCount(m.contextWindow, MAX_CONTEXT_WINDOW) ?? 128_000,
|
|
693
|
+
maxTokens: normalizeTokenCount(m.maxTokens, MAX_OUTPUT_TOKENS) ?? 4_096,
|
|
617
694
|
...(m.thinkingLevelMap ? { thinkingLevelMap: m.thinkingLevelMap } : {}),
|
|
618
695
|
...(m.samplingParams ? { samplingParams: m.samplingParams } : {}),
|
|
619
696
|
...(m.compat ? { compat: m.compat } : {}),
|
|
@@ -627,7 +704,7 @@ function buildProviderModels(models: CachedModel[]) {
|
|
|
627
704
|
* it. The fetched list decides which ids exist; the old cache only fills gaps,
|
|
628
705
|
* so a provider that does report a field always wins.
|
|
629
706
|
*/
|
|
630
|
-
function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): CachedModel[] {
|
|
707
|
+
export function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): CachedModel[] {
|
|
631
708
|
const prior = new Map(previous.map((m) => [m.id, m]));
|
|
632
709
|
return fetched.map((m) => {
|
|
633
710
|
const old = prior.get(m.id);
|
|
@@ -645,20 +722,52 @@ function mergeModelMetadata(previous: CachedModel[], fetched: CachedModel[]): Ca
|
|
|
645
722
|
});
|
|
646
723
|
}
|
|
647
724
|
|
|
648
|
-
|
|
725
|
+
/**
|
|
726
|
+
* Stand-in API key for providers that genuinely use none. pi's registerProvider
|
|
727
|
+
* throws `"apiKey" or "oauth" is required when defining models` on a falsy
|
|
728
|
+
* value, which would abort the extension factory before any /compat-* command
|
|
729
|
+
* is registered — leaving no in-app way to repair the config.
|
|
730
|
+
*/
|
|
731
|
+
export const KEYLESS_PLACEHOLDER = "unused";
|
|
732
|
+
|
|
733
|
+
export function compatKey(key: string): string {
|
|
649
734
|
return `compat-${key}`;
|
|
650
735
|
}
|
|
651
736
|
|
|
652
|
-
function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
|
|
737
|
+
export function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
|
|
653
738
|
pi.registerProvider(compatKey(key), {
|
|
654
739
|
name: `compat/${key.replace(/_/g, "-")}`,
|
|
655
740
|
baseUrl: p.baseUrl,
|
|
656
|
-
apiKey
|
|
741
|
+
// pi rejects a provider that defines models unless apiKey is a non-empty
|
|
742
|
+
// string, so a genuinely keyless endpoint (Ollama, llmproxy) still needs a
|
|
743
|
+
// placeholder here. Deliberately not conditioned on the hostname: a .local
|
|
744
|
+
// or LAN host can be key-protected, which is what the wizard now asks
|
|
745
|
+
// about rather than inferring. Endpoints that ignore Authorization discard
|
|
746
|
+
// this; ones that require a key return a clean 401.
|
|
747
|
+
apiKey: p.apiKey ?? KEYLESS_PLACEHOLDER,
|
|
657
748
|
api: "openai-completions" as const,
|
|
658
749
|
models: buildProviderModels(p.cachedModels),
|
|
659
750
|
});
|
|
660
751
|
}
|
|
661
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Register a provider, reporting failure instead of throwing.
|
|
755
|
+
*
|
|
756
|
+
* Every saved provider is registered before any command is registered, so an
|
|
757
|
+
* uncaught throw here would take down the whole extension — including the
|
|
758
|
+
* /compat-login and /compat-logout commands needed to fix whatever caused it.
|
|
759
|
+
* One malformed entry should cost the user that one provider, nothing more.
|
|
760
|
+
*/
|
|
761
|
+
export function tryRegisterProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): boolean {
|
|
762
|
+
try {
|
|
763
|
+
registerProvider(pi, key, p);
|
|
764
|
+
return true;
|
|
765
|
+
} catch (e) {
|
|
766
|
+
console.error(`[openai-compat:tryRegisterProvider] provider "${key}"`, e);
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
662
771
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
663
772
|
// Extension — async factory so registration completes before pi shows /model
|
|
664
773
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -676,7 +785,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
676
785
|
// continues — providers are visible in /model from the very first render.
|
|
677
786
|
for (const [key, p] of Object.entries(config.providers)) {
|
|
678
787
|
if (p.cachedModels.length > 0) {
|
|
679
|
-
|
|
788
|
+
tryRegisterProvider(pi, key, p);
|
|
680
789
|
}
|
|
681
790
|
}
|
|
682
791
|
|
|
@@ -697,8 +806,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
697
806
|
for (const [key, p] of Object.entries(config.providers)) {
|
|
698
807
|
if (p.cachedModels.length > 0) {
|
|
699
808
|
// Use cached list — fast, no network call.
|
|
700
|
-
|
|
701
|
-
|
|
809
|
+
if (tryRegisterProvider(pi, key, p)) registered.push(p.displayName);
|
|
810
|
+
else failed.push(p.displayName);
|
|
702
811
|
} else {
|
|
703
812
|
// Cache is empty (e.g. migrated from older config). Try a live fetch,
|
|
704
813
|
// honoring any per-provider discovery overrides stored on the config.
|
|
@@ -711,8 +820,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
711
820
|
if (models.length > 0) {
|
|
712
821
|
p.cachedModels = models;
|
|
713
822
|
saveConfig(config);
|
|
714
|
-
|
|
715
|
-
|
|
823
|
+
if (tryRegisterProvider(pi, key, p)) {
|
|
824
|
+
registered.push(`${p.displayName} (refreshed)`);
|
|
825
|
+
} else {
|
|
826
|
+
failed.push(p.displayName);
|
|
827
|
+
}
|
|
716
828
|
} else {
|
|
717
829
|
failed.push(p.displayName);
|
|
718
830
|
}
|
|
@@ -893,7 +1005,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
893
1005
|
modelsKeepTask: tpl.modelsKeepTask,
|
|
894
1006
|
};
|
|
895
1007
|
saveConfig(config);
|
|
896
|
-
|
|
1008
|
+
if (!tryRegisterProvider(pi, key, config.providers[key])) {
|
|
1009
|
+
ctx.ui.notify(
|
|
1010
|
+
`${tpl.displayName} was saved but could not be registered with pi. ` +
|
|
1011
|
+
`Run /compat-logout to remove it, or check the log for details.`,
|
|
1012
|
+
"error"
|
|
1013
|
+
);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
897
1016
|
|
|
898
1017
|
ctx.ui.notify(
|
|
899
1018
|
`${tpl.displayName} registered — ${models.length} model(s) added to /model.`,
|
|
@@ -952,8 +1071,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
952
1071
|
// flaky refresh must never blank out a working provider's models.
|
|
953
1072
|
p.cachedModels = mergeModelMetadata(p.cachedModels, models);
|
|
954
1073
|
saveConfig(config);
|
|
955
|
-
|
|
956
|
-
|
|
1074
|
+
if (tryRegisterProvider(pi, key, p)) {
|
|
1075
|
+
refreshed.push(`${p.displayName} (${models.length})`);
|
|
1076
|
+
} else {
|
|
1077
|
+
failed.push(p.displayName);
|
|
1078
|
+
}
|
|
957
1079
|
} else {
|
|
958
1080
|
failed.push(p.displayName);
|
|
959
1081
|
}
|
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.29",
|
|
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
|
}
|