@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,5 +1,6 @@
1
1
  import {
2
- requireAccessGrant,
2
+ fileOwnerContextForAccess,
3
+ requireAccessGrantAuthorization,
3
4
  requirePermission,
4
5
  requireSessionAuthorization,
5
6
  SessionAuthorizationDeniedError,
@@ -7,10 +8,15 @@ import {
7
8
  type ApiRouteDeps,
8
9
  } from "@opengeni/core";
9
10
  import {
11
+ getGeneratedImageArtifact,
12
+ getGeneratedVideoArtifact,
13
+ getRetainedFileArtifact,
10
14
  hasEditableArtifactSessionLink,
11
15
  hasWorkspaceArtifactSessionLink,
16
+ listArtifactCatalogCandidates,
12
17
  transactionallyAuthorizeEditableArtifactActor,
13
18
  withRlsContext,
19
+ withSessionRlsActorContext,
14
20
  } from "@opengeni/db";
15
21
  import type { Hono } from "hono";
16
22
  import { HTTPException } from "hono/http-exception";
@@ -27,16 +33,26 @@ export function registerSessionArtifactAssociationRoutes(app: Hono, deps: ApiRou
27
33
  "/v1/workspaces/:workspaceId/sessions/:sessionId/artifact-associations/:artifactId",
28
34
  async (context) => {
29
35
  const workspaceId = context.req.param("workspaceId");
30
- const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
31
- requirePermission(grant, "sessions:read");
32
36
  const sessionId = context.req.param("sessionId");
33
37
  const artifactId = context.req.param("artifactId");
38
+ const requestedKind = context.req.query("kind");
39
+ // A UUID names a Site unless the caller asks for a retained artifact
40
+ // (generated image or video, or a published sandbox file) explicitly.
34
41
  const kind = EditableArtifactId.safeParse(artifactId).success
35
42
  ? "editable"
36
43
  : SessionId.safeParse(artifactId).success
37
- ? "site"
44
+ ? requestedKind === "retained"
45
+ ? "retained"
46
+ : "site"
38
47
  : null;
39
- const requestedKind = context.req.query("kind");
48
+ const authorization = await requireAccessGrantAuthorization(
49
+ context,
50
+ deps,
51
+ workspaceId,
52
+ kind === "retained" ? "files:read" : "artifacts:read",
53
+ );
54
+ const grant = authorization.grant;
55
+ requirePermission(grant, "sessions:read");
40
56
  if (
41
57
  !SessionId.safeParse(sessionId).success ||
42
58
  !kind ||
@@ -55,7 +71,20 @@ export function registerSessionArtifactAssociationRoutes(app: Hono, deps: ApiRou
55
71
  const linked =
56
72
  kind === "editable"
57
73
  ? await hasEditableArtifactSessionLink(deps.db, scope, sessionId, artifactId)
58
- : await hasWorkspaceArtifactSessionLink(deps.db, workspaceId, sessionId, artifactId);
74
+ : kind === "retained"
75
+ ? await hasRetainedArtifactSessionLink(
76
+ deps,
77
+ authorization,
78
+ workspaceId,
79
+ sessionId,
80
+ artifactId.toLowerCase(),
81
+ )
82
+ : await hasWorkspaceArtifactSessionLink(
83
+ deps.db,
84
+ workspaceId,
85
+ sessionId,
86
+ artifactId,
87
+ );
59
88
  if (!linked) throw new HTTPException(404, { message: "Artifact association not found" });
60
89
  if (kind === "editable") {
61
90
  const decision = await withRlsContext(deps.db, scope, (tx) =>
@@ -85,3 +114,48 @@ export function registerSessionArtifactAssociationRoutes(app: Hono, deps: ApiRou
85
114
  },
86
115
  );
87
116
  }
117
+
118
+ /**
119
+ * Whether a retained artifact originated in this exact session: a generated
120
+ * image or video the session produced, or a sandbox file it published. Never
121
+ * inferred from an artifact id appearing in a message.
122
+ */
123
+ async function hasRetainedArtifactSessionLink(
124
+ deps: ApiRouteDeps,
125
+ authorization: Awaited<ReturnType<typeof requireAccessGrantAuthorization>>,
126
+ workspaceId: string,
127
+ sessionId: string,
128
+ artifactId: string,
129
+ ): Promise<boolean> {
130
+ const image = await getGeneratedImageArtifact(deps.db, workspaceId, artifactId);
131
+ if (image) return image.sessionId === sessionId;
132
+ const video = await getGeneratedVideoArtifact(deps.db, workspaceId, artifactId);
133
+ if (video) return !video.artifact.deletedAt && video.artifact.sessionId === sessionId;
134
+ const file = await getRetainedFileArtifact(deps.db, workspaceId, artifactId);
135
+ if (!file) return false;
136
+ // Publication receipts are private; the bounded catalog seam filters them
137
+ // by source session and this file's name, then the exact id must match.
138
+ const grant = authorization.grant;
139
+ const actor = await fileOwnerContextForAccess(deps, authorization, "files:read");
140
+ const candidates = await withSessionRlsActorContext(actor, () =>
141
+ listArtifactCatalogCandidates(
142
+ deps.db,
143
+ { accountId: grant.accountId, workspaceId },
144
+ {
145
+ query: {
146
+ sourceSessionId: sessionId,
147
+ q: file.file.filename.slice(0, 200),
148
+ sort: "newest",
149
+ status: "active",
150
+ limit: 100,
151
+ },
152
+ kinds: ["file", "image"],
153
+ snapshotAt: new Date().toISOString(),
154
+ limit: 201,
155
+ },
156
+ ),
157
+ );
158
+ return candidates.some(
159
+ (candidate) => candidate.origin === "sandbox_file" && candidate.id === artifactId,
160
+ );
161
+ }
@@ -1,3 +1,4 @@
1
+ import { AZURE_LIVE_MODEL_ID, buildSessionAzureLiveBroker } from "../azure-live";
1
2
  import { getRetainedProviderCommand } from "@opengeni/db/retained-provider-commands";
2
3
  import { assertGoalResumeAllowed, GoalResumeBlockedError } from "@opengeni/core";
3
4
  import { UnsupportedLatencyModeError } from "@opengeni/config";
@@ -9,6 +10,14 @@ import { SessionMessageSearchRequest } from "@opengeni/contracts";
9
10
  import { scheduledSessionIds } from "@opengeni/db";
10
11
  import { isVerifiedDelegatedHumanAuthorization, withSiteSessionOrigin } from "@opengeni/core";
11
12
  import { freezeSessionRealtimeConnectionAccounts } from "@opengeni/core";
13
+ import {
14
+ createRealtimeVoiceBilling,
15
+ RealtimeVoiceUnavailableError,
16
+ TranscriptionBillingRefusedError,
17
+ } from "@opengeni/core";
18
+ import type { CreditDebitAttribution } from "@opengeni/db";
19
+ import { creditDebitAttributionForGrant } from "../access-grant-rls";
20
+ import { transcriptionBillingRefusal } from "../transcription/billing-refusal";
12
21
  import { resolveSiteSessionOrigin, withOptionalSiteCommandOrigin } from "../site-session-origin";
13
22
  import { SandboxRecoveryRequest } from "@opengeni/contracts";
14
23
  import { getManagedHumanSandboxRecovery, consentManagedHumanSandboxRecovery } from "@opengeni/core";
@@ -376,6 +385,58 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
376
385
  observability: deps.observability,
377
386
  };
378
387
  const workspaceCaptureManifestCache = new WorkspaceCaptureManifestCache();
388
+ const realtimeVoiceBilling = createRealtimeVoiceBilling({ db, settings });
389
+ /** Credit refusal / unavailable deployment voice as an HTTP response, else null. */
390
+ const realtimeVoiceRefusalResponse = (c: Context, error: unknown): Response | null => {
391
+ if (error instanceof TranscriptionBillingRefusedError) {
392
+ return transcriptionBillingRefusal(c, error);
393
+ }
394
+ if (error instanceof RealtimeVoiceUnavailableError) {
395
+ return c.json(
396
+ {
397
+ error: {
398
+ status: 409,
399
+ code: "realtime_voice_unavailable",
400
+ message: error.message,
401
+ retryable: false,
402
+ details: { reason: error.code },
403
+ },
404
+ },
405
+ 409,
406
+ );
407
+ }
408
+ return null;
409
+ };
410
+ /**
411
+ * Bill every started minute observed so far for a deployment-funded call.
412
+ * Owner attribution only when the caller is the voice owner. A settlement
413
+ * failure is logged and retried idempotently on the next heartbeat.
414
+ */
415
+ const settleRealtimeVoice = async (input: {
416
+ grant: { subjectId: string };
417
+ attribution: CreditDebitAttribution;
418
+ workspaceId: string;
419
+ sessionId: string;
420
+ realtimeId: string;
421
+ }) => {
422
+ try {
423
+ return await realtimeVoiceBilling.settle({
424
+ workspaceId: input.workspaceId,
425
+ sessionId: input.sessionId,
426
+ realtimeId: input.realtimeId,
427
+ callerSubjectId: input.grant.subjectId,
428
+ attribution: input.attribution,
429
+ });
430
+ } catch (error) {
431
+ console.error("realtime voice settlement failed", {
432
+ workspaceId: input.workspaceId,
433
+ sessionId: input.sessionId,
434
+ realtimeId: input.realtimeId,
435
+ error: error instanceof Error ? error.message : String(error),
436
+ });
437
+ return null;
438
+ }
439
+ };
379
440
  const withSiteCommandOrigin = <T>(c: Context, workspaceId: string, run: () => Promise<T>) =>
380
441
  withOptionalSiteCommandOrigin(
381
442
  db,
@@ -1307,6 +1368,18 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1307
1368
  message: "invalid session realtime request",
1308
1369
  });
1309
1370
  }
1371
+ try {
1372
+ await realtimeVoiceBilling.admit({
1373
+ accountId: grant.accountId,
1374
+ workspaceId,
1375
+ model: parsed.data.model,
1376
+ attribution: creditDebitAttributionForGrant(grant),
1377
+ });
1378
+ } catch (error) {
1379
+ const refused = realtimeVoiceRefusalResponse(c, error);
1380
+ if (refused) return refused;
1381
+ throw error;
1382
+ }
1310
1383
  try {
1311
1384
  const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) => {
1312
1385
  const accounts = await freezeSessionRealtimeConnectionAccounts({
@@ -1355,6 +1428,16 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1355
1428
  });
1356
1429
  }
1357
1430
  try {
1431
+ // Settle observed minutes before renewing; out of credits keeps the
1432
+ // current lease so the call drains and ends instead of running free.
1433
+ const settlement = await settleRealtimeVoice({
1434
+ grant,
1435
+ attribution: creditDebitAttributionForGrant(grant),
1436
+ workspaceId,
1437
+ sessionId,
1438
+ realtimeId,
1439
+ });
1440
+ const stop = settlement?.stop ?? null;
1358
1441
  const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
1359
1442
  renewSessionRealtimeInTransaction(scopedDb, {
1360
1443
  workspaceId,
@@ -1362,11 +1445,18 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1362
1445
  realtimeId,
1363
1446
  ownerSubjectId: grant.subjectId,
1364
1447
  ...parsed.data,
1448
+ extendLease: stop === null,
1365
1449
  }),
1366
1450
  );
1367
1451
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1368
1452
  c.header("cache-control", "private, no-store");
1369
- return c.json({ mode: result.mode, replay: result.replay });
1453
+ return c.json({
1454
+ mode: result.mode,
1455
+ replay: result.replay,
1456
+ ...(stop && result.mode.state === "active"
1457
+ ? { stop: { code: stop.code, message: stop.message } }
1458
+ : {}),
1459
+ });
1370
1460
  } catch (error) {
1371
1461
  throw sessionRealtimeHttpError(error);
1372
1462
  }
@@ -1403,6 +1493,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1403
1493
  }),
