@opengeni/core 1.1.0 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "1.1.0",
3
+ "version": "1.2.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.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",
53
+ "@opengeni/capabilities": "1.2.0",
54
+ "@opengeni/codex": "1.2.0",
55
+ "@opengeni/config": "1.2.0",
56
+ "@opengeni/contracts": "1.2.0",
57
+ "@opengeni/db": "1.2.0",
58
+ "@opengeni/documents": "1.2.0",
59
+ "@opengeni/events": "1.2.0",
60
+ "@opengeni/network": "1.2.0",
61
+ "@opengeni/observability": "1.2.0",
62
+ "@opengeni/runtime": "1.2.0",
63
+ "@opengeni/storage": "1.2.0",
64
64
  "hono": "^4.12.18",
65
65
  "zod": "^4.2.1"
66
66
  },
@@ -1,9 +1,13 @@
1
- import { configuredStaticUsageLimits, type Settings } from "@opengeni/config";
1
+ import {
2
+ canonicalizeConfiguredModelId,
3
+ configuredStaticUsageLimits,
4
+ type Settings,
5
+ } from "@opengeni/config";
2
6
  import type { EntitlementsPort } from "@opengeni/contracts";
3
7
  import { modelFundingForAdmission } from "./limits";
4
8
  import {
5
9
  checkWorkspaceAllowance,
6
- getBillingBalance,
10
+ getSpendableCreditBalance,
7
11
  isCodexBilledTurn,
8
12
  sumUsageQuantity,
9
13
  type Database,
@@ -51,7 +55,11 @@ export async function agentRunAdmissionDenial(
51
55
  });
52
56
  if (!decision.allowed) return "insufficient_credits";
53
57
  } else {
54
- const balance = await getBillingBalance(services.db, input.accountId);
58
+ const balance = await getSpendableCreditBalance(
59
+ services.db,
60
+ input.accountId,
61
+ canonicalizeConfiguredModelId(services.settings, input.model),
62
+ );
55
63
  if (balance.balanceMicros <= 0) return "insufficient_credits";
56
64
  }
57
65
  }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  configuredStaticUsageLimits,
3
+ canonicalizeConfiguredModelId,
3
4
  resolveModelProviderForTurn,
4
5
  type Settings,
5
6
  } from "@opengeni/config";
