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
This commit is contained in:
Roo Code 2025-08-18 13:20:58 +00:00
parent a8aea14078
commit e8df38a49e
6 changed files with 359 additions and 17 deletions

View file

@ -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
)
})

View file

@ -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

View file

@ -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<string, any>) => {
const translations: Record<string, string> = {
"embeddings:validation.baseUrlRequired": "Base URL is required",
"embeddings:validation.apiKeyRequired": "API key is required",
}
return translations[key] || key
},
}))
const MockedOpenAI = OpenAI as MockedClass<typeof OpenAI>
describe("OpenAICompatibleEmbedder - Encoding Format", () => {
let mockOpenAIInstance: any
let mockEmbeddingsCreate: MockedFunction<any>
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<typeof fetch>
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<typeof fetch>
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",
})
})
})
})

View file

@ -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

View file

@ -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

View file

@ -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) {