@opensaas/stack-rag 0.1.6

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 (149) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +10 -0
  3. package/CLAUDE.md +565 -0
  4. package/LICENSE +21 -0
  5. package/README.md +406 -0
  6. package/dist/config/index.d.ts +63 -0
  7. package/dist/config/index.d.ts.map +1 -0
  8. package/dist/config/index.js +94 -0
  9. package/dist/config/index.js.map +1 -0
  10. package/dist/config/plugin.d.ts +38 -0
  11. package/dist/config/plugin.d.ts.map +1 -0
  12. package/dist/config/plugin.js +215 -0
  13. package/dist/config/plugin.js.map +1 -0
  14. package/dist/config/plugin.test.d.ts +2 -0
  15. package/dist/config/plugin.test.d.ts.map +1 -0
  16. package/dist/config/plugin.test.js +554 -0
  17. package/dist/config/plugin.test.js.map +1 -0
  18. package/dist/config/types.d.ts +249 -0
  19. package/dist/config/types.d.ts.map +1 -0
  20. package/dist/config/types.js +5 -0
  21. package/dist/config/types.js.map +1 -0
  22. package/dist/fields/embedding.d.ts +85 -0
  23. package/dist/fields/embedding.d.ts.map +1 -0
  24. package/dist/fields/embedding.js +81 -0
  25. package/dist/fields/embedding.js.map +1 -0
  26. package/dist/fields/embedding.test.d.ts +2 -0
  27. package/dist/fields/embedding.test.d.ts.map +1 -0
  28. package/dist/fields/embedding.test.js +323 -0
  29. package/dist/fields/embedding.test.js.map +1 -0
  30. package/dist/fields/index.d.ts +6 -0
  31. package/dist/fields/index.d.ts.map +1 -0
  32. package/dist/fields/index.js +5 -0
  33. package/dist/fields/index.js.map +1 -0
  34. package/dist/index.d.ts +8 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +9 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/mcp/index.d.ts +19 -0
  39. package/dist/mcp/index.d.ts.map +1 -0
  40. package/dist/mcp/index.js +18 -0
  41. package/dist/mcp/index.js.map +1 -0
  42. package/dist/providers/index.d.ts +38 -0
  43. package/dist/providers/index.d.ts.map +1 -0
  44. package/dist/providers/index.js +68 -0
  45. package/dist/providers/index.js.map +1 -0
  46. package/dist/providers/ollama.d.ts +49 -0
  47. package/dist/providers/ollama.d.ts.map +1 -0
  48. package/dist/providers/ollama.js +151 -0
  49. package/dist/providers/ollama.js.map +1 -0
  50. package/dist/providers/openai.d.ts +41 -0
  51. package/dist/providers/openai.d.ts.map +1 -0
  52. package/dist/providers/openai.js +126 -0
  53. package/dist/providers/openai.js.map +1 -0
  54. package/dist/providers/providers.test.d.ts +2 -0
  55. package/dist/providers/providers.test.d.ts.map +1 -0
  56. package/dist/providers/providers.test.js +224 -0
  57. package/dist/providers/providers.test.js.map +1 -0
  58. package/dist/providers/types.d.ts +88 -0
  59. package/dist/providers/types.d.ts.map +1 -0
  60. package/dist/providers/types.js +2 -0
  61. package/dist/providers/types.js.map +1 -0
  62. package/dist/runtime/batch.d.ts +183 -0
  63. package/dist/runtime/batch.d.ts.map +1 -0
  64. package/dist/runtime/batch.js +240 -0
  65. package/dist/runtime/batch.js.map +1 -0
  66. package/dist/runtime/batch.test.d.ts +2 -0
  67. package/dist/runtime/batch.test.d.ts.map +1 -0
  68. package/dist/runtime/batch.test.js +251 -0
  69. package/dist/runtime/batch.test.js.map +1 -0
  70. package/dist/runtime/chunking.d.ts +42 -0
  71. package/dist/runtime/chunking.d.ts.map +1 -0
  72. package/dist/runtime/chunking.js +264 -0
  73. package/dist/runtime/chunking.js.map +1 -0
  74. package/dist/runtime/chunking.test.d.ts +2 -0
  75. package/dist/runtime/chunking.test.d.ts.map +1 -0
  76. package/dist/runtime/chunking.test.js +212 -0
  77. package/dist/runtime/chunking.test.js.map +1 -0
  78. package/dist/runtime/embeddings.d.ts +147 -0
  79. package/dist/runtime/embeddings.d.ts.map +1 -0
  80. package/dist/runtime/embeddings.js +201 -0
  81. package/dist/runtime/embeddings.js.map +1 -0
  82. package/dist/runtime/embeddings.test.d.ts +2 -0
  83. package/dist/runtime/embeddings.test.d.ts.map +1 -0
  84. package/dist/runtime/embeddings.test.js +366 -0
  85. package/dist/runtime/embeddings.test.js.map +1 -0
  86. package/dist/runtime/index.d.ts +14 -0
  87. package/dist/runtime/index.d.ts.map +1 -0
  88. package/dist/runtime/index.js +18 -0
  89. package/dist/runtime/index.js.map +1 -0
  90. package/dist/runtime/search.d.ts +135 -0
  91. package/dist/runtime/search.d.ts.map +1 -0
  92. package/dist/runtime/search.js +101 -0
  93. package/dist/runtime/search.js.map +1 -0
  94. package/dist/storage/index.d.ts +41 -0
  95. package/dist/storage/index.d.ts.map +1 -0
  96. package/dist/storage/index.js +73 -0
  97. package/dist/storage/index.js.map +1 -0
  98. package/dist/storage/json.d.ts +34 -0
  99. package/dist/storage/json.d.ts.map +1 -0
  100. package/dist/storage/json.js +82 -0
  101. package/dist/storage/json.js.map +1 -0
  102. package/dist/storage/pgvector.d.ts +53 -0
  103. package/dist/storage/pgvector.d.ts.map +1 -0
  104. package/dist/storage/pgvector.js +168 -0
  105. package/dist/storage/pgvector.js.map +1 -0
  106. package/dist/storage/sqlite-vss.d.ts +49 -0
  107. package/dist/storage/sqlite-vss.d.ts.map +1 -0
  108. package/dist/storage/sqlite-vss.js +148 -0
  109. package/dist/storage/sqlite-vss.js.map +1 -0
  110. package/dist/storage/storage.test.d.ts +2 -0
  111. package/dist/storage/storage.test.d.ts.map +1 -0
  112. package/dist/storage/storage.test.js +440 -0
  113. package/dist/storage/storage.test.js.map +1 -0
  114. package/dist/storage/types.d.ts +79 -0
  115. package/dist/storage/types.d.ts.map +1 -0
  116. package/dist/storage/types.js +49 -0
  117. package/dist/storage/types.js.map +1 -0
  118. package/package.json +82 -0
  119. package/src/config/index.ts +116 -0
  120. package/src/config/plugin.test.ts +664 -0
  121. package/src/config/plugin.ts +257 -0
  122. package/src/config/types.ts +283 -0
  123. package/src/fields/embedding.test.ts +408 -0
  124. package/src/fields/embedding.ts +150 -0
  125. package/src/fields/index.ts +6 -0
  126. package/src/index.ts +33 -0
  127. package/src/mcp/index.ts +21 -0
  128. package/src/providers/index.ts +81 -0
  129. package/src/providers/ollama.ts +186 -0
  130. package/src/providers/openai.ts +161 -0
  131. package/src/providers/providers.test.ts +275 -0
  132. package/src/providers/types.ts +100 -0
  133. package/src/runtime/batch.test.ts +332 -0
  134. package/src/runtime/batch.ts +424 -0
  135. package/src/runtime/chunking.test.ts +258 -0
  136. package/src/runtime/chunking.ts +334 -0
  137. package/src/runtime/embeddings.test.ts +441 -0
  138. package/src/runtime/embeddings.ts +380 -0
  139. package/src/runtime/index.ts +51 -0
  140. package/src/runtime/search.ts +243 -0
  141. package/src/storage/index.ts +86 -0
  142. package/src/storage/json.ts +106 -0
  143. package/src/storage/pgvector.ts +206 -0
  144. package/src/storage/sqlite-vss.ts +193 -0
  145. package/src/storage/storage.test.ts +521 -0
  146. package/src/storage/types.ts +126 -0
  147. package/tsconfig.json +13 -0
  148. package/tsconfig.tsbuildinfo +1 -0
  149. package/vitest.config.ts +18 -0
