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.
@@ -0,0 +1,395 @@
1
+ /** Single-model tests: one request, no automatic retries, bounded and redacted results. */
2
+ import { upstreamModelOf } from './catalog.js';
3
+ import { gatewayRoot, GatewayError, readCache, redact, writeCache } from './util.js';
4
+
5
+ const RESULTS_FILE = 'model-tests.json';
6
+ const CHAT_TIMEOUT_MS = 60_000;
7
+ const STREAM_BYTE_LIMIT = 256 * 1024;
8
+ const IMAGE_BYTE_LIMIT = 64 * 1024 * 1024;
9
+ const IMAGE_TIMEOUT_MS = 11 * 60 * 1000;
10
+ const PREVIEW_LIMIT = 3 * 1024 * 1024;
11
+ const PER_CHANNEL_LIMIT = 3;
12
+ const RESULTS_LIMIT = 1000;
13
+ const PROMPT = 'Reply with OK.';
14
+ const ENDPOINT_TO_PATH = { openai: 'openai', 'openai-response': 'responses', anthropic: 'anthropic', gemini: 'unsupported' };
15
+
16
+ export function pickTestProtocol(endpointTypes, priority) {
17
+ const advertised = endpointTypes?.length ? endpointTypes : ['openai'];
18
+ for (const t of priority ?? []) if (advertised.includes(t) && Object.hasOwn(ENDPOINT_TO_PATH, t)) return ENDPOINT_TO_PATH[t];
19
+ for (const t of advertised) if (Object.hasOwn(ENDPOINT_TO_PATH, t)) return ENDPOINT_TO_PATH[t];
20
+ return 'unsupported';
21
+ }
22
+
23
+ export function classifyTestError(status, message) {
24
+ const text = String(message ?? '');
25
+ if (/no available channel|unknown provider|model_not_found|model not found|does not exist|无可用渠道/i.test(text)) return 'NO_CHANNEL';
26
+ if (status === 401 || status === 403) return 'AUTH';
27
+ if (status === 429) return 'RATE_LIMIT';
28
+ if (status === 503 && /channel/i.test(text)) return 'NO_CHANNEL';
29
+ return 'HTTP';
30
+ }
31
+
32
+ export function requestFor(protocol, root, model, apiKey, headers = {}) {
33
+ const auth = { authorization: `Bearer ${apiKey}`, ...headers };
34
+ if (protocol === 'anthropic') return {
35
+ url: `${root}/v1/messages`,
36
+ headers: { ...auth, 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', accept: 'text/event-stream' },
37
+ body: { model, max_tokens: 16, stream: true, messages: [{ role: 'user', content: PROMPT }] },
38
+ };
39
+ if (protocol === 'responses') return {
40
+ url: `${root}/v1/responses`,
41
+ headers: { ...auth, 'content-type': 'application/json', accept: 'text/event-stream' },
42
+ body: { model, max_output_tokens: 16, stream: true, input: PROMPT },
43
+ };
44
+ if (protocol !== 'openai') throw new GatewayError('testing this protocol is not supported', 'UNSUPPORTED', { status: 400 });
45
+ return {
46
+ url: `${root}/v1/chat/completions`,
47
+ headers: { ...auth, 'content-type': 'application/json', accept: 'text/event-stream' },
48
+ body: { model, max_tokens: 16, stream: true, messages: [{ role: 'user', content: PROMPT }] },
49
+ };
50
+ }
51
+
52
+ function validateId(value, name, limit) {
53
+ if (typeof value !== 'string' || !value.trim() || value !== value.trim() || value.length > limit
54
+ || /[\u0000-\u001f\u007f]/.test(value) || ['__proto__', 'prototype', 'constructor'].includes(value)) {
55
+ throw new GatewayError(`invalid ${name}`, 'INVALID_REQUEST', { status: 400 });
56
+ }
57
+ }
58
+
59
+ function secretsFor(facts, apiKey) {
60
+ return [apiKey, ...Object.entries(facts?.headers ?? {}).filter(([k]) => /auth|key|token|secret|cookie/i.test(k))
61
+ .flatMap(([, v]) => [String(v), String(v).replace(/^Bearer\s+/i, '')])].filter(Boolean);
62
+ }
63
+
64
+ function cleanText(value, secrets = []) {
65
+ let text = String(value ?? '').replace(/https?:\/\/[^\s<>"']+/gi, (value) => {
66
+ try {
67
+ const url = new URL(value);
68
+ if (url.username || url.password || [...url.searchParams.keys()].some((key) => /token|key|auth|secret|signature|credential|googleaccessid/i.test(key))) return '<redacted-url>';
69
+ } catch { /* not a URL */ }
70
+ return value;
71
+ });
72
+ for (const secret of secrets) {
73
+ for (const variant of new Set([secret, encodeURIComponent(secret)])) text = text.split(variant).join('<redacted>');
74
+ }
75
+ return redact(text);
76
+ }
77
+
78
+ // Include nested usage, errors, text, and preview fields, not just error.message.
79
+ function sanitize(value, secrets, depth = 0) {
80
+ if (typeof value === 'string') return cleanText(value, secrets);
81
+ if (value === null || typeof value !== 'object') return value;
82
+ if (depth > 16) return '<omitted>';
83
+ if (Array.isArray(value)) return value.slice(0, 1000).map((v) => sanitize(v, secrets, depth + 1));
84
+ return Object.fromEntries(Object.entries(value).slice(0, 1000).map(([k, v]) => [cleanText(k, secrets), sanitize(v, secrets, depth + 1)]));
85
+ }
86
+
87
+ function errorMessageOf(json, raw) {
88
+ const message = json?.error?.message ?? json?.message ?? json?.error ?? raw ?? '';
89
+ return typeof message === 'string' ? message : JSON.stringify(message);
90
+ }
91
+
92
+ async function consume(res, maxBytes, signal, onChunk) {
93
+ if (!res.body) throw new GatewayError('gateway returned no response body', 'BAD_RESPONSE');
94
+ const reader = res.body.getReader();
95
+ let bytes = 0;
96
+ try {
97
+ for (;;) {
98
+ signal?.throwIfAborted();
99
+ const { done, value } = await reader.read();
100
+ signal?.throwIfAborted();
101
+ if (done) break;
102
+ bytes += value.byteLength;
103
+ if (bytes > maxBytes) throw new GatewayError(`response larger than ${maxBytes} bytes`, 'TOO_LARGE');
104
+ onChunk(value);
105
+ }
106
+ } finally {
107
+ try { await reader.cancel(); } catch { /* preserve the original failure */ }
108
+ reader.releaseLock();
109
+ }
110
+ }
111
+
112
+ async function boundedText(res, limit, signal) {
113
+ const chunks = [];
114
+ await consume(res, limit, signal, (chunk) => chunks.push(Buffer.from(chunk)));
115
+ return Buffer.concat(chunks).toString('utf8');
116
+ }
117
+
118
+ /** A first SSE data event measures first packet, never first generated token. */
119
+ async function readStream(res, started, signal, protocol) {
120
+ const contentType = res.headers.get('content-type');
121
+ if (!contentType || !/^text\/event-stream(?:\s*;|$)/i.test(contentType)) {
122
+ try { await res.body?.cancel(); } catch { /* best effort */ }
123
+ throw new GatewayError('gateway did not return an SSE response', 'BAD_RESPONSE');
124
+ }
125
+ const decoder = new TextDecoder();
126
+ let buffer = '', event = '', data = [], text = '', firstByteMs, usage, streamError, terminal = false, validEvent = false;
127
+ function dispatch() {
128
+ if (!data.length) { event = ''; return; }
129
+ const raw = data.join('\n').trim();
130
+ data = [];
131
+ if (firstByteMs === undefined) firstByteMs = Date.now() - started;
132
+ // Some gateways translate all three protocols to the common DONE sentinel.
133
+ if (raw === '[DONE]') { terminal = true; event = ''; return; }
134
+ let json;
135
+ try { json = JSON.parse(raw); } catch { throw new GatewayError('invalid SSE JSON payload', 'BAD_RESPONSE'); }
136
+ if (!json || typeof json !== 'object' || Array.isArray(json)) throw new GatewayError('invalid SSE protocol payload', 'BAD_RESPONSE');
137
+ const type = json.type ?? event;
138
+ if (json?.error || type === 'error' || ['response.failed', 'response.incomplete'].includes(type)
139
+ || ['failed', 'incomplete'].includes(json?.response?.status)
140
+ || (type === 'response.completed' && json?.response?.status !== undefined && json.response.status !== 'completed')) {
141
+ streamError = errorMessageOf(json?.response ?? json, `gateway stream ended with ${type}`);
142
+ }
143
+ if (protocol === 'responses') {
144
+ if (type === 'response.completed' && (json?.response?.status === undefined || json.response.status === 'completed')) terminal = validEvent = true;
145
+ if (type === 'response.output_text.delta' && typeof json?.delta === 'string') validEvent = true;
146
+ }
147
+ if (protocol === 'anthropic') {
148
+ if (type === 'message_stop') terminal = validEvent = true;
149
+ if ((type === 'message_start' && json?.message?.role === 'assistant')
150
+ || (type === 'content_block_delta' && json?.delta?.type === 'text_delta' && typeof json.delta.text === 'string')) validEvent = true;
151
+ }
152
+ if (protocol === 'openai' && Array.isArray(json?.choices)) {
153
+ if (json.choices.some((c) => typeof c?.finish_reason === 'string' && c.finish_reason.trim())) terminal = validEvent = true;
154
+ if (json.choices.some((c) => c?.delta?.role === 'assistant' || typeof c?.delta?.content === 'string')) validEvent = true;
155
+ }
156
+ const delta = json?.choices?.[0]?.delta?.content ?? json?.delta?.text ?? (type === 'response.output_text.delta' ? json.delta : '');
157
+ if (typeof delta === 'string') text += delta;
158
+ usage = json?.usage ?? json?.message?.usage ?? json?.response?.usage ?? usage;
159
+ event = '';
160
+ }
161
+ await consume(res, STREAM_BYTE_LIMIT, signal, (chunk) => {
162
+ buffer += decoder.decode(chunk, { stream: true });
163
+ let nl;
164
+ while ((nl = buffer.indexOf('\n')) >= 0) {
165
+ const line = buffer.slice(0, nl).replace(/\r$/, '');
166
+ buffer = buffer.slice(nl + 1);
167
+ if (!line) dispatch();
168
+ else if (line.startsWith('event:')) event = line.slice(6).trim();
169
+ else if (line.startsWith('data:')) {
170
+ if (firstByteMs === undefined) firstByteMs = Date.now() - started;
171
+ data.push(line.slice(5).replace(/^ /, ''));
172
+ }
173
+ }
174
+ });
175
+ // DONE is framing, not evidence that the selected protocol produced a result.
176
+ if ((!terminal || !validEvent) && streamError === undefined) throw new GatewayError('gateway stream ended without a valid protocol result and terminal event', 'INCOMPLETE');
177
+ return { firstByteMs, text, streamError, usage };
178
+ }
179
+
180
+ function sniff(bytes) {
181
+ if (bytes[0] === 0x89 && bytes[1] === 0x50) return 'image/png';
182
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) return 'image/jpeg';
183
+ if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[8] === 0x57) return 'image/webp';
184
+ if (bytes[0] === 0x47 && bytes[1] === 0x49) return 'image/gif';
185
+ }
186
+
187
+ function previewURL(value, secrets) {
188
+ if (typeof value !== 'string' || cleanText(value, secrets) !== value) return undefined;
189
+ try {
190
+ const url = new URL(value);
191
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return undefined;
192
+ if ([...url.searchParams].some(([k, v]) => /token|key|auth|secret|signature|credential/i.test(k) || cleanText(v, secrets) !== v)) return undefined;
193
+ if (cleanText(decodeURIComponent(url.pathname + url.hash), secrets) !== decodeURIComponent(url.pathname + url.hash)) return undefined;
194
+ return url.href;
195
+ } catch { return undefined; }
196
+ }
197
+
198
+ // Signed object-storage URLs are results, but their credentials must never be
199
+ // passed to the browser or persisted. Other private/token URLs remain rejected.
200
+ function signedImageURL(value, secrets) {
201
+ if (typeof value !== 'string' || secrets.some((secret) => value.includes(secret) || value.includes(encodeURIComponent(secret)))) return false;
202
+ try {
203
+ const url = new URL(value);
204
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return false;
205
+ const keys = new Map([...url.searchParams].map(([k, v]) => [k.toLowerCase(), v]));
206
+ const signed = keys.get('x-amz-signature') || keys.get('x-goog-signature')
207
+ || (keys.get('signature') && (keys.get('ossaccesskeyid') || keys.get('googleaccessid')))
208
+ || keys.get('x-oss-signature');
209
+ if (!signed) return false;
210
+ for (const key of [...url.searchParams.keys()]) {
211
+ if (/^(?:x-(?:amz|goog|oss)-[\w-]+|signature|ossaccesskeyid|googleaccessid|expires)$/i.test(key)) url.searchParams.delete(key);
212
+ }
213
+ return !!previewURL(url.href, secrets);
214
+ } catch { return false; }
215
+ }
216
+
217
+ function imageBase64(value) {
218
+ if (typeof value !== 'string') return undefined;
219
+ const normalized = value.replace(/[\t\n\r ]/g, '');
220
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) return undefined;
221
+ const bytes = Buffer.from(normalized, 'base64');
222
+ // Buffer.from silently accepts malformed padding and truncated input.
223
+ const canonical = bytes.toString('base64');
224
+ if (normalized !== canonical && normalized !== canonical.replace(/=+$/, '')) return undefined;
225
+ return { bytes, base64: canonical };
226
+ }
227
+
228
+ export class ModelTester {
229
+ /**
230
+ * host.channelFacts(channelId) -> immutable connection facts + apiKey()
231
+ * host.entryFor(channelId, modelId) -> catalog entry
232
+ * host.submitVideo?(channelId, modelId) -> { taskId }, acceptance only
233
+ * TODO host API: resolve facts+entry from ONE revisioned snapshot. Separate
234
+ * calls can observe different channel edits; video submission needs the same
235
+ * snapshot too. This cannot be fixed solely within this consumer.
236
+ */
237
+ constructor(host) {
238
+ this.host = host;
239
+ this.inflight = new Map();
240
+ this.paidInflight = new Set();
241
+ const saved = readCache(RESULTS_FILE);
242
+ this.results = new Map(Object.entries(saved?.results ?? {}).slice(-RESULTS_LIMIT).map(([k, v]) => {
243
+ const { preview, outputPreview, ...durable } = v && typeof v === 'object' ? v : {};
244
+ return [k, sanitize(durable, [])];
245
+ }));
246
+ }
247
+
248
+ key(channelId, modelId) { return `${channelId}\u0000${modelId}`; }
249
+ last(channelId, modelId) { return this.results.get(this.key(channelId, modelId)); }
250
+ forget(channelId) {
251
+ for (const k of this.results.keys()) if (k.startsWith(`${channelId}\u0000`)) this.results.delete(k);
252
+ this.persist();
253
+ }
254
+ persist() {
255
+ try { writeCache(RESULTS_FILE, { version: 1, results: Object.fromEntries(this.results) }); } catch { /* best effort */ }
256
+ }
257
+ record(channelId, modelId, result) {
258
+ const { preview, outputPreview, ...durable } = result;
259
+ const key = this.key(channelId, modelId);
260
+ this.results.delete(key);
261
+ this.results.set(key, durable);
262
+ while (this.results.size > RESULTS_LIMIT) this.results.delete(this.results.keys().next().value);
263
+ this.persist();
264
+ return result;
265
+ }
266
+
267
+ async run(input = {}) {
268
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new GatewayError('invalid test request', 'INVALID_REQUEST', { status: 400 });
269
+ const { channelId, modelId, confirmPaid = false } = input;
270
+ validateId(channelId, 'channelId', 128);
271
+ validateId(modelId, 'modelId', 512);
272
+ if (typeof confirmPaid !== 'boolean') throw new GatewayError('confirmPaid must be boolean', 'INVALID_REQUEST', { status: 400 });
273
+ const running = this.inflight.get(channelId) ?? 0;
274
+ if (running >= PER_CHANNEL_LIMIT) throw new GatewayError('too many tests running on this channel', 'RATE_LIMIT', { status: 429 });
275
+ const key = this.key(channelId, modelId);
276
+ if (this.paidInflight.has(key)) throw new GatewayError('a paid test is already running for this channel and model', 'RATE_LIMIT', { status: 429 });
277
+ // Reserve synchronously, before discovery can make any network requests.
278
+ this.inflight.set(channelId, running + 1);
279
+ let paidReserved = confirmPaid;
280
+ if (paidReserved) this.paidInflight.add(key);
281
+ let kind, upstreamModel = modelId, secrets = [];
282
+ try {
283
+ const facts = await this.host.channelFacts(channelId);
284
+ secrets = secretsFor(facts);
285
+ const apiKey = await facts.apiKey();
286
+ secrets = secretsFor(facts, apiKey);
287
+ const entry = facts.entryFor ? await facts.entryFor(modelId) : await this.host.entryFor(channelId, modelId);
288
+ if (facts.entryFor && !entry) throw new GatewayError('model is not enabled on this channel', 'UNKNOWN_MODEL', { status: 400 });
289
+ upstreamModel = upstreamModelOf({ id: modelId, upstreamModel: entry?.upstreamModel });
290
+ kind = entry?.kind ?? 'chat';
291
+ if (!['chat', 'image', 'video'].includes(kind)) throw new GatewayError(`testing ${kind} models is not supported`, 'UNSUPPORTED', { status: 400 });
292
+ if (kind !== 'chat' && confirmPaid !== true) throw new GatewayError('paid test requires confirmation', 'CONFIRM_REQUIRED', { status: 400 });
293
+ let result;
294
+ if (kind === 'chat') result = await this.chat(facts, entry, modelId, apiKey);
295
+ else if (kind === 'image') result = await this.image(facts, upstreamModel, apiKey);
296
+ else result = await this.video(channelId, modelId, facts);
297
+ return this.record(channelId, modelId, sanitize({ channelId, modelId, upstreamModel, kind, phase: result.ok ? 'completed' : 'failed', ...result, testedAt: Date.now() }, secrets));
298
+ } catch (error) {
299
+ const message = cleanText(error?.message ?? error, secrets).slice(0, 1000);
300
+ if (error?.status === 400 || error?.status === 429) throw new GatewayError(message, cleanText(error.code, secrets), { status: error.status });
301
+ const status = error?.status;
302
+ return this.record(channelId, modelId, sanitize({
303
+ ok: false, phase: 'failed', channelId, modelId, upstreamModel, kind, testedAt: Date.now(),
304
+ code: error?.name === 'TimeoutError' ? 'TIMEOUT' : error?.code && error.code !== 'HTTP' ? error.code : classifyTestError(status, message),
305
+ ...(status ? { status } : {}), error: message,
306
+ }, secrets));
307
+ } finally {
308
+ if (paidReserved) this.paidInflight.delete(key);
309
+ const left = (this.inflight.get(channelId) ?? 1) - 1;
310
+ if (left > 0) this.inflight.set(channelId, left); else this.inflight.delete(channelId);
311
+ }
312
+ }
313
+
314
+ async chat(facts, entry, modelId, apiKey) {
315
+ const protocol = pickTestProtocol(entry?.endpointTypes, facts.endpointPriority);
316
+ if (protocol === 'unsupported') return { ok: false, protocol, code: 'UNSUPPORTED', error: 'testing the advertised protocol is not supported' };
317
+ const req = requestFor(protocol, gatewayRoot(facts.baseURL), upstreamModelOf({ id: modelId, upstreamModel: entry?.upstreamModel }), apiKey, facts.headers);
318
+ const started = Date.now();
319
+ const signal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
320
+ const secrets = secretsFor(facts, apiKey);
321
+ try {
322
+ const res = await fetch(req.url, { method: 'POST', redirect: 'manual', signal, headers: req.headers, body: JSON.stringify(req.body) });
323
+ if (!res.ok) {
324
+ const raw = await boundedText(res, STREAM_BYTE_LIMIT, signal);
325
+ let json; try { json = JSON.parse(raw); } catch { /* non-JSON error */ }
326
+ const message = cleanText(errorMessageOf(json, raw), secrets).slice(0, 1000);
327
+ return { ok: false, protocol, status: res.status, code: classifyTestError(res.status, message), error: message, latencyMs: Date.now() - started };
328
+ }
329
+ const stream = await readStream(res, started, signal, protocol);
330
+ const latencyMs = Date.now() - started;
331
+ if (stream.streamError !== undefined) {
332
+ const message = cleanText(stream.streamError, secrets).slice(0, 1000);
333
+ return { ok: false, protocol, code: classifyTestError(res.status, message), error: message, latencyMs, firstByteMs: stream.firstByteMs };
334
+ }
335
+ return sanitize({ ok: true, phase: 'completed', status: 'completed', protocol, latencyMs, firstByteMs: stream.firstByteMs,
336
+ outputPreview: cleanText(stream.text, secrets).slice(0, 80), ...(stream.usage ? { usage: stream.usage } : {}) }, secrets);
337
+ } catch (error) {
338
+ return { ok: false, protocol, code: error?.name === 'TimeoutError' ? 'TIMEOUT' : error?.code ?? 'UNREACHABLE', error: cleanText(error?.message ?? error, secrets).slice(0, 1000), latencyMs: Date.now() - started };
339
+ }
340
+ }
341
+
342
+ async image(facts, modelId, apiKey) {
343
+ const started = Date.now();
344
+ const signal = AbortSignal.timeout(IMAGE_TIMEOUT_MS);
345
+ const secrets = secretsFor(facts, apiKey);
346
+ try {
347
+ const res = await fetch(`${gatewayRoot(facts.baseURL)}/v1/images/generations`, {
348
+ method: 'POST', redirect: 'manual', signal,
349
+ headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', accept: 'application/json', ...facts.headers },
350
+ body: JSON.stringify({ model: modelId, prompt: 'A small red circle on white.', n: 1 }),
351
+ });
352
+ const raw = await boundedText(res, res.ok ? IMAGE_BYTE_LIMIT : STREAM_BYTE_LIMIT, signal);
353
+ let json; try { json = JSON.parse(raw); } catch { /* handled as missing image */ }
354
+ const latencyMs = Date.now() - started;
355
+ if (!res.ok || json?.error || ['failed', 'incomplete', 'cancelled'].includes(json?.status)) {
356
+ const message = cleanText(errorMessageOf(json, raw), secrets).slice(0, 1000);
357
+ return { ok: false, phase: 'failed', protocol: 'images', status: res.status, code: classifyTestError(res.status, message), error: message, latencyMs };
358
+ }
359
+ const item = json?.data?.[0];
360
+ let preview, hasImage = false, previewOmitted = false;
361
+ const decoded = imageBase64(item?.b64_json);
362
+ if (decoded) {
363
+ const mime = sniff(decoded.bytes);
364
+ hasImage = !!mime;
365
+ if (mime && decoded.bytes.byteLength < PREVIEW_LIMIT) preview = { type: 'image', src: `data:${mime};base64,${decoded.base64}` };
366
+ }
367
+ if (!hasImage && item?.url) {
368
+ const src = previewURL(item.url, secrets);
369
+ previewOmitted = !src && signedImageURL(item.url, secrets);
370
+ hasImage = !!src || previewOmitted;
371
+ if (src) preview = { type: 'image', src };
372
+ }
373
+ const status = String(json?.status ?? '').toLowerCase();
374
+ if (res.status === 202 || ['accepted', 'queued', 'pending', 'processing', 'in_progress', 'running'].includes(status)) {
375
+ return { ok: false, phase: 'accepted', status: status || 'accepted', protocol: 'images', latencyMs, code: 'PENDING', error: 'image request accepted; generation is not yet verified' };
376
+ }
377
+ if (!hasImage) return { ok: false, phase: 'failed', protocol: 'images', code: 'BAD_RESPONSE', error: 'the gateway returned no usable image', latencyMs };
378
+ return sanitize({ ok: true, phase: 'completed', status: 'completed', protocol: 'images', latencyMs,
379
+ ...(preview ? { preview } : {}), ...(previewOmitted ? { previewOmitted: true } : {}) }, secrets);
380
+ } catch (error) {
381
+ const timedOut = error?.name === 'TimeoutError';
382
+ return { ok: false, phase: 'failed', protocol: 'images', code: timedOut ? 'TIMEOUT' : error?.code ?? 'UNREACHABLE',
383
+ error: `${cleanText(error?.message ?? error, secrets).slice(0, 1000)}${timedOut ? ' (the gateway may still finish and bill this image)' : ''}`, latencyMs: Date.now() - started };
384
+ }
385
+ }
386
+
387
+ async video(channelId, modelId, facts) {
388
+ const submit = facts?.submitVideo ?? ((id) => this.host.submitVideo?.(channelId, id));
389
+ if (!facts?.submitVideo && !this.host.submitVideo) return { ok: false, protocol: 'videos', code: 'UNSUPPORTED', error: 'video testing is not available' };
390
+ const started = Date.now();
391
+ const result = await submit(modelId);
392
+ if (typeof result?.taskId !== 'string' || !result.taskId.trim() || result.taskId.length > 512) return { ok: false, phase: 'failed', protocol: 'videos', code: 'BAD_RESPONSE', error: 'video submission returned no task ID' };
393
+ return { ok: false, phase: 'accepted', status: 'accepted', protocol: 'videos', code: 'PENDING', error: 'video request accepted; generation is not yet verified', latencyMs: Date.now() - started, preview: { type: 'video-task', taskId: result.taskId } };
394
+ }
395
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * models.dev capability lookup: context window, output cap, input/output
3
+ * modalities, reasoning. The index is trimmed and cached on disk so a cold
4
+ * start without network still has capability data.
5
+ */
6
+ import { fetchJson, readCache, writeCache } from './util.js';
7
+
8
+ const CACHE_FILE = 'models-dev-index.json';
9
+ const CACHE_VERSION = 1;
10
+ const DEFAULT_URL = 'https://models.dev/api.json';
11
+ const TTL_MS = 24 * 60 * 60 * 1000;
12
+
13
+ /** First-party provider per model family; used to break ties across resellers. */
14
+ const FAMILY_PROVIDERS = [
15
+ [/^claude/, ['anthropic']],
16
+ [/^(gpt|o\d|chatgpt|codex|dall-e|gpt-image|sora|whisper|tts)/, ['openai']],
17
+ [/^gemini|^imagen|^veo/, ['google', 'google-vertex']],
18
+ [/^deepseek/, ['deepseek']],
19
+ [/^(glm|cogview|cogvideo)/, ['zai', 'zhipuai']],
20
+ [/^(qwen|qwq|wan)/, ['alibaba', 'alibaba-cn']],
21
+ [/^grok/, ['xai']],
22
+ [/^(kimi|moonshot)/, ['moonshotai', 'moonshotai-cn']],
23
+ [/^(minimax|abab|hailuo)/, ['minimax', 'minimax-cn']],
24
+ [/^(doubao|seed)/, ['volcengine', 'bytedance']],
25
+ [/^mistral|^codestral|^magistral/, ['mistral']],
26
+ [/^llama/, ['meta', 'meta-llama']],
27
+ ];
28
+
29
+ function bare(id) {
30
+ return String(id).toLowerCase().split('/').pop();
31
+ }
32
+
33
+ /** Own-property read: a model literally named `constructor` must not hit Object.prototype. */
34
+ function own(table, key) {
35
+ return table && Object.hasOwn(table, key) ? table[key] : undefined;
36
+ }
37
+
38
+ function compact(entry, provider) {
39
+ const m = entry;
40
+ return {
41
+ provider,
42
+ name: m.name,
43
+ context: m.limit?.context ?? 0,
44
+ output: m.limit?.output ?? 0,
45
+ input: m.modalities?.input ?? [],
46
+ outputModalities: m.modalities?.output ?? [],
47
+ reasoning: Boolean(m.reasoning),
48
+ toolCall: m.tool_call !== false,
49
+ release: m.release_date,
50
+ };
51
+ }
52
+
53
+ /** Build `{ bareId: [compact entries] }` from the raw models.dev document. */
54
+ function buildIndex(doc) {
55
+ // Null prototype: gateway ids such as `__proto__` or `constructor` must stay data.
56
+ const index = Object.create(null);
57
+ for (const [provider, p] of Object.entries(doc ?? {})) {
58
+ for (const [id, m] of Object.entries(p?.models ?? {})) {
59
+ const key = bare(id);
60
+ (own(index, key) ?? (index[key] = [])).push(compact(m, provider));
61
+ }
62
+ }
63
+ return index;
64
+ }
65
+
66
+ export class ModelsDev {
67
+ constructor({ url = DEFAULT_URL, enabled = true, logger } = {}) {
68
+ this.url = url;
69
+ this.enabled = enabled;
70
+ this.logger = logger;
71
+ const cached = readCache(CACHE_FILE);
72
+ this.index = cached?.version === CACHE_VERSION ? cached.index : undefined;
73
+ this.fetchedAt = cached?.version === CACHE_VERSION ? cached.fetchedAt : 0;
74
+ this.inflight = undefined;
75
+ this.lastError = undefined;
76
+ }
77
+
78
+ /** Refresh in the background when stale; never throws. */
79
+ async refresh({ force = false } = {}) {
80
+ if (!this.enabled) return;
81
+ if (!force && this.index && Date.now() - this.fetchedAt < TTL_MS) return;
82
+ this.inflight ??= (async () => {
83
+ try {
84
+ const doc = await fetchJson(this.url, { timeoutMs: 30_000, maxBytes: 64 * 1024 * 1024 });
85
+ this.index = buildIndex(doc);
86
+ this.fetchedAt = Date.now();
87
+ this.lastError = undefined;
88
+ writeCache(CACHE_FILE, { version: CACHE_VERSION, fetchedAt: this.fetchedAt, index: this.index });
89
+ // Let the catalog drop entries built before this data was available.
90
+ this.onUpdated?.();
91
+ } catch (error) {
92
+ this.lastError = String(error?.message ?? error);
93
+ this.logger?.warn?.(`newapi-provider: models.dev refresh failed: ${this.lastError}`);
94
+ } finally {
95
+ this.inflight = undefined;
96
+ }
97
+ })();
98
+ return this.inflight;
99
+ }
100
+
101
+ /**
102
+ * Match one gateway model id. Exact bare-id match first; then progressively
103
+ * strip trailing `-segment`s (`claude-opus-5-5-high` → `claude-opus-5-5`).
104
+ * Ties across providers prefer the family's first-party provider, then the
105
+ * entry that actually carries a context window.
106
+ */
107
+ match(id) {
108
+ // A disabled switch means no capability data at all, including a previously cached copy.
109
+ if (!this.enabled || !this.index) return undefined;
110
+ const key = bare(id);
111
+ let candidates = own(this.index, key);
112
+ let kind = 'exact';
113
+ if (!candidates) {
114
+ const parts = key.split('-');
115
+ for (let n = parts.length - 1; n >= 2 && !candidates; n--) {
116
+ candidates = own(this.index, parts.slice(0, n).join('-'));
117
+ }
118
+ kind = 'prefix';
119
+ }
120
+ if (!candidates?.length) return undefined;
121
+ const preferred = FAMILY_PROVIDERS.find(([re]) => re.test(key))?.[1] ?? [];
122
+ const score = (c) => {
123
+ const p = preferred.indexOf(c.provider);
124
+ return (p >= 0 ? 100 - p : 0) + (c.context > 0 ? 10 : 0) + (c.input.length > 0 ? 1 : 0);
125
+ };
126
+ const best = [...candidates].sort((a, b) => score(b) - score(a))[0];
127
+ return { ...best, match: kind };
128
+ }
129
+
130
+ status() {
131
+ return {
132
+ enabled: this.enabled,
133
+ loaded: Boolean(this.index),
134
+ fetchedAt: this.fetchedAt || null,
135
+ models: this.index ? Object.keys(this.index).length : 0, lastError: this.lastError ?? null,
136
+ };
137
+ }
138
+ }
package/lib/util.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Shared helpers: gateway URLs, bounded HTTP, secret redaction, cache files.
3
+ */
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ /** Gateway root without trailing slash or version segment (`http://host:3300`). */
9
+ export function gatewayRoot(baseURL) {
10
+ return String(baseURL ?? '').trim().replace(/\/+$/, '').replace(/\/v\d+$/, '');
11
+ }
12
+
13
+ /** OpenAI SDK base for a gateway (`http://host:3300/v1`). */
14
+ export function openaiBase(baseURL) {
15
+ return `${gatewayRoot(baseURL)}/v1`;
16
+ }
17
+
18
+ /** Replace every occurrence of a secret (and anything shaped like a key) in text. */
19
+ export function redact(text, secret) {
20
+ let out = String(text ?? '');
21
+ if (secret) out = out.split(secret).join('<redacted>');
22
+ return out.replace(/sk-[A-Za-z0-9_-]{12,}/g, 'sk-<redacted>');
23
+ }
24
+
25
+ export class GatewayError extends Error {
26
+ constructor(message, code, details = {}) {
27
+ super(message);
28
+ this.name = 'GatewayError';
29
+ this.code = code;
30
+ Object.assign(this, details);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Fetch JSON from the gateway. Redirects are not followed: New API answers a
36
+ * missing trailing slash with 301, and following it can drop the auth header.
37
+ */
38
+ export async function fetchJson(url, { apiKey, method = 'GET', body, headers = {}, timeoutMs = 15_000, signal, maxBytes = 8 * 1024 * 1024 } = {}) {
39
+ const secretValues = [apiKey, ...Object.entries(headers).filter(([key]) => /auth|token|key|secret|cookie/i.test(key)).map(([, value]) => value)].filter((v) => typeof v === 'string' && v);
40
+ const cleanError = (message) => secretValues.reduce((text, secret) => redact(text, secret), String(message));
41
+ const signals = [AbortSignal.timeout(timeoutMs)];
42
+ if (signal) signals.push(signal);
43
+ let res;
44
+ try {
45
+ res = await fetch(url, {
46
+ method,
47
+ redirect: 'manual',
48
+ signal: AbortSignal.any(signals),
49
+ headers: {
50
+ accept: 'application/json',
51
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
52
+ ...(body !== undefined && !(body instanceof FormData) ? { 'content-type': 'application/json' } : {}),
53
+ ...headers,
54
+ },
55
+ ...(body === undefined ? {} : { body: body instanceof FormData ? body : JSON.stringify(body) }),
56
+ });
57
+ } catch (error) {
58
+ const timedOut = error?.name === 'TimeoutError';
59
+ throw new GatewayError(cleanError(`${timedOut ? 'timed out' : 'network error'} calling ${url}: ${error?.message ?? error}`), timedOut ? 'TIMEOUT' : 'UNREACHABLE');
60
+ }
61
+ if (res.status >= 300 && res.status < 400) {
62
+ throw new GatewayError(cleanError(`gateway redirected ${url} to ${res.headers.get('location') ?? '?'}; check the base URL`), 'REDIRECT', { status: res.status });
63
+ }
64
+ let text;
65
+ try {
66
+ text = await readBounded(res, maxBytes);
67
+ } catch (error) {
68
+ const timedOut = signals.some((s) => s.aborted && s.reason?.name === 'TimeoutError') || error?.name === 'TimeoutError';
69
+ throw new GatewayError(cleanError(`response read failed from ${url}: ${error?.message ?? error}`),
70
+ timedOut ? 'TIMEOUT' : error instanceof GatewayError ? error.code : 'UNREACHABLE', { status: res.status });
71
+ }
72
+ let json;
73
+ try { json = text.length > 0 ? JSON.parse(text) : undefined; } catch { json = undefined; }
74
+ if (!res.ok) {
75
+ const message = json?.error?.message ?? json?.message ?? text.slice(0, 300);
76
+ const code = res.status === 401 || res.status === 403 ? 'AUTH' : res.status === 429 ? 'RATE_LIMIT' : res.status >= 500 ? 'SERVER' : 'HTTP';
77
+ const retryAfter = Number(res.headers.get('retry-after'));
78
+ throw new GatewayError(cleanError(`HTTP ${res.status} from ${url}: ${message}`), code, {
79
+ status: res.status,
80
+ ...(Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterMs: retryAfter * 1000 } : {}),
81
+ });
82
+ }
83
+ if (json === undefined) {
84
+ const hint = /<html/i.test(text) ? ' (got HTML — the base URL may be missing /v1 or point at the web UI)' : '';
85
+ throw new GatewayError(`gateway returned non-JSON from ${url}${hint}`, 'BAD_RESPONSE', { status: res.status });
86
+ }
87
+ return json;
88
+ }
89
+
90
+ async function readBounded(res, maxBytes) {
91
+ if (!res.body) return '';
92
+ const reader = res.body.getReader();
93
+ const chunks = [];
94
+ let size = 0;
95
+ for (;;) {
96
+ const { done, value } = await reader.read();
97
+ if (done) break;
98
+ size += value.byteLength;
99
+ if (size > maxBytes) {
100
+ await reader.cancel();
101
+ throw new GatewayError(`response larger than ${maxBytes} bytes`, 'TOO_LARGE');
102
+ }
103
+ chunks.push(value);
104
+ }
105
+ return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
106
+ }
107
+
108
+ /** Directory for this plugin's cache files under the DeepSeek Harness home. */
109
+ export function dataDir() {
110
+ const home = process.env.DSH_HOME && path.isAbsolute(process.env.DSH_HOME)
111
+ ? process.env.DSH_HOME
112
+ : path.join(os.homedir(), '.dsh');
113
+ return path.join(home, 'plugin-data', 'dsh-newapi-provider');
114
+ }
115
+
116
+ export function readCache(name) {
117
+ try {
118
+ return JSON.parse(fs.readFileSync(path.join(dataDir(), name), 'utf8'));
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ export function writeCache(name, value) {
125
+ const dir = dataDir();
126
+ fs.mkdirSync(dir, { recursive: true });
127
+ const file = path.join(dir, name);
128
+ const tmp = `${file}.${process.pid}.tmp`;
129
+ fs.writeFileSync(tmp, JSON.stringify(value));
130
+ fs.renameSync(tmp, file);
131
+ }
132
+
133
+ /** Stable short fingerprint of a secret, for cache keys (never the secret itself). */
134
+ export function fingerprint(secret) {
135
+ let h = 2166136261;
136
+ for (const ch of String(secret ?? '')) h = Math.imul(h ^ ch.codePointAt(0), 16777619);
137
+ return (h >>> 0).toString(36);
138
+ }