@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
package/src/domain/sessions.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
ORGANIZATION_GATEWAY_MODEL_ID_PREFIX,
|
|
43
43
|
ORGANIZATION_OPENROUTER_MODEL_ID_PREFIX,
|
|
44
44
|
allowedFirstPartyMcpToolsForSession,
|
|
45
|
+
deploymentUnavailableFirstPartyMcpTools,
|
|
45
46
|
resolveFirstPartyMcpToolPolicy,
|
|
46
47
|
policyProviderIdForModel,
|
|
47
48
|
resolveTurnExecutionPolicyV1,
|
|
@@ -2051,6 +2052,11 @@ export async function postUserMessageTurn(
|
|
|
2051
2052
|
: {}),
|
|
2052
2053
|
controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
|
|
2053
2054
|
}),
|
|
2055
|
+
undefined,
|
|
2056
|
+
"shared",
|
|
2057
|
+
// Linked-actor capture reauthorizes under the organization-membership
|
|
2058
|
+
// lock; take it before the tenancy fence and the canonical prefix.
|
|
2059
|
+
Boolean(input.captureTurnAuthority),
|
|
2054
2060
|
),
|
|
2055
2061
|
);
|
|
2056
2062
|
} catch (error) {
|
|
@@ -2410,6 +2416,14 @@ async function createSessionForRequestInFileScope(
|
|
|
2410
2416
|
requestOptions: SessionCreateRequestOptions = {},
|
|
2411
2417
|
): Promise<CreateSessionRequestOutcome> {
|
|
2412
2418
|
const payload = CreateSessionRequest.parse(rawPayload);
|
|
2419
|
+
// A tool whose backing workload this deployment does not run is dropped, not
|
|
2420
|
+
// rejected: a client echoing a full catalog must not fail on a deployment fact.
|
|
2421
|
+
if (payload.firstPartyMcpTools) {
|
|
2422
|
+
const unavailable = deploymentUnavailableFirstPartyMcpTools(unresolvedDeps.settings);
|
|
2423
|
+
payload.firstPartyMcpTools = payload.firstPartyMcpTools.filter(
|
|
2424
|
+
(tool) => !unavailable.has(tool),
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2413
2427
|
// Read before any await: the Site scope is request-local provenance.
|
|
2414
2428
|
const surface = resolveTurnSurface({
|
|
2415
2429
|
grant,
|
|
@@ -2581,27 +2595,35 @@ async function createSessionForRequestInFileScope(
|
|
|
2581
2595
|
payload.idempotencyKey &&
|
|
2582
2596
|
(effectiveVisibility !== "user_private" || replayManagedHumanSubjectId !== null)
|
|
2583
2597
|
) {
|
|
2598
|
+
const createIdempotencyKey = payload.idempotencyKey;
|
|
2584
2599
|
try {
|
|
2585
|
-
const initializedReplay = await
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2600
|
+
const initializedReplay = await measureSessionStartPhase(
|
|
2601
|
+
unresolvedDeps.observability,
|
|
2602
|
+
"idempotent_replay",
|
|
2603
|
+
() =>
|
|
2604
|
+
getInitializedSessionCreateReplay(db, {
|
|
2605
|
+
bundledSkillIds,
|
|
2606
|
+
accountId: grant.accountId,
|
|
2607
|
+
workspaceId,
|
|
2608
|
+
subjectId: replayManagedHumanSubjectId ?? grant.subjectId,
|
|
2609
|
+
...(replayManagedHumanSubjectId
|
|
2610
|
+
? { activeManagedHumanSubjectId: replayManagedHumanSubjectId }
|
|
2611
|
+
: {}),
|
|
2612
|
+
createIdempotencyKey,
|
|
2613
|
+
selectedInstalledSkillIds: payload.installedSkillIds ?? [],
|
|
2614
|
+
initialAgentLearning: payload.agentLearning,
|
|
2615
|
+
...sessionScope,
|
|
2597
2616
|
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2617
|
+
...(payload.requestedSessionId
|
|
2618
|
+
? { requestedSessionId: payload.requestedSessionId }
|
|
2619
|
+
: {}),
|
|
2620
|
+
visibility: effectiveVisibility,
|
|
2621
|
+
variableSetIds: payload.variableSetIds ?? [],
|
|
2622
|
+
initialPersonalResourceAttachmentIntent: payload.personalResourceAttachment ?? null,
|
|
2623
|
+
deferInitialTurn: payload.startMode === "realtime",
|
|
2624
|
+
agentConfig,
|
|
2625
|
+
}),
|
|
2626
|
+
);
|
|
2605
2627
|
if (initializedReplay) {
|
|
2606
2628
|
if (initializedReplay.outcome === "denied") {
|
|
2607
2629
|
throw new SessionSpawnDeniedError(SessionSpawnDenial.parse(initializedReplay.denial));
|
|
@@ -2979,36 +3001,43 @@ async function createSessionForRequestInFileScope(
|
|
|
2979
3001
|
const tools = withFirstPartyTools(selectedTools, runtimeSettings);
|
|
2980
3002
|
|
|
2981
3003
|
const captureLinkedAuthority = prepareExternalLinkTurnAdmission(authorization);
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
3004
|
+
const { attachmentOwnerContext, variableSets } = await measureSessionStartPhase(
|
|
3005
|
+
unresolvedDeps.observability,
|
|
3006
|
+
"resource_validation",
|
|
3007
|
+
async () => {
|
|
3008
|
+
await validateGitHubRepositorySelection(db, workspaceId, resources);
|
|
3009
|
+
if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
|
|
3010
|
+
throw new HTTPException(503, {
|
|
3011
|
+
message: "object storage is not configured",
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
const ownerContext = authorization
|
|
3015
|
+
? await fileOwnerContextForAccess({ db }, authorization, "sessions:create")
|
|
3016
|
+
: grant.principalKind === "agent_attempt"
|
|
3017
|
+
? await fileOwnerContextForAgent({ db }, grant, "sessions:create")
|
|
3018
|
+
: undefined;
|
|
3019
|
+
await validateFileResources(
|
|
3020
|
+
db,
|
|
3021
|
+
grant.accountId,
|
|
3022
|
+
workspaceId,
|
|
3023
|
+
personalResourceSubjectId ?? grant.subjectId,
|
|
3024
|
+
resources,
|
|
3025
|
+
ownerContext,
|
|
3026
|
+
);
|
|
3027
|
+
// Every selected Variable Set is independently authorized. Scope does not
|
|
3028
|
+
// affect precedence: explicit order is low-to-high and later sets win name
|
|
3029
|
+
// collisions.
|
|
3030
|
+
const validatedVariableSets: VariableSet[] = [];
|
|
3031
|
+
for (const variableSetId of payload.variableSetIds ?? []) {
|
|
3032
|
+
validatedVariableSets.push(
|
|
3033
|
+
await validateVariableSetAttachment({ settings, db }, grant, workspaceId, variableSetId),
|
|
3034
|
+
);
|
|
3035
|
+
}
|
|
3036
|
+
return { attachmentOwnerContext: ownerContext, variableSets: validatedVariableSets };
|
|
3037
|
+
},
|
|
3038
|
+
);
|
|
2993
3039
|
const attachmentOwner =
|
|
2994
3040
|
attachmentOwnerContext?.privateFileOwnerSubjectId === grant.subjectId ? grant.subjectId : null;
|
|
2995
|
-
await validateFileResources(
|
|
2996
|
-
db,
|
|
2997
|
-
grant.accountId,
|
|
2998
|
-
workspaceId,
|
|
2999
|
-
personalResourceSubjectId ?? grant.subjectId,
|
|
3000
|
-
resources,
|
|
3001
|
-
attachmentOwnerContext,
|
|
3002
|
-
);
|
|
3003
|
-
// Every selected Variable Set is independently authorized. Scope does not
|
|
3004
|
-
// affect precedence: explicit order is low-to-high and later sets win name
|
|
3005
|
-
// collisions.
|
|
3006
|
-
const variableSets: VariableSet[] = [];
|
|
3007
|
-
for (const variableSetId of payload.variableSetIds ?? []) {
|
|
3008
|
-
variableSets.push(
|
|
3009
|
-
await validateVariableSetAttachment({ settings, db }, grant, workspaceId, variableSetId),
|
|
3010
|
-
);
|
|
3011
|
-
}
|
|
3012
3041
|
// RIG BINDING (M3). Resolve the rig this session rides — a UUID binds that
|
|
3013
3042
|
// rig, null explicitly opts out, and omission inherits the workspace default
|
|
3014
3043
|
// (workspaces.default_rig_id) — then FREEZE both the rig id and its currently-
|
|
@@ -3020,22 +3049,27 @@ async function createSessionForRequestInFileScope(
|
|
|
3020
3049
|
// - A stale workspace-default rig (deleted → FK-nulled, or somehow with no
|
|
3021
3050
|
// active version) degrades SILENTLY to rig-less: an operator-side default
|
|
3022
3051
|
// must never brick every create in the workspace.
|
|
3023
|
-
const
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3052
|
+
const { frozenRigId, frozenRigVersionId } = await measureSessionStartPhase(
|
|
3053
|
+
unresolvedDeps.observability,
|
|
3054
|
+
"rig_binding",
|
|
3055
|
+
async () => {
|
|
3056
|
+
const requestedRigId =
|
|
3057
|
+
payload.rigId === undefined
|
|
3058
|
+
? await getWorkspaceDefaultRigId(db, workspaceId)
|
|
3059
|
+
: payload.rigId;
|
|
3060
|
+
if (!requestedRigId) return { frozenRigId: null, frozenRigVersionId: null };
|
|
3061
|
+
const rig = await getRig(db, grant, requestedRigId);
|
|
3062
|
+
if (!rig || !rig.activeVersion) {
|
|
3063
|
+
if (payload.rigId) {
|
|
3064
|
+
throw new HTTPException(422, {
|
|
3065
|
+
message: rig
|
|
3066
|
+
? `sandbox environment ${payload.rigId} has no active version to bind`
|
|
3067
|
+
: `unknown rigId: ${payload.rigId}`,
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
// else: workspace-default fallback that no longer resolves → rig-less.
|
|
3071
|
+
return { frozenRigId: null, frozenRigVersionId: null };
|
|
3036
3072
|
}
|
|
3037
|
-
// else: workspace-default fallback that no longer resolves → rig-less.
|
|
3038
|
-
} else {
|
|
3039
3073
|
for (const defaultVariableSetId of new Set(rig.activeVersion.defaultVariableSetIds)) {
|
|
3040
3074
|
await validateVariableSetAttachment(
|
|
3041
3075
|
{ settings, db },
|
|
@@ -3044,10 +3078,12 @@ async function createSessionForRequestInFileScope(
|
|
|
3044
3078
|
defaultVariableSetId,
|
|
3045
3079
|
);
|
|
3046
3080
|
}
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3081
|
+
return {
|
|
3082
|
+
frozenRigId: rig.id as string | null,
|
|
3083
|
+
frozenRigVersionId: rig.activeVersion.id as string | null,
|
|
3084
|
+
};
|
|
3085
|
+
},
|
|
3086
|
+
);
|
|
3051
3087
|
// CHANNEL FILING. Pure rail organization: a UUID files the session into that
|
|
3052
3088
|
// workspace channel, omission/null leaves it unfiled (inbox). Resolved
|
|
3053
3089
|
// workspace-scoped so a foreign channel id can never attach; an explicit
|
|
@@ -3076,25 +3112,27 @@ async function createSessionForRequestInFileScope(
|
|
|
3076
3112
|
// inherited calling-turn model, or deployment default — so the policy must
|
|
3077
3113
|
// vet that effective value, not just explicit ones (a restricted workspace's
|
|
3078
3114
|
// inherited/default-model session would otherwise be born blocked).
|
|
3079
|
-
await
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3115
|
+
await measureSessionStartPhase(unresolvedDeps.observability, "model_admission", async () => {
|
|
3116
|
+
await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
|
|
3117
|
+
// Direct creation is a fresh model selection. Child inheritance and keyed
|
|
3118
|
+
// repair preserve the existing accepted-model/authority rules.
|
|
3119
|
+
if (retainedKeyedShellModel === null && !parentSession) {
|
|
3120
|
+
const selections = await resolveCallerWorkspaceModelSelections(db, settings, {
|
|
3121
|
+
accountId: grant.accountId,
|
|
3122
|
+
workspaceId,
|
|
3123
|
+
subjectId: personalResourceSubjectId ?? grant.subjectId,
|
|
3124
|
+
...(xaiProviderAccountAuthoritySnapshot
|
|
3125
|
+
? { xaiAuthoritySnapshot: xaiProviderAccountAuthoritySnapshot }
|
|
3126
|
+
: {}),
|
|
3127
|
+
...(claudeProviderAccountAuthoritySnapshot
|
|
3128
|
+
? { claudeAuthoritySnapshot: claudeProviderAccountAuthoritySnapshot }
|
|
3129
|
+
: {}),
|
|
3130
|
+
});
|
|
3131
|
+
if (!admissibleWorkspaceModel(selections, model)) {
|
|
3132
|
+
throw new HTTPException(422, { message: `model is not selectable: ${model}` });
|
|
3133
|
+
}
|
|
3096
3134
|
}
|
|
3097
|
-
}
|
|
3135
|
+
});
|
|
3098
3136
|
const inheritedReasoningEffort =
|
|
3099
3137
|
parentCallingTurn?.reasoningEffort ??
|
|
3100
3138
|
parentSession?.reasoningEffort ??
|
|
@@ -3298,18 +3336,23 @@ async function createSessionForRequestInFileScope(
|
|
|
3298
3336
|
workspace.settings,
|
|
3299
3337
|
),
|
|
3300
3338
|
});
|
|
3301
|
-
const { personalConnectionDelegations, mcpAccountBindings } = await
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3339
|
+
const { personalConnectionDelegations, mcpAccountBindings } = await measureSessionStartPhase(
|
|
3340
|
+
unresolvedDeps.observability,
|
|
3341
|
+
"connection_freeze",
|
|
3342
|
+
() =>
|
|
3343
|
+
freezeConnectionAccounts({
|
|
3344
|
+
db,
|
|
3345
|
+
accountId: grant.accountId,
|
|
3346
|
+
workspaceId,
|
|
3347
|
+
settings: runtimeSettings,
|
|
3348
|
+
tools: connectionAccountTools,
|
|
3349
|
+
resources,
|
|
3350
|
+
source: connectionDelegationSource,
|
|
3351
|
+
authoritySelections: payload.connectionAccounts,
|
|
3352
|
+
googleDrivePublicationEnabled,
|
|
3353
|
+
atlassianEnabled,
|
|
3354
|
+
}),
|
|
3355
|
+
);
|
|
3313
3356
|
if (effectiveGoal) {
|
|
3314
3357
|
const missingGoalTools = ["goal_update", "goal_progress", "goal_complete", "goal_pause"].filter(
|
|
3315
3358
|
(name) => !firstPartyMcpTools.includes(name as FirstPartyMcpToolName),
|
|
@@ -4249,6 +4292,10 @@ async function acceptSessionUserMessageInFileScope(
|
|
|
4249
4292
|
(await requireWorkspace(db, workspaceId)).settings,
|
|
4250
4293
|
),
|
|
4251
4294
|
});
|
|
4295
|
+
const existingEffectiveFirstPartyTools = allowedFirstPartyMcpToolsForSession(
|
|
4296
|
+
settings,
|
|
4297
|
+
existingSession.firstPartyMcpTools,
|
|
4298
|
+
);
|
|
4252
4299
|
const { personalConnectionDelegations, mcpAccountBindings } = await freezeConnectionAccounts({
|
|
4253
4300
|
db,
|
|
4254
4301
|
accountId: grant.accountId,
|
|
@@ -4259,8 +4306,8 @@ async function acceptSessionUserMessageInFileScope(
|
|
|
4259
4306
|
source: connectionDelegationSource,
|
|
4260
4307
|
targetSessionId: sessionId,
|
|
4261
4308
|
googleDrivePublicationEnabled:
|
|
4262
|
-
|
|
4263
|
-
|
|
4309
|
+
existingEffectiveFirstPartyTools.includes("editable_artifact_export") &&
|
|
4310
|
+
existingEffectiveFirstPartyTools.includes("editable_artifact_export_status") &&
|
|
4264
4311
|
(!existingSession.firstPartyMcpPermissions?.length ||
|
|
4265
4312
|
(existingSession.firstPartyMcpPermissions.includes("artifacts:read") &&
|
|
4266
4313
|
existingSession.firstPartyMcpPermissions.includes("artifacts:publish"))),
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import {
|
|
2
|
+
calculateVoiceInputCost,
|
|
3
|
+
configuredStaticUsageLimits,
|
|
4
|
+
voiceInputCreditBillingActive,
|
|
5
|
+
voiceInputPricingHasTokenRates,
|
|
6
|
+
type Settings,
|
|
7
|
+
type VoiceInputPricing,
|
|
8
|
+
type VoiceInputUsage,
|
|
9
|
+
} from "@opengeni/config";
|
|
10
|
+
import {
|
|
11
|
+
applyCreditDebitAfterUse,
|
|
12
|
+
checkWorkspaceAllowance,
|
|
13
|
+
creditDebitAttributionMetadata,
|
|
14
|
+
getSpendableCreditBalance,
|
|
15
|
+
listUnsettledVoiceTranscriptionCharges,
|
|
16
|
+
recordUsageEvent,
|
|
17
|
+
sumUsageQuantity,
|
|
18
|
+
VOICE_TRANSCRIPTION_DEBIT_TYPE,
|
|
19
|
+
VOICE_TRANSCRIPTION_SOURCE_TYPE,
|
|
20
|
+
voiceTranscriptionSettlementKeys,
|
|
21
|
+
VOICE_CREDIT_USAGE,
|
|
22
|
+
type CreditDebitAttribution,
|
|
23
|
+
type Database,
|
|
24
|
+
} from "@opengeni/db";
|
|
25
|
+
import {
|
|
26
|
+
TranscriptionBillingRefusedError,
|
|
27
|
+
TranscriptionServiceError,
|
|
28
|
+
type TranscriptionBilling,
|
|
29
|
+
type TranscriptionBillingContext,
|
|
30
|
+
} from "../transcription";
|
|
31
|
+
import { voiceCreditStanding, voiceInsufficientCreditsMessage } from "./realtime-voice-billing";
|
|
32
|
+
|
|
33
|
+
/** Credit debit type and usage source for deployment-funded voice input. */
|
|
34
|
+
export const VOICE_INPUT_DEBIT_TYPE = VOICE_TRANSCRIPTION_DEBIT_TYPE;
|
|
35
|
+
export const VOICE_INPUT_SOURCE_TYPE = VOICE_TRANSCRIPTION_SOURCE_TYPE;
|
|
36
|
+
|
|
37
|
+
/** How the billed quantity was measured; recorded on the ledger entry. */
|
|
38
|
+
export type VoiceInputBillingBasis =
|
|
39
|
+
| "provider_tokens"
|
|
40
|
+
| "provider_duration"
|
|
41
|
+
| "server_duration"
|
|
42
|
+
| "reconciled_receipt";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pick the billed measurement. Provider usage wins when this deployment can
|
|
46
|
+
* price it (token usage with token rates, or a reported duration). Otherwise
|
|
47
|
+
* the duration the server measured from WAV bytes it produced bills. Every
|
|
48
|
+
* deployment-funded call carries that server duration, so an unpriced usage
|
|
49
|
+
* shape never leaves a call unbillable. A client-reported duration is never a
|
|
50
|
+
* billing input.
|
|
51
|
+
*/
|
|
52
|
+
export function voiceInputBillableUsage(input: {
|
|
53
|
+
pricing: VoiceInputPricing;
|
|
54
|
+
usage: VoiceInputUsage | null;
|
|
55
|
+
trustedDurationSeconds?: number | undefined;
|
|
56
|
+
}): { usage: VoiceInputUsage; basis: VoiceInputBillingBasis } {
|
|
57
|
+
if (input.usage?.kind === "tokens" && voiceInputPricingHasTokenRates(input.pricing)) {
|
|
58
|
+
return { usage: input.usage, basis: "provider_tokens" };
|
|
59
|
+
}
|
|
60
|
+
if (input.usage?.kind === "duration") {
|
|
61
|
+
return { usage: input.usage, basis: "provider_duration" };
|
|
62
|
+
}
|
|
63
|
+
if (
|
|
64
|
+
input.trustedDurationSeconds !== undefined &&
|
|
65
|
+
Number.isFinite(input.trustedDurationSeconds) &&
|
|
66
|
+
input.trustedDurationSeconds >= 0
|
|
67
|
+
) {
|
|
68
|
+
return {
|
|
69
|
+
usage: { kind: "duration", seconds: input.trustedDurationSeconds },
|
|
70
|
+
basis: "server_duration",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
throw new TranscriptionServiceError({
|
|
74
|
+
code: "provider",
|
|
75
|
+
message: "Transcription usage was not reported.",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function initiatingHuman(attribution: CreditDebitAttribution): string | null {
|
|
80
|
+
return attribution.kind === "human" || attribution.kind === "turn"
|
|
81
|
+
? attribution.initiatingHumanSubjectId
|
|
82
|
+
: null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function startOfUtcMonth(): Date {
|
|
86
|
+
const now = new Date();
|
|
87
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Same admission shape as knowledge-search and session admission:
|
|
92
|
+
* voice-spendable credits (general plus signup, not model-scoped promotional
|
|
93
|
+
* credits) must be positive, the workspace/member
|
|
94
|
+
* allowance must not be exhausted, and a static monthly cost cap applies.
|
|
95
|
+
* Admission is a read, not a reservation; settlement is post-use and may take
|
|
96
|
+
* an account below zero by at most the concurrent in-flight calls.
|
|
97
|
+
*/
|
|
98
|
+
export function createVoiceInputBilling(deps: {
|
|
99
|
+
db: Database;
|
|
100
|
+
settings: Settings;
|
|
101
|
+
/** Reconciliation is best-effort at admission; failures are reported here. */
|
|
102
|
+
onReconcileError?: (error: unknown, scope: { accountId: string; workspaceId: string }) => void;
|
|
103
|
+
/** Minimum spacing of admission-time reconciliation per workspace (default 5 min). */
|
|
104
|
+
reconcileIntervalMilliseconds?: number;
|
|
105
|
+
}): TranscriptionBilling {
|
|
106
|
+
const reconcileInterval = deps.reconcileIntervalMilliseconds ?? 5 * 60_000;
|
|
107
|
+
const lastReconciled = new Map<string, number>();
|
|
108
|
+
const reconcileDue = (key: string) => {
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const last = lastReconciled.get(key);
|
|
111
|
+
if (last !== undefined && now - last < reconcileInterval) return false;
|
|
112
|
+
if (lastReconciled.size >= 10_000) lastReconciled.clear();
|
|
113
|
+
lastReconciled.set(key, now);
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
async admit({ accountId, workspaceId, attribution }) {
|
|
118
|
+
if (!voiceInputCreditBillingActive(deps.settings)) return;
|
|
119
|
+
if (attribution.kind === "unknown") {
|
|
120
|
+
// Not a microphone problem: the caller's credential has no payer the
|
|
121
|
+
// ledger can attribute, so the deployment refuses by policy.
|
|
122
|
+
throw new TranscriptionServiceError({
|
|
123
|
+
code: "policy_blocked",
|
|
124
|
+
status: 403,
|
|
125
|
+
message: "Voice input payer could not be verified.",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// Debits whose receipt committed but whose ledger write failed are
|
|
129
|
+
// applied before the balance read, so unsettled use counts against it.
|
|
130
|
+
// Throttled per workspace: failed debits are rare, the scan is not free.
|
|
131
|
+
if (reconcileDue(`${accountId}:${workspaceId}`)) {
|
|
132
|
+
await reconcileUnsettledVoiceInputCharges(deps.db, { accountId, workspaceId }).catch(
|
|
133
|
+
(error: unknown) => {
|
|
134
|
+
deps.onReconcileError?.(error, { accountId, workspaceId });
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const balance = await getSpendableCreditBalance(deps.db, accountId, VOICE_CREDIT_USAGE);
|
|
139
|
+
if (balance.balanceMicros <= 0) {
|
|
140
|
+
throw new TranscriptionBillingRefusedError({
|
|
141
|
+
code: "insufficient_credits",
|
|
142
|
+
message: voiceInsufficientCreditsMessage("Voice input", voiceCreditStanding(balance)),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const refusal = await checkWorkspaceAllowance(deps.db, {
|
|
146
|
+
accountId,
|
|
147
|
+
workspaceId,
|
|
148
|
+
subjectId: initiatingHuman(attribution),
|
|
149
|
+
});
|
|
150
|
+
if (refusal) {
|
|
151
|
+
throw new TranscriptionBillingRefusedError({
|
|
152
|
+
code: refusal.code,
|
|
153
|
+
message: refusal.message,
|
|
154
|
+
details: {
|
|
155
|
+
scope: refusal.scope,
|
|
156
|
+
resetsAt: refusal.resetsAt,
|
|
157
|
+
...(refusal.subjectId ? { subjectId: refusal.subjectId } : {}),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (
|
|
162
|
+
deps.settings.usageLimitsMode === "static" ||
|
|
163
|
+
deps.settings.usageLimitsMode === "managed"
|
|
164
|
+
) {
|
|
165
|
+
const cap = configuredStaticUsageLimits(deps.settings).maxMonthlyCostMicrosPerAccount;
|
|
166
|
+
if (cap) {
|
|
167
|
+
const used = await sumUsageQuantity(deps.db, {
|
|
168
|
+
accountId,
|
|
169
|
+
eventType: "model.cost",
|
|
170
|
+
since: startOfUtcMonth(),
|
|
171
|
+
});
|
|
172
|
+
if (used >= cap) {
|
|
173
|
+
throw new TranscriptionBillingRefusedError({
|
|
174
|
+
code: "monthly_model_cost_limit",
|
|
175
|
+
message: "The monthly model cost limit has been reached.",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async settle(input) {
|
|
183
|
+
if (!voiceInputCreditBillingActive(deps.settings)) return { creditCostMicros: 0 };
|
|
184
|
+
const { usage, basis } = voiceInputBillableUsage({
|
|
185
|
+
pricing: input.pricing,
|
|
186
|
+
usage: input.usage,
|
|
187
|
+
trustedDurationSeconds: input.billing.trustedDurationSeconds,
|
|
188
|
+
});
|
|
189
|
+
const cost = calculateVoiceInputCost(input.pricing, usage);
|
|
190
|
+
return await settleVoiceInputUsage(deps.db, {
|
|
191
|
+
accountId: input.accountId,
|
|
192
|
+
workspaceId: input.workspaceId,
|
|
193
|
+
providerId: input.providerId,
|
|
194
|
+
model: input.model,
|
|
195
|
+
billing: input.billing,
|
|
196
|
+
usage,
|
|
197
|
+
basis,
|
|
198
|
+
providerCostMicros: cost.providerCostMicros,
|
|
199
|
+
creditCostMicros: cost.creditCostMicros,
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Two commits, in order: the `model.cost` usage receipt (Insights spend,
|
|
207
|
+
* monthly cost cap, member-allowance attribution), then the post-use credit
|
|
208
|
+
* debit. The receipt is the first-writer authority for the amount and the
|
|
209
|
+
* durable record of the charge: if the debit commit fails, the receipt stays
|
|
210
|
+
* and {@link reconcileUnsettledVoiceInputCharges} applies the same debit (same
|
|
211
|
+
* idempotency key) on the workspace's next admission. A retried unit settles
|
|
212
|
+
* exactly once at the originally recorded price.
|
|
213
|
+
*/
|
|
214
|
+
export async function settleVoiceInputUsage(
|
|
215
|
+
db: Database,
|
|
216
|
+
input: {
|
|
217
|
+
accountId: string;
|
|
218
|
+
workspaceId: string;
|
|
219
|
+
providerId: string;
|
|
220
|
+
model: string;
|
|
221
|
+
billing: TranscriptionBillingContext;
|
|
222
|
+
usage: VoiceInputUsage;
|
|
223
|
+
basis: VoiceInputBillingBasis;
|
|
224
|
+
providerCostMicros: number;
|
|
225
|
+
creditCostMicros: number;
|
|
226
|
+
},
|
|
227
|
+
): Promise<{ creditCostMicros: number }> {
|
|
228
|
+
const attribution = input.billing.attribution;
|
|
229
|
+
const keys = voiceTranscriptionSettlementKeys({
|
|
230
|
+
workspaceId: input.workspaceId,
|
|
231
|
+
sourceId: input.billing.sourceId,
|
|
232
|
+
});
|
|
233
|
+
const receipt = await recordUsageEvent(db, {
|
|
234
|
+
accountId: input.accountId,
|
|
235
|
+
workspaceId: input.workspaceId,
|
|
236
|
+
eventType: "model.cost",
|
|
237
|
+
quantity: input.creditCostMicros,
|
|
238
|
+
unit: "usd_micros",
|
|
239
|
+
sourceResourceType: VOICE_TRANSCRIPTION_SOURCE_TYPE,
|
|
240
|
+
sourceResourceId: input.billing.sourceId,
|
|
241
|
+
idempotencyKey: keys.usageIdempotencyKey,
|
|
242
|
+
// The API is the writer; the payer is the trusted attribution snapshot.
|
|
243
|
+
initiator: { kind: "service", subjectId: "api:voice-input" },
|
|
244
|
+
initiatorContext: { creditDebitAttribution: attribution },
|
|
245
|
+
});
|
|
246
|
+
const amount = Number(receipt.quantity);
|
|
247
|
+
if (amount > 0) {
|
|
248
|
+
await applyCreditDebitAfterUse(db, {
|
|
249
|
+
accountId: input.accountId,
|
|
250
|
+
workspaceId: input.workspaceId,
|
|
251
|
+
type: VOICE_TRANSCRIPTION_DEBIT_TYPE,
|
|
252
|
+
amountMicros: amount,
|
|
253
|
+
sourceType: VOICE_TRANSCRIPTION_SOURCE_TYPE,
|
|
254
|
+
sourceId: input.billing.sourceId,
|
|
255
|
+
idempotencyKey: keys.debitIdempotencyKey,
|
|
256
|
+
usage: VOICE_CREDIT_USAGE,
|
|
257
|
+
metadata: {
|
|
258
|
+
providerId: input.providerId,
|
|
259
|
+
model: input.model,
|
|
260
|
+
basis: input.basis,
|
|
261
|
+
providerCostMicros: input.providerCostMicros,
|
|
262
|
+
...(input.usage.kind === "tokens"
|
|
263
|
+
? {
|
|
264
|
+
inputTokens: input.usage.inputTokens,
|
|
265
|
+
audioInputTokens: input.usage.audioInputTokens,
|
|
266
|
+
textInputTokens: input.usage.textInputTokens,
|
|
267
|
+
outputTokens: input.usage.outputTokens,
|
|
268
|
+
}
|
|
269
|
+
: { audioMilliseconds: Math.ceil(input.usage.seconds * 1_000) }),
|
|
270
|
+
...(attribution.kind === "unknown" ? {} : creditDebitAttributionMetadata(attribution)),
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return { creditCostMicros: amount };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Apply the debit for every voice usage receipt in this workspace whose debit
|
|
279
|
+
* never committed. Idempotent with the inline settlement (same key), bounded
|
|
280
|
+
* per call, and safe to run concurrently.
|
|
281
|
+
*/
|
|
282
|
+
export async function reconcileUnsettledVoiceInputCharges(
|
|
283
|
+
db: Database,
|
|
284
|
+
input: { accountId: string; workspaceId: string; minAgeMilliseconds?: number },
|
|
285
|
+
): Promise<{ reconciled: number }> {
|
|
286
|
+
const pending = await listUnsettledVoiceTranscriptionCharges(db, {
|
|
287
|
+
accountId: input.accountId,
|
|
288
|
+
workspaceId: input.workspaceId,
|
|
289
|
+
...(input.minAgeMilliseconds !== undefined
|
|
290
|
+
? { minAgeMilliseconds: input.minAgeMilliseconds }
|
|
291
|
+
: {}),
|
|
292
|
+
});
|
|
293
|
+
for (const charge of pending) {
|
|
294
|
+
await applyCreditDebitAfterUse(db, {
|
|
295
|
+
accountId: input.accountId,
|
|
296
|
+
workspaceId: input.workspaceId,
|
|
297
|
+
type: VOICE_TRANSCRIPTION_DEBIT_TYPE,
|
|
298
|
+
amountMicros: charge.amountMicros,
|
|
299
|
+
sourceType: VOICE_TRANSCRIPTION_SOURCE_TYPE,
|
|
300
|
+
sourceId: charge.sourceId,
|
|
301
|
+
idempotencyKey: voiceTranscriptionSettlementKeys({
|
|
302
|
+
workspaceId: input.workspaceId,
|
|
303
|
+
sourceId: charge.sourceId,
|
|
304
|
+
}).debitIdempotencyKey,
|
|
305
|
+
usage: VOICE_CREDIT_USAGE,
|
|
306
|
+
metadata: {
|
|
307
|
+
basis: "reconciled_receipt" satisfies VoiceInputBillingBasis,
|
|
308
|
+
...(charge.attribution.kind === "unknown"
|
|
309
|
+
? {}
|
|
310
|
+
: creditDebitAttributionMetadata(charge.attribution)),
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return { reconciled: pending.length };
|
|
315
|
+
}
|