arcane-os 0.40.1 → 0.41.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.
@@ -0,0 +1,587 @@
1
+ import Is from '../dependencies/strong-type/index.js';
2
+ import Queue from '../dependencies/js-queue/queue.js';
3
+
4
+ const is = new Is(false);
5
+ const INVOKE_URL = 'https://inference.do-ai.run/v1/async-invoke';
6
+ const SUBMISSION_RETRY_DELAY_MS = 1000;
7
+
8
+ function speechError(message, code, cause) {
9
+ const error = cause === undefined
10
+ ? new Error(message)
11
+ : new Error(
12
+ message,
13
+ {cause}
14
+ );
15
+ error.code = code;
16
+ return error;
17
+ }
18
+
19
+ function abortedSpeech(signal) {
20
+ const error = speechError(
21
+ 'The cloud speech request was cancelled.',
22
+ 'ARCANE_AI_REQUEST_ABORTED',
23
+ signal?.reason
24
+ );
25
+ error.name = 'AbortError';
26
+ return error;
27
+ }
28
+
29
+ function assertNotAborted(signal) {
30
+ if (signal?.aborted) throw abortedSpeech(signal);
31
+ }
32
+
33
+ function assertIdentifier(value, label) {
34
+ if (!is.string(value) || !value || value.trim() !== value) {
35
+ throw new TypeError(`${label} must be a nonempty string without surrounding whitespace.`);
36
+ }
37
+ }
38
+
39
+ function waitForStatus(delay, signal) {
40
+ assertNotAborted(signal);
41
+ return new Promise(
42
+ function waitForCloudSpeechStatus(resolve, reject) {
43
+ const timer = setTimeout(finishStatusWait, delay);
44
+ function finishStatusWait() {
45
+ signal.removeEventListener('abort', cancelStatusWait);
46
+ resolve();
47
+ }
48
+ function cancelStatusWait() {
49
+ clearTimeout(timer);
50
+ signal.removeEventListener('abort', cancelStatusWait);
51
+ reject(abortedSpeech(signal));
52
+ }
53
+ signal.addEventListener(
54
+ 'abort',
55
+ cancelStatusWait,
56
+ {once: true}
57
+ );
58
+ if (signal.aborted) cancelStatusWait();
59
+ }
60
+ );
61
+ }
62
+
63
+ function retryAfterDelay(response) {
64
+ const value = response.headers?.get?.('Retry-After');
65
+ if (!value?.trim()) return null;
66
+ const seconds = Number(value);
67
+ if (is.finite(seconds) && seconds >= 0) return seconds * 1000;
68
+ const date = Date.parse(value);
69
+ return is.finite(date) ? Math.max(0, date - Date.now()) : null;
70
+ }
71
+
72
+ async function requireSuccessfulResponse(response, signal, operation) {
73
+ if (response.ok) return response;
74
+ const detail = await response.text();
75
+ assertNotAborted(signal);
76
+ throw speechError(
77
+ `TWiN Cloud speech ${operation} failed with HTTP ${response.status}${detail ? `: ${detail}` : '.'}`,
78
+ 'ARCANE_AI_CLOUD_SPEECH_HTTP_ERROR'
79
+ );
80
+ }
81
+
82
+ /**
83
+ * A remote provider/2 TTS adapter. The application selects the model and voice,
84
+ * and getApiKey returns its current credential or the application's existing
85
+ * refresh promise. Credentials never enter status or configuration. The runtime
86
+ * owns whole-job admission and playback order. This provider owns immediate
87
+ * submissions and one configurable queue for follow-up HTTP/body operations.
88
+ */
89
+ export function createTwinCloudTTSProvider({
90
+ id,
91
+ model,
92
+ getApiKey,
93
+ maxConcurrentRequests = 4,
94
+ followUpQueue: {maxConcurrentRequests: followUpCapacity = 4, intervalMs = 250} = {},
95
+ fetch: fetchImpl = globalThis.fetch
96
+ } = {}) {
97
+ assertIdentifier(id, 'TWiN Cloud speech provider id');
98
+ assertIdentifier(model?.id, 'TWiN Cloud speech model id');
99
+ assertIdentifier(model?.defaultVoice, 'TWiN Cloud speech default voice');
100
+ if (!is.function(getApiKey)) {
101
+ throw new TypeError('TWiN Cloud speech getApiKey must be a function.');
102
+ }
103
+ if (!is.function(fetchImpl)) {
104
+ throw new TypeError('TWiN Cloud speech requires Fetch.');
105
+ }
106
+ if (!is.safeInteger(maxConcurrentRequests) || maxConcurrentRequests < 1) {
107
+ throw new RangeError('TWiN Cloud speech maxConcurrentRequests must be a positive safe integer.');
108
+ }
109
+ if (!is.safeInteger(followUpCapacity) || followUpCapacity < 1) {
110
+ throw new RangeError('TWiN Cloud speech followUpQueue.maxConcurrentRequests must be a positive safe integer.');
111
+ }
112
+ if (!is.finite(intervalMs) || intervalMs < 0) {
113
+ throw new RangeError('TWiN Cloud speech followUpQueue.intervalMs must be a nonnegative finite number.');
114
+ }
115
+
116
+ const modelId = model.id;
117
+ const defaultVoice = model.defaultVoice;
118
+ const activeRequests = new Set();
119
+ const credentialWaits = new Set();
120
+ let state = 'unloaded';
121
+ let generation = 0;
122
+ let unloadOperation = null;
123
+ let disposeOperation = null;
124
+ const followUps = new Queue();
125
+ followUps.autoRun = false;
126
+ let activeFollowUps = 0;
127
+ let nextFollowUpStart = 0;
128
+ let followUpTimer = null;
129
+
130
+ function dispatchFollowUps() {
131
+ clearTimeout(followUpTimer);
132
+ followUpTimer = null;
133
+ if (state !== 'ready' || activeFollowUps >= followUpCapacity || !followUps.size) return;
134
+
135
+ const now = Date.now();
136
+ const eligibleIndex = followUps.contents.findIndex(
137
+ function eligibleFollowUp(task) {return task.eligibleAt <= now;}
138
+ );
139
+ if (now < nextFollowUpStart || eligibleIndex === -1) {
140
+ const firstEligibility = followUps.contents.reduce(
141
+ function earliestFollowUp(time, task) {return Math.min(time, task.eligibleAt);},
142
+ Infinity
143
+ );
144
+ // Long Retry-After dates may exceed the platform timer's range.
145
+ // Revisit at that boundary without shortening the job's eligibility.
146
+ followUpTimer = setTimeout(dispatchFollowUps, Math.min(2147483647, Math.max(nextFollowUpStart, firstEligibility) - now));
147
+ return;
148
+ }
149
+ // A job's Retry-After does not hold up eligible siblings. Queue remains
150
+ // the sole pending-task owner, with FIFO order among eligible entries.
151
+ if (eligibleIndex > 0) {
152
+ const [eligibleTask] = followUps.contents.splice(eligibleIndex, 1);
153
+ followUps.contents.unshift(eligibleTask);
154
+ }
155
+ followUps.next();
156
+ if (followUps.size && activeFollowUps < followUpCapacity) {
157
+ followUpTimer = setTimeout(dispatchFollowUps, Math.min(2147483647, Math.max(0, nextFollowUpStart - Date.now())));
158
+ }
159
+ }
160
+
161
+ function queueFollowUp(readResponse, signal, eligibleAt = 0) {
162
+ assertNotAborted(signal);
163
+ return new Promise(function enqueueCloudSpeechFollowUp(resolve, reject) {
164
+ function finishFollowUp() {
165
+ activeFollowUps -= 1;
166
+ dispatchFollowUps();
167
+ }
168
+ function cancelQueuedFollowUp() {
169
+ followUps.contents = followUps.contents.filter(
170
+ function keepOtherFollowUp(task) {return task !== startFollowUp;}
171
+ );
172
+ signal.removeEventListener('abort', cancelQueuedFollowUp);
173
+ reject(abortedSpeech(signal));
174
+ dispatchFollowUps();
175
+ }
176
+ async function consumeFollowUp() {
177
+ assertNotAborted(signal);
178
+ return await readResponse();
179
+ }
180
+ function startFollowUp() {
181
+ signal.removeEventListener('abort', cancelQueuedFollowUp);
182
+ activeFollowUps += 1;
183
+ // js-queue dispatches functions; it does not observe promises.
184
+ // Keep the slot until Fetch AND its complete body have settled.
185
+ const response = consumeFollowUp();
186
+ nextFollowUpStart = Date.now() + intervalMs;
187
+ response.then(
188
+ function acceptFollowUp(value) {finishFollowUp(); resolve(value);},
189
+ function rejectFollowUp(error) {finishFollowUp(); reject(error);}
190
+ );
191
+ }
192
+ startFollowUp.eligibleAt = eligibleAt;
193
+ followUps.add(startFollowUp);
194
+ signal.addEventListener('abort', cancelQueuedFollowUp, {once: true});
195
+ if (signal.aborted) cancelQueuedFollowUp();
196
+ else dispatchFollowUps();
197
+ });
198
+ }
199
+
200
+ function authority() {
201
+ return {
202
+ protocol: 'arcane-ai-model-authority/1',
203
+ providerId: id,
204
+ modelId,
205
+ role: 'tts',
206
+ localOnly: false
207
+ };
208
+ }
209
+
210
+ function readApiKey(signal) {
211
+ assertNotAborted(signal);
212
+ return new Promise(
213
+ function waitForCloudSpeechCredential(resolve, reject) {
214
+ let settled = false;
215
+ function finishCredentialWait() {
216
+ settled = true;
217
+ credentialWaits.delete(cancelCredentialWait);
218
+ signal?.removeEventListener('abort', cancelCredentialWait);
219
+ }
220
+ function rejectCredential(error) {
221
+ if (settled) return;
222
+ finishCredentialWait();
223
+ reject(error);
224
+ }
225
+ function acceptCredential(apiKey) {
226
+ if (settled) return;
227
+ if (!is.string(apiKey) || !apiKey.trim()) {
228
+ rejectCredential(
229
+ speechError(
230
+ 'A TWiN Cloud inference access key is required for cloud speech.',
231
+ 'ARCANE_AI_CLOUD_SPEECH_KEY_REQUIRED'
232
+ )
233
+ );
234
+ return;
235
+ }
236
+ finishCredentialWait();
237
+ resolve(apiKey);
238
+ }
239
+ function cancelCredentialWait() {
240
+ rejectCredential(abortedSpeech(signal));
241
+ }
242
+ credentialWaits.add(cancelCredentialWait);
243
+ signal?.addEventListener(
244
+ 'abort',
245
+ cancelCredentialWait,
246
+ {once: true}
247
+ );
248
+ if (signal?.aborted) {
249
+ cancelCredentialWait();
250
+ return;
251
+ }
252
+ try {
253
+ Promise.resolve(getApiKey()).then(acceptCredential, rejectCredential);
254
+ } catch (error) {
255
+ rejectCredential(error);
256
+ }
257
+ }
258
+ );
259
+ }
260
+
261
+ function assertSelection(selection) {
262
+ if (selection && (selection.providerId !== id
263
+ || selection.modelId !== modelId
264
+ || selection.localOnly === true
265
+ || (selection.role !== undefined && selection.role !== 'tts'))) {
266
+ throw speechError(
267
+ 'The selected cloud speech model does not match this provider.',
268
+ 'ARCANE_AI_MODEL_AUTHORITY_REQUIRED'
269
+ );
270
+ }
271
+ }
272
+
273
+ function assertContext(context) {
274
+ if (context.role !== undefined && context.role !== 'tts') {
275
+ throw speechError('This provider supports only TTS.', 'ARCANE_AI_INVALID_REQUEST');
276
+ }
277
+ assertSelection(context.selection);
278
+ assertNotAborted(context.signal);
279
+ }
280
+
281
+ function status() {
282
+ const loaded = state === 'ready';
283
+ return {
284
+ providerId: id,
285
+ role: 'tts',
286
+ modelId,
287
+ localOnly: false,
288
+ state,
289
+ loaded,
290
+ busy: activeRequests.size > 0,
291
+ execution: {
292
+ requestedDevice: 'remote',
293
+ selectedDevice: loaded ? 'remote' : null,
294
+ maxConcurrentRequests,
295
+ activeRequestCount: activeRequests.size
296
+ }
297
+ };
298
+ }
299
+
300
+ async function fetchInference(url, options, operation, eligibleAt = 0) {
301
+ const signal = options.signal;
302
+ let retryError = null;
303
+ async function readInferenceResponse() {
304
+ let response;
305
+ try {
306
+ response = await fetchImpl(url, options);
307
+ } catch (error) {
308
+ assertNotAborted(signal);
309
+ // A lost POST response may represent an accepted paid job. Only
310
+ // an identified job's status read can retry a network failure.
311
+ return {error, retryable: options.method === 'GET' && error instanceof TypeError};
312
+ }
313
+ const nextEligibility = Date.now() + (retryAfterDelay(response) ?? 0);
314
+ try {
315
+ await requireSuccessfulResponse(response, signal, operation);
316
+ } catch (error) {
317
+ assertNotAborted(signal);
318
+ return {error, retryable: response.status === 429, eligibleAt: nextEligibility};
319
+ }
320
+ // Parsing errors are not Fetch failures and must not replay a job.
321
+ const result = await response.json();
322
+ assertNotAborted(signal);
323
+ return {result, eligibleAt: nextEligibility};
324
+ }
325
+ while (true) {
326
+ assertNotAborted(signal);
327
+ const outcome = options.method === 'POST'
328
+ ? await readInferenceResponse()
329
+ : await queueFollowUp(readInferenceResponse, signal, eligibleAt);
330
+ assertNotAborted(signal);
331
+ if (!outcome.error) return outcome;
332
+ if (retryError || !outcome.retryable) throw outcome.error;
333
+ retryError = outcome.error;
334
+ console.warn(`Retrying the cloud speech ${operation} once.`, retryError);
335
+ eligibleAt = outcome.eligibleAt ?? 0;
336
+ if (options.method === 'POST') {
337
+ // A readable submission rejection retains its own bounded retry
338
+ // delay. It never delays another job or enters the GET queue.
339
+ await waitForStatus(Math.max(SUBMISSION_RETRY_DELAY_MS, eligibleAt - Date.now()), signal);
340
+ }
341
+ }
342
+ }
343
+
344
+ async function synthesize(payload, controller, requestGeneration) {
345
+ const signal = controller.signal;
346
+ const apiKey = await readApiKey(signal);
347
+ assertNotAborted(signal);
348
+ const headers = {Authorization: `Bearer ${apiKey}`};
349
+ let response = await fetchInference(
350
+ INVOKE_URL,
351
+ {
352
+ method: 'POST',
353
+ headers: {...headers, 'Content-Type': 'application/json'},
354
+ body: JSON.stringify(
355
+ {
356
+ model_id: modelId,
357
+ input: {
358
+ text: payload.input,
359
+ voice: payload.voice ?? defaultVoice,
360
+ speed: payload.speed ?? 1
361
+ }
362
+ }
363
+ ),
364
+ signal
365
+ },
366
+ 'submission'
367
+ );
368
+ let result = response.result;
369
+ assertNotAborted(signal);
370
+ const requestId = result?.request_id;
371
+ while (result?.status === 'QUEUED' || result?.status === 'IN_PROGRESS') {
372
+ if (!is.string(requestId) || !requestId) {
373
+ throw speechError(
374
+ 'TWiN Cloud speech returned a pending job without a request_id.',
375
+ 'ARCANE_AI_INVALID_PROVIDER_RESULT'
376
+ );
377
+ }
378
+ response = await fetchInference(
379
+ `${INVOKE_URL}/${encodeURIComponent(requestId)}`,
380
+ {method: 'GET', headers, signal},
381
+ 'status read',
382
+ response.eligibleAt
383
+ );
384
+ result = response.result;
385
+ assertNotAborted(signal);
386
+ }
387
+ if (result?.status === 'FAILED') {
388
+ throw speechError(
389
+ `TWiN Cloud speech generation failed: ${JSON.stringify(result)}`,
390
+ 'ARCANE_AI_CLOUD_SPEECH_GENERATION_FAILED'
391
+ );
392
+ }
393
+ if (result?.status !== 'COMPLETED' || !is.string(result.output?.audio?.url)
394
+ || !result.output.audio.url) {
395
+ throw speechError(
396
+ `TWiN Cloud speech returned an unsupported result: ${JSON.stringify(result)}`,
397
+ 'ARCANE_AI_INVALID_PROVIDER_RESULT'
398
+ );
399
+ }
400
+ assertNotAborted(signal);
401
+ // The returned media URL has its own delivery authority; never forward the inference key.
402
+ const audio = await queueFollowUp(
403
+ async function downloadCloudSpeechAudio() {
404
+ const audioResponse = await fetchImpl(result.output.audio.url, {signal});
405
+ await requireSuccessfulResponse(audioResponse, signal, 'audio download');
406
+ return await audioResponse.blob();
407
+ },
408
+ signal,
409
+ response.eligibleAt
410
+ );
411
+ assertNotAborted(signal);
412
+ if (generation !== requestGeneration || state !== 'ready') {
413
+ throw speechError(
414
+ 'The cloud speech result was superseded by a provider transition.',
415
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
416
+ );
417
+ }
418
+ return audio;
419
+ }
420
+
421
+ const provider = {
422
+ protocol: 'arcane-ai-provider/2',
423
+ role: 'tts',
424
+ id,
425
+ localOnly: false,
426
+ maxConcurrentRequests,
427
+
428
+ catalog() {
429
+ return [
430
+ {
431
+ id: modelId,
432
+ providerId: id,
433
+ role: 'tts',
434
+ localOnly: false,
435
+ defaultVoice,
436
+ speech: {responseFormats: ['provider-native'], defaultResponseFormat: 'provider-native'}
437
+ }
438
+ ];
439
+ },
440
+
441
+ async inspect(selection, context = {}) {
442
+ assertContext(context);
443
+ if (state === 'disposed' || disposeOperation) {
444
+ return {
445
+ available: false,
446
+ code: 'ARCANE_AI_PROVIDER_DISPOSED',
447
+ message: 'The cloud speech provider is disposed.'
448
+ };
449
+ }
450
+ assertSelection(selection);
451
+ const inspectionGeneration = generation;
452
+ await readApiKey(context.signal);
453
+ assertNotAborted(context.signal);
454
+ if (generation !== inspectionGeneration) {
455
+ throw speechError('Cloud speech inspection was superseded.', 'ARCANE_AI_OPERATION_SUPERSEDED');
456
+ }
457
+ return {available: true, authority: authority()};
458
+ },
459
+
460
+ status,
461
+
462
+ async load(context = {}) {
463
+ assertContext(context);
464
+ if (state === 'disposed' || disposeOperation) {
465
+ throw speechError('The cloud speech provider is disposed.', 'ARCANE_AI_PROVIDER_DISPOSED');
466
+ }
467
+ if (unloadOperation) {
468
+ throw speechError('The cloud speech provider is unloading.', 'ARCANE_AI_OPERATION_SUPERSEDED');
469
+ }
470
+ const loadGeneration = generation;
471
+ if (state !== 'ready') state = 'loading';
472
+ try {
473
+ await readApiKey(context.signal);
474
+ assertNotAborted(context.signal);
475
+ if (generation !== loadGeneration) {
476
+ throw speechError('Cloud speech activation was superseded.', 'ARCANE_AI_OPERATION_SUPERSEDED');
477
+ }
478
+ } catch (error) {
479
+ if (generation === loadGeneration && state === 'loading') state = 'unloaded';
480
+ throw error;
481
+ }
482
+ state = 'ready';
483
+ return status();
484
+ },
485
+
486
+ async request(context = {}) {
487
+ assertContext(context);
488
+ if (context.role !== 'tts' || context.operation !== 'synthesize') {
489
+ throw speechError('Cloud speech supports only TTS synthesis.', 'ARCANE_AI_INVALID_REQUEST');
490
+ }
491
+ if (state !== 'ready' || unloadOperation || disposeOperation) {
492
+ throw speechError('The cloud speech provider is not ready.', 'ARCANE_AI_NOT_READY');
493
+ }
494
+ if (activeRequests.size >= maxConcurrentRequests) {
495
+ throw speechError('The cloud speech provider is at capacity.', 'ARCANE_AI_PROVIDER_BUSY');
496
+ }
497
+ const payload = context.payload;
498
+ if (!is.string(payload?.input) || !payload.input.trim()) {
499
+ throw new TypeError('Cloud speech input must be a nonempty string.');
500
+ }
501
+ if (payload.model !== undefined && payload.model !== modelId) {
502
+ throw speechError('The requested cloud speech model differs from the selection.', 'ARCANE_AI_MODEL_AUTHORITY_REQUIRED');
503
+ }
504
+ if (payload.voice !== undefined) assertIdentifier(payload.voice, 'Cloud speech voice');
505
+ if (payload.responseFormat !== undefined && payload.responseFormat !== 'provider-native') {
506
+ throw speechError('Cloud speech returns the provider-native audio format.', 'ARCANE_AI_UNSUPPORTED_RESPONSE_FORMAT');
507
+ }
508
+ const speed = payload.speed ?? 1;
509
+ if (!is.finite(speed) || speed < 0.7 || speed > 1.2) {
510
+ const error = new RangeError('Cloud speech speed must be from 0.7 through 1.2.');
511
+ error.code = 'ARCANE_AI_TTS_SPEED_INVALID';
512
+ throw error;
513
+ }
514
+
515
+ const controller = new AbortController();
516
+ function cancelCloudSpeechRequest() {
517
+ controller.abort(context.signal.reason);
518
+ }
519
+ context.signal?.addEventListener(
520
+ 'abort',
521
+ cancelCloudSpeechRequest,
522
+ {once: true}
523
+ );
524
+ if (context.signal?.aborted) cancelCloudSpeechRequest();
525
+ const request = {controller, promise: null};
526
+ activeRequests.add(request);
527
+ request.promise = synthesize(payload, controller, generation);
528
+ try {
529
+ return await request.promise;
530
+ } catch (error) {
531
+ if (controller.signal.aborted) throw abortedSpeech(controller.signal);
532
+ throw error;
533
+ } finally {
534
+ context.signal?.removeEventListener('abort', cancelCloudSpeechRequest);
535
+ activeRequests.delete(request);
536
+ }
537
+ },
538
+
539
+ unload(context = {}) {
540
+ assertContext(context);
541
+ if (state === 'disposed') {
542
+ return Promise.resolve(
543
+ status()
544
+ );
545
+ }
546
+ if (unloadOperation) return unloadOperation;
547
+ state = 'unloading';
548
+ generation += 1;
549
+ for (const cancelCredentialWait of [...credentialWaits]) cancelCredentialWait();
550
+ const requests = [...activeRequests];
551
+ for (const request of requests) request.controller.abort('Cloud speech provider unloaded.');
552
+ unloadOperation = Promise.allSettled(
553
+ requests.map(
554
+ function pendingCloudSpeechRequest(request) {
555
+ return request.promise;
556
+ }
557
+ )
558
+ ).then(
559
+ function finishCloudSpeechUnload() {
560
+ state = 'unloaded';
561
+ unloadOperation = null;
562
+ return status();
563
+ }
564
+ );
565
+ return unloadOperation;
566
+ },
567
+
568
+ dispose(context = {}) {
569
+ assertContext(context);
570
+ if (state === 'disposed') {
571
+ return Promise.resolve(
572
+ status()
573
+ );
574
+ }
575
+ if (disposeOperation) return disposeOperation;
576
+ disposeOperation = provider.unload(context).then(
577
+ function finishCloudSpeechDispose() {
578
+ state = 'disposed';
579
+ disposeOperation = null;
580
+ return status();
581
+ }
582
+ );
583
+ return disposeOperation;
584
+ }
585
+ };
586
+ return provider;
587
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Brandon Nozaki Miller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.