@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,62 +1,55 @@
1
1
  import {
2
- configuredModels,
3
- configuredProviders,
4
- withCodexCatalogProvider,
5
- withXaiSubscriptionCatalogProvider,
6
- withWorkspaceGatewayCatalogProvider,
2
+ OPENROUTER_PROVIDER_ID,
3
+ WORKSPACE_OPENROUTER_PROVIDER_ID,
7
4
  type ConfiguredModel,
8
- type Settings,
9
5
  } from "@opengeni/config";
10
6
  import {
11
7
  ClientModel,
12
8
  WorkspaceModelCatalogResponse,
13
- evaluateWorkspaceModelPolicy,
14
- type ModelAvailabilityV1,
15
- type ModelCredentialReadinessV1,
16
9
  type WorkspaceModelCatalogResponse as WorkspaceModelCatalogResponseType,
17
- type WorkspaceModelPolicyContract,
18
10
  } from "@opengeni/contracts";
11
+ import { resolveWorkspaceModelSelection, type WorkspaceModelSelectionInput } from "@opengeni/core";
19
12
 
20
- export type ModelAvailabilityObservation = {
21
- status: "available" | "degraded" | "unavailable";
22
- reason: "not_entitled" | "provider_unhealthy" | null;
23
- checkedAt: string;
24
- };
25
-
26
- export type ModelCredentialReadinessObservation =
27
- | { status: "ready"; checkedAt: string }
28
- | {
29
- status: "not_ready";
30
- reason: "prerequisites_missing" | "needs_reauth";
31
- checkedAt: string;
32
- }
33
- | { status: "error"; reason: "resolver_error"; checkedAt: string };
34
-
35
- export const MODEL_CREDENTIAL_READINESS_OBSERVATION_MAX_AGE_MS = 5 * 60_000;
13
+ export {
14
+ MODEL_CREDENTIAL_READINESS_OBSERVATION_MAX_AGE_MS,
15
+ type ModelAvailabilityObservation,
16
+ type ModelCredentialReadinessObservation,
17
+ } from "@opengeni/core";
36
18
 
37
19
  /** Static, client-safe definition projection. No provider secret is reachable. */
