@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.
- package/dist/application/modal-native-live-declaration-join.d.ts +37 -0
- package/dist/application/modal-native-original-configuration.d.ts +79 -0
- package/dist/domain/capabilities.d.ts +1 -0
- package/dist/domain/connector-tool-permissions.d.ts +1 -1
- package/dist/domain/realtime-voice-billing.d.ts +95 -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-start-timing.d.ts +1 -1
- package/dist/domain/session-tool-policy.d.ts +11 -7
- package/dist/domain/voice-input-billing.d.ts +77 -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 +1065 -365
- package/dist/index.js.map +1 -1
- package/dist/transcription.d.ts +119 -2
- package/package.json +12 -12
- package/src/application/modal-native-live-declaration-join.ts +196 -0
- package/src/application/modal-native-original-configuration.ts +261 -0
- package/src/default-session-model.ts +58 -88
- package/src/domain/capabilities.ts +44 -2
- package/src/domain/connector-tool-permissions.ts +24 -8
- package/src/domain/realtime-voice-billing.ts +400 -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-start-timing.ts +5 -0
- package/src/domain/session-tool-policy.ts +20 -4
- package/src/domain/sessions.ts +145 -98
- package/src/domain/voice-input-billing.ts +315 -0
- package/src/domain/web-search-billing.ts +154 -0
- package/src/index.ts +3 -0
- package/src/model-catalog.ts +32 -38
- package/src/sandbox/routing.ts +59 -25
- package/src/transcription.ts +122 -3
|
@@ -0,0 +1,400 @@
|
|
|
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
|
+
VOICE_CREDIT_USAGE,
|
|
24
|
+
withRlsContext,
|
|
25
|
+
type CreditDebitAttribution,
|
|
26
|
+
type Database,
|
|
27
|
+
} from "@opengeni/db";
|
|
28
|
+
import { TranscriptionBillingRefusedError } from "../transcription";
|
|
29
|
+
|
|
30
|
+
/** Credit debit type and usage source for deployment-funded live voice. */
|
|
31
|
+
export const REALTIME_VOICE_DEBIT_TYPE = "voice_realtime_debit";
|
|
32
|
+
export const REALTIME_VOICE_SOURCE_TYPE = "voice_realtime";
|
|
33
|
+
|
|
34
|
+
export type DeploymentRealtimeVoice = {
|
|
35
|
+
provider: "azure-live" | "ai-gateway";
|
|
36
|
+
/** Credentials exist and this deployment offers the model. */
|
|
37
|
+
configured: boolean;
|
|
38
|
+
pricing: RealtimeVoicePricing | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Live-voice models the deployment pays the provider for. Connected Codex,
|
|
43
|
+
* SuperGrok, and a workspace's own Gateway key are paid by the workspace and
|
|
44
|
+
* return null. (The catalog lists hosted GPT Live instead of the managed
|
|
45
|
+
* Gateway choices when both are configured; both remain priced and gated.)
|
|
46
|
+
*/
|
|
47
|
+
const reportedPricingErrors = new Set<string>();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Malformed pricing withholds the model (like voice input) instead of failing
|
|
51
|
+
* API/worker boot; each distinct error is logged once.
|
|
52
|
+
*/
|
|
53
|
+
function tolerantPricing<T>(parse: () => T, fallback: T): T {
|
|
54
|
+
try {
|
|
55
|
+
return parse();
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
if (!reportedPricingErrors.has(message)) {
|
|
59
|
+
reportedPricingErrors.add(message);
|
|
60
|
+
console.error(`Live voice pricing ignored: ${message}`);
|
|
61
|
+
}
|
|
62
|
+
return fallback;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function deploymentRealtimeVoice(
|
|
67
|
+
settings: Settings,
|
|
68
|
+
model: SessionRealtimeModel | string,
|
|
69
|
+
): DeploymentRealtimeVoice | null {
|
|
70
|
+
const azureConfigured = Boolean(settings.azureLiveEndpoint && settings.azureLiveApiKey?.trim());
|
|
71
|
+
if (model === AZURE_LIVE_REALTIME_MODEL_ID) {
|
|
72
|
+
return {
|
|
73
|
+
provider: "azure-live",
|
|
74
|
+
configured: azureConfigured,
|
|
75
|
+
pricing: tolerantPricing(
|
|
76
|
+
() =>
|
|
77
|
+
parseRealtimeVoicePricingJson(
|
|
78
|
+
settings.azureLivePricingJson,
|
|
79
|
+
"OPENGENI_AZURE_LIVE_PRICING_JSON",
|
|
80
|
+
),
|
|
81
|
+
null,
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
for (const gateway of Object.values(AI_GATEWAY_REALTIME_MODELS)) {
|
|
86
|
+
if (gateway.managedModelId !== model) continue;
|
|
87
|
+
return {
|
|
88
|
+
provider: "ai-gateway",
|
|
89
|
+
configured: Boolean(settings.vercelAiGatewayApiKey),
|
|
90
|
+
pricing:
|
|
91
|
+
tolerantPricing(
|
|
92
|
+
() =>
|
|
93
|
+
parseRealtimeVoicePricingTableJson(
|
|
94
|
+
settings.aiGatewayRealtimePricingJson,
|
|
95
|
+
"OPENGENI_AI_GATEWAY_REALTIME_PRICING_JSON",
|
|
96
|
+
),
|
|
97
|
+
{} as Record<string, RealtimeVoicePricing>,
|
|
98
|
+
)[gateway.upstreamModelId] ?? null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type RealtimeVoiceUnavailableCode = "not_configured" | "pricing_unconfigured";
|
|
105
|
+
|
|
106
|
+
/** The deployment cannot offer this live-voice model at all (not a credit refusal). */
|
|
107
|
+
export class RealtimeVoiceUnavailableError extends Error {
|
|
108
|
+
readonly name = "RealtimeVoiceUnavailableError";
|
|
109
|
+
constructor(
|
|
110
|
+
readonly code: RealtimeVoiceUnavailableCode,
|
|
111
|
+
message: string,
|
|
112
|
+
) {
|
|
113
|
+
super(message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Static offer check: configured, and priced whenever credits are enforced. */
|
|
118
|
+
export function realtimeVoiceOfferProblem(
|
|
119
|
+
settings: Settings,
|
|
120
|
+
voice: DeploymentRealtimeVoice,
|
|
121
|
+
): RealtimeVoiceUnavailableError | null {
|
|
122
|
+
if (!voice.configured) {
|
|
123
|
+
return new RealtimeVoiceUnavailableError(
|
|
124
|
+
"not_configured",
|
|
125
|
+
"Opengeni live voice is not configured on this deployment.",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (voiceInputCreditBillingActive(settings) && !voice.pricing) {
|
|
129
|
+
return new RealtimeVoiceUnavailableError(
|
|
130
|
+
"pricing_unconfigured",
|
|
131
|
+
"Opengeni live voice is not available on this deployment yet.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const REALTIME_VOICE_INSUFFICIENT_CREDITS_MESSAGE =
|
|
138
|
+
"Live voice needs Opengeni credits. Add credits to continue.";
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Credits voice can spend (general and signup credits), only model-scoped
|
|
142
|
+
* promotional credits, or neither.
|
|
143
|
+
*/
|
|
144
|
+
export type VoiceCreditStanding = "spendable" | "promotional_only" | "none";
|
|
145
|
+
|
|
146
|
+
/** `balance` must be the voice-spendable balance (see {@link VOICE_CREDIT_USAGE}). */
|
|
147
|
+
export function voiceCreditStanding(balance: {
|
|
148
|
+
balanceMicros: number;
|
|
149
|
+
promotionalCredits?: readonly { remainingMicros: number }[] | undefined;
|
|
150
|
+
}): VoiceCreditStanding {
|
|
151
|
+
if (balance.balanceMicros > 0) return "spendable";
|
|
152
|
+
return (balance.promotionalCredits ?? []).some((grant) => grant.remainingMicros > 0)
|
|
153
|
+
? "promotional_only"
|
|
154
|
+
: "none";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Insufficient-credit copy for voice. Signup credits pay for voice like
|
|
159
|
+
* general credits; other promotional credits are scoped to chat models, so an
|
|
160
|
+
* account holding only those must not be told it has nothing.
|
|
161
|
+
*/
|
|
162
|
+
export function voiceInsufficientCreditsMessage(
|
|
163
|
+
feature: "Live voice" | "Voice input",
|
|
164
|
+
standing: VoiceCreditStanding,
|
|
165
|
+
): string {
|
|
166
|
+
return standing === "promotional_only"
|
|
167
|
+
? `Promotional credits don't cover ${feature.toLowerCase()}. Add credits to use it.`
|
|
168
|
+
: `${feature} needs Opengeni credits. Add credits to continue.`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function startOfUtcMonth(now: Date): Date {
|
|
172
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function initiatingHuman(attribution: CreditDebitAttribution): string | null {
|
|
176
|
+
return attribution.kind === "human" || attribution.kind === "turn"
|
|
177
|
+
? attribution.initiatingHumanSubjectId
|
|
178
|
+
: null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type RealtimeVoiceSettlement = {
|
|
182
|
+
/** Credits debited by this settlement call (0 on replay). */
|
|
183
|
+
debitedMicros: number;
|
|
184
|
+
/** Why the live call must stop now, when it must. */
|
|
185
|
+
stop: TranscriptionBillingRefusedError | RealtimeVoiceUnavailableError | null;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Credit admission and time metering for deployment-funded live voice.
|
|
190
|
+
*
|
|
191
|
+
* Admission mirrors voice-input admission (same refusal codes and HTTP
|
|
192
|
+
* shape): voice-spendable credits (general plus signup) must be positive, the workspace/member
|
|
193
|
+
* allowance must not be exhausted, and the static monthly cost cap applies.
|
|
194
|
+
* Metering bills every started minute of each issued provider connection,
|
|
195
|
+
* measured only from server-observed facts (connection claim time, owner
|
|
196
|
+
* heartbeats, owner-proven end). Each (connection, minute) is one idempotent
|
|
197
|
+
* usage receipt plus one post-use debit.
|
|
198
|
+
*/
|
|
199
|
+
export function createRealtimeVoiceBilling(deps: { db: Database; settings: Settings }) {
|
|
200
|
+
const billingActive = () => voiceInputCreditBillingActive(deps.settings);
|
|
201
|
+
|
|
202
|
+
async function refusal(input: {
|
|
203
|
+
accountId: string;
|
|
204
|
+
workspaceId: string;
|
|
205
|
+
attribution: CreditDebitAttribution;
|
|
206
|
+
now: Date;
|
|
207
|
+
}): Promise<TranscriptionBillingRefusedError | null> {
|
|
208
|
+
const balance = await getSpendableCreditBalance(deps.db, input.accountId, VOICE_CREDIT_USAGE);
|
|
209
|
+
if (balance.balanceMicros <= 0) {
|
|
210
|
+
return new TranscriptionBillingRefusedError({
|
|
211
|
+
code: "insufficient_credits",
|
|
212
|
+
message: voiceInsufficientCreditsMessage("Live voice", voiceCreditStanding(balance)),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const allowance = await checkWorkspaceAllowance(deps.db, {
|
|
216
|
+
accountId: input.accountId,
|
|
217
|
+
workspaceId: input.workspaceId,
|
|
218
|
+
subjectId: initiatingHuman(input.attribution),
|
|
219
|
+
});
|
|
220
|
+
if (allowance) {
|
|
221
|
+
return new TranscriptionBillingRefusedError({
|
|
222
|
+
code: allowance.code,
|
|
223
|
+
message: allowance.message,
|
|
224
|
+
details: {
|
|
225
|
+
scope: allowance.scope,
|
|
226
|
+
resetsAt: allowance.resetsAt,
|
|
227
|
+
...(allowance.subjectId ? { subjectId: allowance.subjectId } : {}),
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (deps.settings.usageLimitsMode === "static" || deps.settings.usageLimitsMode === "managed") {
|
|
232
|
+
const cap = configuredStaticUsageLimits(deps.settings).maxMonthlyCostMicrosPerAccount;
|
|
233
|
+
if (cap) {
|
|
234
|
+
const used = await sumUsageQuantity(deps.db, {
|
|
235
|
+
accountId: input.accountId,
|
|
236
|
+
eventType: "model.cost",
|
|
237
|
+
since: startOfUtcMonth(input.now),
|
|
238
|
+
});
|
|
239
|
+
if (used >= cap) {
|
|
240
|
+
return new TranscriptionBillingRefusedError({
|
|
241
|
+
code: "monthly_model_cost_limit",
|
|
242
|
+
message: "The monthly model cost limit has been reached.",
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
/**
|
|
252
|
+
* Throws RealtimeVoiceUnavailableError when the deployment cannot offer
|
|
253
|
+
* the model and TranscriptionBillingRefusedError for a credit refusal.
|
|
254
|
+
* Workspace-funded models are always admitted here.
|
|
255
|
+
*/
|
|
256
|
+
async admit(input: {
|
|
257
|
+
accountId: string;
|
|
258
|
+
workspaceId: string;
|
|
259
|
+
model: SessionRealtimeModel;
|
|
260
|
+
attribution: CreditDebitAttribution;
|
|
261
|
+
now?: Date;
|
|
262
|
+
}): Promise<void> {
|
|
263
|
+
const voice = deploymentRealtimeVoice(deps.settings, input.model);
|
|
264
|
+
if (!voice) return;
|
|
265
|
+
const problem = realtimeVoiceOfferProblem(deps.settings, voice);
|
|
266
|
+
if (problem) throw problem;
|
|
267
|
+
if (!billingActive()) return;
|
|
268
|
+
const refused = await refusal({ ...input, now: input.now ?? new Date() });
|
|
269
|
+
if (refused) throw refused;
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
/** Spendable credits are positive (always true when credits are not enforced). */
|
|
273
|
+
async hasSpendableCredits(accountId: string): Promise<boolean> {
|
|
274
|
+
return (await this.creditStanding(accountId)) === "spendable";
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
/** Credit standing for live voice ("spendable" when credits are not enforced). */
|
|
278
|
+
async creditStanding(accountId: string): Promise<VoiceCreditStanding> {
|
|
279
|
+
if (!billingActive()) return "spendable";
|
|
280
|
+
return voiceCreditStanding(
|
|
281
|
+
await getSpendableCreditBalance(deps.db, accountId, VOICE_CREDIT_USAGE),
|
|
282
|
+
);
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Settle every started minute observed so far for one live-voice mode and
|
|
287
|
+
* report whether the call must stop. Safe to call repeatedly and
|
|
288
|
+
* concurrently: receipts and debits are idempotent per (connection, minute).
|
|
289
|
+
*/
|
|
290
|
+
async settle(input: {
|
|
291
|
+
workspaceId: string;
|
|
292
|
+
sessionId: string;
|
|
293
|
+
realtimeId: string;
|
|
294
|
+
/** Authenticated caller; its attribution applies only when it owns the call. */
|
|
295
|
+
callerSubjectId: string;
|
|
296
|
+
attribution: CreditDebitAttribution;
|
|
297
|
+
now?: Date;
|
|
298
|
+
}): Promise<RealtimeVoiceSettlement> {
|
|
299
|
+
const now = input.now ?? new Date();
|
|
300
|
+
if (!billingActive()) return { debitedMicros: 0, stop: null };
|
|
301
|
+
const facts = await loadSessionRealtimeBillingFacts(deps.db, { ...input, now });
|
|
302
|
+
if (!facts) return { debitedMicros: 0, stop: null };
|
|
303
|
+
const voice = deploymentRealtimeVoice(deps.settings, facts.model);
|
|
304
|
+
if (!voice) return { debitedMicros: 0, stop: null };
|
|
305
|
+
if (!voice.pricing) {
|
|
306
|
+
// Pricing was removed after admission: nothing to bill against, so the
|
|
307
|
+
// call must end rather than continue unpriced.
|
|
308
|
+
return {
|
|
309
|
+
debitedMicros: 0,
|
|
310
|
+
stop: new RealtimeVoiceUnavailableError(
|
|
311
|
+
"pricing_unconfigured",
|
|
312
|
+
"Opengeni live voice is no longer available on this deployment.",
|
|
313
|
+
),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const attribution: CreditDebitAttribution =
|
|
317
|
+
input.callerSubjectId === facts.ownerSubjectId ? input.attribution : { kind: "unknown" };
|
|
318
|
+
const cost = realtimeVoiceMinuteCreditMicros(voice.pricing);
|
|
319
|
+
const minutes = facts.connections.flatMap((connection) =>
|
|
320
|
+
Array.from(
|
|
321
|
+
{ length: realtimeVoiceStartedMinutes(connection.startedAt, connection.observedUntil) },
|
|
322
|
+
(_, index) => ({
|
|
323
|
+
connectionId: connection.id,
|
|
324
|
+
minute: index + 1,
|
|
325
|
+
startsAt: new Date(connection.startedAt.getTime() + index * 60_000),
|
|
326
|
+
key: `voice.realtime_cost:${connection.id}:${index + 1}`,
|
|
327
|
+
}),
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
const recorded = await existingUsageEventIdempotencyKeys(deps.db, {
|
|
331
|
+
accountId: facts.accountId,
|
|
332
|
+
workspaceId: facts.workspaceId,
|
|
333
|
+
keys: minutes.map((minute) => minute.key),
|
|
334
|
+
});
|
|
335
|
+
let debitedMicros = 0;
|
|
336
|
+
for (const minute of minutes) {
|
|
337
|
+
if (recorded.has(minute.key)) continue;
|
|
338
|
+
debitedMicros += await withRlsContext(
|
|
339
|
+
deps.db,
|
|
340
|
+
{ accountId: facts.accountId, workspaceId: facts.workspaceId },
|
|
341
|
+
async (tx) => {
|
|
342
|
+
const sourceId = `${minute.connectionId}:${minute.minute}`;
|
|
343
|
+
const receipt = await recordUsageEvent(tx, {
|
|
344
|
+
accountId: facts.accountId,
|
|
345
|
+
workspaceId: facts.workspaceId,
|
|
346
|
+
sessionId: facts.sessionId,
|
|
347
|
+
eventType: "model.cost",
|
|
348
|
+
quantity: cost.creditCostMicros,
|
|
349
|
+
unit: "usd_micros",
|
|
350
|
+
sourceResourceType: REALTIME_VOICE_SOURCE_TYPE,
|
|
351
|
+
sourceResourceId: sourceId,
|
|
352
|
+
idempotencyKey: minute.key,
|
|
353
|
+
occurredAt: minute.startsAt,
|
|
354
|
+
initiator: { kind: "service", subjectId: "api:voice-realtime" },
|
|
355
|
+
initiatorContext: { creditDebitAttribution: attribution },
|
|
356
|
+
});
|
|
357
|
+
const amount = Number(receipt.quantity);
|
|
358
|
+
if (amount <= 0) return 0;
|
|
359
|
+
const debit = await applyCreditDebitAfterUse(tx, {
|
|
360
|
+
accountId: facts.accountId,
|
|
361
|
+
workspaceId: facts.workspaceId,
|
|
362
|
+
type: REALTIME_VOICE_DEBIT_TYPE,
|
|
363
|
+
amountMicros: amount,
|
|
364
|
+
sourceType: REALTIME_VOICE_SOURCE_TYPE,
|
|
365
|
+
sourceId,
|
|
366
|
+
idempotencyKey: `credit:${REALTIME_VOICE_DEBIT_TYPE}:${sourceId}`,
|
|
367
|
+
occurredAt: minute.startsAt,
|
|
368
|
+
usage: VOICE_CREDIT_USAGE,
|
|
369
|
+
metadata: {
|
|
370
|
+
provider: voice.provider,
|
|
371
|
+
model: facts.model,
|
|
372
|
+
realtimeId: facts.realtimeId,
|
|
373
|
+
connectionId: minute.connectionId,
|
|
374
|
+
minute: minute.minute,
|
|
375
|
+
basis: "server_observed_started_minute",
|
|
376
|
+
providerCostMicros: cost.providerCostMicros,
|
|
377
|
+
...(attribution.kind === "unknown"
|
|
378
|
+
? {}
|
|
379
|
+
: creditDebitAttributionMetadata(attribution)),
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
return debit.debitedMicros;
|
|
383
|
+
},
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
const stop =
|
|
387
|
+
facts.state === "active"
|
|
388
|
+
? await refusal({
|
|
389
|
+
accountId: facts.accountId,
|
|
390
|
+
workspaceId: facts.workspaceId,
|
|
391
|
+
attribution,
|
|
392
|
+
now,
|
|
393
|
+
})
|
|
394
|
+
: null;
|
|
395
|
+
return { debitedMicros, stop };
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
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;
|
|
@@ -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"))),
|
|
@@ -2,10 +2,15 @@ import { withTraceContext, type Observability, type Span } from "@opengeni/obser
|
|
|
2
2
|
|
|
3
3
|
type SessionStartPhase =
|
|
4
4
|
| "workspace_read"
|
|
5
|
+
| "idempotent_replay"
|
|
5
6
|
| "model_catalog_initial"
|
|
6
7
|
| "default_model"
|
|
7
8
|
| "model_catalog_effective"
|
|
8
9
|
| "capability_settings"
|
|
10
|
+
| "resource_validation"
|
|
11
|
+
| "rig_binding"
|
|
12
|
+
| "model_admission"
|
|
13
|
+
| "connection_freeze"
|
|
9
14
|
| "initiator_freeze"
|
|
10
15
|
| "allowance"
|
|
11
16
|
| "shell_insert"
|
|
@@ -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 {
|
|
@@ -234,12 +235,14 @@ export async function workspaceSessionToolPolicyContext(
|
|
|
234
235
|
workspaceId: string,
|
|
235
236
|
settings: Settings,
|
|
236
237
|
subjectId?: string,
|
|
238
|
+
/** An in-flight read of this exact workspace a caller already started. */
|
|
239
|
+
workspaceRead?: ReturnType<typeof requireWorkspace>,
|
|
237
240
|
): Promise<{ workspaceServerIds: string[]; workspaceDefaultServerIds: string[] }> {
|
|
238
241
|
const [runtimeSettings, workspace] = await Promise.all([
|
|
239
242
|
settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings, {
|
|
240
243
|
...(subjectId ? { subjectId } : {}),
|
|
241
244
|
}),
|
|
242
|
-
requireWorkspace(db, workspaceId),
|
|
245
|
+
workspaceRead ?? requireWorkspace(db, workspaceId),
|
|
243
246
|
]);
|
|
244
247
|
return {
|
|
245
248
|
workspaceServerIds: sortedIds(runtimeSettings.mcpServers.map((server) => server.id)),
|
|
@@ -325,6 +328,8 @@ export async function workspaceSessionEffectiveToolsContext(
|
|
|
325
328
|
workspaceId: string,
|
|
326
329
|
subjectId: string,
|
|
327
330
|
sessions: readonly Session[],
|
|
331
|
+
/** An in-flight read of this exact workspace a caller already started. */
|
|
332
|
+
workspaceRead?: ReturnType<typeof requireWorkspace>,
|
|
328
333
|
): Promise<SessionEffectiveToolsContext> {
|
|
329
334
|
const configured = sessions.filter((session) => session.agent != null);
|
|
330
335
|
const baseline: SessionEffectiveToolsContext = {
|
|
@@ -335,7 +340,7 @@ export async function workspaceSessionEffectiveToolsContext(
|
|
|
335
340
|
};
|
|
336
341
|
if (configured.length === 0) return baseline;
|
|
337
342
|
const [workspace, catalog, descriptors, sandboxes, routerHistory] = await Promise.all([
|
|
338
|
-
requireWorkspace(deps.db, workspaceId),
|
|
343
|
+
workspaceRead ?? requireWorkspace(deps.db, workspaceId),
|
|
339
344
|
resolveWorkspaceCatalogSettings(deps.db, deps.settings, {
|
|
340
345
|
accountId: configured[0]!.accountId,
|
|
341
346
|
workspaceId,
|
|
@@ -443,9 +448,18 @@ export function sessionEffectiveToolProjectionInput(
|
|
|
443
448
|
const supportsImages =
|
|
444
449
|
model?.model.api === "responses" && model.model.capabilities.inputModalities.includes("image");
|
|
445
450
|
const hostedToolNames: AgentFunctionToolName[] = [];
|
|
451
|
+
// Same plan the worker applies: provider web_search/web_fetch where the
|
|
452
|
+
// model has no hosted search (or the operator chose `replace`).
|
|
453
|
+
const webSearchPlan = context
|
|
454
|
+
? webSearchToolPlan(context.settings, {
|
|
455
|
+
hostedWebSearch: model?.model.hostedWebSearch ?? context.settings.webSearchEnabled,
|
|
456
|
+
transportHostedSearch: model?.provider.kind === "xai-subscription",
|
|
457
|
+
})
|
|
458
|
+
: { hostedWebSearch: false, providerTools: [] };
|
|
446
459
|
if (model?.provider.kind === "xai-subscription") {
|
|
447
460
|
if (context?.settings.webSearchEnabled) hostedToolNames.push("web_search", "x_search");
|
|
448
|
-
} else if (model?.model.hostedWebSearch
|
|
461
|
+
} else if (model?.model.hostedWebSearch && webSearchPlan.hostedWebSearch)
|
|
462
|
+
hostedToolNames.push("web_search");
|
|
449
463
|
const mediaAttachment =
|
|
450
464
|
context?.mediaAttachments?.get(session.id) ??
|
|
451
465
|
(context?.objectStorageAvailable === false
|
|
@@ -460,6 +474,7 @@ export function sessionEffectiveToolProjectionInput(
|
|
|
460
474
|
"skill_read",
|
|
461
475
|
...AGENT_SKILL_MANAGE_TOOL_NAMES,
|
|
462
476
|
...media.runtime,
|
|
477
|
+
...webSearchPlan.providerTools,
|
|
463
478
|
]
|
|
464
479
|
: [];
|
|
465
480
|
const sandboxToolNames: AgentFunctionToolName[] = sandboxAvailable
|
|
@@ -549,6 +564,7 @@ export function sessionEffectiveToolProjectionInput(
|
|
|
549
564
|
"skill_read",
|
|
550
565
|
"request_human_input",
|
|
551
566
|
"list_models",
|
|
567
|
+
...webSearchPlan.providerTools,
|
|
552
568
|
...(toolRefs.some((ref) => ref.id === "opengeni" && ref.eager === true) ||
|
|
553
569
|
!progressiveDisclosure
|
|
554
570
|
? firstPartyMcpTools.filter((name) => !interactionNames.has(name) || !progressiveDisclosure)
|
|
@@ -563,7 +579,7 @@ export function sessionEffectiveToolProjectionInput(
|
|
|
563
579
|
: []),
|
|
564
580
|
]),
|
|
565
581
|
hasSkills: context ? sessionHasSkills(session, context) : false,
|
|
566
|
-
webSearch: hostedToolNames.includes("web_search"),
|
|
582
|
+
webSearch: hostedToolNames.includes("web_search") || webSearchPlan.providerTools.length > 0,
|
|
567
583
|
humanInput: context?.humanInputEnabled ?? false,
|
|
568
584
|
media: media.toolsKnown ? media.hosted.length + media.runtime.length > 0 : undefined,
|
|
569
585
|
routerInHistory:
|