@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
@@ -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: Pick<Settings, "defaultFirstPartyMcpTools" | "allowedFirstPartyMcpTools">;
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 ?? resolveFirstPartyMcpToolPolicy(settings).default;
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: Pick<Settings, "allowedFirstPartyMcpTools" | "defaultFirstPartyMcpTools">,
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 { Settings } from "@opengeni/config";
2
+ import { allowedFirstPartyMcpToolsForSession, type Settings } from "@opengeni/config";
3
3
  import { requireSession, requireWorkspace, type Database } from "@opengeni/db";
4
4
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
5
5
  import {
@@ -44,6 +44,10 @@ export async function freezeSessionRealtimeConnectionAccounts(input: {
44
44
  (await requireWorkspace(db, workspaceId)).settings,
45
45
  ),
46
46
  });
47
+ const effectiveFirstPartyTools = allowedFirstPartyMcpToolsForSession(
48
+ settings,
49
+ session.firstPartyMcpTools,
50
+ );
47
51
  return await freezeConnectionAccounts({
48
52
  db,
49
53
  settings: runtimeSettings,
@@ -54,8 +58,8 @@ export async function freezeSessionRealtimeConnectionAccounts(input: {
54
58
  source: personalConnectionDelegationSourceForGrant(grant),
55
59
  targetSessionId: sessionId,
56
60
  googleDrivePublicationEnabled:
57
- session.firstPartyMcpTools.includes("editable_artifact_export") &&
58
- session.firstPartyMcpTools.includes("editable_artifact_export_status") &&
61
+ effectiveFirstPartyTools.includes("editable_artifact_export") &&
62
+ effectiveFirstPartyTools.includes("editable_artifact_export_status") &&
59
63
  (!session.firstPartyMcpPermissions?.length ||
60
64
  (session.firstPartyMcpPermissions.includes("artifacts:read") &&
61
65
  session.firstPartyMcpPermissions.includes("artifacts:publish"))),
@@ -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) hostedToolNames.push("web_search");
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: