Roo-Code/src/services/code-index/embedders/openai.ts
Roo Code 21e967c35f fix: separate document and query prefixes for embedding models
Adds EmbeddingPurpose type and documentPrefix field to support
different prefixes for indexing vs querying. Models like nomic-embed-code
require a query prefix for search but no prefix for document indexing.

- Add documentPrefix to EmbeddingModelProfile type
- Add getModelDocumentPrefix() and getModelPrefixForPurpose() helpers
- Add purpose parameter to IEmbedder.createEmbeddings()
- Update all embedder implementations to use purpose-aware prefixing
- Scanner and file-watcher pass "index" purpose
- Search service passes "query" purpose
- Update nomic-embed-code profiles (no documentPrefix for indexing)
- Update tests for new behavior

Closes #11707
2026-03-24 14:31:45 +00:00

229 lines
7.3 KiB
TypeScript

import type { EmbeddingPurpose } from "@roo-code/types"
import { OpenAI } from "openai"
import { OpenAiNativeHandler } from "../../../api/providers/openai-native"
import { ApiHandlerOptions } from "../../../shared/api"
import { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces"
import {
MAX_BATCH_TOKENS,
MAX_ITEM_TOKENS,
MAX_BATCH_RETRIES as MAX_RETRIES,
INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS,
} from "../constants"
import { getModelPrefixForPurpose } from "../../../shared/embeddingModels"
import { t } from "../../../i18n"
import { withValidationErrorHandling, formatEmbeddingError, HttpError } from "../shared/validation-helpers"
import { TelemetryEventName } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { handleOpenAIError } from "../../../api/providers/utils/openai-error-handler"
/**
* OpenAI implementation of the embedder interface with batching and rate limiting
*/
export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder {
private embeddingsClient: OpenAI
private readonly defaultModelId: string
/**
* Creates a new OpenAI embedder
* @param options API handler options
*/
constructor(options: ApiHandlerOptions & { openAiEmbeddingModelId?: string }) {
super(options)
const apiKey = this.options.openAiNativeApiKey ?? "not-provided"
// Wrap OpenAI client creation to handle invalid API key characters
try {
this.embeddingsClient = new OpenAI({ apiKey })
} catch (error) {
// Use the error handler to transform ByteString conversion errors
throw handleOpenAIError(error, "OpenAI")
}
this.defaultModelId = options.openAiEmbeddingModelId || "text-embedding-3-small"
}
/**
* Creates embeddings for the given texts with batching and rate limiting
* @param texts Array of text strings to embed
* @param model Optional model identifier
* @returns Promise resolving to embedding response
*/
async createEmbeddings(texts: string[], model?: string, purpose?: EmbeddingPurpose): Promise<EmbeddingResponse> {
const modelToUse = model || this.defaultModelId
// Apply model-specific prefix based on purpose (query vs index)
const queryPrefix = getModelPrefixForPurpose("openai", modelToUse, purpose)
const processedTexts = queryPrefix
? texts.map((text, index) => {
// Prevent double-prefixing
if (text.startsWith(queryPrefix)) {
return text
}
const prefixedText = `${queryPrefix}${text}`
const estimatedTokens = Math.ceil(prefixedText.length / 4)
if (estimatedTokens > MAX_ITEM_TOKENS) {
console.warn(
t("embeddings:textWithPrefixExceedsTokenLimit", {
index,
estimatedTokens,
maxTokens: MAX_ITEM_TOKENS,
}),
)
// Return original text if adding prefix would exceed limit
return text
}
return prefixedText
})
: texts
const allEmbeddings: number[][] = []
const usage = { promptTokens: 0, totalTokens: 0 }
const remainingTexts = [...processedTexts]
while (remainingTexts.length > 0) {
const currentBatch: string[] = []
let currentBatchTokens = 0
const processedIndices: number[] = []
for (let i = 0; i < remainingTexts.length; i++) {
const text = remainingTexts[i]
const itemTokens = Math.ceil(text.length / 4)
if (itemTokens > MAX_ITEM_TOKENS) {
console.warn(
t("embeddings:textExceedsTokenLimit", {
index: i,
itemTokens,
maxTokens: MAX_ITEM_TOKENS,
}),
)
processedIndices.push(i)
continue
}
if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) {
currentBatch.push(text)
currentBatchTokens += itemTokens
processedIndices.push(i)
} else {
break
}
}
// Remove processed items from remainingTexts (in reverse order to maintain correct indices)
for (let i = processedIndices.length - 1; i >= 0; i--) {
remainingTexts.splice(processedIndices[i], 1)
}
if (currentBatch.length > 0) {
const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse)
allEmbeddings.push(...batchResult.embeddings)
usage.promptTokens += batchResult.usage.promptTokens
usage.totalTokens += batchResult.usage.totalTokens
}
}
return { embeddings: allEmbeddings, usage }
}
/**
* Helper method to handle batch embedding with retries and exponential backoff
* @param batchTexts Array of texts to embed in this batch
* @param model Model identifier to use
* @returns Promise resolving to embeddings and usage statistics
*/
private async _embedBatchWithRetries(
batchTexts: string[],
model: string,
): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> {
for (let attempts = 0; attempts < MAX_RETRIES; attempts++) {
try {
const response = await this.embeddingsClient.embeddings.create({
input: batchTexts,
model: model,
})
return {
embeddings: response.data.map((item) => item.embedding),
usage: {
promptTokens: response.usage?.prompt_tokens || 0,
totalTokens: response.usage?.total_tokens || 0,
},
}
} catch (error: any) {
const hasMoreAttempts = attempts < MAX_RETRIES - 1
// Check if it's a rate limit error
const httpError = error as HttpError
if (httpError?.status === 429 && hasMoreAttempts) {
const delayMs = INITIAL_DELAY_MS * Math.pow(2, attempts)
console.warn(
t("embeddings:rateLimitRetry", {
delayMs,
attempt: attempts + 1,
maxRetries: MAX_RETRIES,
}),
)
await new Promise((resolve) => setTimeout(resolve, delayMs))
continue
}
// Capture telemetry before reformatting the error
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
location: "OpenAiEmbedder:_embedBatchWithRetries",
attempt: attempts + 1,
})
// Log the error for debugging
console.error(`OpenAI embedder error (attempt ${attempts + 1}/${MAX_RETRIES}):`, error)
// Format and throw the error
throw formatEmbeddingError(error, MAX_RETRIES)
}
}
throw new Error(t("embeddings:failedMaxAttempts", { attempts: MAX_RETRIES }))
}
/**
* Validates the OpenAI embedder configuration by attempting a minimal embedding request
* @returns Promise resolving to validation result with success status and optional error message
*/
async validateConfiguration(): Promise<{ valid: boolean; error?: string }> {
return withValidationErrorHandling(async () => {
try {
// Test with a minimal embedding request
const response = await this.embeddingsClient.embeddings.create({
input: ["test"],
model: this.defaultModelId,
})
// Check if we got a valid response
if (!response.data || response.data.length === 0) {
return {
valid: false,
error: t("embeddings:openai.invalidResponseFormat"),
}
}
return { valid: true }
} catch (error) {
// Capture telemetry for validation errors
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
location: "OpenAiEmbedder:validateConfiguration",
})
throw error
}
}, "openai")
}
get embedderInfo(): EmbedderInfo {
return {
name: "openai",
}
}
}