@retrivora-ai/rag-engine 1.8.0 → 1.8.1

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 (46) hide show
  1. package/dist/DocumentChunker-Dh9TvmGG.d.mts +45 -0
  2. package/dist/DocumentChunker-Dh9TvmGG.d.ts +45 -0
  3. package/dist/{index-DPsQodME.d.mts → ILLMProvider-BfRgI1Xh.d.mts} +58 -1
  4. package/dist/{index-DPsQodME.d.ts → ILLMProvider-BfRgI1Xh.d.ts} +58 -1
  5. package/dist/MultiTablePostgresProvider-YY7LPNJK.mjs +8 -0
  6. package/dist/{chunk-PV3MFHWU.mjs → chunk-BFYLQYQU.mjs} +808 -439
  7. package/dist/chunk-R3RGUMHE.mjs +218 -0
  8. package/dist/handlers/index.d.mts +2 -2
  9. package/dist/handlers/index.d.ts +2 -2
  10. package/dist/handlers/index.js +995 -603
  11. package/dist/handlers/index.mjs +1 -1
  12. package/dist/{index-Bb2yEopi.d.mts → index-1Z4GuYBi.d.ts} +7 -1
  13. package/dist/{index-CkbTzj9J.d.ts → index-BV0z5mb6.d.mts} +7 -1
  14. package/dist/index.d.mts +3 -3
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.js +491 -316
  17. package/dist/index.mjs +411 -217
  18. package/dist/server.d.mts +35 -5
  19. package/dist/server.d.ts +35 -5
  20. package/dist/server.js +1146 -777
  21. package/dist/server.mjs +163 -176
  22. package/package.json +4 -2
  23. package/src/app/page.tsx +1 -2
  24. package/src/components/MessageBubble.tsx +211 -69
  25. package/src/components/ProductCard.tsx +2 -2
  26. package/src/components/VisualizationRenderer.tsx +347 -247
  27. package/src/config/RagConfig.ts +5 -0
  28. package/src/config/serverConfig.ts +3 -1
  29. package/src/core/Pipeline.ts +65 -206
  30. package/src/core/ProviderRegistry.ts +2 -2
  31. package/src/core/VectorPlugin.ts +9 -0
  32. package/src/handlers/index.ts +23 -7
  33. package/src/hooks/useRagChat.ts +2 -2
  34. package/src/llm/LLMFactory.ts +54 -2
  35. package/src/llm/providers/AnthropicProvider.ts +12 -8
  36. package/src/llm/providers/GeminiProvider.ts +188 -143
  37. package/src/llm/providers/OllamaProvider.ts +7 -3
  38. package/src/llm/providers/OpenAIProvider.ts +12 -8
  39. package/src/types/chat.ts +6 -0
  40. package/src/types/index.ts +80 -0
  41. package/src/utils/SchemaMapper.ts +129 -0
  42. package/src/utils/UITransformer.ts +491 -181
  43. package/dist/DocumentChunker-C1GEEosY.d.ts +0 -93
  44. package/dist/DocumentChunker-CFEiRopR.d.mts +0 -93
  45. package/dist/PostgreSQLProvider-BMOETDZA.mjs +0 -8
  46. package/dist/chunk-FLOSGE6A.mjs +0 -202
@@ -182,6 +182,11 @@ export interface RAGConfig {
182
182
  * Used to dynamically extract hints from natural language queries.
183
183
  */
184
184
  filterableFields?: string[];
185
+ /** Optional mapping from database metadata fields to standard UI properties.
186
+ * Useful for databases with custom schemas.
187
+ * e.g., { "name": "ProductName", "price": "SalesPrice", "image": "ThumbnailUrl" }
188
+ */
189
+ uiMapping?: Record<string, string>;
185
190
  }
186
191
 
187
192
  // ---------------------------------------------------------------------------
