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/LICENSE +21 -0
- package/README.md +234 -0
- package/README.zh.md +218 -0
- package/cordis.patch.yml +10 -0
- package/index.js +398 -0
- package/lib/adapter.js +222 -0
- package/lib/catalog.js +367 -0
- package/lib/client.js +1164 -0
- package/lib/modelsdev.js +113 -0
- package/lib/pi-bridge.js +481 -0
- package/lib/pi-provider.js +148 -0
- package/lib/protocols.js +150 -0
- package/lib/thinking.js +219 -0
- package/package.json +64 -0
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
|
+
}
|