graphile-llm 0.11.5 → 0.12.0

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 +6 -4
  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
@@ -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
@@ -70,18 +70,18 @@ function parseHasChunksTag(raw, codec) {
70
70
  */
71
71
  function discoverChunkTables(build) {
72
72
  const chunkTables = [];
73
- const pgRegistry = build.pgRegistry;
73
+ const pgRegistry = build.input?.pgRegistry ?? build.pgRegistry;
74
74
  if (!pgRegistry)
75
75
  return chunkTables;
76
76
  // Scan all codecs for @hasChunks smart tag
77
- for (const source of Object.values(pgRegistry.pgResources || {})) {
78
- const codec = source?.codec;
79
- if (!codec?.attributes)
77
+ for (const codec of Object.values(pgRegistry.pgCodecs || {})) {
78
+ const c = codec;
79
+ if (!c?.attributes)
80
80
  continue;
81
- const tags = codec.extensions?.tags;
81
+ const tags = c.extensions?.tags;
82
82
  if (!tags?.hasChunks)
83
83
  continue;
84
- const info = parseHasChunksTag(tags.hasChunks, codec);
84
+ const info = parseHasChunksTag(tags.hasChunks, c);
85
85
  if (info) {
86
86
  chunkTables.push(info);
87
87
  }
@@ -135,18 +135,6 @@ export function createLlmRagPlugin(ragDefaults = {}) {
135
135
  let embedder = null;
136
136
  let chatCompleter = null;
137
137
  const schemaExtension = extendSchema((build) => {
138
- // Discover chunk-aware tables from pgRegistry
139
- chunkTables = discoverChunkTables(build);
140
- embedder = build.llmEmbedder || null;
141
- chatCompleter = build.llmChatCompleter || null;
142
- if (chunkTables.length > 0) {
143
- console.log(`[graphile-llm] RAG plugin discovered ${chunkTables.length} chunk-aware table(s): ` +
144
- chunkTables.map((t) => t.parentCodecName).join(', '));
145
- }
146
- else {
147
- console.log('[graphile-llm] RAG plugin found no @hasChunks tables. ' +
148
- 'ragQuery will still work if chunks tables are queried directly.');
149
- }
150
138
  return {
151
139
  typeDefs: gql `
152
140
  """A source chunk retrieved during RAG context assembly."""
@@ -325,8 +313,8 @@ export function createLlmRagPlugin(ragDefaults = {}) {
325
313
  }
326
314
  }
327
315
  };
328
- });
329
- return {
316
+ }, 'LlmRagPlugin');
317
+ const plugin = {
330
318
  ...schemaExtension,
331
319
  name: 'LlmRagPlugin',
332
320
  version: '0.1.0',
@@ -338,4 +326,26 @@ export function createLlmRagPlugin(ragDefaults = {}) {
338
326
  'VectorCodecPlugin'
339
327
  ]
340
328
  };
329
+ // Wrap the build hook to also discover chunk tables.
330
+ // The build hook runs after all init hooks (including smart tag injection),
331
+ // so @hasChunks tags are guaranteed to be visible.
332
+ const existingBuildHook = plugin.schema.hooks.build;
333
+ plugin.schema.hooks.build = (build) => {
334
+ // Run extendSchema's build hook first (sets up graphql ref)
335
+ build = existingBuildHook(build) || build;
336
+ // Discover chunk tables — runs during build phase when all smart tags are applied
337
+ chunkTables = discoverChunkTables(build);
338
+ embedder = build.llmEmbedder || null;
339
+ chatCompleter = build.llmChatCompleter || null;
340
+ if (chunkTables.length > 0) {
341
+ console.log(`[graphile-llm] RAG plugin discovered ${chunkTables.length} chunk-aware table(s): ` +
342
+ chunkTables.map((t) => t.parentCodecName).join(', '));
343
+ }
344
+ else {
345
+ console.log('[graphile-llm] RAG plugin found no @hasChunks tables. ' +
346
+ 'ragQuery will still work if chunks tables are queried directly.');
347
+ }
348
+ return build;
349
+ };
350
+ return plugin;
341
351
  }
@@ -83,8 +83,21 @@ export function createLlmTextMutationPlugin() {
83
83
  */
84
84
  GraphQLInputObjectType_fields(fields, build, context) {
85
85
  const { scope: { isPgPatch, isPgBaseInput, isMutationInput, pgCodec } } = context;
86
- // Only intercept create/update input types for table rows
87
- if (!pgCodec?.attributes || (!isPgPatch && !isPgBaseInput && !isMutationInput)) {
86
+ // Only intercept mutation-related input types for table rows.
87
+ // PostGraphile v5 sets isPgPatch on update types, isPgBaseInput on
88
+ // create base types. For tables with `serial PRIMARY KEY`, the create
89
+ // input type (e.g. ArticleInput) has pgCodec set but none of the
90
+ // explicit mutation scope flags — detect via type name convention.
91
+ if (!pgCodec?.attributes)
92
+ return fields;
93
+ const typeName = context.Self.name;
94
+ const hasExplicitMutationScope = isPgPatch || isPgBaseInput || isMutationInput;
95
+ const looksLikeMutationInput = typeName.endsWith('Input') || typeName.endsWith('Patch');
96
+ const isFilterOrCondition = typeName.endsWith('FilterInput')
97
+ || typeName.endsWith('Filter')
98
+ || typeName.endsWith('Condition')
99
+ || typeName === 'VectorNearbyInput';
100
+ if (!hasExplicitMutationScope && (!looksLikeMutationInput || isFilterOrCondition)) {
88
101
  return fields;
89
102
  }
90
103
  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;
@@ -24,9 +24,12 @@
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
  */
32
+ import { llmConfigStore } from '../embedder';
30
33
  /**
31
34
  * Check if a codec has any pgvector `vector` columns.
32
35
  */
@@ -46,12 +49,34 @@ function hasVectorColumns(pgCodec) {
46
49
  * If the embedder returns null (e.g. quota exceeded), the text field is
47
50
  * removed so the pgvector filter is skipped — graceful text-only fallback.
48
51
  */
49
- async function embedTextInWhere(obj, embedder) {
52
+ export async function embedTextInWhere(obj, embedder, hasTextAdapters, onQuotaExceeded = 'degrade') {
50
53
  if (!obj || typeof obj !== 'object')
51
54
  return;
52
55
  const pending = [];
53
56
  for (const key of Object.keys(obj)) {
54
57
  const value = obj[key];
58
+ // Handle unifiedSearch: embed text and transform to { __text, __vector }
59
+ if (key === 'unifiedSearch' && typeof value === 'string' && value.trim().length > 0) {
60
+ pending.push((async () => {
61
+ const startTime = Date.now();
62
+ const vector = await embedder(value);
63
+ const latencyMs = Date.now() - startTime;
64
+ if (vector === null) {
65
+ if (onQuotaExceeded === 'throw' || !hasTextAdapters) {
66
+ throw new Error('unifiedSearch: embedding failed (quota exceeded or provider unavailable). ' +
67
+ (!hasTextAdapters
68
+ ? 'No text search adapters available for fallback. '
69
+ : 'onQuotaExceeded is set to \'throw\'. ') +
70
+ 'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for text-only fallback.');
71
+ }
72
+ return;
73
+ }
74
+ console.log(`[graphile-llm] unifiedSearch embed: dims=${vector.length}, latency=${latencyMs}ms`);
75
+ // Transform to object shape that graphile-search understands
76
+ obj[key] = { __text: value, __vector: vector };
77
+ })());
78
+ continue;
79
+ }
55
80
  if (!value || typeof value !== 'object')
56
81
  continue;
57
82
  // Detect VectorNearbyInput shape: has `text` and no `vector`
@@ -61,7 +86,10 @@ async function embedTextInWhere(obj, embedder) {
61
86
  const vector = await embedder(value.text);
62
87
  const latencyMs = Date.now() - startTime;
63
88
  if (vector === null) {
64
- // Embedder returned null (e.g. quota exceeded) — skip vector search
89
+ if (onQuotaExceeded === 'throw') {
90
+ throw new Error('VectorNearbyInput: embedding failed (quota exceeded or provider unavailable). ' +
91
+ 'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for graceful fallback.');
92
+ }
65
93
  delete value.text;
66
94
  return;
67
95
  }
@@ -74,12 +102,12 @@ async function embedTextInWhere(obj, embedder) {
74
102
  }
75
103
  // Recurse into nested filter objects (AND, OR, etc.)
76
104
  if (!Array.isArray(value)) {
77
- pending.push(embedTextInWhere(value, embedder));
105
+ pending.push(embedTextInWhere(value, embedder, hasTextAdapters, onQuotaExceeded));
78
106
  }
79
107
  else {
80
108
  // Handle arrays (e.g. AND: [...], OR: [...])
81
109
  for (const item of value) {
82
- pending.push(embedTextInWhere(item, embedder));
110
+ pending.push(embedTextInWhere(item, embedder, hasTextAdapters, onQuotaExceeded));
83
111
  }
84
112
  }
85
113
  }
@@ -87,6 +115,32 @@ async function embedTextInWhere(obj, embedder) {
87
115
  await Promise.all(pending);
88
116
  }
89
117
  }
118
+ /**
119
+ * Resolve per-DB LLM config overrides from express-context.
120
+ * Returns an LlmConfigOverrides object if per-DB config is available,
121
+ * or null to use defaults. These overrides are string parameters only
122
+ * (model name, base URL) — the embedder function and its metering
123
+ * wrapper stay the same.
124
+ */
125
+ async function resolveConfigOverrides(graphqlContext) {
126
+ const ctx = graphqlContext?.constructive;
127
+ if (!ctx?.useLlm)
128
+ return null;
129
+ try {
130
+ const llm = await ctx.useLlm();
131
+ if (!llm?.embeddingModel)
132
+ return null;
133
+ return {
134
+ embeddingModel: llm.embeddingModel,
135
+ embeddingBaseUrl: llm.embeddingBaseUrl,
136
+ chatModel: llm.chatModel ?? undefined,
137
+ chatBaseUrl: llm.chatBaseUrl ?? undefined,
138
+ };
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
90
144
  /**
91
145
  * Creates the LlmTextSearchPlugin.
92
146
  *
@@ -94,7 +148,8 @@ async function embedTextInWhere(obj, embedder) {
94
148
  * existing `vector` field. When a user provides `text`, the plugin's
95
149
  * resolver wrapper embeds it before passing to pgvector.
96
150
  */
97
- export function createLlmTextSearchPlugin() {
151
+ export function createLlmTextSearchPlugin(options = {}) {
152
+ const { onQuotaExceeded = 'degrade' } = options;
98
153
  return {
99
154
  name: 'LlmTextSearchPlugin',
100
155
  version: '0.2.0',
@@ -113,8 +168,8 @@ export function createLlmTextSearchPlugin() {
113
168
  * The field is optional — clients provide either `text` or `vector`.
114
169
  */
115
170
  GraphQLInputObjectType_fields(fields, build, context) {
116
- const { scope: { inputObjectTypeName } } = context;
117
- if (inputObjectTypeName !== 'VectorNearbyInput') {
171
+ const typeName = context.Self.name;
172
+ if (typeName !== 'VectorNearbyInput') {
118
173
  return fields;
119
174
  }
120
175
  const { graphql: { GraphQLString } } = build;
@@ -129,35 +184,51 @@ export function createLlmTextSearchPlugin() {
129
184
  },
130
185
  /**
131
186
  * Wrap connection query resolvers to intercept `where` arguments that
132
- * contain VectorNearbyInput with `text`, embed the text, and replace
133
- * it with the resulting vector before the plan executes.
187
+ * contain VectorNearbyInput with `text` or `unifiedSearch` with text,
188
+ * embed the text, and inject the resulting vector before the plan executes.
189
+ *
190
+ * For tables with vector columns: embeds VectorNearbyInput.text → vector
191
+ * For ALL tables with unifiedSearch: embeds unifiedSearch text → { __text, __vector }
134
192
  *
135
193
  * Uses the same v4-style resolver wrapping pattern as graphile-upload-plugin
136
194
  * and graphile-bucket-provisioner-plugin.
137
195
  */
138
196
  GraphQLObjectType_fields_field(field, build, context) {
139
197
  const { scope: { isRootQuery, pgCodec } } = context;
140
- // Only wrap root query fields on tables with vector columns
141
- if (!isRootQuery || !pgCodec || !hasVectorColumns(pgCodec)) {
198
+ if (!isRootQuery || !pgCodec)
199
+ return field;
200
+ // Wrap if the table has vector columns OR has any searchable columns
201
+ // (for unifiedSearch embedding support)
202
+ const hasVector = hasVectorColumns(pgCodec);
203
+ const hasSearchableColumns = pgCodec.attributes && Object.values(pgCodec.attributes).some((attr) => attr.codec?.name === 'tsvector' || attr.codec?.name === 'vector');
204
+ if (!hasVector && !hasSearchableColumns)
142
205
  return field;
143
- }
144
206
  const embedder = build.llmEmbedder;
145
207
  if (!embedder)
146
208
  return field;
209
+ // Determine if this table has text-based search adapters for fallback logic
210
+ const hasTextAdapters = pgCodec.attributes && Object.values(pgCodec.attributes).some((attr) => attr.codec?.name === 'tsvector');
147
211
  const defaultResolver = (obj) => obj[context.scope.fieldName];
148
212
  const { resolve: oldResolve = defaultResolver, ...rest } = field;
149
213
  return {
150
214
  ...rest,
151
215
  async resolve(source, args, graphqlContext, info) {
152
- // If the query has a `where` argument, check for text fields
153
- if (args?.where) {
154
- await embedTextInWhere(args.where, embedder);
155
- }
156
- // Also handle `filter` for relay-style connections
157
- if (args?.filter) {
158
- await embedTextInWhere(args.filter, embedder);
159
- }
160
- return oldResolve(source, args, graphqlContext, info);
216
+ // Resolve per-DB model/baseUrl overrides from express-context.
217
+ // These are just string parameters — the embedder function
218
+ // (and its metering wrapper) stays the same.
219
+ const overrides = await resolveConfigOverrides(graphqlContext);
220
+ // Run within llmConfigStore so the embedder picks up per-DB
221
+ // model/baseUrl at call time. Metering wraps the same function
222
+ // and is unaffected.
223
+ return llmConfigStore.run(overrides, async () => {
224
+ if (args?.where) {
225
+ await embedTextInWhere(args.where, embedder, !!hasTextAdapters, onQuotaExceeded);
226
+ }
227
+ if (args?.filter) {
228
+ await embedTextInWhere(args.filter, embedder, !!hasTextAdapters, onQuotaExceeded);
229
+ }
230
+ return oldResolve(source, args, graphqlContext, info);
231
+ });
161
232
  }
162
233
  };
163
234
  },
package/esm/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({
package/esm/preset.js 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({
@@ -75,7 +86,7 @@ import { createLlmTextSearchPlugin } from './plugins/text-search-plugin';
75
86
  * @returns A GraphileConfig.Preset to add to your extends array
76
87
  */
77
88
  export function GraphileLlmPreset(options = {}) {
78
- const { enableTextSearch = true, enableTextMutations = true, enableRag = false, ragDefaults, metering } = options;
89
+ const { enableTextSearch = true, enableTextMutations = true, enableRag = false, onQuotaExceeded, ragDefaults, metering } = options;
79
90
  const plugins = [
80
91
  createLlmModulePlugin(options)
81
92
  ];
@@ -86,7 +97,7 @@ export function GraphileLlmPreset(options = {}) {
86
97
  plugins.push(createLlmMeteringPlugin(meteringConfig));
87
98
  }
88
99
  if (enableTextSearch) {
89
- plugins.push(createLlmTextSearchPlugin());
100
+ plugins.push(createLlmTextSearchPlugin({ onQuotaExceeded }));
90
101
  }
91
102
  if (enableTextMutations) {
92
103
  plugins.push(createLlmTextMutationPlugin());
package/esm/types.d.ts CHANGED
@@ -216,6 +216,19 @@ export interface GraphileLlmOptions {
216
216
  * @default true
217
217
  */
218
218
  enableTextSearch?: boolean;
219
+ /**
220
+ * Controls behavior when the embedder returns null (e.g. billing quota exceeded).
221
+ *
222
+ * - `'degrade'` (default): Skip the vector search silently and fall back to
223
+ * text-only adapters (tsvector, BM25, trgm). If no text adapters are
224
+ * available, throws an error regardless of this setting.
225
+ * - `'throw'`: Always throw an error when the embedder fails, even if
226
+ * text adapters could handle the query. Use this when semantic search
227
+ * is critical and keyword-only results are unacceptable.
228
+ *
229
+ * @default 'degrade'
230
+ */
231
+ onQuotaExceeded?: 'degrade' | 'throw';
219
232
  /**
220
233
  * Whether to add `*Text` companion fields on mutation inputs for vector columns.
221
234
  * @default true
package/index.d.ts CHANGED
@@ -29,17 +29,18 @@
29
29
  * };
30
30
  * ```
31
31
  */
32
- export type { LlmEnvOptions, LlmProviderConfig } from './env';
33
- export { getLlmEnvOptions } from './env';
32
+ export type { LlmEnvOptions, LlmProviderConfig, ResolvedLlmEnvOptions } from './env';
33
+ export { getEnvVars, getEnvOptions, getLlmEnvOptions, llmDefaults } from './env';
34
34
  export { GraphileLlmPreset } from './preset';
35
35
  export { createLlmModulePlugin } from './plugins/llm-module-plugin';
36
36
  export { createLlmRagPlugin } from './plugins/rag-plugin';
37
37
  export { createLlmTextMutationPlugin } from './plugins/text-mutation-plugin';
38
- export { createLlmTextSearchPlugin } from './plugins/text-search-plugin';
38
+ export { createLlmTextSearchPlugin, embedTextInWhere } from './plugins/text-search-plugin';
39
39
  export { createLlmMeteringPlugin } from './plugins/metering-plugin';
40
40
  export type { AgentDiscovery, AgentTableInfo } from './plugins/agent-discovery-plugin';
41
41
  export { clearAgentDiscoveryCache, getAgentDiscovery } from './plugins/agent-discovery-plugin';
42
- export { buildEmbedder, buildEmbedderFromEnv, buildEmbedderFromModule } from './embedder';
42
+ export type { LlmConfigOverrides } from './embedder';
43
+ export { buildEmbedder, buildEmbedderFromEnv, buildEmbedderFromModule, llmConfigStore } from './embedder';
43
44
  export { buildChatCompleter, buildChatCompleterFromEnv, buildChatCompleterFromModule } from './chat';
44
45
  export type { InferenceLogEntry, MeteringContext, MeteringOptions, MeterResult, WithPgClient } from './metering';
45
46
  export { logInferenceUsage, meteredChat, meteredEmbed, QuotaExceededError } from './metering';
package/index.js CHANGED
@@ -31,9 +31,12 @@
31
31
  * ```
32
32
  */
33
33
  Object.defineProperty(exports, "__esModule", { value: true });
34
- exports.invalidateLlmBillingConfig = exports.getLlmBillingConfig = exports.getLlmBillingCacheStats = exports.QuotaExceededError = exports.meteredEmbed = exports.meteredChat = exports.logInferenceUsage = exports.buildChatCompleterFromModule = exports.buildChatCompleterFromEnv = exports.buildChatCompleter = exports.buildEmbedderFromModule = exports.buildEmbedderFromEnv = exports.buildEmbedder = exports.getAgentDiscovery = exports.clearAgentDiscoveryCache = exports.createLlmMeteringPlugin = exports.createLlmTextSearchPlugin = exports.createLlmTextMutationPlugin = exports.createLlmRagPlugin = exports.createLlmModulePlugin = exports.GraphileLlmPreset = exports.getLlmEnvOptions = void 0;
34
+ exports.invalidateLlmBillingConfig = exports.getLlmBillingConfig = exports.getLlmBillingCacheStats = exports.QuotaExceededError = exports.meteredEmbed = exports.meteredChat = exports.logInferenceUsage = exports.buildChatCompleterFromModule = exports.buildChatCompleterFromEnv = exports.buildChatCompleter = exports.llmConfigStore = exports.buildEmbedderFromModule = exports.buildEmbedderFromEnv = exports.buildEmbedder = exports.getAgentDiscovery = exports.clearAgentDiscoveryCache = exports.createLlmMeteringPlugin = exports.embedTextInWhere = exports.createLlmTextSearchPlugin = exports.createLlmTextMutationPlugin = exports.createLlmRagPlugin = exports.createLlmModulePlugin = exports.GraphileLlmPreset = exports.llmDefaults = exports.getLlmEnvOptions = exports.getEnvOptions = exports.getEnvVars = void 0;
35
35
  var env_1 = require("./env");
36
+ Object.defineProperty(exports, "getEnvVars", { enumerable: true, get: function () { return env_1.getEnvVars; } });
37
+ Object.defineProperty(exports, "getEnvOptions", { enumerable: true, get: function () { return env_1.getEnvOptions; } });
36
38
  Object.defineProperty(exports, "getLlmEnvOptions", { enumerable: true, get: function () { return env_1.getLlmEnvOptions; } });
39
+ Object.defineProperty(exports, "llmDefaults", { enumerable: true, get: function () { return env_1.llmDefaults; } });
37
40
  // Preset (recommended entry point)
38
41
  var preset_1 = require("./preset");
39
42
  Object.defineProperty(exports, "GraphileLlmPreset", { enumerable: true, get: function () { return preset_1.GraphileLlmPreset; } });
@@ -46,17 +49,18 @@ var text_mutation_plugin_1 = require("./plugins/text-mutation-plugin");
46
49
  Object.defineProperty(exports, "createLlmTextMutationPlugin", { enumerable: true, get: function () { return text_mutation_plugin_1.createLlmTextMutationPlugin; } });
47
50
  var text_search_plugin_1 = require("./plugins/text-search-plugin");
48
51
  Object.defineProperty(exports, "createLlmTextSearchPlugin", { enumerable: true, get: function () { return text_search_plugin_1.createLlmTextSearchPlugin; } });
52
+ Object.defineProperty(exports, "embedTextInWhere", { enumerable: true, get: function () { return text_search_plugin_1.embedTextInWhere; } });
49
53
  // Metering plugin (opt-in billing integration)
50
54
  var metering_plugin_1 = require("./plugins/metering-plugin");
51
55
  Object.defineProperty(exports, "createLlmMeteringPlugin", { enumerable: true, get: function () { return metering_plugin_1.createLlmMeteringPlugin; } });
52
56
  var agent_discovery_plugin_1 = require("./plugins/agent-discovery-plugin");
53
57
  Object.defineProperty(exports, "clearAgentDiscoveryCache", { enumerable: true, get: function () { return agent_discovery_plugin_1.clearAgentDiscoveryCache; } });
54
58
  Object.defineProperty(exports, "getAgentDiscovery", { enumerable: true, get: function () { return agent_discovery_plugin_1.getAgentDiscovery; } });
55
- // Embedder utilities
56
59
  var embedder_1 = require("./embedder");
57
60
  Object.defineProperty(exports, "buildEmbedder", { enumerable: true, get: function () { return embedder_1.buildEmbedder; } });
58
61
  Object.defineProperty(exports, "buildEmbedderFromEnv", { enumerable: true, get: function () { return embedder_1.buildEmbedderFromEnv; } });
59
62
  Object.defineProperty(exports, "buildEmbedderFromModule", { enumerable: true, get: function () { return embedder_1.buildEmbedderFromModule; } });
63
+ Object.defineProperty(exports, "llmConfigStore", { enumerable: true, get: function () { return embedder_1.llmConfigStore; } });
60
64
  // Chat completion utilities
61
65
  var chat_1 = require("./chat");
62
66
  Object.defineProperty(exports, "buildChatCompleter", { enumerable: true, get: function () { return chat_1.buildChatCompleter; } });
package/metering.d.ts CHANGED
@@ -90,9 +90,7 @@ export interface InferenceLogEntry {
90
90
  }
91
91
  /**
92
92
  * Write a row to the usage_log_inference table.
93
- * Gracefully skips if the inference_log_module is not provisioned.
94
- *
95
- * TODO: Also write to child (generated) database when dual-write is needed.
93
+ * Delegates to the shared BillingClient from express-context.
96
94
  */
97
95
  export declare function logInferenceUsage(ctx: MeteringContext, entry: InferenceLogEntry): Promise<void>;
98
96
  /**