mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-14 04:08:28 +00:00
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
229 lines
7.3 KiB
TypeScript
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",
|
|
}
|
|
}
|
|
}
|