@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
|
@@ -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
|
}
|