@openparachute/vault 0.6.5-rc.2 → 0.6.5
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/.parachute/module.json +1 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/do-param-cap.test.ts +161 -0
- package/core/src/links.ts +8 -13
- package/core/src/mcp.ts +1 -1
- package/core/src/notes.ts +33 -15
- package/core/src/onboarding.ts +14 -296
- package/core/src/schema.ts +5 -5
- package/core/src/seed-packs.test.ts +357 -0
- package/core/src/seed-packs.ts +823 -0
- package/core/src/sql-in.test.ts +58 -0
- package/core/src/sql-in.ts +76 -0
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/transcription/provider.ts +141 -0
- package/core/src/types.ts +1 -1
- package/core/src/vault-projection.ts +1 -1
- package/core/src/wikilinks.ts +10 -4
- package/package.json +1 -1
- package/src/add-pack.test.ts +142 -0
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +806 -7
- package/src/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +201 -0
- package/src/module-manifest.ts +8 -0
- package/src/onboarding-seed.test.ts +188 -40
- package/src/onboarding-seed.ts +41 -46
- package/src/routes.ts +20 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +18 -6
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +133 -31
- package/src/services-manifest.ts +8 -0
- package/src/subscriptions.ts +100 -42
- package/src/tag-scope.ts +3 -3
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/token-store.ts +2 -2
- package/src/transcription/build.test.ts +305 -0
- package/src/transcription/build.ts +332 -0
- package/src/transcription/capability.test.ts +118 -0
- package/src/transcription/capability.ts +96 -0
- package/src/transcription/download.test.ts +101 -0
- package/src/transcription/download.ts +71 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/install.test.ts +167 -0
- package/src/transcription/install.ts +296 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/providers/scribe-http.test.ts +195 -0
- package/src/transcription/providers/scribe-http.ts +144 -0
- package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
- package/src/transcription/providers/transcribe-cpp.ts +293 -0
- package/src/transcription/select.test.ts +299 -0
- package/src/transcription/select.ts +397 -0
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/transcription-worker.test.ts +44 -0
- package/src/transcription-worker.ts +57 -122
- package/src/vault-create.test.ts +43 -10
- package/src/vault.test.ts +49 -1
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -248
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parakeet-mlx` — the local Apple-Silicon transcription provider
|
|
3
|
+
* (scribe-fold Phase 2b).
|
|
4
|
+
*
|
|
5
|
+
* Turns audio into text by subprocessing the `parakeet-mlx` CLI
|
|
6
|
+
* (github.com/senstella/parakeet-mlx — NVIDIA Parakeet on Apple's MLX,
|
|
7
|
+
* macOS/Apple-Silicon only). Ported from scribe's proven
|
|
8
|
+
* `src/transcribe/parakeet-mlx.ts`; structurally a sibling of the
|
|
9
|
+
* `transcribe-cpp` provider (same SpawnRunner seam, same terminal-vs-retriable
|
|
10
|
+
* error contract).
|
|
11
|
+
*
|
|
12
|
+
* ## The audio path
|
|
13
|
+
*
|
|
14
|
+
* `parakeet-mlx` decodes the input itself (shelling to ffmpeg internally for
|
|
15
|
+
* non-WAV audio), so unlike transcribe-cpp we do NOT pre-transcode — the raw
|
|
16
|
+
* capture bytes are written to a temp file and handed over directly:
|
|
17
|
+
*
|
|
18
|
+
* parakeet-mlx <audio> --output-format txt --output-dir <tmpout> [--model <id>]
|
|
19
|
+
*
|
|
20
|
+
* The transcript comes back as a `.txt` file in the output dir — named after
|
|
21
|
+
* the input file's stem, though some versions use their own stem, so we fall
|
|
22
|
+
* back to "any .txt in the dir" (scribe's proven fallback).
|
|
23
|
+
*
|
|
24
|
+
* ## The ffmpeg-missing mask (scribe's classic silent failure)
|
|
25
|
+
*
|
|
26
|
+
* parakeet-mlx shells to ffmpeg internally and, when ffmpeg is missing,
|
|
27
|
+
* prints the error then **exits 0** with no output file — the exitCode gate
|
|
28
|
+
* never fires. The signature in the combined stdout+stderr is the only
|
|
29
|
+
* signal, so "no output + ffmpeg-missing signature" maps to a terminal
|
|
30
|
+
* `ffmpeg_missing` error the operator can actually fix (ported from scribe's
|
|
31
|
+
* `looksLikeFfmpegMissing`).
|
|
32
|
+
*
|
|
33
|
+
* ## Error mapping (mirrors transcribe-cpp)
|
|
34
|
+
*
|
|
35
|
+
* - binary not installed → non-retriable `missing_provider` (never spawns).
|
|
36
|
+
* - launch failure (127) → non-retriable `missing_provider`.
|
|
37
|
+
* - non-zero exit → RETRIABLE `parakeet_mlx_error` (transient resource blip
|
|
38
|
+
* backs off; a deterministic failure still terminates after maxAttempts).
|
|
39
|
+
* - exit 0 + no output + ffmpeg signature → non-retriable `ffmpeg_missing`.
|
|
40
|
+
* - exit 0 + no output otherwise / empty transcript → plain `Error`
|
|
41
|
+
* (retriable via the worker).
|
|
42
|
+
*
|
|
43
|
+
* NOTE on first use: the model (~2.5GB for parakeet-tdt-0.6b-v3) downloads
|
|
44
|
+
* into the HuggingFace cache on the first transcription. A slow first run may
|
|
45
|
+
* hit the subprocess timeout; the download resumes on the worker's retry.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { randomUUID } from "crypto";
|
|
49
|
+
import { existsSync, mkdirSync, readdirSync } from "fs";
|
|
50
|
+
import { writeFile, readFile, rm } from "fs/promises";
|
|
51
|
+
import { tmpdir } from "os";
|
|
52
|
+
import { join } from "path";
|
|
53
|
+
import {
|
|
54
|
+
TranscriptionError,
|
|
55
|
+
type TranscriptionProvider,
|
|
56
|
+
type TranscribeInput,
|
|
57
|
+
type TranscribeResult,
|
|
58
|
+
type ProviderAvailability,
|
|
59
|
+
} from "../../../core/src/transcription/provider.ts";
|
|
60
|
+
import { defaultSpawnRunner, type SpawnRunner } from "./transcribe-cpp.ts";
|
|
61
|
+
|
|
62
|
+
/** Model download on first use + MLX inference can be slow; generous default. */
|
|
63
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Detect an ffmpeg-missing signature in a tool's combined stdout+stderr.
|
|
67
|
+
* Ported verbatim from scribe's `transcribe/backend-error.ts`: tolerant by
|
|
68
|
+
* design — requires the co-occurrence of an `ffmpeg` mention with a "not
|
|
69
|
+
* installed / not found / no such file" phrasing anywhere in the output
|
|
70
|
+
* (e.g. `[Errno 2] No such file or directory: 'ffmpeg'`).
|
|
71
|
+
*/
|
|
72
|
+
const FFMPEG_TOKEN = /ffmpeg/i;
|
|
73
|
+
const NOT_PRESENT = /not (?:installed|found)|no such file/i;
|
|
74
|
+
|
|
75
|
+
export function looksLikeFfmpegMissing(combinedOutput: string): boolean {
|
|
76
|
+
if (!combinedOutput) return false;
|
|
77
|
+
return FFMPEG_TOKEN.test(combinedOutput) && NOT_PRESENT.test(combinedOutput);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ParakeetMlxProviderOpts {
|
|
81
|
+
/** Path to the `parakeet-mlx` binary. Undefined ⇒ unavailable + terminal. */
|
|
82
|
+
binPath?: string;
|
|
83
|
+
/** HF model repo id passed as `--model`. Undefined ⇒ the CLI's own default. */
|
|
84
|
+
model?: string;
|
|
85
|
+
/** Per-transcription timeout (ms). Default 600s. */
|
|
86
|
+
timeoutMs?: number;
|
|
87
|
+
/** Subprocess runner (tests inject a mock). Default `defaultSpawnRunner`. */
|
|
88
|
+
spawn?: SpawnRunner;
|
|
89
|
+
/** Existence probe (tests inject). Default `fs.existsSync`. */
|
|
90
|
+
existsImpl?: (p: string) => boolean;
|
|
91
|
+
/** Scratch dir for the temp audio + output files. Default `os.tmpdir()`. */
|
|
92
|
+
tmpDir?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the `parakeet-mlx` argv. Scribe's proven invocation:
|
|
97
|
+
* `parakeet-mlx <audio> --output-format txt --output-dir <dir>`
|
|
98
|
+
* plus an optional `--model <hf-repo>` (the ratified default is
|
|
99
|
+
* mlx-community/parakeet-tdt-0.6b-v3). Exported so a test pins the shape.
|
|
100
|
+
*/
|
|
101
|
+
export function buildParakeetMlxArgs(
|
|
102
|
+
binPath: string,
|
|
103
|
+
audioPath: string,
|
|
104
|
+
outDir: string,
|
|
105
|
+
model?: string,
|
|
106
|
+
): string[] {
|
|
107
|
+
const args = [binPath, audioPath, "--output-format", "txt", "--output-dir", outDir];
|
|
108
|
+
if (model) args.push("--model", model);
|
|
109
|
+
return args;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class ParakeetMlxProvider implements TranscriptionProvider {
|
|
113
|
+
readonly name = "parakeet-mlx";
|
|
114
|
+
|
|
115
|
+
private readonly binPath: string | undefined;
|
|
116
|
+
private readonly model: string | undefined;
|
|
117
|
+
private readonly timeoutMs: number;
|
|
118
|
+
private readonly spawn: SpawnRunner;
|
|
119
|
+
private readonly existsImpl: (p: string) => boolean;
|
|
120
|
+
private readonly tmpDir: string;
|
|
121
|
+
/** Once available, stays available for this process — avoids re-statting. */
|
|
122
|
+
private cachedAvailable = false;
|
|
123
|
+
|
|
124
|
+
constructor(opts: ParakeetMlxProviderOpts = {}) {
|
|
125
|
+
this.binPath = opts.binPath;
|
|
126
|
+
this.model = opts.model;
|
|
127
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
128
|
+
this.spawn = opts.spawn ?? defaultSpawnRunner;
|
|
129
|
+
this.existsImpl = opts.existsImpl ?? existsSync;
|
|
130
|
+
this.tmpDir = opts.tmpDir ?? tmpdir();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Cheap readiness probe — a runnable `parakeet-mlx` binary on disk. NEVER
|
|
135
|
+
* spawns (it gates the capability flag on every `/api/vault` GET). The
|
|
136
|
+
* MODEL is not checked: it lives in the HF cache and downloads on first
|
|
137
|
+
* use, so binary presence is the honest cheap signal. Memoizes the
|
|
138
|
+
* once-true result so repeated landing reads don't re-stat.
|
|
139
|
+
*/
|
|
140
|
+
async available(): Promise<ProviderAvailability> {
|
|
141
|
+
if (this.cachedAvailable) return { ok: true };
|
|
142
|
+
if (!this.binPath || !this.existsImpl(this.binPath)) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
reason:
|
|
146
|
+
"parakeet-mlx is not installed (run `parachute-vault transcription install` on this Mac, or set PARAKEET_MLX_BIN)",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
this.cachedAvailable = true;
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async transcribe(input: TranscribeInput): Promise<TranscribeResult> {
|
|
154
|
+
if (!this.binPath || !this.existsImpl(this.binPath)) {
|
|
155
|
+
throw new TranscriptionError("parakeet-mlx not installed", {
|
|
156
|
+
code: "missing_provider",
|
|
157
|
+
retriable: false,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const id = randomUUID();
|
|
162
|
+
const inExt = extFor(input.filename, input.mimeType);
|
|
163
|
+
const stem = `parachute-pk-${id}`;
|
|
164
|
+
const inputPath = join(this.tmpDir, `${stem}.${inExt}`);
|
|
165
|
+
const outDir = join(this.tmpDir, `${stem}-out`);
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await writeFile(inputPath, input.audio);
|
|
169
|
+
mkdirSync(outDir, { recursive: true });
|
|
170
|
+
|
|
171
|
+
const res = await this.spawn(
|
|
172
|
+
buildParakeetMlxArgs(this.binPath, inputPath, outDir, this.model),
|
|
173
|
+
{ timeoutMs: this.timeoutMs },
|
|
174
|
+
);
|
|
175
|
+
const combined = `${res.stdout}\n${res.stderr}`;
|
|
176
|
+
|
|
177
|
+
if (res.exitCode !== 0) {
|
|
178
|
+
if (res.exitCode === 127) {
|
|
179
|
+
throw new TranscriptionError(
|
|
180
|
+
`parakeet-mlx could not be launched: ${res.stderr.trim()}`,
|
|
181
|
+
{ code: "missing_provider", retriable: false },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
throw new TranscriptionError(
|
|
185
|
+
`parakeet-mlx exited ${res.exitCode}: ${res.stderr.trim().slice(0, 500)}`,
|
|
186
|
+
{ code: "parakeet_mlx_error", retriable: true },
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Find the transcript: <stem>.txt, else any .txt the tool wrote (some
|
|
191
|
+
// versions name outputs after their own stem — scribe's proven fallback).
|
|
192
|
+
const expected = join(outDir, `${stem}.txt`);
|
|
193
|
+
let outFile: string | undefined = this.existsImpl(expected) ? expected : undefined;
|
|
194
|
+
if (!outFile) {
|
|
195
|
+
const txts = safeListTxt(outDir);
|
|
196
|
+
if (txts.length > 0) outFile = join(outDir, txts[0]!);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!outFile) {
|
|
200
|
+
// No output AND exit 0 — the classic ffmpeg-missing mask. Promote to a
|
|
201
|
+
// terminal, actionable error instead of an opaque retry loop.
|
|
202
|
+
if (looksLikeFfmpegMissing(combined)) {
|
|
203
|
+
throw new TranscriptionError(
|
|
204
|
+
"parakeet-mlx needs `ffmpeg` to decode this audio, but ffmpeg isn't installed or isn't on the daemon's PATH (brew install ffmpeg)",
|
|
205
|
+
{ code: "ffmpeg_missing", retriable: false },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
// Plain Error is treated as retriable by the worker.
|
|
209
|
+
throw new Error("parakeet-mlx produced no transcript output");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const text = (await readFile(outFile, "utf8")).trim();
|
|
213
|
+
if (!text) {
|
|
214
|
+
throw new Error("parakeet-mlx produced no transcript text");
|
|
215
|
+
}
|
|
216
|
+
return { text };
|
|
217
|
+
} finally {
|
|
218
|
+
await Promise.all([
|
|
219
|
+
rm(inputPath, { force: true }).catch(() => {}),
|
|
220
|
+
rm(outDir, { recursive: true, force: true }).catch(() => {}),
|
|
221
|
+
]);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** List `.txt` files in a dir, tolerating a dir the tool never created. */
|
|
227
|
+
function safeListTxt(dir: string): string[] {
|
|
228
|
+
try {
|
|
229
|
+
return (readdirSync(dir) as string[]).filter((f) => f.endsWith(".txt"));
|
|
230
|
+
} catch {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Choose a temp-file extension from the filename or mime type. */
|
|
236
|
+
function extFor(filename: string, mimeType: string): string {
|
|
237
|
+
const fromName = filename.split(".").pop();
|
|
238
|
+
if (fromName && fromName.length <= 5 && /^[a-z0-9]+$/i.test(fromName)) return fromName.toLowerCase();
|
|
239
|
+
const sub = mimeType.split("/")[1]?.split(";")[0]?.trim();
|
|
240
|
+
if (sub && /^[a-z0-9]+$/i.test(sub)) return sub.toLowerCase();
|
|
241
|
+
return "bin";
|
|
242
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { ScribeHttpProvider } from "./scribe-http.ts";
|
|
3
|
+
import { TranscriptionError } from "../../../core/src/transcription/provider.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Conformance tests for the `scribe-http` provider (scribe-fold Phase 1).
|
|
7
|
+
*
|
|
8
|
+
* These pin that the provider reproduces the former `callScribe` behavior
|
|
9
|
+
* EXACTLY — endpoint shape, auth header, context part, and the terminal-vs-
|
|
10
|
+
* retriable error mapping the worker depends on. If these drift, existing
|
|
11
|
+
* scribe installs change behavior across the fold, which Phase 1 forbids.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
15
|
+
return new Response(JSON.stringify(body), {
|
|
16
|
+
status,
|
|
17
|
+
headers: { "content-type": "application/json" },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Capture the last fetch call so we can assert endpoint/headers/body. */
|
|
22
|
+
function recordingFetch(response: Response | (() => Response | Promise<Response>)): {
|
|
23
|
+
fetchImpl: typeof fetch;
|
|
24
|
+
calls: Array<{ url: string; init: RequestInit | undefined }>;
|
|
25
|
+
} {
|
|
26
|
+
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
|
|
27
|
+
const fetchImpl = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
28
|
+
calls.push({ url: String(url), init });
|
|
29
|
+
return typeof response === "function" ? await response() : response;
|
|
30
|
+
}) as typeof fetch;
|
|
31
|
+
return { fetchImpl, calls };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const AUDIO = new Uint8Array([1, 2, 3, 4]);
|
|
35
|
+
|
|
36
|
+
describe("ScribeHttpProvider.available", () => {
|
|
37
|
+
test("ok:true when a URL is configured", async () => {
|
|
38
|
+
const p = new ScribeHttpProvider({ url: "http://scribe.test" });
|
|
39
|
+
expect(await p.available()).toEqual({ ok: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("ok:false with a reason when no URL is configured", async () => {
|
|
43
|
+
const p = new ScribeHttpProvider({ url: undefined });
|
|
44
|
+
const avail = await p.available();
|
|
45
|
+
expect(avail.ok).toBe(false);
|
|
46
|
+
expect(avail.reason).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("ok:false for an empty/whitespace URL", async () => {
|
|
50
|
+
expect((await new ScribeHttpProvider({ url: " " }).available()).ok).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("name is stable 'scribe-http'", () => {
|
|
54
|
+
expect(new ScribeHttpProvider({ url: "http://scribe.test" }).name).toBe("scribe-http");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("ScribeHttpProvider.transcribe — happy path", () => {
|
|
59
|
+
test("POSTs multipart to /v1/audio/transcriptions and returns { text }", async () => {
|
|
60
|
+
const { fetchImpl, calls } = recordingFetch(jsonResponse({ text: "hello world" }));
|
|
61
|
+
const p = new ScribeHttpProvider({ url: "http://scribe.test/", fetchImpl });
|
|
62
|
+
|
|
63
|
+
const result = await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
|
|
64
|
+
|
|
65
|
+
expect(result.text).toBe("hello world");
|
|
66
|
+
// Trailing slash on the base URL is normalized away (no `//v1`).
|
|
67
|
+
expect(calls[0]!.url).toBe("http://scribe.test/v1/audio/transcriptions");
|
|
68
|
+
expect(calls[0]!.init?.method).toBe("POST");
|
|
69
|
+
expect(calls[0]!.init?.body).toBeInstanceOf(FormData);
|
|
70
|
+
const form = calls[0]!.init?.body as FormData;
|
|
71
|
+
expect(form.get("file")).toBeInstanceOf(File);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("sends Authorization: Bearer when a token is set", async () => {
|
|
75
|
+
const { fetchImpl, calls } = recordingFetch(jsonResponse({ text: "x" }));
|
|
76
|
+
const p = new ScribeHttpProvider({ url: "http://scribe.test", token: "sekret", fetchImpl });
|
|
77
|
+
await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
|
|
78
|
+
expect((calls[0]!.init?.headers as Record<string, string>)["Authorization"]).toBe("Bearer sekret");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("omits Authorization when no token (loopback-trust)", async () => {
|
|
82
|
+
const { fetchImpl, calls } = recordingFetch(jsonResponse({ text: "x" }));
|
|
83
|
+
const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
|
|
84
|
+
await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
|
|
85
|
+
expect((calls[0]!.init?.headers as Record<string, string>)["Authorization"]).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("attaches a context part when context has entries", async () => {
|
|
89
|
+
const { fetchImpl, calls } = recordingFetch(jsonResponse({ text: "x" }));
|
|
90
|
+
const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
|
|
91
|
+
await p.transcribe({
|
|
92
|
+
audio: AUDIO,
|
|
93
|
+
filename: "a.webm",
|
|
94
|
+
mimeType: "audio/webm",
|
|
95
|
+
context: { entries: [{ name: "Aaron", summary: "founder" }] },
|
|
96
|
+
});
|
|
97
|
+
const form = calls[0]!.init?.body as FormData;
|
|
98
|
+
expect(form.get("context")).toBeInstanceOf(Blob);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("no context part when entries is empty", async () => {
|
|
102
|
+
const { fetchImpl, calls } = recordingFetch(jsonResponse({ text: "x" }));
|
|
103
|
+
const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
|
|
104
|
+
await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm", context: { entries: [] } });
|
|
105
|
+
expect((calls[0]!.init?.body as FormData).get("context")).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("ScribeHttpProvider.transcribe — error mapping", () => {
|
|
110
|
+
test("4xx with error_code → non-retriable TranscriptionError carrying code", async () => {
|
|
111
|
+
const { fetchImpl } = recordingFetch(
|
|
112
|
+
jsonResponse({ error: "no transcription provider configured", error_code: "missing_provider" }, 400),
|
|
113
|
+
);
|
|
114
|
+
const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
|
|
115
|
+
|
|
116
|
+
let thrown: unknown;
|
|
117
|
+
try {
|
|
118
|
+
await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
thrown = err;
|
|
121
|
+
}
|
|
122
|
+
expect(thrown).toBeInstanceOf(TranscriptionError);
|
|
123
|
+
const e = thrown as TranscriptionError;
|
|
124
|
+
expect(e.code).toBe("missing_provider");
|
|
125
|
+
expect(e.httpStatus).toBe(400);
|
|
126
|
+
expect(e.retriable).toBe(false);
|
|
127
|
+
// error message prefers the JSON `error` field verbatim.
|
|
128
|
+
expect(e.message).toBe("no transcription provider configured");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("4xx without error_code → non-retriable with 'scribe returned 400: <body>' message", async () => {
|
|
132
|
+
const { fetchImpl } = recordingFetch(new Response("bad multipart", { status: 400 }));
|
|
133
|
+
const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
|
|
134
|
+
|
|
135
|
+
let thrown: unknown;
|
|
136
|
+
try {
|
|
137
|
+
await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
|
|
138
|
+
} catch (err) {
|
|
139
|
+
thrown = err;
|
|
140
|
+
}
|
|
141
|
+
const e = thrown as TranscriptionError;
|
|
142
|
+
expect(e).toBeInstanceOf(TranscriptionError);
|
|
143
|
+
expect(e.code).toBeUndefined();
|
|
144
|
+
expect(e.retriable).toBe(false);
|
|
145
|
+
expect(e.message).toBe("scribe returned 400: bad multipart");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("5xx → retriable TranscriptionError", async () => {
|
|
149
|
+
const { fetchImpl } = recordingFetch(new Response("upstream boom", { status: 503 }));
|
|
150
|
+
const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
|
|
151
|
+
|
|
152
|
+
let thrown: unknown;
|
|
153
|
+
try {
|
|
154
|
+
await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
|
|
155
|
+
} catch (err) {
|
|
156
|
+
thrown = err;
|
|
157
|
+
}
|
|
158
|
+
const e = thrown as TranscriptionError;
|
|
159
|
+
expect(e).toBeInstanceOf(TranscriptionError);
|
|
160
|
+
expect(e.retriable).toBe(true);
|
|
161
|
+
expect(e.httpStatus).toBe(503);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("200 with missing text field → plain Error (retriable via worker)", async () => {
|
|
165
|
+
const { fetchImpl } = recordingFetch(jsonResponse({ not_text: 1 }));
|
|
166
|
+
const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
|
|
167
|
+
|
|
168
|
+
let thrown: unknown;
|
|
169
|
+
try {
|
|
170
|
+
await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
|
|
171
|
+
} catch (err) {
|
|
172
|
+
thrown = err;
|
|
173
|
+
}
|
|
174
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
175
|
+
expect(thrown).not.toBeInstanceOf(TranscriptionError);
|
|
176
|
+
expect((thrown as Error).message).toBe("scribe response missing text field");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("no URL configured → non-retriable missing_provider (never fetches)", async () => {
|
|
180
|
+
const { fetchImpl, calls } = recordingFetch(jsonResponse({ text: "should not run" }));
|
|
181
|
+
const p = new ScribeHttpProvider({ url: undefined, fetchImpl });
|
|
182
|
+
|
|
183
|
+
let thrown: unknown;
|
|
184
|
+
try {
|
|
185
|
+
await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
thrown = err;
|
|
188
|
+
}
|
|
189
|
+
const e = thrown as TranscriptionError;
|
|
190
|
+
expect(e).toBeInstanceOf(TranscriptionError);
|
|
191
|
+
expect(e.code).toBe("missing_provider");
|
|
192
|
+
expect(e.retriable).toBe(false);
|
|
193
|
+
expect(calls.length).toBe(0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scribe-http` — the whisper-compatible remote transcription provider
|
|
3
|
+
* (scribe-fold Phase 1).
|
|
4
|
+
*
|
|
5
|
+
* This is a byte-for-byte port of the transcription worker's former
|
|
6
|
+
* `callScribe`: discover a scribe URL (via `SCRIBE_URL` / `services.json`,
|
|
7
|
+
* resolved by the caller and handed in), POST the audio as multipart
|
|
8
|
+
* `file` to `<url>/v1/audio/transcriptions` (the Whisper API shape) with an
|
|
9
|
+
* optional `context` part and an optional `Authorization: Bearer` header,
|
|
10
|
+
* and read `{ text }` back. It is BOTH the default provider for existing
|
|
11
|
+
* installs (so their behavior is unchanged across the fold) and the migration
|
|
12
|
+
* bridge to any remote/whisper-compatible endpoint.
|
|
13
|
+
*
|
|
14
|
+
* Error mapping (unchanged from the old `callScribe`, now expressed as the
|
|
15
|
+
* runtime-agnostic `TranscriptionError`):
|
|
16
|
+
* - 4xx with a JSON `error_code` → non-retriable `TranscriptionError`
|
|
17
|
+
* carrying that `code` (e.g. `missing_provider`). Re-POSTing the same
|
|
18
|
+
* audio at the same broken scribe just fails again; the operator must act.
|
|
19
|
+
* - 4xx without `error_code` (auth, malformed multipart) → non-retriable
|
|
20
|
+
* `TranscriptionError` with the body text.
|
|
21
|
+
* - 5xx → retriable `TranscriptionError` (upstream hiccup; worker backs off).
|
|
22
|
+
* - network error / timeout → the raw `Error` propagates (worker treats a
|
|
23
|
+
* plain Error as retriable).
|
|
24
|
+
* - 200 with a missing/invalid `text` field → plain `Error` (retriable).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { appendContextPart } from "../../context.ts";
|
|
28
|
+
import {
|
|
29
|
+
TranscriptionError,
|
|
30
|
+
type TranscriptionProvider,
|
|
31
|
+
type TranscribeInput,
|
|
32
|
+
type TranscribeResult,
|
|
33
|
+
type ProviderAvailability,
|
|
34
|
+
} from "../../../core/src/transcription/provider.ts";
|
|
35
|
+
|
|
36
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
37
|
+
|
|
38
|
+
export interface ScribeHttpProviderOpts {
|
|
39
|
+
/**
|
|
40
|
+
* Resolved scribe base URL (no trailing slash required — trimmed here).
|
|
41
|
+
* `undefined`/empty means scribe isn't discoverable → `available()` is false
|
|
42
|
+
* and `transcribe()` throws a non-retriable `missing_provider`-style error.
|
|
43
|
+
*/
|
|
44
|
+
url?: string;
|
|
45
|
+
/** Optional bearer token for scribe (the shared loopback secret / hub JWT). */
|
|
46
|
+
token?: string;
|
|
47
|
+
/** Per-request timeout (ms). Default 120s — matches the former worker default. */
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
/** Injection seam for tests; production omits it and uses global `fetch`. */
|
|
50
|
+
fetchImpl?: typeof fetch;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class ScribeHttpProvider implements TranscriptionProvider {
|
|
54
|
+
readonly name = "scribe-http";
|
|
55
|
+
|
|
56
|
+
private readonly url: string | undefined;
|
|
57
|
+
private readonly token: string | undefined;
|
|
58
|
+
private readonly timeoutMs: number;
|
|
59
|
+
private readonly fetchImpl: typeof fetch;
|
|
60
|
+
|
|
61
|
+
constructor(opts: ScribeHttpProviderOpts = {}) {
|
|
62
|
+
this.url = opts.url?.trim() ? opts.url.trim().replace(/\/$/, "") : undefined;
|
|
63
|
+
this.token = opts.token;
|
|
64
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
65
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Availability is purely "can we resolve a scribe URL" — no network probe.
|
|
70
|
+
* The capability flag is read on config reads, so this must stay cheap.
|
|
71
|
+
*/
|
|
72
|
+
async available(): Promise<ProviderAvailability> {
|
|
73
|
+
if (!this.url) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
reason: "no scribe URL configured (set SCRIBE_URL or install scribe so it registers in services.json)",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { ok: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async transcribe(input: TranscribeInput): Promise<TranscribeResult> {
|
|
83
|
+
if (!this.url) {
|
|
84
|
+
// Mirrors scribe's own `missing_provider` contract: terminal, operator
|
|
85
|
+
// must act. Reaching here means the worker was constructed without a
|
|
86
|
+
// resolvable scribe URL — a config problem, not a transient one.
|
|
87
|
+
throw new TranscriptionError("no scribe URL configured", {
|
|
88
|
+
code: "missing_provider",
|
|
89
|
+
retriable: false,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
95
|
+
try {
|
|
96
|
+
const file = new File([input.audio], input.filename, { type: input.mimeType });
|
|
97
|
+
const form = new FormData();
|
|
98
|
+
form.append("file", file);
|
|
99
|
+
if (input.context) appendContextPart(form, input.context);
|
|
100
|
+
|
|
101
|
+
const endpoint = `${this.url}/v1/audio/transcriptions`;
|
|
102
|
+
const headers: Record<string, string> = {};
|
|
103
|
+
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
|
104
|
+
|
|
105
|
+
const resp = await this.fetchImpl(endpoint, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers,
|
|
108
|
+
body: form,
|
|
109
|
+
signal: controller.signal,
|
|
110
|
+
});
|
|
111
|
+
if (!resp.ok) {
|
|
112
|
+
const body = await resp.text().catch(() => "");
|
|
113
|
+
// Try to extract a structured error_code from a JSON body (scribe#47).
|
|
114
|
+
let errorCode: string | undefined;
|
|
115
|
+
let errorMessage: string | undefined;
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(body) as { error?: string; error_code?: string; message?: string };
|
|
118
|
+
if (typeof parsed.error_code === "string") errorCode = parsed.error_code;
|
|
119
|
+
if (typeof parsed.error === "string") errorMessage = parsed.error;
|
|
120
|
+
else if (typeof parsed.message === "string") errorMessage = parsed.message;
|
|
121
|
+
} catch {
|
|
122
|
+
// Not JSON — leave errorCode undefined; the raw body becomes the message.
|
|
123
|
+
}
|
|
124
|
+
// 4xx is terminal (re-POSTing the same audio at the same broken scribe
|
|
125
|
+
// will just fail again). 5xx is retriable — provider hiccup.
|
|
126
|
+
const retriable = resp.status >= 500;
|
|
127
|
+
const message = errorMessage
|
|
128
|
+
?? (errorCode ? `scribe ${errorCode}` : `scribe returned ${resp.status}: ${body}`);
|
|
129
|
+
throw new TranscriptionError(message, {
|
|
130
|
+
code: errorCode,
|
|
131
|
+
httpStatus: resp.status,
|
|
132
|
+
retriable,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const result = await resp.json() as { text?: string };
|
|
136
|
+
if (typeof result.text !== "string") {
|
|
137
|
+
throw new Error("scribe response missing text field");
|
|
138
|
+
}
|
|
139
|
+
return { text: result.text };
|
|
140
|
+
} finally {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|