Open History to see the last commit
1import { encodeSpeechPcmRequest } from "@t3tools/shared/speech";
2// @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalFetchInEffect:off - real HTTP streaming boundary test.
3import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { it as effectIt } from "@effect/vitest";
6import { expect, vi } from "vite-plus/test";
7import {
8 AuthSessionId,
9 AuthOrchestrationOperateScope,
10 AuthSettingsWriteScope,
11 type AuthEnvironmentScope,
12 CloudSpeechError,
13 DEFAULT_SPEECH_TRANSCRIPTION_OPTIONS,
14 SPEECH_MAX_OPTIONS_BYTES,
15 DEFAULT_SERVER_SETTINGS,
16 EnvironmentAuthenticatedAuth,
17 EnvironmentAuthenticatedPrincipal,
18 EnvironmentVoiceHttpApi,
19} from "@t3tools/contracts";
20import * as Effect from "effect/Effect";
21import * as Layer from "effect/Layer";
22import * as Redacted from "effect/Redacted";
23import * as Schema from "effect/Schema";
24import * as HttpRouter from "effect/http/HttpRouter";
25import * as HttpServer from "effect/http/HttpServer";
26import * as HttpApi from "effect/http-api/HttpApi";
27import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
28import type * as NetAddress from "effect/net/NetAddress";
29import * as SpeechService from "./SpeechService.ts";
30import * as CloudSpeech from "./CloudSpeech.ts";
31import * as ServerSettings from "../serverSettings.ts";
32import * as TextGeneration from "../textGeneration/TextGeneration.ts";
33import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
34import { speechHttpApiLayer } from "./http.ts";
35
36const decodeSessionId = Schema.decodeUnknownSync(AuthSessionId);
37
38const principal = (...scopes: AuthEnvironmentScope[]) =>
39 Layer.succeed(EnvironmentAuthenticatedAuth, (effect) =>
40 effect.pipe(
41 Effect.provideService(EnvironmentAuthenticatedPrincipal, {
42 sessionId: decodeSessionId("voice-test"),
43 subject: "test",
44 method: "bearer-access-token",
45 scopes: new Set(scopes),
46 }),
47 ),
48 );
49
50const dependencies = Layer.mergeAll(
51 Layer.mock(CloudSpeech.CloudSpeech)({}),
52 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
53 Layer.mock(ServerSettings.ServerSettingsService)({
54 getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS),
55 }),
56 Layer.succeed(
57 TextGeneration.TextGeneration,
58 TextGeneration.TextGeneration.of({
59 generateCommitMessage: () => Effect.die("not used"),
60 generatePrContent: () => Effect.die("not used"),
61 generateBranchName: () => Effect.die("not used"),
62 generateThreadTitle: () => Effect.die("not used"),
63 generateReviewGuide: () => Effect.die("not used"),
64 generateTranscriptionPostProcessing: () => Effect.die("not used"),
65 }),
66 ),
67);
68
69effectIt.live("rejects oversized chunked audio without invoking transcription", () =>
70 Effect.gen(function* () {
71 const transcribe = vi.fn(() => Effect.succeed("unexpected"));
72 const routes = HttpApiBuilder.layer(
73 HttpApi.make("environment").add(EnvironmentVoiceHttpApi),
74 ).pipe(
75 Layer.provide(speechHttpApiLayer),
76 Layer.provide(principal(AuthOrchestrationOperateScope)),
77 Layer.provide(
78 Layer.succeed(SpeechService.SpeechService, {
79 status: Effect.succeed({ supported: false as const, reason: "test" }),
80 prepareModel: Effect.void,
81 models: () => Effect.succeed({ models: [] }),
82 downloadModel: () => Effect.succeed({ models: [] }),
83 linkModels: () => Effect.succeed({ models: [] }),
84 selectModel: () => Effect.succeed({ models: [] }),
85 cancelDownload: () => Effect.succeed({ models: [] }),
86 updateAcceleration: () => Effect.succeed({ supported: false as const, reason: "test" }),
87 updateModelUnloadTimeout: () =>
88 Effect.succeed({ supported: false as const, reason: "test" }),
89 transcribe,
90 startStream: () => Effect.die("not used"),
91 removeModel: () => Effect.succeed({ models: [] }),
92 }),
93 ),
94 );
95 yield* Effect.gen(function* () {
96 yield* Layer.build(HttpRouter.serve(routes, { disableLogger: true, disableListenLog: true }));
97 const server = yield* HttpServer.HttpServer;
98 const address = server.address as NetAddress.InetAddress;
99 const host = address._tag === "InetAddressV6" ? "[::1]" : "127.0.0.1";
100 const url = `http://${host}:${address.port}/api/voice/transcribe`;
101 const select = yield* Effect.promise(() =>
102 fetch(new URL("models/select", url), {
103 method: "POST",
104 headers: { "content-type": "application/json" },
105 body: JSON.stringify({ modelId: "test" }),
106 }),
107 );
108 expect(select.status).toBe(403);
109 const changeKey = yield* Effect.promise(() =>
110 fetch(new URL("cloud/xai/key", url), {
111 method: "POST",
112 headers: { "content-type": "application/json" },
113 body: JSON.stringify({ key: "fixture" }),
114 }),
115 );
116 expect(changeKey.status).toBe(403);
117 yield* Effect.promise(() =>
118 expect(
119 fetch(url, {
120 method: "POST",
121 headers: { "content-type": "application/octet-stream" },
122 body: new ReadableStream({
123 start(controller) {
124 controller.enqueue(
125 new Uint8Array(SpeechService.MAX_SPEECH_BYTES + SPEECH_MAX_OPTIONS_BYTES + 4),
126 );
127 controller.enqueue(new Uint8Array(4));
128 controller.close();
129 },
130 }),
131 duplex: "half",
132 }),
133 ).resolves.toMatchObject({ status: 413 }),
134 );
135 expect(transcribe).not.toHaveBeenCalled();
136 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, dependencies)));
137 }),
138);
139
140effectIt.effect(
141 "keeps keys write-only and returns provider failures without an auth challenge",
142 () =>
143 Effect.gen(function* () {
144 let stored = false;
145 const reference = { provider: "work", reference: "op://Work/Voice/key" };
146 const save = vi.fn();
147 const transcribe = vi.fn();
148 const cloud = Layer.mock(CloudSpeech.CloudSpeech)({
149 configured: Effect.sync(() => ({ openai: stored, xai: false })),
150 models: Effect.succeed([{ id: "acme/transcribe-1", name: "Acme Transcribe" }]),
151 setKey: (provider, key) =>
152 Effect.sync(() => {
153 save(provider, key);
154 stored =
155 provider === "openai" &&
156 Redacted.isRedacted(key) &&
157 Redacted.value(key) === "fixture";
158 }),
159 transcribe: (provider, _pcm, _options, model) => {
160 transcribe(provider, model);
161 return Effect.fail(new CloudSpeechError({ provider, reason: "rejected-key" }));
162 },
163 });
164 const { handler, dispose } = HttpRouter.toWebHandler(
165 HttpApiBuilder.layer(HttpApi.make("environment").add(EnvironmentVoiceHttpApi)).pipe(
166 Layer.provide(speechHttpApiLayer),
167 Layer.provide(principal(AuthOrchestrationOperateScope, AuthSettingsWriteScope)),
168 Layer.provide(
169 Layer.mock(SpeechService.SpeechService)({
170 models: () =>
171 Effect.succeed({
172 models: [
173 {
174 id: "fixture/model",
175 name: "Fixture",
176 description: "",
177 size: 1,
178 languages: ["en"],
179 accuracy: 0,
180 speed: 0,
181 recommended: false,
182 supportsStreaming: false,
183 supportsLanguageDetection: false,
184 supportsTranslation: false,
185 active: true,
186 state: "installed",
187 linkedFrom: "/home/someone/models/fixture.gguf",
188 },
189 ],
190 }),
191 }),
192 ),
193 Layer.provide(cloud),
194 Layer.provide(dependencies),
195 Layer.provide(HttpServer.layerServices),
196 Layer.provide(NodeServices.layer),
197 ),
198 { disableLogger: true },
199 );
200 yield* Effect.addFinalizer(() => Effect.promise(dispose));
201 const context = yield* Layer.build(NodeServices.layer);
202 const call = (path: string, init?: RequestInit) =>
203 Effect.promise(() =>
204 handler(new Request(`http://localhost/api/voice/cloud/${path}`, init), context),
205 );
206 const saved = yield* call("openai/key", {
207 method: "POST",
208 headers: { "content-type": "application/json" },
209 body: JSON.stringify({ key: "fixture" }),
210 });
211 expect(saved.status).toBe(204);
212 expect(yield* Effect.promise(() => saved.text())).toBe("");
213 const keys = yield* call("keys");
214 expect(yield* Effect.promise(() => keys.json())).toEqual({
215 openai: true,
216 xai: false,
217 });
218 const setReference = (key: unknown) =>
219 call("xai/key", {
220 method: "POST",
221 headers: { "content-type": "application/json" },
222 body: JSON.stringify({ key }),
223 });
224 expect((yield* setReference(reference)).status).toBe(204);
225 expect(save).toHaveBeenLastCalledWith("xai", reference);
226 expect((yield* setReference(reference.reference)).status).toBe(400);
227 expect(save).toHaveBeenCalledTimes(2);
228 const failed = yield* call("openai/transcribe", {
229 method: "POST",
230 headers: { "content-type": "application/octet-stream" },
231 body: encodeSpeechPcmRequest(new Uint8Array(4), DEFAULT_SPEECH_TRANSCRIPTION_OPTIONS),
232 });
233 expect(failed.status).toBe(422);
234 expect(yield* Effect.promise(() => failed.json())).toEqual({
235 _tag: "CloudSpeechError",
236 provider: "openai",
237 reason: "rejected-key",
238 });
239 expect(transcribe).toHaveBeenLastCalledWith("openai", undefined);
240 const catalog = yield* call("openrouter/models");
241 expect(yield* Effect.promise(() => catalog.json())).toEqual({
242 models: [{ id: "acme/transcribe-1", name: "Acme Transcribe" }],
243 });
244 const audio = {
245 method: "POST",
246 headers: { "content-type": "application/octet-stream" },
247 body: encodeSpeechPcmRequest(new Uint8Array(4), DEFAULT_SPEECH_TRANSCRIPTION_OPTIONS),
248 };
249 expect((yield* call("openrouter/transcribe?model=openrouter%2Fauto", audio)).status).toBe(
250 400,
251 );
252 expect((yield* call("openrouter/transcribe?model=acme%2Ftranscribe-1", audio)).status).toBe(
253 422,
254 );
255 expect(transcribe).toHaveBeenLastCalledWith("openrouter", "acme/transcribe-1");
256 // Naming a path on the environment needs filesystem access, not only settings access.
257 const link = yield* Effect.promise(() =>
258 handler(
259 new Request("http://localhost/api/voice/models/link", {
260 method: "POST",
261 headers: { "content-type": "application/json" },
262 body: JSON.stringify({ path: "/models" }),
263 }),
264 context,
265 ),
266 );
267 expect(link.status).toBe(403);
268 const listed = yield* Effect.promise(() =>
269 handler(new Request("http://localhost/api/voice/models"), context),
270 );
271 expect(yield* Effect.promise(() => listed.text())).not.toContain("/home/someone");
272 }).pipe(Effect.scoped),
273);
274
275effectIt.effect.each([
276 {
277 error: new SpeechService.SpeechInvalidAudioError({ byteLength: 3, message: "invalid PCM" }),
278 reason: "invalid_audio",
279 },
280 {
281 error: new SpeechService.SpeechUnsupportedPlatformError({
282 platform: "win32",
283 architecture: "arm64",
284 }),
285 reason: "speech_unavailable",
286 },
287 {
288 malformedOptions: true,
289 error: new SpeechService.SpeechInvalidAudioError({ byteLength: 3, message: "invalid PCM" }),
290 reason: "invalid_audio",
291 },
292 {
293 oversized: true,
294 error: new SpeechService.SpeechInvalidAudioError({ byteLength: 3, message: "invalid PCM" }),
295 reason: "invalid_audio",
296 },
297 {
298 error: new SpeechService.SpeechBusyError({ operation: "transcription" }),
299 reason: "speech_busy",
300 },
301])(
302 "returns a typed client error for $reason instead of HTTP 500",
303 ({ error, reason, ...options }) =>
304 Effect.gen(function* () {
305 // Only authentication and the native service are replaced; exercise the actual HTTP handlers and codecs.
306 const transcribe = vi.fn(() => Effect.fail(error));
307 const service = Layer.succeed(SpeechService.SpeechService, {
308 status: Effect.succeed({ supported: false as const, reason: "test" }),
309 prepareModel: Effect.void,
310 models: () => Effect.succeed({ models: [] }),
311 downloadModel: () => Effect.fail(error),
312 linkModels: () => Effect.fail(error),
313 selectModel: () => Effect.fail(error),
314 cancelDownload: () => Effect.fail(error),
315 updateAcceleration: () => Effect.fail(error),
316 updateModelUnloadTimeout: () => Effect.fail(error),
317 transcribe,
318 startStream: () => Effect.fail(error),
319 removeModel: () => Effect.fail(error),
320 });
321 const api = HttpApi.make("environment").add(EnvironmentVoiceHttpApi);
322 const { handler, dispose } = HttpRouter.toWebHandler(
323 HttpApiBuilder.layer(api).pipe(
324 Layer.provide(speechHttpApiLayer),
325 Layer.provide(principal(AuthOrchestrationOperateScope)),
326 Layer.provide(service),
327 Layer.provide(HttpServer.layerServices),
328 Layer.provide(dependencies),
329 Layer.provide(NodeServices.layer),
330 ),
331 { disableLogger: true },
332 );
333 yield* Effect.addFinalizer(() => Effect.promise(dispose));
334 const request = new Request("http://localhost/api/voice/transcribe", {
335 method: "POST",
336 headers: { "content-type": "application/octet-stream" },
337 body:
338 "malformedOptions" in options
339 ? new Uint8Array([255, 255, 255, 255])
340 : encodeSpeechPcmRequest(new Uint8Array(3), DEFAULT_SPEECH_TRANSCRIPTION_OPTIONS),
341 });
342 const decode = vi.spyOn(request, "arrayBuffer");
343 if ("oversized" in options)
344 request.headers.set(
345 "content-length",
346 String(SpeechService.MAX_SPEECH_BYTES + SPEECH_MAX_OPTIONS_BYTES + 5),
347 );
348 const context = yield* Layer.build(Layer.mergeAll(dependencies, NodeServices.layer));
349 const response = yield* Effect.promise(() => handler(request, context));
350 if ("oversized" in options) expect(decode).not.toHaveBeenCalled();
351 if ("malformedOptions" in options || "oversized" in options) {
352 expect(transcribe).not.toHaveBeenCalled();
353 } else {
354 expect(transcribe).toHaveBeenCalledWith(
355 new Uint8Array(3),
356 DEFAULT_SPEECH_TRANSCRIPTION_OPTIONS,
357 );
358 }
359 expect(response.status).toBe(400);
360 expect(yield* Effect.promise(() => response.json())).toMatchObject({
361 code: "invalid_request",
362 reason,
363 });
364 }).pipe(Effect.scoped),
365);