@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
|
@@ -1,16 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
resolveVoiceInputProviderRegistry,
|
|
3
|
+
voiceInputCreditBillingActive,
|
|
4
|
+
type Settings,
|
|
5
|
+
} from "@opengeni/config";
|
|
2
6
|
import { VOICE_INPUT_ACCEPTED_MIME_TYPES } from "@opengeni/contracts";
|
|
7
|
+
import { voiceTranscriptionSettlementKeys } from "@opengeni/db";
|
|
3
8
|
import {
|
|
9
|
+
createVoiceInputBilling,
|
|
4
10
|
filenameForMimeType,
|
|
5
11
|
isAcceptedMimeType,
|
|
6
12
|
normalizeMimeType,
|
|
7
13
|
TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS,
|
|
8
14
|
type TranscriptionAvailabilityContext,
|
|
15
|
+
type TranscriptionBilling,
|
|
16
|
+
type TranscriptionBillingContext,
|
|
9
17
|
type TranscriptionProvider,
|
|
10
18
|
type TranscriptionService,
|
|
11
19
|
TranscriptionServiceError,
|
|
12
20
|
} from "@opengeni/core";
|
|
13
21
|
import type { Database } from "@opengeni/db";
|
|
22
|
+
import { createFfmpegAudioNormalizer, type TranscriptionAudioNormalizer } from "./normalize";
|
|
23
|
+
import { createMaiTranscriptionProvider } from "./providers/azure-mai";
|
|
14
24
|
import { createAzureOpenAiTranscriptionProvider } from "./providers/azure-openai";
|
|
15
25
|
import { createCodexSubscriptionTranscriptionProvider } from "./providers/codex-subscription";
|
|
16
26
|
import { createOpenAiTranscriptionProvider } from "./providers/openai";
|
|
@@ -26,6 +36,14 @@ export function createTranscriptionService(input: {
|
|
|
26
36
|
providerRequestTimeoutMilliseconds?: number;
|
|
27
37
|
/** Test seam for evaluating persisted absolute deadlines after delayed setup. */
|
|
28
38
|
now?: () => Date;
|
|
39
|
+
/** Credit admission/settlement for deployment-funded providers. */
|
|
40
|
+
billing?: TranscriptionBilling;
|
|
41
|
+
/** Test seam: trusted one-shot decode + duration (defaults to ffmpeg). */
|
|
42
|
+
normalizeAudio?: TranscriptionAudioNormalizer;
|
|
43
|
+
/** Loud operator log for settlement failures after provider success. */
|
|
44
|
+
log?: (message: string, attributes: Record<string, string | number | boolean>) => void;
|
|
45
|
+
/** In-process settlement retry pacing after an inline failure. */
|
|
46
|
+
settlementRetryDelaysMilliseconds?: readonly number[];
|
|
29
47
|
}): TranscriptionService {
|
|
30
48
|
const providers: TranscriptionProvider[] = resolveVoiceInputProviderRegistry(input.settings).map(
|
|
31
49
|
(config) => {
|
|
@@ -35,6 +53,12 @@ export function createTranscriptionService(input: {
|
|
|
35
53
|
...config,
|
|
36
54
|
...(input.fetch ? { fetch: input.fetch } : {}),
|
|
37
55
|
});
|
|
56
|
+
case "azure-mai":
|
|
57
|
+
return createMaiTranscriptionProvider({
|
|
58
|
+
...config,
|
|
59
|
+
ffmpegPath: input.settings.voiceInputFfmpegPath,
|
|
60
|
+
...(input.fetch ? { fetch: input.fetch } : {}),
|
|
61
|
+
});
|
|
38
62
|
case "azure-openai":
|
|
39
63
|
return createAzureOpenAiTranscriptionProvider({
|
|
40
64
|
...config,
|
|
@@ -66,16 +90,119 @@ export function createTranscriptionService(input: {
|
|
|
66
90
|
const providerRequestTimeoutMilliseconds =
|
|
67
91
|
input.providerRequestTimeoutMilliseconds ?? TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS;
|
|
68
92
|
const now = input.now ?? (() => new Date());
|
|
93
|
+
const log =
|
|
94
|
+
input.log ??
|
|
95
|
+
((message: string, attributes: Record<string, string | number | boolean>) => {
|
|
96
|
+
console.error(message, attributes);
|
|
97
|
+
});
|
|
98
|
+
const billing =
|
|
99
|
+
input.billing ??
|
|
100
|
+
createVoiceInputBilling({
|
|
101
|
+
db: input.db,
|
|
102
|
+
settings: input.settings,
|
|
103
|
+
onReconcileError: (error, scope) => {
|
|
104
|
+
log("Voice input charge reconciliation failed", {
|
|
105
|
+
...scope,
|
|
106
|
+
errorClass: error instanceof Error ? error.name : "UnknownError",
|
|
107
|
+
error: boundedErrorMessage(error),
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
let normalizer: TranscriptionAudioNormalizer | undefined = input.normalizeAudio;
|
|
112
|
+
const normalizeAudio: TranscriptionAudioNormalizer = (request) => {
|
|
113
|
+
normalizer ??= createFfmpegAudioNormalizer({ ffmpegPath: input.settings.voiceInputFfmpegPath });
|
|
114
|
+
return normalizer(request);
|
|
115
|
+
};
|
|
116
|
+
const retryDelays = input.settlementRetryDelaysMilliseconds ?? [2_000, 15_000, 60_000];
|
|
117
|
+
type Settlement = Parameters<TranscriptionBilling["settle"]>[0];
|
|
118
|
+
/**
|
|
119
|
+
* Post-use settlement never fails the request: the provider already returned
|
|
120
|
+
* text the user is waiting for. A failure is logged with the exact
|
|
121
|
+
* idempotency keys and retried in-process; if the receipt committed but the
|
|
122
|
+
* debit did not, the next admission in the workspace reconciles it.
|
|
123
|
+
*/
|
|
124
|
+
const settleWithoutFailing = async (settlement: Settlement): Promise<number> => {
|
|
125
|
+
try {
|
|
126
|
+
return (await billing.settle(settlement)).creditCostMicros;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
reportUnsettled(settlement, error, 0, retryDelays.length > 0 && !deterministic(error));
|
|
129
|
+
if (!deterministic(error)) scheduleSettlementRetry(settlement, 0);
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const deterministic = (error: unknown) => error instanceof TranscriptionServiceError;
|
|
134
|
+
const scheduleSettlementRetry = (settlement: Settlement, attempt: number) => {
|
|
135
|
+
const delay = retryDelays[attempt];
|
|
136
|
+
if (delay === undefined) return;
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
void billing.settle(settlement).then(
|
|
139
|
+
() => {
|
|
140
|
+
log("Voice input charge settled on retry", {
|
|
141
|
+
...settlementAttributes(settlement),
|
|
142
|
+
attempt: attempt + 1,
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
(error: unknown) => {
|
|
146
|
+
const more = attempt + 1 < retryDelays.length;
|
|
147
|
+
reportUnsettled(settlement, error, attempt + 1, more);
|
|
148
|
+
if (more) scheduleSettlementRetry(settlement, attempt + 1);
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
}, delay);
|
|
152
|
+
(timer as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.();
|
|
153
|
+
};
|
|
154
|
+
const reportUnsettled = (
|
|
155
|
+
settlement: Settlement,
|
|
156
|
+
error: unknown,
|
|
157
|
+
attempt: number,
|
|
158
|
+
willRetry: boolean,
|
|
159
|
+
) => {
|
|
160
|
+
log(
|
|
161
|
+
willRetry
|
|
162
|
+
? "Voice input charge is unsettled after provider success; retrying"
|
|
163
|
+
: "Voice input charge is UNSETTLED after provider success; manual reconciliation needed unless its usage receipt committed",
|
|
164
|
+
{
|
|
165
|
+
...settlementAttributes(settlement),
|
|
166
|
+
attempt,
|
|
167
|
+
willRetry,
|
|
168
|
+
errorClass: error instanceof Error ? error.name : "UnknownError",
|
|
169
|
+
error: boundedErrorMessage(error),
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
const creditBillingActive = voiceInputCreditBillingActive(input.settings);
|
|
174
|
+
/** Deployment-funded calls are charged; the workspace's own subscriptions never are. */
|
|
175
|
+
const chargeable = (provider: TranscriptionProvider) =>
|
|
176
|
+
creditBillingActive && provider.deploymentFunded !== undefined;
|
|
69
177
|
return {
|
|
70
178
|
limits: () => limits,
|
|
71
179
|
async available(context) {
|
|
72
|
-
return (
|
|
73
|
-
|
|
180
|
+
return Boolean(
|
|
181
|
+
await firstAvailable(orderedProviders(providers, context ?? {}), context ?? {}),
|
|
74
182
|
);
|
|
75
183
|
},
|
|
184
|
+
async availableProviderIds(context) {
|
|
185
|
+
// Every ready provider, for the workspace provider picker. One failing
|
|
186
|
+
// probe means that provider is not ready; it never fails the caller.
|
|
187
|
+
const available = await Promise.all(
|
|
188
|
+
providers.map(async (provider) => {
|
|
189
|
+
try {
|
|
190
|
+
return (await provider.available(context)) ? provider.id : null;
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
return available.filter((id): id is string => id !== null);
|
|
197
|
+
},
|
|
76
198
|
async selectProvider(context) {
|
|
77
199
|
return (await firstAvailable(orderedProviders(providers, context), context))?.id ?? null;
|
|
78
200
|
},
|
|
201
|
+
async admit({ providerId, accountId, workspaceId, attribution }) {
|
|
202
|
+
const provider = providers.find((candidate) => candidate.id === providerId);
|
|
203
|
+
if (!provider || !chargeable(provider)) return;
|
|
204
|
+
await billing.admit({ accountId, workspaceId, attribution });
|
|
205
|
+
},
|
|
79
206
|
async transcribe(request) {
|
|
80
207
|
const mimeType = normalizeMimeType(request.mimeType);
|
|
81
208
|
if (!isAcceptedMimeType(mimeType, limits.acceptedMimeTypes)) {
|
|
@@ -120,6 +247,41 @@ export function createTranscriptionService(input: {
|
|
|
120
247
|
message: "Transcription provider does not support bounded requests.",
|
|
121
248
|
});
|
|
122
249
|
}
|
|
250
|
+
if (chargeable(provider)) {
|
|
251
|
+
// Fail closed: a deployment-paid call is never sent without a price
|
|
252
|
+
// and a trusted settlement identity.
|
|
253
|
+
if (!provider.deploymentFunded?.pricing || !request.billing) {
|
|
254
|
+
throw new TranscriptionServiceError({
|
|
255
|
+
code: "unavailable",
|
|
256
|
+
message: "Transcription billing is not configured.",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
await billing.admit({
|
|
260
|
+
accountId: request.accountId,
|
|
261
|
+
workspaceId: request.workspaceId,
|
|
262
|
+
attribution: request.billing.attribution,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
let audio = request.audio;
|
|
266
|
+
let providerMimeType = mimeType;
|
|
267
|
+
let billingContext: TranscriptionBillingContext | undefined = request.billing;
|
|
268
|
+
let audioSeconds = request.durationSeconds ?? 0;
|
|
269
|
+
if (chargeable(provider) && billingContext?.trustedDurationSeconds === undefined) {
|
|
270
|
+
// A deployment-paid one-shot upload is decoded by the server first, so
|
|
271
|
+
// the call always carries a duration the server measured from bytes it
|
|
272
|
+
// produced. That bills any usage shape we cannot price, and bounds
|
|
273
|
+
// what is sent upstream to the configured maximum recording length.
|
|
274
|
+
const normalized = await normalizeAudio({
|
|
275
|
+
audio,
|
|
276
|
+
mimeType,
|
|
277
|
+
maxDurationSeconds: limits.maxDurationSeconds,
|
|
278
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
279
|
+
});
|
|
280
|
+
audio = normalized.bytes;
|
|
281
|
+
providerMimeType = "audio/wav";
|
|
282
|
+
audioSeconds = normalized.durationSeconds;
|
|
283
|
+
billingContext = { ...billingContext!, trustedDurationSeconds: normalized.durationSeconds };
|
|
284
|
+
}
|
|
123
285
|
const startedAt = performance.now();
|
|
124
286
|
const remainingMilliseconds = request.providerDeadlineAt
|
|
125
287
|
? remainingTranscriptionProviderRequestMilliseconds(request.providerDeadlineAt, now())
|
|
@@ -135,12 +297,12 @@ export function createTranscriptionService(input: {
|
|
|
135
297
|
});
|
|
136
298
|
}
|
|
137
299
|
const deadline = createProviderRequestDeadline(request.signal, remainingMilliseconds);
|
|
138
|
-
let result:
|
|
300
|
+
let result: Awaited<ReturnType<TranscriptionProvider["transcribe"]>>;
|
|
139
301
|
try {
|
|
140
302
|
result = await provider.transcribe({
|
|
141
|
-
audio
|
|
142
|
-
mimeType,
|
|
143
|
-
filename: filenameForMimeType(
|
|
303
|
+
audio,
|
|
304
|
+
mimeType: providerMimeType,
|
|
305
|
+
filename: filenameForMimeType(providerMimeType),
|
|
144
306
|
workspaceId: request.workspaceId,
|
|
145
307
|
accountId: request.accountId,
|
|
146
308
|
subjectId: request.subjectId,
|
|
@@ -183,11 +345,30 @@ export function createTranscriptionService(input: {
|
|
|
183
345
|
} finally {
|
|
184
346
|
deadline.dispose();
|
|
185
347
|
}
|
|
348
|
+
const latencyMs = Math.round(performance.now() - startedAt);
|
|
349
|
+
let creditCostMicros = 0;
|
|
350
|
+
const settle = async () => {
|
|
351
|
+
if (chargeable(provider) && provider.deploymentFunded?.pricing && billingContext) {
|
|
352
|
+
creditCostMicros = await settleWithoutFailing({
|
|
353
|
+
accountId: request.accountId,
|
|
354
|
+
workspaceId: request.workspaceId,
|
|
355
|
+
providerId: provider.id,
|
|
356
|
+
model: provider.deploymentFunded.model,
|
|
357
|
+
pricing: provider.deploymentFunded.pricing,
|
|
358
|
+
usage: result.usage ?? null,
|
|
359
|
+
billing: billingContext,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
if (!request.deferBillingSettlement) await settle();
|
|
186
364
|
return {
|
|
187
|
-
|
|
365
|
+
text: result.text,
|
|
366
|
+
languages: result.languages,
|
|
188
367
|
providerId: provider.id,
|
|
189
|
-
audioSeconds
|
|
190
|
-
latencyMs
|
|
368
|
+
audioSeconds,
|
|
369
|
+
latencyMs,
|
|
370
|
+
creditCostMicros,
|
|
371
|
+
...(request.deferBillingSettlement ? { settleBilling: settle } : {}),
|
|
191
372
|
};
|
|
192
373
|
},
|
|
193
374
|
};
|
|
@@ -272,3 +453,27 @@ async function exactAvailable(
|
|
|
272
453
|
const provider = providers.find((candidate) => candidate.id === providerId);
|
|
273
454
|
return provider && (await provider.available(context)) ? provider : null;
|
|
274
455
|
}
|
|
456
|
+
|
|
457
|
+
function settlementAttributes(settlement: {
|
|
458
|
+
accountId: string;
|
|
459
|
+
workspaceId: string;
|
|
460
|
+
providerId: string;
|
|
461
|
+
billing: TranscriptionBillingContext;
|
|
462
|
+
}): Record<string, string> {
|
|
463
|
+
const keys = voiceTranscriptionSettlementKeys({
|
|
464
|
+
workspaceId: settlement.workspaceId,
|
|
465
|
+
sourceId: settlement.billing.sourceId,
|
|
466
|
+
});
|
|
467
|
+
return {
|
|
468
|
+
accountId: settlement.accountId,
|
|
469
|
+
workspaceId: settlement.workspaceId,
|
|
470
|
+
providerId: settlement.providerId,
|
|
471
|
+
sourceId: settlement.billing.sourceId,
|
|
472
|
+
usageIdempotencyKey: keys.usageIdempotencyKey,
|
|
473
|
+
debitIdempotencyKey: keys.debitIdempotencyKey,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function boundedErrorMessage(error: unknown): string {
|
|
478
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
|
479
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration of a PCM RIFF/WAVE buffer the server itself produced (resumable
|
|
3
|
+
* provider segments are ffmpeg-normalized WAV). Used only as the trusted
|
|
4
|
+
* billing fallback when a provider reports no usage; never applied to
|
|
5
|
+
* client-uploaded bytes. Returns null for anything that is not a well-formed
|
|
6
|
+
* WAV with a positive byte rate.
|
|
7
|
+
*/
|
|
8
|
+
export function wavDurationSeconds(bytes: Uint8Array): number | null {
|
|
9
|
+
if (bytes.byteLength < 12) return null;
|
|
10
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
11
|
+
if (ascii(bytes, 0) !== "RIFF" || ascii(bytes, 8) !== "WAVE") return null;
|
|
12
|
+
let offset = 12;
|
|
13
|
+
let byteRate: number | null = null;
|
|
14
|
+
while (offset + 8 <= bytes.byteLength) {
|
|
15
|
+
const id = ascii(bytes, offset);
|
|
16
|
+
const size = view.getUint32(offset + 4, true);
|
|
17
|
+
const body = offset + 8;
|
|
18
|
+
if (id === "fmt " && body + 12 <= bytes.byteLength) {
|
|
19
|
+
byteRate = view.getUint32(body + 8, true);
|
|
20
|
+
} else if (id === "data") {
|
|
21
|
+
if (!byteRate) return null;
|
|
22
|
+
// Streamed WAV writes a placeholder size; the actual payload is the rest.
|
|
23
|
+
const available = bytes.byteLength - body;
|
|
24
|
+
const dataBytes = size === 0 || size === 0xffffffff ? available : Math.min(size, available);
|
|
25
|
+
return dataBytes / byteRate;
|
|
26
|
+
}
|
|
27
|
+
offset = body + size + (size % 2);
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ascii(bytes: Uint8Array, offset: number): string {
|
|
33
|
+
return String.fromCharCode(
|
|
34
|
+
bytes[offset] ?? 0,
|
|
35
|
+
bytes[offset + 1] ?? 0,
|
|
36
|
+
bytes[offset + 2] ?? 0,
|
|
37
|
+
bytes[offset + 3] ?? 0,
|
|
38
|
+
);
|
|
39
|
+
}
|