mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-08 06:54:18 +00:00
* refactor(ai): route TTS, STT and voice-changer REST calls through upstreamFetch upstreamFetch bounds a provider call with AbortSignal.timeout and throws an UpstreamResponseError carrying the upstream status and code, which the /drivers/call boundary maps like any SDK error. Replaces five hand-written status translators (ElevenLabs TTS, xAI TTS, Speechify, xAI STT, voice changer); caller-facing messages are now sanitized by the shared mapper. * perf(ai): bound input image fetches and share one data-URI parser fetchImageBytes now streams under a byte cap (30 MB by default) with a 30-second timeout, via a readBoundedBody helper shared with loadFileInput's web branch and Replicate's input measuring. assertInputImagesShape caps input_images at 16. Chat image inlining uses fetchImageBytes instead of buffering with no timeout. fileInput, imageInput and mediaParts parse data URIs through one parseDataUri. * refactor(ai-tts): one per-character metering path for TTS providers meterPerCharacter holds text length x price, runs the synthesis and meters the same amount once it succeeds; characterCostReport builds the per-character cost lines. Both sit on a new withAiCreditHold (reserve, run, release) that Gemini TTS uses directly. SAMPLE_AUDIO_URL is defined once. The TTSProvider base class is gone: its methods were stubs every provider overrode, and only Polly read its untyped providerConfig. * refactor(ai): one ProviderRegistry and key reader for the AI media drivers ProviderRegistry holds a driver's provider catalog (ids, default, aliases) and its configured instances: alias resolution, the not-found 400, default fallback and parallel 'all' listing. TTS, speech-to-text, OCR and the voice changer route through it. readProviderKey reads apiKey, secret_key, api_key and key in that order for every AI media driver, so providers.xai.secret_key now enables xAI TTS as it already did xAI image and speech-to-text. ElevenLabs caches its voice list for 10 minutes, as Polly does. * perf(ai): stream TTS and voice-changer audio instead of buffering it OpenAI, xAI and ElevenLabs TTS and the voice changer hand the upstream body straight to the caller through upstreamBodyStream; their costs are known before the call, so nothing waits on the download. A body that fails mid-stream ends the response early instead of raising an unhandled stream error. xAI speech-to-text keeps one copy of the audio (the upload Blob) instead of up to three. The voice changer and xAI STT hold credits through withAiCreditHold. * perf(ai-ocr): read Puter files only when the provider needs the bytes resolveFileInput resolves and access-checks a Puter file and defers the read to load(); loadFileInput is that plus the read. OCR resolves first: Textract reads the object straight from S3 and downloads it only if Textract can't reach the bucket, and only Mistral loads the bytes. A file over the size cap is refused from its recorded size before any read. countPdfPages inflates object streams on the libuv pool instead of the request thread. * perf(ai): build image and video model catalogs once and share output saving ModelCatalog holds what the public model routes serve (models, sorted names, cost lines), computed once after boot instead of on every listing. The video driver now awaits its model map before serving. resolveOutputPath and saveGeneratedMediaToFS replace the copies of puter_output_path handling in both drivers, using expandTildePath and normalizeAbsolutePath. The video driver normalizes a copy of the caller's args instead of mutating them, and its never-taken stream branch is gone. * fix(drivers): abort the response when a streamed driver result fails mid-body upstreamBodyStream now errors when the provider body fails instead of ending cleanly, and DriverController pipes stream results through pipeStreamResult (stream.pipeline): a failed source destroys the response so the client sees an aborted transfer, the failure is logged once, and nothing is left to raise an unhandled stream error. A client hanging up stops the source. * fix(drivers): drop a streamed driver result when the caller already hung up pipeline() throws synchronously on a closed destination, which surfaced as a 500 alarm on every early disconnect and left the upstream body open. * fix(ai): bound streamed upstream calls by time to first byte, not download time upstreamFetch takes a `stream` option: the timeout covers the request until the response (or a non-2xx error body) is read, then is cleared so a body piped to the client drains at the client's pace. ElevenLabs TTS, xAI TTS and the voice changer pass it; non-streamed calls keep the full-response bound. A stalled upstream body is still cut by undici's body idle timeout. * fix(drivers): type pipeStreamResult's response by the members it uses
161 lines
6.3 KiB
TypeScript
161 lines
6.3 KiB
TypeScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import { createServer, get, type Server } from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
import { Readable } from 'node:stream';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { pipeStreamResult } from './pipeStreamResult.js';
|
|
import { upstreamBodyStream } from './upstreamErrors.js';
|
|
|
|
let server: Server | undefined;
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
server?.closeAllConnections();
|
|
await new Promise((resolve) => server?.close(resolve) ?? resolve(null));
|
|
server = undefined;
|
|
});
|
|
|
|
/** Serves `source()` through pipeStreamResult and reads it as a client. */
|
|
const fetchThrough = async (
|
|
source: () => Readable,
|
|
onFirstChunk?: () => void,
|
|
): Promise<{ body: string; outcome: 'end' | 'aborted' }> => {
|
|
server = createServer((_req, res) => {
|
|
res.setHeader('Content-Type', 'audio/mpeg');
|
|
res.setHeader('Transfer-Encoding', 'chunked');
|
|
pipeStreamResult(source(), res, 'test.run');
|
|
});
|
|
await new Promise<void>((resolve) => server!.listen(0, resolve));
|
|
const { port } = server.address() as AddressInfo;
|
|
return new Promise((resolve) => {
|
|
get(`http://127.0.0.1:${port}/`, (res) => {
|
|
const chunks: Buffer[] = [];
|
|
let settled = false;
|
|
const done = (outcome: 'end' | 'aborted') => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve({ body: Buffer.concat(chunks).toString(), outcome });
|
|
};
|
|
res.on('data', (c: Buffer) => {
|
|
if (chunks.push(c) === 1) onFirstChunk?.();
|
|
});
|
|
res.on('end', () => done('end'));
|
|
res.on('aborted', () => done('aborted'));
|
|
res.on('error', () => done('aborted'));
|
|
});
|
|
});
|
|
};
|
|
|
|
describe('pipeStreamResult', () => {
|
|
it('delivers a healthy stream and ends the response', async () => {
|
|
const result = await fetchThrough(() => Readable.from(['a', 'b']));
|
|
expect(result).toEqual({ body: 'ab', outcome: 'end' });
|
|
});
|
|
|
|
it('aborts the transfer, logging once and staying up, when the upstream body fails after the first chunk', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const uncaught = vi.fn();
|
|
process.on('uncaughtException', uncaught);
|
|
let upstream!: ReadableStreamDefaultController<Uint8Array>;
|
|
try {
|
|
const result = await fetchThrough(
|
|
() => {
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
upstream = controller;
|
|
controller.enqueue(Buffer.from('first'));
|
|
},
|
|
});
|
|
return upstreamBodyStream(new Response(body));
|
|
},
|
|
() => upstream.error(new Error('connection reset')),
|
|
);
|
|
|
|
expect(result).toEqual({ body: 'first', outcome: 'aborted' });
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(String(warn.mock.calls[0]![0])).toContain('test.run');
|
|
expect(uncaught).not.toHaveBeenCalled();
|
|
} finally {
|
|
process.off('uncaughtException', uncaught);
|
|
}
|
|
});
|
|
|
|
it('stops the source, without logging, when the client hangs up', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
let source!: Readable;
|
|
let pulls = 0;
|
|
server = createServer((_req, res) => {
|
|
source = new Readable({
|
|
read() {
|
|
pulls += 1;
|
|
this.push(Buffer.alloc(64 * 1024));
|
|
},
|
|
});
|
|
pipeStreamResult(source, res, 'test.run');
|
|
});
|
|
await new Promise<void>((resolve) => server!.listen(0, resolve));
|
|
const { port } = server.address() as AddressInfo;
|
|
await new Promise<void>((resolve) => {
|
|
const req = get(`http://127.0.0.1:${port}/`, (res) => {
|
|
res.once('data', () => {
|
|
req.destroy();
|
|
resolve();
|
|
});
|
|
});
|
|
req.on('error', () => {});
|
|
});
|
|
|
|
await vi.waitFor(() => expect(source.destroyed).toBe(true));
|
|
const pullsAtClose = pulls;
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
expect(pulls).toBe(pullsAtClose);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('drops the source, without throwing, when the client left before the result was ready', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const source = Readable.from(['late']);
|
|
const piped = new Promise<unknown>((resolve) => {
|
|
server = createServer((_req, res) => {
|
|
// The driver is still running when the caller hangs up.
|
|
res.once('close', () => {
|
|
try {
|
|
pipeStreamResult(source, res, 'test.run');
|
|
resolve(undefined);
|
|
} catch (e) {
|
|
resolve(e);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) => server!.listen(0, resolve));
|
|
const { port } = server!.address() as AddressInfo;
|
|
const req = get(`http://127.0.0.1:${port}/`);
|
|
req.on('error', () => {});
|
|
req.on('socket', (socket) =>
|
|
socket.on('connect', () => setTimeout(() => req.destroy(), 20)),
|
|
);
|
|
|
|
expect(await piped).toBeUndefined();
|
|
expect(source.destroyed).toBe(true);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
});
|