@opengeni/sdk 0.13.0 → 0.20.0

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/src/errors.ts CHANGED
@@ -11,6 +11,19 @@ export class OpenGeniApiError extends Error {
11
11
  }
12
12
  }
13
13
 
14
+ /** The browser bundle and API disagree about their state-changing wire contract. */
15
+ export class OpenGeniApiContractMismatchError extends Error {
16
+ readonly expected: string;
17
+ readonly actual: string;
18
+
19
+ constructor(expected: string, actual: string) {
20
+ super(`OpenGeni API contract mismatch: client expects ${expected}, API serves ${actual}`);
21
+ this.name = "OpenGeniApiContractMismatchError";
22
+ this.expected = expected;
23
+ this.actual = actual;
24
+ }
25
+ }
26
+
14
27
  /** Error for an unrecoverable event-stream condition (not a transient drop). */
15
28
  export class OpenGeniStreamError extends Error {
16
29
  constructor(message: string) {
package/src/index.ts CHANGED
@@ -2,10 +2,17 @@ export { OpenGeniClient } from "./client";
2
2
  export type {
3
3
  FetchLike,
4
4
  OpenGeniClientOptions,
5
+ OpenGeniRequestOptions,
5
6
  SendMessageInput,
6
7
  SteerMessageResult,
8
+ WorkspaceControlEventPage,
7
9
  } from "./client";
8
- export { OpenGeniApiError, OpenGeniStreamError, isRetryableStreamError } from "./errors";
10
+ export {
11
+ OpenGeniApiContractMismatchError,
12
+ OpenGeniApiError,
13
+ OpenGeniStreamError,
14
+ isRetryableStreamError,
15
+ } from "./errors";
9
16
  export {
10
17
  formatSseEvent,
11
18
  proxySessionEventStream,
@@ -43,7 +50,43 @@ export type {
43
50
  StreamConnectionState,
44
51
  StreamSessionEventsOptions,
45
52
  } from "./stream";
46
- export { KNOWN_PERMISSIONS, KNOWN_USAGE_EVENT_TYPES, SESSION_EVENT_TYPES } from "./types";
53
+ export { streamWorkspaceControlEvents } from "./workspace-control-stream";
54
+ export type { WorkspaceControlStreamTransport } from "./workspace-control-stream";
55
+ export {
56
+ DEFAULT_WORKSPACE_TRANSCRIPTION_POLICY,
57
+ authorizeTranscriptionAdapter,
58
+ createTranscriptionSessionRequest,
59
+ resolveWorkspaceTranscriptionPolicy,
60
+ } from "./transcription";
61
+ export type {
62
+ TranscriptionAdapter,
63
+ TranscriptionAdapterDescriptor,
64
+ TranscriptionAdapterStartContext,
65
+ TranscriptionAuthorization,
66
+ TranscriptionCredentialMode,
67
+ TranscriptionDiagnostic,
68
+ TranscriptionErrorCode,
69
+ TranscriptionEvent,
70
+ TranscriptionEventListener,
71
+ TranscriptionLifecycleStatus,
72
+ TranscriptionPolicyBlockReason,
73
+ TranscriptionResultMetadata,
74
+ TranscriptionSession,
75
+ TranscriptionSessionRequest,
76
+ TranscriptionSpeaker,
77
+ TranscriptionTargetSelection,
78
+ TranscriptionTimeSpan,
79
+ TranscriptionWord,
80
+ WorkspaceTranscriptionPolicy,
81
+ WorkspaceTranscriptionTarget,
82
+ } from "./transcription";
83
+ export {
84
+ KNOWN_PERMISSIONS,
85
+ KNOWN_USAGE_EVENT_TYPES,
86
+ OPENGENI_API_CONTRACT_HEADER,
87
+ OPENGENI_API_CONTRACT_REVISION,
88
+ SESSION_EVENT_TYPES,
89
+ } from "./types";
47
90
  export type {
48
91
  AccessContext,
49
92
  AccessGrant,
@@ -128,14 +171,21 @@ export type {
128
171
  EntitlementValue,
129
172
  EntitlementsMode,
130
173
  FileAsset,
174
+ HumanInputAnswer,
175
+ HumanInputOption,
176
+ HumanInputQuestion,
177
+ HumanInputQuestionKind,
178
+ HumanInputResponse,
131
179
  FileDownloadUrlResponse,
132
180
  FileResourceRef,
133
181
  FileStatus,
134
182
  FileUploadData,
135
183
  GetPackResponse,
136
184
  GitHubAppInfo,
185
+ GitHubInstallationBinding,
137
186
  GitHubRepositoriesResponse,
138
187
  GitHubRepository,
188
+ GitHubRepositoryScope,
139
189
  GoalSpec,
140
190
  IntegrationClientMetadata,
141
191
  KnownPermission,
@@ -148,6 +198,8 @@ export type {
148
198
  KnowledgeSourceKind,
149
199
  KnowledgeSourceRef,
150
200
  GitCredentialProvider,
201
+ GitCredentialBindingId,
202
+ GitRepositoryAccess,
151
203
  ListApiKeysResponse,
152
204
  ListConnectionsResponse,
153
205
  ListPacksResponse,
@@ -191,6 +243,16 @@ export type {
191
243
  SessionQueueMutationResponse,
192
244
  SessionQueueSnapshot,
193
245
  SessionControlResponse,
246
+ ComposerDraft,
247
+ DeleteSessionQueueItemRequest,
248
+ EditSessionQueueItemRequest,
249
+ EffectiveControlBlocker,
250
+ EffectiveControlResumeOption,
251
+ EffectiveSessionControl,
252
+ MoveSessionQueueItemRequest,
253
+ SaveComposerDraftRequest,
254
+ SessionCommandReceipt,
255
+ SteerSessionQueueItemRequest,
194
256
  WorkspaceInferenceControlResponse,
195
257
  SessionSystemUpdate,
196
258
  SessionSystemUpdateKind,
@@ -221,16 +283,27 @@ export type {
221
283
  ViewerHeartbeatRequest,
222
284
  ViewerHeartbeatResponse,
223
285
  SessionEvent,
286
+ SessionEventListOptions,
287
+ SessionEventPage,
288
+ SessionEventPayloadMode,
289
+ SessionEventReadDirection,
290
+ SessionEventReadMode,
291
+ SessionEventSemanticClass,
224
292
  SessionEventType,
225
293
  SessionGoal,
226
294
  SessionGoalCreatedBy,
227
295
  SessionGoalStatus,
296
+ SessionHumanInputRequest,
228
297
  SessionStatus,
229
298
  SessionStatusChangedPayload,
230
299
  SessionStructuredCapabilities,
231
300
  SessionTurn,
301
+ ServiceTurnInitiator,
302
+ ServiceTurnInitiatorContext,
232
303
  SessionTurnSource,
233
304
  SessionTurnStatus,
305
+ SubmitHumanInputResponseRequest,
306
+ TurnInitiator,
234
307
  ToolAuthNeededPayload,
235
308
  UpdateConnectionRequest,
236
309
  // Channel-A structured services (P4.4) — A1 payloads + A2 request/response.
@@ -304,8 +377,10 @@ export type {
304
377
  UsageEvent,
305
378
  UsageEventType,
306
379
  UserApprovalDecisionEventInput,
380
+ UserHumanInputResponseEventInput,
307
381
  UserMessageEventInput,
308
382
  Workspace,
383
+ WorkspaceControlEvent,
309
384
  VariableSet,
310
385
  VariableSetVariableMetadata,
311
386
  Rig,
package/src/stream.ts CHANGED
@@ -34,6 +34,8 @@ export type StreamSessionEventsOptions = {
34
34
  * reconnects = N+1 total open-stream calls). Defaults to unlimited.
35
35
  */
36
36
  maxReconnectAttempts?: number;
37
+ /** Await authoritative client reconciliation before exposing `live`. */
38
+ beforeLive?: (() => void | Promise<void>) | undefined;
37
39
  onStateChange?: (state: StreamConnectionState) => void;
38
40
  };
39
41
 
@@ -75,6 +77,7 @@ export async function* streamSessionEvents(
75
77
  everConnected = true;
76
78
  failedAttempts = 0;
77
79
  delayMs = baseDelayMs;
80
+ await options.beforeLive?.();
78
81
  options.onStateChange?.("live");
79
82
  for await (const message of parseSseStream(body)) {
80
83
  // Re-check after every yield resumption: an abort from the consumer
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Framework- and transport-agnostic speech-to-text capability contract.
3
+ *
4
+ * Audio transport, microphone access, credentials, and provider SDKs belong to
5
+ * host-supplied adapters. This module deliberately contains no browser globals
6
+ * and no provider implementation.
7
+ */
8
+
9
+ export type TranscriptionCredentialMode = "managed" | "byok";
10
+
11
+ export type WorkspaceTranscriptionTarget = {
12
+ provider: string;
13
+ model: string | null;
14
+ credentialMode: TranscriptionCredentialMode;
15
+ /** Workspace-scoped connection reference. This is never a secret. */
16
+ credentialConnectionId: string | null;
17
+ region: string | null;
18
+ };
19
+
20
+ export type WorkspaceTranscriptionPolicy = {
21
+ enabled: boolean;
22
+ /** Exact admin-accepted policy identity; required whenever enabled. */
23
+ acceptanceId: string | null;
24
+ primary: WorkspaceTranscriptionTarget | null;
25
+ /** Explicit language preference. Required when automatic detection is not accepted. */
26
+ language: string | null;
27
+ /** Whether the accepted adapter may automatically detect the spoken language. */
28
+ autoDetectLanguage: boolean;
29
+ /** Whether the accepted adapter may identify distinct speakers. */
30
+ diarization: {
31
+ enabled: boolean;
32
+ maxSpeakers: number | null;
33
+ };
34
+ retention: {
35
+ mode: "none" | "provider-policy";
36
+ maxDays: number | null;
37
+ };
38
+ privacy: {
39
+ allowProviderLogging: boolean;
40
+ allowProviderTraining: boolean;
41
+ };
42
+ fallback: {
43
+ mode: "disabled" | "explicit";
44
+ targets: WorkspaceTranscriptionTarget[];
45
+ };
46
+ cost: {
47
+ currency: "USD";
48
+ maxPerHour: number | null;
49
+ maxPerMonth: number | null;
50
+ };
51
+ };
52
+
53
+ export const DEFAULT_WORKSPACE_TRANSCRIPTION_POLICY: WorkspaceTranscriptionPolicy = {
54
+ enabled: false,
55
+ acceptanceId: null,
56
+ primary: null,
57
+ language: null,
58
+ autoDetectLanguage: false,
59
+ diarization: { enabled: false, maxSpeakers: null },
60
+ retention: { mode: "none", maxDays: null },
61
+ privacy: { allowProviderLogging: false, allowProviderTraining: false },
62
+ fallback: { mode: "disabled", targets: [] },
63
+ cost: { currency: "USD", maxPerHour: null, maxPerMonth: null },
64
+ };
65
+
66
+ export type TranscriptionAdapterDescriptor = {
67
+ provider: string;
68
+ model: string | null;
69
+ credentialMode: TranscriptionCredentialMode;
70
+ region: string | null;
71
+ };
72
+
73
+ export type TranscriptionTargetSelection =
74
+ | { kind: "primary" }
75
+ | { kind: "fallback"; index: number };
76
+
77
+ export type TranscriptionPolicyBlockReason =
78
+ | "disabled"
79
+ | "unaccepted"
80
+ | "target_missing"
81
+ | "fallback_disabled"
82
+ | "fallback_unaccepted"
83
+ | "provider_mismatch"
84
+ | "model_mismatch"
85
+ | "credential_mode_mismatch"
86
+ | "region_mismatch";
87
+
88
+ export type TranscriptionAuthorization =
89
+ | {
90
+ authorized: true;
91
+ acceptanceId: string;
92
+ target: WorkspaceTranscriptionTarget;
93
+ selection: TranscriptionTargetSelection;
94
+ }
95
+ | { authorized: false; reason: TranscriptionPolicyBlockReason };
96
+
97
+ export type TranscriptionLifecycleStatus =
98
+ | "idle"
99
+ | "requesting-permission"
100
+ | "listening"
101
+ | "reconnecting"
102
+ | "cancelling"
103
+ | "closed"
104
+ | "error";
105
+
106
+ export type TranscriptionErrorCode =
107
+ | "permission_denied"
108
+ | "not_supported"
109
+ | "network"
110
+ | "provider"
111
+ | "policy_blocked"
112
+ | "timeout"
113
+ | "cancelled"
114
+ | "unknown";
115
+
116
+ export type TranscriptionTimeSpan = {
117
+ startMilliseconds: number;
118
+ endMilliseconds: number;
119
+ };
120
+
121
+ export type TranscriptionSpeaker = {
122
+ /** Provider-neutral identity stable within the local transcription session. */
123
+ id: string;
124
+ label?: string | undefined;
125
+ };
126
+
127
+ export type TranscriptionWord = {
128
+ text: string;
129
+ span: TranscriptionTimeSpan;
130
+ confidence?: number | undefined;
131
+ speaker?: TranscriptionSpeaker | undefined;
132
+ };
133
+
134
+ /** Optional result detail; adapters omit fields their provider cannot supply. */
135
+ export type TranscriptionResultMetadata = {
136
+ detectedLanguage?: string | undefined;
137
+ span?: TranscriptionTimeSpan | undefined;
138
+ confidence?: number | undefined;
139
+ speaker?: TranscriptionSpeaker | undefined;
140
+ words?: TranscriptionWord[] | undefined;
141
+ };
142
+
143
+ export type TranscriptionDiagnostic = {
144
+ operation: "start" | "session" | "cancel" | "close";
145
+ code: TranscriptionErrorCode;
146
+ /** Diagnostic-only detail. React sanitizes and bounds this before forwarding it. */
147
+ detail: string;
148
+ };
149
+
150
+ type TranscriptionEventBase = {
151
+ /** Stable across reconnects and explicitly accepted fallback attempts. */
152
+ localSessionId: string;
153
+ /** Adapter-monotonic across the entire local session, including replay. */
154
+ sequence: number;
155
+ occurredAt: string;
156
+ };
157
+
158
+ export type TranscriptionEvent =
159
+ | (TranscriptionEventBase & { type: "permission.requested" })
160
+ | (TranscriptionEventBase & {
161
+ type: "session.opened";
162
+ providerSessionId: string;
163
+ })
164
+ | (TranscriptionEventBase & {
165
+ type: "transcript.partial";
166
+ segmentId: string;
167
+ text: string;
168
+ metadata?: TranscriptionResultMetadata | undefined;
169
+ })
170
+ | (TranscriptionEventBase & {
171
+ type: "transcript.final";
172
+ segmentId: string;
173
+ text: string;
174
+ /** Stable provider/coordinator acceptance identity used for dedupe. */
175
+ providerAcceptanceId: string;
176
+ metadata?: TranscriptionResultMetadata | undefined;
177
+ })
178
+ | (TranscriptionEventBase & {
179
+ type: "usage";
180
+ audioMilliseconds: number;
181
+ costUsd: number | null;
182
+ })
183
+ | (TranscriptionEventBase & {
184
+ type: "session.reconnecting";
185
+ attempt: number;
186
+ reason: string;
187
+ })
188
+ | (TranscriptionEventBase & {
189
+ type: "session.error";
190
+ code: TranscriptionErrorCode;
191
+ recoverable: boolean;
192
+ })
193
+ | (TranscriptionEventBase & {
194
+ type: "session.closed";
195
+ reason: "completed" | "cancelled" | "error" | "replaced";
196
+ });
197
+
198
+ export type TranscriptionSessionRequest = {
199
+ localSessionId: string;
200
+ policyAcceptanceId: string;
201
+ selection: TranscriptionTargetSelection;
202
+ target: WorkspaceTranscriptionTarget;
203
+ language: string | null;
204
+ autoDetectLanguage: boolean;
205
+ diarization: WorkspaceTranscriptionPolicy["diarization"];
206
+ retention: WorkspaceTranscriptionPolicy["retention"];
207
+ privacy: WorkspaceTranscriptionPolicy["privacy"];
208
+ cost: WorkspaceTranscriptionPolicy["cost"];
209
+ /** A replacement/reconnect adapter must emit events above this floor. */
210
+ sequenceFloor: number;
211
+ };
212
+
213
+ export type TranscriptionEventListener = (event: TranscriptionEvent) => void;
214
+
215
+ export type TranscriptionAdapterStartContext = {
216
+ /** Aborted on local cancellation, policy replacement, timeout, or unmount. */
217
+ signal: AbortSignal;
218
+ /** Non-UI observability seam; callers receive only bounded, redacted detail. */
219
+ reportDiagnostic: (diagnostic: TranscriptionDiagnostic) => void;
220
+ };
221
+
222
+ export type TranscriptionSession = {
223
+ readonly localSessionId: string;
224
+ cancel(reason?: string): Promise<void>;
225
+ close(): Promise<void>;
226
+ };
227
+
228
+ export type TranscriptionAdapter = {
229
+ readonly descriptor: TranscriptionAdapterDescriptor;
230
+ start(
231
+ request: TranscriptionSessionRequest,
232
+ listener: TranscriptionEventListener,
233
+ context: TranscriptionAdapterStartContext,
234
+ ): Promise<TranscriptionSession>;
235
+ };
236
+
237
+ /** Invalid or absent settings always resolve to the fail-closed default. */
238
+ export function resolveWorkspaceTranscriptionPolicy(
239
+ settings: unknown,
240
+ ): WorkspaceTranscriptionPolicy {
241
+ if (!isRecord(settings)) return cloneDefaultPolicy();
242
+ const candidate = settings.transcription;
243
+ if (!isWorkspaceTranscriptionPolicy(candidate)) return cloneDefaultPolicy();
244
+ return {
245
+ ...candidate,
246
+ primary: candidate.primary ? normalizeTarget(candidate.primary) : null,
247
+ language: candidate.language?.trim() ?? null,
248
+ diarization: { ...candidate.diarization },
249
+ retention: { ...candidate.retention },
250
+ privacy: { ...candidate.privacy },
251
+ fallback: {
252
+ mode: candidate.fallback.mode,
253
+ targets: candidate.fallback.targets.map(normalizeTarget),
254
+ },
255
+ cost: { ...candidate.cost },
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Speech authorization is intentionally independent from turn model policy.
261
+ * Every selected adapter must match one exact admin-accepted target.
262
+ */
263
+ export function authorizeTranscriptionAdapter(
264
+ policy: WorkspaceTranscriptionPolicy,
265
+ descriptor: TranscriptionAdapterDescriptor,
266
+ selection: TranscriptionTargetSelection = { kind: "primary" },
267
+ ): TranscriptionAuthorization {
268
+ if (!isWorkspaceTranscriptionPolicy(policy)) {
269
+ return { authorized: false, reason: "unaccepted" };
270
+ }
271
+ if (!policy.enabled) return { authorized: false, reason: "disabled" };
272
+ if (!policy.acceptanceId) return { authorized: false, reason: "unaccepted" };
273
+ let target: WorkspaceTranscriptionTarget | null | undefined;
274
+ if (selection.kind === "primary") {
275
+ target = policy.primary;
276
+ } else {
277
+ if (policy.fallback.mode !== "explicit") {
278
+ return { authorized: false, reason: "fallback_disabled" };
279
+ }
280
+ target = policy.fallback.targets[selection.index];
281
+ if (!target) return { authorized: false, reason: "fallback_unaccepted" };
282
+ }
283
+ if (!target) return { authorized: false, reason: "target_missing" };
284
+ const acceptedTarget = normalizeTarget(target);
285
+ if (acceptedTarget.provider !== descriptor.provider) {
286
+ return { authorized: false, reason: "provider_mismatch" };
287
+ }
288
+ if (acceptedTarget.model !== descriptor.model) {
289
+ return { authorized: false, reason: "model_mismatch" };
290
+ }
291
+ if (acceptedTarget.credentialMode !== descriptor.credentialMode) {
292
+ return { authorized: false, reason: "credential_mode_mismatch" };
293
+ }
294
+ if (acceptedTarget.region !== descriptor.region) {
295
+ return { authorized: false, reason: "region_mismatch" };
296
+ }
297
+ return {
298
+ authorized: true,
299
+ acceptanceId: policy.acceptanceId,
300
+ target: acceptedTarget,
301
+ selection,
302
+ };
303
+ }
304
+
305
+ export function createTranscriptionSessionRequest(input: {
306
+ policy: WorkspaceTranscriptionPolicy;
307
+ adapter: TranscriptionAdapter;
308
+ localSessionId: string;
309
+ selection?: TranscriptionTargetSelection | undefined;
310
+ sequenceFloor?: number | undefined;
311
+ }): TranscriptionSessionRequest | null {
312
+ const sequenceFloor = input.sequenceFloor ?? 0;
313
+ if (!Number.isSafeInteger(sequenceFloor) || sequenceFloor < 0) return null;
314
+ const authorization = authorizeTranscriptionAdapter(
315
+ input.policy,
316
+ input.adapter.descriptor,
317
+ input.selection,
318
+ );
319
+ if (!authorization.authorized) return null;
320
+ return {
321
+ localSessionId: input.localSessionId,
322
+ policyAcceptanceId: authorization.acceptanceId,
323
+ selection: authorization.selection,
324
+ target: { ...authorization.target },
325
+ language: input.policy.language?.trim() ?? null,
326
+ autoDetectLanguage: input.policy.autoDetectLanguage,
327
+ diarization: { ...input.policy.diarization },
328
+ retention: { ...input.policy.retention },
329
+ privacy: { ...input.policy.privacy },
330
+ cost: { ...input.policy.cost },
331
+ sequenceFloor,
332
+ };
333
+ }
334
+
335
+ function cloneDefaultPolicy(): WorkspaceTranscriptionPolicy {
336
+ return {
337
+ ...DEFAULT_WORKSPACE_TRANSCRIPTION_POLICY,
338
+ diarization: { ...DEFAULT_WORKSPACE_TRANSCRIPTION_POLICY.diarization },
339
+ retention: { ...DEFAULT_WORKSPACE_TRANSCRIPTION_POLICY.retention },
340
+ privacy: { ...DEFAULT_WORKSPACE_TRANSCRIPTION_POLICY.privacy },
341
+ fallback: { mode: "disabled", targets: [] },
342
+ cost: { ...DEFAULT_WORKSPACE_TRANSCRIPTION_POLICY.cost },
343
+ };
344
+ }
345
+
346
+ function isWorkspaceTranscriptionPolicy(value: unknown): value is WorkspaceTranscriptionPolicy {
347
+ if (!isRecord(value) || typeof value.enabled !== "boolean") return false;
348
+ if (
349
+ !hasOnlyKeys(value, [
350
+ "enabled",
351
+ "acceptanceId",
352
+ "primary",
353
+ "language",
354
+ "autoDetectLanguage",
355
+ "diarization",
356
+ "retention",
357
+ "privacy",
358
+ "fallback",
359
+ "cost",
360
+ ])
361
+ ) {
362
+ return false;
363
+ }
364
+ if (!(value.acceptanceId === null || isUuid(value.acceptanceId))) return false;
365
+ if (!(value.primary === null || isTarget(value.primary))) return false;
366
+ if (!(value.language === null || isBoundedString(value.language, 64))) return false;
367
+ if (typeof value.autoDetectLanguage !== "boolean") return false;
368
+ if (
369
+ !isRecord(value.diarization) ||
370
+ !hasOnlyKeys(value.diarization, ["enabled", "maxSpeakers"]) ||
371
+ typeof value.diarization.enabled !== "boolean" ||
372
+ !(
373
+ value.diarization.maxSpeakers === null ||
374
+ (isBoundedInteger(value.diarization.maxSpeakers, 100) && value.diarization.maxSpeakers >= 2)
375
+ )
376
+ ) {
377
+ return false;
378
+ }
379
+ if (!value.diarization.enabled && value.diarization.maxSpeakers !== null) return false;
380
+ if (!isRecord(value.retention) || !hasOnlyKeys(value.retention, ["mode", "maxDays"])) {
381
+ return false;
382
+ }
383
+ if (value.retention.mode !== "none" && value.retention.mode !== "provider-policy") return false;
384
+ if (!(value.retention.maxDays === null || isBoundedInteger(value.retention.maxDays, 3650))) {
385
+ return false;
386
+ }
387
+ if (
388
+ !isRecord(value.privacy) ||
389
+ !hasOnlyKeys(value.privacy, ["allowProviderLogging", "allowProviderTraining"]) ||
390
+ typeof value.privacy.allowProviderLogging !== "boolean" ||
391
+ typeof value.privacy.allowProviderTraining !== "boolean"
392
+ ) {
393
+ return false;
394
+ }
395
+ if (!isRecord(value.fallback) || !hasOnlyKeys(value.fallback, ["mode", "targets"])) {
396
+ return false;
397
+ }
398
+ if (value.fallback.mode !== "disabled" && value.fallback.mode !== "explicit") return false;
399
+ if (
400
+ !Array.isArray(value.fallback.targets) ||
401
+ value.fallback.targets.length > 8 ||
402
+ !value.fallback.targets.every(isTarget)
403
+ ) {
404
+ return false;
405
+ }
406
+ if (value.fallback.mode === "disabled" && value.fallback.targets.length !== 0) return false;
407
+ if (value.fallback.mode === "explicit" && value.fallback.targets.length === 0) return false;
408
+ if (
409
+ !isRecord(value.cost) ||
410
+ !hasOnlyKeys(value.cost, ["currency", "maxPerHour", "maxPerMonth"]) ||
411
+ value.cost.currency !== "USD"
412
+ ) {
413
+ return false;
414
+ }
415
+ if (!isNullableBoundedNumber(value.cost.maxPerHour, 10_000)) return false;
416
+ if (!isNullableBoundedNumber(value.cost.maxPerMonth, 1_000_000)) return false;
417
+ if (value.enabled && (!value.acceptanceId || !value.primary)) return false;
418
+ if (value.enabled && !value.autoDetectLanguage && value.language === null) return false;
419
+ if (value.autoDetectLanguage && value.language !== null) return false;
420
+ const targets = [value.primary, ...value.fallback.targets].filter(
421
+ (target): target is WorkspaceTranscriptionTarget => target !== null,
422
+ );
423
+ if (new Set(targets.map(targetKey)).size !== targets.length) return false;
424
+ return true;
425
+ }
426
+
427
+ function targetKey(target: WorkspaceTranscriptionTarget): string {
428
+ return [
429
+ target.provider.trim(),
430
+ target.model?.trim() ?? "",
431
+ target.credentialMode,
432
+ target.credentialConnectionId ?? "",
433
+ target.region?.trim() ?? "",
434
+ ].join("\u0000");
435
+ }
436
+
437
+ function isTarget(value: unknown): value is WorkspaceTranscriptionTarget {
438
+ if (!isRecord(value)) return false;
439
+ if (
440
+ !hasOnlyKeys(value, ["provider", "model", "credentialMode", "credentialConnectionId", "region"])
441
+ ) {
442
+ return false;
443
+ }
444
+ if (!isBoundedString(value.provider, 128)) return false;
445
+ if (!(value.model === null || isBoundedString(value.model, 256))) return false;
446
+ if (value.credentialMode !== "managed" && value.credentialMode !== "byok") return false;
447
+ if (value.provider.trim() === "azure-speech" && value.credentialMode !== "byok") return false;
448
+ if (!(value.credentialConnectionId === null || isUuid(value.credentialConnectionId))) {
449
+ return false;
450
+ }
451
+ if (!(value.region === null || isBoundedString(value.region, 128))) return false;
452
+ if (value.credentialMode === "byok" && value.credentialConnectionId === null) return false;
453
+ if (value.credentialMode === "managed" && value.credentialConnectionId !== null) return false;
454
+ return true;
455
+ }
456
+
457
+ function normalizeTarget(target: WorkspaceTranscriptionTarget): WorkspaceTranscriptionTarget {
458
+ return {
459
+ provider: target.provider.trim(),
460
+ model: target.model?.trim() ?? null,
461
+ credentialMode: target.credentialMode,
462
+ credentialConnectionId: target.credentialConnectionId,
463
+ region: target.region?.trim() ?? null,
464
+ };
465
+ }
466
+
467
+ function isRecord(value: unknown): value is Record<string, unknown> {
468
+ return typeof value === "object" && value !== null;
469
+ }
470
+
471
+ function hasOnlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
472
+ const accepted = new Set(keys);
473
+ return Object.keys(value).every((key) => accepted.has(key));
474
+ }
475
+
476
+ function isBoundedString(value: unknown, maximum: number): value is string {
477
+ return typeof value === "string" && value.trim().length > 0 && value.length <= maximum;
478
+ }
479
+
480
+ function isUuid(value: unknown): value is string {
481
+ return (
482
+ typeof value === "string" &&
483
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
484
+ );
485
+ }
486
+
487
+ function isBoundedInteger(value: unknown, maximum: number): value is number {
488
+ return Number.isInteger(value) && (value as number) >= 0 && (value as number) <= maximum;
489
+ }
490
+
491
+ function isNullableBoundedNumber(value: unknown, maximum: number): boolean {
492
+ return (
493
+ value === null ||
494
+ (typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= maximum)
495
+ );
496
+ }