@@ -0,0 +1,186 @@
1
+ import type { EmbeddingProvider } from './types.js'
2
+ import type { OllamaEmbeddingConfig } from '../config/types.js'
3
+
4
+ /**
5
+ * Ollama API response types
6
+ */
7
+ type OllamaEmbeddingResponse = {
8
+ embedding: number[]
9
+ model: string
10
+ total_duration?: number
11
+ load_duration?: number
12
+ prompt_eval_count?: number
13
+ }
14
+
15
+ /**
16
+ * Ollama embedding provider
17
+ * Uses local Ollama instance for embedding generation
18
+ */
19
+ export class OllamaEmbeddingProvider implements EmbeddingProvider {
20
+ readonly type = 'ollama'
21
+ readonly model: string
22
+ dimensions: number = 0 // Will be determined from first embedding
23
+
24
+ private baseURL: string
25
+ private dimensionsInitialized = false
26
+
27
+ constructor(config: OllamaEmbeddingConfig) {
28
+ this.baseURL = config.baseURL || 'http://localhost:11434'
29
+ this.model = config.model || 'nomic-embed-text'
30
+
31
+ // Remove trailing slash from baseURL
32
+ if (this.baseURL.endsWith('/')) {
33
+ this.baseURL = this.baseURL.slice(0, -1)
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Initialize dimensions by generating a test embedding
39
+ */
40
+ private async initializeDimensions(): Promise<void> {
41
+ if (this.dimensionsInitialized) {
42
+ return
43
+ }
44
+
45
+ try {
46
+ const testEmbedding = await this.embed('test')
47
+ this.dimensions = testEmbedding.length
48
+ this.dimensionsInitialized = true
49
+ } catch (error) {
50
+ throw new Error(
51
+ `Failed to initialize Ollama provider (ensure Ollama is running and model '${this.model}' is available): ${(error as Error).message}`,
52
+ )
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Make HTTP request to Ollama API
58
+ */
59
+ private async makeRequest<T>(endpoint: string, body: unknown): Promise<T> {
60
+ const url = `${this.baseURL}${endpoint}`
61
+
62
+ try {
63
+ const response = await fetch(url, {
64
+ method: 'POST',
65
+ headers: {
66
+ 'Content-Type': 'application/json',
67
+ },
68
+ body: JSON.stringify(body),
69
+ })
70
+
71
+ if (!response.ok) {
72
+ const errorText = await response.text()
73
+ throw new Error(`HTTP ${response.status}: ${errorText}`)
74
+ }
75
+
76
+ return (await response.json()) as T
77
+ } catch (error) {
78
+ if (error instanceof TypeError && error.message.includes('fetch')) {
79
+ throw new Error(`Failed to connect to Ollama at ${this.baseURL}. Ensure Ollama is running.`)
80
+ }
81
+ throw error
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Generate embedding for a single text
87
+ */
88
+ async embed(text: string): Promise<number[]> {
89
+ if (!text || text.trim().length === 0) {
90
+ throw new Error('Cannot generate embedding for empty text')
91
+ }
92
+
93
+ try {
94
+ const response = await this.makeRequest<OllamaEmbeddingResponse>('/api/embeddings', {
95
+ model: this.model,
96
+ prompt: text,
97
+ })
98
+
99
+ // Initialize dimensions if not yet done
100
+ if (!this.dimensionsInitialized) {
101
+ this.dimensions = response.embedding.length
102
+ this.dimensionsInitialized = true
103
+ }
104
+
105
+ return response.embedding
106
+ } catch (error) {
107
+ throw new Error(`Ollama embedding generation failed: ${(error as Error).message}`)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Generate embeddings for multiple texts in a batch
113
+ * Note: Ollama doesn't have native batch API, so we make parallel requests
114
+ */
115
+ async embedBatch(texts: string[]): Promise<number[][]> {
116
+ if (texts.length === 0) {
117
+ return []
118
+ }
119
+
120
+ // Ensure dimensions are initialized
121
+ if (!this.dimensionsInitialized) {
122
+ await this.initializeDimensions()
123
+ }
124
+
125
+ // Filter out empty texts and keep track of indices
126
+ const validTexts: string[] = []
127
+ const validIndices: number[] = []
128
+
129
+ texts.forEach((text, index) => {
130
+ if (text && text.trim().length > 0) {
131
+ validTexts.push(text)
132
+ validIndices.push(index)
133
+ }
134
+ })
135
+
136
+ if (validTexts.length === 0) {
137
+ throw new Error('Cannot generate embeddings for all empty texts')
138
+ }
139
+
140
+ try {
141
+ // Make parallel requests (Ollama doesn't have batch API)
142
+ const embeddingPromises = validTexts.map((text) => this.embed(text))
143
+ const embeddings = await Promise.all(embeddingPromises)
144
+
145
+ // Create result array with correct size
146
+ const results: number[][] = new Array(texts.length)
147
+
148
+ // Fill in embeddings for valid texts
149
+ embeddings.forEach((embedding, i) => {
150
+ const originalIndex = validIndices[i]
151
+ results[originalIndex] = embedding
152
+ })
153
+
154
+ // Fill in empty arrays for invalid texts
155
+ for (let i = 0; i < texts.length; i++) {
156
+ if (!results[i]) {
157
+ results[i] = new Array(this.dimensions).fill(0)
158
+ }
159
+ }
160
+
161
+ return results
162
+ } catch (error) {
163
+ throw new Error(`Ollama batch embedding generation failed: ${(error as Error).message}`)
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Create an Ollama embedding provider instance
170
+ *
171
+ * @example
172
+ * ```typescript
173
+ * import { createOllamaProvider } from '@opensaas/stack-rag/providers'
174
+ *
175
+ * const provider = createOllamaProvider({
176
+ * type: 'ollama',
177
+ * baseURL: 'http://localhost:11434',
178
+ * model: 'nomic-embed-text'
179
+ * })
180
+ *
181
+ * const embedding = await provider.embed('Hello world')
182
+ * ```
183
+ */
184
+ export function createOllamaProvider(config: OllamaEmbeddingConfig): OllamaEmbeddingProvider {
185
+ return new OllamaEmbeddingProvider(config)
186
+ }
@@ -0,0 +1,161 @@
1
+ import type { EmbeddingProvider } from './types.js'
2
+ import type { OpenAIEmbeddingConfig, OpenAIEmbeddingModel } from '../config/types.js'
3
+
4
+ /**
5
+ * Model dimensions mapping
6
+ */
7
+ const MODEL_DIMENSIONS: Record<OpenAIEmbeddingModel, number> = {
8
+ 'text-embedding-3-small': 1536,
9
+ 'text-embedding-3-large': 3072,
10
+ 'text-embedding-ada-002': 1536,
11
+ }
12
+
13
+ /**
14
+ * Type for OpenAI client (avoids direct dependency)
15
+ */
16
+ type OpenAIClient = {
17
+ embeddings: {
18
+ create: (params: {
19
+ model: string
20
+ input: string | string[]
21
+ encoding_format: string
22
+ }) => Promise<{
23
+ data: Array<{ embedding: number[] }>
24
+ }>
25
+ }
26
+ }
27
+
28
+ /**
29
+ * OpenAI embedding provider
30
+ * Requires the `openai` package to be installed
31
+ */
32
+ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
33
+ readonly type = 'openai'
34
+ readonly model: string
35
+ readonly dimensions: number
36
+
37
+ private client: OpenAIClient
38
+ private config: OpenAIEmbeddingConfig
39
+
40
+ constructor(config: OpenAIEmbeddingConfig) {
41
+ this.config = config
42
+ this.model = config.model || 'text-embedding-3-small'
43
+ this.dimensions = MODEL_DIMENSIONS[this.model as OpenAIEmbeddingModel] || 1536
44
+
45
+ // Initialize OpenAI client
46
+ this.client = this.initializeClient()
47
+ }
48
+
49
+ private initializeClient(): OpenAIClient {
50
+ try {
51
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
52
+ const { OpenAI } = require('openai')
53
+
54
+ return new OpenAI({
55
+ apiKey: this.config.apiKey,
56
+ organization: this.config.organization,
57
+ baseURL: this.config.baseURL,
58
+ }) as OpenAIClient
59
+ } catch (error) {
60
+ throw new Error(
61
+ 'OpenAI package not found. Install it with: npm install openai\n' +
62
+ 'Error: ' +
63
+ (error as Error).message,
64
+ )
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Generate embedding for a single text
70
+ */
71
+ async embed(text: string): Promise<number[]> {
72
+ if (!text || text.trim().length === 0) {
73
+ throw new Error('Cannot generate embedding for empty text')
74
+ }
75
+
76
+ try {
77
+ const response = await this.client.embeddings.create({
78
+ model: this.model,
79
+ input: text,
80
+ encoding_format: 'float',
81
+ })
82
+
83
+ return response.data[0].embedding
84
+ } catch (error) {
85
+ throw new Error(`OpenAI embedding generation failed: ${(error as Error).message}`)
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Generate embeddings for multiple texts in a batch
91
+ */
92
+ async embedBatch(texts: string[]): Promise<number[][]> {
93
+ if (texts.length === 0) {
94
+ return []
95
+ }
96
+
97
+ // Filter out empty texts and keep track of indices
98
+ const validTexts: string[] = []
99
+ const validIndices: number[] = []
100
+
101
+ texts.forEach((text, index) => {
102
+ if (text && text.trim().length > 0) {
103
+ validTexts.push(text)
104
+ validIndices.push(index)
105
+ }
106
+ })
107
+
108
+ if (validTexts.length === 0) {
109
+ throw new Error('Cannot generate embeddings for all empty texts')
110
+ }
111
+
112
+ try {
113
+ // OpenAI supports batch embedding
114
+ const response = await this.client.embeddings.create({
115
+ model: this.model,
116
+ input: validTexts,
117
+ encoding_format: 'float',
118
+ })
119
+
120
+ // Create result array with correct size
121
+ const results: number[][] = new Array(texts.length)
122
+
123
+ // Fill in embeddings for valid texts
124
+ response.data.forEach((item: { embedding: number[] }, i: number) => {
125
+ const originalIndex = validIndices[i]
126
+ results[originalIndex] = item.embedding
127
+ })
128
+
129
+ // Fill in empty arrays for invalid texts
130
+ for (let i = 0; i < texts.length; i++) {
131
+ if (!results[i]) {
132
+ results[i] = new Array(this.dimensions).fill(0)
133
+ }
134
+ }
135
+
136
+ return results
137
+ } catch (error) {
138
+ throw new Error(`OpenAI batch embedding generation failed: ${(error as Error).message}`)
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Create an OpenAI embedding provider instance
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * import { createOpenAIProvider } from '@opensaas/stack-rag/providers'
149
+ *
150
+ * const provider = createOpenAIProvider({
151
+ * type: 'openai',
152
+ * apiKey: process.env.OPENAI_API_KEY!,
153
+ * model: 'text-embedding-3-small'
154
+ * })
155
+ *
156
+ * const embedding = await provider.embed('Hello world')
157
+ * ```
158
+ */
159
+ export function createOpenAIProvider(config: OpenAIEmbeddingConfig): OpenAIEmbeddingProvider {
160
+ return new OpenAIEmbeddingProvider(config)
161
+ }
@@ -0,0 +1,275 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { OpenAIEmbeddingProvider } from './openai.js'
3
+ import { OllamaEmbeddingProvider } from './ollama.js'
4
+ import { createEmbeddingProvider } from './index.js'
5
+
6
+ describe('Embedding Providers', () => {
7
+ describe('OpenAIEmbeddingProvider', () => {
8
+ describe('constructor', () => {
9
+ it('should initialize with default model', () => {
10
+ const provider = new OpenAIEmbeddingProvider({
11
+ type: 'openai',
12
+ apiKey: 'test-key',
13
+ })
14
+
15
+ expect(provider.type).toBe('openai')
16
+ expect(provider.model).toBe('text-embedding-3-small')
17
+ expect(provider.dimensions).toBe(1536)
18
+ })
19
+
20
+ it('should initialize with custom model', () => {
21
+ const provider = new OpenAIEmbeddingProvider({
22
+ type: 'openai',
23
+ apiKey: 'test-key',
24
+ model: 'text-embedding-3-large',
25
+ })
26
+
27
+ expect(provider.model).toBe('text-embedding-3-large')
28
+ expect(provider.dimensions).toBe(3072)
29
+ })
30
+
31
+ it('should support ada-002 model', () => {
32
+ const provider = new OpenAIEmbeddingProvider({
33
+ type: 'openai',
34
+ apiKey: 'test-key',
35
+ model: 'text-embedding-ada-002',
36
+ })
37
+
38
+ expect(provider.model).toBe('text-embedding-ada-002')
39
+ expect(provider.dimensions).toBe(1536)
40
+ })
41
+
42
+ it('should throw error if openai package is not installed', () => {
43
+ // Skip this test - it's difficult to mock require() in ESM environment
44
+ // The actual error handling is tested when the package is genuinely missing
45
+ // This test would require more complex module mocking that isn't worth it
46
+ })
47
+ })
48
+
49
+ describe('embed', () => {
50
+ it('should reject empty text', async () => {
51
+ const provider = new OpenAIEmbeddingProvider({
52
+ type: 'openai',
53
+ apiKey: 'test-key',
54
+ })
55
+
56
+ await expect(provider.embed('')).rejects.toThrow('Cannot generate embedding for empty text')
57
+ await expect(provider.embed(' ')).rejects.toThrow(
58
+ 'Cannot generate embedding for empty text',
59
+ )
60
+ })
61
+
62
+ it('should validate embedding dimensions', () => {
63
+ const provider = new OpenAIEmbeddingProvider({
64
+ type: 'openai',
65
+ apiKey: 'test-key',
66
+ model: 'text-embedding-3-small',
67
+ })
68
+
69
+ expect(provider.dimensions).toBe(1536)
70
+ })
71
+ })
72
+
73
+ describe('embedBatch', () => {
74
+ it('should return empty array for empty input', async () => {
75
+ const provider = new OpenAIEmbeddingProvider({
76
+ type: 'openai',
77
+ apiKey: 'test-key',
78
+ })
79
+
80
+ const result = await provider.embedBatch([])
81
+ expect(result).toEqual([])
82
+ })
83
+
84
+ it('should reject all empty texts', async () => {
85
+ const provider = new OpenAIEmbeddingProvider({
86
+ type: 'openai',
87
+ apiKey: 'test-key',
88
+ })
89
+
90
+ await expect(provider.embedBatch(['', ' ', '\n'])).rejects.toThrow(
91
+ 'Cannot generate embeddings for all empty texts',
92
+ )
93
+ })
94
+
95
+ it('should handle mixed valid and invalid texts', () => {
96
+ const provider = new OpenAIEmbeddingProvider({
97
+ type: 'openai',
98
+ apiKey: 'test-key',
99
+ })
100
+
101
+ // We expect this to filter out empty texts and process valid ones
102
+ // The implementation should fill empty slots with zero vectors
103
+ expect(provider.dimensions).toBe(1536)
104
+ })
105
+ })
106
+ })
107
+
108
+ describe('OllamaEmbeddingProvider', () => {
109
+ describe('constructor', () => {
110
+ it('should initialize with default settings', () => {
111
+ const provider = new OllamaEmbeddingProvider({
112
+ type: 'ollama',
113
+ })
114
+
115
+ expect(provider.type).toBe('ollama')
116
+ expect(provider.model).toBe('nomic-embed-text')
117
+ expect(provider.dimensions).toBe(0) // Not initialized until first embedding
118
+ })
119
+
120
+ it('should initialize with custom model', () => {
121
+ const provider = new OllamaEmbeddingProvider({
122
+ type: 'ollama',
123
+ model: 'llama2',
124
+ })
125
+
126
+ expect(provider.model).toBe('llama2')
127
+ })
128
+
129
+ it('should initialize with custom baseURL', () => {
130
+ const provider = new OllamaEmbeddingProvider({
131
+ type: 'ollama',
132
+ baseURL: 'http://custom-host:8080',
133
+ })
134
+
135
+ expect(provider['baseURL']).toBe('http://custom-host:8080')
136
+ })
137
+
138
+ it('should remove trailing slash from baseURL', () => {
139
+ const provider = new OllamaEmbeddingProvider({
140
+ type: 'ollama',
141
+ baseURL: 'http://localhost:11434/',
142
+ })
143
+
144
+ expect(provider['baseURL']).toBe('http://localhost:11434')
145
+ })
146
+ })
147
+
148
+ describe('embed', () => {
149
+ it('should reject empty text', async () => {
150
+ const provider = new OllamaEmbeddingProvider({
151
+ type: 'ollama',
152
+ })
153
+
154
+ await expect(provider.embed('')).rejects.toThrow('Cannot generate embedding for empty text')
155
+ await expect(provider.embed(' ')).rejects.toThrow(
156
+ 'Cannot generate embedding for empty text',
157
+ )
158
+ })
159
+
160
+ it('should provide helpful error when Ollama is not running', async () => {
161
+ const provider = new OllamaEmbeddingProvider({
162
+ type: 'ollama',
163
+ baseURL: 'http://localhost:99999', // Invalid port
164
+ })
165
+
166
+ // The error message will vary depending on the environment
167
+ // Just check that it throws an error
168
+ await expect(provider.embed('test')).rejects.toThrow()
169
+ })
170
+ })
171
+
172
+ describe('embedBatch', () => {
173
+ it('should return empty array for empty input', async () => {
174
+ const provider = new OllamaEmbeddingProvider({
175
+ type: 'ollama',
176
+ })
177
+
178
+ const result = await provider.embedBatch([])
179
+ expect(result).toEqual([])
180
+ })
181
+
182
+ it('should reject all empty texts', async () => {
183
+ const provider = new OllamaEmbeddingProvider({
184
+ type: 'ollama',
185
+ })
186
+
187
+ // This test will fail if Ollama is not running, which is expected
188
+ // The error could be about initialization or about empty texts
189
+ await expect(provider.embedBatch(['', ' ', '\n'])).rejects.toThrow()
190
+ })
191
+ })
192
+ })
193
+
194
+ describe('createEmbeddingProvider factory', () => {
195
+ it('should create OpenAI provider', () => {
196
+ const provider = createEmbeddingProvider({
197
+ type: 'openai',
198
+ apiKey: 'test-key',
199
+ })
200
+
201
+ expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider)
202
+ expect(provider.type).toBe('openai')
203
+ })
204
+
205
+ it('should create Ollama provider', () => {
206
+ const provider = createEmbeddingProvider({
207
+ type: 'ollama',
208
+ })
209
+
210
+ expect(provider).toBeInstanceOf(OllamaEmbeddingProvider)
211
+ expect(provider.type).toBe('ollama')
212
+ })
213
+
214
+ it('should throw error for unknown provider type', () => {
215
+ expect(() => {
216
+ createEmbeddingProvider({
217
+ type: 'unknown' as 'openai',
218
+ })
219
+ }).toThrow(/Unknown embedding provider type/)
220
+ })
221
+ })
222
+
223
+ describe('Provider interface compliance', () => {
224
+ it('OpenAI provider should implement EmbeddingProvider interface', () => {
225
+ const provider = new OpenAIEmbeddingProvider({
226
+ type: 'openai',
227
+ apiKey: 'test-key',
228
+ })
229
+
230
+ expect(provider).toHaveProperty('type')
231
+ expect(provider).toHaveProperty('model')
232
+ expect(provider).toHaveProperty('dimensions')
233
+ expect(provider).toHaveProperty('embed')
234
+ expect(provider).toHaveProperty('embedBatch')
235
+ expect(typeof provider.embed).toBe('function')
236
+ expect(typeof provider.embedBatch).toBe('function')
237
+ })
238
+
239
+ it('Ollama provider should implement EmbeddingProvider interface', () => {
240
+ const provider = new OllamaEmbeddingProvider({
241
+ type: 'ollama',
242
+ })
243
+
244
+ expect(provider).toHaveProperty('type')
245
+ expect(provider).toHaveProperty('model')
246
+ expect(provider).toHaveProperty('dimensions')
247
+ expect(provider).toHaveProperty('embed')
248
+ expect(provider).toHaveProperty('embedBatch')
249
+ expect(typeof provider.embed).toBe('function')
250
+ expect(typeof provider.embedBatch).toBe('function')
251
+ })
252
+ })
253
+
254
+ describe('Error handling', () => {
255
+ it('should handle API errors gracefully', async () => {
256
+ const provider = new OpenAIEmbeddingProvider({
257
+ type: 'openai',
258
+ apiKey: 'invalid-key',
259
+ })
260
+
261
+ // This will fail when actually calling the API
262
+ // but we're testing that it throws a descriptive error
263
+ await expect(provider.embed('test')).rejects.toThrow(/OpenAI embedding generation failed/)
264
+ })
265
+
266
+ it('should handle network errors for Ollama', async () => {
267
+ const provider = new OllamaEmbeddingProvider({
268
+ type: 'ollama',
269
+ baseURL: 'http://nonexistent-host:11434',
270
+ })
271
+
272
+ await expect(provider.embed('test')).rejects.toThrow()
273
+ })
274
+ })
275
+ })