arcane-os 0.88.1 → 0.90.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 +34 -0
- package/browser-runtime/ai/browser-decisions.mjs +31 -4
- package/browser-runtime/ai/core-local.mjs +105 -25
- package/browser-runtime/ai/decision-runtime.mjs +7 -0
- package/browser-runtime/ai/decision-worker.mjs +9 -1
- package/browser-runtime/core/client.mjs +6 -0
- package/browser-runtime/core/contracts.mjs +10 -0
- package/docs/reference/ai/browser-decisions.md +89 -7
- package/docs/reference/core-client.md +59 -0
- package/docs/reference/core-native-packaging.md +62 -10
- package/docs/reference/core-repositories.md +104 -8
- package/docs/reference/git-text-snapshot.md +16 -1
- package/docs/reference/inventory/package-api.json +25 -25
- package/docs/reference/inventory/runtime-components.json +1 -1
- package/docs/reference/inventory/runtime-modules.json +4 -4
- package/docs/reference/local-ai.md +108 -9
- package/docs/reference/mcp-stdio.md +76 -1
- package/docs/reference/native-app-control.md +17 -5
- package/docs/reference/protocols.md +14 -0
- package/docs/reference/runtime-modules.md +9 -3
- package/docs/reference/sdk-api.md +202 -35
- package/package.json +2 -2
- package/runtime/arcane/modules/RiskSignalAnalyzer.js +1 -5
- package/schemas/arcane-app.schema.json +13 -3
- package/schemas/arcane-package.schema.json +10 -1
- package/src/app-descriptor.mjs +6 -2
- package/src/cli/app-control.mjs +1 -1
- package/src/core/hosts/windows/ArcaneHost.cs +273 -5
- package/src/core/hosts/windows/ArcaneLauncher.cs +9 -0
- package/src/core/hosts/windows/ArcaneWindowControl.cs +7 -7
- package/src/core/repositories.mjs +17 -6
- package/src/core/services/local-ai.mjs +183 -22
- package/src/git-text-snapshot.mjs +4 -2
- package/src/installed-sdk-runtime.mjs +2 -0
- package/src/local-ai/config.mjs +16 -0
- package/src/local-ai/dev.mjs +2 -2
- package/src/local-ai/discover.mjs +3 -0
- package/src/local-ai/install.mjs +130 -20
- package/src/local-ai/llama-execution-windows.ps1 +158 -0
- package/src/local-ai/llama-execution.mjs +169 -0
- package/src/local-ai/server.mjs +38 -8
- package/src/native/portable-layout.mjs +13 -3
- package/src/repository.mjs +33 -8
- package/src/workspace-runtime.mjs +97 -1
package/src/local-ai/install.mjs
CHANGED
|
@@ -5,16 +5,28 @@ import {createRequire} from 'node:module';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import {Readable} from 'node:stream';
|
|
7
7
|
import {pipeline} from 'node:stream/promises';
|
|
8
|
+
import packageMetadata from '../../package.json' with {type:'json'};
|
|
8
9
|
import {ArcaneError,ERROR_CODES,normalizeError,throwIfAborted} from '../errors.mjs';
|
|
9
10
|
import {createEventQueue} from '../event-queue.mjs';
|
|
10
11
|
import {runProcess} from '../process.mjs';
|
|
11
12
|
import {extractLocalAIArchive} from './archive.mjs';
|
|
12
|
-
import {normalizeImageRuntimeRequirement} from './config.mjs';
|
|
13
|
+
import {normalizeImageRuntimeRequirement,normalizeLlamaRuntimeRequirement} from './config.mjs';
|
|
13
14
|
import {normalizeWhisperRuntimeRequirement} from './whisper/config.mjs';
|
|
14
15
|
import {bundledWhisperRuntime, installedWhisperRuntime, installWhisperDistribution, whisperRuntimeSelection} from './whisper/install.mjs';
|
|
15
16
|
|
|
16
17
|
const is=new Is(false);
|
|
17
18
|
const installations=new Map();
|
|
19
|
+
const onnxWindowsDistribution={
|
|
20
|
+
version:'1.30.0',
|
|
21
|
+
revision:'1.30.0-directml-reshape-1',
|
|
22
|
+
asset:'arcane-onnx-runtime-windows-x64.tar.gz'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function selectedONNXNativeDistribution(runtime,platform,architecture){
|
|
26
|
+
if(runtime.id!=='onnx'||runtime.url||platform!=='win32'||architecture!=='x64')return null;
|
|
27
|
+
return (runtime.version??onnxWindowsDistribution.version)===onnxWindowsDistribution.version
|
|
28
|
+
?onnxWindowsDistribution:null;
|
|
29
|
+
}
|
|
18
30
|
|
|
19
31
|
function selectRuntimes(runtimes){
|
|
20
32
|
if(!is.array(runtimes)){
|
|
@@ -24,7 +36,8 @@ function selectRuntimes(runtimes){
|
|
|
24
36
|
for(const item of runtimes){
|
|
25
37
|
const requirement=is.string(item)?{id:item}:item;
|
|
26
38
|
const runtime=requirement?.id==='stable-diffusion.cpp'?normalizeImageRuntimeRequirement(requirement)
|
|
27
|
-
:requirement?.id==='whisper.cpp'?normalizeWhisperRuntimeRequirement(requirement)
|
|
39
|
+
:requirement?.id==='whisper.cpp'?normalizeWhisperRuntimeRequirement(requirement)
|
|
40
|
+
:requirement?.id==='llama.cpp'?normalizeLlamaRuntimeRequirement(requirement):requirement;
|
|
28
41
|
if(!runtime||!['llama.cpp','ollama','nemo-speech','onnx','stable-diffusion.cpp','whisper.cpp'].includes(runtime.id)){
|
|
29
42
|
throw new ArcaneError(ERROR_CODES.targetUnavailable,`Local AI runtime installation is unavailable for ${String(runtime?.id)}.`);
|
|
30
43
|
}
|
|
@@ -32,7 +45,9 @@ function selectRuntimes(runtimes){
|
|
|
32
45
|
if(prior&&runtime.id==='whisper.cpp'&&whisperRuntimeSelection(prior)!==whisperRuntimeSelection(runtime)){
|
|
33
46
|
throw new ArcaneError(ERROR_CODES.usage,'Select one complete whisper.cpp runtime and model requirement for this operation.');
|
|
34
47
|
}
|
|
35
|
-
if(prior&&(prior.version!==runtime.version||prior.url!==runtime.url
|
|
48
|
+
if(prior&&(prior.version!==runtime.version||prior.url!==runtime.url
|
|
49
|
+
||(['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)&&prior.backend!==runtime.backend)
|
|
50
|
+
||(runtime.id==='llama.cpp'&&prior.companionUrl!==runtime.companionUrl))){
|
|
36
51
|
throw new ArcaneError(ERROR_CODES.usage,`Select one ${runtime.id} runtime version and URL for this operation.`);
|
|
37
52
|
}
|
|
38
53
|
if(runtime.version!==undefined&&(!is.string(runtime.version)||!runtime.version)){
|
|
@@ -53,7 +68,7 @@ function selectedDirectory(directory){
|
|
|
53
68
|
return path.resolve(directory);
|
|
54
69
|
}
|
|
55
70
|
|
|
56
|
-
function releaseAssetNames(id,version,platform,architecture){
|
|
71
|
+
function releaseAssetNames(id,version,platform,architecture,backend){
|
|
57
72
|
if(id==='nemo-speech'){
|
|
58
73
|
if(!['x64','arm64'].includes(architecture))return [];
|
|
59
74
|
const arch=architecture==='x64'?'x86_64':'aarch64';
|
|
@@ -71,6 +86,9 @@ function releaseAssetNames(id,version,platform,architecture){
|
|
|
71
86
|
if(platform==='linux')return [`ollama-linux-${arch}.tar.zst`,`ollama-linux-${arch}.tgz`];
|
|
72
87
|
return [];
|
|
73
88
|
}
|
|
89
|
+
if(id==='llama.cpp'&&backend==='cuda'){
|
|
90
|
+
return platform==='win32'&&architecture==='x64'?[`llama-${version}-bin-win-cuda-13.4-x64.zip`]:[];
|
|
91
|
+
}
|
|
74
92
|
if(!['x64','arm64'].includes(architecture))return [];
|
|
75
93
|
if(platform==='win32')return [`llama-${version}-bin-win-cpu-${architecture}.zip`];
|
|
76
94
|
if(platform==='linux')return [`llama-${version}-bin-ubuntu-${architecture}.tar.gz`,`llama-${version}-bin-ubuntu-${architecture}.zip`];
|
|
@@ -93,9 +111,17 @@ async function selectRelease(runtime,platform,architecture,signal){
|
|
|
93
111
|
if (runtime.id === 'stable-diffusion.cpp') {
|
|
94
112
|
return selectImageRelease(runtime, platform, architecture, signal);
|
|
95
113
|
}
|
|
114
|
+
const backend=runtime.id==='llama.cpp'?llamaBackendCandidate(runtime,platform,architecture):undefined;
|
|
96
115
|
if(runtime.url){
|
|
97
116
|
const name=path.posix.basename(new URL(runtime.url).pathname);
|
|
98
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
version:runtime.version??'custom',url:runtime.url,name,
|
|
119
|
+
...(runtime.id==='llama.cpp'?{backend,requestedBackend:runtime.backend}:{}),
|
|
120
|
+
...(backend==='cuda'?{companion:{
|
|
121
|
+
url:runtime.companionUrl,
|
|
122
|
+
name:path.posix.basename(new URL(runtime.companionUrl).pathname)
|
|
123
|
+
}}:{})
|
|
124
|
+
};
|
|
99
125
|
}
|
|
100
126
|
const repository=runtime.id==='nemo-speech'?'NVIDIA/NeMo-Speech.cpp':runtime.id==='llama.cpp'?'ggml-org/llama.cpp':'ollama/ollama';
|
|
101
127
|
const version=runtime.version??(runtime.id==='nemo-speech'?'0.2.0':undefined);
|
|
@@ -112,12 +138,38 @@ async function selectRelease(runtime,platform,architecture,signal){
|
|
|
112
138
|
}
|
|
113
139
|
}
|
|
114
140
|
// Select only published format variants for this exact target distribution.
|
|
115
|
-
const names=releaseAssetNames(runtime.id,release.tag_name,platform,architecture);
|
|
141
|
+
const names=releaseAssetNames(runtime.id,release.tag_name,platform,architecture,backend);
|
|
116
142
|
const asset=release.assets?.find(function isSelectedAsset(candidate){return names.includes(candidate.name);});
|
|
117
143
|
if(!asset){
|
|
118
144
|
throw new ArcaneError(ERROR_CODES.targetUnavailable,`${runtime.id} ${release.tag_name} has no published ${platform}/${architecture} runtime archive${names.length?` named ${names.join(' or ')}`:''}.`);
|
|
119
145
|
}
|
|
120
|
-
|
|
146
|
+
let companion;
|
|
147
|
+
if(backend==='cuda'){
|
|
148
|
+
const companionName='cudart-llama-bin-win-cuda-13.4-x64.zip';
|
|
149
|
+
const companionAsset=release.assets?.find(function selectedCudaRuntime(candidate){return candidate.name===companionName;});
|
|
150
|
+
if(!companionAsset){
|
|
151
|
+
throw new ArcaneError(ERROR_CODES.targetUnavailable,`llama.cpp ${release.tag_name} has no matching CUDA runtime archive named ${companionName}.`);
|
|
152
|
+
}
|
|
153
|
+
companion={url:companionAsset.browser_download_url,name:companionAsset.name};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
version:release.tag_name,url:asset.browser_download_url,name:asset.name,
|
|
157
|
+
...(runtime.id==='llama.cpp'?{backend,requestedBackend:runtime.backend}:{}),
|
|
158
|
+
...(companion?{companion}:{})
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function llamaBackendCandidate(runtime,platform,architecture){
|
|
163
|
+
if(runtime.backend==='cuda'){
|
|
164
|
+
if(platform!=='win32'||architecture!=='x64'){
|
|
165
|
+
throw new ArcaneError(ERROR_CODES.targetUnavailable,`The selected llama.cpp CUDA distribution is unavailable for ${platform}/${architecture}.`);
|
|
166
|
+
}
|
|
167
|
+
return 'cuda';
|
|
168
|
+
}
|
|
169
|
+
// A custom automatic archive declares no backend capability. Official macOS
|
|
170
|
+
// archives include Metal; requested CPU execution is configured at launch.
|
|
171
|
+
if(runtime.url)return runtime.backend;
|
|
172
|
+
return platform==='darwin'?'metal':'cpu';
|
|
121
173
|
}
|
|
122
174
|
|
|
123
175
|
async function imageBackendCandidate(backend, platform, signal) {
|
|
@@ -368,6 +420,7 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
|
|
|
368
420
|
const imageBackend = runtime.id === 'stable-diffusion.cpp' && !runtime.url
|
|
369
421
|
? await imageBackendCandidate(runtime.backend, platform, signal)
|
|
370
422
|
: runtime.backend;
|
|
423
|
+
const llamaBackend=runtime.id==='llama.cpp'?llamaBackendCandidate(runtime,platform,architecture):null;
|
|
371
424
|
for(const entry of entries){
|
|
372
425
|
throwIfAborted(signal);
|
|
373
426
|
if(!entry.isDirectory())continue;
|
|
@@ -380,6 +433,8 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
|
|
|
380
433
|
}
|
|
381
434
|
if(installation.requestVersion!==(runtime.version??null)||installation.requestUrl!==(runtime.url??null))continue;
|
|
382
435
|
if (runtime.id === 'stable-diffusion.cpp' && installation.requestBackend !== runtime.backend) continue;
|
|
436
|
+
if(runtime.id==='llama.cpp'&&((installation.requestBackend??'auto')!==runtime.backend
|
|
437
|
+
||(installation.requestCompanionUrl??null)!==(runtime.companionUrl??null)))continue;
|
|
383
438
|
let whisperRequirement;
|
|
384
439
|
if (runtime.id === 'whisper.cpp') {
|
|
385
440
|
if (!installation.requestWhisper) continue;
|
|
@@ -388,6 +443,7 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
|
|
|
388
443
|
}
|
|
389
444
|
const record=installation.runtime;
|
|
390
445
|
if(record?.id!==runtime.id||record.platform!==platform||record.architecture!==architecture)continue;
|
|
446
|
+
if(runtime.id==='llama.cpp'&&record.backend!==undefined&&record.backend!==llamaBackend)continue;
|
|
391
447
|
try{
|
|
392
448
|
if(runtime.id==='whisper.cpp'){
|
|
393
449
|
if(await installedWhisperRuntime(record,signal,!reuseWhisper))return reuseWhisper?{runtime:record,requirement:whisperRequirement}:record;
|
|
@@ -423,6 +479,8 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
|
|
|
423
479
|
}
|
|
424
480
|
if(runtime.id==='onnx'){
|
|
425
481
|
if(!is.string(record.root)||!is.string(record.modulePath))continue;
|
|
482
|
+
const distribution=selectedONNXNativeDistribution(runtime,platform,architecture);
|
|
483
|
+
if(distribution&&record.nativeDistributionRevision!==distribution.revision)continue;
|
|
426
484
|
const locations=await onnxRuntimeModule(record.root,platform,architecture,signal);
|
|
427
485
|
return {...record,...locations,...(runtime.version?{requestedVersion:runtime.version}:{})};
|
|
428
486
|
}
|
|
@@ -436,7 +494,11 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
|
|
|
436
494
|
}
|
|
437
495
|
continue;
|
|
438
496
|
}
|
|
439
|
-
if((await stat(record.executable)).isFile())return {
|
|
497
|
+
if((await stat(record.executable)).isFile())return {
|
|
498
|
+
...record,
|
|
499
|
+
...(runtime.id==='llama.cpp'?{backend:record.backend??llamaBackend,requestedBackend:runtime.backend}:{}),
|
|
500
|
+
...(runtime.version?{requestedVersion:runtime.version}:{})
|
|
501
|
+
};
|
|
440
502
|
}catch(error){
|
|
441
503
|
if(runtime.id==='onnx'&&(error.code==='MODULE_NOT_FOUND'||error instanceof SyntaxError))continue;
|
|
442
504
|
if(error.code!=='ENOENT')throw error;
|
|
@@ -519,7 +581,7 @@ async function onnxRuntimeModule(root,platform,architecture,signal){
|
|
|
519
581
|
}
|
|
520
582
|
|
|
521
583
|
async function installONNXPackage(runtime,{root,directory,platform,architecture,signal,onEvent}){
|
|
522
|
-
const version=runtime.version??
|
|
584
|
+
const version=runtime.version??onnxWindowsDistribution.version;
|
|
523
585
|
await onEvent({type:'local-ai.install.installing',message:`Installing onnxruntime-node ${version} for ${platform}/${architecture}.`,data:{id:runtime.id,version,platform,architecture}});
|
|
524
586
|
await mkdir(root,{recursive:true});
|
|
525
587
|
await writeFile(path.join(root,'package.json'),'{"private":true}\n',{flag:'wx',signal});
|
|
@@ -528,14 +590,34 @@ async function installONNXPackage(runtime,{root,directory,platform,architecture,
|
|
|
528
590
|
'--no-audit','--no-fund','--foreground-scripts','--registry=https://registry.npmjs.org/',
|
|
529
591
|
'--cache',path.join(directory,'npm-cache'),`onnxruntime-node@${runtime.url??version}`
|
|
530
592
|
],{cwd:root,env:{ONNXRUNTIME_NODE_INSTALL:'skip'},signal,onEvent});
|
|
531
|
-
|
|
593
|
+
const distribution=selectedONNXNativeDistribution(runtime,platform,architecture);
|
|
594
|
+
if(distribution){
|
|
595
|
+
const archive=path.join(path.dirname(root),distribution.asset);
|
|
596
|
+
const nativeDirectory=path.join(root,'node_modules','onnxruntime-node','bin','napi-v6',platform,architecture);
|
|
597
|
+
const url=`https://github.com/TheWizardNexus/arcane-os-sdk/releases/download/${packageMetadata.version}/${distribution.asset}`;
|
|
598
|
+
await onEvent({type:'local-ai.install.downloading',message:`Downloading ${distribution.asset}.`,data:{id:runtime.id,asset:distribution.asset,nativeDistributionRevision:distribution.revision}});
|
|
599
|
+
const response=await upstreamResponse(url,signal);
|
|
600
|
+
await pipeline(Readable.fromWeb(response.body),createWriteStream(archive,{flags:'wx'}),{signal});
|
|
601
|
+
await onEvent({type:'local-ai.install.extracting',message:`Preparing ${distribution.asset}.`,data:{id:runtime.id,asset:distribution.asset,nativeDistributionRevision:distribution.revision}});
|
|
602
|
+
// This is the new attempt's package. Running installations retain their
|
|
603
|
+
// original trees; the same-version JavaScript and binding stay intact.
|
|
604
|
+
await extractLocalAIArchive({archive,directory:nativeDirectory,signal,onEvent});
|
|
605
|
+
await unlink(archive);
|
|
606
|
+
}
|
|
607
|
+
const locations=await onnxRuntimeModule(root,platform,architecture,signal);
|
|
608
|
+
return {...locations,...(distribution?{nativeDistributionRevision:distribution.revision}:{})};
|
|
532
609
|
}
|
|
533
610
|
|
|
534
611
|
async function installRuntime(runtime,{directory,platform,architecture,signal,onEvent}){
|
|
535
612
|
const runtimeBase=path.join(directory,runtime.id,`${platform}-${architecture}`,encodeURIComponent(runtime.version??'default'));
|
|
536
|
-
const base=
|
|
613
|
+
const base=['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)?path.join(runtimeBase,runtime.backend):runtimeBase;
|
|
537
614
|
await onEvent({type:'local-ai.install.starting',message:`Preparing ${runtime.id} for ${platform}/${architecture}.`,data:{id:runtime.id,platform,architecture}});
|
|
538
|
-
|
|
615
|
+
let installed=await installedRuntime(base,runtime,platform,architecture,signal);
|
|
616
|
+
// Previous default installations predate backend-specific directories. Keep
|
|
617
|
+
// those working trees reusable for the same automatic requirement.
|
|
618
|
+
if(!installed&&runtime.id==='llama.cpp'&&runtime.backend==='auto'){
|
|
619
|
+
installed=await installedRuntime(runtimeBase,runtime,platform,architecture,signal);
|
|
620
|
+
}
|
|
539
621
|
if(installed){
|
|
540
622
|
await onEvent({type:'local-ai.install.available',message:`${runtime.id} ${installed.version} is already installed.`,data:installed});
|
|
541
623
|
return installed;
|
|
@@ -550,6 +632,7 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
|
|
|
550
632
|
const attempt=await mkdtemp(path.join(base,'install-'));
|
|
551
633
|
const root=path.join(attempt,'runtime');
|
|
552
634
|
const archive=release&&runtime.id!=='stable-diffusion.cpp'?path.join(attempt,release.name):null;
|
|
635
|
+
const companionArchive=release?.companion?path.join(attempt,'companion',release.companion.name):null;
|
|
553
636
|
let completed=false;
|
|
554
637
|
try{
|
|
555
638
|
let locations;
|
|
@@ -563,19 +646,46 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
|
|
|
563
646
|
{root, directory, platform, architecture, signal, onEvent}
|
|
564
647
|
);
|
|
565
648
|
}else{
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
649
|
+
const assets=[{url:release.url,name:release.name,archive}];
|
|
650
|
+
if(companionArchive){
|
|
651
|
+
await mkdir(path.dirname(companionArchive));
|
|
652
|
+
assets.push({...release.companion,archive:companionArchive});
|
|
653
|
+
}
|
|
654
|
+
const downloads=await Promise.allSettled(assets.map(async function downloadRuntimeArchive(asset){
|
|
655
|
+
await onEvent({
|
|
656
|
+
type:'local-ai.install.downloading',
|
|
657
|
+
message:runtime.id==='llama.cpp'?`Downloading ${runtime.id} ${release.version}: ${asset.name}.`:`Downloading ${runtime.id} ${release.version}.`,
|
|
658
|
+
data:{id:runtime.id,version:release.version,platform,architecture,...(runtime.id==='llama.cpp'?{asset:asset.name}:{})}
|
|
659
|
+
});
|
|
660
|
+
const response=await upstreamResponse(asset.url,signal);
|
|
661
|
+
await pipeline(Readable.fromWeb(response.body),createWriteStream(asset.archive,{flags:'wx'}),{signal});
|
|
662
|
+
}));
|
|
663
|
+
const failures=downloads.filter(function archiveDownloadFailed(outcome){return outcome.status==='rejected';});
|
|
664
|
+
if(failures.length===1)throw failures[0].reason;
|
|
665
|
+
if(failures.length>1){
|
|
666
|
+
throw new AggregateError(failures.map(function archiveDownloadFailure(outcome){return outcome.reason;}),`Download of ${runtime.id} archives failed.`);
|
|
667
|
+
}
|
|
668
|
+
// The pair downloads independently, then extracts in order because
|
|
669
|
+
// both archives populate the same new runtime directory.
|
|
670
|
+
for(const asset of assets){
|
|
671
|
+
await onEvent({
|
|
672
|
+
type:'local-ai.install.extracting',
|
|
673
|
+
message:runtime.id==='llama.cpp'?`Extracting ${runtime.id} ${release.version}: ${asset.name}.`:`Extracting ${runtime.id} ${release.version}.`,
|
|
674
|
+
data:{id:runtime.id,version:release.version,...(runtime.id==='llama.cpp'?{asset:asset.name}:{})}
|
|
675
|
+
});
|
|
676
|
+
await extractLocalAIArchive({archive:asset.archive,directory:root,signal,onEvent});
|
|
677
|
+
}
|
|
678
|
+
locations={version:release.version,
|
|
679
|
+
...(runtime.id==='llama.cpp'?{backend:release.backend,requestedBackend:release.requestedBackend}:{}),
|
|
680
|
+
...(runtime.id==='nemo-speech'
|
|
572
681
|
?await nemoRuntimeDirectories(root,signal)
|
|
573
682
|
:{executable:await runtimeExecutable(root,runtime.id,platform,signal)})};
|
|
574
683
|
}
|
|
575
684
|
const record={id:runtime.id,platform,architecture,root,...locations,...(runtime.version?{requestedVersion:runtime.version}:{})};
|
|
576
685
|
throwIfAborted(signal);
|
|
577
686
|
if(archive)await unlink(archive);
|
|
578
|
-
await
|
|
687
|
+
if(companionArchive)await unlink(companionArchive);
|
|
688
|
+
await writeFile(path.join(attempt,'installation.json'),`${JSON.stringify({requestVersion:runtime.version??null,requestUrl:runtime.url??null,...(['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)?{requestBackend:runtime.backend}:{}),...(runtime.id==='llama.cpp'?{requestCompanionUrl:runtime.companionUrl??null}:{}),...(runtime.id==='whisper.cpp'?{requestWhisper:whisperRuntimeSelection(runtime)}:{}),runtime:record},null,2)}\n`,{flag:'wx',signal});
|
|
579
689
|
completed=true;
|
|
580
690
|
await onEvent({type:'local-ai.install.completed',message:`${runtime.id} ${record.version} is available.`,data:record});
|
|
581
691
|
return record;
|
|
@@ -595,7 +705,7 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
|
|
|
595
705
|
|
|
596
706
|
function shareInstallation(runtime,options){
|
|
597
707
|
throwIfAborted(options.signal);
|
|
598
|
-
const key=JSON.stringify([options.directory,options.platform,options.architecture,runtime.id,runtime.version??null,runtime.url??null,
|
|
708
|
+
const key=JSON.stringify([options.directory,options.platform,options.architecture,runtime.id,runtime.version??null,runtime.url??null,['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)?runtime.backend:null,runtime.id==='whisper.cpp'?whisperRuntimeSelection(runtime):null,runtime.id==='llama.cpp'?runtime.companionUrl??null:null]);
|
|
599
709
|
let entry=installations.get(key);
|
|
600
710
|
if(entry?.controller.signal.aborted){
|
|
601
711
|
// The prior cancelled attempt owns its cleanup. A later request waits
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Invoked through stdin by the SDK's owned process; stdout contains one JSON record.
|
|
2
|
+
$ErrorActionPreference = 'Stop'
|
|
3
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
4
|
+
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
# Enumerate the installed driver's devices in this helper only. An empty
|
|
8
|
+
# CUDA_VISIBLE_DEVICES value hides every device, so remove the inherited value.
|
|
9
|
+
[Environment]::SetEnvironmentVariable('CUDA_VISIBLE_DEVICES', $null, [EnvironmentVariableTarget]::Process)
|
|
10
|
+
|
|
11
|
+
Add-Type -TypeDefinition @'
|
|
12
|
+
using System;
|
|
13
|
+
using System.Globalization;
|
|
14
|
+
using System.Runtime.InteropServices;
|
|
15
|
+
using System.Text;
|
|
16
|
+
|
|
17
|
+
namespace Arcane.LocalAI {
|
|
18
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
19
|
+
public struct AdapterLuid {
|
|
20
|
+
public uint LowPart;
|
|
21
|
+
public int HighPart;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public sealed class CudaDeviceIdentity {
|
|
25
|
+
public string Uuid;
|
|
26
|
+
public uint NodeMask;
|
|
27
|
+
public AdapterLuid Luid;
|
|
28
|
+
public string UuidFunction;
|
|
29
|
+
|
|
30
|
+
// NVIDIA CU_DEVICE_ATTRIBUTE_TCC_DRIVER is 35. CUDA's Windows LUID
|
|
31
|
+
// contract is undefined in TCC mode, so only WDDM devices can be matched.
|
|
32
|
+
// https://nvidia.github.io/cuda-python/cuda-bindings/latest/module/driver.html
|
|
33
|
+
private const int TccDriverAttribute = 35;
|
|
34
|
+
|
|
35
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
36
|
+
private static extern int cuInit(uint flags);
|
|
37
|
+
|
|
38
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
39
|
+
private static extern int cuDeviceGetCount(out int count);
|
|
40
|
+
|
|
41
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
42
|
+
private static extern int cuDeviceGet(out int device, int ordinal);
|
|
43
|
+
|
|
44
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
45
|
+
private static extern int cuDeviceGetAttribute(out int value, int attribute, int device);
|
|
46
|
+
|
|
47
|
+
// CUDA writes the native Windows LUID layout: unsigned LowPart followed
|
|
48
|
+
// by signed HighPart. Marshaling the struct preserves that native layout.
|
|
49
|
+
// https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-luid
|
|
50
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
51
|
+
private static extern int cuDeviceGetLuid(out AdapterLuid luid, out uint nodeMask, int device);
|
|
52
|
+
|
|
53
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
54
|
+
private static extern int cuDeviceGetUuid_v2([Out] byte[] uuid, int device);
|
|
55
|
+
|
|
56
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
57
|
+
private static extern int cuDeviceGetUuid([Out] byte[] uuid, int device);
|
|
58
|
+
|
|
59
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
60
|
+
private static extern int cuGetErrorName(int error, out IntPtr name);
|
|
61
|
+
|
|
62
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
63
|
+
private static extern int cuGetErrorString(int error, out IntPtr message);
|
|
64
|
+
|
|
65
|
+
private static void Check(int result, string operation) {
|
|
66
|
+
if (result == 0) return;
|
|
67
|
+
IntPtr name;
|
|
68
|
+
IntPtr message;
|
|
69
|
+
int nameResult = cuGetErrorName(result, out name);
|
|
70
|
+
int messageResult = cuGetErrorString(result, out message);
|
|
71
|
+
throw new InvalidOperationException(
|
|
72
|
+
operation + " returned CUDA error " + result + ": " +
|
|
73
|
+
(nameResult == 0 ? Marshal.PtrToStringAnsi(name) : "cuGetErrorName returned " + nameResult) + "; " +
|
|
74
|
+
(messageResult == 0 ? Marshal.PtrToStringAnsi(message) : "cuGetErrorString returned " + messageResult)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private static string ReadUuid(int device, out string function) {
|
|
79
|
+
// CUuuid's 16 native octets are unavoidable ABI storage. CUDA UUID
|
|
80
|
+
// text keeps their order; System.Guid would reverse several fields.
|
|
81
|
+
byte[] uuid = new byte[16];
|
|
82
|
+
int result;
|
|
83
|
+
try {
|
|
84
|
+
result = cuDeviceGetUuid_v2(uuid, device);
|
|
85
|
+
function = "cuDeviceGetUuid_v2";
|
|
86
|
+
} catch (EntryPointNotFoundException) {
|
|
87
|
+
// Older drivers expose the original function for this same
|
|
88
|
+
// device. No device or backend selection changes on this path.
|
|
89
|
+
result = cuDeviceGetUuid(uuid, device);
|
|
90
|
+
function = "cuDeviceGetUuid";
|
|
91
|
+
}
|
|
92
|
+
Check(result, function);
|
|
93
|
+
StringBuilder text = new StringBuilder("GPU-");
|
|
94
|
+
for (int index = 0; index < uuid.Length; index++) {
|
|
95
|
+
if (index == 4 || index == 6 || index == 8 || index == 10) text.Append('-');
|
|
96
|
+
text.Append(uuid[index].ToString("x2", CultureInfo.InvariantCulture));
|
|
97
|
+
}
|
|
98
|
+
return text.ToString();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public static CudaDeviceIdentity Resolve(uint lowPart, int highPart) {
|
|
102
|
+
Check(cuInit(0), "cuInit");
|
|
103
|
+
int count;
|
|
104
|
+
Check(cuDeviceGetCount(out count), "cuDeviceGetCount");
|
|
105
|
+
CudaDeviceIdentity matched = null;
|
|
106
|
+
int tccCount = 0;
|
|
107
|
+
for (int ordinal = 0; ordinal < count; ordinal++) {
|
|
108
|
+
int device;
|
|
109
|
+
Check(cuDeviceGet(out device, ordinal), "cuDeviceGet(" + ordinal + ")");
|
|
110
|
+
int tcc;
|
|
111
|
+
Check(cuDeviceGetAttribute(out tcc, TccDriverAttribute, device), "cuDeviceGetAttribute(TCC_DRIVER, " + ordinal + ")");
|
|
112
|
+
if (tcc != 0) {
|
|
113
|
+
tccCount++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
AdapterLuid luid;
|
|
117
|
+
uint nodeMask;
|
|
118
|
+
Check(cuDeviceGetLuid(out luid, out nodeMask, device), "cuDeviceGetLuid(" + ordinal + ")");
|
|
119
|
+
if (luid.LowPart != lowPart || luid.HighPart != highPart) continue;
|
|
120
|
+
if (matched != null) {
|
|
121
|
+
throw new InvalidOperationException("Multiple CUDA devices share the selected adapter LUID; the catalog selection cannot identify one CUDA device.");
|
|
122
|
+
}
|
|
123
|
+
string function;
|
|
124
|
+
string uuid = ReadUuid(device, out function);
|
|
125
|
+
matched = new CudaDeviceIdentity { Uuid = uuid, NodeMask = nodeMask, Luid = luid, UuidFunction = function };
|
|
126
|
+
}
|
|
127
|
+
if (matched == null) {
|
|
128
|
+
throw new InvalidOperationException(
|
|
129
|
+
"No WDDM CUDA device matches the selected adapter LUID. CUDA devices enumerated: " + count +
|
|
130
|
+
"; TCC devices without a defined LUID: " + tccCount + "."
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return matched;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
'@
|
|
138
|
+
|
|
139
|
+
$lowPart = [uint32]::Parse($env:ARCANE_LLAMA_LUID_LOW, [Globalization.CultureInfo]::InvariantCulture)
|
|
140
|
+
$highPart = [int32]::Parse($env:ARCANE_LLAMA_LUID_HIGH, [Globalization.CultureInfo]::InvariantCulture)
|
|
141
|
+
$device = [Arcane.LocalAI.CudaDeviceIdentity]::Resolve($lowPart, $highPart)
|
|
142
|
+
[ordered]@{
|
|
143
|
+
uuid = $device.Uuid
|
|
144
|
+
nodeMask = $device.NodeMask
|
|
145
|
+
adapterLuid = [ordered]@{
|
|
146
|
+
lowPart = $device.Luid.LowPart
|
|
147
|
+
highPart = $device.Luid.HighPart
|
|
148
|
+
}
|
|
149
|
+
driverMode = 'wddm'
|
|
150
|
+
uuidFunction = $device.UuidFunction
|
|
151
|
+
} | ConvertTo-Json -Depth 4 -Compress
|
|
152
|
+
} catch {
|
|
153
|
+
[Console]::Error.WriteLine($_.Exception.ToString())
|
|
154
|
+
if ($null -ne $_.ErrorDetails) { [Console]::Error.WriteLine($_.ErrorDetails.Message) }
|
|
155
|
+
if ($null -ne $_.InvocationInfo) { [Console]::Error.WriteLine($_.InvocationInfo.PositionMessage) }
|
|
156
|
+
if ($_.ScriptStackTrace) { [Console]::Error.WriteLine($_.ScriptStackTrace) }
|
|
157
|
+
exit 1
|
|
158
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import Is from 'strong-type';
|
|
4
|
+
import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
|
|
5
|
+
import {runProcess} from '../process.mjs';
|
|
6
|
+
|
|
7
|
+
const is = new Is(false);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve one explicit catalog selection for a new native llama server lifetime.
|
|
11
|
+
* Default selection performs no discovery. Hardware identity and launch options
|
|
12
|
+
* are configuration evidence; the server's observed execution target stays null.
|
|
13
|
+
*/
|
|
14
|
+
export async function resolveLlamaExecution({
|
|
15
|
+
executionTarget, executionDevices, runtime, signal, onEvent
|
|
16
|
+
} = {}) {
|
|
17
|
+
throwIfAborted(signal);
|
|
18
|
+
if (executionTarget === undefined || executionTarget === null) {
|
|
19
|
+
const cpu = runtime?.requestedBackend === 'cpu';
|
|
20
|
+
return {
|
|
21
|
+
args: cpu ? ['--device', 'none', '--gpu-layers', '0'] : [],
|
|
22
|
+
env: {},
|
|
23
|
+
execution: {
|
|
24
|
+
requestedTarget: null,
|
|
25
|
+
resolvedDevice: null,
|
|
26
|
+
resolution: 'automatic',
|
|
27
|
+
reason: cpu ? 'runtime-backend-selected' : 'engine-default-required',
|
|
28
|
+
configuredTarget: cpu ? {backend: 'cpu', device: 'none', gpuLayers: 0} : null,
|
|
29
|
+
observedTarget: null,
|
|
30
|
+
fallback: false
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (!is.function(executionDevices?.resolveTarget)) {
|
|
35
|
+
throw new ArcaneError(
|
|
36
|
+
'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
|
|
37
|
+
'Explicit llama.cpp device selection requires the host execution-device catalog.',
|
|
38
|
+
{details: {requestedTarget: executionTarget}}
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
// The persistent device ID is resolved again for this load because a Windows
|
|
42
|
+
// adapter LUID belongs to the current host session, not the saved selection.
|
|
43
|
+
const resolution = await executionDevices.resolveTarget(
|
|
44
|
+
{executionTarget, refresh: true, signal}
|
|
45
|
+
);
|
|
46
|
+
throwIfAborted(signal);
|
|
47
|
+
const execution = {
|
|
48
|
+
...resolution,
|
|
49
|
+
configuredTarget: null,
|
|
50
|
+
observedTarget: null,
|
|
51
|
+
fallback: false
|
|
52
|
+
};
|
|
53
|
+
if (resolution.resolution !== 'matched' || !resolution.resolvedDevice) {
|
|
54
|
+
throw new ArcaneError(
|
|
55
|
+
resolution.resolution === 'unsupported'
|
|
56
|
+
? 'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED'
|
|
57
|
+
: 'LOCAL_AI_EXECUTION_TARGET_UNAVAILABLE',
|
|
58
|
+
`The selected llama.cpp execution device could not be resolved: ${resolution.reason}.`,
|
|
59
|
+
{details: execution}
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const device = resolution.resolvedDevice;
|
|
63
|
+
if (device.kind === 'cpu') {
|
|
64
|
+
execution.configuredTarget = {
|
|
65
|
+
backend: 'cpu', deviceId: device.deviceId, device: 'none', gpuLayers: 0
|
|
66
|
+
};
|
|
67
|
+
return {args: ['--device', 'none', '--gpu-layers', '0'], env: {}, execution};
|
|
68
|
+
}
|
|
69
|
+
if (device.kind !== 'gpu' || process.platform !== 'win32') {
|
|
70
|
+
throw new ArcaneError(
|
|
71
|
+
'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
|
|
72
|
+
`Exact llama.cpp ${device.kind} selection is unavailable on ${process.platform}.`,
|
|
73
|
+
{details: execution}
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (runtime?.requestedBackend === 'cpu') {
|
|
77
|
+
throw new ArcaneError(
|
|
78
|
+
'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
|
|
79
|
+
'The selected GPU is incompatible with the explicit llama.cpp CPU runtime configuration.',
|
|
80
|
+
{details: execution}
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (runtime?.backend !== 'cuda') {
|
|
84
|
+
throw new ArcaneError(
|
|
85
|
+
'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
|
|
86
|
+
'Exact Windows GPU selection requires a llama.cpp CUDA runtime.',
|
|
87
|
+
{details: {...execution, runtimeBackend: runtime?.backend ?? null}}
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const adapterLuid = device.addresses?.adapterLuid;
|
|
91
|
+
if (!is.integer(adapterLuid?.lowPart) || adapterLuid.lowPart < 0 || adapterLuid.lowPart > 0xffffffff
|
|
92
|
+
|| !is.integer(adapterLuid?.highPart) || adapterLuid.highPart < -0x80000000 || adapterLuid.highPart > 0x7fffffff) {
|
|
93
|
+
throw new ArcaneError(
|
|
94
|
+
'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
|
|
95
|
+
'The selected Windows GPU has no current adapter LUID for CUDA identity resolution.',
|
|
96
|
+
{details: execution}
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const identity = await resolveWindowsCudaIdentity({adapterLuid, signal, onEvent});
|
|
100
|
+
throwIfAborted(signal);
|
|
101
|
+
execution.configuredTarget = {
|
|
102
|
+
backend: 'cuda',
|
|
103
|
+
deviceId: device.deviceId,
|
|
104
|
+
device: 'CUDA0',
|
|
105
|
+
uuid: identity.uuid,
|
|
106
|
+
nodeMask: identity.nodeMask,
|
|
107
|
+
adapterLuid: identity.adapterLuid,
|
|
108
|
+
driverMode: identity.driverMode,
|
|
109
|
+
identitySource: 'nvidia-cuda-driver',
|
|
110
|
+
uuidFunction: identity.uuidFunction,
|
|
111
|
+
splitMode: 'none',
|
|
112
|
+
mainGpu: 0,
|
|
113
|
+
gpuLayers: 'all'
|
|
114
|
+
};
|
|
115
|
+
if (identity.diagnostics !== '') {
|
|
116
|
+
execution.diagnostics = [{source: 'cuda-driver', message: identity.diagnostics}];
|
|
117
|
+
}
|
|
118
|
+
// A single full UUID makes CUDA0 identify the selected physical GPU in the
|
|
119
|
+
// fresh server process, independent of DXGI or inherited CUDA ordinal order.
|
|
120
|
+
return {
|
|
121
|
+
args: ['--device', 'CUDA0', '--split-mode', 'none', '--main-gpu', '0', '--gpu-layers', 'all'],
|
|
122
|
+
env: {CUDA_VISIBLE_DEVICES: identity.uuid},
|
|
123
|
+
execution
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function resolveWindowsCudaIdentity({adapterLuid, signal, onEvent}) {
|
|
128
|
+
const script = await readFile(
|
|
129
|
+
new URL('./llama-execution-windows.ps1', import.meta.url),
|
|
130
|
+
{encoding: 'utf8', signal}
|
|
131
|
+
);
|
|
132
|
+
throwIfAborted(signal);
|
|
133
|
+
const command = process.env.SystemRoot
|
|
134
|
+
? path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
135
|
+
: 'powershell.exe';
|
|
136
|
+
async function* identityInput() {
|
|
137
|
+
yield `& {\n${script}\n}\n\n`;
|
|
138
|
+
}
|
|
139
|
+
const result = await runProcess(
|
|
140
|
+
command,
|
|
141
|
+
['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'],
|
|
142
|
+
{
|
|
143
|
+
signal,
|
|
144
|
+
onEvent,
|
|
145
|
+
env: {
|
|
146
|
+
ARCANE_LLAMA_LUID_LOW: String(adapterLuid.lowPart),
|
|
147
|
+
ARCANE_LLAMA_LUID_HIGH: String(adapterLuid.highPart)
|
|
148
|
+
},
|
|
149
|
+
input: identityInput(),
|
|
150
|
+
captureOutput: true,
|
|
151
|
+
emitOutputEvents: false
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
throwIfAborted(signal);
|
|
155
|
+
let identity;
|
|
156
|
+
try {
|
|
157
|
+
identity = JSON.parse(result.stdout);
|
|
158
|
+
if (!is.string(identity?.uuid) || identity.uuid === '' || !is.integer(identity.nodeMask)) {
|
|
159
|
+
throw new TypeError('The CUDA driver identity helper returned no device identity.');
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw new ArcaneError(
|
|
163
|
+
ERROR_CODES.operationFailed,
|
|
164
|
+
'Could not read the selected CUDA device identity.',
|
|
165
|
+
{cause: error, details: result}
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return {...identity, diagnostics: result.stderr};
|
|
169
|
+
}
|