@opengeni/api-router 2.6.4 → 2.8.1-canary.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 (43) hide show
  1. package/dist/app.d.ts +2 -1
  2. package/dist/app.js +3 -1
  3. package/dist/{chunk-XTLI3CBH.js → chunk-HHKQMVY7.js} +3009 -1699
  4. package/dist/chunk-HHKQMVY7.js.map +1 -0
  5. package/dist/github-access.d.ts +25 -2
  6. package/dist/index.js +14 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/integrations/personal-github-repositories.d.ts +41 -2
  9. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  10. package/dist/model-catalog.d.ts +5 -31
  11. package/dist/routes/codex.d.ts +8 -1
  12. package/dist/routes/github.d.ts +2 -0
  13. package/dist/routes/organization-model-providers.d.ts +3 -0
  14. package/dist/workspace-deletion.d.ts +6 -0
  15. package/package.json +18 -18
  16. package/src/app.ts +55 -9
  17. package/src/auth/organization-user-setup.ts +3 -3
  18. package/src/github-access.ts +117 -1
  19. package/src/http/sse.ts +15 -7
  20. package/src/index.ts +13 -1
  21. package/src/integrations/personal-github-repositories.ts +238 -9
  22. package/src/integrations/slack-app-home.ts +2 -2
  23. package/src/integrations/slack-interactions.ts +77 -37
  24. package/src/mcp/company-brain-governed-writes.ts +2 -2
  25. package/src/mcp/company-profile-agent-admin.ts +1 -1
  26. package/src/mcp/remember.ts +1 -1
  27. package/src/mcp/server.ts +11 -2
  28. package/src/model-catalog.ts +45 -337
  29. package/src/routes/automations.ts +178 -19
  30. package/src/routes/codex.ts +136 -32
  31. package/src/routes/connections.ts +271 -16
  32. package/src/routes/github.ts +116 -15
  33. package/src/routes/organization-memberships.ts +50 -3
  34. package/src/routes/organization-model-providers.ts +231 -0
  35. package/src/routes/packs.ts +1 -0
  36. package/src/routes/personal-github.ts +39 -0
  37. package/src/routes/pr-review.ts +72 -4
  38. package/src/routes/scheduled-tasks.ts +40 -13
  39. package/src/routes/sessions.ts +88 -52
  40. package/src/routes/supergrok.ts +3 -2
  41. package/src/routes/workspaces.ts +405 -62
  42. package/src/workspace-deletion.ts +64 -0
  43. package/dist/chunk-XTLI3CBH.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import {
2
3
  AddWorkspaceMemberRequest,
3
4
  CreateWorkspaceRequest,
@@ -7,16 +8,25 @@ import {
7
8
  ListWorkspaceMembersResponse,
8
9
  SetWorkspaceDefaultRigRequest,
9
10
  UpdateWorkspaceMemberRequest,
11
+ CreateWorkspaceGatewayCustomModelRequest,
12
+ CreateWorkspaceOpenRouterCustomModelRequest,
13
+ DeleteWorkspaceGatewayCustomModelRequest,
14
+ DeleteWorkspaceOpenRouterCustomModelRequest,
10
15
  UpdateWorkspaceModelPolicyRequest,
11
16
  UpdateWorkspaceRequest,
12
17
  UpdateWorkspaceSettingsRequest,
13
18
  WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
14
19
  WorkspaceModelCatalogResponse,
20
+ WorkspaceGatewayCustomModel,
21
+ WorkspaceGatewayCustomModelsResponse,
22
+ WorkspaceOpenRouterCustomModel,
23
+ WorkspaceOpenRouterCustomModelsResponse,
15
24
  WorkspaceRealtimeModelCatalogResponse,
16
25
  WorkspaceInferenceControlRequest,
17
26
  Workspace,
18
27
  WorkspaceMember,
19
28
  workspaceControlUtf8Bytes,
29
+ stableJson,
20
30
  type AccessContext,
21
31
  type Permission,
22
32
  type WorkspaceMemberCandidate,
@@ -25,7 +35,6 @@ import {
25
35
  import {
26
36
  allWorkspacePermissions,
27
37
  createWorkspace,
28
- deleteWorkspaceIfQuiescent,
29
38
  ensureWorkspaceByExternalIdentity,
30
39
  findWorkspaceByExternalIdentity,
31
40
  getManagedUserProfilesByIds,
@@ -36,6 +45,14 @@ import {
36
45
  normalizeWorkspaceMembershipPermissions,
37
46
  listWorkspaceControlEvents,
38
47
  listSharedWorkspacesForAccount,
48
+ listWorkspaceGatewayCustomModels,
49
+ listWorkspaceOpenRouterCustomModels,
50
+ createWorkspaceGatewayCustomModel,
51
+ createWorkspaceOpenRouterCustomModel,
52
+ deleteWorkspaceGatewayCustomModel,
53
+ deleteWorkspaceOpenRouterCustomModel,
54
+ replayWorkspaceGatewayCustomModelCreate,
55
+ replayWorkspaceOpenRouterCustomModelCreate,
39
56
  listWorkspacesForSubject,
40
57
  nestedPostgresSqlState,
41
58
  removeWorkspaceMember,
@@ -50,7 +67,14 @@ import {
50
67
  workspaceControlRequestLockTimeoutMs,
51
68
  workspaceXaiSubscriptionActive,
52
69
  workspaceVercelAiGatewayConnectionActive,
70
+ workspaceOpenRouterConnectionActive,
71
+ organizationModelProviderConnectionActiveForWorkspace,
72
+ listOrganizationModelProviderCustomModelsForWorkspace,
73
+ WorkspaceGatewayCustomModelHistoryLimitError,
74
+ WorkspaceOpenRouterCustomModelHistoryLimitError,
53
75
  WorkspaceExternalIdentityConflictError,
76
+ WorkspaceGatewayCustomModelLimitError,
77
+ WorkspaceOpenRouterCustomModelLimitError,
54
78
  WorkspaceLimitExceededError,
55
79
  } from "@opengeni/db";
56
80
  import { boundWorkspaceControlHttpPage } from "@opengeni/events";
@@ -63,6 +87,7 @@ import {
63
87
  requireAccessContext,
64
88
  requireAccessGrant,
65
89
  requireFreshAccessGrant,
90
+ resolveWorkspaceCatalogSettings,
66
91
  } from "@opengeni/core";
67
92
  import { requireLimit } from "@opengeni/core";
68
93
  import type { ApiRouteDeps } from "@opengeni/core";
@@ -75,14 +100,20 @@ import { boundedLimit } from "../http/common";
75
100
  import { ApiHttpError } from "../http/api-error";
76
101
  import { browserSseDeliveryOptions, sseWorkspaceControlStream } from "../http/sse";
77
102
  import { buildWorkspaceModelCatalog } from "../model-catalog";
78
- import { processTemporalScheduleCleanupClaims } from "../temporal-schedule-cleanup";
79
- import { workspaceDeleteObserver } from "../workspace-delete-observability";
103
+ import { deleteWorkspaceForRequest } from "../workspace-deletion";
80
104
  import {
81
105
  AI_GATEWAY_REALTIME_MODELS,
82
106
  CODEX_REALTIME_MODEL_ID,
83
107
  SUPERGROK_REALTIME_MODEL_ID,
84
108
  canonicalizeConfiguredModelId,
85
109
  configuredStaticUsageLimits,
110
+ configuredGatewayUpstreamModelIds,
111
+ configuredGatewayWorkspaceProductModelIds,
112
+ configuredModelInputIdentities,
113
+ configuredOpenRouterUpstreamModelIds,
114
+ configuredOpenRouterWorkspaceProductModelIds,
115
+ WORKSPACE_GATEWAY_MODEL_ID_PREFIX,
116
+ WORKSPACE_OPENROUTER_MODEL_ID_PREFIX,
86
117
  type Settings,
87
118
  } from "@opengeni/config";
88
119
 
@@ -96,6 +127,46 @@ export function canonicalWorkspacePolicyModelIds(
96
127
  return [...new Set(modelIds.map((modelId) => canonicalizeConfiguredModelId(settings, modelId)))];
97
128
  }
98
129
 
130
+ function projectWorkspaceGatewayCustomModel(model: {
131
+ id: string;
132
+ upstreamModelId: string;
133
+ label: string | null;
134
+ version: number;
135
+ createdAt: Date;
136
+ updatedAt: Date;
137
+ }) {
138
+ return WorkspaceGatewayCustomModel.parse({
139
+ id: model.id,
140
+ upstreamModelId: model.upstreamModelId,
141
+ label: model.label,
142
+ version: model.version,
143
+ createdAt: model.createdAt.toISOString(),
144
+ updatedAt: model.updatedAt.toISOString(),
145
+ });
146
+ }
147
+
148
+ function projectWorkspaceOpenRouterCustomModel(model: {
149
+ id: string;
150
+ upstreamModelId: string;
151
+ label: string | null;
152
+ version: number;
153
+ createdAt: Date;
154
+ updatedAt: Date;
155
+ }) {
156
+ return WorkspaceOpenRouterCustomModel.parse({
157
+ id: model.id,
158
+ upstreamModelId: model.upstreamModelId,
159
+ label: model.label,
160
+ version: model.version,
161
+ createdAt: model.createdAt.toISOString(),
162
+ updatedAt: model.updatedAt.toISOString(),
163
+ });
164
+ }
165
+
166
+ function workspaceCustomModelRequestHash(value: unknown): string {
167
+ return createHash("sha256").update(stableJson(value), "utf8").digest("hex");
168
+ }
169
+
99
170
  type WorkspaceMemberProjectionInput = Omit<WorkspaceMemberValue, "permissions"> & {
100
171
  permissions: unknown;
101
172
  };
@@ -192,7 +263,9 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
192
263
  name: payload.name,
193
264
  slug: payload.slug ?? null,
194
265
  ...(payload.agentInstructions !== undefined
195
- ? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) }
266
+ ? {
267
+ agentInstructions: normalizeAgentInstructions(payload.agentInstructions),
268
+ }
196
269
  : {}),
197
270
  maxWorkspacesPerAccount: workspaceLimit(deps),
198
271
  });
@@ -317,30 +390,351 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
317
390
  const workspaceId = c.req.param("workspaceId");
318
391
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
319
392
  const [
393
+ resolvedCatalog,
320
394
  policy,
321
395
  codexSubscriptionActive,
322
396
  xaiSubscriptionActive,
323
397
  workspaceGatewayConnectionActive,
398
+ workspaceGatewayCustomModels,
399
+ openRouterConnectionActive,
400
+ workspaceOpenRouterCustomModels,
401
+ organizationGatewayConnectionActive,
402
+ organizationOpenRouterConnectionActive,
403
+ organizationGatewayCustomModels,
404
+ organizationOpenRouterCustomModels,
324
405
  ] = await Promise.all([
406
+ deps.resolveCatalogSettings(),
325
407
  getWorkspaceModelPolicy(deps.db, workspaceId),
326
408
  workspaceCodexSubscriptionActive(deps.db, deps.settings, workspaceId),
327
409
  workspaceXaiSubscriptionActive(deps.db, deps.settings, workspaceId, grant.subjectId),
328
410
  workspaceVercelAiGatewayConnectionActive(deps.db, workspaceId),
411
+ listWorkspaceGatewayCustomModels(deps.db, {
412
+ accountId: grant.accountId,
413
+ workspaceId,
414
+ }),
415
+ workspaceOpenRouterConnectionActive(deps.db, workspaceId),
416
+ listWorkspaceOpenRouterCustomModels(deps.db, {
417
+ accountId: grant.accountId,
418
+ workspaceId,
419
+ }),
420
+ organizationModelProviderConnectionActiveForWorkspace(deps.db, {
421
+ accountId: grant.accountId,
422
+ workspaceId,
423
+ providerKind: "vercel_gateway",
424
+ }),
425
+ organizationModelProviderConnectionActiveForWorkspace(deps.db, {
426
+ accountId: grant.accountId,
427
+ workspaceId,
428
+ providerKind: "openrouter",
429
+ }),
430
+ listOrganizationModelProviderCustomModelsForWorkspace(deps.db, {
431
+ accountId: grant.accountId,
432
+ workspaceId,
433
+ providerKind: "vercel_gateway",
434
+ }),
435
+ listOrganizationModelProviderCustomModelsForWorkspace(deps.db, {
436
+ accountId: grant.accountId,
437
+ workspaceId,
438
+ providerKind: "openrouter",
439
+ }),
329
440
  ]);
330
441
  c.header("cache-control", "private, no-store");
331
442
  return c.json(
332
443
  WorkspaceModelCatalogResponse.parse(
333
444
  buildWorkspaceModelCatalog({
334
- settings: deps.settings,
445
+ settings: resolvedCatalog.settings,
335
446
  policy,
336
447
  codexSubscriptionActive,
337
448
  xaiSubscriptionActive,
338
449
  workspaceGatewayConnectionActive,
450
+ workspaceGatewayCustomModels,
451
+ workspaceOpenRouterConnectionActive: openRouterConnectionActive,
452
+ workspaceOpenRouterCustomModels,
453
+ organizationGatewayConnectionActive,
454
+ organizationOpenRouterConnectionActive,
455
+ organizationGatewayCustomModels,
456
+ organizationOpenRouterCustomModels,
339
457
  }),
340
458
  ),
341
459
  );
342
460
  });
