@retrivora-ai/rag-engine 1.9.7 → 1.9.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.
Files changed (58) hide show
  1. package/README.md +127 -135
  2. package/dist/{ILLMProvider-Bhk6zJOK.d.mts → ILLMProvider-B8ROITYK.d.mts} +57 -2
  3. package/dist/{ILLMProvider-Bhk6zJOK.d.ts → ILLMProvider-B8ROITYK.d.ts} +57 -2
  4. package/dist/handlers/index.d.mts +2 -2
  5. package/dist/handlers/index.d.ts +2 -2
  6. package/dist/handlers/index.js +2198 -457
  7. package/dist/handlers/index.mjs +2195 -457
  8. package/dist/{index-BJ4cd-t5.d.mts → index-B8PbEFSY.d.mts} +12 -2
  9. package/dist/{index-Bu7T6xgr.d.ts → index-BCPKUAVL.d.ts} +27 -52
  10. package/dist/{index-C3SVtPYg.d.mts → index-CrxCy36A.d.mts} +27 -52
  11. package/dist/{index-B9J_XEh0.d.ts → index-DNvoi-sV.d.ts} +12 -2
  12. package/dist/index.css +578 -273
  13. package/dist/index.d.mts +29 -7
  14. package/dist/index.d.ts +29 -7
  15. package/dist/index.js +1160 -282
  16. package/dist/index.mjs +1185 -292
  17. package/dist/server.d.mts +62 -6
  18. package/dist/server.d.ts +62 -6
  19. package/dist/server.js +2061 -306
  20. package/dist/server.mjs +2055 -306
  21. package/package.json +11 -7
  22. package/src/app/constants.tsx +37 -7
  23. package/src/components/AmbientBackground.tsx +10 -10
  24. package/src/components/ArchitectureCard.tsx +43 -7
  25. package/src/components/ArchitectureCardsSection.tsx +37 -4
  26. package/src/components/ChatWidget.tsx +2 -1
  27. package/src/components/ChatWindow.tsx +4 -1
  28. package/src/components/CodeViewer.tsx +19 -14
  29. package/src/components/DocViewer.tsx +43 -49
  30. package/src/components/Documentation.tsx +71 -51
  31. package/src/components/Hero.tsx +103 -20
  32. package/src/components/Lifecycle.tsx +65 -25
  33. package/src/components/MarkdownComponents.tsx +44 -1
  34. package/src/components/MessageBubble.tsx +162 -50
  35. package/src/components/constants.tsx +4 -0
  36. package/src/config/RagConfig.ts +46 -0
  37. package/src/config/constants.ts +4 -0
  38. package/src/config/serverConfig.ts +15 -0
  39. package/src/core/ConfigResolver.ts +6 -0
  40. package/src/core/DatabaseStorage.ts +469 -0
  41. package/src/core/LicenseVerifier.ts +154 -0
  42. package/src/core/MultiAgentCoordinator.ts +239 -0
  43. package/src/core/Pipeline.ts +146 -15
  44. package/src/core/Retrivora.ts +6 -0
  45. package/src/core/VectorPlugin.ts +12 -3
  46. package/src/core/mcp.ts +261 -0
  47. package/src/handlers/index.ts +449 -63
  48. package/src/hooks/useRagChat.ts +96 -42
  49. package/src/hooks/useStoredMessages.ts +15 -4
  50. package/src/index.ts +5 -0
  51. package/src/llm/LLMFactory.ts +9 -1
  52. package/src/llm/providers/GroqProvider.ts +176 -0
  53. package/src/llm/providers/QwenProvider.ts +191 -0
  54. package/src/server.ts +7 -0
  55. package/src/types/chat.ts +14 -0
  56. package/src/types/props.ts +12 -0
  57. package/.env.example +0 -80
  58. package/LICENSE.txt +0 -21
