@opengeni/core 5.1.1 → 5.2.0-canary.37090756480001

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": "5.1.1",
3
+ "version": "5.2.0-canary.37090756480001",
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.5",
54
- "@opengeni/codex": "^0.2.31",
55
- "@opengeni/config": "^3.2.1",
56
- "@opengeni/contracts": "^5.6.0",
57
- "@opengeni/db": "^6.5.0",
58
- "@opengeni/documents": "^0.8.40",
59
- "@opengeni/events": "^0.4.38",
60
- "@opengeni/network": "^0.3.1",
61
- "@opengeni/observability": "^0.8.37",
62
- "@opengeni/runtime": "^4.6.0",
63
- "@opengeni/storage": "^0.2.138",
53
+ "@opengeni/capabilities": "^0.3.6-canary.37090756480001",
54
+ "@opengeni/codex": "^0.2.32-canary.37090756480001",
55
+ "@opengeni/config": "^3.3.0-canary.37090756480001",
56
+ "@opengeni/contracts": "^5.7.0-canary.37090756480001",
57
+ "@opengeni/db": "^6.6.0-canary.37090756480001",
58
+ "@opengeni/documents": "^0.8.41-canary.37090756480001",
59
+ "@opengeni/events": "^0.4.39-canary.37090756480001",
60
+ "@opengeni/network": "^0.3.1-canary.37090756480001",
61
+ "@opengeni/observability": "^0.8.38-canary.37090756480001",
62
+ "@opengeni/runtime": "^4.7.0-canary.37090756480001",
63
+ "@opengeni/storage": "^0.2.139-canary.37090756480001",
64
64
  "hono": "^4.12.18",
65
65
  "zod": "^4.2.1"
66
66
  },
