@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,5 +1,5 @@
1
1
  import type { AccessGrant } from "@opengeni/contracts";
2
- import type { Settings } from "@opengeni/config";
2
+ import { allowedFirstPartyMcpToolsForSession, type Settings } from "@opengeni/config";
3
3
  import { requireSession, requireWorkspace, type Database } from "@opengeni/db";
4
4
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
5
5
  import {
@@ -44,6 +44,10 @@ export async function freezeSessionRealtimeConnectionAccounts(input: {
44
44
  (await requireWorkspace(db, workspaceId)).settings,
45
45
  ),
46
46
  });
47
+ const effectiveFirstPartyTools = allowedFirstPartyMcpToolsForSession(
48
+ settings,
49
+ session.firstPartyMcpTools,
50
+ );
47
51
  return await freezeConnectionAccounts({
48
52
  db,
49
53
  settings: runtimeSettings,
@@ -54,8 +58,8 @@ export async function freezeSessionRealtimeConnectionAccounts(input: {
54
58
  source: personalConnectionDelegationSourceForGrant(grant),
55
59
  targetSessionId: sessionId,
56
60
  googleDrivePublicationEnabled:
57
- session.firstPartyMcpTools.includes("editable_artifact_export") &&
58
- session.firstPartyMcpTools.includes("editable_artifact_export_status") &&
61
+ effectiveFirstPartyTools.includes("editable_artifact_export") &&
62
+ effectiveFirstPartyTools.includes("editable_artifact_export_status") &&
59
63
  (!session.firstPartyMcpPermissions?.length ||
60
64
  (session.firstPartyMcpPermissions.includes("artifacts:read") &&
61
65
  session.firstPartyMcpPermissions.includes("artifacts:publish"))),
@@ -3,6 +3,7 @@ import {
3
3
  codeSearchDeploymentPolicy,
4
4
  resolveModelProviderForTurn,
5
5
  resolveFirstPartyDelegationSecret,
6
+ webSearchToolPlan,
6
7
  type Settings,
7
8
  } from "@opengeni/config";
8
9
  import {
@@ -443,9 +444,18 @@ export function sessionEffectiveToolProjectionInput(
443
444
  const supportsImages =
444
445
  model?.model.api === "responses" && model.model.capabilities.inputModalities.includes("image");
445
446
  const hostedToolNames: AgentFunctionToolName[] = [];
447
+ // Same plan the worker applies: provider web_search/web_fetch where the
448
+ // model has no hosted search (or the operator chose `replace`).
449
+ const webSearchPlan = context
450
+ ? webSearchToolPlan(context.settings, {
451
+ hostedWebSearch: model?.model.hostedWebSearch ?? context.settings.webSearchEnabled,
452
+ transportHostedSearch: model?.provider.kind === "xai-subscription",
453
+ })
454
+ : { hostedWebSearch: false, providerTools: [] };
446
455
  if (model?.provider.kind === "xai-subscription") {
447
456
  if (context?.settings.webSearchEnabled) hostedToolNames.push("web_search", "x_search");
448
- } else if (model?.model.hostedWebSearch) hostedToolNames.push("web_search");
457
+ } else if (model?.model.hostedWebSearch && webSearchPlan.hostedWebSearch)
458
+ hostedToolNames.push("web_search");
449
459
  const mediaAttachment =
450
460
  context?.mediaAttachments?.get(session.id) ??
451
461
  (context?.objectStorageAvailable === false
@@ -460,6 +470,7 @@ export function sessionEffectiveToolProjectionInput(
460
470
  "skill_read",
461
471
  ...AGENT_SKILL_MANAGE_TOOL_NAMES,
462
472
  ...media.runtime,
473
+ ...webSearchPlan.providerTools,
463
474
  ]
464
475
  : [];
465
476
  const sandboxToolNames: AgentFunctionToolName[] = sandboxAvailable
@@ -549,6 +560,7 @@ export function sessionEffectiveToolProjectionInput(
549
560
  "skill_read",
550
561
  "request_human_input",
551
562
  "list_models",
563
+ ...webSearchPlan.providerTools,
552
564
  ...(toolRefs.some((ref) => ref.id === "opengeni" && ref.eager === true) ||
553
565
  !progressiveDisclosure
554
566
  ? firstPartyMcpTools.filter((name) => !interactionNames.has(name) || !progressiveDisclosure)
@@ -563,7 +575,7 @@ export function sessionEffectiveToolProjectionInput(
563
575
  : []),
564
576
  ]),
565
577
  hasSkills: context ? sessionHasSkills(session, context) : false,
566
- webSearch: hostedToolNames.includes("web_search"),
578
+ webSearch: hostedToolNames.includes("web_search") || webSearchPlan.providerTools.length > 0,
567
579
  humanInput: context?.humanInputEnabled ?? false,
568
580
  media: media.toolsKnown ? media.hosted.length + media.runtime.length > 0 : undefined,
569
581
  routerInHistory:
@@ -42,6 +42,7 @@ import {
42
42
  ORGANIZATION_GATEWAY_MODEL_ID_PREFIX,
43
43
  ORGANIZATION_OPENROUTER_MODEL_ID_PREFIX,
44
44
  allowedFirstPartyMcpToolsForSession,
45
+ deploymentUnavailableFirstPartyMcpTools,
45
46
  resolveFirstPartyMcpToolPolicy,
46
47
  policyProviderIdForModel,
47
48
  resolveTurnExecutionPolicyV1,
@@ -2410,6 +2411,14 @@ async function createSessionForRequestInFileScope(
2410
2411
  requestOptions: SessionCreateRequestOptions = {},
2411
2412
  ): Promise<CreateSessionRequestOutcome> {
2412
2413
  const payload = CreateSessionRequest.parse(rawPayload);
2414
+ // A tool whose backing workload this deployment does not run is dropped, not
2415
+ // rejected: a client echoing a full catalog must not fail on a deployment fact.
2416
+ if (payload.firstPartyMcpTools) {
2417
+ const unavailable = deploymentUnavailableFirstPartyMcpTools(unresolvedDeps.settings);
2418
+ payload.firstPartyMcpTools = payload.firstPartyMcpTools.filter(
2419
+ (tool) => !unavailable.has(tool),
2420
+ );
2421
+ }
2413
2422
  // Read before any await: the Site scope is request-local provenance.
2414
2423
  const surface = resolveTurnSurface({
2415
2424
  grant,
@@ -4249,6 +4258,10 @@ async function acceptSessionUserMessageInFileScope(
4249
4258
  (await requireWorkspace(db, workspaceId)).settings,
4250
4259
  ),
4251
4260
  });
4261
+ const existingEffectiveFirstPartyTools = allowedFirstPartyMcpToolsForSession(
4262
+ settings,
4263
+ existingSession.firstPartyMcpTools,
4264
+ );
4252
4265
  const { personalConnectionDelegations, mcpAccountBindings } = await freezeConnectionAccounts({
4253
4266
  db,
4254
4267
  accountId: grant.accountId,
@@ -4259,8 +4272,8 @@ async function acceptSessionUserMessageInFileScope(
4259
4272
  source: connectionDelegationSource,
4260
4273
  targetSessionId: sessionId,
4261
4274
  googleDrivePublicationEnabled:
4262
- existingSession.firstPartyMcpTools.includes("editable_artifact_export") &&
4263
- existingSession.firstPartyMcpTools.includes("editable_artifact_export_status") &&
4275
+ existingEffectiveFirstPartyTools.includes("editable_artifact_export") &&
4276
+ existingEffectiveFirstPartyTools.includes("editable_artifact_export_status") &&
4264
4277
  (!existingSession.firstPartyMcpPermissions?.length ||
4265
4278
  (existingSession.firstPartyMcpPermissions.includes("artifacts:read") &&
4266
4279
  existingSession.firstPartyMcpPermissions.includes("artifacts:publish"))),
@@ -0,0 +1,310 @@
1
+ import {
2
+ calculateVoiceInputCost,
3
+ configuredStaticUsageLimits,
4
+ voiceInputCreditBillingActive,
5
+ voiceInputPricingHasTokenRates,
6
+ type Settings,
7
+ type VoiceInputPricing,
8
+ type VoiceInputUsage,
9
+ } from "@opengeni/config";
10
+ import {
11
+ applyCreditDebitAfterUse,
12
+ checkWorkspaceAllowance,
13
+ creditDebitAttributionMetadata,
14
+ getSpendableCreditBalance,
15
+ listUnsettledVoiceTranscriptionCharges,
16
+ recordUsageEvent,
17
+ sumUsageQuantity,
18
+ VOICE_TRANSCRIPTION_DEBIT_TYPE,
19
+ VOICE_TRANSCRIPTION_SOURCE_TYPE,
20
+ voiceTranscriptionSettlementKeys,
21
+ type CreditDebitAttribution,
22
+ type Database,
23
+ } from "@opengeni/db";
24
+ import {
25
+ TranscriptionBillingRefusedError,
26
+ TranscriptionServiceError,
27
+ type TranscriptionBilling,
28
+ type TranscriptionBillingContext,
29
+ } from "../transcription";
30
+
31
+ /** Credit debit type and usage source for deployment-funded voice input. */
32
+ export const VOICE_INPUT_DEBIT_TYPE = VOICE_TRANSCRIPTION_DEBIT_TYPE;
33
+ export const VOICE_INPUT_SOURCE_TYPE = VOICE_TRANSCRIPTION_SOURCE_TYPE;
34
+
35
+ /** How the billed quantity was measured; recorded on the ledger entry. */
36
+ export type VoiceInputBillingBasis =
37
+ | "provider_tokens"
38
+ | "provider_duration"
39
+ | "server_duration"
40
+ | "reconciled_receipt";
41
+
42
+ /**
43
+ * Pick the billed measurement. Provider usage wins when this deployment can
44
+ * price it (token usage with token rates, or a reported duration). Otherwise
45
+ * the duration the server measured from WAV bytes it produced bills. Every
46
+ * deployment-funded call carries that server duration, so an unpriced usage
47
+ * shape never leaves a call unbillable. A client-reported duration is never a
48
+ * billing input.
49
+ */
50
+ export function voiceInputBillableUsage(input: {
51
+ pricing: VoiceInputPricing;
52
+ usage: VoiceInputUsage | null;
53
+ trustedDurationSeconds?: number | undefined;
54
+ }): { usage: VoiceInputUsage; basis: VoiceInputBillingBasis } {
55
+ if (input.usage?.kind === "tokens" && voiceInputPricingHasTokenRates(input.pricing)) {
56
+ return { usage: input.usage, basis: "provider_tokens" };
57
+ }
58
+ if (input.usage?.kind === "duration") {
59
+ return { usage: input.usage, basis: "provider_duration" };
60
+ }
61
+ if (
62
+ input.trustedDurationSeconds !== undefined &&
63
+ Number.isFinite(input.trustedDurationSeconds) &&
64
+ input.trustedDurationSeconds >= 0
65
+ ) {
66
+ return {
67
+ usage: { kind: "duration", seconds: input.trustedDurationSeconds },
68
+ basis: "server_duration",
69
+ };
70
+ }
71
+ throw new TranscriptionServiceError({
72
+ code: "provider",
73
+ message: "Transcription usage was not reported.",
74
+ });
75
+ }
76
+
77
+ function initiatingHuman(attribution: CreditDebitAttribution): string | null {
78
+ return attribution.kind === "human" || attribution.kind === "turn"
79
+ ? attribution.initiatingHumanSubjectId
80
+ : null;
81
+ }
82
+
83
+ function startOfUtcMonth(): Date {
84
+ const now = new Date();
85
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
86
+ }
87
+
88
+ /**
89
+ * Same admission shape as knowledge-search and session admission: general
90
+ * (not model-promotional) credits must be positive, the workspace/member
91
+ * allowance must not be exhausted, and a static monthly cost cap applies.
92
+ * Admission is a read, not a reservation; settlement is post-use and may take
93
+ * an account below zero by at most the concurrent in-flight calls.
94
+ */
95
+ export function createVoiceInputBilling(deps: {
96
+ db: Database;
97
+ settings: Settings;
98
+ /** Reconciliation is best-effort at admission; failures are reported here. */
99
+ onReconcileError?: (error: unknown, scope: { accountId: string; workspaceId: string }) => void;
100
+ /** Minimum spacing of admission-time reconciliation per workspace (default 5 min). */
101
+ reconcileIntervalMilliseconds?: number;
102
+ }): TranscriptionBilling {
103
+ const reconcileInterval = deps.reconcileIntervalMilliseconds ?? 5 * 60_000;
104
+ const lastReconciled = new Map<string, number>();
105
+ const reconcileDue = (key: string) => {
106
+ const now = Date.now();
107
+ const last = lastReconciled.get(key);
108
+ if (last !== undefined && now - last < reconcileInterval) return false;
109
+ if (lastReconciled.size >= 10_000) lastReconciled.clear();
110
+ lastReconciled.set(key, now);
111
+ return true;
112
+ };
113
+ return {
114
+ async admit({ accountId, workspaceId, attribution }) {
115
+ if (!voiceInputCreditBillingActive(deps.settings)) return;
116
+ if (attribution.kind === "unknown") {
117
+ // Not a microphone problem: the caller's credential has no payer the
118
+ // ledger can attribute, so the deployment refuses by policy.
119
+ throw new TranscriptionServiceError({
120
+ code: "policy_blocked",
121
+ status: 403,
122
+ message: "Voice input payer could not be verified.",
123
+ });
124
+ }
125
+ // Debits whose receipt committed but whose ledger write failed are
126
+ // applied before the balance read, so unsettled use counts against it.
127
+ // Throttled per workspace: failed debits are rare, the scan is not free.
128
+ if (reconcileDue(`${accountId}:${workspaceId}`)) {
129
+ await reconcileUnsettledVoiceInputCharges(deps.db, { accountId, workspaceId }).catch(
130
+ (error: unknown) => {
131
+ deps.onReconcileError?.(error, { accountId, workspaceId });
132
+ },
133
+ );
134
+ }
135
+ const balance = await getSpendableCreditBalance(deps.db, accountId);
136
+ if (balance.balanceMicros <= 0) {
137
+ throw new TranscriptionBillingRefusedError({
138
+ code: "insufficient_credits",
139
+ message: "Voice input needs Opengeni credits. Add credits to continue.",
140
+ });
141
+ }
142
+ const refusal = await checkWorkspaceAllowance(deps.db, {
143
+ accountId,
144
+ workspaceId,
145
+ subjectId: initiatingHuman(attribution),
146
+ });
147
+ if (refusal) {
148
+ throw new TranscriptionBillingRefusedError({
149
+ code: refusal.code,
150
+ message: refusal.message,
151
+ details: {
152
+ scope: refusal.scope,
153
+ resetsAt: refusal.resetsAt,
154
+ ...(refusal.subjectId ? { subjectId: refusal.subjectId } : {}),
155
+ },
156
+ });
157
+ }
158
+ if (
159
+ deps.settings.usageLimitsMode === "static" ||
160
+ deps.settings.usageLimitsMode === "managed"
161
+ ) {
162
+ const cap = configuredStaticUsageLimits(deps.settings).maxMonthlyCostMicrosPerAccount;
163
+ if (cap) {
164
+ const used = await sumUsageQuantity(deps.db, {
165
+ accountId,
166
+ eventType: "model.cost",
167
+ since: startOfUtcMonth(),
168
+ });
169
+ if (used >= cap) {
170
+ throw new TranscriptionBillingRefusedError({
171
+ code: "monthly_model_cost_limit",
172
+ message: "The monthly model cost limit has been reached.",
173
+ });
174
+ }
175
+ }
176
+ }
177
+ },
178
+
179
+ async settle(input) {
180
+ if (!voiceInputCreditBillingActive(deps.settings)) return { creditCostMicros: 0 };
181
+ const { usage, basis } = voiceInputBillableUsage({
182
+ pricing: input.pricing,
183
+ usage: input.usage,
184
+ trustedDurationSeconds: input.billing.trustedDurationSeconds,
185
+ });
186
+ const cost = calculateVoiceInputCost(input.pricing, usage);
187
+ return await settleVoiceInputUsage(deps.db, {
188
+ accountId: input.accountId,
189
+ workspaceId: input.workspaceId,
190
+ providerId: input.providerId,
191
+ model: input.model,
192
+ billing: input.billing,
193
+ usage,
194
+ basis,
195
+ providerCostMicros: cost.providerCostMicros,
196
+ creditCostMicros: cost.creditCostMicros,
197
+ });
198
+ },
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Two commits, in order: the `model.cost` usage receipt (Insights spend,
204
+ * monthly cost cap, member-allowance attribution), then the post-use credit
205
+ * debit. The receipt is the first-writer authority for the amount and the
206
+ * durable record of the charge: if the debit commit fails, the receipt stays
207
+ * and {@link reconcileUnsettledVoiceInputCharges} applies the same debit (same
208
+ * idempotency key) on the workspace's next admission. A retried unit settles
209
+ * exactly once at the originally recorded price.
210
+ */
211
+ export async function settleVoiceInputUsage(
212
+ db: Database,
213
+ input: {
214
+ accountId: string;
215
+ workspaceId: string;
216
+ providerId: string;
217
+ model: string;
218
+ billing: TranscriptionBillingContext;
219
+ usage: VoiceInputUsage;
220
+ basis: VoiceInputBillingBasis;
221
+ providerCostMicros: number;
222
+ creditCostMicros: number;
223
+ },
224
+ ): Promise<{ creditCostMicros: number }> {
225
+ const attribution = input.billing.attribution;
226
+ const keys = voiceTranscriptionSettlementKeys({
227
+ workspaceId: input.workspaceId,
228
+ sourceId: input.billing.sourceId,
229
+ });
230
+ const receipt = await recordUsageEvent(db, {
231
+ accountId: input.accountId,
232
+ workspaceId: input.workspaceId,
233
+ eventType: "model.cost",
234
+ quantity: input.creditCostMicros,
235
+ unit: "usd_micros",
236
+ sourceResourceType: VOICE_TRANSCRIPTION_SOURCE_TYPE,
237
+ sourceResourceId: input.billing.sourceId,
238
+ idempotencyKey: keys.usageIdempotencyKey,
239
+ // The API is the writer; the payer is the trusted attribution snapshot.
240
+ initiator: { kind: "service", subjectId: "api:voice-input" },
241
+ initiatorContext: { creditDebitAttribution: attribution },
242
+ });
243
+ const amount = Number(receipt.quantity);
244
+ if (amount > 0) {
245
+ await applyCreditDebitAfterUse(db, {
246
+ accountId: input.accountId,
247
+ workspaceId: input.workspaceId,
248
+ type: VOICE_TRANSCRIPTION_DEBIT_TYPE,
249
+ amountMicros: amount,
250
+ sourceType: VOICE_TRANSCRIPTION_SOURCE_TYPE,
251
+ sourceId: input.billing.sourceId,
252
+ idempotencyKey: keys.debitIdempotencyKey,
253
+ metadata: {
254
+ providerId: input.providerId,
255
+ model: input.model,
256
+ basis: input.basis,
257
+ providerCostMicros: input.providerCostMicros,
258
+ ...(input.usage.kind === "tokens"
259
+ ? {
260
+ inputTokens: input.usage.inputTokens,
261
+ audioInputTokens: input.usage.audioInputTokens,
262
+ textInputTokens: input.usage.textInputTokens,
263
+ outputTokens: input.usage.outputTokens,
264
+ }
265
+ : { audioMilliseconds: Math.ceil(input.usage.seconds * 1_000) }),
266
+ ...(attribution.kind === "unknown" ? {} : creditDebitAttributionMetadata(attribution)),
267
+ },
268
+ });
269
+ }
270
+ return { creditCostMicros: amount };
271
+ }
272
+
273
+ /**
274
+ * Apply the debit for every voice usage receipt in this workspace whose debit
275
+ * never committed. Idempotent with the inline settlement (same key), bounded
276
+ * per call, and safe to run concurrently.
277
+ */
278
+ export async function reconcileUnsettledVoiceInputCharges(
279
+ db: Database,
280
+ input: { accountId: string; workspaceId: string; minAgeMilliseconds?: number },
281
+ ): Promise<{ reconciled: number }> {
282
+ const pending = await listUnsettledVoiceTranscriptionCharges(db, {
283
+ accountId: input.accountId,
284
+ workspaceId: input.workspaceId,
285
+ ...(input.minAgeMilliseconds !== undefined
286
+ ? { minAgeMilliseconds: input.minAgeMilliseconds }
287
+ : {}),
288
+ });
289
+ for (const charge of pending) {
290
+ await applyCreditDebitAfterUse(db, {
291
+ accountId: input.accountId,
292
+ workspaceId: input.workspaceId,
293
+ type: VOICE_TRANSCRIPTION_DEBIT_TYPE,
294
+ amountMicros: charge.amountMicros,
295
+ sourceType: VOICE_TRANSCRIPTION_SOURCE_TYPE,
296
+ sourceId: charge.sourceId,
297
+ idempotencyKey: voiceTranscriptionSettlementKeys({
298
+ workspaceId: input.workspaceId,
299
+ sourceId: charge.sourceId,
300
+ }).debitIdempotencyKey,
301
+ metadata: {
302
+ basis: "reconciled_receipt" satisfies VoiceInputBillingBasis,
303
+ ...(charge.attribution.kind === "unknown"
304
+ ? {}
305
+ : creditDebitAttributionMetadata(charge.attribution)),
306
+ },
307
+ });
308
+ }
309
+ return { reconciled: pending.length };
310
+ }
@@ -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