1404
1494
  );
1405
1495
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1496
+ await settleRealtimeVoice({
1497
+ grant,
1498
+ attribution: creditDebitAttributionForGrant(grant),
1499
+ workspaceId,
1500
+ sessionId,
1501
+ realtimeId,
1502
+ });
1406
1503
  c.header("cache-control", "private, no-store");
1407
1504
  return c.json({ mode: result.mode, replay: result.replay });
1408
1505
  } catch (error) {
@@ -1438,21 +1535,34 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1438
1535
  ...providerRequest
1439
1536
  } = parsed.data;
1440
1537
  const claim = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
1441
- scopedDb.transaction(async (tx) =>
1442
- claimSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
1443
- workspaceId,
1444
- sessionId,
1445
- realtimeId,
1446
- operationId,
1447
- ownerSubjectId: grant.subjectId,
1448
- browserInstanceId,
1449
- ownerKey,
1450
- expectedVersion,
1451
- expectedConnectionEpoch,
1452
- rotate,
1453
- promotionMode: browserActivation === "required" ? "staged" : "legacy",
1454
- }),
1455
- ),
1538
+ scopedDb.transaction(async (tx) => {
1539
+ const claimed = await claimSessionRealtimeConnectionInTransaction(
1540
+ tx as unknown as Database,
1541
+ {
1542
+ workspaceId,
1543
+ sessionId,
1544
+ realtimeId,
1545
+ operationId,
1546
+ ownerSubjectId: grant.subjectId,
1547
+ browserInstanceId,
1548
+ ownerKey,
1549
+ expectedVersion,
1550
+ expectedConnectionEpoch,
1551
+ rotate,
1552
+ promotionMode: browserActivation === "required" ? "staged" : "legacy",
1553
+ },
1554
+ );
1555
+ if (
1556
+ claimed.mode.model !== "gpt-live-1-boulder-alpha" &&
1557
+ claimed.mode.model !== AZURE_LIVE_MODEL_ID
1558
+ ) {
1559
+ throw new CodexRealtimeBrokerError(
1560
+ "invalid_request",
1561
+ "This voice model does not use WebRTC negotiation",
1562
+ );
1563
+ }
1564
+ return claimed;
1565
+ }),
1456
1566
  );
