dsh-gateway-provider 1.0.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.
package/lib/catalog.js ADDED
@@ -0,0 +1,367 @@
1
+ /**
2
+ * NewAPI model catalog: automatic model discovery and parameter control.
3
+ *
4
+ * The adapter never ships a static model list. Instead it interrogates the
5
+ * gateway itself:
6
+ *
7
+ * 1. `GET {base}/v1/models` (OpenAI-compatible, same key as chat) — every
8
+ * entry advertises `supported_endpoint_types` (e.g. `["openai",
9
+ * "anthropic", "gemini"]`), which is exactly the per-model request-format
10
+ * metadata used to auto-construct the request URL later. When the gateway
11
+ * does not expose this route, falls back to the management API
12
+ * `GET /api/user/models` (flat id list).
13
+ * 2. Each discovered model is enriched with models.dev parameters (context
14
+ * window, output cap, reasoning, family) via {@link fetchModelsDev}.
15
+ *
16
+ * Results are cached per base URL for `catalogTtlMs`; refresh is lazy (on
17
+ * the next read after expiry). Non-chat models (image / speech / embedding /
18
+ * rerank / …) are excluded from the picker by default but remain resolvable
19
+ * if explicitly requested.
20
+ *
21
+ * @module dsh-gateway-provider/catalog
22
+ */
23
+
24
+ import { LlmError } from "@deepseek-ai/dsh-llm";
25
+ import { attributionHeaders } from "@deepseek-ai/dsh-llm";
26
+ import { extractModelsDevParams, fetchModelsDev, matchModelsDev } from "./modelsdev.js";
27
+ import { pickModelApi, endpointTypeOfApi, effectiveEndpointTypes } from "./protocols.js";
28
+ import { baseModelId, variantLabel } from "./thinking.js";
29
+
30
+ /** Endpoint types that indicate a chat-capable model on the gateway. */
31
+ const CHAT_ENDPOINTS = new Set(["openai", "openai-response", "anthropic", "gemini"]);
32
+ /** Default picker exclusions: non-chat model families exposed by newapi gateways. */
33
+ export const DEFAULT_EXCLUDE_PATTERNS = [
34
+ "(^|/|-)image",
35
+ "(^|/|-)speech",
36
+ "(^|/|-)audio",
37
+ "(^|/|-)video",
38
+ "(^|/|-)embed",
39
+ "(^|/|-)rerank",
40
+ "(^|/)bge-",
41
+ "(^|/)text-embedding",
42
+ "(^|/|-)moderation",
43
+ "(^|/|-)tts",
44
+ "(^|/|-)stt",
45
+ "(^|/|-)whisper",
46
+ "nano-banana",
47
+ "dall-e",
48
+ ];
49
+
50
+ const DEFAULT_CATALOG_TTL_MS = 30 * 60 * 1000;
51
+
52
+ /** Pricing absence for models the catalog does not rate (harness never reads cost). */
53
+ export const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
54
+ /** Fallback output-token cap for models without models.dev data. */
55
+ export const DEFAULT_MAX_TOKENS = 32768;
56
+ /** Fallback context window for models without models.dev data. */
57
+ export const DEFAULT_CONTEXT_WINDOW = 128000;
58
+
59
+ /** One cache slot per base URL. */
60
+ const slots = new Map();
61
+
62
+ function slotFor(baseURL) {
63
+ let slot = slots.get(baseURL);
64
+ if (slot === undefined) {
65
+ slot = { at: 0, entries: undefined, error: undefined };
66
+ slots.set(baseURL, slot);
67
+ }
68
+ return slot;
69
+ }
70
+
71
+ /** Normalize a bare endpoint-type list; `undefined` when the gateway disclosed none. */
72
+ function endpointTypesOf(model) {
73
+ const types = model.supported_endpoint_types ?? model.supportedEndpoints;
74
+ if (!Array.isArray(types)) return undefined;
75
+ const clean = types.filter((t) => typeof t === "string" && t.length > 0);
76
+ return clean.length > 0 ? clean : undefined;
77
+ }
78
+
79
+ /** Fetch the OpenAI-compatible model list. */
80
+ async function fetchV1Models(baseURL, apiKey, signal) {
81
+ const res = await fetch(`${baseURL}/v1/models`, {
82
+ headers: { authorization: `Bearer ${apiKey}`, ...attributionHeaders() },
83
+ signal,
84
+ });
85
+ if (!res.ok) {
86
+ throw new LlmError(`newapi model list failed (HTTP ${res.status})`, res.status === 401 || res.status === 403 ? "AUTH" : `HTTP_${res.status}`, { status: res.status });
87
+ }
88
+ const body = await res.json();
89
+ const data = Array.isArray(body?.data) ? body.data : [];
90
+ const entries = [];
91
+ for (const model of data) {
92
+ if (model === null || typeof model !== "object" || typeof model.id !== "string" || model.id.length === 0) continue;
93
+ entries.push({
94
+ id: model.id,
95
+ ownedBy: typeof model.owned_by === "string" ? model.owned_by : undefined,
96
+ endpointTypes: endpointTypesOf(model),
97
+ });
98
+ }
99
+ return entries;
100
+ }
101
+
102
+ /** Fetch the management-API model list (flat id list; requires an access token). */
103
+ async function fetchManagementModels(baseURL, apiKey, userId, signal) {
104
+ const res = await fetch(`${baseURL}/api/user/models`, {
105
+ headers: {
106
+ authorization: `Bearer ${apiKey}`,
107
+ "new-api-user": String(userId ?? "1"),
108
+ ...attributionHeaders(),
109
+ },
110
+ signal,
111
+ });
112
+ if (!res.ok) {
113
+ throw new LlmError(`newapi management model list failed (HTTP ${res.status})`, res.status === 401 || res.status === 403 ? "AUTH" : `HTTP_${res.status}`, { status: res.status });
114
+ }
115
+ const body = await res.json();
116
+ const data = Array.isArray(body?.data) ? body.data : [];
117
+ const entries = [];
118
+ for (const id of data) {
119
+ if (typeof id !== "string" || id.length === 0) continue;
120
+ entries.push({ id, ownedBy: undefined, endpointTypes: undefined });
121
+ }
122
+ return entries;
123
+ }
124
+
125
+ /**
126
+ * The base URL model discovery fetches `/v1/models` against. Gateways with
127
+ * protocol URL fields (custom template) derive it from their OpenAI-style
128
+ * URL; `null` means discovery is impossible (e.g. only an Anthropic address
129
+ * is configured) and the catalog is exactly the user-declared models.
130
+ */
131
+ function discoveryBase(connection) {
132
+ const base = connection.catalogBase !== undefined ? connection.catalogBase : connection.baseURL;
133
+ return typeof base === "string" && base.length > 0 ? base : null;
134
+ }
135
+
136
+ /**
137
+ * Discover the raw model list from the gateway.
138
+ * `auto` mode prefers the OpenAI-compatible route and falls back to the
139
+ * management route; explicit modes fail loud instead of falling back.
140
+ * Returns [] without any network call when no discovery base exists.
141
+ */
142
+ async function fetchNewapiModels(connection, apiKey, signal) {
143
+ const base = discoveryBase(connection);
144
+ if (base === null) return [];
145
+ const mode = connection.catalogMode ?? "auto";
146
+ if (mode === "v1" || mode === "auto") {
147
+ try {
148
+ return await fetchV1Models(base, apiKey, signal);
149
+ } catch (error) {
150
+ if (mode === "v1" || mode === "auto" && (error?.failure?.code ?? error?.code) !== "AUTH") throw error;
151
+ // AUTH on /v1/models (e.g. an access token that only the management API accepts): fall through.
152
+ }
153
+ }
154
+ return fetchManagementModels(base, apiKey, connection.userId, signal);
155
+ }
156
+
157
+ /** Apply the picker filter (chat-only + exclude patterns). */
158
+ function filterEntries(entries, connection) {
159
+ const patterns = (connection.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS).map((p) => {
160
+ try {
161
+ return new RegExp(p);
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ }).filter((r) => r !== undefined);
166
+ return entries.filter((entry) => {
167
+ const types = entry.endpointTypes;
168
+ if (connection.includeChatOnly !== false && types !== undefined && !types.some((t) => CHAT_ENDPOINTS.has(t))) return false;
169
+ if (patterns.some((re) => re.test(entry.id))) return false;
170
+ return true;
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Build the enriched catalog entry for one raw model.
176
+ * @returns a detached catalog entry with merged models.dev parameters.
177
+ */
178
+ function enrichEntry(raw, modelsDev, connection) {
179
+ const params = extractModelsDevParams(matchModelsDev(modelsDev, baseModelId(raw.id)));
180
+ const variant = variantLabel(raw.id);
181
+ // Variants inherit the base model's name but are told apart by their tag
182
+ // (e.g. "GLM-5.2 Highspeed"); the base id itself is never collapsed, so
183
+ // request routing stays on the exact gateway-advertised id.
184
+ const baseName = params.name ?? raw.id;
185
+ const name = variant.length === 0 ? baseName : `${baseName} ${variant}`;
186
+ const reasoning = params.reasoning ?? false;
187
+ return {
188
+ id: raw.id,
189
+ ownedBy: raw.ownedBy,
190
+ endpointTypes: raw.endpointTypes,
191
+ name,
192
+ description: params.description,
193
+ family: params.family,
194
+ reasoning,
195
+ // A reasoning model exposes the standard off/low/medium/high levels; the
196
+ // adapter's thinking helper refines this per-family when known.
197
+ ...reasoning ? { thinkingLevelMap: { off: null, low: "low", medium: "medium", high: "high" } } : {},
198
+ input: normalizeInputModalities(params.input),
199
+ contextWindow: params.contextWindow ?? connection.defaultContextWindow,
200
+ maxTokens: params.maxTokens ?? connection.maxTokens,
201
+ releaseDate: params.releaseDate,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Normalize a raw models.dev input-modality list into the harness-compatible
207
+ * set. Always includes "text"; maps unknown formats to "text" only. Gateway
208
+ * models without a models.dev hit default to ["text"].
209
+ */
210
+ function normalizeInputModalities(raw) {
211
+ if (!Array.isArray(raw) || raw.length === 0) return ["text"];
212
+ const set = new Set(["text"]);
213
+ for (const m of raw) {
214
+ const lower = String(m).toLowerCase();
215
+ if (lower === "image" || lower === "vision") set.add("image");
216
+ }
217
+ return [...set];
218
+ }
219
+
220
+ /**
221
+ * Read the (cached, lazily refreshed) enriched catalog for one connection.
222
+ * @returns the full enriched entry list (before picker filtering), or an
223
+ * empty array when discovery fails and no snapshot exists.
224
+ */
225
+ export async function getCatalog(connection, apiKey, signal) {
226
+ const base = discoveryBase(connection);
227
+ if (base === null) return []; // no discovery endpoint: declared models only
228
+ const slot = slotFor(base);
229
+ const now = Date.now();
230
+ const ttl = connection.catalogTtlMs ?? DEFAULT_CATALOG_TTL_MS;
231
+ if (slot.entries !== undefined && now - slot.at < ttl) return slot.entries;
232
+ try {
233
+ const raw = await fetchNewapiModels(connection, apiKey, signal);
234
+ const modelsDev = connection.useModelsDev === false ? undefined : await fetchModelsDev(connection.modelsUrl, signal);
235
+ const entries = raw.map((m) => enrichEntry(m, modelsDev, connection));
236
+ slot.entries = entries;
237
+ slot.at = Date.now();
238
+ slot.error = undefined;
239
+ return entries;
240
+ } catch (error) {
241
+ if (slot.entries !== undefined) return slot.entries; // stale-but-usable snapshot
242
+ slot.error = error;
243
+ throw error;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Sort the picker list newest-first by release date. Entries without a known
249
+ * release date sort first (treated as the newest), then by name for stable
250
+ * ordering. Returns a new array; the input is not mutated.
251
+ */
252
+ function sortByRelease(entries) {
253
+ return [...entries].sort((a, b) => {
254
+ const da = a.releaseDate ?? "";
255
+ const db = b.releaseDate ?? "";
256
+ if (da === db) return (a.name ?? a.id).localeCompare(b.name ?? b.id) || a.id.localeCompare(b.id);
257
+ if (da.length === 0) return -1; // unknown → newest
258
+ if (db.length === 0) return 1;
259
+ return db.localeCompare(da); // YYYY-MM-DD, descending
260
+ });
261
+ }
262
+
263
+ /** The picker-facing model list (enriched + filtered + sorted + overridden). */
264
+ export async function listPickerModels(connection, apiKey, signal) {
265
+ const entries = await getCatalog(connection, apiKey, signal);
266
+ const overridden = applyModelConfig(mergeCustomModels(entries, connection.modelOverrides), connection.modelOverrides);
267
+ // Protocol availability: with URL-addressed gateways (custom template) only
268
+ // the configured protocols are servable. An explicit per-model protocol
269
+ // override pins the types first, mirroring the adapter's provider build —
270
+ // so the picker never lists a model requests would drop.
271
+ const pinnedTypes = (id) => {
272
+ const p = connection.modelOverrides?.[id]?.protocol;
273
+ return typeof p === "string" && p.length > 0 ? [p] : Array.isArray(p) ? p : undefined;
274
+ };
275
+ const served = overridden.filter((e) => effectiveEndpointTypes(pinnedTypes(e.id) ?? e.endpointTypes, connection.availableTypes) !== null);
276
+ const filtered = filterEntries(served, connection);
277
+ return connection.sortModelsByRelease === false ? filtered : sortByRelease(filtered);
278
+ }
279
+
280
+ /**
281
+ * Apply per-model overrides: drop disabled models, override name/context/
282
+ * maxTokens. Returns a new array; the input is not mutated.
283
+ */
284
+ function applyModelConfig(entries, overrides) {
285
+ if (overrides === undefined || Object.keys(overrides).length === 0) return entries;
286
+ return entries
287
+ .filter((e) => overrides[e.id]?.disabled !== true)
288
+ .map((e) => {
289
+ const ov = overrides[e.id];
290
+ if (ov === undefined) return e;
291
+ return {
292
+ ...e,
293
+ ...typeof ov.name === "string" && ov.name.length > 0 ? { name: ov.name } : {},
294
+ ...Number.isFinite(ov.contextWindow) && ov.contextWindow > 0 ? { contextWindow: ov.contextWindow } : {},
295
+ ...Number.isFinite(ov.maxTokens) && ov.maxTokens > 0 ? { maxTokens: ov.maxTokens } : {},
296
+ };
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Merge custom models (declared in config but absent from the gateway list)
302
+ * into the entry list so they appear in the picker. Custom models inherit no
303
+ * endpoint types, so they default to the OpenAI protocol unless overridden.
304
+ */
305
+ function mergeCustomModels(entries, overrides) {
306
+ if (overrides === undefined) return entries;
307
+ const present = new Set(entries.map((e) => e.id));
308
+ const merged = [...entries];
309
+ for (const [id, ov] of Object.entries(overrides)) {
310
+ if (present.has(id)) continue; // override of an existing model, not a custom add
311
+ if (ov === undefined || ov.disabled === true) continue;
312
+ merged.push({
313
+ id,
314
+ ownedBy: undefined,
315
+ endpointTypes: undefined,
316
+ name: ov.name ?? id,
317
+ description: undefined,
318
+ family: undefined,
319
+ reasoning: undefined,
320
+ contextWindow: ov.contextWindow,
321
+ maxTokens: ov.maxTokens,
322
+ releaseDate: "",
323
+ });
324
+ }
325
+ return merged;
326
+ }
327
+
328
+ /**
329
+ * Discover the raw model list from one gateway, returned in the harness
330
+ * `LlmDiscoveredModel` shape for the web UI's "Fetch available models".
331
+ * @returns id/name/contextWindow/maxTokens/protocol/reasoning per model, where
332
+ * protocol is the endpoint type auto-pickModelApi would select (informational
333
+ * for the settings UI) and reasoning flags models.dev reasoning support.
334
+ */
335
+ export async function discoverGatewayModels(connection, apiKey, signal) {
336
+ const raw = await fetchNewapiModels(connection, apiKey, signal);
337
+ const modelsDev = connection.useModelsDev === false ? undefined : await fetchModelsDev(connection.modelsUrl, signal);
338
+ // Reuse enrichEntry so the discovery surface shows the same names as the
339
+ // picker — including variant tags (e.g. "GLM-5.2 Highspeed") that a bare
340
+ // models.dev lookup would collapse to the base name.
341
+ return raw
342
+ .filter((m) => effectiveEndpointTypes(endpointTypesOf(m), connection.availableTypes) !== null)
343
+ .map((m) => {
344
+ const entry = enrichEntry(m, modelsDev, connection);
345
+ return {
346
+ id: entry.id,
347
+ name: entry.name,
348
+ contextWindow: entry.contextWindow,
349
+ maxTokens: entry.maxTokens,
350
+ protocol: endpointTypeOfApi(pickModelApi(effectiveEndpointTypes(entry.endpointTypes, connection.availableTypes), connection.endpointPriority)),
351
+ reasoning: entry.reasoning === true,
352
+ };
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Look up one exact model in the catalog. The result is advisory: requests
358
+ * still go through for unlisted ids (with configured defaults).
359
+ */
360
+ export async function lookupCatalogEntry(connection, apiKey, model, signal) {
361
+ try {
362
+ const entries = await getCatalog(connection, apiKey, signal);
363
+ return entries.find((entry) => entry.id === model);
364
+ } catch {
365
+ return undefined;
366
+ }
367
+ }