mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 12:34:27 +00:00
feat(embedding): support ZeroEntropy native embed API (#137)
* feat(embedding): support ZeroEntropy native embed API
ZeroEntropy's embedding service is reachable but not OpenAI-shaped:
its endpoint is POST /v1/models/embed (not /v1/embeddings), the
request body requires an `input_type` ("query" or "document") and
rejects an explicit `dimensions` field, and the response is wrapped
as `{results: [{embedding}]}` instead of OpenAI's
`{data: [{index, embedding}]}`.
Branch on `provider === "zeroentropy"` inside OpenAIEmbeddingService
rather than introducing a new service class:
- `/models/embed` is used instead of `/embeddings` (also honoured
by the qclaw proxy path so users can still front it).
- request body sets `input_type: "query"` and omits `dimensions`
(zembed-1 is fixed-dimension and rejects explicit overrides).
- response parsing decodes the `results[]` envelope and preserves
input order via array index rather than OpenAI's `index` field.
`openclaw.plugin.json` updates the `embedding.provider` description to
list `zeroentropy` as a recognized value.
Everything else (timeout, retries, batching, char-cap truncation,
sanitize+normalize) is shared with the OpenAI path — only the wire
format changes.
Fixes #68
Signed-off-by: 李冠辰 <liguanchen@xiaomi.com>
* refactor(embedding): split ZeroEntropy into its own EmbeddingService
Addresses @Xuruida's review feedback on PR #137: instead of branching
OpenAIEmbeddingService._callApi on `providerName === "zeroentropy"`,
extract the shared HTTP layer and give ZeroEntropy its own class.
This keeps OpenAIEmbeddingService focused on the OpenAI-compatible
wire format and stops provider-specific protocol differences from
accumulating inside a single class.
Changes:
- New module-level helpers (provider-agnostic):
* `truncateEmbeddingInputs(texts, maxInputChars, logger)` — was
previously a private method duplicated logic-side.
* `postEmbeddingRequest({fetchUrl, headers, body, timeoutMs})` —
owns fetch + AbortController timeout + exponential-backoff
retries + the EmbeddingApiError non-retry rule for 4xx (except
429). Returns the parsed JSON; callers own response parsing.
- `OpenAIEmbeddingService._callApi` is back to OpenAI-only: builds
`{input, model, dimensions?}`, posts to `/embeddings`, parses
`{data: [{index, embedding}]}` with index-sort. The qclaw proxy
branch is unchanged. No more `isZeroEntropy` reads.
- New `ZeroEntropyEmbeddingService implements EmbeddingService`:
- Same `OpenAIEmbeddingConfig` shape (baseUrl / apiKey / model /
dimensions / maxInputChars / timeoutMs are identical on the
wire), but encodes ZeroEntropy's wire format independently:
posts to `${baseUrl}/models/embed`, sends `input_type: "query"`,
omits `dimensions` (zembed-1 is fixed-dim), parses
`{results: [{embedding}]}` preserving array order.
- Doesn't carry the OpenAI-only knobs (`sendDimensions`,
`proxyUrl`, qclaw) so the class surface stays small.
- `createEmbeddingService` factory: new `provider === "zeroentropy"`
branch placed before the OpenAI-compatible catch-all so it wins
the dispatch.
Behaviour preserved:
- All existing OpenAI / DeepSeek / Azure / qclaw deployments hit
exactly the same wire calls as before this PR was opened.
- ZeroEntropy wire format (URL + body + response) matches the
original PR's branched implementation byte for byte.
- Timeout, retry, error mapping, batch splitting, char-cap
truncation, sanitize+normalize are shared via the module helpers
so the two services can't diverge by accident.
Signed-off-by: 李冠辰 <liguanchen@xiaomi.com>
* fix(embedding): forward dimensions to ZeroEntropy when sendDimensions
Addresses @Xuruida's follow-up review on PR #137. The previous commit
silently dropped `dimensions` from every ZeroEntropy request based on
the wrong claim that zembed-1 is fixed-dim.
Verified against
https://docs.zeroentropy.dev/api-reference/models/embed: `dimensions`
is an optional integer; for zembed-1 the accepted set is
[2560, 1280, 640, 320, 160, 80, 40] (Matryoshka truncation). Not
forwarding it left users no way to pick a smaller dim end-to-end —
the wire payload always used the model default while the Float32Array
return type and getDimensions() still reported config.dimensions, so
the runtime vector size and the configured one could disagree.
- ZeroEntropyEmbeddingService now mirrors OpenAIEmbeddingService:
reads `config.sendDimensions ?? true` into a `sendDimensions` field
and emits `dimensions: this.dims` in the request body when set.
- Class docstring corrected — the "rejects explicit overrides" line
is replaced with the documented Matryoshka set. Server still
rejects out-of-set values, but that's a config choice the user
owns; we forward verbatim instead of clamping silently.
Signed-off-by: 李冠辰 <liguanchen@xiaomi.com>
---------
Signed-off-by: 李冠辰 <liguanchen@xiaomi.com>
This commit is contained in:
+254
-55
@@ -399,6 +399,101 @@ interface OpenAIEmbeddingResponse {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ZeroEntropy's `/v1/models/embed` returns input order via `results[i]`
|
||||
* (no `index` field) and omits the OpenAI `data` envelope. See:
|
||||
* https://docs.zeroentropy.dev/api-reference/models/embed
|
||||
*/
|
||||
interface ZeroEntropyEmbeddingResponse {
|
||||
results: Array<{
|
||||
embedding: number[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Shared HTTP helpers (provider-agnostic)
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Truncate every text to `maxInputChars` (when set), emitting one warning
|
||||
* per text that exceeded the limit. Returns the input array untouched when
|
||||
* no limit is configured.
|
||||
*/
|
||||
function truncateEmbeddingInputs(
|
||||
texts: string[],
|
||||
maxInputChars: number | undefined,
|
||||
logger?: Logger,
|
||||
): string[] {
|
||||
if (!maxInputChars) return texts;
|
||||
return texts.map((text) => {
|
||||
if (text.length <= maxInputChars) return text;
|
||||
logger?.warn?.(
|
||||
`${TAG} Input truncated from ${text.length} to ${maxInputChars} chars (maxInputChars limit)`,
|
||||
);
|
||||
return text.slice(0, maxInputChars);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a remote embedding request with the project's standard timeout +
|
||||
* retry behaviour, returning the parsed JSON body. Provider-specific
|
||||
* services own body construction and response shape — this helper handles
|
||||
* fetch, abort-on-timeout, exponential backoff, and the `EmbeddingApiError`
|
||||
* non-retry rule for 4xx responses (except 429).
|
||||
*/
|
||||
async function postEmbeddingRequest(params: {
|
||||
fetchUrl: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
timeoutMs: number;
|
||||
}): Promise<unknown> {
|
||||
const { fetchUrl, headers, body, timeoutMs } = params;
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const resp = await fetch(fetchUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.text().catch(() => "(unable to read body)");
|
||||
const err = new EmbeddingApiError(
|
||||
`Embedding API error: HTTP ${resp.status} ${resp.statusText} — ${errBody.slice(0, 500)}`,
|
||||
resp.status,
|
||||
);
|
||||
// Don't retry 4xx client errors (except 429 rate limit).
|
||||
if (resp.status >= 400 && resp.status < 500 && resp.status !== 429) {
|
||||
throw err;
|
||||
}
|
||||
lastError = err;
|
||||
continue;
|
||||
}
|
||||
return await resp.json();
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-retryable errors (4xx client errors) — rethrow immediately
|
||||
if (err instanceof EmbeddingApiError && err.isClientError()) {
|
||||
throw err;
|
||||
}
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
// AbortError = timeout, retry
|
||||
if (attempt < MAX_RETRIES) {
|
||||
// Exponential backoff: 500ms, 1000ms
|
||||
const delay = 500 * (attempt + 1);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error("Embedding API call failed after retries");
|
||||
}
|
||||
|
||||
export class OpenAIEmbeddingService implements EmbeddingService {
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey: string;
|
||||
@@ -502,7 +597,7 @@ export class OpenAIEmbeddingService implements EmbeddingService {
|
||||
body.dimensions = this.dims;
|
||||
}
|
||||
|
||||
// Determine fetch URL and headers based on proxy mode
|
||||
// Determine fetch URL and headers based on proxy mode.
|
||||
const useProxy = this.providerName === "qclaw" && !!this.proxyUrl;
|
||||
const fetchUrl = useProxy ? this.proxyUrl! : `${this.baseUrl}/embeddings`;
|
||||
const headers: Record<string, string> = {
|
||||
@@ -516,63 +611,161 @@ export class OpenAIEmbeddingService implements EmbeddingService {
|
||||
);
|
||||
}
|
||||
|
||||
// Retry loop with timeout
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutOverride ?? this.timeoutMs);
|
||||
const json = (await postEmbeddingRequest({
|
||||
fetchUrl,
|
||||
headers,
|
||||
body,
|
||||
timeoutMs: timeoutOverride ?? this.timeoutMs,
|
||||
})) as OpenAIEmbeddingResponse;
|
||||
|
||||
try {
|
||||
const resp = await fetch(fetchUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.text().catch(() => "(unable to read body)");
|
||||
const err = new EmbeddingApiError(
|
||||
`Embedding API error: HTTP ${resp.status} ${resp.statusText} — ${errBody.slice(0, 500)}`,
|
||||
resp.status,
|
||||
);
|
||||
// Don't retry on 4xx client errors (except 429 rate limit)
|
||||
if (resp.status >= 400 && resp.status < 500 && resp.status !== 429) {
|
||||
throw err;
|
||||
}
|
||||
lastError = err;
|
||||
continue;
|
||||
}
|
||||
|
||||
const json = (await resp.json()) as OpenAIEmbeddingResponse;
|
||||
|
||||
if (!json.data || !Array.isArray(json.data)) {
|
||||
throw new Error("Embedding API returned unexpected format: missing 'data' array");
|
||||
}
|
||||
|
||||
// Sort by index to ensure correct order, then sanitize+normalize for consistency with local provider
|
||||
const sorted = [...json.data].sort((a, b) => a.index - b.index);
|
||||
return sorted.map((d) => sanitizeAndNormalize(d.embedding));
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-retryable errors (4xx client errors) — rethrow immediately
|
||||
if (err instanceof EmbeddingApiError && err.isClientError()) {
|
||||
throw err;
|
||||
}
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
// AbortError = timeout, retry
|
||||
if (attempt < MAX_RETRIES) {
|
||||
// Exponential backoff: 500ms, 1000ms
|
||||
const delay = 500 * (attempt + 1);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
if (!json.data || !Array.isArray(json.data)) {
|
||||
throw new Error("Embedding API returned unexpected format: missing 'data' array");
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Embedding API call failed after retries");
|
||||
// Sort by index to ensure correct order, then sanitize+normalize for consistency with local provider.
|
||||
const sorted = [...json.data].sort((a, b) => a.index - b.index);
|
||||
return sorted.map((d) => sanitizeAndNormalize(d.embedding));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// ZeroEntropy embedding service
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* ZeroEntropy native embedding adapter.
|
||||
*
|
||||
* Reuses {@link OpenAIEmbeddingConfig} for the wire-config shape (baseUrl /
|
||||
* apiKey / model / dimensions / sendDimensions are identical), but the wire
|
||||
* format diverges in three places, so we keep this provider on its own class
|
||||
* instead of branching {@link OpenAIEmbeddingService}:
|
||||
*
|
||||
* 1. Endpoint is `${baseUrl}/models/embed` (not `/embeddings`).
|
||||
* 2. Request body requires `input_type` (`"query"` or `"document"`).
|
||||
* `dimensions` is optional — for `zembed-1` the accepted values are the
|
||||
* Matryoshka set [2560, 1280, 640, 320, 160, 80, 40]; any other value is
|
||||
* rejected by the server. The config's `sendDimensions` flag (default
|
||||
* true) controls whether it is forwarded, matching the OpenAI path.
|
||||
* 3. Response envelope is `{ results: [{ embedding }] }` and preserves
|
||||
* input order via array position rather than an `index` field.
|
||||
*
|
||||
* Everything else (timeout, retry, batching, char-cap truncation,
|
||||
* sanitize+normalize) is shared via the module-level
|
||||
* `postEmbeddingRequest` / `truncateEmbeddingInputs` helpers. See
|
||||
* https://docs.zeroentropy.dev/api-reference/models/embed and issue #68.
|
||||
*/
|
||||
export class ZeroEntropyEmbeddingService implements EmbeddingService {
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly model: string;
|
||||
private readonly dims: number;
|
||||
private readonly sendDimensions: boolean;
|
||||
private readonly maxInputChars?: number;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly logger?: Logger;
|
||||
|
||||
constructor(config: OpenAIEmbeddingConfig, logger?: Logger) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error("ZeroEntropyEmbeddingService: apiKey is required");
|
||||
}
|
||||
if (!config.baseUrl) {
|
||||
throw new Error("ZeroEntropyEmbeddingService: baseUrl is required");
|
||||
}
|
||||
if (!config.model) {
|
||||
throw new Error("ZeroEntropyEmbeddingService: model is required");
|
||||
}
|
||||
if (!config.dimensions || config.dimensions <= 0) {
|
||||
throw new Error("ZeroEntropyEmbeddingService: dimensions is required (must be a positive integer)");
|
||||
}
|
||||
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model;
|
||||
this.dims = config.dimensions;
|
||||
this.sendDimensions = config.sendDimensions ?? true;
|
||||
this.maxInputChars = config.maxInputChars && config.maxInputChars > 0 ? config.maxInputChars : undefined;
|
||||
this.timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_API_TIMEOUT_MS;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
getDimensions(): number {
|
||||
return this.dims;
|
||||
}
|
||||
|
||||
getProviderInfo(): EmbeddingProviderInfo {
|
||||
return { provider: "zeroentropy", model: this.model };
|
||||
}
|
||||
|
||||
/** Remote embedding is always ready (stateless HTTP). */
|
||||
isReady(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** No-op for remote embedding (no local model to warm up). */
|
||||
startWarmup(): void {
|
||||
// nothing to do — remote API is stateless
|
||||
}
|
||||
|
||||
async embed(text: string, options?: EmbeddingCallOptions): Promise<Float32Array> {
|
||||
const [result] = await this.embedBatch([text], options);
|
||||
return result;
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[], options?: EmbeddingCallOptions): Promise<Float32Array[]> {
|
||||
if (texts.length === 0) return [];
|
||||
|
||||
const processedTexts = truncateEmbeddingInputs(texts, this.maxInputChars, this.logger);
|
||||
|
||||
if (processedTexts.length > MAX_BATCH_SIZE) {
|
||||
const results: Float32Array[] = [];
|
||||
for (let i = 0; i < processedTexts.length; i += MAX_BATCH_SIZE) {
|
||||
const chunk = processedTexts.slice(i, i + MAX_BATCH_SIZE);
|
||||
const chunkResults = await this._callApi(chunk, options?.timeoutMs);
|
||||
results.push(...chunkResults);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
return this._callApi(processedTexts, options?.timeoutMs);
|
||||
}
|
||||
|
||||
private async _callApi(texts: string[], timeoutOverride?: number): Promise<Float32Array[]> {
|
||||
// ZeroEntropy rejects requests without `input_type`. We default to
|
||||
// "query" because the recall hot path is the only caller of embed()
|
||||
// that returns a Float32Array; capture-side batches eventually feed
|
||||
// the same vector store, and ZeroEntropy's symmetry between "query"
|
||||
// and "document" makes a single type safe across both directions.
|
||||
const body: Record<string, unknown> = {
|
||||
input: texts,
|
||||
model: this.model,
|
||||
input_type: "query",
|
||||
};
|
||||
if (this.sendDimensions) {
|
||||
// ZeroEntropy's docs list `dimensions` as optional. For zembed-1 the
|
||||
// accepted set is [2560, 1280, 640, 320, 160, 80, 40] (Matryoshka);
|
||||
// any other value is rejected server-side. We forward the user's
|
||||
// configured value verbatim — clamping silently would surprise users
|
||||
// who deliberately picked a smaller dim for storage savings.
|
||||
body.dimensions = this.dims;
|
||||
}
|
||||
|
||||
const fetchUrl = `${this.baseUrl}/models/embed`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
};
|
||||
|
||||
const json = (await postEmbeddingRequest({
|
||||
fetchUrl,
|
||||
headers,
|
||||
body,
|
||||
timeoutMs: timeoutOverride ?? this.timeoutMs,
|
||||
})) as ZeroEntropyEmbeddingResponse;
|
||||
|
||||
if (!json.results || !Array.isArray(json.results)) {
|
||||
throw new Error("ZeroEntropy embedding API returned unexpected format: missing 'results' array");
|
||||
}
|
||||
// ZeroEntropy preserves input order via array position (no `index` field).
|
||||
return json.results.map((r) => sanitizeAndNormalize(r.embedding));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,6 +790,12 @@ export function createEmbeddingService(
|
||||
config: EmbeddingConfig | undefined,
|
||||
logger?: Logger,
|
||||
): EmbeddingService {
|
||||
// ZeroEntropy speaks a non-OpenAI wire format and has its own service class.
|
||||
if (config && config.provider === "zeroentropy" && "apiKey" in config && config.apiKey) {
|
||||
logger?.debug?.(`${TAG} Using ZeroEntropy embedding (model=${config.model})`);
|
||||
return new ZeroEntropyEmbeddingService(config as OpenAIEmbeddingConfig, logger);
|
||||
}
|
||||
|
||||
// Remote OpenAI-compatible provider: any provider value other than "local"
|
||||
if (config && config.provider !== "local" && "apiKey" in config && config.apiKey) {
|
||||
logger?.debug?.(`${TAG} Using remote embedding (provider=${config.provider}, model=${config.model})`);
|
||||
|
||||
Reference in New Issue
Block a user