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:
YOMXXX
2026-06-04 16:31:29 +08:00
committed by GitHub
parent b50c2eb723
commit f92b10259b
2 changed files with 255 additions and 56 deletions
+1 -1
View File
@@ -87,7 +87,7 @@
"description": "向量搜索 (Embedding) 配置",
"properties": {
"enabled": { "type": "boolean", "default": true, "description": "是否启用向量搜索(若 provider=none,则实际会被禁用)" },
"provider": { "type": "string", "default": "none", "description": "Embedding 服务提供者:填写兼容 OpenAI API 的远端服务名称(如 openai、deepseek 等);不填或填 none 则禁用向量搜索" },
"provider": { "type": "string", "default": "none", "description": "Embedding 服务提供者:填写兼容 OpenAI API 的远端服务名称(如 openai、deepseek 等);填 zeroentropy 走 ZeroEntropy 原生 /v1/models/embed 协议(zembed-1 等模型);不填或填 none 则禁用向量搜索" },
"proxyUrl": { "type": "string", "description": "本地代理地址(仅 provider=qclaw 时必填)。配置后 embedding 请求将通过该代理转发,原始 baseUrl 作为 Remote-URL 头传递" },
"baseUrl": { "type": "string", "description": "API Base URL(必填):填写对应 provider 的 API 地址" },
"apiKey": { "type": "string", "description": "API Key(必填)" },