@opengeni/api-router 1.3.0 → 1.4.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.
Files changed (42) 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-F3LQ6VWR.js} +1334 -486
  6. package/dist/{chunk-IZEGLLKZ.js.map → chunk-F3LQ6VWR.js.map} +1 -1
  7. package/dist/codemode.d.ts +3 -3
  8. package/dist/http/api-error.d.ts +14 -0
  9. package/dist/index.js +1 -1
  10. package/dist/integrations/oauth-profiles.d.ts +3 -1
  11. package/dist/routes/sessions.d.ts +2 -0
  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 +62 -8
  22. package/src/azure-live.ts +103 -0
  23. package/src/codemode.ts +3 -5
  24. package/src/gateway-realtime.ts +11 -7
  25. package/src/http/api-error.ts +36 -1
  26. package/src/integrations/oauth-client.ts +9 -17
  27. package/src/integrations/oauth-profiles.ts +17 -6
  28. package/src/routes/editable-artifacts.ts +8 -0
  29. package/src/routes/session-artifact-associations.ts +80 -6
  30. package/src/routes/sessions.ts +263 -66
  31. package/src/routes/transcription-recordings.ts +71 -4
  32. package/src/routes/transcriptions.ts +26 -2
  33. package/src/routes/workspaces.ts +49 -7
  34. package/src/sandbox/channel-a.ts +22 -0
  35. package/src/transcription/billing-refusal.ts +14 -0
  36. package/src/transcription/normalize.ts +77 -0
  37. package/src/transcription/providers/azure-mai.ts +98 -0
  38. package/src/transcription/providers/azure-openai.ts +16 -7
  39. package/src/transcription/providers/openai.ts +79 -5
  40. package/src/transcription/segmenter.ts +7 -1
  41. package/src/transcription/service.ts +215 -10
  42. package/src/transcription/wav-duration.ts +39 -0
@@ -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";
@@ -6,9 +7,17 @@ import { listSessionEventSlices } from "@opengeni/db/session-event-slices";
6
7
  import * as sessionPreviewSchema from "@opengeni/db/schema";
7
8
  import { and, eq, sql } from "drizzle-orm";
8
9
  import { SessionMessageSearchRequest } from "@opengeni/contracts";
9
- import { scheduledSessionIds } from "@opengeni/db";
10
+ import { requireWorkspace, 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,
@@ -1131,22 +1192,34 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1131
1192
  if (!session) {
1132
1193
  throw new HTTPException(404, { message: "session not found" });
1133
1194
  }
1134
- const activity = await backgroundCommandActivityForSessions(db, {
1135
- accountId: grant.accountId,
1136
- workspaceId,
1137
- sessionIds: [sessionId],
1138
- });
1139
- const scheduleTargets =
1195
+ // Independent reads of the already-authorized session: the effective
1196
+ // policy context depends only on the session row, not on the activity or
1197
+ // schedule annotations, so all three are read concurrently.
1198
+ const [activity, scheduleTargets, policy] = await Promise.all([
1199
+ backgroundCommandActivityForSessions(db, {
1200
+ accountId: grant.accountId,
1201
+ workspaceId,
1202
+ sessionIds: [sessionId],
1203
+ }),
1140
1204
  hasPermission(grant.permissions, "scheduled_tasks:run") &&
1141
1205
  hasPermission(grant.permissions, "sessions:control")
1142
- ? await scheduledSessionIds(db, workspaceId, [sessionId])
1143
- : new Set<string>();
1206
+ ? scheduledSessionIds(db, workspaceId, [sessionId])
1207
+ : Promise.resolve(new Set<string>()),
1208
+ loadEffectivePolicyContext(deps, workspaceId, grant.subjectId, [session]),
1209
+ ]);
1144
1210
  return c.json(
1145
- await withEffectivePolicy(deps, workspaceId, grant.subjectId, {
1146
- ...session,
1147
- hasSchedules: scheduleTargets.has(sessionId),
1148
- ...(activity.get(sessionId) ? { backgroundCommandActivity: activity.get(sessionId) } : {}),
1149
- }),
1211
+ sessionWithEffectiveToolPolicy(
1212
+ {
1213
+ ...session,
1214
+ hasSchedules: scheduleTargets.has(sessionId),
1215
+ ...(activity.get(sessionId)
1216
+ ? { backgroundCommandActivity: activity.get(sessionId) }
1217
+ : {}),
1218
+ },
1219
+ policy.workspaceServerIds,
1220
+ policy.workspaceDefaultServerIds,
1221
+ policy.effectiveToolsContext,
1222
+ ),
1150
1223
  );
1151
1224
  });
