feat: rewrite VertexEmbedder to use @google/genai library with multiple auth options

- Replace OpenAI-compatible approach with native @google/genai SDK
- Add support for multiple authentication methods:
  - API key (uses regular Gemini API endpoint)
  - JSON credentials (service account)
  - Key file path
  - Application default credentials
- Add projectId and location fields for Vertex AI configuration
- Update UI to show all authentication options for Vertex
- Update tests to reflect new implementation
- Update all related type definitions and interfaces
This commit is contained in:
Roo Code 2025-07-28 18:56:55 +00:00
parent a1c10fe380
commit 8bf8326a10
11 changed files with 629 additions and 139 deletions

View file

@ -36,6 +36,9 @@ export const codebaseIndexConfigSchema = z.object({
// OpenAI Compatible specific fields
codebaseIndexOpenAiCompatibleBaseUrl: z.string().optional(),
codebaseIndexOpenAiCompatibleModelDimension: z.number().optional(),
// Vertex AI specific fields
codebaseIndexVertexProjectId: z.string().optional(),
codebaseIndexVertexLocation: z.string().optional(),
})
export type CodebaseIndexConfig = z.infer<typeof codebaseIndexConfigSchema>
@ -68,6 +71,8 @@ export const codebaseIndexProviderSchema = z.object({
codebaseIndexGeminiApiKey: z.string().optional(),
codebaseIndexMistralApiKey: z.string().optional(),
codebaseIndexVertexApiKey: z.string().optional(),
codebaseIndexVertexJsonCredentials: z.string().optional(),
codebaseIndexVertexKeyFile: z.string().optional(),
})
export type CodebaseIndexProvider = z.infer<typeof codebaseIndexProviderSchema>

View file

@ -185,6 +185,8 @@ export const SECRET_STATE_KEYS = [
"codebaseIndexGeminiApiKey",
"codebaseIndexMistralApiKey",
"codebaseIndexVertexApiKey",
"codebaseIndexVertexJsonCredentials",
"codebaseIndexVertexKeyFile",
"huggingFaceApiKey",
] as const satisfies readonly (keyof ProviderSettings)[]
export type SecretState = Pick<ProviderSettings, (typeof SECRET_STATE_KEYS)[number]>

View file

@ -1554,6 +1554,8 @@ export class ClineProvider
codebaseIndexOpenAiCompatibleBaseUrl: codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl,
codebaseIndexSearchMaxResults: codebaseIndexConfig?.codebaseIndexSearchMaxResults,
codebaseIndexSearchMinScore: codebaseIndexConfig?.codebaseIndexSearchMinScore,
codebaseIndexVertexProjectId: codebaseIndexConfig?.codebaseIndexVertexProjectId,
codebaseIndexVertexLocation: codebaseIndexConfig?.codebaseIndexVertexLocation,
},
mdmCompliant: this.checkMdmCompliance(),
profileThresholds: profileThresholds ?? {},
@ -1726,6 +1728,8 @@ export class ClineProvider
stateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl,
codebaseIndexSearchMaxResults: stateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults,
codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore,
codebaseIndexVertexProjectId: stateValues.codebaseIndexConfig?.codebaseIndexVertexProjectId,
codebaseIndexVertexLocation: stateValues.codebaseIndexConfig?.codebaseIndexVertexLocation,
},
profileThresholds: stateValues.profileThresholds ?? {},
// Add diagnostic message settings

View file

