@oh-my-pi/pi-catalog 18.13.2 → 18.14.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.
@@ -398,8 +398,10 @@ export type CompiledAuthValidation =
398
398
  kind: "models-endpoint";
399
399
  label?: string;
400
400
  url: string;
401
- /** Env var holding an alternate base URL; `/models` is appended to it. */
401
+ /** Env var holding an alternate base URL; `baseUrlPath` is appended to it. */
402
402
  baseUrlEnv?: string;
403
+ /** Path appended to the `baseUrlEnv` value (default `/models`), for bases that exclude the API version. */
404
+ baseUrlPath?: string;
403
405
  /** Hook returning extra request headers (may throw a configuration error). */
404
406
  headersHook?: string;
405
407
  optional?: boolean;
@@ -113,12 +113,11 @@ export interface CodexModelDiscoveryResult {
113
113
  models: ModelSpec<"openai-codex-responses">[];
114
114
  etag?: string;
115
115
  /**
116
- * Set when the backend rejected the credential itself (401/403, e.g.
117
- * `token_revoked`); `models` is empty. A definitive per-account denial,
118
- * unlike the `null` result for transport/parse failures, so multi-account
119
- * discovery can skip the account instead of aborting.
116
+ * A definitive per-account denial (401/403 auth, 402 payment). `models` is
117
+ * empty; unlike transport/parse failures, sibling accounts can still supply
118
+ * an authoritative catalog.
120
119
  */
121
- rejectedStatus?: 401 | 403;
120
+ rejectedStatus?: 401 | 402 | 403;
122
121
  }
123
122
 
