@opengeni/api-router 2.6.3-canary.0 → 2.6.4-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.
@@ -1,4 +1,10 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, createHmac } from "node:crypto";
2
+ import {
3
+ WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
4
+ WORKSPACE_OPENROUTER_CONNECTION_ROLE,
5
+ VERCEL_AI_GATEWAY_CONNECTION_DOMAIN,
6
+ VERCEL_AI_GATEWAY_CONNECTION_ROLE,
7
+ } from "@opengeni/config";
2
8
  import {
3
9
  ATLASSIAN_PROVIDER_DOMAIN,
4
10
  AtlassianConnectionMetadata,
@@ -21,11 +27,16 @@ import {
21
27
  IntegrationClientMetadata,
22
28
  ListSlackInstallationBindingsResponse,
23
29
  ListConnectionsResponse,
30
+ OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
31
+ OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY,
24
32
  OpenGeniSlackBotInstallRequest,
25
33
  OpenGeniSlackBotInstallStart,
26
34
  OAuthStartRequest,
27
35
  OAuthStartResponse,
28
36
  UpdateConnectionRequest,
37
+ VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
38
+ VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_ID_METADATA_KEY,
39
+ stableJson,
29
40
  } from "@opengeni/contracts";
30
41
  import {
31
42
  bindConnectorDocumentDestination,
@@ -69,11 +80,17 @@ import {
69
80
  persistSlackBotInstallationWithSuccessAudit,
70
81
  recordSlackBotInstallCallbackFailure,
71
82
  revokeConnection,
83
+ revokeWorkspaceOpenRouterConnections,
84
+ revokeWorkspaceVercelAiGatewayConnections,
72
85
  revokeConnectionWithSlackBotSuccessAudit,
86
+ rotateWorkspaceVercelAiGatewayConnection,
87
+ rotateWorkspaceOpenRouterConnection,
73
88
  SlackBotLifecycleSuccessAuditError,
74
89
  SlackInstallationBindingConflictError,
75
90
  updateConnection,
76
91
  updateSlackBotDocumentDestination,
92
+ upsertWorkspaceVercelAiGatewayConnection,
93
+ upsertWorkspaceOpenRouterConnection,
77
94
  type SlackBotInstallCallbackFailureReason,
78
95
  type SlackBotInstallCallbackFailureStage,
79
96
  } from "@opengeni/db";
@@ -193,18 +210,69 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
193
210
  assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
194
211
  assertNotDirectAtlassianOAuth(providerDomain, payload.kind, payload.metadata);
195
212
  assertNotDirectPersonalGitHubOAuth(providerDomain, payload.kind, payload.metadata);
196
- const connection = await createConnection(db, {
197
- accountId: grant.accountId,
198
- workspaceId,
213
+ const credentialEncrypted = encryptCredentialBundle(key, payload.credential);
214
+ const workspaceProviderKind = workspaceProviderApiKeyConnectionKind({
199
215
  subjectId,
200
216
  providerDomain,
201
217
  kind: payload.kind,
202
- credentialEncrypted: encryptCredentialBundle(key, payload.credential),
203
- grantedScopes: payload.grantedScopes,
204
- expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
205
218
  metadata: payload.metadata,
206
- createdBySubjectId: grant.subjectId,
207
219
  });
220
+ const connection = workspaceProviderKind
221
+ ? await (async () => {
222
+ const provider = workspaceProviderApiKeyConnectionSpec(workspaceProviderKind);
223
+ if (!payload.operationId) {
224
+ throw new HTTPException(400, {
225
+ message: `connecting ${provider.label} requires an operationId`,
226
+ });
227
+ }
228
+ const expiresAt = payload.expiresAt ? new Date(payload.expiresAt) : null;
229
+ const metadata = workspaceProviderCredentialMetadata(
230
+ workspaceProviderKind,
231
+ payload.metadata,
232
+ );
233
+ const input = {
234
+ accountId: grant.accountId,
235
+ workspaceId,
236
+ operationId: payload.operationId,
237
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
238
+ action: "create",
239
+ providerDomain,
240
+ kind: payload.kind,
241
+ subjectId,
242
+ credential: payload.credential,
243
+ grantedScopes: payload.grantedScopes,
244
+ expiresAt: expiresAt?.toISOString() ?? null,
245
+ metadata,
246
+ }),
247
+ credentialEncrypted,
248
+ grantedScopes: payload.grantedScopes,
249
+ expiresAt,
250
+ metadata,
251
+ updatedBySubjectId: grant.subjectId,
252
+ };
253
+ const created =
254
+ workspaceProviderKind === "vercel_gateway"
255
+ ? await upsertWorkspaceVercelAiGatewayConnection(db, input)
256
+ : await upsertWorkspaceOpenRouterConnection(db, input);
257
+ if (!created || created.status === "revoked") {
258
+ throw new HTTPException(409, {
259
+ message: `${provider.label} is already connected; reload before replacing its key`,
260
+ });
261
+ }
262
+ return created;
263
+ })()
264
+ : await createConnection(db, {
265
+ accountId: grant.accountId,
266
+ workspaceId,
267
+ subjectId,
268
+ providerDomain,
269
+ kind: payload.kind,
270
+ credentialEncrypted,
271
+ grantedScopes: payload.grantedScopes,
272
+ expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
273
+ metadata: payload.metadata,
274
+ createdBySubjectId: grant.subjectId,
275
+ });
208
276
  return c.json(ConnectionResponse.parse({ connection }), 201);
209
277
  });
