dsh-newapi-provider 0.0.0-stage → 0.8.3

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/index.js ADDED
@@ -0,0 +1,687 @@
1
+ /**
2
+ * dsh-newapi-provider — Host half.
3
+ *
4
+ * New API gateways inside DeepSeek Harness:
5
+ * - several gateway "channels" (address + token); exactly one is active and
6
+ * serves the LLM provider route, the balance pill and the media tools;
7
+ * - model list from the active gateway, with context window / output cap /
8
+ * modalities from models.dev and per-model, per-channel user overrides;
9
+ * - token and account balance per channel from /api/usage/token/;
10
+ * - a session-checked JSON API under /newapi-provider/api/ that the Web UI
11
+ * half reads and writes (channel edits go through the DSH settings service,
12
+ * tokens through the DSH credential service).
13
+ * Image and video tools register from ./lib/media.js.
14
+ */
15
+ import z from '@deepseek-ai/schemastery';
16
+ import { LlmError, RetryPolicySchema, assertUsableApiKey, resolveImageAttachmentAccess, resolveRetryPolicy } from '@deepseek-ai/dsh-llm';
17
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
18
+ import { NewApiAdapter, DEFAULT_ENDPOINT_PRIORITY } from './lib/adapter.js';
19
+ import { Catalog, chatModels, classify, upstreamModelOf, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './lib/catalog.js';
20
+ import { ModelsDev } from './lib/modelsdev.js';
21
+ import { BalanceService } from './lib/balance.js';
22
+ import { registerMedia } from './lib/media.js';
23
+ import { fetchJson, fingerprint, gatewayRoot, redact } from './lib/util.js';
24
+ import { ChannelService, resolveChannels, taskChannel, registerAsyncEffect, validateBaseURL } from './lib/channels.js';
25
+ import { ModelTester } from './lib/model-test.js';
26
+ export { resolveChannels } from './lib/channels.js';
27
+
28
+ export const name = 'newapi-provider';
29
+ export const inject = ['llm'];
30
+
31
+ const ROUTE_PREFIX = '/newapi-provider/api/';
32
+ const ENDPOINT_TYPES = ['openai', 'anthropic', 'openai-response', 'gemini'];
33
+ const KINDS = ['chat', 'image', 'video', 'audio', 'embedding', 'other'];
34
+ const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
35
+ const MODALITIES = ['text', 'image', 'audio', 'video', 'pdf'];
36
+
37
+
38
+ const ModelOverride = z.object({
39
+ name: z.string().description('Display name in the model picker.'),
40
+ upstreamModel: z.string().default(undefined).description('Upstream wire model id; omitted uses the stable local model id.'),
41
+ kind: z.union(KINDS).description('Force how the model is used: chat models appear in the picker, image/video models are used by the generation tools.'),
42
+ hidden: z.boolean().description('Hide this model everywhere.'),
43
+ contextWindow: z.number().step(1).min(1).description('Context window in tokens (overrides models.dev).'),
44
+ maxTokens: z.number().step(1).min(1).description('Maximum output tokens (overrides models.dev).'),
45
+ input: z.array(z.union(MODALITIES)).default(undefined).description('Input modalities the model accepts.'),
46
+ reasoning: z.boolean().description('Whether the model supports reasoning effort.'),
47
+ reasoningLevels: z.array(z.union(LEVELS)).default(undefined).description('Reasoning effort levels to offer.'),
48
+ protocol: z.union(ENDPOINT_TYPES).description('Force the wire protocol for this model.'),
49
+ custom: z.boolean().description('Add this model even if the gateway does not list it.'),
50
+ });
51
+
52
+ const Channel = z.object({
53
+ id: z.string().required().description('Stable channel id.'),
54
+ name: z.string().description('Display name.'),
55
+ baseURL: z.string().description('Gateway address, with or without /v1.'),
56
+ apiKeyEnv: z.string().role('credential-ref').description('Credential holding this gateway\'s token (sk-…).'),
57
+ headers: z.dict(z.string()).description('Extra HTTP headers for this gateway.'),
58
+ models: z.dict(ModelOverride).description('Per-model overrides keyed by model id.'),
59
+ enabledModels: z.array(z.string()).default(undefined).description('Models enabled on this channel. Unset shows every model the gateway lists.'),
60
+ });
61
+
62
+ export const Config = z.object({
63
+ channels: z.array(Channel).default([]).description('New API gateways. Exactly one is active at a time.'),
64
+ activeChannel: z.string().description('Id of the active channel; the first channel when unset.'),
65
+ baseURL: z.string().description('Single-gateway form (used when `channels` is empty).'),
66
+ apiKeyEnv: z.string().role('credential-ref').description('Single-gateway form: credential holding the token.'),
67
+ models: z.dict(ModelOverride).description('Single-gateway form: per-model overrides.'),
68
+ providerId: z.string().default('newapi-provider').description('Provider route id in the model picker. Changing it orphans sessions that use the old id.'),
69
+ displayName: z.string().default('New API').description('Provider name shown in the model picker.'),
70
+ endpointPriority: z.array(z.union(ENDPOINT_TYPES)).default([...DEFAULT_ENDPOINT_PRIORITY]).description('Preferred wire protocol when a model supports several.'),
71
+ defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW).description('Context window used when neither models.dev nor an override sizes a model.'),
72
+ defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS).description('Output cap used when neither models.dev nor an override sizes a model.'),
73
+ useModelsDev: z.boolean().default(true).description('Fill context window, output cap and modalities from models.dev.'),
74
+ headers: z.dict(z.string()).default({}).description('Extra HTTP headers sent to every gateway.'),
75
+ streamIdleTimeoutMs: z.number().min(1000).max(2147483647).default(300_000).description('Abort a model stream that stays silent this long.'),
76
+ balanceRefreshSeconds: z.number().step(1).min(0).default(120).description('Refresh the active channel balance this often (0 = only on demand).'),
77
+ retryPolicy: RetryPolicySchema,
78
+ }).volatile();
79
+
80
+ function sendJson(res, status, value) {
81
+ res.statusCode = status;
82
+ res.setHeader('content-type', 'application/json; charset=utf-8');
83
+ res.setHeader('cache-control', 'no-store');
84
+ res.end(JSON.stringify(value));
85
+ }
86
+
87
+ async function readBody(req, limit = 64 * 1024) {
88
+ let size = 0;
89
+ const chunks = [];
90
+ for await (const chunk of req) {
91
+ size += chunk.length;
92
+ if (size > limit) throw Object.assign(new Error('request body too large'), { status: 413 });
93
+ chunks.push(chunk);
94
+ }
95
+ const text = Buffer.concat(chunks).toString('utf8');
96
+ if (!text) return {};
97
+ try { return JSON.parse(text); } catch { throw Object.assign(new Error('invalid JSON body'), { status: 400 }); }
98
+ }
99
+
100
+ export function apply(ctx, config) {
101
+ const settingsNs = ctx.fiber.entry?.options.id ?? 'newapi-provider';
102
+ const current = () => (typeof config?.get === 'function' ? config.get() : config) ?? {};
103
+ const errors = new Map();
104
+ const reportError = (scope, error) => {
105
+ errors.set(scope, { at: Date.now(), code: error?.code ?? 'ERROR', message: String(error?.message ?? error) });
106
+ };
107
+
108
+ // This plugin draws its own settings page; hide the generated form.
109
+ ctx.inject(['settings'], (sctx) => {
110
+ sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber));
111
+ });
112
+
113
+ let channelsMemo;
114
+ const channels = () => {
115
+ const raw = current();
116
+ if (channelsMemo?.raw !== raw) channelsMemo = { raw, value: resolveChannels(raw) };
117
+ return channelsMemo.value;
118
+ };
119
+
120
+ const connFor = (ch) => {
121
+ const raw = current();
122
+ const overrides = ch.models ?? {};
123
+ return {
124
+ channelId: ch.id,
125
+ channelName: ch.name,
126
+ providerId: raw.providerId || 'newapi-provider',
127
+ displayName: raw.displayName || 'New API',
128
+ baseURL: ch.baseURL,
129
+ apiKeyRef: credentialRef(ch.apiKeyEnv),
130
+ apiKeyName: ch.apiKeyEnv,
131
+ overrides,
132
+ enabledModels: Array.isArray(ch.enabledModels) ? ch.enabledModels : undefined,
133
+ overridesRev: fingerprint(`${ch.id}|${JSON.stringify(overrides)}|${JSON.stringify(ch.enabledModels ?? null)}`),
134
+ endpointPriority: raw.endpointPriority?.length ? raw.endpointPriority : DEFAULT_ENDPOINT_PRIORITY,
135
+ defaultContextWindow: raw.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
136
+ defaultMaxTokens: raw.defaultMaxTokens ?? DEFAULT_MAX_TOKENS,
137
+ headers: { ...(raw.headers ?? {}), ...(ch.headers ?? {}) },
138
+ streamIdleTimeoutMs: raw.streamIdleTimeoutMs ?? 300_000,
139
+ retryPolicy: resolveRetryPolicy(raw.retryPolicy, 'newapi-provider retryPolicy'),
140
+ balanceRefreshSeconds: raw.balanceRefreshSeconds ?? 120,
141
+ };
142
+ };
143
+
144
+ let connectionMemo;
145
+ /** Connection facts of the active channel. */
146
+ const connection = () => {
147
+ const { active } = channels();
148
+ const raw = current();
149
+ if (connectionMemo?.raw === raw && connectionMemo.id === active.id) return connectionMemo.value;
150
+ const value = connFor(active);
151
+ connectionMemo = { raw, id: active.id, value };
152
+ return value;
153
+ };
154
+
155
+ const keyFor = async (ch) => {
156
+ const credentials = ctx.get('credentials');
157
+ const ref = credentialRef(ch.apiKeyEnv);
158
+ const hit = credentials ? (await credentials.resolve(ref))?.value : process.env[ch.apiKeyEnv];
159
+ if (!hit) {
160
+ throw new LlmError(`newapi-provider: no token stored for channel "${ch.name}" (credential ${ch.apiKeyEnv}); add it in Settings → New API Provider`, 'MISSING_CREDENTIAL');
161
+ }
162
+ return assertUsableApiKey(hit, 'newapi-provider', ref);
163
+ };
164
+
165
+ const modelsDev = new ModelsDev({ enabled: current().useModelsDev !== false, logger: ctx.logger });
166
+ const catalogs = new Catalog({ modelsDev, logger: ctx.logger });
167
+ // Capability data arriving late must not leave the first catalog (built without
168
+ // it) cached for the whole TTL.
169
+ modelsDev.onUpdated = () => catalogs.invalidate();
170
+
171
+ const catalogFor = async (ch, { signal, force = false } = {}, captured = connFor(ch)) => {
172
+ const apiKey = await keyFor(ch);
173
+ const conn = { ...captured, keyFingerprint: fingerprint(apiKey) };
174
+ try {
175
+ return await catalogs.get(conn, apiKey, { signal, force });
176
+ } catch (error) {
177
+ const stale = catalogs.peek(conn);
178
+ if (stale) return stale;
179
+ throw new LlmError(redact(`newapi-provider: cannot read the model list from ${gatewayRoot(conn.baseURL)}: ${error.message}`, apiKey), error.code === 'AUTH' ? 'AUTH' : 'TRANSPORT', { cause: error });
180
+ }
181
+ };
182
+
183
+ // Capture channel, URL, headers, overrides and credential reference before any await.
184
+ const capture = (id) => {
185
+ const ch = structuredClone(id ? channels().list.find((c) => c.id === id) : channels().active);
186
+ if (!ch) throw Object.assign(new Error(`unknown channel "${id}"; list channel ids with newapi_channels`), { status: 404 });
187
+ const conn = connFor(ch);
188
+ let key;
189
+ const requireApiKey = () => key ??= keyFor(ch);
190
+ return {
191
+ connection: conn, requireApiKey,
192
+ catalog: async (opts) => catalogs.get({ ...conn, keyFingerprint: fingerprint(await requireApiKey()) }, await requireApiKey(), opts),
193
+ };
194
+ };
195
+ const adapter = new NewApiAdapter({
196
+ connection,
197
+ capture,
198
+ reportError,
199
+ logger: ctx.logger,
200
+ piAuth: undefined,
201
+ resolveAttachments: () => ctx.get('attachments'),
202
+ resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (hostPath) => ctx.get('fs')?.processPathFromHostPath(hostPath), ref),
203
+ });
204
+
205
+ // Provider route registration. A route still held by another adapter is
206
+ // retried whenever the provider topology changes, instead of failing.
207
+ let registration;
208
+ let registeredRoute;
209
+ let directory;
210
+ let directoryKey;
211
+ const routeState = { status: 'pending', route: null, error: null };
212
+ const ensureRegistration = () => {
213
+ const conn = connection();
214
+ const entries = [{
215
+ provider: conn.providerId,
216
+ displayName: conn.displayName,
217
+ settingsNs,
218
+ settingsPath: [],
219
+ declared: true,
220
+ defaultBaseURL: gatewayRoot(conn.baseURL),
221
+ }];
222
+ const key = JSON.stringify(entries);
223
+ try {
224
+ if (directoryKey !== key) {
225
+ if (directory) directory.replace(entries); else directory = ctx.llm.registerConfigurableProviders(entries);
226
+ directoryKey = key;
227
+ }
228
+ } catch (error) {
229
+ routeState.error = `directory: ${error.message}`;
230
+ }
231
+ if (registeredRoute === conn.providerId) return;
232
+ try {
233
+ if (registration) registration.replace([conn.providerId]); else registration = ctx.llm.registerAdapter([conn.providerId], adapter);
234
+ registeredRoute = conn.providerId;
235
+ Object.assign(routeState, { status: 'registered', route: conn.providerId, error: null });
236
+ } catch (error) {
237
+ Object.assign(routeState, { status: 'waiting', route: conn.providerId, error: String(error?.message ?? error) });
238
+ ctx.logger.warn(`newapi-provider: provider route "${conn.providerId}" is not available yet (${error.code ?? error.message}); will retry when it is released`);
239
+ }
240
+ };
241
+ ensureRegistration();
242
+ ctx.on('llm/adapters-updated', () => {
243
+ if (registeredRoute !== connection().providerId) ensureRegistration();
244
+ });
245
+ ctx.llm.registerModelDiscovery(settingsNs, async (request, signal) => {
246
+ const ch = channels().active;
247
+ const saved = connFor(ch);
248
+ const draftRoot = typeof request.baseURL === 'string' && request.baseURL.trim()
249
+ ? validateBaseURL(request.baseURL)
250
+ : undefined;
251
+ const isDraft = draftRoot !== undefined && draftRoot !== gatewayRoot(ch.baseURL);
252
+ if (isDraft && !request.apiKey) throw new Error('a draft gateway requires its own token');
253
+ const apiKey = request.apiKey || await keyFor(ch);
254
+ // A draft address is a different host: never forward this channel's headers,
255
+ // and never cache the draft under the saved channel's identity.
256
+ const conn = isDraft
257
+ ? {
258
+ ...saved,
259
+ channelId: `${ch.id}#draft`,
260
+ baseURL: draftRoot,
261
+ headers: { ...(current().headers ?? {}) },
262
+ overrides: {},
263
+ enabledModels: undefined,
264
+ overridesRev: fingerprint(`${ch.id}|draft|${draftRoot}`),
265
+ keyFingerprint: fingerprint(apiKey),
266
+ }
267
+ : { ...saved, keyFingerprint: fingerprint(apiKey) };
268
+ const value = await catalogs.get(conn, apiKey, { signal, force: true });
269
+ return chatModels(value).map((e) => ({
270
+ id: e.id,
271
+ name: e.name,
272
+ ...(e.contextWindow ? { contextWindow: e.contextWindow } : {}),
273
+ ...(e.maxTokens ? { maxTokens: e.maxTokens } : {}),
274
+ inputModalities: e.input.filter((m) => m === 'text' || m === 'image'),
275
+ }));
276
+ });
277
+
278
+ // One balance service per channel, created on demand.
279
+ const balances = new Map();
280
+ const balanceOf = (id) => {
281
+ let svc = balances.get(id);
282
+ if (!svc) {
283
+ const ch = structuredClone(channels().list.find((c) => c.id === id));
284
+ if (!ch) throw new Error('channel removed');
285
+ const conn = connFor(ch);
286
+ svc = new BalanceService({ capture: () => ({ connection: conn, requireApiKey: () => keyFor(ch) }), logger: ctx.logger });
287
+ balances.set(id, svc);
288
+ }
289
+ return svc;
290
+ };
291
+ const balance = { get state() { return balanceOf(channels().active.id).state; }, refresh: (o) => balanceOf(channels().active.id).refresh(o) };
292
+
293
+ let lastActive = channels().active.id;
294
+ ctx.on('loader/volatile-update', () => {
295
+ catalogs.invalidate();
296
+ modelsDev.enabled = current().useModelsDev !== false;
297
+ // Old services may finish, but are no longer observable after an edit/rotation.
298
+ balances.clear();
299
+ try { ensureRegistration(); } catch (error) { ctx.logger.error(error); }
300
+ const now = channels().active.id;
301
+ if (now !== lastActive) { lastActive = now; errors.clear(); }
302
+ void balance.refresh({ force: true });
303
+ });
304
+ void balance.refresh();
305
+ void modelsDev.refresh();
306
+ ctx.effect(() => {
307
+ let timer;
308
+ let stopped = false;
309
+ const schedule = () => {
310
+ if (stopped) return;
311
+ const seconds = connection().balanceRefreshSeconds;
312
+ if (!(seconds > 0)) return;
313
+ timer = setTimeout(() => {
314
+ balance.refresh().catch(() => {}).finally(schedule);
315
+ }, Math.max(15, seconds) * 1000);
316
+ };
317
+ schedule();
318
+ return () => { stopped = true; clearTimeout(timer); };
319
+ });
320
+
321
+ const media = registerMedia(ctx, {
322
+ capture, reportError,
323
+ captureTask: (task) => {
324
+ const ch = taskChannel(task, channels().list);
325
+ return { connection: connFor(ch), requireApiKey: () => keyFor(ch) };
326
+ },
327
+ });
328
+
329
+ // ------------------------------------------------------------ model list & tests
330
+
331
+ const channelById = (id) => {
332
+ const ch = channels().list.find((c) => c.id === id);
333
+ if (!ch) throw Object.assign(new Error('unknown channel'), { status: 404 });
334
+ return ch;
335
+ };
336
+
337
+ /** Gateway catalog for one channel, ignoring its saved list (what the gateway offers). */
338
+ const gatewayCatalog = async (ch, opts = {}) => {
339
+ const snapshotCh = structuredClone(ch);
340
+ const conn = { ...connFor(snapshotCh), enabledModels: undefined, channelId: `${snapshotCh.id}#gateway` };
341
+ const apiKey = await keyFor(snapshotCh);
342
+ return catalogs.get({ ...conn, keyFingerprint: fingerprint(apiKey) }, apiKey, opts);
343
+ };
344
+
345
+ const tester = new ModelTester({
346
+ channelFacts: async (channelId) => {
347
+ const bound = capture(channelId);
348
+ const conn = bound.connection;
349
+ return {
350
+ baseURL: conn.baseURL, headers: conn.headers, endpointPriority: conn.endpointPriority,
351
+ apiKey: bound.requireApiKey,
352
+ entryFor: async (modelId) => (await bound.catalog()).entries.find((e) => e.id === modelId),
353
+ submitVideo: (modelId) => media.submitTestVideo(channelId, modelId, bound),
354
+ };
355
+ },
356
+ });
357
+
358
+ const discoverModels = async ({ channelId }) => {
359
+ const ch = channelById(channelId);
360
+ const value = await gatewayCatalog(ch, { force: true });
361
+ const enabled = Array.isArray(ch.enabledModels) ? new Set(ch.enabledModels) : undefined;
362
+ const gatewayIds = new Set(value.gatewayIds ?? value.entries.filter((e) => e.source?.gateway).map((e) => e.id));
363
+ return {
364
+ channelId: ch.id,
365
+ fetchedAt: value.fetchedAt,
366
+ listMode: enabled ? 'list' : 'auto',
367
+ models: value.entries.filter((e) => gatewayIds.has(e.upstreamModel ?? e.id)).map((e) => ({
368
+ id: e.id, upstreamModel: e.upstreamModel ?? e.id, kind: e.kind, vendor: e.vendor, input: e.input,
369
+ contextWindow: e.contextWindow ?? null, maxTokens: e.maxTokens ?? null,
370
+ reasoning: e.reasoning, pricing: e.pricing,
371
+ // A gateway model already used through a local id's mapping is not "new".
372
+ status: !enabled || enabled.has(e.id) || e.upstreamModel !== e.id ? 'enabled' : 'new',
373
+ })),
374
+ missing: enabled
375
+ ? [...enabled].filter((id) => !gatewayIds.has(upstreamModelOf({ id, upstreamModel: ch.models?.[id]?.upstreamModel }))).map((id) => ({ id, custom: ch.models?.[id]?.custom === true }))
376
+ : [],
377
+ };
378
+ };
379
+
380
+ // ------------------------------------------------------------ config writes
381
+
382
+ const storeKey = async (keyName, apiKey) => {
383
+ const credentials = ctx.get('credentials');
384
+ if (!credentials) throw Object.assign(new Error('the DSH credential service is unavailable'), { status: 503 });
385
+ const value = String(apiKey ?? '').trim();
386
+ if (!value || /\s/.test(value)) throw Object.assign(new Error('the token must be a single non-empty string'), { status: 400 });
387
+ await credentials.set(credentialRef(keyName), value);
388
+ };
389
+
390
+ const keyConfigured = async (ch) => {
391
+ try { await keyFor(ch); return true; } catch { return false; }
392
+ };
393
+
394
+ /** Probe a gateway without saving anything: version, model counts, token quota. */
395
+ const testGateway = async ({ baseURL, apiKey, headers }) => {
396
+ const root = gatewayRoot(baseURL);
397
+ const out = { ok: false, baseURL: root };
398
+ const started = Date.now();
399
+ try {
400
+ const status = await fetchJson(`${root}/api/status`, { headers, timeoutMs: 8_000 }).catch(() => undefined);
401
+ const st = status?.data ?? status;
402
+ out.version = st?.version ?? null;
403
+ out.systemName = st?.system_name ?? null;
404
+ const models = await fetchJson(`${root}/v1/models`, { apiKey, headers, timeoutMs: 10_000 });
405
+ const counts = { total: 0, chat: 0, image: 0, video: 0, other: 0 };
406
+ for (const m of models?.data ?? []) {
407
+ counts.total++;
408
+ const k = classify(m.id, modelsDev.match(m.id));
409
+ if (k === 'chat' || k === 'image' || k === 'video') counts[k]++; else counts.other++;
410
+ }
411
+ out.models = counts;
412
+ out.latencyMs = Date.now() - started;
413
+ out.ok = true;
414
+ } catch (error) {
415
+ out.error = redact(String(error?.message ?? error), apiKey);
416
+ out.code = error?.code ?? 'ERROR';
417
+ }
418
+ return out;
419
+ };
420
+
421
+ const channelService = new ChannelService({
422
+ ns: settingsNs, current, settings: () => ctx.get('settings'), keyFor, keyConfigured,
423
+ storeKey, unsetKey: async (name) => ctx.get('credentials')?.unset(credentialRef(name)), testGateway,
424
+ /** Global headers always; channel headers only for the channel's own saved address. */
425
+ headersFor: (ch, baseURL) => {
426
+ const global = current().headers ?? {};
427
+ if (!ch) return { ...global };
428
+ return gatewayRoot(ch.baseURL) === gatewayRoot(baseURL) ? { ...global, ...(ch.headers ?? {}) } : { ...global };
429
+ },
430
+ // First list edit on an automatic-mode channel starts from what the gateway lists now.
431
+ gatewayModelIds: async (ch) => (await gatewayCatalog(ch, { force: true })).entries.map((e) => e.id),
432
+ changed: () => {
433
+ channelsMemo = undefined; connectionMemo = undefined; catalogs.invalidate(); balances.clear(); ensureRegistration();
434
+ const ids = new Set(channels().list.map((c) => c.id));
435
+ for (const id of new Set([...tester.results.keys()].map((k) => k.split('\u0000')[0]))) if (!ids.has(id)) tester.forget(id);
436
+ },
437
+ });
438
+
439
+ // ---------------------------------------------------------------- snapshot
440
+
441
+ const withTimeout = async (p, ms) => {
442
+ let timer;
443
+ try { return await Promise.race([p, new Promise((r) => { timer = setTimeout(r, ms); })]); }
444
+ finally { clearTimeout(timer); }
445
+ };
446
+
447
+ /**
448
+ * One channel's own model list, with its own overrides and test results.
449
+ * Every channel is maintained independently: what gateway A lists (and what
450
+ * the user enabled there) never leaks into gateway B.
451
+ */
452
+ const modelsOf = async (ch, { force = false } = {}) => {
453
+ const captured = structuredClone(ch);
454
+ const conn = connFor(captured);
455
+ const value = await catalogFor(captured, { force }, conn);
456
+ // A successful read clears the stale diagnostic the page shows under "issues".
457
+ if (captured.id === channels().active.id) errors.delete('catalog');
458
+ return {
459
+ channelId: captured.id,
460
+ listMode: Array.isArray(captured.enabledModels) ? 'list' : 'auto',
461
+ fetchedAt: value.fetchedAt,
462
+ pricingAvailable: value.pricingAvailable,
463
+ pricingError: value.pricingError,
464
+ entries: value.entries.map((e) => ({
465
+ ...e,
466
+ // Own-property read: built-in names such as `toString` are legal model ids.
467
+ override: structuredClone(Object.hasOwn(captured.models ?? {}, e.id) ? captured.models[e.id] : {}),
468
+ automaticContextWindow: e.automaticContextWindow ?? conn.defaultContextWindow,
469
+ automaticMaxTokens: e.automaticMaxTokens ?? conn.defaultMaxTokens,
470
+ lastTest: tester.last(captured.id, e.id) ?? null,
471
+ effectiveContextWindow: e.contextWindow ?? conn.defaultContextWindow,
472
+ effectiveMaxTokens: e.maxTokens ?? conn.defaultMaxTokens,
473
+ })),
474
+ };
475
+ };
476
+
477
+ const snapshot = async ({ force = false } = {}) => {
478
+ const conn = connection();
479
+ const state = channels();
480
+ const revision = channelService.revision();
481
+ const services = new Map(state.list.map((c) => [c.id, balanceOf(c.id)]));
482
+ let models = null;
483
+ try {
484
+ models = await modelsOf(state.active, { force });
485
+ } catch (error) {
486
+ reportError('catalog', error);
487
+ }
488
+ // Load balances for channels not yet queried, without blocking the page for long.
489
+ await withTimeout(Promise.allSettled(state.list.map((c) => {
490
+ const svc = services.get(c.id);
491
+ return svc.state.status === 'idle' || force ? svc.refresh({ force }) : undefined;
492
+ })), 5_000);
493
+ const list = await Promise.all(state.list.map(async (c) => ({
494
+ id: c.id,
495
+ name: c.name,
496
+ baseURL: gatewayRoot(c.baseURL),
497
+ keyName: c.apiKeyEnv,
498
+ keyConfigured: await keyConfigured(c),
499
+ overrideCount: Object.keys(c.models ?? {}).length,
500
+ // Saved list size; null in automatic mode (count comes from the gateway on demand).
501
+ enabledCount: Array.isArray(c.enabledModels) ? c.enabledModels.length : null,
502
+ active: c.id === state.active.id,
503
+ balance: services.get(c.id).state,
504
+ })));
505
+ return {
506
+ plugin: 'dsh-newapi-provider',
507
+ revision,
508
+ channels: list,
509
+ activeChannel: state.active.id,
510
+ listMode: Array.isArray(state.active.enabledModels) ? 'list' : 'auto',
511
+ route: { ...routeState, displayName: conn.displayName },
512
+ defaults: { contextWindow: conn.defaultContextWindow, maxTokens: conn.defaultMaxTokens },
513
+ balance: services.get(state.active.id).state,
514
+ modelsDev: modelsDev.status(),
515
+ models,
516
+ media: media.status(),
517
+ errors: Object.fromEntries(errors),
518
+ };
519
+ };
520
+
521
+ // ------------------------------------------------------------- agent tools
522
+
523
+ ctx.inject(['tools'], (tctx) => {
524
+ registerAsyncEffect(tctx, () => tctx.tools.register({
525
+ name: 'newapi_channels',
526
+ description: 'Manage New API channels: list, test a free connection, save, activate one channel, delete, edit model overrides, read the gateway model list, edit the channel\'s enabled-model list, or test one model. Writes persist shared settings; deleting keeps credentials. List returns bounded metadata and revision, never tokens.',
527
+ parameters: {
528
+ type: 'object', properties: {
529
+ action: { type: 'string', enum: ['list', 'test', 'save', 'activate', 'delete', 'model_override', 'models_discover', 'models_set', 'model_test'] },
530
+ revision: { type: 'integer', description: 'Required for writes (save, activate, delete, model_override, models_set); use revision from list/state. Stale writes fail.' },
531
+ id: { type: 'string', description: 'Channel for test/save/activate/delete. Omit for a new save.' },
532
+ channelId: { type: 'string', description: 'Required for model_override, models_discover, models_set and model_test; never inferred from the active channel.' },
533
+ add: { type: 'array', items: { type: 'string' }, description: 'models_set: local model ids to enable on the channel.' },
534
+ remove: { type: 'array', items: { type: 'string' }, description: 'models_set: model ids to remove from the channel list.' },
535
+ clearOverrides: { type: 'boolean', description: 'models_set: also delete overrides of removed models (kept by default).' },
536
+ overrides: { type: 'object', description: 'models_set: per-model override patches keyed by model id, e.g. {"id":{"custom":true}} for a model the gateway does not list.' },
537
+ confirmPaid: { type: 'boolean', description: 'model_test: required for image and video models, whose test is a real billed generation. Chat tests send one tiny billed request.' },
538
+ name: { type: 'string' },
539
+ baseURL: { type: 'string', description: 'HTTP(S) gateway. Changing an address requires an explicit token.' },
540
+ apiKey: { type: 'string', description: 'Token for save/test; omit to keep the token on an unchanged gateway.' },
541
+ activate: { type: 'boolean', description: 'Activate after save.' },
542
+ modelId: { type: 'string', description: 'Stable local model id, not its upstreamModel mapping.' },
543
+ override: { type: 'object', description: 'Partial override: name (display only), upstreamModel (wire id; local id stays unchanged), kind, hidden, contextWindow, maxTokens, input, reasoning, reasoningLevels, protocol, custom. Null removes a field; omitted fields are preserved.' },
544
+ reset: { type: 'boolean', description: 'Remove all overrides for this model.' },
545
+ offset: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 100 },
546
+ }, required: ['action'], additionalProperties: false,
547
+ },
548
+ isConcurrencySafe: (args) => ['list', 'test', 'models_discover'].includes(args?.action),
549
+ output: { schema: { type: 'object' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 1) }] },
550
+ async execute(args) {
551
+ if (args.action === 'models_discover') return JSON.parse(JSON.stringify(await discoverModels(args)));
552
+ if (args.action === 'model_test') {
553
+ const { preview, ...rest } = await tester.run(args);
554
+ return JSON.parse(JSON.stringify({ ...rest, ...(preview?.type === 'video-task' ? { preview } : {}) }));
555
+ }
556
+ return channelService.run(args.action, args);
557
+ },
558
+ }));
559
+ registerAsyncEffect(tctx, () => tctx.tools.register({
560
+ name: 'newapi_balance',
561
+ description: 'Show the remaining balance and usage of the active New API channel\'s token (and of the account, when the token allows user-quota queries). Read-only and free.',
562
+ parameters: { type: 'object', properties: { refresh: { type: 'boolean', description: 'Query the gateway now instead of using the last cached value.' } }, additionalProperties: false },
563
+ isConcurrencySafe: () => true,
564
+ output: {
565
+ schema: { type: 'object' },
566
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 1) }],
567
+ },
568
+ async execute(args) {
569
+ const ch = channels().active;
570
+ const svc = balanceOf(ch.id);
571
+ const state = args?.refresh || svc.state.status === 'idle' ? await svc.refresh({ force: Boolean(args?.refresh) }) : svc.state;
572
+ return JSON.parse(JSON.stringify({ channel: { id: ch.id, name: ch.name, baseURL: gatewayRoot(ch.baseURL) }, ...state }));
573
+ },
574
+ }));
575
+ registerAsyncEffect(tctx, () => tctx.tools.register({
576
+ name: 'newapi_models',
577
+ description: 'List local model ids and upstreamModel mappings on a New API channel (the active one unless channelId is given) with their kind (chat, image, video, …), context window, output cap, input modalities (text, image, audio, video, pdf), reasoning support and pricing ratios. Each channel keeps its own model list. Read-only and free.',
578
+ parameters: {
579
+ type: 'object',
580
+ properties: {
581
+ channelId: { type: 'string', description: 'Channel to list; defaults to the active channel. Ids come from newapi_channels list.' },
582
+ kind: { type: 'string', enum: KINDS, description: 'Only list models of this kind.' },
583
+ refresh: { type: 'boolean', description: 'Re-read the gateway model list now.' },
584
+ },
585
+ additionalProperties: false,
586
+ },
587
+ isConcurrencySafe: () => true,
588
+ output: {
589
+ schema: { type: 'object' },
590
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 1) }],
591
+ },
592
+ async execute(args) {
593
+ const bound = capture(args?.channelId);
594
+ const conn = bound.connection;
595
+ const value = await bound.catalog({ force: Boolean(args?.refresh) });
596
+ const entries = value.entries.filter((e) => !args?.kind || e.kind === args.kind);
597
+ return JSON.parse(JSON.stringify({
598
+ channel: { id: conn.channelId, name: conn.channelName },
599
+ gateway: gatewayRoot(conn.baseURL),
600
+ route: routeState.route,
601
+ routeStatus: routeState.status,
602
+ modelsDev: modelsDev.status(),
603
+ pricingAvailable: value.pricingAvailable,
604
+ models: entries.map((e) => ({
605
+ id: e.id,
606
+ upstreamModel: e.upstreamModel ?? e.id,
607
+ kind: e.kind,
608
+ hidden: e.hidden,
609
+ contextWindow: e.contextWindow ?? null,
610
+ effectiveContextWindow: e.contextWindow ?? conn.defaultContextWindow,
611
+ maxTokens: e.maxTokens ?? null,
612
+ input: e.input,
613
+ output: e.outputModalities,
614
+ reasoning: e.reasoning,
615
+ endpointTypes: e.endpointTypes,
616
+ vendor: e.vendor,
617
+ pricing: e.pricing,
618
+ capabilitySource: e.source.modelsDev ? `models.dev ${e.source.modelsDev.provider} (${e.source.modelsDev.match})` : 'none',
619
+ overridden: e.source.overridden,
620
+ })),
621
+ }));
622
+ },
623
+ }));
624
+ });
625
+
626
+ // ----------------------------------------------------------------- web API
627
+
628
+ const routes = {
629
+ 'GET state': async () => snapshot(),
630
+ 'GET balance': async () => (balance.state.status === 'idle' ? balance.refresh() : balance.state),
631
+ 'POST refresh': async () => {
632
+ catalogs.invalidate();
633
+ await modelsDev.refresh({ force: true });
634
+ return snapshot({ force: true });
635
+ },
636
+ ...Object.fromEntries([
637
+ ['channel/activate', 'activate'], ['channel/test', 'test'], ['channel/save', 'save'],
638
+ ['channel/delete', 'delete'], ['model/override', 'model_override'], ['models/set', 'models_set'],
639
+ ].map(([route, action]) => [`POST ${route}`, async (body) => {
640
+ const result = await channelService.run(action, body);
641
+ return action === 'test' ? result : snapshot();
642
+ }])),
643
+ 'POST models/discover': async (body) => discoverModels(body),
644
+ // Any channel's own list, active or not, so each gateway is managed independently.
645
+ 'POST models/list': async (body) => {
646
+ if (typeof body?.channelId !== 'string' || !body.channelId) throw Object.assign(new Error('channelId is required'), { status: 400 });
647
+ return modelsOf(channelById(body.channelId), { force: body.force === true });
648
+ },
649
+ 'POST model/test': async (body) => tester.run(body),
650
+ };
651
+
652
+ // `inject` waits for the service: on a cold start the HTTP server may be created
653
+ // after this plugin applies, and reading it once here would silently skip routing
654
+ // (the settings page then fails with 404 until the plugin is reloaded).
655
+ ctx.inject(['webServer'], (wctx) => {
656
+ wctx.effect(() => wctx.webServer.register({
657
+ kind: 'prefix',
658
+ path: ROUTE_PREFIX.slice(0, -1),
659
+ async handler(req, res) {
660
+ // `/api/` belongs to DSH itself, so this plugin uses its own prefix and
661
+ // applies the same session check the Web UI's own requests pass.
662
+ const conn = ctx.get('connection');
663
+ if (!conn || conn.requestRejection(req) !== undefined) return sendJson(res, 403, { error: 'forbidden' });
664
+ const url = new URL(req.url ?? '/', 'http://local');
665
+ try {
666
+ if (url.pathname.startsWith(ROUTE_PREFIX)) {
667
+ const sub = url.pathname.slice(ROUTE_PREFIX.length);
668
+ const route = routes[`${req.method} ${sub}`];
669
+ if (route) {
670
+ let body = {};
671
+ if (req.method === 'POST') {
672
+ // A JSON content type forces a CORS preflight for cross-site callers.
673
+ if (!/^application\/json\b/i.test(req.headers['content-type'] ?? '')) return sendJson(res, 415, { error: 'expected application/json' });
674
+ body = await readBody(req);
675
+ }
676
+ return sendJson(res, 200, await route(body));
677
+ }
678
+ }
679
+ if (await media.handle(req, res, url)) return;
680
+ return sendJson(res, 404, { error: 'not found' });
681
+ } catch (error) {
682
+ return sendJson(res, error?.status ?? 500, { error: String(error?.message ?? error) });
683
+ }
684
+ },
685
+ }));
686
+ });
687
+ }