@opengeni/api-router 0.12.12 → 0.14.4

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 (87) hide show
  1. package/dist/app.d.ts +18 -38
  2. package/dist/app.js +3 -1
  3. package/dist/auth/managed-auth.d.ts +35 -0
  4. package/dist/{chunk-5TULDPWX.js → chunk-36PW33ND.js} +4111 -1277
  5. package/dist/chunk-36PW33ND.js.map +1 -0
  6. package/dist/codex-redemption-security.d.ts +15 -0
  7. package/dist/github-access.d.ts +12 -0
  8. package/dist/github-browser-flow.d.ts +14 -0
  9. package/dist/http/auth.d.ts +3 -0
  10. package/dist/http/common.d.ts +3 -0
  11. package/dist/http/sse.d.ts +70 -0
  12. package/dist/index.d.ts +11 -13
  13. package/dist/index.js +1 -1
  14. package/dist/integrations/google-drive.d.ts +76 -0
  15. package/dist/integrations/oauth-client.d.ts +42 -0
  16. package/dist/integrations/provider-domain.d.ts +7 -0
  17. package/dist/integrations/slack-bot.d.ts +343 -0
  18. package/dist/mcp/documents.d.ts +8 -0
  19. package/dist/mcp/files.d.ts +5 -0
  20. package/dist/mcp/server.d.ts +107 -0
  21. package/dist/mcp/session-view.d.ts +191 -0
  22. package/dist/mcp/toolspace.d.ts +61 -0
  23. package/dist/model-catalog.d.ts +39 -0
  24. package/dist/observability.d.ts +3 -0
  25. package/dist/routes/api-keys.d.ts +3 -0
  26. package/dist/routes/billing.d.ts +16 -0
  27. package/dist/routes/capabilities.d.ts +3 -0
  28. package/dist/routes/catalog-assets.d.ts +5 -0
  29. package/dist/routes/codex.d.ts +10 -0
  30. package/dist/routes/connections.d.ts +3 -0
  31. package/dist/routes/documents.d.ts +3 -0
  32. package/dist/routes/enrollments.d.ts +3 -0
  33. package/dist/routes/environments.d.ts +5 -0
  34. package/dist/routes/files.d.ts +4 -0
  35. package/dist/routes/github.d.ts +3 -0
  36. package/dist/routes/insights.d.ts +3 -0
  37. package/dist/routes/install.d.ts +5 -0
  38. package/dist/routes/machines.d.ts +3 -0
  39. package/dist/routes/packs.d.ts +3 -0
  40. package/dist/routes/preference-registry.d.ts +5 -0
  41. package/dist/routes/rigs.d.ts +3 -0
  42. package/dist/routes/scheduled-tasks.d.ts +3 -0
  43. package/dist/routes/sessions.d.ts +15 -0
  44. package/dist/routes/social.d.ts +3 -0
  45. package/dist/routes/transcriptions.d.ts +3 -0
  46. package/dist/routes/workspace-capture.d.ts +59 -0
  47. package/dist/routes/workspace-instruction-policies.d.ts +3 -0
  48. package/dist/routes/workspace-state.d.ts +3 -0
  49. package/dist/routes/workspaces.d.ts +5 -0
  50. package/dist/sandbox/access.d.ts +21 -0
  51. package/dist/sandbox/auth-callout.d.ts +30 -0
  52. package/dist/sandbox/channel-a.d.ts +37 -0
  53. package/dist/sandbox/enrollment.d.ts +120 -0
  54. package/dist/sandbox/machines.d.ts +29 -0
  55. package/dist/sandbox/metrics-ingestion.d.ts +128 -0
  56. package/dist/sandbox/rematerialize.d.ts +24 -0
  57. package/dist/sandbox/viewer.d.ts +251 -0
  58. package/dist/transcription/providers/azure-openai.d.ts +9 -0
  59. package/dist/transcription/providers/codex-subscription.d.ts +9 -0
  60. package/dist/transcription/providers/openai.d.ts +9 -0
  61. package/dist/transcription/service.d.ts +10 -0
  62. package/dist/workspace-state-projection.d.ts +16 -0
  63. package/package.json +13 -13
  64. package/src/app.ts +67 -5
  65. package/src/integrations/google-drive.ts +869 -0
  66. package/src/integrations/oauth-client.ts +41 -6
  67. package/src/integrations/slack-bot.ts +756 -26
  68. package/src/mcp/server.ts +200 -3
  69. package/src/mcp/session-view.ts +1 -0
  70. package/src/mcp/toolspace.ts +17 -9
  71. package/src/routes/connections.ts +137 -7
  72. package/src/routes/documents.ts +13 -2
  73. package/src/routes/github.ts +7 -5
  74. package/src/routes/insights.ts +30 -0
  75. package/src/routes/preference-registry.ts +482 -0
  76. package/src/routes/sessions.ts +10 -0
  77. package/src/routes/transcriptions.ts +155 -0
  78. package/src/routes/workspace-state.ts +61 -0
  79. package/src/sandbox/channel-a.ts +2 -0
  80. package/src/sandbox/rematerialize.ts +111 -4
  81. package/src/sandbox/viewer.ts +29 -12
  82. package/src/transcription/providers/azure-openai.ts +45 -0
  83. package/src/transcription/providers/codex-subscription.ts +100 -0
  84. package/src/transcription/providers/openai.ts +93 -0
  85. package/src/transcription/service.ts +121 -0
  86. package/src/workspace-state-projection.ts +244 -0
  87. package/dist/chunk-5TULDPWX.js.map +0 -1
