@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.
@@ -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
  * Server-owned upstream budget for one provider attempt. Resumable recording
4
6
  * claims remain fenced for longer than this budget before another worker may
@@ -31,21 +33,112 @@ export type TranscriptionRequest = {
31
33
  preferredProvider?: string | null | undefined;
32
34
  fallbackEnabled?: boolean | undefined;
33
35
  excludedProviders?: readonly string[] | undefined;
36
+ /**
37
+ * Credit settlement facts for a deployment-funded provider. Required when
38
+ * the selected provider is deployment-funded and credit billing is active;
39
+ * the service fails closed without it.
40
+ */
41
+ billing?: TranscriptionBillingContext | undefined;
42
+ /**
43
+ * Internal: return `settleBilling` instead of settling before returning, so
44
+ * a resumable segment's transcript commits before any billing write.
45
+ */
46
+ deferBillingSettlement?: boolean;
47
+ };
48
+ /**
49
+ * Trusted, server-built settlement identity for one transcription unit. The
50
+ * usage receipt and debit idempotency keys derive from `workspaceId` +
51
+ * `sourceId` (see `voiceInputSettlementKeys`), so a retry or a later
52
+ * reconciliation settles the same unit exactly once.
53
+ */
54
+ export type TranscriptionBillingContext = {
55
+ /**
56
+ * Server-derived unit identity. Never a client-chosen value: a reused id
57
+ * settles once, so a client-controlled id would make later calls free.
58
+ */
59
+ sourceId: string;
60
+ /** Trusted payer facts from the authenticated request boundary. */
61
+ attribution: CreditDebitAttribution;
62
+ /**
63
+ * Audio duration measured by the server from WAV bytes it produced (the
64
+ * resumable segment, or the normalized one-shot upload). Bills whenever the
65
+ * provider reports no usage this deployment has a price for.
66
+ */
67
+ trustedDurationSeconds?: number | undefined;
34
68
  };
35
69
  export type TranscriptionResult = TranscribeAudioResponse & {
36
70
  /** Server-private provider id for operational metrics only. Never returned to clients. */
37
71
  providerId: string;
38
72
  audioSeconds: number;
39
73
  latencyMs: number;
74
+ /** Opengeni credits charged for this call (0 for subscription/free providers). */
75
+ creditCostMicros?: number | undefined;
76
+ /**
77
+ * Server-only settlement callback; never serialized into a public response.
78
+ * Never rejects: a settlement failure after provider success is logged and
79
+ * retried, and the transcript is still delivered.
80
+ */
81
+ settleBilling?: () => Promise<void>;
82
+ };
83
+ export type TranscriptionBillingRefusalCode = "insufficient_credits" | "allowance_exhausted" | "monthly_model_cost_limit";
84
+ /**
85
+ * Pre-use refusal for deployment-funded transcription: 402 for credits and
86
+ * allowances (same codes as session admission), 429 for a static monthly cap.
87
+ */
88
+ export declare class TranscriptionBillingRefusedError extends Error {
89
+ readonly status: 402 | 429;
90
+ readonly code: TranscriptionBillingRefusalCode;
91
+ readonly details: {
92
+ scope?: string;
93
+ resetsAt?: string | null;
94
+ subjectId?: string;
95
+ };
96
+ constructor(input: {
97
+ code: TranscriptionBillingRefusalCode;
98
+ message: string;
99
+ details?: {
100
+ scope?: string;
101
+ resetsAt?: string | null;
102
+ subjectId?: string;
103
+ };
104
+ });
105
+ }
106
+ /**
107
+ * Credit admission and settlement for deployment-funded transcription.
108
+ * Subscription providers (Codex, SuperGrok) never reach this port.
109
+ */
110
+ export type TranscriptionBilling = {
111
+ /** Refuse before any audio is sent when the payer cannot fund the call. */
112
+ admit(input: {
113
+ accountId: string;
114
+ workspaceId: string;
115
+ attribution: CreditDebitAttribution;
116
+ }): Promise<void>;
117
+ /**
118
+ * Record usage and debit credits once per unit, after use. The usage receipt
119
+ * commits before the debit, so a failed debit leaves a durable receipt that
120
+ * `admit` reconciles on the workspace's next voice request.
121
+ */
122
+ settle(input: {
123
+ accountId: string;
124
+ workspaceId: string;
125
+ providerId: string;
126
+ model: string;
127
+ pricing: VoiceInputPricing;
128
+ usage: VoiceInputUsage | null;
129
+ billing: TranscriptionBillingContext;
130
+ }): Promise<{
131
+ creditCostMicros: number;
132
+ }>;
40
133
  };
