arcane-os 0.2.0 → 0.2.1

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.
@@ -1,10 +1,15 @@
1
1
  import './DBOPFS.js';
2
2
  import UserEntity from '../entities/User.js';
3
3
  import {getAIPreferencesForRuntime} from './AIPreferenceRuntime.js';
4
- import {getAIProviderRuntime} from './AIProviderRuntime.js';
4
+ import {
5
+ AI_MODEL_AUTHORITY_PROTOCOL,
6
+ AI_PROVIDER_PROTOCOL,
7
+ getAIProviderRuntime
8
+ } from './AIProviderRuntime.js';
5
9
  import {normalizeOllamaModelIdentifier} from './OllamaModelIdentifier.js';
6
10
 
7
11
  let credentials='include';
12
+ const LEGACY_TTS_RESPONSE_FORMAT='opus';
8
13
  credentials='omit';
9
14
 
10
15
  const LEGACY_AI_SERVICES=new Set(['OPENAI','OLLAMA','LOCAL_SPEACH']);
@@ -27,6 +32,148 @@ function normalizeAIRequestAbort(error){
27
32
  return normalized;
28
33
  }
29
34
 
35
+ function legacyAIProviderError(message,code,cause){
36
+ const error=cause===undefined
37
+ ?new Error(message)
38
+ :new Error(message,{cause});
39
+ error.code=code;
40
+ return error;
41
+ }
42
+
43
+ function createLegacyAIStreamBridge(execute,sourceSignal){
44
+ const controller=new AbortController();
45
+ const queue=[];
46
+ const waiters=[];
47
+ let complete=false;
48
+ let failure=null;
49
+ let detached=false;
50
+
51
+ function forwardLegacyAIStreamAbort(){
52
+ if(!controller.signal.aborted){
53
+ controller.abort(sourceSignal?.reason);
54
+ }
55
+ }
56
+
57
+ function detachLegacyAIStreamAbort(){
58
+ if(detached){
59
+ return;
60
+ }
61
+ detached=true;
62
+ sourceSignal?.removeEventListener?.(
63
+ 'abort',
64
+ forwardLegacyAIStreamAbort
65
+ );
66
+ }
67
+
68
+ if(sourceSignal?.aborted){
69
+ forwardLegacyAIStreamAbort();
70
+ }else{
71
+ sourceSignal?.addEventListener?.(
72
+ 'abort',
73
+ forwardLegacyAIStreamAbort,
74
+ {once:true}
75
+ );
76
+ }
77
+
78
+ function emitLegacyAIStreamChunk(chunk){
79
+ if(complete){
80
+ return false;
81
+ }
82
+ const waiter=waiters.shift();
83
+ if(waiter){
84
+ waiter.resolve({value:chunk,done:false});
85
+ }else{
86
+ queue.push(chunk);
87
+ }
88
+ return true;
89
+ }
90
+
91
+ function finishLegacyAIStream(error){
92
+ if(complete){
93
+ return;
94
+ }
95
+ complete=true;
96
+ failure=error||null;
97
+ detachLegacyAIStreamAbort();
98
+ while(waiters.length){
99
+ const waiter=waiters.shift();
100
+ if(failure){
101
+ waiter.reject(failure);
102
+ }else{
103
+ waiter.resolve({value:undefined,done:true});
104
+ }
105
+ }
106
+ }
107
+
108
+ const result=Promise.resolve().then(
109
+ function executeLegacyAIStream(){
110
+ if(controller.signal.aborted){
111
+ throw normalizeAIRequestAbort(controller.signal.reason);
112
+ }
113
+ return execute({
114
+ emit:emitLegacyAIStreamChunk,
115
+ signal:controller.signal
116
+ });
117
+ }
118
+ ).then(
119
+ function acceptLegacyAIStreamResult(value){
120
+ finishLegacyAIStream(null);
121
+ return value;
122
+ },
123
+ function rejectLegacyAIStreamResult(error){
124
+ const normalized=isAIRequestAbort(error,controller.signal)
125
+ ?normalizeAIRequestAbort(error)
126
+ :error;
127
+ finishLegacyAIStream(normalized);
128
+ throw normalized;
129
+ }
130
+ );
131
+ result.catch(function retainLegacyAIStreamFailure() {});
132
+
133
+ async function cancelLegacyAIStream(reason){
134
+ if(!controller.signal.aborted){
135
+ controller.abort(reason);
136
+ }
137
+ await result.catch(function retainCancelledLegacyAIStream() {});
138
+ return true;
139
+ }
140
+
141
+ const handle={
142
+ result,
143
+ cancel:cancelLegacyAIStream,
144
+ next:function readLegacyAIStreamChunk(){
145
+ if(queue.length){
146
+ return Promise.resolve({value:queue.shift(),done:false});
147
+ }
148
+ if(complete){
149
+ return failure
150
+ ?Promise.reject(failure)
151
+ :Promise.resolve({value:undefined,done:true});
152
+ }
153
+ return new Promise(function waitForLegacyAIStreamChunk(resolve,reject){
154
+ waiters.push({resolve,reject});
155
+ });
156
+ },
157
+ return:async function returnLegacyAIStream(value){
158
+ await cancelLegacyAIStream(
159
+ legacyAIProviderError(
160
+ 'The legacy AI stream consumer stopped before completion.',
161
+ 'ARCANE_AI_REQUEST_ABORTED'
162
+ )
163
+ );
164
+ return {value,done:true};
165
+ },
166
+ throw:async function throwLegacyAIStream(error){
167
+ await cancelLegacyAIStream(error);
168
+ throw error;
169
+ },
170
+ [Symbol.asyncIterator]:function iterateLegacyAIStream(){
171
+ return this;
172
+ }
173
+ };
174
+ return Object.freeze(handle);
175
+ }
176
+
30
177
  function normalizeAIStartupOptions(options){
31
178
  if(options===undefined){
32
179
  return Object.freeze({startMuted:true,signal:null});
@@ -190,9 +337,21 @@ class AI {
190
337
  this.setAI(
191
338
  ...preferences
192
339
  );
340
+
341
+ const runtime=this;
342
+ globalThis.addEventListener?.(
343
+ 'arcane-ollama-ready',
344
+ function reconcileLegacyOllamaReadiness(){
345
+ runtime.#retainLegacyLLMReadiness(
346
+ runtime.#reconcileLegacyLLMReadiness()
347
+ );
348
+ }
349
+ );
193
350
  }
194
351
 
195
352
  #providerRuntime=getAIProviderRuntime();
353
+ #legacyLLMProviders=new Map();
354
+ #legacyLLMReadiness=Promise.resolve(null);
196
355
  #preferenceTuple=Object.freeze([
197
356
  'OPENAI',
198
357
  'OPENAI',
@@ -240,11 +399,311 @@ class AI {
240
399
 
241
400
  set license(value){
242
401
  this.#license=typeof value==='string' ? value.trim():'';
402
+ this.#retainLegacyLLMReadiness(
403
+ this.#reconcileLegacyLLMReadiness()
404
+ );
243
405
  return this.#license;
244
406
  }
245
407
 
408
+ #legacyLLMCapability(providerId){
409
+ if(providerId==='OPENAI'){
410
+ return this.llmService==='OPENAI'
411
+ &&Boolean(this.model)
412
+ &&Boolean(this.license)
413
+ &&typeof globalThis.fetch==='function';
414
+ }
415
+ if(providerId==='OLLAMA'){
416
+ return this.llmService==='OLLAMA'
417
+ &&Boolean(this.model)
418
+ &&Boolean(this.#nativeOllama());
419
+ }
420
+ return false;
421
+ }
422
+
423
+ #legacyLLMInspection(providerId,selection){
424
+ const localOnly=providerId==='OLLAMA';
425
+ if(!selection
426
+ ||selection.providerId!==providerId
427
+ ||selection.modelId!==this.model
428
+ ||selection.localOnly!==localOnly
429
+ ||this.llmService!==providerId){
430
+ return Object.freeze({
431
+ available:false,
432
+ code:'ARCANE_AI_MODEL_AUTHORITY_REQUIRED',
433
+ message:'The selected legacy LLM route does not match the active AI configuration.'
434
+ });
435
+ }
436
+ if(!this.#legacyLLMCapability(providerId)){
437
+ return Object.freeze({
438
+ available:false,
439
+ code:providerId==='OLLAMA'
440
+ ?'AI_NATIVE_LOCAL_REQUIRED'
441
+ :'AI_PROVIDER_NOT_CONFIGURED',
442
+ message:providerId==='OLLAMA'
443
+ ?'Local AI requires the capability-gated Arcane API.'
444
+ :'AI provider is not configured.'
445
+ });
446
+ }
447
+ return Object.freeze({
448
+ available:true,
449
+ authority:Object.freeze({
450
+ protocol:AI_MODEL_AUTHORITY_PROTOCOL,
451
+ providerId,
452
+ modelId:selection.modelId,
453
+ admitted:true
454
+ })
455
+ });
456
+ }
457
+
458
+ #createLegacyLLMProvider(providerId){
459
+ const runtime=this;
460
+ const localOnly=providerId==='OLLAMA';
461
+ let state='unloaded';
462
+ let busy=false;
463
+
464
+ function statusLegacyLLMProvider(){
465
+ if(state==='ready'
466
+ &&!busy
467
+ &&!runtime.#legacyLLMCapability(providerId)){
468
+ state='unloaded';
469
+ }
470
+ return Object.freeze({
471
+ state,
472
+ loaded:state==='ready',
473
+ busy
474
+ });
475
+ }
476
+
477
+ function assertLegacyLLMSelection(selection){
478
+ const inspection=runtime.#legacyLLMInspection(
479
+ providerId,
480
+ selection
481
+ );
482
+ if(!inspection.available){
483
+ throw legacyAIProviderError(
484
+ inspection.message,
485
+ inspection.code
486
+ );
487
+ }
488
+ return inspection;
489
+ }
490
+
491
+ function releaseLegacyLLMRequest(){
492
+ busy=false;
493
+ }
494
+
495
+ return Object.freeze({
496
+ protocol:AI_PROVIDER_PROTOCOL,
497
+ role:'llm',
498
+ id:providerId,
499
+ localOnly,
500
+ catalog:function catalogLegacyLLMProvider(){
501
+ if(runtime.llmService!==providerId||!runtime.model){
502
+ return Object.freeze([]);
503
+ }
504
+ return Object.freeze([
505
+ Object.freeze({id:runtime.model})
506
+ ]);
507
+ },
508
+ inspect:function inspectLegacyLLMProvider(selection,{signal}={}){
509
+ if(signal?.aborted){
510
+ throw normalizeAIRequestAbort(signal.reason);
511
+ }
512
+ return runtime.#legacyLLMInspection(providerId,selection);
513
+ },
514
+ status:statusLegacyLLMProvider,
515
+ load:function loadLegacyLLMProvider(context={}){
516
+ if(context.signal?.aborted){
517
+ throw normalizeAIRequestAbort(context.signal.reason);
518
+ }
519
+ if(state==='disposed'){
520
+ throw legacyAIProviderError(
521
+ 'The legacy LLM provider is disposed.',
522
+ 'ARCANE_AI_PROVIDER_DISPOSED'
523
+ );
524
+ }
525
+ if(busy){
526
+ throw legacyAIProviderError(
527
+ 'The legacy LLM provider owns an active request.',
528
+ 'ARCANE_AI_ROLE_BUSY'
529
+ );
530
+ }
531
+ if(typeof context.progress!=='function'){
532
+ throw new TypeError(
533
+ 'Legacy LLM provider load progress must be a function.'
534
+ );
535
+ }
536
+ const inspection=assertLegacyLLMSelection(context.selection);
537
+ state='loading';
538
+ context.progress({
539
+ phase:'capability',
540
+ completed:0,
541
+ total:1,
542
+ unit:'items',
543
+ heartbeat:false
544
+ });
545
+ if(context.signal?.aborted){
546
+ state='unloaded';
547
+ throw normalizeAIRequestAbort(context.signal.reason);
548
+ }
549
+ state='ready';
550
+ context.progress({
551
+ phase:'capability',
552
+ completed:1,
553
+ total:1,
554
+ unit:'items',
555
+ heartbeat:false
556
+ });
557
+ return Object.freeze({
558
+ authority:inspection.authority,
559
+ status:statusLegacyLLMProvider()
560
+ });
561
+ },
562
+ request:function requestLegacyLLMProvider(context={}){
563
+ if(context.signal?.aborted){
564
+ throw normalizeAIRequestAbort(context.signal.reason);
565
+ }
566
+ assertLegacyLLMSelection(context.selection);
567
+ const current=statusLegacyLLMProvider();
568
+ if(current.state!=='ready'||!current.loaded){
569
+ throw legacyAIProviderError(
570
+ 'The legacy LLM provider is not ready.',
571
+ 'ARCANE_AI_ROLE_NOT_READY'
572
+ );
573
+ }
574
+ if(busy){
575
+ throw legacyAIProviderError(
576
+ 'The legacy LLM provider owns an active request.',
577
+ 'ARCANE_AI_ROLE_BUSY'
578
+ );
579
+ }
580
+ busy=true;
581
+ if(context.operation==='chat'){
582
+ return Promise.resolve(
583
+ runtime.#requestLegacyLLMChat(
584
+ context.payload,
585
+ context.signal
586
+ )
587
+ ).finally(releaseLegacyLLMRequest);
588
+ }
589
+ if(context.operation==='stream'){
590
+ const handle=createLegacyAIStreamBridge(
591
+ function executeLegacyLLMProviderStream(bridge){
592
+ return runtime.#requestLegacyLLMStream(
593
+ context.payload,
594
+ bridge
595
+ );
596
+ },
597
+ context.signal
598
+ );
599
+ handle.result.then(
600
+ releaseLegacyLLMRequest,
601
+ releaseLegacyLLMRequest
602
+ );
603
+ return handle;
604
+ }
605
+ busy=false;
606
+ throw legacyAIProviderError(
607
+ 'The legacy LLM provider operation is unsupported.',
608
+ 'ARCANE_AI_PROVIDER_RUNTIME_INVALID'
609
+ );
610
+ },
611
+ unload:function unloadLegacyLLMProvider(context={}){
612
+ if(context.signal?.aborted){
613
+ throw normalizeAIRequestAbort(context.signal.reason);
614
+ }
615
+ state='unloaded';
616
+ busy=false;
617
+ return statusLegacyLLMProvider();
618
+ },
619
+ dispose:function disposeLegacyLLMProvider(context={}){
620
+ if(context.signal?.aborted){
621
+ throw normalizeAIRequestAbort(context.signal.reason);
622
+ }
623
+ state='disposed';
624
+ busy=false;
625
+ return statusLegacyLLMProvider();
626
+ }
627
+ });
628
+ }
629
+
630
+ #ensureLegacyLLMProvider(providerId){
631
+ if(providerId!=='OPENAI'&&providerId!=='OLLAMA'){
632
+ return false;
633
+ }
634
+ if(this.#providerRuntime.hasProvider('llm',providerId)){
635
+ return false;
636
+ }
637
+ const provider=this.#createLegacyLLMProvider(providerId);
638
+ const unregister=this.#providerRuntime.register(provider);
639
+ this.#legacyLLMProviders.set(
640
+ providerId,
641
+ Object.freeze({provider,unregister})
642
+ );
643
+ return true;
644
+ }
645
+
646
+ #releaseInactiveLegacyLLMProviders(activeProviderId){
647
+ for(const [providerId,record] of this.#legacyLLMProviders){
648
+ if(providerId===activeProviderId){
649
+ continue;
650
+ }
651
+ if(record.unregister()){
652
+ this.#legacyLLMProviders.delete(providerId);
653
+ }
654
+ }
655
+ }
656
+
657
+ #internalLegacyLLMSelection(localOnly=false){
658
+ const selection=this.#providerRuntime.selection(
659
+ 'llm',
660
+ {localOnly}
661
+ );
662
+ if(!selection
663
+ ||!this.#legacyLLMProviders.has(selection.providerId)
664
+ ||selection.providerId!==this.llmService
665
+ ||selection.modelId!==this.model){
666
+ return null;
667
+ }
668
+ return selection;
669
+ }
670
+
671
+ #retainLegacyLLMReadiness(operation){
672
+ this.#legacyLLMReadiness=Promise.resolve(operation).catch(
673
+ function retainLegacyLLMReadinessFailure(){
674
+ return null;
675
+ }
676
+ );
677
+ return this.#legacyLLMReadiness;
678
+ }
679
+
680
+ #reconcileLegacyLLMReadiness(){
681
+ const selection=this.#internalLegacyLLMSelection(false);
682
+ if(!selection){
683
+ return Promise.resolve(this.#providerRuntime.status('llm'));
684
+ }
685
+ const status=this.#providerRuntime.status('llm');
686
+ if(this.#legacyLLMCapability(selection.providerId)){
687
+ if(status.state==='ready'&&status.loaded===true){
688
+ return Promise.resolve(status);
689
+ }
690
+ return this.#providerRuntime.load('llm');
691
+ }
692
+ if(status.loaded===true
693
+ ||status.busy===true
694
+ ||status.state==='loading'
695
+ ||status.state==='unloading'){
696
+ return this.#providerRuntime.unload('llm');
697
+ }
698
+ return Promise.resolve(status);
699
+ }
700
+
246
701
  get configured(){
247
702
  if(this.#usesProviderRuntime('llm',this.llmService)){
703
+ if(this.#internalLegacyLLMSelection(false)
704
+ &&!this.#legacyLLMCapability(this.llmService)){
705
+ return false;
706
+ }
248
707
  const state=this.#providerRuntime.status('llm');
249
708
  return state.state==='ready'&&state.loaded===true;
250
709
  }
@@ -259,6 +718,19 @@ class AI {
259
718
 
260
719
  #assertServiceConfigured(service=this.llmService,role='llm'){
261
720
  if(this.#usesProviderRuntime(role,service)){
721
+ const internal=role==='llm'
722
+ ?this.#internalLegacyLLMSelection(false)
723
+ :null;
724
+ if(internal&&!this.#legacyLLMCapability(internal.providerId)){
725
+ const inspection=this.#legacyLLMInspection(
726
+ internal.providerId,
727
+ internal
728
+ );
729
+ throw legacyAIProviderError(
730
+ inspection.message,
731
+ inspection.code
732
+ );
733
+ }
262
734
  return true;
263
735
  }
264
736
  if(service==='OLLAMA'){
@@ -298,6 +770,11 @@ class AI {
298
770
  }
299
771
 
300
772
  #shouldUseProviderRuntime(role,service,localOnly=false){
773
+ // Legacy adapters publish lifecycle without replacing established
774
+ // public transport callbacks or their cancellation behavior.
775
+ if(role==='llm'&&this.#internalLegacyLLMSelection(localOnly)){
776
+ return false;
777
+ }
301
778
  if(!localOnly){
302
779
  return this.#usesProviderRuntime(role,service);
303
780
  }
@@ -361,6 +838,19 @@ class AI {
361
838
  }
362
839
  }
363
840
 
841
+ #normalizedLLMModel(service,model){
842
+ if(service==='OLLAMA'){
843
+ const mappedModel=model==='OPENAI'?null:this.#models[model];
844
+ return mappedModel
845
+ ||normalizeOllamaModelIdentifier(model)
846
+ ||model;
847
+ }
848
+ if(service==='OPENAI'){
849
+ return this.#models.OPENAI;
850
+ }
851
+ return model;
852
+ }
853
+
364
854
  #applyPreferenceTuple(tuple){
