mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
a8aea14078
commit
e8df38a49e
6 changed files with 359 additions and 17 deletions
|
|
@ -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
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue