@opengeni/api-router 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-grant-rls.d.ts +7 -0
- package/dist/app.d.ts +2 -2
- package/dist/app.js +1 -1
- package/dist/azure-live.d.ts +28 -0
- package/dist/{chunk-IZEGLLKZ.js → chunk-KKEXIBQG.js} +1188 -387
- package/dist/{chunk-IZEGLLKZ.js.map → chunk-KKEXIBQG.js.map} +1 -1
- package/dist/codemode.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/integrations/oauth-profiles.d.ts +3 -1
- package/dist/mcp/scheduled-task-view.d.ts +4 -4
- package/dist/mcp/server.d.ts +1 -1
- package/dist/transcription/billing-refusal.d.ts +8 -0
- package/dist/transcription/normalize.d.ts +23 -0
- package/dist/transcription/providers/azure-mai.d.ts +12 -0
- package/dist/transcription/providers/azure-openai.d.ts +4 -0
- package/dist/transcription/providers/openai.d.ts +16 -0
- package/dist/transcription/service.d.ts +10 -1
- package/dist/transcription/wav-duration.d.ts +8 -0
- package/package.json +20 -20
- package/src/access-grant-rls.ts +18 -10
- package/src/app.ts +45 -7
- package/src/azure-live.ts +103 -0
- package/src/codemode.ts +3 -5
- package/src/integrations/oauth-client.ts +1 -0
- package/src/integrations/oauth-profiles.ts +17 -6
- package/src/routes/editable-artifacts.ts +8 -0
- package/src/routes/session-artifact-associations.ts +80 -6
- package/src/routes/sessions.ts +185 -25
- package/src/routes/transcription-recordings.ts +71 -4
- package/src/routes/transcriptions.ts +26 -2
- package/src/routes/workspaces.ts +44 -7
- package/src/sandbox/channel-a.ts +22 -0
- package/src/transcription/billing-refusal.ts +14 -0
- package/src/transcription/normalize.ts +77 -0
- package/src/transcription/providers/azure-mai.ts +98 -0
- package/src/transcription/providers/azure-openai.ts +16 -7
- package/src/transcription/providers/openai.ts +79 -5
- package/src/transcription/segmenter.ts +7 -1
- package/src/transcription/service.ts +215 -10
- package/src/transcription/wav-duration.ts +39 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
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
|
-
? "
|
|
44
|
+
? requestedKind === "retained"
|
|
45
|
+
? "retained"
|
|
46
|
+
: "site"
|
|
38
47
|
: null;
|
|
39
|
-
const
|
|
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
|
-
:
|
|
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
|
+
}
|
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";
|
|
@@ -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({
|
|
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
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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:
|
|
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
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|