arcane-os 0.1.2 → 0.2.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/CHANGELOG.md +17 -0
- package/NOTICE +5 -3
- package/README.md +73 -24
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +67 -18
- package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +16 -5
- package/browser-runtime/ai/browser-kokoro-worker.mjs +3 -0
- package/browser-runtime/ai/browser-speech-artifacts.mjs +1108 -0
- package/browser-runtime/ai/browser-speech-providers.mjs +475 -0
- package/browser-runtime/ai/browser-speech.mjs +9 -0
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1537 -167
- package/browser-runtime/ai/browser-wasm.mjs +46 -1
- package/browser-runtime/ai/browser-whisper-worker.mjs +3 -0
- package/browser-runtime/ai/browser-wllama-runtime.mjs +677 -132
- package/browser-runtime/ai/model-controller.mjs +138 -12
- package/browser-runtime/ai/speech-worker-client.mjs +207 -0
- package/browser-runtime/ai/speech-worker-runtime.mjs +516 -0
- package/browser-runtime/ai/wllama/index.mjs +389 -0
- package/docs/architecture.md +132 -22
- package/docs/reference/README.md +1 -1
- package/docs/reference/ai/browser-wasm.md +101 -42
- package/docs/reference/availability-and-normalization.md +19 -5
- package/docs/reference/behavioral-testing.md +18 -5
- package/docs/reference/cli.md +2 -2
- package/docs/reference/inventory/package-api.json +14 -14
- package/docs/reference/protocols.md +4 -4
- package/docs/reference/sdk-api.md +68 -38
- package/docs/work-amplification.md +8 -4
- package/package.json +7 -3
- package/runtime/ARCANE_RUNTIME_RELEASE.json +50 -20
- package/runtime/arcane/components/chat.html +280 -62
- package/runtime/arcane/components/speech.html +1113 -265
- package/runtime/arcane/entities/Chat.js +246 -43
- package/runtime/arcane/modules/AI.js +713 -162
- package/runtime/arcane/modules/AIProviderRuntime.js +2289 -0
- package/runtime/arcane/modules/AIRuntimeState.js +872 -0
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +293 -27
- package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +682 -0
- package/runtime/arcane/modules/DocumentLexicalSearch.js +292 -0
- package/runtime/arcane/modules/PersistentAIChatSession.js +268 -0
- package/runtime/arcane/modules/StaticDocumentCatalog.js +25 -206
- package/schemas/arcane-lock.schema.json +6 -4
- package/src/cli/main.mjs +14 -2
- package/src/constants.mjs +1 -1
- package/src/dev-server.mjs +244 -13
- package/src/import-map.mjs +59 -1
- package/src/packager/core.mjs +2 -2
- package/src/runtime.mjs +14 -4
- package/src/sdk-browser-runtime.mjs +28 -75
- package/src/templates/workspace-template.mjs +4 -4
- package/src/toolchain.mjs +3 -0
- package/src/workspace-runtime.mjs +1 -1
- package/src/workspace.mjs +1 -1
|
@@ -0,0 +1,2289 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AI_RUNTIME_ROLES,
|
|
3
|
+
getAIRuntimeState,
|
|
4
|
+
publishAIRuntimeRoleState,
|
|
5
|
+
publishAIRuntimeRolesState,
|
|
6
|
+
startAIRuntime,
|
|
7
|
+
subscribeAIRuntimeIntents
|
|
8
|
+
} from './AIRuntimeState.js';
|
|
9
|
+
|
|
10
|
+
export const AI_PROVIDER_PROTOCOL = 'arcane-ai-provider/2';
|
|
11
|
+
export const AI_PROVIDER_RUNTIME_PROTOCOL = 'arcane-ai-runtime/2';
|
|
12
|
+
export const AI_MODEL_AUTHORITY_PROTOCOL = 'arcane-ai-model-authority/1';
|
|
13
|
+
|
|
14
|
+
const ROLE_SET = new Set(AI_RUNTIME_ROLES);
|
|
15
|
+
const PROVIDER_METHODS = Object.freeze([
|
|
16
|
+
'catalog',
|
|
17
|
+
'inspect',
|
|
18
|
+
'status',
|
|
19
|
+
'load',
|
|
20
|
+
'request',
|
|
21
|
+
'unload',
|
|
22
|
+
'dispose'
|
|
23
|
+
]);
|
|
24
|
+
const ROLE_OPERATIONS = Object.freeze(
|
|
25
|
+
{
|
|
26
|
+
llm: Object.freeze(['chat', 'stream']),
|
|
27
|
+
stt: Object.freeze(['transcribe']),
|
|
28
|
+
tts: Object.freeze(['synthesize'])
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
const ROLE_OPERATION_SETS = Object.freeze(
|
|
32
|
+
{
|
|
33
|
+
llm: new Set(ROLE_OPERATIONS.llm),
|
|
34
|
+
stt: new Set(ROLE_OPERATIONS.stt),
|
|
35
|
+
tts: new Set(ROLE_OPERATIONS.tts)
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
const IDENTIFIER_LIMIT = 128;
|
|
39
|
+
const ERROR_MESSAGE_LIMIT = 512;
|
|
40
|
+
const STREAM_CLEANUP_TIMEOUT_MS = 2_000;
|
|
41
|
+
const ROUTE_KEYS = Object.freeze(['default', 'localOnly']);
|
|
42
|
+
const RUNTIME_CONSTRUCTION_AUTHORITY = Object.freeze({});
|
|
43
|
+
const PROVIDER_RECORDS = new WeakMap();
|
|
44
|
+
|
|
45
|
+
function fail(message, code = 'ARCANE_AI_PROVIDER_RUNTIME_INVALID') {
|
|
46
|
+
const error = new TypeError(message);
|
|
47
|
+
error.code = code;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function operationError(message, code, cause) {
|
|
52
|
+
const error = cause === undefined
|
|
53
|
+
? new Error(message)
|
|
54
|
+
: new Error(message, {cause});
|
|
55
|
+
error.code = code;
|
|
56
|
+
return error;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizedAbort(cause) {
|
|
60
|
+
if (cause?.code === 'ARCANE_AI_REQUEST_ABORTED'
|
|
61
|
+
&& cause?.name === 'AbortError') {
|
|
62
|
+
return cause;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const error = operationError(
|
|
66
|
+
'The AI provider operation was cancelled.',
|
|
67
|
+
'ARCANE_AI_REQUEST_ABORTED',
|
|
68
|
+
cause
|
|
69
|
+
);
|
|
70
|
+
error.name = 'AbortError';
|
|
71
|
+
return error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isAbort(error, signal) {
|
|
75
|
+
return signal?.aborted
|
|
76
|
+
|| error?.name === 'AbortError'
|
|
77
|
+
|| error?.code === 'AI_REQUEST_ABORTED'
|
|
78
|
+
|| error?.code === 'ARCANE_AI_REQUEST_ABORTED'
|
|
79
|
+
|| error?.code === 'ARCANE_REQUEST_ABORTED';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function awaitBoundedStreamCleanup(operation) {
|
|
83
|
+
let timer = null;
|
|
84
|
+
const timeout = new Promise(function resolveBoundedAIStreamCleanup(resolve) {
|
|
85
|
+
timer = setTimeout(
|
|
86
|
+
function settleAIStreamCleanupTimeout() {
|
|
87
|
+
resolve({completed: false, results: null});
|
|
88
|
+
},
|
|
89
|
+
STREAM_CLEANUP_TIMEOUT_MS
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
return await Promise.race([
|
|
94
|
+
Promise.resolve(operation).then(
|
|
95
|
+
function settleAIStreamCleanup(results) {
|
|
96
|
+
return {completed: true, results};
|
|
97
|
+
}
|
|
98
|
+
),
|
|
99
|
+
timeout
|
|
100
|
+
]);
|
|
101
|
+
} finally {
|
|
102
|
+
if (timer !== null) {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function assertStreamCleanupComplete(outcome) {
|
|
109
|
+
const rejected = outcome.completed
|
|
110
|
+
&& Array.isArray(outcome.results)
|
|
111
|
+
&& outcome.results.some(function hasRejectedAIStreamCleanup(result) {
|
|
112
|
+
return result.status === 'rejected';
|
|
113
|
+
});
|
|
114
|
+
if (!outcome.completed || rejected) {
|
|
115
|
+
throw operationError(
|
|
116
|
+
'The AI provider stream did not confirm bounded cleanup.',
|
|
117
|
+
'ARCANE_AI_STREAM_CLEANUP_INCOMPLETE'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function assertRole(role) {
|
|
123
|
+
if (!ROLE_SET.has(role)) {
|
|
124
|
+
fail(`AI provider role must be one of ${AI_RUNTIME_ROLES.join(', ')}.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertIdentifier(value, label) {
|
|
129
|
+
if (typeof value !== 'string'
|
|
130
|
+
|| value.length < 1
|
|
131
|
+
|| value.length > IDENTIFIER_LIMIT
|
|
132
|
+
|| value.trim() !== value) {
|
|
133
|
+
fail(`${label} must be a trimmed 1-${IDENTIFIER_LIMIT} character string.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function assertAbortSignal(signal) {
|
|
138
|
+
if (signal === null || signal === undefined) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (typeof signal !== 'object'
|
|
143
|
+
|| typeof signal.aborted !== 'boolean'
|
|
144
|
+
|| typeof signal.addEventListener !== 'function'
|
|
145
|
+
|| typeof signal.removeEventListener !== 'function') {
|
|
146
|
+
fail('AI provider operation signal must be an AbortSignal.');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function assertPlainObject(value, label) {
|
|
151
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
152
|
+
fail(`${label} must be a plain object.`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const prototype = Object.getPrototypeOf(value);
|
|
156
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
157
|
+
fail(`${label} must be a plain object.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function assertCallbackFreeProviderValue(value, seen = new WeakSet(), depth = 0) {
|
|
162
|
+
if (typeof value === 'function') {
|
|
163
|
+
fail(
|
|
164
|
+
'AI providers receive data-only request payloads.',
|
|
165
|
+
'ARCANE_AI_PROVIDER_CALLBACK_BOUNDARY'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (!value || typeof value !== 'object') {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (depth > 32) {
|
|
172
|
+
fail('AI provider request payload nesting exceeds the supported limit.');
|
|
173
|
+
}
|
|
174
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (typeof Blob === 'function' && value instanceof Blob) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (seen.has(value)) {
|
|
181
|
+
fail('AI provider request payloads must not contain cycles.');
|
|
182
|
+
}
|
|
183
|
+
seen.add(value);
|
|
184
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
185
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
186
|
+
const descriptor = descriptors[key];
|
|
187
|
+
if (typeof key === 'symbol' || !Object.hasOwn(descriptor, 'value')) {
|
|
188
|
+
fail('AI provider request payloads must contain data properties only.');
|
|
189
|
+
}
|
|
190
|
+
assertCallbackFreeProviderValue(descriptor.value, seen, depth + 1);
|
|
191
|
+
}
|
|
192
|
+
seen.delete(value);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function assertClosedRecord(value, keys, label) {
|
|
196
|
+
assertPlainObject(value, label);
|
|
197
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
198
|
+
const actual = Reflect.ownKeys(value);
|
|
199
|
+
if (actual.some(function hasSymbolKey(key) {
|
|
200
|
+
return typeof key === 'symbol';
|
|
201
|
+
})) {
|
|
202
|
+
fail(`${label} must not contain symbol keys.`);
|
|
203
|
+
}
|
|
204
|
+
if (actual.length !== keys.length
|
|
205
|
+
|| actual.some(function hasUnknownKey(key) {
|
|
206
|
+
return !keys.includes(key);
|
|
207
|
+
})) {
|
|
208
|
+
fail(`${label} must contain exactly ${keys.join(', ')}.`);
|
|
209
|
+
}
|
|
210
|
+
for (const key of keys) {
|
|
211
|
+
if (!Object.hasOwn(descriptors[key], 'value')) {
|
|
212
|
+
fail(`${label}.${key} must be a data property.`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function immutableSelection(role, value) {
|
|
218
|
+
if (value === null) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
assertClosedRecord(
|
|
223
|
+
value,
|
|
224
|
+
['providerId', 'modelId', 'localOnly'],
|
|
225
|
+
`${role} provider selection`
|
|
226
|
+
);
|
|
227
|
+
assertIdentifier(value.providerId, `${role} provider selection.providerId`);
|
|
228
|
+
assertIdentifier(value.modelId, `${role} provider selection.modelId`);
|
|
229
|
+
if (value.localOnly !== null && typeof value.localOnly !== 'boolean') {
|
|
230
|
+
fail(`${role} provider selection.localOnly must be null or a boolean.`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return Object.freeze(
|
|
234
|
+
{
|
|
235
|
+
providerId: value.providerId,
|
|
236
|
+
modelId: value.modelId,
|
|
237
|
+
localOnly: value.localOnly
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function immutableRoleRoutes(role, value) {
|
|
243
|
+
assertClosedRecord(value, ROUTE_KEYS, `${role} provider routes`);
|
|
244
|
+
const defaultSelection = immutableSelection(role, value.default);
|
|
245
|
+
const localSelection = immutableSelection(role, value.localOnly);
|
|
246
|
+
if (localSelection && localSelection.localOnly !== true) {
|
|
247
|
+
fail(`${role} localOnly route must identify a local-only selection.`);
|
|
248
|
+
}
|
|
249
|
+
return Object.freeze(
|
|
250
|
+
{
|
|
251
|
+
default: defaultSelection,
|
|
252
|
+
localOnly: localSelection
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function immutableSelections(value) {
|
|
258
|
+
assertClosedRecord(value, AI_RUNTIME_ROLES, 'AI provider selections');
|
|
259
|
+
return Object.freeze(
|
|
260
|
+
{
|
|
261
|
+
llm: immutableRoleRoutes('llm', value.llm),
|
|
262
|
+
stt: immutableRoleRoutes('stt', value.stt),
|
|
263
|
+
tts: immutableRoleRoutes('tts', value.tts)
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function immutableStartupOptions(options) {
|
|
269
|
+
if (options === undefined) {
|
|
270
|
+
return Object.freeze({startMuted: true, signal: null});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
assertPlainObject(options, 'AI provider startup options');
|
|
274
|
+
const descriptors = Object.getOwnPropertyDescriptors(options);
|
|
275
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
276
|
+
if (typeof key === 'symbol'
|
|
277
|
+
|| (key !== 'startMuted' && key !== 'signal')) {
|
|
278
|
+
fail('AI provider startup options contain an unknown option.');
|
|
279
|
+
}
|
|
280
|
+
if (!Object.hasOwn(descriptors[key], 'value')) {
|
|
281
|
+
fail(`AI provider startup options.${key} must be a data property.`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const startMuted = Object.hasOwn(descriptors, 'startMuted')
|
|
285
|
+
? descriptors.startMuted.value
|
|
286
|
+
: true;
|
|
287
|
+
const signal = Object.hasOwn(descriptors, 'signal')
|
|
288
|
+
? descriptors.signal.value
|
|
289
|
+
: null;
|
|
290
|
+
if (typeof startMuted !== 'boolean') {
|
|
291
|
+
fail('AI startup startMuted must be a boolean.');
|
|
292
|
+
}
|
|
293
|
+
assertAbortSignal(signal);
|
|
294
|
+
return Object.freeze({startMuted, signal});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function immutableInspectionOptions(options) {
|
|
298
|
+
assertPlainObject(options, 'AI provider inspection options');
|
|
299
|
+
const descriptors = Object.getOwnPropertyDescriptors(options);
|
|
300
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
301
|
+
if (typeof key === 'symbol'
|
|
302
|
+
|| (key !== 'localOnly' && key !== 'signal')) {
|
|
303
|
+
fail('AI provider inspection options contain an unknown option.');
|
|
304
|
+
}
|
|
305
|
+
if (!Object.hasOwn(descriptors[key], 'value')) {
|
|
306
|
+
fail(`AI provider inspection options.${key} must be a data property.`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const localOnly = Object.hasOwn(descriptors, 'localOnly')
|
|
310
|
+
? descriptors.localOnly.value
|
|
311
|
+
: false;
|
|
312
|
+
const signal = Object.hasOwn(descriptors, 'signal')
|
|
313
|
+
? descriptors.signal.value
|
|
314
|
+
: null;
|
|
315
|
+
if (typeof localOnly !== 'boolean') {
|
|
316
|
+
fail('AI provider inspection localOnly must be a boolean.');
|
|
317
|
+
}
|
|
318
|
+
assertAbortSignal(signal);
|
|
319
|
+
return Object.freeze({localOnly, signal});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function nullableTupleIdentifier(value) {
|
|
323
|
+
return typeof value === 'string' && value.trim()
|
|
324
|
+
? value.trim()
|
|
325
|
+
: null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function immutableProgress(value) {
|
|
329
|
+
assertClosedRecord(
|
|
330
|
+
value,
|
|
331
|
+
['phase', 'completed', 'total', 'unit', 'heartbeat'],
|
|
332
|
+
'AI provider progress'
|
|
333
|
+
);
|
|
334
|
+
return Object.freeze(
|
|
335
|
+
{
|
|
336
|
+
phase: value.phase,
|
|
337
|
+
completed: value.completed,
|
|
338
|
+
total: value.total,
|
|
339
|
+
unit: value.unit,
|
|
340
|
+
heartbeat: value.heartbeat
|
|
341
|
+
}
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function stateError(error, fallbackCode) {
|
|
346
|
+
const code = typeof error?.code === 'string'
|
|
347
|
+
&& /^[A-Z][A-Z0-9_]{0,127}$/.test(error.code)
|
|
348
|
+
? error.code.trim()
|
|
349
|
+
: fallbackCode;
|
|
350
|
+
const message = code === 'ARCANE_AI_REQUEST_ABORTED'
|
|
351
|
+
|| code === 'AI_REQUEST_ABORTED'
|
|
352
|
+
? 'The AI operation was cancelled.'
|
|
353
|
+
: code.includes('AUTHORITY') || code.includes('PROVENANCE')
|
|
354
|
+
? 'The selected AI model is not admitted for use.'
|
|
355
|
+
: code.includes('UNAVAILABLE') || code.includes('NOT_REGISTERED')
|
|
356
|
+
? 'The selected AI provider is unavailable.'
|
|
357
|
+
: 'The selected AI provider operation failed.';
|
|
358
|
+
return Object.freeze(
|
|
359
|
+
{
|
|
360
|
+
code,
|
|
361
|
+
message: message.slice(0, ERROR_MESSAGE_LIMIT)
|
|
362
|
+
}
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function roleRecord(role, selection, overrides = {}) {
|
|
367
|
+
return {
|
|
368
|
+
role,
|
|
369
|
+
state: selection ? 'unloaded' : 'unavailable',
|
|
370
|
+
providerId: selection?.providerId ?? null,
|
|
371
|
+
modelId: selection?.modelId ?? null,
|
|
372
|
+
localOnly: selection?.localOnly ?? null,
|
|
373
|
+
loaded: false,
|
|
374
|
+
busy: false,
|
|
375
|
+
operationId: null,
|
|
376
|
+
progress: null,
|
|
377
|
+
error: null,
|
|
378
|
+
...overrides
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function providerKey(role, providerId) {
|
|
383
|
+
return `${role}:${providerId}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function validateProvider(provider) {
|
|
387
|
+
const existing = PROVIDER_RECORDS.get(provider);
|
|
388
|
+
if (existing) {
|
|
389
|
+
return existing;
|
|
390
|
+
}
|
|
391
|
+
assertPlainObject(provider, 'AI provider');
|
|
392
|
+
if (provider.protocol !== AI_PROVIDER_PROTOCOL) {
|
|
393
|
+
fail(`AI provider.protocol must equal ${AI_PROVIDER_PROTOCOL}.`);
|
|
394
|
+
}
|
|
395
|
+
assertRole(provider.role);
|
|
396
|
+
assertIdentifier(provider.id, 'AI provider.id');
|
|
397
|
+
if (typeof provider.localOnly !== 'boolean') {
|
|
398
|
+
fail('AI provider.localOnly must be a boolean.');
|
|
399
|
+
}
|
|
400
|
+
for (const method of PROVIDER_METHODS) {
|
|
401
|
+
if (typeof provider[method] !== 'function') {
|
|
402
|
+
fail(`AI provider.${method} must be a function.`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const record = {
|
|
406
|
+
protocol: AI_PROVIDER_PROTOCOL,
|
|
407
|
+
role: provider.role,
|
|
408
|
+
id: provider.id,
|
|
409
|
+
localOnly: provider.localOnly
|
|
410
|
+
};
|
|
411
|
+
for (const method of PROVIDER_METHODS) {
|
|
412
|
+
record[method] = provider[method].bind(provider);
|
|
413
|
+
}
|
|
414
|
+
const admitted = Object.freeze(record);
|
|
415
|
+
PROVIDER_RECORDS.set(provider, admitted);
|
|
416
|
+
return admitted;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function validateProviderStatus(status) {
|
|
420
|
+
assertPlainObject(status, 'AI provider status');
|
|
421
|
+
if (typeof status.state !== 'string'
|
|
422
|
+
|| typeof status.loaded !== 'boolean'
|
|
423
|
+
|| typeof status.busy !== 'boolean') {
|
|
424
|
+
fail('AI provider status must include state, loaded, and busy values.');
|
|
425
|
+
}
|
|
426
|
+
return status;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function validateInspection(inspection, selection) {
|
|
430
|
+
assertPlainObject(inspection, 'AI provider inspection');
|
|
431
|
+
if (typeof inspection.available !== 'boolean') {
|
|
432
|
+
fail('AI provider inspection.available must be a boolean.');
|
|
433
|
+
}
|
|
434
|
+
if (!inspection.available) {
|
|
435
|
+
const code = typeof inspection.code === 'string' && inspection.code.trim()
|
|
436
|
+
? inspection.code.trim()
|
|
437
|
+
: 'ARCANE_AI_PROVIDER_AUTHORITY_BLOCKED';
|
|
438
|
+
const message = typeof inspection.message === 'string' && inspection.message.trim()
|
|
439
|
+
? inspection.message.trim()
|
|
440
|
+
: 'The selected AI provider is not admitted for use.';
|
|
441
|
+
throw operationError(message, code);
|
|
442
|
+
}
|
|
443
|
+
assertPlainObject(inspection.authority, 'AI provider inspection.authority');
|
|
444
|
+
if (inspection.authority.protocol !== AI_MODEL_AUTHORITY_PROTOCOL
|
|
445
|
+
|| inspection.authority.providerId !== selection.providerId
|
|
446
|
+
|| inspection.authority.modelId !== selection.modelId
|
|
447
|
+
|| inspection.authority.admitted !== true) {
|
|
448
|
+
throw operationError(
|
|
449
|
+
'The selected AI provider did not return an admitted model authority.',
|
|
450
|
+
'ARCANE_AI_MODEL_AUTHORITY_REQUIRED'
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
return inspection;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function createRoleSlot(role) {
|
|
457
|
+
return {
|
|
458
|
+
role,
|
|
459
|
+
routes: Object.freeze({default: null, localOnly: null}),
|
|
460
|
+
selection: null,
|
|
461
|
+
generation: 0,
|
|
462
|
+
operationSequence: 0,
|
|
463
|
+
loadController: null,
|
|
464
|
+
loadPromise: null,
|
|
465
|
+
unloadPromise: null,
|
|
466
|
+
disposePromise: null,
|
|
467
|
+
request: null,
|
|
468
|
+
disposed: false,
|
|
469
|
+
ready: false
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export class AIProviderRuntime {
|
|
474
|
+
#providers = new Map();
|
|
475
|
+
#slots = Object.freeze(
|
|
476
|
+
{
|
|
477
|
+
llm: createRoleSlot('llm'),
|
|
478
|
+
stt: createRoleSlot('stt'),
|
|
479
|
+
tts: createRoleSlot('tts')
|
|
480
|
+
}
|
|
481
|
+
);
|
|
482
|
+
#speechMuted = true;
|
|
483
|
+
#speechDesiredMuted = true;
|
|
484
|
+
#speechTransition = Promise.resolve();
|
|
485
|
+
#unsubscribeIntents = null;
|
|
486
|
+
#closed = false;
|
|
487
|
+
#closing = false;
|
|
488
|
+
#configured = false;
|
|
489
|
+
#configuring = false;
|
|
490
|
+
#disposeAllPromise = null;
|
|
491
|
+
#disposeAllCompletedProviders = new Set();
|
|
492
|
+
|
|
493
|
+
constructor(authority) {
|
|
494
|
+
if (authority !== RUNTIME_CONSTRUCTION_AUTHORITY) {
|
|
495
|
+
throw operationError(
|
|
496
|
+
'AIProviderRuntime is singleton-owned; use getAIProviderRuntime().',
|
|
497
|
+
'ARCANE_AI_RUNTIME_SINGLETON_REQUIRED'
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const runtime = this;
|
|
501
|
+
this.#unsubscribeIntents = subscribeAIRuntimeIntents(
|
|
502
|
+
function handleAIProviderRuntimeIntent(intent) {
|
|
503
|
+
runtime.#acceptIntent(intent);
|
|
504
|
+
}
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
get protocol() {
|
|
509
|
+
return AI_PROVIDER_RUNTIME_PROTOCOL;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
get speechMuted() {
|
|
513
|
+
return this.#speechMuted;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
get configured() {
|
|
517
|
+
return this.#configured;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
register(provider) {
|
|
521
|
+
this.#assertOpen();
|
|
522
|
+
this.#assertNotConfiguring();
|
|
523
|
+
const admitted = validateProvider(provider);
|
|
524
|
+
const key = providerKey(admitted.role, admitted.id);
|
|
525
|
+
const existing = this.#providers.get(key);
|
|
526
|
+
if (existing && existing !== admitted) {
|
|
527
|
+
throw operationError(
|
|
528
|
+
`AI provider ${key} is already registered.`,
|
|
529
|
+
'ARCANE_AI_PROVIDER_ALREADY_REGISTERED'
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
const slot = this.#slots[admitted.role];
|
|
533
|
+
const nextRoutes = {
|
|
534
|
+
default: slot.routes.default,
|
|
535
|
+
localOnly: slot.routes.localOnly
|
|
536
|
+
};
|
|
537
|
+
let reconciled = false;
|
|
538
|
+
for (const routeName of ROUTE_KEYS) {
|
|
539
|
+
const selection = slot.routes[routeName];
|
|
540
|
+
if (!selection || selection.providerId !== admitted.id) {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (selection.localOnly !== null
|
|
544
|
+
&& selection.localOnly !== admitted.localOnly) {
|
|
545
|
+
throw operationError(
|
|
546
|
+
`AI provider ${key} does not match the configured ${routeName} route locality.`,
|
|
547
|
+
'ARCANE_AI_PROVIDER_LOCALITY_MISMATCH'
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
if (selection.localOnly === null) {
|
|
551
|
+
nextRoutes[routeName] = Object.freeze(
|
|
552
|
+
{
|
|
553
|
+
providerId: selection.providerId,
|
|
554
|
+
modelId: selection.modelId,
|
|
555
|
+
localOnly: admitted.localOnly
|
|
556
|
+
}
|
|
557
|
+
);
|
|
558
|
+
reconciled = true;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (reconciled
|
|
562
|
+
&& admitted.localOnly
|
|
563
|
+
&& !nextRoutes.localOnly
|
|
564
|
+
&& nextRoutes.default?.providerId === admitted.id) {
|
|
565
|
+
nextRoutes.localOnly = nextRoutes.default;
|
|
566
|
+
}
|
|
567
|
+
this.#providers.set(key, admitted);
|
|
568
|
+
if (reconciled) {
|
|
569
|
+
const previousSelection = slot.selection;
|
|
570
|
+
slot.routes = Object.freeze(nextRoutes);
|
|
571
|
+
if (previousSelection?.providerId === admitted.id
|
|
572
|
+
&& previousSelection.localOnly === null) {
|
|
573
|
+
slot.generation += 1;
|
|
574
|
+
slot.selection = slot.routes.default?.providerId === admitted.id
|
|
575
|
+
&& slot.routes.default.modelId === previousSelection.modelId
|
|
576
|
+
? slot.routes.default
|
|
577
|
+
: Object.values(slot.routes).find(
|
|
578
|
+
function findReconciledAIProviderSelection(selection) {
|
|
579
|
+
return selection?.providerId === admitted.id
|
|
580
|
+
&& selection.modelId === previousSelection.modelId;
|
|
581
|
+
}
|
|
582
|
+
) ?? previousSelection;
|
|
583
|
+
publishAIRuntimeRoleState(
|
|
584
|
+
admitted.role,
|
|
585
|
+
roleRecord(admitted.role, slot.selection)
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const runtime = this;
|
|
591
|
+
let active = true;
|
|
592
|
+
return function unregisterAIProvider() {
|
|
593
|
+
if (!active) {
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
const removed = runtime.unregister(admitted.role, admitted.id, admitted);
|
|
597
|
+
if (removed) {
|
|
598
|
+
active = false;
|
|
599
|
+
}
|
|
600
|
+
return removed;
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
unregister(role, providerId, expectedProvider = null) {
|
|
605
|
+
this.#assertOpen();
|
|
606
|
+
this.#assertNotConfiguring();
|
|
607
|
+
assertRole(role);
|
|
608
|
+
assertIdentifier(providerId, 'AI provider id');
|
|
609
|
+
const key = providerKey(role, providerId);
|
|
610
|
+
const provider = this.#providers.get(key);
|
|
611
|
+
if (!provider || (expectedProvider && provider !== expectedProvider)) {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const slot = this.#slots[role];
|
|
616
|
+
if (slot.routes.default?.providerId === providerId
|
|
617
|
+
|| slot.routes.localOnly?.providerId === providerId) {
|
|
618
|
+
throw operationError(
|
|
619
|
+
`AI provider ${key} must be deselected before it is unregistered.`,
|
|
620
|
+
'ARCANE_AI_PROVIDER_SELECTED'
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
this.#providers.delete(key);
|
|
624
|
+
this.#disposeAllCompletedProviders.delete(provider);
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
hasProvider(role, providerId) {
|
|
629
|
+
assertRole(role);
|
|
630
|
+
assertIdentifier(providerId, 'AI provider id');
|
|
631
|
+
return this.#providers.has(providerKey(role, providerId));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
providerIdentity(role, providerId) {
|
|
635
|
+
assertRole(role);
|
|
636
|
+
assertIdentifier(providerId, 'AI provider id');
|
|
637
|
+
const provider = this.#providers.get(providerKey(role, providerId)) ?? null;
|
|
638
|
+
if (!provider) {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
return Object.freeze(
|
|
642
|
+
{
|
|
643
|
+
protocol: provider.protocol,
|
|
644
|
+
role: provider.role,
|
|
645
|
+
id: provider.id,
|
|
646
|
+
localOnly: provider.localOnly
|
|
647
|
+
}
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
selection(role, options = {}) {
|
|
652
|
+
assertRole(role);
|
|
653
|
+
assertPlainObject(options, 'AI provider route options');
|
|
654
|
+
for (const key of Reflect.ownKeys(options)) {
|
|
655
|
+
if (key !== 'localOnly') {
|
|
656
|
+
fail('AI provider route options contain an unknown option.');
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const localOnly = Object.hasOwn(options, 'localOnly')
|
|
660
|
+
? options.localOnly
|
|
661
|
+
: false;
|
|
662
|
+
if (typeof localOnly !== 'boolean') {
|
|
663
|
+
fail('AI provider route localOnly must be a boolean.');
|
|
664
|
+
}
|
|
665
|
+
const slot = this.#slots[role];
|
|
666
|
+
return localOnly ? slot.routes.localOnly : slot.routes.default;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
ownsSelection(role, providerId, options = {}) {
|
|
670
|
+
assertRole(role);
|
|
671
|
+
assertIdentifier(providerId, 'AI provider id');
|
|
672
|
+
return this.selection(role, options)?.providerId === providerId;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
validateConfiguration(value) {
|
|
676
|
+
this.#assertOpen();
|
|
677
|
+
const selections = immutableSelections(value);
|
|
678
|
+
for (const role of AI_RUNTIME_ROLES) {
|
|
679
|
+
for (const routeName of ROUTE_KEYS) {
|
|
680
|
+
const selection = selections[role][routeName];
|
|
681
|
+
if (!selection) {
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const provider = this.#providers.get(
|
|
685
|
+
providerKey(role, selection.providerId)
|
|
686
|
+
) ?? null;
|
|
687
|
+
if (provider && selection.localOnly !== provider.localOnly) {
|
|
688
|
+
throw operationError(
|
|
689
|
+
`AI ${role} route ${routeName} does not match provider locality.`,
|
|
690
|
+
'ARCANE_AI_PROVIDER_LOCALITY_MISMATCH'
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return selections;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
configure(value) {
|
|
699
|
+
this.#assertOpen();
|
|
700
|
+
if (this.#configuring) {
|
|
701
|
+
throw operationError(
|
|
702
|
+
'AI provider configuration cannot be changed reentrantly.',
|
|
703
|
+
'ARCANE_AI_CONFIGURATION_REENTRANT'
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
const selections = this.validateConfiguration(value);
|
|
707
|
+
for (const role of AI_RUNTIME_ROLES) {
|
|
708
|
+
const slot = this.#slots[role];
|
|
709
|
+
if (this.#roleHasOwnedWork(slot)) {
|
|
710
|
+
throw operationError(
|
|
711
|
+
`AI role ${role} must be unloaded before it is reconfigured.`,
|
|
712
|
+
'ARCANE_AI_ROLE_BUSY'
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
this.#configuring = true;
|
|
718
|
+
try {
|
|
719
|
+
for (const role of AI_RUNTIME_ROLES) {
|
|
720
|
+
const slot = this.#slots[role];
|
|
721
|
+
slot.generation += 1;
|
|
722
|
+
slot.disposed = false;
|
|
723
|
+
slot.ready = false;
|
|
724
|
+
slot.routes = selections[role];
|
|
725
|
+
slot.selection = selections[role].default;
|
|
726
|
+
}
|
|
727
|
+
this.#speechMuted = true;
|
|
728
|
+
this.#configured = true;
|
|
729
|
+
publishAIRuntimeRolesState(
|
|
730
|
+
{
|
|
731
|
+
llm: roleRecord('llm', this.#slots.llm.selection),
|
|
732
|
+
stt: roleRecord('stt', this.#slots.stt.selection),
|
|
733
|
+
tts: roleRecord('tts', this.#slots.tts.selection)
|
|
734
|
+
}
|
|
735
|
+
);
|
|
736
|
+
} finally {
|
|
737
|
+
this.#configuring = false;
|
|
738
|
+
}
|
|
739
|
+
return selections;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
configureFromTuple(tuple) {
|
|
743
|
+
if (!Array.isArray(tuple) || tuple.length !== 6) {
|
|
744
|
+
fail('AI preference tuple must contain exactly six entries.');
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const providerIds = {
|
|
748
|
+
llm: nullableTupleIdentifier(tuple[0]),
|
|
749
|
+
stt: nullableTupleIdentifier(tuple[1]),
|
|
750
|
+
tts: nullableTupleIdentifier(tuple[2])
|
|
751
|
+
};
|
|
752
|
+
const modelIds = {
|
|
753
|
+
llm: nullableTupleIdentifier(tuple[3]),
|
|
754
|
+
tts: nullableTupleIdentifier(tuple[4]),
|
|
755
|
+
stt: nullableTupleIdentifier(tuple[5])
|
|
756
|
+
};
|
|
757
|
+
const selections = {};
|
|
758
|
+
for (const role of AI_RUNTIME_ROLES) {
|
|
759
|
+
const providerId = providerIds[role];
|
|
760
|
+
const modelId = modelIds[role];
|
|
761
|
+
const provider = providerId
|
|
762
|
+
? this.#providers.get(providerKey(role, providerId)) ?? null
|
|
763
|
+
: null;
|
|
764
|
+
if (!providerId && !modelId) {
|
|
765
|
+
selections[role] = {
|
|
766
|
+
default: null,
|
|
767
|
+
localOnly: null
|
|
768
|
+
};
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (!providerId || !modelId) {
|
|
772
|
+
throw operationError(
|
|
773
|
+
`AI ${role} selection requires both provider and model ids.`,
|
|
774
|
+
'ARCANE_AI_SELECTION_INCOMPLETE'
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
const selection = {
|
|
778
|
+
providerId,
|
|
779
|
+
modelId,
|
|
780
|
+
localOnly: provider?.localOnly ?? null
|
|
781
|
+
};
|
|
782
|
+
selections[role] = {
|
|
783
|
+
default: selection,
|
|
784
|
+
localOnly: provider?.localOnly === true
|
|
785
|
+
? {...selection, localOnly: true}
|
|
786
|
+
: null
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
return this.configure(selections);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
status(role = null) {
|
|
793
|
+
if (role === null) {
|
|
794
|
+
return getAIRuntimeState();
|
|
795
|
+
}
|
|
796
|
+
assertRole(role);
|
|
797
|
+
return getAIRuntimeState().roles[role];
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
catalog(role) {
|
|
801
|
+
assertRole(role);
|
|
802
|
+
const entries = [];
|
|
803
|
+
for (const provider of this.#providers.values()) {
|
|
804
|
+
if (provider.role !== role) {
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
const providerCatalog = provider.catalog();
|
|
808
|
+
if (!Array.isArray(providerCatalog)) {
|
|
809
|
+
fail('AI provider.catalog() must synchronously return an array.');
|
|
810
|
+
}
|
|
811
|
+
entries.push(
|
|
812
|
+
Object.freeze(
|
|
813
|
+
{
|
|
814
|
+
providerId: provider.id,
|
|
815
|
+
localOnly: provider.localOnly,
|
|
816
|
+
models: Object.freeze(providerCatalog.slice())
|
|
817
|
+
}
|
|
818
|
+
)
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
return Object.freeze(entries);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async inspect(role, options = {}) {
|
|
825
|
+
this.#assertOpen();
|
|
826
|
+
this.#assertNotConfiguring();
|
|
827
|
+
assertRole(role);
|
|
828
|
+
const {localOnly, signal} = immutableInspectionOptions(options);
|
|
829
|
+
this.#assertOpen();
|
|
830
|
+
this.#assertNotConfiguring();
|
|
831
|
+
if (signal?.aborted) {
|
|
832
|
+
throw normalizedAbort();
|
|
833
|
+
}
|
|
834
|
+
const slot = this.#slots[role];
|
|
835
|
+
const generation = slot.generation;
|
|
836
|
+
const selection = this.selection(role, {localOnly});
|
|
837
|
+
if (!selection) {
|
|
838
|
+
return Object.freeze(
|
|
839
|
+
{
|
|
840
|
+
available: false,
|
|
841
|
+
code: 'ARCANE_AI_ROLE_NOT_SELECTED',
|
|
842
|
+
message: `No ${role} provider and model are selected.`
|
|
843
|
+
}
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
const provider = this.#providers.get(
|
|
847
|
+
providerKey(role, selection.providerId)
|
|
848
|
+
) ?? null;
|
|
849
|
+
if (!provider) {
|
|
850
|
+
return Object.freeze(
|
|
851
|
+
{
|
|
852
|
+
available: false,
|
|
853
|
+
code: 'ARCANE_AI_PROVIDER_UNAVAILABLE',
|
|
854
|
+
message: `The selected ${role} provider is not registered.`
|
|
855
|
+
}
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
let inspection;
|
|
860
|
+
try {
|
|
861
|
+
if (signal?.aborted) {
|
|
862
|
+
throw normalizedAbort();
|
|
863
|
+
}
|
|
864
|
+
inspection = await provider.inspect(
|
|
865
|
+
selection,
|
|
866
|
+
{role, signal}
|
|
867
|
+
);
|
|
868
|
+
if (signal?.aborted) {
|
|
869
|
+
throw normalizedAbort();
|
|
870
|
+
}
|
|
871
|
+
} catch (error) {
|
|
872
|
+
if (isAbort(error, signal)) {
|
|
873
|
+
throw normalizedAbort(error);
|
|
874
|
+
}
|
|
875
|
+
this.#assertOpen();
|
|
876
|
+
this.#assertNotConfiguring();
|
|
877
|
+
this.#assertCurrentOperation(slot, generation, signal);
|
|
878
|
+
const unavailable = stateError(
|
|
879
|
+
error,
|
|
880
|
+
'ARCANE_AI_PROVIDER_AUTHORITY_BLOCKED'
|
|
881
|
+
);
|
|
882
|
+
return Object.freeze(
|
|
883
|
+
{
|
|
884
|
+
available: false,
|
|
885
|
+
code: unavailable.code,
|
|
886
|
+
message: unavailable.message
|
|
887
|
+
}
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
this.#assertOpen();
|
|
891
|
+
this.#assertNotConfiguring();
|
|
892
|
+
this.#assertCurrentOperation(slot, generation, signal);
|
|
893
|
+
try {
|
|
894
|
+
validateInspection(inspection, selection);
|
|
895
|
+
return inspection;
|
|
896
|
+
} catch (error) {
|
|
897
|
+
const unavailable = stateError(
|
|
898
|
+
error,
|
|
899
|
+
'ARCANE_AI_PROVIDER_AUTHORITY_BLOCKED'
|
|
900
|
+
);
|
|
901
|
+
return Object.freeze(
|
|
902
|
+
{
|
|
903
|
+
available: false,
|
|
904
|
+
code: unavailable.code,
|
|
905
|
+
message: unavailable.message
|
|
906
|
+
}
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async start(options) {
|
|
912
|
+
this.#assertOpen();
|
|
913
|
+
this.#assertNotConfiguring();
|
|
914
|
+
if (!this.#configured) {
|
|
915
|
+
throw operationError(
|
|
916
|
+
'AI provider routes must be configured before startup.',
|
|
917
|
+
'ARCANE_AI_RUNTIME_NOT_CONFIGURED'
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
const normalized = immutableStartupOptions(options);
|
|
921
|
+
await this.#speechTransition.catch(
|
|
922
|
+
function retainPriorAIStartupSpeechTransitionFailure() {}
|
|
923
|
+
);
|
|
924
|
+
await Promise.all(
|
|
925
|
+
AI_RUNTIME_ROLES.map(
|
|
926
|
+
function awaitActiveAIProviderUnload(role) {
|
|
927
|
+
return this.#slots[role].unloadPromise ?? Promise.resolve();
|
|
928
|
+
},
|
|
929
|
+
this
|
|
930
|
+
)
|
|
931
|
+
);
|
|
932
|
+
this.#assertOpen();
|
|
933
|
+
this.#assertNotConfiguring();
|
|
934
|
+
if (normalized.startMuted) {
|
|
935
|
+
await this.setSpeechMuted(true);
|
|
936
|
+
} else {
|
|
937
|
+
this.#speechDesiredMuted = false;
|
|
938
|
+
}
|
|
939
|
+
this.#assertOpen();
|
|
940
|
+
this.#assertNotConfiguring();
|
|
941
|
+
return startAIRuntime(normalized);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
load(role, options = {}) {
|
|
945
|
+
this.#assertOpen();
|
|
946
|
+
this.#assertNotConfiguring();
|
|
947
|
+
assertRole(role);
|
|
948
|
+
assertPlainObject(options, 'AI provider load options');
|
|
949
|
+
for (const key of Reflect.ownKeys(options)) {
|
|
950
|
+
if (key !== 'signal' && key !== 'localOnly') {
|
|
951
|
+
fail('AI provider load options contain an unknown option.');
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const signal = Object.hasOwn(options, 'signal') ? options.signal : null;
|
|
955
|
+
const localOnly = Object.hasOwn(options, 'localOnly')
|
|
956
|
+
? options.localOnly
|
|
957
|
+
: false;
|
|
958
|
+
assertAbortSignal(signal);
|
|
959
|
+
if (typeof localOnly !== 'boolean') {
|
|
960
|
+
fail('AI provider load localOnly must be a boolean.');
|
|
961
|
+
}
|
|
962
|
+
const slot = this.#slots[role];
|
|
963
|
+
if (slot.disposed) {
|
|
964
|
+
return Promise.reject(
|
|
965
|
+
operationError(
|
|
966
|
+
`AI role ${role} is disposed and must be explicitly reconfigured.`,
|
|
967
|
+
'ARCANE_AI_ROLE_DISPOSED'
|
|
968
|
+
)
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
if (slot.unloadPromise || slot.disposePromise) {
|
|
972
|
+
return Promise.reject(
|
|
973
|
+
operationError(
|
|
974
|
+
`AI role ${role} cannot load while cleanup is active.`,
|
|
975
|
+
'ARCANE_AI_OPERATION_SUPERSEDED'
|
|
976
|
+
)
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
if (slot.request) {
|
|
980
|
+
return Promise.reject(
|
|
981
|
+
operationError(
|
|
982
|
+
`AI role ${role} cannot load during an active request.`,
|
|
983
|
+
'ARCANE_AI_ROLE_BUSY'
|
|
984
|
+
)
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
if (role === 'tts' && this.#speechDesiredMuted) {
|
|
988
|
+
return Promise.reject(
|
|
989
|
+
operationError(
|
|
990
|
+
'The TTS role remains unloaded while speech is muted.',
|
|
991
|
+
'ARCANE_AI_TTS_MUTED'
|
|
992
|
+
)
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
const targetSelection = localOnly
|
|
996
|
+
? slot.routes.localOnly
|
|
997
|
+
: slot.routes.default;
|
|
998
|
+
if (!targetSelection) {
|
|
999
|
+
const code = localOnly
|
|
1000
|
+
? 'AI_LOCAL_MODEL_REQUIRED'
|
|
1001
|
+
: 'ARCANE_AI_ROLE_NOT_SELECTED';
|
|
1002
|
+
const message = localOnly
|
|
1003
|
+
? `No explicit local-only ${role} route is configured.`
|
|
1004
|
+
: `No ${role} provider and model are selected.`;
|
|
1005
|
+
return Promise.reject(operationError(message, code));
|
|
1006
|
+
}
|
|
1007
|
+
if (slot.loadPromise) {
|
|
1008
|
+
if (this.#sameSelection(slot.selection, targetSelection)) {
|
|
1009
|
+
return slot.loadPromise;
|
|
1010
|
+
}
|
|
1011
|
+
return Promise.reject(
|
|
1012
|
+
operationError(
|
|
1013
|
+
`AI role ${role} is loading a different explicit route.`,
|
|
1014
|
+
'ARCANE_AI_ROUTE_NOT_READY'
|
|
1015
|
+
)
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
if (slot.ready && this.#sameSelection(slot.selection, targetSelection)) {
|
|
1019
|
+
const readyProvider = this.#providers.get(
|
|
1020
|
+
providerKey(role, targetSelection.providerId)
|
|
1021
|
+
) ?? null;
|
|
1022
|
+
try {
|
|
1023
|
+
const readyStatus = readyProvider
|
|
1024
|
+
? validateProviderStatus(readyProvider.status())
|
|
1025
|
+
: null;
|
|
1026
|
+
if (readyStatus?.state === 'ready'
|
|
1027
|
+
&& readyStatus.loaded === true
|
|
1028
|
+
&& readyStatus.busy === false) {
|
|
1029
|
+
publishAIRuntimeRoleState(
|
|
1030
|
+
role,
|
|
1031
|
+
roleRecord(
|
|
1032
|
+
role,
|
|
1033
|
+
slot.selection,
|
|
1034
|
+
{state: 'ready', loaded: true}
|
|
1035
|
+
)
|
|
1036
|
+
);
|
|
1037
|
+
return Promise.resolve(this.status(role));
|
|
1038
|
+
}
|
|
1039
|
+
} catch {
|
|
1040
|
+
// The private ready flag is revoked when provider proof is stale.
|
|
1041
|
+
}
|
|
1042
|
+
slot.ready = false;
|
|
1043
|
+
}
|
|
1044
|
+
if (slot.ready && !this.#sameSelection(slot.selection, targetSelection)) {
|
|
1045
|
+
return Promise.reject(
|
|
1046
|
+
operationError(
|
|
1047
|
+
`AI role ${role} must unload before changing routes.`,
|
|
1048
|
+
'ARCANE_AI_ROUTE_SWITCH_REQUIRES_UNLOAD'
|
|
1049
|
+
)
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
if (!this.#sameSelection(slot.selection, targetSelection)) {
|
|
1053
|
+
slot.generation += 1;
|
|
1054
|
+
slot.ready = false;
|
|
1055
|
+
slot.selection = targetSelection;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const provider = this.#providerFor(slot);
|
|
1059
|
+
if (!provider) {
|
|
1060
|
+
slot.ready = false;
|
|
1061
|
+
const error = operationError(
|
|
1062
|
+
`The selected ${role} provider is not registered.`,
|
|
1063
|
+
'ARCANE_AI_PROVIDER_UNAVAILABLE'
|
|
1064
|
+
);
|
|
1065
|
+
this.#publishRoleError(slot, error, false);
|
|
1066
|
+
return Promise.reject(error);
|
|
1067
|
+
}
|
|
1068
|
+
if (slot.selection.localOnly === true && provider.localOnly !== true) {
|
|
1069
|
+
const error = operationError(
|
|
1070
|
+
`The selected ${role} route requires a local-only provider.`,
|
|
1071
|
+
'ARCANE_AI_LOCAL_PROVIDER_REQUIRED'
|
|
1072
|
+
);
|
|
1073
|
+
this.#publishRoleError(slot, error, false);
|
|
1074
|
+
return Promise.reject(error);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
slot.generation += 1;
|
|
1078
|
+
slot.ready = false;
|
|
1079
|
+
const generation = slot.generation;
|
|
1080
|
+
const operationId = this.#nextOperationId(slot, 'load');
|
|
1081
|
+
const controller = new AbortController();
|
|
1082
|
+
slot.loadController = controller;
|
|
1083
|
+
const detachSignal = this.#forwardAbort(signal, controller);
|
|
1084
|
+
const runtime = this;
|
|
1085
|
+
const loadOperation = Promise.resolve().then(
|
|
1086
|
+
async function loadAIProviderRole() {
|
|
1087
|
+
try {
|
|
1088
|
+
if (controller.signal.aborted) {
|
|
1089
|
+
throw normalizedAbort();
|
|
1090
|
+
}
|
|
1091
|
+
const inspection = await provider.inspect(
|
|
1092
|
+
slot.selection,
|
|
1093
|
+
{role, signal: controller.signal}
|
|
1094
|
+
);
|
|
1095
|
+
validateInspection(inspection, slot.selection);
|
|
1096
|
+
runtime.#assertCurrentOperation(slot, generation, controller.signal);
|
|
1097
|
+
await provider.load(
|
|
1098
|
+
{
|
|
1099
|
+
role,
|
|
1100
|
+
selection: slot.selection,
|
|
1101
|
+
signal: controller.signal,
|
|
1102
|
+
progress: function publishAIProviderLoadProgress(progress) {
|
|
1103
|
+
runtime.#publishLoadProgress(
|
|
1104
|
+
slot,
|
|
1105
|
+
generation,
|
|
1106
|
+
operationId,
|
|
1107
|
+
progress
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
);
|
|
1112
|
+
runtime.#assertCurrentOperation(slot, generation, controller.signal);
|
|
1113
|
+
const providerStatus = validateProviderStatus(provider.status());
|
|
1114
|
+
if (providerStatus.state !== 'ready'
|
|
1115
|
+
|| providerStatus.loaded !== true
|
|
1116
|
+
|| providerStatus.busy !== false) {
|
|
1117
|
+
throw operationError(
|
|
1118
|
+
`The selected ${role} provider did not become ready.`,
|
|
1119
|
+
'ARCANE_AI_PROVIDER_NOT_READY'
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
slot.ready = true;
|
|
1123
|
+
if (role === 'tts' && !runtime.#speechDesiredMuted) {
|
|
1124
|
+
runtime.#speechMuted = false;
|
|
1125
|
+
}
|
|
1126
|
+
publishAIRuntimeRoleState(
|
|
1127
|
+
role,
|
|
1128
|
+
roleRecord(
|
|
1129
|
+
role,
|
|
1130
|
+
slot.selection,
|
|
1131
|
+
{
|
|
1132
|
+
state: 'ready',
|
|
1133
|
+
loaded: true
|
|
1134
|
+
}
|
|
1135
|
+
)
|
|
1136
|
+
);
|
|
1137
|
+
return runtime.status(role);
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
const normalized = isAbort(error, controller.signal)
|
|
1140
|
+
? normalizedAbort(error)
|
|
1141
|
+
: error;
|
|
1142
|
+
if (slot.generation === generation && !slot.unloadPromise) {
|
|
1143
|
+
slot.ready = false;
|
|
1144
|
+
if (role === 'tts') {
|
|
1145
|
+
runtime.#speechMuted = true;
|
|
1146
|
+
}
|
|
1147
|
+
runtime.#publishRoleError(slot, normalized, false);
|
|
1148
|
+
}
|
|
1149
|
+
throw normalized;
|
|
1150
|
+
} finally {
|
|
1151
|
+
detachSignal();
|
|
1152
|
+
if (slot.generation === generation) {
|
|
1153
|
+
slot.loadController = null;
|
|
1154
|
+
}
|
|
1155
|
+
slot.loadPromise = null;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
);
|
|
1159
|
+
slot.loadPromise = loadOperation;
|
|
1160
|
+
publishAIRuntimeRoleState(
|
|
1161
|
+
role,
|
|
1162
|
+
roleRecord(
|
|
1163
|
+
role,
|
|
1164
|
+
slot.selection,
|
|
1165
|
+
{
|
|
1166
|
+
state: 'loading',
|
|
1167
|
+
operationId,
|
|
1168
|
+
progress: {
|
|
1169
|
+
phase: 'admission',
|
|
1170
|
+
completed: 0,
|
|
1171
|
+
total: null,
|
|
1172
|
+
unit: 'items',
|
|
1173
|
+
heartbeat: false
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
)
|
|
1177
|
+
);
|
|
1178
|
+
return loadOperation;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
unload(role, options = {}) {
|
|
1182
|
+
this.#assertNotConfiguring();
|
|
1183
|
+
assertRole(role);
|
|
1184
|
+
assertPlainObject(options, 'AI provider unload options');
|
|
1185
|
+
for (const key of Reflect.ownKeys(options)) {
|
|
1186
|
+
if (key !== 'signal') {
|
|
1187
|
+
fail('AI provider unload options contain an unknown option.');
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
const signal = Object.hasOwn(options, 'signal') ? options.signal : null;
|
|
1191
|
+
assertAbortSignal(signal);
|
|
1192
|
+
const slot = this.#slots[role];
|
|
1193
|
+
if (slot.unloadPromise) {
|
|
1194
|
+
return slot.unloadPromise;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const provider = this.#providerFor(slot);
|
|
1198
|
+
const before = this.status(role);
|
|
1199
|
+
let providerStatus = null;
|
|
1200
|
+
if (provider) {
|
|
1201
|
+
try {
|
|
1202
|
+
providerStatus = validateProviderStatus(provider.status());
|
|
1203
|
+
} catch {
|
|
1204
|
+
// Cleanup remains fail-closed when provider status is malformed.
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (slot.disposed) {
|
|
1208
|
+
return Promise.resolve(before);
|
|
1209
|
+
}
|
|
1210
|
+
const providerOwned = slot.ready
|
|
1211
|
+
|| providerStatus?.loaded === true
|
|
1212
|
+
|| providerStatus?.busy === true;
|
|
1213
|
+
if (!slot.loadPromise
|
|
1214
|
+
&& !slot.request
|
|
1215
|
+
&& !providerOwned
|
|
1216
|
+
&& (!provider || providerStatus)) {
|
|
1217
|
+
slot.ready = false;
|
|
1218
|
+
return Promise.resolve(before);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
slot.generation += 1;
|
|
1222
|
+
const generation = slot.generation;
|
|
1223
|
+
slot.ready = false;
|
|
1224
|
+
if (role === 'tts') {
|
|
1225
|
+
this.#speechMuted = true;
|
|
1226
|
+
}
|
|
1227
|
+
slot.loadController?.abort();
|
|
1228
|
+
slot.request?.controller.abort();
|
|
1229
|
+
const capturedLoad = slot.loadPromise;
|
|
1230
|
+
const capturedRequestRecord = slot.request;
|
|
1231
|
+
const capturedRequest = capturedRequestRecord?.promise ?? null;
|
|
1232
|
+
const unloadOperationId = providerOwned
|
|
1233
|
+
? this.#nextOperationId(slot, 'unload')
|
|
1234
|
+
: null;
|
|
1235
|
+
const runtime = this;
|
|
1236
|
+
const unloadOperation = Promise.resolve().then(async function unloadAIProviderRole() {
|
|
1237
|
+
try {
|
|
1238
|
+
if (signal?.aborted) {
|
|
1239
|
+
throw normalizedAbort();
|
|
1240
|
+
}
|
|
1241
|
+
if (capturedRequestRecord?.cancel) {
|
|
1242
|
+
try {
|
|
1243
|
+
await capturedRequestRecord.cancel(
|
|
1244
|
+
operationError(
|
|
1245
|
+
`AI role ${role} is unloading.`,
|
|
1246
|
+
'ARCANE_AI_REQUEST_ABORTED'
|
|
1247
|
+
)
|
|
1248
|
+
);
|
|
1249
|
+
} catch {
|
|
1250
|
+
// Provider unload below remains the authoritative cleanup.
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
await Promise.allSettled(
|
|
1254
|
+
[capturedLoad, capturedRequest].filter(Boolean)
|
|
1255
|
+
);
|
|
1256
|
+
if (signal?.aborted) {
|
|
1257
|
+
throw normalizedAbort();
|
|
1258
|
+
}
|
|
1259
|
+
if (provider) {
|
|
1260
|
+
await provider.unload(
|
|
1261
|
+
{
|
|
1262
|
+
role,
|
|
1263
|
+
selection: slot.selection,
|
|
1264
|
+
signal
|
|
1265
|
+
}
|
|
1266
|
+
);
|
|
1267
|
+
const unloadedStatus = validateProviderStatus(provider.status());
|
|
1268
|
+
if (unloadedStatus.loaded || unloadedStatus.busy) {
|
|
1269
|
+
throw operationError(
|
|
1270
|
+
`The selected ${role} provider remained loaded after unload.`,
|
|
1271
|
+
'ARCANE_AI_PROVIDER_UNLOAD_INCOMPLETE'
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
if (slot.generation === generation) {
|
|
1276
|
+
publishAIRuntimeRoleState(
|
|
1277
|
+
role,
|
|
1278
|
+
roleRecord(role, slot.selection)
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
return runtime.status(role);
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
if (slot.generation === generation) {
|
|
1284
|
+
runtime.#publishRoleError(slot, error, providerOwned);
|
|
1285
|
+
}
|
|
1286
|
+
throw error;
|
|
1287
|
+
} finally {
|
|
1288
|
+
slot.unloadPromise = null;
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
slot.unloadPromise = unloadOperation;
|
|
1292
|
+
if (providerOwned) {
|
|
1293
|
+
publishAIRuntimeRoleState(
|
|
1294
|
+
role,
|
|
1295
|
+
roleRecord(
|
|
1296
|
+
role,
|
|
1297
|
+
slot.selection,
|
|
1298
|
+
{
|
|
1299
|
+
state: 'unloading',
|
|
1300
|
+
loaded: true,
|
|
1301
|
+
operationId: unloadOperationId
|
|
1302
|
+
}
|
|
1303
|
+
)
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
return unloadOperation;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
dispose(role, options = {}) {
|
|
1310
|
+
this.#assertNotConfiguring();
|
|
1311
|
+
assertRole(role);
|
|
1312
|
+
assertPlainObject(options, 'AI provider dispose options');
|
|
1313
|
+
for (const key of Reflect.ownKeys(options)) {
|
|
1314
|
+
if (key !== 'signal') {
|
|
1315
|
+
fail('AI provider dispose options contain an unknown option.');
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
const signal = Object.hasOwn(options, 'signal') ? options.signal : null;
|
|
1319
|
+
assertAbortSignal(signal);
|
|
1320
|
+
const slot = this.#slots[role];
|
|
1321
|
+
if (slot.disposePromise) {
|
|
1322
|
+
return slot.disposePromise;
|
|
1323
|
+
}
|
|
1324
|
+
if (slot.disposed) {
|
|
1325
|
+
return Promise.resolve(this.status(role));
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const provider = this.#providerFor(slot);
|
|
1329
|
+
const runtime = this;
|
|
1330
|
+
const disposeOperation = Promise.resolve().then(async function disposeAIProviderRole() {
|
|
1331
|
+
try {
|
|
1332
|
+
await runtime.unload(role, {signal});
|
|
1333
|
+
if (provider) {
|
|
1334
|
+
await provider.dispose(
|
|
1335
|
+
{
|
|
1336
|
+
role,
|
|
1337
|
+
selection: slot.selection,
|
|
1338
|
+
signal
|
|
1339
|
+
}
|
|
1340
|
+
);
|
|
1341
|
+
const disposedStatus = validateProviderStatus(provider.status());
|
|
1342
|
+
if (disposedStatus.loaded || disposedStatus.busy) {
|
|
1343
|
+
throw operationError(
|
|
1344
|
+
`The selected ${role} provider remained active after disposal.`,
|
|
1345
|
+
'ARCANE_AI_PROVIDER_DISPOSE_INCOMPLETE'
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
slot.ready = false;
|
|
1350
|
+
slot.disposed = true;
|
|
1351
|
+
publishAIRuntimeRoleState(
|
|
1352
|
+
role,
|
|
1353
|
+
roleRecord(
|
|
1354
|
+
role,
|
|
1355
|
+
slot.selection,
|
|
1356
|
+
{
|
|
1357
|
+
state: 'disposed'
|
|
1358
|
+
}
|
|
1359
|
+
)
|
|
1360
|
+
);
|
|
1361
|
+
return runtime.status(role);
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
runtime.#publishRoleError(slot, error, runtime.status(role).loaded);
|
|
1364
|
+
throw error;
|
|
1365
|
+
} finally {
|
|
1366
|
+
slot.disposePromise = null;
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
slot.disposePromise = disposeOperation;
|
|
1370
|
+
return disposeOperation;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
disposeAll(options = {}) {
|
|
1374
|
+
this.#assertNotConfiguring();
|
|
1375
|
+
assertPlainObject(options, 'AI provider dispose-all options');
|
|
1376
|
+
for (const key of Reflect.ownKeys(options)) {
|
|
1377
|
+
if (key !== 'signal') {
|
|
1378
|
+
fail('AI provider dispose-all options contain an unknown option.');
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
const signal = Object.hasOwn(options, 'signal') ? options.signal : null;
|
|
1382
|
+
assertAbortSignal(signal);
|
|
1383
|
+
if (this.#disposeAllPromise) {
|
|
1384
|
+
return this.#disposeAllPromise;
|
|
1385
|
+
}
|
|
1386
|
+
if (this.#closed) {
|
|
1387
|
+
return Promise.resolve(this.status());
|
|
1388
|
+
}
|
|
1389
|
+
this.#closing = true;
|
|
1390
|
+
const runtime = this;
|
|
1391
|
+
const disposing = (async function disposeAIProviderRuntime() {
|
|
1392
|
+
const selectedProviders = new Set();
|
|
1393
|
+
const tasks = [];
|
|
1394
|
+
for (const role of AI_RUNTIME_ROLES) {
|
|
1395
|
+
const selectedProvider = runtime.#providerFor(runtime.#slots[role]);
|
|
1396
|
+
if (selectedProvider) {
|
|
1397
|
+
selectedProviders.add(selectedProvider);
|
|
1398
|
+
}
|
|
1399
|
+
tasks.push(
|
|
1400
|
+
{
|
|
1401
|
+
provider: selectedProvider,
|
|
1402
|
+
operation: runtime.dispose(role, {signal})
|
|
1403
|
+
}
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
const uniqueProviders = new Set(runtime.#providers.values());
|
|
1407
|
+
for (const provider of uniqueProviders) {
|
|
1408
|
+
if (selectedProviders.has(provider)
|
|
1409
|
+
|| runtime.#disposeAllCompletedProviders.has(provider)) {
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
const slot = runtime.#slots[provider.role];
|
|
1413
|
+
const selection = ROUTE_KEYS.map(
|
|
1414
|
+
function selectRegisteredAIProviderRoute(routeName) {
|
|
1415
|
+
return slot.routes[routeName];
|
|
1416
|
+
}
|
|
1417
|
+
).find(
|
|
1418
|
+
function findRegisteredAIProviderRoute(candidate) {
|
|
1419
|
+
return candidate?.providerId === provider.id;
|
|
1420
|
+
}
|
|
1421
|
+
) ?? null;
|
|
1422
|
+
tasks.push(
|
|
1423
|
+
{
|
|
1424
|
+
provider,
|
|
1425
|
+
operation: Promise.resolve().then(
|
|
1426
|
+
async function disposeUnselectedAIProvider() {
|
|
1427
|
+
if (signal?.aborted) {
|
|
1428
|
+
throw normalizedAbort();
|
|
1429
|
+
}
|
|
1430
|
+
await provider.dispose(
|
|
1431
|
+
{
|
|
1432
|
+
role: provider.role,
|
|
1433
|
+
selection,
|
|
1434
|
+
signal
|
|
1435
|
+
}
|
|
1436
|
+
);
|
|
1437
|
+
const disposedStatus = validateProviderStatus(
|
|
1438
|
+
provider.status()
|
|
1439
|
+
);
|
|
1440
|
+
if (disposedStatus.loaded || disposedStatus.busy) {
|
|
1441
|
+
throw operationError(
|
|
1442
|
+
`AI provider ${providerKey(provider.role, provider.id)} remained active after disposal.`,
|
|
1443
|
+
'ARCANE_AI_PROVIDER_DISPOSE_INCOMPLETE'
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
)
|
|
1448
|
+
}
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
const results = await Promise.allSettled(
|
|
1452
|
+
tasks.map(function runAIProviderDisposal(task) {
|
|
1453
|
+
return task.operation;
|
|
1454
|
+
})
|
|
1455
|
+
);
|
|
1456
|
+
results.forEach(function retainCompletedAIProviderDisposal(result, index) {
|
|
1457
|
+
const provider = tasks[index].provider;
|
|
1458
|
+
if (result.status === 'fulfilled' && provider) {
|
|
1459
|
+
runtime.#disposeAllCompletedProviders.add(provider);
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
const failed = results.find(function findFailedDispose(result) {
|
|
1463
|
+
return result.status === 'rejected';
|
|
1464
|
+
});
|
|
1465
|
+
if (failed) {
|
|
1466
|
+
throw failed.reason;
|
|
1467
|
+
}
|
|
1468
|
+
runtime.#providers.clear();
|
|
1469
|
+
runtime.#disposeAllCompletedProviders.clear();
|
|
1470
|
+
for (const role of AI_RUNTIME_ROLES) {
|
|
1471
|
+
const slot = runtime.#slots[role];
|
|
1472
|
+
slot.generation += 1;
|
|
1473
|
+
slot.routes = Object.freeze({default: null, localOnly: null});
|
|
1474
|
+
slot.selection = null;
|
|
1475
|
+
slot.loadController = null;
|
|
1476
|
+
slot.loadPromise = null;
|
|
1477
|
+
slot.unloadPromise = null;
|
|
1478
|
+
slot.disposePromise = null;
|
|
1479
|
+
slot.request = null;
|
|
1480
|
+
slot.ready = false;
|
|
1481
|
+
slot.disposed = true;
|
|
1482
|
+
}
|
|
1483
|
+
publishAIRuntimeRolesState(
|
|
1484
|
+
{
|
|
1485
|
+
llm: roleRecord('llm', null, {state: 'disposed'}),
|
|
1486
|
+
stt: roleRecord('stt', null, {state: 'disposed'}),
|
|
1487
|
+
tts: roleRecord('tts', null, {state: 'disposed'})
|
|
1488
|
+
}
|
|
1489
|
+
);
|
|
1490
|
+
runtime.#closing = false;
|
|
1491
|
+
runtime.#closed = true;
|
|
1492
|
+
runtime.#unsubscribeIntents?.();
|
|
1493
|
+
runtime.#unsubscribeIntents = null;
|
|
1494
|
+
return runtime.status();
|
|
1495
|
+
})();
|
|
1496
|
+
this.#disposeAllPromise = disposing.then(
|
|
1497
|
+
function releaseCompletedAIProviderRuntimeDisposal(result) {
|
|
1498
|
+
runtime.#disposeAllPromise = null;
|
|
1499
|
+
return result;
|
|
1500
|
+
},
|
|
1501
|
+
function releaseFailedAIProviderRuntimeDisposal(error) {
|
|
1502
|
+
runtime.#disposeAllPromise = null;
|
|
1503
|
+
throw error;
|
|
1504
|
+
}
|
|
1505
|
+
);
|
|
1506
|
+
return this.#disposeAllPromise;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
request(role, options = {}) {
|
|
1510
|
+
this.#assertOpen();
|
|
1511
|
+
this.#assertNotConfiguring();
|
|
1512
|
+
assertRole(role);
|
|
1513
|
+
assertClosedRecord(
|
|
1514
|
+
options,
|
|
1515
|
+
['operation', 'payload', 'localOnly', 'signal'],
|
|
1516
|
+
'AI provider request options'
|
|
1517
|
+
);
|
|
1518
|
+
if (!ROLE_OPERATION_SETS[role].has(options.operation)) {
|
|
1519
|
+
fail(
|
|
1520
|
+
`AI ${role} operation must be one of ${ROLE_OPERATIONS[role].join(', ')}.`
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
if (options.localOnly !== true && options.localOnly !== false) {
|
|
1524
|
+
fail('AI provider request localOnly must be a boolean.');
|
|
1525
|
+
}
|
|
1526
|
+
assertAbortSignal(options.signal);
|
|
1527
|
+
try {
|
|
1528
|
+
assertCallbackFreeProviderValue(options.payload);
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
return Promise.reject(error);
|
|
1531
|
+
}
|
|
1532
|
+
const slot = this.#slots[role];
|
|
1533
|
+
if (slot.disposed) {
|
|
1534
|
+
return Promise.reject(
|
|
1535
|
+
operationError(
|
|
1536
|
+
`AI role ${role} is disposed and must be explicitly reconfigured.`,
|
|
1537
|
+
'ARCANE_AI_ROLE_DISPOSED'
|
|
1538
|
+
)
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
if (slot.unloadPromise || slot.disposePromise) {
|
|
1542
|
+
return Promise.reject(
|
|
1543
|
+
operationError(
|
|
1544
|
+
`AI role ${role} is cleaning up.`,
|
|
1545
|
+
'ARCANE_AI_OPERATION_SUPERSEDED'
|
|
1546
|
+
)
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
if (slot.request) {
|
|
1550
|
+
return Promise.reject(
|
|
1551
|
+
operationError(
|
|
1552
|
+
`AI role ${role} already owns an active request.`,
|
|
1553
|
+
'ARCANE_AI_ROLE_BUSY'
|
|
1554
|
+
)
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
const targetSelection = options.localOnly
|
|
1558
|
+
? slot.routes.localOnly
|
|
1559
|
+
: slot.routes.default;
|
|
1560
|
+
if (!targetSelection) {
|
|
1561
|
+
return Promise.reject(
|
|
1562
|
+
operationError(
|
|
1563
|
+
options.localOnly
|
|
1564
|
+
? `No explicit local-only ${role} route is configured.`
|
|
1565
|
+
: `No default ${role} route is configured.`,
|
|
1566
|
+
options.localOnly
|
|
1567
|
+
? 'AI_LOCAL_MODEL_REQUIRED'
|
|
1568
|
+
: 'ARCANE_AI_ROLE_NOT_SELECTED'
|
|
1569
|
+
)
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
if (!this.#sameSelection(slot.selection, targetSelection)) {
|
|
1573
|
+
return Promise.reject(
|
|
1574
|
+
operationError(
|
|
1575
|
+
`The explicit ${options.localOnly ? 'local-only' : 'default'} ${role} route is not loaded.`,
|
|
1576
|
+
'ARCANE_AI_ROUTE_NOT_READY'
|
|
1577
|
+
)
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
const provider = this.#providerFor(slot);
|
|
1581
|
+
if (!provider) {
|
|
1582
|
+
return Promise.reject(
|
|
1583
|
+
operationError(
|
|
1584
|
+
`The selected ${role} provider is not registered.`,
|
|
1585
|
+
'ARCANE_AI_PROVIDER_UNAVAILABLE'
|
|
1586
|
+
)
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
let providerStatus;
|
|
1590
|
+
try {
|
|
1591
|
+
providerStatus = validateProviderStatus(provider.status());
|
|
1592
|
+
} catch (error) {
|
|
1593
|
+
slot.ready = false;
|
|
1594
|
+
const invalidStatus = operationError(
|
|
1595
|
+
`The selected ${role} provider did not return a valid status.`,
|
|
1596
|
+
'ARCANE_AI_PROVIDER_STATUS_INVALID',
|
|
1597
|
+
error
|
|
1598
|
+
);
|
|
1599
|
+
this.#publishRoleError(slot, invalidStatus, false);
|
|
1600
|
+
return Promise.reject(invalidStatus);
|
|
1601
|
+
}
|
|
1602
|
+
if (!slot.ready
|
|
1603
|
+
|| providerStatus.state !== 'ready'
|
|
1604
|
+
|| providerStatus.loaded !== true
|
|
1605
|
+
|| providerStatus.busy !== false) {
|
|
1606
|
+
slot.ready = false;
|
|
1607
|
+
return Promise.reject(
|
|
1608
|
+
operationError(
|
|
1609
|
+
`AI role ${role} is not ready.`,
|
|
1610
|
+
'ARCANE_AI_ROLE_NOT_READY'
|
|
1611
|
+
)
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
if (options.localOnly && targetSelection.localOnly !== true) {
|
|
1615
|
+
return Promise.reject(
|
|
1616
|
+
operationError(
|
|
1617
|
+
`AI role ${role} does not have a local-only route.`,
|
|
1618
|
+
'AI_LOCAL_MODEL_REQUIRED'
|
|
1619
|
+
)
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
const generation = slot.generation;
|
|
1624
|
+
const operationId = this.#nextOperationId(slot, options.operation);
|
|
1625
|
+
const controller = new AbortController();
|
|
1626
|
+
const detachSignal = this.#forwardAbort(options.signal, controller);
|
|
1627
|
+
const runtime = this;
|
|
1628
|
+
const requestRecord = {
|
|
1629
|
+
controller,
|
|
1630
|
+
promise: null,
|
|
1631
|
+
cancel: null
|
|
1632
|
+
};
|
|
1633
|
+
slot.request = requestRecord;
|
|
1634
|
+
publishAIRuntimeRoleState(
|
|
1635
|
+
role,
|
|
1636
|
+
roleRecord(
|
|
1637
|
+
role,
|
|
1638
|
+
slot.selection,
|
|
1639
|
+
{
|
|
1640
|
+
state: 'ready',
|
|
1641
|
+
loaded: true,
|
|
1642
|
+
busy: true,
|
|
1643
|
+
operationId
|
|
1644
|
+
}
|
|
1645
|
+
)
|
|
1646
|
+
);
|
|
1647
|
+
|
|
1648
|
+
function restoreAIProviderRoleAfterRequest(error) {
|
|
1649
|
+
if (slot.generation !== generation || slot.unloadPromise) {
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
let providerState = null;
|
|
1653
|
+
try {
|
|
1654
|
+
providerState = validateProviderStatus(provider.status());
|
|
1655
|
+
} catch {
|
|
1656
|
+
// A malformed provider status is itself a lifecycle failure.
|
|
1657
|
+
}
|
|
1658
|
+
if (providerState?.state === 'ready'
|
|
1659
|
+
&& providerState.loaded
|
|
1660
|
+
&& !providerState.busy) {
|
|
1661
|
+
slot.ready = true;
|
|
1662
|
+
publishAIRuntimeRoleState(
|
|
1663
|
+
role,
|
|
1664
|
+
roleRecord(
|
|
1665
|
+
role,
|
|
1666
|
+
slot.selection,
|
|
1667
|
+
{
|
|
1668
|
+
state: 'ready',
|
|
1669
|
+
loaded: true
|
|
1670
|
+
}
|
|
1671
|
+
)
|
|
1672
|
+
);
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
slot.ready = false;
|
|
1676
|
+
runtime.#publishRoleError(slot, error, providerState?.loaded === true);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
if (options.operation === 'stream') {
|
|
1680
|
+
let providerHandle = null;
|
|
1681
|
+
let providerOpenPromise = null;
|
|
1682
|
+
let iterator = null;
|
|
1683
|
+
let cleanupPromise = null;
|
|
1684
|
+
let terminalSettled = false;
|
|
1685
|
+
let resolveTerminal;
|
|
1686
|
+
let rejectTerminal;
|
|
1687
|
+
const terminal = new Promise(function createAIProviderStreamTerminal(resolve, reject) {
|
|
1688
|
+
resolveTerminal = resolve;
|
|
1689
|
+
rejectTerminal = reject;
|
|
1690
|
+
});
|
|
1691
|
+
terminal.catch(function retainAIProviderStreamTerminalRejection() {});
|
|
1692
|
+
requestRecord.promise = terminal;
|
|
1693
|
+
|
|
1694
|
+
function settleAIProviderStream(error, value) {
|
|
1695
|
+
if (terminalSettled) {
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
terminalSettled = true;
|
|
1699
|
+
controller.signal.removeEventListener(
|
|
1700
|
+
'abort',
|
|
1701
|
+
cancelAbortedAIProviderStream
|
|
1702
|
+
);
|
|
1703
|
+
detachSignal();
|
|
1704
|
+
if (slot.request === requestRecord) {
|
|
1705
|
+
slot.request = null;
|
|
1706
|
+
}
|
|
1707
|
+
if (error) {
|
|
1708
|
+
restoreAIProviderRoleAfterRequest(error);
|
|
1709
|
+
rejectTerminal(error);
|
|
1710
|
+
} else {
|
|
1711
|
+
restoreAIProviderRoleAfterRequest(null);
|
|
1712
|
+
resolveTerminal(value);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function beginAIProviderStreamHandleCleanup(opened, activeIterator, reason) {
|
|
1717
|
+
const cleanup = [];
|
|
1718
|
+
if (typeof opened?.cancel === 'function') {
|
|
1719
|
+
cleanup.push(
|
|
1720
|
+
Promise.resolve().then(function cancelOpenedAIStream() {
|
|
1721
|
+
return opened.cancel(reason);
|
|
1722
|
+
})
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
if (activeIterator && typeof activeIterator.return === 'function') {
|
|
1726
|
+
cleanup.push(
|
|
1727
|
+
Promise.resolve().then(function returnOpenedAIStream() {
|
|
1728
|
+
return activeIterator.return();
|
|
1729
|
+
})
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
return Promise.allSettled(cleanup);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
async function cleanupAIProviderStreamHandle(opened, activeIterator, reason) {
|
|
1736
|
+
return awaitBoundedStreamCleanup(
|
|
1737
|
+
beginAIProviderStreamHandleCleanup(
|
|
1738
|
+
opened,
|
|
1739
|
+
activeIterator,
|
|
1740
|
+
reason
|
|
1741
|
+
)
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
async function cleanupOwnedAIProviderStream(reason) {
|
|
1746
|
+
if (providerHandle) {
|
|
1747
|
+
return cleanupAIProviderStreamHandle(
|
|
1748
|
+
providerHandle,
|
|
1749
|
+
iterator,
|
|
1750
|
+
reason
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
if (!providerOpenPromise) {
|
|
1754
|
+
return {completed: true, results: []};
|
|
1755
|
+
}
|
|
1756
|
+
const lateCleanup = providerOpenPromise.then(
|
|
1757
|
+
async function cleanupLateAIProviderStream(lateHandle) {
|
|
1758
|
+
if (!lateHandle || typeof lateHandle.cancel !== 'function') {
|
|
1759
|
+
throw operationError(
|
|
1760
|
+
'The late AI provider stream did not expose cancellable ownership.',
|
|
1761
|
+
'ARCANE_AI_STREAM_CLEANUP_INCOMPLETE'
|
|
1762
|
+
);
|
|
1763
|
+
}
|
|
1764
|
+
providerHandle = lateHandle;
|
|
1765
|
+
const results = await beginAIProviderStreamHandleCleanup(
|
|
1766
|
+
lateHandle,
|
|
1767
|
+
null,
|
|
1768
|
+
reason
|
|
1769
|
+
);
|
|
1770
|
+
assertStreamCleanupComplete(
|
|
1771
|
+
{completed: true, results}
|
|
1772
|
+
);
|
|
1773
|
+
},
|
|
1774
|
+
function confirmRejectedAIProviderStreamOpen() {
|
|
1775
|
+
// A rejected open confirms that no provider handle was returned.
|
|
1776
|
+
}
|
|
1777
|
+
);
|
|
1778
|
+
return awaitBoundedStreamCleanup(
|
|
1779
|
+
Promise.allSettled([lateCleanup])
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
async function cancelAIProviderStream(reason, terminalError = null) {
|
|
1784
|
+
if (cleanupPromise) {
|
|
1785
|
+
return cleanupPromise;
|
|
1786
|
+
}
|
|
1787
|
+
let resolveCleanup;
|
|
1788
|
+
let rejectCleanup;
|
|
1789
|
+
cleanupPromise = new Promise(
|
|
1790
|
+
function createAIProviderStreamCleanup(resolve, reject) {
|
|
1791
|
+
resolveCleanup = resolve;
|
|
1792
|
+
rejectCleanup = reject;
|
|
1793
|
+
}
|
|
1794
|
+
);
|
|
1795
|
+
controller.abort();
|
|
1796
|
+
(async function closeAIProviderStream() {
|
|
1797
|
+
const terminalOutcome = terminalError ?? normalizedAbort(
|
|
1798
|
+
reason instanceof Error
|
|
1799
|
+
? reason
|
|
1800
|
+
: operationError(
|
|
1801
|
+
'The AI stream was cancelled.',
|
|
1802
|
+
'ARCANE_AI_REQUEST_ABORTED'
|
|
1803
|
+
)
|
|
1804
|
+
);
|
|
1805
|
+
try {
|
|
1806
|
+
const cleanupOutcome = await cleanupOwnedAIProviderStream(reason);
|
|
1807
|
+
assertStreamCleanupComplete(cleanupOutcome);
|
|
1808
|
+
settleAIProviderStream(terminalOutcome);
|
|
1809
|
+
} catch (cleanupError) {
|
|
1810
|
+
const incomplete = cleanupError?.code === 'ARCANE_AI_STREAM_CLEANUP_INCOMPLETE'
|
|
1811
|
+
? cleanupError
|
|
1812
|
+
: operationError(
|
|
1813
|
+
'The AI provider stream did not confirm bounded cleanup.',
|
|
1814
|
+
'ARCANE_AI_STREAM_CLEANUP_INCOMPLETE',
|
|
1815
|
+
cleanupError
|
|
1816
|
+
);
|
|
1817
|
+
settleAIProviderStream(incomplete);
|
|
1818
|
+
throw incomplete;
|
|
1819
|
+
}
|
|
1820
|
+
})().then(resolveCleanup, rejectCleanup);
|
|
1821
|
+
await cleanupPromise;
|
|
1822
|
+
}
|
|
1823
|
+
requestRecord.cancel = cancelAIProviderStream;
|
|
1824
|
+
function cancelAbortedAIProviderStream() {
|
|
1825
|
+
cancelAIProviderStream(normalizedAbort()).catch(
|
|
1826
|
+
function retainAbortedAIProviderStreamCleanupFailure() {}
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
controller.signal.addEventListener(
|
|
1830
|
+
'abort',
|
|
1831
|
+
cancelAbortedAIProviderStream,
|
|
1832
|
+
{once: true}
|
|
1833
|
+
);
|
|
1834
|
+
|
|
1835
|
+
const openPromise = (async function openAIProviderStream() {
|
|
1836
|
+
let opened = null;
|
|
1837
|
+
let detachOpenAbort = function detachAbsentAIStreamOpenAbort() {};
|
|
1838
|
+
try {
|
|
1839
|
+
if (controller.signal.aborted) {
|
|
1840
|
+
throw normalizedAbort();
|
|
1841
|
+
}
|
|
1842
|
+
providerOpenPromise = Promise.resolve().then(
|
|
1843
|
+
function requestAIProviderStream() {
|
|
1844
|
+
return provider.request(
|
|
1845
|
+
{
|
|
1846
|
+
role,
|
|
1847
|
+
selection: slot.selection,
|
|
1848
|
+
operation: options.operation,
|
|
1849
|
+
payload: options.payload,
|
|
1850
|
+
signal: controller.signal
|
|
1851
|
+
}
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
);
|
|
1855
|
+
const abortedOpen = new Promise(function rejectAbortedAIStreamOpen(resolve, reject) {
|
|
1856
|
+
function rejectAIStreamOpenAbort() {
|
|
1857
|
+
reject(normalizedAbort());
|
|
1858
|
+
}
|
|
1859
|
+
controller.signal.addEventListener(
|
|
1860
|
+
'abort',
|
|
1861
|
+
rejectAIStreamOpenAbort,
|
|
1862
|
+
{once: true}
|
|
1863
|
+
);
|
|
1864
|
+
detachOpenAbort = function detachAIStreamOpenAbort() {
|
|
1865
|
+
controller.signal.removeEventListener(
|
|
1866
|
+
'abort',
|
|
1867
|
+
rejectAIStreamOpenAbort
|
|
1868
|
+
);
|
|
1869
|
+
};
|
|
1870
|
+
});
|
|
1871
|
+
opened = await Promise.race([providerOpenPromise, abortedOpen]);
|
|
1872
|
+
if (!opened
|
|
1873
|
+
|| typeof opened[Symbol.asyncIterator] !== 'function'
|
|
1874
|
+
|| typeof opened.cancel !== 'function'
|
|
1875
|
+
|| !opened.result
|
|
1876
|
+
|| typeof opened.result.then !== 'function') {
|
|
1877
|
+
throw operationError(
|
|
1878
|
+
'AI stream providers must return an async iterable with result and cancel().',
|
|
1879
|
+
'ARCANE_AI_PROVIDER_STREAM_INVALID'
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
providerHandle = opened;
|
|
1883
|
+
iterator = opened[Symbol.asyncIterator]();
|
|
1884
|
+
runtime.#assertCurrentOperation(slot, generation, controller.signal);
|
|
1885
|
+
Promise.resolve(opened.result).then(
|
|
1886
|
+
function acceptAIProviderStreamResult(value) {
|
|
1887
|
+
try {
|
|
1888
|
+
runtime.#assertCurrentOperation(
|
|
1889
|
+
slot,
|
|
1890
|
+
generation,
|
|
1891
|
+
controller.signal
|
|
1892
|
+
);
|
|
1893
|
+
settleAIProviderStream(null, value);
|
|
1894
|
+
} catch (error) {
|
|
1895
|
+
const normalized = isAbort(error, controller.signal)
|
|
1896
|
+
? normalizedAbort(error)
|
|
1897
|
+
: error;
|
|
1898
|
+
settleAIProviderStream(normalized);
|
|
1899
|
+
}
|
|
1900
|
+
},
|
|
1901
|
+
async function rejectAIProviderStreamResult(error) {
|
|
1902
|
+
const normalized = isAbort(error, controller.signal)
|
|
1903
|
+
? normalizedAbort(error)
|
|
1904
|
+
: error;
|
|
1905
|
+
try {
|
|
1906
|
+
await cancelAIProviderStream(normalized, normalized);
|
|
1907
|
+
} catch {
|
|
1908
|
+
// The original provider result error remains terminal.
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
);
|
|
1912
|
+
|
|
1913
|
+
const handle = {
|
|
1914
|
+
result: terminal,
|
|
1915
|
+
cancel: cancelAIProviderStream,
|
|
1916
|
+
async next(value) {
|
|
1917
|
+
try {
|
|
1918
|
+
return await iterator.next(value);
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
await cancelAIProviderStream(error);
|
|
1921
|
+
throw isAbort(error, controller.signal)
|
|
1922
|
+
? normalizedAbort(error)
|
|
1923
|
+
: error;
|
|
1924
|
+
}
|
|
1925
|
+
},
|
|
1926
|
+
async return(value) {
|
|
1927
|
+
await cancelAIProviderStream(
|
|
1928
|
+
operationError(
|
|
1929
|
+
'The AI stream consumer stopped before completion.',
|
|
1930
|
+
'ARCANE_AI_REQUEST_ABORTED'
|
|
1931
|
+
)
|
|
1932
|
+
);
|
|
1933
|
+
return {value, done: true};
|
|
1934
|
+
},
|
|
1935
|
+
async throw(error) {
|
|
1936
|
+
await cancelAIProviderStream(error);
|
|
1937
|
+
throw error;
|
|
1938
|
+
},
|
|
1939
|
+
[Symbol.asyncIterator]() {
|
|
1940
|
+
return this;
|
|
1941
|
+
}
|
|
1942
|
+
};
|
|
1943
|
+
return Object.freeze(handle);
|
|
1944
|
+
} catch (error) {
|
|
1945
|
+
const normalized = isAbort(error, controller.signal)
|
|
1946
|
+
? normalizedAbort(error)
|
|
1947
|
+
: error;
|
|
1948
|
+
if (cleanupPromise) {
|
|
1949
|
+
try {
|
|
1950
|
+
await cleanupPromise;
|
|
1951
|
+
} catch (cleanupError) {
|
|
1952
|
+
throw cleanupError;
|
|
1953
|
+
}
|
|
1954
|
+
} else if (providerHandle) {
|
|
1955
|
+
try {
|
|
1956
|
+
await cancelAIProviderStream(normalized, normalized);
|
|
1957
|
+
} catch {
|
|
1958
|
+
// The original stream-open failure remains authoritative.
|
|
1959
|
+
}
|
|
1960
|
+
} else if (opened) {
|
|
1961
|
+
await cleanupAIProviderStreamHandle(
|
|
1962
|
+
opened,
|
|
1963
|
+
null,
|
|
1964
|
+
normalized
|
|
1965
|
+
).catch(
|
|
1966
|
+
function retainRejectedAIProviderStreamHandleCleanup() {}
|
|
1967
|
+
);
|
|
1968
|
+
settleAIProviderStream(normalized);
|
|
1969
|
+
} else {
|
|
1970
|
+
settleAIProviderStream(normalized);
|
|
1971
|
+
}
|
|
1972
|
+
throw normalized;
|
|
1973
|
+
} finally {
|
|
1974
|
+
detachOpenAbort();
|
|
1975
|
+
}
|
|
1976
|
+
})();
|
|
1977
|
+
return openPromise;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
requestRecord.cancel = async function cancelAIProviderRequest() {
|
|
1981
|
+
controller.abort();
|
|
1982
|
+
await requestRecord.promise?.catch(
|
|
1983
|
+
function retainCancelledAIProviderRequest() {}
|
|
1984
|
+
);
|
|
1985
|
+
};
|
|
1986
|
+
requestRecord.promise = (async function requestAIProviderRole() {
|
|
1987
|
+
let requestError = null;
|
|
1988
|
+
try {
|
|
1989
|
+
if (controller.signal.aborted) {
|
|
1990
|
+
throw normalizedAbort();
|
|
1991
|
+
}
|
|
1992
|
+
const result = await provider.request(
|
|
1993
|
+
{
|
|
1994
|
+
role,
|
|
1995
|
+
selection: slot.selection,
|
|
1996
|
+
operation: options.operation,
|
|
1997
|
+
payload: options.payload,
|
|
1998
|
+
signal: controller.signal
|
|
1999
|
+
}
|
|
2000
|
+
);
|
|
2001
|
+
runtime.#assertCurrentOperation(slot, generation, controller.signal);
|
|
2002
|
+
return result;
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
const normalized = isAbort(error, controller.signal)
|
|
2005
|
+
? normalizedAbort(error)
|
|
2006
|
+
: error;
|
|
2007
|
+
requestError = normalized;
|
|
2008
|
+
restoreAIProviderRoleAfterRequest(normalized);
|
|
2009
|
+
throw normalized;
|
|
2010
|
+
} finally {
|
|
2011
|
+
detachSignal();
|
|
2012
|
+
if (slot.request === requestRecord) {
|
|
2013
|
+
slot.request = null;
|
|
2014
|
+
}
|
|
2015
|
+
if (!requestError
|
|
2016
|
+
&& slot.generation === generation
|
|
2017
|
+
&& !slot.unloadPromise) {
|
|
2018
|
+
restoreAIProviderRoleAfterRequest(null);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
})();
|
|
2022
|
+
return requestRecord.promise;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
chat(payload, options = {}) {
|
|
2026
|
+
return this.#roleRequestAlias('llm', 'chat', payload, options);
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
stream(payload, options = {}) {
|
|
2030
|
+
return this.#roleRequestAlias('llm', 'stream', payload, options);
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
transcribe(payload, options = {}) {
|
|
2034
|
+
return this.#roleRequestAlias('stt', 'transcribe', payload, options);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
synthesize(payload, options = {}) {
|
|
2038
|
+
return this.#roleRequestAlias('tts', 'synthesize', payload, options);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
cancel(role) {
|
|
2042
|
+
assertRole(role);
|
|
2043
|
+
const slot = this.#slots[role];
|
|
2044
|
+
if (!slot.request) {
|
|
2045
|
+
return false;
|
|
2046
|
+
}
|
|
2047
|
+
slot.request.controller.abort();
|
|
2048
|
+
slot.request.cancel?.(
|
|
2049
|
+
operationError(
|
|
2050
|
+
`AI role ${role} request was cancelled.`,
|
|
2051
|
+
'ARCANE_AI_REQUEST_ABORTED'
|
|
2052
|
+
)
|
|
2053
|
+
).catch(function retainAIProviderCancelFailureInState() {});
|
|
2054
|
+
return true;
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
async setSpeechMuted(muted) {
|
|
2058
|
+
this.#assertOpen();
|
|
2059
|
+
this.#assertNotConfiguring();
|
|
2060
|
+
if (typeof muted !== 'boolean') {
|
|
2061
|
+
fail('AI speech muted state must be a boolean.');
|
|
2062
|
+
}
|
|
2063
|
+
this.#speechDesiredMuted = muted;
|
|
2064
|
+
if (muted) {
|
|
2065
|
+
this.#speechMuted = true;
|
|
2066
|
+
this.#slots.tts.loadController?.abort();
|
|
2067
|
+
this.cancel('tts');
|
|
2068
|
+
}
|
|
2069
|
+
const runtime = this;
|
|
2070
|
+
const transition = this.#speechTransition.catch(
|
|
2071
|
+
function retainPriorAISpeechTransitionFailure() {}
|
|
2072
|
+
).then(
|
|
2073
|
+
async function reconcileLatestAISpeechPreference() {
|
|
2074
|
+
while (true) {
|
|
2075
|
+
const desiredMuted = runtime.#speechDesiredMuted;
|
|
2076
|
+
if (desiredMuted) {
|
|
2077
|
+
runtime.cancel('tts');
|
|
2078
|
+
try {
|
|
2079
|
+
await runtime.unload('tts');
|
|
2080
|
+
} finally {
|
|
2081
|
+
runtime.#speechMuted = true;
|
|
2082
|
+
}
|
|
2083
|
+
} else {
|
|
2084
|
+
const slot = runtime.#slots.tts;
|
|
2085
|
+
if (slot.unloadPromise) {
|
|
2086
|
+
await slot.unloadPromise;
|
|
2087
|
+
}
|
|
2088
|
+
if (runtime.#speechDesiredMuted) {
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
2091
|
+
await runtime.load('tts');
|
|
2092
|
+
}
|
|
2093
|
+
if (runtime.#speechDesiredMuted === desiredMuted) {
|
|
2094
|
+
runtime.#speechMuted = desiredMuted;
|
|
2095
|
+
return runtime.status('tts');
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
);
|
|
2100
|
+
this.#speechTransition = transition;
|
|
2101
|
+
return transition;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
#providerFor(slot) {
|
|
2105
|
+
if (!slot.selection) {
|
|
2106
|
+
return null;
|
|
2107
|
+
}
|
|
2108
|
+
return this.#providers.get(
|
|
2109
|
+
providerKey(slot.role, slot.selection.providerId)
|
|
2110
|
+
) ?? null;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
#roleRequestAlias(role, operation, payload, options) {
|
|
2114
|
+
assertPlainObject(options, `AI ${operation} options`);
|
|
2115
|
+
for (const key of Reflect.ownKeys(options)) {
|
|
2116
|
+
if (key !== 'localOnly' && key !== 'signal') {
|
|
2117
|
+
fail(`AI ${operation} options contain an unknown option.`);
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
const localOnly = Object.hasOwn(options, 'localOnly')
|
|
2121
|
+
? options.localOnly
|
|
2122
|
+
: false;
|
|
2123
|
+
const signal = Object.hasOwn(options, 'signal')
|
|
2124
|
+
? options.signal
|
|
2125
|
+
: null;
|
|
2126
|
+
return this.request(
|
|
2127
|
+
role,
|
|
2128
|
+
{
|
|
2129
|
+
operation,
|
|
2130
|
+
payload,
|
|
2131
|
+
localOnly,
|
|
2132
|
+
signal
|
|
2133
|
+
}
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
#sameSelection(left, right) {
|
|
2138
|
+
return left === right
|
|
2139
|
+
|| Boolean(
|
|
2140
|
+
left
|
|
2141
|
+
&& right
|
|
2142
|
+
&& left.providerId === right.providerId
|
|
2143
|
+
&& left.modelId === right.modelId
|
|
2144
|
+
&& left.localOnly === right.localOnly
|
|
2145
|
+
);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
#assertOpen() {
|
|
2149
|
+
if (this.#closed || this.#closing) {
|
|
2150
|
+
throw operationError(
|
|
2151
|
+
this.#closed
|
|
2152
|
+
? 'The AI provider runtime is disposed.'
|
|
2153
|
+
: 'The AI provider runtime is disposing.',
|
|
2154
|
+
this.#closed
|
|
2155
|
+
? 'ARCANE_AI_RUNTIME_DISPOSED'
|
|
2156
|
+
: 'ARCANE_AI_RUNTIME_DISPOSING'
|
|
2157
|
+
);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
#assertNotConfiguring() {
|
|
2162
|
+
if (this.#configuring) {
|
|
2163
|
+
throw operationError(
|
|
2164
|
+
'The AI provider runtime is committing a configuration.',
|
|
2165
|
+
'ARCANE_AI_RUNTIME_CONFIGURING'
|
|
2166
|
+
);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
#roleHasOwnedWork(slot) {
|
|
2171
|
+
return Boolean(
|
|
2172
|
+
slot.loadPromise
|
|
2173
|
+
|| slot.unloadPromise
|
|
2174
|
+
|| slot.disposePromise
|
|
2175
|
+
|| slot.request
|
|
2176
|
+
|| slot.ready
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
#nextOperationId(slot, operation) {
|
|
2181
|
+
if (slot.operationSequence === Number.MAX_SAFE_INTEGER) {
|
|
2182
|
+
throw operationError(
|
|
2183
|
+
`AI role ${slot.role} exhausted its operation sequence.`,
|
|
2184
|
+
'ARCANE_AI_OPERATION_SEQUENCE_EXHAUSTED'
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
slot.operationSequence += 1;
|
|
2188
|
+
return `${slot.role}-${operation}-${slot.operationSequence}`;
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
#assertCurrentOperation(slot, generation, signal) {
|
|
2192
|
+
if (signal?.aborted) {
|
|
2193
|
+
throw normalizedAbort();
|
|
2194
|
+
}
|
|
2195
|
+
if (slot.generation !== generation) {
|
|
2196
|
+
throw operationError(
|
|
2197
|
+
`AI role ${slot.role} operation was superseded.`,
|
|
2198
|
+
'ARCANE_AI_OPERATION_SUPERSEDED'
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
#forwardAbort(signal, controller) {
|
|
2204
|
+
if (!signal) {
|
|
2205
|
+
return function detachAbsentAIProviderAbort() {};
|
|
2206
|
+
}
|
|
2207
|
+
function forwardAIProviderAbort() {
|
|
2208
|
+
controller.abort();
|
|
2209
|
+
}
|
|
2210
|
+
if (signal.aborted) {
|
|
2211
|
+
controller.abort();
|
|
2212
|
+
return function detachAlreadyAbortedAIProviderSignal() {};
|
|
2213
|
+
}
|
|
2214
|
+
signal.addEventListener('abort', forwardAIProviderAbort, {once: true});
|
|
2215
|
+
return function detachAIProviderAbort() {
|
|
2216
|
+
signal.removeEventListener('abort', forwardAIProviderAbort);
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
#publishLoadProgress(slot, generation, operationId, progress) {
|
|
2221
|
+
if (slot.generation !== generation || slot.loadController?.signal.aborted) {
|
|
2222
|
+
return false;
|
|
2223
|
+
}
|
|
2224
|
+
publishAIRuntimeRoleState(
|
|
2225
|
+
slot.role,
|
|
2226
|
+
roleRecord(
|
|
2227
|
+
slot.role,
|
|
2228
|
+
slot.selection,
|
|
2229
|
+
{
|
|
2230
|
+
state: 'loading',
|
|
2231
|
+
operationId,
|
|
2232
|
+
progress: immutableProgress(progress)
|
|
2233
|
+
}
|
|
2234
|
+
)
|
|
2235
|
+
);
|
|
2236
|
+
return true;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
#publishRoleError(slot, error, loaded) {
|
|
2240
|
+
publishAIRuntimeRoleState(
|
|
2241
|
+
slot.role,
|
|
2242
|
+
roleRecord(
|
|
2243
|
+
slot.role,
|
|
2244
|
+
slot.selection,
|
|
2245
|
+
{
|
|
2246
|
+
state: 'error',
|
|
2247
|
+
loaded,
|
|
2248
|
+
error: stateError(
|
|
2249
|
+
error,
|
|
2250
|
+
'ARCANE_AI_PROVIDER_OPERATION_FAILED'
|
|
2251
|
+
)
|
|
2252
|
+
}
|
|
2253
|
+
)
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
#acceptIntent(intent) {
|
|
2258
|
+
if (this.#closed || this.#closing || this.#configuring) {
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
const slot = this.#slots[intent.role];
|
|
2262
|
+
if (!slot.selection) {
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
let operation;
|
|
2266
|
+
try {
|
|
2267
|
+
if (intent.action === 'load') {
|
|
2268
|
+
operation = this.load(intent.role);
|
|
2269
|
+
} else if (intent.action === 'unload') {
|
|
2270
|
+
operation = this.unload(intent.role);
|
|
2271
|
+
} else {
|
|
2272
|
+
operation = this.dispose(intent.role);
|
|
2273
|
+
}
|
|
2274
|
+
} catch {
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
operation.catch(function retainAIProviderIntentFailureInState() {
|
|
2278
|
+
// Lifecycle failures are published as sticky role state.
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
export const aiProviderRuntime = new AIProviderRuntime(
|
|
2284
|
+
RUNTIME_CONSTRUCTION_AUTHORITY
|
|
2285
|
+
);
|
|
2286
|
+
|
|
2287
|
+
export function getAIProviderRuntime() {
|
|
2288
|
+
return aiProviderRuntime;
|
|
2289
|
+
}
|