365
855
  const [
366
856
  llmService,
@@ -370,15 +860,10 @@ class AI {
370
860
  modelTTS,
371
861
  modelSTT
372
862
  ]=tuple;
373
- let normalizedLLMModel=model;
374
- if(llmService==='OLLAMA'){
375
- const mappedModel=model==='OPENAI'?null:this.#models[model];
376
- normalizedLLMModel=mappedModel
377
- ||normalizeOllamaModelIdentifier(model)
378
- ||model;
379
- }else if(llmService==='OPENAI'){
380
- normalizedLLMModel=this.#models.OPENAI;
381
- }
863
+ const normalizedLLMModel=this.#normalizedLLMModel(
864
+ llmService,
865
+ model
866
+ );
382
867
  this.llmService=llmService;
383
868
  this.sttService=sttService;
384
869
  this.ttsService=ttsService;
@@ -405,7 +890,10 @@ class AI {
405
890
 
406
891
  #routesFromPreferenceTuple(tuple){
407
892
  const roles={
408
- llm:[tuple[0],tuple[3]],
893
+ llm:[
894
+ tuple[0],
895
+ this.#normalizedLLMModel(tuple[0],tuple[3])
896
+ ],
409
897
  stt:[tuple[1],tuple[5]],
410
898
  tts:[tuple[2],tuple[4]]
411
899
  };
@@ -501,15 +989,30 @@ class AI {
501
989
  modelSTT
502
990
  ]);