@@ -20,6 +20,7 @@ import {
20
20
  bootstrapWorkspace,
21
21
  ensureManagedAccessForUser,
22
22
  ensureExternalIdentity,
23
+ lockExternalWorkspaceMembershipLifecycle,
23
24
  resolveExternalIdentityLink,
24
25
  managedPersonalWorkspacePermissions,
25
26
  nestedPostgresSqlState,
@@ -957,9 +958,15 @@ async function apiKeyAccessContext(
957
958
  }
958
959
  let identity: ExternalIdentity;
959
960
  try {
960
- identity = await ensureExternalIdentity(deps.db, {
961
- accountId: apiKey.accountId,
962
- ...selection.identity,
961
+ identity = await withAccountRls(deps.db, apiKey.accountId, async (tx) => {
962
+ // Provisioning locks the live membership after its identity lock.
963
+ // Claim the lifecycle prefix first so a membership-fenced session
964
+ // writer can recheck that identity without a lock inversion.
965
+ await lockExternalWorkspaceMembershipLifecycle(tx, apiKey.accountId);
966
+ return await ensureExternalIdentity(tx, {
967
+ accountId: apiKey.accountId,
968
+ ...selection.identity,
969
+ });
963
970
  });
964
971
  } catch (error) {
965
972
  if (nestedPostgresSqlState(error) === "42501") {
@@ -114,8 +114,22 @@ function creditsCandidate(
114
114
  ): DefaultModelSelection | null {
115
115
  const fallbackEffort = input.settings.openaiReasoningEffort;
116
116
  const deployment = findSelection(input.selections, input.settings.openaiModel);
117
- if (deployment?.availability.selectable && deployment.model.cost === "credits") return null;
118
117
  const configured = findSelection(input.selections, input.settings.creditsDefaultModel);
118
+ if (deployment?.availability.selectable && deployment.model.cost === "credits") {
119
+ // An operator's paid deployment default is never replaced. When it is the
120
+ // credits default model itself, credit holders get the credits default
121
+ // effort rather than the deployment-wide fallback effort.
122
+ if (configured?.model.id !== deployment.model.id) return null;
123
+ return {
124
+ model: deployment.model.id,
125
+ reasoningEffort: clampReasoningEffortForConfiguredModel(
126
+ deployment.model,
127
+ input.settings.creditsDefaultReasoningEffort,
128
+ fallbackEffort,
129
+ ),
130
+ source: "credits",
131
+ };
132
+ }
119
133
  if (configured?.availability.selectable && configured.model.cost === "credits") {
120
134
  return {
121
135
  model: configured.model.id,
@@ -155,9 +169,10 @@ function creditsCandidate(
155
169
  * balance (a purchase, a grant, or the verified-signup trial grant), the
156
170
  * configured credits default (`OPENGENI_CREDITS_DEFAULT_MODEL`, effort
157
171
  * clamped to what the model supports), or the first selectable
158
- * credits-billed model when that one is not selectable. Skipped when the
159
- * deployment default is already a selectable credits-billed model, so an
160
- * operator's paid default is never replaced.
172
+ * credits-billed model when that one is not selectable. When the
173
+ * deployment default is already a selectable credits-billed model it is
174
+ * never replaced: it keeps the deployment effort, except that it takes the
175
+ * credits default effort when it is the credits default model itself.
161
176
  * 4. `deployment`: the deployment default with the deployment reasoning effort
162
177
  * when stably admissible; otherwise the first stably admissible catalog
163
178
  * model with its own default effort. With no admitted models, retain the
@@ -206,6 +206,8 @@ export type AppDependencies = {
206
206
  managedEmailTransport?: ManagedEmailTransport;
207
207
  /** Injectable Codex HTTP transport for deterministic API/provider tests. */
208
208
  codexFetch?: typeof fetch;
209
+ /** Injectable transport for customer OpenAI/Azure connection checks. */
210
+ directModelFetch?: typeof fetch;
209
211
  /** Injectable GitHub transport for deterministic personal-OAuth tests. */
210
212
  githubPersonalFetch?: typeof fetch;
211
213
  /** Injectable credential-free GitHub transport for public repository verification tests. */
@@ -15,6 +15,8 @@ import type {
15
15
  ConnectorToolPermissionEntry,
16
16
  ConnectorToolPermissionsResponse,
17
17
  UpdateConnectorToolPermissionsRequest,
18
+ ConnectionMetadata,
19
+ McpServerConnectionRef,
18
20
  } from "@opengeni/contracts";
19
21
  import {
20
22
  buildConnectionTokenResolver,
@@ -59,6 +61,34 @@ export function connectorToolGroup(tool: ListedTool): "read" | "write" | "other"
59
61
  return "other";
60
62
  }
61
63
 
64
+ /** A selector can supply discovery credentials only when one visible account
65
+ * matches. Never apply one account's approval choices to an arbitrary sibling. */
66
+ export function unpinnedConnectorToolPermissionAccount<T extends ConnectionMetadata>(
67
+ ref: McpServerConnectionRef,
68
+ visible: T[],
69
+ subjectId: string,
70
+ ): T | null {
71
+ const matches = visible.filter(
72
+ (candidate) =>
73
+ candidate.subjectId === (ref.subjectScope === "subject" ? subjectId : null) &&
74
+ candidate.providerDomain === ref.providerDomain &&
75
+ (!ref.kind || candidate.kind === ref.kind) &&
76
+ candidate.status === "active",
77
+ );
78
+ if (matches.length > 1)
79
+ throw new HTTPException(409, {
80
+ message: "Choose one account to manage its tool permissions",
81
+ });
82
+ return matches[0] ?? null;
83
+ }
84
+
85
+ export function connectorToolPermissionReference(
86
+ ref: McpServerConnectionRef,
87
+ connectionId: string,
88
+ ): McpServerConnectionRef {
89
+ return { ...ref, accountSelection: undefined, connectionId };
90
+ }
91
+
62
92
  async function resolveTarget(input: Input) {
63
93
  if (input.capabilityId.startsWith("api:")) {
64
94
  // API Integrations are not MCP connectors: their per-tool approval is part
@@ -97,20 +127,17 @@ async function resolveTarget(input: Input) {
97
127
  input.grant.subjectId,
98
128
  )
99
129
  : null;
100
- if (ref?.subjectScope === "subject" && !ref.connectionId) {
130
+ if (
131
+ ref &&
132
+ !ref.connectionId &&
133
+ (ref.subjectScope === "subject" || ref.accountSelection === "all_eligible")
134
+ ) {
101
135
  const visible = await listConnectionsMetadata(
102
136
  input.db,
103
137
  input.workspaceId,
104
138
  input.grant.subjectId,
105
139
  );
106
- connection =
107
- visible.find(
108
- (candidate) =>
109
- candidate.subjectId === input.grant.subjectId &&
110
- candidate.providerDomain === ref.providerDomain &&
111
- (!ref.kind || candidate.kind === ref.kind) &&
112
- candidate.status === "active",
113
- ) ?? null;
140
+ connection = unpinnedConnectorToolPermissionAccount(ref, visible, input.grant.subjectId);
114
141
  }
115
142
  if (
116
143
  ref &&
@@ -155,7 +182,10 @@ async function listTools(
155
182
  ...(target.connection?.subjectId ? { subjectId: input.grant.subjectId } : {}),
156
183
  serverId: target.server.id,
157
184
  toolName: "tools/list",
158
- connectionRef: { ...target.server.connectionRef, connectionId: target.connectionId },
185
+ connectionRef: connectorToolPermissionReference(
186
+ target.server.connectionRef,
187
+ target.connectionId,
188
+ ),
159
189
  destinationUrl: target.server.url,
160
190
  });
161
191
  if (result.status !== "ok") throw new Error("Reconnect this connector to load its tools.");
@@ -0,0 +1,62 @@
1
+ import { DirectModelProviderMetadata } from "@opengeni/contracts";
2
+ import { HTTPException } from "hono/http-exception";
3
+
4
+ /** Verify the customer's exact route without persisting their key or test response. */
5
+ export async function verifyDirectModelAccess(
6
+ input: DirectModelProviderMetadata,
7
+ apiKey: string,
8
+ fetchImpl: typeof fetch = fetch,
9
+ ): Promise<void> {
10
+ const config = DirectModelProviderMetadata.parse(input);
11
+ const baseUrl = config.provider === "openai" ? "https://api.openai.com/v1" : config.endpoint!;
12
+ let response: Response;
13
+ try {
14
+ response = await fetchImpl(`${baseUrl}/responses`, {
15
+ method: "POST",
16
+ redirect: "error",
17
+ signal: AbortSignal.timeout(15_000),
18
+ headers: {
19
+ "content-type": "application/json",
20
+ ...(config.provider === "openai"
21
+ ? { authorization: `Bearer ${apiKey}` }
22
+ : { "api-key": apiKey }),
23
+ },
24
+ body: JSON.stringify({
25
+ model: config.model,
26
+ input: "Reply OK.",
27
+ max_output_tokens: 32,
28
+ store: false,
29
+ }),
30
+ });
31
+ } catch {
32
+ throw new HTTPException(502, {
33
+ message: "We couldn’t reach your model provider. Check the endpoint and try again.",
34
+ });
35
+ }
36
+ // Never reflect provider responses: they can include credentials or request details.
37
+ if (response.status === 401 || response.status === 403) {
38
+ throw new HTTPException(422, {
39
+ message: "Your provider didn’t accept this API key. Check the key and its permissions.",
40
+ });
41
+ }
42
+ if (response.status === 404 || response.status === 400) {
43
+ throw new HTTPException(422, {
44
+ message:
45
+ config.provider === "azure_openai"
46
+ ? "Azure couldn’t use this deployment. Check the endpoint and deployment name."
47
+ : "OpenAI couldn’t use this model. Check the model name and your account’s access.",
48
+ });
49
+ }
50
+ if (response.status === 429) {
51
+ throw new HTTPException(422, {
52
+ message:
53
+ "Your provider’s quota or rate limit blocked the check. Check your provider billing or try again shortly.",
54
+ });
55
+ }
56
+ const body = response.ok ? await response.json().catch(() => null) : null;
57
+ if (!body || !["completed", "incomplete"].includes(body.status)) {
58
+ throw new HTTPException(502, {
59
+ message: "Your provider couldn’t complete the connection check. Try again shortly.",
60
+ });
61
+ }
62
+ }
@@ -11,6 +11,7 @@ import {
11
11
  countSessionsAttachedToGroups,
12
12
  enumerateUtcDays,
13
13
  enumerateUtcHours,
14
+ getSessionAccessProjection,
14
15
  listFloorSessions,
15
16
  listLiveWarmLeases,
16
17
  listScheduledTasks,
@@ -27,20 +28,35 @@ const MACHINE_HEARTBEAT_FRESH_MS = 120_000;
27
28
  export const WORKSPACE_INSIGHTS_PROVIDER_FILTER_MAX_UTF8_BYTES = 256;
28
29
  export const WORKSPACE_INSIGHTS_MODEL_FILTER_MAX_UTF8_BYTES = 512;
29
30
 
30
- export type WorkspaceInsightsFilterField = "provider" | "model";
31
+ export type WorkspaceInsightsFilterField = "provider" | "model" | "rootSessionId" | "sessionId";
31
32
 
32
33
  export class WorkspaceInsightsFilterValidationError extends Error {
33
34
  readonly field: WorkspaceInsightsFilterField;
34
35
  readonly maxUtf8Bytes: number;
35
36
 
36
- constructor(field: WorkspaceInsightsFilterField, maxUtf8Bytes: number) {
37
- super(`${field} must be at most ${maxUtf8Bytes} UTF-8 bytes`);
37
+ constructor(field: WorkspaceInsightsFilterField, maxUtf8Bytes: number, message?: string) {
38
+ super(message ?? `${field} must be at most ${maxUtf8Bytes} UTF-8 bytes`);
38
39
  this.name = "WorkspaceInsightsFilterValidationError";
39
40
  this.field = field;
40
41
  this.maxUtf8Bytes = maxUtf8Bytes;
41
42
  }
42
43
  }
43
44
 
45
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
46
+
47
+ /** Normalize a session drilldown scope; only exact UUIDs are accepted. */
48
+ export function normalizeWorkspaceInsightsSessionScope(
49
+ value: string | null | undefined,
50
+ field: "rootSessionId" | "sessionId",
51
+ ): string | null {
52
+ const normalized = value?.trim() || null;
53
+ if (normalized === null || normalized === "all") return null;
54
+ if (!UUID_PATTERN.test(normalized)) {
55
+ throw new WorkspaceInsightsFilterValidationError(field, 36, `${field} must be a UUID`);
56
+ }
57
+ return normalized.toLowerCase();
58
+ }
59
+
44
60
  /** Normalize the public filter sentinel and enforce the database authority envelope. */
45
61
  export function normalizeWorkspaceInsightsFilter(
46
62
  value: string | null | undefined,
@@ -63,6 +79,8 @@ export type GetWorkspaceInsightsInput = {
63
79
  range: InsightsRange;
64
80
  provider?: string | null;
65
81
  model?: string | null;
82
+ rootSessionId?: string | null;
83
+ sessionId?: string | null;
66
84
  now?: Date;
67
85
  };
68
86
 
@@ -209,6 +227,23 @@ export function insightsSessionLabel(input: {
209
227
  return `${(input.depth ?? 0) > 0 ? "Agent" : "Session"} ${input.id.slice(0, 8)}`;
210
228
  }
211
229
 
230
+ export function insightsProjectLabel(input: {
231
+ kind: "project" | "other" | "unfiled" | "unavailable";
232
+ name: string | null;
233
+ projects: number;
234
+ }): string {
235
+ switch (input.kind) {
236
+ case "project":
237
+ return input.name?.trim() || "Untitled project";
238
+ case "other":
239
+ return `${input.projects.toLocaleString("en-US")} other ${input.projects === 1 ? "project" : "projects"}`;
240
+ case "unfiled":
241
+ return "No project";
242
+ case "unavailable":
243
+ return "Root session not visible";
244
+ }
245
+ }
246
+
212
247
  export async function getWorkspaceInsights(
213
248
  db: Database,
214
249
  settings: Settings,
@@ -217,13 +252,23 @@ export async function getWorkspaceInsights(
217
252
  ): Promise<WorkspaceInsightsResponse> {
218
253
  const provider = normalizeWorkspaceInsightsFilter(input.provider, "provider");
219
254
  const model = normalizeWorkspaceInsightsFilter(input.model, "model");
255
+ const rootSessionId = normalizeWorkspaceInsightsSessionScope(
256
+ input.rootSessionId,
257
+ "rootSessionId",
258
+ );
259
+ const sessionId = normalizeWorkspaceInsightsSessionScope(input.sessionId, "sessionId");
220
260
  await measureInsightsPhase(observePhase, "require_workspace", () =>
221
261
  requireWorkspace(db, input.workspaceId),
222
262
  );
263
+ const [visibleRootScope, visibleSessionScope] = await Promise.all([
264
+ rootSessionId ? getSessionAccessProjection(db, input.workspaceId, rootSessionId) : null,
265
+ sessionId ? getSessionAccessProjection(db, input.workspaceId, sessionId) : null,
266
+ ]);
223
267
  const now = input.now ?? new Date();
224
268
  const window = resolveRangeWindow(input.range, now);
225
269
  const modelFilterActive = Boolean(provider || model);
226
- const filter = { provider, model };
270
+ const scopeActive = Boolean(rootSessionId || sessionId);
271
+ const filter = { provider, model, rootSessionId, sessionId };
227
272
 
228
273
  const [modelBundle, usageBundle, liveWarm, tasks, depth, floorRows, machinesOnline] =
229
274
  await Promise.all([
@@ -275,10 +320,18 @@ export async function getWorkspaceInsights(
275
320
  factBuckets: factDays,
276
321
  rootDrivers,
277
322
  priorRootDrivers,
323
+ projects,
324
+ privateChats,
325
+ privateChatsTruncated,
278
326
  scheduleFacts,
279
327
  facets,
280
328
  recentCalls,
281
329
  promptContributions,
330
+ dataThrough,
331
+ driverGroups,
332
+ driversTruncated,
333
+ facetsTruncated,
334
+ recentCallsTruncated,
282
335
  } = modelBundle;
283
336
  const {
284
337
  workspaceCreditMicros,
@@ -369,6 +422,8 @@ export async function getWorkspaceInsights(
369
422
  0,
370
423
  );
371
424
  const modelCalls = modelRows.reduce((sum, row) => sum + row.calls, 0);
425
+ const cachedTokens = modelRows.reduce((sum, row) => sum + row.cachedTokens, 0);
426
+ const cacheInputTokens = modelRows.reduce((sum, row) => sum + row.cacheInputTokens, 0);
372
427
  const priorInputTokens = priorModelRows.reduce((sum, row) => sum + row.inputTokens, 0);
373
428
  const priorTotalTokens = priorModelRows.reduce((sum, row) => sum + row.totalTokens, 0);
374
429
  const priorCachedTokens = priorModelRows.reduce((sum, row) => sum + row.cachedTokens, 0);
@@ -397,9 +452,10 @@ export async function getWorkspaceInsights(
397
452
  cacheKnownCalls: 0,
398
453
  calls: 0,
399
454
  };
400
- const modelCostMicros = modelFilterActive
401
- ? facts.costMicros
402
- : (usageBuckets.get(bucket)?.costMicros ?? facts.costMicros);
455
+ const modelCostMicros =
456
+ modelFilterActive || scopeActive
457
+ ? facts.costMicros
458
+ : (usageBuckets.get(bucket)?.costMicros ?? facts.costMicros);
403
459
  return {
404
460
  label: input.range === "today" ? bucket.slice(11) : bucket.slice(5),
405
461
  modelCostUsd: microsToUsd(modelCostMicros),
@@ -452,6 +508,20 @@ export async function getWorkspaceInsights(
452
508
  };
453
509
  });
454
510
 
511
+ const projectRows = projects.map((row) => ({
512
+ id: row.kind === "project" && row.channelId ? `project:${row.channelId}` : row.kind,
513
+ kind: row.kind,
514
+ label: insightsProjectLabel(row),
515
+ projects: row.projects,
516
+ rootSessions: row.rootSessions,
517
+ calls: row.calls,
518
+ creditUsd: microsToUsd(row.pricedCostMicros),
519
+ estimatedProviderUsd: microsToUsd(row.estimatedProviderCostMicros),
520
+ estimatedProviderCostKnownCalls: row.estimatedProviderCostKnownCalls,
521
+ tokens: row.totalTokens,
522
+ cacheHitPct: cacheHitPct(row.cachedTokens, row.cacheInputTokens),
523
+ }));
524
+
455
525
  const scheduleFactById = new Map(scheduleFacts.map((row) => [row.scheduledTaskId, row] as const));
456
526
  const schedules = tasks.map((task) => {
457
527
  const fact = scheduleFactById.get(task.id);
@@ -512,6 +582,9 @@ export async function getWorkspaceInsights(
512
582
  sessions: bucket.sessions,
513
583
  })),
514
584
  drivers,
585
+ projects: projectRows,
586
+ privateChats,
587
+ privateChatsTruncated,
515
588
  schedules,
516
589
  recentCalls: recentCalls.map((row) => ({
517
590
  id: row.id,
@@ -601,7 +674,17 @@ export async function getWorkspaceInsights(
601
674
  billableTokenCap: limits.maxMonthlyTokensPerWorkspace ?? null,
602
675
  agentRunsUsed,
603
676
  agentRunCap: limits.maxMonthlyAgentRunsPerWorkspace ?? null,
604
- modelFilterActive,
677
+ modelFilterActive: modelFilterActive || scopeActive,
678
+ dataThrough: dataThrough?.toISOString() ?? null,
679
+ cacheHitPct: cacheHitPct(cachedTokens, cacheInputTokens),
680
+ scope: {
681
+ rootSessionId: visibleRootScope ? rootSessionId : null,
682
+ sessionId: visibleSessionScope ? sessionId : null,
683
+ },
684
+ driverGroups,
685
+ driversTruncated,
686
+ facetsTruncated,
687
+ recentCallsTruncated,
605
688
  });
606
689
 
607
690
  return { snapshot };
@@ -103,6 +103,7 @@ import {
103
103
  assertWorkspaceModelPolicyAllows,
104
104
  canonicalConfiguredModel,
105
105
  creationInitiatorForGrant,
106
+ modelUnavailableHttpException,
106
107
  settingsWithSessionMcpServerMetadata,
107
108
  } from "./sessions";
108
109
  import {
@@ -155,9 +156,7 @@ function workspaceCustomModelCommitGuard(input: {
155
156
  reference,
156
157
  });
157
158
  if (!active) {
158
- throw new HTTPException(422, {
159
- message: `model is not available: ${input.modelId}`,
160
- });
159
+ throw modelUnavailableHttpException(input.modelId);
161
160
  }
162
161
  };
163
162
  }
@@ -227,6 +227,28 @@ export function resolveTurnToolPolicy(
227
227
  });
228
228
  }
229
229
 
230
+ /** Resolve availability and defaults from the same subject-scoped registry read. */
231
+ export async function workspaceSessionToolPolicyContext(
232
+ db: Database,
233
+ workspaceId: string,
234
+ settings: Settings,
235
+ subjectId?: string,
236
+ ): Promise<{ workspaceServerIds: string[]; workspaceDefaultServerIds: string[] }> {
237
+ const [runtimeSettings, workspace] = await Promise.all([
238
+ settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings, {
239
+ ...(subjectId ? { subjectId } : {}),
240
+ }),
241
+ requireWorkspace(db, workspaceId),
242
+ ]);
243
+ return {
244
+ workspaceServerIds: sortedIds(runtimeSettings.mcpServers.map((server) => server.id)),
245
+ workspaceDefaultServerIds: workspaceSessionToolPolicyDefaultServerIdsFor(
246
+ runtimeSettings.mcpServers,
247
+ workspace.settings,
248
+ ),
249
+ };
250
+ }
251
+
230
252
  /** Current full runtime registry IDs, including configured static servers. */
231
253
  export async function workspaceSessionToolPolicyServerIds(
232
254
  db: Database,
@@ -478,11 +500,15 @@ export function sessionEffectiveToolProjectionInput(
478
500
  "browser_screenshot",
479
501
  "browser_clipboard",
480
502
  "browser_debug",
503
+ "browser_downloads",
481
504
  "computer_targets",
482
505
  "computer_observe",
483
506
  "computer_clipboard",
484
507
  ].includes(name);
485
- if (permissionAllowed(readOnly ? "sessions:read" : "sessions:control")) {
508
+ if (
509
+ permissionAllowed(readOnly ? "sessions:read" : "sessions:control") &&
510
+ (name !== "browser_download_save" || permissionAllowed("files:upload"))
511
+ ) {
486
512
  firstPartyModelNames.set(name, `interaction__${name}`);
487
513
  if (
488
514
  !firstPartyMcpTools.includes(name) &&
@@ -1,3 +1,4 @@
1
+ import { isDirectModelId, ModelUnavailableError } from "@opengeni/contracts";
1
2
  import { acceptSessionFileAttachments } from "@opengeni/db";
2
3
  import { knowledgeContextForAccess } from "./knowledge";
3
4
  import {
@@ -286,6 +287,7 @@ function defaultPolicyExclusions(
286
287
 
287
288
  function isCatalogOverlayModel(modelId: string | null | undefined): boolean {
288
289
  return (
290
+ (modelId != null && isDirectModelId(modelId)) ||
289
291
  modelId?.startsWith(WORKSPACE_GATEWAY_MODEL_ID_PREFIX) === true ||
290
292
  modelId?.startsWith(WORKSPACE_OPENROUTER_MODEL_ID_PREFIX) === true ||
291
293
  modelId?.startsWith(ORGANIZATION_GATEWAY_MODEL_ID_PREFIX) === true ||
@@ -1120,9 +1122,7 @@ export async function createAndStartSessionWithOutcome(input: {
1120
1122
  reference,
1121
1123
  });
1122
1124
  if (!active) {
1123
- throw new HTTPException(422, {
1124
- message: `model is not available: ${input.model}`,
1125
- });
1125
+ throw modelUnavailableHttpException(input.model);
1126
1126
  }
1127
1127
  }
1128
1128
  // Reject an already-stale browser draft before the newly inserted
@@ -1543,6 +1543,17 @@ export function workflowIdForSession(sessionId: string): string {
1543
1543
  return `session-${sessionId}`;
1544
1544
  }
1545
1545
 
1546
+ /**
1547
+ * The one 422 for a model that is not selectable in the live catalog (retired,
1548
+ * removed, or never offered). The message keeps its historical text; the typed
1549
+ * cause lets the API envelope add `details.code: "model_unavailable"` so
1550
+ * clients can ask for another model instead of offering a futile retry.
1551
+ */
1552
+ export function modelUnavailableHttpException(model: string): HTTPException {
1553
+ const cause = new ModelUnavailableError(model);
1554
+ return new HTTPException(422, { message: cause.message, cause });
1555
+ }
1556
+
1546
1557
  /**
1547
1558
  * Reject an explicit model that the host does not expose. The set of usable
1548
1559
  * models is the union surfaced by `configuredAllowedModels` (the built-in
@@ -1578,7 +1589,7 @@ export function canonicalConfiguredModel(
1578
1589
  ) {
1579
1590
  return canonicalModel;
1580
1591
  }
1581
- throw new HTTPException(422, { message: `model is not available: ${model}` });
1592
+ throw modelUnavailableHttpException(model);
1582
1593
  }
1583
1594
  if (configuredAllowedModels(settings).includes(canonicalModel)) {
1584
1595
  return canonicalModel;
@@ -1602,7 +1613,7 @@ export function canonicalConfiguredModel(
1602
1613
  ) {
1603
1614
  return canonicalModel;
1604
1615
  }
1605
- throw new HTTPException(422, { message: `model is not available: ${model}` });
1616
+ throw modelUnavailableHttpException(model);
1606
1617
  }
1607
1618
 
1608
1619
  export function assertConfiguredModel(settings: Settings, model: string | null | undefined): void {
@@ -1976,9 +1987,7 @@ export async function postUserMessageTurn(
1976
1987
  reference,
1977
1988
  });
1978
1989
  if (!active) {
1979
- throw new HTTPException(422, {
1980
- message: `model is not available: ${freshWorkspaceCustomModel}`,
1981
- });
1990
+ throw modelUnavailableHttpException(freshWorkspaceCustomModel);
1982
1991
  }
1983
1992
  },
1984
1993
  }
@@ -2964,7 +2973,7 @@ async function createSessionForRequestInFileScope(
2964
2973
  channelId = channel.id;
2965
2974
  }
2966
2975
  // A spawned worker is causally part of the exact turn that created it. Omitted
2967
- // execution policy fields therefore inherit that calling turn rather than the
2976
+ // model and reasoning fields therefore inherit that calling turn rather than the
2968
2977
  // deployment defaults. This is especially important for Codex subscription
2969
2978
  // managers: falling back to the deployment model would silently move a child
2970
2979
  // onto the OpenGeni-credits billing path. Legacy session-bound grants without
@@ -2998,10 +3007,11 @@ async function createSessionForRequestInFileScope(
2998
3007
  parentSession?.reasoningEffort ??
2999
3008
  resolvedDefault?.reasoningEffort ??
3000
3009
  settings.openaiReasoningEffort;
3001
- const inheritedLatencyMode =
3002
- parentCallingTurn?.latencyMode ?? parentSession?.latencyMode ?? "standard";
3003
3010
  const reasoningEffort = payload.reasoningEffort ?? inheritedReasoningEffort;
3004
- const latencyMode = payload.latencyMode ?? inheritedLatencyMode;
3011
+ // A fresh session never implicitly opts into a faster, higher-cost tier.
3012
+ // This is a fresh-creation default only: replay/repair preserves persisted
3013
+ // policy, and follow-ups use the recipient session's own frozen settings.
3014
+ const latencyMode = payload.latencyMode ?? "standard";
3005
3015
  if (payload.expectedNewSessionDraftRevision !== undefined && payload.rigId === null) {
3006
3016
  throw new HTTPException(409, {
3007
3017
  message: "The submitted session options are not represented by the new-session draft",
@@ -3062,12 +3072,7 @@ async function createSessionForRequestInFileScope(
3062
3072
  : "deployment"
3063
3073
  : "explicit",
3064
3074
  latencyMode,
3065
- latencyModeSource:
3066
- payload.latencyMode === undefined
3067
- ? inheritedFromParent
3068
- ? "continuation"
3069
- : "deployment"
3070
- : "explicit",
3075
+ latencyModeSource: payload.latencyMode === undefined ? "deployment" : "explicit",
3071
3076
  }),
3072
3077
  { grant, authorization, trustedMetadata: [parentSession?.metadata] },
3073
3078
  );
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { readSessionAttachmentFiles } from "./domain/session-file-access";
2
+ export { verifyDirectModelAccess } from "./domain/direct-model-provider";
2
3
  export * from "./domain/skills";
3
4
  export * from "./domain/mcp-account-bindings";
4
5
  export * from "./domain/session-connection-accounts";