@opengeni/api-router 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.
Files changed (42) 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-F3LQ6VWR.js} +1334 -486
  6. package/dist/{chunk-IZEGLLKZ.js.map → chunk-F3LQ6VWR.js.map} +1 -1
  7. package/dist/codemode.d.ts +3 -3
  8. package/dist/http/api-error.d.ts +14 -0
  9. package/dist/index.js +1 -1
  10. package/dist/integrations/oauth-profiles.d.ts +3 -1
  11. package/dist/routes/sessions.d.ts +2 -0
  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 +62 -8
  22. package/src/azure-live.ts +103 -0
  23. package/src/codemode.ts +3 -5
  24. package/src/gateway-realtime.ts +11 -7
  25. package/src/http/api-error.ts +36 -1
  26. package/src/integrations/oauth-client.ts +9 -17
  27. package/src/integrations/oauth-profiles.ts +17 -6
  28. package/src/routes/editable-artifacts.ts +8 -0
  29. package/src/routes/session-artifact-associations.ts +80 -6
  30. package/src/routes/sessions.ts +263 -66
  31. package/src/routes/transcription-recordings.ts +71 -4
  32. package/src/routes/transcriptions.ts +26 -2
  33. package/src/routes/workspaces.ts +49 -7
  34. package/src/sandbox/channel-a.ts +22 -0
  35. package/src/transcription/billing-refusal.ts +14 -0
  36. package/src/transcription/normalize.ts +77 -0
  37. package/src/transcription/providers/azure-mai.ts +98 -0
  38. package/src/transcription/providers/azure-openai.ts +16 -7
  39. package/src/transcription/providers/openai.ts +79 -5
  40. package/src/transcription/segmenter.ts +7 -1
  41. package/src/transcription/service.ts +215 -10
  42. 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;
@@ -37,6 +37,20 @@ export declare const DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE = "DATABASE_UNAVAILA
37
37
  * must reconcile by its own idempotency key before resending.
38
38
  */
39
39
  export declare function databaseUnavailableHttpError(error: unknown, method: string): ApiHttpError | null;
