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
JavaScript
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