@stacksjs/ai 0.75.374 → 0.75.377
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/README.md +18 -0
- package/dist/local-speech-model.d.ts +17 -0
- package/dist/local-speech-model.js +1 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -96,3 +96,21 @@ For casual chit-chat with others using this package:
|
|
|
96
96
|
The MIT License (MIT). Please see [LICENSE](https://github.com/stacksjs/stacks/tree/main/LICENSE.md) for more information.
|
|
97
97
|
|
|
98
98
|
Made with 💙
|
|
99
|
+
|
|
100
|
+
## Verified local speech models
|
|
101
|
+
|
|
102
|
+
Install the pinned multilingual Whisper base model before starting a private CPU speech server:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { ensureLocalSpeechModel } from '@stacksjs/ai/local-speech-model'
|
|
106
|
+
|
|
107
|
+
const modelPath = await ensureLocalSpeechModel({ directory: 'storage/speech/models' })
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The installer checks the exact byte length and SHA-256, reuses a verified file,
|
|
111
|
+
and publishes new bytes atomically. Interrupted downloads resume through a
|
|
112
|
+
validated HTTP Range response. An origin that ignores Range starts a clean
|
|
113
|
+
response instead. Four attempts, each with a three-minute transfer timeout, are
|
|
114
|
+
the defaults; `attempts`, `timeoutMs` and an abort `signal` are configurable.
|
|
115
|
+
A failed install preserves the previous target and removes only its own partial
|
|
116
|
+
file. Custom trusted model metadata can be passed through `model`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Publish a bounded, checksum-verified model atomically; callers own the models directory. */
|
|
2
|
+
export declare function ensureLocalSpeechModel(options: LocalSpeechModelOptions): Promise<string>;
|
|
3
|
+
/** Immutable multilingual Whisper base model used by the private CPU runtime. */
|
|
4
|
+
export declare const MULTILINGUAL_BASE_SPEECH_MODEL: Readonly<LocalSpeechModel>;
|
|
5
|
+
export declare interface LocalSpeechModel {
|
|
6
|
+
filename: string
|
|
7
|
+
url: string
|
|
8
|
+
sha256: string
|
|
9
|
+
bytes: number
|
|
10
|
+
}
|
|
11
|
+
export declare interface LocalSpeechModelOptions {
|
|
12
|
+
directory: string
|
|
13
|
+
model?: Readonly<LocalSpeechModel>
|
|
14
|
+
timeoutMs?: number
|
|
15
|
+
attempts?: number
|
|
16
|
+
signal?: AbortSignal
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createHash,randomUUID}from"node:crypto";import{createReadStream}from"node:fs";import{mkdir,open,rename,rm,stat}from"node:fs/promises";import{join}from"node:path";export const MULTILINGUAL_BASE_SPEECH_MODEL=Object.freeze({filename:"ggml-base.bin",url:"https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base.bin",sha256:"60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe",bytes:147951465});async function verified(path,model){try{if((await stat(path)).size!==model.bytes)return!1}catch(error){if(error.code==="ENOENT")return!1;throw error}const hash=createHash("sha256");for await(const chunk of createReadStream(path))hash.update(chunk);return hash.digest("hex")===model.sha256.toLowerCase()}export async function ensureLocalSpeechModel(options){const model=options.model??MULTILINGUAL_BASE_SPEECH_MODEL,timeoutMs=options.timeoutMs??180000,attempts=options.attempts??4;if(!/^[a-z0-9][a-z0-9._-]*$/i.test(model.filename)||!/^[a-f0-9]{64}$/i.test(model.sha256)||!Number.isSafeInteger(model.bytes)||model.bytes<=0)throw TypeError("A speech model needs a safe filename, SHA-256 and positive byte length");if(!Number.isSafeInteger(timeoutMs)||timeoutMs<=0||timeoutMs>2147483647)throw RangeError("timeoutMs must fit the platform timer range");if(!Number.isSafeInteger(attempts)||attempts<1||attempts>10)throw RangeError("attempts must be between one and ten");options.signal?.throwIfAborted();await mkdir(options.directory,{recursive:!0,mode:448});const target=join(options.directory,model.filename);if(await verified(target,model))return target;const partial=join(options.directory,`.${model.filename}.${randomUUID()}.partial`);let failure;try{for(let attempt=0;attempt<attempts;attempt++){options.signal?.throwIfAborted();let reset=!1;try{let received=await stat(partial).then((file)=>file.size,(error)=>{if(error.code==="ENOENT")return 0;throw error});if(received===model.bytes){if(await verified(partial,model)){await rename(partial,target);return target}await rm(partial,{force:!0});received=0}const signal=options.signal?AbortSignal.any([options.signal,AbortSignal.timeout(timeoutMs)]):AbortSignal.timeout(timeoutMs),headers={"Accept-Encoding":"identity"};if(received)headers.Range=`bytes=${received}-`;const response=await fetch(model.url,{signal,headers});if(![200,206].includes(response.status)||!response.body){await response.body?.cancel();throw Error(`Speech model download failed (HTTP ${response.status})`)}if(response.status===206){const range=/^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get("content-range")||"");if(!range||Number(range[1])!==received||Number(range[3])!==model.bytes||Number(range[2])<received||Number(range[2])>=model.bytes){await response.body.cancel();throw Error("Speech model response did not match the requested byte range")}}else received=0;const file=await open(partial,response.status===206?"a":"w",384),reader=response.body.getReader();try{for(;;){const{value,done}=await reader.read();if(done)break;received+=value.length;if(received>model.bytes){reset=!0;throw Error("Speech model exceeded its pinned byte length")}await file.writeFile(value)}}finally{await reader.cancel().catch(()=>{});await file.close()}if(received!==model.bytes)throw Error("Speech model response ended before its pinned byte length");if(!await verified(partial,model)){reset=!0;throw Error("Speech model did not match its pinned SHA-256")}await rename(partial,target);return target}catch(error){failure=error;options.signal?.throwIfAborted();if(reset)await rm(partial,{force:!0});if(attempt+1<attempts)await Bun.sleep(Math.min(250*2**attempt,2000))}}throw Error(`Speech model could not be verified after ${attempts} download attempts`,{cause:failure})}finally{await rm(partial,{force:!0})}}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/ai",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.377",
|
|
6
6
|
"description": "Stacks Artificial Intelligence.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -65,11 +65,11 @@
|
|
|
65
65
|
"prepublishOnly": "bun run build"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@stacksjs/cli": "0.75.
|
|
69
|
-
"@stacksjs/config": "0.75.
|
|
70
|
-
"@stacksjs/router": "0.75.
|
|
71
|
-
"@stacksjs/storage": "0.75.
|
|
72
|
-
"@stacksjs/ts-cloud": "^0.16.
|
|
68
|
+
"@stacksjs/cli": "0.75.377",
|
|
69
|
+
"@stacksjs/config": "0.75.377",
|
|
70
|
+
"@stacksjs/router": "0.75.377",
|
|
71
|
+
"@stacksjs/storage": "0.75.377",
|
|
72
|
+
"@stacksjs/ts-cloud": "^0.16.89"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"@anthropic-ai/claude-agent-sdk": "^0.2.79"
|