Skip to content
Source
Sign inSign up

http.ts

233 lines9.0 KB

1import { decodeSpeechPcmRequest } from "@t3tools/shared/speech";
2import {
3 AuthFilesystemReadScope,
4 AuthOrchestrationOperateScope,
5 AuthSettingsWriteScope,
6 type AuthEnvironmentScope,
7 EnvironmentAuthenticatedPrincipal,
8 EnvironmentHttpApi,
9 type EnvironmentSpeechModels,
10 EnvironmentVoiceBodyLimit,
11 SPEECH_MAX_OPTIONS_BYTES,
12} from "@t3tools/contracts";
13import * as ByteSize from "effect/ByteSize";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as HttpServerRequest from "effect/http/HttpServerRequest";
18import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
19​
20import {
21 annotateEnvironmentRequest,
22 failEnvironmentInternal,
23 failEnvironmentInvalidRequest,
24 requireEnvironmentScope,
25} from "../auth/http.ts";
26import * as SpeechPostProcessing from "./postProcessing.ts";
27import * as CloudSpeech from "./CloudSpeech.ts";
28import * as SpeechService from "./SpeechService.ts";
29​
30const MAX_TRANSCRIBE_BODY_BYTES = SpeechService.MAX_SPEECH_BYTES + SPEECH_MAX_OPTIONS_BYTES + 4;
31const parseAudio = Option.liftThrowable(decodeSpeechPcmRequest);
32​
33const bodyLimit = Layer.succeed(EnvironmentVoiceBodyLimit, (effect) =>
34 Effect.gen(function* () {
35 const request = yield* HttpServerRequest.HttpServerRequest;
36 if (Number(request.headers["content-length"]) > MAX_TRANSCRIBE_BODY_BYTES)
37 return yield* failEnvironmentInvalidRequest("invalid_audio");
38 return yield* effect.pipe(
39 Effect.provideService(
40 HttpServerRequest.MaxBodySize,
41 ByteSize.bytes(MAX_TRANSCRIBE_BODY_BYTES),
42 ),
43 );
44 }),
45);
46​
47const mapSpeechError = <A, R>(effect: Effect.Effect<A, SpeechService.SpeechError, R>) =>
48 effect.pipe(
49 Effect.catchTags({
50 SpeechInvalidAudioError: () => failEnvironmentInvalidRequest("invalid_audio"),
51 SpeechUnsupportedPlatformError: () => failEnvironmentInvalidRequest("speech_unavailable"),
52 SpeechBusyError: () => failEnvironmentInvalidRequest("speech_busy"),
53 SpeechDownloadCancelledError: () => failEnvironmentInvalidRequest("speech_busy"),
54 SpeechModelNotFoundError: () => failEnvironmentInvalidRequest("invalid_command"),
55 SpeechOperationError: (error) => failEnvironmentInternal("internal_error", error),
56 }),
57 );
58​
59// A linked model's origin is a path on this machine; only filesystem readers may see it.
60// Untraced: it runs inside each handler's own span.
61const withoutHostPaths = Effect.fnUntraced(function* (result: EnvironmentSpeechModels) {
62 const session = yield* EnvironmentAuthenticatedPrincipal;
63 if (session.scopes.has(AuthFilesystemReadScope)) return result;
64 return { models: result.models.map(({ linkedFrom: _, ...model }) => model) };
65});
66​
67export const speechHttpApiLayer = HttpApiBuilder.group(
68 EnvironmentHttpApi,
69 "voice",
70 Effect.fnUntraced(function* (handlers) {
71 const speech = yield* SpeechService.SpeechService;
72 const cloud = yield* CloudSpeech.CloudSpeech;
73 const postProcessing = yield* SpeechPostProcessing.SpeechPostProcessing;
74 const guard = Effect.fnUntraced(function* (
75 endpoint: string,
76 scope: AuthEnvironmentScope = AuthOrchestrationOperateScope,
77 ) {
78 yield* annotateEnvironmentRequest(endpoint);
79 yield* requireEnvironmentScope(scope);
80 });
81 return handlers
82 .handle(
83 "cloudKeys",
84 Effect.fn("environment.voice.cloudKeys")(function* (args) {
85 yield* guard(args.endpoint.name);
86 return yield* cloud.configured.pipe(
87 Effect.catch((error) => failEnvironmentInternal("internal_error", error)),
88 );
89 }),
90 )
91 .handle(
92 "setCloudKey",
93 Effect.fn("environment.voice.setCloudKey")(function* (args) {
94 yield* guard(args.endpoint.name, AuthSettingsWriteScope);
95 yield* cloud
96 .setKey(args.params.provider, args.payload.key)
97 .pipe(Effect.catch((error) => failEnvironmentInternal("internal_error", error)));
98 }),
99 )
100 .handle(
101 "cloudModels",
102 Effect.fn("environment.voice.cloudModels")(function* (args) {
103 yield* guard(args.endpoint.name);
104 return { models: yield* cloud.models };
105 }),
106 )
107 .handle(
108 "transcribeCloud",
109 Effect.fn("environment.voice.transcribeCloud")(function* (args) {
110 yield* guard(args.endpoint.name);
111 const decoded = parseAudio(args.payload);
112 if (Option.isNone(decoded)) return yield* failEnvironmentInvalidRequest("invalid_audio");
113 const text = yield* cloud
114 .transcribe(
115 args.params.provider,
116 decoded.value.pcm,
117 decoded.value.options,
118 args.query.model,
119 )
120 .pipe(
121 Effect.catchTags({
122 ServerSettingsError: (error) => failEnvironmentInternal("internal_error", error),
123 SpeechInvalidAudioError: () => failEnvironmentInvalidRequest("invalid_audio"),
124 }),
125 );
126 return { text };
127 }),
128 )
129 .handle(
130 "status",
131 Effect.fn("environment.voice.status")(function* (args) {
132 yield* guard(args.endpoint.name);
133 return yield* speech.status.pipe(mapSpeechError);
134 }),
135 )
136 .handle(
137 "prepareModel",
138 Effect.fn("environment.voice.prepareModel")(function* (args) {
139 yield* guard(args.endpoint.name);
140 yield* speech.prepareModel.pipe(mapSpeechError);
141 }),
142 )
143 .handle(
144 "models",
145 Effect.fn("environment.voice.models")(function* (args) {
146 yield* guard(args.endpoint.name);
147 return yield* speech
148 .models(args.query.benchmarks === "1")
149 .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths));
150 }),
151 )
152 .handle(
153 "downloadModel",
154 Effect.fn("environment.voice.downloadModel")(function* (args) {
155 yield* guard(args.endpoint.name, AuthSettingsWriteScope);
156 return yield* speech
157 .downloadModel(args.payload.modelId)
158 .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths));
159 }),
160 )
161 .handle(
162 "selectModel",
163 Effect.fn("environment.voice.selectModel")(function* (args) {
164 yield* guard(args.endpoint.name, AuthSettingsWriteScope);
165 return yield* speech
166 .selectModel(args.payload.modelId)
167 .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths));
168 }),
169 )
170 .handle(
171 "cancelModelDownload",
172 Effect.fn("environment.voice.cancelModelDownload")(function* (args) {
173 yield* guard(args.endpoint.name, AuthSettingsWriteScope);
174 return yield* speech
175 .cancelDownload(args.payload.modelId)
176 .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths));
177 }),
178 )
179 .handle(
180 "removeModel",
181 Effect.fn("environment.voice.removeModel")(function* (args) {
182 yield* guard(args.endpoint.name, AuthSettingsWriteScope);
183 return yield* speech
184 .removeModel(args.payload.modelId)
185 .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths));
186 }),
187 )
188 .handle(
189 "linkModels",
190 Effect.fn("environment.voice.linkModels")(function* (args) {
191 yield* guard(args.endpoint.name, AuthSettingsWriteScope);
192 // The request names a path on this machine and the result reveals what is there.
193 yield* requireEnvironmentScope(AuthFilesystemReadScope);
194 return yield* speech.linkModels(args.payload.path).pipe(mapSpeechError);
195 }),
196 )
197 .handle(
198 "updateAcceleration",
199 Effect.fn("environment.voice.updateAcceleration")(function* (args) {
200 yield* guard(args.endpoint.name, AuthSettingsWriteScope);
201 return yield* speech.updateAcceleration(args.payload.acceleration).pipe(mapSpeechError);
202 }),
203 )
204 .handle(
205 "updateModelUnloadTimeout",
206 Effect.fn("environment.voice.updateModelUnloadTimeout")(function* (args) {
207 yield* guard(args.endpoint.name, AuthSettingsWriteScope);
208 return yield* speech.updateModelUnloadTimeout(args.payload.timeout).pipe(mapSpeechError);
209 }),
210 )
211 .handle(
212 "transcribe",
213 Effect.fn("environment.voice.transcribe")(function* (args) {
214 yield* guard(args.endpoint.name);
215 const decoded = parseAudio(args.payload);
216 if (Option.isNone(decoded)) return yield* failEnvironmentInvalidRequest("invalid_audio");
217 const text = yield* speech
218 .transcribe(decoded.value.pcm, decoded.value.options)
219 .pipe(mapSpeechError);
220 return { text };
221 }),
222 )
223 .handle(
224 "postProcess",
225 Effect.fn("environment.voice.postProcess")(function* (args) {
226 yield* guard(args.endpoint.name);
227 return yield* postProcessing
228 .process(args.payload)
229 .pipe(Effect.catch((error) => failEnvironmentInternal("internal_error", error)));
230 }),
231 );
232 }),
233).pipe(Layer.provide(bodyLimit), Layer.provide(SpeechPostProcessing.layer));