@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,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
  }
@@ -25,18 +25,26 @@ import {
25
25
  listAutomationRuns,
26
26
  listAutomationSources,
27
27
  listAutomationTriggers,
28
+ lockActiveWorkspaceGatewayCustomModelForAdmission,
29
+ lockActiveWorkspaceOpenRouterCustomModelForAdmission,
28
30
  recordAutomationEvent,
29
31
  resolveAutomationWebhookEndpoint,
30
32
  updateAutomationSource,
31
33
  updateAutomationTrigger,
34
+ withWorkspaceGatewayCustomModelReadLock,
35
+ withWorkspaceOpenRouterCustomModelReadLock,
32
36
  type AutomationSourceSecret,
33
37
  } from "@opengeni/db";
34
38
  import {
35
39
  automationRequestDigest,
40
+ assertWorkspaceModelPolicyAllows,
36
41
  buildAutomationAcceptedExecution,
42
+ canonicalConfiguredModel,
43
+ workspaceCustomModelReference,
37
44
  requireAccessGrant,
38
45
  requireAutomationAdapter,
39
46
  requirePermission,
47
+ resolveWorkspaceCatalogSettings,
40
48
  type ApiRouteDeps,
41
49
  } from "@opengeni/core";
42
50
  import type { Hono } from "hono";
@@ -157,13 +165,36 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
157
165
  for (const permission of request.sessionTemplate.firstPartyMcpPermissions) {
158
166
  requirePermission(grant, permission);
159
167
  }
168
+ const catalogSettings = (
169
+ await resolveWorkspaceCatalogSettings(deps.db, deps.settings, {
170
+ accountId: grant.accountId,
171
+ workspaceId,
172
+ })
173
+ ).settings;
174
+ const model = canonicalConfiguredModel(catalogSettings, request.sessionTemplate.model) ?? null;
175
+ if (model) {
176
+ await assertWorkspaceModelPolicyAllows(deps.db, catalogSettings, workspaceId, model);
177
+ }
178
+ const normalizedRequest = {
179
+ ...request,
180
+ sessionTemplate: { ...request.sessionTemplate, model },
181
+ };
182
+ const beforeCreateCommit = model
183
+ ? workspaceCustomModelCommitGuard({
184
+ settings: catalogSettings,
185
+ accountId: grant.accountId,
186
+ workspaceId,
187
+ modelId: model,
188
+ })
189
+ : undefined;
160
190
  return c.json(
161
191
  await createAutomationTrigger(deps.db, {
162
192
  accountId: grant.accountId,
163
193
  workspaceId,
164
194
  createdBySubjectId: grant.subjectId,
165
- request,
195
+ request: normalizedRequest,
166
196
  adapterId: adapter.id,
197
+ ...(beforeCreateCommit ? { beforeCreateCommit } : {}),
167
198
  }),
168
199
  201,
169
200
  );
@@ -194,12 +225,54 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
194
225
  requirePermission(grant, permission);
195
226
  }
196
227
  }
