@opengeni/core 4.0.1 → 4.0.3-canary.36199476632001
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 +21 -5
- package/dist/billing/limits.d.ts +4 -0
- package/dist/default-session-model.d.ts +110 -0
- package/dist/domain/capabilities.d.ts +2 -1
- package/dist/domain/knowledge-messages.d.ts +4 -4
- 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/domain/session-native-connection-replacement.d.ts +14 -0
- package/dist/domain/sessions.d.ts +9 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +929 -384
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/src/application/new-session-drafts.ts +101 -22
- package/src/application/session-mcp-credential-rotation.ts +57 -15
- package/src/billing/limits.ts +24 -3
- package/src/default-session-model.ts +467 -0
- package/src/domain/capabilities.ts +23 -7
- 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/src/domain/session-native-connection-replacement.ts +64 -0
- package/src/domain/sessions.ts +42 -7
- package/src/index.ts +1 -0
|
@@ -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
|
+
});
|
|
@@ -1,7 +1,45 @@
|
|
|
1
|
-
import { KnowledgeEntryListRequest } from "@opengeni/contracts";
|
|
2
|
-
import
|
|
1
|
+
import { KnowledgeEntryListRequest, type KnowledgeEntryListResponse } from "@opengeni/contracts";
|
|
2
|
+
import type { Settings } from "@opengeni/config";
|
|
3
|
+
import {
|
|
4
|
+
applyCreditDebitAfterUse,
|
|
5
|
+
getBillingBalance,
|
|
6
|
+
listKnowledgeEntries,
|
|
7
|
+
recordUsageEvent,
|
|
8
|
+
sumUsageQuantity,
|
|
9
|
+
withKnowledgeQueryAccountLock,
|
|
10
|
+
type Database,
|
|
11
|
+
type KnowledgeContext,
|
|
12
|
+
} from "@opengeni/db";
|
|
3
13
|
import type { DocumentEmbedder } from "@opengeni/documents";
|
|
4
14
|
import type { z } from "zod";
|
|
15
|
+
import { documentEmbeddingCostMicros, paidDocumentEmbedding } from "../billing/limits";
|
|
16
|
+
|
|
17
|
+
// Safety ceilings, not a commercial tariff. Paid queries have no durable
|
|
18
|
+
// reusable vector cache, so each bounded request consumes the provider once.
|
|
19
|
+
const MAX_PAID_QUERY_BYTES = 4096;
|
|
20
|
+
const MAX_PAID_QUERY_MICROS = 1000;
|
|
21
|
+
const MAX_PAID_QUERY_BYTES_PER_MINUTE = 64 * 1024;
|
|
22
|
+
const MAX_PAID_QUERY_BYTES_PER_MONTH = 8 * 1024 * 1024;
|
|
23
|
+
const MAX_PAID_QUERY_MICROS_PER_MINUTE = 10_000;
|
|
24
|
+
const MAX_PAID_QUERY_MICROS_PER_MONTH = 1_000_000;
|
|
25
|
+
|
|
26
|
+
export class KnowledgeVectorFundingError extends Error {
|
|
27
|
+
readonly code = "knowledge_vector_funding_required";
|
|
28
|
+
constructor() {
|
|
29
|
+
super("Knowledge vector search needs OpenGeni credits; keyword search remains available.");
|
|
30
|
+
this.name = "KnowledgeVectorFundingError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class KnowledgeVectorQueryRejectedError extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
public readonly code: "pagination_unavailable" | "query_limit" | "quota",
|
|
37
|
+
message: string,
|
|
38
|
+
) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "KnowledgeVectorQueryRejectedError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
5
43
|
|
|
6
44
|
/** Shared search path for HTTP and both first-party retrieval servers. */
|
|
7
45
|
export async function searchKnowledgeEntries(
|
|
@@ -9,7 +47,8 @@ export async function searchKnowledgeEntries(
|
|
|
9
47
|
context: KnowledgeContext,
|
|
10
48
|
input: z.input<typeof KnowledgeEntryListRequest>,
|
|
11
49
|
embedder: () => DocumentEmbedder,
|
|
12
|
-
|
|
50
|
+
settings?: Settings,
|
|
51
|
+
): Promise<KnowledgeEntryListResponse> {
|
|
13
52
|
const request = KnowledgeEntryListRequest.parse(input);
|
|
14
53
|
if (!request.query || request.mode === "keyword") {
|
|
15
54
|
return {
|
|
@@ -27,29 +66,139 @@ export async function searchKnowledgeEntries(
|
|
|
27
66
|
const lexicalRequest = KnowledgeEntryListRequest.safeParse({ ...request, query: lexicalQuery });
|
|
28
67
|
const retrievalRequest =
|
|
29
68
|
request.mode === "hybrid" && lexicalRequest.success ? lexicalRequest.data : request;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
values.length !== provider.dimensions ||
|
|
36
|
-
values.some((value) => !Number.isFinite(value)) ||
|
|
37
|
-
!values.some((value) => value !== 0)
|
|
38
|
-
) {
|
|
39
|
-
throw new Error("Knowledge query embedding is unavailable");
|
|
40
|
-
}
|
|
41
|
-
embedding = { model: provider.model, values };
|
|
42
|
-
} catch (error) {
|
|
43
|
-
// Exact vector requests cannot silently change meaning. Hybrid search can
|
|
44
|
-
// continue using the transactional keyword index while a provider is down.
|
|
69
|
+
const keywordFallback = async (
|
|
70
|
+
queryDb: Database,
|
|
71
|
+
error: unknown,
|
|
72
|
+
fallbackReason: "awaiting_funding" | "quota" | "provider_unavailable" | "query_limit",
|
|
73
|
+
) => {
|
|
45
74
|
if (request.mode === "vector") throw error;
|
|
46
75
|
return {
|
|
47
|
-
...(await listKnowledgeEntries(
|
|
76
|
+
...(await listKnowledgeEntries(queryDb, context, { ...retrievalRequest, mode: "keyword" })),
|
|
48
77
|
searchMode: "keyword" as const,
|
|
78
|
+
fallbackReason,
|
|
49
79
|
};
|
|
50
|
-
}
|
|
51
|
-
return {
|
|
52
|
-
...(await listKnowledgeEntries(db, context, retrievalRequest, embedding)),
|
|
53
|
-
searchMode: request.mode,
|
|
54
80
|
};
|
|
81
|
+
const bytes = Buffer.byteLength(request.query, "utf8");
|
|
82
|
+
const paidSettings = settings && paidDocumentEmbedding(settings) ? settings : undefined;
|
|
83
|
+
// Paid cursors would re-embed and re-charge on each page. Until a durable
|
|
84
|
+
// vector cache exists, require explicit keyword mode to paginate instead.
|
|
85
|
+
if (paidSettings && request.cursor)
|
|
86
|
+
throw new KnowledgeVectorQueryRejectedError(
|
|
87
|
+
"pagination_unavailable",
|
|
88
|
+
"Paid semantic Knowledge pagination is unavailable; use keyword mode",
|
|
89
|
+
);
|
|
90
|
+
const cost = paidSettings ? documentEmbeddingCostMicros(paidSettings, bytes) : 0;
|
|
91
|
+
if (paidSettings && (bytes > MAX_PAID_QUERY_BYTES || cost > MAX_PAID_QUERY_MICROS))
|
|
92
|
+
return keywordFallback(
|
|
93
|
+
db,
|
|
94
|
+
new KnowledgeVectorQueryRejectedError(
|
|
95
|
+
"query_limit",
|
|
96
|
+
"Paid Knowledge query exceeds the per-request limit",
|
|
97
|
+
),
|
|
98
|
+
"query_limit",
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const retrieveThenSettle = async (queryDb: Database) => {
|
|
102
|
+
let embedding: { model: string; values: number[] };
|
|
103
|
+
try {
|
|
104
|
+
const provider = embedder();
|
|
105
|
+
const values = await provider.embedQuery(request.query!);
|
|
106
|
+
if (
|
|
107
|
+
values.length !== provider.dimensions ||
|
|
108
|
+
values.some((value) => !Number.isFinite(value)) ||
|
|
109
|
+
!values.some((value) => value !== 0)
|
|
110
|
+
)
|
|
111
|
+
throw new Error("Knowledge query embedding is unavailable");
|
|
112
|
+
embedding = { model: provider.model, values };
|
|
113
|
+
} catch (error) {
|
|
114
|
+
// Only provider failure may fall back. Retrieval and settlement errors
|
|
115
|
+
// after use surface; they never masquerade as a successful keyword hit.
|
|
116
|
+
return keywordFallback(queryDb, error, "provider_unavailable");
|
|
117
|
+
}
|
|
118
|
+
// Read before billing, in the same transaction for the paid mode. Failure
|
|
119
|
+
// here leaves no customer charge and no vector result to return.
|
|
120
|
+
const found = await listKnowledgeEntries(queryDb, context, retrievalRequest, embedding);
|
|
121
|
+
if (settings) {
|
|
122
|
+
const usageId = crypto.randomUUID();
|
|
123
|
+
await recordUsageEvent(queryDb, {
|
|
124
|
+
accountId: context.accountId,
|
|
125
|
+
workspaceId: context.workspaceId,
|
|
126
|
+
eventType: "document.query_embedding_bytes",
|
|
127
|
+
quantity: bytes,
|
|
128
|
+
unit: "byte",
|
|
129
|
+
sourceResourceType: "knowledge_query",
|
|
130
|
+
sourceResourceId: usageId,
|
|
131
|
+
idempotencyKey: `knowledge.query_bytes:${usageId}`,
|
|
132
|
+
});
|
|
133
|
+
if (paidSettings && cost > 0)
|
|
134
|
+
await recordUsageEvent(queryDb, {
|
|
135
|
+
accountId: context.accountId,
|
|
136
|
+
workspaceId: context.workspaceId,
|
|
137
|
+
eventType: "document.query_embedding_cost",
|
|
138
|
+
quantity: cost,
|
|
139
|
+
unit: "micro_usd",
|
|
140
|
+
sourceResourceType: "knowledge_query",
|
|
141
|
+
sourceResourceId: usageId,
|
|
142
|
+
idempotencyKey: `knowledge.query_cost:${usageId}`,
|
|
143
|
+
});
|
|
144
|
+
if (paidSettings && cost > 0)
|
|
145
|
+
await applyCreditDebitAfterUse(queryDb, {
|
|
146
|
+
accountId: context.accountId,
|
|
147
|
+
workspaceId: context.workspaceId,
|
|
148
|
+
type: "document_embedding_debit",
|
|
149
|
+
amountMicros: cost,
|
|
150
|
+
sourceType: "knowledge_query",
|
|
151
|
+
sourceId: usageId,
|
|
152
|
+
idempotencyKey: `knowledge.query_embedding:${usageId}`,
|
|
153
|
+
metadata: { model: embedding.model, bytes },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return { ...found, searchMode: request.mode };
|
|
157
|
+
};
|
|
158
|
+
if (!paidSettings) return retrieveThenSettle(db);
|
|
159
|
+
// Serialize paid queries across all workspaces of the account before
|
|
160
|
+
// checking balance. This is an execution fence, not a credit reservation.
|
|
161
|
+
return withKnowledgeQueryAccountLock(
|
|
162
|
+
db,
|
|
163
|
+
context.accountId,
|
|
164
|
+
context.workspaceId,
|
|
165
|
+
async (lockedDb) => {
|
|
166
|
+
const balance = await getBillingBalance(lockedDb, context.accountId);
|
|
167
|
+
if (balance.balanceMicros <= 0)
|
|
168
|
+
return keywordFallback(lockedDb, new KnowledgeVectorFundingError(), "awaiting_funding");
|
|
169
|
+
const now = new Date();
|
|
170
|
+
const minuteBytes = await sumUsageQuantity(lockedDb, {
|
|
171
|
+
accountId: context.accountId,
|
|
172
|
+
eventType: "document.query_embedding_bytes",
|
|
173
|
+
since: new Date(now.getTime() - 60_000),
|
|
174
|
+
});
|
|
175
|
+
const monthBytes = await sumUsageQuantity(lockedDb, {
|
|
176
|
+
accountId: context.accountId,
|
|
177
|
+
eventType: "document.query_embedding_bytes",
|
|
178
|
+
since: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)),
|
|
179
|
+
});
|
|
180
|
+
const minuteMicros = await sumUsageQuantity(lockedDb, {
|
|
181
|
+
accountId: context.accountId,
|
|
182
|
+
eventType: "document.query_embedding_cost",
|
|
183
|
+
since: new Date(now.getTime() - 60_000),
|
|
184
|
+
});
|
|
185
|
+
const monthMicros = await sumUsageQuantity(lockedDb, {
|
|
186
|
+
accountId: context.accountId,
|
|
187
|
+
eventType: "document.query_embedding_cost",
|
|
188
|
+
since: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)),
|
|
189
|
+
});
|
|
190
|
+
if (
|
|
191
|
+
minuteBytes + bytes > MAX_PAID_QUERY_BYTES_PER_MINUTE ||
|
|
192
|
+
monthBytes + bytes > MAX_PAID_QUERY_BYTES_PER_MONTH ||
|
|
193
|
+
minuteMicros + cost > MAX_PAID_QUERY_MICROS_PER_MINUTE ||
|
|
194
|
+
monthMicros + cost > MAX_PAID_QUERY_MICROS_PER_MONTH
|
|
195
|
+
)
|
|
196
|
+
return keywordFallback(
|
|
197
|
+
lockedDb,
|
|
198
|
+
new KnowledgeVectorQueryRejectedError("quota", "Paid Knowledge query rate limit reached"),
|
|
199
|
+
"quota",
|
|
200
|
+
);
|
|
201
|
+
return retrieveThenSettle(lockedDb);
|
|
202
|
+
},
|
|
203
|
+
);
|
|
55
204
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ConnectionMetadata, McpServerConnectionRef } from "@opengeni/contracts";
|
|
2
|
+
import { connectionMetadataMatchesBinding } from "@opengeni/db/session-mcp-credential-rotation";
|
|
3
|
+
|
|
4
|
+
/** Selection is restricted to the caller's already-authorized inventory. This
|
|
5
|
+
* projection grants no execution authority: subsequent admission and physical
|
|
6
|
+
* credential use still validate the exact native account normally. */
|
|
7
|
+
export function nativeSessionConnectionReplacement(input: {
|
|
8
|
+
accountId: string;
|
|
9
|
+
workspaceId: string;
|
|
10
|
+
subjectId: string | null;
|
|
11
|
+
serverUrl: string;
|
|
12
|
+
replacementServerUrl?: string;
|
|
13
|
+
currentRef: McpServerConnectionRef | null;
|
|
14
|
+
nativeConnectionId: string;
|
|
15
|
+
connections: ConnectionMetadata[];
|
|
16
|
+
}): McpServerConnectionRef {
|
|
17
|
+
const connection = input.connections.find(
|
|
18
|
+
(candidate) => candidate.id === input.nativeConnectionId,
|
|
19
|
+
);
|
|
20
|
+
if (
|
|
21
|
+
!connection ||
|
|
22
|
+
connection.accountId !== input.accountId ||
|
|
23
|
+
connection.status !== "active" ||
|
|
24
|
+
(connection.subjectId === null
|
|
25
|
+
? connection.workspaceId !== input.workspaceId
|
|
26
|
+
: !input.subjectId || connection.subjectId !== input.subjectId || !connection.authorityId)
|
|
27
|
+
) {
|
|
28
|
+
throw new Error("native connection unavailable");
|
|
29
|
+
}
|
|
30
|
+
const {
|
|
31
|
+
authoritySource: _authoritySource,
|
|
32
|
+
hostBinding: _hostBinding,
|
|
33
|
+
accountSelection: _accountSelection,
|
|
34
|
+
...retained
|
|
35
|
+
} = input.currentRef ?? {};
|
|
36
|
+
// Preserve provider, resource, scope and selected-resource restrictions.
|
|
37
|
+
// Retired host identity is never interpreted as a native connection ID.
|
|
38
|
+
if (
|
|
39
|
+
input.currentRef &&
|
|
40
|
+
input.currentRef.providerDomain.toLowerCase() !== connection.providerDomain.toLowerCase()
|
|
41
|
+
)
|
|
42
|
+
throw new Error("native connection provider mismatch");
|
|
43
|
+
const ref: McpServerConnectionRef = {
|
|
44
|
+
...retained,
|
|
45
|
+
...(!input.currentRef?.resource && typeof connection.metadata.resource === "string"
|
|
46
|
+
? { resource: connection.metadata.resource }
|
|
47
|
+
: {}),
|
|
48
|
+
connectionId: connection.id,
|
|
49
|
+
providerDomain: connection.providerDomain,
|
|
50
|
+
kind: connection.kind,
|
|
51
|
+
subjectScope: connection.subjectId === null ? "workspace" : "subject",
|
|
52
|
+
};
|
|
53
|
+
const destination = input.replacementServerUrl ?? input.serverUrl;
|
|
54
|
+
// A redirect must be explicitly requested and bound in the account metadata;
|
|
55
|
+
// the broker's legacy provider-host fallback is insufficient for rebinding.
|
|
56
|
+
if (
|
|
57
|
+
input.replacementServerUrl &&
|
|
58
|
+
(typeof connection.metadata.mcpUrl !== "string" || !connection.metadata.mcpUrl.trim())
|
|
59
|
+
)
|
|
60
|
+
throw new Error("native connection destination unavailable");
|
|
61
|
+
if (!connectionMetadataMatchesBinding(connection, ref, destination))
|
|
62
|
+
throw new Error("native connection binding mismatch");
|
|
63
|
+
return ref;
|
|
64
|
+
}
|