Skip to content

Commit 5932aeb

Browse files
committed
feat(embedding): add OAPI forwarding support for Ollama models
- Add environment variable OPENAI_CUSTOM_BASE_USING_OLLAMA_MODEL for OAPI forwarding control - Implement automatic /v1 path correction for custom OpenAI base URLs - Add enhanced error handling with detailed diagnostic messages for API failures - Support Ollama models via OAPI forwarding with specialized dimension detection - Fix critical dimension detection bug in ollama-embedding.ts embedBatch method - Add comprehensive test suite for OAPI forwarding functionality - Maintain 100% backward compatibility with existing OpenAI configurations Resolves embedding array access errors when using OAPI forwarding services. All changes are additive and non-breaking for existing integrations.
1 parent 25e46ac commit 5932aeb

File tree

3 files changed

+405
-4
lines changed

3 files changed

+405
-4
lines changed

packages/core/src/embedding/ollama-embedding.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,11 @@ export class OllamaEmbedding extends Embedding {
9292
// Preprocess all texts
9393
const processedTexts = this.preprocessTexts(texts);
9494

95-
// Detect dimension on first use
95+
// Detect dimension on first use if not already detected
9696
if (!this.dimensionDetected && !this.config.dimension) {
9797
this.dimension = await this.detectDimension();
9898
this.dimensionDetected = true;
9999
console.log(`📏 Detected Ollama embedding dimension: ${this.dimension} for model: ${this.config.model}`);
100-
} else {
101-
throw new Error('Failed to detect dimension for model ' + this.config.model);
102100
}
103101

104102
// Use Ollama's native batch embedding API
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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

Comments
 (0)