@opengeni/core 4.0.1 → 4.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
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": {
@@ -51,16 +51,16 @@
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.29.0",
53
53
  "@opengeni/capabilities": "^0.3.4",
54
- "@opengeni/codex": "^0.2.25",
55
- "@opengeni/config": "^2.1.0",
56
- "@opengeni/contracts": "^5.1.0",
57
- "@opengeni/db": "^6.0.1",
58
- "@opengeni/documents": "^0.8.32",
59
- "@opengeni/events": "^0.4.30",
54
+ "@opengeni/codex": "^0.2.26",
55
+ "@opengeni/config": "^2.1.1",
56
+ "@opengeni/contracts": "^5.1.1",
57
+ "@opengeni/db": "^6.0.3",
58
+ "@opengeni/documents": "^0.8.34",
59
+ "@opengeni/events": "^0.4.32",
60
60
  "@opengeni/network": "^0.3.1",
61
- "@opengeni/observability": "^0.8.31",
62
- "@opengeni/runtime": "^4.0.1",
63
- "@opengeni/storage": "^0.2.132",
61
+ "@opengeni/observability": "^0.8.32",
62
+ "@opengeni/runtime": "^4.0.3",
63
+ "@opengeni/storage": "^0.2.133",
64
64
  "hono": "^4.12.18",
65
65
  "zod": "^4.2.1"
66
66
  },
@@ -25,6 +25,26 @@ import type { ApiRouteDeps } from "../dependencies";
25
25
 
26
26
  export type LimitDependencies = Pick<ApiRouteDeps, "db" | "settings">;
27
27
 
