arcane-os 0.33.6 → 0.34.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.
@@ -9,6 +9,7 @@ import {
9
9
  projectArcaneDOMEvent
10
10
  } from 'arcane-os/event-manager';
11
11
  import {getAIPreferencesForRuntime} from './AIPreferenceRuntime.js';
12
+ import {subscribeAIRuntimeState} from './AIRuntimeState.js';
12
13
  import {
13
14
  AI_MODEL_AUTHORITY_PROTOCOL,
14
15
  AI_PROVIDER_PROTOCOL,
@@ -1171,8 +1172,11 @@ class AI {
1171
1172
  #builtInLLMProviders=new Map();
1172
1173
  #builtInLLMReadiness=Promise.resolve(null);
1173
1174
  #builtInSpeechProviders=new Map();
1175
+ #configuredSpeechProviders=new Map();
1174
1176
  #builtInSpeechReadiness=Promise.resolve(null);
1175
1177
  #speechControlGeneration=0;
1178
+ #speechActivation=null;
1179
+ #speechProviderConfiguration=null;
1176
1180
  #speechFailureSequence=0;
1177
1181
  #speechDiagnosticSequence=0;
1178
1182
  #speechJobSequence=0;
@@ -1233,6 +1237,10 @@ class AI {
1233
1237
  return this.#providerRuntime;
1234
1238
  }
1235
1239
 
1240
+ get speechActivationPending(){
1241
+ return this.#speechActivation!==null;
1242
+ }
1243
+
1236
1244
  [AI_PUBLISH_READY](){
1237
1245
  const operationId=`${this.#events.instanceId}:ready:1`;
1238
1246
  const reason=AI_INITIALIZATION_REASONS.initialized;
@@ -2056,10 +2064,6 @@ class AI {
2056
2064
  }
2057
2065
  return value;
2058
2066
  });
2059
- next[1]='LOCAL_SPEACH';
2060
- next[2]='LOCAL_SPEACH';
2061
- next[4]='LOCAL_SPEACH';
2062
- next[5]='LOCAL_SPEACH';
2063
2067
  return completeValue(next);
2064
2068
  }
2065
2069
 
@@ -2075,21 +2079,12 @@ class AI {
2075
2079
  }
2076
2080
 
2077
2081
  #assertDeviceSpeechConfiguration(configuration){
2078
- for(const role of ['stt','tts']){
2079
- for(const routeName of ['default','localOnly']){
2080
- const selection=configuration?.[role]?.[routeName];
2081
- if(selection&&selection.localOnly!==true){
2082
- const operation=role==='stt'
2083
- ?'Speech recognition'
2084
- :'Speech synthesis';
2085
- const error=new TypeError(
2086
- `${operation} supports on-device providers only.`
2087
- );
2088
- error.code=role==='stt'
2089
- ?'AI_STT_DEVICE_ONLY'
2090
- :'AI_TTS_DEVICE_ONLY';
2091
- throw error;
2092
- }
2082
+ for(const routeName of ['default','localOnly']){
2083
+ const selection=configuration?.stt?.[routeName];
2084
+ if(selection&&selection.localOnly!==true){
2085
+ const error=new TypeError('Speech recognition supports on-device providers only.');
2086
+ error.code='AI_STT_DEVICE_ONLY';
2087
+ throw error;
2093
2088
  }
2094
2089
  }
2095
2090
  }
@@ -2537,6 +2532,8 @@ class AI {
2537
2532
 
2538
2533
  #invalidateSpeechControl(){
2539
2534
  this.#speechControlGeneration+=1;
2535
+ this.#speechActivation?.controller.abort();
2536
+ this.#speechActivation=null;
2540
2537
  this.muted=true;
2541
2538
  this.stopAudio();
2542
2539
  }
@@ -2601,7 +2598,10 @@ class AI {
2601
2598
  expectedProviders[role]=null;
2602
2599
  continue;
2603
2600
  }
2604
- const record=this.#builtInSpeechProviders.get(
2601
+ const configured=this.#configuredSpeechProviders.get(role);
2602
+ const record=configured?.providerId===selection.providerId
2603
+ ?configured
2604
+ :this.#builtInSpeechProviders.get(
2605
2605
  this.#builtInSpeechProviderKey(role,selection.providerId)
2606
2606
  );