210
278
 
@@ -794,6 +862,24 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
794
862
  payload.providerDomain ?? existing.providerDomain,
795
863
  );
796
864
  const kind = payload.kind ?? existing.kind;
865
+ const subjectId =
866
+ payload.subjectId === undefined
867
+ ? existing.subjectId
868
+ : writableSubjectId(payload.subjectId, grant.subjectId);
869
+ const targetWorkspaceProviderKind = workspaceProviderApiKeyConnectionKind({
870
+ subjectId,
871
+ providerDomain,
872
+ kind,
873
+ metadata: payload.metadata ?? existing.metadata,
874
+ });
875
+ if (!workspaceProviderApiKeyConnectionKind(existing) && targetWorkspaceProviderKind) {
876
+ throw new HTTPException(422, {
877
+ message:
878
+ targetWorkspaceProviderKind === "vercel_gateway"
879
+ ? "use the Vercel AI Gateway connect flow to create this connection"
880
+ : "use the OpenRouter connect flow to create this connection",
881
+ });
882
+ }
797
883
  assertNotDirectPersonalSlackOAuth(providerDomain, kind);
798
884
  assertNotDirectGoogleDriveOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
799
885
  assertNotDirectAtlassianOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
@@ -819,6 +905,86 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
819
905
  });
820
906
  }
821
907
  }
