Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ interface PluginConfig {
apiKey: string | string[];
model?: string;
baseURL?: string;
/** Internal schema/validation dimension (LanceDB + local checks). */
dimensions?: number;
/** Optional provider request dimension (dimensions/output_dimension). */
requestDimensions?: number;
omitDimensions?: boolean;
taskQuery?: string;
taskPassage?: string;
Expand Down Expand Up @@ -1639,7 +1642,10 @@ const memoryLanceDBProPlugin = {
apiKey: config.embedding.apiKey,
model: config.embedding.model || "text-embedding-3-small",
baseURL: config.embedding.baseURL,
// Internal dimension for local schema/validation checks.
dimensions: config.embedding.dimensions,
// Optional request hint sent to providers that support variable dimensions.
requestDimensions: config.embedding.requestDimensions,
omitDimensions: config.embedding.omitDimensions,
taskQuery: config.embedding.taskQuery,
taskPassage: config.embedding.taskPassage,
Expand Down Expand Up @@ -3776,6 +3782,8 @@ export function parsePluginConfig(value: unknown): PluginConfig {
// Accept number, numeric string, or env-var string (e.g. "${EMBED_DIM}").
// Also accept legacy top-level `dimensions` for convenience.
dimensions: parsePositiveInt(embedding.dimensions ?? cfg.dimensions),
// Request dimension is intentionally separate from internal schema sizing.
requestDimensions: parsePositiveInt(embedding.requestDimensions),
omitDimensions:
typeof embedding.omitDimensions === "boolean"
? embedding.omitDimensions
Expand Down
11 changes: 11 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
"type": "integer",
"minimum": 1
},
"requestDimensions": {
"type": "integer",
"minimum": 1,
"description": "Optional output dimension sent to embedding API requests only (for providers supporting variable dimensions)"
},
"omitDimensions": {
"type": "boolean",
"description": "When true, omit the dimensions parameter from embedding requests even if dimensions is configured"
Expand Down Expand Up @@ -862,6 +867,12 @@
"help": "Override vector dimensions for custom models not in the built-in lookup table",
"advanced": true
},
"embedding.requestDimensions": {
"label": "Request Dimensions",
"placeholder": "unset (do not send)",
"help": "Optional output dimension sent to the embedding API request only (dimensions/output_dimension depending on provider). If unset, no request dimension field is sent.",
"advanced": true
},
"embedding.omitDimensions": {
"label": "Omit Request Dimensions",
"help": "Do not send the dimensions parameter to the embedding API even if embedding.dimensions is configured. Useful for local models like Qwen3-Embedding that reject the field.",
Expand Down
13 changes: 11 additions & 2 deletions src/embedder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ export interface EmbeddingConfig {
apiKey: string | string[];
model: string;
baseURL?: string;
/** Internal vector dimension for schema/validation. This does NOT imply sending API dimensions. */
dimensions?: number;
/** Optional API request output dimension for providers that support variable dimensions. */
requestDimensions?: number;

/** Optional task type for query embeddings (e.g. "retrieval.query") */
taskQuery?: string;
Expand Down Expand Up @@ -428,7 +431,8 @@ export class Embedder {
this._taskQuery = config.taskQuery;
this._taskPassage = config.taskPassage;
this._normalized = config.normalized;
this._requestDimensions = config.dimensions;
// Request-side dimension hint is isolated from internal schema dimension.
this._requestDimensions = config.requestDimensions;
this._omitDimensions = config.omitDimensions === true;
// Enable auto-chunking by default for better handling of long documents
this._autoChunk = config.chunking !== false;
Expand Down Expand Up @@ -472,7 +476,12 @@ export class Embedder {
console.log(`[memory-lancedb-pro] Initialized ${this.clients.length} API keys for round-robin rotation`);
}

this.dimensions = getVectorDimensions(config.model, config.dimensions);
// Internal dimension用于本地校验,优先使用requestDimensions(如有),否则fallback到dimensions。
// 这样可变维度模型(如text-embedding-3-large + requestDimensions: 1024)本地校验与API一致。
const effectiveDims = this._requestDimensions && this._requestDimensions > 0
? this._requestDimensions
: config.dimensions;
this.dimensions = getVectorDimensions(config.model, effectiveDims);
this._cache = new EmbeddingCache(256, 30); // 256 entries, 30 min TTL
}

Expand Down
28 changes: 24 additions & 4 deletions test/embedder-error-hints.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ async function run() {
installMockEmbeddingClient(jinaEmbedder, async (payload) => {
assert.equal(payload.task, "retrieval.passage");
assert.equal(payload.normalized, true);
assert.equal(payload.dimensions, 1024);
assert.equal(payload.dimensions, undefined, "jina should not send dimensions unless requestDimensions is set");
return createEmbeddingResponse(1024);
});
await jinaEmbedder.embedPassage("hello");
Expand All @@ -144,7 +144,7 @@ async function run() {
});
installMockEmbeddingClient(genericEmbedder, async (payload) => {
assert.equal(payload.encoding_format, "float");
assert.equal(payload.dimensions, 384);
assert.equal(payload.dimensions, undefined, "generic profile should not send dimensions unless requestDimensions is set");
return createEmbeddingResponse(384);
});
await genericEmbedder.embedPassage("hello");
Expand Down Expand Up @@ -189,14 +189,15 @@ async function run() {
});
await voyageTaskEmbedder.embedQuery("hello");

// Voyage: configured dimensions should be sent as output_dimension, not dimensions.
// Voyage: requestDimensions should be sent as output_dimension, not dimensions.
// voyage-4-lite is a recommended Voyage model that supports output_dimension.
const voyageDimEmbedder = new Embedder({
provider: "openai-compatible",
apiKey: "test-key",
model: "voyage-4-lite",
baseURL: "https://api.voyageai.com/v1",
dimensions: 512,
requestDimensions: 512,
});
installMockEmbeddingClient(voyageDimEmbedder, async (payload) => {
assert.equal(payload.output_dimension, 512, "voyage should send output_dimension");
Expand All @@ -211,7 +212,7 @@ async function run() {
await withEmbeddingCaptureServer(
(payload) => {
assert.equal(payload.encoding_format, "float", "generic profile should send encoding_format");
assert.equal(payload.dimensions, 384, "generic profile should send dimensions");
assert.equal(payload.dimensions, undefined, "generic profile should not send dimensions by default");
assert.equal(payload.task, undefined, "generic profile should not send task");
assert.equal(payload.normalized, undefined, "generic profile should not send normalized");
return { body: createEmbeddingResponse(384) };
Expand All @@ -228,6 +229,25 @@ async function run() {
},
);

await withEmbeddingCaptureServer(
(payload) => {
assert.equal(payload.encoding_format, "float", "generic profile should send encoding_format");
assert.equal(payload.dimensions, 384, "generic profile should send dimensions when requestDimensions is set");
return { body: createEmbeddingResponse(384) };
},
async ({ baseURL }) => {
const embedder = new Embedder({
provider: "openai-compatible",
apiKey: "test-key",
model: "custom-embed-model",
baseURL,
dimensions: 384,
requestDimensions: 384,
});
await embedder.embedPassage("hello world");
},
);

await withJsonServer(
403,
{ error: { message: "Invalid API key", code: "invalid_api_key" } },
Expand Down
44 changes: 40 additions & 4 deletions test/plugin-manifest-regression.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ assert.equal(
"boolean",
"embedding.omitDimensions should be declared in the plugin schema",
);
assert.equal(
manifest.configSchema.properties.embedding.properties.requestDimensions?.type,
"integer",
"embedding.requestDimensions should be declared in the plugin schema",
);
assert.equal(
manifest.configSchema.properties.sessionMemory.properties.enabled.default,
false,
Expand Down Expand Up @@ -325,14 +330,44 @@ try {
});
const requestCountBeforeWithDimensions = embeddingRequests.length;
await withDimensionsTool.execute("tool-3", {
text: "dimensions should be sent by default",
text: "dimensions should not be sent by default",
scope: "global",
});
const withDimensionsRequest = embeddingRequests.at(requestCountBeforeWithDimensions);
assert.equal(
withDimensionsRequest?.dimensions,
Object.prototype.hasOwnProperty.call(withDimensionsRequest ?? {}, "dimensions"),
false,
"embedding.dimensions should be used for internal schema sizing, not forwarded by default",
);

const withRequestDimensionsApi = createMockApi({
dbPath: path.join(workDir, "db-with-request-dimensions"),
autoCapture: false,
autoRecall: false,
embedding: {
provider: "openai-compatible",
apiKey: "dummy",
model: "text-embedding-3-small",
baseURL: embeddingBaseURL,
dimensions: 4,
requestDimensions: 4,
},
});
plugin.register(withRequestDimensionsApi);
const withRequestDimensionsTool = withRequestDimensionsApi.toolFactories.memory_store({
agentId: "main",
sessionKey: "agent:main:test",
});
const requestCountBeforeRequestDimensions = embeddingRequests.length;
await withRequestDimensionsTool.execute("tool-3b", {
text: "requestDimensions should be forwarded",
scope: "global",
});
const withRequestDimensionsRequest = embeddingRequests.at(requestCountBeforeRequestDimensions);
assert.equal(
withRequestDimensionsRequest?.dimensions,
4,
"embedding.dimensions should be forwarded by default",
"embedding.requestDimensions should be forwarded to embedding requests",
);

const omitDimensionsApi = createMockApi({
Expand All @@ -345,6 +380,7 @@ try {
model: "text-embedding-3-small",
baseURL: embeddingBaseURL,
dimensions: 4,
requestDimensions: 4,
omitDimensions: true,
},
});
Expand All @@ -362,7 +398,7 @@ try {
assert.equal(
Object.prototype.hasOwnProperty.call(omitDimensionsRequest, "dimensions"),
false,
"embedding.omitDimensions=true should omit dimensions from embedding requests",
"embedding.omitDimensions=true should omit dimensions from embedding requests even when requestDimensions is set",
);
} finally {
await new Promise((resolve) => embeddingServer.close(resolve));
Expand Down
Loading