@opengeni/core 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.
@@ -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>;