38
20
  export function projectClientModel(model: ConfiguredModel): ClientModel {
39
21
  const anonymousProvider =
40
22
  model.credentialSource.kind === "deployment" && model.credentialSource.mechanism === "none";
23
+ // Keep the established closed `source` enum compatible for older same-major
24
+ // clients. OpenRouter remains truthfully identified by its public provider
25
+ // id/label and billing metadata; omitting this optional legacy grouping field
26
+ // lets tolerant older contracts parse the additive provider.
41
27
  const source =
42
- model.credentialSource.kind === "connected_subscription"
43
- ? model.credentialSource.provider === "xai"
44
- ? "supergrok"
45
- : "codex"
46
- : model.credentialSource.kind === "workspace_connection"
47
- ? "workspace_gateway"
48
- : anonymousProvider
49
- ? undefined
50
- : "opengeni";
28
+ model.providerId === OPENROUTER_PROVIDER_ID ||
29
+ model.providerId === WORKSPACE_OPENROUTER_PROVIDER_ID
30
+ ? undefined
31
+ : model.credentialSource.kind === "connected_subscription"
32
+ ? model.credentialSource.provider === "xai"
33
+ ? "supergrok"
34
+ : "codex"
35
+ : model.credentialSource.kind === "workspace_connection"
36
+ ? "workspace_gateway"
37
+ : anonymousProvider
38
+ ? undefined
39
+ : "opengeni";
51
40
  const publicProvider = anonymousProvider
52
41
  ? { provider: model.providerId, providerLabel: model.providerLabel }
53
- : source === "codex"
54
- ? { provider: "codex", providerLabel: "Codex" }
55
- : source === "supergrok"
56
- ? { provider: "supergrok", providerLabel: "SuperGrok" }
57
- : source === "workspace_gateway"
58
- ? { provider: "workspace-gateway", providerLabel: "Your Gateway" }
59
- : { provider: "opengeni", providerLabel: "OpenGeni" };
42
+ : model.providerId === OPENROUTER_PROVIDER_ID
43
+ ? { provider: "openrouter", providerLabel: "OpenRouter" }
44
+ : model.providerId === WORKSPACE_OPENROUTER_PROVIDER_ID
45
+ ? { provider: "workspace-openrouter", providerLabel: "Your OpenRouter" }
46
+ : source === "codex"
47
+ ? { provider: "codex", providerLabel: "Codex" }
48
+ : source === "supergrok"
49
+ ? { provider: "supergrok", providerLabel: "SuperGrok" }
50
+ : source === "workspace_gateway"
51
+ ? { provider: "workspace-gateway", providerLabel: "Your Gateway" }
52
+ : { provider: "opengeni", providerLabel: "OpenGeni" };
60
53
  return ClientModel.parse({
61
54
  id: model.id,
62
55
  label: model.label,
@@ -71,243 +64,13 @@ export function projectClientModel(model: ConfiguredModel): ClientModel {
71
64
  aliases: model.aliases,
72
65
  executionLimits: model.executionLimits,
73
66
  billing: model.billing,
67
+ cost: model.cost,
74
68
  capabilities: model.capabilities,
75
69
  ...(model.pricing === undefined ? {} : { pricing: model.pricing }),
76
70
  definitionVersion: model.definitionVersion,
77
71
  });
78
72
  }
79
73
 
80
- function modelDefinitionRunnable(model: ConfiguredModel): boolean {
81
- return (
82
- model.capabilities.inputModalities.includes("text") &&
83
- model.capabilities.outputModalities.includes("text") &&
84
- model.capabilities.transports.sse.runnable
85
- );
86
- }
87
-
88
- function observedCredentialReadiness(input: {
89
- observation: ModelCredentialReadinessObservation | undefined;
90
- basis: "connection" | "resolver";
91
- nowMs: number;
92
- maxAgeMs: number;
93
- }): ModelCredentialReadinessV1 {
94
- if (!input.observation) {
95
- return {
96
- status: "not_ready",
97
- reason: "prerequisites_missing",
98
- basis: input.basis,
99
- checkedAt: null,
100
- };
101
- }
102
- const checkedAtMs = Date.parse(input.observation.checkedAt);
103
- if (!Number.isFinite(checkedAtMs)) {
104
- return {
105
- status: "error",
106
- reason: "resolver_error",
107
- basis: input.basis,
108
- checkedAt: null,
109
- };
110
- }
111
- const checkedAt = new Date(checkedAtMs).toISOString();
112
- if (Math.abs(input.nowMs - checkedAtMs) > input.maxAgeMs) {
113
- return {
114
- status: "not_ready",
115
- reason: "observation_stale",
116
- basis: input.basis,
117
- checkedAt,
118
- };
119
- }
120
- if (input.observation.status === "ready") {
121
- return { status: "ready", reason: null, basis: input.basis, checkedAt };
122
- }
123
- if (input.observation.status === "not_ready") {
124
- return {
125
- status: "not_ready",
126
- reason:
127
- input.observation.reason === "needs_reauth" ? "needs_reauth" : "prerequisites_missing",
128
- basis: input.basis,
129
- checkedAt,
130
- };
131
- }
132
- return {
133
- status: "error",
134
- reason: "resolver_error",
135
- basis: input.basis,
136
- checkedAt,
137
- };
138
- }
139
-
140
- function credentialReadinessFor(input: {
141
- model: ConfiguredModel;
142
- provider: ReturnType<typeof configuredProviders>[number] | undefined;
143
- codexSubscriptionActive: boolean;
144
- xaiSubscriptionActive: boolean;
145
- workspaceGatewayConnectionActive: boolean;
146
- observation: ModelCredentialReadinessObservation | undefined;
147
- nowMs: number;
148
- maxAgeMs: number;
149
- }): ModelCredentialReadinessV1 {
150
- const source = input.model.credentialSource;
151
- if (source.kind === "connected_subscription") {
152
- const active =
153
- source.provider === "xai" ? input.xaiSubscriptionActive : input.codexSubscriptionActive;
154
- return active
155
- ? { status: "ready", reason: null, basis: "connection", checkedAt: null }
156
- : {
157
- status: "not_ready",
158
- reason: "needs_reauth",
159
- basis: "connection",
160
- checkedAt: null,
161
- };
162
- }
163
- if (source.kind === "workspace_connection") {
164
- return input.workspaceGatewayConnectionActive
165
- ? { status: "ready", reason: null, basis: "connection", checkedAt: null }
166
- : {
167
- status: "not_ready",
168
- reason: "needs_reauth",
169
- basis: "connection",
170
- checkedAt: null,
171
- };
172
- }
173
- if (source.kind === "deployment" && source.mechanism === "none") {
174
- return { status: "ready", reason: null, basis: "configuration", checkedAt: null };
175
- }
176
- if (source.kind === "deployment" && source.mechanism === "api_key") {
177
- return input.provider?.apiKey
178
- ? { status: "ready", reason: null, basis: "configuration", checkedAt: null }
179
- : {
180
- status: "not_ready",
181
- reason: "missing_credential",
182
- basis: "configuration",
183
- checkedAt: null,
184
- };
185
- }
186
- return observedCredentialReadiness({
187
- observation: input.observation,
188
- basis: "resolver",
189
- nowMs: input.nowMs,
190
- maxAgeMs: input.maxAgeMs,
191
- });
192
- }
193
-
194
- function isXaiGrokModel(model: ConfiguredModel): boolean {
195
- return model.providerId === "xai" && model.id.startsWith("xai/grok-");
196
- }
197
-
198
- function observationTimestamp(observation: ModelAvailabilityObservation | undefined): {
199
- checkedAt: string | null;
200
- checkedAtMs: number | null;
201
- } {
202
- if (!observation || typeof observation.checkedAt !== "string") {
203
- return { checkedAt: null, checkedAtMs: null };
204
- }
205
- const checkedAtMs = Date.parse(observation.checkedAt);
206
- if (!Number.isFinite(checkedAtMs)) {
207
- return { checkedAt: null, checkedAtMs: null };
208
- }
209
- return { checkedAt: new Date(checkedAtMs).toISOString(), checkedAtMs };
210
- }
211
-
212
- function xaiGrokAvailabilityFor(input: {
213
- observation: ModelAvailabilityObservation | undefined;
214
- nowMs: number;
215
- maxAgeMs: number;
216
- }): ModelAvailabilityV1 {
217
- const { checkedAt, checkedAtMs } = observationTimestamp(input.observation);
218
- const freshSuccessfulObservation =
219
- input.observation?.status === "available" &&
220
- input.observation.reason === null &&
221
- checkedAtMs !== null &&
222
- checkedAtMs <= input.nowMs &&
223
- input.nowMs - checkedAtMs <= input.maxAgeMs;
224
-
225
- if (freshSuccessfulObservation) {
226
- return {
227
- status: "available",
228
- selectable: true,
229
- reason: null,
230
- checkedAt,
231
- };
232
- }
233
-
234
- return {
235
- status: "unavailable",
236
- selectable: false,
237
- reason:
238
- input.observation?.status === "unavailable"
239
- ? (input.observation.reason ?? "provider_unhealthy")
240
- : "provider_unhealthy",
241
- checkedAt,
242
- };
243
- }
244
-
245
- function availabilityFor(input: {
246
- model: ConfiguredModel;
247
- credentialReadiness: ModelCredentialReadinessV1;
248
- policyAllowed: boolean;
249
- observation?: ModelAvailabilityObservation | undefined;
250
- nowMs: number;
251
- maxAgeMs: number;
252
- }): ModelAvailabilityV1 {
253
- if (!modelDefinitionRunnable(input.model)) {
254
- return {
255
- status: "unavailable",
256
- selectable: false,
257
- reason: "unsupported",
258
- checkedAt: null,
259
- };
260
- }
261
- if (input.credentialReadiness.status !== "ready") {
262
- return {
263
- status: "unavailable",
264
- selectable: false,
265
- reason:
266
- input.credentialReadiness.reason === "missing_credential"
267
- ? "missing_credential"
268
- : input.credentialReadiness.reason === "needs_reauth"
269
- ? "needs_reauth"
270
- : "credential_not_ready",
271
- checkedAt: input.credentialReadiness.checkedAt,
272
- };
273
- }
274
- if (!input.policyAllowed) {
275
- return {
276
- status: "unavailable",
277
- selectable: false,
278
- reason: "policy_blocked",
279
- checkedAt: null,
280
- };
281
- }
282
- if (isXaiGrokModel(input.model)) {
283
- return xaiGrokAvailabilityFor({
284
- observation: input.observation,
285
- nowMs: input.nowMs,
286
- maxAgeMs: input.maxAgeMs,
287
- });
288
- }
289
- if (!input.observation) {
290
- // Credential readiness and policy are known-good, but no current
291
- // provider-health observation is available. Unknown is intentionally
292
- // selectable; the execution boundary rechecks all authoritative gates.
293
- return { status: "unknown", selectable: true, reason: null, checkedAt: null };
294
- }
295
- if (input.observation.status === "unavailable") {
296
- return {
297
- status: "unavailable",
298
- selectable: false,
299
- reason: input.observation.reason ?? "provider_unhealthy",
300
- checkedAt: input.observation.checkedAt,
301
- };
302
- }
303
- return {
304
- status: input.observation.status,
305
- selectable: true,
306
- reason: null,
307
- checkedAt: input.observation.checkedAt,
308
- };
309
- }
310
-
311
74
  /**
312
75
  * Authenticated workspace catalog. Codex definitions are included only when
313
76
  * the deployment enables that connection type; concrete readiness is supplied
@@ -316,69 +79,14 @@ function availabilityFor(input: {
316
79
  * successful typed resolver observation; credential health and provider health
317
80
  * are separate inputs and neither is fabricated.
318
81
  */
319
- export function buildWorkspaceModelCatalog(input: {
320
- settings: Settings;
321
- policy: WorkspaceModelPolicyContract | null;
322
- codexSubscriptionActive: boolean;
323
- xaiSubscriptionActive?: boolean;
324
- workspaceGatewayConnectionActive?: boolean;
325
- credentialReadinessObservations?:
326
- | Readonly<Record<string, ModelCredentialReadinessObservation>>
327
- | undefined;
328
- observations?: Readonly<Record<string, ModelAvailabilityObservation>> | undefined;
329
- now?: Date | undefined;
330
- credentialReadinessMaxAgeMs?: number | undefined;
331
- }): WorkspaceModelCatalogResponseType {
332
- const codexSettings = input.settings.codexSubscriptionEnabled
333
- ? withCodexCatalogProvider(input.settings)
334
- : input.settings;
335
- const xaiSettings = input.settings.supergrokSubscriptionEnabled
336
- ? withXaiSubscriptionCatalogProvider(codexSettings)
337
- : codexSettings;
338
- const catalogSettings = withWorkspaceGatewayCatalogProvider(xaiSettings);
339
- const providers = new Map(
340
- configuredProviders(catalogSettings).map((provider) => [provider.id, provider]),
341
- );
342
- const requestedNowMs = input.now?.getTime();
343
- const nowMs =
344
- typeof requestedNowMs === "number" && Number.isFinite(requestedNowMs)
345
- ? requestedNowMs
346
- : Date.now();
347
- const maxAgeMs =
348
- typeof input.credentialReadinessMaxAgeMs === "number" &&
349
- Number.isFinite(input.credentialReadinessMaxAgeMs) &&
350
- input.credentialReadinessMaxAgeMs >= 0
351
- ? input.credentialReadinessMaxAgeMs
352
- : MODEL_CREDENTIAL_READINESS_OBSERVATION_MAX_AGE_MS;
353
- const models = configuredModels(catalogSettings).map((model) => {
354
- const provider = providers.get(model.providerId);
355
- const policyAllowed = evaluateWorkspaceModelPolicy(input.policy, {
356
- providerId: model.providerId,
357
- modelId: model.id,
358
- }).allowed;
359
- const credentialReadiness = credentialReadinessFor({
360
- model,
361
- provider,
362
- codexSubscriptionActive: input.codexSubscriptionActive,
363
- xaiSubscriptionActive: input.xaiSubscriptionActive === true,
364
- workspaceGatewayConnectionActive: input.workspaceGatewayConnectionActive === true,
365
- observation: input.credentialReadinessObservations?.[model.definitionVersion],
366
- nowMs,
367
- maxAgeMs,
368
- });
369
- return {
370
- ...projectClientModel(model),
371
- credentialReadiness,
372
- policyAllowed,
373
- availability: availabilityFor({
374
- model,
375
- credentialReadiness,
376
- policyAllowed,
377
- observation: input.observations?.[model.definitionVersion],
378
- nowMs,
379
- maxAgeMs,
380
- }),
381
- };
382
- });
82
+ export function buildWorkspaceModelCatalog(
83
+ input: WorkspaceModelSelectionInput,
84
+ ): WorkspaceModelCatalogResponseType {
85
+ const models = resolveWorkspaceModelSelection(input).map((selection) => ({
86
+ ...projectClientModel(selection.model),
87
+ credentialReadiness: selection.credentialReadiness,
88
+ policyAllowed: selection.policyAllowed,
89
+ availability: selection.availability,
90
+ }));
383
91
  return WorkspaceModelCatalogResponse.parse({ models });
384
92
  }
@@ -29,14 +29,21 @@ import {
29
29
  resolveAutomationWebhookEndpoint,
30
30
  updateAutomationSource,
31
31
  updateAutomationTrigger,
32
+ withWorkspaceGatewayCustomModelReadLock,
33
+ withWorkspaceOpenRouterCustomModelReadLock,
32
34
  type AutomationSourceSecret,
33
35
  } from "@opengeni/db";
34
36
  import {
35
37
  automationRequestDigest,
38
+ assertWorkspaceModelPolicyAllows,
36
39
  buildAutomationAcceptedExecution,
40
+ canonicalConfiguredModel,
41
+ workspaceCustomModelReference,
42
+ lockActiveCustomModelForAdmission,
37
43
  requireAccessGrant,
38
44
  requireAutomationAdapter,
39
45
  requirePermission,
46
+ resolveWorkspaceCatalogSettings,
40
47
  type ApiRouteDeps,
41
48
  } from "@opengeni/core";
42
49
  import type { Hono } from "hono";
@@ -157,13 +164,36 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
157
164
  for (const permission of request.sessionTemplate.firstPartyMcpPermissions) {
158
165
  requirePermission(grant, permission);
159
166
  }
167
+ const catalogSettings = (
168
+ await resolveWorkspaceCatalogSettings(deps.db, deps.settings, {
169
+ accountId: grant.accountId,
170
+ workspaceId,
171
+ })
172
+ ).settings;
173
+ const model = canonicalConfiguredModel(catalogSettings, request.sessionTemplate.model) ?? null;
174
+ if (model) {
175
+ await assertWorkspaceModelPolicyAllows(deps.db, catalogSettings, workspaceId, model);
176
+ }
177
+ const normalizedRequest = {
178
+ ...request,
179
+ sessionTemplate: { ...request.sessionTemplate, model },
180
+ };
181
+ const beforeCreateCommit = model
182
+ ? workspaceCustomModelCommitGuard({
183
+ settings: catalogSettings,
184
+ accountId: grant.accountId,
185
+ workspaceId,
186
+ modelId: model,
187
+ })
188
+ : undefined;
160
189
  return c.json(
161
190
  await createAutomationTrigger(deps.db, {
162
191
  accountId: grant.accountId,
163
192
  workspaceId,
164
193
  createdBySubjectId: grant.subjectId,
165
- request,
194
+ request: normalizedRequest,
166
195
  adapterId: adapter.id,
196
+ ...(beforeCreateCommit ? { beforeCreateCommit } : {}),
167
197
  }),
168
198
  201,
169
199
  );
@@ -194,12 +224,54 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
194
224
  requirePermission(grant, permission);
195
225
  }
196
226
  }
