@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.
@@ -12,6 +12,7 @@ import {
12
12
  GatewayRealtimeConnectRequest,
13
13
  ClientSessionEvent,
14
14
  CompactSessionContextRequest,
15
+ CreateSessionRequest,
15
16
  DeleteSessionQueueItemRequest,
16
17
  EditSessionQueueItemRequest,
17
18
  EndSessionRealtimeRequest,
@@ -47,6 +48,7 @@ import {
47
48
  SessionEventSemanticClass,
48
49
  SessionEventType,
49
50
  SessionMcpServerId,
51
+ SaveNewSessionDraftRequest,
50
52
  compactSessionEventResult,
51
53
  sessionEventLatestClassToSemanticClass,
52
54
  SaveComposerDraftRequest,
@@ -231,6 +233,7 @@ import {
231
233
  requirePermission,
232
234
  requireSessionAuthorization,
233
235
  requireSessionAuthorizationListScope,
236
+ resolveWorkspaceCatalogSettings,
234
237
  withResolvedSessionAuthorization,
235
238
  SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
236
239
  SessionAuthorizationDeniedError,
@@ -549,6 +552,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
549
552
  }
550
553
  let session: Session;
551
554
  try {
555
+ CreateSessionRequest.parse(payload);
552
556
  session = await createSessionForRequest(deps, grant, workspaceId, payload, authorization);
553
557
  } catch (error) {
554
558
  return sessionCreateErrorResponse(c, error);
@@ -562,7 +566,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
562
566
  app.get("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
563
567
  const workspaceId = c.req.param("workspaceId");
564
568
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
565
- return c.json(await getActorNewSessionDraft({ settings, db }, grant, workspaceId));
569
+ const catalog = await resolveWorkspaceCatalogSettings(db, settings, {
570
+ accountId: grant.accountId,
571
+ workspaceId,
572
+ });
573
+ return c.json(
574
+ await getActorNewSessionDraft({ settings: catalog.settings, db }, grant, workspaceId),
575
+ );
566
576
  });
567
577
 
568
578
  app.put("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
@@ -587,9 +597,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
587
597
  );
588
598
  }
589
599
  try {
600
+ SaveNewSessionDraftRequest.parse(payload);
601
+ const catalog = await resolveWorkspaceCatalogSettings(db, settings, {
602
+ accountId: grant.accountId,
603
+ workspaceId,
604
+ });
590
605
  return c.json(
591
606
  await saveActorNewSessionDraft(
592
- { settings, db, objectStorage },
607
+ { settings: catalog.settings, db, objectStorage },
593
608
  grant,
594
609
  workspaceId,
595
610
  payload,
@@ -2898,28 +2913,33 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2898
2913
  const sessionId = c.req.param("sessionId");
2899
2914
  await assertSessionExists(db, workspaceId, sessionId);
2900
2915
  const payload = parseSteerSessionAdmission(await c.req.json().catch(() => null));
2901
- const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
2902
- text: payload.text,
2903
- annotations: payload.annotations,
2904
- modelContext: payload.modelContext ?? null,
2905
- resources: payload.resources,
2906
- model: payload.model ?? null,
2907
- reasoningEffort: payload.reasoningEffort ?? null,
2908
- latencyMode: payload.latencyMode ?? null,
2909
- mcpCredentialUpdates: payload.mcpCredentialUpdates ?? [],
2910
- connectionAuthorities: payload.connectionAuthorities,
2911
- ...(payload.personalResourceAttachment
2912
- ? { personalResourceAttachment: payload.personalResourceAttachment }
2913
- : {}),
2914
- authorization,
2915
- delivery: "steer",
2916
- origin: "human",
2917
- ...(payload.controlEtag !== undefined ? { controlEtag: payload.controlEtag } : {}),
2918
- ...(payload.expectedDraftRevision !== undefined
2919
- ? { expectedDraftRevision: payload.expectedDraftRevision }
2920
- : {}),
2921
- ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
2922
- });
2916
+ let result: Awaited<ReturnType<typeof acceptSessionUserMessage>>;
2917
+ try {
2918
+ result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
2919
+ text: payload.text,
2920
+ annotations: payload.annotations,
2921
+ modelContext: payload.modelContext ?? null,
2922
+ resources: payload.resources,
2923
+ model: payload.model ?? null,
2924
+ reasoningEffort: payload.reasoningEffort ?? null,
2925
+ latencyMode: payload.latencyMode ?? null,
2926
+ mcpCredentialUpdates: payload.mcpCredentialUpdates ?? [],
2927
+ connectionAuthorities: payload.connectionAuthorities,
2928
+ ...(payload.personalResourceAttachment
2929
+ ? { personalResourceAttachment: payload.personalResourceAttachment }
2930
+ : {}),
2931
+ authorization,
2932
+ delivery: "steer",
2933
+ origin: "human",
2934
+ ...(payload.controlEtag !== undefined ? { controlEtag: payload.controlEtag } : {}),
2935
+ ...(payload.expectedDraftRevision !== undefined
2936
+ ? { expectedDraftRevision: payload.expectedDraftRevision }
2937
+ : {}),
2938
+ ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
2939
+ });
2940
+ } catch (error) {
2941
+ return commandConflictResponse(c, error);
2942
+ }
2923
2943
  return c.json(result, 202);
