@stacksjs/ai 0.75.361 → 0.75.366

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.
@@ -0,0 +1,18 @@
1
+ /** Run whisper.cpp without retaining a recording, transcript, or command output. */
2
+ export declare function whisperTranscriber(options: Pick<LocalSpeechOptions, 'modelPath' | 'executable' | 'threads'>): boolean;
3
+ /**
4
+ * Authenticated, OpenAI-compatible local transcription for Bun.serve.
5
+ * Bind the host to loopback and set maxRequestBodySize to maxBytes + 65536.
6
+ * Reads are bounded here as well; uploads and inference are never queued without limit.
7
+ */
8
+ export declare function createLocalSpeechHandler(options: LocalSpeechOptions): (request: Request) => Promise<Response>;
9
+ export declare interface LocalSpeechOptions {
10
+ bearerToken: string
11
+ modelPath: string
12
+ executable?: string
13
+ threads?: number
14
+ maxBytes?: number
15
+ maxConcurrent?: number
16
+ timeoutMs?: number
17
+ transcribe?: (audio: Uint8Array, signal: AbortSignal) => Promise<string>
18
+ }
@@ -0,0 +1 @@
1
+ import{timingSafeEqual}from"node:crypto";import{mkdtemp,rm}from"node:fs/promises";import{tmpdir}from"node:os";import{join,resolve}from"node:path";import process from"node:process";export function whisperTranscriber(options){const executable=options.executable||"whisper-cli",model=resolve(options.modelPath),threads=Math.max(1,Math.min(8,Math.floor(options.threads||2)));return async(audio,signal)=>{const directory=await mkdtemp(join(tmpdir(),"stacks-speech-"));try{const input=join(directory,"recording.wav"),output=join(directory,"transcript");await Bun.write(input,audio,{mode:384});if(signal.aborted)throw Error("Transcription timed out");const child=Bun.spawn([executable,"--model",model,"--file",input,"--output-json","--output-file",output,"--no-prints","--language","auto","--threads",String(threads),"--no-gpu"],{stdout:"ignore",stderr:"ignore",env:{PATH:process.env.PATH||"",LD_LIBRARY_PATH:process.env.LD_LIBRARY_PATH||"",TMPDIR:tmpdir()}}),stop=()=>child.kill("SIGKILL");signal.addEventListener("abort",stop,{once:!0});if(signal.aborted)stop();let status;try{status=await child.exited}finally{signal.removeEventListener("abort",stop)}if(signal.aborted)throw Error("Transcription timed out");if(status!==0)throw Error("The local speech engine could not decode this recording");const result=await Bun.file(`${output}.json`).json();if(!Array.isArray(result.transcription))throw Error("The local speech engine returned no transcript");return result.transcription.map((part)=>typeof part.text==="string"?part.text:"").join("").trim()}finally{await rm(directory,{recursive:!0,force:!0})}}}export function createLocalSpeechHandler(options){if(options.bearerToken.length<24)throw Error("Local speech requires a private service token of at least 24 characters");const token=Buffer.from(`Bearer ${options.bearerToken}`),maxBytes=options.maxBytes??12582912,maxConcurrent=options.maxConcurrent??1,timeoutMs=options.timeoutMs??80000;if(!Number.isSafeInteger(maxBytes)||maxBytes<44||!Number.isSafeInteger(maxConcurrent)||maxConcurrent<1||!Number.isFinite(timeoutMs)||timeoutMs<1)throw Error("Invalid local speech limits");const transcribe=options.transcribe??whisperTranscriber(options);let active=0;const json=(body,status=200,headers={})=>Response.json(body,{status,headers:{"Cache-Control":"no-store",...headers}});return async(request)=>{const authorization=Buffer.from(request.headers.get("authorization")||"");if(authorization.length!==token.length||!timingSafeEqual(authorization,token))return json({error:{message:"Unauthorized"}},401);const path=new URL(request.url).pathname;if(path==="/health"&&request.method==="GET")return json({ready:!0,active,capacity:maxConcurrent});if(path!=="/v1/audio/transcriptions")return json({error:{message:"Not found"}},404);if(request.method!=="POST")return json({error:{message:"Use POST"}},405,{Allow:"POST"});if(!request.headers.get("content-type")?.startsWith("multipart/form-data;"))return json({error:{message:"Send multipart audio"}},415);if(active>=maxConcurrent)return json({error:{message:"The speech service is busy. Try again shortly."}},503,{"Retry-After":"3"});active++;const controller=new AbortController,abort=()=>controller.abort();request.signal.addEventListener("abort",abort,{once:!0});if(request.signal.aborted)abort();const timer=setTimeout(abort,timeoutMs);try{const reader=request.body?.getReader();if(!reader)return json({error:{message:"Send audio"}},422);const chunks=[];let size=0;const cancel=()=>{reader.cancel().catch(()=>{})};controller.signal.addEventListener("abort",cancel,{once:!0});try{while(!0){const chunk=await reader.read();if(chunk.done)break;size+=chunk.value.length;if(size>maxBytes+65536){await reader.cancel();return json({error:{message:"Recording is too large"}},413)}chunks.push(chunk.value)}}finally{controller.signal.removeEventListener("abort",cancel)}if(controller.signal.aborted)return json({error:{message:"Transcription timed out"}},504);const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length}let form;try{form=await new Request(request.url,{method:"POST",headers:request.headers,body:bytes}).formData()}catch{return json({error:{message:"Invalid multipart audio"}},422)}const file=form.get("file");if(!(file instanceof Blob)||file.size<44)return json({error:{message:"Send a WAV recording as file"}},422);if(file.size>maxBytes)return json({error:{message:"Recording is too large"}},413);const audio=new Uint8Array(await file.arrayBuffer());if(String.fromCharCode(...audio.slice(0,4))!=="RIFF"||String.fromCharCode(...audio.slice(8,12))!=="WAVE")return json({error:{message:"Send decoded WAV audio"}},422);if(form.get("response_format")&&form.get("response_format")!=="json")return json({error:{message:"Use JSON response format"}},422);const text=(await transcribe(audio,controller.signal)).trim();if(controller.signal.aborted)return json({error:{message:"Transcription timed out"}},504);if(!text||text.length>16000)return json({error:{message:"No usable speech was detected"}},422);return json({text})}catch{return json({error:{message:controller.signal.aborted?"Transcription timed out":"The recording could not be transcribed"}},controller.signal.aborted?504:422)}finally{clearTimeout(timer);request.signal.removeEventListener("abort",abort);active--}}}
package/dist/speech.d.ts CHANGED
@@ -85,3 +85,9 @@ export type SpeechFormat = 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
85
85
  export type OpenAISpeechVoice = | 'alloy' | 'ash' | 'ballad' | 'cedar' | 'coral' | 'echo' | 'fable'