41
134
  export declare class TranscriptionServiceError extends Error {
42
- readonly code: VoiceInputErrorCode;
135
+ readonly code: Exclude<VoiceInputErrorCode, TranscriptionBillingRefusalCode>;
43
136
  readonly status: number;
44
137
  readonly retryable: boolean;
45
138
  /** Explicit rejection before any transcription result; safe to try another provider. */
46
139
  readonly fallbackSafe: boolean;
47
140
  constructor(input: {
48
- code: VoiceInputErrorCode;
141
+ code: Exclude<VoiceInputErrorCode, TranscriptionBillingRefusalCode>;
49
142
  message: string;
50
143
  status?: number;
51
144
  retryable?: boolean;
@@ -73,6 +166,15 @@ export type TranscriptionProvider = {
73
166
  /** The adapter guarantees that its upstream transport honors AbortSignal. */
74
167
  readonly supportsServerDeadline: true;
75
168
  readonly experimental?: boolean | undefined;
169
+ /**
170
+ * Present when the deployment pays the upstream provider (OpenAI/Azure):
171
+ * the call is admitted against and settled in Opengeni credits. Absent for
172
+ * the workspace's own subscriptions, which are never charged.
173
+ */
174
+ readonly deploymentFunded?: {
175
+ readonly model: string;
176
+ readonly pricing: VoiceInputPricing | null;
177
+ } | undefined;
76
178
  /**
77
179
  * Deployment readiness when called without a workspace. When `workspaceId` is
78
180
  * provided, providers may require a workspace-attached credential (e.g. Codex).
@@ -90,14 +192,27 @@ export type TranscriptionProvider = {
90
192
  }): Promise<{
91
193
  text: string;
92
194
  languages: string[];
195
+ usage?: VoiceInputUsage | null | undefined;
93
196
  }>;
94
197
  };
95
198
  export type TranscriptionService = {
96
199
  limits(): TranscriptionLimits;
97
200
  /** True when at least one ready provider can serve requests. */
98
201
  available(context?: TranscriptionAvailabilityContext): boolean | Promise<boolean>;
202
+ availableProviderIds?(context: TranscriptionAvailabilityContext): Promise<string[]>;
99
203
  /** Select one provider before a durable segment attempt; retries pin this id. */
100
204
  selectProvider?(context: TranscriptionAvailabilityContext): string | null | Promise<string | null>;
205
+ /**
206
+ * Credit admission for an exact provider before durable work is claimed.
207
+ * Resolves for subscription providers; throws TranscriptionBillingRefusedError
208
+ * when a deployment-funded provider cannot be paid for.
209
+ */
210
+ admit?(input: {
211
+ providerId: string;
212
+ accountId: string;
213
+ workspaceId: string;
214
+ attribution: CreditDebitAttribution;
215
+ }): Promise<void>;
101
216
  transcribe(request: TranscriptionRequest): Promise<TranscriptionResult>;
102
217
  };
103
218
  export type PreparedTranscriptionSegment = {
@@ -113,6 +228,8 @@ export type TranscriptionSegmenter = {
113
228
  sourceMimeType: string;
114
229
  totalDurationMilliseconds: number;
115
230
  providerSegmentSeconds: number;
231
+ /** Hard decode ceiling (`ffmpeg -t`); audio past it is never decoded. */
232
+ maxDecodeSeconds?: number | undefined;
116
233
  chunks: AsyncIterable<Uint8Array>;
117
234
  signal?: AbortSignal | undefined;
118
235
  }): AsyncIterable<PreparedTranscriptionSegment>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -50,17 +50,17 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.29.0",
53
- "@opengeni/capabilities": "1.3.0",
54
- "@opengeni/codex": "1.3.0",
55
- "@opengeni/config": "1.3.0",
56
- "@opengeni/contracts": "1.3.0",
57
- "@opengeni/db": "1.3.0",
58
- "@opengeni/documents": "1.3.0",
59
- "@opengeni/events": "1.3.0",
60
- "@opengeni/network": "1.3.0",
61
- "@opengeni/observability": "1.3.0",
62
- "@opengeni/runtime": "1.3.0",
63
- "@opengeni/storage": "1.3.0",
53
+ "@opengeni/capabilities": "1.4.0",
54
+ "@opengeni/codex": "1.4.0",
55
+ "@opengeni/config": "1.4.0",
56
+ "@opengeni/contracts": "1.4.0",
57
+ "@opengeni/db": "1.4.0",
58
+ "@opengeni/documents": "1.4.0",
59
+ "@opengeni/events": "1.4.0",
60
+ "@opengeni/network": "1.4.0",
61
+ "@opengeni/observability": "1.4.0",
62
+ "@opengeni/runtime": "1.4.0",
63
+ "@opengeni/storage": "1.4.0",
64
64
  "hono": "^4.12.18",
65
65
  "zod": "^4.2.1"
66
66
  },
@@ -23,7 +23,6 @@ import type {
23
23
  import {
24
24
  buildConnectionTokenResolver,
25
25
  getConnectionMetadata,
26
- listConnectionsMetadata,
27
26
  listEnabledMcpCapabilityServers,
28
27
  listConnectorToolPermissionPolicies,
29
28
  listChangedConnectorToolPermissions,
@@ -37,11 +36,16 @@ import {
37
36
  import { HTTPException } from "hono/http-exception";
38
37
  import {
39
38
  GMAIL_REST_MCP_TOOLS,
39
+ gmailToolAvailableOnDeployment,
40
40
  gmailToolSupportsScopes,
41
41
  isOfficialGmailMcpConfig,
42
42
  isOfficialSlackMcpConfig,
43
43
  } from "@opengeni/runtime";
44
44
  import { hasPermission } from "../access";
45
+ import {
46
+ personalConnectionDelegationSourceForGrant,
47
+ visibleMcpAccountConnections,
48
+ } from "./personal-connection-delegations";
45
49
  import {
46
50
  buildCapabilityCatalog,
47
51
  settingsWithMcpCapabilityServers,
@@ -195,12 +199,14 @@ async function resolveTarget(input: Input) {
195
199
  throw new HTTPException(403, {
196
200
  message: "Only the authenticated connection owner may manage personal tool permissions",
197
201
  });
202
+ const source = personalConnectionDelegationSourceForGrant(input.grant);
198
203
  const visible = ref
199
- ? await listConnectionsMetadata(
200
- input.db,
201
- input.workspaceId,
202
- input.personalOwnerVerified ? input.grant.subjectId : null,
203
- )
204
+ ? await visibleMcpAccountConnections(input.db, {
205
+ accountId: input.grant.accountId,
206
+ workspaceId: input.workspaceId,
207
+ source:
208
+ input.personalOwnerVerified && source.kind === "subject" ? source : { kind: "none" },
209
+ })
204
210
  : [];
205
211
  const bindings = mcpAccountBindingsFromVisibleConnections({
206
212
  accountId: input.grant.accountId,
@@ -256,9 +262,16 @@ async function resolveTarget(input: Input) {
256
262
  export function reviewedConnectorToolCatalog(
257
263
  server: Pick<Settings["mcpServers"][number], "url" | "connectionRef" | "allowedTools">,
258
264
  grantedScopes: readonly string[],
265
+ deployment: Pick<Settings, "gmailWatchTopicName">,
259
266
  ): ListedTool[] | null {
260
267
  const tools = isOfficialGmailMcpConfig(server.url, server.connectionRef)
261
- ? GMAIL_REST_MCP_TOOLS.filter((tool) => gmailToolSupportsScopes(tool.name, grantedScopes))
268
+ ? GMAIL_REST_MCP_TOOLS.filter(
269
+ (tool) =>
270
+ gmailToolSupportsScopes(tool.name, grantedScopes) &&
271
+ gmailToolAvailableOnDeployment(tool.name, {
272
+ watchTopicName: deployment.gmailWatchTopicName,
273
+ }),
274
+ )
262
275
  : isOfficialSlackMcpConfig(server.url, server.connectionRef)
263
276
  ? slackRestMcpToolsForScopes(grantedScopes)
264
277
  : null;
@@ -275,6 +288,7 @@ async function listTools(
275
288
  const reviewed = reviewedConnectorToolCatalog(
276
289
  target.server,
277
290
  target.connection?.grantedScopes ?? [],
291
+ input.settings,
278
292
  );
279
293
  if (reviewed !== null) return reviewed;
280
294
  let headers = { ...target.server.headers };
@@ -283,7 +297,9 @@ async function listTools(
283
297
  input.db,
284
298
  input.settings,
285
299
  )({
286
- workspaceId: input.workspaceId,
300
+ // The selected account's credentials remain in its origin workspace;
301
+ // approval preferences remain scoped to the workspace being configured.
302
+ workspaceId: target.connection?.workspaceId ?? input.workspaceId,
287
303
  ...(target.connection?.subjectId ? { subjectId: input.grant.subjectId } : {}),
288
304
  serverId: target.server.id,
289
305
  toolName: "tools/list",
@@ -0,0 +1,330 @@
1
+ import {
2
+ AI_GATEWAY_REALTIME_MODELS,
3
+ AZURE_LIVE_REALTIME_MODEL_ID,
4
+ configuredStaticUsageLimits,
5
+ parseRealtimeVoicePricingJson,
6
+ parseRealtimeVoicePricingTableJson,
7
+ realtimeVoiceMinuteCreditMicros,
8
+ realtimeVoiceStartedMinutes,
9
+ voiceInputCreditBillingActive,
10
+ type RealtimeVoicePricing,
11
+ type Settings,
12
+ } from "@opengeni/config";
13
+ import type { SessionRealtimeModel } from "@opengeni/contracts";
14
+ import {
15
+ applyCreditDebitAfterUse,
16
+ checkWorkspaceAllowance,
17
+ creditDebitAttributionMetadata,
18
+ existingUsageEventIdempotencyKeys,
19
+ getSpendableCreditBalance,
20
+ loadSessionRealtimeBillingFacts,
21
+ recordUsageEvent,
22
+ sumUsageQuantity,
23
+ withRlsContext,
24
+ type CreditDebitAttribution,
25
+ type Database,
26
+ } from "@opengeni/db";
27
+ import { TranscriptionBillingRefusedError } from "../transcription";
28
+
29
+ /** Credit debit type and usage source for deployment-funded live voice. */
30
+ export const REALTIME_VOICE_DEBIT_TYPE = "voice_realtime_debit";
31
+ export const REALTIME_VOICE_SOURCE_TYPE = "voice_realtime";
32
+
33
+ export type DeploymentRealtimeVoice = {
34
+ provider: "azure-live" | "ai-gateway";
35
+ /** Credentials exist and this deployment offers the model. */
36
+ configured: boolean;
37
+ pricing: RealtimeVoicePricing | null;
38
+ };
39
+
40
+ /**
41
+ * Live-voice models the deployment pays the provider for. Connected Codex,
42
+ * SuperGrok, and a workspace's own Gateway key are paid by the workspace and
43
+ * return null. (The catalog lists hosted GPT Live instead of the managed
44
+ * Gateway choices when both are configured; both remain priced and gated.)
45
+ */
46
+ export function deploymentRealtimeVoice(
47
+ settings: Settings,
48
+ model: SessionRealtimeModel | string,
49
+ ): DeploymentRealtimeVoice | null {
50
+ const azureConfigured = Boolean(settings.azureLiveEndpoint && settings.azureLiveApiKey?.trim());
51
+ if (model === AZURE_LIVE_REALTIME_MODEL_ID) {
52
+ return {
53
+ provider: "azure-live",
54
+ configured: azureConfigured,
55
+ pricing: parseRealtimeVoicePricingJson(settings.azureLivePricingJson),
56
+ };
57
+ }
58
+ for (const gateway of Object.values(AI_GATEWAY_REALTIME_MODELS)) {
59
+ if (gateway.managedModelId !== model) continue;
60
+ return {
61
+ provider: "ai-gateway",
62
+ configured: Boolean(settings.vercelAiGatewayApiKey),
63
+ pricing:
64
+ parseRealtimeVoicePricingTableJson(settings.aiGatewayRealtimePricingJson)[
65
+ gateway.upstreamModelId
66
+ ] ?? null,
67
+ };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ export type RealtimeVoiceUnavailableCode = "not_configured" | "pricing_unconfigured";
73
+
74
+ /** The deployment cannot offer this live-voice model at all (not a credit refusal). */
75
+ export class RealtimeVoiceUnavailableError extends Error {
76
+ readonly name = "RealtimeVoiceUnavailableError";
77
+ constructor(
78
+ readonly code: RealtimeVoiceUnavailableCode,
79
+ message: string,
80
+ ) {
81
+ super(message);
82
+ }
83
+ }
84
+
85
+ /** Static offer check: configured, and priced whenever credits are enforced. */
86
+ export function realtimeVoiceOfferProblem(
87
+ settings: Settings,
88
+ voice: DeploymentRealtimeVoice,
89
+ ): RealtimeVoiceUnavailableError | null {
90
+ if (!voice.configured) {
91
+ return new RealtimeVoiceUnavailableError(
92
+ "not_configured",
93
+ "Opengeni live voice is not configured on this deployment.",
94
+ );
95
+ }
96
+ if (voiceInputCreditBillingActive(settings) && !voice.pricing) {
97
+ return new RealtimeVoiceUnavailableError(
98
+ "pricing_unconfigured",
99
+ "Opengeni live voice is not available on this deployment yet.",
100
+ );
101
+ }
102
+ return null;
103
+ }
104
+
105
+ export const REALTIME_VOICE_INSUFFICIENT_CREDITS_MESSAGE =
106
+ "Live voice needs Opengeni credits. Add credits to continue.";
107
+
108
+ function startOfUtcMonth(now: Date): Date {
109
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
110
+ }
111
+
112
+ function initiatingHuman(attribution: CreditDebitAttribution): string | null {
113
+ return attribution.kind === "human" || attribution.kind === "turn"
114
+ ? attribution.initiatingHumanSubjectId
115
+ : null;
116
+ }
117
+
118
+ export type RealtimeVoiceSettlement = {
119
+ /** Credits debited by this settlement call (0 on replay). */
120
+ debitedMicros: number;
121
+ /** Why the live call must stop now, when it must. */
122
+ stop: TranscriptionBillingRefusedError | RealtimeVoiceUnavailableError | null;
123
+ };
124
+
125
+ /**
126
+ * Credit admission and time metering for deployment-funded live voice.
127
+ *
128
+ * Admission mirrors voice-input admission (same refusal codes and HTTP
129
+ * shape): spendable general credits must be positive, the workspace/member
130
+ * allowance must not be exhausted, and the static monthly cost cap applies.
131
+ * Metering bills every started minute of each issued provider connection,
132
+ * measured only from server-observed facts (connection claim time, owner
133
+ * heartbeats, owner-proven end). Each (connection, minute) is one idempotent
134
+ * usage receipt plus one post-use debit.
135
+ */
136
+ export function createRealtimeVoiceBilling(deps: { db: Database; settings: Settings }) {
137
+ const billingActive = () => voiceInputCreditBillingActive(deps.settings);
138
+
139
+ async function refusal(input: {
140
+ accountId: string;
141
+ workspaceId: string;
142
+ attribution: CreditDebitAttribution;
143
+ now: Date;
144
+ }): Promise<TranscriptionBillingRefusedError | null> {
145
+ const balance = await getSpendableCreditBalance(deps.db, input.accountId);
146
+ if (balance.balanceMicros <= 0) {
147
+ return new TranscriptionBillingRefusedError({
148
+ code: "insufficient_credits",
149
+ message: REALTIME_VOICE_INSUFFICIENT_CREDITS_MESSAGE,
150
+ });
151
+ }
152
+ const allowance = await checkWorkspaceAllowance(deps.db, {
153
+ accountId: input.accountId,
154
+ workspaceId: input.workspaceId,
155
+ subjectId: initiatingHuman(input.attribution),
156
+ });
157
+ if (allowance) {
158
+ return new TranscriptionBillingRefusedError({
159
+ code: allowance.code,
160
+ message: allowance.message,
161
+ details: {
162
+ scope: allowance.scope,
163
+ resetsAt: allowance.resetsAt,
164
+ ...(allowance.subjectId ? { subjectId: allowance.subjectId } : {}),
165
+ },
166
+ });
167
+ }
168
+ if (deps.settings.usageLimitsMode === "static" || deps.settings.usageLimitsMode === "managed") {
169
+ const cap = configuredStaticUsageLimits(deps.settings).maxMonthlyCostMicrosPerAccount;
170
+ if (cap) {
171
+ const used = await sumUsageQuantity(deps.db, {
172
+ accountId: input.accountId,
173
+ eventType: "model.cost",
174
+ since: startOfUtcMonth(input.now),
175
+ });
176
+ if (used >= cap) {
177
+ return new TranscriptionBillingRefusedError({
178
+ code: "monthly_model_cost_limit",
179
+ message: "The monthly model cost limit has been reached.",
180
+ });
181
+ }
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ return {
188
+ /**
189
+ * Throws RealtimeVoiceUnavailableError when the deployment cannot offer
190
+ * the model and TranscriptionBillingRefusedError for a credit refusal.
191
+ * Workspace-funded models are always admitted here.
192
+ */
193
+ async admit(input: {
194
+ accountId: string;
195
+ workspaceId: string;
196
+ model: SessionRealtimeModel;
197
+ attribution: CreditDebitAttribution;
198
+ now?: Date;
199
+ }): Promise<void> {
200
+ const voice = deploymentRealtimeVoice(deps.settings, input.model);
201
+ if (!voice) return;
202
+ const problem = realtimeVoiceOfferProblem(deps.settings, voice);
203
+ if (problem) throw problem;
204
+ if (!billingActive()) return;
205
+ const refused = await refusal({ ...input, now: input.now ?? new Date() });
206
+ if (refused) throw refused;
207
+ },
208
+
209
+ /** Spendable credits are positive (always true when credits are not enforced). */
210
+ async hasSpendableCredits(accountId: string): Promise<boolean> {
211
+ if (!billingActive()) return true;
212
+ const balance = await getSpendableCreditBalance(deps.db, accountId);
213
+ return balance.balanceMicros > 0;
214
+ },
215
+
216
+ /**
217
+ * Settle every started minute observed so far for one live-voice mode and
218
+ * report whether the call must stop. Safe to call repeatedly and
219
+ * concurrently: receipts and debits are idempotent per (connection, minute).
220
+ */
221
+ async settle(input: {
222
+ workspaceId: string;
223
+ sessionId: string;
224
+ realtimeId: string;
225
+ /** Authenticated caller; its attribution applies only when it owns the call. */
226
+ callerSubjectId: string;
227
+ attribution: CreditDebitAttribution;
228
+ now?: Date;
229
+ }): Promise<RealtimeVoiceSettlement> {
230
+ const now = input.now ?? new Date();
231
+ if (!billingActive()) return { debitedMicros: 0, stop: null };
232
+ const facts = await loadSessionRealtimeBillingFacts(deps.db, { ...input, now });
233
+ if (!facts) return { debitedMicros: 0, stop: null };
234
+ const voice = deploymentRealtimeVoice(deps.settings, facts.model);
235
+ if (!voice) return { debitedMicros: 0, stop: null };
236
+ if (!voice.pricing) {
237
+ // Pricing was removed after admission: nothing to bill against, so the
238
+ // call must end rather than continue unpriced.
239
+ return {
240
+ debitedMicros: 0,
241
+ stop: new RealtimeVoiceUnavailableError(
242
+ "pricing_unconfigured",
243
+ "Opengeni live voice is no longer available on this deployment.",
244
+ ),
245
+ };
246
+ }
247
+ const attribution: CreditDebitAttribution =
248
+ input.callerSubjectId === facts.ownerSubjectId ? input.attribution : { kind: "unknown" };
249
+ const cost = realtimeVoiceMinuteCreditMicros(voice.pricing);
250
+ const minutes = facts.connections.flatMap((connection) =>
251
+ Array.from(
252
+ { length: realtimeVoiceStartedMinutes(connection.startedAt, connection.observedUntil) },
253
+ (_, index) => ({
254
+ connectionId: connection.id,
255
+ minute: index + 1,
256
+ startsAt: new Date(connection.startedAt.getTime() + index * 60_000),
257
+ key: `voice.realtime_cost:${connection.id}:${index + 1}`,
258
+ }),
259
+ ),
260
+ );
261
+ const recorded = await existingUsageEventIdempotencyKeys(deps.db, {
262
+ accountId: facts.accountId,
263
+ workspaceId: facts.workspaceId,
264
+ keys: minutes.map((minute) => minute.key),
265
+ });
266
+ let debitedMicros = 0;
267
+ for (const minute of minutes) {
268
+ if (recorded.has(minute.key)) continue;
269
+ debitedMicros += await withRlsContext(
270
+ deps.db,
271
+ { accountId: facts.accountId, workspaceId: facts.workspaceId },
272
+ async (tx) => {
273
+ const sourceId = `${minute.connectionId}:${minute.minute}`;
274
+ const receipt = await recordUsageEvent(tx, {
275
+ accountId: facts.accountId,
276
+ workspaceId: facts.workspaceId,
277
+ sessionId: facts.sessionId,
278
+ eventType: "model.cost",
279
+ quantity: cost.creditCostMicros,
280
+ unit: "usd_micros",
281
+ sourceResourceType: REALTIME_VOICE_SOURCE_TYPE,
282
+ sourceResourceId: sourceId,
283
+ idempotencyKey: minute.key,
284
+ occurredAt: minute.startsAt,
285
+ initiator: { kind: "service", subjectId: "api:voice-realtime" },
286
+ initiatorContext: { creditDebitAttribution: attribution },
287
+ });
288
+ const amount = Number(receipt.quantity);
289
+ if (amount <= 0) return 0;
290
+ const debit = await applyCreditDebitAfterUse(tx, {
291
+ accountId: facts.accountId,
292
+ workspaceId: facts.workspaceId,
293
+ type: REALTIME_VOICE_DEBIT_TYPE,
294
+ amountMicros: amount,
295
+ sourceType: REALTIME_VOICE_SOURCE_TYPE,
296
+ sourceId,
297
+ idempotencyKey: `credit:${REALTIME_VOICE_DEBIT_TYPE}:${sourceId}`,
298
+ occurredAt: minute.startsAt,
299
+ metadata: {
300
+ provider: voice.provider,
301
+ model: facts.model,
302
+ realtimeId: facts.realtimeId,
303
+ connectionId: minute.connectionId,
304
+ minute: minute.minute,
305
+ basis: "server_observed_started_minute",
306
+ providerCostMicros: cost.providerCostMicros,
307
+ ...(attribution.kind === "unknown"
308
+ ? {}
309
+ : creditDebitAttributionMetadata(attribution)),
310
+ },
311
+ });
312
+ return debit.debitedMicros;
313
+ },
314
+ );
315
+ }
316
+ const stop =
317
+ facts.state === "active"
318
+ ? await refusal({
319
+ accountId: facts.accountId,
320
+ workspaceId: facts.workspaceId,
321
+ attribution,
322
+ now,
323
+ })
324
+ : null;
325
+ return { debitedMicros, stop };
326
+ },
327
+ };
328
+ }
329
+
330
+ export type RealtimeVoiceBilling = ReturnType<typeof createRealtimeVoiceBilling>;
@@ -14,6 +14,7 @@ import {
14
14
  allowedFirstPartyMcpToolsForSession,
15
15
  resolveFirstPartyMcpToolPolicy,
16
16
  type Settings,
17
+ type FirstPartyMcpToolPolicySettings,
17
18
  } from "@opengeni/config";
18
19
  import {
19
20
  DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
@@ -256,7 +257,7 @@ export function planScheduledTaskOpenGeniTools(input: {
256
257
  ScheduledTaskCreatorPolicy,
257
258
  "firstPartyMcpTools" | "firstPartyMcpPermissions"
258
259
  > | null;
259
- settings: Pick<Settings, "defaultFirstPartyMcpTools" | "allowedFirstPartyMcpTools">;
260
+ settings: FirstPartyMcpToolPolicySettings;
260
261
  grantPermissions: readonly Permission[];
261
262
  grantPermissionMode?: AccessGrant["permissionMode"];
262
263
  permissionsRequiredByTools: FirstPartyToolPermissionRequirements;
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  allowedFirstPartyMcpToolsForSession,
17
17
  resolveFirstPartyMcpToolPolicy,
18
+ type FirstPartyMcpToolPolicySettings,
18
19
  type Settings,
19
20
  } from "@opengeni/config";
20
21
  import {
@@ -232,7 +233,7 @@ export function scheduledConnectionSurfaceEligibility(
232
233
  settings: Settings,
233
234
  target: Pick<Session, "firstPartyMcpTools" | "firstPartyMcpPermissions"> | null,
234
235
  ): { googleDrivePublicationEnabled: boolean; atlassianEnabled: boolean } {
235
- const tools = target?.firstPartyMcpTools ?? resolveFirstPartyMcpToolPolicy(settings).default;
236
+ const tools = allowedFirstPartyMcpToolsForSession(settings, target?.firstPartyMcpTools);
236
237
  const permissions = target?.firstPartyMcpPermissions ?? DEFAULT_FIRST_PARTY_MCP_PERMISSIONS;
237
238
  return {
238
239
  googleDrivePublicationEnabled:
@@ -2502,7 +2503,7 @@ async function validateScheduledTaskAgentConfig(input: {
2502
2503
  * Mutable rig/variable-set metadata is revalidated again at dispatch.
2503
2504
  */
2504
2505
  export function validateIncidentTelemetryPreflightSelection(
2505
- settings: Pick<Settings, "allowedFirstPartyMcpTools" | "defaultFirstPartyMcpTools">,
2506
+ settings: FirstPartyMcpToolPolicySettings,
2506
2507
  agentConfig: ScheduledTaskAgentConfig,
2507
2508
  ): void {
2508
2509
  const executionClass = agentConfig.executionClass;