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
@@ -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
- * The embedder is resolved at request time from:
8
- * 1. The `llm_module` api_modules configuration (per-database)
9
- * 2. The preset's `defaultEmbedder` option (fallback for dev/testing)
10
- * 3. Environment variables (EMBEDDER_PROVIDER, EMBEDDER_MODEL, EMBEDDER_BASE_URL)
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
- * The embedder is resolved at request time from:
8
- * 1. The `llm_module` api_modules configuration (per-database)
9
- * 2. The preset's `defaultEmbedder` option (fallback for dev/testing)
10
- * 3. Environment variables (EMBEDDER_PROVIDER, EMBEDDER_MODEL, EMBEDDER_BASE_URL)
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
- * Uses the /api/embed endpoint which returns prompt_eval_count (real token count).
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(baseUrl = 'http://localhost:11434', model = 'nomic-embed-text') {
21
- const client = new OllamaClient(baseUrl);
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 Environment Configuration
2
+ * Re-export LLM env configuration from @constructive-io/llm-env.
3
3
  *
4
- * Single source of truth for all LLM-related environment variables and defaults.
5
- * Every other module in graphile-llm imports from here — no direct process.env
6
- * reads or scattered null coalescing elsewhere.
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 declare function getLlmEnvOptions(): LlmEnvOptions;
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 Environment Configuration
2
+ * Re-export LLM env configuration from @constructive-io/llm-env.
3
3
  *
4
- * Single source of truth for all LLM-related environment variables and defaults.
5
- * Every other module in graphile-llm imports from here — no direct process.env
6
- * reads or scattered null coalescing elsewhere.
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 function getLlmEnvOptions() {
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 { 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/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
- // Embedder utilities
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
- * 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
  /**
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
- // ─── Billing SQL Helpers ────────────────────────────────────────────────────
20
+ import { createBillingClient } from '@constructive-io/express-context';
21
+ // ─── Billing Client Adapter ─────────────────────────────────────────────────
21
22
  /**
22
- * Check if the entity has sufficient quota for the requested amount.
23
- * Returns true if the call is allowed, false if quota is exceeded.
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
- async function recordUsage(pgClient, billing, entityId, meterSlug, amount, metadata) {
45
- try {
46
- const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
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
- * Gracefully skips if the inference_log_module is not provisioned.
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
- if (!ctx.inferenceLog)
62
- return;
63
- const { schema, tableName } = ctx.inferenceLog;
64
- const sql = `INSERT INTO "${schema}"."${tableName}" (
65
- database_id, entity_id, actor_id,
66
- model, provider, service, operation,
67
- input_tokens, output_tokens, total_tokens,
68
- cache_read_tokens, cache_write_tokens,
69
- latency_ms, rag_enabled, chunks_retrieved,
70
- embedding_model, embedding_latency_ms,
71
- status, error_type, raw_usage
72
- ) VALUES (
73
- $1, $2, $3,
74
- $4, $5, $6, $7,
75
- $8, $9, $10,
76
- $11, $12,
77
- $13, $14, $15,
78
- $16, $17,
79
- $18, $19, $20
80
- )`;
81
- try {
82
- await ctx.withPgClient(ctx.pgSettings, async (pgClient) => {
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
- let allowed = true;
140
- try {
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
- ctx.withPgClient(ctx.pgSettings, async (pgClient) => {
182
- await recordUsage(pgClient, ctx.billing, ctx.entityId, meterSlug, promptTokens, {
183
- request_id: ctx.requestId,
184
- input_chars: text.length,
185
- prompt_tokens: promptTokens,
186
- dims: embedding.length,
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
- let allowed = true;
256
- try {
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
- ctx.withPgClient(ctx.pgSettings, async (pgClient) => {
300
- await recordUsage(pgClient, ctx.billing, ctx.entityId, meterSlug, usage.totalTokens, {
301
- request_id: ctx.requestId,
302
- input_tokens: usage.input,
303
- output_tokens: usage.output,
304
- cache_read_tokens: usage.cacheRead,
305
- cache_write_tokens: usage.cacheWrite,
306
- messages_count: messages.length,
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. `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