@openparachute/vault 0.7.5-rc.7 → 0.7.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/package.json +1 -1
- package/src/mirror-routes.ts +13 -6
- package/src/routing.ts +36 -0
- package/src/transcription-routes.test.ts +195 -0
- package/src/transcription-routes.ts +327 -0
- package/web/ui/dist/assets/index-g3_KwmRE.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-cdAcASvO.js +0 -61
package/package.json
CHANGED
package/src/mirror-routes.ts
CHANGED
|
@@ -1655,7 +1655,7 @@ export async function applyCredentialsToMirror(
|
|
|
1655
1655
|
// "credentials": null
|
|
1656
1656
|
// | { "kind": "pat", "token": "ghp_..." }
|
|
1657
1657
|
// | { "kind": "none" },
|
|
1658
|
-
// "enable_sync":
|
|
1658
|
+
// "enable_sync": false // optional, DEFAULT FALSE (vault#641)
|
|
1659
1659
|
// }
|
|
1660
1660
|
//
|
|
1661
1661
|
// `credentials: null` means "use the stored mirror credentials." Passing
|
|
@@ -1663,11 +1663,18 @@ export async function applyCredentialsToMirror(
|
|
|
1663
1663
|
// for the CLONE, but IS persisted when sync is enabled (it's the push
|
|
1664
1664
|
// credential for the now-configured mirror).
|
|
1665
1665
|
//
|
|
1666
|
-
// `enable_sync`
|
|
1667
|
-
//
|
|
1668
|
-
// credentials
|
|
1669
|
-
// one
|
|
1670
|
-
//
|
|
1666
|
+
// `enable_sync` — DEFAULT **FALSE** when omitted (vault#641). When explicitly
|
|
1667
|
+
// enabled, a successful import also configures mirror push-back to the SAME
|
|
1668
|
+
// repo, reusing the import's credentials — "import a repo" and "back up to that
|
|
1669
|
+
// repo going forward" in one flow. The UI's checkbox is **unchecked** by default
|
|
1670
|
+
// to match.
|
|
1671
|
+
//
|
|
1672
|
+
// This shipped default-ON in vault#416 and was inverted in vault#641: import is
|
|
1673
|
+
// a READ, and defaulting to ON silently made the repo you were reading from into
|
|
1674
|
+
// this vault's push target. The full rationale lives at the handler below; keep
|
|
1675
|
+
// these two in agreement.
|
|
1676
|
+
//
|
|
1677
|
+
// Edge cases (handled in `enableSyncToImportedRepo`, never fail the
|
|
1671
1678
|
// whole import):
|
|
1672
1679
|
// - `auth: none` (public repo, no push creds) → skip + warn (can't push
|
|
1673
1680
|
// without a credential).
|
package/src/routing.ts
CHANGED
|
@@ -118,6 +118,10 @@ import {
|
|
|
118
118
|
handleMirrorRunNow,
|
|
119
119
|
} from "./mirror-routes.ts";
|
|
120
120
|
import { handleEmbeddingsGet, handleEmbeddingsPut } from "./embeddings-routes.ts";
|
|
121
|
+
import {
|
|
122
|
+
handleTranscriptionGet,
|
|
123
|
+
handleTranscriptionPut,
|
|
124
|
+
} from "./transcription-routes.ts";
|
|
121
125
|
import { getMirrorManager } from "./mirror-registry.ts";
|
|
122
126
|
import { buildUsageReport } from "./usage.ts";
|
|
123
127
|
import { handleTicketSpend } from "./attachment-tickets.ts";
|
|
@@ -716,6 +720,38 @@ export async function route(
|
|
|
716
720
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
717
721
|
}
|
|
718
722
|
|
|
723
|
+
// /.parachute/transcription — Admin-gated read+write of the transcription
|
|
724
|
+
// setup. Mirrors the embeddings toggle's shape (persist a preference, report
|
|
725
|
+
// `restart_required`) but answers a harder question: transcription can be
|
|
726
|
+
// "configured" and still not work, because it needs a binary and a model on
|
|
727
|
+
// disk that no config file can promise are there.
|
|
728
|
+
//
|
|
729
|
+
// So GET reports readiness with the missing PIECE named, the paths searched,
|
|
730
|
+
// and the exact command that fixes it. That's the gap this closes: a box
|
|
731
|
+
// could accept audio for weeks and transcribe nothing, with the only evidence
|
|
732
|
+
// a boot log line the operator scrolled past (vault#643).
|
|
733
|
+
//
|
|
734
|
+
// PUT persists provider/model only — installing downloads hundreds of MB and
|
|
735
|
+
// shells brew/tar, which belongs in the CLI with a progress bar, not in a
|
|
736
|
+
// request a browser tab can abandon. See transcription-routes.ts.
|
|
737
|
+
if (subpath === "/.parachute/transcription") {
|
|
738
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
739
|
+
return Response.json(
|
|
740
|
+
{
|
|
741
|
+
error: "Forbidden",
|
|
742
|
+
error_type: "insufficient_scope",
|
|
743
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
744
|
+
required_scope: SCOPE_ADMIN,
|
|
745
|
+
granted_scopes: auth.scopes,
|
|
746
|
+
},
|
|
747
|
+
{ status: 403 },
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
if (req.method === "GET") return handleTranscriptionGet();
|
|
751
|
+
if (req.method === "PUT") return handleTranscriptionPut(req);
|
|
752
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
753
|
+
}
|
|
754
|
+
|
|
719
755
|
// The per-vault `/tokens` REST surface (pvt_* mint/list/revoke) was removed
|
|
720
756
|
// at 0.5.0 (vault#282 Stage 2 — vault is a pure hub resource-server). Hub
|
|
721
757
|
// JWTs are minted via hub's registry (`/api/auth/mint-token`); a `/tokens`
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcription setup snapshot.
|
|
3
|
+
*
|
|
4
|
+
* The behaviour worth pinning is that "configured" and "working" are DIFFERENT
|
|
5
|
+
* — a box can have the right env vars and still transcribe nothing, which is
|
|
6
|
+
* how audio silently went nowhere for weeks (vault#643). So every test here is
|
|
7
|
+
* about the snapshot telling the truth about which piece is missing, rather
|
|
8
|
+
* than collapsing to a single boolean an operator can't act on.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { buildTranscriptionSnapshot, handleTranscriptionGet } from "./transcription-routes.ts";
|
|
16
|
+
|
|
17
|
+
const ORIG = { ...process.env };
|
|
18
|
+
let home: string;
|
|
19
|
+
/** Fake filesystem: only paths explicitly installed by a test exist. */
|
|
20
|
+
let present: Set<string>;
|
|
21
|
+
/** Resolution driven entirely by `present`, never by the real machine. */
|
|
22
|
+
function deps(active = false) {
|
|
23
|
+
return {
|
|
24
|
+
active,
|
|
25
|
+
existsImpl: (p: string) => present.has(p),
|
|
26
|
+
resolveBinaryImpl: (engine: "parakeet" | "whisper") => {
|
|
27
|
+
const name = engine === "whisper" ? "whisper-cli" : "parakeet-cli";
|
|
28
|
+
const p = join(home, "transcription", "bin", name);
|
|
29
|
+
return present.has(p) ? p : undefined;
|
|
30
|
+
},
|
|
31
|
+
resolveFfmpegImpl: () => {
|
|
32
|
+
const p = join(home, "ff", "ffmpeg");
|
|
33
|
+
return present.has(p) ? p : undefined;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
home = mkdtempSync(join(tmpdir(), "tr-routes-"));
|
|
40
|
+
present = new Set();
|
|
41
|
+
process.env.PARACHUTE_HOME = home;
|
|
42
|
+
// Isolate from the developer's real machine: an empty PATH plus no override
|
|
43
|
+
// means nothing resolves unless a test puts it there.
|
|
44
|
+
process.env.PATH = "";
|
|
45
|
+
delete process.env.WHISPER_CPP_BIN_DIR;
|
|
46
|
+
delete process.env.TRANSCRIPTION_PROVIDER;
|
|
47
|
+
delete process.env.TRANSCRIPTION_MODEL;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
rmSync(home, { recursive: true, force: true });
|
|
52
|
+
for (const k of ["PARACHUTE_HOME", "PATH", "WHISPER_CPP_BIN_DIR", "TRANSCRIPTION_PROVIDER", "TRANSCRIPTION_MODEL"]) {
|
|
53
|
+
if (ORIG[k] === undefined) delete process.env[k];
|
|
54
|
+
else process.env[k] = ORIG[k];
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Put a fake binary on the resolution ladder. */
|
|
59
|
+
function installBinary(name: string) {
|
|
60
|
+
present.add(join(home, "transcription", "bin", name));
|
|
61
|
+
}
|
|
62
|
+
/** Put a model file where the resolver looks. */
|
|
63
|
+
function installModel(filename: string) {
|
|
64
|
+
present.add(join(home, "transcription", "models", filename));
|
|
65
|
+
}
|
|
66
|
+
/** ffmpeg, via the explicit bin-dir override. */
|
|
67
|
+
function installFfmpeg() {
|
|
68
|
+
present.add(join(home, "ff", "ffmpeg"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("snapshot — the default (stale) provider", () => {
|
|
72
|
+
test("scribe-http with no backend reports NOT ready and says why", () => {
|
|
73
|
+
// The fresh-install state: nothing configured, so the provider resolves to
|
|
74
|
+
// scribe-http and there is no scribe. This is the case that used to be
|
|
75
|
+
// invisible.
|
|
76
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
77
|
+
expect(s.provider).toBe("scribe-http");
|
|
78
|
+
expect(s.ready).toBe(false);
|
|
79
|
+
expect(s.reason).toMatch(/no reachable backend/);
|
|
80
|
+
expect(s.reason).toMatch(/whisper-cpp/);
|
|
81
|
+
expect(s.fix_command).toBeTruthy();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("snapshot — whisper-cpp readiness names the MISSING piece", () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
process.env.TRANSCRIPTION_PROVIDER = "whisper-cpp";
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("nothing installed → names binary, model AND ffmpeg", () => {
|
|
91
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
92
|
+
expect(s.ready).toBe(false);
|
|
93
|
+
expect(s.reason).toMatch(/parakeet-cli/);
|
|
94
|
+
expect(s.reason).toMatch(/model file/);
|
|
95
|
+
expect(s.reason).toMatch(/ffmpeg/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("binary + model but NO ffmpeg → ffmpeg-specific fix, not a generic install", () => {
|
|
99
|
+
// Different problem, different command. Telling someone to re-run
|
|
100
|
+
// `transcription install` when the real gap is a system package wastes a
|
|
101
|
+
// 400 MB download and doesn't fix it.
|
|
102
|
+
installBinary("parakeet-cli");
|
|
103
|
+
installModel("ggml-parakeet-tdt-0.6b-v3-q4_k.bin");
|
|
104
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
105
|
+
expect(s.ready).toBe(false);
|
|
106
|
+
expect(s.reason).toMatch(/ffmpeg/);
|
|
107
|
+
expect(s.fix_command).toMatch(/brew install ffmpeg|apt install ffmpeg/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("everything present → ready, no reason, no fix", () => {
|
|
111
|
+
installBinary("parakeet-cli");
|
|
112
|
+
installModel("ggml-parakeet-tdt-0.6b-v3-q4_k.bin");
|
|
113
|
+
installFfmpeg();
|
|
114
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
115
|
+
expect(s.ready).toBe(true);
|
|
116
|
+
expect(s.reason).toBeNull();
|
|
117
|
+
expect(s.fix_command).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a whisper model asks for whisper-cli, not parakeet-cli", () => {
|
|
121
|
+
process.env.TRANSCRIPTION_MODEL = "whisper-base.en";
|
|
122
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
123
|
+
expect(s.binary.name).toBe("whisper-cli");
|
|
124
|
+
expect(s.reason).toMatch(/whisper-cli/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("an unknown model id is reported, not silently defaulted", () => {
|
|
128
|
+
process.env.TRANSCRIPTION_MODEL = "whisper-enormous";
|
|
129
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
130
|
+
expect(s.model).toBeNull();
|
|
131
|
+
expect(s.ready).toBe(false);
|
|
132
|
+
expect(s.reason).toMatch(/isn't in the catalog/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("snapshot — ready vs active", () => {
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
process.env.TRANSCRIPTION_PROVIDER = "whisper-cpp";
|
|
139
|
+
installBinary("parakeet-cli");
|
|
140
|
+
installModel("ggml-parakeet-tdt-0.6b-v3-q4_k.bin");
|
|
141
|
+
installFfmpeg();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("ready but not active → restart_required", () => {
|
|
145
|
+
// The operator just set a preference; the running worker hasn't picked it
|
|
146
|
+
// up. Conflating the two would report success on a box still doing nothing.
|
|
147
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
148
|
+
expect(s.ready).toBe(true);
|
|
149
|
+
expect(s.active).toBe(false);
|
|
150
|
+
expect(s.restart_required).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("ready and active → nothing to do", () => {
|
|
154
|
+
const s = buildTranscriptionSnapshot(deps(true));
|
|
155
|
+
expect(s.restart_required).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("snapshot — actionability", () => {
|
|
160
|
+
test("reports the directories searched, so 'not found' is debuggable", () => {
|
|
161
|
+
// On macOS the likeliest failure is a binary that IS installed but
|
|
162
|
+
// invisible to a launchd-supervised vault (no login-shell PATH). A boolean
|
|
163
|
+
// can't express that; a list of probed directories can.
|
|
164
|
+
// Intentionally the real ladder — this asserts what we SHOW the operator.
|
|
165
|
+
const s = buildTranscriptionSnapshot(false);
|
|
166
|
+
expect(s.binary.searched.length).toBeGreaterThan(0);
|
|
167
|
+
expect(s.binary.searched.some((d) => d.includes("homebrew") || d.includes("/usr/local"))).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("offers the whole catalog with real sizes and per-model install state", () => {
|
|
171
|
+
installModel("ggml-tiny.en.bin");
|
|
172
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
173
|
+
expect(s.available_models.length).toBeGreaterThan(3);
|
|
174
|
+
const tiny = s.available_models.find((m) => m.id === "whisper-tiny.en");
|
|
175
|
+
expect(tiny?.installed).toBe(true);
|
|
176
|
+
expect(tiny?.size_mb).toBe(74);
|
|
177
|
+
// A model we didn't write is correctly reported as absent.
|
|
178
|
+
expect(s.available_models.find((m) => m.id === "whisper-small.en")?.installed).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("catalog is smallest-first, so the picker reads as a ladder", () => {
|
|
182
|
+
const sizes = buildTranscriptionSnapshot(deps(false)).available_models.map((m) => m.size_mb);
|
|
183
|
+
expect([...sizes].sort((a, b) => a - b)).toEqual(sizes);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("GET handler", () => {
|
|
188
|
+
test("200 + no-store (a polled status must never be cached stale)", async () => {
|
|
189
|
+
const res = handleTranscriptionGet(false);
|
|
190
|
+
expect(res.status).toBe(200);
|
|
191
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
192
|
+
const body = (await res.json()) as { ready: boolean };
|
|
193
|
+
expect(typeof body.ready).toBe("boolean");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET|PUT /vault/<name>/.parachute/transcription` — the transcription setup
|
|
3
|
+
* surface for the admin SPA.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* Configuring local transcription meant reading source: which provider is
|
|
8
|
+
* resolved, whether a binary is anywhere findable, whether the model is on
|
|
9
|
+
* disk, and — the part nobody could see — whether the running worker actually
|
|
10
|
+
* picked any of it up. All of it lived in env vars and a boot log line the
|
|
11
|
+
* operator had usually scrolled past. A box could sit for weeks accepting audio
|
|
12
|
+
* and transcribing nothing with no way to tell from the UI (vault#643).
|
|
13
|
+
*
|
|
14
|
+
* So this endpoint answers, in one shot, the three questions an operator
|
|
15
|
+
* actually has:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Is transcription working right now?** (`ready` + `active`)
|
|
18
|
+
* 2. **If not, exactly what's missing?** (`reason`, `binary`, `model`)
|
|
19
|
+
* 3. **What do I run to fix it?** (`fix_command`)
|
|
20
|
+
*
|
|
21
|
+
* Deliberately reports paths and the directories searched, not just booleans.
|
|
22
|
+
* "Not installed" is unactionable when it could mean two different things with
|
|
23
|
+
* two different fixes — and on macOS the likeliest cause is that the binary IS
|
|
24
|
+
* installed but a launchd-supervised vault can't see it (no login-shell PATH),
|
|
25
|
+
* which a boolean can never express.
|
|
26
|
+
*
|
|
27
|
+
* ## What PUT does, and deliberately doesn't
|
|
28
|
+
*
|
|
29
|
+
* PUT writes the *preference* — provider + model — to the vault's `.env`. It
|
|
30
|
+
* does NOT download anything or run a package manager. Installing needs to
|
|
31
|
+
* fetch hundreds of megabytes and shell `brew`/`tar`, which is a CLI job with a
|
|
32
|
+
* progress bar, not a web request that a browser tab can abandon halfway. The
|
|
33
|
+
* UI's job is to make the state legible and hand over the exact command; the
|
|
34
|
+
* CLI's job is to do the work. `restart_required` is honest about the gap
|
|
35
|
+
* between a persisted preference and the running worker, exactly like the
|
|
36
|
+
* embeddings toggle it mirrors.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { existsSync } from "node:fs";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { readEnvFile, setEnvVar } from "./config.ts";
|
|
42
|
+
import { getTranscriptionWorker } from "./transcription-registry.ts";
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_MODEL_ID,
|
|
45
|
+
findModel,
|
|
46
|
+
TRANSCRIPTION_MODELS,
|
|
47
|
+
type TranscriptionModel,
|
|
48
|
+
} from "./transcription/models.ts";
|
|
49
|
+
import {
|
|
50
|
+
binaryNameFor,
|
|
51
|
+
candidateBinDirs,
|
|
52
|
+
managedModelDir,
|
|
53
|
+
resolveCliBinary,
|
|
54
|
+
resolveFfmpeg,
|
|
55
|
+
} from "./transcription/resolve-binary.ts";
|
|
56
|
+
import {
|
|
57
|
+
resolveTranscriptionModelId,
|
|
58
|
+
resolveTranscriptionProviderName,
|
|
59
|
+
TRANSCRIPTION_PROVIDERS,
|
|
60
|
+
} from "./transcription/select.ts";
|
|
61
|
+
|
|
62
|
+
/** One entry in the model picker. */
|
|
63
|
+
export interface TranscriptionModelOption {
|
|
64
|
+
id: string;
|
|
65
|
+
label: string;
|
|
66
|
+
engine: "parakeet" | "whisper";
|
|
67
|
+
size_mb: number;
|
|
68
|
+
min_ram_mb: number;
|
|
69
|
+
note: string;
|
|
70
|
+
/** Whether this specific model's file is already downloaded. */
|
|
71
|
+
installed: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TranscriptionSnapshot {
|
|
75
|
+
/** Resolved provider name (`whisper-cpp`, `scribe-http`, …). */
|
|
76
|
+
provider: string;
|
|
77
|
+
/** Every provider the build knows about, for the picker. */
|
|
78
|
+
available_providers: readonly string[];
|
|
79
|
+
/** Configured model id (only meaningful for `whisper-cpp`). */
|
|
80
|
+
model_id: string;
|
|
81
|
+
/** The resolved model, or `null` when `model_id` names nothing known. */
|
|
82
|
+
model: TranscriptionModelOption | null;
|
|
83
|
+
/** Catalog for the picker, smallest first. */
|
|
84
|
+
available_models: TranscriptionModelOption[];
|
|
85
|
+
/** The CLI this model needs, and whether we can find it. */
|
|
86
|
+
binary: {
|
|
87
|
+
name: string;
|
|
88
|
+
path: string | null;
|
|
89
|
+
/** Directories probed, in order — so a "not found" is debuggable. */
|
|
90
|
+
searched: string[];
|
|
91
|
+
};
|
|
92
|
+
/** ffmpeg, required for transcoding regardless of provider. */
|
|
93
|
+
ffmpeg: { path: string | null };
|
|
94
|
+
/**
|
|
95
|
+
* True when everything needed to transcribe is present. Distinct from
|
|
96
|
+
* `active`: flipping a preference changes `ready` immediately, but the
|
|
97
|
+
* running worker only picks it up on restart.
|
|
98
|
+
*/
|
|
99
|
+
ready: boolean;
|
|
100
|
+
/** Whether the running process has a transcription worker live. */
|
|
101
|
+
active: boolean;
|
|
102
|
+
/** `true` when `ready !== active` — restart to apply. */
|
|
103
|
+
restart_required: boolean;
|
|
104
|
+
/** Human-readable reason when `ready` is false; `null` when ready. */
|
|
105
|
+
reason: string | null;
|
|
106
|
+
/** The exact command that fixes a not-ready state; `null` when ready. */
|
|
107
|
+
fix_command: string | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toOption(
|
|
111
|
+
m: TranscriptionModel,
|
|
112
|
+
modelDir: string,
|
|
113
|
+
exists: (p: string) => boolean = existsSync,
|
|
114
|
+
): TranscriptionModelOption {
|
|
115
|
+
return {
|
|
116
|
+
id: m.id,
|
|
117
|
+
label: m.label,
|
|
118
|
+
engine: m.engine,
|
|
119
|
+
size_mb: m.sizeMb,
|
|
120
|
+
min_ram_mb: m.minRamMb,
|
|
121
|
+
note: m.note,
|
|
122
|
+
installed: exists(join(modelDir, m.filename)),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Injection seams. All optional; production passes nothing.
|
|
128
|
+
*
|
|
129
|
+
* These exist because binary resolution deliberately probes Homebrew's
|
|
130
|
+
* prefixes whether or not they're on PATH (the launchd case — see
|
|
131
|
+
* `resolve-binary.ts`). That's correct in production and makes the snapshot
|
|
132
|
+
* NON-HERMETIC in tests: a developer with `brew install whisper-cpp` would see
|
|
133
|
+
* "installed" no matter what the test set up, so a test asserting the
|
|
134
|
+
* not-installed path would pass for the wrong reason on one machine and fail on
|
|
135
|
+
* another. Injecting resolution is what makes those assertions mean something.
|
|
136
|
+
*/
|
|
137
|
+
export interface SnapshotDeps {
|
|
138
|
+
/** Whether a worker is live. Production reads the shared registry. */
|
|
139
|
+
active?: boolean;
|
|
140
|
+
resolveBinaryImpl?: (engine: "parakeet" | "whisper") => string | undefined;
|
|
141
|
+
resolveFfmpegImpl?: () => string | undefined;
|
|
142
|
+
existsImpl?: (p: string) => boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build the snapshot. `deps.active` is how the server tells us whether a worker
|
|
147
|
+
* is actually running — this module can't know that on its own, and guessing
|
|
148
|
+
* would reintroduce the exact "looks configured, transcribes nothing" gap the
|
|
149
|
+
* endpoint exists to close.
|
|
150
|
+
*
|
|
151
|
+
* Accepts a bare boolean for back-compat with the `handleTranscription*`
|
|
152
|
+
* callers, which only ever passed `active`.
|
|
153
|
+
*/
|
|
154
|
+
export function buildTranscriptionSnapshot(
|
|
155
|
+
depsOrActive?: SnapshotDeps | boolean,
|
|
156
|
+
): TranscriptionSnapshot {
|
|
157
|
+
const deps: SnapshotDeps =
|
|
158
|
+
typeof depsOrActive === "boolean" ? { active: depsOrActive } : (depsOrActive ?? {});
|
|
159
|
+
const activeOverride = deps.active;
|
|
160
|
+
const exists = deps.existsImpl ?? existsSync;
|
|
161
|
+
const resolveBin = deps.resolveBinaryImpl ?? resolveCliBinary;
|
|
162
|
+
const resolveFf = deps.resolveFfmpegImpl ?? resolveFfmpeg;
|
|
163
|
+
|
|
164
|
+
const provider = resolveTranscriptionProviderName();
|
|
165
|
+
const modelId = resolveTranscriptionModelId();
|
|
166
|
+
const model = findModel(modelId);
|
|
167
|
+
const modelDir = managedModelDir();
|
|
168
|
+
|
|
169
|
+
const engine = model?.engine ?? "parakeet";
|
|
170
|
+
const binPath = resolveBin(engine) ?? null;
|
|
171
|
+
const ffmpegPath = resolveFf() ?? null;
|
|
172
|
+
const modelInstalled = model ? exists(join(modelDir, model.filename)) : false;
|
|
173
|
+
|
|
174
|
+
// Readiness is provider-specific. `whisper-cpp` needs binary + model +
|
|
175
|
+
// ffmpeg; the remote provider needs a URL, which the worker resolves itself.
|
|
176
|
+
let ready: boolean;
|
|
177
|
+
let reason: string | null = null;
|
|
178
|
+
let fix: string | null = null;
|
|
179
|
+
|
|
180
|
+
if (provider === "whisper-cpp") {
|
|
181
|
+
const missing: string[] = [];
|
|
182
|
+
if (!model) missing.push(`the model id "${modelId}" isn't in the catalog`);
|
|
183
|
+
if (!binPath) missing.push(`the ${binaryNameFor(engine)} binary`);
|
|
184
|
+
if (model && !modelInstalled) missing.push(`the model file (${model.label}, ${model.sizeMb} MB)`);
|
|
185
|
+
if (!ffmpegPath) missing.push("ffmpeg (needed to transcode audio to 16 kHz mono WAV)");
|
|
186
|
+
ready = missing.length === 0;
|
|
187
|
+
if (!ready) {
|
|
188
|
+
reason = `Not ready — missing ${missing.join("; ")}.`;
|
|
189
|
+
fix = !ffmpegPath && binPath && modelInstalled
|
|
190
|
+
? "brew install ffmpeg # or: sudo apt install ffmpeg"
|
|
191
|
+
: "parachute-vault transcription install";
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
// Any other provider: we can't introspect it from here, so defer to
|
|
195
|
+
// whether the server actually started a worker. Saying "ready" about a
|
|
196
|
+
// provider we haven't checked is how the silent-no-op bug happened.
|
|
197
|
+
ready = activeOverride ?? getTranscriptionWorker() !== null;
|
|
198
|
+
if (!ready) {
|
|
199
|
+
reason =
|
|
200
|
+
`Provider "${provider}" has no reachable backend. Local transcription needs ` +
|
|
201
|
+
`TRANSCRIPTION_PROVIDER=whisper-cpp.`;
|
|
202
|
+
fix = "parachute-vault transcription install";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Read the SHARED registry the server populates at boot, mirroring how the
|
|
207
|
+
// embeddings snapshot reads its provider state. Defaulting to `false` here
|
|
208
|
+
// would report "not active" on a perfectly working box.
|
|
209
|
+
const active = activeOverride ?? getTranscriptionWorker() !== null;
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
provider,
|
|
213
|
+
available_providers: TRANSCRIPTION_PROVIDERS,
|
|
214
|
+
model_id: modelId,
|
|
215
|
+
model: model ? toOption(model, modelDir, exists) : null,
|
|
216
|
+
available_models: TRANSCRIPTION_MODELS.map((m) => toOption(m, modelDir, exists)),
|
|
217
|
+
binary: {
|
|
218
|
+
name: binaryNameFor(engine),
|
|
219
|
+
path: binPath,
|
|
220
|
+
// Only the first few — the full PATH is noise, and the ones that matter
|
|
221
|
+
// (our managed dir, then Homebrew) lead.
|
|
222
|
+
searched: candidateBinDirs().slice(0, 5),
|
|
223
|
+
},
|
|
224
|
+
ffmpeg: { path: ffmpegPath },
|
|
225
|
+
ready,
|
|
226
|
+
active,
|
|
227
|
+
restart_required: ready !== active,
|
|
228
|
+
reason,
|
|
229
|
+
fix_command: fix,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** `GET` — the snapshot. Admin-gated upstream in routing.ts. */
|
|
234
|
+
export function handleTranscriptionGet(activeOverride?: boolean): Response {
|
|
235
|
+
return Response.json(buildTranscriptionSnapshot(activeOverride), {
|
|
236
|
+
headers: { "Access-Control-Allow-Origin": "*", "cache-control": "no-store" },
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* `PUT` — persist a provider and/or model preference to the vault `.env`.
|
|
242
|
+
*
|
|
243
|
+
* Writes only; never downloads. See the module docstring for why installing
|
|
244
|
+
* stays a CLI job. Returns the fresh snapshot so the UI can render the new
|
|
245
|
+
* state — including `restart_required`, which will now be true.
|
|
246
|
+
*/
|
|
247
|
+
export async function handleTranscriptionPut(
|
|
248
|
+
req: Request,
|
|
249
|
+
activeOverride?: boolean,
|
|
250
|
+
): Promise<Response> {
|
|
251
|
+
let body: { provider?: unknown; model_id?: unknown };
|
|
252
|
+
try {
|
|
253
|
+
body = (await req.json()) as { provider?: unknown; model_id?: unknown };
|
|
254
|
+
} catch (err) {
|
|
255
|
+
return Response.json(
|
|
256
|
+
{
|
|
257
|
+
error: "Invalid JSON body",
|
|
258
|
+
error_type: "invalid_json",
|
|
259
|
+
message: (err as Error).message ?? String(err),
|
|
260
|
+
},
|
|
261
|
+
{ status: 400 },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (body.provider !== undefined) {
|
|
266
|
+
if (
|
|
267
|
+
typeof body.provider !== "string" ||
|
|
268
|
+
!(TRANSCRIPTION_PROVIDERS as readonly string[]).includes(body.provider)
|
|
269
|
+
) {
|
|
270
|
+
return Response.json(
|
|
271
|
+
{
|
|
272
|
+
error: "provider invalid",
|
|
273
|
+
error_type: "validation",
|
|
274
|
+
field: "provider",
|
|
275
|
+
message: `provider must be one of: ${TRANSCRIPTION_PROVIDERS.join(", ")}.`,
|
|
276
|
+
},
|
|
277
|
+
{ status: 400 },
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (body.model_id !== undefined) {
|
|
283
|
+
if (typeof body.model_id !== "string" || !findModel(body.model_id)) {
|
|
284
|
+
return Response.json(
|
|
285
|
+
{
|
|
286
|
+
error: "model_id invalid",
|
|
287
|
+
error_type: "validation",
|
|
288
|
+
field: "model_id",
|
|
289
|
+
message:
|
|
290
|
+
`model_id must be a known model. Valid ids: ` +
|
|
291
|
+
`${TRANSCRIPTION_MODELS.map((m) => m.id).join(", ")}.`,
|
|
292
|
+
},
|
|
293
|
+
{ status: 400 },
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (body.provider === undefined && body.model_id === undefined) {
|
|
299
|
+
return Response.json(
|
|
300
|
+
{
|
|
301
|
+
error: "nothing to set",
|
|
302
|
+
error_type: "validation",
|
|
303
|
+
message: "Provide `provider` and/or `model_id`.",
|
|
304
|
+
},
|
|
305
|
+
{ status: 400 },
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Touch the env file only for keys actually supplied, so setting a model
|
|
310
|
+
// doesn't silently pin a provider the operator didn't choose.
|
|
311
|
+
if (typeof body.provider === "string") setEnvVar("TRANSCRIPTION_PROVIDER", body.provider);
|
|
312
|
+
if (typeof body.model_id === "string") setEnvVar("TRANSCRIPTION_MODEL", body.model_id);
|
|
313
|
+
|
|
314
|
+
// `setEnvVar` writes the file; the running process's `process.env` is what
|
|
315
|
+
// `resolve*` reads, so mirror the write in-process or the snapshot we return
|
|
316
|
+
// would describe the OLD preference and look like the write failed.
|
|
317
|
+
const env = readEnvFile();
|
|
318
|
+
if (env.TRANSCRIPTION_PROVIDER) process.env.TRANSCRIPTION_PROVIDER = env.TRANSCRIPTION_PROVIDER;
|
|
319
|
+
if (env.TRANSCRIPTION_MODEL) process.env.TRANSCRIPTION_MODEL = env.TRANSCRIPTION_MODEL;
|
|
320
|
+
|
|
321
|
+
return Response.json(buildTranscriptionSnapshot(activeOverride), {
|
|
322
|
+
headers: { "Access-Control-Allow-Origin": "*", "cache-control": "no-store" },
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Re-exported for the SPA's typing convenience. */
|
|
327
|
+
export { DEFAULT_MODEL_ID };
|