@@ -16,7 +17,7 @@ import {
16
17
  countActiveOrganizationApiKeysForAccount,
17
18
  countScheduledTasksForWorkspace,
18
19
  countWorkspacesForAccount,
19
- getBillingBalance,
20
+ getSpendableCreditBalance,
20
21
  isCodexBilledTurn,
21
22
  recordUsageEvent,
22
23
  sumUsageQuantity,
@@ -148,11 +149,21 @@ async function checkCreditBalance(
148
149
  if (!usesCreditLimits(deps) || !isCostlyAction(input.action)) {
149
150
  return { allowed: true };
150
151
  }
151
- const balance = await getBillingBalance(deps.db, input.accountId);
152
+ const balance = await getSpendableCreditBalance(
153
+ deps.db,
154
+ input.accountId,
155
+ input.model ? canonicalizeConfiguredModelId(deps.settings, input.model) : undefined,
156
+ );
152
157
  if (balance.balanceMicros > 0) {
153
158
  return { allowed: true };
154
159
  }
155
- return { allowed: false, code: "insufficient_credits", message: "insufficient Opengeni credits" };
160
+ return {
161
+ allowed: false,
162
+ code: "insufficient_credits",
163
+ message: input.model
164
+ ? "No credits available for this model. Choose a model covered by your promotional credits or add credits."
165
+ : "No general credits available. Promotional credits cover eligible models only.",
166
+ };
156
167
  }
157
168
 
158
169
  async function checkStaticCaps(
@@ -13,6 +13,7 @@ import {
13
13
  type WorkspaceSessionDefaults,
14
14
  type XaiProviderAccountAuthoritySnapshotV1,
15
15
  type ClaudeProviderAccountAuthoritySnapshotV1,
16
+ type BillingBalance,
16
17
  } from "@opengeni/contracts";
17
18
  import {
18
19
  getScheduledTaskXaiProviderAccountAuthoritySnapshot,
@@ -30,7 +31,8 @@ import {
30
31
  workspaceCodexSubscriptionActive,
31
32
  workspaceOpenRouterConnectionActive,
32
33
  workspaceVercelAiGatewayConnectionActive,
33
- organizationHoldsCredits,
34
+ getBillingBalance,
35
+ spendableCreditMicros,
34
36
  workspaceXaiSubscriptionActive,
35
37
  workspaceXaiSubscriptionActiveForAuthority,
36
38
  XaiAuthorityPoolInactiveError,
@@ -112,16 +114,22 @@ export type DefaultSessionModelInput = {
112
114
  * `organizationHoldsCredits`).
113
115
  */
114
116
  creditsAvailable: boolean;
117
+ creditBalance?: BillingBalance;
115
118
  };
116
119
 
117
120
  function creditsCandidate(
118
- input: Pick<DefaultSessionModelInput, "settings" | "selections">,
121
+ input: Pick<DefaultSessionModelInput, "settings" | "selections" | "creditBalance">,
119
122
  ): DefaultModelSelection | null {
123
+ const selections = input.creditBalance
124
+ ? input.selections.filter(
125
+ (selection) => spendableCreditMicros(input.creditBalance!, selection.model.id) > 0,
126
+ )
127
+ : input.selections;
120
128
  const fallbackEffort = input.settings.openaiReasoningEffort;
121
- const deployment = findSelection(input.selections, input.settings.openaiModel);
122
- const configured = findSelection(input.selections, input.settings.creditsDefaultModel);
129
+ const deployment = findSelection(selections, input.settings.openaiModel);
130
+ const configured = findSelection(selections, input.settings.creditsDefaultModel);
123
131
  if (deployment?.availability.selectable && deployment.model.cost === "credits") {
124
- // An operator's paid deployment default is never replaced. When it is the
132
+ // A funded paid deployment default is preserved. When it is the
125
133
  // credits default model itself, credit holders get the credits default
126
134
  // effort rather than the deployment-wide fallback effort.
127
135
  if (configured?.model.id !== deployment.model.id) return null;
@@ -146,7 +154,7 @@ function creditsCandidate(
146
154
  source: "credits",
147
155
  };
148
156
  }
149
- const first = input.selections.find(
157
+ const first = selections.find(
150
158
  (selection) => selection.availability.selectable && selection.model.cost === "credits",
151
159
  );
152
160
  return first
@@ -175,8 +183,8 @@ function creditsCandidate(
175
183
  * configured credits default (`OPENGENI_CREDITS_DEFAULT_MODEL`, effort
176
184
  * clamped to what the model supports), or the first selectable
177
185
  * credits-billed model when that one is not selectable. When the
178
- * deployment default is already a selectable credits-billed model it is
179
- * never replaced: it keeps the deployment effort, except that it takes the
186
+ * deployment default is already a funded, selectable credits-billed model it
187
+ * keeps the deployment effort, except that it takes the
180
188
  * credits default effort when it is the credits default model itself.
181
189
  * 4. `deployment`: the deployment default with the deployment reasoning effort
182
190
  * when stably admissible; otherwise the first stably admissible catalog
@@ -247,6 +255,7 @@ export function creditsDefaultSessionModel(input: {
247
255
  settings: Settings;
248
256
  selections: readonly WorkspaceModelSelection[];
249
257
  workspaceSettings: unknown;
258
+ creditBalance?: BillingBalance;
250
259
  }): DefaultModelSelection | null {
251
260
  if (input.settings.billingMode !== "stripe") return null;
252
261
  return selectDefaultSessionModel({
@@ -254,6 +263,7 @@ export function creditsDefaultSessionModel(input: {
254
263
  selections: input.selections,
255
264
  workspaceDefaults: resolveWorkspaceSessionDefaults(input.workspaceSettings),
256
265
  creditsAvailable: true,
266
+ ...(input.creditBalance ? { creditBalance: input.creditBalance } : {}),
257
267
  });
258
268
  }
259
269
 
@@ -279,13 +289,14 @@ export async function resolveDefaultSessionModelForSelections(
279
289
  if (
280
290
  withoutCredits.source !== "deployment" ||
281
291
  input.settings.billingMode !== "stripe" ||
282
- creditsCandidate(decision) === null
292
+ !input.selections.some(
293
+ (selection) => selection.availability.selectable && selection.model.cost === "credits",
294
+ )
283
295
  ) {
284
296
  return withoutCredits;
285
297
  }
286
- return (await organizationHoldsCredits(db, input.accountId))
287
- ? selectDefaultSessionModel({ ...decision, creditsAvailable: true })
288
- : withoutCredits;
298
+ const creditBalance = await getBillingBalance(db, input.accountId);
299
+ return selectDefaultSessionModel({ ...decision, creditsAvailable: true, creditBalance });
289
300
  }
290
301
 
291
302
  export type WorkspaceModelSelectionContext = {
@@ -40,7 +40,7 @@ const usage = mock(
40
40
  );
41
41
  mock.module("@opengeni/db", () => ({
42
42
  listKnowledgeEntries: list,
43
- getBillingBalance: async () => ({ balanceMicros: balance }),
43
+ getSpendableCreditBalance: async () => ({ balanceMicros: balance }),
44
44
  applyCreditDebitAfterUse: debits,
45
45
  checkWorkspaceAllowance: allowanceChecks,
46
46
  creditDebitAttributionForTurn: async (_db: unknown, input: { turnId: string }) => ({
@@ -4,7 +4,7 @@ import {
4
4
  applyCreditDebitAfterUse,
5
5
  checkWorkspaceAllowance,
6
6
  creditDebitAttributionForTurn,
7
- getBillingBalance,
7
+ getSpendableCreditBalance,
8
8
  listKnowledgeEntries,
9
9
  recordUsageEvent,
10
10
  sumUsageQuantity,
@@ -190,7 +190,7 @@ export async function searchKnowledgeEntries(
190
190
  context.accountId,
191
191
  context.workspaceId,
192
192
  async (lockedDb) => {
193
- const balance = await getBillingBalance(lockedDb, context.accountId);
193
+ const balance = await getSpendableCreditBalance(lockedDb, context.accountId);
194
194
  if (balance.balanceMicros <= 0)
195
195
  return keywordFallback(lockedDb, new KnowledgeVectorFundingError(), "awaiting_funding");
196
196
  const attribution =