1152
1225
 
@@ -1307,6 +1380,18 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1307
1380
  message: "invalid session realtime request",
1308
1381
  });
1309
1382
  }
1383
+ try {
1384
+ await realtimeVoiceBilling.admit({
1385
+ accountId: grant.accountId,
1386
+ workspaceId,
1387
+ model: parsed.data.model,
1388
+ attribution: creditDebitAttributionForGrant(grant),
1389
+ });
1390
+ } catch (error) {
1391
+ const refused = realtimeVoiceRefusalResponse(c, error);
1392
+ if (refused) return refused;
1393
+ throw error;
1394
+ }
1310
1395
  try {
1311
1396
  const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) => {
1312
1397
  const accounts = await freezeSessionRealtimeConnectionAccounts({
@@ -1355,6 +1440,16 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1355
1440
  });
1356
1441
  }
1357
1442
  try {
1443
+ // Settle observed minutes before renewing; out of credits keeps the
1444
+ // current lease so the call drains and ends instead of running free.
1445
+ const settlement = await settleRealtimeVoice({
1446
+ grant,
1447
+ attribution: creditDebitAttributionForGrant(grant),
1448
+ workspaceId,
1449
+ sessionId,
1450
+ realtimeId,
1451
+ });
1452
+ const stop = settlement?.stop ?? null;
1358
1453
  const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
1359
1454
  renewSessionRealtimeInTransaction(scopedDb, {
1360
1455
  workspaceId,
@@ -1362,11 +1457,18 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1362
1457
  realtimeId,
1363
1458
  ownerSubjectId: grant.subjectId,
1364
1459
  ...parsed.data,
1460
+ extendLease: stop === null,
1365
1461
  }),
1366
1462
  );
1367
1463
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1368
1464
  c.header("cache-control", "private, no-store");
1369
- return c.json({ mode: result.mode, replay: result.replay });
1465
+ return c.json({
1466
+ mode: result.mode,
1467
+ replay: result.replay,
1468
+ ...(stop && result.mode.state === "active"
1469
+ ? { stop: { code: stop.code, message: stop.message } }
1470
+ : {}),
1471
+ });
1370
1472
  } catch (error) {
1371
1473
  throw sessionRealtimeHttpError(error);
1372
1474
  }
@@ -1403,6 +1505,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1403
1505
  }),
1404
1506
  );
1405
1507
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1508
+ await settleRealtimeVoice({
1509
+ grant,
1510
+ attribution: creditDebitAttributionForGrant(grant),
1511
+ workspaceId,
1512
+ sessionId,
1513
+ realtimeId,
1514
+ });
1406
1515
  c.header("cache-control", "private, no-store");
1407
1516
  return c.json({ mode: result.mode, replay: result.replay });
1408
1517
  } catch (error) {
@@ -1438,21 +1547,34 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1438
1547
  ...providerRequest
1439
1548
  } = parsed.data;
1440
1549
  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
- ),
1550
+ scopedDb.transaction(async (tx) => {
1551
+ const claimed = await claimSessionRealtimeConnectionInTransaction(
1552
+ tx as unknown as Database,
1553
+ {
1554
+ workspaceId,
1555
+ sessionId,
1556
+ realtimeId,
1557
+ operationId,
1558
+ ownerSubjectId: grant.subjectId,
1559
+ browserInstanceId,
1560
+ ownerKey,
1561
+ expectedVersion,
1562
+ expectedConnectionEpoch,
1563
+ rotate,
1564
+ promotionMode: browserActivation === "required" ? "staged" : "legacy",
1565
+ },
1566
+ );
1567
+ if (
1568
+ claimed.mode.model !== "gpt-live-1-boulder-alpha" &&
1569
+ claimed.mode.model !== AZURE_LIVE_MODEL_ID
1570
+ ) {
1571
+ throw new CodexRealtimeBrokerError(
1572
+ "invalid_request",
1573
+ "This voice model does not use WebRTC negotiation",
1574
+ );
1575
+ }
1576
+ return claimed;
1577
+ }),
1456
1578
  );
