@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.
Files changed (40) hide show
  1. package/dist/access-grant-rls.d.ts +7 -0
  2. package/dist/app.d.ts +2 -2
  3. package/dist/app.js +1 -1
  4. package/dist/azure-live.d.ts +28 -0
  5. package/dist/{chunk-IZEGLLKZ.js → chunk-KKEXIBQG.js} +1188 -387
  6. package/dist/{chunk-IZEGLLKZ.js.map → chunk-KKEXIBQG.js.map} +1 -1
  7. package/dist/codemode.d.ts +3 -3
  8. package/dist/index.js +1 -1
  9. package/dist/integrations/oauth-profiles.d.ts +3 -1
  10. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  11. package/dist/mcp/server.d.ts +1 -1
  12. package/dist/transcription/billing-refusal.d.ts +8 -0
  13. package/dist/transcription/normalize.d.ts +23 -0
  14. package/dist/transcription/providers/azure-mai.d.ts +12 -0
  15. package/dist/transcription/providers/azure-openai.d.ts +4 -0
  16. package/dist/transcription/providers/openai.d.ts +16 -0
  17. package/dist/transcription/service.d.ts +10 -1
  18. package/dist/transcription/wav-duration.d.ts +8 -0
  19. package/package.json +20 -20
  20. package/src/access-grant-rls.ts +18 -10
  21. package/src/app.ts +45 -7
  22. package/src/azure-live.ts +103 -0
  23. package/src/codemode.ts +3 -5
  24. package/src/integrations/oauth-client.ts +1 -0
  25. package/src/integrations/oauth-profiles.ts +17 -6
  26. package/src/routes/editable-artifacts.ts +8 -0
  27. package/src/routes/session-artifact-associations.ts +80 -6
  28. package/src/routes/sessions.ts +185 -25
  29. package/src/routes/transcription-recordings.ts +71 -4
  30. package/src/routes/transcriptions.ts +26 -2
  31. package/src/routes/workspaces.ts +44 -7
  32. package/src/sandbox/channel-a.ts +22 -0
  33. package/src/transcription/billing-refusal.ts +14 -0
  34. package/src/transcription/normalize.ts +77 -0
  35. package/src/transcription/providers/azure-mai.ts +98 -0
  36. package/src/transcription/providers/azure-openai.ts +16 -7
  37. package/src/transcription/providers/openai.ts +79 -5
  38. package/src/transcription/segmenter.ts +7 -1
  39. package/src/transcription/service.ts +215 -10
  40. package/src/transcription/wav-duration.ts +39 -0
@@ -1,16 +1,26 @@
1
- import { resolveVoiceInputProviderRegistry, type Settings } from "@opengeni/config";
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 (await Promise.all(providers.map((provider) => provider.available(context)))).some(
73
- Boolean,
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: { text: string; languages: string[] };
300
+ let result: Awaited<ReturnType<TranscriptionProvider["transcribe"]>>;
139
301
  try {
140
302
  result = await provider.transcribe({
141
- audio: request.audio,
142
- mimeType,
143
- filename: filenameForMimeType(mimeType),
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
- ...result,
365
+ text: result.text,
366
+ languages: result.languages,
188
367
  providerId: provider.id,
189
- audioSeconds: request.durationSeconds ?? 0,
190
- latencyMs: Math.round(performance.now() - startedAt),
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
+ }