graphile-llm 1.21.6 → 1.21.7

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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Plan-time text-to-vector embedding.
3
+ *
4
+ * Under Grafast a field's `resolve()` runs after its plan has executed, so the
5
+ * SQL has already been built by the time a resolver wrapper could embed text.
6
+ * Instead, input fields that carry text to embed are tagged with an
7
+ * `EmbedRule` where they are declared, and `planTimeEmbedding` (run from a
8
+ * `finalize` hook) wraps the plan of every object field whose arguments can
9
+ * reach a tagged input field. The wrapper shadows `fieldArgs.getRaw` so every
10
+ * consumer of those arguments (connection-filter's `where` applyPlan, the
11
+ * create/update `args.apply`) reads a step that embeds the text first.
12
+ */
13
+ import { access, ConstantStep, context, lambda, object } from 'grafast';
14
+ import { llmConfigStore } from './embedder';
15
+ export const EMBED_EXT = 'graphileLlmEmbed';
16
+ export function embedField(rule, spec) {
17
+ return { ...spec, extensions: { ...spec.extensions, [EMBED_EXT]: rule } };
18
+ }
19
+ /**
20
+ * `build.llmEmbedder` resolves to `{ embedding }` from the providers and to
21
+ * `number[] | null` once the metering plugin has wrapped it.
22
+ */
23
+ export function toVectorEmbedder(raw) {
24
+ if (typeof raw !== 'function')
25
+ return null;
26
+ return async (text) => {
27
+ const result = await raw(text);
28
+ const vector = Array.isArray(result) ? result : result?.embedding;
29
+ return Array.isArray(vector) && vector.length > 0 ? vector : null;
30
+ };
31
+ }
32
+ function isNonEmptyVector(value) {
33
+ return Array.isArray(value) && value.length > 0;
34
+ }
35
+ export async function resolveConfigOverrides(graphqlContext) {
36
+ const ctx = graphqlContext?.constructive;
37
+ if (!ctx?.useLlm)
38
+ return null;
39
+ try {
40
+ const llm = await ctx.useLlm();
41
+ if (!llm?.embeddingModel)
42
+ return null;
43
+ return {
44
+ embeddingModel: llm.embeddingModel,
45
+ embeddingBaseUrl: llm.embeddingBaseUrl,
46
+ chatModel: llm.chatModel ?? undefined,
47
+ chatBaseUrl: llm.chatBaseUrl ?? undefined
48
+ };
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /** Binds the embedder to one request's per-database model overrides, resolved on first use. */
55
+ function forRequest(embedder, graphqlContext) {
56
+ if (!embedder)
57
+ return null;
58
+ let overrides;
59
+ return async (text) => {
60
+ overrides ??= resolveConfigOverrides(graphqlContext);
61
+ const resolved = await overrides;
62
+ const startTime = Date.now();
63
+ const vector = await llmConfigStore.run(resolved, () => embedder(text));
64
+ if (vector) {
65
+ console.log(`[graphile-llm] embed: dims=${vector.length}, latency=${Date.now() - startTime}ms`);
66
+ }
67
+ return vector;
68
+ };
69
+ }
70
+ export function createEmbedIndex(graphql) {
71
+ const { getNamedType, isInputObjectType, isListType, isNonNullType } = graphql;
72
+ const reachesMemo = new WeakMap();
73
+ const inputObjectFields = (type) => Object.values(type.getFields());
74
+ const namedInputObject = (field) => {
75
+ const named = getNamedType(field.type);
76
+ return isInputObjectType(named) ? named : null;
77
+ };
78
+ // Filter types are cyclic (AND/OR/NOT, forward and backward relations), so
79
+ // reachability is a fixed point over everything reachable from `type`; a
80
+ // depth-first walk that memoizes a provisional answer mid-cycle gets it wrong.
81
+ function reaches(type) {
82
+ const start = getNamedType(type);
83
+ if (!isInputObjectType(start))
84
+ return false;
85
+ const known = reachesMemo.get(start);
86
+ if (known !== undefined)
87
+ return known;
88
+ const pending = [];
89
+ const stack = [start];
90
+ const seen = new Set();
91
+ while (stack.length > 0) {
92
+ const node = stack.pop();
93
+ if (seen.has(node) || reachesMemo.has(node))
94
+ continue;
95
+ seen.add(node);
96
+ pending.push(node);
97
+ for (const field of inputObjectFields(node)) {
98
+ const child = namedInputObject(field);
99
+ if (child)
100
+ stack.push(child);
101
+ }
102
+ }
103
+ const hit = new Set(pending.filter((node) => inputObjectFields(node).some((f) => f.extensions?.[EMBED_EXT])));
104
+ const isHit = (node) => hit.has(node) || reachesMemo.get(node) === true;
105
+ let changed = true;
106
+ while (changed) {
107
+ changed = false;
108
+ for (const node of pending) {
109
+ if (hit.has(node))
110
+ continue;
111
+ if (inputObjectFields(node).some((f) => {
112
+ const child = namedInputObject(f);
113
+ return child !== null && isHit(child);
114
+ })) {
115
+ hit.add(node);
116
+ changed = true;
117
+ }
118
+ }
119
+ }
120
+ for (const node of pending)
121
+ reachesMemo.set(node, hit.has(node));
122
+ return reachesMemo.get(start);
123
+ }
124
+ async function applyRule(rule, fieldName, typeName, value, next, embed) {
125
+ const text = value[fieldName];
126
+ switch (rule.kind) {
127
+ case 'nearby': {
128
+ delete next[fieldName];
129
+ if (isNonEmptyVector(value[rule.into]))
130
+ return;
131
+ if (typeof text !== 'string') {
132
+ throw new Error(`${typeName}: provide either \`${rule.into}\` or \`${fieldName}\`.`);
133
+ }
134
+ if (!embed)
135
+ return;
136
+ const vector = await embed(text);
137
+ if (vector) {
138
+ next[rule.into] = vector;
139
+ }
140
+ else if (rule.onQuotaExceeded === 'throw') {
141
+ throw new Error(`${typeName}: embedding failed (quota exceeded or provider unavailable). ` +
142
+ 'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for graceful fallback.');
143
+ }
144
+ return;
145
+ }
146
+ case 'unified': {
147
+ if (typeof text !== 'string' || text.trim().length === 0 || !embed)
148
+ return;
149
+ const vector = await embed(text);
150
+ if (vector) {
151
+ next[fieldName] = { __text: text, __vector: vector };
152
+ }
153
+ else if (rule.onQuotaExceeded === 'throw') {
154
+ throw new Error(`${fieldName}: embedding failed (quota exceeded or provider unavailable). ` +
155
+ 'onQuotaExceeded is set to \'throw\'. ' +
156
+ 'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for text-only fallback.');
157
+ }
158
+ return;
159
+ }
160
+ case 'companion': {
161
+ delete next[fieldName];
162
+ if (isNonEmptyVector(value[rule.into]))
163
+ return;
164
+ if (typeof text !== 'string') {
165
+ if (rule.required) {
166
+ throw new Error(`${typeName}: provide either \`${rule.into}\` or \`${fieldName}\`.`);
167
+ }
168
+ return;
169
+ }
170
+ if (!embed) {
171
+ throw new Error(`EMBED_NOT_CONFIGURED: Cannot embed ${fieldName} — no embedding provider configured. ` +
172
+ 'Set defaultEmbedder in GraphileLlmPreset options or EMBEDDER_PROVIDER env var.');
173
+ }
174
+ const vector = await embed(text);
175
+ if (!vector) {
176
+ throw new Error(`EMBED_QUOTA_EXCEEDED: Cannot embed ${fieldName} — embedding quota exceeded. ` +
177
+ 'Upgrade your plan or wait for the next billing period.');
178
+ }
179
+ next[rule.into] = vector;
180
+ return;
181
+ }
182
+ }
183
+ }
184
+ /** Returns `value` itself when its type cannot reach a tagged field. */
185
+ async function embedValue(type, value, embed) {
186
+ if (value == null)
187
+ return value;
188
+ const inner = isNonNullType(type) ? type.ofType : type;
189
+ if (isListType(inner)) {
190
+ if (!Array.isArray(value) || !reaches(inner.ofType))
191
+ return value;
192
+ return Promise.all(value.map((item) => embedValue(inner.ofType, item, embed)));
193
+ }
194
+ if (!isInputObjectType(inner) || !reaches(inner))
195
+ return value;
196
+ const next = { ...value };
197
+ const jobs = [];
198
+ for (const [name, field] of Object.entries(inner.getFields())) {
199
+ const rule = field.extensions?.[EMBED_EXT];
200
+ if (rule) {
201
+ jobs.push(applyRule(rule, name, inner.name, value, next, embed));
202
+ }
203
+ else if (value[name] != null && reaches(field.type)) {
204
+ jobs.push(embedValue(field.type, value[name], embed).then((embedded) => { next[name] = embedded; }));
205
+ }
206
+ }
207
+ await Promise.all(jobs);
208
+ return next;
209
+ }
210
+ return { reaches, embedValue };
211
+ }
212
+ function withEmbeddedArgs(originalPlan, argNames, embeddable, index, embedder) {
213
+ return function planWithEmbeddedArgs(parent, fieldArgs, info) {
214
+ const originalGetRaw = fieldArgs.getRaw;
215
+ const roots = new Map();
216
+ const rootStep = (argName) => {
217
+ let $root = roots.get(argName);
218
+ if ($root)
219
+ return $root;
220
+ const $raw = originalGetRaw.call(fieldArgs, argName);
221
+ const absent = $raw instanceof ConstantStep && $raw.data === undefined;
222
+ if (absent || !embeddable.has(argName)) {
223
+ $root = $raw;
224
+ }
225
+ else {
226
+ const type = fieldArgs.typeAt(argName);
227
+ $root = $raw.withLayerPlan(() => lambda({ raw: $raw, ctx: context() }, ({ raw, ctx }) => index.embedValue(type, raw, forRequest(embedder, ctx))));
228
+ }
229
+ roots.set(argName, $root);
230
+ return $root;
231
+ };
232
+ // grafast reads `fieldArgs.getRaw` through the object (apply, processAfter,
233
+ // `$arg` getters, getBaked), so shadowing it here reaches every consumer.
234
+ fieldArgs.getRaw = function getRawEmbedded(path) {
235
+ if (path === undefined) {
236
+ const steps = Object.fromEntries(argNames.map((n) => [n, rootStep(n)]));
237
+ return steps[argNames[0]].withLayerPlan(() => object(steps));
238
+ }
239
+ const [argName, ...rest] = typeof path === 'string' ? [path] : path;
240
+ if (!embeddable.has(argName))
241
+ return originalGetRaw.call(fieldArgs, path);
242
+ const $root = rootStep(argName);
243
+ return (rest.length === 0 ? $root : $root.withLayerPlan(() => access($root, rest)));
244
+ };
245
+ return originalPlan(parent, fieldArgs, info);
246
+ };
247
+ }
248
+ const processedSchemas = new WeakSet();
249
+ /**
250
+ * Wraps the plan of every object field whose arguments reach a tagged input
251
+ * field. Runs in `finalize` because args are attached after the
252
+ * `GraphQLObjectType_fields_field` hook. Idempotent per schema, so every plugin
253
+ * that tags fields can call it without depending on the others being loaded.
254
+ */
255
+ export function planTimeEmbedding(schema, build) {
256
+ if (processedSchemas.has(schema))
257
+ return schema;
258
+ processedSchemas.add(schema);
259
+ const { graphql } = build;
260
+ const index = createEmbedIndex(graphql);
261
+ const embedder = toVectorEmbedder(build.llmEmbedder);
262
+ for (const type of Object.values(schema.getTypeMap())) {
263
+ if (!graphql.isObjectType(type) || type.name.startsWith('__'))
264
+ continue;
265
+ for (const field of Object.values(type.getFields())) {
266
+ const grafastExt = field.extensions?.grafast;
267
+ if (typeof grafastExt?.plan !== 'function')
268
+ continue;
269
+ const embeddable = new Set(field.args.filter((a) => index.reaches(a.type)).map((a) => a.name));
270
+ if (embeddable.size === 0)
271
+ continue;
272
+ const argNames = field.args.map((a) => a.name);
273
+ grafastExt.plan = withEmbeddedArgs(grafastExt.plan, argNames, embeddable, index, embedder);
274
+ }
275
+ }
276
+ return schema;
277
+ }
package/esm/embedder.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * embeddings. Currently supports Ollama via @agentic-kit/ollama.
6
6
  *
7
7
  * Per-request model/baseUrl overrides are read from AsyncLocalStorage
8
- * (`llmConfigStore`). The text-search-plugin sets these overrides from
8
+ * (`llmConfigStore`). embed-inputs.ts sets these overrides from
9
9
  * the database's `llm_module` config via `ctx.useLlm()`. This means
10
10
  * the same embedder function — and its metering wrapper — handle every
11
11
  * request, regardless of which tenant's model config applies.
@@ -25,7 +25,7 @@ export interface LlmConfigOverrides {
25
25
  /**
26
26
  * AsyncLocalStorage for per-request LLM config overrides.
27
27
  *
28
- * Set by the text-search-plugin resolver wrapper (from ctx.useLlm()),
28
+ * Set by the plan-time embedding step in embed-inputs.ts (from ctx.useLlm()),
29
29
  * read by the embedder at call time. The metering wrapper sits between
30
30
  * them and is completely unaware — it just wraps the same function.
31
31
  */
package/esm/embedder.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * embeddings. Currently supports Ollama via @agentic-kit/ollama.
6
6
  *
7
7
  * Per-request model/baseUrl overrides are read from AsyncLocalStorage
8
- * (`llmConfigStore`). The text-search-plugin sets these overrides from
8
+ * (`llmConfigStore`). embed-inputs.ts sets these overrides from
9
9
  * the database's `llm_module` config via `ctx.useLlm()`. This means
10
10
  * the same embedder function — and its metering wrapper — handle every
11
11
  * request, regardless of which tenant's model config applies.
@@ -16,7 +16,7 @@ import { getLlmEnvOptions } from './env';
16
16
  /**
17
17
  * AsyncLocalStorage for per-request LLM config overrides.
18
18
  *
19
- * Set by the text-search-plugin resolver wrapper (from ctx.useLlm()),
19
+ * Set by the plan-time embedding step in embed-inputs.ts (from ctx.useLlm()),
20
20
  * read by the embedder at call time. The metering wrapper sits between
21
21
  * them and is completely unaware — it just wraps the same function.
22
22
  */
package/esm/index.d.ts CHANGED
@@ -36,6 +36,8 @@ 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
38
  export { createLlmTextSearchPlugin, embedTextInWhere } from './plugins/text-search-plugin';
39
+ export type { EmbedRule } from './embed-inputs';
40
+ export { embedField, planTimeEmbedding } from './embed-inputs';
39
41
  export { createLlmMeteringPlugin } from './plugins/metering-plugin';
40
42
  export type { AgentDiscovery, AgentTableInfo } from './plugins/agent-discovery-plugin';
41
43
  export { clearAgentDiscoveryCache, getAgentDiscovery } from './plugins/agent-discovery-plugin';
package/esm/index.js CHANGED
@@ -37,6 +37,7 @@ export { createLlmModulePlugin } from './plugins/llm-module-plugin';
37
37
  export { createLlmRagPlugin } from './plugins/rag-plugin';
38
38
  export { createLlmTextMutationPlugin } from './plugins/text-mutation-plugin';
39
39
  export { createLlmTextSearchPlugin, embedTextInWhere } from './plugins/text-search-plugin';
40
+ export { embedField, planTimeEmbedding } from './embed-inputs';
40
41
  // Metering plugin (opt-in billing integration)
41
42
  export { createLlmMeteringPlugin } from './plugins/metering-plugin';
42
43
  export { clearAgentDiscoveryCache, getAgentDiscovery } from './plugins/agent-discovery-plugin';
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * Per-database model/baseUrl overrides (from `llm_module` via `ctx.useLlm()`)
19
19
  * are applied at request time via `llmConfigStore` (AsyncLocalStorage) in
20
- * the text-search-plugin resolver wrapper. The same embedder function — and
20
+ * the plan-time embedding step (embed-inputs.ts). The same embedder function — and
21
21
  * its metering wrapper — handles every request; only the model name and
22
22
  * base URL parameters change per-tenant.
23
23
  *
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * Per-database model/baseUrl overrides (from `llm_module` via `ctx.useLlm()`)
19
19
  * are applied at request time via `llmConfigStore` (AsyncLocalStorage) in
20
- * the text-search-plugin resolver wrapper. The same embedder function — and
20
+ * the plan-time embedding step (embed-inputs.ts). The same embedder function — and
21
21
  * its metering wrapper — handles every request; only the model name and
22
22
  * base URL parameters change per-tenant.
23
23
  *
@@ -17,9 +17,10 @@
17
17
  * filter/query-side text-to-vector). Together they let clients work entirely
18
18
  * with text/prompts instead of raw float vectors.
19
19
  *
20
- * Runtime embedding uses the v4-style resolver wrapping approach (same as
21
- * graphile-upload-plugin and graphile-bucket-provisioner-plugin). grafserv v5
22
- * supports this through its backwards-compatibility layer.
20
+ * The text is embedded while the mutation is planned (see embed-inputs.ts),
21
+ * before the INSERT/UPDATE is built. On a create input for a NOT NULL vector
22
+ * column the vector field becomes optional, and one of `{col}` or `{col}Text`
23
+ * is required at execution time. If both are given, the vector is used.
23
24
  *
24
25
  * The companion fields are only added when the LLM plugin is loaded.
25
26
  * If no embedder is configured, the fields are still registered for schema
@@ -39,10 +40,7 @@ declare global {
39
40
  * Creates the LlmTextMutationPlugin.
40
41
  *
41
42
  * Hooks into GraphQLInputObjectType_fields for create/update input types
42
- * and adds `{columnName}Text: String` for each vector column.
43
- *
44
- * Also wraps mutation resolvers via GraphQLObjectType_fields_field to
45
- * intercept `*Text` companion field values, embed them, and inject the
46
- * resulting vectors before the mutation executes.
43
+ * and adds `{columnName}Text: String` for each vector column, tagged for
44
+ * plan-time embedding.
47
45
  */
48
46
  export declare function createLlmTextMutationPlugin(): GraphileConfig.Plugin;
@@ -17,9 +17,10 @@
17
17
  * filter/query-side text-to-vector). Together they let clients work entirely
18
18
  * with text/prompts instead of raw float vectors.
19
19
  *
20
- * Runtime embedding uses the v4-style resolver wrapping approach (same as
21
- * graphile-upload-plugin and graphile-bucket-provisioner-plugin). grafserv v5
22
- * supports this through its backwards-compatibility layer.
20
+ * The text is embedded while the mutation is planned (see embed-inputs.ts),
21
+ * before the INSERT/UPDATE is built. On a create input for a NOT NULL vector
22
+ * column the vector field becomes optional, and one of `{col}` or `{col}Text`
23
+ * is required at execution time. If both are given, the vector is used.
23
24
  *
24
25
  * The companion fields are only added when the LLM plugin is loaded.
25
26
  * If no embedder is configured, the fields are still registered for schema
@@ -27,51 +28,24 @@
27
28
  */
28
29
  import 'graphile-build';
29
30
  import 'graphile-build-pg';
31
+ import { embedField, planTimeEmbedding } from '../embed-inputs';
30
32
  /**
31
33
  * Check if a codec is the pgvector `vector` type.
32
34
  */
33
35
  function isVectorCodec(codec) {
34
36
  return codec?.name === 'vector';
35
37
  }
36
- /**
37
- * Collect the text→vector field mapping for a codec's vector columns.
38
- * Returns a map of textFieldName → vectorFieldName.
39
- */
40
- function getTextToVectorMapping(pgCodec, build) {
41
- const mapping = {};
42
- if (!pgCodec?.attributes)
43
- return mapping;
44
- const attributeFieldNames = new Set(Object.keys(pgCodec.attributes).map((attributeName) => build.inflection.attribute({ codec: pgCodec, attributeName })));
45
- for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
46
- if (isVectorCodec(attribute.codec)) {
47
- const fieldName = build.inflection.attribute({
48
- codec: pgCodec,
49
- attributeName
50
- });
51
- const textFieldName = `${fieldName}Text`;
52
- // Skip when a physical column already inflects to the companion name
53
- // (e.g. `embedding_text` next to `embedding`) — the physical field owns it.
54
- if (attributeFieldNames.has(textFieldName))
55
- continue;
56
- mapping[textFieldName] = fieldName;
57
- }
58
- }
59
- return mapping;
60
- }
61
38
  /**
62
39
  * Creates the LlmTextMutationPlugin.
63
40
  *
64
41
  * Hooks into GraphQLInputObjectType_fields for create/update input types
65
- * and adds `{columnName}Text: String` for each vector column.
66
- *
67
- * Also wraps mutation resolvers via GraphQLObjectType_fields_field to
68
- * intercept `*Text` companion field values, embed them, and inject the
69
- * resulting vectors before the mutation executes.
42
+ * and adds `{columnName}Text: String` for each vector column, tagged for
43
+ * plan-time embedding.
70
44
  */
71
45
  export function createLlmTextMutationPlugin() {
72
46
  return {
73
47
  name: 'LlmTextMutationPlugin',
74
- version: '0.2.0',
48
+ version: '0.3.0',
75
49
  description: 'Adds text companion fields on mutation inputs for vector columns — ' +
76
50
  'text is embedded server-side before storing',
77
51
  after: [
@@ -106,7 +80,7 @@ export function createLlmTextMutationPlugin() {
106
80
  if (!hasExplicitMutationScope && (!looksLikeMutationInput || isFilterOrCondition)) {
107
81
  return fields;
108
82
  }
109
- const { graphql: { GraphQLString } } = build;
83
+ const { graphql: { GraphQLString, getNullableType, isNonNullType } } = build;
110
84
  // Find vector columns on this table
111
85
  const vectorColumns = [];
112
86
  for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
@@ -131,94 +105,27 @@ export function createLlmTextMutationPlugin() {
131
105
  if (textFieldName in newFields) {
132
106
  continue;
133
107
  }
108
+ const vectorField = newFields[fieldName];
109
+ const required = !!vectorField && isNonNullType(vectorField.type);
110
+ if (required) {
111
+ newFields = {
112
+ ...newFields,
113
+ [fieldName]: { ...vectorField, type: getNullableType(vectorField.type) }
114
+ };
115
+ }
134
116
  newFields = build.extend(newFields, {
135
- [textFieldName]: {
117
+ [textFieldName]: embedField({ kind: 'companion', into: fieldName, required }, {
136
118
  type: GraphQLString,
137
119
  description: `Natural language text to embed server-side into the \`${fieldName}\` vector column. ` +
138
- `Mutually exclusive with \`${fieldName}\` — provide one or the other. ` +
120
+ `Provide \`${fieldName}\` or \`${textFieldName}\`; if both are given, \`${fieldName}\` is used. ` +
139
121
  'Requires the LLM plugin to be configured with an embedding provider.'
140
- }
122
+ })
141
123
  }, `LlmTextMutationPlugin adding ${textFieldName} companion field for vector column '${columnName}'`);
142
124
  }
143
125
  return newFields;
144
126
  },
145
- /**
146
- * Wrap create/update mutation resolvers to intercept `*Text` companion
147
- * field values, embed them using the configured embedder, and inject
148
- * the resulting vector into the corresponding vector field.
149
- *
150
- * Uses the same v4-style resolver wrapping pattern as graphile-upload-plugin
151
- * and graphile-bucket-provisioner-plugin. grafserv v5 supports this through
152
- * its backwards-compatibility layer.
153
- *
154
- * If the embedder returns null (e.g. quota exceeded), throws an error.
155
- */
156
- GraphQLObjectType_fields_field(field, build, context) {
157
- const { scope: { isRootMutation, fieldName, pgCodec } } = context;
158
- // Only wrap root mutation fields on tables with attributes
159
- if (!isRootMutation || !pgCodec || !pgCodec.attributes) {
160
- return field;
161
- }
162
- // Only wrap create/update mutations
163
- const isCreate = fieldName.startsWith('create');
164
- const isUpdate = fieldName.startsWith('update');
165
- if (!isCreate && !isUpdate) {
166
- return field;
167
- }
168
- // Build the text→vector mapping for this codec
169
- const textToVectorMap = getTextToVectorMapping(pgCodec, build);
170
- if (Object.keys(textToVectorMap).length === 0) {
171
- return field;
172
- }
173
- const embedder = build.llmEmbedder;
174
- const defaultResolver = (obj) => obj[fieldName];
175
- const { resolve: oldResolve = defaultResolver, ...rest } = field;
176
- return {
177
- ...rest,
178
- async resolve(source, args, graphqlContext, info) {
179
- // Walk through the input args and embed any *Text companion fields
180
- async function embedTextFields(obj) {
181
- if (!obj || typeof obj !== 'object')
182
- return;
183
- const pending = [];
184
- for (const key of Object.keys(obj)) {
185
- const value = obj[key];
186
- // Check if this key is a *Text companion field
187
- if (key in textToVectorMap && typeof value === 'string') {
188
- const vectorFieldName = textToVectorMap[key];
189
- pending.push((async () => {
190
- if (!embedder) {
191
- throw new Error(`EMBED_NOT_CONFIGURED: Cannot embed ${key} — no embedding provider configured. ` +
192
- 'Set defaultEmbedder in GraphileLlmPreset options or EMBEDDER_PROVIDER env var.');
193
- }
194
- const startTime = Date.now();
195
- const vector = await embedder(value);
196
- const latencyMs = Date.now() - startTime;
197
- if (vector === null) {
198
- throw new Error(`EMBED_QUOTA_EXCEEDED: Cannot embed ${key} — embedding quota exceeded. ` +
199
- 'Upgrade your plan or wait for the next billing period.');
200
- }
201
- console.log(`[graphile-llm] Mutation embed: field=${key}, dims=${vector.length}, latency=${latencyMs}ms`);
202
- // Inject the vector into the corresponding field
203
- obj[vectorFieldName] = vector;
204
- // Remove the consumed *Text field
205
- delete obj[key];
206
- })());
207
- continue;
208
- }
209
- // Recurse into nested objects (e.g. input.article.embeddingText)
210
- if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
211
- pending.push(embedTextFields(value));
212
- }
213
- }
214
- if (pending.length > 0) {
215
- await Promise.all(pending);
216
- }
217
- }
218
- await embedTextFields(args);
219
- return oldResolve(source, args, graphqlContext, info);
220
- }
221
- };
127
+ finalize(schema, build) {
128
+ return planTimeEmbedding(schema, build);
222
129
  }
223
130
  }
224
131
  }
@@ -10,24 +10,21 @@
10
10
  * accepts a compound input (point + distance) and the plugin handles
11
11
  * the conversion to SQL internally.
12
12
  *
13
- * The `text` field is mutually exclusive with `vector`: clients provide
14
- * one or the other. When `text` is provided, the plugin embeds it and
15
- * injects the resulting vector into the normal pgvector pipeline.
13
+ * Clients provide `text` or `vector`; `vector` becomes optional. When both
14
+ * are given the vector wins. `unifiedSearch` text is embedded too, so pgvector
15
+ * participates alongside the text adapters.
16
16
  *
17
- * Runtime embedding for query filters uses the v4-style resolver wrapping
18
- * approach (same as graphile-upload-plugin). When a connection query's
19
- * `where` argument includes a VectorNearbyInput with `text`, the resolver
20
- * wrapper embeds the text and replaces it with the resulting vector before
21
- * the plan executes.
17
+ * The text is embedded while the operation is planned (see embed-inputs.ts),
18
+ * before SQL is built.
22
19
  *
23
- * If the embedder is not configured, the `text` field is still registered
24
- * (so the schema is stable) but will return a clear error at execution time.
20
+ * If no embedder is configured, `text` is ignored and `unifiedSearch` runs
21
+ * on the text adapters only.
25
22
  *
26
23
  * If the embedder returns null (e.g. quota exceeded when the metering
27
24
  * 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.
25
+ * - `'degrade'` (default): drops the text and continues without the vector
26
+ * filter (text-only search for `unifiedSearch`).
27
+ * - `'throw'`: throws an error.
31
28
  */
32
29
  import type { GraphileConfig } from 'graphile-config';
33
30
  declare global {
@@ -43,14 +40,18 @@ declare global {
43
40
  *
44
41
  * If the embedder returns null (e.g. quota exceeded), the text field is
45
42
  * removed so the pgvector filter is skipped — graceful text-only fallback.
43
+ *
44
+ * @deprecated The plugins no longer call this: under Grafast a resolver runs
45
+ * after the plan's SQL, so embedding there is too late. They embed at plan
46
+ * time through `embedField` and `planTimeEmbedding` instead.
46
47
  */
47
48
  export declare function embedTextInWhere(obj: any, embedder: (text: string) => Promise<number[] | null>, hasTextAdapters: boolean, onQuotaExceeded?: 'degrade' | 'throw'): Promise<void>;
48
49
  /**
49
50
  * Creates the LlmTextSearchPlugin.
50
51
  *
51
52
  * Hooks into VectorNearbyInput to add a `text` field alongside the
52
- * existing `vector` field. When a user provides `text`, the plugin's
53
- * resolver wrapper embeds it before passing to pgvector.
53
+ * existing `vector` field, and tags `text` and `unifiedSearch` for
54
+ * plan-time embedding.
54
55
  */
55
56
  export declare function createLlmTextSearchPlugin(options?: {
56
57
  onQuotaExceeded?: 'degrade' | 'throw';