1457
1579
  if (claim.replay) {
1458
1580
  if (
@@ -1487,7 +1609,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1487
1609
  return c.json({
1488
1610
  sdp: claim.connection.sdpAnswer,
1489
1611
  version: "v3" as const,
1490
- model: "gpt-live-1-boulder-alpha" as const,
1612
+ model: claim.mode.model,
1491
1613
  connectionId: claim.connection.id,
1492
1614
  connectionEpoch: claim.connection.connectionEpoch,
1493
1615
  startupFenceSequence: claim.connection.startupFenceSequence,
@@ -1495,13 +1617,36 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1495
1617
  replay: true,
1496
1618
  });
1497
1619
  }
1498
- const broker = buildSessionCodexRealtimeBroker(
1499
- db,
1500
- settings,
1501
- workspaceId,
1502
- sessionId,
1503
- deps.codexFetch,
1504
- );
1620
+ try {
1621
+ await realtimeVoiceBilling.admit({
1622
+ accountId: grant.accountId,
1623
+ workspaceId,
1624
+ model: claim.mode.model,
1625
+ attribution: creditDebitAttributionForGrant(grant),
1626
+ });
1627
+ } catch (error) {
1628
+ const refused = realtimeVoiceRefusalResponse(c, error);
1629
+ if (!refused) throw error;
1630
+ await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
1631
+ scopedDb.transaction(async (tx) =>
1632
+ failSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
1633
+ workspaceId,
1634
+ sessionId,
1635
+ realtimeId,
1636
+ connectionId: claim.connection.id,
1637
+ operationId,
1638
+ connectionEpoch: claim.connection.connectionEpoch,
1639
+ failureCode: "billing_refused",
1640
+ }),
1641
+ ),
1642
+ ).catch(() => undefined);
1643
+ return refused;
1644
+ }
1645
+ const broker = (
1646
+ claim.mode.model === AZURE_LIVE_MODEL_ID
1647
+ ? buildSessionAzureLiveBroker
1648
+ : buildSessionCodexRealtimeBroker
1649
+ )(db, settings, workspaceId, sessionId, deps.codexFetch);
1505
1650
  try {
1506
1651
  const answer = await broker({
1507
1652
  request: providerRequest,
@@ -1540,6 +1685,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1540
1685
  ),
1541
1686
  )
1542
1687
  : null;
1688
+ await settleRealtimeVoice({
1689
+ grant,
1690
+ attribution: creditDebitAttributionForGrant(grant),
1691
+ workspaceId,
1692
+ sessionId,
1693
+ realtimeId,
1694
+ });
1543
1695
  return c.json({
1544
1696
  ...answer,
1545
1697
  connectionId: completed.connection.id,
@@ -1640,6 +1792,12 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1640
1792
  "Realtime Gateway tokens are single-use; reconnect with a new operation",
1641
1793
  );
1642
1794
  }
1795
+ await realtimeVoiceBilling.admit({
1796
+ accountId: grant.accountId,
1797
+ workspaceId,
1798
+ model: claim.mode.model,
1799
+ attribution: creditDebitAttributionForGrant(grant),
1800
+ });
1643
1801
  const secret = await createGatewayRealtimeConnectionSecret({
1644
1802
  db,
1645
1803
  settings,
@@ -1663,6 +1821,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1663
1821
  ),
1664
1822
  );
1665
1823
  connectionCompleted = true;
1824
+ await settleRealtimeVoice({
1825
+ grant,
1826
+ attribution: creditDebitAttributionForGrant(grant),
1827
+ workspaceId,
1828
+ sessionId,
1829
+ realtimeId,
1830
+ });
1666
1831
  return c.json({
1667
1832
  ...secret,
1668
1833
  connectionId: completed.connection.id,
@@ -1684,11 +1849,18 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1684
1849
  operationId,
1685
1850
  connectionEpoch: claimed.connection.connectionEpoch,
1686
1851
  failureCode:
1687
- error instanceof GatewayRealtimeBrokerError ? error.code : "gateway_error",
1852
+ error instanceof GatewayRealtimeBrokerError
1853
+ ? error.code
1854
+ : error instanceof TranscriptionBillingRefusedError ||
1855
+ error instanceof RealtimeVoiceUnavailableError
1856
+ ? "billing_refused"
1857
+ : "gateway_error",
1688
1858
  }),
1689
1859
  ),
1690
1860
  ).catch(() => undefined);
1691
1861
  }
1862
+ const refused = realtimeVoiceRefusalResponse(c, error);
1863
+ if (refused) return refused;
1692
1864
  if (error instanceof SessionRealtimeConflictError) throw sessionRealtimeHttpError(error);
1693
1865
  if (!(error instanceof GatewayRealtimeBrokerError)) throw error;
1694
1866
  const status = error.code === "credential_unavailable" ? 409 : 502;
@@ -1897,29 +2069,37 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1897
2069
  });
1898
2070
  }
1899
2071
  try {
1900
- const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) => {
1901
- // Acquire origin authority before inference/session locks. A deferred
1902
- // realtime session has no initial worker turn; capture its exact
1903
- // linked actor when the ledger actually admits ordinary agent work.
1904
- await beforeCommit?.(scopedDb as unknown as Database);
1905
- return syncSessionRealtimeLedgerInTransaction(
1906
- scopedDb,
1907
- {
1908
- workspaceId,
1909
- sessionId,
1910
- realtimeId,
1911
- ownerSubjectId: grant.subjectId,
1912
- ...parsed.data,
1913
- controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
1914
- },
1915
- captureLinked
1916
- ? {
1917
- afterDelegationAdmission: async ({ turnId }) =>
1918
- captureLinked(scopedDb as unknown as Database, sessionId, turnId),
1919
- }
1920
- : {},
1921
- );
1922
- });
2072
+ const result = await withWorkspaceSessionActivityRls(
2073
+ db,
2074
+ workspaceId,
2075
+ async (scopedDb) => {
2076
+ // Acquire origin authority before inference/session locks. A deferred
2077
+ // realtime session has no initial worker turn; capture its exact
2078
+ // linked actor when the ledger actually admits ordinary agent work.
2079
+ await beforeCommit?.(scopedDb as unknown as Database);
2080
+ return syncSessionRealtimeLedgerInTransaction(
2081
+ scopedDb,
2082
+ {
2083
+ workspaceId,
2084
+ sessionId,
2085
+ realtimeId,
2086
+ ownerSubjectId: grant.subjectId,
2087
+ ...parsed.data,
2088
+ controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
2089
+ },
2090
+ captureLinked
2091
+ ? {
2092
+ afterDelegationAdmission: async ({ turnId }) =>
2093
+ captureLinked(scopedDb as unknown as Database, sessionId, turnId),
2094
+ }
2095
+ : {},
2096
+ );
2097
+ },
2098
+ undefined,
2099
+ // External reauthorization takes the organization-membership lock,
2100
+ // which precedes the tenancy fence and the canonical session prefix.
2101
+ Boolean(beforeCommit),
2102
+ );
1923
2103
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1924
2104
  c.header("cache-control", "private, no-store");
1925
2105
  return c.json({ accepted: result.accepted, outbound: result.outbound });
@@ -5021,11 +5201,19 @@ function codexRealtimeHttpFailure(error: CodexRealtimeBrokerError): {
5021
5201
  }
5022
5202
  }
5023
5203
 
5024
- function sessionRealtimeHttpError(error: unknown): HTTPException {
5204
+ /** Conflicts a person can hit by starting voice; the browser shows these verbatim. */
5205
+ const SESSION_REALTIME_USER_MESSAGES: Partial<Record<string, string>> = {
5206
+ REALTIME_ACTIVE:
5207
+ "Voice is already on for this session in another tab or window. End it there, or try again in a minute.",
5208
+ CONTROL_NOT_ACTIVE: "Resume this session to start voice.",
5209
+ SESSION_CANCELLED: "This session was cancelled, so voice can't start.",
5210
+ };
5211
+
5212
+ export function sessionRealtimeHttpError(error: unknown): HTTPException {
5025
5213
  if (error instanceof HTTPException) return error;
5026
5214
  if (error instanceof SessionRealtimeConflictError) {
5027
5215
  return new HTTPException(error.code === "REALTIME_NOT_FOUND" ? 404 : 409, {
5028
- message: error.message,
5216
+ message: SESSION_REALTIME_USER_MESSAGES[error.code] ?? error.message,
5029
5217
  cause: error,
5030
5218
  });
5031
5219
  }
@@ -6045,9 +6233,18 @@ async function loadEffectivePolicyContext(
6045
6233
  subjectId: string,
6046
6234
  sessions: readonly Session[],
6047
6235
  ): Promise<EffectivePolicyContext> {
6236
+ // Both projections read the same workspace row; hydrate it once.
6237
+ const workspaceRead = requireWorkspace(deps.db, workspaceId);
6238
+ void workspaceRead.catch(() => undefined);
6048
6239
  const [policy, effectiveToolsContext] = await Promise.all([
6049
- workspaceSessionToolPolicyContext(deps.db, workspaceId, deps.settings, subjectId),
6050
- workspaceSessionEffectiveToolsContext(deps, workspaceId, subjectId, sessions),
6240
+ workspaceSessionToolPolicyContext(
6241
+ deps.db,
6242
+ workspaceId,
6243
+ deps.settings,
6244
+ subjectId,
6245
+ workspaceRead,
6246
+ ),
6247
+ workspaceSessionEffectiveToolsContext(deps, workspaceId, subjectId, sessions, workspaceRead),
6051
6248
  ]);
6052
6249
  return { ...policy, effectiveToolsContext };
6053
6250
  }
@@ -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
  }