@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.
- package/dist/domain/connector-tool-permissions.d.ts +1 -1
- package/dist/domain/knowledge-messages.d.ts +2 -2
- package/dist/domain/realtime-voice-billing.d.ts +81 -0
- package/dist/domain/scheduled-task-access.d.ts +2 -2
- package/dist/domain/scheduled-tasks.d.ts +2 -2
- package/dist/domain/session-connection-accounts.d.ts +1 -1
- package/dist/domain/session-tool-policy.d.ts +4 -4
- package/dist/domain/voice-input-billing.d.ts +76 -0
- package/dist/domain/web-search-billing.d.ts +45 -0
- package/dist/goal-admission.d.ts +24 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +790 -142
- package/dist/index.js.map +1 -1
- package/dist/transcription.d.ts +119 -2
- package/package.json +12 -12
- package/src/domain/connector-tool-permissions.ts +24 -8
- package/src/domain/realtime-voice-billing.ts +330 -0
- package/src/domain/scheduled-task-access.ts +2 -1
- package/src/domain/scheduled-tasks.ts +3 -2
- package/src/domain/session-connection-accounts.ts +7 -3
- package/src/domain/session-tool-policy.ts +14 -2
- package/src/domain/sessions.ts +15 -2
- package/src/domain/voice-input-billing.ts +310 -0
- package/src/domain/web-search-billing.ts +154 -0
- package/src/index.ts +3 -0
- package/src/sandbox/routing.ts +59 -25
- package/src/transcription.ts +122 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AccessGrant } from "@opengeni/contracts";
|
|
2
|
-
import type
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
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:
|
package/src/domain/sessions.ts
CHANGED
|
@@ -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
|
-
|
|
4263
|
-
|
|
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
|