@opengeni/api-router 1.3.0 → 1.4.0
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/access-grant-rls.d.ts +7 -0
- package/dist/app.d.ts +2 -2
- package/dist/app.js +1 -1
- package/dist/azure-live.d.ts +28 -0
- package/dist/{chunk-IZEGLLKZ.js → chunk-KKEXIBQG.js} +1188 -387
- package/dist/{chunk-IZEGLLKZ.js.map → chunk-KKEXIBQG.js.map} +1 -1
- package/dist/codemode.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/integrations/oauth-profiles.d.ts +3 -1
- package/dist/mcp/scheduled-task-view.d.ts +4 -4
- package/dist/mcp/server.d.ts +1 -1
- package/dist/transcription/billing-refusal.d.ts +8 -0
- package/dist/transcription/normalize.d.ts +23 -0
- package/dist/transcription/providers/azure-mai.d.ts +12 -0
- package/dist/transcription/providers/azure-openai.d.ts +4 -0
- package/dist/transcription/providers/openai.d.ts +16 -0
- package/dist/transcription/service.d.ts +10 -1
- package/dist/transcription/wav-duration.d.ts +8 -0
- package/package.json +20 -20
- package/src/access-grant-rls.ts +18 -10
- package/src/app.ts +45 -7
- package/src/azure-live.ts +103 -0
- package/src/codemode.ts +3 -5
- package/src/integrations/oauth-client.ts +1 -0
- package/src/integrations/oauth-profiles.ts +17 -6
- package/src/routes/editable-artifacts.ts +8 -0
- package/src/routes/session-artifact-associations.ts +80 -6
- package/src/routes/sessions.ts +185 -25
- package/src/routes/transcription-recordings.ts +71 -4
- package/src/routes/transcriptions.ts +26 -2
- package/src/routes/workspaces.ts +44 -7
- package/src/sandbox/channel-a.ts +22 -0
- package/src/transcription/billing-refusal.ts +14 -0
- package/src/transcription/normalize.ts +77 -0
- package/src/transcription/providers/azure-mai.ts +98 -0
- package/src/transcription/providers/azure-openai.ts +16 -7
- package/src/transcription/providers/openai.ts +79 -5
- package/src/transcription/segmenter.ts +7 -1
- package/src/transcription/service.ts +215 -10
- package/src/transcription/wav-duration.ts +39 -0
package/src/routes/workspaces.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { AZURE_LIVE_MODEL_ID } from "../azure-live";
|
|
2
|
+
import {
|
|
3
|
+
createRealtimeVoiceBilling,
|
|
4
|
+
deploymentRealtimeVoice,
|
|
5
|
+
realtimeVoiceOfferProblem,
|
|
6
|
+
} from "@opengeni/core";
|
|
1
7
|
import { withDirectModelProviders } from "@opengeni/config";
|
|
2
8
|
import { listConnectionsMetadata } from "@opengeni/db";
|
|
3
9
|
import {
|
|
@@ -950,16 +956,47 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
950
956
|
: { available: false, unavailableReason: credentialReason };
|
|
951
957
|
};
|
|
952
958
|
const gatewayModels = Object.values(AI_GATEWAY_REALTIME_MODELS);
|
|
953
|
-
|
|
954
|
-
|
|
959
|
+
// Deployment-funded voice is listed only when this deployment can offer
|
|
960
|
+
// it (configured, and priced while credits are enforced). Hosted GPT Live
|
|
961
|
+
// replaces the managed Gateway choices when configured.
|
|
962
|
+
const managedCandidates = [
|
|
963
|
+
{
|
|
964
|
+
id: AZURE_LIVE_MODEL_ID,
|
|
965
|
+
label: "GPT Live 1",
|
|
966
|
+
description: "Realtime voice with session delegation",
|
|
967
|
+
},
|
|
968
|
+
...gatewayModels.map((model) => ({
|
|
955
969
|
id: model.managedModelId,
|
|
956
970
|
label: model.label,
|
|
957
|
-
provider: "OpenGeni" as const,
|
|
958
971
|
description: model.description,
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
)
|
|
972
|
+
})),
|
|
973
|
+
]
|
|
974
|
+
.filter((model) => {
|
|
975
|
+
const voice = deploymentRealtimeVoice(deps.settings, model.id);
|
|
976
|
+
return voice !== null && realtimeVoiceOfferProblem(deps.settings, voice) === null;
|
|
977
|
+
})
|
|
978
|
+
.filter((model, _index, offered) =>
|
|
979
|
+
offered.some((candidate) => candidate.id === AZURE_LIVE_MODEL_ID)
|
|
980
|
+
? model.id === AZURE_LIVE_MODEL_ID
|
|
981
|
+
: true,
|
|
982
|
+
);
|
|
983
|
+
const hasCredits =
|
|
984
|
+
managedCandidates.length > 0 &&
|
|
985
|
+
(await createRealtimeVoiceBilling({
|
|
986
|
+
db: deps.db,
|
|
987
|
+
settings: deps.settings,
|
|
988
|
+
}).hasSpendableCredits(grant.accountId));
|
|
989
|
+
const models = [
|
|
990
|
+
...managedCandidates.map((model, index) => ({
|
|
991
|
+
...model,
|
|
992
|
+
provider: "OpenGeni" as const,
|
|
993
|
+
...(hasCredits
|
|
994
|
+
? { available: true, unavailableReason: null, unavailableCode: null }
|
|
995
|
+
: {
|
|
996
|
+
available: false,
|
|
997
|
+
unavailableReason: "Add Opengeni credits to use live voice",
|
|
998
|
+
unavailableCode: "insufficient_credits",
|
|
999
|
+
}),
|
|
963
1000
|
recommended: index === 0,
|
|
964
1001
|
})),
|
|
965
1002
|
{
|
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
SandboxResumeIdentityMismatchError,
|
|
70
70
|
SandboxResumeIdentityUnavailableError,
|
|
71
71
|
RoutingActiveRouteChangedError,
|
|
72
|
+
RoutingMutationOutputRejectedError,
|
|
72
73
|
RoutingWorkspaceRootChangedError,
|
|
73
74
|
SelfhostedWorkspaceRootChangedError,
|
|
74
75
|
ChannelAConflictError,
|
|
@@ -1214,6 +1215,20 @@ export function mapChannelAError(error: unknown, waitSignal?: AbortSignal): unkn
|
|
|
1214
1215
|
error instanceof SandboxResumeIdentityUnavailableError
|
|
1215
1216
|
)
|
|
1216
1217
|
return new HTTPException(409, { message: error.message });
|
|
1218
|
+
if (error instanceof RoutingMutationOutputRejectedError) {
|
|
1219
|
+
const revoked = error.reasonCode === "authority_revoked";
|
|
1220
|
+
return new ApiHttpError(revoked ? 403 : 409, {
|
|
1221
|
+
code: revoked ? "forbidden" : "conflict",
|
|
1222
|
+
message: error.message,
|
|
1223
|
+
retryable: false,
|
|
1224
|
+
outcomeUnknown: false,
|
|
1225
|
+
details: {
|
|
1226
|
+
code: error.code,
|
|
1227
|
+
reasonCode: error.reasonCode,
|
|
1228
|
+
physicalOutcome: "resolved",
|
|
1229
|
+
},
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1217
1232
|
if (
|
|
1218
1233
|
error instanceof RoutingActiveRouteChangedError ||
|
|
1219
1234
|
error instanceof RoutingWorkspaceRootChangedError ||
|
|
@@ -1309,6 +1324,13 @@ export function channelAOperationFailureDiagnostic(
|
|
|
1309
1324
|
errorCode: "sandbox_channel_a_cancelled",
|
|
1310
1325
|
};
|
|
1311
1326
|
}
|
|
1327
|
+
if (error instanceof RoutingMutationOutputRejectedError) {
|
|
1328
|
+
return {
|
|
1329
|
+
reason: "request_rejected",
|
|
1330
|
+
status: error.reasonCode === "authority_revoked" ? 403 : 409,
|
|
1331
|
+
errorCode: "sandbox_channel_a_operation_failed",
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1312
1334
|
if (error instanceof SandboxProviderReadLockUnavailableError) {
|
|
1313
1335
|
return {
|
|
1314
1336
|
reason: "provider_read_busy",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TranscriptionBillingRefusedError } from "@opengeni/core";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Same body shape as session admission refusals (`code` + `message`, plus
|
|
6
|
+
* allowance `scope`/`resetsAt`), so SDK/React clients classify it as a
|
|
7
|
+
* definitive credit/allowance refusal rather than a retryable failure.
|
|
8
|
+
*/
|
|
9
|
+
export function transcriptionBillingRefusal(
|
|
10
|
+
c: Context,
|
|
11
|
+
error: TranscriptionBillingRefusedError,
|
|
12
|
+
): Response {
|
|
13
|
+
return c.json({ code: error.code, message: error.message, ...error.details }, error.status);
|
|
14
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { TranscriptionServiceError } from "@opengeni/core";
|
|
2
|
+
import { createFfmpegTranscriptionSegmenter, TranscriptionSegmenterError } from "./segmenter";
|
|
3
|
+
import { wavDurationSeconds } from "./wav-duration";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Decode one uploaded recording to 16 kHz mono PCM WAV with a hard decode
|
|
7
|
+
* ceiling, and measure its duration from the bytes the server produced.
|
|
8
|
+
*/
|
|
9
|
+
export type TranscriptionAudioNormalizer = (input: {
|
|
10
|
+
audio: Uint8Array;
|
|
11
|
+
mimeType: string;
|
|
12
|
+
/** Longest accepted recording; anything longer is refused, never truncated. */
|
|
13
|
+
maxDurationSeconds: number;
|
|
14
|
+
signal?: AbortSignal | undefined;
|
|
15
|
+
}) => Promise<{ bytes: Uint8Array; durationSeconds: number }>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ffmpeg decodes at most `maxDurationSeconds + 1` seconds (`-t`), so a long or
|
|
19
|
+
* hostile upload costs bounded CPU and disk. Output that reaches that ceiling
|
|
20
|
+
* was longer than the limit and is refused as `too_large` before any provider
|
|
21
|
+
* call, so the transcript the user receives is never silently cut short.
|
|
22
|
+
*/
|
|
23
|
+
export function createFfmpegAudioNormalizer(input: {
|
|
24
|
+
ffmpegPath: string;
|
|
25
|
+
}): TranscriptionAudioNormalizer {
|
|
26
|
+
const segmenter = createFfmpegTranscriptionSegmenter({ ffmpegPath: input.ffmpegPath });
|
|
27
|
+
return async ({ audio, mimeType, maxDurationSeconds, signal }) => {
|
|
28
|
+
if (!Number.isFinite(maxDurationSeconds) || maxDurationSeconds <= 0) {
|
|
29
|
+
throw new TranscriptionServiceError({
|
|
30
|
+
code: "unavailable",
|
|
31
|
+
message: "Voice input duration limit is not configured.",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const decodeSeconds = Math.ceil(maxDurationSeconds) + 1;
|
|
35
|
+
// One segment strictly longer than the decode ceiling: never a second file.
|
|
36
|
+
const segmentSeconds = decodeSeconds + 1;
|
|
37
|
+
let normalized: Uint8Array | undefined;
|
|
38
|
+
try {
|
|
39
|
+
for await (const segment of segmenter.segment({
|
|
40
|
+
sourceMimeType: mimeType,
|
|
41
|
+
chunks: (async function* () {
|
|
42
|
+
yield audio;
|
|
43
|
+
})(),
|
|
44
|
+
providerSegmentSeconds: segmentSeconds,
|
|
45
|
+
totalDurationMilliseconds: segmentSeconds * 1_000,
|
|
46
|
+
maxDecodeSeconds: decodeSeconds,
|
|
47
|
+
...(signal ? { signal } : {}),
|
|
48
|
+
})) {
|
|
49
|
+
if (normalized) {
|
|
50
|
+
throw new TranscriptionServiceError({ code: "too_large", message: "Audio is too long." });
|
|
51
|
+
}
|
|
52
|
+
normalized = segment.bytes;
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error instanceof TranscriptionSegmenterError) {
|
|
56
|
+
throw new TranscriptionServiceError({
|
|
57
|
+
code: error.code === "unknown" ? "invalid_audio" : error.code,
|
|
58
|
+
message: "Audio could not be prepared for transcription.",
|
|
59
|
+
retryable: error.retryable,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
const durationSeconds = normalized ? wavDurationSeconds(normalized) : null;
|
|
65
|
+
if (!normalized || durationSeconds === null || !(durationSeconds > 0)) {
|
|
66
|
+
throw new TranscriptionServiceError({
|
|
67
|
+
code: "invalid_audio",
|
|
68
|
+
message: "Audio could not be decoded.",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// Reaching the decode ceiling (within one 20 ms frame) means the source ran past it.
|
|
72
|
+
if (durationSeconds > decodeSeconds - 0.02) {
|
|
73
|
+
throw new TranscriptionServiceError({ code: "too_large", message: "Audio is too long." });
|
|
74
|
+
}
|
|
75
|
+
return { bytes: normalized, durationSeconds };
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { VoiceInputPricing } from "@opengeni/config";
|
|
2
|
+
import type { TranscriptionProvider } from "@opengeni/core";
|
|
3
|
+
import { createFfmpegAudioNormalizer } from "../normalize";
|
|
4
|
+
import { fetchError, responseError } from "./openai";
|
|
5
|
+
|
|
6
|
+
/** Azure Speech file transcription; credentials and model selection stay server-side. */
|
|
7
|
+
export function createMaiTranscriptionProvider(input: {
|
|
8
|
+
endpoint: string;
|
|
9
|
+
apiKey: string;
|
|
10
|
+
apiVersion: string;
|
|
11
|
+
model: string;
|
|
12
|
+
pricing?: VoiceInputPricing | null;
|
|
13
|
+
ffmpegPath: string;
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
}): TranscriptionProvider {
|
|
16
|
+
const fetchImpl = input.fetch ?? fetch;
|
|
17
|
+
const normalize = createFfmpegAudioNormalizer({ ffmpegPath: input.ffmpegPath });
|
|
18
|
+
return {
|
|
19
|
+
id: "azure-mai",
|
|
20
|
+
supportsServerDeadline: true,
|
|
21
|
+
deploymentFunded: { model: input.model, pricing: input.pricing ?? null },
|
|
22
|
+
available: () => Boolean(input.apiKey),
|
|
23
|
+
async transcribe({ audio, mimeType, filename, requestId, signal }) {
|
|
24
|
+
let bytes = audio;
|
|
25
|
+
let contentType = mimeType;
|
|
26
|
+
let name = filename;
|
|
27
|
+
// Resumable segments and credit-billed one-shot uploads already arrive
|
|
28
|
+
// as server-normalized WAV. Anything else (credit billing off) gets the
|
|
29
|
+
// same bounded decoder before entering Speech's file API.
|
|
30
|
+
if (!["audio/wav", "audio/x-wav", "audio/mpeg", "audio/flac"].includes(mimeType)) {
|
|
31
|
+
const normalized = await normalize({
|
|
32
|
+
audio,
|
|
33
|
+
mimeType,
|
|
34
|
+
maxDurationSeconds: 600,
|
|
35
|
+
...(signal ? { signal } : {}),
|
|
36
|
+
});
|
|
37
|
+
bytes = normalized.bytes;
|
|
38
|
+
contentType = "audio/wav";
|
|
39
|
+
name = "audio.wav";
|
|
40
|
+
}
|
|
41
|
+
const form = new FormData();
|
|
42
|
+
form.append("audio", new Blob([Uint8Array.from(bytes).buffer], { type: contentType }), name);
|
|
43
|
+
form.append(
|
|
44
|
+
"definition",
|
|
45
|
+
JSON.stringify({ enhancedMode: { enabled: true, model: input.model } }),
|
|
46
|
+
);
|
|
47
|
+
let response: Response;
|
|
48
|
+
try {
|
|
49
|
+
response = await fetchImpl(
|
|
50
|
+
`${input.endpoint.replace(/\/+$/, "")}/speechtotext/transcriptions:transcribe?api-version=${encodeURIComponent(input.apiVersion)}`,
|
|
51
|
+
{
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
"Ocp-Apim-Subscription-Key": input.apiKey,
|
|
55
|
+
"x-opengeni-request-id": requestId,
|
|
56
|
+
},
|
|
57
|
+
body: form,
|
|
58
|
+
...(signal ? { signal } : {}),
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw fetchError(error);
|
|
63
|
+
}
|
|
64
|
+
if (!response.ok) throw responseError(response.status);
|
|
65
|
+
const body = await response.json().catch(() => null);
|
|
66
|
+
if (
|
|
67
|
+
!body ||
|
|
68
|
+
!Array.isArray(body.combinedPhrases) ||
|
|
69
|
+
!body.combinedPhrases.every(
|
|
70
|
+
(phrase: unknown) =>
|
|
71
|
+
typeof phrase === "object" &&
|
|
72
|
+
phrase !== null &&
|
|
73
|
+
"text" in phrase &&
|
|
74
|
+
typeof phrase.text === "string",
|
|
75
|
+
)
|
|
76
|
+
) {
|
|
77
|
+
throw responseError(502);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
usage:
|
|
81
|
+
typeof body.durationMilliseconds === "number" &&
|
|
82
|
+
Number.isFinite(body.durationMilliseconds) &&
|
|
83
|
+
body.durationMilliseconds >= 0
|
|
84
|
+
? { kind: "duration" as const, seconds: body.durationMilliseconds / 1000 }
|
|
85
|
+
: null,
|
|
86
|
+
text: body.combinedPhrases.map((phrase: { text: string }) => phrase.text).join("\n"),
|
|
87
|
+
languages: [
|
|
88
|
+
...new Set<string>(
|
|
89
|
+
(Array.isArray(body.phrases) ? body.phrases : []).flatMap(
|
|
90
|
+
(phrase: { locale?: unknown }) =>
|
|
91
|
+
typeof phrase?.locale === "string" && phrase.locale ? [phrase.locale] : [],
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { voiceInputModelReportsDurationOnly, type VoiceInputPricing } from "@opengeni/config";
|
|
1
2
|
import type { TranscriptionProvider } from "@opengeni/core";
|
|
2
|
-
import { fetchError, responseError } from "./openai";
|
|
3
|
+
import { fetchError, parseTranscriptionResponseBody, responseError } from "./openai";
|
|
3
4
|
|
|
4
5
|
export function createAzureOpenAiTranscriptionProvider(input: {
|
|
5
6
|
endpoint: string;
|
|
@@ -7,17 +8,27 @@ export function createAzureOpenAiTranscriptionProvider(input: {
|
|
|
7
8
|
apiVersion: string;
|
|
8
9
|
apiKey: string | null;
|
|
9
10
|
adToken: string | null;
|
|
11
|
+
/** Underlying model of the deployment; defaults to the deployment name. */
|
|
12
|
+
model?: string;
|
|
13
|
+
pricing?: VoiceInputPricing | null;
|
|
10
14
|
fetch?: typeof fetch;
|
|
11
15
|
}): TranscriptionProvider {
|
|
12
16
|
const fetchImpl = input.fetch ?? fetch;
|
|
13
|
-
const
|
|
17
|
+
const model = input.model ?? input.deployment;
|
|
18
|
+
const endpoint = input.endpoint.replace(/\/+$/, "");
|
|
19
|
+
const url = `${endpoint}/openai/deployments/${encodeURIComponent(input.deployment)}/audio/transcriptions?api-version=${encodeURIComponent(input.apiVersion)}`;
|
|
14
20
|
return {
|
|
15
21
|
id: "azure-openai",
|
|
16
22
|
supportsServerDeadline: true,
|
|
23
|
+
deploymentFunded: { model, pricing: input.pricing ?? null },
|
|
17
24
|
available: () => Boolean(input.apiKey || input.adToken),
|
|
18
25
|
async transcribe({ audio, mimeType, filename, requestId, signal }) {
|
|
19
26
|
const form = new FormData();
|
|
20
27
|
form.append("file", new Blob([Uint8Array.from(audio).buffer], { type: mimeType }), filename);
|
|
28
|
+
// Whisper reports its billed audio duration only in verbose_json.
|
|
29
|
+
if (voiceInputModelReportsDurationOnly(model)) {
|
|
30
|
+
form.append("response_format", "verbose_json");
|
|
31
|
+
}
|
|
21
32
|
const headers: Record<string, string> = input.apiKey
|
|
22
33
|
? { "api-key": input.apiKey, "x-opengeni-request-id": requestId }
|
|
23
34
|
: { Authorization: `Bearer ${input.adToken}`, "x-opengeni-request-id": requestId };
|
|
@@ -34,13 +45,11 @@ export function createAzureOpenAiTranscriptionProvider(input: {
|
|
|
34
45
|
}
|
|
35
46
|
if (!response.ok) throw responseError(response.status);
|
|
36
47
|
const body = await response.json().catch(() => null);
|
|
37
|
-
|
|
48
|
+
const parsed = parseTranscriptionResponseBody(body);
|
|
49
|
+
if (!parsed) {
|
|
38
50
|
throw responseError(502);
|
|
39
51
|
}
|
|
40
|
-
return
|
|
41
|
-
text: body.text,
|
|
42
|
-
languages: typeof body.language === "string" && body.language ? [body.language] : [],
|
|
43
|
-
};
|
|
52
|
+
return parsed;
|
|
44
53
|
},
|
|
45
54
|
};
|
|
46
55
|
}
|
|
@@ -1,20 +1,31 @@
|
|
|
1
|
+
import {
|
|
2
|
+
voiceInputModelReportsDurationOnly,
|
|
3
|
+
type VoiceInputPricing,
|
|
4
|
+
type VoiceInputUsage,
|
|
5
|
+
} from "@opengeni/config";
|
|
1
6
|
import { type TranscriptionProvider, TranscriptionServiceError } from "@opengeni/core";
|
|
2
7
|
|
|
3
8
|
export function createOpenAiTranscriptionProvider(input: {
|
|
4
9
|
apiKey: string;
|
|
5
10
|
baseUrl: string;
|
|
6
11
|
model: string;
|
|
12
|
+
pricing?: VoiceInputPricing | null;
|
|
7
13
|
fetch?: typeof fetch;
|
|
8
14
|
}): TranscriptionProvider {
|
|
9
15
|
const fetchImpl = input.fetch ?? fetch;
|
|
10
16
|
return {
|
|
11
17
|
id: "openai",
|
|
12
18
|
supportsServerDeadline: true,
|
|
19
|
+
deploymentFunded: { model: input.model, pricing: input.pricing ?? null },
|
|
13
20
|
available: () => true,
|
|
14
21
|
async transcribe({ audio, mimeType, filename, requestId, signal }) {
|
|
15
22
|
const form = new FormData();
|
|
16
23
|
form.append("file", audioBlob(audio, mimeType), filename);
|
|
17
24
|
form.append("model", input.model);
|
|
25
|
+
// Whisper reports its billed audio duration only in verbose_json.
|
|
26
|
+
if (voiceInputModelReportsDurationOnly(input.model)) {
|
|
27
|
+
form.append("response_format", "verbose_json");
|
|
28
|
+
}
|
|
18
29
|
let response: Response;
|
|
19
30
|
try {
|
|
20
31
|
response = await fetchImpl(`${input.baseUrl}/audio/transcriptions`, {
|
|
@@ -32,20 +43,83 @@ export function createOpenAiTranscriptionProvider(input: {
|
|
|
32
43
|
}
|
|
33
44
|
if (!response.ok) throw responseError(response.status);
|
|
34
45
|
const body = await response.json().catch(() => null);
|
|
35
|
-
|
|
46
|
+
const parsed = parseTranscriptionResponseBody(body);
|
|
47
|
+
if (!parsed) {
|
|
36
48
|
throw new TranscriptionServiceError({
|
|
37
49
|
code: "provider",
|
|
38
50
|
message: "Invalid transcription response.",
|
|
39
51
|
});
|
|
40
52
|
}
|
|
41
|
-
return
|
|
42
|
-
text: body.text,
|
|
43
|
-
languages: typeof body.language === "string" && body.language ? [body.language] : [],
|
|
44
|
-
};
|
|
53
|
+
return parsed;
|
|
45
54
|
},
|
|
46
55
|
};
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
/**
|
|
59
|
+
* OpenAI-compatible `/audio/transcriptions` JSON (OpenAI and Azure OpenAI).
|
|
60
|
+
* `usage` is `{type:"tokens", input_tokens, input_token_details:{audio_tokens,
|
|
61
|
+
* text_tokens}, output_tokens, total_tokens}` for the gpt-4o-transcribe family
|
|
62
|
+
* or `{type:"duration", seconds}` for duration-billed models; Whisper
|
|
63
|
+
* `verbose_json` carries the processed audio `duration`. Unusable usage is
|
|
64
|
+
* dropped (null), never guessed.
|
|
65
|
+
*/
|
|
66
|
+
export function parseTranscriptionResponseBody(
|
|
67
|
+
body: unknown,
|
|
68
|
+
): { text: string; languages: string[]; usage: VoiceInputUsage | null } | null {
|
|
69
|
+
if (!body || typeof body !== "object") return null;
|
|
70
|
+
const record = body as Record<string, unknown>;
|
|
71
|
+
if (typeof record.text !== "string") return null;
|
|
72
|
+
const languages =
|
|
73
|
+
typeof record.language === "string" && record.language
|
|
74
|
+
? [record.language]
|
|
75
|
+
: Array.isArray(record.languages)
|
|
76
|
+
? record.languages.flatMap((language) => {
|
|
77
|
+
const code = typeof language === "string" ? language : language?.code;
|
|
78
|
+
return typeof code === "string" && code ? [code] : [];
|
|
79
|
+
})
|
|
80
|
+
: [];
|
|
81
|
+
return { text: record.text, languages, usage: parseTranscriptionUsage(record) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseTranscriptionUsage(body: Record<string, unknown>): VoiceInputUsage | null {
|
|
85
|
+
const usage = body.usage;
|
|
86
|
+
if (usage && typeof usage === "object") {
|
|
87
|
+
const record = usage as Record<string, unknown>;
|
|
88
|
+
if (record.type === "tokens") {
|
|
89
|
+
const inputTokens = count(record.input_tokens);
|
|
90
|
+
const outputTokens = count(record.output_tokens);
|
|
91
|
+
if (inputTokens !== null && outputTokens !== null) {
|
|
92
|
+
const details =
|
|
93
|
+
record.input_token_details && typeof record.input_token_details === "object"
|
|
94
|
+
? (record.input_token_details as Record<string, unknown>)
|
|
95
|
+
: {};
|
|
96
|
+
return {
|
|
97
|
+
kind: "tokens",
|
|
98
|
+
inputTokens,
|
|
99
|
+
audioInputTokens: count(details.audio_tokens) ?? 0,
|
|
100
|
+
textInputTokens: count(details.text_tokens) ?? 0,
|
|
101
|
+
outputTokens,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (record.type === "duration" && seconds(record.seconds) !== null) {
|
|
106
|
+
return { kind: "duration", seconds: seconds(record.seconds)! };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const duration = seconds(body.duration);
|
|
110
|
+
return duration === null ? null : { kind: "duration", seconds: duration };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function count(value: unknown): number | null {
|
|
114
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function seconds(value: unknown): number | null {
|
|
118
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value < 86_400
|
|
119
|
+
? value
|
|
120
|
+
: null;
|
|
121
|
+
}
|
|
122
|
+
|
|
49
123
|
function audioBlob(audio: Uint8Array, mimeType: string): Blob {
|
|
50
124
|
return new Blob([Uint8Array.from(audio).buffer], { type: mimeType });
|
|
51
125
|
}
|
|
@@ -41,7 +41,9 @@ export function createFfmpegTranscriptionSegmenter(input: {
|
|
|
41
41
|
!Number.isSafeInteger(request.providerSegmentSeconds) ||
|
|
42
42
|
request.providerSegmentSeconds <= 0 ||
|
|
43
43
|
!Number.isSafeInteger(request.totalDurationMilliseconds) ||
|
|
44
|
-
request.totalDurationMilliseconds <= 0
|
|
44
|
+
request.totalDurationMilliseconds <= 0 ||
|
|
45
|
+
(request.maxDecodeSeconds !== undefined &&
|
|
46
|
+
(!Number.isSafeInteger(request.maxDecodeSeconds) || request.maxDecodeSeconds <= 0))
|
|
45
47
|
) {
|
|
46
48
|
throw new TranscriptionSegmenterError(
|
|
47
49
|
"Audio segmentation bounds are invalid",
|
|
@@ -77,6 +79,10 @@ export function createFfmpegTranscriptionSegmenter(input: {
|
|
|
77
79
|
"-y",
|
|
78
80
|
"-i",
|
|
79
81
|
inputPath,
|
|
82
|
+
// Output-side ceiling: audio past it is never decoded or written.
|
|
83
|
+
...(request.maxDecodeSeconds !== undefined
|
|
84
|
+
? ["-t", String(request.maxDecodeSeconds)]
|
|
85
|
+
: []),
|
|
80
86
|
"-map",
|
|
81
87
|
"0:a:0",
|
|
82
88
|
"-vn",
|