908
+ const existingWorkspaceProviderKind = existing
909
+ ? workspaceProviderApiKeyConnectionKind(existing)
910
+ : null;
911
+ if (existing && existingWorkspaceProviderKind) {
912
+ const provider = workspaceProviderApiKeyConnectionSpec(existingWorkspaceProviderKind);
913
+ const providerDomain = canonicalProviderDomain(
914
+ payload.providerDomain ?? existing.providerDomain,
915
+ );
916
+ const subjectId =
917
+ payload.subjectId === undefined
918
+ ? existing.subjectId
919
+ : writableSubjectId(payload.subjectId, grant.subjectId);
920
+ const kind = payload.kind ?? existing.kind;
921
+ if (
922
+ subjectId !== null ||
923
+ providerDomain !== provider.providerDomain ||
924
+ kind !== "api_key" ||
925
+ (payload.metadata?.credentialRole !== undefined &&
926
+ payload.metadata.credentialRole !== provider.credentialRole)
927
+ ) {
928
+ throw new HTTPException(422, {
929
+ message: `${provider.label} connection identity cannot be changed`,
930
+ });
931
+ }
932
+ if (payload.credential === undefined) {
933
+ throw new HTTPException(400, {
934
+ message: `updating a ${provider.label} connection requires a new credential`,
935
+ });
936
+ }
937
+ if (payload.expectedVersion === undefined || payload.operationId === undefined) {
938
+ throw new HTTPException(400, {
939
+ message: `updating a ${provider.label} connection requires expectedVersion and operationId`,
940
+ });
941
+ }
942
+ const key = requireEnvironmentEncryption(settings);
943
+ const grantedScopes = payload.grantedScopes ?? existing.grantedScopes;
944
+ const expiresAt =
945
+ payload.expiresAt !== undefined
946
+ ? payload.expiresAt
947
+ ? new Date(payload.expiresAt)
948
+ : null
949
+ : existing.expiresAt
950
+ ? new Date(existing.expiresAt)
951
+ : null;
952
+ const metadata = workspaceProviderCredentialMetadata(existingWorkspaceProviderKind, {
953
+ ...existing.metadata,
954
+ ...(payload.metadata ?? {}),
955
+ });
956
+ const input = {
957
+ accountId: grant.accountId,
958
+ workspaceId,
959
+ connectionId: existing.id,
960
+ expectedVersion: payload.expectedVersion,
961
+ operationId: payload.operationId,
962
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
963
+ action: "rotate",
964
+ connectionId: existing.id,
965
+ expectedVersion: payload.expectedVersion,
966
+ credential: payload.credential,
967
+ grantedScopes,
968
+ expiresAt: expiresAt?.toISOString() ?? null,
969
+ metadata,
970
+ }),
971
+ credentialEncrypted: encryptCredentialBundle(key, payload.credential),
972
+ grantedScopes,
973
+ expiresAt,
974
+ metadata,
975
+ updatedBySubjectId: grant.subjectId,
976
+ };
977
+ const connection =
978
+ existingWorkspaceProviderKind === "vercel_gateway"
979
+ ? await rotateWorkspaceVercelAiGatewayConnection(db, input)
980
+ : await rotateWorkspaceOpenRouterConnection(db, input);
981
+ if (!connection) {
982
+ throw new HTTPException(409, {
983
+ message: `${provider.label} connection changed; reload before replacing its key`,
984
+ });
985
+ }
986
+ return c.json(ConnectionResponse.parse({ connection }));
987
+ }
822
988
  const key = payload.credential === undefined ? null : requireEnvironmentEncryption(settings);
823
989
  const subjectId =
824
990
  payload.subjectId === undefined
@@ -879,6 +1045,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
879
1045
  existing.providerDomain === ATLASSIAN_PROVIDER_DOMAIN &&
880
1046
  existing.kind === "oauth2" &&
881
1047
  AtlassianConnectionMetadata.safeParse(existing.metadata).success;
1048
+ const workspaceProviderKind = workspaceProviderApiKeyConnectionKind(existing);
882
1049
  const disconnectPayload = await c.req.json().catch(() => null);
883
1050
  const googleDriveDisconnect = isGoogleDrive
884
1051
  ? GoogleDriveDisconnectRequest.safeParse(disconnectPayload)
@@ -909,7 +1076,13 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
909
1076
  "invalid personal GitHub disconnect request",
910
1077
  });
911
1078
  }
912
- if (existing.status === "revoked" && !isGoogleDrive && !isAtlassian && !isPersonalGitHub) {
1079
+ if (
1080
+ existing.status === "revoked" &&
1081
+ !isGoogleDrive &&
1082
+ !isAtlassian &&
1083
+ !isPersonalGitHub &&
1084
+ !workspaceProviderKind
1085
+ ) {
913
1086
  return c.json(ConnectionResponse.parse({ connection: existing }));
914
1087
  }
915
1088
  const connection = isGoogleDrive
@@ -944,13 +1117,29 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
944
1117
  credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
945
1118
  slackTeamId: openGeniSlackBotMetadata(existing.metadata)!.slackTeamId,
946
1119
  })
947
- : await revokeConnection(
948
- db,
949
- workspaceId,
950
- connectionId,
951
- grant.subjectId,
952
- existing.version,
953
- );
1120
+ : workspaceProviderKind
1121
+ ? workspaceProviderKind === "vercel_gateway"
1122
+ ? await revokeWorkspaceVercelAiGatewayConnections(db, {
1123
+ accountId: grant.accountId,
1124
+ workspaceId,
1125
+ connectionId,
1126
+ expectedVersion: existing.version,
1127
+ updatedBySubjectId: grant.subjectId,
1128
+ })
1129
+ : await revokeWorkspaceOpenRouterConnections(db, {
1130
+ accountId: grant.accountId,
1131
+ workspaceId,
1132
+ connectionId,
1133
+ expectedVersion: existing.version,
1134
+ updatedBySubjectId: grant.subjectId,
1135
+ })
1136
+ : await revokeConnection(
1137
+ db,
1138
+ workspaceId,
1139
+ connectionId,
1140
+ grant.subjectId,
1141
+ existing.version,
1142
+ );
954
1143
  if (!connection) {
955
1144
  throw new HTTPException(409, { message: "connection changed during disconnect; try again" });
956
1145
  }