@@ -63,6 +63,7 @@ export function useRagChat(
63
63
  ): UseRagChatReturn {
64
64
  const {
65
65
  apiUrl = '/api/chat',
66
+ retrivoraApiBase = '/api/retrivora',
66
67
  namespace,
67
68
  persist = true,
68
69
  onReply,
@@ -89,9 +90,13 @@ export function useRagChat(
89
90
 
90
91
  lastInputRef.current = trimmed;
91
92
 
93
+ const userMsgId = opts?.skipUserMessage
94
+ ? (messagesRef.current.filter((m) => m.role === 'user').pop()?.id || `user_${Date.now()}`)
95
+ : `user_${Date.now()}`;
96
+
92
97
  if (!opts?.skipUserMessage) {
93
98
  const userMessage: RagMessage = {
94
- id: `user_${Date.now()}`,
99
+ id: userMsgId,
95
100
  role: 'user',
96
101
  content: trimmed,
97
102
  createdAt: new Date().toISOString(),
@@ -112,6 +117,7 @@ export function useRagChat(
112
117
 
113
118
  try {
114
119
  const history = messagesRef.current.map(({ role, content }) => ({ role, content }));
120
+ const assistantMessageId = `assistant_${Date.now()}`;
115
121
 
116
122
  const response = await fetch(apiUrl, {
117
123
  method: 'POST',
@@ -124,6 +130,9 @@ export function useRagChat(
124
130
  message: trimmed,
125
131
  history,
126
132
  namespace: namespace ?? projectId,
133
+ sessionId: options.sessionId || 'default',
134
+ messageId: assistantMessageId,
135
+ userMessageId: userMsgId,
127
136
  }),
128
137
  });
129
138
 
@@ -180,7 +189,6 @@ export function useRagChat(
180
189
  // ─────────────────────────────────────────────────────────────────
181
190
 
182
191
  // Add a placeholder assistant message
183
- const assistantMessageId = `assistant_${Date.now()}`;
184
192
  setMessages((prev) => [
185
193
  ...prev,
186
194
  {
@@ -196,58 +204,34 @@ export function useRagChat(
196
204
  if (done) break;
197
205
 
198
206
  buffer += decoder.decode(value, { stream: true });
207
+ const frames = parseSseChunk(buffer);
208
+
209
+ // Clear processed frames from buffer (approximate by matching last found frame boundary)
210
+ const lastIndex = buffer.lastIndexOf('\n\n');
211
+ if (lastIndex !== -1) {
212
+ buffer = buffer.slice(lastIndex + 2);
213
+ }
199
214
 
200
- const lastBoundary = buffer.lastIndexOf('\n\n');
201
- if (lastBoundary === -1) continue;
202
-
203
- const complete = buffer.slice(0, lastBoundary + 2);
204
- buffer = buffer.slice(lastBoundary + 2);
205
-
206
- let hasNonTextFrame = false;
207
-
208
- for (const frame of parseSseChunk(complete)) {
209
- if (frame.type === 'text' && frame.text) {
215
+ for (const frame of frames) {
216
+ if (frame.type === 'text') {
210
217
  assistantContent += frame.text;
211
218
  pendingContentRef.current = assistantContent;
212
- // Schedule a batched RAF flush instead of immediate setMessages
213
219
  scheduleFlush(assistantMessageId);
214
- } else if (frame.type === 'thinking' && frame.text) {
220
+ } else if (frame.type === 'thinking') {
215
221
  thinkingContent += frame.text;
216
222
  pendingThinkingRef.current = thinkingContent;
217
223
  scheduleFlush(assistantMessageId);
218
224
  } else if (frame.type === 'metadata') {
219
225
  sources = frame.sources ?? [];
220
- thinkingMs = (frame as any).thinkingMs;
221
- hasNonTextFrame = true;
226
+ thinkingMs = frame.thinkingMs;
222
227
  } else if (frame.type === 'ui_transformation') {
223
228
  uiTransformation = frame.data;
224
- hasNonTextFrame = true;
225
229
  } else if (frame.type === 'observability') {
226
- trace = (frame as SseObservabilityFrame).data;
227
- hasNonTextFrame = true;
230
+ trace = frame.data;
228
231
  } else if (frame.type === 'error') {
229
- setError(frame.error || 'Stream error');
232
+ throw new Error(frame.error);
230
233
  }
231
234
  }
232
-
233
- // Flush non-text metadata immediately (sources, UI transform, trace)
234
- if (hasNonTextFrame) {
235
- pendingFlush = false; // cancel pending text flush — we'll do a full update
236
- setMessages((prev) =>
237
- prev.map((msg) =>
238
- msg.id === assistantMessageId
239
- ? {
240
- ...msg,
241
- content: assistantContent,
242
- thinking: thinkingContent || undefined,
243
- thinkingMs: thinkingMs,
244
- sources: sources.length > 0 ? sources : msg.sources,
245
- uiTransformation: uiTransformation || (msg as RagMessage).uiTransformation,
246
- }
247
- : msg
248
- )
249
- );
250
- }
251
235
  }
252
236
 
253
237
  // Process any remaining buffered data
@@ -257,9 +241,9 @@ export function useRagChat(
257
241
  else if (frame.type === 'thinking' && frame.text) thinkingContent += frame.text;
258
242
  else if (frame.type === 'metadata') {
259
243
  sources = frame.sources ?? [];
260
- thinkingMs = (frame as any).thinkingMs;
244
+ thinkingMs = frame.thinkingMs;
261
245
  }
262
- else if (frame.type === 'observability') trace = (frame as SseObservabilityFrame).data;
246
+ else if (frame.type === 'observability') trace = frame.data;
263
247
  }
264
248
  }
265
249
 
@@ -292,7 +276,7 @@ export function useRagChat(
292
276
  abortControllerRef.current = null;
293
277
  }
294
278
  },
295
- [apiUrl, isLoading, namespace, onError, onReply, projectId, setMessages]
279
+ [apiUrl, isLoading, namespace, onError, onReply, projectId, setMessages, options.headers, options.sessionId]
296
280
  );
297
281
 
298
282
  const clear = useCallback(() => {
@@ -318,6 +302,73 @@ export function useRagChat(
318
302
  setIsLoading(false);
319
303
  }, []);
320
304
 
305
+ const loadHistory = useCallback(async (sessionId: string) => {
306
+ setIsLoading(true);
307
+ setError(null);
308
+ try {
309
+ const res = await fetch(`${retrivoraApiBase}/history?sessionId=${encodeURIComponent(sessionId)}`);
310
+ if (!res.ok) throw new Error(`Failed to load history: ${res.statusText}`);
311
+ const data = await res.json();
312
+ const loadedMessages = (data.history || []).map((msg: any) => ({
313
+ id: msg.id,
314
+ role: msg.role,
315
+ content: msg.content,
316
+ sources: msg.sources,
317
+ uiTransformation: msg.uiTransformation,
318
+ trace: msg.trace,
319
+ createdAt: msg.createdAt,
320
+ }));
321
+ setMessages(loadedMessages);
322
+ } catch (err) {
323
+ const msg = err instanceof Error ? err.message : 'Failed to load history';
324
+ setError(msg);
325
+ onError?.(msg);
326
+ } finally {
327
+ setIsLoading(false);
328
+ }
329
+ }, [retrivoraApiBase, onError, setMessages]);
330
+
331
+ const clearHistory = useCallback(async (sessionId: string) => {
332
+ setIsLoading(true);
333
+ setError(null);
334
+ try {
335
+ const res = await fetch(`${retrivoraApiBase}/history/clear`, {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({ sessionId }),
339
+ });
340
+ if (!res.ok) throw new Error(`Failed to clear history: ${res.statusText}`);
341
+ setMessages([]);
342
+ if (persist) localStorage.removeItem(storageKey);
343
+ } catch (err) {
344
+ const msg = err instanceof Error ? err.message : 'Failed to clear history';
345
+ setError(msg);
346
+ onError?.(msg);
347
+ } finally {
348
+ setIsLoading(false);
349
+ }
350
+ }, [retrivoraApiBase, onError, persist, setMessages, storageKey]);
351
+
352
+ const submitFeedback = useCallback(async (messageId: string, rating: 'thumbs_up' | 'thumbs_down', comment?: string) => {
353
+ try {
354
+ const res = await fetch(`${retrivoraApiBase}/feedback`, {
355
+ method: 'POST',
356
+ headers: { 'Content-Type': 'application/json' },
357
+ body: JSON.stringify({
358
+ messageId,
359
+ sessionId: options.sessionId || 'default',
360
+ rating,
361
+ comment,
362
+ }),
363
+ });
364
+ if (!res.ok) throw new Error(`Failed to submit feedback: ${res.statusText}`);
365
+ } catch (err) {
366
+ const msg = err instanceof Error ? err.message : 'Failed to submit feedback';
367
+ onError?.(msg);
368
+ throw err;
369
+ }
370
+ }, [retrivoraApiBase, onError, options.sessionId]);
371
+
321
372
  return {
322
373
  messages,
323
374
  isLoading,
@@ -327,5 +378,8 @@ export function useRagChat(
327
378
  retry,
328
379
  setMessages,
329
380
  stop,
381
+ loadHistory,
382
+ clearHistory,
383
+ submitFeedback,
330
384
  };
331
385
  }
@@ -32,12 +32,23 @@ export function useStoredMessages<T extends StoredMessage>(
32
32
  storageKey: string,
33
33
  persist: boolean
34
34
  ): [T[], React.Dispatch<React.SetStateAction<T[]>>] {
35
- const [messages, setMessages] = React.useState<T[]>(() => (
36
- persist ? (readStoredMessages(storageKey) as T[]) : []
37
- ));
35
+ const [messages, setMessages] = React.useState<T[]>([]);
36
+ const isLoaded = React.useRef(false);
38
37
 
38
+ // Load from localStorage only on client-side mount to prevent hydration mismatch
39
39
  React.useEffect(() => {
40
- if (!persist || typeof window === 'undefined') {
40
+ if (persist) {
41
+ const loaded = readStoredMessages(storageKey) as T[];
42
+ if (loaded.length > 0) {
43
+ setMessages(loaded);
44
+ }
45
+ }
46
+ isLoaded.current = true;
47
+ }, [storageKey, persist]);
48
+
49
+ // Save to localStorage only after initialization has completed to avoid overwriting with empty array
50
+ React.useEffect(() => {
51
+ if (!isLoaded.current || !persist || typeof window === 'undefined') {
41
52
  return;
42
53
  }
43
54
 
package/src/index.ts CHANGED
@@ -15,6 +15,11 @@ export { MessageBubble } from './components/MessageBubble';
15
15
  export { SourceCard } from './components/SourceCard';
16
16
  export { ObservabilityPanel } from './components/ObservabilityPanel';
17
17
  export { ConfigProvider, useConfig } from './components/ConfigProvider';
18
+ export { Hero } from './components/Hero';
19
+ export { Lifecycle } from './components/Lifecycle';
20
+ export { ArchitectureCardsSection } from './components/ArchitectureCardsSection';
21
+ export { Documentation } from './components/Documentation';
22
+ export { AmbientBackground } from './components/AmbientBackground';
18
23
 
19
24
  // ── Headless Hooks ────────────────────────────────────────────
20
25
  export { useRagChat } from './hooks/useRagChat';
@@ -4,6 +4,8 @@ import { OpenAIProvider } from './providers/OpenAIProvider';
4
4
  import { AnthropicProvider } from './providers/AnthropicProvider';
5
5
  import { OllamaProvider } from './providers/OllamaProvider';
6
6
  import { GeminiProvider } from './providers/GeminiProvider';
7
+ import { GroqProvider } from './providers/GroqProvider';
8
+ import { QwenProvider } from './providers/QwenProvider';
7
9
  import { UniversalLLMAdapter } from './providers/UniversalLLMAdapter';
8
10
  import { IProviderValidator, IProviderHealthChecker } from '../core/ProviderInterfaces';
9
11
  import { ProviderNotFoundException } from '../exceptions';
@@ -53,7 +55,7 @@ export class LLMFactory {
53
55
  */
54
56
  static listProviders(): string[] {
55
57
  return [
56
- 'openai', 'anthropic', 'ollama', 'gemini', 'rest', 'universal_rest', 'custom',
58
+ 'openai', 'anthropic', 'ollama', 'gemini', 'groq', 'qwen', 'rest', 'universal_rest', 'custom',
57
59
  ...Array.from(customProviders.keys()),
58
60
  ];
59
61
  }
@@ -67,6 +69,10 @@ export class LLMFactory {
67
69
  return new OllamaProvider(llmConfig, embeddingConfig);
68
70
  case 'gemini':
69
71
  return new GeminiProvider(llmConfig, embeddingConfig);
72
+ case 'groq':
73
+ return new GroqProvider(llmConfig, embeddingConfig);
74
+ case 'qwen':
75
+ return new QwenProvider(llmConfig, embeddingConfig);
70
76
  case 'rest':
71
77
  case 'universal_rest':
72
78
  case 'custom':
@@ -110,6 +116,8 @@ export class LLMFactory {
110
116
  case 'anthropic': return AnthropicProvider;
111
117
  case 'ollama': return OllamaProvider;
112
118
  case 'gemini': return GeminiProvider;
119
+ case 'groq': return GroqProvider;
120
+ case 'qwen': return QwenProvider;
113
121
  case 'rest':
114
122
  case 'universal_rest':
115
123
  case 'custom': return UniversalLLMAdapter as unknown as LLMProviderStatic;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Groq LLM Provider
3
+ */
4
+
5
+ import OpenAI from 'openai';
6
+ import { ILLMProvider, EmbedOptions } from '../ILLMProvider';
7
+ import { ChatMessage, ChatOptions } from '../../types';
8
+ import { LLMConfig, EmbeddingConfig } from '../../config/RagConfig';
9
+ import { IProviderValidator, IProviderHealthChecker, HealthCheckResult } from '../../core/ProviderInterfaces';
10
+ import { ValidationError } from '../../core/ConfigValidator';
11
+ import { buildSystemContent } from '../utils';
12
+
13
+ export class GroqProvider implements ILLMProvider {
14
+ private readonly client: OpenAI;
15
+ private readonly llmConfig: LLMConfig;
16
+
17
+ constructor(llmConfig: LLMConfig, embeddingConfig?: EmbeddingConfig) {
18
+ void embeddingConfig;
19
+ const apiKey = llmConfig.apiKey || process.env.GROQ_API_KEY;
20
+ if (!apiKey) throw new Error('[GroqProvider] llmConfig.apiKey or GROQ_API_KEY environment variable is required');
21
+
22
+ this.client = new OpenAI({
23
+ apiKey,
24
+ baseURL: llmConfig.baseUrl || 'https://api.groq.com/openai/v1',
25
+ });
26
+ this.llmConfig = llmConfig;
27
+ }
28
+
29
+ static getValidator(): IProviderValidator {
30
+ return {
31
+ validate(config: Record<string, unknown>): ValidationError[] {
32
+ const errors: ValidationError[] = [];
33
+ if (!config.apiKey && !process.env.GROQ_API_KEY) {
34
+ errors.push({
35
+ field: 'llm.apiKey',
36
+ message: 'Groq API key is required',
37
+ suggestion: 'Set GROQ_API_KEY environment variable',
38
+ severity: 'error',
39
+ });
40
+ }
41
+ if (!config.model) {
42
+ errors.push({
43
+ field: 'llm.model',
44
+ message: 'Groq model name is required',
45
+ suggestion: 'e.g., "llama-3.3-70b-versatile"',
46
+ severity: 'error',
47
+ });
48
+ }
49
+ return errors;
50
+ }
51
+ };
52
+ }
53
+
54
+ static getHealthChecker(): IProviderHealthChecker {
55
+ return {
56
+ async check(config: Record<string, unknown>): Promise<HealthCheckResult> {
57
+ const timestamp = Date.now();
58
+ const apiKey = (config.apiKey as string) || process.env.GROQ_API_KEY || '';
59
+ const modelName = config.model as string;
60
+
61
+ try {
62
+ const OpenAI = await import('openai');
63
+ const client = new OpenAI.default({
64
+ apiKey,
65
+ baseURL: (config.baseUrl as string) || 'https://api.groq.com/openai/v1',
66
+ });
67
+ const models = await client.models.list();
68
+ const hasModel = models.data.some((m) => m.id === modelName);
69
+ return {
70
+ healthy: true,
71
+ provider: 'groq',
72
+ capabilities: { model: modelName, available: hasModel, totalModels: models.data.length },
73
+ timestamp,
74
+ };
75
+ } catch (error) {
76
+ return {
77
+ healthy: false,
78
+ provider: 'groq',
79
+ error: `Connection failed: ${error instanceof Error ? error.message : String(error)}`,
80
+ timestamp,
81
+ };
82
+ }
83
+ }
84
+ };
85
+ }
86
+
87
+ async chat(messages: ChatMessage[], context: string, options?: ChatOptions): Promise<string> {
88
+ const basePrompt =
89
+ options?.systemPrompt ??
90
+ this.llmConfig.systemPrompt ??
91
+ 'You are a helpful assistant. Answer questions based on the provided context.';
92
+
93
+ const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
94
+ role: 'system',
95
+ content: buildSystemContent(basePrompt, context),
96
+ };
97
+
98
+ const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
99
+ systemMessage,
100
+ ...messages.map((m) => ({
101
+ role: m.role as 'user' | 'assistant',
102
+ content: m.content,
103
+ })),
104
+ ];
105
+
106
+ const completion = await this.client.chat.completions.create({
107
+ model: this.llmConfig.model,
108
+ messages: formattedMessages,
109
+ max_tokens: options?.maxTokens ?? this.llmConfig.maxTokens ?? 1024,
110
+ temperature: options?.temperature ?? this.llmConfig.temperature ?? 0.7,
111
+ stop: options?.stop,
112
+ });
113
+
114
+ return completion.choices[0]?.message?.content ?? '';
115
+ }
116
+
117
+ async *chatStream(messages: ChatMessage[], context: string, options?: ChatOptions): AsyncIterable<string> {
118
+ const basePrompt =
119
+ options?.systemPrompt ??
120
+ this.llmConfig.systemPrompt ??
121
+ 'You are a helpful assistant. Answer questions based on the provided context.';
122
+
123
+ const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
124
+ role: 'system',
125
+ content: buildSystemContent(basePrompt, context),
126
+ };
127
+
128
+ const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
129
+ systemMessage,
130
+ ...messages.map((m) => ({
131
+ role: m.role as 'user' | 'assistant',
132
+ content: m.content,
133
+ })),
134
+ ];
135
+
136
+ const stream = await this.client.chat.completions.create({
137
+ model: this.llmConfig.model,
138
+ messages: formattedMessages,
139
+ max_tokens: options?.maxTokens ?? this.llmConfig.maxTokens ?? 1024,
140
+ temperature: options?.temperature ?? this.llmConfig.temperature ?? 0.7,
141
+ stop: options?.stop,
142
+ stream: true,
143
+ });
144
+
145
+ if (!stream) {
146
+ throw new Error('[GroqProvider] completions.create stream is undefined');
147
+ }
148
+
149
+ for await (const chunk of stream) {
150
+ const content = chunk.choices[0]?.delta?.content || '';
151
+ if (content) yield content;
152
+ }
153
+ }
154
+
155
+ async embed(text: string, options?: EmbedOptions): Promise<number[]> {
156
+ void text;
157
+ void options;
158
+ throw new Error('[GroqProvider] Groq does not provide a native embedding API. Please configure "openai" or "gemini" for embeddings in RagConfig.');
159
+ }
160
+
161
+ async batchEmbed(texts: string[], options?: EmbedOptions): Promise<number[][]> {
162
+ void texts;
163
+ void options;
164
+ throw new Error('[GroqProvider] Groq does not provide a native embedding API. Please configure "openai" or "gemini" for embeddings in RagConfig.');
165
+ }
166
+
167
+ async ping(): Promise<boolean> {
168
+ try {
169
+ await this.client.models.list();
170
+ return true;
171
+ } catch (err) {
172
+ console.error('[GroqProvider] Ping failed:', err);
173
+ return false;
174
+ }
175
+ }
176
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Qwen LLM Provider (via Alibaba DashScope / ModelStudio compatible mode)
3
+ */
4
+
5
+ import OpenAI from 'openai';
6
+ import { ILLMProvider, EmbedOptions } from '../ILLMProvider';
7
+ import { ChatMessage, ChatOptions } from '../../types';
8
+ import { LLMConfig, EmbeddingConfig } from '../../config/RagConfig';
9
+ import { IProviderValidator, IProviderHealthChecker, HealthCheckResult } from '../../core/ProviderInterfaces';
10
+ import { ValidationError } from '../../core/ConfigValidator';
11
+ import { buildSystemContent } from '../utils';
12
+
13
+ export class QwenProvider implements ILLMProvider {
14
+ private readonly client: OpenAI;
15
+ private readonly llmConfig: LLMConfig;
16
+ private readonly embeddingConfig?: EmbeddingConfig;
17
+
18
+ constructor(llmConfig: LLMConfig, embeddingConfig?: EmbeddingConfig) {
19
+ const apiKey = llmConfig.apiKey || process.env.DASHSCOPE_API_KEY || process.env.QWEN_API_KEY;
20
+ if (!apiKey) throw new Error('[QwenProvider] llmConfig.apiKey, DASHSCOPE_API_KEY, or QWEN_API_KEY environment variable is required');
21
+
22
+ this.client = new OpenAI({
23
+ apiKey,
24
+ baseURL: llmConfig.baseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1',
25
+ });
26
+ this.llmConfig = llmConfig;
27
+ this.embeddingConfig = embeddingConfig;
28
+ }
29
+
30
+ static getValidator(): IProviderValidator {
31
+ return {
32
+ validate(config: Record<string, unknown>): ValidationError[] {
33
+ const errors: ValidationError[] = [];
34
+ const isEmbedding = config.provider === 'qwen' && 'dimensions' in config;
35
+ const prefix = isEmbedding ? 'embedding' : 'llm';
36
+
37
+ if (!config.apiKey && !process.env.DASHSCOPE_API_KEY && !process.env.QWEN_API_KEY) {
38
+ errors.push({
39
+ field: `${prefix}.apiKey`,
40
+ message: 'Qwen API key is required',
41
+ suggestion: 'Set DASHSCOPE_API_KEY or QWEN_API_KEY environment variable',
42
+ severity: 'error',
43
+ });
44
+ }
45
+ if (!config.model) {
46
+ errors.push({
47
+ field: `${prefix}.model`,
48
+ message: 'Qwen model name is required',
49
+ suggestion: isEmbedding ? 'e.g., "text-embedding-v3"' : 'e.g., "qwen-plus" or "qwen-max"',
50
+ severity: 'error',
51
+ });
52
+ }
53
+ return errors;
54
+ }
55
+ };
56
+ }
57
+
58
+ static getHealthChecker(): IProviderHealthChecker {
59
+ return {
60
+ async check(config: Record<string, unknown>): Promise<HealthCheckResult> {
61
+ const timestamp = Date.now();
62
+ const apiKey = (config.apiKey as string) || process.env.DASHSCOPE_API_KEY || process.env.QWEN_API_KEY || '';
63
+ const modelName = config.model as string;
64
+
65
+ try {
66
+ const OpenAI = await import('openai');
67
+ const client = new OpenAI.default({
68
+ apiKey,
69
+ baseURL: (config.baseUrl as string) || 'https://dashscope.aliyuncs.com/compatible-mode/v1',
70
+ });
71
+ const models = await client.models.list();
72
+ const hasModel = models.data.some((m) => m.id === modelName);
73
+ return {
74
+ healthy: true,
75
+ provider: 'qwen',
76
+ capabilities: { model: modelName, available: hasModel, totalModels: models.data.length },
77
+ timestamp,
78
+ };
79
+ } catch (error) {
80
+ return {
81
+ healthy: false,
82
+ provider: 'qwen',
83
+ error: `Connection failed: ${error instanceof Error ? error.message : String(error)}`,
84
+ timestamp,
85
+ };
86
+ }
87
+ }
88
+ };
89
+ }
90
+
91
+ async chat(messages: ChatMessage[], context: string, options?: ChatOptions): Promise<string> {
92
+ const basePrompt =
93
+ options?.systemPrompt ??
94
+ this.llmConfig.systemPrompt ??
95
+ 'You are a helpful assistant. Answer questions based on the provided context.';
96
+
97
+ const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
98
+ role: 'system',
99
+ content: buildSystemContent(basePrompt, context),
100
+ };
101
+
102
+ const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
103
+ systemMessage,
104
+ ...messages.map((m) => ({
105
+ role: m.role as 'user' | 'assistant',
106
+ content: m.content,
107
+ })),
108
+ ];
109
+
110
+ const completion = await this.client.chat.completions.create({
111
+ model: this.llmConfig.model,
112
+ messages: formattedMessages,
113
+ max_tokens: options?.maxTokens ?? this.llmConfig.maxTokens ?? 1024,
114
+ temperature: options?.temperature ?? this.llmConfig.temperature ?? 0.7,
115
+ stop: options?.stop,
116
+ });
117
+
118
+ return completion.choices[0]?.message?.content ?? '';
119
+ }
120
+
121
+ async *chatStream(messages: ChatMessage[], context: string, options?: ChatOptions): AsyncIterable<string> {
122
+ const basePrompt =
123
+ options?.systemPrompt ??
124
+ this.llmConfig.systemPrompt ??
125
+ 'You are a helpful assistant. Answer questions based on the provided context.';
126
+
127
+ const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
128
+ role: 'system',
129
+ content: buildSystemContent(basePrompt, context),
130
+ };
131
+
132
+ const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
133
+ systemMessage,
134
+ ...messages.map((m) => ({
135
+ role: m.role as 'user' | 'assistant',
136
+ content: m.content,
137
+ })),
138
+ ];
139
+
140
+ const stream = await this.client.chat.completions.create({
141
+ model: this.llmConfig.model,
142
+ messages: formattedMessages,
143
+ max_tokens: options?.maxTokens ?? this.llmConfig.maxTokens ?? 1024,
144
+ temperature: options?.temperature ?? this.llmConfig.temperature ?? 0.7,
145
+ stop: options?.stop,
146
+ stream: true,
147
+ });
148
+
149
+ if (!stream) {
150
+ throw new Error('[QwenProvider] completions.create stream is undefined');
151
+ }
152
+
153
+ for await (const chunk of stream) {
154
+ const content = chunk.choices[0]?.delta?.content || '';
155
+ if (content) yield content;
156
+ }
157
+ }
158
+
159
+ async embed(text: string, options?: EmbedOptions): Promise<number[]> {
160
+ const results = await this.batchEmbed([text], options);
161
+ return results[0];
162
+ }
163
+
164
+ async batchEmbed(texts: string[], options?: EmbedOptions): Promise<number[][]> {
165
+ const model =
166
+ options?.model ??
167
+ this.embeddingConfig?.model ??
168
+ 'text-embedding-v1';
169
+
170
+ const apiKey = this.embeddingConfig?.apiKey ?? this.llmConfig.apiKey;
171
+ const client = apiKey !== this.llmConfig.apiKey
172
+ ? new OpenAI({
173
+ apiKey,
174
+ baseURL: this.embeddingConfig?.baseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1',
175
+ })
176
+ : this.client;
177
+
178
+ const response = await client.embeddings.create({ model, input: texts });
179
+ return response.data.map((d) => d.embedding);
180
+ }
181
+
182
+ async ping(): Promise<boolean> {
183
+ try {
184
+ await this.client.models.list();
185
+ return true;
186
+ } catch (err) {
187
+ console.error('[QwenProvider] Ping failed:', err);
188
+ return false;
189
+ }
190
+ }
191
+ }