2607
2607
  if(!record
@@ -2615,7 +2615,7 @@ class AI {
2615
2615
  continue;
2616
2616
  }
2617
2617
  throw this.#browserSpeechProviderRouteOwnershipError(
2618
- `The selected ${role} route is not owned by the replaceable built-in AI speech boundary.`
2618
+ `The selected ${role} route is not owned by this AI speech configuration.`
2619
2619
  );
2620
2620
  }
2621
2621
  expectedProviders[role]=record.provider;
@@ -2649,6 +2649,9 @@ class AI {
2649
2649
  if(this.#builtInSpeechProviders.get(key)===record){
2650
2650
  this.#builtInSpeechProviders.delete(key);
2651
2651
  }
2652
+ if(this.#configuredSpeechProviders.get(record.role)===record){
2653
+ this.#configuredSpeechProviders.delete(record.role);
2654
+ }
2652
2655
  this.#browserSpeechRetiredBuiltInRecords.delete(record);
2653
2656
  }catch(error){
2654
2657
  failures.push(error);
@@ -2658,12 +2661,12 @@ class AI {
2658
2661
  throw aiBrowserSpeechError(
2659
2662
  AI_BROWSER_SPEECH_ERROR_CODES.providerDisposalRejected,
2660
2663
  AI_BROWSER_SPEECH_REASONS.providerDisposalRejected,
2661
- 'The replaced built-in speech providers could not be disposed.',
2664
+ 'The replaced speech providers could not be disposed.',
2662
2665
  failures.length===1
2663
2666
  ?failures[0]
2664
2667
  :new AggregateError(
2665
2668
  failures,
2666
- 'Multiple replaced built-in speech provider disposals were rejected.'
2669
+ 'Multiple replaced speech provider disposals were rejected.'
2667
2670
  ),
2668
2671
  {committed}
2669
2672
  );
@@ -2673,17 +2676,21 @@ class AI {
2673
2676
 
2674
2677
  #assertSynchronousBrowserSpeechSupersession(method){
2675
2678
  if(!this.#browserSpeechConfigurationRecord
2676
- &&this.#browserSpeechRetiredRecords.size===0){
2679
+ &&this.#browserSpeechRetiredRecords.size===0
2680
+ &&this.#configuredSpeechProviders.size===0){
2677
2681
  return;
2678
2682
  }
2679
2683
  throw aiBrowserSpeechError(
2680
2684
  AI_BROWSER_SPEECH_ERROR_CODES.asyncTransitionRequired,
2681
2685
  AI_BROWSER_SPEECH_REASONS.asyncTransitionRequired,
2682
- `${method} cannot replace SDK-owned browser speech providers synchronously; await AI.disposeBrowserSpeech() or use an asynchronous transition method.`
2686
+ `${method} cannot replace SDK-owned speech providers synchronously; use configureSpeechProvider, disposeBrowserSpeech, or an asynchronous transition method.`
2683
2687
  );
2684
2688
  }
2685
2689
 
2686
2690
  async #supersedeBrowserSpeechForRouteChange(){
2691
+ for(const role of [...this.#configuredSpeechProviders.keys()]){
2692
+ await this.configureSpeechProvider(role,null);
2693
+ }
2687
2694
  if(!this.#browserSpeechConfigurationRecord
2688
2695
  &&this.#browserSpeechRetiredRecords.size===0){
2689
2696
  return false;
@@ -3416,6 +3423,183 @@ class AI {
3416
3423
  return descriptor;
3417
3424
  }
3418
3425
 
3426
+ configureSpeechProvider(role,provider,options={}){
3427
+ if(role!=='stt'&&role!=='tts'){
3428
+ throw new TypeError('AI.configureSpeechProvider requires stt or tts.');
3429
+ }
3430
+ if(provider!==null&&provider?.role!==role){
3431
+ throw new TypeError('The speech provider must implement the selected role.');
3432
+ }
3433
+ if(role==='stt'&&provider&&provider.localOnly!==true){
3434
+ const error=new TypeError('Speech recognition supports on-device providers only.');
3435
+ error.code='AI_STT_DEVICE_ONLY';
3436
+ throw error;
3437
+ }
3438
+ const {modelId,signal,expectedProvider}=options;
3439
+ const runtime=this;
3440
+ const speechConfiguration=role==='tts'
3441
+ ?{generation:this.#speechControlGeneration,promise:null}
3442
+ :null;
3443
+ if(speechConfiguration)this.#speechProviderConfiguration=speechConfiguration;
3444
+ // Use the existing configuration lane. Unlike a replacement of the
3445
+ // same browser configuration, a disjoint role must not cancel its peer.
3446
+ const scheduled=this.#browserSpeechTransition.then(
3447
+ async function configureSelectedSpeechRole(){
3448
+ let committed=false;
3449
+ let candidateDisposalAttempted=false;
3450
+ const alreadyOwned=provider
3451
+ &&runtime.#providerRuntime.ownsProvider(role,provider);
3452
+ try{
3453
+ signal?.throwIfAborted();
3454
+ if(expectedProvider!==undefined
3455
+ &&(!runtime.#providerRuntime.ownsProvider(role,expectedProvider)
3456
+ ||runtime.#providerRuntime.selection(role)?.providerId
3457
+ !==expectedProvider?.id)){
3458
+ if(provider&&!alreadyOwned){
3459
+ candidateDisposalAttempted=true;
3460
+ await provider.dispose({role});
3461
+ }
3462
+ return false;
3463
+ }
3464
+ const previousBrowser=runtime.#browserSpeechConfigurationRecord;
3465
+ if(previousBrowser&&!runtime.#browserSpeechRecordIsActive(previousBrowser)){
3466
+ throw runtime.#browserSpeechProviderRouteOwnershipError(
3467
+ 'Browser speech ownership changed before single-role replacement.'
3468
+ );
3469
+ }
3470
+ const boundary=runtime.#browserSpeechReplacementBoundary(
3471
+ previousBrowser,[role]
3472
+ );
3473
+ const selectedModelId=provider
3474
+ ?modelId??provider.catalog()[0]?.id
3475
+ :null;
3476
+ if(provider&&!selectedModelId){
3477
+ throw new TypeError('A speech provider model must be selected.');
3478
+ }
3479
+ const selection=provider
3480
+ ?{providerId:provider.id,modelId:selectedModelId,localOnly:provider.localOnly}
3481
+ :null;
3482
+ const routes={
3483
+ default:selection,
3484
+ localOnly:selection?.localOnly===true?selection:null
3485
+ };
3486
+ if(provider===boundary.expectedProviders[role]
3487
+ &&runtime.#sameBrowserSpeechSelection(
3488
+ runtime.#providerRuntime.selection(role),selection
3489
+ )){
3490
+ return selection;
3491
+ }
3492
+ const priorSelection=runtime.#providerRuntime.selection(role);
3493
+ const hydratesPendingSelection=provider
3494
+ &&boundary.expectedProviders[role]===null
3495
+ &&priorSelection?.providerId===provider.id
3496
+ &&priorSelection.modelId===selectedModelId;
3497
+ const preservesNewerActivation=speechConfiguration
3498
+ &&runtime.#speechActivation?.configuration
3499
+ &&runtime.#speechActivation.generation>speechConfiguration.generation;
3500
+ if(role==='tts'&&!hydratesPendingSelection&&!preservesNewerActivation){
3501
+ runtime.#invalidateSpeechControl();
3502
+ }
3503
+ await runtime.#unloadSpeechProviderRolesForTransition(
3504
+ signal,boundary.expectedProviders,[role]
3505
+ );
3506
+ signal?.throwIfAborted();
3507
+ const replacement=runtime.#providerRuntime.replaceSpeechProvider(
3508
+ role,{
3509
+ provider,routes,
3510
+ expectedProvider:boundary.expectedProviders[role]
3511
+ }
3512
+ );
3513
+ committed=true;
3514
+ if(provider){
3515
+ runtime.#configuredSpeechProviders.set(role,{
3516
+ role,providerId:provider.id,provider,
3517
+ unregister:replacement.unregister
3518
+ });
3519
+ }else{
3520
+ runtime.#configuredSpeechProviders.delete(role);
3521
+ }
3522
+ for(const record of boundary.builtInRecords){
3523
+ if(record.provider!==provider){
3524
+ runtime.#browserSpeechRetiredBuiltInRecords.add(record);
3525
+ }
3526
+ }
3527
+ if(previousBrowser?.managedRoles.includes(role)){
3528
+ const remaining=previousBrowser.managedRoles.filter(
3529
+ function retainOtherSpeechRole(candidate){return candidate!==role;}
3530
+ );
3531
+ runtime.#retireBrowserSpeechRegistration(previousBrowser,[role]);
3532
+ if(remaining.length){
3533
+ const configuration={...previousBrowser.configuration};
3534
+ delete configuration[role];
3535
+ runtime.#browserSpeechConfigurationRecord={
3536
+ ...previousBrowser,
3537
+ configuration,
3538
+ configurationByRole:{...previousBrowser.configurationByRole,[role]:null},
3539
+ descriptor:{...previousBrowser.descriptor,[role]:null},
3540
+ providers:{...previousBrowser.providers,[role]:null},
3541
+ routes:runtime.#currentSpeechRoutes(),
3542
+ managedRoles:remaining,
3543
+ candidateRoles:remaining,
3544
+ unregisters:{...previousBrowser.unregisters,[role]:null},
3545
+ registrationState:{...previousBrowser.registrationState,[role]:false},
3546
+ retirementState:{stt:false,tts:false}
3547
+ };
3548
+ }else{
3549
+ runtime.#browserSpeechConfigurationRecord=null;
3550
+ }
3551
+ }
3552
+ runtime.#applySpeechPreferenceTuple(
3553
+ runtime.#tupleFromSpeechProviderRoutes(runtime.#currentSpeechRoutes())
3554
+ );
3555
+ await runtime.#cleanupRetiredBuiltInSpeechProviders({committed:true});
3556
+ if(previousBrowser?.retirementState[role]){
3557
+ await runtime.#cleanupBrowserSpeechRecord(previousBrowser,{committed:true});
3558
+ }
3559
+ return selection;
3560
+ }catch(error){
3561
+ if(committed){
3562
+ const committedError=error instanceof Error
3563
+ ?error
3564
+ :new Error('Speech provider cleanup failed.',{cause:error});
3565
+ committedError.committed=true;
3566
+ throw committedError;
3567
+ }
3568
+ if(role==='tts'&&provider
3569
+ &&runtime.#providerRuntime.selection('tts')?.providerId===provider.id
3570
+ &&!runtime.#providerRuntime.hasProvider('tts',provider.id)
3571
+ &&!(runtime.#speechActivation?.configuration
3572
+ &&runtime.#speechActivation.generation>speechConfiguration.generation)){
3573
+ runtime.#invalidateSpeechControl();
3574
+ }
3575
+ if(provider&&!alreadyOwned&&!candidateDisposalAttempted
3576
+ &&!runtime.#providerRuntime.ownsProvider(role,provider)){
3577
+ try{
3578
+ await provider.dispose({role});
3579
+ }catch(cleanupError){
3580
+ throw new AggregateError(
3581
+ [error,cleanupError],
3582
+ 'Speech configuration and candidate disposal failed.'
3583
+ );
3584
+ }
3585
+ }
3586
+ throw error;
3587
+ }finally{
3588
+ if(speechConfiguration
3589
+ &&runtime.#speechProviderConfiguration===speechConfiguration){
3590
+ runtime.#speechProviderConfiguration=null;
3591
+ }
3592
+ }
3593
+ }
3594
+ );
3595
+ if(speechConfiguration)speechConfiguration.promise=scheduled;
3596
+ this.#browserSpeechTransition=scheduled.then(
3597
+ function completeSingleSpeechRoleConfiguration(){},
3598
+ function retainSingleSpeechRoleConfigurationFailure(){}
3599
+ );
3600
+ return scheduled;
3601
+ }
3602
+
3419
3603
  configureBrowserSpeech(configuration,options={}){
3420
3604
  this.#traceSpeech('configureBrowserSpeech.call',{configuration,options});
3421
3605
  const normalized=normalizeBrowserSpeechConfiguration(configuration);
@@ -3719,6 +3903,8 @@ class AI {
3719
3903
  async startProviders(options){
3720
3904
  const normalized=normalizeAIStartupOptions(options);
3721
3905
  const generation=++this.#speechControlGeneration;
3906
+ this.#speechActivation?.controller.abort();
3907
+ this.#speechActivation=null;
3722
3908
  this.muted=true;
3723
3909
  if(normalized.startMuted){
3724
3910
  this.stopAudio();
@@ -3742,30 +3928,116 @@ class AI {
3742
3928
  return handle;
3743
3929
  }
3744
3930
 
3931
+ #waitForSelectedSpeechProvider(signal){
3932
+ const runtime=this;
3933
+ const selected=this.#providerRuntime.selection('tts');
3934
+ if(!selected)return Promise.resolve(false);
3935
+ return new Promise(function observeSelectedSpeechRegistration(resolve,reject){
3936
+ let unsubscribe=null;
3937
+ let settled=false;
3938
+ function finish(value,error){
3939
+ if(settled)return;
3940
+ settled=true;
3941
+ signal.removeEventListener('abort',cancel);
3942
+ unsubscribe?.();
3943
+ if(error)reject(error);
3944
+ else resolve(value);
3945
+ }
3946
+ function cancel(){
3947
+ finish(false,signal.reason??new DOMException('Speech activation cancelled.','AbortError'));
3948
+ }
3949
+ function observe(snapshot){
3950
+ const current=runtime.#providerRuntime.selection('tts');
3951
+ if(current?.providerId!==selected.providerId
3952
+ ||current?.modelId!==selected.modelId){
3953
+ finish(false);
3954
+ }else if(runtime.#providerRuntime.hasProvider('tts',current.providerId)){
3955
+ finish(true);
3956
+ }else if(snapshot.roles.tts.state==='disposed'){
3957
+ finish(false);
3958
+ }else if(snapshot.roles.tts.state==='error'){
3959
+ finish(false,snapshot.roles.tts.error
3960
+ ??new Error('Speech provider registration failed.'));
3961
+ }
3962
+ }
3963
+ signal.addEventListener('abort',cancel,{once:true});
3964
+ if(signal.aborted){cancel();return;}
3965
+ unsubscribe=subscribeAIRuntimeState(observe);
3966
+ if(settled)unsubscribe();
3967
+ });
3968
+ }
3969
+
3745
3970
  async setSpeechMuted(muted){
3746
3971
  const callId=this.#traceSpeech('setSpeechMuted.call',{requestedMuted:muted});
3747
3972
  if(!is.boolean(muted)){
3748
3973
  throw new TypeError('AI speech muted state must be a boolean.');
3749
3974
  }
3975
+ const configuration=this.#speechProviderConfiguration;
3976
+ if(!muted&&this.#speechActivation){
3977
+ if(!configuration||this.#speechActivation.configuration===configuration){
3978
+ return this.#speechActivation.promise;
3979
+ }
3980
+ this.#invalidateSpeechControl();
3981
+ }
3750
3982
  const generation=++this.#speechControlGeneration;
3751
3983
  this.muted=true;
3752
3984
  if(muted){
3985
+ this.#speechActivation?.controller.abort();
3986
+ this.#speechActivation=null;
3753
3987
  this.stopAudio();
3754
3988
  }
3755
- if(!this.#usesProviderRuntime('tts',this.ttsService)){
3989
+ if(!this.#usesProviderRuntime('tts',this.ttsService)
3990
+ &&(muted||!configuration)){
3756
3991
  if(generation===this.#speechControlGeneration)this.muted=muted;
3757
3992
  this.#traceSpeech('setSpeechMuted.result',{callId,result:true});
3758
3993
  return true;
3759
3994
  }
3760
- await this.#providerRuntime.setSpeechMuted(muted);
3761
- if(generation===this.#speechControlGeneration){
3762
- const status=this.#providerRuntime.status('tts');
3763
- this.muted=muted
3764
- ||status.state!=='ready'
3765
- ||status.loaded!==true;
3995
+ if(muted){
3996
+ await this.#providerRuntime.setSpeechMuted(true);
3997
+ return true;
3766
3998
  }
3767
- this.#traceSpeech('setSpeechMuted.result',{callId,result:true});
3768
- return true;
3999
+ const runtime=this;
4000
+ // A pending route owns the next utterance. Release old-route audio now,
4001
+ // before callers can queue text for this explicit activation.
4002
+ if(configuration)this.stopAudio();
4003
+ const activation={controller:new AbortController(),promise:null,generation,configuration};
4004
+ this.#speechActivation=activation;
4005
+ activation.promise=Promise.resolve().then(async function activateRequestedSpeech(){
4006
+ try{
4007
+ if(configuration){
4008
+ const configured=await runtime.#waitForSpeechOperation(
4009
+ configuration.promise,activation.controller.signal
4010
+ );
4011
+ activation.controller.signal.throwIfAborted();
4012
+ if(!configured)return false;
4013
+ }
4014
+ // Selection may precede registration. Observe the owner's sticky
4015
+ // state rather than treating that ordinary waiting state as failure.
4016
+ await runtime.#waitForSpeechOperation(
4017
+ runtime.#browserSpeechTransition,activation.controller.signal
4018
+ );
4019
+ if(!await runtime.#waitForSelectedSpeechProvider(activation.controller.signal)){
4020
+ return false;
4021
+ }
4022
+ // A registration event can fire before its configuration finishes.
4023
+ await runtime.#waitForSpeechOperation(
4024
+ runtime.#browserSpeechTransition,activation.controller.signal
4025
+ );
4026
+ activation.controller.signal.throwIfAborted();
4027
+ await runtime.#providerRuntime.setSpeechMuted(false);
4028
+ if(runtime.#speechActivation!==activation)return false;
4029
+ const status=runtime.#providerRuntime.status('tts');
4030
+ runtime.muted=status.state!=='ready'||status.loaded!==true;
4031
+ return !runtime.muted;
4032
+ }catch(error){
4033
+ if(activation.controller.signal.aborted)return false;
4034
+ throw error;
4035
+ }finally{
4036
+ if(runtime.#speechActivation===activation)runtime.#speechActivation=null;
4037
+ runtime.#traceSpeech('setSpeechMuted.result',{callId,result:!runtime.muted});
4038
+ }
4039
+ });
4040
+ return activation.promise;
3769
4041
  }
3770
4042
 
3771
4043
  #nativeOllama(){
@@ -4171,7 +4443,8 @@ class AI {
4171
4443
  function ignoreBuiltInLLMProviderRequest(){},
4172
4444
  signal,
4173
4445
  payload.reasoningEffort,
4174
- onRetry
4446
+ onRetry,
4447
+ payload.model
4175
4448
  );
4176
4449
  }
4177
4450
 
@@ -4202,7 +4475,8 @@ class AI {
4202
4475
  function ignoreBuiltInLLMStreamResult(){},
4203
4476
  payload.reasoningEffort,
4204
4477
  observeToolText,
4205
- onRetry
4478
+ onRetry,
4479
+ payload.model
4206
4480
  );
4207
4481
  }
4208
4482
 
@@ -4283,7 +4557,7 @@ class AI {
4283
4557
  });
4284
4558
  }
4285
4559
 
4286
- #openAICompatibleOllamaResponse(response={},id=Date.now()){
4560
+ #openAICompatibleOllamaResponse(response={},id=Date.now(),model=this.model){
4287
4561
  const responseRecord=isPlainAIRecord(response)?response:{};
4288
4562
  const message=isPlainAIRecord(responseRecord.message)
4289
4563
  ?responseRecord.message
@@ -4325,7 +4599,7 @@ class AI {
4325
4599
  :Math.floor(Date.now()/1000),
4326
4600
  model:Object.hasOwn(responseRecord,'model')
4327
4601
  ?responseRecord.model
4328
- :this.model,
4602
+ :model,
4329
4603
  choices:[
4330
4604
  {
4331
4605
  index:0,
@@ -4348,6 +4622,7 @@ class AI {
4348
4622
 
4349
4623
  async streamRequest({
4350
4624
  messages=[],
4625
+ model,
4351
4626
  structuredOutput=false,
4352
4627
  localOnly=false,
4353
4628
  onChunk=function ignoreStreamChunk(){},
@@ -4404,6 +4679,7 @@ class AI {
4404
4679
  if(this.#shouldUseProviderRuntime('llm',this.llmService,localOnly)){
4405
4680
  const request={
4406
4681
  messages,
4682
+ ...(model!==undefined?{model}:{}),
4407
4683
  structuredOutput,
4408
4684
  tools,
4409
4685
  toolChoice,
@@ -4529,7 +4805,8 @@ class AI {
4529
4805
  onDataResult,
4530
4806
  normalizedReasoningEffort,
4531
4807
  observeToolText,
4532
- onRetry
4808
+ onRetry,
4809
+ model
4533
4810
  );
4534
4811
  const structuralToolCalls=normalizeAICompletionToolCalls(
4535
4812
  completion
@@ -4638,7 +4915,8 @@ class AI {
4638
4915
  dataResultHandler=function ignoreBuiltInStreamDataResult(){},
4639
4916
  reasoningEffort,
4640
4917
  observeToolText=null,
4641
- onRetry = null
4918
+ onRetry = null,
4919
+ model=this.model
4642
4920
  ){
4643
4921
  let speechTurnCompleted=false;
4644
4922
 
@@ -4662,7 +4940,7 @@ class AI {
4662
4940
  reasoningEffort===undefined?this.reasoningEffort:reasoningEffort
4663
4941
  );
4664
4942
  const request={
4665
- model:this.model,
4943
+ model,
4666
4944
  messages:messages,
4667
4945
  stream:true
4668
4946
  }
@@ -4709,7 +4987,7 @@ class AI {
4709
4987
  const ollamaMessages=this.#ollamaMessages(messages,tool_choice);
4710
4988
  const adaptNativeToolCalls=value=>this.#openAICompatibleOllamaToolCalls(value,id);
4711
4989
  const ollamaRequest={
4712
- model:this.model,
4990
+ model,
4713
4991
  messages:ollamaMessages,
4714
4992
  stream:true,
4715
4993
  ...(normalizedReasoningEffort
@@ -4819,7 +5097,8 @@ class AI {
4819
5097
  :nativeContent
4820
5098
  }
4821
5099
  },
4822
- id
5100
+ id,
5101
+ model
4823
5102
  );
4824
5103
  const structuralToolCalls=normalizeAICompletionToolCalls(
4825
5104
  nativeCompletion
@@ -5311,7 +5590,7 @@ class AI {
5311
5590
  created:Object.hasOwn(streamMetadata,'created')
5312
5591
  ?streamMetadata.created
5313
5592
  :Math.floor(Date.now()/1000),
5314
- model:Object.hasOwn(streamMetadata,'model')?streamMetadata.model:this.model,
5593
+ model:Object.hasOwn(streamMetadata,'model')?streamMetadata.model:model,
5315
5594
  choices:completionChoices
5316
5595
  };
5317
5596
  const terminalToolCalls=normalizeAICompletionToolCalls(completion);
@@ -5356,6 +5635,7 @@ class AI {
5356
5635
 
5357
5636
  async fetchRequest({
5358
5637
  messages=[],
5638
+ model,
5359
5639
  structuredOutput=false,
5360
5640
  localOnly=false,
5361
5641
  tools=[],
@@ -5398,6 +5678,7 @@ class AI {
5398
5678
  }
5399
5679
  const request={
5400
5680
  messages,
5681
+ ...(model!==undefined?{model}:{}),
5401
5682
  structuredOutput,
5402
5683
  tools,
5403
5684
  toolChoice,
@@ -5454,7 +5735,8 @@ class AI {
5454
5735
  onRequest,
5455
5736
  signal,
5456
5737
  normalizedReasoningEffort,
5457
- onRetry
5738
+ onRetry,
5739
+ model
5458
5740
  );
5459
5741
  }
5460
5742
 
@@ -5508,7 +5790,8 @@ class AI {
5508
5790
  requestHandler=function ignoreFetchRequest(){},
5509
5791
  signal=null,
5510
5792
  reasoningEffort,
5511
- onRetry = null
5793
+ onRetry = null,
5794
+ model=this.model
5512
5795
  ){
5513
5796
  validateAIStructuralRequest(messages,tools,parallel_tool_calls);
5514
5797
  this.#assertServiceConfigured(this.llmService);
@@ -5527,7 +5810,7 @@ class AI {
5527
5810
  reasoningEffort===undefined?this.reasoningEffort:reasoningEffort
5528
5811
  );
5529
5812
  const request={
5530
- model:this.model,
5813
+ model,
5531
5814
  messages:messages,
5532
5815
  stream:false
5533
5816
  }
@@ -5566,7 +5849,7 @@ class AI {
5566
5849
  const ollamaTools=this.#ollamaTools(tools,tool_choice);
5567
5850
  const ollamaMessages=this.#ollamaMessages(messages,tool_choice);
5568
5851
  const nativeRequest={
5569
- model:this.model,
5852
+ model,
5570
5853
  messages:ollamaMessages,
5571
5854
  stream:false,
5572
5855
  ...(normalizedReasoningEffort
@@ -5597,7 +5880,8 @@ class AI {
5597
5880
  }
5598
5881
  const responseJSON=this.#openAICompatibleOllamaResponse(
5599
5882
  nativeResponse,
5600
- id
5883
+ id,
5884
+ model
5601
5885
  );
5602
5886
  this.#assertRequiredOllamaToolCall(
5603
5887
  normalizeAICompletionToolCalls(responseJSON),
@@ -5934,7 +6218,7 @@ class AI {
5934
6218
  ){
5935
6219
  const callId=this.#traceSpeech('streamTTS.call',{text,end,options});
5936
6220
  for(const playback of this.#preparedSpeechPlaybacks) playback.stop();
5937
- if(this.muted){
6221
+ if(this.muted&&!this.speechActivationPending){
5938
6222
  if(end){
5939
6223
  this.audioMessageChunks='';
5940
6224
  this.#markdownSpeech.reset();
@@ -5963,7 +6247,9 @@ class AI {
5963
6247
  }
5964
6248
 
5965
6249
  try{
5966
- this.#assertServiceConfigured(this.ttsService,'tts');
6250
+ if(!this.speechActivationPending){
6251
+ this.#assertServiceConfigured(this.ttsService,'tts');
6252
+ }
5967
6253
  }catch(error){
5968
6254
  this.#traceSpeech('streamTTS.error',{callId,error});
5969
6255
  this.#publishTTSFailure(error,{
@@ -5999,7 +6285,7 @@ class AI {
5999
6285
  }
6000
6286
 
6001
6287
  #finishStreamingSpeech(){
6002
- if(this.muted){
6288
+ if(this.muted&&!this.speechActivationPending){
6003
6289
  for(const playback of this.#preparedSpeechPlaybacks) playback.stop();
6004
6290
  this.audioMessageChunks='';
6005
6291
  this.#markdownSpeech.reset();
@@ -6163,7 +6449,7 @@ class AI {
6163
6449
  const job={
6164
6450
  diagnosticId:++this.#speechJobSequence,
6165
6451
  diagnosticCallId:options.diagnosticCallId,
6166
- abortController:null,
6452
+ abortController:new AbortController(),
6167
6453
  audioBuffer:null,
6168
6454
  audioContext:null,
6169
6455
  generation,
@@ -6203,7 +6489,31 @@ class AI {
6203
6489
  return playback||preparation;
6204
6490
  }
6205
6491
 
6492
+ #waitForSpeechOperation(operation,signal){
6493
+ return new Promise(function observeSpeechOperation(resolve,reject){
6494
+ let settled=false;
6495
+ function finish(value,error){
6496
+ if(settled)return;
6497
+ settled=true;
6498
+ signal.removeEventListener('abort',cancel);
6499
+ if(error)reject(error);
6500
+ else resolve(value);
6501
+ }
6502
+ function cancel(){finish(false);}
6503
+ signal.addEventListener('abort',cancel,{once:true});
6504
+ if(signal.aborted){cancel();return;}
6505
+ operation.then(
6506
+ function speechOperationCompleted(value){finish(value);},
6507
+ function speechOperationFailed(error){finish(false,error);}
6508
+ );
6509
+ });
6510
+ }
6511
+
6206
6512
  async #prepareSpeechJob(job){
6513
+ const activation=this.#speechActivation;
6514
+ if(activation&&job.generation===this.speechGeneration){
6515
+ await this.#waitForSpeechOperation(activation.promise,job.abortController.signal);
6516
+ }
6207
6517
  if(job.generation!==this.speechGeneration||this.muted){
6208
6518
  return this.#cancelSpeechJob(job);
6209
6519
  }
@@ -6229,7 +6539,7 @@ class AI {
6229
6539
  }
6230
6540
 
6231
6541
  async #requestSpeechAudio(job){
6232
- job.abortController=new AbortController();
6542
+ job.abortController??=new AbortController();
6233
6543
  const selection=this.#providerRuntime.selection('tts');
6234
6544
  const voice=job.voice===undefined
6235
6545
  ?(selection?this.#providerSpeechVoice():null)