@@ -1367,6 +1556,51 @@ function assertNotReservedPersonalGitHubMetadata(
1367
1556
  }
1368
1557
  }
1369
1558
 
1559
+ type WorkspaceProviderApiKeyConnectionKind = "vercel_gateway" | "openrouter";
1560
+
1561
+ function workspaceProviderApiKeyConnectionSpec(
1562
+ providerKind: WorkspaceProviderApiKeyConnectionKind,
1563
+ ): {
1564
+ providerDomain: string;
1565
+ credentialRole: string;
1566
+ label: string;
1567
+ } {
1568
+ return providerKind === "vercel_gateway"
1569
+ ? {
1570
+ providerDomain: VERCEL_AI_GATEWAY_CONNECTION_DOMAIN,
1571
+ credentialRole: VERCEL_AI_GATEWAY_CONNECTION_ROLE,
1572
+ label: "Vercel AI Gateway",
1573
+ }
1574
+ : {
1575
+ providerDomain: WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
1576
+ credentialRole: WORKSPACE_OPENROUTER_CONNECTION_ROLE,
1577
+ label: "OpenRouter",
1578
+ };
1579
+ }
1580
+
1581
+ function workspaceProviderApiKeyConnectionKind(input: {
1582
+ subjectId?: string | null;
1583
+ providerDomain: string;
1584
+ kind: string;
1585
+ metadata?: Record<string, unknown>;
1586
+ }): WorkspaceProviderApiKeyConnectionKind | null {
1587
+ if (input.subjectId != null || input.kind !== "api_key") return null;
1588
+ const providerDomain = input.providerDomain.toLowerCase();
1589
+ if (
1590
+ providerDomain === VERCEL_AI_GATEWAY_CONNECTION_DOMAIN &&
1591
+ input.metadata?.credentialRole === VERCEL_AI_GATEWAY_CONNECTION_ROLE
1592
+ ) {
1593
+ return "vercel_gateway";
1594
+ }
1595
+ if (
1596
+ providerDomain === WORKSPACE_OPENROUTER_CONNECTION_DOMAIN &&
1597
+ input.metadata?.credentialRole === WORKSPACE_OPENROUTER_CONNECTION_ROLE
1598
+ ) {
1599
+ return "openrouter";
1600
+ }
1601
+ return null;
1602
+ }
1603
+
1370
1604
  function assertNotReservedSlackBotMetadata(metadata: Record<string, unknown> | undefined): void {
1371
1605
  if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
1372
1606
  throw new HTTPException(422, {
@@ -1426,6 +1660,27 @@ function encryptCredentialBundle(key: Uint8Array, credential: Record<string, unk
1426
1660
  return encryptEnvironmentValue(key, JSON.stringify(credential));
1427
1661
  }
1428
1662
 
1663
+ function workspaceProviderCredentialMetadata(
1664
+ providerKind: WorkspaceProviderApiKeyConnectionKind,
1665
+ metadata: Record<string, unknown> | undefined,
1666
+ ): Record<string, unknown> {
1667
+ const {
1668
+ [OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY]: _openRouterOperationId,
1669
+ [OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY]: _openRouterOperationDigest,
1670
+ [VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_ID_METADATA_KEY]: _operationId,
1671
+ [VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY]: _operationDigest,
1672
+ ...effectiveMetadata
1673
+ } = metadata ?? {};
1674
+ return {
1675
+ ...effectiveMetadata,
1676
+ credentialRole: workspaceProviderApiKeyConnectionSpec(providerKind).credentialRole,
1677
+ };
1678
+ }
1679
+
1680
+ function workspaceProviderCredentialRequestDigest(key: Uint8Array, value: unknown): string {
1681
+ return createHmac("sha256", key).update(stableJson(value), "utf8").digest("hex");
1682
+ }
1683
+
1429
1684
  function slackBotCredentialBundle(token: string): Record<string, unknown> {
1430
1685
  const headerName = ["author", "ization"].join("");
1431
1686
  const scheme = ["Bear", "er"].join("");
@@ -428,14 +428,6 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
428
428
  if (candidates.length === 0) {
429
429
  return redirectToGitHubInstallation(c, deps, selectionState);
430
430
  }
431
- if (candidates.length === 1) {
432
- return redirectToExactGitHubAuthorization(
433
- c,
434
- deps,
435
- selectionState,
436
- candidates[0]!.installation.installationId,
437
- );
438
- }
439
431
  setGitHubStateCookie(c, deps, selectionState);
440
432
  return c.html(
441
433
  githubInstallationChooserHtml(
@@ -13,12 +13,14 @@ import {
13
13
  canonicalConfiguredModel,
14
14
  defaultPrReviewProviderBaseUrl,
15
15
  getCapabilityPack,
16
+ workspaceCustomModelReference,
16
17
  prReviewWebhookAuthKind,
17
18
  normalizePrReviewProviderBaseUrl,
18
19
  prReviewPackConnectorId,
19
20
  PR_REVIEW_AUTOMATION_TEMPLATE_ID,
20
21
  requireAccessGrant,
21
22
  requirePermission,
23
+ resolveWorkspaceCatalogSettings,
22
24
  type ApiRouteDeps,
23
25
  } from "@opengeni/core";
24
26
  import {
@@ -32,6 +34,8 @@ import {
32
34
  getPackInstallation,
33
35
  listPrReviewAppRegistrations,
34
36
  listPrReviewRepositoryBindings,
37
+ lockActiveWorkspaceGatewayCustomModelForAdmission,
38
+ lockActiveWorkspaceOpenRouterCustomModelForAdmission,
35
39
  nestedPostgresSqlState,
36
40
  recordAuditEvent,
37
41
  updatePrReviewAppRegistration,
@@ -385,10 +389,24 @@ export function registerPrReviewRoutes(app: Hono, deps: ApiRouteDeps): void {
385
389
  throw error;
386
390
  }
387
391
  }
392
+ const catalogSettings = (
393
+ await resolveWorkspaceCatalogSettings(db, deps.settings, {
394
+ accountId: grant.accountId,
395
+ workspaceId,
396
+ })
397
+ ).settings;
388
398
  const model = payload.model
389
- ? (canonicalConfiguredModel(settings, payload.model) ?? null)
399
+ ? (canonicalConfiguredModel(catalogSettings, payload.model) ?? null)
390
400
  : null;
391
- if (model) await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
401
+ if (model) await assertWorkspaceModelPolicyAllows(db, catalogSettings, workspaceId, model);
402
+ const beforeCreateCommit = model
403
+ ? prReviewCustomModelCommitGuard({
404
+ settings: catalogSettings,
405
+ accountId: grant.accountId,
406
+ workspaceId,
407
+ modelId: model,
408
+ })
409
+ : undefined;
392
410
  const template = getCapabilityPack(OPENGENI_PR_REVIEW_PACK_ID)?.automationTemplates?.find(
393
411
  (candidate) => candidate.id === PR_REVIEW_AUTOMATION_TEMPLATE_ID,
394
412
  );
@@ -417,6 +435,7 @@ export function registerPrReviewRoutes(app: Hono, deps: ApiRouteDeps): void {
417
435
  eventTypes: template.eventTypes,
418
436
  configuration: template.configuration,
419
437
  sessionTemplate: template.sessionTemplate,
438
+ ...(beforeCreateCommit ? { beforeCreateCommit } : {}),
420
439
  }),
421
440
  "This repository is already bound to the selected PR Review registration",
422
441
  );
@@ -441,13 +460,40 @@ export function registerPrReviewRoutes(app: Hono, deps: ApiRouteDeps): void {
441
460
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
442
461
  await requirePrReviewPackActive(db, workspaceId);
443
462
  const payload = UpdatePrReviewRepositoryBindingRequest.parse(await c.req.json());
463
+ const catalogSettings = (
464
+ await resolveWorkspaceCatalogSettings(db, deps.settings, {
465
+ accountId: grant.accountId,
466
+ workspaceId,
467
+ })
468
+ ).settings;
444
469
  const model =
445
470
  payload.model === undefined
446
471
  ? undefined
447
472
  : payload.model === null
448
473
  ? null
449
- : (canonicalConfiguredModel(settings, payload.model) ?? null);
450
- if (model) await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
474
+ : (canonicalConfiguredModel(catalogSettings, payload.model) ?? null);
475
+ if (model) await assertWorkspaceModelPolicyAllows(db, catalogSettings, workspaceId, model);
476
+ const beforeUpdateCommit = async (
477
+ tx: ApiRouteDeps["db"],
478
+ context: {
479
+ currentModel: string | null;
480
+ currentStatus: "active" | "disabled";
481
+ nextModel: string | null;
482
+ nextStatus: "active" | "disabled";
483
+ },
484
+ ): Promise<void> => {
485
+ const materialExecutionChange =
486
+ payload.model !== undefined ||
487
+ payload.additionalInstructions !== undefined ||
488
+ (context.currentStatus === "disabled" && context.nextStatus === "active");
489
+ if (!materialExecutionChange || !context.nextModel) return;
490
+ await prReviewCustomModelCommitGuard({
491
+ settings: catalogSettings,
492
+ accountId: grant.accountId,
493
+ workspaceId,
494
+ modelId: context.nextModel,
495
+ })?.(tx);
496
+ };
451
497
  const binding = await updatePrReviewRepositoryBinding(db, {
452
498
  accountId: grant.accountId,
453
499
  workspaceId,
@@ -458,6 +504,7 @@ export function registerPrReviewRoutes(app: Hono, deps: ApiRouteDeps): void {
458
504
  ? { additionalInstructions: payload.additionalInstructions }
459
505
  : {}),
460
506
  ...(payload.status !== undefined ? { status: payload.status } : {}),
507
+ beforeUpdateCommit,
461
508
  });
462
509
  if (!binding)
463
510
  throw new HTTPException(404, {
@@ -501,6 +548,35 @@ export function registerPrReviewRoutes(app: Hono, deps: ApiRouteDeps): void {
501
548
  });
502
549
  }
503
550
 
551
+ function prReviewCustomModelCommitGuard(input: {
552
+ settings: ApiRouteDeps["settings"];
553
+ accountId: string;
554
+ workspaceId: string;
555
+ modelId: string;
556
+ }): ((tx: ApiRouteDeps["db"]) => Promise<void>) | undefined {
557
+ const reference = workspaceCustomModelReference(input.settings, input.modelId);
558
+ if (!reference) return undefined;
559
+ return async (tx): Promise<void> => {
560
+ const active =
561
+ reference.providerKind === "openrouter"
562
+ ? await lockActiveWorkspaceOpenRouterCustomModelForAdmission(tx, {
563
+ accountId: input.accountId,
564
+ workspaceId: input.workspaceId,
565
+ upstreamModelId: reference.upstreamModelId,
566
+ })
567
+ : await lockActiveWorkspaceGatewayCustomModelForAdmission(tx, {
568
+ accountId: input.accountId,
569
+ workspaceId: input.workspaceId,
570
+ upstreamModelId: reference.upstreamModelId,
571
+ });
572
+ if (!active) {
573
+ throw new HTTPException(422, {
574
+ message: `model is not available: ${input.modelId}`,
575
+ });
576
+ }
577
+ };
578
+ }
579
+
504
580
  function assertPrReviewSandboxBackend(deps: ApiRouteDeps): void {
505
581
  if (deps.settings.sandboxBackend === "selfhosted") {
506
582
  throw new HTTPException(409, {
@@ -6,7 +6,7 @@ import {
6
6
  import { listScheduledTaskRuns, listScheduledTasks } from "@opengeni/db";
7
7
  import type { Hono } from "hono";
8
8
  import { HTTPException } from "hono/http-exception";
9
- import { requireAccessGrant } from "@opengeni/core";
9
+ import { requireAccessGrant, resolveWorkspaceCatalogSettings } from "@opengeni/core";
10
10
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
11
11
  import type { ApiRouteDeps } from "@opengeni/core";
12
12
  import {
@@ -30,7 +30,7 @@ import { boundedLimit } from "../http/common";
30
30
  import { deleteScheduledTaskWithDurableCleanup } from "../scheduled-task-deletion";
31
31
 
32
32
  export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void {
33
- const { settings, db, workflowClient, objectStorage } = deps;
33
+ const { db, workflowClient, objectStorage } = deps;
34
34
 
35
35
  app.post("/v1/workspaces/:workspaceId/scheduled-tasks", async (c) => {
36
36
  const workspaceId = c.req.param("workspaceId");
@@ -43,6 +43,12 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
43
43
  });
44
44
  }
45
45
  const payload = parsedPayload.data;
46
+ const catalogSettings = (
47
+ await resolveWorkspaceCatalogSettings(db, deps.settings, {
48
+ accountId: grant.accountId,
49
+ workspaceId,
50
+ })
51
+ ).settings;
46
52
  await requireLimit(deps, {
47
53
  accountId: grant.accountId,
48
54
  workspaceId,
@@ -50,7 +56,7 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
50
56
  quantity: 1,
51
57
  });
52
58
  const task = await createValidatedScheduledTask({
53
- settings,
59
+ settings: catalogSettings,
54
60
  db,
55
61
  objectStorage,
56
62
  grant,
@@ -91,8 +97,14 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
91
97
  });
92
98
  }
93
99
  const payload = parsedPayload.data;
100
+ const catalogSettings = (
101
+ await resolveWorkspaceCatalogSettings(db, deps.settings, {
102
+ accountId: grant.accountId,
103
+ workspaceId,
104
+ })
105
+ ).settings;
94
106
  const update = await validatedScheduledTaskUpdate({
95
- settings,
107
+ settings: catalogSettings,
96
108
  db,
97
109
  objectStorage,
98
110
  grant,
@@ -124,8 +136,14 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
124
136
  const grant = await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:manage");
125
137
  const existing = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
126
138
  const previous = await captureScheduledTaskRestoreState(db, existing);
139
+ const catalogSettings = (
140
+ await resolveWorkspaceCatalogSettings(db, deps.settings, {
141
+ accountId: grant.accountId,
142
+ workspaceId,
143
+ })
144
+ ).settings;
127
145
  const update = await validatedScheduledTaskUpdate({
128
- settings,
146
+ settings: catalogSettings,
129
147
  db,
130
148
  objectStorage,
131
149
  grant,
@@ -146,6 +164,12 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
146
164
  // recognised as codex-billed and skip the credit/cost gates at the edge.
147
165
  const task = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
148
166
  if (task.action.kind === "agent_turn") {
167
+ const catalogSettings = (
168
+ await resolveWorkspaceCatalogSettings(db, deps.settings, {
169
+ accountId: grant.accountId,
170
+ workspaceId,
171
+ })
172
+ ).settings;
149
173
  await validateScheduledTaskTarget({
150
174
  db,
151
175
  sessionAuthorization: deps.sessionAuthorization,
@@ -159,20 +183,23 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
159
183
  missingTargetStatus: 404,
160
184
  });
161
185
  await validateScheduledTaskMachineTarget({
162
- settings,
186
+ settings: catalogSettings,
163
187
  db,
164
188
  grant,
165
189
  runMode: task.runMode,
166
190
  agentConfig: task.agentConfig,
167
191
  requireOnline: true,
168
192
  });
169
- await requireLimit(deps, {
170
- accountId: grant.accountId,
171
- workspaceId,
172
- action: "agent_run:create",
173
- quantity: 1,
174
- model: task.agentConfig.model ?? deps.settings.openaiModel,
175
- });
193
+ await requireLimit(
194
+ { ...deps, settings: catalogSettings },
195
+ {
196
+ accountId: grant.accountId,
197
+ workspaceId,
198
+ action: "agent_run:create",
199
+ quantity: 1,
200
+ model: task.agentConfig.model ?? catalogSettings.openaiModel,
201
+ },
202
+ );
176
203
  }
177
204
  // Body is optional (a bare POST is still a valid trigger); only a present,
178
205
  // non-empty body must parse against the contract.