86
86
  | 'marin' | 'nova' | 'onyx' | 'sage' | 'shimmer' | 'verse';
87
87
  export type SpeechInput = Blob | Uint8Array | ArrayBuffer;
88
+ /** Keep HTTP failures actionable for callers without parsing provider message text. */
89
+ export declare class SpeechRequestError extends Error {
90
+ readonly status: number;
91
+ readonly retryAfter: string | null;
92
+ constructor(message: string, status: number, retryAfter?: string | null);
93
+ }
package/dist/speech.js CHANGED
@@ -1 +1 @@
1
- import{resolveAIProvider}from"./client";import{fetchWithRetry}from"./utils/retry";export const SPEECH_DRIVERS=["openai"];const OPENAI_BASE_URL="https://api.openai.com/v1",MAX_OPENAI_INPUT=4096;async function loadConfig(config){if(config)return config;try{const{ai}=await import("@stacksjs/config");return ai??{}}catch{return{}}}function unsupported(feature,driver){return Error(`${feature} is not supported by the "${driver}" AI driver. Drivers that support it: ${SPEECH_DRIVERS.join(", ")}. Pass { driver: '${SPEECH_DRIVERS[0]}' } or set \`default\` in config/ai.ts.`)}function resolveSpeechDriver(feature,config,override){const requested=String(override||config.default||"").trim();if(!requested)throw Error(`${feature} needs an AI driver. Pass { driver: '${SPEECH_DRIVERS[0]}' } or set \`default\` in config/ai.ts.`);let provider;try{provider=resolveAIProvider({default:requested})}catch{throw unsupported(feature,requested)}if(!SPEECH_DRIVERS.includes(provider))throw unsupported(feature,provider);return provider}function openAICredentials(config){const driver=config.drivers?.openai??{},apiKey=driver.apiKey?.trim()||process.env.OPENAI_API_KEY?.trim()||"",customBase=driver.baseUrl?.trim();if(!apiKey&&!customBase)throw Error("OpenAI API key not set. Set OPENAI_API_KEY or `drivers.openai.apiKey` in config/ai.ts.");return{apiKey,baseUrl:(customBase||OPENAI_BASE_URL).replace(/\/+$/,"")}}async function failure(provider,operation,response){let body="";try{body=(await response.text()).trim()}catch{}const snippet=body.length>500?`${body.slice(0,500)}...`:body;return Error(`${provider} ${operation} failed with HTTP ${response.status}${snippet?`: ${snippet}`:""}`)}const MIME_TYPES={mp3:"audio/mpeg",opus:"audio/ogg",aac:"audio/aac",flac:"audio/flac",wav:"audio/wav",pcm:"audio/pcm"};export function speechMimeType(format){return MIME_TYPES[format]}export async function textToSpeech(text,options={}){if(typeof text!=="string"||!text.trim())throw Error("textToSpeech() needs non-empty text.");const format=options.format??"mp3";if(!(format in MIME_TYPES))throw Error(`Unsupported speech format "${format}". Use one of: ${Object.keys(MIME_TYPES).join(", ")}.`);if(options.speed!==void 0&&!(options.speed>=0.25&&options.speed<=4))throw Error(`Speech speed must be between 0.25 and 4.0, got ${options.speed}.`);const config=await loadConfig(options.config),driver=resolveSpeechDriver("Text-to-speech",config,options.driver);return openAITextToSpeech(text,format,options,config,driver)}async function openAITextToSpeech(text,format,options,config,driver){if(text.length>MAX_OPENAI_INPUT)throw Error(`OpenAI text-to-speech accepts at most ${MAX_OPENAI_INPUT} characters per request, got ${text.length}. Split the text into chunks.`);const model=options.model??"gpt-4o-mini-tts",voice=options.voice??"alloy";if(options.instructions&&/^tts-1(?:-hd)?$/.test(model))throw Error(`OpenAI model "${model}" does not support \`instructions\`. Use gpt-4o-mini-tts.`);const{apiKey,baseUrl}=openAICredentials(config),body={model,input:text,voice,response_format:format};if(options.speed!==void 0)body.speed=options.speed;if(options.instructions)body.instructions=options.instructions;const headers={"Content-Type":"application/json"};if(apiKey)headers.Authorization=`Bearer ${apiKey}`;const response=await fetchWithRetry(`${baseUrl}/audio/speech`,{method:"POST",headers,body:JSON.stringify(body),signal:options.signal},options.retry);if(!response.ok)throw await failure("OpenAI","text-to-speech",response);const audio=new Uint8Array(await response.arrayBuffer()),contentType=response.headers.get("content-type")?.split(";")[0]?.trim(),mimeType=contentType?.startsWith("audio/")?contentType:MIME_TYPES[format];return{audio,mimeType,format,driver,model,voice}}export async function storeSpeech(speech,path,options={}){if(!path?.trim())throw Error("storeSpeech() needs a destination path.");let disk;if(options.disk&&typeof options.disk==="object")disk=options.disk;else{let storage;try{storage=await import("@stacksjs/storage")}catch(error){throw Error(`storeSpeech() needs @stacksjs/storage to resolve a disk by name (${error instanceof Error?error.message:String(error)}). Install it, or pass a storage adapter as { disk }.`)}disk=storage.Storage.disk(options.disk||void 0)}const result=await disk.write(path,speech.audio);return{path:result.path,size:result.size,mimeType:speech.mimeType}}const EXTENSIONS={"audio/mpeg":"mp3","audio/mp3":"mp3","audio/mp4":"m4a","audio/m4a":"m4a","audio/x-m4a":"m4a","audio/wav":"wav","audio/x-wav":"wav","audio/webm":"webm","audio/ogg":"ogg","audio/flac":"flac"};export async function speechToText(audio,options={}){if(options.temperature!==void 0&&!(options.temperature>=0&&options.temperature<=1))throw Error(`Transcription temperature must be between 0 and 1, got ${options.temperature}.`);const config=await loadConfig(options.config),driver=resolveSpeechDriver("Speech-to-text",config,options.driver),model=options.model??"whisper-1",{apiKey,baseUrl}=openAICredentials(config),blob=audio instanceof Blob?audio:new Blob([audio]);if(blob.size===0)throw Error("speechToText() needs non-empty audio.");const ownName=audio.name,fileName=options.filename||(typeof ownName==="string"&&ownName?ownName.split("/").pop():void 0)||`audio.${EXTENSIONS[blob.type.split(";")[0]??""]??"mp3"}`,form=new FormData;form.append("file",blob,fileName);form.append("model",model);form.append("response_format","json");if(options.language)form.append("language",options.language);if(options.prompt)form.append("prompt",options.prompt);if(options.temperature!==void 0)form.append("temperature",String(options.temperature));const headers={};if(apiKey)headers.Authorization=`Bearer ${apiKey}`;const response=await fetchWithRetry(`${baseUrl}/audio/transcriptions`,{method:"POST",headers,body:form,signal:options.signal},options.retry);if(!response.ok)throw await failure("OpenAI","speech-to-text",response);const data=await response.json();if(typeof data?.text!=="string")throw Error("OpenAI speech-to-text returned no text.");return{text:data.text,driver,model}}
1
+ import{resolveAIProvider}from"./client";import{fetchWithRetry}from"./utils/retry";export const SPEECH_DRIVERS=["openai"];const OPENAI_BASE_URL="https://api.openai.com/v1",MAX_OPENAI_INPUT=4096;async function loadConfig(config){if(config)return config;try{const{ai}=await import("@stacksjs/config");return ai??{}}catch{return{}}}function unsupported(feature,driver){return Error(`${feature} is not supported by the "${driver}" AI driver. Drivers that support it: ${SPEECH_DRIVERS.join(", ")}. Pass { driver: '${SPEECH_DRIVERS[0]}' } or set \`default\` in config/ai.ts.`)}function resolveSpeechDriver(feature,config,override){const requested=String(override||config.default||"").trim();if(!requested)throw Error(`${feature} needs an AI driver. Pass { driver: '${SPEECH_DRIVERS[0]}' } or set \`default\` in config/ai.ts.`);let provider;try{provider=resolveAIProvider({default:requested})}catch{throw unsupported(feature,requested)}if(!SPEECH_DRIVERS.includes(provider))throw unsupported(feature,provider);return provider}function openAICredentials(config){const driver=config.drivers?.openai??{},apiKey=driver.apiKey?.trim()||process.env.OPENAI_API_KEY?.trim()||"",customBase=driver.baseUrl?.trim();if(!apiKey&&!customBase)throw Error("OpenAI API key not set. Set OPENAI_API_KEY or `drivers.openai.apiKey` in config/ai.ts.");return{apiKey,baseUrl:(customBase||OPENAI_BASE_URL).replace(/\/+$/,"")}}export class SpeechRequestError extends Error{status;retryAfter;constructor(message,status,retryAfter=null){super(message);this.status=status;this.retryAfter=retryAfter;this.name="SpeechRequestError"}}async function failure(provider,operation,response){let body="";try{body=(await response.text()).trim()}catch{}const snippet=body.length>500?`${body.slice(0,500)}...`:body;return new SpeechRequestError(`${provider} ${operation} failed with HTTP ${response.status}${snippet?`: ${snippet}`:""}`,response.status,response.headers.get("retry-after"))}const MIME_TYPES={mp3:"audio/mpeg",opus:"audio/ogg",aac:"audio/aac",flac:"audio/flac",wav:"audio/wav",pcm:"audio/pcm"};export function speechMimeType(format){return MIME_TYPES[format]}export async function textToSpeech(text,options={}){if(typeof text!=="string"||!text.trim())throw Error("textToSpeech() needs non-empty text.");const format=options.format??"mp3";if(!(format in MIME_TYPES))throw Error(`Unsupported speech format "${format}". Use one of: ${Object.keys(MIME_TYPES).join(", ")}.`);if(options.speed!==void 0&&!(options.speed>=0.25&&options.speed<=4))throw Error(`Speech speed must be between 0.25 and 4.0, got ${options.speed}.`);const config=await loadConfig(options.config),driver=resolveSpeechDriver("Text-to-speech",config,options.driver);return openAITextToSpeech(text,format,options,config,driver)}async function openAITextToSpeech(text,format,options,config,driver){if(text.length>MAX_OPENAI_INPUT)throw Error(`OpenAI text-to-speech accepts at most ${MAX_OPENAI_INPUT} characters per request, got ${text.length}. Split the text into chunks.`);const model=options.model??"gpt-4o-mini-tts",voice=options.voice??"alloy";if(options.instructions&&/^tts-1(?:-hd)?$/.test(model))throw Error(`OpenAI model "${model}" does not support \`instructions\`. Use gpt-4o-mini-tts.`);const{apiKey,baseUrl}=openAICredentials(config),body={model,input:text,voice,response_format:format};if(options.speed!==void 0)body.speed=options.speed;if(options.instructions)body.instructions=options.instructions;const headers={"Content-Type":"application/json"};if(apiKey)headers.Authorization=`Bearer ${apiKey}`;const response=await fetchWithRetry(`${baseUrl}/audio/speech`,{method:"POST",headers,body:JSON.stringify(body),signal:options.signal},options.retry);if(!response.ok)throw await failure("OpenAI","text-to-speech",response);const audio=new Uint8Array(await response.arrayBuffer()),contentType=response.headers.get("content-type")?.split(";")[0]?.trim(),mimeType=contentType?.startsWith("audio/")?contentType:MIME_TYPES[format];return{audio,mimeType,format,driver,model,voice}}export async function storeSpeech(speech,path,options={}){if(!path?.trim())throw Error("storeSpeech() needs a destination path.");let disk;if(options.disk&&typeof options.disk==="object")disk=options.disk;else{let storage;try{storage=await import("@stacksjs/storage")}catch(error){throw Error(`storeSpeech() needs @stacksjs/storage to resolve a disk by name (${error instanceof Error?error.message:String(error)}). Install it, or pass a storage adapter as { disk }.`)}disk=storage.Storage.disk(options.disk||void 0)}const result=await disk.write(path,speech.audio);return{path:result.path,size:result.size,mimeType:speech.mimeType}}const EXTENSIONS={"audio/mpeg":"mp3","audio/mp3":"mp3","audio/mp4":"m4a","audio/m4a":"m4a","audio/x-m4a":"m4a","audio/wav":"wav","audio/x-wav":"wav","audio/webm":"webm","audio/ogg":"ogg","audio/flac":"flac"};export async function speechToText(audio,options={}){if(options.temperature!==void 0&&!(options.temperature>=0&&options.temperature<=1))throw Error(`Transcription temperature must be between 0 and 1, got ${options.temperature}.`);const config=await loadConfig(options.config),driver=resolveSpeechDriver("Speech-to-text",config,options.driver),model=options.model??"whisper-1",{apiKey,baseUrl}=openAICredentials(config),blob=audio instanceof Blob?audio:new Blob([audio]);if(blob.size===0)throw Error("speechToText() needs non-empty audio.");const ownName=audio.name,fileName=options.filename||(typeof ownName==="string"&&ownName?ownName.split("/").pop():void 0)||`audio.${EXTENSIONS[blob.type.split(";")[0]??""]??"mp3"}`,form=new FormData;form.append("file",blob,fileName);form.append("model",model);form.append("response_format","json");if(options.language)form.append("language",options.language);if(options.prompt)form.append("prompt",options.prompt);if(options.temperature!==void 0)form.append("temperature",String(options.temperature));const headers={};if(apiKey)headers.Authorization=`Bearer ${apiKey}`;const response=await fetchWithRetry(`${baseUrl}/audio/transcriptions`,{method:"POST",headers,body:form,signal:options.signal},options.retry);if(!response.ok)throw await failure("OpenAI","speech-to-text",response);const data=await response.json();if(typeof data?.text!=="string")throw Error("OpenAI speech-to-text returned no text.");return{text:data.text,driver,model}}
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.361",
5
+ "version": "0.75.366",
6
6
  "description": "Stacks Artificial Intelligence.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -65,10 +65,10 @@
65
65
  "prepublishOnly": "bun run build"
66
66
  },
67
67
  "dependencies": {
68
- "@stacksjs/cli": "0.75.361",
69
- "@stacksjs/config": "0.75.361",
70
- "@stacksjs/router": "0.75.361",
71
- "@stacksjs/storage": "0.75.361",
68
+ "@stacksjs/cli": "0.75.366",
69
+ "@stacksjs/config": "0.75.366",
70
+ "@stacksjs/router": "0.75.366",
71
+ "@stacksjs/storage": "0.75.366",
72
72
  "@stacksjs/ts-cloud": "^0.16.69"
73
73
  },
74
74
  "peerDependencies": {