2924
2944
  });
2925
2945
 
@@ -2975,29 +2995,34 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2975
2995
  }
2976
2996
  }
2977
2997
  if (event.type === "user.message") {
2978
- const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
2979
- text: event.payload.text,
2980
- annotations: event.payload.annotations,
2981
- modelContext: event.payload.modelContext ?? null,
2982
- resources: event.payload.resources ?? [],
2983
- model: event.payload.model ?? null,
2984
- reasoningEffort: event.payload.reasoningEffort ?? null,
2985
- latencyMode: event.payload.latencyMode ?? null,
2986
- mcpCredentialUpdates: event.payload.mcpCredentialUpdates ?? [],
2987
- connectionAuthorities: event.payload.connectionAuthorities,
2988
- ...(event.payload.personalResourceAttachment
2989
- ? { personalResourceAttachment: event.payload.personalResourceAttachment }
2990
- : {}),
2991
- authorization,
2992
- ...(event.payload.controlEtag !== undefined
2993
- ? { controlEtag: event.payload.controlEtag }
2994
- : {}),
2995
- ...(event.payload.expectedDraftRevision !== undefined
2996
- ? { expectedDraftRevision: event.payload.expectedDraftRevision }
2997
- : {}),
2998
- ...(event.clientEventId ? { clientEventId: event.clientEventId } : {}),
2999
- });
3000
- return c.json(accepted, 202);
2998
+ let result: Awaited<ReturnType<typeof acceptSessionUserMessage>>;
2999
+ try {
3000
+ result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
3001
+ text: event.payload.text,
3002
+ annotations: event.payload.annotations,
3003
+ modelContext: event.payload.modelContext ?? null,
3004
+ resources: event.payload.resources ?? [],
3005
+ model: event.payload.model ?? null,
3006
+ reasoningEffort: event.payload.reasoningEffort ?? null,
3007
+ latencyMode: event.payload.latencyMode ?? null,
3008
+ mcpCredentialUpdates: event.payload.mcpCredentialUpdates ?? [],
3009
+ connectionAuthorities: event.payload.connectionAuthorities,
3010
+ ...(event.payload.personalResourceAttachment
3011
+ ? { personalResourceAttachment: event.payload.personalResourceAttachment }
3012
+ : {}),
3013
+ authorization,
3014
+ ...(event.payload.controlEtag !== undefined
3015
+ ? { controlEtag: event.payload.controlEtag }
3016
+ : {}),
3017
+ ...(event.payload.expectedDraftRevision !== undefined
3018
+ ? { expectedDraftRevision: event.payload.expectedDraftRevision }
3019
+ : {}),
3020
+ ...(event.clientEventId ? { clientEventId: event.clientEventId } : {}),
3021
+ });
3022
+ } catch (error) {
3023
+ return commandConflictResponse(c, error);
3024
+ }
3025
+ return c.json(result.accepted, 202);
3001
3026
  }
3002
3027
 