40
+ /** `details.code` of the retryable 503 for a deadlock/serialization victim. */
41
+ export declare const DATABASE_CONTENTION_ERROR_DETAIL_CODE = "DATABASE_CONTENTION";
42
+ /**
43
+ * PostgreSQL aborted the request's transaction as a deadlock (40P01) or
44
+ * serialization (40001) victim after any in-process retry was exhausted. The
45
+ * aborted transaction committed nothing, so this is transient contention, not
46
+ * an internal error. A read is safe to repeat. For a mutation the default is
47
+ * `outcomeUnknown` because an earlier transaction of the same request may have
48
+ * committed; a route whose only write is the aborted idempotent transaction
49
+ * passes `outcomeUnknown: false`.
50
+ */
51
+ export declare function databaseContentionHttpError(error: unknown, method: string, options?: {
52
+ outcomeUnknown?: boolean;
53
+ }): ApiHttpError | null;
40
54
  /**
41
55
  * A typed agent-configuration failure (capability unavailable, conflict,
42
56
  * widening, not enabled). Rendered as 422 validation_failed with the specific
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-F3LQ6VWR.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
  }>;
@@ -1,11 +1,13 @@
1
1
  import { ClientSessionEvent, SessionScopeSubjectId, SteerSessionMessageRequest, type SessionStatus, type WorkClaimSubjectFilter, type SessionGoalRevision, type SessionAuthorizationOperation } from "@opengeni/contracts";
2
2
  import { decodeSessionListCursor, type SessionDiscoveryCursor } from "@opengeni/db";
3
3
  import type { Context, Hono } from "hono";
4
+ import { HTTPException } from "hono/http-exception";
4
5
  import type { ApiRouteDeps } from "@opengeni/core";
5
6
  import { type ViewerServices } from "../sandbox/viewer.js";
6
7
  type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
7
8
  export declare function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void;
8
9
  export declare function goalProposalMatchesExpectedRevision(revision: Pick<SessionGoalRevision, "baseObjectiveRevision">, expectedObjectiveRevision: number): boolean;
10
+ export declare function sessionRealtimeHttpError(error: unknown): HTTPException;
9
11
  /**
10
12
  * Map every mounted session-addressed HTTP path to the host-neutral operation
11
13
  * the embedding port authorizes. Returning null is deliberately fail-closed in
@@ -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.2",
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.2",
47
+ "@opengeni/artifact-tool": "1.4.2",
48
+ "@opengeni/capabilities": "1.4.2",
49
+ "@opengeni/codemode": "1.4.2",
50
+ "@opengeni/codex": "1.4.2",
51
+ "@opengeni/config": "1.4.2",
52
+ "@opengeni/contracts": "1.4.2",
53
+ "@opengeni/core": "1.4.2",
54
+ "@opengeni/db": "1.4.2",
55
+ "@opengeni/documents": "1.4.2",
56
+ "@opengeni/events": "1.4.2",
57
+ "@opengeni/github": "1.4.2",
58
+ "@opengeni/interaction": "1.4.2",
59
+ "@opengeni/network": "1.4.2",
60
+ "@opengeni/observability": "1.4.2",
61
+ "@opengeni/runtime": "1.4.2",
62
+ "@opengeni/storage": "1.4.2",
63
+ "@opengeni/tool-gateway": "1.4.2",
64
+ "@opengeni/xai-subscription": "1.4.2",
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,
@@ -97,7 +99,9 @@ import {
97
99
  ApiHttpError,
98
100
  agentConfigHttpError,
99
101
  allowanceExhaustedHttpError,
102
+ DATABASE_CONTENTION_ERROR_DETAIL_CODE,
100
103
  DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE,
104
+ databaseContentionHttpError,
101
105
  databaseUnavailableHttpError,
102
106
  modelUnavailableHttpError,
103
107
  scheduledTaskTargetAccessHttpError,
@@ -491,12 +495,27 @@ export function createAppComposition(deps: AppDependencies): {
491
495
  }, MANAGED_AUTH_REAPER_INTERVAL_MS);
492
496
  (timer as ReturnType<typeof setInterval> & { unref?: () => void }).unref?.();
493
497
  }
498
+ if (deps.transcription === undefined) {
499
+ // Voice pricing problems never fail boot (workers share this config); the
500
+ // affected provider is withheld and the operator sees exactly why, once.
501
+ for (const issue of voiceInputPricingIssues(deps.settings)) {
502
+ observability.error(
503
+ `Voice input provider ${issue.providerId} is unavailable: ${issue.message}`,
504
+ {
505
+ providerId: issue.providerId,
506
+ env: issue.env,
507
+ reason: issue.reason,
508
+ },
509
+ );
510
+ }
511
+ }
494
512
  const transcription =
495
513
  deps.transcription === undefined
496
514
  ? createTranscriptionService({
497
515
  settings: deps.settings,
498
516
  db: deps.db,
499
517
  ...(deps.codexFetch ? { codexFetch: deps.codexFetch } : {}),
518
+ log: (message, attributes) => observability.error(message, attributes),
500
519
  })
501
520
  : deps.transcription;
502
521
  const transcriptionSegmenter =
@@ -644,8 +663,14 @@ export function createAppComposition(deps: AppDependencies): {
644
663
  "X-OpenGeni-Access-Key",
645
664
  "X-OpenGeni-Api-Contract",
646
665
  "X-OpenGeni-Actor-Epoch",
666
+ // Resumable voice-input chunk integrity/timing (cross-origin embeds).
667
+ "X-OpenGeni-Chunk-Duration-Milliseconds",
668
+ "X-OpenGeni-Chunk-Sha256",
669
+ "X-OpenGeni-Chunk-Start-Milliseconds",
647
670
  "X-OpenGeni-Correlation-Id",
648
671
  "X-OpenGeni-Session-Csrf",
672
+ // Session scope for a session proxy; the API itself ignores it.
673
+ "X-OpenGeni-Session-Id",
649
674
  "X-OpenGeni-Site-Id",
650
675
  "X-OpenGeni-Site-Version",
651
676
  "X-OpenGeni-Subject",
@@ -1191,6 +1216,8 @@ export function createAppComposition(deps: AppDependencies): {
1191
1216
  creditsAvailable: false,
1192
1217
  });
1193
1218
  let modelSelectionForbidden = false;
1219
+ let voiceInputAvailable = false;
1220
+ let voiceInputProviders: string[] = [];
1194
1221
  const requestedWorkspaceId = c.req.query("workspaceId");
1195
1222
  if (requestedWorkspaceId !== undefined && requestedWorkspaceId.trim() === "") {
1196
1223
  throw new HTTPException(422, { message: "workspaceId must not be empty" });
@@ -1241,6 +1268,25 @@ export function createAppComposition(deps: AppDependencies): {
1241
1268
  selections = [];
1242
1269
  }
1243
1270
  const workspace = await getWorkspace(deps.db, workspaceId);
1271
+ const voicePreferences = WorkspaceVoiceInputSettings.safeParse(
1272
+ workspace?.settings.voiceInput,
1273
+ ).data;
1274
+ const voiceContext = {
1275
+ workspaceId,
1276
+ subjectId: grant.subjectId,
1277
+ preferredProvider: voicePreferences?.preferredProvider,
1278
+ fallbackEnabled: voicePreferences?.fallbackEnabled,
1279
+ };
1280
+ if (hasPermission(grant.permissions, "sessions:create") && transcription) {
1281
+ // `providers` lists every ready provider (the workspace picker);
1282
+ // `available` honours the workspace preference and fallback, so
1283
+ // the composer never offers a mic every request would refuse.
1284
+ voiceInputProviders = (await transcription.availableProviderIds?.(voiceContext)) ?? [];
1285
+ voiceInputAvailable =
1286
+ voiceInputProviders.length > 0 &&
1287
+ (await Promise.resolve(transcription.available(voiceContext)).catch(() => false));
1288
+ }
1289
+
1244
1290
  defaultSelection = await resolveDefaultSessionModelForSelections(deps.db, {
1245
1291
  settings: catalogSettings,
1246
1292
  accountId: grant.accountId,
@@ -1303,10 +1349,8 @@ export function createAppComposition(deps: AppDependencies): {
1303
1349
  maxSizeBytes: objectStorage?.maxSinglePutSizeBytes ?? 5_000_000_000,
1304
1350
  },
1305
1351
  voiceInput: {
1306
- providers: resolveVoiceInputProviderRegistry(deps.settings).map(
1307
- (provider) => provider.id,
1308
- ),
1309
- available: (await transcription?.available()) ?? false,
1352
+ providers: voiceInputProviders,
1353
+ available: voiceInputAvailable,
1310
1354
  maxDurationSeconds: deps.settings.voiceInputMaxDurationSeconds,
1311
1355
  maxSizeBytes: deps.settings.voiceInputMaxSizeBytes,
1312
1356
  acceptedMimeTypes: [...VOICE_INPUT_ACCEPTED_MIME_TYPES],
@@ -1314,7 +1358,7 @@ export function createAppComposition(deps: AppDependencies): {
1314
1358
  objectStorage &&
1315
1359
  transcription &&
1316
1360
  transcriptionSegmenter &&
1317
- (await transcription.available()) &&
1361
+ voiceInputAvailable &&
1318
1362
  (await transcriptionSegmenter.available())
1319
1363
  ? {
1320
1364
  resumable: {
@@ -1920,6 +1964,7 @@ export function createAppComposition(deps: AppDependencies): {
1920
1964
  requestBodyValidationHttpError(rawError) ??
1921
1965
  invalidPathIdentifierHttpError(rawError, new URL(c.req.url).pathname) ??
1922
1966
  databaseUnavailableHttpError(rawError, c.req.method) ??
1967
+ databaseContentionHttpError(rawError, c.req.method) ??
1923
1968
  rawError);
1924
1969
  const compactionLock = codexCompactionV2ProviderLockedError(error);
1925
1970
  const apiError = error instanceof ApiHttpError ? error : null;
@@ -1928,7 +1973,10 @@ export function createAppComposition(deps: AppDependencies): {
1928
1973
  ? compactionLock.code
1929
1974
  : (apiError?.code ?? errorCodeForStatus(status));
1930
1975
  if (status >= 500) logHttpFailure(c, status, code, rawError);
1931
- if (apiError?.details?.code === DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE) {
1976
+ if (
1977
+ apiError?.details?.code === DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE ||
1978
+ apiError?.details?.code === DATABASE_CONTENTION_ERROR_DETAIL_CODE
1979
+ ) {
1932
1980
  c.header("retry-after", "1");
1933
1981
  }
1934
1982
  const envelope = ErrorEnvelope.parse({
@@ -2107,7 +2155,7 @@ function clientAuthConfig(settings: AppDependencies["settings"], newSignupsEnabl
2107
2155
 
2108
2156
  /** Configured SDK credentials cannot regain tools from widened legacy columns. */
