@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/donors.ts
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* donors.ts — where model values come from.
|
|
3
|
+
*
|
|
4
|
+
* A "donor" is a list of models whose values can be copied onto ours, matched on
|
|
5
|
+
* the BARE model name: the id with any `vendor/` prefix stripped on both sides,
|
|
6
|
+
* so `cyberouter/glm-5.3-flash` matches `glm-5.3-flash`. A prefix relationship is
|
|
7
|
+
* not an identity: `cyberouter/glm-5.3` does not inherit from `glm-5.3-flash`.
|
|
8
|
+
*
|
|
9
|
+
* ## One source, and it ships with Pi
|
|
10
|
+
*
|
|
11
|
+
* The donor is the catalog Pi bundles inside its own package:
|
|
12
|
+
*
|
|
13
|
+
* pi-ai/dist/providers/data/<provider>.json
|
|
14
|
+
*
|
|
15
|
+
* There is no hand-written donor and none is required. A list maintained by a
|
|
16
|
+
* person goes stale, and making every user own an account with an obscure
|
|
17
|
+
* provider before the extension does anything useful is a dependency nobody
|
|
18
|
+
* should have to accept. These files are already on disk after installing Pi,
|
|
19
|
+
* and reading a file needs no credential — a key is only needed to CALL an API,
|
|
20
|
+
* not to read what Pi shipped.
|
|
21
|
+
*
|
|
22
|
+
* openrouter the primary donor. It is the largest model router in the world
|
|
23
|
+
* and the catalog is its core business, so the numbers are kept
|
|
24
|
+
* by people who cannot afford to be wrong.
|
|
25
|
+
* the rest corroboration only. A provider lower in the order confirms the
|
|
26
|
+
* model exists and agrees on its structure; it fills a field the
|
|
27
|
+
* ones above left empty and never overrides.
|
|
28
|
+
*
|
|
29
|
+
* ## The order
|
|
30
|
+
*
|
|
31
|
+
* what is already in models.json > openrouter > the rest
|
|
32
|
+
*
|
|
33
|
+
* What is already written wins, because it is specific to this endpoint. Inside
|
|
34
|
+
* a 5% band the bundled figure takes over, since `128_000` and `131_072` are the
|
|
35
|
+
* same number written differently and the second is the exact one. Outside that
|
|
36
|
+
* band they genuinely disagree and what is written stands. Nothing is ever
|
|
37
|
+
* averaged: a number nobody published is not a consensus, it is an invention.
|
|
38
|
+
*
|
|
39
|
+
* ## What no donor may set
|
|
40
|
+
*
|
|
41
|
+
* contextWindow — the live catalog states what this endpoint actually serves
|
|
42
|
+
* cost — a donor's price is for a different reseller
|
|
43
|
+
* compat — OpenRouter's `thinkingFormat: "openrouter"` and friends
|
|
44
|
+
* describe how OpenRouter wants reasoning sent. EnClave speaks
|
|
45
|
+
* the OpenAI shape, which is how it was verified. Copying
|
|
46
|
+
* those flags would change the request format on an endpoint
|
|
47
|
+
* they were never tested against.
|
|
48
|
+
*
|
|
49
|
+
* ## Router aliases are never resolved
|
|
50
|
+
*
|
|
51
|
+
* `cyberouter/auto` is EnClave's own pseudo-model. OpenRouter has an `auto` too,
|
|
52
|
+
* advertising a 2,000,000 window — a different thing that happens to share the
|
|
53
|
+
* name, and a lie here.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
57
|
+
import { dirname, join, resolve } from "node:path";
|
|
58
|
+
import { fileURLToPath } from "node:url";
|
|
59
|
+
|
|
60
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
61
|
+
export type ThinkingLevelMap = Partial<Record<ThinkingLevel, string | null>>;
|
|
62
|
+
|
|
63
|
+
export interface ModelEntry {
|
|
64
|
+
id: string;
|
|
65
|
+
name?: string;
|
|
66
|
+
api?: string;
|
|
67
|
+
provider?: string;
|
|
68
|
+
reasoning?: boolean;
|
|
69
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
70
|
+
input?: Array<"text" | "image">;
|
|
71
|
+
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
72
|
+
contextWindow?: number;
|
|
73
|
+
maxTokens?: number;
|
|
74
|
+
compat?: Record<string, unknown>;
|
|
75
|
+
/**
|
|
76
|
+
* Set by the sync script. `source` supplied the values; `corroborating`
|
|
77
|
+
* lists the other providers that also know the model and agree it exists.
|
|
78
|
+
*/
|
|
79
|
+
donor?: {
|
|
80
|
+
/** The provider that supplied the values. */
|
|
81
|
+
source: string;
|
|
82
|
+
/**
|
|
83
|
+
* The exact id that matched, WITH its prefix — `qwen/qwen3.8-max-0902`, not
|
|
84
|
+
* `qwen3.8-max`. Matching is done on the bare name because the two sides
|
|
85
|
+
* carry different vendor prefixes, but recording the full id is what makes a
|
|
86
|
+
* match auditable: you can see which entry was taken, including when it came
|
|
87
|
+
* from a dated slug.
|
|
88
|
+
*/
|
|
89
|
+
matchedId?: string;
|
|
90
|
+
/** Other catalogs that also know this model. They confirm, never override. */
|
|
91
|
+
corroborating: string[];
|
|
92
|
+
rule: "vendor" | "donated" | "kept" | "corroborated" | "none";
|
|
93
|
+
};
|
|
94
|
+
[key: string]: unknown;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Fields a bundled donor may contribute. `compat` is deliberately absent. */
|
|
98
|
+
export const BUNDLED_FIELDS = ["reasoning", "thinkingLevelMap", "input", "maxTokens"] as const;
|
|
99
|
+
|
|
100
|
+
/** Fields already written in models.json may contribute, `compat` included. */
|
|
101
|
+
export const HAND_FIELDS = [...BUNDLED_FIELDS, "compat"] as const;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* How close a hand-written value must be to a bundled one to count as the same
|
|
105
|
+
* number. `128_000` vs `131_072` is 2.3%; `232_000` vs `384_000` is 66%.
|
|
106
|
+
*/
|
|
107
|
+
export const ROUNDING_TOLERANCE = 0.05;
|
|
108
|
+
|
|
109
|
+
export function bareName(id: string): string {
|
|
110
|
+
const i = id.lastIndexOf("/");
|
|
111
|
+
return i === -1 ? id : id.slice(i + 1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* EnClave sells a model under a short name; a catalog lists the same weights
|
|
116
|
+
* under the dated slug the vendor publishes. These are the same model, so the
|
|
117
|
+
* dated slug is looked up when the short one finds nothing.
|
|
118
|
+
*
|
|
119
|
+
* Kept as data, not a rule: each line is a fact about one model, not a general
|
|
120
|
+
* "strip the date" heuristic. Stripping dates automatically would be wrong —
|
|
121
|
+
* `deepseek-v4-flash` and `deepseek-v4-flash-0731` are different checkpoints,
|
|
122
|
+
* and so is `qwen3.8-max` from `qwen3.8-max-0902`.
|
|
123
|
+
*/
|
|
124
|
+
export const NAME_ALIASES: Record<string, readonly string[]> = {
|
|
125
|
+
"qwen3.8-max": ["qwen3.8-max-0902"],
|
|
126
|
+
"nemotron-ultra": ["nemotron-3-ultra-550b-a55b"],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function num(v: unknown): number | undefined {
|
|
130
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Where Pi keeps its bundled catalogs
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Find `pi-ai/dist/providers/data` without hardcoding the version.
|
|
139
|
+
*
|
|
140
|
+
* The directory is named `@earendil-works+pi-ai@<version>_<dependency hash>`,
|
|
141
|
+
* so the hash changes on every Pi update and any stored path dies with it. It
|
|
142
|
+
* sits under the pnpm store beneath the agent directory; this walks that store
|
|
143
|
+
* rather than guessing a depth.
|
|
144
|
+
*/
|
|
145
|
+
export interface CatalogLocation {
|
|
146
|
+
dir: string;
|
|
147
|
+
/** Which copy this is, for the report. */
|
|
148
|
+
origin: "pi install" | "global install";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function catalogAt(piAiPackage: string): string | undefined {
|
|
152
|
+
const candidate = join(dirname(piAiPackage), "dist", "providers", "data");
|
|
153
|
+
try {
|
|
154
|
+
if (statSync(candidate).isDirectory()) return candidate;
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Where the Pi package itself may live. Only used to resolve pi-ai.
|
|
163
|
+
*
|
|
164
|
+
* The prefix is derived from `process.execPath` rather than assumed: on Termux
|
|
165
|
+
* it is `/data/data/com.termux/files/usr`, not `/usr`, so a hardcoded `/usr/lib`
|
|
166
|
+
* silently never matches. Getting this wrong does not throw — it just sends the
|
|
167
|
+
* lookup to the wrong copy of the catalog.
|
|
168
|
+
*/
|
|
169
|
+
function piPackageCandidates(): string[] {
|
|
170
|
+
const out: string[] = [];
|
|
171
|
+
const add = (base: string) => {
|
|
172
|
+
const p = resolve(base, "@earendil-works", "pi-coding-agent");
|
|
173
|
+
try {
|
|
174
|
+
if (statSync(p).isDirectory()) out.push(p);
|
|
175
|
+
} catch {
|
|
176
|
+
// not here
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// The install this module lives under, when Pi loads it from its own bundle.
|
|
181
|
+
add(dirname(fileURLToPath(import.meta.url)) + "/..");
|
|
182
|
+
|
|
183
|
+
// The global prefix this node was installed under.
|
|
184
|
+
try {
|
|
185
|
+
add(dirname(dirname(process.execPath)) + "/lib/node_modules");
|
|
186
|
+
} catch {
|
|
187
|
+
// execPath unavailable
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Conventional prefixes, only used where they actually exist.
|
|
191
|
+
add("/usr/local/lib/node_modules");
|
|
192
|
+
add("/usr/lib/node_modules");
|
|
193
|
+
|
|
194
|
+
return [...new Set(out)];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Find Pi's bundled catalogs without depending on a version or a hash.
|
|
199
|
+
*
|
|
200
|
+
* The directory is named `@earendil-works+pi-ai@<version>_<dependency hash>`, so
|
|
201
|
+
* any stored path dies on the next update. Only stable prefixes are used and the
|
|
202
|
+
* tree is searched at run time.
|
|
203
|
+
*
|
|
204
|
+
* Order matters, because there can be more than one copy of pi-ai and reading
|
|
205
|
+
* the wrong one fails silently:
|
|
206
|
+
*
|
|
207
|
+
* 1. ASK NODE. `createRequire` from Pi's own package resolves the exact pi-ai
|
|
208
|
+
* that Pi loads its models from. That is ground truth, not a guess.
|
|
209
|
+
* 2. FALL BACK to the agent's pnpm store, which is where `pi install npm:…`
|
|
210
|
+
* puts things on this device. When several versions sit there, the one whose
|
|
211
|
+
* version matches Pi's package wins; otherwise the highest, never a `+` build.
|
|
212
|
+
*
|
|
213
|
+
* On this device step 1 currently fails: the global install of pi-ai is an empty
|
|
214
|
+
* directory, so Pi resolves it from the agent's store. Both steps are kept
|
|
215
|
+
* because either can be true after an update.
|
|
216
|
+
*/
|
|
217
|
+
export function findBundledCatalogs(agentDir: string): CatalogLocation[] {
|
|
218
|
+
const found: CatalogLocation[] = [];
|
|
219
|
+
const push = (dir: string | undefined, origin: CatalogLocation["origin"]) => {
|
|
220
|
+
if (dir && !found.some((f) => f.dir === dir)) found.push({ dir, origin });
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Walking the tree by hand rather than using module resolution: pi-ai is
|
|
224
|
+
// ESM-only with an `exports` map that exposes neither a main nor its own
|
|
225
|
+
// package.json, so require.resolve fails on it with
|
|
226
|
+
// ERR_PACKAGE_PATH_NOT_EXPORTED. The directory is right there next to Pi, so
|
|
227
|
+
// looking for it directly is both simpler and immune to that.
|
|
228
|
+
for (const piPkg of piPackageCandidates()) {
|
|
229
|
+
push(catalogAt(join(piPkg, "node_modules", "@earendil-works", "pi-ai", "package.json")), "la que usa Pi");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const dir of storeCatalogs(agentDir)) push(dir, "pi install");
|
|
233
|
+
return found;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Every usable copy in the agent's pnpm store, best first.
|
|
238
|
+
*
|
|
239
|
+
* When several versions sit there the newest wins, and a `+` build never does —
|
|
240
|
+
* a prerelease is never the one a released Pi runs. Exported separately so the
|
|
241
|
+
* preference order can be tested without the Pi install shadowing it.
|
|
242
|
+
*/
|
|
243
|
+
export function storeCatalogs(agentDir: string): string[] {
|
|
244
|
+
const store = join(agentDir, "npm", "node_modules", ".pnpm");
|
|
245
|
+
let entries: string[] = [];
|
|
246
|
+
try {
|
|
247
|
+
entries = readdirSync(store).filter((e) => e.startsWith("@earendil-works+pi-ai@"));
|
|
248
|
+
} catch {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
const out: string[] = [];
|
|
252
|
+
entries
|
|
253
|
+
.map((e) => ({ entry: e, version: e.slice("@earendil-works+pi-ai@".length).split("_")[0] }))
|
|
254
|
+
.filter((c) => !c.version.includes("+"))
|
|
255
|
+
.sort((a, b) => compareVersions(b.version, a.version))
|
|
256
|
+
.forEach((c) => {
|
|
257
|
+
const dir = catalogAt(join(store, c.entry, "node_modules", "@earendil-works", "pi-ai", "package.json"));
|
|
258
|
+
if (dir) out.push(dir);
|
|
259
|
+
});
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Pi's own version, for the report only. Note this is NOT pi-ai's version:
|
|
264
|
+
* the two use independent numbering (1.0.0 vs 0.85.1), so they never match and
|
|
265
|
+
* must not be compared. */
|
|
266
|
+
export function piPackageVersion(): string | undefined {
|
|
267
|
+
for (const piPkg of piPackageCandidates()) {
|
|
268
|
+
try {
|
|
269
|
+
const pkg = JSON.parse(readFileSync(join(piPkg, "package.json"), "utf8")) as { version?: string };
|
|
270
|
+
if (pkg.version) return pkg.version;
|
|
271
|
+
} catch {
|
|
272
|
+
// try the next
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Numeric dotted comparison, so 0.9 sorts below 0.10. */
|
|
279
|
+
function compareVersions(a: string, b: string): number {
|
|
280
|
+
const pa = a.split(".").map((n) => Number(n) || 0);
|
|
281
|
+
const pb = b.split(".").map((n) => Number(n) || 0);
|
|
282
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
283
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
284
|
+
if (d) return d;
|
|
285
|
+
}
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The catalogs to read, best location first. Never throws. */
|
|
290
|
+
export function findBundledCatalogDir(agentDir: string): string | undefined {
|
|
291
|
+
return findBundledCatalogs(agentDir)[0]?.dir;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Provider ids that have a credential in `auth.json`. */
|
|
295
|
+
export function activeProviders(agentDir: string): Set<string> {
|
|
296
|
+
try {
|
|
297
|
+
const auth = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8")) as Record<string, unknown>;
|
|
298
|
+
return new Set(Object.keys(auth));
|
|
299
|
+
} catch {
|
|
300
|
+
return new Set();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface BundledCatalog {
|
|
305
|
+
provider: string;
|
|
306
|
+
/** bare name -> entry */
|
|
307
|
+
models: Map<string, ModelEntry>;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Read one bundled catalog. The JSON is keyed by API, then by model id. */
|
|
311
|
+
export function readBundledCatalog(
|
|
312
|
+
catalogDir: string,
|
|
313
|
+
provider: string,
|
|
314
|
+
): BundledCatalog | undefined {
|
|
315
|
+
let raw: Record<string, Record<string, ModelEntry>>;
|
|
316
|
+
try {
|
|
317
|
+
raw = JSON.parse(readFileSync(join(catalogDir, `${provider}.json`), "utf8"));
|
|
318
|
+
} catch {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
const models = new Map<string, ModelEntry>();
|
|
322
|
+
for (const byApi of Object.values(raw)) {
|
|
323
|
+
if (!byApi || typeof byApi !== "object") continue;
|
|
324
|
+
for (const entry of Object.values(byApi)) {
|
|
325
|
+
if (!entry || typeof entry.id !== "string") continue;
|
|
326
|
+
// "free" models are deliberately excluded: they routinely ship with
|
|
327
|
+
// capabilities cut down, so their numbers describe a reduced product.
|
|
328
|
+
if (/free$/i.test(entry.id)) continue;
|
|
329
|
+
const bare = bareName(entry.id);
|
|
330
|
+
if (!models.has(bare)) models.set(bare, entry);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return { provider, models };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Every bundled catalog belonging to an active provider, free ids removed. */
|
|
337
|
+
/**
|
|
338
|
+
* Every catalog Pi ships, best donor first.
|
|
339
|
+
*
|
|
340
|
+
* No credential is required and none is asked for: these files are data Pi
|
|
341
|
+
* already installed. OpenRouter leads because it is the primary donor; the rest
|
|
342
|
+
* are corroboration.
|
|
343
|
+
*/
|
|
344
|
+
export function readPiCatalogs(agentDir: string, options: { exclude?: readonly string[] } = {}): BundledCatalog[] {
|
|
345
|
+
const dir = findBundledCatalogDir(agentDir);
|
|
346
|
+
if (!dir) return [];
|
|
347
|
+
const skip = new Set(options.exclude ?? []);
|
|
348
|
+
let files: string[] = [];
|
|
349
|
+
try {
|
|
350
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
351
|
+
} catch {
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
const out: BundledCatalog[] = [];
|
|
355
|
+
for (const file of files) {
|
|
356
|
+
const provider = file.slice(0, -".json".length);
|
|
357
|
+
if (skip.has(provider)) continue;
|
|
358
|
+
const catalog = readBundledCatalog(dir, provider);
|
|
359
|
+
if (catalog && catalog.models.size) out.push(catalog);
|
|
360
|
+
}
|
|
361
|
+
const rank = (p: string) => {
|
|
362
|
+
const i = PROVIDER_PRIORITY.indexOf(p as (typeof PROVIDER_PRIORITY)[number]);
|
|
363
|
+
return i === -1 ? PROVIDER_PRIORITY.length : i;
|
|
364
|
+
};
|
|
365
|
+
return out.sort((a, b) => rank(a.provider) - rank(b.provider) || a.provider.localeCompare(b.provider));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
): BundledCatalog[] {
|
|
370
|
+
const dir = findBundledCatalogDir(agentDir);
|
|
371
|
+
if (!dir) return [];
|
|
372
|
+
const skip = new Set(options.exclude ?? []);
|
|
373
|
+
const out: BundledCatalog[] = [];
|
|
374
|
+
for (const provider of activeProviders(agentDir)) {
|
|
375
|
+
if (skip.has(provider)) continue;
|
|
376
|
+
const catalog = readBundledCatalog(dir, provider);
|
|
377
|
+
if (catalog && catalog.models.size) out.push(catalog);
|
|
378
|
+
}
|
|
379
|
+
// Strict order: the first provider that knows a model supplies its values.
|
|
380
|
+
// The hand-written layer is passed separately and always outranks these.
|
|
381
|
+
const rank = (p: string) => {
|
|
382
|
+
const i = PROVIDER_PRIORITY.indexOf(p as (typeof PROVIDER_PRIORITY)[number]);
|
|
383
|
+
return i === -1 ? PROVIDER_PRIORITY.length : i;
|
|
384
|
+
};
|
|
385
|
+
return out.sort((a, b) => rank(a.provider) - rank(b.provider) || a.provider.localeCompare(b.provider));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Resolving one model
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Which provider's catalog outranks which. Anything not listed falls after
|
|
394
|
+
* these, in the order they appear in auth.json.
|
|
395
|
+
*/
|
|
396
|
+
export const PROVIDER_PRIORITY = ["openrouter"] as const;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The vendor's own model card, where it is known and a catalog is wrong.
|
|
400
|
+
*
|
|
401
|
+
* This outranks every catalog, including OpenRouter. The reasoning is specific:
|
|
402
|
+
* a catalog records what a RESELLER believes a model accepts, while the model
|
|
403
|
+
* card records what the model itself implements. When those disagree the
|
|
404
|
+
* catalog is usually not lying — it is describing the gateway's shape. EnClave
|
|
405
|
+
* accepts all six effort values for `glm-5.3`; the card says the model only
|
|
406
|
+
* implements low, high and max. The extra values are accepted and then ignored,
|
|
407
|
+
* which is worse than not offering them: Pi would show a thinking level that
|
|
408
|
+
* silently does nothing.
|
|
409
|
+
*
|
|
410
|
+
* Every entry here is a transcription of a published card, not an inference, and
|
|
411
|
+
* carries the reason it exists. `null` in a thinking map means the level is not
|
|
412
|
+
* supported; `off: null` means thinking cannot be switched off.
|
|
413
|
+
*/
|
|
414
|
+
export interface VendorSpec {
|
|
415
|
+
input?: Array<"text" | "image">;
|
|
416
|
+
maxTokens?: number;
|
|
417
|
+
thinkingLevelMap?: Record<string, string | null>;
|
|
418
|
+
/** Why this entry exists, so a future reader can check it. */
|
|
419
|
+
why: string;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export const VENDOR_SPEC: Record<string, VendorSpec> = {
|
|
423
|
+
"glm-5.3": {
|
|
424
|
+
input: ["text"],
|
|
425
|
+
maxTokens: 131_072,
|
|
426
|
+
thinkingLevelMap: { off: null, minimal: null, low: "low", medium: null, high: "high", xhigh: null, max: "max" },
|
|
427
|
+
why: "GLM-5.3 model card: no vision/audio/video, reasoning low|high|max, context 1M, max output 128K. OpenRouter declares 943718 and all six levels; the endpoint accepts both, and simply ignores the levels the card does not list.",
|
|
428
|
+
},
|
|
429
|
+
"glm-5.2": {
|
|
430
|
+
input: ["text"],
|
|
431
|
+
maxTokens: 131_072,
|
|
432
|
+
thinkingLevelMap: { off: null, minimal: null, low: null, medium: null, high: "high", xhigh: null, max: "max" },
|
|
433
|
+
why: "GLM-5.2 model card: no vision/audio/video, reasoning high|max, context 1M, max output 128K. OpenRouter declares 943718 and all six levels; same reason as glm-5.3.",
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
export interface Resolved {
|
|
438
|
+
entry: ModelEntry;
|
|
439
|
+
/** The source that supplied the values, if any. */
|
|
440
|
+
source?: string;
|
|
441
|
+
/** Every other provider that also knows this model: corroboration only. */
|
|
442
|
+
corroborating: string[];
|
|
443
|
+
rule: "vendor" | "donated" | "kept" | "corroborated" | "none";
|
|
444
|
+
/** The exact id in the donor catalog that matched, prefix included. */
|
|
445
|
+
matchedId?: string;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const withinTolerance = (a: number, b: number) =>
|
|
449
|
+
Math.abs(a - b) <= ROUNDING_TOLERANCE * Math.max(Math.abs(a), Math.abs(b));
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Resolve one model's values, in strict priority order:
|
|
453
|
+
*
|
|
454
|
+
* hand-written > openrouter > the rest of the active providers
|
|
455
|
+
*
|
|
456
|
+
* The first source that knows the model supplies its values. Every other source
|
|
457
|
+
* that knows it is recorded as corroboration — it confirms the model exists and
|
|
458
|
+
* agrees on its structure, but it never overrides a higher source. Nothing is
|
|
459
|
+
* averaged.
|
|
460
|
+
*/
|
|
461
|
+
export function resolveModel(
|
|
462
|
+
bare: string,
|
|
463
|
+
kept: ModelEntry | undefined,
|
|
464
|
+
bundled: readonly BundledCatalog[],
|
|
465
|
+
isAlias: boolean,
|
|
466
|
+
): Resolved {
|
|
467
|
+
// Aliases are left alone: the same bare name is a different thing in a
|
|
468
|
+
// different router, and its catalog numbers would be false here.
|
|
469
|
+
if (isAlias) return { entry: {}, corroborating: [], rule: "none" };
|
|
470
|
+
|
|
471
|
+
// The exact name first; a dated vendor slug only when the catalog does not
|
|
472
|
+
// know the short one.
|
|
473
|
+
const candidates = [bare, ...(NAME_ALIASES[bare] ?? [])];
|
|
474
|
+
const knowing = bundled
|
|
475
|
+
.map((c) => {
|
|
476
|
+
for (const name of candidates) {
|
|
477
|
+
const entry = c.models.get(name);
|
|
478
|
+
if (entry) return { provider: c.provider, entry, matchedId: entry.id };
|
|
479
|
+
}
|
|
480
|
+
return undefined;
|
|
481
|
+
})
|
|
482
|
+
.filter((h): h is { provider: string; entry: ModelEntry; matchedId: string } => h !== undefined);
|
|
483
|
+
|
|
484
|
+
// The primary donor is whichever catalog ranks first in PROVIDER_PRIORITY.
|
|
485
|
+
const primary = bundled.length ? bundled[0].provider : undefined;
|
|
486
|
+
const donor = knowing.find((h) => h.provider === primary) ?? knowing[0];
|
|
487
|
+
const corroborating = knowing.filter((h) => h.provider !== donor?.provider).map((h) => h.provider);
|
|
488
|
+
|
|
489
|
+
// 1. The vendor's own card, where one exists. Nothing outranks the model
|
|
490
|
+
// card about the model.
|
|
491
|
+
const spec = VENDOR_SPEC[bare];
|
|
492
|
+
if (spec) {
|
|
493
|
+
const entry: ModelEntry = donor ? copyFields(donor.entry, BUNDLED_FIELDS) : {};
|
|
494
|
+
if (spec.input) entry.input = spec.input;
|
|
495
|
+
if (spec.maxTokens !== undefined) entry.maxTokens = spec.maxTokens;
|
|
496
|
+
if (spec.thinkingLevelMap) entry.thinkingLevelMap = spec.thinkingLevelMap as ThinkingLevelMap;
|
|
497
|
+
if (!donor && kept) for (const f of HAND_FIELDS) if (kept[f] !== undefined) (entry as Record<string, unknown>)[f] = kept[f];
|
|
498
|
+
return {
|
|
499
|
+
entry,
|
|
500
|
+
source: "model card",
|
|
501
|
+
matchedId: donor?.matchedId,
|
|
502
|
+
corroborating: corroborating.concat(donor ? [donor.provider] : []),
|
|
503
|
+
rule: "vendor",
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 2. The primary donor decides every field it knows. It is a catalog whose
|
|
508
|
+
// whole business is routing these models.
|
|
509
|
+
if (donor) {
|
|
510
|
+
return {
|
|
511
|
+
entry: copyFields(donor.entry, BUNDLED_FIELDS),
|
|
512
|
+
source: donor.provider,
|
|
513
|
+
matchedId: donor.matchedId,
|
|
514
|
+
corroborating,
|
|
515
|
+
rule: corroborating.length ? "corroborated" : "donated",
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// 3. No donor knows this model: what is already written stands. It may be a
|
|
520
|
+
// value measured against this endpoint, which no catalog can beat.
|
|
521
|
+
if (kept) {
|
|
522
|
+
return {
|
|
523
|
+
entry: copyFields(kept, HAND_FIELDS),
|
|
524
|
+
source: "models.json",
|
|
525
|
+
corroborating: [],
|
|
526
|
+
rule: "kept",
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return { entry: {}, corroborating: [], rule: "none" };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function copyFields(from: ModelEntry, fields: readonly string[]): ModelEntry {
|
|
534
|
+
const out: ModelEntry = {};
|
|
535
|
+
for (const field of fields) {
|
|
536
|
+
if (from[field] !== undefined) (out as Record<string, unknown>)[field] = from[field];
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Pi's extension loader treats EVERY `extensions/*.ts` file as an extension
|
|
543
|
+
* factory and reports "does not export a valid factory function" otherwise.
|
|
544
|
+
* This file is a helper imported by `index.ts`, so it needs a no-op default
|
|
545
|
+
* export to load silently beside it.
|
|
546
|
+
*/
|
|
547
|
+
export default async function donorsHelper(): Promise<void> {}
|