@opengeni/api-router 4.1.0 → 4.1.2-canary.36079201993001

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.
@@ -18,6 +18,8 @@ import {
18
18
  prepareKnowledgeFile,
19
19
  prepareKnowledgeSave,
20
20
  searchKnowledgeEntries,
21
+ KnowledgeVectorFundingError,
22
+ KnowledgeVectorQueryRejectedError,
21
23
  hasPermission,
22
24
  knowledgeContextForAccess,
23
25
  requireAccessGrantAuthorization,
@@ -47,6 +49,7 @@ import type { Context, Hono } from "hono";
47
49
  import { HTTPException } from "hono/http-exception";
48
50
  import { z } from "zod";
49
51
  import { withAccessGrantSessionRlsContext } from "../access-grant-rls";
52
+ import { ApiHttpError } from "../http/api-error";
50
53
 
51
54
  const ReadSettings = z
52
55
  .object({
@@ -63,6 +66,18 @@ const WriteSettings = ReadSettings.extend({
63
66
  const Id = z.uuid();
64
67
  function knowledgeHttpError(error: unknown): never {
65
68
  if (error instanceof HTTPException) throw error;
69
+ if (error instanceof KnowledgeVectorFundingError)
70
+ throw new ApiHttpError(402, {
71
+ code: "payment_required",
72
+ message: error.message,
73
+ details: { code: error.code, keywordAvailable: true },
74
+ });
75
+ if (error instanceof KnowledgeVectorQueryRejectedError)
76
+ throw new ApiHttpError(error.code === "quota" ? 429 : 422, {
77
+ code: error.code === "quota" ? "limit_exceeded" : "validation_failed",
78
+ message: error.message,
79
+ details: { code: error.code, keywordAvailable: true },
80
+ });
66
81
  if (error instanceof z.ZodError)
67
82
  throw new HTTPException(422, {
68
83
  message: error.issues[0]?.message ?? "Invalid Knowledge request",
@@ -216,6 +231,8 @@ export function registerKnowledgeRoutes(app: Hono, deps: ApiRouteDeps) {
216
231
  context,
217
232
  KnowledgeSavePreparationRequest.parse(await c.req.json()),
218
233
  () => deps.getDocumentServices().embedder,
234
+ undefined,
235
+ deps.settings,
219
236
  ),
220
237
  );
221
238
  }),
@@ -228,6 +245,7 @@ export function registerKnowledgeRoutes(app: Hono, deps: ApiRouteDeps) {
228
245
  context,
229
246
  KnowledgeEntryListRequest.parse(await c.req.json()),
230
247
  () => deps.getDocumentServices().embedder,
248
+ deps.settings,
231
249
  ),
232
250
  ),
233
251
  ),
@@ -270,6 +288,7 @@ export function registerKnowledgeRoutes(app: Hono, deps: ApiRouteDeps) {
270
288
  ...(query.limit ? { limit: Number(query.limit) } : {}),
271
289
  }),
272
290
  () => deps.getDocumentServices().embedder,
291
+ deps.settings,
273
292
  ),
274
293
  );
275
294
  }),
@@ -77,6 +77,9 @@ export function registerManagedOnboardingRoutes(
77
77
  organizationName,
78
78
  operationId: parsed.data.operationId,
79
79
  requestFingerprint,
80
+ trialCreditsEnabled:
81
+ deps.settings.productAccessMode === "managed" &&
82
+ deps.settings.verifiedSignupTrialCreditsEnabled,
80
83
  }),
81
84
  ),
82
85
  );
@@ -237,6 +237,9 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
237
237
  subjectId,
238
238
  subjectLabel: session.user.email || session.user.name,
239
239
  ...payload,
240
+ trialCreditsEnabled:
241
+ deps.settings.productAccessMode === "managed" &&
242
+ deps.settings.verifiedSignupTrialCreditsEnabled,
240
243
  }),
241
244
  ),
242
245
  201,
@@ -1,5 +1,8 @@
1
1
  import { getRetainedProviderCommand } from "@opengeni/db/retained-provider-commands";
2
2
  import { searchSessionMessagesForSubject, SessionMessageSearchCursorError } from "@opengeni/db";