1457
1567
  if (claim.replay) {
1458
1568
  if (
@@ -1487,7 +1597,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1487
1597
  return c.json({
1488
1598
  sdp: claim.connection.sdpAnswer,
1489
1599
  version: "v3" as const,
1490
- model: "gpt-live-1-boulder-alpha" as const,
1600
+ model: claim.mode.model,
1491
1601
  connectionId: claim.connection.id,
1492
1602
  connectionEpoch: claim.connection.connectionEpoch,
1493
1603
  startupFenceSequence: claim.connection.startupFenceSequence,
@@ -1495,13 +1605,36 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1495
1605
  replay: true,
1496
1606
  });
1497
1607
  }
1498
- const broker = buildSessionCodexRealtimeBroker(
1499
- db,
1500
- settings,
1501
- workspaceId,
1502
- sessionId,
1503
- deps.codexFetch,
1504
- );
1608
+ try {
1609
+ await realtimeVoiceBilling.admit({
1610
+ accountId: grant.accountId,
1611
+ workspaceId,
1612
+ model: claim.mode.model,
1613
+ attribution: creditDebitAttributionForGrant(grant),
1614
+ });
1615
+ } catch (error) {
1616
+ const refused = realtimeVoiceRefusalResponse(c, error);
1617
+ if (!refused) throw error;
1618
+ await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
1619
+ scopedDb.transaction(async (tx) =>
1620
+ failSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
1621
+ workspaceId,
1622
+ sessionId,
1623
+ realtimeId,
1624
+ connectionId: claim.connection.id,
1625
+ operationId,
1626
+ connectionEpoch: claim.connection.connectionEpoch,
1627
+ failureCode: "billing_refused",
1628
+ }),
1629
+ ),
1630
+ ).catch(() => undefined);
1631
+ return refused;
1632
+ }
1633
+ const broker = (
1634
+ claim.mode.model === AZURE_LIVE_MODEL_ID
1635
+ ? buildSessionAzureLiveBroker
1636
+ : buildSessionCodexRealtimeBroker
1637
+ )(db, settings, workspaceId, sessionId, deps.codexFetch);
1505
1638
  try {
1506
1639
  const answer = await broker({
1507
1640
  request: providerRequest,
@@ -1540,6 +1673,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1540
1673
  ),
1541
1674
  )
1542
1675
  : null;
1676
+ await settleRealtimeVoice({
1677
+ grant,
1678
+ attribution: creditDebitAttributionForGrant(grant),
1679
+ workspaceId,
1680
+ sessionId,
1681
+ realtimeId,
1682
+ });
1543
1683
  return c.json({
1544
1684
  ...answer,
1545
1685
  connectionId: completed.connection.id,
@@ -1640,6 +1780,12 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1640
1780
  "Realtime Gateway tokens are single-use; reconnect with a new operation",
1641
1781
  );
1642
1782
  }
1783
+ await realtimeVoiceBilling.admit({
1784
+ accountId: grant.accountId,
1785
+ workspaceId,
1786
+ model: claim.mode.model,
1787
+ attribution: creditDebitAttributionForGrant(grant),
1788
+ });
1643
1789
  const secret = await createGatewayRealtimeConnectionSecret({
1644
1790
  db,
1645
1791
  settings,
@@ -1663,6 +1809,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1663
1809
  ),
1664
1810
  );
1665
1811
  connectionCompleted = true;
1812
+ await settleRealtimeVoice({
1813
+ grant,
1814
+ attribution: creditDebitAttributionForGrant(grant),
1815
+ workspaceId,
1816
+ sessionId,
1817
+ realtimeId,
1818
+ });
1666
1819
  return c.json({
1667
1820
  ...secret,
1668
1821
  connectionId: completed.connection.id,
@@ -1684,11 +1837,18 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1684
1837
  operationId,
1685
1838
  connectionEpoch: claimed.connection.connectionEpoch,
1686
1839
  failureCode:
1687
- error instanceof GatewayRealtimeBrokerError ? error.code : "gateway_error",
1840
+ error instanceof GatewayRealtimeBrokerError
1841
+ ? error.code
1842
+ : error instanceof TranscriptionBillingRefusedError ||
1843
+ error instanceof RealtimeVoiceUnavailableError
1844
+ ? "billing_refused"
1845
+ : "gateway_error",
1688
1846
  }),
1689
1847
  ),
