fix: exclude encoding_format parameter for Google Gemini embeddings API

- Added isGeminiEndpoint detection to OpenAICompatibleEmbedder
- Conditionally include encoding_format only for non-Gemini endpoints
- Gemini API does not support the OpenAI-specific encoding_format parameter
- Added comprehensive tests to verify Gemini compatibility

Fixes #7761
This commit is contained in:
Roo Code 2025-09-07 16:10:18 +00:00
parent 247da38b02
commit 22abb2c45b
2 changed files with 185 additions and 14 deletions

View file

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

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