3
+ import { listSessionEventSlices } from "@opengeni/db/session-event-slices";
4
+ import * as sessionPreviewSchema from "@opengeni/db/schema";
5
+ import { and, eq, sql } from "drizzle-orm";
3
6
  import { SessionMessageSearchRequest } from "@opengeni/contracts";
4
7
  import { scheduledSessionIds } from "@opengeni/db";
5
8
  import { withSiteSessionOrigin } from "@opengeni/core";
@@ -2924,6 +2927,90 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2924
2927
  return c.json(page.events);
2925
2928
  });
2926
2929
 
2930
+ // Selected search hit only: never use the audit event projection here, even
2931
+ // in summary mode (it can contain modelContext or truncate visible text).
2932
+ app.get(
2933
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/events/:eventId/message-preview",
2934
+ async (c) => {
2935
+ const workspaceId = c.req.param("workspaceId");
2936
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
2937
+ const sessionId = c.req.param("sessionId");
2938
+ await assertSessionExists(db, workspaceId, sessionId);
2939
+ const eventId = c.req.param("eventId");
2940
+ const rawSequence = c.req.query("sequence");
2941
+ const sequence = Number(rawSequence);
2942
+ if (
2943
+ !z.string().uuid().safeParse(eventId).success ||
2944
+ rawSequence === undefined ||
2945
+ !/^\d+$/.test(rawSequence) ||
2946
+ !Number.isSafeInteger(sequence) ||
2947
+ sequence < 1 ||
2948
+ sequence > 2_147_483_647
2949
+ )
2950
+ throw new HTTPException(400, { message: "Invalid message preview reference" });
2951
+
2952
+ const notFound = () => new HTTPException(404, { message: "message not found" });
2953
+ const maxUnits = 12_000;
2954
+ const eventTable = sessionPreviewSchema.sessionEvents;
2955
+ // The slice helper supports structured result views too. Check only the
2956
+ // JSON scalar *kind* before calling it; never fetch a structured text value.
2957
+ const candidates = await withWorkspaceRls(db, workspaceId, (tx) =>
2958
+ tx
2959
+ .select({ kind: sql<string | null>`jsonb_typeof(${eventTable.payload}->'text')` })
2960
+ .from(eventTable)
2961
+ .where(
2962
+ and(
2963
+ eq(eventTable.workspaceId, workspaceId),
2964
+ eq(eventTable.sessionId, sessionId),
2965
+ eq(eventTable.id, eventId),
2966
+ eq(eventTable.sequence, sequence),
2967
+ sql`${eventTable.type} in ('user.message', 'agent.message.completed')`,
2968
+ ),
2969
+ )
2970
+ .limit(1),
2971
+ );
2972
+ if (candidates[0]?.kind !== "string") throw notFound();
2973
+ const read = (offset: number) =>
2974
+ listSessionEventSlices(db, workspaceId, sessionId, {
2975
+ sourceSequence: sequence,
2976
+ sourceOffset: offset,
2977
+ after: sequence - 1,
2978
+ before: sequence + 1,
2979
+ includeTypes: ["user.message", "agent.message.completed"],
2980
+ view: "conversation",
2981
+ });
2982
+ const first = await read(0);
2983
+ const event = first.events[0];
2984
+ const slice = first.slices?.[sequence];
2985
+ // Includes the slice reader's duplicate, late and unclaimed-prompt gates.
2986
+ if (!event || event.id !== eventId || event.sequence !== sequence || !slice || slice.omitted)
2987
+ throw notFound();
2988
+ if (slice.total > maxUnits) return c.json({ status: "unavailable" as const });
2989
+
2990
+ let text = slice.text;
2991
+ let offset = slice.unit === "utf16" ? slice.text.length : Array.from(slice.text).length;
2992
+ if (text.length > maxUnits) return c.json({ status: "unavailable" as const });
2993
+ while (offset < slice.total) {
2994
+ if (offset === 0) throw notFound();
2995
+ const next = await read(offset);
2996
+ const part = next.slices?.[sequence];
2997
+ if (
2998
+ next.events[0]?.id !== eventId ||
2999
+ !part ||
3000
+ part.omitted ||
3001
+ part.unit !== slice.unit ||
3002
+ part.total !== slice.total ||
3003
+ part.offset !== offset
3004
+ )
3005
+ throw notFound();
3006
+ text += part.text;
3007
+ offset += slice.unit === "utf16" ? part.text.length : Array.from(part.text).length;
3008
+ if (text.length > maxUnits) return c.json({ status: "unavailable" as const });
3009
+ }
3010
+ return c.json({ status: "available" as const, text });
3011
+ },
3012
+ );
3013
+
2927
3014
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events/stream", async (c) => {
2928
3015
  const workspaceId = c.req.param("workspaceId");
