@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.
- 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-F3LQ6VWR.js} +1334 -486
- package/dist/{chunk-IZEGLLKZ.js.map → chunk-F3LQ6VWR.js.map} +1 -1
- package/dist/codemode.d.ts +3 -3
- package/dist/http/api-error.d.ts +14 -0
- package/dist/index.js +1 -1
- package/dist/integrations/oauth-profiles.d.ts +3 -1
- package/dist/routes/sessions.d.ts +2 -0
- 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 +62 -8
- package/src/azure-live.ts +103 -0
- package/src/codemode.ts +3 -5
- package/src/gateway-realtime.ts +11 -7
- package/src/http/api-error.ts +36 -1
- package/src/integrations/oauth-client.ts +9 -17
- 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 +263 -66
- package/src/routes/transcription-recordings.ts +71 -4
- package/src/routes/transcriptions.ts +26 -2
- package/src/routes/workspaces.ts +49 -7
- package/src/sandbox/channel-a.ts +22 -0
- package/src/transcription/billing-refusal.ts +14 -0
- package/src/transcription/normalize.ts +77 -0
- package/src/transcription/providers/azure-mai.ts +98 -0
- package/src/transcription/providers/azure-openai.ts +16 -7
- package/src/transcription/providers/openai.ts +79 -5
- package/src/transcription/segmenter.ts +7 -1
- package/src/transcription/service.ts +215 -10
- package/src/transcription/wav-duration.ts +39 -0
package/src/routes/sessions.ts
CHANGED
|
@@ -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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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({
|
|
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
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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:
|
|
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
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
|
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(
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
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
|
-
|
|
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(
|
|
6050
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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 {
|
|
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 (
|
|
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
|
}
|