@@ -0,0 +1,61 @@
1
+ import {
2
+ WORKSPACE_STATE_MAX_BASES,
3
+ WORKSPACE_STATE_MAX_TOPICS,
4
+ WORKSPACE_STATE_TOPIC_MAX_CHARS,
5
+ WorkspaceStateResponse,
6
+ } from "@opengeni/contracts";
7
+ import { hasPermission, requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
8
+ import {
9
+ getWorkspace,
10
+ listWorkspaceStateMemoryRecords,
11
+ listWorkspaceInstructionPolicyRevisions,
12
+ } from "@opengeni/db";
13
+ import { getDocumentInventory } from "@opengeni/documents";
14
+ import type { Hono } from "hono";
15
+ import { HTTPException } from "hono/http-exception";
16
+
17
+ import { projectWorkspaceState } from "../workspace-state-projection";
18
+
19
+ export function registerWorkspaceStateRoutes(app: Hono, deps: ApiRouteDeps): void {
20
+ app.get("/v1/workspaces/:workspaceId/workspace-state", async (context) => {
21
+ const workspaceId = context.req.param("workspaceId");
22
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:read");
23
+ const generatedAt = new Date().toISOString();
24
+ const canInspectKnowledge = hasPermission(grant.permissions, "documents:search");
25
+
26
+ const [workspace, policies, knowledge] = await Promise.all([
27
+ getWorkspace(deps.db, workspaceId),
28
+ listWorkspaceInstructionPolicyRevisions(deps.db, workspaceId, { limit: 1 }),
29
+ canInspectKnowledge
30
+ ? (async () => {
31
+ const [documents, memories] = await Promise.all([
32
+ getDocumentInventory(deps.db, workspaceId, {
33
+ baseLimit: WORKSPACE_STATE_MAX_BASES,
34
+ topicLimit: WORKSPACE_STATE_MAX_TOPICS,
35
+ topicMaxChars: WORKSPACE_STATE_TOPIC_MAX_CHARS,
36
+ access: { viewerSubjectId: grant.subjectId },
37
+ }),
38
+ listWorkspaceStateMemoryRecords(deps.db, workspaceId),
39
+ ]);
40
+ return { documents, memories };
41
+ })()
42
+ : Promise.resolve(null),
43
+ ]);
44
+
45
+ if (!workspace) {
46
+ throw new HTTPException(404, { message: "workspace not found" });
47
+ }
48
+ context.header("cache-control", "private, no-store");
49
+ return context.json(
50
+ WorkspaceStateResponse.parse(
51
+ projectWorkspaceState({
52
+ workspaceId,
53
+ generatedAt,
54
+ workspaceAgentInstructions: workspace.agentInstructions,
55
+ policies,
56
+ knowledge,
57
+ }),
58
+ ),
59
+ );
60
+ });
61
+ }
@@ -21,6 +21,7 @@ import {
21
21
  applyGitAuthPointerEnvironment,
22
22
  hasGitCredentialRepositorySelection,
23
23
  hasGitHubRepositorySelection,
24
+ sandboxArchiveCaptureTimeoutMs,
24
25
  stableSandboxEnvironmentForRun,
25
26
  type Settings,
26
27
  } from "@opengeni/config";
@@ -247,6 +248,7 @@ export async function withChannelA<T>(
247
248
  os: session.sandboxOs,
248
249
  leaseTtlMs,
249
250
  warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
251
+ captureWaitMs: sandboxArchiveCaptureTimeoutMs(settings),
250
252
  });