503
991
  this.#assertValidProviderTuple(tuple);
992
+ this.#ensureLegacyLLMProvider(tuple[0]);
504
993
  this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
505
994
  this.#applyPreferenceTuple(tuple);
995
+ this.#releaseInactiveLegacyLLMProviders(tuple[0]);
996
+ this.#retainLegacyLLMReadiness(
997
+ this.#reconcileLegacyLLMReadiness()
998
+ );
506
999
  return true;
507
1000
  }
508
1001
 
509
1002
  configureProviders(selections){
510
- this.#assertRegisteredLegacyRoutes(selections);
511
- const configured=this.#providerRuntime.configure(selections);
1003
+ const prepared=this.#providerRuntime.validateConfiguration(selections);
1004
+ this.#ensureLegacyLLMProvider(
1005
+ prepared.llm.default?.providerId
1006
+ );
1007
+ this.#assertRegisteredLegacyRoutes(prepared);
1008
+ const configured=this.#providerRuntime.configure(prepared);
512
1009
  this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
1010
+ this.#releaseInactiveLegacyLLMProviders(
1011
+ configured.llm.default?.providerId
1012
+ );
1013
+ this.#retainLegacyLLMReadiness(
1014
+ this.#reconcileLegacyLLMReadiness()
1015
+ );
513
1016
  return configured;