228
+ const catalogSettings = (
229
+ await resolveWorkspaceCatalogSettings(deps.db, deps.settings, {
230
+ accountId: grant.accountId,
231
+ workspaceId,
232
+ })
233
+ ).settings;
234
+ const normalizedRequest = request.sessionTemplate
235
+ ? {
236
+ ...request,
237
+ sessionTemplate: {
238
+ ...request.sessionTemplate,
239
+ model: canonicalConfiguredModel(catalogSettings, request.sessionTemplate.model) ?? null,
240
+ },
241
+ }
242
+ : request;
243
+ if (normalizedRequest.sessionTemplate?.model) {
244
+ await assertWorkspaceModelPolicyAllows(
245
+ deps.db,
246
+ catalogSettings,
247
+ workspaceId,
248
+ normalizedRequest.sessionTemplate.model,
249
+ );
250
+ }
251
+ const effectiveModel = normalizedRequest.sessionTemplate
252
+ ? normalizedRequest.sessionTemplate.model
253
+ : existing.sessionTemplate.model;
254
+ const materialExecutionChange =
255
+ request.eventTypes !== undefined ||
256
+ request.configuration !== undefined ||
257
+ request.parameters !== undefined ||
258
+ request.sessionTemplate !== undefined ||
259
+ (request.status === "active" && existing.status !== "active");
260
+ const beforeUpdateCommit =
261
+ materialExecutionChange && effectiveModel
262
+ ? workspaceCustomModelCommitGuard({
263
+ settings: catalogSettings,
264
+ accountId: grant.accountId,
265
+ workspaceId,
266
+ modelId: effectiveModel,
267
+ })
268
+ : undefined;
197
269
  try {
198
270
  const trigger = await updateAutomationTrigger(deps.db, {
199
271
  workspaceId,
200
272
  triggerId: c.req.param("triggerId"),
201
273
  subjectId: grant.subjectId,
202
- request,
274
+ request: normalizedRequest,
275
+ ...(beforeUpdateCommit ? { beforeUpdateCommit } : {}),
203
276
  });
204
277
  if (!trigger)
205
278
  throw new HTTPException(404, {
@@ -377,29 +450,86 @@ export async function acceptAutomationEvent(
377
450
  workspaceId: source.workspaceId,
378
451
  sourceId: source.id,
379
452
  });
380
- const matchingAtAcceptance = triggers.filter((trigger) =>
453
+ const matchingByEvent = triggers.filter((trigger) =>
381
454
  adapter.matches({ event: input.normalizedEvent, trigger }),
382
455
  );
383
- if (matchingAtAcceptance.length > AUTOMATION_MAX_MATCHED_TRIGGERS) {
456
+ if (matchingByEvent.length > AUTOMATION_MAX_MATCHED_TRIGGERS) {
384
457
  throw new HTTPException(422, {
385
458
  message: `automation event matches more than ${AUTOMATION_MAX_MATCHED_TRIGGERS} triggers`,
386
459
  });
387
460
  }
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
- });
461
+ const stored =
462
+ matchingByEvent.length === 0
463
+ ? await recordAutomationEvent(deps.db, {
464
+ accountId: source.accountId,
465
+ workspaceId: source.workspaceId,
466
+ sourceId: source.id,
467
+ sourceVersion: source.version,
468
+ sourceConfiguration: source.configuration,
469
+ matchedTriggerRevisions: [],
470
+ deliveryKey: input.deliveryKey,
471
+ requestDigest: input.requestDigest,
472
+ normalizedEvent: input.normalizedEvent,
473
+ ignoredReason: "no_matching_triggers",
474
+ })
475
+ : await withWorkspaceGatewayCustomModelReadLock(
476
+ deps.db,
477
+ { accountId: source.accountId, workspaceId: source.workspaceId },
478
+ async (gatewayLockedDb) =>
479
+ await withWorkspaceOpenRouterCustomModelReadLock(
480
+ gatewayLockedDb,
481
+ { accountId: source.accountId, workspaceId: source.workspaceId },
482
+ async (lockedDb) => {
483
+ const catalogSourceSettings = deps.catalogSourceSettings ?? deps.settings;
484
+ const catalogSettings = (
485
+ await resolveWorkspaceCatalogSettings(lockedDb, catalogSourceSettings, {
486
+ accountId: source.accountId,
487
+ workspaceId: source.workspaceId,
488
+ })
489
+ ).settings;
490
+ const matchingAtAcceptance = [];
491
+ for (const trigger of matchingByEvent) {
492
+ try {
493
+ const render = adapter.render({
494
+ event: input.normalizedEvent,
495
+ trigger,
496
+ source,
497
+ });
498
+ const requestedModel =
499
+ render.sessionTemplate.model ?? catalogSettings.openaiModel;
500
+ const model = canonicalConfiguredModel(catalogSettings, requestedModel);
501
+ if (!model) continue;
502
+ await assertWorkspaceModelPolicyAllows(
503
+ lockedDb,
504
+ catalogSettings,
505
+ source.workspaceId,
506
+ model,
507
+ );
508
+ matchingAtAcceptance.push(trigger);
509
+ } catch (error) {
510
+ if (isUnprocessableEntity(error)) continue;
511
+ throw error;
512
+ }
513
+ }
514
+ return await recordAutomationEvent(lockedDb, {
515
+ accountId: source.accountId,
516
+ workspaceId: source.workspaceId,
517
+ sourceId: source.id,
518
+ sourceVersion: source.version,
519
+ sourceConfiguration: source.configuration,
520
+ matchedTriggerRevisions: matchingAtAcceptance.map((trigger) => ({
521
+ triggerId: trigger.id,
522
+ revision: trigger.revision,
523
+ })),
524
+ deliveryKey: input.deliveryKey,
525
+ requestDigest: input.requestDigest,
526
+ normalizedEvent: input.normalizedEvent,
527
+ ignoredReason:
528
+ matchingAtAcceptance.length === 0 ? "no_executable_triggers" : null,
529
+ });
530
+ },
531
+ ),
532
+ );
403
533
  const matching = await getAutomationTriggerRevisions(deps.db, {
404
534
  workspaceId: source.workspaceId,
405
535
  refs: stored.event.matchedTriggerRevisions,
@@ -457,6 +587,43 @@ export async function acceptAutomationEvent(
457
587
  });
458
588
  }
459
589
 
590
+ function isUnprocessableEntity(error: unknown): boolean {
591
+ return (
592
+ error instanceof Error &&
593
+ "status" in error &&
594
+ (error as Error & { status?: unknown }).status === 422
595
+ );
596
+ }
597
+
598
+ function workspaceCustomModelCommitGuard(input: {
599
+ settings: ApiRouteDeps["settings"];
600
+ accountId: string;
601
+ workspaceId: string;
602
+ modelId: string;
603
+ }): ((tx: ApiRouteDeps["db"]) => Promise<void>) | undefined {
604
+ const reference = workspaceCustomModelReference(input.settings, input.modelId);
605
+ if (!reference) return undefined;
606
+ return async (tx): Promise<void> => {
607
+ const active =
608
+ reference.providerKind === "openrouter"
609
+ ? await lockActiveWorkspaceOpenRouterCustomModelForAdmission(tx, {
610
+ accountId: input.accountId,
611
+ workspaceId: input.workspaceId,
612
+ upstreamModelId: reference.upstreamModelId,
613
+ })
614
+ : await lockActiveWorkspaceGatewayCustomModelForAdmission(tx, {
615
+ accountId: input.accountId,
616
+ workspaceId: input.workspaceId,
617
+ upstreamModelId: reference.upstreamModelId,
618
+ });
619
+ if (!active) {
620
+ throw new HTTPException(422, {
621
+ message: `model is not available: ${input.modelId}`,
622
+ });
623
+ }
624
+ };
625
+ }
626
+
460
627
  async function requireSource(
461
628
  deps: ApiRouteDeps,
462
629
  accountId: string,