@opengeni/core 4.0.1 → 4.0.3-canary.36199476632001

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "4.0.1",
3
+ "version": "4.0.3-canary.36199476632001",
4
4
  "description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -50,17 +50,17 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.29.0",
53
- "@opengeni/capabilities": "^0.3.4",
54
- "@opengeni/codex": "^0.2.25",
55
- "@opengeni/config": "^2.1.0",
56
- "@opengeni/contracts": "^5.1.0",
57
- "@opengeni/db": "^6.0.1",
58
- "@opengeni/documents": "^0.8.32",
59
- "@opengeni/events": "^0.4.30",
60
- "@opengeni/network": "^0.3.1",
61
- "@opengeni/observability": "^0.8.31",
62
- "@opengeni/runtime": "^4.0.1",
63
- "@opengeni/storage": "^0.2.132",
53
+ "@opengeni/capabilities": "^0.3.4-canary.36199476632001",
54
+ "@opengeni/codex": "^0.2.26-canary.36199476632001",
55
+ "@opengeni/config": "^2.1.1-canary.36199476632001",
56
+ "@opengeni/contracts": "^5.1.1-canary.36199476632001",
57
+ "@opengeni/db": "^6.0.3-canary.36199476632001",
58
+ "@opengeni/documents": "^0.8.34-canary.36199476632001",
59
+ "@opengeni/events": "^0.4.32-canary.36199476632001",
60
+ "@opengeni/network": "^0.3.1-canary.36199476632001",
61
+ "@opengeni/observability": "^0.8.32-canary.36199476632001",
62
+ "@opengeni/runtime": "^4.0.3-canary.36199476632001",
63
+ "@opengeni/storage": "^0.2.133-canary.36199476632001",
64
64
  "hono": "^4.12.18",
65
65
  "zod": "^4.2.1"
66
66
  },
@@ -13,6 +13,7 @@ import {
13
13
  getSandbox,
14
14
  getVariableSet,
15
15
  NewSessionDraftAccessError,
16
+ newSessionDraftModelProvided,
16
17
  newSessionDraftSelectedProjectChannelId,
17
18
  newSessionDraftToolsProvided,
18
19
  newSessionSelectionHistory,
@@ -38,6 +39,8 @@ import {
38
39
  type AccessGrantAuthorization,
39
40
  } from "../access";
40
41
  import { assertConfiguredModel, assertWorkspaceModelPolicyAllows } from "../domain/sessions";
42
+ import { resolveDefaultSessionModel } from "../default-session-model";
43
+ import { canonicalizeConfiguredModelId, type Settings } from "@opengeni/config";
41
44
 
42
45
  type NewSessionDraftDependencies = Pick<AppDependencies, "settings" | "db" | "objectStorage">;
43
46
 
@@ -59,6 +62,9 @@ function mapNewSessionDraft(
59
62
  model: row.model,
60
63
  reasoningEffort: row.reasoningEffort,
61
64
  latencyMode: row.latencyMode,
65
+ ...(newSessionDraftModelProvided(row) !== undefined
66
+ ? { modelProvided: newSessionDraftModelProvided(row) }
67
+ : {}),
62
68
  ...(selectedProjectChannelId !== undefined ? { selectedProjectChannelId } : {}),
63
69
  options: publicNewSessionDraftOptions(row),
64
70
  selectionHistory: newSessionSelectionHistory(row),
@@ -66,15 +72,71 @@ function mapNewSessionDraft(
66
72
  });
67
73
  }
68
74
 