@ -1998,6 +1998,8 @@ export const webviewMessageHandler = async (
codebaseIndexOpenAiCompatibleBaseUrl: settings.codebaseIndexOpenAiCompatibleBaseUrl,
codebaseIndexSearchMaxResults: settings.codebaseIndexSearchMaxResults,
codebaseIndexSearchMinScore: settings.codebaseIndexSearchMinScore,
codebaseIndexVertexProjectId: settings.codebaseIndexVertexProjectId,
codebaseIndexVertexLocation: settings.codebaseIndexVertexLocation,
}
// Save global state first
@ -2028,6 +2030,24 @@ export const webviewMessageHandler = async (
settings.codebaseIndexMistralApiKey,
)
}
if (settings.codebaseIndexVertexApiKey !== undefined) {
await provider.contextProxy.storeSecret(
"codebaseIndexVertexApiKey",
settings.codebaseIndexVertexApiKey,
)
}
if (settings.codebaseIndexVertexJsonCredentials !== undefined) {
await provider.contextProxy.storeSecret(
"codebaseIndexVertexJsonCredentials",
settings.codebaseIndexVertexJsonCredentials,
)
}
if (settings.codebaseIndexVertexKeyFile !== undefined) {
await provider.contextProxy.storeSecret(
"codebaseIndexVertexKeyFile",
settings.codebaseIndexVertexKeyFile,
)
}
// Send success response first - settings are saved regardless of validation
await provider.postMessageToWebview({
@ -2149,6 +2169,11 @@ export const webviewMessageHandler = async (
))
const hasGeminiApiKey = !!(await provider.context.secrets.get("codebaseIndexGeminiApiKey"))
const hasMistralApiKey = !!(await provider.context.secrets.get("codebaseIndexMistralApiKey"))
const hasVertexApiKey = !!(await provider.context.secrets.get("codebaseIndexVertexApiKey"))
const hasVertexJsonCredentials = !!(await provider.context.secrets.get(
"codebaseIndexVertexJsonCredentials",
))
const hasVertexKeyFile = !!(await provider.context.secrets.get("codebaseIndexVertexKeyFile"))
provider.postMessageToWebview({
type: "codeIndexSecretStatus",
@ -2158,6 +2183,9 @@ export const webviewMessageHandler = async (
hasOpenAiCompatibleApiKey,
hasGeminiApiKey,
hasMistralApiKey,
hasVertexApiKey,
hasVertexJsonCredentials,
hasVertexKeyFile,
},
})
break

View file

@ -1,10 +1,9 @@
import { vitest, describe, it, expect, beforeEach } from "vitest"
import type { MockedClass } from "vitest"
import { VertexEmbedder } from "../vertex"
import { OpenAICompatibleEmbedder } from "../openai-compatible"
import { GoogleGenAI } from "@google/genai"
// Mock the OpenAICompatibleEmbedder
vitest.mock("../openai-compatible")
// Mock the @google/genai library
vitest.mock("@google/genai")
// Mock TelemetryService
vitest.mock("@roo-code/telemetry", () => ({
@ -15,61 +14,161 @@ vitest.mock("@roo-code/telemetry", () => ({
},
}))
const MockedOpenAICompatibleEmbedder = OpenAICompatibleEmbedder as MockedClass<typeof OpenAICompatibleEmbedder>
// Mock i18n
vitest.mock("../../../../i18n", () => ({
t: (key: string, params?: Record<string, any>) => {
const translations: Record<string, string> = {
"validation.apiKeyRequired": "API key is required",
"embeddings:validation.authenticationFailed": "Authentication failed",
"embeddings:validation.connectionFailed": "Connection failed",
"embeddings:validation.modelNotAvailable": "Model not available",
"embeddings:validation.unexpectedError": "Unexpected error",
"embeddings:validation.vertexAuthRequired": "At least one authentication method is required for Vertex AI",
"embeddings:validation.noEmbeddingsReturned": "No embeddings returned",
"embeddings:validation.configurationError": "Configuration error",
}
return translations[key] || key
},
}))
// Mock safeJsonParse
vitest.mock("../../../shared/safeJsonParse", () => ({
safeJsonParse: (json: string, defaultValue: any) => {
try {
return JSON.parse(json)
} catch {
return defaultValue
}
},
}))
describe("VertexEmbedder", () => {
let embedder: VertexEmbedder
let mockClient: any
let mockModel: any
let mockEmbedContent: any
beforeEach(() => {
vitest.clearAllMocks()
// Setup mock for embedContent
mockEmbedContent = vitest.fn()
mockModel = {
embedContent: mockEmbedContent,
}
mockClient = {
models: {
embedContent: mockEmbedContent,
},
}
// Mock GoogleGenAI constructor
;(GoogleGenAI as any).mockImplementation(() => mockClient)
})
describe("constructor", () => {
it("should create an instance with default model when no model specified", () => {
// Arrange
const apiKey = "test-vertex-api-key"
it("should create an instance with API key authentication", () => {
// Act
embedder = new VertexEmbedder(apiKey)
embedder = new VertexEmbedder({
apiKey: "test-api-key",
projectId: "test-project",
location: "us-central1",
})
// Assert
expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith(
"https://generativelanguage.googleapis.com/v1beta/openai/",
apiKey,
"text-embedding-004",
2048,
)
expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: "test-api-key" })
expect(embedder.embedderInfo.name).toBe("vertex")
})
it("should create an instance with specified model", () => {
// Arrange
const apiKey = "test-vertex-api-key"
const modelId = "text-multilingual-embedding-002"
it("should create an instance with JSON credentials authentication", () => {
// Act
embedder = new VertexEmbedder(apiKey, modelId)
embedder = new VertexEmbedder({
jsonCredentials: '{"type": "service_account"}',
projectId: "test-project",
location: "us-central1",
})
// Assert
expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith(
"https://generativelanguage.googleapis.com/v1beta/openai/",
apiKey,
"text-multilingual-embedding-002",
2048,
)
expect(GoogleGenAI).toHaveBeenCalledWith({
vertexai: true,
project: "test-project",
location: "us-central1",
googleAuthOptions: {
credentials: { type: "service_account" },
},
})
expect(embedder.embedderInfo.name).toBe("vertex")
})
it("should throw error when API key is not provided", () => {
// Act & Assert
expect(() => new VertexEmbedder("")).toThrow("validation.apiKeyRequired")
expect(() => new VertexEmbedder(null as any)).toThrow("validation.apiKeyRequired")
expect(() => new VertexEmbedder(undefined as any)).toThrow("validation.apiKeyRequired")
it("should create an instance with key file authentication", () => {
// Act
embedder = new VertexEmbedder({
keyFile: "/path/to/keyfile.json",
projectId: "test-project",
location: "us-central1",
})
// Assert
expect(GoogleGenAI).toHaveBeenCalledWith({
vertexai: true,
project: "test-project",
location: "us-central1",
googleAuthOptions: { keyFile: "/path/to/keyfile.json" },
})
expect(embedder.embedderInfo.name).toBe("vertex")
})
it("should create an instance with application default credentials", () => {
// Act
embedder = new VertexEmbedder({
apiKey: "", // Empty string to trigger ADC path
projectId: "test-project",
location: "us-central1",
})
// Assert
expect(GoogleGenAI).toHaveBeenCalledWith({
vertexai: true,
project: "test-project",
location: "us-central1",
})
expect(embedder.embedderInfo.name).toBe("vertex")
})
it("should use default model when not specified", () => {
// Act
embedder = new VertexEmbedder({
apiKey: "test-api-key",
projectId: "test-project",
location: "us-central1",
})
// Assert
expect(embedder["modelId"]).toBe("text-embedding-004")
})
it("should use specified model", () => {
// Act
embedder = new VertexEmbedder({
apiKey: "test-api-key",
modelId: "text-multilingual-embedding-002",
projectId: "test-project",
location: "us-central1",
})
// Assert
expect(embedder["modelId"]).toBe("text-multilingual-embedding-002")
})
})
describe("embedderInfo", () => {
it("should return correct embedder info", () => {
// Arrange
embedder = new VertexEmbedder("test-api-key")
embedder = new VertexEmbedder({
apiKey: "test-api-key",
projectId: "test-project",
location: "us-central1",
})
// Act
const info = embedder.embedderInfo
@ -79,115 +178,185 @@ describe("VertexEmbedder", () => {
name: "vertex",
})
})
})
describe("createEmbeddings", () => {
let mockCreateEmbeddings: any
beforeEach(() => {
mockCreateEmbeddings = vitest.fn()
MockedOpenAICompatibleEmbedder.prototype.createEmbeddings = mockCreateEmbeddings
describe("createEmbeddings", () => {
beforeEach(() => {
embedder = new VertexEmbedder({
apiKey: "test-api-key",
projectId: "test-project",
location: "us-central1",
})
})
it("should use instance model when no model parameter provided", async () => {
// Arrange
embedder = new VertexEmbedder("test-api-key")
const texts = ["test text 1", "test text 2"]
const mockResponse = {
embeddings: [
[0.1, 0.2],
[0.3, 0.4],
],
}
mockCreateEmbeddings.mockResolvedValue(mockResponse)
it("should create embeddings for single text", async () => {
// Arrange
const texts = ["test text"]
const mockResponse = {
embeddings: [{ values: [0.1, 0.2, 0.3] }],
}
mockEmbedContent.mockResolvedValue(mockResponse)
// Act
const result = await embedder.createEmbeddings(texts)
// Act
const result = await embedder.createEmbeddings(texts)
// Assert
expect(mockCreateEmbeddings).toHaveBeenCalledWith(texts, "text-embedding-004")
expect(result).toEqual(mockResponse)
// Assert
expect(mockEmbedContent).toHaveBeenCalledWith({
model: "text-embedding-004",
contents: [{ parts: [{ text: "test text" }] }],
})
it("should use provided model parameter when specified", async () => {
// Arrange
embedder = new VertexEmbedder("test-api-key", "textembedding-gecko@003")
const texts = ["test text 1", "test text 2"]
const mockResponse = {
embeddings: [
[0.1, 0.2],
[0.3, 0.4],
],
}
mockCreateEmbeddings.mockResolvedValue(mockResponse)
// Act
const result = await embedder.createEmbeddings(texts, "text-multilingual-embedding-002")
// Assert
expect(mockCreateEmbeddings).toHaveBeenCalledWith(texts, "text-multilingual-embedding-002")
expect(result).toEqual(mockResponse)
expect(result).toEqual({
embeddings: [[0.1, 0.2, 0.3]],
})
})
it("should handle errors from OpenAICompatibleEmbedder", async () => {
// Arrange
embedder = new VertexEmbedder("test-api-key")
const texts = ["test text"]
const error = new Error("Embedding failed")
mockCreateEmbeddings.mockRejectedValue(error)
it("should create embeddings for multiple texts in batches", async () => {
// Arrange
const texts = ["text1", "text2", "text3"]
const mockResponse = {
embeddings: [{ values: [0.1, 0.2] }, { values: [0.3, 0.4] }, { values: [0.5, 0.6] }],
}
mockEmbedContent.mockResolvedValue(mockResponse)
// Act & Assert
await expect(embedder.createEmbeddings(texts)).rejects.toThrow("Embedding failed")
// Act
const result = await embedder.createEmbeddings(texts)
// Assert
expect(mockEmbedContent).toHaveBeenCalledTimes(1)
expect(mockEmbedContent).toHaveBeenCalledWith({
model: "text-embedding-004",
contents: [
{ parts: [{ text: "text1" }] },
{ parts: [{ text: "text2" }] },
{ parts: [{ text: "text3" }] },
],
})
expect(result).toEqual({
embeddings: [
[0.1, 0.2],
[0.3, 0.4],
[0.5, 0.6],
],
})
})
it("should use custom model when provided", async () => {
// Arrange
const texts = ["test text"]
const mockResponse = {
embeddings: [{ values: [0.1, 0.2] }],
}
mockEmbedContent.mockResolvedValue(mockResponse)
// Act
await embedder.createEmbeddings(texts, "text-multilingual-embedding-002")
// Assert
expect(mockEmbedContent).toHaveBeenCalledWith({
model: "text-multilingual-embedding-002",
contents: [{ parts: [{ text: "test text" }] }],
})
})
it("should handle empty text array", async () => {
// Act
const result = await embedder.createEmbeddings([])
// Assert
expect(mockEmbedContent).not.toHaveBeenCalled()
expect(result).toEqual({ embeddings: [] })
})
it("should handle API errors", async () => {
// Arrange
const texts = ["test text"]
const error = new Error("API Error")
mockEmbedContent.mockRejectedValue(error)
// Act & Assert
await expect(embedder.createEmbeddings(texts)).rejects.toThrow("API Error")
})
})
describe("validateConfiguration", () => {
let mockValidateConfiguration: any
beforeEach(() => {
mockValidateConfiguration = vitest.fn()
MockedOpenAICompatibleEmbedder.prototype.validateConfiguration = mockValidateConfiguration
embedder = new VertexEmbedder({
apiKey: "test-api-key",
projectId: "test-project",
location: "us-central1",
})
})
it("should delegate validation to OpenAICompatibleEmbedder", async () => {
it("should validate configuration successfully", async () => {
// Arrange
embedder = new VertexEmbedder("test-api-key")
mockValidateConfiguration.mockResolvedValue({ valid: true })
const mockResponse = {
embeddings: [{ values: [0.1, 0.2] }],
}
mockEmbedContent.mockResolvedValue(mockResponse)
// Act
const result = await embedder.validateConfiguration()
// Assert
expect(mockValidateConfiguration).toHaveBeenCalled()
expect(mockEmbedContent).toHaveBeenCalledWith({
model: "text-embedding-004",
contents: [{ parts: [{ text: "test" }] }],
})
expect(result).toEqual({ valid: true })
})
it("should pass through validation errors from OpenAICompatibleEmbedder", async () => {
it("should handle unexpected errors", async () => {
// Arrange
embedder = new VertexEmbedder("test-api-key")
mockValidateConfiguration.mockResolvedValue({
valid: false,
error: "embeddings:validation.authenticationFailed",
})
const error = new Error("Something went wrong")
mockEmbedContent.mockRejectedValue(error)
// Act
const result = await embedder.validateConfiguration()
// Assert
expect(mockValidateConfiguration).toHaveBeenCalled()
expect(result).toEqual({
valid: false,
error: "embeddings:validation.authenticationFailed",
error: "Something went wrong",
})
})
})
describe("createBatches", () => {
beforeEach(() => {
embedder = new VertexEmbedder({
apiKey: "test-api-key",
projectId: "test-project",
location: "us-central1",
})
})
it("should handle validation exceptions", async () => {
it("should create batches respecting token limits", () => {
// Arrange
embedder = new VertexEmbedder("test-api-key")
mockValidateConfiguration.mockRejectedValue(new Error("Validation failed"))
const texts = [
"short text",
"another short text",
"a".repeat(5000), // Long text
"more text",
]
// Act & Assert
await expect(embedder.validateConfiguration()).rejects.toThrow("Validation failed")
// Act
const batches = embedder["createBatches"](texts)
// Assert
expect(batches.length).toBe(1)
expect(batches[0].length).toBe(4) // All texts in one batch (under 100 limit)
})
it("should handle all oversized texts", () => {
// Arrange
const texts = ["a".repeat(10000), "b".repeat(10000)]
// Act
const batches = embedder["createBatches"](texts)
// Assert
expect(batches.length).toBe(1)
expect(batches[0].length).toBe(2) // Both texts in one batch
})
})
})

View file

@ -1,13 +1,15 @@
import { OpenAICompatibleEmbedder } from "./openai-compatible"
import { GoogleGenAI } from "@google/genai"
import type { JWTInput } from "google-auth-library"
import { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder"
import { VERTEX_MAX_ITEM_TOKENS } from "../constants"
import { t } from "../../../i18n"
import { TelemetryEventName } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { safeJsonParse } from "../../../shared/safeJsonParse"
/**
* Vertex AI embedder implementation that wraps the OpenAI Compatible embedder
* with configuration for Google's Vertex AI embedding API.
* Vertex AI embedder implementation using the @google/genai library
* with support for multiple authentication methods.
*
* Supported models:
* - text-embedding-004 (dimension: 768)
@ -16,31 +18,65 @@ import { TelemetryService } from "@roo-code/telemetry"
* - textembedding-gecko-multilingual@001 (dimension: 768)
*/
export class VertexEmbedder implements IEmbedder {
private readonly openAICompatibleEmbedder: OpenAICompatibleEmbedder
private static readonly VERTEX_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
private readonly client: GoogleGenAI
private static readonly DEFAULT_MODEL = "text-embedding-004"
private readonly modelId: string
private readonly maxItemTokens: number
/**
* Creates a new Vertex AI embedder
* @param apiKey The Google AI API key for authentication
* @param modelId The model ID to use (defaults to text-embedding-004)
* @param options Configuration options including authentication methods
*/
constructor(apiKey: string, modelId?: string) {
if (!apiKey) {
throw new Error(t("embeddings:validation.apiKeyRequired"))
constructor(options: {
apiKey?: string
jsonCredentials?: string
keyFile?: string
projectId: string
location: string
modelId?: string
}) {
const { apiKey, jsonCredentials, keyFile, projectId, location, modelId } = options
// Validate required fields
if (!projectId) {
throw new Error("Project ID is required for Vertex AI")
}
if (!location) {
throw new Error("Location is required for Vertex AI")
}
// Use provided model or default
this.modelId = modelId || VertexEmbedder.DEFAULT_MODEL
this.maxItemTokens = VERTEX_MAX_ITEM_TOKENS
// Create an OpenAI Compatible embedder with Vertex AI's configuration
this.openAICompatibleEmbedder = new OpenAICompatibleEmbedder(
VertexEmbedder.VERTEX_BASE_URL,
apiKey,
this.modelId,
VERTEX_MAX_ITEM_TOKENS,
)
// Create the GoogleGenAI client with appropriate auth
if (jsonCredentials) {
this.client = new GoogleGenAI({
vertexai: true,
project: projectId,
location,
googleAuthOptions: {
credentials: safeJsonParse<JWTInput>(jsonCredentials, undefined),
},
})
} else if (keyFile) {
this.client = new GoogleGenAI({
vertexai: true,
project: projectId,
location,
googleAuthOptions: { keyFile },
})
} else if (apiKey && apiKey.trim() !== "") {
// For API key auth, we use the regular Gemini API endpoint
this.client = new GoogleGenAI({ apiKey })
} else {
// Default to application default credentials
this.client = new GoogleGenAI({
vertexai: true,
project: projectId,
location,
})
}
}
/**
@ -51,9 +87,33 @@ export class VertexEmbedder implements IEmbedder {
*/
async createEmbeddings(texts: string[], model?: string): Promise<EmbeddingResponse> {
try {
// Use the provided model or fall back to the instance's model
const modelToUse = model || this.modelId
return await this.openAICompatibleEmbedder.createEmbeddings(texts, modelToUse)
// Batch texts if they exceed token limits
const batches = this.createBatches(texts)
const allEmbeddings: number[][] = []
for (const batch of batches) {
const result = await this.client.models.embedContent({
model: modelToUse,
contents: batch.map((text) => ({ parts: [{ text }] })),
})
if (!result.embeddings || result.embeddings.length === 0) {
throw new Error(t("embeddings:validation.noEmbeddingsReturned"))
}
// Filter out any embeddings without values
const validEmbeddings = result.embeddings
.filter((e) => e.values !== undefined)
.map((e) => e.values as number[])
allEmbeddings.push(...validEmbeddings)
}
return {
embeddings: allEmbeddings,
}
} catch (error) {
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: error instanceof Error ? error.message : String(error),
@ -65,21 +125,52 @@ export class VertexEmbedder implements IEmbedder {
}
/**
* Validates the Vertex AI embedder configuration by delegating to the underlying OpenAI-compatible embedder
* Creates batches of texts that respect token limits
*/
private createBatches(texts: string[]): string[][] {
// Simple batching - in production, you'd want to estimate tokens
const batchSize = 100 // Vertex AI typically supports up to 100 texts per batch
const batches: string[][] = []
for (let i = 0; i < texts.length; i += batchSize) {
batches.push(texts.slice(i, i + batchSize))
}
return batches
}
/**
* Validates the Vertex AI embedder configuration
* @returns Promise resolving to validation result with success status and optional error message
*/
async validateConfiguration(): Promise<{ valid: boolean; error?: string }> {
try {
// Delegate validation to the OpenAI-compatible embedder
// The error messages will be specific to Vertex AI since we're using Vertex AI's base URL
return await this.openAICompatibleEmbedder.validateConfiguration()
// Test with a simple embedding request
const testText = "test"
const result = await this.client.models.embedContent({
model: this.modelId,
contents: [{ parts: [{ text: testText }] }],
})
if (!result.embeddings || result.embeddings.length === 0) {
return {
valid: false,
error: t("embeddings:validation.noEmbeddingsReturned"),
}
}
return { valid: true }
} catch (error) {
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
location: "VertexEmbedder:validateConfiguration",
})
throw error
return {
valid: false,
error: error instanceof Error ? error.message : t("embeddings:validation.configurationError"),
}
}
}

View file

@ -14,7 +14,13 @@ export interface CodeIndexConfig {
openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
geminiOptions?: { apiKey: string }
mistralOptions?: { apiKey: string }
vertexOptions?: { apiKey: string }
vertexOptions?: {
apiKey?: string
jsonCredentials?: string
keyFile?: string
projectId?: string
location?: string
}
qdrantUrl?: string
qdrantApiKey?: string
searchMinScore?: number
@ -37,6 +43,10 @@ export type PreviousConfigSnapshot = {
geminiApiKey?: string
mistralApiKey?: string
vertexApiKey?: string
vertexJsonCredentials?: string
vertexKeyFile?: string
vertexProjectId?: string
vertexLocation?: string
qdrantUrl?: string
qdrantApiKey?: string
}

View file

@ -72,10 +72,29 @@ export class CodeIndexServiceFactory {
}
return new MistralEmbedder(config.mistralOptions.apiKey, config.modelId)
} else if (provider === "vertex") {
if (!config.vertexOptions?.apiKey) {
const vertexOptions = config.vertexOptions
if (!vertexOptions) {
throw new Error(t("embeddings:serviceFactory.vertexConfigMissing"))
}
return new VertexEmbedder(config.vertexOptions.apiKey, config.modelId)
// Validate that at least one auth method is provided
if (!vertexOptions.apiKey && !vertexOptions.jsonCredentials && !vertexOptions.keyFile) {
throw new Error(t("embeddings:serviceFactory.vertexAuthRequired"))
}
// Validate required fields for Vertex AI
if (!vertexOptions.projectId || !vertexOptions.location) {
throw new Error(t("embeddings:serviceFactory.vertexProjectLocationRequired"))
}
return new VertexEmbedder({
apiKey: vertexOptions.apiKey,
jsonCredentials: vertexOptions.jsonCredentials,
keyFile: vertexOptions.keyFile,
projectId: vertexOptions.projectId,
location: vertexOptions.location,
modelId: config.modelId,
})
}
throw new Error(

View file

@ -249,13 +249,15 @@ export interface WebviewMessage {
// Global state settings
codebaseIndexEnabled: boolean
codebaseIndexQdrantUrl: string
codebaseIndexEmbedderProvider: "openai" | "ollama" | "openai-compatible" | "gemini" | "mistral"
codebaseIndexEmbedderProvider: "openai" | "ollama" | "openai-compatible" | "gemini" | "mistral" | "vertex"
codebaseIndexEmbedderBaseUrl?: string
codebaseIndexEmbedderModelId: string
codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers
codebaseIndexOpenAiCompatibleBaseUrl?: string
codebaseIndexSearchMaxResults?: number
codebaseIndexSearchMinScore?: number
codebaseIndexVertexProjectId?: string
codebaseIndexVertexLocation?: string
// Secret settings
codeIndexOpenAiKey?: string
@ -263,6 +265,9 @@ export interface WebviewMessage {
codebaseIndexOpenAiCompatibleApiKey?: string
codebaseIndexGeminiApiKey?: string
codebaseIndexMistralApiKey?: string
codebaseIndexVertexApiKey?: string
codebaseIndexVertexJsonCredentials?: string
codebaseIndexVertexKeyFile?: string
}
}

View file

@ -70,6 +70,10 @@ interface LocalCodeIndexSettings {
codebaseIndexGeminiApiKey?: string
codebaseIndexMistralApiKey?: string
codebaseIndexVertexApiKey?: string
codebaseIndexVertexJsonCredentials?: string
codebaseIndexVertexKeyFile?: string
codebaseIndexVertexProjectId?: string
codebaseIndexVertexLocation?: string
}
// Validation schema for codebase index settings
@ -137,12 +141,32 @@ const createValidationSchema = (provider: EmbedderProvider, t: any) => {
})
case "vertex":
return baseSchema.extend({
codebaseIndexVertexApiKey: z.string().min(1, t("settings:codeIndex.validation.vertexApiKeyRequired")),
codebaseIndexEmbedderModelId: z
.string()
.min(1, t("settings:codeIndex.validation.modelSelectionRequired")),
})
return baseSchema
.extend({
// At least one auth method is required
codebaseIndexVertexApiKey: z.string().optional(),
codebaseIndexVertexJsonCredentials: z.string().optional(),
codebaseIndexVertexKeyFile: z.string().optional(),
codebaseIndexVertexProjectId: z
.string()
.min(1, t("settings:codeIndex.validation.vertexProjectIdRequired")),
codebaseIndexVertexLocation: z
.string()
.min(1, t("settings:codeIndex.validation.vertexLocationRequired")),
codebaseIndexEmbedderModelId: z
.string()
.min(1, t("settings:codeIndex.validation.modelSelectionRequired")),
})
.refine(
(data) =>
data.codebaseIndexVertexApiKey ||
data.codebaseIndexVertexJsonCredentials ||
data.codebaseIndexVertexKeyFile,
{
message: t("settings:codeIndex.validation.vertexAuthRequired"),
path: ["codebaseIndexVertexApiKey"],
},
)
default:
return baseSchema
@ -189,6 +213,10 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
codebaseIndexGeminiApiKey: "",
codebaseIndexMistralApiKey: "",
codebaseIndexVertexApiKey: "",
codebaseIndexVertexJsonCredentials: "",
codebaseIndexVertexKeyFile: "",
codebaseIndexVertexProjectId: "",
codebaseIndexVertexLocation: "us-central1",
})
// Initial settings state - stores the settings when popover opens
@ -224,6 +252,10 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
codebaseIndexGeminiApiKey: "",
codebaseIndexMistralApiKey: "",
codebaseIndexVertexApiKey: "",
codebaseIndexVertexJsonCredentials: "",
codebaseIndexVertexKeyFile: "",
codebaseIndexVertexProjectId: codebaseIndexConfig.codebaseIndexVertexProjectId || "",
codebaseIndexVertexLocation: codebaseIndexConfig.codebaseIndexVertexLocation || "us-central1",
}
setInitialSettings(settings)
setCurrentSettings(settings)
@ -321,6 +353,17 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
if (!prev.codebaseIndexVertexApiKey || prev.codebaseIndexVertexApiKey === SECRET_PLACEHOLDER) {
updated.codebaseIndexVertexApiKey = secretStatus.hasVertexApiKey ? SECRET_PLACEHOLDER : ""
}
if (
!prev.codebaseIndexVertexJsonCredentials ||
prev.codebaseIndexVertexJsonCredentials === SECRET_PLACEHOLDER
) {
updated.codebaseIndexVertexJsonCredentials = secretStatus.hasVertexJsonCredentials
? SECRET_PLACEHOLDER
: ""
}
if (!prev.codebaseIndexVertexKeyFile || prev.codebaseIndexVertexKeyFile === SECRET_PLACEHOLDER) {
updated.codebaseIndexVertexKeyFile = secretStatus.hasVertexKeyFile ? SECRET_PLACEHOLDER : ""
}
return updated
}
@ -394,7 +437,9 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
key === "codebaseIndexOpenAiCompatibleApiKey" ||
key === "codebaseIndexGeminiApiKey" ||
key === "codebaseIndexMistralApiKey" ||
key === "codebaseIndexVertexApiKey"
key === "codebaseIndexVertexApiKey" ||
key === "codebaseIndexVertexJsonCredentials" ||
key === "codebaseIndexVertexKeyFile"
) {
dataToValidate[key] = "placeholder-valid"
}
@ -1036,6 +1081,15 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
{currentSettings.codebaseIndexEmbedderProvider === "vertex" && (
<>
<div className="space-y-2">
<label className="text-sm font-medium">
{t("settings:codeIndex.vertexAuthMethodLabel")}
</label>
<p className="text-xs text-vscode-descriptionForeground mb-2">
{t("settings:codeIndex.vertexAuthMethodDescription")}
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t("settings:codeIndex.vertexApiKeyLabel")}
@ -1058,6 +1112,96 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t("settings:codeIndex.vertexJsonCredentialsLabel")}
</label>
<VSCodeTextField
type="password"
value={currentSettings.codebaseIndexVertexJsonCredentials || ""}
onInput={(e: any) =>
updateSetting(
"codebaseIndexVertexJsonCredentials",
e.target.value,
)
}
placeholder={t(
"settings:codeIndex.vertexJsonCredentialsPlaceholder",
)}
className={cn("w-full", {
"border-red-500": formErrors.codebaseIndexVertexJsonCredentials,
})}
/>
{formErrors.codebaseIndexVertexJsonCredentials && (
<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
{formErrors.codebaseIndexVertexJsonCredentials}
</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t("settings:codeIndex.vertexKeyFileLabel")}
</label>
<VSCodeTextField
value={currentSettings.codebaseIndexVertexKeyFile || ""}
onInput={(e: any) =>
updateSetting("codebaseIndexVertexKeyFile", e.target.value)
}
placeholder={t("settings:codeIndex.vertexKeyFilePlaceholder")}
className={cn("w-full", {
"border-red-500": formErrors.codebaseIndexVertexKeyFile,
})}
/>
{formErrors.codebaseIndexVertexKeyFile && (
<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
{formErrors.codebaseIndexVertexKeyFile}
</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t("settings:codeIndex.vertexProjectIdLabel")}
</label>
<VSCodeTextField
value={currentSettings.codebaseIndexVertexProjectId || ""}
onInput={(e: any) =>
updateSetting("codebaseIndexVertexProjectId", e.target.value)
}
placeholder={t("settings:codeIndex.vertexProjectIdPlaceholder")}
className={cn("w-full", {
"border-red-500": formErrors.codebaseIndexVertexProjectId,
})}
/>
{formErrors.codebaseIndexVertexProjectId && (
<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
{formErrors.codebaseIndexVertexProjectId}
</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t("settings:codeIndex.vertexLocationLabel")}
</label>
<VSCodeTextField
value={currentSettings.codebaseIndexVertexLocation || ""}
onInput={(e: any) =>
updateSetting("codebaseIndexVertexLocation", e.target.value)
}
placeholder={t("settings:codeIndex.vertexLocationPlaceholder")}
className={cn("w-full", {
"border-red-500": formErrors.codebaseIndexVertexLocation,
})}
/>
{formErrors.codebaseIndexVertexLocation && (
<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
{formErrors.codebaseIndexVertexLocation}
</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t("settings:codeIndex.modelLabel")}

View file

@ -58,6 +58,16 @@
"vertexProvider": "Vertex AI",
"vertexApiKeyLabel": "API Key:",
"vertexApiKeyPlaceholder": "Enter your Vertex AI API key",
"vertexAuthMethodLabel": "Authentication Method",
"vertexAuthMethodDescription": "Choose one authentication method: API Key, JSON Credentials, or Key File",
"vertexJsonCredentialsLabel": "JSON Credentials:",
"vertexJsonCredentialsPlaceholder": "Paste your service account JSON credentials",
"vertexKeyFileLabel": "Key File Path:",
"vertexKeyFilePlaceholder": "Enter path to your service account key file",
"vertexProjectIdLabel": "Project ID:",
"vertexProjectIdPlaceholder": "Enter your Google Cloud project ID",
"vertexLocationLabel": "Location:",
"vertexLocationPlaceholder": "e.g., us-central1",
"openaiCompatibleProvider": "OpenAI Compatible",
"openAiKeyLabel": "OpenAI API Key",
"openAiKeyPlaceholder": "Enter your OpenAI API key",
@ -124,6 +134,9 @@
"geminiApiKeyRequired": "Gemini API key is required",
"mistralApiKeyRequired": "Mistral API key is required",
"vertexApiKeyRequired": "Vertex AI API key is required",
"vertexAuthRequired": "At least one authentication method is required",
"vertexProjectIdRequired": "Project ID is required",
"vertexLocationRequired": "Location is required",
"ollamaBaseUrlRequired": "Ollama base URL is required",
"baseUrlRequired": "Base URL is required",
"modelDimensionMinValue": "Model dimension must be greater than 0"