From e8df38a49ed4eeb52fc7b858c063d3e4f13e0892 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 18 Aug 2025 13:20:58 +0000 Subject: [PATCH] fix: add support for float encoding format in OpenAI Compatible embedder - Add encodingFormat configuration option to interfaces - Update OpenAICompatibleEmbedder to support both base64 and float formats - Default to base64 for backward compatibility - Add comprehensive tests for encoding format functionality - Update service factory to pass encoding format parameter Fixes #7180 --- .../__tests__/service-factory.spec.ts | 6 +- src/services/code-index/config-manager.ts | 8 +- .../openai-compatible-encoding.spec.ts | 308 ++++++++++++++++++ .../code-index/embedders/openai-compatible.ts | 50 ++- src/services/code-index/interfaces/config.ts | 2 +- src/services/code-index/service-factory.ts | 2 + 6 files changed, 359 insertions(+), 17 deletions(-) create mode 100644 src/services/code-index/embedders/__tests__/openai-compatible-encoding.spec.ts diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index 1d8f7ba478..a8c78de39d 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -194,6 +194,8 @@ describe("CodeIndexServiceFactory", () => { "https://api.example.com/v1", "test-api-key", testModelId, + undefined, // maxItemTokens + undefined, // encodingFormat ) }) @@ -216,7 +218,9 @@ describe("CodeIndexServiceFactory", () => { expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( "https://api.example.com/v1", "test-api-key", - undefined, + undefined, // modelId + undefined, // maxItemTokens + undefined, // encodingFormat ) }) diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts index 1723f1c2a0..5ae4e55e2f 100644 --- a/src/services/code-index/config-manager.ts +++ b/src/services/code-index/config-manager.ts @@ -16,7 +16,7 @@ export class CodeIndexConfigManager { private modelDimension?: number private openAiOptions?: ApiHandlerOptions private ollamaOptions?: ApiHandlerOptions - private openAiCompatibleOptions?: { baseUrl: string; apiKey: string } + private openAiCompatibleOptions?: { baseUrl: string; apiKey: string; encodingFormat?: "base64" | "float" } private geminiOptions?: { apiKey: string } private mistralOptions?: { apiKey: string } private qdrantUrl?: string = "http://localhost:6333" @@ -67,6 +67,9 @@ export class CodeIndexConfigManager { // Fix: Read OpenAI Compatible settings from the correct location within codebaseIndexConfig const openAiCompatibleBaseUrl = codebaseIndexConfig.codebaseIndexOpenAiCompatibleBaseUrl ?? "" const openAiCompatibleApiKey = this.contextProxy?.getSecret("codebaseIndexOpenAiCompatibleApiKey") ?? "" + // Default to base64 for backward compatibility, but allow float format + const openAiCompatibleEncodingFormat = + (codebaseIndexConfig as any).codebaseIndexOpenAiCompatibleEncodingFormat ?? "base64" const geminiApiKey = this.contextProxy?.getSecret("codebaseIndexGeminiApiKey") ?? "" const mistralApiKey = this.contextProxy?.getSecret("codebaseIndexMistralApiKey") ?? "" @@ -119,6 +122,7 @@ export class CodeIndexConfigManager { ? { baseUrl: openAiCompatibleBaseUrl, apiKey: openAiCompatibleApiKey, + encodingFormat: openAiCompatibleEncodingFormat as "base64" | "float", } : undefined @@ -138,7 +142,7 @@ export class CodeIndexConfigManager { modelDimension?: number openAiOptions?: ApiHandlerOptions ollamaOptions?: ApiHandlerOptions - openAiCompatibleOptions?: { baseUrl: string; apiKey: string } + openAiCompatibleOptions?: { baseUrl: string; apiKey: string; encodingFormat?: "base64" | "float" } geminiOptions?: { apiKey: string } mistralOptions?: { apiKey: string } qdrantUrl?: string diff --git a/src/services/code-index/embedders/__tests__/openai-compatible-encoding.spec.ts b/src/services/code-index/embedders/__tests__/openai-compatible-encoding.spec.ts new file mode 100644 index 0000000000..12441b1e8b --- /dev/null +++ b/src/services/code-index/embedders/__tests__/openai-compatible-encoding.spec.ts @@ -0,0 +1,308 @@ +import type { MockedClass, MockedFunction } from "vitest" +import { OpenAI } from "openai" +import { OpenAICompatibleEmbedder } from "../openai-compatible" + +// Mock the OpenAI SDK +vitest.mock("openai") + +// Mock global fetch +global.fetch = vitest.fn() + +// Mock TelemetryService +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureEvent: vitest.fn(), + }, + }, +})) + +// Mock i18n +vitest.mock("../../../../i18n", () => ({ + t: (key: string, params?: Record) => { + const translations: Record = { + "embeddings:validation.baseUrlRequired": "Base URL is required", + "embeddings:validation.apiKeyRequired": "API key is required", + } + return translations[key] || key + }, +})) + +const MockedOpenAI = OpenAI as MockedClass + +describe("OpenAICompatibleEmbedder - Encoding Format", () => { + let mockOpenAIInstance: any + let mockEmbeddingsCreate: MockedFunction + + const testBaseUrl = "https://api.example.com/v1" + const testApiKey = "test-api-key" + const testModelId = "text-embedding-3-small" + + beforeEach(() => { + vitest.clearAllMocks() + vitest.spyOn(console, "warn").mockImplementation(() => {}) + vitest.spyOn(console, "error").mockImplementation(() => {}) + + // Setup mock OpenAI instance + mockEmbeddingsCreate = vitest.fn() + mockOpenAIInstance = { + embeddings: { + create: mockEmbeddingsCreate, + }, + } + + MockedOpenAI.mockImplementation(() => mockOpenAIInstance) + }) + + afterEach(() => { + vitest.restoreAllMocks() + }) + + describe("constructor with encoding format", () => { + it("should create embedder with default base64 encoding format", () => { + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + expect(MockedOpenAI).toHaveBeenCalledWith({ + baseURL: testBaseUrl, + apiKey: testApiKey, + }) + expect(embedder).toBeDefined() + // Default should be base64 + expect((embedder as any).encodingFormat).toBe("base64") + }) + + it("should create embedder with explicit base64 encoding format", () => { + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId, undefined, "base64") + + expect(embedder).toBeDefined() + expect((embedder as any).encodingFormat).toBe("base64") + }) + + it("should create embedder with float encoding format", () => { + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId, undefined, "float") + + expect(embedder).toBeDefined() + expect((embedder as any).encodingFormat).toBe("float") + }) + }) + + describe("createEmbeddings with float format", () => { + it("should not set encoding_format parameter when using float format", async () => { + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId, undefined, "float") + + 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 embedder.createEmbeddings(testTexts) + + // Should NOT include encoding_format when using float + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: testModelId, + // No encoding_format property + }) + }) + + it("should handle float array responses correctly", async () => { + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId, undefined, "float") + + const testTexts = ["Hello world", "Test text"] + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }], + usage: { prompt_tokens: 20, total_tokens: 30 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + expect(result).toEqual({ + embeddings: [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ], + usage: { promptTokens: 20, totalTokens: 30 }, + }) + }) + }) + + describe("createEmbeddings with base64 format", () => { + it("should set encoding_format to base64 when using base64 format", async () => { + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId, undefined, "base64") + + const testTexts = ["Hello world"] + // Create a Float32Array with test values + const testEmbedding = new Float32Array([0.25, 0.5, 0.75]) + const buffer = Buffer.from(testEmbedding.buffer) + const base64String = buffer.toString("base64") + + const mockResponse = { + data: [{ embedding: base64String }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + // Should include encoding_format: "base64" + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: testModelId, + encoding_format: "base64", + }) + + // Should correctly decode base64 to float array + expect(result.embeddings[0]).toEqual([0.25, 0.5, 0.75]) + }) + }) + + describe("direct HTTP requests with encoding formats", () => { + it("should use float format in direct HTTP requests", async () => { + const fullUrl = "https://api.example.com/v1/embeddings" + const embedder = new OpenAICompatibleEmbedder(fullUrl, testApiKey, testModelId, undefined, "float") + + const mockFetch = global.fetch as MockedFunction + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }), + text: async () => "", + } as any) + + const testTexts = ["Hello world"] + await embedder.createEmbeddings(testTexts) + + // Check that the request body contains float encoding format + expect(mockFetch).toHaveBeenCalledWith( + fullUrl, + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + input: testTexts, + model: testModelId, + encoding_format: "float", + }), + }), + ) + }) + + it("should use base64 format in direct HTTP requests", async () => { + const fullUrl = "https://api.example.com/v1/embeddings" + const embedder = new OpenAICompatibleEmbedder(fullUrl, testApiKey, testModelId, undefined, "base64") + + const testEmbedding = new Float32Array([0.25, 0.5, 0.75]) + const buffer = Buffer.from(testEmbedding.buffer) + const base64String = buffer.toString("base64") + + const mockFetch = global.fetch as MockedFunction + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: [{ embedding: base64String }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }), + text: async () => "", + } as any) + + const testTexts = ["Hello world"] + const result = await embedder.createEmbeddings(testTexts) + + // Check that the request body contains base64 encoding format + expect(mockFetch).toHaveBeenCalledWith( + fullUrl, + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + input: testTexts, + model: testModelId, + encoding_format: "base64", + }), + }), + ) + + // Should correctly decode base64 + expect(result.embeddings[0]).toEqual([0.25, 0.5, 0.75]) + }) + }) + + describe("validateConfiguration with encoding formats", () => { + it("should validate successfully with float format", async () => { + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId, undefined, "float") + + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + // Should not include encoding_format for float + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["test"], + model: testModelId, + }) + }) + + it("should validate successfully with base64 format", async () => { + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId, undefined, "base64") + + const testEmbedding = new Float32Array([0.1, 0.2, 0.3]) + const buffer = Buffer.from(testEmbedding.buffer) + const base64String = buffer.toString("base64") + + const mockResponse = { + data: [{ embedding: base64String }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + // Should include encoding_format: "base64" + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["test"], + model: testModelId, + encoding_format: "base64", + }) + }) + }) + + describe("backward compatibility", () => { + it("should maintain backward compatibility when encoding format is not specified", async () => { + // When no encoding format is specified, it should default to base64 + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + const testTexts = ["Hello world"] + const testEmbedding = new Float32Array([0.25, 0.5, 0.75]) + const buffer = Buffer.from(testEmbedding.buffer) + const base64String = buffer.toString("base64") + + const mockResponse = { + data: [{ embedding: base64String }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + await embedder.createEmbeddings(testTexts) + + // Should default to base64 encoding + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: testModelId, + encoding_format: "base64", + }) + }) + }) +}) diff --git a/src/services/code-index/embedders/openai-compatible.ts b/src/services/code-index/embedders/openai-compatible.ts index 06c4ba5282..1b63f60b1c 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 encodingFormat: "base64" | "float" // Global rate limiting state shared across all instances private static globalRateLimitState = { @@ -55,8 +56,15 @@ export class OpenAICompatibleEmbedder implements IEmbedder { * @param apiKey The API key for authentication * @param modelId Optional model identifier (defaults to "text-embedding-3-small") * @param maxItemTokens Optional maximum tokens per item (defaults to MAX_ITEM_TOKENS) + * @param encodingFormat Optional encoding format for embeddings (defaults to "base64") */ - constructor(baseUrl: string, apiKey: string, modelId?: string, maxItemTokens?: number) { + constructor( + baseUrl: string, + apiKey: string, + modelId?: string, + maxItemTokens?: number, + encodingFormat?: "base64" | "float", + ) { if (!baseUrl) { throw new Error(t("embeddings:validation.baseUrlRequired")) } @@ -74,6 +82,8 @@ export class OpenAICompatibleEmbedder implements IEmbedder { // Cache the URL type check for performance this.isFullUrl = this.isFullEndpointUrl(baseUrl) this.maxItemTokens = maxItemTokens || MAX_ITEM_TOKENS + // Default to base64 for backward compatibility, but allow float format + this.encodingFormat = encodingFormat || "base64" } /** @@ -207,7 +217,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { body: JSON.stringify({ input: batchTexts, model: model, - encoding_format: "base64", + encoding_format: this.encodingFormat, }), }) @@ -263,19 +273,26 @@ 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 createParams: 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 set encoding_format if it's base64 (SDK default is float) + // 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. + if (this.encodingFormat === "base64") { + createParams.encoding_format = "base64" + } + + response = (await this.embeddingsClient.embeddings.create(createParams)) as OpenAIEmbeddingResponse } - // Convert base64 embeddings to float32 arrays + // Process embeddings based on encoding format const processedEmbeddings = response.data.map((item: EmbeddingItem) => { - if (typeof item.embedding === "string") { + if (this.encodingFormat === "base64" && typeof item.embedding === "string") { + // Convert base64 embeddings to float32 arrays const buffer = Buffer.from(item.embedding, "base64") // Create Float32Array view over the buffer @@ -286,6 +303,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { embedding: Array.from(float32Array), } } + // For float format, embeddings should already be arrays return item }) @@ -365,11 +383,17 @@ 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 createParams: any = { input: testTexts, model: modelToUse, - encoding_format: "base64", - })) as OpenAIEmbeddingResponse + } + + // Only set encoding_format if it's base64 + if (this.encodingFormat === "base64") { + createParams.encoding_format = "base64" + } + + response = (await this.embeddingsClient.embeddings.create(createParams)) as OpenAIEmbeddingResponse } // Check if we got a valid response diff --git a/src/services/code-index/interfaces/config.ts b/src/services/code-index/interfaces/config.ts index 9098a60091..469f17ead2 100644 --- a/src/services/code-index/interfaces/config.ts +++ b/src/services/code-index/interfaces/config.ts @@ -11,7 +11,7 @@ export interface CodeIndexConfig { modelDimension?: number // Generic dimension property for all providers openAiOptions?: ApiHandlerOptions ollamaOptions?: ApiHandlerOptions - openAiCompatibleOptions?: { baseUrl: string; apiKey: string } + openAiCompatibleOptions?: { baseUrl: string; apiKey: string; encodingFormat?: "base64" | "float" } geminiOptions?: { apiKey: string } mistralOptions?: { apiKey: string } qdrantUrl?: string diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index 5c1c366107..e655b0e916 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -60,6 +60,8 @@ export class CodeIndexServiceFactory { config.openAiCompatibleOptions.baseUrl, config.openAiCompatibleOptions.apiKey, config.modelId, + undefined, // maxItemTokens - use default + config.openAiCompatibleOptions.encodingFormat, ) } else if (provider === "gemini") { if (!config.geminiOptions?.apiKey) {