@opengeni/api-router 1.3.0 → 1.4.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.
Files changed (40) hide show
  1. package/dist/access-grant-rls.d.ts +7 -0
  2. package/dist/app.d.ts +2 -2
  3. package/dist/app.js +1 -1
  4. package/dist/azure-live.d.ts +28 -0
  5. package/dist/{chunk-IZEGLLKZ.js → chunk-KKEXIBQG.js} +1188 -387
  6. package/dist/{chunk-IZEGLLKZ.js.map → chunk-KKEXIBQG.js.map} +1 -1
  7. package/dist/codemode.d.ts +3 -3
  8. package/dist/index.js +1 -1
  9. package/dist/integrations/oauth-profiles.d.ts +3 -1
  10. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  11. package/dist/mcp/server.d.ts +1 -1
  12. package/dist/transcription/billing-refusal.d.ts +8 -0
  13. package/dist/transcription/normalize.d.ts +23 -0
  14. package/dist/transcription/providers/azure-mai.d.ts +12 -0
  15. package/dist/transcription/providers/azure-openai.d.ts +4 -0
  16. package/dist/transcription/providers/openai.d.ts +16 -0
  17. package/dist/transcription/service.d.ts +10 -1
  18. package/dist/transcription/wav-duration.d.ts +8 -0
  19. package/package.json +20 -20
  20. package/src/access-grant-rls.ts +18 -10
  21. package/src/app.ts +45 -7
  22. package/src/azure-live.ts +103 -0
  23. package/src/codemode.ts +3 -5
  24. package/src/integrations/oauth-client.ts +1 -0
  25. package/src/integrations/oauth-profiles.ts +17 -6
  26. package/src/routes/editable-artifacts.ts +8 -0
  27. package/src/routes/session-artifact-associations.ts +80 -6
  28. package/src/routes/sessions.ts +185 -25
  29. package/src/routes/transcription-recordings.ts +71 -4
  30. package/src/routes/transcriptions.ts +26 -2
  31. package/src/routes/workspaces.ts +44 -7
  32. package/src/sandbox/channel-a.ts +22 -0
  33. package/src/transcription/billing-refusal.ts +14 -0
  34. package/src/transcription/normalize.ts +77 -0
  35. package/src/transcription/providers/azure-mai.ts +98 -0
  36. package/src/transcription/providers/azure-openai.ts +16 -7
  37. package/src/transcription/providers/openai.ts +79 -5
  38. package/src/transcription/segmenter.ts +7 -1
  39. package/src/transcription/service.ts +215 -10
  40. package/src/transcription/wav-duration.ts +39 -0
@@ -1,7 +1,7 @@
1
1
  import { type AccessGrant, type AttemptToolCatalog, type CodemodeCallSubmission as CodemodeCallSubmissionValue, type CodemodeOperation } from "@opengeni/contracts";
2
2
  import type { ApiRouteDeps } from "@opengeni/core";
3
3
  import { type SessionTurnForExecution } from "@opengeni/db";
4
- import { type Settings } from "@opengeni/config";
4
+ import { type FirstPartyMcpToolPolicySettings } from "@opengeni/config";
5
5
  import { type Permission, type Session } from "@opengeni/contracts";
6
6
  /** The REST authority a Codemode SDK proxy token may ever carry. */
7
7
  export declare const CODEMODE_SESSION_PROXY_PERMISSION_CEILING: readonly ["workspace:read", "sessions:read", "sessions:create", "sessions:control"];
@@ -13,10 +13,10 @@ export declare const CODEMODE_SESSION_PROXY_PERMISSION_CEILING: readonly ["works
13
13
  * gets only workspace:read and every proxied /sessions handler refuses it,
14
14
  * exactly as its MCP surface would.
15
15
  */
16
- export declare function codemodeSessionProxyPermissions(settings: Pick<Settings, "defaultFirstPartyMcpTools" | "allowedFirstPartyMcpTools">, session: Pick<Session, "firstPartyMcpTools" | "firstPartyMcpPermissions">): Permission[];
16
+ export declare function codemodeSessionProxyPermissions(settings: FirstPartyMcpToolPolicySettings, session: Pick<Session, "firstPartyMcpTools" | "firstPartyMcpPermissions">): Permission[];
17
17
  /** Reuse normal REST handlers, including their resource/command authorization.
18
18
  * The exact attempt is checked before issuing this internal-only credential. */
19
- export declare function codemodeSessionRequest(deps: ApiRouteDeps, grant: AccessGrant, request: Request, path: string, resolveProxySettings?: (session: Session) => Pick<Settings, "defaultFirstPartyMcpTools" | "allowedFirstPartyMcpTools">): Promise<Request>;
19
+ export declare function codemodeSessionRequest(deps: ApiRouteDeps, grant: AccessGrant, request: Request, path: string, resolveProxySettings?: (session: Session) => FirstPartyMcpToolPolicySettings): Promise<Request>;
20
20
  export type CodemodeGrantAuthority = {
21
21
  accountId: string;
22
22
  workspaceId: string;
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  startSlackInteractionPump,
13
13
  startTemporalScheduleCleanupPump,
14
14
  withAccessGrantSessionRlsContext
15
- } from "./chunk-IZEGLLKZ.js";
15
+ } from "./chunk-KKEXIBQG.js";
16
16
  import "./chunk-OGQDXVXY.js";
