@opengeni/core 1.4.0 → 1.4.4

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.
@@ -22,15 +22,11 @@ import {
22
22
  getWorkspace,
23
23
  getWorkspaceConnectionModelRestrictions,
24
24
  getWorkspaceModelPolicy,
25
- getWorkspaceProviderApiKeyConnectionMetadata,
26
- listWorkspaceProviderCustomModels,
27
- listOrganizationModelProviderCustomModelsForWorkspace,
28
- listWorkspaceGatewayCustomModels,
29
- listWorkspaceOpenRouterCustomModels,
30
- organizationModelProviderConnectionActiveForWorkspace,
25
+ getOrganizationModelProviderCatalogForWorkspace,
26
+ listConnectionsMetadata,
27
+ listWorkspaceProviderCustomModelsByKind,
31
28
  workspaceCodexSubscriptionActive,
32
- workspaceOpenRouterConnectionActive,
33
- workspaceVercelAiGatewayConnectionActive,
29
+ workspaceProviderApiKeyConnectionMetadataFromConnections,
34
30
  getBillingBalance,
35
31
  spendableCreditMicros,
36
32
  workspaceXaiSubscriptionActive,
@@ -41,6 +37,7 @@ import {
41
37
  workspaceClaudeSubscriptionActiveForAuthority,
42
38
  type ConnectionModelRestrictions,
43
39
  type Database,
40
+ type WorkspaceCustomModelProviderKind,
44
41
  } from "@opengeni/db";
45
42
  import {
46
43
  isWorkspaceModelAdmissible,
@@ -422,82 +419,43 @@ export async function loadWorkspaceModelSelectionInput(
422
419
  options: { observeAvailability?: boolean } = {},
423
420
  ): Promise<WorkspaceModelSelectionInput> {
424
421
  const { accountId, workspaceId } = context;
425
- const restrictionsRead = (async () =>
426
- connectionRestrictionsAndXaiReadiness(db, settings, context))();
427
- const claudePoolRead = (async () =>
428
- loadWorkspaceClaudeSubscriptionReadiness(db, settings, context))();
422
+ // The exact provider set the catalog route reads. A disabled Claude
423
+ // subscription contributes no catalog entry, so its rows are not read.
424
+ const providerKinds: WorkspaceCustomModelProviderKind[] = [
425
+ "vercel_gateway",
426
+ "openrouter",
427
+ ...CLAUDE_CONNECTION_KINDS.filter(
428
+ (kind) => kind !== "claude_subscription" || settings.claudeSubscriptionEnabled,
429
+ ),
430
+ ];
429
431
  const inputRead = (async () =>
430
432
  Promise.all([
431
- restrictionsRead,
432
- claudePoolRead,
433
+ connectionRestrictionsAndXaiReadiness(db, settings, context),
434
+ loadWorkspaceClaudeSubscriptionReadiness(db, settings, context),
433
435
  getWorkspaceModelPolicy(db, workspaceId),
434
436
  workspaceCodexSubscriptionActive(db, settings, workspaceId),
435
437
  options.observeAvailability === false
436
438
  ? Promise.resolve({})
437
439
  : loadWorkspaceCodexModelAvailability(db, settings, workspaceId),
438
- workspaceVercelAiGatewayConnectionActive(db, workspaceId),
439
- listWorkspaceGatewayCustomModels(db, { accountId, workspaceId }),
440
- workspaceOpenRouterConnectionActive(db, workspaceId),
441
- listWorkspaceOpenRouterCustomModels(db, { accountId, workspaceId }),
442
- organizationModelProviderConnectionActiveForWorkspace(db, {
443
- accountId,
444
- workspaceId,
445
- providerKind: "vercel_gateway",
446
- }),
447
- organizationModelProviderConnectionActiveForWorkspace(db, {
448
- accountId,
449
- workspaceId,
450
- providerKind: "openrouter",
451
- }),
452
- listOrganizationModelProviderCustomModelsForWorkspace(db, {
453
- accountId,
454
- workspaceId,
455
- providerKind: "vercel_gateway",
456
- }),
457
- listOrganizationModelProviderCustomModelsForWorkspace(db, {
458
- accountId,
459
- workspaceId,
460
- providerKind: "openrouter",
461
- }),
462
440
  ]))();
463
- const claudeConnections: ClaudeConnectionCatalog = {};
464
- const workspaceClaudeConnections: ClaudeConnectionCatalog = {};
465
441
  // Transaction handles share one backend and LOCAL scope. Preserve the old
466
442
  // batch ordering there rather than introduce concurrent nested savepoints.
467
443
  if (typeof (db as Database & { rollback?: unknown }).rollback === "function") await inputRead;
468
- // Catalog metadata does not depend on the other providers' policy/readiness
469
- // batch. Only subscription activation joins its exact existing pool read.
470
- // These helpers keep their separate root-pool RLS scopes; no shared transaction.
444
+ // One scoped read per catalog family (workspace connection metadata,
445
+ // workspace custom models, organization provider readiness + models) instead
446
+ // of one scoped transaction per provider kind. The batched readers keep each
447
+ // provider's filters, bound and overflow error, exactly as the workspace
448
+ // model catalog route reads them.
471
449
  const catalogRead = (async () =>
472
- Promise.all(
473
- CLAUDE_CONNECTION_KINDS.map(async (kind) => {
474
- if (kind === "claude_subscription" && !settings.claudeSubscriptionEnabled) return;
475
- const [active, models, metadata, workspaceModels] = await Promise.all([
476
- kind === "claude_subscription"
477
- ? claudePoolRead.then((pool) => pool.organization)
478
- : organizationModelProviderConnectionActiveForWorkspace(db, {
479
- accountId,
480
- workspaceId,
481
- providerKind: kind,
482
- }),
483
- listOrganizationModelProviderCustomModelsForWorkspace(db, {
484
- accountId,
485
- workspaceId,
486
- providerKind: kind,
487
- }),
488
- kind === "claude_subscription"
489
- ? Promise.resolve(null)
490
- : getWorkspaceProviderApiKeyConnectionMetadata(db, workspaceId, kind),
491
- listWorkspaceProviderCustomModels(db, { accountId, workspaceId, providerKind: kind }),
492
- ]);
493
- claudeConnections[kind] = { active, models };
494
- workspaceClaudeConnections[kind] = {
495
- active:
496
- kind === "claude_subscription" ? (await claudePoolRead).workspace : metadata !== null,
497
- models: workspaceModels,
498
- };
450
+ Promise.all([
451
+ listConnectionsMetadata(db, workspaceId, null),
452
+ listWorkspaceProviderCustomModelsByKind(db, { accountId, workspaceId, providerKinds }),
453
+ getOrganizationModelProviderCatalogForWorkspace(db, {
454
+ accountId,
455
+ workspaceId,
456
+ providerKinds,
499
457
  }),
500
- ))();
458
+ ]))();
501
459
  // Observe both batches before admission resumes or fails. Retain the previous
502
460
  // input-batch error precedence even if the independent catalog fails first.
503
461
  const [inputResult, catalogResult] = await Promise.allSettled([inputRead, catalogRead]);
@@ -505,19 +463,31 @@ export async function loadWorkspaceModelSelectionInput(
505
463
  if (catalogResult.status === "rejected") throw catalogResult.reason;
506
464
  const [
507
465
  { restrictions: connectionModelRestrictions, xaiSubscriptionActive },
508
- _claudePool,
466
+ claudePool,
509
467
  policy,
510
468
  codexSubscriptionActive,
511
469
  observations,
512
- workspaceGatewayConnectionActive,
513
- workspaceGatewayCustomModels,
514
- openRouterConnectionActive,
515
- workspaceOpenRouterCustomModels,
516
- organizationGatewayConnectionActive,
517
- organizationOpenRouterConnectionActive,
518
- organizationGatewayCustomModels,
519
- organizationOpenRouterCustomModels,
520
470
  ] = inputResult.value;
471
+ const [workspaceConnections, workspaceCustomModels, organizationProviders] = catalogResult.value;
472
+ const workspaceConnectionActive = (kind: WorkspaceCustomModelProviderKind) =>
473
+ workspaceProviderApiKeyConnectionMetadataFromConnections(workspaceConnections, kind) !== null;
474
+ const claudeConnections: ClaudeConnectionCatalog = {};
475
+ const workspaceClaudeConnections: ClaudeConnectionCatalog = {};
476
+ for (const kind of CLAUDE_CONNECTION_KINDS) {
477
+ if (kind === "claude_subscription" && !settings.claudeSubscriptionEnabled) continue;
478
+ claudeConnections[kind] = {
479
+ active:
480
+ kind === "claude_subscription"
481
+ ? claudePool.organization
482
+ : organizationProviders[kind].active,
483
+ models: organizationProviders[kind].models,
484
+ };
485
+ workspaceClaudeConnections[kind] = {
486
+ active:
487
+ kind === "claude_subscription" ? claudePool.workspace : workspaceConnectionActive(kind),
488
+ models: workspaceCustomModels[kind],
489
+ };
490
+ }
521
491
  return {
522
492
  claudeConnections,
523
493
  workspaceClaudeConnections,
@@ -527,14 +497,14 @@ export async function loadWorkspaceModelSelectionInput(
527
497
  codexSubscriptionActive,
528
498
  observations,
529
499
  xaiSubscriptionActive,
530
- workspaceGatewayConnectionActive,
531
- workspaceGatewayCustomModels,
532
- workspaceOpenRouterConnectionActive: openRouterConnectionActive,
533
- workspaceOpenRouterCustomModels,
534
- organizationGatewayConnectionActive,
535
- organizationOpenRouterConnectionActive,
536
- organizationGatewayCustomModels,
537
- organizationOpenRouterCustomModels,
500
+ workspaceGatewayConnectionActive: workspaceConnectionActive("vercel_gateway"),
501
+ workspaceGatewayCustomModels: workspaceCustomModels.vercel_gateway,
502
+ workspaceOpenRouterConnectionActive: workspaceConnectionActive("openrouter"),
503
+ workspaceOpenRouterCustomModels: workspaceCustomModels.openrouter,
504
+ organizationGatewayConnectionActive: organizationProviders.vercel_gateway.active,
505
+ organizationOpenRouterConnectionActive: organizationProviders.openrouter.active,
506
+ organizationGatewayCustomModels: organizationProviders.vercel_gateway.models,
507
+ organizationOpenRouterCustomModels: organizationProviders.openrouter.models,
538
508
  };
539
509
  }
540
510
 
@@ -3,7 +3,12 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
3
3
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { withLockedCapabilityInstallation } from "@opengeni/db/capability-reconciliation";
6
- import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
6
+ import {
7
+ environmentsEncryptionKeyBytes,
8
+ findIntegrationsOauthClient,
9
+ parseIntegrationsOauthClientsJson,
10
+ type Settings,
11
+ } from "@opengeni/config";
7
12
  import { pinnedFetch } from "@opengeni/network";
8
13
  import {
9
14
  CapabilityCatalogItem,
@@ -102,6 +107,7 @@ export async function buildCapabilityCatalog(input: {
102
107
  listEnabledMcpCapabilityServers(input.db, input.workspaceId),
103
108
  ]);
104
109
  const runnableCapabilityIds = new Set(runnableMcpServers.map((server) => server.capabilityId));
110
+ const operatorOAuthClientConfigured = operatorOAuthClientResolver(input.settings);
105
111
  const catalogInstallations = capabilityInstallations.filter(
106
112
  (installation) => installation.kind === "mcp",
107
113
  );
@@ -164,7 +170,9 @@ export async function buildCapabilityCatalog(input: {
164
170
  },
165
171
  }
166
172
  : projected;
167
- return applyCapabilityLifecycle(runtimeProjected);
173
+ return applyCapabilityLifecycle(
174
+ applyOperatorOAuthClientRequirement(runtimeProjected, operatorOAuthClientConfigured),
175
+ );
168
176
  })
169
177
  .sort(compareCatalogItems);
170
178
  return {
@@ -173,6 +181,40 @@ export async function buildCapabilityCatalog(input: {
173
181
  };
174
182
  }
175
183
 
184
+ /**
185
+ * `metadata.oauthClientRequirement` is stamped by the catalog import from
186
+ * `data/catalog/oauth-client-requirements.json` for providers that refuse
187
+ * OAuth self-registration. Whether the row can connect here is a deployment
188
+ * fact, so the catalog projects it rather than every surface guessing. The
189
+ * matcher is the one the OAuth start uses to find an operator client.
190
+ */
191
+ function operatorOAuthClientResolver(settings: Settings): (issuer: string) => boolean {
192
+ let configured: ReturnType<typeof parseIntegrationsOauthClientsJson> = {};
193
+ try {
194
+ configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
195
+ } catch {
196
+ // Startup validates this setting; a malformed value can configure nothing.
197
+ }
198
+ return (issuer) => findIntegrationsOauthClient(configured, [issuer]) !== null;
199
+ }
200
+
201
+ export function applyOperatorOAuthClientRequirement(
202
+ item: CapabilityCatalogItem,
203
+ configured: (issuer: string) => boolean,
204
+ ): CapabilityCatalogItem {
205
+ if (item.kind !== "mcp") return item;
206
+ const requirement = item.metadata.oauthClientRequirement;
207
+ const issuer =
208
+ requirement && typeof requirement === "object" && !Array.isArray(requirement)
209
+ ? (requirement as Record<string, unknown>).issuer
210
+ : undefined;
211
+ if (typeof issuer !== "string" || !issuer) return item;
212
+ return {
213
+ ...item,
214
+ runtime: { ...item.runtime, operatorOAuthClient: { configured: configured(issuer) } },
215
+ };
216
+ }
217
+
176
218
  export async function createCatalogItem(input: {
177
219
  db: Database;
178
220
  accountId: string;
@@ -20,6 +20,7 @@ import {
20
20
  loadSessionRealtimeBillingFacts,
21
21
  recordUsageEvent,
22
22
  sumUsageQuantity,
23
+ VOICE_CREDIT_USAGE,
23
24
  withRlsContext,
24
25
  type CreditDebitAttribution,
25
26
  type Database,
@@ -43,6 +44,25 @@ export type DeploymentRealtimeVoice = {
43
44
  * return null. (The catalog lists hosted GPT Live instead of the managed
44
45
  * Gateway choices when both are configured; both remain priced and gated.)
45
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
+
46
66
  export function deploymentRealtimeVoice(
47
67
  settings: Settings,
48
68
  model: SessionRealtimeModel | string,
@@ -52,7 +72,14 @@ export function deploymentRealtimeVoice(
52
72
  return {
53
73
  provider: "azure-live",
54
74
  configured: azureConfigured,
55
- pricing: parseRealtimeVoicePricingJson(settings.azureLivePricingJson),
75
+ pricing: tolerantPricing(
76
+ () =>
77
+ parseRealtimeVoicePricingJson(
78
+ settings.azureLivePricingJson,
79
+ "OPENGENI_AZURE_LIVE_PRICING_JSON",
80
+ ),
81
+ null,
82
+ ),
56
83
  };
57
84
  }
58
85
  for (const gateway of Object.values(AI_GATEWAY_REALTIME_MODELS)) {
@@ -61,9 +88,14 @@ export function deploymentRealtimeVoice(
61
88
  provider: "ai-gateway",
62
89
  configured: Boolean(settings.vercelAiGatewayApiKey),
63
90
  pricing:
64
- parseRealtimeVoicePricingTableJson(settings.aiGatewayRealtimePricingJson)[
65
- gateway.upstreamModelId
66
- ] ?? null,
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,
67
99
  };
68
100
  }
69
101
  return null;
@@ -105,6 +137,37 @@ export function realtimeVoiceOfferProblem(
105
137
  export const REALTIME_VOICE_INSUFFICIENT_CREDITS_MESSAGE =
106
138
  "Live voice needs Opengeni credits. Add credits to continue.";
107
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
+
108
171
  function startOfUtcMonth(now: Date): Date {
109
172
  return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
110
173
  }
@@ -126,7 +189,7 @@ export type RealtimeVoiceSettlement = {
126
189
  * Credit admission and time metering for deployment-funded live voice.
127
190
  *
128
191
  * Admission mirrors voice-input admission (same refusal codes and HTTP
129
- * shape): spendable general credits must be positive, the workspace/member
192
+ * shape): voice-spendable credits (general plus signup) must be positive, the workspace/member
130
193
  * allowance must not be exhausted, and the static monthly cost cap applies.
131
194
  * Metering bills every started minute of each issued provider connection,
132
195
  * measured only from server-observed facts (connection claim time, owner
@@ -142,11 +205,11 @@ export function createRealtimeVoiceBilling(deps: { db: Database; settings: Setti
142
205
  attribution: CreditDebitAttribution;
143
206
  now: Date;
144
207
  }): Promise<TranscriptionBillingRefusedError | null> {
145
- const balance = await getSpendableCreditBalance(deps.db, input.accountId);
208
+ const balance = await getSpendableCreditBalance(deps.db, input.accountId, VOICE_CREDIT_USAGE);
146
209
  if (balance.balanceMicros <= 0) {
147
210
  return new TranscriptionBillingRefusedError({
148
211
  code: "insufficient_credits",
149
- message: REALTIME_VOICE_INSUFFICIENT_CREDITS_MESSAGE,
212
+ message: voiceInsufficientCreditsMessage("Live voice", voiceCreditStanding(balance)),
150
213
  });
151
214
  }
152
215
  const allowance = await checkWorkspaceAllowance(deps.db, {
@@ -208,9 +271,15 @@ export function createRealtimeVoiceBilling(deps: { db: Database; settings: Setti
208
271
 
209
272
  /** Spendable credits are positive (always true when credits are not enforced). */
210
273
  async hasSpendableCredits(accountId: string): Promise<boolean> {
211
- if (!billingActive()) return true;
212
- const balance = await getSpendableCreditBalance(deps.db, accountId);
213
- return balance.balanceMicros > 0;
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
+ );
214
283
  },
215
284
 
216
285
  /**
@@ -296,6 +365,7 @@ export function createRealtimeVoiceBilling(deps: { db: Database; settings: Setti
296
365
  sourceId,
297
366
  idempotencyKey: `credit:${REALTIME_VOICE_DEBIT_TYPE}:${sourceId}`,
298
367
  occurredAt: minute.startsAt,
368
+ usage: VOICE_CREDIT_USAGE,
299
369
  metadata: {
300
370
  provider: voice.provider,
301
371
  model: facts.model,
@@ -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"
@@ -235,12 +235,14 @@ export async function workspaceSessionToolPolicyContext(
235
235
  workspaceId: string,
236
236
  settings: Settings,
237
237
  subjectId?: string,
238
+ /** An in-flight read of this exact workspace a caller already started. */
239
+ workspaceRead?: ReturnType<typeof requireWorkspace>,
238
240
  ): Promise<{ workspaceServerIds: string[]; workspaceDefaultServerIds: string[] }> {
239
241
  const [runtimeSettings, workspace] = await Promise.all([
240
242
  settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings, {
241
243
  ...(subjectId ? { subjectId } : {}),
242
244
  }),
243
- requireWorkspace(db, workspaceId),
245
+ workspaceRead ?? requireWorkspace(db, workspaceId),
244
246
  ]);
245
247
  return {
246
248
  workspaceServerIds: sortedIds(runtimeSettings.mcpServers.map((server) => server.id)),
@@ -326,6 +328,8 @@ export async function workspaceSessionEffectiveToolsContext(
326
328
  workspaceId: string,
327
329
  subjectId: string,
328
330
  sessions: readonly Session[],
331
+ /** An in-flight read of this exact workspace a caller already started. */
332
+ workspaceRead?: ReturnType<typeof requireWorkspace>,
329
333
  ): Promise<SessionEffectiveToolsContext> {
330
334
  const configured = sessions.filter((session) => session.agent != null);
331
335
  const baseline: SessionEffectiveToolsContext = {
@@ -336,7 +340,7 @@ export async function workspaceSessionEffectiveToolsContext(
336
340
  };
337
341
  if (configured.length === 0) return baseline;
338
342
  const [workspace, catalog, descriptors, sandboxes, routerHistory] = await Promise.all([
339
- requireWorkspace(deps.db, workspaceId),
343
+ workspaceRead ?? requireWorkspace(deps.db, workspaceId),
340
344
  resolveWorkspaceCatalogSettings(deps.db, deps.settings, {
341
345
  accountId: configured[0]!.accountId,
342
346
  workspaceId,