75
+ /**
76
+ * Whether a stored draft's model policy was the person's choice. A row written
77
+ * before the marker existed (or by an older client) counts as a choice unless
78
+ * it is exactly the deployment default policy (model, reasoning, standard
79
+ * speed), which is what an untouched composer used to save.
80
+ */
81
+ export function draftModelProvided(
82
+ settings: Settings,
83
+ draft: Pick<NewSessionDraftValue, "model" | "reasoningEffort" | "latencyMode" | "modelProvided">,
84
+ ): boolean {
85
+ if (draft.modelProvided !== undefined) return draft.modelProvided;
86
+ return !(
87
+ canonicalizeConfiguredModelId(settings, draft.model) ===
88
+ canonicalizeConfiguredModelId(settings, settings.openaiModel) &&
89
+ draft.reasoningEffort === settings.openaiReasoningEffort &&
90
+ draft.latencyMode === "standard"
91
+ );
92
+ }
93
+
94
+ /** The resolved new-chat default for this actor and workspace. */
95
+ async function actorDefaultModel(
96
+ deps: Pick<NewSessionDraftDependencies, "db" | "settings">,
97
+ grant: AccessGrant,
98
+ workspaceId: string,
99
+ ) {
100
+ return await resolveDefaultSessionModel(deps.db, deps.settings, {
101
+ accountId: grant.accountId,
102
+ workspaceId,
103
+ subjectId: grant.subjectId,
104
+ });
105
+ }
106
+
107
+ type NewSessionDraftReadOptions = {
108
+ /**
109
+ * Project a draft that follows the default onto today's resolved default.
110
+ * Callers that only reuse a chosen model (Slack defaults) skip the
111
+ * resolution; session creation resolves the default itself.
112
+ */
113
+ projectDefaultModel?: boolean;
114
+ };
115
+
69
116
  async function hydrateNewSessionDraft(
70
117
  deps: Pick<NewSessionDraftDependencies, "db" | "settings">,
71
118
  grant: AccessGrant,
72
119
  workspaceId: string,
73
120
  row: Awaited<ReturnType<typeof getNewSessionDraftInTransaction>>,
121
+ readOptions: NewSessionDraftReadOptions = {},
74
122
  ): Promise<NewSessionDraftValue | null> {
75
123
  if (!row) return null;
76
- const mapped = mapNewSessionDraft(row);
77
- if (!mapped) return null;
124
+ const stored = mapNewSessionDraft(row);
125
+ if (!stored) return null;
126
+ // A draft that follows the default is projected onto today's default, so a
127
+ // later subscription connect or credit purchase replaces an untouched free
128
+ // model. The stored row is unchanged until the person saves again.
129
+ const modelProvided = draftModelProvided(deps.settings, stored);
130
+ let mapped: NewSessionDraftValue = { ...stored, modelProvided };
131
+ if (!modelProvided && readOptions.projectDefaultModel !== false) {
132
+ const resolved = await actorDefaultModel(deps, grant, workspaceId);
133
+ mapped = {
134
+ ...mapped,
135
+ model: resolved.model,
136
+ reasoningEffort: resolved.reasoningEffort,
137
+ latencyMode: resolved.model === stored.model ? stored.latencyMode : "standard",
138
+ };
139
+ }
78
140
  const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
79
141
  deps.db,
80
142
  workspaceId,
@@ -191,6 +253,7 @@ async function getActorNewSessionDraftInFileScope(
191
253
  deps: Pick<NewSessionDraftDependencies, "settings" | "db">,
192
254
  grant: AccessGrant,
193
255
  workspaceId: string,
256
+ readOptions: NewSessionDraftReadOptions = {},
194
257
  ): Promise<NewSessionDraftValue> {
195
258
  const row = await withWorkspaceSubjectRls(deps.db, workspaceId, grant.subjectId, (scoped) =>
196
259
  getNewSessionDraftInTransaction(scoped, {
@@ -198,21 +261,26 @@ async function getActorNewSessionDraftInFileScope(
198
261
  subjectId: grant.subjectId,
199
262
  }),
200
263
  );
201
- return (
202
- (await hydrateNewSessionDraft(deps, grant, workspaceId, row)) ?? {
203
- revision: 0,
204
- text: "",
205
- resources: [],
206
- tools: [],
207
- toolsProvided: false,
208
- model: deps.settings.openaiModel,
209
- reasoningEffort: deps.settings.openaiReasoningEffort,
210
- latencyMode: "standard",
211
- options: {},
212
- selectionHistory: { projects: [] },
213
- updatedAt: null,
214
- }
215
- );
264
+ const hydrated = await hydrateNewSessionDraft(deps, grant, workspaceId, row, readOptions);
265
+ if (hydrated) return hydrated;
266
+ const resolved =
267
+ readOptions.projectDefaultModel === false
268
+ ? { model: deps.settings.openaiModel, reasoningEffort: deps.settings.openaiReasoningEffort }
269
+ : await actorDefaultModel(deps, grant, workspaceId);
270
+ return {
271
+ revision: 0,
272
+ text: "",
273
+ resources: [],
274
+ tools: [],
275
+ toolsProvided: false,
276
+ model: resolved.model,
277
+ reasoningEffort: resolved.reasoningEffort,
278
+ latencyMode: "standard",
279
+ modelProvided: false,
280
+ options: {},
281
+ selectionHistory: { projects: [] },
282
+ updatedAt: null,
283
+ };
216
284
  }
217
285
 
218
286
  /**
@@ -284,6 +352,7 @@ async function saveActorNewSessionDraftInFileScope(
284
352
  model: input.model,
285
353
  reasoningEffort: input.reasoningEffort,
286
354
  latencyMode: input.latencyMode,
355
+ ...(input.modelProvided !== undefined ? { modelProvided: input.modelProvided } : {}),
287
356
  ...(input.selectedProjectChannelId !== undefined
288
357
  ? { selectedProjectChannelId: input.selectedProjectChannelId }
289
358
  : {}),
@@ -303,7 +372,10 @@ async function saveActorNewSessionDraftInFileScope(
303
372
  }),
304
373
  ),
305
374
  );
306
- return mapNewSessionDraft(saved)!;
375
+ // Report the same model-policy marker a read of this row reports, so the
376
+ // save response and the next GET agree for old and new clients alike.
377
+ const mapped = mapNewSessionDraft(saved)!;
378
+ return { ...mapped, modelProvided: draftModelProvided(deps.settings, mapped) };
307
379
  } catch (error) {
308
380
  if (error instanceof NewSessionDraftAccessError) {
309
381
  throw new HTTPException(403, { message: error.message });
@@ -317,12 +389,13 @@ export async function getActorNewSessionDraft(
317
389
  grant: AccessGrant,
318
390
  workspaceId: string,
319
391
  authorization?: AccessGrantAuthorization,
392
+ readOptions: NewSessionDraftReadOptions = {},
320
393
  ): Promise<NewSessionDraftValue> {
321
394
  const actor = authorization
322
395
  ? await fileOwnerContextForAccess(deps, authorization, "sessions:read")
323
396
  : { subjectId: grant.subjectId, privateFileOwnerSubjectId: null };
324
397
  return withSessionRlsActorContext(actor, () =>
325
- getActorNewSessionDraftInFileScope(deps, grant, workspaceId),
398
+ getActorNewSessionDraftInFileScope(deps, grant, workspaceId, readOptions),
326
399
  );
327
400
  }
328
401
  export async function saveActorNewSessionDraft(
@@ -341,11 +414,17 @@ export async function getActorNewSessionDefaults(
341
414
  grant: AccessGrant,
342
415
  workspaceId: string,
343
416
  ) {
344
- const draft = await getActorNewSessionDraft(deps, grant, workspaceId);
417
+ // The model is reused only when the person chose it, so a draft that follows
418
+ // the default is not projected here; session creation resolves it once.
419
+ const draft = await getActorNewSessionDraft(deps, grant, workspaceId, undefined, {
420
+ projectDefaultModel: false,
421
+ });
345
422
  const options = draft.options;
346
423
  return {
347
- // Revision zero is a synthetic empty form, not a user's model preference.
348
- ...(draft.revision > 0
424
+ // Only a model the person chose is carried over. A draft that follows the
425
+ // default (including the synthetic revision-zero form) leaves the model
426
+ // out, so session creation resolves the same default itself.
427
+ ...(draft.revision > 0 && draft.modelProvided === true
349
428
  ? {
350
429
  model: draft.model,
351
430
  reasoningEffort: draft.reasoningEffort,
@@ -27,6 +27,11 @@ import {
27
27
  import { requireVariableSetEncryption } from "../domain/environments";
28
28
  import { normalizedSessionMcpCredentialHeaders } from "../domain/sessions";
29
29
  import { externalContinuationCommitAuthorizer } from "./external-continuation";
30
+ import {
31
+ listOwnConnectionAccountsForGrant,
32
+ personalConnectionDelegationSourceForGrant,
33
+ } from "../domain/personal-connection-delegations";
34
+ import { nativeSessionConnectionReplacement } from "../domain/session-native-connection-replacement";
30
35
 
31
36
  /** HMAC domains separate request identity from key availability. The key tag
32
37
  * carries no request material and neither fingerprint is returned publicly. */
@@ -80,11 +85,20 @@ export async function rotateSessionMcpCredentialsForRequest(
80
85
  id: update.id,
81
86
  expectedCredentialVersion: update.expectedCredentialVersion,
82
87
  expectedServerUrl: update.expectedServerUrl,
83
- headers: Object.fromEntries(
84
- Object.entries(normalizedSessionMcpCredentialHeaders(update.headers))
85
- .map(([name, value]) => [name.toLowerCase(), value] as const)
86
- .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
87
- ),
88
+ ...("nativeConnectionId" in update
89
+ ? {
90
+ nativeConnectionId: update.nativeConnectionId,
91
+ ...(update.replacementServerUrl
92
+ ? { replacementServerUrl: update.replacementServerUrl }
93
+ : {}),
94
+ }
95
+ : {
96
+ headers: Object.fromEntries(
97
+ Object.entries(normalizedSessionMcpCredentialHeaders(update.headers))
98
+ .map(([name, value]) => [name.toLowerCase(), value] as const)
99
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
100
+ ),
101
+ }),
88
102
  }))
