@opengeni/core 1.4.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/knowledge-messages.d.ts +2 -2
- package/dist/domain/realtime-voice-billing.d.ts +21 -7
- package/dist/domain/session-start-timing.d.ts +1 -1
- package/dist/domain/session-tool-policy.d.ts +7 -3
- package/dist/domain/voice-input-billing.d.ts +3 -2
- package/dist/index.js +575 -523
- package/dist/index.js.map +1 -1
- 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/realtime-voice-billing.ts +80 -10
- package/src/domain/session-start-timing.ts +5 -0
- package/src/domain/session-tool-policy.ts +6 -2
- package/src/domain/sessions.ts +130 -96
- package/src/domain/voice-input-billing.ts +9 -4
- package/src/model-catalog.ts +32 -38
|
@@ -22,15 +22,11 @@ import {
|
|
|
22
22
|
getWorkspace,
|
|
23
23
|
getWorkspaceConnectionModelRestrictions,
|
|
24
24
|
getWorkspaceModelPolicy,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
listWorkspaceGatewayCustomModels,
|
|
29
|
-
listWorkspaceOpenRouterCustomModels,
|
|
30
|
-
organizationModelProviderConnectionActiveForWorkspace,
|
|
25
|
+
getOrganizationModelProviderCatalogForWorkspace,
|
|
26
|
+
listConnectionsMetadata,
|
|
27
|
+
listWorkspaceProviderCustomModelsByKind,
|
|
31
28
|
workspaceCodexSubscriptionActive,
|
|
32
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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(
|
|
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:
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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,
|