2109
2157
  export function configuredCodemodeSessionProxyTools(
2110
- settings: Pick<Settings, "defaultFirstPartyMcpTools" | "allowedFirstPartyMcpTools">,
2158
+ settings: FirstPartyMcpToolPolicySettings,
2111
2159
  session: Pick<Session, "agent">,
2112
2160
  ): FirstPartyMcpToolName[] | null {
2113
2161
  if (!session.agent) return null;
@@ -2236,6 +2284,12 @@ export function assertConfiguredCodemodeSessionProxyPath(
2236
2284
  }
2237
2285
 
2238
2286
  function codemodeHttpError(error: unknown): HTTPException {
2287
+ // The journal admission is one idempotent transaction keyed by the
2288
+ // caller-owned operation id and is already retried in-process; every other
2289
+ // step is a read. An exhausted 40P01/40001 victim therefore committed
2290
+ // nothing and the client may resubmit the same operation id.
2291
+ const contention = databaseContentionHttpError(error, "POST", { outcomeUnknown: false });
2292
+ if (contention) return contention;
2239
2293
  if (error instanceof SiteSessionPathError) {
2240
2294
  // The proxied Site/SDK surface is an explicit allowlist; a route outside
2241
2295
  // it (tool policy, visibility, forks, Steer, control, ...) does not exist
@@ -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);
@@ -72,6 +72,7 @@ export async function createGatewayRealtimeConnectionSecret(input: {
72
72
  getSessionRealtimeContinuityEntries(input.db, input.workspaceId, input.sessionId),
73
73
  mintGatewayClientSecret({
74
74
  apiKey,
75
+ source: resolved.source,
75
76
  upstreamModelId: resolved.upstreamModelId,
76
77
  fetchImpl: input.fetchImpl ?? fetch,
77
78
  }),
@@ -86,6 +87,7 @@ export async function createGatewayRealtimeConnectionSecret(input: {
86
87
 
87
88
  async function mintGatewayClientSecret(input: {
88
89
  apiKey: string;
90
+ source: "managed" | "workspace";
89
91
  upstreamModelId: string;
90
92
  fetchImpl: typeof fetch;
91
93
  }): Promise<{ token: string; url: string; expiresAt: number | null }> {
@@ -105,17 +107,19 @@ async function mintGatewayClientSecret(input: {
105
107
  } catch {
106
108
  throw new GatewayRealtimeBrokerError(
107
109
  "provider_error",
108
- "AI Gateway realtime token request failed",
110
+ "The voice provider could not be reached. Try again.",
109
111
  );
110
112
  }
111
113
  if (!response.ok) {
114
+ const rejected = response.status === 401 || response.status === 403;
115
+ // User-facing: say what to do, never which deployment credential failed.
112
116
  throw new GatewayRealtimeBrokerError(
113
- response.status === 401 || response.status === 403
114
- ? "credential_unavailable"
115
- : "provider_error",
116
- response.status === 401 || response.status === 403
117
- ? "AI Gateway credentials were rejected"
118
- : "AI Gateway realtime token request failed",
117
+ rejected ? "credential_unavailable" : "provider_error",
118
+ rejected
119
+ ? input.source === "managed"
120
+ ? "Opengeni voice is temporarily unavailable. Try another voice model."
121
+ : "Your AI Gateway key was rejected. Update it in workspace settings."
122
+ : "The voice provider could not start a call. Try again.",
119
123
  response.status,
120
124
  );
121
125
  }