1+ import { OpenAI } from 'openai' ;
2+ import { OpenAIEmbedding } from './openai-embedding' ;
3+ import type { EmbeddingVector } from './base-embedding' ;
4+
5+ // Mock the OpenAI client module
6+ const mockEmbeddingsCreate = jest . fn ( ) ;
7+ jest . mock ( 'openai' , ( ) => {
8+ return jest . fn ( ) . mockImplementation ( ( ) => ( {
9+ embeddings : {
10+ create : mockEmbeddingsCreate ,
11+ } ,
12+ } ) ) ;
13+ } ) ;
14+
15+ const MockOpenAI = OpenAI as jest . Mock ;
16+
17+ describe ( 'OpenAIEmbedding OAPI Forwarding' , ( ) => {
18+ const originalEnv = process . env ;
19+ let consoleLogSpy : jest . SpyInstance ;
20+
21+ beforeEach ( ( ) => {
22+ jest . resetModules ( ) ;
23+ process . env = { ...originalEnv } ;
24+ mockEmbeddingsCreate . mockClear ( ) ;
25+ MockOpenAI . mockClear ( ) ;
26+ consoleLogSpy = jest . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } ) ;
27+ } ) ;
28+
29+ afterEach ( ( ) => {
30+ process . env = originalEnv ;
31+ consoleLogSpy . mockRestore ( ) ;
32+ } ) ;
33+
34+ describe ( 'Constructor and Configuration' , ( ) => {
35+ it ( 'should initialize for standard OpenAI API by default' , ( ) => {
36+ const embedding = new OpenAIEmbedding ( { model : 'text-embedding-3-small' , apiKey : 'test-key' } ) ;
37+ expect ( embedding [ 'isOllamaViaOAPI' ] ) . toBe ( false ) ;
38+ expect ( embedding . getDimension ( ) ) . toBe ( 1536 ) ;
39+ expect ( consoleLogSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'Configured for Ollama model' ) ) ;
40+ } ) ;
41+
42+ it ( 'should enable OAPI forwarding via config flag useOllamaModel: true' , ( ) => {
43+ const embedding = new OpenAIEmbedding ( {
44+ model : 'nomic-embed-text' ,
45+ apiKey : 'ollama-key' ,
46+ useOllamaModel : true ,
47+ } ) ;
48+ expect ( embedding [ 'isOllamaViaOAPI' ] ) . toBe ( true ) ;
49+ expect ( embedding . getDimension ( ) ) . toBe ( 768 ) ;
50+ expect ( consoleLogSpy ) . toHaveBeenCalledWith ( '[OpenAI] Configured for Ollama model nomic-embed-text via OAPI forwarding' ) ;
51+ } ) ;
52+
53+ it . each ( [
54+ [ 'true' ] ,
55+ [ 'True' ] ,
56+ ] ) ( 'should enable OAPI forwarding when OPENAI_CUSTOM_BASE_USING_OLLAMA_MODEL is "%s"' , ( envValue ) => {
57+ process . env . OPENAI_CUSTOM_BASE_USING_OLLAMA_MODEL = envValue ;
58+ const embedding = new OpenAIEmbedding ( { model : 'nomic-embed-text' , apiKey : 'ollama-key' } ) ;
59+ expect ( embedding [ 'isOllamaViaOAPI' ] ) . toBe ( true ) ;
60+ expect ( embedding . getDimension ( ) ) . toBe ( 768 ) ;
61+ } ) ;
62+
63+ it ( 'should not enable OAPI forwarding for other env var values' , ( ) => {
64+ process . env . OPENAI_CUSTOM_BASE_USING_OLLAMA_MODEL = 'false' ;
65+ const embedding = new OpenAIEmbedding ( { model : 'text-embedding-3-small' , apiKey : 'test-key' } ) ;
66+ expect ( embedding [ 'isOllamaViaOAPI' ] ) . toBe ( false ) ;
67+ } ) ;
68+ } ) ;
69+
70+ describe ( 'baseURL Correction' , ( ) => {
71+ it ( 'should append /v1 to baseURL if missing' , ( ) => {
72+ new OpenAIEmbedding ( { model : 'any-model' , apiKey : 'key' , baseURL : 'http://localhost:8080' } ) ;
73+ expect ( MockOpenAI ) . toHaveBeenCalledWith ( { apiKey : 'key' , baseURL : 'http://localhost:8080/v1' } ) ;
74+ expect ( consoleLogSpy ) . toHaveBeenCalledWith ( '[OpenAI] Auto-correcting baseURL: http://localhost:8080 → http://localhost:8080/v1' ) ;
75+ } ) ;
76+
77+ it ( 'should append /v1 to baseURL with trailing slash' , ( ) => {
78+ new OpenAIEmbedding ( { model : 'any-model' , apiKey : 'key' , baseURL : 'http://localhost:8080/' } ) ;
79+ expect ( MockOpenAI ) . toHaveBeenCalledWith ( { apiKey : 'key' , baseURL : 'http://localhost:8080/v1' } ) ;
80+ } ) ;
81+
82+ it ( 'should not modify baseURL if it already contains /v1' , ( ) => {
83+ new OpenAIEmbedding ( { model : 'any-model' , apiKey : 'key' , baseURL : 'http://localhost:8080/v1' } ) ;
84+ expect ( MockOpenAI ) . toHaveBeenCalledWith ( { apiKey : 'key' , baseURL : 'http://localhost:8080/v1' } ) ;
85+ expect ( consoleLogSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'Auto-correcting baseURL' ) ) ;
86+ } ) ;
87+
88+ it ( 'should not modify official OpenAI API URLs' , ( ) => {
89+ const officialURL = 'https://api.openai.com/v1' ;
90+ new OpenAIEmbedding ( { model : 'any-model' , apiKey : 'key' , baseURL : officialURL } ) ;
91+ expect ( MockOpenAI ) . toHaveBeenCalledWith ( { apiKey : 'key' , baseURL : officialURL } ) ;
92+ } ) ;
93+
94+ it ( 'should pass undefined baseURL if not provided' , ( ) => {
95+ new OpenAIEmbedding ( { model : 'any-model' , apiKey : 'key' } ) ;
96+ expect ( MockOpenAI ) . toHaveBeenCalledWith ( { apiKey : 'key' , baseURL : undefined } ) ;
97+ } ) ;
98+ } ) ;
99+
100+ describe ( 'OAPI Forwarding (Ollama)' , ( ) => {
101+ const ollamaConfig = { model : 'nomic-embed-text' , apiKey : 'ollama-key' , useOllamaModel : true } ;
102+
103+ it ( 'should use OAPI-specific logic for embed()' , async ( ) => {
104+ const embedding = new OpenAIEmbedding ( ollamaConfig ) ;
105+ const mockVector = Array ( 768 ) . fill ( 0.1 ) ;
106+ mockEmbeddingsCreate . mockResolvedValue ( { data : [ { embedding : mockVector } ] } ) ;
107+
108+ const result = await embedding . embed ( 'hello ollama' ) ;
109+
110+ expect ( result . vector ) . toEqual ( mockVector ) ;
111+ expect ( result . dimension ) . toBe ( 768 ) ;
112+ expect ( mockEmbeddingsCreate ) . toHaveBeenCalledWith ( {
113+ model : 'nomic-embed-text' ,
114+ input : 'hello ollama' ,
115+ encoding_format : 'float' ,
116+ } ) ;
117+ } ) ;
118+
119+ it ( 'should detect dimension on first call if default is present' , async ( ) => {
120+ const embedding = new OpenAIEmbedding ( ollamaConfig ) ;
121+ embedding [ 'dimension' ] = 1536 ;
122+
123+ const detectionVector = Array ( 768 ) . fill ( 0.2 ) ;
124+ const embedVector = Array ( 768 ) . fill ( 0.3 ) ;
125+ mockEmbeddingsCreate
126+ . mockResolvedValueOnce ( { data : [ { embedding : detectionVector } ] } )
127+ . mockResolvedValueOnce ( { data : [ { embedding : embedVector } ] } ) ;
128+
129+ await embedding . embed ( 'test text' ) ;
130+
131+ expect ( mockEmbeddingsCreate ) . toHaveBeenCalledTimes ( 2 ) ;
132+ expect ( embedding . getDimension ( ) ) . toBe ( 768 ) ;
133+ } ) ;
134+
135+ it ( 'should throw OAPI-specific error on empty response for embed()' , async ( ) => {
136+ const embedding = new OpenAIEmbedding ( ollamaConfig ) ;
137+ mockEmbeddingsCreate . mockResolvedValue ( { data : [ ] } ) ;
138+
139+ await expect ( embedding . embed ( 'test' ) ) . rejects . toThrow (
140+ 'OAPI forwarding returned empty response for Ollama model nomic-embed-text. Check OAPI service and Ollama model availability.'
141+ ) ;
142+ } ) ;
143+
144+ it ( 'should throw OAPI-specific error on batch mismatch' , async ( ) => {
145+ const embedding = new OpenAIEmbedding ( ollamaConfig ) ;
146+ mockEmbeddingsCreate . mockResolvedValue ( { data : [ { embedding : [ 1 , 2 , 3 ] } ] } ) ;
147+
148+ await expect ( embedding . embedBatch ( [ 'text1' , 'text2' ] ) ) . rejects . toThrow (
149+ 'OAPI forwarding returned 1 embeddings but expected 2 for Ollama model nomic-embed-text. This indicates: 1) Some texts were rejected by Ollama, 2) OAPI service issues, 3) Ollama model capacity limits. Check OAPI logs and Ollama status.'
150+ ) ;
151+ } ) ;
152+ } ) ;
153+
154+ describe ( 'Standard OpenAI Embedding' , ( ) => {
155+ const openaiConfig = { model : 'text-embedding-3-small' , apiKey : 'openai-key' } ;
156+
157+ it ( 'should generate embedding for a known model' , async ( ) => {
158+ const embedding = new OpenAIEmbedding ( openaiConfig ) ;
159+ const mockVector = Array ( 1536 ) . fill ( 0.5 ) ;
160+ mockEmbeddingsCreate . mockResolvedValue ( { data : [ { embedding : mockVector } ] } ) ;
161+
162+ const result = await embedding . embed ( 'hello openai' ) ;
163+
164+ expect ( result . vector ) . toEqual ( mockVector ) ;
165+ expect ( result . dimension ) . toBe ( 1536 ) ;
166+ expect ( mockEmbeddingsCreate ) . toHaveBeenCalledTimes ( 1 ) ;
167+ expect ( consoleLogSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'Detecting' ) ) ;
168+ } ) ;
169+
170+ it ( 'should detect dimension for unknown model before embedding' , async ( ) => {
171+ const customModelConfig = { model : 'my-custom-model' , apiKey : 'openai-key' } ;
172+ const embedding = new OpenAIEmbedding ( customModelConfig ) ;
173+
174+ const detectionVector = Array ( 512 ) . fill ( 0.3 ) ;
175+ const embedVector = Array ( 512 ) . fill ( 0.4 ) ;
176+ mockEmbeddingsCreate
177+ . mockResolvedValueOnce ( { data : [ { embedding : detectionVector } ] } )
178+ . mockResolvedValueOnce ( { data : [ { embedding : embedVector } ] } ) ;
179+
180+ const result = await embedding . embed ( 'test' ) ;
181+
182+ expect ( mockEmbeddingsCreate ) . toHaveBeenCalledTimes ( 2 ) ;
183+ expect ( embedding . getDimension ( ) ) . toBe ( 512 ) ;
184+ expect ( result . dimension ) . toBe ( 512 ) ;
185+ expect ( result . vector ) . toEqual ( embedVector ) ;
186+ } ) ;
187+
188+ it ( 'should throw specific error for empty API response' , async ( ) => {
189+ const embedding = new OpenAIEmbedding ( openaiConfig ) ;
190+ mockEmbeddingsCreate . mockResolvedValue ( { data : [ ] } ) ;
191+
192+ await expect ( embedding . embed ( 'test' ) ) . rejects . toThrow (
193+ 'API returned empty response. This might indicate: 1) Incorrect baseURL (missing /v1?), 2) Invalid API key, 3) Model not available, or 4) Input text was filtered out'
194+ ) ;
195+ } ) ;
196+
197+ it ( 'should handle batch embeddings correctly' , async ( ) => {
198+ const embedding = new OpenAIEmbedding ( openaiConfig ) ;
199+ const vectors = [ Array ( 1536 ) . fill ( 0.1 ) , Array ( 1536 ) . fill ( 0.2 ) ] ;
200+ mockEmbeddingsCreate . mockResolvedValue ( {
201+ data : [
202+ { embedding : vectors [ 0 ] } ,
203+ { embedding : vectors [ 1 ] } ,
204+ ]
205+ } ) ;
206+
207+ const results = await embedding . embedBatch ( [ 'text1' , 'text2' ] ) ;
208+ expect ( results . length ) . toBe ( 2 ) ;
209+ expect ( results [ 0 ] . vector ) . toEqual ( vectors [ 0 ] ) ;
210+ expect ( results [ 1 ] . dimension ) . toBe ( 1536 ) ;
211+ } ) ;
212+ } ) ;
213+
214+ describe ( 'Backward Compatibility' , ( ) => {
215+ it ( 'should maintain existing OpenAI interface without OAPI features' , ( ) => {
216+ const embedding = new OpenAIEmbedding ( {
217+ model : 'text-embedding-3-small' ,
218+ apiKey : 'test-key'
219+ } ) ;
220+
221+ // Verify all existing methods still work
222+ expect ( embedding . getProvider ( ) ) . toBe ( 'OpenAI' ) ;
223+ expect ( embedding . getDimension ( ) ) . toBe ( 1536 ) ;
224+ expect ( typeof embedding . getClient ( ) ) . toBe ( 'object' ) ;
225+ expect ( typeof embedding . setModel ) . toBe ( 'function' ) ;
226+ } ) ;
227+
228+ it ( 'should support all existing static methods' , ( ) => {
229+ const models = OpenAIEmbedding . getSupportedModels ( ) ;
230+ expect ( models [ 'text-embedding-3-small' ] ) . toBeDefined ( ) ;
231+ expect ( models [ 'text-embedding-3-large' ] ) . toBeDefined ( ) ;
232+ expect ( models [ 'text-embedding-ada-002' ] ) . toBeDefined ( ) ;
233+ } ) ;
234+ } ) ;
235+ } ) ;
0 commit comments