@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.
- package/LICENSE +21 -0
- package/README.md +245 -0
- package/donors.ts +547 -0
- package/enclave-live.ts +364 -0
- package/index.ts +47 -0
- package/package.json +52 -0
- package/scripts/sync-models.mjs +186 -0
- package/scripts/test-sync.mjs +233 -0
package/enclave-live.ts
ADDED
|
@@ -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`);
|