1690
1848
  ).catch(() => undefined);
1691
1849
  }
1850
+ const refused = realtimeVoiceRefusalResponse(c, error);
1851
+ if (refused) return refused;
1692
1852
  if (error instanceof SessionRealtimeConflictError) throw sessionRealtimeHttpError(error);
1693
1853
  if (!(error instanceof GatewayRealtimeBrokerError)) throw error;
1694
1854
  const status = error.code === "credential_unavailable" ? 409 : 502;
@@ -15,6 +15,7 @@ import {
15
15
  normalizeMimeType,
16
16
  requireAccessGrant,
17
17
  TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS,
18
+ TranscriptionBillingRefusedError,
18
19
  TranscriptionServiceError,
19
20
  type ApiRouteDeps,
20
21
  } from "@opengeni/core";
@@ -45,10 +46,13 @@ import {
45
46
  TranscriptionRecordingStateError,
46
47
  isSessionEventPersistenceError,
47
48
  } from "@opengeni/db";
48
- import { getWorkspace } from "@opengeni/db";
49
+ import { getWorkspace, type CreditDebitAttribution } from "@opengeni/db";
49
50
  import type { Context, Hono } from "hono";
51
+ import { creditDebitAttributionForGrant } from "../access-grant-rls";
50
52
  import { ApiHttpError } from "../http/api-error";
