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/index.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway model provider plugin for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Registers one or more `gateway:*` provider routes on `ctx.llm`, each backed
|
|
5
|
+
* by an OpenAI-compatible `/v1/models` gateway (newapi, LiteLLM, Higress, or
|
|
6
|
+
* any generic gateway), or by a fully-custom gateway whose per-protocol
|
|
7
|
+
* endpoint URLs (`openaiURL` / `responsesURL` / `anthropicURL`) are written
|
|
8
|
+
* out in full. Backwards-compatible with the original single-connection
|
|
9
|
+
* `newapi` route: the legacy flat fields (`baseURL`, `apiKeyEnv`, …) build a
|
|
10
|
+
* default gateway, while the `gateways` array adds more.
|
|
11
|
+
*
|
|
12
|
+
* Per gateway:
|
|
13
|
+
* - auto-discovers the model list (`/v1/models`, falling back to the
|
|
14
|
+
* management `/api/user/models`), including each model's
|
|
15
|
+
* `supported_endpoint_types`;
|
|
16
|
+
* - enriches every model with models.dev parameters (context window, output
|
|
17
|
+
* cap, reasoning, family, release date, input modalities);
|
|
18
|
+
* - picks each model's wire protocol from its advertised endpoint types (or an
|
|
19
|
+
* explicit per-model override) and dispatches through the pi-ai SDK's
|
|
20
|
+
* protocol layer (openai-completions / openai-responses / anthropic-messages
|
|
21
|
+
* / google-generative-ai).
|
|
22
|
+
*
|
|
23
|
+
* Per model (in the gateway config) the user can: disable it (hide from
|
|
24
|
+
* picker), override its context/maxTokens/reasoning, force a protocol, or add a
|
|
25
|
+
* custom model the gateway does not advertise.
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-gateway-provider
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import z from "@deepseek-ai/schemastery";
|
|
31
|
+
import { LlmError, RetryPolicySchema, assertUsableApiKey, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
32
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
33
|
+
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
34
|
+
import { deepEqualJson, installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
35
|
+
import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
|
|
36
|
+
import { NewapiAdapter } from "./lib/adapter.js";
|
|
37
|
+
import { DEFAULT_EXCLUDE_PATTERNS, DEFAULT_MAX_TOKENS, DEFAULT_CONTEXT_WINDOW } from "./lib/catalog.js";
|
|
38
|
+
import { DEFAULT_ENDPOINT_PRIORITY, deriveProtocolURLs } from "./lib/protocols.js";
|
|
39
|
+
|
|
40
|
+
export const name = "llm-newapi";
|
|
41
|
+
export const inject = ["llm"];
|
|
42
|
+
|
|
43
|
+
/** User-settings namespace whose section overrides this entry. */
|
|
44
|
+
const NS = settingsNamespace("llm-newapi");
|
|
45
|
+
/** The legacy single provider route (kept for backwards compatibility). */
|
|
46
|
+
export const PROVIDER = "newapi";
|
|
47
|
+
/** Prefix for additional gateway routes. */
|
|
48
|
+
export const GATEWAY_PREFIX = "gateway:";
|
|
49
|
+
|
|
50
|
+
const DEFAULT_API_KEY_ENV = "NEWAPI_API_KEY";
|
|
51
|
+
const DEFAULT_BASE_URL_ENV = "NEWAPI_BASE_URL";
|
|
52
|
+
const ALT_BASE_URL_ENV = "NEWAPI_API_URL";
|
|
53
|
+
/** Public newapi.ai cloud gateway; a self-hosted instance overrides it. */
|
|
54
|
+
export const PUBLIC_BASE_URL = "https://api.newapi.ai";
|
|
55
|
+
export const DEFAULT_MODELS_URL = "https://models.dev/models.json";
|
|
56
|
+
|
|
57
|
+
// Idle cap on waiting for the next stream chunk: a gateway that stays silent
|
|
58
|
+
// for this long (long reasoning / buffered thinking phases) is treated as hung.
|
|
59
|
+
// 10 minutes is generous enough for very long streaming responses.
|
|
60
|
+
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 600_000;
|
|
61
|
+
const DEFAULT_CATALOG_TTL_MS = 30 * 60 * 1000;
|
|
62
|
+
|
|
63
|
+
/** Schema for one model-level override on a gateway (all fields optional). */
|
|
64
|
+
const ModelOverrideSchema = z.object({
|
|
65
|
+
/** Model id exactly as the gateway accepts it. */
|
|
66
|
+
id: z.string(),
|
|
67
|
+
/** Display name override. */
|
|
68
|
+
name: z.string(),
|
|
69
|
+
/** Hide this model from the picker. */
|
|
70
|
+
disabled: z.boolean(),
|
|
71
|
+
/** Force the wire protocol for this model (openai/anthropic/gemini/…). */
|
|
72
|
+
protocol: z.union(["openai","openai-response","anthropic","gemini"]),
|
|
73
|
+
/** Override the context window. */
|
|
74
|
+
contextWindow: z.number().step(1).min(1),
|
|
75
|
+
/** Override the output-token cap. */
|
|
76
|
+
maxTokens: z.number().step(1).min(1),
|
|
77
|
+
/** Reasoning level set (e.g. ["off","low","medium","high"]). */
|
|
78
|
+
reasoningLevels: z.array(z.string()),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** Gateway-type templates the settings UI offers (informational labels). */
|
|
82
|
+
const FLAVORS = ["newapi", "litellm", "higress", "openai-compatible", "custom"];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Schema for one gateway in the `gateways` array (id required; either
|
|
86
|
+
* `baseURL` or at least one protocol URL must be set — the rest falls back
|
|
87
|
+
* to the shared defaults at resolution time).
|
|
88
|
+
*/
|
|
89
|
+
const GatewaySchema = z.object({
|
|
90
|
+
/** Short stable id; becomes the provider route suffix (`gateway:<id>`). */
|
|
91
|
+
id: z.string().required(),
|
|
92
|
+
/** Human-readable gateway name. */
|
|
93
|
+
label: z.string(),
|
|
94
|
+
/** Gateway base URL (unused when protocol URL fields are set). */
|
|
95
|
+
baseURL: z.string(),
|
|
96
|
+
/** Full chat-completions endpoint URL (custom template). */
|
|
97
|
+
openaiURL: z.string(),
|
|
98
|
+
/** Full Responses endpoint URL (custom template). */
|
|
99
|
+
responsesURL: z.string(),
|
|
100
|
+
/** Full Anthropic messages endpoint URL (custom template). */
|
|
101
|
+
anthropicURL: z.string(),
|
|
102
|
+
/** Environment-variable name (credential ref) holding the API key. */
|
|
103
|
+
apiKeyEnv: z.string().role("credential-ref"),
|
|
104
|
+
/** Gateway type label (newapi/litellm/higress/openai-compatible/custom). */
|
|
105
|
+
flavor: z.union(FLAVORS),
|
|
106
|
+
/** Model-list source: `auto` (prefer /v1/models), `v1`, or `management`. */
|
|
107
|
+
catalogMode: z.union(["auto", "v1", "management"]),
|
|
108
|
+
/** User id sent to the management API when it is used. */
|
|
109
|
+
userId: z.string(),
|
|
110
|
+
/** Per-model overrides and custom models. */
|
|
111
|
+
models: z.array(ModelOverrideSchema),
|
|
112
|
+
/** Wire-format preference order for this gateway. */
|
|
113
|
+
endpointPriority: z.array(z.string()),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const Config = z.object({
|
|
117
|
+
// ---- Legacy single-connection fields (build the default `newapi` route) ----
|
|
118
|
+
/** Display name of the default gateway route (defaults to "NewAPI"). */
|
|
119
|
+
label: z.string(),
|
|
120
|
+
/** Environment-variable name (credential ref) holding the default gateway API key. */
|
|
121
|
+
apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
|
|
122
|
+
/** Default gateway base URL; resolved from NEWAPI_BASE_URL / NEWAPI_API_URL then the public cloud default. */
|
|
123
|
+
baseURL: z.string(),
|
|
124
|
+
/** Full chat-completions endpoint URL (custom template for the default gateway). */
|
|
125
|
+
openaiURL: z.string(),
|
|
126
|
+
/** Full Responses endpoint URL (custom template for the default gateway). */
|
|
127
|
+
responsesURL: z.string(),
|
|
128
|
+
/** Full Anthropic messages endpoint URL (custom template for the default gateway). */
|
|
129
|
+
anthropicURL: z.string(),
|
|
130
|
+
/** Gateway type label shown in the UI (newapi/litellm/higress/openai-compatible/custom). */
|
|
131
|
+
flavor: z.union(FLAVORS).default("newapi"),
|
|
132
|
+
/** models.dev catalog URL (any fetch-able URL; file: works for offline mirrors). */
|
|
133
|
+
modelsUrl: z.string().default(DEFAULT_MODELS_URL),
|
|
134
|
+
/** Enrich gateway models with models.dev parameters. */
|
|
135
|
+
useModelsDev: z.boolean().default(true),
|
|
136
|
+
/** Widen the unknown-model reasoning fallback to the full off~max set. */
|
|
137
|
+
extendedReasoningLevels: z.boolean().default(false),
|
|
138
|
+
/** Sort the picker newest-first by release date (unknown dates first). */
|
|
139
|
+
sortModelsByRelease: z.boolean().default(true),
|
|
140
|
+
/** Model-list source: `auto` (prefer /v1/models), `v1`, or `management`. */
|
|
141
|
+
catalogMode: z.union(["auto", "v1", "management"]).default("auto"),
|
|
142
|
+
/** Model-list cache freshness window. */
|
|
143
|
+
catalogTtlMs: z.number().step(1).min(1_000).default(DEFAULT_CATALOG_TTL_MS),
|
|
144
|
+
/** Exclude non-chat model families from the picker. */
|
|
145
|
+
includeChatOnly: z.boolean().default(true),
|
|
146
|
+
/** Regex patterns excluding models from the picker. */
|
|
147
|
+
excludePatterns: z.array(z.string()).default(DEFAULT_EXCLUDE_PATTERNS),
|
|
148
|
+
/** Wire-format preference order, intersected with each model's supported types. */
|
|
149
|
+
endpointPriority: z.array(z.string()).default(DEFAULT_ENDPOINT_PRIORITY),
|
|
150
|
+
/** User id sent to the management API when it is used. */
|
|
151
|
+
userId: z.string().default("1"),
|
|
152
|
+
/** Fallback output-token cap for models without models.dev data. */
|
|
153
|
+
maxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
|
|
154
|
+
/** Fallback context window for models without models.dev data. */
|
|
155
|
+
defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
|
|
156
|
+
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
157
|
+
retryPolicy: RetryPolicySchema,
|
|
158
|
+
/** Per-model overrides for the default (legacy) gateway. */
|
|
159
|
+
models: z.array(ModelOverrideSchema).default([]),
|
|
160
|
+
// ---- Additional gateways ----
|
|
161
|
+
/** Additional gateways; each becomes a `gateway:<id>` provider route. */
|
|
162
|
+
gateways: z.array(GatewaySchema).default([]),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Normalize one gateway entry into the connection facts the adapter needs.
|
|
167
|
+
* Per-gateway fields fall back to the shared defaults. Protocol URL fields
|
|
168
|
+
* (`openaiURL` / `responsesURL` / `anthropicURL`, the "custom" template)
|
|
169
|
+
* redefine the gateway's protocol surface: each configured URL becomes the
|
|
170
|
+
* exact SDK base for its wire protocol, unconfigured protocols are
|
|
171
|
+
* unavailable, and the plain `baseURL` is no longer inherited from the
|
|
172
|
+
* shared defaults (nothing may silently point at another gateway).
|
|
173
|
+
*/
|
|
174
|
+
function gatewayConnection(gw, defaults, provider, label) {
|
|
175
|
+
const apiKeyEnv = gw.apiKeyEnv ?? defaults.apiKeyEnv;
|
|
176
|
+
const flavor = gw.flavor ?? defaults.flavor ?? "openai-compatible";
|
|
177
|
+
const proto = deriveProtocolURLs(gw);
|
|
178
|
+
const ownBase = typeof gw.baseURL === "string" && gw.baseURL.length > 0 ? gw.baseURL.replace(/\/+$/, "") : "";
|
|
179
|
+
const baseURL = proto !== undefined ? ownBase : (ownBase || defaults.baseURL);
|
|
180
|
+
return {
|
|
181
|
+
providerId: provider,
|
|
182
|
+
displayName: label,
|
|
183
|
+
apiKeyEnv: typeof apiKeyEnv === "string" ? credentialRef(apiKeyEnv) : defaults.apiKeyEnv,
|
|
184
|
+
baseURL,
|
|
185
|
+
apiBases: proto?.apiBases,
|
|
186
|
+
availableTypes: proto?.availableTypes,
|
|
187
|
+
catalogBase: proto !== undefined ? (proto.catalogBase ?? (ownBase || null)) : baseURL,
|
|
188
|
+
flavor,
|
|
189
|
+
modelsUrl: defaults.modelsUrl,
|
|
190
|
+
useModelsDev: defaults.useModelsDev,
|
|
191
|
+
extendedReasoningLevels: defaults.extendedReasoningLevels,
|
|
192
|
+
sortModelsByRelease: defaults.sortModelsByRelease,
|
|
193
|
+
catalogMode: gw.catalogMode ?? defaults.catalogMode,
|
|
194
|
+
catalogTtlMs: defaults.catalogTtlMs,
|
|
195
|
+
includeChatOnly: defaults.includeChatOnly,
|
|
196
|
+
excludePatterns: defaults.excludePatterns,
|
|
197
|
+
endpointPriority: gw.endpointPriority ?? defaults.endpointPriority,
|
|
198
|
+
userId: gw.userId ?? defaults.userId,
|
|
199
|
+
modelOverrides: indexModelOverrides(gw.models ?? []),
|
|
200
|
+
headers: defaults.headers,
|
|
201
|
+
maxTokens: defaults.maxTokens,
|
|
202
|
+
defaultContextWindow: defaults.defaultContextWindow,
|
|
203
|
+
streamIdleTimeoutMs: defaults.streamIdleTimeoutMs,
|
|
204
|
+
retryPolicy: defaults.retryPolicy,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Index a model-override array into an id → override map (disabled excluded). */
|
|
209
|
+
function indexModelOverrides(models) {
|
|
210
|
+
const map = {};
|
|
211
|
+
if (!Array.isArray(models)) return map;
|
|
212
|
+
for (const m of models) {
|
|
213
|
+
if (m === null || typeof m !== "object" || typeof m.id !== "string" || m.id.length === 0) continue;
|
|
214
|
+
map[m.id] = m;
|
|
215
|
+
}
|
|
216
|
+
return map;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Sanitize a gateway id into a stable provider-route suffix. */
|
|
220
|
+
function routeIdOf(id) {
|
|
221
|
+
return String(id ?? "").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Build the gateway list and provider-route map from the resolved config. */
|
|
225
|
+
export function resolveGateways(config, environment) {
|
|
226
|
+
const rootProto = deriveProtocolURLs(config);
|
|
227
|
+
const explicitBase = config.baseURL
|
|
228
|
+
?? environment?.get(DEFAULT_BASE_URL_ENV)?.value
|
|
229
|
+
?? environment?.get(ALT_BASE_URL_ENV)?.value;
|
|
230
|
+
// Root protocol URLs (custom template) replace the base entirely; without
|
|
231
|
+
// them the base keeps its env / public-cloud fallback chain.
|
|
232
|
+
const baseURL = rootProto !== undefined ? (explicitBase ?? "") : (explicitBase ?? PUBLIC_BASE_URL);
|
|
233
|
+
if ((typeof baseURL !== "string" || baseURL.length === 0) && rootProto === undefined) {
|
|
234
|
+
throw new Error('llm-newapi: baseURL must be a non-empty string (set llm-newapi.baseURL in settings or export NEWAPI_BASE_URL)');
|
|
235
|
+
}
|
|
236
|
+
const defaults = {
|
|
237
|
+
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
|
|
238
|
+
baseURL: baseURL.replace(/\/+$/, ""),
|
|
239
|
+
flavor: config.flavor ?? "newapi",
|
|
240
|
+
modelsUrl: config.modelsUrl ?? DEFAULT_MODELS_URL,
|
|
241
|
+
useModelsDev: config.useModelsDev ?? true,
|
|
242
|
+
extendedReasoningLevels: config.extendedReasoningLevels ?? false,
|
|
243
|
+
sortModelsByRelease: config.sortModelsByRelease ?? true,
|
|
244
|
+
catalogMode: config.catalogMode ?? "auto",
|
|
245
|
+
catalogTtlMs: config.catalogTtlMs ?? DEFAULT_CATALOG_TTL_MS,
|
|
246
|
+
includeChatOnly: config.includeChatOnly ?? true,
|
|
247
|
+
excludePatterns: config.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS,
|
|
248
|
+
endpointPriority: config.endpointPriority ?? DEFAULT_ENDPOINT_PRIORITY,
|
|
249
|
+
userId: config.userId ?? "1",
|
|
250
|
+
headers: config.headers,
|
|
251
|
+
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
252
|
+
defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
253
|
+
streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
254
|
+
retryPolicy: resolveRetryPolicy(config.retryPolicy, "llm-newapi: retryPolicy"),
|
|
255
|
+
};
|
|
256
|
+
// The legacy `newapi` route is always the first gateway, built from flat fields.
|
|
257
|
+
const defaultLabel = config.label ?? "NewAPI";
|
|
258
|
+
const gateways = [
|
|
259
|
+
{ provider: PROVIDER, id: "default", label: defaultLabel, connection: gatewayConnection({
|
|
260
|
+
baseURL,
|
|
261
|
+
models: config.models ?? [],
|
|
262
|
+
openaiURL: config.openaiURL,
|
|
263
|
+
responsesURL: config.responsesURL,
|
|
264
|
+
anthropicURL: config.anthropicURL,
|
|
265
|
+
}, defaults, PROVIDER, defaultLabel) },
|
|
266
|
+
];
|
|
267
|
+
// Additional gateways from the array.
|
|
268
|
+
for (const gw of config.gateways ?? []) {
|
|
269
|
+
const id = routeIdOf(gw.id);
|
|
270
|
+
if (id.length === 0) continue;
|
|
271
|
+
const hasBase = typeof gw.baseURL === "string" && gw.baseURL.length > 0;
|
|
272
|
+
if (!hasBase && deriveProtocolURLs(gw) === undefined) continue;
|
|
273
|
+
const provider = `${GATEWAY_PREFIX}${id}`;
|
|
274
|
+
const label = gw.label ?? provider;
|
|
275
|
+
gateways.push({
|
|
276
|
+
provider,
|
|
277
|
+
id,
|
|
278
|
+
label,
|
|
279
|
+
connection: gatewayConnection(gw, defaults, provider, label),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return gateways;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function apply(ctx, config) {
|
|
286
|
+
let current = () => config;
|
|
287
|
+
let lastRaw;
|
|
288
|
+
let resolved = null;
|
|
289
|
+
/** Resolve (and cache) the gateway list from the current config snapshot. */
|
|
290
|
+
const resolve = () => {
|
|
291
|
+
const raw = current();
|
|
292
|
+
if (raw === lastRaw && resolved !== null) return resolved;
|
|
293
|
+
try {
|
|
294
|
+
const next = resolveGateways(raw, launchEnvironmentOf(ctx));
|
|
295
|
+
lastRaw = raw;
|
|
296
|
+
resolved = next;
|
|
297
|
+
return next;
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (resolved === null) throw error;
|
|
300
|
+
lastRaw = raw;
|
|
301
|
+
ctx.logger.error("llm-newapi: keeping the last good configuration after an invalid settings section");
|
|
302
|
+
ctx.logger.error(error);
|
|
303
|
+
return resolved;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
resolve();
|
|
307
|
+
|
|
308
|
+
/** Adapter config: resolve the connection for one provider route. */
|
|
309
|
+
const options = (provider) => {
|
|
310
|
+
const gateways = resolve();
|
|
311
|
+
const gw = gateways.find((g) => g.provider === provider);
|
|
312
|
+
return (gw ?? gateways[0]).connection;
|
|
313
|
+
};
|
|
314
|
+
const providerInfo = (provider) => {
|
|
315
|
+
const gateways = resolve();
|
|
316
|
+
const gw = gateways.find((g) => g.provider === provider) ?? gateways[0];
|
|
317
|
+
return { id: gw.provider, name: gw.label };
|
|
318
|
+
};
|
|
319
|
+
const resolveApiKey = async (connection) => {
|
|
320
|
+
const ref = connection.apiKeyEnv;
|
|
321
|
+
const credentials = ctx.get("credentials");
|
|
322
|
+
if (credentials !== undefined) {
|
|
323
|
+
const hit = await credentials.resolve(ref);
|
|
324
|
+
if (hit !== undefined) return assertUsableApiKey(hit.value, "llm-newapi", ref);
|
|
325
|
+
} else {
|
|
326
|
+
const ambient = launchEnvironmentOf(ctx).get(ref);
|
|
327
|
+
if (ambient !== undefined && ambient.value.length > 0) return assertUsableApiKey(ambient.value, "llm-newapi", ref);
|
|
328
|
+
}
|
|
329
|
+
throw new LlmError(
|
|
330
|
+
`llm-newapi: no API key for gateway credential ref "${ref}"; store it through the credentials service (the web Models page writes it), or export ${ref} in the launching environment`,
|
|
331
|
+
"MISSING_CREDENTIAL",
|
|
332
|
+
);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const adapter = new NewapiAdapter({
|
|
336
|
+
options,
|
|
337
|
+
resolveApiKey,
|
|
338
|
+
providerInfo,
|
|
339
|
+
providerCache: new Map(),
|
|
340
|
+
resolveAttachments: () => ctx.get("attachments"),
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
/** Re-register the directory + adapter whenever the gateway list changes. */
|
|
344
|
+
let directory = undefined;
|
|
345
|
+
let registration = undefined;
|
|
346
|
+
let directoryFacts = undefined;
|
|
347
|
+
const ensureRegistration = () => {
|
|
348
|
+
const gateways = resolve();
|
|
349
|
+
// Include the per-protocol bases: URL-addressed (custom) gateways may
|
|
350
|
+
// share an empty plain baseURL, and URL-only edits must re-register.
|
|
351
|
+
const facts = gateways.map((g) => `${g.provider}:${g.connection.baseURL}:${g.label}:${g.connection.apiBases ? JSON.stringify(g.connection.apiBases) : ""}`);
|
|
352
|
+
if (deepEqualJson(facts, directoryFacts)) return;
|
|
353
|
+
const entries = gateways.map((g, i) => ({
|
|
354
|
+
provider: g.provider,
|
|
355
|
+
displayName: g.label,
|
|
356
|
+
settingsNs: NS,
|
|
357
|
+
settingsPath: i === 0 ? [] : ["gateways", String(g.id)],
|
|
358
|
+
declared: i > 0,
|
|
359
|
+
}));
|
|
360
|
+
const routes = gateways.map((g) => g.provider);
|
|
361
|
+
if (directory === undefined) directory = ctx.llm.registerConfigurableProviders(entries);
|
|
362
|
+
else directory.replace(entries);
|
|
363
|
+
if (registration === undefined) registration = ctx.llm.registerAdapter(routes, adapter);
|
|
364
|
+
else registration.replace(routes);
|
|
365
|
+
directoryFacts = facts;
|
|
366
|
+
};
|
|
367
|
+
ensureRegistration();
|
|
368
|
+
|
|
369
|
+
/** Expose model discovery so the web UI can fetch a gateway's model list. */
|
|
370
|
+
ctx.llm.registerModelDiscovery(NS, async (request) => {
|
|
371
|
+
const env = launchEnvironmentOf(ctx);
|
|
372
|
+
const gateways = resolve();
|
|
373
|
+
const gw = request.provider !== undefined ? gateways.find((g) => g.provider === request.provider) : gateways[0];
|
|
374
|
+
if (gw === undefined) return [];
|
|
375
|
+
// An explicit baseURL override (the settings UI's test/fetch path) wins
|
|
376
|
+
// for discovery; empty strings count as absent.
|
|
377
|
+
const reqBase = typeof request.baseURL === "string" && request.baseURL.length > 0
|
|
378
|
+
? request.baseURL.replace(/\/+$/, "")
|
|
379
|
+
: undefined;
|
|
380
|
+
const connection = reqBase !== undefined
|
|
381
|
+
? { ...gw.connection, baseURL: reqBase, catalogBase: reqBase }
|
|
382
|
+
: gw.connection;
|
|
383
|
+
const apiKey = request.apiKey ?? await resolveApiKey(gw.connection).catch(() => undefined);
|
|
384
|
+
const { discoverGatewayModels } = await import("./lib/catalog.js");
|
|
385
|
+
try {
|
|
386
|
+
return await discoverGatewayModels(connection, apiKey, request.signal);
|
|
387
|
+
} catch {
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
installSettingsSection(ctx, NS, Config, config, {
|
|
393
|
+
setSource: (source) => {
|
|
394
|
+
current = source;
|
|
395
|
+
},
|
|
396
|
+
onChange: ensureRegistration,
|
|
397
|
+
});
|
|
398
|
+
}
|
package/lib/adapter.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NewapiAdapter: a pi-ai-backed LlmAdapter serving every model advertised by
|
|
3
|
+
* any configured gateway.
|
|
4
|
+
*
|
|
5
|
+
* Each gateway becomes a provider route (`gateway:<id>`, plus the legacy
|
|
6
|
+
* `newapi` route). For each request the adapter:
|
|
7
|
+
* 1. resolves the gateway's connection facts (baseURL, credential, catalog);
|
|
8
|
+
* 2. discovers the gateway's models and builds a pi-ai `Provider` whose
|
|
9
|
+
* `Model` entries each carry the wire protocol (`api` field) pi-ai should
|
|
10
|
+
* speak for them (openai-completions / openai-responses / anthropic-messages
|
|
11
|
+
* / google-generative-ai);
|
|
12
|
+
* 3. converts the harness request into a pi-ai `Context`;
|
|
13
|
+
* 4. streams through pi-ai's protocol layer and translates the pi-ai event
|
|
14
|
+
* stream back into harness `StreamChunk`s.
|
|
15
|
+
*
|
|
16
|
+
* The wire formats (OpenAI Chat Completions, OpenAI Responses, Anthropic
|
|
17
|
+
* Messages, Google Generative AI) are implemented entirely by the pi-ai SDK;
|
|
18
|
+
* this adapter owns only the gateway→pi-ai mapping and the harness↔pi-ai
|
|
19
|
+
* bridge.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-gateway-provider/adapter
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
LlmAdapter,
|
|
26
|
+
LlmError,
|
|
27
|
+
ReasoningEffortId,
|
|
28
|
+
contentHasImage,
|
|
29
|
+
} from "@deepseek-ai/dsh-llm";
|
|
30
|
+
import { idleWatchdog, timeoutOf } from "@deepseek-ai/dsh-timeout";
|
|
31
|
+
import { createModels, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
32
|
+
import { getCatalog, listPickerModels, lookupCatalogEntry, DEFAULT_MAX_TOKENS, DEFAULT_CONTEXT_WINDOW } from "./catalog.js";
|
|
33
|
+
import { buildModel, buildProvider, DEFAULT_ENDPOINT_PRIORITY } from "./pi-provider.js";
|
|
34
|
+
import { effectiveEndpointTypes } from "./protocols.js";
|
|
35
|
+
import { findPiModel, thinkingEffortsOf, effortsFromLevels } from "./thinking.js";
|
|
36
|
+
import { toPiContext, toStreamChunks, requestHeaders } from "./pi-bridge.js";
|
|
37
|
+
|
|
38
|
+
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
|
39
|
+
const STREAM_IDLE_TIMEOUT_CODE = "LLM_STREAM_IDLE_TIMEOUT";
|
|
40
|
+
|
|
41
|
+
/** Detached display metadata for one picker model. */
|
|
42
|
+
function modelInfo(provider, entry) {
|
|
43
|
+
return {
|
|
44
|
+
provider,
|
|
45
|
+
id: entry.id,
|
|
46
|
+
name: entry.name ?? entry.id,
|
|
47
|
+
...entry.description === undefined ? {} : { description: entry.description },
|
|
48
|
+
inputModalities: entry.input ?? ["text"],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class NewapiAdapter extends LlmAdapter {
|
|
53
|
+
constructor(config) {
|
|
54
|
+
super();
|
|
55
|
+
this.config = config;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
providerInfo(provider) {
|
|
59
|
+
return this.config.providerInfo(provider);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
providerRetryPolicy(provider) {
|
|
63
|
+
return this.config.options(provider).retryPolicy;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async listModels(provider) {
|
|
67
|
+
const connection = this.config.options(provider);
|
|
68
|
+
const apiKey = await this.config.resolveApiKey(connection);
|
|
69
|
+
const entries = await listPickerModels(connection, apiKey);
|
|
70
|
+
return entries.map((entry) => modelInfo(provider, entry));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async resolveModel(provider, model, signal) {
|
|
74
|
+
const connection = this.config.options(provider);
|
|
75
|
+
let entry;
|
|
76
|
+
try {
|
|
77
|
+
const apiKey = await this.config.resolveApiKey(connection);
|
|
78
|
+
entry = await lookupCatalogEntry(connection, apiKey, model, signal);
|
|
79
|
+
} catch {
|
|
80
|
+
entry = undefined; // catalog failures must not break exact-model resolution
|
|
81
|
+
}
|
|
82
|
+
const configured = entry;
|
|
83
|
+
const override = connection.modelOverrides?.[model];
|
|
84
|
+
// Per-model overrides (context window / output cap) must win over the
|
|
85
|
+
// models.dev-enriched catalog, mirroring buildModel + applyModelConfig. The
|
|
86
|
+
// harness reads this `context.contextWindow` for context management, so a
|
|
87
|
+
// missing override here silently compacts on the wrong window.
|
|
88
|
+
const contextWindow = override?.contextWindow ?? configured?.contextWindow ?? connection.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
89
|
+
const maxTokens = override?.maxTokens ?? configured?.maxTokens ?? connection.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
90
|
+
// An explicit per-model reasoningLevels override wins over catalog inference.
|
|
91
|
+
const overrideEfforts = effortsFromLevels(override?.reasoningLevels);
|
|
92
|
+
const reasoning = overrideEfforts.length > 0
|
|
93
|
+
? { efforts: overrideEfforts }
|
|
94
|
+
: thinkingEffortsOf(configured, findPiModel(model), { extended: connection.extendedReasoningLevels });
|
|
95
|
+
return {
|
|
96
|
+
...configured === undefined ? { provider, id: model, name: model, inputModalities: ["text"] } : modelInfo(provider, configured),
|
|
97
|
+
context: { contextWindow },
|
|
98
|
+
defaultMaxTokens: maxTokens,
|
|
99
|
+
...reasoning === undefined ? {} : { reasoning },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build (and cache) one pi-ai Provider for a gateway connection.
|
|
105
|
+
* The provider's Model list is the gateway's discovered, enriched catalog;
|
|
106
|
+
* each model's `api` field dispatches pi-ai to the right wire protocol.
|
|
107
|
+
*/
|
|
108
|
+
async piProviderOf(connection, apiKey) {
|
|
109
|
+
// Key by provider route + every base a request can hit: gateways with
|
|
110
|
+
// protocol URL fields (custom template) may share an empty plain base.
|
|
111
|
+
const cacheKey = connection.providerId + "|" + (connection.baseURL ?? "")
|
|
112
|
+
+ (connection.apiBases !== undefined ? "|" + JSON.stringify(connection.apiBases) : "");
|
|
113
|
+
const cached = this.config.providerCache.get(cacheKey);
|
|
114
|
+
if (cached !== undefined && cached.apiKey === apiKey && cached.at > Date.now() - (connection.catalogTtlMs ?? 30 * 60 * 1000)) {
|
|
115
|
+
return cached.provider;
|
|
116
|
+
}
|
|
117
|
+
const entries = await getCatalog(connection, apiKey);
|
|
118
|
+
const priority = connection.endpointPriority ?? DEFAULT_ENDPOINT_PRIORITY;
|
|
119
|
+
// An explicit per-model protocol override pins the endpoint types to that
|
|
120
|
+
// single choice, so pickModelApi can no longer drift with the priority.
|
|
121
|
+
const pinnedTypes = (ov) => Array.isArray(ov?.protocol) ? ov.protocol : (typeof ov?.protocol === "string" && ov.protocol.length > 0 ? [ov.protocol] : undefined);
|
|
122
|
+
const models = [];
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
const override = connection.modelOverrides?.[entry.id];
|
|
125
|
+
const pinned = pinnedTypes(override);
|
|
126
|
+
// URL-addressed gateways only serve their configured protocols; models
|
|
127
|
+
// that advertise none of them are dropped (unaddressable models would
|
|
128
|
+
// fail every request).
|
|
129
|
+
const types = effectiveEndpointTypes(pinned ?? entry.endpointTypes, connection.availableTypes);
|
|
130
|
+
if (types === null) continue;
|
|
131
|
+
const effective = types === entry.endpointTypes ? entry : { ...entry, endpointTypes: types };
|
|
132
|
+
models.push(buildModel(effective, connection.providerId, connection.baseURL, priority, connection, override));
|
|
133
|
+
}
|
|
134
|
+
// Apply user overrides that disable models (hide from the provider).
|
|
135
|
+
const visible = models.filter((m) => connection.modelOverrides?.[m.id]?.disabled !== true);
|
|
136
|
+
// Merge custom models declared in settings but absent from the gateway.
|
|
137
|
+
const present = new Set(visible.map((m) => m.id));
|
|
138
|
+
const custom = Object.entries(connection.modelOverrides ?? {})
|
|
139
|
+
.filter(([id, ov]) => {
|
|
140
|
+
if (present.has(id) || ov?.disabled === true) return false;
|
|
141
|
+
// A pinned custom model must also be servable on a configured protocol.
|
|
142
|
+
const pinned = pinnedTypes(ov);
|
|
143
|
+
return effectiveEndpointTypes(pinned ?? undefined, connection.availableTypes) !== null;
|
|
144
|
+
})
|
|
145
|
+
.map(([id, ov]) => {
|
|
146
|
+
const pinned = pinnedTypes(ov);
|
|
147
|
+
const types = effectiveEndpointTypes(pinned ?? undefined, connection.availableTypes);
|
|
148
|
+
return buildModel({ id, name: ov.name ?? id, endpointTypes: types }, connection.providerId, connection.baseURL, priority, connection, ov);
|
|
149
|
+
});
|
|
150
|
+
const provider = buildProvider({
|
|
151
|
+
providerId: connection.providerId,
|
|
152
|
+
displayName: connection.displayName ?? connection.providerId,
|
|
153
|
+
baseURL: connection.baseURL,
|
|
154
|
+
models: [...visible, ...custom],
|
|
155
|
+
});
|
|
156
|
+
this.config.providerCache.set(cacheKey, { provider, apiKey, at: Date.now() });
|
|
157
|
+
return provider;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async *stream(options) {
|
|
161
|
+
if (options.stop !== undefined) throw new LlmError("newapi adapter does not support GenerateOptions.stop", "UNSUPPORTED_OPTION");
|
|
162
|
+
const connection = this.config.options(options.provider);
|
|
163
|
+
const apiKey = await this.config.resolveApiKey(connection);
|
|
164
|
+
|
|
165
|
+
const consumer = new AbortController();
|
|
166
|
+
const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]);
|
|
167
|
+
const watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const provider = await this.piProviderOf(connection, apiKey);
|
|
171
|
+
const models = createModels();
|
|
172
|
+
models.setProvider(provider);
|
|
173
|
+
const model = models.getModel(connection.providerId, options.model);
|
|
174
|
+
if (model === undefined) throw new LlmError(`gateway "${connection.providerId}" has no model "${options.model}"`, "UNKNOWN_MODEL");
|
|
175
|
+
|
|
176
|
+
const containsImage = options.messages.some((message) => contentHasImage(message.content));
|
|
177
|
+
if (containsImage && !model.input.includes("image")) throw new LlmError(`gateway model "${model.id}" does not support image input`, "UNSUPPORTED_CONTENT");
|
|
178
|
+
const attachments = containsImage ? this.config.resolveAttachments?.() : undefined;
|
|
179
|
+
if (containsImage && attachments === undefined) throw new LlmError("gateway image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
|
|
180
|
+
|
|
181
|
+
const context = attachments === undefined ? toPiContext(options) : await toPiContext(options, attachments);
|
|
182
|
+
|
|
183
|
+
const reasoning = options.reasoningEffort === undefined
|
|
184
|
+
? undefined
|
|
185
|
+
: (getSupportedThinkingLevels(model).some((level) => level === options.reasoningEffort) ? options.reasoningEffort : undefined);
|
|
186
|
+
|
|
187
|
+
const piStream = models.streamSimple(model, context, {
|
|
188
|
+
apiKey,
|
|
189
|
+
...reasoning === undefined ? {} : { reasoning },
|
|
190
|
+
...options.temperature === undefined ? {} : { temperature: options.temperature },
|
|
191
|
+
...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },
|
|
192
|
+
signal: watchdog.signal,
|
|
193
|
+
headers: requestHeaders(connection.headers),
|
|
194
|
+
maxRetries: 0,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const iterator = toStreamChunks(piStream, model.contextWindow)[Symbol.asyncIterator]();
|
|
198
|
+
let exhausted = false;
|
|
199
|
+
try {
|
|
200
|
+
while (true) {
|
|
201
|
+
const result = await watchdog.next(iterator);
|
|
202
|
+
if (result.done) { exhausted = true; return; }
|
|
203
|
+
yield result.value;
|
|
204
|
+
}
|
|
205
|
+
} finally {
|
|
206
|
+
if (!exhausted) {
|
|
207
|
+
consumer.abort("gateway stream consumer stopped");
|
|
208
|
+
try { await iterator.return(undefined); } catch { /* aborted */ }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) {
|
|
213
|
+
throw new LlmError(`gateway stream idle timeout after ${connection.streamIdleTimeoutMs}ms`, "TIMEOUT", { cause: error });
|
|
214
|
+
}
|
|
215
|
+
if (options.signal?.aborted) throw new LlmError("gateway request aborted by caller", "ABORTED", { cause: error });
|
|
216
|
+
if (error instanceof LlmError) throw error;
|
|
217
|
+
throw new LlmError(`gateway API stream from ${connection.baseURL || connection.providerId} failed`, "TRANSPORT", { cause: error });
|
|
218
|
+
} finally {
|
|
219
|
+
consumer.abort("gateway stream consumer stopped");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|