251
253
 
252
254
  if (acquired.role === "blocked") {
@@ -1,5 +1,6 @@
1
1
  import type { Settings } from "@opengeni/config";
2
2
  import {
3
+ adoptLegacyModalCheckpointArtifact,
3
4
  beginSandboxRematerialization,
4
5
  commitWarmingToWarm,
5
6
  failSandboxRematerialization,
@@ -12,9 +13,13 @@ import {
12
13
  type LeaseSnapshot,
13
14
  } from "@opengeni/db";
14
15
  import {
16
+ describeLegacyNativeSnapshotArchive,
15
17
  establishSandboxSessionFromEnvelope,
16
18
  isProviderSandboxNotFoundError,
19
+ modalSessionMatchesCheckpointProviderBinding,
20
+ parseWorkspaceArchiveDescriptor,
17
21
  requirePersistableReplacementSandboxEnvelope,
22
+ resolveModalCheckpointProviderBindingForSession,
18
23
  serializeReplacementSandboxEnvelope,
19
24
  tagModalSandbox,
20
25
  verifySandboxExecReadiness,
@@ -33,6 +38,20 @@ function hasWorkspaceArchive(envelope: Record<string, unknown> | null): boolean
33
38
  );
34
39
  }
35
40
 
41
+ function legacyNativeArchiveFromEnvelope(envelope: Record<string, unknown> | null) {
42
+ const sessionState =
43
+ envelope?.sessionState && typeof envelope.sessionState === "object"
44
+ ? (envelope.sessionState as Record<string, unknown>)
45
+ : null;
46
+ if (!sessionState) return null;
47
+ const existing = parseWorkspaceArchiveDescriptor(sessionState.workspaceArchiveMeta);
48
+ if (existing?.version === 2) return null;
49
+ return describeLegacyNativeSnapshotArchive(
50
+ sessionState.workspaceArchive,
51
+ existing?.version === 1 ? Date.parse(existing.capturedAt) : Date.now(),
52
+ );
53
+ }
54
+
36
55
  function withoutProviderIdentity(
37
56
  envelope: Record<string, unknown> | null,
38
57
  ): Record<string, unknown> | null {
@@ -48,7 +67,9 @@ function withoutProviderIdentity(
48
67
 
49
68
  async function terminateCreated(established: EstablishedSandboxSession | null): Promise<boolean> {
50
69
  if (!established) return true;
51
- const client = established.client as { delete?: (state: unknown) => Promise<unknown> };
70
+ const client = established.client as {
71
+ delete?: (state: unknown) => Promise<unknown>;
72
+ };
52
73
  try {
53
74
  if (typeof client.delete === "function" && established.sessionState !== undefined) {
54
75
  await client.delete(established.sessionState);
@@ -93,20 +114,30 @@ export async function establishApiSandboxSpawner(input: {
93
114
  hasWorkspaceArchive(input.fallbackEnvelope)
94
115
  ? withoutProviderIdentity(input.fallbackEnvelope)
95
116
  : null;
96
- const spawnEnvelope =
117
+ let spawnEnvelope =
97
118
  fallbackArchiveEnvelope ?? input.acquiredLease.resumeState ?? input.fallbackEnvelope;
98
119
  const archiveSource =
99
120
  input.acquiredLease.recovery.archive.status === "none"
100
121
  ? fallbackArchiveEnvelope
101
122
  : input.acquiredLease.resumeState;
102
123
  let established: EstablishedSandboxSession | null = null;
103
- let rematerialization: { id: string; selectedRevision: string } | null = null;
124
+ let rematerialization: {
125
+ id: string;
126
+ selectedRevision: string;
127
+ workspaceGeneration: number;
128
+ providerBindingKey: string | null;
129
+ legacyCheckpoint: ReturnType<typeof legacyNativeArchiveFromEnvelope>;
130
+ legacyProviderBinding: Awaited<
131
+ ReturnType<typeof resolveModalCheckpointProviderBindingForSession>
132
+ > | null;
133
+ } | null = null;
104
134
  try {
105
135
  if (
106
136
  input.acquiredLease.recovery.archive.status === "available" ||
107
137
  hasWorkspaceArchive(archiveSource)
108
138
  ) {
109
139
  const id = crypto.randomUUID();
140
+ const legacyNativeArchive = legacyNativeArchiveFromEnvelope(archiveSource);
110
141
  const begun = await beginSandboxRematerialization(input.db, {
111
142
  accountId: input.accountId,
112
143
  workspaceId: input.workspaceId,
@@ -114,6 +145,7 @@ export async function establishApiSandboxSpawner(input: {
114
145
  expectedEpoch: input.expectedEpoch,
115
146
  rematerializationId: id,
116
147
  archiveSource,
148
+ legacyNativeArchive,
117
149
  });
118
150
  if (begun.status !== "started") {
119
151
  if (begun.code === "stale_epoch" || begun.code === "attempt_conflict") {
@@ -129,6 +161,7 @@ export async function establishApiSandboxSpawner(input: {
129
161
  begun.lease?.recovery ?? input.acquiredLease.recovery,
130
162
  );
131
163
  }
164
+ spawnEnvelope = begun.lease.resumeState ?? spawnEnvelope;
132
165
  const selectedRevision = begun.lease.recovery.restore.selectedRevision;
133
166
  if (!selectedRevision) {
134
167
  throw new WorkspaceArchiveIntegrityError(
@@ -136,7 +169,14 @@ export async function establishApiSandboxSpawner(input: {
136
169
  "sandbox rematerialization selected no durable archive revision",
137
170
  );
138
171
  }
139
- rematerialization = { id, selectedRevision };
172
+ rematerialization = {
173
+ id,
174
+ selectedRevision,
175
+ workspaceGeneration: begun.lease.workspaceGeneration,
176
+ providerBindingKey: begun.checkpointArtifact?.providerBindingKey ?? null,
177
+ legacyCheckpoint: begun.checkpointArtifact === null ? legacyNativeArchive : null,
178
+ legacyProviderBinding: null,
179
+ };
140
180
  } else if (input.acquiredLease.recovery.archive.status !== "none") {
141
181
  throw new SandboxLeaseRecoveryBlockedError(
142
182
  input.sandboxGroupId,
@@ -146,6 +186,7 @@ export async function establishApiSandboxSpawner(input: {
146
186
  );
147
187
  }
148
188
 
189
+ const providerCreateStartedAt = new Date();
149
190
  established = await establishSandboxSessionFromEnvelope(input.settings, spawnEnvelope, {
150
191
  sessionId: input.sessionId,
151
192
  recovery: "create-or-restore",
@@ -153,6 +194,37 @@ export async function establishApiSandboxSpawner(input: {
153
194
  environment: input.environment,
154
195
  onSandboxCreated: async (created) => {
155
196
  established = created;
197
+ if (
198
+ rematerialization &&
199
+ (rematerialization.providerBindingKey || rematerialization.legacyCheckpoint)
200
+ ) {
201
+ if (created.backendId !== "modal") {
202
+ throw new WorkspaceArchiveIntegrityError(
203
+ "native_snapshot_reference_invalid",
204
+ "Modal checkpoint restore resolved a non-Modal sandbox backend",
205
+ );
206
+ }
207
+ if (rematerialization.providerBindingKey) {
208
+ if (
209
+ !(await modalSessionMatchesCheckpointProviderBinding(
210
+ input.settings,
211
+ created.session,
212
+ rematerialization.providerBindingKey,
213
+ ))
214
+ ) {
215
+ throw new WorkspaceArchiveIntegrityError(
216
+ "native_snapshot_reference_invalid",
217
+ "Modal checkpoint restore refused because the authenticated provider workspace changed",
218
+ );
219
+ }
220
+ } else {
221
+ rematerialization.legacyProviderBinding =
222
+ await resolveModalCheckpointProviderBindingForSession(
223
+ input.settings,
224
+ created.session,
225
+ );
226
+ }
227
+ }
156
228
  const resumeState = requirePersistableReplacementSandboxEnvelope(
157
229
  await serializeReplacementSandboxEnvelope(created, spawnEnvelope),
158
230
  created.backendId,
@@ -166,6 +238,14 @@ export async function establishApiSandboxSpawner(input: {
166
238
  instanceId: created.instanceId,
167
239
  resumeBackendId: created.backendId,
168
240
  resumeState,
241
+ ...(created.backendId === "modal"
242
+ ? {
243
+ providerCreatedAt: providerCreateStartedAt,
244
+ providerDeadlineAt: new Date(
245
+ providerCreateStartedAt.getTime() + input.settings.modalTimeoutSeconds * 1000,
246
+ ),
247
+ }
248
+ : {}),
169
249
  leaseTtlMs: input.settings.sandboxLeaseTtlMs,
170
250
  warmingLeaseTtlMs: input.settings.sandboxWarmingTimeoutMs,
171
251
  });
@@ -187,6 +267,33 @@ export async function establishApiSandboxSpawner(input: {
187
267
  `hydrated archive revision ${descriptor.revision} does not match the selected rematerialization revision`,
188
268
  );
189
269
  }
270
+ if (rematerialization.legacyCheckpoint) {
271
+ const binding = rematerialization.legacyProviderBinding;
272
+ if (!binding) {
273
+ throw new WorkspaceArchiveIntegrityError(
274
+ "native_snapshot_reference_invalid",
275
+ "Legacy Modal checkpoint restore produced no authenticated provider identity",
276
+ );
277
+ }
278
+ const adopted = await adoptLegacyModalCheckpointArtifact(input.db, {
279
+ accountId: input.accountId,
280
+ workspaceId: input.workspaceId,
281
+ sandboxGroupId: input.sandboxGroupId,
282
+ leaseId: input.acquiredLease.id,
283
+ leaseEpoch: input.expectedEpoch,
284
+ workspaceGeneration: rematerialization.workspaceGeneration,
285
+ slot: "current",
286
+ archiveBase64: rematerialization.legacyCheckpoint.archiveBase64,
287
+ descriptor: rematerialization.legacyCheckpoint.descriptor,
288
+ providerBindingKey: binding.key,
289
+ providerBinding: binding.binding,
290
+ rematerializationId: rematerialization.id,
291
+ });
292
+ if (!adopted) {
293
+ throw new SandboxLeaseSupersededError(input.sandboxGroupId, input.expectedEpoch);
294
+ }
295
+ rematerialization.providerBindingKey = binding.key;
296
+ }
190
297
  const verifying = await markSandboxRestoreVerifying(input.db, {
191
298
  accountId: input.accountId,
192
299
  workspaceId: input.workspaceId,
@@ -24,6 +24,7 @@ import {
24
24
  hasGitCredentialRepositorySelection,
25
25
  hasGitHubRepositorySelection,
26
26
  resolveStreamTokenSecret,
27
+ sandboxArchiveCaptureTimeoutMs,
27
28
  stableSandboxEnvironmentForRun,
28
29
  } from "@opengeni/config";
29
30
  import type { Settings } from "@opengeni/config";
@@ -42,6 +43,7 @@ import {
42
43
  recordLeaseTerminalDataPlaneUrl,
43
44
  releaseLeaseHolder,
44
45
  SandboxLeaseSupersededError,
46
+ SandboxViewerAdmissionBlockedError,
45
47
  type Database,
46
48
  type LeaseSnapshot,
47
49
  type SandboxRecord,
@@ -195,18 +197,33 @@ export async function attachViewer(
195
197
  });
196
198
  };
197
199
 
198
- const acquired = await acquireLease(db, {
199
- accountId,
200
- workspaceId,
201
- sandboxGroupId,
202
- kind: "viewer",
203
- holderId: viewerId,
204
- subjectId: session.id,
205
- backend: session.sandboxBackend,
206
- os: session.sandboxOs,
207
- leaseTtlMs,
208
- warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
209
- });
200
+ let acquired: Awaited<ReturnType<typeof acquireLease>>;
201
+ try {
202
+ acquired = await acquireLease(db, {
203
+ accountId,
204
+ workspaceId,
205
+ sandboxGroupId,
206
+ kind: "viewer",
207
+ holderId: viewerId,
208
+ subjectId: session.id,
209
+ backend: session.sandboxBackend,
210
+ os: session.sandboxOs,
211
+ leaseTtlMs,
212
+ warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
213
+ captureWaitMs: sandboxArchiveCaptureTimeoutMs(settings),
214
+ });
215
+ } catch (error) {
216
+ if (error instanceof SandboxViewerAdmissionBlockedError) {
217
+ throw new HTTPException(error.reason === "balance" ? 402 : 429, {
218
+ message:
219
+ error.reason === "balance"
220
+ ? "insufficient OpenGeni credits for an idle sandbox viewer"
221
+ : "workspace sandbox warm allowance exhausted",
222
+ cause: error,
223
+ });
224
+ }
225
+ throw error;
226
+ }
210
227
 
211
228
  // FENCED: a newer epoch re-established the box. Release our just-registered
212
229
  // holder and surface a 409 — the client re-reads capabilities and re-attaches.
@@ -0,0 +1,45 @@
1
+ import type { TranscriptionProvider } from "@opengeni/core";
2
+ import { fetchError, responseError } from "./openai";
3
+
4
+ export function createAzureOpenAiTranscriptionProvider(input: {
5
+ endpoint: string;
6
+ deployment: string;
7
+ apiVersion: string;
8
+ apiKey: string | null;
9
+ adToken: string | null;
10
+ fetch?: typeof fetch;
11
+ }): TranscriptionProvider {
12
+ const fetchImpl = input.fetch ?? fetch;
13
+ const url = `${input.endpoint}/openai/deployments/${encodeURIComponent(input.deployment)}/audio/transcriptions?api-version=${encodeURIComponent(input.apiVersion)}`;
14
+ return {
15
+ id: "azure-openai",
16
+ available: () => Boolean(input.apiKey || input.adToken),
17
+ async transcribe({ audio, mimeType, filename, signal }) {
18
+ const form = new FormData();
19
+ form.append("file", new Blob([Uint8Array.from(audio).buffer], { type: mimeType }), filename);
20
+ const headers: Record<string, string> = input.apiKey
21
+ ? { "api-key": input.apiKey }
22
+ : { Authorization: `Bearer ${input.adToken}` };
23
+ let response: Response;
24
+ try {
25
+ response = await fetchImpl(url, {
26
+ method: "POST",
27
+ headers,
28
+ body: form,
29
+ ...(signal ? { signal } : {}),
30
+ });
31
+ } catch (error) {
32
+ throw fetchError(error);
33
+ }
34
+ if (!response.ok) throw responseError(response.status);
35
+ const body = await response.json().catch(() => null);
36
+ if (!body || typeof body.text !== "string") {
37
+ throw responseError(502);
38
+ }
39
+ return {
40
+ text: body.text,
41
+ languages: typeof body.language === "string" && body.language ? [body.language] : [],
42
+ };
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,100 @@
1
+ import { CODEX_CLIENT_VERSION, CODEX_ORIGINATOR } from "@opengeni/codex/constants";
2
+ import type { Settings } from "@opengeni/config";
3
+ import {
4
+ type TranscriptionAvailabilityContext,
5
+ type TranscriptionProvider,
6
+ TranscriptionServiceError,
7
+ } from "@opengeni/core";
8
+ import { buildCodexTokenResolver, type Database, listCodexAccountStatuses } from "@opengeni/db";
9
+ import { fetchError, responseError } from "./openai";
10
+
11
+ const TRANSCRIBE_URL = "https://chatgpt.com/backend-api/transcribe";
12
+
13
+ async function workspaceHasActiveCodexAccount(db: Database, workspaceId: string): Promise<boolean> {
14
+ const account = (await listCodexAccountStatuses(db, workspaceId)).find(
15
+ (candidate) => candidate.isActive && candidate.status === "active",
16
+ );
17
+ return account != null;
18
+ }
19
+
20
+ export function createCodexSubscriptionTranscriptionProvider(input: {
21
+ settings: Settings;
22
+ db: Database;
23
+ fetch?: typeof fetch;
24
+ probe?: (context?: TranscriptionAvailabilityContext) => boolean | Promise<boolean>;
25
+ }): TranscriptionProvider {
26
+ const fetchImpl = input.fetch ?? fetch;
27
+ const probe =
28
+ input.probe ??
29
+ (async (context?: TranscriptionAvailabilityContext) => {
30
+ // Deployment-level readiness: registry construction already gated this
31
+ // provider on OPENGENI_CODEX_SUBSCRIPTION_ENABLED. Request selection
32
+ // passes workspaceId so we only claim Codex when a subscription is
33
+ // attached; otherwise OpenAI/Azure remain eligible.
34
+ if (!context?.workspaceId) return true;
35
+ return await workspaceHasActiveCodexAccount(input.db, context.workspaceId);
36
+ });
37
+ return {
38
+ id: "codex-subscription",
39
+ experimental: true,
40
+ available: probe,
41
+ async transcribe({ audio, mimeType, filename, workspaceId, signal }) {
42
+ const account = (await listCodexAccountStatuses(input.db, workspaceId)).find(
43
+ (candidate) => candidate.isActive && candidate.status === "active",
44
+ );
45
+ if (!account) {
46
+ throw new TranscriptionServiceError({
47
+ code: "unavailable",
48
+ message: "Transcription is unavailable.",
49
+ });
50
+ }
51
+ const resolver = buildCodexTokenResolver(input.db, input.settings, workspaceId, account.id);
52
+ let token: Awaited<ReturnType<typeof resolver.getToken>>;
53
+ try {
54
+ token = await resolver.getToken();
55
+ } catch {
56
+ throw new TranscriptionServiceError({
57
+ code: "unavailable",
58
+ message: "Transcription is unavailable.",
59
+ });
60
+ }
61
+ const request = async (accessToken: string, accountId: string | null) => {
62
+ const form = new FormData();
63
+ form.append(
64
+ "file",
65
+ new Blob([Uint8Array.from(audio).buffer], { type: mimeType }),
66
+ filename,
67
+ );
68
+ return await fetchImpl(TRANSCRIBE_URL, {
69
+ method: "POST",
70
+ headers: {
71
+ Authorization: `Bearer ${accessToken}`,
72
+ ...(accountId ? { "ChatGPT-Account-ID": accountId } : {}),
73
+ originator: CODEX_ORIGINATOR,
74
+ "User-Agent": `${CODEX_ORIGINATOR}/${CODEX_CLIENT_VERSION}`,
75
+ version: CODEX_CLIENT_VERSION,
76
+ },
77
+ body: form,
78
+ ...(signal ? { signal } : {}),
79
+ });
80
+ };
81
+ let response: Response;
82
+ try {
83
+ response = await request(token.accessToken, token.chatgptAccountId);
84
+ if (response.status === 401) {
85
+ token = await resolver.refresh();
86
+ response = await request(token.accessToken, token.chatgptAccountId);
87
+ }
88
+ } catch (error) {
89
+ throw fetchError(error);
90
+ }
91
+ if (!response.ok) throw responseError(response.status);
92
+ const body = await response.json().catch(() => null);
93
+ if (!body || typeof body.text !== "string") throw responseError(502);
94
+ return {
95
+ text: body.text,
96
+ languages: typeof body.language === "string" && body.language ? [body.language] : [],
97
+ };
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,93 @@
1
+ import { type TranscriptionProvider, TranscriptionServiceError } from "@opengeni/core";
2
+
3
+ export function createOpenAiTranscriptionProvider(input: {
4
+ apiKey: string;
5
+ baseUrl: string;
6
+ model: string;
7
+ fetch?: typeof fetch;
8
+ }): TranscriptionProvider {
9
+ const fetchImpl = input.fetch ?? fetch;
10
+ return {
11
+ id: "openai",
12
+ available: () => true,
13
+ async transcribe({ audio, mimeType, filename, signal }) {
14
+ const form = new FormData();
15
+ form.append("file", audioBlob(audio, mimeType), filename);
16
+ form.append("model", input.model);
17
+ let response: Response;
18
+ try {
19
+ response = await fetchImpl(`${input.baseUrl}/audio/transcriptions`, {
20
+ method: "POST",
21
+ headers: { Authorization: `Bearer ${input.apiKey}` },
22
+ body: form,
23
+ ...(signal ? { signal } : {}),
24
+ });
25
+ } catch (error) {
26
+ throw fetchError(error);
27
+ }
28
+ if (!response.ok) throw responseError(response.status);
29
+ const body = await response.json().catch(() => null);
30
+ if (!body || typeof body.text !== "string") {
31
+ throw new TranscriptionServiceError({
32
+ code: "provider",
33
+ message: "Invalid transcription response.",
34
+ });
35
+ }
36
+ return {
37
+ text: body.text,
38
+ languages: typeof body.language === "string" && body.language ? [body.language] : [],
39
+ };
40
+ },
41
+ };
42
+ }
43
+
44
+ function audioBlob(audio: Uint8Array, mimeType: string): Blob {
45
+ return new Blob([Uint8Array.from(audio).buffer], { type: mimeType });
46
+ }
47
+
48
+ export function responseError(status: number): TranscriptionServiceError {
49
+ if (status === 401 || status === 403) {
50
+ return new TranscriptionServiceError({
51
+ code: "unavailable",
52
+ message: "Transcription is unavailable.",
53
+ });
54
+ }
55
+ if (status === 413) {
56
+ return new TranscriptionServiceError({
57
+ code: "too_large",
58
+ message: "Audio is too large.",
59
+ });
60
+ }
61
+ if (status === 400 || status === 422) {
62
+ return new TranscriptionServiceError({
63
+ code: "invalid_audio",
64
+ message: "Audio could not be transcribed.",
65
+ });
66
+ }
67
+ if (status === 408 || status === 504) {
68
+ return new TranscriptionServiceError({
69
+ code: "timeout",
70
+ message: "Transcription timed out.",
71
+ retryable: true,
72
+ });
73
+ }
74
+ return new TranscriptionServiceError({
75
+ code: status >= 500 ? "unavailable" : "provider",
76
+ message: "Transcription provider failed.",
77
+ retryable: status >= 500,
78
+ });
79
+ }
80
+
81
+ export function fetchError(error: unknown): TranscriptionServiceError {
82
+ if (error instanceof DOMException && error.name === "AbortError") {
83
+ return new TranscriptionServiceError({
84
+ code: "cancelled",
85
+ message: "Transcription was cancelled.",
86
+ });
87
+ }
88
+ return new TranscriptionServiceError({
89
+ code: "network",
90
+ message: "Transcription provider is unreachable.",
91
+ retryable: true,
92
+ });
93
+ }