graphile-llm 1.21.6 → 1.21.8

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,54 @@
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 type { GraphQLInputType, GraphQLSchema } from 'graphql';
14
+ import type { LlmConfigOverrides } from './embedder';
15
+ export type QuotaPolicy = 'degrade' | 'throw';
16
+ /** Stored on a GraphQL input field's `extensions[EMBED_EXT]`. */
17
+ export type EmbedRule =
18
+ /** `VectorNearbyInput.text`: embedded into the sibling `into` field. */
19
+ {
20
+ kind: 'nearby';
21
+ into: string;
22
+ onQuotaExceeded: QuotaPolicy;
23
+ }
24
+ /** `unifiedSearch: String`: becomes `{ __text, __vector }` for graphile-search. */
25
+ | {
26
+ kind: 'unified';
27
+ onQuotaExceeded: QuotaPolicy;
28
+ }
29
+ /** `{col}Text` on a create input or patch. `required` means the column is NOT NULL. */
30
+ | {
31
+ kind: 'companion';
32
+ into: string;
33
+ required: boolean;
34
+ };
35
+ export declare const EMBED_EXT = "graphileLlmEmbed";
36
+ export type VectorEmbedder = (text: string) => Promise<number[] | null>;
37
+ export declare function embedField<T extends object>(rule: EmbedRule, spec: T): T;
38
+ /**
39
+ * `build.llmEmbedder` resolves to `{ embedding }` from the providers and to
40
+ * `number[] | null` once the metering plugin has wrapped it.
41
+ */
42
+ export declare function toVectorEmbedder(raw: unknown): VectorEmbedder | null;
43
+ export declare function resolveConfigOverrides(graphqlContext: any): Promise<LlmConfigOverrides | null>;
44
+ export declare function createEmbedIndex(graphql: any): {
45
+ reaches: (type: GraphQLInputType) => boolean;
46
+ embedValue: (type: any, value: any, embed: VectorEmbedder | null) => Promise<any>;
47
+ };
48
+ /**
49
+ * Wraps the plan of every object field whose arguments reach a tagged input
50
+ * field. Runs in `finalize` because args are attached after the
51
+ * `GraphQLObjectType_fields_field` hook. Idempotent per schema, so every plugin
52
+ * that tags fields can call it without depending on the others being loaded.
53
+ */
54
+ export declare function planTimeEmbedding(schema: GraphQLSchema, build: any): GraphQLSchema;
@@ -0,0 +1,292 @@
1
+ "use strict";
2
+ /**
3
+ * Plan-time text-to-vector embedding.
4
+ *
5
+ * Under Grafast a field's `resolve()` runs after its plan has executed, so the
6
+ * SQL has already been built by the time a resolver wrapper could embed text.
7
+ * Instead, input fields that carry text to embed are tagged with an
8
+ * `EmbedRule` where they are declared, and `planTimeEmbedding` (run from a
9
+ * `finalize` hook) wraps the plan of every object field whose arguments can
10
+ * reach a tagged input field. The wrapper shadows `fieldArgs.getRaw` so every
11
+ * consumer of those arguments (connection-filter's `where` applyPlan, the
12
+ * create/update `args.apply`) reads a step that embeds the text first.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.EMBED_EXT = void 0;
16
+ exports.embedField = embedField;
17
+ exports.toVectorEmbedder = toVectorEmbedder;
18
+ exports.resolveConfigOverrides = resolveConfigOverrides;
19
+ exports.createEmbedIndex = createEmbedIndex;
20
+ exports.planTimeEmbedding = planTimeEmbedding;
21
+ const grafast_1 = require("grafast");
22
+ const embedder_1 = require("./embedder");
23
+ exports.EMBED_EXT = 'graphileLlmEmbed';
24
+ function embedField(rule, spec) {
25
+ return { ...spec, extensions: { ...spec.extensions, [exports.EMBED_EXT]: rule } };
26
+ }
27
+ /**
28
+ * `build.llmEmbedder` resolves to `{ embedding }` from the providers and to
29
+ * `number[] | null` once the metering plugin has wrapped it.
30
+ */
31
+ function toVectorEmbedder(raw) {
32
+ if (typeof raw !== 'function')
33
+ return null;
34
+ return async (text) => {
35
+ const result = await raw(text);
36
+ const vector = Array.isArray(result) ? result : result?.embedding;
37
+ return Array.isArray(vector) && vector.length > 0 ? vector : null;
38
+ };
39
+ }
40
+ function isNonEmptyVector(value) {
41
+ return Array.isArray(value) && value.length > 0;
42
+ }
43
+ async function resolveConfigOverrides(graphqlContext) {
44
+ const ctx = graphqlContext?.constructive;
45
+ if (!ctx?.useLlm)
46
+ return null;
47
+ try {
48
+ const llm = await ctx.useLlm();
49
+ if (!llm?.embeddingModel)
50
+ return null;
51
+ return {
52
+ embeddingModel: llm.embeddingModel,
53
+ embeddingBaseUrl: llm.embeddingBaseUrl,
54
+ chatModel: llm.chatModel ?? undefined,
55
+ chatBaseUrl: llm.chatBaseUrl ?? undefined
56
+ };
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /** Binds the embedder to one request's per-database model overrides, resolved on first use. */
63
+ function forRequest(embedder, graphqlContext) {
64
+ if (!embedder)
65
+ return null;
66
+ let overrides;
67
+ return async (text) => {
68
+ overrides ??= resolveConfigOverrides(graphqlContext);
69
+ const resolved = await overrides;
70
+ const startTime = Date.now();
71
+ const vector = await embedder_1.llmConfigStore.run(resolved, () => embedder(text));
72
+ if (vector) {
73
+ console.log(`[graphile-llm] embed: dims=${vector.length}, latency=${Date.now() - startTime}ms`);
74
+ }
75
+ return vector;
76
+ };
77
+ }
78
+ function createEmbedIndex(graphql) {
79
+ const { getNamedType, isInputObjectType, isListType, isNonNullType } = graphql;
80
+ const reachesMemo = new WeakMap();
81
+ const inputObjectFields = (type) => Object.values(type.getFields());
82
+ const namedInputObject = (field) => {
83
+ const named = getNamedType(field.type);
84
+ return isInputObjectType(named) ? named : null;
85
+ };
86
+ // Filter types are cyclic (AND/OR/NOT, forward and backward relations), so
87
+ // reachability is a fixed point over everything reachable from `type`; a
88
+ // depth-first walk that memoizes a provisional answer mid-cycle gets it wrong.
89
+ function reaches(type) {
90
+ const start = getNamedType(type);
91
+ if (!isInputObjectType(start))
92
+ return false;
93
+ const known = reachesMemo.get(start);
94
+ if (known !== undefined)
95
+ return known;
96
+ const pending = [];
97
+ const stack = [start];
98
+ const seen = new Set();
99
+ while (stack.length > 0) {
100
+ const node = stack.pop();
101
+ if (seen.has(node) || reachesMemo.has(node))
102
+ continue;
103
+ seen.add(node);
104
+ pending.push(node);
105
+ for (const field of inputObjectFields(node)) {
106
+ const child = namedInputObject(field);
107
+ if (child)
108
+ stack.push(child);
109
+ }
110
+ }
111
+ const hit = new Set(pending.filter((node) => inputObjectFields(node).some((f) => f.extensions?.[exports.EMBED_EXT])));
112
+ const isHit = (node) => hit.has(node) || reachesMemo.get(node) === true;
113
+ let changed = true;
114
+ while (changed) {
115
+ changed = false;
116
+ for (const node of pending) {
117
+ if (hit.has(node))
118
+ continue;
119
+ if (inputObjectFields(node).some((f) => {
120
+ const child = namedInputObject(f);
121
+ return child !== null && isHit(child);
122
+ })) {
123
+ hit.add(node);
124
+ changed = true;
125
+ }
126
+ }
127
+ }
128
+ for (const node of pending)
129
+ reachesMemo.set(node, hit.has(node));
130
+ return reachesMemo.get(start);
131
+ }
132
+ async function applyRule(rule, fieldName, typeName, value, next, embed) {
133
+ const text = value[fieldName];
134
+ switch (rule.kind) {
135
+ case 'nearby': {
136
+ delete next[fieldName];
137
+ const hasVector = isNonEmptyVector(value[rule.into]);
138
+ const hasText = typeof text === 'string';
139
+ if (hasVector && hasText) {
140
+ throw new Error(`${typeName}: provide exactly one of \`${rule.into}\` or \`${fieldName}\`, not both.`);
141
+ }
142
+ if (!hasVector && !hasText) {
143
+ throw new Error(`${typeName}: provide exactly one of \`${rule.into}\` or \`${fieldName}\`; neither was given.`);
144
+ }
145
+ if (hasVector)
146
+ return;
147
+ if (!embed) {
148
+ throw new Error(`${typeName}: \`${fieldName}\` needs an embedding provider and none is configured; ` +
149
+ `send \`${rule.into}\` instead.`);
150
+ }
151
+ const vector = await embed(text);
152
+ if (vector) {
153
+ next[rule.into] = vector;
154
+ }
155
+ else if (rule.onQuotaExceeded === 'throw') {
156
+ throw new Error(`${typeName}: embedding failed (quota exceeded or provider unavailable). ` +
157
+ 'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for graceful fallback.');
158
+ }
159
+ return;
160
+ }
161
+ case 'unified': {
162
+ if (typeof text !== 'string' || text.trim().length === 0 || !embed)
163
+ return;
164
+ const vector = await embed(text);
165
+ if (vector) {
166
+ next[fieldName] = { __text: text, __vector: vector };
167
+ }
168
+ else if (rule.onQuotaExceeded === 'throw') {
169
+ throw new Error(`${fieldName}: embedding failed (quota exceeded or provider unavailable). ` +
170
+ 'onQuotaExceeded is set to \'throw\'. ' +
171
+ 'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for text-only fallback.');
172
+ }
173
+ return;
174
+ }
175
+ case 'companion': {
176
+ delete next[fieldName];
177
+ if (isNonEmptyVector(value[rule.into]))
178
+ return;
179
+ if (typeof text !== 'string') {
180
+ if (rule.required) {
181
+ throw new Error(`${typeName}: provide either \`${rule.into}\` or \`${fieldName}\`.`);
182
+ }
183
+ return;
184
+ }
185
+ if (!embed) {
186
+ throw new Error(`EMBED_NOT_CONFIGURED: Cannot embed ${fieldName} — no embedding provider configured. ` +
187
+ 'Set defaultEmbedder in GraphileLlmPreset options or EMBEDDER_PROVIDER env var.');
188
+ }
189
+ const vector = await embed(text);
190
+ if (!vector) {
191
+ throw new Error(`EMBED_QUOTA_EXCEEDED: Cannot embed ${fieldName} — embedding quota exceeded. ` +
192
+ 'Upgrade your plan or wait for the next billing period.');
193
+ }
194
+ next[rule.into] = vector;
195
+ return;
196
+ }
197
+ }
198
+ }
199
+ /** Returns `value` itself when its type cannot reach a tagged field. */
200
+ async function embedValue(type, value, embed) {
201
+ if (value == null)
202
+ return value;
203
+ const inner = isNonNullType(type) ? type.ofType : type;
204
+ if (isListType(inner)) {
205
+ if (!Array.isArray(value) || !reaches(inner.ofType))
206
+ return value;
207
+ return Promise.all(value.map((item) => embedValue(inner.ofType, item, embed)));
208
+ }
209
+ if (!isInputObjectType(inner) || !reaches(inner))
210
+ return value;
211
+ const next = { ...value };
212
+ const jobs = [];
213
+ for (const [name, field] of Object.entries(inner.getFields())) {
214
+ const rule = field.extensions?.[exports.EMBED_EXT];
215
+ if (rule) {
216
+ jobs.push(applyRule(rule, name, inner.name, value, next, embed));
217
+ }
218
+ else if (value[name] != null && reaches(field.type)) {
219
+ jobs.push(embedValue(field.type, value[name], embed).then((embedded) => { next[name] = embedded; }));
220
+ }
221
+ }
222
+ await Promise.all(jobs);
223
+ return next;
224
+ }
225
+ return { reaches, embedValue };
226
+ }
227
+ function withEmbeddedArgs(originalPlan, argNames, embeddable, index, embedder) {
228
+ return function planWithEmbeddedArgs(parent, fieldArgs, info) {
229
+ const originalGetRaw = fieldArgs.getRaw;
230
+ const roots = new Map();
231
+ const rootStep = (argName) => {
232
+ let $root = roots.get(argName);
233
+ if ($root)
234
+ return $root;
235
+ const $raw = originalGetRaw.call(fieldArgs, argName);
236
+ const absent = $raw instanceof grafast_1.ConstantStep && $raw.data === undefined;
237
+ if (absent || !embeddable.has(argName)) {
238
+ $root = $raw;
239
+ }
240
+ else {
241
+ const type = fieldArgs.typeAt(argName);
242
+ $root = $raw.withLayerPlan(() => (0, grafast_1.lambda)({ raw: $raw, ctx: (0, grafast_1.context)() }, ({ raw, ctx }) => index.embedValue(type, raw, forRequest(embedder, ctx))));
243
+ }
244
+ roots.set(argName, $root);
245
+ return $root;
246
+ };
247
+ // grafast reads `fieldArgs.getRaw` through the object (apply, processAfter,
248
+ // `$arg` getters, getBaked), so shadowing it here reaches every consumer.
249
+ fieldArgs.getRaw = function getRawEmbedded(path) {
250
+ if (path === undefined) {
251
+ const steps = Object.fromEntries(argNames.map((n) => [n, rootStep(n)]));
252
+ return steps[argNames[0]].withLayerPlan(() => (0, grafast_1.object)(steps));
253
+ }
254
+ const [argName, ...rest] = typeof path === 'string' ? [path] : path;
255
+ if (!embeddable.has(argName))
256
+ return originalGetRaw.call(fieldArgs, path);
257
+ const $root = rootStep(argName);
258
+ return (rest.length === 0 ? $root : $root.withLayerPlan(() => (0, grafast_1.access)($root, rest)));
259
+ };
260
+ return originalPlan(parent, fieldArgs, info);
261
+ };
262
+ }
263
+ const processedSchemas = new WeakSet();
264
+ /**
265
+ * Wraps the plan of every object field whose arguments reach a tagged input
266
+ * field. Runs in `finalize` because args are attached after the
267
+ * `GraphQLObjectType_fields_field` hook. Idempotent per schema, so every plugin
268
+ * that tags fields can call it without depending on the others being loaded.
269
+ */
270
+ function planTimeEmbedding(schema, build) {
271
+ if (processedSchemas.has(schema))
272
+ return schema;
273
+ processedSchemas.add(schema);
274
+ const { graphql } = build;
275
+ const index = createEmbedIndex(graphql);
276
+ const embedder = toVectorEmbedder(build.llmEmbedder);
277
+ for (const type of Object.values(schema.getTypeMap())) {
278
+ if (!graphql.isObjectType(type) || type.name.startsWith('__'))
279
+ continue;
280
+ for (const field of Object.values(type.getFields())) {
281
+ const grafastExt = field.extensions?.grafast;
282
+ if (typeof grafastExt?.plan !== 'function')
283
+ continue;
284
+ const embeddable = new Set(field.args.filter((a) => index.reaches(a.type)).map((a) => a.name));
285
+ if (embeddable.size === 0)
286
+ continue;
287
+ const argNames = field.args.map((a) => a.name);
288
+ grafastExt.plan = withEmbeddedArgs(grafastExt.plan, argNames, embeddable, index, embedder);
289
+ }
290
+ }
291
+ return schema;
292
+ }
package/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/embedder.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * embeddings. Currently supports Ollama via @agentic-kit/ollama.
7
7
  *
8
8
  * Per-request model/baseUrl overrides are read from AsyncLocalStorage
9
- * (`llmConfigStore`). The text-search-plugin sets these overrides from
9
+ * (`llmConfigStore`). embed-inputs.ts sets these overrides from
10
10
  * the database's `llm_module` config via `ctx.useLlm()`. This means
11
11
  * the same embedder function — and its metering wrapper — handle every
12
12
  * request, regardless of which tenant's model config applies.
@@ -25,7 +25,7 @@ const env_1 = require("./env");
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
  */
@@ -0,0 +1,54 @@
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 type { GraphQLInputType, GraphQLSchema } from 'graphql';
14
+ import type { LlmConfigOverrides } from './embedder';
15
+ export type QuotaPolicy = 'degrade' | 'throw';
16
+ /** Stored on a GraphQL input field's `extensions[EMBED_EXT]`. */
17
+ export type EmbedRule =
18
+ /** `VectorNearbyInput.text`: embedded into the sibling `into` field. */
19
+ {
20
+ kind: 'nearby';
21
+ into: string;
22
+ onQuotaExceeded: QuotaPolicy;
23
+ }
24
+ /** `unifiedSearch: String`: becomes `{ __text, __vector }` for graphile-search. */
25
+ | {
26
+ kind: 'unified';
27
+ onQuotaExceeded: QuotaPolicy;
28
+ }
29
+ /** `{col}Text` on a create input or patch. `required` means the column is NOT NULL. */
30
+ | {
31
+ kind: 'companion';
32
+ into: string;
33
+ required: boolean;
34
+ };
35
+ export declare const EMBED_EXT = "graphileLlmEmbed";
36
+ export type VectorEmbedder = (text: string) => Promise<number[] | null>;
37
+ export declare function embedField<T extends object>(rule: EmbedRule, spec: T): T;
38
+ /**
39
+ * `build.llmEmbedder` resolves to `{ embedding }` from the providers and to
40
+ * `number[] | null` once the metering plugin has wrapped it.
41
+ */
42
+ export declare function toVectorEmbedder(raw: unknown): VectorEmbedder | null;
43
+ export declare function resolveConfigOverrides(graphqlContext: any): Promise<LlmConfigOverrides | null>;
44
+ export declare function createEmbedIndex(graphql: any): {
45
+ reaches: (type: GraphQLInputType) => boolean;
46
+ embedValue: (type: any, value: any, embed: VectorEmbedder | null) => Promise<any>;
47
+ };
48
+ /**
49
+ * Wraps the plan of every object field whose arguments reach a tagged input
50
+ * field. Runs in `finalize` because args are attached after the
51
+ * `GraphQLObjectType_fields_field` hook. Idempotent per schema, so every plugin
52
+ * that tags fields can call it without depending on the others being loaded.
53
+ */
54
+ export declare function planTimeEmbedding(schema: GraphQLSchema, build: any): GraphQLSchema;