pi-model-sync 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,399 @@
1
+ /**
2
+ * pi-model-sync live discovery.
3
+ *
4
+ * Lists models from a provider's own catalog endpoint. Shapes differ per API
5
+ * family; unknown families fall through openai -> anthropic -> ollama ->
6
+ * google and report a skip reason when none fit. Paginating families
7
+ * (Anthropic cursors, Google page tokens) are walked to the end: a partial
8
+ * first page must never prune real models. Every failure is a skip reason
9
+ * for one provider, never a fatal sync error (callers isolate per
10
+ * provider).
11
+ */
12
+
13
+ import { isNonEmptyString, isObject, isString } from "./decode.js";
14
+
15
+ // Only locally generated reasons may reach reports; transport errors can carry credentials.
16
+ export class DiscoveryError extends Error {}
17
+
18
+ // A later page failure must not trigger endpoint fallback and turn a partial
19
+ // catalog into a successful list eligible for pruning.
20
+ class IncompleteCatalogError extends DiscoveryError {}
21
+
22
+ export const ANTHROPIC_VERSION = "2023-06-01";
23
+
24
+ export const DISCOVERY_TIMEOUT_MS = 30000;
25
+
26
+ // List page width for families that paginate (Anthropic limit, Google
27
+ // pageSize). Both cap at 1000; the cursor loops below finish the walk even
28
+ // if a server clamps the width.
29
+ export const LIST_PAGE_LIMIT = 1000;
30
+
31
+ // A server echoing has_more/nextPageToken forever must not hang the sync:
32
+ // 100 full pages is 100k models, far past any real catalog.
33
+ export const MAX_LIST_PAGES = 100;
34
+
35
+ // Mirror of Pi's own OAuth-key heuristic (pi-ai anthropic-messages): OAuth
36
+ // access tokens use the Authorization header, key credentials use x-api-key.
37
+ function isAnthropicOAuthToken(apiKey) {
38
+ return isString(apiKey) && apiKey.includes("sk-ant-oat");
39
+ }
40
+
41
+ // Provider base URLs are inconsistent: some embed the version
42
+ // (api.openai.com/v1, openrouter.ai/api/v1, z.ai .../paas/v4), some do not
43
+ // (ai-gateway.vercel.sh, api.anthropic.com). Respect an embedded version,
44
+ // default the rest to v1.
45
+ function listUrl(baseUrl, family) {
46
+ const base = baseUrl.replace(/\/+$/, "");
47
+
48
+ if (family === "ollama") {
49
+ return `${base}/api/tags`;
50
+ }
51
+
52
+ if (family === "google") {
53
+ return base.endsWith("/v1beta") || base.endsWith("/v1") ? `${base}/models` : `${base}/v1beta/models`;
54
+ }
55
+
56
+ return /\/v\d+$/.test(base) ? `${base}/models` : `${base}/v1/models`;
57
+ }
58
+
59
+ // Bare fallback for hosts that serve the list off-version (e.g. Copilot's
60
+ // /models). Tried only after the versioned URL 404s, never speculatively.
61
+ function bareListUrl(baseUrl) {
62
+ return `${baseUrl.replace(/\/+$/, "")}/models`;
63
+ }
64
+
65
+ function isNotFound(error) {
66
+ return error instanceof Error && error.message.includes("HTTP 404");
67
+ }
68
+
69
+ function withQuery(base, params) {
70
+ const url = new URL(base);
71
+
72
+ for (const [key, value] of Object.entries(params)) {
73
+ url.searchParams.set(key, value);
74
+ }
75
+
76
+ return url.toString();
77
+ }
78
+
79
+ async function getJson(url, headers, fetchImpl) {
80
+ const response = await fetchImpl(url, {
81
+ headers,
82
+ signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
83
+ });
84
+
85
+ const text = await response.text();
86
+
87
+ if (!response.ok) {
88
+ throw new DiscoveryError(`list failed (HTTP ${response.status})`);
89
+ }
90
+
91
+ let body;
92
+
93
+ try {
94
+ body = JSON.parse(text);
95
+ } catch {
96
+ throw new DiscoveryError("list returned invalid JSON");
97
+ }
98
+
99
+ if (!isObject(body)) {
100
+ throw new DiscoveryError("unexpected list shape");
101
+ }
102
+
103
+ return body;
104
+ }
105
+
106
+ function mergeHeaders(defaults, extra, userAgent) {
107
+ const fields = new Map();
108
+
109
+ for (const [name, value] of [...Object.entries(defaults), ...Object.entries(extra)]) {
110
+ fields.set(name.toLowerCase(), [name, value]);
111
+ }
112
+
113
+ fields.set("user-agent", ["User-Agent", userAgent]);
114
+
115
+ return Object.fromEntries(fields.values());
116
+ }
117
+
118
+ function bearerHeaders(apiKey, extra, userAgent) {
119
+ const headers = { Accept: "application/json", "User-Agent": userAgent };
120
+
121
+ if (isNonEmptyString(apiKey)) {
122
+ headers.Authorization = ["Bearer", apiKey].join(" ");
123
+ }
124
+
125
+ return mergeHeaders(headers, extra, userAgent);
126
+ }
127
+
128
+ function openaiEntries(body) {
129
+ if (!Array.isArray(body.data)) {
130
+ throw new DiscoveryError("unexpected list shape");
131
+ }
132
+
133
+ const models = [];
134
+
135
+ for (const item of body.data) {
136
+ if (isObject(item) && isNonEmptyString(item.id)) {
137
+ models.push({ id: item.id, meta: item });
138
+ }
139
+ }
140
+
141
+ return models;
142
+ }
143
+
144
+ // Try the versioned list URL, then the bare one on an initial-request 404
145
+ // only (never after accepting a page, never twice for the same URL).
146
+ async function withBareFallback(baseUrl, family, listAt) {
147
+ try {
148
+ return await listAt(listUrl(baseUrl, family));
149
+ } catch (error) {
150
+ if (error instanceof IncompleteCatalogError || !isNotFound(error) || bareListUrl(baseUrl) === listUrl(baseUrl, family)) {
151
+ throw error;
152
+ }
153
+
154
+ return await listAt(bareListUrl(baseUrl));
155
+ }
156
+ }
157
+
158
+ async function listOpenAI(baseUrl, apiKey, extra, fetchImpl, userAgent) {
159
+ const headers = bearerHeaders(apiKey, extra, userAgent);
160
+
161
+ return withBareFallback(baseUrl, "openai", async (url) => {
162
+ const body = await getJson(url, headers, fetchImpl);
163
+
164
+ if (body.has_more === true) {
165
+ throw new DiscoveryError("paginated catalog requires a paginating API family");
166
+ }
167
+
168
+ return openaiEntries(body);
169
+ });
170
+ }
171
+
172
+ async function listAnthropic(baseUrl, apiKey, extra, fetchImpl, userAgent) {
173
+ // Compatibility pragmatism: real Anthropic keys travel as x-api-key, but
174
+ // Anthropic-compatible routers (OpenRouter, MiniMax, Kimi) vary. Sending
175
+ // both costs nothing over TLS and heals the mixed cases. OAuth tokens
176
+ // stay Bearer-only, exactly as first-party clients send them.
177
+ const headers = { Accept: "application/json", "anthropic-version": ANTHROPIC_VERSION, "User-Agent": userAgent };
178
+
179
+ if (isNonEmptyString(apiKey)) {
180
+ if (isAnthropicOAuthToken(apiKey)) {
181
+ headers.Authorization = ["Bearer", apiKey].join(" ");
182
+ } else {
183
+ headers["x-api-key"] = apiKey;
184
+ headers.Authorization = ["Bearer", apiKey].join(" ");
185
+ }
186
+ }
187
+
188
+ const merged = mergeHeaders(headers, extra, userAgent);
189
+
190
+ return withBareFallback(baseUrl, "anthropic", (url) =>
191
+ collectPages(
192
+ withQuery(url, { limit: String(LIST_PAGE_LIMIT) }),
193
+ merged,
194
+ fetchImpl,
195
+ openaiEntries,
196
+ anthropicCursor,
197
+ ),
198
+ );
199
+ }
200
+
201
+ // Walk a paginating list to the end. entriesOf parses one page; cursorOf
202
+ // returns the next page URL from the parsed body, or undefined to stop.
203
+ async function collectPages(firstUrl, headers, fetchImpl, entriesOf, cursorOf) {
204
+ const models = [];
205
+ let url = firstUrl;
206
+
207
+ for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
208
+ let next;
209
+ let recognized = false;
210
+
211
+ try {
212
+ const body = await getJson(url, headers, fetchImpl);
213
+ const entries = entriesOf(body);
214
+ recognized = true;
215
+
216
+ for (const entry of entries) {
217
+ models.push(entry);
218
+ }
219
+
220
+ next = cursorOf(body, firstUrl);
221
+ } catch (error) {
222
+ if (page > 0 || recognized) {
223
+ // Once the shape is recognized, transport, page-shape and cursor failures
224
+ // establish incompleteness, not permission to probe a subset elsewhere.
225
+ throw new IncompleteCatalogError(error instanceof DiscoveryError ? error.message : "list request failed");
226
+ }
227
+
228
+ throw error;
229
+ }
230
+
231
+ if (next === undefined) {
232
+ return models;
233
+ }
234
+
235
+ url = next;
236
+ }
237
+
238
+ throw new IncompleteCatalogError("catalog pagination limit reached; catalog incomplete");
239
+ }
240
+
241
+ function anthropicCursor(body, firstUrl) {
242
+ const data = body.data;
243
+ const last = data[data.length - 1];
244
+
245
+ if (body.has_more !== true) {
246
+ return undefined;
247
+ }
248
+
249
+ if (!isObject(last) || !isNonEmptyString(last.id)) {
250
+ throw new DiscoveryError("catalog has more pages but no usable cursor");
251
+ }
252
+
253
+ return withQuery(firstUrl, { limit: String(LIST_PAGE_LIMIT), after_id: last.id });
254
+ }
255
+
256
+ function googleCursor(body, firstUrl) {
257
+ if (!isNonEmptyString(body.nextPageToken)) {
258
+ return undefined;
259
+ }
260
+
261
+ return withQuery(firstUrl, { pageSize: String(LIST_PAGE_LIMIT), pageToken: body.nextPageToken });
262
+ }
263
+
264
+ async function listOllama(baseUrl, apiKey, extra, fetchImpl, userAgent) {
265
+ // Servers without auth ignore the header; authenticated remotes (Ollama
266
+ // Cloud) need it. The key was configured for this base URL either way.
267
+ const body = await getJson(
268
+ listUrl(baseUrl, "ollama"),
269
+ bearerHeaders(apiKey, extra, userAgent),
270
+ fetchImpl,
271
+ );
272
+
273
+ return ollamaEntries(body);
274
+ }
275
+
276
+ function ollamaEntries(body) {
277
+ if (!Array.isArray(body.models)) {
278
+ throw new DiscoveryError("unexpected list shape");
279
+ }
280
+
281
+ const models = [];
282
+
283
+ for (const item of body.models) {
284
+ if (isObject(item) && isNonEmptyString(item.name)) {
285
+ models.push({ id: item.name, meta: item });
286
+ }
287
+ }
288
+
289
+ return models;
290
+ }
291
+
292
+ function googleEntries(body) {
293
+ if (!Array.isArray(body.models)) {
294
+ throw new DiscoveryError("unexpected list shape");
295
+ }
296
+
297
+ const models = [];
298
+
299
+ for (const item of body.models) {
300
+ if (!isObject(item) || !isNonEmptyString(item.name)) {
301
+ continue;
302
+ }
303
+
304
+ const id = item.name.startsWith("models/") ? item.name.slice("models/".length) : item.name;
305
+
306
+ if (!isNonEmptyString(id)) {
307
+ continue;
308
+ }
309
+
310
+ models.push({ id, meta: item });
311
+ }
312
+
313
+ return models;
314
+ }
315
+
316
+ async function listGoogle(baseUrl, apiKey, extra, fetchImpl, userAgent) {
317
+ const headers = { Accept: "application/json", "User-Agent": userAgent };
318
+
319
+ if (isNonEmptyString(apiKey)) {
320
+ headers["x-goog-api-key"] = apiKey;
321
+ }
322
+
323
+ return await collectPages(
324
+ withQuery(listUrl(baseUrl, "google"), { pageSize: String(LIST_PAGE_LIMIT) }),
325
+ mergeHeaders(headers, extra, userAgent),
326
+ fetchImpl,
327
+ googleEntries,
328
+ googleCursor,
329
+ );
330
+ }
331
+
332
+ // Pi api -> discovery family. Unrecognized apis (mistral-conversations,
333
+ // extension dialects) return unknown and probe shapes in turn, openai
334
+ // first, so OpenAI-compatible endpoints still resolve on probe one.
335
+ export function discoveryFamily(api) {
336
+ if (!isString(api)) {
337
+ return "unknown";
338
+ }
339
+
340
+ if (api.startsWith("openai")) {
341
+ return "openai";
342
+ }
343
+
344
+ if (api.startsWith("anthropic")) {
345
+ return "anthropic";
346
+ }
347
+
348
+ if (api.startsWith("google")) {
349
+ return "google";
350
+ }
351
+
352
+ if (api.startsWith("ollama")) {
353
+ return "ollama";
354
+ }
355
+
356
+ return "unknown";
357
+ }
358
+
359
+ const LISTERS = { openai: listOpenAI, anthropic: listAnthropic, ollama: listOllama, google: listGoogle };
360
+
361
+ // Unknown families probe every shape in turn; anything unrecognized fails
362
+ // with the last probe's error (google's), never a guess.
363
+ const PROBE_ORDER = ["openai", "anthropic", "ollama", "google"];
364
+
365
+ function authParts(auth) {
366
+ return { apiKey: auth?.apiKey, extra: auth?.headers ?? {} };
367
+ }
368
+
369
+ // List live models. Resolves to [{id, meta}]; rejects with an Error whose
370
+ // message is a user-facing skip reason (status included, body excluded so
371
+ // no key material can leak into reports).
372
+ export async function listModels(baseUrl, auth, family, fetchImpl, userAgent) {
373
+ const { apiKey, extra } = authParts(auth);
374
+
375
+ if (!isNonEmptyString(baseUrl)) {
376
+ throw new DiscoveryError("provider has no base URL");
377
+ }
378
+
379
+ if (family !== "unknown" && !Object.hasOwn(LISTERS, family)) {
380
+ throw new DiscoveryError(`unsupported API family ${family}`);
381
+ }
382
+
383
+ const chain = family === "unknown" ? PROBE_ORDER : [family];
384
+ let last;
385
+
386
+ for (const name of chain) {
387
+ try {
388
+ return await LISTERS[name](baseUrl, apiKey, extra, fetchImpl, userAgent);
389
+ } catch (error) {
390
+ // A recognized catalog that failed mid-traversal is not a shape mismatch.
391
+ // Another API family may expose only a subset and cannot authorize pruning.
392
+ if (error instanceof IncompleteCatalogError) throw error;
393
+
394
+ last = error instanceof DiscoveryError ? error : new DiscoveryError("list request failed");
395
+ }
396
+ }
397
+
398
+ throw last;
399
+ }