514
1017
  }
515
1018
 
@@ -532,18 +1035,28 @@ class AI {
532
1035
  this.#assertValidProviderTuple(tuple);
533
1036
  this.stopAudio();
534
1037
  await this.#unloadProviderRolesForTransition();
1038
+ this.#ensureLegacyLLMProvider(tuple[0]);
535
1039
  this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
536
1040
  this.#applyPreferenceTuple(tuple);
1041
+ this.#releaseInactiveLegacyLLMProviders(tuple[0]);
1042
+ await this.#reconcileLegacyLLMReadiness();
537
1043
  return this.#providerRuntime.status();
538
1044
  }
539
1045
 
540
1046
  async transitionProviders(selections){
541
- this.#assertRegisteredLegacyRoutes(selections);
542
1047
  const prepared=this.#providerRuntime.validateConfiguration(selections);
1048
+ this.#ensureLegacyLLMProvider(
1049
+ prepared.llm.default?.providerId
1050
+ );
1051
+ this.#assertRegisteredLegacyRoutes(prepared);
543
1052
  this.stopAudio();
544
1053
  await this.#unloadProviderRolesForTransition();
545
1054
  const configured=this.#providerRuntime.configure(prepared);
546
1055
  this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
1056
+ this.#releaseInactiveLegacyLLMProviders(
1057
+ configured.llm.default?.providerId
1058
+ );
1059
+ await this.#reconcileLegacyLLMReadiness();
547
1060
  return configured;
