@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
package/dist/transcription.d.ts
CHANGED
|
@@ -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
|
+
"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.
|
|
54
|
-
"@opengeni/codex": "1.
|
|
55
|
-
"@opengeni/config": "1.
|
|
56
|
-
"@opengeni/contracts": "1.
|
|
57
|
-
"@opengeni/db": "1.
|
|
58
|
-
"@opengeni/documents": "1.
|
|
59
|
-
"@opengeni/events": "1.
|
|
60
|
-
"@opengeni/network": "1.
|
|
61
|
-
"@opengeni/observability": "1.
|
|
62
|
-
"@opengeni/runtime": "1.
|
|
63
|
-
"@opengeni/storage": "1.
|
|
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
|
|
200
|
-
input.
|
|
201
|
-
input.workspaceId,
|
|
202
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
2506
|
+
settings: FirstPartyMcpToolPolicySettings,
|
|
2506
2507
|
agentConfig: ScheduledTaskAgentConfig,
|
|
2507
2508
|
): void {
|
|
2508
2509
|
const executionClass = agentConfig.executionClass;
|