@lokeraar/pi-enclave-bridge 0.1.0

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.
@@ -0,0 +1,364 @@
1
+ /**
2
+ * enclave-live.ts — the small shared core for the EnClave bridge.
3
+ *
4
+ * Values live in `models.json` (`providers.EnClave.models`), written by
5
+ * `scripts/sync-models.mjs`. This module exists so the script and the extension
6
+ * agree on one definition of "a model" and one definition of "where do values
7
+ * come from". Nothing is computed here at runtime except membership.
8
+ *
9
+ * Value precedence, applied by the sync script:
10
+ *
11
+ * 1. DONOR — `providers.opendesign` in the same models.json, matched on the
12
+ * bare model name (the id with any `vendor/` prefix stripped on both
13
+ * sides). The donor is the source of truth. Its numbers are copied as-is,
14
+ * even when they are larger than what a local measurement found: a value
15
+ * the user chose beats one this tool inferred. Lower it deliberately if a
16
+ * problem ever shows up, not preemptively.
17
+ * 2. EXISTING — whatever the EnClave entry already says. This is where
18
+ * hand-measured values live, and for models with no donor it is the only
19
+ * source. Copying is deliberately not additive: the donor replaces the
20
+ * block, so there is no leftover hybrid.
21
+ * 3. GATEWAY — `context_length` and `pricing` only, because those are facts
22
+ * about this endpoint and the donor has no opinion on them.
23
+ *
24
+ * `cost` is always the gateway's: the donor carries no price, so inheriting it
25
+ * would publish every model as free.
26
+ *
27
+ * Membership comes from the live catalog. A model is published only if the
28
+ * catalog lists it AND `routeable_endpoint_count > 0` — an id with no healthy
29
+ * route for this key is not something the picker should offer.
30
+ */
31
+
32
+ import { readFileSync, writeFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import {
35
+ type BundledCatalog,
36
+ type ModelEntry,
37
+ type Resolved,
38
+ bareName,
39
+ resolveModel,
40
+ } from "./donors.ts";
41
+
42
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
43
+ export type ThinkingLevelMap = Partial<Record<ThinkingLevel, string | null>>;
44
+
45
+ export type { ModelEntry } from "./donors.ts";
46
+
47
+ export const ENCLAVE_BASE_URL = "https://router.enclave.ai/v1";
48
+ export const PROVIDER_ID = "EnClave";
49
+
50
+ export { bareName } from "./donors.ts";
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // models.json
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export interface ModelsJson {
57
+ providers?: Record<string, Record<string, unknown> & { models?: ModelEntry[] }>;
58
+ }
59
+
60
+ export function readModelsJson(agentDir: string): ModelsJson {
61
+ return JSON.parse(readFileSync(join(agentDir, "models.json"), "utf8")) as ModelsJson;
62
+ }
63
+
64
+ export function writeModelsJson(agentDir: string, data: ModelsJson): void {
65
+ writeFileSync(join(agentDir, "models.json"), `${JSON.stringify(data, null, 2)}\n`);
66
+ }
67
+
68
+ export function providerModels(data: ModelsJson, provider: string): ModelEntry[] {
69
+ return data.providers?.[provider]?.models ?? [];
70
+ }
71
+
72
+ /** bare name -> the values already written for that model. First wins. */
73
+ export function keptIndex(data: ModelsJson, provider = PROVIDER_ID): Map<string, ModelEntry> {
74
+ const index = new Map<string, ModelEntry>();
75
+ for (const m of providerModels(data, provider)) {
76
+ const bare = bareName(m.id);
77
+ if (!index.has(bare)) index.set(bare, m);
78
+ }
79
+ return index;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Live catalog
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export interface CatalogModel {
87
+ id: string;
88
+ name?: string;
89
+ contextLength?: number;
90
+ pricingPrompt?: number;
91
+ pricingCompletion?: number;
92
+ routeable: boolean;
93
+ }
94
+
95
+ export interface CatalogAlias {
96
+ id: string;
97
+ task: string | null;
98
+ }
99
+
100
+ export interface LiveCatalog {
101
+ models: CatalogModel[];
102
+ aliases: CatalogAlias[];
103
+ }
104
+
105
+ export async function fetchCatalog(
106
+ baseUrl: string,
107
+ key: string,
108
+ signal: AbortSignal,
109
+ ): Promise<LiveCatalog | undefined> {
110
+ try {
111
+ const res = await fetch(`${baseUrl}/models`, {
112
+ headers: { Authorization: `Bearer ${key}` },
113
+ signal: AbortSignal.any([signal, AbortSignal.timeout(20_000)]),
114
+ });
115
+ if (!res.ok) return undefined;
116
+ const json = (await res.json()) as { data?: Array<Record<string, unknown>>; aliases?: Array<Record<string, unknown>> };
117
+ const models: CatalogModel[] = [];
118
+ for (const e of json.data ?? []) {
119
+ if (typeof e.id !== "string") continue;
120
+ const pricing = (e.pricing ?? {}) as Record<string, unknown>;
121
+ const routeableCount = typeof e.routeable_endpoint_count === "number" ? e.routeable_endpoint_count : undefined;
122
+ models.push({
123
+ id: e.id,
124
+ name: typeof e.name === "string" ? e.name : undefined,
125
+ contextLength: typeof e.context_length === "number" ? e.context_length : undefined,
126
+ pricingPrompt: typeof pricing.prompt === "number" ? pricing.prompt : undefined,
127
+ pricingCompletion: typeof pricing.completion === "number" ? pricing.completion : undefined,
128
+ routeable: routeableCount === undefined ? true : routeableCount > 0,
129
+ });
130
+ }
131
+ const aliases: CatalogAlias[] = [];
132
+ for (const e of json.aliases ?? []) {
133
+ if (typeof e.id === "string") aliases.push({ id: e.id, task: typeof e.task === "string" ? e.task : null });
134
+ }
135
+ return models.length ? { models, aliases } : undefined;
136
+ } catch {
137
+ return undefined;
138
+ }
139
+ }
140
+
141
+ /** Cheap liveness call. Returns an error class, or "ok". */
142
+ export async function liveness(
143
+ baseUrl: string,
144
+ key: string,
145
+ modelId: string,
146
+ signal: AbortSignal,
147
+ ): Promise<"ok" | "no-route" | "upstream-gone" | "other"> {
148
+ try {
149
+ const res = await fetch(`${baseUrl}/chat/completions`, {
150
+ method: "POST",
151
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
152
+ body: JSON.stringify({ model: modelId, messages: [{ role: "user", content: "hi" }], max_tokens: 8 }),
153
+ signal: AbortSignal.any([signal, AbortSignal.timeout(20_000)]),
154
+ });
155
+ if (res.ok) return "ok";
156
+ const body = await res.text().catch(() => "");
157
+ if (res.status === 404) return "no-route";
158
+ // The router is a tunnel: it reports the upstream status in the body.
159
+ const m = /returned HTTP (\d{3})/i.exec(body);
160
+ if (m?.[1] === "410") return "upstream-gone";
161
+ return "other";
162
+ } catch {
163
+ return "other";
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Building the block
169
+ // ---------------------------------------------------------------------------
170
+
171
+ export interface BuildResult {
172
+ models: ModelEntry[];
173
+ /** id -> which donors contributed and by which rule, for the report. */
174
+ resolved: Map<string, Resolved>;
175
+ pending: string[];
176
+ skipped: Array<{ id: string; why: string }>;
177
+ }
178
+
179
+ const num = (v: unknown, fallback: number) => (typeof v === "number" && v > 0 ? v : fallback);
180
+
181
+ /**
182
+ * Tokens held back from an output ceiling so the prompt has room. See the clamp
183
+ * in buildBlock.
184
+ */
185
+ const PROMPT_RESERVE_TOKENS = 2_048;
186
+
187
+ /** Aliases the router exposes: never resolved from a donor. See donors.ts. */
188
+ function isAliasId(id: string, aliases: readonly CatalogAlias[]): boolean {
189
+ return aliases.some((a) => a.id === id);
190
+ }
191
+
192
+ /**
193
+ * Build the EnClave model block from the live catalog and every donor.
194
+ *
195
+ * Precedence, per field: the hand-written layer outranks a bundled catalog
196
+ * unless the two agree to within rounding, in which case the exact figure wins.
197
+ * `contextWindow` and `cost` are never taken from a donor — they describe this
198
+ * endpoint.
199
+ */
200
+ export function buildBlock(
201
+ catalog: LiveCatalog,
202
+ kept: Map<string, ModelEntry>,
203
+ bundled: readonly BundledCatalog[],
204
+ baseUrl: string,
205
+ alive: (id: string) => boolean = () => true,
206
+ ): BuildResult {
207
+ const models: ModelEntry[] = [];
208
+ const resolved = new Map<string, Resolved>();
209
+ const pending: string[] = [];
210
+ const skipped: Array<{ id: string; why: string }> = [];
211
+
212
+ const contexts = catalog.models.map((m) => m.contextLength).filter((c): c is number => !!c);
213
+ const pricesIn = catalog.models.map((m) => m.pricingPrompt).filter((c): c is number => !!c);
214
+ const pricesOut = catalog.models.map((m) => m.pricingCompletion).filter((c): c is number => !!c);
215
+
216
+ for (const listing of catalog.models) {
217
+ // routeable_endpoint_count 0 means the catalog lists it but this key has no
218
+ // healthy route: every request 404s.
219
+ if (!listing.routeable) {
220
+ skipped.push({ id: listing.id, why: "sin ruta para esta clave" });
221
+ continue;
222
+ }
223
+ if (!alive(listing.id)) {
224
+ skipped.push({ id: listing.id, why: "no responde" });
225
+ continue;
226
+ }
227
+
228
+ const bare = bareName(listing.id);
229
+ const isAlias = isAliasId(listing.id, catalog.aliases);
230
+ const r = resolveModel(bare, kept.get(bare), bundled, isAlias);
231
+ resolved.set(listing.id, r);
232
+ if (!r.source) pending.push(listing.id);
233
+
234
+ const entry: ModelEntry = {
235
+ ...r.entry,
236
+ id: listing.id,
237
+ name: listing.name ?? listing.id,
238
+ reasoning: r.entry.reasoning ?? true,
239
+ thinkingLevelMap:
240
+ r.entry.thinkingLevelMap ??
241
+ { off: null, minimal: null, low: null, medium: "medium", high: null, xhigh: null, max: null },
242
+ input: r.entry.input ?? ["text"],
243
+ // The donor carries no per-model compat, and this block REPLACES rather
244
+ // than merges, so a default is required. Without it Pi sends
245
+ // role:"developer" and every request fails with
246
+ // messages.0.role: Invalid option: expected one of
247
+ // "system"|"user"|"assistant"|"tool"
248
+ compat: r.entry.compat ?? { supportsDeveloperRole: false },
249
+ // A donor may simply not state an output ceiling. `num` turns that into a
250
+ // conservative default rather than leaving `undefined`, which would reach
251
+ // Pi as null and fail schema validation.
252
+ //
253
+ // Clamped to the window MINUS a prompt reserve, because an output ceiling
254
+ // larger than what the context can hold is not a bigger claim, it is an
255
+ // impossible one. EnClave rejects it outright:
256
+ // "This request needs about N tokens (messages + tools + max_tokens)"
257
+ // so the ceiling is the window minus whatever the prompt occupies. The
258
+ // reserve is deliberately coarse (2048) because the prompt size is not
259
+ // knowable here and the cost of being too generous is a rejected request,
260
+ // while the cost of reserving too little is only headroom.
261
+ //
262
+ // Note the clamp can never EQUAL the window either: measured on inkling,
263
+ // 262,144 was rejected while 261,120 passed. OpenRouter lists that same
264
+ // model at 471,859 against a 262,144 window here, so this is not
265
+ // hypothetical. A value inside the limit is used exactly as given — the
266
+ // clamp removes impossibilities, it does not second-guess the donor.
267
+ maxTokens: Math.min(
268
+ num(r.entry.maxTokens, 16_384),
269
+ Math.max(1_024, num(listing.contextLength, 128_000) - PROMPT_RESERVE_TOKENS),
270
+ ),
271
+ // The endpoint owns these two.
272
+ contextWindow: num(listing.contextLength, 128_000),
273
+ cost: {
274
+ input: listing.pricingPrompt ?? 0,
275
+ output: listing.pricingCompletion ?? 0,
276
+ cacheRead: 0,
277
+ cacheWrite: 0,
278
+ },
279
+ api: "openai-completions",
280
+ donor: r.source
281
+ ? {
282
+ source: r.source,
283
+ matchedId: r.matchedId,
284
+ corroborating: r.corroborating,
285
+ rule: r.rule,
286
+ }
287
+ : undefined,
288
+ };
289
+ models.push(entry);
290
+ }
291
+
292
+ // Router aliases: usable as a model, but their window and price depend on
293
+ // which concrete model the router picks per request, so both are bounded
294
+ // rather than guessed — context at the catalog floor, price at the ceiling.
295
+ // Left vanilla on purpose: the same bare name is a different thing elsewhere.
296
+ for (const alias of catalog.aliases) {
297
+ if (!alive(alias.id)) {
298
+ skipped.push({ id: alias.id, why: "no responde" });
299
+ continue;
300
+ }
301
+ models.push({
302
+ id: alias.id,
303
+ name: alias.id,
304
+ api: "openai-completions",
305
+ reasoning: true,
306
+ thinkingLevelMap: { off: null, minimal: null, low: null, medium: "medium", high: null, xhigh: null, max: null },
307
+ input: ["text"],
308
+ contextWindow: contexts.length ? Math.min(...contexts) : 128_000,
309
+ maxTokens: 16_384,
310
+ compat: { supportsDeveloperRole: false },
311
+ cost: {
312
+ input: pricesIn.length ? Math.max(...pricesIn) : 0,
313
+ output: pricesOut.length ? Math.max(...pricesOut) : 0,
314
+ cacheRead: 0,
315
+ cacheWrite: 0,
316
+ },
317
+ });
318
+ }
319
+
320
+ return { models, resolved, pending, skipped };
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // refreshModels — membership only
325
+ // ---------------------------------------------------------------------------
326
+
327
+ export interface RefreshModelsContextLike {
328
+ credential?: { type?: string; key?: string };
329
+ stored?: { models?: readonly ModelEntry[] };
330
+ publish(p: { persist?: { models: ModelEntry[]; checkedAt?: number } | null; update?: () => void }): Promise<boolean>;
331
+ allowNetwork: boolean;
332
+ signal: AbortSignal;
333
+ }
334
+
335
+ /** This module is imported by index.ts, so it needs a valid factory export. */
336
+ export default async function enclaveHelper(): Promise<void> {}
337
+
338
+ /**
339
+ * Keep membership fresh. Values come from `models.json`, which the sync script
340
+ * owns — this only adds ids the endpoint started serving and drops ids it
341
+ * stopped. It never invents a value.
342
+ */
343
+ export function makeRefreshModels(agentDir: string, baseUrl = ENCLAVE_BASE_URL) {
344
+ return async function refreshModels(ctx: RefreshModelsContextLike): Promise<ModelEntry[] | undefined> {
345
+ if (process.env.PI_ENCLAVE_LIVE === "0") return undefined;
346
+ const key = ctx.credential?.type === "api_key" ? ctx.credential.key : undefined;
347
+ if (!ctx.allowNetwork || !key) return undefined;
348
+
349
+ const catalog = await fetchCatalog(baseUrl, key, ctx.signal);
350
+ if (!catalog || ctx.signal.aborted) return undefined;
351
+
352
+ const live = new Set<string>([...catalog.models.filter((m) => m.routeable).map((m) => m.id), ...catalog.aliases.map((a) => a.id)]);
353
+ const configured = providerModels(readModelsJson(agentDir), PROVIDER_ID);
354
+
355
+ // Known values for anything the endpoint still serves. A new id has no
356
+ // values here; `scripts/sync-models.mjs` is what fills it in.
357
+ const out = configured.filter((m) => live.has(m.id));
358
+ for (const id of live) if (!out.some((m) => m.id === id)) out.push({ id, name: id });
359
+
360
+ if (ctx.signal.aborted) return undefined;
361
+ const ok = await ctx.publish({ persist: { models: out, checkedAt: Date.now() } });
362
+ return ok && !ctx.signal.aborted ? out : undefined;
363
+ };
364
+ }
package/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @lokeraar/pi-enclave-bridge — EnClave provider for Pi.
3
+ *
4
+ * The values live in `models.json` under `providers.EnClave`. This file only
5
+ * registers the provider and keeps its model list in step with the endpoint.
6
+ *
7
+ * Two scripts own the data:
8
+ *
9
+ * scripts/sync-models.mjs rebuilds the EnClave block: reads the live
10
+ * catalog, copies values from the `opendesign`
11
+ * donor by bare model name, keeps the endpoint's own
12
+ * context window and price, and drops models that do
13
+ * not answer. Run it after changing the donor.
14
+ *
15
+ * scripts/probe-models.mjs optional; measures reasoning levels and output
16
+ * ceilings for models with no donor, so the "work by
17
+ * hand" list has somewhere to go.
18
+ *
19
+ * Local install: copy `index.ts` and `enclave-live.ts` into
20
+ * `~/.pi/agent/extensions/`, renaming `index.ts` to `enclave-bridge.ts`. Pi
21
+ * loads every `extensions/*.ts` as a factory, so a file called `index.ts`
22
+ * collides and registers the provider twice.
23
+ */
24
+
25
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import {
27
+ ENCLAVE_BASE_URL,
28
+ makeRefreshModels,
29
+ providerModels,
30
+ readModelsJson,
31
+ PROVIDER_ID,
32
+ } from "./enclave-live.ts";
33
+
34
+ export default async function (pi: ExtensionAPI) {
35
+ const agentDir = getAgentDir();
36
+ const cfg = readModelsJson(agentDir).providers?.[PROVIDER_ID] ?? {};
37
+ const baseUrl = (cfg.baseUrl as string) ?? ENCLAVE_BASE_URL;
38
+
39
+ pi.registerProvider(PROVIDER_ID, {
40
+ name: (cfg.name as string) ?? "EnClave",
41
+ api: (cfg.api as string) ?? "openai-completions",
42
+ baseUrl,
43
+ authHeader: cfg.authHeader !== false,
44
+ models: providerModels(readModelsJson(agentDir), PROVIDER_ID),
45
+ refreshModels: makeRefreshModels(agentDir, baseUrl),
46
+ });
47
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@lokeraar/pi-enclave-bridge",
3
+ "version": "0.1.0",
4
+ "description": "EnClave provider bridge for Pi: /login once and the live router catalog appears in /model with real context windows, real per-token prices, live membership and the router's task aliases \u2014 resolved from the model catalog Pi already ships.",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "files": [
8
+ "*.ts",
9
+ "scripts/*.mjs",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "pi": {
14
+ "extensions": [
15
+ "./index.ts"
16
+ ]
17
+ },
18
+ "keywords": [
19
+ "pi",
20
+ "pi-package",
21
+ "pi-extension",
22
+ "provider",
23
+ "ai",
24
+ "ai-provider",
25
+ "llm",
26
+ "enclave",
27
+ "enclave-ai",
28
+ "cyberouter",
29
+ "openai-compatible",
30
+ "openrouter-compatible",
31
+ "model-catalog",
32
+ "model-sync",
33
+ "ai-model",
34
+ "llm-router"
35
+ ],
36
+ "author": "Lokeraar",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/Lokeraar/pi-EnClave-bridge.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/Lokeraar/pi-EnClave-bridge/issues"
44
+ },
45
+ "homepage": "https://pi.dev/packages/@lokeraar/pi-enclave-bridge",
46
+ "scripts": {
47
+ "test": "node --experimental-strip-types scripts/test-sync.mjs"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sync-models.mjs — rebuild the `EnClave` block of `models.json`.
4
+ *
5
+ * The whole donor mechanism lives here, in one readable place:
6
+ *
7
+ * 1. Read the live catalog from the endpoint — who is actually served.
8
+ * 2. Read the donor (`providers.opendesign` in the same file) by BARE model
9
+ * name: the id with any `vendor/` prefix stripped on both sides.
10
+ * 3. Copy the donor's values onto the matching EnClave entries.
11
+ * 4. For models with no donor, keep whatever the block already says.
12
+ * 5. For models with neither, report them — that is the hand work left to do.
13
+ *
14
+ * The donor is the source of truth. Its numbers are copied as they are, even
15
+ * when they are larger than a local measurement: a value chosen on purpose
16
+ * beats one this tool inferred. If a value ever causes a problem, lower it
17
+ * deliberately then, not preemptively here.
18
+ *
19
+ * Two fields are never taken from the donor, because they describe this
20
+ * endpoint rather than the model:
21
+ * contextWindow — the catalog declares it, and it is what actually serves
22
+ * cost — the donor has no price at all; inheriting it would make
23
+ * every model look free
24
+ *
25
+ * A model is published only if the catalog lists it, `routeable_endpoint_count`
26
+ * is greater than 0, and it answers a request.
27
+ *
28
+ * Usage:
29
+ * node --experimental-strip-types scripts/sync-models.mjs --dry-run
30
+ * node --experimental-strip-types scripts/sync-models.mjs
31
+ */
32
+
33
+ import { copyFileSync } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { join } from "node:path";
36
+
37
+ const HERE = new URL(".", import.meta.url).pathname;
38
+ const ROOT = join(HERE, "..");
39
+ const {
40
+ ENCLAVE_BASE_URL,
41
+ PROVIDER_ID,
42
+ buildBlock,
43
+ fetchCatalog,
44
+ keptIndex,
45
+ liveness,
46
+ providerModels,
47
+ readModelsJson,
48
+ writeModelsJson,
49
+ } = await import(join(ROOT, "enclave-live.ts"));
50
+ const { findBundledCatalogDir, readPiCatalogs } = await import(join(ROOT, "donors.ts"));
51
+
52
+ const argv = process.argv.slice(2);
53
+ const flag = (n) => argv.includes(n);
54
+ const opt = (n, d) => {
55
+ const i = argv.indexOf(n);
56
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : d;
57
+ };
58
+
59
+ if (flag("--help")) {
60
+ console.log("sync-models — rebuild the EnClave block of models.json\n\n --dry-run report only, write nothing\n --agent-dir <dir> default ~/.pi/agent");
61
+ process.exit(0);
62
+ }
63
+
64
+ const agentDir = opt("--agent-dir", join(homedir(), ".pi", "agent"));
65
+ const dryRun = flag("--dry-run");
66
+ const checkLive = !flag("--no-check");
67
+
68
+ const modelsJsonPath = join(agentDir, "models.json");
69
+ const data = readModelsJson(agentDir);
70
+ const cfg = data.providers?.[PROVIDER_ID] ?? {};
71
+ const baseUrl = cfg.baseUrl ?? ENCLAVE_BASE_URL;
72
+ const key = (cfg.apiKey ?? process.env.ENCLAVE_API_KEY ?? "").trim();
73
+
74
+ if (!key) {
75
+ console.error(`No API key. Add providers.${PROVIDER_ID}.apiKey in ${modelsJsonPath}, or set ENCLAVE_API_KEY.`);
76
+ process.exit(2);
77
+ }
78
+
79
+ const signal = AbortSignal.any([AbortSignal.timeout(120_000)]);
80
+ const catalog = await fetchCatalog(baseUrl, key, signal);
81
+ if (!catalog) {
82
+ console.error(`GET ${baseUrl}/models failed.`);
83
+ process.exit(1);
84
+ }
85
+
86
+ // A listed model with routeable_endpoint_count 0 is already excluded by the
87
+ // catalog. Beyond that, check that each one actually answers.
88
+ const dead = new Set();
89
+ if (checkLive) {
90
+ process.stderr.write("Comprobando que cada modelo responde...\n");
91
+ for (const listing of catalog.models) {
92
+ if (!listing.routeable) continue;
93
+ const state = await liveness(baseUrl, key, listing.id, signal);
94
+ if (state === "upstream-gone" || state === "no-route") dead.add(listing.id);
95
+ }
96
+ for (const alias of catalog.aliases) {
97
+ const state = await liveness(baseUrl, key, alias.id, signal);
98
+ if (state === "upstream-gone" || state === "no-route") dead.add(alias.id);
99
+ }
100
+ }
101
+
102
+ const kept = keptIndex(data);
103
+ // Pi's own bundled catalogs, for whichever providers are active. The directory
104
+ // name carries the pi-ai version and a dependency hash, so it is discovered at
105
+ // run time rather than stored.
106
+ const catalogDir = findBundledCatalogDir(agentDir);
107
+ const bundled = catalogDir ? readPiCatalogs(agentDir, { exclude: [PROVIDER_ID] }) : [];
108
+ const result = buildBlock(catalog, kept, bundled, baseUrl, (id) => !dead.has(id));
109
+
110
+ const short = (id) => id.replace(/^cyberouter\//, "");
111
+ const byRule = (rule) => [...result.resolved.entries()].filter(([, r]) => r.rule === rule && r.source);
112
+
113
+ const report = [
114
+ `EnClave — ${baseUrl}`,
115
+ `${result.models.length} publicables (${catalog.models.length} modelos + ${catalog.aliases.length} aliases en el catálogo)`,
116
+ ``,
117
+ `Donantes disponibles:`,
118
+ ` ya escrito ${kept.size} entradas en providers.EnClave`,
119
+ ` catálogos de Pi ${bundled.length} leídos: ${bundled.slice(0, 6).map((c) => c.provider).join(", ")}${bundled.length > 6 ? " …" : ""}`,
120
+ ` catálogo de Pi ${catalogDir ?? "NO ENCONTRADO"}`,
121
+ ``,
122
+ `Resueltos desde un donante: ${[...result.resolved.values()].filter((r) => r.source).length}`,
123
+ ``,
124
+ `Orden: model card del vendor > openrouter > otros catálogos (solo confirman).`,
125
+ ];
126
+
127
+ const grouped = [
128
+ ["model card del vendor (máxima autoridad)", byRule("vendor")],
129
+ ["de openrouter, confirmado por otros catálogos", byRule("corroborated")],
130
+ ["de openrouter", byRule("donated")],
131
+ ["sin donante: queda lo ya escrito", byRule("kept")],
132
+ ];
133
+ for (const [label, rows] of grouped) {
134
+ if (!rows.length) continue;
135
+ report.push(`\n ${label}: ${rows.length}`);
136
+ for (const [id, r] of rows) {
137
+ // Confirmar con 42 catálogos no es información: es ruido. Con 3 alcanza.
138
+ const n = r.corroborating.length;
139
+ const shown = r.corroborating.slice(0, 3).join(", ");
140
+ const more = n > 3 ? ` +${n - 3} más` : "";
141
+ const corr = n ? ` confirmado por ${n}: ${shown}${more}` : "";
142
+ report.push(` ${short(id).padEnd(22)} ${r.matchedId ?? "-"}`.padEnd(52) + ` ${r.source ?? "-"}${corr}`);
143
+ }
144
+ }
145
+
146
+ const corroboratedOnly = [...result.resolved.values()].filter((r) => r.source !== "hand" && r.corroborating.length);
147
+ if (corroboratedOnly.length) {
148
+ report.push(`\n además confirmados por un segundo proveedor: ${corroboratedOnly.length}`);
149
+ }
150
+
151
+ // Aliases are handled in their own loop, so they are reported separately rather
152
+ // than looked for among the resolved models.
153
+ const aliasIds = new Set(catalog.aliases.map((a) => a.id));
154
+ const aliasesPublished = result.models.filter((m) => aliasIds.has(m.id));
155
+ report.push(`\n aliases publicados sin tocar (vanilla, a propósito): ${aliasesPublished.length}`);
156
+ for (const a of aliasesPublished) report.push(` ${short(a.id)}`);
157
+
158
+ const pendingReal = result.pending.filter((id) => !aliasIds.has(id));
159
+ if (pendingReal.length) {
160
+ report.push(`\nModelos sin donante y sin valores — trabajo a mano: ${pendingReal.length}`);
161
+ for (const id of pendingReal) report.push(` ? ${short(id)}`);
162
+ }
163
+ if (result.skipped.length) {
164
+ report.push(`\nExcluidos: ${result.skipped.length}`);
165
+ for (const s of result.skipped) report.push(` x ${short(s.id).padEnd(24)} ${s.why}`);
166
+ }
167
+ report.push("");
168
+ console.log(report.join("\n"));
169
+
170
+ if (dryRun) {
171
+ console.log("--dry-run: no se escribió nada.\n");
172
+ process.exit(0);
173
+ }
174
+
175
+ copyFileSync(modelsJsonPath, `${modelsJsonPath}.bak`);
176
+ data.providers ??= {};
177
+ data.providers[PROVIDER_ID] = {
178
+ ...cfg,
179
+ name: cfg.name ?? "EnClave",
180
+ baseUrl,
181
+ api: cfg.api ?? "openai-completions",
182
+ authHeader: cfg.authHeader !== false,
183
+ models: result.models,
184
+ };
185
+ writeModelsJson(agentDir, data);
186
+ console.log(`Escrito: ${result.models.length} modelos. Backup en ${modelsJsonPath}.bak\n`);