Spaces:
Paused
Paused
| import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; | |
| import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js"; | |
| import { isTruthyEnvValue } from "../infra/env.js"; | |
| import { createSubsystemLogger } from "../logging/subsystem.js"; | |
| export type GeminiEmbeddingClient = { | |
| baseUrl: string; | |
| headers: Record<string, string>; | |
| model: string; | |
| modelPath: string; | |
| }; | |
| const DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; | |
| export const DEFAULT_GEMINI_EMBEDDING_MODEL = "gemini-embedding-001"; | |
| const debugEmbeddings = isTruthyEnvValue(process.env.OPENCLAW_DEBUG_MEMORY_EMBEDDINGS); | |
| const log = createSubsystemLogger("memory/embeddings"); | |
| const debugLog = (message: string, meta?: Record<string, unknown>) => { | |
| if (!debugEmbeddings) { | |
| return; | |
| } | |
| const suffix = meta ? ` ${JSON.stringify(meta)}` : ""; | |
| log.raw(`${message}${suffix}`); | |
| }; | |
| function resolveRemoteApiKey(remoteApiKey?: string): string | undefined { | |
| const trimmed = remoteApiKey?.trim(); | |
| if (!trimmed) { | |
| return undefined; | |
| } | |
| if (trimmed === "GOOGLE_API_KEY" || trimmed === "GEMINI_API_KEY") { | |
| return process.env[trimmed]?.trim(); | |
| } | |
| return trimmed; | |
| } | |
| function normalizeGeminiModel(model: string): string { | |
| const trimmed = model.trim(); | |
| if (!trimmed) { | |
| return DEFAULT_GEMINI_EMBEDDING_MODEL; | |
| } | |
| const withoutPrefix = trimmed.replace(/^models\//, ""); | |
| if (withoutPrefix.startsWith("gemini/")) { | |
| return withoutPrefix.slice("gemini/".length); | |
| } | |
| if (withoutPrefix.startsWith("google/")) { | |
| return withoutPrefix.slice("google/".length); | |
| } | |
| return withoutPrefix; | |
| } | |
| function normalizeGeminiBaseUrl(raw: string): string { | |
| const trimmed = raw.replace(/\/+$/, ""); | |
| const openAiIndex = trimmed.indexOf("/openai"); | |
| if (openAiIndex > -1) { | |
| return trimmed.slice(0, openAiIndex); | |
| } | |
| return trimmed; | |
| } | |
| function buildGeminiModelPath(model: string): string { | |
| return model.startsWith("models/") ? model : `models/${model}`; | |
| } | |
| export async function createGeminiEmbeddingProvider( | |
| options: EmbeddingProviderOptions, | |
| ): Promise<{ provider: EmbeddingProvider; client: GeminiEmbeddingClient }> { | |
| const client = await resolveGeminiEmbeddingClient(options); | |
| const baseUrl = client.baseUrl.replace(/\/$/, ""); | |
| const embedUrl = `${baseUrl}/${client.modelPath}:embedContent`; | |
| const batchUrl = `${baseUrl}/${client.modelPath}:batchEmbedContents`; | |
| const embedQuery = async (text: string): Promise<number[]> => { | |
| if (!text.trim()) { | |
| return []; | |
| } | |
| const res = await fetch(embedUrl, { | |
| method: "POST", | |
| headers: client.headers, | |
| body: JSON.stringify({ | |
| content: { parts: [{ text }] }, | |
| taskType: "RETRIEVAL_QUERY", | |
| }), | |
| }); | |
| if (!res.ok) { | |
| const payload = await res.text(); | |
| throw new Error(`gemini embeddings failed: ${res.status} ${payload}`); | |
| } | |
| const payload = (await res.json()) as { embedding?: { values?: number[] } }; | |
| return payload.embedding?.values ?? []; | |
| }; | |
| const embedBatch = async (texts: string[]): Promise<number[][]> => { | |
| if (texts.length === 0) { | |
| return []; | |
| } | |
| const requests = texts.map((text) => ({ | |
| model: client.modelPath, | |
| content: { parts: [{ text }] }, | |
| taskType: "RETRIEVAL_DOCUMENT", | |
| })); | |
| const res = await fetch(batchUrl, { | |
| method: "POST", | |
| headers: client.headers, | |
| body: JSON.stringify({ requests }), | |
| }); | |
| if (!res.ok) { | |
| const payload = await res.text(); | |
| throw new Error(`gemini embeddings failed: ${res.status} ${payload}`); | |
| } | |
| const payload = (await res.json()) as { embeddings?: Array<{ values?: number[] }> }; | |
| const embeddings = Array.isArray(payload.embeddings) ? payload.embeddings : []; | |
| return texts.map((_, index) => embeddings[index]?.values ?? []); | |
| }; | |
| return { | |
| provider: { | |
| id: "gemini", | |
| model: client.model, | |
| embedQuery, | |
| embedBatch, | |
| }, | |
| client, | |
| }; | |
| } | |
| export async function resolveGeminiEmbeddingClient( | |
| options: EmbeddingProviderOptions, | |
| ): Promise<GeminiEmbeddingClient> { | |
| const remote = options.remote; | |
| const remoteApiKey = resolveRemoteApiKey(remote?.apiKey); | |
| const remoteBaseUrl = remote?.baseUrl?.trim(); | |
| const apiKey = remoteApiKey | |
| ? remoteApiKey | |
| : requireApiKey( | |
| await resolveApiKeyForProvider({ | |
| provider: "google", | |
| cfg: options.config, | |
| agentDir: options.agentDir, | |
| }), | |
| "google", | |
| ); | |
| const providerConfig = options.config.models?.providers?.google; | |
| const rawBaseUrl = remoteBaseUrl || providerConfig?.baseUrl?.trim() || DEFAULT_GEMINI_BASE_URL; | |
| const baseUrl = normalizeGeminiBaseUrl(rawBaseUrl); | |
| const headerOverrides = Object.assign({}, providerConfig?.headers, remote?.headers); | |
| const headers: Record<string, string> = { | |
| "Content-Type": "application/json", | |
| "x-goog-api-key": apiKey, | |
| ...headerOverrides, | |
| }; | |
| const model = normalizeGeminiModel(options.model); | |
| const modelPath = buildGeminiModelPath(model); | |
| debugLog("memory embeddings: gemini client", { | |
| rawBaseUrl, | |
| baseUrl, | |
| model, | |
| modelPath, | |
| embedEndpoint: `${baseUrl}/${modelPath}:embedContent`, | |
| batchEndpoint: `${baseUrl}/${modelPath}:batchEmbedContents`, | |
| }); | |
| return { baseUrl, headers, model, modelPath }; | |
| } | |