53
+ import { wavDurationSeconds } from "../transcription/wav-duration";
51
54
  import { TranscriptionSegmenterError } from "../transcription/segmenter";
55
+ import { transcriptionBillingRefusal } from "../transcription/billing-refusal";
52
56
 
53
57
  const CHUNK_SHA256_HEADER = "x-opengeni-chunk-sha256";
54
58
  const CHUNK_START_HEADER = "x-opengeni-chunk-start-milliseconds";
@@ -60,6 +64,8 @@ type RecordingAuthority = {
60
64
  workspaceId: string;
61
65
  subjectId: string;
62
66
  voiceInput?: WorkspaceVoiceInputSettings | undefined;
67
+ /** Trusted payer for deployment-funded segments. */
68
+ attribution: CreditDebitAttribution;
63
69
  };
64
70
 
65
71
  class RecordingProcessingError extends Error {
@@ -370,6 +376,8 @@ export function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDe
370
376
  !(await service.available({
371
377
  workspaceId: authority.workspaceId,
372
378
  subjectId: authority.subjectId,
379
+ preferredProvider: authority.voiceInput?.preferredProvider,
380
+ fallbackEnabled: authority.voiceInput?.fallbackEnabled,
373
381
  }))
374
382
  ) {
375
383
  return c.json({ code: "unavailable" }, 503);
@@ -420,6 +428,37 @@ export function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDe
420
428
  false,
421
429
  );