28
+ /** Deterministic/local embeddings never debit credits, even in paid mode. */
29
+ export function paidDocumentEmbedding(settings: Settings): boolean {
30
+ return (
31
+ settings.documentEmbeddingBillingMode === "credits" &&
32
+ settings.documentEmbeddingProvider === "openai"
33
+ );
34
+ }
35
+
36
+ /** Round up each provider request so a positive input cannot become a free debit. */
37
+ export function documentEmbeddingCostMicros(settings: Settings, inputBytes: number): number {
38
+ if (!Number.isSafeInteger(inputBytes) || inputBytes < 0)
39
+ throw new Error("invalid embedding bytes");
40
+ const amount =
41
+ (BigInt(inputBytes) * BigInt(settings.documentEmbeddingRateMicrosPerMillionBytes) + 999_999n) /
42
+ 1_000_000n;
43
+ if (amount > BigInt(Number.MAX_SAFE_INTEGER))
44
+ throw new Error("embedding cost exceeds safe integer");
45
+ return Number(amount);
46
+ }
47
+
28
48
  export type LimitCheckInput = {
29
49
  accountId: string;
30
50
  workspaceId?: string;
@@ -296,9 +316,10 @@ function usesCreditLimits(deps: LimitDependencies): boolean {
296
316
  }
297
317
 
298
318
  function isCostlyAction(action: LimitAction): boolean {
299
- return (
300
- action === "agent_run:create" || action === "tokens:consume" || action === "document:index"
301
- );
319
+ // Document admission retains the monthly chunk quota above, but parsing and
320
+ // source retention do not consume embedding credits. The worker admits paid
321
+ // embedding separately and settles its actual provider input after use.
322
+ return action === "agent_run:create" || action === "tokens:consume";
302
323
  }
303
324
 
304
325
  function blocked(code: string, message: string): LimitDecision {
@@ -1314,6 +1314,7 @@ function fikenCatalogItem(fikenConnections: ConnectionMetadata[]): CapabilityCat
1314
1314
  metadata: {
1315
1315
  connectorMode: "first_party_fiken",
1316
1316
  ownership: "workspace",
1317
+ connectionStatus: fikenConnection?.status ?? null,
1317
1318
  // Derived from the contracts catalog so a new fiken_* tool cannot be
1318
1319
  // registered without also appearing on the capability tile.
1319
1320
  firstPartyMcpTools: FIRST_PARTY_MCP_TOOL_NAMES.filter((name) => name.startsWith("fiken_")),
@@ -1563,6 +1564,11 @@ export function applyCapabilityEnablement(
1563
1564
  item: CapabilityCatalogItem,
1564
1565
  installation: CapabilityInstallation | undefined,
1565
1566
  ): CapabilityCatalogItem {
1567
+ if (item.surfaceType === "first_party_fiken") {
1568
+ // Fiken is an API item with connection-derived state, not a generic API
1569
+ // installation. Preserve that state before the non-MCP fallback below.
1570
+ return { ...item, connectionRef: null };
1571
+ }
1566
1572
  if (item.kind === "skill" || item.kind === "api" || item.kind === "plugin") {
1567
1573
  return { ...item, enabled: false, enabledReason: null, connectionRef: null };
1568
1574
  }
@@ -1572,12 +1578,6 @@ export function applyCapabilityEnablement(
1572
1578
  // publishes a personal connection UUID or collapses many accounts to one.
1573
1579
  return { ...item, connectionRef: null };
1574
1580
  }
1575
- if (item.surfaceType === "first_party_fiken") {
1576
- // Fiken connector state is derived from the authoritative workspace
1577
- // connection row while the catalog is built; browseable never means an
1578
- // account is already connected.
1579
- return { ...item, connectionRef: null };
1580
- }
1581
1581
  if (item.surfaceType === "codex_apps") {
1582
1582
  // Unlike ordinary built-ins, Apps availability is derived from the exact
1583
1583
  // active workspace designation above. Do not overwrite it with the
@@ -221,6 +221,31 @@ test("published and pending searches reuse one query embedding", async () => {
221
221
  expect(calls).toBe(1);
222
222
  });
223
223
 
224
+ test("a keyword fallback in the first view never retries an unfunded provider in the review view", async () => {
225
+ const searches: Array<{ mode: string | undefined; settingsProvided: boolean }> = [];
226
+ await prepareKnowledgeSave(
227
+ db,
228
+ context,
229
+ { query: "Acme renewal" },
230
+ () => {
231
+ throw new Error("An unfunded provider was called");
232
+ },
233
+ {
234
+ list: async () => ({ entries: [], nextCursor: null }),
235
+ get: async () => null,
236
+ search: async (_db, _context, request, _embedder, settings) => {
237
+ searches.push({ mode: request.mode, settingsProvided: !!settings });
238
+ return { entries: [], nextCursor: null, searchMode: "keyword" };
239
+ },
240
+ },
241
+ { documentEmbeddingProvider: "openai", documentEmbeddingBillingMode: "credits" } as never,
242
+ );
243
+ expect(searches).toEqual([
244
+ { mode: "keyword", settingsProvided: false },
245
+ { mode: "keyword", settingsProvided: false },
246
+ ]);
247
+ });
248
+
224
249
  test("catalog rechecks current metadata after a collection changes", async () => {
225
250
  const old = group("Old name");
226
251
  const current = {
@@ -9,6 +9,8 @@ import {
9
9
  type KnowledgeContext,
10
10
  } from "@opengeni/db";
11
11
  import type { DocumentEmbedder } from "@opengeni/documents";
12
+ import type { Settings } from "@opengeni/config";
13
+ import { paidDocumentEmbedding } from "../billing/limits";
12
14
  import { searchKnowledgeEntries } from "./knowledge-search";
13
15
 
14
16
  const defaults = {
@@ -29,6 +31,7 @@ export async function prepareKnowledgeSave(
29
31
  input: KnowledgeSavePreparationRequest,
30
32
  embedder: () => DocumentEmbedder,
31
33
  services = defaults,
34
+ settings?: Settings,
32
35
  ): Promise<KnowledgeSavePreparationResponse> {
33
36
  const request = KnowledgeSavePreparationRequest.parse(input);
34
37
  if (context.actor.kind !== "agent" && !(context.actor.kind === "human" && context.actor.review)) {
@@ -56,20 +59,38 @@ export async function prepareKnowledgeSave(
56
59
  };
57
60
  // Search is independent of collection placement and always includes the
58
61
  // separate unapproved view. No authoring policy or write is invoked here.
59
- const matches = {
60
- published: await services.search(
61
- db,
62
- context,
63
- { query: request.query, limit: request.limit, view: "published" },
64
- sharedEmbedder,
65
- ),
66
- needs_review: await services.search(
67
- db,
68
- context,
69
- { query: request.query, limit: request.limit, view: "needs_review" },
70
- sharedEmbedder,
71
- ),
72
- };
62
+ // Preparation returns BOTH views plus a catalog. A charge on the first
63
+ // semantic search would precede the second view and catalog; any later
64
+ // failure would leave no deliverable preparation result. Paid mode uses
65
+ // keyword discovery until a single-operation settlement seam exists.
66
+ const paid = settings != null && paidDocumentEmbedding(settings);
67
+ const published = await services.search(
68
+ db,
69
+ context,
70
+ {
71
+ query: request.query,
72
+ limit: request.limit,
73
+ view: "published",
74
+ ...(paid ? { mode: "keyword" as const } : {}),
75
+ },
76
+ sharedEmbedder,
77
+ paid ? undefined : settings,
78
+ );
79
+ // Reuse the first request's provider result (and charge). If it fell back
80
+ // to keyword because funding or the provider was unavailable, do not retry
81
+ // an unfunded embedding request in the second review view.
82
+ const needsReview = await services.search(
83
+ db,
84
+ context,
85
+ {
86
+ query: request.query,
87
+ limit: request.limit,
88
+ view: "needs_review",
89
+ ...(published.searchMode === "keyword" ? { mode: "keyword" as const } : {}),
90
+ },
91
+ sharedEmbedder,
92
+ );
93
+ const matches = { published, needs_review: needsReview };
73
94
  for (const view of ["published", "needs_review"] as const) {
74
95
  if (request.collectionCursors && request.collectionCursors[view] === null) continue;
75
96
  let cursor = request.collectionCursors?.[view] ?? undefined;
@@ -1,12 +1,53 @@
1
1
  import { afterAll, expect, mock, test } from "bun:test";
2
+ import type { Settings } from "@opengeni/config";
2
3
  const list = mock(
3
- async (_db: unknown, _context: unknown, _request: unknown, _embedding?: unknown) => ({
4
- entries: [],
5
- nextCursor: null,
6
- }),
4
+ async (_db: unknown, _context: unknown, _request: unknown, _embedding?: unknown) => {
5
+ if (failNextRetrieval) {
6
+ failNextRetrieval = false;
7
+ throw new Error("retrieval unavailable");
8
+ }
9
+ return { entries: [], nextCursor: null };
10
+ },
7
11
  );
8
- mock.module("@opengeni/db", () => ({ listKnowledgeEntries: list }));
12
+ let balance = 0;
13
+ let usedChunks = 0;
14
+ let queryRateBytes = 0;
15
+ let queryRateMicros = 0;
16
+ let failNextRetrieval = false;
17
+ let failSettlement = false;
18
+ const debits = mock(async (_db: unknown, input: { amountMicros: number }) => {
19
+ if (failSettlement) throw new Error("settlement unavailable");
20
+ balance -= input.amountMicros;
21
+ return { balance: { balanceMicros: balance }, debitedMicros: input.amountMicros };
22
+ });
23
+ const usage = mock(
24
+ async (_db: unknown, _input: { quantity: number; eventType: string }) => undefined,
25
+ );
26
+ mock.module("@opengeni/db", () => ({
27
+ listKnowledgeEntries: list,
28
+ getBillingBalance: async () => ({ balanceMicros: balance }),
29
+ applyCreditDebitAfterUse: debits,
30
+ recordUsageEvent: usage,
31
+ withKnowledgeQueryAccountLock: async (
32
+ _db: unknown,
33
+ _account: unknown,
34
+ _workspace: unknown,
35
+ fn: (db: unknown) => Promise<unknown>,
36
+ ) => fn(_db),
37
+ isCodexBilledTurn: async () => false,
38
+ sumUsageQuantity: async (_db: unknown, input: { eventType: string }) =>
39
+ input.eventType === "document.query_embedding_bytes"
40
+ ? queryRateBytes
41
+ : input.eventType === "document.query_embedding_cost"
42
+ ? queryRateMicros
43
+ : usedChunks,
44
+ countScheduledTasksForWorkspace: async () => 0,
45
+ countWorkspacesForAccount: async () => 0,
46
+ countActiveApiKeysForWorkspace: async () => 0,
47
+ countActiveOrganizationApiKeysForAccount: async () => 0,
48
+ }));
9
49
  const { searchKnowledgeEntries } = await import("./knowledge-search");
50
+ const { checkLimit } = await import("../billing/limits");
10
51
  afterAll(() => mock.restore());
11
52
  const unavailable = () => {
12
53
  throw new Error("embedding unavailable");
@@ -23,6 +64,7 @@ test("hybrid fallback keeps lexical recall and passes the exact scope and cursor
23
64
  unavailable,
24
65
  );
25
66
  expect(result.searchMode).toBe("keyword");
67
+ expect(result.fallbackReason).toBe("provider_unavailable");
26
68
  expect(list.mock.calls.at(-1)?.[2]).toMatchObject({
27
69
  query: '"Acme" OR "renewal" OR "renew" OR "contract" OR "expiration" OR "date"',
28
70
  mode: "keyword",
@@ -43,3 +85,287 @@ test("keyword and explicit search syntax remain unchanged", async () => {
43
85
  );
44
86
  expect(list.mock.calls.at(-1)?.[2]).toMatchObject({ query: "Acme renewal", mode: "keyword" });
45
87
  });
88
+
89
+ test("paid vector queries check funding, debit post-use, and preserve hybrid keyword fallback", async () => {
90
+ const settings = {
91
+ documentEmbeddingProvider: "openai",
92
+ documentEmbeddingBillingMode: "credits",
93
+ documentEmbeddingRateMicrosPerMillionBytes: 1_000_000,
94
+ } as Settings;
95
+ const context = { accountId: "account", workspaceId: "workspace" } as never;
96
+ let embedded = 0;
97
+ const provider = () =>
98
+ ({
99
+ model: "test",
100
+ dimensions: 3,
101
+ embedQuery: async () => {
102
+ embedded++;
103
+ return [1, 0, 0];
104
+ },
105
+ }) as never;
106
+ balance = 0;
107
+ const unfunded = await searchKnowledgeEntries(
108
+ {} as never,
109
+ context,
110
+ { query: "paid" },
111
+ provider,
112
+ settings,
113
+ );
114
+ expect(unfunded.searchMode).toBe("keyword");
115
+ expect(unfunded.fallbackReason).toBe("awaiting_funding");
116
+ await expect(
117
+ searchKnowledgeEntries(
118
+ {} as never,
119
+ context,
120
+ { query: "paid", mode: "vector" },
121
+ provider,
122
+ settings,
123
+ ),
124
+ ).rejects.toMatchObject({ code: "knowledge_vector_funding_required" });
125
+ expect(embedded).toBe(0);
126
+ balance = 1;
127
+ const paid = await searchKnowledgeEntries(
128
+ {} as never,
129
+ context,
130
+ { query: "paid", mode: "vector" },
131
+ provider,
132
+ settings,
133
+ );
134
+ expect(paid.searchMode).toBe("vector");
135
+ expect(embedded).toBe(1);
136
+ expect(debits.mock.calls.at(-1)?.[1]).toMatchObject({
137
+ amountMicros: 4,
138
+ sourceType: "knowledge_query",
139
+ });
140
+ expect(balance).toBe(-3);
141
+ const priorUsage = usage.mock.calls.length;
142
+ await searchKnowledgeEntries(
143
+ {} as never,
144
+ context,
145
+ { query: "paid", mode: "keyword" },
146
+ provider,
147
+ settings,
148
+ );
149
+ expect(embedded).toBe(1);
150
+ expect(usage.mock.calls.length).toBe(priorUsage);
151
+ });
152
+
153
+ test("shadow query usage meters bytes without checking or consuming credits", async () => {
154
+ const settings = {
155
+ documentEmbeddingProvider: "openai",
156
+ documentEmbeddingBillingMode: "shadow",
157
+ documentEmbeddingRateMicrosPerMillionBytes: 1_000_000,
158
+ } as Settings;
159
+ balance = 0;
160
+ const before = debits.mock.calls.length;
161
+ const found = await searchKnowledgeEntries(
162
+ {} as never,
163
+ { accountId: "account", workspaceId: "workspace" } as never,
164
+ { query: "é", mode: "vector" },
165
+ () => ({ model: "test", dimensions: 3, embedQuery: async () => [1, 0, 0] }) as never,
166
+ settings,
167
+ );
168
+ expect(found.searchMode).toBe("vector");
169
+ expect(usage.mock.calls.at(-1)?.[1]).toMatchObject({
170
+ quantity: 2,
171
+ eventType: "document.query_embedding_bytes",
172
+ });
173
+ expect(debits.mock.calls.length).toBe(before);
174
+ });
175
+
176
+ test("paid query does not debit when vector retrieval fails after embedding", async () => {
177
+ const settings = {
178
+ documentEmbeddingProvider: "openai",
179
+ documentEmbeddingBillingMode: "credits",
180
+ documentEmbeddingRateMicrosPerMillionBytes: 1_000_000,
181
+ } as Settings;
182
+ balance = 1;
183
+ const debitsBefore = debits.mock.calls.length;
184
+ failNextRetrieval = true;
185
+ await expect(
186
+ searchKnowledgeEntries(
187
+ {} as never,
188
+ { accountId: "account", workspaceId: "workspace" } as never,
189
+ { query: "paid", mode: "vector" },
190
+ () => ({ model: "test", dimensions: 3, embedQuery: async () => [1, 0, 0] }) as never,
191
+ settings,
192
+ ),
193
+ ).rejects.toThrow("retrieval unavailable");
194
+ expect(debits.mock.calls.length).toBe(debitsBefore);
195
+ });
196
+
197
+ test("paid semantic pagination fails closed before provider use", async () => {
198
+ let embedded = 0;
199
+ const before = debits.mock.calls.length;
200
+ await expect(
201
+ searchKnowledgeEntries(
202
+ {} as never,
203
+ { accountId: "account", workspaceId: "workspace" } as never,
204
+ { query: "paid", mode: "vector", cursor: "next-page" },
205
+ () =>
206
+ ({
207
+ model: "test",
208
+ dimensions: 3,
209
+ embedQuery: async () => {
210
+ embedded++;
211
+ return [1, 0, 0];
212
+ },
213
+ }) as never,
214
+ {
215
+ documentEmbeddingProvider: "openai",
216
+ documentEmbeddingBillingMode: "credits",
217
+ documentEmbeddingRateMicrosPerMillionBytes: 1_000_000,
218
+ } as Settings,
219
+ ),
220
+ ).rejects.toThrow("pagination is unavailable");
221
+ expect(embedded).toBe(0);
222
+ expect(debits.mock.calls.length).toBe(before);
223
+ });
224
+
225
+ test("paid query rate ceiling falls back for hybrid and rejects vector without using the provider", async () => {
226
+ queryRateBytes = 64 * 1024;
227
+ balance = 1;
228
+ let embedded = 0;
229
+ const provider = () =>
230
+ ({
231
+ model: "test",
232
+ dimensions: 3,
233
+ embedQuery: async () => {
234
+ embedded++;
235
+ return [1, 0, 0];
236
+ },
237
+ }) as never;
238
+ const settings = {
239
+ documentEmbeddingProvider: "openai",
240
+ documentEmbeddingBillingMode: "credits",
241
+ documentEmbeddingRateMicrosPerMillionBytes: 1_000_000,
242
+ } as Settings;
243
+ try {
244
+ expect(
245
+ (
246
+ await searchKnowledgeEntries(
247
+ {} as never,
248
+ { accountId: "account", workspaceId: "workspace" } as never,
249
+ { query: "paid" },
250
+ provider,
251
+ settings,
252
+ )
253
+ ).searchMode,
254
+ ).toBe("keyword");
255
+ await expect(
256
+ searchKnowledgeEntries(
257
+ {} as never,
258
+ { accountId: "account", workspaceId: "workspace" } as never,
259
+ { query: "paid", mode: "vector" },
260
+ provider,
261
+ settings,
262
+ ),
263
+ ).rejects.toThrow("rate limit reached");
264
+ expect(embedded).toBe(0);
265
+ } finally {
266
+ queryRateBytes = 0;
267
+ }
268
+ });
269
+
270
+ test("paid micro-cost ceiling blocks repeated tiny queries before embedding", async () => {
271
+ balance = 1;
272
+ queryRateMicros = 10_000;
273
+ let embedded = 0;
274
+ try {
275
+ const result = await searchKnowledgeEntries(
276
+ {} as never,
277
+ { accountId: "account", workspaceId: "workspace" } as never,
278
+ { query: "tiny" },
279
+ () =>
280
+ ({
281
+ model: "test",
282
+ dimensions: 3,
283
+ embedQuery: async () => {
284
+ embedded++;
285
+ return [1, 0, 0];
286
+ },
287
+ }) as never,
288
+ {
289
+ documentEmbeddingProvider: "openai",
290
+ documentEmbeddingBillingMode: "credits",
291
+ documentEmbeddingRateMicrosPerMillionBytes: 1_000_000,
292
+ } as Settings,
293
+ );
294
+ expect(result.searchMode).toBe("keyword");
295
+ expect(embedded).toBe(0);
296
+ } finally {
297
+ queryRateMicros = 0;
298
+ }
299
+ });
300
+
301
+ test("paid settlement failure propagates without a fallback", async () => {
302
+ balance = 1;
303
+ failSettlement = true;
304
+ const before = list.mock.calls.length;
305
+ try {
306
+ await expect(
307
+ searchKnowledgeEntries(
308
+ {} as never,
309
+ { accountId: "account", workspaceId: "workspace" } as never,
310
+ { query: "paid" },
311
+ () => ({ model: "test", dimensions: 3, embedQuery: async () => [1, 0, 0] }) as never,
312
+ {
313
+ documentEmbeddingProvider: "openai",
314
+ documentEmbeddingBillingMode: "credits",
315
+ documentEmbeddingRateMicrosPerMillionBytes: 1_000_000,
316
+ } as Settings,
317
+ ),
318
+ ).rejects.toThrow("settlement unavailable");
319
+ expect(list.mock.calls.length).toBe(before + 1);
320
+ } finally {
321
+ failSettlement = false;
322
+ }
323
+ });
324
+
325
+ test("deterministic embeddings remain unpriced even with credits mode selected", async () => {
326
+ balance = 0;
327
+ const before = debits.mock.calls.length;
328
+ const found = await searchKnowledgeEntries(
329
+ {} as never,
330
+ { accountId: "account", workspaceId: "workspace" } as never,
331
+ { query: "local", mode: "vector" },
332
+ () => ({ model: "local", dimensions: 3, embedQuery: async () => [1, 0, 0] }) as never,
333
+ {
334
+ documentEmbeddingProvider: "deterministic",
335
+ documentEmbeddingBillingMode: "credits",
336
+ documentEmbeddingRateMicrosPerMillionBytes: 1_000_000,
337
+ } as Settings,
338
+ );
339
+ expect(found.searchMode).toBe("vector");
340
+ expect(debits.mock.calls.length).toBe(before);
341
+ });
342
+
343
+ test("unpriced document source admission ignores zero credits but preserves the monthly chunk quota", async () => {
344
+ balance = 0;
345
+ usedChunks = 1;
346
+ const deps = {
347
+ db: {} as never,
348
+ settings: {
349
+ billingMode: "stripe",
350
+ usageLimitsMode: "static",
351
+ staticUsageLimitsJson: JSON.stringify({ maxDocumentIndexedChunksPerWorkspace: 2 }),
352
+ } as Settings,
353
+ };
354
+ expect(
355
+ await checkLimit(deps, {
356
+ accountId: "account",
357
+ workspaceId: "workspace",
358
+ action: "document:index",
359
+ quantity: 0,
360
+ }),
361
+ ).toMatchObject({ allowed: true });
362
+ usedChunks = 2;
363
+ expect(
364
+ await checkLimit(deps, {
365
+ accountId: "account",
366
+ workspaceId: "workspace",
367
+ action: "document:index",
368
+ quantity: 1,
369
+ }),
370
+ ).toMatchObject({ allowed: false, code: "max_document_indexed_chunks_per_workspace" });
371
+ });