@opengeni/core 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/application/modal-native-live-declaration-join.d.ts +37 -0
- package/dist/application/modal-native-original-configuration.d.ts +79 -0
- package/dist/domain/capabilities.d.ts +1 -0
- package/dist/domain/connector-tool-permissions.d.ts +1 -1
- package/dist/domain/realtime-voice-billing.d.ts +95 -0
- package/dist/domain/scheduled-task-access.d.ts +2 -2
- package/dist/domain/scheduled-tasks.d.ts +2 -2
- package/dist/domain/session-connection-accounts.d.ts +1 -1
- package/dist/domain/session-start-timing.d.ts +1 -1
- package/dist/domain/session-tool-policy.d.ts +11 -7
- package/dist/domain/voice-input-billing.d.ts +77 -0
- package/dist/domain/web-search-billing.d.ts +45 -0
- package/dist/goal-admission.d.ts +24 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1065 -365
- package/dist/index.js.map +1 -1
- package/dist/transcription.d.ts +119 -2
- package/package.json +12 -12
- package/src/application/modal-native-live-declaration-join.ts +196 -0
- package/src/application/modal-native-original-configuration.ts +261 -0
- package/src/default-session-model.ts +58 -88
- package/src/domain/capabilities.ts +44 -2
- package/src/domain/connector-tool-permissions.ts +24 -8
- package/src/domain/realtime-voice-billing.ts +400 -0
- package/src/domain/scheduled-task-access.ts +2 -1
- package/src/domain/scheduled-tasks.ts +3 -2
- package/src/domain/session-connection-accounts.ts +7 -3
- package/src/domain/session-start-timing.ts +5 -0
- package/src/domain/session-tool-policy.ts +20 -4
- package/src/domain/sessions.ts +145 -98
- package/src/domain/voice-input-billing.ts +315 -0
- package/src/domain/web-search-billing.ts +154 -0
- package/src/index.ts +3 -0
- package/src/model-catalog.ts +32 -38
- package/src/sandbox/routing.ts +59 -25
- package/src/transcription.ts +122 -3
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import {
|
|
3
|
+
applyCreditDebitAfterUse,
|
|
4
|
+
checkWorkspaceAllowance,
|
|
5
|
+
creditDebitAttributionForTurn,
|
|
6
|
+
creditDebitAttributionMetadata,
|
|
7
|
+
getSpendableCreditBalance,
|
|
8
|
+
recordUsageEvent,
|
|
9
|
+
withRlsContext,
|
|
10
|
+
type CreditDebitAttribution,
|
|
11
|
+
type Database,
|
|
12
|
+
} from "@opengeni/db";
|
|
13
|
+
|
|
14
|
+
/** Credit ledger type and usage source for deployment-funded web search calls. */
|
|
15
|
+
export const WEB_SEARCH_DEBIT_TYPE = "web_search_debit";
|
|
16
|
+
export const WEB_SEARCH_SOURCE_TYPE = "web_search";
|
|
17
|
+
const WEB_SEARCH_BILLING_INITIATOR = "worker:web-search";
|
|
18
|
+
|
|
19
|
+
/** Same rule as other deployment-funded resources: Stripe or managed limits. */
|
|
20
|
+
export function webSearchCreditBillingActive(
|
|
21
|
+
settings: Pick<Settings, "billingMode" | "usageLimitsMode">,
|
|
22
|
+
): boolean {
|
|
23
|
+
return settings.billingMode === "stripe" || settings.usageLimitsMode === "managed";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Exact attempt that called the tool. Every charge is attributed to its turn. */
|
|
27
|
+
export type WebSearchCallScope = {
|
|
28
|
+
accountId: string;
|
|
29
|
+
workspaceId: string;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
turnId: string;
|
|
32
|
+
attemptId: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type WebSearchCallCost = {
|
|
36
|
+
/** Model tool-call or Codemode operation identity, unique within the attempt. */
|
|
37
|
+
operationId: string;
|
|
38
|
+
operation: "search" | "fetch";
|
|
39
|
+
provider: string;
|
|
40
|
+
providerMicros: number;
|
|
41
|
+
creditMicros: number;
|
|
42
|
+
marginBps: number;
|
|
43
|
+
basis: "provider_reported" | "configured_price" | "list_price";
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export class WebSearchBillingRefusedError extends Error {
|
|
47
|
+
constructor(
|
|
48
|
+
readonly code: "insufficient_credits" | "allowance_exhausted",
|
|
49
|
+
message: string,
|
|
50
|
+
) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "WebSearchBillingRefusedError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Admission and post-use settlement for paid web search, matching paid
|
|
58
|
+
* Knowledge queries and voice input: admission reads general credits and the
|
|
59
|
+
* workspace/member allowance (a read, not a reservation); settlement records a
|
|
60
|
+
* durable usage receipt and the idempotent debit in one transaction. Calls
|
|
61
|
+
* that cost nothing (free providers, or billing inactive) are never refused.
|
|
62
|
+
*/
|
|
63
|
+
export function createWebSearchBilling(deps: { db: Database; settings: Settings }) {
|
|
64
|
+
const active = webSearchCreditBillingActive(deps.settings);
|
|
65
|
+
const attributionByTurn = new Map<string, Promise<CreditDebitAttribution>>();
|
|
66
|
+
const attributionFor = (scope: WebSearchCallScope) => {
|
|
67
|
+
let attribution = attributionByTurn.get(scope.turnId);
|
|
68
|
+
if (!attribution) {
|
|
69
|
+
attribution = creditDebitAttributionForTurn(deps.db, scope);
|
|
70
|
+
attributionByTurn.set(scope.turnId, attribution);
|
|
71
|
+
attribution.catch(() => attributionByTurn.delete(scope.turnId));
|
|
72
|
+
}
|
|
73
|
+
return attribution;
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
active,
|
|
77
|
+
async admit(scope: WebSearchCallScope, expectedProviderMicros: number): Promise<void> {
|
|
78
|
+
if (!active || expectedProviderMicros <= 0) return;
|
|
79
|
+
const attribution = await attributionFor(scope);
|
|
80
|
+
// Non-model resources spend general credits only.
|
|
81
|
+
const balance = await getSpendableCreditBalance(deps.db, scope.accountId);
|
|
82
|
+
if (balance.balanceMicros <= 0) {
|
|
83
|
+
throw new WebSearchBillingRefusedError(
|
|
84
|
+
"insufficient_credits",
|
|
85
|
+
"Web search needs Opengeni credits, and this account has none left.",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const refusal = await checkWorkspaceAllowance(deps.db, {
|
|
89
|
+
accountId: scope.accountId,
|
|
90
|
+
workspaceId: scope.workspaceId,
|
|
91
|
+
subjectId: attribution.kind === "turn" ? attribution.initiatingHumanSubjectId : null,
|
|
92
|
+
});
|
|
93
|
+
if (refusal) throw new WebSearchBillingRefusedError("allowance_exhausted", refusal.message);
|
|
94
|
+
},
|
|
95
|
+
async settle(scope: WebSearchCallScope, cost: WebSearchCallCost): Promise<void> {
|
|
96
|
+
const shared = {
|
|
97
|
+
accountId: scope.accountId,
|
|
98
|
+
workspaceId: scope.workspaceId,
|
|
99
|
+
sessionId: scope.sessionId,
|
|
100
|
+
turnId: scope.turnId,
|
|
101
|
+
turnAttemptId: scope.attemptId,
|
|
102
|
+
sourceResourceType: WEB_SEARCH_SOURCE_TYPE,
|
|
103
|
+
sourceResourceId: `${scope.attemptId}:${cost.operationId}`,
|
|
104
|
+
};
|
|
105
|
+
// Request counts are recorded for every deployment, billed or not, so
|
|
106
|
+
// operators see provider volume.
|
|
107
|
+
await recordUsageEvent(deps.db, {
|
|
108
|
+
...shared,
|
|
109
|
+
eventType: `web_search.${cost.operation}_requests`,
|
|
110
|
+
quantity: 1,
|
|
111
|
+
unit: "request",
|
|
112
|
+
idempotencyKey: `usage:web_search.${cost.operation}_requests:${shared.sourceResourceId}`,
|
|
113
|
+
});
|
|
114
|
+
if (!active || cost.creditMicros <= 0) return;
|
|
115
|
+
const attribution = await attributionFor(scope);
|
|
116
|
+
await withRlsContext(
|
|
117
|
+
deps.db,
|
|
118
|
+
{ accountId: scope.accountId, workspaceId: scope.workspaceId },
|
|
119
|
+
async (tx) => {
|
|
120
|
+
await recordUsageEvent(tx, {
|
|
121
|
+
...shared,
|
|
122
|
+
eventType: "web_search.cost",
|
|
123
|
+
quantity: cost.creditMicros,
|
|
124
|
+
unit: "usd_micros",
|
|
125
|
+
idempotencyKey: `usage:web_search.cost:${shared.sourceResourceId}`,
|
|
126
|
+
initiator: { kind: "service", subjectId: WEB_SEARCH_BILLING_INITIATOR },
|
|
127
|
+
initiatorContext: { creditDebitAttribution: attribution },
|
|
128
|
+
});
|
|
129
|
+
await applyCreditDebitAfterUse(tx, {
|
|
130
|
+
accountId: scope.accountId,
|
|
131
|
+
workspaceId: scope.workspaceId,
|
|
132
|
+
type: WEB_SEARCH_DEBIT_TYPE,
|
|
133
|
+
amountMicros: cost.creditMicros,
|
|
134
|
+
sourceType: WEB_SEARCH_SOURCE_TYPE,
|
|
135
|
+
sourceId: shared.sourceResourceId,
|
|
136
|
+
idempotencyKey: `credit:${WEB_SEARCH_DEBIT_TYPE}:${shared.sourceResourceId}`,
|
|
137
|
+
metadata: {
|
|
138
|
+
// turnId lets the allowance trigger resolve the causal human
|
|
139
|
+
// from the immutable turn receipt.
|
|
140
|
+
...creditDebitAttributionMetadata(attribution),
|
|
141
|
+
provider: cost.provider,
|
|
142
|
+
operation: cost.operation,
|
|
143
|
+
providerCostMicros: cost.providerMicros,
|
|
144
|
+
marginBps: cost.marginBps,
|
|
145
|
+
basis: cost.basis,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type WebSearchBilling = ReturnType<typeof createWebSearchBilling>;
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,9 @@ export * from "./domain/session-connection-accounts";
|
|
|
7
7
|
export * from "./domain/organization-integration-catalog";
|
|
8
8
|
export * from "./domain/knowledge";
|
|
9
9
|
export * from "./domain/knowledge-search";
|
|
10
|
+
export * from "./domain/voice-input-billing";
|
|
11
|
+
export * from "./domain/web-search-billing";
|
|
12
|
+
export * from "./domain/realtime-voice-billing";
|
|
10
13
|
// @opengeni/core — the framework-agnostic OpenGeni core.
|
|
11
14
|
//
|
|
12
15
|
// WHAT THIS PACKAGE IS: the OpenGeni domain, access, and billing layers carved
|
package/src/model-catalog.ts
CHANGED
|
@@ -39,18 +39,19 @@ import {
|
|
|
39
39
|
import {
|
|
40
40
|
listConnectionsMetadata,
|
|
41
41
|
getDeploymentModelCatalog,
|
|
42
|
-
|
|
42
|
+
listWorkspaceProviderCustomModelsByKind,
|
|
43
43
|
getWorkspaceProviderCustomModelForExecution,
|
|
44
44
|
lockActiveWorkspaceProviderCustomModelForAdmission,
|
|
45
45
|
getWorkspaceGatewayCustomModelForExecution,
|
|
46
46
|
getWorkspaceOpenRouterCustomModelForExecution,
|
|
47
47
|
getOrganizationModelProviderCustomModelForExecution,
|
|
48
|
-
|
|
49
|
-
listWorkspaceOpenRouterCustomModels,
|
|
50
|
-
listOrganizationModelProviderCustomModelsForWorkspace,
|
|
48
|
+
getOrganizationModelProviderCatalogForWorkspace,
|
|
51
49
|
lockActiveOrganizationModelProviderCustomModelForAdmission,
|
|
52
50
|
type OrganizationClaudeModelAdmissionAuthority,
|
|
53
51
|
type Database,
|
|
52
|
+
type WorkspaceCustomModelProviderKind,
|
|
53
|
+
type WorkspaceGatewayCustomModel,
|
|
54
|
+
type WorkspaceOpenRouterCustomModel,
|
|
54
55
|
} from "@opengeni/db";
|
|
55
56
|
|
|
56
57
|
export type ResolvedCatalogSettings = {
|
|
@@ -249,26 +250,34 @@ export async function resolveWorkspaceCatalogSettings(
|
|
|
249
250
|
? [productModelId.slice(ORGANIZATION_OPENROUTER_MODEL_ID_PREFIX.length)]
|
|
250
251
|
: [],
|
|
251
252
|
);
|
|
253
|
+
// One scoped read per catalog family instead of one scoped transaction per
|
|
254
|
+
// provider kind. The batched readers keep each provider's filters, bound and
|
|
255
|
+
// overflow error. The Claude subscription flag is env-only (the catalog
|
|
256
|
+
// document never changes it), so it decides the requested kinds up front.
|
|
257
|
+
const workspaceCustomModelKinds: WorkspaceCustomModelProviderKind[] = [
|
|
258
|
+
"vercel_gateway",
|
|
259
|
+
"openrouter",
|
|
260
|
+
...(supportsOrganizationProviderReads
|
|
261
|
+
? CLAUDE_CONNECTION_KINDS.filter(
|
|
262
|
+
(kind) => kind !== "claude_subscription" || envSettings.claudeSubscriptionEnabled,
|
|
263
|
+
)
|
|
264
|
+
: []),
|
|
265
|
+
];
|
|
252
266
|
const [
|
|
253
267
|
resolved,
|
|
254
|
-
|
|
255
|
-
activeOpenRouterCustomModels,
|
|
268
|
+
workspaceCustomModels,
|
|
256
269
|
retainedGatewayCustomModels,
|
|
257
270
|
retainedOpenRouterCustomModels,
|
|
258
|
-
|
|
259
|
-
organizationOpenRouterCustomModels,
|
|
271
|
+
organizationProviders,
|
|
260
272
|
retainedOrganizationGatewayCustomModels,
|
|
261
273
|
retainedOrganizationOpenRouterCustomModels,
|
|
262
274
|
directConnections,
|
|
263
275
|
] = await Promise.all([
|
|
264
276
|
resolveCatalogSettings(db, envSettings),
|
|
265
|
-
|
|
266
|
-
accountId: input.accountId,
|
|
267
|
-
workspaceId: input.workspaceId,
|
|
268
|
-
}),
|
|
269
|
-
listWorkspaceOpenRouterCustomModels(db, {
|
|
277
|
+
listWorkspaceProviderCustomModelsByKind(db, {
|
|
270
278
|
accountId: input.accountId,
|
|
271
279
|
workspaceId: input.workspaceId,
|
|
280
|
+
providerKinds: workspaceCustomModelKinds,
|
|
272
281
|
}),
|
|
273
282
|
Promise.all(
|
|
274
283
|
[...new Set(retainedGatewayUpstreamModelIds)].map(
|
|
@@ -291,19 +300,12 @@ export async function resolveWorkspaceCatalogSettings(
|
|
|
291
300
|
),
|
|
292
301
|
),
|
|
293
302
|
supportsOrganizationProviderReads
|
|
294
|
-
?
|
|
295
|
-
accountId: input.accountId,
|
|
296
|
-
workspaceId: input.workspaceId,
|
|
297
|
-
providerKind: "vercel_gateway",
|
|
298
|
-
})
|
|
299
|
-
: Promise.resolve([]),
|
|
300
|
-
supportsOrganizationProviderReads
|
|
301
|
-
? listOrganizationModelProviderCustomModelsForWorkspace(db, {
|
|
303
|
+
? getOrganizationModelProviderCatalogForWorkspace(db, {
|
|
302
304
|
accountId: input.accountId,
|
|
303
305
|
workspaceId: input.workspaceId,
|
|
304
|
-
|
|
306
|
+
providerKinds: ["vercel_gateway", "openrouter", ...CLAUDE_CONNECTION_KINDS],
|
|
305
307
|
})
|
|
306
|
-
:
|
|
308
|
+
: null,
|
|
307
309
|
supportsOrganizationProviderReads
|
|
308
310
|
? Promise.all(
|
|
309
311
|
[...new Set(retainedOrganizationGatewayUpstreamModelIds)].map(
|
|
@@ -352,29 +354,25 @@ export async function resolveWorkspaceCatalogSettings(
|
|
|
352
354
|
return customModels;
|
|
353
355
|
};
|
|
354
356
|
const gatewayCustomModels = includeRetainedModels(
|
|
355
|
-
|
|
357
|
+
workspaceCustomModels.vercel_gateway as WorkspaceGatewayCustomModel[],
|
|
356
358
|
retainedGatewayCustomModels,
|
|
357
359
|
);
|
|
358
360
|
const openRouterCustomModels = includeRetainedModels(
|
|
359
|
-
|
|
361
|
+
workspaceCustomModels.openrouter as WorkspaceOpenRouterCustomModel[],
|
|
360
362
|
retainedOpenRouterCustomModels,
|
|
361
363
|
);
|
|
362
364
|
const organizationGatewayModels = includeRetainedModels(
|
|
363
|
-
|
|
365
|
+
organizationProviders?.vercel_gateway.models ?? [],
|
|
364
366
|
retainedOrganizationGatewayCustomModels,
|
|
365
367
|
);
|
|
366
368
|
const organizationOpenRouterModels = includeRetainedModels(
|
|
367
|
-
|
|
369
|
+
organizationProviders?.openrouter.models ?? [],
|
|
368
370
|
retainedOrganizationOpenRouterCustomModels,
|
|
369
371
|
);
|
|
370
372
|
const claudeConnections: ClaudeConnectionCatalog = {};
|
|
371
|
-
if (
|
|
373
|
+
if (organizationProviders)
|
|
372
374
|
for (const kind of CLAUDE_CONNECTION_KINDS) {
|
|
373
|
-
const models =
|
|
374
|
-
accountId: input.accountId,
|
|
375
|
-
workspaceId: input.workspaceId,
|
|
376
|
-
providerKind: kind,
|
|
377
|
-
});
|
|
375
|
+
const models = [...organizationProviders[kind].models];
|
|
378
376
|
const prefix = claudeProviderId(kind) + "/";
|
|
379
377
|
for (const productId of retainedProductModelIds)
|
|
380
378
|
if (productId?.startsWith(prefix)) {
|
|
@@ -392,11 +390,7 @@ export async function resolveWorkspaceCatalogSettings(
|
|
|
392
390
|
if (supportsOrganizationProviderReads)
|
|
393
391
|
for (const kind of CLAUDE_CONNECTION_KINDS) {
|
|
394
392
|
if (kind === "claude_subscription" && !resolved.settings.claudeSubscriptionEnabled) continue;
|
|
395
|
-
const models =
|
|
396
|
-
accountId: input.accountId,
|
|
397
|
-
workspaceId: input.workspaceId,
|
|
398
|
-
providerKind: kind,
|
|
399
|
-
});
|
|
393
|
+
const models = [...workspaceCustomModels[kind]];
|
|
400
394
|
const prefix = claudeProviderId(kind, "workspace") + "/";
|
|
401
395
|
for (const productId of retainedProductModelIds) {
|
|
402
396
|
if (!productId?.startsWith(prefix)) continue;
|
package/src/sandbox/routing.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
readActiveSandbox,
|
|
29
29
|
retainWorkspaceMutationProcess,
|
|
30
30
|
SandboxRetainedProcessPromotionFencedError,
|
|
31
|
+
SandboxWorkspaceMutationOutputRejectedError,
|
|
31
32
|
retainedProcessSettlementIdentity,
|
|
32
33
|
settleRetainedProcess,
|
|
33
34
|
verifyDirectWorkspaceMutationSettlement,
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
NatsControlRpc,
|
|
44
45
|
NatsOpStreamTransport,
|
|
45
46
|
RoutingSandboxSession,
|
|
47
|
+
RoutingMutationOutputRejectedError,
|
|
46
48
|
resolveModalCheckpointProviderBindingForSession,
|
|
47
49
|
type ControlRpc,
|
|
48
50
|
type EstablishedSandboxSession,
|
|
@@ -359,22 +361,38 @@ export function wrapChannelABoxWithRouting(
|
|
|
359
361
|
throw new Error(
|
|
360
362
|
"Outcome-unknown command settlement requires its exact retained invocation",
|
|
361
363
|
);
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
364
|
+
try {
|
|
365
|
+
await verifyDirectWorkspaceMutationSettlement(db, {
|
|
366
|
+
accountId: ids.accountId,
|
|
367
|
+
workspaceId: ids.workspaceId,
|
|
368
|
+
sessionId: ids.sessionId,
|
|
369
|
+
requestId: ids.directRequest.requestId,
|
|
370
|
+
holderId: ids.directRequest.holderId,
|
|
371
|
+
initiatorSubjectId: ids.resourceSubjectId,
|
|
372
|
+
sandboxGroupId: homeLease.sandboxGroupId,
|
|
373
|
+
expectedEpoch: backend.leaseEpoch,
|
|
374
|
+
expectedInstanceId: backend.providerInstanceId,
|
|
375
|
+
routeTargetId: exactAdmission.routeTargetId,
|
|
376
|
+
routeEpoch: exactAdmission.routeEpoch,
|
|
377
|
+
admission: exactAdmission,
|
|
378
|
+
operation: op,
|
|
379
|
+
outcome,
|
|
380
|
+
});
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (
|
|
383
|
+
error instanceof SandboxWorkspaceMutationOutputRejectedError &&
|
|
384
|
+
error.matchesPhysicalSettlement({
|
|
385
|
+
accountId: ids.accountId,
|
|
386
|
+
workspaceId: ids.workspaceId,
|
|
387
|
+
admission: exactAdmission,
|
|
388
|
+
operation: op,
|
|
389
|
+
outcome,
|
|
390
|
+
})
|
|
391
|
+
) {
|
|
392
|
+
throw new RoutingMutationOutputRejectedError(op, error.code, { cause: error });
|
|
393
|
+
}
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
378
396
|
}
|
|
379
397
|
: undefined;
|
|
380
398
|
const beforeProcessMutation = homeLease
|
|
@@ -419,15 +437,31 @@ export function wrapChannelABoxWithRouting(
|
|
|
419
437
|
) {
|
|
420
438
|
throw new Error("API retained-process mutation settlement lacked its exact admission");
|
|
421
439
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
440
|
+
try {
|
|
441
|
+
await verifyRetainedProcessMutationSettlement(db, {
|
|
442
|
+
accountId: ids.accountId,
|
|
443
|
+
workspaceId: ids.workspaceId,
|
|
444
|
+
sessionId: ids.sessionId,
|
|
445
|
+
processId: process.id,
|
|
446
|
+
admission: admission as SandboxWorkspaceMutationAdmission,
|
|
447
|
+
operation: op,
|
|
448
|
+
outcome,
|
|
449
|
+
});
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (
|
|
452
|
+
error instanceof SandboxWorkspaceMutationOutputRejectedError &&
|
|
453
|
+
error.matchesPhysicalSettlement({
|
|
454
|
+
accountId: ids.accountId,
|
|
455
|
+
workspaceId: ids.workspaceId,
|
|
456
|
+
admission: admission as SandboxWorkspaceMutationAdmission,
|
|
457
|
+
operation: op,
|
|
458
|
+
outcome,
|
|
459
|
+
})
|
|
460
|
+
) {
|
|
461
|
+
throw new RoutingMutationOutputRejectedError(op, error.code, { cause: error });
|
|
462
|
+
}
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
431
465
|
}
|
|
432
466
|
: undefined;
|
|
433
467
|
const settleProcess = homeLease
|
package/src/transcription.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { VoiceInputPricing, VoiceInputUsage } from "@opengeni/config";
|
|
1
2
|
import type { TranscribeAudioResponse, VoiceInputErrorCode } from "@opengeni/contracts";
|
|
3
|
+
import type { CreditDebitAttribution } from "@opengeni/db";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Server-owned upstream budget for one provider attempt. Resumable recording
|
|
@@ -34,6 +36,39 @@ export type TranscriptionRequest = {
|
|
|
34
36
|
preferredProvider?: string | null | undefined;
|
|
35
37
|
fallbackEnabled?: boolean | undefined;
|
|
36
38
|
excludedProviders?: readonly string[] | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Credit settlement facts for a deployment-funded provider. Required when
|
|
41
|
+
* the selected provider is deployment-funded and credit billing is active;
|
|
42
|
+
* the service fails closed without it.
|
|
43
|
+
*/
|
|
44
|
+
billing?: TranscriptionBillingContext | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Internal: return `settleBilling` instead of settling before returning, so
|
|
47
|
+
* a resumable segment's transcript commits before any billing write.
|
|
48
|
+
*/
|
|
49
|
+
deferBillingSettlement?: boolean;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Trusted, server-built settlement identity for one transcription unit. The
|
|
54
|
+
* usage receipt and debit idempotency keys derive from `workspaceId` +
|
|
55
|
+
* `sourceId` (see `voiceInputSettlementKeys`), so a retry or a later
|
|
56
|
+
* reconciliation settles the same unit exactly once.
|
|
57
|
+
*/
|
|
58
|
+
export type TranscriptionBillingContext = {
|
|
59
|
+
/**
|
|
60
|
+
* Server-derived unit identity. Never a client-chosen value: a reused id
|
|
61
|
+
* settles once, so a client-controlled id would make later calls free.
|
|
62
|
+
*/
|
|
63
|
+
sourceId: string;
|
|
64
|
+
/** Trusted payer facts from the authenticated request boundary. */
|
|
65
|
+
attribution: CreditDebitAttribution;
|
|
66
|
+
/**
|
|
67
|
+
* Audio duration measured by the server from WAV bytes it produced (the
|
|
68
|
+
* resumable segment, or the normalized one-shot upload). Bills whenever the
|
|
69
|
+
* provider reports no usage this deployment has a price for.
|
|
70
|
+
*/
|
|
71
|
+
trustedDurationSeconds?: number | undefined;
|
|
37
72
|
};
|
|
38
73
|
|
|
39
74
|
export type TranscriptionResult = TranscribeAudioResponse & {
|
|
@@ -41,17 +76,79 @@ export type TranscriptionResult = TranscribeAudioResponse & {
|
|
|
41
76
|
providerId: string;
|
|
42
77
|
audioSeconds: number;
|
|
43
78
|
latencyMs: number;
|
|
79
|
+
/** Opengeni credits charged for this call (0 for subscription/free providers). */
|
|
80
|
+
creditCostMicros?: number | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* Server-only settlement callback; never serialized into a public response.
|
|
83
|
+
* Never rejects: a settlement failure after provider success is logged and
|
|
84
|
+
* retried, and the transcript is still delivered.
|
|
85
|
+
*/
|
|
86
|
+
settleBilling?: () => Promise<void>;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type TranscriptionBillingRefusalCode =
|
|
90
|
+
| "insufficient_credits"
|
|
91
|
+
| "allowance_exhausted"
|
|
92
|
+
| "monthly_model_cost_limit";
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Pre-use refusal for deployment-funded transcription: 402 for credits and
|
|
96
|
+
* allowances (same codes as session admission), 429 for a static monthly cap.
|
|
97
|
+
*/
|
|
98
|
+
export class TranscriptionBillingRefusedError extends Error {
|
|
99
|
+
readonly status: 402 | 429;
|
|
100
|
+
readonly code: TranscriptionBillingRefusalCode;
|
|
101
|
+
readonly details: { scope?: string; resetsAt?: string | null; subjectId?: string };
|
|
102
|
+
|
|
103
|
+
constructor(input: {
|
|
104
|
+
code: TranscriptionBillingRefusalCode;
|
|
105
|
+
message: string;
|
|
106
|
+
details?: { scope?: string; resetsAt?: string | null; subjectId?: string };
|
|
107
|
+
}) {
|
|
108
|
+
super(input.message);
|
|
109
|
+
this.name = "TranscriptionBillingRefusedError";
|
|
110
|
+
this.code = input.code;
|
|
111
|
+
this.status = input.code === "monthly_model_cost_limit" ? 429 : 402;
|
|
112
|
+
this.details = input.details ?? {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Credit admission and settlement for deployment-funded transcription.
|
|
118
|
+
* Subscription providers (Codex, SuperGrok) never reach this port.
|
|
119
|
+
*/
|
|
120
|
+
export type TranscriptionBilling = {
|
|
121
|
+
/** Refuse before any audio is sent when the payer cannot fund the call. */
|
|
122
|
+
admit(input: {
|
|
123
|
+
accountId: string;
|
|
124
|
+
workspaceId: string;
|
|
125
|
+
attribution: CreditDebitAttribution;
|
|
126
|
+
}): Promise<void>;
|
|
127
|
+
/**
|
|
128
|
+
* Record usage and debit credits once per unit, after use. The usage receipt
|
|
129
|
+
* commits before the debit, so a failed debit leaves a durable receipt that
|
|
130
|
+
* `admit` reconciles on the workspace's next voice request.
|
|
131
|
+
*/
|
|
132
|
+
settle(input: {
|
|
133
|
+
accountId: string;
|
|
134
|
+
workspaceId: string;
|
|
135
|
+
providerId: string;
|
|
136
|
+
model: string;
|
|
137
|
+
pricing: VoiceInputPricing;
|
|
138
|
+
usage: VoiceInputUsage | null;
|
|
139
|
+
billing: TranscriptionBillingContext;
|
|
140
|
+
}): Promise<{ creditCostMicros: number }>;
|
|
44
141
|
};
|
|
45
142
|
|
|
46
143
|
export class TranscriptionServiceError extends Error {
|
|
47
|
-
readonly code: VoiceInputErrorCode
|
|
144
|
+
readonly code: Exclude<VoiceInputErrorCode, TranscriptionBillingRefusalCode>;
|
|
48
145
|
readonly status: number;
|
|
49
146
|
readonly retryable: boolean;
|
|
50
147
|
/** Explicit rejection before any transcription result; safe to try another provider. */
|
|
51
148
|
readonly fallbackSafe: boolean;
|
|
52
149
|
|
|
53
150
|
constructor(input: {
|
|
54
|
-
code: VoiceInputErrorCode
|
|
151
|
+
code: Exclude<VoiceInputErrorCode, TranscriptionBillingRefusalCode>;
|
|
55
152
|
message: string;
|
|
56
153
|
status?: number;
|
|
57
154
|
retryable?: boolean;
|
|
@@ -114,6 +211,14 @@ export type TranscriptionProvider = {
|
|
|
114
211
|
/** The adapter guarantees that its upstream transport honors AbortSignal. */
|
|
115
212
|
readonly supportsServerDeadline: true;
|
|
116
213
|
readonly experimental?: boolean | undefined;
|
|
214
|
+
/**
|
|
215
|
+
* Present when the deployment pays the upstream provider (OpenAI/Azure):
|
|
216
|
+
* the call is admitted against and settled in Opengeni credits. Absent for
|
|
217
|
+
* the workspace's own subscriptions, which are never charged.
|
|
218
|
+
*/
|
|
219
|
+
readonly deploymentFunded?:
|
|
220
|
+
| { readonly model: string; readonly pricing: VoiceInputPricing | null }
|
|
221
|
+
| undefined;
|
|
117
222
|
/**
|
|
118
223
|
* Deployment readiness when called without a workspace. When `workspaceId` is
|
|
119
224
|
* provided, providers may require a workspace-attached credential (e.g. Codex).
|
|
@@ -128,17 +233,29 @@ export type TranscriptionProvider = {
|
|
|
128
233
|
subjectId: string;
|
|
129
234
|
requestId: string;
|
|
130
235
|
signal?: AbortSignal | undefined;
|
|
131
|
-
}): Promise<{ text: string; languages: string[] }>;
|
|
236
|
+
}): Promise<{ text: string; languages: string[]; usage?: VoiceInputUsage | null | undefined }>;
|
|
132
237
|
};
|
|
133
238
|
|
|
134
239
|
export type TranscriptionService = {
|
|
135
240
|
limits(): TranscriptionLimits;
|
|
136
241
|
/** True when at least one ready provider can serve requests. */
|
|
137
242
|
available(context?: TranscriptionAvailabilityContext): boolean | Promise<boolean>;
|
|
243
|
+
availableProviderIds?(context: TranscriptionAvailabilityContext): Promise<string[]>;
|
|
138
244
|
/** Select one provider before a durable segment attempt; retries pin this id. */
|
|
139
245
|
selectProvider?(
|
|
140
246
|
context: TranscriptionAvailabilityContext,
|
|
141
247
|
): string | null | Promise<string | null>;
|
|
248
|
+
/**
|
|
249
|
+
* Credit admission for an exact provider before durable work is claimed.
|
|
250
|
+
* Resolves for subscription providers; throws TranscriptionBillingRefusedError
|
|
251
|
+
* when a deployment-funded provider cannot be paid for.
|
|
252
|
+
*/
|
|
253
|
+
admit?(input: {
|
|
254
|
+
providerId: string;
|
|
255
|
+
accountId: string;
|
|
256
|
+
workspaceId: string;
|
|
257
|
+
attribution: CreditDebitAttribution;
|
|
258
|
+
}): Promise<void>;
|
|
142
259
|
transcribe(request: TranscriptionRequest): Promise<TranscriptionResult>;
|
|
143
260
|
};
|
|
144
261
|
|
|
@@ -156,6 +273,8 @@ export type TranscriptionSegmenter = {
|
|
|
156
273
|
sourceMimeType: string;
|
|
157
274
|
totalDurationMilliseconds: number;
|
|
158
275
|
providerSegmentSeconds: number;
|
|
276
|
+
/** Hard decode ceiling (`ffmpeg -t`); audio past it is never decoded. */
|
|
277
|
+
maxDecodeSeconds?: number | undefined;
|
|
159
278
|
chunks: AsyncIterable<Uint8Array>;
|
|
160
279
|
signal?: AbortSignal | undefined;
|
|
161
280
|
}): AsyncIterable<PreparedTranscriptionSegment>;
|