548
1061
  }
549
1062
 
@@ -912,6 +1425,55 @@ class AI {
912
1425
  return typeof content==='string'?content:completion;
913
1426
  }
914
1427
 
1428
+ #requestLegacyLLMChat(payload={},signal=null){
1429
+ return this.#fetchLegacy(
1430
+ payload.messages??[],
1431
+ function ignoreLegacyLLMProviderResponse(){},
1432
+ payload.structuredOutput??false,
1433
+ payload.tools??[],
1434
+ payload.toolChoice??'auto',
1435
+ payload.parallelToolCalls??true,
1436
+ payload.id??Date.now(),
1437
+ function ignoreLegacyLLMProviderRequest(){},
1438
+ signal
1439
+ );
1440
+ }
1441
+
1442
+ #requestLegacyLLMStream(payload={},bridge){
1443
+ function emitLegacyLLMStreamText(text,id,thinking){
1444
+ if(typeof text!=='string'||!text){
1445
+ return;
1446
+ }
1447
+ bridge.emit(
1448
+ thinking
1449
+ ?{thinking:text}
1450
+ :{content:text}
1451
+ );
1452
+ }
1453
+
1454
+ function emitLegacyLLMStreamTool(name){
1455
+ if(typeof name==='string'&&name){
1456
+ bridge.emit({toolCalls:[{name}]});
1457
+ }
1458
+ }
1459
+
1460
+ return this.#streamLegacyMessage(
1461
+ payload.messages??[],
1462
+ emitLegacyLLMStreamText,
1463
+ function ignoreLegacyLLMProviderCompletion(){},
1464
+ payload.tools??[],
1465
+ payload.toolChoice??'auto',
1466
+ emitLegacyLLMStreamTool,
1467
+ payload.parallelToolCalls??true,
1468
+ payload.id??Date.now(),
1469
+ payload.seeThinking??false,
1470
+ bridge.signal,
1471
+ function ignoreLegacyLLMProviderRequest(){},
1472
+ payload.structuredOutput??false,
1473
+ false
1474
+ );
1475
+ }
1476
+
915
1477
  #assertRequiredOllamaToolCall(toolCalls=[],toolChoice='auto'){
