diff --git a/index.ts b/index.ts index 212dede..51965e0 100644 --- a/index.ts +++ b/index.ts @@ -76,6 +76,7 @@ interface PluginConfig { model?: string; baseURL?: string; dimensions?: number; + omitDimensions?: boolean; taskQuery?: string; taskPassage?: string; normalized?: boolean; @@ -1692,6 +1693,7 @@ const memoryLanceDBProPlugin = { model: config.embedding.model || "text-embedding-3-small", baseURL: config.embedding.baseURL, dimensions: config.embedding.dimensions, + omitDimensions: config.embedding.omitDimensions, taskQuery: config.embedding.taskQuery, taskPassage: config.embedding.taskPassage, normalized: config.embedding.normalized, @@ -3617,6 +3619,10 @@ 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), + omitDimensions: + typeof embedding.omitDimensions === "boolean" + ? embedding.omitDimensions + : undefined, taskQuery: typeof embedding.taskQuery === "string" ? embedding.taskQuery diff --git a/openclaw.plugin.json b/openclaw.plugin.json index a6b1db1..e195838 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -46,6 +46,10 @@ "type": "integer", "minimum": 1 }, + "omitDimensions": { + "type": "boolean", + "description": "When true, omit the dimensions parameter from embedding requests even if dimensions is configured" + }, "taskQuery": { "type": "string", "description": "Embedding task for queries (provider-specific, e.g. Jina: retrieval.query)" @@ -764,6 +768,11 @@ "help": "Override vector dimensions for custom models not in the built-in lookup table", "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.", + "advanced": true + }, "embedding.taskQuery": { "label": "Query Task", "placeholder": "retrieval.query", diff --git a/src/embedder.ts b/src/embedder.ts index 74947fb..bcbbaa7 100644 --- a/src/embedder.ts +++ b/src/embedder.ts @@ -98,6 +98,9 @@ export interface EmbeddingConfig { taskPassage?: string; /** Optional flag to request normalized embeddings (provider-dependent, e.g. Jina v5) */ normalized?: boolean; + /** When true, omit the dimensions parameter from embedding requests even if dimensions is set. + * Use this for local models that reject the dimensions parameter with "matryoshka representation" errors. */ + omitDimensions?: boolean; /** Enable automatic chunking for documents exceeding context limits (default: true) */ chunking?: boolean; } @@ -410,6 +413,8 @@ export class Embedder { /** Optional requested dimensions to pass through to the embedding provider (OpenAI-compatible). */ private readonly _requestDimensions?: number; + /** When true, omit the dimensions parameter even if _requestDimensions is set. */ + private readonly _omitDimensions: boolean; /** Enable automatic chunking for long documents (default: true) */ private readonly _autoChunk: boolean; @@ -424,6 +429,7 @@ export class Embedder { this._taskPassage = config.taskPassage; this._normalized = config.normalized; this._requestDimensions = config.dimensions; + this._omitDimensions = config.omitDimensions === true; // Enable auto-chunking by default for better handling of long documents this._autoChunk = config.chunking !== false; const profile = detectEmbeddingProviderProfile(this._baseURL, this._model); @@ -641,8 +647,9 @@ export class Embedder { } // Output dimension: field name is provider-defined. - // Only sent when explicitly configured to avoid breaking providers that reject unknown fields. - if (this._capabilities.dimensionsField && this._requestDimensions && this._requestDimensions > 0) { + // Only sent when explicitly configured, unless omitDimensions is enabled for + // local or provider-compatible models that reject the dimensions field. + if (!this._omitDimensions && this._capabilities.dimensionsField && this._requestDimensions && this._requestDimensions > 0) { payload[this._capabilities.dimensionsField] = this._requestDimensions; } diff --git a/test/plugin-manifest-regression.mjs b/test/plugin-manifest-regression.mjs index d021b39..ac5131b 100644 --- a/test/plugin-manifest-regression.mjs +++ b/test/plugin-manifest-regression.mjs @@ -104,6 +104,11 @@ assert.equal( true, "embedding.chunking schema default should match runtime default", ); +assert.equal( + manifest.configSchema.properties.embedding.properties.omitDimensions?.type, + "boolean", + "embedding.omitDimensions should be declared in the plugin schema", +); assert.equal( manifest.configSchema.properties.sessionMemory.properties.enabled.default, false, @@ -127,6 +132,7 @@ assert.equal( const workDir = mkdtempSync(path.join(tmpdir(), "memory-plugin-regression-")); const services = []; +const embeddingRequests = []; try { const api = createMockApi( @@ -204,6 +210,7 @@ try { const chunks = []; for await (const chunk of req) chunks.push(chunk); const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + embeddingRequests.push(payload); const inputs = Array.isArray(payload.input) ? payload.input : [payload.input]; if (inputs.some((input) => String(input).length > threshold)) { @@ -293,6 +300,65 @@ try { "created", "embedding.chunking=true should recover from long-document embedding errors", ); + + const withDimensionsApi = createMockApi({ + dbPath: path.join(workDir, "db-with-dimensions"), + autoCapture: false, + autoRecall: false, + embedding: { + provider: "openai-compatible", + apiKey: "dummy", + model: "text-embedding-3-small", + baseURL: embeddingBaseURL, + dimensions: 4, + }, + }); + plugin.register(withDimensionsApi); + const withDimensionsTool = withDimensionsApi.toolFactories.memory_store({ + agentId: "main", + sessionKey: "agent:main:test", + }); + const requestCountBeforeWithDimensions = embeddingRequests.length; + await withDimensionsTool.execute("tool-3", { + text: "dimensions should be sent by default", + scope: "global", + }); + const withDimensionsRequest = embeddingRequests.at(requestCountBeforeWithDimensions); + assert.equal( + withDimensionsRequest?.dimensions, + 4, + "embedding.dimensions should be forwarded by default", + ); + + const omitDimensionsApi = createMockApi({ + dbPath: path.join(workDir, "db-omit-dimensions"), + autoCapture: false, + autoRecall: false, + embedding: { + provider: "openai-compatible", + apiKey: "dummy", + model: "text-embedding-3-small", + baseURL: embeddingBaseURL, + dimensions: 4, + omitDimensions: true, + }, + }); + plugin.register(omitDimensionsApi); + const omitDimensionsTool = omitDimensionsApi.toolFactories.memory_store({ + agentId: "main", + sessionKey: "agent:main:test", + }); + const requestCountBeforeOmitDimensions = embeddingRequests.length; + await omitDimensionsTool.execute("tool-4", { + text: "dimensions should be omitted when configured", + scope: "global", + }); + const omitDimensionsRequest = embeddingRequests.at(requestCountBeforeOmitDimensions); + assert.equal( + Object.prototype.hasOwnProperty.call(omitDimensionsRequest, "dimensions"), + false, + "embedding.omitDimensions=true should omit dimensions from embedding requests", + ); } finally { await new Promise((resolve) => embeddingServer.close(resolve)); }