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.
- package/__tests__/graphile-llm.test.js +318 -37
- package/__tests__/unified-search-embedding.test.d.ts +10 -0
- package/__tests__/unified-search-embedding.test.js +176 -0
- package/embedder.d.ts +24 -4
- package/embedder.js +36 -7
- package/env.d.ts +6 -28
- package/env.js +10 -47
- package/esm/__tests__/graphile-llm.test.js +319 -38
- package/esm/__tests__/unified-search-embedding.test.d.ts +10 -0
- package/esm/__tests__/unified-search-embedding.test.js +174 -0
- package/esm/embedder.d.ts +24 -4
- package/esm/embedder.js +35 -7
- package/esm/env.d.ts +6 -28
- package/esm/env.js +5 -46
- package/esm/index.d.ts +5 -4
- package/esm/index.js +3 -4
- package/esm/metering.d.ts +1 -3
- package/esm/metering.js +48 -108
- package/esm/plugins/llm-module-plugin.d.ts +9 -4
- package/esm/plugins/llm-module-plugin.js +9 -4
- package/esm/plugins/rag-plugin.js +30 -20
- package/esm/plugins/text-mutation-plugin.js +15 -2
- package/esm/plugins/text-search-plugin.d.ts +15 -3
- package/esm/plugins/text-search-plugin.js +94 -23
- package/esm/preset.d.ts +13 -2
- package/esm/preset.js +15 -4
- package/esm/types.d.ts +13 -0
- package/index.d.ts +5 -4
- package/index.js +6 -2
- package/metering.d.ts +1 -3
- package/metering.js +48 -108
- package/package.json +6 -4
- package/plugins/llm-module-plugin.d.ts +9 -4
- package/plugins/llm-module-plugin.js +9 -4
- package/plugins/rag-plugin.js +30 -20
- package/plugins/text-mutation-plugin.js +15 -2
- package/plugins/text-search-plugin.d.ts +15 -3
- package/plugins/text-search-plugin.js +95 -23
- package/preset.d.ts +13 -2
- package/preset.js +15 -4
- package/types.d.ts +13 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for LlmTextSearchPlugin's embedTextInWhere function.
|
|
3
|
+
*
|
|
4
|
+
* Tests the transformation logic:
|
|
5
|
+
* - unifiedSearch: "text" → unifiedSearch: { __text: "text", __vector: [...] }
|
|
6
|
+
* - VectorNearbyInput.text → VectorNearbyInput.vector (existing behavior)
|
|
7
|
+
*
|
|
8
|
+
* Pure unit tests — no database or Ollama required.
|
|
9
|
+
*/
|
|
10
|
+
import { embedTextInWhere } from '../../src/plugins/text-search-plugin';
|
|
11
|
+
describe('unifiedSearch embedding integration', () => {
|
|
12
|
+
const mockVector = [0.1, 0.2, 0.3, 0.4, 0.5];
|
|
13
|
+
const mockEmbedder = jest.fn(async (_text) => mockVector);
|
|
14
|
+
const nullEmbedder = jest.fn(async (_text) => null);
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
mockEmbedder.mockClear();
|
|
17
|
+
nullEmbedder.mockClear();
|
|
18
|
+
});
|
|
19
|
+
describe('unifiedSearch text → { __text, __vector } transformation', () => {
|
|
20
|
+
it('transforms unifiedSearch string to object with __text and __vector', async () => {
|
|
21
|
+
const where = { unifiedSearch: 'HIPAA compliance' };
|
|
22
|
+
await embedTextInWhere(where, mockEmbedder, true);
|
|
23
|
+
expect(where.unifiedSearch).toEqual({
|
|
24
|
+
__text: 'HIPAA compliance',
|
|
25
|
+
__vector: mockVector,
|
|
26
|
+
});
|
|
27
|
+
expect(mockEmbedder).toHaveBeenCalledWith('HIPAA compliance');
|
|
28
|
+
});
|
|
29
|
+
it('leaves unifiedSearch as plain string when embedder returns null (graceful degradation)', async () => {
|
|
30
|
+
const where = { unifiedSearch: 'database normalization' };
|
|
31
|
+
await embedTextInWhere(where, nullEmbedder, true);
|
|
32
|
+
// Should remain as string — text adapters handle it
|
|
33
|
+
expect(where.unifiedSearch).toBe('database normalization');
|
|
34
|
+
expect(nullEmbedder).toHaveBeenCalledWith('database normalization');
|
|
35
|
+
});
|
|
36
|
+
it('throws when embedder returns null and no text adapters available', async () => {
|
|
37
|
+
const where = { unifiedSearch: 'vector only query' };
|
|
38
|
+
await expect(embedTextInWhere(where, nullEmbedder, false)).rejects.toThrow('embedding failed');
|
|
39
|
+
});
|
|
40
|
+
it('does not embed empty unifiedSearch strings', async () => {
|
|
41
|
+
const where = { unifiedSearch: ' ' };
|
|
42
|
+
await embedTextInWhere(where, mockEmbedder, true);
|
|
43
|
+
expect(where.unifiedSearch).toBe(' ');
|
|
44
|
+
expect(mockEmbedder).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
it('does not embed null unifiedSearch', async () => {
|
|
47
|
+
const where = { unifiedSearch: null };
|
|
48
|
+
await embedTextInWhere(where, mockEmbedder, true);
|
|
49
|
+
expect(where.unifiedSearch).toBeNull();
|
|
50
|
+
expect(mockEmbedder).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('VectorNearbyInput text → vector (existing behavior)', () => {
|
|
54
|
+
it('transforms VectorNearbyInput text to vector', async () => {
|
|
55
|
+
const where = { vectorEmbedding: { text: 'semantic query' } };
|
|
56
|
+
await embedTextInWhere(where, mockEmbedder, true);
|
|
57
|
+
expect(where.vectorEmbedding).toEqual({ vector: mockVector });
|
|
58
|
+
expect(mockEmbedder).toHaveBeenCalledWith('semantic query');
|
|
59
|
+
});
|
|
60
|
+
it('removes text field when embedder returns null', async () => {
|
|
61
|
+
const where = { vectorEmbedding: { text: 'failed query' } };
|
|
62
|
+
await embedTextInWhere(where, nullEmbedder, true);
|
|
63
|
+
expect(where.vectorEmbedding).toEqual({});
|
|
64
|
+
expect(nullEmbedder).toHaveBeenCalledWith('failed query');
|
|
65
|
+
});
|
|
66
|
+
it('does not modify VectorNearbyInput with existing vector', async () => {
|
|
67
|
+
const existingVector = [1, 2, 3];
|
|
68
|
+
const where = { vectorEmbedding: { vector: existingVector, text: 'ignored' } };
|
|
69
|
+
await embedTextInWhere(where, mockEmbedder, true);
|
|
70
|
+
// text + vector present → not modified (vector takes precedence)
|
|
71
|
+
expect(where.vectorEmbedding.vector).toBe(existingVector);
|
|
72
|
+
expect(mockEmbedder).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('combined unifiedSearch + VectorNearbyInput', () => {
|
|
76
|
+
it('embeds both unifiedSearch and VectorNearbyInput.text in parallel', async () => {
|
|
77
|
+
const where = {
|
|
78
|
+
unifiedSearch: 'hybrid search query',
|
|
79
|
+
vectorEmbedding: { text: 'vector part' },
|
|
80
|
+
};
|
|
81
|
+
await embedTextInWhere(where, mockEmbedder, true);
|
|
82
|
+
expect(where.unifiedSearch).toEqual({
|
|
83
|
+
__text: 'hybrid search query',
|
|
84
|
+
__vector: mockVector,
|
|
85
|
+
});
|
|
86
|
+
expect(where.vectorEmbedding).toEqual({ vector: mockVector });
|
|
87
|
+
expect(mockEmbedder).toHaveBeenCalledTimes(2);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('nested filter structures (AND, OR)', () => {
|
|
91
|
+
it('handles unifiedSearch inside nested AND/OR filters', async () => {
|
|
92
|
+
const where = {
|
|
93
|
+
AND: [
|
|
94
|
+
{ unifiedSearch: 'first query' },
|
|
95
|
+
{ unifiedSearch: 'second query' },
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
await embedTextInWhere(where, mockEmbedder, true);
|
|
99
|
+
expect(where.AND[0].unifiedSearch).toEqual({
|
|
100
|
+
__text: 'first query',
|
|
101
|
+
__vector: mockVector,
|
|
102
|
+
});
|
|
103
|
+
expect(where.AND[1].unifiedSearch).toEqual({
|
|
104
|
+
__text: 'second query',
|
|
105
|
+
__vector: mockVector,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe('onQuotaExceeded option', () => {
|
|
110
|
+
it('degrade mode (default): leaves unifiedSearch as string when embedder returns null', async () => {
|
|
111
|
+
const where = { unifiedSearch: 'some query' };
|
|
112
|
+
await embedTextInWhere(where, nullEmbedder, true, 'degrade');
|
|
113
|
+
expect(where.unifiedSearch).toBe('some query');
|
|
114
|
+
});
|
|
115
|
+
it('throw mode: throws on unifiedSearch when embedder returns null even with text adapters', async () => {
|
|
116
|
+
const where = { unifiedSearch: 'some query' };
|
|
117
|
+
await expect(embedTextInWhere(where, nullEmbedder, true, 'throw')).rejects.toThrow('onQuotaExceeded is set to \'throw\'');
|
|
118
|
+
});
|
|
119
|
+
it('throw mode: throws on VectorNearbyInput.text when embedder returns null', async () => {
|
|
120
|
+
const where = { vectorEmbedding: { text: 'semantic query' } };
|
|
121
|
+
await expect(embedTextInWhere(where, nullEmbedder, true, 'throw')).rejects.toThrow('VectorNearbyInput: embedding failed');
|
|
122
|
+
});
|
|
123
|
+
it('degrade mode: silently removes VectorNearbyInput.text when embedder returns null', async () => {
|
|
124
|
+
const where = { vectorEmbedding: { text: 'failed query' } };
|
|
125
|
+
await embedTextInWhere(where, nullEmbedder, true, 'degrade');
|
|
126
|
+
expect(where.vectorEmbedding).toEqual({});
|
|
127
|
+
});
|
|
128
|
+
it('throw mode: still works normally when embedder succeeds', async () => {
|
|
129
|
+
const where = { unifiedSearch: 'good query' };
|
|
130
|
+
await embedTextInWhere(where, mockEmbedder, true, 'throw');
|
|
131
|
+
expect(where.unifiedSearch).toEqual({
|
|
132
|
+
__text: 'good query',
|
|
133
|
+
__vector: mockVector,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
it('no text adapters: throws regardless of onQuotaExceeded setting', async () => {
|
|
137
|
+
const where1 = { unifiedSearch: 'query1' };
|
|
138
|
+
await expect(embedTextInWhere(where1, nullEmbedder, false, 'degrade')).rejects.toThrow('No text search adapters available');
|
|
139
|
+
const where2 = { unifiedSearch: 'query2' };
|
|
140
|
+
await expect(embedTextInWhere(where2, nullEmbedder, false, 'throw')).rejects.toThrow('No text search adapters available');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe('apply function object shape handling', () => {
|
|
144
|
+
it('plugin.ts apply function handles { __text, __vector } correctly', () => {
|
|
145
|
+
// Simulate what the apply function does with the transformed value
|
|
146
|
+
const val = { __text: 'HIPAA compliance', __vector: [0.1, 0.2, 0.3] };
|
|
147
|
+
let text;
|
|
148
|
+
let vector = null;
|
|
149
|
+
if (typeof val === 'object' && val.__text) {
|
|
150
|
+
text = val.__text;
|
|
151
|
+
vector = val.__vector ?? null;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
text = typeof val === 'string' ? val : String(val);
|
|
155
|
+
}
|
|
156
|
+
expect(text).toBe('HIPAA compliance');
|
|
157
|
+
expect(vector).toEqual([0.1, 0.2, 0.3]);
|
|
158
|
+
});
|
|
159
|
+
it('plugin.ts apply function handles plain string correctly', () => {
|
|
160
|
+
const val = 'plain text search';
|
|
161
|
+
let text;
|
|
162
|
+
let vector = null;
|
|
163
|
+
if (typeof val === 'object' && val.__text) {
|
|
164
|
+
text = val.__text;
|
|
165
|
+
vector = val.__vector ?? null;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
text = typeof val === 'string' ? val : String(val);
|
|
169
|
+
}
|
|
170
|
+
expect(text).toBe('plain text search');
|
|
171
|
+
expect(vector).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
package/esm/embedder.d.ts
CHANGED
|
@@ -4,12 +4,32 @@
|
|
|
4
4
|
* Provides a provider-based architecture for converting text into vector
|
|
5
5
|
* embeddings. Currently supports Ollama via @agentic-kit/ollama.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Per-request model/baseUrl overrides are read from AsyncLocalStorage
|
|
8
|
+
* (`llmConfigStore`). The text-search-plugin sets these overrides from
|
|
9
|
+
* the database's `llm_module` config via `ctx.useLlm()`. This means
|
|
10
|
+
* the same embedder function — and its metering wrapper — handle every
|
|
11
|
+
* request, regardless of which tenant's model config applies.
|
|
11
12
|
*/
|
|
13
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
12
14
|
import type { EmbedderConfig, EmbedderFunction, LlmModuleData } from './types';
|
|
15
|
+
/**
|
|
16
|
+
* Per-request LLM config overrides from the database's llm_module.
|
|
17
|
+
* Only string parameters — the embedder client itself stays the same.
|
|
18
|
+
*/
|
|
19
|
+
export interface LlmConfigOverrides {
|
|
20
|
+
embeddingModel?: string;
|
|
21
|
+
embeddingBaseUrl?: string;
|
|
22
|
+
chatModel?: string;
|
|
23
|
+
chatBaseUrl?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* AsyncLocalStorage for per-request LLM config overrides.
|
|
27
|
+
*
|
|
28
|
+
* Set by the text-search-plugin resolver wrapper (from ctx.useLlm()),
|
|
29
|
+
* read by the embedder at call time. The metering wrapper sits between
|
|
30
|
+
* them and is completely unaware — it just wraps the same function.
|
|
31
|
+
*/
|
|
32
|
+
export declare const llmConfigStore: AsyncLocalStorage<LlmConfigOverrides>;
|
|
13
33
|
/**
|
|
14
34
|
* Build an embedder function from a config object.
|
|
15
35
|
*
|
package/esm/embedder.js
CHANGED
|
@@ -4,22 +4,50 @@
|
|
|
4
4
|
* Provides a provider-based architecture for converting text into vector
|
|
5
5
|
* embeddings. Currently supports Ollama via @agentic-kit/ollama.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Per-request model/baseUrl overrides are read from AsyncLocalStorage
|
|
8
|
+
* (`llmConfigStore`). The text-search-plugin sets these overrides from
|
|
9
|
+
* the database's `llm_module` config via `ctx.useLlm()`. This means
|
|
10
|
+
* the same embedder function — and its metering wrapper — handle every
|
|
11
|
+
* request, regardless of which tenant's model config applies.
|
|
11
12
|
*/
|
|
13
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
12
14
|
import OllamaClient from '@agentic-kit/ollama';
|
|
13
15
|
import { getLlmEnvOptions } from './env';
|
|
16
|
+
/**
|
|
17
|
+
* AsyncLocalStorage for per-request LLM config overrides.
|
|
18
|
+
*
|
|
19
|
+
* Set by the text-search-plugin resolver wrapper (from ctx.useLlm()),
|
|
20
|
+
* read by the embedder at call time. The metering wrapper sits between
|
|
21
|
+
* them and is completely unaware — it just wraps the same function.
|
|
22
|
+
*/
|
|
23
|
+
export const llmConfigStore = new AsyncLocalStorage();
|
|
24
|
+
// ─── Client cache ───────────────────────────────────────────────────────────
|
|
25
|
+
/** Cache OllamaClient instances by baseUrl to avoid re-creation per request */
|
|
26
|
+
const ollamaClientCache = new Map();
|
|
27
|
+
function getOllamaClient(baseUrl) {
|
|
28
|
+
let client = ollamaClientCache.get(baseUrl);
|
|
29
|
+
if (!client) {
|
|
30
|
+
client = new OllamaClient(baseUrl);
|
|
31
|
+
ollamaClientCache.set(baseUrl, client);
|
|
32
|
+
}
|
|
33
|
+
return client;
|
|
34
|
+
}
|
|
14
35
|
// ─── Built-in Providers ─────────────────────────────────────────────────────
|
|
15
36
|
/**
|
|
16
37
|
* Create an Ollama-based embedder function.
|
|
17
38
|
*
|
|
18
|
-
*
|
|
39
|
+
* At call time, checks `llmConfigStore` for per-request model/baseUrl
|
|
40
|
+
* overrides. Falls back to the defaults passed at creation time.
|
|
41
|
+
* Client instances are cached by baseUrl — no new objects per request.
|
|
19
42
|
*/
|
|
20
|
-
function createOllamaEmbedder(
|
|
21
|
-
|
|
43
|
+
function createOllamaEmbedder(defaultBaseUrl = 'http://localhost:11434', defaultModel = 'nomic-embed-text') {
|
|
44
|
+
// Pre-cache the default client
|
|
45
|
+
const defaultClient = getOllamaClient(defaultBaseUrl);
|
|
22
46
|
return async (text) => {
|
|
47
|
+
const overrides = llmConfigStore.getStore();
|
|
48
|
+
const model = overrides?.embeddingModel ?? defaultModel;
|
|
49
|
+
const baseUrl = overrides?.embeddingBaseUrl ?? defaultBaseUrl;
|
|
50
|
+
const client = baseUrl === defaultBaseUrl ? defaultClient : getOllamaClient(baseUrl);
|
|
23
51
|
return client.generateEmbedding(text, model);
|
|
24
52
|
};
|
|
25
53
|
}
|
package/esm/env.d.ts
CHANGED
|
@@ -1,31 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* LLM
|
|
2
|
+
* Re-export LLM env configuration from @constructive-io/llm-env.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Environment variables:
|
|
9
|
-
* EMBEDDER_PROVIDER - Embedding provider name ('ollama')
|
|
10
|
-
* EMBEDDER_MODEL - Embedding model (default: 'nomic-embed-text')
|
|
11
|
-
* EMBEDDER_BASE_URL - Embedding provider URL (default: 'http://localhost:11434')
|
|
12
|
-
* CHAT_PROVIDER - Chat provider name ('ollama')
|
|
13
|
-
* CHAT_MODEL - Chat model (default: 'llama3')
|
|
14
|
-
* CHAT_BASE_URL - Chat provider URL (default: 'http://localhost:11434')
|
|
15
|
-
*/
|
|
16
|
-
export interface LlmProviderConfig {
|
|
17
|
-
provider: string;
|
|
18
|
-
model: string;
|
|
19
|
-
baseUrl: string;
|
|
20
|
-
}
|
|
21
|
-
export interface LlmEnvOptions {
|
|
22
|
-
embedding: LlmProviderConfig;
|
|
23
|
-
chat: LlmProviderConfig;
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Resolve LLM configuration from environment variables with sensible defaults.
|
|
27
|
-
*
|
|
28
|
-
* Call this once and pass the result around — never read process.env directly
|
|
29
|
-
* in plugin code.
|
|
4
|
+
* This file exists for backward compatibility — all internal graphile-llm
|
|
5
|
+
* modules import from './env'. The actual implementation lives in the
|
|
6
|
+
* shared @constructive-io/llm-env package.
|
|
30
7
|
*/
|
|
31
|
-
export
|
|
8
|
+
export { getEnvVars, getEnvOptions, getLlmEnvOptions, llmDefaults, } from '@constructive-io/llm-env';
|
|
9
|
+
export type { LlmEnvOptions, LlmProviderConfig, ResolvedLlmEnvOptions, } from '@constructive-io/llm-env';
|
package/esm/env.js
CHANGED
|
@@ -1,49 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* LLM
|
|
2
|
+
* Re-export LLM env configuration from @constructive-io/llm-env.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Environment variables:
|
|
9
|
-
* EMBEDDER_PROVIDER - Embedding provider name ('ollama')
|
|
10
|
-
* EMBEDDER_MODEL - Embedding model (default: 'nomic-embed-text')
|
|
11
|
-
* EMBEDDER_BASE_URL - Embedding provider URL (default: 'http://localhost:11434')
|
|
12
|
-
* CHAT_PROVIDER - Chat provider name ('ollama')
|
|
13
|
-
* CHAT_MODEL - Chat model (default: 'llama3')
|
|
14
|
-
* CHAT_BASE_URL - Chat provider URL (default: 'http://localhost:11434')
|
|
15
|
-
*/
|
|
16
|
-
// ─── Defaults ───────────────────────────────────────────────────────────────
|
|
17
|
-
const LLM_DEFAULTS = {
|
|
18
|
-
embedding: {
|
|
19
|
-
provider: 'ollama',
|
|
20
|
-
model: 'nomic-embed-text',
|
|
21
|
-
baseUrl: 'http://localhost:11434'
|
|
22
|
-
},
|
|
23
|
-
chat: {
|
|
24
|
-
provider: 'ollama',
|
|
25
|
-
model: 'llama3',
|
|
26
|
-
baseUrl: 'http://localhost:11434'
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
// ─── Resolution ─────────────────────────────────────────────────────────────
|
|
30
|
-
/**
|
|
31
|
-
* Resolve LLM configuration from environment variables with sensible defaults.
|
|
32
|
-
*
|
|
33
|
-
* Call this once and pass the result around — never read process.env directly
|
|
34
|
-
* in plugin code.
|
|
4
|
+
* This file exists for backward compatibility — all internal graphile-llm
|
|
5
|
+
* modules import from './env'. The actual implementation lives in the
|
|
6
|
+
* shared @constructive-io/llm-env package.
|
|
35
7
|
*/
|
|
36
|
-
export
|
|
37
|
-
return {
|
|
38
|
-
embedding: {
|
|
39
|
-
provider: process.env.EMBEDDER_PROVIDER ?? LLM_DEFAULTS.embedding.provider,
|
|
40
|
-
model: process.env.EMBEDDER_MODEL ?? LLM_DEFAULTS.embedding.model,
|
|
41
|
-
baseUrl: process.env.EMBEDDER_BASE_URL ?? LLM_DEFAULTS.embedding.baseUrl
|
|
42
|
-
},
|
|
43
|
-
chat: {
|
|
44
|
-
provider: process.env.CHAT_PROVIDER ?? LLM_DEFAULTS.chat.provider,
|
|
45
|
-
model: process.env.CHAT_MODEL ?? LLM_DEFAULTS.chat.model,
|
|
46
|
-
baseUrl: process.env.CHAT_BASE_URL ?? LLM_DEFAULTS.chat.baseUrl
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
}
|
|
8
|
+
export { getEnvVars, getEnvOptions, getLlmEnvOptions, llmDefaults, } from '@constructive-io/llm-env';
|
package/esm/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 {
|
|
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/esm/index.js
CHANGED
|
@@ -29,19 +29,18 @@
|
|
|
29
29
|
* };
|
|
30
30
|
* ```
|
|
31
31
|
*/
|
|
32
|
-
export { getLlmEnvOptions } from './env';
|
|
32
|
+
export { getEnvVars, getEnvOptions, getLlmEnvOptions, llmDefaults } from './env';
|
|
33
33
|
// Preset (recommended entry point)
|
|
34
34
|
export { GraphileLlmPreset } from './preset';
|
|
35
35
|
// Individual plugins (pure — no billing dependency)
|
|
36
36
|
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
|
-
export { createLlmTextSearchPlugin } from './plugins/text-search-plugin';
|
|
39
|
+
export { createLlmTextSearchPlugin, embedTextInWhere } from './plugins/text-search-plugin';
|
|
40
40
|
// Metering plugin (opt-in billing integration)
|
|
41
41
|
export { createLlmMeteringPlugin } from './plugins/metering-plugin';
|
|
42
42
|
export { clearAgentDiscoveryCache, getAgentDiscovery } from './plugins/agent-discovery-plugin';
|
|
43
|
-
|
|
44
|
-
export { buildEmbedder, buildEmbedderFromEnv, buildEmbedderFromModule } from './embedder';
|
|
43
|
+
export { buildEmbedder, buildEmbedderFromEnv, buildEmbedderFromModule, llmConfigStore } from './embedder';
|
|
45
44
|
// Chat completion utilities
|
|
46
45
|
export { buildChatCompleter, buildChatCompleterFromEnv, buildChatCompleterFromModule } from './chat';
|
|
47
46
|
export { logInferenceUsage, meteredChat, meteredEmbed, QuotaExceededError } from './metering';
|
package/esm/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
|
-
*
|
|
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
|
/**
|
package/esm/metering.js
CHANGED
|
@@ -17,85 +17,43 @@
|
|
|
17
17
|
* Graphile `withPgClient` callback. Function locations (schema, names) are
|
|
18
18
|
* resolved from `billing_module` metaschema and cached by `config-cache.ts`.
|
|
19
19
|
*/
|
|
20
|
-
|
|
20
|
+
import { createBillingClient } from '@constructive-io/express-context';
|
|
21
|
+
// ─── Billing Client Adapter ─────────────────────────────────────────────────
|
|
21
22
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* Gracefully returns true if the billing function doesn't exist or errors —
|
|
26
|
-
* metering is opt-in, so missing infrastructure means "allow".
|
|
27
|
-
*/
|
|
28
|
-
async function checkQuota(pgClient, billing, entityId, meterSlug, amount) {
|
|
29
|
-
try {
|
|
30
|
-
const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`;
|
|
31
|
-
const result = await pgClient.query(sql, [meterSlug, entityId, amount]);
|
|
32
|
-
return result.rows[0]?.allowed !== false;
|
|
33
|
-
}
|
|
34
|
-
catch (e) {
|
|
35
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
36
|
-
console.warn(`[graphile-llm] check_billing_quota failed (allowing): ${message}`);
|
|
37
|
-
return true;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Record usage after a successful call.
|
|
42
|
-
* Gracefully skips if the billing function doesn't exist or errors.
|
|
23
|
+
* Create a BillingClient from a MeteringContext by adapting Graphile's 2-arg
|
|
24
|
+
* withPgClient(pgSettings, fn) to the express-context 1-arg withPgClient(fn).
|
|
43
25
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
await pgClient.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata)]);
|
|
48
|
-
}
|
|
49
|
-
catch (e) {
|
|
50
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
51
|
-
console.warn(`[graphile-llm] record_usage failed (non-fatal): ${message}`);
|
|
52
|
-
}
|
|
26
|
+
function billingClientFromCtx(ctx) {
|
|
27
|
+
const adaptedWithPgClient = (fn) => ctx.withPgClient(ctx.pgSettings, fn);
|
|
28
|
+
return createBillingClient(adaptedWithPgClient, ctx.entityId, ctx.billing, ctx.inferenceLog);
|
|
53
29
|
}
|
|
54
30
|
/**
|
|
55
31
|
* Write a row to the usage_log_inference table.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* TODO: Also write to child (generated) database when dual-write is needed.
|
|
32
|
+
* Delegates to the shared BillingClient from express-context.
|
|
59
33
|
*/
|
|
60
34
|
export async function logInferenceUsage(ctx, entry) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
await pgClient.query(sql, [
|
|
84
|
-
entry.databaseId, entry.entityId, entry.actorId,
|
|
85
|
-
entry.model, entry.provider, entry.service, entry.operation,
|
|
86
|
-
entry.inputTokens, entry.outputTokens, entry.totalTokens,
|
|
87
|
-
entry.cacheReadTokens, entry.cacheWriteTokens,
|
|
88
|
-
entry.latencyMs, entry.ragEnabled, entry.chunksRetrieved,
|
|
89
|
-
entry.embeddingModel, entry.embeddingLatencyMs,
|
|
90
|
-
entry.status, entry.errorType,
|
|
91
|
-
entry.rawUsage ? JSON.stringify(entry.rawUsage) : null
|
|
92
|
-
]);
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
catch (e) {
|
|
96
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
97
|
-
console.warn(`[graphile-llm] inference log INSERT failed (non-fatal): ${message}`);
|
|
98
|
-
}
|
|
35
|
+
const client = billingClientFromCtx(ctx);
|
|
36
|
+
return client.logInference({
|
|
37
|
+
entityId: entry.entityId,
|
|
38
|
+
actorId: entry.actorId,
|
|
39
|
+
model: entry.model,
|
|
40
|
+
provider: entry.provider,
|
|
41
|
+
service: entry.service,
|
|
42
|
+
operation: entry.operation,
|
|
43
|
+
inputTokens: entry.inputTokens,
|
|
44
|
+
outputTokens: entry.outputTokens,
|
|
45
|
+
totalTokens: entry.totalTokens,
|
|
46
|
+
latencyMs: entry.latencyMs,
|
|
47
|
+
status: entry.status,
|
|
48
|
+
cacheReadTokens: entry.cacheReadTokens,
|
|
49
|
+
cacheWriteTokens: entry.cacheWriteTokens,
|
|
50
|
+
ragEnabled: entry.ragEnabled,
|
|
51
|
+
chunksRetrieved: entry.chunksRetrieved,
|
|
52
|
+
embeddingModel: entry.embeddingModel,
|
|
53
|
+
embeddingLatencyMs: entry.embeddingLatencyMs,
|
|
54
|
+
errorType: entry.errorType,
|
|
55
|
+
rawUsage: entry.rawUsage
|
|
56
|
+
});
|
|
99
57
|
}
|
|
100
58
|
// ─── Metered Embedder ───────────────────────────────────────────────────────
|
|
101
59
|
/**
|
|
@@ -136,15 +94,8 @@ export async function meteredEmbed(embedder, text, ctx, options = {}) {
|
|
|
136
94
|
};
|
|
137
95
|
}
|
|
138
96
|
// Pre-check: can this entity afford this call?
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
await ctx.withPgClient(ctx.pgSettings, async (pgClient) => {
|
|
142
|
-
allowed = await checkQuota(pgClient, ctx.billing, ctx.entityId, meterSlug, 1);
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
146
|
-
allowed = true;
|
|
147
|
-
}
|
|
97
|
+
const billingClient = billingClientFromCtx(ctx);
|
|
98
|
+
const allowed = await billingClient.checkQuota(meterSlug);
|
|
148
99
|
if (!allowed) {
|
|
149
100
|
logInferenceUsage(ctx, {
|
|
150
101
|
databaseId: ctx.databaseId,
|
|
@@ -178,14 +129,12 @@ export async function meteredEmbed(embedder, text, ctx, options = {}) {
|
|
|
178
129
|
// Execute embedding — real token count from provider via EmbeddingResult
|
|
179
130
|
const { embedding, promptTokens } = await embedder(text);
|
|
180
131
|
const latencyMs = Date.now() - startTime;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
latency_ms: latencyMs
|
|
188
|
-
});
|
|
132
|
+
billingClient.recordUsage(meterSlug, promptTokens, {
|
|
133
|
+
request_id: ctx.requestId,
|
|
134
|
+
input_chars: text.length,
|
|
135
|
+
prompt_tokens: promptTokens,
|
|
136
|
+
dims: embedding.length,
|
|
137
|
+
latency_ms: latencyMs
|
|
189
138
|
}).catch(() => { });
|
|
190
139
|
// Log to inference usage table
|
|
191
140
|
logInferenceUsage(ctx, {
|
|
@@ -252,15 +201,8 @@ export async function meteredChat(chat, messages, ctx, chatOptions, meteringOpti
|
|
|
252
201
|
};
|
|
253
202
|
}
|
|
254
203
|
// Pre-check: can this entity afford this call?
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
await ctx.withPgClient(ctx.pgSettings, async (pgClient) => {
|
|
258
|
-
allowed = await checkQuota(pgClient, ctx.billing, ctx.entityId, meterSlug, 1);
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
catch {
|
|
262
|
-
allowed = true;
|
|
263
|
-
}
|
|
204
|
+
const billingClient = billingClientFromCtx(ctx);
|
|
205
|
+
const allowed = await billingClient.checkQuota(meterSlug);
|
|
264
206
|
if (!allowed) {
|
|
265
207
|
const estimatedInputTokens = Math.ceil(messages.reduce((sum, m) => sum + m.content.length, 0) / 4);
|
|
266
208
|
logInferenceUsage(ctx, {
|
|
@@ -296,16 +238,14 @@ export async function meteredChat(chat, messages, ctx, chatOptions, meteringOpti
|
|
|
296
238
|
const chatResult = await chat(messages, chatOptions);
|
|
297
239
|
const latencyMs = Date.now() - startTime;
|
|
298
240
|
const usage = chatResult.usage;
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
latency_ms: latencyMs
|
|
308
|
-
});
|
|
241
|
+
billingClient.recordUsage(meterSlug, usage.totalTokens, {
|
|
242
|
+
request_id: ctx.requestId,
|
|
243
|
+
input_tokens: usage.input,
|
|
244
|
+
output_tokens: usage.output,
|
|
245
|
+
cache_read_tokens: usage.cacheRead,
|
|
246
|
+
cache_write_tokens: usage.cacheWrite,
|
|
247
|
+
messages_count: messages.length,
|
|
248
|
+
latency_ms: latencyMs
|
|
309
249
|
}).catch(() => { });
|
|
310
250
|
// Log to inference usage table with real provider token counts
|
|
311
251
|
logInferenceUsage(ctx, {
|
|
@@ -11,10 +11,15 @@
|
|
|
11
11
|
* to consume.
|
|
12
12
|
*
|
|
13
13
|
* Resolution order for the embedder:
|
|
14
|
-
* 1. `
|
|
15
|
-
* 2.
|
|
16
|
-
* 3.
|
|
17
|
-
*
|
|
14
|
+
* 1. `defaultEmbedder` from preset options (build-time)
|
|
15
|
+
* 2. Environment variables (EMBEDDER_PROVIDER, EMBEDDER_MODEL, EMBEDDER_BASE_URL)
|
|
16
|
+
* 3. null — LLM features are disabled
|
|
17
|
+
*
|
|
18
|
+
* Per-database model/baseUrl overrides (from `llm_module` via `ctx.useLlm()`)
|
|
19
|
+
* are applied at request time via `llmConfigStore` (AsyncLocalStorage) in
|
|
20
|
+
* the text-search-plugin resolver wrapper. The same embedder function — and
|
|
21
|
+
* its metering wrapper — handles every request; only the model name and
|
|
22
|
+
* base URL parameters change per-tenant.
|
|
18
23
|
*
|
|
19
24
|
* This plugin is intentionally pure — no billing or metering logic.
|
|
20
25
|
* The optional LlmMeteringPlugin wraps the embedder with billing integration
|