916
1478
  const requiredName=toolChoice?.function?.name;
917
1479
 
@@ -1152,6 +1714,38 @@ class AI {
1152
1714
  signal
1153
1715
  });
1154
1716
  }
1717
+
1718
+ return this.#streamLegacyMessage(
1719
+ messages,
1720
+ streamHandler,
1721
+ streamComplete,
1722
+ tools,
1723
+ tool_choice,
1724
+ earlyFunctionTrigger,
1725
+ parallel_tool_calls,
1726
+ id,
1727
+ seeThinking,
1728
+ signal,
1729
+ requestHandler,
1730
+ structuredOutput
1731
+ );
1732
+ }
1733
+
1734
+ async #streamLegacyMessage(
1735
+ messages=[],
1736
+ streamHandler=function ignoreStreamChunk(){},
1737
+ streamComplete=function finishIgnoredStream(){},
1738
+ tools=[],
1739
+ tool_choice='auto',
1740
+ earlyFunctionTrigger=function ignoreEarlyFunction(){},
1741
+ parallel_tool_calls=true,
1742
+ id=Date.now(),
1743
+ seeThinking=false,
1744
+ signal=null,
1745
+ requestHandler=function ignoreStreamRequest(){},
1746
+ structuredOutput=false,
1747
+ finishSpeech=true
1748
+ ){
1155
1749
  let speechTurnCompleted=false;
1156
1750
 
1157
1751
  try{
@@ -1198,6 +1792,13 @@ class AI {
1198
1792
 
1199
1793
  const nativeOllama=this.#nativeOllama();
1200
1794
 
1795
+ if(this.llmService==='OLLAMA'&&!nativeOllama){
1796
+ throw legacyAIProviderError(
1797
+ 'Local AI requires the capability-gated Arcane API.',
1798
+ 'AI_NATIVE_LOCAL_REQUIRED'
1799
+ );
1800
+ }
1801
+
1201
1802
  if(nativeOllama){
1202
1803
  let nativeContent='';
1203
1804
  const nativeToolCalls={};
@@ -1294,7 +1895,9 @@ class AI {
1294
1895
  if(Object.keys(nativeToolCalls).length&&!nativeContent){
1295
1896
  streamHandler('',`M-${id}`,false);
1296
1897
  }
1297
- this.finishTTS();
1898
+ if(finishSpeech){
1899
+ this.finishTTS();
1900
+ }
1298
1901
  await streamComplete(nativeResult,`M-${id}`,isThinking);
1299
1902
  speechTurnCompleted=true;
1300
1903
  return nativeResult;
@@ -1496,7 +2099,9 @@ class AI {
1496
2099
  if(Object.keys(tool_funcs).length&&!chunkString){
1497
2100
  streamHandler('',`M-${id}`,false);
1498
2101
  }
1499
- this.finishTTS();
2102
+ if(finishSpeech){
2103
+ this.finishTTS();
2104
+ }
1500
2105
  await streamComplete(streamResult, `M-${id}`,isThinking);
1501
2106
 
1502
2107
  //sync
@@ -1609,6 +2214,31 @@ class AI {
1609
2214
  onResponse:responseHandler
1610
2215
  });
1611
2216
  }
2217
+
2218
+ return this.#fetchLegacy(
2219
+ messages,
2220
+ responseHandler,
2221
+ structuredOutput,
2222
+ tools,
2223
+ tool_choice,
2224
+ parallel_tool_calls,
2225
+ id,
2226
+ requestHandler,
2227
+ signal
2228
+ );
2229
+ }
2230
+
2231
+ async #fetchLegacy(
2232
+ messages=[],
2233
+ responseHandler=function ignoreFetchResponse(){},
2234
+ structuredOutput=false,
2235
+ tools=[],
2236
+ tool_choice='auto',
2237
+ parallel_tool_calls=true,
2238
+ id=Date.now(),
2239
+ requestHandler=function ignoreFetchRequest(){},
2240
+ signal=null,
2241
+ ){
1612
2242
  this.#assertServiceConfigured(this.llmService);
1613
2243
  if(signal&&(
1614
2244
  typeof signal.aborted!=='boolean'
@@ -1645,6 +2275,13 @@ class AI {
1645
2275
 
1646
2276
  const nativeOllama=this.#nativeOllama();
1647
2277
 
2278
+ if(this.llmService==='OLLAMA'&&!nativeOllama){
2279
+ throw legacyAIProviderError(
2280
+ 'Local AI requires the capability-gated Arcane API.',
2281
+ 'AI_NATIVE_LOCAL_REQUIRED'
2282
+ );
2283
+ }
2284
+
1648
2285
  if(nativeOllama){
1649
2286
  const ollamaTools=this.#ollamaTools(tools,tool_choice);
1650
2287
  const ollamaMessages=this.#ollamaMessages(messages,tool_choice);
@@ -1931,6 +2568,7 @@ class AI {
1931
2568
  async #requestSpeechAudio(job){
1932
2569
  if(this.#usesProviderRuntime('tts',this.ttsService)){
1933
2570
  job.abortController=new AbortController();
2571
+ const responseFormat=this.#providerSpeechResponseFormat();
1934
2572
  const response=await this.#providerRuntime.request(
1935
2573
  'tts',
1936
2574
  {
@@ -1939,7 +2577,7 @@ class AI {
1939
2577
  model:this.#providerRuntime.selection('tts')?.modelId,
1940
2578
  voice:String(window.user?.AI_voice||'af_heart'),
1941
2579
  input:job.text,
1942
- responseFormat:this.audioFormat,
2580
+ responseFormat,
1943
2581
  speed:this.voiceSpeed
1944
2582
  },
1945
2583
  localOnly:false,
@@ -2028,6 +2666,49 @@ class AI {
2028
2666
  return {chunks,type:this.audioType};
2029
2667
  }
2030
2668
 
2669
+ #providerSpeechResponseFormat(){
2670
+ const selection=this.#providerRuntime.selection('tts');
2671
+ if(!selection){
2672
+ return this.audioFormat;
2673
+ }
2674
+ const provider=this.#providerRuntime.catalog('tts').find(
2675
+ entry=>entry.providerId===selection.providerId
2676
+ );
2677
+ const model=provider?.models.find(entry=>entry?.id===selection.modelId);
2678
+ const speech=model?.speech;
2679
+ if(speech===undefined){
2680
+ return this.audioFormat;
2681
+ }
2682
+ const prototype=speech&&typeof speech==='object'
2683
+ ?Object.getPrototypeOf(speech)
2684
+ :null;
2685
+ const descriptors=prototype===Object.prototype||prototype===null
2686
+ ?Object.getOwnPropertyDescriptors(speech)
2687
+ :null;
2688
+ const formats=descriptors?.responseFormats?.value;
2689
+ const defaultFormat=descriptors?.defaultResponseFormat?.value;
2690
+ if(!Array.isArray(formats)
2691
+ ||formats.length<1
2692
+ ||!formats.every(format=>typeof format==='string'&&format.trim()===format&&format)
2693
+ ||typeof defaultFormat!=='string'
2694
+ ||!formats.includes(defaultFormat)){
2695
+ throw legacyAIProviderError(
2696
+ 'The selected TTS provider returned an invalid speech format catalog.',
2697
+ 'ARCANE_AI_PROVIDER_RUNTIME_INVALID'
2698
+ );
2699
+ }
2700
+ if(formats.includes(this.audioFormat)){
2701
+ return this.audioFormat;
2702
+ }
2703
+ if(this.audioFormat===LEGACY_TTS_RESPONSE_FORMAT){
2704
+ return defaultFormat;
2705
+ }
2706
+ throw legacyAIProviderError(
2707
+ `The selected TTS provider does not support ${this.audioFormat}.`,
2708
+ 'ARCANE_AI_UNSUPPORTED_RESPONSE_FORMAT'
2709
+ );
2710
+ }
2711
+
2031
2712
  async #normalizeProviderSpeechAudio(response){
2032
2713
  if(response instanceof Blob){
2033
2714
  return {