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.
Files changed (41) hide show
  1. package/__tests__/graphile-llm.test.js +318 -37
  2. package/__tests__/unified-search-embedding.test.d.ts +10 -0
  3. package/__tests__/unified-search-embedding.test.js +176 -0
  4. package/embedder.d.ts +24 -4
  5. package/embedder.js +36 -7
  6. package/env.d.ts +6 -28
  7. package/env.js +10 -47
  8. package/esm/__tests__/graphile-llm.test.js +319 -38
  9. package/esm/__tests__/unified-search-embedding.test.d.ts +10 -0
  10. package/esm/__tests__/unified-search-embedding.test.js +174 -0
  11. package/esm/embedder.d.ts +24 -4
  12. package/esm/embedder.js +35 -7
  13. package/esm/env.d.ts +6 -28
  14. package/esm/env.js +5 -46
  15. package/esm/index.d.ts +5 -4
  16. package/esm/index.js +3 -4
  17. package/esm/metering.d.ts +1 -3
  18. package/esm/metering.js +48 -108
  19. package/esm/plugins/llm-module-plugin.d.ts +9 -4
  20. package/esm/plugins/llm-module-plugin.js +9 -4
  21. package/esm/plugins/rag-plugin.js +30 -20
  22. package/esm/plugins/text-mutation-plugin.js +15 -2
  23. package/esm/plugins/text-search-plugin.d.ts +15 -3
  24. package/esm/plugins/text-search-plugin.js +94 -23
  25. package/esm/preset.d.ts +13 -2
  26. package/esm/preset.js +15 -4
  27. package/esm/types.d.ts +13 -0
  28. package/index.d.ts +5 -4
  29. package/index.js +6 -2
  30. package/metering.d.ts +1 -3
  31. package/metering.js +48 -108
  32. package/package.json +7 -5
  33. package/plugins/llm-module-plugin.d.ts +9 -4
  34. package/plugins/llm-module-plugin.js +9 -4
  35. package/plugins/rag-plugin.js +30 -20
  36. package/plugins/text-mutation-plugin.js +15 -2
  37. package/plugins/text-search-plugin.d.ts +15 -3
  38. package/plugins/text-search-plugin.js +95 -23
  39. package/preset.d.ts +13 -2
  40. package/preset.js +15 -4
  41. 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
- // ─── Billing SQL Helpers ────────────────────────────────────────────────────
26
+ const express_context_1 = require("@constructive-io/express-context");
27
+ // ─── Billing Client Adapter ─────────────────────────────────────────────────
27
28
  /**
28
- * Check if the entity has sufficient quota for the requested amount.
29
- * Returns true if the call is allowed, false if quota is exceeded.
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
- async function recordUsage(pgClient, billing, entityId, meterSlug, amount, metadata) {
51
- try {
52
- const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
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
- * Gracefully skips if the inference_log_module is not provisioned.
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
- if (!ctx.inferenceLog)
68
- return;
69
- const { schema, tableName } = ctx.inferenceLog;
70
- const sql = `INSERT INTO "${schema}"."${tableName}" (
71
- database_id, entity_id, actor_id,
72
- model, provider, service, operation,
73
- input_tokens, output_tokens, total_tokens,
74
- cache_read_tokens, cache_write_tokens,
75
- latency_ms, rag_enabled, chunks_retrieved,
76
- embedding_model, embedding_latency_ms,
77
- status, error_type, raw_usage
78
- ) VALUES (
79
- $1, $2, $3,
80
- $4, $5, $6, $7,
81
- $8, $9, $10,
82
- $11, $12,
83
- $13, $14, $15,
84
- $16, $17,
85
- $18, $19, $20
86
- )`;
87
- try {
88
- await ctx.withPgClient(ctx.pgSettings, async (pgClient) => {
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
- let allowed = true;
146
- try {
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
- ctx.withPgClient(ctx.pgSettings, async (pgClient) => {
188
- await recordUsage(pgClient, ctx.billing, ctx.entityId, meterSlug, promptTokens, {
189
- request_id: ctx.requestId,
190
- input_chars: text.length,
191
- prompt_tokens: promptTokens,
192
- dims: embedding.length,
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
- let allowed = true;
262
- try {
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
- ctx.withPgClient(ctx.pgSettings, async (pgClient) => {
306
- await recordUsage(pgClient, ctx.billing, ctx.entityId, meterSlug, usage.totalTokens, {
307
- request_id: ctx.requestId,
308
- input_tokens: usage.input,
309
- output_tokens: usage.output,
310
- cache_read_tokens: usage.cacheRead,
311
- cache_write_tokens: usage.cacheWrite,
312
- messages_count: messages.length,
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.11.5",
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": "^2.0.0",
33
- "graphile-cache": "^3.13.0"
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.16.0",
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": "2558a0cdb58c815933af8bfe6769cd59014c77cf"
78
+ "gitHead": "fc6f9780ea1cab2c9344c2e392649ae644e84849"
77
79
  }
@@ -11,10 +11,15 @@
11
11
  * to consume.
12
12
  *
13
13
  * Resolution order for the embedder:
14
- * 1. `llm_module` from api_modules (per-database, loaded at schema build time)
15
- * 2. `defaultEmbedder` from preset options (dev/testing fallback)
16
- * 3. Environment variables (EMBEDDER_PROVIDER, EMBEDDER_MODEL, EMBEDDER_BASE_URL)
17
- * 4. null — LLM features are disabled
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. `llm_module` from api_modules (per-database, loaded at schema build time)
16
- * 2. `defaultEmbedder` from preset options (dev/testing fallback)
17
- * 3. Environment variables (EMBEDDER_PROVIDER, EMBEDDER_MODEL, EMBEDDER_BASE_URL)
18
- * 4. null — LLM features are disabled
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
@@ -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 source of Object.values(pgRegistry.pgResources || {})) {
81
- const codec = source?.codec;
82
- if (!codec?.attributes)
80
+ for (const codec of Object.values(pgRegistry.pgCodecs || {})) {
81
+ const c = codec;
82
+ if (!c?.attributes)
83
83
  continue;
84
- const tags = codec.extensions?.tags;
84
+ const tags = c.extensions?.tags;
85
85
  if (!tags?.hasChunks)
86
86
  continue;
87
- const info = parseHasChunksTag(tags.hasChunks, codec);
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
- return {
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 create/update input types for table rows
90
- if (!pgCodec?.attributes || (!isPgPatch && !isPgBaseInput && !isMutationInput)) {
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), the text field is silently removed — the query
28
- * continues with text-only search as a graceful fallback.
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(): GraphileConfig.Plugin;
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), the text field is silently removed — the query
29
- * continues with text-only search as a graceful fallback.
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
- // Embedder returned null (e.g. quota exceeded) — skip vector search
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 { scope: { inputObjectTypeName } } = context;
120
- if (inputObjectTypeName !== 'VectorNearbyInput') {
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`, embed the text, and replace
136
- * it with the resulting vector before the plan executes.
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
- // Only wrap root query fields on tables with vector columns
144
- if (!isRootQuery || !pgCodec || !hasVectorColumns(pgCodec)) {
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
- // If the query has a `where` argument, check for text fields
156
- if (args?.where) {
157
- await embedTextInWhere(args.where, embedder);
158
- }
159
- // Also handle `filter` for relay-style connections
160
- if (args?.filter) {
161
- await embedTextInWhere(args.filter, embedder);
162
- }
163
- return oldResolve(source, args, graphqlContext, info);
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
- * This preset is standalone — it is NOT included in ConstructivePreset by default.
14
- * Projects that want LLM features opt in by adding it to their preset.
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({