@opengeni/core 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/billing/agent-run-admission.d.ts +17 -0
- package/dist/dependencies.d.ts +3 -1
- package/dist/domain/knowledge-messages.d.ts +2 -2
- package/dist/goal-admission.d.ts +480 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +245 -34
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/src/application/session-tenancy.ts +4 -8
- package/src/application/user-resource-grants.ts +0 -13
- package/src/billing/agent-run-admission.ts +99 -0
- package/src/dependencies.ts +3 -0
- package/src/domain/session-tool-policy.ts +14 -5
- package/src/domain/sessions.ts +11 -6
- package/src/goal-admission.ts +187 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/core",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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": "1.0
|
|
54
|
-
"@opengeni/codex": "1.0
|
|
55
|
-
"@opengeni/config": "1.0
|
|
56
|
-
"@opengeni/contracts": "1.0
|
|
57
|
-
"@opengeni/db": "1.0
|
|
58
|
-
"@opengeni/documents": "1.0
|
|
59
|
-
"@opengeni/events": "1.0
|
|
60
|
-
"@opengeni/network": "1.0
|
|
61
|
-
"@opengeni/observability": "1.0
|
|
62
|
-
"@opengeni/runtime": "1.0
|
|
63
|
-
"@opengeni/storage": "1.0
|
|
53
|
+
"@opengeni/capabilities": "1.1.0",
|
|
54
|
+
"@opengeni/codex": "1.1.0",
|
|
55
|
+
"@opengeni/config": "1.1.0",
|
|
56
|
+
"@opengeni/contracts": "1.1.0",
|
|
57
|
+
"@opengeni/db": "1.1.0",
|
|
58
|
+
"@opengeni/documents": "1.1.0",
|
|
59
|
+
"@opengeni/events": "1.1.0",
|
|
60
|
+
"@opengeni/network": "1.1.0",
|
|
61
|
+
"@opengeni/observability": "1.1.0",
|
|
62
|
+
"@opengeni/runtime": "1.1.0",
|
|
63
|
+
"@opengeni/storage": "1.1.0",
|
|
64
64
|
"hono": "^4.12.18",
|
|
65
65
|
"zod": "^4.2.1"
|
|
66
66
|
},
|
|
@@ -19,10 +19,8 @@ import {
|
|
|
19
19
|
isRetryableDatabaseTransportFailure,
|
|
20
20
|
nestedPostgresSqlState,
|
|
21
21
|
replayAppliedSessionFork,
|
|
22
|
-
sessionTenancyProductActivated,
|
|
23
22
|
SessionTenancyAccessError,
|
|
24
23
|
SessionTenancyInvalidRequestError,
|
|
25
|
-
SessionTenancyNotActivatedError,
|
|
26
24
|
transitionSessionVisibility,
|
|
27
25
|
type Database,
|
|
28
26
|
type ForkSessionContentResult,
|
|
@@ -101,9 +99,10 @@ export async function getManagedHumanSessionCreateCapabilities(
|
|
|
101
99
|
}
|
|
102
100
|
throw error;
|
|
103
101
|
}
|
|
104
|
-
// Mirror the database create fence exactly:
|
|
105
|
-
//
|
|
106
|
-
//
|
|
102
|
+
// Mirror the database create fence exactly: every organization is
|
|
103
|
+
// session-tenancy activated (migration 0611), so a member's own Personal
|
|
104
|
+
// workspace is always private-capable, while a shared organization workspace
|
|
105
|
+
// additionally needs the owner/admin Only-me product setting.
|
|
107
106
|
let activated = false;
|
|
108
107
|
try {
|
|
109
108
|
const policy = await getPrivateSessionCreatePolicy(deps.db, {
|
|
@@ -153,9 +152,6 @@ async function requireSessionTenancyMutationGate(
|
|
|
153
152
|
// missing, shared, or another owner's private session.
|
|
154
153
|
requireVerifiedOwningUser(authorization, workspaceId);
|
|
155
154
|
for (const permission of permissions) requirePermission(authorization.grant, permission);
|
|
156
|
-
if (!(await sessionTenancyProductActivated(deps.db, workspaceId))) {
|
|
157
|
-
throw new SessionTenancyNotActivatedError();
|
|
158
|
-
}
|
|
159
155
|
}
|
|
160
156
|
|
|
161
157
|
async function publishExactCommittedEvent(
|
|
@@ -8,8 +8,6 @@ import {
|
|
|
8
8
|
issueSelfUserResourceGrant,
|
|
9
9
|
listSelfUserResourceAuthorities,
|
|
10
10
|
revokeSelfUserResourceGrant,
|
|
11
|
-
sessionTenancyProductActivated,
|
|
12
|
-
SessionTenancyNotActivatedError,
|
|
13
11
|
} from "@opengeni/db";
|
|
14
12
|
import { requirePermission, type AccessGrantAuthorization } from "../access";
|
|
15
13
|
import type { AppDependencies } from "../dependencies";
|
|
@@ -46,9 +44,6 @@ async function requireOwnerProductGate(
|
|
|
46
44
|
// Target-free gates must run before an optional target-session lookup or host
|
|
47
45
|
// callback so rejected principals cannot use grant management as an oracle.
|
|
48
46
|
requireOwnerAuthority(authorization, workspaceId, permissions);
|
|
49
|
-
if (!(await sessionTenancyProductActivated(deps.db, workspaceId))) {
|
|
50
|
-
throw new SessionTenancyNotActivatedError();
|
|
51
|
-
}
|
|
52
47
|
}
|
|
53
48
|
|
|
54
49
|
function requireOwnerAuthority(
|
|
@@ -75,14 +70,6 @@ export async function listManagedHumanUserResourceAuthorities(
|
|
|
75
70
|
input: { resourceKind: UserResourceKind; cursor?: string | undefined; limit: number },
|
|
76
71
|
) {
|
|
77
72
|
requireOwnerAuthority(authorization, workspaceId, LIST_PERMISSIONS[input.resourceKind]);
|
|
78
|
-
// Before activation there cannot be usable personal-resource authority in
|
|
79
|
-
// this organization. Discovery therefore has an exact empty answer; only
|
|
80
|
-
// issue/revoke and runtime use remain behind the forward-only product gate.
|
|
81
|
-
// This keeps ordinary workspace-owned Rig/Variable Set selection independent
|
|
82
|
-
// from an optional personal-resource product that is not enabled.
|
|
83
|
-
if (!(await sessionTenancyProductActivated(deps.db, workspaceId))) {
|
|
84
|
-
return { authorities: [], nextCursor: null };
|
|
85
|
-
}
|
|
86
73
|
return await listSelfUserResourceAuthorities(deps.db, {
|
|
87
74
|
accountId: authorization.grant.accountId,
|
|
88
75
|
workspaceId,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { configuredStaticUsageLimits, type Settings } from "@opengeni/config";
|
|
2
|
+
import type { EntitlementsPort } from "@opengeni/contracts";
|
|
3
|
+
import { modelFundingForAdmission } from "./limits";
|
|
4
|
+
import {
|
|
5
|
+
checkWorkspaceAllowance,
|
|
6
|
+
getBillingBalance,
|
|
7
|
+
isCodexBilledTurn,
|
|
8
|
+
sumUsageQuantity,
|
|
9
|
+
type Database,
|
|
10
|
+
} from "@opengeni/db";
|
|
11
|
+
|
|
12
|
+
export type AgentRunAdmissionDenial =
|
|
13
|
+
| "insufficient_credits"
|
|
14
|
+
| "allowance_exhausted"
|
|
15
|
+
| "monthly_model_cost_limit"
|
|
16
|
+
| "monthly_agent_run_limit";
|
|
17
|
+
|
|
18
|
+
/** Read-only admission shared by service-authored runs and goal Resume. */
|
|
19
|
+
export async function agentRunAdmissionDenial(
|
|
20
|
+
services: { db: Database; settings: Settings; entitlements?: EntitlementsPort | null },
|
|
21
|
+
input: {
|
|
22
|
+
accountId: string;
|
|
23
|
+
workspaceId: string;
|
|
24
|
+
model: string;
|
|
25
|
+
requestedAgentRuns: number;
|
|
26
|
+
/** The accepted work's causal human, not the scheduler/service caller. */
|
|
27
|
+
initiatingHumanSubjectId?: string | null;
|
|
28
|
+
},
|
|
29
|
+
): Promise<AgentRunAdmissionDenial | null> {
|
|
30
|
+
const codexBilled = await isCodexBilledTurn({
|
|
31
|
+
db: services.db,
|
|
32
|
+
settings: services.settings,
|
|
33
|
+
workspaceId: input.workspaceId,
|
|
34
|
+
model: input.model,
|
|
35
|
+
});
|
|
36
|
+
const externallyBilled = modelFundingForAdmission(
|
|
37
|
+
services.settings,
|
|
38
|
+
input.model,
|
|
39
|
+
codexBilled,
|
|
40
|
+
).fundedWithoutCredits;
|
|
41
|
+
if (
|
|
42
|
+
!externallyBilled &&
|
|
43
|
+
(services.settings.billingMode === "stripe" || services.settings.usageLimitsMode === "managed")
|
|
44
|
+
) {
|
|
45
|
+
if (services.entitlements) {
|
|
46
|
+
const decision = await services.entitlements.admitRun({
|
|
47
|
+
accountId: input.accountId,
|
|
48
|
+
workspaceId: input.workspaceId,
|
|
49
|
+
action: "agent_run:create",
|
|
50
|
+
quantity: input.requestedAgentRuns,
|
|
51
|
+
});
|
|
52
|
+
if (!decision.allowed) return "insufficient_credits";
|
|
53
|
+
} else {
|
|
54
|
+
const balance = await getBillingBalance(services.db, input.accountId);
|
|
55
|
+
if (balance.balanceMicros <= 0) return "insufficient_credits";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!externallyBilled) {
|
|
59
|
+
const refusal = await checkWorkspaceAllowance(services.db, {
|
|
60
|
+
accountId: input.accountId,
|
|
61
|
+
workspaceId: input.workspaceId,
|
|
62
|
+
subjectId: input.initiatingHumanSubjectId ?? null,
|
|
63
|
+
});
|
|
64
|
+
if (refusal) return refusal.code;
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
services.settings.usageLimitsMode !== "static" &&
|
|
68
|
+
services.settings.usageLimitsMode !== "managed"
|
|
69
|
+
) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const limits = configuredStaticUsageLimits(services.settings);
|
|
73
|
+
if (!externallyBilled && limits.maxMonthlyCostMicrosPerAccount) {
|
|
74
|
+
const used = await sumUsageQuantity(services.db, {
|
|
75
|
+
accountId: input.accountId,
|
|
76
|
+
eventType: "model.cost",
|
|
77
|
+
since: startOfUtcMonth(),
|
|
78
|
+
});
|
|
79
|
+
if (used >= limits.maxMonthlyCostMicrosPerAccount) {
|
|
80
|
+
return "monthly_model_cost_limit";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (limits.maxMonthlyAgentRunsPerWorkspace) {
|
|
84
|
+
const used = await sumUsageQuantity(services.db, {
|
|
85
|
+
workspaceId: input.workspaceId,
|
|
86
|
+
eventType: "agent_run.created",
|
|
87
|
+
since: startOfUtcMonth(),
|
|
88
|
+
});
|
|
89
|
+
if (used + input.requestedAgentRuns > limits.maxMonthlyAgentRunsPerWorkspace) {
|
|
90
|
+
return "monthly_agent_run_limit";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function startOfUtcMonth(): Date {
|
|
97
|
+
const now = new Date();
|
|
98
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
99
|
+
}
|
package/src/dependencies.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ConnectionCredentialsPort,
|
|
4
4
|
Document,
|
|
5
5
|
DocumentAuthorityKind,
|
|
6
|
+
EntitlementsPort,
|
|
6
7
|
GitHubAppApiPort,
|
|
7
8
|
ScheduledTask,
|
|
8
9
|
ScheduledTaskTriggerType,
|
|
@@ -155,6 +156,8 @@ export type AppDependencies = {
|
|
|
155
156
|
*/
|
|
156
157
|
catalogSourceSettings?: Settings;
|
|
157
158
|
db: Database;
|
|
159
|
+
/** Read-only host funding admission; unset uses the standalone billing ledger. */
|
|
160
|
+
entitlements?: EntitlementsPort | null;
|
|
158
161
|
/**
|
|
159
162
|
* Host-composed editable artifact engine. Standalone startup binds the same
|
|
160
163
|
* native kernel/DB/object-store implementation; embedded hosts may inject an
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
AGENT_SKILL_MANAGE_TOOL_NAMES,
|
|
10
10
|
AUTOMATIC_SESSION_TITLE_FALLBACK,
|
|
11
|
+
bundledSkillSelectionForAgentConfig,
|
|
11
12
|
DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
|
|
12
13
|
FIRST_PARTY_IN_PROCESS_TOOL_NAMES,
|
|
13
14
|
type FirstPartyMcpToolName,
|
|
@@ -395,14 +396,19 @@ export async function workspaceSessionEffectiveToolsContext(
|
|
|
395
396
|
|
|
396
397
|
function sessionHasSkills(session: Session, context: SessionEffectiveToolsContext): boolean {
|
|
397
398
|
if (context.hasWorkspaceSkills || session.skills.length > 0) return true;
|
|
398
|
-
// Undefined is the worker's bundled default. Explicit [] means no bundles
|
|
399
|
-
|
|
399
|
+
// Undefined is the worker's bundled default. Explicit [] means no bundles,
|
|
400
|
+
// and a "none" agent without an explicit list has none (as in the worker).
|
|
401
|
+
const bundledSkillIds = bundledSkillSelectionForAgentConfig(
|
|
402
|
+
session.bundledSkillIds,
|
|
403
|
+
session.agent,
|
|
404
|
+
);
|
|
405
|
+
if (bundledSkillIds === undefined) return true;
|
|
400
406
|
const tools = new Set(
|
|
401
407
|
resolveAgentToolFamilies(session.agent).firstPartyTools(
|
|
402
408
|
allowedFirstPartyMcpToolsForSession(context.settings, session.firstPartyMcpTools),
|
|
403
409
|
),
|
|
404
410
|
);
|
|
405
|
-
return
|
|
411
|
+
return bundledSkillIds.some((id) => {
|
|
406
412
|
if (
|
|
407
413
|
[
|
|
408
414
|
"builtin:opengeni-documents",
|
|
@@ -479,7 +485,7 @@ export function sessionEffectiveToolProjectionInput(
|
|
|
479
485
|
(!session.title?.trim() || session.title.trim() === AUTOMATIC_SESSION_TITLE_FALLBACK);
|
|
480
486
|
const firstPartyMcpTools =
|
|
481
487
|
context && toolRefs.some((ref) => ref.id === "opengeni")
|
|
482
|
-
? resolveAgentToolFamilies(session.agent)
|
|
488
|
+
? resolveAgentToolFamilies(session.agent, { sandboxAttached: sandboxAvailable })
|
|
483
489
|
.firstPartyTools(
|
|
484
490
|
allowedFirstPartyMcpToolsForSession(context.settings, session.firstPartyMcpTools),
|
|
485
491
|
)
|
|
@@ -512,7 +518,9 @@ export function sessionEffectiveToolProjectionInput(
|
|
|
512
518
|
firstPartyModelNames.set(name, `interaction__${name}`);
|
|
513
519
|
if (
|
|
514
520
|
!firstPartyMcpTools.includes(name) &&
|
|
515
|
-
resolveAgentToolFamilies(session.agent
|
|
521
|
+
resolveAgentToolFamilies(session.agent, {
|
|
522
|
+
sandboxAttached: sandboxAvailable,
|
|
523
|
+
}).allowsFirstPartyTool(name) &&
|
|
516
524
|
session.firstPartyMcpTools.includes(name)
|
|
517
525
|
)
|
|
518
526
|
firstPartyMcpTools.push(name);
|
|
@@ -561,6 +569,7 @@ export function sessionEffectiveToolProjectionInput(
|
|
|
561
569
|
routerInHistory:
|
|
562
570
|
context?.routerInHistory === true ||
|
|
563
571
|
context?.routerHistorySessionIds?.has(session.id) === true,
|
|
572
|
+
...(context ? { sandboxAttached: sandboxAvailable } : {}),
|
|
564
573
|
};
|
|
565
574
|
const families = resolveAgentToolFamilies(session.agent, environment);
|
|
566
575
|
environment.hasDeferredTools =
|
package/src/domain/sessions.ts
CHANGED
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
|
|
73
73
|
SessionSkills,
|
|
74
74
|
resolveBundledSkillSelection,
|
|
75
|
+
bundledSkillSelectionForAgentConfig,
|
|
75
76
|
SessionSpawnDenial,
|
|
76
77
|
ServiceTurnInitiator,
|
|
77
78
|
ServiceTurnInitiatorContext,
|
|
@@ -182,7 +183,6 @@ import {
|
|
|
182
183
|
SessionToolPolicyVersionConflictError,
|
|
183
184
|
SessionCreateIdempotencyConflictError,
|
|
184
185
|
PersonalResourceAttachmentAcceptanceError,
|
|
185
|
-
sessionTenancyProductActivated,
|
|
186
186
|
workspaceControlRequestLockTimeoutMs,
|
|
187
187
|
WorkspaceControlBusyError,
|
|
188
188
|
type SessionCommandActor,
|
|
@@ -327,11 +327,6 @@ async function requireAtomicPersonalResourceAttachment(
|
|
|
327
327
|
cause: error,
|
|
328
328
|
});
|
|
329
329
|
}
|
|
330
|
-
if (!(await sessionTenancyProductActivated(deps.db, workspaceId))) {
|
|
331
|
-
throw new HTTPException(409, {
|
|
332
|
-
message: "Session tenancy is not activated for this organization.",
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
330
|
if (existingSession && intent.expectedAuthorityEpoch === undefined) {
|
|
336
331
|
throw new HTTPException(422, {
|
|
337
332
|
message: "Personal-resource attachment requires expectedAuthorityEpoch.",
|
|
@@ -1051,6 +1046,13 @@ export async function createAndStartSessionWithOutcome(input: {
|
|
|
1051
1046
|
allowNestedAgentDepthIncrease?: boolean;
|
|
1052
1047
|
subjectId?: string | null;
|
|
1053
1048
|
}): Promise<CreateSessionOutcome> {
|
|
1049
|
+
const frozenBundledSkillIds = bundledSkillSelectionForAgentConfig(
|
|
1050
|
+
input.bundledSkillIds,
|
|
1051
|
+
input.agentConfig,
|
|
1052
|
+
);
|
|
1053
|
+
if (frozenBundledSkillIds !== input.bundledSkillIds) {
|
|
1054
|
+
input = { ...input, bundledSkillIds: frozenBundledSkillIds };
|
|
1055
|
+
}
|
|
1054
1056
|
const sessionMetadata = metadataWithTurnExecutionPolicyV1(
|
|
1055
1057
|
{
|
|
1056
1058
|
...input.metadata,
|
|
@@ -2545,6 +2547,9 @@ async function createSessionForRequestInFileScope(
|
|
|
2545
2547
|
if (agentResolution.instructions !== undefined) {
|
|
2546
2548
|
payload.instructions = agentResolution.instructions;
|
|
2547
2549
|
}
|
|
2550
|
+
// Freeze what the agent configuration implies before keyed replay compares
|
|
2551
|
+
// it: a `"none"` agent omits the bundled guides unless the request lists them.
|
|
2552
|
+
bundledSkillIds = bundledSkillSelectionForAgentConfig(bundledSkillIds, agentConfig);
|
|
2548
2553
|
const parentCallingTurn =
|
|
2549
2554
|
parentSession && creationInitiator.actor
|
|
2550
2555
|
? await getSessionTurnForAttempt(
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isModelAvailableForNewSelection,
|
|
3
|
+
policyProviderIdForModel,
|
|
4
|
+
resolveModelProvider,
|
|
5
|
+
runnableLatencyModesForModel,
|
|
6
|
+
withCodexCatalogProvider,
|
|
7
|
+
withXaiSubscriptionCatalogProvider,
|
|
8
|
+
type Settings,
|
|
9
|
+
} from "@opengeni/config";
|
|
10
|
+
import {
|
|
11
|
+
evaluateWorkspaceModelPolicy,
|
|
12
|
+
type GoalAdmissionPausedReason,
|
|
13
|
+
type Session,
|
|
14
|
+
} from "@opengeni/contracts";
|
|
15
|
+
import { isCodexBilledModel } from "@opengeni/codex";
|
|
16
|
+
import { getWorkspaceModelPolicy, type Database } from "@opengeni/db";
|
|
17
|
+
import { resolveWorkspaceCatalogSettings } from "./model-catalog";
|
|
18
|
+
import { agentRunAdmissionDenial } from "./billing/agent-run-admission";
|
|
19
|
+
|
|
20
|
+
export type GoalAdmissionBlock = { pausedReason: GoalAdmissionPausedReason; message: string };
|
|
21
|
+
|
|
22
|
+
/** Catalog membership never grants access to a connection or changes the selected model. */
|
|
23
|
+
export function goalContinuationModelDecision(input: {
|
|
24
|
+
settings: Settings;
|
|
25
|
+
workspaceModelPolicy: Awaited<ReturnType<typeof getWorkspaceModelPolicy>>;
|
|
26
|
+
inheritedModel: string;
|
|
27
|
+
codexCompactionMode?: Session["codexCompactionMode"];
|
|
28
|
+
latencyMode?: Session["latencyMode"];
|
|
29
|
+
}): { model: string; blocked: string | null; pausedReason?: GoalAdmissionPausedReason } {
|
|
30
|
+
const catalogSettings = input.settings.supergrokSubscriptionEnabled
|
|
31
|
+
? withXaiSubscriptionCatalogProvider(
|
|
32
|
+
input.settings.codexSubscriptionEnabled
|
|
33
|
+
? withCodexCatalogProvider(input.settings)
|
|
34
|
+
: input.settings,
|
|
35
|
+
)
|
|
36
|
+
: input.settings.codexSubscriptionEnabled
|
|
37
|
+
? withCodexCatalogProvider(input.settings)
|
|
38
|
+
: input.settings;
|
|
39
|
+
const model = input.inheritedModel;
|
|
40
|
+
if (!resolveModelProvider(catalogSettings, model)) {
|
|
41
|
+
return {
|
|
42
|
+
model,
|
|
43
|
+
blocked: "The selected model is unavailable. Choose an available model before resuming.",
|
|
44
|
+
pausedReason: "model_unavailable",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (!isModelAvailableForNewSelection(catalogSettings, model)) {
|
|
48
|
+
return {
|
|
49
|
+
model,
|
|
50
|
+
blocked: "The selected model has been retired. Choose an available model before resuming.",
|
|
51
|
+
pausedReason: "model_unavailable",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
input.workspaceModelPolicy !== null &&
|
|
56
|
+
!evaluateWorkspaceModelPolicy(input.workspaceModelPolicy, {
|
|
57
|
+
providerId: policyProviderIdForModel(catalogSettings, model),
|
|
58
|
+
modelId: model,
|
|
59
|
+
}).allowed
|
|
60
|
+
) {
|
|
61
|
+
return {
|
|
62
|
+
model,
|
|
63
|
+
blocked:
|
|
64
|
+
"Workspace policy blocks the selected model. Choose an allowed model before resuming.",
|
|
65
|
+
pausedReason: "model_policy",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (input.codexCompactionMode === "remote_v2" && !isCodexBilledModel(model)) {
|
|
69
|
+
return {
|
|
70
|
+
model,
|
|
71
|
+
blocked: "This conversation requires a Codex model. Choose a Codex model before resuming.",
|
|
72
|
+
pausedReason: "model_policy",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
!runnableLatencyModesForModel(catalogSettings, model).includes(input.latencyMode ?? "standard")
|
|
77
|
+
) {
|
|
78
|
+
return {
|
|
79
|
+
model,
|
|
80
|
+
blocked:
|
|
81
|
+
"The selected model does not support this conversation's latency mode. Choose a supported mode or model before resuming.",
|
|
82
|
+
pausedReason: "model_policy",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { model, blocked: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The same scoped, secret-free catalog used at ordinary turn admission. */
|
|
89
|
+
export async function resolveGoalModelAdmission(
|
|
90
|
+
db: Database,
|
|
91
|
+
settings: Settings,
|
|
92
|
+
input: {
|
|
93
|
+
accountId: string;
|
|
94
|
+
workspaceId: string;
|
|
95
|
+
model: string;
|
|
96
|
+
codexCompactionMode?: Session["codexCompactionMode"];
|
|
97
|
+
latencyMode?: Session["latencyMode"];
|
|
98
|
+
},
|
|
99
|
+
) {
|
|
100
|
+
const catalog = await resolveWorkspaceCatalogSettings(db, settings, {
|
|
101
|
+
accountId: input.accountId,
|
|
102
|
+
workspaceId: input.workspaceId,
|
|
103
|
+
retainedProductModelId: input.model,
|
|
104
|
+
});
|
|
105
|
+
const workspaceModelPolicy = await getWorkspaceModelPolicy(db, input.workspaceId);
|
|
106
|
+
return {
|
|
107
|
+
settings: catalog.settings,
|
|
108
|
+
...goalContinuationModelDecision({
|
|
109
|
+
settings: catalog.settings,
|
|
110
|
+
workspaceModelPolicy,
|
|
111
|
+
inheritedModel: input.model,
|
|
112
|
+
...(input.codexCompactionMode ? { codexCompactionMode: input.codexCompactionMode } : {}),
|
|
113
|
+
...(input.latencyMode ? { latencyMode: input.latencyMode } : {}),
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Shared with scheduled work; reasons describe the actual admission gate. */
|
|
119
|
+
export async function goalRunBudgetBlocked(
|
|
120
|
+
services: Parameters<typeof agentRunAdmissionDenial>[0],
|
|
121
|
+
input: Omit<Parameters<typeof agentRunAdmissionDenial>[1], "requestedAgentRuns">,
|
|
122
|
+
): Promise<GoalAdmissionBlock | null> {
|
|
123
|
+
const denial = await agentRunAdmissionDenial(services, { ...input, requestedAgentRuns: 1 });
|
|
124
|
+
if (denial === null) return null;
|
|
125
|
+
const blocks: Record<NonNullable<typeof denial>, GoalAdmissionBlock> = {
|
|
126
|
+
insufficient_credits: services.entitlements
|
|
127
|
+
? {
|
|
128
|
+
pausedReason: "usage_policy",
|
|
129
|
+
message: "The application's usage policy blocks another run. Resume when it allows.",
|
|
130
|
+
}
|
|
131
|
+
: {
|
|
132
|
+
pausedReason: "credits",
|
|
133
|
+
message: "Insufficient Opengeni credits. Add credits before resuming.",
|
|
134
|
+
},
|
|
135
|
+
allowance_exhausted: {
|
|
136
|
+
pausedReason: "allowance",
|
|
137
|
+
message: "Opengeni usage allowance exhausted. Resume when your allowance is available.",
|
|
138
|
+
},
|
|
139
|
+
monthly_model_cost_limit: {
|
|
140
|
+
pausedReason: "budget",
|
|
141
|
+
message: "Monthly model spending limit reached. Resume when the spending limit allows.",
|
|
142
|
+
},
|
|
143
|
+
monthly_agent_run_limit: {
|
|
144
|
+
pausedReason: "usage_limit",
|
|
145
|
+
message: "Monthly agent run limit reached. Resume when the run limit allows.",
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
return blocks[denial];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class GoalResumeBlockedError extends Error {
|
|
152
|
+
constructor(
|
|
153
|
+
readonly pausedReason: GoalAdmissionPausedReason,
|
|
154
|
+
message: string,
|
|
155
|
+
) {
|
|
156
|
+
super(message);
|
|
157
|
+
this.name = "GoalResumeBlockedError";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Called under the goal transition's locks, before counters, events or wakes change. */
|
|
162
|
+
export async function assertGoalResumeAllowed(
|
|
163
|
+
services: Parameters<typeof agentRunAdmissionDenial>[0] & { catalogSourceSettings?: Settings },
|
|
164
|
+
session: Pick<Session, "accountId" | "workspaceId" | "model" | "codexCompactionMode"> &
|
|
165
|
+
Partial<Pick<Session, "latencyMode">>,
|
|
166
|
+
causalTurn: { initiatingHumanSubjectId: string | null } | null,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
const decision = await resolveGoalModelAdmission(
|
|
169
|
+
services.db,
|
|
170
|
+
services.catalogSourceSettings ?? services.settings,
|
|
171
|
+
session,
|
|
172
|
+
);
|
|
173
|
+
if (decision.blocked) {
|
|
174
|
+
throw new GoalResumeBlockedError(decision.pausedReason!, decision.blocked);
|
|
175
|
+
}
|
|
176
|
+
const block = await goalRunBudgetBlocked(
|
|
177
|
+
{ ...services, settings: decision.settings },
|
|
178
|
+
{
|
|
179
|
+
accountId: session.accountId,
|
|
180
|
+
workspaceId: session.workspaceId,
|
|
181
|
+
model: decision.model,
|
|
182
|
+
// Resuming does not lend the clicking user's allowance or credentials.
|
|
183
|
+
initiatingHumanSubjectId: causalTurn?.initiatingHumanSubjectId ?? null,
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
if (block) throw new GoalResumeBlockedError(block.pausedReason, block.message);
|
|
187
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,7 @@ export {
|
|
|
63
63
|
} from "./managed-session";
|
|
64
64
|
export * from "./transcription";
|
|
65
65
|
export * from "./model-catalog";
|
|
66
|
+
export * from "./goal-admission";
|
|
66
67
|
export * from "./codex-model-availability";
|
|
67
68
|
export * from "./default-session-model";
|
|
68
69
|
|
|
@@ -86,6 +87,7 @@ export * from "./session-authorization";
|
|
|
86
87
|
|
|
87
88
|
// Billing / usage-limit admission (checkLimit / requireLimit / recordWorkspaceUsage).
|
|
88
89
|
export * from "./billing/limits";
|
|
90
|
+
export * from "./billing/agent-run-admission";
|
|
89
91
|
|
|
90
92
|
// Domain layer — the off-HTTP V2 surface (createSessionForRequest,
|
|
91
93
|
|