@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/dist/default-session-model.d.ts +4 -3
- package/dist/dependencies.d.ts +2 -0
- package/dist/domain/connector-tool-permissions.d.ts +5 -1
- package/dist/domain/direct-model-provider.d.ts +3 -0
- package/dist/domain/insights.d.ts +11 -2
- package/dist/domain/knowledge-messages.d.ts +2 -2
- package/dist/domain/pr-review.d.ts +4 -4
- package/dist/domain/session-tool-policy.d.ts +7 -2
- package/dist/domain/sessions.d.ts +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +662 -486
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/src/access/index.ts +10 -3
- package/src/default-session-model.ts +19 -4
- package/src/dependencies.ts +2 -0
- package/src/domain/connector-tool-permissions.ts +40 -10
- package/src/domain/direct-model-provider.ts +62 -0
- package/src/domain/insights.ts +91 -8
- package/src/domain/scheduled-tasks.ts +2 -3
- package/src/domain/session-tool-policy.ts +27 -1
- package/src/domain/sessions.ts +23 -18
- package/src/index.ts +1 -0
- package/src/model-catalog.ts +29 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/core",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
54
|
-
"@opengeni/codex": "^0.2.
|
|
55
|
-
"@opengeni/config": "^3.
|
|
56
|
-
"@opengeni/contracts": "^5.
|
|
57
|
-
"@opengeni/db": "^6.
|
|
58
|
-
"@opengeni/documents": "^0.8.
|
|
59
|
-
"@opengeni/events": "^0.4.
|
|
60
|
-
"@opengeni/network": "^0.3.1",
|
|
61
|
-
"@opengeni/observability": "^0.8.
|
|
62
|
-
"@opengeni/runtime": "^4.
|
|
63
|
-
"@opengeni/storage": "^0.2.
|
|
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
|
},
|
package/src/access/index.ts
CHANGED
|
@@ -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
|
|
961
|
-
|
|
962
|
-
|
|
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.
|
|
159
|
-
* deployment default is already a selectable credits-billed model
|
|
160
|
-
*
|
|
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
|
package/src/dependencies.ts
CHANGED
|
@@ -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 (
|
|
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:
|
|
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
|
+
}
|
package/src/domain/insights.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
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 (
|
|
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) &&
|
package/src/domain/sessions.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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";
|