@khanglvm/llm-router 1.0.5

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,905 @@
1
+ /**
2
+ * Provider probing utilities (Node CLI config/update flow).
3
+ * Detects supported request/response format(s) and attempts model discovery.
4
+ */
5
+
6
+ import { FORMATS } from "../translator/index.js";
7
+ import { resolveProviderUrl } from "../runtime/config.js";
8
+
9
+ const DEFAULT_TIMEOUT_MS = 10000;
10
+
11
+ function makeProviderShell(baseUrl) {
12
+ return {
13
+ baseUrl,
14
+ formats: [FORMATS.OPENAI, FORMATS.CLAUDE],
15
+ format: FORMATS.OPENAI
16
+ };
17
+ }
18
+
19
+ function normalizeProbeBaseUrlByFormat(value) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
21
+ const openai = typeof value.openai === "string" ? value.openai.trim() : "";
22
+ const claude = typeof value.claude === "string"
23
+ ? value.claude.trim()
24
+ : (typeof value.anthropic === "string" ? value.anthropic.trim() : "");
25
+ const out = {};
26
+ if (openai) out[FORMATS.OPENAI] = openai;
27
+ if (claude) out[FORMATS.CLAUDE] = claude;
28
+ return Object.keys(out).length > 0 ? out : undefined;
29
+ }
30
+
31
+ function cloneHeaders(headers) {
32
+ return { ...(headers || {}) };
33
+ }
34
+
35
+ function makeProgressEmitter(callback) {
36
+ if (typeof callback !== "function") return () => {};
37
+ return (event) => {
38
+ try {
39
+ callback(event);
40
+ } catch {
41
+ // ignore probe progress callback failures
42
+ }
43
+ };
44
+ }
45
+
46
+ function makeAuthVariants(format, apiKey) {
47
+ if (!apiKey) return [];
48
+
49
+ if (format === FORMATS.CLAUDE) {
50
+ return [
51
+ { type: "x-api-key", headers: { "x-api-key": apiKey } },
52
+ { type: "bearer", headers: { Authorization: `Bearer ${apiKey}` } }
53
+ ];
54
+ }
55
+
56
+ return [
57
+ { type: "bearer", headers: { Authorization: `Bearer ${apiKey}` } },
58
+ { type: "x-api-key", headers: { "x-api-key": apiKey } }
59
+ ];
60
+ }
61
+
62
+ function resolveModelsUrl(baseUrl, format) {
63
+ const clean = String(baseUrl || "").trim().replace(/\/+$/, "");
64
+ if (!clean) return "";
65
+ const isVersionedApiRoot = /\/v\d+(?:\.\d+)?$/i.test(clean);
66
+
67
+ if (format === FORMATS.OPENAI) {
68
+ if (clean.endsWith("/chat/completions")) {
69
+ return clean.replace(/\/chat\/completions$/, "/models");
70
+ }
71
+ if (clean.endsWith("/v1") || isVersionedApiRoot) return `${clean}/models`;
72
+ return `${clean}/v1/models`;
73
+ }
74
+
75
+ if (clean.endsWith("/v1/messages")) {
76
+ return clean.replace(/\/messages$/, "/models");
77
+ }
78
+ if (clean.endsWith("/messages")) {
79
+ const parent = clean.replace(/\/messages$/, "");
80
+ if (parent.endsWith("/v1") || /\/v\d+(?:\.\d+)?$/i.test(parent)) return `${parent}/models`;
81
+ return `${parent}/v1/models`;
82
+ }
83
+ if (clean.endsWith("/v1") || isVersionedApiRoot) return `${clean}/models`;
84
+ return `${clean}/v1/models`;
85
+ }
86
+
87
+ async function safeFetchJson(url, init = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
88
+ const headers = cloneHeaders(init.headers);
89
+ let response;
90
+ let text = "";
91
+ let json = null;
92
+ let error = null;
93
+
94
+ try {
95
+ response = await fetch(url, {
96
+ ...init,
97
+ headers,
98
+ signal: init.signal || AbortSignal.timeout(timeoutMs)
99
+ });
100
+ text = await response.text();
101
+ if (text) {
102
+ try {
103
+ json = JSON.parse(text);
104
+ } catch {
105
+ json = null;
106
+ }
107
+ }
108
+ } catch (fetchError) {
109
+ error = fetchError instanceof Error ? fetchError.message : String(fetchError);
110
+ }
111
+
112
+ return {
113
+ ok: Boolean(response?.ok),
114
+ status: response?.status ?? 0,
115
+ statusText: response?.statusText || "",
116
+ headers: response ? Object.fromEntries(response.headers.entries()) : {},
117
+ text,
118
+ json,
119
+ error
120
+ };
121
+ }
122
+
123
+ function getErrorMessage(body, fallbackText = "") {
124
+ if (!body) return fallbackText;
125
+ if (typeof body.error === "string") return body.error;
126
+ if (body.error && typeof body.error.message === "string") return body.error.message;
127
+ if (typeof body.message === "string") return body.message;
128
+ return fallbackText;
129
+ }
130
+
131
+ function looksOpenAI(result) {
132
+ const body = result.json;
133
+ if (!body || typeof body !== "object") return false;
134
+ if (Array.isArray(body.choices)) return true;
135
+ if (body.object === "list" && Array.isArray(body.data)) return true;
136
+ if (body.type === "error" && body.error && typeof body.error === "object") {
137
+ return false;
138
+ }
139
+ if (body.error && (typeof body.error === "string" || typeof body.error.message === "string")) {
140
+ return true;
141
+ }
142
+ return false;
143
+ }
144
+
145
+ function looksClaude(result) {
146
+ const body = result.json;
147
+ if (!body || typeof body !== "object") return false;
148
+ if (body.type === "message") return true;
149
+ if (body.type === "error" && body.error) return true;
150
+ return false;
151
+ }
152
+
153
+ function authLooksValid(result) {
154
+ if (result.ok) return true;
155
+ const msg = (getErrorMessage(result.json, result.text) || "").toLowerCase();
156
+
157
+ if (!msg) {
158
+ // A 400 with empty body still means the endpoint exists and the key was accepted often enough to parse body.
159
+ return result.status >= 400 && result.status < 500 && result.status !== 401 && result.status !== 403;
160
+ }
161
+
162
+ if (msg.includes("api key") && (msg.includes("invalid") || msg.includes("missing"))) return false;
163
+ if (msg.includes("unauthorized") || msg.includes("forbidden") || msg.includes("authentication")) return false;
164
+
165
+ // Common signal for valid auth but bad model/payload.
166
+ if (msg.includes("model") || msg.includes("messages") || msg.includes("max_tokens") || msg.includes("invalid request")) {
167
+ return true;
168
+ }
169
+
170
+ return result.status !== 401 && result.status !== 403;
171
+ }
172
+
173
+ function extractModelIds(result) {
174
+ const body = result.json;
175
+ if (!body || !Array.isArray(body.data)) return [];
176
+ const ids = [];
177
+ for (const item of body.data) {
178
+ if (!item || typeof item !== "object") continue;
179
+ const id = typeof item.id === "string" ? item.id : (typeof item.name === "string" ? item.name : null);
180
+ if (id) ids.push(id);
181
+ }
182
+ return [...new Set(ids)];
183
+ }
184
+
185
+ function dedupeStrings(values) {
186
+ return [...new Set((values || []).filter(Boolean).map((value) => String(value).trim()).filter(Boolean))];
187
+ }
188
+
189
+ function normalizeUrlPathForScoring(endpoint) {
190
+ try {
191
+ return new URL(String(endpoint)).pathname.replace(/\/+$/, "") || "/";
192
+ } catch {
193
+ return String(endpoint || "").trim().replace(/^https?:\/\/[^/]+/i, "").replace(/\/+$/, "") || "/";
194
+ }
195
+ }
196
+
197
+ function orderAuthVariants(authVariants, preferredAuth) {
198
+ if (!preferredAuth || !Array.isArray(authVariants) || authVariants.length <= 1) return authVariants;
199
+ const normalized = String(preferredAuth).trim().toLowerCase();
200
+ const preferred = authVariants.find((item) => item.type === normalized);
201
+ if (!preferred) return authVariants;
202
+ return [preferred, ...authVariants.filter((item) => item !== preferred)];
203
+ }
204
+
205
+ function getResultMessage(result) {
206
+ return String(getErrorMessage(result.json, result.text) || "").trim();
207
+ }
208
+
209
+ function truncateMessage(value, max = 220) {
210
+ const text = String(value || "").trim();
211
+ if (!text) return "";
212
+ if (text.length <= max) return text;
213
+ return `${text.slice(0, max - 3)}...`;
214
+ }
215
+
216
+ function isUnsupportedModelMessage(message) {
217
+ const text = String(message || "").toLowerCase();
218
+ if (!text) return false;
219
+ const patterns = [
220
+ /model .*not found/,
221
+ /unknown model/,
222
+ /unsupported model/,
223
+ /invalid model/,
224
+ /no such model/,
225
+ /model .*does not exist/,
226
+ /model .*not available/,
227
+ /unrecognized model/,
228
+ /model .*is not supported/,
229
+ /not enabled for this model/,
230
+ /not available for this api/,
231
+ /not available in this api/,
232
+ /does not support .*api/,
233
+ /must use .*endpoint/,
234
+ /use .*\/v1/,
235
+ /only available via/
236
+ ];
237
+ return patterns.some((pattern) => pattern.test(text));
238
+ }
239
+
240
+ function isTransientModelRuntimeError(result, message) {
241
+ const status = Number(result?.status || 0);
242
+ if ([408, 409, 429, 500, 502, 503, 504].includes(status)) return true;
243
+
244
+ const text = String(message || "").toLowerCase();
245
+ if (!text) return false;
246
+ const patterns = [
247
+ /rate limit/,
248
+ /too many requests/,
249
+ /quota/,
250
+ /overloaded/,
251
+ /try again/,
252
+ /temporar/,
253
+ /service unavailable/,
254
+ /gateway timeout/,
255
+ /upstream/,
256
+ /timeout/
257
+ ];
258
+ return patterns.some((pattern) => pattern.test(text));
259
+ }
260
+
261
+ function classifyModelProbeResult(format, result) {
262
+ const message = getResultMessage(result);
263
+
264
+ if (result.error) {
265
+ return {
266
+ supported: false,
267
+ confirmed: false,
268
+ outcome: "network-error",
269
+ message: result.error
270
+ };
271
+ }
272
+
273
+ if (result.ok) {
274
+ return {
275
+ supported: true,
276
+ confirmed: true,
277
+ outcome: "ok",
278
+ message: "ok"
279
+ };
280
+ }
281
+
282
+ if (!looksExpectedFormat(format, result)) {
283
+ return {
284
+ supported: false,
285
+ confirmed: false,
286
+ outcome: "format-mismatch",
287
+ message: message || "Endpoint response does not match expected format."
288
+ };
289
+ }
290
+
291
+ if (!authLooksValid(result)) {
292
+ return {
293
+ supported: false,
294
+ confirmed: false,
295
+ outcome: "auth-error",
296
+ message: message || "Authentication failed for this format."
297
+ };
298
+ }
299
+
300
+ if (isUnsupportedModelMessage(message)) {
301
+ return {
302
+ supported: false,
303
+ confirmed: false,
304
+ outcome: "model-unsupported",
305
+ message: message || "Model is not supported on this endpoint."
306
+ };
307
+ }
308
+
309
+ if (isTransientModelRuntimeError(result, message)) {
310
+ return {
311
+ supported: true,
312
+ confirmed: false,
313
+ outcome: "runtime-error",
314
+ message: message || "Request reached endpoint but failed with transient runtime error."
315
+ };
316
+ }
317
+
318
+ return {
319
+ supported: false,
320
+ confirmed: false,
321
+ outcome: "unconfirmed",
322
+ message: message || "Could not confirm model support for this endpoint/format."
323
+ };
324
+ }
325
+
326
+ function looksExpectedFormat(format, result) {
327
+ if (format === FORMATS.CLAUDE) return looksClaude(result);
328
+ return looksOpenAI(result);
329
+ }
330
+
331
+ function buildProbeRequest(format, modelId) {
332
+ if (format === FORMATS.CLAUDE) {
333
+ return {
334
+ model: modelId,
335
+ max_tokens: 1,
336
+ stream: false,
337
+ messages: [{ role: "user", content: "ping" }]
338
+ };
339
+ }
340
+
341
+ return {
342
+ model: modelId,
343
+ messages: [{ role: "user", content: "ping" }],
344
+ max_tokens: 1,
345
+ stream: false
346
+ };
347
+ }
348
+
349
+ function makeProbeHeaders(format, extraHeaders, authHeaders = {}) {
350
+ const headers = {
351
+ "Content-Type": "application/json",
352
+ ...extraHeaders,
353
+ ...authHeaders
354
+ };
355
+ if (format === FORMATS.CLAUDE) {
356
+ if (!headers["anthropic-version"] && !headers["Anthropic-Version"]) {
357
+ headers["anthropic-version"] = "2023-06-01";
358
+ }
359
+ }
360
+ return headers;
361
+ }
362
+
363
+ async function probeModelForFormat({
364
+ baseUrl,
365
+ format,
366
+ apiKey,
367
+ modelId,
368
+ timeoutMs,
369
+ extraHeaders,
370
+ preferredAuthType
371
+ }) {
372
+ const url = resolveProviderUrl(makeProviderShell(baseUrl), format);
373
+ const authVariants = orderAuthVariants(makeAuthVariants(format, apiKey), preferredAuthType);
374
+
375
+ for (const variant of authVariants) {
376
+ const headers = makeProbeHeaders(format, extraHeaders, variant.headers);
377
+ const result = await safeFetchJson(url, {
378
+ method: "POST",
379
+ headers,
380
+ body: JSON.stringify(buildProbeRequest(format, modelId))
381
+ }, timeoutMs);
382
+
383
+ const classified = classifyModelProbeResult(format, result);
384
+ if (classified.supported) {
385
+ return {
386
+ supported: true,
387
+ confirmed: classified.confirmed,
388
+ outcome: classified.outcome,
389
+ authType: variant.type,
390
+ status: result.status,
391
+ message: classified.outcome === "ok"
392
+ ? "ok"
393
+ : truncateMessage(classified.message || getResultMessage(result)),
394
+ error: result.error || null
395
+ };
396
+ }
397
+
398
+ if (classified.outcome === "auth-error") {
399
+ continue;
400
+ }
401
+
402
+ if (classified.outcome === "format-mismatch" || classified.outcome === "model-unsupported" || classified.outcome === "network-error") {
403
+ return {
404
+ supported: false,
405
+ confirmed: false,
406
+ outcome: classified.outcome,
407
+ authType: variant.type,
408
+ status: result.status,
409
+ message: truncateMessage(classified.message),
410
+ error: result.error || null
411
+ };
412
+ }
413
+ }
414
+
415
+ return {
416
+ supported: false,
417
+ confirmed: false,
418
+ outcome: "unknown",
419
+ authType: null,
420
+ status: 0,
421
+ message: "Could not validate model support for this endpoint/format.",
422
+ error: null
423
+ };
424
+ }
425
+
426
+ async function probeOpenAI(baseUrl, apiKey, timeoutMs, extraHeaders = {}) {
427
+ const authVariants = makeAuthVariants(FORMATS.OPENAI, apiKey);
428
+ const modelsUrl = resolveModelsUrl(baseUrl, FORMATS.OPENAI);
429
+ const messagesUrl = resolveProviderUrl(makeProviderShell(baseUrl), FORMATS.OPENAI);
430
+
431
+ const details = {
432
+ format: FORMATS.OPENAI,
433
+ supported: false,
434
+ working: false,
435
+ models: [],
436
+ auth: null,
437
+ checks: []
438
+ };
439
+
440
+ for (const variant of authVariants) {
441
+ const commonHeaders = { "Content-Type": "application/json", ...extraHeaders, ...variant.headers };
442
+
443
+ const modelsResult = await safeFetchJson(modelsUrl, {
444
+ method: "GET",
445
+ headers: commonHeaders
446
+ }, timeoutMs);
447
+ details.checks.push({ step: "models", auth: variant.type, status: modelsResult.status, error: modelsResult.error || null });
448
+
449
+ const chatResult = await safeFetchJson(messagesUrl, {
450
+ method: "POST",
451
+ headers: commonHeaders,
452
+ body: JSON.stringify({
453
+ model: "__llm_router_probe__",
454
+ messages: [{ role: "user", content: "ping" }],
455
+ max_tokens: 1,
456
+ stream: false
457
+ })
458
+ }, timeoutMs);
459
+ details.checks.push({ step: "chat", auth: variant.type, status: chatResult.status, error: chatResult.error || null });
460
+
461
+ if (looksOpenAI(chatResult)) {
462
+ details.supported = true;
463
+ if (authLooksValid(chatResult)) {
464
+ details.working = true;
465
+ details.auth = { type: variant.type === "x-api-key" ? "x-api-key" : "bearer" };
466
+ if (looksOpenAI(modelsResult) && authLooksValid(modelsResult)) {
467
+ details.models = extractModelIds(modelsResult);
468
+ }
469
+ return details;
470
+ }
471
+ }
472
+ }
473
+
474
+ return details;
475
+ }
476
+
477
+ async function probeClaude(baseUrl, apiKey, timeoutMs, extraHeaders = {}) {
478
+ const authVariants = makeAuthVariants(FORMATS.CLAUDE, apiKey);
479
+ const modelsUrl = resolveModelsUrl(baseUrl, FORMATS.CLAUDE);
480
+ const messagesUrl = resolveProviderUrl(makeProviderShell(baseUrl), FORMATS.CLAUDE);
481
+
482
+ const details = {
483
+ format: FORMATS.CLAUDE,
484
+ supported: false,
485
+ working: false,
486
+ models: [],
487
+ auth: null,
488
+ checks: []
489
+ };
490
+
491
+ for (const variant of authVariants) {
492
+ const commonHeaders = {
493
+ "Content-Type": "application/json",
494
+ "anthropic-version": "2023-06-01",
495
+ ...extraHeaders,
496
+ ...variant.headers
497
+ };
498
+
499
+ const modelsResult = await safeFetchJson(modelsUrl, {
500
+ method: "GET",
501
+ headers: commonHeaders
502
+ }, timeoutMs);
503
+ details.checks.push({ step: "models", auth: variant.type, status: modelsResult.status, error: modelsResult.error || null });
504
+
505
+ const messagesResult = await safeFetchJson(messagesUrl, {
506
+ method: "POST",
507
+ headers: commonHeaders,
508
+ body: JSON.stringify({
509
+ model: "__llm_router_probe__",
510
+ max_tokens: 1,
511
+ messages: [{ role: "user", content: "ping" }]
512
+ })
513
+ }, timeoutMs);
514
+ details.checks.push({ step: "messages", auth: variant.type, status: messagesResult.status, error: messagesResult.error || null });
515
+
516
+ if (looksClaude(messagesResult)) {
517
+ details.supported = true;
518
+ if (authLooksValid(messagesResult)) {
519
+ details.working = true;
520
+ details.auth = { type: variant.type === "x-api-key" ? "x-api-key" : "bearer" };
521
+ if (looksClaude(modelsResult) && authLooksValid(modelsResult)) {
522
+ details.models = extractModelIds(modelsResult);
523
+ }
524
+ return details;
525
+ }
526
+ }
527
+ }
528
+
529
+ return details;
530
+ }
531
+
532
+ export async function probeProvider(options) {
533
+ const emitProgress = makeProgressEmitter(options?.onProgress);
534
+ const baseUrl = String(options?.baseUrl || "").trim();
535
+ const baseUrlByFormat = normalizeProbeBaseUrlByFormat(options?.baseUrlByFormat);
536
+ const apiKey = String(options?.apiKey || "").trim();
537
+ const timeoutMs = Number.isFinite(options?.timeoutMs) ? Number(options.timeoutMs) : DEFAULT_TIMEOUT_MS;
538
+ const extraHeaders = options?.headers && typeof options.headers === "object" && !Array.isArray(options.headers)
539
+ ? options.headers
540
+ : {};
541
+ const openaiProbeBaseUrl = String(baseUrlByFormat?.[FORMATS.OPENAI] || baseUrl || "").trim();
542
+ const claudeProbeBaseUrl = String(baseUrlByFormat?.[FORMATS.CLAUDE] || baseUrl || "").trim();
543
+
544
+ if (!openaiProbeBaseUrl && !claudeProbeBaseUrl) {
545
+ throw new Error("Provider baseUrl is required for probing.");
546
+ }
547
+ if (!apiKey) {
548
+ throw new Error("Provider apiKey is required for probing.");
549
+ }
550
+
551
+ emitProgress({ phase: "provider-probe-start", baseUrl: baseUrl || openaiProbeBaseUrl || claudeProbeBaseUrl });
552
+
553
+ const [openai, claude] = await Promise.all([
554
+ openaiProbeBaseUrl
555
+ ? probeOpenAI(openaiProbeBaseUrl, apiKey, timeoutMs, extraHeaders)
556
+ : Promise.resolve({ format: FORMATS.OPENAI, supported: false, working: false, models: [], auth: null, checks: [] }),
557
+ claudeProbeBaseUrl
558
+ ? probeClaude(claudeProbeBaseUrl, apiKey, timeoutMs, extraHeaders)
559
+ : Promise.resolve({ format: FORMATS.CLAUDE, supported: false, working: false, models: [], auth: null, checks: [] })
560
+ ]);
561
+
562
+ const supportedFormats = [claude, openai]
563
+ .filter((entry) => entry.supported)
564
+ .map((entry) => entry.format);
565
+
566
+ const workingFormats = [claude, openai]
567
+ .filter((entry) => entry.working)
568
+ .map((entry) => entry.format);
569
+
570
+ const preferredFormat =
571
+ (claude.working && FORMATS.CLAUDE) ||
572
+ (openai.working && FORMATS.OPENAI) ||
573
+ (claude.supported && FORMATS.CLAUDE) ||
574
+ (openai.supported && FORMATS.OPENAI) ||
575
+ null;
576
+
577
+ const authByFormat = {};
578
+ if (openai.auth) authByFormat[FORMATS.OPENAI] = openai.auth;
579
+ if (claude.auth) authByFormat[FORMATS.CLAUDE] = claude.auth;
580
+
581
+ const models = [...new Set([...(claude.models || []), ...(openai.models || [])])];
582
+
583
+ emitProgress({
584
+ phase: "provider-probe-done",
585
+ baseUrl: baseUrl || openaiProbeBaseUrl || claudeProbeBaseUrl,
586
+ supportedFormats,
587
+ workingFormats
588
+ });
589
+
590
+ return {
591
+ ok: workingFormats.length > 0,
592
+ baseUrl: baseUrl || openaiProbeBaseUrl || claudeProbeBaseUrl,
593
+ baseUrlByFormat,
594
+ formats: supportedFormats,
595
+ workingFormats,
596
+ preferredFormat,
597
+ authByFormat,
598
+ auth: preferredFormat ? authByFormat[preferredFormat] : null,
599
+ models,
600
+ details: {
601
+ openai,
602
+ claude
603
+ }
604
+ };
605
+ }
606
+
607
+ function normalizeEndpointList(rawEndpoints, fallbackBaseUrl = "") {
608
+ const values = [];
609
+ if (Array.isArray(rawEndpoints)) {
610
+ values.push(...rawEndpoints);
611
+ } else if (typeof rawEndpoints === "string") {
612
+ values.push(...rawEndpoints.split(/[,\n;\s]+/g));
613
+ }
614
+ if (fallbackBaseUrl) values.push(fallbackBaseUrl);
615
+ return dedupeStrings(values);
616
+ }
617
+
618
+ function pickBestEndpointForFormat(endpointRows, format) {
619
+ const endpointPreferenceScore = (endpoint) => {
620
+ const path = normalizeUrlPathForScoring(endpoint);
621
+ const looksVersioned = /\/v\d+(?:\.\d+)?$/i.test(path);
622
+ const hasOpenAIHint = /\/openai(?:\/|$)/i.test(path);
623
+ const hasAnthropicHint = /\/anthropic(?:\/|$)|\/claude(?:\/|$)/i.test(path);
624
+
625
+ if (format === FORMATS.OPENAI) {
626
+ if (hasOpenAIHint) return 100;
627
+ if (looksVersioned) return 90;
628
+ if (path === "/" || path === "") return 10;
629
+ return 50;
630
+ }
631
+ if (format === FORMATS.CLAUDE) {
632
+ if (hasAnthropicHint) return 100;
633
+ if (path === "/" || path === "") return 90;
634
+ if (looksVersioned) return 10;
635
+ return 50;
636
+ }
637
+ return 0;
638
+ };
639
+
640
+ const candidates = endpointRows
641
+ .filter((row) => (row.workingFormats || []).includes(format))
642
+ .map((row) => ({
643
+ row,
644
+ score: (row.modelsByFormat?.[format] || []).length,
645
+ pref: endpointPreferenceScore(row.endpoint)
646
+ }))
647
+ .sort((a, b) => {
648
+ if (b.pref !== a.pref) return b.pref - a.pref;
649
+ if (b.score !== a.score) return b.score - a.score;
650
+ return 0;
651
+ });
652
+ return candidates[0]?.row || null;
653
+ }
654
+
655
+ function guessNativeModelFormat(modelId) {
656
+ const id = String(modelId || "").trim().toLowerCase();
657
+ if (!id) return null;
658
+
659
+ // High-confidence Anthropic family.
660
+ if (id.startsWith("claude")) return FORMATS.CLAUDE;
661
+
662
+ // Default most aggregator/coding endpoints expose native OpenAI-compatible format
663
+ // for many model families (gpt, gemini, glm, qwen, deepseek, etc).
664
+ return FORMATS.OPENAI;
665
+ }
666
+
667
+ function pickPreferredFormatForModel(modelId, formats, { providerPreferredFormat } = {}) {
668
+ const supported = dedupeStrings(formats).filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
669
+ if (supported.length === 0) return null;
670
+ if (supported.length === 1) return supported[0];
671
+
672
+ const guessed = guessNativeModelFormat(modelId);
673
+ if (guessed && supported.includes(guessed)) return guessed;
674
+ if (providerPreferredFormat && supported.includes(providerPreferredFormat)) return providerPreferredFormat;
675
+ if (supported.includes(FORMATS.OPENAI)) return FORMATS.OPENAI;
676
+ return supported[0];
677
+ }
678
+
679
+ export async function probeProviderEndpointMatrix(options) {
680
+ const emitProgress = makeProgressEmitter(options?.onProgress);
681
+ const apiKey = String(options?.apiKey || "").trim();
682
+ const timeoutMs = Number.isFinite(options?.timeoutMs) ? Number(options.timeoutMs) : DEFAULT_TIMEOUT_MS;
683
+ const extraHeaders = options?.headers && typeof options.headers === "object" && !Array.isArray(options.headers)
684
+ ? options.headers
685
+ : {};
686
+ const endpoints = normalizeEndpointList(options?.endpoints, options?.baseUrl);
687
+ const models = dedupeStrings(options?.models || []);
688
+
689
+ if (!apiKey) throw new Error("Provider apiKey is required for probing.");
690
+ if (endpoints.length === 0) throw new Error("At least one endpoint is required for probing.");
691
+ if (models.length === 0) throw new Error("At least one model is required for endpoint-model probing.");
692
+
693
+ emitProgress({
694
+ phase: "matrix-start",
695
+ endpointCount: endpoints.length,
696
+ modelCount: models.length
697
+ });
698
+
699
+ const endpointRows = [];
700
+ const modelFormatsMap = {};
701
+ const warnings = [];
702
+
703
+ let completedChecks = 0;
704
+ let totalChecks = 0;
705
+ for (const endpoint of endpoints) {
706
+ totalChecks += 2 * models.length;
707
+ }
708
+
709
+ for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex += 1) {
710
+ const endpoint = endpoints[endpointIndex];
711
+ emitProgress({
712
+ phase: "endpoint-start",
713
+ endpoint,
714
+ endpointIndex: endpointIndex + 1,
715
+ endpointCount: endpoints.length
716
+ });
717
+
718
+ const endpointProbe = await probeProvider({
719
+ baseUrl: endpoint,
720
+ apiKey,
721
+ timeoutMs,
722
+ headers: extraHeaders,
723
+ onProgress: (event) => emitProgress({
724
+ ...event,
725
+ endpoint,
726
+ endpointIndex: endpointIndex + 1,
727
+ endpointCount: endpoints.length
728
+ })
729
+ });
730
+ const rowAuthByFormat = { ...(endpointProbe.authByFormat || {}) };
731
+ const initialWorkingFormats = endpointProbe.workingFormats || [];
732
+ const initialSupportedFormats = endpointProbe.formats || [];
733
+ const formatsToTest = dedupeStrings([
734
+ FORMATS.OPENAI,
735
+ FORMATS.CLAUDE,
736
+ ...initialWorkingFormats,
737
+ ...initialSupportedFormats
738
+ ]).filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
739
+ const modelsByFormat = {};
740
+ const modelChecks = [];
741
+
742
+ if (formatsToTest.length === 0) {
743
+ warnings.push(`No supported format detected for endpoint ${endpoint}.`);
744
+ }
745
+ emitProgress({
746
+ phase: "endpoint-formats",
747
+ endpoint,
748
+ endpointIndex: endpointIndex + 1,
749
+ endpointCount: endpoints.length,
750
+ formatsToTest
751
+ });
752
+
753
+ for (const format of formatsToTest) {
754
+ const workingModels = [];
755
+ modelsByFormat[format] = workingModels;
756
+ const preferredAuthType = endpointProbe.authByFormat?.[format]?.type;
757
+
758
+ emitProgress({
759
+ phase: "format-start",
760
+ endpoint,
761
+ format,
762
+ endpointIndex: endpointIndex + 1,
763
+ endpointCount: endpoints.length,
764
+ modelCount: models.length
765
+ });
766
+
767
+ for (const modelId of models) {
768
+ const check = await probeModelForFormat({
769
+ baseUrl: endpoint,
770
+ format,
771
+ apiKey,
772
+ modelId,
773
+ timeoutMs,
774
+ extraHeaders,
775
+ preferredAuthType
776
+ });
777
+ modelChecks.push({
778
+ endpoint,
779
+ format,
780
+ model: modelId,
781
+ supported: check.supported,
782
+ confirmed: check.confirmed,
783
+ outcome: check.outcome,
784
+ status: check.status,
785
+ authType: check.authType,
786
+ message: check.message,
787
+ error: check.error
788
+ });
789
+ completedChecks += 1;
790
+ emitProgress({
791
+ phase: "model-check",
792
+ endpoint,
793
+ format,
794
+ model: modelId,
795
+ supported: check.supported,
796
+ confirmed: check.confirmed,
797
+ outcome: check.outcome,
798
+ status: check.status,
799
+ message: check.message,
800
+ error: check.error,
801
+ completedChecks,
802
+ totalChecks
803
+ });
804
+
805
+ if (!check.supported) continue;
806
+ workingModels.push(modelId);
807
+ if (!rowAuthByFormat[format] && check.authType) {
808
+ rowAuthByFormat[format] = { type: check.authType === "x-api-key" ? "x-api-key" : "bearer" };
809
+ }
810
+ if (!modelFormatsMap[modelId]) modelFormatsMap[modelId] = new Set();
811
+ modelFormatsMap[modelId].add(format);
812
+ }
813
+ }
814
+
815
+ const inferredWorkingFormats = dedupeStrings(formatsToTest.filter((format) => (modelsByFormat[format] || []).length > 0));
816
+ const inferredSupportedFormats = dedupeStrings([
817
+ ...initialSupportedFormats,
818
+ ...inferredWorkingFormats
819
+ ]);
820
+
821
+ endpointRows.push({
822
+ endpoint,
823
+ supportedFormats: inferredSupportedFormats,
824
+ workingFormats: inferredWorkingFormats,
825
+ preferredFormat: endpointProbe.preferredFormat,
826
+ authByFormat: rowAuthByFormat,
827
+ modelsByFormat,
828
+ modelChecks,
829
+ details: endpointProbe.details
830
+ });
831
+
832
+ emitProgress({
833
+ phase: "endpoint-done",
834
+ endpoint,
835
+ endpointIndex: endpointIndex + 1,
836
+ endpointCount: endpoints.length,
837
+ workingFormats: inferredWorkingFormats,
838
+ modelsByFormat
839
+ });
840
+ }
841
+
842
+ const openaiEndpoint = pickBestEndpointForFormat(endpointRows, FORMATS.OPENAI);
843
+ const claudeEndpoint = pickBestEndpointForFormat(endpointRows, FORMATS.CLAUDE);
844
+
845
+ const baseUrlByFormat = {};
846
+ if (openaiEndpoint) baseUrlByFormat[FORMATS.OPENAI] = openaiEndpoint.endpoint;
847
+ if (claudeEndpoint) baseUrlByFormat[FORMATS.CLAUDE] = claudeEndpoint.endpoint;
848
+
849
+ const authByFormat = {};
850
+ if (openaiEndpoint?.authByFormat?.[FORMATS.OPENAI]) {
851
+ authByFormat[FORMATS.OPENAI] = openaiEndpoint.authByFormat[FORMATS.OPENAI];
852
+ }
853
+ if (claudeEndpoint?.authByFormat?.[FORMATS.CLAUDE]) {
854
+ authByFormat[FORMATS.CLAUDE] = claudeEndpoint.authByFormat[FORMATS.CLAUDE];
855
+ }
856
+
857
+ const workingFormats = Object.keys(baseUrlByFormat);
858
+ const formats = dedupeStrings(endpointRows.flatMap((row) => row.supportedFormats || []));
859
+ const modelSupport = Object.fromEntries(
860
+ Object.entries(modelFormatsMap).map(([model, formatsSet]) => [model, [...formatsSet]])
861
+ );
862
+ const preferredFormat =
863
+ (workingFormats.includes(FORMATS.CLAUDE) && FORMATS.CLAUDE) ||
864
+ (workingFormats.includes(FORMATS.OPENAI) && FORMATS.OPENAI) ||
865
+ null;
866
+ const modelPreferredFormat = Object.fromEntries(
867
+ Object.entries(modelSupport)
868
+ .map(([modelId, supportedFormats]) => [
869
+ modelId,
870
+ pickPreferredFormatForModel(modelId, supportedFormats, { providerPreferredFormat: preferredFormat })
871
+ ])
872
+ .filter(([, preferred]) => Boolean(preferred))
873
+ );
874
+ const supportedModels = dedupeStrings(Object.keys(modelSupport));
875
+
876
+ if (workingFormats.length === 0) {
877
+ warnings.push("No working endpoint format detected with provided API key.");
878
+ }
879
+ if (supportedModels.length === 0) {
880
+ warnings.push("No provided model was confirmed as working on the detected endpoints.");
881
+ }
882
+
883
+ emitProgress({
884
+ phase: "matrix-done",
885
+ workingFormats,
886
+ baseUrlByFormat,
887
+ supportedModelCount: supportedModels.length
888
+ });
889
+
890
+ return {
891
+ ok: workingFormats.length > 0 && supportedModels.length > 0,
892
+ endpoints,
893
+ formats,
894
+ workingFormats,
895
+ preferredFormat,
896
+ baseUrlByFormat,
897
+ authByFormat,
898
+ auth: preferredFormat ? authByFormat[preferredFormat] || null : null,
899
+ models: supportedModels,
900
+ modelSupport,
901
+ modelPreferredFormat,
902
+ endpointMatrix: endpointRows,
903
+ warnings
904
+ };
905
+ }