@opengeni/api-router 0.21.14 → 0.22.2
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/dist/app.d.ts +1 -0
- package/dist/app.js +1 -1
- package/dist/{chunk-R5PDSH2A.js → chunk-HWXJW5C7.js} +1034 -75
- package/dist/chunk-HWXJW5C7.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/routes/transcription-recordings.d.ts +3 -0
- package/dist/transcription/segmenter.d.ts +10 -0
- package/dist/transcription/service.d.ts +5 -0
- package/package.json +11 -11
- package/src/app.ts +36 -2
- package/src/mcp/server.ts +1 -1
- package/src/routes/transcription-recordings.ts +722 -0
- package/src/routes/transcriptions.ts +2 -0
- package/src/transcription/providers/azure-openai.ts +4 -3
- package/src/transcription/providers/codex-subscription.ts +4 -1
- package/src/transcription/providers/openai.ts +7 -2
- package/src/transcription/segmenter.ts +260 -0
- package/src/transcription/service.ts +111 -10
- package/dist/chunk-R5PDSH2A.js.map +0 -1
|
@@ -13,14 +13,15 @@ import {
|
|
|
13
13
|
OPENGENI_API_CONTRACT_REVISION,
|
|
14
14
|
OPENGENI_CORRELATION_HEADER,
|
|
15
15
|
resolveWorkspaceMemoryEnabled,
|
|
16
|
-
VOICE_INPUT_ACCEPTED_MIME_TYPES as VOICE_INPUT_ACCEPTED_MIME_TYPES2
|
|
16
|
+
VOICE_INPUT_ACCEPTED_MIME_TYPES as VOICE_INPUT_ACCEPTED_MIME_TYPES2,
|
|
17
|
+
TRANSCRIPTION_RECORDING_PROVIDER_SEGMENT_SECONDS as TRANSCRIPTION_RECORDING_PROVIDER_SEGMENT_SECONDS2
|
|
17
18
|
} from "@opengeni/contracts";
|
|
18
19
|
import {
|
|
19
20
|
createDocumentServices,
|
|
20
21
|
getDocument as getDocument2,
|
|
21
22
|
indexDocumentNow
|
|
22
23
|
} from "@opengeni/documents";
|
|
23
|
-
import { dbSql, getWorkspace as
|
|
24
|
+
import { dbSql, getWorkspace as getWorkspace6, rlsContextForWorkspace } from "@opengeni/db";
|
|
24
25
|
import { createObservability } from "@opengeni/observability";
|
|
25
26
|
import { createObjectStorage } from "@opengeni/storage";
|
|
26
27
|
import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport3 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
@@ -49,7 +50,7 @@ var ApiHttpError = class extends HTTPException {
|
|
|
49
50
|
import {
|
|
50
51
|
CodexCompactionV2ProviderLockedError,
|
|
51
52
|
hasPermission as hasPermission15,
|
|
52
|
-
requireAccessGrant as
|
|
53
|
+
requireAccessGrant as requireAccessGrant25,
|
|
53
54
|
requirePermission,
|
|
54
55
|
requireSessionAuthorization as requireSessionAuthorization3,
|
|
55
56
|
SessionAuthorizationDeniedError as SessionAuthorizationDeniedError2,
|
|
@@ -7625,7 +7626,7 @@ function registerFleetTools(server, deps, grant, sessionId, json) {
|
|
|
7625
7626
|
server.registerTool(
|
|
7626
7627
|
"sandboxes_list",
|
|
7627
7628
|
{
|
|
7628
|
-
description: "List the sandboxes this session can run on: its own session sandbox plus enrolled selfhosted machines. `liveness` is conservative: online requires observed provider existence and verified workspace readiness. Provider, lease, route, archive, restore, workspace, lease epoch, and route epoch are also reported separately. Use an entry `id` as an attach/swap/run_on target.",
|
|
7629
|
+
description: "List the sandboxes this session can run on: its own session sandbox plus enrolled selfhosted machines. `liveness` is conservative: online requires observed provider existence and verified workspace readiness. An idle session-home sandbox may report offline/cold/draining and still wake or restore on the next ordinary sandbox operation; never infer that shell/files are unavailable from list liveness alone\u2014only a typed operation/attach failure proves that. Provider, lease, route, archive, restore, workspace, lease epoch, and route epoch are also reported separately. Use an entry `id` as an attach/swap/run_on target.",
|
|
7629
7630
|
inputSchema: {}
|
|
7630
7631
|
},
|
|
7631
7632
|
async () => json(await listFleet(services, await fleetContext()))
|
|
@@ -23946,17 +23947,871 @@ function registerInsightsRoutes(app, deps) {
|
|
|
23946
23947
|
|
|
23947
23948
|
// src/routes/transcriptions.ts
|
|
23948
23949
|
import {
|
|
23949
|
-
resolveWorkspaceVoiceInputEnabled
|
|
23950
|
+
resolveWorkspaceVoiceInputEnabled as resolveWorkspaceVoiceInputEnabled2
|
|
23950
23951
|
} from "@opengeni/contracts";
|
|
23951
|
-
import { requireAccessGrant as
|
|
23952
|
+
import { requireAccessGrant as requireAccessGrant23, TranscriptionServiceError as TranscriptionServiceError2 } from "@opengeni/core";
|
|
23953
|
+
import { getWorkspace as getWorkspace4 } from "@opengeni/db";
|
|
23954
|
+
|
|
23955
|
+
// src/routes/transcription-recordings.ts
|
|
23956
|
+
import { createHash as createHash11 } from "crypto";
|
|
23957
|
+
import {
|
|
23958
|
+
CreateTranscriptionRecordingRequest,
|
|
23959
|
+
FinalizeTranscriptionRecordingRequest,
|
|
23960
|
+
resolveWorkspaceVoiceInputEnabled,
|
|
23961
|
+
TRANSCRIPTION_RECORDING_PROVIDER_SEGMENT_SECONDS,
|
|
23962
|
+
TRANSCRIPTION_RECORDING_RECOVERY_RETRY_AFTER_MILLISECONDS
|
|
23963
|
+
} from "@opengeni/contracts";
|
|
23964
|
+
import {
|
|
23965
|
+
isAcceptedMimeType,
|
|
23966
|
+
normalizeMimeType,
|
|
23967
|
+
requireAccessGrant as requireAccessGrant22,
|
|
23968
|
+
TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS,
|
|
23969
|
+
TranscriptionServiceError
|
|
23970
|
+
} from "@opengeni/core";
|
|
23971
|
+
import {
|
|
23972
|
+
claimNextTranscriptionRecordingSegment,
|
|
23973
|
+
claimTranscriptionRecordingAssembly,
|
|
23974
|
+
completeTranscriptionRecordingAssembly,
|
|
23975
|
+
completeTranscriptionRecordingChunk,
|
|
23976
|
+
completeTranscriptionRecordingSegment,
|
|
23977
|
+
completeTranscriptionRecordingSegmentPreparation,
|
|
23978
|
+
createTranscriptionRecording,
|
|
23979
|
+
discardTranscriptionRecording,
|
|
23980
|
+
failTranscriptionRecordingAssembly,
|
|
23981
|
+
failTranscriptionRecordingSegment,
|
|
23982
|
+
getTranscriptionRecording,
|
|
23983
|
+
listTranscriptionRecordings,
|
|
23984
|
+
listTranscriptionRecordingChunks,
|
|
23985
|
+
markTranscriptionRecordingObjectCleaned,
|
|
23986
|
+
markTranscriptionRecordingObjectsCleaned,
|
|
23987
|
+
reserveTranscriptionRecordingChunk,
|
|
23988
|
+
reserveTranscriptionRecordingSegment,
|
|
23989
|
+
startTranscriptionRecordingSegmentProviderCall,
|
|
23990
|
+
transcriptionRecordingObjectKeys,
|
|
23991
|
+
TranscriptionRecordingConflictError,
|
|
23992
|
+
TranscriptionRecordingNotFoundError,
|
|
23993
|
+
TranscriptionRecordingStateError
|
|
23994
|
+
} from "@opengeni/db";
|
|
23952
23995
|
import { getWorkspace as getWorkspace3 } from "@opengeni/db";
|
|
23996
|
+
|
|
23997
|
+
// src/transcription/segmenter.ts
|
|
23998
|
+
import { spawn } from "child_process";
|
|
23999
|
+
import { createWriteStream } from "fs";
|
|
24000
|
+
import { mkdtemp, readFile as readFile2, readdir, rm } from "fs/promises";
|
|
24001
|
+
import { tmpdir } from "os";
|
|
24002
|
+
import { join } from "path";
|
|
24003
|
+
import { once } from "events";
|
|
24004
|
+
var STDERR_MAX_BYTES = 64 * 1024;
|
|
24005
|
+
var TranscriptionSegmenterError = class extends Error {
|
|
24006
|
+
constructor(message, code, retryable) {
|
|
24007
|
+
super(message);
|
|
24008
|
+
this.code = code;
|
|
24009
|
+
this.retryable = retryable;
|
|
24010
|
+
}
|
|
24011
|
+
name = "TranscriptionSegmenterError";
|
|
24012
|
+
};
|
|
24013
|
+
function createFfmpegTranscriptionSegmenter(input) {
|
|
24014
|
+
let availability = null;
|
|
24015
|
+
return {
|
|
24016
|
+
available() {
|
|
24017
|
+
availability ??= commandSucceeds(input.ffmpegPath, ["-version"]);
|
|
24018
|
+
return availability;
|
|
24019
|
+
},
|
|
24020
|
+
async *segment(request) {
|
|
24021
|
+
if (request.signal?.aborted) {
|
|
24022
|
+
throw new TranscriptionSegmenterError(
|
|
24023
|
+
"Audio segmentation was cancelled",
|
|
24024
|
+
"cancelled",
|
|
24025
|
+
true
|
|
24026
|
+
);
|
|
24027
|
+
}
|
|
24028
|
+
if (!Number.isSafeInteger(request.providerSegmentSeconds) || request.providerSegmentSeconds <= 0 || !Number.isSafeInteger(request.totalDurationMilliseconds) || request.totalDurationMilliseconds <= 0) {
|
|
24029
|
+
throw new TranscriptionSegmenterError(
|
|
24030
|
+
"Audio segmentation bounds are invalid",
|
|
24031
|
+
"invalid_audio",
|
|
24032
|
+
false
|
|
24033
|
+
);
|
|
24034
|
+
}
|
|
24035
|
+
if (Math.ceil(request.totalDurationMilliseconds / (request.providerSegmentSeconds * 1e3)) > 1e3) {
|
|
24036
|
+
throw new TranscriptionSegmenterError(
|
|
24037
|
+
"Audio exceeds the bounded segment projection",
|
|
24038
|
+
"too_large",
|
|
24039
|
+
false
|
|
24040
|
+
);
|
|
24041
|
+
}
|
|
24042
|
+
const directory = await mkdtemp(join(tmpdir(), "opengeni-transcription-"));
|
|
24043
|
+
const inputPath = join(
|
|
24044
|
+
directory,
|
|
24045
|
+
`recording.${extensionForMimeType(request.sourceMimeType)}`
|
|
24046
|
+
);
|
|
24047
|
+
const outputPattern = join(directory, "segment-%06d.wav");
|
|
24048
|
+
try {
|
|
24049
|
+
await writeChunks(inputPath, request.chunks, request.signal);
|
|
24050
|
+
const result = await runCommand(
|
|
24051
|
+
input.ffmpegPath,
|
|
24052
|
+
[
|
|
24053
|
+
"-nostdin",
|
|
24054
|
+
"-hide_banner",
|
|
24055
|
+
"-loglevel",
|
|
24056
|
+
"error",
|
|
24057
|
+
"-y",
|
|
24058
|
+
"-i",
|
|
24059
|
+
inputPath,
|
|
24060
|
+
"-map",
|
|
24061
|
+
"0:a:0",
|
|
24062
|
+
"-vn",
|
|
24063
|
+
"-ac",
|
|
24064
|
+
"1",
|
|
24065
|
+
"-ar",
|
|
24066
|
+
"16000",
|
|
24067
|
+
"-c:a",
|
|
24068
|
+
"pcm_s16le",
|
|
24069
|
+
"-f",
|
|
24070
|
+
"segment",
|
|
24071
|
+
"-segment_time",
|
|
24072
|
+
String(request.providerSegmentSeconds),
|
|
24073
|
+
"-reset_timestamps",
|
|
24074
|
+
"1",
|
|
24075
|
+
outputPattern
|
|
24076
|
+
],
|
|
24077
|
+
request.signal
|
|
24078
|
+
);
|
|
24079
|
+
if (result.cancelled) {
|
|
24080
|
+
throw new TranscriptionSegmenterError(
|
|
24081
|
+
"Audio segmentation was cancelled",
|
|
24082
|
+
"cancelled",
|
|
24083
|
+
true
|
|
24084
|
+
);
|
|
24085
|
+
}
|
|
24086
|
+
if (result.spawnError) {
|
|
24087
|
+
throw new TranscriptionSegmenterError(
|
|
24088
|
+
"Audio segmentation is unavailable",
|
|
24089
|
+
"unavailable",
|
|
24090
|
+
true
|
|
24091
|
+
);
|
|
24092
|
+
}
|
|
24093
|
+
if (result.exitCode !== 0) {
|
|
24094
|
+
throw new TranscriptionSegmenterError(
|
|
24095
|
+
result.stderr || "Audio could not be decoded",
|
|
24096
|
+
"invalid_audio",
|
|
24097
|
+
false
|
|
24098
|
+
);
|
|
24099
|
+
}
|
|
24100
|
+
const files = (await readdir(directory)).filter((file) => /^segment-[0-9]{6}\.wav$/.test(file)).sort();
|
|
24101
|
+
if (files.length === 0 || files.length > 1e3) {
|
|
24102
|
+
throw new TranscriptionSegmenterError(
|
|
24103
|
+
"Audio did not produce a bounded segment set",
|
|
24104
|
+
"invalid_audio",
|
|
24105
|
+
false
|
|
24106
|
+
);
|
|
24107
|
+
}
|
|
24108
|
+
const segmentMilliseconds = request.providerSegmentSeconds * 1e3;
|
|
24109
|
+
for (let segmentNumber = 0; segmentNumber < files.length; segmentNumber += 1) {
|
|
24110
|
+
if (request.signal?.aborted) {
|
|
24111
|
+
throw new TranscriptionSegmenterError(
|
|
24112
|
+
"Audio segmentation was cancelled",
|
|
24113
|
+
"cancelled",
|
|
24114
|
+
true
|
|
24115
|
+
);
|
|
24116
|
+
}
|
|
24117
|
+
const bytes = new Uint8Array(await readFile2(join(directory, files[segmentNumber])));
|
|
24118
|
+
if (bytes.byteLength === 0) {
|
|
24119
|
+
throw new TranscriptionSegmenterError(
|
|
24120
|
+
"Audio produced an empty segment",
|
|
24121
|
+
"invalid_audio",
|
|
24122
|
+
false
|
|
24123
|
+
);
|
|
24124
|
+
}
|
|
24125
|
+
const startMilliseconds = segmentNumber * segmentMilliseconds;
|
|
24126
|
+
const remaining = request.totalDurationMilliseconds - startMilliseconds;
|
|
24127
|
+
if (remaining <= 0) {
|
|
24128
|
+
throw new TranscriptionSegmenterError(
|
|
24129
|
+
"Audio segment count exceeds the declared duration",
|
|
24130
|
+
"invalid_audio",
|
|
24131
|
+
false
|
|
24132
|
+
);
|
|
24133
|
+
}
|
|
24134
|
+
yield {
|
|
24135
|
+
segmentNumber,
|
|
24136
|
+
startMilliseconds,
|
|
24137
|
+
durationMilliseconds: Math.min(segmentMilliseconds, remaining),
|
|
24138
|
+
mimeType: "audio/wav",
|
|
24139
|
+
bytes
|
|
24140
|
+
};
|
|
24141
|
+
}
|
|
24142
|
+
} catch (error) {
|
|
24143
|
+
if (error instanceof TranscriptionSegmenterError) throw error;
|
|
24144
|
+
throw new TranscriptionSegmenterError(
|
|
24145
|
+
error instanceof Error ? error.message : "Audio segmentation failed",
|
|
24146
|
+
request.signal?.aborted ? "cancelled" : "unknown",
|
|
24147
|
+
true
|
|
24148
|
+
);
|
|
24149
|
+
} finally {
|
|
24150
|
+
await rm(directory, { recursive: true, force: true }).catch(() => void 0);
|
|
24151
|
+
}
|
|
24152
|
+
}
|
|
24153
|
+
};
|
|
24154
|
+
}
|
|
24155
|
+
async function writeChunks(path, chunks, signal) {
|
|
24156
|
+
const stream = createWriteStream(path, { flags: "wx" });
|
|
24157
|
+
try {
|
|
24158
|
+
for await (const chunk of chunks) {
|
|
24159
|
+
if (signal?.aborted) {
|
|
24160
|
+
throw new TranscriptionSegmenterError(
|
|
24161
|
+
"Audio segmentation was cancelled",
|
|
24162
|
+
"cancelled",
|
|
24163
|
+
true
|
|
24164
|
+
);
|
|
24165
|
+
}
|
|
24166
|
+
if (!stream.write(chunk)) await once(stream, "drain");
|
|
24167
|
+
}
|
|
24168
|
+
stream.end();
|
|
24169
|
+
await once(stream, "close");
|
|
24170
|
+
} catch (error) {
|
|
24171
|
+
stream.destroy();
|
|
24172
|
+
throw error;
|
|
24173
|
+
}
|
|
24174
|
+
}
|
|
24175
|
+
function extensionForMimeType(mimeType) {
|
|
24176
|
+
switch (mimeType.trim().toLowerCase().split(";", 1)[0]) {
|
|
24177
|
+
case "audio/mp4":
|
|
24178
|
+
case "audio/m4a":
|
|
24179
|
+
return "mp4";
|
|
24180
|
+
case "audio/ogg":
|
|
24181
|
+
return "ogg";
|
|
24182
|
+
case "audio/mpeg":
|
|
24183
|
+
case "audio/mp3":
|
|
24184
|
+
return "mp3";
|
|
24185
|
+
case "audio/wav":
|
|
24186
|
+
case "audio/x-wav":
|
|
24187
|
+
return "wav";
|
|
24188
|
+
case "audio/webm":
|
|
24189
|
+
default:
|
|
24190
|
+
return "webm";
|
|
24191
|
+
}
|
|
24192
|
+
}
|
|
24193
|
+
async function commandSucceeds(command, args) {
|
|
24194
|
+
const result = await runCommand(command, args);
|
|
24195
|
+
return !result.spawnError && result.exitCode === 0;
|
|
24196
|
+
}
|
|
24197
|
+
async function runCommand(command, args, signal) {
|
|
24198
|
+
return await new Promise((resolve) => {
|
|
24199
|
+
const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
24200
|
+
let stderr = Buffer.alloc(0);
|
|
24201
|
+
let spawnError = false;
|
|
24202
|
+
let settled = false;
|
|
24203
|
+
const onAbort = () => child.kill("SIGKILL");
|
|
24204
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
24205
|
+
if (signal?.aborted) onAbort();
|
|
24206
|
+
child.stderr.on("data", (chunk) => {
|
|
24207
|
+
if (stderr.byteLength >= STDERR_MAX_BYTES) return;
|
|
24208
|
+
const remaining = STDERR_MAX_BYTES - stderr.byteLength;
|
|
24209
|
+
stderr = Buffer.concat([stderr, Buffer.from(chunk).subarray(0, remaining)]);
|
|
24210
|
+
});
|
|
24211
|
+
child.once("error", () => {
|
|
24212
|
+
spawnError = true;
|
|
24213
|
+
});
|
|
24214
|
+
child.once("close", (exitCode) => {
|
|
24215
|
+
if (settled) return;
|
|
24216
|
+
settled = true;
|
|
24217
|
+
signal?.removeEventListener("abort", onAbort);
|
|
24218
|
+
resolve({
|
|
24219
|
+
exitCode,
|
|
24220
|
+
stderr: stderr.toString("utf8").trim(),
|
|
24221
|
+
spawnError,
|
|
24222
|
+
cancelled: signal?.aborted ?? false
|
|
24223
|
+
});
|
|
24224
|
+
});
|
|
24225
|
+
});
|
|
24226
|
+
}
|
|
24227
|
+
|
|
24228
|
+
// src/routes/transcription-recordings.ts
|
|
24229
|
+
var CHUNK_SHA256_HEADER = "x-opengeni-chunk-sha256";
|
|
24230
|
+
var CHUNK_START_HEADER = "x-opengeni-chunk-start-milliseconds";
|
|
24231
|
+
var CHUNK_DURATION_HEADER = "x-opengeni-chunk-duration-milliseconds";
|
|
24232
|
+
var PROCESSING_LEASE_MILLISECONDS = 15 * 60 * 1e3;
|
|
24233
|
+
var RecordingProcessingError = class extends Error {
|
|
24234
|
+
constructor(message, code, retryable) {
|
|
24235
|
+
super(message);
|
|
24236
|
+
this.code = code;
|
|
24237
|
+
this.retryable = retryable;
|
|
24238
|
+
}
|
|
24239
|
+
name = "RecordingProcessingError";
|
|
24240
|
+
};
|
|
24241
|
+
function registerResumableTranscriptionRoutes(app, deps) {
|
|
24242
|
+
app.get("/v1/workspaces/:workspaceId/transcription-recordings", async (c) => {
|
|
24243
|
+
try {
|
|
24244
|
+
const authority = await requireRecordingAuthority(c, deps, false);
|
|
24245
|
+
return c.json({
|
|
24246
|
+
recordings: await listTranscriptionRecordings(deps.db, {
|
|
24247
|
+
workspaceId: authority.workspaceId,
|
|
24248
|
+
subjectId: authority.subjectId
|
|
24249
|
+
})
|
|
24250
|
+
});
|
|
24251
|
+
} catch (error) {
|
|
24252
|
+
return routeError(c, error);
|
|
24253
|
+
}
|
|
24254
|
+
});
|
|
24255
|
+
app.post("/v1/workspaces/:workspaceId/transcription-recordings", async (c) => {
|
|
24256
|
+
try {
|
|
24257
|
+
const authority = await requireRecordingAuthority(c, deps, true);
|
|
24258
|
+
if (!await resumableAvailable(deps, authority.workspaceId)) {
|
|
24259
|
+
return c.json({ code: "unavailable" }, 503);
|
|
24260
|
+
}
|
|
24261
|
+
const parsed = CreateTranscriptionRecordingRequest.safeParse(await jsonBody(c));
|
|
24262
|
+
if (!parsed.success) return c.json({ code: "invalid_request" }, 400);
|
|
24263
|
+
const mimeType = normalizeMimeType(parsed.data.mimeType);
|
|
24264
|
+
if (!isAcceptedMimeType(mimeType, deps.transcription.limits().acceptedMimeTypes)) {
|
|
24265
|
+
return c.json({ code: "not_supported" }, 415);
|
|
24266
|
+
}
|
|
24267
|
+
const recording = await createTranscriptionRecording(deps.db, {
|
|
24268
|
+
...authority,
|
|
24269
|
+
recordingId: parsed.data.recordingId,
|
|
24270
|
+
mimeType,
|
|
24271
|
+
expiresAt: new Date(Date.now() + deps.settings.voiceInputResumableRetentionSeconds * 1e3)
|
|
24272
|
+
});
|
|
24273
|
+
return c.json(recording, 201);
|
|
24274
|
+
} catch (error) {
|
|
24275
|
+
return routeError(c, error);
|
|
24276
|
+
}
|
|
24277
|
+
});
|
|
24278
|
+
app.get("/v1/workspaces/:workspaceId/transcription-recordings/:recordingId", async (c) => {
|
|
24279
|
+
try {
|
|
24280
|
+
const authority = await requireRecordingAuthority(c, deps, false);
|
|
24281
|
+
const response = await getTranscriptionRecording(deps.db, {
|
|
24282
|
+
...authority,
|
|
24283
|
+
recordingId: uuidParam(c, "recordingId")
|
|
24284
|
+
});
|
|
24285
|
+
return c.json(withRecoveryRetryHint(await cleanupTerminalObjects(deps, authority, response)));
|
|
24286
|
+
} catch (error) {
|
|
24287
|
+
return routeError(c, error);
|
|
24288
|
+
}
|
|
24289
|
+
});
|
|
24290
|
+
app.put(
|
|
24291
|
+
"/v1/workspaces/:workspaceId/transcription-recordings/:recordingId/chunks/:chunkNumber",
|
|
24292
|
+
async (c) => {
|
|
24293
|
+
try {
|
|
24294
|
+
const authority = await requireRecordingAuthority(c, deps, true);
|
|
24295
|
+
if (!deps.objectStorage || !deps.settings.voiceInputResumableEnabled) {
|
|
24296
|
+
return c.json({ code: "unavailable" }, 503);
|
|
24297
|
+
}
|
|
24298
|
+
const chunkNumber = nonnegativeInteger(c.req.param("chunkNumber"));
|
|
24299
|
+
const startMilliseconds = headerInteger(c, CHUNK_START_HEADER, true);
|
|
24300
|
+
const durationMilliseconds = headerInteger(c, CHUNK_DURATION_HEADER, true);
|
|
24301
|
+
const declaredSha256 = c.req.header(CHUNK_SHA256_HEADER)?.trim().toLowerCase() ?? "";
|
|
24302
|
+
if (!/^[0-9a-f]{64}$/.test(declaredSha256)) {
|
|
24303
|
+
return c.json({ code: "invalid_request" }, 400);
|
|
24304
|
+
}
|
|
24305
|
+
const body2 = await readBoundedBody(
|
|
24306
|
+
c.req.raw,
|
|
24307
|
+
deps.settings.voiceInputResumableMaxChunkSizeBytes
|
|
24308
|
+
);
|
|
24309
|
+
const sha256 = sha256Hex2(body2);
|
|
24310
|
+
if (sha256 !== declaredSha256) {
|
|
24311
|
+
return c.json({ code: "conflict" }, 409);
|
|
24312
|
+
}
|
|
24313
|
+
const existing = await getTranscriptionRecording(deps.db, {
|
|
24314
|
+
...authority,
|
|
24315
|
+
recordingId: uuidParam(c, "recordingId")
|
|
24316
|
+
});
|
|
24317
|
+
if (normalizeMimeType(c.req.header("content-type") ?? "") !== normalizeMimeType(existing.recording.mimeType)) {
|
|
24318
|
+
return c.json({ code: "not_supported" }, 415);
|
|
24319
|
+
}
|
|
24320
|
+
const reservation = await reserveTranscriptionRecordingChunk(deps.db, {
|
|
24321
|
+
...authority,
|
|
24322
|
+
recordingId: existing.recording.id,
|
|
24323
|
+
chunkNumber,
|
|
24324
|
+
byteLength: body2.byteLength,
|
|
24325
|
+
sha256,
|
|
24326
|
+
startMilliseconds,
|
|
24327
|
+
durationMilliseconds,
|
|
24328
|
+
maxTotalBytes: deps.settings.voiceInputResumableMaxSizeBytes,
|
|
24329
|
+
maxDurationMilliseconds: deps.settings.voiceInputResumableMaxDurationSeconds * 1e3
|
|
24330
|
+
});
|
|
24331
|
+
if (!reservation.deduplicated) {
|
|
24332
|
+
try {
|
|
24333
|
+
await deps.objectStorage.putObject({
|
|
24334
|
+
key: reservation.chunk.objectKey,
|
|
24335
|
+
contentType: existing.recording.mimeType,
|
|
24336
|
+
body: body2,
|
|
24337
|
+
sha256
|
|
24338
|
+
});
|
|
24339
|
+
} catch {
|
|
24340
|
+
throw new RecordingProcessingError("Chunk upload failed", "network", true);
|
|
24341
|
+
}
|
|
24342
|
+
}
|
|
24343
|
+
const completed = await completeTranscriptionRecordingChunk(deps.db, {
|
|
24344
|
+
workspaceId: authority.workspaceId,
|
|
24345
|
+
subjectId: authority.subjectId,
|
|
24346
|
+
recordingId: existing.recording.id,
|
|
24347
|
+
chunkNumber
|
|
24348
|
+
});
|
|
24349
|
+
const response = {
|
|
24350
|
+
recording: completed.recording.recording,
|
|
24351
|
+
chunk: {
|
|
24352
|
+
chunkNumber: completed.chunk.chunkNumber,
|
|
24353
|
+
byteLength: completed.chunk.byteLength,
|
|
24354
|
+
sha256: completed.chunk.sha256,
|
|
24355
|
+
startMilliseconds: completed.chunk.startMilliseconds,
|
|
24356
|
+
durationMilliseconds: completed.chunk.durationMilliseconds,
|
|
24357
|
+
deduplicated: reservation.deduplicated || completed.deduplicated
|
|
24358
|
+
}
|
|
24359
|
+
};
|
|
24360
|
+
return c.json(response);
|
|
24361
|
+
} catch (error) {
|
|
24362
|
+
return routeError(c, error);
|
|
24363
|
+
}
|
|
24364
|
+
}
|
|
24365
|
+
);
|
|
24366
|
+
app.post(
|
|
24367
|
+
"/v1/workspaces/:workspaceId/transcription-recordings/:recordingId/finalize",
|
|
24368
|
+
async (c) => {
|
|
24369
|
+
const owner = correlationId(c);
|
|
24370
|
+
let authority = null;
|
|
24371
|
+
let generation = 0;
|
|
24372
|
+
try {
|
|
24373
|
+
authority = await requireRecordingAuthority(c, deps, true);
|
|
24374
|
+
if (!await resumableAvailable(deps, authority.workspaceId)) {
|
|
24375
|
+
return c.json({ code: "unavailable" }, 503);
|
|
24376
|
+
}
|
|
24377
|
+
const parsed = FinalizeTranscriptionRecordingRequest.safeParse(await jsonBody(c));
|
|
24378
|
+
if (!parsed.success) return c.json({ code: "invalid_request" }, 400);
|
|
24379
|
+
const recordingId = uuidParam(c, "recordingId");
|
|
24380
|
+
const claim = await claimTranscriptionRecordingAssembly(deps.db, {
|
|
24381
|
+
workspaceId: authority.workspaceId,
|
|
24382
|
+
subjectId: authority.subjectId,
|
|
24383
|
+
recordingId,
|
|
24384
|
+
owner,
|
|
24385
|
+
...parsed.data,
|
|
24386
|
+
staleBefore: new Date(Date.now() - PROCESSING_LEASE_MILLISECONDS)
|
|
24387
|
+
});
|
|
24388
|
+
generation = claim.generation;
|
|
24389
|
+
if (!claim.claimed) {
|
|
24390
|
+
const response = withRecoveryRetryHint(claim.recording);
|
|
24391
|
+
return c.json(response, claim.recording.recording.state === "segmenting" ? 202 : 200);
|
|
24392
|
+
}
|
|
24393
|
+
for (const key of claim.staleObjectKeys) {
|
|
24394
|
+
try {
|
|
24395
|
+
await deps.objectStorage.deleteObject(key);
|
|
24396
|
+
await markTranscriptionRecordingObjectCleaned(deps.db, {
|
|
24397
|
+
workspaceId: authority.workspaceId,
|
|
24398
|
+
subjectId: authority.subjectId,
|
|
24399
|
+
recordingId,
|
|
24400
|
+
objectKey: key
|
|
24401
|
+
});
|
|
24402
|
+
} catch {
|
|
24403
|
+
}
|
|
24404
|
+
}
|
|
24405
|
+
const chunks = await listTranscriptionRecordingChunks(deps.db, {
|
|
24406
|
+
workspaceId: authority.workspaceId,
|
|
24407
|
+
subjectId: authority.subjectId,
|
|
24408
|
+
recordingId
|
|
24409
|
+
});
|
|
24410
|
+
const providerMaxSegmentSeconds = deps.transcription.limits().maxDurationSeconds;
|
|
24411
|
+
const minimumBoundedSegmentSeconds = Math.ceil(
|
|
24412
|
+
claim.recording.recording.totalDurationMilliseconds / 1e3 / 1e3
|
|
24413
|
+
);
|
|
24414
|
+
if (providerMaxSegmentSeconds < minimumBoundedSegmentSeconds) {
|
|
24415
|
+
throw new RecordingProcessingError(
|
|
24416
|
+
"Recording cannot fit the bounded provider segment projection",
|
|
24417
|
+
"too_large",
|
|
24418
|
+
false
|
|
24419
|
+
);
|
|
24420
|
+
}
|
|
24421
|
+
const providerSegmentSeconds = Math.min(
|
|
24422
|
+
TRANSCRIPTION_RECORDING_PROVIDER_SEGMENT_SECONDS,
|
|
24423
|
+
providerMaxSegmentSeconds
|
|
24424
|
+
);
|
|
24425
|
+
for await (const segment of deps.transcriptionSegmenter.segment({
|
|
24426
|
+
sourceMimeType: claim.recording.recording.mimeType,
|
|
24427
|
+
totalDurationMilliseconds: claim.recording.recording.totalDurationMilliseconds,
|
|
24428
|
+
providerSegmentSeconds,
|
|
24429
|
+
chunks: verifiedChunkBytes(deps, chunks, c.req.raw.signal),
|
|
24430
|
+
signal: c.req.raw.signal
|
|
24431
|
+
})) {
|
|
24432
|
+
const sha256 = sha256Hex2(segment.bytes);
|
|
24433
|
+
const reservation = await reserveTranscriptionRecordingSegment(deps.db, {
|
|
24434
|
+
...authority,
|
|
24435
|
+
recordingId,
|
|
24436
|
+
owner,
|
|
24437
|
+
generation,
|
|
24438
|
+
segmentNumber: segment.segmentNumber,
|
|
24439
|
+
byteLength: segment.bytes.byteLength,
|
|
24440
|
+
sha256,
|
|
24441
|
+
startMilliseconds: segment.startMilliseconds,
|
|
24442
|
+
durationMilliseconds: segment.durationMilliseconds
|
|
24443
|
+
});
|
|
24444
|
+
try {
|
|
24445
|
+
await deps.objectStorage.putObject({
|
|
24446
|
+
key: reservation.objectKey,
|
|
24447
|
+
contentType: segment.mimeType,
|
|
24448
|
+
body: segment.bytes,
|
|
24449
|
+
sha256
|
|
24450
|
+
});
|
|
24451
|
+
} catch {
|
|
24452
|
+
throw new RecordingProcessingError("Segment upload failed", "network", true);
|
|
24453
|
+
}
|
|
24454
|
+
await completeTranscriptionRecordingSegmentPreparation(deps.db, {
|
|
24455
|
+
workspaceId: authority.workspaceId,
|
|
24456
|
+
subjectId: authority.subjectId,
|
|
24457
|
+
recordingId,
|
|
24458
|
+
owner,
|
|
24459
|
+
generation,
|
|
24460
|
+
segmentNumber: segment.segmentNumber
|
|
24461
|
+
});
|
|
24462
|
+
}
|
|
24463
|
+
return c.json(
|
|
24464
|
+
await completeTranscriptionRecordingAssembly(deps.db, {
|
|
24465
|
+
workspaceId: authority.workspaceId,
|
|
24466
|
+
subjectId: authority.subjectId,
|
|
24467
|
+
recordingId,
|
|
24468
|
+
owner,
|
|
24469
|
+
generation
|
|
24470
|
+
})
|
|
24471
|
+
);
|
|
24472
|
+
} catch (error) {
|
|
24473
|
+
if (authority && generation > 0) {
|
|
24474
|
+
const failure = processingFailure(error);
|
|
24475
|
+
const persisted = await failTranscriptionRecordingAssembly(deps.db, {
|
|
24476
|
+
workspaceId: authority.workspaceId,
|
|
24477
|
+
subjectId: authority.subjectId,
|
|
24478
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
24479
|
+
owner,
|
|
24480
|
+
generation,
|
|
24481
|
+
errorCode: failure.code,
|
|
24482
|
+
retryable: failure.retryable
|
|
24483
|
+
}).catch(() => null);
|
|
24484
|
+
if (persisted) return c.json(persisted);
|
|
24485
|
+
}
|
|
24486
|
+
return routeError(c, error);
|
|
24487
|
+
}
|
|
24488
|
+
}
|
|
24489
|
+
);
|
|
24490
|
+
app.post(
|
|
24491
|
+
"/v1/workspaces/:workspaceId/transcription-recordings/:recordingId/process-next",
|
|
24492
|
+
async (c) => {
|
|
24493
|
+
let authority = null;
|
|
24494
|
+
let attemptId = null;
|
|
24495
|
+
let segmentNumber = null;
|
|
24496
|
+
try {
|
|
24497
|
+
authority = await requireRecordingAuthority(c, deps, true);
|
|
24498
|
+
const service = deps.transcription;
|
|
24499
|
+
if (!deps.objectStorage || !service || !await service.available({ workspaceId: authority.workspaceId })) {
|
|
24500
|
+
return c.json({ code: "unavailable" }, 503);
|
|
24501
|
+
}
|
|
24502
|
+
const selectedProvider = service.selectProvider ? await service.selectProvider({ workspaceId: authority.workspaceId }) : "host";
|
|
24503
|
+
if (!selectedProvider) return c.json({ code: "unavailable" }, 503);
|
|
24504
|
+
attemptId = correlationId(c);
|
|
24505
|
+
const claim = await claimNextTranscriptionRecordingSegment(deps.db, {
|
|
24506
|
+
workspaceId: authority.workspaceId,
|
|
24507
|
+
subjectId: authority.subjectId,
|
|
24508
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
24509
|
+
attemptId,
|
|
24510
|
+
providerId: selectedProvider,
|
|
24511
|
+
staleBefore: new Date(Date.now() - PROCESSING_LEASE_MILLISECONDS),
|
|
24512
|
+
providerDeadlineAt: new Date(Date.now() + PROCESSING_LEASE_MILLISECONDS)
|
|
24513
|
+
});
|
|
24514
|
+
if (!claim.claimed || !claim.segment) {
|
|
24515
|
+
const cleaned = await cleanupTerminalObjects(deps, authority, claim.recording);
|
|
24516
|
+
return c.json(
|
|
24517
|
+
withRecoveryRetryHint(cleaned),
|
|
24518
|
+
cleaned.recording.state === "transcribing" ? 202 : 200
|
|
24519
|
+
);
|
|
24520
|
+
}
|
|
24521
|
+
segmentNumber = claim.segment.segmentNumber;
|
|
24522
|
+
const stored = await deps.objectStorage?.getObjectBytes(claim.segment.objectKey);
|
|
24523
|
+
if (!stored) {
|
|
24524
|
+
throw new RecordingProcessingError("Provider segment is missing", "invalid_audio", false);
|
|
24525
|
+
}
|
|
24526
|
+
if (stored.bytes.byteLength !== claim.segment.byteLength || sha256Hex2(stored.bytes) !== claim.segment.sha256) {
|
|
24527
|
+
throw new RecordingProcessingError(
|
|
24528
|
+
"Provider segment failed integrity verification",
|
|
24529
|
+
"invalid_audio",
|
|
24530
|
+
false
|
|
24531
|
+
);
|
|
24532
|
+
}
|
|
24533
|
+
const providerStartedAt = /* @__PURE__ */ new Date();
|
|
24534
|
+
const providerDeadlineAt = new Date(
|
|
24535
|
+
providerStartedAt.getTime() + TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS
|
|
24536
|
+
);
|
|
24537
|
+
await startTranscriptionRecordingSegmentProviderCall(deps.db, {
|
|
24538
|
+
workspaceId: authority.workspaceId,
|
|
24539
|
+
subjectId: authority.subjectId,
|
|
24540
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
24541
|
+
segmentNumber,
|
|
24542
|
+
attemptId,
|
|
24543
|
+
providerStartedAt,
|
|
24544
|
+
providerDeadlineAt
|
|
24545
|
+
});
|
|
24546
|
+
const result = await service.transcribe({
|
|
24547
|
+
workspaceId: authority.workspaceId,
|
|
24548
|
+
accountId: authority.accountId,
|
|
24549
|
+
audio: stored.bytes,
|
|
24550
|
+
mimeType: "audio/wav",
|
|
24551
|
+
durationSeconds: claim.segment.durationMilliseconds / 1e3,
|
|
24552
|
+
requestId: attemptId,
|
|
24553
|
+
providerDeadlineAt,
|
|
24554
|
+
...claim.segment.providerId && claim.segment.providerId !== "host" ? { providerId: claim.segment.providerId } : {}
|
|
24555
|
+
});
|
|
24556
|
+
const completed = await completeTranscriptionRecordingSegment(deps.db, {
|
|
24557
|
+
workspaceId: authority.workspaceId,
|
|
24558
|
+
subjectId: authority.subjectId,
|
|
24559
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
24560
|
+
segmentNumber,
|
|
24561
|
+
attemptId,
|
|
24562
|
+
text: result.text,
|
|
24563
|
+
languages: result.languages,
|
|
24564
|
+
providerId: claim.segment.providerId ?? result.providerId
|
|
24565
|
+
});
|
|
24566
|
+
return c.json(await cleanupTerminalObjects(deps, authority, completed));
|
|
24567
|
+
} catch (error) {
|
|
24568
|
+
if (authority && attemptId && segmentNumber !== null) {
|
|
24569
|
+
const failure = processingFailure(error);
|
|
24570
|
+
const persisted = await failTranscriptionRecordingSegment(deps.db, {
|
|
24571
|
+
workspaceId: authority.workspaceId,
|
|
24572
|
+
subjectId: authority.subjectId,
|
|
24573
|
+
recordingId: uuidParam(c, "recordingId"),
|
|
24574
|
+
segmentNumber,
|
|
24575
|
+
attemptId,
|
|
24576
|
+
errorCode: failure.code,
|
|
24577
|
+
retryable: failure.retryable
|
|
24578
|
+
}).catch(() => null);
|
|
24579
|
+
if (persisted) return c.json(persisted);
|
|
24580
|
+
}
|
|
24581
|
+
return routeError(c, error);
|
|
24582
|
+
}
|
|
24583
|
+
}
|
|
24584
|
+
);
|
|
24585
|
+
app.delete("/v1/workspaces/:workspaceId/transcription-recordings/:recordingId", async (c) => {
|
|
24586
|
+
try {
|
|
24587
|
+
const authority = await requireRecordingAuthority(c, deps, false);
|
|
24588
|
+
const discarded = await discardTranscriptionRecording(deps.db, {
|
|
24589
|
+
workspaceId: authority.workspaceId,
|
|
24590
|
+
subjectId: authority.subjectId,
|
|
24591
|
+
recordingId: uuidParam(c, "recordingId")
|
|
24592
|
+
});
|
|
24593
|
+
return c.json(await cleanupTerminalObjects(deps, authority, discarded));
|
|
24594
|
+
} catch (error) {
|
|
24595
|
+
return routeError(c, error);
|
|
24596
|
+
}
|
|
24597
|
+
});
|
|
24598
|
+
}
|
|
24599
|
+
function withRecoveryRetryHint(response) {
|
|
24600
|
+
if (response.recording.state === "segmenting" || response.recording.state === "transcribing") {
|
|
24601
|
+
return {
|
|
24602
|
+
...response,
|
|
24603
|
+
retryAfterMilliseconds: TRANSCRIPTION_RECORDING_RECOVERY_RETRY_AFTER_MILLISECONDS
|
|
24604
|
+
};
|
|
24605
|
+
}
|
|
24606
|
+
return response;
|
|
24607
|
+
}
|
|
24608
|
+
async function requireRecordingAuthority(c, deps, requirePolicy) {
|
|
24609
|
+
const workspaceId = c.req.param("workspaceId");
|
|
24610
|
+
if (!workspaceId) {
|
|
24611
|
+
throw new TranscriptionRecordingNotFoundError("Workspace not found");
|
|
24612
|
+
}
|
|
24613
|
+
const grant = await requireAccessGrant22(c, deps, workspaceId, "sessions:create");
|
|
24614
|
+
if (requirePolicy) {
|
|
24615
|
+
const workspace = await getWorkspace3(deps.db, workspaceId);
|
|
24616
|
+
if (!workspace) throw new TranscriptionRecordingNotFoundError("Workspace not found");
|
|
24617
|
+
if (resolveWorkspaceVoiceInputEnabled(workspace.settings) === false) {
|
|
24618
|
+
throw new RecordingProcessingError("Voice input is disabled", "policy_blocked", false);
|
|
24619
|
+
}
|
|
24620
|
+
}
|
|
24621
|
+
return {
|
|
24622
|
+
accountId: grant.accountId,
|
|
24623
|
+
workspaceId,
|
|
24624
|
+
subjectId: grant.subjectId
|
|
24625
|
+
};
|
|
24626
|
+
}
|
|
24627
|
+
async function resumableAvailable(deps, workspaceId) {
|
|
24628
|
+
return Boolean(
|
|
24629
|
+
deps.settings.voiceInputResumableEnabled && deps.objectStorage && deps.transcription && deps.transcriptionSegmenter && await deps.transcription.available({ workspaceId }) && await deps.transcriptionSegmenter.available()
|
|
24630
|
+
);
|
|
24631
|
+
}
|
|
24632
|
+
async function cleanupTerminalObjects(deps, authority, response) {
|
|
24633
|
+
if (!deps.objectStorage || response.recording.objectsCleaned || response.recording.state !== "complete" && response.recording.state !== "discarded" && !(response.recording.state === "failed" && !response.recording.retryable)) {
|
|
24634
|
+
return response;
|
|
24635
|
+
}
|
|
24636
|
+
try {
|
|
24637
|
+
const keys = await transcriptionRecordingObjectKeys(deps.db, {
|
|
24638
|
+
workspaceId: authority.workspaceId,
|
|
24639
|
+
subjectId: authority.subjectId,
|
|
24640
|
+
recordingId: response.recording.id
|
|
24641
|
+
});
|
|
24642
|
+
let current = response;
|
|
24643
|
+
for (const key of keys) {
|
|
24644
|
+
await deps.objectStorage.deleteObject(key);
|
|
24645
|
+
current = await markTranscriptionRecordingObjectCleaned(deps.db, {
|
|
24646
|
+
workspaceId: authority.workspaceId,
|
|
24647
|
+
subjectId: authority.subjectId,
|
|
24648
|
+
recordingId: response.recording.id,
|
|
24649
|
+
objectKey: key
|
|
24650
|
+
});
|
|
24651
|
+
}
|
|
24652
|
+
if (current.recording.objectsCleaned) return current;
|
|
24653
|
+
return await markTranscriptionRecordingObjectsCleaned(deps.db, {
|
|
24654
|
+
workspaceId: authority.workspaceId,
|
|
24655
|
+
subjectId: authority.subjectId,
|
|
24656
|
+
recordingId: response.recording.id
|
|
24657
|
+
});
|
|
24658
|
+
} catch {
|
|
24659
|
+
return response;
|
|
24660
|
+
}
|
|
24661
|
+
}
|
|
24662
|
+
async function* verifiedChunkBytes(deps, chunks, signal) {
|
|
24663
|
+
for (const chunk of chunks) {
|
|
24664
|
+
if (signal.aborted) {
|
|
24665
|
+
throw new RecordingProcessingError("Audio assembly was cancelled", "cancelled", true);
|
|
24666
|
+
}
|
|
24667
|
+
let stored;
|
|
24668
|
+
try {
|
|
24669
|
+
stored = await deps.objectStorage.getObjectBytes(chunk.objectKey);
|
|
24670
|
+
} catch {
|
|
24671
|
+
throw new RecordingProcessingError("Chunk download failed", "network", true);
|
|
24672
|
+
}
|
|
24673
|
+
if (!stored) {
|
|
24674
|
+
throw new RecordingProcessingError("Chunk is missing", "invalid_audio", false);
|
|
24675
|
+
}
|
|
24676
|
+
if (stored.bytes.byteLength !== chunk.byteLength || sha256Hex2(stored.bytes) !== chunk.sha256) {
|
|
24677
|
+
throw new RecordingProcessingError(
|
|
24678
|
+
"Chunk integrity verification failed",
|
|
24679
|
+
"invalid_audio",
|
|
24680
|
+
false
|
|
24681
|
+
);
|
|
24682
|
+
}
|
|
24683
|
+
yield stored.bytes;
|
|
24684
|
+
}
|
|
24685
|
+
}
|
|
24686
|
+
function processingFailure(error) {
|
|
24687
|
+
if (error instanceof RecordingProcessingError) {
|
|
24688
|
+
return { code: error.code, retryable: error.retryable };
|
|
24689
|
+
}
|
|
24690
|
+
if (error instanceof TranscriptionSegmenterError) {
|
|
24691
|
+
return { code: error.code, retryable: error.retryable };
|
|
24692
|
+
}
|
|
24693
|
+
if (error instanceof TranscriptionServiceError) {
|
|
24694
|
+
return {
|
|
24695
|
+
code: error.code,
|
|
24696
|
+
retryable: error.retryable || error.code === "cancelled" || error.code === "network" || error.code === "timeout" || error.code === "unavailable" || error.code === "provider"
|
|
24697
|
+
};
|
|
24698
|
+
}
|
|
24699
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
24700
|
+
return { code: "cancelled", retryable: true };
|
|
24701
|
+
}
|
|
24702
|
+
return { code: "unknown", retryable: true };
|
|
24703
|
+
}
|
|
24704
|
+
function routeError(c, error) {
|
|
24705
|
+
if (error instanceof TranscriptionRecordingNotFoundError) {
|
|
24706
|
+
return c.json({ code: "not_found" }, 404);
|
|
24707
|
+
}
|
|
24708
|
+
if (error instanceof TranscriptionRecordingConflictError) {
|
|
24709
|
+
return c.json({ code: "conflict" }, 409);
|
|
24710
|
+
}
|
|
24711
|
+
if (error instanceof TranscriptionRecordingStateError) {
|
|
24712
|
+
return c.json({ code: "invalid_state" }, 409);
|
|
24713
|
+
}
|
|
24714
|
+
if (error instanceof RecordingProcessingError) {
|
|
24715
|
+
const status = error.code === "policy_blocked" ? 403 : error.code === "not_supported" ? 415 : error.code === "too_large" ? 413 : error.code === "invalid_audio" ? 400 : error.code === "unavailable" ? 503 : 502;
|
|
24716
|
+
return c.json({ code: error.code }, status);
|
|
24717
|
+
}
|
|
24718
|
+
return c.json({ code: "unknown" }, 500);
|
|
24719
|
+
}
|
|
24720
|
+
async function jsonBody(c) {
|
|
24721
|
+
try {
|
|
24722
|
+
return await c.req.json();
|
|
24723
|
+
} catch {
|
|
24724
|
+
return null;
|
|
24725
|
+
}
|
|
24726
|
+
}
|
|
24727
|
+
function correlationId(c) {
|
|
24728
|
+
const value = c.req.header("x-opengeni-correlation-id")?.trim();
|
|
24729
|
+
return value && /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? valueAsUuid(value) : crypto.randomUUID();
|
|
24730
|
+
}
|
|
24731
|
+
function valueAsUuid(value) {
|
|
24732
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
|
24733
|
+
return value;
|
|
24734
|
+
}
|
|
24735
|
+
const hex = createHash11("sha256").update(value).digest("hex").slice(0, 32);
|
|
24736
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
24737
|
+
}
|
|
24738
|
+
function nonnegativeInteger(value) {
|
|
24739
|
+
if (!/^(0|[1-9][0-9]*)$/.test(value)) {
|
|
24740
|
+
throw new RecordingProcessingError("Invalid integer", "invalid_audio", false);
|
|
24741
|
+
}
|
|
24742
|
+
const parsed = Number(value);
|
|
24743
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
24744
|
+
throw new RecordingProcessingError("Invalid integer", "invalid_audio", false);
|
|
24745
|
+
}
|
|
24746
|
+
return parsed;
|
|
24747
|
+
}
|
|
24748
|
+
function uuidParam(c, name) {
|
|
24749
|
+
const value = c.req.param(name);
|
|
24750
|
+
if (!value || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
|
24751
|
+
throw new TranscriptionRecordingNotFoundError("Recording not found");
|
|
24752
|
+
}
|
|
24753
|
+
return value;
|
|
24754
|
+
}
|
|
24755
|
+
function headerInteger(c, name, allowZero) {
|
|
24756
|
+
const raw = c.req.header(name) ?? "";
|
|
24757
|
+
const value = nonnegativeInteger(raw);
|
|
24758
|
+
if (!allowZero && value === 0) {
|
|
24759
|
+
throw new RecordingProcessingError("Invalid integer", "invalid_audio", false);
|
|
24760
|
+
}
|
|
24761
|
+
return value;
|
|
24762
|
+
}
|
|
24763
|
+
async function readBoundedBody(request, maxBytes) {
|
|
24764
|
+
const contentLength = Number(request.headers.get("content-length"));
|
|
24765
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
24766
|
+
throw new RecordingProcessingError("Chunk is too large", "too_large", false);
|
|
24767
|
+
}
|
|
24768
|
+
if (!request.body) {
|
|
24769
|
+
throw new RecordingProcessingError("Chunk is required", "invalid_audio", false);
|
|
24770
|
+
}
|
|
24771
|
+
const reader = request.body.getReader();
|
|
24772
|
+
const chunks = [];
|
|
24773
|
+
let total = 0;
|
|
24774
|
+
try {
|
|
24775
|
+
for (; ; ) {
|
|
24776
|
+
if (request.signal.aborted) {
|
|
24777
|
+
throw new RecordingProcessingError("Chunk upload was cancelled", "cancelled", true);
|
|
24778
|
+
}
|
|
24779
|
+
const next = await reader.read();
|
|
24780
|
+
if (next.done) break;
|
|
24781
|
+
total += next.value.byteLength;
|
|
24782
|
+
if (total > maxBytes) {
|
|
24783
|
+
await reader.cancel();
|
|
24784
|
+
throw new RecordingProcessingError("Chunk is too large", "too_large", false);
|
|
24785
|
+
}
|
|
24786
|
+
chunks.push(next.value);
|
|
24787
|
+
}
|
|
24788
|
+
} finally {
|
|
24789
|
+
reader.releaseLock();
|
|
24790
|
+
}
|
|
24791
|
+
if (total === 0) {
|
|
24792
|
+
throw new RecordingProcessingError("Chunk is required", "invalid_audio", false);
|
|
24793
|
+
}
|
|
24794
|
+
const body2 = new Uint8Array(total);
|
|
24795
|
+
let offset = 0;
|
|
24796
|
+
for (const chunk of chunks) {
|
|
24797
|
+
body2.set(chunk, offset);
|
|
24798
|
+
offset += chunk.byteLength;
|
|
24799
|
+
}
|
|
24800
|
+
return body2;
|
|
24801
|
+
}
|
|
24802
|
+
function sha256Hex2(bytes) {
|
|
24803
|
+
return createHash11("sha256").update(bytes).digest("hex");
|
|
24804
|
+
}
|
|
24805
|
+
|
|
24806
|
+
// src/routes/transcriptions.ts
|
|
23953
24807
|
function registerTranscriptionRoutes(app, deps) {
|
|
24808
|
+
registerResumableTranscriptionRoutes(app, deps);
|
|
23954
24809
|
app.post("/v1/workspaces/:workspaceId/transcriptions", async (c) => {
|
|
23955
24810
|
const workspaceId = c.req.param("workspaceId");
|
|
23956
|
-
const grant = await
|
|
23957
|
-
const workspace = await
|
|
24811
|
+
const grant = await requireAccessGrant23(c, deps, workspaceId, "sessions:create");
|
|
24812
|
+
const workspace = await getWorkspace4(deps.db, workspaceId);
|
|
23958
24813
|
if (!workspace) return c.json({ code: "not_found" }, 404);
|
|
23959
|
-
if (
|
|
24814
|
+
if (resolveWorkspaceVoiceInputEnabled2(workspace.settings) === false) {
|
|
23960
24815
|
return c.json({ code: "policy_blocked" }, 403);
|
|
23961
24816
|
}
|
|
23962
24817
|
const service = deps.transcription;
|
|
@@ -23980,7 +24835,7 @@ function registerTranscriptionRoutes(app, deps) {
|
|
|
23980
24835
|
};
|
|
23981
24836
|
return c.json(response);
|
|
23982
24837
|
} catch (error) {
|
|
23983
|
-
if (error instanceof
|
|
24838
|
+
if (error instanceof TranscriptionServiceError2) {
|
|
23984
24839
|
return c.json({ code: error.code }, error.status);
|
|
23985
24840
|
}
|
|
23986
24841
|
if (isAbort(error)) return c.json({ code: "cancelled" }, 499);
|
|
@@ -23991,7 +24846,7 @@ function registerTranscriptionRoutes(app, deps) {
|
|
|
23991
24846
|
async function audioRequest(request, maxSizeBytes) {
|
|
23992
24847
|
const contentLength = Number(request.headers.get("content-length"));
|
|
23993
24848
|
if (Number.isFinite(contentLength) && contentLength > maxSizeBytes + 64 * 1024) {
|
|
23994
|
-
throw new
|
|
24849
|
+
throw new TranscriptionServiceError2({
|
|
23995
24850
|
code: "too_large",
|
|
23996
24851
|
message: "Audio is too large."
|
|
23997
24852
|
});
|
|
@@ -24001,13 +24856,13 @@ async function audioRequest(request, maxSizeBytes) {
|
|
|
24001
24856
|
const form = await request.formData();
|
|
24002
24857
|
const audio = form.get("audio");
|
|
24003
24858
|
if (!(audio instanceof File)) {
|
|
24004
|
-
throw new
|
|
24859
|
+
throw new TranscriptionServiceError2({
|
|
24005
24860
|
code: "invalid_audio",
|
|
24006
24861
|
message: "Audio file is required."
|
|
24007
24862
|
});
|
|
24008
24863
|
}
|
|
24009
24864
|
if (audio.size > maxSizeBytes) {
|
|
24010
|
-
throw new
|
|
24865
|
+
throw new TranscriptionServiceError2({
|
|
24011
24866
|
code: "too_large",
|
|
24012
24867
|
message: "Audio is too large."
|
|
24013
24868
|
});
|
|
@@ -24021,7 +24876,7 @@ async function audioRequest(request, maxSizeBytes) {
|
|
|
24021
24876
|
}
|
|
24022
24877
|
const mimeType = contentType.split(";", 1)[0]?.trim() ?? "";
|
|
24023
24878
|
if (!mimeType.toLowerCase().startsWith("audio/")) {
|
|
24024
|
-
throw new
|
|
24879
|
+
throw new TranscriptionServiceError2({
|
|
24025
24880
|
code: "not_supported",
|
|
24026
24881
|
message: "Unsupported audio format."
|
|
24027
24882
|
});
|
|
@@ -24035,7 +24890,7 @@ async function audioRequest(request, maxSizeBytes) {
|
|
|
24035
24890
|
}
|
|
24036
24891
|
async function readBounded(body2, maxSizeBytes, signal) {
|
|
24037
24892
|
if (!body2)
|
|
24038
|
-
throw new
|
|
24893
|
+
throw new TranscriptionServiceError2({
|
|
24039
24894
|
code: "invalid_audio",
|
|
24040
24895
|
message: "Audio is required."
|
|
24041
24896
|
});
|
|
@@ -24050,7 +24905,7 @@ async function readBounded(body2, maxSizeBytes, signal) {
|
|
|
24050
24905
|
size += next.value.byteLength;
|
|
24051
24906
|
if (size > maxSizeBytes) {
|
|
24052
24907
|
await reader.cancel();
|
|
24053
|
-
throw new
|
|
24908
|
+
throw new TranscriptionServiceError2({
|
|
24054
24909
|
code: "too_large",
|
|
24055
24910
|
message: "Audio is too large."
|
|
24056
24911
|
});
|
|
@@ -24072,7 +24927,7 @@ function durationValue(value) {
|
|
|
24072
24927
|
if (value == null || value.trim() === "") return void 0;
|
|
24073
24928
|
const duration = Number(value);
|
|
24074
24929
|
if (!Number.isFinite(duration)) {
|
|
24075
|
-
throw new
|
|
24930
|
+
throw new TranscriptionServiceError2({
|
|
24076
24931
|
code: "invalid_audio",
|
|
24077
24932
|
message: "Invalid audio duration."
|
|
24078
24933
|
});
|
|
@@ -24091,19 +24946,21 @@ import { resolveVoiceInputProviderRegistry } from "@opengeni/config";
|
|
|
24091
24946
|
import { VOICE_INPUT_ACCEPTED_MIME_TYPES } from "@opengeni/contracts";
|
|
24092
24947
|
import {
|
|
24093
24948
|
filenameForMimeType,
|
|
24094
|
-
isAcceptedMimeType,
|
|
24095
|
-
normalizeMimeType,
|
|
24096
|
-
|
|
24949
|
+
isAcceptedMimeType as isAcceptedMimeType2,
|
|
24950
|
+
normalizeMimeType as normalizeMimeType2,
|
|
24951
|
+
TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS as TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS2,
|
|
24952
|
+
TranscriptionServiceError as TranscriptionServiceError5
|
|
24097
24953
|
} from "@opengeni/core";
|
|
24098
24954
|
|
|
24099
24955
|
// src/transcription/providers/openai.ts
|
|
24100
|
-
import { TranscriptionServiceError as
|
|
24956
|
+
import { TranscriptionServiceError as TranscriptionServiceError3 } from "@opengeni/core";
|
|
24101
24957
|
function createOpenAiTranscriptionProvider(input) {
|
|
24102
24958
|
const fetchImpl = input.fetch ?? fetch;
|
|
24103
24959
|
return {
|
|
24104
24960
|
id: "openai",
|
|
24961
|
+
supportsServerDeadline: true,
|
|
24105
24962
|
available: () => true,
|
|
24106
|
-
async transcribe({ audio, mimeType, filename, signal }) {
|
|
24963
|
+
async transcribe({ audio, mimeType, filename, requestId, signal }) {
|
|
24107
24964
|
const form = new FormData();
|
|
24108
24965
|
form.append("file", audioBlob(audio, mimeType), filename);
|
|
24109
24966
|
form.append("model", input.model);
|
|
@@ -24111,7 +24968,11 @@ function createOpenAiTranscriptionProvider(input) {
|
|
|
24111
24968
|
try {
|
|
24112
24969
|
response = await fetchImpl(`${input.baseUrl}/audio/transcriptions`, {
|
|
24113
24970
|
method: "POST",
|
|
24114
|
-
headers: {
|
|
24971
|
+
headers: {
|
|
24972
|
+
Authorization: `Bearer ${input.apiKey}`,
|
|
24973
|
+
// Observability only; the upstream API is not treated as idempotent.
|
|
24974
|
+
"x-opengeni-request-id": requestId
|
|
24975
|
+
},
|
|
24115
24976
|
body: form,
|
|
24116
24977
|
...signal ? { signal } : {}
|
|
24117
24978
|
});
|
|
@@ -24121,7 +24982,7 @@ function createOpenAiTranscriptionProvider(input) {
|
|
|
24121
24982
|
if (!response.ok) throw responseError(response.status);
|
|
24122
24983
|
const body2 = await response.json().catch(() => null);
|
|
24123
24984
|
if (!body2 || typeof body2.text !== "string") {
|
|
24124
|
-
throw new
|
|
24985
|
+
throw new TranscriptionServiceError3({
|
|
24125
24986
|
code: "provider",
|
|
24126
24987
|
message: "Invalid transcription response."
|
|
24127
24988
|
});
|
|
@@ -24138,31 +24999,31 @@ function audioBlob(audio, mimeType) {
|
|
|
24138
24999
|
}
|
|
24139
25000
|
function responseError(status) {
|
|
24140
25001
|
if (status === 401 || status === 403) {
|
|
24141
|
-
return new
|
|
25002
|
+
return new TranscriptionServiceError3({
|
|
24142
25003
|
code: "unavailable",
|
|
24143
25004
|
message: "Transcription is unavailable."
|
|
24144
25005
|
});
|
|
24145
25006
|
}
|
|
24146
25007
|
if (status === 413) {
|
|
24147
|
-
return new
|
|
25008
|
+
return new TranscriptionServiceError3({
|
|
24148
25009
|
code: "too_large",
|
|
24149
25010
|
message: "Audio is too large."
|
|
24150
25011
|
});
|
|
24151
25012
|
}
|
|
24152
25013
|
if (status === 400 || status === 422) {
|
|
24153
|
-
return new
|
|
25014
|
+
return new TranscriptionServiceError3({
|
|
24154
25015
|
code: "invalid_audio",
|
|
24155
25016
|
message: "Audio could not be transcribed."
|
|
24156
25017
|
});
|
|
24157
25018
|
}
|
|
24158
25019
|
if (status === 408 || status === 504) {
|
|
24159
|
-
return new
|
|
25020
|
+
return new TranscriptionServiceError3({
|
|
24160
25021
|
code: "timeout",
|
|
24161
25022
|
message: "Transcription timed out.",
|
|
24162
25023
|
retryable: true
|
|
24163
25024
|
});
|
|
24164
25025
|
}
|
|
24165
|
-
return new
|
|
25026
|
+
return new TranscriptionServiceError3({
|
|
24166
25027
|
code: status >= 500 ? "unavailable" : "provider",
|
|
24167
25028
|
message: "Transcription provider failed.",
|
|
24168
25029
|
retryable: status >= 500
|
|
@@ -24170,12 +25031,12 @@ function responseError(status) {
|
|
|
24170
25031
|
}
|
|
24171
25032
|
function fetchError(error) {
|
|
24172
25033
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
24173
|
-
return new
|
|
25034
|
+
return new TranscriptionServiceError3({
|
|
24174
25035
|
code: "cancelled",
|
|
24175
25036
|
message: "Transcription was cancelled."
|
|
24176
25037
|
});
|
|
24177
25038
|
}
|
|
24178
|
-
return new
|
|
25039
|
+
return new TranscriptionServiceError3({
|
|
24179
25040
|
code: "network",
|
|
24180
25041
|
message: "Transcription provider is unreachable.",
|
|
24181
25042
|
retryable: true
|
|
@@ -24188,11 +25049,12 @@ function createAzureOpenAiTranscriptionProvider(input) {
|
|
|
24188
25049
|
const url = `${input.endpoint}/openai/deployments/${encodeURIComponent(input.deployment)}/audio/transcriptions?api-version=${encodeURIComponent(input.apiVersion)}`;
|
|
24189
25050
|
return {
|
|
24190
25051
|
id: "azure-openai",
|
|
25052
|
+
supportsServerDeadline: true,
|
|
24191
25053
|
available: () => Boolean(input.apiKey || input.adToken),
|
|
24192
|
-
async transcribe({ audio, mimeType, filename, signal }) {
|
|
25054
|
+
async transcribe({ audio, mimeType, filename, requestId, signal }) {
|
|
24193
25055
|
const form = new FormData();
|
|
24194
25056
|
form.append("file", new Blob([Uint8Array.from(audio).buffer], { type: mimeType }), filename);
|
|
24195
|
-
const headers = input.apiKey ? { "api-key": input.apiKey } : { Authorization: `Bearer ${input.adToken}
|
|
25057
|
+
const headers = input.apiKey ? { "api-key": input.apiKey, "x-opengeni-request-id": requestId } : { Authorization: `Bearer ${input.adToken}`, "x-opengeni-request-id": requestId };
|
|
24196
25058
|
let response;
|
|
24197
25059
|
try {
|
|
24198
25060
|
response = await fetchImpl(url, {
|
|
@@ -24220,7 +25082,7 @@ function createAzureOpenAiTranscriptionProvider(input) {
|
|
|
24220
25082
|
// src/transcription/providers/codex-subscription.ts
|
|
24221
25083
|
import { CODEX_CLIENT_VERSION as CODEX_CLIENT_VERSION3, CODEX_ORIGINATOR } from "@opengeni/codex/constants";
|
|
24222
25084
|
import {
|
|
24223
|
-
TranscriptionServiceError as
|
|
25085
|
+
TranscriptionServiceError as TranscriptionServiceError4
|
|
24224
25086
|
} from "@opengeni/core";
|
|
24225
25087
|
import { buildCodexTokenResolver as buildCodexTokenResolver3, listCodexAccountStatuses as listCodexAccountStatuses3 } from "@opengeni/db";
|
|
24226
25088
|
var TRANSCRIBE_URL = "https://chatgpt.com/backend-api/transcribe";
|
|
@@ -24238,14 +25100,15 @@ function createCodexSubscriptionTranscriptionProvider(input) {
|
|
|
24238
25100
|
});
|
|
24239
25101
|
return {
|
|
24240
25102
|
id: "codex-subscription",
|
|
25103
|
+
supportsServerDeadline: true,
|
|
24241
25104
|
experimental: true,
|
|
24242
25105
|
available: probe,
|
|
24243
|
-
async transcribe({ audio, mimeType, filename, workspaceId, signal }) {
|
|
25106
|
+
async transcribe({ audio, mimeType, filename, workspaceId, requestId, signal }) {
|
|
24244
25107
|
const account = (await listCodexAccountStatuses3(input.db, workspaceId)).find(
|
|
24245
25108
|
(candidate) => candidate.isActive && candidate.status === "active"
|
|
24246
25109
|
);
|
|
24247
25110
|
if (!account) {
|
|
24248
|
-
throw new
|
|
25111
|
+
throw new TranscriptionServiceError4({
|
|
24249
25112
|
code: "unavailable",
|
|
24250
25113
|
message: "Transcription is unavailable."
|
|
24251
25114
|
});
|
|
@@ -24255,7 +25118,7 @@ function createCodexSubscriptionTranscriptionProvider(input) {
|
|
|
24255
25118
|
try {
|
|
24256
25119
|
token = await resolver.getToken();
|
|
24257
25120
|
} catch {
|
|
24258
|
-
throw new
|
|
25121
|
+
throw new TranscriptionServiceError4({
|
|
24259
25122
|
code: "unavailable",
|
|
24260
25123
|
message: "Transcription is unavailable."
|
|
24261
25124
|
});
|
|
@@ -24274,7 +25137,9 @@ function createCodexSubscriptionTranscriptionProvider(input) {
|
|
|
24274
25137
|
...accountId ? { "ChatGPT-Account-ID": accountId } : {},
|
|
24275
25138
|
originator: CODEX_ORIGINATOR,
|
|
24276
25139
|
"User-Agent": `${CODEX_ORIGINATOR}/${CODEX_CLIENT_VERSION3}`,
|
|
24277
|
-
version: CODEX_CLIENT_VERSION3
|
|
25140
|
+
version: CODEX_CLIENT_VERSION3,
|
|
25141
|
+
// Observability only; the upstream API is not treated as idempotent.
|
|
25142
|
+
"x-opengeni-request-id": requestId
|
|
24278
25143
|
},
|
|
24279
25144
|
body: form,
|
|
24280
25145
|
...signal ? { signal } : {}
|
|
@@ -24333,6 +25198,8 @@ function createTranscriptionService(input) {
|
|
|
24333
25198
|
maxSizeBytes: input.settings.voiceInputMaxSizeBytes,
|
|
24334
25199
|
acceptedMimeTypes: [...VOICE_INPUT_ACCEPTED_MIME_TYPES]
|
|
24335
25200
|
};
|
|
25201
|
+
const providerRequestTimeoutMilliseconds = input.providerRequestTimeoutMilliseconds ?? TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS2;
|
|
25202
|
+
const now = input.now ?? (() => /* @__PURE__ */ new Date());
|
|
24336
25203
|
return {
|
|
24337
25204
|
limits: () => limits,
|
|
24338
25205
|
async available(context) {
|
|
@@ -24340,43 +25207,81 @@ function createTranscriptionService(input) {
|
|
|
24340
25207
|
Boolean
|
|
24341
25208
|
);
|
|
24342
25209
|
},
|
|
25210
|
+
async selectProvider(context) {
|
|
25211
|
+
return (await firstAvailable(providers, context))?.id ?? null;
|
|
25212
|
+
},
|
|
24343
25213
|
async transcribe(request) {
|
|
24344
|
-
const mimeType =
|
|
24345
|
-
if (!
|
|
24346
|
-
throw new
|
|
25214
|
+
const mimeType = normalizeMimeType2(request.mimeType);
|
|
25215
|
+
if (!isAcceptedMimeType2(mimeType, limits.acceptedMimeTypes)) {
|
|
25216
|
+
throw new TranscriptionServiceError5({
|
|
24347
25217
|
code: "not_supported",
|
|
24348
25218
|
message: "Unsupported audio format."
|
|
24349
25219
|
});
|
|
24350
25220
|
}
|
|
24351
25221
|
if (request.audio.byteLength > limits.maxSizeBytes) {
|
|
24352
|
-
throw new
|
|
25222
|
+
throw new TranscriptionServiceError5({
|
|
24353
25223
|
code: "too_large",
|
|
24354
25224
|
message: "Audio is too large."
|
|
24355
25225
|
});
|
|
24356
25226
|
}
|
|
24357
25227
|
if (request.durationSeconds !== void 0 && (!Number.isFinite(request.durationSeconds) || request.durationSeconds < 0 || request.durationSeconds > limits.maxDurationSeconds)) {
|
|
24358
|
-
throw new
|
|
25228
|
+
throw new TranscriptionServiceError5({
|
|
24359
25229
|
code: "invalid_audio",
|
|
24360
25230
|
message: "Invalid audio duration."
|
|
24361
25231
|
});
|
|
24362
25232
|
}
|
|
24363
|
-
const provider = await firstAvailable(providers, {
|
|
24364
|
-
workspaceId: request.workspaceId
|
|
24365
|
-
});
|
|
25233
|
+
const provider = request.providerId ? await exactAvailable(providers, request.providerId, { workspaceId: request.workspaceId }) : await firstAvailable(providers, { workspaceId: request.workspaceId });
|
|
24366
25234
|
if (!provider) {
|
|
24367
|
-
throw new
|
|
25235
|
+
throw new TranscriptionServiceError5({
|
|
24368
25236
|
code: "unavailable",
|
|
24369
25237
|
message: "Transcription is unavailable."
|
|
24370
25238
|
});
|
|
24371
25239
|
}
|
|
25240
|
+
if (provider.supportsServerDeadline !== true) {
|
|
25241
|
+
throw new TranscriptionServiceError5({
|
|
25242
|
+
code: "unavailable",
|
|
25243
|
+
message: "Transcription provider does not support bounded requests."
|
|
25244
|
+
});
|
|
25245
|
+
}
|
|
24372
25246
|
const startedAt = performance.now();
|
|
24373
|
-
const
|
|
24374
|
-
|
|
24375
|
-
|
|
24376
|
-
|
|
24377
|
-
|
|
24378
|
-
|
|
24379
|
-
|
|
25247
|
+
const remainingMilliseconds = request.providerDeadlineAt ? remainingTranscriptionProviderRequestMilliseconds(request.providerDeadlineAt, now()) : providerRequestTimeoutMilliseconds;
|
|
25248
|
+
if (request.providerDeadlineAt && (!Number.isFinite(remainingMilliseconds) || remainingMilliseconds <= 0)) {
|
|
25249
|
+
throw new TranscriptionServiceError5({
|
|
25250
|
+
code: "timeout",
|
|
25251
|
+
message: "Transcription provider deadline expired.",
|
|
25252
|
+
retryable: true
|
|
25253
|
+
});
|
|
25254
|
+
}
|
|
25255
|
+
const deadline = createProviderRequestDeadline(request.signal, remainingMilliseconds);
|
|
25256
|
+
let result;
|
|
25257
|
+
try {
|
|
25258
|
+
result = await provider.transcribe({
|
|
25259
|
+
audio: request.audio,
|
|
25260
|
+
mimeType,
|
|
25261
|
+
filename: filenameForMimeType(mimeType),
|
|
25262
|
+
workspaceId: request.workspaceId,
|
|
25263
|
+
requestId: request.requestId,
|
|
25264
|
+
signal: deadline.signal
|
|
25265
|
+
});
|
|
25266
|
+
if (deadline.timedOut && !request.signal?.aborted) {
|
|
25267
|
+
throw new TranscriptionServiceError5({
|
|
25268
|
+
code: "timeout",
|
|
25269
|
+
message: "Transcription provider timed out.",
|
|
25270
|
+
retryable: true
|
|
25271
|
+
});
|
|
25272
|
+
}
|
|
25273
|
+
} catch (error) {
|
|
25274
|
+
if (deadline.timedOut && !request.signal?.aborted) {
|
|
25275
|
+
throw new TranscriptionServiceError5({
|
|
25276
|
+
code: "timeout",
|
|
25277
|
+
message: "Transcription provider timed out.",
|
|
25278
|
+
retryable: true
|
|
25279
|
+
});
|
|
25280
|
+
}
|
|
25281
|
+
throw error;
|
|
25282
|
+
} finally {
|
|
25283
|
+
deadline.dispose();
|
|
25284
|
+
}
|
|
24380
25285
|
return {
|
|
24381
25286
|
...result,
|
|
24382
25287
|
providerId: provider.id,
|
|
@@ -24386,15 +25291,50 @@ function createTranscriptionService(input) {
|
|
|
24386
25291
|
}
|
|
24387
25292
|
};
|
|
24388
25293
|
}
|
|
25294
|
+
function remainingTranscriptionProviderRequestMilliseconds(providerDeadlineAt, now) {
|
|
25295
|
+
return providerDeadlineAt.getTime() - now.getTime();
|
|
25296
|
+
}
|
|
25297
|
+
function createProviderRequestDeadline(parentSignal, timeoutMilliseconds) {
|
|
25298
|
+
const controller = new AbortController();
|
|
25299
|
+
let timedOut = false;
|
|
25300
|
+
const timeout = setTimeout(
|
|
25301
|
+
() => {
|
|
25302
|
+
timedOut = true;
|
|
25303
|
+
controller.abort(new DOMException("Transcription provider timed out", "TimeoutError"));
|
|
25304
|
+
},
|
|
25305
|
+
Math.max(1, Math.ceil(timeoutMilliseconds))
|
|
25306
|
+
);
|
|
25307
|
+
const abortFromParent = () => {
|
|
25308
|
+
controller.abort(parentSignal?.reason);
|
|
25309
|
+
};
|
|
25310
|
+
if (parentSignal) {
|
|
25311
|
+
if (parentSignal.aborted) abortFromParent();
|
|
25312
|
+
else parentSignal.addEventListener("abort", abortFromParent, { once: true });
|
|
25313
|
+
}
|
|
25314
|
+
return {
|
|
25315
|
+
signal: controller.signal,
|
|
25316
|
+
get timedOut() {
|
|
25317
|
+
return timedOut;
|
|
25318
|
+
},
|
|
25319
|
+
dispose: () => {
|
|
25320
|
+
clearTimeout(timeout);
|
|
25321
|
+
parentSignal?.removeEventListener("abort", abortFromParent);
|
|
25322
|
+
}
|
|
25323
|
+
};
|
|
25324
|
+
}
|
|
24389
25325
|
async function firstAvailable(providers, context) {
|
|
24390
25326
|
for (const provider of providers) {
|
|
24391
25327
|
if (await provider.available(context)) return provider;
|
|
24392
25328
|
}
|
|
24393
25329
|
return null;
|
|
24394
25330
|
}
|
|
25331
|
+
async function exactAvailable(providers, providerId, context) {
|
|
25332
|
+
const provider = providers.find((candidate) => candidate.id === providerId);
|
|
25333
|
+
return provider && await provider.available(context) ? provider : null;
|
|
25334
|
+
}
|
|
24395
25335
|
|
|
24396
25336
|
// src/integrations/slack-interactions.ts
|
|
24397
|
-
import { createHash as
|
|
25337
|
+
import { createHash as createHash12, createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
24398
25338
|
import {
|
|
24399
25339
|
DEFAULT_FIRST_PARTY_MCP_TOOLS as DEFAULT_FIRST_PARTY_MCP_TOOLS2,
|
|
24400
25340
|
hasOpenGeniSlackReactionScope,
|
|
@@ -24420,7 +25360,7 @@ import {
|
|
|
24420
25360
|
getSlackInteractionByClientEventId,
|
|
24421
25361
|
getSlackInteractionByRoute,
|
|
24422
25362
|
getSessionEventByClientEventId,
|
|
24423
|
-
getWorkspace as
|
|
25363
|
+
getWorkspace as getWorkspace5,
|
|
24424
25364
|
getWorkspaceGrant as getWorkspaceGrant6,
|
|
24425
25365
|
listSessionEventPage as listSessionEventPage3,
|
|
24426
25366
|
listSessionHumanInputRequests as listSessionHumanInputRequests2,
|
|
@@ -24438,7 +25378,7 @@ import {
|
|
|
24438
25378
|
controlHumanSessionWorkstream as controlHumanSessionWorkstream3,
|
|
24439
25379
|
createSessionForRequest as createSessionForRequest3,
|
|
24440
25380
|
hasPermission as hasPermission14,
|
|
24441
|
-
requireAccessGrant as
|
|
25381
|
+
requireAccessGrant as requireAccessGrant24
|
|
24442
25382
|
} from "@opengeni/core";
|
|
24443
25383
|
import { publishDurableSessionEvents as publishDurableSessionEvents3 } from "@opengeni/events";
|
|
24444
25384
|
import { HTTPException as HTTPException33 } from "hono/http-exception";
|
|
@@ -24542,7 +25482,7 @@ function slackReactionInboxEntry(payload, bot, settings) {
|
|
|
24542
25482
|
if (!settings.enabled || !event || event.type !== "reaction_added" || item?.type !== "message" || !teamId || !eventId || !userId || userId === bot.botUserId || !reaction || reaction !== settings.emoji || !channelId || !workspaceSlackReactionChannelAllowed(settings, channelId) || !timestamp) {
|
|
24543
25483
|
return null;
|
|
24544
25484
|
}
|
|
24545
|
-
const stableReactionIdentity =
|
|
25485
|
+
const stableReactionIdentity = createHash12("sha256").update([teamId, userId, channelId, timestamp, reaction].join("\n")).digest("hex");
|
|
24546
25486
|
return {
|
|
24547
25487
|
providerEventId: eventId,
|
|
24548
25488
|
providerMessageId: `reaction:${stableReactionIdentity}`,
|
|
@@ -24576,7 +25516,7 @@ function registerSlackInteractionRoutes(app, deps) {
|
|
|
24576
25516
|
const event = record3(payload.event);
|
|
24577
25517
|
if (event?.type === "reaction_added") {
|
|
24578
25518
|
const [workspace, connection] = await Promise.all([
|
|
24579
|
-
|
|
25519
|
+
getWorkspace5(deps.db, installation.workspaceId),
|
|
24580
25520
|
getConnectionMetadata4(deps.db, installation.workspaceId, installation.connectionId, null)
|
|
24581
25521
|
]);
|
|
24582
25522
|
if (!workspace || !connection || !hasOpenGeniSlackReactionScope(connection.grantedScopes)) {
|
|
@@ -24675,7 +25615,7 @@ function registerSlackInteractionRoutes(app, deps) {
|
|
|
24675
25615
|
});
|
|
24676
25616
|
app.post("/v1/workspaces/:workspaceId/integrations/slack/user-links", async (c) => {
|
|
24677
25617
|
const workspaceId = c.req.param("workspaceId");
|
|
24678
|
-
const grant = await
|
|
25618
|
+
const grant = await requireAccessGrant24(c, deps, workspaceId, "sessions:create");
|
|
24679
25619
|
const body2 = record3(await c.req.json().catch(() => null));
|
|
24680
25620
|
const linkToken = boundedString(body2?.linkToken, 2048);
|
|
24681
25621
|
const signingSecret = deps.settings.slackSigningSecret;
|
|
@@ -24708,7 +25648,7 @@ function registerSlackInteractionRoutes(app, deps) {
|
|
|
24708
25648
|
"/v1/workspaces/:workspaceId/integrations/slack/user-links/:slackUserId",
|
|
24709
25649
|
async (c) => {
|
|
24710
25650
|
const workspaceId = c.req.param("workspaceId");
|
|
24711
|
-
await
|
|
25651
|
+
await requireAccessGrant24(c, deps, workspaceId, "connections:write");
|
|
24712
25652
|
const connectionId = boundedString(c.req.query("connectionId"), 64);
|
|
24713
25653
|
if (!connectionId) throw new HTTPException33(400, { message: "connectionId is required" });
|
|
24714
25654
|
return c.json({
|
|
@@ -24723,7 +25663,7 @@ function registerSlackInteractionRoutes(app, deps) {
|
|
|
24723
25663
|
);
|
|
24724
25664
|
app.get("/v1/workspaces/:workspaceId/integrations/slack/reaction-channels", async (c) => {
|
|
24725
25665
|
const workspaceId = c.req.param("workspaceId");
|
|
24726
|
-
const grant = await
|
|
25666
|
+
const grant = await requireAccessGrant24(c, deps, workspaceId, "workspace:admin");
|
|
24727
25667
|
const connectionId = boundedString(c.req.query("connectionId"), 64);
|
|
24728
25668
|
if (!connectionId) throw new HTTPException33(400, { message: "connectionId is required" });
|
|
24729
25669
|
const cursor = boundedString(c.req.query("cursor"), 1024);
|
|
@@ -24959,7 +25899,7 @@ async function processSlackInboxEntry(deps, entry) {
|
|
|
24959
25899
|
}
|
|
24960
25900
|
async function processSlackReactionInboxEntry(deps, entry) {
|
|
24961
25901
|
const [workspace, connection, link] = await Promise.all([
|
|
24962
|
-
|
|
25902
|
+
getWorkspace5(deps.db, entry.workspaceId),
|
|
24963
25903
|
getConnectionMetadata4(deps.db, entry.workspaceId, entry.connectionId, null),
|
|
24964
25904
|
getSlackBotUserLink(deps.db, entry.workspaceId, entry.connectionId, entry.slackUserId)
|
|
24965
25905
|
]);
|
|
@@ -25543,7 +26483,7 @@ function slackRouteKey(channelId, threadTs) {
|
|
|
25543
26483
|
return `${channelId}:${threadTs}`;
|
|
25544
26484
|
}
|
|
25545
26485
|
function deterministicUuid(value) {
|
|
25546
|
-
const bytes =
|
|
26486
|
+
const bytes = createHash12("sha256").update(value).digest().subarray(0, 16);
|
|
25547
26487
|
bytes[6] = bytes[6] & 15 | 80;
|
|
25548
26488
|
bytes[8] = bytes[8] & 63 | 128;
|
|
25549
26489
|
const hex = bytes.toString("hex");
|
|
@@ -25662,7 +26602,11 @@ import {
|
|
|
25662
26602
|
import { workflowIdForSession as workflowIdForSession2 } from "@opengeni/core";
|
|
25663
26603
|
var API_MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
|
|
25664
26604
|
function apiRequestBodyLimitBytes(settings) {
|
|
25665
|
-
return Math.max(
|
|
26605
|
+
return Math.max(
|
|
26606
|
+
API_MAX_REQUEST_BODY_BYTES,
|
|
26607
|
+
settings.voiceInputMaxSizeBytes + 64 * 1024,
|
|
26608
|
+
(settings.voiceInputResumableMaxChunkSizeBytes ?? 0) + 64 * 1024
|
|
26609
|
+
);
|
|
25666
26610
|
}
|
|
25667
26611
|
var API_PUBLIC_ERROR_MESSAGE_MAX_BYTES = 512;
|
|
25668
26612
|
function createApp(deps) {
|
|
@@ -25732,6 +26676,9 @@ function createAppComposition(deps) {
|
|
|
25732
26676
|
db: deps.db,
|
|
25733
26677
|
...deps.codexFetch ? { codexFetch: deps.codexFetch } : {}
|
|
25734
26678
|
}) : deps.transcription;
|
|
26679
|
+
const transcriptionSegmenter = deps.transcriptionSegmenter === void 0 ? createFfmpegTranscriptionSegmenter({
|
|
26680
|
+
ffmpegPath: deps.settings.voiceInputFfmpegPath
|
|
26681
|
+
}) : deps.transcriptionSegmenter;
|
|
25735
26682
|
const routeDeps = {
|
|
25736
26683
|
...deps,
|
|
25737
26684
|
observability,
|
|
@@ -25741,15 +26688,16 @@ function createAppComposition(deps) {
|
|
|
25741
26688
|
documentIndexer,
|
|
25742
26689
|
getDocumentServices,
|
|
25743
26690
|
transcription,
|
|
26691
|
+
transcriptionSegmenter,
|
|
25744
26692
|
...sandboxClient ? { sandboxClient } : {},
|
|
25745
26693
|
resumeBoxById
|
|
25746
26694
|
};
|
|
25747
26695
|
const app = new Hono();
|
|
25748
26696
|
const correlationIds = /* @__PURE__ */ new WeakMap();
|
|
25749
26697
|
app.use("*", async (c, next) => {
|
|
25750
|
-
const
|
|
25751
|
-
correlationIds.set(c.req.raw,
|
|
25752
|
-
c.header(OPENGENI_CORRELATION_HEADER,
|
|
26698
|
+
const correlationId2 = boundedCorrelationId(c.req.header(OPENGENI_CORRELATION_HEADER)) ?? crypto.randomUUID();
|
|
26699
|
+
correlationIds.set(c.req.raw, correlationId2);
|
|
26700
|
+
c.header(OPENGENI_CORRELATION_HEADER, correlationId2);
|
|
25753
26701
|
await next();
|
|
25754
26702
|
});
|
|
25755
26703
|
const corsHeaders = {
|
|
@@ -25799,7 +26747,7 @@ function createAppComposition(deps) {
|
|
|
25799
26747
|
app.use("*", async (c, next) => {
|
|
25800
26748
|
const url = new URL(c.req.url);
|
|
25801
26749
|
const route = routeLabel(url.pathname);
|
|
25802
|
-
const
|
|
26750
|
+
const correlationId2 = correlationIds.get(c.req.raw) ?? crypto.randomUUID();
|
|
25803
26751
|
const start = performance.now();
|
|
25804
26752
|
const span = observability.startSpan(`HTTP ${c.req.method} ${route}`, {
|
|
25805
26753
|
"http.request.method": c.req.method,
|
|
@@ -25829,7 +26777,7 @@ function createAppComposition(deps) {
|
|
|
25829
26777
|
durationMs: Math.round(durationSeconds * 1e3),
|
|
25830
26778
|
traceId: span.traceId,
|
|
25831
26779
|
spanId: span.spanId,
|
|
25832
|
-
correlationId
|
|
26780
|
+
correlationId: correlationId2
|
|
25833
26781
|
});
|
|
25834
26782
|
} catch (error) {
|
|
25835
26783
|
const status = httpStatusForError(error);
|
|
@@ -25860,7 +26808,7 @@ function createAppComposition(deps) {
|
|
|
25860
26808
|
durationMs: Math.round(durationSeconds * 1e3),
|
|
25861
26809
|
traceId: span.traceId,
|
|
25862
26810
|
spanId: span.spanId,
|
|
25863
|
-
correlationId,
|
|
26811
|
+
correlationId: correlationId2,
|
|
25864
26812
|
errorCode,
|
|
25865
26813
|
errorClass: error instanceof Error ? error.name : "NonErrorThrown"
|
|
25866
26814
|
});
|
|
@@ -25939,7 +26887,18 @@ function createAppComposition(deps) {
|
|
|
25939
26887
|
available: await transcription?.available() ?? false,
|
|
25940
26888
|
maxDurationSeconds: deps.settings.voiceInputMaxDurationSeconds,
|
|
25941
26889
|
maxSizeBytes: deps.settings.voiceInputMaxSizeBytes,
|
|
25942
|
-
acceptedMimeTypes: [...VOICE_INPUT_ACCEPTED_MIME_TYPES2]
|
|
26890
|
+
acceptedMimeTypes: [...VOICE_INPUT_ACCEPTED_MIME_TYPES2],
|
|
26891
|
+
...deps.settings.voiceInputResumableEnabled && objectStorage && transcription && transcriptionSegmenter && await transcription.available() && await transcriptionSegmenter.available() ? {
|
|
26892
|
+
resumable: {
|
|
26893
|
+
maxDurationSeconds: deps.settings.voiceInputResumableMaxDurationSeconds,
|
|
26894
|
+
maxSizeBytes: deps.settings.voiceInputResumableMaxSizeBytes,
|
|
26895
|
+
maxChunkSizeBytes: deps.settings.voiceInputResumableMaxChunkSizeBytes,
|
|
26896
|
+
providerSegmentSeconds: Math.min(
|
|
26897
|
+
TRANSCRIPTION_RECORDING_PROVIDER_SEGMENT_SECONDS2,
|
|
26898
|
+
deps.settings.voiceInputMaxDurationSeconds
|
|
26899
|
+
)
|
|
26900
|
+
}
|
|
26901
|
+
} : {}
|
|
25943
26902
|
},
|
|
25944
26903
|
productAccessMode: deps.settings.productAccessMode,
|
|
25945
26904
|
auth: clientAuthConfig(deps.settings),
|
|
@@ -26005,7 +26964,7 @@ function createAppComposition(deps) {
|
|
|
26005
26964
|
throw error;
|
|
26006
26965
|
}
|
|
26007
26966
|
}
|
|
26008
|
-
const workspace = await
|
|
26967
|
+
const workspace = await getWorkspace6(routeDeps.db, workspaceId);
|
|
26009
26968
|
const workspaceMemoryEnabled = resolveWorkspaceMemoryEnabled(workspace?.settings);
|
|
26010
26969
|
const transport = new WebStandardStreamableHTTPServerTransport3({
|
|
26011
26970
|
enableJsonResponse: true
|
|
@@ -26096,7 +27055,7 @@ function appendVary(current, value) {
|
|
|
26096
27055
|
return values.join(", ");
|
|
26097
27056
|
}
|
|
26098
27057
|
async function requireMcpAccessGrant(c, deps, workspaceId) {
|
|
26099
|
-
const grant = await
|
|
27058
|
+
const grant = await requireAccessGrant25(c, deps, workspaceId);
|
|
26100
27059
|
if (hasPermission15(grant.permissions, "workspace:read")) {
|
|
26101
27060
|
return grant;
|
|
26102
27061
|
}
|
|
@@ -26695,4 +27654,4 @@ export {
|
|
|
26695
27654
|
withDefaultEnabledCapabilityMcpTools,
|
|
26696
27655
|
workflowIdForSession2 as workflowIdForSession
|
|
26697
27656
|
};
|
|
26698
|
-
//# sourceMappingURL=chunk-
|
|
27657
|
+
//# sourceMappingURL=chunk-HWXJW5C7.js.map
|