diff --git a/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts b/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts index 0353771f60..5c76bedb02 100644 --- a/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts @@ -1051,4 +1051,139 @@ describe("OpenAICompatibleEmbedder", () => { expect(result.error).toBe("embeddings:validation.configurationError") }) }) + + describe("Gemini compatibility", () => { + let geminiEmbedder: OpenAICompatibleEmbedder + const geminiBaseUrl = "https://generativelanguage.googleapis.com/v1beta/openai/" + const geminiApiKey = "test-gemini-api-key" + const geminiModelId = "gemini-embedding-001" + + beforeEach(() => { + vitest.clearAllMocks() + geminiEmbedder = new OpenAICompatibleEmbedder(geminiBaseUrl, geminiApiKey, geminiModelId) + }) + + it("should NOT include encoding_format for Gemini endpoints", async () => { + const testTexts = ["Hello world"] + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + await geminiEmbedder.createEmbeddings(testTexts) + + // Verify that encoding_format is NOT included for Gemini + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: geminiModelId, + // encoding_format should NOT be present + }) + + // Verify the call doesn't have encoding_format property + const callArgs = mockEmbeddingsCreate.mock.calls[0][0] + expect(callArgs).not.toHaveProperty("encoding_format") + }) + + it("should still include encoding_format for non-Gemini OpenAI-compatible endpoints", async () => { + // Create a non-Gemini embedder + const regularEmbedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + const testTexts = ["Hello world"] + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + await regularEmbedder.createEmbeddings(testTexts) + + // Verify that encoding_format IS included for non-Gemini endpoints + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: testModelId, + encoding_format: "base64", + }) + }) + + it("should correctly identify Gemini URLs", () => { + const geminiUrls = [ + "https://generativelanguage.googleapis.com/v1beta/openai/", + "https://generativelanguage.googleapis.com/v1/openai/", + "https://generativelanguage.googleapis.com/v2/embeddings", + ] + + geminiUrls.forEach((url) => { + const embedder = new OpenAICompatibleEmbedder(url, geminiApiKey, geminiModelId) + const isGemini = (embedder as any).isGeminiUrl(url) + expect(isGemini).toBe(true) + }) + }) + + it("should not identify non-Gemini URLs as Gemini", () => { + const nonGeminiUrls = [ + "https://api.openai.com/v1", + "https://api.example.com/embeddings", + "https://myinstance.openai.azure.com/openai/deployments/my-deployment/embeddings", + "http://localhost:8080", + ] + + nonGeminiUrls.forEach((url) => { + const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId) + const isGemini = (embedder as any).isGeminiUrl(url) + expect(isGemini).toBe(false) + }) + }) + + it("should validate Gemini configuration without encoding_format", async () => { + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await geminiEmbedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + + // Verify validation call doesn't include encoding_format + const callArgs = mockEmbeddingsCreate.mock.calls[0][0] + expect(callArgs).not.toHaveProperty("encoding_format") + }) + + it("should handle direct HTTP requests for Gemini full URLs without encoding_format", async () => { + const geminiFullUrl = "https://generativelanguage.googleapis.com/v1beta/openai/embeddings" + const fullUrlEmbedder = new OpenAICompatibleEmbedder(geminiFullUrl, geminiApiKey, geminiModelId) + + const mockFetch = vitest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }), + }) + global.fetch = mockFetch + + await fullUrlEmbedder.createEmbeddings(["test"]) + + // Check that the request body doesn't include encoding_format + expect(mockFetch).toHaveBeenCalledWith( + geminiFullUrl, + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + input: ["test"], + model: geminiModelId, + // encoding_format should NOT be present + }), + }), + ) + + // Verify the actual body content + const callArgs = mockFetch.mock.calls[0][1] + const bodyContent = JSON.parse(callArgs.body) + expect(bodyContent).not.toHaveProperty("encoding_format") + }) + }) }) diff --git a/src/services/code-index/embedders/openai-compatible.ts b/src/services/code-index/embedders/openai-compatible.ts index 06c4ba5282..cdd260dee7 100644 --- a/src/services/code-index/embedders/openai-compatible.ts +++ b/src/services/code-index/embedders/openai-compatible.ts @@ -38,6 +38,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { private readonly apiKey: string private readonly isFullUrl: boolean private readonly maxItemTokens: number + private readonly isGeminiEndpoint: boolean // Global rate limiting state shared across all instances private static globalRateLimitState = { @@ -73,6 +74,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { this.defaultModelId = modelId || getDefaultModelId("openai-compatible") // Cache the URL type check for performance this.isFullUrl = this.isFullEndpointUrl(baseUrl) + this.isGeminiEndpoint = this.isGeminiUrl(baseUrl) this.maxItemTokens = maxItemTokens || MAX_ITEM_TOKENS } @@ -182,6 +184,15 @@ export class OpenAICompatibleEmbedder implements IEmbedder { return patterns.some((pattern) => pattern.test(url)) } + /** + * Determines if the provided URL is a Google Gemini endpoint + * @param url The URL to check + * @returns true if it's a Gemini endpoint + */ + private isGeminiUrl(url: string): boolean { + return url.includes("generativelanguage.googleapis.com") + } + /** * Makes a direct HTTP request to the embeddings endpoint * Used when the user provides a full endpoint URL (e.g., Azure OpenAI with query parameters) @@ -204,11 +215,19 @@ export class OpenAICompatibleEmbedder implements IEmbedder { "api-key": this.apiKey, Authorization: `Bearer ${this.apiKey}`, }, - body: JSON.stringify({ - input: batchTexts, - model: model, - encoding_format: "base64", - }), + body: JSON.stringify( + this.isGeminiEndpoint + ? { + input: batchTexts, + model: model, + // Gemini doesn't support encoding_format parameter + } + : { + input: batchTexts, + model: model, + encoding_format: "base64", + }, + ), }) if (!response || !response.ok) { @@ -263,14 +282,23 @@ export class OpenAICompatibleEmbedder implements IEmbedder { response = await this.makeDirectEmbeddingRequest(this.baseUrl, batchTexts, model) } else { // Use OpenAI SDK for base URLs - response = (await this.embeddingsClient.embeddings.create({ + const embeddingParams: any = { input: batchTexts, model: model, - // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256 - // when processing numeric arrays, which breaks compatibility with models using larger dimensions. - // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. - encoding_format: "base64", - })) as OpenAIEmbeddingResponse + } + + // Only add encoding_format for non-Gemini endpoints + // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256 + // when processing numeric arrays, which breaks compatibility with models using larger dimensions. + // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. + // However, Gemini doesn't support this parameter, so we exclude it for Gemini endpoints. + if (!this.isGeminiEndpoint) { + embeddingParams.encoding_format = "base64" + } + + response = (await this.embeddingsClient.embeddings.create( + embeddingParams, + )) as OpenAIEmbeddingResponse } // Convert base64 embeddings to float32 arrays @@ -365,11 +393,19 @@ export class OpenAICompatibleEmbedder implements IEmbedder { response = await this.makeDirectEmbeddingRequest(this.baseUrl, testTexts, modelToUse) } else { // Test using OpenAI SDK for base URLs - response = (await this.embeddingsClient.embeddings.create({ + const embeddingParams: any = { input: testTexts, model: modelToUse, - encoding_format: "base64", - })) as OpenAIEmbeddingResponse + } + + // Only add encoding_format for non-Gemini endpoints + if (!this.isGeminiEndpoint) { + embeddingParams.encoding_format = "base64" + } + + response = (await this.embeddingsClient.embeddings.create( + embeddingParams, + )) as OpenAIEmbeddingResponse } // Check if we got a valid response