@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
@@ -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,
@@ -2051,6 +2052,11 @@ export async function postUserMessageTurn(
2051
2052
  : {}),
2052
2053
  controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
2053
2054
  }),
2055
+ undefined,
2056
+ "shared",
2057
+ // Linked-actor capture reauthorizes under the organization-membership
2058
+ // lock; take it before the tenancy fence and the canonical prefix.
2059
+ Boolean(input.captureTurnAuthority),
2054
2060
  ),
2055
2061
  );
2056
2062
  } catch (error) {
@@ -2410,6 +2416,14 @@ async function createSessionForRequestInFileScope(
2410
2416
  requestOptions: SessionCreateRequestOptions = {},
2411
2417
  ): Promise<CreateSessionRequestOutcome> {
2412
2418
  const payload = CreateSessionRequest.parse(rawPayload);
2419
+ // A tool whose backing workload this deployment does not run is dropped, not
2420
+ // rejected: a client echoing a full catalog must not fail on a deployment fact.
2421
+ if (payload.firstPartyMcpTools) {
2422
+ const unavailable = deploymentUnavailableFirstPartyMcpTools(unresolvedDeps.settings);
2423
+ payload.firstPartyMcpTools = payload.firstPartyMcpTools.filter(
2424
+ (tool) => !unavailable.has(tool),
2425
+ );
2426
+ }
2413
2427
  // Read before any await: the Site scope is request-local provenance.
2414
2428
  const surface = resolveTurnSurface({
2415
2429
  grant,
@@ -2581,27 +2595,35 @@ async function createSessionForRequestInFileScope(
2581
2595
  payload.idempotencyKey &&
2582
2596
  (effectiveVisibility !== "user_private" || replayManagedHumanSubjectId !== null)
2583
2597
  ) {
2598
+ const createIdempotencyKey = payload.idempotencyKey;
2584
2599
  try {
2585
- const initializedReplay = await getInitializedSessionCreateReplay(db, {
2586
- bundledSkillIds,
2587
- accountId: grant.accountId,
2588
- workspaceId,
2589
- subjectId: replayManagedHumanSubjectId ?? grant.subjectId,
2590
- ...(replayManagedHumanSubjectId
2591
- ? { activeManagedHumanSubjectId: replayManagedHumanSubjectId }
2592
- : {}),
2593
- createIdempotencyKey: payload.idempotencyKey,
2594
- selectedInstalledSkillIds: payload.installedSkillIds ?? [],
2595
- initialAgentLearning: payload.agentLearning,
2596
- ...sessionScope,
2600
+ const initializedReplay = await measureSessionStartPhase(
2601
+ unresolvedDeps.observability,
2602
+ "idempotent_replay",
2603
+ () =>
2604
+ getInitializedSessionCreateReplay(db, {
2605
+ bundledSkillIds,
2606
+ accountId: grant.accountId,
2607
+ workspaceId,
2608
+ subjectId: replayManagedHumanSubjectId ?? grant.subjectId,
2609
+ ...(replayManagedHumanSubjectId
2610
+ ? { activeManagedHumanSubjectId: replayManagedHumanSubjectId }
2611
+ : {}),
2612
+ createIdempotencyKey,
2613
+ selectedInstalledSkillIds: payload.installedSkillIds ?? [],
2614
+ initialAgentLearning: payload.agentLearning,
2615
+ ...sessionScope,
2597
2616
 
2598
- ...(payload.requestedSessionId ? { requestedSessionId: payload.requestedSessionId } : {}),
2599
- visibility: effectiveVisibility,
2600
- variableSetIds: payload.variableSetIds ?? [],
2601
- initialPersonalResourceAttachmentIntent: payload.personalResourceAttachment ?? null,
2602
- deferInitialTurn: payload.startMode === "realtime",
2603
- agentConfig,
2604
- });
2617
+ ...(payload.requestedSessionId
2618
+ ? { requestedSessionId: payload.requestedSessionId }
2619
+ : {}),
2620
+ visibility: effectiveVisibility,
2621
+ variableSetIds: payload.variableSetIds ?? [],
2622
+ initialPersonalResourceAttachmentIntent: payload.personalResourceAttachment ?? null,
2623
+ deferInitialTurn: payload.startMode === "realtime",
2624
+ agentConfig,
2625
+ }),
2626
+ );
2605
2627
  if (initializedReplay) {
2606
2628
  if (initializedReplay.outcome === "denied") {
2607
2629
  throw new SessionSpawnDeniedError(SessionSpawnDenial.parse(initializedReplay.denial));
@@ -2979,36 +3001,43 @@ async function createSessionForRequestInFileScope(
2979
3001
  const tools = withFirstPartyTools(selectedTools, runtimeSettings);
2980
3002
 
2981
3003
  const captureLinkedAuthority = prepareExternalLinkTurnAdmission(authorization);
2982
- await validateGitHubRepositorySelection(db, workspaceId, resources);
2983
- if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
2984
- throw new HTTPException(503, {
2985
- message: "object storage is not configured",
2986
- });
2987
- }
2988
- const attachmentOwnerContext = authorization
2989
- ? await fileOwnerContextForAccess({ db }, authorization, "sessions:create")
2990
- : grant.principalKind === "agent_attempt"
2991
- ? await fileOwnerContextForAgent({ db }, grant, "sessions:create")
2992
- : undefined;
3004
+ const { attachmentOwnerContext, variableSets } = await measureSessionStartPhase(
3005
+ unresolvedDeps.observability,
3006
+ "resource_validation",
3007
+ async () => {
3008
+ await validateGitHubRepositorySelection(db, workspaceId, resources);
3009
+ if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
3010
+ throw new HTTPException(503, {
3011
+ message: "object storage is not configured",
3012
+ });
3013
+ }
3014
+ const ownerContext = authorization
3015
+ ? await fileOwnerContextForAccess({ db }, authorization, "sessions:create")
3016
+ : grant.principalKind === "agent_attempt"
3017
+ ? await fileOwnerContextForAgent({ db }, grant, "sessions:create")
3018
+ : undefined;
3019
+ await validateFileResources(
3020
+ db,
3021
+ grant.accountId,
3022
+ workspaceId,
3023
+ personalResourceSubjectId ?? grant.subjectId,
3024
+ resources,
3025
+ ownerContext,
3026
+ );
3027
+ // Every selected Variable Set is independently authorized. Scope does not
3028
+ // affect precedence: explicit order is low-to-high and later sets win name
3029
+ // collisions.
3030
+ const validatedVariableSets: VariableSet[] = [];
3031
+ for (const variableSetId of payload.variableSetIds ?? []) {
3032
+ validatedVariableSets.push(
3033
+ await validateVariableSetAttachment({ settings, db }, grant, workspaceId, variableSetId),
3034
+ );
3035
+ }
3036
+ return { attachmentOwnerContext: ownerContext, variableSets: validatedVariableSets };
3037
+ },
3038
+ );
2993
3039
  const attachmentOwner =
2994
3040
  attachmentOwnerContext?.privateFileOwnerSubjectId === grant.subjectId ? grant.subjectId : null;
2995
- await validateFileResources(
2996
- db,
2997
- grant.accountId,
2998
- workspaceId,
2999
- personalResourceSubjectId ?? grant.subjectId,
3000
- resources,
3001
- attachmentOwnerContext,
3002
- );
3003
- // Every selected Variable Set is independently authorized. Scope does not
3004
- // affect precedence: explicit order is low-to-high and later sets win name
3005
- // collisions.
3006
- const variableSets: VariableSet[] = [];
3007
- for (const variableSetId of payload.variableSetIds ?? []) {
3008
- variableSets.push(
3009
- await validateVariableSetAttachment({ settings, db }, grant, workspaceId, variableSetId),
3010
- );
3011
- }
3012
3041
  // RIG BINDING (M3). Resolve the rig this session rides — a UUID binds that
3013
3042
  // rig, null explicitly opts out, and omission inherits the workspace default
3014
3043
  // (workspaces.default_rig_id) — then FREEZE both the rig id and its currently-
@@ -3020,22 +3049,27 @@ async function createSessionForRequestInFileScope(
3020
3049
  // - A stale workspace-default rig (deleted → FK-nulled, or somehow with no
3021
3050
  // active version) degrades SILENTLY to rig-less: an operator-side default
3022
3051
  // must never brick every create in the workspace.
3023
- const requestedRigId =
3024
- payload.rigId === undefined ? await getWorkspaceDefaultRigId(db, workspaceId) : payload.rigId;
3025
- let frozenRigId: string | null = null;
3026
- let frozenRigVersionId: string | null = null;
3027
- if (requestedRigId) {
3028
- const rig = await getRig(db, grant, requestedRigId);
3029
- if (!rig || !rig.activeVersion) {
3030
- if (payload.rigId) {
3031
- throw new HTTPException(422, {
3032
- message: rig
3033
- ? `sandbox environment ${payload.rigId} has no active version to bind`
3034
- : `unknown rigId: ${payload.rigId}`,
3035
- });
3052
+ const { frozenRigId, frozenRigVersionId } = await measureSessionStartPhase(
3053
+ unresolvedDeps.observability,
3054
+ "rig_binding",
3055
+ async () => {
3056
+ const requestedRigId =
3057
+ payload.rigId === undefined
3058
+ ? await getWorkspaceDefaultRigId(db, workspaceId)
3059
+ : payload.rigId;
3060
+ if (!requestedRigId) return { frozenRigId: null, frozenRigVersionId: null };
3061
+ const rig = await getRig(db, grant, requestedRigId);
3062
+ if (!rig || !rig.activeVersion) {
3063
+ if (payload.rigId) {
3064
+ throw new HTTPException(422, {
3065
+ message: rig
3066
+ ? `sandbox environment ${payload.rigId} has no active version to bind`
3067
+ : `unknown rigId: ${payload.rigId}`,
3068
+ });
3069
+ }
3070
+ // else: workspace-default fallback that no longer resolves → rig-less.
3071
+ return { frozenRigId: null, frozenRigVersionId: null };
3036
3072
  }
3037
- // else: workspace-default fallback that no longer resolves → rig-less.
3038
- } else {
3039
3073
  for (const defaultVariableSetId of new Set(rig.activeVersion.defaultVariableSetIds)) {
3040
3074
  await validateVariableSetAttachment(
3041
3075
  { settings, db },
@@ -3044,10 +3078,12 @@ async function createSessionForRequestInFileScope(
3044
3078
  defaultVariableSetId,
3045
3079
  );
3046
3080
  }
3047
- frozenRigId = rig.id;
3048
- frozenRigVersionId = rig.activeVersion.id;
3049
- }
3050
- }
3081
+ return {
3082
+ frozenRigId: rig.id as string | null,
3083
+ frozenRigVersionId: rig.activeVersion.id as string | null,
3084
+ };
3085
+ },
3086
+ );
3051
3087
  // CHANNEL FILING. Pure rail organization: a UUID files the session into that
3052
3088
  // workspace channel, omission/null leaves it unfiled (inbox). Resolved
3053
3089
  // workspace-scoped so a foreign channel id can never attach; an explicit
@@ -3076,25 +3112,27 @@ async function createSessionForRequestInFileScope(
3076
3112
  // inherited calling-turn model, or deployment default — so the policy must
3077
3113
  // vet that effective value, not just explicit ones (a restricted workspace's
3078
3114
  // inherited/default-model session would otherwise be born blocked).
3079
- await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
3080
- // Direct creation is a fresh model selection. Child inheritance and keyed
3081
- // repair preserve the existing accepted-model/authority rules.
3082
- if (retainedKeyedShellModel === null && !parentSession) {
3083
- const selections = await resolveCallerWorkspaceModelSelections(db, settings, {
3084
- accountId: grant.accountId,
3085
- workspaceId,
3086
- subjectId: personalResourceSubjectId ?? grant.subjectId,
3087
- ...(xaiProviderAccountAuthoritySnapshot
3088
- ? { xaiAuthoritySnapshot: xaiProviderAccountAuthoritySnapshot }
3089
- : {}),
3090
- ...(claudeProviderAccountAuthoritySnapshot
3091
- ? { claudeAuthoritySnapshot: claudeProviderAccountAuthoritySnapshot }
3092
- : {}),
3093
- });
3094
- if (!admissibleWorkspaceModel(selections, model)) {
3095
- throw new HTTPException(422, { message: `model is not selectable: ${model}` });
3115
+ await measureSessionStartPhase(unresolvedDeps.observability, "model_admission", async () => {
3116
+ await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
3117
+ // Direct creation is a fresh model selection. Child inheritance and keyed
3118
+ // repair preserve the existing accepted-model/authority rules.
3119
+ if (retainedKeyedShellModel === null && !parentSession) {
3120
+ const selections = await resolveCallerWorkspaceModelSelections(db, settings, {
3121
+ accountId: grant.accountId,
3122
+ workspaceId,
3123
+ subjectId: personalResourceSubjectId ?? grant.subjectId,
3124
+ ...(xaiProviderAccountAuthoritySnapshot
3125
+ ? { xaiAuthoritySnapshot: xaiProviderAccountAuthoritySnapshot }
3126
+ : {}),
3127
+ ...(claudeProviderAccountAuthoritySnapshot
3128
+ ? { claudeAuthoritySnapshot: claudeProviderAccountAuthoritySnapshot }
3129
+ : {}),
3130
+ });
3131
+ if (!admissibleWorkspaceModel(selections, model)) {
3132
+ throw new HTTPException(422, { message: `model is not selectable: ${model}` });
3133
+ }
3096
3134
  }
3097
- }
3135
+ });
3098
3136
  const inheritedReasoningEffort =
3099
3137
  parentCallingTurn?.reasoningEffort ??
3100
3138
  parentSession?.reasoningEffort ??
@@ -3298,18 +3336,23 @@ async function createSessionForRequestInFileScope(
3298
3336
  workspace.settings,
3299
3337
  ),
3300
3338
  });
3301
- const { personalConnectionDelegations, mcpAccountBindings } = await freezeConnectionAccounts({
3302
- db,
3303
- accountId: grant.accountId,
3304
- workspaceId,
3305
- settings: runtimeSettings,
3306
- tools: connectionAccountTools,
3307
- resources,
3308
- source: connectionDelegationSource,
3309
- authoritySelections: payload.connectionAccounts,
3310
- googleDrivePublicationEnabled,
3311
- atlassianEnabled,
3312
- });
3339
+ const { personalConnectionDelegations, mcpAccountBindings } = await measureSessionStartPhase(
3340
+ unresolvedDeps.observability,
3341
+ "connection_freeze",
3342
+ () =>
3343
+ freezeConnectionAccounts({
3344
+ db,
3345
+ accountId: grant.accountId,
3346
+ workspaceId,
3347
+ settings: runtimeSettings,
3348
+ tools: connectionAccountTools,
3349
+ resources,
3350
+ source: connectionDelegationSource,
3351
+ authoritySelections: payload.connectionAccounts,
3352
+ googleDrivePublicationEnabled,
3353
+ atlassianEnabled,
3354
+ }),
3355
+ );
3313
3356
  if (effectiveGoal) {
3314
3357
  const missingGoalTools = ["goal_update", "goal_progress", "goal_complete", "goal_pause"].filter(
3315
3358
  (name) => !firstPartyMcpTools.includes(name as FirstPartyMcpToolName),
@@ -4249,6 +4292,10 @@ async function acceptSessionUserMessageInFileScope(
4249
4292
  (await requireWorkspace(db, workspaceId)).settings,
4250
4293
  ),
4251
4294
  });
4295
+ const existingEffectiveFirstPartyTools = allowedFirstPartyMcpToolsForSession(
4296
+ settings,
4297
+ existingSession.firstPartyMcpTools,
4298
+ );
4252
4299
  const { personalConnectionDelegations, mcpAccountBindings } = await freezeConnectionAccounts({
4253
4300
  db,
4254
4301
  accountId: grant.accountId,
@@ -4259,8 +4306,8 @@ async function acceptSessionUserMessageInFileScope(
4259
4306
  source: connectionDelegationSource,
4260
4307
  targetSessionId: sessionId,
4261
4308
  googleDrivePublicationEnabled:
4262
- existingSession.firstPartyMcpTools.includes("editable_artifact_export") &&
4263
- existingSession.firstPartyMcpTools.includes("editable_artifact_export_status") &&
4309
+ existingEffectiveFirstPartyTools.includes("editable_artifact_export") &&
4310
+ existingEffectiveFirstPartyTools.includes("editable_artifact_export_status") &&
4264
4311
  (!existingSession.firstPartyMcpPermissions?.length ||
4265
4312
  (existingSession.firstPartyMcpPermissions.includes("artifacts:read") &&
4266
4313
  existingSession.firstPartyMcpPermissions.includes("artifacts:publish"))),
@@ -0,0 +1,315 @@
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
+ VOICE_CREDIT_USAGE,
22
+ type CreditDebitAttribution,
23
+ type Database,
24
+ } from "@opengeni/db";
25
+ import {
26
+ TranscriptionBillingRefusedError,
27
+ TranscriptionServiceError,
28
+ type TranscriptionBilling,
29
+ type TranscriptionBillingContext,
30
+ } from "../transcription";
31
+ import { voiceCreditStanding, voiceInsufficientCreditsMessage } from "./realtime-voice-billing";
32
+
33
+ /** Credit debit type and usage source for deployment-funded voice input. */
34
+ export const VOICE_INPUT_DEBIT_TYPE = VOICE_TRANSCRIPTION_DEBIT_TYPE;
35
+ export const VOICE_INPUT_SOURCE_TYPE = VOICE_TRANSCRIPTION_SOURCE_TYPE;
36
+
37
+ /** How the billed quantity was measured; recorded on the ledger entry. */
38
+ export type VoiceInputBillingBasis =
39
+ | "provider_tokens"
40
+ | "provider_duration"
41
+ | "server_duration"
42
+ | "reconciled_receipt";
43
+
44
+ /**
45
+ * Pick the billed measurement. Provider usage wins when this deployment can
46
+ * price it (token usage with token rates, or a reported duration). Otherwise
47
+ * the duration the server measured from WAV bytes it produced bills. Every
48
+ * deployment-funded call carries that server duration, so an unpriced usage
49
+ * shape never leaves a call unbillable. A client-reported duration is never a
50
+ * billing input.
51
+ */
52
+ export function voiceInputBillableUsage(input: {
53
+ pricing: VoiceInputPricing;
54
+ usage: VoiceInputUsage | null;
55
+ trustedDurationSeconds?: number | undefined;
56
+ }): { usage: VoiceInputUsage; basis: VoiceInputBillingBasis } {
57
+ if (input.usage?.kind === "tokens" && voiceInputPricingHasTokenRates(input.pricing)) {
58
+ return { usage: input.usage, basis: "provider_tokens" };
59
+ }
60
+ if (input.usage?.kind === "duration") {
61
+ return { usage: input.usage, basis: "provider_duration" };
62
+ }
63
+ if (
64
+ input.trustedDurationSeconds !== undefined &&
65
+ Number.isFinite(input.trustedDurationSeconds) &&
66
+ input.trustedDurationSeconds >= 0
67
+ ) {
68
+ return {
69
+ usage: { kind: "duration", seconds: input.trustedDurationSeconds },
70
+ basis: "server_duration",
71
+ };
72
+ }
73
+ throw new TranscriptionServiceError({
74
+ code: "provider",
75
+ message: "Transcription usage was not reported.",
76
+ });
77
+ }
78
+
79
+ function initiatingHuman(attribution: CreditDebitAttribution): string | null {
80
+ return attribution.kind === "human" || attribution.kind === "turn"
81
+ ? attribution.initiatingHumanSubjectId
82
+ : null;
83
+ }
84
+
85
+ function startOfUtcMonth(): Date {
86
+ const now = new Date();
87
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
88
+ }
89
+
90
+ /**
91
+ * Same admission shape as knowledge-search and session admission:
92
+ * voice-spendable credits (general plus signup, not model-scoped promotional
93
+ * credits) must be positive, the workspace/member
94
+ * allowance must not be exhausted, and a static monthly cost cap applies.
95
+ * Admission is a read, not a reservation; settlement is post-use and may take
96
+ * an account below zero by at most the concurrent in-flight calls.
97
+ */
98
+ export function createVoiceInputBilling(deps: {
99
+ db: Database;
100
+ settings: Settings;
101
+ /** Reconciliation is best-effort at admission; failures are reported here. */
102
+ onReconcileError?: (error: unknown, scope: { accountId: string; workspaceId: string }) => void;
103
+ /** Minimum spacing of admission-time reconciliation per workspace (default 5 min). */
104
+ reconcileIntervalMilliseconds?: number;
105
+ }): TranscriptionBilling {
106
+ const reconcileInterval = deps.reconcileIntervalMilliseconds ?? 5 * 60_000;
107
+ const lastReconciled = new Map<string, number>();
108
+ const reconcileDue = (key: string) => {
109
+ const now = Date.now();
110
+ const last = lastReconciled.get(key);
111
+ if (last !== undefined && now - last < reconcileInterval) return false;
112
+ if (lastReconciled.size >= 10_000) lastReconciled.clear();
113
+ lastReconciled.set(key, now);
114
+ return true;
115
+ };
116
+ return {
117
+ async admit({ accountId, workspaceId, attribution }) {
118
+ if (!voiceInputCreditBillingActive(deps.settings)) return;
119
+ if (attribution.kind === "unknown") {
120
+ // Not a microphone problem: the caller's credential has no payer the
121
+ // ledger can attribute, so the deployment refuses by policy.
122
+ throw new TranscriptionServiceError({
123
+ code: "policy_blocked",
124
+ status: 403,
125
+ message: "Voice input payer could not be verified.",
126
+ });
127
+ }
128
+ // Debits whose receipt committed but whose ledger write failed are
129
+ // applied before the balance read, so unsettled use counts against it.
130
+ // Throttled per workspace: failed debits are rare, the scan is not free.
131
+ if (reconcileDue(`${accountId}:${workspaceId}`)) {
132
+ await reconcileUnsettledVoiceInputCharges(deps.db, { accountId, workspaceId }).catch(
133
+ (error: unknown) => {
134
+ deps.onReconcileError?.(error, { accountId, workspaceId });
135
+ },
136
+ );
137
+ }
138
+ const balance = await getSpendableCreditBalance(deps.db, accountId, VOICE_CREDIT_USAGE);
139
+ if (balance.balanceMicros <= 0) {
140
+ throw new TranscriptionBillingRefusedError({
141
+ code: "insufficient_credits",
142
+ message: voiceInsufficientCreditsMessage("Voice input", voiceCreditStanding(balance)),
143
+ });
144
+ }
145
+ const refusal = await checkWorkspaceAllowance(deps.db, {
146
+ accountId,
147
+ workspaceId,
148
+ subjectId: initiatingHuman(attribution),
149
+ });
150
+ if (refusal) {
151
+ throw new TranscriptionBillingRefusedError({
152
+ code: refusal.code,
153
+ message: refusal.message,
154
+ details: {
155
+ scope: refusal.scope,
156
+ resetsAt: refusal.resetsAt,
157
+ ...(refusal.subjectId ? { subjectId: refusal.subjectId } : {}),
158
+ },
159
+ });
160
+ }
161
+ if (
162
+ deps.settings.usageLimitsMode === "static" ||
163
+ deps.settings.usageLimitsMode === "managed"
164
+ ) {
165
+ const cap = configuredStaticUsageLimits(deps.settings).maxMonthlyCostMicrosPerAccount;
166
+ if (cap) {
167
+ const used = await sumUsageQuantity(deps.db, {
168
+ accountId,
169
+ eventType: "model.cost",
170
+ since: startOfUtcMonth(),
171
+ });
172
+ if (used >= cap) {
173
+ throw new TranscriptionBillingRefusedError({
174
+ code: "monthly_model_cost_limit",
175
+ message: "The monthly model cost limit has been reached.",
176
+ });
177
+ }
178
+ }
179
+ }
180
+ },
181
+
182
+ async settle(input) {
183
+ if (!voiceInputCreditBillingActive(deps.settings)) return { creditCostMicros: 0 };
184
+ const { usage, basis } = voiceInputBillableUsage({
185
+ pricing: input.pricing,
186
+ usage: input.usage,
187
+ trustedDurationSeconds: input.billing.trustedDurationSeconds,
188
+ });
189
+ const cost = calculateVoiceInputCost(input.pricing, usage);
190
+ return await settleVoiceInputUsage(deps.db, {
191
+ accountId: input.accountId,
192
+ workspaceId: input.workspaceId,
193
+ providerId: input.providerId,
194
+ model: input.model,
195
+ billing: input.billing,
196
+ usage,
197
+ basis,
198
+ providerCostMicros: cost.providerCostMicros,
199
+ creditCostMicros: cost.creditCostMicros,
200
+ });
201
+ },
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Two commits, in order: the `model.cost` usage receipt (Insights spend,
207
+ * monthly cost cap, member-allowance attribution), then the post-use credit
208
+ * debit. The receipt is the first-writer authority for the amount and the
209
+ * durable record of the charge: if the debit commit fails, the receipt stays
210
+ * and {@link reconcileUnsettledVoiceInputCharges} applies the same debit (same
211
+ * idempotency key) on the workspace's next admission. A retried unit settles
212
+ * exactly once at the originally recorded price.
213
+ */
214
+ export async function settleVoiceInputUsage(
215
+ db: Database,
216
+ input: {
217
+ accountId: string;
218
+ workspaceId: string;
219
+ providerId: string;
220
+ model: string;
221
+ billing: TranscriptionBillingContext;
222
+ usage: VoiceInputUsage;
223
+ basis: VoiceInputBillingBasis;
224
+ providerCostMicros: number;
225
+ creditCostMicros: number;
226
+ },
227
+ ): Promise<{ creditCostMicros: number }> {
228
+ const attribution = input.billing.attribution;
229
+ const keys = voiceTranscriptionSettlementKeys({
230
+ workspaceId: input.workspaceId,
231
+ sourceId: input.billing.sourceId,
232
+ });
233
+ const receipt = await recordUsageEvent(db, {
234
+ accountId: input.accountId,
235
+ workspaceId: input.workspaceId,
236
+ eventType: "model.cost",
237
+ quantity: input.creditCostMicros,
238
+ unit: "usd_micros",
239
+ sourceResourceType: VOICE_TRANSCRIPTION_SOURCE_TYPE,
240
+ sourceResourceId: input.billing.sourceId,
241
+ idempotencyKey: keys.usageIdempotencyKey,
242
+ // The API is the writer; the payer is the trusted attribution snapshot.
243
+ initiator: { kind: "service", subjectId: "api:voice-input" },
244
+ initiatorContext: { creditDebitAttribution: attribution },
245
+ });
246
+ const amount = Number(receipt.quantity);
247
+ if (amount > 0) {
248
+ await applyCreditDebitAfterUse(db, {
249
+ accountId: input.accountId,
250
+ workspaceId: input.workspaceId,
251
+ type: VOICE_TRANSCRIPTION_DEBIT_TYPE,
252
+ amountMicros: amount,
253
+ sourceType: VOICE_TRANSCRIPTION_SOURCE_TYPE,
254
+ sourceId: input.billing.sourceId,
255
+ idempotencyKey: keys.debitIdempotencyKey,
256
+ usage: VOICE_CREDIT_USAGE,
257
+ metadata: {
258
+ providerId: input.providerId,
259
+ model: input.model,
260
+ basis: input.basis,
261
+ providerCostMicros: input.providerCostMicros,
262
+ ...(input.usage.kind === "tokens"
263
+ ? {
264
+ inputTokens: input.usage.inputTokens,
265
+ audioInputTokens: input.usage.audioInputTokens,
266
+ textInputTokens: input.usage.textInputTokens,
267
+ outputTokens: input.usage.outputTokens,
268
+ }
269
+ : { audioMilliseconds: Math.ceil(input.usage.seconds * 1_000) }),
270
+ ...(attribution.kind === "unknown" ? {} : creditDebitAttributionMetadata(attribution)),
271
+ },
272
+ });
273
+ }
274
+ return { creditCostMicros: amount };
275
+ }
276
+
277
+ /**
278
+ * Apply the debit for every voice usage receipt in this workspace whose debit
279
+ * never committed. Idempotent with the inline settlement (same key), bounded
280
+ * per call, and safe to run concurrently.
281
+ */
282
+ export async function reconcileUnsettledVoiceInputCharges(
283
+ db: Database,
284
+ input: { accountId: string; workspaceId: string; minAgeMilliseconds?: number },
285
+ ): Promise<{ reconciled: number }> {
286
+ const pending = await listUnsettledVoiceTranscriptionCharges(db, {
287
+ accountId: input.accountId,
288
+ workspaceId: input.workspaceId,
289
+ ...(input.minAgeMilliseconds !== undefined
290
+ ? { minAgeMilliseconds: input.minAgeMilliseconds }
291
+ : {}),
292
+ });
293
+ for (const charge of pending) {
294
+ await applyCreditDebitAfterUse(db, {
295
+ accountId: input.accountId,
296
+ workspaceId: input.workspaceId,
297
+ type: VOICE_TRANSCRIPTION_DEBIT_TYPE,
298
+ amountMicros: charge.amountMicros,
299
+ sourceType: VOICE_TRANSCRIPTION_SOURCE_TYPE,
300
+ sourceId: charge.sourceId,
301
+ idempotencyKey: voiceTranscriptionSettlementKeys({
302
+ workspaceId: input.workspaceId,
303
+ sourceId: charge.sourceId,
304
+ }).debitIdempotencyKey,
305
+ usage: VOICE_CREDIT_USAGE,
306
+ metadata: {
307
+ basis: "reconciled_receipt" satisfies VoiceInputBillingBasis,
308
+ ...(charge.attribution.kind === "unknown"
309
+ ? {}
310
+ : creditDebitAttributionMetadata(charge.attribution)),
311
+ },
312
+ });
313
+ }
314
+ return { reconciled: pending.length };
315
+ }