124
123
  /**
@@ -148,7 +147,7 @@ export async function fetchCodexModels(options: CodexModelDiscoveryOptions): Pro
148
147
  continue;
149
148
  }
150
149
 
151
- if (response.status === 401 || response.status === 403) {
150
+ if (response.status === 401 || response.status === 402 || response.status === 403) {
152
151
  return { models: [], rejectedStatus: response.status };
153
152
  }
154
153
  if (!response.ok) {
@@ -173,6 +173,14 @@ export interface CacheEntry<TApi extends Api = Api> {
173
173
  let sharedDb: Database | null = null;
174
174
  let sharedDbPath: string | null = null;
175
175
 
176
+ /** Connection to a non-default cache path, held open while any retain is outstanding. */
177
+ interface PinnedDb {
178
+ db: Database | null;
179
+ refs: number;
180
+ }
181
+
182
+ const pinnedDbs = new Map<string, PinnedDb>();
183
+
176
184
  interface ReadRowCacheEntry {
177
185
  /** `PRAGMA data_version` of the shared connection when validated; null for per-call connections. */
178
186
  dataVersion: number | null;
@@ -286,16 +294,54 @@ export function closeModelCache(): void {
286
294
  closeSharedDb();
287
295
  }
288
296
 
297
+ /**
298
+ * Keeps one connection to a non-default cache path open until the returned
299
+ * handle is disposed. The default `models.db` already shares a long-lived
300
+ * connection; other paths otherwise open, initialize and close the database on
301
+ * every read and write, which a refresh pass repeats once per provider.
302
+ * Retains nest: the connection closes when the last one is disposed.
303
+ */
304
+ export function retainModelCacheConnection(dbPath: string | undefined): Disposable {
305
+ const { resolvedPath, shared } = resolveCacheDb(dbPath);
306
+ if (shared) return { [Symbol.dispose]() {} };
307
+ let pin = pinnedDbs.get(resolvedPath);
308
+ if (!pin) {
309
+ pin = { db: null, refs: 0 };
310
+ pinnedDbs.set(resolvedPath, pin);
311
+ }
312
+ pin.refs++;
313
+ const held = pin;
314
+ let released = false;
315
+ return {
316
+ [Symbol.dispose]() {
317
+ if (released) return;
318
+ released = true;
319
+ held.refs--;
320
+ if (held.refs > 0) return;
321
+ pinnedDbs.delete(resolvedPath);
322
+ held.db?.close();
323
+ held.db = null;
324
+ },
325
+ };
326
+ }
327
+
289
328
  function runModelCacheDb<T>(resolvedPath: string, shared: boolean, useDb: (db: Database) => T): T {
290
329
  if (shared && sharedDb && sharedDbPath !== resolvedPath) closeSharedDb();
291
- if (shared && sharedDb) {
330
+ const pin = shared ? undefined : pinnedDbs.get(resolvedPath);
331
+ const heldDb = shared ? sharedDb : pin?.db;
332
+ if (heldDb) {
292
333
  try {
293
- return useDb(sharedDb);
334
+ return useDb(heldDb);
294
335
  } catch (error) {
295
336
  if (!isSqliteCorruptionError(error)) throw error;
296
337
  // The opener owns recovery for new handles. Drop this stale handle
297
338
  // first so its WAL cannot remain attached to the replacement.
298
- closeSharedDb();
339
+ if (pin) {
340
+ heldDb.close();
341
+ pin.db = null;
342
+ } else {
343
+ closeSharedDb();
344
+ }
299
345
  invalidateReadPath(resolvedPath);
300
346
  return runModelCacheDb(resolvedPath, shared, useDb);
301
347
  }
@@ -309,6 +355,8 @@ function runModelCacheDb<T>(resolvedPath: string, shared: boolean, useDb: (db: D
309
355
  if (shared) {
310
356
  sharedDb = db;
311
357
  sharedDbPath = resolvedPath;
358
+ } else if (pin) {
359
+ pin.db = db;
312
360
  } else {
313
361
  db.close();
314
362
  }
@@ -0,0 +1,299 @@
1
+ import type { Api, Model } from "./types";
2
+
3
+ // References are distinguished from JSON scalar values by their one-element array.
4
+ // -1 represents an explicitly present undefined value.
5
+ type EncodedValue = string | number | boolean | null | [number];
6
+ type ObjectNode = [0, EncodedValue[], number, number[]];
7
+ type ArrayNode = [1, EncodedValue[]];
8
+ type Node = ObjectNode | ArrayNode;
9
+ type CompatDelta = [number[], EncodedValue];
10
+ type ModelRow = [string, EncodedValue, CompatDelta?];
11
+ type ProviderRow = [string, EncodedValue, ModelRow[]];
12
+
13
+ interface CatalogData {
14
+ version: 1;
15
+ keys: string[];
16
+ booleanShapes: number[][];
17
+ nodes: Node[];
18
+ compat: [string, EncodedValue][];
19
+ providers: ProviderRow[];
20
+ }
21
+
22
+ /**
23
+ * Parsed compact catalog. Decoded nested objects and arrays can be shared by
24
+ * multiple models; callers must treat those values as immutable.
25
+ */
26
+ export interface ModelCatalog {
27
+ readonly data: CatalogData;
28
+ readonly values: unknown[];
29
+ readonly decoded: boolean[];
30
+ readonly providers: Map<string, Record<string, Model<Api>>>;
31
+ readonly compatBases: Map<string, EncodedValue>;
32
+ readonly compatValues: Map<string, Map<number, Map<string, Record<string, unknown>>>>;
33
+ }
34
+
35
+ function isObject(value: unknown): value is Record<string, unknown> {
36
+ return value !== null && typeof value === "object" && !Array.isArray(value);
37
+ }
38
+
39
+ // JSON serialization with sorted object keys gives both deterministic ordering
40
+ // and value-based interning, independent of the input objects' insertion order.
41
+ function canonical(value: unknown): string {
42
+ if (value === undefined) return "u";
43
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
44
+ if (isObject(value)) {
45
+ return `{${Object.keys(value)
46
+ .sort()
47
+ .map(key => `${JSON.stringify(key)}:${canonical(value[key])}`)
48
+ .join(",")}}`;
49
+ }
50
+ return JSON.stringify(value);
51
+ }
52
+
53
+ /** Encode a model catalog into a deterministic, compact JSON representation. */
54
+ export function encodeModelCatalog(models: Record<string, Record<string, Model<Api>>>): string {
55
+ // Match JSON's treatment of undefined and non-finite numbers in generated rows.
56
+ models = JSON.parse(JSON.stringify(models));
57
+ const keys: string[] = [];
58
+ const keyIds = new Map<string, number>();
59
+ const booleanShapes: number[][] = [];
60
+ const shapeIds = new Map<string, number>();
61
+ const nodes: Node[] = [];
62
+ const nodeIds = new Map<string, number>();
63
+ const keyId = (key: string): number => {
64
+ let id = keyIds.get(key);
65
+ if (id === undefined) {
66
+ id = keys.length;
67
+ keys.push(key);
68
+ keyIds.set(key, id);
69
+ }
70
+ return id;
71
+ };
72
+ const encode = (value: unknown): EncodedValue => {
73
+ if (value === undefined) return [-1];
74
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
75
+ return value;
76
+ }
77
+ const signature = canonical(value);
78
+ const existing = nodeIds.get(signature);
79
+ if (existing !== undefined) return [existing];
80
+ let node: Node;
81
+ if (Array.isArray(value)) {
82
+ node = [1, value.map(encode)];
83
+ } else if (isObject(value)) {
84
+ const properties: EncodedValue[] = [];
85
+ const boolKeys: number[] = [];
86
+ const masks: number[] = [];
87
+ for (const key of Object.keys(value).sort()) {
88
+ const entry = value[key];
89
+ if (typeof entry === "boolean") {
90
+ const bit = boolKeys.length;
91
+ boolKeys.push(keyId(key));
92
+ const word = Math.floor(bit / 32);
93
+ masks[word] ??= 0;
94
+ if (entry) masks[word] = (masks[word] | (1 << (bit % 32))) >>> 0;
95
+ } else {
96
+ properties.push(keyId(key), encode(entry));
97
+ }
98
+ }
99
+ const shape = boolKeys.join(",");
100
+ let shapeId = shapeIds.get(shape);
101
+ if (shapeId === undefined) {
102
+ shapeId = booleanShapes.length;
103
+ booleanShapes.push(boolKeys);
104
+ shapeIds.set(shape, shapeId);
105
+ }
106
+ node = [0, properties, shapeId, masks];
107
+ } else {
108
+ throw new TypeError("Model catalog values must be JSON-compatible");
109
+ }
110
+ const id = nodes.length;
111
+ nodes.push(node);
112
+ nodeIds.set(signature, id);
113
+ return [id];
114
+ };
115
+
116
+ // Select the most common complete compat object for each API. A row's
117
+ // delta records changed properties and explicitly removed base properties.
118
+ const compatCounts = new Map<string, Map<string, { value: Record<string, unknown>; count: number }>>();
119
+ for (const provider of Object.keys(models).sort()) {
120
+ for (const id of Object.keys(models[provider]).sort()) {
121
+ const model = models[provider][id];
122
+ if (typeof model.api !== "string" || !isObject(model.compat)) continue;
123
+ let counts = compatCounts.get(model.api);
124
+ if (!counts) compatCounts.set(model.api, (counts = new Map()));
125
+ const signature = canonical(model.compat);
126
+ const entry = counts.get(signature);
127
+ if (entry) entry.count++;
128
+ else counts.set(signature, { value: model.compat, count: 1 });
129
+ }
130
+ }
131
+ const bases = new Map<string, Record<string, unknown>>();
132
+ for (const api of [...compatCounts.keys()].sort()) {
133
+ const choices = [...compatCounts.get(api)!.entries()].sort(
134
+ (a, b) => b[1].count - a[1].count || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0),
135
+ );
136
+ bases.set(api, choices[0][1].value);
137
+ }
138
+ const compat: [string, EncodedValue][] = [...bases].map(([api, value]) => [api, encode(value)]);
139
+ const providers: ProviderRow[] = [];
140
+ for (const provider of Object.keys(models).sort()) {
141
+ const entries = Object.entries(models[provider]).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
142
+ const constants: Record<string, unknown> = Object.create(null);
143
+ if (entries.length) {
144
+ const first = entries[0][1] as unknown as Record<string, unknown>;
145
+ for (const key of Object.keys(first).sort()) {
146
+ const value = first[key];
147
+ if (key === "id" || key === "compat" || (value !== null && typeof value === "object")) continue;
148
+ if (
149
+ entries.every(
150
+ ([, model]) =>
151
+ Object.hasOwn(model, key) && (model as unknown as Record<string, unknown>)[key] === value,
152
+ )
153
+ ) {
154
+ constants[key] = value;
155
+ }
156
+ }
157
+ }
158
+ const encodedConstants = encode(constants);
159
+ const rows: ModelRow[] = [];
160
+ for (const [id, model] of entries) {
161
+ const rest: Record<string, unknown> = Object.create(null);
162
+ const source = model as unknown as Record<string, unknown>;
163
+ const base = typeof model.api === "string" ? bases.get(model.api) : undefined;
164
+ const useBase = base !== undefined && isObject(model.compat);
165
+ for (const key of Object.keys(source).sort()) {
166
+ if (Object.hasOwn(constants, key) || (key === "id" && source.id === id) || (key === "compat" && useBase))
167
+ continue;
168
+ rest[key] = source[key];
169
+ }
170
+ const row: ModelRow = [id, encode(rest)];
171
+ if (useBase) {
172
+ const value = model.compat as Record<string, unknown>;
173
+ const removed = Object.keys(base)
174
+ .filter(key => !Object.hasOwn(value, key))
175
+ .sort()
176
+ .map(keyId);
177
+ const changed: Record<string, unknown> = Object.create(null);
178
+ for (const key of Object.keys(value).sort()) {
179
+ if (!Object.hasOwn(base, key) || canonical(value[key]) !== canonical(base[key]))
180
+ changed[key] = value[key];
181
+ }
182
+ row.push([removed, encode(changed)]);
183
+ }
184
+ rows.push(row);
185
+ }
186
+ providers.push([provider, encodedConstants, rows]);
187
+ }
188
+ return JSON.stringify({ version: 1, keys, booleanShapes, nodes, compat, providers } satisfies CatalogData);
189
+ }
190
+
191
+ /** Open a parsed catalog without materializing its provider models. */
192
+ export function openModelCatalog(value: unknown): ModelCatalog {
193
+ const data = value as CatalogData;
194
+ if (data.version !== 1) throw new Error("Unsupported model catalog version");
195
+ return {
196
+ data,
197
+ values: [],
198
+ decoded: [],
199
+ providers: new Map(),
200
+ compatBases: new Map(data.compat),
201
+ compatValues: new Map(),
202
+ };
203
+ }
204
+
205
+ // Assignment is safe for ordinary keys, but __proto__ must remain an own key.
206
+ function setProperty(object: Record<string, unknown>, key: string, value: unknown): void {
207
+ if (key === "__proto__") {
208
+ Object.defineProperty(object, key, { value, writable: true, enumerable: true, configurable: true });
209
+ } else {
210
+ object[key] = value;
211
+ }
212
+ }
213
+
214
+ function decodeObject(catalog: ModelCatalog, object: Record<string, unknown>, node: ObjectNode): void {
215
+ for (let i = 0; i < node[1].length; i += 2) {
216
+ setProperty(object, catalog.data.keys[node[1][i] as number], decode(catalog, node[1][i + 1]));
217
+ }
218
+ const shape = catalog.data.booleanShapes[node[2]];
219
+ for (let i = 0; i < shape.length; i++) {
220
+ setProperty(object, catalog.data.keys[shape[i]], ((node[3][i >>> 5] >>> (i % 32)) & 1) !== 0);
221
+ }
222
+ }
223
+
224
+ function decode(catalog: ModelCatalog, value: EncodedValue): unknown {
225
+ if (!Array.isArray(value)) return value;
226
+ const id = value[0];
227
+ if (id === -1) return undefined;
228
+ if (catalog.decoded[id]) return catalog.values[id];
229
+ const node = catalog.data.nodes[id];
230
+ let result: unknown;
231
+ if (node[0] === 1) {
232
+ result = node[1].map(entry => decode(catalog, entry));
233
+ } else {
234
+ const object: Record<string, unknown> = {};
235
+ decodeObject(catalog, object, node);
236
+ result = object;
237
+ }
238
+ catalog.values[id] = result;
239
+ catalog.decoded[id] = true;
240
+ return result;
241
+ }
242
+
243
+ /** List catalog provider keys in deterministic order. */
244
+ export function getCatalogProviders(catalog: ModelCatalog): string[] {
245
+ return catalog.data.providers.map(provider => provider[0]);
246
+ }
247
+
248
+ /** Materialize and cache one provider, or return undefined if it is absent. */
249
+ export function getCatalogProvider(catalog: ModelCatalog, provider: string): Record<string, Model<Api>> | undefined {
250
+ const cached = catalog.providers.get(provider);
251
+ if (cached) return cached;
252
+ const entry = catalog.data.providers.find(row => row[0] === provider);
253
+ if (!entry) return undefined;
254
+ const constants = decode(catalog, entry[1]) as Record<string, unknown>;
255
+ const models: Record<string, Model<Api>> = {};
256
+ for (const [id, encoded, delta] of entry[2]) {
257
+ if (!Array.isArray(encoded) || encoded[0] < 0) throw new Error("Invalid compact model row");
258
+ const row = catalog.data.nodes[encoded[0]];
259
+ if (!row || row[0] !== 0) throw new Error("Invalid compact model row");
260
+ const model: Record<string, unknown> = { id, ...constants };
261
+ decodeObject(catalog, model, row);
262
+ if (delta) {
263
+ const api = model.api as string;
264
+ const changeId = (delta[1] as [number])[0];
265
+ const changeNode = catalog.data.nodes[changeId] as ObjectNode;
266
+ const base = decode(catalog, catalog.compatBases.get(api)!) as Record<string, unknown>;
267
+ let compat: Record<string, unknown>;
268
+ if (
269
+ delta[0].length === 0 &&
270
+ changeNode[1].length === 0 &&
271
+ catalog.data.booleanShapes[changeNode[2]].length === 0
272
+ ) {
273
+ compat = base;
274
+ } else {
275
+ let byChange = catalog.compatValues.get(api);
276
+ if (!byChange) catalog.compatValues.set(api, (byChange = new Map()));
277
+ let byRemoved = byChange.get(changeId);
278
+ if (!byRemoved) byChange.set(changeId, (byRemoved = new Map()));
279
+ const removedKey = delta[0].join(",");
280
+ const cachedCompat = byRemoved.get(removedKey);
281
+ if (cachedCompat) {
282
+ compat = cachedCompat;
283
+ } else {
284
+ compat = { ...base };
285
+ for (const key of delta[0]) delete compat[catalog.data.keys[key]];
286
+ const changes = decode(catalog, delta[1]) as Record<string, unknown>;
287
+ for (const key in changes) {
288
+ if (Object.hasOwn(changes, key)) setProperty(compat, key, changes[key]);
289
+ }
290
+ byRemoved.set(removedKey, compat);
291
+ }
292
+ }
293
+ model.compat = compat;
294
+ }
295
+ setProperty(models as Record<string, unknown>, id, model);
296
+ }
297
+ catalog.providers.set(provider, models);
298
+ return models;
299
+ }