3003
3028
  if (event.type === "user.approvalDecision") {
@@ -552,8 +552,9 @@ export function registerSuperGrokRoutes(app: Hono, deps: ApiRouteDeps): void {
552
552
  } catch {
553
553
  valid = false;
554
554
  }
555
- const catalog = valid
556
- ? configuredModels(withXaiSubscriptionCatalogProvider(deps.settings))
555
+ const catalogSettings = valid ? (await deps.resolveCatalogSettings()).settings : null;
556
+ const catalog = catalogSettings
557
+ ? configuredModels(withXaiSubscriptionCatalogProvider(catalogSettings))
557
558
  .filter(
558
559
  (model) =>
559
560
  model.credentialSource.kind === "connected_subscription" &&
@@ -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,
@@ -36,6 +46,14 @@ import {
36
46
  normalizeWorkspaceMembershipPermissions,
37
47
  listWorkspaceControlEvents,
38
48
  listSharedWorkspacesForAccount,
49
+ listWorkspaceGatewayCustomModels,
50
+ listWorkspaceOpenRouterCustomModels,
51
+ createWorkspaceGatewayCustomModel,
52
+ createWorkspaceOpenRouterCustomModel,
53
+ deleteWorkspaceGatewayCustomModel,
54
+ deleteWorkspaceOpenRouterCustomModel,
55
+ replayWorkspaceGatewayCustomModelCreate,
56
+ replayWorkspaceOpenRouterCustomModelCreate,
39
57
  listWorkspacesForSubject,
40
58
  nestedPostgresSqlState,
41
59
  removeWorkspaceMember,
@@ -50,7 +68,12 @@ import {
50
68
  workspaceControlRequestLockTimeoutMs,
51
69
  workspaceXaiSubscriptionActive,
52
70
  workspaceVercelAiGatewayConnectionActive,
71
+ workspaceOpenRouterConnectionActive,
72
+ WorkspaceGatewayCustomModelHistoryLimitError,
73
+ WorkspaceOpenRouterCustomModelHistoryLimitError,
53
74
  WorkspaceExternalIdentityConflictError,
75
+ WorkspaceGatewayCustomModelLimitError,
76
+ WorkspaceOpenRouterCustomModelLimitError,
54
77
  WorkspaceLimitExceededError,
55
78
  } from "@opengeni/db";
56
79
  import { boundWorkspaceControlHttpPage } from "@opengeni/events";
@@ -63,6 +86,7 @@ import {
63
86
  requireAccessContext,
64
87
  requireAccessGrant,
65
88
  requireFreshAccessGrant,
89
+ resolveWorkspaceCatalogSettings,
66
90
  } from "@opengeni/core";
67
91
  import { requireLimit } from "@opengeni/core";
68
92
  import type { ApiRouteDeps } from "@opengeni/core";
@@ -83,6 +107,13 @@ import {
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,323 @@ 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,
324
401
  ] = await Promise.all([
402
+ deps.resolveCatalogSettings(),
325
403
  getWorkspaceModelPolicy(deps.db, workspaceId),
326
404
  workspaceCodexSubscriptionActive(deps.db, deps.settings, workspaceId),
327
405
  workspaceXaiSubscriptionActive(deps.db, deps.settings, workspaceId, grant.subjectId),
328
406
  workspaceVercelAiGatewayConnectionActive(deps.db, workspaceId),
407
+ listWorkspaceGatewayCustomModels(deps.db, {
408
+ accountId: grant.accountId,
409
+ workspaceId,
410
+ }),
411
+ workspaceOpenRouterConnectionActive(deps.db, workspaceId),
412
+ listWorkspaceOpenRouterCustomModels(deps.db, {
413
+ accountId: grant.accountId,
414
+ workspaceId,
415
+ }),
329
416
  ]);
330
417
  c.header("cache-control", "private, no-store");
331
418
  return c.json(
332
419
  WorkspaceModelCatalogResponse.parse(
333
420
  buildWorkspaceModelCatalog({
334
- settings: deps.settings,
421
+ settings: resolvedCatalog.settings,
335
422
  policy,
336
423
  codexSubscriptionActive,
337
424
  xaiSubscriptionActive,
338
425
  workspaceGatewayConnectionActive,
426
+ workspaceGatewayCustomModels,
427
+ workspaceOpenRouterConnectionActive: openRouterConnectionActive,
428
+ workspaceOpenRouterCustomModels,
339
429
  }),
340
430
  ),
341
431
  );
342
432
  });
343
433
 
434
+ app.get("/v1/workspaces/:workspaceId/gateway-custom-models", async (c) => {
435
+ const workspaceId = c.req.param("workspaceId");
436
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
437
+ const models = await listWorkspaceGatewayCustomModels(deps.db, {
438
+ accountId: grant.accountId,
439
+ workspaceId,
440
+ });
441
+ c.header("cache-control", "private, no-store");
442
+ return c.json(
443
+ WorkspaceGatewayCustomModelsResponse.parse({
444
+ models: models.map(projectWorkspaceGatewayCustomModel),
445
+ }),
446
+ );
447
+ });
448
+
449
+ app.post("/v1/workspaces/:workspaceId/gateway-custom-models", async (c) => {
450
+ const workspaceId = c.req.param("workspaceId");
451
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
452
+ const parsed = CreateWorkspaceGatewayCustomModelRequest.safeParse(
453
+ await c.req.json().catch(() => null),
454
+ );
455
+ if (!parsed.success) {
456
+ throw new HTTPException(422, { message: "invalid Gateway custom model" });
457
+ }
458
+ const requestHash = workspaceCustomModelRequestHash({
459
+ action: "create",
460
+ upstreamModelId: parsed.data.upstreamModelId,
461
+ label: parsed.data.label ?? null,
462
+ });
463
+ const replay = await replayWorkspaceGatewayCustomModelCreate(deps.db, {
464
+ accountId: grant.accountId,
465
+ workspaceId,
466
+ operationId: parsed.data.operationId,
467
+ requestHash,
468
+ });
469
+ if (replay.outcome === "conflict") {
470
+ throw new HTTPException(409, {
471
+ message: "Gateway custom model operation conflicts with current state",
472
+ });
473
+ }
474
+ if (replay.outcome === "success") {
475
+ return c.json(projectWorkspaceGatewayCustomModel(replay.model), 201);
476
+ }
477
+ const catalog = await deps.resolveCatalogSettings();
478
+ if (configuredGatewayUpstreamModelIds(catalog.settings).includes(parsed.data.upstreamModelId)) {
479
+ throw new HTTPException(422, {
480
+ message: "Gateway model is already included in the deployment catalog",
481
+ });
482
+ }
483
+ const customProductId = `${WORKSPACE_GATEWAY_MODEL_ID_PREFIX}${parsed.data.upstreamModelId}`;
484
+ const deploymentProductIds = new Set([
485
+ ...configuredModelInputIdentities(catalog.settings),
486
+ ...configuredGatewayWorkspaceProductModelIds(catalog.settings),
487
+ ]);
488
+ if (deploymentProductIds.has(customProductId)) {
489
+ throw new HTTPException(422, {
490
+ message: "Gateway model product id conflicts with the deployment catalog",
491
+ });
492
+ }
493
+ try {
494
+ const model = await createWorkspaceGatewayCustomModel(deps.db, {
495
+ accountId: grant.accountId,
496
+ workspaceId,
497
+ upstreamModelId: parsed.data.upstreamModelId,
498
+ label: parsed.data.label ?? null,
499
+ operationId: parsed.data.operationId,
500
+ requestHash,
501
+ createdBySubjectId: grant.subjectId,
502
+ });
503
+ if (!model || model.retiredAt) {
504
+ throw new HTTPException(409, {
505
+ message: "Gateway custom model operation conflicts with current state",
506
+ });
507
+ }
508
+ return c.json(projectWorkspaceGatewayCustomModel(model), 201);
509
+ } catch (error) {
510
+ if (
511
+ error instanceof WorkspaceGatewayCustomModelLimitError ||
512
+ error instanceof WorkspaceGatewayCustomModelHistoryLimitError
513
+ ) {
514
+ throw new HTTPException(422, { message: error.message });
515
+ }
516
+ if (nestedPostgresSqlState(error) === "23505") {
517
+ throw new HTTPException(422, {
518
+ message: "Gateway custom model already exists",
519
+ });
520
+ }
521
+ throw error;
522
+ }
523
+ });
524
+
525
+ app.delete("/v1/workspaces/:workspaceId/gateway-custom-models/:customModelId", async (c) => {
526
+ const workspaceId = c.req.param("workspaceId");
527
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
528
+ const customModelId = c.req.param("customModelId");
529
+ if (
530
+ !/^[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(
531
+ customModelId,
532
+ )
533
+ ) {
534
+ throw new HTTPException(422, {
535
+ message: "invalid Gateway custom model id",
536
+ });
537
+ }
538
+ const parsed = DeleteWorkspaceGatewayCustomModelRequest.safeParse(
539
+ await c.req.json().catch(() => null),
540
+ );
541
+ if (!parsed.success) {
542
+ throw new HTTPException(422, {
543
+ message: "invalid Gateway custom model deletion",
544
+ });
545
+ }
546
+ const removed = await deleteWorkspaceGatewayCustomModel(deps.db, {
547
+ accountId: grant.accountId,
548
+ workspaceId,
549
+ customModelId,
550
+ expectedVersion: parsed.data.expectedVersion,
551
+ operationId: parsed.data.operationId,
552
+ requestHash: workspaceCustomModelRequestHash({
553
+ action: "delete",
554
+ customModelId,
555
+ expectedVersion: parsed.data.expectedVersion,
556
+ }),
557
+ });
558
+ if (removed.outcome === "not_found") {
559
+ throw new HTTPException(404, {
560
+ message: "Gateway custom model not found",
561
+ });
562
+ }
563
+ if (removed.outcome === "conflict") {
564
+ throw new HTTPException(409, {
565
+ message: "Gateway custom model changed; reload and retry",
566
+ });
567
+ }
568
+ return c.body(null, 204);
569
+ });
570
+
571
+ app.get("/v1/workspaces/:workspaceId/openrouter-custom-models", async (c) => {
572
+ const workspaceId = c.req.param("workspaceId");
573
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
574
+ const models = await listWorkspaceOpenRouterCustomModels(deps.db, {
575
+ accountId: grant.accountId,
576
+ workspaceId,
577
+ });
578
+ c.header("cache-control", "private, no-store");
579
+ return c.json(
580
+ WorkspaceOpenRouterCustomModelsResponse.parse({
581
+ models: models.map(projectWorkspaceOpenRouterCustomModel),
582
+ }),
583
+ );
584
+ });
585
+
586
+ app.post("/v1/workspaces/:workspaceId/openrouter-custom-models", async (c) => {
587
+ const workspaceId = c.req.param("workspaceId");
588
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
589
+ const parsed = CreateWorkspaceOpenRouterCustomModelRequest.safeParse(
590
+ await c.req.json().catch(() => null),
591
+ );
592
+ if (!parsed.success) {
593
+ throw new HTTPException(422, { message: "invalid OpenRouter custom model" });
594
+ }
595
+ const requestHash = workspaceCustomModelRequestHash({
596
+ action: "create",
597
+ upstreamModelId: parsed.data.upstreamModelId,
598
+ label: parsed.data.label ?? null,
599
+ });
600
+ const replay = await replayWorkspaceOpenRouterCustomModelCreate(deps.db, {
601
+ accountId: grant.accountId,
602
+ workspaceId,
603
+ operationId: parsed.data.operationId,
604
+ requestHash,
605
+ });
606
+ if (replay.outcome === "conflict") {
607
+ throw new HTTPException(409, {
608
+ message: "OpenRouter custom model operation conflicts with current state",
609
+ });
610
+ }
611
+ if (replay.outcome === "success") {
612
+ return c.json(projectWorkspaceOpenRouterCustomModel(replay.model), 201);
613
+ }
614
+ const catalog = await deps.resolveCatalogSettings();
615
+ if (
616
+ configuredOpenRouterUpstreamModelIds(catalog.settings).includes(parsed.data.upstreamModelId)
617
+ ) {
618
+ throw new HTTPException(422, {
619
+ message: "OpenRouter model is already included in the deployment catalog",
620
+ });
621
+ }
622
+ const customProductId = `${WORKSPACE_OPENROUTER_MODEL_ID_PREFIX}${parsed.data.upstreamModelId}`;
623
+ const deploymentProductIds = new Set([
624
+ ...configuredModelInputIdentities(catalog.settings),
625
+ ...configuredOpenRouterWorkspaceProductModelIds(catalog.settings),
626
+ ]);
627
+ if (deploymentProductIds.has(customProductId)) {
628
+ throw new HTTPException(422, {
629
+ message: "OpenRouter model product id conflicts with the deployment catalog",
630
+ });
631
+ }
632
+ try {
633
+ const model = await createWorkspaceOpenRouterCustomModel(deps.db, {
634
+ accountId: grant.accountId,
635
+ workspaceId,
636
+ upstreamModelId: parsed.data.upstreamModelId,
637
+ label: parsed.data.label ?? null,
638
+ operationId: parsed.data.operationId,
639
+ requestHash,
640
+ createdBySubjectId: grant.subjectId,
641
+ });
642
+ if (!model || model.retiredAt) {
643
+ throw new HTTPException(409, {
644
+ message: "OpenRouter custom model operation conflicts with current state",
645
+ });
646
+ }
647
+ return c.json(projectWorkspaceOpenRouterCustomModel(model), 201);
648
+ } catch (error) {
649
+ if (
650
+ error instanceof WorkspaceOpenRouterCustomModelLimitError ||
651
+ error instanceof WorkspaceOpenRouterCustomModelHistoryLimitError
652
+ ) {
653
+ throw new HTTPException(422, { message: error.message });
654
+ }
655
+ if (nestedPostgresSqlState(error) === "23505") {
656
+ throw new HTTPException(422, {
657
+ message: "OpenRouter custom model already exists",
658
+ });
659
+ }
660
+ throw error;
661
+ }
662
+ });
663
+
664
+ app.delete("/v1/workspaces/:workspaceId/openrouter-custom-models/:customModelId", async (c) => {
665
+ const workspaceId = c.req.param("workspaceId");
666
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
667
+ const customModelId = c.req.param("customModelId");
668
+ if (
669
+ !/^[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(
670
+ customModelId,
671
+ )
672
+ ) {
673
+ throw new HTTPException(422, {
674
+ message: "invalid OpenRouter custom model id",
675
+ });
676
+ }
677
+ const parsed = DeleteWorkspaceOpenRouterCustomModelRequest.safeParse(
678
+ await c.req.json().catch(() => null),
679
+ );
680
+ if (!parsed.success) {
681
+ throw new HTTPException(422, {
682
+ message: "invalid OpenRouter custom model deletion",
683
+ });
684
+ }
685
+ const removed = await deleteWorkspaceOpenRouterCustomModel(deps.db, {
686
+ accountId: grant.accountId,
687
+ workspaceId,
688
+ customModelId,
689
+ expectedVersion: parsed.data.expectedVersion,
690
+ operationId: parsed.data.operationId,
691
+ requestHash: workspaceCustomModelRequestHash({
692
+ action: "delete",
693
+ customModelId,
694
+ expectedVersion: parsed.data.expectedVersion,
695
+ }),
696
+ });
697
+ if (removed.outcome === "not_found") {
698
+ throw new HTTPException(404, {
699
+ message: "OpenRouter custom model not found",
700
+ });
701
+ }
702
+ if (removed.outcome === "conflict") {
703
+ throw new HTTPException(409, {
704
+ message: "OpenRouter custom model changed; reload and retry",
705
+ });
706
+ }
707
+ return c.body(null, 204);
708
+ });
709
+
344
710
  app.get("/v1/workspaces/:workspaceId/realtime-model-catalog", async (c) => {
345
711
  const workspaceId = c.req.param("workspaceId");
346
712
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
@@ -417,11 +783,15 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
417
783
  const workspaceId = c.req.param("workspaceId");
418
784
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
419
785
  const payload = UpdateWorkspaceModelPolicyRequest.parse(await c.req.json());
786
+ const catalog = await resolveWorkspaceCatalogSettings(deps.db, deps.settings, {
787
+ accountId: grant.accountId,
788
+ workspaceId,
789
+ });
420
790
  const policy = await upsertWorkspaceModelPolicy(deps.db, {
421
791
  accountId: grant.accountId,
422
792
  workspaceId,
423
793
  allowedProviders: payload.allowedProviders ?? null,
424
- allowedModels: canonicalWorkspacePolicyModelIds(deps.settings, payload.allowedModels),
794
+ allowedModels: canonicalWorkspacePolicyModelIds(catalog.settings, payload.allowedModels),
425
795
  });
426
796
  return c.json(policy);
427
797
  });