@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/dist/application/new-session-drafts.d.ts +3 -3
- package/dist/billing/limits.d.ts +4 -0
- package/dist/domain/knowledge-messages.d.ts +2 -2
- package/dist/domain/knowledge-preparation.d.ts +2 -1
- package/dist/domain/knowledge-search.d.ts +11 -76
- package/dist/domain/pr-review.d.ts +2 -2
- package/dist/index.js +416 -266
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/billing/limits.ts +24 -3
- package/src/domain/capabilities.ts +6 -6
- package/src/domain/knowledge-preparation.test.ts +25 -0
- package/src/domain/knowledge-preparation.ts +35 -14
- package/src/domain/knowledge-search.test.ts +331 -5
- package/src/domain/knowledge-search.ts +172 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/core",
|
|
3
|
-
"version": "4.0.
|
|
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.
|
|
55
|
-
"@opengeni/config": "^2.1.
|
|
56
|
-
"@opengeni/contracts": "^5.1.
|
|
57
|
-
"@opengeni/db": "^6.0.
|
|
58
|
-
"@opengeni/documents": "^0.8.
|
|
59
|
-
"@opengeni/events": "^0.4.
|
|
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.
|
|
62
|
-
"@opengeni/runtime": "^4.0.
|
|
63
|
-
"@opengeni/storage": "^0.2.
|
|
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
|
},
|
package/src/billing/limits.ts
CHANGED
|
@@ -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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
+
});
|