@wuyaos/pi-model-config 0.0.0-stage → 0.1.1

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,427 @@
1
+ import type { AuthResult, FetchFunction } from "@earendil-works/pi-ai";
2
+ import type { CandidateModel, DiscoveryAuthHeader, ManagedModelType, ProviderDraft } from "./types.ts";
3
+ import { identityOf } from "./types.ts";
4
+ import { createProviderFetch } from "./transport.ts";
5
+ import { validateHttpUrl } from "./config.ts";
6
+ import { mcError, ModelConfigError } from "./i18n.ts";
7
+ import { defaultChatThinking } from "./thinking-defaults.ts";
8
+
9
+ const MAX_BODY_BYTES = 1024 * 1024;
10
+ const MAX_ERROR_BYTES = 8 * 1024;
11
+ const MAX_ID_LENGTH = 256;
12
+ const MAX_NAME_LENGTH = 256;
13
+ export const COMPAT_CONTEXT_WINDOW = 128_000;
14
+ export const COMPAT_MAX_TOKENS = 16_384;
15
+
16
+ interface DiscoveryCredentials {
17
+ apiKey?: string;
18
+ headers?: Record<string, string>;
19
+ }
20
+
21
+ export interface DiscoveryResult {
22
+ models: CandidateModel[];
23
+ url: string;
24
+ }
25
+
26
+ function unique(values: string[]): string[] {
27
+ return [...new Set(values)];
28
+ }
29
+
30
+ function normalizedBase(value: string | undefined): URL | undefined {
31
+ if (!value?.trim()) return undefined;
32
+ try {
33
+ const url = new URL(value.trim());
34
+ url.search = "";
35
+ url.hash = "";
36
+ validateHttpUrl(url.toString(), "Base URL");
37
+ url.pathname = url.pathname.replace(/\/+$/u, "") || "/";
38
+ return url;
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ function withPath(base: URL, path: string): string {
45
+ const url = new URL(base);
46
+ url.pathname = path.startsWith("/") ? path : `/${path}`;
47
+ url.search = "";
48
+ url.hash = "";
49
+ return validateHttpUrl(url.toString(), "Discovery URL");
50
+ }
51
+
52
+ function pathWithModels(path: string): string {
53
+ const clean = path.replace(/\/+$/u, "") || "/";
54
+ if (/(?:^|\/)models$/iu.test(clean)) return clean;
55
+ return clean === "/" ? "/models" : `${clean}/models`;
56
+ }
57
+
58
+ function openAiCandidates(base: URL): string[] {
59
+ const path = base.pathname.replace(/\/+$/u, "") || "/";
60
+ if (/(?:^|\/)models$/iu.test(path)) return [withPath(base, path)];
61
+ if (/\/v\d+(?:beta\d*)?$/iu.test(path)) return unique([withPath(base, `${path}/models`), withPath(base, "/models")]);
62
+ if (path === "/") return [withPath(base, "/v1/models"), withPath(base, "/models")];
63
+ return unique([withPath(base, pathWithModels(path)), withPath(base, "/v1/models"), withPath(base, "/models")]);
64
+ }
65
+
66
+ function versionedCandidate(base: URL, version: string): string[] {
67
+ const path = base.pathname.replace(/\/+$/u, "") || "/";
68
+ if (/(?:^|\/)models$/iu.test(path)) return [withPath(base, path)];
69
+ if (path.endsWith(`/${version}`)) return [withPath(base, `${path}/models`)];
70
+ return [withPath(base, `/${version}/models`)];
71
+ }
72
+
73
+ function codexCandidates(base: URL): string[] {
74
+ const path = base.pathname.replace(/\/+$/u, "") || "/";
75
+ let preferred: string;
76
+ if (/\/backend-api$/iu.test(path)) preferred = `${path}/codex/models`;
77
+ else if (/\/codex\/responses$/iu.test(path)) preferred = path.replace(/\/responses$/iu, "/models");
78
+ else if (/\/codex$/iu.test(path)) preferred = `${path}/models`;
79
+ else if (/\/codex\/models$/iu.test(path)) preferred = path;
80
+ else if (path === "/" || /\/v1$/iu.test(path)) preferred = "/backend-api/codex/models";
81
+ else return [];
82
+ return unique([withPath(base, preferred), withPath(base, "/v1/models")]);
83
+ }
84
+
85
+ /** Build conservative, same-origin discovery candidates. Explicit Discovery URL always wins. */
86
+ export function buildDiscoveryCandidates(
87
+ draft: Pick<ProviderDraft, "discoveryUrl" | "baseUrl" | "chatApi" | "proxyUrl">,
88
+ fallbackBaseUrls: Iterable<string | undefined> = [],
89
+ ): string[] {
90
+ if (draft.discoveryUrl) {
91
+ try { return [validateHttpUrl(draft.discoveryUrl, "Discovery URL")]; } catch { return []; }
92
+ }
93
+ const bases: URL[] = [];
94
+ if (draft.baseUrl !== undefined) {
95
+ const explicit = normalizedBase(draft.baseUrl);
96
+ if (!explicit) return [];
97
+ bases.push(explicit);
98
+ } else {
99
+ for (const value of fallbackBaseUrls) {
100
+ const base = normalizedBase(value);
101
+ if (base) bases.push(base);
102
+ }
103
+ }
104
+ const base = bases[0];
105
+ if (!base) return [];
106
+ const api = draft.chatApi;
107
+ if (["azure-openai-responses", "bedrock-converse-stream", "google-vertex", "pi-messages"].includes(api)) return [];
108
+ if (api === "google-generative-ai") return draft.proxyUrl ? [] : versionedCandidate(base, "v1beta");
109
+ if (api === "anthropic-messages") return versionedCandidate(base, "v1");
110
+ if (api === "mistral-conversations") return versionedCandidate(base, "v1");
111
+ if (api === "openai-codex-responses") return codexCandidates(base);
112
+ if (api === "openai-completions" || api === "openai-responses") return openAiCandidates(base);
113
+ return [];
114
+ }
115
+
116
+ function positiveInteger(record: Record<string, unknown>, names: string[], fallback: number): number {
117
+ for (const name of names) {
118
+ const value = record[name];
119
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.floor(value);
120
+ }
121
+ return fallback;
122
+ }
123
+
124
+ function stringField(record: Record<string, unknown>, names: string[]): string | undefined {
125
+ for (const name of names) if (typeof record[name] === "string" && (record[name] as string).trim()) return (record[name] as string).trim();
126
+ return undefined;
127
+ }
128
+
129
+ function sanitizeId(value: unknown): string | undefined {
130
+ if (typeof value !== "string") return undefined;
131
+ const id = value.trim();
132
+ if (!id || [...id].length > MAX_ID_LENGTH || /[\u0000-\u001f\u007f-\u009f]/u.test(id)) return undefined;
133
+ return id;
134
+ }
135
+
136
+ function sanitizeName(value: unknown, fallback: string): string {
137
+ if (typeof value !== "string") return fallback;
138
+ const clean = value
139
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "")
140
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
141
+ .replace(/\s+/gu, " ")
142
+ .trim();
143
+ if (!clean) return fallback;
144
+ return [...clean].slice(0, MAX_NAME_LENGTH).join("");
145
+ }
146
+
147
+ function inferredType(record: Record<string, unknown>, fallback: ManagedModelType): ManagedModelType {
148
+ const raw = stringField(record, ["type", "model_type", "task"])?.toLowerCase();
149
+ if (raw === "classifier" || raw === "classification") return "classifier";
150
+ if (raw === "image" || raw === "image-generation" || raw === "text-to-image") return "image";
151
+ if (raw === "chat" || raw === "language" || raw === "llm") return "chat";
152
+ return fallback;
153
+ }
154
+
155
+ function modalities(record: Record<string, unknown>, names: string[], fallback: ("text" | "image")[]): ("text" | "image")[] {
156
+ for (const name of names) {
157
+ const value = record[name];
158
+ if (!Array.isArray(value)) continue;
159
+ const parsed = value.filter((entry): entry is "text" | "image" => entry === "text" || entry === "image");
160
+ if (parsed.length) return [...new Set(parsed)];
161
+ }
162
+ return fallback;
163
+ }
164
+
165
+ function numericCost(record: Record<string, unknown>): CandidateModel["cost"] {
166
+ const source = [record.cost, record.pricing].find((value): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value));
167
+ const read = (names: string[]) => {
168
+ for (const name of names) {
169
+ const value = source?.[name];
170
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
171
+ }
172
+ return 0;
173
+ };
174
+ return {
175
+ input: read(["input", "input_price", "prompt"]),
176
+ output: read(["output", "output_price", "completion"]),
177
+ cacheRead: read(["cacheRead", "cache_read"]),
178
+ cacheWrite: read(["cacheWrite", "cache_write"]),
179
+ };
180
+ }
181
+
182
+ function thinkingMap(record: Record<string, unknown>): Record<string, string | null> | undefined {
183
+ const raw = record.thinkingLevelMap ?? record.thinking_level_map;
184
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
185
+ const levels = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
186
+ const entries = Object.entries(raw as Record<string, unknown>).filter(([key, value]) => levels.has(key) && (typeof value === "string" || value === null));
187
+ return Object.fromEntries(entries) as Record<string, string | null>;
188
+ }
189
+
190
+ function explicitReasoning(record: Record<string, unknown>): boolean | undefined {
191
+ if (typeof record.reasoning === "boolean") return record.reasoning;
192
+ const capabilities = typeof record.capabilities === "object" && record.capabilities !== null && !Array.isArray(record.capabilities)
193
+ ? record.capabilities as Record<string, unknown>
194
+ : undefined;
195
+ if (typeof capabilities?.reasoning === "boolean") return capabilities.reasoning;
196
+ if (Array.isArray(record.supported_reasoning_levels)) return record.supported_reasoning_levels.some((value) => typeof value === "string" && value.trim());
197
+ return undefined;
198
+ }
199
+
200
+ function normalizeRemote(value: unknown, draft: ProviderDraft): CandidateModel | undefined {
201
+ const record: Record<string, unknown> = typeof value === "string"
202
+ ? { id: value }
203
+ : typeof value === "object" && value !== null && !Array.isArray(value)
204
+ ? value as Record<string, unknown>
205
+ : {};
206
+ const id = sanitizeId(stringField(record, ["id", "model", "model_id", "slug"]));
207
+ if (!id) return undefined;
208
+ const name = sanitizeName(stringField(record, ["name", "display_name", "displayName"]), id);
209
+ const type = inferredType(record, draft.defaultType);
210
+ const explicitContext = positiveInteger(record, ["context_window", "contextWindow", "context_length", "max_context_tokens", "max_input_tokens"], 0);
211
+ const contextWindow = explicitContext || COMPAT_CONTEXT_WINDOW;
212
+ const explicitMax = positiveInteger(record, ["max_output_tokens", "maxTokens", "max_tokens", "max_completion_tokens"], 0);
213
+ const requestedMax = explicitMax || COMPAT_MAX_TOKENS;
214
+ const common = {
215
+ id,
216
+ name,
217
+ api: type === "chat" ? draft.chatApi : type === "classifier" ? draft.classifierApi : draft.imageApi,
218
+ ...(draft.baseUrl ? { baseUrl: draft.baseUrl } : {}),
219
+ input: modalities(record, ["input_modalities", "inputModalities", "input"], ["text"]),
220
+ cost: numericCost(record),
221
+ source: "remote" as const,
222
+ mutableType: true,
223
+ contextSource: type === "image" ? undefined : (explicitContext ? "remote" as const : "compat" as const),
224
+ maxTokensSource: type === "chat" ? (explicitMax ? "remote" as const : "compat" as const) : undefined,
225
+ };
226
+ if (type === "classifier") return { ...common, type, contextWindow };
227
+ if (type === "image") {
228
+ const output = modalities(record, ["output_modalities", "outputModalities", "output"], ["image"]);
229
+ return { ...common, type, output: output.includes("image") ? output : ["image", ...output] };
230
+ }
231
+ const thinking = defaultChatThinking(draft.chatApi, { reasoning: explicitReasoning(record), thinkingLevelMap: thinkingMap(record) }, "remote");
232
+ return { ...common, type, ...thinking, contextWindow, maxTokens: Math.min(requestedMax, contextWindow) };
233
+ }
234
+
235
+ function paginationReason(body: Record<string, unknown>, response: Response): string | undefined {
236
+ const link = response.headers.get("link");
237
+ if (link && /rel\s*=\s*["']?next/iu.test(link)) return "Link rel=next";
238
+ if (body.has_more === true || body.hasMore === true) return "has_more=true";
239
+ for (const key of ["next", "next_page", "nextPage", "next_cursor", "nextCursor"]) {
240
+ const value = body[key];
241
+ if (typeof value === "string" && value.trim().length > 0) return key;
242
+ if (typeof value === "number" && Number.isFinite(value) && value !== 0) return key;
243
+ if (value === true) return key;
244
+ if (typeof value === "object" && value !== null && (!Array.isArray(value) || value.length > 0)) return key;
245
+ }
246
+ return undefined;
247
+ }
248
+
249
+ async function cancelBody(response: Response): Promise<void> {
250
+ try { await response.body?.cancel(); } catch { /* best effort */ }
251
+ }
252
+
253
+ async function readTextLimited(response: Response, limit: number, strict: boolean): Promise<string> {
254
+ const length = Number(response.headers.get("content-length"));
255
+ if (strict && Number.isFinite(length) && length > limit) {
256
+ await cancelBody(response);
257
+ throw mcError("MCFG_E_DISCOVERY_BODY_LIMIT", { limit });
258
+ }
259
+ if (!response.body) return "";
260
+ const reader = response.body.getReader();
261
+ const decoder = new TextDecoder();
262
+ let total = 0;
263
+ let text = "";
264
+ try {
265
+ while (true) {
266
+ const { done, value } = await reader.read();
267
+ if (done) break;
268
+ total += value.byteLength;
269
+ if (total > limit) {
270
+ await reader.cancel();
271
+ if (strict) throw mcError("MCFG_E_DISCOVERY_BODY_LIMIT", { limit });
272
+ break;
273
+ }
274
+ text += decoder.decode(value, { stream: true });
275
+ }
276
+ text += decoder.decode();
277
+ return text;
278
+ } finally {
279
+ reader.releaseLock();
280
+ }
281
+ }
282
+
283
+ function safeErrorDetail(text: string): string {
284
+ return text
285
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "")
286
+ .replace(/(["']?authorization["']?\s*[:=]\s*["']?)(?:(?:Bearer|Basic)\s+)?[^"'\s,;}]+/giu, "$1[REDACTED]")
287
+ .replace(/(["']?(?:x-api-key|x-goog-api-key|api[-_ ]?key|token)["']?\s*[:=]\s*["']?)[^"'\s,;}]+/giu, "$1[REDACTED]")
288
+ .replace(/([?&](?:key|token|api_key)=)[^&\s]+/giu, "$1[REDACTED]")
289
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
290
+ .replace(/\s+/gu, " ")
291
+ .trim()
292
+ .slice(0, 1024);
293
+ }
294
+
295
+ async function fetchWithSafeRedirects(initialUrl: string, fetchImpl: FetchFunction, init: RequestInit, signal: AbortSignal): Promise<Response> {
296
+ let current = new URL(initialUrl);
297
+ for (let redirects = 0; redirects <= 5; redirects++) {
298
+ signal.throwIfAborted();
299
+ const response = await fetchImpl(current, { ...init, signal, redirect: "manual" });
300
+ if (![301, 302, 303, 307, 308].includes(response.status)) return response;
301
+ const location = response.headers.get("location");
302
+ if (!location) {
303
+ await cancelBody(response);
304
+ throw mcError("MCFG_E_REDIRECT_LOCATION", { status: response.status });
305
+ }
306
+ const next = new URL(location, current);
307
+ await cancelBody(response);
308
+ if (next.origin !== current.origin) throw mcError("MCFG_E_REDIRECT_ORIGIN");
309
+ current = next;
310
+ }
311
+ throw mcError("MCFG_E_REDIRECT_LIMIT");
312
+ }
313
+
314
+ function effectiveAuthHeader(draft: ProviderDraft): DiscoveryAuthHeader {
315
+ if (draft.authHeader !== "auto") return draft.authHeader;
316
+ if (draft.chatApi === "anthropic-messages") return "anthropic";
317
+ if (draft.chatApi === "google-generative-ai") return "x-goog-api-key";
318
+ if (draft.chatApi === "azure-openai-responses") return "api-key";
319
+ return "bearer";
320
+ }
321
+
322
+ function validOrigin(value: string | undefined): string | undefined {
323
+ if (!value) return undefined;
324
+ try { return new URL(validateHttpUrl(value, "Base URL")).origin; } catch { return undefined; }
325
+ }
326
+
327
+ export async function discoverModels(
328
+ draft: ProviderDraft,
329
+ piAuth: AuthResult | undefined,
330
+ signal: AbortSignal,
331
+ timeoutMs = 15_000,
332
+ ): Promise<CandidateModel[]> {
333
+ if (!draft.discoveryUrl) throw mcError("MCFG_E_DISCOVERY_URL");
334
+ const url = validateHttpUrl(draft.discoveryUrl, "Discovery URL");
335
+ const authHeader = effectiveAuthHeader(draft);
336
+ const credentials: DiscoveryCredentials = {};
337
+ if (draft.authMode === "models-json") {
338
+ const raw = draft.apiKeyRaw;
339
+ const envMatch = raw?.match(/^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/u);
340
+ if (envMatch) {
341
+ const name = envMatch[1] ?? envMatch[2]!;
342
+ credentials.apiKey = process.env[name];
343
+ if (!credentials.apiKey) throw mcError("MCFG_E_ENV_MISSING", { name });
344
+ } else if (raw?.startsWith("$$") || raw?.startsWith("$!")) credentials.apiKey = raw.slice(1);
345
+ else credentials.apiKey = raw;
346
+ } else if (draft.authMode === "temporary") credentials.apiKey = draft.temporaryKey;
347
+ else if (draft.authMode === "reuse-pi") {
348
+ credentials.apiKey = piAuth?.auth.apiKey;
349
+ const resolvedHeaders = Object.fromEntries(Object.entries(piAuth?.auth.headers ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
350
+ if (!credentials.apiKey && !Object.keys(resolvedHeaders).length) throw mcError("MCFG_E_PI_AUTH");
351
+ credentials.headers = authHeader === "none" ? {} : resolvedHeaders;
352
+ const hasCredentialMaterial = authHeader !== "none" && (!!credentials.apiKey || Object.keys(resolvedHeaders).length > 0);
353
+ const modelOrigin = validOrigin(draft.baseUrl) ?? validOrigin(piAuth?.auth.baseUrl);
354
+ if (hasCredentialMaterial && (!modelOrigin || new URL(url).origin !== modelOrigin)) throw mcError("MCFG_E_DISCOVERY_AUTH_ORIGIN");
355
+ }
356
+
357
+ const headers = new Headers({ accept: "application/json", ...credentials.headers });
358
+ if (credentials.apiKey && authHeader === "bearer") headers.set("authorization", `Bearer ${credentials.apiKey}`);
359
+ if (credentials.apiKey && authHeader === "x-api-key") headers.set("x-api-key", credentials.apiKey);
360
+ if (credentials.apiKey && authHeader === "api-key") headers.set("api-key", credentials.apiKey);
361
+ if (credentials.apiKey && authHeader === "x-goog-api-key") headers.set("x-goog-api-key", credentials.apiKey);
362
+ if (authHeader === "anthropic") {
363
+ if (credentials.apiKey) headers.set("x-api-key", credentials.apiKey);
364
+ headers.set("anthropic-version", "2023-06-01");
365
+ }
366
+ if (authHeader === "none") {
367
+ headers.delete("authorization");
368
+ headers.delete("x-api-key");
369
+ headers.delete("api-key");
370
+ headers.delete("x-goog-api-key");
371
+ }
372
+
373
+ const fetchImpl = draft.proxyUrl ? createProviderFetch(draft.proxyUrl) : globalThis.fetch;
374
+ const combined = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
375
+ const response = await fetchWithSafeRedirects(url, fetchImpl, { headers }, combined);
376
+ if (!response.ok) {
377
+ const detail = safeErrorDetail(await readTextLimited(response, MAX_ERROR_BYTES, false));
378
+ throw mcError("MCFG_E_DISCOVERY_HTTP", { status: response.status, detail: detail ? `: ${detail}` : "" });
379
+ }
380
+ const text = await readTextLimited(response, MAX_BODY_BYTES, true);
381
+ let body: unknown;
382
+ try { body = JSON.parse(text); } catch { throw mcError("MCFG_E_DISCOVERY_JSON"); }
383
+ const object = typeof body === "object" && body !== null && !Array.isArray(body) ? body as Record<string, unknown> : undefined;
384
+ if (object) {
385
+ const reason = paginationReason(object, response);
386
+ if (reason) throw mcError("MCFG_E_PAGINATION", { reason });
387
+ }
388
+ const rows = Array.isArray(body)
389
+ ? body
390
+ : object
391
+ ? [object.data, object.models, object.list, object.result].find(Array.isArray)
392
+ : undefined;
393
+ if (!rows) throw mcError("MCFG_E_DISCOVERY_SHAPE");
394
+ const models = rows.map((row) => normalizeRemote(row, draft)).filter((row): row is CandidateModel => row !== undefined);
395
+ const seen = new Set<string>();
396
+ return models.filter((model) => {
397
+ const key = identityOf(model, draft.id);
398
+ if (seen.has(key)) return false;
399
+ seen.add(key);
400
+ return true;
401
+ });
402
+ }
403
+
404
+ export async function discoverModelsFromCandidates(
405
+ draft: ProviderDraft,
406
+ candidates: readonly string[],
407
+ piAuth: AuthResult | undefined,
408
+ signal: AbortSignal,
409
+ timeoutMs = 15_000,
410
+ ): Promise<DiscoveryResult> {
411
+ if (!candidates.length) throw mcError("MCFG_E_DISCOVERY_URL");
412
+ const normalized = candidates.map((candidate) => validateHttpUrl(candidate, "Discovery URL"));
413
+ const origin = new URL(normalized[0]!).origin;
414
+ if (normalized.some((candidate) => new URL(candidate).origin !== origin)) throw mcError("MCFG_E_REDIRECT_ORIGIN");
415
+ for (let index = 0; index < normalized.length; index++) {
416
+ const url = normalized[index]!;
417
+ try {
418
+ const models = await discoverModels({ ...draft, discoveryUrl: url }, piAuth, signal, timeoutMs);
419
+ return { models, url };
420
+ } catch (error) {
421
+ const status = error instanceof ModelConfigError && error.code === "MCFG_E_DISCOVERY_HTTP" ? Number(error.params.status) : undefined;
422
+ if (index < normalized.length - 1 && (status === 404 || status === 405)) continue;
423
+ throw error;
424
+ }
425
+ }
426
+ throw mcError("MCFG_E_DISCOVERY_URL");
427
+ }