422
430
  }
431
+ const segmentProviderId =
432
+ claim.segment.providerId && claim.segment.providerId !== "host"
433
+ ? claim.segment.providerId
434
+ : null;
435
+ // Credit admission for the exact pinned provider, before the durable
436
+ // provider-start fence. A refusal releases the claim untouched (no
437
+ // audio sent) and surfaces 402 so the client stops retrying.
438
+ if (segmentProviderId && service.admit) {
439
+ try {
440
+ await service.admit({
441
+ providerId: segmentProviderId,
442
+ accountId: authority.accountId,
443
+ workspaceId: authority.workspaceId,
444
+ attribution: authority.attribution,
445
+ });
446
+ } catch (error) {
447
+ if (!(error instanceof TranscriptionBillingRefusedError)) throw error;
448
+ await failTranscriptionRecordingSegment(deps.db, {
449
+ workspaceId: authority.workspaceId,
450
+ subjectId: authority.subjectId,
451
+ recordingId: uuidParam(c, "recordingId"),
452
+ segmentNumber,
453
+ attemptId,
454
+ fallbackProviderId: null,
455
+ // The real refusal, so a reloaded recording still explains it.
456
+ errorCode: error.code,
457
+ retryable: true,
458
+ }).catch(() => null);
459
+ return transcriptionBillingRefusal(c, error);
460
+ }
461
+ }
423
462
  const providerStartedAt = new Date();
424
463
  const providerDeadlineAt = new Date(
425
464
  providerStartedAt.getTime() + TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS,
@@ -442,10 +481,22 @@ export function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDe
442
481
  durationSeconds: claim.segment.durationMilliseconds / 1_000,
443
482
  requestId: attemptId,
444
483
  providerDeadlineAt,
445
- ...(claim.segment.providerId && claim.segment.providerId !== "host"
446
- ? { providerId: claim.segment.providerId }
447
- : {}),
484
+ deferBillingSettlement: true,
485
+ ...(segmentProviderId ? { providerId: segmentProviderId } : {}),
486
+ billing: {
487
+ // One settlement per prepared segment of this exact recording,
488
+ // however many provider attempts it takes. The recording id is
489
+ // client-chosen, so the unit id also binds its server creation
490
+ // time and the server-produced segment bytes.
491
+ sourceId: `${claim.recording.recording.id}:${Date.parse(claim.recording.recording.createdAt)}:${segmentNumber}:${claim.segment.sha256}`,
492
+ attribution: authority.attribution,
493
+ trustedDurationSeconds: wavDurationSeconds(stored.bytes) ?? undefined,
494
+ },
448
495
  });
496
+ // The transcript commits first and on its own: a billing failure can
497
+ // never roll back text the user already waited for. Only the attempt
498
+ // that durably completed the segment settles it (a stale attempt is
499
+ // refused here and never charges); settlement itself never throws.
449
500
  const completed = await completeTranscriptionRecordingSegment(deps.db, {
450
501
  workspaceId: authority.workspaceId,
451
502
  subjectId: authority.subjectId,
@@ -456,6 +507,9 @@ export function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDe
456
507
  languages: result.languages,
457
508
  providerId: claim.segment.providerId ?? result.providerId,
458
509
  });
510
+ // Never rejects for the built-in service (it logs and retries); a
511
+ // custom service's failure must not turn a durable transcript into an error.
512
+ await result.settleBilling?.().catch(() => undefined);
459
513
  return c.json(await cleanupTerminalObjects(deps, authority, completed));
460
514
  } catch (error) {
461
515
  if (authority && attemptId && segmentNumber !== null) {
@@ -489,6 +543,11 @@ export function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDe
489
543
  errorCode: failure.code,
490
544
  retryable: failure.retryable,
491
545
  }).catch(() => null);
546
+ // A credit refusal sent no audio; the released segment stays
547
+ // retryable, but the client must see the definitive refusal.
548
+ if (error instanceof TranscriptionBillingRefusedError) {
549
+ return transcriptionBillingRefusal(c, error);
550
+ }
492
551
  if (persisted) return c.json(persisted);
493
552
  }
494
553
  return routeError(c, error);