17
17
 
18
18
  // src/index.ts
@@ -130,7 +130,9 @@ export type OAuthProviderProfile = {
130
130
  /** Distinguish a rejected OAuth grant from a temporary verification failure. */
131
131
  isRejectedGrant?: (payload: Record<string, unknown>) => boolean;
132
132
  validateIdentity: (payload: Record<string, unknown>) => Record<string, string>;
133
- toolsForScopes: (scopes: readonly string[]) => Array<{
133
+ toolsForScopes: (scopes: readonly string[], deployment: {
134
+ gmailWatchTopicName?: string | undefined;
135
+ }) => Array<{
134
136
  name: string;
135
137
  description?: string;
136
138
  }>;
@@ -83,8 +83,8 @@ export declare function scheduledTaskMcpSummary(task: ScheduledTask): {
83
83
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
84
84
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
85
85
  action: {
86
+ readonly sourceId?: never;
86
87
  kind: "agent_turn";
87
- sourceId?: never;
88
88
  sourceGeneration?: never;
89
89
  sourceLifecycleGeneration?: never;
90
90
  destination?: never;
@@ -220,8 +220,8 @@ export declare function boundScheduledTaskDetailMcp(task: ScheduledTask): {
220
220
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
221
221
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
222
222
  action: {
223
+ readonly sourceId?: never;
223
224
  kind: "agent_turn";
224
- sourceId?: never;
225
225
  sourceGeneration?: never;
226
226
  sourceLifecycleGeneration?: never;
227
227
  destination?: never;
@@ -405,8 +405,8 @@ export declare function boundScheduledTaskDetailMcp(task: ScheduledTask): {
405
405
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
406
406
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
407
407
  action: {
408
+ readonly sourceId?: never;
408
409
  kind: "agent_turn";
409
- sourceId?: never;
410
410
  sourceGeneration?: never;
411
411
  sourceLifecycleGeneration?: never;
412
412
  destination?: never;
@@ -597,8 +597,8 @@ export declare function boundScheduledTaskMcpPage(input: {
597
597
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
598
598
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
599
599
  action: {
600
+ readonly sourceId?: never;
600
601
  kind: "agent_turn";
601
- sourceId?: never;
602
602
  sourceGeneration?: never;
603
603
  sourceLifecycleGeneration?: never;
604
604
  destination?: never;
@@ -43,7 +43,7 @@ export declare function slackBotFileContentResult(result: Awaited<ReturnType<Ope
43
43
  mimeType?: never;
44
44
  data?: never;
45
45
  } | {
46
- readonly text?: never;
46
+ text?: never;
47
47
  type: "image";
48
48
  mimeType: "image/jpeg" | "image/png" | "image/webp";
49
49
  data: string;
@@ -0,0 +1,8 @@
1
+ import type { TranscriptionBillingRefusedError } from "@opengeni/core";
2
+ import type { Context } from "hono";
3
+ /**
4
+ * Same body shape as session admission refusals (`code` + `message`, plus
5
+ * allowance `scope`/`resetsAt`), so SDK/React clients classify it as a
6
+ * definitive credit/allowance refusal rather than a retryable failure.
7
+ */
8
+ export declare function transcriptionBillingRefusal(c: Context, error: TranscriptionBillingRefusedError): Response;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Decode one uploaded recording to 16 kHz mono PCM WAV with a hard decode
3
+ * ceiling, and measure its duration from the bytes the server produced.
4
+ */
5
+ export type TranscriptionAudioNormalizer = (input: {
6
+ audio: Uint8Array;
7
+ mimeType: string;
8
+ /** Longest accepted recording; anything longer is refused, never truncated. */
9
+ maxDurationSeconds: number;
10
+ signal?: AbortSignal | undefined;
11
+ }) => Promise<{
12
+ bytes: Uint8Array;
13
+ durationSeconds: number;
14
+ }>;
15
+ /**
16
+ * ffmpeg decodes at most `maxDurationSeconds + 1` seconds (`-t`), so a long or
17
+ * hostile upload costs bounded CPU and disk. Output that reaches that ceiling
18
+ * was longer than the limit and is refused as `too_large` before any provider
19
+ * call, so the transcript the user receives is never silently cut short.
20
+ */
21
+ export declare function createFfmpegAudioNormalizer(input: {
22
+ ffmpegPath: string;
23
+ }): TranscriptionAudioNormalizer;
@@ -0,0 +1,12 @@
1
+ import type { VoiceInputPricing } from "@opengeni/config";
2
+ import type { TranscriptionProvider } from "@opengeni/core";
3
+ /** Azure Speech file transcription; credentials and model selection stay server-side. */
4
+ export declare function createMaiTranscriptionProvider(input: {
5
+ endpoint: string;
6
+ apiKey: string;
7
+ apiVersion: string;
8
+ model: string;
9
+ pricing?: VoiceInputPricing | null;
10
+ ffmpegPath: string;
11
+ fetch?: typeof fetch;
12
+ }): TranscriptionProvider;
@@ -1,3 +1,4 @@
1
+ import { type VoiceInputPricing } from "@opengeni/config";
1
2
  import type { TranscriptionProvider } from "@opengeni/core";
2
3
  export declare function createAzureOpenAiTranscriptionProvider(input: {
3
4
  endpoint: string;
@@ -5,5 +6,8 @@ export declare function createAzureOpenAiTranscriptionProvider(input: {
5
6
  apiVersion: string;
6
7
  apiKey: string | null;
7
8
  adToken: string | null;
9
+ /** Underlying model of the deployment; defaults to the deployment name. */
10
+ model?: string;
11
+ pricing?: VoiceInputPricing | null;
8
12
  fetch?: typeof fetch;
9
13
  }): TranscriptionProvider;
@@ -1,9 +1,25 @@
1
+ import { type VoiceInputPricing, type VoiceInputUsage } from "@opengeni/config";
1
2
  import { type TranscriptionProvider, TranscriptionServiceError } from "@opengeni/core";
2
3
  export declare function createOpenAiTranscriptionProvider(input: {
3
4
  apiKey: string;
4
5
  baseUrl: string;
5
6
  model: string;
7
+ pricing?: VoiceInputPricing | null;
6
8
  fetch?: typeof fetch;
7
9
  }): TranscriptionProvider;
10
+ /**
11
+ * OpenAI-compatible `/audio/transcriptions` JSON (OpenAI and Azure OpenAI).
12
+ * `usage` is `{type:"tokens", input_tokens, input_token_details:{audio_tokens,
13
+ * text_tokens}, output_tokens, total_tokens}` for the gpt-4o-transcribe family
14
+ * or `{type:"duration", seconds}` for duration-billed models; Whisper
15
+ * `verbose_json` carries the processed audio `duration`. Unusable usage is
16
+ * dropped (null), never guessed.
17
+ */
18
+ export declare function parseTranscriptionResponseBody(body: unknown): {
19
+ text: string;
20
+ languages: string[];
21
+ usage: VoiceInputUsage | null;
22
+ } | null;
23
+ export declare function parseTranscriptionUsage(body: Record<string, unknown>): VoiceInputUsage | null;
8
24
  export declare function responseError(status: number): TranscriptionServiceError;
9
25
  export declare function fetchError(error: unknown): TranscriptionServiceError;
@@ -1,6 +1,7 @@
1
1
  import { type Settings } from "@opengeni/config";
2
- import { type TranscriptionAvailabilityContext, type TranscriptionProvider, type TranscriptionService } from "@opengeni/core";
2
+ import { type TranscriptionAvailabilityContext, type TranscriptionBilling, type TranscriptionProvider, type TranscriptionService } from "@opengeni/core";
3
3
  import type { Database } from "@opengeni/db";
4
+ import { type TranscriptionAudioNormalizer } from "./normalize.js";
4
5
  export declare function createTranscriptionService(input: {
5
6
  settings: Settings;
6
7
  db: Database;
@@ -11,6 +12,14 @@ export declare function createTranscriptionService(input: {
11
12
  providerRequestTimeoutMilliseconds?: number;
12
13
  /** Test seam for evaluating persisted absolute deadlines after delayed setup. */
13
14
  now?: () => Date;
15
+ /** Credit admission/settlement for deployment-funded providers. */
16
+ billing?: TranscriptionBilling;
17
+ /** Test seam: trusted one-shot decode + duration (defaults to ffmpeg). */
18
+ normalizeAudio?: TranscriptionAudioNormalizer;
19
+ /** Loud operator log for settlement failures after provider success. */
20
+ log?: (message: string, attributes: Record<string, string | number | boolean>) => void;
21
+ /** In-process settlement retry pacing after an inline failure. */
22
+ settlementRetryDelaysMilliseconds?: readonly number[];
14
23
  }): TranscriptionService;
15
24
  export declare function remainingTranscriptionProviderRequestMilliseconds(providerDeadlineAt: Date, now: Date): number;
16
25
  export declare function orderedProviders(providers: readonly TranscriptionProvider[], context: TranscriptionAvailabilityContext): TranscriptionProvider[];
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Duration of a PCM RIFF/WAVE buffer the server itself produced (resumable
3
+ * provider segments are ffmpeg-normalized WAV). Used only as the trusted
4
+ * billing fallback when a provider reports no usage; never applied to
5
+ * client-uploaded bytes. Returns null for anything that is not a well-formed
6
+ * WAV with a positive byte rate.
7
+ */
8
+ export declare function wavDurationSeconds(bytes: Uint8Array): number | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,25 +43,25 @@
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@llamaindex/liteparse": "2.14.2",
45
45
  "@modelcontextprotocol/sdk": "^1.29.0",
46
- "@opengeni/agent-proto": "1.3.0",
47
- "@opengeni/artifact-tool": "1.3.0",
48
- "@opengeni/capabilities": "1.3.0",
49
- "@opengeni/codemode": "1.3.0",
50
- "@opengeni/codex": "1.3.0",
51
- "@opengeni/config": "1.3.0",
52
- "@opengeni/contracts": "1.3.0",
53
- "@opengeni/core": "1.3.0",
54
- "@opengeni/db": "1.3.0",
55
- "@opengeni/documents": "1.3.0",
56
- "@opengeni/events": "1.3.0",
57
- "@opengeni/github": "1.3.0",
58
- "@opengeni/interaction": "1.3.0",
59
- "@opengeni/network": "1.3.0",
60
- "@opengeni/observability": "1.3.0",
61
- "@opengeni/runtime": "1.3.0",
62
- "@opengeni/storage": "1.3.0",
63
- "@opengeni/tool-gateway": "1.3.0",
64
- "@opengeni/xai-subscription": "1.3.0",
46
+ "@opengeni/agent-proto": "1.4.0",
47
+ "@opengeni/artifact-tool": "1.4.0",
48
+ "@opengeni/capabilities": "1.4.0",
49
+ "@opengeni/codemode": "1.4.0",
50
+ "@opengeni/codex": "1.4.0",
51
+ "@opengeni/config": "1.4.0",
52
+ "@opengeni/contracts": "1.4.0",
53
+ "@opengeni/core": "1.4.0",
54
+ "@opengeni/db": "1.4.0",
55
+ "@opengeni/documents": "1.4.0",
56
+ "@opengeni/events": "1.4.0",
57
+ "@opengeni/github": "1.4.0",
58
+ "@opengeni/interaction": "1.4.0",
59
+ "@opengeni/network": "1.4.0",
60
+ "@opengeni/observability": "1.4.0",
61
+ "@opengeni/runtime": "1.4.0",
62
+ "@opengeni/storage": "1.4.0",
63
+ "@opengeni/tool-gateway": "1.4.0",
64
+ "@opengeni/xai-subscription": "1.4.0",
65
65
  "@temporalio/client": "^1.17.0",
66
66
  "better-auth": "^1.6.14",
67
67
  "hono": "^4.12.18",
@@ -8,25 +8,33 @@ import {
8
8
  freezeAgentLearningPolicy,
9
9
  withSessionRlsActorContext,
10
10
  withCreditDebitAttribution,
11
+ type CreditDebitAttribution,
12
+ currentCreditDebitAttribution,
11
13
  } from "@opengeni/db";
12
14
  import { HTTPException } from "hono/http-exception";
13
15
 
16
+ /**
17
+ * Trusted payer for a non-agent grant: an authenticated human session pays as
18
+ * that human; keys and services are service work. An agent attempt must be
19
+ * resolved through the validated middleware context installed below.
20
+ */
21
+ export function creditDebitAttributionForGrant(grant: AccessGrant): CreditDebitAttribution {
22
+ if (grant.principalKind === "agent_attempt") return currentCreditDebitAttribution();
23
+ return grant.principalKind === "human_session"
24
+ ? { kind: "human", initiatingHumanSubjectId: grant.subjectId }
25
+ : ["service", "api_key", "configured_key", "mcp_gateway"].includes(grant.principalKind ?? "")
26
+ ? { kind: "service" }
27
+ : { kind: "unknown" };
28
+ }
29
+
14
30
  export async function withAccessGrantSessionRlsContext<T>(
15
31
  deps: Pick<ApiRouteDeps, "db">,
16
32
  grant: AccessGrant,
17
33
  fn: () => Promise<T>,
18
34
  ): Promise<T> {
19
35
  if (grant.principalKind !== "agent_attempt") {
20
- return await withCreditDebitAttribution(
21
- grant.principalKind === "human_session"
22
- ? { kind: "human", initiatingHumanSubjectId: grant.subjectId }
23
- : grant.principalKind === "service" ||
24
- grant.principalKind === "api_key" ||
25
- grant.principalKind === "configured_key" ||
26
- grant.principalKind === "mcp_gateway"
27
- ? { kind: "service" }
28
- : { kind: "unknown" },
29
- () => withSessionRlsActorContext({ subjectId: grant.subjectId }, fn),
36
+ return await withCreditDebitAttribution(creditDebitAttributionForGrant(grant), () =>
37
+ withSessionRlsActorContext({ subjectId: grant.subjectId }, fn),
30
38
  );
31
39
  }
32
40
  const callerSessionId = grant.metadata?.sessionId;
package/src/app.ts CHANGED
@@ -24,9 +24,10 @@ import {
24
24
  canonicalPublicOrigin,
25
25
  configuredAllowedReasoningEfforts,
26
26
  resolveFirstPartyMcpToolPolicy,
27
- resolveVoiceInputProviderRegistry,
28
27
  UnsupportedLatencyModeError,
28
+ voiceInputPricingIssues,
29
29
  type Settings,
30
+ type FirstPartyMcpToolPolicySettings,
30
31
  } from "@opengeni/config";
31
32
  import {
32
33
  AGENT_CAPABILITY_IDS,
@@ -40,6 +41,7 @@ import {
40
41
  resolveWorkspaceMemoryEnabled,
41
42
  resolveWorkspaceMemoryPromptMode,
42
43
  VOICE_INPUT_ACCEPTED_MIME_TYPES,
44
+ WorkspaceVoiceInputSettings,
43
45
  TRANSCRIPTION_RECORDING_PROVIDER_SEGMENT_SECONDS,
44
46
  ToolGatewayApprovalRequest,
45
47
  ToolGatewayCallRequest,
@@ -491,12 +493,27 @@ export function createAppComposition(deps: AppDependencies): {
491
493
  }, MANAGED_AUTH_REAPER_INTERVAL_MS);
492
494
  (timer as ReturnType<typeof setInterval> & { unref?: () => void }).unref?.();
493
495
  }
496
+ if (deps.transcription === undefined) {
497
+ // Voice pricing problems never fail boot (workers share this config); the
498
+ // affected provider is withheld and the operator sees exactly why, once.
499
+ for (const issue of voiceInputPricingIssues(deps.settings)) {
500
+ observability.error(
501
+ `Voice input provider ${issue.providerId} is unavailable: ${issue.message}`,
502
+ {
503
+ providerId: issue.providerId,
504
+ env: issue.env,
505
+ reason: issue.reason,
506
+ },
507
+ );
508
+ }
509
+ }
494
510
  const transcription =
495
511
  deps.transcription === undefined
496
512
  ? createTranscriptionService({
497
513
  settings: deps.settings,
498
514
  db: deps.db,
499
515
  ...(deps.codexFetch ? { codexFetch: deps.codexFetch } : {}),
516
+ log: (message, attributes) => observability.error(message, attributes),
500
517
  })
501
518
  : deps.transcription;
502
519
  const transcriptionSegmenter =
@@ -646,6 +663,8 @@ export function createAppComposition(deps: AppDependencies): {
646
663
  "X-OpenGeni-Actor-Epoch",
647
664
  "X-OpenGeni-Correlation-Id",
648
665
  "X-OpenGeni-Session-Csrf",
666
+ // Session scope for a session proxy; the API itself ignores it.
667
+ "X-OpenGeni-Session-Id",
649
668
  "X-OpenGeni-Site-Id",
650
669
  "X-OpenGeni-Site-Version",
651
670
  "X-OpenGeni-Subject",
@@ -1191,6 +1210,8 @@ export function createAppComposition(deps: AppDependencies): {
1191
1210
  creditsAvailable: false,
1192
1211
  });
1193
1212
  let modelSelectionForbidden = false;
1213
+ let voiceInputAvailable = false;
1214
+ let voiceInputProviders: string[] = [];
1194
1215
  const requestedWorkspaceId = c.req.query("workspaceId");
1195
1216
  if (requestedWorkspaceId !== undefined && requestedWorkspaceId.trim() === "") {
1196
1217
  throw new HTTPException(422, { message: "workspaceId must not be empty" });
@@ -1241,6 +1262,25 @@ export function createAppComposition(deps: AppDependencies): {
1241
1262
  selections = [];
1242
1263
  }
1243
1264
  const workspace = await getWorkspace(deps.db, workspaceId);
1265
+ const voicePreferences = WorkspaceVoiceInputSettings.safeParse(
1266
+ workspace?.settings.voiceInput,
1267
+ ).data;
1268
+ const voiceContext = {
1269
+ workspaceId,
1270
+ subjectId: grant.subjectId,
1271
+ preferredProvider: voicePreferences?.preferredProvider,
1272
+ fallbackEnabled: voicePreferences?.fallbackEnabled,
1273
+ };
1274
+ if (hasPermission(grant.permissions, "sessions:create") && transcription) {
1275
+ // `providers` lists every ready provider (the workspace picker);
1276
+ // `available` honours the workspace preference and fallback, so
1277
+ // the composer never offers a mic every request would refuse.
1278
+ voiceInputProviders = (await transcription.availableProviderIds?.(voiceContext)) ?? [];
1279
+ voiceInputAvailable =
1280
+ voiceInputProviders.length > 0 &&
1281
+ (await Promise.resolve(transcription.available(voiceContext)).catch(() => false));
1282
+ }
1283
+
1244
1284
  defaultSelection = await resolveDefaultSessionModelForSelections(deps.db, {
1245
1285
  settings: catalogSettings,
1246
1286
  accountId: grant.accountId,
@@ -1303,10 +1343,8 @@ export function createAppComposition(deps: AppDependencies): {
1303
1343
  maxSizeBytes: objectStorage?.maxSinglePutSizeBytes ?? 5_000_000_000,
1304
1344
  },
1305
1345
  voiceInput: {
1306
- providers: resolveVoiceInputProviderRegistry(deps.settings).map(
1307
- (provider) => provider.id,
1308
- ),
1309
- available: (await transcription?.available()) ?? false,
1346
+ providers: voiceInputProviders,
1347
+ available: voiceInputAvailable,
1310
1348
  maxDurationSeconds: deps.settings.voiceInputMaxDurationSeconds,
1311
1349
  maxSizeBytes: deps.settings.voiceInputMaxSizeBytes,
1312
1350
  acceptedMimeTypes: [...VOICE_INPUT_ACCEPTED_MIME_TYPES],
@@ -1314,7 +1352,7 @@ export function createAppComposition(deps: AppDependencies): {
1314
1352
  objectStorage &&
1315
1353
  transcription &&
1316
1354
  transcriptionSegmenter &&
1317
- (await transcription.available()) &&
1355
+ voiceInputAvailable &&
1318
1356
  (await transcriptionSegmenter.available())
1319
1357
  ? {
1320
1358
  resumable: {
@@ -2107,7 +2145,7 @@ function clientAuthConfig(settings: AppDependencies["settings"], newSignupsEnabl
2107
2145
 
2108
2146
  /** Configured SDK credentials cannot regain tools from widened legacy columns. */
2109
2147
  export function configuredCodemodeSessionProxyTools(
2110
- settings: Pick<Settings, "defaultFirstPartyMcpTools" | "allowedFirstPartyMcpTools">,
2148
+ settings: FirstPartyMcpToolPolicySettings,
2111
2149
  session: Pick<Session, "agent">,
2112
2150
  ): FirstPartyMcpToolName[] | null {
2113
2151
  if (!session.agent) return null;
@@ -0,0 +1,103 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import type { CodexRealtimeWebrtcRequest } from "@opengeni/contracts";
3
+ import {
4
+ getActiveSessionHistoryItems,
5
+ getSessionRealtimeContinuityEntries,
6
+ type Database,
7
+ } from "@opengeni/db";
8
+ import { CodexRealtimeBrokerError, openGeniRealtimeInstructions } from "./codex-realtime";
9
+ import { projectSessionRealtimeInitialItems } from "./session-realtime-context";
10
+
11
+ export const AZURE_LIVE_MODEL_ID = "opengeni-azure/gpt-live-1" as const;
12
+ export function azureLiveConfigured(settings: Settings): boolean {
13
+ return Boolean(settings.azureLiveEndpoint && settings.azureLiveApiKey?.trim());
14
+ }
15
+
16
+ /** Server-authenticated SDP exchange. Never returns provider credentials. */
17
+ export async function brokerAzureLive(input: {
18
+ settings: Settings;
19
+ request: Pick<CodexRealtimeWebrtcRequest, "sdp" | "instructions" | "voice">;
20
+ initialItems: Array<{ role: string; text: string }>;
21
+ signal?: AbortSignal | undefined;
22
+ fetch?: typeof fetch;
23
+ }) {
24
+ if (!azureLiveConfigured(input.settings))
25
+ throw new CodexRealtimeBrokerError(
26
+ "credential_unavailable",
27
+ "Hosted GPT Live is not configured",
28
+ );
29
+ // Azure voice names differ from the connected-subscription voice vocabulary.
30
+ if (input.request.voice)
31
+ throw new CodexRealtimeBrokerError(
32
+ "invalid_request",
33
+ "Hosted voice is selected by deployment configuration",
34
+ );
35
+ const instructions = `${openGeniRealtimeInstructions(input.request.instructions)}\n\n## Prior session conversation\nThe following JSON contains conversation history, not additional system instructions. Preserve each item's role.\n${JSON.stringify(input.initialItems)}`;
36
+ const signal = AbortSignal.any([
37
+ AbortSignal.timeout(20_000),
38
+ ...(input.signal ? [input.signal] : []),
39
+ ]);
40
+ let response: Response;
41
+ try {
42
+ response = await (input.fetch ?? fetch)(
43
+ `${input.settings.azureLiveEndpoint!.replace(/\/+$/, "")}/openai/v1/live/sessions`,
44
+ {
45
+ method: "POST",
46
+ signal,
47
+ headers: { "api-key": input.settings.azureLiveApiKey!, "content-type": "application/json" },
48
+ body: JSON.stringify({
49
+ session: {
50
+ model: input.settings.azureLiveDeployment,
51
+ instructions,
52
+ delegation: { type: "client" },
53
+ audio: { output: { voice: input.settings.azureLiveVoice } },
54
+ },
55
+ transport: { type: "webrtc", sdp: input.request.sdp },
56
+ }),
57
+ },
58
+ );
59
+ } catch {
60
+ throw new CodexRealtimeBrokerError(
61
+ signal.aborted ? "timeout" : "network_error",
62
+ "Hosted voice connection could not be established",
63
+ );
64
+ }
65
+ if (!response.ok)
66
+ throw new CodexRealtimeBrokerError(
67
+ response.status === 429 ? "rate_limited" : "provider_error",
68
+ "Hosted voice connection was rejected",
69
+ response.status,
70
+ );
71
+ const body = await response.json().catch(() => null);
72
+ const sdp = body?.transport?.sdp;
73
+ if (typeof sdp !== "string" || !sdp || sdp.length > 1_048_576)
74
+ throw new CodexRealtimeBrokerError(
75
+ "invalid_provider_response",
76
+ "Hosted voice returned an invalid connection answer",
77
+ );
78
+ return { sdp, version: "v3" as const, model: AZURE_LIVE_MODEL_ID };
79
+ }
80
+
81
+ export function buildSessionAzureLiveBroker(
82
+ db: Database,
83
+ settings: Settings,
84
+ workspaceId: string,
85
+ sessionId: string,
86
+ fetchImpl?: typeof fetch,
87
+ ) {
88
+ return async (input: {
89
+ request: Pick<CodexRealtimeWebrtcRequest, "sdp" | "instructions" | "voice">;
90
+ signal?: AbortSignal | undefined;
91
+ }) => {
92
+ const [history, continuity] = await Promise.all([
93
+ getActiveSessionHistoryItems(db, workspaceId, sessionId),
94
+ getSessionRealtimeContinuityEntries(db, workspaceId, sessionId),
95
+ ]);
96
+ return brokerAzureLive({
97
+ settings,
98
+ ...input,
99
+ initialItems: projectSessionRealtimeInitialItems(history, continuity),
100
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
101
+ });
102
+ };
103
+ }
package/src/codemode.ts CHANGED
@@ -28,7 +28,7 @@ import { getSession } from "@opengeni/db";
28
28
  import {
29
29
  allowedFirstPartyMcpToolsForSession,
30
30
  resolveFirstPartyDelegationSecret,
31
- type Settings,
31
+ type FirstPartyMcpToolPolicySettings,
32
32
  } from "@opengeni/config";
33
33
  import {
34
34
  DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
@@ -59,7 +59,7 @@ export const CODEMODE_SESSION_PROXY_PERMISSION_CEILING = [
59
59
  * exactly as its MCP surface would.
60
60
  */
61
61
  export function codemodeSessionProxyPermissions(
62
- settings: Pick<Settings, "defaultFirstPartyMcpTools" | "allowedFirstPartyMcpTools">,
62
+ settings: FirstPartyMcpToolPolicySettings,
63
63
  session: Pick<Session, "firstPartyMcpTools" | "firstPartyMcpPermissions">,
64
64
  ): Permission[] {
65
65
  const sessionPermissions = session.firstPartyMcpPermissions ?? [
@@ -82,9 +82,7 @@ export async function codemodeSessionRequest(
82
82
  grant: AccessGrant,
83
83
  request: Request,
84
84
  path: string,
85
- resolveProxySettings?: (
86
- session: Session,
87
- ) => Pick<Settings, "defaultFirstPartyMcpTools" | "allowedFirstPartyMcpTools">,
85
+ resolveProxySettings?: (session: Session) => FirstPartyMcpToolPolicySettings,
88
86
  ): Promise<Request> {
89
87
  siteSessionPath(path, grant.workspaceId, request.method);
90
88
  const { authority, turn } = await requireActiveCodemodeCatalog(deps, grant);
@@ -2708,6 +2708,7 @@ async function verifyMcpToolsListNonFatal(
2708
2708
  providerIdentity = local.validateIdentity(payload);
2709
2709
  const verifiedTools = local.toolsForScopes(
2710
2710
  grantedScopes(token.scopeText, state.authorizeScopes, profile),
2711
+ settings,
2711
2712
  );
2712
2713
  if (local.required && verifiedTools.length === 0)
2713
2714
  throw new Error("Connector verification did not report an authorized tool scope");
@@ -7,7 +7,11 @@ import { OPENGENI_PERSONAL_SLACK_MCP_URL, type ConnectionOwnership } from "@open
7
7
  import type { Settings } from "@opengeni/config";
8
8
  import { HTTPException } from "hono/http-exception";
9
9
  import { z } from "zod";
10
- import { GMAIL_REST_MCP_TOOLS, gmailToolSupportsScopes } from "@opengeni/runtime/gmail-rest-mcp";
10
+ import {
11
+ GMAIL_REST_MCP_TOOLS,
12
+ gmailToolAvailableOnDeployment,
13
+ gmailToolSupportsScopes,
14
+ } from "@opengeni/runtime/gmail-rest-mcp";
11
15
  import { canonicalProviderDomain } from "./provider-domain";
12
16
 
13
17
  /**
@@ -132,7 +136,10 @@ export type OAuthProviderProfile = {
132
136
  /** Distinguish a rejected OAuth grant from a temporary verification failure. */
133
137
  isRejectedGrant?: (payload: Record<string, unknown>) => boolean;
134
138
  validateIdentity: (payload: Record<string, unknown>) => Record<string, string>;
135
- toolsForScopes: (scopes: readonly string[]) => Array<{ name: string; description?: string }>;
139
+ toolsForScopes: (
140
+ scopes: readonly string[],
141
+ deployment: { gmailWatchTopicName?: string | undefined },
142
+ ) => Array<{ name: string; description?: string }>;
136
143
  };
137
144
  };
138
145
 
@@ -265,10 +272,14 @@ const OFFICIAL_GMAIL_PROFILE: OAuthProviderProfile = {
265
272
  }
266
273
  return { gmailEmail: payload.emailAddress };
267
274
  },
268
- toolsForScopes: (scopes) => {
269
- return GMAIL_REST_MCP_TOOLS.filter((tool) => gmailToolSupportsScopes(tool.name, scopes)).map(
270
- ({ name, description }) => ({ name, ...(description ? { description } : {}) }),
271
- );
275
+ toolsForScopes: (scopes, deployment) => {
276
+ return GMAIL_REST_MCP_TOOLS.filter(
277
+ (tool) =>
278
+ gmailToolSupportsScopes(tool.name, scopes) &&
279
+ gmailToolAvailableOnDeployment(tool.name, {
280
+ watchTopicName: deployment.gmailWatchTopicName,
281
+ }),
282
+ ).map(({ name, description }) => ({ name, ...(description ? { description } : {}) }));
272
283
  },
273
284
  },
274
285
  };
@@ -609,6 +609,14 @@ export function registerEditableArtifactRoutes(
609
609
  const body = await parseBoundedMaterializationRequest(context);
610
610
  const scope = editableArtifactScope({ accountId: grant.accountId, workspaceId });
611
611
  const actor = editableArtifactActorForGrant(grant, body.replicaId);
612
+ // Without a materializer workload the job would stay queued forever.
613
+ if (!deps.settings.artifactMaterializerDeployed) {
614
+ throw new ApiHttpError(503, {
615
+ code: "upstream_unavailable",
616
+ message: "File export is not available on this deployment.",
617
+ retryable: false,
618
+ });
619
+ }
612
620
  try {
613
621
  const result = await requireEditableArtifactExports(deps).enqueueMaterialization({
614
622
  scope,