343
461
 
462
+ app.get("/v1/workspaces/:workspaceId/gateway-custom-models", async (c) => {
463
+ const workspaceId = c.req.param("workspaceId");
464
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
465
+ const models = await listWorkspaceGatewayCustomModels(deps.db, {
466
+ accountId: grant.accountId,
467
+ workspaceId,
468
+ });
469
+ c.header("cache-control", "private, no-store");
470
+ return c.json(
471
+ WorkspaceGatewayCustomModelsResponse.parse({
472
+ models: models.map(projectWorkspaceGatewayCustomModel),
473
+ }),
474
+ );
475
+ });
476
+
477
+ app.post("/v1/workspaces/:workspaceId/gateway-custom-models", async (c) => {
478
+ const workspaceId = c.req.param("workspaceId");
479
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
480
+ const parsed = CreateWorkspaceGatewayCustomModelRequest.safeParse(
481
+ await c.req.json().catch(() => null),
482
+ );
483
+ if (!parsed.success) {
484
+ throw new HTTPException(422, { message: "invalid Gateway custom model" });
485
+ }
486
+ const requestHash = workspaceCustomModelRequestHash({
487
+ action: "create",
488
+ upstreamModelId: parsed.data.upstreamModelId,
489
+ label: parsed.data.label ?? null,
490
+ });
491
+ const replay = await replayWorkspaceGatewayCustomModelCreate(deps.db, {
492
+ accountId: grant.accountId,
493
+ workspaceId,
494
+ operationId: parsed.data.operationId,
495
+ requestHash,
496
+ });
497
+ if (replay.outcome === "conflict") {
498
+ throw new HTTPException(409, {
499
+ message: "Gateway custom model operation conflicts with current state",
500
+ });
501
+ }
502
+ if (replay.outcome === "success") {
503
+ return c.json(projectWorkspaceGatewayCustomModel(replay.model), 201);
504
+ }
505
+ const catalog = await deps.resolveCatalogSettings();
506
+ if (configuredGatewayUpstreamModelIds(catalog.settings).includes(parsed.data.upstreamModelId)) {
507
+ throw new HTTPException(422, {
508
+ message: "Gateway model is already included in the deployment catalog",
509
+ });
510
+ }
511
+ const customProductId = `${WORKSPACE_GATEWAY_MODEL_ID_PREFIX}${parsed.data.upstreamModelId}`;
512
+ const deploymentProductIds = new Set([
513
+ ...configuredModelInputIdentities(catalog.settings),
514
+ ...configuredGatewayWorkspaceProductModelIds(catalog.settings),
515
+ ]);
516
+ if (deploymentProductIds.has(customProductId)) {
517
+ throw new HTTPException(422, {
518
+ message: "Gateway model product id conflicts with the deployment catalog",
519
+ });
520
+ }
521
+ try {
522
+ const model = await createWorkspaceGatewayCustomModel(deps.db, {
523
+ accountId: grant.accountId,
524
+ workspaceId,
525
+ upstreamModelId: parsed.data.upstreamModelId,
526
+ label: parsed.data.label ?? null,
527
+ operationId: parsed.data.operationId,
528
+ requestHash,
529
+ createdBySubjectId: grant.subjectId,
530
+ });
531
+ if (!model || model.retiredAt) {
532
+ throw new HTTPException(409, {
533
+ message: "Gateway custom model operation conflicts with current state",
534
+ });
535
+ }
536
+ return c.json(projectWorkspaceGatewayCustomModel(model), 201);
537
+ } catch (error) {
538
+ if (
539
+ error instanceof WorkspaceGatewayCustomModelLimitError ||
540
+ error instanceof WorkspaceGatewayCustomModelHistoryLimitError
541
+ ) {
542
+ throw new HTTPException(422, { message: error.message });
543
+ }
544
+ if (nestedPostgresSqlState(error) === "23505") {
545
+ throw new HTTPException(422, {
546
+ message: "Gateway custom model already exists",
547
+ });
548
+ }
549
+ throw error;
550
+ }
551
+ });
552
+
553
+ app.delete("/v1/workspaces/:workspaceId/gateway-custom-models/:customModelId", async (c) => {
554
+ const workspaceId = c.req.param("workspaceId");
555
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
556
+ const customModelId = c.req.param("customModelId");
557
+ if (
558
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
559
+ customModelId,
560
+ )
561
+ ) {
562
+ throw new HTTPException(422, {
563
+ message: "invalid Gateway custom model id",
564
+ });
565
+ }
566
+ const parsed = DeleteWorkspaceGatewayCustomModelRequest.safeParse(
567
+ await c.req.json().catch(() => null),
568
+ );
569
+ if (!parsed.success) {
570
+ throw new HTTPException(422, {
571
+ message: "invalid Gateway custom model deletion",
572
+ });
573
+ }
574
+ const removed = await deleteWorkspaceGatewayCustomModel(deps.db, {
575
+ accountId: grant.accountId,
576
+ workspaceId,
577
+ customModelId,
578
+ expectedVersion: parsed.data.expectedVersion,
579
+ operationId: parsed.data.operationId,
580
+ requestHash: workspaceCustomModelRequestHash({
581
+ action: "delete",
582
+ customModelId,
583
+ expectedVersion: parsed.data.expectedVersion,
584
+ }),
585
+ });
586
+ if (removed.outcome === "not_found") {
587
+ throw new HTTPException(404, {
588
+ message: "Gateway custom model not found",
589
+ });
590
+ }
591
+ if (removed.outcome === "conflict") {
592
+ throw new HTTPException(409, {
593
+ message: "Gateway custom model changed; reload and retry",
594
+ });
595
+ }
596
+ return c.body(null, 204);
597
+ });
598
+
599
+ app.get("/v1/workspaces/:workspaceId/openrouter-custom-models", async (c) => {
600
+ const workspaceId = c.req.param("workspaceId");
601
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
602
+ const models = await listWorkspaceOpenRouterCustomModels(deps.db, {
603
+ accountId: grant.accountId,
604
+ workspaceId,
605
+ });
606
+ c.header("cache-control", "private, no-store");
607
+ return c.json(
608
+ WorkspaceOpenRouterCustomModelsResponse.parse({
609
+ models: models.map(projectWorkspaceOpenRouterCustomModel),
610
+ }),
611
+ );
612
+ });
613
+
614
+ app.post("/v1/workspaces/:workspaceId/openrouter-custom-models", async (c) => {
615
+ const workspaceId = c.req.param("workspaceId");
616
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
617
+ const parsed = CreateWorkspaceOpenRouterCustomModelRequest.safeParse(
618
+ await c.req.json().catch(() => null),
619
+ );
620
+ if (!parsed.success) {
621
+ throw new HTTPException(422, { message: "invalid OpenRouter custom model" });
622
+ }
623
+ const requestHash = workspaceCustomModelRequestHash({
624
+ action: "create",
625
+ upstreamModelId: parsed.data.upstreamModelId,
626
+ label: parsed.data.label ?? null,
627
+ });
628
+ const replay = await replayWorkspaceOpenRouterCustomModelCreate(deps.db, {
629
+ accountId: grant.accountId,
630
+ workspaceId,
631
+ operationId: parsed.data.operationId,
632
+ requestHash,
633
+ });
634
+ if (replay.outcome === "conflict") {
635
+ throw new HTTPException(409, {
636
+ message: "OpenRouter custom model operation conflicts with current state",
637
+ });
638
+ }
639
+ if (replay.outcome === "success") {
640
+ return c.json(projectWorkspaceOpenRouterCustomModel(replay.model), 201);
641
+ }
642
+ const catalog = await deps.resolveCatalogSettings();
643
+ if (
644
+ configuredOpenRouterUpstreamModelIds(catalog.settings).includes(parsed.data.upstreamModelId)
645
+ ) {
646
+ throw new HTTPException(422, {
647
+ message: "OpenRouter model is already included in the deployment catalog",
648
+ });
649
+ }
650
+ const customProductId = `${WORKSPACE_OPENROUTER_MODEL_ID_PREFIX}${parsed.data.upstreamModelId}`;
651
+ const deploymentProductIds = new Set([
652
+ ...configuredModelInputIdentities(catalog.settings),
653
+ ...configuredOpenRouterWorkspaceProductModelIds(catalog.settings),
654
+ ]);
655
+ if (deploymentProductIds.has(customProductId)) {
656
+ throw new HTTPException(422, {
657
+ message: "OpenRouter model product id conflicts with the deployment catalog",
658
+ });
659
+ }
660
+ try {
661
+ const model = await createWorkspaceOpenRouterCustomModel(deps.db, {
662
+ accountId: grant.accountId,
663
+ workspaceId,
664
+ upstreamModelId: parsed.data.upstreamModelId,
665
+ label: parsed.data.label ?? null,
666
+ operationId: parsed.data.operationId,
667
+ requestHash,
668
+ createdBySubjectId: grant.subjectId,
669
+ });
670
+ if (!model || model.retiredAt) {
671
+ throw new HTTPException(409, {
672
+ message: "OpenRouter custom model operation conflicts with current state",
673
+ });
674
+ }
675
+ return c.json(projectWorkspaceOpenRouterCustomModel(model), 201);
676
+ } catch (error) {
677
+ if (
678
+ error instanceof WorkspaceOpenRouterCustomModelLimitError ||
679
+ error instanceof WorkspaceOpenRouterCustomModelHistoryLimitError
680
+ ) {
681
+ throw new HTTPException(422, { message: error.message });
682
+ }
683
+ if (nestedPostgresSqlState(error) === "23505") {
684
+ throw new HTTPException(422, {
685
+ message: "OpenRouter custom model already exists",
686
+ });
687
+ }
688
+ throw error;
689
+ }
690
+ });
691
+
692
+ app.delete("/v1/workspaces/:workspaceId/openrouter-custom-models/:customModelId", async (c) => {
693
+ const workspaceId = c.req.param("workspaceId");
694
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
695
+ const customModelId = c.req.param("customModelId");
696
+ if (
697
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
698
+ customModelId,
699
+ )
700
+ ) {
701
+ throw new HTTPException(422, {
702
+ message: "invalid OpenRouter custom model id",
703
+ });
704
+ }
705
+ const parsed = DeleteWorkspaceOpenRouterCustomModelRequest.safeParse(
706
+ await c.req.json().catch(() => null),
707
+ );
708
+ if (!parsed.success) {
709
+ throw new HTTPException(422, {
710
+ message: "invalid OpenRouter custom model deletion",
711
+ });
712
+ }
713
+ const removed = await deleteWorkspaceOpenRouterCustomModel(deps.db, {
714
+ accountId: grant.accountId,
715
+ workspaceId,
716
+ customModelId,
717
+ expectedVersion: parsed.data.expectedVersion,
718
+ operationId: parsed.data.operationId,
719
+ requestHash: workspaceCustomModelRequestHash({
720
+ action: "delete",
721
+ customModelId,
722
+ expectedVersion: parsed.data.expectedVersion,
723
+ }),
724
+ });
725
+ if (removed.outcome === "not_found") {
726
+ throw new HTTPException(404, {
727
+ message: "OpenRouter custom model not found",
728
+ });
729
+ }
730
+ if (removed.outcome === "conflict") {
731
+ throw new HTTPException(409, {
732
+ message: "OpenRouter custom model changed; reload and retry",
733
+ });
734
+ }
735
+ return c.body(null, 204);
736
+ });
737
+
344
738
  app.get("/v1/workspaces/:workspaceId/realtime-model-catalog", async (c) => {
345
739
  const workspaceId = c.req.param("workspaceId");
346
740
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
@@ -417,11 +811,15 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
417
811
  const workspaceId = c.req.param("workspaceId");
418
812
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
419
813
  const payload = UpdateWorkspaceModelPolicyRequest.parse(await c.req.json());
814
+ const catalog = await resolveWorkspaceCatalogSettings(deps.db, deps.settings, {
815
+ accountId: grant.accountId,
816
+ workspaceId,
817
+ });
420
818
  const policy = await upsertWorkspaceModelPolicy(deps.db, {
421
819
  accountId: grant.accountId,
422
820
  workspaceId,
423
821
  allowedProviders: payload.allowedProviders ?? null,
424
- allowedModels: canonicalWorkspacePolicyModelIds(deps.settings, payload.allowedModels),
822
+ allowedModels: canonicalWorkspacePolicyModelIds(catalog.settings, payload.allowedModels),
425
823
  });
426
824
  return c.json(policy);
427
825
  });