2929
3016
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -4817,6 +4904,8 @@ export function sessionAuthorizationOperationForHttp(
4817
4904
  return verb === "POST" ? "session.context.write" : null;
4818
4905
  }
4819
4906
  if (suffix === "/events/stream" && verb === "GET") return "session.stream.read";
4907
+ if (/^\/events\/[^/]+\/message-preview$/.test(suffix) && verb === "GET")
4908
+ return "session.events.read";
4820
4909
  if (suffix === "/events") {
4821
4910
  if (verb === "GET") return "session.events.read";
4822
4911
  if (verb === "POST") return "session.append";
@@ -22,6 +22,7 @@ import {
22
22
  hasGitCredentialRepositorySelection,
23
23
  hasGitHubRepositorySelection,
24
24
  sandboxLifecycleTransitionWaitMs,
25
+ sandboxWarmRateMicrosPerSecond,
25
26
  stableSandboxEnvironmentForRun,
26
27
  type Settings,
27
28
  } from "@opengeni/config";
@@ -42,6 +43,7 @@ import {
42
43
  SandboxImageConflictError,
43
44
  SandboxProviderReadLockUnavailableError,
44
45
  SandboxRigConflictError,
46
+ SandboxPaidComputeAdmissionError,
45
47
  withSandboxProviderReadLock,
46
48
  type Database,
47
49
  type LeaseSnapshot,
@@ -823,6 +825,10 @@ async function withChannelAOperation<T>(
823
825
  holderId,
824
826
  subjectId: session.id,
825
827
  backend: session.sandboxBackend,
828
+ warmBilling: {
829
+ mode: settings.sandboxWarmBillingMode,
830
+ rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(settings, session.sandboxBackend),
831
+ },
826
832
  os: session.sandboxOs,
827
833
  image: sandboxRuntime.image,
828
834
  rigVersionId: session.rigVersionId,
@@ -1210,6 +1216,8 @@ async function withChannelAOperation<T>(
1210
1216
  * already-HTTPException unchanged. */
1211
1217
  export function mapChannelAError(error: unknown, waitSignal?: AbortSignal): unknown {
1212
1218
  if (error instanceof HTTPException) return error;
1219
+ if (error instanceof SandboxPaidComputeAdmissionError)
1220
+ return new HTTPException(402, { message: error.message, cause: error });
1213
1221
  if (isChannelARequestCancellation(error, waitSignal))
1214
1222
  return new HTTPException(499 as never, {
1215
1223
  message: "request cancelled",
@@ -26,6 +26,7 @@ import {
26
26
  hasGitHubRepositorySelection,
27
27
  resolveStreamTokenSecret,
28
28
  sandboxLifecycleTransitionWaitMs,
29
+ sandboxWarmRateMicrosPerSecond,
29
30
  stableSandboxEnvironmentForRun,
30
31
  } from "@opengeni/config";
31
32
  import type { Settings } from "@opengeni/config";
@@ -37,7 +38,7 @@ import {
37
38
  getSandbox,
38
39
  getScheduledScopedRigVersionMetadata,
39
40
  getSandboxSessionEnvelope,
40
- heartbeatLeaseHolder,
41
+ heartbeatLeaseHolderStatus,
41
42
  loadWorkspaceEnvironmentForRun,
42
43
  markSandboxProviderReady,
43
44
  markWarmLeaseInstanceLost,
@@ -47,6 +48,7 @@ import {
47
48
  releaseLeaseHolder,
48
49
  SandboxLeaseSupersededError,
49
50
  SandboxViewerAdmissionBlockedError,
51
+ SandboxPaidComputeAdmissionError,
50
52
  type Database,
51
53
  type LeaseSnapshot,
52
54
  type SandboxRecord,
@@ -300,6 +302,10 @@ export async function attachViewer(
300
302
  ...(attachSubjectId ? { viewerSubjectId: attachSubjectId } : {}),
301
303
  ...(attachAuthorityEpoch !== null ? { viewerAuthorityEpoch: attachAuthorityEpoch } : {}),
302
304
  backend: session.sandboxBackend,
305
+ warmBilling: {
306
+ mode: settings.sandboxWarmBillingMode,
307
+ rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(settings, session.sandboxBackend),
308
+ },
303
309
  os: session.sandboxOs,
304
310
  image: sandboxRuntime.image,
305
311
  rigVersionId: session.rigVersionId,
@@ -309,6 +315,9 @@ export async function attachViewer(
309
315
  ...(input.waitSignal ? { waitSignal: input.waitSignal } : {}),
310
316
  });
311
317
  } catch (error) {
318
+ if (error instanceof SandboxPaidComputeAdmissionError) {
319
+ throw new HTTPException(402, { message: error.message, cause: error });
320
+ }
312
321
  if (error instanceof SandboxViewerAdmissionBlockedError) {
313
322
  throw new HTTPException(error.reason === "balance" ? 402 : 429, {
314
323
  message:
@@ -592,7 +601,7 @@ export async function heartbeatViewer(
592
601
  expectedEpoch: number;
593
602
  },
594
603
  ): Promise<boolean> {
595
- const alive = await heartbeatLeaseHolder(services.db, {
604
+ const status = await heartbeatLeaseHolderStatus(services.db, {
596
605
  accountId: input.accountId,
597
606
  workspaceId: input.workspaceId,
598
607
  sandboxGroupId: input.sandboxGroupId,
@@ -600,8 +609,12 @@ export async function heartbeatViewer(
600
609
  holderId: input.viewerId,
601
610
  leaseTtlMs: services.settings.sandboxLeaseTtlMs,
602
611
  expectedEpoch: input.expectedEpoch,
612
+ billingMode: services.settings.sandboxWarmBillingMode,
603
613
  });
604
- if (!alive) return false;
614
+ // A funding fence stops renewing paid provider time, but the holder is
615
+ // deliberately kept alive while the viewer winds down or adds funds. Other
616
+ // fences (epoch/rotation/drain) still require the client to re-attach.
617
+ if (!status.leaseExtended) return status.holderAlive && status.fence === "funding";
605
618
  const lease = await readLease(services.db, input.workspaceId, input.sandboxGroupId);
606
619
  if (lease?.liveness === "warm" && lease.leaseEpoch === input.expectedEpoch && lease.instanceId) {
607
620
  await renewSandboxProviderExpiration({
@@ -27,6 +27,12 @@ import {
27
27
  } from "@opengeni/db";
28
28
  import { retryWhileMissing, type ObjectHead, type ObjectStorage } from "@opengeni/storage";
29
29
  import { HTTPException } from "hono/http-exception";
30
+ import {
31
+ isConnectedMachineAbsolutePath,
32
+ relativeConnectedMachinePath,
33
+ resolveConnectedMachinePath,
34
+ type SandboxChannelAService,
35
+ } from "@opengeni/runtime/sandbox";
30
36
 
31
37
  import { withChannelARead } from "./sandbox/channel-a";
32
38
  import { sanitizeFilename } from "./routes/files";
@@ -98,13 +104,12 @@ async function publishSandboxFileArtifactInScope(
98
104
  });
99
105
  }
100
106
 
101
- const path = sandboxArtifactRelativePath(input.path);
102
107
  const maxArtifactBytes = Math.min(SANDBOX_FILE_ARTIFACT_MAX_BYTES, storage.maxSinglePutSizeBytes);
103
108
  if (maxArtifactBytes < 1) {
104
109
  throw new HTTPException(503, { message: "object storage cannot accept artifact files" });
105
110
  }
106
111
  const readLimit = Math.min(25 * 1024 * 1024, maxArtifactBytes + FILE_READ_SENTINEL_BYTES);
107
- const read = await withChannelARead(
112
+ const { path, sandboxPath, read } = await withChannelARead(
108
113
  {
109
114
  db: deps.db,
110
115
  settings: deps.settings,
@@ -119,7 +124,7 @@ async function publishSandboxFileArtifactInScope(
119
124
  ...(input.signal ? { waitSignal: input.signal } : {}),
120
125
  operation: "artifact.publish",
121
126
  },
122
- ({ service }) => service.fsRead({ path, encoding: "base64", maxBytes: readLimit }),
127
+ ({ service }) => readSandboxArtifactFile(service, input.path, readLimit),
123
128
  );
124
129
  if (read.truncated || read.sizeBytes > maxArtifactBytes) {
125
130
  throw new HTTPException(413, {
@@ -234,48 +239,54 @@ async function publishSandboxFileArtifactInScope(
234
239
  });
235
240
  return SandboxFileArtifactReceipt.parse({
236
241
  type: "sandbox_file",
237
- sandboxPath: `/workspace/${path}`,
242
+ sandboxPath,
238
243
  filename,
239
244
  artifact,
240
245
  });
241
246
  }
242
247
 
243
- export function sandboxArtifactRelativePath(value: string): string {
248
+ /** Use the root captured by the same fenced Channel-A service that reads bytes,
249
+ * not the session's placement-home label or a caller-supplied root. */
250
+ export async function readSandboxArtifactFile(
251
+ service: Pick<SandboxChannelAService, "capabilities" | "fsRead">,
252
+ value: string,
253
+ maxBytes: number,
254
+ ) {
255
+ const root = service.capabilities().FileSystem.root;
256
+ const path = sandboxArtifactRelativePath(value, root);
257
+ const sandboxPath = resolveConnectedMachinePath(root, path);
258
+ const read = await service.fsRead({ path, encoding: "base64", maxBytes });
259
+ return { path, sandboxPath, read };
260
+ }
261
+
262
+ export function sandboxArtifactRelativePath(value: string, workspaceRoot = "/workspace"): string {
244
263
  let path = value.trim();
245
264
  if (path.startsWith("sandbox:")) {
246
265
  path = path.slice("sandbox:".length);
247
266
  }
248
- if (path === "/workspace" || path === "/workspace/") {
267
+ if (!path || /[\\/]$/.test(path)) {
249
268
  throw new HTTPException(400, { message: "sandbox artifact path must name a file" });
250
269
  }
251
- if (path.startsWith("/workspace/")) {
252
- path = path.slice("/workspace/".length);
253
- } else if (path.startsWith("/")) {
270
+ let absolute: string;
271
+ let relative: string | null;
272
+ try {
273
+ if (!isConnectedMachineAbsolutePath(workspaceRoot)) throw new Error("Missing workspace root");
274
+ absolute = resolveConnectedMachinePath(workspaceRoot, path);
275
+ relative = relativeConnectedMachinePath(workspaceRoot, absolute);
276
+ } catch {
254
277
  throw new HTTPException(400, {
255
- message: "sandbox artifact path must be inside /workspace",
278
+ message: "sandbox artifact path is invalid for this workspace",
256
279
  });
257
280
  }
258
- if (!path) {
259
- throw new HTTPException(400, { message: "sandbox artifact path must name a file" });
260
- }
261
- if (path.includes("\0")) {
262
- throw new HTTPException(400, { message: "sandbox artifact path is invalid" });
263
- }
264
- const normalized = posix.normalize(path);
265
- if (
266
- normalized === "." ||
267
- normalized === ".." ||
268
- normalized.startsWith("../") ||
269
- normalized.endsWith("/")
270
- ) {
281
+ if (!relative) {
271
282
  throw new HTTPException(400, {
272
- message: "sandbox artifact path must canonically name a file inside /workspace",
283
+ message: "sandbox artifact path must name a file inside the active workspace root",
273
284
  });
274
285
  }
275
- if (`/workspace/${normalized}`.length > SANDBOX_ARTIFACT_ABSOLUTE_PATH_MAX_CHARS) {
286
+ if (absolute.length > SANDBOX_ARTIFACT_ABSOLUTE_PATH_MAX_CHARS) {
276
287
  throw new HTTPException(400, { message: "sandbox artifact path is too long" });
277
288
  }
278
- return normalized;
289
+ return relative;
279
290
  }
280
291
 
281
292
  export function sandboxArtifactSafeFilename(filename: string): string {
@@ -299,6 +299,7 @@ async function prepareWorkspaceToolGatewayForGrantInternal(
299
299
  routeDeps.getDocumentServices(),
300
300
  {
301
301
  knowledge: await knowledgeContextForGateway(routeDeps, grant),
302
+ settings: gatewaySettings,
302
303
  },
303
304
  ),
304
305
  ),