@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.
Files changed (36) hide show
  1. package/dist/application/modal-native-live-declaration-join.d.ts +37 -0
  2. package/dist/application/modal-native-original-configuration.d.ts +79 -0
  3. package/dist/domain/capabilities.d.ts +1 -0
  4. package/dist/domain/connector-tool-permissions.d.ts +1 -1
  5. package/dist/domain/realtime-voice-billing.d.ts +95 -0
  6. package/dist/domain/scheduled-task-access.d.ts +2 -2
  7. package/dist/domain/scheduled-tasks.d.ts +2 -2
  8. package/dist/domain/session-connection-accounts.d.ts +1 -1
  9. package/dist/domain/session-start-timing.d.ts +1 -1
  10. package/dist/domain/session-tool-policy.d.ts +11 -7
  11. package/dist/domain/voice-input-billing.d.ts +77 -0
  12. package/dist/domain/web-search-billing.d.ts +45 -0
  13. package/dist/goal-admission.d.ts +24 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +1065 -365
  16. package/dist/index.js.map +1 -1
  17. package/dist/transcription.d.ts +119 -2
  18. package/package.json +12 -12
  19. package/src/application/modal-native-live-declaration-join.ts +196 -0
  20. package/src/application/modal-native-original-configuration.ts +261 -0
  21. package/src/default-session-model.ts +58 -88
  22. package/src/domain/capabilities.ts +44 -2
  23. package/src/domain/connector-tool-permissions.ts +24 -8
  24. package/src/domain/realtime-voice-billing.ts +400 -0
  25. package/src/domain/scheduled-task-access.ts +2 -1
  26. package/src/domain/scheduled-tasks.ts +3 -2
  27. package/src/domain/session-connection-accounts.ts +7 -3
  28. package/src/domain/session-start-timing.ts +5 -0
  29. package/src/domain/session-tool-policy.ts +20 -4
  30. package/src/domain/sessions.ts +145 -98
  31. package/src/domain/voice-input-billing.ts +315 -0
  32. package/src/domain/web-search-billing.ts +154 -0
  33. package/src/index.ts +3 -0
  34. package/src/model-catalog.ts +32 -38
  35. package/src/sandbox/routing.ts +59 -25
  36. 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
@@ -39,18 +39,19 @@ import {
39
39
  import {
40
40
  listConnectionsMetadata,
41
41
  getDeploymentModelCatalog,
42
- listWorkspaceProviderCustomModels,
42
+ listWorkspaceProviderCustomModelsByKind,
43
43
  getWorkspaceProviderCustomModelForExecution,
44
44
  lockActiveWorkspaceProviderCustomModelForAdmission,
45
45
  getWorkspaceGatewayCustomModelForExecution,
46
46
  getWorkspaceOpenRouterCustomModelForExecution,
47
47
  getOrganizationModelProviderCustomModelForExecution,
48
- listWorkspaceGatewayCustomModels,
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
- activeGatewayCustomModels,
255
- activeOpenRouterCustomModels,
268
+ workspaceCustomModels,
256
269
  retainedGatewayCustomModels,
257
270
  retainedOpenRouterCustomModels,
258
- organizationGatewayCustomModels,
259
- organizationOpenRouterCustomModels,
271
+ organizationProviders,
260
272
  retainedOrganizationGatewayCustomModels,
261
273
  retainedOrganizationOpenRouterCustomModels,
262
274
  directConnections,
263
275
  ] = await Promise.all([
264
276
  resolveCatalogSettings(db, envSettings),
265
- listWorkspaceGatewayCustomModels(db, {
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
- ? listOrganizationModelProviderCustomModelsForWorkspace(db, {
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
- providerKind: "openrouter",
306
+ providerKinds: ["vercel_gateway", "openrouter", ...CLAUDE_CONNECTION_KINDS],
305
307
  })
306
- : Promise.resolve([]),
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
- activeGatewayCustomModels,
357
+ workspaceCustomModels.vercel_gateway as WorkspaceGatewayCustomModel[],
356
358
  retainedGatewayCustomModels,
357
359
  );
358
360
  const openRouterCustomModels = includeRetainedModels(
359
- activeOpenRouterCustomModels,
361
+ workspaceCustomModels.openrouter as WorkspaceOpenRouterCustomModel[],
360
362
  retainedOpenRouterCustomModels,
361
363
  );
362
364
  const organizationGatewayModels = includeRetainedModels(
363
- organizationGatewayCustomModels,
365
+ organizationProviders?.vercel_gateway.models ?? [],
364
366
  retainedOrganizationGatewayCustomModels,
365
367
  );
366
368
  const organizationOpenRouterModels = includeRetainedModels(
367
- organizationOpenRouterCustomModels,
369
+ organizationProviders?.openrouter.models ?? [],
368
370
  retainedOrganizationOpenRouterCustomModels,
369
371
  );
370
372
  const claudeConnections: ClaudeConnectionCatalog = {};
371
- if (supportsOrganizationProviderReads)
373
+ if (organizationProviders)
372
374
  for (const kind of CLAUDE_CONNECTION_KINDS) {
373
- const models = await listOrganizationModelProviderCustomModelsForWorkspace(db, {
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 = await listWorkspaceProviderCustomModels(db, {
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;
@@ -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
- await verifyDirectWorkspaceMutationSettlement(db, {
363
- accountId: ids.accountId,
364
- workspaceId: ids.workspaceId,
365
- sessionId: ids.sessionId,
366
- requestId: ids.directRequest.requestId,
367
- holderId: ids.directRequest.holderId,
368
- initiatorSubjectId: ids.resourceSubjectId,
369
- sandboxGroupId: homeLease.sandboxGroupId,
370
- expectedEpoch: backend.leaseEpoch,
371
- expectedInstanceId: backend.providerInstanceId,
372
- routeTargetId: exactAdmission.routeTargetId,
373
- routeEpoch: exactAdmission.routeEpoch,
374
- admission: exactAdmission,
375
- operation: op,
376
- outcome,
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
- await verifyRetainedProcessMutationSettlement(db, {
423
- accountId: ids.accountId,
424
- workspaceId: ids.workspaceId,
425
- sessionId: ids.sessionId,
426
- processId: process.id,
427
- admission: admission as SandboxWorkspaceMutationAdmission,
428
- operation: op,
429
- outcome,
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
@@ -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>;