@@ -513,65 +911,10 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
513
911
  app.delete("/v1/workspaces/:workspaceId", async (c) => {
514
912
  const workspaceId = c.req.param("workspaceId");
515
913
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
516
- // The DB transaction locks the account/workspace and every existing
517
- // session/lease before checking runtime quiescence, then returns the exact
518
- // external schedules removed by the cascade. A racing cold->warm transition
519
- // can therefore never erase the only provider/capture ownership receipt.
520
- const deleteObserver = workspaceDeleteObserver(deps.observability, {
521
- accountId: grant.accountId,
522
- workspaceId,
523
- });
524
- const deleted = await deleteWorkspaceIfQuiescent(deps.db, {
914
+ await deleteWorkspaceForRequest(deps, {
525
915
  accountId: grant.accountId,
526
916
  workspaceId,
527
- ...(deleteObserver ? { observer: deleteObserver } : {}),
528
917
  });
529
- if (deleted.status === "not_found") {
530
- throw new HTTPException(404, { message: "workspace not found" });
531
- }
532
- if (deleted.status === "only_workspace") {
533
- throw new HTTPException(409, {
534
- message: "cannot delete the account's only workspace",
535
- });
536
- }
537
- if (deleted.status === "active_sessions") {
538
- throw new HTTPException(409, {
539
- message: "stop the workspace's running sessions before deleting it",
540
- });
541
- }
542
- if (deleted.status === "active_video_generations") {
543
- throw new HTTPException(409, {
544
- message: "wait for the workspace's active video generations to finish before deleting it",
545
- });
546
- }
547
- if (deleted.status === "active_background_commands") {
548
- throw new HTTPException(409, {
549
- message: "pause or cancel the workspace's background commands before deleting it",
550
- });
551
- }
552
- if (deleted.status === "live_sandboxes") {
553
- throw new HTTPException(409, {
554
- message: "wait for the workspace's active sandboxes to finish draining before deleting it",
555
- });
556
- }
557
- if (deleted.status !== "deleted") {
558
- throw new Error(`Unhandled workspace deletion outcome: ${deleted.status}`);
559
- }
560
- // The cleanup claims were inserted in the same transaction as the cascade.
561
- // Try them immediately; failures are released to the replica-safe outbox
562
- // pump, so a process crash or Temporal outage cannot orphan the schedules.
563
- await processTemporalScheduleCleanupClaims(
564
- {
565
- db: deps.db,
566
- deleteSchedule: async (temporalScheduleId) => {
567
- await deps.workflowClient.deleteScheduledTaskSchedule({
568
- temporalScheduleId,
569
- });
570
- },
571
- ...(deps.observability ? { observability: deps.observability } : {}),
572
- },
573
- deleted.temporalScheduleCleanups,
574
- );
575
918
  return c.body(null, 204);
576
919
  });
577
920
 
@@ -0,0 +1,64 @@
1
+ import { deleteWorkspaceIfQuiescent, nestedPostgresSqlState } from "@opengeni/db";
2
+ import type { ApiRouteDeps } from "@opengeni/core";
3
+ import { HTTPException } from "hono/http-exception";
4
+
5
+ import { processTemporalScheduleCleanupClaims } from "./temporal-schedule-cleanup";
6
+ import { workspaceDeleteObserver } from "./workspace-delete-observability";
7
+
8
+ export async function deleteWorkspaceForRequest(
9
+ deps: ApiRouteDeps,
10
+ input: {
11
+ accountId: string;
12
+ workspaceId: string;
13
+ organizationAdministratorSubjectId?: string;
14
+ },
15
+ ): Promise<void> {
16
+ const observer = workspaceDeleteObserver(deps.observability, input);
17
+ let deleted;
18
+ try {
19
+ deleted = await deleteWorkspaceIfQuiescent(deps.db, {
20
+ ...input,
21
+ ...(observer ? { observer } : {}),
22
+ });
23
+ } catch (error) {
24
+ const state = nestedPostgresSqlState(error);
25
+ if (state === "42501") {
26
+ throw new HTTPException(403, { message: "organization administration is not authorized" });
27
+ }
28
+ if (state === "P0002") {
29
+ throw new HTTPException(404, { message: "workspace not found" });
30
+ }
31
+ throw error;
32
+ }
33
+
34
+ if (deleted.status === "not_found") {
35
+ throw new HTTPException(404, { message: "workspace not found" });
36
+ }
37
+ const conflicts = {
38
+ only_workspace: "cannot delete the account's only workspace",
39
+ active_sessions: "stop the workspace's running sessions before deleting it",
40
+ active_video_generations:
41
+ "wait for the workspace's active video generations to finish before deleting it",
42
+ active_background_commands:
43
+ "pause or cancel the workspace's background commands before deleting it",
44
+ live_sandboxes:
45
+ "wait for the workspace's active sandboxes to finish draining before deleting it",
46
+ } as const;
47
+ if (deleted.status in conflicts) {
48
+ throw new HTTPException(409, { message: conflicts[deleted.status as keyof typeof conflicts] });
49
+ }
50
+ if (deleted.status !== "deleted") {
51
+ throw new Error(`Unhandled workspace deletion outcome: ${deleted.status}`);
52
+ }
53
+
54
+ await processTemporalScheduleCleanupClaims(
55
+ {
56
+ db: deps.db,
57
+ deleteSchedule: async (temporalScheduleId) => {
58
+ await deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId });
59
+ },
60
+ ...(deps.observability ? { observability: deps.observability } : {}),
61
+ },
62
+ deleted.temporalScheduleCleanups,
63
+ );
64
+ }