89
103
  .sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)),
90
104
  };
@@ -102,20 +116,45 @@ export async function rotateSessionMcpCredentialsForRequest(
102
116
  actorType: grant.principalKind === "service" ? ("service" as const) : ("human" as const),
103
117
  };
104
118
  const externalAuthorize = externalContinuationCommitAuthorizer(authorization);
119
+ let authorizedGrant = grant;
105
120
  try {
106
121
  return await rotateSessionMcpCredentialsAtomically(deps.db, {
107
122
  ...scope,
108
123
  operationKey: request.operationKey,
109
124
  ...sessionMcpRotationFingerprint(encryptionKey, scope, request),
110
- updates: request.updates.map(({ headers, ...update }) => ({
111
- ...update,
112
- headersEncrypted: Object.fromEntries(
113
- Object.entries(headers).map(([name, value]) => [
114
- name,
115
- encryptVariableSetValue(encryptionKey, value),
116
- ]),
117
- ),
118
- })),
125
+ updates: request.updates.map((update) =>
126
+ "nativeConnectionId" in update
127
+ ? update
128
+ : {
129
+ id: update.id,
130
+ expectedCredentialVersion: update.expectedCredentialVersion,
131
+ expectedServerUrl: update.expectedServerUrl,
132
+ headersEncrypted: Object.fromEntries(
133
+ Object.entries(update.headers).map(([name, value]) => [
134
+ name,
135
+ encryptVariableSetValue(encryptionKey, value),
136
+ ]),
137
+ ),
138
+ },
139
+ ),
140
+ resolveNativeConnection: async (tx, server, nativeConnectionId, replacementServerUrl) => {
141
+ const connections = await listOwnConnectionAccountsForGrant(tx, authorizedGrant);
142
+ const source = personalConnectionDelegationSourceForGrant(authorizedGrant);
143
+ try {
144
+ return nativeSessionConnectionReplacement({
145
+ accountId: grant.accountId,
146
+ workspaceId: grant.workspaceId,
147
+ subjectId: source.kind === "subject" ? source.subjectId : null,
148
+ serverUrl: server.url,
149
+ ...(replacementServerUrl ? { replacementServerUrl } : {}),
150
+ currentRef: server.connectionRef,
151
+ nativeConnectionId,
152
+ connections,
153
+ });
154
+ } catch {
155
+ throw new SessionMcpCredentialRotationError("connection_unavailable");
156
+ }
157
+ },
119
158
  authorize: async (tx) => {
120
159
  await externalAuthorize?.(tx);
121
160
  const fresh = await reauthorize(tx);
@@ -145,6 +184,7 @@ export async function rotateSessionMcpCredentialsForRequest(
145
184
  operation: "session.mcp.credentials.rotate",
146
185
  surface: "http",
147
186
  });
187
+ authorizedGrant = fresh;
148
188
  },
149
189
  });
150
190
  } catch (error) {
@@ -162,7 +202,9 @@ export async function rotateSessionMcpCredentialsForRequest(
162
202
  ? 403
163
203
  : error.code === "not_found"
164
204
  ? 404
165
- : error.code === "invalid_request" || error.code === "brokered_server"
205
+ : error.code === "invalid_request" ||
206
+ error.code === "brokered_server" ||
207
+ error.code === "connection_unavailable"
166
208
  ? 422
167
209
  : 409;
168
210
  throw new HTTPException(status, { message: `credential_rotation_${error.code}` });
@@ -25,6 +25,26 @@ import type { ApiRouteDeps } from "../dependencies";
25
25
 
26
26
  export type LimitDependencies = Pick<ApiRouteDeps, "db" | "settings">;
27
27
 
28
+ /** Deterministic/local embeddings never debit credits, even in paid mode. */
29
+ export function paidDocumentEmbedding(settings: Settings): boolean {
30
+ return (
31
+ settings.documentEmbeddingBillingMode === "credits" &&
32
+ settings.documentEmbeddingProvider === "openai"
33
+ );
34
+ }
35
+
36
+ /** Round up each provider request so a positive input cannot become a free debit. */
37
+ export function documentEmbeddingCostMicros(settings: Settings, inputBytes: number): number {
38
+ if (!Number.isSafeInteger(inputBytes) || inputBytes < 0)
39
+ throw new Error("invalid embedding bytes");
40
+ const amount =
41
+ (BigInt(inputBytes) * BigInt(settings.documentEmbeddingRateMicrosPerMillionBytes) + 999_999n) /
42
+ 1_000_000n;
43
+ if (amount > BigInt(Number.MAX_SAFE_INTEGER))
44
+ throw new Error("embedding cost exceeds safe integer");
45
+ return Number(amount);
46
+ }
47
+
28
48
  export type LimitCheckInput = {
29
49
  accountId: string;
30
50
  workspaceId?: string;
@@ -296,9 +316,10 @@ function usesCreditLimits(deps: LimitDependencies): boolean {
296
316
  }
297
317
 
298
318
  function isCostlyAction(action: LimitAction): boolean {
299
- return (
300
- action === "agent_run:create" || action === "tokens:consume" || action === "document:index"
301
- );
319
+ // Document admission retains the monthly chunk quota above, but parsing and
320
+ // source retention do not consume embedding credits. The worker admits paid
321
+ // embedding separately and settles its actual provider input after use.
322
+ return action === "agent_run:create" || action === "tokens:consume";
302
323
  }
303
324
 
304
325
  function blocked(code: string, message: string): LimitDecision {