227
+ const catalogSettings = (
228
+ await resolveWorkspaceCatalogSettings(deps.db, deps.settings, {
229
+ accountId: grant.accountId,
230
+ workspaceId,
231
+ })
232
+ ).settings;
233
+ const normalizedRequest = request.sessionTemplate
234
+ ? {
235
+ ...request,
236
+ sessionTemplate: {
237
+ ...request.sessionTemplate,
238
+ model: canonicalConfiguredModel(catalogSettings, request.sessionTemplate.model) ?? null,
239
+ },
240
+ }
241
+ : request;
242
+ if (normalizedRequest.sessionTemplate?.model) {
243
+ await assertWorkspaceModelPolicyAllows(
244
+ deps.db,
245
+ catalogSettings,
246
+ workspaceId,
247
+ normalizedRequest.sessionTemplate.model,
248
+ );
249
+ }
250
+ const effectiveModel = normalizedRequest.sessionTemplate
251
+ ? normalizedRequest.sessionTemplate.model
252
+ : existing.sessionTemplate.model;
253
+ const materialExecutionChange =
254
+ request.eventTypes !== undefined ||
255
+ request.configuration !== undefined ||
256
+ request.parameters !== undefined ||
257
+ request.sessionTemplate !== undefined ||
258
+ (request.status === "active" && existing.status !== "active");
259
+ const beforeUpdateCommit =
260
+ materialExecutionChange && effectiveModel
261
+ ? workspaceCustomModelCommitGuard({
262
+ settings: catalogSettings,
263
+ accountId: grant.accountId,
264
+ workspaceId,
265
+ modelId: effectiveModel,
266
+ })
267
+ : undefined;
197
268
  try {
198
269
  const trigger = await updateAutomationTrigger(deps.db, {
199
270
  workspaceId,
200
271
  triggerId: c.req.param("triggerId"),
201
272
  subjectId: grant.subjectId,
202
- request,
273
+ request: normalizedRequest,
274
+ ...(beforeUpdateCommit ? { beforeUpdateCommit } : {}),
203
275
  });
204
276
  if (!trigger)
205
277
  throw new HTTPException(404, {
@@ -377,29 +449,86 @@ export async function acceptAutomationEvent(
377
449
  workspaceId: source.workspaceId,
378
450
  sourceId: source.id,
379
451
  });
380
- const matchingAtAcceptance = triggers.filter((trigger) =>
452
+ const matchingByEvent = triggers.filter((trigger) =>
381
453
  adapter.matches({ event: input.normalizedEvent, trigger }),
382
454
  );
383
- if (matchingAtAcceptance.length > AUTOMATION_MAX_MATCHED_TRIGGERS) {
455
+ if (matchingByEvent.length > AUTOMATION_MAX_MATCHED_TRIGGERS) {
384
456
  throw new HTTPException(422, {
385
457
  message: `automation event matches more than ${AUTOMATION_MAX_MATCHED_TRIGGERS} triggers`,
386
458
  });
387
459
  }
388
- const stored = await recordAutomationEvent(deps.db, {
389
- accountId: source.accountId,
390
- workspaceId: source.workspaceId,
391
- sourceId: source.id,
392
- sourceVersion: source.version,
393
- sourceConfiguration: source.configuration,
394
- matchedTriggerRevisions: matchingAtAcceptance.map((trigger) => ({
395
- triggerId: trigger.id,
396
- revision: trigger.revision,
397
- })),
398
- deliveryKey: input.deliveryKey,
399
- requestDigest: input.requestDigest,
400
- normalizedEvent: input.normalizedEvent,
401
- ignoredReason: matchingAtAcceptance.length === 0 ? "no_matching_triggers" : null,
402
- });
460
+ const stored =
461
+ matchingByEvent.length === 0
462
+ ? await recordAutomationEvent(deps.db, {
463
+ accountId: source.accountId,
464
+ workspaceId: source.workspaceId,
465
+ sourceId: source.id,
466
+ sourceVersion: source.version,
467
+ sourceConfiguration: source.configuration,
468
+ matchedTriggerRevisions: [],
469
+ deliveryKey: input.deliveryKey,
470
+ requestDigest: input.requestDigest,
471
+ normalizedEvent: input.normalizedEvent,
472
+ ignoredReason: "no_matching_triggers",
473
+ })
474
+ : await withWorkspaceGatewayCustomModelReadLock(
475
+ deps.db,
476
+ { accountId: source.accountId, workspaceId: source.workspaceId },
477
+ async (gatewayLockedDb) =>
478
+ await withWorkspaceOpenRouterCustomModelReadLock(
479
+ gatewayLockedDb,
480
+ { accountId: source.accountId, workspaceId: source.workspaceId },
481
+ async (lockedDb) => {
482
+ const catalogSourceSettings = deps.catalogSourceSettings ?? deps.settings;
483
+ const catalogSettings = (
484
+ await resolveWorkspaceCatalogSettings(lockedDb, catalogSourceSettings, {
485
+ accountId: source.accountId,
486
+ workspaceId: source.workspaceId,
487
+ })
488
+ ).settings;
489
+ const matchingAtAcceptance = [];
490
+ for (const trigger of matchingByEvent) {
491
+ try {
492
+ const render = adapter.render({
493
+ event: input.normalizedEvent,
494
+ trigger,
495
+ source,
496
+ });
497
+ const requestedModel =
498
+ render.sessionTemplate.model ?? catalogSettings.openaiModel;
499
+ const model = canonicalConfiguredModel(catalogSettings, requestedModel);
500
+ if (!model) continue;
501
+ await assertWorkspaceModelPolicyAllows(
502
+ lockedDb,
503
+ catalogSettings,
504
+ source.workspaceId,
505
+ model,
506
+ );
507
+ matchingAtAcceptance.push(trigger);
508
+ } catch (error) {
509
+ if (isUnprocessableEntity(error)) continue;
510
+ throw error;
511
+ }
512
+ }
513
+ return await recordAutomationEvent(lockedDb, {
514
+ accountId: source.accountId,
515
+ workspaceId: source.workspaceId,
516
+ sourceId: source.id,
517
+ sourceVersion: source.version,
518
+ sourceConfiguration: source.configuration,
519
+ matchedTriggerRevisions: matchingAtAcceptance.map((trigger) => ({
520
+ triggerId: trigger.id,
521
+ revision: trigger.revision,
522
+ })),
523
+ deliveryKey: input.deliveryKey,
524
+ requestDigest: input.requestDigest,
525
+ normalizedEvent: input.normalizedEvent,
526
+ ignoredReason:
527
+ matchingAtAcceptance.length === 0 ? "no_executable_triggers" : null,
528
+ });
529
+ },
530
+ ),
531
+ );
403
532
  const matching = await getAutomationTriggerRevisions(deps.db, {
404
533
  workspaceId: source.workspaceId,
405
534
  refs: stored.event.matchedTriggerRevisions,
@@ -457,6 +586,36 @@ export async function acceptAutomationEvent(
457
586
  });
458
587
  }
459
588
 
589
+ function isUnprocessableEntity(error: unknown): boolean {
590
+ return (
591
+ error instanceof Error &&
592
+ "status" in error &&
593
+ (error as Error & { status?: unknown }).status === 422
594
+ );
595
+ }
596
+
597
+ function workspaceCustomModelCommitGuard(input: {
598
+ settings: ApiRouteDeps["settings"];
599
+ accountId: string;
600
+ workspaceId: string;
601
+ modelId: string;
602
+ }): ((tx: ApiRouteDeps["db"]) => Promise<void>) | undefined {
603
+ const reference = workspaceCustomModelReference(input.settings, input.modelId);
604
+ if (!reference) return undefined;
605
+ return async (tx): Promise<void> => {
606
+ const active = await lockActiveCustomModelForAdmission(tx, {
607
+ accountId: input.accountId,
608
+ workspaceId: input.workspaceId,
609
+ reference,
610
+ });
611
+ if (!active) {
612
+ throw new HTTPException(422, {
613
+ message: `model is not available: ${input.modelId}`,
614
+ });
615
+ }
616
+ };
617
+ }
618
+
460
619
  async function requireSource(
461
620
  deps: ApiRouteDeps,
462
621
  accountId: string,