@maheidem/model-discovery 0.6.1 → 0.7.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.
package/providers.ts ADDED
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Provider detection and model config extraction.
3
+ *
4
+ * Pure module (no TUI, no storage): reads what the servers actually report — the
5
+ * OpenAI-layer `/v1/models` catalogue plus per-type *native* enrichment endpoints
6
+ * (llama.cpp `/props`, oMLX `/v1/models/status`, Ollama `/api/tags` + `/api/ps`) —
7
+ * and turns it into the ModelConfig that registerProvider() registers with Pi.
8
+ *
9
+ * The per-server API research this encodes lives in docs/providers/.
10
+ */
11
+
12
+ export interface ModelConfig {
13
+ id: string;
14
+ name: string;
15
+ contextWindow: number | null;
16
+ maxTokens: number | null;
17
+ reasoning: boolean | null;
18
+ input: string[] | null;
19
+ source: string;
20
+ loaded?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Plugin-internal enrichment merged into raw model objects by enrichModels().
25
+ * Values come from the server's *native* (non-OpenAI) endpoints and are consulted
26
+ * only when the /v1/models entry does not report the field itself.
27
+ */
28
+ export interface ModelEnrichment {
29
+ /** Effective runtime context (llama.cpp /props n_ctx, oMLX max_context_window, Ollama default num_ctx). */
30
+ contextWindow?: number;
31
+ /** Effective max output tokens (oMLX per-model setting). */
32
+ maxTokens?: number;
33
+ /** Model is currently loaded in memory (oMLX status `loaded`, Ollama /api/ps). */
34
+ loaded?: boolean;
35
+ /** Authoritative VLM flag (llama.cpp /props `modalities.vision`). */
36
+ vision?: boolean;
37
+ /** Server-reported thinking-capable default (oMLX status `thinking_default`). */
38
+ thinkingDefault?: boolean;
39
+ }
40
+
41
+ /** Key under which enrichModels() stashes a ModelEnrichment on a raw model object. */
42
+ export const ENRICH_KEY = "__md";
43
+
44
+ export function redactSecret(value: string, secret?: string): string {
45
+ return secret ? value.replaceAll(secret, "[redacted]") : value;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Server detection (headers first, then model-object fingerprints)
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export function detectServerType(headers: Headers, models: Record<string, unknown>[]): string {
53
+ const server = (headers.get("server") ?? "").toLowerCase();
54
+ const poweredBy = (headers.get("x-powered-by") ?? "").toLowerCase();
55
+
56
+ if (server.includes("llama-cpp") || server.includes("llama.cpp")) return "llama.cpp";
57
+ if (server.includes("ollama")) return "Ollama";
58
+ if (server.includes("vllm")) return "vLLM";
59
+ if (server.includes("sglang")) return "SGLang";
60
+ if (server.includes("lm-studio") || server.includes("lm studio") || server.includes("lmstudio")) return "LM Studio";
61
+ if (server.includes("omlx") || poweredBy.includes("omlx")) return "oMLX";
62
+
63
+ for (const m of models) {
64
+ const ownedBy = String(m.owned_by ?? "").toLowerCase();
65
+ if (ownedBy === "omlx") return "oMLX";
66
+ if (ownedBy === "vllm") return "vLLM";
67
+ if (ownedBy === "llamacpp") return "llama.cpp";
68
+ }
69
+ for (const m of models) {
70
+ // MTPLX /v1/models entries carry a `capability` field (chat/embedding/rerank)
71
+ if (typeof m.capability === "string" && (m.capability as string).length > 0) return "MTPLX";
72
+ }
73
+ for (const m of models) {
74
+ if (String(m.id ?? "").includes(":")) return "Ollama";
75
+ }
76
+ return "OpenAI-compatible";
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Raw field parsing helpers
81
+ // ---------------------------------------------------------------------------
82
+
83
+ function tryNum(v: unknown): number | null {
84
+ if (typeof v === "number" && !isNaN(v)) return v;
85
+ if (typeof v === "string") {
86
+ const n = parseInt(v, 10);
87
+ return isNaN(n) ? null : n;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function parseArgValue(args: string[] | undefined, flag: string): number | null {
93
+ if (!args) return null;
94
+ for (let i = 0; i < args.length - 1; i++) {
95
+ if (args[i] === flag) {
96
+ const n = parseInt(args[i + 1], 10);
97
+ return isNaN(n) ? null : n;
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function parsePresetValue(preset: string | undefined, key: string): number | null {
104
+ if (!preset) return null;
105
+ const m = preset.match(new RegExp(`${key}\\s*=\\s*(\\d+)`, "i"));
106
+ if (m) {
107
+ const n = parseInt(m[1], 10);
108
+ return isNaN(n) ? null : n;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Model config extraction (reads real server data, null for anything missing)
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export function extractModelConfig(raw: Record<string, unknown>): ModelConfig {
118
+ const id = String(raw.id ?? "");
119
+ const name = String(raw.name ?? id);
120
+ const status = (raw.status && typeof raw.status === "object" ? raw.status : undefined) as
121
+ | Record<string, unknown>
122
+ | undefined;
123
+ const args = status?.args as string[] | undefined;
124
+ const preset = status?.preset as string | undefined;
125
+ const enriched = (raw[ENRICH_KEY] ?? {}) as ModelEnrichment;
126
+
127
+ // Context window: standard fields, then llama.cpp args/preset, then loaded meta,
128
+ // then native-endpoint enrichment (llama.cpp /props, oMLX status, Ollama tags)
129
+ let contextWindow =
130
+ tryNum(raw.context_length) ??
131
+ tryNum(raw.context_window) ??
132
+ tryNum(raw.max_model_len) ??
133
+ tryNum(raw.max_context_len) ??
134
+ tryNum(raw.max_context_length) ??
135
+ parseArgValue(args, "--ctx-size") ??
136
+ parsePresetValue(preset, "ctx-size");
137
+ if (contextWindow === null && raw.meta && typeof raw.meta === "object") {
138
+ contextWindow = tryNum((raw.meta as Record<string, unknown>).n_ctx);
139
+ }
140
+ if (contextWindow === null) contextWindow = enriched.contextWindow ?? null;
141
+
142
+ // Max output tokens
143
+ let maxTokens =
144
+ tryNum(raw.max_tokens) ??
145
+ tryNum(raw.max_output_tokens) ??
146
+ tryNum(raw.max_completion_tokens) ??
147
+ parseArgValue(args, "--n-predict") ??
148
+ parsePresetValue(preset, "n-predict");
149
+ if (maxTokens === null) maxTokens = enriched.maxTokens ?? null;
150
+
151
+ // Reasoning
152
+ let reasoning: boolean | null = null;
153
+ if (Array.isArray(raw.capabilities)) reasoning = (raw.capabilities as string[]).includes("reasoning");
154
+ if (reasoning === null && raw.reasoning !== undefined) reasoning = !!raw.reasoning;
155
+ if (reasoning === null) {
156
+ const budget = parseArgValue(args, "--reasoning-budget") ?? parsePresetValue(preset, "reasoning-budget");
157
+ if (budget !== null) reasoning = budget !== 0;
158
+ }
159
+ if (reasoning === null && enriched.thinkingDefault === true) reasoning = true;
160
+
161
+ // Input modalities
162
+ let input: string[] | null = null;
163
+ let hasVision = false;
164
+
165
+ // 1. Standard architecture.input_modalities (vLLM, SGLang, etc.)
166
+ if (raw.architecture && typeof raw.architecture === "object") {
167
+ const arch = raw.architecture as Record<string, unknown>;
168
+ const modalities = arch.input_modalities as string[] | undefined;
169
+ if (Array.isArray(modalities) && modalities.length > 0) {
170
+ input = [];
171
+ for (const m of modalities) {
172
+ const l = m.toLowerCase();
173
+ if (l.includes("text") && !input.includes("text")) input.push("text");
174
+ if ((l.includes("image") || l.includes("vision")) && !input.includes("image")) {
175
+ input.push("image");
176
+ hasVision = true;
177
+ }
178
+ }
179
+ }
180
+ // Also check for vision-specific architecture keys
181
+ if (!hasVision && (arch.vision_config || arch.vision_model || arch.mm_proj || arch.multi_modal_projector)) {
182
+ hasVision = true;
183
+ }
184
+ }
185
+
186
+ // 2. Direct input array on the model object
187
+ if (!input && Array.isArray(raw.input)) {
188
+ input = raw.input as string[];
189
+ if (input.includes("image")) hasVision = true;
190
+ }
191
+
192
+ // 3. llama.cpp: --mmproj flag in args or preset (multimodal projector file)
193
+ if (!hasVision && args) {
194
+ for (const a of args) {
195
+ if (a.startsWith("--mmproj") || a.startsWith("--vision")) {
196
+ hasVision = true;
197
+ break;
198
+ }
199
+ }
200
+ }
201
+ if (!hasVision && preset) {
202
+ if (/mmproj|vision/i.test(preset)) {
203
+ hasVision = true;
204
+ }
205
+ }
206
+
207
+ // 4. oMLX: check for vision-specific capabilities or model tags
208
+ if (!hasVision && Array.isArray(raw.capabilities)) {
209
+ const caps = (raw.capabilities as string[]).map((c: string) => c.toLowerCase());
210
+ if (caps.some((c: string) => c.includes("vision") || c.includes("image") || c.includes("multimodal"))) {
211
+ hasVision = true;
212
+ }
213
+ }
214
+
215
+ // 5. Enrichment: native endpoint reports an authoritative VLM flag (llama.cpp /props)
216
+ if (!hasVision && enriched.vision === true) hasVision = true;
217
+
218
+ // 6. Build final input array — always include "text", add "image" if vision detected
219
+ if (hasVision) {
220
+ input = input && input.includes("image") ? input : ["text", "image"];
221
+ } else if (!input) {
222
+ input = ["text"];
223
+ } else if (!input.includes("text")) {
224
+ input.unshift("text");
225
+ }
226
+
227
+ let loaded = status?.value === "loaded" ? true : status?.value === "unloaded" ? false : undefined;
228
+ if (loaded === undefined && enriched.loaded === true) loaded = true;
229
+ const source = String(raw.source ?? (status ? "server args" : "api"));
230
+
231
+ return { id, name, contextWindow, maxTokens, reasoning, input, source, loaded };
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Live probe: OpenAI catalogue + native enrichment
236
+ // ---------------------------------------------------------------------------
237
+
238
+ export async function fetchModels(
239
+ baseUrl: string,
240
+ apiKey?: string,
241
+ signal?: AbortSignal,
242
+ ): Promise<{ models: Record<string, unknown>[]; serverType: string }> {
243
+ const url = baseUrl.replace(/\/+$/, "") + "/v1/models";
244
+ const headers: Record<string, string> = { Accept: "application/json" };
245
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
246
+
247
+ const response = await fetch(url, { headers, signal });
248
+ if (!response.ok) {
249
+ const body = await response.text().catch(() => "");
250
+ throw new Error(`HTTP ${response.status}: ${redactSecret(body.slice(0, 200), apiKey)}`);
251
+ }
252
+ const data = (await response.json()) as Record<string, unknown>;
253
+ if (!data || typeof data !== "object" || !Array.isArray(data.data)) {
254
+ throw new Error("Invalid /v1/models response: expected a data array.");
255
+ }
256
+ const models = data.data.filter(
257
+ (model): model is Record<string, unknown> =>
258
+ !!model && typeof model === "object" && typeof (model as Record<string, unknown>).id === "string" &&
259
+ (model as Record<string, unknown>).id !== "",
260
+ );
261
+ if (models.length !== data.data.length) {
262
+ throw new Error("Invalid /v1/models response: every model must have a non-empty string id.");
263
+ }
264
+ const serverType = detectServerType(response.headers, models);
265
+ await enrichModels(baseUrl, apiKey, serverType, models);
266
+ return { models, serverType };
267
+ }
268
+
269
+ /**
270
+ * Best-effort enrichment from each server type's *native* (non-OpenAI) endpoints.
271
+ * Merges server-reported context windows, max tokens, load state, and VLM flags
272
+ * into the raw model objects (under ENRICH_KEY) for whatever /v1/models omitted.
273
+ * Never throws: a missing or failing native endpoint leaves the catalogue unchanged.
274
+ */
275
+ export async function enrichModels(
276
+ baseUrl: string,
277
+ apiKey: string | undefined,
278
+ serverType: string,
279
+ models: Record<string, unknown>[],
280
+ ): Promise<void> {
281
+ const base = baseUrl.replace(/\/+$/, "");
282
+ const headers: Record<string, string> = { Accept: "application/json" };
283
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
284
+ const getJson = async (path: string): Promise<Record<string, unknown> | null> => {
285
+ try {
286
+ const res = await fetch(`${base}${path}`, { headers, signal: AbortSignal.timeout(1_000) });
287
+ if (!res.ok) return null;
288
+ const data = (await res.json()) as unknown;
289
+ return data && typeof data === "object" ? (data as Record<string, unknown>) : null;
290
+ } catch {
291
+ return null;
292
+ }
293
+ };
294
+ const list = (v: unknown): Record<string, unknown>[] => (Array.isArray(v) ? (v as Record<string, unknown>[]) : []);
295
+ const merge = (m: Record<string, unknown>, e: ModelEnrichment): void => {
296
+ const existing = (m[ENRICH_KEY] ?? {}) as ModelEnrichment;
297
+ m[ENRICH_KEY] = { ...existing, ...e };
298
+ };
299
+ try {
300
+ if (serverType === "llama.cpp") {
301
+ // Native /props: the real runtime context + the authoritative VLM flag
302
+ const props = await getJson("/props");
303
+ if (!props) return;
304
+ const gen = props.default_generation_settings as Record<string, unknown> | undefined;
305
+ const nCtx = gen ? tryNum(gen.n_ctx) : null;
306
+ const vision = Boolean((props.modalities as Record<string, unknown> | undefined)?.vision);
307
+ if ((nCtx !== null && nCtx > 0) || vision) {
308
+ for (const m of models) merge(m, { contextWindow: nCtx ?? undefined, vision });
309
+ }
310
+ return;
311
+ }
312
+ if (serverType === "oMLX") {
313
+ // Extended /v1/models/status: effective context, per-model max tokens,
314
+ // load state, and the thinking-capable default
315
+ const status = await getJson("/v1/models/status");
316
+ if (!status) return;
317
+ const byId = new Map<string, ModelEnrichment>();
318
+ for (const entry of list(status.models)) {
319
+ const id = String(entry.id ?? "");
320
+ if (!id) continue;
321
+ const e: ModelEnrichment = {
322
+ contextWindow:
323
+ tryNum(entry.max_context_window) ?? tryNum(entry.model_context_length) ?? undefined,
324
+ maxTokens: tryNum(entry.max_tokens) ?? undefined,
325
+ loaded: entry.loaded === true ? true : undefined,
326
+ thinkingDefault: entry.thinking_default === true ? true : undefined,
327
+ };
328
+ byId.set(id, e);
329
+ // /v1/models may surface the user alias as the id — match both
330
+ const alias = String(entry.model_alias ?? "");
331
+ if (alias && alias !== id) byId.set(alias, e);
332
+ }
333
+ for (const m of models) {
334
+ const e = byId.get(String(m.id ?? ""));
335
+ if (e) merge(m, e);
336
+ }
337
+ return;
338
+ }
339
+ if (serverType === "Ollama") {
340
+ // Native /api/tags: model cards incl. the default context; /api/ps: loaded models
341
+ const tags = await getJson("/api/tags");
342
+ const tagByName = new Map<string, Record<string, unknown>>();
343
+ for (const t of list(tags?.models)) {
344
+ const name = String(t.name ?? "");
345
+ if (name) tagByName.set(name, t);
346
+ }
347
+ const loadedNames = new Set<string>();
348
+ for (const p of list((await getJson("/api/ps"))?.models)) {
349
+ const name = String(p.name ?? "");
350
+ if (name) loadedNames.add(name);
351
+ }
352
+ for (const m of models) {
353
+ const name = String(m.id ?? "");
354
+ const details = (tagByName.get(name)?.details ?? {}) as Record<string, unknown>;
355
+ const e: ModelEnrichment = {
356
+ contextWindow: tryNum(details.context_length) ?? undefined,
357
+ loaded: loadedNames.has(name) ? true : undefined,
358
+ };
359
+ if (e.contextWindow !== undefined || e.loaded !== undefined) merge(m, e);
360
+ }
361
+ return;
362
+ }
363
+ // vLLM / SGLang / LM Studio / MTPLX / generic: no reliable native metadata
364
+ // endpoint today (see docs/providers/ — context stays override-driven)
365
+ } catch {
366
+ /* enrichment is best-effort — never fail the scan */
367
+ }
368
+ }