UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

902 lines • 46.6 kB
import { lazyRequire } from '../lib/lazyRequire.js'; import { createGoogleGenAIClientResolver, } from '../adapters/llm/googleGenAI.js'; /** * Native output size of every OpenAI embedding model, so `.dimensions` reports * the truth instead of one hard-coded guess. * * Sources: OpenAI embeddings guide — text-embedding-3-small "By default, the * length of the embedding vector is 1536", text-embedding-3-large "3072" * (https://developers.openai.com/api/docs/guides/embeddings); Microsoft Learn's * Azure OpenAI model table, "Output Dimensions" column, for ada-002 = 1,536 * (https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models). */ const NATIVE_DIMENSIONS = { 'text-embedding-3-small': 1536, 'text-embedding-3-large': 3072, 'text-embedding-ada-002': 1536, }; /** * Chars-per-token used to convert a documented TOKEN window into the CHARACTER * ceiling {@link Embedder.maxInputChars} states (9.1.0). * * **4 characters per token**, the standard rule of thumb for English prose, and * the number every ceiling below is derived from — 8,191 tokens becomes 32,000 * characters (floored to a round number a warning can state plainly). * * It is an ASSUMPTION, not a measurement, and it is stated here so it can be * argued with. Code, tables, CJK text and heavy punctuation all tokenise * DENSER than prose, so a chunk of exactly 32,000 characters of such material * can still exceed 8,191 tokens and be clipped. That is what an explicit * `maxChunkChars` is for: it always wins over a declared ceiling, precisely so * a caller who knows their corpus is dense can say a smaller number. For the * shipped splitter defaults (1,000 characters a chunk) the distinction never * arises — the ceiling matters when a consumer RAISES the splitter, which is * the case this whole mechanism exists to keep honest. */ const CHARS_PER_TOKEN = 4; /** * Documented input window per OpenAI embedding model, in TOKENS. * * All three current models accept 8,191 tokens — OpenAI's embeddings guide and * the model pages state the same limit for `text-embedding-3-small`, * `text-embedding-3-large` and `text-embedding-ada-002`. A model this library * does not know gets NO declared ceiling rather than a guessed one: the same * rule `.dimensions` applies, for the same reason — a wrong ceiling clips in * silence, and an absent one simply leaves the indexer's conservative default * in place. */ const NATIVE_MAX_INPUT_TOKENS = { 'text-embedding-3-small': 8191, 'text-embedding-3-large': 8191, 'text-embedding-ada-002': 8191, }; /** Tokens → characters, floored to a round number a message can state plainly. */ function charsFor(tokens) { return tokens === undefined ? undefined : Math.floor((tokens * CHARS_PER_TOKEN) / 1000) * 1000; } /** * OpenAI's hosted embeddings endpoint. * * `.dimensions` is the length callers WILL get back, never an assumption: * an explicit `{ dimensions }` is sent to the API and reported; otherwise the * model's documented native size is reported. A model outside * {@link NATIVE_DIMENSIONS} (a gateway, a self-hosted model behind `baseURL`, * an Azure deployment name, a future OpenAI model) has no size this library can * know, so it is a construction-time error rather than a guess that a vector * store would silently trust. * * `.maxInputChars` (9.1.0) reports **32,000** for the three models above: * their documented 8,191-token window at the stated {@link CHARS_PER_TOKEN} * assumption. An indexer reads it in preference to its own 2,000-character * default, so a corpus split at 2,500 characters is embedded WHOLE here * instead of being clipped by a default measured on an on-device model. * An unknown model declares no ceiling — the indexer's default stands, which * is conservative rather than wrong. * * @throws if there is no API key, or if `model` is unknown and `dimensions` * was not supplied. */ export function openaiEmbedder(options = {}) { const apiKey = options.apiKey ?? (typeof process !== 'undefined' ? process.env?.['OPENAI_API_KEY'] : undefined); if (!apiKey || !apiKey.trim()) { throw new Error('openaiEmbedder: no API key — set OPENAI_API_KEY or pass { apiKey }.'); } const model = options.model ?? 'text-embedding-3-small'; // Only an EXPLICIT request is sent. Defaulting it and sending that would // break ada-002 (which rejects the parameter) for callers who asked for // nothing — the request body stays byte-identical unless you opt in. const requested = options.dimensions; const dimensions = requested ?? NATIVE_DIMENSIONS[model]; if (dimensions === undefined) { throw new Error(`openaiEmbedder: unknown model '${model}' — its vector length is not something this ` + `library can know, and reporting a wrong .dimensions silently corrupts a vector store. ` + `Pass { dimensions } with the length that model returns.`); } const maxInputChars = charsFor(NATIVE_MAX_INPUT_TOKENS[model]); const url = `${options.baseURL ?? 'https://api.openai.com/v1'}/embeddings`; async function call(input, signal) { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` }, body: JSON.stringify(requested === undefined ? { model, input } : { model, input, dimensions: requested }), ...(signal ? { signal } : {}), }); if (!res.ok) throw new Error(`openaiEmbedder: ${res.status} ${await res.text()}`); const json = (await res.json()); return json.data.map((d) => d.embedding); } return { dimensions, // The embedding SPACE, not the instance: two `openaiEmbedder()` calls with // the same model produce interchangeable vectors, and a store must treat // them as one index. The SIZE is deliberately not in here — a store's // fingerprint is `'<id>@<dims>'`, so it appends the dimensions itself and // a truncated vector is already a different fingerprint from a native one. id: `openai:${model}`, // The documented window, in the unit a splitter cuts in. Spread rather // than assigned so an unknown model declares NOTHING instead of // `undefined` — the field is optional and its absence is meaningful. ...(maxInputChars !== undefined && { maxInputChars }), async embed({ text, signal }) { return (await call([text], signal))[0]; }, async embedBatch({ texts, signal }) { return call([...texts], signal); }, }; } /** * The Bedrock embedding models this library knows by name — three facts each, * in one table, because they arrive together and drift apart when they are * kept apart (9.3.0; two Titan-only tables until then). * * **Size.** Titan Text Embeddings **V2** is the configurable one — 1024 * (default), 512, 256. Titan Embeddings G1 – Text (**V1**) has one size, 1536, * and no `dimensions` parameter at all. Cohere Embed v3 (English and * Multilingual) returns 1024 and likewise takes no size. A model outside this * table has a length this library cannot know and must state its own — the same * rule `openaiEmbedder` applies, for the same reason: a store that trusts * `.dimensions` and gets a different length back corrupts silently. * * **Window.** Both Titan text-embedding models accept **8,192 tokens** — * sixteen times the on-device model the shipped default ceiling was measured * on. Cohere Embed v3 accepts **512**, and that number is the whole argument * for a per-MODEL ceiling rather than a per-VENDOR one: 512 tokens is ~2,000 * characters, so the same corpus that is embedded whole by Titan is silently * truncated by Cohere at a quarter of the chunk. Declared, the indexers cut to * fit; guessed from a sibling model, they would not. * * **Family.** The body shape (see {@link BedrockEmbeddingFamily}) — the fact * whose absence made a Cohere model id constructible and unusable before 9.3.0. */ const BEDROCK_EMBEDDING_MODELS = { 'amazon.titan-embed-text-v2:0': { family: 'titan', dimensions: 1024, sizes: [1024, 512, 256], maxInputTokens: 8192, }, 'amazon.titan-embed-text-v1': { family: 'titan', dimensions: 1536, maxInputTokens: 8192 }, 'cohere.embed-english-v3': { family: 'cohere', dimensions: 1024, maxInputTokens: 512 }, 'cohere.embed-multilingual-v3': { family: 'cohere', dimensions: 1024, maxInputTokens: 512 }, }; /** * Titan V2's configurable sizes, kept as a constant because an id this table * has never seen can still be recognisably Titan V2 (a variant released after * this version) — and for such an id the size must still be SENT, or the model * returns 1024 while `.dimensions` reports 512. */ const TITAN_V2_SIZES = [1024, 512, 256]; /** * How many texts Cohere embeds in ONE `InvokeModel` call. * * **96**, the documented maximum, and the reason `embedBatch` is a real batch * here and N calls for Titan: a 500-chunk corpus is 6 round-trips instead of * 500. Chunked at exactly the documented number rather than under it, because * unlike a byte limit this one is a count the caller can see — and a batch that * silently used half the allowance would be a cost nobody asked for. */ const COHERE_MAX_TEXTS_PER_CALL = 96; /** * The body shape sent to a model id this library has never met. * * Titan's — because it is the shape EVERY release before 9.3.0 sent to every * model, so an unknown id keeps doing exactly what it did. `family` is how a * caller says otherwise, and the read-side refusal names that option. */ const DEFAULT_BEDROCK_FAMILY = 'titan'; /** * The facts for a model id, including ids that WRAP a known one. * * A cross-region inference profile (`us.amazon.titan-embed-text-v2:0`) and an * inference-profile ARN both END in the model id they route to, so containment * resolves them exactly rather than by guess — and an id that names its model is * not "unknown" just because it has a prefix. Before 9.3.0 those were refused * as unknown models, which is why widening this is safe: it accepts what used * to throw. */ function bedrockFactsFor(model) { const exact = BEDROCK_EMBEDDING_MODELS[model]; if (exact) return exact; for (const [id, facts] of Object.entries(BEDROCK_EMBEDDING_MODELS)) { if (model.includes(id)) return facts; } return undefined; } /** * The family a model id NAMES, or `undefined` when it names none. * * Substring, not prefix: the id may be wrapped by a region prefix or an ARN. * An id that says neither gets Titan's body — the shape every release before * 9.3.0 sent to everything — and `family` is how you say otherwise. */ function inferBedrockFamily(model) { if (model.includes('cohere.embed')) return 'cohere'; if (model.includes('titan-embed')) return 'titan'; return undefined; } /** * Amazon Bedrock's hosted embeddings, through `InvokeModel`. * * No API key option, deliberately: Bedrock authenticates through the AWS * credential chain (environment, profile, instance role, SSO), and inventing * a key parameter would be a second, worse way to configure the same thing. * * ── Why the id carries the DIMENSION COUNT ─────────────────────────────── * `openaiEmbedder` deliberately leaves the size out of its id, because a * store's fingerprint is `'<id>@<dims>'` and appends it. This one puts it in, * and the difference is not an inconsistency — it is the one place the * fingerprint cannot reach. * * `MemoryEntry.embeddingModel` stores the id ALONE, and it is the only thing * `SearchOptions.embedderId` filters on: the filter the port describes as * preventing "silent cross-model similarity pollution". Titan V2 at 512 and * Titan V2 at 1024 are different embedding spaces from one model id, so an id * without the size makes those two indistinguishable to that filter — and the * size alone cannot separate them either, since V1 and V2 both answer at * 1024. Only `'bedrock:<model>:<dims>'` separates all of them. The store's * fingerprint then reads `'bedrock:amazon.titan-embed-text-v2:0:512@512'`, * which restates the size once; a redundant fingerprint is harmless, and a * filter that cannot tell two vector spaces apart is not. * * (Precedent: `localEmbedder` puts `dtype` in its id for the same reason — a * q8 and an fp32 build of one model are near-identical spaces, and "near" is * exactly the difference that surfaces as a mysteriously worse ranking.) * * ── One operation, two body shapes (9.3.0) ─────────────────────────────── * `InvokeModel` is a single API over vendor-specific JSON. Titan takes * `{ inputText }` and answers `{ embedding }`; Cohere takes * `{ texts, input_type }` and answers `{ embeddings }`, embeds up to * {@link COHERE_MAX_TEXTS_PER_CALL} of them per call, and distinguishes a * QUERY from a DOCUMENT. So the model id selects a FAMILY * ({@link BedrockEmbeddingFamily}), and the family owns the request, the * response and the batching. Before this, one shape was sent to everything — * a Cohere id constructed fine and failed at the first embed. * * ── The input ceiling (9.1.0) ──────────────────────────────────────────── * `.maxInputChars` is per MODEL, from its documented token window converted at * the stated {@link CHARS_PER_TOKEN} assumption of 4 characters per token: * **32,000** for both Titan text-embedding models (8,192 tokens — sixteen * times the indexer's own default, which was measured on an on-device model), * and **2,000** for Cohere Embed v3 (512 tokens). Those two numbers are why the * ceiling cannot be a per-vendor constant: the same 2,500-character chunk is * read whole by Titan and truncated by Cohere. Dense text (code, tables, CJK) * tokenises tighter than the assumption; pass an explicit `maxChunkChars` for * such a corpus, and it wins over this number. * * @throws if `model` is unknown and `dimensions` was not supplied; if * `dimensions` is a size the model does not produce; if `family` * contradicts a known model id; or if the SDK is missing and no * `client` / `_client` / `_sdk` was passed. * * @example * ```ts * import { bedrockEmbedder } from 'agentfootprint/providers'; * import { sqliteVectorStore } from 'agentfootprint/memory'; * import { indexFolder } from 'agentfootprint/rag'; * * const embedder = bedrockEmbedder({ region: 'us-east-1', dimensions: 512 }); * await indexFolder('./docs', { to: sqliteVectorStore({ file: './corpus.db' }), embedder }); * ``` * * @example A Cohere embedding model on the same runtime * ```ts * // Body shape, response field, batch size and 512-token window all follow * // from the model id — nothing else changes at the call site. * const embedder = bedrockEmbedder({ model: 'cohere.embed-english-v3' }); * ``` */ export function bedrockEmbedder(options = {}) { const model = options.model ?? 'amazon.titan-embed-text-v2:0'; const facts = bedrockFactsFor(model); const named = inferBedrockFamily(model); if (options.family !== undefined && facts !== undefined && options.family !== facts.family) { throw new Error(`bedrockEmbedder: model '${model}' is a ${facts.family} model, and \`family: ` + `'${options.family}'\` says otherwise. The two cannot both be right, and guessing ` + `which line is the mistake would decide the request body — the one thing a wrong ` + `answer here breaks at the first embed. Drop \`family\` (the model id already says ` + `it), or name the model you meant.`); } // Explicit, then what the id names, then Titan — which is the body every // release before 9.3.0 sent to every model, so an id this library has never // met keeps behaving exactly as it did. const family = options.family ?? facts?.family ?? named ?? DEFAULT_BEDROCK_FAMILY; const requested = options.dimensions; const dimensions = requested ?? facts?.dimensions; if (dimensions === undefined) { throw new Error(`bedrockEmbedder: unknown model '${model}' — its vector length is not something this ` + `library can know, and reporting a wrong .dimensions silently corrupts a vector store. ` + `Pass { dimensions } with the length that model returns.`); } // A model this table has never seen can still be recognisably Titan V2, and // for one of those the requested size must still be validated and SENT. const sizes = facts?.sizes ?? (family === 'titan' && model.includes('titan-embed-text-v2') ? TITAN_V2_SIZES : undefined); if (requested !== undefined && sizes !== undefined && !sizes.includes(requested)) { throw new Error(`bedrockEmbedder: '${model}' returns ${sizes.join(', ')} ` + `dimensions — received ${String(requested)}. Asking for a size the model does not ` + `produce would store vectors of a length that disagrees with the .dimensions this ` + `embedder reports.`); } if (requested !== undefined && sizes === undefined && facts !== undefined && requested !== facts.dimensions) { throw new Error(`bedrockEmbedder: '${model}' returns ${facts.dimensions}-dimension vectors and takes no ` + `size parameter — received ${String(requested)}, which would be reported as ` + `.dimensions and never be the length that comes back. Drop \`dimensions\`: this ` + `factory already knows this model's size.`); } const maxInputChars = options.maxInputChars ?? charsFor(facts?.maxInputTokens); // Only a size the model actually TAKES is sent. Titan V2 defaults to 1024 on // its own side, V1 and Cohere reject the field — so a caller who asked for // nothing gets a request body identical to the one before this option // existed. const sendSize = requested !== undefined && sizes !== undefined; let connection; /** * Resolve the client + command constructor, once, on first embed. * * Three ways in, and the first is what keeps the tests free of AWS: * `_client` — test injection. The SDK is never required at all, and the * command is a plain object carrying the request, so a * double sees exactly what would have been sent. * `client` — your own pre-built client. The SDK still has to be * present for `InvokeModelCommand` (you built a client, so * it is), but the config and credentials stay yours. * neither — this factory builds one from `region`. */ const connect = () => { if (connection) return connection; if (options._client) { connection = { client: options._client, Command: options._sdk?.InvokeModelCommand ?? IdentityCommand, }; return connection; } const sdk = options._sdk ?? loadBedrockRuntimeSdk(); if (!sdk.InvokeModelCommand) { throw new Error('bedrockEmbedder: `@aws-sdk/client-bedrock-runtime` is installed but ' + '`InvokeModelCommand` was not found. Update the SDK.'); } if (!options.client && !sdk.BedrockRuntimeClient) { throw new Error('bedrockEmbedder: `@aws-sdk/client-bedrock-runtime` is installed but ' + '`BedrockRuntimeClient` was not found. Update the SDK.'); } connection = { client: options.client ?? // Checked directly above. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion new sdk.BedrockRuntimeClient({ ...(options.region && { region: options.region }) }), Command: sdk.InvokeModelCommand, }; return connection; }; /** * ONE `InvokeModel` round-trip, with the family's body on the way in and the * family's field on the way out. * * Takes a slice of texts rather than one, because how many fit in a call is * itself a family fact: Titan embeds a single `inputText`, Cohere embeds up * to {@link COHERE_MAX_TEXTS_PER_CALL}. The callers below never have to know * which — they hand over texts and get one vector per text back, in order. */ async function invoke(texts, inputType, signal) { const conn = connect(); const body = JSON.stringify(family === 'cohere' ? { texts: [...texts], // Required by the model, so there is no "unset" — see `inputType`. input_type: options.inputType ?? inputType, } : { inputText: texts[0], ...(sendSize && { dimensions: requested }), }); const command = new conn.Command({ modelId: model, contentType: 'application/json', accept: 'application/json', body, }); const out = await (signal ? conn.client.send(command, { abortSignal: signal }) : conn.client.send(command)); return readEmbeddings(out, model, family, texts.length, facts === undefined); } /** * The calls one batch becomes: Titan one text at a time, Cohere in chunks. * * Sequential rather than parallel either way: callers of the batch path are * usually indexing a whole corpus, and `indexCorpus` already fans out over * batches with its own bounded parallelism and retry. Racing N more requests * inside one of those branches is how a corpus index meets a throttling * error. * * The abort is checked between calls as well as passed into each one, so an * aborted batch stops at the next boundary instead of embedding the rest of a * corpus nobody is waiting for. */ async function invokeAll(texts, inputType, signal) { const perCall = family === 'cohere' ? COHERE_MAX_TEXTS_PER_CALL : 1; const out = []; for (let i = 0; i < texts.length; i += perCall) { signal?.throwIfAborted(); out.push(...(await invoke(texts.slice(i, i + perCall), inputType, signal))); } return out; } return { dimensions, // The embedding SPACE, size included — see the note above. id: `bedrock:${model}:${dimensions}`, // Spread, so a model this factory does not know declares NO ceiling rather // than one it cannot stand behind. ...(maxInputChars !== undefined && { maxInputChars }), async embed({ text, signal }) { // ONE text is this library's query shape (`loadRelevant` embeds the // question) — so Cohere is told it is a query unless `inputType` says // otherwise. Titan ignores the argument entirely. return (await invoke([text], 'search_query', signal))[0]; }, async embedBatch({ texts, signal }) { // MANY texts is this library's document shape (`indexDocuments`, // `embedMessages`), and Cohere embeds a document into a different place // from a query on purpose. return invokeAll(texts, 'search_document', signal); }, }; } /** A no-op command shim: an injected `_client` receives the raw input object. */ const IdentityCommand = class { constructor(input) { Object.assign(this, input); } }; function loadBedrockRuntimeSdk() { try { return lazyRequire('@aws-sdk/client-bedrock-runtime'); } catch { throw new Error('bedrockEmbedder requires the `@aws-sdk/client-bedrock-runtime` peer dependency.\n' + ' Install: npm install @aws-sdk/client-bedrock-runtime\n' + ' Or pass `client` with a pre-built Bedrock runtime client.'); } } /** * Pull the vectors out of an `InvokeModel` response, by FAMILY. * * The SDK hands back `body` as a `Uint8Array`; a hand-rolled or mock client * may hand back the decoded object or a string. All three are read, and * anything else is refused by NAME rather than returning an empty vector — * an embedder that silently returns `[]` writes a row a store will never * rank, which is indistinguishable from "the corpus does not mention that". * * The field differs with the family (`embedding` vs `embeddings`), so the * refusal has to as well: told the wrong family, the response is perfectly * valid and this is the only place that can say so — which is why the message * names `family` when the model id was not one this library knows. * * @param expected how many vectors were asked for. A count mismatch is refused * rather than returned short: the caller pairs vectors with texts by * POSITION, so a missing one does not go missing — it silently re-labels * every passage after it. * @param guessedFamily whether the family was a fallback rather than a fact. */ function readEmbeddings(response, model, family, expected, guessedFamily) { const body = response?.body ?? response; let parsed = body; if (body instanceof Uint8Array) { parsed = JSON.parse(new TextDecoder().decode(body)); } else if (typeof body === 'string') { parsed = JSON.parse(body); } const field = family === 'cohere' ? 'embeddings' : 'embedding'; const raw = parsed?.[field]; // Cohere answers `{ embeddings: [[...]] }`, or `{ embeddings: { float: [[...]] } }` // when a caller asked for typed embeddings. This never asks, and reads both. const rows = family === 'cohere' ? Array.isArray(raw) ? raw : raw?.float : [raw]; const vectors = Array.isArray(rows) && rows.every((row) => Array.isArray(row) && row.every((n) => typeof n === 'number')) ? rows : undefined; if (vectors === undefined) { throw new Error(`bedrockEmbedder: '${model}' returned no \`${field}\` array. Bedrock answered with ` + `${describeShape(parsed)}, which this adapter cannot read — check the model id names an ` + `EMBEDDING model (a text-generation model answers a different shape).` + (guessedFamily ? `\n This model id is not one this library knows, so it was sent the ${family} ` + `request body. If it is a ${family === 'titan' ? 'Cohere' : 'Titan'} model, pass ` + `\`family: '${family === 'titan' ? 'cohere' : 'titan'}'\` — the request body and ` + `the response field both differ per family.` : '')); } if (vectors.length !== expected) { throw new Error(`bedrockEmbedder: '${model}' was sent ${expected} text(s) and answered with ` + `${vectors.length} vector(s). Vectors are paired with texts by POSITION, so a short ` + `answer would not lose one passage — it would attach every later vector to the wrong ` + `passage, and the corpus would rank confidently and wrongly forever.`); } return vectors; } /** Describe a response by shape, never by content — it may carry customer text. */ function describeShape(value) { if (value === null) return 'null'; if (Array.isArray(value)) return `an array of ${value.length}`; if (typeof value === 'object') { const keys = Object.keys(value); return keys.length === 0 ? 'an object with no keys' : `an object with keys: ${keys.join(', ')}`; } return typeof value; } /** * The Gemini embedding models this library knows by name. * * **`gemini-embedding-001`** — text only, 3072 dimensions, a **2,048-token** * input window, and the full `task_type` vocabulary. It is the default because * this library's two call sites are a query and a passage, and the query / * document task types are exactly that distinction. * * **`gemini-embedding-2`** — natively multimodal, 3072 dimensions, an * **8,192-token** text window, and **no `task_type` parameter at all**: Google * replaced it with task instructions written into the prompt. That difference * is why the field is a per-model fact here rather than a constant — asked for * on the model that does not take it, `task_type` is a request field the * service will reject, and this adapter refuses first with the reason. * * A model outside this table has a length this library cannot know and must * state its own — the same rule `openaiEmbedder` and `bedrockEmbedder` apply, * for the same reason. */ const GEMINI_EMBEDDING_MODELS = { 'gemini-embedding-001': { dimensions: 3072, maxInputTokens: 2048, supportsTaskType: true }, 'gemini-embedding-2': { dimensions: 3072, maxInputTokens: 8192, supportsTaskType: false }, }; /** * How many texts Gemini embeds in ONE `embedContent` call: **one**. * * Not a conservative choice — the documented limit. `gemini-embedding-001` * accepts exactly one input text per request, and libraries that assumed * otherwise (batching 96 or 250 instances the way an OpenAI or Titan client * would) send an oversized request that the service rejects on every batch * bigger than a single text. `embedBatch` is therefore N sequential calls, and * says so rather than looking like a batch that is secretly a loop. */ const GEMINI_MAX_TEXTS_PER_CALL = 1; /** * Google's hosted embeddings, through `models.embedContent` — on Vertex or on * the Gemini API. * * The door is chosen exactly as `gemini()` chooses it: `{ project, location }` * is Vertex with Application Default Credentials, `{ apiKey }` is the Gemini * API, and neither is guessed. One `GoogleGenAI` client serves both this and * the LLM provider, so an app that talks to both can build the client once and * pass it as `_client`. * * ── Why the id carries the DIMENSION COUNT ─────────────────────────────── * `'gemini:<model>:<dims>'`, for the reason `bedrockEmbedder` gives at length: * `MemoryEntry.embeddingModel` stores the id ALONE and it is the only thing * `SearchOptions.embedderId` filters on. One model id at 768 and the same model * id at 3072 are different embedding spaces, so an id without the size cannot * tell them apart — and neither can the size alone, since both known models are * 3072 natively. * * The `taskType` is deliberately NOT in the id, matching `bedrockEmbedder`'s * treatment of Cohere's `input_type`: the default query/document pair is ONE * space by construction (the two projections exist to be compared with each * other), and a pinned value pins both call sites at once, so the store stays * self-consistent either way. * * ── The input ceiling, and the service telling on itself (9.1.0) ───────── * `.maxInputChars` is per MODEL, from its documented token window at the stated * {@link CHARS_PER_TOKEN} assumption: **8,000** for `gemini-embedding-001` * (2,048 tokens) and **32,000** for `gemini-embedding-2` (8,192). A quarter of * the newer model's window is not a rounding difference — it is the whole * reason the ceiling is per-model. Past the ceiling Gemini CLIPS rather than * refuses, so `onTruncation` decides what happens when the response admits it. * * @throws if `model` is unknown and `dimensions` was not supplied; if * `dimensions` exceeds what the model can produce; if `taskType` is set * on a model that takes none; if neither a project nor an API key is * resolvable; or if `@google/genai` is not installed and no `_client` * was passed. * * @example * ```ts * import { geminiEmbedder } from 'agentfootprint/providers'; * import { sqliteVectorStore } from 'agentfootprint/memory'; * import { indexFolder } from 'agentfootprint/rag'; * * const embedder = geminiEmbedder({ project: 'my-project', dimensions: 768 }); * await indexFolder('./docs', { to: sqliteVectorStore({ file: './corpus.db' }), embedder }); * ``` * * @example The Gemini API door, and a store that must never hold a clipped vector * ```ts * const embedder = geminiEmbedder({ * apiKey: process.env.GEMINI_API_KEY, * onTruncation: 'refuse', // the default — stated here because it is the point * }); * ``` */ export function geminiEmbedder(options = {}) { const model = options.model ?? 'gemini-embedding-001'; const facts = GEMINI_EMBEDDING_MODELS[model]; const requested = options.dimensions; const dimensions = requested ?? facts?.dimensions; if (dimensions === undefined) { throw new Error(`geminiEmbedder: unknown model '${model}' — its vector length is not something this ` + `library can know, and reporting a wrong .dimensions silently corrupts a vector store. ` + `Pass { dimensions } with the length that model returns.`); } if (requested !== undefined && (!Number.isInteger(requested) || requested < 1)) { throw new Error(`geminiEmbedder: \`dimensions\` must be a positive whole number — received ` + `${String(requested)}.`); } if (requested !== undefined && facts !== undefined && requested > facts.dimensions) { throw new Error(`geminiEmbedder: '${model}' produces at most ${facts.dimensions} dimensions — received ` + `${String(requested)}. Asking for more would be reported as .dimensions and never be ` + `the length that comes back.`); } if (options.taskType !== undefined && facts !== undefined && !facts.supportsTaskType) { throw new Error(`geminiEmbedder: '${model}' takes no \`task_type\` — Google replaced the parameter with ` + `task instructions written into the text itself on this model. Drop \`taskType\` and ` + `prefix your input (for example "task: search result | query: …"), or use ` + `'gemini-embedding-001', which does take it.`); } const sendTaskType = facts?.supportsTaskType ?? true; const maxInputChars = options.maxInputChars ?? charsFor(facts?.maxInputTokens); const onTruncation = options.onTruncation ?? 'refuse'; // Lazy on purpose, as it always was: constructing an embedder is not // connecting to one, and a factory that reached for credentials would refuse // at import time in a process that only ever embeds behind a feature flag. // Under a callback `apiKey` this also asks for the credential per request — // see `createGoogleGenAIClientResolver`. const connect = createGoogleGenAIClientResolver(options, 'geminiEmbedder', options._client); /** * ONE `embedContent` round-trip, carrying however many texts the model takes * ({@link GEMINI_MAX_TEXTS_PER_CALL}) — one, today. Written as a slice rather * than a single string so that the day Google raises the limit is a * one-number change here and nowhere else. */ async function embedChunk(texts, taskType, signal) { const config = { ...(sendTaskType && { taskType: options.taskType ?? taskType }), ...(requested !== undefined && { outputDimensionality: requested }), ...(signal && { abortSignal: signal }), }; const { client } = await connect(); const response = await client.models.embedContent({ model, contents: [...texts], ...(Object.keys(config).length > 0 && { config }), }); return readGeminiEmbeddings(response, model, dimensions, texts, onTruncation); } /** The calls one batch becomes, in order, one chunk at a time. */ async function embedAll(texts, taskType, signal) { const out = []; for (let i = 0; i < texts.length; i += GEMINI_MAX_TEXTS_PER_CALL) { signal?.throwIfAborted(); out.push(...(await embedChunk(texts.slice(i, i + GEMINI_MAX_TEXTS_PER_CALL), taskType, signal))); } return out; } return { dimensions, // The embedding SPACE, size included — see the note above. id: `gemini:${model}:${dimensions}`, // Spread, so a model this factory does not know declares NO ceiling rather // than one it cannot stand behind. ...(maxInputChars !== undefined && { maxInputChars }), async embed({ text, signal }) { // ONE text is this library's query shape (`loadRelevant` embeds the // question). return (await embedChunk([text], 'RETRIEVAL_QUERY', signal))[0]; }, async embedBatch({ texts, signal }) { // MANY texts is this library's document shape, and Gemini embeds a // document into a different place from a query on purpose. // // Sequential rather than parallel: callers of this path are usually // indexing a whole corpus, and `indexCorpus` already fans out over // batches with its own bounded parallelism and retry. Racing N more // requests inside one of those branches is how a corpus index meets a // throttling error. The abort is checked between calls as well as passed // into each one. return embedAll(texts, 'RETRIEVAL_DOCUMENT', signal); }, }; } /** * Read the vectors out of an `embedContent` response — and refuse everything * else by name. * * Four refusals, each for a failure that would otherwise be silent: * • a count that disagrees with the texts sent — vectors are paired with * texts by POSITION, so a short answer does not lose one passage, it * re-labels every later one; * • no readable `values` — an embedder that returns `[]` writes a row a store * will never rank, which is indistinguishable from "the corpus does not * mention that"; * • a length that disagrees with the declared `.dimensions` — the number a * vector store fingerprints on; * • `statistics.truncated` under `onTruncation: 'refuse'` — the service * saying it indexed the opening of the passage and threw the rest away. */ function readGeminiEmbeddings(response, model, dimensions, texts, onTruncation) { const rows = response.embeddings; if (!Array.isArray(rows) || rows.length !== texts.length) { throw new Error(`geminiEmbedder: '${model}' was sent ${texts.length} text(s) and answered with ` + `${Array.isArray(rows) ? rows.length : describeShape(response)} — vectors are paired ` + `with texts by POSITION, so a mismatched answer would attach every later vector to the ` + `wrong passage, and the corpus would rank confidently and wrongly forever.`); } return rows.map((row, index) => { const values = row?.values; if (!Array.isArray(values) || !values.every((n) => typeof n === 'number')) { throw new Error(`geminiEmbedder: '${model}' returned no \`embeddings[${index}].values\` array. The ` + `service answered with ${describeShape(response)}, which this adapter cannot read — ` + `check that the model id names an EMBEDDING model.`); } if (values.length !== dimensions) { throw new Error(`geminiEmbedder: '${model}' answered with a ${values.length}-number vector while this ` + `embedder reports .dimensions ${dimensions}. A store fingerprints on the reported ` + `number, so writing this vector would make the store's own record of its shape a lie. ` + `Pass { dimensions: ${values.length} } if that is the size you meant.`); } if (row?.statistics?.truncated === true && onTruncation === 'refuse') { const chars = (texts[index] ?? '').length; throw new Error(`geminiEmbedder: '${model}' CLIPPED this input — it reported ` + `\`statistics.truncated\`, so the vector represents only the opening of the ` + `${chars.toLocaleString('en-US')}-character text it was given. Storing it would ` + `index a passage by a prefix of itself, and retrieval would then fail to find words ` + `the passage visibly contains.\n` + ` Fix 1 — cut smaller: pass \`maxChunkChars\` to the indexer (it wins over this ` + `embedder's declared .maxInputChars, which is a characters-per-token ASSUMPTION and ` + `is optimistic for code, tables and CJK).\n` + ` Fix 2 — \`onTruncation: 'allow'\` if a prefix embedding is genuinely what you ` + `want. The vector is returned unchanged; only this refusal goes away.`); } return [...values]; }); } /** * The MEASURED cliff of the default model, and the origin of the whole * mechanism: `Xenova/all-MiniLM-L6-v2` silently truncates at 512 wordpiece * tokens ≈ 1,800–2,000 characters of English. Measured directly — at 508 base * tokens an appended tail still moves the vector (cosine 0.9965); at 596 it * does not (0.999999). Nothing is thrown and nothing says so. * * It is the number `indexCorpus` used as its own default for every embedder, * which is why a hosted model with an 8k-token window was being cut into * pieces a sixteenth of what it could read. Here it is declared by the * embedder it was measured on, where it is true. */ const LOCAL_MAX_INPUT_CHARS = 2000; export function localEmbedder(options = {}) { const model = options.model ?? 'Xenova/all-MiniLM-L6-v2'; const dimensions = options.dimensions ?? 384; const dtype = options.dtype ?? 'q8'; let pipe; const build = (m) => { if (options.cacheDir && m.env && typeof m.env === 'object') { m.env['cacheDir'] = options.cacheDir; } return m.pipeline('feature-extraction', model, { dtype }); }; const getPipe = () => { const injected = options.backend; if (injected) return (pipe ??= build(injected)); // Variable specifier so the compiler/bundler does NOT resolve the module at // build time — @huggingface/transformers stays an optional peer dep, loaded // only when localEmbedder is actually used. A bundler cannot see through // this; bundled apps pass { backend } instead. const spec = '@huggingface/transformers'; return (pipe ??= import(spec).then((mod) => build(mod))); }; return { dimensions, // `dtype` is part of the name because a q8 and an fp32 build of one model // are close but not identical spaces, and "close" is exactly the kind of // difference that shows up as a mysteriously worse ranking. id: `local:${model}:${dtype}`, maxInputChars: options.maxInputChars ?? LOCAL_MAX_INPUT_CHARS, async embed({ text }) { const p = await getPipe(); const out = await p(text, { pooling: 'mean', normalize: true }); return Array.from(out.data); }, async embedBatch({ texts }) { const p = await getPipe(); const out = await p([...texts], { pooling: 'mean', normalize: true }); return out.tolist(); }, }; } /** * Declared input ceiling for the static path (9.1.0), and the one case where * the honest answer is "there isn't a cliff". * * A Model2Vec model has no transformer and therefore no context window: it * looks each token's vector up and pools them, so the ten-thousandth token * contributes exactly like the first. Nothing is dropped at any length. The * number is a practical bound (a megabyte of text), declared rather than * omitted so that an indexer does not fall back to a 2,000-character default * measured on a model with a real cliff and warn about clipping that never * happened. */ const STATIC_MAX_INPUT_CHARS = 1_000_000; export function staticEmbedder(options = {}) { const dimensions = options.dimensions ?? 256; const spec = options.module ?? '@yarflam/potion-base-8m'; let embedFn; // potion-base-8m exports `embed(texts) => Promise<Float32Array[]>` (a batch // async fn, also on its default export). Accept a small set of shapes so // other Model2Vec builds slot in: a named `embed`/`encode` on the module or // its default, or a default export that IS the fn. const pick = (mod, source) => { const m = mod; const d = (m.default ?? {}); const fn = m['embed'] ?? d['embed'] ?? m['encode'] ?? d['encode'] ?? (typeof m.default === 'function' ? m.default : undefined); if (!fn) { throw new Error(`staticEmbedder: no embed()/encode() export on ${source}. Pass { module } or wrap it in your own Embedder.`); } return fn; }; const getEmbed = () => { const injected = options.backend; if (injected) { return (embedFn ??= Promise.resolve(pick(injected, 'the module passed as { backend }'))); } return (embedFn ??= import(spec).then((mod) => pick(mod, `'${spec}'`))); }; // Normalize a batch result into number[][] (one row per input). Handles // Float32Array[] (potion), number[][], and a single flat vector for the call. const toRows = (out) => { const rows = out; if (rows == null || typeof rows.length !== 'number') { throw new Error('staticEmbedder: embed() did not return an array of vectors.'); } if (rows.length > 0 && typeof rows[0] === 'number') { return [Array.from(rows)]; // one flat vector for the call } return Array.from(rows, (v) => Array.from(v)); }; return { dimensions, id: `static:${spec}`, // No context window to fall off — see the constant. maxInputChars: STATIC_MAX_INPUT_CHARS, async embed({ text }) { const fn = await getEmbed(); const rows = toRows(await fn([text])); return rows[0] ?? []; }, async embedBatch({ texts }) { const fn = await getEmbed(); return toRows(await fn([...texts])); }, }; } //# sourceMappingURL=index.js.map