@allmodels/dsh-speech 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,717 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
3
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
4
+ import WebSocket, { WebSocketServer } from "ws";
5
+
6
+ //#region src/shared.ts
7
+ const PLUGIN_NAME = "@allmodels/dsh-speech";
8
+ const SETTINGS_NAMESPACE = "dsh-speech";
9
+ const DEFAULT_API_KEY_ENV = "ALLMODELS_API_KEY";
10
+ const DEFAULT_BASE_URL = "https://api.allmodels.io";
11
+ const DEFAULT_LOW_BALANCE_USD = .5;
12
+ const DEFAULT_TOP_UP_USD = 10;
13
+ const CATALOG_TTL_MS = 300 * 1e3;
14
+ const AUDIO_FORMAT = "pcm_16000";
15
+ function stringArray(value) {
16
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
17
+ }
18
+ function finiteNumber(value) {
19
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
20
+ return Number.isFinite(parsed) ? parsed : void 0;
21
+ }
22
+ /** Normalize only bindings this client can feed with its fixed PCM16/16 kHz pipeline. */
23
+ function normalizeCatalog(raw, fetchedAt = Date.now()) {
24
+ const providersValue = (raw !== null && typeof raw === "object" ? raw : {}).providers;
25
+ const providers = providersValue !== null && typeof providersValue === "object" ? providersValue : {};
26
+ const bindings = [];
27
+ for (const [provider, value] of Object.entries(providers)) {
28
+ if (!Array.isArray(value.stt)) continue;
29
+ const defaultModel = typeof value.defaults?.stt?.model === "string" ? value.defaults.stt.model : void 0;
30
+ for (const candidate of value.stt) {
31
+ if (candidate.streaming !== true || candidate.streamingInput === void 0) continue;
32
+ if (!stringArray(candidate.streamingInput.audioFormats).includes(AUDIO_FORMAT)) continue;
33
+ if (typeof candidate.id !== "string" || candidate.id.length === 0) continue;
34
+ const options = stringArray(candidate.streamingInput.portableOptions);
35
+ const price = candidate.pricing?.unit === "minute" ? finiteNumber(candidate.pricing.unitPrice) : void 0;
36
+ const canonical = typeof candidate.canonical === "string" && candidate.canonical.includes("/") ? candidate.canonical : `${provider}/${candidate.id}`;
37
+ bindings.push({
38
+ provider,
39
+ model: canonical,
40
+ canonical,
41
+ isProviderDefault: candidate.id === defaultModel || canonical === defaultModel,
42
+ contextSupported: options.includes("context"),
43
+ interimResultsSupported: options.includes("interim_results"),
44
+ ...stringArray(candidate.languages).length === 0 ? {} : { languages: stringArray(candidate.languages) },
45
+ ...price === void 0 ? {} : { pricePerMinuteUsd: price }
46
+ });
47
+ }
48
+ }
49
+ bindings.sort((a, b) => a.model.localeCompare(b.model) || a.provider.localeCompare(b.provider));
50
+ return {
51
+ bindings,
52
+ fetchedAt
53
+ };
54
+ }
55
+ function selectBinding(bindings, locale, preferred) {
56
+ if (preferred?.model !== void 0) {
57
+ const preferredModel = preferred.model;
58
+ const matchesModel = (binding) => binding.model === preferredModel || binding.canonical === preferredModel || !preferredModel.includes("/") && binding.model.endsWith(`/${preferredModel}`);
59
+ const exact = bindings.find((binding) => matchesModel(binding) && (preferred.provider === void 0 || binding.provider === preferred.provider));
60
+ if (exact !== void 0) return exact;
61
+ const sameModel = bindings.find(matchesModel);
62
+ if (sameModel !== void 0) return sameModel;
63
+ }
64
+ const preferredProvider = locale.toLowerCase().startsWith("zh") ? "soniox" : "assemblyai";
65
+ return bindings.find((binding) => binding.provider === preferredProvider && binding.isProviderDefault) ?? bindings.find((binding) => binding.provider === preferredProvider) ?? bindings[0];
66
+ }
67
+ function targetAllows(targets, provider, model) {
68
+ const providerModel = model === void 0 || model.includes("/") ? model : `${provider}/${model}`;
69
+ if (targets === void 0 || targets === null) return true;
70
+ if (Array.isArray(targets)) {
71
+ if (targets.length === 0) return true;
72
+ return targets.some((target) => targetAllows(target, provider, model));
73
+ }
74
+ if (typeof targets === "string") return targets === "*" || targets === provider || targets === model || targets === providerModel;
75
+ if (typeof targets !== "object") return false;
76
+ const record = targets;
77
+ const providers = stringArray(record.providers ?? record.provider);
78
+ const models = stringArray(record.models ?? record.model);
79
+ const providerOk = providers.length === 0 || provider === void 0 || providers.includes(provider);
80
+ const modelOk = models.length === 0 || model === void 0 || models.includes(model) || providerModel !== void 0 && models.includes(providerModel);
81
+ return providerOk && modelOk;
82
+ }
83
+ function summarizeBalance(raw, lowThresholdUsd, selected, now = Date.now()) {
84
+ const value = raw !== null && typeof raw === "object" ? raw : {};
85
+ const paidUsd = finiteNumber(value.paid_balance_usd ?? value.spendable_paid_credits_usd) ?? 0;
86
+ const grantsValue = value.promotion_grants ?? value.promotional_grants ?? [];
87
+ const grants = Array.isArray(grantsValue) ? grantsValue : [];
88
+ let promotionUsd = 0;
89
+ for (const entry of grants) {
90
+ if (entry === null || typeof entry !== "object") continue;
91
+ const grant = entry;
92
+ const remaining = finiteNumber(grant.remaining_usd) ?? 0;
93
+ const eligible = grant.eligible !== false;
94
+ const expiresAt = typeof grant.expires_at === "string" ? Date.parse(grant.expires_at) : Number.POSITIVE_INFINITY;
95
+ if (eligible && expiresAt > now && targetAllows(grant.targets, selected?.provider, selected?.model)) promotionUsd += Math.max(0, remaining);
96
+ }
97
+ const usableUsd = Math.round((Math.max(0, paidUsd) + promotionUsd) * 1e9) / 1e9;
98
+ return {
99
+ ...typeof value.state === "string" ? { state: value.state } : {},
100
+ paidUsd: Math.max(0, paidUsd),
101
+ promotionUsd,
102
+ usableUsd,
103
+ low: usableUsd < lowThresholdUsd,
104
+ exhausted: usableUsd <= 0,
105
+ fetchedAt: now
106
+ };
107
+ }
108
+ const CJK = /^(zh|ja|ko)(-|$)/i;
109
+ function transcriptSeparator(left, right, language) {
110
+ if (left.length === 0 || right.length === 0) return "";
111
+ if (language !== void 0 && CJK.test(language)) return "";
112
+ if (/\s$/u.test(left) || /^\s/u.test(right)) return "";
113
+ if (/^[,.;:!?\u3000-\u303f\uff00-\uff65]/u.test(right)) return "";
114
+ return " ";
115
+ }
116
+ function appendTranscript(left, right, language) {
117
+ return `${left}${transcriptSeparator(left, right, language)}${right}`;
118
+ }
119
+ function createTranscript(base) {
120
+ return {
121
+ base,
122
+ finals: "",
123
+ partial: "",
124
+ lastSequence: -1
125
+ };
126
+ }
127
+ function transcriptText(state) {
128
+ return appendTranscript(appendTranscript(state.base, state.finals, state.language), state.partial, state.language);
129
+ }
130
+ function applyTranscriptEvent(state, event) {
131
+ if (event.sequence <= state.lastSequence) return state;
132
+ const language = event.languageCode ?? state.language;
133
+ if (event.kind === "partial") return {
134
+ ...state,
135
+ partial: event.text,
136
+ ...language === void 0 ? {} : { language },
137
+ lastSequence: event.sequence
138
+ };
139
+ return {
140
+ ...state,
141
+ finals: appendTranscript(state.finals, event.text, language),
142
+ partial: "",
143
+ ...language === void 0 ? {} : { language },
144
+ lastSequence: event.sequence
145
+ };
146
+ }
147
+
148
+ //#endregion
149
+ //#region src/allmodels.ts
150
+ const HTTP_TIMEOUT_MS = 15e3;
151
+ var AllModelsError = class extends Error {
152
+ status;
153
+ code;
154
+ constructor(status, code, message) {
155
+ super(message);
156
+ this.name = "AllModelsError";
157
+ this.status = status;
158
+ this.code = code;
159
+ }
160
+ };
161
+ function endpoint(baseURL, path) {
162
+ const base = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
163
+ return new URL(path.replace(/^\//u, ""), base);
164
+ }
165
+ function errorMessage(value, fallback) {
166
+ if (value === null || typeof value !== "object") return fallback;
167
+ const record = value;
168
+ const candidate = record.message ?? record.error ?? record.detail;
169
+ return typeof candidate === "string" && candidate.length <= 500 ? candidate : fallback;
170
+ }
171
+ async function requestJson(baseURL, path, init = {}, apiKey) {
172
+ const controller = new AbortController();
173
+ const timeout = setTimeout(() => {
174
+ controller.abort();
175
+ }, HTTP_TIMEOUT_MS);
176
+ try {
177
+ const headers = new Headers(init.headers);
178
+ headers.set("accept", "application/json");
179
+ if (init.body !== void 0) headers.set("content-type", "application/json");
180
+ if (apiKey !== void 0) headers.set("authorization", `Bearer ${apiKey}`);
181
+ const response = await fetch(endpoint(baseURL, path), {
182
+ ...init,
183
+ headers,
184
+ signal: controller.signal
185
+ });
186
+ const body = await response.json().catch(() => void 0);
187
+ if (!response.ok) throw new AllModelsError(response.status, `HTTP_${String(response.status)}`, errorMessage(body, "AllModels request failed"));
188
+ return body;
189
+ } catch (error) {
190
+ if (error instanceof AllModelsError) throw error;
191
+ if (controller.signal.aborted) throw new AllModelsError(504, "TIMEOUT", "AllModels request timed out");
192
+ throw new AllModelsError(502, "NETWORK", "Unable to reach AllModels");
193
+ } finally {
194
+ clearTimeout(timeout);
195
+ }
196
+ }
197
+ var AllModelsClient = class {
198
+ catalogCache;
199
+ async catalog(settings, force = false) {
200
+ const now = Date.now();
201
+ if (!force && this.catalogCache?.key === settings.baseURL && this.catalogCache.expiresAt > now) return this.catalogCache.value;
202
+ const value = normalizeCatalog(await requestJson(settings.baseURL, "/v1/providers"), now);
203
+ if (value.bindings.length === 0) throw new AllModelsError(502, "EMPTY_CATALOG", "No compatible streaming STT models are available");
204
+ this.catalogCache = {
205
+ key: settings.baseURL,
206
+ expiresAt: now + CATALOG_TTL_MS,
207
+ value
208
+ };
209
+ return value;
210
+ }
211
+ async balance(settings, apiKey) {
212
+ return summarizeBalance(await requestJson(settings.baseURL, "/account/balance", {}, apiKey), settings.lowBalanceUsd, {
213
+ ...settings.provider === void 0 ? {} : { provider: settings.provider },
214
+ ...settings.model === void 0 ? {} : { model: settings.model }
215
+ });
216
+ }
217
+ async startAuth(settings, email) {
218
+ await requestJson(settings.baseURL, "/account/agent-signup", {
219
+ method: "POST",
220
+ body: JSON.stringify({ email })
221
+ });
222
+ }
223
+ async verifyAuth(settings, email, code) {
224
+ const raw = await requestJson(settings.baseURL, "/account/agent-signup/verify", {
225
+ method: "POST",
226
+ body: JSON.stringify({
227
+ email,
228
+ code
229
+ })
230
+ });
231
+ if (raw === null || typeof raw !== "object" || typeof raw.apiKey !== "string") throw new AllModelsError(502, "INVALID_RESPONSE", "AllModels did not return an API key");
232
+ return raw.apiKey;
233
+ }
234
+ async topUp(settings, apiKey, amountUsd) {
235
+ const raw = await requestJson(settings.baseURL, "/account/top-up-link", {
236
+ method: "POST",
237
+ body: JSON.stringify({ amount_usd: amountUsd })
238
+ }, apiKey);
239
+ if (raw === null || typeof raw !== "object") throw new AllModelsError(502, "INVALID_RESPONSE", "Invalid top-up response");
240
+ const record = raw;
241
+ const url = record.url ?? record.top_up_url ?? record.checkout_url;
242
+ if (typeof url !== "string" || !/^https:\/\//u.test(url)) throw new AllModelsError(502, "INVALID_RESPONSE", "AllModels did not return a secure top-up link");
243
+ return {
244
+ url,
245
+ ...typeof record.expires_at === "string" ? { expiresAt: record.expires_at } : {}
246
+ };
247
+ }
248
+ };
249
+
250
+ //#endregion
251
+ //#region src/security.ts
252
+ const LOOPBACK = new Set([
253
+ "127.0.0.1",
254
+ "::1",
255
+ "::ffff:127.0.0.1",
256
+ "localhost"
257
+ ]);
258
+ function hostname(value) {
259
+ if (value === void 0) return "";
260
+ const trimmed = value.trim();
261
+ if (trimmed.startsWith("[")) {
262
+ const end = trimmed.indexOf("]");
263
+ return end === -1 ? "" : trimmed.slice(1, end);
264
+ }
265
+ const colon = trimmed.lastIndexOf(":");
266
+ return colon === -1 ? trimmed : trimmed.slice(0, colon);
267
+ }
268
+ /** Restrict privileged plugin routes to the loopback Harness page. */
269
+ function isTrustedRequest(req, method) {
270
+ if (method !== void 0 && req.method !== method) return false;
271
+ const peer = req.socket.remoteAddress;
272
+ if (peer === void 0 || !LOOPBACK.has(peer)) return false;
273
+ const host = typeof req.headers.host === "string" ? hostname(req.headers.host) : "";
274
+ if (!LOOPBACK.has(host)) return false;
275
+ const fetchSite = req.headers["sec-fetch-site"];
276
+ if (typeof fetchSite === "string" && fetchSite !== "same-origin" && fetchSite !== "none") return false;
277
+ const origin = req.headers.origin;
278
+ if (typeof origin === "string") try {
279
+ if (hostname(new URL(origin).host) !== host) return false;
280
+ } catch {
281
+ return false;
282
+ }
283
+ return true;
284
+ }
285
+
286
+ //#endregion
287
+ //#region src/stt-proxy.ts
288
+ const MAX_AUDIO_FRAME = 64 * 1024;
289
+ const MAX_CONTROL_FRAME = 4 * 1024;
290
+ const MAX_BUFFERED_BYTES = 1024 * 1024;
291
+ const START_TIMEOUT_MS = 1e4;
292
+ function parseStart(data) {
293
+ if (typeof data !== "string" && !Buffer.isBuffer(data)) return void 0;
294
+ const text = typeof data === "string" ? data : data.toString("utf8");
295
+ if (Buffer.byteLength(text) > MAX_CONTROL_FRAME) return void 0;
296
+ try {
297
+ const value = JSON.parse(text);
298
+ if (value === null || typeof value !== "object") return void 0;
299
+ const record = value;
300
+ if (record.type !== "start" || record.audioFormat !== AUDIO_FORMAT || typeof record.locale !== "string") return void 0;
301
+ return {
302
+ type: "start",
303
+ audioFormat: AUDIO_FORMAT,
304
+ locale: record.locale.slice(0, 64)
305
+ };
306
+ } catch {
307
+ return;
308
+ }
309
+ }
310
+ function send(socket, value) {
311
+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(value));
312
+ }
313
+ function closeWithError(socket, code, message) {
314
+ send(socket, {
315
+ type: "error",
316
+ code,
317
+ message
318
+ });
319
+ socket.close(1011, code.slice(0, 120));
320
+ }
321
+ function upstreamUrl(settings, binding) {
322
+ const base = new URL(settings.baseURL);
323
+ base.protocol = base.protocol === "http:" ? "ws:" : "wss:";
324
+ base.pathname = "/v1/stt";
325
+ base.search = "";
326
+ base.searchParams.set("provider", binding.provider);
327
+ base.searchParams.set("model", binding.model);
328
+ base.searchParams.set("audio_format", AUDIO_FORMAT);
329
+ base.searchParams.set("event_format", "normalized");
330
+ base.searchParams.set("commit_strategy", "auto");
331
+ if (binding.interimResultsSupported) base.searchParams.set("interim_results", "true");
332
+ if (settings.language !== void 0 && settings.language !== "" && settings.language !== "auto") base.searchParams.set("language", settings.language);
333
+ if (binding.contextSupported && settings.context !== void 0 && settings.context.trim() !== "") base.searchParams.set("context", settings.context.trim());
334
+ return base;
335
+ }
336
+ function normalizedEvent(data) {
337
+ try {
338
+ const text = typeof data === "string" ? data : data.toString("utf8");
339
+ const value = JSON.parse(text);
340
+ if (value === null || typeof value !== "object") return void 0;
341
+ const event = value;
342
+ const eventType = event.type ?? event.event;
343
+ const sequenceValue = event.sequence ?? event.seq;
344
+ const sequence = typeof sequenceValue === "number" ? sequenceValue : 0;
345
+ const languageCode = typeof event.language_code === "string" ? event.language_code : typeof event.languageCode === "string" ? event.languageCode : void 0;
346
+ if (eventType === "stt.transcript.partial" && typeof event.text === "string") return {
347
+ type: "partial",
348
+ sequence,
349
+ text: event.text,
350
+ ...languageCode === void 0 ? {} : { languageCode }
351
+ };
352
+ if (eventType === "stt.transcript.final" && typeof event.text === "string") return {
353
+ type: "final",
354
+ sequence,
355
+ text: event.text,
356
+ ...languageCode === void 0 ? {} : { languageCode }
357
+ };
358
+ if (eventType === "stt.error") return {
359
+ type: "error",
360
+ code: typeof event.code === "string" ? event.code : "UPSTREAM_ERROR",
361
+ message: typeof event.message === "string" ? event.message : "Speech recognition failed"
362
+ };
363
+ if (eventType === "stt.session.ended" || eventType === "stt.ended") return { type: "ended" };
364
+ return;
365
+ } catch {
366
+ return;
367
+ }
368
+ }
369
+ function rawDataBytes(data) {
370
+ if (typeof data === "string") return Buffer.byteLength(data);
371
+ if (data instanceof ArrayBuffer) return data.byteLength;
372
+ if (Array.isArray(data)) return data.reduce((sum, entry) => sum + entry.byteLength, 0);
373
+ return data.byteLength;
374
+ }
375
+ var SttProxy = class {
376
+ server = new WebSocketServer({
377
+ noServer: true,
378
+ maxPayload: MAX_AUDIO_FRAME
379
+ });
380
+ clients = /* @__PURE__ */ new Set();
381
+ constructor(options) {
382
+ this.options = options;
383
+ this.server.on("connection", (socket) => {
384
+ this.accept(socket);
385
+ });
386
+ }
387
+ handleUpgrade(req, socket, head) {
388
+ if (!isTrustedRequest(req)) {
389
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
390
+ socket.destroy();
391
+ return;
392
+ }
393
+ this.server.handleUpgrade(req, socket, head, (client) => {
394
+ this.server.emit("connection", client, req);
395
+ });
396
+ }
397
+ close() {
398
+ for (const client of this.clients) client.close(1001, "plugin disposed");
399
+ this.clients.clear();
400
+ this.server.close();
401
+ }
402
+ accept(client) {
403
+ this.clients.add(client);
404
+ let upstream;
405
+ let started = false;
406
+ let closing = false;
407
+ let clientClosed = false;
408
+ const startTimeout = setTimeout(() => {
409
+ if (!started) closeWithError(client, "START_TIMEOUT", "Speech session did not start in time");
410
+ }, START_TIMEOUT_MS);
411
+ const cleanup = () => {
412
+ clientClosed = true;
413
+ clearTimeout(startTimeout);
414
+ this.clients.delete(client);
415
+ if (upstream !== void 0 && upstream.readyState < WebSocket.CLOSING) upstream.close(1e3, "client closed");
416
+ };
417
+ client.once("close", cleanup);
418
+ client.on("error", (error) => {
419
+ this.options.warn(error);
420
+ });
421
+ client.on("message", (data, isBinary) => {
422
+ if (!started) {
423
+ if (isBinary) {
424
+ closeWithError(client, "EXPECTED_START", "The first speech frame must be a start message");
425
+ return;
426
+ }
427
+ const start = parseStart(data);
428
+ if (start === void 0) {
429
+ closeWithError(client, "INVALID_START", "Invalid speech start message");
430
+ return;
431
+ }
432
+ started = true;
433
+ clearTimeout(startTimeout);
434
+ this.openUpstream(client, start).then((value) => {
435
+ upstream = value;
436
+ if (clientClosed && value.readyState < WebSocket.CLOSING) value.close(1e3, "client closed");
437
+ }).catch((error) => {
438
+ closeWithError(client, "START_FAILED", error instanceof Error ? error.message : "Unable to start speech recognition");
439
+ });
440
+ return;
441
+ }
442
+ if (isBinary) {
443
+ if (rawDataBytes(data) > MAX_AUDIO_FRAME) {
444
+ closeWithError(client, "FRAME_TOO_LARGE", "Audio frame is too large");
445
+ return;
446
+ }
447
+ if (upstream?.readyState !== WebSocket.OPEN) return;
448
+ if (upstream.bufferedAmount > MAX_BUFFERED_BYTES) {
449
+ closeWithError(client, "BACKPRESSURE", "The speech provider cannot keep up with audio input");
450
+ return;
451
+ }
452
+ upstream.send(data, { binary: true });
453
+ return;
454
+ }
455
+ if (rawDataBytes(data) > MAX_CONTROL_FRAME || upstream?.readyState !== WebSocket.OPEN) return;
456
+ try {
457
+ const control = JSON.parse(data.toString("utf8"));
458
+ if (control.type === "commit") upstream.send(JSON.stringify({ type: "stt.audio.commit" }));
459
+ if (control.type === "close" && !closing) {
460
+ closing = true;
461
+ upstream.send(JSON.stringify({ type: "stt.session.close" }));
462
+ }
463
+ } catch {
464
+ closeWithError(client, "INVALID_CONTROL", "Invalid speech control message");
465
+ }
466
+ });
467
+ }
468
+ async openUpstream(client, start) {
469
+ const settings = this.options.settings();
470
+ const [credential, catalog] = await Promise.all([this.options.resolveCredential(), this.options.allModels.catalog(settings)]);
471
+ if (credential === void 0) throw new Error("Connect AllModels in Settings → Speech");
472
+ const binding = selectBinding(catalog.bindings, start.locale, {
473
+ ...settings.model === void 0 ? {} : { model: settings.model },
474
+ ...settings.provider === void 0 ? {} : { provider: settings.provider }
475
+ });
476
+ if (binding === void 0) throw new Error("No compatible streaming STT model is available");
477
+ const upstream = new WebSocket(upstreamUrl(settings, binding), { headers: { authorization: `Bearer ${credential.value}` } });
478
+ upstream.on("open", () => {
479
+ send(client, {
480
+ type: "ready",
481
+ model: binding.model,
482
+ provider: binding.provider
483
+ });
484
+ });
485
+ upstream.on("message", (data) => {
486
+ const event = normalizedEvent(data);
487
+ if (event !== void 0) send(client, event);
488
+ });
489
+ upstream.on("error", () => {
490
+ closeWithError(client, "UPSTREAM_CONNECTION", "The speech provider connection failed");
491
+ });
492
+ upstream.on("close", () => {
493
+ send(client, { type: "ended" });
494
+ if (client.readyState < WebSocket.CLOSING) client.close(1e3, "speech ended");
495
+ });
496
+ return upstream;
497
+ }
498
+ };
499
+
500
+ //#endregion
501
+ //#region src/index.ts
502
+ const name = PLUGIN_NAME;
503
+ const inject = ["webServer", "credentials"];
504
+ const SPEECH_SETTINGS_NS = settingsNamespace(SETTINGS_NAMESPACE);
505
+ const Config = z.object({
506
+ apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
507
+ baseURL: z.string().default(DEFAULT_BASE_URL),
508
+ lowBalanceUsd: z.number().min(0).default(DEFAULT_LOW_BALANCE_USD),
509
+ defaultTopUpUsd: z.number().min(5).max(1e3).default(DEFAULT_TOP_UP_USD),
510
+ model: z.string(),
511
+ provider: z.string(),
512
+ language: z.string(),
513
+ context: z.string()
514
+ });
515
+ const UserSettingsConfig = z.object({
516
+ model: z.string(),
517
+ provider: z.string(),
518
+ language: z.string(),
519
+ context: z.string()
520
+ });
521
+ const MAX_BODY = 16 * 1024;
522
+ async function readJson(req) {
523
+ let size = 0;
524
+ const chunks = [];
525
+ for await (const chunk of req) {
526
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
527
+ size += buffer.length;
528
+ if (size > MAX_BODY) throw new Error("request body too large");
529
+ chunks.push(buffer);
530
+ }
531
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
532
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("expected a JSON object");
533
+ return parsed;
534
+ }
535
+ function sendJson(res, status, body) {
536
+ res.writeHead(status, {
537
+ "content-type": "application/json; charset=utf-8",
538
+ "cache-control": "no-store",
539
+ "x-content-type-options": "nosniff"
540
+ });
541
+ res.end(JSON.stringify(body));
542
+ }
543
+ function safeError(error) {
544
+ if (error instanceof AllModelsError) {
545
+ const message = /bearer|authorization|secret|api.?key|\bsk-[A-Za-z0-9_-]+/iu.test(error.message) ? "AllModels rejected the credential" : error.message.slice(0, 500);
546
+ return {
547
+ status: error.status >= 400 && error.status < 600 ? error.status : 502,
548
+ body: { error: {
549
+ code: error.code,
550
+ message
551
+ } }
552
+ };
553
+ }
554
+ return {
555
+ status: 400,
556
+ body: { error: {
557
+ code: "INVALID_REQUEST",
558
+ message: error instanceof Error && !/api.?key|bearer|secret/i.test(error.message) ? error.message.slice(0, 500) : "Request failed"
559
+ } }
560
+ };
561
+ }
562
+ function emailField(body) {
563
+ const email = body.email;
564
+ if (typeof email !== "string" || email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email)) throw new Error("Enter a valid email address");
565
+ return email;
566
+ }
567
+ function validateSettings(value) {
568
+ const url = new URL(value.baseURL);
569
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error("baseURL must use HTTP or HTTPS");
570
+ if (value.language !== void 0 && value.language !== "auto" && !/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u.test(value.language)) throw new Error("language must be auto or a valid BCP-47 tag");
571
+ if (value.context !== void 0 && value.context.length > 4e3) throw new Error("context is limited to 4000 characters");
572
+ }
573
+ function apply(ctx, config) {
574
+ validateSettings(config);
575
+ const entrySettings = {
576
+ ...config.model === void 0 ? {} : { model: config.model },
577
+ ...config.provider === void 0 ? {} : { provider: config.provider },
578
+ ...config.language === void 0 ? {} : { language: config.language },
579
+ ...config.context === void 0 ? {} : { context: config.context }
580
+ };
581
+ let userSettingsSource = () => entrySettings;
582
+ const settingsSource = () => ({
583
+ ...config,
584
+ ...userSettingsSource()
585
+ });
586
+ installSettingsSection(ctx, SPEECH_SETTINGS_NS, UserSettingsConfig, entrySettings, {
587
+ setSource: (source) => {
588
+ userSettingsSource = source;
589
+ },
590
+ onChange: () => {},
591
+ validate: (value) => {
592
+ validateSettings({
593
+ baseURL: config.baseURL,
594
+ ...value
595
+ });
596
+ }
597
+ });
598
+ const allModels = new AllModelsClient();
599
+ const keyFor = () => credentialRef(settingsSource().apiKeyEnv);
600
+ const resolveCredential = () => ctx.credentials.resolve(keyFor());
601
+ const register = (method, path, handler) => {
602
+ ctx.effect(() => ctx.webServer.register({
603
+ kind: "exact",
604
+ path,
605
+ handler: async (req, res) => {
606
+ if (!isTrustedRequest(req, method)) {
607
+ sendJson(res, 403, { error: {
608
+ code: "FORBIDDEN",
609
+ message: "Forbidden"
610
+ } });
611
+ return;
612
+ }
613
+ try {
614
+ sendJson(res, 200, await handler(req));
615
+ } catch (error) {
616
+ const safe = safeError(error);
617
+ sendJson(res, safe.status, safe.body);
618
+ }
619
+ }
620
+ }), `${PLUGIN_NAME}: ${method} ${path}`);
621
+ };
622
+ register("GET", "/api/dsh-speech/status", async () => {
623
+ const settings = settingsSource();
624
+ const credential = await ctx.credentials.describe(keyFor());
625
+ const publicSettings = {
626
+ ...settings.model === void 0 ? {} : { model: settings.model },
627
+ ...settings.provider === void 0 ? {} : { provider: settings.provider },
628
+ ...settings.language === void 0 ? {} : { language: settings.language },
629
+ ...settings.context === void 0 ? {} : { context: settings.context },
630
+ lowBalanceUsd: settings.lowBalanceUsd,
631
+ defaultTopUpUsd: settings.defaultTopUpUsd
632
+ };
633
+ if (!credential.configured) return {
634
+ credential,
635
+ settings: publicSettings
636
+ };
637
+ const resolved = await resolveCredential();
638
+ if (resolved === void 0) return {
639
+ credential: {
640
+ ...credential,
641
+ configured: false
642
+ },
643
+ settings: publicSettings
644
+ };
645
+ try {
646
+ return {
647
+ credential,
648
+ settings: publicSettings,
649
+ balance: await allModels.balance(settings, resolved.value)
650
+ };
651
+ } catch (error) {
652
+ return {
653
+ credential,
654
+ settings: publicSettings,
655
+ balanceError: safeError(error).body.error.message
656
+ };
657
+ }
658
+ });
659
+ register("GET", "/api/dsh-speech/catalog", async (req) => {
660
+ const force = new URL(req.url ?? "/", "http://localhost").searchParams.get("refresh") === "1";
661
+ return allModels.catalog(settingsSource(), force);
662
+ });
663
+ register("POST", "/api/dsh-speech/auth/start", async (req) => {
664
+ await allModels.startAuth(settingsSource(), emailField(await readJson(req)));
665
+ return {
666
+ ok: true,
667
+ expiresInSeconds: 300
668
+ };
669
+ });
670
+ register("POST", "/api/dsh-speech/auth/verify", async (req) => {
671
+ const body = await readJson(req);
672
+ const code = body.code;
673
+ if (typeof code !== "string" || !/^\d{6}$/u.test(code)) throw new Error("Enter the six-digit code");
674
+ const key = await allModels.verifyAuth(settingsSource(), emailField(body), code);
675
+ await ctx.credentials.set(keyFor(), key);
676
+ return { ok: true };
677
+ });
678
+ register("POST", "/api/dsh-speech/auth/key", async (req) => {
679
+ const apiKey = (await readJson(req)).apiKey;
680
+ if (typeof apiKey !== "string" || apiKey.trim().length < 8 || apiKey.length > 2048) throw new Error("Enter a valid API key");
681
+ await allModels.balance(settingsSource(), apiKey.trim());
682
+ await ctx.credentials.set(keyFor(), apiKey.trim());
683
+ return { ok: true };
684
+ });
685
+ register("POST", "/api/dsh-speech/auth/logout", async () => {
686
+ if (!(await ctx.credentials.describe(keyFor())).writable) throw new Error("This credential is managed outside Harness and cannot be removed here");
687
+ await ctx.credentials.unset(keyFor());
688
+ return { ok: true };
689
+ });
690
+ register("POST", "/api/dsh-speech/top-up", async (req) => {
691
+ const amountUsd = (await readJson(req)).amountUsd;
692
+ if (typeof amountUsd !== "number" || !Number.isFinite(amountUsd) || amountUsd < 5 || amountUsd > 1e3) throw new Error("Top-up amount must be between $5 and $1000");
693
+ const credential = await resolveCredential();
694
+ if (credential === void 0) throw new Error("Connect AllModels first");
695
+ return allModels.topUp(settingsSource(), credential.value, amountUsd);
696
+ });
697
+ const proxy = new SttProxy({
698
+ settings: settingsSource,
699
+ resolveCredential,
700
+ allModels,
701
+ warn: (error) => {
702
+ ctx.logger.warn(error);
703
+ }
704
+ });
705
+ ctx.effect(() => ctx.webServer.registerUpgrade({
706
+ path: "/api/dsh-speech/stt",
707
+ handler: (req, socket, head) => {
708
+ proxy.handleUpgrade(req, socket, head);
709
+ }
710
+ }), `${PLUGIN_NAME}: WebSocket /api/dsh-speech/stt`);
711
+ ctx.effect(() => () => {
712
+ proxy.close();
713
+ }, `${PLUGIN_NAME}: close speech sockets`);
714
+ }
715
+
716
+ //#endregion
717
+ export { Config, SPEECH_SETTINGS_NS, UserSettingsConfig, appendTranscript, apply, applyTranscriptEvent, createTranscript, inject, name, normalizeCatalog, selectBinding, summarizeBalance, transcriptText };