@@ -76,7 +76,9 @@ export function getEnvConfig(env: Record<string, string | undefined> = process.e
76
76
  vectorDbOptions.apiKey = readString(env, 'PINECONE_API_KEY') ?? (base?.vectorDb?.options?.apiKey as string) ?? '';
77
77
  vectorDbOptions.indexName = readString(env, 'PINECONE_INDEX') ?? (base?.vectorDb?.indexName as string) ?? (base?.vectorDb?.options?.indexName as string);
78
78
  } else if (vectorProvider === 'pgvector' || vectorProvider === 'postgresql') {
79
- vectorDbOptions.connectionString = readString(env, 'PGVECTOR_CONNECTION_STRING') ?? (base?.vectorDb?.options?.connectionString as string) ?? '';
79
+ vectorDbOptions.connectionString = readString(env, 'PGVECTOR_CONNECTION_STRING') ?? readString(env, 'POSTGRES_URL') ?? (base?.vectorDb?.options?.connectionString as string) ?? '';
80
+ vectorDbOptions.tables = (readString(env, 'VECTOR_DB_TABLES') ?? readString(env, 'POSTGRES_TABLES'))?.split(',').map(t => t.trim()) ?? (base?.vectorDb?.options?.tables as string[]);
81
+ vectorDbOptions.searchFields = readString(env, 'POSTGRES_SEARCH_FIELDS')?.split(',').map(f => f.trim()) ?? (base?.vectorDb?.options?.searchFields as string[]);
80
82
  vectorDbOptions.dimensions = embeddingDimensions;
81
83
  } else if (vectorProvider === 'mongodb') {
82
84
  vectorDbOptions.uri = readString(env, 'MONGODB_URI') ?? (base?.vectorDb?.options?.uri as string) ?? '';
@@ -14,6 +14,7 @@ import { EmbeddingStrategyResolver } from '../config/EmbeddingStrategy';
14
14
  import { QueryProcessor } from './QueryProcessor';
15
15
  import { IngestDocument, ChatResponse, VectorMatch, GraphSearchResult, RetrievalResult, UpsertDocument } from '../types';
16
16
  import { UITransformer } from '../utils/UITransformer';
17
+ import { SchemaMapper, SchemaMap } from '../utils/SchemaMapper';
17
18
 
18
19
  // ─── LRU Embedding Cache ───────────────────────────────────────────────────────
19
20
 
@@ -97,209 +98,36 @@ export class Pipeline {
97
98
  this.reranker = new Reranker();
98
99
  }
99
100
 
101
+ /**
102
+ * Expose the underlying LLM provider (set after initialize()).
103
+ * Used by the stream handler to pass to UITransformer.analyzeAndDecide().
104
+ */
105
+ getLLMProvider(): ILLMProvider | undefined {
106
+ return this.initialised ? this.llmProvider : undefined;
107
+ }
108
+
100
109
  async initialize(): Promise<void> {
101
110
  if (this.initialised) return;
102
111
 
103
112
  // Augment system prompt with a Universal Visualization Protocol
104
113
  // We use a unique marker to ensure this is only injected once but is ALWAYS present.
105
- const CHART_MARKER = '<!-- UI_PROTOCOL_V7 -->';
114
+ //const CHART_MARKER = '<!-- UI_PROTOCOL_V7 -->';
106
115
  const chartInstruction = `
107
-
108
- ${CHART_MARKER}
109
- ### UNIVERSAL UI PROTOCOL V7 (STRICT MODE)
110
-
111
- You are responsible for returning a SINGLE structured UI block when visualization is required.
112
-
113
- ---
114
-
115
- ## 1. INTENT CLASSIFICATION (MANDATORY)
116
-
117
- Classify the query into ONE of the following intents:
118
-
119
- - "explore" → user wants to browse items (e.g., "show me products")
120
- - "analyze" → user wants aggregation/distribution (e.g., "distribution of products")
121
- - "compare" → user wants side-by-side comparison
122
- - "detail" → user wants structured/tabular data
123
-
124
- ---
125
-
126
- ## 2. VIEW SELECTION (DYNAMIC)
127
-
128
- Choose the best primary "view" ("carousel", "chart", or "table") based on the data:
129
- - TRENDS/GROWTH → "chart" (type: "line")
130
- - DISTRIBUTIONS/RATIOS → "chart" (type: "pie")
131
- - COMPARISONS → "chart" (type: "bar")
132
- - PRODUCT LISTS → "carousel"
133
- - RAW DATA/SPECS → "table"
134
-
135
- ⚠️ Dynamic Overrides:
136
- - If query is "show products", ALWAYS use carousel.
137
- - If data is statistical, ALWAYS use a chart.
138
- - NEVER explain why you chose a view.
139
-
140
- ---
141
-
142
- ## 3. RESPONSE FORMAT (STRICT JSON ONLY INSIDE BLOCK)
143
-
144
- \`\`\`ui
145
- {
146
- "view": "carousel" | "chart" | "table",
147
- "title": "string",
148
- "description": "string",
149
- "metadata": {
150
- "intent": "explore" | "analyze" | "compare" | "detail",
151
- "confidence": 0.0-1.0
152
- },
153
-
154
- // CHART ONLY
155
- "chart": {
156
- "type": "pie" | "bar" | "line",
157
- "xKey": "label",
158
- "yKeys": ["value"],
159
- "data": []
160
- },
161
-
162
- // TABLE ONLY
163
- "table": {
164
- "columns": [],
165
- "rows": []
166
- },
167
-
168
- // CAROUSEL ONLY
169
- "carousel": {
170
- "items": []
171
- },
172
-
173
- "insights": []
174
- }
175
- \`\`\`
176
-
177
- ---
178
-
179
- ## 4. DATA CONTRACT (CRITICAL)
180
-
181
- ### 🔹 CAROUSEL ITEM FORMAT
182
- {
183
- "id": "string",
184
- "name": "string",
185
- "brand": "string",
186
- "price": number,
187
- "image": "url",
188
- "link": "url",
189
- "inStock": boolean
190
- }
191
-
192
- ### 🔹 CHART DATA FORMAT
193
- {
194
- "label": "string",
195
- "value": number,
196
- "inStockCount": number (optional),
197
- "outOfStockCount": number (optional)
198
- }
199
-
200
- ### 🔹 TABLE FORMAT
201
- - columns MUST match row keys
202
- - rows MUST be flat objects
203
-
204
- ---
205
-
206
- ## 5. DATA RULES
207
-
208
- - NEVER hallucinate fields or data.
209
- - ONLY use data provided in the retrieved context.
210
- - STRICT DATA ISOLATION: The labels and numbers in the EXAMPLES section below are placeholders. NEVER use them.
211
- - IF NO DATA IS FOUND: You MUST NOT generate a chart. You MUST NOT say "This example assumes" or provide hypothetical numbers. Simply state "No relevant data found in the database." and STOP.
212
- - NO META-COMMENTARY: NEVER talk about the task itself (e.g., "I've used JSON", "In a real-world app", "Dedicated library").
213
- - NEVER explain YOUR formatting choice.
214
- - Aggregate BEFORE rendering chart.
215
- - DYNAMIC INTELLIGENCE: Autonomously extract relevant categories/values from the context and calculate totals for the "data" array based on the user query.
216
-
217
- ---
218
-
219
- ## 6. SMART RULES (IMPORTANT)
220
-
221
- - If user asks BOTH:
222
- "show products AND distribution"
223
- → PRIORITIZE "explore" → carousel
224
-
225
- - If dataset size > 20:
226
- → aggregate → chart or table
227
-
228
- ---
229
-
230
- ## 7. OUTPUT RULES
231
-
232
- - ALWAYS return EXACTLY ONE \`\`\`ui\`\`\` block.
233
- - JSON must be VALID.
234
- - FIELD MAPPING (MANDATORY): Always use "label" for the name/category and "value" for the numeric count.
235
- - NO xKey or yKeys: Do not include these fields; the UI uses "label" and "value" by default.
236
- - SILENT DATA: If a chart is provided, DO NOT say "Here is the JSON" or "Below is the data". Simply output the block.
237
- - TEXT OUTSIDE THE BLOCK: One brief sentence maximum.
238
-
239
- ---
240
-
241
- ## 8. EXAMPLES
242
-
243
- User: "show me beauty products"
244
-
245
- \`\`\`ui
246
- {
247
- "view": "carousel",
248
- "title": "TITLE",
249
- "description": "DESCRIPTION",
250
- "metadata": {
251
- "intent": "explore",
252
- "confidence": 0.95
253
- },
254
- "carousel": {
255
- "items": [
256
- {
257
- "id": "ID",
258
- "name": "PRODUCT_NAME",
259
- "brand": "BRAND",
260
- "price": 0,
261
- "image": "URL",
262
- "link": "URL",
263
- "inStock": true
264
- }
265
- ]
266
- },
267
- "insights": ["INSIGHT"]
268
- }
269
- \`\`\`
270
-
271
- ---
272
-
273
-
274
-
275
- \`\`\`ui
276
- {
277
- "view": "chart",
278
- "title": "TITLE",
279
- "description": "DESCRIPTION",
280
- "metadata": {
281
- "intent": "analyze",
282
- "confidence": 0.98
283
- },
284
- "chart": {
285
- "type": "pie",
286
- "data": [
287
- { "label": "LABEL_A", "value": 0, "inStockCount": 0, "outOfStockCount": 0 },
288
- { "label": "LABEL_B", "value": 0, "inStockCount": 0, "outOfStockCount": 0 }
289
- ]
290
- },
291
- "insights": ["INSIGHT"]
292
- }
293
- \`\`\`
116
+ You are a helpful product assistant. Use the provided context to answer questions accurately.
117
+
118
+ ### UI STYLE RULES (CRITICAL):
119
+ - NEVER generate markdown tables. If you do, the UI will break.
120
+ - NEVER generate HTML tags like <figure>, <tbody>, <tr>, etc.
121
+ - NEVER generate text-based charts or graphs.
122
+ - ONLY use plain text and bullet points.
123
+
124
+ ### PRODUCT DISPLAY:
125
+ - When recommending products, simply list their names, prices, and features in a friendly, conversational manner.
126
+ - The UI will automatically detect these products and show high-quality product cards in a carousel below your message.
127
+ - Do NOT try to format product lists as tables.
294
128
  `;
295
- if (!this.config.llm.systemPrompt?.includes(CHART_MARKER)) {
296
- // Clean up ANY existing protocol markers (V1-V7) to avoid prompt bloat
297
- let cleanPrompt = this.config.llm.systemPrompt || '';
298
- cleanPrompt = cleanPrompt.replace(/\n\n### UNIVERSAL UI PROTOCOL[\s\S]*?(?=\n\n|$)/g, '');
299
- cleanPrompt = cleanPrompt.replace(/<!-- UI_PROTOCOL_V\d+ -->[\s\S]*?(?=\n\n|$)/g, '');
300
-
301
- this.config.llm.systemPrompt = cleanPrompt.trim() + chartInstruction;
302
- }
129
+
130
+ this.config.llm.systemPrompt = chartInstruction;
303
131
 
304
132
  console.log(`[Pipeline] Initializing with provider: ${this.config.vectorDb.provider}...`);
305
133
  // Resolve vector DB provider
@@ -487,7 +315,7 @@ User: "show me beauty products"
487
315
  let sources: VectorMatch[] = [];
488
316
  let graphData: GraphSearchResult | undefined;
489
317
 
490
- let uiTransformation: any;
318
+ let uiTransformation: unknown;
491
319
  for await (const chunk of stream) {
492
320
  if (typeof chunk === 'string') {
493
321
  reply += chunk;
@@ -551,8 +379,17 @@ User: "show me beauty products"
551
379
  context = `GRAPH KNOWLEDGE:\n${graphContext}\n\nVECTOR CONTEXT:\n${context}`;
552
380
  }
553
381
 
382
+ // 4.5 Schema Training (Dynamic Discovery) - Start training but don't block the stream yet
383
+ const allMetadataKeys = Array.from(new Set(sources.flatMap(s => Object.keys(s.metadata || {}))));
384
+ const trainingPromise = allMetadataKeys.length > 0
385
+ ? SchemaMapper.train(this.llmProvider, ns, allMetadataKeys)
386
+ : Promise.resolve(undefined);
387
+
554
388
  // 5. Generation (Streaming)
555
- const messages: ChatMessage[] = [...history, { role: 'user', content: question }];
389
+ const restrictionSuffix = "\n\n(IMPORTANT: Use plain text only. NEVER generate tables, HTML figures, or text charts. If listing products, use simple bullet points.)";
390
+ const hardenedHistory = [...history];
391
+ const userQuestion = { role: 'user', content: question + restrictionSuffix } as ChatMessage;
392
+ const messages: ChatMessage[] = [...hardenedHistory, userQuestion];
556
393
 
557
394
  if (this.llmProvider.chatStream) {
558
395
  const stream = this.llmProvider.chatStream(messages, context);
@@ -568,16 +405,17 @@ User: "show me beauty products"
568
405
  }
569
406
 
570
407
  // 6. Automated UI Fallback (UITransformer)
571
- // This provides a secondary, rule-based visualization frame in case the LLM
572
- // fails to adhere to the strict protocol or outputs a text fallback.
573
- const uiTransformation = UITransformer.transform(question, sources);
408
+ // Wait for training to finish if it hasn't already
409
+ const trainedSchema = await trainingPromise;
410
+
411
+ const uiTransformation = await this.generateUiTransformation(question, sources, context, trainedSchema);
574
412
 
575
413
  // Yield retrieval metadata and automated UI transformation at the end
576
- yield {
577
- reply: '',
578
- sources,
579
- graphData,
580
- ui_transformation: uiTransformation
414
+ yield {
415
+ reply: '',
416
+ sources,
417
+ graphData,
418
+ ui_transformation: uiTransformation
581
419
  } as ChatResponse;
582
420
 
583
421
  } catch (error) {
@@ -589,6 +427,27 @@ User: "show me beauty products"
589
427
  * Universal retrieval method combining all enabled providers.
590
428
  * Uses an LRU-bounded embedding cache to avoid re-embedding the same query.
591
429
  */
430
+ private async generateUiTransformation(
431
+ question: string,
432
+ sources: VectorMatch[],
433
+ _context: string,
434
+ trainedSchema?: SchemaMap
435
+ ): Promise<unknown> {
436
+ if (!sources || sources.length === 0) {
437
+ return UITransformer.transform(question, sources, this.config, trainedSchema);
438
+ }
439
+
440
+ try {
441
+ // Delegate to the centralized LLM-driven visualization decision.
442
+ // UITransformer.analyzeAndDecide() internally falls back to the keyword
443
+ // heuristic if the LLM call fails or returns unparseable JSON.
444
+ return await UITransformer.analyzeAndDecide(question, sources, this.llmProvider);
445
+ } catch (err) {
446
+ console.warn('[Pipeline] generateUiTransformation failed, using heuristic fallback:', err);
447
+ return UITransformer.transform(question, sources, this.config, trainedSchema);
448
+ }
449
+ }
450
+
592
451
  async retrieve(query: string, options: { namespace?: string; topK?: number; filter?: Record<string, unknown> }): Promise<RetrievalResult> {
593
452
  const ns = options.namespace ?? this.config.projectId;
594
453
  const topK = options.topK ?? 5;
@@ -77,8 +77,8 @@ export class ProviderRegistry {
77
77
  }
78
78
  case 'pgvector':
79
79
  case 'postgresql': {
80
- const { PostgreSQLProvider } = await import('../providers/vectordb/PostgreSQLProvider');
81
- return PostgreSQLProvider;
80
+ const { MultiTablePostgresProvider } = await import('../providers/vectordb/MultiTablePostgresProvider');
81
+ return MultiTablePostgresProvider as unknown as VectorProviderClass;
82
82
  }
83
83
  case 'mongodb': {
84
84
  const { MongoDBProvider } = await import('../providers/vectordb/MongoDBProvider');
@@ -38,6 +38,15 @@ export class VectorPlugin {
38
38
  return this.config;
39
39
  }
40
40
 
41
+ /**
42
+ * Get the initialized LLM provider (available after the first request).
43
+ * Used by handlers to pass to UITransformer.analyzeAndDecide() for
44
+ * LLM-driven visualization decisions.
45
+ */
46
+ getLLMProvider() {
47
+ return this.pipeline.getLLMProvider();
48
+ }
49
+
41
50
  /**
42
51
  * Perform pre-flight health checks on all configured providers.
43
52
  * Useful to verify connectivity before running operations.
@@ -27,7 +27,7 @@ function sseMetaFrame(meta: unknown): string {
27
27
 
28
28
  /** Encode the UI transformation result as an SSE frame. */
29
29
  function sseUIFrame(uiTransformation: unknown): string {
30
- return `data: ${JSON.stringify({ type: 'ui_transformation', ...((uiTransformation as object) ?? {}) })}\n\n`;
30
+ return `data: ${JSON.stringify({ type: 'ui_transformation', data: uiTransformation })}\n\n`;
31
31
  }
32
32
 
33
33
  /** Encode a stream error as an SSE frame. */
@@ -152,15 +152,31 @@ export function createStreamHandler(configOrPlugin?: Partial<RagConfig> | Vector
152
152
  // Retrieval metadata object — always the final frame
153
153
  enqueue(sseMetaFrame(chunk));
154
154
 
155
- // Transform retrieved sources to UI representation
156
- const sources = (chunk as ChatResponse)?.sources || [];
157
- if (sources && sources.length > 0) {
155
+ // ── LLM-driven visualization decision ─────────────────────────
156
+ // Sends question + RAG context to the LLM and asks it to choose
157
+ // the best visualization format (bar_chart, pie_chart, table…).
158
+ // Falls back to keyword heuristic if the LLM call fails.
159
+ const responseChunk = chunk as ChatResponse;
160
+ const sources = responseChunk?.sources || [];
161
+
162
+ if (sources.length > 0) {
158
163
  try {
159
- const uiTransformation = UITransformer.transform(message, sources);
160
- enqueue(sseUIFrame(uiTransformation));
164
+ const llmProvider = plugin.getLLMProvider?.();
165
+ const uiTransformation = responseChunk?.ui_transformation ??
166
+ (llmProvider
167
+ ? await UITransformer.analyzeAndDecide(message, sources, llmProvider)
168
+ : UITransformer.transform(message, sources, plugin.getConfig()));
169
+
170
+ if (uiTransformation) {
171
+ enqueue(sseUIFrame(uiTransformation));
172
+ }
161
173
  } catch (transformError) {
162
174
  console.warn('[createStreamHandler] UI transformation warning:', transformError);
163
- // Don't fail the stream if transformation fails, just skip it
175
+ // Last-resort fallback: heuristic transform
176
+ try {
177
+ const fallback = UITransformer.transform(message, sources, plugin.getConfig());
178
+ if (fallback) enqueue(sseUIFrame(fallback));
179
+ } catch { /* silent */ }
164
180
  }
165
181
  }
166
182
  }
@@ -155,7 +155,7 @@ export function useRagChat(
155
155
  buffer = buffer.slice(lastBoundary + 2);
156
156
 
157
157
  for (const frame of parseSseChunk(complete)) {
158
- if (frame.type === 'text') {
158
+ if (frame.type === 'text' && frame.text) {
159
159
  assistantContent += frame.text;
160
160
  } else if (frame.type === 'metadata') {
161
161
  sources = frame.sources ?? [];
@@ -184,7 +184,7 @@ export function useRagChat(
184
184
  // Process any remaining buffered data
185
185
  if (buffer.trim()) {
186
186
  for (const frame of parseSseChunk(buffer)) {
187
- if (frame.type === 'text') assistantContent += frame.text;
187
+ if (frame.type === 'text' && frame.text) assistantContent += frame.text;
188
188
  else if (frame.type === 'metadata') sources = frame.sources ?? [];
189
189
  }
190
190
  }
@@ -12,10 +12,50 @@ interface LLMProviderStatic {
12
12
  getHealthChecker?: () => IProviderHealthChecker;
13
13
  }
14
14
 
15
+ /** Registry for custom provider factories registered at runtime. */
16
+ const customProviders = new Map<string, (config: LLMConfig) => ILLMProvider>();
17
+
15
18
  /**
16
19
  * LLMFactory — instantiates the correct ILLMProvider based on llmConfig.provider.
17
20
  */
18
21
  export class LLMFactory {
22
+ /**
23
+ * Register a custom LLM provider factory at runtime.
24
+ *
25
+ * Use this to add support for any LLM backend (Azure OpenAI, Cohere, Mistral,
26
+ * Bedrock, etc.) without modifying this library's source code.
27
+ *
28
+ * @example
29
+ * // In your Next.js app initialization:
30
+ * import { LLMFactory } from '@retrivora-ai/rag-engine/server';
31
+ * import { MyCustomProvider } from './providers/MyCustomProvider';
32
+ *
33
+ * LLMFactory.register('my-provider', (config) => new MyCustomProvider(config));
34
+ *
35
+ * // Then set in your .env.local:
36
+ * // LLM_PROVIDER=my-provider
37
+ */
38
+ static register(name: string, factory: (config: LLMConfig) => ILLMProvider): void {
39
+ customProviders.set(name.toLowerCase(), factory);
40
+ console.log(`[LLMFactory] Registered custom provider: "${name}"`);
41
+ }
42
+
43
+ /**
44
+ * Unregister a previously registered custom provider.
45
+ */
46
+ static unregister(name: string): void {
47
+ customProviders.delete(name.toLowerCase());
48
+ }
49
+
50
+ /**
51
+ * List all registered provider names (built-in + custom).
52
+ */
53
+ static listProviders(): string[] {
54
+ return [
55
+ 'openai', 'anthropic', 'ollama', 'gemini', 'rest', 'universal_rest', 'custom',
56
+ ...Array.from(customProviders.keys()),
57
+ ];
58
+ }
19
59
  static create(llmConfig: LLMConfig, embeddingConfig?: EmbeddingConfig): ILLMProvider {
20
60
  switch (llmConfig.provider) {
21
61
  case 'openai':
@@ -30,11 +70,23 @@ export class LLMFactory {
30
70
  case 'universal_rest':
31
71
  case 'custom':
32
72
  return new UniversalLLMAdapter(llmConfig);
33
- default:
73
+ default: {
74
+ // Check the runtime custom provider registry first
75
+ const providerName = String(llmConfig.provider ?? '').toLowerCase();
76
+ const customFactory = customProviders.get(providerName);
77
+ if (customFactory) {
78
+ return customFactory(llmConfig);
79
+ }
80
+ // Fall back to UniversalLLMAdapter if a baseUrl is provided
34
81
  if (llmConfig.baseUrl || (llmConfig.options as Record<string, unknown>)?.baseUrl) {
35
82
  return new UniversalLLMAdapter(llmConfig);
36
83
  }
37
- throw new Error(`[LLMFactory] Unknown provider "${llmConfig.provider}"`);
84
+ throw new Error(
85
+ `[LLMFactory] Unknown provider "${llmConfig.provider}". ` +
86
+ `Built-in providers: ${LLMFactory.listProviders().join(', ')}. ` +
87
+ `Register a custom provider with LLMFactory.register().`
88
+ );
89
+ }
38
90
  }
39
91
  }
40
92
 
@@ -75,13 +75,15 @@ export class AnthropicProvider implements ILLMProvider {
75
75
  }
76
76
 
77
77
  async chat(messages: ChatMessage[], context: string, options?: ChatOptions): Promise<string> {
78
- const systemPrompt =
78
+ const resolvedPrompt = options?.systemPrompt ??
79
79
  this.llmConfig.systemPrompt ??
80
80
  `You are a helpful assistant. Use the context below to answer the user's question accurately.\n\nContext:\n${context}`;
81
81
 
82
- const system = systemPrompt.includes('{{context}}')
83
- ? systemPrompt.replace('{{context}}', context)
84
- : `${systemPrompt}\n\nContext:\n${context}`;
82
+ const system = resolvedPrompt.includes('{{context}}')
83
+ ? resolvedPrompt.replace('{{context}}', context)
84
+ : options?.systemPrompt
85
+ ? resolvedPrompt // per-call: context is already in the user message
86
+ : `${resolvedPrompt}\n\nContext:\n${context}`;
85
87
 
86
88
  const anthropicMessages: Anthropic.MessageParam[] = messages.map((m) => ({
87
89
  role: m.role === 'assistant' ? 'assistant' : 'user',
@@ -100,13 +102,15 @@ export class AnthropicProvider implements ILLMProvider {
100
102
  }
101
103
 
102
104
  async *chatStream(messages: ChatMessage[], context: string, options?: ChatOptions): AsyncIterable<string> {
103
- const systemPrompt =
105
+ const resolvedPrompt = options?.systemPrompt ??
104
106
  this.llmConfig.systemPrompt ??
105
107
  `You are a helpful assistant. Use the context below to answer the user's question accurately.\n\nContext:\n${context}`;
106
108
 
107
- const system = systemPrompt.includes('{{context}}')
108
- ? systemPrompt.replace('{{context}}', context)
109
- : `${systemPrompt}\n\nContext:\n${context}`;
109
+ const system = resolvedPrompt.includes('{{context}}')
110
+ ? resolvedPrompt.replace('{{context}}', context)
111
+ : options?.systemPrompt
112
+ ? resolvedPrompt
113
+ : `${resolvedPrompt}\n\nContext:\n${context}`;
110
114
 
111
115
  const anthropicMessages: Anthropic.MessageParam[] = messages.map((m) => ({
112
116
  role: m.role === 'assistant' ? 'assistant' : 'user',