graphile-llm 0.11.5 → 0.12.1
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/__tests__/graphile-llm.test.js +318 -37
- package/__tests__/unified-search-embedding.test.d.ts +10 -0
- package/__tests__/unified-search-embedding.test.js +176 -0
- package/embedder.d.ts +24 -4
- package/embedder.js +36 -7
- package/env.d.ts +6 -28
- package/env.js +10 -47
- package/esm/__tests__/graphile-llm.test.js +319 -38
- package/esm/__tests__/unified-search-embedding.test.d.ts +10 -0
- package/esm/__tests__/unified-search-embedding.test.js +174 -0
- package/esm/embedder.d.ts +24 -4
- package/esm/embedder.js +35 -7
- package/esm/env.d.ts +6 -28
- package/esm/env.js +5 -46
- package/esm/index.d.ts +5 -4
- package/esm/index.js +3 -4
- package/esm/metering.d.ts +1 -3
- package/esm/metering.js +48 -108
- package/esm/plugins/llm-module-plugin.d.ts +9 -4
- package/esm/plugins/llm-module-plugin.js +9 -4
- package/esm/plugins/rag-plugin.js +30 -20
- package/esm/plugins/text-mutation-plugin.js +15 -2
- package/esm/plugins/text-search-plugin.d.ts +15 -3
- package/esm/plugins/text-search-plugin.js +94 -23
- package/esm/preset.d.ts +13 -2
- package/esm/preset.js +15 -4
- package/esm/types.d.ts +13 -0
- package/index.d.ts +5 -4
- package/index.js +6 -2
- package/metering.d.ts +1 -3
- package/metering.js +48 -108
- package/package.json +7 -5
- package/plugins/llm-module-plugin.d.ts +9 -4
- package/plugins/llm-module-plugin.js +9 -4
- package/plugins/rag-plugin.js +30 -20
- package/plugins/text-mutation-plugin.js +15 -2
- package/plugins/text-search-plugin.d.ts +15 -3
- package/plugins/text-search-plugin.js +95 -23
- package/preset.d.ts +13 -2
- package/preset.js +15 -4
- package/types.d.ts +13 -0
package/metering.js
CHANGED
|
@@ -23,85 +23,43 @@ exports.QuotaExceededError = void 0;
|
|
|
23
23
|
exports.logInferenceUsage = logInferenceUsage;
|
|
24
24
|
exports.meteredEmbed = meteredEmbed;
|
|
25
25
|
exports.meteredChat = meteredChat;
|
|
26
|
-
|
|
26
|
+
const express_context_1 = require("@constructive-io/express-context");
|
|
27
|
+
// ─── Billing Client Adapter ─────────────────────────────────────────────────
|
|
27
28
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* Gracefully returns true if the billing function doesn't exist or errors —
|
|
32
|
-
* metering is opt-in, so missing infrastructure means "allow".
|
|
33
|
-
*/
|
|
34
|
-
async function checkQuota(pgClient, billing, entityId, meterSlug, amount) {
|
|
35
|
-
try {
|
|
36
|
-
const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`;
|
|
37
|
-
const result = await pgClient.query(sql, [meterSlug, entityId, amount]);
|
|
38
|
-
return result.rows[0]?.allowed !== false;
|
|
39
|
-
}
|
|
40
|
-
catch (e) {
|
|
41
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
42
|
-
console.warn(`[graphile-llm] check_billing_quota failed (allowing): ${message}`);
|
|
43
|
-
return true;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Record usage after a successful call.
|
|
48
|
-
* Gracefully skips if the billing function doesn't exist or errors.
|
|
29
|
+
* Create a BillingClient from a MeteringContext by adapting Graphile's 2-arg
|
|
30
|
+
* withPgClient(pgSettings, fn) to the express-context 1-arg withPgClient(fn).
|
|
49
31
|
*/
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
await pgClient.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata)]);
|
|
54
|
-
}
|
|
55
|
-
catch (e) {
|
|
56
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
57
|
-
console.warn(`[graphile-llm] record_usage failed (non-fatal): ${message}`);
|
|
58
|
-
}
|
|
32
|
+
function billingClientFromCtx(ctx) {
|
|
33
|
+
const adaptedWithPgClient = (fn) => ctx.withPgClient(ctx.pgSettings, fn);
|
|
34
|
+
return (0, express_context_1.createBillingClient)(adaptedWithPgClient, ctx.entityId, ctx.billing, ctx.inferenceLog);
|
|
59
35
|
}
|
|
60
36
|
/**
|
|
61
37
|
* Write a row to the usage_log_inference table.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* TODO: Also write to child (generated) database when dual-write is needed.
|
|
38
|
+
* Delegates to the shared BillingClient from express-context.
|
|
65
39
|
*/
|
|
66
40
|
async function logInferenceUsage(ctx, entry) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
await pgClient.query(sql, [
|
|
90
|
-
entry.databaseId, entry.entityId, entry.actorId,
|
|
91
|
-
entry.model, entry.provider, entry.service, entry.operation,
|
|
92
|
-
entry.inputTokens, entry.outputTokens, entry.totalTokens,
|
|
93
|
-
entry.cacheReadTokens, entry.cacheWriteTokens,
|
|
94
|
-
entry.latencyMs, entry.ragEnabled, entry.chunksRetrieved,
|
|
95
|
-
entry.embeddingModel, entry.embeddingLatencyMs,
|
|
96
|
-
entry.status, entry.errorType,
|
|
97
|
-
entry.rawUsage ? JSON.stringify(entry.rawUsage) : null
|
|
98
|
-
]);
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
catch (e) {
|
|
102
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
103
|
-
console.warn(`[graphile-llm] inference log INSERT failed (non-fatal): ${message}`);
|
|
104
|
-
}
|
|
41
|
+
const client = billingClientFromCtx(ctx);
|
|
42
|
+
return client.logInference({
|
|
43
|
+
entityId: entry.entityId,
|
|
44
|
+
actorId: entry.actorId,
|
|
45
|
+
model: entry.model,
|
|
46
|
+
provider: entry.provider,
|
|
47
|
+
service: entry.service,
|
|
48
|
+
operation: entry.operation,
|
|
49
|
+
inputTokens: entry.inputTokens,
|
|
50
|
+
outputTokens: entry.outputTokens,
|
|
51
|
+
totalTokens: entry.totalTokens,
|
|
52
|
+
latencyMs: entry.latencyMs,
|
|
53
|
+
status: entry.status,
|
|
54
|
+
cacheReadTokens: entry.cacheReadTokens,
|
|
55
|
+
cacheWriteTokens: entry.cacheWriteTokens,
|
|
56
|
+
ragEnabled: entry.ragEnabled,
|
|
57
|
+
chunksRetrieved: entry.chunksRetrieved,
|
|
58
|
+
embeddingModel: entry.embeddingModel,
|
|
59
|
+
embeddingLatencyMs: entry.embeddingLatencyMs,
|
|
60
|
+
errorType: entry.errorType,
|
|
61
|
+
rawUsage: entry.rawUsage
|
|
62
|
+
});
|
|
105
63
|
}
|
|
106
64
|
// ─── Metered Embedder ───────────────────────────────────────────────────────
|
|
107
65
|
/**
|
|
@@ -142,15 +100,8 @@ async function meteredEmbed(embedder, text, ctx, options = {}) {
|
|
|
142
100
|
};
|
|
143
101
|
}
|
|
144
102
|
// Pre-check: can this entity afford this call?
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
await ctx.withPgClient(ctx.pgSettings, async (pgClient) => {
|
|
148
|
-
allowed = await checkQuota(pgClient, ctx.billing, ctx.entityId, meterSlug, 1);
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
allowed = true;
|
|
153
|
-
}
|
|
103
|
+
const billingClient = billingClientFromCtx(ctx);
|
|
104
|
+
const allowed = await billingClient.checkQuota(meterSlug);
|
|
154
105
|
if (!allowed) {
|
|
155
106
|
logInferenceUsage(ctx, {
|
|
156
107
|
databaseId: ctx.databaseId,
|
|
@@ -184,14 +135,12 @@ async function meteredEmbed(embedder, text, ctx, options = {}) {
|
|
|
184
135
|
// Execute embedding — real token count from provider via EmbeddingResult
|
|
185
136
|
const { embedding, promptTokens } = await embedder(text);
|
|
186
137
|
const latencyMs = Date.now() - startTime;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
latency_ms: latencyMs
|
|
194
|
-
});
|
|
138
|
+
billingClient.recordUsage(meterSlug, promptTokens, {
|
|
139
|
+
request_id: ctx.requestId,
|
|
140
|
+
input_chars: text.length,
|
|
141
|
+
prompt_tokens: promptTokens,
|
|
142
|
+
dims: embedding.length,
|
|
143
|
+
latency_ms: latencyMs
|
|
195
144
|
}).catch(() => { });
|
|
196
145
|
// Log to inference usage table
|
|
197
146
|
logInferenceUsage(ctx, {
|
|
@@ -258,15 +207,8 @@ async function meteredChat(chat, messages, ctx, chatOptions, meteringOptions = {
|
|
|
258
207
|
};
|
|
259
208
|
}
|
|
260
209
|
// Pre-check: can this entity afford this call?
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
await ctx.withPgClient(ctx.pgSettings, async (pgClient) => {
|
|
264
|
-
allowed = await checkQuota(pgClient, ctx.billing, ctx.entityId, meterSlug, 1);
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
catch {
|
|
268
|
-
allowed = true;
|
|
269
|
-
}
|
|
210
|
+
const billingClient = billingClientFromCtx(ctx);
|
|
211
|
+
const allowed = await billingClient.checkQuota(meterSlug);
|
|
270
212
|
if (!allowed) {
|
|
271
213
|
const estimatedInputTokens = Math.ceil(messages.reduce((sum, m) => sum + m.content.length, 0) / 4);
|
|
272
214
|
logInferenceUsage(ctx, {
|
|
@@ -302,16 +244,14 @@ async function meteredChat(chat, messages, ctx, chatOptions, meteringOptions = {
|
|
|
302
244
|
const chatResult = await chat(messages, chatOptions);
|
|
303
245
|
const latencyMs = Date.now() - startTime;
|
|
304
246
|
const usage = chatResult.usage;
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
latency_ms: latencyMs
|
|
314
|
-
});
|
|
247
|
+
billingClient.recordUsage(meterSlug, usage.totalTokens, {
|
|
248
|
+
request_id: ctx.requestId,
|
|
249
|
+
input_tokens: usage.input,
|
|
250
|
+
output_tokens: usage.output,
|
|
251
|
+
cache_read_tokens: usage.cacheRead,
|
|
252
|
+
cache_write_tokens: usage.cacheWrite,
|
|
253
|
+
messages_count: messages.length,
|
|
254
|
+
latency_ms: latencyMs
|
|
315
255
|
}).catch(() => { });
|
|
316
256
|
// Log to inference usage table with real provider token counts
|
|
317
257
|
logInferenceUsage(ctx, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-llm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "LLM integration plugin for PostGraphile v5 — server-side text-to-vector embedding and text companion fields for pgvector columns",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"homepage": "https://github.com/constructive-io/constructive",
|
|
@@ -29,8 +29,10 @@
|
|
|
29
29
|
"url": "https://github.com/constructive-io/constructive/issues"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@agentic-kit/ollama": "
|
|
33
|
-
"
|
|
32
|
+
"@agentic-kit/ollama": "2.1.0",
|
|
33
|
+
"@constructive-io/express-context": "^0.6.0",
|
|
34
|
+
"@constructive-io/llm-env": "^0.1.1",
|
|
35
|
+
"graphile-cache": "^3.13.1"
|
|
34
36
|
},
|
|
35
37
|
"peerDependencies": {
|
|
36
38
|
"@dataplan/pg": "1.0.3",
|
|
@@ -55,7 +57,7 @@
|
|
|
55
57
|
"devDependencies": {
|
|
56
58
|
"@types/node": "^22.19.11",
|
|
57
59
|
"graphile-connection-filter": "^1.12.4",
|
|
58
|
-
"graphile-search": "^1.
|
|
60
|
+
"graphile-search": "^1.17.0",
|
|
59
61
|
"graphile-test": "^4.17.4",
|
|
60
62
|
"makage": "^0.3.0",
|
|
61
63
|
"pgsql-test": "^4.16.4"
|
|
@@ -73,5 +75,5 @@
|
|
|
73
75
|
"ollama",
|
|
74
76
|
"openai"
|
|
75
77
|
],
|
|
76
|
-
"gitHead": "
|
|
78
|
+
"gitHead": "fc6f9780ea1cab2c9344c2e392649ae644e84849"
|
|
77
79
|
}
|
|
@@ -11,10 +11,15 @@
|
|
|
11
11
|
* to consume.
|
|
12
12
|
*
|
|
13
13
|
* Resolution order for the embedder:
|
|
14
|
-
* 1. `
|
|
15
|
-
* 2.
|
|
16
|
-
* 3.
|
|
17
|
-
*
|
|
14
|
+
* 1. `defaultEmbedder` from preset options (build-time)
|
|
15
|
+
* 2. Environment variables (EMBEDDER_PROVIDER, EMBEDDER_MODEL, EMBEDDER_BASE_URL)
|
|
16
|
+
* 3. null — LLM features are disabled
|
|
17
|
+
*
|
|
18
|
+
* Per-database model/baseUrl overrides (from `llm_module` via `ctx.useLlm()`)
|
|
19
|
+
* are applied at request time via `llmConfigStore` (AsyncLocalStorage) in
|
|
20
|
+
* the text-search-plugin resolver wrapper. The same embedder function — and
|
|
21
|
+
* its metering wrapper — handles every request; only the model name and
|
|
22
|
+
* base URL parameters change per-tenant.
|
|
18
23
|
*
|
|
19
24
|
* This plugin is intentionally pure — no billing or metering logic.
|
|
20
25
|
* The optional LlmMeteringPlugin wraps the embedder with billing integration
|
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
* to consume.
|
|
13
13
|
*
|
|
14
14
|
* Resolution order for the embedder:
|
|
15
|
-
* 1. `
|
|
16
|
-
* 2.
|
|
17
|
-
* 3.
|
|
18
|
-
*
|
|
15
|
+
* 1. `defaultEmbedder` from preset options (build-time)
|
|
16
|
+
* 2. Environment variables (EMBEDDER_PROVIDER, EMBEDDER_MODEL, EMBEDDER_BASE_URL)
|
|
17
|
+
* 3. null — LLM features are disabled
|
|
18
|
+
*
|
|
19
|
+
* Per-database model/baseUrl overrides (from `llm_module` via `ctx.useLlm()`)
|
|
20
|
+
* are applied at request time via `llmConfigStore` (AsyncLocalStorage) in
|
|
21
|
+
* the text-search-plugin resolver wrapper. The same embedder function — and
|
|
22
|
+
* its metering wrapper — handles every request; only the model name and
|
|
23
|
+
* base URL parameters change per-tenant.
|
|
19
24
|
*
|
|
20
25
|
* This plugin is intentionally pure — no billing or metering logic.
|
|
21
26
|
* The optional LlmMeteringPlugin wraps the embedder with billing integration
|
package/plugins/rag-plugin.js
CHANGED
|
@@ -73,18 +73,18 @@ function parseHasChunksTag(raw, codec) {
|
|
|
73
73
|
*/
|
|
74
74
|
function discoverChunkTables(build) {
|
|
75
75
|
const chunkTables = [];
|
|
76
|
-
const pgRegistry = build.pgRegistry;
|
|
76
|
+
const pgRegistry = build.input?.pgRegistry ?? build.pgRegistry;
|
|
77
77
|
if (!pgRegistry)
|
|
78
78
|
return chunkTables;
|
|
79
79
|
// Scan all codecs for @hasChunks smart tag
|
|
80
|
-
for (const
|
|
81
|
-
const
|
|
82
|
-
if (!
|
|
80
|
+
for (const codec of Object.values(pgRegistry.pgCodecs || {})) {
|
|
81
|
+
const c = codec;
|
|
82
|
+
if (!c?.attributes)
|
|
83
83
|
continue;
|
|
84
|
-
const tags =
|
|
84
|
+
const tags = c.extensions?.tags;
|
|
85
85
|
if (!tags?.hasChunks)
|
|
86
86
|
continue;
|
|
87
|
-
const info = parseHasChunksTag(tags.hasChunks,
|
|
87
|
+
const info = parseHasChunksTag(tags.hasChunks, c);
|
|
88
88
|
if (info) {
|
|
89
89
|
chunkTables.push(info);
|
|
90
90
|
}
|
|
@@ -138,18 +138,6 @@ function createLlmRagPlugin(ragDefaults = {}) {
|
|
|
138
138
|
let embedder = null;
|
|
139
139
|
let chatCompleter = null;
|
|
140
140
|
const schemaExtension = (0, graphile_utils_1.extendSchema)((build) => {
|
|
141
|
-
// Discover chunk-aware tables from pgRegistry
|
|
142
|
-
chunkTables = discoverChunkTables(build);
|
|
143
|
-
embedder = build.llmEmbedder || null;
|
|
144
|
-
chatCompleter = build.llmChatCompleter || null;
|
|
145
|
-
if (chunkTables.length > 0) {
|
|
146
|
-
console.log(`[graphile-llm] RAG plugin discovered ${chunkTables.length} chunk-aware table(s): ` +
|
|
147
|
-
chunkTables.map((t) => t.parentCodecName).join(', '));
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
console.log('[graphile-llm] RAG plugin found no @hasChunks tables. ' +
|
|
151
|
-
'ragQuery will still work if chunks tables are queried directly.');
|
|
152
|
-
}
|
|
153
141
|
return {
|
|
154
142
|
typeDefs: (0, graphile_utils_1.gql) `
|
|
155
143
|
"""A source chunk retrieved during RAG context assembly."""
|
|
@@ -328,8 +316,8 @@ function createLlmRagPlugin(ragDefaults = {}) {
|
|
|
328
316
|
}
|
|
329
317
|
}
|
|
330
318
|
};
|
|
331
|
-
});
|
|
332
|
-
|
|
319
|
+
}, 'LlmRagPlugin');
|
|
320
|
+
const plugin = {
|
|
333
321
|
...schemaExtension,
|
|
334
322
|
name: 'LlmRagPlugin',
|
|
335
323
|
version: '0.1.0',
|
|
@@ -341,4 +329,26 @@ function createLlmRagPlugin(ragDefaults = {}) {
|
|
|
341
329
|
'VectorCodecPlugin'
|
|
342
330
|
]
|
|
343
331
|
};
|
|
332
|
+
// Wrap the build hook to also discover chunk tables.
|
|
333
|
+
// The build hook runs after all init hooks (including smart tag injection),
|
|
334
|
+
// so @hasChunks tags are guaranteed to be visible.
|
|
335
|
+
const existingBuildHook = plugin.schema.hooks.build;
|
|
336
|
+
plugin.schema.hooks.build = (build) => {
|
|
337
|
+
// Run extendSchema's build hook first (sets up graphql ref)
|
|
338
|
+
build = existingBuildHook(build) || build;
|
|
339
|
+
// Discover chunk tables — runs during build phase when all smart tags are applied
|
|
340
|
+
chunkTables = discoverChunkTables(build);
|
|
341
|
+
embedder = build.llmEmbedder || null;
|
|
342
|
+
chatCompleter = build.llmChatCompleter || null;
|
|
343
|
+
if (chunkTables.length > 0) {
|
|
344
|
+
console.log(`[graphile-llm] RAG plugin discovered ${chunkTables.length} chunk-aware table(s): ` +
|
|
345
|
+
chunkTables.map((t) => t.parentCodecName).join(', '));
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
console.log('[graphile-llm] RAG plugin found no @hasChunks tables. ' +
|
|
349
|
+
'ragQuery will still work if chunks tables are queried directly.');
|
|
350
|
+
}
|
|
351
|
+
return build;
|
|
352
|
+
};
|
|
353
|
+
return plugin;
|
|
344
354
|
}
|
|
@@ -86,8 +86,21 @@ function createLlmTextMutationPlugin() {
|
|
|
86
86
|
*/
|
|
87
87
|
GraphQLInputObjectType_fields(fields, build, context) {
|
|
88
88
|
const { scope: { isPgPatch, isPgBaseInput, isMutationInput, pgCodec } } = context;
|
|
89
|
-
// Only intercept
|
|
90
|
-
|
|
89
|
+
// Only intercept mutation-related input types for table rows.
|
|
90
|
+
// PostGraphile v5 sets isPgPatch on update types, isPgBaseInput on
|
|
91
|
+
// create base types. For tables with `serial PRIMARY KEY`, the create
|
|
92
|
+
// input type (e.g. ArticleInput) has pgCodec set but none of the
|
|
93
|
+
// explicit mutation scope flags — detect via type name convention.
|
|
94
|
+
if (!pgCodec?.attributes)
|
|
95
|
+
return fields;
|
|
96
|
+
const typeName = context.Self.name;
|
|
97
|
+
const hasExplicitMutationScope = isPgPatch || isPgBaseInput || isMutationInput;
|
|
98
|
+
const looksLikeMutationInput = typeName.endsWith('Input') || typeName.endsWith('Patch');
|
|
99
|
+
const isFilterOrCondition = typeName.endsWith('FilterInput')
|
|
100
|
+
|| typeName.endsWith('Filter')
|
|
101
|
+
|| typeName.endsWith('Condition')
|
|
102
|
+
|| typeName === 'VectorNearbyInput';
|
|
103
|
+
if (!hasExplicitMutationScope && (!looksLikeMutationInput || isFilterOrCondition)) {
|
|
91
104
|
return fields;
|
|
92
105
|
}
|
|
93
106
|
const { graphql: { GraphQLString } } = build;
|
|
@@ -24,8 +24,10 @@
|
|
|
24
24
|
* (so the schema is stable) but will return a clear error at execution time.
|
|
25
25
|
*
|
|
26
26
|
* If the embedder returns null (e.g. quota exceeded when the metering
|
|
27
|
-
* plugin is loaded),
|
|
28
|
-
*
|
|
27
|
+
* plugin is loaded), behavior depends on `onQuotaExceeded`:
|
|
28
|
+
* - `'degrade'` (default): silently removes the text field and continues
|
|
29
|
+
* with text-only search as a graceful fallback.
|
|
30
|
+
* - `'throw'`: always throws an error, even if text adapters could handle it.
|
|
29
31
|
*/
|
|
30
32
|
import type { GraphileConfig } from 'graphile-config';
|
|
31
33
|
declare global {
|
|
@@ -35,6 +37,14 @@ declare global {
|
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Recursively walk a `where` argument object and embed any VectorNearbyInput
|
|
42
|
+
* values that have `text` instead of `vector`.
|
|
43
|
+
*
|
|
44
|
+
* If the embedder returns null (e.g. quota exceeded), the text field is
|
|
45
|
+
* removed so the pgvector filter is skipped — graceful text-only fallback.
|
|
46
|
+
*/
|
|
47
|
+
export declare function embedTextInWhere(obj: any, embedder: (text: string) => Promise<number[] | null>, hasTextAdapters: boolean, onQuotaExceeded?: 'degrade' | 'throw'): Promise<void>;
|
|
38
48
|
/**
|
|
39
49
|
* Creates the LlmTextSearchPlugin.
|
|
40
50
|
*
|
|
@@ -42,4 +52,6 @@ declare global {
|
|
|
42
52
|
* existing `vector` field. When a user provides `text`, the plugin's
|
|
43
53
|
* resolver wrapper embeds it before passing to pgvector.
|
|
44
54
|
*/
|
|
45
|
-
export declare function createLlmTextSearchPlugin(
|
|
55
|
+
export declare function createLlmTextSearchPlugin(options?: {
|
|
56
|
+
onQuotaExceeded?: 'degrade' | 'throw';
|
|
57
|
+
}): GraphileConfig.Plugin;
|
|
@@ -25,11 +25,15 @@
|
|
|
25
25
|
* (so the schema is stable) but will return a clear error at execution time.
|
|
26
26
|
*
|
|
27
27
|
* If the embedder returns null (e.g. quota exceeded when the metering
|
|
28
|
-
* plugin is loaded),
|
|
29
|
-
*
|
|
28
|
+
* plugin is loaded), behavior depends on `onQuotaExceeded`:
|
|
29
|
+
* - `'degrade'` (default): silently removes the text field and continues
|
|
30
|
+
* with text-only search as a graceful fallback.
|
|
31
|
+
* - `'throw'`: always throws an error, even if text adapters could handle it.
|
|
30
32
|
*/
|
|
31
33
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.embedTextInWhere = embedTextInWhere;
|
|
32
35
|
exports.createLlmTextSearchPlugin = createLlmTextSearchPlugin;
|
|
36
|
+
const embedder_1 = require("../embedder");
|
|
33
37
|
/**
|
|
34
38
|
* Check if a codec has any pgvector `vector` columns.
|
|
35
39
|
*/
|
|
@@ -49,12 +53,34 @@ function hasVectorColumns(pgCodec) {
|
|
|
49
53
|
* If the embedder returns null (e.g. quota exceeded), the text field is
|
|
50
54
|
* removed so the pgvector filter is skipped — graceful text-only fallback.
|
|
51
55
|
*/
|
|
52
|
-
async function embedTextInWhere(obj, embedder) {
|
|
56
|
+
async function embedTextInWhere(obj, embedder, hasTextAdapters, onQuotaExceeded = 'degrade') {
|
|
53
57
|
if (!obj || typeof obj !== 'object')
|
|
54
58
|
return;
|
|
55
59
|
const pending = [];
|
|
56
60
|
for (const key of Object.keys(obj)) {
|
|
57
61
|
const value = obj[key];
|
|
62
|
+
// Handle unifiedSearch: embed text and transform to { __text, __vector }
|
|
63
|
+
if (key === 'unifiedSearch' && typeof value === 'string' && value.trim().length > 0) {
|
|
64
|
+
pending.push((async () => {
|
|
65
|
+
const startTime = Date.now();
|
|
66
|
+
const vector = await embedder(value);
|
|
67
|
+
const latencyMs = Date.now() - startTime;
|
|
68
|
+
if (vector === null) {
|
|
69
|
+
if (onQuotaExceeded === 'throw' || !hasTextAdapters) {
|
|
70
|
+
throw new Error('unifiedSearch: embedding failed (quota exceeded or provider unavailable). ' +
|
|
71
|
+
(!hasTextAdapters
|
|
72
|
+
? 'No text search adapters available for fallback. '
|
|
73
|
+
: 'onQuotaExceeded is set to \'throw\'. ') +
|
|
74
|
+
'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for text-only fallback.');
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
console.log(`[graphile-llm] unifiedSearch embed: dims=${vector.length}, latency=${latencyMs}ms`);
|
|
79
|
+
// Transform to object shape that graphile-search understands
|
|
80
|
+
obj[key] = { __text: value, __vector: vector };
|
|
81
|
+
})());
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
58
84
|
if (!value || typeof value !== 'object')
|
|
59
85
|
continue;
|
|
60
86
|
// Detect VectorNearbyInput shape: has `text` and no `vector`
|
|
@@ -64,7 +90,10 @@ async function embedTextInWhere(obj, embedder) {
|
|
|
64
90
|
const vector = await embedder(value.text);
|
|
65
91
|
const latencyMs = Date.now() - startTime;
|
|
66
92
|
if (vector === null) {
|
|
67
|
-
|
|
93
|
+
if (onQuotaExceeded === 'throw') {
|
|
94
|
+
throw new Error('VectorNearbyInput: embedding failed (quota exceeded or provider unavailable). ' +
|
|
95
|
+
'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for graceful fallback.');
|
|
96
|
+
}
|
|
68
97
|
delete value.text;
|
|
69
98
|
return;
|
|
70
99
|
}
|
|
@@ -77,12 +106,12 @@ async function embedTextInWhere(obj, embedder) {
|
|
|
77
106
|
}
|
|
78
107
|
// Recurse into nested filter objects (AND, OR, etc.)
|
|
79
108
|
if (!Array.isArray(value)) {
|
|
80
|
-
pending.push(embedTextInWhere(value, embedder));
|
|
109
|
+
pending.push(embedTextInWhere(value, embedder, hasTextAdapters, onQuotaExceeded));
|
|
81
110
|
}
|
|
82
111
|
else {
|
|
83
112
|
// Handle arrays (e.g. AND: [...], OR: [...])
|
|
84
113
|
for (const item of value) {
|
|
85
|
-
pending.push(embedTextInWhere(item, embedder));
|
|
114
|
+
pending.push(embedTextInWhere(item, embedder, hasTextAdapters, onQuotaExceeded));
|
|
86
115
|
}
|
|
87
116
|
}
|
|
88
117
|
}
|
|
@@ -90,6 +119,32 @@ async function embedTextInWhere(obj, embedder) {
|
|
|
90
119
|
await Promise.all(pending);
|
|
91
120
|
}
|
|
92
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Resolve per-DB LLM config overrides from express-context.
|
|
124
|
+
* Returns an LlmConfigOverrides object if per-DB config is available,
|
|
125
|
+
* or null to use defaults. These overrides are string parameters only
|
|
126
|
+
* (model name, base URL) — the embedder function and its metering
|
|
127
|
+
* wrapper stay the same.
|
|
128
|
+
*/
|
|
129
|
+
async function resolveConfigOverrides(graphqlContext) {
|
|
130
|
+
const ctx = graphqlContext?.constructive;
|
|
131
|
+
if (!ctx?.useLlm)
|
|
132
|
+
return null;
|
|
133
|
+
try {
|
|
134
|
+
const llm = await ctx.useLlm();
|
|
135
|
+
if (!llm?.embeddingModel)
|
|
136
|
+
return null;
|
|
137
|
+
return {
|
|
138
|
+
embeddingModel: llm.embeddingModel,
|
|
139
|
+
embeddingBaseUrl: llm.embeddingBaseUrl,
|
|
140
|
+
chatModel: llm.chatModel ?? undefined,
|
|
141
|
+
chatBaseUrl: llm.chatBaseUrl ?? undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
93
148
|
/**
|
|
94
149
|
* Creates the LlmTextSearchPlugin.
|
|
95
150
|
*
|
|
@@ -97,7 +152,8 @@ async function embedTextInWhere(obj, embedder) {
|
|
|
97
152
|
* existing `vector` field. When a user provides `text`, the plugin's
|
|
98
153
|
* resolver wrapper embeds it before passing to pgvector.
|
|
99
154
|
*/
|
|
100
|
-
function createLlmTextSearchPlugin() {
|
|
155
|
+
function createLlmTextSearchPlugin(options = {}) {
|
|
156
|
+
const { onQuotaExceeded = 'degrade' } = options;
|
|
101
157
|
return {
|
|
102
158
|
name: 'LlmTextSearchPlugin',
|
|
103
159
|
version: '0.2.0',
|
|
@@ -116,8 +172,8 @@ function createLlmTextSearchPlugin() {
|
|
|
116
172
|
* The field is optional — clients provide either `text` or `vector`.
|
|
117
173
|
*/
|
|
118
174
|
GraphQLInputObjectType_fields(fields, build, context) {
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
175
|
+
const typeName = context.Self.name;
|
|
176
|
+
if (typeName !== 'VectorNearbyInput') {
|
|
121
177
|
return fields;
|
|
122
178
|
}
|
|
123
179
|
const { graphql: { GraphQLString } } = build;
|
|
@@ -132,35 +188,51 @@ function createLlmTextSearchPlugin() {
|
|
|
132
188
|
},
|
|
133
189
|
/**
|
|
134
190
|
* Wrap connection query resolvers to intercept `where` arguments that
|
|
135
|
-
* contain VectorNearbyInput with `text
|
|
136
|
-
*
|
|
191
|
+
* contain VectorNearbyInput with `text` or `unifiedSearch` with text,
|
|
192
|
+
* embed the text, and inject the resulting vector before the plan executes.
|
|
193
|
+
*
|
|
194
|
+
* For tables with vector columns: embeds VectorNearbyInput.text → vector
|
|
195
|
+
* For ALL tables with unifiedSearch: embeds unifiedSearch text → { __text, __vector }
|
|
137
196
|
*
|
|
138
197
|
* Uses the same v4-style resolver wrapping pattern as graphile-upload-plugin
|
|
139
198
|
* and graphile-bucket-provisioner-plugin.
|
|
140
199
|
*/
|
|
141
200
|
GraphQLObjectType_fields_field(field, build, context) {
|
|
142
201
|
const { scope: { isRootQuery, pgCodec } } = context;
|
|
143
|
-
|
|
144
|
-
|
|
202
|
+
if (!isRootQuery || !pgCodec)
|
|
203
|
+
return field;
|
|
204
|
+
// Wrap if the table has vector columns OR has any searchable columns
|
|
205
|
+
// (for unifiedSearch embedding support)
|
|
206
|
+
const hasVector = hasVectorColumns(pgCodec);
|
|
207
|
+
const hasSearchableColumns = pgCodec.attributes && Object.values(pgCodec.attributes).some((attr) => attr.codec?.name === 'tsvector' || attr.codec?.name === 'vector');
|
|
208
|
+
if (!hasVector && !hasSearchableColumns)
|
|
145
209
|
return field;
|
|
146
|
-
}
|
|
147
210
|
const embedder = build.llmEmbedder;
|
|
148
211
|
if (!embedder)
|
|
149
212
|
return field;
|
|
213
|
+
// Determine if this table has text-based search adapters for fallback logic
|
|
214
|
+
const hasTextAdapters = pgCodec.attributes && Object.values(pgCodec.attributes).some((attr) => attr.codec?.name === 'tsvector');
|
|
150
215
|
const defaultResolver = (obj) => obj[context.scope.fieldName];
|
|
151
216
|
const { resolve: oldResolve = defaultResolver, ...rest } = field;
|
|
152
217
|
return {
|
|
153
218
|
...rest,
|
|
154
219
|
async resolve(source, args, graphqlContext, info) {
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
220
|
+
// Resolve per-DB model/baseUrl overrides from express-context.
|
|
221
|
+
// These are just string parameters — the embedder function
|
|
222
|
+
// (and its metering wrapper) stays the same.
|
|
223
|
+
const overrides = await resolveConfigOverrides(graphqlContext);
|
|
224
|
+
// Run within llmConfigStore so the embedder picks up per-DB
|
|
225
|
+
// model/baseUrl at call time. Metering wraps the same function
|
|
226
|
+
// and is unaffected.
|
|
227
|
+
return embedder_1.llmConfigStore.run(overrides, async () => {
|
|
228
|
+
if (args?.where) {
|
|
229
|
+
await embedTextInWhere(args.where, embedder, !!hasTextAdapters, onQuotaExceeded);
|
|
230
|
+
}
|
|
231
|
+
if (args?.filter) {
|
|
232
|
+
await embedTextInWhere(args.filter, embedder, !!hasTextAdapters, onQuotaExceeded);
|
|
233
|
+
}
|
|
234
|
+
return oldResolve(source, args, graphqlContext, info);
|
|
235
|
+
});
|
|
164
236
|
}
|
|
165
237
|
};
|
|
166
238
|
},
|
package/preset.d.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* - Adds `{column}Text: String` companion fields on mutation inputs for vector columns
|
|
11
11
|
* - Optionally enables billing/metering via the LlmMeteringPlugin
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Included in ConstructivePreset when `enableLlm` is true (the default).
|
|
14
|
+
* Can also be used standalone by adding it directly to your preset's `extends` array.
|
|
15
15
|
*
|
|
16
16
|
* @example
|
|
17
17
|
* ```typescript
|
|
@@ -53,6 +53,17 @@
|
|
|
53
53
|
* })
|
|
54
54
|
* ```
|
|
55
55
|
*
|
|
56
|
+
* @example With strict quota enforcement (fail if embedding unavailable):
|
|
57
|
+
* ```typescript
|
|
58
|
+
* GraphileLlmPreset({
|
|
59
|
+
* defaultEmbedder: { provider: 'openai', model: 'text-embedding-3-small' },
|
|
60
|
+
* metering: true,
|
|
61
|
+
* onQuotaExceeded: 'throw',
|
|
62
|
+
* // → if billing quota is exceeded, queries with unifiedSearch throw an error
|
|
63
|
+
* // → without this, queries silently fall back to text-only search
|
|
64
|
+
* })
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
56
67
|
* @example With custom entity_id resolution (bill per-database):
|
|
57
68
|
* ```typescript
|
|
58
69
|
* GraphileLlmPreset({
|