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