@@ -545,6 +604,7 @@ async function requireRecordingAuthority(
545
604
  accountId: grant.accountId,
546
605
  workspaceId,
547
606
  subjectId: grant.subjectId,
607
+ attribution: creditDebitAttributionForGrant(grant),
548
608
  };
549
609
  }
550
610
 
@@ -641,6 +701,10 @@ function processingFailure(error: unknown): {
641
701
  if (error instanceof TranscriptionSegmenterError) {
642
702
  return { code: error.code, retryable: error.retryable };
643
703
  }
704
+ if (error instanceof TranscriptionBillingRefusedError) {
705
+ // Refused before any audio was sent; retryable once the payer is funded.
706
+ return { code: error.code, retryable: true };
707
+ }
644
708
  if (error instanceof TranscriptionServiceError) {
645
709
  return {
646
710
  code: error.code,
@@ -663,6 +727,9 @@ function routeError(c: Context, error: unknown): Response | Promise<Response> {
663
727
  if (error instanceof TranscriptionRecordingNotFoundError) {
664
728
  return c.json({ code: "not_found" }, 404);
665
729
  }
730
+ if (error instanceof TranscriptionBillingRefusedError) {
731
+ return transcriptionBillingRefusal(c, error);
732
+ }
666
733
  if (error instanceof TranscriptionRecordingConflictError) {
667
734
  return c.json({ code: "conflict" }, 409);
668
735
  }
@@ -3,9 +3,16 @@ import {
3
3
  WorkspaceVoiceInputSettings,
4
4
  type TranscribeAudioResponse,
5
5
  } from "@opengeni/contracts";
6
- import { type ApiRouteDeps, requireAccessGrant, TranscriptionServiceError } from "@opengeni/core";
6
+ import {
7
+ type ApiRouteDeps,
8
+ requireAccessGrant,
9
+ TranscriptionBillingRefusedError,
10
+ TranscriptionServiceError,
11
+ } from "@opengeni/core";
7
12
  import { getWorkspace } from "@opengeni/db";
8
13
  import type { Hono } from "hono";
14
+ import { creditDebitAttributionForGrant } from "../access-grant-rls";
15
+ import { transcriptionBillingRefusal } from "../transcription/billing-refusal";
9
16
  import { registerResumableTranscriptionRoutes } from "./transcription-recordings";
10
17
 
11
18
  export function registerTranscriptionRoutes(app: Hono, deps: ApiRouteDeps): void {
@@ -20,12 +27,26 @@ export function registerTranscriptionRoutes(app: Hono, deps: ApiRouteDeps): void
20
27
  }
21
28
  const preferences = WorkspaceVoiceInputSettings.safeParse(workspace.settings.voiceInput).data;
22
29
  const service = deps.transcription;
23
- if (!service || !(await service.available({ workspaceId, subjectId: grant.subjectId }))) {
30
+ if (
31
+ !service ||
32
+ !(await service.available({
33
+ workspaceId,
34
+ subjectId: grant.subjectId,
35
+ preferredProvider: preferences?.preferredProvider,
36
+ fallbackEnabled: preferences?.fallbackEnabled,
37
+ }))
38
+ ) {
24
39
  return c.json({ code: "unavailable" }, 503);
25
40
  }
26
41
  try {
27
42
  const body = await audioRequest(c.req.raw, service.limits().maxSizeBytes);
43
+ // Server-generated: the correlation id is client-chosen and a reused
44
+ // settlement key would make every later call with it free.
28
45
  const result = await service.transcribe({
46
+ billing: {
47
+ sourceId: crypto.randomUUID(),
48
+ attribution: creditDebitAttributionForGrant(grant),
49
+ },
29
50
  preferredProvider: preferences?.preferredProvider,
30
51
  fallbackEnabled: preferences?.fallbackEnabled,
31
52
  workspaceId,
@@ -46,6 +67,9 @@ export function registerTranscriptionRoutes(app: Hono, deps: ApiRouteDeps): void
46
67
  if (error instanceof TranscriptionServiceError) {
47
68
  return c.json({ code: error.code }, error.status as never);
48
69
  }
70
+ if (error instanceof TranscriptionBillingRefusedError) {
71
+ return transcriptionBillingRefusal(c, error);
72
+ }
49
73
  if (isAbort(error)) return c.json({ code: "cancelled" }, 499 as never);
50
74
  return c.json({ code: "unknown" }, 500);
51
75
  }